feat(3ds): Nintendo 3DS host with a PICA200 backend - #252
Merged
Conversation
doodlewind
force-pushed
the
quasar-heat
branch
from
August 27, 2026 05:44
2dca4d8 to
c49bcc1
Compare
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
force-pushed
the
quasar-heat
branch
from
August 28, 2026 13:27
09bb90b to
6351b83
Compare
doodlewind
marked this pull request as ready for review
August 28, 2026 13:30
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
.3dsxand.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-devprofile. 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
hosts/3ds/coreexports the host ABI and DrawList;hosts/3ds/src/gfx.cowns PICA200 resources and submission.uploadImgEntry.Packaging
bun run 3ds 3ds-demo --ciabuilds:dist/3ds/pocket3ds-demo-main.3dsxdist/3ds/pocket3ds-demo-main.ciaThe 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 greenbun test tests/3ds-profile.test.ts— 16/16 passcargo clippy --manifest-path hosts/3ds/core/Cargo.toml --release -- -D warningsbun run 3ds 3ds-demo --ciabun run 3ds 3ds-demo --capturebun run e2e:3ds— 3/3 400×240 frames byte-exact