PocketJS on the 3DS: 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 400x240 top screen and a simultaneous 320x240 auxiliary bottom screen, both at rasterDensity 1 and presentation native, under the out-of-registry 3ds-dev profile in tools/3ds-profile.ts. The resistive panel reports contacts through input.touch.auxiliary.
The CIA boots and renders the calibration app on a New 3DS LL. The profile remains out of the production registry because the current hardware and golden suite does not directly exercise the synthesized cursor, sprites, streamed textures or a large font atlas.
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/ui-cabi 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 process boot, reusable guest lifecycle, frame loop
src/runtime.c .pocket admission, immutable storage, active/rollback state
src/devserver.c discovery, paired TCP pump, uploads, screenshots, receipts
src/dev_protocol.c byte-order-safe development wire encoding and admission
src/devmenu.c Runtime-owned bottom-screen development menu
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 iconBuilding
Two toolchains, one repository:
- The Rust staticlib builds on macOS.
armv6k-nintendo-3dsis a built-in rustc target, socore/.cargo/config.tomlonly has to ask forbuild-std;core/rust-toolchain.tomlpins the nightly. The target defaults to unwind, so the crate setspanic = "abort". - The C half builds in the digest-pinned
devkitpro/devkitarmimage, which bringsarm-none-eabi-gcc, libctru, citro3d,picasso,smdhtooland3dsxtool.
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.
bun tools/3ds.ts 3ds-demo # dist/3ds/<output>.3dsx
bun tools/3ds.ts 3ds-demo --capture # the deterministic e2e binary
bun tools/3ds.ts 3ds-demo --cia # also dist/3ds/<output>.cia
bun tools/3ds.ts 3ds-demo --pocket-only # rebuild only dist/3ds/<output>.pocketEvery build writes a target-thinned .pocket next to the native artifact. The .pocket contains the admitted manifest, resolved plan, compiled JS and target-flavoured PAK. The native runtime embeds the same file as its immutable recovery guest; it no longer embeds independent app.js and app.pak files.
Updating the guest from SD
The runtime checks one staging path at boot:
sdmc:/pocketjs/runtime/pending.pocketWith an FTP server running as a separate homebrew application, build and upload the guest package, then exit the FTP server and start Pocket Runtime:
bun tools/3ds.ts 3ds-demo --pocket-only
curl --ftp-create-dirs -T dist/3ds/pocket3ds-demo-main.pocket \
ftp://<device>/pocketjs/runtime/pending.pocketThe runtime verifies the package footer, exact 3ds-dev target, host ABI, identity, resolved plan and NUL-terminated JS section before it can boot. A complete pending package is renamed to sdmc:/pocketjs/runtime/packages/<hash>.pocket; package blobs are immutable. An incomplete FTP upload stays at pending.pocket and does not replace the running or accepted guest.
A package becomes active only after its first submitted PICA command list has retired successfully. State is committed by appending a generation marker under sdmc:/pocketjs/runtime/state/. Eval, frame or first-render failure loads the previous active package, then last-good, then the embedded ROMFS recovery package. Power loss before the generation marker leaves the previous generation active.
L+R+X requests the same package check at a GPU-idle frame boundary. The full chord is removed from the application's button mask. This supports an emulator or direct SD writer; a separate 3DS ftpd cannot run concurrently with Pocket Runtime.
Runtime receipts are written to:
sdmc:/pocketjs/runtime/status.txt
sdmc:/pocketjs/runtime/last-error.txtstatus.txt records the current generation, active hash, last-good hash, running package hash and source path. last-error.txt records the failed phase without deleting the rejected package blob.
In-process development connection
L+R+SELECT opens the 3DS host's native development menu on the bottom screen. It shows the current IP and port, pairing or connection state, active generation, running package hash, update and screenshot counts, and transport errors. X requests a dual-screen screenshot from a connected client; B or START closes the menu.
While visible, the menu replaces the guest's bottom-screen DrawList with a host-owned DrawList and consumes all guest input. It uses the same verified PICA200 backend as the guest and reads a fixed native Runtime snapshot; it is not part of globalThis.ui, the guest input contract, or a published PocketJS capability. The input latch stays active until the keys used to close the menu have been released.
Pocket Runtime listens on TCP and UDP port 8131 when this file exists:
sdmc:/pocketjs/runtime/dev.keyPair once while ftpd is running, then restart Pocket Runtime:
bun run 3ds:dev pair --host <device-ip> --ftp-port 5000The pairing command generates a random 32-byte key, stores the local copy under .pocket/3ds/devices/, uploads the device copy, and verifies the FTP readback byte for byte. The Runtime does not open a listener without the key, and a client must prove the complete key before any command or package byte is accepted.
The key authenticates a client but does not encrypt the TCP stream. Use the listener on a trusted LAN, and pass --rotate to pair after a key is exposed.
After pairing, ftpd is not part of the development loop:
bun run 3ds:dev discover
bun run 3ds:dev push --app 3ds-demo
bun run 3ds:dev probe
bun run 3ds:dev dev --app 3ds-demodiscover, push, probe, and dev do not require the 3DS IP. The Runtime answers one fixed-size UDP discovery request with its target, ABI, TCP port, generation, active hash, and a stable ID derived from the pairing key. The reply never contains the key. The desktop tool matches that ID to a local key and then authenticates the TCP connection with the complete 32-byte key. This keeps the pairing valid when DHCP changes the console's address. Pass --host <device-ip> when broadcast discovery is unavailable; the native menu supplies that address.
dev waits when no paired Runtime is present, replaces a disconnected TCP client, and rediscovers the same paired device ID until it reconnects. Panel commands and keyboard shortcuts are routed only to the current authenticated client. A client that receives no PONG for eight seconds is replaced even when the operating system has not reported the half-open TCP socket as closed. The Runtime reserves the latest PONG until the bounded output queue can send it; bulk screenshot traffic cannot discard the heartbeat response.
push builds and transfers the target-thinned .pocket, then waits for the device's accepted-after-retired-frame receipt. probe requests runtime status, native counters, the component tree, a live REPL evaluation, a console message, and a combined top/bottom PNG. dev keeps the DevTools panel attached; r rebuilds and pushes, s captures both screens, and o opens the panel.
One authenticated, ordered TCP connection carries every development message. JSON frames contain only Pocket DevTools control and logs. Package and rotated RGB8 screenshot bytes use bounded binary frames, so bulk data never enters QuickJS or the application's capability surface. Uploads stream to network-upload.pocket; the existing package admission, immutable blob, GPU-idle cold-swap, acceptance, and rollback path remains the only route to an active guest.
The connection updates the guest .pocket, not the running .3dsx or CIA host binary. A native host or ABI change still requires deploying a new .3dsx/CIA and restarting it; the embedded .pocket remains its final recovery guest. Keeping that boundary lets ordinary app, asset and resolved-plan changes use the in-process loop without letting a guest replace the process that admits and rolls it back.
Two build-time facts are load-bearing:
-DJS_NO_NAN_BOXINGmust be on every translation unit that includesquickjs.h. The header turns NaN boxing on by default for any 32-bit target, which makesJSValue8 bytes instead of 16, whilelibquickjs.ais 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/<output>.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
-iconalready carries the icon and the title strings, and-exefslogosupplies 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 thatmkromfs3dsproduces — the container 3dsxtool takes — fails withInvalid RomFS Binary; the two packagers share the staged directory and nothing else. - makerom ships in neither devkitPro nor Homebrew, so
tools/3ds.tsfetches one pinnedgithub.com/3DSGuy/Project_CTRrevision intodist/3ds/makerom/src, builds it in the same container as everything else, and caches the binary against the container image and revision. mbedtls, blz and yaml are vendored in that repository, so the fetch 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:
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.__viewportis the logical UI size, and omitting it is a layout bug, not a missing nicety.framework/src/index.tssizes 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 agrowsibling, everyright-0absolute — off the panel. The value is read back from the core withui_viewport_width/ui_viewport_heightaftermain.chas calledui_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 needsinstallResizeViewportHook, which atakeoverhost never calls.
What the backend has to honour
src/gfx.c is the 3DS twin of engine/ui-cabi/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, andMtx_OrthoTiltkeeps guest coordinates landscape.C3D_FrameDrawOnresets the viewport, soC3D_SetViewportcomes 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_TexUploadis a plainmemcpy. 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
linearAllocmemory (BufInfo_Addrejects any pointer below physical0x18000000), 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_T8is 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.
bun tests/e2e/azahar.tsAzahar'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 belongs to the bottom auxiliary surface, so it is exposed only as input.touch.auxiliary; contacts are never remapped into the top screen's coordinate space. audio.pcm is not implemented in v1.


