diff --git a/package.json b/package.json index 6d912968..386e064c 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,11 @@ "site:build": "bun tools/site-build.ts", "gba:imagegen": "bun imagegen", "vapor": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx", + "vapor:rpg": "bun vapor/compiler/cli.ts vapor/examples/rpg/rpg.tsx", + "vapor:rpg:play": "bun vapor/scripts/play.ts vapor/examples/rpg/rpg.tsx", + "vapor:rpg:assets:generate": "bun vapor/scripts/rpg-assets.ts generate", + "vapor:rpg:assets:build": "bun vapor/scripts/rpg-assets.ts build", + "vapor:rpg:assets:check": "bun vapor/scripts/rpg-assets.ts check", "vapor:play": "bun vapor/scripts/play.ts", "vapor:test": "bun test vapor/tests/", "serve": "bun hosts/web/serve.ts", diff --git a/vapor/DESIGN.md b/vapor/DESIGN.md index 22edb3e7..f03dd788 100644 --- a/vapor/DESIGN.md +++ b/vapor/DESIGN.md @@ -194,9 +194,9 @@ oxlint plugin would give red squiggles without booting the compiler. The Each target presents a fixed logical cell screen: 30×20 on GBA, 20×18 on GB and ESP32, 22×18 on NES, and 50×30 on Playdate. The ESP32 MeowBit profile rasterizes its 20×18 grid as 8×7 cells into a 160×126 content area on the 160×128 ST7735 -panel. The JSX vocabulary is deliberately one intrinsic with two -interpreters — the C cell grid on device, and a ~60-line tree walker over -the oracle's micro-DOM: +panel. The portable, cross-target cell vocabulary is deliberately one +intrinsic with two interpreters — the C cell grid on device, and a ~60-line +tree walker over the oracle's micro-DOM: - `` — paints its text children at (x, y) in palette `p`, padded with spaces to the right edge; later rows overwrite @@ -210,15 +210,65 @@ the oracle's micro-DOM: - Looks come from the class DSL (§4.5); the painter and every runtime agree on pair ids, and the oracle asserts them as a per-cell grid. -Input is not DOM events. The host module exposes two explicit capabilities: +There is one deliberately target-specific exception to that portable cell +vocabulary: the experimental GBA RPG host. It is a narrow vertical slice, +not a new general-purpose JSX renderer, and non-GBA target admission rejects +it. Its responsibilities are split four ways: + +1. **Static assets — `defineRpgMap(...)`.** Map rows, solid characters, + event-character ids, and dialogue records are build-time declarations. + The compiler validates the 30×20 bound, equal row widths and byte-oriented + ids, then emits flat tile/collision data plus event and dialogue tables in + ROM. None of this is mutable gameplay state. +2. **Pure world queries — `rpgBlocked(...)` / `rpgEventAt(...)`.** The host + module provides real, deterministic JS lookup semantics; AOT recognizes + the same calls and emits calls to fixed C helpers over the generated map. + Out-of-bounds movement fails closed. +3. **Reactive gameplay — ordinary Vue Vapor.** Mode, integer player position, + facing, in-flight pixel progress, quest state, current dialogue/choice, HP + and battle selection are ordinary `ref` slots; gates are `computed` and + screen offsets are derived expressions. `onButton` keymaps, the standard + Vue Vapor `onFrame(buttons)` lifecycle, and setup functions implement movement, + interaction, dialogue and battle. A step advances by a deterministic 2px + per 60 Hz tick; the destination cell and its event commit only on arrival. + They use the same dirty-bit graph and direct C lowering as Todo. There is + no residual script bytecode, stack machine, hidden controller state, or + second gameplay VM. +4. **Fixed presentation — ``.** The component is stateless: + every dynamic input, including the signed player pixel offsets and walk + frame, arrives as a prop and therefore participates in the normal + dependency mask. The GBA runtime derives a fractional camera from those + props, renders a 16×11 overscan tile window on BG1 and characters through + OBJ, then commits tile/OAM/scroll/window shadows during VBlank. It does not + own a hidden motion timeline or expose GBA registers or SDK concepts to + application code. + +The RPG movement contract consumes the standard hardware-neutral button mask +through `@pocketjs/framework/vue-vapor/lifecycle`'s `onFrame`. The GBA adapter +maps its physical held keys to the shared `BTN` ABI; app code never reads GBA +key bits. Direction priority is deterministic, a current step finishes after +release, and turning, collision and arrival events remain gameplay state that +the compiler/debug tape can observe. Dialogue and battle still use +`onButton` press edges, so choices advance once per physical press. +The JS `RpgScreen` currently returns no pixels, so collision/event behavior +can run in the JS/oracle path but pixel-frame acceptance is mGBA-only for +this POC. A browser pixel renderer, saves, audio and CJK text are follow-ups, +not implied capabilities. + +Input is not DOM events. Pocket Vapor exposes four explicit capabilities: - `onButton((b: Button) => void)` for frame-latched press edges; +- `onButtonRepeat((b: Button) => void)` for normalized held-D-pad repeats + (currently supplied by the GBA target); +- `onFrame((buttons) => void)` from the public Vue Vapor lifecycle for the + shared held `BTN` mask (currently lowered by the GBA target); - `onAxisDelta(RelativeAxis.Primary, (delta) => void)` for signed, hardware-neutral incremental movement in canonical units. Under the oracle the module executes and the test tape feeds it; under the -compiler registrations become `app_on_button()` and -`app_on_axis_delta(axis, delta)`. Physical hosts own normalization: +compiler registrations become `app_on_button()`, `app_on_button_repeat()`, +`app_on_frame(buttons)`, and `app_on_axis_delta(axis, delta)`. Physical hosts +own normalization: rotary adapters preserve signed motion as millidegrees, while applications own detents, acceleration, and sensitivity. Playdate forwards crank motion to Primary; a future ESP32 board can adapt an encoder without exposing pins diff --git a/vapor/README.md b/vapor/README.md index 937db309..f5bb5328 100644 --- a/vapor/README.md +++ b/vapor/README.md @@ -172,6 +172,64 @@ Vapor in your browser — inspectable DOM rows, keyboard as the pad, `?target=esp32` to preview the MeowBit viewport, or `?target=playdate` for the 50×30 one-bit contract. +## GBA RPG POC + +[`examples/rpg/rpg.tsx`](examples/rpg/rpg.tsx) is an experimental, +GBA-only RPG vertical slice. It adds a small native game host without adding +a second gameplay language or VM: + +- `defineRpgMap(...)` declares static map rows, solid tile characters, event + characters, and dialogue records. The compiler validates the declaration + and emits fixed ROM tables. +- `rpgBlocked(...)` and `rpgEventAt(...)` are pure map queries with real JS + implementations for behavior/oracle use and equivalent native C helpers. +- The player, mode, facing, quest, dialogue choice, HP, battle cursor, and + in-flight walking progress are ordinary `ref` state; the quest gate is an + ordinary `computed` and presentation offsets are derived expressions. + Button/frame handlers and setup functions contain the actual gameplay and + compile directly to C. +- One stateless `` receives that complete reactive state. On + GBA the compiler lowers it to the fixed tile/sprite RPG renderer rather + than shipping Vue or JavaScript in the ROM. +- The world uses 16×16 logical metatiles with a pixel-scrolled, clamped 15×10 + camera, detailed 32×32 world actors, four-frame directional walk cycles, + and 64×64 battle portraits. A 16×11 overscan buffer keeps fractional camera + movement covered while HUD, dialogue, font, and command UI remain fixed on + the native 8×8 screen grid. + +The demo's eleven reactive refs occupy 44 bytes. The fixed GBA host adds 3,136 +bytes of BG1 tilemap, OAM, UI/camera-cache, and register-shadow state so VRAM, +OAM, scroll, and window commits stay inside VBlank; map, collision, event, +dialogue, tile and sprite assets remain in ROM. The fixed four-bank art payload +is 17,216 bytes, including sixteen generated directional walk frames. + +The demo is one complete loop: start at `(2,2)`, walk next to the solid +Elder, face them and press A, choose whether to accept the quest, walk onto +the Slime event, use ATTACK or HEAL in a turn battle, defeat the Slime with +three attacks, then return to the Elder to complete the quest. + +Controls: + +- World: D-pad starts a cardinal step and advances it by 2 pixels per fixed + 60 Hz frame. Holding continues seamlessly; releasing finishes the accepted + step without stopping between cells. The integer map coordinate and any + destination event commit only after all 16 pixels arrive. A talks to the + event cell directly in front of an idle player. +- Dialogue: Up/Down selects YES or NO; A confirms or advances. +- Battle: Up/Down selects ATTACK or HEAL; A performs the action. + +```sh +bun run vapor:rpg # build dist/vapor/rpg.gba +bun run vapor:rpg:play # build and open the ROM in mGBA +``` + +The POC deliberately keeps a narrow evidence and product boundary. It is +GBA-only, tile-based, and uses printable English ASCII; there is no save +data, audio, or CJK text yet. The JS collision/event helpers are real, but +the browser `RpgScreen` is currently a stateless placeholder rather than a +pixel oracle renderer. Validation is scoped to mGBA; this section does not +claim physical-GBA or flash-cart acceptance. + ## Commands The ESP32 `flash` and default `verify` commands below write the connected @@ -219,7 +277,8 @@ arithmetic and bit masks come from a ROM table. vapor/ DESIGN.md the thesis + subset + target/style contracts examples/todo/ portable Todo + Playdate relative-axis input variant - host/ input.ts (buttons + relative axes), screen.ts (SCREEN geometry) + examples/rpg/ experimental GBA-only reactive RPG vertical slice + host/ input/screen contracts + the experimental RPG host oracle/ micro-DOM + grid painter + bundle boot (real vue) compiler/ compile.ts (TS AST → C), styles.ts (class DSL), rom.ts, cli.ts runtime/ vapor.h contract + vapor_core.c (shared grid/strings/line) diff --git a/vapor/compiler/cli.ts b/vapor/compiler/cli.ts index cf5010b1..5a86d5e0 100644 --- a/vapor/compiler/cli.ts +++ b/vapor/compiler/cli.ts @@ -136,10 +136,15 @@ if (target !== "playdate" && playdateModeIdx >= 0) { const source = await Bun.file(entry).text(); const name = basename(entry).replace(/\.tsx$/, ""); +const title = name.startsWith("todo") + ? "VAPOR TODO" + : name === "rpg" + ? "VAPOR QUEST" + : name.toUpperCase(); const app = compileVaporApp( entry, source, - name.startsWith("todo") ? "VAPOR TODO" : name.toUpperCase(), + title, target, ); diff --git a/vapor/compiler/compile.ts b/vapor/compiler/compile.ts index 9ae59949..625381fb 100644 --- a/vapor/compiler/compile.ts +++ b/vapor/compiler/compile.ts @@ -17,6 +17,7 @@ // arms), which can only cause redundant repaints, never a missed one. import ts from "typescript"; +import { BTN as FRAMEWORK_BTN } from "../../contracts/spec/spec.ts"; import { FONT8 } from "./font.gen.ts"; import { rgb555, rgb565, StyleTable, type StyleIssue } from "./styles.ts"; import { BACKDROP } from "./styles.ts"; @@ -55,6 +56,18 @@ export const VAPOR_TARGETS: Record = { }; const BUTTON_NAMES = ["A", "B", "Select", "Start", "Right", "Left", "Up", "Down", "R", "L"] as const; +const FRAMEWORK_BUTTON_TO_VAPOR: Readonly> = { + CROSS: 0, + CIRCLE: 1, + SELECT: 2, + START: 3, + RIGHT: 4, + LEFT: 5, + UP: 6, + DOWN: 7, + RTRIGGER: 8, + LTRIGGER: 9, +}; const PLAYDATE_BUTTONS = new Set([0, 1, 4, 5, 6, 7]); const RELATIVE_AXIS_NAMES = ["Primary", "Secondary"] as const; const TARGET_RELATIVE_AXES: Record = { @@ -68,6 +81,8 @@ const TARGET_RELATIVE_AXES: Record = { export interface CompiledApp { c: string; title: string; + /** GBA-only pixel RPG host/runtime is required by this app. */ + rpgEnabled: boolean; graph: string; plan: string; debugSlots: DebugSlot[]; @@ -160,6 +175,30 @@ interface ComponentBinding { body: ts.JsxElement | ts.JsxSelfClosingElement; // the root } +interface RpgEventSrc { + tile: number; + event: number; +} + +interface RpgDialogSrc { + speaker: string; + line1: string; + line2: string; + choice0: string; + choice1: string; +} + +interface RpgMapBinding { + kind: "rpgMap"; + name: string; + width: number; + height: number; + rows: string[]; + solid: string; + events: RpgEventSrc[]; + dialogs: RpgDialogSrc[]; +} + /** Active props substitution while inlining one component use. */ interface PropsCtx { param: string; @@ -188,7 +227,8 @@ type Binding = | ConstBinding | LocalBinding | KeymapBinding - | ComponentBinding; + | ComponentBinding + | RpgMapBinding; // --------------------------------------------------------------------------- // Compiler @@ -239,14 +279,26 @@ class AppCompiler { private computeds: ComputedBinding[] = []; private fns: FnBinding[] = []; private handler: ts.ArrowFunction | null = null; + private repeatHandler: ts.ArrowFunction | null = null; + private frameHandler: ts.ArrowFunction | null = null; private axisHandlers = new Map(); private template: ts.JsxFragment | null = null; private vueRef = ""; private vueComputed = ""; private hostOnButton = ""; + private hostOnButtonRepeat = ""; private hostButton = ""; private hostOnAxisDelta = ""; + private frameworkOnFrame = ""; + private frameworkButton = ""; + private hostDefineRpgMap = ""; + private hostRpgBlocked = ""; + private hostRpgEventAt = ""; + private hostRpgScreen = ""; + + private rpgMaps: RpgMapBinding[] = []; + private rpgScreenCount = 0; // emission private decls: string[] = []; @@ -290,7 +342,7 @@ class AppCompiler { else if (ts.isInterfaceDeclaration(stmt)) this.scanInterface(stmt); else if (ts.isTypeAliasDeclaration(stmt)) continue; // types are erased else if (ts.isFunctionDeclaration(stmt)) this.scanComponent(stmt); - else if (ts.isVariableStatement(stmt)) this.scanModuleConst(stmt); + else if (ts.isVariableStatement(stmt)) this.scanModuleConst(stmt, true); else if (ts.isExportAssignment(stmt)) { if (!ts.isArrowFunction(stmt.expression)) this.err(stmt, "export default must be an arrow component"); component = stmt.expression; @@ -315,6 +367,7 @@ class AppCompiler { else this.err(spec, `unsupported vue import: ${imported} (subset allows ref, computed)`); } else if (/\/host\/input(\.ts)?$/.test(from)) { if (imported === "onButton") this.hostOnButton = local; + else if (imported === "onButtonRepeat") this.hostOnButtonRepeat = local; else if (imported === "Button") this.hostButton = local; else if (imported === "onAxisDelta") this.hostOnAxisDelta = local; else if (imported === "RelativeAxis") @@ -337,6 +390,22 @@ class AppCompiler { name: local, value: { width: this.target.width, height: this.target.height }, }); + } else if (/\/host\/rpg(\.ts)?$/.test(from)) { + if (this.target.name !== "gba") + this.err(spec, "Pocket Vapor RPG is currently supported only on the gba target"); + if (imported === "defineRpgMap") this.hostDefineRpgMap = local; + else if (imported === "rpgBlocked") this.hostRpgBlocked = local; + else if (imported === "rpgEventAt") this.hostRpgEventAt = local; + else if (imported === "RpgScreen") this.hostRpgScreen = local; + else this.err(spec, `unsupported RPG host import: ${imported}`); + } else if (from === "@pocketjs/framework/vue-vapor/lifecycle") { + if (imported === "onFrame") this.frameworkOnFrame = local; + else this.err(spec, `unsupported framework lifecycle import: ${imported}`); + } else if (from === "@pocketjs/framework/vue-vapor/input") { + if (imported === "BTN") { + this.frameworkButton = local; + this.scope.set(local, { kind: "const", name: local, value: FRAMEWORK_BTN }); + } else this.err(spec, `unsupported framework input import: ${imported}`); } else this.err(stmt, `unsupported import source: ${from}`); } } @@ -357,12 +426,21 @@ class AppCompiler { this.ifaces.set(decl.name.text, { name: decl.name.text, fields }); } - private scanModuleConst(stmt: ts.VariableStatement): void { + private scanModuleConst(stmt: ts.VariableStatement, allowRpg: boolean): void { if (!(stmt.declarationList.flags & ts.NodeFlags.Const)) this.err(stmt, "module variables must be const"); for (const decl of stmt.declarationList.declarations) { if (!ts.isIdentifier(decl.name) || !decl.initializer) this.err(decl, "const needs a simple name + initializer"); const name = decl.name.text; const init = decl.initializer; + if ( + ts.isCallExpression(init) && + ts.isIdentifier(init.expression) && + init.expression.text === this.hostDefineRpgMap + ) { + if (!allowRpg) this.err(init, "defineRpgMap() must initialize a module-level const"); + this.scanRpgMap(name, init); + continue; + } const folded = this.constNum(init) ?? this.constBool(init); if (folded !== null) this.scope.set(name, { kind: "const", name, value: folded }); else if (ts.isStringLiteral(init)) this.scope.set(name, { kind: "const", name, value: init.text }); @@ -386,6 +464,152 @@ class AppCompiler { } } + // ---- RPG static assets ---------------------------------------------------- + + private rpgPropertyName(prop: ts.PropertyAssignment, context: string): string { + if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) return prop.name.text; + this.err(prop.name, `${context} keys must be identifiers or string literals`); + } + + private rpgObjectFields( + obj: ts.ObjectLiteralExpression, + context: string, + allowed: readonly string[], + required: readonly string[], + ): Map { + const fields = new Map(); + for (const prop of obj.properties) { + if (!ts.isPropertyAssignment(prop)) + this.err(prop, `${context} must use explicit \`name: value\` properties (no shorthand or spreads)`); + const name = this.rpgPropertyName(prop, context); + if (!allowed.includes(name)) + this.err(prop.name, `unknown ${context} property ${JSON.stringify(name)}; expected ${allowed.join(", ")}`); + if (fields.has(name)) this.err(prop.name, `duplicate ${context} property ${JSON.stringify(name)}`); + fields.set(name, prop.initializer); + } + for (const name of required) + if (!fields.has(name)) this.err(obj, `${context} is missing required property ${JSON.stringify(name)}`); + return fields; + } + + private rpgString(e: ts.Expression, context: string): string { + if (!ts.isStringLiteral(e)) this.err(e, `${context} must be a string literal`); + return e.text; + } + + /** RPG tile keys and dialogue text are byte-oriented in the GBA POC. */ + private assertRpgAscii(node: ts.Node, text: string, context: string): void { + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code < 0x20 || code > 0x7e) + this.err(node, `${context} must contain printable single-byte ASCII characters`); + } + } + + private scanRpgMap(name: string, call: ts.CallExpression): void { + if (this.target.name !== "gba") this.err(call, "Pocket Vapor RPG is currently supported only on the gba target"); + if (call.arguments.length !== 1 || !ts.isObjectLiteralExpression(call.arguments[0])) + this.err(call, "defineRpgMap() takes exactly one object literal"); + if (this.scope.has(name)) this.err(call, `duplicate binding ${name}`); + + const spec = this.rpgObjectFields( + call.arguments[0], + "RPG map", + ["rows", "solid", "events", "dialogs"], + ["rows", "solid", "events", "dialogs"], + ); + + const rowsExpr = spec.get("rows")!; + if (!ts.isArrayLiteralExpression(rowsExpr)) this.err(rowsExpr, "RPG map rows must be a string-literal array"); + if (rowsExpr.elements.length === 0) this.err(rowsExpr, "RPG map rows cannot be empty"); + if (rowsExpr.elements.length > 20) this.err(rowsExpr, "RPG map height exceeds the GBA limit of 20 rows"); + const rows = rowsExpr.elements.map((row, i) => { + if (!ts.isStringLiteral(row)) this.err(row, `RPG map row ${i} must be a string literal`); + this.assertRpgAscii(row, row.text, `RPG map row ${i}`); + return row.text; + }); + const width = rows[0].length; + if (width === 0) this.err(rowsExpr.elements[0], "RPG map rows cannot be empty strings"); + if (width > 30) this.err(rowsExpr.elements[0], "RPG map width exceeds the GBA limit of 30 columns"); + for (let i = 1; i < rows.length; i++) + if (rows[i].length !== width) + this.err( + rowsExpr.elements[i], + `RPG map rows must be equal width: row 0 is ${width}, row ${i} is ${rows[i].length}`, + ); + + const solidExpr = spec.get("solid")!; + const solid = this.rpgString(solidExpr, "RPG map solid"); + this.assertRpgAscii(solidExpr, solid, "RPG map solid"); + const seenSolid = new Set(); + for (const ch of solid) { + if (seenSolid.has(ch)) this.err(solidExpr, `RPG map solid contains duplicate tile ${JSON.stringify(ch)}`); + seenSolid.add(ch); + } + + const eventsExpr = spec.get("events")!; + if (!ts.isObjectLiteralExpression(eventsExpr)) + this.err(eventsExpr, "RPG map events must be an object of single-character keys to event ids"); + if (eventsExpr.properties.length > 255) this.err(eventsExpr, "RPG map supports at most 255 event tile keys"); + const events: RpgEventSrc[] = []; + const seenEventKeys = new Set(); + const flatRows = rows.join(""); + for (const prop of eventsExpr.properties) { + if (!ts.isPropertyAssignment(prop)) this.err(prop, "RPG event entries must use \`\"X\": id\`"); + const key = this.rpgPropertyName(prop, "RPG event"); + if (key.length !== 1) this.err(prop.name, "RPG event keys must be exactly one character"); + this.assertRpgAscii(prop.name, key, "RPG event key"); + if (seenEventKeys.has(key)) this.err(prop.name, `duplicate RPG event key ${JSON.stringify(key)}`); + seenEventKeys.add(key); + if (!flatRows.includes(key)) + this.err(prop.name, `RPG event key ${JSON.stringify(key)} does not occur in any map row`); + const event = this.constNum(prop.initializer); + if (event === null || !Number.isInteger(event) || event < 1 || event > 255) + this.err(prop.initializer, "RPG event ids must be compile-time integers in 1..255 (0 means no event)"); + events.push({ tile: key.charCodeAt(0), event }); + } + + const dialogsExpr = spec.get("dialogs")!; + if (!ts.isArrayLiteralExpression(dialogsExpr)) this.err(dialogsExpr, "RPG dialogs must be an object-literal array"); + if (dialogsExpr.elements.length > 255) this.err(dialogsExpr, "RPG maps support at most 255 dialogs"); + const dialogs = dialogsExpr.elements.map((dialog, i): RpgDialogSrc => { + if (!ts.isObjectLiteralExpression(dialog)) this.err(dialog, `RPG dialog ${i} must be an object literal`); + const fields = this.rpgObjectFields( + dialog, + `RPG dialog ${i}`, + ["speaker", "line1", "line2", "choice0", "choice1"], + ["speaker", "line1"], + ); + const read = (field: "speaker" | "line1" | "line2" | "choice0" | "choice1"): string => { + const value = fields.get(field); + if (!value) return ""; + const text = this.rpgString(value, `RPG dialog ${i}.${field}`); + this.assertRpgAscii(value, text, `RPG dialog ${i}.${field}`); + return text; + }; + return { + speaker: read("speaker"), + line1: read("line1"), + line2: read("line2"), + choice0: read("choice0"), + choice1: read("choice1"), + }; + }); + + const binding: RpgMapBinding = { + kind: "rpgMap", + name, + width, + height: rows.length, + rows, + solid, + events, + dialogs, + }; + this.rpgMaps.push(binding); + this.scope.set(name, binding); + } + // ---- setup scan ---------------------------------------------------------- private scanSetup(component: ts.ArrowFunction): void { @@ -421,6 +645,30 @@ class AppCompiler { const arg = call.arguments[0]; if (!arg || !ts.isArrowFunction(arg)) this.err(call, "onButton takes an arrow"); this.handler = arg; + } else if ( + ts.isCallExpression(call) && + ts.isIdentifier(call.expression) && + call.expression.text === this.hostOnButtonRepeat + ) { + if (this.target.name !== "gba") + this.err(call, "onButtonRepeat is currently supported only on the gba target"); + if (this.repeatHandler) this.err(call, "only one onButtonRepeat handler is supported"); + const arg = call.arguments[0]; + if (!arg || !ts.isArrowFunction(arg)) this.err(call, "onButtonRepeat takes an arrow"); + this.repeatHandler = arg; + } else if ( + ts.isCallExpression(call) && + ts.isIdentifier(call.expression) && + call.expression.text === this.frameworkOnFrame + ) { + if (this.target.name !== "gba") + this.err(call, "onFrame is currently supported only on the gba target"); + if (this.frameHandler) this.err(call, "only one onFrame handler is supported"); + const arg = call.arguments[0]; + if (!arg || !ts.isArrowFunction(arg)) this.err(call, "onFrame takes an arrow"); + if (arg.parameters.length > 1 || (arg.parameters[0] && !ts.isIdentifier(arg.parameters[0].name))) + this.err(arg, "onFrame arrow takes zero args or one simple buttons parameter"); + this.frameHandler = arg; } else if ( ts.isCallExpression(call) && ts.isIdentifier(call.expression) && @@ -484,7 +732,7 @@ class AppCompiler { ) { this.scanKeymap(name, init); } else { - this.scanModuleConst(stmt); + this.scanModuleConst(stmt, false); return; } } @@ -875,6 +1123,10 @@ class AppCompiler { const sub = this.substProp(e); if (sub) return this.constNum(sub); if (ts.isNumericLiteral(e)) return Number(e.text); + if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken) { + const operand = this.constNum(e.operand); + return operand === null ? null : -operand; + } if (ts.isIdentifier(e)) { const b = this.scope.get(e.text); if (b?.kind === "const" && typeof b.value === "number") return b.value; @@ -887,7 +1139,15 @@ class AppCompiler { if (b?.kind === "const" && Array.isArray(b.value) && e.name.text === "length") return b.value.length; if (b?.kind === "const" && typeof b.value === "object" && !Array.isArray(b.value)) { const record = b.value as Record; - if (e.name.text in record) return record[e.name.text]; + if (e.name.text in record) { + if (e.expression.text === this.frameworkButton) { + const button = FRAMEWORK_BUTTON_TO_VAPOR[e.name.text]; + if (button === undefined) + this.err(e, `BTN.${e.name.text} has no Pocket Vapor button mapping`); + this.buttonsUsed.add(button); + } + return record[e.name.text]; + } } if (e.expression.text === this.hostButton) { const buttons: Record = { @@ -1166,8 +1426,39 @@ class AppCompiler { return cName; } + private rpgMapArg(e: ts.Expression, callName: string): RpgMapBinding { + e = this.unparen(e); + if (!ts.isIdentifier(e)) this.err(e, `${callName} map must be a defineRpgMap module const`); + const binding = this.scope.get(e.text); + if (binding?.kind !== "rpgMap") this.err(e, `${callName} map must be a defineRpgMap module const`); + return binding; + } + + private compileRpgQuery( + e: ts.CallExpression, + out: string[], + ind: string, + callName: "rpgBlocked" | "rpgEventAt", + ): { c: string; ty: Ty } { + if (this.target.name !== "gba") this.err(e, `${callName} is currently supported only on the gba target`); + if (e.arguments.length !== 3) this.err(e, `${callName}() takes (map, x, y)`); + const map = this.rpgMapArg(e.arguments[0], callName); + const x = this.compileExpr(e.arguments[1], out, ind); + const y = this.compileExpr(e.arguments[2], out, ind); + if (x.ty.k !== "num" || y.ty.k !== "num") this.err(e, `${callName} x and y must be numbers`); + const cName = callName === "rpgBlocked" ? "vp_rpg_blocked" : "vp_rpg_event_at"; + return { + c: `${cName}(&RPG_${map.name}, ${x.c}, ${y.c})`, + ty: callName === "rpgBlocked" ? BOOL : NUM, + }; + } + private compileCall(e: ts.CallExpression, out: string[], ind: string): { c: string; ty: Ty } { const callee = this.unparen(e.expression); + if (ts.isIdentifier(callee) && callee.text === this.hostRpgBlocked) + return this.compileRpgQuery(e, out, ind, "rpgBlocked"); + if (ts.isIdentifier(callee) && callee.text === this.hostRpgEventAt) + return this.compileRpgQuery(e, out, ind, "rpgEventAt"); // Integer-safe Math subset. Every compiled NUM is s32, so Math.trunc is // an identity after C integer division while preserving Vue-oracle parity. if ( @@ -1228,6 +1519,8 @@ class AppCompiler { [K.AsteriskToken]: "*", [K.SlashToken]: "/", [K.PercentToken]: "%", + [K.AmpersandToken]: "&", + [K.BarToken]: "|", [K.LessThanToken]: "<", [K.GreaterThanToken]: ">", [K.LessThanEqualsToken]: "<=", @@ -1573,6 +1866,8 @@ class AppCompiler { decls: string[]; body: string[]; isStatic: boolean; + /** The native host owns this unit's backing rows and redraw policy. */ + skipRowClear?: boolean; } const units: Unit[] = []; @@ -1582,7 +1877,11 @@ class AppCompiler { continue; } if (ts.isJsxElement(rawChild) || ts.isJsxSelfClosingElement(rawChild)) { - units.push(this.compileRowUnit(rawChild)); + const tag = ts.isJsxElement(rawChild) + ? rawChild.openingElement.tagName.getText(this.sf) + : rawChild.tagName.getText(this.sf); + if (this.hostRpgScreen && tag === this.hostRpgScreen) units.push(this.compileRpgScreenUnit(rawChild)); + else units.push(this.compileRowUnit(rawChild)); continue; } if (ts.isJsxExpression(rawChild)) { @@ -1605,6 +1904,10 @@ class AppCompiler { } } + if (this.rpgScreenCount > 0 && units.length !== 1) { + this.err(this.template!, "RpgScreen owns the full native display and must be the only root render unit"); + } + // static rows paint once; overlap with a dynamic span demotes to dynamic const dynamic = units.filter((u) => !u.isStatic); const overlap = (a: [number, number], b: [number, number]) => a[0] < b[1] && b[0] < a[1]; @@ -1618,6 +1921,7 @@ class AppCompiler { const hit = merged.find((m) => overlap(m.span, u.span)); if (hit) { hit.span = [Math.min(hit.span[0], u.span[0]), Math.max(hit.span[1], u.span[1])]; + hit.skipRowClear = !!hit.skipRowClear && !!u.skipRowClear; for (const d of u.deps) hit.deps.add(d); hit.decls.push(...u.decls); hit.body.push(...u.body); @@ -1642,6 +1946,7 @@ class AppCompiler { Math.min(merged[i].span[0], merged[j].span[0]), Math.max(merged[i].span[1], merged[j].span[1]), ]; + merged[i].skipRowClear = !!merged[i].skipRowClear && !!merged[j].skipRowClear; for (const d of merged[j].deps) merged[i].deps.add(d); merged[i].decls.push(...merged[j].decls); merged[i].body.push(...merged[j].body); @@ -1658,7 +1963,11 @@ class AppCompiler { const effects: { name: string; mask: number; span: [number, number] }[] = []; merged.forEach((u, i) => { const name = `eff_${i}`; - const body = [...u.decls, ` vp_row_clear(${u.span[0]}, ${u.span[1]});`, ...u.body]; + const body = [ + ...u.decls, + ...(u.skipRowClear ? [] : [` vp_row_clear(${u.span[0]}, ${u.span[1]});`]), + ...u.body, + ]; this.bodies.push(`static void ${name}(void) {\n${body.join("\n")}\n}\n`); effects.push({ name, mask: this.maskOf(u.deps), span: u.span }); }); @@ -1851,6 +2160,91 @@ class AppCompiler { return { span: [y, y + 1], deps, decls, body, isStatic: false }; } + private compileRpgScreenUnit(el: ts.JsxElement | ts.JsxSelfClosingElement): { + span: [number, number]; + deps: Set; + decls: string[]; + body: string[]; + isStatic: boolean; + skipRowClear: boolean; + } { + if (this.target.name !== "gba") this.err(el, "RpgScreen is currently supported only on the gba target"); + if (!ts.isJsxSelfClosingElement(el)) this.err(el, "RpgScreen must be a self-closing element"); + if (this.rpgScreenCount > 0) this.err(el, "only one RpgScreen is supported per component"); + this.rpgScreenCount++; + + const required = [ + "map", + "mode", + "playerX", + "playerY", + "playerOffsetX", + "playerOffsetY", + "facing", + "playerFrame", + "quest", + "dialog", + "choice", + "heroHp", + "enemyHp", + "battleCursor", + ] as const; + type RpgScreenProp = (typeof required)[number]; + const attrs = new Map(); + for (const prop of el.attributes.properties) { + if (!ts.isJsxAttribute(prop)) this.err(prop, "RpgScreen does not support spread props"); + const name = prop.name.getText(this.sf); + if (!(required as readonly string[]).includes(name)) + this.err(prop, `unknown RpgScreen prop ${JSON.stringify(name)}; expected exactly ${required.join(", ")}`); + const typedName = name as RpgScreenProp; + if (attrs.has(typedName)) this.err(prop, `duplicate RpgScreen prop ${JSON.stringify(name)}`); + attrs.set(typedName, this.attrExpr(prop)); + } + for (const name of required) + if (!attrs.has(name)) this.err(el, `RpgScreen is missing required prop ${JSON.stringify(name)}`); + + const map = this.rpgMapArg(attrs.get("map")!, "RpgScreen"); + const deps = new Set(); + const prev = this.curDeps; + this.curDeps = deps; + const { decls, body } = this.withHoist((out) => { + const readNumber = (name: Exclude): { c: string; ty: Ty } => { + const value = this.compileExpr(attrs.get(name)!, out, " "); + if (value.ty.k !== "num") this.err(attrs.get(name)!, `RpgScreen ${name} must be a number`); + return value; + }; + const mode = readNumber("mode"); + const playerX = readNumber("playerX"); + const playerY = readNumber("playerY"); + const playerOffsetX = readNumber("playerOffsetX"); + const playerOffsetY = readNumber("playerOffsetY"); + const facing = readNumber("facing"); + const playerFrame = readNumber("playerFrame"); + const quest = readNumber("quest"); + const dialog = readNumber("dialog"); + const choice = readNumber("choice"); + const heroHp = readNumber("heroHp"); + const enemyHp = readNumber("enemyHp"); + const battleCursor = readNumber("battleCursor"); + out.push( + ` vp_rpg_render(&RPG_${map.name}, (u8)(${mode.c}), ${playerX.c}, ${playerY.c}, ` + + `${playerOffsetX.c}, ${playerOffsetY.c}, (u8)(${facing.c}), (u8)(${playerFrame.c}), ` + + `${quest.c}, ${dialog.c}, ${choice.c}, ${heroHp.c}, ${enemyHp.c}, ${battleCursor.c});`, + ); + }); + this.curDeps = prev; + /* The RPG host maintains BG0 UI incrementally. Clearing the full character + * grid before every movement effect would erase the cached fixed HUD. */ + return { + span: [0, this.target.height], + deps, + decls, + body, + isStatic: false, + skipRowClear: true, + }; + } + private compileMapUnit(call: ts.CallExpression): { span: [number, number]; deps: Set; @@ -1927,6 +2321,48 @@ class AppCompiler { this.err(yExpr, "map row y must be `i` or `CONST + i`"); } + private emitRpgData(): string { + const out: string[] = []; + for (const map of this.rpgMaps) { + const prefix = `RPG_${map.name}`; + const tiles = map.rows.join(""); + const solidSet = new Set(map.solid.split("")); + const solidMask = tiles.split("").map((tile) => (solidSet.has(tile) ? 1 : 0)); + out.push(`static const u8 ${prefix}_tiles[] = "${this.escC(tiles)}";`); + out.push(`static const u8 ${prefix}_solid[${solidMask.length}] = { ${solidMask.join(", ")} };`); + + if (map.events.length > 0) { + out.push( + `static const vp_rpg_event ${prefix}_events[${map.events.length}] = { ${map.events + .map((event) => `{ ${event.tile}, ${event.event} }`) + .join(", ")} };`, + ); + } else { + out.push(`static const vp_rpg_event ${prefix}_events[1] = { { 0, 0 } };`); + } + + if (map.dialogs.length > 0) { + out.push( + `static const vp_rpg_dialog ${prefix}_dialogs[${map.dialogs.length}] = { ${map.dialogs + .map( + (dialog) => + `{ ${this.cStrLit(dialog.speaker)}, ${this.cStrLit(dialog.line1)}, ${this.cStrLit(dialog.line2)}, ` + + `${this.cStrLit(dialog.choice0)}, ${this.cStrLit(dialog.choice1)} }`, + ) + .join(", ")} };`, + ); + } else { + out.push(`static const vp_rpg_dialog ${prefix}_dialogs[1] = { { 0, 0, 0, 0, 0 } };`); + } + + out.push( + `static const vp_rpg_map ${prefix} = { ${map.width}, ${map.height}, ${prefix}_tiles, ${prefix}_solid, ` + + `${prefix}_events, ${map.events.length}, ${prefix}_dialogs, ${map.dialogs.length} };`, + ); + } + return out.join("\n"); + } + // ---- final emission ------------------------------------------------------- private emit(): CompiledApp { @@ -1950,6 +2386,49 @@ class AppCompiler { this.scope = saved; } + let repeatHandlerOut: string[] = []; + if (this.repeatHandler) { + const saved = new Map(this.scope); + const param = this.repeatHandler.parameters[0]?.name.getText(this.sf); + if (!param) this.err(this.repeatHandler, "onButtonRepeat arrow needs a (b) param"); + this.scope.set(param, { kind: "local", cName: "b_arg", ty: NUM }); + const repeatHandlerArrow = this.repeatHandler; + const { decls, body } = this.withHoist((out) => { + if (ts.isBlock(repeatHandlerArrow.body)) { + for (const stmt of repeatHandlerArrow.body.statements) this.compileStmt(stmt, out, " "); + } else { + this.compileExprStmt(repeatHandlerArrow.body, out, " "); + } + }); + repeatHandlerOut = [...decls, ...body]; + this.scope = saved; + } + + let frameHandlerOut: string[] = []; + if (this.frameHandler) { + const saved = new Map(this.scope); + const parameter = this.frameHandler.parameters[0]; + if (parameter) { + if (!ts.isIdentifier(parameter.name)) + this.err(this.frameHandler, "onFrame arrow needs a simple buttons parameter"); + this.scope.set(parameter.name.text, { + kind: "local", + cName: "buttons_arg", + ty: NUM, + }); + } + const frameHandlerArrow = this.frameHandler; + const { decls, body } = this.withHoist((out) => { + if (ts.isBlock(frameHandlerArrow.body)) { + for (const stmt of frameHandlerArrow.body.statements) this.compileStmt(stmt, out, " "); + } else { + this.compileExprStmt(frameHandlerArrow.body, out, " "); + } + }); + frameHandlerOut = [...decls, ...body]; + this.scope = saved; + } + const axisHandlerFns: string[] = []; const axisHandlerCases: string[] = []; for (const [axis, handler] of [...this.axisHandlers].sort(([a], [b]) => a - b)) { @@ -1977,6 +2456,8 @@ class AppCompiler { } const { inits, effects } = this.emitTemplate(); + // Collect RPG dialogue literals before the shared string table is emitted. + const rpgData = this.emitRpgData(); // seed + init const initOut: string[] = []; @@ -2067,6 +2548,7 @@ class AppCompiler { "static inline const char *VP_UNUSED_FN vp_cstr_at(const char *const *arr, s32 n, s32 i) { return (i >= 0 && i < n) ? arr[i] : (const char *)\"\"; }", ); c.push("static inline char VP_UNUSED_FN vp_char_at(const char *s, s32 n, s32 i) { return (i >= 0 && i < n) ? s[i] : ' '; }"); + c.push(`const u8 vp_rpg_enabled = ${this.rpgMaps.length > 0 ? 1 : 0};`); c.push(""); // record structs @@ -2093,6 +2575,10 @@ class AppCompiler { // string literals for (const [text, name] of this.strLits) c.push(`static const char ${name}[] = "${this.escC(text)}";`); c.push(""); + if (rpgData) { + c.push(rpgData); + c.push(""); + } c.push(this.decls.join("\n")); c.push(""); c.push(this.bodies.join("\n")); @@ -2106,6 +2592,22 @@ class AppCompiler { // handler c.push(`void app_on_button(u8 b) {\n s32 b_arg = (s32)b;\n${handlerOut.join("\n")}\n}\n`); + if (this.frameHandler) { + c.push( + `void app_on_frame(u32 buttons) {\n s32 buttons_arg = (s32)buttons;\n${frameHandlerOut.join("\n")}\n}\n`, + ); + } else { + c.push("void app_on_frame(u32 buttons) {\n (void)buttons;\n}\n"); + } + if (this.target.name === "gba") { + if (this.repeatHandler) { + c.push( + `void app_on_button_repeat(u8 b) {\n s32 b_arg = (s32)b;\n${repeatHandlerOut.join("\n")}\n}\n`, + ); + } else { + c.push("void app_on_button_repeat(u8 b) {\n (void)b;\n}\n"); + } + } c.push(axisHandlerFns.join("\n\n")); if (axisHandlerCases.length > 0) { c.push( @@ -2164,6 +2666,14 @@ class AppCompiler { // ---- reports ---- const graphLines: string[] = []; + if (this.rpgMaps.length > 0) { + graphLines.push("rpg maps:"); + for (const map of this.rpgMaps) + graphLines.push( + ` ${map.name}: ${map.width}x${map.height}, ${map.events.length} event keys, ${map.dialogs.length} dialogs`, + ); + graphLines.push(`rpg screen effects: ${this.rpgScreenCount} full-screen runtime effect(s)`); + } graphLines.push("refs:"); for (const r of this.refs) graphLines.push(` bit ${r.index}: ${r.name} (${r.refTy})`); graphLines.push("computeds:"); @@ -2189,6 +2699,8 @@ class AppCompiler { .join(", ") || "none" }`, ); + graphLines.push(` frame hook: ${this.frameHandler ? "fixed (gba)" : "none"}`); + graphLines.push(` button repeats: ${this.repeatHandler ? "directional (gba)" : "none"}`); graphLines.push( ` relative axes: ${ [...this.axisHandlers.keys()] @@ -2225,10 +2737,21 @@ class AppCompiler { `reactive tables: ${this.refs.length} dirty bits, ${this.computeds.length} validity bits, ${effects.length} effects`, `ROM data: ${romStrings} B strings + ${fontBytes} B font + ${styleBytes} B style data`, ]; + if (this.rpgMaps.length > 0) { + const tileCount = this.rpgMaps.reduce((sum, map) => sum + map.width * map.height, 0); + const eventCount = this.rpgMaps.reduce((sum, map) => sum + map.events.length, 0); + const dialogCount = this.rpgMaps.reduce((sum, map) => sum + map.dialogs.length, 0); + planLines.push( + `RPG assets: ${this.rpgMaps.length} map(s), ${tileCount} tiles + collision bytes, ${eventCount} events, ${dialogCount} dialogs; ${this.rpgScreenCount} full-screen effect(s)`, + ); + planLines.push("RPG art: 54 BG tiles + 6 static and 16 walking 32x32 world frames + 2 64x64 battle OBJ frames, 4 palette banks; 17216 B ROM copied to VRAM"); + planLines.push("RPG host RAM: 3136 B fixed BG1 tilemap + OAM/UI/cache/register shadow state"); + } return { c: c.join("\n"), title: this.title, + rpgEnabled: this.rpgMaps.length > 0, graph: graphLines.join("\n"), plan: planLines.join("\n"), debugSlots, diff --git a/vapor/compiler/rom.ts b/vapor/compiler/rom.ts index 0aeca0a0..03e258c6 100644 --- a/vapor/compiler/rom.ts +++ b/vapor/compiler/rom.ts @@ -125,7 +125,9 @@ export async function buildGbaRom(app: CompiledApp, outRom: string): Promise<{ r const gbaDir = join(RUNTIME, "gba"); const elf = join(genDir, "app.elf"); const defines = targetDefines("gba"); - await $`arm-none-eabi-gcc -mcpu=arm7tdmi -mthumb-interwork -marm -ffreestanding -nostdlib -Os -fno-strict-aliasing -Wall -Werror=implicit-function-declaration ${defines} -I${RUNTIME} -I${gbaDir} -T${gbaDir}/gba.ld ${gbaDir}/crt0.s ${RUNTIME}/vapor_core.c ${gbaDir}/vapor_gba.c ${genC} -lgcc -o ${elf}`; + const rpgDefine = app.rpgEnabled ? ["-DVP_ENABLE_RPG=1"] : []; + const rpgSource = app.rpgEnabled ? [join(gbaDir, "vapor_rpg.c")] : []; + await $`arm-none-eabi-gcc -mcpu=arm7tdmi -mthumb-interwork -marm -ffreestanding -nostdlib -Os -fno-strict-aliasing -Wall -Werror=implicit-function-declaration ${defines} ${rpgDefine} -I${RUNTIME} -I${gbaDir} -T${gbaDir}/gba.ld ${gbaDir}/crt0.s ${RUNTIME}/vapor_core.c ${rpgSource} ${gbaDir}/vapor_gba.c ${genC} -lgcc -o ${elf}`; await $`arm-none-eabi-objcopy -O binary ${elf} ${outRom}`; const rom = new Uint8Array(await Bun.file(outRom).arrayBuffer()); diff --git a/vapor/examples/rpg/assets/README.md b/vapor/examples/rpg/assets/README.md new file mode 100644 index 00000000..baccac3a --- /dev/null +++ b/vapor/examples/rpg/assets/README.md @@ -0,0 +1,58 @@ +# Vapor Quest pixel art + +This folder is the reviewed visual source for the GBA RPG POC. PixelLab creates +the source PNGs; the offline asset compiler fixes their dimensions, transparent +anchors and GBA palettes before emitting the reviewed sheets in `final/` and +`vapor/runtime/gba/vapor_rpg_assets.generated.h`. + +## Art bible + +- Bright field, deep-navy information layer, expressive close-up chibi silhouettes. +- Native hard pixels, no antialiasing, gradients, dithering or partial alpha. +- Lighting always comes from the top left; visible outlines use `#18294a`. +- Logical world cells are 16x16 metatiles, so the 240x160 screen shows a + camera-followed 15x10 slice of the map. UI pieces remain screen-space 8x8. +- World OBJ frames are 32x32 with a shared y=31 foot line. Battle derives + readable 64x64 close-up frames from the same reviewed PixelLab sources. +- The hero has four reviewed walking frames in each cardinal direction. Each + direction shares one scale and horizontal anchor, while every contacting + foot is normalized to y=31 so movement stays grounded without sprite jitter. +- The SLIME gameplay role uses a compact teal tentacled puddle-ooze silhouette; + its short pseudopods, two white eyes and broad ground contact are intentional. +- Generated terrain luminance clusters remain the base texture; deterministic + material ramps and sparse accents make them legible in one fixed 4bpp bank. +- World/UI, hero, elder and slime each have one fixed 4bpp palette bank. +- Flowers remain low-contrast walkable decoration; walls, water and trees read + as barriers. UI texture remains quieter and higher contrast than the world. + +`generation.json` records the exact PixelLab endpoints, prompts, seeds, IDs and +source hashes. It intentionally contains no API token. + +## Rebuild + +Source generation is opt-in because it consumes PixelLab quota and replaces +reviewed art: + +```sh +set -a +source ~/code/.env +set +a +bun run vapor:rpg:assets:generate --force --only=all +``` + +The normal build and CI paths are entirely offline: + +```sh +bun run vapor:rpg:assets:build +bun run vapor:rpg:assets:check +``` + +Do not hand-edit the generated header. Review `background.png`, `actors.png`, +the 4x4 `hero-walk.png` cycle sheet, `battle-actors.png`, the palette guide, +and the world/dialog/battle mGBA captures after changing any source image. + +Use `--only=style`, `--only=world`, `--only=tree`, `--only=flower`, +`--only=characters`, `--only=hero`, `--only=walk`, `--only=elder` or +`--only=slime` with `--force` for a targeted iteration. The walk generator +reuses the persistent PixelLab hero and its `walking-4-frames` skeleton +template. Without `--force`, generation only fills missing sources. diff --git a/vapor/examples/rpg/assets/final/actors.png b/vapor/examples/rpg/assets/final/actors.png new file mode 100644 index 00000000..d964dd78 Binary files /dev/null and b/vapor/examples/rpg/assets/final/actors.png differ diff --git a/vapor/examples/rpg/assets/final/background.png b/vapor/examples/rpg/assets/final/background.png new file mode 100644 index 00000000..487a1407 Binary files /dev/null and b/vapor/examples/rpg/assets/final/background.png differ diff --git a/vapor/examples/rpg/assets/final/battle-actors.png b/vapor/examples/rpg/assets/final/battle-actors.png new file mode 100644 index 00000000..f3109b00 Binary files /dev/null and b/vapor/examples/rpg/assets/final/battle-actors.png differ diff --git a/vapor/examples/rpg/assets/final/hero-walk.png b/vapor/examples/rpg/assets/final/hero-walk.png new file mode 100644 index 00000000..fef336dc Binary files /dev/null and b/vapor/examples/rpg/assets/final/hero-walk.png differ diff --git a/vapor/examples/rpg/assets/final/palette-guide.png b/vapor/examples/rpg/assets/final/palette-guide.png new file mode 100644 index 00000000..a5d3f46a Binary files /dev/null and b/vapor/examples/rpg/assets/final/palette-guide.png differ diff --git a/vapor/examples/rpg/assets/generation.json b/vapor/examples/rpg/assets/generation.json new file mode 100644 index 00000000..16389da8 --- /dev/null +++ b/vapor/examples/rpg/assets/generation.json @@ -0,0 +1,207 @@ +{ + "version": 3, + "provider": "PixelLab", + "apiBase": "https://api.pixellab.ai/v2", + "artDirection": "Vapor Quest close-up: 16px world cells, 32px actors, deep-navy information layer", + "assets": { + "styleAnchor": { + "endpoint": "/create-image-pixen", + "seed": 23050, + "size": { + "width": 64, + "height": 64 + }, + "prompt": "Original cheerful early-2000s handheld cartridge RPG pixel art. High top-down world view, expressive 32-pixel chibi characters, crisp hard-edged native pixels. One-pixel selective deep-navy outlines, flat clusters, fixed top-left lighting. Readable silhouettes and restrained surface texture; no imitation of any existing game. Exactly one single south-facing full-body blue-tunic adventurer with short brown hair and a warm orange scarf, centered in a neutral standing pose on transparent background. Blue clothing must be the largest color area. This is a canonical style reference, not a sprite sheet: one subject, one pose, no duplicate, no alternate view, no scenery, text or UI." + }, + "terrainSets": [ + { + "name": "field", + "endpoint": "/tilesets", + "seed": 23061, + "lower": "Original cheerful early-2000s handheld cartridge RPG terrain pixel art. Strict high top-down orthographic view, crisp hard-edged native pixels. Small flat color clusters, fixed top-left lighting, restrained readable texture. No characters, items, icons, perspective, canvas border or imitation of an existing game. Native 16x16 seamless bright green meadow grass, quiet walkable ground, four or five deliberate dark grass blades, no objects or border.", + "upper": "Original cheerful early-2000s handheld cartridge RPG terrain pixel art. Strict high top-down orthographic view, crisp hard-edged native pixels. Small flat color clusters, fixed top-left lighting, restrained readable texture. No characters, items, icons, perspective, canvas border or imitation of an existing game. Native 16x16 seamless warm tan compacted village footpath, quiet walkable ground, a few readable brown pebbles, no grass edge or border.", + "lowerFile": "grass.png", + "upperFile": "path.png" + }, + { + "name": "barriers", + "endpoint": "/tilesets", + "seed": 23062, + "lower": "Original cheerful early-2000s handheld cartridge RPG terrain pixel art. Strict high top-down orthographic view, crisp hard-edged native pixels. Small flat color clusters, fixed top-left lighting, restrained readable texture. No characters, items, icons, perspective, canvas border or imitation of an existing game. Native 16x16 seamless deep blue stream water, clearly impassable, three crisp horizontal ripple clusters, quiet even field, no shore or border.", + "upper": "Original cheerful early-2000s handheld cartridge RPG terrain pixel art. Strict high top-down orthographic view, crisp hard-edged native pixels. Small flat color clusters, fixed top-left lighting, restrained readable texture. No characters, items, icons, perspective, canvas border or imitation of an existing game. Native 16x16 seamless gray stone barrier, clearly solid and impassable, chunky rectangular stones, dark mortar and bright top-left edges, no grass or border.", + "lowerFile": "water.png", + "upperFile": "wall.png" + } + ], + "mapObjects": [ + { + "name": "tree", + "endpoint": "/map-objects", + "seed": 23069, + "prompt": "Original cheerful early-2000s handheld cartridge RPG pixel art. High top-down world view, expressive 32-pixel chibi characters, crisp hard-edged native pixels. One-pixel selective deep-navy outlines, flat clusters, fixed top-left lighting. Readable silhouettes and restrained surface texture; no imitation of any existing game. One centered top-down deciduous tree map object designed to fill one 16x16 world cell after reduction. A single compact, solid, filled round dome canopy occupies most of the image and must never form a hollow ring, split crown, doorway or arch. Dense leaves read as impassable, with dark lower-right foliage, bright top-left leaf clusters and exactly one short centered trunk. No grass tile and no cast shadow." + }, + { + "name": "flower", + "endpoint": "/map-objects", + "seed": 23066, + "prompt": "Original cheerful early-2000s handheld cartridge RPG pixel art. High top-down world view, expressive 32-pixel chibi characters, crisp hard-edged native pixels. One-pixel selective deep-navy outlines, flat clusters, fixed top-left lighting. Readable silhouettes and restrained surface texture; no imitation of any existing game. One readable gold-and-cream meadow flower cluster designed for one 16x16 world cell after reduction. Low-profile walkable decoration, three small blossoms and delicate green stems, no enclosing outline, no ground tile and no cast shadow." + } + ], + "heroSouth": { + "endpoint": "/create-image-bitforge", + "seed": 23063, + "size": { + "width": 64, + "height": 64 + }, + "prompt": "Original cheerful early-2000s handheld cartridge RPG pixel art. High top-down world view, expressive 32-pixel chibi characters, crisp hard-edged native pixels. One-pixel selective deep-navy outlines, flat clusters, fixed top-left lighting. Readable silhouettes and restrained surface texture; no imitation of any existing game. Exactly one south-facing native 64x64 source sprite designed to retain facial, clothing and limb detail in a 32x32 GBA character. Full-body chibi adventurer with a readable head, short brown hair, torso, two arms, gloves, boots and two separated feet. Blue tunic and darker blue trousers are the largest color blocks; warm orange-red scarf is the identity accent; warm skin and selective deep-navy outline. Centered, feet at the bottom, strong silhouette, no weapon, no detached pixels, no duplicate, no alternate pose, no sprite sheet." + }, + "heroRotations": { + "endpoint": "/create-character-v3", + "seed": 23054, + "prompt": "The same expressive blue-tunic adventurer with short brown hair, orange-red scarf, gloves and boots, rotated consistently for a high top-down RPG; preserve face, head height, shoulder width, clothing blocks, palette, outline and foot anchor in every direction." + }, + "heroWalk": { + "endpoint": "/characters/animations", + "seed": 23071, + "templateAnimationId": "walking-4-frames", + "animationName": "Vapor Quest Walk", + "directions": [ + "south", + "north", + "west", + "east" + ], + "frameCount": 4, + "prompt": "A compact, cyclic four-frame walk with alternating arms and legs, steady high top-down camera, stable body proportions, fixed ground plane and no translation inside the source canvas." + }, + "elder": { + "endpoint": "/create-image-pixen", + "seed": 23060, + "size": { + "width": 64, + "height": 64 + }, + "prompt": "Original cheerful early-2000s handheld cartridge RPG pixel art. High top-down world view, expressive 32-pixel chibi characters, crisp hard-edged native pixels. One-pixel selective deep-navy outlines, flat clusters, fixed top-left lighting. Readable silhouettes and restrained surface texture; no imitation of any existing game. Exactly one south-facing native 64x64 source sprite designed to retain facial, hair and robe detail in a 32x32 GBA character. Full-body chibi village elder with swept white hair, eyebrows, short white beard, broad layered gray robe, blue sash, warm skin, deep-navy outline, two visible arms and a grounded hem. Same height, head ratio, lighting and foot anchor as the blue-tunic adventurer. No staff, centered, no skeleton or ghost appearance, no detached pixels, no duplicate, no alternate pose, no sprite sheet." + }, + "slime": { + "endpoint": "/map-objects", + "seed": 23065, + "size": { + "width": 64, + "height": 64 + }, + "prompt": "Original cheerful early-2000s handheld cartridge RPG monster pixel art. High top-down view, crisp hard-edged native pixels, flat color clusters and fixed top-left lighting. One-pixel selective deep-navy outline, readable non-humanoid silhouette, no imitation of any existing game. Exactly one native 64x64 source sprite designed to retain face, highlight and volume detail in a 32x32 GBA monster. A compact teal tentacled puddle ooze with a rounded central body and several short grounded pseudopods. Its amorphous body is at least ninety percent a four-step teal ramp, with a deep-navy outline, broad grounded base, two tiny white eyes and a small dark mouth. Cute but clearly an enemy, wider than tall, with no humanoid torso, clothing, hands or feet, no floating parts, centered at the bottom, no duplicate, no alternate pose, no sprite sheet." + } + }, + "records": { + "styleAnchor": { + "hash": "3ec15df5cdc74c049590da8af13a25bbf3504eb057c3ca4d42dff2bb534f4448" + }, + "heroSouth": { + "hash": "7764b0fb59265d96a1c94286967670114e2bad05a0a7239048f263aae3ab7b30" + }, + "heroRotations": { + "backgroundJobId": "50015090-9110-432c-8609-a7e258b57d28", + "characterId": "d42cc855-5d6a-4f8f-b679-76ca6b616c2a", + "hashes": { + "south": "b75a498f65c4c6ec4ed19125a5008c625e2f71370e27e52a7d045f23728e109f", + "north": "6d880d8f7abecb1c1d2b7ef925abe7843b85934d2a8c121a4b0ed97ead774c0c", + "west": "1fe6ba8ac67b1365d22fa3a3e6c29ac9df9b40e2334a51ca7dcb12f333a25a95", + "east": "e0d2c1a0507fab758d8ffb7efafc7beaa01454d82e1cf72c2b77274db7649d16" + } + }, + "elder": { + "hash": "e0c3e265aec11add024c0ca30ea478a4dcfccd18cf4adebcbeb627f8744e3387" + }, + "slime": { + "backgroundJobId": "be4d78f4-4e82-4447-87a3-5fc396dd42ea", + "objectId": "d3145b9f-aeff-41f9-bad2-f5cdab3de582", + "hash": "3947c9588011dfacb1e1e5c9876bc990655745abef08400c1afdbe35f981a27e" + }, + "world": { + "field": { + "backgroundJobId": "8312ff9f-c8f7-4377-b1d7-5ae7090bdeda", + "tilesetId": "eddeda27-2e8a-40ce-9fd7-9c025608efd5", + "grass.png": "eff978921c698cc79df902a6c014905ba0369b396578ac877d7dada38028afc1", + "path.png": "63c7b786955f8f17d8fabd8efd53dbcf6fbef95c5326eb9e6c742f01a0e3206d" + }, + "barriers": { + "backgroundJobId": "7bb600a4-4e6d-4371-ad74-a46a508ddb50", + "tilesetId": "78b6c665-d598-476d-a740-e14c2e5fdee8", + "water.png": "86ea76cde4dc27f2d0a8d6b5cfad8ba59c8db965a05223eaccfa95b4e6f1e30b", + "wall.png": "106e7deded7789ba4fbb73458f56c38c7d43de58e0d4722227a2e0cef14c6cd3" + }, + "tree": { + "backgroundJobId": "77a9a734-f6ee-4c7f-878f-bef76f019964", + "objectId": "6c78da41-e1ed-4ad1-a609-545834575631", + "hash": "b80c79db32cc0f7fa732910266b57d86203ce4b308fcfec9565e861457bf826f" + }, + "flower": { + "backgroundJobId": "6412ac24-9b29-45f3-b339-cecdebbce64a", + "objectId": "890ba93b-3aa5-4839-81a0-ac9af36d6afc", + "hash": "ec65da8eaa922bb68e60ceda4de7273b82c14074248fab6a54c681103c29c72e" + } + }, + "heroWalk": { + "characterId": "d42cc855-5d6a-4f8f-b679-76ca6b616c2a", + "animationGroupId": "dcefbb34-fe52-42ea-b11e-6bdc2ab4683a", + "backgroundJobIds": { + "south": "9c7e2ca6-d213-44e9-8f6c-27f619f56a94", + "north": "cfe3e201-8adf-471a-ac1d-8cd23c01cc7c", + "west": "41786af5-95cc-4f7c-9446-38e402ed29fe", + "east": "06f53421-63ce-4152-9022-863e7cbab3aa" + }, + "hashes": { + "hero-walk-south-0.png": "cd21a5fe4d1348fd29474022f996401149017f8235fce628424eb9bcb1f86a3c", + "hero-walk-south-1.png": "b2b46717d35df16c7a79bf61273f90e446dcf0b1d620ff373c6358772c00ce20", + "hero-walk-south-2.png": "8a15001d86f7f513fc34decebb7d4abd0fa3b34852177b7ec9544154f6db0b15", + "hero-walk-south-3.png": "a7e05f9f52fa8b029a6a21c38678539a458d0c03f5e75ae9fa1e931f3473404e", + "hero-walk-north-0.png": "def20ac9c33217dcb9e73db4fff8c327aab7f12ec925dbe3bcc08edd66b91a35", + "hero-walk-north-1.png": "7379150c6a377c49311d5f38ce5fb3c7577ca29591e85e4e062a8eeed843531d", + "hero-walk-north-2.png": "a86afe6206d7be71d3a00e98455ec581ab3356c8e4d9e0bef8b215b6ca6f3be2", + "hero-walk-north-3.png": "4d73d662899e3ab0ca53b8063cbf03d3e6aef972cf7186010126120efd49a12a", + "hero-walk-west-0.png": "038d6b9f4641933854cf0eaf537bee97829d12a24ed3dfc9c6ed6988658361e0", + "hero-walk-west-1.png": "c99444f24d98b5759fa15b5ab0e38233f25151e0592b450c320f460d41702c88", + "hero-walk-west-2.png": "beb60344869e289119a8dbc5a643570006946b84fccab21ced5fed3bd0d767a9", + "hero-walk-west-3.png": "011376191c63b349e1c42a5f83bbfe0aedf62e4ad38f75b40e5ae4a0d0d63cbf", + "hero-walk-east-0.png": "3ebccd965e038911d3a78a11c4563098f32f5e61c86fc29bdb4734fe8a234d11", + "hero-walk-east-1.png": "c7722de28772640aaaee7e0208a8c325d81958bdb3d245cf198d6623cadcdfa0", + "hero-walk-east-2.png": "82071cdebe27c6f071f90abe5a4a20fa8ea5db13cc19baed16a801c6d542d5b4", + "hero-walk-east-3.png": "6d4072e46cf50513432866b3fc95be76daffe607c5a5adcd24419548235205df" + } + } + }, + "sourceHashes": { + "style-anchor.png": "3ec15df5cdc74c049590da8af13a25bbf3504eb057c3ca4d42dff2bb534f4448", + "grass.png": "eff978921c698cc79df902a6c014905ba0369b396578ac877d7dada38028afc1", + "path.png": "63c7b786955f8f17d8fabd8efd53dbcf6fbef95c5326eb9e6c742f01a0e3206d", + "water.png": "86ea76cde4dc27f2d0a8d6b5cfad8ba59c8db965a05223eaccfa95b4e6f1e30b", + "wall.png": "106e7deded7789ba4fbb73458f56c38c7d43de58e0d4722227a2e0cef14c6cd3", + "tree.png": "b80c79db32cc0f7fa732910266b57d86203ce4b308fcfec9565e861457bf826f", + "flower.png": "ec65da8eaa922bb68e60ceda4de7273b82c14074248fab6a54c681103c29c72e", + "hero-south-reference.png": "7764b0fb59265d96a1c94286967670114e2bad05a0a7239048f263aae3ab7b30", + "hero-south.png": "b75a498f65c4c6ec4ed19125a5008c625e2f71370e27e52a7d045f23728e109f", + "hero-north.png": "6d880d8f7abecb1c1d2b7ef925abe7843b85934d2a8c121a4b0ed97ead774c0c", + "hero-west.png": "1fe6ba8ac67b1365d22fa3a3e6c29ac9df9b40e2334a51ca7dcb12f333a25a95", + "hero-east.png": "e0d2c1a0507fab758d8ffb7efafc7beaa01454d82e1cf72c2b77274db7649d16", + "elder.png": "e0c3e265aec11add024c0ca30ea478a4dcfccd18cf4adebcbeb627f8744e3387", + "slime.png": "3947c9588011dfacb1e1e5c9876bc990655745abef08400c1afdbe35f981a27e", + "hero-walk-south-0.png": "cd21a5fe4d1348fd29474022f996401149017f8235fce628424eb9bcb1f86a3c", + "hero-walk-south-1.png": "b2b46717d35df16c7a79bf61273f90e446dcf0b1d620ff373c6358772c00ce20", + "hero-walk-south-2.png": "8a15001d86f7f513fc34decebb7d4abd0fa3b34852177b7ec9544154f6db0b15", + "hero-walk-south-3.png": "a7e05f9f52fa8b029a6a21c38678539a458d0c03f5e75ae9fa1e931f3473404e", + "hero-walk-north-0.png": "def20ac9c33217dcb9e73db4fff8c327aab7f12ec925dbe3bcc08edd66b91a35", + "hero-walk-north-1.png": "7379150c6a377c49311d5f38ce5fb3c7577ca29591e85e4e062a8eeed843531d", + "hero-walk-north-2.png": "a86afe6206d7be71d3a00e98455ec581ab3356c8e4d9e0bef8b215b6ca6f3be2", + "hero-walk-north-3.png": "4d73d662899e3ab0ca53b8063cbf03d3e6aef972cf7186010126120efd49a12a", + "hero-walk-west-0.png": "038d6b9f4641933854cf0eaf537bee97829d12a24ed3dfc9c6ed6988658361e0", + "hero-walk-west-1.png": "c99444f24d98b5759fa15b5ab0e38233f25151e0592b450c320f460d41702c88", + "hero-walk-west-2.png": "beb60344869e289119a8dbc5a643570006946b84fccab21ced5fed3bd0d767a9", + "hero-walk-west-3.png": "011376191c63b349e1c42a5f83bbfe0aedf62e4ad38f75b40e5ae4a0d0d63cbf", + "hero-walk-east-0.png": "3ebccd965e038911d3a78a11c4563098f32f5e61c86fc29bdb4734fe8a234d11", + "hero-walk-east-1.png": "c7722de28772640aaaee7e0208a8c325d81958bdb3d245cf198d6623cadcdfa0", + "hero-walk-east-2.png": "82071cdebe27c6f071f90abe5a4a20fa8ea5db13cc19baed16a801c6d542d5b4", + "hero-walk-east-3.png": "6d4072e46cf50513432866b3fc95be76daffe607c5a5adcd24419548235205df" + } +} diff --git a/vapor/examples/rpg/assets/source/elder.png b/vapor/examples/rpg/assets/source/elder.png new file mode 100644 index 00000000..cbecb53c Binary files /dev/null and b/vapor/examples/rpg/assets/source/elder.png differ diff --git a/vapor/examples/rpg/assets/source/flower.png b/vapor/examples/rpg/assets/source/flower.png new file mode 100644 index 00000000..30cb9c73 Binary files /dev/null and b/vapor/examples/rpg/assets/source/flower.png differ diff --git a/vapor/examples/rpg/assets/source/grass.png b/vapor/examples/rpg/assets/source/grass.png new file mode 100644 index 00000000..c004f54a Binary files /dev/null and b/vapor/examples/rpg/assets/source/grass.png differ diff --git a/vapor/examples/rpg/assets/source/hero-east.png b/vapor/examples/rpg/assets/source/hero-east.png new file mode 100644 index 00000000..816d98dd Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-east.png differ diff --git a/vapor/examples/rpg/assets/source/hero-north.png b/vapor/examples/rpg/assets/source/hero-north.png new file mode 100644 index 00000000..f1ac0cd3 Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-north.png differ diff --git a/vapor/examples/rpg/assets/source/hero-south-reference.png b/vapor/examples/rpg/assets/source/hero-south-reference.png new file mode 100644 index 00000000..c25dd930 Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-south-reference.png differ diff --git a/vapor/examples/rpg/assets/source/hero-south.png b/vapor/examples/rpg/assets/source/hero-south.png new file mode 100644 index 00000000..1efc3c96 Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-south.png differ diff --git a/vapor/examples/rpg/assets/source/hero-walk-east-0.png b/vapor/examples/rpg/assets/source/hero-walk-east-0.png new file mode 100644 index 00000000..8182fc2a Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-walk-east-0.png differ diff --git a/vapor/examples/rpg/assets/source/hero-walk-east-1.png b/vapor/examples/rpg/assets/source/hero-walk-east-1.png new file mode 100644 index 00000000..2fa757ef Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-walk-east-1.png differ diff --git a/vapor/examples/rpg/assets/source/hero-walk-east-2.png b/vapor/examples/rpg/assets/source/hero-walk-east-2.png new file mode 100644 index 00000000..b533ab9e Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-walk-east-2.png differ diff --git a/vapor/examples/rpg/assets/source/hero-walk-east-3.png b/vapor/examples/rpg/assets/source/hero-walk-east-3.png new file mode 100644 index 00000000..387066f0 Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-walk-east-3.png differ diff --git a/vapor/examples/rpg/assets/source/hero-walk-north-0.png b/vapor/examples/rpg/assets/source/hero-walk-north-0.png new file mode 100644 index 00000000..4ae4989b Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-walk-north-0.png differ diff --git a/vapor/examples/rpg/assets/source/hero-walk-north-1.png b/vapor/examples/rpg/assets/source/hero-walk-north-1.png new file mode 100644 index 00000000..a93f73b4 Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-walk-north-1.png differ diff --git a/vapor/examples/rpg/assets/source/hero-walk-north-2.png b/vapor/examples/rpg/assets/source/hero-walk-north-2.png new file mode 100644 index 00000000..3a16f3ac Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-walk-north-2.png differ diff --git a/vapor/examples/rpg/assets/source/hero-walk-north-3.png b/vapor/examples/rpg/assets/source/hero-walk-north-3.png new file mode 100644 index 00000000..ec418d5d Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-walk-north-3.png differ diff --git a/vapor/examples/rpg/assets/source/hero-walk-south-0.png b/vapor/examples/rpg/assets/source/hero-walk-south-0.png new file mode 100644 index 00000000..f0ca3c71 Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-walk-south-0.png differ diff --git a/vapor/examples/rpg/assets/source/hero-walk-south-1.png b/vapor/examples/rpg/assets/source/hero-walk-south-1.png new file mode 100644 index 00000000..9454e4ea Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-walk-south-1.png differ diff --git a/vapor/examples/rpg/assets/source/hero-walk-south-2.png b/vapor/examples/rpg/assets/source/hero-walk-south-2.png new file mode 100644 index 00000000..f5303cfc Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-walk-south-2.png differ diff --git a/vapor/examples/rpg/assets/source/hero-walk-south-3.png b/vapor/examples/rpg/assets/source/hero-walk-south-3.png new file mode 100644 index 00000000..d382f2a6 Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-walk-south-3.png differ diff --git a/vapor/examples/rpg/assets/source/hero-walk-west-0.png b/vapor/examples/rpg/assets/source/hero-walk-west-0.png new file mode 100644 index 00000000..4b0af693 Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-walk-west-0.png differ diff --git a/vapor/examples/rpg/assets/source/hero-walk-west-1.png b/vapor/examples/rpg/assets/source/hero-walk-west-1.png new file mode 100644 index 00000000..6efd4080 Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-walk-west-1.png differ diff --git a/vapor/examples/rpg/assets/source/hero-walk-west-2.png b/vapor/examples/rpg/assets/source/hero-walk-west-2.png new file mode 100644 index 00000000..b8c52525 Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-walk-west-2.png differ diff --git a/vapor/examples/rpg/assets/source/hero-walk-west-3.png b/vapor/examples/rpg/assets/source/hero-walk-west-3.png new file mode 100644 index 00000000..c282fab0 Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-walk-west-3.png differ diff --git a/vapor/examples/rpg/assets/source/hero-west.png b/vapor/examples/rpg/assets/source/hero-west.png new file mode 100644 index 00000000..6e141b40 Binary files /dev/null and b/vapor/examples/rpg/assets/source/hero-west.png differ diff --git a/vapor/examples/rpg/assets/source/path.png b/vapor/examples/rpg/assets/source/path.png new file mode 100644 index 00000000..882b1cc3 Binary files /dev/null and b/vapor/examples/rpg/assets/source/path.png differ diff --git a/vapor/examples/rpg/assets/source/slime.png b/vapor/examples/rpg/assets/source/slime.png new file mode 100644 index 00000000..f126281a Binary files /dev/null and b/vapor/examples/rpg/assets/source/slime.png differ diff --git a/vapor/examples/rpg/assets/source/style-anchor.png b/vapor/examples/rpg/assets/source/style-anchor.png new file mode 100644 index 00000000..092f3e64 Binary files /dev/null and b/vapor/examples/rpg/assets/source/style-anchor.png differ diff --git a/vapor/examples/rpg/assets/source/tree.png b/vapor/examples/rpg/assets/source/tree.png new file mode 100644 index 00000000..319ba027 Binary files /dev/null and b/vapor/examples/rpg/assets/source/tree.png differ diff --git a/vapor/examples/rpg/assets/source/wall.png b/vapor/examples/rpg/assets/source/wall.png new file mode 100644 index 00000000..adae5aea Binary files /dev/null and b/vapor/examples/rpg/assets/source/wall.png differ diff --git a/vapor/examples/rpg/assets/source/water.png b/vapor/examples/rpg/assets/source/water.png new file mode 100644 index 00000000..6570a4f1 Binary files /dev/null and b/vapor/examples/rpg/assets/source/water.png differ diff --git a/vapor/examples/rpg/rpg.tsx b/vapor/examples/rpg/rpg.tsx new file mode 100644 index 00000000..e27cb93a --- /dev/null +++ b/vapor/examples/rpg/rpg.tsx @@ -0,0 +1,341 @@ +// POCKET VAPOR RPG — one complete reactive gameplay loop for the GBA host. +// +// All mutable game state is ordinary Vue ref/computed state. Under Pocket +// Vapor it becomes fixed native C slots and functions; the host module owns +// only static map data, collision/event lookup, and rendering. There is no JS +// engine, hidden RPG state machine, device SDK input, or fake crank button. +// +// Test route: spawn (2,2); Right, Down, Right turns into the solid Elder at +// (4,3), then A talks. Choose YES and press A again to close. Up, then Right +// x5 reaches the Slime at (8,2). Select ATTACK and press A three times. Close +// the victory dialog, then return and face the Elder with Left x5, Down, +// Right, A to complete the quest. + +import { computed, ref } from "vue"; +import { onFrame } from "@pocketjs/framework/vue-vapor/lifecycle"; +import { BTN } from "@pocketjs/framework/vue-vapor/input"; +import { Button, onButton } from "../../host/input.ts"; +import { + defineRpgMap, + RpgScreen, + rpgBlocked, + rpgEventAt, +} from "../../host/rpg.ts"; + +type Keymap = Record void>; + +const Mode = { + World: 0, + Dialog: 1, + Battle: 2, +}; + +const Facing = { + Down: 0, + Up: 1, + Left: 2, + Right: 3, +}; + +const Quest = { + NotStarted: 0, + Accepted: 1, + Won: 2, + Complete: 3, +}; + +const Dialog = { + None: 0, + Offer: 1, + Accepted: 2, + Declined: 3, + Victory: 4, + Complete: 5, +}; + +const Event = { + None: 0, + Elder: 1, + Slime: 2, +}; + +const RPG_MAP = defineRpgMap({ + rows: [ + "##############################", + "#....T......~~~~......T......#", + "#.......S....==..............#", + "#...N........==....T.........#", + "#............==..............#", + "#....######..==..~~~~........#", + "#............==..~~~~........#", + "#..**........==........T.....#", + "#............======..........#", + "#......T.....................#", + "#............~~~~............#", + "#............~~~~....**......#", + "#..T.........................#", + "#........=====...............#", + "#........=...=.......T.......#", + "#..~~~~..=====...............#", + "#..~~~~..................**..#", + "#..............T.............#", + "#............................#", + "##############################", + ], + solid: "#NT~", + events: { + N: Event.Elder, + S: Event.Slime, + }, + // Dialog ids are one-based: Dialog.Offer selects dialogs[0]. + dialogs: [ + { + speaker: "ELDER", + line1: "SLIME BLOCKS EAST ROAD.", + line2: "WILL YOU HELP?", + choice0: "YES", + choice1: "NO", + }, + { + speaker: "ELDER", + line1: "THANK YOU, BRAVE HERO.", + line2: "FOLLOW THE ROAD EAST.", + }, + { + speaker: "ELDER", + line1: "COME BACK WHEN READY.", + }, + { + speaker: "HERO", + line1: "THE SLIME IS DEFEATED!", + line2: "REPORT TO THE ELDER.", + }, + { + speaker: "ELDER", + line1: "THE VILLAGE IS SAFE.", + line2: "QUEST COMPLETE!", + }, + ], +}); + +export default () => { + // Eleven refs: comfortably inside Pocket Vapor's current 16-ref budget. + const mode = ref(0); + const playerX = ref(2); + const playerY = ref(2); + const facing = ref(0); + const quest = ref(0); + const dialog = ref(0); + const choice = ref(0); + const heroHp = ref(30); + const enemyHp = ref(18); + const battleCursor = ref(0); + // -1 is idle; a live cardinal step advances 0, 2, ... 14 pixels before + // committing its destination cell on the following fixed semantic frame. + const walkPx = ref(-1); + + const questActive = computed(() => quest.value === Quest.Accepted); + + function openDialog(id: number) { + dialog.value = id; + choice.value = 0; + mode.value = Mode.Dialog; + } + + function closeDialog() { + dialog.value = Dialog.None; + choice.value = 0; + mode.value = Mode.World; + } + + function startBattle() { + heroHp.value = 30; + enemyHp.value = 18; + battleCursor.value = 0; + mode.value = Mode.Battle; + } + + function handleEvent(event: number) { + if (event === Event.Elder) { + if (quest.value === Quest.NotStarted) { + openDialog(Dialog.Offer); + } else if (quest.value === Quest.Accepted) { + openDialog(Dialog.Accepted); + } else if (quest.value === Quest.Won) { + quest.value = Quest.Complete; + openDialog(Dialog.Complete); + } else { + openDialog(Dialog.Complete); + } + } else if (event === Event.Slime) { + if (questActive.value) startBattle(); + } + } + + function beginMove(dx: number, dy: number, nextFacing: number) { + if (walkPx.value >= 0) return; + facing.value = nextFacing; + const nextX = playerX.value + dx; + const nextY = playerY.value + dy; + if (!rpgBlocked(RPG_MAP, nextX, nextY)) { + walkPx.value = 0; + } + } + + function advanceMove() { + if (walkPx.value < 0) return; + if (walkPx.value < 14) { + walkPx.value += 2; + return; + } + + if (facing.value === Facing.Up) playerY.value -= 1; + else if (facing.value === Facing.Down) playerY.value += 1; + else if (facing.value === Facing.Left) playerX.value -= 1; + else playerX.value += 1; + walkPx.value = -1; + + const event = rpgEventAt(RPG_MAP, playerX.value, playerY.value); + if (event !== Event.None) handleEvent(event); + } + + function tickWorld(buttons: number) { + advanceMove(); + if (mode.value !== Mode.World || walkPx.value >= 0) return; + + if (buttons & BTN.UP) beginMove(0, -1, Facing.Up); + else if (buttons & BTN.DOWN) beginMove(0, 1, Facing.Down); + else if (buttons & BTN.LEFT) beginMove(-1, 0, Facing.Left); + else if (buttons & BTN.RIGHT) beginMove(1, 0, Facing.Right); + } + + function moveChoice(delta: number) { + if (dialog.value === Dialog.Offer) { + choice.value = (choice.value + delta + 2) % 2; + } + } + + function interact() { + if (walkPx.value >= 0) return; + if (facing.value === Facing.Up) { + handleEvent(rpgEventAt(RPG_MAP, playerX.value, playerY.value - 1)); + } else if (facing.value === Facing.Down) { + handleEvent(rpgEventAt(RPG_MAP, playerX.value, playerY.value + 1)); + } else if (facing.value === Facing.Left) { + handleEvent(rpgEventAt(RPG_MAP, playerX.value - 1, playerY.value)); + } else { + handleEvent(rpgEventAt(RPG_MAP, playerX.value + 1, playerY.value)); + } + } + + function advanceDialog() { + if (dialog.value === Dialog.Offer) { + if (choice.value === 0) { + quest.value = Quest.Accepted; + dialog.value = Dialog.Accepted; + } else { + dialog.value = Dialog.Declined; + } + choice.value = 0; + } else { + closeDialog(); + } + } + + function enemyTurn() { + heroHp.value = Math.max(0, heroHp.value - 4); + if (heroHp.value === 0) { + heroHp.value = 30; + enemyHp.value = 18; + mode.value = Mode.World; + } + } + + function attack() { + enemyHp.value = Math.max(0, enemyHp.value - 6); + if (enemyHp.value === 0) { + quest.value = Quest.Won; + openDialog(Dialog.Victory); + } else { + enemyTurn(); + } + } + + function heal() { + heroHp.value = Math.min(30, heroHp.value + 8); + enemyTurn(); + } + + const worldKeys: Keymap = { + [Button.A]: interact, + }; + + const dialogKeys: Keymap = { + [Button.Up]: () => moveChoice(-1), + [Button.Down]: () => moveChoice(1), + [Button.A]: advanceDialog, + }; + + const battleKeys: Keymap = { + [Button.Up]: () => { + battleCursor.value = 0; + }, + [Button.Down]: () => { + battleCursor.value = 1; + }, + [Button.A]: () => { + if (battleCursor.value === 0) attack(); + else heal(); + }, + }; + + onButton( + (button) => + (mode.value === Mode.Dialog + ? dialogKeys + : mode.value === Mode.Battle + ? battleKeys + : worldKeys)[button]?.(), + ); + + // Movement advances on fixed semantic frames using the framework's shared + // held-button mask. Menus still consume only physical press edges above. + onFrame((buttons) => tickWorld(buttons)); + + return ( + <> + + + ); +}; diff --git a/vapor/host/input.ts b/vapor/host/input.ts index bc1b1414..7ca666bf 100644 --- a/vapor/host/input.ts +++ b/vapor/host/input.ts @@ -2,9 +2,10 @@ // // One module, two lives. Under the oracle (real Vue Vapor on a JS host) this // file executes: handlers register here and the test harness feeds button -// edges through __dispatchButton. Under the Pocket Vapor compiler the module -// is never executed — the compiler recognizes button and relative-axis -// registrations from this path and emits hooks fed by each target runtime. +// edges/repeats through the dispatch helpers. Under the Pocket Vapor compiler +// the module is never executed — the compiler recognizes button and +// relative-axis registrations from this path and emits hooks fed by each +// target runtime. // // Button values ARE the shared Pocket pad ABI. RelativeAxis values are the // hardware-neutral ABI for incremental controls: a Playdate crank, rotary @@ -34,6 +35,7 @@ export type ButtonId = (typeof Button)[keyof typeof Button]; type ButtonHandler = (button: number) => void; const handlers: ButtonHandler[] = []; +const repeatHandlers: ButtonHandler[] = []; export const RelativeAxis = { Primary: 0, @@ -66,6 +68,17 @@ export function onButton(handler: ButtonHandler): void { handlers.push(handler); } +/** + * Register for normalized D-pad repeat events after the initial press edge. + * + * The GBA host currently supplies this capability: after a short hold delay, + * it emits repeats at a fixed frame cadence. Applications decide which modes + * consume them, so a world can keep moving without making menus auto-repeat. + */ +export function onButtonRepeat(handler: ButtonHandler): void { + repeatHandlers.push(handler); +} + /** * Register a handler for signed, relative movement on one logical axis. * @@ -85,6 +98,11 @@ export function __dispatchButton(button: number): void { for (const handler of handlers) handler(button); } +/** Oracle-only: deliver one normalized held-D-pad repeat event. */ +export function __dispatchButtonRepeat(button: number): void { + for (const handler of repeatHandlers) handler(button); +} + /** Oracle-only: deliver one non-zero, integral relative-axis delta. */ export function __dispatchAxisDelta(axis: RelativeAxisId, delta: number): void { assertRelativeAxis(axis); @@ -97,5 +115,6 @@ export function __dispatchAxisDelta(axis: RelativeAxisId, delta: number): void { /** Oracle-only: drop all registered input handlers (fresh boot between tests). */ export function __resetButtons(): void { handlers.length = 0; + repeatHandlers.length = 0; axisHandlers.clear(); } diff --git a/vapor/host/rpg.ts b/vapor/host/rpg.ts new file mode 100644 index 00000000..caaadb6b --- /dev/null +++ b/vapor/host/rpg.ts @@ -0,0 +1,119 @@ +// vapor/host/rpg.ts — the Pocket Vapor RPG host contract. +// +// Like the other Vapor host modules, this file has two lives. The JS/oracle +// path uses the concrete map helpers below. The AOT compiler recognizes the +// same declarations, queries, and RpgScreen component and lowers them to the +// fixed native RPG runtime. Gameplay state never lives here: RpgScreen is a +// pure host boundary whose complete state is supplied through props. + +export interface RpgDialog { + speaker: string; + line1: string; + line2?: string; + choice0?: string; + choice1?: string; +} + +export interface RpgMapDefinition { + rows: readonly string[]; + solid: string; + events: Record; + dialogs: readonly RpgDialog[]; +} + +export interface RpgMap extends RpgMapDefinition { + readonly width: number; + readonly height: number; +} + +export interface RpgScreenProps { + map: RpgMap; + mode: number; + playerX: number; + playerY: number; + /** Signed sub-cell presentation offset in world pixels. */ + playerOffsetX: number; + /** Signed sub-cell presentation offset in world pixels. */ + playerOffsetY: number; + facing: number; + /** Zero selects the facing idle frame; 1..4 select walking frames. */ + playerFrame: number; + quest: number; + dialog: number; + choice: number; + heroHp: number; + enemyHp: number; + battleCursor: number; +} + +/** + * Declare one static, cell-addressed RPG map. + * + * GBA's logical viewport is at most 30x20 cells. Every event is attached to + * one map character so the same source drives oracle lookup and native ROM + * tables without a second coordinate convention. + */ +export function defineRpgMap(definition: RpgMapDefinition): RpgMap { + const height = definition.rows.length; + if (height === 0) throw new Error("RPG map needs at least one row"); + if (height > 20) throw new Error(`RPG map height ${height} exceeds the 20-row GBA viewport`); + + const width = definition.rows[0]!.length; + if (width === 0) throw new Error("RPG map rows must not be empty"); + if (width > 30) throw new Error(`RPG map width ${width} exceeds the 30-column GBA viewport`); + + for (let y = 1; y < height; y++) { + const rowWidth = definition.rows[y]!.length; + if (rowWidth !== width) { + throw new Error(`RPG map row ${y} has width ${rowWidth}; expected ${width}`); + } + } + + const eventEntries = Object.entries(definition.events); + if (eventEntries.length > 255) throw new Error("RPG map supports at most 255 event tile keys"); + for (const [tile, event] of eventEntries) { + if (tile.length !== 1) throw new Error(`RPG event key ${JSON.stringify(tile)} must be one character`); + if (!definition.rows.some((row) => row.includes(tile))) { + throw new Error(`RPG event key ${JSON.stringify(tile)} does not appear in the map`); + } + if (!Number.isInteger(event) || event < 1 || event > 255) { + throw new Error(`RPG event for ${JSON.stringify(tile)} must be an integer from 1 to 255`); + } + } + + if (definition.dialogs.length > 255) throw new Error("RPG map supports at most 255 dialogs"); + + return { + rows: definition.rows, + solid: definition.solid, + events: definition.events, + dialogs: definition.dialogs, + width, + height, + }; +} + +/** Out-of-bounds coordinates are solid, so movement fails closed. */ +export function rpgBlocked(map: RpgMap, x: number, y: number): boolean { + if (!Number.isInteger(x) || !Number.isInteger(y)) return true; + if (x < 0 || y < 0 || x >= map.width || y >= map.height) return true; + return map.solid.includes(map.rows[y]![x]!); +} + +/** Return the event attached to a map cell, or zero for no event. */ +export function rpgEventAt(map: RpgMap, x: number, y: number): number { + if (!Number.isInteger(x) || !Number.isInteger(y)) return 0; + if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 0; + return map.events[map.rows[y]![x]!] ?? 0; +} + +/** + * Stateless RPG render boundary. + * + * The browser oracle may replace this with a visible renderer. Pocket Vapor + * lowers it to the target's native tile/sprite renderer. Keeping the JS body + * empty is intentional: every observable gameplay input is explicit in props. + */ +export function RpgScreen(_props: RpgScreenProps): null { + return null; +} diff --git a/vapor/oracle/boot.ts b/vapor/oracle/boot.ts index 062d8992..cd3e12df 100644 --- a/vapor/oracle/boot.ts +++ b/vapor/oracle/boot.ts @@ -42,6 +42,8 @@ export interface Oracle { root: VaporElement; /** Deliver one button edge and settle vapor's scheduler. */ press(button: number): Promise; + /** Deliver one normalized held-D-pad repeat and settle the scheduler. */ + repeat(button: number): Promise; /** Deliver one relative-axis delta and settle vapor's scheduler. */ axisDelta(axis: number, delta: number): Promise; /** Current rendered grid. */ @@ -69,6 +71,7 @@ export async function bootOracle(opts: OracleOptions = {}): Promise { const hooks = globalThis as Record; const boot = hooks.__vaporBoot as (container: unknown) => { unmount(): void }; const pressHook = hooks.__vaporPress as (button: number) => void; + const repeatHook = hooks.__vaporRepeat as (button: number) => void; const axisDeltaHook = hooks.__vaporAxisDelta as (axis: number, delta: number) => void; const tick = hooks.__vaporTick as () => Promise; @@ -82,6 +85,10 @@ export async function bootOracle(opts: OracleOptions = {}): Promise { pressHook(button); await tick(); }, + async repeat(button: number) { + repeatHook(button); + await tick(); + }, async axisDelta(axis: number, delta: number) { axisDeltaHook(axis, delta); await tick(); diff --git a/vapor/oracle/entry-playdate.ts b/vapor/oracle/entry-playdate.ts index ffffc013..d873e84e 100644 --- a/vapor/oracle/entry-playdate.ts +++ b/vapor/oracle/entry-playdate.ts @@ -9,6 +9,7 @@ import TodoApp from "../examples/todo/todo.playdate.tsx"; import { __dispatchAxisDelta, __dispatchButton, + __dispatchButtonRepeat, __resetButtons, } from "../host/input.ts"; @@ -29,6 +30,10 @@ hooks.__vaporPress = (button: number): void => { __dispatchButton(button); }; +hooks.__vaporRepeat = (button: number): void => { + __dispatchButtonRepeat(button); +}; + hooks.__vaporAxisDelta = (axis: number, delta: number): void => { __dispatchAxisDelta(axis as 0 | 1, delta); }; diff --git a/vapor/oracle/entry.ts b/vapor/oracle/entry.ts index 6076a58c..f8b6ee18 100644 --- a/vapor/oracle/entry.ts +++ b/vapor/oracle/entry.ts @@ -10,6 +10,7 @@ import TodoApp from "../examples/todo/todo.tsx"; import { __dispatchAxisDelta, __dispatchButton, + __dispatchButtonRepeat, __resetButtons, } from "../host/input.ts"; @@ -30,6 +31,10 @@ hooks.__vaporPress = (button: number): void => { __dispatchButton(button); }; +hooks.__vaporRepeat = (button: number): void => { + __dispatchButtonRepeat(button); +}; + hooks.__vaporAxisDelta = (axis: number, delta: number): void => { __dispatchAxisDelta(axis as 0 | 1, delta); }; diff --git a/vapor/runtime/gba/vapor_gba.c b/vapor/runtime/gba/vapor_gba.c index 615f2127..c9243640 100644 --- a/vapor/runtime/gba/vapor_gba.c +++ b/vapor/runtime/gba/vapor_gba.c @@ -1,9 +1,9 @@ /* vapor/runtime/gba/vapor_gba.c — the GBA half of the runtime. * - * Mode 0, BG0 only: the 30x20 cell grid from vapor_core.c rendered as font - * tiles with real per-bank palettes. Cells diff at write time and mark row - * bits; the commit after vblank copies only dirty rows into the - * screenblock. The debug block is mirrored to EWRAM each frame. + * Mode 0 always keeps the 30x20 BG0 cell grid from vapor_core.c. RPG apps + * additionally enable the fixed BG1 tile-world + OBJ host; their generated + * refs still own every gameplay transition. Commits happen after vblank and + * the debug block is mirrored to EWRAM each frame. */ #include "vapor.h" @@ -14,6 +14,19 @@ #define REG_BG0HOFS REG(0x04000010) #define REG_BG0VOFS REG(0x04000012) #define REG_KEYINPUT REG(0x04000130) +#define VP_DPAD_MASK 0x00f0 +#define VP_REPEAT_DELAY_FRAMES 12 +#define VP_REPEAT_INTERVAL_FRAMES 6 +#define VP_BTN_SELECT 0x0001u +#define VP_BTN_START 0x0008u +#define VP_BTN_UP 0x0010u +#define VP_BTN_RIGHT 0x0020u +#define VP_BTN_DOWN 0x0040u +#define VP_BTN_LEFT 0x0080u +#define VP_BTN_LTRIGGER 0x0100u +#define VP_BTN_RTRIGGER 0x0200u +#define VP_BTN_CIRCLE 0x2000u +#define VP_BTN_CROSS 0x4000u #define PAL_BG ((volatile u16 *)0x05000000) #define VRAM ((volatile u16 *)0x06000000) #define SB_MAP 8 @@ -88,14 +101,36 @@ static void vsync(void) { while (REG_VCOUNT < 160) {} } +/* Translate GBA KEYINPUT positions to the public PocketJS BTN contract used + * by PocketJS framework lifecycle callbacks. Unsupported PSP face buttons + * remain clear; GBA A/B are the primary CROSS/CIRCLE actions. */ +static u32 framework_buttons(u16 held) { + u32 buttons = 0; + if (held & (1 << 0)) buttons |= VP_BTN_CROSS; + if (held & (1 << 1)) buttons |= VP_BTN_CIRCLE; + if (held & (1 << 2)) buttons |= VP_BTN_SELECT; + if (held & (1 << 3)) buttons |= VP_BTN_START; + if (held & (1 << 4)) buttons |= VP_BTN_RIGHT; + if (held & (1 << 5)) buttons |= VP_BTN_LEFT; + if (held & (1 << 6)) buttons |= VP_BTN_UP; + if (held & (1 << 7)) buttons |= VP_BTN_DOWN; + if (held & (1 << 8)) buttons |= VP_BTN_RTRIGGER; + if (held & (1 << 9)) buttons |= VP_BTN_LTRIGGER; + return buttons; +} + int main(void) { u16 i; u16 prev_keys = 0x03ff; + u8 repeat_frames[10] = { 0 }; u32 frame = 0, flushes = 0; for (i = 0; i < (u16)(vp_palette_count * 16); i++) PAL_BG[i] = vp_palettes[i]; PAL_BG[0] = vp_backdrop; upload_font(); +#if defined(VP_ENABLE_RPG) + if (vp_rpg_enabled) vp_rpg_video_init(); +#endif REG_BG0CNT = (SB_MAP << 8) | 0; /* 4bpp, charblock 0, priority 0 */ REG_BG0HOFS = 0; REG_BG0VOFS = 0; @@ -105,21 +140,44 @@ int main(void) { app_flush(); flushes++; - REG_DISPCNT = 0x0100; /* mode 0, BG0 on */ + /* RPG: mode 0 + 1D OBJ mapping + BG0/BG1/OBJ. Text apps remain BG0-only. */ +#if defined(VP_ENABLE_RPG) + REG_DISPCNT = vp_rpg_enabled ? 0x1340 : 0x0100; +#else + REG_DISPCNT = 0x0100; +#endif for (;;) { - u16 keys, edges; + u16 keys, edges, held; u8 b; vsync(); commit_rows(); +#if defined(VP_ENABLE_RPG) + if (vp_rpg_enabled) vp_rpg_video_commit(); +#endif frame++; debug_commit(frame, flushes); keys = (u16)(REG_KEYINPUT & 0x03ff); edges = (u16)(prev_keys & ~keys); /* KEYINPUT is active-low */ + held = (u16)(~keys & 0x03ff); prev_keys = keys; - for (b = 0; b < 10; b++) - if (edges & (u16)(1 << b)) app_on_button(b); + for (b = 0; b < 10; b++) { + u16 bit = (u16)(1 << b); + if (edges & bit) { + app_on_button(b); + repeat_frames[b] = (bit & VP_DPAD_MASK) ? VP_REPEAT_DELAY_FRAMES : 0; + } else if ((held & bit) && repeat_frames[b]) { + repeat_frames[b]--; + if (!repeat_frames[b]) { + app_on_button_repeat(b); + repeat_frames[b] = VP_REPEAT_INTERVAL_FRAMES; + } + } else { + repeat_frames[b] = 0; + } + } + app_on_frame(framework_buttons(held)); if (app_flush()) flushes++; } } diff --git a/vapor/runtime/gba/vapor_rpg.c b/vapor/runtime/gba/vapor_rpg.c new file mode 100644 index 00000000..df9e9863 --- /dev/null +++ b/vapor/runtime/gba/vapor_rpg.c @@ -0,0 +1,569 @@ +/* vapor/runtime/gba/vapor_rpg.c -- fixed GBA pixel host for Pocket Vapor RPGs. + * + * Gameplay remains compiler-generated reactive C. This file owns only ROM + * map queries and a small Mode 0 renderer: BG1 is the pixel world, BG0 is the + * existing (now transparent) Vapor font, and OBJ contains the actors. + */ +#include "vapor.h" +#include "vapor_rpg_assets.generated.h" + +#define REG16(addr) (*(volatile u16 *)(addr)) +#define REG32(addr) (*(volatile u32 *)(addr)) +#define REG_DISPCNT REG16(0x04000000) +#define REG_BG1CNT REG16(0x0400000a) +#define REG_BG1HOFS REG16(0x04000014) +#define REG_BG1VOFS REG16(0x04000016) +#define REG_WIN0H REG16(0x04000040) +#define REG_WIN0V REG16(0x04000044) +#define REG_WININ REG16(0x04000048) +#define REG_WINOUT REG16(0x0400004a) +#define REG_DMA3SAD REG32(0x040000d4) +#define REG_DMA3DAD REG32(0x040000d8) +#define REG_DMA3CNT_L REG16(0x040000dc) +#define REG_DMA3CNT_H REG16(0x040000de) + +#define PAL_BG ((volatile u16 *)0x05000000) +#define PAL_OBJ ((volatile u16 *)0x05000200) +#define FONT_VRAM ((volatile u16 *)0x06000000) +#define RPG_BG_VRAM ((volatile u16 *)0x06008000) /* charblock 2 */ +#define RPG_BG_MAP ((volatile u16 *)0x06004800) /* screenblock 9 */ +#define RPG_OBJ_VRAM ((volatile u16 *)0x06010000) +#define OAM ((volatile u16 *)0x07000000) +/* Presentation receipt after the shared grid debug mirrors (0x20005b8 end). */ +#define RPG_DEBUG_SCROLL_X (*(volatile u16 *)0x020005c0) +#define RPG_DEBUG_SCROLL_Y (*(volatile u16 *)0x020005c2) + +#define RPG_BG_BANK 15 +#define RPG_ENTRY(tile) ((u16)((tile) | (RPG_BG_BANK << 12))) +#define RPG_OBJ_PRIORITY (1 << 10) +#define RPG_WORLD_CELL_PX 16 +#define RPG_VIEW_W 15 +#define RPG_VIEW_H 10 +#define RPG_BUFFER_W 16 +#define RPG_BUFFER_H 11 +#define RPG_FOCUS_X 7 +#define RPG_FOCUS_Y 4 + +enum { + WORLD_BLANK, + WORLD_GRASS_A, + WORLD_GRASS_B, + WORLD_PATH_A, + WORLD_PATH_B, + WORLD_WALL, + WORLD_WATER_A, + WORLD_WATER_B, + WORLD_TREE, + WORLD_FLOWER, + WORLD_TILE_COUNT +}; + +enum { + TILE_BOX_FILL = VP_RPG_UI_TILE_BASE, + TILE_BOX_TOP, + TILE_BOX_BOTTOM, + TILE_BOX_LEFT, + TILE_BOX_RIGHT, + TILE_BOX_TL, + TILE_BOX_TR, + TILE_BOX_BL, + TILE_BOX_BR, + TILE_BATTLE_SKY, + TILE_BATTLE_GROUND, + TILE_HP_EMPTY, + TILE_HP_FULL, + TILE_HUD, + TILE_COUNT +}; + +typedef char vp_rpg_bg_tile_count_must_match[ + TILE_COUNT == VP_RPG_BG_TILE_COUNT ? 1 : -1]; +typedef char vp_rpg_world_tile_count_must_match[ + WORLD_TILE_COUNT == VP_RPG_WORLD_TILE_FRAME_COUNT ? 1 : -1]; +typedef char vp_rpg_walk_direction_count_must_match[ + VP_RPG_WORLD_WALK_DIRECTION_COUNT == 4 ? 1 : -1]; +typedef char vp_rpg_walk_frame_count_must_match[ + VP_RPG_WORLD_WALK_FRAMES == 4 ? 1 : -1]; + +static u16 rpg_bg_shadow[32 * 32]; +static u16 rpg_oam_shadow[128 * 4]; +static const vp_rpg_map *rpg_world_cache_map; +static s32 rpg_world_cache_x; +static s32 rpg_world_cache_y; +static u16 rpg_dispcnt_shadow; +static u16 rpg_bg1_hofs_shadow; +static u16 rpg_bg1_vofs_shadow; +static u16 rpg_win0h_shadow; +static u16 rpg_win0v_shadow; +static u16 rpg_winin_shadow; +static u16 rpg_winout_shadow; +static u16 rpg_dma_fill_value; +static s32 rpg_ui_mode; +static s32 rpg_ui_quest; +static s32 rpg_ui_dialog; +static s32 rpg_ui_choice; +static s32 rpg_ui_hero_hp; +static s32 rpg_ui_enemy_hp; +static s32 rpg_ui_battle_cursor; +static u8 rpg_bg_dirty; +static u8 rpg_oam_dirty; +static u8 rpg_oam_visible_slots; +static u8 rpg_oam_commit_slots; +static u8 rpg_registers_dirty; +static u8 rpg_world_cache_valid; +static u8 rpg_ui_cache_valid; +static u8 rpg_ready; + +static void stage_video(u16 dispcnt, u16 win0h, u16 win0v, + u16 winin, u16 winout) { + rpg_dispcnt_shadow = dispcnt; + rpg_win0h_shadow = win0h; + rpg_win0v_shadow = win0v; + rpg_winin_shadow = winin; + rpg_winout_shadow = winout; + rpg_registers_dirty = 1; +} + +static void stage_scroll(u16 x, u16 y) { + rpg_bg1_hofs_shadow = x; + rpg_bg1_vofs_shadow = y; + rpg_registers_dirty = 1; +} + +static void dma3_copy16(const void *source, volatile void *target, + u16 halfwords) { + if (!halfwords) return; + REG_DMA3CNT_H = 0; + REG_DMA3SAD = (u32)source; + REG_DMA3DAD = (u32)target; + REG_DMA3CNT_L = halfwords; + REG_DMA3CNT_H = 0x8000; /* immediate, incrementing, 16-bit transfer */ +} + +static void dma3_fill16(u16 value, void *target, u16 halfwords) { + if (!halfwords) return; + rpg_dma_fill_value = value; + REG_DMA3CNT_H = 0; + REG_DMA3SAD = (u32)&rpg_dma_fill_value; + REG_DMA3DAD = (u32)target; + REG_DMA3CNT_L = halfwords; + REG_DMA3CNT_H = 0x8100; /* immediate, fixed source, 16-bit transfer */ +} + +static void upload_transparent_font(void) { + volatile u16 *dst = FONT_VRAM + 16; /* tile zero remains transparent */ + u16 i; + for (i = 0; i < 95 * 16; i++) { + u8 a = vp_font_tiles[(u16)i * 2]; + u8 b = vp_font_tiles[(u16)i * 2 + 1]; + u8 lo = (u8)(a & 15); + u8 hi = (u8)(a >> 4); + if (lo == 2) lo = 0; + if (hi == 2) hi = 0; + a = (u8)(lo | (hi << 4)); + lo = (u8)(b & 15); + hi = (u8)(b >> 4); + if (lo == 2) lo = 0; + if (hi == 2) hi = 0; + b = (u8)(lo | (hi << 4)); + dst[i] = (u16)(a | ((u16)b << 8)); + } +} + +static void reset_objects(void) { + u16 i; + for (i = 0; i < 128; i++) { + rpg_oam_shadow[i * 4] = 0x0200; + rpg_oam_shadow[i * 4 + 1] = 0; + rpg_oam_shadow[i * 4 + 2] = 0; + rpg_oam_shadow[i * 4 + 3] = 0; + } + rpg_oam_visible_slots = 0; + rpg_oam_commit_slots = 128; + rpg_oam_dirty = 1; +} + +static void finish_objects(u8 visible) { + u8 i; + u8 previous = rpg_oam_visible_slots; + for (i = visible; i < previous; i++) { + u16 at = (u16)i * 4; + rpg_oam_shadow[at] = 0x0200; + rpg_oam_shadow[at + 1] = 0; + rpg_oam_shadow[at + 2] = 0; + rpg_oam_shadow[at + 3] = 0; + } + { + u8 required = visible > previous ? visible : previous; + if (required > rpg_oam_commit_slots) rpg_oam_commit_slots = required; + } + rpg_oam_visible_slots = visible; + rpg_oam_dirty = 1; +} + +static void show_object_32(u8 slot, s16 x, s16 y, u16 tile, u8 palette) { + u16 at = (u16)slot * 4; + rpg_oam_shadow[at] = (u16)(y & 0x00ff); /* square, regular OBJ */ + rpg_oam_shadow[at + 1] = (u16)((x & 0x01ff) | 0x8000); /* 32x32 */ + rpg_oam_shadow[at + 2] = (u16)((tile & 0x03ff) | RPG_OBJ_PRIORITY | + ((u16)palette << 12)); + rpg_oam_shadow[at + 3] = 0; + rpg_oam_dirty = 1; +} + +static void show_object_64(u8 slot, s16 x, s16 y, u16 tile, u8 palette) { + u16 at = (u16)slot * 4; + rpg_oam_shadow[at] = (u16)(y & 0x00ff); /* square, regular OBJ */ + rpg_oam_shadow[at + 1] = (u16)((x & 0x01ff) | 0xc000); /* 64x64 */ + rpg_oam_shadow[at + 2] = (u16)((tile & 0x03ff) | RPG_OBJ_PRIORITY | + ((u16)palette << 12)); + rpg_oam_shadow[at + 3] = 0; + rpg_oam_dirty = 1; +} + +static void map_fill(u8 tile) { + u16 entry = RPG_ENTRY(tile); + dma3_fill16(entry, rpg_bg_shadow, 32 * 32); + rpg_bg_dirty = 1; +} + +static void map_cell(u8 x, u8 y, u8 tile) { + if (x < 32 && y < 32) { + rpg_bg_shadow[(u16)y * 32 + x] = RPG_ENTRY(tile); + rpg_bg_dirty = 1; + } +} + +static void map_world_cell(u8 x, u8 y, u8 frame) { + u8 tile = (u8)(frame * VP_RPG_WORLD_TILE_FRAME_TILES); + map_cell(x, y, tile); + map_cell((u8)(x + 1), y, (u8)(tile + 1)); + map_cell(x, (u8)(y + 1), (u8)(tile + 2)); + map_cell((u8)(x + 1), (u8)(y + 1), (u8)(tile + 3)); +} + +static u8 world_tile(u8 ch, u8 x, u8 y) { + if (ch == '#') return WORLD_WALL; + if (ch == '=' || ch == ':' || ch == 'p') + return ((x + y) & 1) ? WORLD_PATH_A : WORLD_PATH_B; + if (ch == '~') return ((x + y) & 1) ? WORLD_WATER_A : WORLD_WATER_B; + if (ch == 'T' || ch == 't') return WORLD_TREE; + if (ch == '*') return WORLD_FLOWER; + return ((x + y) & 1) ? WORLD_GRASS_A : WORLD_GRASS_B; +} + +static void line_text(u8 row, u8 x, const char *text) { + vp_ln_reset(); + if (text) vp_ln_str(text); + vp_ln_commit(row, x, 0, VP_ALIGN_LEFT); +} + +static void line_hp(u8 row, u8 x, s32 hp, s32 max_hp) { + vp_ln_reset(); + vp_ln_str("HP "); + vp_ln_int(hp); + vp_ln_ch('/'); + vp_ln_int(max_hp); + vp_ln_commit(row, x, 0, VP_ALIGN_LEFT); +} + +static void line_choice(u8 row, u8 x, const char *text, u8 selected) { + vp_ln_reset(); + vp_ln_ch(selected ? '>' : ' '); + vp_ln_ch(' '); + if (text) vp_ln_str(text); + vp_ln_commit(row, x, 0, VP_ALIGN_LEFT); +} + +static void draw_box(u8 left, u8 top, u8 right, u8 bottom) { + u8 x, y; + map_cell(left, top, TILE_BOX_TL); + map_cell(right, top, TILE_BOX_TR); + map_cell(left, bottom, TILE_BOX_BL); + map_cell(right, bottom, TILE_BOX_BR); + for (x = (u8)(left + 1); x < right; x++) { + map_cell(x, top, TILE_BOX_TOP); + map_cell(x, bottom, TILE_BOX_BOTTOM); + } + for (y = (u8)(top + 1); y < bottom; y++) { + map_cell(left, y, TILE_BOX_LEFT); + map_cell(right, y, TILE_BOX_RIGHT); + for (x = (u8)(left + 1); x < right; x++) + map_cell(x, y, TILE_BOX_FILL); + } +} + +static s32 camera_axis_px(s32 player_px, s32 map_size, s32 view_size, + s32 focus) { + s32 camera; + s32 maximum; + if (map_size <= view_size) return 0; + maximum = (map_size - view_size) * RPG_WORLD_CELL_PX; + camera = player_px - focus * RPG_WORLD_CELL_PX; + if (camera < 0) return 0; + if (camera > maximum) return maximum; + return camera; +} + +static void draw_world(const vp_rpg_map *map, s32 player_x, s32 player_y, + s32 player_offset_x, s32 player_offset_y, + u8 facing, u8 player_frame, s32 quest, u8 hud) { + u8 x, y, ox, oy, slot; + s32 cam_px, cam_py, cam_x, cam_y, world_y, end_y; + s32 player_px, player_py, player_sort_y; + if (!map || !map->tiles || !map->width || !map->height) { + finish_objects(0); + return; + } + if (player_offset_x < -15) player_offset_x = -15; + if (player_offset_x > 15) player_offset_x = 15; + if (player_offset_y < -15) player_offset_y = -15; + if (player_offset_y > 15) player_offset_y = 15; + player_px = player_x * RPG_WORLD_CELL_PX + player_offset_x; + player_py = player_y * RPG_WORLD_CELL_PX + player_offset_y; + cam_px = camera_axis_px(player_px, map->width, RPG_VIEW_W, RPG_FOCUS_X); + cam_py = camera_axis_px(player_py, map->height, RPG_VIEW_H, RPG_FOCUS_Y); + cam_x = cam_px / RPG_WORLD_CELL_PX; + cam_y = cam_py / RPG_WORLD_CELL_PX; + stage_scroll((u16)(cam_px - cam_x * RPG_WORLD_CELL_PX), + (u16)(cam_py - cam_y * RPG_WORLD_CELL_PX)); + ox = map->width < RPG_VIEW_W ? (u8)((RPG_VIEW_W - map->width) >> 1) : 0; + oy = map->height < RPG_VIEW_H ? (u8)((RPG_VIEW_H - map->height) >> 1) : 0; + if (!rpg_world_cache_valid || rpg_world_cache_map != map || + rpg_world_cache_x != cam_x || rpg_world_cache_y != cam_y) { + map_fill(0); + for (y = (u8)cam_y; y < map->height; y++) { + u8 screen_y = (u8)(oy + y - cam_y); + if (screen_y >= RPG_BUFFER_H) break; + for (x = (u8)cam_x; x < map->width; x++) { + u8 screen_x = (u8)(ox + x - cam_x); + u16 at; + if (screen_x >= RPG_BUFFER_W) break; + at = (u16)y * map->width + x; + map_world_cell((u8)(screen_x * 2), (u8)(screen_y * 2), + world_tile(map->tiles[at], x, y)); + } + } + rpg_world_cache_map = map; + rpg_world_cache_x = cam_x; + rpg_world_cache_y = cam_y; + rpg_world_cache_valid = 1; + } + slot = 0; + end_y = cam_y + RPG_BUFFER_H - oy; + if (end_y > map->height) end_y = map->height; + player_sort_y = player_py / RPG_WORLD_CELL_PX; + for (world_y = end_y; world_y > cam_y && slot < 127;) { + s16 actor_y; + world_y--; + actor_y = (s16)(oy * RPG_WORLD_CELL_PX + + world_y * RPG_WORLD_CELL_PX - cam_py - 16); + if (player_x >= 0 && player_x < map->width && + player_y >= 0 && player_y < map->height && + player_sort_y == world_y) { + u16 hero_tile = 0; + s16 actor_x = (s16)(ox * RPG_WORLD_CELL_PX + player_px - cam_px - 8); + s16 hero_y = (s16)(oy * RPG_WORLD_CELL_PX + player_py - cam_py - 16); + if (facing < VP_RPG_WORLD_WALK_DIRECTION_COUNT) { + hero_tile = (u16)(facing * VP_RPG_WORLD_ACTOR_FRAME_TILES); + if (player_frame > 0 && player_frame <= VP_RPG_WORLD_WALK_FRAMES) { + hero_tile = (u16)(VP_RPG_WORLD_WALK_TILE_BASE + + ((u16)facing * VP_RPG_WORLD_WALK_FRAMES + player_frame - 1) * + VP_RPG_WORLD_ACTOR_FRAME_TILES); + } + } + if (actor_x > -32 && actor_x < 240 && hero_y > -32 && hero_y < 160) + show_object_32(slot++, actor_x, hero_y, hero_tile, 0); + } + for (x = 0; x < map->width && slot < 127; x++) { + s16 actor_x = (s16)(ox * RPG_WORLD_CELL_PX + + (s32)x * RPG_WORLD_CELL_PX - cam_px - 8); + u8 ch; + if (actor_x <= -32 || actor_x >= 240 || actor_y <= -32 || actor_y >= 160) + continue; + ch = map->tiles[(u16)world_y * map->width + x]; + if (ch == 'N') { + show_object_32(slot++, actor_x, actor_y, + VP_RPG_WORLD_ELDER_TILE, 1); + } else if (ch == 'S' && quest == 1) { + show_object_32(slot++, actor_x, actor_y, + VP_RPG_WORLD_SLIME_TILE, 2); + } + } + } + if (hud) { + if (quest == 0) line_text(0, 1, "QUEST: TALK TO THE ELDER"); + else if (quest == 1) line_text(0, 1, "QUEST: DEFEAT THE SLIME"); + else if (quest == 2) line_text(0, 1, "QUEST: RETURN TO THE ELDER"); + else line_text(0, 1, "QUEST COMPLETE: VILLAGE SAVED"); + } + finish_objects(slot); +} + +static void draw_dialog(const vp_rpg_map *map, s32 player_x, s32 player_y, + s32 player_offset_x, s32 player_offset_y, + u8 facing, u8 player_frame, s32 quest, + s32 dialog, s32 choice) { + const vp_rpg_dialog *d; + draw_world(map, player_x, player_y, player_offset_x, player_offset_y, + facing, player_frame, quest, 0); + draw_box(1, 11, 28, 19); + rpg_world_cache_valid = 0; + if (!map || !map->dialogs || dialog < 1 || dialog > map->dialog_count) { + line_text(15, 3, "..."); + return; + } + d = &map->dialogs[dialog - 1]; + line_text(12, 3, d->speaker); + line_text(14, 3, d->line1); + line_text(15, 3, d->line2); + if (d->choice0 && d->choice0[0]) + line_choice(17, 3, d->choice0, choice == 0); + if (d->choice1 && d->choice1[0]) + line_choice(18, 3, d->choice1, choice == 1); +} + +static u8 hp_tiles(s32 hp, s32 max_hp) { + if (hp <= 0 || max_hp <= 0) return 0; + if (hp >= max_hp) return 10; + return (u8)((hp * 10 + max_hp - 1) / max_hp); +} + +static void draw_battle(s32 hero_hp, s32 enemy_hp, s32 cursor) { + u8 x, y, full; + stage_scroll(0, 0); + rpg_world_cache_valid = 0; + for (y = 0; y < 32; y++) + for (x = 0; x < 32; x++) + rpg_bg_shadow[(u16)y * 32 + x] = + RPG_ENTRY(y < 11 ? TILE_BATTLE_SKY : TILE_BATTLE_GROUND); + rpg_bg_dirty = 1; + full = hp_tiles(enemy_hp, 18); + for (x = 0; x < 10; x++) + map_cell((u8)(2 + x), 4, x < full ? TILE_HP_FULL : TILE_HP_EMPTY); + full = hp_tiles(hero_hp, 30); + for (x = 0; x < 10; x++) + map_cell((u8)(18 + x), 13, x < full ? TILE_HP_FULL : TILE_HP_EMPTY); + draw_box(2, 14, 27, 19); + show_object_64(0, 8, 40, VP_RPG_BATTLE_HERO_TILE, 0); + show_object_64(1, 168, 16, VP_RPG_BATTLE_SLIME_TILE, 2); + finish_objects(2); + line_text(1, 2, "WILD SLIME"); + line_hp(2, 2, enemy_hp, 18); + line_text(10, 18, "HERO"); + line_hp(11, 18, hero_hp, 30); + line_choice(15, 4, "ATTACK", cursor == 0); + line_choice(17, 4, "HEAL", cursor == 1); +} + +u8 vp_rpg_blocked(const vp_rpg_map *map, s32 x, s32 y) { + u32 at; + if (!map || x < 0 || y < 0 || x >= map->width || y >= map->height) + return 1; + if (!map->solid) return 0; + at = (u32)y * map->width + (u32)x; + return map->solid[at] ? 1 : 0; +} + +u8 vp_rpg_event_at(const vp_rpg_map *map, s32 x, s32 y) { + u8 tile, i; + u32 at; + if (!map || !map->tiles || !map->events || x < 0 || y < 0 || + x >= map->width || y >= map->height) + return 0; + at = (u32)y * map->width + (u32)x; + tile = map->tiles[at]; + for (i = 0; i < map->event_count; i++) + if (map->events[i].tile == tile) return map->events[i].event; + return 0; +} + +void vp_rpg_video_init(void) { + u16 i; + upload_transparent_font(); + for (i = 0; i < 16; i++) { + PAL_BG[RPG_BG_BANK * 16 + i] = vp_rpg_bg_palette[i]; + } + for (i = 0; i < 3 * 16; i++) PAL_OBJ[i] = vp_rpg_obj_palettes[i]; + for (i = 0; i < VP_RPG_BG_TILE_COUNT * 16; i++) + RPG_BG_VRAM[i] = vp_rpg_bg_tiles[i]; + for (i = 0; i < VP_RPG_OBJ_TILE_COUNT * 16; i++) + RPG_OBJ_VRAM[i] = vp_rpg_obj_tiles[i]; + map_fill(0); + reset_objects(); + REG_BG1CNT = (u16)(2 | (2 << 2) | (9 << 8)); + stage_scroll(0, 0); + stage_video(0x1340, 0, 0, 0, 0); + rpg_ready = 1; +} + +void vp_rpg_render(const vp_rpg_map *map, u8 mode, s32 player_x, + s32 player_y, s32 player_offset_x, + s32 player_offset_y, u8 facing, u8 player_frame, + s32 quest, s32 dialog, s32 choice, s32 hero_hp, + s32 enemy_hp, s32 battle_cursor) { + u8 ui_changed; + if (!rpg_ready) return; + ui_changed = !rpg_ui_cache_valid || rpg_ui_mode != mode || + rpg_ui_quest != quest || rpg_ui_dialog != dialog || + rpg_ui_choice != choice || rpg_ui_hero_hp != hero_hp || + rpg_ui_enemy_hp != enemy_hp || + rpg_ui_battle_cursor != battle_cursor; + if (ui_changed) { + vp_row_clear(0, VP_GRID_H); + rpg_ui_mode = mode; + rpg_ui_quest = quest; + rpg_ui_dialog = dialog; + rpg_ui_choice = choice; + rpg_ui_hero_hp = hero_hp; + rpg_ui_enemy_hp = enemy_hp; + rpg_ui_battle_cursor = battle_cursor; + rpg_ui_cache_valid = 1; + } + if (mode == 1) { + /* Keep enlarged OBJ heads visible above the dialog, but clip their lower + * pixels at its y=88 edge so arbitrary maps cannot draw actors over UI. */ + stage_video(0x3340, (u16)((0 << 8) | 240), + (u16)((88 << 8) | 160), + 0x0003, 0x0013); + draw_dialog(map, player_x, player_y, player_offset_x, player_offset_y, + facing, player_frame, quest, dialog, choice); + } else if (mode == 2) { + stage_video(0x1340, 0, 0, 0, 0); + draw_battle(hero_hp, enemy_hp, battle_cursor); + } else { + /* The map scrolls behind a fixed 8px HUD. WIN0 exposes only BG0 and the + * backdrop in that strip, so neither world tiles nor actors can drift + * through the screen-space status line. */ + stage_video(0x3340, (u16)((0 << 8) | 240), + (u16)((0 << 8) | 8), + 0x0001, 0x0013); + draw_world(map, player_x, player_y, player_offset_x, player_offset_y, + facing, player_frame, quest, ui_changed); + } +} + +void vp_rpg_video_commit(void) { + if (!rpg_ready) return; + if (rpg_registers_dirty) { + REG_BG1HOFS = rpg_bg1_hofs_shadow; + REG_BG1VOFS = rpg_bg1_vofs_shadow; + REG_WIN0H = rpg_win0h_shadow; + REG_WIN0V = rpg_win0v_shadow; + REG_WININ = rpg_winin_shadow; + REG_WINOUT = rpg_winout_shadow; + REG_DISPCNT = rpg_dispcnt_shadow; + RPG_DEBUG_SCROLL_X = rpg_bg1_hofs_shadow; + RPG_DEBUG_SCROLL_Y = rpg_bg1_vofs_shadow; + rpg_registers_dirty = 0; + } + if (rpg_bg_dirty) { + dma3_copy16(rpg_bg_shadow, RPG_BG_MAP, 32 * RPG_BUFFER_H * 2); + rpg_bg_dirty = 0; + } + if (rpg_oam_dirty) { + dma3_copy16(rpg_oam_shadow, OAM, (u16)rpg_oam_commit_slots * 4); + rpg_oam_commit_slots = 0; + rpg_oam_dirty = 0; + } +} diff --git a/vapor/runtime/gba/vapor_rpg_assets.generated.h b/vapor/runtime/gba/vapor_rpg_assets.generated.h new file mode 100644 index 00000000..dff53b40 --- /dev/null +++ b/vapor/runtime/gba/vapor_rpg_assets.generated.h @@ -0,0 +1,1110 @@ +/* Generated by vapor/scripts/rpg-assets.ts. Do not edit by hand. */ +#ifndef VP_RPG_ASSETS_GENERATED_H +#define VP_RPG_ASSETS_GENERATED_H + +#define VP_RPG_WORLD_TILE_FRAME_COUNT 10 +#define VP_RPG_WORLD_TILE_FRAME_TILES 4 +#define VP_RPG_UI_TILE_COUNT 14 +#define VP_RPG_UI_TILE_BASE (VP_RPG_WORLD_TILE_FRAME_COUNT * VP_RPG_WORLD_TILE_FRAME_TILES) +#define VP_RPG_BG_TILE_COUNT (VP_RPG_UI_TILE_BASE + VP_RPG_UI_TILE_COUNT) +#define VP_RPG_WORLD_ACTOR_FRAME_TILES 16 +#define VP_RPG_WORLD_STATIC_ACTOR_FRAME_COUNT 6 +#define VP_RPG_WORLD_WALK_DIRECTION_COUNT 4 +#define VP_RPG_WORLD_WALK_FRAMES 4 +#define VP_RPG_WORLD_WALK_TILE_BASE (VP_RPG_WORLD_STATIC_ACTOR_FRAME_COUNT * VP_RPG_WORLD_ACTOR_FRAME_TILES) +#define VP_RPG_WORLD_ACTOR_FRAME_COUNT (VP_RPG_WORLD_STATIC_ACTOR_FRAME_COUNT + VP_RPG_WORLD_WALK_DIRECTION_COUNT * VP_RPG_WORLD_WALK_FRAMES) +#define VP_RPG_WORLD_ELDER_TILE (4 * VP_RPG_WORLD_ACTOR_FRAME_TILES) +#define VP_RPG_WORLD_SLIME_TILE (5 * VP_RPG_WORLD_ACTOR_FRAME_TILES) +#define VP_RPG_BATTLE_HERO_TILE (VP_RPG_WORLD_ACTOR_FRAME_COUNT * VP_RPG_WORLD_ACTOR_FRAME_TILES) +#define VP_RPG_BATTLE_SLIME_TILE (VP_RPG_BATTLE_HERO_TILE + 64) +#define VP_RPG_OBJ_TILE_COUNT (VP_RPG_BATTLE_SLIME_TILE + 64) + +static const u16 vp_rpg_bg_palette[16] = { + 0x0000, 0x24a3, 0x21e4, 0x2f08, 0x1570, 0x2a78, 0x3149, 0x5693, + 0x4923, 0x7247, 0x1544, 0x2285, 0x235f, 0x30c4, 0x4b7d, 0x295f, +}; + +static const u16 vp_rpg_obj_palettes[48] = { + 0x0000, 0x24a3, 0x21b8, 0x32bf, 0x5544, 0x7e08, 0x7ed0, 0x1916, + 0x25ff, 0x4b7d, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x24a3, 0x21b8, 0x32bf, 0x3149, 0x45ee, 0x5693, 0x6f58, + 0x7fff, 0x7e08, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x24a3, 0x2d60, 0x56a0, 0x6347, 0x6fd2, 0x7fff, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, +}; + +static const u16 vp_rpg_bg_tiles[864] = { + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3233, 0x3333, + 0xa333, 0x3333, 0x3333, 0xa333, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x3333, 0x2333, 0x3333, 0x3333, 0x333a, 0x3333, 0x3333, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3a33, 0x3333, 0xaaa3, + 0x3333, 0x3323, 0x3333, 0x3a33, 0x3333, 0x3333, 0x3333, 0x3333, + 0xa333, 0x3333, 0xa333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x33b3, 0x3a3b, 0x3333, 0x3333, 0x323b, 0x3333, 0xb333, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3332, 0x3333, 0x33a3, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3332, 0xa333, 0x3333, + 0x3333, 0x3333, 0x333a, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x333a, 0x3333, 0x333a, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x3333, 0x3a33, 0xb3a3, 0xb323, 0x3333, 0x333b, 0x3333, + 0x3333, 0x3333, 0x3333, 0x3333, 0x33a3, 0x3333, 0x3a2a, 0x3333, + 0x3333, 0x333b, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, + 0x5555, 0x5555, 0x5555, 0x5555, 0x4555, 0x5555, 0x4554, 0x4444, + 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5554, 0x5554, + 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x554e, 0x5554, + 0x4455, 0x5554, 0x5555, 0x5555, 0x5555, 0x5554, 0x5555, 0x4555, + 0x5555, 0x5555, 0x5e55, 0x5555, 0x5555, 0x5555, 0x4555, 0x5455, + 0x5555, 0x5555, 0x55ee, 0xe555, 0x555e, 0xe554, 0x5545, 0x5555, + 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5544, 0x5555, + 0x5555, 0x55e5, 0x55ee, 0xe555, 0x55ee, 0x5455, 0x5555, 0x5555, + 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x555e, 0x4555, 0x4444, + 0x4555, 0x5544, 0x5555, 0x5545, 0x5555, 0x5555, 0x5554, 0x5555, + 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5554, 0x5444, 0x4555, + 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x4555, 0x4555, + 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5455, 0x4455, + 0x5555, 0x5555, 0x555e, 0xee55, 0x5555, 0xee55, 0x5555, 0x5555, + 0xe555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5554, + 0x5555, 0x5555, 0x555e, 0xee55, 0x445e, 0xe555, 0x5555, 0x5455, + 0x6666, 0x6666, 0x77e6, 0x6667, 0x7766, 0x6667, 0x7777, 0x6777, + 0x7777, 0x6777, 0x6666, 0x6666, 0x6777, 0x7777, 0x6777, 0x7777, + 0x7666, 0x6666, 0x7666, 0x6677, 0x7776, 0x7777, 0x7777, 0x7777, + 0x7777, 0x7777, 0x6666, 0x6666, 0x7777, 0x7777, 0x77e7, 0x7777, + 0x6777, 0x7777, 0x6777, 0x7777, 0x6777, 0x7777, 0x6666, 0x6666, + 0x7777, 0x7777, 0xee77, 0x7777, 0xee77, 0x7777, 0x7777, 0x7777, + 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, 0x77ee, 0x6666, 0x6666, + 0x7777, 0x7776, 0xe777, 0x7ee6, 0xe777, 0x76e6, 0x7777, 0x7776, + 0x8899, 0x9988, 0x9999, 0x9999, 0x9999, 0x9999, 0x8889, 0x9888, + 0x9899, 0x9999, 0x9999, 0x9999, 0x9998, 0x9889, 0x9999, 0x9999, + 0x9999, 0x9899, 0x9999, 0x9999, 0x9989, 0x999d, 0x9989, 0x9999, + 0x8999, 0x9899, 0x9999, 0x9999, 0x9989, 0x9999, 0x9999, 0x9999, + 0x8899, 0x9999, 0x9999, 0x9999, 0x8889, 0x9888, 0x9999, 0x9889, + 0x9899, 0x999d, 0x9999, 0x9999, 0xdd99, 0x988d, 0x9999, 0x9999, + 0x8899, 0x9888, 0x9999, 0x9999, 0x9999, 0x9999, 0x9989, 0x9999, + 0x8999, 0x9899, 0x9999, 0x99dd, 0xd999, 0x99dd, 0x8899, 0x9888, + 0x8989, 0x9888, 0x9999, 0x9999, 0x9999, 0x9899, 0x9999, 0x9899, + 0xd989, 0x9998, 0x9999, 0x9999, 0x8889, 0x9888, 0x9999, 0x9999, + 0x9999, 0x9999, 0x9999, 0x9999, 0x8899, 0x9888, 0x9999, 0x9999, + 0x9999, 0x9989, 0x9999, 0x9999, 0x9889, 0x8999, 0x9999, 0x9999, + 0x8989, 0x9998, 0x9999, 0x9999, 0x9999, 0x9999, 0x9999, 0x9899, + 0x9989, 0x9998, 0x8889, 0x9888, 0xdd99, 0x999d, 0x9999, 0x9999, + 0x9999, 0x9988, 0x9999, 0x9999, 0x9989, 0x9999, 0x8889, 0x988d, + 0x9999, 0x9989, 0x9999, 0x99dd, 0xd889, 0x99dd, 0x9999, 0x9999, + 0x3333, 0x3333, 0x3333, 0xa333, 0x3333, 0xaaa3, 0x3333, 0xabaa, + 0xa333, 0xaaba, 0xb333, 0xbaaa, 0xa333, 0xaaba, 0xba33, 0xbaaa, + 0x33ab, 0x3333, 0x3baa, 0x3333, 0xaaab, 0x3333, 0xabaa, 0x333a, + 0xaaab, 0x33ab, 0xabaa, 0x3baa, 0xaaba, 0x3aab, 0xbaaa, 0x3baa, + 0xaba3, 0xaaba, 0xa333, 0xbaab, 0x3333, 0xab33, 0x3333, 0xaaa3, + 0x3333, 0x5333, 0x3333, 0x4433, 0x3333, 0x44a3, 0x3333, 0x44ba, + 0xaaba, 0x3aba, 0xbaaa, 0x33aa, 0x3aba, 0x3333, 0x3aab, 0x3333, + 0x3355, 0x33b3, 0x3a44, 0x3333, 0xab44, 0x333b, 0xaa44, 0xb33b, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x3333, 0x3333, 0xe333, 0x3333, 0xc333, 0x3333, 0xc333, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x3333, 0x333c, 0x3333, 0x333c, 0x3333, 0x33ce, 0x3333, + 0x3333, 0x33ca, 0x3333, 0x3bbb, 0x3333, 0x3bbb, 0x3333, 0x3b33, + 0x3333, 0x3b3b, 0x3333, 0xbbbb, 0xb333, 0xbbbb, 0x333b, 0xbbbb, + 0x3333, 0x3333, 0x3333, 0x3333, 0x33b3, 0x3333, 0x3bab, 0x3333, + 0x3b3b, 0x333b, 0xbbbb, 0x3333, 0xbbbb, 0x3333, 0xbbbb, 0x3333, + 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, + 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, + 0xeeee, 0xeeee, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, + 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, + 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, + 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xeeee, 0xeeee, + 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, + 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, + 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, + 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, + 0xeeee, 0xeeee, 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, + 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, + 0xeeee, 0xeeee, 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, + 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, + 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, + 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, 0xeeee, 0xeeee, + 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, + 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xeeee, 0xeeee, + 0x8888, 0x8888, 0x8888, 0x8888, 0x8888, 0x8888, 0x8888, 0x8888, + 0x8888, 0x8888, 0x8888, 0x8888, 0x8888, 0x8888, 0x8888, 0x8888, + 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, + 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, + 0x1111, 0x1111, 0x1111, 0x1111, 0x6666, 0x6666, 0x7776, 0x6777, + 0x7776, 0x6777, 0x6666, 0x6666, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0xaaaa, 0xaaaa, 0xccca, 0xaccc, + 0xccca, 0xaccc, 0xaaaa, 0xaaaa, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0xcccc, 0xcccc, +}; + +static const u16 vp_rpg_obj_tiles[7680] = { + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, + 0x1100, 0x7393, 0x7700, 0x2173, 0x7310, 0x1117, 0x2921, 0x8722, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0111, 0x0000, + 0x8281, 0x0017, 0x2287, 0x0027, 0x1117, 0x0172, 0x2777, 0x1781, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, + 0x0000, 0x1000, 0x0000, 0x2000, 0x0000, 0x8000, 0x0000, 0x7000, + 0x0000, 0x7000, 0x0000, 0x7000, 0x0000, 0x7000, 0x0000, 0x1000, + 0x2822, 0x2822, 0x9122, 0x2278, 0x7218, 0x7171, 0x7712, 0x7318, + 0x1177, 0x1991, 0x8111, 0x1977, 0x9119, 0x8111, 0x9392, 0x9455, + 0x2772, 0x2772, 0x2722, 0x7219, 0x1717, 0x7127, 0x8137, 0x7177, + 0x1991, 0x7711, 0x7791, 0x1117, 0x1118, 0x9119, 0x5549, 0x2939, + 0x0001, 0x0000, 0x0007, 0x0000, 0x0007, 0x0000, 0x0007, 0x0000, + 0x0007, 0x0000, 0x0007, 0x0000, 0x0007, 0x0000, 0x0001, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x6100, + 0x9391, 0x9941, 0x8110, 0x9999, 0x1400, 0x9389, 0x1410, 0x1717, + 0x6644, 0x7777, 0x6644, 0x7771, 0x6161, 0x7111, 0x4199, 0x3166, + 0x4499, 0x1939, 0x9999, 0x0118, 0x3239, 0x0041, 0x7177, 0x0165, + 0x7777, 0x6666, 0x1777, 0x6666, 0x1117, 0x1616, 0x6413, 0x9914, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0016, 0x0000, + 0x0000, 0x3100, 0x0000, 0x3000, 0x0000, 0x1000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1299, 0x3164, 0x1181, 0x3144, 0x7711, 0x3156, 0x7510, 0x3114, + 0x4410, 0x6666, 0x6400, 0x1166, 0x1100, 0x0111, 0x1000, 0x0011, + 0x4413, 0x9921, 0x4513, 0x1814, 0x6413, 0x1173, 0x1113, 0x1773, + 0x6666, 0x1465, 0x6441, 0x0146, 0x4110, 0x0014, 0x1000, 0x0011, + 0x0013, 0x0000, 0x0003, 0x0000, 0x0001, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x1100, 0x8111, + 0x8800, 0x2299, 0x9810, 0x2299, 0x2291, 0x7222, 0x2292, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1112, 0x0001, + 0x8992, 0x0012, 0x9222, 0x0013, 0x2227, 0x0192, 0x2222, 0x1292, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x7000, 0x0000, 0x2100, 0x0000, 0x2100, 0x0000, 0x2100, + 0x0000, 0x8100, 0x0000, 0x7100, 0x0000, 0x7100, 0x0000, 0x7000, + 0x2298, 0x2222, 0x2282, 0x2222, 0x2922, 0x2222, 0x9222, 0x2222, + 0x2228, 0x8822, 0x2272, 0x8222, 0x2777, 0x2277, 0x7777, 0x7777, + 0x2222, 0x2222, 0x2222, 0x2292, 0x2222, 0x7729, 0x9222, 0x7722, + 0x2228, 0x7777, 0x2222, 0x7777, 0x7727, 0x7777, 0x7777, 0x7777, + 0x0001, 0x0000, 0x0007, 0x0000, 0x0007, 0x0000, 0x0007, 0x0000, + 0x0007, 0x0000, 0x0007, 0x0000, 0x0007, 0x0000, 0x0001, 0x0000, + 0x0000, 0x7000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x1100, 0x0000, 0x1100, + 0x7777, 0x7777, 0x7771, 0x7777, 0x7710, 0x7777, 0x1100, 0x7771, + 0x4461, 0x4441, 0x4466, 0x1114, 0x4466, 0x4444, 0x4445, 0x4444, + 0x7777, 0x7777, 0x7777, 0x0177, 0x7777, 0x0077, 0x1777, 0x0011, + 0x1444, 0x0164, 0x4411, 0x1664, 0x4444, 0x1644, 0x4444, 0x1444, + 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0001, 0x0000, 0x0016, 0x0000, 0x0016, 0x0000, + 0x0000, 0x4100, 0x0000, 0x4000, 0x0000, 0x1000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x4411, 0x4444, 0x4444, 0x4444, 0x4111, 0x4444, 0x4110, 0x4444, + 0x6610, 0x4446, 0x6610, 0x4446, 0x6400, 0x1116, 0x1100, 0x0001, + 0x4444, 0x1144, 0x4444, 0x4441, 0x4444, 0x1114, 0x4444, 0x0111, + 0x6444, 0x0166, 0x6444, 0x0166, 0x6111, 0x0016, 0x1100, 0x0011, + 0x0016, 0x0000, 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x8100, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x1100, 0x1101, 0x7700, 0x2137, + 0x1111, 0x1212, 0x2299, 0x1172, 0x2228, 0x2222, 0x7228, 0x2229, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0111, 0x0000, 0x9822, 0x0012, + 0x2212, 0x0199, 0x2271, 0x1722, 0x1171, 0x2821, 0x2872, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, 0x0002, 0x0000, + 0x0000, 0x2100, 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2172, 0x2222, 0x1371, 0x9212, 0x3371, 0x2211, 0x9911, 0x7211, + 0x1910, 0x1184, 0x1910, 0x1996, 0x1991, 0x3996, 0x9991, 0x3999, + 0x2222, 0x2222, 0x2829, 0x2222, 0x8222, 0x7222, 0x8222, 0x7722, + 0x7777, 0x7777, 0x7111, 0x7777, 0x7989, 0x7777, 0x7989, 0x7777, + 0x0002, 0x0000, 0x0017, 0x0000, 0x0017, 0x0000, 0x0017, 0x0000, + 0x0017, 0x0000, 0x0017, 0x0000, 0x0007, 0x0000, 0x0001, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9910, 0x7399, 0x1181, 0x7771, 0x2281, 0x1777, 0x7711, 0x6177, + 0x1100, 0x1661, 0x0000, 0x6111, 0x0000, 0x4661, 0x1000, 0x1461, + 0x7199, 0x1777, 0x7111, 0x0017, 0x4556, 0x0011, 0x1116, 0x0014, + 0x4666, 0x0041, 0x4666, 0x0041, 0x4666, 0x0041, 0x4646, 0x0041, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1000, 0x1141, 0x0000, 0x9311, 0x0000, 0x8141, 0x0000, 0x1110, + 0x0000, 0x6660, 0x0000, 0x6610, 0x0000, 0x1100, 0x0000, 0x1000, + 0x4116, 0x0041, 0x1449, 0x0044, 0x1133, 0x0014, 0x1133, 0x0001, + 0x4777, 0x0001, 0x4444, 0x0001, 0x1444, 0x0000, 0x0111, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x2000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, 0x2100, 0x7289, + 0x9910, 0x1282, 0x2221, 0x1222, 0x1288, 0x1711, 0x2222, 0x2782, + 0x0000, 0x0000, 0x0000, 0x0000, 0x1011, 0x0011, 0x7312, 0x0077, + 0x1211, 0x1117, 0x2111, 0x9922, 0x2222, 0x8822, 0x9222, 0x8821, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0001, 0x0000, 0x0018, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, + 0x0000, 0x2000, 0x0000, 0x2100, 0x0000, 0x7100, 0x0000, 0x7100, + 0x0000, 0x7100, 0x0000, 0x7100, 0x0000, 0x7000, 0x0000, 0x1000, + 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2277, 0x2228, + 0x7777, 0x7777, 0x7777, 0x1117, 0x7777, 0x9897, 0x7777, 0x9897, + 0x2222, 0x8712, 0x7199, 0x1731, 0x1122, 0x1733, 0x1122, 0x0199, + 0x1817, 0x0191, 0x6991, 0x0191, 0x6993, 0x1991, 0x9993, 0x1999, + 0x0012, 0x0000, 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7771, 0x9917, 0x7100, 0x1117, 0x1100, 0x6654, 0x4100, 0x6111, + 0x1400, 0x6664, 0x1400, 0x6664, 0x1400, 0x1664, 0x1400, 0x6664, + 0x9987, 0x0199, 0x1777, 0x1811, 0x2771, 0x1882, 0x7716, 0x1187, + 0x1661, 0x0011, 0x9116, 0x0013, 0x1664, 0x0033, 0x1641, 0x0013, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1400, 0x6614, 0x4400, 0x9341, 0x4100, 0x3331, 0x1000, 0x3311, + 0x1000, 0x7774, 0x1000, 0x4444, 0x0000, 0x4441, 0x0000, 0x1110, + 0x1511, 0x0001, 0x1419, 0x0000, 0x1441, 0x0000, 0x1112, 0x0000, + 0x0669, 0x0000, 0x0166, 0x0000, 0x0011, 0x0000, 0x0001, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x7761, 0x7100, 0x8788, 0x7710, 0x8868, 0x5781, 0x4888, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0116, 0x0000, 0x6778, 0x0001, 0x5678, 0x0017, 0x8888, 0x0167, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x5486, 0x8674, 0x2346, 0x4445, 0x3325, 0x3333, 0x3281, 0x3333, + 0x3815, 0x8763, 0x3141, 0x6443, 0x4230, 0x7123, 0x2820, 0x3324, + 0x8768, 0x0578, 0x8888, 0x6664, 0x7542, 0x1568, 0x6642, 0x8875, + 0x5552, 0x7566, 0x3524, 0x4663, 0x2423, 0x5553, 0x3263, 0x5551, + 0x0000, 0x0000, 0x0006, 0x0000, 0x0000, 0x0000, 0x0061, 0x0000, + 0x0017, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x4100, + 0x0000, 0x5100, 0x0000, 0x1300, 0x0000, 0x4300, 0x0000, 0x2100, + 0x6670, 0x2288, 0x6811, 0x4846, 0x7116, 0x5568, 0x1154, 0x4415, + 0x4654, 0x6155, 0x5414, 0x6655, 0x4444, 0x6554, 0x5511, 0x1155, + 0x1172, 0x0155, 0x4467, 0x0001, 0x6641, 0x0011, 0x1615, 0x0165, + 0x5116, 0x0566, 0x4146, 0x1566, 0x1145, 0x4551, 0x1141, 0x4555, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1151, 0x5551, 0x5461, 0x5551, 0x5410, 0x6665, 0x1100, 0x6614, + 0x1440, 0x1111, 0x1110, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2144, 0x1222, 0x4155, 0x1333, 0x4556, 0x0122, 0x4566, 0x0001, + 0x4411, 0x0000, 0x1141, 0x0000, 0x1141, 0x0000, 0x0441, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1100, + 0x1000, 0x3334, 0x3100, 0x4433, 0x3310, 0x4443, 0x3330, 0x4443, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0111, 0x0000, + 0x3444, 0x0001, 0x5444, 0x0034, 0x5454, 0x0344, 0x4444, 0x1444, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x3000, 0x0000, 0x1210, 0x0000, 0x3330, + 0x3331, 0x4443, 0x3331, 0x1143, 0x6331, 0x1143, 0x3331, 0x1433, + 0x3331, 0x4433, 0x3311, 0x3333, 0x2133, 0x2211, 0x1123, 0x2133, + 0x4444, 0x5114, 0x4444, 0x4114, 0x4441, 0x4446, 0x1144, 0x3441, + 0x1344, 0x1344, 0x3333, 0x1333, 0x2222, 0x3111, 0x1223, 0x3133, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0014, 0x0000, 0x0031, 0x0000, 0x0011, 0x0000, 0x1113, 0x0000, + 0x0000, 0x2320, 0x0000, 0x1110, 0x0000, 0x1130, 0x2100, 0x1122, + 0x1000, 0x1211, 0x0000, 0x2100, 0x0000, 0x3000, 0x0000, 0x0000, + 0x2112, 0x1133, 0x3211, 0x1123, 0x3321, 0x1122, 0x3321, 0x2112, + 0x1111, 0x2111, 0x1112, 0x1232, 0x1333, 0x1111, 0x0000, 0x3200, + 0x1133, 0x2133, 0x1132, 0x1333, 0x1133, 0x2332, 0x1233, 0x2321, + 0x1233, 0x1111, 0x3111, 0x1113, 0x2211, 0x3321, 0x0123, 0x1100, + 0x1333, 0x0000, 0x1332, 0x0000, 0x1111, 0x0001, 0x3111, 0x0032, + 0x1122, 0x0011, 0x0002, 0x0000, 0x0013, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1110, 0x7000, 0x7998, 0x7100, 0x7773, 0x7210, 0x1117, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0111, 0x0000, 0x7777, 0x0001, 0x2771, 0x0012, 0x8117, 0x0178, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x0000, + 0x2277, 0x7727, 0x3827, 0x7773, 0x9177, 0x7172, 0x7717, 0x1317, + 0x7177, 0x7111, 0x1177, 0x1333, 0x3117, 0x3311, 0x1197, 0x3111, + 0x7711, 0x7781, 0x3777, 0x7832, 0x9777, 0x7318, 0x7817, 0x2187, + 0x1117, 0x8177, 0x8317, 0x1771, 0x1182, 0x2179, 0x1172, 0x8314, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, + 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x3193, 0x9944, 0x3110, 0x9999, 0x7100, 0x9993, 0x4000, 0x1711, + 0x5100, 0x1771, 0x6100, 0x1776, 0x6610, 0x2116, 0x6610, 0x3151, + 0x4499, 0x2913, 0x9999, 0x0119, 0x9999, 0x0011, 0x7117, 0x0011, + 0x7717, 0x0161, 0x1717, 0x1164, 0x5127, 0x1666, 0x6133, 0x1641, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x6410, 0x3151, 0x4110, 0x9144, 0x9910, 0x8141, 0x9910, 0x8113, + 0x1000, 0x6661, 0x1000, 0x1654, 0x1000, 0x1111, 0x0000, 0x0111, + 0x1133, 0x1441, 0x2199, 0x1991, 0x1188, 0x0131, 0x6188, 0x0014, + 0x6666, 0x0006, 0x4111, 0x0004, 0x7110, 0x0007, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, 0x1000, 0x7797, + 0x7100, 0x1172, 0x2210, 0x1712, 0x7271, 0x7222, 0x7777, 0x7773, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0111, 0x0000, 0x7771, 0x0017, + 0x7777, 0x0172, 0x1117, 0x1228, 0x7282, 0x7997, 0x7278, 0x7373, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x3877, 0x2779, 0x7717, 0x1217, 0x7177, 0x1211, 0x1777, 0x1388, + 0x8177, 0x3911, 0x9191, 0x3111, 0x3810, 0x9959, 0x7100, 0x9993, + 0x7128, 0x7319, 0x1778, 0x7137, 0x1772, 0x7771, 0x3317, 0x7171, + 0x1181, 0x3173, 0x1188, 0x8719, 0x9699, 0x1919, 0x9999, 0x0127, + 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, + 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1000, 0x9997, 0x4000, 0x1171, 0x5100, 0x7771, 0x6100, 0x2716, + 0x6410, 0x8166, 0x6410, 0x8161, 0x6410, 0x9161, 0x6410, 0x8161, + 0x8999, 0x0011, 0x1717, 0x0115, 0x1777, 0x0166, 0x6822, 0x0166, + 0x6139, 0x0141, 0x1188, 0x0131, 0x6199, 0x0011, 0x6183, 0x0001, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9910, 0x8118, 0x3100, 0x5617, 0x1000, 0x1111, 0x1000, 0x0177, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x6188, 0x0001, 0x6665, 0x0006, 0x6664, 0x0006, 0x5611, 0x0005, + 0x9410, 0x0009, 0x1110, 0x0001, 0x9110, 0x0019, 0x1000, 0x0001, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1110, 0x1000, 0x7732, 0x7100, 0x1173, 0x7110, 0x1712, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0111, 0x0000, 0x7397, 0x0007, 0x2777, 0x0012, 0x8117, 0x0132, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x3371, 0x7222, 0x7271, 0x7733, 0x3872, 0x7778, 0x2717, 0x7137, + 0x7777, 0x1711, 0x1177, 0x1237, 0x8137, 0x3111, 0x8981, 0x8144, + 0x7777, 0x7997, 0x9772, 0x2923, 0x2172, 0x7813, 0x1218, 0x7177, + 0x1217, 0x7771, 0x7817, 0x7711, 0x1131, 0x3176, 0x1438, 0x7916, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, + 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9981, 0x9166, 0x1100, 0x9999, 0x1000, 0x9991, 0x5000, 0x1131, + 0x5100, 0x1271, 0x6100, 0x1775, 0x1610, 0x7141, 0x5410, 0x3161, + 0x4499, 0x2916, 0x9999, 0x0113, 0x9999, 0x0017, 0x8111, 0x0011, + 0x8717, 0x0151, 0x7777, 0x0166, 0x6177, 0x1466, 0x6199, 0x1461, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9611, 0x8161, 0x9921, 0x9159, 0x1810, 0x8179, 0x1100, 0x7191, + 0x4100, 0x6664, 0x4000, 0x1564, 0x1000, 0x1141, 0x1000, 0x0011, + 0x6133, 0x1461, 0x6149, 0x1441, 0x5133, 0x1991, 0x5133, 0x1991, + 0x6666, 0x0017, 0x6111, 0x0006, 0x4110, 0x0004, 0x1000, 0x0001, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, 0x1000, 0x7333, + 0x7100, 0x7119, 0x7710, 0x1177, 0x3771, 0x7727, 0x7227, 0x7329, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0111, 0x0000, 0x7892, 0x0007, + 0x7777, 0x0017, 0x7117, 0x0172, 0x7777, 0x7997, 0x7777, 0x2982, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x3727, 0x2779, 0x7717, 0x7137, 0x7777, 0x7211, 0x1177, 0x1733, + 0x7187, 0x8211, 0x7921, 0x3141, 0x9310, 0x9956, 0x8100, 0x9993, + 0x2777, 0x2919, 0x7778, 0x2128, 0x1817, 0x7177, 0x8917, 0x7711, + 0x1188, 0x2177, 0x4488, 0x8917, 0x6999, 0x1913, 0x9999, 0x0118, + 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, + 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1000, 0x9992, 0x1100, 0x1171, 0x4410, 0x1771, 0x1410, 0x1771, + 0x1410, 0x7711, 0x1910, 0x9314, 0x1100, 0x4411, 0x1000, 0x9814, + 0x3999, 0x0001, 0x7111, 0x0001, 0x7717, 0x0011, 0x1777, 0x0111, + 0x5117, 0x1666, 0x1519, 0x1661, 0x1114, 0x4661, 0x1519, 0x4551, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1000, 0x8214, 0x4000, 0x6666, 0x4000, 0x6666, 0x4000, 0x4164, + 0x1000, 0x0149, 0x1000, 0x0111, 0x7000, 0x0176, 0x1000, 0x0011, + 0x4518, 0x9991, 0x7311, 0x1391, 0x4445, 0x0111, 0x1110, 0x0001, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x2111, + 0x1000, 0x2988, 0x9700, 0x2229, 0x9911, 0x2222, 0x2911, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, 0x1112, 0x0000, + 0x7932, 0x0011, 0x2227, 0x0199, 0x2222, 0x1792, 0x2222, 0x7792, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2927, 0x2222, 0x2927, 0x2222, 0x2227, 0x2229, 0x2227, 0x8292, + 0x2777, 0x2222, 0x7777, 0x2772, 0x7771, 0x7772, 0x7710, 0x7777, + 0x2222, 0x7792, 0x2222, 0x7792, 0x9222, 0x7722, 0x2982, 0x7772, + 0x7222, 0x7777, 0x7222, 0x7777, 0x7777, 0x7777, 0x7777, 0x1777, + 0x0000, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, + 0x0001, 0x0000, 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7700, 0x7777, 0x1000, 0x7771, 0x1000, 0x1111, 0x6600, 0x1116, + 0x6600, 0x4446, 0x6600, 0x4446, 0x6600, 0x4446, 0x1100, 0x4441, + 0x7777, 0x0177, 0x1777, 0x0011, 0x1111, 0x0011, 0x4411, 0x0164, + 0x4444, 0x0164, 0x4444, 0x0164, 0x4444, 0x0114, 0x4444, 0x0114, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9910, 0x4416, 0x5510, 0x4414, 0x6600, 0x1116, 0x1100, 0x0441, + 0x1100, 0x0449, 0x1000, 0x0111, 0x1000, 0x0111, 0x0000, 0x0000, + 0x4444, 0x0114, 0x4444, 0x0014, 0x4111, 0x0014, 0x4440, 0x0014, + 0x4440, 0x0014, 0x1910, 0x0011, 0x1190, 0x0011, 0x1110, 0x0011, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, + 0x1000, 0x2992, 0x7100, 0x2988, 0x9900, 0x7222, 0x2910, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, + 0x8992, 0x0001, 0x8282, 0x0001, 0x2227, 0x0099, 0x2222, 0x0192, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2911, 0x2222, 0x2927, 0x2222, 0x9227, 0x2222, 0x2227, 0x8292, + 0x2227, 0x8222, 0x2777, 0x2222, 0x7777, 0x2772, 0x7771, 0x7777, + 0x2222, 0x1792, 0x2222, 0x7792, 0x2222, 0x7729, 0x2882, 0x7722, + 0x2222, 0x7772, 0x7222, 0x7777, 0x7722, 0x7777, 0x7777, 0x1777, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7710, 0x7777, 0x7100, 0x7777, 0x1000, 0x7771, 0x1100, 0x1114, + 0x6600, 0x1446, 0x6600, 0x4446, 0x6600, 0x4446, 0x6600, 0x4446, + 0x7777, 0x0177, 0x7777, 0x0017, 0x1777, 0x0011, 0x1111, 0x0061, + 0x4411, 0x0044, 0x4444, 0x0144, 0x4444, 0x0114, 0x4444, 0x0114, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9910, 0x4419, 0x9910, 0x4419, 0x5610, 0x1111, 0x6600, 0x1416, + 0x1100, 0x0441, 0x1000, 0x0111, 0x0000, 0x0000, 0x0000, 0x0000, + 0x4444, 0x0014, 0x4444, 0x0014, 0x4111, 0x0001, 0x4441, 0x0001, + 0x4440, 0x0001, 0x1940, 0x0001, 0x1140, 0x0001, 0x1110, 0x0001, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, + 0x1000, 0x2111, 0x8100, 0x2298, 0x2910, 0x2222, 0x2971, 0x7222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1118, 0x0000, 0x1998, 0x0001, 0x9227, 0x0013, 0x2227, 0x0089, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x0000, + 0x2271, 0x2222, 0x2282, 0x2222, 0x2222, 0x2222, 0x9222, 0x2222, + 0x2222, 0x2822, 0x2282, 0x2222, 0x2227, 0x2222, 0x7777, 0x7777, + 0x2222, 0x0129, 0x2222, 0x1729, 0x2222, 0x7779, 0x2222, 0x7777, + 0x7988, 0x7777, 0x7222, 0x7777, 0x7727, 0x7777, 0x7777, 0x1777, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7777, 0x7777, 0x7710, 0x7777, 0x7100, 0x7777, 0x1100, 0x7771, + 0x6600, 0x4414, 0x6610, 0x1144, 0x4610, 0x4444, 0x4110, 0x4444, + 0x7777, 0x1777, 0x7777, 0x0077, 0x7777, 0x0001, 0x1177, 0x0011, + 0x6114, 0x0066, 0x6441, 0x0166, 0x4444, 0x0166, 0x4444, 0x0111, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x4110, 0x4444, 0x4110, 0x4444, 0x1100, 0x4441, 0x1000, 0x1114, + 0x1000, 0x0444, 0x1000, 0x0441, 0x1000, 0x0991, 0x0000, 0x0111, + 0x1444, 0x1999, 0x1444, 0x1655, 0x1444, 0x0151, 0x1111, 0x0011, + 0x1410, 0x0014, 0x4110, 0x0011, 0x1610, 0x0011, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x2111, + 0x1000, 0x2998, 0x3700, 0x2223, 0x9910, 0x7222, 0x2910, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, 0x1112, 0x0001, + 0x9992, 0x0001, 0x9222, 0x0039, 0x2227, 0x0199, 0x2222, 0x1192, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2981, 0x2222, 0x2987, 0x2222, 0x2227, 0x2229, 0x2227, 0x8292, + 0x2277, 0x8222, 0x7777, 0x2222, 0x7777, 0x2772, 0x7771, 0x7777, + 0x2222, 0x7232, 0x2222, 0x7792, 0x9222, 0x7722, 0x2882, 0x7772, + 0x2222, 0x7777, 0x7222, 0x7777, 0x7722, 0x7777, 0x7777, 0x1777, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7710, 0x7777, 0x1000, 0x7777, 0x1000, 0x7771, 0x6600, 0x4411, + 0x6600, 0x1144, 0x4410, 0x4444, 0x4110, 0x4444, 0x4110, 0x4444, + 0x7777, 0x0177, 0x7777, 0x0017, 0x1777, 0x0001, 0x5144, 0x0015, + 0x6411, 0x0066, 0x6444, 0x0166, 0x4444, 0x0166, 0x4444, 0x0166, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x4100, 0x4444, 0x1100, 0x4444, 0x1000, 0x1114, 0x1000, 0x0444, + 0x1000, 0x0444, 0x1000, 0x0441, 0x1000, 0x0924, 0x1000, 0x0111, + 0x9444, 0x1999, 0x9444, 0x1999, 0x1111, 0x1666, 0x1140, 0x0141, + 0x6440, 0x0013, 0x1110, 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x7000, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x1100, 0x1100, 0x7700, 0x2277, + 0x1111, 0x7111, 0x2292, 0x1122, 0x2222, 0x2222, 0x7222, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0111, 0x0000, 0x9982, 0x0001, + 0x9222, 0x0019, 0x2717, 0x1782, 0x1111, 0x1282, 0x2222, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x7100, 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7121, 0x2231, 0x1771, 0x9911, 0x9911, 0x2719, 0x7110, 0x7717, + 0x1910, 0x7191, 0x1910, 0x7194, 0x1910, 0x1994, 0x9900, 0x1799, + 0x2329, 0x2222, 0x2239, 0x7222, 0x2822, 0x7772, 0x2882, 0x7772, + 0x7711, 0x7777, 0x7199, 0x7777, 0x7193, 0x7777, 0x7713, 0x0177, + 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, + 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x3100, 0x7799, 0x1000, 0x1177, 0x7100, 0x6577, 0x1000, 0x1161, + 0x0000, 0x6566, 0x0000, 0x6111, 0x0000, 0x1166, 0x0000, 0x6166, + 0x7771, 0x0017, 0x1144, 0x0001, 0x0416, 0x0000, 0x1146, 0x0000, + 0x1146, 0x0000, 0x1146, 0x0000, 0x1466, 0x0000, 0x1446, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1166, 0x0000, 0x9966, 0x0000, 0x9961, 0x0000, 0x9161, + 0x0000, 0x1161, 0x0000, 0x4610, 0x0000, 0x4110, 0x0000, 0x1100, + 0x1144, 0x0000, 0x0119, 0x0000, 0x0189, 0x0000, 0x0119, 0x0000, + 0x0111, 0x0000, 0x0114, 0x0000, 0x0114, 0x0000, 0x0011, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x7000, 0x0000, 0x7000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1100, 0x1100, + 0x7700, 0x2277, 0x1111, 0x2111, 0x2299, 0x1122, 0x2227, 0x1222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0011, 0x0000, + 0x1922, 0x0001, 0x9282, 0x0007, 0x2227, 0x0122, 0x2117, 0x0122, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x2000, 0x0000, 0x1000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2227, 0x2222, 0x7771, 0x2293, 0x3171, 0x9887, 0x9911, 0x2271, + 0x2710, 0x2271, 0x1310, 0x7713, 0x1910, 0x1189, 0x9910, 0x3199, + 0x2222, 0x1222, 0x2229, 0x7222, 0x2222, 0x7722, 0x2888, 0x7772, + 0x2778, 0x7777, 0x7771, 0x7777, 0x7719, 0x1777, 0x7718, 0x0177, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9900, 0x9199, 0x1100, 0x1777, 0x2100, 0x5177, 0x7000, 0x1617, + 0x0000, 0x6646, 0x0000, 0x6146, 0x0000, 0x6161, 0x0000, 0x1661, + 0x7779, 0x0017, 0x7711, 0x0001, 0x1144, 0x0000, 0x0441, 0x0000, + 0x0116, 0x0000, 0x0446, 0x0000, 0x1466, 0x0000, 0x1666, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x6661, 0x0000, 0x6661, 0x0000, 0x1161, 0x0000, 0x4466, + 0x0000, 0x4466, 0x0000, 0x4445, 0x1000, 0x1128, 0x1000, 0x0111, + 0x1461, 0x0000, 0x8911, 0x0001, 0x8991, 0x0001, 0x1911, 0x0000, + 0x0111, 0x0000, 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x8000, 0x0000, 0x1000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1100, 0x1100, + 0x7700, 0x2277, 0x1111, 0x7111, 0x2299, 0x1122, 0x2228, 0x1222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0011, 0x0000, + 0x1922, 0x0001, 0x9222, 0x0007, 0x2228, 0x0122, 0x2112, 0x0122, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x8000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7228, 0x2222, 0x7171, 0x9292, 0x1317, 0x9927, 0x3910, 0x2271, + 0x7710, 0x2211, 0x1910, 0x7731, 0x1910, 0x8199, 0x9910, 0x3199, + 0x2222, 0x1222, 0x2229, 0x7222, 0x2222, 0x7722, 0x2882, 0x7772, + 0x2778, 0x7777, 0x7771, 0x7777, 0x7718, 0x1777, 0x7713, 0x0177, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9900, 0x9199, 0x0000, 0x1771, 0x1000, 0x4177, 0x2100, 0x1177, + 0x0000, 0x6641, 0x0000, 0x6166, 0x0000, 0x6666, 0x0000, 0x6666, + 0x7779, 0x0017, 0x7711, 0x0001, 0x1114, 0x0000, 0x0441, 0x0000, + 0x0441, 0x0000, 0x1146, 0x0000, 0x1146, 0x0000, 0x1146, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x6615, 0x0000, 0x6116, 0x0000, 0x3991, 0x0000, 0x2991, + 0x0000, 0x7311, 0x0000, 0x1110, 0x0000, 0x4410, 0x0000, 0x1100, + 0x1146, 0x0000, 0x1114, 0x0000, 0x0117, 0x0000, 0x0118, 0x0000, + 0x0141, 0x0000, 0x0115, 0x0000, 0x0144, 0x0000, 0x0011, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x7000, 0x0000, 0x1000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1100, 0x1100, + 0x7700, 0x2287, 0x1111, 0x2111, 0x2299, 0x1122, 0x2221, 0x1222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0011, 0x0000, + 0x1982, 0x0001, 0x9282, 0x0007, 0x2727, 0x0172, 0x2117, 0x0122, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x7000, 0x0000, 0x1000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2222, 0x2222, 0x7171, 0x9297, 0x1871, 0x9927, 0x3910, 0x2271, + 0x7110, 0x2271, 0x1910, 0x1731, 0x1910, 0x9199, 0x9910, 0x3199, + 0x2222, 0x1222, 0x2229, 0x7222, 0x2222, 0x7722, 0x2882, 0x7772, + 0x2772, 0x7777, 0x7771, 0x7777, 0x7719, 0x1777, 0x7713, 0x0177, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9900, 0x3199, 0x0000, 0x1171, 0x0000, 0x1177, 0x1000, 0x6677, + 0x0000, 0x6661, 0x0000, 0x6611, 0x0000, 0x6616, 0x0000, 0x6611, + 0x7779, 0x0017, 0x1711, 0x0001, 0x1151, 0x0000, 0x1546, 0x0000, + 0x0416, 0x0000, 0x1416, 0x0000, 0x1116, 0x0000, 0x1414, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9100, 0x4661, 0x9100, 0x4416, 0x1000, 0x1199, 0x0000, 0x1111, + 0x0000, 0x6610, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, + 0x1444, 0x0000, 0x1444, 0x0000, 0x0111, 0x0000, 0x0444, 0x0000, + 0x0446, 0x0000, 0x1441, 0x0000, 0x1771, 0x0000, 0x0111, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, 0x1000, 0x2897, + 0x8100, 0x8389, 0x2371, 0x7122, 0x2281, 0x2171, 0x2227, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0011, 0x0001, 0x8972, 0x0001, + 0x7717, 0x0111, 0x2211, 0x1722, 0x2222, 0x0182, 0x3222, 0x0787, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2222, 0x9222, 0x2222, 0x2282, 0x2227, 0x2222, 0x2777, 0x2222, + 0x7777, 0x7777, 0x7777, 0x7177, 0x7777, 0x9817, 0x7710, 0x8717, + 0x1922, 0x1171, 0x1199, 0x0171, 0x9122, 0x0011, 0x3112, 0x0019, + 0x1972, 0x0019, 0x4971, 0x0019, 0x4981, 0x0019, 0x9981, 0x0019, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7100, 0x2177, 0x0000, 0x1111, 0x0000, 0x4410, 0x0000, 0x4440, + 0x0000, 0x6441, 0x0000, 0x4441, 0x0000, 0x4141, 0x0000, 0x4141, + 0x9981, 0x0001, 0x1711, 0x0000, 0x1764, 0x0000, 0x1616, 0x0000, + 0x1166, 0x0000, 0x6166, 0x0000, 0x6166, 0x0000, 0x6166, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1440, 0x0000, 0x1410, 0x0000, 0x4100, 0x0000, 0x4100, + 0x0000, 0x4100, 0x0000, 0x1100, 0x0000, 0x7100, 0x0000, 0x1000, + 0x1154, 0x0000, 0x9993, 0x0000, 0x1987, 0x0000, 0x0111, 0x0000, + 0x0166, 0x0000, 0x0166, 0x0000, 0x0182, 0x0000, 0x0011, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, + 0x7100, 0x2787, 0x8710, 0x1739, 0x2271, 0x1217, 0x1227, 0x1211, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1001, 0x0000, + 0x7711, 0x0000, 0x1721, 0x0111, 0x2222, 0x1772, 0x2222, 0x0112, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2227, 0x2222, 0x2222, 0x2992, 0x2222, 0x2222, 0x2227, 0x2227, + 0x7777, 0x2227, 0x7777, 0x1117, 0x7777, 0x9337, 0x7771, 0x9977, + 0x7222, 0x0172, 0x7782, 0x1771, 0x1189, 0x0171, 0x1722, 0x0001, + 0x3771, 0x0001, 0x4191, 0x0001, 0x5192, 0x0001, 0x9992, 0x0001, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7710, 0x9317, 0x1000, 0x1111, 0x0000, 0x4441, 0x0000, 0x4441, + 0x1000, 0x4444, 0x1000, 0x6444, 0x1000, 0x4144, 0x1000, 0x4144, + 0x9992, 0x0001, 0x1277, 0x0000, 0x0171, 0x0000, 0x0116, 0x0000, + 0x0116, 0x0000, 0x1616, 0x0000, 0x1566, 0x0000, 0x1566, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1000, 0x1444, 0x1000, 0x4444, 0x0000, 0x1110, 0x0000, 0x4410, + 0x0000, 0x4410, 0x0000, 0x4441, 0x0000, 0x8771, 0x0000, 0x1110, + 0x1765, 0x0000, 0x9944, 0x0000, 0x9921, 0x0000, 0x1214, 0x0000, + 0x0114, 0x0000, 0x0144, 0x0000, 0x0011, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, 0x1000, 0x2293, + 0x3100, 0x1288, 0x2871, 0x1712, 0x2271, 0x1711, 0x2222, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x1011, 0x0000, 0x8712, 0x0001, + 0x7111, 0x0111, 0x2221, 0x1722, 0x2222, 0x0122, 0x2922, 0x0227, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2222, 0x2992, 0x2222, 0x2222, 0x2227, 0x2227, 0x7777, 0x2227, + 0x7777, 0x7777, 0x7777, 0x1117, 0x7777, 0x8927, 0x7771, 0x3717, + 0x7792, 0x1117, 0x3129, 0x0101, 0x9722, 0x0001, 0x2771, 0x0001, + 0x1131, 0x0001, 0x1187, 0x0001, 0x5492, 0x0013, 0x9998, 0x0019, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7710, 0x7117, 0x1000, 0x1111, 0x0000, 0x4410, 0x0000, 0x4440, + 0x0000, 0x4441, 0x0000, 0x4441, 0x0000, 0x4411, 0x0000, 0x4111, + 0x9937, 0x0001, 0x1171, 0x0000, 0x7715, 0x0000, 0x1615, 0x0000, + 0x6166, 0x0000, 0x6166, 0x0000, 0x6666, 0x0000, 0x6644, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x4410, 0x0000, 0x9810, 0x0000, 0x8100, 0x0000, 0x1100, + 0x0000, 0x4100, 0x0000, 0x4100, 0x0000, 0x7100, 0x0000, 0x1000, + 0x6611, 0x0000, 0x1619, 0x0000, 0x4119, 0x0000, 0x1557, 0x0000, + 0x9666, 0x0000, 0x1166, 0x0000, 0x0066, 0x0000, 0x0011, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, 0x1000, 0x2299, + 0x3100, 0x1232, 0x2871, 0x1212, 0x2271, 0x1711, 0x2277, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x1011, 0x0000, 0x7212, 0x0001, + 0x7111, 0x0111, 0x2221, 0x1722, 0x2222, 0x0182, 0x9222, 0x0787, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2222, 0x9922, 0x2222, 0x2222, 0x2227, 0x2227, 0x2777, 0x2227, + 0x7777, 0x2277, 0x7777, 0x1777, 0x7777, 0x9917, 0x7711, 0x9817, + 0x1322, 0x1717, 0x7138, 0x1117, 0x7177, 0x0171, 0x1971, 0x0171, + 0x1112, 0x0001, 0x1181, 0x0001, 0x1531, 0x0019, 0x9937, 0x0019, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7110, 0x3177, 0x0000, 0x1111, 0x0000, 0x5440, 0x0000, 0x1141, + 0x0000, 0x5141, 0x1000, 0x6514, 0x1000, 0x6614, 0x1000, 0x6114, + 0x9937, 0x0001, 0x1771, 0x0001, 0x7111, 0x0017, 0x7644, 0x0012, + 0x1466, 0x0012, 0x6666, 0x0001, 0x6666, 0x0000, 0x6616, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1000, 0x6641, 0x0000, 0x4991, 0x0000, 0x8991, 0x0000, 0x2871, + 0x0000, 0x1110, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x6611, 0x0000, 0x6661, 0x0000, 0x1111, 0x0000, 0x1411, 0x0000, + 0x1444, 0x0000, 0x1814, 0x0000, 0x7971, 0x0000, 0x1111, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1100, 0x0000, 0x1100, 0x1000, 0x2211, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1110, 0x1111, 0x7771, 0x8888, 0x7771, 0x8888, 0x8999, 0x2722, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1111, 0x1000, 0x2228, 0x1111, 0x2228, 0x1111, 0x1122, 0x7223, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0111, 0x0000, 0x1888, 0x0011, 0x1888, 0x0011, 0x1777, 0x0001, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x2110, 0x0000, 0x2110, + 0x2100, 0x9992, 0x2100, 0x9992, 0x8211, 0x2228, 0x2877, 0x2222, + 0x2877, 0x2222, 0x2888, 0x1122, 0x2222, 0x2222, 0x2222, 0x2222, + 0x8222, 0x2122, 0x8222, 0x2122, 0x2112, 0x2122, 0x1222, 0x1122, + 0x1222, 0x1122, 0x1111, 0x2177, 0x8882, 0x2277, 0x8882, 0x2277, + 0x1112, 0x1112, 0x1112, 0x1112, 0x1112, 0x2221, 0x8811, 0x2888, + 0x8811, 0x2888, 0x2222, 0x2222, 0x2222, 0x9222, 0x2222, 0x9222, + 0x1177, 0x1111, 0x1177, 0x1111, 0x9222, 0x9999, 0x2222, 0x2282, + 0x2222, 0x2282, 0x2222, 0x8882, 0x2211, 0x8882, 0x2211, 0x8882, + 0x0001, 0x0000, 0x0001, 0x0000, 0x1118, 0x0000, 0x0117, 0x0000, + 0x0117, 0x0000, 0x0001, 0x0000, 0x0111, 0x0000, 0x0111, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x2110, 0x0000, 0x2771, 0x0000, 0x2771, 0x0000, 0x2771, + 0x0000, 0x7771, 0x0000, 0x7771, 0x0000, 0x7771, 0x0000, 0x7771, + 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, + 0x2222, 0x2222, 0x2222, 0x2222, 0x2777, 0x2222, 0x7777, 0x2227, + 0x2222, 0x2222, 0x8222, 0x2999, 0x8222, 0x2999, 0x2882, 0x9222, + 0x2222, 0x2222, 0x2222, 0x2222, 0x2228, 0x2222, 0x8227, 0x7888, + 0x2222, 0x2992, 0x9922, 0x2119, 0x9922, 0x2119, 0x9999, 0x7111, + 0x2222, 0x1331, 0x2222, 0x1331, 0x2222, 0x1991, 0x7717, 0x7771, + 0x7122, 0x8877, 0x1122, 0x1171, 0x1122, 0x1171, 0x1311, 0x1171, + 0x1333, 0x1171, 0x1333, 0x1171, 0x1999, 0x0011, 0x9777, 0x0019, + 0x1112, 0x0000, 0x1112, 0x0000, 0x1112, 0x0000, 0x0111, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x7771, 0x0000, 0x7771, 0x0000, 0x7771, 0x0000, 0x7110, + 0x0000, 0x7110, 0x0000, 0x7110, 0x0000, 0x1000, 0x0000, 0x1000, + 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, + 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, + 0x7777, 0x7777, 0x7777, 0x7777, 0x1777, 0x7111, 0x9117, 0x7933, + 0x9117, 0x7933, 0x9117, 0x1988, 0x9117, 0x1988, 0x9117, 0x1988, + 0x1177, 0x1118, 0x1177, 0x1118, 0x9917, 0x6449, 0x9917, 0x6449, + 0x9917, 0x6449, 0x9931, 0x6449, 0x9931, 0x9999, 0x9931, 0x9999, + 0x9911, 0x0019, 0x9911, 0x0019, 0x9911, 0x0019, 0x9911, 0x1199, + 0x9911, 0x1199, 0x9911, 0x1199, 0x9999, 0x1199, 0x9999, 0x1199, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7711, 0x7777, 0x7100, 0x7777, 0x7100, 0x7777, 0x1000, 0x7711, + 0x0000, 0x1110, 0x0000, 0x1110, 0x0000, 0x4410, 0x0000, 0x4410, + 0x1777, 0x1999, 0x7777, 0x7111, 0x7777, 0x7111, 0x1117, 0x7111, + 0x5444, 0x1666, 0x5444, 0x1666, 0x1111, 0x6611, 0x4441, 0x1144, + 0x8871, 0x9999, 0x7777, 0x7333, 0x7777, 0x7333, 0x7777, 0x1117, + 0x7711, 0x2777, 0x7711, 0x2777, 0x1166, 0x7777, 0x1161, 0x7771, + 0x9999, 0x0019, 0x2199, 0x0012, 0x2199, 0x0012, 0x8111, 0x1188, + 0x8822, 0x1188, 0x8822, 0x1188, 0x8877, 0x1118, 0x1277, 0x0011, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x4410, 0x1000, 0x1141, 0x1000, 0x1141, 0x1000, 0x1141, + 0x1000, 0x1141, 0x1000, 0x1141, 0x1000, 0x1141, 0x1000, 0x1141, + 0x4441, 0x1144, 0x6444, 0x6666, 0x6444, 0x6666, 0x6444, 0x6666, + 0x6664, 0x4166, 0x6664, 0x1166, 0x6444, 0x1666, 0x6444, 0x1666, + 0x1161, 0x7771, 0x6616, 0x1116, 0x1166, 0x9661, 0x1166, 0x9661, + 0x6664, 0x1116, 0x6641, 0x1666, 0x4411, 0x1666, 0x4411, 0x1666, + 0x1277, 0x0011, 0x0111, 0x0000, 0x0133, 0x0000, 0x0133, 0x0000, + 0x1933, 0x0001, 0x1333, 0x0001, 0x0133, 0x0000, 0x0133, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1000, 0x1141, 0x1000, 0x1141, 0x1000, 0x1141, 0x1000, 0x4441, + 0x0000, 0x4410, 0x0000, 0x4410, 0x0000, 0x1100, 0x0000, 0x1100, + 0x1444, 0x3666, 0x5111, 0x9955, 0x5111, 0x9955, 0x4441, 0x9933, + 0x3111, 0x3333, 0x3111, 0x3333, 0x1771, 0x1333, 0x7774, 0x2111, + 0x1113, 0x1665, 0x1199, 0x1554, 0x1199, 0x1554, 0x1199, 0x1554, + 0x4413, 0x1114, 0x4413, 0x1114, 0x1121, 0x1111, 0x6632, 0x0116, + 0x0011, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1100, 0x0000, 0x1100, 0x0000, 0x1100, 0x0000, 0x1100, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7774, 0x2111, 0x7774, 0x2777, 0x4444, 0x6444, 0x4444, 0x6444, + 0x4111, 0x4444, 0x7771, 0x2777, 0x7771, 0x2777, 0x1110, 0x1111, + 0x6632, 0x0116, 0x6692, 0x0116, 0x6666, 0x0001, 0x6666, 0x0001, + 0x1114, 0x0000, 0x1122, 0x0000, 0x1122, 0x0000, 0x0011, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x1111, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1111, 0x0001, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1100, 0x0000, 0x3310, 0x0000, 0x3331, + 0x1000, 0x3333, 0x3000, 0x3333, 0x3000, 0x3333, 0x3100, 0x3333, + 0x4111, 0x3333, 0x3333, 0x4333, 0x3333, 0x4443, 0x3333, 0x4444, + 0x4333, 0x4444, 0x4433, 0x4444, 0x4433, 0x4444, 0x4433, 0x4444, + 0x3333, 0x1113, 0x4444, 0x3344, 0x4444, 0x4454, 0x4444, 0x4544, + 0x4444, 0x4544, 0x4554, 0x4544, 0x4544, 0x4444, 0x4444, 0x4444, + 0x0000, 0x0000, 0x0011, 0x0000, 0x0134, 0x0000, 0x1344, 0x0000, + 0x1444, 0x0001, 0x4444, 0x0013, 0x4444, 0x0014, 0x4444, 0x0044, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x3100, 0x3333, 0x3310, 0x3333, 0x3310, 0x3335, 0x3310, 0x3333, + 0x3310, 0x3333, 0x3310, 0x6333, 0x3310, 0x3333, 0x3310, 0x3333, + 0x4443, 0x4444, 0x4443, 0x4444, 0x4443, 0x4114, 0x4443, 0x1111, + 0x1443, 0x1141, 0x1443, 0x1141, 0x4433, 0x1111, 0x4333, 0x4114, + 0x4444, 0x4444, 0x4444, 0x4444, 0x4444, 0x4444, 0x4444, 0x4444, + 0x4441, 0x4444, 0x4441, 0x4444, 0x1444, 0x1111, 0x1444, 0x1111, + 0x1444, 0x0041, 0x1144, 0x0111, 0x4144, 0x0111, 0x1144, 0x0111, + 0x1444, 0x0141, 0x4416, 0x0144, 0x4441, 0x0144, 0x4441, 0x0144, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x5100, 0x0000, 0x2100, + 0x1000, 0x1222, 0x2100, 0x2333, 0x3100, 0x3333, 0x3100, 0x3333, + 0x3311, 0x3333, 0x3313, 0x3333, 0x3113, 0x3333, 0x1311, 0x2222, + 0x2331, 0x1211, 0x1333, 0x1111, 0x1333, 0x1111, 0x1233, 0x2111, + 0x4333, 0x4444, 0x3333, 0x4444, 0x3333, 0x4333, 0x2222, 0x2222, + 0x2111, 0x2222, 0x1333, 0x2222, 0x1333, 0x1221, 0x1333, 0x1121, + 0x4444, 0x1111, 0x4444, 0x1113, 0x4444, 0x4444, 0x2222, 0x2222, + 0x2222, 0x2222, 0x2111, 0x1222, 0x1233, 0x3122, 0x1333, 0x2122, + 0x4441, 0x1134, 0x3444, 0x4333, 0x3333, 0x3133, 0x2222, 0x1112, + 0x2111, 0x1321, 0x1133, 0x1321, 0x1333, 0x3321, 0x1333, 0x3321, + 0x0001, 0x0000, 0x0013, 0x0000, 0x0134, 0x0000, 0x0123, 0x0000, + 0x0011, 0x0000, 0x1111, 0x0001, 0x1113, 0x0012, 0x3233, 0x0012, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1100, 0x0000, 0x2310, 0x0000, 0x3110, + 0x2100, 0x2333, 0x2100, 0x2222, 0x1000, 0x1111, 0x1100, 0x1111, + 0x3100, 0x1111, 0x2211, 0x1122, 0x2222, 0x1112, 0x2222, 0x2332, + 0x1222, 0x3211, 0x1122, 0x3322, 0x2111, 0x3332, 0x2111, 0x3333, + 0x3111, 0x3333, 0x3111, 0x2333, 0x3111, 0x2333, 0x2112, 0x2222, + 0x1333, 0x2111, 0x1233, 0x2111, 0x1233, 0x2111, 0x1223, 0x2111, + 0x1222, 0x2111, 0x1122, 0x2211, 0x1112, 0x3211, 0x1111, 0x3211, + 0x1333, 0x2121, 0x2333, 0x2111, 0x2332, 0x2111, 0x3332, 0x2111, + 0x3333, 0x2111, 0x3333, 0x1112, 0x3333, 0x1112, 0x3333, 0x1112, + 0x1333, 0x3311, 0x3333, 0x3212, 0x3333, 0x2223, 0x3332, 0x2123, + 0x3322, 0x1133, 0x3322, 0x1133, 0x3221, 0x1123, 0x2211, 0x2122, + 0x3333, 0x0013, 0x3333, 0x0012, 0x3332, 0x0012, 0x2222, 0x0111, + 0x1111, 0x1111, 0x1111, 0x1211, 0x1111, 0x2231, 0x2233, 0x2333, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0011, 0x0000, 0x0132, 0x0000, 0x0122, 0x0000, + 0x0000, 0x1100, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1111, 0x1223, 0x0000, 0x1111, 0x0000, 0x2310, 0x0000, 0x3310, + 0x0000, 0x3100, 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1111, 0x1111, 0x1121, 0x1111, 0x1222, 0x2111, 0x2233, 0x2333, + 0x3333, 0x1113, 0x1111, 0x0011, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1111, 0x2211, 0x1211, 0x2211, 0x3332, 0x1112, 0x1222, 0x1121, + 0x2111, 0x1131, 0x1000, 0x2322, 0x1000, 0x3332, 0x0000, 0x1111, + 0x2323, 0x1112, 0x2222, 0x2211, 0x1111, 0x3311, 0x1111, 0x2221, + 0x1111, 0x1222, 0x2221, 0x0112, 0x2333, 0x0001, 0x1111, 0x0000, + 0x1111, 0x2111, 0x1111, 0x2111, 0x1123, 0x2211, 0x2222, 0x3312, + 0x3211, 0x3333, 0x1100, 0x1332, 0x0000, 0x0111, 0x0000, 0x0000, + 0x1122, 0x1111, 0x0011, 0x0000, 0x0001, 0x0000, 0x0012, 0x0000, + 0x0012, 0x0000, 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0011, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, +}; + +#endif diff --git a/vapor/runtime/vapor.h b/vapor/runtime/vapor.h index a37c758a..c91f212f 100644 --- a/vapor/runtime/vapor.h +++ b/vapor/runtime/vapor.h @@ -56,6 +56,44 @@ typedef struct { u8 idx[VP_VIEW_CAP]; } vp_view; +/* ---- RPG host -------------------------------------------------------------- + * Static world/dialogue data is emitted by the Pocket Vapor compiler. Dynamic + * gameplay state remains in ordinary generated refs; these records only give + * the fixed GBA renderer and pure map-query helpers access to ROM assets. */ +typedef struct { + u8 tile; + u8 event; +} vp_rpg_event; + +typedef struct { + const char *speaker; + const char *line1; + const char *line2; + const char *choice0; + const char *choice1; +} vp_rpg_dialog; + +typedef struct { + u8 width; + u8 height; + const u8 *tiles; /* width * height source glyphs */ + const u8 *solid; /* width * height, 0 or 1 */ + const vp_rpg_event *events; + u8 event_count; + const vp_rpg_dialog *dialogs; + u8 dialog_count; +} vp_rpg_map; + +u8 vp_rpg_blocked(const vp_rpg_map *map, s32 x, s32 y); +u8 vp_rpg_event_at(const vp_rpg_map *map, s32 x, s32 y); +void vp_rpg_video_init(void); +void vp_rpg_render(const vp_rpg_map *map, u8 mode, s32 player_x, + s32 player_y, s32 player_offset_x, + s32 player_offset_y, u8 facing, u8 player_frame, + s32 quest, s32 dialog, s32 choice, s32 hero_hp, + s32 enemy_hp, s32 battle_cursor); +void vp_rpg_video_commit(void); + /* ---- grid (runtime-owned) -------------------------------------------------- */ void vp_row_clear(u8 y0, u8 y1); /* rows [y0, y1): space, pair 0 */ @@ -99,6 +137,9 @@ extern const u32 vp_bit32[32]; /* vp_bit32[n] == 1UL << n */ #define VP_RELATIVE_AXIS_SECONDARY 1 void app_init(void); /* seed state + first paint (all effects) */ void app_on_button(u8 b); /* one press edge, GBA key bit index */ +/* One fixed semantic tick with the hardware-neutral framework BTN mask. */ +void app_on_frame(u32 buttons); +void app_on_button_repeat(u8 b); /* normalized held-D-pad repeat */ /* Signed physical motion on a hardware-neutral relative axis. Axis 0 is * RelativeAxis.Primary. Rotary hosts use millidegrees and preserve the * signed total; applications own detents and sensitivity. */ @@ -121,5 +162,6 @@ extern const u16 vp_ink565[]; extern const u16 vp_paper565[]; extern const u8 vp_pal_style[]; extern const char vp_app_title[]; /* cartridge title, <= 12 chars */ +extern const u8 vp_rpg_enabled; /* compiler-selected GBA pixel host */ #endif diff --git a/vapor/scripts/play.ts b/vapor/scripts/play.ts index e310ed20..139ae4d9 100644 --- a/vapor/scripts/play.ts +++ b/vapor/scripts/play.ts @@ -11,7 +11,8 @@ const ROOT = join(import.meta.dir, "..", ".."); const entry = resolve(process.argv[2] ?? join(import.meta.dir, "..", "examples", "todo", "todo.tsx")); const name = basename(entry).replace(/\.tsx$/, ""); -const app = compileVaporApp(entry, await Bun.file(entry).text(), name === "todo" ? "VAPOR TODO" : name.toUpperCase()); +const title = name === "todo" ? "VAPOR TODO" : name === "rpg" ? "VAPOR QUEST" : name.toUpperCase(); +const app = compileVaporApp(entry, await Bun.file(entry).text(), title); console.log(app.graph); console.log(app.plan); const rom = join(ROOT, "dist", "vapor", `${name}.gba`); diff --git a/vapor/scripts/rpg-assets.ts b/vapor/scripts/rpg-assets.ts new file mode 100644 index 00000000..d58cfefd --- /dev/null +++ b/vapor/scripts/rpg-assets.ts @@ -0,0 +1,1504 @@ +// PixelLab -> deterministic GBA 4bpp assets for the Vapor Quest POC. +// +// `generate` is the only networked step. It reads PIXELLAB_API_KEY from the +// environment and commits no credential. `build` and `check` are offline and +// turn the reviewed source PNGs into exact palette-indexed sheets plus a C +// header consumed by the GBA runtime. + +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { createCanvas, loadImage, type SKRSContext2D } from "@napi-rs/canvas"; + +const ROOT = join(import.meta.dir, "..", "examples", "rpg", "assets"); +const SOURCE = join(ROOT, "source"); +const FINAL = join(ROOT, "final"); +const HEADER = join(import.meta.dir, "..", "runtime", "gba", "vapor_rpg_assets.generated.h"); +const MANIFEST = join(ROOT, "generation.json"); +const API = "https://api.pixellab.ai/v2"; + +const WORLD_TILE_SIZE = 16; +const WORLD_TILE_COLUMNS = 10; +const WORLD_ACTOR_SIZE = 32; +const BATTLE_ACTOR_SIZE = 64; +const WALK_DIRECTIONS = ["south", "north", "west", "east"] as const; +const WALK_FRAME_COUNT = 4; + +type Rgb = readonly [number, number, number]; +type Palette = readonly Rgb[]; +type ImageDataLike = { data: Uint8ClampedArray; width: number; height: number }; + +const BG: Palette = [ + [0x00, 0x00, 0x00], [0x18, 0x29, 0x4a], [0x21, 0x7b, 0x42], [0x42, 0xc6, 0x5a], + [0x84, 0x5a, 0x29], [0xc6, 0x9c, 0x52], [0x4a, 0x52, 0x63], [0x9c, 0xa5, 0xad], + [0x18, 0x4a, 0x94], [0x39, 0x94, 0xe7], [0x21, 0x52, 0x29], [0x29, 0xa5, 0x42], + [0xff, 0xd6, 0x42], [0x21, 0x31, 0x63], [0xef, 0xde, 0x94], [0xff, 0x52, 0x52], +] as const; + +const HERO: Palette = [ + [0x00, 0x00, 0x00], [0x18, 0x29, 0x4a], [0xc6, 0x6b, 0x42], [0xff, 0xad, 0x63], + [0x21, 0x52, 0xad], [0x42, 0x84, 0xff], [0x84, 0xb5, 0xff], [0xb5, 0x42, 0x31], + [0xff, 0x7b, 0x4a], [0xef, 0xde, 0x94], +] as const; +const ELDER: Palette = [ + [0x00, 0x00, 0x00], [0x18, 0x29, 0x4a], [0xc6, 0x6b, 0x42], [0xff, 0xad, 0x63], + [0x4a, 0x52, 0x63], [0x73, 0x7b, 0x8c], [0x9c, 0xa5, 0xad], [0xc6, 0xd6, 0xde], + [0xff, 0xff, 0xff], [0x42, 0x84, 0xff], +] as const; +const SLIME: Palette = [ + [0x00, 0x00, 0x00], [0x18, 0x29, 0x4a], [0x00, 0x5a, 0x5a], [0x00, 0xad, 0xad], + [0x39, 0xd6, 0xc6], [0x94, 0xf7, 0xde], [0xff, 0xff, 0xff], +] as const; + +const STYLE_PALETTE: Palette = [ + [0x18, 0x29, 0x4a], [0x21, 0x31, 0x63], [0x21, 0x7b, 0x42], [0x42, 0xc6, 0x5a], + [0x84, 0x5a, 0x29], [0xc6, 0x9c, 0x52], [0x18, 0x4a, 0x94], [0x39, 0x94, 0xe7], + [0xc6, 0x6b, 0x42], [0xff, 0xad, 0x63], [0x42, 0x84, 0xff], [0xff, 0x7b, 0x4a], + [0x73, 0x7b, 0x8c], [0xff, 0xff, 0xff], [0x00, 0xad, 0xad], [0x94, 0xf7, 0xde], +] as const; + +const WORLD_TILE_NAMES = [ + "blank", "grass-a", "grass-b", "path-a", "path-b", "wall", "water-a", "water-b", + "tree", "flower", +] as const; +const UI_TILE_NAMES = [ + "box-fill", "box-top", "box-bottom", "box-left", "box-right", "box-tl", "box-tr", "box-bl", + "box-br", "battle-sky", "battle-ground", "hp-empty", "hp-full", "hud", +] as const; +const ACTOR_NAMES = ["hero-south", "hero-north", "hero-west", "hero-east", "elder", "slime"] as const; +const WALK_SOURCE_FILES = WALK_DIRECTIONS.flatMap((direction) => + Array.from({ length: WALK_FRAME_COUNT }, (_, frame) => `hero-walk-${direction}-${frame}.png`) +); + +const STYLE = [ + "Original cheerful early-2000s handheld cartridge RPG pixel art.", + "High top-down world view, expressive 32-pixel chibi characters, crisp hard-edged native pixels.", + "One-pixel selective deep-navy outlines, flat clusters, fixed top-left lighting.", + "Readable silhouettes and restrained surface texture; no imitation of any existing game.", +].join(" "); + +const TERRAIN_STYLE = [ + "Original cheerful early-2000s handheld cartridge RPG terrain pixel art.", + "Strict high top-down orthographic view, crisp hard-edged native pixels.", + "Small flat color clusters, fixed top-left lighting, restrained readable texture.", + "No characters, items, icons, perspective, canvas border or imitation of an existing game.", +].join(" "); + +const MONSTER_STYLE = [ + "Original cheerful early-2000s handheld cartridge RPG monster pixel art.", + "High top-down view, crisp hard-edged native pixels, flat color clusters and fixed top-left lighting.", + "One-pixel selective deep-navy outline, readable non-humanoid silhouette, no imitation of any existing game.", +].join(" "); + +const EXCLUSIONS = [ + "antialiasing", "blur", "gradients", "dithering", "soft shadows", "bloom", "photorealism", + "painterly texture", "isometric view", "perspective", "text", "letters", "numbers", "labels", + "watermark", "logo", "mockup", "enlarged preview", "multiple characters", "duplicate subject", + "sprite sheet", "contact sheet", "lineup", "alternate poses", +].join(", "); + +const GENERATION = { + version: 3, + provider: "PixelLab", + apiBase: API, + artDirection: "Vapor Quest close-up: 16px world cells, 32px actors, deep-navy information layer", + assets: { + styleAnchor: { + endpoint: "/create-image-pixen", + seed: 23050, + size: { width: 64, height: 64 }, + prompt: `${STYLE} Exactly one single south-facing full-body blue-tunic adventurer with short brown hair and a warm orange scarf, centered in a neutral standing pose on transparent background. Blue clothing must be the largest color area. This is a canonical style reference, not a sprite sheet: one subject, one pose, no duplicate, no alternate view, no scenery, text or UI.`, + }, + terrainSets: [ + { + name: "field", + endpoint: "/tilesets", + seed: 23061, + lower: `${TERRAIN_STYLE} Native 16x16 seamless bright green meadow grass, quiet walkable ground, four or five deliberate dark grass blades, no objects or border.`, + upper: `${TERRAIN_STYLE} Native 16x16 seamless warm tan compacted village footpath, quiet walkable ground, a few readable brown pebbles, no grass edge or border.`, + lowerFile: "grass.png", + upperFile: "path.png", + }, + { + name: "barriers", + endpoint: "/tilesets", + seed: 23062, + lower: `${TERRAIN_STYLE} Native 16x16 seamless deep blue stream water, clearly impassable, three crisp horizontal ripple clusters, quiet even field, no shore or border.`, + upper: `${TERRAIN_STYLE} Native 16x16 seamless gray stone barrier, clearly solid and impassable, chunky rectangular stones, dark mortar and bright top-left edges, no grass or border.`, + lowerFile: "water.png", + upperFile: "wall.png", + }, + ], + mapObjects: [ + { + name: "tree", + endpoint: "/map-objects", + seed: 23069, + prompt: `${STYLE} One centered top-down deciduous tree map object designed to fill one 16x16 world cell after reduction. A single compact, solid, filled round dome canopy occupies most of the image and must never form a hollow ring, split crown, doorway or arch. Dense leaves read as impassable, with dark lower-right foliage, bright top-left leaf clusters and exactly one short centered trunk. No grass tile and no cast shadow.`, + }, + { + name: "flower", + endpoint: "/map-objects", + seed: 23066, + prompt: `${STYLE} One readable gold-and-cream meadow flower cluster designed for one 16x16 world cell after reduction. Low-profile walkable decoration, three small blossoms and delicate green stems, no enclosing outline, no ground tile and no cast shadow.`, + }, + ], + heroSouth: { + endpoint: "/create-image-bitforge", + seed: 23063, + size: { width: 64, height: 64 }, + prompt: `${STYLE} Exactly one south-facing native 64x64 source sprite designed to retain facial, clothing and limb detail in a 32x32 GBA character. Full-body chibi adventurer with a readable head, short brown hair, torso, two arms, gloves, boots and two separated feet. Blue tunic and darker blue trousers are the largest color blocks; warm orange-red scarf is the identity accent; warm skin and selective deep-navy outline. Centered, feet at the bottom, strong silhouette, no weapon, no detached pixels, no duplicate, no alternate pose, no sprite sheet.`, + }, + heroRotations: { + endpoint: "/create-character-v3", + seed: 23054, + prompt: "The same expressive blue-tunic adventurer with short brown hair, orange-red scarf, gloves and boots, rotated consistently for a high top-down RPG; preserve face, head height, shoulder width, clothing blocks, palette, outline and foot anchor in every direction.", + }, + heroWalk: { + endpoint: "/characters/animations", + seed: 23071, + templateAnimationId: "walking-4-frames", + animationName: "Vapor Quest Walk", + directions: WALK_DIRECTIONS, + frameCount: WALK_FRAME_COUNT, + prompt: "A compact, cyclic four-frame walk with alternating arms and legs, steady high top-down camera, stable body proportions, fixed ground plane and no translation inside the source canvas.", + }, + elder: { + endpoint: "/create-image-pixen", + seed: 23060, + size: { width: 64, height: 64 }, + prompt: `${STYLE} Exactly one south-facing native 64x64 source sprite designed to retain facial, hair and robe detail in a 32x32 GBA character. Full-body chibi village elder with swept white hair, eyebrows, short white beard, broad layered gray robe, blue sash, warm skin, deep-navy outline, two visible arms and a grounded hem. Same height, head ratio, lighting and foot anchor as the blue-tunic adventurer. No staff, centered, no skeleton or ghost appearance, no detached pixels, no duplicate, no alternate pose, no sprite sheet.`, + }, + slime: { + endpoint: "/map-objects", + seed: 23065, + size: { width: 64, height: 64 }, + prompt: `${MONSTER_STYLE} Exactly one native 64x64 source sprite designed to retain face, highlight and volume detail in a 32x32 GBA monster. A compact teal tentacled puddle ooze with a rounded central body and several short grounded pseudopods. Its amorphous body is at least ninety percent a four-step teal ramp, with a deep-navy outline, broad grounded base, two tiny white eyes and a small dark mouth. Cute but clearly an enemy, wider than tall, with no humanoid torso, clothing, hands or feet, no floating parts, centered at the bottom, no duplicate, no alternate pose, no sprite sheet.`, + }, + }, +} as const; + +function ensureDirs(): void { + mkdirSync(SOURCE, { recursive: true }); + mkdirSync(FINAL, { recursive: true }); +} + +function hex([r, g, b]: Rgb): string { + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; +} + +function paletteText(palette: Palette): string { + return palette.map(hex).join(", "); +} + +function palettePng(palette: Palette): Buffer { + const canvas = createCanvas(64, 16); + const ctx = canvas.getContext("2d"); + for (let i = 0; i < 16; i++) { + const color = palette[Math.min(i, palette.length - 1)]; + ctx.fillStyle = hex(color); + ctx.fillRect(i * 4, 0, 4, 16); + } + return canvas.toBuffer("image/png"); +} + +function paletteGuidePng(): Buffer { + const canvas = createCanvas(64, 64); + const ctx = canvas.getContext("2d"); + for (const [row, palette] of [BG, HERO, ELDER, SLIME].entries()) { + for (let i = 0; i < 16; i++) { + // Match the generated GBA palette banks: unused OBJ entries are zero, + // not repetitions of the last authored color. + const color = palette[i] ?? ([0, 0, 0] as const); + ctx.fillStyle = hex(color); + ctx.fillRect(i * 4, row * 16, 4, 16); + } + } + return canvas.toBuffer("image/png"); +} + +function base64Image(bytes: Buffer): { type: "base64"; base64: string; format: "png" } { + return { type: "base64", base64: bytes.toString("base64"), format: "png" }; +} + +async function resizePng(bytes: Buffer, width: number, height: number): Promise { + const image = await loadImage(bytes); + const canvas = createCanvas(width, height); + const ctx = canvas.getContext("2d"); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(image, 0, 0, width, height); + return canvas.toBuffer("image/png"); +} + +async function pixelLab(path: string, body: unknown): Promise { + const key = process.env.PIXELLAB_API_KEY; + if (!key) throw new Error("PIXELLAB_API_KEY is not set; source ~/code/.env first"); + const response = await fetch(`${API}${path}`, { + method: "POST", + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const detail = (await response.text()).slice(0, 1000); + throw new Error(`PixelLab ${path} failed (${response.status}): ${detail}`); + } + return await response.json() as T; +} + +function imageBytes(image: { base64: string }): Buffer { + const encoded = image.base64.includes("base64,") + ? image.base64.slice(image.base64.indexOf("base64,") + 7) + : image.base64; + return Buffer.from(encoded, "base64"); +} + +async function generatePixen( + file: string, + spec: { prompt: string; seed: number; size: { width: number; height: number } }, + palette: Palette, +): Promise<{ hash: string }> { + console.log(`PixelLab: generating ${file}`); + const response = await pixelLab<{ image: { base64: string } }>("/create-image-pixen", { + description: `${spec.prompt} Use this fixed color language: ${paletteText(palette)}. Avoid ${EXCLUSIONS}.`, + image_size: spec.size, + outline: "selective outline", + detail: "medium detail", + view: "high top-down", + direction: "south", + no_background: true, + background_removal_task: "remove_simple_background", + seed: spec.seed, + }); + const bytes = imageBytes(response.image); + writeFileSync(join(SOURCE, file), bytes); + return { hash: sha256(bytes) }; +} + +async function generateBitforge( + file: string, + spec: { prompt: string; seed: number; size: { width: number; height: number } }, + palette: Palette, + styleImage: Buffer, +): Promise<{ hash: string }> { + console.log(`PixelLab: generating ${file}`); + const styleReference = await resizePng(styleImage, spec.size.width, spec.size.height); + const response = await pixelLab<{ image: { base64: string } }>("/create-image-bitforge", { + description: `${spec.prompt} Use only these palette colors: ${paletteText(palette)}.`, + negative_description: EXCLUSIONS, + image_size: spec.size, + text_guidance_scale: 12, + style_strength: 35, + outline: "selective outline", + shading: "basic shading", + detail: "medium detail", + view: "high top-down", + direction: "south", + no_background: true, + coverage_percentage: 84, + color_image: base64Image(palettePng(palette)), + style_image: base64Image(styleReference), + seed: spec.seed, + }); + const bytes = imageBytes(response.image); + writeFileSync(join(SOURCE, file), bytes); + return { hash: sha256(bytes) }; +} + +async function generateHeroRotations(reference: Buffer): Promise<{ + backgroundJobId: string; + characterId: string; + hashes: Record; +}> { + const spec = GENERATION.assets.heroRotations; + console.log("PixelLab: generating consistent hero rotations"); + const created = await pixelLab<{ + background_job_id: string; + character_id: string; + }>("/create-character-v3", { + description: spec.prompt, + reference_image: base64Image(reference), + view: "high top-down", + template_id: "mannequin", + name: "Vapor Quest Hero", + seed: spec.seed, + no_background: true, + outline: "selective outline", + detail: "medium detail", + }); + + for (;;) { + await Bun.sleep(5000); + const response = await fetch(`${API}/background-jobs/${created.background_job_id}`, { + headers: { Authorization: `Bearer ${process.env.PIXELLAB_API_KEY}` }, + }); + if (!response.ok) throw new Error(`PixelLab job poll failed (${response.status})`); + const job = await response.json() as { status: string; last_response?: unknown }; + console.log(`PixelLab: hero rotations ${job.status}`); + if (job.status === "failed") throw new Error(`PixelLab hero rotation job failed: ${JSON.stringify(job.last_response)}`); + if (job.status === "completed") break; + } + + const detailResponse = await fetch(`${API}/characters/${created.character_id}`, { + headers: { Authorization: `Bearer ${process.env.PIXELLAB_API_KEY}` }, + }); + if (!detailResponse.ok) throw new Error(`PixelLab character fetch failed (${detailResponse.status})`); + const detail = await detailResponse.json() as { + rotation_urls: Record | null; + }; + if (!detail.rotation_urls) throw new Error("PixelLab returned no hero rotations"); + const hashes: Record = {}; + for (const direction of ["south", "north", "west", "east"] as const) { + const url = detail.rotation_urls[direction]; + if (!url) throw new Error(`PixelLab returned no ${direction} hero rotation`); + const response = await fetch(url); + if (!response.ok) throw new Error(`PixelLab ${direction} rotation download failed (${response.status})`); + const bytes = Buffer.from(await response.arrayBuffer()); + writeFileSync(join(SOURCE, `hero-${direction}.png`), bytes); + hashes[direction] = sha256(bytes); + } + return { + backgroundJobId: created.background_job_id, + characterId: created.character_id, + hashes, + }; +} + +async function waitForJob(jobId: string, label: string): Promise> { + for (;;) { + await Bun.sleep(5000); + const response = await fetch(`${API}/background-jobs/${jobId}`, { + headers: { Authorization: `Bearer ${process.env.PIXELLAB_API_KEY}` }, + }); + if (!response.ok) throw new Error(`PixelLab ${label} job poll failed (${response.status})`); + const job = await response.json() as { status: string; last_response?: Record }; + console.log(`PixelLab: ${label} ${job.status}`); + if (job.status === "failed") throw new Error(`PixelLab ${label} failed: ${JSON.stringify(job.last_response)}`); + if (job.status === "completed") return job.last_response ?? {}; + } +} + +type CharacterAnimationGroup = { + animation_type: string; + display_name?: string | null; + animation_group_id?: string | null; + directions: Array<{ direction: string; frame_count: number; frames: string[] }>; +}; + +async function generateHeroWalk(characterId: string): Promise<{ + characterId: string; + animationGroupId: string; + backgroundJobIds: Record; + hashes: Record; +}> { + const spec = GENERATION.assets.heroWalk; + console.log("PixelLab: generating consistent four-direction hero walk"); + const created = await pixelLab<{ + background_job_ids: string[]; + directions: string[]; + }>(spec.endpoint, { + character_id: characterId, + mode: "template", + template_animation_id: spec.templateAnimationId, + animation_name: spec.animationName, + action_description: spec.prompt, + directions: spec.directions, + outline: "selective outline", + shading: "basic shading", + detail: "medium detail", + color_image: base64Image(palettePng(HERO)), + force_colors: true, + seed: spec.seed, + }); + if (created.background_job_ids.length !== WALK_DIRECTIONS.length || + created.directions.length !== WALK_DIRECTIONS.length) { + throw new Error("PixelLab hero walk did not queue exactly four directions"); + } + const backgroundJobIds: Record = {}; + const groupIds = new Set(); + for (let index = 0; index < created.background_job_ids.length; index++) { + const direction = created.directions[index]; + const jobId = created.background_job_ids[index]; + if (!WALK_DIRECTIONS.includes(direction as typeof WALK_DIRECTIONS[number])) { + throw new Error(`PixelLab hero walk returned unexpected direction ${direction}`); + } + backgroundJobIds[direction] = jobId; + const result = await waitForJob(jobId, `hero walk ${direction}`); + const groupId = result.animation_group_id; + if (typeof groupId === "string") groupIds.add(groupId); + } + if (Object.keys(backgroundJobIds).length !== WALK_DIRECTIONS.length) { + throw new Error("PixelLab hero walk returned duplicate directions"); + } + + let group: CharacterAnimationGroup | undefined; + for (let attempt = 0; attempt < 12 && !group; attempt++) { + const detailResponse = await fetch(`${API}/characters/${characterId}`, { + headers: { Authorization: `Bearer ${process.env.PIXELLAB_API_KEY}` }, + }); + if (!detailResponse.ok) throw new Error(`PixelLab hero walk character fetch failed (${detailResponse.status})`); + const detail = await detailResponse.json() as { animations?: CharacterAnimationGroup[] }; + const animations = detail.animations ?? []; + group = animations.find((candidate) => + !!candidate.animation_group_id && groupIds.has(candidate.animation_group_id) + ) ?? [...animations].reverse().find((candidate) => + candidate.display_name === spec.animationName && candidate.animation_type === spec.templateAnimationId + ) ?? [...animations].reverse().find((candidate) => candidate.animation_type === spec.templateAnimationId); + if (!group) await Bun.sleep(5000); + } + if (!group) throw new Error("PixelLab hero walk animation group did not become available"); + if (!group.animation_group_id) throw new Error("PixelLab hero walk animation has no group ID"); + + const hashes: Record = {}; + for (const direction of WALK_DIRECTIONS) { + const sequence = group.directions.find((candidate) => candidate.direction === direction); + if (!sequence || sequence.frame_count !== WALK_FRAME_COUNT || sequence.frames.length !== WALK_FRAME_COUNT) { + throw new Error(`PixelLab hero walk ${direction} must contain exactly ${WALK_FRAME_COUNT} frames`); + } + for (let frame = 0; frame < WALK_FRAME_COUNT; frame++) { + const response = await fetch(sequence.frames[frame]); + if (!response.ok) throw new Error(`PixelLab hero walk ${direction} frame ${frame} download failed (${response.status})`); + const bytes = Buffer.from(await response.arrayBuffer()); + const file = `hero-walk-${direction}-${frame}.png`; + writeFileSync(join(SOURCE, file), bytes); + hashes[file] = sha256(bytes); + } + } + return { + characterId, + animationGroupId: group.animation_group_id, + backgroundJobIds, + hashes, + }; +} + +async function generateTileset(spec: typeof GENERATION.assets.terrainSets[number]): Promise> { + console.log(`PixelLab: generating ${spec.name} terrain tileset`); + const created = await pixelLab<{ background_job_id: string; tileset_id: string }>("/tilesets", { + lower_description: spec.lower, + upper_description: spec.upper, + transition_description: "clean hard pixel boundary", + tile_size: { width: 16, height: 16 }, + mode: "standard", + text_guidance_scale: 10, + outline: "selective outline", + shading: "basic shading", + detail: "low detail", + view: "high top-down", + tile_strength: 1.3, + tileset_adherence_freedom: 400, + tileset_adherence: 120, + transition_size: 0.25, + seed: spec.seed, + }); + await waitForJob(created.background_job_id, `${spec.name} terrain`); + const response = await fetch(`${API}/tilesets/${created.tileset_id}`, { + headers: { Authorization: `Bearer ${process.env.PIXELLAB_API_KEY}` }, + }); + if (!response.ok) throw new Error(`PixelLab ${spec.name} tileset fetch failed (${response.status})`); + const result = await response.json() as { + tileset: { tiles: Array<{ + corners: Record; + image: { type: "base64"; base64: string; format: string }; + }> }; + }; + const hashes: Record = {}; + for (const [terrain, file] of [["lower", spec.lowerFile], ["upper", spec.upperFile]] as const) { + const tile = result.tileset.tiles.find((candidate) => + Object.values(candidate.corners).every((corner) => corner === terrain)); + if (!tile) throw new Error(`PixelLab ${spec.name} tileset has no all-${terrain} base tile`); + const bytes = imageBytes(tile.image); + writeFileSync(join(SOURCE, file), bytes); + hashes[file] = sha256(bytes); + } + return { backgroundJobId: created.background_job_id, tilesetId: created.tileset_id, ...hashes }; +} + +async function generateMapObject(spec: typeof GENERATION.assets.mapObjects[number]): Promise> { + console.log(`PixelLab: generating ${spec.name} map object`); + const created = await pixelLab<{ background_job_id: string; object_id: string }>("/map-objects", { + description: `${spec.prompt} Use only these palette colors: ${paletteText(BG)}.`, + image_size: { width: 32, height: 32 }, + view: "high top-down", + outline: "selective outline", + shading: "basic shading", + detail: "low detail", + text_guidance_scale: 10, + color_image: base64Image(await resizePng(palettePng(BG), 32, 32)), + seed: spec.seed, + }); + await waitForJob(created.background_job_id, `${spec.name} object`); + const detail = await fetch(`${API}/map-objects/${created.object_id}`, { + headers: { Authorization: `Bearer ${process.env.PIXELLAB_API_KEY}` }, + }); + if (!detail.ok) throw new Error(`PixelLab ${spec.name} object fetch failed (${detail.status})`); + const object = await detail.json() as { download_url?: string | null }; + if (!object.download_url) throw new Error(`PixelLab ${spec.name} object has no download URL`); + const download = await fetch(object.download_url); + if (!download.ok) throw new Error(`PixelLab ${spec.name} object download failed (${download.status})`); + const bytes = Buffer.from(await download.arrayBuffer()); + writeFileSync(join(SOURCE, `${spec.name}.png`), bytes); + return { backgroundJobId: created.background_job_id, objectId: created.object_id, hash: sha256(bytes) }; +} + +async function generateMapSprite( + file: string, + spec: { prompt: string; seed: number; size: { width: number; height: number } }, + palette: Palette, +): Promise> { + console.log(`PixelLab: generating ${file}`); + const created = await pixelLab<{ background_job_id: string; object_id: string }>("/map-objects", { + description: `${spec.prompt} Use only these palette colors: ${paletteText(palette)}.`, + image_size: spec.size, + view: "high top-down", + outline: "selective outline", + shading: "basic shading", + detail: "medium detail", + text_guidance_scale: 12, + color_image: base64Image(await resizePng(palettePng(palette), spec.size.width, spec.size.height)), + seed: spec.seed, + }); + await waitForJob(created.background_job_id, file); + const detail = await fetch(`${API}/map-objects/${created.object_id}`, { + headers: { Authorization: `Bearer ${process.env.PIXELLAB_API_KEY}` }, + }); + if (!detail.ok) throw new Error(`PixelLab ${file} fetch failed (${detail.status})`); + const object = await detail.json() as { download_url?: string | null }; + if (!object.download_url) throw new Error(`PixelLab ${file} has no download URL`); + const download = await fetch(object.download_url); + if (!download.ok) throw new Error(`PixelLab ${file} download failed (${download.status})`); + const bytes = Buffer.from(await download.arrayBuffer()); + writeFileSync(join(SOURCE, file), bytes); + return { + backgroundJobId: created.background_job_id, + objectId: created.object_id, + hash: sha256(bytes), + }; +} + +function sha256(bytes: Uint8Array | string): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +async function generate(force: boolean): Promise { + ensureDirs(); + const onlyArg = process.argv.find((arg) => arg.startsWith("--only=")); + const only = onlyArg?.slice("--only=".length) ?? "missing"; + if (!["missing", "all", "style", "world", "tree", "flower", "characters", "hero", "walk", "elder", "slime"].includes(only)) { + throw new Error("--only must be one of missing, all, style, world, tree, flower, characters, hero, walk, elder, slime"); + } + const previous = existsSync(MANIFEST) + ? JSON.parse(readFileSync(MANIFEST, "utf8")) as { records?: Record } + : {}; + const records: Record = { ...previous.records }; + const selected = (group: string, files: string[]): boolean => + only === "all" || only === group || files.some((file) => !existsSync(join(SOURCE, file))); + + if (selected("style", ["style-anchor.png"]) && (force || !existsSync(join(SOURCE, "style-anchor.png")))) { + records.styleAnchor = await generatePixen("style-anchor.png", GENERATION.assets.styleAnchor, STYLE_PALETTE); + } + const anchor = readFileSync(join(SOURCE, "style-anchor.png")); + const worldFiles = [ + ...GENERATION.assets.terrainSets.flatMap((asset) => [asset.lowerFile, asset.upperFile]), + ...GENERATION.assets.mapObjects.map((asset) => `${asset.name}.png`), + ]; + const worldSelected = only === "all" || only === "world" || only === "tree" || only === "flower" + || worldFiles.some((file) => !existsSync(join(SOURCE, file))); + if (worldSelected) { + records.world = { + ...(typeof records.world === "object" && records.world !== null + ? records.world as Record + : {}), + }; + for (const asset of GENERATION.assets.terrainSets) { + const forceTerrain = force && (only === "all" || only === "world"); + if (forceTerrain || !existsSync(join(SOURCE, asset.lowerFile)) || !existsSync(join(SOURCE, asset.upperFile))) { + (records.world as Record)[asset.name] = await generateTileset(asset); + } + } + for (const asset of GENERATION.assets.mapObjects) { + const file = `${asset.name}.png`; + const forceObject = force && (only === "all" || only === "world" || only === asset.name); + if (forceObject || !existsSync(join(SOURCE, file))) { + (records.world as Record)[asset.name] = await generateMapObject(asset); + } + } + } + + const characterFiles = [ + "hero-south-reference.png", "hero-south.png", "hero-north.png", "hero-west.png", "hero-east.png", + "elder.png", "slime.png", + ]; + const charactersMissing = characterFiles.some((file) => !existsSync(join(SOURCE, file))); + const charactersSelected = only === "all" || only === "characters" || only === "hero" + || only === "elder" || only === "slime" || charactersMissing; + let heroRegenerated = false; + if (charactersSelected) { + const forceHero = force && (only === "all" || only === "characters" || only === "hero"); + if (forceHero || !existsSync(join(SOURCE, "hero-south-reference.png"))) { + records.heroSouth = await generateBitforge("hero-south-reference.png", GENERATION.assets.heroSouth, HERO, anchor); + } + const rotationsMissing = ["hero-south.png", "hero-north.png", "hero-west.png", "hero-east.png"] + .some((file) => !existsSync(join(SOURCE, file))); + if (forceHero || rotationsMissing) { + records.heroRotations = await generateHeroRotations(readFileSync(join(SOURCE, "hero-south-reference.png"))); + heroRegenerated = true; + } + const forceElder = force && (only === "all" || only === "characters" || only === "elder"); + if (forceElder || !existsSync(join(SOURCE, "elder.png"))) { + records.elder = await generatePixen("elder.png", GENERATION.assets.elder, ELDER); + } + const forceSlime = force && (only === "all" || only === "characters" || only === "slime"); + if (forceSlime || !existsSync(join(SOURCE, "slime.png"))) { + records.slime = await generateMapSprite("slime.png", GENERATION.assets.slime, SLIME); + } + } + const walkRecord = records.heroWalk as { characterId?: unknown } | undefined; + const walkMissing = WALK_SOURCE_FILES.some((file) => !existsSync(join(SOURCE, file))) + || typeof walkRecord?.characterId !== "string"; + const walkSelected = only === "all" || only === "characters" || only === "hero" || only === "walk" || walkMissing; + const forceWalk = heroRegenerated || (force && ["all", "characters", "hero", "walk"].includes(only)); + if (walkSelected && (forceWalk || walkMissing)) { + const rotations = records.heroRotations as { characterId?: unknown } | undefined; + if (typeof rotations?.characterId !== "string") { + throw new Error("hero walk needs a generated PixelLab character; run --only=hero first"); + } + records.heroWalk = await generateHeroWalk(rotations.characterId); + } + delete records.backgroundAtlas; + const sourceHashes = Object.fromEntries( + ["style-anchor.png", ...worldFiles, ...characterFiles, ...WALK_SOURCE_FILES] + .map((file) => [file, sha256(readFileSync(join(SOURCE, file)))]), + ); + writeFileSync(MANIFEST, `${JSON.stringify({ ...GENERATION, records, sourceHashes }, null, 2)}\n`); + await build(false); +} + +function nearestColor(r: number, g: number, b: number, palette: Palette, start = 0): number { + let best = start; + let bestDistance = Number.POSITIVE_INFINITY; + for (let i = start; i < palette.length; i++) { + const color = palette[i]; + const distance = (r - color[0]) ** 2 + (g - color[1]) ** 2 + (b - color[2]) ** 2; + if (distance < bestDistance) { + best = i; + bestDistance = distance; + } + } + return best; +} + +/** + * Preserve a generated terrain tile's pixel clusters while translating its + * arbitrary colors into a small semantic GBA ramp. The input luminance range + * determines the authored texture; the ramp only fixes material identity. + */ +export function projectTerrain( + rgba: Uint8ClampedArray, + width: number, + height: number, + shadow: number, + base: number, + highlight: number, + shadowCount: number, + highlightCount: number, +): Uint8Array { + if (rgba.length !== width * height * 4) throw new Error("terrain RGBA dimensions do not match"); + if (shadowCount < 0 || highlightCount < 0 || shadowCount + highlightCount > width * height) { + throw new Error("terrain projection counts exceed the source tile"); + } + const ranked = Array.from({ length: width * height }, (_, index) => ({ + index, + // Integer Rec. 709-style weights plus an index tie-break make the same + // reviewed PNG produce byte-identical art on every build host. + luminance: rgba[index * 4] * 54 + rgba[index * 4 + 1] * 183 + rgba[index * 4 + 2] * 19, + })).sort((a, b) => a.luminance - b.luminance || a.index - b.index); + const result = new Uint8Array(width * height); + result.fill(base); + for (let i = 0; i < shadowCount; i++) result[ranked[i].index] = shadow; + for (let i = 0; i < highlightCount; i++) result[ranked[ranked.length - 1 - i].index] = highlight; + return result; +} + +function indicesFromContext(ctx: SKRSContext2D, width: number, height: number, palette: Palette, transparent: boolean): Uint8Array { + const raw = ctx.getImageData(0, 0, width, height) as ImageDataLike; + const result = new Uint8Array(width * height); + for (let i = 0; i < result.length; i++) { + const alpha = raw.data[i * 4 + 3]; + if (transparent && alpha < 128) { + result[i] = 0; + } else { + result[i] = nearestColor(raw.data[i * 4], raw.data[i * 4 + 1], raw.data[i * 4 + 2], palette, transparent ? 1 : 0); + } + } + return result; +} + +function drawIndices(ctx: SKRSContext2D, pixels: Uint8Array, width: number, height: number, palette: Palette, dx: number, dy: number, transparent: boolean): void { + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = pixels[y * width + x]; + if (transparent && index === 0) continue; + ctx.fillStyle = hex(palette[index]); + ctx.fillRect(dx + x, dy + y, 1, 1); + } + } +} + +async function loadExact(path: string, width: number, height: number) { + if (!existsSync(path)) throw new Error(`missing source asset: ${path}; run vapor:rpg:assets:generate`); + const image = await loadImage(path); + if (image.width !== width || image.height !== height) { + throw new Error(`${path} must be ${width}x${height}, got ${image.width}x${image.height}`); + } + return image; +} + +export async function buildBackground(sourceDir = SOURCE): Promise<{ png: Buffer; pixels: Uint8Array }> { + const sheetWidth = WORLD_TILE_COLUMNS * WORLD_TILE_SIZE; + const sheetHeight = WORLD_TILE_SIZE + 8; + const canvas = createCanvas(sheetWidth, sheetHeight); + const ctx = canvas.getContext("2d"); + ctx.imageSmoothingEnabled = false; + ctx.fillStyle = hex(BG[1]); + ctx.fillRect(0, 0, sheetWidth, sheetHeight); + const images = Object.fromEntries(await Promise.all([ + ...["grass", "path", "wall", "water"].map(async (name) => [name, await loadExact(join(sourceDir, `${name}.png`), 16, 16)]), + ...["tree", "flower"].map(async (name) => [name, await loadExact(join(sourceDir, `${name}.png`), 32, 32)]), + ])); + const drawWorldTile = (name: string, frame: number, flip = false) => { + const dx = frame * WORLD_TILE_SIZE; + ctx.save(); + if (flip) { + ctx.translate(dx + WORLD_TILE_SIZE, 0); + ctx.scale(-1, 1); + ctx.drawImage(images[name], 0, 0, WORLD_TILE_SIZE, WORLD_TILE_SIZE); + } else { + ctx.drawImage(images[name], dx, 0, WORLD_TILE_SIZE, WORLD_TILE_SIZE); + } + ctx.restore(); + }; + const drawObject = (name: string, frame: number, maxWidth: number, maxHeight: number): Set => { + const image = images[name]; + const scratch = createCanvas(image.width, image.height); + const scratchCtx = scratch.getContext("2d"); + scratchCtx.drawImage(image, 0, 0); + const raw = scratchCtx.getImageData(0, 0, image.width, image.height) as ImageDataLike; + let minX = image.width; + let minY = image.height; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < image.height; y++) { + for (let x = 0; x < image.width; x++) { + if (raw.data[(y * image.width + x) * 4 + 3] >= 128) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + if (maxX < minX || maxY < minY) throw new Error(`${name}.png has no opaque pixels`); + const sourceWidth = maxX - minX + 1; + const sourceHeight = maxY - minY + 1; + const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight); + const width = Math.max(1, Math.round(sourceWidth * scale)); + const height = Math.max(1, Math.round(sourceHeight * scale)); + const localX = Math.floor((WORLD_TILE_SIZE - width) / 2); + const localY = WORLD_TILE_SIZE - height; + const ox = frame * WORLD_TILE_SIZE + localX; + const oy = localY; + ctx.imageSmoothingEnabled = false; + ctx.drawImage(image, minX, minY, sourceWidth, sourceHeight, ox, oy, width, height); + const mask = new Set(); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const sx = minX + Math.min(sourceWidth - 1, Math.floor(x * sourceWidth / width)); + const sy = minY + Math.min(sourceHeight - 1, Math.floor(y * sourceHeight / height)); + if (raw.data[(sy * image.width + sx) * 4 + 3] >= 128) { + mask.add((localY + y) * WORLD_TILE_SIZE + localX + x); + } + } + } + return mask; + }; + drawWorldTile("grass", 1); + drawWorldTile("grass", 2, true); + drawWorldTile("path", 3); + drawWorldTile("path", 4, true); + drawWorldTile("wall", 5); + drawWorldTile("water", 6); + drawWorldTile("water", 7, true); + drawWorldTile("grass", 8); + drawWorldTile("grass", 9, true); + const treeMask = drawObject("tree", 8, 15, 16); + const flowerMask = drawObject("flower", 9, 10, 11); + const indices = indicesFromContext(ctx, sheetWidth, sheetHeight, BG, false); + + const worldAt = (frame: number, x: number, y: number): number => + y * sheetWidth + frame * WORLD_TILE_SIZE + x; + const paintWorld = (frame: number, x: number, y: number, color: number): void => { + indices[worldAt(frame, x, y)] = color; + }; + + // The generated PNGs own the base texture. Translate their luminance + // clusters into material-specific ramps, then add only sparse readability + // accents below. This keeps PixelLab input materially present in the ROM. + const sourceRgba = (ctx.getImageData(0, 0, sheetWidth, WORLD_TILE_SIZE) as ImageDataLike).data; + for (const [frame, sourceFrame, shadow, base, highlight, shadowCount, highlightCount] of [ + [1, 1, 10, 3, 11, 8, 4], [2, 2, 10, 3, 11, 8, 4], + [3, 3, 4, 5, 14, 20, 10], [4, 4, 4, 5, 14, 20, 10], + [5, 5, 6, 7, 14, 32, 12], + [6, 6, 8, 9, 13, 28, 8], [7, 7, 8, 9, 13, 28, 8], + // Object frames reuse pristine grass projection; their PixelLab masks are + // applied afterwards and cannot perturb the surrounding ground texture. + [8, 1, 10, 3, 11, 8, 4], [9, 2, 10, 3, 11, 8, 4], + ] as const) { + const tile = new Uint8ClampedArray(WORLD_TILE_SIZE * WORLD_TILE_SIZE * 4); + for (let y = 0; y < WORLD_TILE_SIZE; y++) { + for (let x = 0; x < WORLD_TILE_SIZE; x++) { + const sourceAt = (y * sheetWidth + sourceFrame * WORLD_TILE_SIZE + x) * 4; + const targetAt = (y * WORLD_TILE_SIZE + x) * 4; + tile[targetAt] = sourceRgba[sourceAt]; + tile[targetAt + 1] = sourceRgba[sourceAt + 1]; + tile[targetAt + 2] = sourceRgba[sourceAt + 2]; + tile[targetAt + 3] = sourceRgba[sourceAt + 3]; + } + } + const mapped = projectTerrain( + tile, + WORLD_TILE_SIZE, + WORLD_TILE_SIZE, + shadow, + base, + highlight, + shadowCount, + highlightCount, + ); + for (let y = 0; y < WORLD_TILE_SIZE; y++) { + for (let x = 0; x < WORLD_TILE_SIZE; x++) paintWorld(frame, x, y, mapped[y * WORLD_TILE_SIZE + x]); + } + } + for (const [frame, color, marks] of [ + [1, 2, [[2, 3], [11, 5], [5, 12], [14, 14]]], + [2, 2, [[12, 2], [4, 6], [9, 11], [1, 14]]], + [3, 4, [[3, 2], [12, 6], [6, 11], [14, 14]]], + [4, 4, [[12, 2], [5, 5], [2, 11], [10, 14]]], + ] as const) { + for (const [x, y] of marks) paintWorld(frame, x, y, color); + } + for (const [frame, marks] of [ + [1, [[3, 4], [12, 6], [6, 13]]], + [2, [[11, 3], [5, 7], [2, 13]]], + ] as const) for (const [x, y] of marks) paintWorld(frame, x, y, 10); + for (const [frame, marks] of [ + [3, [[8, 3], [2, 9], [13, 12]]], + [4, [[4, 2], [11, 8], [6, 13]]], + ] as const) for (const [x, y] of marks) paintWorld(frame, x, y, 14); + for (let x = 0; x < WORLD_TILE_SIZE; x++) { + paintWorld(5, x, 5, 6); + paintWorld(5, x, 11, 6); + } + for (let y = 0; y < 5; y++) paintWorld(5, 7, y, 6); + for (let y = 6; y < 11; y++) paintWorld(5, 3, y, 6); + for (let y = 12; y < 16; y++) paintWorld(5, 12, y, 6); + paintWorld(5, 1, 1, 14); + paintWorld(5, 9, 7, 14); + paintWorld(5, 14, 13, 14); + for (const [frame, rows] of [[6, [3, 10]], [7, [6, 13]]] as const) { + for (const y of rows) { + for (let x = 1; x < 7; x++) paintWorld(frame, x, y, 8); + for (let x = 10; x < 15; x++) paintWorld(frame, x, (y + 5) % 16, 8); + } + } + paintWorld(6, 12, 2, 13); + paintWorld(6, 4, 12, 13); + paintWorld(7, 3, 4, 13); + paintWorld(7, 12, 11, 13); + + for (const local of treeMask) { + const x = local % WORLD_TILE_SIZE; + const y = Math.floor(local / WORLD_TILE_SIZE); + const at = worldAt(8, x, y); + if (y >= 12 && x >= 6 && x <= 9) indices[at] = y === 12 ? 5 : 4; + else indices[at] = ((x * 3 + y * 5) % 11 < 3) ? 11 : 10; + } + for (const local of flowerMask) { + const x = local % WORLD_TILE_SIZE; + const y = Math.floor(local / WORLD_TILE_SIZE); + indices[worldAt(9, x, y)] = y < 9 ? ((x + y) % 3 ? 12 : 14) : 11; + } + + // UI remains a screen-space 8x8 system. These exact pieces guarantee that + // dialog borders, HUD and HP bars tile without seams beside 16px world cells. + const setUiTile = (tile: number, fill: number, border: "none" | "top" | "bottom" | "left" | "right" | "tl" | "tr" | "bl" | "br") => { + const ox = tile * 8; + const oy = WORLD_TILE_SIZE; + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) indices[(oy + y) * sheetWidth + ox + x] = fill; + const edge = 14; + if (border === "top" || border === "tl" || border === "tr") for (let x = 0; x < 8; x++) indices[oy * sheetWidth + ox + x] = edge; + if (border === "bottom" || border === "bl" || border === "br") for (let x = 0; x < 8; x++) indices[(oy + 7) * sheetWidth + ox + x] = edge; + if (border === "left" || border === "tl" || border === "bl") for (let y = 0; y < 8; y++) indices[(oy + y) * sheetWidth + ox] = edge; + if (border === "right" || border === "tr" || border === "br") for (let y = 0; y < 8; y++) indices[(oy + y) * sheetWidth + ox + 7] = edge; + }; + setUiTile(0, 13, "none"); + setUiTile(1, 13, "top"); + setUiTile(2, 13, "bottom"); + setUiTile(3, 13, "left"); + setUiTile(4, 13, "right"); + setUiTile(5, 13, "tl"); + setUiTile(6, 13, "tr"); + setUiTile(7, 13, "bl"); + setUiTile(8, 13, "br"); + setUiTile(9, 8, "none"); + setUiTile(10, 2, "none"); + setUiTile(11, 1, "none"); + setUiTile(12, 1, "none"); + setUiTile(13, 1, "none"); + const paintUi = (tile: number, x: number, y: number, color: number) => { + const ox = tile * 8; + const oy = WORLD_TILE_SIZE; + indices[(oy + y) * sheetWidth + ox + x] = color; + }; + for (let x = 0; x < 8; x++) { + for (let y = 2; y <= 5; y++) { + paintUi(11, x, y, y === 2 || y === 5 || x === 0 || x === 7 ? 6 : 7); + paintUi(12, x, y, y === 2 || y === 5 || x === 0 || x === 7 ? 10 : 12); + } + paintUi(13, x, 7, 12); + } + + const finalCanvas = createCanvas(sheetWidth, sheetHeight); + const finalCtx = finalCanvas.getContext("2d"); + drawIndices(finalCtx, indices, sheetWidth, sheetHeight, BG, 0, 0, false); + return { png: finalCanvas.toBuffer("image/png"), pixels: indices }; +} + +async function normalizeSprite( + path: string, + palette: Palette, + size: number, + maxWidth: number, + maxHeight: number, +): Promise { + if (!existsSync(path)) throw new Error(`missing source asset: ${path}; run vapor:rpg:assets:generate`); + const image = await loadImage(path); + const source = createCanvas(image.width, image.height); + const sourceCtx = source.getContext("2d"); + sourceCtx.drawImage(image, 0, 0); + const raw = sourceCtx.getImageData(0, 0, image.width, image.height) as ImageDataLike; + let minX = image.width; + let minY = image.height; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < image.height; y++) { + for (let x = 0; x < image.width; x++) { + if (raw.data[(y * image.width + x) * 4 + 3] >= 128) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + if (maxX < minX || maxY < minY) throw new Error(`${path} has no opaque actor pixels`); + if (minX === 0 || minY === 0 || maxX === image.width - 1 || maxY === image.height - 1) { + throw new Error(`${path} actor touches the source canvas edge; reject scene/background leakage`); + } + const sourceWidth = maxX - minX + 1; + const sourceHeight = maxY - minY + 1; + const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight); + const width = Math.max(1, Math.round(sourceWidth * scale)); + const height = Math.max(1, Math.round(sourceHeight * scale)); + const x = Math.floor((size - width) / 2); + const y = size - height; + const target = createCanvas(size, size); + const ctx = target.getContext("2d"); + ctx.clearRect(0, 0, size, size); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(image, minX, minY, sourceWidth, sourceHeight, x, y, width, height); + return indicesFromContext(ctx, size, size, palette, true); +} + +async function normalizeWalkDirection(direction: typeof WALK_DIRECTIONS[number]): Promise { + const sources: Array<{ + image: Awaited>; + width: number; + height: number; + minX: number; + minY: number; + maxX: number; + maxY: number; + }> = []; + for (let frame = 0; frame < WALK_FRAME_COUNT; frame++) { + const path = join(SOURCE, `hero-walk-${direction}-${frame}.png`); + if (!existsSync(path)) throw new Error(`missing source asset: ${path}; run vapor:rpg:assets:generate --only=walk`); + const image = await loadImage(path); + const canvas = createCanvas(image.width, image.height); + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, 0, 0); + const raw = ctx.getImageData(0, 0, image.width, image.height) as ImageDataLike; + let minX = image.width; + let minY = image.height; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < image.height; y++) for (let x = 0; x < image.width; x++) { + if (raw.data[(y * image.width + x) * 4 + 3] < 128) continue; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + if (maxX < minX || maxY < minY) throw new Error(`${path} has no opaque actor pixels`); + if (minX === 0 || minY === 0 || maxX === image.width - 1 || maxY === image.height - 1) { + throw new Error(`${path} actor touches the source canvas edge; reject scene/background leakage`); + } + sources.push({ image, width: image.width, height: image.height, minX, minY, maxX, maxY }); + } + const sourceWidth = sources[0].width; + const sourceHeight = sources[0].height; + if (sources.some((source) => source.width !== sourceWidth || source.height !== sourceHeight)) { + throw new Error(`hero walk ${direction} source canvases must have identical dimensions`); + } + + // One horizontal crop and one scale per direction keep the torso anchored + // while limbs alternate. Each frame's lowest contacting foot is then placed + // on y=31, so generated canvas drift never becomes world-space vibration. + const minX = Math.min(...sources.map((source) => source.minX)); + const maxX = Math.max(...sources.map((source) => source.maxX)); + const cropWidth = maxX - minX + 1; + const maxHeight = Math.max(...sources.map((source) => source.maxY - source.minY + 1)); + const scale = Math.min(28 / cropWidth, 30 / maxHeight); + const width = Math.max(1, Math.round(cropWidth * scale)); + const x = Math.floor((WORLD_ACTOR_SIZE - width) / 2); + return sources.map((source) => { + const cropHeight = source.maxY - source.minY + 1; + const height = Math.max(1, Math.round(cropHeight * scale)); + const y = WORLD_ACTOR_SIZE - height; + const target = createCanvas(WORLD_ACTOR_SIZE, WORLD_ACTOR_SIZE); + const ctx = target.getContext("2d"); + ctx.clearRect(0, 0, WORLD_ACTOR_SIZE, WORLD_ACTOR_SIZE); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(source.image, minX, source.minY, cropWidth, cropHeight, x, y, width, height); + return indicesFromContext(ctx, WORLD_ACTOR_SIZE, WORLD_ACTOR_SIZE, HERO, true); + }); +} + +function polishSlime(slime: Uint8Array, size: number): void { + const dark = 2; + const mid = 3; + const bright = 4; + const pale = 5; + const eye = 6; + for (let i = 0; i < slime.length; i++) { + if (slime[i] === eye) slime[i] = bright; + } + let slimeMinX = size; + let slimeMinY = size; + let slimeMaxX = -1; + let slimeMaxY = -1; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + if (slime[y * size + x] !== 0) { + slimeMinX = Math.min(slimeMinX, x); + slimeMinY = Math.min(slimeMinY, y); + slimeMaxX = Math.max(slimeMaxX, x); + slimeMaxY = Math.max(slimeMaxY, y); + } + } + } + const slimeWidth = slimeMaxX - slimeMinX + 1; + const slimeHeight = slimeMaxY - slimeMinY + 1; + const eyeY = slimeMinY + Math.max(1, Math.floor(slimeHeight * 0.36)); + const leftEye = eyeY * size + slimeMinX + Math.floor(slimeWidth * 0.34); + const rightEye = eyeY * size + slimeMinX + Math.floor(slimeWidth * 0.66); + const tealRows: number[] = []; + for (let y = 0; y < size; y++) { + if (slime.slice(y * size, y * size + size).some((pixel) => pixel >= dark && pixel <= pale)) tealRows.push(y); + } + const shadeFrom = tealRows.length > 0 + ? Math.floor((tealRows[0] + tealRows[tealRows.length - 1] + 1) / 2) + : size; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const at = y * size + x; + if (y < shadeFrom && slime[at] === dark) slime[at] = mid; + if (y >= shadeFrom && slime[at] >= bright && slime[at] <= pale) slime[at] = mid; + } + } + const body = [...slime.keys()].filter((at) => slime[at] >= dark && slime[at] <= pale); + if (body.length > 3) { + slime[body[0]] = bright; + slime[body[Math.floor(body.length / 4)]] = pale; + slime[body[body.length - 1]] = dark; + } + slime[leftEye] = eye; + slime[rightEye] = eye; +} + +async function buildActors(): Promise<{ + png: Buffer; + pixels: Uint8Array[]; + walkPng: Buffer; + walkPixels: Uint8Array[]; + battlePng: Buffer; + battlePixels: Uint8Array[]; +}> { + const specs = [ + ["hero-south.png", HERO, "person"], + ["hero-north.png", HERO, "person"], + ["hero-west.png", HERO, "person"], + ["hero-east.png", HERO, "person"], + ["elder.png", ELDER, "person"], + ["slime.png", SLIME, "slime"], + ] as const; + const pixels: Uint8Array[] = []; + for (const [file, palette, kind] of specs) { + pixels.push(await normalizeSprite( + join(SOURCE, file), palette, WORLD_ACTOR_SIZE, + kind === "slime" ? 28 : 20, + kind === "slime" ? 21 : 30, + )); + } + // Reassert the reviewed eyes and top-left/lower-right teal shading so the + // generated puddle-ooze silhouette survives RGB555 quantization and the + // dimmer original GBA LCD without changing its PixelLab-authored outline. + const slime = pixels[5]; + polishSlime(slime, WORLD_ACTOR_SIZE); + const canvas = createCanvas(ACTOR_NAMES.length * WORLD_ACTOR_SIZE, WORLD_ACTOR_SIZE); + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + for (let i = 0; i < pixels.length; i++) { + const palette = i < 4 ? HERO : i === 4 ? ELDER : SLIME; + drawIndices(ctx, pixels[i], WORLD_ACTOR_SIZE, WORLD_ACTOR_SIZE, palette, i * WORLD_ACTOR_SIZE, 0, true); + } + const walkPixels: Uint8Array[] = []; + for (const direction of WALK_DIRECTIONS) { + walkPixels.push(...await normalizeWalkDirection(direction)); + } + const walkCanvas = createCanvas(WALK_FRAME_COUNT * WORLD_ACTOR_SIZE, WALK_DIRECTIONS.length * WORLD_ACTOR_SIZE); + const walkCtx = walkCanvas.getContext("2d"); + walkCtx.clearRect(0, 0, walkCanvas.width, walkCanvas.height); + for (let direction = 0; direction < WALK_DIRECTIONS.length; direction++) { + for (let frame = 0; frame < WALK_FRAME_COUNT; frame++) { + drawIndices( + walkCtx, + walkPixels[direction * WALK_FRAME_COUNT + frame], + WORLD_ACTOR_SIZE, + WORLD_ACTOR_SIZE, + HERO, + frame * WORLD_ACTOR_SIZE, + direction * WORLD_ACTOR_SIZE, + true, + ); + } + } + const battlePixels = [ + await normalizeSprite(join(SOURCE, "hero-east.png"), HERO, BATTLE_ACTOR_SIZE, 48, 60), + await normalizeSprite(join(SOURCE, "slime.png"), SLIME, BATTLE_ACTOR_SIZE, 54, 44), + ]; + polishSlime(battlePixels[1], BATTLE_ACTOR_SIZE); + const battleCanvas = createCanvas(2 * BATTLE_ACTOR_SIZE, BATTLE_ACTOR_SIZE); + const battleCtx = battleCanvas.getContext("2d"); + battleCtx.clearRect(0, 0, battleCanvas.width, battleCanvas.height); + drawIndices(battleCtx, battlePixels[0], BATTLE_ACTOR_SIZE, BATTLE_ACTOR_SIZE, HERO, 0, 0, true); + drawIndices(battleCtx, battlePixels[1], BATTLE_ACTOR_SIZE, BATTLE_ACTOR_SIZE, SLIME, BATTLE_ACTOR_SIZE, 0, true); + return { + png: canvas.toBuffer("image/png"), + pixels, + walkPng: walkCanvas.toBuffer("image/png"), + walkPixels, + battlePng: battleCanvas.toBuffer("image/png"), + battlePixels, + }; +} + +function bgr555([r, g, b]: Rgb): number { + return (r >> 3) | ((g >> 3) << 5) | ((b >> 3) << 10); +} + +function packTile(pixels: Uint8Array, stride: number, ox: number, oy: number): number[] { + const words: number[] = []; + for (let y = 0; y < 8; y++) { + for (let x = 0; x < 8; x += 4) { + words.push( + pixels[(oy + y) * stride + ox + x] + | (pixels[(oy + y) * stride + ox + x + 1] << 4) + | (pixels[(oy + y) * stride + ox + x + 2] << 8) + | (pixels[(oy + y) * stride + ox + x + 3] << 12), + ); + } + } + return words; +} + +function wordsForBackground(pixels: Uint8Array): number[] { + const words: number[] = []; + const stride = WORLD_TILE_COLUMNS * WORLD_TILE_SIZE; + for (let frame = 0; frame < WORLD_TILE_NAMES.length; frame++) { + for (let ty = 0; ty < 2; ty++) { + for (let tx = 0; tx < 2; tx++) { + words.push(...packTile(pixels, stride, frame * WORLD_TILE_SIZE + tx * 8, ty * 8)); + } + } + } + for (let tile = 0; tile < UI_TILE_NAMES.length; tile++) { + words.push(...packTile(pixels, stride, tile * 8, WORLD_TILE_SIZE)); + } + return words; +} + +function wordsForActors(actors: Uint8Array[], walkActors: Uint8Array[], battleActors: Uint8Array[]): number[] { + const words: number[] = []; + for (const pixels of [...actors, ...walkActors]) { + for (let ty = 0; ty < 4; ty++) for (let tx = 0; tx < 4; tx++) words.push(...packTile(pixels, WORLD_ACTOR_SIZE, tx * 8, ty * 8)); + } + for (const pixels of battleActors) { + for (let ty = 0; ty < 8; ty++) for (let tx = 0; tx < 8; tx++) words.push(...packTile(pixels, BATTLE_ACTOR_SIZE, tx * 8, ty * 8)); + } + return words; +} + +function formatWords(name: string, words: readonly number[], columns = 8): string { + const lines: string[] = []; + for (let i = 0; i < words.length; i += columns) { + lines.push(` ${words.slice(i, i + columns).map((word) => `0x${word.toString(16).padStart(4, "0")}`).join(", ")},`); + } + return `static const u16 ${name}[${words.length}] = {\n${lines.join("\n")}\n};`; +} + +function generatedHeader( + bgPixels: Uint8Array, + actorPixels: Uint8Array[], + walkPixels: Uint8Array[], + battlePixels: Uint8Array[], +): string { + const paddedPalette = (palette: Palette): number[] => [ + ...palette.map(bgr555), + ...Array(Math.max(0, 16 - palette.length)).fill(0), + ]; + const palettes = [paddedPalette(HERO), paddedPalette(ELDER), paddedPalette(SLIME)].flat(); + return [ + "/* Generated by vapor/scripts/rpg-assets.ts. Do not edit by hand. */", + "#ifndef VP_RPG_ASSETS_GENERATED_H", + "#define VP_RPG_ASSETS_GENERATED_H", + "", + `#define VP_RPG_WORLD_TILE_FRAME_COUNT ${WORLD_TILE_NAMES.length}`, + "#define VP_RPG_WORLD_TILE_FRAME_TILES 4", + `#define VP_RPG_UI_TILE_COUNT ${UI_TILE_NAMES.length}`, + "#define VP_RPG_UI_TILE_BASE (VP_RPG_WORLD_TILE_FRAME_COUNT * VP_RPG_WORLD_TILE_FRAME_TILES)", + "#define VP_RPG_BG_TILE_COUNT (VP_RPG_UI_TILE_BASE + VP_RPG_UI_TILE_COUNT)", + "#define VP_RPG_WORLD_ACTOR_FRAME_TILES 16", + `#define VP_RPG_WORLD_STATIC_ACTOR_FRAME_COUNT ${ACTOR_NAMES.length}`, + `#define VP_RPG_WORLD_WALK_DIRECTION_COUNT ${WALK_DIRECTIONS.length}`, + `#define VP_RPG_WORLD_WALK_FRAMES ${WALK_FRAME_COUNT}`, + "#define VP_RPG_WORLD_WALK_TILE_BASE (VP_RPG_WORLD_STATIC_ACTOR_FRAME_COUNT * VP_RPG_WORLD_ACTOR_FRAME_TILES)", + "#define VP_RPG_WORLD_ACTOR_FRAME_COUNT (VP_RPG_WORLD_STATIC_ACTOR_FRAME_COUNT + VP_RPG_WORLD_WALK_DIRECTION_COUNT * VP_RPG_WORLD_WALK_FRAMES)", + "#define VP_RPG_WORLD_ELDER_TILE (4 * VP_RPG_WORLD_ACTOR_FRAME_TILES)", + "#define VP_RPG_WORLD_SLIME_TILE (5 * VP_RPG_WORLD_ACTOR_FRAME_TILES)", + "#define VP_RPG_BATTLE_HERO_TILE (VP_RPG_WORLD_ACTOR_FRAME_COUNT * VP_RPG_WORLD_ACTOR_FRAME_TILES)", + "#define VP_RPG_BATTLE_SLIME_TILE (VP_RPG_BATTLE_HERO_TILE + 64)", + "#define VP_RPG_OBJ_TILE_COUNT (VP_RPG_BATTLE_SLIME_TILE + 64)", + "", + formatWords("vp_rpg_bg_palette", paddedPalette(BG)), + "", + formatWords("vp_rpg_obj_palettes", palettes), + "", + formatWords("vp_rpg_bg_tiles", wordsForBackground(bgPixels)), + "", + formatWords("vp_rpg_obj_tiles", wordsForActors(actorPixels, walkPixels, battlePixels)), + "", + "#endif", + "", + ].join("\n"); +} + +function assertAssetSemantics( + bg: Uint8Array, + actors: Uint8Array[], + walkActors: Uint8Array[], + battleActors: Uint8Array[], +): void { + const bounds = (frame: Uint8Array, size: number): { width: number; height: number } => { + let minX = size; + let minY = size; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) { + if (frame[y * size + x] === 0) continue; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + return { width: maxX - minX + 1, height: maxY - minY + 1 }; + }; + const connectedComponents = (frame: Uint8Array, size: number): number => { + const visited = new Uint8Array(frame.length); + let components = 0; + for (let start = 0; start < frame.length; start++) { + if (frame[start] === 0 || visited[start]) continue; + components++; + const stack = [start]; + visited[start] = 1; + while (stack.length > 0) { + const at = stack.pop()!; + const x = at % size; + const y = Math.floor(at / size); + for (const next of [x > 0 ? at - 1 : -1, x + 1 < size ? at + 1 : -1, y > 0 ? at - size : -1, y + 1 < size ? at + size : -1]) { + if (next >= 0 && frame[next] !== 0 && !visited[next]) { + visited[next] = 1; + stack.push(next); + } + } + } + } + return components; + }; + const bottomContactSpans = (frame: Uint8Array, size: number): number => { + let spans = 0; + let opaque = false; + for (let x = 0; x < size; x++) { + const next = frame[(size - 1) * size + x] !== 0; + if (next && !opaque) spans++; + opaque = next; + } + return spans; + }; + if (bg.length !== WORLD_TILE_COLUMNS * WORLD_TILE_SIZE * (WORLD_TILE_SIZE + 8)) { + throw new Error("background preview must contain ten 16x16 world frames and fourteen 8x8 UI tiles"); + } + if (actors.length !== 6 || actors.some((frame) => frame.length !== WORLD_ACTOR_SIZE ** 2)) { + throw new Error("actor sheet must contain six 32x32 frames"); + } + for (const [index, frame] of actors.entries()) { + const visible = frame.filter((pixel) => pixel !== 0).length; + if (visible < (index === 5 ? 220 : 280)) throw new Error(`${ACTOR_NAMES[index]} silhouette is too sparse (${visible} pixels)`); + if (!frame.slice(31 * WORLD_ACTOR_SIZE).some((pixel) => pixel !== 0)) throw new Error(`${ACTOR_NAMES[index]} does not touch the shared y=31 foot line`); + const box = bounds(frame, WORLD_ACTOR_SIZE); + if (box.width < (index === 5 ? 22 : 18) || box.height < (index === 5 ? 14 : 26)) { + throw new Error(`${ACTOR_NAMES[index]} bbox is too small (${box.width}x${box.height})`); + } + const max = index < 4 ? HERO.length - 1 : index === 4 ? ELDER.length - 1 : SLIME.length - 1; + if (frame.some((pixel) => pixel > max)) throw new Error(`${ACTOR_NAMES[index]} exceeds its OBJ palette`); + if (connectedComponents(frame, WORLD_ACTOR_SIZE) !== 1) throw new Error(`${ACTOR_NAMES[index]} must be one connected silhouette`); + if (index === 5 && (box.width < box.height || bottomContactSpans(frame, WORLD_ACTOR_SIZE) < 2)) { + throw new Error("slime must remain a wide puddle ooze with at least two grounded pseudopod contacts"); + } + } + const heroHashes = actors.slice(0, 4).map((pixels) => sha256(pixels)); + if (new Set(heroHashes).size !== 4) throw new Error("hero directions must be visually distinct"); + const heroHeights = actors.slice(0, 4).map((frame) => bounds(frame, WORLD_ACTOR_SIZE).height); + if (Math.max(...heroHeights) - Math.min(...heroHeights) > 2) throw new Error("hero direction heights drift by more than two pixels"); + const slimeTeals = new Set(actors[5].filter((pixel) => pixel >= 2 && pixel <= 5)); + if (slimeTeals.size < 3) throw new Error("slime must retain at least three teal shades"); + if (actors[5].filter((pixel) => pixel === 6).length !== 2) throw new Error("slime must have exactly two white eye pixels"); + if (walkActors.length !== WALK_DIRECTIONS.length * WALK_FRAME_COUNT || + walkActors.some((frame) => frame.length !== WORLD_ACTOR_SIZE ** 2)) { + throw new Error("hero walk sheet must contain four frames for each of four directions"); + } + for (let direction = 0; direction < WALK_DIRECTIONS.length; direction++) { + const sequence = walkActors.slice(direction * WALK_FRAME_COUNT, (direction + 1) * WALK_FRAME_COUNT); + const hashes = new Set(); + const heights: number[] = []; + const centers: number[] = []; + for (const [frameIndex, frame] of sequence.entries()) { + const visible = frame.filter((pixel) => pixel !== 0).length; + if (visible < 260) throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} frame ${frameIndex} is too sparse (${visible} pixels)`); + if (!frame.slice(31 * WORLD_ACTOR_SIZE).some((pixel) => pixel !== 0)) { + throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} frame ${frameIndex} does not touch y=31`); + } + const box = bounds(frame, WORLD_ACTOR_SIZE); + if (box.width < 16 || box.height < 26) { + throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} frame ${frameIndex} bbox is too small (${box.width}x${box.height})`); + } + if (frame.some((pixel) => pixel >= HERO.length)) { + throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} frame ${frameIndex} exceeds the hero OBJ palette`); + } + if (connectedComponents(frame, WORLD_ACTOR_SIZE) !== 1) { + throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} frame ${frameIndex} must be one connected silhouette`); + } + let weightedX = 0; + for (let at = 0; at < frame.length; at++) if (frame[at] !== 0) weightedX += at % WORLD_ACTOR_SIZE; + centers.push(weightedX / visible); + heights.push(box.height); + hashes.add(sha256(frame)); + } + if (hashes.size !== WALK_FRAME_COUNT) throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} frames must be distinct`); + if (Math.max(...heights) - Math.min(...heights) > 3) { + throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} height drifts by more than three pixels`); + } + if (Math.max(...centers) - Math.min(...centers) > 3) { + throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} horizontal center drifts by more than three pixels`); + } + for (let frame = 0; frame < WALK_FRAME_COUNT; frame++) { + const next = (frame + 1) % WALK_FRAME_COUNT; + let changed = 0; + for (let at = 0; at < sequence[frame].length; at++) { + if (sequence[frame][at] !== sequence[next][at]) changed++; + } + if (changed < 40 || changed > 520) { + throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} transition ${frame}->${next} changes ${changed} pixels`); + } + } + } + if (battleActors.length !== 2 || battleActors.some((frame) => frame.length !== BATTLE_ACTOR_SIZE ** 2)) { + throw new Error("battle actor sheet must contain two 64x64 frames"); + } + for (const [index, frame] of battleActors.entries()) { + if (frame.filter((pixel) => pixel !== 0).length < (index === 0 ? 1000 : 900)) throw new Error(`battle actor ${index} silhouette is too sparse`); + if (!frame.slice(63 * BATTLE_ACTOR_SIZE).some((pixel) => pixel !== 0)) throw new Error(`battle actor ${index} does not touch y=63`); + const box = bounds(frame, BATTLE_ACTOR_SIZE); + if (box.width < (index === 0 ? 38 : 44) || box.height < (index === 0 ? 52 : 30)) { + throw new Error(`battle actor ${index} bbox is too small (${box.width}x${box.height})`); + } + const max = index === 0 ? HERO.length - 1 : SLIME.length - 1; + if (frame.some((pixel) => pixel > max)) throw new Error(`battle actor ${index} exceeds its OBJ palette`); + if (connectedComponents(frame, BATTLE_ACTOR_SIZE) !== 1) throw new Error(`battle actor ${index} must be one connected silhouette`); + if (index === 1 && box.width < box.height) throw new Error("battle slime must remain wider than it is tall"); + } +} + +async function build(check: boolean): Promise { + ensureDirs(); + const background = await buildBackground(); + const actors = await buildActors(); + assertAssetSemantics(background.pixels, actors.pixels, actors.walkPixels, actors.battlePixels); + const header = generatedHeader(background.pixels, actors.pixels, actors.walkPixels, actors.battlePixels); + const targets: Array<[string, Buffer | string]> = [ + [join(FINAL, "background.png"), background.png], + [join(FINAL, "actors.png"), actors.png], + [join(FINAL, "hero-walk.png"), actors.walkPng], + [join(FINAL, "battle-actors.png"), actors.battlePng], + [join(FINAL, "palette-guide.png"), paletteGuidePng()], + [HEADER, header], + ]; + if (check) { + for (const [path, expected] of targets) { + if (!existsSync(path)) throw new Error(`generated asset missing: ${path}`); + const actual = readFileSync(path); + const bytes = typeof expected === "string" ? Buffer.from(expected) : expected; + if (!actual.equals(bytes)) throw new Error(`generated asset is stale: ${path}`); + } + console.log(`RPG assets OK: 54 BG tiles, ${ACTOR_NAMES.length} static + ${actors.walkPixels.length} walking 32x32 world actors + 2 64x64 battle actors, four fixed 4bpp palettes`); + return; + } + for (const [path, contents] of targets) writeFileSync(path, contents); + console.log(`Built ${join(FINAL, "background.png")}`); + console.log(`Built ${join(FINAL, "actors.png")}`); + console.log(`Built ${join(FINAL, "hero-walk.png")}`); + console.log(`Built ${join(FINAL, "battle-actors.png")}`); + console.log(`Built ${HEADER}`); +} + +if (import.meta.main) { + const command = process.argv[2] ?? "check"; + if (command === "generate") await generate(process.argv.includes("--force")); + else if (command === "build") await build(false); + else if (command === "check") await build(true); + else throw new Error(`usage: bun vapor/scripts/rpg-assets.ts [--force]`); +} diff --git a/vapor/tests/compiler.test.ts b/vapor/tests/compiler.test.ts index cb2f1a15..df15ee42 100644 --- a/vapor/tests/compiler.test.ts +++ b/vapor/tests/compiler.test.ts @@ -6,6 +6,14 @@ import { loadBoard } from "../compiler/boards.ts"; import { compileVaporApp, VAPOR_TARGETS, VaporCompileError } from "../compiler/compile.ts"; import { esp32BuildId } from "../compiler/esp32.ts"; import { FONT8 } from "../compiler/font.gen.ts"; +import { + __dispatchButton, + __dispatchButtonRepeat, + __resetButtons, + Button, + onButton, + onButtonRepeat, +} from "../host/input.ts"; const ENTRY = join(import.meta.dir, "..", "examples", "todo", "todo.tsx"); @@ -47,7 +55,9 @@ describe("pocket vapor compiler", () => { const a = compileVaporApp(ENTRY, source, "VAPOR TODO"); const b = compileVaporApp(ENTRY, source, "VAPOR TODO"); expect(a.c).toBe(b.c); + expect(a.rpgEnabled).toBe(false); expect(a.c).toContain("vp_mark"); + expect(a.c).toContain("vp_row_clear"); expect(a.graph).toContain("visible: view(maxLen 12)"); expect(a.plan).toContain("pools"); }); @@ -170,6 +180,67 @@ export default () => { expect(app.c).toContain("fn_closeEditor"); // bare fn reference as keymap value }); + test("GBA button repeat is explicit and lowers to a separate native hook", () => { + const source = minimal(` + onButtonRepeat((b) => { + if (b === Button.Right) count.value = count.value + 10; + });`).replace("{ Button, onButton }", "{ Button, onButton, onButtonRepeat }"); + const app = compileVaporApp("repeat.tsx", source, "REPEAT", "gba"); + expect(app.c).toContain("void app_on_button_repeat(u8 b)"); + expect(app.c).toMatch(/app_on_button_repeat[\s\S]*g_count \+ 10/); + expect(app.graph).toContain("button repeats: directional (gba)"); + expect(() => compileVaporApp("repeat.tsx", source, "REPEAT", "gb")).toThrow( + /onButtonRepeat is currently supported only on the gba target/, + ); + }); + + test("official Vue Vapor onFrame and BTN imports lower to the fixed GBA frame hook", () => { + const source = `${HEADER} +import { onFrame } from "@pocketjs/framework/vue-vapor/lifecycle"; +import { BTN } from "@pocketjs/framework/vue-vapor/input"; +export default () => { + const count = ref(0); + onFrame((buttons) => { + if (buttons & (BTN.RIGHT | BTN.CROSS)) count.value = count.value + 2; + }); + onButton((b) => { + if (b === Button.A) count.value = count.value + 1; + }); + return (<>{count.value}); +}; +`; + const app = compileVaporApp("frame.tsx", source, "FRAME", "gba"); + expect(app.c).toContain("void app_on_frame(u32 buttons)"); + expect(app.c).toMatch(/app_on_frame[\s\S]*buttons_arg & \(32 \| 16384\)/); + expect(app.buttonsUsed).toEqual([0, 4]); + expect(app.graph).toContain("frame hook: fixed (gba)"); + expect(() => compileVaporApp("frame.tsx", source, "FRAME", "gb")).toThrow( + /onFrame is currently supported only on the gba target/, + ); + }); + + test("negative numeric ref seeds survive native initialization", () => { + const source = minimal("const idle = ref(-1);"); + const app = compileVaporApp("negative-seed.tsx", source, "NEGATIVE", "gba"); + expect(app.c).toContain("g_idle = -1;"); + }); + + test("oracle repeat dispatch stays separate from physical press edges", () => { + let presses = 0; + let repeats = 0; + __resetButtons(); + try { + onButton(() => presses++); + onButtonRepeat(() => repeats++); + __dispatchButtonRepeat(Button.Right); + expect([presses, repeats]).toEqual([0, 1]); + __dispatchButton(Button.Right); + expect([presses, repeats]).toEqual([1, 1]); + } finally { + __resetButtons(); + } + }); + test("computed can yield a record reference (current todo)", async () => { const source = await Bun.file(ENTRY).text(); const app = compileVaporApp(ENTRY, source); diff --git a/vapor/tests/harness/mgba_runner.c b/vapor/tests/harness/mgba_runner.c index 01d5a7c6..50273dac 100644 --- a/vapor/tests/harness/mgba_runner.c +++ b/vapor/tests/harness/mgba_runner.c @@ -10,6 +10,7 @@ * run release frames * R read 1/2/4 bytes little-endian * D read len bytes, emit as hex string + * H hash the current 240x160 video buffer * S screenshot (PPM P6) * Output: one JSON object on stdout: {"ok":true,"reads":{...}}. */ @@ -60,6 +61,23 @@ static void screenshot(const char *path) { fclose(f); } +static uint32_t video_hash(void) { + uint32_t hash = 2166136261u; + unsigned i; + for (i = 0; i < vw * vh; i++) { + uint32_t pixel = videoBuffer[i]; + hash ^= pixel & 0xff; + hash *= 16777619u; + hash ^= (pixel >> 8) & 0xff; + hash *= 16777619u; + hash ^= (pixel >> 16) & 0xff; + hash *= 16777619u; + hash ^= (pixel >> 24) & 0xff; + hash *= 16777619u; + } + return hash; +} + int main(int argc, char **argv) { FILE *sc; char line[1024]; @@ -136,6 +154,11 @@ int main(int argc, char **argv) { for (i = 0; i < len; i++) printf("%02x", core->busRead8(core, addr + (unsigned)i)); printf("\""); first_read = 0; + } else if (op == 'H') { + char name[256]; + sscanf(line + 1, "%255s", name); + printf("%s\"%s\":%u", first_read ? "" : ",", name, video_hash()); + first_read = 0; } else if (op == 'S') { char path[512]; sscanf(line + 1, "%511s", path); diff --git a/vapor/tests/rpg-assets.test.ts b/vapor/tests/rpg-assets.test.ts new file mode 100644 index 00000000..f3b5fa9f --- /dev/null +++ b/vapor/tests/rpg-assets.test.ts @@ -0,0 +1,309 @@ +// Deterministic art-pipeline checks for the PixelLab-backed GBA RPG assets. + +import { createHash } from "node:crypto"; +import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { $ } from "bun"; +import { describe, expect, test } from "bun:test"; +import { createCanvas, loadImage } from "@napi-rs/canvas"; +import { buildBackground } from "../scripts/rpg-assets.ts"; + +const ROOT = join(import.meta.dir, "..", "examples", "rpg", "assets"); +const SOURCE = join(ROOT, "source"); +const FINAL = join(ROOT, "final"); +const SCRIPT = join(import.meta.dir, "..", "scripts", "rpg-assets.ts"); +const HEADER = join(import.meta.dir, "..", "runtime", "gba", "vapor_rpg_assets.generated.h"); + +const BG = [ + "0,0,0", "24,41,74", "33,123,66", "66,198,90", "132,90,41", "198,156,82", + "74,82,99", "156,165,173", "24,74,148", "57,148,231", "33,82,41", "41,165,66", + "255,214,66", "33,49,99", "239,222,148", "255,82,82", +]; +const HERO = [ + "24,41,74", "198,107,66", "255,173,99", "33,82,173", "66,132,255", + "132,181,255", "181,66,49", "255,123,74", "239,222,148", +]; +const ELDER = [ + "24,41,74", "198,107,66", "255,173,99", "74,82,99", "115,123,140", + "156,165,173", "198,214,222", "255,255,255", "66,132,255", +]; +const SLIME = ["24,41,74", "0,90,90", "0,173,173", "57,214,198", "148,247,222", "255,255,255"]; +const WALK_FILES = ["south", "north", "west", "east"] + .flatMap((direction) => Array.from({ length: 4 }, (_, frame) => `hero-walk-${direction}-${frame}.png`)); +const SOURCE_FILES = [ + "style-anchor.png", + "grass.png", "path.png", "wall.png", "water.png", "tree.png", "flower.png", + "hero-south-reference.png", "hero-south.png", "hero-north.png", "hero-west.png", "hero-east.png", + "elder.png", "slime.png", + ...WALK_FILES, +].sort(); + +async function rgba(path: string): Promise<{ width: number; height: number; data: Uint8ClampedArray }> { + const image = await loadImage(path); + const canvas = createCanvas(image.width, image.height); + const context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + return { width: image.width, height: image.height, data: context.getImageData(0, 0, image.width, image.height).data }; +} + +function generatedWordCount(name: string): number { + const header = readFileSync(HEADER, "utf8"); + const match = header.match(new RegExp(`static const u16 ${name}\\[(\\d+)\\] = \\{([\\s\\S]*?)\\n\\};`)); + if (!match) throw new Error(`missing generated array ${name}`); + expect([...match[2].matchAll(/0x[0-9a-f]{4}/g)]).toHaveLength(Number(match[1])); + return Number(match[1]); +} + +describe("Vapor Quest GBA art pipeline", () => { + test("reviewed PNG inputs reproduce every committed output offline", async () => { + await $`bun ${SCRIPT} check`.quiet(); + expect(generatedWordCount("vp_rpg_bg_palette")).toBe(16); + expect(generatedWordCount("vp_rpg_obj_palettes")).toBe(48); + expect(generatedWordCount("vp_rpg_bg_tiles")).toBe(54 * 16); + expect(generatedWordCount("vp_rpg_obj_tiles")).toBe(480 * 16); + const header = readFileSync(HEADER, "utf8"); + expect(header).toContain("#define VP_RPG_WORLD_WALK_DIRECTION_COUNT 4"); + expect(header).toContain("#define VP_RPG_WORLD_WALK_FRAMES 4"); + expect(header).toContain("#define VP_RPG_WORLD_WALK_TILE_BASE (VP_RPG_WORLD_STATIC_ACTOR_FRAME_COUNT * VP_RPG_WORLD_ACTOR_FRAME_TILES)"); + }); + + test("generation provenance is complete and contains no credential", () => { + const text = readFileSync(join(ROOT, "generation.json"), "utf8"); + expect(text).not.toMatch(/PIXELLAB_API_KEY|Authorization|Bearer\s/i); + const manifest = JSON.parse(text) as { + provider: string; + apiBase: string; + assets: { + heroWalk: { + endpoint: string; + templateAnimationId: string; + animationName: string; + directions: string[]; + frameCount: number; + prompt: string; + seed: number; + }; + }; + records: { + world: Record; + heroRotations: { characterId: string }; + heroWalk: { + characterId: string; + animationGroupId: string; + backgroundJobIds: Record; + hashes: Record; + }; + }; + sourceHashes: Record; + }; + expect(manifest.provider).toBe("PixelLab"); + expect(manifest.apiBase).toBe("https://api.pixellab.ai/v2"); + expect(Object.keys(manifest.records.world).sort()).toEqual(["barriers", "field", "flower", "tree"]); + expect(manifest.records.heroRotations.characterId).toMatch(/^[0-9a-f-]{36}$/); + expect(manifest.assets.heroWalk).toEqual({ + endpoint: "/characters/animations", + templateAnimationId: "walking-4-frames", + directions: ["south", "north", "west", "east"], + frameCount: 4, + animationName: "Vapor Quest Walk", + prompt: expect.any(String), + seed: 23071, + }); + expect(manifest.records.heroWalk.characterId).toBe(manifest.records.heroRotations.characterId); + expect(manifest.records.heroWalk.animationGroupId).toMatch(/^[0-9a-f-]{36}$/); + expect(Object.keys(manifest.records.heroWalk.backgroundJobIds)).toEqual(["south", "north", "west", "east"]); + expect(Object.keys(manifest.sourceHashes).sort()).toEqual(SOURCE_FILES); + expect(Object.keys(manifest.records.heroWalk.hashes)).toEqual(WALK_FILES); + for (const id of Object.values(manifest.records.heroWalk.backgroundJobIds)) expect(id).toMatch(/^[0-9a-f-]{36}$/); + for (const [file, expected] of Object.entries(manifest.sourceHashes)) { + const actual = createHash("sha256").update(readFileSync(join(SOURCE, file))).digest("hex"); + expect(actual, file).toBe(expected); + } + for (const file of WALK_FILES) { + expect(manifest.records.heroWalk.hashes[file], file).toBe(manifest.sourceHashes[file]); + } + }); + + test("the background sheet combines ten 16x16 world cells with fourteen 8x8 UI tiles", async () => { + const image = await rgba(join(FINAL, "background.png")); + expect([image.width, image.height]).toEqual([160, 24]); + const allowed = new Set(BG); + for (let at = 0; at < image.data.length; at += 4) { + expect(image.data[at + 3]).toBe(255); + expect(allowed.has(`${image.data[at]},${image.data[at + 1]},${image.data[at + 2]}`)).toBe(true); + } + }); + + test("each reviewed PixelLab terrain source materially changes the final atlas", async () => { + const directory = mkdtempSync(join(tmpdir(), "vapor-rpg-terrain-")); + const files = ["grass.png", "path.png", "wall.png", "water.png", "tree.png", "flower.png"]; + try { + for (const file of files) copyFileSync(join(SOURCE, file), join(directory, file)); + const baseline = await buildBackground(directory); + expect(createHash("sha256").update((await buildBackground(directory)).png).digest("hex")) + .toBe(createHash("sha256").update(baseline.png).digest("hex")); + for (const file of ["grass.png", "path.png", "wall.png", "water.png"]) { + const path = join(directory, file); + const original = readFileSync(path); + const image = await loadImage(original); + const canvas = createCanvas(16, 16); + const context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + for (let y = 4; y < 8; y++) { + for (let x = 4; x < 8; x++) { + context.fillStyle = (x + y) % 2 === 0 ? "#000000" : "#ffffff"; + context.fillRect(x, y, 1, 1); + } + } + writeFileSync(path, canvas.toBuffer("image/png")); + const changed = await buildBackground(directory); + expect(createHash("sha256").update(changed.png).digest("hex"), file) + .not.toBe(createHash("sha256").update(baseline.png).digest("hex")); + writeFileSync(path, original); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + test("the review palette guide pads unused OBJ slots exactly like GBA RAM", async () => { + const image = await rgba(join(FINAL, "palette-guide.png")); + expect([image.width, image.height]).toEqual([64, 64]); + const lengths = [16, 10, 10, 7]; + for (let row = 0; row < lengths.length; row++) { + for (let index = lengths[row]; index < 16; index++) { + const at = ((row * 16 + 8) * image.width + index * 4 + 1) * 4; + expect([...image.data.slice(at, at + 4)]).toEqual([0, 0, 0, 255]); + } + } + const header = readFileSync(HEADER, "utf8"); + const match = header.match(/static const u16 vp_rpg_obj_palettes\[48\] = \{([\s\S]*?)\n\};/); + if (!match) throw new Error("missing generated OBJ palette"); + const words = [...match[1].matchAll(/0x([0-9a-f]{4})/g)].map((entry) => Number.parseInt(entry[1], 16)); + for (const [base, used] of [[0, 10], [16, 10], [32, 7]] as const) { + expect(words.slice(base + used, base + 16)).toEqual(Array(16 - used).fill(0)); + } + }); + + test("six detailed 32x32 actor frames share a foot line and stay inside their OBJ banks", async () => { + const image = await rgba(join(FINAL, "actors.png")); + expect([image.width, image.height]).toEqual([192, 32]); + const frameHashes: string[] = []; + const frameColors: Set[] = []; + for (let frame = 0; frame < 6; frame++) { + const allowed = new Set(frame < 4 ? HERO : frame === 4 ? ELDER : SLIME); + const colors = new Set(); + let visible = 0; + let grounded = false; + const bytes: number[] = []; + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + const at = (y * 192 + frame * 32 + x) * 4; + const alpha = image.data[at + 3]; + expect(alpha === 0 || alpha === 255).toBe(true); + bytes.push(image.data[at], image.data[at + 1], image.data[at + 2], alpha); + if (alpha === 0) continue; + visible++; + if (y === 31) grounded = true; + const color = `${image.data[at]},${image.data[at + 1]},${image.data[at + 2]}`; + colors.add(color); + expect(allowed.has(color)).toBe(true); + } + } + expect(visible).toBeGreaterThanOrEqual(frame === 5 ? 220 : 280); + expect(grounded).toBe(true); + expect(colors.size).toBeGreaterThanOrEqual(frame === 5 ? 5 : 6); + frameHashes.push(createHash("sha256").update(Uint8Array.from(bytes)).digest("hex")); + frameColors.push(colors); + } + expect(new Set(frameHashes.slice(0, 4)).size).toBe(4); + expect(frameColors[5].has("255,255,255")).toBe(true); + }); + + test("four directional walk cycles stay anchored, distinct and inside the hero palette", async () => { + const image = await rgba(join(FINAL, "hero-walk.png")); + expect([image.width, image.height]).toEqual([128, 128]); + const allowed = new Set(HERO); + for (let direction = 0; direction < 4; direction++) { + const frames: string[][] = []; + const hashes = new Set(); + const centers: number[] = []; + for (let frame = 0; frame < 4; frame++) { + const source = await rgba(join(SOURCE, `hero-walk-${["south", "north", "west", "east"][direction]}-${frame}.png`)); + expect([source.width, source.height]).toEqual([88, 88]); + expect(source.data[3]).toBe(0); + expect(source.data[source.data.length - 1]).toBe(0); + + const pixels: string[] = []; + const colors = new Set(); + const bytes: number[] = []; + let visible = 0; + let weightedX = 0; + let minX = 32; + let minY = 32; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + const at = ((direction * 32 + y) * image.width + frame * 32 + x) * 4; + const alpha = image.data[at + 3]; + expect(alpha === 0 || alpha === 255).toBe(true); + const color = alpha === 0 ? "" : `${image.data[at]},${image.data[at + 1]},${image.data[at + 2]}`; + pixels.push(color); + bytes.push(image.data[at], image.data[at + 1], image.data[at + 2], alpha); + if (alpha === 0) continue; + expect(allowed.has(color)).toBe(true); + colors.add(color); + visible++; + weightedX += x; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + expect(visible).toBeGreaterThanOrEqual(260); + expect(maxY).toBe(31); + expect(maxX - minX + 1).toBeGreaterThanOrEqual(16); + expect(maxY - minY + 1).toBeGreaterThanOrEqual(28); + expect(colors.size).toBeGreaterThanOrEqual(8); + centers.push(weightedX / visible); + frames.push(pixels); + hashes.add(createHash("sha256").update(Uint8Array.from(bytes)).digest("hex")); + } + expect(hashes.size).toBe(4); + expect(Math.max(...centers) - Math.min(...centers)).toBeLessThanOrEqual(1); + for (let frame = 0; frame < 4; frame++) { + const changed = frames[frame].filter((pixel, at) => pixel !== frames[(frame + 1) % 4][at]).length; + expect(changed).toBeGreaterThanOrEqual(90); + expect(changed).toBeLessThanOrEqual(400); + } + } + }); + + test("battle reuses the same actors and palettes at a readable 64x64 scale", async () => { + expect(createHash("sha256").update(readFileSync(join(FINAL, "battle-actors.png"))).digest("hex")) + .toBe("854493423c1c2c070bc7ca70c27fc4049e8ed9630e9c20a0508b3c6b9f14a521"); + const image = await rgba(join(FINAL, "battle-actors.png")); + expect([image.width, image.height]).toEqual([128, 64]); + for (let frame = 0; frame < 2; frame++) { + const allowed = new Set(frame === 0 ? HERO : SLIME); + let visible = 0; + let grounded = false; + for (let y = 0; y < 64; y++) { + for (let x = 0; x < 64; x++) { + const at = (y * 128 + frame * 64 + x) * 4; + const alpha = image.data[at + 3]; + expect(alpha === 0 || alpha === 255).toBe(true); + if (alpha === 0) continue; + visible++; + if (y === 63) grounded = true; + expect(allowed.has(`${image.data[at]},${image.data[at + 1]},${image.data[at + 2]}`)).toBe(true); + } + } + expect(visible).toBeGreaterThan(frame === 0 ? 1000 : 900); + expect(grounded).toBe(true); + } + }); +}); diff --git a/vapor/tests/rpg.test.ts b/vapor/tests/rpg.test.ts new file mode 100644 index 00000000..003b6467 --- /dev/null +++ b/vapor/tests/rpg.test.ts @@ -0,0 +1,548 @@ +// Pocket Vapor RPG POC: compiler contract + a complete native GBA play tape. + +import { beforeAll, describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { $ } from "bun"; +import { compileVaporApp, type CompiledApp, VaporCompileError } from "../compiler/compile.ts"; +import { buildGbaRom } from "../compiler/rom.ts"; +import { Button } from "../host/input.ts"; +import { defineRpgMap, rpgBlocked, rpgEventAt } from "../host/rpg.ts"; + +const HERE = import.meta.dir; +const ENTRY = join(HERE, "..", "examples", "rpg", "rpg.tsx"); +const OUT = join(HERE, "..", "..", "dist", "vapor"); +const ROM = join(OUT, "rpg.gba"); +const RUNNER = join(HERE, "harness", "mgba_runner"); +const ASSET_HEADER = join(HERE, "..", "runtime", "gba", "vapor_rpg_assets.generated.h"); +const DEBUG_STATE = 0x02000010; + +let source = ""; +let app: CompiledApp; +let runReads: Record; +let holdReads: Record; +let cameraReads: Record; +let motionReads: Record; +let motionReplayReads: Record; +let cameraMotionReads: Record; + +function press(button: number): string { + // A cardinal step needs one acceptance tick plus eight 2px motion ticks. + // The release tail also absorbs libmGBA's one-frame key-sampling boundary. + return `P ${(1 << button).toString(16)} 2 10`; +} + +function stateAddress(name: string): number { + const slot = app.debugSlots.find((candidate) => candidate.name === name); + if (!slot) throw new Error(`missing debug slot ${name}`); + return DEBUG_STATE + slot.offset; +} + +function readState(lines: string[], label: string, names: readonly string[]): void { + for (const name of names) { + lines.push(`R ${label}_${name} 0x${stateAddress(name).toString(16)} 4`); + } +} + +function value(label: string, name: string): number { + return runReads[`${label}_${name}`] as number; +} + +function generatedWords(name: string): number[] { + const header = readFileSync(ASSET_HEADER, "utf8"); + const match = header.match(new RegExp(`static const u16 ${name}\\[\\d+\\] = \\{([\\s\\S]*?)\\n\\};`)); + if (!match) throw new Error(`missing generated asset array ${name}`); + return [...match[1].matchAll(/0x([0-9a-f]{4})/g)].map((word) => Number.parseInt(word[1], 16)); +} + +function littleEndianHex(words: number[]): string { + return words.map((word) => `${(word & 0xff).toString(16).padStart(2, "0")}${(word >> 8).toString(16).padStart(2, "0")}`).join(""); +} + +function littleEndianU16(hex: string, byteOffset: number): number { + const at = byteOffset * 2; + return Number.parseInt(`${hex.slice(at + 2, at + 4)}${hex.slice(at, at + 2)}`, 16); +} + +function signed32(value: number): number { + return value > 0x7fffffff ? value - 0x1_0000_0000 : value; +} + +function hexAscii(hex: string): string { + let out = ""; + for (let at = 0; at < hex.length; at += 2) { + out += String.fromCharCode(Number.parseInt(hex.slice(at, at + 2), 16)); + } + return out; +} + +interface MotionSample { + frame: number; + x: number; + walk: number; + oam: string; + scroll?: number; + video?: number; +} + +function semanticMotion(reads: Record): MotionSample[] { + const samples: MotionSample[] = []; + let lastFrame = -1; + for (let tick = 0; tick < 20; tick++) { + const frame = reads[`t${tick}_frame`] as number; + if (frame === lastFrame) continue; + lastFrame = frame; + const sample: MotionSample = { + frame, + x: reads[`t${tick}_x`] as number, + walk: signed32(reads[`t${tick}_walk`] as number), + oam: reads[`t${tick}_oam`] as string, + }; + const scroll = reads[`t${tick}_scroll`]; + if (typeof scroll === "number") sample.scroll = scroll; + const video = reads[`t${tick}_video`]; + if (typeof video === "number") sample.video = video; + samples.push(sample); + } + return samples; +} + +async function countPpmColors(path: string): Promise { + const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()); + const head = new TextDecoder().decode(bytes.slice(0, 64)); + const match = head.match(/^P6\n(\d+) (\d+)\n255\n/); + if (!match) throw new Error(`not a P6 screenshot: ${path}`); + const offset = match[0].length; + const colors = new Set(); + for (let i = offset; i + 2 < bytes.length; i += 3) { + colors.add(`${bytes[i]},${bytes[i + 1]},${bytes[i + 2]}`); + } + return colors.size; +} + +beforeAll(async () => { + source = await Bun.file(ENTRY).text(); + app = compileVaporApp(ENTRY, source, "VAPOR QUEST", "gba"); + await buildGbaRom(app, ROM); + if (!existsSync(RUNNER)) await $`bun ${join(HERE, "harness", "build.ts")}`.quiet(); + + const state = [ + "mode", + "playerX", + "playerY", + "facing", + "quest", + "dialog", + "choice", + "heroHp", + "enemyHp", + "battleCursor", + "walkPx", + ] as const; + const worldShot = join(OUT, "rpg-world.ppm"); + const dialogShot = join(OUT, "rpg-dialog.ppm"); + const battleShot = join(OUT, "rpg-battle.ppm"); + const lines: string[] = ["A 8"]; + + readState(lines, "boot", state); + lines.push(`S ${worldShot}`); + lines.push("D world_bg1 0x06004800 2048"); + lines.push("D world_oam 0x07000000 24"); + lines.push("R world_dispcnt 0x04000000 2"); + lines.push("D asset_bg_tiles 0x06008000 1728"); + lines.push("D asset_obj_tiles 0x06010000 15360"); + lines.push("D asset_bg_palette 0x050001e0 32"); + lines.push("D asset_obj_palettes 0x05000200 96"); + + // Walk into the north wall, then restore the spawn row. + lines.push(press(Button.Up), press(Button.Up)); + readState(lines, "wall", ["playerX", "playerY", "facing"]); + lines.push(press(Button.Down)); + + // Stand west of the elder. Right is blocked by N but still turns the hero; + // A then talks to the facing cell. + lines.push(press(Button.Right), press(Button.Down), press(Button.Right), press(Button.A)); + readState(lines, "offer", ["mode", "playerX", "playerY", "facing", "quest", "dialog", "choice"]); + lines.push(`S ${dialogShot}`); + lines.push("R dialog_dispcnt 0x04000000 2"); + + // Exercise NO with a long hold: repeats are world-only, so the two-choice + // dialog must not toggle back and forth. Close, talk again, then accept YES. + lines.push(`P ${(1 << Button.Down).toString(16)} 20 4`); + readState(lines, "offerHeld", ["choice"]); + lines.push(press(Button.A)); + readState(lines, "declined", ["mode", "quest", "dialog", "choice"]); + lines.push(press(Button.A), press(Button.A), press(Button.A)); + readState(lines, "accepted", ["mode", "quest", "dialog", "choice"]); + lines.push(press(Button.A)); + + // Return to y=2 and approach S at (8,2). The final step is sampled in + // flight: integer occupancy and its battle event must stay at x=7/world + // until the full 16px transition arrives. + lines.push(press(Button.Up)); + for (let i = 0; i < 4; i++) lines.push(press(Button.Right)); + readState(lines, "slimeBefore", ["mode", "playerX", "playerY", "walkPx"]); + lines.push(`P ${(1 << Button.Right).toString(16)} 5 0`); + readState(lines, "slimeMid", ["mode", "playerX", "playerY", "walkPx"]); + lines.push("A 10"); + readState(lines, "battle", ["mode", "playerX", "playerY", "quest", "heroHp", "enemyHp", "battleCursor"]); + lines.push(`S ${battleShot}`); + lines.push("D battle_bg1 0x06004800 2048"); + lines.push("D battle_oam 0x07000000 24"); + lines.push("R battle_dispcnt 0x04000000 2"); + + // HEAL once after another long hold; battle selection also ignores repeats. + // Then ATTACK three times. Victory is a reactive dialog state. + lines.push(`P ${(1 << Button.Down).toString(16)} 25 4`, press(Button.A)); + readState(lines, "healed", ["mode", "heroHp", "enemyHp", "battleCursor"]); + lines.push(press(Button.Up), press(Button.A), press(Button.A), press(Button.A)); + readState(lines, "won", ["mode", "quest", "dialog", "heroHp", "enemyHp"]); + lines.push(press(Button.A)); + + // Return to the elder, turn into the solid NPC, report, and close. + for (let i = 0; i < 5; i++) lines.push(press(Button.Left)); + lines.push(press(Button.Down), press(Button.Right), press(Button.A)); + readState(lines, "completeDialog", ["mode", "playerX", "playerY", "facing", "quest", "dialog"]); + lines.push(press(Button.A)); + readState(lines, "complete", ["mode", "quest", "dialog"]); + lines.push("R trips 0x0200000c 1"); + + const scenario = join(OUT, "rpg-play-tape.txt"); + await Bun.write(scenario, `${lines.join("\n")}\n`); + const output = await $`${RUNNER} ${ROM} ${scenario}`.text(); + const parsed = JSON.parse(output) as { ok: boolean; reads: Record }; + expect(parsed.ok).toBe(true); + runReads = parsed.reads; + + // Held movement is sampled every semantic frame. Release in mid-step must + // finish that accepted cell and then settle at idle rather than freezing + // on a sub-cell offset. + const holdScenario = join(OUT, "rpg-hold-tape.txt"); + await Bun.write( + holdScenario, + [ + "A 8", + `P ${(1 << Button.Right).toString(16)} 22 0`, + `R held_x 0x${stateAddress("playerX").toString(16)} 4`, + `R held_y 0x${stateAddress("playerY").toString(16)} 4`, + `R held_facing 0x${stateAddress("facing").toString(16)} 4`, + `R held_walk 0x${stateAddress("walkPx").toString(16)} 4`, + "A 16", + `R released_x 0x${stateAddress("playerX").toString(16)} 4`, + `R released_walk 0x${stateAddress("walkPx").toString(16)} 4`, + "R hold_trips 0x0200000c 1", + "", + ].join("\n"), + ); + const holdOutput = await $`${RUNNER} ${ROM} ${holdScenario}`.text(); + const holdParsed = JSON.parse(holdOutput) as { + ok: boolean; + reads: Record; + }; + expect(holdParsed.ok).toBe(true); + holdReads = holdParsed.reads; + + // Drop below the solid Slime, then walk far enough east to put the hero on + // the camera focus column. The logical map state remains global while BG1 + // and OAM become a 15x10 window. + const cameraScenario = join(OUT, "rpg-camera-tape.txt"); + const cameraLines = ["A 8", press(Button.Down), press(Button.Down)]; + for (let i = 0; i < 12; i++) cameraLines.push(press(Button.Right)); + cameraLines.push( + `R player_x 0x${stateAddress("playerX").toString(16)} 4`, + "D bg1 0x06004800 2048", + "D oam 0x07000000 16", + "R trips 0x0200000c 1", + "", + ); + await Bun.write(cameraScenario, cameraLines.join("\n")); + const cameraOutput = await $`${RUNNER} ${ROM} ${cameraScenario}`.text(); + const cameraParsed = JSON.parse(cameraOutput) as { + ok: boolean; + reads: Record; + }; + expect(cameraParsed.ok).toBe(true); + cameraReads = cameraParsed.reads; + + // Sample one continuous hold after every libmGBA video frame. DBG_FRAME + // lets assertions discard only the initial host key-sampling duplicate; + // every semantic tick then has matching reactive state and committed OAM. + const motionScenario = join(OUT, "rpg-motion-tape.txt"); + const motionShot = join(OUT, "rpg-motion.ppm"); + const motionLines = ["A 8"]; + for (let tick = 0; tick < 20; tick++) { + motionLines.push( + `P ${(1 << Button.Right).toString(16)} 1 0`, + `R t${tick}_frame 0x02000004 4`, + `R t${tick}_x 0x${stateAddress("playerX").toString(16)} 4`, + `R t${tick}_walk 0x${stateAddress("walkPx").toString(16)} 4`, + `D t${tick}_oam 0x07000000 16`, + `H t${tick}_video`, + ); + if (tick === 6) motionLines.push(`D t${tick}_hud 0x02000100 30`); + if (tick === 6) motionLines.push(`S ${motionShot}`); + } + motionLines.push("R motion_trips 0x0200000c 1", ""); + await Bun.write(motionScenario, motionLines.join("\n")); + const motionOutput = await $`${RUNNER} ${ROM} ${motionScenario}`.text(); + const motionParsed = JSON.parse(motionOutput) as { + ok: boolean; + reads: Record; + }; + expect(motionParsed.ok).toBe(true); + motionReads = motionParsed.reads; + const motionReplayOutput = await $`${RUNNER} ${ROM} ${motionScenario}`.text(); + const motionReplayParsed = JSON.parse(motionReplayOutput) as { + ok: boolean; + reads: Record; + }; + expect(motionReplayParsed.ok).toBe(true); + motionReplayReads = motionReplayParsed.reads; + + // Put the hero on the camera focus at (8,4), then sample a full step. The + // camera scroll should advance by the same 2px timeline while hero OAM stays + // pinned at the focus pixel. + const cameraMotionScenario = join(OUT, "rpg-camera-motion-tape.txt"); + const cameraMotionLines = ["A 8", press(Button.Down), press(Button.Down)]; + for (let i = 0; i < 6; i++) cameraMotionLines.push(press(Button.Right)); + for (let tick = 0; tick < 20; tick++) { + cameraMotionLines.push( + `P ${(1 << Button.Right).toString(16)} 1 0`, + `R t${tick}_frame 0x02000004 4`, + `R t${tick}_x 0x${stateAddress("playerX").toString(16)} 4`, + `R t${tick}_walk 0x${stateAddress("walkPx").toString(16)} 4`, + `R t${tick}_scroll 0x020005c0 2`, + `D t${tick}_oam 0x07000000 16`, + `H t${tick}_video`, + ); + } + cameraMotionLines.push("R camera_motion_trips 0x0200000c 1", ""); + await Bun.write(cameraMotionScenario, cameraMotionLines.join("\n")); + const cameraMotionOutput = await $`${RUNNER} ${ROM} ${cameraMotionScenario}`.text(); + const cameraMotionParsed = JSON.parse(cameraMotionOutput) as { + ok: boolean; + reads: Record; + }; + expect(cameraMotionParsed.ok).toBe(true); + cameraMotionReads = cameraMotionParsed.reads; +}, 120000); + +describe("Pocket Vapor RPG host", () => { + test("JS host queries share the map's collision and event semantics", () => { + const map = defineRpgMap({ + rows: ["###", "#N#", "###"], + solid: "#N", + events: { N: 7 }, + dialogs: [], + }); + expect(rpgBlocked(map, 1, 1)).toBe(true); + expect(rpgBlocked(map, -1, 1)).toBe(true); + expect(rpgEventAt(map, 1, 1)).toBe(7); + expect(rpgEventAt(map, 1, 0)).toBe(0); + expect(() => + defineRpgMap({ rows: ["..."], solid: "#", events: { N: 1 }, dialogs: [] }), + ).toThrow("does not appear"); + }); + + test("compiler emits ROM assets, pure queries, and one reactive native effect", () => { + const again = compileVaporApp(ENTRY, source, "VAPOR QUEST", "gba"); + expect(again.c).toBe(app.c); + expect(app.rpgEnabled).toBe(true); + expect(app.c).toContain("const u8 vp_rpg_enabled = 1"); + expect(app.c).toContain("vp_rpg_blocked(&RPG_RPG_MAP"); + expect(app.c).toContain("vp_rpg_event_at(&RPG_RPG_MAP"); + expect(app.c).toContain("vp_rpg_render(&RPG_RPG_MAP"); + const rpgEffect = [...app.c.matchAll(/static void eff_\d+\(void\) \{([\s\S]*?)\n\}/g)] + .find((match) => match[1].includes("vp_rpg_render")); + expect(rpgEffect).toBeDefined(); + expect(rpgEffect![1]).not.toContain("vp_row_clear"); + expect(app.c).toContain("SLIME BLOCKS EAST ROAD."); + expect(app.graph).toContain("mode (num)"); + expect(app.graph).toContain("questActive:"); + expect(app.graph).toContain("walkPx (num)"); + expect(app.graph).toContain("frame hook: fixed (gba)"); + expect(app.c).toContain("void app_on_frame(u32 buttons)"); + expect(app.plan).toContain("RPG host RAM: 3136 B"); + expect(app.plan).toContain("6 static and 16 walking 32x32 world frames"); + expect(app.plan).toContain("17216 B ROM"); + }); + + test("pixel RPG host is explicitly GBA-only", () => { + expect(() => compileVaporApp(ENTRY, source, "VAPOR QUEST", "gb")).toThrow(VaporCompileError); + expect(() => compileVaporApp(ENTRY, source, "VAPOR QUEST", "gb")).toThrow(/GBA/i); + }); + + test("compiler rejects malformed map assets and an incomplete screen contract", () => { + const ragged = source.replace( + '"##############################",', + '"#############################",', + ); + expect(() => compileVaporApp(ENTRY, ragged, "VAPOR QUEST", "gba")).toThrow(/equal width/); + + const missingEventTile = source.replace("N: Event.Elder,", "Z: Event.Elder,"); + expect(() => compileVaporApp(ENTRY, missingEventTile, "VAPOR QUEST", "gba")).toThrow( + /does not occur/, + ); + + const missingProp = source.replace(" battleCursor={battleCursor.value}\n", ""); + expect(() => compileVaporApp(ENTRY, missingProp, "VAPOR QUEST", "gba")).toThrow( + /missing required prop "battleCursor"/, + ); + + const siblingRow = source.replace( + " {"not native UI"}\n compileVaporApp(ENTRY, siblingRow, "VAPOR QUEST", "gba")).toThrow( + /must be the only root render unit/, + ); + }); +}); + +describe("native GBA RPG play tape", () => { + test("reactive walking advances 2px per semantic frame and cycles native walk tiles", async () => { + const step = semanticMotion(motionReads).filter((sample) => sample.walk >= 0).slice(0, 9); + expect(step.map((sample) => sample.walk)).toEqual([0, 2, 4, 6, 8, 10, 12, 14, 0]); + expect(step.map((sample) => sample.x)).toEqual([2, 2, 2, 2, 2, 2, 2, 2, 3]); + expect(step.map((sample) => littleEndianU16(sample.oam, 10) & 0x01ff)).toEqual([ + 24, 26, 28, 30, 32, 34, 36, 38, 40, + ]); + expect(step.map((sample) => littleEndianU16(sample.oam, 12) & 0x03ff)).toEqual([ + 288, 288, 304, 304, 320, 320, 336, 336, 288, + ]); + expect(motionReads.motion_trips).toBe(0); + expect(motionReplayReads).toEqual(motionReads); + expect(hexAscii(motionReads.t6_hud as string).trim()).toBe("QUEST: TALK TO THE ELDER"); + expect(await countPpmColors(join(OUT, "rpg-motion.ppm"))).toBeGreaterThanOrEqual(8); + }); + + test("holding chains steps and release completes only the accepted step", () => { + expect([holdReads.held_x, holdReads.held_y, holdReads.held_facing]).toEqual([4, 2, 3]); + expect(signed32(holdReads.held_walk as number)).toBeGreaterThanOrEqual(0); + expect(signed32(holdReads.held_walk as number)).toBeLessThan(16); + expect(holdReads.released_x).toBe(5); + expect(signed32(holdReads.released_walk as number)).toBe(-1); + expect(holdReads.hold_trips).toBe(0); + }); + + test("the fractional camera follows the same timeline without moving hero focus", () => { + const step = semanticMotion(cameraMotionReads).filter((sample) => sample.walk >= 0).slice(0, 9); + expect(step.map((sample) => sample.walk)).toEqual([0, 2, 4, 6, 8, 10, 12, 14, 0]); + expect(step.map((sample) => sample.scroll)).toEqual([0, 2, 4, 6, 8, 10, 12, 14, 0]); + expect(step.map((sample) => littleEndianU16(sample.oam, 2) & 0x01ff)).toEqual( + Array(9).fill(104), + ); + const physicalFrames = semanticMotion(cameraMotionReads).filter((sample) => sample.walk >= 0); + for (let at = 1; at < physicalFrames.length; at++) { + expect(physicalFrames[at].video).not.toBe(physicalFrames[at - 1].video); + } + expect(cameraMotionReads.camera_motion_trips).toBe(0); + }); + + test("the 15x10 camera follows the player and culls off-window NPCs", () => { + expect(cameraReads.player_x).toBe(14); + const oam = cameraReads.oam as string; + expect(littleEndianU16(oam, 2) & 0x01ff).toBe(104); + expect(littleEndianU16(oam, 8) & 0x0200).toBe(0x0200); + expect(cameraReads.bg1).not.toBe(runReads.world_bg1); + expect(cameraReads.trips).toBe(0); + }); + + test("collision turns without moving and A opens the elder offer", () => { + expect([value("boot", "mode"), value("boot", "playerX"), value("boot", "playerY")]).toEqual([0, 2, 2]); + expect([value("wall", "playerX"), value("wall", "playerY"), value("wall", "facing")]).toEqual([2, 1, 1]); + expect([ + value("offer", "mode"), + value("offer", "playerX"), + value("offer", "playerY"), + value("offer", "facing"), + value("offer", "quest"), + value("offer", "dialog"), + ]).toEqual([1, 3, 3, 3, 0, 1]); + }); + + test("destination occupancy and the Slime event commit only on arrival", () => { + expect([ + value("slimeBefore", "mode"), + value("slimeBefore", "playerX"), + value("slimeBefore", "playerY"), + signed32(value("slimeBefore", "walkPx")), + ]).toEqual([0, 7, 2, -1]); + expect([ + value("slimeMid", "mode"), + value("slimeMid", "playerX"), + value("slimeMid", "playerY"), + ]).toEqual([0, 7, 2]); + expect(signed32(value("slimeMid", "walkPx"))).toBeGreaterThanOrEqual(0); + expect(signed32(value("slimeMid", "walkPx"))).toBeLessThan(16); + expect([value("battle", "mode"), value("battle", "playerX"), value("battle", "playerY")]).toEqual([ + 2, 8, 2, + ]); + }); + + test("choice, quest gate, heal, battle, and report form one complete loop", () => { + expect(value("offerHeld", "choice")).toBe(1); + expect([value("declined", "quest"), value("declined", "dialog")]).toEqual([0, 3]); + expect([value("accepted", "mode"), value("accepted", "quest"), value("accepted", "dialog")]).toEqual([1, 1, 2]); + expect([ + value("battle", "mode"), + value("battle", "playerX"), + value("battle", "playerY"), + value("battle", "quest"), + value("battle", "heroHp"), + value("battle", "enemyHp"), + ]).toEqual([2, 8, 2, 1, 30, 18]); + expect([value("healed", "heroHp"), value("healed", "enemyHp"), value("healed", "battleCursor")]).toEqual([26, 18, 1]); + expect([ + value("won", "mode"), + value("won", "quest"), + value("won", "dialog"), + value("won", "heroHp"), + value("won", "enemyHp"), + ]).toEqual([1, 2, 4, 18, 0]); + expect([ + value("completeDialog", "mode"), + value("completeDialog", "playerX"), + value("completeDialog", "playerY"), + value("completeDialog", "facing"), + value("completeDialog", "quest"), + value("completeDialog", "dialog"), + ]).toEqual([1, 3, 3, 3, 3, 5]); + expect([value("complete", "mode"), value("complete", "quest"), value("complete", "dialog")]).toEqual([0, 3, 0]); + expect(runReads.trips).toBe(0); + }); + + test("world, dialog, and battle produce distinct multi-color hardware frames", async () => { + const paths = ["rpg-world.ppm", "rpg-dialog.ppm", "rpg-battle.ppm"].map((name) => join(OUT, name)); + const frames = await Promise.all(paths.map((path) => Bun.file(path).arrayBuffer())); + expect(new Set(frames.map((bytes) => Bun.hash(new Uint8Array(bytes)).toString())).size).toBe(3); + for (const path of paths) expect(await countPpmColors(path)).toBeGreaterThanOrEqual(8); + expect(runReads.world_bg1).not.toBe(runReads.battle_bg1); + expect(runReads.world_oam).not.toBe(runReads.battle_oam); + }); + + test("world metatiles and enlarged OBJ sizes reach native GBA OAM", () => { + const world = runReads.world_oam as string; + const battle = runReads.battle_oam as string; + const worldAttr1 = [littleEndianU16(world, 2), littleEndianU16(world, 10)]; + const battleAttr1 = [littleEndianU16(battle, 2), littleEndianU16(battle, 10)]; + expect(worldAttr1.map((attr) => attr & 0xc000)).toEqual([0x8000, 0x8000]); + expect(battleAttr1.map((attr) => attr & 0xc000)).toEqual([0xc000, 0xc000]); + // At boot the lower elder is OAM slot zero and the hero is slot one. + // Their two-cell X distance is now 32 screen pixels, proving the 16px grid. + expect([(worldAttr1[0] & 0x01ff), (worldAttr1[1] & 0x01ff)]).toEqual([56, 24]); + }); + + test("hardware windows keep the scrolling world behind fixed HUD and dialog UI", () => { + expect(runReads.world_dispcnt).toBe(0x3340); + expect(runReads.dialog_dispcnt).toBe(0x3340); + expect(runReads.battle_dispcnt).toBe(0x1340); + }); + + test("generated 4bpp art and palettes arrive in the exact GBA VRAM banks", () => { + expect(runReads.asset_bg_tiles).toBe(littleEndianHex(generatedWords("vp_rpg_bg_tiles"))); + expect(runReads.asset_obj_tiles).toBe(littleEndianHex(generatedWords("vp_rpg_obj_tiles"))); + expect(runReads.asset_bg_palette).toBe(littleEndianHex(generatedWords("vp_rpg_bg_palette"))); + expect(runReads.asset_obj_palettes).toBe(littleEndianHex(generatedWords("vp_rpg_obj_palettes"))); + }); +});