Skip to content

Debug GBA programs from an IDE: debug-core, a Debug Adapter, the panels, and a VS Code extension - #16

Open
macabeus wants to merge 33 commits into
mainfrom
ide-debugger
Open

Debug GBA programs from an IDE: debug-core, a Debug Adapter, the panels, and a VS Code extension#16
macabeus wants to merge 33 commits into
mainfrom
ide-debugger

Conversation

@macabeus

@macabeus macabeus commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Debug a GBA ROM where the code is written: breakpoints in C, DWARF-typed variables, data breakpoints, replay-exact rewind, and the screen, PPU and I/O views beside the source. Built as four layers so a second editor costs only the thin top one.

What this adds

Unit What it is
@gba-kit/debug-core The IDE-agnostic session: breakpoints of every kind, gdb-style stepping, DWARF values, replay-exact rewind, trace and event logs, labels, recordings, save states, PPU and I/O views
@gba-kit/debug-adapter A Debug Adapter Protocol server, plus gba-kit/* custom requests, a frame/audio/input pipe, and gba-kit-screen for editors with no display
@gba-kit/debug-ui Twelve React panels over a Transport seam, hosted by both a VS Code webview and the webapp
apps/vscode-extension The editor layer: debugger contribution, Screen and Tools webviews, commands, an Extension Development Host suite

The emulator, the DWARF reader and the disassembler gained what a debugger needs underneath: execution that stops on a predicate, bit-exact snapshot restore, a hardware event sink, read and write watchpoints, DWARF scopes, locations, values and call-frame unwinding, and statement-aware line queries.

The webapp's Debug page now runs on the same core and panels, sharing one machine with the Play page.

Notable design decisions

  • DAP is the seam. Nothing below it knows about an editor, so Neovim, Emacs, Zed and JetBrains work through the same adapter; the README carries their recipes.
  • Frames never travel over the protocol. They go through a pipe the client owns, so inspection stays responsive while the game runs.
  • Honest capabilities. supportsStepBack is advertised only because step-back really is replay-exact; set-next-statement is not claimed.
  • Inlined calls are hidden layers, as gdb treats them: a step-over walks past one, a step-into reveals it.

Verification

  • 43 turbo tasks green: build, test, check-types, lint, check-deps, plus prettier.
  • 901 tests, including three owned fixture variants (Thumb -O0, Thumb -O2, ARM -O0) whose ROM/ELF pairs are committed and rebuilt in CI, headless DAP tests against a real child process, and an Extension Development Host suite.
  • Exercised against real projects: the kleod and Klonoa decompilations and balatro-gba.

Audit

The branch was then reviewed by an adversarial multi-agent audit: 173 findings raised, 151 confirmed, 149 fixed across the last six commits, each fix independently re-checked. It found and fixed, among others, three reported defects: an "empty expression" notification on every breakpoint stop, enum constants unresolved in hover, and stop-recording opening an editor instead of the Recording tab.

Two defects survived that audit's own validation and were caught by re-running the suite by hand: reverse continue cached one expression environment per frame, so a condition was judged in another breakpoint's scope and a false hit stopped the machine; and a test compared a process-wide timer count for equality, so it failed under load.

Known gaps

  • session.breakpoints.replaceData records a data breakpoint without installing a watchpoint. No shipped caller reaches it.
  • The webview grants blob: to worker-src rather than script-src for the audio worklet.
  • The Screen panel's own stop-recording branch has no test, though the four layers beneath it do.

🤖 Generated with Claude Code

macabeus and others added 30 commits September 5, 2026 03:35
…epping, DWARF values and exact rewind

`@gba-kit/debug-core` is the IDE-agnostic layer a Debug Adapter, a browser
page or a test drives the same way. A `Session` owns one machine and answers
in source lines, call frames, typed values, frames and scanlines.

- Execution under a stop predicate: every stop is an exact (frame,
  instruction) position, and frames stay on the hardware grid.
- Breakpoints: source lines resolve to the starts of a line's statement runs
  (one per piece of code the compiler emitted for it, `is_stmt` rows only), or
  to the entry of a call inlined at that line, which has no rows of its own;
  instruction and function breakpoints; conditions, hit counts and logpoints
  in a Mesen-style expression grammar; data breakpoints for writes, reads or
  both (read watchpoints are new in the bus), naming the writer or the DMA
  that did it; event breakpoints on VBlank, HBlank, IRQs, DMA, I/O and halts.
- Stepping like gdb: statement rows, CFA frame identity, inlined calls as
  hidden layers. A stop at the entry of an inlined call shows the call site
  until the user steps in, whether it came from a breakpoint, a step-out, a
  pause or a rewind (gdb's rule for inline frames).
- Call stacks, scopes and values from the DWARF, with writable scalars.
- Replay-exact rewind from keyframes and an input log: step back, reverse
  continue to the previous breakpoint hit, rewind N frames; re-running from a
  rewound point reproduces the original run byte for byte.
- Trace ring, hardware event log, labels persisted per project, input
  recording and replay, save states bound to the ROM hash, memory search, and
  the palette / tile / tilemap / sprite / I/O views.

Tested on one C program built as Thumb -O0, Thumb -O2 and ARM -O0; the
ROM/ELF pairs are committed and CI rebuilds them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvrRmTvh3Qhu7CCeM65xFJ
`@gba-kit/debug-adapter` puts a `@gba-kit/debug-core` session behind DAP:
breakpoints of every kind (lines, functions, instructions, conditions, hit
counts, logpoints, read/write data breakpoints with the writer named, hardware
events as exception filters), stepping by statement or instruction with the
response always ahead of the stop it causes, a call stack with inlined frames,
DWARF-typed variables with memory references and evaluate names, writable
scalars and registers, hover/watch evaluation, disassembly with symbols and
labels, memory, loaded sources, restart, and replay-exact stepBack and
reverseContinue. Variable references from before the machine last moved are
refused as stale.

What only an emulator has is a `gba-kit/*` custom request, typed in
`@gba-kit/debug-adapter/protocol`: buttons, frame and scanline steps, rewind by
frames, save states, input recordings, the PPU and I/O views, trace and event
logs, labels, memory search, and a frame/audio stream over a pipe the client
owns, so frames never queue behind the DAP connection. Launch diagnostics
refuse an ELF that is not the ROM's build unless told otherwise, and say when
no source file was found under cwd.

`dist/cli.js` runs it over stdio for any editor; the class also runs
in-process. The tests drive it with real DAP messages over in-memory streams
against the debug-core fixtures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvrRmTvh3Qhu7CCeM65xFJ
…ools as React panels

`@gba-kit/debug-ui` is the set of debugger panels an editor has no native view
for, over a `Transport` seam so one implementation serves a VS Code webview
(postMessage to the extension host) and the webapp (direct calls into a
debug-core session) alike:

- Screen with keyboard and gamepad input as one button mask, audio through an
  AudioWorklet fed from a ring buffer, and run/pause, frame step, rewind and
  record.
- Palette, tiles (any character base, 4/8 bpp, palette bank), tilemap rendered
  from its tiles, sprites as an OAM table with each one painted (1D and 2D
  mapping), I/O registers with decoded fields, the instruction trace, the
  hardware event log, memory search with narrowing, labels with .sym import
  and export, save states, and input recordings.
- `DebugPanels` puts them behind tabs; `--gk-*` variables paint them in the
  host's theme.

The pixel helpers, transports, key maps and views are tested in Node; the
in-process transport against a real session on the debug-core fixtures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvrRmTvh3Qhu7CCeM65xFJ
…lay page's machine

The Debug page now drives a `@gba-kit/debug-core` session instead of the
browser bridge's ad-hoc breakpoints and steps: statement stepping, step
into/out/back, frame steps, rewind, the session's disassembly with labels and
source lines, and its register view (`lr`/`pc` symbolized, `cpsr` decoded).
The emulator views come from `@gba-kit/debug-ui`: the screen, I/O registers,
palette, tiles, tilemap, sprites, trace, events, memory search and labels,
replacing the page's own screen and I/O views. The dev server's sidecar ELF
loads once at the app level and the Source view's picker hands a chosen ELF
up, so the session is rebuilt with it.

Play and Debug take turns driving one `Gba`: a session can wrap an existing
machine (`SessionOptions.machine`) and `resync()` when someone else drove it
(returning from Play, loading a save state), forgetting the history that no
longer describes the machine and re-arming its hooks. History is anchored at
the frame a session starts or resyncs on, so step-back works right away.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvrRmTvh3Qhu7CCeM65xFJ
…lain process

- `gba-kit-screen`: a browser page with the GBA display and a keyboard gamepad,
  served by a dependency-free HTTP + WebSocket server that owns the pipe the
  adapter is told about with `gba-kit/stream`. The pipe is now two-way: a
  `type 3` message carries a button mask back, so a screen that is not a DAP
  client still plays.
- A second client: the adapter spawned as a child process on stdio, driven with
  hand-framed DAP messages through a launch, a function breakpoint, a stop, a
  stack and a disconnect that exits the process.
- Neovim, Emacs, Zed and JetBrains recipes in the adapter's README; the root
  README lists the new packages and the extension.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvrRmTvh3Qhu7CCeM65xFJ
…sion Development Host suite

`apps/vscode-extension` is the thin editor layer over the DAP seam: it
registers the `gba-kit` debugger (the bundled adapter as its own Node process,
inline in the extension host when `node` is not on the PATH or by setting
`gba-kit.adapter`), owns the Screen and Tools webviews that host
`@gba-kit/debug-ui`, and exposes commands that are one custom request each:
frame and scanline steps, rewind, input recording, save and load states, label
import and export. Frames and audio reach the webviews over a pipe the
extension owns; the panels talk to the extension host in messages that a
tested bridge routes to the debug session.

esbuild produces `dist/extension.js`, `dist/adapter.js` and the browser bundle
`dist/webview.js` + `webview.css`; `vsce package --no-dependencies` makes a
355 KB .vsix from them. `test:vscode` runs a suite inside an Extension
Development Host on the debug-core fixtures: a breakpoint set before launch is
sent during configuration and verified, hits in `main` on the right line,
scopes and a DWARF value read through VS Code, a frame step through the
extension's command, both panels open, the session ends. The DAP traffic is
recorded through VS Code's tracker API for the assertions and the failure
report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvrRmTvh3Qhu7CCeM65xFJ
Remediation of the audit findings in the emulator, the DWARF reader and the
session core.

- Reverse continue judged every condition in one expression environment per
  frame, so a breakpoint's condition was evaluated in the scope of whichever
  breakpoint the frame reached first. Its locals were missing there, and a
  condition that cannot be evaluated counts as a hit, so the scan reported
  breakpoints the forward run never reached. The environment is now built per
  address, and reverse replay evaluates conditions, reconstructs hit counts and
  consults data and event breakpoints as its documentation always claimed.
- Enum constants resolve: `DwarfScopes.enumeratorByName` indexes every
  enumeration in the ELF, and the inspector consults it after locals, globals
  and linker declarations, so a variable still shadows a constant. Hovering an
  enumerator now answers with its value and type instead of "unknown symbol".
- A halted CPU no longer counts polls as instructions, so a position taken
  while halted stays exact across replay.
- A throwing `output` listener no longer recurses into `#emit` until the stack
  overflows; `restart` and `resync` route through `#stop`, so a state change is
  always announced.
- Hit counters reset with the machine, unnamed constants got names, duplicated
  bitfield extraction and the replay-mode ritual were folded into one place,
  and several doc comments that described code other than the one below them
  were corrected.

The new reverse-continue test fails on all three fixture variants without the
scoping fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Remediation of the audit findings in the debug adapter.

The adapter reported every failure with `sendErrorResponse(response, <number>,
<message>)`, whose default destination is the user, so VS Code turned an error
response into a modal notification. Its own hover and watch send `evaluate`
with an empty expression on a stop, which the core rightly refuses, so a
breakpoint in a decomp raised an "empty expression" notification each time the
machine stopped. Inspection, execution and custom-request failures now answer
through `#fail`, which reports in the request's own response; only a launch
that cannot start still asks to be shown, because nothing else would surface
it. `evaluateRequest` also checks that an expression is a string before
trimming it, instead of crashing on a request without one.

Also here: variable references re-expand after a write instead of going stale,
the frame stream drops a slow consumer's backlog rather than growing it,
`ScreenServer` honours the host and port it is given, and the timer test states
what it actually pins, so it no longer fails when an unrelated timer ends
during a launch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…self

Remediation of the audit findings in the panels.

"Stop recording" on the Screen panel opened an editor holding the script, which
is not where a recording is reviewed or replayed. The transport gained
`showPanel`, so stopping now reveals the Tools panel on its Recording tab; a
host without one still gets the editor. The Recording panel shows the session's
last recording whoever stopped it, and its passive refresh no longer writes to
the error line, which belongs to what the user just did, nor asks anything
before a session exists.

Also here: a panel subscribes to a feed on its first listener and unsubscribes
on its last, so a hidden panel is not sent 150 KB frames; the state a panel
renders comes from one place; and the pixel helpers handle a short palette, an
odd sprite tile and a truncated tilemap rather than reading past their arrays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s panel

Remediation of the audit findings in the VS Code extension.

The host bridge now holds a `showPanel` request until the webview subscribes,
so the Tools panel opens on its Recording tab even when the panel is created by
that same request, and the recording command reveals it instead of opening an
editor. Frames are withheld from a hidden panel and the newest is sent once it
is shown again, while audio keeps flowing, because a user may listen while
reading code. Feeds are dropped when their last listener goes.

The frame server moved out of `extension.ts` into its own module with tests,
and the webview shell states the content policy the panels actually need.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Remediation of the audit findings in the webapp and the browser bridge.

Instruction breakpoints live in the session rather than in the Debug page's
state, so switching to Play and back shows the breakpoints that are actually
set. The memory viewer honours what the bus reports as readable and draws
unmapped bytes as such instead of as zeros, and the register view reads the
session's own naming.

`EmulatorBridge.refreshFrame` re-reads the PPU into the canvas, so the Play
screen and a save state's thumbnail show the machine as the debugger left it
rather than the last frame Play itself drew.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pnpm-lock.yaml` for the dev dependencies the audit's new tests and checks
introduced across the workspace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A console line of the shape `place = value` stores, where until now the console
could only read. The place is resolved exactly as any other expression is, so
`gUnk_03005220.dreamStones = 10` and `gEntityInfo[3].id = 2` name a member and
an element the same way a watch or a hover names them, and the value is an
expression too (`g_samples[2] = g_samples[1] + 1`). The write itself is the one
the variables view already performs, so a bitfield, an enum and a narrow int
are range-checked and refused by name rather than silently truncated.

`=` is told apart from `==`, `!=`, `<=` and `>=`, so a comparison typed in the
console stays a comparison; a compound operator is refused by name instead of
failing as the unreadable target `x +`. Only the console writes: a hover over
`a = b` in the source reads, because an editor sends whatever is under the
mouse and must never store it.

A client that asked for `invalidated` is told the variables it holds went
stale, so the views re-read after a console write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four packages ran on vitest's 5s default while their tests boot a GBA and run
hundreds of frames, which is seconds of honest work. On a loaded machine or a
busy CI runner they crossed the line and the build read as broken: the same
commit passed on one runner and failed on another, on the emulator's
predicate-stop test and the webapp's thumbnail test. They now allow 20s, as
the debug adapter's tests already did. No assertion changed; only the clock
they are judged against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The screen is what a debug session is for, so it now comes up with one instead
of waiting for the command. A panel that is already open is left exactly where
it is rather than revealed, so a session starting never pulls a tab out from
under whoever is reading it, and the panel is created before the frame stream
is attached, so the first frame reaches it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A screen exists to be watched, so a session with one up runs instead of sitting
at its entry stop waiting for Run. It happens once per session, and only from
the entry stop: opening the screen while stopped at a breakpoint leaves the
machine exactly where it stopped. Opening the screen later, at a session that
is already sitting at its entry, runs it then.

`gba-kit/state` now carries the reason the machine is stopped, which is what
tells an entry stop from a breakpoint; any client can use it to say why it is
paused.

This does mean `stopOnEntry: true` runs on as soon as its screen appears. Set
it to false to skip the stop altogether, or close the screen to sit at it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Recording panel showed only the last recording, as a script and a row of
buttons. It now lists every recording of the session as a grid of Thumbnail,
Script and Actions.

- The session keeps its finished recordings, each with the screen it began on
  (halved in each axis, so a row costs kilobytes rather than the 150 KB a
  frame does), and drops the oldest past twenty so a long session is bounded.
- A recording is replayed either from where it was recorded, which puts the
  machine back there and reproduces it, or from here, which presses the same
  buttons wherever the machine is now — a recorded move used somewhere else.
- The script's own open button floats in its corner instead of sitting in a
  row, and it is offered only when the host has an editor to open it in.
- The log button is gone: the log is what the script says, in a form nobody
  reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pass over every comment and every line of prose this branch wrote, against
the code beneath it.

- Corrected what was untrue. Among them: a full keyframe is written every
  `fullEvery` + 1, not every `fullEvery`; `stepFrame` finishes the current
  frame rather than advancing a whole one; `romHash` falls back to a length
  when `crypto.subtle` is absent; the tile cap is 2048 4bpp tiles, not 8bpp;
  `epoch` moves on a restart and a resync; `decodeDelta` never writes in
  place; the I/O table is a chosen list, not every register the emulator
  models; address-class DWARF forms are read LSB-first, so a big-endian
  target's DIE addresses are wrong; a register-resident local is shown, not
  written; a variable reference is dropped when the machine moves, and refused
  only across a restart.
- Deleted what the code already said, and what narrated the change rather than
  describing the code.
- Matched each file's own voice, and cut the rest for length.

No executable line moved; the suite is unchanged and green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment pass found sixteen places where a comment described a defect
instead of the code being right. Each is fixed at the source, with a test that
fails without it.

- The DWARF reader honours the payload's byte order everywhere. `readBytes`
  assembled address forms LSB-first, so `DW_AT_low_pc` was misread on a
  big-endian target and no function resolved at all; the `.debug_addr`,
  `rnglistx` and `loclistx` index tables read the same way round. The order is
  now a field of `DwarfSections`, so a reader cannot forget to ask.
- Every 32-bit bus read returns an unsigned word. A word with bit 31 set was
  negative, which is why `disassembleFunction` never recognised `bx lr` and a
  32-bit `searchMemory` never matched such a value.
- A line whose run begins with a non-statement row keeps its address, instead
  of losing the statement row behind it.
- `loadState` starts a new epoch, so a client's handles are stale rather than
  silently pointing at another machine.
- `EntryIndex.unit` names the offset it cannot find instead of answering with
  another unit's bases.
- A write from the console tells the session's listeners, so the webapp's
  memory, register and disassembly views refresh rather than showing stale
  bytes until the next stop.
- The event log names the register a write landed in, as the stop does.
- Loading a state trims the name the way saving it did, so a state saved as
  ` hi ` can be loaded again.
- `#memberFacts` exposes the array rank it already returned; `CpuSnapshot`
  loses a field nothing ever set (a snapshot carrying it still loads, and the
  changeset says so); `dataSpecs` and an unreachable name fallback are gone.
- The timer round-trip program enables a timer, which it never did, so the
  test exercises the overflow drift it was written for. Three test names now
  describe what their bodies do, and a DWARF 5 case reaches the path it claims.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaying a recording ran its frames in one blocking loop inside the request,
so the machine arrived at the end before the caller heard anything. On a game
that spends its frame asleep in `VBlankIntrWait`, both replay actions landed
back on that same line with nothing on screen to say they had run.

A replay now drives the same paced loop a resume does: one recorded mask a
tick, over any button the user is still holding, so the playback is watched at
the speed it was recorded and a breakpoint, a stall or a pause ends it where it
hit. `Session.replaying` and the state body's `replaying` say one is under way,
and the Recording panel says so too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A save state is the whole machine at a moment; the game's own battery save is
something else, so the term stays "save state" everywhere a user reads it. The
tool tab said only "States", which also collided with the running/stopped state
the panels report. It now says what it holds.

A state keeps the screen it was saved on, as a 120×80 thumbnail written before
the snapshot so a listing still reads it from the file's head. The GBA Screen
panel gained a drawer of this ROM's states, each shown as that screen: click one
to load it, or rename and delete it in place. The tool panel lists the same ones
as a table with the same three actions.

`gba-kit/renameState` and `gba-kit/deleteState` carry that over the protocol,
answered by the adapter and by the in-process transport alike. `HostFiles` grew
an optional `remove`, and a host without one refuses to delete rather than
pretending the file went away. The webapp's own drawer now names a new save for
the frame it holds, as the debugger does, instead of counting saves this session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six of this package's Effects were doing work React has a better answer for.

The debugger's state was subscribed to and copied into component state, so a
panel's first render always showed "connecting" and the real state arrived a
render later. The transport is the store, so the state now comes from
`useSyncExternalStore`: it is read while rendering. Both transports keep one
state object per change, and the in-process one follows the session itself
rather than leaving that to whoever subscribed, so a panel rendering before it
subscribes still sees the machine as it is.

Painting a canvas was written out four times. `Screenshot`, the recording
thumbnail and the sprite preview now all go through `usePixels`, the one place
that touches a canvas, and the sprite's pixels are worked out while rendering
instead of inside the Effect that paints them.

Three panels each fetched a list their own way, none of them guarding against a
slow response landing after a newer one. They share `useFetched` now, which
drops a response that a newer read or an unmounted view has overtaken, and
`useAtStop` is written in terms of it. Two paths that both refreshed a list —
the panel's own action and the event the session raises — are one path: the
event, which a recording stopped elsewhere or a label named in the editor
already travels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The panels drew their actions with whatever glyph came to hand: emoji for sound
and delete, arrows and geometric shapes elsewhere. VS Code ships the icons it
uses for these ideas, so the panels use those: the codicon font, named as the
editor names them, through one `Icon` component whose set is closed. A run
button is `debug-continue`, a rewind is `debug-step-back`, a delete is `trash`.
The font ships with the extension beside the stylesheet that loads it, and the
webapp gets the same set from the same import.

A destructive action no longer shouts before it is reached. Delete is the
editor's toolbar button — the glyph alone, its frame drawn under the pointer,
and only then coloured as a warning — instead of a red-bordered button sitting
in a row of cards.

A save state's screen was drawn at the size it was stored at, which overflowed
the card holding it. A `Screenshot` without a `scale` now takes the width it is
given and keeps its shape, so a card's screen is the card's width whatever the
stored size, and the recording table draws its screen at the size it was stored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A recording lived only as long as the session that made it. It now goes under
the project's `.gba-kit/recordings/`, one file per take carrying the input, the
screen it began on and when it was made, and a session opened later lists and
replays what earlier ones recorded.

The file is named for the moment it was taken and the frame it starts on, so two
takes of the same frame are two files. Nothing is deleted to make room: a launch
reads newest first and stops at the twenty a session lists, leaving older files
where they are, and `gba-kit/deleteRecording` removes one when the user asks.
A file that is not one of ours, or belongs to another ROM, is passed over.

The take is answered from the session and written to disk after, so this session
reads its own recordings from memory and the file is for the next one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AJKogeQcw1deu97DMTNs42
macabeus and others added 3 commits September 6, 2026 17:08
"From where recorded" rewinds to the frame the recording starts at, which a
session opened later cannot reach: it never ran those frames, so there is no
history to rewind through, and the action failed with "no longer in history".

A recording now carries the machine as it was on its first frame. Not as a save
state, which would be near a megabyte a take, but run-length encoded against
nothing: most of a GBA's RAM is zero, so a ~470 KB snapshot packs to ~24 KB and
a recording file goes from 51 KB to 86 KB. Rewinding is still tried first, being
exact and cheap; the stored state is the way back when it cannot reach.

`gba-kit/replay` takes the `id` of the take a recording came from, so the
session can use the state it kept for it. A recording replayed from somewhere
else, or one written before this, replays from here as it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AJKogeQcw1deu97DMTNs42
The two grew up side by side and had drifted into saying the same things twice,
sometimes three times.

In debug-core, a `Screen` is now one type with one pair of converters, used by
both file formats instead of each spelling out `{width, height, rgba}` and its
base64. A session names its own `.gba-kit/` paths, so the directory is written in
one place rather than in the adapter twice and the core once, and takes the
screen it keeps beside a state or a recording through one method.

The protocol builds the bodies for a saved state and a take, so the debug adapter
and the in-process transport agree on their shape without either restating it.
That also retires the transport's own base64, which was a second implementation
of one the core already exported, and the adapter's use of `Buffer` for the same.

In debug-ui, the save states panel and the recording panel had a copy each of
"run this, show that it is running, show why it failed"; they share `useAction`
now. Renaming in place had a copy in the panel and another in the drawer, both
carrying a draft, a commit and the two keys; `EditableName` is that once, and an
uncontrolled field, so Escape is putting the name back rather than a second path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Following the same idea outward from save states and recordings.

The two implementations of the protocol were each building the PPU views and the
screen body by hand, and had already drifted: one read a client's numbers as
numbers, the other trusted them, and each had its own base64. Both come from the
protocol now, which is where the shape they must agree on belongs.

The session announced a run in four places and started a paced one in two;
`#beginRun` and `#runPaced` say it once. Pressing a button and setting the whole
mask both spelled out when input reaches the machine and when it waits for the
frame boundary; `#holdButtons` is that rule, in one place, with the reason.

The adapter reported a breakpoint three times over and asked for its session
twice; both are one function now. Two panels drew the same "when did this
happen" columns of a log table, two views of a save state drew the same rename
and delete, and a renderer wrote a pixel two ways. Each is one thing now, and
the save-state pieces both views share live in a module of their own rather than
inside one of them.

The panels also had seven copies of one magic padding and three of another;
those are two named classes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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