diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index 955d408..fd94174 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -23,7 +23,7 @@ pocketjs/ ├─ framework/ Guest: @pocketjs/framework │ ├─ src/ the TS runtime (Solid + Vue Vapor renderers, components, input, osk…) │ └─ compiler/ the interpreted-path build pipeline (jsx-plugin, tailwind, pak) -├─ vapor/ Pocket Vapor: the AOT compiler family (Vue Vapor subset → GBA/GB/NES) +├─ vapor/ Pocket Vapor: AOT Vue subset → consoles, ESP32, Playdate Simulator ├─ contracts/ single sources of truth binding the layers │ ├─ spec/ op contract, platform contracts, manifest + package spec, gen-rust │ └─ schema/ published JSON schemas (pocket-2.json) @@ -46,7 +46,7 @@ New things go where the axis says — never invent a top-level directory: - **A new Rust simulation core** → `engine/` (workspace member if it builds on desktop; excluded standalone crate if it needs a console toolchain). - **A new platform embedding** (ESP32, 3DS, …) → `hosts//`. -- **A new AOT backend** (Vapor gains a console) → `vapor/runtime//`; +- **A new AOT backend** (Vapor gains a native target) → `vapor/runtime//`; the vapor compiler grows a target entry, the top level does not change. - **A new demo** → `apps//` with a `pocket.json`. Standalone products keep the `pocket-` separate-repo convention and do not move in. diff --git a/package.json b/package.json index c75dff0..f935813 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,8 @@ "vapor:esp32": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target esp32", "vapor:esp32:flash": "bun vapor/scripts/esp32.ts flash", "vapor:esp32:verify": "bun vapor/scripts/esp32.ts verify", + "vapor:playdate": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target playdate", + "vapor:playdate:play": "bun vapor/scripts/playdate.ts", "vapor:dev": "bun vapor/scripts/dev.ts", "vapor:check": "bun vapor/compiler/cli.ts check vapor/examples/todo/todo.tsx" }, diff --git a/vapor/DESIGN.md b/vapor/DESIGN.md index dd5999c..e143e24 100644 --- a/vapor/DESIGN.md +++ b/vapor/DESIGN.md @@ -6,9 +6,11 @@ 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 the +official Playdate Simulator; every build is still native C with a fixed +memory plan, not an embedded JavaScript runtime. The Playdate backend +currently produces a host-native Simulator bundle only, not a physical +device build. Vue Vapor's thesis is *compile the virtual DOM away*. Pocket Vapor extends it one machine layer down: **compile the JavaScript engine away.** The @@ -35,6 +37,11 @@ an operational definition: > Vue build as the oracle, the compiled ROM in an emulator — drives the same > input script into each, and asserts the same rendered screen. +The Playdate backend is currently an explicit simulator-only extension of +that model: it shares the compiler, generated C, and logical-grid contract, +but local official-Simulator acceptance is not yet part of the automated +ROM parity matrix. + That is the MicroPython positioning: MicroPython is not a Python-flavored DSL, it is a subset of Python with an implementation sized for microcontrollers. Pocket Vapor is that, for Vue. @@ -171,6 +178,7 @@ MEANS is the target's style contract: | 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) | | esp32 | `rgb565` | pair id -> RGB565 ink/paper values rasterized into the LCD cell | +| playdate | `styles2` | pair -> black-on-white or white-on-black 1-bit glyph style | Diagnostics are compile-time and structured (`bun vapor/compiler/cli.ts check ` prints the whole matrix in one run, no toolchains needed): @@ -192,11 +200,12 @@ 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 -20×18 grid as 8×7 cells into a 160×126 content area on the 160×128 ST7735 -panel. The JSX vocabulary is deliberately one intrinsic with two -interpreters — the C cell grid on device, and a ~60-line tree walker over -the oracle's micro-DOM: +GB and ESP32, 22×18 on NES, and 50×30 in the Playdate Simulator. 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. Playdate maps each 8×8 1-bit +glyph directly onto the Simulator's 400×240 framebuffer. The JSX vocabulary +is deliberately one intrinsic with two interpreters — the C cell grid on a +native target, and a ~60-line tree walker over the oracle's micro-DOM: - `` — paints its text children at (x, y) in palette `p`, padded with spaces to the right edge; later rows overwrite @@ -216,6 +225,13 @@ 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. +Playdate has D-pad plus A/B rather than Pocket's full ten-button vocabulary. +Its runtime sends D-pad edges directly, emits A/B for short presses on +release, maps a 500 ms A hold to START, and maps a 500 ms B hold to SELECT. +A completed hold suppresses the corresponding short press. The Todo app +already maps Right to the same filter action as R, so all of its actions +remain reachable without inventing a physical R button. + ## 6. Pipeline ``` @@ -230,14 +246,17 @@ 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 + → cartridge ROM, ESP32 firmware, or + host-native Playdate Simulator .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 official +Simulator update callback, 52-byte-stride 1-bit framebuffer commit, and +short/long-press translation. ## 7. E2E: the oracle is real Vue @@ -268,29 +287,34 @@ 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 is narrower in scope: manual local acceptance +builds with the official SDK's `make simulator` target and opens the +resulting `.pdx` in the official Simulator. It does not currently claim +automated per-press parity, an ARM device build, or physical Playdate +verification. ## 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 Simulator | +|---|---|---|---|---|---| +| CPU | ARM7TDMI 16.8MHz | SM83 4.19MHz | 6502 1.79MHz | Xtensa LX6, up to 240MHz | macOS host | +| Toolchain | arm-none-eabi-gcc | sdcc + sdasgb + makebin + rgbfix | cc65/ca65/ld65 | ESP-IDF v6.0.2 | official Playdate C SDK + host clang | +| Grid | 30x20 | 20x18 | 22x18 (centered) | 20x18 on ST7735 160x128 | 50x30 on 400x240 | +| Palettes | 6 real BG banks | 2 glyph styles (BGP is global) | 2 glyph styles in CHR-ROM | RGB565 ink/paper pairs | 2 1-bit 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 | `.pdx` with host `pdex.dylib` | +| Debug receipt | EWRAM 0x2000000 | WRAM 0xD800 (grid IS the block) | $0200 fixed segment | UART 115200 (`H/R/P/D`) | startup console marker only | +| E2E transport | libmgba | libmgba | jsnes | physical USB serial (opt-in) | official Simulator (manual launch) | The generated C is target-independent; geometry and budgets arrive as `#define`s, `SCREEN.*` folds in the compiler, and each hardware runtime implements the same `vapor.h` contract. 8-bit portability rules baked into -codegen: `s32`/`u32` are `long` (16-bit `int` consoles), -declarations hoist to function tops (cc65 is C89), variable-index record -access is u16 pointer arithmetic and bit masks come from a ROM table -(sdcc 4.6 SM83 miscompiles some u8-by-u8 multiplies — the __muluchar -lesson inherited from Pocket Static). +codegen: `s32`/`u32` are `long` on the 16-bit-`int` console toolchains, while +the Playdate Simulator uses `int32_t`/`uint32_t` because macOS `long` is +64-bit. Declarations hoist to function tops (cc65 is C89), variable-index +record access is u16 pointer arithmetic and bit masks come from a ROM table +(sdcc 4.6 SM83 miscompiles some u8-by-u8 multiplies — the __muluchar lesson +inherited from Pocket Static). ## 8. The demo — VAPOR TODO @@ -308,6 +332,10 @@ 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. +In the Playdate Simulator, D-pad input is direct. Short A/B perform the +normal A/B actions; hold A for 500 ms for START (new/save), and hold B for +500 ms for SELECT (clear/cancel). Right cycles the filter in list mode. + State: 6 refs, 4 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 @@ -320,4 +348,5 @@ Components-with-props and slots (block machinery is shaped for them; not demoed), `reactive()`/`watch()` sugar, floats/fixed-point, CJK text, sound, saves, and unattended physical-device CI. Console parity remains emulator-driven; ESP32 parity is available through the explicit -USB-connected verifier. +USB-connected verifier. Physical Playdate builds and hardware verification +are not claimed by the current Simulator-only target. diff --git a/vapor/README.md b/vapor/README.md index c43049b..d7d770a 100644 --- a/vapor/README.md +++ b/vapor/README.md @@ -4,9 +4,10 @@ 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 -allocator. Vue Vapor compiles the virtual DOM away; Pocket Vapor compiles -the JavaScript engine away. +6502 on the NES, and Xtensa LX6 on the ESP32**, plus a host-native C bundle +for the official Playdate Simulator. No JS engine, no GC, no allocator. Vue +Vapor compiles the virtual DOM away; Pocket Vapor compiles the JavaScript +engine away. | GBA (arm-none-eabi-gcc) | GBA edit mode | |---|---| @@ -16,11 +17,13 @@ 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 -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. +One component file spans the oracle on real vue 3.6, three cartridges, one +ESP32 firmware image, and a native `.pdx` running in the official Playdate +Simulator. Screen geometry is a compile-time constant +(`SCREEN.width`/`SCREEN.height` from the host module): layout math and width +ternaries fold per target, so target-specific help strings cost zero bytes +elsewhere — compile-time responsive UI. The Playdate target is currently +Simulator-only; this does not claim a physical-device build. The proof is [`examples/todo/todo.tsx`](examples/todo/todo.tsx) — TodoMVC with filters, a computed remaining-count, windowed scrolling and a glyph @@ -28,16 +31,18 @@ editor. The **same file** 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. -- **Device**: compiled to C by `vapor/compiler/compile.ts`, linked against a - target runtime, and run as native code on a console or ESP32 — still with - no JavaScript engine. +- **Native target**: compiled to C by `vapor/compiler/compile.ts`, linked + against a target runtime, and run as native code on a console, ESP32, or + the official Playdate Simulator — still with no JavaScript engine. The parity suite drives one tape of button presses through the oracle and each console emulator, then compares the rendered logical cell grid — characters *and* palettes — cell-for-cell after every press. The ESP32 device verifier applies the same contract over UART to the physical board; it is an opt-in hardware check, not a claim made by the emulator-only test -suite. +suite. Playdate currently has a separate interactive build-and-launch path; +it is not part of that per-press parity matrix and makes no physical +Playdate claim. ``` $ bun test vapor/tests/ # incl. 3-console per-press parity @@ -107,7 +112,7 @@ it deliberately over-approximates Vue (static dependency analysis). The look is declarative now — the same Tailwind names the big framework compiles, lowered through each target's style contract (GBA: real palette -banks; ESP32: RGB565 ink/paper pairs; GB/NES: two glyph styles by +banks; ESP32: RGB565 ink/paper pairs; GB/NES/Playdate: two glyph styles by luminance), with the whole diagnostics matrix one command away: ```tsx @@ -123,6 +128,8 @@ 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 OK 50x30, 6 style pairs + warn VS104: 3 distinct color pairs render as the same glyph style ... 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 @@ -138,7 +145,8 @@ 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=esp32` to preview the MeowBit's 20×18 logical viewport. Use +`?target=playdate` for the Playdate target's 50×30 monochrome grid. ## Commands @@ -155,6 +163,8 @@ bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target nes # → tod bun run vapor:esp32 # → app-only todo.esp32.bin + gen-esp32/ bun run vapor:esp32:flash # build + flash the connected ESP32 MeowBit bun run vapor:esp32:verify # build + flash + replay the Vue-oracle tape +bun run vapor:playdate # → dist/vapor/todo.pdx (Simulator bundle) +bun run vapor:playdate:play # build + open it in the official Simulator bun vapor/scripts/play.ts # build + open in mGBA bun vapor/scripts/dev.ts [app.tsx] # visible oracle in the browser bun vapor/compiler/cli.ts check [--strict] # cross-target diagnostics matrix @@ -165,15 +175,35 @@ 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** (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 -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 -miscompiles some u8-by-u8 multiplies, so generated indexing is u16 pointer -arithmetic and bit masks come from a ROM table. +the installation). Playdate uses Panic's official macOS SDK and host clang. +Install the SDK from ; the build resolves it from +`PLAYDATE_SDK_PATH`, then `SDKRoot` in `~/.Playdate/config`, then the +installer default `~/Developer/PlaydateSDK`. For example: + +```sh +export PLAYDATE_SDK_PATH="$HOME/Developer/PlaydateSDK" +bun run vapor:playdate +``` + +Both Playdate commands invoke the SDK's `make simulator` path and package a +host `pdex.dylib` with `pdc`. They intentionally do not run `make device` or +claim a device-compatible `.pdx`. Oracle tests run with bun alone. Notable +per-target facts the runtime absorbs: the console shadow grid IS the debug +block (fixed 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; +Playdate maps a 50×30 grid of 8×8 glyphs directly onto its 400×240 1-bit +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. + +On Playdate, D-pad presses are direct. A short A/B press emits Pocket A/B +when released; holding A for 500 ms emits START (new/save), and holding B +for 500 ms emits SELECT (clear/cancel), without also emitting the short +press. Right also cycles the Todo filter, so the demo does not need a +physical R button. ## Layout @@ -187,7 +217,8 @@ vapor/ 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 - scripts/ dev.ts (visible oracle), play.ts, shot.ts, esp32.ts (device protocol) + runtime/playdate/ official Simulator framebuffer, input and update loop + scripts/ dev.ts, play.ts, shot.ts, esp32.ts, playdate.ts 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 931e1a9..49e03b2 100644 --- a/vapor/compiler/cli.ts +++ b/vapor/compiler/cli.ts @@ -1,7 +1,7 @@ #!/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] // bun vapor/compiler/cli.ts check [--strict] [--json] // // `check` runs the compiler frontend for EVERY target and prints the @@ -102,7 +102,7 @@ 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 ]", ); process.exit(2); } @@ -133,7 +133,9 @@ const ext = ? "nes" : target === "esp32" ? "esp32.bin" - : target satisfies never; + : target === "playdate" + ? "pdx" + : target satisfies never; const rom = join(outDir, `${name}.${ext}`); const { romBytes } = await buildRom(app, target, rom); await Bun.write(join(outDir, `${name}.${target}.debug.json`), JSON.stringify(app.debugSlots, null, 2)); diff --git a/vapor/compiler/compile.ts b/vapor/compiler/compile.ts index 770ddbd..dfadd45 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,7 @@ 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 }, }; export interface CompiledApp { @@ -850,7 +851,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 +878,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 +945,11 @@ class AppCompiler { return v.c; } + private condition(v: { c: string; ty: Ty }): string { + const c = this.truthy(v); + return c.startsWith("(") && c.endsWith(")") ? c : `(${c})`; + } + // ---- scalar expression compilation --------------------------------------- private compileExpr(e: ts.Expression, out: string[], ind: string): { c: string; ty: Ty } { @@ -1202,7 +1208,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 {`); @@ -1760,7 +1766,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; @@ -2070,7 +2076,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 @@ -2119,8 +2126,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 { +/** 1bpp raster 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,13 +2213,15 @@ 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` + styleTable ); } + case "playdate": + return `${emitFont1bpp()}\n${styleTable}`; case "gb": return `${emitFontGb()}\n${styleTable}`; case "nes": diff --git a/vapor/compiler/playdate.ts b/vapor/compiler/playdate.ts new file mode 100644 index 0000000..e5c53ac --- /dev/null +++ b/vapor/compiler/playdate.ts @@ -0,0 +1,166 @@ +// vapor/compiler/playdate.ts — package a generated Pocket Vapor app for the +// official Playdate Simulator using Panic's C SDK build support. + +import { $ } from "bun"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, readdir, rename, rm, stat, symlink } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { VAPOR_TARGETS, type CompiledApp } from "./compile.ts"; + +const RUNTIME = join(import.meta.dir, "..", "runtime"); + +function configuredSdkRoot(): string | null { + const config = join(homedir(), ".Playdate", "config"); + if (!existsSync(config)) return null; + const match = readFileSync(config, "utf8").match(/^\s*SDKRoot\s+(.+?)\s*$/m); + return match?.[1]?.replace(/^["']|["']$/g, "") ?? null; +} + +function isPlaydateSdk(path: string): boolean { + return ( + existsSync(join(path, "bin", "pdc")) && + existsSync(join(path, "C_API", "pd_api.h")) && + existsSync(join(path, "C_API", "buildsupport", "common.mk")) + ); +} + +export function resolvePlaydateSdk(): string { + const candidates = [ + process.env.PLAYDATE_SDK_PATH, + configuredSdkRoot(), + join(homedir(), "Developer", "PlaydateSDK"), + ]; + for (const candidate of candidates) { + if (!candidate) continue; + const path = resolve(candidate); + if (isPlaydateSdk(path)) return path; + } + throw new Error( + "Playdate SDK not found. Install the official macOS SDK from https://play.date/dev/ " + + "and set PLAYDATE_SDK_PATH (default: ~/Developer/PlaydateSDK).", + ); +} + +export function playdateSimulatorApp(sdk = resolvePlaydateSdk()): string { + const app = join(sdk, "bin", "Playdate Simulator.app"); + if (!existsSync(app)) throw new Error(`Playdate Simulator not found in SDK: ${app}`); + return app; +} + +export function playdateSdkVersion(sdk = resolvePlaydateSdk()): string { + const version = join(sdk, "VERSION.txt"); + return existsSync(version) ? readFileSync(version, "utf8").trim() : "unknown"; +} + +export function safePlaydateProductName(outPdx: string): string { + const stem = basename(outPdx, ".pdx") + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/^[.-]+|[.-]+$/g, ""); + return `${stem || "app"}.pdx`; +} + +function metadata(value: string): string { + return value.replace(/[\r\n=]+/g, " ").trim(); +} + +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 += (await stat(child)).size; + } + return bytes; +} + +export interface PreparedPlaydateProject { + projectDir: string; + product: string; +} + +export async function preparePlaydateProject( + app: CompiledApp, + outPdx: string, + sdk = resolvePlaydateSdk(), +): Promise { + const target = VAPOR_TARGETS.playdate; + const outDir = dirname(outPdx); + const projectDir = join(outDir, "gen-playdate"); + const srcDir = join(projectDir, "src"); + const sourceDir = join(projectDir, "Source"); + const sdkLink = join(projectDir, "sdk"); + const product = safePlaydateProductName(outPdx); + + await rm(projectDir, { recursive: true, force: true }); + await mkdir(srcDir, { recursive: true }); + await mkdir(sourceDir, { recursive: true }); + // common.mk expands SDK and PRODUCT through unquoted Make/shell contexts. + // A stable relative SDK symlink and a strict product basename keep valid + // custom SDK paths working without making those expansions executable. + await symlink(sdk, sdkLink, "dir"); + + await Bun.write(join(srcDir, "gen_app.c"), app.c); + await Bun.write(join(srcDir, "vapor.h"), Bun.file(join(RUNTIME, "vapor.h"))); + await Bun.write(join(srcDir, "vapor_core.c"), Bun.file(join(RUNTIME, "vapor_core.c"))); + await Bun.write( + join(srcDir, "vapor_playdate.c"), + Bun.file(join(RUNTIME, "playdate", "vapor_playdate.c")), + ); + + const slug = basename(outPdx, ".pdx") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + await Bun.write( + join(sourceDir, "pdxinfo"), + [ + `name=${metadata(app.title)}`, + "author=PocketJS", + "description=Pocket Vapor native app for Playdate", + `bundleID=dev.pocketjs.vapor.${slug || "app"}`, + "version=1.0.0", + "buildNumber=1", + "", + ].join("\n"), + ); + + await Bun.write( + join(projectDir, "Makefile"), + [ + "HEAP_SIZE = 8388208", + "STACK_SIZE = 61800", + `PRODUCT = ${product}`, + "SDK = sdk", + "", + "VPATH += src", + "SRC = src/vapor_core.c src/vapor_playdate.c src/gen_app.c", + "UINCDIR = src", + `UDEFS = -DVP_GRID_W=${target.width} -DVP_GRID_H=${target.height} -DVP_STR_CAP=${target.strCap} -DVP_VIEW_CAP=${target.poolCap}`, + "UASRC =", + "UADEFS =", + "ULIBDIR =", + "ULIBS =", + "", + "include $(SDK)/C_API/buildsupport/common.mk", + "SIMCOMPILER += $(UDEFS)", + "", + ].join("\n"), + ); + + return { projectDir, product }; +} + +export async function buildPlaydatePdx( + app: CompiledApp, + outPdx: string, +): Promise<{ romBytes: number; sdkVersion: string }> { + const sdk = resolvePlaydateSdk(); + const { projectDir, product } = await preparePlaydateProject(app, outPdx, sdk); + await $`make -C ${projectDir} simulator`; + + const built = join(projectDir, product); + await rm(outPdx, { recursive: true, force: true }); + await rename(built, outPdx); + return { romBytes: await directoryBytes(outPdx), sdkVersion: playdateSdkVersion(sdk) }; +} diff --git a/vapor/compiler/rom.ts b/vapor/compiler/rom.ts index 53bad6f..c7ac881 100644 --- a/vapor/compiler/rom.ts +++ b/vapor/compiler/rom.ts @@ -1,15 +1,17 @@ // vapor/compiler/rom.ts — drive the console toolchains and patch cart headers. -// Four targets, four toolchains, one generated C file: +// Five targets, five toolchains, 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: Panic's C SDK + host clang, native Simulator .pdx bundle // 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 { buildPlaydatePdx } from "./playdate.ts"; const RUNTIME = join(import.meta.dir, "..", "runtime"); const CC65_LIB = "/opt/homebrew/share/cc65/lib/none.lib"; @@ -49,6 +51,7 @@ export async function buildRom( if (target === "gb") return buildGbRom(app, outRom); if (target === "nes") return buildNesRom(app, outRom); if (target === "esp32") return buildEsp32Firmware(app, outRom); + if (target === "playdate") return buildPlaydatePdx(app, outRom); target satisfies never; throw new Error(`unsupported Pocket Vapor target: ${String(target)}`); } diff --git a/vapor/compiler/styles.ts b/vapor/compiler/styles.ts index a6fa14b..61bf9c1 100644 --- a/vapor/compiler/styles.ts +++ b/vapor/compiler/styles.ts @@ -14,11 +14,11 @@ // the app's PAIR TABLE; the pal byte in the cell grid is the pair id on // every target. What a pair id MEANS is the target's style contract: // -// 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, -// or an error under --strict +// gba "rgb555" pair id = BG palette bank (ink/paper BGR555), <= 15 pairs +// esp32 "rgb565" pair id = direct ink/paper RGB565 table index +// gb/nes/ +// playdate "styles2" pair id -> glyph style by luminance (dark-on-light / +// light-on-dark); collapsing distinct pairs warns, or errors in --strict // // The oracle (real Vue in a browser/bun) renders the same classes with the // full-color web contract — degradation is visible by flipping targets. @@ -113,6 +113,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 +252,16 @@ export function styleTableCss(table: StyleTable, target: string): string { ink = q(ink); paper = q(paper); } else if (caps.kind === "styles2") { - // DMG-flavored two-style preview + // Match the target's two-style display: Playdate is pure 1-bit while + // GB keeps the familiar DMG green 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/todo/todo.tsx b/vapor/examples/todo/todo.tsx index 3de19cf..3cf254c 100644 --- a/vapor/examples/todo/todo.tsx +++ b/vapor/examples/todo/todo.tsx @@ -33,6 +33,7 @@ const EDIT_Y = SCREEN.height - 3; const HELP_Y = SCREEN.height - 1; const TEXT_MAX = 20; const NARROW = SCREEN.width < 30; +const PLAYDATE = SCREEN.width === 50; // ---- UI components ---------------------------------------------------------- // Presentational, pure functions of props: they own their palette and @@ -209,10 +210,14 @@ export default () => { line={HELP_Y} text={ editing.value - ? NARROW + ? PLAYDATE + ? "A:PUT B:DEL <>:GLYPH HOLD-A:SAVE HOLD-B:QUIT" + : NARROW ? "A:+ B:- ST:OK SE:Q" : "A:PUT B:DEL ST:SAVE SE:QUIT" - : NARROW + : PLAYDATE + ? "A:DONE B:DEL >:FILTER HOLD-A:NEW HOLD-B:CLEAR" + : NARROW ? "A:OK B:X >:F ST:NEW" : "A:DONE B:DEL R:FILT ST:NEW" } diff --git a/vapor/host/screen.ts b/vapor/host/screen.ts index 453e228..9e32c0a 100644 --- a/vapor/host/screen.ts +++ b/vapor/host/screen.ts @@ -2,9 +2,10 @@ // // 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 -// of ROM. Under the oracle the values come from globals the test harness -// sets before boot, so one bundle replays as any console. +// NES 22x18, ESP32 20x18, Playdate 50x30) — layout math and width ternaries +// fold, dead branches drop out of the binary. Under the oracle the values +// come from globals the test harness sets before boot, so one bundle replays +// as any target. const g = globalThis as Record; diff --git a/vapor/runtime/playdate/vapor_playdate.c b/vapor/runtime/playdate/vapor_playdate.c new file mode 100644 index 0000000..2469911 --- /dev/null +++ b/vapor/runtime/playdate/vapor_playdate.c @@ -0,0 +1,175 @@ +/* vapor/runtime/playdate/vapor_playdate.c — Pocket Vapor on Playdate. + * + * A 50x30 logical grid maps exactly onto the 400x240 1-bit display with the + * compiler's 8x8 font. The runtime writes Panic's 52-byte-stride framebuffer + * directly and marks only rows changed by reactive paint effects. + * + * Playdate has six game buttons. D-pad and short A/B presses map directly; + * holding A emits Pocket Start (new/save) and holding B emits Pocket Select + * (clear/cancel). This preserves every Todo action without impossible + * opposite-direction D-pad chords. + */ +#include "vapor.h" + +#include + +#include "pd_api.h" + +#define VP_BTN_A 0 +#define VP_BTN_B 1 +#define VP_BTN_SELECT 2 +#define VP_BTN_START 3 +#define VP_BTN_RIGHT 4 +#define VP_BTN_LEFT 5 +#define VP_BTN_UP 6 +#define VP_BTN_DOWN 7 + +#define VP_LONG_PRESS_MS 500U + +_Static_assert(VP_GRID_W == LCD_COLUMNS / 8, "Playdate target must use 50 columns"); +_Static_assert(VP_GRID_H == LCD_ROWS / 8, "Playdate target must use 30 rows"); +_Static_assert(VP_GRID_H <= 32, "dirty-row mask supports at most 32 rows"); +_Static_assert(sizeof(u32) == 4, "Playdate ABI requires exact 32-bit u32"); +_Static_assert(sizeof(s32) == 4, "Playdate ABI requires exact 32-bit s32"); + +u8 vp_grid_ch[VP_GRID_H][VP_GRID_W]; +u8 vp_grid_pal[VP_GRID_H][VP_GRID_W]; + +static PlaydateAPI *pd; +static u8 a_down; +static u8 b_down; +static u8 a_long_sent; +static u8 b_long_sent; +static u32 a_started_at; +static u32 b_started_at; + +static void dispatch(u8 button) { + if (button < 10) app_on_button(button); +} + +static void poll_buttons(void) { + PDButtons current = 0; + PDButtons pushed = 0; + PDButtons released = 0; + u32 now; + + pd->system->getButtonState(¤t, &pushed, &released); + now = (u32)pd->system->getCurrentTimeMilliseconds(); + + if (pushed & kButtonUp) dispatch(VP_BTN_UP); + if (pushed & kButtonDown) dispatch(VP_BTN_DOWN); + if (pushed & kButtonLeft) dispatch(VP_BTN_LEFT); + if (pushed & kButtonRight) dispatch(VP_BTN_RIGHT); + + if (pushed & kButtonA) { + a_down = 1; + a_long_sent = 0; + a_started_at = now; + } + if (pushed & kButtonB) { + b_down = 1; + b_long_sent = 0; + b_started_at = now; + } + + if (a_down && !a_long_sent && (current & kButtonA) && (u32)(now - a_started_at) >= VP_LONG_PRESS_MS) { + dispatch(VP_BTN_START); + a_long_sent = 1; + } + if (b_down && !b_long_sent && (current & kButtonB) && (u32)(now - b_started_at) >= VP_LONG_PRESS_MS) { + dispatch(VP_BTN_SELECT); + b_long_sent = 1; + } + + if ((released & kButtonA) && a_down) { + if (!a_long_sent) { + if ((u32)(now - a_started_at) >= VP_LONG_PRESS_MS) + dispatch(VP_BTN_START); + else + dispatch(VP_BTN_A); + } + a_down = 0; + } + if ((released & kButtonB) && b_down) { + if (!b_long_sent) { + if ((u32)(now - b_started_at) >= VP_LONG_PRESS_MS) + dispatch(VP_BTN_SELECT); + else + dispatch(VP_BTN_B); + } + b_down = 0; + } +} + +static int commit_rows(void) { + u32 dirty = vp_rows_dirty; + u8 *frame; + u8 y; + + if (!dirty) return 0; + frame = pd->graphics->getFrame(); + if (frame == NULL) return 0; + + for (y = 0; y < VP_GRID_H; y++) { + u8 py; + if (!(dirty & vp_bit32[y])) continue; + for (py = 0; py < 8; py++) { + u8 *row = frame + (u16)(y * 8 + py) * LCD_ROWSIZE; + u8 x; + for (x = 0; x < VP_GRID_W; x++) { + u8 ch = vp_grid_ch[y][x]; + u8 style = vp_pal_style[vp_grid_pal[y][x]]; + u8 bits; + if (ch < 0x20 || ch > 0x7e) ch = '?'; + bits = vp_font_tiles[(u16)(ch - 0x20) * 8 + py]; + /* Playdate framebuffer bit 1 is white. */ + row[x] = style ? bits : (u8)~bits; + } + } + pd->graphics->markUpdatedRows((int)y * 8, (int)y * 8 + 7); + } + + vp_rows_dirty = 0; + return 1; +} + +static void runtime_reset(void) { + vp_tripwires = 0; + vp_rows_dirty = 0; + a_down = 0; + b_down = 0; + a_long_sent = 0; + b_long_sent = 0; + vp_row_clear(0, VP_GRID_H); + app_init(); + app_flush(); + vp_rows_dirty = VP_GRID_H == 32 ? 0xffffffffUL : vp_bit32[VP_GRID_H] - 1; + commit_rows(); +} + +static int update(void *userdata) { + (void)userdata; + poll_buttons(); + app_flush(); + return commit_rows(); +} + +#ifdef _WINDLL +__declspec(dllexport) +#endif +int eventHandler(PlaydateAPI *playdate, PDSystemEvent event, uint32_t arg) { + (void)arg; + if (event == kEventInit) { + pd = playdate; + pd->display->setRefreshRate(30.0f); + pd->graphics->setBackgroundColor(kColorWhite); + pd->graphics->clear(kColorWhite); + runtime_reset(); + pd->system->logToConsole( + "PVREADY target=playdate grid=%dx%d controls=short-a-b,long-a-start,long-b-select", + VP_GRID_W, + VP_GRID_H); + pd->system->setUpdateCallback(update, NULL); + } + return 0; +} diff --git a/vapor/runtime/vapor.h b/vapor/runtime/vapor.h index bed5ccd..bbde722 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 @@ -10,19 +11,30 @@ * compile time. * * Portability: this compiles under arm-none-eabi-gcc (ARM7TDMI), sdcc - * (SM83), cc65 (6502), and xtensa-esp-elf-gcc. `int` is 16-bit on the - * 8-bit consoles, so the 32-bit types are `long`; cc65 is C89, so - * `inline` vanishes there. + * (SM83), cc65 (6502), xtensa-esp-elf-gcc, and Playdate's host/device C + * toolchains. `long` is 64-bit in the macOS Simulator, so Playdate uses + * stdint's exact-width types. `int` is 16-bit on the 8-bit consoles, where + * the 32-bit types remain `long`; cc65 is C89, so `inline` vanishes there. */ #ifndef POCKET_VAPOR_H #define POCKET_VAPOR_H +#if defined(TARGET_SIMULATOR) || defined(TARGET_PLAYDATE) +#include +typedef uint8_t u8; +typedef uint16_t u16; +typedef uint32_t u32; +typedef int8_t s8; +typedef int16_t s16; +typedef int32_t s32; +#else typedef unsigned char u8; typedef unsigned short u16; typedef unsigned long u32; typedef signed char s8; typedef signed short s16; typedef signed long s32; +#endif #if defined(__CC65__) #define inline @@ -103,7 +115,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 + * 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 3679455..0a2bec1 100644 --- a/vapor/scripts/dev.ts +++ b/vapor/scripts/dev.ts @@ -7,10 +7,10 @@ // 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 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. +// 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. import { join, resolve } from "node:path"; import { jsxPlugin } from "../../framework/compiler/jsx-plugin.ts"; @@ -31,6 +31,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 { @@ -155,5 +156,5 @@ Bun.serve({ console.log(`pocket vapor dev host: http://localhost:${port}/ (entry: ${entry})`); console.log( - `targets: http://localhost:${port}/?target=web|gba|gb|nes|esp32 — edit ${entry} and refresh`, + `targets: http://localhost:${port}/?target=web|gba|gb|nes|esp32|playdate — edit ${entry} and refresh`, ); diff --git a/vapor/scripts/playdate.ts b/vapor/scripts/playdate.ts new file mode 100644 index 0000000..76ba585 --- /dev/null +++ b/vapor/scripts/playdate.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env bun +// Build a Pocket Vapor component for the official Playdate Simulator and open it. +// bun vapor/scripts/playdate.ts [component.tsx] + +import { $ } from "bun"; +import { basename, join, resolve } from "node:path"; +import { compileVaporApp } from "../compiler/compile.ts"; +import { + buildPlaydatePdx, + playdateSdkVersion, + playdateSimulatorApp, + resolvePlaydateSdk, +} from "../compiler/playdate.ts"; + +const ROOT = join(import.meta.dir, "..", ".."); +const entry = resolve( + process.argv[2] ?? join(import.meta.dir, "..", "examples", "todo", "todo.tsx"), +); +const name = basename(entry).replace(/\.tsx$/, ""); +const app = compileVaporApp( + entry, + await Bun.file(entry).text(), + name === "todo" ? "VAPOR TODO" : name.toUpperCase(), + "playdate", +); +const pdx = join(ROOT, "dist", "vapor", `${name}.pdx`); + +console.log(app.graph); +console.log(app.plan); +const result = await buildPlaydatePdx(app, pdx); +const sdk = resolvePlaydateSdk(); +console.log( + `${pdx} (${(result.romBytes / 1024).toFixed(1)} KB, Playdate SDK ${playdateSdkVersion(sdk)})`, +); + +// A fresh Simulator process gives each native extension a clean BSS. Reusing +// an existing process can retain state while switching between unrelated PDXs. +await $`open -na ${playdateSimulatorApp(sdk)} --args ${pdx}`; diff --git a/vapor/tests/compiler.test.ts b/vapor/tests/compiler.test.ts index cb2f1a1..e3a2d82 100644 --- a/vapor/tests/compiler.test.ts +++ b/vapor/tests/compiler.test.ts @@ -1,11 +1,14 @@ // vapor/test/compiler.test.ts — subset diagnostics + deterministic output. import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readlink, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadBoard } from "../compiler/boards.ts"; import { compileVaporApp, VAPOR_TARGETS, VaporCompileError } from "../compiler/compile.ts"; import { esp32BuildId } from "../compiler/esp32.ts"; import { FONT8 } from "../compiler/font.gen.ts"; +import { preparePlaydateProject, safePlaydateProductName } from "../compiler/playdate.ts"; const ENTRY = join(import.meta.dir, "..", "examples", "todo", "todo.tsx"); @@ -88,6 +91,66 @@ describe("pocket vapor compiler", () => { expect(compiled.plan).toContain("760 B font + 12 B style data"); }); + test("playdate uses its native 50x30 grid and emits 1bpp styles2 data", () => { + expect(VAPOR_TARGETS.playdate).toEqual({ + name: "playdate", + width: 50, + height: 30, + poolCap: 32, + strCap: 24, + }); + + const compiled = compileVaporApp( + "playdate.tsx", + minimal("", '{count.value}'), + "PLAYDATE", + "playdate", + ); + expect(compiled.c).toContain("/* target: playdate (50x30) */"); + expect(compiled.c).toContain("#define VP_GRID_W 50"); + expect(compiled.c).toContain("#define VP_GRID_H 30"); + expect(compiled.c).toContain("#define VP_STR_CAP 24"); + expect(compiled.c).toContain("#define VP_VIEW_CAP 32"); + + const font = compiled.c.match(/const u8 vp_font_tiles\[\] = \{ ([^}]*) \};/); + expect(font).not.toBeNull(); + const fontBytes = font![1].split(",").map(Number); + expect(fontBytes).toHaveLength(95 * 8); + expect(fontBytes).toEqual(FONT8.flat()); + + expect(compiled.c).toContain("const u8 vp_pal_style[2] = { 1,0 };"); + expect(compiled.c).not.toContain("vp_ink565"); + expect(compiled.c).not.toContain("vp_paper565"); + expect(compiled.c).not.toContain("vp_palettes"); + expect(compiled.diagnostics).toEqual([]); + expect(compiled.plan).toContain("ROM data: 9 B strings + 760 B font + 2 B style data"); + }); + + test("playdate keeps Make product names inert", () => { + expect(safePlaydateProductName("/tmp/todo.pdx")).toBe("todo.pdx"); + expect(safePlaydateProductName("/tmp/my; false $app.pdx")).toBe("my-false-app.pdx"); + expect(safePlaydateProductName("/tmp/....pdx")).toBe("app.pdx"); + }); + + test("playdate isolates custom SDK paths from the generated Makefile", async () => { + const root = await mkdtemp(join(tmpdir(), "pocket-vapor-playdate ")); + try { + const sdk = join(root, "SDK Root"); + const outPdx = join(root, "my; false $app.pdx"); + await mkdir(sdk); + const app = compileVaporApp("playdate.tsx", minimal(""), "PLAYDATE", "playdate"); + const prepared = await preparePlaydateProject(app, outPdx, sdk); + + expect(prepared.product).toBe("my-false-app.pdx"); + expect(await readlink(join(prepared.projectDir, "sdk"))).toBe(sdk); + expect(await Bun.file(join(prepared.projectDir, "Makefile")).text()).toContain( + "PRODUCT = my-false-app.pdx\nSDK = sdk", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + test("esp32 build identity is deterministic, source- and board-sensitive", async () => { const app = compileVaporApp("esp32.tsx", minimal(""), "ESP32", "esp32"); const same = compileVaporApp("esp32.tsx", minimal(""), "ESP32", "esp32"); diff --git a/vapor/tests/styles.test.ts b/vapor/tests/styles.test.ts index 3a2ccbe..a366349 100644 --- a/vapor/tests/styles.test.ts +++ b/vapor/tests/styles.test.ts @@ -4,7 +4,14 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; import { compileVaporApp } from "../compiler/compile.ts"; -import { parseRowClass, rgb565, STYLE_CAPS, styleOfPair, StyleTable } from "../compiler/styles.ts"; +import { + parseRowClass, + rgb565, + STYLE_CAPS, + styleOfPair, + StyleTable, + styleTableCss, +} from "../compiler/styles.ts"; const ENTRY = join(import.meta.dir, "..", "examples", "todo", "todo.tsx"); @@ -76,6 +83,20 @@ describe("per-target lowering", () => { expect(rgb565(0x00ff00)).toBe(0x07e0); expect(rgb565(0x0000ff)).toBe(0x001f); }); + + test("playdate lowers pairs to two styles with a pure black-and-white preview", () => { + const table = new StyleTable(); + table.resolveClass("text-[#000000] bg-[#ffffff]"); + + expect(STYLE_CAPS.playdate).toEqual({ kind: "styles2" }); + expect(table.lower("playdate", true)).toEqual({ styleMap: [1, 0], issues: [] }); + expect(styleTableCss(table, "playdate")).toBe( + [ + 'row[data-pal="0"] { color: #ffffff; background: #000000; }', + 'row[data-pal="1"] { color: #000000; background: #ffffff; }', + ].join("\n"), + ); + }); }); describe("compile-time style diagnostics", () => {