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 + +[![GitHub Stars](https://flat.badgen.net/github/stars/macabeus/gba-kit?icon=github)](https://github.com/macabeus/gba-kit) +[![Visual Studio Marketplace Downloads](https://flat.badgen.net/vs-marketplace/d/macabeus.gba-kit-vscode?icon=visualstudio)](https://marketplace.visualstudio.com/items?itemName=macabeus.gba-kit-vscode) + +

+ The debugger stopped at a breakpoint, with the game's screen, the call stack and the variables beside the code +

+ +## 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(` + +`; +} diff --git a/apps/vscode-extension/src/webview/main.tsx b/apps/vscode-extension/src/webview/main.tsx new file mode 100644 index 0000000..bb54446 --- /dev/null +++ b/apps/vscode-extension/src/webview/main.tsx @@ -0,0 +1,66 @@ +/** + * The webview side: mount the panels with a transport that talks to the + * extension host in messages. One bundle serves both the Screen panel and the + * Tools panel; `data-root` on the container says which. + */ +import { + DebugPanels, + type HostToTransport, + ScreenPanel, + type TransportToHost, + createMessageTransport, +} from '@gba-kit/debug-ui'; +import '@gba-kit/debug-ui/styles.css'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +declare function acquireVsCodeApi(): { + postMessage(message: unknown): void; + getState(): unknown; + setState(state: unknown): void; +}; + +const vscode = acquireVsCodeApi(); + +const transport = createMessageTransport({ + post: (message: TransportToHost) => vscode.postMessage(message), + listen: (handler) => { + const onMessage = (e: MessageEvent): void => handler(e.data); + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, +}); + +const container = document.getElementById('root')!; +const root = container.dataset.root === 'tools' ? 'tools' : 'screen'; +const scale = Number(container.dataset.screenScale) || 2; +const saved = (vscode.getState() as { panel?: string } | undefined) ?? {}; + +createRoot(container).render( + + {root === 'screen' ? ( +
+ +
+ ) : ( + vscode.setState({ ...saved, panel })} + /> + )} +
, +); diff --git a/apps/vscode-extension/tsconfig.json b/apps/vscode-extension/tsconfig.json new file mode 100644 index 0000000..de93341 --- /dev/null +++ b/apps/vscode-extension/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "jsx": "react-jsx", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["node", "vscode"], + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/apps/vscode-extension/vitest.config.ts b/apps/vscode-extension/vitest.config.ts new file mode 100644 index 0000000..c0ffacb --- /dev/null +++ b/apps/vscode-extension/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // The extension and frame-server tests wait on real pipes and sockets, so the 5s + // default fails them on a loaded machine or a busy CI runner, which reads as a broken + // build rather than a slow one. (The bundling and packaging tests carry their own.) + testTimeout: 20_000, + include: ['src/__tests__/**/*.spec.ts'], + }, +}); diff --git a/apps/webapp/README.md b/apps/webapp/README.md index 469264e..a8e5d0a 100644 --- a/apps/webapp/README.md +++ b/apps/webapp/README.md @@ -6,6 +6,8 @@ Browser-based GBA debugger with real-time emulation, disassembly, breakpoints, m - GBA emulation - Debugger panel with ARM/Thumb disassembly, execution control, CPU register viewer, memory viewer, etc +- Source-level debugging from a sidecar ELF (`@gba-kit/debug-core`): C source view, DWARF-typed variables, breakpoints and exact rewind +- The `@gba-kit/debug-ui` panels: screen, palette, tiles, tilemaps, sprites, I/O registers, trace, events, memory search, labels - Save state slots, persisted in IndexedDB - Input recording and script replay diff --git a/apps/webapp/package.json b/apps/webapp/package.json index c643d75..be0e984 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -17,7 +17,9 @@ "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.40.0", "@gba-kit/arm-emulator": "workspace:*", + "@gba-kit/debug-core": "workspace:*", "@gba-kit/debug-info": "workspace:*", + "@gba-kit/debug-ui": "workspace:*", "@gba-kit/gba-browser": "workspace:*", "@gba-kit/gba-emulator": "workspace:*", "@gba-kit/gba-react": "workspace:*", @@ -35,6 +37,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "dependency-cruiser": "^17.3.9", + "jsdom": "^30.0.1", "tailwindcss": "^4.2.2", "tsx": "^4.21.0", "typescript": "^6.0.2", diff --git a/apps/webapp/src/App.tsx b/apps/webapp/src/App.tsx index 63b29cb..6643bc7 100644 --- a/apps/webapp/src/App.tsx +++ b/apps/webapp/src/App.tsx @@ -8,6 +8,8 @@ import { DebugView } from './pages/debug/DebugView'; import { LoadView } from './pages/load/LoadView'; import { PlayView } from './pages/play/PlayView'; import { InputRecorder } from './scripting'; +import { fetchElfFromServer } from './session/elf-loader'; +import { useDebugSession } from './session/use-session'; type AppMode = 'play' | 'debug'; @@ -20,6 +22,7 @@ export function App() { const [mode, setMode] = useState('play'); const [romLoaded, setRomLoaded] = useState(false); const [romData, setRomData] = useState(null); + const [elfData, setElfData] = useState(null); const [romAutoLoading, setRomAutoLoading] = useState(false); const [, forceRender] = useState(0); @@ -28,9 +31,11 @@ export function App() { forceRender((n) => n + 1); recorderRef.current?.onFrame(); }, - onBreakpoint: () => setMode('debug'), }); + // The Debug page runs a debug-core session over the same machine, while it is shown + const debug = useDebugSession(emulator, romData, elfData, mode === 'debug'); + // Lazily create the recorder const recorderRef = useRef(null); if (!recorderRef.current) { @@ -58,10 +63,22 @@ export function App() { emulator.loadRom(data); setRomLoaded(true); setRomData(data); + // An ELF describes one ROM: the sidecar is fetched again, a picked one must be picked again + setElfData(null); }, [emulator], ); + // Auto-load the sidecar ELF the dev server serves, when configured + useEffect(() => { + if (!window.__GBAKIT_CONFIG__?.hasElf || elfData) { + return; + } + fetchElfFromServer() + .then(setElfData) + .catch((err) => console.error('Auto ELF load failed:', err)); + }, [elfData]); + // Auto-load ROM from server if configured useEffect(() => { const config = window.__GBAKIT_CONFIG__; @@ -100,8 +117,6 @@ export function App() { const handleRun = useCallback(() => emulator.run(), [emulator]); const handlePause = useCallback(() => emulator.pause(), [emulator]); - const handleStep = useCallback(() => emulator.stepInstruction(), [emulator]); - const handleStepOver = useCallback(() => emulator.stepOver(), [emulator]); const handleStartRecording = useCallback(() => { recorder.start(); @@ -136,14 +151,7 @@ export function App() { ); } else { content = ( - + ); } @@ -158,7 +166,7 @@ export function App() { {content} - {romLoaded && } + {romLoaded && } ); } diff --git a/apps/webapp/src/__tests__/browser-files.spec.ts b/apps/webapp/src/__tests__/browser-files.spec.ts new file mode 100644 index 0000000..d3b00ea --- /dev/null +++ b/apps/webapp/src/__tests__/browser-files.spec.ts @@ -0,0 +1,64 @@ +/** + * The session's project files over Web Storage: what a label edit saves is what + * a session rebuilt for the same ROM loads, and another ROM's session never sees. + */ +import { ManualHost } from '@gba-kit/debug-core'; +import { describe, expect, it } from 'vitest'; + +import { type KeyValueStorage, storageFiles } from '../session/browser-files'; +import { bootSession } from './fixtures'; + +/** `Storage` over a map, the way a browser's local storage behaves. */ +function memoryStorage(): KeyValueStorage & { keys(): string[] } { + const map = new Map(); + return { + get length() { + return map.size; + }, + key: (index) => [...map.keys()][index] ?? null, + getItem: (key) => map.get(key) ?? null, + setItem: (key, value) => void map.set(key, value), + removeItem: (key) => void map.delete(key), + keys: () => [...map.keys()], + }; +} + +describe('storageFiles', () => { + it('round-trips text and bytes, lists a directory, and joins paths', async () => { + const storage = memoryStorage(); + const files = storageFiles(storage); + expect(files.join('/', '.gba-kit', 'labels.json')).toBe('/.gba-kit/labels.json'); + expect(files.join('/roms/abc/', '/.gba-kit', 'x')).toBe('/roms/abc/.gba-kit/x'); + + expect(await files.readText('/a/b.txt')).toBeNull(); + await files.writeText('/a/b.txt', 'hello'); + expect(await files.readText('a/b.txt')).toBe('hello'); + + const bytes = Uint8Array.from([0, 1, 2, 250, 255]); + await files.writeBytes('/a/c.bin', bytes); + expect(await files.readBytes('/a/c.bin')).toEqual(bytes); + expect(await files.readBytes('/a/missing.bin')).toBeNull(); + + await files.writeText('/a/d/e.txt', 'nested'); + expect(await files.list('/a')).toEqual(['b.txt', 'c.bin']); + expect(await files.list('/a/d')).toEqual(['e.txt']); + expect(await files.list('/none')).toEqual([]); + expect(storage.keys().every((k) => k.startsWith('gba-kit:file:/'))).toBe(true); + }); + + it("keeps a ROM's labels for the session rebuilt after it, under that ROM alone", async () => { + const host = new ManualHost(storageFiles(memoryStorage())); + const first = await bootSession('thumb-O0', host, { projectDir: '/roms/a' }); + const address = first.program.symbolAddress('add_bonus')!; + first.labels.set({ address, label: 'AddBonus' }); + expect(await first.saveLabels()).toBe('/roms/a/.gba-kit/labels.json'); + expect(first.labels.dirty).toBe(false); + + const rebuilt = await bootSession('thumb-O0', host, { projectDir: '/roms/a' }); + expect(rebuilt.labels.at(address)?.label).toBe('AddBonus'); + expect(rebuilt.disassemble(address, 1)[0]!.label).toBe('AddBonus'); + + const another = await bootSession('thumb-O0', host, { projectDir: '/roms/b' }); + expect(another.labels.size).toBe(0); + }); +}); diff --git a/apps/webapp/src/__tests__/emulator-bridge.spec.ts b/apps/webapp/src/__tests__/emulator-bridge.spec.ts new file mode 100644 index 0000000..c1d4d9c --- /dev/null +++ b/apps/webapp/src/__tests__/emulator-bridge.spec.ts @@ -0,0 +1,174 @@ +/** + * The Play page's bridge over a machine the Debug session also drives: what its + * canvas and save-state thumbnails show after the session moved the `Gba`, and + * what its `run()` and its breakpoint edits do to a CPU debug hook it did not + * install. The DOM the bridge touches (ImageData, canvases, the animation + * frame) is stubbed so each paint can be read back. + */ +import { Machine, ManualHost, Session } from '@gba-kit/debug-core'; +import { EmulatorBridge } from '@gba-kit/gba-browser'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { fixture } from './fixtures'; + +/** A 2d context that keeps what was painted onto it and which canvas was drawn into it. */ +interface FakeContext { + imageSmoothingEnabled: boolean; + painted: Uint8ClampedArray | null; + drawn: FakeCanvas | null; + putImageData(image: ImageData, x: number, y: number): void; + drawImage(source: FakeCanvas, ...rest: number[]): void; +} + +interface FakeCanvas { + width: number; + height: number; + ctx: FakeContext; + getContext(kind: string): FakeContext; + toBlob(callback: (blob: Blob | null) => void, type?: string): void; +} + +function fakeCanvas(): FakeCanvas { + const ctx: FakeContext = { + imageSmoothingEnabled: true, + painted: null, + drawn: null, + putImageData(image) { + ctx.painted = new Uint8ClampedArray(image.data); + }, + drawImage(source) { + ctx.drawn = source; + }, + }; + return { + width: 0, + height: 0, + ctx, + getContext: () => ctx, + toBlob: (callback) => callback(new Blob([])), + }; +} + +/** Every canvas `document.createElement` handed out, in creation order. */ +const created: FakeCanvas[] = []; + +class FakeImageData { + readonly data: Uint8ClampedArray; + constructor( + readonly width: number, + readonly height: number, + ) { + this.data = new Uint8ClampedArray(width * height * 4); + } +} + +const saved: Partial> = {}; + +beforeAll(() => { + const g = globalThis as unknown as Record; + for (const name of ['ImageData', 'document', 'requestAnimationFrame', 'cancelAnimationFrame'] as const) { + saved[name] = g[name]; + } + g['ImageData'] = FakeImageData; + g['document'] = { + createElement: (): FakeCanvas => { + const canvas = fakeCanvas(); + created.push(canvas); + return canvas; + }, + }; + g['requestAnimationFrame'] = (): number => 1; + g['cancelAnimationFrame'] = (): void => {}; +}); + +afterAll(() => { + const g = globalThis as unknown as Record; + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) { + delete g[name]; + } else { + g[name] = value; + } + } +}); + +function bufferOf(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +/** A bridge with the ROM loaded, and a session over the same `Gba`, the way the Debug page boots one. */ +async function bootShared(): Promise<{ bridge: EmulatorBridge; session: Session; rom: Uint8Array }> { + const { rom, elf } = fixture('thumb-O0'); + const bridge = new EmulatorBridge(); + bridge.loadRom(bufferOf(rom)); + const session = await Session.create(new ManualHost(), { + rom, + elf, + cwd: '/', + exists: () => true, + machine: new Machine(rom, bridge.gba), + }); + return { bridge, session, rom }; +} + +function live(session: Session): Uint8ClampedArray { + return new Uint8ClampedArray(session.machine.framebufferRgba()); +} + +describe('EmulatorBridge over a machine the debug session moves', () => { + it('thumbnails the screen as it is now, and refreshFrame repaints the canvas with it', async () => { + const { bridge, session } = await bootShared(); + const playCanvas = fakeCanvas(); + bridge.attachCanvas(playCanvas as unknown as HTMLCanvasElement); + for (let i = 0; i < 60; i++) { + bridge.runOneFrame(); + } + const beforeDebug = playCanvas.ctx.painted!; + expect(beforeDebug).toEqual(live(session)); + bridge.detachCanvas(); + + // Debug takes the machine and renders through the session's own screen + session.resync('back from Play'); + for (let i = 0; i < 120; i++) { + session.stepFrame(); + } + const now = live(session); + expect(now).not.toEqual(beforeDebug); + + created.length = 0; + const { snapshot } = await bridge.saveState(); + expect(snapshot.cpu.registers[15]).toBe(session.pc); + const [thumb, source] = created; + expect(thumb!.ctx.drawn).toBe(source); + expect(source!.ctx.painted).toEqual(now); + + // back to Play: the canvas is attached, then the bridge is told the machine moved + const again = fakeCanvas(); + bridge.attachCanvas(again as unknown as HTMLCanvasElement); + bridge.refreshFrame(); + expect(again.ctx.painted).toEqual(now); + }); + + it('run() leaves a CPU debug hook it did not install in place, and clears only its own', async () => { + const { bridge } = await bootShared(); + let seen = 0; + bridge.gba.armCpu.setDebugHooks({ onInstructionPost: () => void seen++ }); + + bridge.run(); // runs one frame before the (stubbed) animation frame is asked for + bridge.pause(); + expect(seen).toBeGreaterThan(0); + + const afterRun = seen; + bridge.runOneFrame(); + expect(seen).toBeGreaterThan(afterRun); + + // the bridge's breakpoint hooks take the slot; removing its last breakpoint empties it + bridge.addBreakpoint(0x08000000); + const withBreakpoint = seen; + bridge.runOneFrame(); + expect(seen).toBe(withBreakpoint); + bridge.removeBreakpoint(0x08000000); + bridge.runOneFrame(); + expect(seen).toBe(withBreakpoint); + }); +}); diff --git a/apps/webapp/src/__tests__/fixtures.ts b/apps/webapp/src/__tests__/fixtures.ts new file mode 100644 index 0000000..134237b --- /dev/null +++ b/apps/webapp/src/__tests__/fixtures.ts @@ -0,0 +1,43 @@ +/** The debug-core fixtures (a small game loop built as Thumb -O0 and -O2), and a session booted over one. */ +import { ManualHost, Session } from '@gba-kit/debug-core'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const build = join( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + '..', + '..', + 'packages', + 'debug-core', + 'test-fixtures', + 'build', +); + +export type Variant = 'thumb-O0' | 'thumb-O2'; + +export const FRAME_MS = 1000 / 59.7275; + +export function fixture(variant: Variant): { rom: Uint8Array; elf: Uint8Array } { + return { + rom: new Uint8Array(readFileSync(join(build, `${variant}.gba`))), + elf: new Uint8Array(readFileSync(join(build, `${variant}.elf`))), + }; +} + +export function bootSession( + variant: Variant, + host = new ManualHost(), + options: { elf?: Uint8Array | null; projectDir?: string } = {}, +): Promise { + const { rom, elf } = fixture(variant); + return Session.create(host, { + rom, + elf: 'elf' in options ? options.elf : elf, + cwd: '/', + projectDir: options.projectDir, + exists: () => true, + }); +} diff --git a/apps/webapp/src/__tests__/instruction-breakpoints.spec.ts b/apps/webapp/src/__tests__/instruction-breakpoints.spec.ts new file mode 100644 index 0000000..14b4703 --- /dev/null +++ b/apps/webapp/src/__tests__/instruction-breakpoints.spec.ts @@ -0,0 +1,43 @@ +/** + * The Debug page's instruction breakpoints are the session's: derived on every + * render, so leaving for Play and coming back (a remount with no local state) + * shows and honours what was set before. + */ +import { ManualHost, type StopInfo } from '@gba-kit/debug-core'; +import { describe, expect, it } from 'vitest'; + +import { instructionAddresses, toggleInstructionBreakpoint } from '../pages/debug/instruction-breakpoints'; +import { FRAME_MS, bootSession } from './fixtures'; + +describe('instruction breakpoints', () => { + it('toggle sets and clears through the session, ascending and halfword-aligned', async () => { + const session = await bootSession('thumb-O0'); + expect(instructionAddresses(session)).toEqual([]); + expect(toggleInstructionBreakpoint(session, 0x08000241)).toEqual([0x08000240]); + expect(toggleInstructionBreakpoint(session, 0x08000100)).toEqual([0x08000100, 0x08000240]); + expect(toggleInstructionBreakpoint(session, 0x08000240)).toEqual([0x08000100]); + expect(session.breakpoints.all().map((bp) => [bp.kind, bp.verified, bp.addresses])).toEqual([ + ['instruction', true, [0x08000100]], + ]); + }); + + it('survives the Play round trip: a fresh derivation lists it, and running still stops there', async () => { + const host = new ManualHost(); + const session = await bootSession('thumb-O0', host); + const main = session.program.symbolAddress('main')!; + toggleInstructionBreakpoint(session, main); + + // leaving Debug pauses the session; coming back mounts a view with no state of its own + session.pause(); + expect(instructionAddresses(session)).toEqual([main]); + expect(session.breakpoints.all()).toHaveLength(1); + + const stops: StopInfo[] = []; + session.on({ stopped: (info) => stops.push(info) }); + session.continue(); + for (let i = 0; i < 300 && stops.length === 0; i++) { + host.tick(FRAME_MS); + } + expect(stops[0]).toMatchObject({ reason: 'instruction breakpoint', address: main }); + }); +}); diff --git a/apps/webapp/src/__tests__/memory-model.spec.ts b/apps/webapp/src/__tests__/memory-model.spec.ts new file mode 100644 index 0000000..3c5c17e --- /dev/null +++ b/apps/webapp/src/__tests__/memory-model.spec.ts @@ -0,0 +1,51 @@ +/** + * Memory rows over what the bus actually maps: the zeros a partial read fills + * in for SRAM without a chip, or past the ROM's end, are drawn as absent. + */ +import { describe, expect, it } from 'vitest'; + +import { memoryRows } from '../pages/debug/memory-model'; +import { bootSession, fixture } from './fixtures'; + +const hexOf = (rows: ReturnType): string[] => rows.flatMap((r) => r.cells.map((c) => c.hex)); + +describe('memoryRows', () => { + it('lays mapped bytes out as hex and ASCII, at ascending row addresses', () => { + const data = new Uint8Array([0x41, 0x00, 0x7f, 0x20, 0x7e, 0xff, 0x30, 0x0a]); + const rows = memoryRows({ data, readable: 8 }, 0x03000000, 4, 2); + expect(rows.map((r) => r.address)).toEqual([0x03000000, 0x03000004]); + expect(hexOf(rows)).toEqual(['41', '00', '7f', '20', '7e', 'ff', '30', '0a']); + expect(rows.map((r) => r.cells.map((c) => c.ascii).join(''))).toEqual(['A.. ', '~.0.']); + expect(rows.every((r) => r.cells.every((c) => c.mapped))).toBe(true); + }); + + it('draws every byte past `readable` as absent, not as 00', () => { + const rows = memoryRows({ data: new Uint8Array(8), readable: 3 }, 0, 4, 2); + expect(hexOf(rows)).toEqual(['00', '00', '00', '--', '--', '--', '--', '--']); + expect(rows[0]!.cells.map((c) => c.ascii).join('')).toBe('... '); + expect(rows[1]!.cells.map((c) => c.mapped)).toEqual([false, false, false, false]); + }); + + it('shows SRAM without a backup chip and the space after a ROM as unmapped', async () => { + const session = await bootSession('thumb-O0'); + const romEnd = 0x08000000 + fixture('thumb-O0').rom.length; + + const sram = memoryRows(session.readMemory(0x0e000000, 128), 0x0e000000, 16, 8); + expect(hexOf(sram).every((h) => h === '--')).toBe(true); + + const tail = memoryRows(session.readMemory(romEnd - 16, 128), romEnd - 16, 16, 8); + expect( + hexOf(tail) + .slice(0, 16) + .every((h) => h !== '--'), + ).toBe(true); + expect( + hexOf(tail) + .slice(16) + .every((h) => h === '--'), + ).toBe(true); + + const iwram = memoryRows(session.readMemory(0x03000000, 128), 0x03000000, 16, 8); + expect(hexOf(iwram).every((h) => h !== '--')).toBe(true); + }); +}); diff --git a/apps/webapp/src/__tests__/program-status.spec.ts b/apps/webapp/src/__tests__/program-status.spec.ts new file mode 100644 index 0000000..2a4a214 --- /dev/null +++ b/apps/webapp/src/__tests__/program-status.spec.ts @@ -0,0 +1,25 @@ +/** The toolbar's word on the ELF: silent when it matches the ROM, loud when it describes another one. */ +import { describe, expect, it } from 'vitest'; + +import { programStatus } from '../pages/debug/program-status'; +import { bootSession, fixture } from './fixtures'; + +describe('programStatus', () => { + it('says nothing for an ELF that matches the ROM', async () => { + const session = await bootSession('thumb-O0'); + expect(programStatus(session.program)).toBeNull(); + }); + + it('says there is no ELF', async () => { + const session = await bootSession('thumb-O0', undefined, { elf: null }); + expect(programStatus(session.program)).toEqual({ text: 'no ELF' }); + }); + + it('flags an ELF kept from another ROM, with the contradiction as the detail', async () => { + const session = await bootSession('thumb-O2', undefined, { elf: fixture('thumb-O0').elf }); + expect(programStatus(session.program)).toEqual({ + text: 'ELF does not match ROM', + detail: expect.stringContaining('extends past the end of the ROM'), + }); + }); +}); diff --git a/apps/webapp/src/__tests__/use-session.spec.ts b/apps/webapp/src/__tests__/use-session.spec.ts new file mode 100644 index 0000000..2eb089d --- /dev/null +++ b/apps/webapp/src/__tests__/use-session.spec.ts @@ -0,0 +1,201 @@ +// @vitest-environment jsdom +/** + * The Debug page's session hook over a real machine: when the session is + * built, what bumps `revision`, when a loaded save state resyncs it, and what + * a rebuilt session remembers. + */ +import { Session } from '@gba-kit/debug-core'; +import type { EmulatorBridge } from '@gba-kit/gba-browser'; +import { Gba } from '@gba-kit/gba-emulator'; +import { act, createElement } from 'react'; +import { type Root, createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type DebugSessionHandle, useDebugSession } from '../session/use-session'; +import { fixture } from './fixtures'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +interface HarnessProps { + emulator: EmulatorBridge; + romData: ArrayBuffer | null; + elfData: Uint8Array | null; + active: boolean; +} + +let latest: DebugSessionHandle | null = null; + +function Harness(props: HarnessProps): null { + latest = useDebugSession(props.emulator, props.romData, props.elfData, props.active); + return null; +} + +type FakeBridge = EmulatorBridge & { pause: ReturnType; refreshFrame: ReturnType }; + +/** The part of the bridge the hook uses: the machine, the pause it calls on entering Debug and the repaint on leaving. */ +function bridgeOver(rom: Uint8Array): FakeBridge { + const gba = new Gba(); + gba.loadRom(rom); + return { gba, pause: vi.fn(), refreshFrame: vi.fn() } as unknown as FakeBridge; +} + +function bufferOf(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +describe('useDebugSession', () => { + let root: Root; + let container: HTMLDivElement; + + const render = (props: HarnessProps): Promise => act(async () => root.render(createElement(Harness, props))); + + /** Session creation is asynchronous (the ROM is hashed first): settle until the hook has one. */ + const settle = async (): Promise => { + for (let i = 0; i < 20 && !latest?.session; i++) { + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + } + expect(latest?.session).not.toBeNull(); + return latest!; + }; + + beforeEach(() => { + localStorage.clear(); + latest = null; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + }); + + it('builds the session on entering Debug, pausing Play and resyncing exactly once', async () => { + const resync = vi.spyOn(Session.prototype, 'resync'); + const { rom, elf } = fixture('thumb-O0'); + const emulator = bridgeOver(rom); + const romData = bufferOf(rom); + await render({ emulator, romData, elfData: elf, active: false }); + expect(latest?.session).toBeNull(); + + await render({ emulator, romData, elfData: elf, active: true }); + const handle = await settle(); + expect(handle.state).toBe('stopped'); + expect(emulator.pause).toHaveBeenCalled(); + expect(resync).toHaveBeenCalledTimes(1); + expect(handle.session!.machine.gba).toBe(emulator.gba); + }); + + it('a loaded save state resyncs the session only while Debug is shown', async () => { + const { rom, elf } = fixture('thumb-O0'); + const emulator = bridgeOver(rom); + const romData = bufferOf(rom); + await render({ emulator, romData, elfData: elf, active: true }); + const { session } = await settle(); + const resync = vi.spyOn(session!, 'resync'); + + // in Play the session waits; the next Debug entry catches up with the machine + await render({ emulator, romData, elfData: elf, active: false }); + await act(async () => latest!.onStateLoaded()); + expect(resync).not.toHaveBeenCalled(); + expect(latest!.session).toBe(session); + + await render({ emulator, romData, elfData: elf, active: true }); + expect(resync).toHaveBeenCalledTimes(1); + expect(resync).toHaveBeenLastCalledWith('back from Play'); + await act(async () => latest!.onStateLoaded()); + expect(resync).toHaveBeenCalledTimes(2); + expect(resync).toHaveBeenLastCalledWith('a save state was loaded'); + }); + + it('leaving Debug takes the hooks off the machine and repaints Play, so its frames cost and show nothing', async () => { + const { rom, elf } = fixture('thumb-O0'); + const emulator = bridgeOver(rom); + const romData = bufferOf(rom); + await render({ emulator, romData, elfData: elf, active: true }); + const { session } = await settle(); + session!.setTracing(true); + session!.stepFrame(); + expect(session!.trace.size).toBeGreaterThan(0); + expect(emulator.gba.onHardwareEvent).not.toBeNull(); + + await render({ emulator, romData, elfData: elf, active: false }); + expect(emulator.refreshFrame).toHaveBeenCalledTimes(2); // on leaving, and once the hooks were off + expect(emulator.gba.onHardwareEvent).toBeNull(); + const logged = session!.trace.size; + for (let i = 0; i < 10; i++) { + emulator.gba.runFrame(); + } + expect(session!.trace.size).toBe(logged); + expect(session!.tracing).toBe(true); + + // back in Debug the hooks return, and the trace starts over from here + await render({ emulator, romData, elfData: elf, active: true }); + expect(emulator.gba.onHardwareEvent).not.toBeNull(); + session!.stepFrame(); + expect(session!.trace.size).toBeGreaterThan(0); + }); + + it('a session still running when Play is shown yields the machine once it stops', async () => { + const { rom, elf } = fixture('thumb-O0'); + const emulator = bridgeOver(rom); + const romData = bufferOf(rom); + await render({ emulator, romData, elfData: elf, active: true }); + const { session } = await settle(); + await act(async () => session!.continue()); + expect(session!.state).toBe('running'); + + await render({ emulator, romData, elfData: elf, active: false }); + expect(emulator.refreshFrame).toHaveBeenCalledTimes(1); + expect(emulator.gba.onHardwareEvent).not.toBeNull(); // not yet: the loop stops at its frame boundary + for (let i = 0; i < 50 && emulator.gba.onHardwareEvent !== null; i++) { + await act(() => new Promise((resolve) => setTimeout(resolve, 10))); + } + expect(session!.state).toBe('stopped'); + expect(emulator.gba.onHardwareEvent).toBeNull(); + expect(emulator.refreshFrame).toHaveBeenCalledTimes(2); + }); + + it('a label edit bumps the revision the views key their caches on', async () => { + const { rom, elf } = fixture('thumb-O0'); + const emulator = bridgeOver(rom); + await render({ emulator, romData: bufferOf(rom), elfData: elf, active: true }); + const { session, revision } = await settle(); + const pc = session!.pc; + await act(async () => session!.labels.set({ address: pc, label: 'renamed' })); + expect(latest!.revision).toBe(revision + 1); + expect(session!.disassemble(pc, 1)[0]!.label).toBe('renamed'); + }); + + it('a write to the machine bumps the revision, so the memory and register views re-read at once', async () => { + const { rom, elf } = fixture('thumb-O0'); + const emulator = bridgeOver(rom); + await render({ emulator, romData: bufferOf(rom), elfData: elf, active: true }); + const { session, revision } = await settle(); + const address = session!.program.symbolAddress('g_frame')!; + await act(async () => session!.writeMemory(address, new Uint8Array([0x2a, 0, 0, 0]))); + expect(latest!.revision).toBe(revision + 1); + expect(session!.readMemory(address, 1).data[0]).toBe(0x2a); + await act(async () => session!.setRegister(0, 1)); + expect(latest!.revision).toBe(revision + 2); + }); + + it('a session rebuilt for another ELF still has the labels saved for the ROM', async () => { + const { rom, elf } = fixture('thumb-O0'); + const emulator = bridgeOver(rom); + const romData = bufferOf(rom); + await render({ emulator, romData, elfData: elf, active: true }); + const { session: first } = await settle(); + const address = first!.program.symbolAddress('add_bonus')!; + first!.labels.set({ address, label: 'AddBonus' }); + await first!.saveLabels(); + + await render({ emulator, romData, elfData: new Uint8Array(elf), active: true }); + expect(latest!.session).toBeNull(); + const { session: rebuilt } = await settle(); + expect(rebuilt).not.toBe(first); + expect(rebuilt!.labels.at(address)?.label).toBe('AddBonus'); + }); +}); diff --git a/apps/webapp/src/components/SaveStateDrawer.tsx b/apps/webapp/src/components/SaveStateDrawer.tsx index ec5259e..79777b4 100644 --- a/apps/webapp/src/components/SaveStateDrawer.tsx +++ b/apps/webapp/src/components/SaveStateDrawer.tsx @@ -9,21 +9,22 @@ import { saveState, } from '@gba-kit/gba-browser'; import clsx from 'clsx'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { SaveSlotCard } from './SaveSlotCard'; interface SaveStateDrawerProps { emulator: EmulatorBridge; romData: ArrayBuffer | null; + /** a state was loaded into the machine (the Debug page resyncs its session) */ + onStateLoaded?: () => void; } -export function SaveStateDrawer({ emulator, romData }: SaveStateDrawerProps) { +export function SaveStateDrawer({ emulator, romData, onStateLoaded }: SaveStateDrawerProps) { const [expanded, setExpanded] = useState(false); const [saves, setSaves] = useState([]); const [romHash, setRomHash] = useState(null); const [saving, setSaving] = useState(false); - const saveCountRef = useRef(0); // Compute ROM hash when ROM changes useEffect(() => { @@ -54,9 +55,9 @@ export function SaveStateDrawer({ emulator, romData }: SaveStateDrawerProps) { } setSaving(true); try { - saveCountRef.current++; const { snapshot, thumbnail } = await emulator.saveState(); - await saveState(romHash, snapshot, thumbnail, `Save #${saveCountRef.current}`); + // named for the frame it holds, the way the debugger names one: it is what you look for later + await saveState(romHash, snapshot, thumbnail, `frame-${emulator.gba.frameCount}`); await refreshList(); setExpanded(true); } finally { @@ -69,9 +70,10 @@ export function SaveStateDrawer({ emulator, romData }: SaveStateDrawerProps) { const record = await loadState(id); if (record) { emulator.loadState(record.snapshot); + onStateLoaded?.(); } }, - [emulator], + [emulator, onStateLoaded], ); const handleDelete = useCallback( @@ -136,7 +138,7 @@ export function SaveStateDrawer({ emulator, romData }: SaveStateDrawerProps) { className="w-full bg-zinc-900 border-t border-zinc-700 px-4 py-1.5 flex items-center justify-between text-sm text-zinc-400 hover:text-zinc-200 transition-colors" onClick={() => setExpanded(!expanded)} > - Save States ({saves.length}) + Save states ({saves.length}) diff --git a/apps/webapp/src/main.tsx b/apps/webapp/src/main.tsx index 6dd544b..d6e284e 100644 --- a/apps/webapp/src/main.tsx +++ b/apps/webapp/src/main.tsx @@ -1,3 +1,4 @@ +import '@gba-kit/debug-ui/styles.css'; import React from 'react'; import { createRoot } from 'react-dom/client'; diff --git a/apps/webapp/src/pages/debug/BreakpointPanel.tsx b/apps/webapp/src/pages/debug/BreakpointPanel.tsx index 9c5189e..a271fe0 100644 --- a/apps/webapp/src/pages/debug/BreakpointPanel.tsx +++ b/apps/webapp/src/pages/debug/BreakpointPanel.tsx @@ -1,48 +1,39 @@ -import type { EmulatorBridge } from '@gba-kit/gba-browser'; import { useCallback, useState } from 'react'; import { Panel } from '../../components/Panel'; interface BreakpointPanelProps { - emulator: EmulatorBridge; - breakpointVersion: number; - onBreakpointChange: () => void; + breakpoints: number[]; + onToggle: (address: number) => void; } -export function BreakpointPanel({ emulator, breakpointVersion, onBreakpointChange }: BreakpointPanelProps) { - void breakpointVersion; // used to trigger re-render +/** Instruction breakpoints by address; source-line breakpoints are the editor's job. */ +export function BreakpointPanel({ breakpoints, onToggle }: BreakpointPanelProps) { const [inputAddr, setInputAddr] = useState(''); - const breakpoints = emulator.getBreakpoints(); const handleAdd = useCallback(() => { const addr = parseInt(inputAddr, 16); if (!isNaN(addr)) { - emulator.addBreakpoint(addr); + onToggle((addr & ~1) >>> 0); setInputAddr(''); - onBreakpointChange(); } - }, [emulator, inputAddr, onBreakpointChange]); + }, [inputAddr, onToggle]); return (
{breakpoints.length === 0 ? ( -
No breakpoints set
+
No breakpoints set (Ctrl+B toggles one at the PC)
) : ( - breakpoints.map((bp) => ( + breakpoints.map((address) => (
- - 0x{bp.address.toString(16).padStart(8, '0')} - + 0x{address.toString(16).padStart(8, '0')} ); } diff --git a/apps/webapp/src/pages/debug/DisassemblyView.tsx b/apps/webapp/src/pages/debug/DisassemblyView.tsx index 0b21b33..6fd6da5 100644 --- a/apps/webapp/src/pages/debug/DisassemblyView.tsx +++ b/apps/webapp/src/pages/debug/DisassemblyView.tsx @@ -1,37 +1,35 @@ -import type { EmulatorBridge } from '@gba-kit/gba-browser'; +import type { Session } from '@gba-kit/debug-core'; import clsx from 'clsx'; import { useMemo } from 'react'; import { Panel } from '../../components/Panel'; interface DisassemblyViewProps { - emulator: EmulatorBridge; - pc: number; - breakpointVersion: number; - onBreakpointChange: () => void; + session: Session; + revision: number; + breakpoints: number[]; + onToggleBreakpoint: (address: number) => void; } const LINES_BEFORE = 10; const LINES_AFTER = 30; -export function DisassemblyView({ emulator, pc, breakpointVersion, onBreakpointChange }: DisassemblyViewProps) { - const breakpoints = useMemo(() => { - const bps = emulator.getBreakpoints(); - return new Set(bps.filter((bp) => bp.enabled).map((bp) => bp.address)); - }, [emulator, breakpointVersion]); - - const isThumb = emulator.cpu.getT(); - const instrSize = isThumb ? 2 : 4; +export function DisassemblyView({ session, revision, breakpoints, onToggleBreakpoint }: DisassemblyViewProps) { + const pc = session.pc; + const instrSize = session.machine.thumb ? 2 : 4; const startAddr = Math.max(0, pc - LINES_BEFORE * instrSize); const totalLines = LINES_BEFORE + 1 + LINES_AFTER; + const bpSet = useMemo(() => new Set(breakpoints), [breakpoints]); - const lines = useMemo(() => emulator.disassembleAt(startAddr, totalLines), [emulator, startAddr, totalLines]); + // `revision` is the cache key: the machine moved, or a label changed + const lines = useMemo(() => session.disassemble(startAddr, totalLines), [session, startAddr, totalLines, revision]); return ( {lines.map((line) => { const isCurrent = line.address === pc; - const isBp = breakpoints.has(line.address); + const isBp = bpSet.has(line.address); + const name = line.label ?? line.symbol; return (
{ - if (breakpoints.has(line.address)) { - emulator.removeBreakpoint(line.address); - } else { - emulator.addBreakpoint(line.address); - } - onBreakpointChange(); - }} + onClick={() => onToggleBreakpoint(line.address)} > - {/* Breakpoint gutter */}
{isBp && }
- - {/* PC marker */}
{isCurrent ? '>' : ''}
- - {/* Address */} 0x{line.address.toString(16).padStart(8, '0')} - - {/* Mnemonic */} - {line.mnemonic} + {line.bytes} + ' ? 'text-slate-600' : 'text-slate-200'}>{line.text} + {name && {name}} + {line.source && ( + + {line.source.path.replace(/^.*\//, '')}:{line.source.line} + + )}
); })} diff --git a/apps/webapp/src/pages/debug/IoRegisterView.tsx b/apps/webapp/src/pages/debug/IoRegisterView.tsx deleted file mode 100644 index 434dce2..0000000 --- a/apps/webapp/src/pages/debug/IoRegisterView.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import type { EmulatorBridge } from '@gba-kit/gba-browser'; -import { useMemo } from 'react'; - -import { Panel } from '../../components/Panel'; - -interface IoRegisterViewProps { - emulator: EmulatorBridge; -} - -interface DecodedRegister { - name: string; - offset: number; - value: number; - fields: Array<{ name: string; value: string | number }>; -} - -const BG_MODES = ['Mode 0', 'Mode 1', 'Mode 2', 'Mode 3', 'Mode 4', 'Mode 5', 'Invalid', 'Invalid']; - -function decodeDispcnt(value: number): DecodedRegister { - return { - name: 'DISPCNT', - offset: 0x00, - value, - fields: [ - { name: 'Mode', value: BG_MODES[value & 7]! }, - { name: 'Frame', value: (value >> 4) & 1 }, - { name: 'HBlank OAM', value: (value >> 5) & 1 ? 'Yes' : 'No' }, - { name: 'OBJ Map', value: (value >> 6) & 1 ? '1D' : '2D' }, - { name: 'Force Blank', value: (value >> 7) & 1 ? 'Yes' : 'No' }, - { name: 'BG0', value: (value >> 8) & 1 ? 'ON' : 'off' }, - { name: 'BG1', value: (value >> 9) & 1 ? 'ON' : 'off' }, - { name: 'BG2', value: (value >> 10) & 1 ? 'ON' : 'off' }, - { name: 'BG3', value: (value >> 11) & 1 ? 'ON' : 'off' }, - { name: 'OBJ', value: (value >> 12) & 1 ? 'ON' : 'off' }, - { name: 'WIN0', value: (value >> 13) & 1 ? 'ON' : 'off' }, - { name: 'WIN1', value: (value >> 14) & 1 ? 'ON' : 'off' }, - { name: 'OBJ WIN', value: (value >> 15) & 1 ? 'ON' : 'off' }, - ], - }; -} - -function decodeBgCnt(name: string, offset: number, value: number): DecodedRegister { - return { - name, - offset, - value, - fields: [ - { name: 'Priority', value: value & 3 }, - { name: 'Tile Base', value: `0x${(((value >> 2) & 3) * 0x4000).toString(16)}` }, - { name: 'Mosaic', value: (value >> 6) & 1 ? 'Yes' : 'No' }, - { name: 'Color', value: (value >> 7) & 1 ? '256' : '16' }, - { name: 'Map Base', value: `0x${(((value >> 8) & 0x1f) * 0x800).toString(16)}` }, - { name: 'Size', value: (value >> 14) & 3 }, - ], - }; -} - -function decodeBldcnt(value: number): DecodedRegister { - const effects = ['None', 'Alpha', 'Bright+', 'Bright-']; - return { - name: 'BLDCNT', - offset: 0x50, - value, - fields: [ - { name: 'Effect', value: effects[(value >> 6) & 3]! }, - { name: '1st BG0', value: value & 1 ? 'Yes' : 'No' }, - { name: '1st BG1', value: (value >> 1) & 1 ? 'Yes' : 'No' }, - { name: '1st OBJ', value: (value >> 4) & 1 ? 'Yes' : 'No' }, - { name: '2nd BG0', value: (value >> 8) & 1 ? 'Yes' : 'No' }, - { name: '2nd BD', value: (value >> 13) & 1 ? 'Yes' : 'No' }, - ], - }; -} - -export function IoRegisterView({ emulator }: IoRegisterViewProps) { - const mmio = emulator.gba.bus.mmioRegisters; - - const registers = useMemo(() => { - const read16 = (offset: number) => mmio[offset]! | (mmio[offset + 1]! << 8); - - return [ - decodeDispcnt(read16(0x00)), - decodeBgCnt('BG0CNT', 0x08, read16(0x08)), - decodeBgCnt('BG1CNT', 0x0a, read16(0x0a)), - decodeBgCnt('BG2CNT', 0x0c, read16(0x0c)), - decodeBgCnt('BG3CNT', 0x0e, read16(0x0e)), - decodeBldcnt(read16(0x50)), - ]; - }, [mmio]); - - return ( - - {registers.map((reg) => ( -
-
- {reg.name} - 0x{reg.value.toString(16).padStart(4, '0')} -
-
- {reg.fields.map((field) => ( -
- {field.name} - {field.value} -
- ))} -
-
- ))} -
- ); -} diff --git a/apps/webapp/src/pages/debug/MemoryViewer.tsx b/apps/webapp/src/pages/debug/MemoryViewer.tsx index 34164f8..9aa1b09 100644 --- a/apps/webapp/src/pages/debug/MemoryViewer.tsx +++ b/apps/webapp/src/pages/debug/MemoryViewer.tsx @@ -1,11 +1,13 @@ -import type { EmulatorBridge } from '@gba-kit/gba-browser'; +import type { Session } from '@gba-kit/debug-core'; import clsx from 'clsx'; import { useCallback, useMemo, useState } from 'react'; import { Panel } from '../../components/Panel'; +import { memoryRows } from './memory-model'; interface MemoryViewerProps { - emulator: EmulatorBridge; + session: Session; + revision: number; } interface MemorySection { @@ -28,7 +30,7 @@ const MEMORY_SECTIONS: MemorySection[] = [ const BYTES_PER_ROW = 16; const VISIBLE_ROWS = 8; -export function MemoryViewer({ emulator }: MemoryViewerProps) { +export function MemoryViewer({ session, revision }: MemoryViewerProps) { const [sectionIndex, setSectionIndex] = useState(0); const section = MEMORY_SECTIONS[sectionIndex]!; @@ -50,7 +52,9 @@ export function MemoryViewer({ emulator }: MemoryViewerProps) { }, [inputAddr]); const totalBytes = BYTES_PER_ROW * VISIBLE_ROWS; - const data = useMemo(() => emulator.readMemory(baseAddr, totalBytes), [emulator, baseAddr, totalBytes]); + // `revision` is the cache key: the machine moved + const read = useMemo(() => session.readMemory(baseAddr, totalBytes), [session, baseAddr, totalBytes, revision]); + const rows = useMemo(() => memoryRows(read, baseAddr, BYTES_PER_ROW, VISIBLE_ROWS), [read, baseAddr]); const memoryHeaderRight = (
@@ -96,31 +100,29 @@ export function MemoryViewer({ emulator }: MemoryViewerProps) { headerExtra={memorySectionTabs} contentClassName="font-mono text-[13px] leading-[1.4] text-xs px-3 py-1" > - {Array.from({ length: VISIBLE_ROWS }, (_, row) => { - const rowAddr = baseAddr + row * BYTES_PER_ROW; - const rowData = data.subarray(row * BYTES_PER_ROW, (row + 1) * BYTES_PER_ROW); - - return ( -
- {/* Address */} - {rowAddr.toString(16).padStart(8, '0')} - - {/* Hex bytes */} -
- {Array.from(rowData, (byte, i) => ( - - {byte.toString(16).padStart(2, '0')} - - ))} -
- - {/* ASCII */} - - {Array.from(rowData, (byte) => (byte >= 0x20 && byte < 0x7f ? String.fromCharCode(byte) : '.')).join('')} - + {read.readable === 0 && ( +
+ nothing is mapped here (SRAM without a backup chip, or past the end of the ROM) +
+ )} + {rows.map((row) => ( +
+ {/* Address */} + {row.address.toString(16).padStart(8, '0')} + + {/* Hex bytes: `--` where nothing is mapped */} +
+ {row.cells.map((cell, i) => ( + + {cell.hex} + + ))}
- ); - })} + + {/* ASCII */} + {row.cells.map((cell) => cell.ascii).join('')} +
+ ))} ); } diff --git a/apps/webapp/src/pages/debug/RegisterView.tsx b/apps/webapp/src/pages/debug/RegisterView.tsx index abdcac9..d3af1a4 100644 --- a/apps/webapp/src/pages/debug/RegisterView.tsx +++ b/apps/webapp/src/pages/debug/RegisterView.tsx @@ -1,24 +1,20 @@ -import type { EmulatorBridge } from '@gba-kit/gba-browser'; +import type { Session } from '@gba-kit/debug-core'; import { useMemo } from 'react'; import { Panel } from '../../components/Panel'; interface RegisterViewProps { - emulator: EmulatorBridge; + session: Session; + revision: number; } -const REG_NAMES = ['R0', 'R1', 'R2', 'R3', 'R4', 'R5', 'R6', 'R7', 'R8', 'R9', 'R10', 'R11', 'R12', 'SP', 'LR', 'PC']; - -export function RegisterView({ emulator }: RegisterViewProps) { - const cpu = emulator.cpu; - const regs = cpu.registers; - const n = cpu.getN(); - const z = cpu.getZ(); - const c = cpu.getC(); - const v = cpu.getV(); - const isThumb = cpu.getT(); - - const flagStr = useMemo(() => [n ? 'N' : 'n', z ? 'Z' : 'z', c ? 'C' : 'c', v ? 'V' : 'v'].join(''), [n, z, c, v]); +/** The registers as the session's inspector names them: `lr`/`pc` symbolized, `cpsr` decoded. */ +export function RegisterView({ session, revision }: RegisterViewProps) { + // `revision` is the cache key: the machine moved, or a label changed + const nodes = useMemo( + () => (session.state === 'stopped' ? (session.scopes(0).find((s) => s.kind === 'registers')?.nodes ?? []) : []), + [session, revision], + ); return ( - {REG_NAMES.map((name, i) => ( -
- {name} - 0x{(regs[i]! >>> 0).toString(16).padStart(8, '0')} -
- ))} - -
-
- CPSR - {flagStr} -
-
- Mode - {isThumb ? 'Thumb' : 'ARM'} -
-
+ {nodes.length === 0 ? ( +
{session.state === 'running' ? 'running…' : 'no registers'}
+ ) : ( + nodes.map((node) => ( +
+ {node.name} + {node.value} +
+ )) + )}
); } diff --git a/apps/webapp/src/pages/debug/ScreenView.tsx b/apps/webapp/src/pages/debug/ScreenView.tsx deleted file mode 100644 index 3ad7b20..0000000 --- a/apps/webapp/src/pages/debug/ScreenView.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { RefObject } from 'react'; - -import { Panel } from '../../components/Panel'; - -interface ScreenViewProps { - canvasRef: RefObject; -} - -export function ScreenView({ canvasRef }: ScreenViewProps) { - return ( - - - - ); -} diff --git a/apps/webapp/src/pages/debug/SourceView.tsx b/apps/webapp/src/pages/debug/SourceView.tsx index 060855a..03b69e7 100644 --- a/apps/webapp/src/pages/debug/SourceView.tsx +++ b/apps/webapp/src/pages/debug/SourceView.tsx @@ -1,15 +1,16 @@ -import type { DebugInfo } from '@gba-kit/debug-info'; -import type { EmulatorBridge } from '@gba-kit/gba-browser'; +import type { Session } from '@gba-kit/debug-core'; import clsx from 'clsx'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Panel } from '../../components/Panel'; -import { loadDebugInfoFromFile, loadDebugInfoFromServer } from './elf-loader'; +import { readElfFile } from '../../session/elf-loader'; import { type SourceRow, buildSourceRows, matchSegmentsIndex, toRenderItems } from './source-model'; interface SourceViewProps { - emulator: EmulatorBridge; - pc: number; + session: Session; + revision: number; + /** the user picked an ELF: the session is rebuilt with it */ + onElfLoad: (elf: Uint8Array) => void; } /** A picked source file, with its path split into segments for suffix matching. */ @@ -18,13 +19,13 @@ interface PickedFile { file: File; } -export function SourceView({ emulator, pc }: SourceViewProps) { - const [di, setDi] = useState(null); +export function SourceView({ session, revision, onElfLoad }: SourceViewProps) { + const pc = session.pc; + const di = session.program.debugInfo; const [error, setError] = useState(null); const elfInputRef = useRef(null); const sourcesInputRef = useRef(null); const activeRef = useRef(null); - const autoLoadTried = useRef(false); // Picked source files + lazily-read file contents (DWARF path -> lines | null). const [sourceFiles, setSourceFiles] = useState(null); @@ -39,25 +40,23 @@ export function SourceView({ emulator, pc }: SourceViewProps) { } }, [di]); - const handleElfLoad = useCallback(async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) { - return; - } - setError(null); - try { - const loaded = await loadDebugInfoFromFile(file); - if (!loaded.hasLineInfo) { - setError('That ELF has no DWARF line info. Build with -g and load the sidecar ELF.'); + const handleElfLoad = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) { return; } - setDi(loaded); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load ELF'); - } finally { - e.target.value = ''; - } - }, []); + setError(null); + try { + onElfLoad(await readElfFile(file)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load ELF'); + } finally { + e.target.value = ''; + } + }, + [onElfLoad], + ); const handleSourcesLoad = useCallback((e: React.ChangeEvent) => { const list = e.target.files; @@ -74,39 +73,19 @@ export function SourceView({ emulator, pc }: SourceViewProps) { e.target.value = ''; }, []); - // Auto-load the sidecar ELF the dev server serves (zero-click), if one is - // configured and nothing is loaded yet. Falls back to the manual picker. - useEffect(() => { - if (di || autoLoadTried.current || !window.__GBAKIT_CONFIG__?.hasElf) { - return; - } - autoLoadTried.current = true; - let cancelled = false; - void loadDebugInfoFromServer() - .then((loaded) => { - if (cancelled) { - return; - } - if (!loaded.hasLineInfo) { - setError('The server ELF has no DWARF line info. Build with -g.'); - return; - } - setDi(loaded); - }) - .catch((err) => { - if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to auto-load ELF'); - } - }); - return () => { - cancelled = true; - }; - }, [di]); - const fn = useMemo(() => di?.pcToFunction(pc) ?? null, [di, pc]); const rows: SourceRow[] = useMemo( - () => (di && fn ? buildSourceRows((a, c) => emulator.disassembleAt(a, c), di, fn.address, fn.end) : []), - [di, fn, emulator], + () => + di && fn + ? buildSourceRows( + (a, c) => session.disassemble(a, c).map((l) => ({ address: l.address, mnemonic: l.text })), + di, + fn.address, + fn.end, + ) + : [], + // `revision` re-disassembles at each stop, resume and label edit: the rows carry the label names + [di, fn, session, revision], ); const current = useMemo(() => (di ? di.lines.pcToSource(pc) : null), [di, pc]); @@ -165,7 +144,7 @@ export function SourceView({ emulator, pc }: SourceViewProps) { /> ); - if (!di) { + if (!di || !di.hasLineInfo) { return (
@@ -174,7 +153,8 @@ export function SourceView({ emulator, pc }: SourceViewProps) { klonoa-eod.elf) to follow execution in source.

- The ELF carries DWARF; the shipped .gba doesn't. Its loadable bytes match the ROM. + The ELF carries DWARF; the shipped .gba doesn't. Its loadable bytes match the ROM. Start + the dev server with --elf to load it automatically.

, + ); + expect(button).toContain('aria-label="Mute sound"'); + expect(button).toContain('aria-pressed="true"'); + expect(renderToString()).not.toContain('aria-pressed'); + const tabs = renderToString( + {}} + />, + ); + expect(tabs).toContain(`id="${tabIds('gk', 'trace').tab}"`); + expect(tabs).toContain(`aria-controls="${tabIds('gk', 'trace').panel}"`); + expect(tabs).toContain('aria-selected="true"'); + expect(tabs).toContain('tabindex="-1"'); // only the active tab is in the tab order; arrows move between them + }); +}); diff --git a/packages/debug-ui/src/audio.ts b/packages/debug-ui/src/audio.ts new file mode 100644 index 0000000..14da85f --- /dev/null +++ b/packages/debug-ui/src/audio.ts @@ -0,0 +1,161 @@ +/** + * Plays the interleaved stereo samples the emulator produces. An `AudioWorklet` + * fed from a queue of sample chunks, created on the first user gesture (browsers + * require one); a `ScriptProcessorNode` when worklets are unavailable (a strict + * CSP). The queue holds about 100 ms: enough to ride out a slow frame, little + * enough that a button press is heard promptly. + */ +const WORKLET_SOURCE = ` +class GbaKitPlayer extends AudioWorkletProcessor { + constructor() { + super(); + this.queue = []; + this.offset = 0; + this.port.onmessage = (e) => { + if (e.data === 'flush') { this.queue = []; this.offset = 0; return; } + this.queue.push(e.data); + // keep at most ~100 ms queued: drop the oldest when the host outruns us + let total = 0; + for (const q of this.queue) total += q.length / 2; + while (total > sampleRate / 10 && this.queue.length > 1) total -= this.queue.shift().length / 2; + }; + } + process(_inputs, outputs) { + const left = outputs[0][0]; + const right = outputs[0][1] ?? left; + for (let i = 0; i < left.length; i++) { + const chunk = this.queue[0]; + if (!chunk) { left[i] = 0; right[i] = 0; continue; } + left[i] = chunk[this.offset]; + right[i] = chunk[this.offset + 1]; + this.offset += 2; + if (this.offset >= chunk.length) { this.queue.shift(); this.offset = 0; } + } + return true; + } +} +registerProcessor('gba-kit-player', GbaKitPlayer); +`; + +export class AudioPlayer { + #context: AudioContext | null = null; + #worklet: AudioWorkletNode | null = null; + #fallback: ScriptProcessorNode | null = null; + #queue: Float32Array[] = []; + #offset = 0; + /** resolves once the graph exists; the same promise for every `start` until `close` */ + #ready: Promise | null = null; + /** bumped by `close`, so a graph still being built for a closed context is abandoned */ + #generation = 0; + #muted = false; + + get enabled(): boolean { + return this.#context !== null && !this.#muted; + } + + /** + * Create the audio graph (call from a user gesture), or resume it after `mute`. + * Resolves once samples pushed will be heard; concurrent calls share the build. + */ + start(sampleRate: number): Promise { + this.#muted = false; + const generation = this.#generation; + this.#ready ??= this.#build(sampleRate, generation); + return this.#ready.then(() => { + // resume undoes mute()'s suspend; after a close() there is nothing to resume + if (this.#generation === generation && this.#context && !this.#muted) { + return this.#context.resume(); + } + }); + } + + async #build(sampleRate: number, generation: number): Promise { + const context = new AudioContext({ sampleRate }); + this.#context = context; + const abandoned = (): boolean => this.#generation !== generation; + try { + const url = URL.createObjectURL(new Blob([WORKLET_SOURCE], { type: 'application/javascript' })); + try { + await context.audioWorklet.addModule(url); + } finally { + URL.revokeObjectURL(url); + } + if (abandoned()) { + return; + } + const node = new AudioWorkletNode(context, 'gba-kit-player', { outputChannelCount: [2] }); + node.connect(context.destination); + this.#worklet = node; + } catch (err) { + if (abandoned()) { + return; + } + // the fallback plays, with more latency: say so, since a CSP that refuses the worklet is the usual cause + console.warn('gba-kit: AudioWorklet unavailable, playing through a ScriptProcessorNode', err); + const node = context.createScriptProcessor(2048, 0, 2); + node.onaudioprocess = (e) => this.#fill(e.outputBuffer.getChannelData(0), e.outputBuffer.getChannelData(1)); + node.connect(context.destination); + this.#fallback = node; + } + } + + /** Silence now: what is queued is dropped, so nothing stale plays on the next `start`. */ + mute(): void { + this.#muted = true; + this.#queue = []; + this.#offset = 0; + this.#worklet?.port.postMessage('flush'); + void this.#context?.suspend().catch(() => {}); + } + + push(samples: Float32Array): void { + if (!this.#context || this.#muted) { + return; + } + if (this.#worklet) { + const copy = samples.slice(); + this.#worklet.port.postMessage(copy, [copy.buffer]); + } else if (this.#fallback) { + this.#queue.push(samples.slice()); + let total = 0; + for (const q of this.#queue) { + total += q.length / 2; + } + while (total > this.#context.sampleRate / 10 && this.#queue.length > 1) { + total -= this.#queue.shift()!.length / 2; + } + } + } + + #fill(left: Float32Array, right: Float32Array): void { + for (let i = 0; i < left.length; i++) { + const chunk = this.#queue[0]; + if (!chunk) { + left[i] = 0; + right[i] = 0; + continue; + } + left[i] = chunk[this.#offset]!; + right[i] = chunk[this.#offset + 1]!; + this.#offset += 2; + if (this.#offset >= chunk.length) { + this.#queue.shift(); + this.#offset = 0; + } + } + } + + /** Tear the graph down; a build still in flight for it stops short of touching the closed context. */ + close(): void { + this.#generation++; + this.#worklet?.disconnect(); + this.#fallback?.disconnect(); + void this.#context?.close().catch(() => {}); + this.#context = null; + this.#worklet = null; + this.#fallback = null; + this.#ready = null; + this.#queue = []; + this.#offset = 0; + } +} diff --git a/packages/debug-ui/src/components.tsx b/packages/debug-ui/src/components.tsx new file mode 100644 index 0000000..f85797b --- /dev/null +++ b/packages/debug-ui/src/components.tsx @@ -0,0 +1,362 @@ +/** Small building blocks the panels share. */ +import type { TimeStamp } from '@gba-kit/debug-core'; +import { type ReactNode, useMemo, useRef } from 'react'; + +import { usePixels } from './hooks.js'; +import { base64ToBytes } from './render.js'; + +export function Panel({ + title, + right, + children, + pad = false, + className, +}: { + title: string; + right?: ReactNode; + children: ReactNode; + pad?: boolean; + className?: string; +}) { + return ( +
+
+ {title} + {right} +
+
{children}
+
+ ); +} + +/** + * The icons are VS Code's own, named as the editor names them, so a panel shown in + * an editor uses the same glyph for the same idea as the rest of the editor does. + * The set is closed: an icon this package has no name for is one to add here. + */ +export type IconName = + | 'add' + | 'chevron-down' + | 'chevron-right' + | 'debug-continue' + | 'debug-pause' + | 'debug-restart' + | 'debug-step-back' + | 'debug-step-over' + | 'debug-stop' + | 'edit' + | 'go-to-file' + | 'mute' + | 'play' + | 'record' + | 'refresh' + | 'trash' + | 'unmute'; + +/** An icon, hidden from screen readers: whatever carries it says what it does in words. */ +export function Icon({ name }: { name: IconName }) { + return