From 18339edb80f9c841f0ea0a824c0e289b7efb5ef9 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:42:48 +0800 Subject: [PATCH 1/2] feat(3ds): Nintendo 3DS host with a PICA200 backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A QuickJS guest over the PICA200 GPU on the 3DS top screen, admitted through the out-of-registry `3ds-dev` profile (hostAbi 7) until it passes on hardware, the same route symbian-e7-dev and iphone2g-dev take. The split follows hosts/iphone2g rather than hosts/psp: citro3d is mostly `static inline`, so C owns the GPU and Rust owns the core. `hosts/3ds/core` is a no_std staticlib built for the built-in `armv6k-nintendo-3ds` target with `-Z build-std` on macOS, exporting the `ui_*` C ABI plus the DrawList itself; `hosts/3ds/src/gfx.c` walks that list and issues citro3d calls. QuickJS builds for the 3DS from the revision hosts/psp already pins, with three portability flags: `JS_NO_NAN_BOXING` (the Vita treatment for 32-bit ARM), `__TM_GMTOFF=tm_gmtoff` (newlib declares the field only under that macro), and `-Wno-incompatible-pointer-types` (devkitARM ships GCC 16). Everything the device toolchain touches runs in the `devkitpro/devkitarm` container, driven by `tools/3ds.ts`. The top screen is 400x240 — smaller than 480x272 on both axes — so integer-fit is arithmetically impossible and the resolver has no scaling fallback by design. The profile declares 400x240 `native`, and apps/3ds-demo declares that viewport and doubles as a calibration surface: an orientation key whose notch moves quadrant if the texture flip or the 8x8 Morton tiling is wrong, corner brackets that only touch all four edges at this size, and the raw packed analog word printed so a wrong `(x<<8)|y` is readable off a capture. `input.touch` is deliberately not advertised: the touchscreen is the bottom screen while the UI is on the top, so reporting those contacts as top-screen logical coordinates would be false. tests/e2e/azahar.ts builds a capture .3dsx per spec, boots Azahar against a fixture $HOME (the emulator has no config or user-dir flag, and CITRA_USER_DIR is a no-op on macOS), waits for the guest's sentinel, SIGKILLs, and compares a GX display transfer of the render target — a real GPU readback, not a CPU oracle. Software and Vulkan do not agree (48.7% of pixels on real UI content), so the fixture pins graphics_api=0 and tests/goldens/3ds/AZAHAR-BUILD.txt records what the goldens came from. Two bugs found by measuring against the wasm oracle rather than by reading: the C objects did not depend on their `-D` values, so a changed capture window or input tape lingered in cached objects (a CFLAGS stamp now forces the rebuild); and `qjs.c` never published `ui.__viewport`, so the framework sized its layers at the 480x272 spec screen and everything measured from a row's right edge sat exactly 80px too far right. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + apps/3ds-demo/app.tsx | 154 +++++ apps/3ds-demo/main.tsx | 5 + apps/3ds-demo/orient-key.svg | 12 + apps/3ds-demo/pocket.json | 33 + docs/DESIGN.md | 6 +- hosts/3ds/Makefile | 140 +++++ hosts/3ds/README.md | 154 +++++ hosts/3ds/core/.cargo/config.toml | 6 + hosts/3ds/core/Cargo.lock | 114 ++++ hosts/3ds/core/Cargo.toml | 37 ++ hosts/3ds/core/rust-toolchain.toml | 4 + hosts/3ds/core/src/alloc.rs | 71 +++ hosts/3ds/core/src/lib.rs | 690 +++++++++++++++++++++ hosts/3ds/icon.png | Bin 0 -> 324 bytes hosts/3ds/include/pocket_core.h | 175 ++++++ hosts/3ds/src/gfx.c | 939 +++++++++++++++++++++++++++++ hosts/3ds/src/gfx.h | 26 + hosts/3ds/src/input.c | 77 +++ hosts/3ds/src/input.h | 13 + hosts/3ds/src/main.c | 351 +++++++++++ hosts/3ds/src/qjs.c | 628 +++++++++++++++++++ hosts/3ds/src/qjs.h | 28 + hosts/3ds/src/vshader.v.pica | 39 ++ package.json | 2 + site/content/docs/overview.md | 2 +- tests/3ds-profile.test.ts | 171 ++++++ tests/e2e/azahar.ts | 382 ++++++++++++ tests/golden-specs.ts | 25 + tests/goldens/3ds/3ds-demo.12.png | Bin 0 -> 11267 bytes tests/goldens/3ds/3ds-demo.2.png | Bin 0 -> 11246 bytes tests/goldens/3ds/3ds-demo.22.png | Bin 0 -> 11267 bytes tests/goldens/3ds/AZAHAR-BUILD.txt | 1 + tests/platform-contracts.test.ts | 1 + tools/3ds-profile.ts | 62 ++ tools/3ds.ts | 660 ++++++++++++++++++++ tools/test.ts | 1 + 37 files changed, 5008 insertions(+), 2 deletions(-) create mode 100644 apps/3ds-demo/app.tsx create mode 100644 apps/3ds-demo/main.tsx create mode 100644 apps/3ds-demo/orient-key.svg create mode 100644 apps/3ds-demo/pocket.json create mode 100644 hosts/3ds/Makefile create mode 100644 hosts/3ds/README.md create mode 100644 hosts/3ds/core/.cargo/config.toml create mode 100644 hosts/3ds/core/Cargo.lock create mode 100644 hosts/3ds/core/Cargo.toml create mode 100644 hosts/3ds/core/rust-toolchain.toml create mode 100644 hosts/3ds/core/src/alloc.rs create mode 100644 hosts/3ds/core/src/lib.rs create mode 100644 hosts/3ds/icon.png create mode 100644 hosts/3ds/include/pocket_core.h create mode 100644 hosts/3ds/src/gfx.c create mode 100644 hosts/3ds/src/gfx.h create mode 100644 hosts/3ds/src/input.c create mode 100644 hosts/3ds/src/input.h create mode 100644 hosts/3ds/src/main.c create mode 100644 hosts/3ds/src/qjs.c create mode 100644 hosts/3ds/src/qjs.h create mode 100644 hosts/3ds/src/vshader.v.pica create mode 100644 tests/3ds-profile.test.ts create mode 100644 tests/e2e/azahar.ts create mode 100644 tests/goldens/3ds/3ds-demo.12.png create mode 100644 tests/goldens/3ds/3ds-demo.2.png create mode 100644 tests/goldens/3ds/3ds-demo.22.png create mode 100644 tests/goldens/3ds/AZAHAR-BUILD.txt create mode 100644 tools/3ds-profile.ts create mode 100644 tools/3ds.ts diff --git a/README.md b/README.md index c5236151..a9563e28 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ a hardware protocol receipt, and a manual screen check prove different things. | **macOS widget** | Registered Guest profile | Dynamic native window, pointer, keyboard/IME, clipboard, and runtime glyph paths | | **Browser, desktop, headless Bun** | Guest development and verification hosts | WASM/native rendering, interactive development, deterministic simulation, and image goldens | | **Nokia E7 / Symbian** | Hardware-tested development Guest host | SIS install, launch, visible rendering, keys, and rotation on the reference device; not a production target profile | +| **Nintendo 3DS** | Emulator-tested development Guest host | QuickJS guest and PICA200 (citro3d) rendering on the 400×240 top screen, driven by Azahar with byte-exact goldens taken from a GX readback of the render target; no hardware pass yet, so it is not a production target profile | | **GBA, Game Boy, NES** | Pocket Vapor AOT | Per-interaction emulator parity against the Vue oracle, including logical characters and styles | | **ESP32 MeowBit** | Pocket Vapor AOT | Optional physical-board UART replay verifies the logical grid and exercises LCD commits; it neither reads panel pixels nor actuates GPIO buttons | | **Playdate** | Pocket Vapor AOT | Native-boundary tests and Simulator/device package smoke; physical display and input acceptance remains manual | diff --git a/apps/3ds-demo/app.tsx b/apps/3ds-demo/app.tsx new file mode 100644 index 00000000..b49959a3 --- /dev/null +++ b/apps/3ds-demo/app.tsx @@ -0,0 +1,154 @@ +// apps/3ds-demo/app.tsx — the 400x240 top-screen demo for the 3ds-dev host. +// +// Everything on this screen is a check the host must pass, placed so a wrong +// answer is visible in one look at the captured frame: +// +// - four corner brackets and four edge ticks pinned with absolute insets: +// they touch all four edges, and the ticks straddle (200, 120), only when +// the host runs the app at 400x240 with rasterDensity 1. A 480x272 +// viewport pushes the right and bottom brackets off the panel; a +// transposed target moves the ticks off the edge midpoints. +// - orient-key.svg, a 64x64 texture drawn 1:1 — four flat quadrants (so an +// 8x8 tile ordering bug scrambles visibly), a white diagonal (destroyed by +// any transpose), a 2px white ring (edge/UV clamp), and a dark notch in +// the RED quadrant marking the top-left corner (a vertical flip moves it +// into the blue quadrant). +// - text at five sizes (12/14/16/18/24 px, regular and bold), so a font-atlas +// or baseline bug shows up on more than one glyph run. +// - three focusable tiles in a row and a RESET button on the row above: +// LEFT/RIGHT and UP/DOWN both have somewhere to go, and focus emphasis is +// a native focus: variant (no JS runs on a focus change). +// - the circle pad, decoded to -1..1 by the framework's deadzone, drives a +// dot inside a 72px well and prints the host's raw packed sample. +// +// The root paints the whole panel slate-950, so a host that skips the clear or +// mis-sizes the first quad leaves uncleared VRAM showing around the fill. + +import { createSignal } from "solid-js"; +import { Image, Text, View } from "@pocketjs/framework/components"; +import { analogRaw, analogX, analogY, onFrame } from "@pocketjs/framework/lifecycle"; + +/** Pad-well geometry: the dot travels +/- this many px from the well center. */ +const PAD_TRAVEL = 26; + +interface Tile { + label: string; + /** tile body class (base + focus variants, per-accent border). */ + cls: string; + /** the counter's accent, applied to the value line. */ + value: string; +} + +const TILES: Tile[] = [ + { + label: "LAYOUT", + cls: "flex-col items-start gap-1 w-[120] p-2 rounded-lg border border-slate-700 bg-slate-900 translate-y-[2] focus:bg-slate-800 focus:border-red-400 focus:translate-y-[0] transition-all duration-150 ease-out", + value: "text-lg text-red-400 font-bold", + }, + { + label: "TEXTURE", + cls: "flex-col items-start gap-1 w-[120] p-2 rounded-lg border border-slate-700 bg-slate-900 translate-y-[2] focus:bg-slate-800 focus:border-emerald-400 focus:translate-y-[0] transition-all duration-150 ease-out", + value: "text-lg text-emerald-400 font-bold", + }, + { + label: "INPUT", + cls: "flex-col items-start gap-1 w-[120] p-2 rounded-lg border border-slate-700 bg-slate-900 translate-y-[2] focus:bg-slate-800 focus:border-amber-400 focus:translate-y-[0] transition-all duration-150 ease-out", + value: "text-lg text-amber-400 font-bold", + }, +]; + +export default function ThreeDsDemo() { + const [counts, setCounts] = createSignal([0, 0, 0]); + const [padX, setPadX] = createSignal(0); + const [padY, setPadY] = createSignal(0); + const [padRaw, setPadRaw] = createSignal(analogRaw()); + // Signals hold === equality, so a resting stick sets nothing and the tree + // stays untouched for the whole run. + onFrame(() => { + setPadX(analogX()); + setPadY(analogY()); + setPadRaw(analogRaw()); + }); + const total = () => counts().reduce((sum, n) => sum + n, 0); + const bump = (i: number) => + setCounts((prev) => prev.map((n, j) => (j === i ? n + 1 : n))); + const padLabel = () => `0x${padRaw().toString(16).padStart(4, "0")}`; + + return ( + + {/* Edge ticks straddling the middle of each edge: (200, 120). */} + + + + + + {/* Corner brackets, one color each, drawn from the screen edge. */} + + + + + + + + + + + + + + + PocketJS on 3DS + TOP SCREEN · PICA200 + + + + 400 × 240 + + + + + + + + PRESSES + {total()} + setCounts([0, 0, 0])} + > + RESET + + + + + + + + + PAD {padLabel()} + + + + + {TILES.map((tile, i) => ( + bump(i)}> + {tile.label} + {counts()[i]} + + ))} + + + + D-PAD FOCUS · A CONFIRMS · CIRCLE PAD MOVES THE DOT + + + + ); +} diff --git a/apps/3ds-demo/main.tsx b/apps/3ds-demo/main.tsx new file mode 100644 index 00000000..0fd965c5 --- /dev/null +++ b/apps/3ds-demo/main.tsx @@ -0,0 +1,5 @@ +// @title PocketJS: 3DS Top Screen +import ThreeDsDemo from "./app.tsx"; +import { mount } from "@pocketjs/framework/solid"; + +mount(() => ); diff --git a/apps/3ds-demo/orient-key.svg b/apps/3ds-demo/orient-key.svg new file mode 100644 index 00000000..56d9fcb0 --- /dev/null +++ b/apps/3ds-demo/orient-key.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/3ds-demo/pocket.json b/apps/3ds-demo/pocket.json new file mode 100644 index 00000000..968249ab --- /dev/null +++ b/apps/3ds-demo/pocket.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-2.json", + "pocket": 2, + "id": "dev.pocket-stack.3ds-demo", + "name": "pocketjs-3ds-demo", + "title": "PocketJS: 3DS Top Screen", + "version": "0.1.0", + "engine": { + "capabilities": { + "requires": [ + "text.glyphs.baked", + "input.buttons" + ], + "enhances": [ + "input.analog.left" + ] + } + }, + "app": { + "entry": "apps/3ds-demo/main.tsx", + "output": "pocket3ds-demo-main", + "framework": "solid", + "viewport": { + "fixed": { + "logical": [ + 400, + 240 + ], + "presentation": "native" + } + } + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 6bbae072..2c6f5c58 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -395,5 +395,9 @@ pak (base64-in-JS is the known QuickJS boot killer). Kinetic scroll views, CLUT/swizzled textures, render-to-texture opacity groups (per-vertex alpha propagation instead — wrong on overlap, fine for demos), -kerning, `hover:`, percentage sizes beyond `-full`, 3DS/Android hosts, +kerning, `hover:`, percentage sizes beyond `-full`, Android hosts, `rounded-full` on runtime-sized nodes. + +The 3DS left this list with `hosts/3ds` — a QuickJS guest over a PICA200 +backend, admitted through the `3ds-dev` profile in `tools/3ds-profile.ts` until +it passes on hardware. diff --git a/hosts/3ds/Makefile b/hosts/3ds/Makefile new file mode 100644 index 00000000..713fed25 --- /dev/null +++ b/hosts/3ds/Makefile @@ -0,0 +1,140 @@ +# PocketJS Nintendo 3DS host — run INSIDE the devkitpro/devkitarm container. +# +# tools/3ds.ts builds the Rust staticlib and QuickJS on macOS, then invokes +# this with CWD hosts/3ds and every path already translated to a container +# path. All inputs arrive as ENVIRONMENT variables, so the recipes below only +# ever read them: +# +# POCKETJS_TARGET target id to publish as ui.__host +# POCKETJS_HOST_ABI ABI number to publish as ui.__hostAbi +# POCKETJS_LOGICAL_WIDTH guest viewport, baked into the host +# POCKETJS_LOGICAL_HEIGHT +# POCKETJS_RASTER_DENSITY raster samples per logical pixel +# POCKETJS_CORE_LIB libpocketjs_3ds_core.a +# POCKETJS_QUICKJS_DIR directory holding quickjs.h and libquickjs.a +# POCKETJS_APP_JS guest bundle to embed +# POCKETJS_APP_PAK guest pak to embed +# POCKETJS_BUILD_DIR scratch for objects, the .shbin, the .elf, romfs +# POCKETJS_OUT_3DSX the .3dsx to write +# POCKETJS_SMDH_TITLE SMDH metadata +# POCKETJS_SMDH_AUTHOR +# POCKETJS_SMDH_DESC +# POCKETJS_CAPTURE "1" builds the deterministic e2e binary +# POCKETJS_CAPTURE_INPUT baked input tape, "frame:mask,frame:mask" +# POCKETJS_CAP_START first frame to dump +# POCKETJS_CAP_N how many frames to dump +# +# The tape and the capture window travel INSIDE the binary: a capture run never +# reads the emulator's filesystem for its input. Nothing is written outside +# POCKETJS_BUILD_DIR and POCKETJS_OUT_3DSX. + +DEVKITPRO ?= /opt/devkitpro +DEVKITARM ?= $(DEVKITPRO)/devkitARM + +SOURCE := $(CURDIR)/src +INCLUDE_DIR := $(CURDIR)/include +BUILD := $(POCKETJS_BUILD_DIR) +OUT := $(POCKETJS_OUT_3DSX) +# 3dsxtool reads the guest out of a RomFS directory, so the two embedded files +# are staged under one. +ROMFS := $(BUILD)/romfs +ICON ?= $(CURDIR)/icon.png + +CC := $(DEVKITARM)/bin/arm-none-eabi-gcc +PICASSO := $(DEVKITPRO)/tools/bin/picasso +BIN2S := $(DEVKITPRO)/tools/bin/bin2s +SMDHTOOL := $(DEVKITPRO)/tools/bin/smdhtool +THREEDSXTOOL := $(DEVKITPRO)/tools/bin/3dsxtool + +# The ARM11 in both console revisions: ARMv6K, MPCore tuning, hardware float, +# and the soft thread pointer libctru is built against. -mword-relocations is +# what keeps the 3dsx loader's relocation table expressible. +ARCH := -march=armv6k -mtune=mpcore -mfloat-abi=hard -mtp=soft + +# JS_NO_NAN_BOXING is not optional and not cosmetic: quickjs.h turns NaN boxing +# ON by default for any 32-bit target, which makes JSValue 8 bytes instead of +# 16. libquickjs.a is built with it defined (the Vita treatment in +# libquickjs-sys/build.rs), so a translation unit that includes quickjs.h +# without it links cleanly and then hands the library differently shaped +# values — the guest boots and QuickJS's GC walks garbage pointers. +CFLAGS := -Wall -Wextra -O2 -g -std=gnu11 -mword-relocations -ffunction-sections -fdata-sections \ + $(ARCH) -D__3DS__ -DJS_NO_NAN_BOXING \ + -DPOCKETJS_TARGET_ID='"$(POCKETJS_TARGET)"' \ + -DPOCKETJS_HOST_ABI=$(POCKETJS_HOST_ABI) \ + -DPOCKETJS_VIEW_W=$(POCKETJS_LOGICAL_WIDTH) \ + -DPOCKETJS_VIEW_H=$(POCKETJS_LOGICAL_HEIGHT) \ + -DPOCKETJS_RASTER_DENSITY=$(POCKETJS_RASTER_DENSITY) \ + -I$(INCLUDE_DIR) -I$(BUILD) -I$(POCKETJS_QUICKJS_DIR) \ + -I$(DEVKITPRO)/libctru/include + +ifeq ($(POCKETJS_CAPTURE),1) +CFLAGS += -DPOCKETJS_CAPTURE \ + -DPOCKETJS_CAPTURE_INPUT='"$(POCKETJS_CAPTURE_INPUT)"' \ + -DPOCKETJS_CAP_START=$(POCKETJS_CAP_START) \ + -DPOCKETJS_CAP_N=$(POCKETJS_CAP_N) +endif + +LDFLAGS := -specs=3dsx.specs $(ARCH) -Wl,--gc-sections -Wl,-Map,$(BUILD)/pocketjs-3ds.map +LIBPATHS := -L$(DEVKITPRO)/libctru/lib +LIBS := -lcitro3d -lctru -lm + +OBJECTS := $(BUILD)/main.o $(BUILD)/gfx.o $(BUILD)/qjs.o $(BUILD)/input.o $(BUILD)/vshader_shbin.o +ELF := $(BUILD)/pocketjs-3ds.elf +SMDH := $(BUILD)/pocketjs-3ds.smdh + +.PHONY: all clean +all: $(OUT) + +$(BUILD) $(ROMFS): + mkdir -p $@ + +# The PICA200 vertex shader is assembled and linked in as data; there is no +# fragment shader on this GPU (gfx.c drives TEV stages instead). +$(BUILD)/vshader.shbin: $(SOURCE)/vshader.v.pica | $(BUILD) + $(PICASSO) -o $@ $< + +$(BUILD)/vshader_shbin.s $(BUILD)/vshader_shbin.h: $(BUILD)/vshader.shbin + cd $(BUILD) && $(BIN2S) -a 4 -H vshader_shbin.h vshader.shbin > vshader_shbin.s + +$(BUILD)/vshader_shbin.o: $(BUILD)/vshader_shbin.s + $(CC) $(ARCH) -c $< -o $@ + +# The capture window, the input tape and the viewport reach the C sources as -D +# defines, so a rebuild driven by source mtimes alone would keep a previous +# run's values — a stale tape or a short capture window produces a plausible +# .3dsx that dumps the wrong frames. Objects therefore depend on a stamp +# carrying the flags themselves, rewritten only when they actually change (the +# make-side counterpart of cargo:rerun-if-env-changed). +FLAGS_STAMP := $(BUILD)/cflags.stamp +.PHONY: $(FLAGS_STAMP).probe +$(FLAGS_STAMP).probe: | $(BUILD) + @printf '%s\n' "$(CFLAGS)" > $(FLAGS_STAMP).new + @cmp -s $(FLAGS_STAMP).new $(FLAGS_STAMP) || mv $(FLAGS_STAMP).new $(FLAGS_STAMP) + @rm -f $(FLAGS_STAMP).new +$(FLAGS_STAMP): $(FLAGS_STAMP).probe ; + +$(BUILD)/%.o: $(SOURCE)/%.c $(BUILD)/vshader_shbin.h $(FLAGS_STAMP) | $(BUILD) + $(CC) $(CFLAGS) -c $< -o $@ + +$(ELF): $(OBJECTS) + $(CC) $(LDFLAGS) $(OBJECTS) $(POCKETJS_CORE_LIB) $(POCKETJS_QUICKJS_DIR)/libquickjs.a \ + $(LIBPATHS) $(LIBS) -o $@ + +$(ROMFS)/app.js: $(POCKETJS_APP_JS) | $(ROMFS) + cp $< $@ + +$(ROMFS)/app.pak: $(POCKETJS_APP_PAK) | $(ROMFS) + cp $< $@ + +# 3dsxtool refuses to embed a RomFS without SMDH metadata, so one is always +# built, from the app's own strings over the host's icon. +$(SMDH): $(ICON) | $(BUILD) + $(SMDHTOOL) --create "$(POCKETJS_SMDH_TITLE)" "$(POCKETJS_SMDH_DESC)" \ + "$(POCKETJS_SMDH_AUTHOR)" $(ICON) $@ + +$(OUT): $(ELF) $(SMDH) $(ROMFS)/app.js $(ROMFS)/app.pak + mkdir -p $(dir $(OUT)) + $(THREEDSXTOOL) $(ELF) $@ --romfs=$(ROMFS) --smdh=$(SMDH) + +clean: + rm -rf $(BUILD) $(OUT) diff --git a/hosts/3ds/README.md b/hosts/3ds/README.md new file mode 100644 index 00000000..335f0b51 --- /dev/null +++ b/hosts/3ds/README.md @@ -0,0 +1,154 @@ +# Nintendo 3DS host + +PocketJS on the 3DS top screen: QuickJS runs the guest bundle, the Rust core +owns the retained tree, layout, animation and DrawList emission, and a C +backend walks that DrawList into **PICA200 draw calls through citro3d**. The +app owns the whole panel — **400x240, rasterDensity 1, presentation `native`** +— under the out-of-registry `3ds-dev` profile in `tools/3ds-profile.ts`. + +`hosts/psp` puts its GPU backend in Rust because the `psp` crate has bindings +for the GE. citro3d is a C library of mostly `static inline` functions, so here +the split is the other way round and matches `hosts/iphone2g`: **C owns the +graphics API, Rust owns everything above it.** That is why this host's crate +exports the DrawList itself (`ui_draw`, `ui_draw_list_ptr`, +`ui_draw_list_len`) and the texture and font registries over the C ABI, which +`engine/symbian` does not — its GLES backends consume the list internally. + +``` +core/ pocketjs-3ds-core: the ui_* C ABI over pocketjs-core + src/lib.rs lifecycle, HostOps, DrawList handoff, pak feed + src/alloc.rs #[global_allocator] over newlib + panic handler +include/pocket_core.h the C header for the above +src/main.c libctru/citro3d boot, the frame loop, frame capture +src/gfx.c the DrawList -> citro3d walker +src/qjs.c QuickJS embedding: globalThis.ui -> ui_* calls +src/input.c 3DS keys and circle pad -> the PSP BTN bitmask +src/vshader.v.pica the PICA200 vertex shader +Makefile run INSIDE the container by tools/3ds.ts +icon.png 48x48 SMDH icon +``` + +## Building + +Two toolchains, one repository: + +- The **Rust staticlib builds on macOS**. `armv6k-nintendo-3ds` is a built-in + rustc target, so `core/.cargo/config.toml` only has to ask for `build-std`; + `core/rust-toolchain.toml` pins the nightly. The target defaults to unwind, + so the crate sets `panic = "abort"`. +- The **C half builds in `devkitpro/devkitarm`**, which brings + `arm-none-eabi-gcc`, libctru, citro3d, `picasso`, `smdhtool` and `3dsxtool`. + +`tools/3ds.ts` drives both and hands this Makefile container paths in +environment variables (the list is at the top of the Makefile). Nothing here +reaches outside `hosts/3ds` except through them. + +```sh +bun tools/3ds.ts 3ds-demo # dist/3ds/.3dsx +bun tools/3ds.ts 3ds-demo --capture # the deterministic e2e binary +``` + +Two build-time facts are load-bearing: + +- **`-DJS_NO_NAN_BOXING` must be on every translation unit that includes + `quickjs.h`.** The header turns NaN boxing on by default for any 32-bit + target, which makes `JSValue` 8 bytes instead of 16, while `libquickjs.a` is + compiled with the flag. The mismatch links cleanly and then hands the library + differently shaped values: the guest boots and QuickJS's GC walks garbage + pointers a few hundred milliseconds later. +- **`__stacksize__` is raised to 1 MiB.** devkitPro's 3dsx crt0 gives the main + thread 32 KiB, and QuickJS's interpreter plus the guest's render pass recurse + far past that. + +## What `globalThis.ui` has to publish + +Beyond the HostOps table, `src/qjs.c` publishes four properties the framework +reads directly. `__host` and `__hostAbi` come from the build's `-D` defines and +gate mounting. `__textures` and `__sprites` are the pak name tables. The fourth +is geometry: + +- **`ui.__viewport` is the logical UI size, and omitting it is a layout bug, + not a missing nicety.** `framework/src/index.ts` sizes the mounted app and + overlay layers from it and falls back to the spec screen, 480x272, when a + host leaves it off. On this 400x240 panel that fallback lays the app out + **80 px too wide**: the extra width is invisible for anything anchored left, + and moves everything measured from the layer's right edge — `justify-between`, + a row's last child after a `grow` sibling, every `right-0` absolute — off the + panel. The value is read back from the core with `ui_viewport_width` / + `ui_viewport_height` after `main.c` has called `ui_set_viewport`, so the JS + root layer and the native root node cannot drift apart. Publishing a size is + not a live-resize capability: that needs `installResizeViewportHook`, which a + `takeover` host never calls. + +## What the backend has to honour + +`src/gfx.c` is the 3DS twin of `engine/symbian/src/gl/mod.rs`: the same walk, +the same texture and font-atlas caches, the same batching by texture and +scissor. It does **no clipping** — the core's CPU clip stage guarantees every +coordinate is already inside the viewport and i16-safe. The PICA200 adds: + +- Render targets are created **rotated**: `C3D_RenderTargetCreate(240, 400, …)` + for the top screen, and `Mtx_OrthoTilt` keeps guest coordinates landscape. + `C3D_FrameDrawOn` resets the viewport, so `C3D_SetViewport` comes after it. +- **The scissor register is in raw framebuffer pixels and both of its axes run + opposite to the logical ones**: the horizontal pair counts down from the + logical height, the vertical pair from the logical width. Flipping only one + of them mirrors the clip along the other axis, which stays invisible until + the clipped content is not already the size of its window. +- Textures must be power-of-two, 8..1024 per side, and **already in the + hardware's tiled layout** — 8x8 tiles row-major, Morton order inside a tile. + `C3D_TexUpload` is a plain `memcpy`. Non-power-of-two images get a + power-of-two envelope and their UVs are rescaled. +- **Tiled row 0 is sampled at v = 1**, so the source is flipped vertically + while it is tiled and DrawList UVs then pass through unchanged. +- **RGBA8 texels are stored bytes A, B, G, R** — the reverse of the core's + order. +- **Vertex buffers must live in `linearAlloc` memory** (`BufInfo_Add` rejects + any pointer below physical `0x18000000`), and the arena is flushed out of the + data cache before the draws that read it. +- There is **no fragment shader**: one TEV stage modulates the sampled texel by + the vertex colour, and untextured ops bind an 8x8 white texture so that + single stage covers every op. There is also **no paletted format**, so + `PSM_T8` is expanded at upload. + +## Capture and the Azahar loop + +`-DPOCKETJS_CAPTURE` turns `main.c` into the e2e binary: input comes from a +tape baked into the binary rather than from the emulator's filesystem, the +frames in `[POCKETJS_CAP_START, POCKETJS_CAP_START + POCKETJS_CAP_N)` are read +back off the render target, and the process **parks instead of exiting** — +Azahar does not stop when the app returns from `main()`. + +Emitted under `sdmc:/pocketjs-captures/`: `fNNNN.raw` named by the +process-global frame counter (exactly `400*240*4` bytes), then `done` written +only after the last frame is closed, and `error.txt` on the failure path so the +driver reports the message instead of a timeout. + +The readback is **not** `gfxGetFramebuffer` after `C3D_FrameEnd` — that buffer +has already been swapped and reads back black. It is an explicit +`C3D_SyncDisplayTransfer` of the render target, after a vblank so the GPU has +finished. The bytes stay in the screen's rotated orientation, 240 wide by 400 +tall, so the driver decodes `src[(x * 240 + (239 - y)) * 4]` into +`dst[y * 400 + x]` and reads the channels back as A, B, G, R. + +```sh +bun tests/e2e/azahar.ts +``` + +**Azahar's two renderers do not agree.** The same build and the same frame +differed in **48.7% of pixels** between Software (`graphics_api=0`) and Vulkan +(`graphics_api=2`) on an Apple M3 Max: under Vulkan small quads came back as +periodic bands while Software reproduced the geometry exactly. A golden +therefore belongs to one backend, and the e2e fixture pins it. Two independent +Software runs of the demo produced **20 byte-identical frames**. + +Azahar derives its whole user directory from `$HOME` and has no switch for any +part of it, so a run gets its own config and SD card by getting its own `$HOME`. + +## Not advertised + +`input.touch` is deliberately absent from the profile. The touchscreen is the +**bottom** screen at 320x240 while the UI renders on the **top** at 400x240; +reporting bottom-screen contacts as logical coordinates inside the top screen's +space would be a lie, and a second surface needs a design, not a capability id. +`audio.pcm` is not implemented in v1. diff --git a/hosts/3ds/core/.cargo/config.toml b/hosts/3ds/core/.cargo/config.toml new file mode 100644 index 00000000..2ddbe8f3 --- /dev/null +++ b/hosts/3ds/core/.cargo/config.toml @@ -0,0 +1,6 @@ +[unstable] +build-std = ["core", "alloc", "compiler_builtins"] +build-std-features = ["compiler-builtins-mem"] + +[build] +target = "armv6k-nintendo-3ds" diff --git a/hosts/3ds/core/Cargo.lock b/hosts/3ds/core/Cargo.lock new file mode 100644 index 00000000..b70ec802 --- /dev/null +++ b/hosts/3ds/core/Cargo.lock @@ -0,0 +1,114 @@ +# 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 = "pocketjs-3ds-core" +version = "0.1.0" +dependencies = [ + "pocketjs-core", +] + +[[package]] +name = "pocketjs-core" +version = "0.1.0" +dependencies = [ + "taffy", +] + +[[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 = "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 = "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" diff --git a/hosts/3ds/core/Cargo.toml b/hosts/3ds/core/Cargo.toml new file mode 100644 index 00000000..ec7776d3 --- /dev/null +++ b/hosts/3ds/core/Cargo.toml @@ -0,0 +1,37 @@ +# pocketjs-3ds-core — pocketjs-core behind a C ABI for the Nintendo 3DS host. +# +# citro3d is a C library that is mostly `static inline`, so unlike hosts/psp +# the GPU backend cannot live in Rust: this crate owns the retained tree, +# layout, damage and DrawList emission, and hosts/3ds/src/gfx.c walks the +# emitted word stream. That is why `ui_draw_list_ptr`/`ui_draw_list_len` and +# the texture/font registry accessors exist here and not in +# engine/symbian/src/lib.rs, whose GLES backends consume the list internally. +# +# Standalone workspace: the armv6k-nintendo-3ds target needs nightly +# build-std (see .cargo/config.toml) while the desktop engine workspace stays +# on the normal host toolchain. +# +# Build from this directory (rust-toolchain.toml pins the compiler): +# cargo build --release --locked + +[package] +name = "pocketjs-3ds-core" +version = "0.1.0" +edition = "2021" + +[lib] +name = "pocketjs_3ds_core" +crate-type = ["staticlib"] + +[dependencies] +pocketjs-core = { path = "../../../engine/core" } + +[profile.release] +# armv6k-nintendo-3ds defaults to unwind; a staticlib linked into a libctru +# binary has no unwinder to reach. +panic = "abort" +opt-level = "s" +codegen-units = 1 +lto = true + +[workspace] diff --git a/hosts/3ds/core/rust-toolchain.toml b/hosts/3ds/core/rust-toolchain.toml new file mode 100644 index 00000000..2ddd4360 --- /dev/null +++ b/hosts/3ds/core/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "nightly-2026-07-02" +components = ["rust-src"] +profile = "minimal" diff --git a/hosts/3ds/core/src/alloc.rs b/hosts/3ds/core/src/alloc.rs new file mode 100644 index 00000000..7684cc26 --- /dev/null +++ b/hosts/3ds/core/src/alloc.rs @@ -0,0 +1,71 @@ +//! Global allocator over newlib's heap, plus the panic and allocation-error +//! handlers a `no_std` staticlib has to provide itself. +//! +//! devkitARM's newlib `malloc` returns 8-byte aligned blocks, so an over- +//! aligned Rust layout has no legal answer here and gets a null pointer +//! (the same contract engine/symbian/src/lib.rs states for Symbian's malloc). +//! Nothing in pocketjs-core asks for more than 16-byte alignment through the +//! allocator — its 16-byte-aligned texture stores are `Vec`, whose +//! element alignment newlib does satisfy on ARM. + +use core::alloc::{GlobalAlloc, Layout}; +use core::ffi::c_void; + +/// Alignment newlib's `malloc` guarantees on devkitARM (`MALLOC_ALIGNMENT`). +const C_MALLOC_ALIGNMENT: usize = 8; + +#[inline] +const fn c_allocator_supports_alignment(alignment: usize) -> bool { + alignment <= C_MALLOC_ALIGNMENT +} + +unsafe extern "C" { + fn malloc(size: usize) -> *mut c_void; + fn memalign(alignment: usize, size: usize) -> *mut c_void; + fn realloc(ptr: *mut c_void, size: usize) -> *mut c_void; + fn free(ptr: *mut c_void); + fn abort() -> !; +} + +struct CAllocator; + +unsafe impl GlobalAlloc for CAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + if c_allocator_supports_alignment(layout.align()) { + malloc(layout.size().max(1)).cast() + } else { + memalign(layout.align(), layout.size().max(1)).cast() + } + } + + unsafe fn dealloc(&self, ptr: *mut u8, _layout: Layout) { + free(ptr.cast()); + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 { + // `realloc` only preserves malloc's own alignment. An over-aligned + // block came from `memalign`, so grow it by hand. + if c_allocator_supports_alignment(layout.align()) { + return realloc(ptr.cast(), size.max(1)).cast(); + } + let grown: *mut u8 = memalign(layout.align(), size.max(1)).cast(); + if !grown.is_null() { + core::ptr::copy_nonoverlapping(ptr, grown, layout.size().min(size)); + free(ptr.cast()); + } + grown + } +} + +#[global_allocator] +static ALLOCATOR: CAllocator = CAllocator; + +#[panic_handler] +fn panic(_info: &core::panic::PanicInfo<'_>) -> ! { + unsafe { abort() } +} + +#[alloc_error_handler] +fn allocation_error(_layout: Layout) -> ! { + unsafe { abort() } +} diff --git a/hosts/3ds/core/src/lib.rs b/hosts/3ds/core/src/lib.rs new file mode 100644 index 00000000..d2783a5e --- /dev/null +++ b/hosts/3ds/core/src/lib.rs @@ -0,0 +1,690 @@ +//! Nintendo 3DS C ABI for PocketJS's retained UI core. +//! +//! The libctru host owns QuickJS, the PICA200 and presentation, and calls this +//! library synchronously from its main thread. There is exactly one `Ui` +//! instance. Strings and blobs are borrowed as `(ptr, len)` for the duration of +//! a call and copied by the core whenever they must outlive it. +//! +//! Unlike engine/symbian, the graphics backend is NOT in this crate: citro3d is +//! a C library of mostly `static inline` functions, so the DrawList word stream +//! itself crosses the ABI (`ui_draw`, `ui_draw_list_ptr`, `ui_draw_list_len`) +//! and hosts/3ds/src/gfx.c walks it. The same reason forces the texture and +//! font-atlas registries out over the ABI: the C backend resolves a DrawList +//! texture handle to pixels exactly the way engine/symbian/src/gl/mod.rs's +//! `sync_resources`/`image_name` do, only from the other side of the boundary. +//! +//! All returned pointers borrow core-owned storage and stay valid until the +//! next call that can move it — a texture upload/free, a font-atlas load, a +//! `ui_draw`, `ui_init` or `ui_shutdown`. The host re-reads them every frame. + +#![no_std] +#![feature(alloc_error_handler)] +#![allow(static_mut_refs)] +#![allow(clippy::not_unsafe_ptr_arg_deref)] + +extern crate alloc; + +use alloc::string::String; +use alloc::vec::Vec; + +use pocketjs_core::spec; +use pocketjs_core::Ui; + +// `extern crate alloc` owns the `alloc` name at the crate root, so the +// allocator module is mounted under a name of its own. +#[path = "alloc.rs"] +mod heap; + +static mut UI: Option = None; + +/// Snapshot of the most recent `ui_draw`. The core's `Vec` reallocates as +/// a frame's op count changes, so this is refreshed per build rather than +/// cached by the caller across frames. +static mut DRAW_PTR: *const u32 = core::ptr::null(); +static mut DRAW_LEN: usize = 0; + +/// `ui:img.` and `ui:sprite.` registrations from the last +/// `ui_feed_pak`, in pak order. The host publishes them as `ui.__textures` / +/// `ui.__sprites` (hosts/psp/src/pak.rs feeds the same two tables). +static mut PAK_TEXTURES: Vec<(String, i32)> = Vec::new(); +static mut PAK_SPRITES: Vec = Vec::new(); + +struct PakSprite { + name: String, + handle: i32, + frames: u16, + columns: u16, + step: u16, +} + +/// One live texture slot, as the C backend needs it: everything +/// `pocketjs_core::TexView` carries plus the slot's current generation-tagged +/// handle and content revision (the GPU cache key). +#[repr(C)] +pub struct PocketTexture { + pub pixels: *const u8, + pub pixels_len: usize, + /// 1024-byte CLUT (256 x u32 ABGR), non-null exactly when `psm` is PSM_T8. + pub palette: *const u8, + pub palette_len: usize, + pub width: u32, + pub height: u32, + /// spec::psm::* pixel format. + pub psm: u32, + /// Bilinear sampling hint (spec::img::FLAG_LINEAR); nearest otherwise. + pub linear: u32, + pub handle: i32, + pub revision: u64, +} + +/// One registered font atlas. `coverage` is glyphCount x coverage_height rows +/// of `coverage_width` alpha bytes, top row first — the layout +/// `pocketjs_core::text::Atlas::glyph_rows` indexes. +#[repr(C)] +pub struct PocketFontAtlas { + pub coverage: *const u8, + pub coverage_len: usize, + /// Logical cell size; DrawList glyph cells are drawn at exactly this size. + pub cell_width: u32, + pub cell_height: u32, + /// Raster samples: cell size times the atlas's density. + pub coverage_width: u32, + pub coverage_height: u32, + pub glyph_count: u32, +} + +#[inline] +fn ui() -> &'static mut Ui { + unsafe { UI.get_or_insert_with(Ui::new) } +} + +#[inline] +unsafe fn bytes<'a>(ptr: *const u8, len: usize) -> &'a [u8] { + if ptr.is_null() || len == 0 { + &[] + } else { + core::slice::from_raw_parts(ptr, len) + } +} + +#[inline] +unsafe fn text<'a>(ptr: *const u8, len: usize) -> &'a str { + core::str::from_utf8(bytes(ptr, len)).unwrap_or("") +} + +/// QuickJS encodes lone UTF-16 surrogates (a string sliced mid-emoji) as WTF-8 +/// bytes that are not valid UTF-8. They become U+FFFD, matching the web host, +/// instead of silently dropping the whole update. Valid input borrows. +#[inline] +unsafe fn text_lossy<'a>(ptr: *const u8, len: usize) -> alloc::borrow::Cow<'a, str> { + alloc::string::String::from_utf8_lossy(bytes(ptr, len)) +} + +#[inline] +fn read_f64_le(record: &[u8], offset: usize) -> f64 { + let mut raw = [0u8; 8]; + raw.copy_from_slice(&record[offset..offset + 8]); + f64::from_le_bytes(raw) +} + +fn clear_draw_snapshot() { + unsafe { + DRAW_PTR = core::ptr::null(); + DRAW_LEN = 0; + } +} + +// ---- lifecycle ------------------------------------------------------------- + +/// Reset the single UI instance. `raster_density == 0` selects density 1. +#[no_mangle] +pub extern "C" fn ui_init(raster_density: u32) { + unsafe { + UI = Some(Ui::new_with_raster_density(raster_density.max(1))); + PAK_TEXTURES = Vec::new(); + PAK_SPRITES = Vec::new(); + } + clear_draw_snapshot(); +} + +/// Drop all retained UI, texture and font allocations. +#[no_mangle] +pub extern "C" fn ui_shutdown() { + unsafe { + UI = None; + PAK_TEXTURES = Vec::new(); + PAK_SPRITES = Vec::new(); + } + clear_draw_snapshot(); +} + +/// Set the logical viewport. The 3DS host owns the whole 400x240 top screen, +/// so this is called once at boot and never changes (form "takeover"). +#[no_mangle] +pub extern "C" fn ui_set_viewport(width: f32, height: f32) { + ui().set_viewport(width, height); + clear_draw_snapshot(); +} + +#[no_mangle] +pub extern "C" fn ui_viewport_width() -> u32 { + ui().viewport().0 as u32 +} + +#[no_mangle] +pub extern "C" fn ui_viewport_height() -> u32 { + ui().viewport().1 as u32 +} + +/// Optional C-side scratch allocation out of the Rust heap. The caller must +/// release it with the exact same `len`; ordinary borrowed HostOps arguments +/// do not need this. +#[no_mangle] +pub extern "C" fn ui_alloc(len: usize) -> *mut u8 { + let mut value = Vec::::with_capacity(len.max(1)); + let ptr = value.as_mut_ptr(); + core::mem::forget(value); + ptr +} + +#[no_mangle] +pub extern "C" fn ui_free(ptr: *mut u8, len: usize) { + if !ptr.is_null() { + unsafe { + drop(Vec::from_raw_parts(ptr, 0, len.max(1))); + } + } +} + +// ---- HostOps --------------------------------------------------------------- + +#[no_mangle] +pub extern "C" fn ui_create_node(node_type: u32) -> i32 { + ui().create_node(node_type as u8) +} + +#[no_mangle] +pub extern "C" fn ui_destroy_node(id: i32) { + ui().destroy_node(id); +} + +#[no_mangle] +pub extern "C" fn ui_insert_before(parent: i32, child: i32, anchor: i32) { + ui().insert_before(parent, child, anchor); +} + +#[no_mangle] +pub extern "C" fn ui_remove_child(parent: i32, child: i32) { + ui().remove_child(parent, child); +} + +#[no_mangle] +pub extern "C" fn ui_set_style(id: i32, style_id: i32) { + ui().set_style(id, style_id); +} + +#[no_mangle] +pub extern "C" fn ui_set_prop(id: i32, prop: u32, value: f64) { + ui().set_prop(id, prop as u8, value); +} + +/// Apply packed little-endian Float64 triples `[nodeId, propId, value]`. +/// A trailing partial record is ignored. +#[no_mangle] +pub extern "C" fn ui_set_prop_batch(ptr: *const u8, len: usize) { + let (records, _) = unsafe { bytes(ptr, len) }.as_chunks::<24>(); + let instance = ui(); + for record in records { + instance.set_prop( + read_f64_le(record, 0) as i32, + read_f64_le(record, 8) as u8, + read_f64_le(record, 16), + ); + } +} + +#[no_mangle] +pub extern "C" fn ui_set_text(id: i32, ptr: *const u8, len: usize) { + ui().set_text(id, unsafe { &text_lossy(ptr, len) }); +} + +#[no_mangle] +pub extern "C" fn ui_replace_text(id: i32, ptr: *const u8, len: usize) { + ui().replace_text(id, unsafe { &text_lossy(ptr, len) }); +} + +#[no_mangle] +pub extern "C" fn ui_upload_texture( + ptr: *const u8, + len: usize, + width: u32, + height: u32, + psm: u32, +) -> i32 { + ui().upload_texture(unsafe { bytes(ptr, len) }, width, height, psm) +} + +#[no_mangle] +pub extern "C" fn ui_upload_img_entry(ptr: *const u8, len: usize) -> i32 { + ui().upload_img_entry(unsafe { bytes(ptr, len) }) +} + +/// Decode one tile from a complete TILESET pak entry (spec op 23). +#[no_mangle] +pub extern "C" fn ui_upload_tileset_tile(ptr: *const u8, len: usize, index: u32) -> i32 { + ui().upload_tileset_tile(unsafe { bytes(ptr, len) }, index) +} + +#[no_mangle] +pub extern "C" fn ui_free_texture(handle: i32) { + ui().free_texture(handle); +} + +#[no_mangle] +pub extern "C" fn ui_set_image(id: i32, texture: i32) { + ui().set_image(id, texture); +} + +#[no_mangle] +pub extern "C" fn ui_set_sprite(id: i32, atlas: i32, frames: u32, columns: u32, step: u32) { + ui().set_sprite(id, atlas, frames, columns, step); +} + +#[no_mangle] +pub extern "C" fn ui_animate( + id: i32, + prop: u32, + to: f64, + duration_ms: u32, + easing: u32, + delay_ms: u32, +) -> i32 { + ui().animate(id, prop as u8, to, duration_ms, easing as u8, delay_ms) +} + +#[no_mangle] +pub extern "C" fn ui_cancel_anim(animation_id: i32) { + ui().cancel_anim(animation_id); +} + +#[no_mangle] +pub extern "C" fn ui_set_focus(id: i32) { + ui().set_focus(id); +} + +#[no_mangle] +pub extern "C" fn ui_set_active(id: i32, active: i32) { + ui().set_active(id, active != 0); +} + +#[no_mangle] +pub extern "C" fn ui_hit_test(x: f32, y: f32) -> i32 { + ui().hit_test(x, y) +} + +#[no_mangle] +pub extern "C" fn ui_hit_test_bounds(x: f32, y: f32) -> i32 { + ui().hit_test_bounds(x, y) +} + +#[no_mangle] +pub extern "C" fn ui_set_cursor(texture: i32, hot_x: f32, hot_y: f32, width: f32, height: f32) { + ui().set_cursor(texture, hot_x, hot_y, width, height); +} + +#[no_mangle] +pub extern "C" fn ui_set_cursor_pos(x: f32, y: f32) { + ui().set_cursor_pos(x, y); +} + +#[no_mangle] +pub extern "C" fn ui_load_styles(ptr: *const u8, len: usize) -> i32 { + ui().load_styles(unsafe { bytes(ptr, len) }) as i32 +} + +#[no_mangle] +pub extern "C" fn ui_load_font_atlas(ptr: *const u8, len: usize) -> i32 { + ui().load_font_atlas(unsafe { bytes(ptr, len) }) as i32 +} + +#[no_mangle] +pub extern "C" fn ui_measure_text(ptr: *const u8, len: usize, font_slot: u32) -> f32 { + ui().measure_text(unsafe { &text_lossy(ptr, len) }, font_slot as u8) +} + +// ---- fixed-step frame and DrawList ----------------------------------------- + +#[no_mangle] +pub extern "C" fn ui_tick() { + ui().tick(); +} + +/// Build this frame's DrawList and return its length in words. +/// +/// Call exactly once per presented frame: the build is not idempotent (it +/// advances the DevTools highlight glide) and it is the only thing that +/// refreshes what `ui_draw_list_ptr`/`ui_draw_list_len` report. +#[no_mangle] +pub extern "C" fn ui_draw() -> usize { + let words = &ui().draw().words; + unsafe { + DRAW_PTR = words.as_ptr(); + DRAW_LEN = words.len(); + DRAW_LEN + } +} + +/// The word stream built by the last `ui_draw` (null before the first one). +/// Format: contracts/spec/spec.ts "DRAWLIST op format". +#[no_mangle] +pub extern "C" fn ui_draw_list_ptr() -> *const u32 { + unsafe { DRAW_PTR } +} + +#[no_mangle] +pub extern "C" fn ui_draw_list_len() -> usize { + unsafe { DRAW_LEN } +} + +/// FNV-1a64 over the last built word stream — the cheap frame identity the +/// golden tooling compares when pixels are not available. +#[no_mangle] +pub extern "C" fn ui_draw_hash() -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325u64; + let words = unsafe { core::slice::from_raw_parts(DRAW_PTR, DRAW_LEN) }; + for word in words { + for byte in word.to_le_bytes() { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + } + hash +} + +// ---- texture and font registries (what gfx.c binds) ------------------------ + +/// Number of texture slots ever allocated — the walk bound for +/// `ui_texture_at`. +#[no_mangle] +pub extern "C" fn ui_texture_slot_count() -> usize { + ui().texture_slot_count() +} + +/// Mask that turns a DrawList texture handle into its slot index +/// (spec TEX_SLOT_BITS). The generation lives above it, so a cache entry is +/// only valid while its stored `handle` still equals the DrawList's word. +#[no_mangle] +pub extern "C" fn ui_texture_slot_mask() -> u32 { + spec::TEX_SLOT_MASK +} + +/// Fill `out` with the live texture in `slot`. Returns 1 when the slot holds +/// one, 0 when it is free (leaving `out` untouched). +#[no_mangle] +pub extern "C" fn ui_texture_at(slot: u32, out: *mut PocketTexture) -> i32 { + if out.is_null() { + return 0; + } + let Some((handle, revision, view)) = ui().texture_at_versioned(slot) else { + return 0; + }; + unsafe { + (*out).pixels = view.pixels.as_ptr(); + (*out).pixels_len = view.pixels.len(); + (*out).palette = view.palette.map_or(core::ptr::null(), |p| p.as_ptr()); + (*out).palette_len = view.palette.map_or(0, |p| p.len()); + (*out).width = view.w; + (*out).height = view.h; + (*out).psm = view.psm; + (*out).linear = view.linear as u32; + (*out).handle = handle; + (*out).revision = revision; + } + 1 +} + +/// Font slots the core can hold (spec MAX_FONT_SLOTS) — the walk bound for +/// `ui_font_atlas`. +#[no_mangle] +pub extern "C" fn ui_font_slot_count() -> usize { + spec::MAX_FONT_SLOTS +} + +/// Fill `out` with the atlas registered in `slot`. Returns 1 when one is +/// registered, 0 otherwise. `coverage` doubles as the cache identity: loading +/// a new atlas into a slot replaces the allocation. +#[no_mangle] +pub extern "C" fn ui_font_atlas(slot: u32, out: *mut PocketFontAtlas) -> i32 { + if out.is_null() || slot >= spec::MAX_FONT_SLOTS as u32 { + return 0; + } + let Some(atlas) = ui().font_atlas(slot as u8) else { + return 0; + }; + unsafe { + (*out).coverage = atlas.bitmap.as_ptr(); + (*out).coverage_len = atlas.bitmap.len(); + (*out).cell_width = atlas.cell_w; + (*out).cell_height = atlas.cell_h; + (*out).coverage_width = atlas.coverage_width(); + (*out).coverage_height = atlas.coverage_height(); + (*out).glyph_count = atlas.glyph_count as u32; + } + 1 +} + +// ---- asset pack ------------------------------------------------------------ + +/// Feed every recognized entry of an app pak straight to the core, before any +/// JS runs — styles.bin, font atlases, images and sprite atlases, with zero +/// QuickJS-heap transit. Malformed entries are skipped, never fatal. +/// +/// Returns the number of entries fed. The `ui:img.*` and `ui:sprite.*` name +/// tables the host publishes as `ui.__textures` / `ui.__sprites` are then read +/// back through the accessors below. `ui:tile.*` is deliberately skipped: +/// deep-zoom tiles stream one at a time through `ui_upload_tileset_tile`. +#[no_mangle] +pub extern "C" fn ui_feed_pak(ptr: *const u8, len: usize) -> u32 { + let pak = unsafe { bytes(ptr, len) }; + let instance = ui(); + let mut fed = 0u32; + let mut textures = Vec::new(); + let mut sprites = Vec::new(); + for entry in pocketjs_core::pak::entries(pak) { + let blob = entry.blob; + if entry.key == "ui:styles" { + fed += instance.load_styles(blob) as u32; + } else if entry.key.starts_with("ui:font.") { + fed += instance.load_font_atlas(blob) as u32; + } else if let Some(name) = entry.key.strip_prefix("ui:img.") { + // IMG entry: 8-byte header {u16 w, u16 h, u8 psm, 3B pad} + pixels + // (framework/compiler/pak.ts encodeImageEntry). + let (Some(width), Some(height), Some(&psm), Some(pixels)) = ( + read_u16(blob, 0), + read_u16(blob, 2), + blob.get(4), + blob.get(8..), + ) else { + continue; + }; + let handle = instance.upload_texture(pixels, width as u32, height as u32, psm as u32); + if handle >= 0 { + textures.push((String::from(name), handle)); + fed += 1; + } + } else if let Some(name) = entry.key.strip_prefix("ui:sprite.") { + // SPRITE entry: 16-byte header {u16 atlasW, u16 atlasH, u8 psm, + // u8 pad, u16 frameCount, u16 cols, u16 frameStep, 4B pad} + atlas + // pixels (framework/compiler/pak.ts encodeSpriteEntry). + let ( + Some(width), + Some(height), + Some(&psm), + Some(frames), + Some(columns), + Some(step), + Some(pixels), + ) = ( + read_u16(blob, 0), + read_u16(blob, 2), + blob.get(4), + read_u16(blob, 6), + read_u16(blob, 8), + read_u16(blob, 10), + blob.get(16..), + ) + else { + continue; + }; + let handle = instance.upload_texture(pixels, width as u32, height as u32, psm as u32); + if handle >= 0 { + sprites.push(PakSprite { + name: String::from(name), + handle, + frames, + columns, + step, + }); + fed += 1; + } + } + // unknown keys: ignored (forward compatible) + } + unsafe { + PAK_TEXTURES = textures; + PAK_SPRITES = sprites; + } + fed +} + +/// Look up one pak entry's blob by exact key — the runtime side of the +/// streaming ops (`loadTileTexture`'s `ui:tile.` keys, which +/// `ui_feed_pak` skips). Returns the blob length, 0 on a miss. +#[no_mangle] +pub extern "C" fn ui_pak_find( + ptr: *const u8, + len: usize, + key_ptr: *const u8, + key_len: usize, + out: *mut *const u8, +) -> usize { + let pak = unsafe { bytes(ptr, len) }; + let key = unsafe { text(key_ptr, key_len) }; + match pocketjs_core::pak::find(pak, key) { + Some(blob) => { + if !out.is_null() { + unsafe { *out = blob.as_ptr() }; + } + blob.len() + } + None => 0, + } +} + +#[no_mangle] +pub extern "C" fn ui_pak_texture_count() -> usize { + unsafe { PAK_TEXTURES.len() } +} + +/// The bare `src` name of registration `index` — NOT NUL-terminated; pair it +/// with `ui_pak_texture_name_len`. +#[no_mangle] +pub extern "C" fn ui_pak_texture_name(index: usize) -> *const u8 { + unsafe { + PAK_TEXTURES + .get(index) + .map_or(core::ptr::null(), |(name, _)| name.as_ptr()) + } +} + +#[no_mangle] +pub extern "C" fn ui_pak_texture_name_len(index: usize) -> usize { + unsafe { PAK_TEXTURES.get(index).map_or(0, |(name, _)| name.len()) } +} + +#[no_mangle] +pub extern "C" fn ui_pak_texture_handle(index: usize) -> i32 { + unsafe { PAK_TEXTURES.get(index).map_or(-1, |&(_, handle)| handle) } +} + +#[no_mangle] +pub extern "C" fn ui_pak_sprite_count() -> usize { + unsafe { PAK_SPRITES.len() } +} + +#[no_mangle] +pub extern "C" fn ui_pak_sprite_name(index: usize) -> *const u8 { + unsafe { + PAK_SPRITES + .get(index) + .map_or(core::ptr::null(), |sprite| sprite.name.as_ptr()) + } +} + +#[no_mangle] +pub extern "C" fn ui_pak_sprite_name_len(index: usize) -> usize { + unsafe { PAK_SPRITES.get(index).map_or(0, |sprite| sprite.name.len()) } +} + +#[no_mangle] +pub extern "C" fn ui_pak_sprite_handle(index: usize) -> i32 { + unsafe { PAK_SPRITES.get(index).map_or(-1, |sprite| sprite.handle) } +} + +#[no_mangle] +pub extern "C" fn ui_pak_sprite_frames(index: usize) -> u32 { + unsafe { + PAK_SPRITES + .get(index) + .map_or(0, |sprite| sprite.frames as u32) + } +} + +#[no_mangle] +pub extern "C" fn ui_pak_sprite_columns(index: usize) -> u32 { + unsafe { + PAK_SPRITES + .get(index) + .map_or(0, |sprite| sprite.columns as u32) + } +} + +#[no_mangle] +pub extern "C" fn ui_pak_sprite_step(index: usize) -> u32 { + unsafe { PAK_SPRITES.get(index).map_or(0, |sprite| sprite.step as u32) } +} + +// ---- DevTools (spec ops 18..22) -------------------------------------------- + +#[no_mangle] +pub extern "C" fn ui_debug_inspect(id: i32) { + ui().debug_inspect(id); +} + +#[no_mangle] +pub extern "C" fn ui_debug_rect_xy() -> i32 { + ui().debug_rect_xy() +} + +#[no_mangle] +pub extern "C" fn ui_debug_rect_wh() -> i32 { + ui().debug_rect_wh() +} + +#[no_mangle] +pub extern "C" fn ui_debug_pause(on: i32) { + ui().debug_pause(on != 0); +} + +#[no_mangle] +pub extern "C" fn ui_debug_step() { + ui().debug_step(); +} + +#[inline] +fn read_u16(blob: &[u8], offset: usize) -> Option { + Some(u16::from_le_bytes([ + *blob.get(offset)?, + *blob.get(offset + 1)?, + ])) +} diff --git a/hosts/3ds/icon.png b/hosts/3ds/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b7caebde787cfb438c089258ad82a4bed660171d GIT binary patch literal 324 zcmV-K0lWT*P)>L`p})0_?#OtdSiksSyH!lma2-*v|QW;ZAyO{Jz{f z$4&@d#%elSJSk<;G#QMPaTJi$1DC5ciBKW9Oqar#GG7g$!F&t?1z_G?NL3AxiVwB< z7{urpm6)Gw8WNR2R0B|%|AugYTnA3)M^frLU>?9{K)M6_?hcZg2$=`)8IbM(W^5e5 zXTZ{7&g^ko>OzAGAaSKc2T*^p|0{rIcX-PIyaR<1`i#wxO5=2G+g_|Q-~gPvP;MvU z*o8oUs3ifm$6GJZ+y6-b$uBY}K3X8E%*OyYU{Y(22FO8h3S?dbNVRbZ7!uQ7fv#TG Wce&C|(^AF&0000 +#include + +void ui_init(uint32_t raster_density); +void ui_shutdown(void); +void ui_set_viewport(float width, float height); +uint32_t ui_viewport_width(void); +uint32_t ui_viewport_height(void); +uint8_t *ui_alloc(size_t length); +void ui_free(uint8_t *bytes, size_t length); + +/* HostOps (framework/src/host.ts; op codes in contracts/spec/spec.ts OP). */ +int32_t ui_create_node(uint32_t node_type); +void ui_destroy_node(int32_t id); +void ui_insert_before(int32_t parent, int32_t child, int32_t anchor); +void ui_remove_child(int32_t parent, int32_t child); +void ui_set_style(int32_t id, int32_t style_id); +void ui_set_prop(int32_t id, uint32_t prop, double value); +void ui_set_prop_batch(const uint8_t *bytes, size_t length); +void ui_set_text(int32_t id, const uint8_t *text, size_t length); +void ui_replace_text(int32_t id, const uint8_t *text, size_t length); +int32_t ui_upload_texture( + const uint8_t *bytes, + size_t length, + uint32_t width, + uint32_t height, + uint32_t pixel_storage +); +int32_t ui_upload_img_entry(const uint8_t *bytes, size_t length); +int32_t ui_upload_tileset_tile(const uint8_t *bytes, size_t length, uint32_t index); +void ui_free_texture(int32_t handle); +void ui_set_image(int32_t id, int32_t texture); +void ui_set_sprite( + int32_t id, + int32_t atlas, + uint32_t frames, + uint32_t columns, + uint32_t step +); +int32_t ui_animate( + int32_t id, + uint32_t prop, + double to, + uint32_t duration_ms, + uint32_t easing, + uint32_t delay_ms +); +void ui_cancel_anim(int32_t animation_id); +void ui_set_focus(int32_t id); +void ui_set_active(int32_t id, int32_t active); +int32_t ui_hit_test(float x, float y); +int32_t ui_hit_test_bounds(float x, float y); +void ui_set_cursor(int32_t texture, float hot_x, float hot_y, float width, float height); +void ui_set_cursor_pos(float x, float y); +int32_t ui_load_styles(const uint8_t *bytes, size_t length); +int32_t ui_load_font_atlas(const uint8_t *bytes, size_t length); +float ui_measure_text(const uint8_t *text, size_t length, uint32_t font_slot); + +/* Fixed-step frame. The core steps at exactly 1/60 s per ui_tick regardless + * of the host's present cadence (contracts/spec/spec.ts FIXED_DT). */ +void ui_tick(void); + +/* + * DrawList handoff. Unlike engine/symbian, whose GLES backends consume the + * list inside the Rust crate, the PICA200 backend is C (citro3d is mostly + * `static inline`), so the word stream itself crosses the ABI. Format: + * contracts/spec/spec.ts "DRAWLIST op format". + * + * ui_draw builds the frame's list and returns its length in words; the two + * accessors report what it built. Call it exactly once per presented frame — + * the build is not idempotent (it advances the DevTools highlight glide). + */ +size_t ui_draw(void); +const uint32_t *ui_draw_list_ptr(void); +size_t ui_draw_list_len(void); +uint64_t ui_draw_hash(void); + +/* + * Texture and font registries: how the C backend resolves a DrawList handle + * to pixels. A handle's slot is `handle & ui_texture_slot_mask()`; the bits + * above it are the generation, so a cached GPU texture is only valid while + * its recorded `handle` still equals the value in the DrawList word, and its + * recorded `revision` still equals the slot's (bytes can be overwritten in + * place behind one live handle). + */ +typedef struct { + const uint8_t *pixels; + size_t pixels_length; + /* 1024-byte CLUT (256 x uint32 ABGR), non-null exactly when psm is PSM_T8. */ + const uint8_t *palette; + size_t palette_length; + uint32_t width; + uint32_t height; + /* contracts/spec/spec.ts PSM. */ + uint32_t pixel_storage; + /* Bilinear sampling hint (IMG FLAG_LINEAR); nearest otherwise. */ + uint32_t linear; + int32_t handle; + uint64_t revision; +} PocketTexture; + +size_t ui_texture_slot_count(void); +uint32_t ui_texture_slot_mask(void); +int32_t ui_texture_at(uint32_t slot, PocketTexture *out); + +/* + * A registered font atlas. `coverage` holds glyph_count blocks of + * coverage_height rows of coverage_width alpha bytes, top row first; glyph + * `gid` starts at gid * coverage_height * coverage_width. Cells are drawn at + * the LOGICAL size — coverage dimensions are those times the atlas density. + * `coverage` doubles as the cache identity: loading a new atlas into a slot + * replaces the allocation. + */ +typedef struct { + const uint8_t *coverage; + size_t coverage_length; + uint32_t cell_width; + uint32_t cell_height; + uint32_t coverage_width; + uint32_t coverage_height; + uint32_t glyph_count; +} PocketFontAtlas; + +size_t ui_font_slot_count(void); +int32_t ui_font_atlas(uint32_t slot, PocketFontAtlas *out); + +/* + * Asset pack. ui_feed_pak walks the app's .pak and feeds styles.bin, font + * atlases, images and sprite atlases straight to the core before any JS runs + * — zero QuickJS-heap transit. The `ui:img.*` / `ui:sprite.*` name tables it + * records are what the host publishes as `ui.__textures` / `ui.__sprites`. + * Names are NOT NUL-terminated; use them with their length. + */ +uint32_t ui_feed_pak(const uint8_t *bytes, size_t length); +size_t ui_pak_find( + const uint8_t *bytes, + size_t length, + const uint8_t *key, + size_t key_length, + const uint8_t **out +); +size_t ui_pak_texture_count(void); +const uint8_t *ui_pak_texture_name(size_t index); +size_t ui_pak_texture_name_len(size_t index); +int32_t ui_pak_texture_handle(size_t index); +size_t ui_pak_sprite_count(void); +const uint8_t *ui_pak_sprite_name(size_t index); +size_t ui_pak_sprite_name_len(size_t index); +int32_t ui_pak_sprite_handle(size_t index); +uint32_t ui_pak_sprite_frames(size_t index); +uint32_t ui_pak_sprite_columns(size_t index); +uint32_t ui_pak_sprite_step(size_t index); + +/* DevTools (spec ops 18..22, docs/DEVTOOLS.md). All default-off. */ +void ui_debug_inspect(int32_t id); +int32_t ui_debug_rect_xy(void); +int32_t ui_debug_rect_wh(void); +void ui_debug_pause(int32_t paused); +void ui_debug_step(void); + +#endif diff --git a/hosts/3ds/src/gfx.c b/hosts/3ds/src/gfx.c new file mode 100644 index 00000000..3bbec9ac --- /dev/null +++ b/hosts/3ds/src/gfx.c @@ -0,0 +1,939 @@ +/* + * PICA200 DrawList backend. + * + * The Rust core emits the same flat u32 word stream on every target + * (contracts/spec/spec.ts "DRAWLIST op format"); this file is the 3DS + * equivalent of engine/symbian/src/gl/mod.rs — the walk, the texture and + * font-atlas caches, and batching by texture and scissor are the same shape, + * only the state they turn into is citro3d instead of GLES. + * + * No clipping happens here. The core's CPU clip stage guarantees every + * coordinate is inside the viewport and i16-safe before the list is emitted. + * + * PICA200 constraints that shape the code: + * - Textures are power-of-two, 8..1024 per dimension, and must already be + * in the hardware's tiled layout: 8x8 tiles row-major, Morton order + * inside a tile. C3D_TexUpload is a plain memcpy and will happily upload + * garbage. + * - Tiled row 0 is sampled at v = 1, so the image is flipped vertically + * while it is being tiled and DrawList UVs then pass through unchanged. + * - RGBA8 texels are stored bytes A, B, G, R. + * - Vertex buffers must live in linearAlloc memory; BufInfo_Add rejects any + * pointer below physical 0x18000000, so a malloc'd buffer can never be a + * VBO. + * - There is no fragment shader. One TEV stage modulates the sampled texel + * by the primary colour, and untextured ops bind an 8x8 white texture, so + * that single stage covers every op. + * - There is no paletted texture format, so PSM_T8 is expanded at upload. + */ + +#include "gfx.h" + +#include <3ds.h> +#include +#include +#include + +#include "pocket_core.h" +#include "vshader_shbin.h" + +/* contracts/spec/spec.ts DRAW_OP. */ +#define DRAW_RECT 1u +#define DRAW_GRAD_RECT 2u +#define DRAW_GLYPH_RUN 3u +#define DRAW_TEX_QUAD 4u +#define DRAW_SCISSOR 5u +#define DRAW_SCISSOR_POP 6u +#define DRAW_TRI 7u +#define DRAW_TEX_TRI 8u + +/* contracts/spec/spec.ts GradDir. */ +#define GRAD_TO_BOTTOM 0u +#define GRAD_TO_TOP 1u +#define GRAD_TO_LEFT 2u +#define GRAD_TO_RIGHT 3u + +/* contracts/spec/spec.ts PSM. */ +#define PSM_5650 0u +#define PSM_4444 2u +#define PSM_8888 3u +#define PSM_T8 5u + +#define PICA_TEX_MIN 8u +#define PICA_TEX_MAX 1024u + +/* + * A frame's whole geometry lives in one linear-memory bump arena. 24576 + * vertices is 864 KiB and roughly 4000 quads — an order of magnitude more + * than a 400x240 screen of text and boxes emits. Overflow drops the rest of + * the frame's geometry and is counted, never silent. + */ +#define MAX_VERTICES 24576u +#define MAX_COMMANDS 1024u +#define MAX_CLIP_DEPTH 64u + +typedef struct { + float x, y; + float u, v; + float r, g, b, a; +} Vertex; + +typedef struct { + int32_t x, y, w, h; +} Clip; + +typedef struct { + C3D_Tex *texture; + uint32_t first; + uint32_t count; + Clip clip; +} Command; + +typedef struct { + C3D_Tex texture; + int32_t handle; + uint64_t revision; + /* Image size over its power-of-two envelope: DrawList UVs are normalised + * against the image, the sampler against the envelope. */ + float u_scale; + float v_scale; + bool live; +} ImageTexture; + +typedef struct { + C3D_Tex texture; + /* The core's coverage allocation doubles as the cache identity. */ + const uint8_t *coverage; + uint32_t glyph_count; + uint32_t coverage_width; + uint32_t coverage_height; + uint32_t cell_width; + uint32_t cell_height; + uint32_t columns; + uint32_t texture_width; + uint32_t texture_height; + bool live; +} FontTexture; + +static DVLB_s *shader_blob; +static shaderProgram_s shader_program; +static int projection_uniform; +static C3D_Mtx projection; + +static Vertex *vertices; +static uint32_t vertex_count; +static uint32_t dropped_vertices; +static Command commands[MAX_COMMANDS]; +static uint32_t command_count; + +static C3D_Tex white; +static ImageTexture *images; +static size_t image_capacity; +static FontTexture *fonts; +static size_t font_capacity; + +static uint32_t viewport_width; +static uint32_t viewport_height; +static bool initialized; + +// --------------------------------------------------------------------------- +// word decoding +// --------------------------------------------------------------------------- + +static inline float word_x(uint32_t word) { + return (float)(int16_t)(word & 0xffffu); +} + +static inline float word_y(uint32_t word) { + return (float)(int16_t)((word >> 16) & 0xffffu); +} + +static inline float word_w(uint32_t word) { + return (float)(word & 0xffffu); +} + +static inline float word_h(uint32_t word) { + return (float)((word >> 16) & 0xffffu); +} + +static inline float word_float(uint32_t word) { + float value; + memcpy(&value, &word, sizeof value); + return value; +} + +/* DrawList colours are u32 ABGR, whose little-endian bytes are R, G, B, A. */ +static inline void unpack_color(uint32_t color, float *out) { + out[0] = (float)(color & 0xffu) / 255.0f; + out[1] = (float)((color >> 8) & 0xffu) / 255.0f; + out[2] = (float)((color >> 16) & 0xffu) / 255.0f; + out[3] = (float)((color >> 24) & 0xffu) / 255.0f; +} + +// --------------------------------------------------------------------------- +// texture upload +// --------------------------------------------------------------------------- + +static uint32_t next_power_of_two(uint32_t value) { + uint32_t result = PICA_TEX_MIN; + while (result < value) result <<= 1; + return result; +} + +/* Byte offset of (x, y) inside one 8x8 PICA tile. */ +static inline uint32_t morton_offset(uint32_t x, uint32_t y) { + return (x & 1u) | ((y & 1u) << 1) | ((x & 2u) << 1) | ((y & 2u) << 2) | + ((x & 4u) << 2) | ((y & 4u) << 3); +} + +/* One source texel as R, G, B, A — the core's byte order (see + * engine/symbian/src/gl/mod.rs texture_rgba, which expands the same formats + * for GLES). Out-of-image reads are transparent so a non-power-of-two image + * can sit inside a power-of-two envelope. */ +static void fetch_texel( + const PocketTexture *source, + uint32_t x, + uint32_t y, + uint8_t *out +) { + size_t index = (size_t)y * source->width + x; + out[0] = out[1] = out[2] = out[3] = 0; + switch (source->pixel_storage) { + case PSM_5650: { + if ((index + 1) * 2 > source->pixels_length) return; + uint32_t pixel = (uint32_t)source->pixels[index * 2] | + ((uint32_t)source->pixels[index * 2 + 1] << 8); + uint32_t red = pixel & 0x1fu; + uint32_t green = (pixel >> 5) & 0x3fu; + uint32_t blue = (pixel >> 11) & 0x1fu; + out[0] = (uint8_t)((red << 3) | (red >> 2)); + out[1] = (uint8_t)((green << 2) | (green >> 4)); + out[2] = (uint8_t)((blue << 3) | (blue >> 2)); + out[3] = 255; + return; + } + case PSM_4444: { + if ((index + 1) * 2 > source->pixels_length) return; + uint32_t pixel = (uint32_t)source->pixels[index * 2] | + ((uint32_t)source->pixels[index * 2 + 1] << 8); + out[0] = (uint8_t)((pixel & 0x0fu) * 17u); + out[1] = (uint8_t)(((pixel >> 4) & 0x0fu) * 17u); + out[2] = (uint8_t)(((pixel >> 8) & 0x0fu) * 17u); + out[3] = (uint8_t)(((pixel >> 12) & 0x0fu) * 17u); + return; + } + case PSM_8888: { + if ((index + 1) * 4 > source->pixels_length) return; + memcpy(out, source->pixels + index * 4, 4); + return; + } + case PSM_T8: { + if (index + 1 > source->pixels_length || source->palette_length < 1024) return; + memcpy(out, source->palette + (size_t)source->pixels[index] * 4, 4); + return; + } + default: + return; + } +} + +/* + * Tile `width` x `height` source rows into a `texture_width` x + * `texture_height` power-of-two envelope and hand it to the GPU. + * + * The vertical flip lives here: tiled row 0 is sampled at v = 1, so image row + * 0 has to be the LAST tiled row for a DrawList UV of v = 0 to mean the top + * of the image. A short envelope therefore leaves its padding at the top. + */ +static bool upload_tiled( + C3D_Tex *texture, + const PocketTexture *source, + const uint8_t *coverage, + uint32_t width, + uint32_t height, + uint32_t texture_width, + uint32_t texture_height, + bool linear +) { + if (!C3D_TexInit(texture, (u16)texture_width, (u16)texture_height, GPU_RGBA8)) return false; + size_t bytes = (size_t)texture_width * texture_height * 4; + uint8_t *tiled = malloc(bytes); + if (tiled == NULL) { + C3D_TexDelete(texture); + return false; + } + memset(tiled, 0, bytes); + uint32_t tiles_across = texture_width / 8; + for (uint32_t ty = 0; ty < texture_height; ty += 8) { + for (uint32_t tx = 0; tx < texture_width; tx += 8) { + uint32_t tile = (ty / 8) * tiles_across + (tx / 8); + for (uint32_t y = 0; y < 8; y += 1) { + uint32_t row = texture_height - 1 - (ty + y); + if (row >= height) continue; + for (uint32_t x = 0; x < 8; x += 1) { + uint32_t column = tx + x; + if (column >= width) continue; + uint8_t rgba[4]; + if (source != NULL) { + fetch_texel(source, column, row, rgba); + } else { + /* Font coverage: white ink, alpha from the glyph grid. */ + rgba[0] = rgba[1] = rgba[2] = 255; + rgba[3] = coverage[(size_t)row * width + column]; + } + uint8_t *destination = tiled + ((size_t)tile * 64 + morton_offset(x, y)) * 4; + destination[0] = rgba[3]; + destination[1] = rgba[2]; + destination[2] = rgba[1]; + destination[3] = rgba[0]; + } + } + } + } + C3D_TexUpload(texture, tiled); + C3D_TexSetFilter( + texture, + linear ? GPU_LINEAR : GPU_NEAREST, + linear ? GPU_LINEAR : GPU_NEAREST + ); + C3D_TexSetWrap(texture, GPU_CLAMP_TO_EDGE, GPU_CLAMP_TO_EDGE); + free(tiled); + return true; +} + +// --------------------------------------------------------------------------- +// resource sync +// --------------------------------------------------------------------------- + +static bool upload_image(ImageTexture *entry, const PocketTexture *source) { + if (source->width == 0 || source->height == 0) return false; + if (source->width > PICA_TEX_MAX || source->height > PICA_TEX_MAX) return false; + uint32_t texture_width = next_power_of_two(source->width); + uint32_t texture_height = next_power_of_two(source->height); + if (!upload_tiled( + &entry->texture, + source, + NULL, + source->width, + source->height, + texture_width, + texture_height, + source->linear != 0 + )) { + return false; + } + entry->u_scale = (float)source->width / (float)texture_width; + entry->v_scale = (float)source->height / (float)texture_height; + return true; +} + +/* Lay the glyph coverage cells out in a roughly square grid whose power-of-two + * envelope the PICA can hold — engine/symbian/src/gl/mod.rs font_grid picks + * the same shape for GLES. */ +static bool font_grid( + const PocketFontAtlas *atlas, + uint32_t *out_columns, + uint32_t *out_width, + uint32_t *out_height +) { + if (atlas->coverage_width == 0 || atlas->coverage_height == 0) return false; + if (atlas->coverage_width > PICA_TEX_MAX || atlas->coverage_height > PICA_TEX_MAX) return false; + uint32_t max_columns = PICA_TEX_MAX / atlas->coverage_width; + if (max_columns == 0) return false; + uint32_t columns = 1; + while (columns < max_columns && columns * columns < atlas->glyph_count) columns += 1; + for (;;) { + uint32_t rows = (atlas->glyph_count + columns - 1) / columns; + uint32_t width = next_power_of_two(columns * atlas->coverage_width); + uint32_t height = next_power_of_two(rows * atlas->coverage_height); + if (width <= PICA_TEX_MAX && height <= PICA_TEX_MAX) { + *out_columns = columns; + *out_width = width; + *out_height = height; + return true; + } + if (columns >= max_columns) return false; + columns = max_columns; + } +} + +static bool upload_font(FontTexture *entry, const PocketFontAtlas *atlas) { + uint32_t columns = 0; + uint32_t texture_width = 0; + uint32_t texture_height = 0; + if (!font_grid(atlas, &columns, &texture_width, &texture_height)) return false; + size_t grid_bytes = (size_t)texture_width * texture_height; + uint8_t *grid = calloc(grid_bytes, 1); + if (grid == NULL) return false; + for (uint32_t glyph = 0; glyph < atlas->glyph_count; glyph += 1) { + size_t source = (size_t)glyph * atlas->coverage_height * atlas->coverage_width; + if (source + (size_t)atlas->coverage_height * atlas->coverage_width > atlas->coverage_length) { + break; + } + uint32_t x = (glyph % columns) * atlas->coverage_width; + uint32_t y = (glyph / columns) * atlas->coverage_height; + for (uint32_t row = 0; row < atlas->coverage_height; row += 1) { + memcpy( + grid + (size_t)(y + row) * texture_width + x, + atlas->coverage + source + (size_t)row * atlas->coverage_width, + atlas->coverage_width + ); + } + } + bool ok = upload_tiled( + &entry->texture, + NULL, + grid, + texture_width, + texture_height, + texture_width, + texture_height, + true + ); + free(grid); + if (!ok) return false; + entry->coverage = atlas->coverage; + entry->glyph_count = atlas->glyph_count; + entry->coverage_width = atlas->coverage_width; + entry->coverage_height = atlas->coverage_height; + entry->cell_width = atlas->cell_width; + entry->cell_height = atlas->cell_height; + entry->columns = columns; + entry->texture_width = texture_width; + entry->texture_height = texture_height; + return true; +} + +static void release_image(ImageTexture *entry) { + if (entry->live) { + C3D_TexDelete(&entry->texture); + entry->live = false; + } +} + +static void release_font(FontTexture *entry) { + if (entry->live) { + C3D_TexDelete(&entry->texture); + entry->live = false; + } +} + +static void sync_resources(void) { + size_t slots = ui_texture_slot_count(); + if (slots > image_capacity) { + ImageTexture *grown = realloc(images, slots * sizeof *images); + if (grown != NULL) { + memset(grown + image_capacity, 0, (slots - image_capacity) * sizeof *grown); + images = grown; + image_capacity = slots; + } else { + slots = image_capacity; + } + } + for (size_t slot = 0; slot < image_capacity; slot += 1) { + ImageTexture *entry = &images[slot]; + PocketTexture source; + if (slot < slots && ui_texture_at((uint32_t)slot, &source)) { + if (entry->live && entry->handle == source.handle && entry->revision == source.revision) { + continue; + } + release_image(entry); + if (upload_image(entry, &source)) { + entry->handle = source.handle; + entry->revision = source.revision; + entry->live = true; + } + } else { + release_image(entry); + } + } + + for (size_t slot = 0; slot < font_capacity; slot += 1) { + FontTexture *entry = &fonts[slot]; + PocketFontAtlas atlas; + if (ui_font_atlas((uint32_t)slot, &atlas)) { + if (entry->live && entry->coverage == atlas.coverage && + entry->glyph_count == atlas.glyph_count) { + continue; + } + release_font(entry); + entry->live = upload_font(entry, &atlas); + } else { + release_font(entry); + } + } +} + +static C3D_Tex *image_texture(int32_t handle, float *u_scale, float *v_scale) { + if (handle < 0) return NULL; + size_t slot = (size_t)((uint32_t)handle & ui_texture_slot_mask()); + if (slot >= image_capacity) return NULL; + ImageTexture *entry = &images[slot]; + if (!entry->live || entry->handle != handle) return NULL; + *u_scale = entry->u_scale; + *v_scale = entry->v_scale; + return &entry->texture; +} + +// --------------------------------------------------------------------------- +// geometry +// --------------------------------------------------------------------------- + +static void push_vertex(float x, float y, float u, float v, const float *color) { + if (vertex_count >= MAX_VERTICES) { + dropped_vertices += 1; + return; + } + Vertex *vertex = &vertices[vertex_count++]; + vertex->x = x; + vertex->y = y; + vertex->u = u; + vertex->v = v; + vertex->r = color[0]; + vertex->g = color[1]; + vertex->b = color[2]; + vertex->a = color[3]; +} + +/* Two triangles, corner colours in top-left, top-right, bottom-right, + * bottom-left order (the gradient corners GRAD_RECT resolves to). */ +static void push_quad( + float x0, + float y0, + float x1, + float y1, + float u0, + float v0, + float u1, + float v1, + const uint32_t *colors +) { + float top_left[4], top_right[4], bottom_right[4], bottom_left[4]; + unpack_color(colors[0], top_left); + unpack_color(colors[1], top_right); + unpack_color(colors[2], bottom_right); + unpack_color(colors[3], bottom_left); + push_vertex(x0, y0, u0, v0, top_left); + push_vertex(x1, y0, u1, v0, top_right); + push_vertex(x1, y1, u1, v1, bottom_right); + push_vertex(x0, y0, u0, v0, top_left); + push_vertex(x1, y1, u1, v1, bottom_right); + push_vertex(x0, y1, u0, v1, bottom_left); +} + +static void flush(C3D_Tex *texture, Clip clip, uint32_t *start) { + if (vertex_count > *start && command_count < MAX_COMMANDS) { + Command *command = &commands[command_count++]; + command->texture = texture; + command->first = *start; + command->count = vertex_count - *start; + command->clip = clip; + } + *start = vertex_count; +} + +static void build(const uint32_t *words, size_t length) { + vertex_count = 0; + command_count = 0; + Clip full = { 0, 0, (int32_t)viewport_width, (int32_t)viewport_height }; + Clip clip = full; + Clip clip_stack[MAX_CLIP_DEPTH]; + uint32_t clip_depth = 0; + C3D_Tex *texture = &white; + uint32_t start = 0; + size_t index = 0; + + while (index < length) { + switch (words[index]) { + case DRAW_RECT: { + if (index + 4 > length) return; + if (texture != &white) { + flush(texture, clip, &start); + texture = &white; + } + float x = word_x(words[index + 1]); + float y = word_y(words[index + 1]); + float w = word_w(words[index + 2]); + float h = word_h(words[index + 2]); + uint32_t color = words[index + 3]; + if (w > 0.0f && h > 0.0f && (color >> 24) != 0) { + uint32_t colors[4] = { color, color, color, color }; + push_quad(x, y, x + w, y + h, 0.0f, 0.0f, 1.0f, 1.0f, colors); + } + index += 4; + break; + } + case DRAW_GRAD_RECT: { + if (index + 6 > length) return; + if (texture != &white) { + flush(texture, clip, &start); + texture = &white; + } + float x = word_x(words[index + 1]); + float y = word_y(words[index + 1]); + float w = word_w(words[index + 2]); + float h = word_h(words[index + 2]); + uint32_t from = words[index + 3]; + uint32_t to = words[index + 4]; + uint32_t colors[4]; + switch (words[index + 5]) { + case GRAD_TO_TOP: + colors[0] = to; colors[1] = to; colors[2] = from; colors[3] = from; + break; + case GRAD_TO_LEFT: + colors[0] = to; colors[1] = from; colors[2] = from; colors[3] = to; + break; + case GRAD_TO_RIGHT: + colors[0] = from; colors[1] = to; colors[2] = to; colors[3] = from; + break; + default: + colors[0] = from; colors[1] = from; colors[2] = to; colors[3] = to; + break; + } + if (w > 0.0f && h > 0.0f) { + push_quad(x, y, x + w, y + h, 0.0f, 0.0f, 1.0f, 1.0f, colors); + } + index += 6; + break; + } + case DRAW_GLYPH_RUN: { + if (index + 3 > length) return; + size_t slot = words[index + 1] & 0xffu; + size_t count = words[index + 1] >> 16; + size_t next = index + 3 + count * 2; + if (next > length) return; + FontTexture *font = slot < font_capacity ? &fonts[slot] : NULL; + if (font == NULL || !font->live) { + index = next; + break; + } + if (texture != &font->texture) { + flush(texture, clip, &start); + texture = &font->texture; + } + uint32_t color = words[index + 2]; + uint32_t colors[4] = { color, color, color, color }; + for (size_t glyph = 0; glyph < count; glyph += 1) { + size_t body = index + 3 + glyph * 2; + uint32_t id = words[body + 1] & 0xffffu; + if (id >= font->glyph_count) continue; + float x = word_x(words[body]); + float y = word_y(words[body]); + uint32_t column = id % font->columns; + uint32_t row = id / font->columns; + float u0 = (float)(column * font->coverage_width) / (float)font->texture_width; + float v0 = (float)(row * font->coverage_height) / (float)font->texture_height; + float u1 = (float)((column + 1) * font->coverage_width) / (float)font->texture_width; + float v1 = (float)((row + 1) * font->coverage_height) / (float)font->texture_height; + push_quad( + x, + y, + x + (float)font->cell_width, + y + (float)font->cell_height, + u0, + v0, + u1, + v1, + colors + ); + } + index = next; + break; + } + case DRAW_TEX_QUAD: { + if (index + 9 > length) return; + float u_scale = 1.0f; + float v_scale = 1.0f; + C3D_Tex *bound = image_texture((int32_t)words[index + 1], &u_scale, &v_scale); + if (bound == NULL) { + index += 9; + break; + } + if (texture != bound) { + flush(texture, clip, &start); + texture = bound; + } + float x = word_x(words[index + 2]); + float y = word_y(words[index + 2]); + float w = word_w(words[index + 3]); + float h = word_h(words[index + 3]); + uint32_t color = words[index + 8]; + uint32_t colors[4] = { color, color, color, color }; + if (w > 0.0f && h > 0.0f) { + push_quad( + x, + y, + x + w, + y + h, + word_float(words[index + 4]) * u_scale, + word_float(words[index + 5]) * v_scale, + word_float(words[index + 6]) * u_scale, + word_float(words[index + 7]) * v_scale, + colors + ); + } + index += 9; + break; + } + case DRAW_TEX_TRI: { + if (index + 12 > length) return; + float u_scale = 1.0f; + float v_scale = 1.0f; + C3D_Tex *bound = image_texture((int32_t)words[index + 1], &u_scale, &v_scale); + if (bound == NULL) { + index += 12; + break; + } + if (texture != bound) { + flush(texture, clip, &start); + texture = bound; + } + float color[4]; + unpack_color(words[index + 11], color); + for (size_t corner = 0; corner < 3; corner += 1) { + size_t offset = index + 2 + corner * 3; + push_vertex( + word_x(words[offset]), + word_y(words[offset]), + word_float(words[offset + 1]) * u_scale, + word_float(words[offset + 2]) * v_scale, + color + ); + } + index += 12; + break; + } + case DRAW_TRI: { + if (index + 7 > length) return; + if (texture != &white) { + flush(texture, clip, &start); + texture = &white; + } + for (size_t corner = 0; corner < 3; corner += 1) { + float color[4]; + unpack_color(words[index + 4 + corner], color); + push_vertex( + word_x(words[index + 1 + corner]), + word_y(words[index + 1 + corner]), + 0.0f, + 0.0f, + color + ); + } + index += 7; + break; + } + case DRAW_SCISSOR: { + if (index + 3 > length) return; + flush(texture, clip, &start); + if (clip_depth < MAX_CLIP_DEPTH) clip_stack[clip_depth] = clip; + clip_depth += 1; + clip.x = (int32_t)word_x(words[index + 1]); + clip.y = (int32_t)word_y(words[index + 1]); + clip.w = (int32_t)word_w(words[index + 2]); + clip.h = (int32_t)word_h(words[index + 2]); + index += 3; + break; + } + case DRAW_SCISSOR_POP: { + flush(texture, clip, &start); + if (clip_depth > 0) { + clip_depth -= 1; + clip = clip_depth < MAX_CLIP_DEPTH ? clip_stack[clip_depth] : full; + } else { + clip = full; + } + index += 1; + break; + } + default: + return; + } + } + flush(texture, clip, &start); +} + +// --------------------------------------------------------------------------- +// submission +// --------------------------------------------------------------------------- + +/* + * The render target is created rotated — 240 wide by 400 tall — and + * Mtx_OrthoTilt keeps app coordinates landscape by swapping the axes, so a + * logical rectangle has to be turned back into the raw framebuffer pixels the + * scissor register takes. + * + * Both scissor axes run OPPOSITE to the logical ones, which is easy to get + * half right: the horizontal pair counts down from the logical HEIGHT and the + * vertical pair counts down from the logical WIDTH. Passing the vertical pair + * un-flipped leaves the rect the right size in the right place along one axis + * and mirrored along the other, which only shows up when the clipped content + * is not already the size of its window. + */ +static bool apply_clip(Clip clip) { + int32_t x0 = clip.x; + int32_t y0 = clip.y; + int32_t x1 = clip.x + clip.w; + int32_t y1 = clip.y + clip.h; + /* The core already clipped every coordinate to the viewport, so these + * clamps never fire; they only keep the unsigned arithmetic below total. */ + if (x0 < 0) x0 = 0; + if (y0 < 0) y0 = 0; + if (x1 > (int32_t)viewport_width) x1 = (int32_t)viewport_width; + if (y1 > (int32_t)viewport_height) y1 = (int32_t)viewport_height; + if (x1 <= x0 || y1 <= y0) return false; + C3D_SetScissor( + GPU_SCISSOR_NORMAL, + (uint32_t)((int32_t)viewport_height - y1), + (uint32_t)((int32_t)viewport_width - x1), + (uint32_t)((int32_t)viewport_height - y0), + (uint32_t)((int32_t)viewport_width - x0) + ); + return true; +} + +void gfx_render(const uint32_t *words, size_t length) { + if (!initialized) return; + sync_resources(); + build(words, length); + if (command_count == 0) return; + + /* The arena is ordinary cached linear memory and the PICA reads main memory + * directly, so this frame's vertices have to be written back before any draw + * command can reference them. */ + GSPGPU_FlushDataCache(vertices, vertex_count * sizeof *vertices); + + C3D_BindProgram(&shader_program); + C3D_FVUnifMtx4x4(GPU_VERTEX_SHADER, projection_uniform, &projection); + C3D_DepthTest(false, GPU_ALWAYS, GPU_WRITE_COLOR); + C3D_CullFace(GPU_CULL_NONE); + C3D_AlphaBlend( + GPU_BLEND_ADD, + GPU_BLEND_ADD, + GPU_SRC_ALPHA, + GPU_ONE_MINUS_SRC_ALPHA, + GPU_SRC_ALPHA, + GPU_ONE_MINUS_SRC_ALPHA + ); + + /* One stage: sampled texel times the vertex colour, for RGB and alpha. The + * five stages behind it pass the result through untouched. */ + C3D_TexEnv *env = C3D_GetTexEnv(0); + C3D_TexEnvInit(env); + C3D_TexEnvSrc(env, C3D_Both, GPU_TEXTURE0, GPU_PRIMARY_COLOR, 0); + C3D_TexEnvFunc(env, C3D_Both, GPU_MODULATE); + for (int stage = 1; stage < 6; stage += 1) C3D_TexEnvInit(C3D_GetTexEnv(stage)); + + C3D_Tex *bound = NULL; + bool scissored = false; + for (uint32_t index = 0; index < command_count; index += 1) { + const Command *command = &commands[index]; + if (command->texture != bound) { + C3D_TexBind(0, command->texture); + bound = command->texture; + } + bool full = + command->clip.x <= 0 && command->clip.y <= 0 && + command->clip.x + command->clip.w >= (int32_t)viewport_width && + command->clip.y + command->clip.h >= (int32_t)viewport_height; + if (full) { + if (scissored) { + C3D_SetScissor(GPU_SCISSOR_DISABLE, 0, 0, 0, 0); + scissored = false; + } + } else { + if (!apply_clip(command->clip)) continue; + scissored = true; + } + C3D_DrawArrays(GPU_TRIANGLES, (int)command->first, (int)command->count); + } + if (scissored) C3D_SetScissor(GPU_SCISSOR_DISABLE, 0, 0, 0, 0); +} + +// --------------------------------------------------------------------------- +// lifecycle +// --------------------------------------------------------------------------- + +bool gfx_init(uint32_t logical_width, uint32_t logical_height) { + viewport_width = logical_width; + viewport_height = logical_height; + + shader_blob = DVLB_ParseFile((u32 *)vshader_shbin, vshader_shbin_size); + if (shader_blob == NULL) return false; + shaderProgramInit(&shader_program); + shaderProgramSetVsh(&shader_program, &shader_blob->DVLE[0]); + C3D_BindProgram(&shader_program); + projection_uniform = shaderInstanceGetUniformLocation(shader_program.vertexShader, "projection"); + if (projection_uniform < 0) return false; + Mtx_OrthoTilt( + &projection, + 0.0f, + (float)logical_width, + (float)logical_height, + 0.0f, + 0.0f, + 1.0f, + true + ); + + /* The DrawList is screen space, so the vertex carries no depth: the shader + * supplies a mid-range z from a constant rather than the buffer spending + * four bytes a vertex on the same number. */ + C3D_AttrInfo *attributes = C3D_GetAttrInfo(); + AttrInfo_Init(attributes); + AttrInfo_AddLoader(attributes, 0, GPU_FLOAT, 2); /* position */ + AttrInfo_AddLoader(attributes, 1, GPU_FLOAT, 2); /* texcoord */ + AttrInfo_AddLoader(attributes, 2, GPU_FLOAT, 4); /* colour */ + + vertices = linearAlloc((size_t)MAX_VERTICES * sizeof *vertices); + if (vertices == NULL) return false; + C3D_BufInfo *buffer = C3D_GetBufInfo(); + BufInfo_Init(buffer); + if (BufInfo_Add(buffer, vertices, sizeof *vertices, 3, 0x210) < 0) return false; + + uint8_t opaque[8 * 8 * 4]; + memset(opaque, 0xff, sizeof opaque); + PocketTexture solid = { + .pixels = opaque, + .pixels_length = sizeof opaque, + .palette = NULL, + .palette_length = 0, + .width = 8, + .height = 8, + .pixel_storage = PSM_8888, + .linear = 0, + .handle = -1, + .revision = 0, + }; + if (!upload_tiled(&white, &solid, NULL, 8, 8, 8, 8, false)) return false; + + font_capacity = ui_font_slot_count(); + fonts = calloc(font_capacity, sizeof *fonts); + if (fonts == NULL) return false; + + initialized = true; + return true; +} + +void gfx_shutdown(void) { + if (!initialized) return; + for (size_t slot = 0; slot < image_capacity; slot += 1) release_image(&images[slot]); + for (size_t slot = 0; slot < font_capacity; slot += 1) release_font(&fonts[slot]); + free(images); + free(fonts); + images = NULL; + fonts = NULL; + image_capacity = 0; + font_capacity = 0; + C3D_TexDelete(&white); + linearFree(vertices); + vertices = NULL; + shaderProgramFree(&shader_program); + DVLB_Free(shader_blob); + shader_blob = NULL; + initialized = false; +} + +uint32_t gfx_dropped_vertices(void) { + return dropped_vertices; +} diff --git a/hosts/3ds/src/gfx.h b/hosts/3ds/src/gfx.h new file mode 100644 index 00000000..dcedbb11 --- /dev/null +++ b/hosts/3ds/src/gfx.h @@ -0,0 +1,26 @@ +#ifndef POCKETJS_3DS_GFX_H +#define POCKETJS_3DS_GFX_H + +#include +#include +#include + +/* + * The PICA200 DrawList backend. gfx_init builds the shader, the attribute + * layout and the linear-memory vertex arena once; gfx_render walks one frame's + * word stream and issues the citro3d draws for it. + * + * gfx_render must run between C3D_FrameDrawOn/C3D_SetViewport and + * C3D_FrameEnd: it writes into the vertex arena that the previous frame's + * draws read, and C3D_FrameBegin(C3D_FRAME_SYNCDRAW) is what guarantees those + * are finished. + */ +bool gfx_init(uint32_t logical_width, uint32_t logical_height); +void gfx_render(const uint32_t *words, size_t length); +void gfx_shutdown(void); + +/* Vertices dropped because the frame overflowed the arena, cumulative. Zero + * on every frame a real app draws; non-zero means geometry is missing. */ +uint32_t gfx_dropped_vertices(void); + +#endif diff --git a/hosts/3ds/src/input.c b/hosts/3ds/src/input.c new file mode 100644 index 00000000..58bd64c4 --- /dev/null +++ b/hosts/3ds/src/input.c @@ -0,0 +1,77 @@ +/* + * 3DS keys and circle pad onto the frame contract's two arguments. + * + * The button bitmask is the PSP's (contracts/spec/spec.ts BTN) on every host, + * so the mapping here is positional: A/B/X/Y sit where CIRCLE/CROSS/TRIANGLE/ + * SQUARE sit, which keeps CIRCLE as confirm exactly as the PSP host and the + * launcher expect. + * + * The touchscreen is deliberately not read. It is the BOTTOM screen (320x240) + * while the UI renders on the top (400x240), so reporting its contacts as + * logical coordinates in the top screen's space would be a lie; the 3DS + * profile does not advertise input.touch. + */ + +#include "input.h" + +#include <3ds.h> +#include + +/* contracts/spec/spec.ts BTN. */ +#define BTN_SELECT 0x0001 +#define BTN_START 0x0008 +#define BTN_UP 0x0010 +#define BTN_RIGHT 0x0020 +#define BTN_DOWN 0x0040 +#define BTN_LEFT 0x0080 +#define BTN_LTRIGGER 0x0100 +#define BTN_RTRIGGER 0x0200 +#define BTN_TRIANGLE 0x1000 +#define BTN_CIRCLE 0x2000 +#define BTN_CROSS 0x4000 +#define BTN_SQUARE 0x8000 + +/* Full-deflection reading of the circle pad on both console revisions. */ +#define CIRCLE_PAD_RANGE 156 + +static const struct { + uint32_t key; + int32_t button; +} KEY_MAP[] = { + { KEY_A, BTN_CIRCLE }, + { KEY_B, BTN_CROSS }, + { KEY_X, BTN_TRIANGLE }, + { KEY_Y, BTN_SQUARE }, + { KEY_L, BTN_LTRIGGER }, + { KEY_R, BTN_RTRIGGER }, + { KEY_START, BTN_START }, + { KEY_SELECT, BTN_SELECT }, + { KEY_DUP, BTN_UP }, + { KEY_DDOWN, BTN_DOWN }, + { KEY_DLEFT, BTN_LEFT }, + { KEY_DRIGHT, BTN_RIGHT }, +}; + +int32_t input_buttons(void) { + uint32_t held = hidKeysHeld(); + int32_t buttons = 0; + for (size_t index = 0; index < sizeof KEY_MAP / sizeof KEY_MAP[0]; index += 1) { + if (held & KEY_MAP[index].key) buttons |= KEY_MAP[index].button; + } + return buttons; +} + +/* One axis to the PSP nub's 0..255 with 128 the centre. */ +static int32_t axis(int value) { + int scaled = 128 + (value * 127) / CIRCLE_PAD_RANGE; + if (scaled < 0) scaled = 0; + if (scaled > 255) scaled = 255; + return scaled; +} + +int32_t input_analog(void) { + circlePosition pad; + hidCircleRead(&pad); + /* The circle pad reads dy positive UP; the contract's Y is positive DOWN. */ + return (axis(pad.dx) << 8) | axis(-pad.dy); +} diff --git a/hosts/3ds/src/input.h b/hosts/3ds/src/input.h new file mode 100644 index 00000000..4d76f967 --- /dev/null +++ b/hosts/3ds/src/input.h @@ -0,0 +1,13 @@ +#ifndef POCKETJS_3DS_INPUT_H +#define POCKETJS_3DS_INPUT_H + +#include + +/* The frame contract's two arguments (contracts/spec/spec.ts). `buttons` is + * the PSP BTN bitmask, identical on every host; `analog` packs the left stick + * as (x << 8) | y with 128 the centre of each axis. Call hidScanInput() once + * per frame before either. */ +int32_t input_buttons(void); +int32_t input_analog(void); + +#endif diff --git a/hosts/3ds/src/main.c b/hosts/3ds/src/main.c new file mode 100644 index 00000000..73db93e0 --- /dev/null +++ b/hosts/3ds/src/main.c @@ -0,0 +1,351 @@ +/* + * PocketJS Nintendo 3DS host: libctru/citro3d boot, then fixed-rate virtual + * frames while QuickJS runs the guest, the Rust core ticks animations and + * layout, and the PICA200 backend draws the DrawList. + * + * Frame order (docs/DESIGN.md, the shape hosts/psp/src/main.rs drives): + * hidScanInput -> globalThis.frame(buttons, analog) -> drain jobs -> + * ui_tick (fixed 1/60) -> ui_draw -> C3D_FrameBegin/Clear/FrameDrawOn/ + * SetViewport -> gfx_render -> C3D_FrameEnd. + * + * The app owns the whole 400x240 top screen (form "takeover"). The render + * target is created ROTATED — 240 wide by 400 tall — and Mtx_OrthoTilt in + * gfx.c keeps the guest's coordinates landscape. + * + * Building with -DPOCKETJS_CAPTURE turns this into the deterministic e2e + * binary: input comes from a baked tape instead of the hardware, the listed + * frames are read back off the render target into sdmc:/fNNNN.raw, and the + * process parks instead of exiting so the emulator stays alive for the driver + * to kill. + */ + +#include <3ds.h> +#include +#include +#include +#include +#include +#include + +#include "gfx.h" +#include "input.h" +#include "pocket_core.h" +#include "qjs.h" + +/* The guest viewport comes from the resolved build plan, never a literal. */ +#ifndef POCKETJS_VIEW_W +#error "POCKETJS_VIEW_W must come from the verified ResolvedBuildPlan" +#endif +#ifndef POCKETJS_VIEW_H +#error "POCKETJS_VIEW_H must come from the verified ResolvedBuildPlan" +#endif +#ifndef POCKETJS_RASTER_DENSITY +#error "POCKETJS_RASTER_DENSITY must come from the verified ResolvedBuildPlan" +#endif +#define VIEW_W POCKETJS_VIEW_W +#define VIEW_H POCKETJS_VIEW_H +#define CAPTURE_BYTES ((size_t)VIEW_W * VIEW_H * 4) + +/* contracts/spec/spec.ts ANALOG_CENTER. */ +#define ANALOG_CENTER 0x8080 + +/* + * devkitPro's 3dsx crt0 gives the main thread 32 KiB of stack. QuickJS's + * interpreter recurses, and so does the Solid render pass it runs, so the + * default is far too small — a bundle that mounts fine everywhere else + * corrupts the stack here. libctru reads this symbol at startup. + */ +unsigned int __stacksize__ = 1024 * 1024; + +static C3D_RenderTarget *target; + +static const u32 DISPLAY_TRANSFER_FLAGS = + GX_TRANSFER_FLIP_VERT(0) | GX_TRANSFER_OUT_TILED(0) | GX_TRANSFER_RAW_COPY(0) | + GX_TRANSFER_IN_FORMAT(GX_TRANSFER_FMT_RGBA8) | + GX_TRANSFER_OUT_FORMAT(GX_TRANSFER_FMT_RGB8) | + GX_TRANSFER_SCALING(GX_TRANSFER_SCALE_NO); + +// --------------------------------------------------------------------------- +// romfs assets +// --------------------------------------------------------------------------- + +/* Read a whole romfs file, NUL-terminating it: JS_Eval requires + * `source[length] == '\0'`, and one extra byte costs nothing for the pak. */ +static uint8_t *read_file(const char *path, size_t *length) { + FILE *file = fopen(path, "rb"); + if (file == NULL) return NULL; + if (fseek(file, 0, SEEK_END) != 0) { + fclose(file); + return NULL; + } + 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; + } + size_t read = fread(bytes, 1, (size_t)size, file); + fclose(file); + if (read != (size_t)size) { + free(bytes); + return NULL; + } + bytes[size] = '\0'; + *length = (size_t)size; + return bytes; +} + +// --------------------------------------------------------------------------- +// capture build (tests/e2e/azahar.ts) +// --------------------------------------------------------------------------- + +#ifdef POCKETJS_CAPTURE + +#ifndef POCKETJS_CAPTURE_INPUT +#define POCKETJS_CAPTURE_INPUT "" +#endif +#ifndef POCKETJS_CAP_START +#define POCKETJS_CAP_START 0 +#endif +#ifndef POCKETJS_CAP_N +#define POCKETJS_CAP_N 1 +#endif + +/* Everything the driver reads lives in one directory, so a run's frames can + * never be confused with a previous run's (tests/e2e/azahar.ts). */ +#define CAPTURE_DIR "sdmc:/pocketjs-captures" + +static u32 *capture_buffer; +static const char CAPTURE_INPUT[] = POCKETJS_CAPTURE_INPUT; + +/* Read one unsigned value, decimal or 0x-prefixed hex, from [start, end). */ +static bool parse_uint(const char *text, size_t start, size_t end, uint32_t *out) { + while (start < end && (text[start] == ' ' || text[start] == '\t')) start += 1; + if (start >= end) return false; + bool hex = start + 1 < end && text[start] == '0' && + (text[start + 1] == 'x' || text[start + 1] == 'X'); + if (hex) start += 2; + uint32_t value = 0; + bool any = false; + for (; start < end; start += 1) { + char c = text[start]; + uint32_t digit; + if (c >= '0' && c <= '9') digit = (uint32_t)(c - '0'); + else if (hex && c >= 'a' && c <= 'f') digit = (uint32_t)(c - 'a' + 10); + else if (hex && c >= 'A' && c <= 'F') digit = (uint32_t)(c - 'A' + 10); + else if (c == ' ' || c == '\t') break; + else return false; + value = value * (hex ? 16u : 10u) + digit; + any = true; + } + if (!any) return false; + *out = value; + return true; +} + +static bool capture_wants(uint32_t frame) { + return frame - (uint32_t)POCKETJS_CAP_START < (uint32_t)POCKETJS_CAP_N; +} + +/* + * Baked scripted input, `frame:mask,frame:mask` with decimal or hex masks — + * the format hosts/psp/src/main.rs reads. The active mask is the last + * threshold at or before `frame`, so `0:0,20:0x40,24:0` means idle, press + * DOWN at frame 20, release at 24. Input is baked into the binary at build + * time and never read from the emulator's filesystem at runtime. + */ +static int32_t scripted_buttons(uint32_t frame) { + size_t length = sizeof CAPTURE_INPUT - 1; + size_t index = 0; + bool found = false; + uint32_t best_frame = 0; + uint32_t best_mask = 0; + while (index < length) { + while (index < length && (CAPTURE_INPUT[index] == ',' || CAPTURE_INPUT[index] == ';' || + CAPTURE_INPUT[index] == ' ')) { + index += 1; + } + size_t frame_start = index; + while (index < length && CAPTURE_INPUT[index] != ':' && CAPTURE_INPUT[index] != ',') { + index += 1; + } + if (index >= length || CAPTURE_INPUT[index] != ':') break; + size_t frame_end = index; + index += 1; + size_t mask_start = index; + while (index < length && CAPTURE_INPUT[index] != ',' && CAPTURE_INPUT[index] != ';') { + index += 1; + } + uint32_t at = 0; + uint32_t mask = 0; + if (parse_uint(CAPTURE_INPUT, frame_start, frame_end, &at) && + parse_uint(CAPTURE_INPUT, mask_start, index, &mask) && at <= frame && + (!found || at >= best_frame)) { + found = true; + best_frame = at; + best_mask = mask; + } + } + return (int32_t)best_mask; +} + +/* + * Read the render target back. + * + * NOT gfxGetFramebuffer after C3D_FrameEnd: that buffer has already been + * swapped and reads back black. An explicit display transfer untiles the + * PICA200 colour buffer into linear CPU-readable memory, and the result is + * byte-identical across runs and across Azahar's Software and Vulkan + * renderers. + * + * The bytes stay in the rotated screen orientation — 240 wide by 400 tall, + * column-major — and each RGBA8 word is byte order A, B, G, R. The e2e driver + * decodes with src[(x * 240 + (239 - y)) * 4] -> dst[y * 400 + x]. + */ +static bool capture_write(uint32_t frame) { + /* C3D_FrameEnd only queues the frame. The colour buffer is not finished + * until the GPU is, so wait before transferring it out. */ + gspWaitForVBlank(); + C3D_SyncDisplayTransfer( + (u32 *)target->frameBuf.colorBuf, + GX_BUFFER_DIM(VIEW_H, VIEW_W), + capture_buffer, + GX_BUFFER_DIM(VIEW_H, VIEW_W), + GX_TRANSFER_FLIP_VERT(0) | GX_TRANSFER_OUT_TILED(0) | GX_TRANSFER_RAW_COPY(0) | + GX_TRANSFER_IN_FORMAT(GX_TRANSFER_FMT_RGBA8) | + GX_TRANSFER_OUT_FORMAT(GX_TRANSFER_FMT_RGBA8) | + GX_TRANSFER_SCALING(GX_TRANSFER_SCALE_NO) + ); + GSPGPU_InvalidateDataCache(capture_buffer, (s32)CAPTURE_BYTES); + + /* Named by the process-global frame counter, which is also what indexes the + * baked input tape: input at frame N and file fN are the same frame. */ + char path[64]; + snprintf(path, sizeof path, CAPTURE_DIR "/f%04lu.raw", (unsigned long)frame); + FILE *file = fopen(path, "wb"); + if (file == NULL) return false; + size_t written = fwrite(capture_buffer, 1, CAPTURE_BYTES, file); + return fclose(file) == 0 && written == CAPTURE_BYTES; +} + +/* The sentinel the driver waits for. Written only after every requested frame + * has been written AND closed, so a partial file can never be compared. */ +static void capture_done(void) { + FILE *file = fopen(CAPTURE_DIR "/done", "wb"); + if (file == NULL) return; + fputs("ok\n", file); + fclose(file); +} + +#endif /* POCKETJS_CAPTURE */ + +/* Report a boot or runtime failure to the driver as itself rather than as a + * timeout, then park: Azahar does not stop when the app returns from main. */ +static void fail(const char *message) { +#ifdef POCKETJS_CAPTURE + mkdir(CAPTURE_DIR, 0777); + FILE *file = fopen(CAPTURE_DIR "/error.txt", "wb"); +#else + FILE *file = fopen("sdmc:/pocketjs-error.txt", "wb"); +#endif + if (file != NULL) { + fputs(message == NULL || message[0] == '\0' ? "unknown failure" : message, file); + fputs("\n", file); + fclose(file); + } + for (;;) gspWaitForVBlank(); +} + +// --------------------------------------------------------------------------- +// boot +// --------------------------------------------------------------------------- + +int main(void) { + gfxInitDefault(); + /* No-op on an Old 3DS; on a New 3DS it unlocks the faster clock and the + * extra cache, which the QuickJS guest feels directly. */ + osSetSpeedupEnable(true); + C3D_Init(C3D_DEFAULT_CMDBUF_SIZE); + + target = C3D_RenderTargetCreate(VIEW_H, VIEW_W, GPU_RB_RGBA8, GPU_RB_DEPTH24_STENCIL8); + if (target == NULL) fail("C3D_RenderTargetCreate failed"); + C3D_RenderTargetSetOutput(target, GFX_TOP, GFX_LEFT, DISPLAY_TRANSFER_FLAGS); + + if (R_FAILED(romfsInit())) fail("romfsInit failed: the .3dsx has no romfs"); + + size_t source_length = 0; + uint8_t *source = read_file("romfs:/app.js", &source_length); + if (source == NULL) fail("romfs:/app.js is missing or unreadable"); + size_t pack_length = 0; + uint8_t *pack = read_file("romfs:/app.pak", &pack_length); + + /* The core is fed from the pak natively, before any JS runs: styles.bin, + * font atlases and images never transit the QuickJS heap. */ + ui_init(POCKETJS_RASTER_DENSITY); + ui_set_viewport((float)VIEW_W, (float)VIEW_H); + if (pack != NULL) ui_feed_pak(pack, pack_length); + + if (!gfx_init(VIEW_W, VIEW_H)) fail("PICA200 backend failed to initialize"); + if (!qjs_boot((const char *)source, source_length, pack, pack_length)) fail(qjs_last_error()); + +#ifdef POCKETJS_CAPTURE + mkdir(CAPTURE_DIR, 0777); + capture_buffer = linearAlloc(CAPTURE_BYTES); + if (capture_buffer == NULL) fail("capture buffer allocation failed"); + uint32_t frame = 0; +#endif + + while (aptMainLoop()) { + hidScanInput(); +#ifdef POCKETJS_CAPTURE + /* The tape has no analog track: pin the stick to centre so scripted runs + * stay deterministic. */ + int32_t buttons = scripted_buttons(frame); + int32_t analog = ANALOG_CENTER; +#else + int32_t buttons = input_buttons(); + int32_t analog = input_analog(); +#endif + + if (!qjs_frame(buttons, analog)) fail(qjs_last_error()); + /* Animations always advance at the fixed 1/60 timestep; this host + * presents at the same rate, so it is one tick per frame. */ + ui_tick(); + size_t words = ui_draw(); + + C3D_FrameBegin(C3D_FRAME_SYNCDRAW); + C3D_RenderTargetClear(target, C3D_CLEAR_ALL, 0x000000ff, 0); + C3D_FrameDrawOn(target); + /* C3D_FrameDrawOn resets the viewport, so this comes after it. */ + C3D_SetViewport(0, 0, VIEW_H, VIEW_W); + gfx_render(ui_draw_list_ptr(), words); + C3D_FrameEnd(0); + +#ifdef POCKETJS_CAPTURE + if (capture_wants(frame)) { + /* A frame that overflowed the vertex arena is missing geometry, which + * must never become a golden. */ + if (gfx_dropped_vertices() > 0) fail("vertex arena overflowed during capture"); + if (!capture_write(frame)) fail("capture write failed"); + if (frame + 1 >= (uint32_t)POCKETJS_CAP_START + POCKETJS_CAP_N) { + capture_done(); + /* Park: Azahar does not stop when the app returns from main, and a + * still process is what the driver kills. */ + for (;;) gspWaitForVBlank(); + } + } + frame += 1; +#endif + } + + qjs_shutdown(); + gfx_shutdown(); + ui_shutdown(); + C3D_Fini(); + gfxExit(); + return 0; +} diff --git a/hosts/3ds/src/qjs.c b/hosts/3ds/src/qjs.c new file mode 100644 index 00000000..e4953e11 --- /dev/null +++ b/hosts/3ds/src/qjs.c @@ -0,0 +1,628 @@ +/* + * QuickJS bindings: the `globalThis.ui` namespace — the 3DS side of the + * HostOps contract (contracts/spec/spec.ts OP table; JS caller in + * framework/src/host.ts). + * + * hosts/psp/src/ffi.rs is the reference for op semantics, argument + * marshalling and return values: a missing argument reads as 0 rather than + * throwing, because native hosts are the NON-strict kind (framework/src/host.ts) + * and a crash on hardware is worse than a missing style. + * + * One core instance, one JS thread. All ops are synchronous; the JS renderer + * keeps a mirror tree so reconciler reads never cross this boundary. + * + * Extra (not spec ops): `ui.__textures` and `ui.__sprites`, the pak image and + * sprite-atlas name tables built by ui_feed_pak before any JS runs + * (framework/src/index.ts walks them so JSX `src=""` resolves), and + * `ui.__viewport`, the logical UI size the same file sizes the mounted app and + * overlay layers from. + */ + +#include "qjs.h" + +#include +#include + +#include "pocket_core.h" +#include "quickjs.h" + +#ifndef POCKETJS_TARGET_ID +#error "POCKETJS_TARGET_ID must come from the verified ResolvedBuildPlan" +#endif +#ifndef POCKETJS_HOST_ABI +#error "POCKETJS_HOST_ABI must come from the verified ResolvedBuildPlan" +#endif + +/* contracts/spec/spec.ts FIXED_DT: the core steps at exactly 1/60 s, and this + * host presents at the same rate, so the advertised simulation rate is 60. */ +#define POCKETJS_SIMULATION_HZ 60 +/* QuickJS recurses; the 3DS main thread's stack is set in main.c. */ +#define POCKETJS_JS_STACK_SIZE (192 * 1024) + +typedef enum { + HostCreateNode, + HostDestroyNode, + HostInsertBefore, + HostRemoveChild, + HostSetStyle, + HostSetProp, + HostSetPropBatch, + HostSetText, + HostReplaceText, + HostUploadTexture, + HostSetImage, + HostSetSprite, + HostAnimate, + HostCancelAnim, + HostSetFocus, + HostSetActive, + HostHitTest, + HostHitTestBounds, + HostSetCursor, + HostSetCursorPos, + HostLoadStyles, + HostLoadFontAtlas, + HostMeasureText, + HostLoadTileTexture, + HostFreeTexture, + HostUploadImgEntry, + HostDebugInspect, + HostDebugRectXY, + HostDebugRectWH, + HostDebugPause, + HostDebugStep, +} HostOperation; + +static JSRuntime *runtime; +static JSContext *context; +static JSValue global; +static JSValue frame_function; +static const uint8_t *installed_pack; +static size_t installed_pack_length; +static char last_error[512]; + +static void set_error(const char *message) { + size_t length = message == NULL ? 0 : strlen(message); + if (length >= sizeof last_error) length = sizeof last_error - 1; + if (length > 0) memcpy(last_error, message, length); + last_error[length] = '\0'; +} + +/* Take the pending exception as the reported error. The message is what the + * capture path writes to error.txt, so a JS throw surfaces as itself instead + * of as a timeout. */ +static void take_exception(void) { + JSValue exception = JS_GetException(context); + size_t length = 0; + const char *message = JS_ToCStringLen2(context, &length, exception, 0); + if (message != NULL) { + size_t copy = length < sizeof last_error - 1 ? length : sizeof last_error - 1; + memcpy(last_error, message, copy); + last_error[copy] = '\0'; + JS_FreeCString(context, message); + } else { + set_error("QuickJS exception"); + } + JS_FreeValue(context, exception); +} + +// --------------------------------------------------------------------------- +// argument helpers +// --------------------------------------------------------------------------- + +static int32_t argument_int(JSContext *ctx, int argc, JSValueConst *argv, int index) { + int32_t value = 0; + if (index < argc) JS_ToInt32(ctx, &value, argv[index]); + return value; +} + +static double argument_float(JSContext *ctx, int argc, JSValueConst *argv, int index) { + double value = 0.0; + if (index < argc) JS_ToFloat64(ctx, &value, argv[index]); + return value; +} + +/* + * Borrow the bytes behind an ArrayBuffer OR a typed-array view (host.ts passes + * Uint8Arrays). The pointer is only valid until the next JS allocation, so + * callers consume it before returning to JS. + */ +static int argument_bytes( + JSContext *ctx, + int argc, + JSValueConst *argv, + int index, + const uint8_t **bytes, + size_t *length +) { + if (index >= argc) return 0; + size_t direct_length = 0; + uint8_t *direct = JS_GetArrayBuffer(ctx, &direct_length, argv[index]); + if (direct != NULL) { + *bytes = direct; + *length = direct_length; + return 1; + } + /* Not an ArrayBuffer: clear the pending TypeError and try the view's + * `buffer` + `byteOffset`/`byteLength`. */ + JS_FreeValue(ctx, JS_GetException(ctx)); + JSValue buffer = JS_GetPropertyStr(ctx, argv[index], "buffer"); + size_t base_length = 0; + uint8_t *base = JS_GetArrayBuffer(ctx, &base_length, buffer); + JS_FreeValue(ctx, buffer); + if (base == NULL) { + JS_FreeValue(ctx, JS_GetException(ctx)); + return 0; + } + JSValue offset_value = JS_GetPropertyStr(ctx, argv[index], "byteOffset"); + int32_t offset = 0; + JS_ToInt32(ctx, &offset, offset_value); + JS_FreeValue(ctx, offset_value); + JSValue length_value = JS_GetPropertyStr(ctx, argv[index], "byteLength"); + int32_t view_length = 0; + JS_ToInt32(ctx, &view_length, length_value); + JS_FreeValue(ctx, length_value); + if (offset < 0 || view_length < 0 || (size_t)offset + (size_t)view_length > base_length) { + return 0; + } + *bytes = base + offset; + *length = (size_t)view_length; + return 1; +} + +// --------------------------------------------------------------------------- +// ops +// --------------------------------------------------------------------------- + +static JSValue host_operation( + JSContext *ctx, + JSValueConst this_value, + int argc, + JSValueConst *argv, + int magic +) { + (void)this_value; + const uint8_t *bytes = NULL; + size_t byte_length = 0; + const char *text = NULL; + size_t text_length = 0; + + switch ((HostOperation)magic) { + case HostCreateNode: + return JS_NewInt32(ctx, ui_create_node((uint32_t)argument_int(ctx, argc, argv, 0))); + case HostDestroyNode: + ui_destroy_node(argument_int(ctx, argc, argv, 0)); + return JS_UNDEFINED; + case HostInsertBefore: + ui_insert_before( + argument_int(ctx, argc, argv, 0), + argument_int(ctx, argc, argv, 1), + argument_int(ctx, argc, argv, 2) + ); + return JS_UNDEFINED; + case HostRemoveChild: + ui_remove_child(argument_int(ctx, argc, argv, 0), argument_int(ctx, argc, argv, 1)); + return JS_UNDEFINED; + case HostSetStyle: + ui_set_style(argument_int(ctx, argc, argv, 0), argument_int(ctx, argc, argv, 1)); + return JS_UNDEFINED; + case HostSetProp: + ui_set_prop( + argument_int(ctx, argc, argv, 0), + (uint32_t)argument_int(ctx, argc, argv, 1), + argument_float(ctx, argc, argv, 2) + ); + return JS_UNDEFINED; + case HostSetPropBatch: + if (argument_bytes(ctx, argc, argv, 0, &bytes, &byte_length)) { + ui_set_prop_batch(bytes, byte_length); + } + return JS_UNDEFINED; + case HostSetText: + case HostReplaceText: { + if (argc < 2) return JS_UNDEFINED; + int32_t id = argument_int(ctx, argc, argv, 0); + text = JS_ToCStringLen2(ctx, &text_length, argv[1], 0); + if (text == NULL) return JS_UNDEFINED; + if (magic == HostSetText) ui_set_text(id, (const uint8_t *)text, text_length); + else ui_replace_text(id, (const uint8_t *)text, text_length); + JS_FreeCString(ctx, text); + return JS_UNDEFINED; + } + case HostUploadTexture: + if (argc < 4 || !argument_bytes(ctx, argc, argv, 0, &bytes, &byte_length)) { + return JS_NewInt32(ctx, -1); + } + return JS_NewInt32( + ctx, + ui_upload_texture( + bytes, + byte_length, + (uint32_t)argument_int(ctx, argc, argv, 1), + (uint32_t)argument_int(ctx, argc, argv, 2), + (uint32_t)argument_int(ctx, argc, argv, 3) + ) + ); + case HostSetImage: + ui_set_image(argument_int(ctx, argc, argv, 0), argument_int(ctx, argc, argv, 1)); + return JS_UNDEFINED; + case HostSetSprite: + ui_set_sprite( + argument_int(ctx, argc, argv, 0), + argument_int(ctx, argc, argv, 1), + (uint32_t)argument_int(ctx, argc, argv, 2), + (uint32_t)argument_int(ctx, argc, argv, 3), + (uint32_t)argument_int(ctx, argc, argv, 4) + ); + return JS_UNDEFINED; + case HostAnimate: { + int32_t duration = argument_int(ctx, argc, argv, 3); + int32_t delay = argument_int(ctx, argc, argv, 5); + return JS_NewInt32( + ctx, + ui_animate( + argument_int(ctx, argc, argv, 0), + (uint32_t)argument_int(ctx, argc, argv, 1), + argument_float(ctx, argc, argv, 2), + (uint32_t)(duration < 0 ? 0 : duration), + (uint32_t)argument_int(ctx, argc, argv, 4), + (uint32_t)(delay < 0 ? 0 : delay) + ) + ); + } + case HostCancelAnim: + ui_cancel_anim(argument_int(ctx, argc, argv, 0)); + return JS_UNDEFINED; + case HostSetFocus: + ui_set_focus(argument_int(ctx, argc, argv, 0)); + return JS_UNDEFINED; + case HostSetActive: + ui_set_active(argument_int(ctx, argc, argv, 0), argument_int(ctx, argc, argv, 1)); + return JS_UNDEFINED; + case HostHitTest: + return JS_NewInt32( + ctx, + ui_hit_test( + (float)argument_float(ctx, argc, argv, 0), + (float)argument_float(ctx, argc, argv, 1) + ) + ); + case HostHitTestBounds: + return JS_NewInt32( + ctx, + ui_hit_test_bounds( + (float)argument_float(ctx, argc, argv, 0), + (float)argument_float(ctx, argc, argv, 1) + ) + ); + case HostSetCursor: + ui_set_cursor( + argument_int(ctx, argc, argv, 0), + (float)argument_float(ctx, argc, argv, 1), + (float)argument_float(ctx, argc, argv, 2), + (float)argument_float(ctx, argc, argv, 3), + (float)argument_float(ctx, argc, argv, 4) + ); + return JS_UNDEFINED; + case HostSetCursorPos: + ui_set_cursor_pos( + (float)argument_float(ctx, argc, argv, 0), + (float)argument_float(ctx, argc, argv, 1) + ); + return JS_UNDEFINED; + case HostLoadStyles: + case HostLoadFontAtlas: + if (!argument_bytes(ctx, argc, argv, 0, &bytes, &byte_length)) { + return JS_NewBool(ctx, 0); + } + return JS_NewBool( + ctx, + magic == HostLoadStyles + ? ui_load_styles(bytes, byte_length) + : ui_load_font_atlas(bytes, byte_length) + ); + case HostMeasureText: { + if (argc < 1) return JS_NewFloat64(ctx, 0.0); + text = JS_ToCStringLen2(ctx, &text_length, argv[0], 0); + if (text == NULL) return JS_NewFloat64(ctx, 0.0); + float width = ui_measure_text( + (const uint8_t *)text, + text_length, + (uint32_t)argument_int(ctx, argc, argv, 1) + ); + JS_FreeCString(ctx, text); + return JS_NewFloat64(ctx, (double)width); + } + case HostLoadTileTexture: { + /* spec op 23: decode ONE tile of a TILESET pak entry, looked up by key + * in the installed pak (ui_feed_pak skips `ui:tile.*` — tiles stream on + * demand). Missing pak, missing key and malformed entries are all -1. */ + if (argc < 2) return JS_NewInt32(ctx, -1); + text = JS_ToCStringLen2(ctx, &text_length, argv[0], 0); + if (text == NULL) return JS_NewInt32(ctx, -1); + const uint8_t *blob = NULL; + size_t blob_length = ui_pak_find( + installed_pack, + installed_pack_length, + (const uint8_t *)text, + text_length, + &blob + ); + JS_FreeCString(ctx, text); + if (blob_length == 0) return JS_NewInt32(ctx, -1); + return JS_NewInt32( + ctx, + ui_upload_tileset_tile(blob, blob_length, (uint32_t)argument_int(ctx, argc, argv, 1)) + ); + } + case HostFreeTexture: + ui_free_texture(argument_int(ctx, argc, argv, 0)); + return JS_UNDEFINED; + case HostUploadImgEntry: + if (!argument_bytes(ctx, argc, argv, 0, &bytes, &byte_length)) { + return JS_NewInt32(ctx, -1); + } + return JS_NewInt32(ctx, ui_upload_img_entry(bytes, byte_length)); + case HostDebugInspect: + ui_debug_inspect(argument_int(ctx, argc, argv, 0)); + return JS_UNDEFINED; + case HostDebugRectXY: + return JS_NewInt32(ctx, ui_debug_rect_xy()); + case HostDebugRectWH: + return JS_NewInt32(ctx, ui_debug_rect_wh()); + case HostDebugPause: + ui_debug_pause(argument_int(ctx, argc, argv, 0)); + return JS_UNDEFINED; + case HostDebugStep: + ui_debug_step(); + return JS_UNDEFINED; + } + return JS_UNDEFINED; +} + +static void add_operation( + JSValueConst object, + const char *name, + int arity, + HostOperation operation +) { + JS_SetPropertyStr( + context, + object, + name, + JS_NewCFunctionMagic( + context, + host_operation, + name, + arity, + JS_CFUNC_generic_magic, + (int)operation + ) + ); +} + +/* Pak names are borrowed length-delimited slices; JS_SetPropertyStr needs a + * NUL-terminated key. */ +static void set_named_property(JSValueConst object, const uint8_t *name, size_t length, JSValue value) { + char *key = malloc(length + 1); + if (key == NULL) { + JS_FreeValue(context, value); + return; + } + memcpy(key, name, length); + key[length] = '\0'; + JS_SetPropertyStr(context, object, key, value); + free(key); +} + +static void install_host(void) { + JSValue ui = JS_NewObject(context); + + add_operation(ui, "createNode", 1, HostCreateNode); + add_operation(ui, "destroyNode", 1, HostDestroyNode); + add_operation(ui, "insertBefore", 3, HostInsertBefore); + add_operation(ui, "removeChild", 2, HostRemoveChild); + add_operation(ui, "setStyle", 2, HostSetStyle); + add_operation(ui, "setProp", 3, HostSetProp); + add_operation(ui, "setPropBatch", 1, HostSetPropBatch); + add_operation(ui, "setText", 2, HostSetText); + add_operation(ui, "replaceText", 2, HostReplaceText); + add_operation(ui, "uploadTexture", 4, HostUploadTexture); + add_operation(ui, "setImage", 2, HostSetImage); + add_operation(ui, "setSprite", 5, HostSetSprite); + add_operation(ui, "animate", 6, HostAnimate); + add_operation(ui, "cancelAnim", 1, HostCancelAnim); + add_operation(ui, "setFocus", 1, HostSetFocus); + add_operation(ui, "setActive", 2, HostSetActive); + /* Virtual cursor ops (spec ops 27..29, 42; input.cursor). */ + add_operation(ui, "hitTest", 2, HostHitTest); + add_operation(ui, "hitTestBounds", 2, HostHitTestBounds); + add_operation(ui, "setCursor", 5, HostSetCursor); + add_operation(ui, "setCursorPos", 2, HostSetCursorPos); + add_operation(ui, "loadStyles", 1, HostLoadStyles); + add_operation(ui, "loadFontAtlas", 1, HostLoadFontAtlas); + add_operation(ui, "measureText", 2, HostMeasureText); + /* Texture streaming ops (spec ops 23..25: deep-zoom tiles + dynamic IMGs). */ + add_operation(ui, "loadTileTexture", 2, HostLoadTileTexture); + add_operation(ui, "freeTexture", 1, HostFreeTexture); + add_operation(ui, "uploadImgEntry", 1, HostUploadImgEntry); + /* DevTools ops (docs/DEVTOOLS.md; debug-only, default-off). */ + add_operation(ui, "debugInspect", 1, HostDebugInspect); + add_operation(ui, "debugRectXY", 0, HostDebugRectXY); + add_operation(ui, "debugRectWH", 0, HostDebugRectWH); + add_operation(ui, "debugPause", 1, HostDebugPause); + add_operation(ui, "debugStep", 0, HostDebugStep); + + /* Framework-owned host identity, from the build's -D defines rather than + * literals that can drift. Bundles refuse to mount when they disagree. */ + JS_SetPropertyStr(context, ui, "__host", JS_NewString(context, POCKETJS_TARGET_ID)); + JS_SetPropertyStr(context, ui, "__hostAbi", JS_NewInt32(context, POCKETJS_HOST_ABI)); + + /* + * The logical UI size. framework/src/index.ts sizes the mounted app and + * overlay layers from this and falls back to the 480x272 spec screen when a + * host omits it, which lays a 400x240 app out 80 px too wide and carries + * every right-anchored element off the panel. Read back from the core — + * main.c calls ui_set_viewport before qjs_boot — so the JS layers and the + * native root cannot drift apart. A size, not a live-resize capability: + * that needs installResizeViewportHook, which a takeover host never calls. + */ + JSValue viewport = JS_NewObject(context); + JS_SetPropertyStr(context, viewport, "w", JS_NewInt32(context, (int32_t)ui_viewport_width())); + JS_SetPropertyStr(context, viewport, "h", JS_NewInt32(context, (int32_t)ui_viewport_height())); + JS_SetPropertyStr(context, ui, "__viewport", viewport); + + JSValue textures = JS_NewObject(context); + for (size_t index = 0; index < ui_pak_texture_count(); index += 1) { + set_named_property( + textures, + ui_pak_texture_name(index), + ui_pak_texture_name_len(index), + JS_NewInt32(context, ui_pak_texture_handle(index)) + ); + } + JS_SetPropertyStr(context, ui, "__textures", textures); + + JSValue sprites = JS_NewObject(context); + for (size_t index = 0; index < ui_pak_sprite_count(); index += 1) { + JSValue meta = JS_NewObject(context); + JS_SetPropertyStr(context, meta, "handle", JS_NewInt32(context, ui_pak_sprite_handle(index))); + JS_SetPropertyStr( + context, + meta, + "frames", + JS_NewInt32(context, (int32_t)ui_pak_sprite_frames(index)) + ); + JS_SetPropertyStr( + context, + meta, + "cols", + JS_NewInt32(context, (int32_t)ui_pak_sprite_columns(index)) + ); + JS_SetPropertyStr( + context, + meta, + "step", + JS_NewInt32(context, (int32_t)ui_pak_sprite_step(index)) + ); + set_named_property(sprites, ui_pak_sprite_name(index), ui_pak_sprite_name_len(index), meta); + } + JS_SetPropertyStr(context, ui, "__sprites", sprites); + + /* JS_SetPropertyStr consumes ownership of `ui`. */ + JS_SetPropertyStr(context, global, "ui", ui); +} + +static bool drain_jobs(void) { + for (;;) { + JSContext *pending = NULL; + int result = JS_ExecutePendingJob(runtime, &pending); + if (result > 0) continue; + if (result < 0) { + take_exception(); + return false; + } + return true; + } +} + +// --------------------------------------------------------------------------- +// lifecycle +// --------------------------------------------------------------------------- + +bool qjs_boot( + const char *source, + size_t source_length, + const uint8_t *pack, + size_t pack_length +) { + last_error[0] = '\0'; + installed_pack = pack; + installed_pack_length = pack_length; + frame_function = JS_UNDEFINED; + global = JS_UNDEFINED; + + runtime = JS_NewRuntime(); + if (runtime == NULL) { + set_error("JS_NewRuntime returned null"); + return false; + } + JS_SetMaxStackSize(runtime, POCKETJS_JS_STACK_SIZE); + context = JS_NewContext(runtime); + if (context == NULL) { + set_error("JS_NewContext returned null"); + return false; + } + global = JS_GetGlobalObject(context); + install_host(); + + /* The pak is exposed read-only and zero-copy (free_func 0): it lives in the + * host's own allocation for the process lifetime. The core was already fed + * from it natively, so this is only for the framework paths that read pak + * entries themselves (framework/src/tiles.ts). */ + if (pack != NULL && pack_length > 0) { + JS_SetPropertyStr( + context, + global, + "__pak", + JS_NewArrayBuffer(context, (uint8_t *)pack, pack_length, NULL, NULL, 0) + ); + } + /* The bundle mounts synchronously during JS_Eval and resetClock() latches + * this at mount, so install it beforehand. */ + JS_SetPropertyStr(context, global, "__simHz", JS_NewInt32(context, POCKETJS_SIMULATION_HZ)); + + JSValue result = JS_Eval(context, source, source_length, "app.js", JS_EVAL_TYPE_GLOBAL); + if (JS_IsException(result)) { + take_exception(); + return false; + } + JS_FreeValue(context, result); + + frame_function = JS_GetPropertyStr(context, global, "frame"); + if (!JS_IsFunction(context, frame_function)) { + set_error("app.js did not install globalThis.frame"); + return false; + } + return drain_jobs(); +} + +bool qjs_frame(int32_t buttons, int32_t analog) { + if (context == NULL) return false; + JSValue arguments[2] = { + JS_NewInt32(context, buttons), + JS_NewInt32(context, analog), + }; + JSValue result = JS_Call(context, frame_function, global, 2, arguments); + JS_FreeValue(context, arguments[0]); + JS_FreeValue(context, arguments[1]); + if (JS_IsException(result)) { + take_exception(); + JS_FreeValue(context, result); + return false; + } + /* Leak guard: the return value is freed every frame. */ + JS_FreeValue(context, result); + return drain_jobs(); +} + +const char *qjs_last_error(void) { + return last_error; +} + +void qjs_shutdown(void) { + if (context != NULL) { + JS_FreeValue(context, frame_function); + JS_FreeValue(context, global); + JS_FreeContext(context); + context = NULL; + } + if (runtime != NULL) { + JS_FreeRuntime(runtime); + runtime = NULL; + } + frame_function = JS_UNDEFINED; + global = JS_UNDEFINED; + installed_pack = NULL; + installed_pack_length = 0; +} diff --git a/hosts/3ds/src/qjs.h b/hosts/3ds/src/qjs.h new file mode 100644 index 00000000..346e82e9 --- /dev/null +++ b/hosts/3ds/src/qjs.h @@ -0,0 +1,28 @@ +#ifndef POCKETJS_3DS_QJS_H +#define POCKETJS_3DS_QJS_H + +#include +#include +#include + +/* + * QuickJS embedding: `globalThis.ui` over the pocketjs-3ds-core C ABI, plus + * the frame contract. + * + * qjs_boot evaluates the app bundle and looks up `globalThis.frame`; the pack + * is borrowed for the process lifetime (it is exposed to JS zero-copy as + * `globalThis.__pak`). qjs_frame calls `frame(buttons, analog)` once and + * drains QuickJS's pending job queue. Both return false and leave a message + * in qjs_last_error on failure. + */ +bool qjs_boot( + const char *source, + size_t source_length, + const uint8_t *pack, + size_t pack_length +); +bool qjs_frame(int32_t buttons, int32_t analog); +const char *qjs_last_error(void); +void qjs_shutdown(void); + +#endif diff --git a/hosts/3ds/src/vshader.v.pica b/hosts/3ds/src/vshader.v.pica new file mode 100644 index 00000000..0340b5d2 --- /dev/null +++ b/hosts/3ds/src/vshader.v.pica @@ -0,0 +1,39 @@ +; PocketJS 3DS vertex shader: the DrawList's logical pixel coordinates through +; the ortho-tilt projection, with UV and colour passed to the TEV stages. +; +; The PICA200 has no fragment shader, so `outclr` is the primary colour that +; TEV stage 0 modulates the sampled texel by (see gfx.c). Untextured ops bind +; an 8x8 white texture, which makes that one stage cover every op. +; +; picasso freezes the GPU on two consecutive `mova`, and one instruction may +; reference only a single input register across its source operands — hence +; the plain per-attribute moves rather than anything fused. + +.constf myconst(0.0, 1.0, -1.0, 0.5) +.alias zeros myconst.xxxx +.alias ones myconst.yyyy +.alias half myconst.wwww + +.fvec projection[4] + +.in inpos v0 +.in intex v1 +.in inclr v2 + +.out outpos position +.out outtex texcoord0 +.out outclr color + +.entry vmain +.proc vmain + mov r0.xy, inpos + mov r0.z, half + mov r0.w, ones + dp4 outpos.x, projection[0], r0 + dp4 outpos.y, projection[1], r0 + dp4 outpos.z, projection[2], r0 + dp4 outpos.w, projection[3], r0 + mov outtex.xy, intex.xy + mov outclr, inclr + end +.end diff --git a/package.json b/package.json index de52aa40..1fe8042e 100644 --- a/package.json +++ b/package.json @@ -143,9 +143,11 @@ "vita": "bun tools/vita.ts", "symbian": "bun tools/symbian.ts", "iphone2g": "bun tools/iphone2g.ts", + "3ds": "bun tools/3ds.ts", "vita:art": "bun tools/generate-vita-livearea.ts", "vita:art:check": "bun tools/generate-vita-livearea.ts --check", "e2e:vita": "bun tests/e2e/vita3k.ts", + "e2e:3ds": "bun tests/e2e/azahar.ts", "hw": "bun tools/hw.ts", "dev": "bun tools/dev.ts", "wasm": "bun tools/wasm.ts", diff --git a/site/content/docs/overview.md b/site/content/docs/overview.md index 4914fe27..b160617f 100644 --- a/site/content/docs/overview.md +++ b/site/content/docs/overview.md @@ -244,7 +244,7 @@ PocketJS 0.4 is deliberately scoped. It does **not** yet include: - Render-to-texture opacity groups (per-vertex alpha is used instead — wrong on overlap, fine for demos) - Kerning -- 3DS / Android hosts +- Android hosts These are omissions, not silent failures: unsupported class tokens and disallowed patterns surface as loud compile-time or dev errors. See the full diff --git a/tests/3ds-profile.test.ts b/tests/3ds-profile.test.ts new file mode 100644 index 00000000..288c547b --- /dev/null +++ b/tests/3ds-profile.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { POCKET_TARGETS } from "../contracts/spec/platforms.ts"; +import { POCKET_PACKAGE_TARGET_BYTES } from "../contracts/spec/pocket-package.ts"; +import { verifyPlanHash } from "../framework/src/manifest/plan.ts"; +import { + validateAndResolveBuildPlan, + validatePlatformContractRegistry, +} from "../framework/src/manifest/resolve.ts"; +import { + resolve3dsBuildPlan, + THREE_DS_DEV_CONTRACTS, + THREE_DS_DEV_HOST_ABI, + THREE_DS_DEV_TARGET_ID, + THREE_DS_VIEWPORT, +} from "../tools/3ds-profile.ts"; + +/** A guest app declaring the top screen exactly: 400x240 logical, native. */ +function topScreenManifest(): Record { + return { + $schema: "https://pocketjs.dev/schema/pocket-2.json", + pocket: 2, + id: "dev.pocket-stack.3ds-demo", + name: "pocketjs-3ds-hero", + title: "PocketJS: 3DS Hero", + version: "0.1.0", + engine: { + capabilities: { + requires: ["input.buttons", "text.glyphs.baked"], + enhances: ["input.analog.left", "audio.pcm"], + }, + }, + app: { + entry: "apps/3ds-demo/main.tsx", + output: "pocket3ds-demo-main", + framework: "solid", + viewport: { + fixed: { logical: [400, 240], presentation: "native" }, + }, + }, + }; +} + +function diagnosticCodes(manifest: unknown): string[] { + const resolution = validateAndResolveBuildPlan( + manifest, + { target: THREE_DS_DEV_TARGET_ID }, + THREE_DS_DEV_CONTRACTS, + ); + expect(resolution.ok).toBe(false); + return resolution.ok ? [] : resolution.diagnostics.map((d) => d.code); +} + +describe("private Nintendo 3DS build profile", () => { + test("stays private and describes the top screen", () => { + expect(POCKET_TARGETS).not.toHaveProperty(THREE_DS_DEV_TARGET_ID); + expect(THREE_DS_DEV_CONTRACTS.targets[THREE_DS_DEV_TARGET_ID]).toEqual({ + hostAbi: THREE_DS_DEV_HOST_ABI, + platform: "3ds", + form: "takeover", + display: { + physicalViewport: THREE_DS_VIEWPORT, + logicalViewports: [THREE_DS_VIEWPORT], + presentations: ["native"], + rasterDensity: 1, + }, + capabilities: [ + "input.analog.left", + "input.buttons", + "input.cursor", + "text.glyphs.baked", + ], + }); + expect(validatePlatformContractRegistry(THREE_DS_DEV_CONTRACTS)).toEqual([]); + }); + + test("takes the next hostAbi in the registry-wide sequence", () => { + // hostAbi is one sequence across every profile, private ones included: + // 1 psp, 2 vita, 3 macos-widget, 4 symbian-e7-dev, 5 pocketbook, + // 6 iphone2g-dev. A collision would let a bundle mount on the wrong host. + expect(THREE_DS_DEV_HOST_ABI).toBe(7); + expect( + Object.values(POCKET_TARGETS).map((profile) => profile.hostAbi), + ).not.toContain(THREE_DS_DEV_HOST_ABI); + // The .pocket container stores the target id in a NUL-padded fixed field. + expect(new TextEncoder().encode(THREE_DS_DEV_TARGET_ID).length).toBeLessThan( + POCKET_PACKAGE_TARGET_BYTES, + ); + }); + + test("resolves a 400x240 native app to an exact device plan", () => { + const plan = resolve3dsBuildPlan(topScreenManifest()); + + expect(plan.target).toEqual({ + id: THREE_DS_DEV_TARGET_ID, + hostAbi: THREE_DS_DEV_HOST_ABI, + }); + expect(plan.viewport).toEqual({ + logical: THREE_DS_VIEWPORT, + physical: THREE_DS_VIEWPORT, + presentation: "native", + rasterDensity: 1, + }); + // The nub is provided, so the enhancement resolves true; the host ships no + // audio module in v1, so audio.pcm resolves false instead of failing. + expect(plan.features).toEqual({ + "audio.pcm": false, + "input.analog.left": true, + "input.buttons": true, + "text.glyphs.baked": true, + }); + expect(plan.app.entry).toBe("apps/3ds-demo/main.tsx"); + expect(verifyPlanHash(plan)).toBe(true); + }); + + test("rejects the 480x272 integer-fit corpus", () => { + // The top screen is smaller than 480x272 on both axes and the resolver has + // no scaling fallback, so the stock PSP-shaped app corpus cannot be + // admitted here — 3DS apps declare their own 400x240 native viewport. + const psp = topScreenManifest(); + psp.app.viewport.fixed = { + logical: [480, 272], + presentation: "integer-fit", + }; + expect(diagnosticCodes(psp)).toEqual([ + "viewport.logicalUnsupported", + "viewport.presentationUnsupported", + // 400/480 and 240/272 are not one positive integer scale. + "viewport.integerFitMismatch", + ]); + expect(() => resolve3dsBuildPlan(psp)).toThrow("480x272"); + }); + + test("refuses capabilities the top-screen host cannot provide", () => { + // The touchscreen is the bottom screen: its contacts are not top-screen + // logical coordinates, so input.touch is not advertised. + const needsTouch = topScreenManifest(); + needsTouch.engine.capabilities.requires.push("input.touch"); + expect(diagnosticCodes(needsTouch)).toEqual(["capability.unavailable"]); + expect(() => resolve3dsBuildPlan(needsTouch)).toThrow("input.touch"); + + const needsRuntimeGlyphs = topScreenManifest(); + needsRuntimeGlyphs.engine.capabilities.requires.push("text.glyphs.runtime"); + expect(diagnosticCodes(needsRuntimeGlyphs)).toEqual([ + "capability.unavailable", + ]); + }); + + test("forbids a dynamic viewport on a takeover form", () => { + const dynamic = topScreenManifest(); + dynamic.app.viewport = { dynamic: { default: [400, 240] } }; + expect(diagnosticCodes(dynamic)).toEqual(["viewport.fixedRequired"]); + }); + + test("publishes the core's viewport as ui.__viewport", () => { + // framework/src/index.ts sizes the mounted app and overlay layers from + // ui.__viewport and falls back to the 480x272 spec screen when a host + // omits it, which lays a 400x240 app out 80 px too wide and pushes every + // right-anchored element off the panel. Nothing catches that without an + // emulator, so the publication is pinned here — read back from the core + // rather than re-derived, so the JS layer and the native root cannot drift. + const qjs = readFileSync( + join(new URL("..", import.meta.url).pathname, "hosts/3ds/src/qjs.c"), + "utf8", + ); + expect(qjs).toContain('JS_SetPropertyStr(context, ui, "__viewport", viewport)'); + expect(qjs).toContain("ui_viewport_width()"); + expect(qjs).toContain("ui_viewport_height()"); + }); +}); diff --git a/tests/e2e/azahar.ts b/tests/e2e/azahar.ts new file mode 100644 index 00000000..f5003d03 --- /dev/null +++ b/tests/e2e/azahar.ts @@ -0,0 +1,382 @@ +// tests/e2e/azahar.ts — deterministic Nintendo 3DS E2E: build a capture .3dsx +// per golden spec, boot it in Azahar against a per-run emulator user directory, +// wait for the guest's completion marker, then byte-compare the decoded +// 400x240 top-screen readbacks against tests/goldens/3ds/. +// +// bun run e2e:3ds # compare against tests/goldens/3ds/ +// UPDATE_3DS=1 bun run e2e:3ds # regenerate goldens (then eyeball the PNGs) +// +// Environment: AZAHAR (the .app bundle), AZAHAR_CONFIG (the settings to clone), +// E2E_AZAHAR_APP (one spec name instead of the default set), E2E_AZAHAR_3DSX +// (run a .3dsx that is already built), E2E_AZAHAR_TIMEOUT_MS. +// +// Determinism: the core steps a fixed dt (contracts/spec/spec.ts FIXED_DT) and +// the baked input tape is indexed by the same frame counter that names the +// dumped files, so a frame is a pure function of its index. The capture is a GX +// display transfer of the PICA200 render target — a real GPU readback, not a +// CPU oracle — and it is byte-identical run to run under one renderer. It is +// NOT identical between renderers: a shaded triangle differed on every measured +// frame between Software (graphics_api=0) and Vulkan (graphics_api=2), 34% of +// pixels on the first, so the fixture pins the backend and a golden belongs to +// the pinned one. +// +// Azahar has no headless mode, ignores SIGTERM, and does not exit when the +// guest returns from main(); the driver therefore owns both its lifetime +// (SIGKILL on every path) and its user directory. Emulator-bound and +// GUI-bound: keep it out of `bun run test` and CI. + +import { $ } from "bun"; +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { demoManifestFor } from "../../tools/demo-identity.ts"; +import { encodePNG } from "../png.ts"; +import { encodeThresholdInput, THREE_DS_GOLDEN_SPECS, type GoldenSpec } from "../golden-specs.ts"; + +const ROOT = new URL("../..", import.meta.url).pathname; +const OUT = `${ROOT}dist/e2e-3ds`; +// Azahar derives its whole user directory from $HOME on macOS, and has no +// command line switch for any part of it. A fixture $HOME is therefore the only +// way to give a run its own config and its own SD card. +const FIXTURE_HOME = `${OUT}/home`; +const USER_DIR = `${FIXTURE_HOME}/Library/Application Support/Azahar`; +const CONFIG = `${USER_DIR}/config/qt-config.ini`; +const CAPTURE_DIR = `${USER_DIR}/sdmc/pocketjs-captures`; +const CONSOLE_LOG = `${OUT}/azahar-console.log`; +const GOLDENS = `${ROOT}tests/goldens/3ds`; + +// The 3DS top screen. The transferred buffer is still in the screen's rotated +// orientation, so it is 240 wide by 400 tall on the way out of the GPU. +const W = 400; +const H = 240; +const RAW_BYTES = W * H * 4; + +const TIMEOUT_MS = Number(process.env.E2E_AZAHAR_TIMEOUT_MS ?? 180_000); +const LAUNCH_GRACE_MS = 20_000; +const update = process.env.UPDATE_3DS === "1"; + +const azaharApp = process.env.AZAHAR || "/Applications/Azahar.app"; +const azaharBinary = `${azaharApp}/Contents/MacOS/azahar`; +const sourceConfig = + process.env.AZAHAR_CONFIG || `${homedir()}/Library/Application Support/Azahar/config/qt-config.ini`; +const sourceUserDir = sourceConfig.replace(/\/config\/[^/]+$/, ""); +// Set to run a .3dsx that is already built (the tools/3ds.ts build is skipped). +const prebuilt = process.env.E2E_AZAHAR_3DSX; +const romDir = process.env.E2E_AZAHAR_ROM_DIR ?? `${ROOT}dist/3ds`; + +// The 3DS top screen is 400x240; the stock 480x272 demo corpus does not fit it +// on either axis and the resolver has no scaling fallback, so this driver runs +// only the specs whose app declares the 400x240 native viewport. +const DEFAULT_SPEC_NAMES = ["3ds-demo"]; + +// --------------------------------------------------------------------------- +// Preflight +// --------------------------------------------------------------------------- + +if (process.platform !== "darwin") { + console.error("the Azahar E2E driver is macOS-only (it launches the emulator through LaunchServices)"); + process.exit(2); +} +if (!existsSync(azaharBinary)) { + console.error(`Azahar not found at ${azaharApp} (set AZAHAR to the .app bundle)`); + process.exit(2); +} +if (!existsSync(sourceConfig)) { + console.error(`Azahar config not found at ${sourceConfig} (launch Azahar once, or set AZAHAR_CONFIG)`); + process.exit(2); +} +for (const tool of ["open", "pgrep", "pkill"]) { + if (!Bun.which(tool)) { + console.error(`${tool} not found (required to launch and to reap the emulator)`); + process.exit(2); + } +} + +const requested = process.env.E2E_AZAHAR_APP; +const names = requested ? [requested] : DEFAULT_SPEC_NAMES; +const specs: GoldenSpec[] = []; +for (const name of names) { + const spec = THREE_DS_GOLDEN_SPECS.find((candidate) => candidate.name === name || candidate.name === `${name}-main`); + if (!spec) { + console.error( + `no THREE_DS_GOLDEN_SPECS entry named ${JSON.stringify(name)} in tests/golden-specs.ts — ` + + "the input tape and capture frames are shared with the other hosts, never invented here", + ); + process.exit(2); + } + specs.push(spec); +} + +// --------------------------------------------------------------------------- +// Per-run fixture +// --------------------------------------------------------------------------- + +/** Clone the developer's emulator settings, then pin the keys a golden depends + * on. Azahar ignores a value whose sibling `\default=false` line is + * missing, so both lines are always written. */ +function writeFixture(): void { + rmSync(OUT, { recursive: true, force: true }); + mkdirSync(`${USER_DIR}/config`, { recursive: true }); + mkdirSync(CAPTURE_DIR, { recursive: true }); + // The emulated system files, so a fixture $HOME boots the same way the + // developer's install does. The SD card is deliberately not copied: a shared + // sdmc lets a previous run's frames satisfy the capture check. + for (const directory of ["nand", "sysdata"]) { + if (existsSync(`${sourceUserDir}/${directory}`)) { + cpSync(`${sourceUserDir}/${directory}`, `${USER_DIR}/${directory}`, { recursive: true }); + } + } + + let config = readFileSync(sourceConfig, "utf8"); + const set = (key: string, value: string): void => { + const assignment = new RegExp(`^${key}=.*$`, "gm"); + if ((config.match(assignment)?.length ?? 0) !== 1) { + throw new Error(`qt-config.ini does not carry exactly one ${key} key`); + } + config = config.replace(new RegExp(`^${key}=.*$`, "m"), () => `${key}=${value}`); + config = new RegExp(`^${key}\\\\default=.*$`, "m").test(config) + ? config.replace(new RegExp(`^${key}\\\\default=.*$`, "m"), () => `${key}\\default=false`) + : config.replace(new RegExp(`^${key}=.*$`, "m"), () => `${key}=${value}\n${key}\\default=false`); + }; + // The renderers do not agree: the same capture hashed differently under + // Software and Vulkan on every measured frame, while each backend was + // byte-stable across runs. Goldens therefore belong to one backend, and it is + // the software rasterizer — the one that does not depend on the developer's + // GPU driver. + set("graphics_api", "0"); + // The capture transfers a 240x400 render target; any internal upscale changes + // what comes back. + set("resolution_factor", "1"); + set("use_vsync", "false"); + set("frame_limit", "1000"); + set("use_disk_shader_cache", "false"); + set("check_for_update_on_start", "false"); + writeFileSync(CONFIG, config); +} + +// --------------------------------------------------------------------------- +// Emulator lifetime +// --------------------------------------------------------------------------- + +function emulatorRunning(): boolean { + return Bun.spawnSync(["pgrep", "-f", azaharBinary], { stdout: "ignore", stderr: "ignore" }).exitCode === 0; +} + +/** Azahar outlives its guest, so a run interrupted with ^C leaves an instance + * holding the fixture and racing the next run's capture files. */ +function killEmulator(): void { + Bun.spawnSync(["pkill", "-9", "-f", azaharBinary], { stdout: "ignore", stderr: "ignore" }); +} + +async function runAzahar(rom: string): Promise { + const done = `${CAPTURE_DIR}/done`; + const error = `${CAPTURE_DIR}/error.txt`; + rmSync(CAPTURE_DIR, { recursive: true, force: true }); + mkdirSync(CAPTURE_DIR, { recursive: true }); + killEmulator(); + + // LaunchServices, not a direct exec: Azahar only reaches the window server — + // and only then advances the guest — when it is launched into the user's GUI + // session. `--env` carries the fixture $HOME across the hand-off, which the + // launched process does not otherwise inherit, and `-n` refuses to reuse an + // instance that is already up. + const launch = Bun.spawnSync( + ["open", "-n", "-a", azaharApp, "--env", `HOME=${FIXTURE_HOME}`, + "--stdout", CONSOLE_LOG, "--stderr", CONSOLE_LOG, "--args", rom], + { stdout: "pipe", stderr: "pipe" }, + ); + if (launch.exitCode !== 0) { + throw new Error(`could not launch Azahar: ${launch.stderr.toString().trim()}`); + } + + const started = Date.now(); + let seenRunning = false; + try { + while (Date.now() - started < TIMEOUT_MS) { + // Four conditions, not one: the guest's failure path, the guest's + // completion marker, the emulator dying, and the deadline. + if (existsSync(error)) throw new Error(readFileSync(error, "utf8").trim()); + if (existsSync(done)) return; + if (emulatorRunning()) seenRunning = true; + else if (seenRunning) throw new Error("Azahar exited before the guest finished"); + else if (Date.now() - started > LAUNCH_GRACE_MS) throw new Error("Azahar never started"); + await Bun.sleep(100); + } + // Azahar's own log file is buffered and flushed on a clean exit only, so + // the SIGKILL below leaves it empty and rotates the previous run's log + // away. The console stream redirected at launch is the diagnostic instead. + throw new Error( + `timed out after ${TIMEOUT_MS} ms without the guest's done marker ` + + "(see dist/e2e-3ds/azahar-console.log)", + ); + } finally { + killEmulator(); + } +} + +// --------------------------------------------------------------------------- +// Decode + structural guards +// --------------------------------------------------------------------------- + +/** The display transfer keeps the screen's rotated orientation: the buffer is + * 240 wide by 400 tall, column-major, and each RGBA8 word is stored A,B,G,R. + * Decoding it as a plain 400x240 image mismatches every pixel while looking + * almost right. */ +function decodeTopScreen(raw: Uint8Array): Uint8Array { + const rgba = new Uint8Array(RAW_BYTES); + for (let x = 0; x < W; x++) { + for (let y = 0; y < H; y++) { + const source = (x * H + (H - 1 - y)) * 4; + const destination = (y * W + x) * 4; + rgba[destination] = raw[source + 3]; + rgba[destination + 1] = raw[source + 2]; + rgba[destination + 2] = raw[source + 1]; + rgba[destination + 3] = 255; // the target's own alpha is never presented + } + } + return rgba; +} + +function isNonFlat(rgba: Uint8Array): boolean { + const pixels = new Uint32Array(rgba.buffer, rgba.byteOffset, rgba.byteLength / 4); + const seen = new Set(); + for (const pixel of pixels) { + seen.add(pixel); + if (seen.size >= 3) return true; + } + return false; +} + +/** Prove the frame is the top screen's own 400x240 and not a 200x120 render + * with every pixel doubled. */ +function hasNativeDetail(rgba: Uint8Array): boolean { + for (let y = 0; y < H; y += 2) { + for (let x = 0; x < W; x += 2) { + const topLeft = (y * W + x) * 4; + for (const offset of [topLeft + 4, topLeft + W * 4, topLeft + W * 4 + 4]) { + for (let channel = 0; channel < 4; channel++) { + if (rgba[topLeft + channel] !== rgba[offset + channel]) return true; + } + } + } + } + return false; +} + +// --------------------------------------------------------------------------- +// Runner +// --------------------------------------------------------------------------- + +try { + writeFixture(); +} catch (error) { + console.error(`could not build the run fixture: ${(error as Error).message}`); + process.exit(2); +} +mkdirSync(GOLDENS, { recursive: true }); +// Emulator provenance: byte-exact goldens are only promised for the Azahar +// build and the renderer they were recorded with. +const buildStamp = `${Bun.spawnSync([azaharBinary, "--version"]).stdout.toString().trim()}, graphics_api=0`; +const stampPath = `${GOLDENS}/AZAHAR-BUILD.txt`; +const recordedStamp = existsSync(stampPath) ? readFileSync(stampPath, "utf8").trim() : null; +let passed = 0; +let failed = 0; + +for (const spec of specs) { + // A spec name mirrors its app directory (`3ds-demo-main` -> apps/3ds-demo); + // the .3dsx is named by the manifest's app.output, which is not the same + // string. + const demo = (spec.app ?? spec.name).replace(/-main$/, ""); + const manifest = demoManifestFor(ROOT, demo) as { app: { output?: string } }; + const rom = prebuilt ?? `${romDir}/${manifest.app.output ?? `${demo}-main`}.3dsx`; + // The window the guest dumps. It starts at 0 so a frame's file name is its + // global frame index whether the host names dumps by the counter or by the + // offset into the window. + const capN = Math.max(...spec.capture) + 1; + console.log(`\n## ${spec.name} (${spec.capture.length} golden frame(s) of a ${capN}-frame dump)`); + + if (!prebuilt) { + if (!existsSync(`${ROOT}apps/${demo}`)) { + console.error(`FAIL ${spec.name}: no apps/${demo} to build`); + failed += spec.capture.length; + continue; + } + // The tape and the capture window are baked into the binary: the guest + // never reads them back off the emulator's filesystem at runtime. + const build = await $`bun tools/3ds.ts ${demo} --capture` + .cwd(ROOT) + .env({ + ...process.env, + POCKETJS_CAPTURE_INPUT: encodeThresholdInput(spec), + POCKETJS_CAP_START: "0", + POCKETJS_CAP_N: String(capN), + }) + .quiet() + .nothrow(); + if (build.exitCode !== 0) { + console.error(`FAIL ${spec.name}: 3DS build failed\n${build.stdout}${build.stderr}`); + failed += spec.capture.length; + continue; + } + } + if (!existsSync(rom)) { + console.error(`FAIL ${spec.name}: no capture .3dsx at ${rom}`); + failed += spec.capture.length; + continue; + } + + try { + await runAzahar(rom); + } catch (error) { + console.error(`FAIL ${spec.name}: ${(error as Error).message}`); + failed += spec.capture.length; + continue; + } + + for (const frame of spec.capture) { + const label = `${spec.name}.${frame}`; + try { + // Structural guards run before any comparison, and in UPDATE mode too: a + // golden that is short, flat, or upscaled must never be recorded. + const rawPath = `${CAPTURE_DIR}/f${String(frame).padStart(4, "0")}.raw`; + if (!existsSync(rawPath)) throw new Error(`${label}: capture file missing`); + const raw = readFileSync(rawPath); + if (raw.byteLength !== RAW_BYTES) { + throw new Error(`${label}: expected ${RAW_BYTES} bytes (400x240 RGBA8), got ${raw.byteLength}`); + } + const rgba = decodeTopScreen(raw); + if (!isNonFlat(rgba)) throw new Error(`${label}: degenerate flat frame`); + if (!hasNativeDetail(rgba)) { + throw new Error(`${label}: frame contains only duplicated 2x2 pixels`); + } + + const actual = encodePNG(rgba, W, H); + const golden = `${GOLDENS}/${label}.png`; + if (update) { + writeFileSync(golden, actual); + console.log(`WROTE ${label} (400x240 PICA200 readback)`); + passed++; + continue; + } + if (!existsSync(golden)) { + throw new Error(`${label}: golden missing (run with UPDATE_3DS=1 after visual review)`); + } + if (!actual.equals(readFileSync(golden))) { + writeFileSync(`${OUT}/${label}.actual.png`, actual); + const drift = + recordedStamp && recordedStamp !== buildStamp + ? ` — note: goldens came from ${recordedStamp}, this run is ${buildStamp}` + : ""; + throw new Error(`${label}: PNG bytes differ (see dist/e2e-3ds/${label}.actual.png)${drift}`); + } + console.log(`PASS ${label} (400x240 top screen, byte-exact)`); + passed++; + } catch (error) { + console.error(`FAIL ${(error as Error).message}`); + failed++; + } + } +} + +if (update && passed > 0) writeFileSync(stampPath, `${buildStamp}\n`); +console.log(`\nAzahar E2E: ${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); diff --git a/tests/golden-specs.ts b/tests/golden-specs.ts index 49b05316..55e5d601 100644 --- a/tests/golden-specs.ts +++ b/tests/golden-specs.ts @@ -240,6 +240,31 @@ export const GOLDEN_SPECS: GoldenSpec[] = [ }, ]; +/** + * Specs for the 400x240 Nintendo 3DS top screen (tools/3ds-profile.ts), kept + * out of GOLDEN_SPECS on purpose: that array drives the 480x272 wasm oracle in + * tests/golden.ts and the Vita driver, and neither can run an app whose only + * viewport is 400x240. Same GoldenSpec type and the same encoders, so + * tests/e2e/azahar.ts shares every mechanism with the other drivers. + * + * The spec name is the app DIRECTORY, as in every other driver. The built + * artifact is a different string — `resolve.ts` refuses a derived output that + * does not start with a letter, so apps/3ds-demo builds as + * pocket3ds-demo-main — and the driver reads it off the manifest rather than + * from the spec. + */ +export const THREE_DS_GOLDEN_SPECS: GoldenSpec[] = [ + { + name: "3ds-demo", + frames: 24, + capture: [2, 12, 22], + // Frame 4 presses RIGHT (BTN.RIGHT = 0x20) and frame 8 releases it, so the + // frame-12 and frame-22 captures carry a native focus: variant the guest + // never re-rendered for. Edge detection needs the release. + input: (frame) => (frame >= 4 && frame < 8 ? 0x20 : 0), + }, +]; + export function encodeThresholdInput(spec: GoldenSpec): string { const lastFrame = Math.max(...spec.capture); const entries: string[] = []; diff --git a/tests/goldens/3ds/3ds-demo.12.png b/tests/goldens/3ds/3ds-demo.12.png new file mode 100644 index 0000000000000000000000000000000000000000..d08abad087ec0c74863d61e2702238574f706e50 GIT binary patch literal 11267 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 E0g*F#M*si- literal 0 HcmV?d00001 diff --git a/tests/goldens/3ds/3ds-demo.2.png b/tests/goldens/3ds/3ds-demo.2.png new file mode 100644 index 0000000000000000000000000000000000000000..4278798b62a5040f18ddb585abb8f4fdc34a0b17 GIT binary patch literal 11246 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 E0g*F#M*si- literal 0 HcmV?d00001 diff --git a/tests/goldens/3ds/AZAHAR-BUILD.txt b/tests/goldens/3ds/AZAHAR-BUILD.txt new file mode 100644 index 00000000..f6c479c3 --- /dev/null +++ b/tests/goldens/3ds/AZAHAR-BUILD.txt @@ -0,0 +1 @@ +Azahar 2125.1.2, graphics_api=0 diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index f7b0ab22..e5078ba0 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -334,6 +334,7 @@ describe("semantic resolution", () => { // both policies, while the note is dynamic-only. A new demo missing here // fails the test on purpose. const expected: Record = { + "3ds-demo": [false, false, false], // admitted only by the private 3ds-dev profile (400x240 native) cafe: [true, true, false], cards: [true, true, false], chrome: [true, true, false], diff --git a/tools/3ds-profile.ts b/tools/3ds-profile.ts new file mode 100644 index 00000000..1984669c --- /dev/null +++ b/tools/3ds-profile.ts @@ -0,0 +1,62 @@ +import { + POCKET_CAPABILITIES, + definePlatformContractRegistry, + defineTargetRegistry, +} from "../contracts/spec/platforms.ts"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; +import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.ts"; + +/** + * Transitional Nintendo 3DS profile used only by `bun run 3ds`. + * + * It deliberately stays out of the production `POCKET_TARGETS` registry until + * the citro3d host has passed the full hardware acceptance suite. The app owns + * the 400x240 top screen: the PICA200 render target is that panel exactly, so + * the only presentation is native at density 1. + * + * The touchscreen is the *bottom* screen (320x240) and is not advertised — + * reporting its contacts as logical coordinates inside the top screen's space + * would be a lie. That needs a second-surface design, not a capability id. + */ +export const THREE_DS_DEV_TARGET_ID = "3ds-dev"; +export const THREE_DS_DEV_HOST_ABI = 7; +export const THREE_DS_VIEWPORT = [400, 240] as const; + +export const THREE_DS_DEV_CONTRACTS = definePlatformContractRegistry( + POCKET_CAPABILITIES, + defineTargetRegistry({ + [THREE_DS_DEV_TARGET_ID]: { + hostAbi: THREE_DS_DEV_HOST_ABI, + platform: "3ds", + form: "takeover", + display: { + physicalViewport: THREE_DS_VIEWPORT, + logicalViewports: [THREE_DS_VIEWPORT], + presentations: ["native"], + rasterDensity: 1, + }, + capabilities: [ + "input.analog.left", + "input.buttons", + "input.cursor", + "text.glyphs.baked", + ], + }, + }), +); + +export function resolve3dsBuildPlan(input: unknown): ResolvedBuildPlan { + const resolution = validateAndResolveBuildPlan( + input, + { target: THREE_DS_DEV_TARGET_ID }, + THREE_DS_DEV_CONTRACTS, + ); + if (!resolution.ok) { + throw new Error( + `pocket 3ds: manifest did not resolve: ${resolution.diagnostics + .map((diagnostic) => `${diagnostic.path || "/"}: ${diagnostic.message}`) + .join("; ")}`, + ); + } + return resolution.plan; +} diff --git a/tools/3ds.ts b/tools/3ds.ts new file mode 100644 index 00000000..2629c871 --- /dev/null +++ b/tools/3ds.ts @@ -0,0 +1,660 @@ +// tools/3ds.ts [cargo args…] — build the app JS+pak (tools/build.ts), the +// Rust core staticlib, QuickJS, and the .3dsx for the out-of-registry "3ds-dev" +// profile (tools/3ds-profile.ts). +// +// bun tools/3ds.ts 3ds-demo +// bun tools/3ds.ts 3ds-demo --capture (e2e frame-dump build) +// bun tools/3ds.ts --plan= --project-root= +// +// The toolchain spans two environments. The Rust half compiles on macOS: +// armv6k-nintendo-3ds is a built-in rustc target, so -Z build-std works +// host-side with no devkitARM present. The C half compiles inside the +// devkitpro/devkitarm container, which owns arm-none-eabi-gcc, libctru, +// citro3d, picasso and 3dsxtool. Both halves see the same repository through +// one bind mount at /repo. +// +// 1. tools/build.ts -> /.js + /.pak +// 2. cargo build --release -> hosts/3ds/core/target/armv6k-nintendo-3ds/release/ +// libpocketjs_3ds_core.a (macOS) +// 3. QuickJS -> dist/3ds/quickjs/libquickjs.a (container, cached) +// 4. hosts/3ds/Makefile -> dist/3ds/.3dsx (container) +// +// is the resolved plan's app.output, not the bare app argument. +// +// dist/3ds/ is this target's own output tree. A 3DS build never writes into +// dist/, where a PSP or Vita build keeps target-flavored bundles of the same +// name. +// +// --------------------------------------------------------------------------- +// The contract with hosts/3ds/Makefile +// --------------------------------------------------------------------------- +// The Makefile runs in the container with CWD /repo/hosts/3ds and receives all +// paths as container paths. It gets the twelve variables hostBuildEnvironment() +// emits (POCKETJS_APP_OUTPUT, POCKETJS_EMBED_APP, POCKETJS_OUTPUT_DIR, +// POCKETJS_TARGET, POCKETJS_HOST_ABI, POCKETJS_LOGICAL_WIDTH/HEIGHT, +// POCKETJS_PHYSICAL_WIDTH/HEIGHT, POCKETJS_PRESENTATION, +// POCKETJS_RASTER_DENSITY) — POCKETJS_TARGET and POCKETJS_HOST_ABI are the +// values the host must publish as ui.__host / ui.__hostAbi, so the C compile +// derives -DPOCKETJS_TARGET_ID and -DPOCKETJS_HOST_ABI from them rather than +// from literals — plus: +// +// POCKETJS_CORE_LIB absolute path to libpocketjs_3ds_core.a +// POCKETJS_QUICKJS_DIR directory holding quickjs.h and libquickjs.a +// POCKETJS_APP_JS the guest bundle to embed +// POCKETJS_APP_PAK the guest pak to embed +// POCKETJS_BUILD_DIR scratch directory for objects, .shbin and the .elf +// POCKETJS_OUT_3DSX the .3dsx path to write +// POCKETJS_SMDH_TITLE application title (3dsxtool --smdh metadata) +// POCKETJS_SMDH_AUTHOR application id +// POCKETJS_SMDH_DESC application description +// POCKETJS_CAPTURE "1" under --capture, "" otherwise +// POCKETJS_CAPTURE_INPUT scripted input tape ("frame:mask,…"), baked in +// POCKETJS_CAP_START first frame to dump +// POCKETJS_CAP_N how many frames to dump +// +// The default goal must produce POCKETJS_OUT_3DSX and nothing outside +// POCKETJS_BUILD_DIR and dist/3ds/. + +import { $ } from "bun"; +import { createHash } from "node:crypto"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { availableParallelism, homedir } from "node:os"; +import { join, resolve as resolvePath } from "node:path"; +import { + extractHostBuildInputs, + hostBuildEnvironment, +} from "../framework/src/manifest/host-build-inputs.ts"; +import { + verifyPlanHash, + type ResolvedBuildPlan, +} from "../framework/src/manifest/plan.ts"; +import { + THREE_DS_DEV_TARGET_ID, + resolve3dsBuildPlan, +} from "./3ds-profile.ts"; + +const repository = new URL("..", import.meta.url).pathname; // PocketJS/ +const hostDirectory = `${repository}hosts/3ds/`; +const coreDirectory = `${hostDirectory}core/`; + +/** The dev profile's target id; a plan for any other target is rejected. */ +const TARGET_ID = THREE_DS_DEV_TARGET_ID; +const RUST_TARGET = "armv6k-nintendo-3ds"; +/** Produced by the `pocketjs-3ds-core` staticlib crate in hosts/3ds/core. */ +const CORE_STATIC_LIBRARY = "libpocketjs_3ds_core.a"; +const CONTAINER_IMAGE = "devkitpro/devkitarm:latest"; +const CONTAINER_REPOSITORY = "/repo"; +const CONTAINER_OUTPUT = "/out"; + +// The QuickJS revision hosts/psp/Cargo.toml pins, unpacked by cargo into the +// git checkout cache. libquickjs-sys's build.rs is bypassed: it would need the +// `cc` crate to find a 3DS-capable compiler on macOS, and there is none. +const QUICKJS_CHECKOUT = + ".cargo/git/checkouts/quickjs-rs-1bf011a924d415f9/ba5bdd0/libquickjs-sys/embed/quickjs"; +const QUICKJS_SOURCES = [ + "quickjs.c", + "cutils.c", + "libregexp.c", + "libunicode.c", + "dtoa.c", +] as const; +const QUICKJS_HEADERS = [ + "cutils.h", + "dtoa.h", + "libregexp-opcode.h", + "libregexp.h", + "libunicode-table.h", + "libunicode.h", + "list.h", + "quickjs-atom.h", + "quickjs-opcode.h", + "quickjs.h", +] as const; + +/** The devkitARM ABI, published by the toolchain itself in 3dsvars.sh. */ +const ARM_ARCHITECTURE_FLAGS = [ + "-march=armv6k", + "-mtune=mpcore", + "-mfloat-abi=hard", + "-mtp=soft", + "-mword-relocations", + "-ffunction-sections", + "-fdata-sections", +]; + +// Verified to build a 1.3 MB libquickjs.a exporting 181 JS_* symbols. +// JS_NO_NAN_BOXING matches libquickjs-sys's own Vita treatment (16-byte +// JSValue on 32-bit ARM). __TM_GMTOFF is how newlib gates struct tm's +// tm_gmtoff, which js_date_getTimezoneOffset reads on every target that is +// neither __PSP__ nor __vita__ — and the same two macros are why malloc.h has +// to be force-included here rather than by quickjs.c itself. devkitARM ships +// GCC 16, which promoted incompatible pointer types to errors; this is the +// same source that builds for PSP. +const QUICKJS_COMPILE_FLAGS = [ + ...ARM_ARCHITECTURE_FLAGS, + "-O2", + "-D__3DS__", + "-DCONFIG_VERSION='\"pocket3ds\"'", + "-D_GNU_SOURCE", + "-DJS_NO_NAN_BOXING", + "-D__TM_GMTOFF=tm_gmtoff", + "-include", + "malloc.h", + "-fno-strict-aliasing", + "-funsigned-char", + "-Wno-incompatible-pointer-types", + "-Wno-implicit-function-declaration", + "-I.", +]; + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +export interface ThreeDsArguments { + /** Bare app name (apps//pocket.json); empty when --plan is given. */ + readonly app: string; + readonly planPath?: string; + readonly projectRoot: string; + /** Where tools/build.ts writes .js and .pak (trailing slash). */ + readonly outputDir: string; + /** Where the .3dsx lands. */ + readonly packageDir: string; + readonly skipBuild: boolean; + readonly capture: boolean; + readonly configPath: string; + readonly configFlagged: boolean; + readonly useConfig: boolean; + /** Forwarded to tools/build.ts. */ + readonly buildFlags: readonly string[]; + /** Everything unrecognized, forwarded to cargo. */ + readonly cargoArgs: readonly string[]; +} + +export interface ParseOptions { + readonly repositoryRoot?: string; + readonly workingDirectory?: string; +} + +export function parse3dsArguments( + argv: readonly string[], + options: ParseOptions = {}, +): ThreeDsArguments { + const root = options.repositoryRoot ?? repository; + let app = ""; + let planPath: string | undefined; + let projectRoot = options.workingDirectory ?? process.cwd(); + let outputDir = `${root}dist/3ds/guest/`; + let packageDir = `${root}dist/3ds`; + let skipBuild = false; + let capture = false; + let configPath = `${root}pocket.config.ts`; + let configFlagged = false; + let useConfig = true; + const buildFlags: string[] = []; + const cargoArgs: string[] = []; + + for (const a of argv) { + if (a === "--capture") capture = true; + else if (a === "--skip-build") skipBuild = true; + else if (a.startsWith("--plan=")) planPath = resolvePath(a.slice("--plan=".length)); + else if (a.startsWith("--project-root=")) projectRoot = resolvePath(a.slice("--project-root=".length)); + else if (a.startsWith("--outdir=")) outputDir = resolvePath(a.slice("--outdir=".length)) + "/"; + else if (a.startsWith("--package-outdir=")) packageDir = resolvePath(a.slice("--package-outdir=".length)); + else if (a.startsWith("--config=")) { + configPath = resolvePath(root, a.slice("--config=".length)); + configFlagged = true; + buildFlags.push(a); + } else if (a === "--no-config") { + useConfig = false; + buildFlags.push(a); + } else if (!app && !a.startsWith("-")) app = a; + else cargoArgs.push(a); + } + + return { + app, + planPath, + projectRoot, + outputDir, + packageDir, + skipBuild, + capture, + configPath, + configFlagged, + useConfig, + buildFlags, + cargoArgs, + }; +} + +const USAGE = + "usage: bun tools/3ds.ts [--plan=] [--project-root=] " + + "[--outdir=] [--package-outdir=] [--skip-build] [--capture] [cargo args…] " + + "e.g. bun tools/3ds.ts 3ds-demo --capture"; + +// --------------------------------------------------------------------------- +// Container plumbing +// --------------------------------------------------------------------------- + +interface Mount { + readonly hostPath: string; + readonly containerPath: string; +} + +/** + * Translate a macOS path into the container path it is mounted at. Longest + * mount wins so a nested output directory maps through its own mount. + */ +export function containerPathFor( + hostPath: string, + mounts: readonly Mount[], +): string { + const absolute = resolvePath(hostPath); + const candidates = [...mounts].sort( + (a, b) => resolvePath(b.hostPath).length - resolvePath(a.hostPath).length, + ); + for (const mount of candidates) { + const base = resolvePath(mount.hostPath); + if (absolute === base) return mount.containerPath; + if (absolute.startsWith(`${base}/`)) { + return `${mount.containerPath}${absolute.slice(base.length)}`; + } + } + throw new Error( + `PocketJS 3ds: ${absolute} is outside every container mount ` + + `(${mounts.map((mount) => resolvePath(mount.hostPath)).join(", ")}); ` + + "keep --outdir/--package-outdir inside the repository or the project root", + ); +} + +interface CommandResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +async function capture( + command: string, + args: readonly string[], + cwd = repository, +): Promise { + const child = Bun.spawn({ + cmd: [command, ...args], + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { exitCode, stdout, stderr }; +} + +/** Preamble every container script needs: the tools are not on PATH. */ +const CONTAINER_PREAMBLE = [ + "set -euo pipefail", + 'export DEVKITPRO="${DEVKITPRO:-/opt/devkitpro}"', + 'export DEVKITARM="${DEVKITARM:-/opt/devkitpro/devkitARM}"', + 'export PATH="$DEVKITARM/bin:$DEVKITPRO/tools/bin:$PATH"', +].join("\n"); + +async function runContainer( + script: string, + mounts: readonly Mount[], + workingDirectory: string, + environment: Readonly>, + label: string, +): Promise { + const args = ["run", "--rm", "--network=none"]; + for (const mount of mounts) { + args.push("-v", `${resolvePath(mount.hostPath)}:${mount.containerPath}`); + } + args.push("-w", workingDirectory); + for (const [key, value] of Object.entries(environment)) { + args.push("-e", `${key}=${value}`); + } + args.push(CONTAINER_IMAGE, "bash", "-c", `${CONTAINER_PREAMBLE}\n${script}`); + const child = Bun.spawn({ + cmd: ["docker", ...args], + cwd: repository, + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await child.exited; + if (exitCode !== 0) { + throw new Error(`PocketJS 3ds: ${label} failed in ${CONTAINER_IMAGE} (${exitCode})`); + } +} + +// --------------------------------------------------------------------------- +// Preflight +// --------------------------------------------------------------------------- + +async function preflightContainer(): Promise { + if (!Bun.which("docker")) { + throw new Error( + "PocketJS 3ds: docker was not found on PATH. The 3DS C toolchain " + + "(arm-none-eabi-gcc, libctru, citro3d, picasso, 3dsxtool) only exists " + + "in a container; install Docker Desktop and start it.", + ); + } + const daemon = await capture("docker", ["info", "--format", "{{.ServerVersion}}"]); + if (daemon.exitCode !== 0) { + throw new Error( + "PocketJS 3ds: the Docker daemon is not responding — start Docker Desktop and retry.\n" + + (daemon.stderr.trim() || daemon.stdout.trim()), + ); + } + const image = await capture("docker", [ + "image", + "inspect", + "--format", + "{{.Id}}", + CONTAINER_IMAGE, + ]); + if (image.exitCode !== 0) { + throw new Error( + `PocketJS 3ds: the ${CONTAINER_IMAGE} image is not present locally. Run:\n` + + ` docker pull ${CONTAINER_IMAGE}`, + ); + } + return image.stdout.trim(); +} + +/** + * The toolchain the Rust core builds with: hosts/3ds/core/rust-toolchain.toml + * owns the choice when it exists, otherwise plain nightly. -Z build-std needs + * rust-src for whichever one wins. + */ +async function preflightRust(): Promise<{ rustup: string; toolchain: string }> { + const rustup = Bun.which("rustup") ?? `${homedir()}/.cargo/bin/rustup`; + if (!existsSync(rustup)) { + throw new Error( + "PocketJS 3ds: rustup not found (expected ~/.cargo/bin/rustup). Install Rust from https://rustup.rs.", + ); + } + let toolchain = "nightly"; + if (existsSync(`${coreDirectory}rust-toolchain.toml`)) { + const active = await capture(rustup, ["show", "active-toolchain"], coreDirectory); + const named = active.stdout.trim().split(/\s+/)[0]; + if (active.exitCode === 0 && named) toolchain = named; + } + const rustc = await capture(rustup, ["run", toolchain, "rustc", "--version"]); + if (rustc.exitCode !== 0) { + throw new Error( + `PocketJS 3ds: the ${toolchain} toolchain is not installed. Run:\n` + + ` rustup toolchain install ${toolchain}`, + ); + } + const components = await capture(rustup, [ + "component", + "list", + "--toolchain", + toolchain, + "--installed", + ]); + if (!components.stdout.split(/\r?\n/).some((line) => line.startsWith("rust-src"))) { + throw new Error( + `PocketJS 3ds: rust-src is required to build core/alloc for ${RUST_TARGET}. Run:\n` + + ` rustup component add rust-src --toolchain ${toolchain}`, + ); + } + return { rustup, toolchain }; +} + +// --------------------------------------------------------------------------- +// QuickJS +// --------------------------------------------------------------------------- + +function quickJsSourceDirectory(): string { + const pinned = join(homedir(), QUICKJS_CHECKOUT); + if (existsSync(join(pinned, "quickjs.c"))) return pinned; + throw new Error( + `PocketJS 3ds: the pinned QuickJS sources are absent at ${pinned}. ` + + "They arrive with the PSP host's dependencies — run `cargo fetch` in hosts/psp/ " + + "(or `bun run bootstrap`) and retry.", + ); +} + +/** + * Compile QuickJS for the 3DS in the container and cache the archive. The + * stamp covers the sources, the flag set and the container image, so a new + * devkitARM release or an edited flag rebuilds and nothing else does. + */ +export async function ensureQuickJs( + cacheDirectory: string, + imageId: string, + mounts: readonly Mount[], +): Promise { + const sources = quickJsSourceDirectory(); + const files = [...QUICKJS_SOURCES, ...QUICKJS_HEADERS]; + const digest = createHash("sha256"); + digest.update(imageId); + digest.update(QUICKJS_COMPILE_FLAGS.join(" ")); + for (const name of files) { + const path = join(sources, name); + if (!existsSync(path)) { + throw new Error(`PocketJS 3ds: QuickJS source ${name} is missing from ${sources}`); + } + digest.update(name); + digest.update(readFileSync(path)); + } + const stamp = digest.digest("hex"); + const stampPath = join(cacheDirectory, ".stamp"); + const archive = join(cacheDirectory, "libquickjs.a"); + if ( + existsSync(archive) && + existsSync(stampPath) && + readFileSync(stampPath, "utf8").trim() === stamp + ) { + console.log(`PocketJS 3ds: QuickJS cached (${archive})`); + return; + } + + mkdirSync(cacheDirectory, { recursive: true }); + for (const name of files) copyFileSync(join(sources, name), join(cacheDirectory, name)); + const objects = QUICKJS_SOURCES.map((name) => name.replace(/\.c$/, ".o")); + const script = [ + "rm -f *.o libquickjs.a", + `for src in ${QUICKJS_SOURCES.join(" ")}; do`, + ' echo "cc $src"', + ` arm-none-eabi-gcc ${QUICKJS_COMPILE_FLAGS.join(" ")} -c "$src" -o "\${src%.c}.o"`, + "done", + // D: deterministic archive (zeroed mtime/uid/gid), so the cache stamp and + // the archive agree run to run. + `arm-none-eabi-ar rcsD libquickjs.a ${objects.join(" ")}`, + ].join("\n"); + console.log("PocketJS 3ds: compiling QuickJS for armv6k-nintendo-3ds …"); + await runContainer( + script, + mounts, + containerPathFor(cacheDirectory, mounts), + {}, + "QuickJS compile", + ); + if (!existsSync(archive)) { + throw new Error(`PocketJS 3ds: QuickJS compile did not produce ${archive}`); + } + writeFileSync(stampPath, `${stamp}\n`); +} + +// --------------------------------------------------------------------------- +// Build plan +// --------------------------------------------------------------------------- + +function assert3dsPlan(plan: ResolvedBuildPlan, origin: string): ResolvedBuildPlan { + if (!verifyPlanHash(plan) || plan.target.id !== TARGET_ID) { + throw new Error(`PocketJS 3ds: invalid ${TARGET_ID} ResolvedBuildPlan at ${origin}`); + } + return plan; +} + +async function loadBuildPlan( + args: ThreeDsArguments, +): Promise<{ plan: ResolvedBuildPlan; planPath: string }> { + if (args.planPath) { + if (args.configFlagged || !args.useConfig) { + throw new Error("PocketJS 3ds: config overrides are forbidden with --plan"); + } + const plan = (await Bun.file(args.planPath).json()) as ResolvedBuildPlan; + return { plan: assert3dsPlan(plan, args.planPath), planPath: args.planPath }; + } + // An app outside this repository is named relative to --project-root. + const candidates = [ + ...new Set([ + join(args.projectRoot, "apps", args.app, "pocket.json"), + `${repository}apps/${args.app}/pocket.json`, + ]), + ]; + const manifest = candidates.find((path) => existsSync(path)); + if (!manifest) { + throw new Error( + `PocketJS 3ds: no manifest for "${args.app}" (looked in ${candidates.join(", ")}). ` + + "A 3DS app declares its own 400x240 native viewport; the stock " + + "integer-fit demos cannot be admitted.", + ); + } + const plan = assert3dsPlan( + resolve3dsBuildPlan(JSON.parse(readFileSync(manifest, "utf8"))), + manifest, + ); + const planPath = `${repository}.pocket/3ds/${plan.app.output}.plan.json`; + mkdirSync(resolvePath(planPath, ".."), { recursive: true }); + writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`); + return { plan, planPath }; +} + +// --------------------------------------------------------------------------- +// The pipeline +// --------------------------------------------------------------------------- + +export async function build3ds(argv: readonly string[]): Promise { + const args = parse3dsArguments(argv); + if (!args.app && !args.planPath) throw new Error(USAGE); + if (!existsSync(hostDirectory)) { + throw new Error(`PocketJS 3ds: the host is absent at ${hostDirectory}`); + } + + const imageId = await preflightContainer(); + const { rustup, toolchain } = await preflightRust(); + const { plan, planPath } = await loadBuildPlan(args); + const inputs = extractHostBuildInputs(plan, { expectedTarget: TARGET_ID }); + + // 1. guest bundle + pak + console.log(`PocketJS 3ds: building app "${plan.app.output}" (${plan.app.framework})`); + mkdirSync(args.outputDir, { recursive: true }); + if (!args.skipBuild) { + await $`bun tools/build.ts --plan=${planPath} --project-root=${args.projectRoot} --outdir=${args.outputDir} ${args.buildFlags}` + .cwd(repository); + } + const guestJavaScript = join(args.outputDir, `${inputs.appOutput}.js`); + const guestPack = join(args.outputDir, `${inputs.appOutput}.pak`); + for (const artifact of [guestJavaScript, guestPack]) { + if (!existsSync(artifact)) { + throw new Error(`PocketJS 3ds: the guest build did not produce ${artifact}`); + } + } + + // 2. the Rust core staticlib, on macOS + console.log(`PocketJS 3ds: cargo build --release (${RUST_TARGET}, ${toolchain})`); + await $`${rustup} run ${toolchain} cargo build --release ${args.cargoArgs}` + .cwd(coreDirectory) + .env({ + ...process.env, + ...hostBuildEnvironment(inputs, { + outputDirectory: args.outputDir, + embedApp: true, + }), + }); + const releaseDirectory = `${coreDirectory}target/${RUST_TARGET}/release`; + const coreLibrary = join(releaseDirectory, CORE_STATIC_LIBRARY); + if (!existsSync(coreLibrary)) { + const found = existsSync(releaseDirectory) + ? readdirSync(releaseDirectory).filter((name) => name.endsWith(".a")) + : []; + throw new Error( + `PocketJS 3ds: ${CORE_STATIC_LIBRARY} is absent from ${releaseDirectory}` + + (found.length > 0 ? ` (found ${found.join(", ")})` : "") + + " — hosts/3ds/core must be a staticlib crate named pocketjs-3ds-core", + ); + } + + // 3-4. everything that needs devkitARM + const distributionRoot = `${repository}dist/3ds`; + const quickJsDirectory = join(distributionRoot, "quickjs"); + const buildDirectory = join(distributionRoot, "build"); + mkdirSync(buildDirectory, { recursive: true }); + mkdirSync(args.packageDir, { recursive: true }); + + const mounts: Mount[] = [ + { hostPath: repository, containerPath: CONTAINER_REPOSITORY }, + ]; + const outsideRepository = [args.outputDir, args.packageDir, args.projectRoot].filter( + (path) => !resolvePath(path).startsWith(`${resolvePath(repository)}/`), + ); + if (outsideRepository.length > 0) { + // One extra mount covers an app built outside the repository; a second + // distinct root would need its own and is refused by containerPathFor. + mounts.push({ + hostPath: resolvePath(outsideRepository[0]), + containerPath: CONTAINER_OUTPUT, + }); + } + + await ensureQuickJs(quickJsDirectory, imageId, mounts); + + const output = join(args.packageDir, `${inputs.appOutput}.3dsx`); + const makeEnvironment: Record = { + ...hostBuildEnvironment(inputs, { + outputDirectory: containerPathFor(args.outputDir, mounts), + embedApp: true, + }), + POCKETJS_CORE_LIB: containerPathFor(coreLibrary, mounts), + POCKETJS_QUICKJS_DIR: containerPathFor(quickJsDirectory, mounts), + POCKETJS_APP_JS: containerPathFor(guestJavaScript, mounts), + POCKETJS_APP_PAK: containerPathFor(guestPack, mounts), + POCKETJS_BUILD_DIR: containerPathFor(buildDirectory, mounts), + POCKETJS_OUT_3DSX: containerPathFor(output, mounts), + POCKETJS_SMDH_TITLE: plan.app.title, + POCKETJS_SMDH_AUTHOR: plan.app.id, + POCKETJS_SMDH_DESC: `PocketJS ${plan.app.title}`, + POCKETJS_CAPTURE: args.capture ? "1" : "", + // Explicit so a previous run's tape never lingers in the object cache. + POCKETJS_CAPTURE_INPUT: process.env.POCKETJS_CAPTURE_INPUT ?? "", + POCKETJS_CAP_START: process.env.POCKETJS_CAP_START ?? "", + POCKETJS_CAP_N: process.env.POCKETJS_CAP_N ?? "", + }; + + console.log(`PocketJS 3ds: make (${CONTAINER_IMAGE}${args.capture ? ", capture" : ""})`); + await runContainer( + `make -j${availableParallelism()}`, + mounts, + containerPathFor(hostDirectory, mounts), + makeEnvironment, + "hosts/3ds/Makefile", + ); + if (!existsSync(output)) { + throw new Error(`PocketJS 3ds: the container build did not produce ${output}`); + } + console.log(`output: ${output}`); + return output; +} + +if (import.meta.main) { + try { + await build3ds(Bun.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/tools/test.ts b/tools/test.ts index 82bd6dd2..d38824bc 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -46,6 +46,7 @@ const SUITE: readonly Stage[] = [ "tests/note.test.ts", "tests/site-stage.test.ts", "tests/host-build-inputs.test.ts", + "tests/3ds-profile.test.ts", "tests/iphone2g-profile.test.ts", "tests/iphone2g-device-contract.test.ts", "tests/iphone2g-toolchain.test.ts", From 2dca4d8829729bbdb8d687d8c5203901b44c639d Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:39:19 +0800 Subject: [PATCH 2/2] fix(3ds): capture through an RGB8 transfer, and add CIA output The capture display transfer asked the PPF for a 32-bit linear output out of the 240x400 tiled colour buffer. Azahar's software rasterizer answers that correctly, which is why the committed goldens are right, but a hardware renderer returns rows that are individually correct and progressively misregistered: the same build came back shredded under Vulkan. Transfer with GX_TRANSFER_FMT_RGB8 instead, the format citro3d's own presentation transfer uses, and widen B,G,R into the A,B,G,R capture word on device so the driver's decode is unchanged. Re-recorded goldens are byte-identical to the old ones. A Vulkan capture now decodes to the correct screen; it still differs from the software goldens on 5.1% of pixels, 99.5% of them by 1 or 2 of 255, so the backend pin stays and E2E_AZAHAR_GRAPHICS_API re-measures the gap. --cia writes dist/3ds/.cia from the same ELF and the same staged romfs directory. A .3dsx inherits the Homebrew Launcher's memory allocation; a CIA is its own title and asks for its own region through hosts/3ds/app.rsf's SystemMode: 64MB. makerom ships in neither devkitPro nor Homebrew, so it is cloned shallow, built in the container and cached the way libquickjs.a is. Title, product code and unique id are derived from the resolved plan, with the unique id inside the 0xFF000-0xFFFFF homebrew block. Co-Authored-By: Claude Opus 5 (1M context) --- hosts/3ds/Makefile | 51 ++++++++- hosts/3ds/README.md | 87 +++++++++++++- hosts/3ds/app.rsf | 231 ++++++++++++++++++++++++++++++++++++++ hosts/3ds/src/main.c | 44 ++++++-- tests/3ds-profile.test.ts | 71 ++++++++++++ tests/e2e/azahar.ts | 36 +++--- tools/3ds.ts | 192 ++++++++++++++++++++++++++++++- 7 files changed, 679 insertions(+), 33 deletions(-) create mode 100644 hosts/3ds/app.rsf diff --git a/hosts/3ds/Makefile b/hosts/3ds/Makefile index 713fed25..0f4d632b 100644 --- a/hosts/3ds/Makefile +++ b/hosts/3ds/Makefile @@ -23,10 +23,15 @@ # POCKETJS_CAPTURE_INPUT baked input tape, "frame:mask,frame:mask" # POCKETJS_CAP_START first frame to dump # POCKETJS_CAP_N how many frames to dump +# POCKETJS_OUT_CIA the .cia to write; empty leaves the goal off +# POCKETJS_MAKEROM the makerom binary tools/3ds.ts built and cached +# POCKETJS_CIA_TITLE exheader process name (8 bytes) +# POCKETJS_CIA_PRODUCT CTR-P-XXXX product code +# POCKETJS_CIA_UNIQUE_ID title id unique part, e.g. 0xFF3D0 # # The tape and the capture window travel INSIDE the binary: a capture run never # reads the emulator's filesystem for its input. Nothing is written outside -# POCKETJS_BUILD_DIR and POCKETJS_OUT_3DSX. +# POCKETJS_BUILD_DIR, POCKETJS_OUT_3DSX and POCKETJS_OUT_CIA. DEVKITPRO ?= /opt/devkitpro DEVKITARM ?= $(DEVKITPRO)/devkitARM @@ -39,6 +44,8 @@ OUT := $(POCKETJS_OUT_3DSX) # are staged under one. ROMFS := $(BUILD)/romfs ICON ?= $(CURDIR)/icon.png +CIA := $(POCKETJS_OUT_CIA) +RSF := $(CURDIR)/app.rsf CC := $(DEVKITARM)/bin/arm-none-eabi-gcc PICASSO := $(DEVKITPRO)/tools/bin/picasso @@ -84,6 +91,9 @@ SMDH := $(BUILD)/pocketjs-3ds.smdh .PHONY: all clean all: $(OUT) +ifneq ($(CIA),) +all: $(CIA) +endif $(BUILD) $(ROMFS): mkdir -p $@ @@ -136,5 +146,42 @@ $(OUT): $(ELF) $(SMDH) $(ROMFS)/app.js $(ROMFS)/app.pak mkdir -p $(dir $(OUT)) $(THREEDSXTOOL) $(ELF) $@ --romfs=$(ROMFS) --smdh=$(SMDH) +ifneq ($(CIA),) +# The CIA's identity arrives as -D substitutions, not in a file, and make cannot +# see a changed environment variable — the same trap FLAGS_STAMP covers for +# CFLAGS. A stamp carrying the three values makes a renamed or re-issued title +# rebuild the .cia. +CIA_ARGS := $(POCKETJS_CIA_TITLE) $(POCKETJS_CIA_PRODUCT) $(POCKETJS_CIA_UNIQUE_ID) +CIA_STAMP := $(BUILD)/cia.stamp +.PHONY: $(CIA_STAMP).probe +$(CIA_STAMP).probe: | $(BUILD) + @printf '%s\n' "$(CIA_ARGS)" > $(CIA_STAMP).new + @cmp -s $(CIA_STAMP).new $(CIA_STAMP) || mv $(CIA_STAMP).new $(CIA_STAMP) + @rm -f $(CIA_STAMP).new +$(CIA_STAMP): $(CIA_STAMP).probe ; + +# makerom, not 3dsxtool: a CIA is its own installed title, so it carries the +# exheader that asks for the 64MB application memory region (app.rsf), which a +# .3dsx cannot do — it inherits whatever the Homebrew Launcher was given. +# +# The romfs is the SAME staged directory the .3dsx embeds, and makerom builds +# the image itself from RomFs.RootPath. It rejects a prebuilt raw romfs binary +# (the container format 3dsxtool takes) with "Invalid RomFS Binary", so there is +# nothing to share but the directory. +# +# -target t is the test target: makerom signs with the fixed test keys rather +# than Nintendo's retail ones, which is what Azahar installs. No banner is +# passed — makerom needs one only for a title that plays an animated banner in +# HOME Menu, and the SMDH already carries the icon and the title strings. +$(CIA): $(ELF) $(SMDH) $(ROMFS)/app.js $(ROMFS)/app.pak $(RSF) $(CIA_STAMP) + mkdir -p $(dir $(CIA)) + $(POCKETJS_MAKEROM) -f cia -o $@ -rsf $(RSF) -target t -exefslogo \ + -DAPP_TITLE="$(POCKETJS_CIA_TITLE)" \ + -DAPP_PRODUCT_CODE="$(POCKETJS_CIA_PRODUCT)" \ + -DAPP_UNIQUE_ID=$(POCKETJS_CIA_UNIQUE_ID) \ + -DAPP_ROMFS=$(ROMFS) \ + -elf $(ELF) -icon $(SMDH) +endif + clean: - rm -rf $(BUILD) $(OUT) + rm -rf $(BUILD) $(OUT) $(CIA) diff --git a/hosts/3ds/README.md b/hosts/3ds/README.md index 335f0b51..ae9a91da 100644 --- a/hosts/3ds/README.md +++ b/hosts/3ds/README.md @@ -25,6 +25,7 @@ src/qjs.c QuickJS embedding: globalThis.ui -> ui_* calls src/input.c 3DS keys and circle pad -> the PSP BTN bitmask src/vshader.v.pica the PICA200 vertex shader Makefile run INSIDE the container by tools/3ds.ts +app.rsf the CIA descriptor makerom reads icon.png 48x48 SMDH icon ``` @@ -46,6 +47,7 @@ reaches outside `hosts/3ds` except through them. ```sh bun tools/3ds.ts 3ds-demo # dist/3ds/.3dsx bun tools/3ds.ts 3ds-demo --capture # the deterministic e2e binary +bun tools/3ds.ts 3ds-demo --cia # also dist/3ds/.cia ``` Two build-time facts are load-bearing: @@ -60,6 +62,62 @@ Two build-time facts are load-bearing: thread 32 KiB, and QuickJS's interpreter plus the guest's render pass recurse far past that. +## The CIA, and the memory region it asks for + +`--cia` writes `dist/3ds/.cia` next to the `.3dsx`, from the same ELF +and the same staged romfs directory. + +**A `.3dsx` runs under the Homebrew Launcher and lives inside hbmenu's memory +allocation. A CIA is its own installed title and asks the kernel for its own +memory region.** That request is `SystemMode: 64MB` in `app.rsf` — the largest +region an Old 3DS gives an application, out of the console's 128 MiB — plus +`SystemModeExt: 124MB`, which a New 3DS honours and an Old 3DS ignores. A guest +whose arena, expanded textures and pak add up past what hbmenu hands out has no +way to ask for more as a `.3dsx`. That is why the format is here: Pocket Voxel's +12 MiB arena plus ~14 MiB of expanded textures plus a 30.6 MiB pak is exactly +the budget that may not fit under the Homebrew Launcher on a real console. + +Three facts about the packaging itself: + +- **No banner is required.** makerom needs one only for a title that plays an + animated banner in HOME Menu. The SMDH passed as `-icon` already carries the + icon and the title strings, and `-exefslogo` supplies the boot logo. +- **The romfs is a directory, not an image.** makerom builds the romfs itself + from `RomFs.RootPath`, pointed at the same directory 3dsxtool embeds. Handing + it the raw romfs binary that `mkromfs3ds` produces — the container 3dsxtool + takes — fails with `Invalid RomFS Binary`; the two packagers share the staged + directory and nothing else. +- **makerom ships in neither devkitPro nor Homebrew**, so `tools/3ds.ts` clones + `github.com/3DSGuy/Project_CTR` shallow into `dist/3ds/makerom/src`, builds it + in the same container as everything else, and caches the binary against the + container image and the checked out revision. mbedtls, blz and yaml are + vendored in that repository, so the clone is the only step that needs the + network. + +The title's identity comes from the resolved plan, never from a literal per app +(`ciaUniqueId`, `ciaProductCode`, `ciaProcessName` in `tools/3ds.ts`): the +**unique id is `0xFF000 | hash(app.id) & 0xFFF`**, inside the `0xFF000-0xFFFFF` +block that no retail or system title uses, so an app keeps one title id across +rebuilds and an install replaces its predecessor instead of accumulating. The +product code is `CTR-P-` plus four characters of the app id. The RSF's +`BasicInfo.Title` is the exheader's process name, which is 8 bytes — the cut +happens in TypeScript rather than silently inside makerom, and the title HOME +Menu shows is the SMDH's, still whole. + +Azahar installs one and then boots the installed title from its own SD card: + +```sh +azahar -i dist/3ds/pocket3ds-demo-main.cia +azahar "$HOME/Library/Application Support/Azahar/sdmc/Nintendo 3DS/\ +00000000000000000000000000000000/00000000000000000000000000000000/\ +title/00040000/0ffc1900/content/0429b6bc.app" +``` + +`00040000` is the application category and `0ffc1900` is this demo's unique id +shifted up by its 8-bit variation; `tools/3ds.ts` prints the whole title id when +it writes the file. A capture build installed and booted this way produced +frames **byte-identical to the `.3dsx` goldens** in `tests/goldens/3ds/`. + ## What `globalThis.ui` has to publish Beyond the HostOps table, `src/qjs.c` publishes four properties the framework @@ -131,16 +189,33 @@ finished. The bytes stay in the screen's rotated orientation, 240 wide by 400 tall, so the driver decodes `src[(x * 240 + (239 - y)) * 4]` into `dst[y * 400 + x]` and reads the channels back as A, B, G, R. +**That transfer's output format is `GX_TRANSFER_FMT_RGB8`, and `main.c` widens +B, G, R into the A, B, G, R capture word itself.** Asking the transfer engine +for a 32-bit linear output out of this 240x400 tiled colour buffer returns rows +that are each individually correct and progressively misregistered — every +fourth output row slips a further 64 texels — while the same frame presents +perfectly on the screen. Azahar's software rasterizer answers the 32-bit request +correctly, so the wrong format is invisible until something renders through a +GPU: the identical build and the identical CIA both came back shredded under +Vulkan. Measured in the Pocket Voxel host against a known probe rectangle, +RGBA8 out matched 74.6% of it and RGB8 out matched 100.0%. RGB8 is also the +format citro3d's own presentation transfer uses, so the capture travels the path +the screen travels; the alpha byte it drops was never read, because the decode +takes R, G and B only. + ```sh bun tests/e2e/azahar.ts ``` -**Azahar's two renderers do not agree.** The same build and the same frame -differed in **48.7% of pixels** between Software (`graphics_api=0`) and Vulkan -(`graphics_api=2`) on an Apple M3 Max: under Vulkan small quads came back as -periodic bands while Software reproduced the geometry exactly. A golden -therefore belongs to one backend, and the e2e fixture pins it. Two independent -Software runs of the demo produced **20 byte-identical frames**. +**Azahar's two renderers agree on the picture but not on every byte.** With the +RGB8 readback in place, a Vulkan (`graphics_api=2`) capture of the demo differs +from the committed Software (`graphics_api=0`) goldens on **5.1% of pixels, +99.5% of them by 1 or 2 of 255** — the two rasterizers round texture filtering +and TEV blending differently — plus **24 pixels along the logo's one diagonal +edge**, by up to 157. A golden therefore still belongs to one backend, and the +e2e fixture pins it to Software, the backend that does not depend on the +developer's GPU driver. `E2E_AZAHAR_GRAPHICS_API=2 bun tests/e2e/azahar.ts` +re-measures the gap. Azahar derives its whole user directory from `$HOME` and has no switch for any part of it, so a run gets its own config and SD card by getting its own `$HOME`. diff --git a/hosts/3ds/app.rsf b/hosts/3ds/app.rsf new file mode 100644 index 00000000..d4dafbc0 --- /dev/null +++ b/hosts/3ds/app.rsf @@ -0,0 +1,231 @@ +# PocketJS Nintendo 3DS CIA descriptor — read by makerom, invoked from +# hosts/3ds/Makefile with the four values below supplied as -DNAME=VALUE: +# +# APP_TITLE the manifest title, cut to the 8 bytes the exheader holds +# APP_PRODUCT_CODE CTR-P-XXXX, derived from the app id +# APP_UNIQUE_ID the title id's unique part, derived from the app id +# APP_ROMFS the directory whose contents become romfs:/ +# +# tools/3ds.ts computes all four from the resolved plan; nothing here is per-app. +# +# Why a CIA exists in this tree at all: a .3dsx runs under the Homebrew Launcher +# and lives inside hbmenu's memory allocation. **A CIA is its own title and asks +# the kernel for its own memory region through SystemMode below.** A guest whose +# arena, expanded textures and pak add up past what hbmenu hands out has no way +# to ask for more as a .3dsx. + +BasicInfo: + # The exheader's process name field is 8 bytes and makerom truncates to it. + # The title HOME Menu shows comes from the SMDH instead (--icon), which + # carries the manifest's full string. + Title : "$(APP_TITLE)" + ProductCode : "$(APP_PRODUCT_CODE)" + # The ExeFS "logo" section, which the loader shows while the title boots. + # Homebrew is the one makerom itself ships; -exefslogo selects it. + Logo : Homebrew + +TitleInfo: + Category : Application + # Unique ids 0xFF000-0xFFFFF are the homebrew block: no retail or system + # title uses them, so an installed CIA can never collide with a real one. + # tools/3ds.ts hashes the manifest's app id into the low 12 bits, so an app + # keeps its title id across rebuilds and two apps do not overwrite each + # other's save data or installed content. + UniqueId : $(APP_UNIQUE_ID) + +Option: + UseOnSD : true + FreeProductCode : true + MediaFootPadding : false + EnableCrypt : false + EnableCompress : true + +AccessControlInfo: + CoreVersion : 2 + DescVersion : 2 + ReleaseKernelMajor : "02" + ReleaseKernelMinor : "33" + MemoryType : Application + # **The memory region this title runs in.** 64MB is the largest an Old 3DS + # application may take; the remaining 64 MiB of the console's 128 MiB is the + # system region and is not offered. + SystemMode : 64MB + # A New 3DS has 256 MiB and can hand an application 124 MiB. A console + # without the extra memory ignores this and keeps the 64MB above. + SystemModeExt : 124MB + IdealProcessor : 0 + AffinityMask : 1 + Priority : 16 + MaxCpu : 0 + HandleTableSize : 512 + DisableDebug : true + EnableForceDebug : false + CanWriteSharedPage : true + CanUsePrivilegedPriority : false + CanUseNonAlphabetAndNumber : true + PermitMainFunctionArgument : true + CanShareDeviceMemory : true + RunnableOnSleep : false + SpecialMemoryArrange : true + ResourceLimitCategory : Application + FileSystemAccess: + - CategorySystemApplication + - CategoryHardwareCheck + - CategoryFileSystemTool + - Debug + - TwlCardBackup + - TwlNandData + - Boss + - DirectSdmc + - Core + - CtrNandRo + - CtrNandRw + - CtrNandRoWrite + - CategorySystemSettings + - CardBoard + - ExportImportIvs + - DirectSdmcWrite + - SwitchCleanup + - SaveDataMove + - Shop + - Shell + - CategoryHomeMenu + IoAccessControl: + - FsMountNand + - FsMountNandRoWrite + - FsMountTwln + - FsMountWnand + - FsMountCardSpi + - UseSdif3 + - CreateSeed + - UseCardSpi + SystemCallAccess: + ArbitrateAddress: 34 + Break: 60 + CancelTimer: 28 + ClearEvent: 25 + ClearTimer: 29 + CloseHandle: 35 + ConnectToPort: 45 + ControlMemory: 1 + CreateAddressArbiter: 33 + CreateEvent: 23 + CreateMemoryBlock: 30 + CreateMutex: 19 + CreateSemaphore: 21 + CreateThread: 8 + CreateTimer: 26 + DuplicateHandle: 39 + ExitProcess: 3 + ExitThread: 9 + GetCurrentProcessorNumber: 17 + GetHandleInfo: 41 + GetProcessId: 53 + GetProcessIdOfThread: 54 + GetProcessIdealProcessor: 6 + GetProcessInfo: 43 + GetResourceLimit: 56 + GetResourceLimitCurrentValues: 58 + GetResourceLimitLimitValues: 57 + GetSystemInfo: 42 + GetSystemTick: 40 + GetThreadContext: 59 + GetThreadId: 55 + GetThreadIdealProcessor: 15 + GetThreadInfo: 44 + GetThreadPriority: 11 + MapMemoryBlock: 31 + OutputDebugString: 61 + QueryMemory: 2 + ReleaseMutex: 20 + ReleaseSemaphore: 22 + SendSyncRequest1: 46 + SendSyncRequest2: 47 + SendSyncRequest3: 48 + SendSyncRequest4: 49 + SendSyncRequest: 50 + SetThreadPriority: 12 + SetTimer: 27 + SignalEvent: 24 + SleepThread: 10 + UnmapMemoryBlock: 32 + WaitSynchronization1: 36 + WaitSynchronizationN: 37 + InterruptNumbers: + ServiceAccessControl: + - APT:U + - ac:u + - am:net + - boss:U + - cam:u + - cecd:u + - cfg:nor + - cfg:u + - csnd:SND + - dsp::DSP + - frd:u + - fs:USER + - gsp::Gpu + - gsp::Lcd + - hid:USER + - http:C + - ir:rst + - ir:u + - ir:USER + - mic:u + - ndm:u + - news:u + - nwm::UDS + - ptm:sysm + - ptm:u + - pxi:dev + - soc:U + - ssl:C + - y2r:u + +SystemControlInfo: + SaveDataSize: 0K + RemasterVersion: 0 + # The main thread's stack. hosts/3ds/src/main.c raises the 3dsx crt0's + # 32 KiB with __stacksize__; a CIA takes the number from here instead. + StackSize: 0x40000 + Dependency: + ac: 0x0004013000002402 + am: 0x0004013000001502 + boss: 0x0004013000003402 + camera: 0x0004013000001602 + cfg: 0x0004013000001702 + codec: 0x0004013000001802 + csnd: 0x0004013000002702 + dlp: 0x0004013000002802 + dsp: 0x0004013000001a02 + friends: 0x0004013000003202 + gpio: 0x0004013000001b02 + gsp: 0x0004013000001c02 + hid: 0x0004013000001d02 + http: 0x0004013000002902 + i2c: 0x0004013000001e02 + ir: 0x0004013000003302 + mcu: 0x0004013000001f02 + mic: 0x0004013000002002 + ndm: 0x0004013000002b02 + news: 0x0004013000003502 + nfc: 0x0004013000004002 + nim: 0x0004013000002c02 + nwm: 0x0004013000002d02 + pdn: 0x0004013000002102 + ps: 0x0004013000003102 + ptm: 0x0004013000002202 + qtm: 0x0004013000004202 + ro: 0x0004013000003702 + socket: 0x0004013000002e02 + spi: 0x0004013000002302 + ssl: 0x0004013000002f02 + +# **makerom builds the romfs itself from this directory.** The raw romfs image +# that 3dsxtool embeds (mkromfs3ds output) is a different container and makerom +# rejects it outright with "Invalid RomFS Binary" — there is no way to hand the +# .3dsx's romfs across, so both packagers are pointed at the same staged +# directory instead. +RomFs: + RootPath: $(APP_ROMFS) diff --git a/hosts/3ds/src/main.c b/hosts/3ds/src/main.c index 73db93e0..b53692cb 100644 --- a/hosts/3ds/src/main.c +++ b/hosts/3ds/src/main.c @@ -119,7 +119,11 @@ static uint8_t *read_file(const char *path, size_t *length) { * never be confused with a previous run's (tests/e2e/azahar.ts). */ #define CAPTURE_DIR "sdmc:/pocketjs-captures" +/* The PPF's own RGB8 staging buffer, and the A,B,G,R buffer the file holds. */ +#define CAPTURE_RGB_BYTES ((size_t)VIEW_W * VIEW_H * 3) + static u32 *capture_buffer; +static u8 *capture_rgb; static const char CAPTURE_INPUT[] = POCKETJS_CAPTURE_INPUT; /* Read one unsigned value, decimal or 0x-prefixed hex, from [start, end). */ @@ -198,13 +202,24 @@ static int32_t scripted_buttons(uint32_t frame) { * * NOT gfxGetFramebuffer after C3D_FrameEnd: that buffer has already been * swapped and reads back black. An explicit display transfer untiles the - * PICA200 colour buffer into linear CPU-readable memory, and the result is - * byte-identical across runs and across Azahar's Software and Vulkan - * renderers. + * PICA200 colour buffer into linear CPU-readable memory. + * + * **The transfer's output format must be GX_TRANSFER_FMT_RGB8, not RGBA8.** + * Asking the PPF for a 32-bit linear output out of this 240x400 tiled colour + * buffer returns rows that are each individually correct and progressively + * misregistered — every fourth output row slips a further 64 texels — which + * reads as a shredded screen while the same frame presents perfectly. Azahar's + * software rasterizer happens to answer the 32-bit request correctly, so the + * bug only shows under a hardware renderer; measured against a known probe + * rectangle in the Pocket Voxel host, RGBA8 out matched 74.6% of it and RGB8 + * out matched 100.0%. RGB8 is also the format citro3d's own presentation + * transfer uses (DISPLAY_TRANSFER_FLAGS above), so the capture travels the + * path the screen travels. The dropped alpha byte was never read: the decode + * takes R, G and B only. * * The bytes stay in the rotated screen orientation — 240 wide by 400 tall, - * column-major — and each RGBA8 word is byte order A, B, G, R. The e2e driver - * decodes with src[(x * 240 + (239 - y)) * 4] -> dst[y * 400 + x]. + * column-major — and each capture word is byte order A, B, G, R. The e2e + * driver decodes with src[(x * 240 + (239 - y)) * 4] -> dst[y * 400 + x]. */ static bool capture_write(uint32_t frame) { /* C3D_FrameEnd only queues the frame. The colour buffer is not finished @@ -213,14 +228,24 @@ static bool capture_write(uint32_t frame) { C3D_SyncDisplayTransfer( (u32 *)target->frameBuf.colorBuf, GX_BUFFER_DIM(VIEW_H, VIEW_W), - capture_buffer, + (u32 *)capture_rgb, GX_BUFFER_DIM(VIEW_H, VIEW_W), GX_TRANSFER_FLIP_VERT(0) | GX_TRANSFER_OUT_TILED(0) | GX_TRANSFER_RAW_COPY(0) | GX_TRANSFER_IN_FORMAT(GX_TRANSFER_FMT_RGBA8) | - GX_TRANSFER_OUT_FORMAT(GX_TRANSFER_FMT_RGBA8) | + GX_TRANSFER_OUT_FORMAT(GX_TRANSFER_FMT_RGB8) | GX_TRANSFER_SCALING(GX_TRANSFER_SCALE_NO) ); - GSPGPU_InvalidateDataCache(capture_buffer, (s32)CAPTURE_BYTES); + GSPGPU_InvalidateDataCache(capture_rgb, (s32)CAPTURE_RGB_BYTES); + + /* Widen B, G, R back into the A, B, G, R word the golden format states, so + * the on-device format change costs the driver nothing. */ + uint8_t *out = (uint8_t *)capture_buffer; + for (size_t i = 0; i < (size_t)VIEW_W * VIEW_H; i += 1) { + out[i * 4 + 0] = 0xff; + out[i * 4 + 1] = capture_rgb[i * 3 + 0]; + out[i * 4 + 2] = capture_rgb[i * 3 + 1]; + out[i * 4 + 3] = capture_rgb[i * 3 + 2]; + } /* Named by the process-global frame counter, which is also what indexes the * baked input tape: input at frame N and file fN are the same frame. */ @@ -295,7 +320,8 @@ int main(void) { #ifdef POCKETJS_CAPTURE mkdir(CAPTURE_DIR, 0777); capture_buffer = linearAlloc(CAPTURE_BYTES); - if (capture_buffer == NULL) fail("capture buffer allocation failed"); + capture_rgb = linearAlloc(CAPTURE_RGB_BYTES); + if (capture_buffer == NULL || capture_rgb == NULL) fail("capture buffer allocation failed"); uint32_t frame = 0; #endif diff --git a/tests/3ds-profile.test.ts b/tests/3ds-profile.test.ts index 288c547b..cd78b01a 100644 --- a/tests/3ds-profile.test.ts +++ b/tests/3ds-profile.test.ts @@ -15,6 +15,12 @@ import { THREE_DS_DEV_TARGET_ID, THREE_DS_VIEWPORT, } from "../tools/3ds-profile.ts"; +import { + ciaProcessName, + ciaProductCode, + ciaTitleId, + ciaUniqueId, +} from "../tools/3ds.ts"; /** A guest app declaring the top screen exactly: 400x240 logical, native. */ function topScreenManifest(): Record { @@ -169,3 +175,68 @@ describe("private Nintendo 3DS build profile", () => { expect(qjs).toContain("ui_viewport_height()"); }); }); + +describe("CIA title identity", () => { + const APP = "dev.pocket-stack.3ds-demo"; + + test("puts the unique id in the homebrew block and keeps it stable", () => { + // 0xFF000-0xFFFFF is the range no retail or system title is assigned, so an + // installed CIA cannot collide with one the console already has. + for (const app of [APP, "dev.pocket-stack.voxel", "a", ""]) { + const unique = Number.parseInt(ciaUniqueId(app), 16); + expect(unique).toBeGreaterThanOrEqual(0xff000); + expect(unique).toBeLessThanOrEqual(0xfffff); + } + // Derived, so a rebuild replaces the installed title instead of adding one. + expect(ciaUniqueId(APP)).toBe(ciaUniqueId(APP)); + expect(ciaUniqueId(APP)).not.toBe(ciaUniqueId("dev.pocket-stack.voxel")); + }); + + test("names the directory the installed title lands in", () => { + // 00040000 is the application category; the low word is the unique id + // shifted up by the 8-bit variation, which is 0. + const unique = Number.parseInt(ciaUniqueId(APP), 16); + expect(ciaTitleId(APP)).toBe(`00040000${((unique << 8) >>> 0).toString(16).padStart(8, "0")}`); + }); + + test("emits a product code makerom accepts without FreeProductCode", () => { + // makerom's IsValidProductCode: 10..16 characters, CTR or KTR, '-' at 3 and + // 5, digits or uppercase letters elsewhere. + for (const app of [APP, "x", "dev.pocket-stack.a-b", "UPPER.case.9"]) { + expect(ciaProductCode(app)).toMatch(/^CTR-[A-Z0-9]-[A-Z0-9]{4}$/); + } + expect(ciaProductCode(APP)).toBe("CTR-P-3DSD"); + }); + + test("cuts the process name to the 8 bytes the exheader holds", () => { + // makerom truncates BasicInfo.Title to 8 silently; doing it here keeps the + // cut visible. The SMDH still carries the manifest title whole. + expect(ciaProcessName("PocketJS: 3DS Top Screen", APP)).toBe("PocketJS"); + expect(ciaProcessName("", APP)).not.toBe(""); + // Characters that would end the RSF's quoted scalar or open another + // substitution are dropped before the cut. + expect(ciaProcessName('a"b\\c$d', APP)).toBe("abcd"); + for (const title of ["", "字", 'a"b', "a".repeat(40)]) { + const name = ciaProcessName(title, APP); + expect(new TextEncoder().encode(name).length).toBeLessThanOrEqual(8); + expect(name).toMatch(/^[\x20-\x7e]+$/); + } + }); + + test("the RSF asks for the memory region that is the point of a CIA", () => { + const rsf = readFileSync( + join(new URL("..", import.meta.url).pathname, "hosts/3ds/app.rsf"), + "utf8", + ); + // A .3dsx inherits hbmenu's allocation; a CIA asks for its own region. + expect(rsf).toMatch(/^\s+SystemMode\s+: 64MB$/m); + expect(rsf).toMatch(/^\s+SystemModeExt\s+: 124MB$/m); + // The four values hosts/3ds/Makefile substitutes; a rename breaks here. + for (const name of ["APP_TITLE", "APP_PRODUCT_CODE", "APP_UNIQUE_ID", "APP_ROMFS"]) { + expect(rsf).toContain(`$(${name})`); + } + // makerom builds the romfs from a directory; the raw image 3dsxtool embeds + // is rejected as "Invalid RomFS Binary". + expect(rsf).toMatch(/^RomFs:\n {2}RootPath: \$\(APP_ROMFS\)$/m); + }); +}); diff --git a/tests/e2e/azahar.ts b/tests/e2e/azahar.ts index f5003d03..3e3d9282 100644 --- a/tests/e2e/azahar.ts +++ b/tests/e2e/azahar.ts @@ -8,17 +8,24 @@ // // Environment: AZAHAR (the .app bundle), AZAHAR_CONFIG (the settings to clone), // E2E_AZAHAR_APP (one spec name instead of the default set), E2E_AZAHAR_3DSX -// (run a .3dsx that is already built), E2E_AZAHAR_TIMEOUT_MS. +// (run a .3dsx that is already built), E2E_AZAHAR_TIMEOUT_MS, +// E2E_AZAHAR_GRAPHICS_API (0 software, 2 Vulkan). // // Determinism: the core steps a fixed dt (contracts/spec/spec.ts FIXED_DT) and // the baked input tape is indexed by the same frame counter that names the // dumped files, so a frame is a pure function of its index. The capture is a GX // display transfer of the PICA200 render target — a real GPU readback, not a -// CPU oracle — and it is byte-identical run to run under one renderer. It is -// NOT identical between renderers: a shaded triangle differed on every measured -// frame between Software (graphics_api=0) and Vulkan (graphics_api=2), 34% of -// pixels on the first, so the fixture pins the backend and a golden belongs to -// the pinned one. +// CPU oracle — and it is byte-identical run to run under one renderer. +// +// It is NOT byte-identical BETWEEN renderers, so the fixture pins one and a +// golden belongs to the pinned one. Measured on Azahar 2125.1.2 with the RGB8 +// readback in place, Vulkan (graphics_api=2) against these Software +// (graphics_api=0) goldens: 5.1% of pixels differ, 99.5% of those by 1 or 2 of +// 255 — the two rasterizers round texture filtering and TEV blending +// differently — and 24 pixels along the logo's one diagonal edge differ by more, +// up to 157. Both renderers produce the same picture; only Software produces it +// the same way on every machine, which is why it is the pin. +// E2E_AZAHAR_GRAPHICS_API=2 re-measures that. // // Azahar has no headless mode, ignores SIGTERM, and does not exit when the // guest returns from main(); the driver therefore owns both its lifetime @@ -62,6 +69,9 @@ const sourceUserDir = sourceConfig.replace(/\/config\/[^/]+$/, ""); // Set to run a .3dsx that is already built (the tools/3ds.ts build is skipped). const prebuilt = process.env.E2E_AZAHAR_3DSX; const romDir = process.env.E2E_AZAHAR_ROM_DIR ?? `${ROOT}dist/3ds`; +// Azahar's renderer: 0 software, 2 Vulkan. The default is the software +// rasterizer, which is the same on every machine. +const graphicsApi = process.env.E2E_AZAHAR_GRAPHICS_API ?? "0"; // The 3DS top screen is 400x240; the stock 480x272 demo corpus does not fit it // on either axis and the resolver has no scaling fallback, so this driver runs @@ -137,12 +147,12 @@ function writeFixture(): void { ? config.replace(new RegExp(`^${key}\\\\default=.*$`, "m"), () => `${key}\\default=false`) : config.replace(new RegExp(`^${key}=.*$`, "m"), () => `${key}=${value}\n${key}\\default=false`); }; - // The renderers do not agree: the same capture hashed differently under - // Software and Vulkan on every measured frame, while each backend was - // byte-stable across runs. Goldens therefore belong to one backend, and it is - // the software rasterizer — the one that does not depend on the developer's - // GPU driver. - set("graphics_api", "0"); + // The renderers agree on the picture but not on every byte: a Vulkan capture + // differs from these goldens on 5.1% of pixels, almost all by 1 or 2 of 255 + // (see the header). Goldens therefore belong to one backend, and it is the + // software rasterizer — the one that does not depend on the developer's GPU + // driver. + set("graphics_api", graphicsApi); // The capture transfers a 240x400 render target; any internal upscale changes // what comes back. set("resolution_factor", "1"); @@ -275,7 +285,7 @@ try { mkdirSync(GOLDENS, { recursive: true }); // Emulator provenance: byte-exact goldens are only promised for the Azahar // build and the renderer they were recorded with. -const buildStamp = `${Bun.spawnSync([azaharBinary, "--version"]).stdout.toString().trim()}, graphics_api=0`; +const buildStamp = `${Bun.spawnSync([azaharBinary, "--version"]).stdout.toString().trim()}, graphics_api=${graphicsApi}`; const stampPath = `${GOLDENS}/AZAHAR-BUILD.txt`; const recordedStamp = existsSync(stampPath) ? readFileSync(stampPath, "utf8").trim() : null; let passed = 0; diff --git a/tools/3ds.ts b/tools/3ds.ts index 2629c871..67c0edf4 100644 --- a/tools/3ds.ts +++ b/tools/3ds.ts @@ -4,6 +4,7 @@ // // bun tools/3ds.ts 3ds-demo // bun tools/3ds.ts 3ds-demo --capture (e2e frame-dump build) +// bun tools/3ds.ts 3ds-demo --cia (also emit an installable CIA) // bun tools/3ds.ts --plan= --project-root= // // The toolchain spans two environments. The Rust half compiles on macOS: @@ -17,7 +18,9 @@ // 2. cargo build --release -> hosts/3ds/core/target/armv6k-nintendo-3ds/release/ // libpocketjs_3ds_core.a (macOS) // 3. QuickJS -> dist/3ds/quickjs/libquickjs.a (container, cached) +// 3b. makerom (--cia) -> dist/3ds/makerom/bin/makerom (container, cached) // 4. hosts/3ds/Makefile -> dist/3ds/.3dsx (container) +// dist/3ds/.cia under --cia // // is the resolved plan's app.output, not the bare app argument. // @@ -52,6 +55,14 @@ // POCKETJS_CAP_START first frame to dump // POCKETJS_CAP_N how many frames to dump // +// and, only under --cia, the five the CIA goal needs: +// +// POCKETJS_OUT_CIA the .cia path to write ("" disables the goal) +// POCKETJS_MAKEROM the makerom binary built by ensureMakerom() +// POCKETJS_CIA_TITLE exheader process name, the manifest title cut to 8 B +// POCKETJS_CIA_PRODUCT CTR-P-XXXX product code +// POCKETJS_CIA_UNIQUE_ID title id unique part, e.g. 0xFF3D0 +// // The default goal must produce POCKETJS_OUT_3DSX and nothing outside // POCKETJS_BUILD_DIR and dist/3ds/. @@ -63,6 +74,7 @@ import { mkdirSync, readFileSync, readdirSync, + rmSync, writeFileSync, } from "node:fs"; import { availableParallelism, homedir } from "node:os"; @@ -118,6 +130,13 @@ const QUICKJS_HEADERS = [ "quickjs.h", ] as const; +// makerom is what turns the ELF into an installable title. It ships in neither +// devkitPro nor Homebrew, so --cia clones and builds it: every dependency +// (mbedtls, blz, yaml) is vendored in the repository, so the clone is the only +// step that needs the network and the build runs in the same offline container +// as everything else. +const MAKEROM_REPOSITORY = "https://github.com/3DSGuy/Project_CTR"; + /** The devkitARM ABI, published by the toolchain itself in 3dsvars.sh. */ const ARM_ARCHITECTURE_FLAGS = [ "-march=armv6k", @@ -169,6 +188,8 @@ export interface ThreeDsArguments { readonly packageDir: string; readonly skipBuild: boolean; readonly capture: boolean; + /** Also package the ELF as an installable CIA title. */ + readonly cia: boolean; readonly configPath: string; readonly configFlagged: boolean; readonly useConfig: boolean; @@ -195,6 +216,7 @@ export function parse3dsArguments( let packageDir = `${root}dist/3ds`; let skipBuild = false; let capture = false; + let cia = false; let configPath = `${root}pocket.config.ts`; let configFlagged = false; let useConfig = true; @@ -203,6 +225,7 @@ export function parse3dsArguments( for (const a of argv) { if (a === "--capture") capture = true; + else if (a === "--cia") cia = true; else if (a === "--skip-build") skipBuild = true; else if (a.startsWith("--plan=")) planPath = resolvePath(a.slice("--plan=".length)); else if (a.startsWith("--project-root=")) projectRoot = resolvePath(a.slice("--project-root=".length)); @@ -227,6 +250,7 @@ export function parse3dsArguments( packageDir, skipBuild, capture, + cia, configPath, configFlagged, useConfig, @@ -237,8 +261,8 @@ export function parse3dsArguments( const USAGE = "usage: bun tools/3ds.ts [--plan=] [--project-root=] " + - "[--outdir=] [--package-outdir=] [--skip-build] [--capture] [cargo args…] " + - "e.g. bun tools/3ds.ts 3ds-demo --capture"; + "[--outdir=] [--package-outdir=] [--skip-build] [--capture] [--cia] [cargo args…] " + + "e.g. bun tools/3ds.ts 3ds-demo --cia"; // --------------------------------------------------------------------------- // Container plumbing @@ -488,6 +512,146 @@ export async function ensureQuickJs( writeFileSync(stampPath, `${stamp}\n`); } +// --------------------------------------------------------------------------- +// makerom (--cia) +// --------------------------------------------------------------------------- + +/** + * Clone and build makerom, and cache the binary. The stamp covers the checked + * out revision and the container image, so a re-clone or a new devkitARM + * release rebuilds and nothing else does. + * + * The clone happens on macOS because the container runs with --network=none; + * the build happens in the container because that is where this repository's + * device toolchain lives. makerom vendors mbedtls, blz and yaml, so nothing + * after the clone reaches the network. + */ +export async function ensureMakerom( + cacheDirectory: string, + imageId: string, + mounts: readonly Mount[], +): Promise { + const checkout = join(cacheDirectory, "src"); + const project = join(checkout, "makerom"); + const binary = join(project, "bin", "makerom"); + const stampPath = join(cacheDirectory, ".stamp"); + + if (!existsSync(join(project, "makefile"))) { + if (!Bun.which("git")) { + throw new Error("PocketJS 3ds: --cia needs git on PATH to fetch makerom."); + } + mkdirSync(cacheDirectory, { recursive: true }); + rmSync(checkout, { recursive: true, force: true }); + console.log(`PocketJS 3ds: cloning ${MAKEROM_REPOSITORY} …`); + const clone = await capture("git", [ + "clone", + "--depth", + "1", + MAKEROM_REPOSITORY, + checkout, + ]); + if (clone.exitCode !== 0) { + rmSync(checkout, { recursive: true, force: true }); + throw new Error( + `PocketJS 3ds: could not clone ${MAKEROM_REPOSITORY} into ${checkout}.\n` + + (clone.stderr.trim() || clone.stdout.trim()) + + "\nmakerom is the only tool that builds a CIA and ships in neither " + + "devkitPro nor Homebrew. With no network, clone it by hand into that " + + "path and rerun; the build itself is offline.", + ); + } + } + + const head = await capture("git", ["rev-parse", "HEAD"], checkout); + const stamp = `${imageId} ${head.exitCode === 0 ? head.stdout.trim() : "unknown"}`; + if ( + existsSync(binary) && + existsSync(stampPath) && + readFileSync(stampPath, "utf8").trim() === stamp + ) { + console.log(`PocketJS 3ds: makerom cached (${binary})`); + return binary; + } + + console.log("PocketJS 3ds: building makerom …"); + await runContainer( + ["make deps", `make -j${availableParallelism()}`].join("\n"), + mounts, + containerPathFor(project, mounts), + {}, + "makerom build", + ); + if (!existsSync(binary)) { + throw new Error(`PocketJS 3ds: the makerom build did not produce ${binary}`); + } + writeFileSync(stampPath, `${stamp}\n`); + return binary; +} + +// --------------------------------------------------------------------------- +// CIA identity +// --------------------------------------------------------------------------- + +/** FNV-1a over the UTF-8 bytes; the hash this repository already stamps with. */ +function fnv1a32(text: string): number { + let hash = 0x811c9dc5; + for (const byte of new TextEncoder().encode(text)) { + hash = Math.imul(hash ^ byte, 0x01000193) >>> 0; + } + return hash; +} + +/** + * The unique part of the title id, `0x000400000000`. + * + * **Unique ids 0xFF000-0xFFFFF are the homebrew block**: no retail game and no + * system title is assigned one, so an installed CIA cannot collide with a title + * the console already has. The low 12 bits come from the manifest's app id, so + * an app keeps its title id across rebuilds — an install replaces the previous + * one instead of accumulating — and two apps get different ids without anyone + * choosing a number by hand. + */ +export function ciaUniqueId(appId: string): string { + return `0x${(0xff000 | (fnv1a32(appId) & 0xfff)).toString(16).toUpperCase()}`; +} + +/** + * The full 64-bit title id as hex: category 0x00040000 (a CTR application), + * then the unique id shifted up by the 8-bit variation, which is 0. It names + * the directory the installed title lands in, on an SD card and in Azahar + * alike: `Nintendo 3DS///title/00040000//content/`. + */ +export function ciaTitleId(appId: string): string { + const unique = 0xff000 | (fnv1a32(appId) & 0xfff); + return `00040000${((unique << 8) >>> 0).toString(16).padStart(8, "0")}`; +} + +/** + * The product code, `CTR-P-XXXX`. Nintendo assigns retail codes; homebrew + * invents its own, so the four characters are the app id's last dotted segment + * reduced to A-Z0-9, extended from the id's hash when it is shorter than four. + * The shape is the one makerom validates even without `FreeProductCode`. + */ +export function ciaProductCode(appId: string): string { + const segment = appId.split(".").pop() || appId; + const letters = segment.toUpperCase().replace(/[^A-Z0-9]/g, ""); + const filler = fnv1a32(appId).toString(36).toUpperCase(); + return `CTR-P-${`${letters}${filler}`.slice(0, 4)}`; +} + +/** + * The exheader's process name, which is **8 bytes** — makerom truncates a + * longer BasicInfo.Title to it silently, so the cut happens here where it is + * visible. Characters that would end the RSF's quoted scalar or start another + * substitution are dropped first. The title HOME Menu shows is the SMDH's, not + * this one, and keeps the manifest string whole. + */ +export function ciaProcessName(title: string, appId: string): string { + const printable = title.replace(/[^\x20-\x7e]/g, "").replace(/["\\$]/g, ""); + const cut = printable.slice(0, 8).trim(); + return cut || `PJ${(fnv1a32(appId) & 0xffff).toString(16).toUpperCase().padStart(4, "0")}`; +} + // --------------------------------------------------------------------------- // Build plan // --------------------------------------------------------------------------- @@ -612,8 +776,12 @@ export async function build3ds(argv: readonly string[]): Promise { } await ensureQuickJs(quickJsDirectory, imageId, mounts); + const makerom = args.cia + ? await ensureMakerom(join(distributionRoot, "makerom"), imageId, mounts) + : ""; const output = join(args.packageDir, `${inputs.appOutput}.3dsx`); + const ciaOutput = join(args.packageDir, `${inputs.appOutput}.cia`); const makeEnvironment: Record = { ...hostBuildEnvironment(inputs, { outputDirectory: containerPathFor(args.outputDir, mounts), @@ -633,9 +801,19 @@ export async function build3ds(argv: readonly string[]): Promise { POCKETJS_CAPTURE_INPUT: process.env.POCKETJS_CAPTURE_INPUT ?? "", POCKETJS_CAP_START: process.env.POCKETJS_CAP_START ?? "", POCKETJS_CAP_N: process.env.POCKETJS_CAP_N ?? "", + // The CIA goal is off unless POCKETJS_OUT_CIA names a file. Title, product + // code and unique id are all derived from the resolved plan. + POCKETJS_OUT_CIA: args.cia ? containerPathFor(ciaOutput, mounts) : "", + POCKETJS_MAKEROM: args.cia ? containerPathFor(makerom, mounts) : "", + POCKETJS_CIA_TITLE: ciaProcessName(plan.app.title, plan.app.id), + POCKETJS_CIA_PRODUCT: ciaProductCode(plan.app.id), + POCKETJS_CIA_UNIQUE_ID: ciaUniqueId(plan.app.id), }; - console.log(`PocketJS 3ds: make (${CONTAINER_IMAGE}${args.capture ? ", capture" : ""})`); + const notes = [args.capture ? "capture" : "", args.cia ? "cia" : ""].filter(Boolean); + console.log( + `PocketJS 3ds: make (${CONTAINER_IMAGE}${notes.length > 0 ? `, ${notes.join(", ")}` : ""})`, + ); await runContainer( `make -j${availableParallelism()}`, mounts, @@ -647,6 +825,14 @@ export async function build3ds(argv: readonly string[]): Promise { throw new Error(`PocketJS 3ds: the container build did not produce ${output}`); } console.log(`output: ${output}`); + if (args.cia) { + if (!existsSync(ciaOutput)) { + throw new Error(`PocketJS 3ds: the container build did not produce ${ciaOutput}`); + } + console.log( + `output: ${ciaOutput} (title id ${ciaTitleId(plan.app.id)}, install with \`azahar -i\`)`, + ); + } return output; }