DevTools
Pocket DevTools is built into every bundle: a component inspector that
highlights nodes on the device screen (real PSP included), pause and
single-step for the whole world, a REPL and console.log from hardware, an
always-on input-tape flight recorder, and on-demand screenshots. The design
rests on one property: the core advances virtual time in whole ticks at the
rate the bundle was built for, and the recorder captures every input track it
has — buttons, analog, touch — so a recorded input tape replays any session
byte-for-byte. Architecture deep-dive:
the blog post and
docs/DEVTOOLS.md.
One command
bun run devtools # panel + hub + USB bridge, one process
bun run devtools cards # + build, USB-link and launch cards on a real PSPThe panel serves at http://127.0.0.1:8130/devtools. With an app argument it
builds the EBOOT, serves it to the PSP over usbhostfs and launches it through
PSPLINK; if a bun psplink / bun run hw session already owns the cable it is
detected and bridged into instead. Shortcuts: o open panel, r rebuild +
relaunch, q quit. Also available from the CLI as pocket devtools.
Browser-host debugging needs nothing extra — any demo loaded from the dev server connects to the panel on load.
The component tree
The left panel is the live component tree. Hover a node and the region lights
up on the device screen — the highlight is drawn by the renderer itself (five
rectangles appended to the DrawList: a #4bb0f5 fill at ~30% alpha plus four
solid 2 px edges), so it works the same on PSP hardware, in the browser, and
headless, and it glides between nodes as you move. Click
pins a node and shows its details: type, classes, text, and the world-space
rect.
Name your components so the tree reads like your source:
// a) the debugName prop on any primitive
<View debugName="Header" class="flex-row items-center justify-between">…</View>
// b) the <Named> wrapper around a component subtree (renders no node)
<Named name="MessageCard"><Card {...props} /></Named>Both are mirror-only — zero pixel and zero native cost (goldens are byte-identical with and without them).
Time travel
Every bundle runs a flight recorder: two 36 000-entry
Uint16Array rings, one for the button mask and one for the packed analog
position, so 144 KB buys 36 000 frames — 10 minutes at 60 Hz. The touch
track is a third ring allocated on the first frame that carries contacts, so
touch-free sessions never pay for it. Because the runtime is deterministic,
that tape is the session.
In the panel: ⏸ pause freezes the entire world in the core — every animation, timeline and sprite clock holds, and stepping advances everything by one core tick. Load tape renders the input activity per frame; clicking a frame seeks there (the browser host reloads and replays the tape up to that frame). Export downloads the tape as JSON; Replay file… plays one back from boot.
Headless, the same tape answers debugging questions from the terminal:
bun run tape replay <app> session.tape.json --hashes h.json # per-frame hashes
bun run tape replay <app> session.tape.json --assert h.json # first divergent frame
bun run tape replay <app> session.tape.json --png 4120 # render any frame
bun run tape tree <app> session.tape.json --at 4120 # tree JSON at a frame--assert turns a recorded session into a regression test: replay it against a
new build and it names the exact frame where behavior changed. The repo ships
one as a session golden (bun run tape:check).
Tapes carry touch as well as buttons. tape record takes a --touch script
alongside --input:
bun run tape record <app> --frames 60 --touch "12:0,240,136;20:-" --out t.jsonEach entry is frame:id,x,y, + joins contacts that are down on the same
frame, and frame:- releases. Entries are level-triggered: a contact stays
down every frame until the next entry replaces it.
REPL, console, errors
The console panel evals JavaScript in the app's global scope between frames —
the world is quiescent there, so what you inspect is real state. console.log
/ warn / error mirror to the panel from every host, including QuickJS on
the PSP (which has no console of its own — the shim installs one). Exceptions
thrown during a frame are reported with their frame index; the frame number
plus the tape is a reproduction.
Screenshots
The 📷 button captures the device framebuffer on demand and downloads it as a PNG (plus a thumbnail strip of recent captures). On real hardware the raw VRAM rides the usbhostfs mount and the desktop bridge encodes the PNG — pixels never cross the debug channel.
Real PSP setup
The debug channel to hardware is a file mailbox on the PSPLINK usbhostfs share
(host0:/pocketjs-dbg/). The app probes for it once at boot — so start
bun run devtools first, then (re)launch the app; without PSPLINK the probe is
two failed file-opens and the app never touches IO again. On PSP the shim polls
every 10 frames (166 ms of hover latency at 60 Hz); other hosts poll every
frame.
The same mailbox works under the PPSSPP emulator: PPSSPP maps the EBOOT's own
directory as host0:, which is where bun run devtools <app> puts the
mailbox.