Skip to content

feat(3ds): Pocket Voxel on the PICA200 - #3

Draft
doodlewind wants to merge 6 commits into
mainfrom
feat/3ds-pica
Draft

feat(3ds): Pocket Voxel on the PICA200#3
doodlewind wants to merge 6 commits into
mainfrom
feat/3ds-pica

Conversation

@doodlewind

@doodlewind doodlewind commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

The diorama renders through the 3DS GPU instead of a software rasterizer — a fourth backend beside the PSP GE, the Vita GXM backend that landed on main in #2, and the CPU oracle, consuming the same DrawList in the same order through the same draw::resolve_pal, with the same pull math, alpha cutoff and three depth behaviours.

All 11 story marks render correctly on Azahar and pass acceptance. Pallet Town shows the town square, the roofed lab with lit windows, the tree hedge, the paved path, the player and the planter row; Route 1 shows tall encounter grass, hedgerows, the trainer and the player.

How it is split

citro3d is largely static inline, so Rust resolves the frame and C issues the calls.

  • crates/pocketvoxel-pica — lowers each DrawList item into a flat #[repr(C)] command stream, a deduplicated matrix table and this frame's texture keys, staging geometry into a banked linearAlloc arena. The PSP's zero-copy path is impossible: BufInfo_Add rejects any pointer below physical 0x18000000, so the pak cannot be drawn in place. Measured over the shipped pak, the worst frame stages 2400 KB of a 6144 KB bank.
  • crates/pocketvoxel-3ds — the voxel op surface ported line by line from the PSP EBOOT: 31 ops, same codes, same arities, same marshalling down to a missing argument reading as 0. QuickJS stays on the C side and the op table crosses as data, so the 16-byte JS_NO_NAN_BOXING JSValue ABI is expressed in exactly one place.
  • hosts/3ds — the command walker, the PICA200 vertex shader, romfs pak loading, the frame loop and the capture path.

The guest stays on tier 0, the psp rung. The ladder's tier ids are dense, ordered and append-only with the last rung the identity, so a weaker 3DS rung cannot be appended and inserting one would renumber desktop. Untouched.

What the PICA200 forces that the GE did not

  1. No paletted texture format at all, so pages expand to RGBA5551 per (page, frame, resolved VPAL, tinted)541 textures, 12.70 MiB, measured over the shipped pak rather than estimated.
  2. 8×8 Morton tiles rather than the pak's PSP 16×8 swizzle, and the source must be flipped vertically first because tiled row 0 samples at v = 1. Verified against devkitPro's own tex3ds encoder rather than against documentation — which also turned up that tex3ds --raw prepends a 4-byte header, without which the tiling claim looks false.
  3. Power-of-two textures, which most pak pages are not (terrain 128×184, ground bake 128×256, pics 40×40), so each carries a POT envelope with the UV rescale folded into the draw.

The camera is not touched. The pak is hard-rejected unless its META says 480×272, so the existing camera renders into a 400×226 letterboxed viewport on the 400×240 top screen, preserving the cooked aspect exactly at the cost of 7px bars.

The bug that cost the bring-up, and was not in the renderer

The GPU drew correctly from the first boot. C3D_SyncDisplayTransfer with a 32-bit linear output out of the 240×400 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 screen.

Measured against a probe rectangle with known bounds: RGBA8 out 74.6%, RGB8 out 100.0%, a hand-untiled CPU copy exact. The capture now transfers GX_TRANSFER_FMT_RGB8 and widens B,G,R into the capture word itself.

A plausible stride/pitch hypothesis was killed by measurement first: scoring the documented decode against 14 alternative pitches and 8 tiled-width readings put the documented one first. The diagonal shear that suggested a stride bug is what any column-major buffer looks like read row-major, so it carried no information.

Acceptance is three claims, not one

A pixel comparison against the oracle cannot work through a resample. Against the oracle resized to the 400×226 band, worst-mark AE is 62118 of 90400 at -fuzz 2% — and the comparison's own floor, the oracle against nothing but a trip through the same grid, is 61215. The floor is 99% of the signal; a tolerance there would be measuring ImageMagick's filter choice.

So bun run e2e:3ds asserts:

  1. tests/goldens/voxel/story-3ds.hashes — FNV-1a64 per mark. Proves determinism and regression, not parity, and says so in its header.
  2. Mean RGB vs the untouched 480×272 oracle — both are area averages of the same frustum, so this gate has no resampling floor. Worst 1.41 of 255; tolerance 3.0.
  3. Coarse structure — both sides box-averaged to a common 100×56 grid. Worst 759 of 5600, floor 136, nearest wrong scene 3571; tolerance 1700.

Hashes belong to one renderer: Azahar's Vulkan and software backends hash differently on all 11 marks, so the fixture pins graphics_api=0.

No ROM-derived byte is committed — goldens are hashes, captures stay under git-ignored dist/.

Not verified

  • No real 3DS hardware. Azahar 2125.1.2 only. The memory split — 12 MiB arena plus ~14 MiB of expanded textures plus a 30.6 MiB pak — fits the emulator but is a budget an Old 3DS may refuse.
  • Parity, as opposed to agreement. Capturing from an off-screen 480×272 target would remove the resample and let the PSP driver's pixel comparison transfer unchanged. That is the route to a real parity check and it is not done.
  • One derived PICA claim is still open: whether disabling the blender needs C3D_ColorLogicOp(GPU_LOGICOP_COPY) rather than C3D_AlphaBlend with identity factors. This frame cannot discriminate it — RGBA5551 expands alpha to 0 or 255 and the alpha test kills the zeros, so every surviving texel has src_alpha = 1. It needs a deliberate sub-255-alpha probe.
  • Only the story tape's 11 marks were captured; the battle tape's gates are unmeasured.
  • Pre-existing and unrelated: cargo test -p pocketvoxel-core fails draw::tests::detail_meshes_fade_with_the_rung, reproduced identically on a pristine HEAD checkout.

Requires the 3DS host from pocket-stack/pocketjs#252 for its toolchain conventions.

🤖 Generated with Claude Code


Rebased on main (merge 33d1f75), which brought in the Vita raw-GXM backend. Both conflicts were additive-union — the workspace exclude list and the package.json scripts — and both sides were kept. Acceptance re-run after the merge: all 11 marks still byte-exact, including the oracle path through main's updated tools/voxel.ts.

doodlewind and others added 6 commits August 8, 2026 23:28
The diorama renders through the 3DS GPU instead of a software rasterizer:
a third backend beside the PSP GE and the CPU oracle, consuming the same
DrawList in the same order through the same draw::resolve_pal, with the same
pull math, alpha cutoff and three depth behaviours.

Because citro3d is largely `static inline`, the split is Rust-resolves /
C-issues. `crates/pocketvoxel-pica` lowers each DrawList item into a flat
#[repr(C)] command stream, a deduplicated matrix table and this frame's texture
keys, staging geometry into a banked linearAlloc arena — the PSP's zero-copy
path is impossible here because BufInfo_Add rejects heap pointers.
`crates/pocketvoxel-3ds` ports the voxel op surface line by line from the PSP
EBOOT (31 ops, same codes, same arities, same marshalling down to a missing
argument reading as 0) and keeps the guest on tier 0, the psp rung, so the
quality ladder is untouched.

The PICA200 forces three transforms the GE did not. It has no paletted format
at all, so pages expand to RGBA5551 per (page, frame, resolved VPAL, tinted) —
541 textures, 12.70 MiB, measured over the shipped pak. Its textures are 8x8
Morton tiles rather than the pak's PSP 16x8 swizzle, and must be flipped
vertically first because tiled row 0 samples at v = 1. And they must be
power-of-two, which most pages are not, so each carries a POT envelope with the
UV rescale folded into the draw. The tiling was verified against devkitPro's own
tex3ds encoder rather than against the documentation.

The camera is not touched: the pak is hard-rejected unless its META says
480x272, so the existing camera renders into a 400x226 letterboxed viewport on
the 400x240 top screen, which preserves the cooked aspect exactly.

One bug cost most of the bring-up and none of it was in the renderer. The GPU
was drawing correctly from the first boot; `C3D_SyncDisplayTransfer` with a
32-bit linear output out of the 240x400 tiled colour buffer returns rows that
are each individually correct and progressively misregistered, every fourth row
slipping a further 64 texels, while the same frame presents perfectly on screen.
Measured against a probe rectangle with known bounds: RGBA8 out 74.6%, RGB8 out
100.0%, a hand-untiled CPU copy exact. The capture now transfers RGB8 and widens
the channels itself.

Acceptance is three claims, not one, because a pixel comparison against the
oracle cannot work through a resample: at -fuzz 2% the comparison's own floor is
61215 of 90400 against a signal of 62118. So the committed hashes prove
determinism and regression, a mean-RGB gate compares against the untouched
480x272 oracle with no resampling floor, and a coarse-structure gate box-averages
both sides to a common grid. Capturing from an off-screen 480x272 target would
remove the resample and let the PSP driver's pixel comparison transfer unchanged;
that is the route to real parity and it is not done.

Verified on Azahar only — no hardware pass, and the memory split (12 MiB arena
plus ~14 MiB of expanded textures plus a 30.6 MiB pak) is a budget an Old 3DS
may refuse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`bun tools/voxel-3ds.ts --cia` writes dist/3ds/voxelmon.cia beside the .3dsx,
built by makerom (cloned and compiled in the devkitARM container, cached like
libquickjs.a) from the tracked RSF in hosts/3ds/app.rsf.

The format is not packaging convenience. A .3dsx runs under the Homebrew
Launcher and inherits hbmenu's memory allocation; a CIA is its own title and
asks the kernel for its own region through the RSF — `SystemMode: 64MB` on Old
3DS and `SystemModeExt: 124MB` on New 3DS. This runtime's budget is a 12 MiB
arena plus ~14 MiB of expanded textures plus a 30.6 MiB pak, which is the one
number most likely to refuse to fit under hbmenu on real hardware, so the CIA is
the build that has a chance there.

Two traps, both hit and both commented at their site: makerom rejects
mkromfs3ds's output (`Invalid RomFS Binary` — that is the raw romfs 3dsx uses),
so the RSF carries `RomFs: RootPath` and lets makerom build it; and no banner is
required, despite most homebrew build systems shipping one.

Verified on Azahar: the CIA installs as title 00040000/0ff66100 and boots from
the installed title, rendering the diorama, and `bun run e2e:3ds` still passes
all 11 marks. The larger New 3DS region parses but was never exercised — Azahar
is not a New 3DS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	Cargo.toml
#	package.json
A run that stops on hardware left nothing behind. It is blocked, not
erroring, so fail() never runs; the top screen holds its last frame,
aptMainLoop is never reached again so HOME stops responding, and the
bottom screen's counter line only refreshes every 30 ticks — so a run
that stops inside the first 30 said nothing at all about where.

Three things now make it talk.

**Every step of the frame names itself** in `voxel_host_stage` before it
runs: guest-frame, tick, record, gfx-prepare, frame-sync, frame-begin,
clear, draw-walk, frame-end, readback, apt. It is a store into a global,
not a syscall, and a debugger on a halted console reads it directly
(`p voxel_host_stage`, `p voxel_host_tick`).

**The heartbeat writes the same facts to the SD card**, one truncated
line in sdmc:/pocketvoxel-3ds/hb.txt, so a wedge is readable over FTP
with no debugger — the mechanism crates/pocketvoxel-psp/src/capture.rs
already uses, with the stage and the frame's counters added. It writes
every 30 ticks and, past the first frame, on every stage change, because
the wedge this was built for happened inside the first 30 ticks. That
costs one SD write per stage change and does not hold 60 Hz;
`--heartbeat 0` turns the file off and keeps the free part. A capture
build defaults to off, so the golden path keeps the writes it was
recorded with.

**Neither of the frame's waits on the GPU can block forever.**
C3D_FrameBegin(C3D_FRAME_SYNCDRAW) is two waits and neither ends on its
own: C3D_FrameSync blocks until both screens' vblank counters advance,
which needs the process to still be receiving GSP events, and the queue
wait blocks until the GPU has drained the previous frame — which is what
makes the arena bank the next record rewinds safe. The host now runs the
two halves itself, each polled against a deadline: C3D_FrameCounter for
the first, C3D_FrameBegin(C3D_FRAME_NONBLOCK) for the second. The queue
wait is unchanged in meaning, so the rewind rule holds as written. On
expiry the run writes an error naming the wait, the stage, the tick and
the counters, then parks exactly as fail() does. The deadline is four
seconds by default and is measured in system ticks (svcGetSystemTick,
SYSCLOCK_ARM11), not wall time, so neither a heavy frame nor a slow
emulator approaches it; it also restarts while aptIsActive() is false,
so the HOME menu and sleep cannot be mistaken for a wedge.

Measured on Azahar. `bun run e2e:3ds` passes all 11 marks byte-exact
against the committed hashes. Drilled with --frame-wait-ms 0: the run
writes "no vblank arrived within 0 ms: stage frame-sync, tick 0, scene
tick 1, items 10, draws 9 verts 7336 idx 11004 ..." and parks, and
hb.txt carries the same line. Drilled again with a frame-begin that
never succeeds: "the GPU never finished the previous frame within 1000
ms: stage frame-begin, tick 0, ...". At --frame-wait-ms 20, 200x tighter
than the default, a normal run still reached tick 1334 without tripping,
with the heartbeat tracking it live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wedge deadline did not fire in the one case it was built for. A New
3DS LL stopped and left one line in sdmc:/pocketvoxel-3ds/hb.txt:

  tick 1 stage frame-begin scene 2 items 10 rung 0 draws 9 verts 7336
  idx 11004 tex 2/80 KiB arena 136/136 KiB drop a0 t0 w0

frame-begin at tick 1 is the queue wait, so frame 0 was submitted and the
PICA200 never finished it. That frame was 9 draws with nothing dropped
and the run had 92020 KiB of heap and 17881 KiB of linear memory free, so
neither the arena nor memory is involved. No error file was written: the
four-second deadline never expired on a console that never ran another
frame. Every drill on Azahar expired correctly. Hardware only.

**aptIsActive() is not the suspension test the deadline read it as.**
libctru's apt.c writes FLAG_ACTIVE in exactly four functions —
aptWaitForWakeUp, aptJumpToHomeMenu, aptLaunchLibraryApplet,
aptLaunchSystemApplet — and every one runs on the thread that calls
aptMainLoop(); the APT event thread only sets FLAG_SHOULDSLEEP and the
home-button state. So the bit cannot change while that same thread sits
in one of these waits, and a suspension cannot begin inside one: HOME and
sleep take effect in aptHandleJumpToHome / aptHandleSleep at the top of
the frame loop, which is behind the wait. The restart forgave nothing it
was written to forgive. Worse, the bit is false for a WHOLE RUN wherever
aptIsCrippled() holds (RUNFLAG_APTWORKAROUND set, RUNFLAG_APTREINIT
clear): aptInit returns before the wakeup that first sets it. There the
restart ran on every poll and both deadlines were infinite. Azahar
reports runflags=0x0 apt=1, which is why the drills passed there.

**So the deadline counts what it actually needs: time this thread was
running.** A wedged GPU does not stop this thread — it keeps polling and
every gap between two polls is short. A process that is suspended,
asleep, behind an applet or frozen does not execute the loop at all, so
however long that lasts it arrives as ONE long gap. Each wait credits the
gap between its own polls: at or under PV3DS_HOST_WAIT_STALL_MS (250,
250x the poll gap) it counts against the deadline, above it never does.
The credited total only grows, so every wait ends after bounded running
time whatever APT reports, and a suspension of any length still costs
nothing — including a freeze the process is never told about, which the
APT bit could not have covered. aptIsActive() and the run flags are now
reported instead of waited on, in the error line and in memory.txt's boot
line, so the next console says which environment it was in.

Also: the error file moves next to hb.txt (a playable build wrote it to
the SD root), and the last heartbeat a wedged run writes says WEDGED and
names the wait, so it is no longer identical to a live line.

Drilled on Azahar with a build whose queue never drains after frame 0,
which reproduces the console's heartbeat line byte for byte.

  old rule + APT forced false: 120 s, no error file — the console's
    failure, reproduced
  --poll-gap-ms 100 (under the stall threshold): "the GPU never finished
    the previous frame within 1000 ms of this thread running (ran 1000
    ms, stalled 0 ms in 0 gaps, apt 1, runflags 0x0): stage frame-begin,
    tick 1, …", and hb.txt carries the same line marked WEDGED
  --poll-gap-ms 400 (over it — the suspension shape): 150 s, no error
    file, hb.txt live at tick 1 stage frame-begin
  one injected 6 s absence: "ran 1000 ms, stalled 6000 ms in 1 gaps" —
    the absence excluded, the wedge still caught
  --frame-wait-ms 20, 200x tighter than the default, healthy: tick 1334,
    no error file

bun run e2e:3ds passes all 11 marks byte-exact against the committed
hashes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The New 3DS LL wedge left one line of hb.txt, and the counters on it were
the wrong frame's. The loop records frame N, then waits for the GPU to
finish frame N-1, so at a frame-begin wedge the crate's stats have already
been overwritten by the frame that has NOT been submitted: `tick 1 stage
frame-begin ... draws 9 verts 7336 idx 11004` is frame 1, and frame 0 --
the frame the PICA200 stopped on -- was never written down. The heartbeat
now carries both, with the submitted frame snapshotted at the end of the
command walk so the next record cannot overwrite it, plus a per-command
breadcrumb naming the kind, vertex format, depth mode, flags, page and
palette of the command the walk last reached.

A frame hands the GX queue up to three jobs, from three engines: the memory
fill C3D_RenderTargetClear queues (PSC), the command list C3D_FrameEnd
submits (P3D), and the display transfer that presents it (PPF). One queue
wait covers all three and cannot say which did not finish, so two build
knobs take them away one at a time. --max-draws N caps how many draw
commands reach the GPU; at 0 the command buffer is empty, C3Di_SplitFrame
finds nothing and no command list is queued at all. --present 0 skips
C3D_RenderTargetSetOutput, leaving citro3d's linkedTarget[] empty so
C3D_FrameEnd transfers nothing. Together they reduce a frame to the fill
alone. Both are reported in hb.txt (`cap N present 0`), in memory.txt and
on the bottom screen, and both are refused on a capture build. Walked on
Azahar: 633 ticks at cap 0, 638 at cap 0 present 0.

--sd-pak reads the pak from sdmc:/pocketvoxel-3ds/voxelmon.vxpak instead of
RomFS, which takes the package from 31.9 MiB to 1.3 MiB -- seconds over the
console's FTP instead of the fifty a full push takes. RomFS stays the
default because it makes the pak and the binary reading it one artifact;
the SD path gives that up, so it is off by default, refused on a capture
build, and named in memory.txt and on the bottom screen.

docs/PICA.md derived claim 3 was wrong and is corrected from devkitARM's
own libcitro3d.a: BufInfo_Init writes base_paddr = 0x18000000 and nothing
moves it, so index offsets are taken from the base of FCRAM and indices
need not sit above the vertices. A bad index pointer makes C3D_DrawElements
return before emitting -- a dropped draw, not a hung GPU -- so it was never
the wedge candidate it looked like.

bun run e2e:3ds: 11/11 marks byte-exact. bun run test: 226 pass.
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