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..0f4d632b
--- /dev/null
+++ b/hosts/3ds/Makefile
@@ -0,0 +1,187 @@
+# 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
+# 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, POCKETJS_OUT_3DSX and POCKETJS_OUT_CIA.
+
+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
+CIA := $(POCKETJS_OUT_CIA)
+RSF := $(CURDIR)/app.rsf
+
+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)
+ifneq ($(CIA),)
+all: $(CIA)
+endif
+
+$(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)
+
+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) $(CIA)
diff --git a/hosts/3ds/README.md b/hosts/3ds/README.md
new file mode 100644
index 00000000..ae9a91da
--- /dev/null
+++ b/hosts/3ds/README.md
@@ -0,0 +1,229 @@
+# 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
+app.rsf the CIA descriptor makerom reads
+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
+bun tools/3ds.ts 3ds-demo --cia # also dist/3ds/.cia
+```
+
+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.
+
+## 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
+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.
+
+**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 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`.
+
+## 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/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/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 00000000..b7caebde
Binary files /dev/null and b/hosts/3ds/icon.png differ
diff --git a/hosts/3ds/include/pocket_core.h b/hosts/3ds/include/pocket_core.h
new file mode 100644
index 00000000..c84bca66
--- /dev/null
+++ b/hosts/3ds/include/pocket_core.h
@@ -0,0 +1,175 @@
+#ifndef POCKETJS_3DS_CORE_H
+#define POCKETJS_3DS_CORE_H
+
+/*
+ * C ABI of hosts/3ds/core (the `pocketjs-3ds-core` staticlib): PocketJS's
+ * retained UI core — tree, style, layout, text, animation and DrawList
+ * emission — for the libctru host.
+ *
+ * Every pointer returned here borrows core-owned storage. It stays valid
+ * until the next call that can move it (a texture upload or free, a font
+ * atlas load, ui_draw, ui_init, ui_shutdown), so the host re-reads pointers
+ * every frame rather than caching them across frames.
+ */
+
+#include
+#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..b53692cb
--- /dev/null
+++ b/hosts/3ds/src/main.c
@@ -0,0 +1,377 @@
+/*
+ * 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"
+
+/* 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). */
+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.
+ *
+ * **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 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
+ * until the GPU is, so wait before transferring it out. */
+ gspWaitForVBlank();
+ C3D_SyncDisplayTransfer(
+ (u32 *)target->frameBuf.colorBuf,
+ GX_BUFFER_DIM(VIEW_H, VIEW_W),
+ (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_RGB8) |
+ GX_TRANSFER_SCALING(GX_TRANSFER_SCALE_NO)
+ );
+ 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. */
+ 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);
+ capture_rgb = linearAlloc(CAPTURE_RGB_BYTES);
+ if (capture_buffer == NULL || capture_rgb == 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..cd78b01a
--- /dev/null
+++ b/tests/3ds-profile.test.ts
@@ -0,0 +1,242 @@
+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";
+import {
+ ciaProcessName,
+ ciaProductCode,
+ ciaTitleId,
+ ciaUniqueId,
+} from "../tools/3ds.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()");
+ });
+});
+
+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
new file mode 100644
index 00000000..3e3d9282
--- /dev/null
+++ b/tests/e2e/azahar.ts
@@ -0,0 +1,392 @@
+// 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,
+// 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 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
+// (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`;
+// 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
+// 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 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");
+ 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=${graphicsApi}`;
+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 00000000..d08abad0
Binary files /dev/null and b/tests/goldens/3ds/3ds-demo.12.png differ
diff --git a/tests/goldens/3ds/3ds-demo.2.png b/tests/goldens/3ds/3ds-demo.2.png
new file mode 100644
index 00000000..4278798b
Binary files /dev/null and b/tests/goldens/3ds/3ds-demo.2.png differ
diff --git a/tests/goldens/3ds/3ds-demo.22.png b/tests/goldens/3ds/3ds-demo.22.png
new file mode 100644
index 00000000..d08abad0
Binary files /dev/null and b/tests/goldens/3ds/3ds-demo.22.png differ
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..67c0edf4
--- /dev/null
+++ b/tools/3ds.ts
@@ -0,0 +1,846 @@
+// 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 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:
+// 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)
+// 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.
+//
+// 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
+//
+// 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/.
+
+import { $ } from "bun";
+import { createHash } from "node:crypto";
+import {
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ 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;
+
+// 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",
+ "-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;
+ /** Also package the ELF as an installable CIA title. */
+ readonly cia: 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 cia = 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 === "--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));
+ 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,
+ cia,
+ configPath,
+ configFlagged,
+ useConfig,
+ buildFlags,
+ cargoArgs,
+ };
+}
+
+const USAGE =
+ "usage: bun tools/3ds.ts [--plan=] [--project-root=] " +
+ "[--outdir=] [--package-outdir=] [--skip-build] [--capture] [--cia] [cargo args…] " +
+ "e.g. bun tools/3ds.ts 3ds-demo --cia";
+
+// ---------------------------------------------------------------------------
+// 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`);
+}
+
+// ---------------------------------------------------------------------------
+// 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
+// ---------------------------------------------------------------------------
+
+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 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),
+ 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 ?? "",
+ // 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),
+ };
+
+ 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,
+ 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}`);
+ 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;
+}
+
+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",