diff --git a/.changeset/config.json b/.changeset/config.json
index 567384e..aa7f702 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -2,7 +2,7 @@
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
- "fixed": [["@gba-kit/*"]],
+ "fixed": [["@gba-kit/*", "gba-kit-vscode"]],
"linked": [],
"access": "public",
"baseBranch": "main",
diff --git a/.changeset/ide-debugger-debug-adapter.md b/.changeset/ide-debugger-debug-adapter.md
new file mode 100644
index 0000000..6624b6d
--- /dev/null
+++ b/.changeset/ide-debugger-debug-adapter.md
@@ -0,0 +1,14 @@
+---
+'@gba-kit/debug-adapter': minor
+'@gba-kit/debug-core': minor
+---
+
+New package `@gba-kit/debug-adapter`: a Debug Adapter Protocol server for GBA programs. Any editor with a DAP client (VS Code, Neovim, Emacs, Zed, JetBrains) launches `npx @gba-kit/debug-adapter` and gets a source-level debugger for a ROM: breakpoints of every kind (line, function, instruction, conditional, hit count, logpoint, data breakpoints on reads and writes naming the code that touched the range, hardware events as exception filters), stepping by statement or instruction, a call stack with inlined frames, DWARF-typed variables with memory references and evaluate names, writable scalars and registers other than `cpsr`, hover/watch evaluation, disassembly with symbols and labels, memory read and write, loaded sources, restart (which reloads the ROM and ELF from disk, breakpoints carried over), and replay-exact `stepBack` / `reverseContinue`.
+
+Emulator-only operations are `gba-kit/*` custom requests, typed in `@gba-kit/debug-adapter/protocol` (a re-export of `@gba-kit/debug-core/protocol`, where the vocabulary lives so browser clients need no Node package): buttons, frame and scanline steps, rewind by frames, save states (save, list, load, rename, delete), input recordings (kept under the project and listed again in the next session, replayable from where each was recorded, with delete), the palette / tiles / tilemap / sprite / background views, decoded I/O registers, trace and event logs, labels, memory search, and a frame/audio stream over a pipe the client owns. A `gba-kit/state` event reports every stop, resume and rewind, and every recording start or stop and tracing toggle; `gba-kit/lastRecording` hands out the recording last stopped, whoever stopped it.
+
+`gba-kit-screen` (`npx -p @gba-kit/debug-adapter gba-kit-screen`) is a browser page with the display and a keyboard gamepad for editors that have none, fed by the adapter over the same pipe (which is two-way: the page's button presses come back). `newPipePath()` names a fresh pipe for a client to listen on.
+
+Launch diagnostics refuse an ELF whose loadable bytes differ from the ROM (naming the first mismatching section) unless `allowElfMismatch` is set, and say when no source file was found under `cwd`. Responses always precede the `stopped` they cause, and variable references are dropped whenever the machine moves, so a client expands again at the new stop; one held across a restart is refused as stale.
+
+`@gba-kit/debug-core`: `Program.hasCodeAt` (for `breakpointLocations`), and `SourceMapper.localFiles` keeps the file system's spelling on case-insensitive systems.
diff --git a/.changeset/ide-debugger-debug-core.md b/.changeset/ide-debugger-debug-core.md
new file mode 100644
index 0000000..8abd3ee
--- /dev/null
+++ b/.changeset/ide-debugger-debug-core.md
@@ -0,0 +1,19 @@
+---
+'@gba-kit/debug-core': minor
+---
+
+New package: an IDE-agnostic debugging session for GBA programs, the layer a Debug
+Adapter Protocol server, a browser page or a test drives the same way.
+
+- **A `Session` owns one machine** and answers in addresses, frames, symbols and typed values. It runs frame by frame under a stop predicate, so every stop lands on an exact (frame, instruction) position and the hardware frame grid never drifts.
+- **Breakpoints of every kind**: source lines (statement rows, or the entry of a call inlined at that line; a line without code slides forward), instruction addresses, function names, conditions, hit counts (`3`, `>= 3`, `% 4`) and logpoints with `{expressions}`; data breakpoints on a typed variable path, a symbol's whole extent, a label or a hex address, for writes, reads or both, naming the code that touched it (or the DMA channel and the instruction that started it); event breakpoints on VBlank, HBlank, IRQ request/entry, DMA, I/O writes and halts.
+- **Stepping the way gdb steps**: instruction, statement (over, into, out), frame and scanline. Frames are told apart by their CFA, so recursion and leaf functions step correctly; inlined calls are hidden layers a step-over walks past and a step-into reveals, and a stop at the entry of an inlined call shows the call site until stepped into.
+- **Call stacks, scopes and values from the DWARF**: physical frames unwound through `.debug_frame` with a link-register fallback, inlined frames in between, locals and parameters with their location at this PC (or where the compiler did keep an optimized-out value), globals, registers and machine state; values unfold structs, unions, bitfields, arrays, enums and pointers, and a scalar that lives in memory is writable, as are the registers of the Registers scope.
+- **A Mesen-style expression grammar** for conditions, logpoints and the watch view: C operators, `[addr]` / `{addr}` / `u32(addr)` reads, registers, `frame` / `scanline` / `cycle`, symbols and `a.b[3].c` paths, `&symbol`, labels.
+- **Replay-exact rewind**: keyframes (XOR + run-length deltas, a full snapshot every N) plus a per-frame input log put the machine back at any earlier (frame, instruction) by replaying it; `stepBack`, `reverseContinue` (to the previous breakpoint hit) and `rewindFrames` are built on it, and re-running from a rewound point reproduces the original run byte for byte.
+- **Tracing and events**: an instruction trace ring and a hardware event log with frame, scanline and cycle stamps.
+- **Labels** for addresses the ELF does not name (a decomp's `gUnk_...`), persisted per project and importable from `.sym` files, usable in expressions and shown in disassembly; a `labels` session event says when they change.
+- **Input recording and replay** (`recording` and `tracing` session events say when one starts or stops, `recordingStart` and `lastRecording` say where it began and what it produced; a finished take carries the screen and the machine it began on, packed, and reads and writes as a file, so a project keeps its recordings and replays one from where it was recorded in a session that never ran those frames), save states bound to the ROM's hash (each keeping the screen it was saved on, so a view can list them by sight), memory search with narrowing, and the emulator views: palette, tiles, tilemaps, sprites, backgrounds and decoded I/O registers.
+- **`@gba-kit/debug-core/protocol`**: the `gba-kit/*` request and event vocabulary a debug adapter answers and every client speaks, with the argument helpers (entry counts, rewind frames, tile counts), the body builders for a saved state and a take, and the audio sample rate both hosts share, so the two implementations of the protocol agree without either restating it.
+- **Shares a machine with a player**: a session can wrap an existing `Gba` (`SessionOptions.machine`) and `resync()` after someone else drove it (a play mode, a state loaded outside), so a page plays a ROM and debugs it in turns.
+- Tested against one small C program built three ways (Thumb -O0, Thumb -O2, ARM -O0), whose ROM/ELF pairs are committed under `test-fixtures/` and rebuilt on CI.
diff --git a/.changeset/ide-debugger-debug-info.md b/.changeset/ide-debugger-debug-info.md
new file mode 100644
index 0000000..a3a6427
--- /dev/null
+++ b/.changeset/ide-debugger-debug-info.md
@@ -0,0 +1,14 @@
+---
+'@gba-kit/debug-info': minor
+---
+
+The queries an IDE debugger needs on top of the parser:
+
+- `LineTable.sourceToPcs(file, line)`, `nearestLineWithCode`, `rowAt(address)` (statement-aware, for stepping) and `files`; paths are matched normalized. A line's locations are the starts of its statement runs: one per piece of code the compiler emitted for it (a loop condition, a hoisted load), not one per row, and rows without `is_stmt` are not places to stop.
+- `DwarfScopes.inlineCallSitesAt(file, line)` and `entryPc(inlined)`: where a call inlined at a source line is entered (`DW_AT_entry_pc`, else the lowest range). Such a line has no rows of its own, so it is where a breakpoint on it goes.
+- `SymbolIndex` keeps each symbol's binding and section; `globalSymbol(name)` / `DebugInfo.globalSymbolAddress` answer only with a defined global (a file-static of the same spelling never satisfies a C `extern`, and two globals at different addresses are refused as ambiguous). Linker globals placed inside a section (`gFoo = .;`, as a decomp's ldscript does) resolve, not only `SHN_ABS` ones; undefined/common symbols and absolute FUNC placeholders are dropped.
+- `modeAt(address)` reports the instruction set from GNU `$a` / `$t` / `$d` mapping symbols.
+- `checkRomIdentity(rom)` compares the ELF's cartridge-window sections with a ROM and names the first mismatch; `isLinked` distinguishes an image from an object file (`ElfFile.type`).
+- Line rows for code the linker discarded (addresses below every loadable section) are dropped, so a PC in the BIOS stub does not resolve into them.
+- `readDwarfEntries(elf)` exports the DIE trees with attribute forms and unit versions, for scope- and location-level readers.
+- `DebugInfo.scopes` (`DwarfScopes`): the function and inlined calls containing a PC, the variables visible there and where they live at that PC (location lists for DWARF 2–5, a DWARF expression evaluator, frame bases via `.debug_frame` CFA), typed value trees for any DWARF type (structs, both bitfield dialects, arrays, enums, pointers), call-frame unwinding, and "optimized out" answers that say where the compiler did keep the value.
diff --git a/.changeset/ide-debugger-debug-ui.md b/.changeset/ide-debugger-debug-ui.md
new file mode 100644
index 0000000..6c9ab5b
--- /dev/null
+++ b/.changeset/ide-debugger-debug-ui.md
@@ -0,0 +1,11 @@
+---
+'@gba-kit/debug-ui': minor
+---
+
+New package: the debugger panels an editor has no native view for, as React components over a `Transport` seam, so one implementation serves a VS Code webview and a web page alike.
+
+- **Screen** with keyboard and gamepad input (sent as one button mask so the two never fight), audio through an `AudioWorklet` fed from a queue of sample chunks, and a transport bar: run/pause, frame step, rewind, record (a stopped recording opens the Recording tab through the transport's `showPanel`).
+- **Palette**, **Tiles** (any character base, 4/8 bpp, palette bank), **Tilemap** (rendered from the map and its tiles, with per-entry inspection), **Sprites** (a table of OAM with a painted preview of each, 1D and 2D mapping), **I/O registers** (decoded fields, filterable), **Trace** and **Events** (the instruction trace and the hardware event log), **Memory search** (search and narrow), **Labels** (edit, import `.sym`, export), **Save states** (each shown as the screen it was saved on, to load, rename or delete) and **Recording** (record, replay, open as a script, delete; recordings the project kept are listed with the ones made now). The Screen panel carries the same save states as a drawer beneath the display. Actions are drawn with VS Code's own icons (the codicon font), so the panels use the same glyph for the same idea as the editor around them.
+- `DebugPanels` puts them behind tabs for a host with one slot.
+- `createMessageTransport` / `serveTransport` speak `postMessage` between a webview and its host (a feed is unsubscribed once its last listener leaves, so frames and audio stop crossing to a panel that no longer shows them); `createSessionTransport` answers the same requests from an in-process `@gba-kit/debug-core` session. `@gba-kit/debug-ui/transport` exports the transport alone, for a host that bundles no React.
+- Styled through `--gk-*` variables (`@gba-kit/debug-ui/styles.css`), so a host paints the panels in its own theme.
diff --git a/.changeset/ide-debugger-execution.md b/.changeset/ide-debugger-execution.md
new file mode 100644
index 0000000..7844284
--- /dev/null
+++ b/.changeset/ide-debugger-execution.md
@@ -0,0 +1,18 @@
+---
+'@gba-kit/gba-emulator': minor
+'@gba-kit/arm-emulator': minor
+'@gba-kit/gba-browser': patch
+---
+
+Debugger-grade execution and inspection in the emulator core:
+
+- `Gba.runFrame(shouldStop?)` takes a stop predicate checked before every instruction and while the CPU is halted. A stop charges no cycle, and the next call finishes the same hardware frame, so frames stay on the hardware grid however often a debugger interrupts them. `Gba.runScanline()`, `Gba.frameCount` and `Gba.scanline` are new; `runFrame` returns a `RunOutcome`.
+- A CPU debug hook that refuses an instruction costs no scheduler cycle (`ArmCpu.halted` distinguishes a halted CPU from a refused instruction).
+- `CpuSnapshot.haltedBySWI` is gone: nothing ever set it, because a GBA halts through `HALTCNT` into the interrupt controller. A snapshot written with the field still loads, the field being ignored, but code that reads or constructs a `CpuSnapshot` must drop it.
+- Snapshot restore is bit-exact: scheduled events keep their `fireCycle` and only get their callbacks reattached (`Scheduler.reattach`, `TimerController.reattachEvents`, `DmaController.reattachEvents`), held buttons are restored, and `frameCount` is part of the snapshot, so running K frames from a restored snapshot reproduces the original run.
+- The HLE BIOS keeps no module-global state: `handleSwi` takes a per-machine `BiosEnv`, so two `Gba` instances in one process cannot cross-talk.
+- `GbaSystemBus.peek` / `poke`: side-effect-free debugger reads (an EEPROM peek never clocks its protocol) and writes that store the byte typed (no OAM drop / VRAM duplication) without notifying data watchpoints.
+- `GbaSystemBus.addReadWatchpoint`: read data breakpoints, the counterpart of the write watchpoints. A load overlapping the range reports the value it returned and which DMA channel, if any, performed it; the read paths pay one length check when none is set.
+- `EmulatorBridge.loadState` releases the buttons a snapshot restores, so a loaded state does not arrive with buttons held. `EmulatorBridge.refreshFrame()` repaints the canvas from the PPU after another driver of the same `Gba` (a debug session) moved it, `saveState` draws its thumbnail from the screen as it is now rather than the last frame the bridge rendered, and `run()` clears only the CPU debug hooks the bridge itself installed.
+- `disassembleThumbAt` / `disassembleArmAt`: a Thumb `bl` prefix/suffix pair is one 4-byte instruction with its target, and branch / literal-pool targets can be symbolized.
+- `Gba.onHardwareEvent`: one sink for interrupt requests and entries, DMA transfers (with the instruction that started them), I/O writes, VBlank/HBlank and halts — the feed for an event log; the hot paths pay nothing when nobody listens.
diff --git a/.gitignore b/.gitignore
index 742fd21..ba1ee35 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,7 @@ Thumbs.db
# Cache
.cache/
+
+# VS Code downloaded for the Extension Development Host tests
+apps/vscode-extension/.vscode-test/
+apps/vscode-extension/*.vsix
diff --git a/README.md b/README.md
index 1fb6cde..156bd2a 100644
--- a/README.md
+++ b/README.md
@@ -30,24 +30,28 @@
- **TypeScript-native** — Emulator built entirely in TypeScript, designed for the JS/TS ecosystem
- **Modular npm packages** — Use just the ARM CPU core, the GBA emulator, or the Node.js, browser, and React runtimes
- **First-class scripting API** — Run headless emulation from Node.js scripts for automated testing, TAS, ROM research, and tooling
-- **Built-in debugger** — Run the disassemblier, set breakpoints, open the memory viewer, inspect registers, and more
+- **Built-in debugger** — Run the disassembler, set breakpoints, open the memory viewer, inspect registers, and more
## Packages
-| Package | Description |
-| ------------------------------------------------ | ------------------------------------------------------------------------------------------- |
-| [`@gba-kit/arm-emulator`](packages/arm-emulator) | ARM7TDMI CPU emulator (Thumb + ARM instruction sets) |
-| [`@gba-kit/gba-emulator`](packages/gba-emulator) | Full GBA hardware emulation (PPU, APU, DMA, timers, interrupts, system bus) |
-| [`@gba-kit/gba-node`](packages/gba-node) | Headless Node.js runtime for scripted GBA emulation |
-| [`@gba-kit/gba-browser`](packages/gba-browser) | Browser runtime for GBA emulation (Canvas rendering, keyboard input, IndexedDB save states) |
-| [`@gba-kit/gba-react`](packages/gba-react) | React hooks for GBA emulation (`useEmulator`, `useEmulatorCanvas`, `useEmulatorKeyboard`) |
-| [`@gba-kit/debug-info`](packages/debug-info) | Parse ELF symbols + DWARF line tables (PC→source) for source-level debugging |
+| Package | Description |
+| -------------------------------------------------- | ------------------------------------------------------------------------------------------- |
+| [`@gba-kit/arm-emulator`](packages/arm-emulator) | ARM7TDMI CPU emulator (Thumb + ARM instruction sets) |
+| [`@gba-kit/gba-emulator`](packages/gba-emulator) | Full GBA hardware emulation (PPU, APU, DMA, timers, interrupts, system bus) |
+| [`@gba-kit/gba-node`](packages/gba-node) | Headless Node.js runtime for scripted GBA emulation |
+| [`@gba-kit/gba-browser`](packages/gba-browser) | Browser runtime for GBA emulation (Canvas rendering, keyboard input, IndexedDB save states) |
+| [`@gba-kit/gba-react`](packages/gba-react) | React hooks for GBA emulation (`useEmulator`, `useEmulatorCanvas`, `useEmulatorKeyboard`) |
+| [`@gba-kit/debug-info`](packages/debug-info) | Parse ELF symbols + DWARF line tables (PC→source) for source-level debugging |
+| [`@gba-kit/debug-core`](packages/debug-core) | IDE-agnostic debugging session: breakpoints, gdb-style stepping, DWARF values, exact rewind |
+| [`@gba-kit/debug-adapter`](packages/debug-adapter) | Debug Adapter Protocol server: debug a ROM from VS Code, Neovim, Emacs, Zed or JetBrains |
+| [`@gba-kit/debug-ui`](packages/debug-ui) | React panels for the screen, PPU, I/O, trace, events, labels; hosted by editors and the web |
## Apps
-| App | Description |
-| -------------------------------- | ---------------------------------------------------------------------------------------- |
-| [`@gba-kit/webapp`](apps/webapp) | Browser-based GBA debugger with disassembly, breakpoints, memory viewer, and save states |
+| App | Description |
+| ----------------------------------------- | ---------------------------------------------------------------------------------------- |
+| [`@gba-kit/webapp`](apps/webapp) | Browser-based GBA debugger with disassembly, breakpoints, memory viewer, and save states |
+| [`gba-kit-vscode`](apps/vscode-extension) | VS Code extension: debug a GBA ROM in C with the screen, PPU and I/O views beside it |
## Scripting
@@ -117,8 +121,12 @@ gba-kit/
gba-browser/ # Browser runtime (Canvas, keyboard, IndexedDB)
gba-react/ # React hooks (wraps gba-browser)
debug-info/ # ELF/DWARF parser (PC→source)
+ debug-core/ # IDE-agnostic debug session (breakpoints, stepping, rewind)
+ debug-adapter/ # Debug Adapter Protocol server
+ debug-ui/ # React debugger panels (screen, PPU, I/O, trace, labels)
apps/
webapp/ # Browser debugger UI + dev server
+ vscode-extension/ # VS Code debugger extension
```
### Using with npm link
diff --git a/apps/vscode-extension/.vscodeignore b/apps/vscode-extension/.vscodeignore
new file mode 100644
index 0000000..36575cc
--- /dev/null
+++ b/apps/vscode-extension/.vscodeignore
@@ -0,0 +1,10 @@
+**
+!dist/extension.js
+!dist/adapter.js
+!dist/webview.js
+!dist/webview.css
+!dist/codicon.ttf
+!media/logo.png
+!README.md
+!LICENSE
+!package.json
diff --git a/apps/vscode-extension/LICENSE b/apps/vscode-extension/LICENSE
new file mode 100644
index 0000000..152d1d3
--- /dev/null
+++ b/apps/vscode-extension/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Bruno Macabeus
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/apps/vscode-extension/README.md b/apps/vscode-extension/README.md
new file mode 100644
index 0000000..67c9068
--- /dev/null
+++ b/apps/vscode-extension/README.md
@@ -0,0 +1,61 @@
+# GBA Debugger for VS Code
+
+> 🕹️ Debug Game Boy Advance games where you write them
+
+[](https://github.com/macabeus/gba-kit)
+[](https://marketplace.visualstudio.com/items?itemName=macabeus.gba-kit-vscode)
+
+
+
+
+
+## Features
+
+- Set breakpoints on lines, functions and addresses, or on memory being read or written
+- Read and edit DWARF-typed variables, memory and registers
+- Step by statement, instruction, frame or scanline, and step back
+- Play the ROM in the editor, with a gamepad and sound
+- Record your inputs and replay them exactly, or take the recording away as a script
+- Keep save states with the project, and load one by the screen it was saved on
+- Explore the PPU views, the I/O registers, the instruction trace and the event log
+
+## Getting started
+
+1. Build your ROM with debug info: the `.gba` and the ELF it was made from compiled with `-g`
+2. Add a launch configuration:
+
+ ```jsonc
+ {
+ "type": "gba-kit",
+ "request": "launch",
+ "name": "Debug GBA ROM",
+ "rom": "${workspaceFolder}/build/game.gba",
+ "elf": "${workspaceFolder}/build/game.elf",
+ "cwd": "${workspaceFolder}",
+
+ // Sit at the first instruction instead of letting the Screen panel start the game
+ // "stopOnEntry": true,
+
+ // Rewrite the paths inside the ELF, when the build ran somewhere else (Docker, CI)
+ // "sourceMap": { "/build-container/src": "${workspaceFolder}/src" },
+
+ // Where `.gba-kit/` keeps this project's labels, save states and recordings (default: `cwd`)
+ // "projectDir": "${workspaceFolder}",
+
+ // Debug anyway when the ELF is not this ROM's build; breakpoints then land in the wrong places
+ // "allowElfMismatch": true,
+ }
+ ```
+
+3. Start the debugger, and enjoy debugging the game 🎉
+
+## Develop
+
+```bash
+pnpm --filter gba-kit-vscode build
+pnpm --filter gba-kit-vscode test # Unit tests
+pnpm --filter gba-kit-vscode test:vscode # Extension Development Host tests
+pnpm --filter gba-kit-vscode package # .vsix
+```
+
+> The extension is part of a monorepo that includes other modules for GBA development. [Check in the root of the repository for additional information.](https://github.com/macabeus/gba-kit)
diff --git a/apps/vscode-extension/esbuild.mjs b/apps/vscode-extension/esbuild.mjs
new file mode 100644
index 0000000..bef2721
--- /dev/null
+++ b/apps/vscode-extension/esbuild.mjs
@@ -0,0 +1,56 @@
+// The bundles built here:
+// dist/extension.js the extension host side (CommonJS, `vscode` provided by the host)
+// dist/adapter.js the debug adapter as a standalone Node process (CommonJS)
+// dist/webview.js the panels for the webviews (browser, React + @gba-kit/debug-ui)
+// dist/webview.css the panels' stylesheet, emitted with that bundle
+// dist/codicon.ttf VS Code's icon font, which that stylesheet loads
+// dist/test/*.js the Extension Development Host tests
+import * as esbuild from 'esbuild';
+
+const watch = process.argv.includes('--watch');
+
+const node = {
+ bundle: true,
+ format: 'cjs',
+ platform: 'node',
+ target: 'node20',
+ sourcemap: true,
+ logLevel: 'info',
+};
+
+const contexts = await Promise.all([
+ esbuild.context({
+ ...node,
+ entryPoints: { extension: 'src/extension.ts', 'test/run': 'src/test/run.ts', 'test/suite': 'src/test/suite.ts' },
+ outdir: 'dist',
+ external: ['vscode', '@vscode/test-electron'],
+ }),
+ esbuild.context({
+ ...node,
+ entryPoints: { adapter: 'src/adapter.ts' },
+ outdir: 'dist',
+ }),
+ esbuild.context({
+ bundle: true,
+ format: 'iife',
+ platform: 'browser',
+ target: 'es2022',
+ sourcemap: true,
+ minify: !watch,
+ logLevel: 'info',
+ entryPoints: { webview: 'src/webview/main.tsx' },
+ outdir: 'dist',
+ define: { 'process.env.NODE_ENV': watch ? '"development"' : '"production"' },
+ // the codicon font ships beside the stylesheet, which points at it by name;
+ // that name is fixed rather than hashed so the package's allowlist can name it
+ loader: { '.css': 'css', '.ttf': 'file' },
+ assetNames: '[name]',
+ }),
+]);
+
+if (watch) {
+ await Promise.all(contexts.map((c) => c.watch()));
+} else {
+ await Promise.all(contexts.map((c) => c.rebuild()));
+ await Promise.all(contexts.map((c) => c.dispose()));
+}
diff --git a/apps/vscode-extension/media/logo.png b/apps/vscode-extension/media/logo.png
new file mode 100644
index 0000000..ecbf89d
Binary files /dev/null and b/apps/vscode-extension/media/logo.png differ
diff --git a/apps/vscode-extension/media/screenshot.png b/apps/vscode-extension/media/screenshot.png
new file mode 100644
index 0000000..e475276
Binary files /dev/null and b/apps/vscode-extension/media/screenshot.png differ
diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json
new file mode 100644
index 0000000..2e13830
--- /dev/null
+++ b/apps/vscode-extension/package.json
@@ -0,0 +1,337 @@
+{
+ "name": "gba-kit-vscode",
+ "displayName": "GBA Debugger",
+ "description": "Debug Game Boy Advance programs in VS Code: breakpoints in C, DWARF variables, data breakpoints, exact rewind, the screen with a gamepad, and the PPU and I/O views",
+ "version": "0.6.0",
+ "private": true,
+ "publisher": "macabeus",
+ "license": "MIT",
+ "icon": "media/logo.png",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/macabeus/gba-kit",
+ "directory": "apps/vscode-extension"
+ },
+ "engines": {
+ "vscode": "^1.90.0"
+ },
+ "categories": [
+ "Debuggers"
+ ],
+ "keywords": [
+ "gba",
+ "gameboy-advance",
+ "debugger",
+ "emulator",
+ "dwarf"
+ ],
+ "main": "./dist/extension.js",
+ "activationEvents": [
+ "onDebug"
+ ],
+ "contributes": {
+ "debuggers": [
+ {
+ "type": "gba-kit",
+ "label": "gba-kit (Game Boy Advance)",
+ "languages": [
+ "c",
+ "cpp",
+ "arm",
+ "asm"
+ ],
+ "configurationAttributes": {
+ "launch": {
+ "required": [
+ "rom"
+ ],
+ "properties": {
+ "rom": {
+ "type": "string",
+ "description": "The .gba ROM to debug",
+ "default": "${workspaceFolder}/build/game.gba"
+ },
+ "elf": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "The ELF the ROM was made from, built with -g (symbols and DWARF). Default: the ROM's sibling .elf; null for none",
+ "default": "${workspaceFolder}/build/game.elf"
+ },
+ "cwd": {
+ "type": "string",
+ "description": "The project root relative DWARF source paths resolve against",
+ "default": "${workspaceFolder}"
+ },
+ "projectDir": {
+ "type": "string",
+ "description": "Where .gba-kit/ lives: labels, save states and recordings (default: cwd)"
+ },
+ "sourceMap": {
+ "type": "object",
+ "description": "DWARF path prefix → local prefix, for sources compiled elsewhere, e.g. {\"/build-container/src\": \"${workspaceFolder}/src\"}",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "stopOnEntry": {
+ "type": "boolean",
+ "description": "Stop at the entry point before running",
+ "default": true
+ },
+ "allowElfMismatch": {
+ "type": "boolean",
+ "description": "Debug even when the ELF's loadable bytes differ from the ROM",
+ "default": false
+ },
+ "rewind": {
+ "type": "object",
+ "description": "History for rewind: a keyframe every keyframeInterval frames, a full snapshot every fullEvery keyframes, within maxBytes",
+ "properties": {
+ "keyframeInterval": {
+ "type": "number",
+ "default": 10
+ },
+ "fullEvery": {
+ "type": "number",
+ "default": 12
+ },
+ "maxBytes": {
+ "type": "number",
+ "default": 100663296
+ }
+ }
+ }
+ }
+ }
+ },
+ "initialConfigurations": [
+ {
+ "type": "gba-kit",
+ "request": "launch",
+ "name": "Debug GBA ROM",
+ "rom": "${workspaceFolder}/build/game.gba",
+ "elf": "${workspaceFolder}/build/game.elf",
+ "cwd": "${workspaceFolder}",
+ "stopOnEntry": true
+ }
+ ],
+ "configurationSnippets": [
+ {
+ "label": "gba-kit: Debug ROM",
+ "description": "Run a GBA ROM under the gba-kit debugger",
+ "body": {
+ "type": "gba-kit",
+ "request": "launch",
+ "name": "Debug GBA ROM",
+ "rom": "^\"\\${workspaceFolder}/build/game.gba\"",
+ "elf": "^\"\\${workspaceFolder}/build/game.elf\"",
+ "cwd": "^\"\\${workspaceFolder}\"",
+ "stopOnEntry": true
+ }
+ }
+ ]
+ }
+ ],
+ "breakpoints": [
+ {
+ "language": "c"
+ },
+ {
+ "language": "cpp"
+ },
+ {
+ "language": "arm"
+ },
+ {
+ "language": "asm"
+ }
+ ],
+ "commands": [
+ {
+ "command": "gba-kit.showScreen",
+ "title": "GBA: Show Screen",
+ "icon": "$(device-mobile)"
+ },
+ {
+ "command": "gba-kit.showTools",
+ "title": "GBA: Show Tools (PPU, I/O, trace, events, labels…)",
+ "icon": "$(tools)"
+ },
+ {
+ "command": "gba-kit.stepFrame",
+ "title": "GBA: Step One Frame",
+ "icon": "$(debug-step-over)"
+ },
+ {
+ "command": "gba-kit.stepScanline",
+ "title": "GBA: Step One Scanline"
+ },
+ {
+ "command": "gba-kit.rewind",
+ "title": "GBA: Rewind One Second",
+ "icon": "$(debug-reverse-continue)"
+ },
+ {
+ "command": "gba-kit.toggleRecording",
+ "title": "GBA: Start/Stop Recording Inputs",
+ "icon": "$(record)"
+ },
+ {
+ "command": "gba-kit.saveState",
+ "title": "GBA: Save State"
+ },
+ {
+ "command": "gba-kit.loadState",
+ "title": "GBA: Load State"
+ },
+ {
+ "command": "gba-kit.importLabels",
+ "title": "GBA: Import Labels From a Symbol File"
+ },
+ {
+ "command": "gba-kit.exportLabels",
+ "title": "GBA: Export Labels As a Symbol File"
+ }
+ ],
+ "menus": {
+ "debug/toolBar": [
+ {
+ "command": "gba-kit.showScreen",
+ "when": "debugType == gba-kit",
+ "group": "navigation@1"
+ },
+ {
+ "command": "gba-kit.showTools",
+ "when": "debugType == gba-kit",
+ "group": "navigation@2"
+ },
+ {
+ "command": "gba-kit.stepFrame",
+ "when": "debugType == gba-kit",
+ "group": "navigation@3"
+ },
+ {
+ "command": "gba-kit.rewind",
+ "when": "debugType == gba-kit",
+ "group": "navigation@4"
+ },
+ {
+ "command": "gba-kit.toggleRecording",
+ "when": "debugType == gba-kit",
+ "group": "navigation@5"
+ }
+ ],
+ "commandPalette": [
+ {
+ "command": "gba-kit.showScreen",
+ "when": "debugType == gba-kit"
+ },
+ {
+ "command": "gba-kit.showTools",
+ "when": "debugType == gba-kit"
+ },
+ {
+ "command": "gba-kit.stepFrame",
+ "when": "debugType == gba-kit"
+ },
+ {
+ "command": "gba-kit.stepScanline",
+ "when": "debugType == gba-kit"
+ },
+ {
+ "command": "gba-kit.rewind",
+ "when": "debugType == gba-kit"
+ },
+ {
+ "command": "gba-kit.toggleRecording",
+ "when": "debugType == gba-kit"
+ },
+ {
+ "command": "gba-kit.saveState",
+ "when": "debugType == gba-kit"
+ },
+ {
+ "command": "gba-kit.loadState",
+ "when": "debugType == gba-kit"
+ },
+ {
+ "command": "gba-kit.importLabels",
+ "when": "debugType == gba-kit"
+ },
+ {
+ "command": "gba-kit.exportLabels",
+ "when": "debugType == gba-kit"
+ }
+ ]
+ },
+ "keybindings": [
+ {
+ "command": "gba-kit.stepFrame",
+ "key": "ctrl+alt+f",
+ "mac": "cmd+alt+f",
+ "when": "debugType == gba-kit && debugState == stopped"
+ },
+ {
+ "command": "gba-kit.rewind",
+ "key": "ctrl+alt+r",
+ "mac": "cmd+alt+r",
+ "when": "debugType == gba-kit && debugState == stopped"
+ }
+ ],
+ "configuration": {
+ "title": "gba-kit",
+ "properties": {
+ "gba-kit.adapter": {
+ "type": "string",
+ "enum": [
+ "process",
+ "inline"
+ ],
+ "default": "process",
+ "description": "Run the debug adapter as a separate Node process (needs `node` on the PATH) or inside the extension host"
+ },
+ "gba-kit.runOnScreen": {
+ "type": "boolean",
+ "default": true,
+ "description": "Run the machine when a session reaches its entry stop with the Screen panel up, instead of leaving it stopped"
+ },
+ "gba-kit.screenScale": {
+ "type": "number",
+ "default": 2,
+ "minimum": 1,
+ "maximum": 6,
+ "description": "How many times the 240×160 screen is scaled in the Screen panel"
+ }
+ }
+ }
+ },
+ "scripts": {
+ "build": "node esbuild.mjs",
+ "watch": "node esbuild.mjs --watch",
+ "check-types": "tsc --noEmit",
+ "lint": "eslint src/",
+ "test": "vitest run",
+ "test:vscode": "node esbuild.mjs && node dist/test/run.js",
+ "package": "node esbuild.mjs && vsce package --no-dependencies --baseImagesUrl https://github.com/macabeus/gba-kit/raw/HEAD/apps/vscode-extension"
+ },
+ "dependencies": {
+ "@gba-kit/debug-adapter": "workspace:*",
+ "@gba-kit/debug-ui": "workspace:*",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3"
+ },
+ "devDependencies": {
+ "@types/node": "^25.5.0",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@types/vscode": "^1.90.0",
+ "@vscode/debugprotocol": "^1.68.0",
+ "@vscode/test-electron": "^3.1.0",
+ "@vscode/vsce": "^3.0.0",
+ "esbuild": "^0.27.0",
+ "typescript": "^6.0.2",
+ "vitest": "^4.1.1"
+ }
+}
diff --git a/apps/vscode-extension/src/__tests__/bundle.spec.ts b/apps/vscode-extension/src/__tests__/bundle.spec.ts
new file mode 100644
index 0000000..762efc7
--- /dev/null
+++ b/apps/vscode-extension/src/__tests__/bundle.spec.ts
@@ -0,0 +1,28 @@
+/** What the extension-host bundle carries: the host serves transports and streams, so React stays in the webview bundle. */
+import * as esbuild from 'esbuild';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
+
+describe('extension bundle', () => {
+ it('bundles no React and no panel into the extension host', async () => {
+ const { metafile } = await esbuild.build({
+ entryPoints: [join(root, 'src', 'extension.ts')],
+ bundle: true,
+ write: false,
+ metafile: true,
+ platform: 'node',
+ format: 'cjs',
+ target: 'node20',
+ external: ['vscode'],
+ absWorkingDir: root,
+ logLevel: 'silent',
+ });
+ const inputs = Object.keys(metafile.inputs);
+ expect(inputs.some((p) => p.includes('debug-ui/dist/transport.js'))).toBe(true);
+ expect(inputs.filter((p) => /node_modules\/react/.test(p))).toEqual([]);
+ expect(inputs.filter((p) => /debug-ui\/dist\/panels\//.test(p))).toEqual([]);
+ }, 30_000);
+});
diff --git a/apps/vscode-extension/src/__tests__/extension.spec.ts b/apps/vscode-extension/src/__tests__/extension.spec.ts
new file mode 100644
index 0000000..367cb17
--- /dev/null
+++ b/apps/vscode-extension/src/__tests__/extension.spec.ts
@@ -0,0 +1,318 @@
+/**
+ * The extension activated against a stand-in `vscode`: what the panels are told
+ * when a session starts, wants audio, ends, or ends before its frame stream was
+ * attached, and that a screen runs the machine on from the entry stop, once, never
+ * from a breakpoint, and never while `gba-kit.runOnScreen` is off.
+ */
+import { FrameStream, STREAM } from '@gba-kit/debug-adapter';
+import type { HostToTransport, TransportToHost } from '@gba-kit/debug-ui/transport';
+import { readdirSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+
+interface Listener {
+ (e: unknown): void;
+}
+
+/** A webview panel that keeps what the host posts and lets the test speak for its webview. */
+class FakePanel {
+ readonly posted: HostToTransport[] = [];
+ visible = true;
+ #receive: ((m: unknown) => void) | null = null;
+ #viewState: ((e: unknown) => void) | null = null;
+ readonly webview = {
+ cspSource: 'vscode-resource:',
+ html: '',
+ asWebviewUri: (uri: unknown) => uri,
+ postMessage: async (m: HostToTransport): Promise => {
+ this.posted.push(m);
+ return true;
+ },
+ onDidReceiveMessage: (l: (m: unknown) => void) => {
+ this.#receive = l;
+ return { dispose() {} };
+ },
+ };
+
+ onDidChangeViewState(l: (e: unknown) => void): { dispose(): void } {
+ this.#viewState = l;
+ return { dispose() {} };
+ }
+ onDidDispose(): { dispose(): void } {
+ return { dispose() {} };
+ }
+ reveal(): void {}
+ dispose(): void {}
+
+ /** The webview said something. */
+ deliver(message: TransportToHost): void {
+ this.#receive?.(message);
+ }
+ show(visible: boolean): void {
+ this.visible = visible;
+ this.#viewState?.({ webviewPanel: this });
+ }
+}
+
+const stub = vi.hoisted(() => ({
+ start: [] as Array<(e: unknown) => void>,
+ terminate: [] as Array<(e: unknown) => void>,
+ custom: [] as Array<(e: unknown) => void>,
+ commands: new Map unknown>(),
+ panels: [] as unknown[],
+ /** the view id of each panel created, in order */
+ created: [] as string[],
+ log: [] as string[],
+ createPanel: null as (() => unknown) | null,
+ /** settings the workspace answers with, by `.`; anything else takes the caller's default */
+ settings: new Map(),
+}));
+
+vi.mock('vscode', () => {
+ const disposable = { dispose() {} };
+ const event =
+ (list: Listener[]) =>
+ (l: Listener): { dispose(): void } => {
+ list.push(l);
+ return disposable;
+ };
+ const uri = (path: string): { path: string; toString(): string } => ({ path, toString: () => path });
+ return {
+ ViewColumn: { Active: -1, Beside: -2 },
+ Uri: { joinPath: (base: { path: string }, ...parts: string[]) => uri([base.path, ...parts].join('/')) },
+ window: {
+ createOutputChannel: () => ({ appendLine: (line: string) => stub.log.push(line), dispose() {} }),
+ createWebviewPanel: (id: string) => {
+ const panel = stub.createPanel!();
+ stub.panels.push(panel);
+ stub.created.push(id);
+ return panel;
+ },
+ showWarningMessage: () => undefined,
+ showErrorMessage: () => undefined,
+ showInformationMessage: () => undefined,
+ setStatusBarMessage: () => undefined,
+ },
+ workspace: {
+ getConfiguration: (section: string) => ({
+ get: (key: string, fallback: unknown) => stub.settings.get(`${section}.${key}`) ?? fallback,
+ }),
+ },
+ commands: {
+ registerCommand: (id: string, fn: (...args: unknown[]) => unknown) => {
+ stub.commands.set(id, fn);
+ return disposable;
+ },
+ executeCommand: async () => undefined,
+ },
+ debug: {
+ registerDebugAdapterDescriptorFactory: () => disposable,
+ onDidStartDebugSession: event(stub.start),
+ onDidTerminateDebugSession: event(stub.terminate),
+ onDidReceiveDebugSessionCustomEvent: event(stub.custom),
+ activeDebugSession: undefined,
+ },
+ DebugAdapterExecutable: class {},
+ DebugAdapterInlineImplementation: class {},
+ };
+});
+
+/** A process-mode session (no inline adapter registered for it) that records its requests. */
+function fakeSession(id: string, gate?: Promise, configuration: Record = {}) {
+ const calls: Array<{ command: string; args: unknown }> = [];
+ return {
+ id,
+ type: 'gba-kit',
+ name: id,
+ configuration,
+ calls,
+ customRequest: async (command: string, args?: unknown): Promise => {
+ calls.push({ command, args });
+ if (command === 'gba-kit/stream' && gate) {
+ await gate;
+ }
+ return {};
+ },
+ };
+}
+
+const PIXELS = new Uint8Array(STREAM.width * STREAM.height * 4);
+const wait = (ms: number): Promise => new Promise((r) => setTimeout(r, ms));
+const until = async (ok: () => boolean, ms = 2000): Promise => {
+ const end = Date.now() + ms;
+ while (!ok()) {
+ if (Date.now() > end) {
+ throw new Error('condition not met in time');
+ }
+ await wait(5);
+ }
+};
+
+/** The stream pipes this extension host has bound (a socket file each, on POSIX). */
+const boundPipes = (): string[] =>
+ process.platform === 'win32' ? [] : readdirSync(tmpdir()).filter((f) => f.startsWith(`gba-kit-${process.pid}-`));
+
+const subscriptions: Array<{ dispose(): void }> = [];
+
+describe('extension', () => {
+ beforeAll(async () => {
+ stub.createPanel = () => new FakePanel();
+ const { activate } = await import('../extension.js');
+ activate({
+ subscriptions,
+ extensionUri: { path: '/ext', toString: () => '/ext' },
+ asAbsolutePath: (p: string) => `/ext/${p}`,
+ } as never);
+ });
+
+ afterEach(() => {
+ stub.settings.clear();
+ });
+
+ afterAll(() => {
+ for (const s of subscriptions) {
+ s.dispose();
+ }
+ });
+
+ it('a session that ends while its stream is being attached never becomes live, and its pipe is unbound', async () => {
+ let release: () => void = () => {};
+ const gate = new Promise((r) => (release = r));
+ const dead = fakeSession('dead', gate);
+ stub.start.forEach((l) => l(dead));
+ // a session brings the screen up on its own, before its stream is even attached
+ expect(stub.created).toEqual(['gba-kit.screen']);
+ await until(() => dead.calls.some((c) => c.command === 'gba-kit/stream'));
+ const pipes = boundPipes();
+ stub.terminate.forEach((l) => l(dead));
+ release();
+ await wait(50);
+ if (process.platform !== 'win32') {
+ expect(pipes.length).toBe(1);
+ expect(boundPipes()).toEqual([]);
+ }
+
+ stub.commands.get('gba-kit.showScreen')!(); // the panel is already up: revealed, not stacked
+ expect(stub.created).toEqual(['gba-kit.screen']);
+ const screen = stub.panels[0] as FakePanel;
+ screen.deliver({ type: 'subscribe', what: 'state' });
+ await wait(20);
+ expect(dead.calls.map((c) => c.command)).toEqual(['gba-kit/stream']); // the panel follows no session
+ expect(screen.posted).toEqual([]);
+ });
+
+ it('asks the live session for audio only while a panel plays it, and tells the panels when it ends', async () => {
+ const live = fakeSession('live');
+ const streams = (): unknown[] => live.calls.filter((c) => c.command === 'gba-kit/stream').map((c) => c.args);
+ stub.start.forEach((l) => l(live));
+ // the panel already follows state (from the test above): attaching pushes it the state right away
+ const screen = stub.panels[0] as FakePanel;
+ await until(() => screen.posted.length === 1);
+ expect(screen.posted[0]).toEqual({ type: 'state', state: {} });
+ expect(streams()).toEqual([{ path: expect.any(String), audio: false }]);
+ const pipe = (streams()[0] as { path: string }).path;
+
+ screen.deliver({ type: 'subscribe', what: 'audio' });
+ await until(() => streams().length === 2);
+ expect(streams()[1]).toEqual({ path: pipe, audio: true });
+ screen.deliver({ type: 'unsubscribe', what: 'audio' });
+ await until(() => streams().length === 3);
+ expect(streams()[2]).toEqual({ path: pipe, audio: false });
+ await wait(20);
+ expect(streams().length).toBe(3);
+
+ // frames come through the pipe, and only reach a panel that is on screen
+ const adapter = new FrameStream();
+ await adapter.connect(pipe);
+ screen.deliver({ type: 'subscribe', what: 'frame' });
+ const frames = (): number[] =>
+ screen.posted.filter((m) => m.type === 'frame').map((m) => (m as { frame: number }).frame);
+ adapter.sendFrame(PIXELS, 1);
+ await until(() => frames().length === 1);
+ screen.show(false);
+ adapter.sendFrame(PIXELS, 2);
+ adapter.sendFrame(PIXELS, 3);
+ await wait(50);
+ expect(frames()).toEqual([1]);
+ screen.show(true);
+ expect(frames()).toEqual([1, 3]);
+ adapter.close();
+
+ stub.terminate.forEach((l) => l(live));
+ await wait(50);
+ expect(screen.posted.at(-1)).toMatchObject({ type: 'state', state: { state: 'disposed' } });
+ if (process.platform !== 'win32') {
+ expect(boundPipes()).toEqual([]);
+ }
+ });
+ it('runs the machine for the screen at the entry stop, once, and never from a breakpoint', async () => {
+ const started = fakeSession('entry-run');
+ stub.start.forEach((l) => l(started));
+ await until(() => started.calls.some((c) => c.command === 'gba-kit/stream'));
+ const resumes = (): number => started.calls.filter((c) => c.command === 'continue').length;
+ expect(resumes()).toBe(0);
+
+ // the screen is up (an earlier session opened it), so the entry stop runs on
+ stub.custom.forEach((l) =>
+ l({ session: started, event: 'gba-kit/state', body: { state: 'stopped', reason: 'entry' } }),
+ );
+ await until(() => resumes() === 1);
+
+ // only once, however often the state is repeated
+ stub.custom.forEach((l) =>
+ l({ session: started, event: 'gba-kit/state', body: { state: 'stopped', reason: 'entry' } }),
+ );
+ await wait(20);
+ expect(resumes()).toBe(1);
+
+ // and never from a stop the user asked for
+ const atBreakpoint = fakeSession('bp');
+ stub.start.forEach((l) => l(atBreakpoint));
+ await until(() => atBreakpoint.calls.some((c) => c.command === 'gba-kit/stream'));
+ stub.custom.forEach((l) =>
+ l({ session: atBreakpoint, event: 'gba-kit/state', body: { state: 'stopped', reason: 'breakpoint' } }),
+ );
+ await wait(20);
+ expect(atBreakpoint.calls.filter((c) => c.command === 'continue')).toEqual([]);
+ stub.terminate.forEach((l) => l(atBreakpoint));
+ stub.terminate.forEach((l) => l(started));
+ await wait(20);
+ });
+
+ it('leaves the entry stop alone when the launch configuration asked to stop there', async () => {
+ const asked = fakeSession('stop-on-entry', undefined, { stopOnEntry: true });
+ stub.start.forEach((l) => l(asked));
+ await until(() => asked.calls.some((c) => c.command === 'gba-kit/stream'));
+ stub.custom.forEach((l) =>
+ l({ session: asked, event: 'gba-kit/state', body: { state: 'stopped', reason: 'entry' } }),
+ );
+ await wait(20);
+ expect(asked.calls.filter((c) => c.command === 'continue')).toEqual([]);
+ stub.terminate.forEach((l) => l(asked));
+ await wait(20);
+ });
+
+ it('leaves the entry stop alone while gba-kit.runOnScreen is off', async () => {
+ stub.settings.set('gba-kit.runOnScreen', false);
+ const held = fakeSession('held');
+ stub.start.forEach((l) => l(held));
+ await until(() => held.calls.some((c) => c.command === 'gba-kit/stream'));
+ const resumes = (): number => held.calls.filter((c) => c.command === 'continue').length;
+ const entry = (): void => {
+ stub.custom.forEach((l) =>
+ l({ session: held, event: 'gba-kit/state', body: { state: 'stopped', reason: 'entry' } }),
+ );
+ };
+
+ entry();
+ await wait(20);
+ expect(resumes()).toBe(0);
+
+ // the setting off is the only thing holding it: unset, the session runs on as it does by default
+ stub.settings.delete('gba-kit.runOnScreen');
+ entry();
+ await until(() => resumes() === 1);
+ stub.terminate.forEach((l) => l(held));
+ await wait(20);
+ });
+});
diff --git a/apps/vscode-extension/src/__tests__/frame-server.spec.ts b/apps/vscode-extension/src/__tests__/frame-server.spec.ts
new file mode 100644
index 0000000..10eeceb
--- /dev/null
+++ b/apps/vscode-extension/src/__tests__/frame-server.spec.ts
@@ -0,0 +1,74 @@
+/** The pipe server a process adapter streams into, driven by the adapter's own `FrameStream`. */
+import { FrameStream, STREAM, newPipePath } from '@gba-kit/debug-adapter';
+import { describe, expect, it } from 'vitest';
+
+import { type FrameServer, serveFrames } from '../frame-server.js';
+
+const PIXELS = new Uint8Array(STREAM.width * STREAM.height * 4);
+
+const wait = (ms: number): Promise => new Promise((r) => setTimeout(r, ms));
+const until = async (ok: () => boolean, ms = 2000): Promise => {
+ const end = Date.now() + ms;
+ while (!ok()) {
+ if (Date.now() > end) {
+ throw new Error('condition not met in time');
+ }
+ await wait(5);
+ }
+};
+
+async function serve(): Promise<{ server: FrameServer; pipe: string; frames: number[]; audio: number[] }> {
+ const pipe = newPipePath('gba-kit-test');
+ const frames: number[] = [];
+ const audio: number[] = [];
+ const server = await serveFrames(
+ pipe,
+ { frame: (_rgba, frame) => frames.push(frame), audio: (samples) => audio.push(samples.length) },
+ () => {},
+ );
+ return { server, pipe, frames, audio };
+}
+
+describe('frame server', () => {
+ it('feeds the sink from the adapter, and disposing drops the adapter so nothing more arrives', async () => {
+ const { server, pipe, frames, audio } = await serve();
+ const stream = new FrameStream();
+ try {
+ await stream.connect(pipe);
+ stream.sendFrame(PIXELS, 1);
+ stream.sendAudio(new Float32Array(4), 32768);
+ await until(() => frames.length === 1 && audio.length === 1);
+ expect(audio).toEqual([4]);
+
+ server.dispose();
+ await until(() => !stream.connected); // the adapter's socket was closed, not just the listener
+ stream.sendFrame(PIXELS, 2);
+ await wait(50);
+ expect(frames).toEqual([1]);
+ } finally {
+ stream.close();
+ server.dispose();
+ }
+ });
+
+ it('takes the newest connection: an adapter that connects again replaces its earlier socket', async () => {
+ const { server, pipe, frames } = await serve();
+ const first = new FrameStream();
+ const second = new FrameStream();
+ try {
+ await first.connect(pipe);
+ first.sendFrame(PIXELS, 1);
+ await until(() => frames.length === 1);
+ await second.connect(pipe);
+ await until(() => !first.connected);
+ first.sendFrame(PIXELS, 2); // nowhere to go
+ second.sendFrame(PIXELS, 3);
+ await until(() => frames.length === 2);
+ expect(frames).toEqual([1, 3]);
+ } finally {
+ first.close();
+ second.close();
+ server.dispose();
+ }
+ });
+});
diff --git a/apps/vscode-extension/src/__tests__/host-bridge.spec.ts b/apps/vscode-extension/src/__tests__/host-bridge.spec.ts
new file mode 100644
index 0000000..86ece0f
--- /dev/null
+++ b/apps/vscode-extension/src/__tests__/host-bridge.spec.ts
@@ -0,0 +1,131 @@
+import type { HostToTransport } from '@gba-kit/debug-ui/transport';
+import { describe, expect, it } from 'vitest';
+
+import { type BridgeSession, CONTROL_REQUESTS, HostBridge } from '../host-bridge.js';
+
+/** A session that records what it was asked. */
+function fakeSession(calls: string[]): BridgeSession {
+ return {
+ customRequest: async (command, args) => {
+ calls.push(command);
+ return command === 'gba-kit/state' ? { state: 'stopped', frame: 3 } : { args };
+ },
+ control: async (action) => {
+ calls.push(`control:${action}`);
+ },
+ };
+}
+
+describe('host bridge', () => {
+ it('routes requests and controls to the session, and feeds only what the panel subscribed to', async () => {
+ const posted: HostToTransport[] = [];
+ const calls: string[] = [];
+ const bridge = new HostBridge({ post: (m) => posted.push(m) });
+ await bridge.receive({ type: 'request', id: 1, command: 'gba-kit/state' });
+ expect(posted[0]).toMatchObject({
+ type: 'response',
+ id: 1,
+ error: expect.stringMatching(/no gba-kit debug session/),
+ });
+
+ bridge.attach(fakeSession(calls));
+ await bridge.receive({ type: 'subscribe', what: 'state' });
+ expect(posted[1]).toEqual({ type: 'state', state: { state: 'stopped', frame: 3 } });
+ await bridge.receive({ type: 'request', id: 2, command: 'gba-kit/rewind', args: { frames: 2 } });
+ expect(posted[2]).toEqual({ type: 'response', id: 2, body: { args: { frames: 2 } } });
+ await bridge.receive({ type: 'control', id: 3, action: 'stepOver' });
+ expect(posted[3]).toEqual({ type: 'response', id: 3 });
+ expect(calls).toEqual(['gba-kit/state', 'gba-kit/rewind', 'control:stepOver']);
+
+ bridge.frame(new Uint8Array(4), 1);
+ expect(posted.length).toBe(4); // no frame subscription yet
+ await bridge.receive({ type: 'subscribe', what: 'frame' });
+ expect(posted[4]).toMatchObject({ type: 'frame', frame: 1 }); // the last frame, right away
+ expect(calls.at(-1)).toBe('gba-kit/requestFrame');
+ bridge.audio(new Float32Array(2), 32768);
+ expect(posted.length).toBe(5);
+ bridge.labels();
+ expect(posted.length).toBe(5);
+ await bridge.receive({ type: 'subscribe', what: 'labels' });
+ bridge.labels();
+ expect(posted.at(-1)).toEqual({ type: 'labels' });
+ });
+
+ it('stops a feed when the webview unsubscribes, and tells the host on every change', async () => {
+ const posted: HostToTransport[] = [];
+ const changes: string[] = [];
+ const bridge = new HostBridge({
+ post: (m) => posted.push(m),
+ subscriptionsChanged: () => changes.push([...bridge.subscriptions].join(',')),
+ });
+ await bridge.receive({ type: 'subscribe', what: 'audio' });
+ await bridge.receive({ type: 'subscribe', what: 'audio' }); // said twice: one change
+ bridge.audio(new Float32Array(2), 32768);
+ expect(posted).toEqual([{ type: 'audio', samples: new Float32Array(2), sampleRate: 32768 }]);
+
+ await bridge.receive({ type: 'unsubscribe', what: 'audio' });
+ await bridge.receive({ type: 'unsubscribe', what: 'audio' }); // already gone: no change
+ bridge.audio(new Float32Array(2), 32768);
+ expect(posted.length).toBe(1);
+ expect(bridge.subscriptions.has('audio')).toBe(false);
+ expect(changes).toEqual(['audio', '']);
+ });
+
+ it('holds frames back from a hidden webview and sends the newest once it shows again', async () => {
+ const posted: HostToTransport[] = [];
+ const bridge = new HostBridge({ post: (m) => posted.push(m) });
+ await bridge.receive({ type: 'subscribe', what: 'frame' });
+ bridge.setVisible(false);
+ bridge.frame(new Uint8Array([1]), 6);
+ bridge.frame(new Uint8Array([2]), 7);
+ expect(posted).toEqual([]);
+ bridge.setVisible(true);
+ expect(posted).toEqual([{ type: 'frame', rgba: new Uint8Array([2]), frame: 7 }]);
+ bridge.setVisible(false);
+ bridge.setVisible(true); // nothing arrived meanwhile: nothing to catch up on
+ expect(posted.length).toBe(1);
+ bridge.frame(new Uint8Array([3]), 8);
+ expect(posted.at(-1)).toMatchObject({ type: 'frame', frame: 8 });
+ });
+
+ it("does not replay one session's last frame to a webview following the next", async () => {
+ const posted: HostToTransport[] = [];
+ const bridge = new HostBridge({ post: (m) => posted.push(m) });
+ bridge.attach(fakeSession([]));
+ bridge.frame(new Uint8Array(4), 7);
+ const calls: string[] = [];
+ bridge.attach(fakeSession(calls));
+ await bridge.receive({ type: 'subscribe', what: 'frame' });
+ expect(posted).toEqual([]);
+ expect(calls).toEqual(['gba-kit/requestFrame']);
+ bridge.frame(new Uint8Array(4), 8);
+ expect(posted).toEqual([{ type: 'frame', rgba: new Uint8Array(4), frame: 8 }]);
+ });
+
+ it('routes a panel request to the host, and holds one for a webview until its tabs can hear it', async () => {
+ const posted: HostToTransport[] = [];
+ const shown: string[] = [];
+ const bridge = new HostBridge({ post: (m) => posted.push(m), showPanel: (panel) => shown.push(panel) });
+ await bridge.receive({ type: 'showPanel', panel: 'recording' });
+ expect(shown).toEqual(['recording']);
+
+ bridge.showPanel('recording'); // the webview is still loading: nothing to post to yet
+ expect(posted).toEqual([]);
+ await bridge.receive({ type: 'subscribe', what: 'showPanel' });
+ expect(posted).toEqual([{ type: 'showPanel', panel: 'recording' }]);
+ bridge.showPanel('trace');
+ expect(posted.at(-1)).toEqual({ type: 'showPanel', panel: 'trace' });
+
+ const silent = new HostBridge({ post: (m) => posted.push(m) });
+ await silent.receive({ type: 'showPanel', panel: 'recording' }); // a host with nowhere to show it: ignored
+ expect(posted.length).toBe(2);
+ });
+
+ it('knows the DAP request behind each control', () => {
+ expect(CONTROL_REQUESTS.stepInstruction).toEqual({
+ command: 'next',
+ args: { threadId: 1, granularity: 'instruction' },
+ });
+ expect(CONTROL_REQUESTS.restart.command).toBe('restart');
+ });
+});
diff --git a/apps/vscode-extension/src/__tests__/package.spec.ts b/apps/vscode-extension/src/__tests__/package.spec.ts
new file mode 100644
index 0000000..91a1558
--- /dev/null
+++ b/apps/vscode-extension/src/__tests__/package.spec.ts
@@ -0,0 +1,40 @@
+/**
+ * What `vsce package` would ship, from a manifest vsce accepts: the bundles and
+ * stylesheet the extension loads at run time, plus package.json, README and LICENSE.
+ * `.vscodeignore` is an allowlist, so a new bundle or asset has to be named there, and
+ * a dropped one shows up here.
+ */
+import { execFileSync } from 'node:child_process';
+import { existsSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { beforeAll, describe, expect, it } from 'vitest';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
+
+describe('package', () => {
+ beforeAll(() => {
+ // the listing needs the bundles on disk; CI builds before it tests, a bare checkout does not
+ if (!existsSync(join(root, 'dist', 'extension.js'))) {
+ execFileSync(process.execPath, ['esbuild.mjs'], { cwd: root, stdio: 'ignore' });
+ }
+ }, 120_000);
+
+ it('ships the three bundles, the stylesheet and its icon font, the marketplace icon, the manifest, the README and the license, and nothing else', () => {
+ const listed = execFileSync(join(root, 'node_modules', '.bin', 'vsce'), ['ls', '--no-dependencies'], {
+ cwd: root,
+ encoding: 'utf8',
+ });
+ expect(listed.trim().split('\n').sort()).toEqual([
+ 'LICENSE',
+ 'README.md',
+ 'dist/adapter.js',
+ 'dist/codicon.ttf',
+ 'dist/extension.js',
+ 'dist/webview.css',
+ 'dist/webview.js',
+ 'media/logo.png',
+ 'package.json',
+ ]);
+ }, 30_000);
+});
diff --git a/apps/vscode-extension/src/__tests__/webview-html.spec.ts b/apps/vscode-extension/src/__tests__/webview-html.spec.ts
new file mode 100644
index 0000000..1b5d5b4
--- /dev/null
+++ b/apps/vscode-extension/src/__tests__/webview-html.spec.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest';
+
+import { nonce, webviewHtml } from '../webview-html.js';
+
+/** The CSP of a page, directive by directive. */
+function directives(html: string): Record {
+ const content = html.match(/http-equiv="Content-Security-Policy" content="([^"]*)"/)?.[1];
+ expect(content).toBeDefined();
+ const out: Record = {};
+ for (const directive of content!.split(';')) {
+ const [name, ...sources] = directive.trim().split(/\s+/);
+ if (name) {
+ out[name] = sources.join(' ');
+ }
+ }
+ return out;
+}
+
+describe('webview html', () => {
+ it('makes a fresh nonce for every webview', () => {
+ const a = nonce();
+ const b = nonce();
+ expect(a).not.toBe(b);
+ expect(Buffer.from(a, 'base64').length).toBe(16);
+ });
+
+ it('locks the CSP to the nonce and the extension resources, with blob: scripts for the audio worklet', () => {
+ const n = nonce();
+ const html = webviewHtml({
+ cspSource: 'vscode-resource:',
+ scriptUri: 'x/webview.js',
+ styleUri: 'x/webview.css',
+ root: 'tools',
+ nonce: n,
+ screenScale: 3,
+ });
+ expect(directives(html)).toEqual({
+ 'default-src': "'none'",
+ 'style-src': "vscode-resource: 'unsafe-inline'",
+ 'script-src': `'nonce-${n}' blob:`,
+ 'img-src': 'vscode-resource: data:',
+ 'font-src': 'vscode-resource:',
+ });
+ expect(html).toContain(`
+