diff --git a/CLAUDE.md b/CLAUDE.md index a3b68399..6da2d620 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,3 +4,4 @@ - If the user also asks to merge the change, open the draft pull request first, then mark it ready and merge it after the relevant checks pass. - Name pull requests (and the branch's primary commit) using the Conventional Commits format — `type(scope): summary`, e.g. `feat(gallery): …`, `fix: …`, `docs: …`, `refactor: …`. - Keep PocketJS examples explicit about API ownership: import PocketJS runtime, host components, lifecycle, input, and animation APIs from `@pocketjs/framework/*`; import Solid primitives and control flow directly from `solid-js`. +- Pocket Vapor apps take incremental input only through the hardware-neutral `RelativeAxis`/`onAxisDelta` contract in `vapor/host/input.ts` — never device SDK concepts in app code, never crank motion encoded as fake buttons (details: `vapor/DESIGN.md` §5). diff --git a/package.json b/package.json index 45b60db0..3ceb51ea 100644 --- a/package.json +++ b/package.json @@ -143,6 +143,9 @@ "vapor:gb": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target gb", "vapor:nes": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target nes", "vapor:esp32": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target esp32", + "vapor:playdate": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.playdate.tsx --target playdate --playdate-mode simulator", + "vapor:playdate:device": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.playdate.tsx --target playdate --playdate-mode device", + "vapor:playdate:both": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.playdate.tsx --target playdate --playdate-mode both", "vapor:esp32:flash": "bun vapor/scripts/esp32.ts flash", "vapor:esp32:verify": "bun vapor/scripts/esp32.ts verify", "vapor:dev": "bun vapor/scripts/dev.ts", diff --git a/vapor/BOARDS.md b/vapor/BOARDS.md index 8923af9a..2ceb504f 100644 --- a/vapor/BOARDS.md +++ b/vapor/BOARDS.md @@ -25,6 +25,11 @@ open-ended and mostly built by other people. Stretching capability ids over it ("esp32.lcd.st7735") would burn the registry's own first rule: ids name observable framework behavior, never hardware. +Fixed console-style AOT targets such as Playdate live directly in +`VAPOR_TARGETS` and their runtime/compiler contract. They do not enter the +ESP32-only board JSON schema: a fixed SDK target is not an open-ended +chip/panel/pin combination. + A pocket.json may declare which classes it ships as (`execution.classes`, default `["guest"]`); the guest build resolver refuses a manifest that ships no guest artifact (`execution.guestExcluded`). @@ -75,13 +80,14 @@ behaviors the runtime already has; it never programs new ones. Guest apps hand-declare `requires` because the framework cannot always see what they use. The AOT compiler *can* see: which buttons the keymaps and -handlers statically reference (`CompiledApp.buttonsUsed`), how many style -pairs the class DSL resolved, which `SCREEN` folds the layout takes, the -whole memory plan. So the demand block is compiler output: +handlers statically reference (`CompiledApp.buttonsUsed`), which incremental +controls register (`CompiledApp.relativeAxesUsed`), how many style pairs the +class DSL resolved, which `SCREEN` folds the layout takes, the whole memory +plan. So the demand block is compiler output: ```sh bun vapor/compiler/cli.ts check app.tsx --json -# { targets: { esp32: { ok, grid, stylePairs, buttonsUsed, ... } }, +# { targets: { esp32: { ok, grid, stylePairs, buttonsUsed, relativeAxesUsed, ... } }, # boards: { meowbit: { ok, issues: [...] } } } ``` @@ -132,6 +138,12 @@ don't enumerate monitors; they declare breakpoints and the client decides. ## Deliberately not built yet +- **ESP32 relative-axis adapters.** Vapor's generated ABI already models + signed canonical `RelativeAxis` deltas and Playdate maps its crank to + Primary in millidegrees. An + ESP32 board may only declare an encoder/wheel after its runtime implements + pulse decoding, direction, and `pulsesPerStep`; until then admission must + fail instead of accepting an inert axis. - **Per-board grid geometry.** Today the `esp32` target owns 20×18 and the board must fit it. The second board with a different panel promotes geometry to a board field and turns `VAPOR_TARGETS.esp32` into a family. diff --git a/vapor/DESIGN.md b/vapor/DESIGN.md index dd5999c4..22edb3e7 100644 --- a/vapor/DESIGN.md +++ b/vapor/DESIGN.md @@ -6,9 +6,9 @@ Vapor** — real `ref`/`computed` reactivity, real JSX templates — and the compiler emits native code for machines that could never host a JavaScript engine. The original proof target is the Game Boy Advance: 16.8 MHz ARM7TDMI, 256 KB of work RAM, no OS, no allocator, no GC. The same compiler -now also targets the Game Boy, NES, and an ESP32 MeowBit profile; the ESP32 -build is still native C with a fixed memory plan, not an embedded JavaScript -runtime. +now also targets the Game Boy, NES, an ESP32 MeowBit profile, and Playdate; +all builds remain native C with a fixed memory plan, not an embedded +JavaScript runtime. Vue Vapor's thesis is *compile the virtual DOM away*. Pocket Vapor extends it one machine layer down: **compile the JavaScript engine away.** The @@ -169,7 +169,7 @@ MEANS is the target's style contract: |---|---|---| | web (oracle/dev host) | `web` | full color, CSS | | gba | `rgb555`, <= 15 pairs | pair id = BG palette bank (BGR555 ink/paper) | -| gb, nes | `styles2` | pair -> glyph style by luminance polarity (dark-on-light / light-on-dark) | +| gb, nes, playdate | `styles2` | pair -> glyph style by luminance polarity (dark-on-light / light-on-dark) | | esp32 | `rgb565` | pair id -> RGB565 ink/paper values rasterized into the LCD cell | Diagnostics are compile-time and structured (`bun vapor/compiler/cli.ts @@ -192,7 +192,7 @@ oxlint plugin would give red squiggles without booting the compiler. The ## 5. Host vocabulary and rendering Each target presents a fixed logical cell screen: 30×20 on GBA, 20×18 on -GB and ESP32, and 22×18 on NES. The ESP32 MeowBit profile rasterizes its +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 @@ -210,11 +210,20 @@ 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 -`onButton((b: Button) => void)` (frame-latched edge triggering, GBA -KEYINPUT bit order). Under the oracle the module executes and the test tape -feeds it; under the compiler the import is recognized and the handler -compiles to a C function fed by the runtime's key-edge loop. +Input is not DOM events. The host module exposes two explicit capabilities: + +- `onButton((b: Button) => void)` for frame-latched press edges; +- `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: +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 +to the app. Axis demands are derived from registrations, and a target +without an adapter fails admission with `VT102`. ## 6. Pipeline @@ -230,14 +239,15 @@ todo.tsx ─┬─ framework/compiler/jsx-plugin.ts + vue-jsx-vapor ──► re 6 memory plan slots, pools, budgets (printed with the graph) 7 emit C gen_app.c (state, computeds, effects, handler) 8 cc + link target toolchain + vapor/runtime//* - → cartridge ROM or ESP32 firmware image + → ROM, firmware, or Playdate .pdx ``` The fixed C contract (`vapor/runtime/vapor.h` plus `vapor_core.c`) is shared by all apps. Each target supplies its hardware half: the console runtimes own startup, video commit, input edges and a fixed debug block; the ESP32 runtime owns the ESP-IDF frame loop, ST7735 RGB565 raster, MeowBit GPIO -input and the UART receipt protocol. +input and the UART receipt protocol. The Playdate runtime owns the SDK event +handler, pushed-button snapshots, and direct 52-byte-stride 1bpp framebuffer. ## 7. E2E: the oracle is real Vue @@ -263,25 +273,35 @@ input and the UART receipt protocol. logical-grid parity and exercises LCD commits, but does not read panel pixels or electrically actuate GPIO buttons; those remain manual checks. Physical hardware is not part of the default emulator-only test suite. +5. **Playdate native boundary** (`playdate.test.ts`) — compiled C tests + exercise exact framebuffer bytes and a fake SDK table drives lifecycle, + pushed-button batches, crank accumulation/docking, redraws, receipts, and + fatal render behavior. The Playdate Todo is also replayed through the real + Vue Vapor oracle with signed Primary-axis deltas. + Separate SDK smoke builds validate Simulator and ARM device packages; + physical display/input verification remains manual. Layers 3 and 4 state the claim of the whole project: same file, real Vue on a JS engine, and native code on devices, compared for every step of the interaction rather than only the final frame. The three console targets run under automated emulators; ESP32 uses the same grid-receipt assertion when -a board is connected. +a board is connected. Playdate's first implementation has deterministic +native-boundary tests and package smoke builds, not a claimed hardware +parity transport. ## 7.5 Targets -| | GBA | GB (DMG) | NES | ESP32 MeowBit | -|---|---|---|---|---| -| CPU | ARM7TDMI 16.8MHz | SM83 4.19MHz | 6502 1.79MHz | Xtensa LX6, up to 240MHz | -| Toolchain | arm-none-eabi-gcc | sdcc + sdasgb + makebin + rgbfix | cc65/ca65/ld65 | ESP-IDF v6.0.2 | -| Grid | 30x20 | 20x18 | 22x18 (centered) | 20x18 on ST7735 160x128 | -| Palettes | 6 real BG banks | 2 glyph styles (BGP is global) | 2 glyph styles in CHR-ROM | RGB565 ink/paper pairs | -| Pool / str caps | 32 / 24 | 32 / 24 | 8 / 20 (2 KB CPU RAM) | 32 / 24 | -| Image | flat ROM + header patch | ROM-only 32 KB | NROM-256 + CHR-ROM | ESP-IDF flash image | -| Debug receipt | EWRAM 0x2000000 | WRAM 0xD800 (grid IS the block) | $0200 fixed segment | UART 115200 (`H/R/P/D`) | -| E2E transport | libmgba | libmgba | jsnes | physical USB serial (opt-in) | +| | GBA | GB (DMG) | NES | ESP32 MeowBit | Playdate | +|---|---|---|---|---|---| +| CPU | ARM7TDMI 16.8MHz | SM83 4.19MHz | 6502 1.79MHz | Xtensa LX6, up to 240MHz | Cortex-M7 | +| Toolchain | arm-none-eabi-gcc | sdcc + sdasgb + makebin + rgbfix | cc65/ca65/ld65 | ESP-IDF v6.0.2 | Playdate SDK CMake + pdc | +| Grid | 30x20 | 20x18 | 22x18 (centered) | 20x18 on ST7735 160x128 | 50x30 on 400x240 1bpp | +| Palettes | 6 real BG banks | 2 glyph styles (BGP is global) | 2 glyph styles in CHR-ROM | RGB565 ink/paper pairs | 2 glyph styles | +| Pool / str caps | 32 / 24 | 32 / 24 | 8 / 20 (2 KB CPU RAM) | 32 / 24 | 32 / 24 | +| Image | flat ROM + header patch | ROM-only 32 KB | NROM-256 + CHR-ROM | ESP-IDF flash image | independent Simulator/device `.pdx` | +| Debug receipt | EWRAM 0x2000000 | WRAM 0xD800 (grid IS the block) | $0200 fixed segment | UART 115200 (`H/R/P/D`) | SDK console `PVREADY/PVFRAME/PVERROR` | +| E2E transport | libmgba | libmgba | jsnes | physical USB serial (opt-in) | fake SDK unit; Simulator/device smoke | +| Relative axes | none | none | none | board adapter (not yet configured) | Primary = crank, signed millidegrees | The generated C is target-independent; geometry and budgets arrive as `#define`s, `SCREEN.*` folds in the compiler, and each hardware runtime @@ -308,7 +328,14 @@ The ESP32 MeowBit exposes the six direct directions/action inputs used by the app. Release-latched pairs provide the remaining Pocket buttons: A+B = START, Left+Right = SELECT, and Up+Down = R. -State: 6 refs, 4 computeds (two of them list views), 4 span-merged paint +Playdate exposes A, B and the D-pad directly. It has no ordinary +Select/Start/R/L inputs, so target admission rejects those demands with +`VT101`. `vapor/examples/todo/todo.playdate.tsx` keeps the same model and +view but consumes `RelativeAxis.Primary` for list movement. This frees +Up/Down for new/clear in list mode and save/cancel in edit mode. The +six-button example remains a minimal regression fixture. + +State: 6 refs, 5 computeds (two of them list views), 4 span-merged paint effects, a 32-entry todo pool. The compiler's memory plan for the whole app is ~940 bytes of RAM; the GBA cartridge is under 9 KB, while the ESP32 artifact also includes the ESP-IDF platform image — which is the point of diff --git a/vapor/README.md b/vapor/README.md index c43049b0..937db309 100644 --- a/vapor/README.md +++ b/vapor/README.md @@ -4,7 +4,7 @@ TypeScript subset of Vue Vapor — real `ref`/`computed`, real JSX — and the Pocket Vapor compiler emits native code for devices that could never host a JavaScript engine: **ARM7 on the Game Boy Advance, SM83 on the Game Boy, -6502 on the NES, and Xtensa LX6 on the ESP32**. No JS engine, no GC, no +6502 on the NES, Xtensa LX6 on the ESP32, and Cortex-M7 on Playdate**. No JS engine, no GC, no allocator. Vue Vapor compiles the virtual DOM away; Pocket Vapor compiles the JavaScript engine away. @@ -16,15 +16,19 @@ the JavaScript engine away. |---|---| | ![gb](docs/todo-gb.png) | ![nes](docs/todo-nes.png) | -One component file, five executions: the oracle on real vue 3.6, three -cartridges, and one ESP32 firmware image. Screen geometry is a compile-time +The portable Todo component targets the oracle on real vue 3.6, three +cartridges, and ESP32 firmware. Its Playdate input variant shares the same +business model and rendering vocabulary while replacing list Up/Down with +the generic relative-axis input supplied by the crank. Screen geometry is a compile-time constant (`SCREEN.width`/`SCREEN.height` from the host module): layout math and width ternaries fold per target, so the narrow help strings on GB/NES/ESP32 cost zero bytes on GBA — compile-time responsive UI. -The proof is [`examples/todo/todo.tsx`](examples/todo/todo.tsx) — TodoMVC -with filters, a computed remaining-count, windowed scrolling and a glyph -editor. The **same file** runs two ways: +The proof is [`examples/todo/todo.tsx`](examples/todo/todo.tsx), plus the +Playdate control mapping in +[`examples/todo/todo.playdate.tsx`](examples/todo/todo.playdate.tsx) — +TodoMVC with filters, a computed remaining-count, windowed scrolling and a +glyph editor. Each component runs two ways: - **Oracle**: unmodified on `vue@3.6` `runtime-with-vapor` (through the repo's vue-jsx-vapor pipeline) over a micro-DOM, in bun. @@ -74,6 +78,26 @@ const listKeys: Keymap = { onButton((b) => (editing.value ? editKeys : listKeys)[b]?.()); ``` +Incremental controls are a separate, hardware-neutral input capability: + +```tsx +onAxisDelta(RelativeAxis.Primary, (delta) => { + if (!editing.value) { + remainder.value += delta; + const steps = Math.trunc( + remainder.value / (45 * RelativeAxisUnits.PerDegree), + ); + remainder.value %= 45 * RelativeAxisUnits.PerDegree; + moveCursor(steps); + } +}); +``` + +The generated ABI receives signed canonical deltas. Rotary hosts normalize +physical movement to millidegrees but do not choose a UI detent. The Playdate +Todo chooses 45 degrees itself; a future ESP32 board can map an encoder or +wheel to the same axis without exposing GPIO or Playdate APIs to the app. + Deleting is `todos.value = todos.value.filter((x) => x !== t)` (compiled to in-place pool compaction), and the selected todo is itself a computed — `const current = computed(() => filtered.value[cursor.value])` — cached as @@ -123,12 +147,19 @@ gb OK 20x18, 6 style pairs nes OK 22x18, 6 style pairs warn VS104: 3 distinct color pairs render as the same glyph style ... esp32 OK 20x18, 6 style pairs +playdate FAIL + error VT101: playdate has no physical input for Select, Start, R ... meowbit OK board (esp32) warn VB103: "start" is only reachable as the a+b chord on meowbit ... $ bun vapor/compiler/cli.ts check app.tsx --strict # lossy lowering = failure $ bun vapor/compiler/cli.ts check app.tsx --json # demands + verdicts as data ``` +That failure is intentional for the portable button-only file. Checking +`todo.playdate.tsx` reports Playdate support through its six direct buttons +and `RelativeAxis.Primary`; targets without a relative-axis adapter fail +with `VT102`. + Board rows are the AOT admission rule at work: MCU devices are data files (`boards/meowbit.json`), the compiler derives what the app demands (buttons used, style pairs, grid), and `check` judges every registered board against @@ -137,8 +168,9 @@ ability to enumerate devices. And the oracle is visible: `bun run vapor:dev` serves the app on real Vue Vapor in your browser — inspectable DOM rows, keyboard as the pad, -`?target=gb` to see the DMG's two-style world before you burn a cart, or -`?target=esp32` to preview the MeowBit's 20×18 logical viewport. +`?target=gb` to see the DMG's two-style world before you burn a cart, +`?target=esp32` to preview the MeowBit viewport, or `?target=playdate` for +the 50×30 one-bit contract. ## Commands @@ -153,6 +185,10 @@ bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx # → dis bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target gb # → todo.gb (32 KB) bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target nes # → todo.nes (40 KB) bun run vapor:esp32 # → app-only todo.esp32.bin + gen-esp32/ +bun run vapor:playdate # → crank-driven Todo Simulator .pdx +bun run vapor:playdate:device # → crank-driven Todo device .pdx +bun run vapor:playdate:both # → both independent .pdx packages +bun run vapor:playdate:smoke # → six-button regression fixture bun run vapor:esp32:flash # build + flash the connected ESP32 MeowBit bun run vapor:esp32:verify # build + flash + replay the Vue-oracle tape bun vapor/scripts/play.ts # build + open in mGBA @@ -163,7 +199,8 @@ bun test vapor/tests/ # oracle + compiler + ``` Toolchains: `arm-none-eabi-gcc` + `mgba` (GBA/GB), `sdcc` + `rgbfix` (GB), -`cc65` (NES, emulated by the jsnes dev-dependency), and **ESP-IDF v6.0.2** +`cc65` (NES, emulated by the jsnes dev-dependency), **ESP-IDF v6.0.2**, +and the Playdate SDK CMake/pdc toolchain (ESP32; set `IDF_PATH` / `IDF_TOOLS_PATH` when auto-discovery does not find the installation). Oracle tests run with bun alone. Notable per-target facts the runtime absorbs: the console shadow grid IS the debug block (fixed @@ -171,7 +208,8 @@ WRAM/CPU-RAM addresses), so the harness reads the logical screen even while a 1 MHz SM83 trickles VRAM through vblank; DMG has one palette, so logical palettes map to baked glyph styles; NES fits grid + pool + views into 2 KB of CPU RAM with the font in CHR-ROM; ESP32 rasterizes the same logical -20×18 grid into RGB565 on a 160×128 ST7735; and sdcc 4.6's SM83 port +20×18 grid into RGB565 on a 160×128 ST7735; Playdate maps a 50×30 grid +byte-for-cell into its 400×240 1bpp framebuffer; and sdcc 4.6's SM83 port miscompiles some u8-by-u8 multiplies, so generated indexing is u16 pointer arithmetic and bit masks come from a ROM table. @@ -180,13 +218,14 @@ arithmetic and bit masks come from a ROM table. ``` vapor/ DESIGN.md the thesis + subset + target/style contracts - examples/todo/ todo.tsx — the multi-console demo app - host/ input.ts (Button/onButton), screen.ts (SCREEN geometry) + examples/todo/ portable Todo + Playdate relative-axis input variant + host/ input.ts (buttons + relative axes), screen.ts (SCREEN geometry) 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) runtime/gba|gb|nes/ per-console halves: crt0, video commit, input, debug block runtime/esp32/ ESP-IDF loop, ST7735 RGB565 raster, buttons, UART receipt + runtime/playdate/ SDK lifecycle, raw 1bpp framebuffer, buttons + crank adapter scripts/ dev.ts (visible oracle), play.ts, shot.ts, esp32.ts (device protocol) tests/ styles + compiler + oracle + 3-console parity + shared device tape tests/harness/ headless libmgba runner (GBA+GB) + jsnes runner (NES) diff --git a/vapor/compiler/cli.ts b/vapor/compiler/cli.ts index 931e1a94..cf5010b1 100644 --- a/vapor/compiler/cli.ts +++ b/vapor/compiler/cli.ts @@ -1,7 +1,8 @@ #!/usr/bin/env bun // vapor/compiler/cli.ts — compile a Pocket Vapor component to a cartridge. // -// bun vapor/compiler/cli.ts [--target gba|gb|nes|esp32] [--out dist/vapor] +// bun vapor/compiler/cli.ts [--target gba|gb|nes|esp32|playdate] [--out dist/vapor] +// [--playdate-mode simulator|device|both] // bun vapor/compiler/cli.ts check [--strict] [--json] // // `check` runs the compiler frontend for EVERY target and prints the @@ -16,6 +17,7 @@ import { basename, join, resolve } from "node:path"; import { admitBoard, listBoards, loadBoard, POCKET_PAD, type BoardIssue } from "./boards.ts"; import { compileVaporApp, VAPOR_TARGETS, type CompiledApp, type VaporTargetName } from "./compile.ts"; import { buildRom } from "./rom.ts"; +import type { PlaydateBuildMode } from "./playdate.ts"; let args = process.argv.slice(2); @@ -37,6 +39,7 @@ if (args[0] === "check") { grid: string; stylePairs?: number; buttonsUsed?: string[]; + relativeAxesUsed?: string[]; warnings: string[]; errors: string[]; } @@ -53,6 +56,9 @@ if (args[0] === "check") { grid, stylePairs: app.styles.pairs.length, buttonsUsed: app.buttonsUsed.map((id) => POCKET_PAD[id]), + relativeAxesUsed: app.relativeAxesUsed.map((id) => + id === 0 ? "primary" : id === 1 ? "secondary" : String(id), + ), warnings: app.diagnostics, errors: [], }; @@ -102,7 +108,8 @@ if (args[0] === "check") { const entry = args.find((a) => !a.startsWith("--")); if (!entry) { console.error( - "usage: bun vapor/compiler/cli.ts [--target gba|gb|nes|esp32] [--out ]", + "usage: bun vapor/compiler/cli.ts [--target gba|gb|nes|esp32|playdate] " + + "[--out ] [--playdate-mode simulator|device|both]", ); process.exit(2); } @@ -114,10 +121,27 @@ if (!(target in VAPOR_TARGETS)) { console.error(`unknown target: ${target}`); process.exit(2); } +const playdateModeIdx = args.indexOf("--playdate-mode"); +const playdateMode = ( + playdateModeIdx >= 0 ? args[playdateModeIdx + 1] : "simulator" +) as PlaydateBuildMode; +if (!["simulator", "device", "both"].includes(playdateMode)) { + console.error(`invalid --playdate-mode: ${playdateMode}`); + process.exit(2); +} +if (target !== "playdate" && playdateModeIdx >= 0) { + console.error("--playdate-mode requires --target playdate"); + process.exit(2); +} const source = await Bun.file(entry).text(); const name = basename(entry).replace(/\.tsx$/, ""); -const app = compileVaporApp(entry, source, name === "todo" ? "VAPOR TODO" : name.toUpperCase(), target); +const app = compileVaporApp( + entry, + source, + name.startsWith("todo") ? "VAPOR TODO" : name.toUpperCase(), + target, +); console.log(`== reactive graph (${target}) ==`); console.log(app.graph); @@ -133,8 +157,15 @@ const ext = ? "nes" : target === "esp32" ? "esp32.bin" - : target satisfies never; -const rom = join(outDir, `${name}.${ext}`); -const { romBytes } = await buildRom(app, target, rom); + : target === "playdate" + ? null + : target satisfies never; +const output = ext ? join(outDir, `${name}.${ext}`) : join(outDir, name); +const artifacts = await buildRom(app, target, output, { playdateMode }); await Bun.write(join(outDir, `${name}.${target}.debug.json`), JSON.stringify(app.debugSlots, null, 2)); -console.log(`\n${rom} (${(romBytes / 1024).toFixed(1)} KB)`); +for (const artifact of artifacts) { + const platform = artifact.platform ? `/${artifact.platform}` : ""; + console.log( + `\n${artifact.path} (${(artifact.bytes / 1024).toFixed(1)} KB, ${artifact.kind}${platform})`, + ); +} diff --git a/vapor/compiler/compile.ts b/vapor/compiler/compile.ts index 770ddbdd..9ae59949 100644 --- a/vapor/compiler/compile.ts +++ b/vapor/compiler/compile.ts @@ -32,7 +32,7 @@ export interface DebugSlot { kind: "num" | "bool" | "str" | "listLen"; } -export type VaporTargetName = "gba" | "gb" | "nes" | "esp32"; +export type VaporTargetName = "gba" | "gb" | "nes" | "esp32" | "playdate"; export interface VaporTarget { name: VaporTargetName; @@ -51,6 +51,18 @@ export const VAPOR_TARGETS: Record = { gb: { name: "gb", width: 20, height: 18, poolCap: 32, strCap: 24 }, nes: { name: "nes", width: 22, height: 18, poolCap: 8, strCap: 20 }, esp32: { name: "esp32", width: 20, height: 18, poolCap: 32, strCap: 24 }, + playdate: { name: "playdate", width: 50, height: 30, poolCap: 32, strCap: 24 }, +}; + +const BUTTON_NAMES = ["A", "B", "Select", "Start", "Right", "Left", "Up", "Down", "R", "L"] as const; +const PLAYDATE_BUTTONS = new Set([0, 1, 4, 5, 6, 7]); +const RELATIVE_AXIS_NAMES = ["Primary", "Secondary"] as const; +const TARGET_RELATIVE_AXES: Record = { + gba: [], + gb: [], + nes: [], + esp32: [], + playdate: [0], }; export interface CompiledApp { @@ -67,6 +79,8 @@ export interface CompiledApp { * folded Button.X reads) — the input half of the app's derived demands. * Per target: code behind a false SCREEN fold is never compiled. */ buttonsUsed: number[]; + /** Relative axes statically registered through onAxisDelta(). */ + relativeAxesUsed: number[]; } export class VaporCompileError extends Error { @@ -193,7 +207,29 @@ export function compileVaporApp( options: CompileOptions = {}, ): CompiledApp { const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX); - return new AppCompiler(sf, title, VAPOR_TARGETS[targetName], options).compile(); + const app = new AppCompiler(sf, title, VAPOR_TARGETS[targetName], options).compile(); + if (targetName === "playdate") { + const unsupported = app.buttonsUsed.filter((button) => !PLAYDATE_BUTTONS.has(button)); + if (unsupported.length > 0) { + throw new Error( + `${fileName} — VT101: playdate has no physical input for ${unsupported + .map((button) => BUTTON_NAMES[button] ?? `button ${button}`) + .join(", ")}; supported buttons are A, B, Right, Left, Up, Down`, + ); + } + } + const supportedAxes = new Set(TARGET_RELATIVE_AXES[targetName]); + const unsupportedAxes = app.relativeAxesUsed.filter((axis) => !supportedAxes.has(axis)); + if (unsupportedAxes.length > 0) { + throw new Error( + `${fileName} — VT102: ${targetName} has no adapter for relative axis ${unsupportedAxes + .map((axis) => RELATIVE_AXIS_NAMES[axis] ?? String(axis)) + .join(", ")}; supported relative axes are ${ + [...supportedAxes].map((axis) => RELATIVE_AXIS_NAMES[axis] ?? String(axis)).join(", ") || "none" + }`, + ); + } + return app; } class AppCompiler { @@ -203,12 +239,14 @@ class AppCompiler { private computeds: ComputedBinding[] = []; private fns: FnBinding[] = []; private handler: ts.ArrowFunction | null = null; + private axisHandlers = new Map(); private template: ts.JsxFragment | null = null; private vueRef = ""; private vueComputed = ""; private hostOnButton = ""; private hostButton = ""; + private hostOnAxisDelta = ""; // emission private decls: string[] = []; @@ -278,6 +316,19 @@ class AppCompiler { } else if (/\/host\/input(\.ts)?$/.test(from)) { if (imported === "onButton") this.hostOnButton = local; else if (imported === "Button") this.hostButton = local; + else if (imported === "onAxisDelta") this.hostOnAxisDelta = local; + else if (imported === "RelativeAxis") + this.scope.set(local, { + kind: "const", + name: local, + value: { Primary: 0, Secondary: 1 }, + }); + else if (imported === "RelativeAxisUnits") + this.scope.set(local, { + kind: "const", + name: local, + value: { PerDegree: 1000, PerTurn: 360000 }, + }); else this.err(spec, `unsupported host import: ${imported}`); } else if (/\/host\/screen(\.ts)?$/.test(from)) { if (imported !== "SCREEN") this.err(spec, `unsupported host import: ${imported}`); @@ -370,6 +421,24 @@ 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.hostOnAxisDelta + ) { + const axisNode = call.arguments[0]; + const handler = call.arguments[1]; + if (!axisNode) this.err(call, "onAxisDelta requires a relative axis"); + const axis = this.constNum(axisNode); + if (axis === null || axis < 0 || axis >= RELATIVE_AXIS_NAMES.length) + this.err(axisNode, "onAxisDelta axis must be a compile-time RelativeAxis constant"); + if (!handler || !ts.isArrowFunction(handler)) + this.err(call, "onAxisDelta takes an axis and an arrow"); + if (handler.parameters.length !== 1) + this.err(handler, "onAxisDelta arrow needs exactly one delta parameter"); + if (this.axisHandlers.has(axis)) + this.err(axisNode, `only one onAxisDelta handler is supported for ${RELATIVE_AXIS_NAMES[axis]}`); + this.axisHandlers.set(axis, handler); } else this.err(stmt, "unsupported setup statement"); } else if (ts.isReturnStatement(stmt)) { if (!stmt.expression) this.err(stmt, "component must return JSX"); @@ -850,7 +919,7 @@ class AppCompiler { e = this.unparen(e); if (ts.isConditionalExpression(e)) { const cond = this.compileExpr(e.condition, out, ind); - out.push(`${ind}if (${this.truthy(cond)}) {`); + out.push(`${ind}if (${this.condition(cond)}) {`); this.compileViewInto(e.whenTrue, target, out, ind + " "); out.push(`${ind}} else {`); this.compileViewInto(e.whenFalse, target, out, ind + " "); @@ -877,7 +946,7 @@ class AppCompiler { this.scope.set(param, { kind: "local", cName: p, ty: { k: "obj", iface, listRef } }); const pred = this.compileExpr(arrow.body, out, ind + " "); this.scope = saved; - out.push(`${ind} if (${this.truthy(pred)}) ${target}.idx[${target}.len++] = ${src.at(i)};`); + out.push(`${ind} if (${this.condition(pred)}) ${target}.idx[${target}.len++] = ${src.at(i)};`); out.push(`${ind} }`); out.push(`${ind}}`); return; @@ -944,6 +1013,14 @@ class AppCompiler { return v.c; } + /** C control-flow syntax already supplies the outer parentheses. */ + private condition(v: { c: string; ty: Ty }): string { + const c = this.truthy(v); + return v.ty.k === "bool" && c.startsWith("(") && c.endsWith(")") + ? c.slice(1, -1) + : c; + } + // ---- scalar expression compilation --------------------------------------- private compileExpr(e: ts.Expression, out: string[], ind: string): { c: string; ty: Ty } { @@ -1091,7 +1168,8 @@ class AppCompiler { private compileCall(e: ts.CallExpression, out: string[], ind: string): { c: string; ty: Ty } { const callee = this.unparen(e.expression); - // Math.min/max + // 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 ( ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && @@ -1100,6 +1178,10 @@ class AppCompiler { const args = e.arguments.map((a) => this.compileExpr(a, out, ind).c); if (callee.name.text === "max") return { c: `vp_max(${args.join(", ")})`, ty: NUM }; if (callee.name.text === "min") return { c: `vp_min(${args.join(", ")})`, ty: NUM }; + if (callee.name.text === "trunc") { + if (args.length !== 1) this.err(e, "Math.trunc takes exactly one argument"); + return { c: `(${args[0]})`, ty: NUM }; + } this.err(e, `unsupported Math.${callee.name.text}`); } // list.indexOf(ptr) @@ -1202,7 +1284,7 @@ class AppCompiler { } if (ts.isIfStatement(stmt)) { const cond = this.compileExpr(stmt.expression, out, ind); - out.push(`${ind}if (${this.truthy(cond)}) {`); + out.push(`${ind}if (${this.condition(cond)}) {`); this.compileStmt(stmt.thenStatement, out, ind + " "); if (stmt.elseStatement) { out.push(`${ind}} else {`); @@ -1248,7 +1330,7 @@ class AppCompiler { const cond = stmt.condition ? this.compileExpr(stmt.condition, out, ind) : { c: "1", ty: BOOL }; const incr = stmt.incrementor ? this.compileIncrement(stmt.incrementor) : ""; out.push(`${ind}{ s32 ${cName};`); - out.push(`${ind}for (${cName} = ${init.c}; ${this.truthy(cond)}; ${incr}) {`); + out.push(`${ind}for (${cName} = ${init.c}; ${this.condition(cond)}; ${incr}) {`); this.compileStmt(stmt.statement, out, ind + " "); out.push(`${ind}} }`); this.scope = saved; @@ -1760,7 +1842,7 @@ class AppCompiler { const prevCtx = this.propsCtx; this.propsCtx = ctx ?? prevCtx; y = this.rowConstY(src); - out.push(` if (${this.truthy(cond)}) {`); + out.push(` if (${this.condition(cond)}) {`); this.compileRowPaint(src, String(y), out, " "); out.push(` }`); this.propsCtx = prevCtx; @@ -1868,6 +1950,32 @@ class AppCompiler { this.scope = saved; } + const axisHandlerFns: string[] = []; + const axisHandlerCases: string[] = []; + for (const [axis, handler] of [...this.axisHandlers].sort(([a], [b]) => a - b)) { + const saved = new Map(this.scope); + const parameter = handler.parameters[0]; + if (!parameter || !ts.isIdentifier(parameter.name)) + this.err(handler, "onAxisDelta arrow needs a simple delta parameter"); + this.scope.set(parameter.name.text, { + kind: "local", + cName: "axis_delta_arg", + ty: NUM, + }); + const { decls, body } = this.withHoist((out) => { + if (ts.isBlock(handler.body)) { + for (const stmt of handler.body.statements) this.compileStmt(stmt, out, " "); + } else { + this.compileExprStmt(handler.body, out, " "); + } + }); + axisHandlerFns.push( + `static void vp_axis_handler_${axis}(s32 axis_delta_arg) {\n${[...decls, ...body].join("\n")}\n}`, + ); + axisHandlerCases.push(` case ${axis}: vp_axis_handler_${axis}(delta); break;`); + this.scope = saved; + } + const { inits, effects } = this.emitTemplate(); // seed + init @@ -1948,12 +2056,17 @@ class AppCompiler { c.push(`#define VP_VIEW_CAP ${this.target.poolCap}`); c.push('#include "vapor.h"'); c.push(""); - c.push("static inline s32 vp_max(s32 a, s32 b) { return a > b ? a : b; }"); - c.push("static inline s32 vp_min(s32 a, s32 b) { return a < b ? a : b; }"); + c.push("#if defined(__GNUC__)"); + c.push("#define VP_UNUSED_FN __attribute__((unused))"); + c.push("#else"); + c.push("#define VP_UNUSED_FN"); + c.push("#endif"); + c.push("static inline s32 VP_UNUSED_FN vp_max(s32 a, s32 b) { return a > b ? a : b; }"); + c.push("static inline s32 VP_UNUSED_FN vp_min(s32 a, s32 b) { return a < b ? a : b; }"); c.push( - "static inline const char *vp_cstr_at(const char *const *arr, s32 n, s32 i) { return (i >= 0 && i < n) ? arr[i] : (const char *)\"\"; }", + "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_char_at(const char *s, s32 n, s32 i) { return (i >= 0 && i < n) ? s[i] : ' '; }"); + 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(""); // record structs @@ -1993,6 +2106,18 @@ class AppCompiler { // handler c.push(`void app_on_button(u8 b) {\n s32 b_arg = (s32)b;\n${handlerOut.join("\n")}\n}\n`); + c.push(axisHandlerFns.join("\n\n")); + if (axisHandlerCases.length > 0) { + c.push( + `void app_on_axis_delta(u8 axis, s32 delta) {\n switch (axis) {\n${axisHandlerCases.join( + "\n", + )}\n default: break;\n }\n}\n`, + ); + } else { + c.push( + "void app_on_axis_delta(u8 axis, s32 delta) {\n (void)axis;\n (void)delta;\n}\n", + ); + } // flush const flush: string[] = []; @@ -2055,6 +2180,23 @@ class AppCompiler { .join(", ")}}`, ), ); + graphLines.push("inputs:"); + graphLines.push( + ` buttons: ${ + [...this.buttonsUsed] + .sort((a, b) => a - b) + .map((button) => BUTTON_NAMES[button] ?? String(button)) + .join(", ") || "none" + }`, + ); + graphLines.push( + ` relative axes: ${ + [...this.axisHandlers.keys()] + .sort((a, b) => a - b) + .map((axis) => RELATIVE_AXIS_NAMES[axis] ?? String(axis)) + .join(", ") || "none" + }`, + ); const pools = this.refs.filter((r) => r.refTy === "list"); const poolBytes = pools.reduce((acc, p) => { @@ -2070,7 +2212,8 @@ class AppCompiler { const romStrings = [...this.strLits.keys()].reduce((a, s) => a + s.length + 1, 0) + this.title.length + 1; const pairCount = this.styleTable.pairs.length; - const fontBytes = this.target.name === "esp32" ? 95 * 8 : 95 * 32; + const fontBytes = + this.target.name === "esp32" || this.target.name === "playdate" ? 95 * 8 : 95 * 32; const styleBytes = this.target.name === "gba" ? pairCount * 16 * 2 + pairCount + 3 @@ -2092,6 +2235,7 @@ class AppCompiler { styles: this.styleTable, diagnostics: this.styleWarnings, buttonsUsed: [...this.buttonsUsed].sort((a, b) => a - b), + relativeAxesUsed: [...this.axisHandlers.keys()].sort((a, b) => a - b), }; } } @@ -2119,8 +2263,8 @@ function emitFontGba(): string { return `const u8 vp_font_tiles[] = { ${bytes.join(",")} };`; } -/** ESP32: one byte per 8-pixel row, MSB = leftmost pixel. */ -function emitFontEsp32(): string { +/** Direct 1bpp targets: one byte per 8-pixel row, MSB = leftmost pixel. */ +function emitFont1bpp(): string { const bytes: number[] = []; for (let g = 0; g < 95; g++) bytes.push(...FONT8[g]); return `const u8 vp_font_tiles[] = { ${bytes.join(",")} };`; @@ -2206,7 +2350,7 @@ function emitTargetData(target: VaporTarget, styles: StyleTable): string { const ink = styles.pairs.map((pair) => rgb565(pair.ink)); const paper = styles.pairs.map((pair) => rgb565(pair.paper)); return ( - `${emitFontEsp32()}\n` + + `${emitFont1bpp()}\n` + `const u16 vp_ink565[] = { ${ink.join(",")} };\n` + `const u16 vp_paper565[] = { ${paper.join(",")} };\n` + `const u16 vp_backdrop = ${rgb565(BACKDROP)};\n` + @@ -2218,5 +2362,11 @@ function emitTargetData(target: VaporTarget, styles: StyleTable): string { case "nes": /* NES font ships as CHR-ROM (rom.ts); only the style map is C data. */ return styleTable; + case "playdate": + return ( + `${emitFont1bpp()}\n` + + `const u8 vp_palette_count = ${styles.pairs.length};\n` + + styleTable + ); } } diff --git a/vapor/compiler/playdate.ts b/vapor/compiler/playdate.ts new file mode 100644 index 00000000..4633635c --- /dev/null +++ b/vapor/compiler/playdate.ts @@ -0,0 +1,276 @@ +// Build a generated Pocket Vapor application as native Playdate packages. +// +// pd-wasm4 proved the SDK boundary used here: Simulator is a host shared +// library, device is an ARM executable staged as pdex.elf, and pdc packages +// each one independently. WAMR/Lua/cart loading are deliberately absent. + +import { existsSync, readFileSync } from "node:fs"; +import { copyFile, mkdir, readdir, rm } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import type { CompiledApp } from "./compile.ts"; + +const RUNTIME = resolve(import.meta.dir, "..", "runtime"); +const PLAYDATE_RUNTIME = join(RUNTIME, "playdate"); + +export type PlaydateBuildMode = "simulator" | "device" | "both"; +export type PlaydatePlatform = Exclude; + +export interface PlaydateSdkEnvironment { + path: string; + version: string; + pdc: string; + armToolchain: string; +} + +export interface PlaydateArtifact { + path: string; + kind: "pdx"; + platform: PlaydatePlatform; + bytes: number; + buildId: string; + projectDir: string; + buildDir: string; +} + +function requirePath(path: string, description: string): void { + if (!existsSync(path)) throw new Error(`${description} not found: ${path}`); +} + +function configuredSdkRoot(configPath: string): string | undefined { + if (!existsSync(configPath)) return undefined; + const config = readFileSync(configPath, "utf8"); + for (const line of config.split(/\r?\n/)) { + const match = /^\s*SDKRoot(?:\t+|\s{2,})(.+?)\s*$/.exec(line); + if (match) return match[1]; + } + return undefined; +} + +/** + * Resolve one observable SDK. An explicit invalid PLAYDATE_SDK_PATH is an + * error and never falls through to a different local installation. + */ +export function resolvePlaydateSdk( + env: Readonly> = process.env, + home = homedir(), +): PlaydateSdkEnvironment { + const explicit = env.PLAYDATE_SDK_PATH?.trim(); + const configPath = join(home, ".Playdate", "config"); + const configured = explicit ? undefined : configuredSdkRoot(configPath); + const sdkPath = explicit ?? configured; + if (!sdkPath) { + throw new Error( + `Playdate SDK path not found: set PLAYDATE_SDK_PATH or add SDKRoot to ${configPath}`, + ); + } + + const path = resolve(sdkPath); + requirePath(path, explicit ? "PLAYDATE_SDK_PATH" : "configured Playdate SDK"); + requirePath(join(path, "C_API", "pd_api.h"), "Playdate C API header"); + requirePath( + join(path, "C_API", "buildsupport", "playdate.cmake"), + "Playdate CMake support", + ); + const armToolchain = join(path, "C_API", "buildsupport", "arm.cmake"); + requirePath(armToolchain, "Playdate ARM toolchain"); + + const versionPath = join(path, "VERSION.txt"); + requirePath(versionPath, "Playdate SDK version receipt"); + const version = readFileSync(versionPath, "utf8").trim(); + if (!version) throw new Error(`Playdate SDK version receipt is empty: ${versionPath}`); + + const pdc = [join(path, "bin", "pdc"), join(path, "bin", "pdc.exe")].find(existsSync); + if (!pdc) throw new Error(`Playdate pdc not found under ${join(path, "bin")}`); + return { path, version, pdc, armToolchain }; +} + +function simulatorExtension(platform = process.platform): "dylib" | "so" | "dll" { + if (platform === "darwin") return "dylib"; + if (platform === "win32") return "dll"; + return "so"; +} + +function debugStateBytes(app: CompiledApp): number { + const end = Math.max(1, ...app.debugSlots.map((slot) => slot.offset + slot.size)); + return (end + 3) & ~3; +} + +/** Stable identity of generated C, runtime, build template, metadata and SDK. */ +export async function playdateBuildId( + app: CompiledApp, + sdkVersion: string, +): Promise { + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(`playdate-sdk=${sdkVersion}\ntitle=${app.title}\n${app.c}\n`); + hasher.update(await Bun.file(import.meta.path).arrayBuffer()); + for (const path of [ + join(RUNTIME, "vapor.h"), + join(RUNTIME, "vapor_core.c"), + join(PLAYDATE_RUNTIME, "framebuffer.h"), + join(PLAYDATE_RUNTIME, "framebuffer.c"), + join(PLAYDATE_RUNTIME, "vapor_playdate.c"), + join(PLAYDATE_RUNTIME, "CMakeLists.txt"), + join(PLAYDATE_RUNTIME, "pdxinfo.in"), + ]) { + hasher.update(await Bun.file(path).arrayBuffer()); + } + return hasher.digest("hex").slice(0, 16); +} + +function packageName(title: string): string { + const name = title.replace(/[\r\n=]/g, " ").trim(); + return name || "POCKET VAPOR"; +} + +function bundleSlug(title: string): string { + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 40); + return slug || "app"; +} + +async function writePdxInfo(stageDir: string, app: CompiledApp): Promise { + const template = await Bun.file(join(PLAYDATE_RUNTIME, "pdxinfo.in")).text(); + const content = template + .replaceAll("@NAME@", packageName(app.title)) + .replaceAll("@BUNDLE_ID@", `dev.pocketjs.vapor.${bundleSlug(app.title)}`); + await Bun.write(join(stageDir, "pdxinfo"), content); +} + +function commandText(args: readonly string[]): string { + return args.map((arg) => (/\s/.test(arg) ? JSON.stringify(arg) : arg)).join(" "); +} + +async function run(args: string[], cwd?: string): Promise { + console.log(`[playdate] ${commandText(args)}`); + const child = Bun.spawn(args, { + cwd, + env: process.env, + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await child.exited; + if (exitCode !== 0) { + throw new Error(`Playdate command failed (${exitCode}): ${commandText(args)}`); + } +} + +async function directoryBytes(path: string): Promise { + let bytes = 0; + for (const entry of await readdir(path, { withFileTypes: true })) { + const child = join(path, entry.name); + if (entry.isDirectory()) bytes += await directoryBytes(child); + else if (entry.isFile()) bytes += Bun.file(child).size; + } + return bytes; +} + +async function requireNonEmptyFile(path: string, description: string): Promise { + const file = Bun.file(path); + if (!(await file.exists())) throw new Error(`${description} not found: ${path}`); + if (file.size === 0) throw new Error(`${description} is empty: ${path}`); +} + +async function validatePackage( + path: string, + platform: PlaydatePlatform, +): Promise { + requirePath(path, `${platform} Playdate package`); + await requireNonEmptyFile(join(path, "pdxinfo"), `${platform} package metadata`); + if (platform === "device") { + await requireNonEmptyFile(join(path, "pdex.bin"), "Playdate device binary"); + } else { + await requireNonEmptyFile( + join(path, `pdex.${simulatorExtension()}`), + "Playdate Simulator library", + ); + } + const bytes = await directoryBytes(path); + if (bytes === 0) throw new Error(`${platform} Playdate package is empty: ${path}`); + return bytes; +} + +async function buildOne( + app: CompiledApp, + outputBase: string, + platform: PlaydatePlatform, + sdk: PlaydateSdkEnvironment, + buildId: string, +): Promise { + const outputDir = dirname(outputBase); + const name = basename(outputBase).replace(/\.pdx$/, ""); + const projectDir = join(outputDir, `gen-playdate-${name}`); + const buildDir = join(projectDir, `build-${platform}`); + const stageDir = join(projectDir, `stage-${platform}`); + const output = `${outputBase}.playdate-${platform}.pdx`; + + await mkdir(projectDir, { recursive: true }); + await rm(stageDir, { recursive: true, force: true }); + await mkdir(stageDir, { recursive: true }); + await Bun.write(join(projectDir, "gen_app.c"), app.c); + await copyFile( + join(PLAYDATE_RUNTIME, "CMakeLists.txt"), + join(projectDir, "CMakeLists.txt"), + ); + await writePdxInfo(stageDir, app); + + const cmake = Bun.which("cmake"); + if (!cmake) throw new Error("cmake not found in PATH"); + const configure = [ + cmake, + "-S", + projectDir, + "-B", + buildDir, + `-DSDK=${sdk.path}`, + `-DVP_RUNTIME_DIR=${RUNTIME}`, + `-DVP_GEN_APP=${join(projectDir, "gen_app.c")}`, + `-DVP_STAGE_DIR=${stageDir}`, + `-DVP_BUILD_ID=${buildId}`, + `-DVP_DEBUG_STATE_BYTES=${debugStateBytes(app)}`, + "-DCMAKE_BUILD_TYPE=Release", + ]; + if (platform === "device") { + configure.push(`-DCMAKE_TOOLCHAIN_FILE=${sdk.armToolchain}`); + } + + await run(configure); + await run([cmake, "--build", buildDir, "--config", "Release"]); + + if (platform === "device") { + await requireNonEmptyFile(join(stageDir, "pdex.elf"), "staged Playdate device ELF"); + } else { + await requireNonEmptyFile( + join(stageDir, `pdex.${simulatorExtension()}`), + "staged Playdate Simulator library", + ); + } + + await rm(output, { recursive: true, force: true }); + await run([sdk.pdc, "-sdkpath", sdk.path, stageDir, output]); + const bytes = await validatePackage(output, platform); + return { path: output, kind: "pdx", platform, bytes, buildId, projectDir, buildDir }; +} + +export async function buildPlaydatePackages( + app: CompiledApp, + outputBase: string, + mode: PlaydateBuildMode = "simulator", + sdk = resolvePlaydateSdk(), +): Promise { + const base = resolve(outputBase).replace(/\.pdx$/, ""); + const buildId = await playdateBuildId(app, sdk.version); + console.log(`[playdate] SDK ${sdk.version}: ${sdk.path}`); + console.log(`[playdate] build ${buildId}, mode=${mode}`); + + const platforms: PlaydatePlatform[] = + mode === "both" ? ["simulator", "device"] : [mode]; + const artifacts: PlaydateArtifact[] = []; + for (const platform of platforms) { + artifacts.push(await buildOne(app, base, platform, sdk, buildId)); + } + return artifacts; +} diff --git a/vapor/compiler/rom.ts b/vapor/compiler/rom.ts index 53bad6f1..0aeca0a0 100644 --- a/vapor/compiler/rom.ts +++ b/vapor/compiler/rom.ts @@ -1,15 +1,20 @@ // vapor/compiler/rom.ts — drive the console toolchains and patch cart headers. -// Four targets, four toolchains, one generated C file: +// ROM/firmware targets plus Playdate packages, one generated C file: // GBA: arm-none-eabi-gcc, flat ROM, Nintendo-logo/checksum patch // GB: sdcc (SM83) + sdasgb + makebin + rgbfix, ROM-only cart // NES: cc65/ca65/ld65, NROM-256 + CHR-ROM font, generated ld65 config // ESP32: ESP-IDF, retained build project + app image for offset 0x10000 +// Playdate: SDK CMake, independent Simulator/device .pdx packages // Toolchain recipes carry over from Pocket Static's target packagers. import { $ } from "bun"; import { dirname, join } from "node:path"; import { nesFontBytes, VAPOR_TARGETS, type CompiledApp, type VaporTargetName } from "./compile.ts"; import { buildEsp32Firmware } from "./esp32.ts"; +import { + buildPlaydatePackages, + type PlaydateBuildMode, +} from "./playdate.ts"; const RUNTIME = join(import.meta.dir, "..", "runtime"); const CC65_LIB = "/opt/homebrew/share/cc65/lib/none.lib"; @@ -40,19 +45,74 @@ function targetDefines(target: VaporTargetName): string[] { ]; } -export async function buildRom( +export interface BuiltArtifact { + path: string; + kind: "rom" | "firmware" | "pdx"; + bytes: number; + platform?: "simulator" | "device"; +} + +export interface BuildRomOptions { + playdateMode?: PlaydateBuildMode; +} + +type SingleArtifactTarget = Exclude; + +async function buildSingleArtifact( app: CompiledApp, - target: VaporTargetName, - outRom: string, -): Promise<{ romBytes: number }> { - if (target === "gba") return buildGbaRom(app, outRom); - if (target === "gb") return buildGbRom(app, outRom); - if (target === "nes") return buildNesRom(app, outRom); - if (target === "esp32") return buildEsp32Firmware(app, outRom); + target: SingleArtifactTarget, + outPath: string, +): Promise { + if (target === "gba") { + const { romBytes } = await buildGbaRom(app, outPath); + return { path: outPath, kind: "rom", bytes: romBytes }; + } + if (target === "gb") { + const { romBytes } = await buildGbRom(app, outPath); + return { path: outPath, kind: "rom", bytes: romBytes }; + } + if (target === "nes") { + const { romBytes } = await buildNesRom(app, outPath); + return { path: outPath, kind: "rom", bytes: romBytes }; + } + if (target === "esp32") { + const { romBytes } = await buildEsp32Firmware(app, outPath); + return { path: outPath, kind: "firmware", bytes: romBytes }; + } target satisfies never; throw new Error(`unsupported Pocket Vapor target: ${String(target)}`); } +/** + * Build the distributable artifact set for one Vapor target. + * + * `buildRom` is the stable public entry-point name. Its result is deliberately + * plural: console targets and ESP32 return one artifact, while Playdate + * `both` returns independent Simulator and device packages because a native + * .pdx cannot contain both executable flavors. + */ +export async function buildRom( + app: CompiledApp, + target: VaporTargetName, + outPath: string, + options: BuildRomOptions = {}, +): Promise { + if (target === "playdate") { + const packages = await buildPlaydatePackages( + app, + outPath, + options.playdateMode ?? "simulator", + ); + return packages.map(({ path, kind, platform, bytes }) => ({ + path, + kind, + platform, + bytes, + })); + } + return [await buildSingleArtifact(app, target, outPath)]; +} + // ---- GBA ------------------------------------------------------------------- export async function buildGbaRom(app: CompiledApp, outRom: string): Promise<{ romBytes: number }> { diff --git a/vapor/compiler/styles.ts b/vapor/compiler/styles.ts index a6fa14b5..1fb27ad4 100644 --- a/vapor/compiler/styles.ts +++ b/vapor/compiler/styles.ts @@ -17,7 +17,8 @@ // gba "rgb555" pair id = BG palette bank (ink/paper BGR555), <= 15 pairs // esp32 "rgb565" pair id = direct ink/paper RGB565 table index // gb "styles2" pair id -> glyph style via luminance (dark-on-light / -// nes light-on-dark); collapsing distinct pairs is a warning, +// nes +// playdate light-on-dark); collapsing distinct pairs is a warning, // or an error under --strict // // The oracle (real Vue in a browser/bun) renders the same classes with the @@ -113,6 +114,7 @@ export const STYLE_CAPS: Record = { esp32: { kind: "rgb565", maxPairs: 256 }, gb: { kind: "styles2" }, nes: { kind: "styles2" }, + playdate: { kind: "styles2" }, web: { kind: "web" }, }; @@ -251,10 +253,15 @@ export function styleTableCss(table: StyleTable, target: string): string { ink = q(ink); paper = q(paper); } else if (caps.kind === "styles2") { - // DMG-flavored two-style preview + // Preserve each target's actual two-color appearance in the preview. const s = styleOfPair(pair); - ink = s === 0 ? 0x0f380f : 0x9bbc0f; - paper = s === 0 ? 0x9bbc0f : 0x0f380f; + if (target === "playdate") { + ink = s === 0 ? 0x000000 : 0xffffff; + paper = s === 0 ? 0xffffff : 0x000000; + } else { + ink = s === 0 ? 0x0f380f : 0x9bbc0f; + paper = s === 0 ? 0x9bbc0f : 0x0f380f; + } } lines.push(`row[data-pal="${id}"] { color: ${css(ink)}; background: ${css(paper)}; }`); }); diff --git a/vapor/examples/playdate-six-button/playdate-six-button.tsx b/vapor/examples/playdate-six-button/playdate-six-button.tsx new file mode 100644 index 00000000..9385f5bb --- /dev/null +++ b/vapor/examples/playdate-six-button/playdate-six-button.tsx @@ -0,0 +1,32 @@ +import { computed, ref } from "vue"; +import { Button, onButton } from "../../host/input.ts"; +import { SCREEN } from "../../host/screen.ts"; + +export default () => { + const x = ref(0); + const y = ref(0); + const value = ref(0); + const position = computed(() => x.value + y.value); + + onButton((button) => { + if (button === Button.A) value.value = value.value + 1; + else if (button === Button.B) value.value = value.value - 1; + else if (button === Button.Right) x.value = x.value + 1; + else if (button === Button.Left) x.value = x.value - 1; + else if (button === Button.Up) y.value = y.value - 1; + else if (button === Button.Down) y.value = y.value + 1; + }); + + return ( + <> + + {SCREEN.width === 50 ? "POCKET VAPOR PLAYDATE" : "POCKET VAPOR SIX BUTTON"} + + {"DPAD X "}{x.value}{" Y "}{y.value} + {"A/B VALUE "}{value.value} + {"X+Y "}{position.value} + {"A +1 B -1"} + {"DPAD MOVES X/Y"} + + ); +}; diff --git a/vapor/examples/todo/todo.playdate.tsx b/vapor/examples/todo/todo.playdate.tsx new file mode 100644 index 00000000..546501b5 --- /dev/null +++ b/vapor/examples/todo/todo.playdate.tsx @@ -0,0 +1,228 @@ +// PLAYDATE VAPOR TODO — the native Playdate input variant. +// +// The application remains hardware-neutral at the event boundary: list +// movement consumes RelativeAxis.Primary signed millidegrees. The Playdate +// runtime preserves physical crank motion; this app, not the host, chooses a +// 45-degree list detent. A future ESP32 encoder host can provide the same +// capability without changing this business logic. +// +// Controls — list mode: crank cursor, A toggle done, B delete, Right cycle +// filter, Up new todo, Down clear completed. Edit mode: Left/Right scrub +// glyph, A put glyph, B backspace, Up save, Down cancel. + +import { computed, ref } from "vue"; +import { + Button, + onAxisDelta, + onButton, + RelativeAxis, + RelativeAxisUnits, +} from "../../host/input.ts"; +import { SCREEN } from "../../host/screen.ts"; + +interface Todo { + text: string; + done: boolean; +} + +type Keymap = Record void>; + +const FILTERS = ["ALL", "ACTIVE", "DONE"]; +const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789"; +const LIST_Y = 3; +const WINDOW = SCREEN.height - 8; +const EDIT_Y = SCREEN.height - 3; +const HELP_Y = SCREEN.height - 1; +const TEXT_MAX = 20; +const LIST_CRANK_DEGREES = 45; +const LIST_CRANK_THRESHOLD = + LIST_CRANK_DEGREES * RelativeAxisUnits.PerDegree; + +function TitleBar(props: { line: number; text: string }) { + return ( + + {props.text} + + ); +} + +function StatusBar(props: { line: number; count: number; label: string }) { + return ( + + {props.count} + {" LEFT / "} + {props.label} + + ); +} + +function TodoRow(props: { line: number; todo: Todo; selected: boolean }) { + return ( + + {props.selected ? ">" : " "} + {"["} + {props.todo.done ? "X" : " "} + {"] "} + {props.todo.text} + + ); +} + +function Notice(props: { line: number; text: string }) { + return ( + + {props.text} + + ); +} + +function EditorBar(props: { line: number; draft: string; glyph: string }) { + return ( + + {"NEW: "} + {props.draft} + {"["} + {props.glyph} + {"]"} + + ); +} + +function HelpBar(props: { line: number; text: string }) { + return ( + + {props.text} + + ); +} + +export default () => { + const todos = ref([ + { text: "SHIP POCKET VAPOR", done: false }, + { text: "WRITE THE COMPILER", done: true }, + { text: "RUN ON PLAYDATE", done: false }, + ]); + const cursor = ref(0); + const filter = ref(0); + const editing = ref(false); + const draft = ref(""); + const glyph = ref(0); + const crankRemainder = ref(0); + + const filtered = computed(() => + filter.value === 0 + ? todos.value + : filter.value === 1 + ? todos.value.filter((t) => !t.done) + : todos.value.filter((t) => t.done), + ); + const remaining = computed(() => todos.value.filter((t) => !t.done).length); + const current = computed(() => filtered.value[cursor.value]); + const scroll = computed(() => + Math.max(0, Math.min(cursor.value - WINDOW + 1, filtered.value.length - WINDOW)), + ); + const visible = computed(() => filtered.value.slice(scroll.value, scroll.value + WINDOW)); + + function moveCursor(d: number) { + cursor.value = Math.max(0, Math.min(cursor.value + d, filtered.value.length - 1)); + } + function scrubGlyph(d: number) { + glyph.value = (glyph.value + d + GLYPHS.length) % GLYPHS.length; + } + function toggleDone() { + const t = current.value; + if (t) t.done = !t.done; + moveCursor(0); + } + function deleteCurrent() { + const t = current.value; + if (t) todos.value = todos.value.filter((x) => x !== t); + moveCursor(0); + } + function clearDone() { + todos.value = todos.value.filter((t) => !t.done); + moveCursor(0); + } + function cycleFilter() { + filter.value = (filter.value + 1) % FILTERS.length; + moveCursor(0); + } + function openEditor() { + crankRemainder.value = 0; + editing.value = true; + glyph.value = 0; + } + function closeEditor() { + crankRemainder.value = 0; + draft.value = ""; + editing.value = false; + } + function putGlyph() { + if (draft.value.length < TEXT_MAX) draft.value += GLYPHS[glyph.value]; + } + function saveDraft() { + if (draft.value.length > 0) { + todos.value.push({ text: draft.value, done: false }); + closeEditor(); + } + } + + const listKeys: Keymap = { + [Button.A]: toggleDone, + [Button.B]: deleteCurrent, + [Button.Right]: cycleFilter, + [Button.Up]: openEditor, + [Button.Down]: clearDone, + }; + + const editKeys: Keymap = { + [Button.Left]: () => scrubGlyph(-1), + [Button.Right]: () => scrubGlyph(1), + [Button.A]: putGlyph, + [Button.B]: () => { + draft.value = draft.value.slice(0, -1); + }, + [Button.Up]: saveDraft, + [Button.Down]: closeEditor, + }; + + onButton((button) => (editing.value ? editKeys : listKeys)[button]?.()); + onAxisDelta(RelativeAxis.Primary, (delta) => { + if (!editing.value) { + crankRemainder.value += delta; + const steps = Math.trunc( + crankRemainder.value / LIST_CRANK_THRESHOLD, + ); + if (steps !== 0) { + crankRemainder.value %= LIST_CRANK_THRESHOLD; + moveCursor(steps); + } + } + }); + + return ( + <> + + + {visible.value.map((todo, i) => ( + + ))} + {filtered.value.length === 0 ? : null} + {editing.value ? ( + + ) : null} + :GLYPH UP:SAVE DOWN:QUIT" + : "CRANK:MOVE A:DONE B:DEL >:FILT UP:NEW DOWN:CLEAR" + } + /> + + ); +}; diff --git a/vapor/host/input.ts b/vapor/host/input.ts index 0586a099..bc1b1414 100644 --- a/vapor/host/input.ts +++ b/vapor/host/input.ts @@ -3,11 +3,18 @@ // 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 imports of `onButton` and -// `Button` from this path and wires handlers to the GBA key-edge register. +// 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 GBA KEYINPUT bit positions (and mGBA's key-mask bit -// order): a press on device sets bit (1 << Button.X). +// Button values ARE the shared Pocket pad ABI. RelativeAxis values are the +// hardware-neutral ABI for incremental controls: a Playdate crank, rotary +// encoder, or wheel host converts physical motion into signed canonical +// units. Apps never depend on GPIO pulses or a specific device, and apps +// choose their own detents and sensitivity. +// +// Playdate directly exposes A/B/Right/Left/Up/Down plus RelativeAxis.Primary +// through its crank. It rejects unsupported demands at compile time; it +// never invents chords for missing physical inputs. export const Button = { A: 0, @@ -28,17 +35,67 @@ type ButtonHandler = (button: number) => void; const handlers: ButtonHandler[] = []; +export const RelativeAxis = { + Primary: 0, + Secondary: 1, +} as const; + +/** + * Canonical rotary-axis resolution. Hosts preserve signed motion in + * millidegrees; applications own detents, acceleration, and interaction + * thresholds. + */ +export const RelativeAxisUnits = { + PerDegree: 1_000, + PerTurn: 360_000, +} as const; + +export type RelativeAxisId = (typeof RelativeAxis)[keyof typeof RelativeAxis]; +type AxisDeltaHandler = (delta: number) => void; + +const axisHandlers = new Map(); + +function assertRelativeAxis(axis: number): asserts axis is RelativeAxisId { + if (axis !== RelativeAxis.Primary && axis !== RelativeAxis.Secondary) { + throw new Error(`unknown relative axis ${axis}`); + } +} + /** Register a handler called once per button press edge (per frame). */ export function onButton(handler: ButtonHandler): void { handlers.push(handler); } +/** + * Register a handler for signed, relative movement on one logical axis. + * + * `delta` is a non-zero integer in canonical axis units. Rotary adapters use + * millidegrees (`RelativeAxisUnits.PerDegree`) and preserve sub-unit motion + * between frames. Applications, not hosts, choose detents and sensitivity. + */ +export function onAxisDelta(axis: RelativeAxisId, handler: AxisDeltaHandler): void { + assertRelativeAxis(axis); + const registered = axisHandlers.get(axis); + if (registered) registered.push(handler); + else axisHandlers.set(axis, [handler]); +} + /** Oracle-only: deliver one button press edge to every registered handler. */ export function __dispatchButton(button: number): void { for (const handler of handlers) handler(button); } -/** Oracle-only: drop all registered handlers (fresh boot between tests). */ +/** Oracle-only: deliver one non-zero, integral relative-axis delta. */ +export function __dispatchAxisDelta(axis: RelativeAxisId, delta: number): void { + assertRelativeAxis(axis); + if (!Number.isInteger(delta) || delta === 0) { + throw new Error(`relative axis delta must be a non-zero integer, got ${delta}`); + } + for (const handler of axisHandlers.get(axis) ?? []) handler(delta); +} + +/** Oracle-only: drop all registered input handlers (fresh boot between tests). */ export function __resetButtons(): void { handlers.length = 0; + axisHandlers.clear(); } diff --git a/vapor/host/screen.ts b/vapor/host/screen.ts index 453e2289..f3077f08 100644 --- a/vapor/host/screen.ts +++ b/vapor/host/screen.ts @@ -2,7 +2,7 @@ // // One module, two lives. Under the compiler, `SCREEN.width`/`SCREEN.height` // are compile-time constants of the selected target (GBA 30x20, GB 20x18, -// NES 24x20) — layout math and width ternaries fold, dead branches drop out +// NES 22x18, Playdate 50x30) — layout math and width ternaries fold, dead branches drop out // of ROM. Under the oracle the values come from globals the test harness // sets before boot, so one bundle replays as any console. diff --git a/vapor/oracle/boot.ts b/vapor/oracle/boot.ts index 5f47d31e..062d8992 100644 --- a/vapor/oracle/boot.ts +++ b/vapor/oracle/boot.ts @@ -13,12 +13,13 @@ import { paintGrid, type CellGrid } from "./paint.ts"; const ENTRY = join(import.meta.dir, "entry.ts"); -let bundleText: string | null = null; +const bundleTexts = new Map(); -async function buildOracleBundle(): Promise { - if (bundleText) return bundleText; +async function buildOracleBundle(entry: string): Promise { + const cached = bundleTexts.get(entry); + if (cached) return cached; const result = await Bun.build({ - entrypoints: [ENTRY], + entrypoints: [entry], format: "iife", target: "browser", conditions: ["browser"], @@ -32,7 +33,8 @@ async function buildOracleBundle(): Promise { if (!result.success) { throw new Error(`oracle bundle failed:\n${result.logs.join("\n")}`); } - bundleText = await result.outputs[0].text(); + const bundleText = await result.outputs[0].text(); + bundleTexts.set(entry, bundleText); return bundleText; } @@ -40,6 +42,8 @@ export interface Oracle { root: VaporElement; /** Deliver one button edge and settle vapor's scheduler. */ press(button: number): Promise; + /** Deliver one relative-axis delta and settle vapor's scheduler. */ + axisDelta(axis: number, delta: number): Promise; /** Current rendered grid. */ grid(): CellGrid; unmount(): void; @@ -50,10 +54,12 @@ export interface OracleOptions { height?: number; /** compile-produced style table: class -> pair id/align for the painter */ styles?: StyleTable; + /** bundle entry installing the hooks; defaults to the todo entry */ + entry?: string; } export async function bootOracle(opts: OracleOptions = {}): Promise { - const bundle = await buildOracleBundle(); + const bundle = await buildOracleBundle(opts.entry ?? ENTRY); installOracleDom(); const g = globalThis as Record; g.__vaporScreenW = opts.width ?? 30; @@ -63,6 +69,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 axisDeltaHook = hooks.__vaporAxisDelta as (axis: number, delta: number) => void; const tick = hooks.__vaporTick as () => Promise; const root = createRootElement(); @@ -75,6 +82,10 @@ export async function bootOracle(opts: OracleOptions = {}): Promise { pressHook(button); await tick(); }, + async axisDelta(axis: number, delta: number) { + axisDeltaHook(axis, delta); + await tick(); + }, grid: () => paintGrid(root, opts.width ?? 30, opts.height ?? 20, opts.styles), unmount: () => app.unmount(), }; diff --git a/vapor/oracle/entry-playdate.ts b/vapor/oracle/entry-playdate.ts new file mode 100644 index 00000000..ffffc013 --- /dev/null +++ b/vapor/oracle/entry-playdate.ts @@ -0,0 +1,36 @@ +// vapor/oracle/entry-playdate.ts — oracle bundle entry for the Playdate Todo. +// +// Identical hook installation to entry.ts, mounting the crank-driven +// todo.playdate.tsx variant so the relative-axis path replays under the real +// Vue Vapor runtime exactly like buttons do. + +import { createVaporApp, nextTick } from "vue"; +import TodoApp from "../examples/todo/todo.playdate.tsx"; +import { + __dispatchAxisDelta, + __dispatchButton, + __resetButtons, +} from "../host/input.ts"; + +type AnyApp = { mount(container: unknown): void; unmount(): void }; + +const hooks = globalThis as Record; + +hooks.__vaporBoot = (container: unknown): AnyApp => { + __resetButtons(); + const app = (createVaporApp as unknown as (comp: unknown) => AnyApp)({ + setup: () => (TodoApp as () => unknown)(), + }); + app.mount(container); + return app; +}; + +hooks.__vaporPress = (button: number): void => { + __dispatchButton(button); +}; + +hooks.__vaporAxisDelta = (axis: number, delta: number): void => { + __dispatchAxisDelta(axis as 0 | 1, delta); +}; + +hooks.__vaporTick = (): Promise => nextTick(); diff --git a/vapor/oracle/entry.ts b/vapor/oracle/entry.ts index 5a1b9285..6076a58c 100644 --- a/vapor/oracle/entry.ts +++ b/vapor/oracle/entry.ts @@ -7,7 +7,11 @@ import { createVaporApp, nextTick } from "vue"; import TodoApp from "../examples/todo/todo.tsx"; -import { __dispatchButton, __resetButtons } from "../host/input.ts"; +import { + __dispatchAxisDelta, + __dispatchButton, + __resetButtons, +} from "../host/input.ts"; type AnyApp = { mount(container: unknown): void; unmount(): void }; @@ -26,4 +30,8 @@ hooks.__vaporPress = (button: number): void => { __dispatchButton(button); }; +hooks.__vaporAxisDelta = (axis: number, delta: number): void => { + __dispatchAxisDelta(axis as 0 | 1, delta); +}; + hooks.__vaporTick = (): Promise => nextTick(); diff --git a/vapor/runtime/playdate/CMakeLists.txt b/vapor/runtime/playdate/CMakeLists.txt new file mode 100644 index 00000000..6028e2a9 --- /dev/null +++ b/vapor/runtime/playdate/CMakeLists.txt @@ -0,0 +1,95 @@ +cmake_minimum_required(VERSION 3.19) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) + +foreach(REQUIRED SDK VP_RUNTIME_DIR VP_GEN_APP VP_STAGE_DIR VP_BUILD_ID VP_DEBUG_STATE_BYTES) + if(NOT DEFINED ${REQUIRED} OR "${${REQUIRED}}" STREQUAL "") + message(FATAL_ERROR "${REQUIRED} is required") + endif() +endforeach() + +if(NOT EXISTS "${SDK}/C_API/pd_api.h") + message(FATAL_ERROR "Playdate SDK headers not found under SDK=${SDK}") +endif() +if(NOT EXISTS "${VP_GEN_APP}") + message(FATAL_ERROR "generated application not found: ${VP_GEN_APP}") +endif() + +set(CMAKE_CONFIGURATION_TYPES "Debug;Release") +set(PLAYDATE_GAME_NAME pocket_vapor) +set(PLAYDATE_GAME_DEVICE pocket_vapor_device) + +project(${PLAYDATE_GAME_NAME} C ASM) + +set(VP_SOURCES + "${VP_RUNTIME_DIR}/vapor_core.c" + "${VP_RUNTIME_DIR}/playdate/framebuffer.c" + "${VP_RUNTIME_DIR}/playdate/vapor_playdate.c" + "${VP_GEN_APP}" +) + +if(TOOLCHAIN STREQUAL "armgcc") + add_executable(${PLAYDATE_GAME_DEVICE} ${VP_SOURCES}) + set(VP_TARGET ${PLAYDATE_GAME_DEVICE}) +else() + add_library(${PLAYDATE_GAME_NAME} SHARED ${VP_SOURCES}) + set(VP_TARGET ${PLAYDATE_GAME_NAME}) +endif() + +# playdate.cmake owns the SDK compiler/linker flags and device setup.c glue. +# Packaging stays in compiler/playdate.ts so Simulator and device artifacts +# are staged and validated independently. +include("${SDK}/C_API/buildsupport/playdate.cmake") + +target_include_directories(${VP_TARGET} PRIVATE + "${VP_RUNTIME_DIR}" + "${VP_RUNTIME_DIR}/playdate" + "${SDK}/C_API" +) +target_compile_definitions(${VP_TARGET} PRIVATE + VP_GRID_W=50 + VP_GRID_H=30 + VP_STR_CAP=24 + VP_VIEW_CAP=32 + VP_DEBUG_STATE_BYTES=${VP_DEBUG_STATE_BYTES} + VP_BUILD_ID=\"${VP_BUILD_ID}\" +) + +if(NOT MSVC) + target_compile_options(${VP_TARGET} PRIVATE + -Werror=implicit-function-declaration + -Werror=return-type + ) +endif() + +if(TOOLCHAIN STREQUAL "armgcc") + set_property(TARGET ${VP_TARGET} PROPERTY OUTPUT_NAME "pocket_vapor_device.elf") + add_custom_command( + TARGET ${VP_TARGET} + POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "${VP_STAGE_DIR}" + COMMAND ${CMAKE_STRIP} --strip-unneeded -R .comment -g + "$" + -o "${VP_STAGE_DIR}/pdex.elf" + VERBATIM + ) +else() + if(MSVC OR MINGW) + set(VP_PDEX_EXT dll) + elseif(APPLE) + set(VP_PDEX_EXT dylib) + elseif(UNIX) + set(VP_PDEX_EXT so) + else() + message(FATAL_ERROR "Unsupported Playdate Simulator build platform") + endif() + add_custom_command( + TARGET ${VP_TARGET} + POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "${VP_STAGE_DIR}" + COMMAND ${CMAKE_COMMAND} -E copy + "$" + "${VP_STAGE_DIR}/pdex.${VP_PDEX_EXT}" + VERBATIM + ) +endif() diff --git a/vapor/runtime/playdate/README.md b/vapor/runtime/playdate/README.md new file mode 100644 index 00000000..fdb8eeba --- /dev/null +++ b/vapor/runtime/playdate/README.md @@ -0,0 +1,94 @@ +# Pocket Vapor Playdate runtime + +This directory is the native Playdate hardware boundary for Pocket Vapor. +It links generated `gen_app.c` and the shared `vapor_core.c` directly into a +Playdate C application. No JavaScript engine, interpreter, GC, or PocketJS +guest host is involved. + +## Prerequisites + +- Playdate SDK, resolved from an explicit `PLAYDATE_SDK_PATH` or the first + `SDKRoot` entry in `~/.Playdate/config` +- CMake +- a platform C compiler for Simulator builds +- `arm-none-eabi-gcc` for device builds + +Build through the compiler so SDK resolution, build identity, staging, and +artifact validation stay observable: + +```sh +bun vapor/compiler/cli.ts vapor/examples/todo/todo.playdate.tsx \ + --target playdate --playdate-mode simulator + +bun vapor/compiler/cli.ts vapor/examples/todo/todo.playdate.tsx \ + --target playdate --playdate-mode device +``` + +`both` produces two independent packages. Playdate loads either a native +Simulator library or a device binary from a package, so the target never +claims that one `.pdx` contains both: + +```text +dist/vapor/todo.playdate.playdate-simulator.pdx +dist/vapor/todo.playdate.playdate-device.pdx +``` + +The renderer writes the SDK's 52-byte-stride framebuffer directly. Only the +first 50 bytes of each physical row are visible and modified. Invalid +character/palette data or framebuffer acquisition failure sets +`VP_TRIP_PLATFORM_RENDER`, logs `PVERROR`, preserves dirty state, and stops +the update loop instead of substituting fallback pixels. + +The crank implements the shared `RelativeAxis.Primary` capability. The +runtime samples `getCrankChange()` and forwards signed millidegrees without +choosing an interaction detent. Fractional sub-millidegree motion is retained +between frames. Clockwise is positive. The Todo application, rather than the +runtime, chooses a 45-degree list detent. Docked and lifecycle-reset motion is +drained so it cannot reappear as a ghost event. Buttons and relative-axis +input are dispatched before one batched `app_flush()` per update. + +Runtime receipts: + +```text +PVREADY target=playdate build= grid=50x30 ... +PVFRAME frame= flush= commit= trips= +PVINPUT axis=primary delta_mdeg= raw_mdeg= sub_mdeg_x1000= event= +PVERROR stage= code= ... +``` + +The checked-in fake-framebuffer test verifies byte layout. A physical-device +smoke is still required before claiming display polarity, lifecycle redraws, +or hardware input parity are verified. + +## Manual acceptance checklist + +Build and open the Simulator package: + +```sh +bun run vapor:playdate +``` + +The Simulator supports dragging its crank control or using a mouse/trackpad +scroll wheel (see the +[official Simulator controls](https://help.play.date/manual/simulator/)). +Validate the following: + +1. Boot shows `PLAYDATE VAPOR TODO`, three seed rows, `2 LEFT / ALL`, and no + `PVERROR`. +2. Extend the crank. Clockwise motion moves the selection down; anti-clockwise + moves it up. The Todo moves once per 45 degrees; slower partial turns + accumulate instead of being lost. +3. Stow the crank, rotate/scroll, then extend it again. No delayed cursor jump + should occur. +4. In list mode: A toggles completion, B deletes, Right cycles the filter, Up + opens the editor, and Down clears completed todos. +5. In edit mode: Left/Right select the glyph, A inserts, B backspaces, Up + saves, and Down cancels. Crank motion must not move the hidden list cursor. +6. Pause/resume or lock/unlock. The full screen should redraw without + corruption or a synthetic crank step. +7. Console output should contain `PVREADY`, `PVINPUT` with signed + millidegrees, and `PVFRAME` after paints; `trips` remains zero. + +For hardware, build `bun run vapor:playdate:device`, sideload the resulting +device `.pdx`, and repeat the same sequence. Simulator success is not a +substitute for checking physical screen polarity and crank feel. diff --git a/vapor/runtime/playdate/framebuffer.c b/vapor/runtime/playdate/framebuffer.c new file mode 100644 index 00000000..4b97a9a1 --- /dev/null +++ b/vapor/runtime/playdate/framebuffer.c @@ -0,0 +1,98 @@ +#include "framebuffer.h" + +#include +#include + +#define VP_PD_VALID_DIRTY_MASK ((UINT32_C(1) << VP_PD_GRID_H) - UINT32_C(1)) + +static void reset_result(vp_pd_render_result *result) { + memset(result, 0, sizeof(*result)); +} + +static vp_pd_render_code fail( + vp_pd_render_error *error, + vp_pd_render_code code, + uint8_t x, + uint8_t y, + uint8_t ch, + uint8_t palette) { + if (error) { + error->code = code; + error->x = x; + error->y = y; + error->ch = ch; + error->palette = palette; + } + return code; +} + +vp_pd_render_code vp_pd_render_frame( + uint8_t *frame, + uint32_t dirty, + const uint8_t *grid_ch, + const uint8_t *grid_pal, + const uint8_t *font, + const uint8_t *pal_style, + uint8_t palette_count, + vp_pd_render_result *result, + vp_pd_render_error *error) { + uint8_t x; + uint8_t y; + int run = -1; + + if (!result) return fail(error, VP_PD_RENDER_NULL_ARGUMENT, 0, 0, 0, 0); + reset_result(result); + if (error) memset(error, 0, sizeof(*error)); + if (!dirty) return VP_PD_RENDER_OK; + if (!frame || !grid_ch || !grid_pal || !font || !pal_style) + return fail(error, VP_PD_RENDER_NULL_ARGUMENT, 0, 0, 0, 0); + if (dirty & ~VP_PD_VALID_DIRTY_MASK) + return fail(error, VP_PD_RENDER_DIRTY_RANGE, 0, 0, 0, 0); + if (!palette_count) + return fail(error, VP_PD_RENDER_INVALID_PALETTE, 0, 0, 0, 0); + + /* Validate first. A malformed cell must never leave a half-painted frame. */ + for (y = 0; y < VP_PD_GRID_H; y++) { + size_t row; + if (!(dirty & (UINT32_C(1) << y))) continue; + row = (size_t)y * VP_PD_GRID_W; + for (x = 0; x < VP_PD_GRID_W; x++) { + uint8_t ch = grid_ch[row + x]; + uint8_t palette = grid_pal[row + x]; + if (ch < 0x20 || ch > 0x7e) + return fail(error, VP_PD_RENDER_INVALID_CHAR, x, y, ch, palette); + if (palette >= palette_count) + return fail(error, VP_PD_RENDER_INVALID_PALETTE, x, y, ch, palette); + } + } + + for (y = 0; y < VP_PD_GRID_H; y++) { + size_t row; + uint8_t glyph_y; + if (!(dirty & (UINT32_C(1) << y))) continue; + row = (size_t)y * VP_PD_GRID_W; + for (glyph_y = 0; glyph_y < VP_PD_CELL_SIZE; glyph_y++) { + uint8_t *dst = + frame + ((size_t)y * VP_PD_CELL_SIZE + glyph_y) * VP_PD_LCD_ROWSIZE; + for (x = 0; x < VP_PD_GRID_W; x++) { + uint8_t ch = grid_ch[row + x]; + uint8_t glyph = font[(size_t)(ch - 0x20) * VP_PD_CELL_SIZE + glyph_y]; + uint8_t style = pal_style[grid_pal[row + x]]; + /* Font bits are ink; Playdate framebuffer bits are white. Style 0 is + * dark-on-light (ink bits cleared to black on a set white paper), + * style 1 is light-on-dark — matching the styles2 luminance contract + * shared with GB/NES and the oracle preview. */ + dst[x] = style ? glyph : (uint8_t)~glyph; + } + } + + if (run < 0 || result->runs[run].last + 1 != y * VP_PD_CELL_SIZE) { + run++; + result->runs[run].first = y * VP_PD_CELL_SIZE; + } + result->runs[run].last = y * VP_PD_CELL_SIZE + VP_PD_CELL_SIZE - 1; + result->rendered_mask |= UINT32_C(1) << y; + } + result->run_count = (uint8_t)(run + 1); + return VP_PD_RENDER_OK; +} diff --git a/vapor/runtime/playdate/framebuffer.h b/vapor/runtime/playdate/framebuffer.h new file mode 100644 index 00000000..bcb55c04 --- /dev/null +++ b/vapor/runtime/playdate/framebuffer.h @@ -0,0 +1,60 @@ +#ifndef POCKET_VAPOR_PLAYDATE_FRAMEBUFFER_H +#define POCKET_VAPOR_PLAYDATE_FRAMEBUFFER_H + +#include + +#define VP_PD_GRID_W 50 +#define VP_PD_GRID_H 30 +#define VP_PD_CELL_SIZE 8 +#define VP_PD_LCD_ROWS 240 +#define VP_PD_LCD_ROWSIZE 52 +#define VP_PD_VISIBLE_ROW_BYTES 50 +#define VP_PD_MAX_DIRTY_RUNS VP_PD_GRID_H + +typedef enum { + VP_PD_RENDER_OK = 0, + VP_PD_RENDER_NULL_ARGUMENT = 1, + VP_PD_RENDER_DIRTY_RANGE = 2, + VP_PD_RENDER_INVALID_CHAR = 3, + VP_PD_RENDER_INVALID_PALETTE = 4, +} vp_pd_render_code; + +typedef struct { + uint8_t first; + uint8_t last; +} vp_pd_updated_rows; + +typedef struct { + uint32_t rendered_mask; + uint8_t run_count; + vp_pd_updated_rows runs[VP_PD_MAX_DIRTY_RUNS]; +} vp_pd_render_result; + +typedef struct { + vp_pd_render_code code; + uint8_t x; + uint8_t y; + uint8_t ch; + uint8_t palette; +} vp_pd_render_error; + +/* + * Render a snapshot of logical dirty rows into Playdate's raw framebuffer. + * + * The function validates the complete dirty snapshot before writing a byte, + * so callers can preserve vp_rows_dirty unchanged on any contract error. + * On success, result->rendered_mask identifies exactly what was written and + * result->runs contains inclusive physical-row ranges for markUpdatedRows(). + */ +vp_pd_render_code vp_pd_render_frame( + uint8_t *frame, + uint32_t dirty, + const uint8_t *grid_ch, + const uint8_t *grid_pal, + const uint8_t *font, + const uint8_t *pal_style, + uint8_t palette_count, + vp_pd_render_result *result, + vp_pd_render_error *error); + +#endif diff --git a/vapor/runtime/playdate/pdxinfo.in b/vapor/runtime/playdate/pdxinfo.in new file mode 100644 index 00000000..4e8bb8c9 --- /dev/null +++ b/vapor/runtime/playdate/pdxinfo.in @@ -0,0 +1,6 @@ +name=@NAME@ +author=PocketJS +description=Pocket Vapor native Playdate application +bundleID=@BUNDLE_ID@ +version=0.1.0 +buildNumber=1 diff --git a/vapor/runtime/playdate/vapor_playdate.c b/vapor/runtime/playdate/vapor_playdate.c new file mode 100644 index 00000000..70428b29 --- /dev/null +++ b/vapor/runtime/playdate/vapor_playdate.c @@ -0,0 +1,258 @@ +/* Pocket Vapor native Playdate host. + * + * The generated app and vapor_core.c remain allocator-free. This file owns + * only the SDK boundary: lifecycle, pushed-button sampling, framebuffer + * commits, and machine-readable diagnostics. + */ +#include "vapor.h" + +#include +#include + +#include "framebuffer.h" +#include "pd_api.h" + +#ifndef VP_BUILD_ID +#define VP_BUILD_ID "unknown" +#endif + +_Static_assert(VP_GRID_W == VP_PD_GRID_W, "Playdate requires a 50-column grid"); +_Static_assert(VP_GRID_H == VP_PD_GRID_H, "Playdate requires a 30-row grid"); +_Static_assert(VP_GRID_H <= 32, "Playdate dirty-row mask supports at most 32 rows"); + +u8 vp_grid_ch[VP_GRID_H][VP_GRID_W]; +u8 vp_grid_pal[VP_GRID_H][VP_GRID_W]; + +static PlaydateAPI *pd; +static u32 frame_no; +static u32 flush_no; +static u32 commit_no; +static u32 axis_event_no; +static float crank_sub_millidegrees; +static u8 stopped; + +static u32 full_dirty_mask(void) { + return vp_bit32[VP_GRID_H] - 1; +} + +static void log_render_error(const vp_pd_render_error *error) { + pd->system->logToConsole( + "PVERROR stage=render code=%u x=%u y=%u ch=%u pal=%u dirty=%lx", + (unsigned int)error->code, + (unsigned int)error->x, + (unsigned int)error->y, + (unsigned int)error->ch, + (unsigned int)error->palette, + (unsigned long)vp_rows_dirty); +} + +static int commit_rows(void) { + vp_pd_render_result result; + vp_pd_render_error error; + uint8_t *frame; + uint32_t dirty = (uint32_t)vp_rows_dirty; + uint8_t i; + + if (!dirty) return 0; + frame = pd->graphics->getFrame(); + if (!frame) { + vp_tripwires |= VP_TRIP_PLATFORM_RENDER; + stopped = 1; + pd->system->logToConsole( + "PVERROR stage=getFrame code=null-frame dirty=%lx", + (unsigned long)vp_rows_dirty); + return 0; + } + + if (vp_pd_render_frame( + frame, + dirty, + (const uint8_t *)vp_grid_ch, + (const uint8_t *)vp_grid_pal, + vp_font_tiles, + vp_pal_style, + vp_palette_count, + &result, + &error) != VP_PD_RENDER_OK) { + vp_tripwires |= VP_TRIP_PLATFORM_RENDER; + stopped = 1; + log_render_error(&error); + return 0; + } + + for (i = 0; i < result.run_count; i++) + pd->graphics->markUpdatedRows(result.runs[i].first, result.runs[i].last); + vp_rows_dirty &= ~(u32)result.rendered_mask; + commit_no++; + return result.rendered_mask != 0; +} + +static void dispatch_pushed(PDButtons pushed) { + static const struct { + PDButtons physical; + u8 logical; + } map[] = { + {kButtonA, 0}, + {kButtonB, 1}, + {kButtonRight, 4}, + {kButtonLeft, 5}, + {kButtonUp, 6}, + {kButtonDown, 7}, + }; + uint8_t i; + for (i = 0; i < sizeof(map) / sizeof(map[0]); i++) + if (pushed & map[i].physical) app_on_button(map[i].logical); +} + +static void reset_crank_input(const char *reason) { + float discarded = pd->system->getCrankChange(); + crank_sub_millidegrees = 0.0f; + pd->system->logToConsole( + "PVINPUT axis=primary event=reset reason=%s discarded_mdeg=%ld", + reason, + (long)(discarded * 1000.0f)); +} + +static s32 dispatch_crank_delta(void) { + float change = pd->system->getCrankChange(); + float accumulated_millidegrees; + s32 delta_millidegrees; + + if (pd->system->isCrankDocked()) { + crank_sub_millidegrees = 0.0f; + return 0; + } + accumulated_millidegrees = + crank_sub_millidegrees + (change * 1000.0f); + delta_millidegrees = (s32)accumulated_millidegrees; + crank_sub_millidegrees = + accumulated_millidegrees - (float)delta_millidegrees; + if (!delta_millidegrees) return 0; + + app_on_axis_delta(VP_RELATIVE_AXIS_PRIMARY, delta_millidegrees); + axis_event_no++; + pd->system->logToConsole( + "PVINPUT axis=primary delta_mdeg=%ld raw_mdeg=%ld sub_mdeg_x1000=%ld event=%lu", + (long)delta_millidegrees, + (long)(change * 1000.0f), + (long)(crank_sub_millidegrees * 1000.0f), + (unsigned long)axis_event_no); + return delta_millidegrees; +} + +static int update(void *userdata) { + PDButtons pushed = 0; + int painted; + (void)userdata; + + if (stopped) return 0; + pd->system->getButtonState(NULL, &pushed, NULL); + dispatch_pushed(pushed); + dispatch_crank_delta(); + if (app_flush()) flush_no++; + painted = commit_rows(); + frame_no++; + if (painted) { + pd->system->logToConsole( + "PVFRAME frame=%lu flush=%lu commit=%lu trips=%u", + (unsigned long)frame_no, + (unsigned long)flush_no, + (unsigned long)commit_no, + (unsigned int)vp_tripwires); + } + return painted; +} + +static void force_full_redraw(const char *reason) { + if (stopped) return; + vp_rows_dirty |= full_dirty_mask(); + pd->system->logToConsole("PVLIFECYCLE event=%s redraw=full", reason); +} + +#ifdef _WINDLL +__declspec(dllexport) +#endif +int eventHandler(PlaydateAPI *playdate, PDSystemEvent event, uint32_t arg) { + pd = playdate; + if (!pd || !pd->system) return 0; + + switch (event) { + case kEventInit: + if (!pd->graphics || !pd->display) { + pd->system->logToConsole("PVERROR stage=init code=missing-sdk-api"); + stopped = 1; + return 0; + } + if (!pd->system->getCrankChange || !pd->system->isCrankDocked) { + pd->system->logToConsole("PVERROR stage=init code=missing-relative-axis-api"); + stopped = 1; + return 0; + } + stopped = 0; + frame_no = 0; + flush_no = 0; + commit_no = 0; + axis_event_no = 0; + crank_sub_millidegrees = 0.0f; + vp_tripwires = 0; + vp_rows_dirty = 0; + vp_row_clear(0, VP_GRID_H); + app_init(); + app_flush(); + flush_no++; + vp_rows_dirty |= full_dirty_mask(); + if (!commit_rows()) { + pd->system->logToConsole("PVERROR stage=init code=first-frame"); + stopped = 1; + return 0; + } + reset_crank_input("init"); + pd->display->setRefreshRate(30.0f); + pd->system->setUpdateCallback(update, NULL); + pd->system->logToConsole( + "PVREADY target=playdate build=%s grid=%dx%d frame=%lu flush=%lu commit=%lu", + VP_BUILD_ID, + VP_GRID_W, + VP_GRID_H, + (unsigned long)frame_no, + (unsigned long)flush_no, + (unsigned long)commit_no); + break; + case kEventUnlock: + reset_crank_input("unlock"); + force_full_redraw("unlock"); + break; + case kEventResume: + reset_crank_input("resume"); + force_full_redraw("resume"); + break; + case kEventMirrorStarted: + force_full_redraw("mirror-started"); + break; + case kEventMirrorEnded: + force_full_redraw("mirror-ended"); + break; + case kEventLock: + pd->system->logToConsole("PVLIFECYCLE event=lock arg=%lu", (unsigned long)arg); + break; + case kEventPause: + pd->system->logToConsole("PVLIFECYCLE event=pause arg=%lu", (unsigned long)arg); + break; + case kEventLowPower: + pd->system->logToConsole("PVLIFECYCLE event=low-power arg=%lu", (unsigned long)arg); + break; + case kEventTerminate: + stopped = 1; + pd->system->logToConsole("PVLIFECYCLE event=terminate arg=%lu", (unsigned long)arg); + break; + case kEventInitLua: + case kEventKeyPressed: + case kEventKeyReleased: + pd->system->logToConsole( + "PVLIFECYCLE event=ignored-native code=%u arg=%lu", + (unsigned int)event, + (unsigned long)arg); + break; + } + return 0; +} diff --git a/vapor/runtime/vapor.h b/vapor/runtime/vapor.h index bed5ccd0..a37c758a 100644 --- a/vapor/runtime/vapor.h +++ b/vapor/runtime/vapor.h @@ -2,7 +2,8 @@ * * Two parties compile against this header: the fixed per-console runtime * (gba/vapor_gba.c, gb/vapor_gb.c, nes/vapor_nes.c, - * esp32/vapor_esp32.c) and the compiler-generated application (gen_app.c). + * esp32/vapor_esp32.c, playdate/vapor_playdate.c) and the + * compiler-generated application (gen_app.c). * The runtime owns the cell grid, video commit, input edges, the frame loop * and the debug block; the generated * app owns all reactive state, computeds, paint effects and button @@ -86,6 +87,7 @@ u8 vp_sb_eq(const vp_sb *a, const vp_sb *b); #define VP_TRIP_POOL_FULL 1 #define VP_TRIP_STR_TRUNC 2 #define VP_TRIP_VIEW_FULL 4 +#define VP_TRIP_PLATFORM_RENDER 8 extern u8 vp_tripwires; /* core state shared with the per-target runtime */ @@ -93,8 +95,14 @@ extern u32 vp_rows_dirty; extern const u32 vp_bit32[32]; /* vp_bit32[n] == 1UL << n */ /* ---- generated app hooks ----------------------------------------------------- */ +#define VP_RELATIVE_AXIS_PRIMARY 0 +#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 */ +/* 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. */ +void app_on_axis_delta(u8 axis, s32 delta); u8 app_flush(void); /* computeds + dirty effects; 1 if painted */ u16 app_debug_state(volatile u8 *out); /* mirror reactive state; returns bytes */ @@ -103,7 +111,8 @@ u16 app_debug_state(volatile u8 *out); /* mirror reactive state; returns bytes * * GB: vp_font_tiles (2 styles x 95) x 16B 2bpp interleaved * NES: vp_font_tiles (2 styles x 95) x 16B 2bpp planar * ESP32: vp_font_tiles 95x8B 1bpp, direct RGB565 ink/paper tables - * GB/NES: vp_pal_style[8] maps logical palette -> glyph style (0/1) */ + * Playdate: vp_font_tiles 95x8B 1bpp, vp_pal_style maps pair -> normal/inverse + * GB/NES/Playdate: vp_pal_style maps logical palette -> glyph style (0/1) */ extern const u8 vp_font_tiles[]; extern const u16 vp_palettes[]; extern const u8 vp_palette_count; diff --git a/vapor/scripts/dev.ts b/vapor/scripts/dev.ts index 36794554..0c6ad16a 100644 --- a/vapor/scripts/dev.ts +++ b/vapor/scripts/dev.ts @@ -7,10 +7,12 @@ // The page mounts the same component file the cartridges are compiled from, // through the same vue-jsx-vapor pipeline — but onto the browser DOM: every // is a live element (devtools-inspectable), keyboard maps to the pad, -// and ?target=web|gba|gb|nes|esp32 re-renders with that target's screen geometry +// and ?target=web|gba|gb|nes|esp32|playdate re-renders with that target's screen geometry // and style lowering, so degradation is something you can SEE while // debugging. Keys: arrows = d-pad, Z=A, X=B, Enter=Start, Shift=Select, -// A=L, S=R. +// A=L, S=R. Wheel/trackpad distance drives RelativeAxis.Primary: downward +// motion is positive, upward motion is negative, and the page shows the +// accumulated emulated angle. import { join, resolve } from "node:path"; import { jsxPlugin } from "../../framework/compiler/jsx-plugin.ts"; @@ -31,6 +33,7 @@ const TARGET_DIMS: Record = { gb: { w: 20, h: 18 }, nes: { w: 22, h: 18 }, esp32: { w: 20, h: 18 }, + playdate: { w: 50, h: 30 }, }; function pickTarget(url: URL): string { @@ -44,10 +47,11 @@ async function buildAppBundle(): Promise { devEntry, `import { createVaporApp } from "vue"; import App from ${JSON.stringify(entry)}; -import { __dispatchButton } from ${JSON.stringify(join(HOST_DIR, "input.ts"))}; +import { __dispatchAxisDelta, __dispatchButton, RelativeAxis, RelativeAxisUnits } from ${JSON.stringify(join(HOST_DIR, "input.ts"))}; import { parseRowClass } from ${JSON.stringify(join(import.meta.dir, "..", "compiler", "styles.ts"))}; const screen = document.getElementById("screen")!; +const axisReadout = document.getElementById("axis-readout")!; const app = createVaporApp({ setup: () => (App as unknown as () => unknown)() }); app.mount(screen); @@ -91,6 +95,29 @@ addEventListener("keydown", (e) => { __dispatchButton(b); } }); +let wheelSubMillidegrees = 0; +let wheelTotalDegrees = 0; +function wheelDegrees(e: WheelEvent): number { + // Browsers report wheel deltas in pixels, lines, or pages. Normalize all + // three to an explicit development-only angle while preserving magnitude. + if (e.deltaMode === WheelEvent.DOM_DELTA_LINE) return e.deltaY * 15; + if (e.deltaMode === WheelEvent.DOM_DELTA_PAGE) return e.deltaY * 90; + return e.deltaY; // pixel-mode trackpads: one CSS pixel emulates one degree +} +addEventListener("wheel", (e) => { + if (e.deltaY === 0) return; + e.preventDefault(); + const degrees = wheelDegrees(e); + wheelTotalDegrees += degrees; + axisReadout.textContent = + "Primary: " + (wheelTotalDegrees >= 0 ? "+" : "") + wheelTotalDegrees.toFixed(3) + "°"; + + const accumulated = + wheelSubMillidegrees + degrees * RelativeAxisUnits.PerDegree; + const delta = Math.trunc(accumulated); + wheelSubMillidegrees = accumulated - delta; + if (delta !== 0) __dispatchAxisDelta(RelativeAxis.Primary, delta); +}, { passive: false }); `, ); const result = await Bun.build({ @@ -121,6 +148,7 @@ function page(target: string): string { body { background:#0b0e1a; color:#8b96ad; font: 14px ui-monospace, monospace; display:flex; flex-direction:column; align-items:center; gap:12px; padding:24px; } a { color:#42b883; } b { color:#e6edf3; } + #axis-readout { color:#e6edf3; font-variant-numeric:tabular-nums; } #screen { position:relative; width:${dims.w}ch; height:calc(${dims.h} * var(--ch-h)); font: 18px/22px ui-monospace, monospace; background:#101423; outline:6px solid #1c2233; border-radius:2px; overflow:hidden; } @@ -131,6 +159,7 @@ function page(target: string): string {
pocket vapor dev · target: ${picker}
arrows=pad   Z=A   X=B   Enter=Start   Shift=Select   A/S=L/R
+
wheel down=Primary+   wheel up=Primary−   Primary: +0.000°