From 0fc469f08bcd8dfff2b450e4c23142b423d54b76 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Fri, 24 Jul 2026 13:31:56 +0800 Subject: [PATCH 1/9] docs(switch): plan the native homebrew port --- docs/SWITCH.md | 468 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 docs/SWITCH.md diff --git a/docs/SWITCH.md b/docs/SWITCH.md new file mode 100644 index 0000000..5c89aaf --- /dev/null +++ b/docs/SWITCH.md @@ -0,0 +1,468 @@ +# Nintendo Switch port plan + +Status: planning only. This document does not add a Switch target or runtime. + +## Goal + +Ship PocketJS applications as Nintendo Switch homebrew `.nro` files and run +them in Ryujinx and, later, on homebrew-enabled hardware. + +The first compatibility target is the current PSP application surface: + +- all 17 PSP-admissible demos; +- the Pocket Launcher and whole-guest app switching; +- `input.buttons`, `input.analog.left`, `input.cursor`, and + `text.glyphs.baked`; +- the existing 480x272 logical viewport and deterministic frame contract. + +The 17 applications are `cafe`, `cards`, `chrome`, `cursor`, `gallery`, `hero`, +`hero-vue-sfc`, `hero-vue-vapor`, `im`, `library`, `motions`, `music`, +`notifications`, `settings`, `stats`, `vue-sfc-lab`, and `zoomlab`. +`ipod-nano` and `note` are not PSP-admissible today and are not part of this +port's initial acceptance set. + +## Corrections to the initial proposal + +The direction was right—Switch is a new guest runtime target—but three details +need changing: + +1. PocketJS does not currently have a generic `PocketHost` TypeScript + interface implemented by each native host. Applications compile against a + target profile and a `ResolvedBuildPlan`; native QuickJS hosts install the + synchronous `globalThis.ui` `HostOps` ABI and consume the Rust core's + `DrawList`. +2. A new target therefore touches more than `hosts/switch`: the target + registry, backend dispatch, build/package tooling, runtime ABI checks, + launcher admission, and target tests are all part of the port. +3. The official Switch OpenGL examples use EGL plus Mesa's OpenGL 4.3 core + profile, not OpenGL ES 3.2. `deko3d` is the lower-level native alternative. + Neither should be a day-one dependency before the existing deterministic + software rasterizer is measured. + +Adding another desktop simulator is also out of scope. `hosts/web` and +`hosts/sim` already provide the fast development and deterministic test loops; +Ryujinx supplies the Switch-specific integration loop. + +## Existing architecture + +```text + pocket.json target profile + └──── resolve ────┘ + │ + ▼ + .pocket//plan.json + │ + ┌─────┴──────────┐ + ▼ ▼ + JS/pak compiler native backend + │ │ + └──── embed ─────┘ + │ + ▼ + native package +``` + +The reusable boundaries are: + +- `contracts/spec/platforms.ts`: capabilities and stock target facts; +- `framework/src/host.ts`: the JavaScript/native `HostOps` contract and frame + callback; +- `engine/core`: the `no_std` retained UI core, `DrawList`, and deterministic + software rasterizer; +- `hosts/psp` and `hosts/vita`: QuickJS embedding, pak feeding, input, + rendering, and guest lifecycle; +- `tools/pocket.ts`: one resolver/compiler path followed by typed native + backend dispatch. + +The Switch port must reuse these boundaries. It must not introduce a parallel +framework API or a Switch branch in application code. + +## Proposed Switch architecture + +The first implementation should use the standard devkitPro/libnx build and +packaging path, with a thin C shell around a Rust static library: + +```text + PocketJS build plan + │ + ┌───────────┴───────────┐ + ▼ ▼ + app.js + app.pak host build inputs + │ │ + └────── NRO RomFS ──────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ pocketjs-switch.nro │ + │ │ + │ libnx C shell │ + │ applet loop · framebuffer · PadState · RomFS · logging │ + │ │ │ + │ ▼ │ + │ Rust static library │ + │ QuickJS · globalThis.ui · pak feed · pocketjs-core │ + │ │ │ + │ ▼ │ + │ deterministic DrawList rasterizer │ + └─────────────────────────────────────────────────────────────┘ +``` + +This split is a spike decision, not yet a proven build: + +- libnx owns the established NRO startup, services, input, framebuffer, and + packaging path; +- Rust retains the existing core and most of the PSP/Vita QuickJS `HostOps` + implementation; +- QuickJS and Rust can resolve C runtime symbols from devkitA64/newlib at the + final link; +- application artifacts live in NRO RomFS, avoiding another generated Rust + byte table during the first port. + +The pure-Rust `cargo-nx` route is not the initial choice. Its official Rust +target is Tier 3 and useful, but PocketJS also needs QuickJS C/newlib +integration. Replacing the proven libnx startup and packaging path does not +reduce the first port's risk. + +### Display contract + +The proposed initial profile is: + +```text +target id switch +platform switch +form takeover +host ABI 4 +physical viewport 1280x720 +logical viewport 480x272 +presentation integer-fit +raster density 2 +``` + +`host ABI = 4` is a proposed new native contract identity; it is not assigned +until the host exists and its tests pass. + +Integer-fit produces a 960x544 content surface centered in the 1280x720 Switch +framebuffer: + +```text +1280x720 +┌──────────────────────────────────────────────────────────────┐ +│ 88 px │ +│ ┌────────────────────────────────────────────┐ │ +│ 160 px │ 960x544 content │ 160 px │ +│ │ 480x272 logical viewport at 2x │ │ +│ └────────────────────────────────────────────┘ │ +│ 88 px │ +└──────────────────────────────────────────────────────────────┘ +``` + +This preserves the manifests' existing `integer-fit` promise and produces the +same density-2 raster inputs as Vita. Stretching to 16:9 would be visually +small but contractually wrong. A future `fit` presentation should be designed +as an explicit manifest/profile feature rather than silently changing +`integer-fit`. + +### Input mapping + +The Switch shell maps libnx `PadState` to the existing PocketJS button mask: + +| Switch input | PocketJS input | +| --- | --- | +| D-pad | Up, down, left, right | +| A | Circle / confirm | +| B | Cross / cancel | +| X | Triangle | +| Y | Square | +| Plus | Start | +| Minus | Select / launcher summon | +| L or ZL | Left trigger | +| R or ZR | Right trigger | +| Left stick | Packed analog value, axes 0–255, center 128 | + +The initial target advertises only capabilities that have end-to-end tests. +Touch, right analog, motion, rumble, audio, networking, and service mailboxes +do not block PSP parity and must not be advertised speculatively. + +### Rendering ladder + +Stop at the first renderer that sustains the required workload: + +1. Use `pocketjs_core::raster::render_scaled(..., 2)` to produce the existing + deterministic 960x544 RGBA surface, then copy it into the centered libnx + 1280x720 linear framebuffer. +2. Measure release builds in Ryujinx and on hardware. Keep this renderer if it + sustains 60 FPS with the current golden applications. +3. Only if the measurement fails, add an OpenGL 4.3 `DrawList` backend using + `switch-mesa` and `switch-glad`. +4. Consider direct `deko3d` only if the OpenGL backend has a measured size, + latency, or compatibility problem. + +The software renderer is already the byte-exact oracle used by web and Vita +capture tests. Starting there gives correct gradients, glyphs, clipping, +textures, sprites, and transformed triangles before taking on a second +problem: a new GPU backend. + +## Local workstation audit + +Observed on 2026-07-24: + +- Apple Silicon macOS; +- Ryujinx at `/Applications/Ryujinx.app`, build `1.3.3-e2143d4`; +- Ryujinx has created its normal application support directory and recognizes + `.nro` as a document type; +- Bun, CMake, Ninja, Homebrew, and rustup are present; +- devkitPro, devkitA64, libnx, `elf2nro`, and `nxlink` are not installed; +- the local `nightly-2026-05-28` directory is present, but rustup reports a + missing manifest, so it cannot be treated as a valid Switch toolchain. + +No dependency was installed while preparing this plan. + +## Toolchain bootstrap + +The supported macOS path is devkitPro pacman plus the `switch-dev` package +group. `switch-dev` includes devkitA64, libnx, Switch tools, deko3d, and the +official examples. The first bootstrap should not install Mesa, `cargo-nx`, or +unrelated Switch port libraries. + +Planned commands: + +```sh +curl -fLO \ + https://github.com/devkitPro/pacman/releases/download/v6.0.2/devkitpro-pacman-installer.pkg +sudo installer -pkg devkitpro-pacman-installer.pkg -target / +sudo dkp-pacman -Syu +sudo dkp-pacman -S --needed switch-dev +``` + +The PocketJS build tool should supply its own environment instead of requiring +shell startup-file edits: + +```sh +export DEVKITPRO=/opt/devkitpro +export DEVKITA64="$DEVKITPRO/devkitA64" +export PATH="$DEVKITA64/bin:$DEVKITPRO/tools/bin:$PATH" +``` + +Bootstrap verification: + +```sh +aarch64-none-elf-gcc --version +elf2nro --help +nxlink --help +make -C /opt/devkitpro/examples/switch/graphics/simplegfx +``` + +The resulting official `simplegfx.nro` must boot in the installed Ryujinx +before PocketJS code is added. This isolates emulator/toolchain setup from +PocketJS integration. Proprietary firmware, keys, or SDK files must not be +added to the repository; the first Ryujinx spike determines whether this +homebrew-only path needs any user-local emulator setup. + +Rust requirements for the static-library spike: + +- a pinned nightly toolchain; +- `rust-src`; +- `-Z build-std=core,alloc`; +- `aarch64-nintendo-switch-freestanding`; +- panic abort and position-independent AArch64 code compatible with the libnx + final link. + +Use the repository's pinned nightly date if it supports the target after a +clean rustup install. Change the date only for a demonstrated compiler or +target failure. + +## Implementation milestones + +### M0 — toolchain and emulator proof + +Scope: + +- install only devkitPro pacman and `switch-dev`; +- repair/install the pinned Rust nightly with `rust-src`; +- build the official `simplegfx` example; +- boot its NRO directly in Ryujinx; +- record exact tool versions and the successful Ryujinx invocation. + +Exit criteria: + +- one reproducible command builds an official NRO; +- one reproducible command boots it in Ryujinx; +- no PocketJS source change is needed to diagnose toolchain failures. + +### M1 — target contract + +Scope: + +- add a truthful `switch` profile to `contracts/spec/platforms.ts`; +- add Switch to platform-contract fixtures and the demo admission matrix; +- add a typed backend entry to `tools/pocket.ts`; +- expose the Switch host files through the npm package allowlist only where a + downstream build actually consumes them. + +Exit criteria: + +- `pocket check --target switch` admits the 17 PSP applications and launcher; +- unsupported viewport/capability combinations still fail; +- existing PSP, Vita, and macOS target tests remain unchanged and green. + +### M2 — NRO shell and Rust/QuickJS link spike + +Scope: + +- create the smallest `hosts/switch` build; +- link one Rust `no_std + alloc` static library into a libnx NRO; +- prove C-to-Rust calls, Rust allocation, panic abort, QuickJS creation, and + JavaScript evaluation; +- load `app.js` and `app.pak` from RomFS; +- draw a solid diagnostic frame and read buttons. + +The current pinned `pocket-stack/quickjs-rs` build script special-cases PSP and +Vita but not Switch. The spike must determine the minimal upstreamable change: +target compiler selection, `__SWITCH__`/newlib guards, and archive linkage. Do +not fork the entire runtime or add a second JavaScript engine. + +Exit criteria: + +- a self-contained NRO evaluates a trivial embedded script in Ryujinx; +- Plus exits cleanly to the emulator; +- failures are visible through stderr/nxlink-compatible logging. + +### M3 — first PocketJS frame + +Scope: + +- port the native `HostOps` registration and frame lifecycle from the + PSP/Vita host; +- feed styles, fonts, images, and sprites from the normal pak; +- call the existing density-2 software rasterizer; +- map buttons and the left stick; +- build and boot `hero`. + +Exit criteria: + +- `hero` renders and responds to input in Ryujinx; +- the bundled target id and host ABI handshake passes; +- the captured density-2 content frame matches the deterministic oracle. + +### M4 — framework parity and developer loop + +Scope: + +- implement every mandatory current `HostOps` operation; +- add cursor hit testing and cursor drawing; +- add `bun switch ` for low-level host development; +- add `pocket build --target switch`; +- extend `bun play switch ` to build and boot the exact NRO in Ryujinx; +- package app title, version, and a valid NACP/icon into the NRO. + +Exit criteria: + +- all 17 applications build as NROs; +- the current PSP capability surface behaves on Switch; +- stale NROs cannot be launched accidentally by `play`; +- the release `hero` frame loop sustains 60 FPS in Ryujinx. + +### M5 — automated Ryujinx regression proof + +Scope: + +- add a capture feature with scripted input, following the Vita E2E pattern; +- write raw density-2 content frames to a dedicated path under Ryujinx's + virtual SD card; +- launch only the spawned Ryujinx process and wait for a `done` or error file; +- compare existing golden scenarios to the deterministic density-2 oracle; +- smoke-build and boot every PSP-admissible application. + +Do not make GUI screenshots the pixel oracle. Emulator window chrome, display +scaling, and host color management are not PocketJS output. + +Exit criteria: + +- the existing golden scenarios pass through the Switch QuickJS/input/frame + loop; +- every PSP-admissible demo builds; +- emulator crashes and timeouts are reported with the app name and log path. + +### M6 — launcher parity + +Scope: + +- extend launcher target admission and package thinning to Switch; +- embed the launcher plus every Switch-admitted `.pocket` package in RomFS; +- port whole-guest teardown, app table, launch request, and frozen shot; +- map Minus to the existing Select summon behavior. + +Exit criteria: + +- one NRO contains the launcher and all 17 applications; +- switching repeatedly does not retain QuickJS realms, core textures, or + guest state; +- a broken child returns to the launcher rather than terminating the NRO. + +### M7 — hardware and CI + +Scope: + +- run the same NRO on homebrew-enabled hardware through hbmenu/nxlink; +- record frame time and memory for representative heavy demos; +- add a pinned, reproducible CI Switch build; +- document hardware installation and debugging after it has been exercised. + +Exit criteria: + +- hardware and Ryujinx use the same release NRO; +- `hero`, `gallery`, `motions`, `im`, and the launcher sustain 60 FPS; +- CI publishes the NRO as a build artifact without proprietary inputs. + +## Expected change surface + +The implementation is expected to touch: + +- `contracts/spec/platforms.ts`; +- `tests/platform-contracts.test.ts` and plan fixtures; +- `tools/pocket.ts`, `tools/play.ts`, and a small `tools/switch.ts`; +- `tools/launcher.ts`; +- `hosts/switch/`; +- `package.json` scripts and package file allowlist; +- Switch build, contract, and Ryujinx E2E tests; +- this document and the command reference after commands are real. + +`engine/core` and application sources should not change for the first working +port. A change there requires a cross-platform defect or a missing reusable +boundary demonstrated by the Switch spike. + +## Pull request sequence + +Keep each step independently reviewable: + +1. `feat(switch): add the target contract and NRO shell` +2. `feat(switch): run the PocketJS guest in Ryujinx` +3. `feat(switch): complete rendering and input parity` +4. `test(switch): add Ryujinx regression coverage` +5. `feat(switch): port the Pocket Launcher` +6. `ci(switch): publish reproducible NRO artifacts` + +If the M2 link spike disproves the C-shell/Rust-static-library split, stop and +update this document with the measured failure before expanding the design. + +## Risks and gates + +| Risk | Gate | +| --- | --- | +| devkitPro on the current macOS beta | Official `simplegfx.nro` builds before PocketJS work | +| Ryujinx NRO launch or virtual SD behavior | M0 records a working invocation and M5 proves file-based completion | +| Rust Tier-3 target and libnx final link | M2 calls an allocating Rust function from the NRO before QuickJS work | +| QuickJS/newlib build assumptions | M2 creates/evaluates/destroys one realm before `HostOps` is ported | +| Software rasterizer performance | Release frame-time measurement decides whether OpenGL is added | +| 16:9 versus 480x272 presentation | Explicit centered integer-fit contract; no silent stretch | +| Guest-switch resource leaks | Repeated launcher switching plus handle/realm counters | +| Scope creep into Switch-only APIs | Initial profile advertises PSP parity only | + +## Primary references + +- [devkitPro Getting Started](https://devkitpro.org/wiki/Getting_Started) +- [libnx documentation](https://switchbrew.github.io/libnx/) +- [official Switch examples](https://github.com/switchbrew/switch-examples) +- [deko3d](https://github.com/devkitPro/deko3d) +- [Rust Switch target support](https://doc.rust-lang.org/rustc/platform-support/aarch64-nintendo-switch-freestanding.html) +- [cargo-nx](https://github.com/aarch64-switch-rs/cargo-nx) From fe3e4b98034997f227adafe3a97aeb0e496e0c8e Mon Sep 17 00:00:00 2001 From: zerob13 Date: Fri, 24 Jul 2026 13:59:02 +0800 Subject: [PATCH 2/9] feat(switch): add target contract and NRO shell --- .gitignore | 7 ++ contracts/spec/platforms.ts | 18 +++++ docs/SWITCH.md | 29 +++++-- framework/src/manifest/resolve.ts | 9 ++- hosts/switch/Makefile | 72 ++++++++++++++++++ hosts/switch/source/main.c | 78 +++++++++++++++++++ package.json | 3 + tests/npm-package.test.ts | 2 + tests/platform-contracts.test.ts | 87 ++++++++++++++------- tools/pocket.ts | 14 ++++ tools/switch.ts | 122 ++++++++++++++++++++++++++++++ 11 files changed, 404 insertions(+), 37 deletions(-) create mode 100644 hosts/switch/Makefile create mode 100644 hosts/switch/source/main.c create mode 100644 tools/switch.ts diff --git a/.gitignore b/.gitignore index 34bc5c5..3208f01 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,13 @@ apps/launcher/covers/ hosts/web/pocketjs.wasm # generated per-demo XMB metadata (tools/psp.ts, from apps//psp/Psp.toml) hosts/psp/Psp.toml +# generated Switch host inputs and devkitPro outputs +hosts/switch/romfs/ +hosts/switch/build/ +hosts/switch/pocketjs-switch.elf +hosts/switch/pocketjs-switch.map +hosts/switch/pocketjs-switch.nacp +hosts/switch/pocketjs-switch.nro # Obsolete checkout-local SDK location; bootstrap uses the shared cache. mipsel-sony-psp/ # golden-mismatch dumps written by tests/golden.ts diff --git a/contracts/spec/platforms.ts b/contracts/spec/platforms.ts index 7886a28..08ad7cf 100644 --- a/contracts/spec/platforms.ts +++ b/contracts/spec/platforms.ts @@ -162,6 +162,7 @@ export type PocketCapabilityId = CapabilityId; export const POCKET_TARGETS = defineTargetRegistry; readonly vita: TargetProfile; + readonly switch: TargetProfile; readonly pocketbook: TargetProfile; readonly "macos-widget": TargetProfile; }>({ @@ -202,6 +203,23 @@ export const POCKET_TARGETS = defineTargetRegistry` embeds the + expected ASET payload. The PocketJS Makefile uses the working explicit path. + +The reproducible emulator invocation is: -No dependency was installed while preparing this plan. +```sh +/Applications/Ryujinx.app/Contents/MacOS/Ryujinx /path/to/application.nro +``` + +Ryujinx currently stops before executing even the official example with +`RYU-0001: Keys not found`. M0 remains open until the user supplies a legally +dumped `prod.keys` in the local Ryujinx system directory. No proprietary input +belongs in this repository. ## Toolchain bootstrap diff --git a/framework/src/manifest/resolve.ts b/framework/src/manifest/resolve.ts index 0953ea8..70e8c44 100644 --- a/framework/src/manifest/resolve.ts +++ b/framework/src/manifest/resolve.ts @@ -157,13 +157,14 @@ function resolveViewport( ok = false; } if (presentation === "integer-fit") { - const x = physicalViewport[0] / logical[0]; - const y = physicalViewport[1] / logical[1]; - if (!Number.isInteger(x) || x < 1 || x !== y) { + const scale = Math.floor( + Math.min(physicalViewport[0] / logical[0], physicalViewport[1] / logical[1]), + ); + if (scale < 1) { diagnostics.push({ code: "viewport.integerFitMismatch", path: fixedPath, - message: "integer-fit requires one positive integer scale on both axes", + message: "integer-fit requires the logical viewport to fit at a positive integer scale", }); ok = false; } diff --git a/hosts/switch/Makefile b/hosts/switch/Makefile new file mode 100644 index 0000000..bbc72da --- /dev/null +++ b/hosts/switch/Makefile @@ -0,0 +1,72 @@ +.SUFFIXES: + +ifeq ($(strip $(DEVKITPRO)),) +$(error "Please set DEVKITPRO (normally /opt/devkitpro)") +endif + +TOPDIR ?= $(CURDIR) +include $(DEVKITPRO)/libnx/switch_rules + +TARGET := pocketjs-switch +BUILD := build +SOURCES := source +ROMFS := romfs + +APP_TITLE ?= PocketJS +APP_AUTHOR ?= PocketJS +APP_VERSION ?= 0.1.0 + +ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE +CFLAGS := -g -Wall -Wextra -O2 -ffunction-sections $(ARCH) $(DEFINES) +CFLAGS += $(INCLUDE) -D__SWITCH__ +ASFLAGS := -g $(ARCH) +LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) \ + -Wl,-Map,$(notdir $*.map) +LIBS := -lnx +LIBDIRS := $(PORTLIBS) $(LIBNX) + +ifneq ($(BUILD),$(notdir $(CURDIR))) + +export OUTPUT := $(CURDIR)/$(TARGET) +export TOPDIR := $(CURDIR) +export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir)) +export DEPSDIR := $(CURDIR)/$(BUILD) + +CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c))) +SFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.s))) + +export LD := $(CC) +export OFILES := $(CFILES:.c=.o) $(SFILES:.s=.o) +export INCLUDE := $(foreach dir,$(LIBDIRS),-I$(dir)/include) -I$(CURDIR)/$(BUILD) +export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib) + +.PHONY: all clean + +all: $(BUILD) + +$(BUILD): + @[ -d $@ ] || mkdir -p $@ + @build_romfs $(CURDIR)/$(ROMFS) $(CURDIR)/$(BUILD)/pocketjs.romfs >/dev/null + @$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile \ + NROFLAGS="--icon=$(LIBNX)/default_icon.jpg \ + --nacp=$(CURDIR)/$(TARGET).nacp \ + --romfs=$(CURDIR)/$(BUILD)/pocketjs.romfs" + +clean: + @rm -rf $(BUILD) $(TARGET).elf $(TARGET).map $(TARGET).nacp $(TARGET).nro + +else + +DEPENDS := $(OFILES:.o=.d) + +.PHONY: all + +all: $(OUTPUT).nro + +$(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp + +$(OUTPUT).elf: $(OFILES) + +-include $(DEPENDS) + +endif diff --git a/hosts/switch/source/main.c b/hosts/switch/source/main.c new file mode 100644 index 0000000..9754b1d --- /dev/null +++ b/hosts/switch/source/main.c @@ -0,0 +1,78 @@ +#include + +#include + +#define FB_WIDTH 1280 +#define FB_HEIGHT 720 +#define CONTENT_X 160 +#define CONTENT_Y 88 +#define CONTENT_WIDTH 960 +#define CONTENT_HEIGHT 544 + +static bool file_exists(const char *path) { + FILE *file = fopen(path, "rb"); + if (file == NULL) { + return false; + } + fclose(file); + return true; +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + + consoleDebugInit(debugDevice_SVC); + const Result romfs_result = romfsInit(); + const bool app_ready = R_SUCCEEDED(romfs_result) && + file_exists("romfs:/pocketjs/app.js") && + file_exists("romfs:/pocketjs/app.pak"); + printf( + "[PocketJS Switch] target=switch hostAbi=4 romfs=%s\n", + app_ready ? "ready" : "missing" + ); + fflush(stdout); + + Framebuffer framebuffer; + framebufferCreate( + &framebuffer, + nwindowGetDefault(), + FB_WIDTH, + FB_HEIGHT, + PIXEL_FORMAT_RGBA_8888, + 2 + ); + framebufferMakeLinear(&framebuffer); + + padConfigureInput(1, HidNpadStyleSet_NpadStandard); + PadState pad; + padInitializeDefault(&pad); + + while (appletMainLoop()) { + padUpdate(&pad); + if (padGetButtonsDown(&pad) & HidNpadButton_Plus) { + break; + } + + u32 stride = 0; + u32 *pixels = framebufferBegin(&framebuffer, &stride); + const u32 row_pixels = stride / sizeof(*pixels); + for (u32 y = 0; y < FB_HEIGHT; y++) { + for (u32 x = 0; x < FB_WIDTH; x++) { + const bool content = + x >= CONTENT_X && x < CONTENT_X + CONTENT_WIDTH && + y >= CONTENT_Y && y < CONTENT_Y + CONTENT_HEIGHT; + pixels[y * row_pixels + x] = content + ? (app_ready ? RGBA8_MAXALPHA(28, 120, 84) : RGBA8_MAXALPHA(160, 36, 48)) + : RGBA8_MAXALPHA(0, 0, 0); + } + } + framebufferEnd(&framebuffer); + } + + framebufferClose(&framebuffer); + if (R_SUCCEEDED(romfs_result)) { + romfsExit(); + } + return 0; +} diff --git a/package.json b/package.json index a4d387c..899447d 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "hosts/vita/Cargo.lock", "hosts/vita/README.md", "hosts/vita/rust-toolchain.toml", + "hosts/switch/Makefile", + "hosts/switch/source", "hosts/symbian/probe", "hosts/symbian/runtime", "engine/pocket3d/crates/pocket3d-vita/src", @@ -107,6 +109,7 @@ "psp:all": "bun tools/psp-all.ts", "psp:switch": "bun psplink", "vita": "bun tools/vita.ts", + "switch": "bun tools/switch.ts", "symbian": "bun tools/symbian.ts", "vita:art": "bun tools/generate-vita-livearea.ts", "vita:art:check": "bun tools/generate-vita-livearea.ts --check", diff --git a/tests/npm-package.test.ts b/tests/npm-package.test.ts index f3ce9af..a4855ab 100644 --- a/tests/npm-package.test.ts +++ b/tests/npm-package.test.ts @@ -84,6 +84,8 @@ describe("published npm artifacts", () => { "hosts/vita/assets/sce_sys/livearea/contents/bg.png", "hosts/vita/assets/sce_sys/livearea/contents/startup.png", "hosts/vita/assets/sce_sys/livearea/contents/template.xml", + "hosts/switch/Makefile", + "hosts/switch/source/main.c", "hosts/symbian/probe/main.cpp", "hosts/symbian/probe/pocketjs-e7-probe.pro", "hosts/symbian/runtime/main.cpp", diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index 7eec6a0..e2c0637 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -141,7 +141,7 @@ describe("pocket.json v2 schema", () => { describe("platform registry", () => { test("production advertises only the truthful stock-host profiles", () => { - expect(Object.keys(POCKET_TARGETS)).toEqual(["psp", "vita", "pocketbook", "macos-widget"]); + expect(Object.keys(POCKET_TARGETS)).toEqual(["psp", "vita", "switch", "pocketbook", "macos-widget"]); expect(validatePlatformContractRegistry(POCKET_PLATFORM_CONTRACTS)).toEqual([]); expect(POCKET_TARGETS.psp.capabilities).toEqual([ "input.analog.left", @@ -162,6 +162,23 @@ describe("platform registry", () => { presentations: ["integer-fit"], rasterDensity: 2, }); + expect(POCKET_TARGETS.switch).toEqual({ + hostAbi: 4, + platform: "switch", + form: "takeover", + display: { + physicalViewport: [1280, 720], + logicalViewports: [[480, 272]], + presentations: ["integer-fit"], + rasterDensity: 2, + }, + capabilities: [ + "input.analog.left", + "input.buttons", + "input.cursor", + "text.glyphs.baked", + ], + }); // The PocketBook e-reader target: real touch, no nub/cursor, and the // same nominal 960×544 @2x surface as vita (the host integer-fits it // onto whatever panel the device actually has). @@ -327,34 +344,34 @@ describe("semantic resolution", () => { test("every committed demo manifest lands on the expected admission matrix", async () => { const { readdirSync, existsSync } = await import("node:fs"); - // demo -> [psp, vita, macos-widget] admission. Fixed-only console demos - // stay off the desktop widget (its profile presents "native" over a + // demo -> [psp, vita, switch, macos-widget] admission. Fixed-only console + // demos stay off the desktop widget (its profile presents "native" over a // dynamic viewport, not the console integer-fit contract); Hero declares - // both policies, while the note is dynamic-only. A new demo missing here - // fails the test on purpose. - const expected: Record = { - cafe: [true, true, false], - cards: [true, true, false], - chrome: [true, true, false], - cursor: [true, true, false], - gallery: [true, true, false], - hero: [true, true, true], - "hero-vue-sfc": [true, true, false], - "hero-vue-vapor": [true, true, false], - im: [true, true, false], - "ipod-nano": [false, false, false], // admitted by the package-shaped macos-embedded target - launcher: [true, true, false], // the Cover Flow deck (docs/LAUNCHER.md) is an ordinary console app - library: [true, true, false], - motions: [true, true, false], - music: [true, true, false], - note: [false, false, true], - notifications: [true, true, false], - settings: [true, true, false], - stats: [true, true, false], - "vue-sfc-lab": [true, true, false], - zoomlab: [true, true, false], + // both policies so it lands on the widget too, while the note is dynamic- + // only. A new demo missing here fails the test on purpose. + const expected: Record = { + cafe: [true, true, true, false], + cards: [true, true, true, false], + chrome: [true, true, true, false], + cursor: [true, true, true, false], + gallery: [true, true, true, false], + hero: [true, true, true, true], + "hero-vue-sfc": [true, true, true, false], + "hero-vue-vapor": [true, true, true, false], + im: [true, true, true, false], + "ipod-nano": [false, false, false, false], // admitted by the package-shaped macos-embedded target + launcher: [true, true, true, false], // the Cover Flow deck (docs/LAUNCHER.md) is an ordinary console app + library: [true, true, true, false], + motions: [true, true, true, false], + music: [true, true, true, false], + note: [false, false, false, true], + notifications: [true, true, true, false], + settings: [true, true, true, false], + stats: [true, true, true, false], + "vue-sfc-lab": [true, true, true, false], + zoomlab: [true, true, true, false], }; - const targets = ["psp", "vita", "macos-widget"] as const; + const targets = ["psp", "vita", "switch", "macos-widget"] as const; for (const demo of readdirSync(new URL("../apps/", import.meta.url)).sort()) { const url = new URL(`../apps/${demo}/pocket.json`, import.meta.url); if (!existsSync(url)) continue; @@ -414,6 +431,7 @@ describe("semantic resolution", () => { expect(POCKET_TARGETS.psp.platform).toBe("psp"); expect(POCKET_TARGETS.psp.form).toBe("takeover"); expect(POCKET_TARGETS.vita.form).toBe("takeover"); + expect(POCKET_TARGETS.switch.form).toBe("takeover"); expect(POCKET_TARGETS["macos-widget"].platform).toBe("macos"); expect(POCKET_TARGETS["macos-widget"].form).toBe("widget"); }); @@ -479,7 +497,7 @@ describe("semantic resolution", () => { }); test("reports unknown targets and missing hard requirements", () => { - const unknownTarget = validateAndResolveBuildPlan(portableInput, { target: "switch" }); + const unknownTarget = validateAndResolveBuildPlan(portableInput, { target: "gameboy" }); expect(unknownTarget.ok).toBe(false); if (!unknownTarget.ok) expect(unknownTarget.diagnostics[0]?.code).toBe("target.unknown"); @@ -514,6 +532,19 @@ describe("semantic resolution", () => { expect(Object.values(result.plan.features).every(Boolean)).toBe(true); }); + test("integer-fit allows centered letterboxing on Switch", () => { + const result = validateAndResolveBuildPlan(portableInput, { target: "switch" }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.plan.target).toEqual({ id: "switch", hostAbi: 4 }); + expect(result.plan.viewport).toEqual({ + logical: [480, 272], + physical: [1280, 720], + presentation: "integer-fit", + rasterDensity: 2, + }); + }); + test("enhancements record target availability without gating resolution", () => { const adaptive = structuredClone(portableInput) as Record; adaptive.engine.capabilities.enhances = ["input.touch"]; diff --git a/tools/pocket.ts b/tools/pocket.ts index 313cb92..9dd8c74 100644 --- a/tools/pocket.ts +++ b/tools/pocket.ts @@ -157,6 +157,20 @@ const targetBackends = { "Vita backend", ); }, + switch: async ({ planPath, projectRoot, outdir, args }) => { + await run( + [ + Bun.which("bun") ?? "bun", + resolve(frameworkRoot, "tools/switch.ts"), + `--plan=${planPath}`, + `--project-root=${projectRoot}`, + `--outdir=${outdir}`, + "--skip-build", + ...args, + ], + "Switch backend", + ); + }, pocketbook: async ({ outdir }) => { // The PocketBook host loads the pak + bundle from the device filesystem // (hosts/pocketbook), so there is no platform binary to package here — diff --git a/tools/switch.ts b/tools/switch.ts new file mode 100644 index 0000000..c3436ff --- /dev/null +++ b/tools/switch.ts @@ -0,0 +1,122 @@ +import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { extractHostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; + +const root = resolve(fileURLToPath(new URL("..", import.meta.url))); +const hostDir = resolve(root, "hosts/switch"); +const args = Bun.argv.slice(2); + +function option(name: string): string | undefined { + const prefix = `--${name}=`; + const index = args.findIndex((value) => value.startsWith(prefix)); + if (index < 0) return undefined; + return args.splice(index, 1)[0]!.slice(prefix.length); +} + +function flag(name: string): boolean { + const index = args.indexOf(`--${name}`); + if (index < 0) return false; + args.splice(index, 1); + return true; +} + +function usage(message?: string): never { + if (message) console.error(`PocketJS Switch: ${message}`); + console.error( + "usage: bun tools/switch.ts --plan= --project-root= --outdir= [--skip-build]", + ); + process.exit(2); +} + +const planPath = option("plan"); +const projectRoot = resolve(option("project-root") ?? root); +const outdir = resolve(projectRoot, option("outdir") ?? "dist"); +const skipBuild = flag("skip-build"); +flag("release"); +if (!planPath) usage("--plan is required"); +if (args.length > 0) usage(`unknown option ${args[0]}`); + +const planInput: unknown = await Bun.file(resolve(planPath)).json(); +const inputs = extractHostBuildInputs(planInput, { expectedTarget: "switch" }); +const plan = planInput as ResolvedBuildPlan; +if ( + inputs.hostAbi !== 4 || + inputs.viewport.logical[0] !== 480 || + inputs.viewport.logical[1] !== 272 || + inputs.viewport.physical[0] !== 1280 || + inputs.viewport.physical[1] !== 720 || + inputs.viewport.presentation !== "integer-fit" || + inputs.viewport.rasterDensity !== 2 +) { + throw new Error("PocketJS Switch: unsupported target contract"); +} + +const devkitpro = resolve(process.env.DEVKITPRO ?? "/opt/devkitpro"); +const devkitA64 = resolve(process.env.DEVKITA64 ?? `${devkitpro}/devkitA64`); +const toolPath = `${devkitA64}/bin:${devkitpro}/tools/bin:${process.env.PATH ?? ""}`; +const gcc = resolve(devkitA64, "bin/aarch64-none-elf-gcc"); +if (!existsSync(gcc)) { + throw new Error("PocketJS Switch: devkitA64 not found; install devkitPro switch-dev"); +} + +async function run(command: string[], label: string, cwd = projectRoot): Promise { + const child = Bun.spawn(command, { + cwd, + env: { + ...process.env, + DEVKITPRO: devkitpro, + DEVKITA64: devkitA64, + PATH: toolPath, + }, + stdout: "inherit", + stderr: "inherit", + }); + const status = await child.exited; + if (status !== 0) throw new Error(`${label} failed with exit ${status}`); +} + +if (!skipBuild) { + await run( + [ + process.execPath, + resolve(root, "tools/build.ts"), + `--plan=${resolve(planPath)}`, + `--project-root=${projectRoot}`, + `--outdir=${outdir}`, + ], + "PocketJS compiler", + ); +} + +const appJs = resolve(outdir, `${inputs.appOutput}.js`); +const appPak = resolve(outdir, `${inputs.appOutput}.pak`); +if (!existsSync(appJs) || !existsSync(appPak)) { + throw new Error(`PocketJS Switch: missing ${inputs.appOutput}.js/.pak in ${outdir}`); +} + +const romfs = resolve(hostDir, "romfs/pocketjs"); +rmSync(dirname(romfs), { recursive: true, force: true }); +mkdirSync(romfs, { recursive: true }); +cpSync(appJs, resolve(romfs, "app.js")); +cpSync(appPak, resolve(romfs, "app.pak")); + +await run(["make", "clean"], "Switch host clean", hostDir); +await run( + [ + "make", + `APP_TITLE=${plan.app.title}`, + "APP_AUTHOR=PocketJS", + "APP_VERSION=0.1.0", + ], + "Switch host build", + hostDir, +); + +const builtNro = resolve(hostDir, "pocketjs-switch.nro"); +const packageDir = resolve(outdir, "switch"); +const outputNro = resolve(packageDir, `${inputs.appOutput}.nro`); +mkdirSync(packageDir, { recursive: true }); +cpSync(builtNro, outputNro); +console.log(`PocketJS Switch: built ${outputNro}`); From 796176927cab2393921cd498692a39221d68cf6a Mon Sep 17 00:00:00 2001 From: zerob13 Date: Fri, 24 Jul 2026 14:08:45 +0800 Subject: [PATCH 3/9] docs(switch): record the verified emulator bootstrap --- docs/SWITCH.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/SWITCH.md b/docs/SWITCH.md index 76934c5..69fdf15 100644 --- a/docs/SWITCH.md +++ b/docs/SWITCH.md @@ -231,10 +231,10 @@ The reproducible emulator invocation is: /Applications/Ryujinx.app/Contents/MacOS/Ryujinx /path/to/application.nro ``` -Ryujinx currently stops before executing even the official example with -`RYU-0001: Keys not found`. M0 remains open until the user supplies a legally -dumped `prod.keys` in the local Ryujinx system directory. No proprietary input -belongs in this repository. +After user-local keys and firmware were configured, Ryujinx loaded +`simplegfx.nro` as homebrew, reported firmware `22.5.0`, created the libnx +framebuffer, and sustained 60 FPS. M0 is complete. Keys and firmware remain +user-local proprietary inputs and do not belong in this repository. ## Toolchain bootstrap From 80029b046cd67f8584a36318a22b08ed80c92e77 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Fri, 24 Jul 2026 15:25:40 +0800 Subject: [PATCH 4/9] feat(switch): run the PocketJS guest in Ryujinx --- docs/SWITCH.md | 27 ++- hosts/{vita/src => native}/ffi.rs | 4 +- hosts/{vita/src => native}/pak.rs | 8 +- hosts/switch/Cargo.lock | 205 ++++++++++++++++++++ hosts/switch/Cargo.toml | 15 ++ hosts/switch/Makefile | 7 +- hosts/switch/source/main.c | 144 +++++++++++--- hosts/switch/src/dbg.rs | 15 ++ hosts/switch/src/graphics.rs | 15 ++ hosts/switch/src/lib.rs | 304 ++++++++++++++++++++++++++++++ hosts/switch/src/switch.rs | 23 +++ hosts/vita/src/lib.rs | 6 +- package.json | 4 + tests/npm-package.test.ts | 5 + tools/switch.ts | 48 ++++- 15 files changed, 791 insertions(+), 39 deletions(-) rename hosts/{vita/src => native}/ffi.rs (99%) rename hosts/{vita/src => native}/pak.rs (97%) create mode 100644 hosts/switch/Cargo.lock create mode 100644 hosts/switch/Cargo.toml create mode 100644 hosts/switch/src/dbg.rs create mode 100644 hosts/switch/src/graphics.rs create mode 100644 hosts/switch/src/lib.rs create mode 100644 hosts/switch/src/switch.rs diff --git a/docs/SWITCH.md b/docs/SWITCH.md index 69fdf15..cc98c30 100644 --- a/docs/SWITCH.md +++ b/docs/SWITCH.md @@ -1,7 +1,9 @@ # Nintendo Switch port plan -Status: implementation in progress. The target contract and diagnostic NRO -shell are implemented; the PocketJS runtime is not yet linked. +Status: implementation in progress. M0 through M3 are implemented: the +release `hero` NRO runs the PocketJS QuickJS guest, renders through the shared +software rasterizer at 60 FPS in Ryujinx, and accepts controller input. The +developer loop and broader demo acceptance work remain. ## Goal @@ -223,7 +225,15 @@ Observed on 2026-07-24: `/opt/devkitpro/examples/switch/graphics/simplegfx`; - the installed macOS `elf2nro` silently ignores `--romfsdir`, while an explicit `build_romfs` followed by `elf2nro --romfs=` embeds the - expected ASET payload. The PocketJS Makefile uses the working explicit path. + expected ASET payload. The PocketJS Makefile uses the working explicit path; +- the built-in Rust Switch target omits the Unix/newlib cfg values required by + the `libc` crate, so the backend supplies those known target facts while + building the Rust static library; +- the pinned QuickJS build needs Switch newlib's `malloc_usable_size` + declaration and the same zero-timezone fallback used by the PSP/Vita hosts; +- `hero-main.nro` loads its normal `app.js` and `app.pak`, completes the host + ABI handshake, renders the centered 960x544 content surface at 60 FPS, and + responds to controller input in Ryujinx. The reproducible emulator invocation is: @@ -324,7 +334,7 @@ Exit criteria: - unsupported viewport/capability combinations still fail; - existing PSP, Vita, and macOS target tests remain unchanged and green. -### M2 — NRO shell and Rust/QuickJS link spike +### M2 — NRO shell and Rust/QuickJS link spike (complete) Scope: @@ -343,10 +353,11 @@ not fork the entire runtime or add a second JavaScript engine. Exit criteria: - a self-contained NRO evaluates a trivial embedded script in Ryujinx; -- Plus exits cleanly to the emulator; +- Plus+Minus exits cleanly to the emulator without consuming the mapped Start + button; - failures are visible through stderr/nxlink-compatible logging. -### M3 — first PocketJS frame +### M3 — first PocketJS frame (complete) Scope: @@ -361,7 +372,9 @@ Exit criteria: - `hero` renders and responds to input in Ryujinx; - the bundled target id and host ABI handshake passes; -- the captured density-2 content frame matches the deterministic oracle. +- the host uses the same density-2 deterministic rasterizer as the oracle. + Byte-exact emulator capture remains the M5 regression proof rather than an + extra one-off M3 mechanism. ### M4 — framework parity and developer loop diff --git a/hosts/vita/src/ffi.rs b/hosts/native/ffi.rs similarity index 99% rename from hosts/vita/src/ffi.rs rename to hosts/native/ffi.rs index e09196e..d5c9671 100644 --- a/hosts/vita/src/ffi.rs +++ b/hosts/native/ffi.rs @@ -29,7 +29,7 @@ static mut UI: Option = None; /// /// # Safety /// -/// Call once per guest on the Vita render thread, with no outstanding reference +/// Call once per guest on the host render thread, with no outstanding reference /// to the process-global UI. pub unsafe fn init_ui() -> &'static mut Ui { let mut instance = Ui::new_with_raster_density(crate::graphics::RASTER_DENSITY); @@ -45,7 +45,7 @@ pub unsafe fn init_ui() -> &'static mut Ui { /// /// # Safety /// -/// The caller must stay on the Vita render thread and must not create another +/// The caller must stay on the host render thread and must not create another /// live mutable reference to the global UI. pub unsafe fn ui() -> &'static mut Ui { UI.as_mut().expect("ffi::init_ui not called") diff --git a/hosts/vita/src/pak.rs b/hosts/native/pak.rs similarity index 97% rename from hosts/vita/src/pak.rs rename to hosts/native/pak.rs index 17071c4..fc8a4bc 100644 --- a/hosts/vita/src/pak.rs +++ b/hosts/native/pak.rs @@ -132,11 +132,11 @@ pub fn feed(ui: &mut Ui, pak: &[u8]) -> (Vec<(String, i32)>, Vec) { }; if key == "ui:styles" { if !ui.load_styles(blob) { - crate::vita_log(format_args!("[PocketJS pak] bad styles.bin")); + crate::host_log(format_args!("[PocketJS pak] bad styles.bin")); } } else if key.starts_with("ui:font.") { if !ui.load_font_atlas(blob) { - crate::vita_log(format_args!("[PocketJS pak] bad font atlas {}", key)); + crate::host_log(format_args!("[PocketJS pak] bad font atlas {}", key)); } else { let slot = blob.get(12).copied().unwrap_or(0); if let Some(atlas) = ui.font_atlas(slot) { @@ -162,7 +162,7 @@ pub fn feed(ui: &mut Ui, pak: &[u8]) -> (Vec<(String, i32)>, Vec) { crate::graphics::register_texture(ui, handle); textures.push((String::from(name), handle)); } else { - crate::vita_log(format_args!( + crate::host_log(format_args!( "[PocketJS pak] bad image {} ({}x{} psm {})", key, w, h, psm )); @@ -196,7 +196,7 @@ pub fn feed(ui: &mut Ui, pak: &[u8]) -> (Vec<(String, i32)>, Vec) { step, }); } else { - crate::vita_log(format_args!( + crate::host_log(format_args!( "[PocketJS pak] bad sprite {} ({}x{} psm {})", key, w, h, psm )); diff --git a/hosts/switch/Cargo.lock b/hosts/switch/Cargo.lock new file mode 100644 index 0000000..ddbeffc --- /dev/null +++ b/hosts/switch/Cargo.lock @@ -0,0 +1,205 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "copy_dir" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "543d1dd138ef086e2ff05e3a48cf9da045da2033d16f8538fd76b86cd49b2ca3" +dependencies = [ + "walkdir", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libquickjs-sys" +version = "0.9.0" +source = "git+https://github.com/pocket-stack/quickjs-rs.git?rev=0fc946fb670c0c29bc0135f510bcb0f595415a61#0fc946fb670c0c29bc0135f510bcb0f595415a61" +dependencies = [ + "cc", + "copy_dir", + "libc", +] + +[[package]] +name = "pocketjs-core" +version = "0.1.0" +dependencies = [ + "taffy", +] + +[[package]] +name = "pocketjs-switch" +version = "0.1.0" +dependencies = [ + "libquickjs-sys", + "pocketjs-core", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "taffy" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfde4e2f8595f222ceaae1fb16b4963952e9b33e358869dc4cd6316b0e0790cd" +dependencies = [ + "arrayvec", + "serde", + "slotmap", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/hosts/switch/Cargo.toml b/hosts/switch/Cargo.toml new file mode 100644 index 0000000..1a8adfe --- /dev/null +++ b/hosts/switch/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "pocketjs-switch" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["staticlib"] + +[dependencies] +libquickjs-sys = { git = "https://github.com/pocket-stack/quickjs-rs.git", rev = "0fc946fb670c0c29bc0135f510bcb0f595415a61" } +pocketjs-core = { path = "../../engine/core" } + +[profile.release] +lto = true +panic = "abort" diff --git a/hosts/switch/Makefile b/hosts/switch/Makefile index bbc72da..a102924 100644 --- a/hosts/switch/Makefile +++ b/hosts/switch/Makefile @@ -11,6 +11,9 @@ TARGET := pocketjs-switch BUILD := build SOURCES := source ROMFS := romfs +RUST_TARGET := aarch64-nintendo-switch-freestanding +RUST_PROFILE ?= release +RUSTLIB := $(TOPDIR)/target/$(RUST_TARGET)/$(RUST_PROFILE)/libpocketjs_switch.a APP_TITLE ?= PocketJS APP_AUTHOR ?= PocketJS @@ -22,7 +25,7 @@ CFLAGS += $(INCLUDE) -D__SWITCH__ ASFLAGS := -g $(ARCH) LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) \ -Wl,-Map,$(notdir $*.map) -LIBS := -lnx +LIBS := $(RUSTLIB) -lm -lnx LIBDIRS := $(PORTLIBS) $(LIBNX) ifneq ($(BUILD),$(notdir $(CURDIR))) @@ -65,7 +68,7 @@ all: $(OUTPUT).nro $(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp -$(OUTPUT).elf: $(OFILES) +$(OUTPUT).elf: $(OFILES) $(RUSTLIB) -include $(DEPENDS) diff --git a/hosts/switch/source/main.c b/hosts/switch/source/main.c index 9754b1d..778532b 100644 --- a/hosts/switch/source/main.c +++ b/hosts/switch/source/main.c @@ -1,4 +1,6 @@ #include +#include +#include #include @@ -8,14 +10,89 @@ #define CONTENT_Y 88 #define CONTENT_WIDTH 960 #define CONTENT_HEIGHT 544 +#define CONTENT_BYTES (CONTENT_WIDTH * CONTENT_HEIGHT * 4) -static bool file_exists(const char *path) { +extern bool pocketjs_switch_init( + const uint8_t *app_js, + size_t app_js_length, + const uint8_t *app_pak, + size_t app_pak_length +); +extern bool pocketjs_switch_frame( + int32_t buttons, + int32_t analog, + const uint8_t **output, + size_t *output_length +); +extern void pocketjs_switch_shutdown(void); + +void pocketjs_switch_log(const uint8_t *bytes, size_t length) { + svcOutputDebugString((const char *)bytes, length); + svcOutputDebugString("\n", 1); +} + +__attribute__((noreturn)) void pocketjs_switch_abort(void) { + static const char message[] = "[PocketJS Switch] Rust abort\n"; + svcOutputDebugString(message, sizeof(message) - 1); + for (;;) { + svcSleepThread(1000000000L); + } +} + +static uint8_t *read_file(const char *path, size_t *length) { FILE *file = fopen(path, "rb"); if (file == NULL) { - return false; + return NULL; + } + if (fseek(file, 0, SEEK_END) != 0) { + fclose(file); + return NULL; + } + const long size = ftell(file); + if (size < 0 || fseek(file, 0, SEEK_SET) != 0) { + fclose(file); + return NULL; + } + uint8_t *bytes = malloc((size_t)size + 1); + if (bytes == NULL) { + fclose(file); + return NULL; + } + if (size != 0 && fread(bytes, 1, (size_t)size, file) != (size_t)size) { + free(bytes); + fclose(file); + return NULL; } + bytes[size] = 0; fclose(file); - return true; + *length = (size_t)size; + return bytes; +} + +static int32_t pocket_buttons(uint64_t buttons) { + int32_t output = 0; + if (buttons & HidNpadButton_Minus) output |= 0x0001; + if (buttons & HidNpadButton_Plus) output |= 0x0008; + if (buttons & HidNpadButton_Up) output |= 0x0010; + if (buttons & HidNpadButton_Right) output |= 0x0020; + if (buttons & HidNpadButton_Down) output |= 0x0040; + if (buttons & HidNpadButton_Left) output |= 0x0080; + if (buttons & (HidNpadButton_L | HidNpadButton_ZL)) output |= 0x0100; + if (buttons & (HidNpadButton_R | HidNpadButton_ZR)) output |= 0x0200; + if (buttons & HidNpadButton_X) output |= 0x1000; + if (buttons & HidNpadButton_A) output |= 0x2000; + if (buttons & HidNpadButton_B) output |= 0x4000; + if (buttons & HidNpadButton_Y) output |= 0x8000; + return output; +} + +static uint8_t analog_axis(int32_t value) { + const int64_t clamped = value < -32768 ? -32768 : value > 32767 ? 32767 : value; + return (uint8_t)(((clamped + 32768) * 255 + 32767) / 65535); +} + +static int32_t pocket_analog(HidAnalogStickState stick) { + return analog_axis(stick.x) | ((int32_t)analog_axis(-stick.y) << 8); } int main(int argc, char **argv) { @@ -24,14 +101,16 @@ int main(int argc, char **argv) { consoleDebugInit(debugDevice_SVC); const Result romfs_result = romfsInit(); - const bool app_ready = R_SUCCEEDED(romfs_result) && - file_exists("romfs:/pocketjs/app.js") && - file_exists("romfs:/pocketjs/app.pak"); - printf( - "[PocketJS Switch] target=switch hostAbi=4 romfs=%s\n", - app_ready ? "ready" : "missing" - ); - fflush(stdout); + size_t app_js_length = 0; + size_t app_pak_length = 0; + uint8_t *app_js = R_SUCCEEDED(romfs_result) + ? read_file("romfs:/pocketjs/app.js", &app_js_length) + : NULL; + uint8_t *app_pak = R_SUCCEEDED(romfs_result) + ? read_file("romfs:/pocketjs/app.pak", &app_pak_length) + : NULL; + bool runtime_ready = app_js != NULL && app_pak != NULL && + pocketjs_switch_init(app_js, app_js_length, app_pak, app_pak_length); Framebuffer framebuffer; framebufferCreate( @@ -50,27 +129,50 @@ int main(int argc, char **argv) { while (appletMainLoop()) { padUpdate(&pad); - if (padGetButtonsDown(&pad) & HidNpadButton_Plus) { + const uint64_t buttons = padGetButtons(&pad); + if ((buttons & (HidNpadButton_Plus | HidNpadButton_Minus)) == + (HidNpadButton_Plus | HidNpadButton_Minus)) { break; } u32 stride = 0; u32 *pixels = framebufferBegin(&framebuffer, &stride); - const u32 row_pixels = stride / sizeof(*pixels); - for (u32 y = 0; y < FB_HEIGHT; y++) { - for (u32 x = 0; x < FB_WIDTH; x++) { - const bool content = - x >= CONTENT_X && x < CONTENT_X + CONTENT_WIDTH && - y >= CONTENT_Y && y < CONTENT_Y + CONTENT_HEIGHT; - pixels[y * row_pixels + x] = content - ? (app_ready ? RGBA8_MAXALPHA(28, 120, 84) : RGBA8_MAXALPHA(160, 36, 48)) - : RGBA8_MAXALPHA(0, 0, 0); + memset(pixels, 0, stride * FB_HEIGHT); + + const uint8_t *content = NULL; + size_t content_length = 0; + if (runtime_ready) { + const HidAnalogStickState left_stick = padGetStickPos(&pad, 0); + runtime_ready = pocketjs_switch_frame( + pocket_buttons(buttons), + pocket_analog(left_stick), + &content, + &content_length + ); + } + if (runtime_ready && content != NULL && content_length == CONTENT_BYTES) { + for (u32 y = 0; y < CONTENT_HEIGHT; y++) { + memcpy( + (uint8_t *)pixels + (CONTENT_Y + y) * stride + CONTENT_X * 4, + content + y * CONTENT_WIDTH * 4, + CONTENT_WIDTH * 4 + ); + } + } else { + for (u32 y = 0; y < CONTENT_HEIGHT; y++) { + u32 *row = (u32 *)((uint8_t *)pixels + (CONTENT_Y + y) * stride); + for (u32 x = CONTENT_X; x < CONTENT_X + CONTENT_WIDTH; x++) { + row[x] = RGBA8_MAXALPHA(160, 36, 48); + } } } framebufferEnd(&framebuffer); } + pocketjs_switch_shutdown(); framebufferClose(&framebuffer); + free(app_js); + free(app_pak); if (R_SUCCEEDED(romfs_result)) { romfsExit(); } diff --git a/hosts/switch/src/dbg.rs b/hosts/switch/src/dbg.rs new file mode 100644 index 0000000..c74e4e3 --- /dev/null +++ b/hosts/switch/src/dbg.rs @@ -0,0 +1,15 @@ +use alloc::string::String; + +pub fn active() -> bool { + false +} + +pub fn poll() -> Option { + None +} + +pub fn send(_bytes: &[u8]) {} + +pub fn shot() -> bool { + false +} diff --git a/hosts/switch/src/graphics.rs b/hosts/switch/src/graphics.rs new file mode 100644 index 0000000..68ef503 --- /dev/null +++ b/hosts/switch/src/graphics.rs @@ -0,0 +1,15 @@ +use pocketjs_core::{text::Atlas, Ui}; + +pub const LOGICAL_W: i32 = 480; +pub const LOGICAL_H: i32 = 272; +pub const RASTER_DENSITY: u32 = 2; +pub const INTEGER_SCALE: u32 = 2; +pub const CONTENT_W: usize = LOGICAL_W as usize * INTEGER_SCALE as usize; +pub const CONTENT_H: usize = LOGICAL_H as usize * INTEGER_SCALE as usize; +pub const CONTENT_BYTES: usize = CONTENT_W * CONTENT_H * 4; + +pub fn register_texture(_ui: &Ui, _handle: i32) {} + +pub fn free_texture(_handle: i32) {} + +pub fn register_font_atlas(_slot: u8, _atlas: &Atlas) {} diff --git a/hosts/switch/src/lib.rs b/hosts/switch/src/lib.rs new file mode 100644 index 0000000..7993dbc --- /dev/null +++ b/hosts/switch/src/lib.rs @@ -0,0 +1,304 @@ +#![no_std] +#![feature(alloc_error_handler)] +#![allow(static_mut_refs)] + +extern crate alloc; + +use alloc::alloc::{GlobalAlloc, Layout}; +use alloc::string::String; +use alloc::vec; +use alloc::vec::Vec; +use core::ffi::{c_char, c_void}; +use core::fmt; +use core::ptr; + +use libquickjs_sys::*; + +pub mod dbg; +#[path = "../../native/ffi.rs"] +pub mod ffi; +pub mod graphics; +#[path = "../../native/pak.rs"] +pub mod pak; +pub mod switch; + +extern "C" { + fn malloc(size: usize) -> *mut c_void; + fn memalign(alignment: usize, size: usize) -> *mut c_void; + fn free(pointer: *mut c_void); + fn pocketjs_switch_log(bytes: *const u8, length: usize); + fn pocketjs_switch_abort() -> !; + + fn JS_NewArrayBuffer( + ctx: *mut JSContext, + buffer: *mut u8, + length: usize, + free_function: Option, + opaque: *mut c_void, + shared: i32, + ) -> JSValue; + fn JS_ExecutePendingJob(runtime: *mut JSRuntime, context: *mut *mut JSContext) -> i32; +} + +struct NewlibAllocator; + +unsafe impl GlobalAlloc for NewlibAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let size = layout.size().max(1); + if layout.align() <= 16 { + malloc(size) as *mut u8 + } else { + memalign(layout.align(), size) as *mut u8 + } + } + + unsafe fn dealloc(&self, pointer: *mut u8, _layout: Layout) { + free(pointer as *mut c_void); + } +} + +#[global_allocator] +static ALLOCATOR: NewlibAllocator = NewlibAllocator; + +#[alloc_error_handler] +fn allocation_error(_layout: Layout) -> ! { + unsafe { pocketjs_switch_abort() } +} + +#[panic_handler] +fn panic(_info: &core::panic::PanicInfo<'_>) -> ! { + unsafe { pocketjs_switch_abort() } +} + +pub fn host_log(args: fmt::Arguments<'_>) { + use core::fmt::Write; + let mut message = String::new(); + if message.write_fmt(args).is_ok() { + unsafe { pocketjs_switch_log(message.as_ptr(), message.len()) }; + } +} + +unsafe fn exception_string(context: *mut JSContext) -> String { + let value = JS_GetException(context); + let mut length: size_t = 0; + let raw = JS_ToCStringLen2(context, &mut length, value, 0); + let message = if raw.is_null() { + String::from("unknown JavaScript exception") + } else { + let bytes = core::slice::from_raw_parts(raw as *const u8, length); + let text = String::from_utf8_lossy(bytes).into_owned(); + JS_FreeCString(context, raw); + text + }; + JS_FreeValue(context, value); + message +} + +struct Runtime { + runtime: *mut JSRuntime, + context: *mut JSContext, + global: JSValue, + frame: JSValue, + pixels: Vec, +} + +impl Runtime { + unsafe fn new(app_pak: &'static [u8]) -> Result { + let ui = ffi::init_ui(); + pak::install(app_pak); + let (textures, sprites) = pak::feed(ui, app_pak); + switch::upload_shot(ui); + + let runtime = JS_NewRuntime(); + if runtime.is_null() { + ffi::reset_ui(); + pak::uninstall(); + return Err(String::from("JS_NewRuntime failed")); + } + let context = JS_NewContext(runtime); + if context.is_null() { + JS_FreeRuntime(runtime); + ffi::reset_ui(); + pak::uninstall(); + return Err(String::from("JS_NewContext failed")); + } + let global = JS_GetGlobalObject(context); + ffi::register(context, global, &textures, &sprites); + if !app_pak.is_empty() { + let pointer = app_pak.as_ptr() as *mut u8; + let buffer = + JS_NewArrayBuffer(context, pointer, app_pak.len(), None, pointer.cast(), 0); + JS_SetPropertyStr(context, global, c"__pak".as_ptr(), buffer); + } + Ok(Self { + runtime, + context, + global, + frame: JS_UNDEFINED, + pixels: vec![0; graphics::CONTENT_BYTES], + }) + } + + unsafe fn eval(&mut self, app_js: &[u8]) -> Result<(), String> { + let result = JS_Eval( + self.context, + app_js.as_ptr() as *const c_char, + app_js.len(), + c"app.js".as_ptr(), + JS_EVAL_TYPE_GLOBAL as i32, + ); + if JS_IsException(result) { + return Err(exception_string(self.context)); + } + JS_FreeValue(self.context, result); + self.frame = JS_GetPropertyStr(self.context, self.global, c"frame".as_ptr()); + if JS_IsUndefined(self.frame) { + return Err(String::from("globalThis.frame is undefined")); + } + Ok(()) + } + + unsafe fn run_frame(&mut self, buttons: i32, analog: i32) -> Result<(), String> { + let mut arguments = [ + JS_NewInt32(self.context, buttons), + JS_NewInt32(self.context, analog), + ]; + let result = JS_Call( + self.context, + self.frame, + self.global, + arguments.len() as i32, + arguments.as_mut_ptr(), + ); + if JS_IsException(result) { + return Err(exception_string(self.context)); + } + JS_FreeValue(self.context, result); + loop { + let mut pending_context = ptr::null_mut(); + let status = JS_ExecutePendingJob(self.runtime, &mut pending_context); + if status > 0 { + continue; + } + if status < 0 { + return Err(exception_string(if pending_context.is_null() { + self.context + } else { + pending_context + })); + } + break; + } + + ffi::ui().tick(); + let (words_pointer, words_length) = { + let words = &ffi::ui().draw().words; + (words.as_ptr(), words.len()) + }; + pocketjs_core::raster::render_scaled( + ffi::ui(), + core::slice::from_raw_parts(words_pointer, words_length), + &mut self.pixels, + graphics::INTEGER_SCALE, + ); + Ok(()) + } + + unsafe fn shutdown(&mut self) { + if self.context.is_null() { + return; + } + if !JS_IsUndefined(self.frame) { + JS_FreeValue(self.context, self.frame); + self.frame = JS_UNDEFINED; + } + JS_FreeValue(self.context, self.global); + self.global = JS_UNDEFINED; + JS_FreeContext(self.context); + self.context = ptr::null_mut(); + JS_FreeRuntime(self.runtime); + self.runtime = ptr::null_mut(); + ffi::reset_ui(); + pak::uninstall(); + } +} + +static mut RUNTIME: Option = None; + +unsafe fn static_bytes(pointer: *const u8, length: usize) -> Option<&'static [u8]> { + if length == 0 { + return Some(&[]); + } + (!pointer.is_null()).then(|| core::slice::from_raw_parts(pointer, length)) +} + +#[no_mangle] +pub unsafe extern "C" fn pocketjs_switch_init( + app_js: *const u8, + app_js_length: usize, + app_pak: *const u8, + app_pak_length: usize, +) -> bool { + if RUNTIME.is_some() { + host_log(format_args!( + "[PocketJS Switch] guest is already initialized" + )); + return false; + } + let (Some(js), Some(pak)) = ( + static_bytes(app_js, app_js_length), + static_bytes(app_pak, app_pak_length), + ) else { + host_log(format_args!("[PocketJS Switch] invalid RomFS buffers")); + return false; + }; + if core::str::from_utf8(js).is_err() { + host_log(format_args!("[PocketJS Switch] app.js is not UTF-8")); + return false; + } + let mut runtime = match Runtime::new(pak) { + Ok(runtime) => runtime, + Err(error) => { + host_log(format_args!("[PocketJS Switch] {error}")); + return false; + } + }; + if let Err(error) = runtime.eval(js) { + host_log(format_args!("[PocketJS Switch] {error}")); + runtime.shutdown(); + return false; + } + RUNTIME = Some(runtime); + host_log(format_args!("[PocketJS Switch] guest initialized")); + true +} + +#[no_mangle] +pub unsafe extern "C" fn pocketjs_switch_frame( + buttons: i32, + analog: i32, + output: *mut *const u8, + output_length: *mut usize, +) -> bool { + let Some(runtime) = RUNTIME.as_mut() else { + return false; + }; + if let Err(error) = runtime.run_frame(buttons, analog) { + host_log(format_args!("[PocketJS Switch] {error}")); + return false; + } + if !output.is_null() { + *output = runtime.pixels.as_ptr(); + } + if !output_length.is_null() { + *output_length = runtime.pixels.len(); + } + true +} + +#[no_mangle] +pub unsafe extern "C" fn pocketjs_switch_shutdown() { + if let Some(mut runtime) = RUNTIME.take() { + runtime.shutdown(); + } +} diff --git a/hosts/switch/src/switch.rs b/hosts/switch/src/switch.rs new file mode 100644 index 0000000..0e05e5a --- /dev/null +++ b/hosts/switch/src/switch.rs @@ -0,0 +1,23 @@ +use alloc::string::String; + +use pocketjs_core::Ui; + +pub fn multi() -> bool { + false +} + +pub fn find(_output: &str) -> Option { + None +} + +pub unsafe fn request_launch(_index: usize) {} + +pub unsafe fn table_json() -> String { + String::from("{\"apps\":[]}") +} + +pub unsafe fn shot_handle() -> i32 { + -1 +} + +pub unsafe fn upload_shot(_ui: &mut Ui) {} diff --git a/hosts/vita/src/lib.rs b/hosts/vita/src/lib.rs index 341084d..3ac199a 100644 --- a/hosts/vita/src/lib.rs +++ b/hosts/vita/src/lib.rs @@ -10,9 +10,11 @@ use libquickjs_sys::*; use pocketjs_core::Ui; pub mod dbg; +#[path = "../../native/ffi.rs"] pub mod ffi; pub mod graphics; pub mod input; +#[path = "../../native/pak.rs"] pub mod pak; pub mod switch; @@ -42,7 +44,7 @@ extern "C" { fn sceClibPrintf(fmt: *const i8, ...) -> i32; } -pub fn vita_log(args: fmt::Arguments<'_>) { +pub fn host_log(args: fmt::Arguments<'_>) { use std::fmt::Write; let mut line = String::new(); let _ = line.write_fmt(args); @@ -52,6 +54,8 @@ pub fn vita_log(args: fmt::Arguments<'_>) { } } +pub use host_log as vita_log; + unsafe fn exception_string(ctx: *mut JSContext) -> String { let value = JS_GetException(ctx); let mut len: size_t = 0; diff --git a/package.json b/package.json index 899447d..bc6a858 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,12 @@ "hosts/vita/Cargo.lock", "hosts/vita/README.md", "hosts/vita/rust-toolchain.toml", + "hosts/native", "hosts/switch/Makefile", + "hosts/switch/Cargo.toml", + "hosts/switch/Cargo.lock", "hosts/switch/source", + "hosts/switch/src", "hosts/symbian/probe", "hosts/symbian/runtime", "engine/pocket3d/crates/pocket3d-vita/src", diff --git a/tests/npm-package.test.ts b/tests/npm-package.test.ts index a4855ab..200b596 100644 --- a/tests/npm-package.test.ts +++ b/tests/npm-package.test.ts @@ -84,8 +84,13 @@ describe("published npm artifacts", () => { "hosts/vita/assets/sce_sys/livearea/contents/bg.png", "hosts/vita/assets/sce_sys/livearea/contents/startup.png", "hosts/vita/assets/sce_sys/livearea/contents/template.xml", + "hosts/native/ffi.rs", + "hosts/native/pak.rs", "hosts/switch/Makefile", + "hosts/switch/Cargo.toml", + "hosts/switch/Cargo.lock", "hosts/switch/source/main.c", + "hosts/switch/src/lib.rs", "hosts/symbian/probe/main.cpp", "hosts/symbian/probe/pocketjs-e7-probe.pro", "hosts/symbian/runtime/main.cpp", diff --git a/tools/switch.ts b/tools/switch.ts index c3436ff..a48ec32 100644 --- a/tools/switch.ts +++ b/tools/switch.ts @@ -1,7 +1,10 @@ import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { extractHostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import { + extractHostBuildInputs, + hostBuildEnvironment, +} from "../framework/src/manifest/host-build-inputs.ts"; import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; const root = resolve(fileURLToPath(new URL("..", import.meta.url))); @@ -34,7 +37,7 @@ const planPath = option("plan"); const projectRoot = resolve(option("project-root") ?? root); const outdir = resolve(projectRoot, option("outdir") ?? "dist"); const skipBuild = flag("skip-build"); -flag("release"); +const release = flag("release"); if (!planPath) usage("--plan is required"); if (args.length > 0) usage(`unknown option ${args[0]}`); @@ -60,6 +63,29 @@ const gcc = resolve(devkitA64, "bin/aarch64-none-elf-gcc"); if (!existsSync(gcc)) { throw new Error("PocketJS Switch: devkitA64 not found; install devkitPro switch-dev"); } +const toolchain = await Bun.file(resolve(root, "tools/cli/psp-toolchain.json")).json() as { + rust: { toolchain: string }; +}; +const rustTarget = "aarch64-nintendo-switch-freestanding"; +const rustProfile = release ? "release" : "debug"; +const nativeEnvironment = { + ...hostBuildEnvironment(inputs, { + outputDirectory: outdir, + embedApp: true, + }), + CC_aarch64_nintendo_switch_freestanding: gcc, + AR_aarch64_nintendo_switch_freestanding: resolve(devkitA64, "bin/aarch64-none-elf-ar"), + // QuickJS expects GNU tm_gmtoff; Switch newlib has no timezone offset field. + // tm_isdst is always -1/0/1, so QuickJS's minute conversion yields UTC zero. + CFLAGS_aarch64_nintendo_switch_freestanding: + "-D__SWITCH__ -include malloc.h -Dtm_gmtoff=tm_isdst " + + "-fPIE -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft", + // Rust's built-in Switch target omits the Unix/newlib cfgs used by the libc crate. + RUSTFLAGS: + `${process.env.RUSTFLAGS ?? ""} -Aexplicit_builtin_cfgs_in_flags ` + + `--cfg unix --cfg target_family="unix" --cfg target_env="newlib" ` + + `-C relocation-model=pic`, +}; async function run(command: string[], label: string, cwd = projectRoot): Promise { const child = Bun.spawn(command, { @@ -69,6 +95,7 @@ async function run(command: string[], label: string, cwd = projectRoot): Promise DEVKITPRO: devkitpro, DEVKITA64: devkitA64, PATH: toolPath, + ...nativeEnvironment, }, stdout: "inherit", stderr: "inherit", @@ -102,10 +129,27 @@ mkdirSync(romfs, { recursive: true }); cpSync(appJs, resolve(romfs, "app.js")); cpSync(appPak, resolve(romfs, "app.pak")); +await run( + [ + "rustup", + "run", + toolchain.rust.toolchain, + "cargo", + "build", + "-Z", + "build-std=core,alloc", + "--target", + rustTarget, + ...(release ? ["--release"] : []), + ], + "Switch Rust runtime build", + hostDir, +); await run(["make", "clean"], "Switch host clean", hostDir); await run( [ "make", + `RUST_PROFILE=${rustProfile}`, `APP_TITLE=${plan.app.title}`, "APP_AUTHOR=PocketJS", "APP_VERSION=0.1.0", From 6881cfc3860739f8b76725c06dbdfad66fd4866a Mon Sep 17 00:00:00 2001 From: zerob13 Date: Fri, 24 Jul 2026 16:52:35 +0800 Subject: [PATCH 5/9] feat(switch): add the developer build and play loop --- framework/src/manifest/host-build-inputs.ts | 7 +- framework/src/manifest/plan.ts | 2 +- framework/src/manifest/resolve.ts | 1 + hosts/switch/source/main.c | 2 +- tests/cli.test.ts | 6 +- tests/fixtures/plans/portable-psp.plan.json | 3 +- tests/fixtures/plans/portable-vita.plan.json | 3 +- tests/host-build-inputs.test.ts | 2 + tests/platform-contracts.test.ts | 1 + tools/cli/bin.mjs | 13 +- tools/play.ts | 169 +++++++++++++++++-- tools/switch.ts | 48 +++++- 12 files changed, 226 insertions(+), 31 deletions(-) diff --git a/framework/src/manifest/host-build-inputs.ts b/framework/src/manifest/host-build-inputs.ts index 844bd7d..faef471 100644 --- a/framework/src/manifest/host-build-inputs.ts +++ b/framework/src/manifest/host-build-inputs.ts @@ -8,6 +8,8 @@ import { verifyPlanHash, type ResolvedBuildPlan } from "./plan.ts"; /** Stable subset of the internal build plan consumed by custom native hosts. */ export interface HostBuildInputs { readonly appOutput: string; + readonly appTitle: string; + readonly appVersion: string; readonly target: string; readonly hostAbi: number; readonly viewport: { @@ -42,7 +44,8 @@ function hasHostInputShape(input: unknown): input is ResolvedBuildPlan { if (!isRecord(input.viewport) || !isRecord(input.features)) return false; if ( typeof input.app.id !== "string" || input.app.id.length === 0 || - typeof input.app.title !== "string" || input.app.title.length === 0 + typeof input.app.title !== "string" || input.app.title.length === 0 || + typeof input.app.version !== "string" || input.app.version.length === 0 ) return false; if (typeof input.app.output !== "string" || input.app.output.length === 0) return false; if (typeof input.target.id !== "string" || input.target.id.length === 0) return false; @@ -91,6 +94,8 @@ export function extractHostBuildInputs( } return { appOutput: plan.app.output, + appTitle: plan.app.title, + appVersion: plan.app.version, target: plan.target.id, hostAbi: plan.target.hostAbi, viewport: { diff --git a/framework/src/manifest/plan.ts b/framework/src/manifest/plan.ts index ab2718b..67aa880 100644 --- a/framework/src/manifest/plan.ts +++ b/framework/src/manifest/plan.ts @@ -3,7 +3,7 @@ import type { PocketManifestV2 } from "../../../contracts/spec/pocket-manifest.t import type { PresentationMode, Viewport } from "../../../contracts/spec/platforms.ts"; export interface ResolvedBuildPlanContent { - readonly app: Pick & + readonly app: Pick & Pick & { readonly output: string; }; diff --git a/framework/src/manifest/resolve.ts b/framework/src/manifest/resolve.ts index 70e8c44..2d6fbeb 100644 --- a/framework/src/manifest/resolve.ts +++ b/framework/src/manifest/resolve.ts @@ -349,6 +349,7 @@ export function resolveBuildPlan( app: { id: manifest.id, title: manifest.title, + version: manifest.version, entry: manifest.app.entry, output, framework: manifest.app.framework, diff --git a/hosts/switch/source/main.c b/hosts/switch/source/main.c index 778532b..77a8afb 100644 --- a/hosts/switch/source/main.c +++ b/hosts/switch/source/main.c @@ -92,7 +92,7 @@ static uint8_t analog_axis(int32_t value) { } static int32_t pocket_analog(HidAnalogStickState stick) { - return analog_axis(stick.x) | ((int32_t)analog_axis(-stick.y) << 8); + return ((int32_t)analog_axis(stick.x) << 8) | analog_axis(-stick.y); } int main(int argc, char **argv) { diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 2a9d2af..e7995ce 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -54,7 +54,7 @@ describe("published PocketJS CLI", () => { expect(readFileSync(join(app, "main.tsx"), "utf8")) .toContain('from "@pocketjs/framework/solid"'); - for (const target of ["psp", "vita"] as const) { + for (const target of ["psp", "vita", "switch"] as const) { const resolution = validateAndResolveBuildPlan(manifest, { target }); expect(resolution.ok, target).toBe(true); } @@ -69,7 +69,7 @@ describe("published PocketJS CLI", () => { script: import.meta.url, args: Bun.argv.slice(2), }));\n`; - for (const name of ["pocket", "play", "symbian", "vita"]) { + for (const name of ["pocket", "play", "symbian", "vita", "switch"]) { writeFileSync(join(scripts, `${name}.ts`), recorder); } @@ -78,7 +78,9 @@ describe("published PocketJS CLI", () => { { cliArgs: ["compile", "--target", "vita"], script: "pocket.ts", args: ["compile", "--target", "vita"] }, { cliArgs: ["build", "--target", "vita", "--", "--release"], script: "pocket.ts", args: ["build", "--target", "vita", "--", "--release"] }, { cliArgs: ["play", "vita", "hero"], script: "play.ts", args: ["vita", "hero"] }, + { cliArgs: ["play", "switch", "hero"], script: "play.ts", args: ["switch", "hero"] }, { cliArgs: ["vita", "hero", "--release"], script: "vita.ts", args: ["hero", "--release"] }, + { cliArgs: ["switch", "hero", "--release"], script: "switch.ts", args: ["hero", "--release"] }, { cliArgs: ["symbian", "doctor", "--device"], script: "symbian.ts", args: ["doctor", "--device"] }, { cliArgs: ["symbian", "coda", "usb"], script: "symbian.ts", args: ["coda", "usb"] }, { cliArgs: ["symbian", "coda", "usb", "launch"], script: "symbian.ts", args: ["coda", "usb", "launch"] }, diff --git a/tests/fixtures/plans/portable-psp.plan.json b/tests/fixtures/plans/portable-psp.plan.json index 988d86d..47a8323 100644 --- a/tests/fixtures/plans/portable-psp.plan.json +++ b/tests/fixtures/plans/portable-psp.plan.json @@ -2,6 +2,7 @@ "app": { "id": "dev.pocket-stack.telemetry", "title": "Pocket Telemetry", + "version": "0.1.0", "entry": "app/main.tsx", "output": "main", "framework": "solid" @@ -27,5 +28,5 @@ "input.buttons": true, "text.glyphs.baked": true }, - "planHash": "sha256:b8224b08498af9535f31343a15fd769f1dfba05868f46cc4db94d174200ba1ad" + "planHash": "sha256:5a4c61d865f5a384d021fe81ae84a74e911025f72c33d96a964cbcd986d5e58c" } diff --git a/tests/fixtures/plans/portable-vita.plan.json b/tests/fixtures/plans/portable-vita.plan.json index 5b56b84..12da15c 100644 --- a/tests/fixtures/plans/portable-vita.plan.json +++ b/tests/fixtures/plans/portable-vita.plan.json @@ -2,6 +2,7 @@ "app": { "id": "dev.pocket-stack.telemetry", "title": "Pocket Telemetry", + "version": "0.1.0", "entry": "app/main.tsx", "output": "main", "framework": "solid" @@ -27,5 +28,5 @@ "input.buttons": true, "text.glyphs.baked": true }, - "planHash": "sha256:c763eee7c84f6ac5101bb82c75b21706b691279c8043e0561d2a193b71fc9deb" + "planHash": "sha256:612d2c356c2c64cf229f204bf7491a12a0d0e381124dbf809c1fe719988a7279" } diff --git a/tests/host-build-inputs.test.ts b/tests/host-build-inputs.test.ts index 3a0d030..7edf10c 100644 --- a/tests/host-build-inputs.test.ts +++ b/tests/host-build-inputs.test.ts @@ -20,6 +20,8 @@ describe("custom host build boundary", () => { const plan = portablePlan(); expect(extractHostBuildInputs(plan, { expectedTarget: "psp" })).toEqual({ appOutput: "main", + appTitle: "Pocket Telemetry", + appVersion: "0.1.0", target: "psp", hostAbi: 1, viewport: { diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index e2c0637..003c620 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -268,6 +268,7 @@ describe("semantic resolution", () => { expect(result.plan.app).toEqual({ id: "dev.pocket-stack.telemetry", title: "Pocket Telemetry", + version: "0.1.0", entry: "app/main.tsx", output: "main", framework: "solid", diff --git a/tools/cli/bin.mjs b/tools/cli/bin.mjs index 8216966..f0fa07b 100644 --- a/tools/cli/bin.mjs +++ b/tools/cli/bin.mjs @@ -5,10 +5,10 @@ // pocket doctor diagnose the local toolchain // pocket setup [--yes] run the checkout's pinned, idempotent bootstrap // pocket create scaffold a manifest-first demo app -// pocket check|compile|build --target [...args] +// pocket check|compile|build --target [...args] // resolve pocket.json once, then build from its plan -// pocket play vita build, install and launch a demo in Vita3K -// pocket dev|psp|vita|hw|psplink|devtools|tape [...args] +// pocket play build and launch a demo in an emulator +// pocket dev|psp|vita|switch|hw|psplink|devtools|tape [...args] // low-level passthrough to the checkout's bun scripts // pocket symbian Nokia E7 toolchain doctor/setup/build/deploy // @@ -357,6 +357,7 @@ function create(name) { console.log(C.dim(` pocket check --target psp --manifest apps/${name}/pocket.json`)); console.log(C.dim(` pocket build --target psp --manifest apps/${name}/pocket.json -- --release`)); console.log(C.dim(` pocket build --target vita --manifest apps/${name}/pocket.json -- --release`)); + console.log(C.dim(` pocket build --target switch --manifest apps/${name}/pocket.json -- --release`)); } // --------------------------------------------------------------------------- @@ -367,6 +368,7 @@ const SCRIPTS = { dev: "tools/dev.ts", psp: "tools/psp.ts", vita: "tools/vita.ts", + switch: "tools/switch.ts", symbian: "tools/symbian.ts", hw: "tools/hw.ts", psplink: "tools/psplink.ts", @@ -410,11 +412,13 @@ const HELP = `${C.bold("pocket")} — the PocketJS toolchain CLI pocket check --target T validate pocket.json, target APIs and app types pocket compile --target T check + emit JS/pak from one resolved build plan - pocket build --target T check + compile + package PSP or Vita artifacts + pocket build --target T check + compile + package native artifacts pocket play vita build, install and launch a demo in Vita3K + pocket play switch build and launch a demo in Ryujinx pocket dev -main build + serve an app in the browser pocket psp build the PSP EBOOT pocket vita build the PS Vita VPK + pocket switch build the Nintendo Switch NRO pocket symbian Nokia E7 doctor/setup/build-probe/deploy pocket hw build + run on a real PSP over PSPLINK pocket psplink interactive multi-app switcher on a real PSP @@ -440,6 +444,7 @@ switch (cmd) { case "dev": case "psp": case "vita": + case "switch": case "symbian": case "hw": case "psplink": diff --git a/tools/play.ts b/tools/play.ts index 3a5298c..8e18fab 100644 --- a/tools/play.ts +++ b/tools/play.ts @@ -24,6 +24,7 @@ const VPK = `${RELEASE_DIR}/pocketjs-vita.vpk`; const VPK_STAMP = `${VPK}.play.json`; const PLAY_DIR = `${ROOT}dist/play-vita`; const PLAY_CONFIG = `${PLAY_DIR}/config.yml`; +const SWITCH_PLAY_DIR = `${ROOT}dist/play-switch`; const defaultVita3kApp = [`${HOME}/Applications/Vita3K.app`, "/Applications/Vita3K.app"].find( existsSync, @@ -37,11 +38,21 @@ const defaultConfig = ? `${HOME}/Library/Application Support/Vita3K/Vita3K/config.yml` : `${HOME}/.config/Vita3K/config.yml`; const sourceConfig = process.env.VITA3K_CONFIG || defaultConfig; +const defaultRyujinxApp = ["/Applications/Ryujinx.app", `${HOME}/Applications/Ryujinx.app`].find( + existsSync, +); +const ryujinxApp = process.env.RYUJINX_APP || defaultRyujinxApp; +const ryujinxCandidate = + process.env.RYUJINX || + (ryujinxApp ? `${ryujinxApp}/Contents/MacOS/Ryujinx` : Bun.which("Ryujinx")); +const ryujinx = ryujinxCandidate?.includes("/") + ? ryujinxCandidate + : Bun.which(ryujinxCandidate ?? ""); function demos(): string[] { const root = `${ROOT}apps`; return readdirSync(root, { withFileTypes: true }) - .filter((entry) => entry.isDirectory() && existsSync(`${root}/${entry.name}/main.tsx`)) + .filter((entry) => entry.isDirectory() && existsSync(`${root}/${entry.name}/pocket.json`)) .map((entry) => entry.name) .sort(); } @@ -49,7 +60,7 @@ function demos(): string[] { function usage(message?: string): never { if (message) console.error(`play: ${message}\n`); console.error( - "usage: bun play vita [--fullscreen] [--no-build] [--no-launch] [--framework=solid|vue-vapor]\n" + + "usage: bun play [--fullscreen] [--no-build] [--no-launch] [--framework=solid|vue-vapor]\n" + `demos: ${demos().join(", ")}`, ); process.exit(message ? 2 : 0); @@ -78,8 +89,8 @@ function vitaFsFrom(configPath: string, config: string): string { return configured || `${dirname(configPath)}/fs`; } -function vpkSha256(): string { - return createHash("sha256").update(readFileSync(VPK)).digest("hex"); +function sha256(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); } async function run(command: string[], label: string): Promise { @@ -88,6 +99,121 @@ async function run(command: string[], label: string): Promise { if (code !== 0) throw new Error(`${label} failed with exit code ${code}`); } +function playManifest( + demo: string, + fallbackFramework: string, + frameworkOverride?: string, +): { + manifest: Record; + path: string; + framework: string; +} { + const manifest = demoManifestFor(ROOT, demo, fallbackFramework) as Record; + const framework = manifest.app?.framework; + if (typeof framework !== "string" || framework.length === 0) { + throw new Error(`demo ${demo} has no app.framework`); + } + if (frameworkOverride && frameworkOverride !== framework) { + throw new Error( + `demo ${demo} owns framework ${framework}; remove --framework=${frameworkOverride} or use --framework=${framework}`, + ); + } + const path = `${ROOT}.pocket/play/${demo}.json`; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n"); + return { manifest, path, framework }; +} + +async function playSwitch( + demo: string, + fallbackFramework: string, + frameworkOverride: string | undefined, + noBuild: boolean, + noLaunch: boolean, +): Promise { + const { manifest, path: manifestPath, framework } = playManifest( + demo, + fallbackFramework, + frameworkOverride, + ); + const output = manifest.app?.output; + if (typeof output !== "string" || output.length === 0) { + throw new Error(`demo ${demo} has no app.output`); + } + const nro = `${ROOT}dist/switch/${output}.nro`; + const stamp = `${SWITCH_PLAY_DIR}/${output}.json`; + + if (!noBuild) { + rmSync(nro, { force: true }); + rmSync(stamp, { force: true }); + console.log(`PocketJS play: building ${demo} for Nintendo Switch ...`); + await run( + [ + Bun.which("bun") ?? "bun", + "tools/pocket.ts", + "build", + "--target", + "switch", + "--manifest", + manifestPath, + "--project-root", + ROOT, + "--", + "--release", + ], + "Switch build", + ); + if (!existsSync(nro)) throw new Error(`Switch build did not produce ${nro}`); + mkdirSync(SWITCH_PLAY_DIR, { recursive: true }); + writeFileSync(stamp, JSON.stringify({ demo, framework, sha256: sha256(nro) }) + "\n"); + } else { + if (!existsSync(nro)) { + throw new Error(`NRO not found at ${nro}; remove --no-build and retry`); + } + let cached: { demo?: string; framework?: string; sha256?: string } = {}; + try { + cached = JSON.parse(readFileSync(stamp, "utf8")); + } catch { + // The actionable error below covers missing and invalid stamps. + } + if ( + cached.demo !== demo || + cached.framework !== framework || + cached.sha256 !== sha256(nro) + ) { + throw new Error(`the cached NRO is not ${demo}; remove --no-build and retry`); + } + } + + if (noLaunch) { + console.log("PocketJS play: --no-launch requested; emulator not started"); + process.exit(0); + } + if (!ryujinx || !existsSync(ryujinx)) { + throw new Error( + "Ryujinx not found; set RYUJINX/RYUJINX_APP or install /Applications/Ryujinx.app", + ); + } + + const proc = Bun.spawn([ryujinx, nro], { + cwd: ROOT, + detached: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + const earlyExit = await Promise.race([ + proc.exited.then((code) => code), + Bun.sleep(2_000).then(() => null), + ]); + if (earlyExit !== null) { + throw new Error(`Ryujinx exited during launch with code ${earlyExit}`); + } + proc.unref(); + console.log(`PocketJS play: ${demo} is running in Ryujinx`); + process.exit(0); +} + async function stopVita3K(): Promise { if (!vita3k || !Bun.which("pgrep") || !Bun.which("pkill")) return; const isRunning = (): boolean => @@ -124,7 +250,7 @@ const args = Bun.argv.slice(2); if (args.includes("--help") || args.includes("-h")) usage(); const platform = args.shift(); const demoArg = args.shift(); -const playTargets = { vita: true } as const; +const playTargets = { vita: true, switch: true } as const; if (!platform || !(platform in playTargets)) usage(`unsupported platform ${platform ?? ""}`); if (!demoArg) usage("missing demo name"); @@ -141,6 +267,21 @@ if (!demos().includes(demo)) usage(`unknown demo ${demoArg}`); const identity = demoIdentity(demo); const titleId = vitaTitleId(identity.id); const stagedApp = `${PLAY_DIR}/${titleId}`; +const frameworkOverride = framework?.slice("--framework=".length); +const fallbackFramework = + frameworkOverride || (demo.endsWith("vue-vapor") ? "vue-vapor" : "solid"); + +if (platform === "switch") { + if (fullscreen) usage("--fullscreen is not supported by the Switch launcher"); + await playSwitch(demo, fallbackFramework, frameworkOverride, noBuild, noLaunch); +} + +const { path: manifestPath, framework: actualFramework } = playManifest( + demo, + fallbackFramework, + frameworkOverride, +); + if (!vita3k || !existsSync(vita3k)) { throw new Error("Vita3K not found; set VITA3K/VITA3K_APP or install ~/Applications/Vita3K.app"); } @@ -148,14 +289,7 @@ if (!existsSync(sourceConfig)) { throw new Error(`Vita3K config not found at ${sourceConfig}; run Vita3K once or set VITA3K_CONFIG`); } -const requestedFramework = - framework?.slice("--framework=".length) || (demo.endsWith("vue-vapor") ? "vue-vapor" : "solid"); - if (!noBuild) { - const manifest = demoManifestFor(ROOT, demo, requestedFramework) as Record; - const manifestPath = `${ROOT}.pocket/play/${demo}.json`; - mkdirSync(dirname(manifestPath), { recursive: true }); - writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n"); const buildArgs = [ Bun.which("bun") ?? "bun", "tools/pocket.ts", @@ -174,7 +308,12 @@ if (!noBuild) { } if (!existsSync(VPK)) throw new Error(`VPK not found at ${VPK}; remove --no-build and retry`); if (!noBuild) { - writeFileSync(VPK_STAMP, JSON.stringify({ demo, framework: requestedFramework, titleId, sha256: vpkSha256() })); + writeFileSync(VPK_STAMP, JSON.stringify({ + demo, + framework: actualFramework, + titleId, + sha256: sha256(VPK), + })); } else { let cached: { demo?: string; framework?: string; titleId?: string; sha256?: string } = {}; try { @@ -185,9 +324,9 @@ if (!noBuild) { } if ( cached.demo !== demo || - cached.framework !== requestedFramework || + cached.framework !== actualFramework || cached.titleId !== titleId || - cached.sha256 !== vpkSha256() + cached.sha256 !== sha256(VPK) ) { throw new Error(`the cached VPK is not ${demo}; remove --no-build and retry`); } diff --git a/tools/switch.ts b/tools/switch.ts index a48ec32..eb50a4b 100644 --- a/tools/switch.ts +++ b/tools/switch.ts @@ -5,7 +5,6 @@ import { extractHostBuildInputs, hostBuildEnvironment, } from "../framework/src/manifest/host-build-inputs.ts"; -import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; const root = resolve(fileURLToPath(new URL("..", import.meta.url))); const hostDir = resolve(root, "hosts/switch"); @@ -28,11 +27,49 @@ function flag(name: string): boolean { function usage(message?: string): never { if (message) console.error(`PocketJS Switch: ${message}`); console.error( - "usage: bun tools/switch.ts --plan= --project-root= --outdir= [--skip-build]", + "usage: bun switch [--release]\n" + + " or: bun tools/switch.ts --plan= --project-root= --outdir= [--skip-build]", ); process.exit(2); } +function validateNacpText(label: string, value: string, maxBytes: number): string { + if (/["\\$`\r\n\0]/.test(value)) { + throw new Error(`PocketJS Switch: ${label} contains characters unsafe for the NACP build`); + } + const bytes = new TextEncoder().encode(value).length; + if (bytes > maxBytes) { + throw new Error(`PocketJS Switch: ${label} exceeds the NACP limit of ${maxBytes} UTF-8 bytes`); + } + return value; +} + +const directApp = args[0] && !args[0].startsWith("--") + ? args.shift()!.replace(/-main$/, "") + : undefined; +if (directApp) { + if (!/^[a-z][a-z0-9-]*$/.test(directApp)) usage(`invalid demo name ${directApp}`); + const manifest = resolve(root, `apps/${directApp}/pocket.json`); + if (!existsSync(manifest)) usage(`demo manifest not found: apps/${directApp}/pocket.json`); + const child = Bun.spawn( + [ + process.execPath, + resolve(root, "tools/pocket.ts"), + "build", + "--target", + "switch", + "--manifest", + manifest, + "--project-root", + root, + "--", + ...args, + ], + { cwd: root, stdout: "inherit", stderr: "inherit" }, + ); + process.exit(await child.exited); +} + const planPath = option("plan"); const projectRoot = resolve(option("project-root") ?? root); const outdir = resolve(projectRoot, option("outdir") ?? "dist"); @@ -43,7 +80,8 @@ if (args.length > 0) usage(`unknown option ${args[0]}`); const planInput: unknown = await Bun.file(resolve(planPath)).json(); const inputs = extractHostBuildInputs(planInput, { expectedTarget: "switch" }); -const plan = planInput as ResolvedBuildPlan; +const appTitle = validateNacpText("app title", inputs.appTitle, 511); +const appVersion = validateNacpText("app version", inputs.appVersion, 15); if ( inputs.hostAbi !== 4 || inputs.viewport.logical[0] !== 480 || @@ -150,9 +188,9 @@ await run( [ "make", `RUST_PROFILE=${rustProfile}`, - `APP_TITLE=${plan.app.title}`, + `APP_TITLE=${appTitle}`, "APP_AUTHOR=PocketJS", - "APP_VERSION=0.1.0", + `APP_VERSION=${appVersion}`, ], "Switch host build", hostDir, From ca08bf1031d978258503ae321b2662acefbcd2b5 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Fri, 24 Jul 2026 17:06:35 +0800 Subject: [PATCH 6/9] docs(switch): document the supported host toolchain --- README.md | 28 ++- docs/RUNTIMES.md | 1 + docs/STRUCTURE.md | 14 +- docs/SWITCH.md | 500 -------------------------------------- hosts/switch/README.md | 133 ++++++++++ package.json | 1 + tests/npm-package.test.ts | 1 + tools/cli/README.md | 15 +- tools/cli/package.json | 4 +- 9 files changed, 178 insertions(+), 519 deletions(-) delete mode 100644 docs/SWITCH.md create mode 100644 hosts/switch/README.md diff --git a/README.md b/README.md index 65867c7..d550c35 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,13 @@ animation under an 8 MB memory budget. Write Solid JSX, Vue Vapor JSX, or Vue single-file components, run them on QuickJS, and let PocketJS move layout, styling, text and animation into a tiny `no_std` Rust core. -It runs on real PSP and PS Vita hardware, PPSSPP, Vita3K, the browser (WASM), -native macOS windows (wgpu) and headless Bun. Full design + contracts: -[docs/DESIGN.md](./DESIGN.md). PocketJS is growing into a family of specialized +It runs on real PSP and PS Vita hardware, Nintendo Switch homebrew in Ryujinx, +PPSSPP, Vita3K, the browser (WASM), native macOS windows (wgpu), and headless +Bun. Full design + contracts: +[docs/DESIGN.md](./docs/DESIGN.md). PocketJS is growing into a family of specialized runtimes — Rust cores, spec-pinned surfaces, one QuickJS guest — documented -in [docs/RUNTIMES.md](./RUNTIMES.md); the 3D base lives in -[engine/pocket3d/](./pocket3d/), and its first game runtime is +in [docs/RUNTIMES.md](./docs/RUNTIMES.md); the 3D base lives in +[engine/pocket3d/](./engine/pocket3d/), and its first game runtime is [OpenStrike](https://github.com/pocket-stack/open-strike). ## Screenshots @@ -51,9 +52,9 @@ Or drive everything through the [`pocket` CLI](https://www.npmjs.com/package/@po `npm i -g @pocketjs/cli`, then `pocket doctor` checks the Bun / Rust / PSP toolchain (`pocket setup` runs the same pinned bootstrap), `pocket create ` scaffolds a format-2 manifest, and `pocket check|compile|build --target -psp|vita` delegate to the canonical resolver. Low-level host-development -commands such as `pocket dev`, `pocket psp`, `pocket vita`, and `pocket play` -remain available. +psp|vita|switch` delegate to the canonical resolver. Low-level host-development +commands such as `pocket dev`, `pocket psp`, `pocket vita`, `pocket switch`, and +`pocket play` remain available. The build is two-pass: pass 1 transforms every module reachable from the entry (framework-specific JSX + TypeScript, or Vue SFC compiled directly to Vapor; @@ -146,16 +147,20 @@ required router package. bun run bootstrap # idempotent PSP toolchain setup bun play vita hero # build, install and launch in Vita3K bun play vita gallery --fullscreen # stretch to the host's full screen +bun play switch hero # build and launch the NRO in Ryujinx bun play --help # list every runnable demo bun run test # spec contract + tailwind parser tests bun pocket check --target psp # validate pocket.json + resolved target contract bun pocket compile --target psp # typecheck and compile, for custom native hosts bun pocket build --target psp # typecheck, compile, and package the target pocket build --target vita -- --release +pocket build --target switch -- --release pocket play vita hero # build, install and launch in Vita3K +pocket play switch hero # build and launch in Ryujinx bun tools/build.ts [--framework=solid|vue-vapor] [--extra-chars=…] bun run psp # low-level PSP demo build bun run vita # low-level Vita demo build +bun run switch --release # low-level Nintendo Switch NRO build bun run dev [app] # browser dev host bun run wasm # rebuild the wasm core bun run e2e:vita # Vita3K, native-density 960x544 golden E2E @@ -241,6 +246,13 @@ fonts, vectors and core masks at Vita's native 960x544 density. Physical controls, left-analog input, and front-panel multi-touch snapshots are supported; PSP builds retain their controller-only fallback. +The Nintendo Switch homebrew host is documented in +[hosts/switch/README.md](./hosts/switch/README.md). It runs the same QuickJS +Solid and Vue Vapor guests, renders the density-2 surface into a centered +960x544 framebuffer region, and packages manifest metadata plus RomFS assets as +an NRO. Its devkitPro, Rust, QuickJS/newlib, and Ryujinx requirements live with +the host rather than in the general framework setup. + ## The Pocket Launcher (on-device app switching) One PSP EBOOT or Vita VPK can embed every app admitted by that target plus a diff --git a/docs/RUNTIMES.md b/docs/RUNTIMES.md index fbbfb38..1e2041a 100644 --- a/docs/RUNTIMES.md +++ b/docs/RUNTIMES.md @@ -155,6 +155,7 @@ The grammar is implemented once, as infrastructure every runtime reuses: | `pocketjs-psp` (lib) | Guest hosting + `ui` surface, PSP edition: the arena allocator, the QuickJS embedding, the DrawList GE backend (with an overlay mode for 3D compositing), pak feeding, and the DevTools mailbox — everything the 2D EBOOT proved, linkable by game EBOOTs. | | `pocket3d-vita` | The 3D substrate, Vita edition: CPU projection and six-plane clipping into vita2d/GXM at 960x544, painter-sorted so a PocketJS HUD can share the same scene. | | `pocketjs-vita` (lib) | Guest hosting + `ui` surface, Vita edition: QuickJS, density-2 pak/font resources, controller/dual-analog input, logical-coordinate front-panel contacts and a native-density 960x544 vita2d backend over the portable 480x272 logical layout. | +| `pocketjs-switch` (lib + libnx shell) | Guest hosting + `ui` surface, Nintendo Switch homebrew edition: a libnx shell owns RomFS, controller input and the 1280x720 framebuffer; a Rust static library owns QuickJS, the shared native `HostOps`, pak feeding and the deterministic density-2 software rasterizer, centered as a 960x544 surface. | A specialized runtime is then a thin composition. OpenStrike is: diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index b34fa84..1de6f26 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -19,7 +19,8 @@ pocketjs/ │ standalone; see each crate's Cargo.toml for its toolchain) ├─ hosts/ Surfaces: every embedding of the cores │ ├─ psp/ QuickJS + rust-psp EBOOT host -│ ├─ vita/ Vita host +│ ├─ vita/ QuickJS + vitasdk VPK host +│ ├─ switch/ QuickJS + libnx/Rust NRO host │ ├─ esp32p4/ reusable ESP-IDF PPA adapter + component smoke build │ ├─ pocketbook/ PocketBook e-reader host (inkview, standalone lone-bin crate) │ ├─ symbian/ Nokia E7 Qt/QuickJS runtime + visible toolchain probe @@ -28,7 +29,7 @@ pocketjs/ ├─ framework/ Guest: @pocketjs/framework │ ├─ src/ the TS runtime (Solid + Vue Vapor renderers, components, input, osk…) │ └─ compiler/ the interpreted-path build pipeline (jsx-plugin, tailwind, pak) -├─ vapor/ Pocket Vapor: the AOT compiler family (Vue Vapor subset → GBA/GB/NES) +├─ vapor/ Pocket Vapor: the AOT compiler family (Vue Vapor subset → GBA/GB/NES/ESP32) ├─ contracts/ single sources of truth binding the layers │ ├─ spec/ op contract, platform contracts, manifest + package spec, gen-rust │ └─ schema/ published JSON schemas (pocket-2.json) @@ -52,8 +53,11 @@ New things go where the axis says — never invent a top-level directory: - **A new Rust simulation core** → `engine/` (workspace member if it builds on desktop; excluded standalone crate if it needs a console toolchain). - **A new platform embedding** (ESP32, 3DS, …) → `hosts//`. -- **A new AOT backend** (Vapor gains a console) → `vapor/runtime//`; - the vapor compiler grows a target entry, the top level does not change. +- **A new AOT backend** (Pocket Vapor gains a device) → + `vapor/runtime//`; the vapor compiler grows a target entry, the top + level does not change. A QuickJS guest host such as `hosts/switch/` does not + imply an AOT backend: Vue Vapor guests already use the shared framework + runtime and `HostOps`. - **A new demo** → `apps//` with a `pocket.json`. Standalone products keep the `pocket-` separate-repo convention and do not move in. - **A new command** → `tools/.ts`. No single-file top-level directories. @@ -67,6 +71,6 @@ New things go where the axis says — never invent a top-level directory: change; the `exports`/`files` maps in package.json absorb internal moves. - **Cargo stays non-workspace where toolchains demand it**: `engine/core`, `engine/wasm`, `engine/symbian`, `engine/backends/esp32p4-ppa`, `hosts/psp`, - `hosts/vita`, `hosts/pocketbook`, and the gu/vita 3D crates each stand alone + `hosts/vita`, `hosts/switch`, `hosts/pocketbook`, and the gu/vita 3D crates each stand alone with their own lockfiles. `engine/Cargo.toml` is the one desktop workspace. - **Moves are `git mv`** — history stays traceable. diff --git a/docs/SWITCH.md b/docs/SWITCH.md deleted file mode 100644 index cc98c30..0000000 --- a/docs/SWITCH.md +++ /dev/null @@ -1,500 +0,0 @@ -# Nintendo Switch port plan - -Status: implementation in progress. M0 through M3 are implemented: the -release `hero` NRO runs the PocketJS QuickJS guest, renders through the shared -software rasterizer at 60 FPS in Ryujinx, and accepts controller input. The -developer loop and broader demo acceptance work remain. - -## Goal - -Ship PocketJS applications as Nintendo Switch homebrew `.nro` files and run -them in Ryujinx and, later, on homebrew-enabled hardware. - -The first compatibility target is the current PSP application surface: - -- all 17 PSP-admissible demos; -- the Pocket Launcher and whole-guest app switching; -- `input.buttons`, `input.analog.left`, `input.cursor`, and - `text.glyphs.baked`; -- the existing 480x272 logical viewport and deterministic frame contract. - -The 17 applications are `cafe`, `cards`, `chrome`, `cursor`, `gallery`, `hero`, -`hero-vue-sfc`, `hero-vue-vapor`, `im`, `library`, `motions`, `music`, -`notifications`, `settings`, `stats`, `vue-sfc-lab`, and `zoomlab`. -`ipod-nano` and `note` are not PSP-admissible today and are not part of this -port's initial acceptance set. - -## Corrections to the initial proposal - -The direction was right—Switch is a new guest runtime target—but three details -need changing: - -1. PocketJS does not currently have a generic `PocketHost` TypeScript - interface implemented by each native host. Applications compile against a - target profile and a `ResolvedBuildPlan`; native QuickJS hosts install the - synchronous `globalThis.ui` `HostOps` ABI and consume the Rust core's - `DrawList`. -2. A new target therefore touches more than `hosts/switch`: the target - registry, backend dispatch, build/package tooling, runtime ABI checks, - launcher admission, and target tests are all part of the port. -3. The official Switch OpenGL examples use EGL plus Mesa's OpenGL 4.3 core - profile, not OpenGL ES 3.2. `deko3d` is the lower-level native alternative. - Neither should be a day-one dependency before the existing deterministic - software rasterizer is measured. - -Adding another desktop simulator is also out of scope. `hosts/web` and -`hosts/sim` already provide the fast development and deterministic test loops; -Ryujinx supplies the Switch-specific integration loop. - -## Existing architecture - -```text - pocket.json target profile - └──── resolve ────┘ - │ - ▼ - .pocket//plan.json - │ - ┌─────┴──────────┐ - ▼ ▼ - JS/pak compiler native backend - │ │ - └──── embed ─────┘ - │ - ▼ - native package -``` - -The reusable boundaries are: - -- `contracts/spec/platforms.ts`: capabilities and stock target facts; -- `framework/src/host.ts`: the JavaScript/native `HostOps` contract and frame - callback; -- `engine/core`: the `no_std` retained UI core, `DrawList`, and deterministic - software rasterizer; -- `hosts/psp` and `hosts/vita`: QuickJS embedding, pak feeding, input, - rendering, and guest lifecycle; -- `tools/pocket.ts`: one resolver/compiler path followed by typed native - backend dispatch. - -The Switch port must reuse these boundaries. It must not introduce a parallel -framework API or a Switch branch in application code. - -## Proposed Switch architecture - -The first implementation should use the standard devkitPro/libnx build and -packaging path, with a thin C shell around a Rust static library: - -```text - PocketJS build plan - │ - ┌───────────┴───────────┐ - ▼ ▼ - app.js + app.pak host build inputs - │ │ - └────── NRO RomFS ──────┘ - │ - ▼ - ┌─────────────────────────────────────────────────────────────┐ - │ pocketjs-switch.nro │ - │ │ - │ libnx C shell │ - │ applet loop · framebuffer · PadState · RomFS · logging │ - │ │ │ - │ ▼ │ - │ Rust static library │ - │ QuickJS · globalThis.ui · pak feed · pocketjs-core │ - │ │ │ - │ ▼ │ - │ deterministic DrawList rasterizer │ - └─────────────────────────────────────────────────────────────┘ -``` - -This split is a spike decision, not yet a proven build: - -- libnx owns the established NRO startup, services, input, framebuffer, and - packaging path; -- Rust retains the existing core and most of the PSP/Vita QuickJS `HostOps` - implementation; -- QuickJS and Rust can resolve C runtime symbols from devkitA64/newlib at the - final link; -- application artifacts live in NRO RomFS, avoiding another generated Rust - byte table during the first port. - -The pure-Rust `cargo-nx` route is not the initial choice. Its official Rust -target is Tier 3 and useful, but PocketJS also needs QuickJS C/newlib -integration. Replacing the proven libnx startup and packaging path does not -reduce the first port's risk. - -### Display contract - -The proposed initial profile is: - -```text -target id switch -platform switch -form takeover -host ABI 4 -physical viewport 1280x720 -logical viewport 480x272 -presentation integer-fit -raster density 2 -``` - -`host ABI = 4` is a proposed new native contract identity; it is not assigned -until the host exists and its tests pass. - -Integer-fit produces a 960x544 content surface centered in the 1280x720 Switch -framebuffer: - -```text -1280x720 -┌──────────────────────────────────────────────────────────────┐ -│ 88 px │ -│ ┌────────────────────────────────────────────┐ │ -│ 160 px │ 960x544 content │ 160 px │ -│ │ 480x272 logical viewport at 2x │ │ -│ └────────────────────────────────────────────┘ │ -│ 88 px │ -└──────────────────────────────────────────────────────────────┘ -``` - -This preserves the manifests' existing `integer-fit` promise and produces the -same density-2 raster inputs as Vita. Stretching to 16:9 would be visually -small but contractually wrong. A future `fit` presentation should be designed -as an explicit manifest/profile feature rather than silently changing -`integer-fit`. - -### Input mapping - -The Switch shell maps libnx `PadState` to the existing PocketJS button mask: - -| Switch input | PocketJS input | -| --- | --- | -| D-pad | Up, down, left, right | -| A | Circle / confirm | -| B | Cross / cancel | -| X | Triangle | -| Y | Square | -| Plus | Start | -| Minus | Select / launcher summon | -| L or ZL | Left trigger | -| R or ZR | Right trigger | -| Left stick | Packed analog value, axes 0–255, center 128 | - -The initial target advertises only capabilities that have end-to-end tests. -Touch, right analog, motion, rumble, audio, networking, and service mailboxes -do not block PSP parity and must not be advertised speculatively. - -### Rendering ladder - -Stop at the first renderer that sustains the required workload: - -1. Use `pocketjs_core::raster::render_scaled(..., 2)` to produce the existing - deterministic 960x544 RGBA surface, then copy it into the centered libnx - 1280x720 linear framebuffer. -2. Measure release builds in Ryujinx and on hardware. Keep this renderer if it - sustains 60 FPS with the current golden applications. -3. Only if the measurement fails, add an OpenGL 4.3 `DrawList` backend using - `switch-mesa` and `switch-glad`. -4. Consider direct `deko3d` only if the OpenGL backend has a measured size, - latency, or compatibility problem. - -The software renderer is already the byte-exact oracle used by web and Vita -capture tests. Starting there gives correct gradients, glyphs, clipping, -textures, sprites, and transformed triangles before taking on a second -problem: a new GPU backend. - -## Local workstation audit - -Observed on 2026-07-24: - -- Apple Silicon macOS; -- Ryujinx at `/Applications/Ryujinx.app`, build `1.3.3-e2143d4`; -- Ryujinx has created its normal application support directory and recognizes - `.nro` as a document type; -- Bun, CMake, Ninja, Homebrew, and rustup are present; -- devkitPro pacman `6.0.2`, devkitA64 `r30`, GCC `16.1.0`, libnx `4.12.0`, - switch-tools `1.13.1`, deko3d `0.5.0`, and switch-examples `20260201` are - installed under `/opt/devkitpro`; -- the official pacman installer matched GitHub's published asset digest - `sha256:da74156211cd3d8664ba7fb95544fbc0a2fbef1dd87b1a6bfafb30d2d446cd30`; -- the partial `nightly-2026-05-28` install was repaired in place with - `rust-src`; it reports `rustc 1.98.0-nightly (57d06900f 2026-05-27)`; -- the official `simplegfx.nro` builds successfully from a writable copy of - `/opt/devkitpro/examples/switch/graphics/simplegfx`; -- the installed macOS `elf2nro` silently ignores `--romfsdir`, while an - explicit `build_romfs` followed by `elf2nro --romfs=` embeds the - expected ASET payload. The PocketJS Makefile uses the working explicit path; -- the built-in Rust Switch target omits the Unix/newlib cfg values required by - the `libc` crate, so the backend supplies those known target facts while - building the Rust static library; -- the pinned QuickJS build needs Switch newlib's `malloc_usable_size` - declaration and the same zero-timezone fallback used by the PSP/Vita hosts; -- `hero-main.nro` loads its normal `app.js` and `app.pak`, completes the host - ABI handshake, renders the centered 960x544 content surface at 60 FPS, and - responds to controller input in Ryujinx. - -The reproducible emulator invocation is: - -```sh -/Applications/Ryujinx.app/Contents/MacOS/Ryujinx /path/to/application.nro -``` - -After user-local keys and firmware were configured, Ryujinx loaded -`simplegfx.nro` as homebrew, reported firmware `22.5.0`, created the libnx -framebuffer, and sustained 60 FPS. M0 is complete. Keys and firmware remain -user-local proprietary inputs and do not belong in this repository. - -## Toolchain bootstrap - -The supported macOS path is devkitPro pacman plus the `switch-dev` package -group. `switch-dev` includes devkitA64, libnx, Switch tools, deko3d, and the -official examples. The first bootstrap should not install Mesa, `cargo-nx`, or -unrelated Switch port libraries. - -Planned commands: - -```sh -curl -fLO \ - https://github.com/devkitPro/pacman/releases/download/v6.0.2/devkitpro-pacman-installer.pkg -sudo installer -pkg devkitpro-pacman-installer.pkg -target / -sudo dkp-pacman -Syu -sudo dkp-pacman -S --needed switch-dev -``` - -The PocketJS build tool should supply its own environment instead of requiring -shell startup-file edits: - -```sh -export DEVKITPRO=/opt/devkitpro -export DEVKITA64="$DEVKITPRO/devkitA64" -export PATH="$DEVKITA64/bin:$DEVKITPRO/tools/bin:$PATH" -``` - -Bootstrap verification: - -```sh -aarch64-none-elf-gcc --version -elf2nro --help -nxlink --help -make -C /opt/devkitpro/examples/switch/graphics/simplegfx -``` - -The resulting official `simplegfx.nro` must boot in the installed Ryujinx -before PocketJS code is added. This isolates emulator/toolchain setup from -PocketJS integration. Proprietary firmware, keys, or SDK files must not be -added to the repository; the first Ryujinx spike determines whether this -homebrew-only path needs any user-local emulator setup. - -Rust requirements for the static-library spike: - -- a pinned nightly toolchain; -- `rust-src`; -- `-Z build-std=core,alloc`; -- `aarch64-nintendo-switch-freestanding`; -- panic abort and position-independent AArch64 code compatible with the libnx - final link. - -Use the repository's pinned nightly date if it supports the target after a -clean rustup install. Change the date only for a demonstrated compiler or -target failure. - -## Implementation milestones - -### M0 — toolchain and emulator proof - -Scope: - -- install only devkitPro pacman and `switch-dev`; -- repair/install the pinned Rust nightly with `rust-src`; -- build the official `simplegfx` example; -- boot its NRO directly in Ryujinx; -- record exact tool versions and the successful Ryujinx invocation. - -Exit criteria: - -- one reproducible command builds an official NRO; -- one reproducible command boots it in Ryujinx; -- no PocketJS source change is needed to diagnose toolchain failures. - -### M1 — target contract - -Scope: - -- add a truthful `switch` profile to `contracts/spec/platforms.ts`; -- add Switch to platform-contract fixtures and the demo admission matrix; -- add a typed backend entry to `tools/pocket.ts`; -- expose the Switch host files through the npm package allowlist only where a - downstream build actually consumes them. - -Exit criteria: - -- `pocket check --target switch` admits the 17 PSP applications and launcher; -- unsupported viewport/capability combinations still fail; -- existing PSP, Vita, and macOS target tests remain unchanged and green. - -### M2 — NRO shell and Rust/QuickJS link spike (complete) - -Scope: - -- create the smallest `hosts/switch` build; -- link one Rust `no_std + alloc` static library into a libnx NRO; -- prove C-to-Rust calls, Rust allocation, panic abort, QuickJS creation, and - JavaScript evaluation; -- load `app.js` and `app.pak` from RomFS; -- draw a solid diagnostic frame and read buttons. - -The current pinned `pocket-stack/quickjs-rs` build script special-cases PSP and -Vita but not Switch. The spike must determine the minimal upstreamable change: -target compiler selection, `__SWITCH__`/newlib guards, and archive linkage. Do -not fork the entire runtime or add a second JavaScript engine. - -Exit criteria: - -- a self-contained NRO evaluates a trivial embedded script in Ryujinx; -- Plus+Minus exits cleanly to the emulator without consuming the mapped Start - button; -- failures are visible through stderr/nxlink-compatible logging. - -### M3 — first PocketJS frame (complete) - -Scope: - -- port the native `HostOps` registration and frame lifecycle from the - PSP/Vita host; -- feed styles, fonts, images, and sprites from the normal pak; -- call the existing density-2 software rasterizer; -- map buttons and the left stick; -- build and boot `hero`. - -Exit criteria: - -- `hero` renders and responds to input in Ryujinx; -- the bundled target id and host ABI handshake passes; -- the host uses the same density-2 deterministic rasterizer as the oracle. - Byte-exact emulator capture remains the M5 regression proof rather than an - extra one-off M3 mechanism. - -### M4 — framework parity and developer loop - -Scope: - -- implement every mandatory current `HostOps` operation; -- add cursor hit testing and cursor drawing; -- add `bun switch ` for low-level host development; -- add `pocket build --target switch`; -- extend `bun play switch ` to build and boot the exact NRO in Ryujinx; -- package app title, version, and a valid NACP/icon into the NRO. - -Exit criteria: - -- all 17 applications build as NROs; -- the current PSP capability surface behaves on Switch; -- stale NROs cannot be launched accidentally by `play`; -- the release `hero` frame loop sustains 60 FPS in Ryujinx. - -### M5 — automated Ryujinx regression proof - -Scope: - -- add a capture feature with scripted input, following the Vita E2E pattern; -- write raw density-2 content frames to a dedicated path under Ryujinx's - virtual SD card; -- launch only the spawned Ryujinx process and wait for a `done` or error file; -- compare existing golden scenarios to the deterministic density-2 oracle; -- smoke-build and boot every PSP-admissible application. - -Do not make GUI screenshots the pixel oracle. Emulator window chrome, display -scaling, and host color management are not PocketJS output. - -Exit criteria: - -- the existing golden scenarios pass through the Switch QuickJS/input/frame - loop; -- every PSP-admissible demo builds; -- emulator crashes and timeouts are reported with the app name and log path. - -### M6 — launcher parity - -Scope: - -- extend launcher target admission and package thinning to Switch; -- embed the launcher plus every Switch-admitted `.pocket` package in RomFS; -- port whole-guest teardown, app table, launch request, and frozen shot; -- map Minus to the existing Select summon behavior. - -Exit criteria: - -- one NRO contains the launcher and all 17 applications; -- switching repeatedly does not retain QuickJS realms, core textures, or - guest state; -- a broken child returns to the launcher rather than terminating the NRO. - -### M7 — hardware and CI - -Scope: - -- run the same NRO on homebrew-enabled hardware through hbmenu/nxlink; -- record frame time and memory for representative heavy demos; -- add a pinned, reproducible CI Switch build; -- document hardware installation and debugging after it has been exercised. - -Exit criteria: - -- hardware and Ryujinx use the same release NRO; -- `hero`, `gallery`, `motions`, `im`, and the launcher sustain 60 FPS; -- CI publishes the NRO as a build artifact without proprietary inputs. - -## Expected change surface - -The implementation is expected to touch: - -- `contracts/spec/platforms.ts`; -- `tests/platform-contracts.test.ts` and plan fixtures; -- `tools/pocket.ts`, `tools/play.ts`, and a small `tools/switch.ts`; -- `tools/launcher.ts`; -- `hosts/switch/`; -- `package.json` scripts and package file allowlist; -- Switch build, contract, and Ryujinx E2E tests; -- this document and the command reference after commands are real. - -`engine/core` and application sources should not change for the first working -port. A change there requires a cross-platform defect or a missing reusable -boundary demonstrated by the Switch spike. - -## Pull request sequence - -Keep each step independently reviewable: - -1. `feat(switch): add the target contract and NRO shell` -2. `feat(switch): run the PocketJS guest in Ryujinx` -3. `feat(switch): complete rendering and input parity` -4. `test(switch): add Ryujinx regression coverage` -5. `feat(switch): port the Pocket Launcher` -6. `ci(switch): publish reproducible NRO artifacts` - -If the M2 link spike disproves the C-shell/Rust-static-library split, stop and -update this document with the measured failure before expanding the design. - -## Risks and gates - -| Risk | Gate | -| --- | --- | -| devkitPro on the current macOS beta | Official `simplegfx.nro` builds before PocketJS work | -| Ryujinx NRO launch or virtual SD behavior | M0 records a working invocation and M5 proves file-based completion | -| Rust Tier-3 target and libnx final link | M2 calls an allocating Rust function from the NRO before QuickJS work | -| QuickJS/newlib build assumptions | M2 creates/evaluates/destroys one realm before `HostOps` is ported | -| Software rasterizer performance | Release frame-time measurement decides whether OpenGL is added | -| 16:9 versus 480x272 presentation | Explicit centered integer-fit contract; no silent stretch | -| Guest-switch resource leaks | Repeated launcher switching plus handle/realm counters | -| Scope creep into Switch-only APIs | Initial profile advertises PSP parity only | - -## Primary references - -- [devkitPro Getting Started](https://devkitpro.org/wiki/Getting_Started) -- [libnx documentation](https://switchbrew.github.io/libnx/) -- [official Switch examples](https://github.com/switchbrew/switch-examples) -- [deko3d](https://github.com/devkitPro/deko3d) -- [Rust Switch target support](https://doc.rust-lang.org/rustc/platform-support/aarch64-nintendo-switch-freestanding.html) -- [cargo-nx](https://github.com/aarch64-switch-rs/cargo-nx) diff --git a/hosts/switch/README.md b/hosts/switch/README.md new file mode 100644 index 0000000..0752f81 --- /dev/null +++ b/hosts/switch/README.md @@ -0,0 +1,133 @@ +# PocketJS Nintendo Switch host + +This host packages one manifest-resolved PocketJS guest as a Nintendo Switch +homebrew `.nro`. libnx owns startup, RomFS, controller input, and the linear +framebuffer. A linked Rust static library owns QuickJS, the complete `HostOps` +surface, `pocketjs-core`, pak resources, and density-2 software rasterization. +The same guest bundle and framework APIs used by the PSP and Vita hosts run +without Switch branches in application code. + +## Supported contract + +The stock `switch` target uses host ABI 4 and supports the current +PSP-compatible application surface: + +- a fixed 480x272 logical viewport; +- density-2 rasterization into a centered 960x544 image in the 1280x720 + framebuffer; +- buttons, left analog input, and the framework's analog-driven virtual cursor; +- baked glyphs, images, sprites, gradients, clipping, and transformed draw + commands through the shared deterministic rasterizer; +- Solid, Vue Vapor JSX, and Vue single-file-component guests through the shared + `globalThis.ui` contract. + +Switch A/B/X/Y map to PocketJS Circle/Cross/Triangle/Square. The D-pad and +shoulder buttons preserve the PSP mask, the left stick supplies packed analog +input, Minus maps to Select, and Plus maps to Start. Plus+Minus exits the +homebrew application without consuming the mapped Start button. + +This is a QuickJS **guest** target. It does not add a `vapor/runtime/switch` +backend: Pocket Vapor is the separate AOT compiler family for devices that do +not ship a JavaScript engine. Vue Vapor guest applications already run on this +host through the platform-independent framework runtime and shared `HostOps`. + +## Dependencies + +- Bun; +- the Rust nightly pinned in [`tools/cli/psp-toolchain.json`](../../tools/cli/psp-toolchain.json), + including its `rust-src` component; +- devkitPro pacman and the `switch-dev` package group, which supplies devkitA64, + libnx, switch-tools, deko3d, and the official examples; +- Ryujinx for local integration testing. + +On macOS, install devkitPro pacman with its official installer, then install the +Switch package group and pinned Rust component: + +```sh +sudo dkp-pacman -Syu +sudo dkp-pacman -S --needed switch-dev +rustup toolchain install nightly-2026-05-28 --component rust-src +``` + +The backend resolves `/opt/devkitpro` by default. Override `DEVKITPRO` or +`DEVKITA64` for another installation; the build command supplies the required +compiler and tool paths without requiring shell startup-file changes. + +Verify the native tools independently before debugging PocketJS: + +```sh +export DEVKITPRO=/opt/devkitpro +export DEVKITA64="$DEVKITPRO/devkitA64" +export PATH="$DEVKITA64/bin:$DEVKITPRO/tools/bin:$PATH" + +aarch64-none-elf-gcc --version +elf2nro --help +nxlink --help +``` + +The Rust half builds `core` and `alloc` for +`aarch64-nintendo-switch-freestanding` with panic abort and position-independent +AArch64 code. The backend supplies the Unix/newlib cfg values omitted by Rust's +built-in Tier-3 target. The pinned QuickJS C build also receives Switch newlib's +`malloc_usable_size` declaration and the zero-timezone fallback used by the +other native console hosts. These are backend-owned target facts; applications +must not set them. + +## Build and run + +Build a repository demo directly: + +```sh +bun switch hero --release +``` + +Build any manifest-driven application: + +```sh +bun pocket build \ + --target switch \ + --manifest apps/hero/pocket.json \ + --project-root . \ + -- \ + --release +``` + +Build and launch a repository demo in Ryujinx: + +```sh +bun play switch hero +``` + +Set `RYUJINX` to the emulator executable or `RYUJINX_APP` to its macOS app +bundle when it is not installed at `/Applications/Ryujinx.app`. Add +`--no-launch` to build without opening the emulator. `--no-build` launches only +when the NRO content hash and play stamp match the selected demo and framework, +which prevents accidentally launching a stale artifact. + +The equivalent direct emulator invocation is: + +```sh +/Applications/Ryujinx.app/Contents/MacOS/Ryujinx /path/to/application.nro +``` + +The NRO is written to `dist/switch/.nro`. `app.js` and `app.pak` are +embedded under `romfs:/pocketjs/`. The manifest title and version become NACP +metadata, and the package uses libnx's valid default homebrew icon. + +The Makefile deliberately runs `build_romfs` and passes the resulting image to +`elf2nro` with `--romfs`. The tested macOS `elf2nro --romfsdir` path can silently +produce an NRO without its ASET payload, so it is not used. + +## Emulator setup and limitations + +Ryujinx firmware and keys are proprietary, user-local inputs. They are not +build dependencies and must never be added to this repository. First validate a +local emulator installation with an official devkitPro homebrew example if an +NRO does not reach the framebuffer. + +The current host intentionally does not advertise native touch, right analog, +motion, rumble, audio, networking, or service mailboxes. It packages one guest +per NRO; the Pocket Launcher multi-guest lifecycle is not implemented for +Switch. Automated Ryujinx frame capture, real-hardware acceptance, and a pinned +Switch CI artifact are also not part of the supported contract. Do not infer +those guarantees from successful emulator execution. diff --git a/package.json b/package.json index bc6a858..61149eb 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "hosts/vita/rust-toolchain.toml", "hosts/native", "hosts/switch/Makefile", + "hosts/switch/README.md", "hosts/switch/Cargo.toml", "hosts/switch/Cargo.lock", "hosts/switch/source", diff --git a/tests/npm-package.test.ts b/tests/npm-package.test.ts index 200b596..de3e9e7 100644 --- a/tests/npm-package.test.ts +++ b/tests/npm-package.test.ts @@ -87,6 +87,7 @@ describe("published npm artifacts", () => { "hosts/native/ffi.rs", "hosts/native/pak.rs", "hosts/switch/Makefile", + "hosts/switch/README.md", "hosts/switch/Cargo.toml", "hosts/switch/Cargo.lock", "hosts/switch/source/main.c", diff --git a/tools/cli/README.md b/tools/cli/README.md index f151df1..496d984 100644 --- a/tools/cli/README.md +++ b/tools/cli/README.md @@ -2,8 +2,8 @@ The [PocketJS](https://pocketjs.dev) toolchain CLI — `doctor`/`setup` for the bun + Rust + PSP toolchain (flutter-doctor style), manifest-first app -scaffolding, build/run passthrough for PSP and PS Vita, and an isolated -Nokia E7 / Symbian development toolchain. +scaffolding, build/run passthrough for PSP, PS Vita, and Nintendo Switch +homebrew, and an isolated Nokia E7 / Symbian development toolchain. ```sh npm install -g @pocketjs/cli @@ -15,10 +15,13 @@ pocket check --target psp --manifest apps/my-app/pocket.json pocket compile --target psp --manifest apps/my-app/pocket.json pocket build --target psp --manifest apps/my-app/pocket.json -- --release pocket build --target vita --manifest apps/my-app/pocket.json -- --release +pocket build --target switch --manifest apps/my-app/pocket.json -- --release pocket play vita hero # build, install and launch a stock demo in Vita3K +pocket play switch hero # build and launch a stock demo in Ryujinx pocket dev my-app-main # build + serve in the browser pocket psp my-app # build the PSP EBOOT pocket vita my-app # build the PS Vita VPK +pocket switch my-app # build the Nintendo Switch NRO pocket symbian doctor --device pocket symbian doctor --coda-usb pocket symbian setup --yes @@ -44,9 +47,11 @@ pocket doctor `check`, `compile`, and `build` delegate to PocketJS's canonical manifest resolver. `pocket.json` owns the framework, entry, output, viewport and API requirements; the target backend consumes the resulting build plan. Arguments -after `--` go to the selected PSP or Vita backend. The low-level `dev`, `psp`, -`vita`, `hw`, `psplink`, `devtools`, and `tape` commands remain available for -framework demos and host development. +after `--` go to the selected PSP, Vita, or Switch backend. The low-level +`dev`, `psp`, `vita`, `switch`, `hw`, `psplink`, `devtools`, and `tape` commands +remain available for framework demos and host development. Switch's devkitPro, +Rust, packaging, and Ryujinx prerequisites are documented in +[`hosts/switch/README.md`](../../hosts/switch/README.md). Only Node ≥ 18 is required for the CLI itself; everything it diagnoses or installs is for building PocketJS apps. See the diff --git a/tools/cli/package.json b/tools/cli/package.json index 6685bf6..777f7da 100644 --- a/tools/cli/package.json +++ b/tools/cli/package.json @@ -1,7 +1,7 @@ { "name": "@pocketjs/cli", "version": "0.7.0", - "description": "The PocketJS CLI — manifest-first handheld builds, app scaffolding, and pinned native toolchain setup.", + "description": "The PocketJS CLI — manifest-first PSP, PS Vita, and Nintendo Switch homebrew builds, emulator launch, app scaffolding, and pinned native toolchain setup.", "license": "MIT", "type": "module", "bin": { @@ -26,6 +26,8 @@ "pocketjs", "psp", "vita", + "nintendo-switch", + "ryujinx", "symbian", "cli", "toolchain", From 680a82e2c89ebc2dd13cbf8470278845a733c17d Mon Sep 17 00:00:00 2001 From: zerob13 Date: Fri, 24 Jul 2026 17:51:05 +0800 Subject: [PATCH 7/9] chore: change back doc --- docs/STRUCTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index 1de6f26..3d288ef 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -19,7 +19,7 @@ pocketjs/ │ standalone; see each crate's Cargo.toml for its toolchain) ├─ hosts/ Surfaces: every embedding of the cores │ ├─ psp/ QuickJS + rust-psp EBOOT host -│ ├─ vita/ QuickJS + vitasdk VPK host +│ ├─ vita/ Vita host │ ├─ switch/ QuickJS + libnx/Rust NRO host │ ├─ esp32p4/ reusable ESP-IDF PPA adapter + component smoke build │ ├─ pocketbook/ PocketBook e-reader host (inkview, standalone lone-bin crate) From 2ca7d96a4f4b2a5c5c2751be2de38064a2889f41 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Fri, 24 Jul 2026 18:06:25 +0800 Subject: [PATCH 8/9] docs(switch): neutralize platform-specific terms in shared native ffi --- hosts/native/ffi.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/hosts/native/ffi.rs b/hosts/native/ffi.rs index d5c9671..64ba4f0 100644 --- a/hosts/native/ffi.rs +++ b/hosts/native/ffi.rs @@ -612,8 +612,8 @@ unsafe extern "C" fn js_debug_step( JS_UNDEFINED } -/// ui.__dbgActive() -> bool: whether the PSPLINK/memstick mailbox was found -/// at boot (dbg::init). The JS shim only builds a transport when true. +/// ui.__dbgActive() -> bool: whether the debug mailbox was found at boot +/// (dbg::init). The JS shim only builds a transport when true. unsafe extern "C" fn js_dbg_active( ctx: *mut JSContext, _this: JSValue, @@ -625,7 +625,7 @@ unsafe extern "C" fn js_dbg_active( /// ui.__dbgPoll() -> string | undefined: new complete JSON lines from the /// bridge (may batch several). The shim rate-limits calls to ~every 10 -/// frames; each call is a few sceIo round trips over usbhostfs. +/// frames; each call is a few round trips over the debug bridge. unsafe extern "C" fn js_dbg_poll( ctx: *mut JSContext, _this: JSValue, @@ -797,7 +797,7 @@ pub unsafe fn register( add_fn(ctx, ui_obj, b"__dbgPoll\0", js_dbg_poll, 0); add_fn(ctx, ui_obj, b"__dbgSend\0", js_dbg_send, 1); add_fn(ctx, ui_obj, b"__dbgShot\0", js_dbg_shot, 0); - // Optional launcher surface. Single-app VPKs omit these ops and preserve + // Optional launcher surface. Single-app builds omit these ops and preserve // the framework's documented degraded behavior. if crate::switch::multi() { add_fn(ctx, ui_obj, b"appTable\0", js_app_table, 0); @@ -805,9 +805,10 @@ pub unsafe fn register( add_fn(ctx, ui_obj, b"appShot\0", js_app_shot, 0); } - // Framework-owned host identity. The bundle rejects a VPK assembled for a - // different target or HostOps ABI before app code mounts. planHash is a - // build-time checksum and intentionally does not enter runtime handshake. + // Framework-owned host identity. The bundle rejects a package assembled + // for a different target or HostOps ABI before app code mounts. planHash + // is a build-time checksum and intentionally does not enter runtime + // handshake. let target = env!("POCKETJS_TARGET"); JS_SetPropertyStr( ctx, From 5b53f40da0ae22ab3c4ec90f26b0d0e3c3711e00 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Sat, 25 Jul 2026 19:41:11 +0800 Subject: [PATCH 9/9] fix(switch): align symbian/npm tests with switch target on rebased main --- tests/npm-package.test.ts | 7 +++++++ tests/symbian-runtime.test.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/npm-package.test.ts b/tests/npm-package.test.ts index de3e9e7..1576cf1 100644 --- a/tests/npm-package.test.ts +++ b/tests/npm-package.test.ts @@ -58,6 +58,13 @@ describe("published npm artifacts", () => { "hosts/vita/Cargo.lock", "hosts/vita/README.md", "hosts/vita/rust-toolchain.toml", + "hosts/native", + "hosts/switch/Makefile", + "hosts/switch/README.md", + "hosts/switch/Cargo.toml", + "hosts/switch/Cargo.lock", + "hosts/switch/source", + "hosts/switch/src", "hosts/symbian/probe", "hosts/symbian/runtime", "engine/pocket3d/crates/pocket3d-vita/src", diff --git a/tests/symbian-runtime.test.ts b/tests/symbian-runtime.test.ts index 7ce3f10..71da001 100644 --- a/tests/symbian-runtime.test.ts +++ b/tests/symbian-runtime.test.ts @@ -132,7 +132,7 @@ describe("experimental Nokia E7 runtime profile", () => { }); test("does not register an unproven production target", () => { - expect(Object.keys(POCKET_TARGETS)).toEqual(["psp", "vita", "pocketbook", "macos-widget"]); + expect(Object.keys(POCKET_TARGETS)).toEqual(["psp", "vita", "switch", "pocketbook", "macos-widget"]); expect(POCKET_TARGETS).not.toHaveProperty(SYMBIAN_E7_DEV_TARGET_ID); expect(SYMBIAN_E7_DEV_HOST_ABI).toBe(4); });