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",