From 725d68cd63f3234b1610cd61eb47536abcc35c5c Mon Sep 17 00:00:00 2001 From: colmugx Date: Thu, 30 Jul 2026 18:46:42 +0800 Subject: [PATCH 1/5] feat(vapor): add Playdate target support to compiler and C runtime --- vapor/compiler/playdate.ts | 276 ++++++++++++++++++ vapor/compiler/styles.ts | 15 +- vapor/host/input.ts | 5 +- vapor/host/screen.ts | 2 +- vapor/runtime/playdate/CMakeLists.txt | 95 ++++++ vapor/runtime/playdate/framebuffer.c | 94 ++++++ vapor/runtime/playdate/framebuffer.h | 60 ++++ vapor/runtime/playdate/pdxinfo.in | 6 + vapor/runtime/playdate/vapor_playdate.c | 209 +++++++++++++ vapor/runtime/vapor.h | 7 +- vapor/scripts/dev.ts | 5 +- .../tests/harness/playdate_framebuffer_test.c | 167 +++++++++++ vapor/tests/harness/playdate_runtime_test.c | 174 +++++++++++ vapor/tests/harness/playdate_sdk/pd_api.h | 60 ++++ vapor/tests/playdate.test.ts | 206 +++++++++++++ vapor/tests/styles.test.ts | 21 +- vapor/tsconfig.json | 2 +- 17 files changed, 1391 insertions(+), 13 deletions(-) create mode 100644 vapor/compiler/playdate.ts create mode 100644 vapor/runtime/playdate/CMakeLists.txt create mode 100644 vapor/runtime/playdate/framebuffer.c create mode 100644 vapor/runtime/playdate/framebuffer.h create mode 100644 vapor/runtime/playdate/pdxinfo.in create mode 100644 vapor/runtime/playdate/vapor_playdate.c create mode 100644 vapor/tests/harness/playdate_framebuffer_test.c create mode 100644 vapor/tests/harness/playdate_runtime_test.c create mode 100644 vapor/tests/harness/playdate_sdk/pd_api.h create mode 100644 vapor/tests/playdate.test.ts 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/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/host/input.ts b/vapor/host/input.ts index 0586a099..387c42ab 100644 --- a/vapor/host/input.ts +++ b/vapor/host/input.ts @@ -6,8 +6,9 @@ // is never executed — the compiler recognizes imports of `onButton` and // `Button` from this path and wires handlers to the GBA key-edge register. // -// 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. Playdate directly exposes +// A/B/Right/Left/Up/Down and rejects Select/Start/R/L demands at compile +// time; it never invents chords for missing physical inputs. export const Button = { A: 0, 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/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/framebuffer.c b/vapor/runtime/playdate/framebuffer.c new file mode 100644 index 00000000..fc53b4da --- /dev/null +++ b/vapor/runtime/playdate/framebuffer.c @@ -0,0 +1,94 @@ +#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]]; + dst[x] = style ? (uint8_t)~glyph : 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..957f0bb8 --- /dev/null +++ b/vapor/runtime/playdate/vapor_playdate.c @@ -0,0 +1,209 @@ +/* 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 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 int update(void *userdata) { + PDButtons pushed = 0; + int painted; + (void)userdata; + + if (stopped) return 0; + pd->system->getButtonState(NULL, &pushed, NULL); + dispatch_pushed(pushed); + 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; + } + stopped = 0; + frame_no = 0; + flush_no = 0; + commit_no = 0; + 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; + } + 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: + force_full_redraw("unlock"); + break; + case kEventResume: + 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..e8015379 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 */ @@ -103,7 +105,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..7d1cf239 100644 --- a/vapor/scripts/dev.ts +++ b/vapor/scripts/dev.ts @@ -7,7 +7,7 @@ // 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. @@ -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/tests/harness/playdate_framebuffer_test.c b/vapor/tests/harness/playdate_framebuffer_test.c new file mode 100644 index 00000000..d522a85b --- /dev/null +++ b/vapor/tests/harness/playdate_framebuffer_test.c @@ -0,0 +1,167 @@ +#include +#include +#include + +#include "framebuffer.h" + +static uint8_t frame[VP_PD_LCD_ROWS * VP_PD_LCD_ROWSIZE]; +static uint8_t chars[VP_PD_GRID_W * VP_PD_GRID_H]; +static uint8_t palettes[VP_PD_GRID_W * VP_PD_GRID_H]; +static uint8_t font[95 * VP_PD_CELL_SIZE]; +static const uint8_t styles[2] = {0, 1}; + +static int check(int condition, const char *message) { + if (condition) return 1; + fprintf(stderr, "playdate framebuffer test failed: %s\n", message); + return 0; +} + +static int visible_and_padding(void) { + vp_pd_render_result result; + vp_pd_render_error error; + uint32_t dirty = UINT32_C(1) | (UINT32_C(1) << 29); + int y; + + memset(frame, 0xa5, sizeof(frame)); + memset(chars, ' ', sizeof(chars)); + memset(palettes, 0, sizeof(palettes)); + memset(font, 0, sizeof(font)); + font[('A' - 0x20) * 8 + 0] = 0x81; + font[('A' - 0x20) * 8 + 7] = 0x42; + chars[0] = 'A'; + chars[VP_PD_GRID_W * VP_PD_GRID_H - 1] = 'A'; + palettes[VP_PD_GRID_W * VP_PD_GRID_H - 1] = 1; + + if (!check( + vp_pd_render_frame( + frame, + dirty, + chars, + palettes, + font, + styles, + 2, + &result, + &error) == VP_PD_RENDER_OK, + "valid render returned an error")) + return 0; + if (!check(result.rendered_mask == dirty, "rendered mask differs from dirty snapshot")) + return 0; + if (!check(result.run_count == 2, "disjoint logical rows did not produce two runs")) + return 0; + if (!check( + result.runs[0].first == 0 && result.runs[0].last == 7 && + result.runs[1].first == 232 && result.runs[1].last == 239, + "physical updated-row ranges are wrong")) + return 0; + if (!check(frame[0] == 0x81, "normal glyph byte or MSB order is wrong")) return 0; + if (!check( + frame[(size_t)232 * VP_PD_LCD_ROWSIZE + 49] == (uint8_t)~0x81, + "inverse boundary glyph byte is wrong")) + return 0; + if (!check( + frame[(size_t)239 * VP_PD_LCD_ROWSIZE + 49] == (uint8_t)~0x42, + "last scanline boundary write is wrong")) + return 0; + + for (y = 0; y < VP_PD_LCD_ROWS; y++) { + if (!check( + frame[(size_t)y * VP_PD_LCD_ROWSIZE + 50] == 0xa5 && + frame[(size_t)y * VP_PD_LCD_ROWSIZE + 51] == 0xa5, + "row padding was modified")) + return 0; + } + return 1; +} + +static int contiguous_runs(void) { + vp_pd_render_result result; + vp_pd_render_error error; + memset(frame, 0, sizeof(frame)); + memset(chars, ' ', sizeof(chars)); + memset(palettes, 0, sizeof(palettes)); + if (!check( + vp_pd_render_frame( + frame, + UINT32_C(3), + chars, + palettes, + font, + styles, + 2, + &result, + &error) == VP_PD_RENDER_OK, + "adjacent render returned an error")) + return 0; + return check( + result.run_count == 1 && result.runs[0].first == 0 && result.runs[0].last == 15, + "adjacent logical rows did not merge into one physical run"); +} + +static int failures_are_transactional(void) { + vp_pd_render_result result; + vp_pd_render_error error; + size_t i; + + memset(frame, 0x5a, sizeof(frame)); + memset(chars, ' ', sizeof(chars)); + memset(palettes, 0, sizeof(palettes)); + chars[17] = 0x1f; + if (!check( + vp_pd_render_frame( + frame, + UINT32_C(1), + chars, + palettes, + font, + styles, + 2, + &result, + &error) == VP_PD_RENDER_INVALID_CHAR, + "invalid character was accepted")) + return 0; + if (!check(error.x == 17 && error.y == 0 && error.ch == 0x1f, "invalid character receipt is wrong")) + return 0; + for (i = 0; i < sizeof(frame); i++) + if (!check(frame[i] == 0x5a, "failed render changed framebuffer bytes")) return 0; + + chars[17] = ' '; + palettes[23] = 2; + if (!check( + vp_pd_render_frame( + frame, + UINT32_C(1), + chars, + palettes, + font, + styles, + 2, + &result, + &error) == VP_PD_RENDER_INVALID_PALETTE, + "invalid palette was accepted")) + return 0; + if (!check(error.x == 23 && error.palette == 2, "invalid palette receipt is wrong")) + return 0; + + palettes[23] = 0; + return check( + vp_pd_render_frame( + frame, + UINT32_C(1) << 31, + chars, + palettes, + font, + styles, + 2, + &result, + &error) == VP_PD_RENDER_DIRTY_RANGE, + "dirty bit above the logical grid was accepted"); +} + +int main(void) { + if (!visible_and_padding()) return 1; + if (!contiguous_runs()) return 1; + if (!failures_are_transactional()) return 1; + puts("playdate framebuffer: ok"); + return 0; +} diff --git a/vapor/tests/harness/playdate_runtime_test.c b/vapor/tests/harness/playdate_runtime_test.c new file mode 100644 index 00000000..37e79381 --- /dev/null +++ b/vapor/tests/harness/playdate_runtime_test.c @@ -0,0 +1,174 @@ +#include +#include +#include +#include + +#include "framebuffer.h" +#include "pd_api.h" +#include "vapor.h" + +extern u8 vp_grid_ch[VP_GRID_H][VP_GRID_W]; +extern u8 vp_grid_pal[VP_GRID_H][VP_GRID_W]; +int eventHandler(PlaydateAPI *playdate, PDSystemEvent event, uint32_t arg); + +const u8 vp_font_tiles[95 * 8] = {0}; +const u8 vp_palette_count = 2; +const u8 vp_pal_style[2] = {0, 1}; +const char vp_app_title[] = "TEST"; + +static int app_init_calls; +static int app_flush_calls; +static int buttons[16]; +static int button_count; + +void app_init(void) { + app_init_calls++; + vp_ln_reset(); + vp_ln_str("BOOT"); + vp_ln_commit(0, 0, 0, VP_ALIGN_LEFT); +} + +void app_on_button(u8 button) { + buttons[button_count++] = button; + vp_ln_reset(); + vp_ln_str("BUTTON "); + vp_ln_int(button); + vp_ln_commit(1, 0, 0, VP_ALIGN_LEFT); +} + +u8 app_flush(void) { + app_flush_calls++; + return 1; +} + +u16 app_debug_state(volatile u8 *out) { + out[0] = (u8)button_count; + return 1; +} + +static uint8_t framebuffer[VP_PD_LCD_ROWS * VP_PD_LCD_ROWSIZE]; +static PDButtons next_pushed; +static PDCallbackFunction *installed_update; +static void *installed_userdata; +static float refresh_rate; +static int marked_first[64]; +static int marked_last[64]; +static int mark_count; +static char logs[8192]; +static size_t logs_len; + +static void fake_log(const char *fmt, ...) { + va_list args; + int written; + va_start(args, fmt); + written = vsnprintf(logs + logs_len, sizeof(logs) - logs_len, fmt, args); + va_end(args); + if (written > 0) { + logs_len += (size_t)written; + if (logs_len + 1 < sizeof(logs)) logs[logs_len++] = '\n'; + } +} + +static void fake_set_update(PDCallbackFunction *update, void *userdata) { + installed_update = update; + installed_userdata = userdata; +} + +static void fake_buttons(PDButtons *current, PDButtons *pushed, PDButtons *released) { + if (current) *current = next_pushed; + if (pushed) *pushed = next_pushed; + if (released) *released = 0; + next_pushed = 0; +} + +static uint8_t *fake_get_frame(void) { + return framebuffer; +} + +static void fake_mark_rows(int first, int last) { + marked_first[mark_count] = first; + marked_last[mark_count] = last; + mark_count++; +} + +static void fake_refresh(float rate) { + refresh_rate = rate; +} + +static int check(int condition, const char *message) { + if (condition) return 1; + fprintf(stderr, "playdate runtime test failed: %s\nlogs:\n%s", message, logs); + return 0; +} + +int main(void) { + const struct playdate_sys system = { + fake_log, + fake_set_update, + fake_buttons, + }; + const struct playdate_graphics graphics = { + fake_get_frame, + fake_mark_rows, + }; + const struct playdate_display display = { + fake_refresh, + }; + PlaydateAPI api = { + &system, + NULL, + &graphics, + NULL, + &display, + NULL, + NULL, + NULL, + NULL, + NULL, + }; + int before; + + memset(framebuffer, 0xa5, sizeof(framebuffer)); + if (!check(eventHandler(&api, kEventInit, 0) == 0, "init handler failed")) return 1; + if (!check(app_init_calls == 1 && app_flush_calls == 1, "boot app sequence is wrong")) + return 1; + if (!check(refresh_rate == 30.0f && installed_update, "30 Hz update callback was not installed")) + return 1; + if (!check(mark_count == 1 && marked_first[0] == 0 && marked_last[0] == 239, "first frame is not full")) + return 1; + if (!check(strstr(logs, "PVREADY target=playdate") != NULL, "PVREADY receipt missing")) + return 1; + if (!check(framebuffer[50] == 0xa5 && framebuffer[51] == 0xa5, "first-frame padding changed")) + return 1; + + before = app_flush_calls; + next_pushed = (PDButtons)(kButtonA | kButtonLeft | kButtonDown); + if (!check(installed_update(installed_userdata) == 1, "button update did not paint")) return 1; + if (!check(app_flush_calls == before + 1, "button batch flushed more than once")) return 1; + if (!check( + button_count == 3 && buttons[0] == 0 && buttons[1] == 5 && buttons[2] == 7, + "physical buttons were not normalized in deterministic order")) + return 1; + + before = mark_count; + eventHandler(&api, kEventResume, 0); + if (!check(installed_update(installed_userdata) == 1, "resume did not force a repaint")) + return 1; + if (!check( + mark_count == before + 1 && marked_first[before] == 0 && marked_last[before] == 239, + "resume repaint was not full-screen")) + return 1; + + vp_grid_ch[0][0] = 0; + vp_rows_dirty |= 1; + if (!check(installed_update(installed_userdata) == 0, "invalid cell reported a painted frame")) + return 1; + if (!check((vp_tripwires & VP_TRIP_PLATFORM_RENDER) != 0, "render tripwire was not set")) + return 1; + if (!check((vp_rows_dirty & 1) != 0, "failed render cleared dirty state")) return 1; + if (!check(strstr(logs, "PVERROR stage=render") != NULL, "render error receipt missing")) + return 1; + + puts("playdate runtime: ok"); + return 0; +} diff --git a/vapor/tests/harness/playdate_sdk/pd_api.h b/vapor/tests/harness/playdate_sdk/pd_api.h new file mode 100644 index 00000000..16d205c8 --- /dev/null +++ b/vapor/tests/harness/playdate_sdk/pd_api.h @@ -0,0 +1,60 @@ +#ifndef POCKET_VAPOR_PLAYDATE_SDK_SHIM_H +#define POCKET_VAPOR_PLAYDATE_SDK_SHIM_H + +#include + +typedef enum { + kButtonLeft = 1 << 0, + kButtonRight = 1 << 1, + kButtonUp = 1 << 2, + kButtonDown = 1 << 3, + kButtonB = 1 << 4, + kButtonA = 1 << 5, +} PDButtons; + +typedef enum { + kEventInit, + kEventInitLua, + kEventLock, + kEventUnlock, + kEventPause, + kEventResume, + kEventTerminate, + kEventKeyPressed, + kEventKeyReleased, + kEventLowPower, + kEventMirrorStarted, + kEventMirrorEnded, +} PDSystemEvent; + +typedef int PDCallbackFunction(void *userdata); + +struct playdate_sys { + void (*logToConsole)(const char *fmt, ...); + void (*setUpdateCallback)(PDCallbackFunction *update, void *userdata); + void (*getButtonState)(PDButtons *current, PDButtons *pushed, PDButtons *released); +}; + +struct playdate_graphics { + uint8_t *(*getFrame)(void); + void (*markUpdatedRows)(int start, int end); +}; + +struct playdate_display { + void (*setRefreshRate)(float rate); +}; + +typedef struct PlaydateAPI { + const struct playdate_sys *system; + const void *file; + const struct playdate_graphics *graphics; + const void *sprite; + const struct playdate_display *display; + const void *sound; + const void *lua; + const void *json; + const void *scoreboards; + const void *network; +} PlaydateAPI; + +#endif diff --git a/vapor/tests/playdate.test.ts b/vapor/tests/playdate.test.ts new file mode 100644 index 00000000..6b1f58d2 --- /dev/null +++ b/vapor/tests/playdate.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { compileVaporApp, VAPOR_TARGETS } from "../compiler/compile.ts"; +import { + playdateBuildId, + resolvePlaydateSdk, +} from "../compiler/playdate.ts"; +import { FONT8 } from "../compiler/font.gen.ts"; + +const RUNTIME = join(import.meta.dir, "..", "runtime"); +const SIX_BUTTON = join( + import.meta.dir, + "..", + "examples", + "playdate-six-button", + "playdate-six-button.tsx", +); +const TODO = join(import.meta.dir, "..", "examples", "todo", "todo.tsx"); + +describe("playdate compiler target", () => { + test("uses the 50x30 grid and emits only 1bpp two-style data", async () => { + expect(VAPOR_TARGETS.playdate).toEqual({ + name: "playdate", + width: 50, + height: 30, + poolCap: 32, + strCap: 24, + }); + const source = await Bun.file(SIX_BUTTON).text(); + const app = compileVaporApp(SIX_BUTTON, source, "PLAYDATE SIX", "playdate"); + expect(app.c).toContain("/* target: playdate (50x30) */"); + const font = app.c.match(/const u8 vp_font_tiles\[\] = \{ ([^}]*) \};/); + expect(font).not.toBeNull(); + expect(font![1].split(",").map(Number)).toEqual(FONT8.flat()); + expect(font![1].split(",")).toHaveLength(95 * 8); + expect(app.c).toContain("const u8 vp_palette_count = 3;"); + expect(app.c).toContain("const u8 vp_pal_style[3]"); + expect(app.c).not.toContain("vp_ink565"); + expect(app.c).not.toContain("vp_paper565"); + expect(app.c).not.toContain("vp_palettes"); + expect(app.plan).toContain("760 B font + 3 B style data"); + expect(compileVaporApp(SIX_BUTTON, source, "PLAYDATE SIX", "playdate").c).toBe(app.c); + }); + + test("admits six physical buttons and rejects missing Playdate inputs", async () => { + const source = await Bun.file(SIX_BUTTON).text(); + const app = compileVaporApp(SIX_BUTTON, source, "PLAYDATE SIX", "playdate"); + expect(app.buttonsUsed).toEqual([0, 1, 4, 5, 6, 7]); + + const todo = await Bun.file(TODO).text(); + expect(() => compileVaporApp(TODO, todo, "TODO", "playdate")).toThrow( + /VT101: playdate has no physical input for Select, Start, R/, + ); + }); +}); + +describe("playdate build inputs", () => { + test("SDK resolution never hides an invalid explicit path", async () => { + const root = await mkdtemp(join(tmpdir(), "pocket-vapor-playdate-sdk-")); + const home = join(root, "home"); + const sdk = join(root, "sdk"); + await mkdir(join(home, ".Playdate"), { recursive: true }); + await mkdir(join(sdk, "C_API", "buildsupport"), { recursive: true }); + await mkdir(join(sdk, "bin"), { recursive: true }); + await writeFile(join(sdk, "C_API", "pd_api.h"), ""); + await writeFile(join(sdk, "C_API", "buildsupport", "playdate.cmake"), ""); + await writeFile(join(sdk, "C_API", "buildsupport", "arm.cmake"), ""); + await writeFile(join(sdk, "bin", "pdc"), ""); + await writeFile(join(sdk, "VERSION.txt"), "3.1.1\n"); + await writeFile(join(home, ".Playdate", "config"), `SDKRoot\t${sdk}\n`); + + try { + expect(resolvePlaydateSdk({}, home)).toEqual({ + path: sdk, + version: "3.1.1", + pdc: join(sdk, "bin", "pdc"), + armToolchain: join(sdk, "C_API", "buildsupport", "arm.cmake"), + }); + expect(() => + resolvePlaydateSdk({ PLAYDATE_SDK_PATH: join(root, "missing") }, home), + ).toThrow(/PLAYDATE_SDK_PATH not found/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("build identity is deterministic and source/SDK-sensitive", async () => { + const source = await Bun.file(SIX_BUTTON).text(); + const app = compileVaporApp(SIX_BUTTON, source, "PLAYDATE SIX", "playdate"); + const same = compileVaporApp(SIX_BUTTON, source, "PLAYDATE SIX", "playdate"); + const changed = compileVaporApp( + SIX_BUTTON, + source.replace("value.value + 1", "value.value + 2"), + "PLAYDATE SIX", + "playdate", + ); + const id = await playdateBuildId(app, "3.1.1"); + expect(id).toMatch(/^[0-9a-f]{16}$/); + expect(await playdateBuildId(same, "3.1.1")).toBe(id); + expect(await playdateBuildId(changed, "3.1.1")).not.toBe(id); + expect(await playdateBuildId(app, "3.1.2")).not.toBe(id); + }); + + test("native source manifest contains no interpreter runtime", async () => { + const cmake = await Bun.file(join(RUNTIME, "playdate", "CMakeLists.txt")).text(); + expect(cmake).toContain("vapor_core.c"); + expect(cmake).toContain("vapor_playdate.c"); + expect(cmake).not.toMatch(/quickjs|wamr|wasm-micro-runtime/i); + }); +}); + +test("playdate framebuffer C unit", async () => { + const cc = Bun.which("cc"); + expect(cc).not.toBeNull(); + const root = await mkdtemp(join(tmpdir(), "pocket-vapor-playdate-frame-")); + const binary = join(root, "framebuffer-test"); + try { + const compile = Bun.spawn( + [ + cc!, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + `-I${join(RUNTIME, "playdate")}`, + join(RUNTIME, "playdate", "framebuffer.c"), + join(import.meta.dir, "harness", "playdate_framebuffer_test.c"), + "-o", + binary, + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const [compileOut, compileErr, compileCode] = await Promise.all([ + new Response(compile.stdout).text(), + new Response(compile.stderr).text(), + compile.exited, + ]); + expect(`${compileOut}${compileErr}`).toBe(""); + expect(compileCode).toBe(0); + + const run = Bun.spawn([binary], { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, code] = await Promise.all([ + new Response(run.stdout).text(), + new Response(run.stderr).text(), + run.exited, + ]); + expect(stderr).toBe(""); + expect(code).toBe(0); + expect(stdout).toBe("playdate framebuffer: ok\n"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("playdate lifecycle and input C unit", async () => { + const cc = Bun.which("cc"); + expect(cc).not.toBeNull(); + const root = await mkdtemp(join(tmpdir(), "pocket-vapor-playdate-runtime-")); + const binary = join(root, "runtime-test"); + try { + const compile = Bun.spawn( + [ + cc!, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-DVP_GRID_W=50", + "-DVP_GRID_H=30", + "-DVP_STR_CAP=24", + "-DVP_VIEW_CAP=32", + `-I${join(import.meta.dir, "harness", "playdate_sdk")}`, + `-I${RUNTIME}`, + `-I${join(RUNTIME, "playdate")}`, + join(RUNTIME, "vapor_core.c"), + join(RUNTIME, "playdate", "framebuffer.c"), + join(RUNTIME, "playdate", "vapor_playdate.c"), + join(import.meta.dir, "harness", "playdate_runtime_test.c"), + "-o", + binary, + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const [compileOut, compileErr, compileCode] = await Promise.all([ + new Response(compile.stdout).text(), + new Response(compile.stderr).text(), + compile.exited, + ]); + expect(`${compileOut}${compileErr}`).toBe(""); + expect(compileCode).toBe(0); + + const run = Bun.spawn([binary], { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, code] = await Promise.all([ + new Response(run.stdout).text(), + new Response(run.stderr).text(), + run.exited, + ]); + expect(stderr).toBe(""); + expect(code).toBe(0); + expect(stdout).toBe("playdate runtime: ok\n"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/vapor/tests/styles.test.ts b/vapor/tests/styles.test.ts index 3a2ccbea..0ddc1f51 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"); @@ -63,6 +70,9 @@ describe("per-target lowering", () => { expect(table.lower("gb").issues.some((i) => i.code === "VS104" && i.severity === "warn")).toBe(true); expect(table.lower("gb", true).issues.some((i) => i.code === "VS104" && i.severity === "error")).toBe(true); expect(table.lower("gba").issues).toEqual([]); + expect(STYLE_CAPS.playdate).toEqual({ kind: "styles2" }); + expect(table.lower("playdate").issues.some((i) => i.code === "VS104")).toBe(true); + expect(table.lower("playdate", true).issues.some((i) => i.severity === "error")).toBe(true); }); test("esp32 preserves every pair as an RGB565 table index", () => { @@ -76,6 +86,15 @@ describe("per-target lowering", () => { expect(rgb565(0x00ff00)).toBe(0x07e0); expect(rgb565(0x0000ff)).toBe(0x001f); }); + + test("playdate preview uses black and white, not the DMG green ramp", () => { + const table = new StyleTable(); + table.resolveClass("bg-white text-black"); + const css = styleTableCss(table, "playdate"); + expect(css).toContain("color: #ffffff; background: #000000"); + expect(css).toContain("color: #000000; background: #ffffff"); + expect(css).not.toContain("#9bbc0f"); + }); }); describe("compile-time style diagnostics", () => { diff --git a/vapor/tsconfig.json b/vapor/tsconfig.json index 53d10b0e..b23ce4af 100644 --- a/vapor/tsconfig.json +++ b/vapor/tsconfig.json @@ -1,6 +1,6 @@ { // Editors + `bunx tsc --noEmit -p vapor` typecheck ONLY. The JSX transform - // is owned by vue-jsx-vapor (oracle) and the Pocket Vapor compiler (GBA); + // is owned by vue-jsx-vapor (oracle) and the Pocket Vapor AOT compiler; // both require jsx "preserve". Separate from the root tsconfig because the // intrinsic must not leak into the Solid/PSP JSX surface. "compilerOptions": { From 2ab10e6a47bbd6f6d652abe7982250d89a824c19 Mon Sep 17 00:00:00 2001 From: colmugx Date: Thu, 30 Jul 2026 18:51:29 +0800 Subject: [PATCH 2/5] feat(vapor): add target-neutral buildArtifact helper and relative axis handlers --- vapor/compiler/cli.ts | 47 +++++++++-- vapor/compiler/compile.ts | 169 ++++++++++++++++++++++++++++++++++---- vapor/compiler/rom.ts | 53 +++++++++++- 3 files changed, 245 insertions(+), 24 deletions(-) diff --git a/vapor/compiler/cli.ts b/vapor/compiler/cli.ts index 931e1a94..3f834956 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 @@ -15,7 +16,8 @@ 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 { buildArtifact } 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 buildArtifact(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..b50d0cd5 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,13 @@ 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 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 +415,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 +913,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 +940,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 +1007,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 } { @@ -1202,7 +1273,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 +1319,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 +1831,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 +1939,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 +2045,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 +2095,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 +2169,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 +2201,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 +2224,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 +2252,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 +2339,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 +2351,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/rom.ts b/vapor/compiler/rom.ts index 53bad6f1..841da9e6 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"; @@ -49,10 +54,56 @@ 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") { + throw new Error("Playdate produces .pdx packages; use buildArtifact()"); + } target satisfies never; throw new Error(`unsupported Pocket Vapor target: ${String(target)}`); } +export interface BuiltArtifact { + path: string; + kind: "rom" | "firmware" | "pdx"; + bytes: number; + platform?: "simulator" | "device"; +} + +export interface BuildArtifactOptions { + playdateMode?: PlaydateBuildMode; +} + +/** Target-neutral artifact dispatch. Playdate `both` intentionally returns + * two independent packages because a .pdx cannot host both device and + * Simulator native binaries. */ +export async function buildArtifact( + app: CompiledApp, + target: VaporTargetName, + outPath: string, + options: BuildArtifactOptions = {}, +): Promise { + if (target === "playdate") { + const packages = await buildPlaydatePackages( + app, + outPath, + options.playdateMode ?? "simulator", + ); + return packages.map(({ path, kind, platform, bytes }) => ({ + path, + kind, + platform, + bytes, + })); + } + const { romBytes } = await buildRom(app, target, outPath); + return [ + { + path: outPath, + kind: target === "esp32" ? "firmware" : "rom", + bytes: romBytes, + }, + ]; +} + // ---- GBA ------------------------------------------------------------------- export async function buildGbaRom(app: CompiledApp, outRom: string): Promise<{ romBytes: number }> { From 0f1b851aff6f8aa42fc53e1cbe8bbe7c3da3e9d9 Mon Sep 17 00:00:00 2001 From: colmugx Date: Thu, 30 Jul 2026 22:10:23 +0800 Subject: [PATCH 3/5] feat(playdate): integrate Playdate target, host input, and example apps --- package.json | 3 + vapor/README.md | 63 ++++- vapor/compiler/cli.ts | 4 +- vapor/compiler/compile.ts | 13 +- vapor/compiler/rom.ts | 69 +++--- .../playdate-six-button.tsx | 32 +++ vapor/examples/todo/todo.playdate.tsx | 228 ++++++++++++++++++ vapor/host/input.ts | 68 +++++- vapor/oracle/boot.ts | 7 + vapor/oracle/entry.ts | 10 +- vapor/runtime/playdate/vapor_playdate.c | 49 ++++ vapor/runtime/vapor.h | 6 + vapor/scripts/dev.ts | 32 ++- vapor/tests/harness/playdate_runtime_test.c | 93 ++++++- vapor/tests/harness/playdate_sdk/pd_api.h | 2 + vapor/tests/playdate.test.ts | 80 ++++++ 16 files changed, 702 insertions(+), 57 deletions(-) create mode 100644 vapor/examples/playdate-six-button/playdate-six-button.tsx create mode 100644 vapor/examples/todo/todo.playdate.tsx 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/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 3f834956..cf5010b1 100644 --- a/vapor/compiler/cli.ts +++ b/vapor/compiler/cli.ts @@ -16,7 +16,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 { buildArtifact } from "./rom.ts"; +import { buildRom } from "./rom.ts"; import type { PlaydateBuildMode } from "./playdate.ts"; let args = process.argv.slice(2); @@ -161,7 +161,7 @@ const ext = ? null : target satisfies never; const output = ext ? join(outDir, `${name}.${ext}`) : join(outDir, name); -const artifacts = await buildArtifact(app, target, output, { playdateMode }); +const artifacts = await buildRom(app, target, output, { playdateMode }); await Bun.write(join(outDir, `${name}.${target}.debug.json`), JSON.stringify(app.debugSlots, null, 2)); for (const artifact of artifacts) { const platform = artifact.platform ? `/${artifact.platform}` : ""; diff --git a/vapor/compiler/compile.ts b/vapor/compiler/compile.ts index b50d0cd5..9ae59949 100644 --- a/vapor/compiler/compile.ts +++ b/vapor/compiler/compile.ts @@ -323,6 +323,12 @@ class AppCompiler { 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}`); @@ -1162,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) && @@ -1171,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) diff --git a/vapor/compiler/rom.ts b/vapor/compiler/rom.ts index 841da9e6..0aeca0a0 100644 --- a/vapor/compiler/rom.ts +++ b/vapor/compiler/rom.ts @@ -45,22 +45,6 @@ function targetDefines(target: VaporTargetName): string[] { ]; } -export async function buildRom( - 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); - if (target === "playdate") { - throw new Error("Playdate produces .pdx packages; use buildArtifact()"); - } - target satisfies never; - throw new Error(`unsupported Pocket Vapor target: ${String(target)}`); -} - export interface BuiltArtifact { path: string; kind: "rom" | "firmware" | "pdx"; @@ -68,18 +52,50 @@ export interface BuiltArtifact { platform?: "simulator" | "device"; } -export interface BuildArtifactOptions { +export interface BuildRomOptions { playdateMode?: PlaydateBuildMode; } -/** Target-neutral artifact dispatch. Playdate `both` intentionally returns - * two independent packages because a .pdx cannot host both device and - * Simulator native binaries. */ -export async function buildArtifact( +type SingleArtifactTarget = Exclude; + +async function buildSingleArtifact( + app: CompiledApp, + 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: BuildArtifactOptions = {}, + options: BuildRomOptions = {}, ): Promise { if (target === "playdate") { const packages = await buildPlaydatePackages( @@ -94,14 +110,7 @@ export async function buildArtifact( bytes, })); } - const { romBytes } = await buildRom(app, target, outPath); - return [ - { - path: outPath, - kind: target === "esp32" ? "firmware" : "rom", - bytes: romBytes, - }, - ]; + return [await buildSingleArtifact(app, target, outPath)]; } // ---- GBA ------------------------------------------------------------------- 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 387c42ab..bc1b1414 100644 --- a/vapor/host/input.ts +++ b/vapor/host/input.ts @@ -3,12 +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 shared Pocket pad ABI. Playdate directly exposes -// A/B/Right/Left/Up/Down and rejects Select/Start/R/L demands at compile -// time; it never invents chords for missing physical inputs. +// 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, @@ -29,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/oracle/boot.ts b/vapor/oracle/boot.ts index 5f47d31e..bbb0cc49 100644 --- a/vapor/oracle/boot.ts +++ b/vapor/oracle/boot.ts @@ -40,6 +40,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; @@ -63,6 +65,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 +78,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.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/vapor_playdate.c b/vapor/runtime/playdate/vapor_playdate.c index 957f0bb8..70428b29 100644 --- a/vapor/runtime/playdate/vapor_playdate.c +++ b/vapor/runtime/playdate/vapor_playdate.c @@ -27,6 +27,8 @@ 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) { @@ -102,6 +104,42 @@ static void dispatch_pushed(PDButtons pushed) { 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; @@ -110,6 +148,7 @@ static int update(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++; @@ -144,10 +183,17 @@ int eventHandler(PlaydateAPI *playdate, PDSystemEvent event, uint32_t arg) { 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); @@ -160,6 +206,7 @@ int eventHandler(PlaydateAPI *playdate, PDSystemEvent event, uint32_t arg) { stopped = 1; return 0; } + reset_crank_input("init"); pd->display->setRefreshRate(30.0f); pd->system->setUpdateCallback(update, NULL); pd->system->logToConsole( @@ -172,9 +219,11 @@ int eventHandler(PlaydateAPI *playdate, PDSystemEvent event, uint32_t arg) { (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: diff --git a/vapor/runtime/vapor.h b/vapor/runtime/vapor.h index e8015379..a37c758a 100644 --- a/vapor/runtime/vapor.h +++ b/vapor/runtime/vapor.h @@ -95,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 */ diff --git a/vapor/scripts/dev.ts b/vapor/scripts/dev.ts index 7d1cf239..0c6ad16a 100644 --- a/vapor/scripts/dev.ts +++ b/vapor/scripts/dev.ts @@ -10,7 +10,9 @@ // 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"; @@ -45,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); @@ -92,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({ @@ -122,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; } @@ -132,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°