Skip to content

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

Merged
doodlewind merged 8 commits into
mainfrom
quasar-heat
Aug 28, 2026
Merged

feat(3ds): Nintendo 3DS host with a PICA200 backend#252
doodlewind merged 8 commits into
mainfrom
quasar-heat

Conversation

@doodlewind

@doodlewind doodlewind commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Adds a Nintendo 3DS host: QuickJS drives the shared Rust UI core, while a C citro3d backend renders the DrawList through PICA200 on the 400×240 top screen. The build emits both .3dsx and .cia, and Azahar captures three deterministic GPU-readback frames.

Hardware acceptance

The CIA boots, renders the calibration app, and is usable on a New 3DS LL. The hardware pass confirmed the 1 MiB CIA stack and the complete shader output fix: every mapped output register component is written before the vertex exits the PICA200 shader.

The target remains the out-of-registry 3ds-dev profile. Promotion is deferred until the synthesized cursor, sprite, streamed-texture, and large-atlas paths have direct coverage; the current pass does not prove those paths.

Architecture

  • C owns citro3d and Rust owns the portable core. hosts/3ds/core exports the host ABI and DrawList; hosts/3ds/src/gfx.c owns PICA200 resources and submission.
  • App input is the public button and analog contract. Hardware bring-up L/R/Y render overrides and normal-path trace output were removed after the device issue was isolated.
  • Pak images use the shared IMG parser. RLE and linear-sampling flags follow the same core path as uploadImgEntry.
  • Build inputs are reproducible and app-scoped. The devkitARM image and makerom revision are pinned, each app has isolated object, romfs, and SMDH state, metadata changes invalidate SMDH safely, and grouped shader outputs avoid parallel Make races.
  • The bottom touchscreen is not advertised as top-screen touch input. A second display surface needs a separate contract.

Packaging

bun run 3ds 3ds-demo --cia builds:

  • dist/3ds/pocket3ds-demo-main.3dsx
  • dist/3ds/pocket3ds-demo-main.cia

The CIA requests 64 MB on Old 3DS and 124 MB on New 3DS through hosts/3ds/app.rsf. Its title identity is derived from the resolved app id.

Validation

  • bun run test — 11/11 stages green
  • bun test tests/3ds-profile.test.ts — 16/16 pass
  • cargo clippy --manifest-path hosts/3ds/core/Cargo.toml --release -- -D warnings
  • bun run 3ds 3ds-demo --cia
  • unset-environment bun run 3ds 3ds-demo --capture
  • bun run e2e:3ds — 3/3 400×240 frames byte-exact

doodlewind and others added 8 commits August 28, 2026 21:24
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>
A .3dsx takes its main-thread stack from the crt0's __stacksize__, which
hosts/3ds/src/main.c already raises to 1 MiB; a CIA takes it from the RSF
instead, so the two formats did not get the same stack and a .3dsx that
survives proved nothing about the CIA.

At the RSF's 256 KiB, QuickJS recursing through its parser and interpreter
overruns the stack, and the overrun does not fault where it happened: it
smashes a return address and the CPU takes a prefetch abort at whatever the
stack held, with PC equal to SP. That is the signature this host produced on
a New 3DS LL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…en it fails

Every step before the frame loop runs with nothing drawn and before
aptMainLoop() has been reached, so a build that dies there presents as two
black screens and a dead HOME button. On hardware the only way out is holding
the power button, and that is also the one action that can lose the failure
note fail() just wrote to the SD card — so the console tells the user nothing
and then destroys the evidence.

fail() now prints the message on the bottom screen, which the app does not
otherwise use, and parks in `while (aptMainLoop())` so HOME exits cleanly
instead of costing a forced power-off. Each boot stage prints as it passes,
which also names the stage when it *hangs* rather than returns — the case no
error file can report, and the case that sent me reading the source instead of
reading a message.

The capture build keeps the frame path its goldens were taken from: the trace
compiles out under POCKETJS_CAPTURE and fail() still parks in a bare loop
there, because Azahar does not stop when the app returns from main. Re-ran
`bun run e2e:3ds`: 3/3 still byte-exact.

Unverified on hardware — the console it was written for is powered off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The boot trace proved every init stage and the QuickJS boot pass on real
hardware, and moved the hang into the frame loop with HOME dead — some
call inside one iteration never returns. Trace the first two frames
stage by stage (guest, FrameBegin, gfx_render, FrameEnd) and print a
once-a-second heartbeat after that, so the last line on the panel names
the blocking call: 'f1 begin...' means frame 0's command list hung the
GPU, a running heartbeat means the loop is healthy and only the present
path is broken.

printf only inside the loop — never a swap or a VBlank wait, which
would fight citro3d for the GX queue mid-frame. Compiled out under
POCKETJS_CAPTURE; the three Azahar goldens still pass byte-exact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On the real console the trace pinned the hang: frame 0's command list is
submitted (f0 end ok) but the GPU never finishes it, so frame 1's
C3D_FrameBegin(C3D_FRAME_SYNCDRAW) waits forever with HOME dead. The
goldens are pinned to Azahar's software rasterizer, so the real PICA200
is the first strict consumer of this command stream.

Three changes, all compiled out of capture builds except the flush:

- C3D_TexUpload is a memcpy through the CPU's write-back cache and the
  PICA samples physical memory, so flush the texture data after upload.
  Azahar can never show this; on hardware it reads as garbage texels.
- Replace SYNCDRAW's unbounded wait with a NONBLOCK poll and a
  three-second watchdog that names the hung frame and its batch counts
  on the bottom screen, then parks with HOME alive.
- Latch a bisect mode from whatever is held at launch: L skips every
  draw, R binds the white texture for every batch, Y never touches the
  scissor registers. One build answers four experiments.

The three Azahar goldens still pass byte-exact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hardware bisect (L: no draws, alive; R: all-white textures, hung;
Y: no scissor, hung) pinned the GPU hang to the draw path itself, and
the shader is the one deviation left in it: `mov outtex.xy, intex.xy`
never writes outtex.zw. A vertex leaves the PICA200's shader unit only
after every component of every mapped output register has been written,
so the first real draw waits forever for the two components that never
come — the whole-console hang the New 3DS reproduced on every launch.
Emulators emit the vertex at `end` regardless, which is why Azahar
passes both its software and Vulkan renderers and the goldens never
moved: the three still pass byte-exact (texcoord0.zw is not sampled).

The loader pads the two-float attribute to (x, y, 0, 1), so the
whole-register mov changes no sampled value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remove hardware bring-up controls from the normal input and render paths. Keep pak image parsing on the shared IMG contract, make capture defaults valid, and avoid hashing a null draw-list pointer.\n\nPin the devkitARM image and makerom revision, isolate per-app build state, and make romfs, SMDH, and shader outputs deterministic under incremental and parallel builds. Record the New 3DS LL hardware smoke while keeping the target private until the remaining host paths have coverage.
@doodlewind
doodlewind marked this pull request as ready for review August 28, 2026 13:30
@doodlewind
doodlewind merged commit 0fe2274 into main Aug 28, 2026
2 checks passed
@doodlewind
doodlewind deleted the quasar-heat branch August 28, 2026 13:32
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