Skip to content

feat(3ds): Nintendo 3DS host with a PICA200 backend - #252

Draft
doodlewind wants to merge 2 commits into
mainfrom
quasar-heat
Draft

feat(3ds): Nintendo 3DS host with a PICA200 backend#252
doodlewind wants to merge 2 commits into
mainfrom
quasar-heat

Conversation

@doodlewind

Copy link
Copy Markdown
Collaborator

Adds a Nintendo 3DS host: a QuickJS guest rendering through the PICA200 GPU on the 400×240 top screen, with an end-to-end golden suite driven by the Azahar emulator.

A real PocketJS Solid bundle boots and renders — text at five baked sizes, textures, gradients, rounded borders, d-pad focus traversal — and three captured frames compare byte-exact run to run.

How it is put together

The split follows hosts/iphone2g, not hosts/psp: citro3d is mostly static inline, so C owns the GPU and Rust owns the core.

  • hosts/3ds/core — a no_std staticlib built for the built-in armv6k-nintendo-3ds target with -Z build-std on macOS, exporting the ui_* C ABI plus the DrawList itself.
  • hosts/3ds/src/gfx.c — walks that u32 DrawList and issues citro3d calls; all 8 ops.
  • QuickJS builds for the 3DS from the revision hosts/psp/Cargo.toml already pins, with three portability flags: JS_NO_NAN_BOXING (the Vita treatment for 32-bit ARM), __TM_GMTOFF=tm_gmtoff (newlib declares the field only under that macro), -Wno-incompatible-pointer-types (devkitARM ships GCC 16).
  • Everything the device toolchain touches runs in the devkitpro/devkitarm container, driven by tools/3ds.ts. Nothing needs devkitPro installed on the host.

Contract

Registered as the out-of-registry 3ds-dev profile, hostAbi 7 (tools/3ds-profile.ts), the route symbian-e7-dev and iphone2g-dev take. POCKET_TARGETS is an inventory of hardware-tested hosts and this has only been run on an emulator, so promotion is a follow-up gated on a device pass.

The top screen is 400×240 — smaller than 480×272 on both axes — so integer-fit is arithmetically impossible and the resolver has no scaling fallback by design. The profile declares 400×240 native; the stock corpus does not admit, and apps/3ds-demo declares that viewport.

input.touch is deliberately not advertised: the touchscreen is the bottom screen while the UI is on the top, so reporting those contacts as top-screen logical coordinates would be false.

The demo is a calibration surface

apps/3ds-demo is built so a backend bug is visible rather than subtle: an orientation key whose notch changes quadrant if the vertical flip or the 8×8 Morton tiling is wrong, corner brackets that touch all four edges only at 400×240 density 1, and the raw packed analog word printed so a wrong (x<<8)|y reads straight off a capture.

E2E

bun run e2e:3ds (UPDATE_3DS=1 to re-record). Azahar has no headless mode, ignores SIGTERM, and does not stop when the guest returns from main(), so the driver owns its lifetime (SIGKILL on every path) and its user directory.

  • Isolation: the emulator has no config/sdmc/user-dir flag and CITRA_USER_DIR is a no-op on macOS, so each run gets a fixture $HOME. Only one run at a time per machine.
  • The capture is a real GPU readback — a C3D_SyncDisplayTransfer of the PICA200 render target — not a CPU oracle. Reading gfxGetFramebuffer after C3D_FrameEnd returns the already-swapped buffer, which is black.
  • Software and Vulkan do not agree (48.7% of pixels on real UI content), so the fixture pins graphics_api=0 and tests/goldens/3ds/AZAHAR-BUILD.txt records the build and backend the goldens came from.

bun run test: 11/11 stages green.

Two bugs found by measuring, not by reading

Both were caught by diffing the PICA200 render against the wasm rasterizer at the same size, and both looked healthy from the outside:

  1. The C objects did not depend on their -D values, so a changed capture window or input tape silently lingered in cached objects — a run dumped 3 frames instead of 23 and reported success. hosts/3ds/Makefile now carries a CFLAGS stamp, the make-side counterpart of cargo:rerun-if-env-changed.
  2. qjs.c never published ui.__viewport, so framework/src/index.ts fell back to the 480×272 spec screen and sized its layers there. Everything measured from a row's right edge sat exactly 80px (480−400) too far right. A regression test pins the publication — nothing short of an emulator run would otherwise catch its removal.

Residual against the wasm oracle is 5.1%, all ±1–2 in a single channel on colour transitions (glyph AA, rounded-border and gradient blends). It is provably non-geometric: zero differing pixels sit inside a flat 3×3 block of the oracle, which a displaced shape could not manage.

Not verified

  • No real hardware. Azahar 2125.1.2 only. Frame rate is unmeasured.
  • Font atlases upload as RGBA8 rather than LA8 to keep one swizzle path; untested against a large CJK atlas.
  • setPropBatch, setSprite, the streamed-texture and DevTools ops are marshalled but never exercised by this demo.

🤖 Generated with Claude Code

A QuickJS guest over the PICA200 GPU on the 3DS top screen, admitted through
the out-of-registry `3ds-dev` profile (hostAbi 7) until it passes on hardware,
the same route symbian-e7-dev and iphone2g-dev take.

The split follows hosts/iphone2g rather than hosts/psp: citro3d is mostly
`static inline`, so C owns the GPU and Rust owns the core. `hosts/3ds/core` is
a no_std staticlib built for the built-in `armv6k-nintendo-3ds` target with
`-Z build-std` on macOS, exporting the `ui_*` C ABI plus the DrawList itself;
`hosts/3ds/src/gfx.c` walks that list and issues citro3d calls. QuickJS builds
for the 3DS from the revision hosts/psp already pins, with three portability
flags: `JS_NO_NAN_BOXING` (the Vita treatment for 32-bit ARM),
`__TM_GMTOFF=tm_gmtoff` (newlib declares the field only under that macro), and
`-Wno-incompatible-pointer-types` (devkitARM ships GCC 16). Everything the
device toolchain touches runs in the `devkitpro/devkitarm` container, driven by
`tools/3ds.ts`.

The top screen is 400x240 — smaller than 480x272 on both axes — so integer-fit
is arithmetically impossible and the resolver has no scaling fallback by
design. The profile declares 400x240 `native`, and apps/3ds-demo declares that
viewport and doubles as a calibration surface: an orientation key whose notch
moves quadrant if the texture flip or the 8x8 Morton tiling is wrong, corner
brackets that only touch all four edges at this size, and the raw packed analog
word printed so a wrong `(x<<8)|y` is readable off a capture.

`input.touch` is deliberately not advertised: the touchscreen is the bottom
screen while the UI is on the top, so reporting those contacts as top-screen
logical coordinates would be false.

tests/e2e/azahar.ts builds a capture .3dsx per spec, boots Azahar against a
fixture $HOME (the emulator has no config or user-dir flag, and CITRA_USER_DIR
is a no-op on macOS), waits for the guest's sentinel, SIGKILLs, and compares a
GX display transfer of the render target — a real GPU readback, not a CPU
oracle. Software and Vulkan do not agree (48.7% of pixels on real UI content),
so the fixture pins graphics_api=0 and tests/goldens/3ds/AZAHAR-BUILD.txt
records what the goldens came from.

Two bugs found by measuring against the wasm oracle rather than by reading:
the C objects did not depend on their `-D` values, so a changed capture window
or input tape lingered in cached objects (a CFLAGS stamp now forces the
rebuild); and `qjs.c` never published `ui.__viewport`, so the framework sized
its layers at the 480x272 spec screen and everything measured from a row's
right edge sat exactly 80px too far right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The capture display transfer asked the PPF for a 32-bit linear output out of
the 240x400 tiled colour buffer. Azahar's software rasterizer answers that
correctly, which is why the committed goldens are right, but a hardware
renderer returns rows that are individually correct and progressively
misregistered: the same build came back shredded under Vulkan. Transfer with
GX_TRANSFER_FMT_RGB8 instead, the format citro3d's own presentation transfer
uses, and widen B,G,R into the A,B,G,R capture word on device so the driver's
decode is unchanged.

Re-recorded goldens are byte-identical to the old ones. A Vulkan capture now
decodes to the correct screen; it still differs from the software goldens on
5.1% of pixels, 99.5% of them by 1 or 2 of 255, so the backend pin stays and
E2E_AZAHAR_GRAPHICS_API re-measures the gap.

--cia writes dist/3ds/<app>.cia from the same ELF and the same staged romfs
directory. A .3dsx inherits the Homebrew Launcher's memory allocation; a CIA is
its own title and asks for its own region through hosts/3ds/app.rsf's
SystemMode: 64MB. makerom ships in neither devkitPro nor Homebrew, so it is
cloned shallow, built in the container and cached the way libquickjs.a is.
Title, product code and unique id are derived from the resolved plan, with the
unique id inside the 0xFF000-0xFFFFF homebrew block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant