Pocket DevTools
Overview
PocketJS is fixed-dt deterministic and its entire per-frame input is one
button bitmask, so a recorded input tape replays any session byte-exactly.
DevTools (design: repo docs/DEVTOOLS.md) is built into every bundle: a flight
recorder is ALWAYS on (36 000-frame ring), and a JSON-line debug channel
connects the runtime to a desktop panel over WebSocket (browser host) or the
PSPLINK USB mailbox (real PSP).
Prefer the headless tape workflow for agent debugging — every question is
answerable from the terminal. Use the panel when a human is co-driving.
Headless workflow (no screen needed)
Keep per-run tapes, hashes, captures, and logs in ignored output. For example:
mkdir -p .pocket-build/validation/devtools
bun run tape record hero-main --frames 180 --input "5:64,40:8192" --out .pocket-build/validation/devtools/t.json
bun run tape replay hero-main .pocket-build/validation/devtools/t.json --hashes .pocket-build/validation/devtools/h.json
bun run tape replay hero-main .pocket-build/validation/devtools/t.json --assert .pocket-build/validation/devtools/h.json --outdir .pocket-build/validation/devtools
bun run tape replay hero-main .pocket-build/validation/devtools/t.json --png 60,120 --outdir .pocket-build/validation/devtools
bun run tape tree hero-main .pocket-build/validation/devtools/t.json --at 60
bun run tape:check # committed session golden
- Input masks are contracts/spec/spec.ts
BTN values (CIRCLE=0x2000=8192, DOWN=0x40=64).
- A regression workflow: record/obtain a tape on the OLD build →
--hashes →
switch builds → --assert names the exact first frame that changed →
--png <that frame> on both builds to see the difference.
- Tapes exported from ANY host (panel "Export", or
__pocketDevtools.dumpTape()
in the REPL) replay headlessly. startFrame > 0 means the ring wrapped —
replay is then an approximation (warned automatically).
- Committed session goldens live in
tests/tapes/; regenerate hashes only when
a visual change is intended.
- A replay capture or exported session is temporary validation output. Promote
it to a committed fixture only when a named regression test consumes it.
Put selected screenshots in PR attachments and keep raw captures, logs and
receipts outside the tracked tree, following
AGENTS.md/CLAUDE.md.
Panel workflow (one command)
bun run devtools # panel + WS hub + mailbox bridge, one process
bun run devtools cards # + build, USB-link and launch cards on a real PSP
Panel at http://127.0.0.1:8130/devtools. bun run devtools auto-detects an
already-running bun psplink / bun run hw usbhostfs session and bridges
into it instead of fighting for the cable (relaunch the app there so it
probes the mailbox at boot). Shortcuts: o open panel, r rebuild+relaunch
(managed sessions), q quit.
Panel capabilities: hover tree → highlight ON THE DEVICE SCREEN (core-drawn
overlay, works on real PSP); click pins; pause/step/resume freeze the whole
world; tape strip with click-to-seek (browser host reloads and deterministically
fast-forwards); REPL evals in the app global scope between frames; 📷
screenshot button downloads a PNG (browser: canvas; PSP: raw VRAM dump over
usbhostfs, converted by the bridge — pixels never cross the JSON channel).
Native desktop (macOS) specifics
pocket-ui-wgpu hosts (OpenStrike desktop, engine/pocket3d/examples/uihost)
carry the same file-mailbox transport as the PSP, pointed at
$POCKETJS_DBG_DIR (else the process cwd). Workflow: bun run devtools --dir <root> --port 8131 FIRST (explicit --dir wins over a running
PSPLINK session, so a PSP panel and a desktop panel can run side by
side), then launch the app with that cwd — e.g. cargo run -p openstrike
from the open-strike repo root. The app probes pocketjs-dbg/enable
once at mount, like the PSP boot probe.
- Everything except 📷 screenshots works identically (desktop
__dbgShot
is unimplemented; the panel button logs a warning). hello reports
host:"desktop" via ui.__host.
Real-PSP specifics
- Transport =
pocketjs-dbg/{enable,in,out}.jsonl on the usbhostfs share
(host0:). The app probes enable ONCE at boot — start the bridge before
launching the app, or relaunch after. No PSPLINK → zero cost.
- Shim polls every 10 frames on PSP (~166 ms hover latency); every frame on
other hosts.
console.log on PSP: the shim installs a safe no-op console at mount
(QuickJS has none) and upgrades it to a channel mirror when a transport
attaches.
- PPSSPP finds the mailbox via
host0: = the EBOOT's own directory (so
bun run devtools <app> bridging the target dir works for PPSSPP loading
that EBOOT too); the ms0: fallback also exists (memstick root).
Protocol quick reference (JSON lines)
Panel→device: inspect{id} (0 clears) · pause · resume · step ·
getTree · eval{id,code} · dumpTape · screenshot · seek{frame} /
replay{tape} (browser host-level).
Device→panel: hello{app,host,frame} · tree{root:{i,t,n,c,x,k}} ·
inspect{id,rect|null} · stats{frame,nodes,tapeLen,paused} ·
log · error{frame,message} · evalResult{id,ok,value} · tape{tape} ·
screenshot{frame,data}.
Gotchas
rect: null / debugRectXY() === -1 = the node is never painted
(display:none, detached, or inside a paint_3d perspective subtree — 3D
interiors aren't captured; the perspective ROOT is).
- Adding spec ops: edit
contracts/spec/spec.ts → bun contracts/spec/gen-rust.ts → implement
on Ui + BOTH hosts (hosts/psp/src/ffi.rs, engine/wasm/src/lib.rs) + hosts/web/ wasm-ops.js + HostOps (optional members) — tests/contract.ts locks the
spec halves together.
debugName / <Named> are JS-mirror-only: provably zero pixel impact
(goldens + tape hashes unchanged). Name every function component's root
element; the tree panel and tape tree output read like the source.
- Shim tests live in
tests/devtools.test.ts (mock ops + in-process
transport; --conditions=browser required). Core pause/inspect tests in
engine/core/src/tests.rs.
- Missing QuickJS symbols (vendored libquickjs-sys is minimal): declare a
local
extern "C" block — precedent in hosts/psp/src/main.rs and ffi.rs.
- NEVER hand VRAM addresses (
0x4xxxxxxx uncached mirror) to usbhostfs IO:
its send path runs dcache writeback + USB bulk DMA on the caller's buffer
and hangs the device on the first 64 KB block (frozen PSP, 0-byte file).
Bounce through a cached-RAM buffer in chunks (dbg::shot). ms0: (Memory
Stick driver) tolerates VRAM-direct writes — that's why cap_dump_frame
gets away with it and why PPSSPP won't reproduce the hang.
- The GE writes framebuffer alpha as 0: any raw-framebuffer consumer must
force alpha opaque (bridge
convertShot, e2e's -alpha off) or the PNG
renders fully transparent.
- When hardware behavior "mysteriously regresses" after running e2e scripts,
check WHICH build is on disk before debugging anything else: e2e capture
specs rebuild the EBOOT/PRX in place, and capture builds
sceKernelExitGame() after their window — reloading one on the PSP looks
exactly like a freeze/hang a few seconds after boot. Rebuild your intended
feature set first; only then trust symptoms. (Cost an hour on the
OpenStrike bring-up: a phantom "mailbox freeze" was a self-exiting e2e
build the whole time.)
- The whole device-side path (eval, console, screenshot) is testable
without hardware, at host0: fidelity: PPSSPP maps the EBOOT's own
directory as
host0:, so a mailbox in
hosts/psp/target/mipsel-sony-psp/debug/pocketjs-dbg/ is found by the SAME
probe hardware uses. Create enable/in/out there, run PPSSPPHeadless with
a timeout, append commands to in.jsonl mid-run, read out.jsonl. (This
also means a stale hardware-session mailbox hijacks emulator runs —
tests/e2e/ppsspp.ts removes it before each golden run.)
1---2name: pocketjs-devtools3description: Debug PocketJS apps with Pocket DevTools — deterministic input-tape time travel, component-tree inspection with on-device highlight, pause/step, REPL eval and console on real PSP hardware over PSPLINK, on-demand screenshots, and headless tape replay for regression evidence. Use when asked to debug UI behavior, reproduce or bisect a visual regression, inspect the component tree, capture device screenshots, or verify a fix against a recorded session.4---56# Pocket DevTools78## Overview910PocketJS is fixed-dt deterministic and its entire per-frame input is one11button bitmask, so a recorded input tape replays any session byte-exactly.12DevTools (design: repo `docs/DEVTOOLS.md`) is built into every bundle: a flight13recorder is ALWAYS on (36 000-frame ring), and a JSON-line debug channel14connects the runtime to a desktop panel over WebSocket (browser host) or the15PSPLINK USB mailbox (real PSP).1617Prefer the headless tape workflow for agent debugging — every question is18answerable from the terminal. Use the panel when a human is co-driving.1920## Headless workflow (no screen needed)2122Keep per-run tapes, hashes, captures, and logs in ignored output. For example:2324```bash25mkdir -p .pocket-build/validation/devtools26bun run tape record hero-main --frames 180 --input "5:64,40:8192" --out .pocket-build/validation/devtools/t.json27bun run tape replay hero-main .pocket-build/validation/devtools/t.json --hashes .pocket-build/validation/devtools/h.json28bun run tape replay hero-main .pocket-build/validation/devtools/t.json --assert .pocket-build/validation/devtools/h.json --outdir .pocket-build/validation/devtools29bun run tape replay hero-main .pocket-build/validation/devtools/t.json --png 60,120 --outdir .pocket-build/validation/devtools30bun run tape tree hero-main .pocket-build/validation/devtools/t.json --at 6031bun run tape:check # committed session golden32```3334- Input masks are contracts/spec/spec.ts `BTN` values (`CIRCLE=0x2000=8192`, `DOWN=0x40=64`).35- A regression workflow: record/obtain a tape on the OLD build → `--hashes` →36 switch builds → `--assert` names the exact first frame that changed →37 `--png <that frame>` on both builds to see the difference.38- Tapes exported from ANY host (panel "Export", or `__pocketDevtools.dumpTape()`39 in the REPL) replay headlessly. `startFrame > 0` means the ring wrapped —40 replay is then an approximation (warned automatically).41- Committed session goldens live in `tests/tapes/`; regenerate hashes only when42 a visual change is intended.43- A replay capture or exported session is temporary validation output. Promote44 it to a committed fixture only when a named regression test consumes it.45 Put selected screenshots in PR attachments and keep raw captures, logs and46 receipts outside the tracked tree, following `AGENTS.md`/`CLAUDE.md`.4748## Panel workflow (one command)4950```bash51bun run devtools # panel + WS hub + mailbox bridge, one process52bun run devtools cards # + build, USB-link and launch cards on a real PSP53```5455Panel at `http://127.0.0.1:8130/devtools`. `bun run devtools` auto-detects an56already-running `bun psplink` / `bun run hw` usbhostfs session and bridges57into it instead of fighting for the cable (relaunch the app there so it58probes the mailbox at boot). Shortcuts: `o` open panel, `r` rebuild+relaunch59(managed sessions), `q` quit.6061Panel capabilities: hover tree → highlight ON THE DEVICE SCREEN (core-drawn62overlay, works on real PSP); click pins; pause/step/resume freeze the whole63world; tape strip with click-to-seek (browser host reloads and deterministically64fast-forwards); REPL evals in the app global scope between frames; 📷65screenshot button downloads a PNG (browser: canvas; PSP: raw VRAM dump over66usbhostfs, converted by the bridge — pixels never cross the JSON channel).6768## Native desktop (macOS) specifics6970- `pocket-ui-wgpu` hosts (OpenStrike desktop, `engine/pocket3d/examples/uihost`)71 carry the same file-mailbox transport as the PSP, pointed at72 `$POCKETJS_DBG_DIR` (else the process cwd). Workflow: `bun run devtools73 --dir <root> --port 8131` FIRST (explicit `--dir` wins over a running74 PSPLINK session, so a PSP panel and a desktop panel can run side by75 side), then launch the app with that cwd — e.g. `cargo run -p openstrike`76 from the open-strike repo root. The app probes `pocketjs-dbg/enable`77 once at mount, like the PSP boot probe.78- Everything except 📷 screenshots works identically (desktop `__dbgShot`79 is unimplemented; the panel button logs a warning). `hello` reports80 `host:"desktop"` via `ui.__host`.8182## Real-PSP specifics8384- Transport = `pocketjs-dbg/{enable,in,out}.jsonl` on the usbhostfs share85 (`host0:`). The app probes `enable` ONCE at boot — start the bridge before86 launching the app, or relaunch after. No PSPLINK → zero cost.87- Shim polls every 10 frames on PSP (~166 ms hover latency); every frame on88 other hosts.89- `console.log` on PSP: the shim installs a safe no-op console at mount90 (QuickJS has none) and upgrades it to a channel mirror when a transport91 attaches.92- PPSSPP finds the mailbox via `host0:` = the EBOOT's own directory (so93 `bun run devtools <app>` bridging the target dir works for PPSSPP loading94 that EBOOT too); the `ms0:` fallback also exists (memstick root).9596## Protocol quick reference (JSON lines)9798Panel→device: `inspect{id}` (0 clears) · `pause` · `resume` · `step` ·99`getTree` · `eval{id,code}` · `dumpTape` · `screenshot` · `seek{frame}` /100`replay{tape}` (browser host-level).101Device→panel: `hello{app,host,frame}` · `tree{root:{i,t,n,c,x,k}}` ·102`inspect{id,rect|null}` · `stats{frame,nodes,tapeLen,paused}` ·103`log` · `error{frame,message}` · `evalResult{id,ok,value}` · `tape{tape}` ·104`screenshot{frame,data}`.105106## Gotchas107108- `rect: null` / `debugRectXY() === -1` = the node is never painted109 (display:none, detached, or inside a `paint_3d` perspective subtree — 3D110 interiors aren't captured; the perspective ROOT is).111- Adding spec ops: edit `contracts/spec/spec.ts` → `bun contracts/spec/gen-rust.ts` → implement112 on `Ui` + BOTH hosts (`hosts/psp/src/ffi.rs`, `engine/wasm/src/lib.rs`) + `hosts/web/113 wasm-ops.js` + `HostOps` (optional members) — `tests/contract.ts` locks the114 spec halves together.115- `debugName` / `<Named>` are JS-mirror-only: provably zero pixel impact116 (goldens + tape hashes unchanged). Name every function component's root117 element; the tree panel and `tape tree` output read like the source.118- Shim tests live in `tests/devtools.test.ts` (mock ops + in-process119 transport; `--conditions=browser` required). Core pause/inspect tests in120 `engine/core/src/tests.rs`.121- Missing QuickJS symbols (vendored libquickjs-sys is minimal): declare a122 local `extern "C"` block — precedent in `hosts/psp/src/main.rs` and `ffi.rs`.123- NEVER hand VRAM addresses (`0x4xxxxxxx` uncached mirror) to usbhostfs IO:124 its send path runs dcache writeback + USB bulk DMA on the caller's buffer125 and hangs the device on the first 64 KB block (frozen PSP, 0-byte file).126 Bounce through a cached-RAM buffer in chunks (`dbg::shot`). ms0: (Memory127 Stick driver) tolerates VRAM-direct writes — that's why cap_dump_frame128 gets away with it and why PPSSPP won't reproduce the hang.129- The GE writes framebuffer alpha as 0: any raw-framebuffer consumer must130 force alpha opaque (bridge `convertShot`, e2e's `-alpha off`) or the PNG131 renders fully transparent.132- When hardware behavior "mysteriously regresses" after running e2e scripts,133 check WHICH build is on disk before debugging anything else: e2e capture134 specs rebuild the EBOOT/PRX in place, and capture builds135 `sceKernelExitGame()` after their window — reloading one on the PSP looks136 exactly like a freeze/hang a few seconds after boot. Rebuild your intended137 feature set first; only then trust symptoms. (Cost an hour on the138 OpenStrike bring-up: a phantom "mailbox freeze" was a self-exiting e2e139 build the whole time.)140- The whole device-side path (eval, console, screenshot) is testable141 without hardware, at host0: fidelity: **PPSSPP maps the EBOOT's own142 directory as `host0:`**, so a mailbox in143 `hosts/psp/target/mipsel-sony-psp/debug/pocketjs-dbg/` is found by the SAME144 probe hardware uses. Create enable/in/out there, run PPSSPPHeadless with145 a timeout, append commands to in.jsonl mid-run, read out.jsonl. (This146 also means a stale hardware-session mailbox hijacks emulator runs —147 tests/e2e/ppsspp.ts removes it before each golden run.)