diff --git a/extension/notes/PLAN.md b/extension/notes/PLAN.md
index 0a27d8a9..d554629e 100644
--- a/extension/notes/PLAN.md
+++ b/extension/notes/PLAN.md
@@ -1643,7 +1643,12 @@ debugger shouldn't become a third.
`step`, `pc`, ...), and it gains `back [n]` and `seek `. Scripted
tests of rewind run here, headless, fast.
- **VS Code**: `DebugController` runs in the emuworker, next to the
- emulator, and is exposed over the existing RPC. The Debug Adapter (next
+ emulator, and is exposed over the existing RPC. *(Done: the worker hosts a
+ `DebugService` and answers `debug(method, ...)`; its frame loop runs
+ through the controller, and every stop is a `stopped` event. The adapter
+ is `src/tools/dapsession.ts`, shared with `8bws dap`;
+ `extension/src/debugbackend.ts` is its RPC backend. Not yet wired into
+ `extension.ts`.)* The Debug Adapter (next
section) runs in the extension host and translates DAP to RPC. DAP has
reverse debugging built in: report `supportsStepBack`, and handle
`stepBack` and `reverseContinue`. Scrubbing (`seekFrame`) is a timeline
diff --git a/extension/src/debugbackend.ts b/extension/src/debugbackend.ts
new file mode 100644
index 00000000..63e3e2de
--- /dev/null
+++ b/extension/src/debugbackend.ts
@@ -0,0 +1,57 @@
+// debugbackend - the debug adapter's view of the emulator worker. The
+// adapter (src/tools/dapsession.ts, shared with `8bws dap`) runs in the
+// extension host; each call goes to the worker's DebugService over the RPC,
+// and the worker's 'stopped' events come back through handleStop().
+
+import type { StopEvent } from '../../src/common/debugcontroller';
+import type { DebugBackend, LaunchArgs, LaunchResult } from '../../src/tools/dapsession';
+import type { DebugService } from '../../src/tools/debugservice';
+
+/** Calls the running emulator worker's `debug` RPC. */
+export type DebugCall = (method: string, ...args: any[]) => Promise;
+
+export class WorkerDebugBackend implements DebugBackend {
+ private stopListeners: ((e: StopEvent) => void)[] = [];
+ private outputListeners: ((text: string) => void)[] = [];
+
+ /**
+ * `launcher` builds the program and starts the emulator stopped (see
+ * emuworker's LoadOptions.paused), then hands over its build (setBuild).
+ */
+ constructor(
+ private call: DebugCall,
+ private launcher: (args: LaunchArgs) => Promise<{ root: string }>,
+ private stopper: () => Promise) { }
+
+ onStop(fn: (e: StopEvent) => void) { this.stopListeners.push(fn); }
+ onOutput(fn: (text: string) => void) { this.outputListeners.push(fn); }
+
+ /** A 'stopped' event from the worker. */
+ handleStop(e: StopEvent) { this.stopListeners.forEach(fn => fn(e)); }
+
+ /** Something to show in the Debug Console. */
+ output(text: string) { this.outputListeners.forEach(fn => fn(text)); }
+
+ async launch(args: LaunchArgs): Promise {
+ const { root } = await this.launcher(args);
+ if (args.script) {
+ const r = await this.call('evaluate', args.script, 'repl');
+ if (r.result) this.output(r.result + '\n');
+ }
+ return { capabilities: await this.call('capabilities'), root };
+ }
+
+ terminate() { return this.stopper(); }
+
+ setBreakpoints(...a: Parameters) { return this.call('setBreakpoints', ...a); }
+ continue() { return this.call('continue'); }
+ step(...a: Parameters) { return this.call('step', ...a); }
+ pause() { return this.call('pause'); }
+ stepBack(...a: Parameters) { return this.call('stepBack', ...a); }
+ reverseContinue() { return this.call('reverseContinue'); }
+ location() { return this.call('location'); }
+ registers() { return this.call('registers'); }
+ readMemory(...a: Parameters) { return this.call('readMemory', ...a); }
+ disassemble(...a: Parameters) { return this.call('disassemble', ...a); }
+ evaluate(...a: Parameters) { return this.call('evaluate', ...a); }
+}
diff --git a/extension/src/emuworker.ts b/extension/src/emuworker.ts
index b81ffa93..31ccd545 100644
--- a/extension/src/emuworker.ts
+++ b/extension/src/emuworker.ts
@@ -12,6 +12,8 @@ import { ControlHint, PLATFORM_CONTROLS } from '../../src/common/controls';
import { AudioStream, setAudioStreamFactory } from '../../src/common/audio';
import { getRootBasePlatform } from '../../src/common/util';
import type { FileData } from '../../src/common/workertypes';
+import type { StopEvent } from '../../src/common/debugcontroller';
+import { BuildInfo, DebugService, TimelineInfo } from '../../src/tools/debugservice';
/** Audio is resampled to this rate in the worker; the webview plays it back. */
const STREAM_RATE = 48000;
@@ -28,6 +30,8 @@ export interface FrameEvent {
height: number;
rotate?: number;
aspect?: number;
+ /** the recorded range and where this frame is in it */
+ timeline?: TimelineInfo;
}
export interface EmuStatus {
@@ -39,8 +43,23 @@ export interface EmuStatus {
controls?: ControlHint[];
/** the platform's audio output rate, if it makes sound */
audio?: { sampleRate: number };
+ timeline?: TimelineInfo;
}
+/** How the host wants a program loaded. */
+export interface LoadOptions {
+ /** load it stopped, as a debug session does until it is configured */
+ paused?: boolean;
+}
+
+/** DebugService methods the host may call through `debug`. */
+const DEBUG_METHODS = new Set([
+ 'capabilities', 'setBuild', 'setBreakpoints', 'continue', 'step', 'pause', 'stepBack', 'reverseContinue',
+ 'seekFrame', 'location', 'timeline', 'registers', 'readMemory', 'disassemble', 'evaluate',
+]);
+// the ones that start the machine running toward a goal
+const FORWARD_METHODS = new Set(['continue', 'step']);
+
// frames to run at once when catching up, before giving up and resyncing
const MAX_CATCHUP_FRAMES = 4;
@@ -49,6 +68,8 @@ installNodeMocks(workerData.rootDir);
setHaltHandler(err => halt(err?.message || 'Program halted'));
let target: EmuTarget | null = null;
+let service: DebugService | null = null;
+/** frames are running (toward the service's goal) */
let running = false;
/** the panel is hidden: don't run frames, but keep `running` for when it shows */
let hidden = false;
@@ -78,7 +99,7 @@ class RpcAudioStream implements AudioStream {
const rpc: Rpc = new Rpc(parentPort, {
/** `files` are the project files the program reads at load time (BuildOutcome.files) */
- async start(platform: string, rom: any, files?: { [path: string]: FileData }) {
+ async start(platform: string, rom: any, files?: { [path: string]: FileData }, opts: LoadOptions = {}) {
// platform modules keep global state (Javatari deletes its own start()),
// so a worker runs one emulator; the host starts a new worker per run
if (started) throw new Error('This emulator worker already ran a platform; start a new worker.');
@@ -87,22 +108,31 @@ const rpc: Rpc = new Rpc(parentPort, {
clearLastKeycodeMap();
target = await loadPlatform(platform);
await target.start();
+ service = new DebugService(target, stopped);
// machines build their keyboard handler as they start
controls = PLATFORM_CONTROLS[getRootBasePlatform(platform)] || describeControls(getLastKeycodeMap());
target.setFileData(files || {});
await target.loadROM(rom);
- resume();
+ if (!opts.paused) resume();
rpc.emit('audioReset', null);
return status();
},
- async loadROM(rom: any, files?: { [path: string]: FileData }) {
+ async loadROM(rom: any, files?: { [path: string]: FileData }, opts: LoadOptions = {}) {
if (!target) throw new Error('emulator not started');
target.setFileData(files || {});
await target.loadROM(rom);
- resume();
+ if (!opts.paused) resume();
rpc.emit('audioReset', null);
return status();
},
+ /** Call a DebugService method; stops come back as 'stopped' events. */
+ debug(method: string, ...args: any[]) {
+ if (!service) throw new Error('emulator not started');
+ if (!DEBUG_METHODS.has(method)) throw new Error(`no debug method '${method}'`);
+ const result = (service as any)[method](...args);
+ if (FORWARD_METHODS.has(method)) startRunning();
+ return result;
+ },
reset() {
if (!target) return null;
target.reset();
@@ -115,6 +145,12 @@ const rpc: Rpc = new Rpc(parentPort, {
rpc.emit('audioReset', null);
return status();
},
+ /** Show a recorded frame, stopped there. */
+ seekFrame(frame: number) {
+ if (!service) return null;
+ service.seekFrame(frame);
+ return status();
+ },
resume() {
resume();
rpc.emit('audioReset', null);
@@ -151,20 +187,42 @@ function status(): EmuStatus | null {
return {
state: running ? 'running' : 'paused', platform: target.id, frame: target.frameCount,
controls, audio: audio ? { sampleRate: audio.sampleRate } : undefined,
+ timeline: service?.timeline() ?? undefined,
};
}
+/** Run on from here, until a breakpoint or a pause. */
function resume() {
- if (!target || running) return;
+ if (!service || running) return;
+ service.continue();
+ startRunning();
+}
+
+/** Run frames toward whatever goal the service has. */
+function startRunning() {
+ if (running) return;
running = true;
schedule();
rpc.emit('status', status());
}
+/** Stop between frames. The stop comes back through stopped(). */
function pause() {
+ if (service) service.pause();
+ else { running = false; stopTimer(); }
+}
+
+/** Every stop: a breakpoint, a finished step, a pause, a halt, a seek. */
+function stopped(e: StopEvent) {
running = false;
stopTimer();
- rpc.emit('status', status());
+ sendFrame();
+ rpc.emit('stopped', e);
+ if (e.reason === 'halt' || e.reason === 'exception') {
+ rpc.emit('status', { ...status(), state: 'halted', message: e.message });
+ } else {
+ rpc.emit('status', status());
+ }
}
/** Start ticking from now, if running, shown, and not already ticking. */
@@ -180,35 +238,42 @@ function stopTimer() {
}
function stop() {
- pause();
- target = null;
-}
-
-function halt(message: string) {
- if (!running || !target) return;
running = false;
stopTimer();
- sendFrame();
- rpc.emit('status', { ...status(), state: 'halted', message });
+ rpc.emit('status', status());
+ target = null;
+ service = null;
+}
+
+/**
+ * The program ended (the platform's halt handler) or the emulator failed.
+ * Both stop the machine where it is, like any other stop.
+ */
+function halt(message: string, reason: 'halt' | 'exception' = 'halt') {
+ if (!running || !target || !service) return;
+ service.debug.pause();
+ const { at, pc } = service.location();
+ stopped({ reason, at, pc, message });
}
function tick() {
timer = null;
- if (!running || hidden || !target) return;
+ if (!running || hidden || !target || !service) return;
var interval = 1000 / target.frameRate;
var now = performance.now();
var frames = 0;
try {
+ // a stop (breakpoint, step done, halt) comes back through stopped()
while (running && nextTime <= now && frames < MAX_CATCHUP_FRAMES) {
- target.advanceFrame();
+ service.advance(1);
nextTime += interval;
frames++;
}
} catch (e) {
- halt(String(e && e.message || e));
+ halt(String(e && e.message || e), 'exception');
return;
}
- if (!running) return; // halted during the frame
+ if (!running) return; // stopped during the frame
if (now - nextTime > interval * MAX_CATCHUP_FRAMES) nextTime = now; // too far behind
if (frames) sendFrame();
timer = setTimeout(tick, Math.max(0, nextTime - performance.now()));
@@ -219,6 +284,9 @@ function sendFrame() {
if (!video) return;
// the platform keeps drawing into its buffer, so send a copy
var pixels = video.pixels.slice().buffer;
- var frame: FrameEvent = { pixels, width: video.width, height: video.height, rotate: video.rotate, aspect: video.aspect };
+ var frame: FrameEvent = {
+ pixels, width: video.width, height: video.height, rotate: video.rotate, aspect: video.aspect,
+ timeline: service?.timeline() ?? undefined,
+ };
rpc.emit('frame', frame, [pixels]);
}
diff --git a/extension/test/emuworker.test.ts b/extension/test/emuworker.test.ts
index 5aa94c45..adea5730 100644
--- a/extension/test/emuworker.test.ts
+++ b/extension/test/emuworker.test.ts
@@ -183,4 +183,63 @@ describe('extension emuworker', function () {
await rpc.call('status');
});
});
+
+ describe('debugging', function () {
+ var built: Awaited>;
+ var stops: any[];
+
+ before(async function () {
+ var mainText = fs.readFileSync(path.join(ROOT, 'presets/mw8080bw/game2.c'), 'utf-8');
+ var read = async (rel: string) => rel === 'game2.c' ? new TextEncoder().encode(mainText) : null;
+ built = await new Builder(ROOT).build({
+ platform: 'mw8080bw', mainPath: 'game2.c', mainText,
+ files: new ProjectFileProvider(read, ROOT, 'mw8080bw'),
+ });
+ assert.deepEqual(built.diagnostics, []);
+ });
+
+ beforeEach(function () {
+ stops = [];
+ rpc.on('stopped', e => stops.push(e));
+ });
+
+ /** The next stop not yet seen; it may have come before the call's reply. */
+ async function nextStop() {
+ for (var i = 0; i < 300 && !stops.length; i++) await new Promise(r => setTimeout(r, 20));
+ assert.ok(stops.length, 'never stopped');
+ return stops.shift();
+ }
+
+ const debug = (method: string, ...args: any[]) => rpc.call('debug', method, ...args);
+
+ it('starts stopped, then stops at a source breakpoint, steps, and steps back', async function () {
+ var s = await rpc.call('start', 'mw8080bw', built.output, built.files, { paused: true });
+ assert.equal(s.state, 'paused');
+ await debug('setBuild', { listings: built.listings, symbols: built.symbolmap, mainPath: 'game2.c', paths: built.paths });
+ var [bp] = await debug('setBreakpoints', [{ id: 1, type: 'source', file: 'game2.c', line: 166, enabled: true }]);
+ assert.ok(bp.verified, bp.message);
+ await debug('continue');
+ assert.equal((await nextStop()).reason, 'breakpoint');
+ assert.equal((await debug('location')).source.line, 166);
+ await debug('step', 'over', 'line');
+ assert.equal((await nextStop()).reason, 'step');
+ assert.equal((await debug('location')).source.line, 167);
+ await debug('stepBack', 'line');
+ assert.equal((await nextStop()).reason, 'step');
+ assert.equal((await debug('location')).source.line, 166);
+ assert.equal(statuses[statuses.length - 1].state, 'paused');
+ });
+
+ it('pauses with a stop, and seeks to a recorded frame', async function () {
+ await rpc.call('start', 'mw8080bw', built.output, built.files);
+ await waitForFrames(10);
+ await rpc.call('pause');
+ assert.equal((await nextStop()).reason, 'pause');
+ var s = await rpc.call('seekFrame', 3);
+ assert.equal((await nextStop()).reason, 'goto');
+ assert.equal(s.timeline.now.frame, 3);
+ assert.ok(s.timeline.past);
+ assert.ok(s.timeline.last >= 10);
+ });
+ });
});
diff --git a/package-lock.json b/package-lock.json
index f25c2285..2be571c6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,8 @@
"@lezer/lr": "^1.4.8",
"@types/dompurify": "^3.0.5",
"@types/emscripten": "^1.39.5",
+ "@vscode/debugadapter": "^1.68.0",
+ "@vscode/debugprotocol": "^1.68.0",
"atob": "^2.1.x",
"binaryen": "^101.0.0",
"btoa": "^1.2.x",
@@ -1758,6 +1760,24 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@vscode/debugadapter": {
+ "version": "1.68.0",
+ "resolved": "https://registry.npmjs.org/@vscode/debugadapter/-/debugadapter-1.68.0.tgz",
+ "integrity": "sha512-D6gk5Fw2y4FV8oYmltoXpj+VAZexxJFopN/mcZ6YcgzQE9dgq2L45Aj3GLxScJOD6GeLILcxJIaA8l3v11esGg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vscode/debugprotocol": "1.68.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@vscode/debugprotocol": {
+ "version": "1.68.0",
+ "resolved": "https://registry.npmjs.org/@vscode/debugprotocol/-/debugprotocol-1.68.0.tgz",
+ "integrity": "sha512-2J27dysaXmvnfuhFGhfeuxfHRXunqNPxtBoR3koiTOA9rdxWNDTa1zIFLCFMSHJ9MPTPKFcBeblsyaCJCIlQxg==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
diff --git a/package.json b/package.json
index 8e71affc..ce79f58d 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,8 @@
"@lezer/lr": "^1.4.8",
"@types/dompurify": "^3.0.5",
"@types/emscripten": "^1.39.5",
+ "@vscode/debugadapter": "^1.68.0",
+ "@vscode/debugprotocol": "^1.68.0",
"atob": "^2.1.x",
"binaryen": "^101.0.0",
"btoa": "^1.2.x",
diff --git a/src/common/breakcond.ts b/src/common/breakcond.ts
index 90b58469..43b8d0fe 100644
--- a/src/common/breakcond.ts
+++ b/src/common/breakcond.ts
@@ -28,7 +28,7 @@ export interface CondContext {
}
export type CondFn = (c: any) => boolean;
-type NumFn = (c: any) => number;
+export type NumFn = (c: any) => number;
interface Token {
t: 'num' | 'id' | 'op';
@@ -314,8 +314,7 @@ function compileNode(n: Node, ctx: CondContext): NumFn {
// The returned function never throws: if evaluation fails (e.g. unreadable
// memory) it returns false so the emulator loop is never interrupted.
export function compileCondition(src: string, ctx: CondContext): CondFn {
- let ast = new Parser(tokenize(src)).parse();
- let fn = compileNode(ast, ctx);
+ let fn = compileExpression(src, ctx);
return (c) => {
try {
return fn(c) != 0;
@@ -325,6 +324,12 @@ export function compileCondition(src: string, ctx: CondContext): CondFn {
};
}
+// Compile an expression to its value, for watches and hovers. Unlike a
+// condition, evaluating it throws when it fails, so the caller can say why.
+export function compileExpression(src: string, ctx: CondContext): NumFn {
+ return compileNode(new Parser(tokenize(src)).parse(), ctx);
+}
+
// Parse a breakpoint target: '$hex', '0xhex', decimal, or a symbol name.
export function parseTarget(target: string, symbol: (name: string) => number | undefined): { pc?: number, error?: string } {
let s = (target || '').trim();
diff --git a/src/common/breakpoints.ts b/src/common/breakpoints.ts
index 846be0c1..1855eddc 100644
--- a/src/common/breakpoints.ts
+++ b/src/common/breakpoints.ts
@@ -37,7 +37,8 @@ export interface BreakpointContext {
platform?: Platform;
}
-function makeCondContext(ctx: BreakpointContext): CondContext {
+/** What an expression can read: registers, symbols, memory, hardware. */
+export function makeCondContext(ctx: BreakpointContext): CondContext {
let platform = ctx.platform;
let cpuFields = new Set();
try {
diff --git a/src/common/debugcontroller.ts b/src/common/debugcontroller.ts
index 6eb7e4f4..6aa76d30 100644
--- a/src/common/debugcontroller.ts
+++ b/src/common/debugcontroller.ts
@@ -64,6 +64,8 @@ export function isReturnInsn(line: string): boolean { return RETURN_INSN.test(li
interface Goal {
reason: StopReason;
test(pc: number | null, first: boolean): boolean;
+ /** never stops by itself: with no breakpoints, frames can run untrapped */
+ free?: boolean;
/** the next test() is at the starting position */
atStart: boolean;
}
@@ -107,6 +109,7 @@ export class DebugController {
continue() {
this.setGoal('breakpoint', () => false);
+ this.goal.free = true;
}
/** Run until the PC reaches `addr`, which may be where it already is. */
@@ -119,6 +122,21 @@ export class DebugController {
this.setGoal('step', (_pc, first) => !first);
}
+ /**
+ * Step to the next source line, into any call on this one, or one
+ * instruction. Without source for the current PC, steps one instruction.
+ */
+ stepIn(granularity: StepGranularity = 'line') {
+ this.requireStep();
+ const line = granularity === 'line' ? this.sourceAt(this.core.getPC()) : null;
+ this.setGoal('step', (pc, first) => {
+ if (first) return false;
+ if (!line) return true;
+ const here = this.sourceAt(pc);
+ return !!here && !sameLine(here, line);
+ });
+ }
+
/**
* Step to the next source line, or instruction, running calls to their
* return. Without source for the current PC, steps one instruction.
@@ -170,6 +188,7 @@ export class DebugController {
run(maxFrames = DEFAULT_MAX_FRAMES): StopEvent | null {
const g = this.goal;
if (!g) return null;
+ if (g.free && this.byPC.size === 0) return this.runFree(maxFrames);
let stop: StopEvent | null = null;
const pred = () => {
const first = g.atStart;
@@ -182,15 +201,30 @@ export class DebugController {
if (g.test(pc, first)) { stop = this.stopEvent(g.reason); return true; }
return false;
};
- try {
+ return this.stopOnHalt(() => {
this.core.runUntil(pred, maxFrames);
+ if (stop) this.goal = null;
+ return stop;
+ });
+ }
+
+ /** Whole frames with no trap, which is as fast as the emulator runs. */
+ private runFree(maxFrames: number): StopEvent | null {
+ return this.stopOnHalt(() => {
+ for (let i = 0; i < maxFrames; i++) this.core.advanceFrame();
+ return null;
+ });
+ }
+
+ /** A halt (KIL, a watchdog, a program's exit) is a stop, not an error. */
+ private stopOnHalt(body: () => StopEvent | null): StopEvent | null {
+ try {
+ return body();
} catch (e) {
if (!(e instanceof EmuHalt)) throw e;
this.goal = null;
return this.stopEvent(e.normal ? 'halt' : 'exception', { message: e.message });
}
- if (stop) this.goal = null;
- return stop;
}
/** run() until it stops, for hosts that can wait: null if it ran out of frames. */
@@ -346,7 +380,8 @@ export function buildDebugContext(build: {
paths?: string[],
}): DebugContext {
const listings = build.listings || {};
- if (Object.values(listings).some(l => l.lines && !l.sourcefile)) processListings(listings);
+ // listings that came over an RPC have lost their SourceFile methods
+ if (Object.values(listings).some(l => l.lines && !(l.sourcefile instanceof SourceFile))) processListings(listings);
const paths = build.paths && build.paths.length ? build.paths : [build.mainPath];
const byName = (name: string): string => {
const want = getFilenamePrefix(getFilenameForPath(stripLocalPath(name, build.mainPath)));
diff --git a/src/tools/8bws.ts b/src/tools/8bws.ts
index 235b857f..f5d0aa8b 100644
--- a/src/tools/8bws.ts
+++ b/src/tools/8bws.ts
@@ -14,11 +14,12 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { isProbablyBinary } from '../common/util';
-import { fail, hasOutput, note, output, setJsonMode } from './cliformat';
+import { fail, hasOutput, note, output, setJsonMode, setServerMode } from './cliformat';
import { EmuTarget, loadPlatform } from './emutarget';
import { RUN_SCRIPT_HELP, RunScript, parseNum } from './runscript';
import { verifyReplay } from './verifyreplay';
import { buildDebugContext } from '../common/debugcontroller';
+import type { BuildInfo } from './debugservice';
import { parseSymbolFile } from '../common/symbols/symbolfile';
import { romBytes, type CompileResult } from './testlib';
import { ROM_PLATFORMS } from '../common/detect';
@@ -226,17 +227,30 @@ function buildScript(args: Args): string {
return parts.length ? parts.join('\n') : 'run 1';
}
-async function doRun(args: Args, positional: string[]): Promise {
- let input = positional[0];
+/** A program loaded into an emulator, from a ROM or built from source. */
+interface Program {
+ target: EmuTarget;
+ /** the ROM or main source file (a folder resolves to its main file) */
+ source: string;
+ /** the ROM file, or null when loaded straight from the build */
+ romFile: string | null;
+ built?: Awaited>;
+ symbols: { [name: string]: number };
+ /** the build's listings and symbols, for the debugger */
+ debugInfo?: BuildInfo;
+}
+
+/** Build `input` if it's source, and load it into a new emulator. */
+async function openProgram(command: string, args: Args, input: string): Promise {
if (!input) {
- fail('run', 'Required: run --platform ');
+ fail(command, `Required: ${command} --platform `);
}
- if (!fs.existsSync(input)) fail('run', `No such file: ${input}`);
+ if (!fs.existsSync(input)) fail(command, `No such file: ${input}`);
const platformArg = str(args, 'platform');
let resolvedPlatform: string | undefined;
if (fs.statSync(input).isDirectory()) {
- const resolved = await resolveDirectory('run', input, platformArg);
+ const resolved = await resolveDirectory(command, input, platformArg);
input = resolved.source;
resolvedPlatform = resolved.platform;
}
@@ -247,7 +261,7 @@ async function doRun(args: Args, positional: string[]): Promise {
let built: Awaited> | undefined;
let platformId = platformArg || resolvedPlatform || ROM_PLATFORMS[path.extname(input).toLowerCase()];
if (!looksLikeROM(input)) {
- if (!platformId) platformId = await inferPlatform('run', input);
+ if (!platformId) platformId = await inferPlatform(command, input);
built = await compileSource(args, input, platformId);
symbols = built.symbolmap;
if (built.rom) {
@@ -259,7 +273,7 @@ async function doRun(args: Args, positional: string[]): Promise {
note(`built ${input} with ${built.tool}`);
}
} else if (!platformId) {
- fail('run', `Cannot infer a platform from '${path.basename(input)}': pass --platform`);
+ fail(command, `Cannot infer a platform from '${path.basename(input)}': pass --platform`);
}
const target = await openTarget(args, platformId);
@@ -275,13 +289,21 @@ async function doRun(args: Args, positional: string[]): Promise {
} else {
await target.loadROM(built.result.output, path.basename(input));
}
+ let debugInfo: BuildInfo | undefined;
+ if (built) {
+ const mainPath = path.basename(input);
+ debugInfo = { listings: built.result.listings, symbols, mainPath, paths: [mainPath] };
+ }
+ return { target, source: input, romFile, built, symbols, debugInfo };
+}
+
+async function doRun(args: Args, positional: string[]): Promise {
+ const input = positional[0];
+ const { target, romFile, symbols, debugInfo } = await openProgram('run', args, input);
const script = new RunScript(target);
script.addSymbols(symbols);
- if (built) {
- const mainPath = path.basename(input);
- script.setDebugContext(buildDebugContext({ listings: built.result.listings, symbols, mainPath, paths: [mainPath] }));
- }
+ if (debugInfo) script.setDebugContext(buildDebugContext(debugInfo));
const symbolFile = str(args, 'symbols');
if (symbolFile) script.addSymbols(parseSymbolFile(fs.readFileSync(symbolFile, 'utf8')));
script.startTracing();
@@ -436,6 +458,26 @@ async function doDetect(positional: string[]): Promise {
////////////////////////////////////////////////////////////////////////
// determinism
+/**
+ * Serve the Debug Adapter Protocol on stdin/stdout. A launch request names
+ * the program (and platform); flags given here (--tool, --bios, --define)
+ * apply to every launch.
+ */
+async function doDap(args: Args): Promise {
+ setServerMode();
+ const { EmuDebugSession } = await import('./dapsession');
+ const { LocalDebugBackend } = await import('./daplocal');
+ const backend = new LocalDebugBackend(async launch => {
+ const launchArgs: Args = { ...args };
+ if (launch.platform) launchArgs['platform'] = launch.platform;
+ const prog = await openProgram('dap', launchArgs, launch.program || launch.mainFile);
+ return { target: prog.target, debugInfo: prog.debugInfo, root: path.dirname(path.resolve(prog.source)) };
+ });
+ new EmuDebugSession(backend).start(process.stdin, process.stdout);
+ await new Promise(resolve => process.stdin.on('end', resolve));
+ process.exit(0);
+}
+
/** Record a run with random key input, replay it, and check every frame matches. */
async function doVerifyReplay(args: Args, positional: string[]): Promise {
const input = positional[0];
@@ -502,6 +544,7 @@ function usage(error?: string): never {
'build': 'compile a source file or folder to a ROM',
'run': 'run a ROM -- or a source file or folder, built first',
'detect': 'guess the platform and main file of a source file or directory',
+ 'dap': 'serve the Debug Adapter Protocol on stdin/stdout, for editors',
'verify-replay': 'record a run with random key input, replay it, and check every frame matches',
'list-platforms': 'platforms available to --platform',
'list-tools': 'compilers and assemblers available to --tool',
@@ -556,7 +599,8 @@ async function main() {
// A platform whose start() never settles (usually one that needs a
// browser-only library) drains the event loop and would otherwise exit 0.
process.on('exit', () => {
- if (hasOutput()) return;
+ // dap answers over its protocol, not with a result
+ if (hasOutput() || command === 'dap') return;
output({ success: false, command, error: `${command} did not run to completion -- the emulator never finished starting` });
process.exitCode = 1;
});
@@ -566,6 +610,7 @@ async function main() {
case 'run': await doRun(args, positional); break;
case 'detect': await doDetect(positional); break;
case 'verify-replay': await doVerifyReplay(args, positional); break;
+ case 'dap': await doDap(args); break;
case 'list-tools':
case 'list-platforms': await doList(command); break;
case 'help': usage(); break;
diff --git a/src/tools/cliformat.ts b/src/tools/cliformat.ts
index 50e93982..5df73a62 100644
--- a/src/tools/cliformat.ts
+++ b/src/tools/cliformat.ts
@@ -43,6 +43,19 @@ export function setJsonMode(on: boolean) {
}
export function isJsonMode() { return jsonMode; }
+let serving = false;
+
+/**
+ * For `8bws dap`: stdout carries the protocol, so console output goes to
+ * stderr, and fail() throws, so the server can answer with the error
+ * instead of exiting.
+ */
+export function setServerMode() {
+ serving = true;
+ const toStderr = (...args: any[]) => { process.stderr.write(args.join(' ') + '\n'); };
+ console.log = console.info = console.warn = console.debug = toStderr;
+}
+
/** Human-facing output: stdout normally, stderr in --json mode. */
export function write(s: string): void {
(jsonMode ? process.stderr : process.stdout).write(s);
@@ -50,7 +63,7 @@ export function write(s: string): void {
// progress/status chatter -- suppressed in --json mode so stdout stays parseable
export function note(msg: string): void {
- if (!jsonMode) process.stderr.write(`${c.dim}${msg}${c.reset}\n`);
+ if (!jsonMode || serving) process.stderr.write(`${c.dim}${msg}${c.reset}\n`);
}
let emitted = false;
@@ -78,6 +91,10 @@ export function output(result: CLIResult): void {
// exit with a formatted error message
export function fail(command: string, error: string, data?: any): never {
+ if (serving) {
+ const details = (data?.errors || []).map((e: any) => `\n${e.path || ''}${e.line ? ':' + e.line : ''}: ${e.msg}`).join('');
+ throw new Error(`${command}: ${error}${details}`);
+ }
output({ success: false, command, error, data });
process.exit(1);
}
diff --git a/src/tools/daplocal.ts b/src/tools/daplocal.ts
new file mode 100644
index 00000000..bde71b53
--- /dev/null
+++ b/src/tools/daplocal.ts
@@ -0,0 +1,83 @@
+// A DebugBackend that runs the emulator in this process: for `8bws dap`, and
+// for testing the adapter without VS Code. Running has no display to keep
+// pace with, so it goes as fast as it can, a few frames at a time, taking
+// requests (pause) in between.
+
+import type { StopEvent } from '../common/debugcontroller';
+import type { DebugBackend, LaunchArgs, LaunchResult } from './dapsession';
+import { BuildInfo, DebugService } from './debugservice';
+import type { EmuTarget } from './emutarget';
+
+// frames to run between looking for requests
+const FRAMES_PER_SLICE = 4;
+
+export interface LoadedProgram {
+ target: EmuTarget;
+ debugInfo?: BuildInfo;
+ /** the directory project paths are relative to */
+ root: string;
+}
+
+export class LocalDebugBackend implements DebugBackend {
+ private service: DebugService | null = null;
+ private stopListeners: ((e: StopEvent) => void)[] = [];
+ private outputListeners: ((text: string) => void)[] = [];
+ private looping = false;
+
+ constructor(private load: (args: LaunchArgs) => Promise) { }
+
+ onStop(fn: (e: StopEvent) => void) { this.stopListeners.push(fn); }
+ onOutput(fn: (text: string) => void) { this.outputListeners.push(fn); }
+
+ async launch(args: LaunchArgs): Promise {
+ const prog = await this.load(args);
+ const svc = this.service = new DebugService(prog.target, e => this.stopListeners.forEach(fn => fn(e)));
+ if (prog.debugInfo) svc.setBuild(prog.debugInfo);
+ if (args.script) {
+ const r = svc.evaluate(args.script, 'repl');
+ if (r.result) this.outputListeners.forEach(fn => fn(r.result + '\n'));
+ }
+ return { capabilities: svc.capabilities(), root: prog.root };
+ }
+
+ async terminate() {
+ this.service = null;
+ }
+
+ async setBreakpoints(...a: Parameters) { return this.svc.setBreakpoints(...a); }
+ async continue() { this.svc.continue(); this.loop(); }
+ async step(...a: Parameters) { this.svc.step(...a); this.loop(); }
+ async pause() { this.svc.pause(); }
+ async stepBack(...a: Parameters) { this.svc.stepBack(...a); }
+ async reverseContinue() { this.svc.reverseContinue(); }
+ async location() { return this.svc.location(); }
+ async registers() { return this.svc.registers(); }
+ async readMemory(...a: Parameters) { return this.svc.readMemory(...a); }
+ async disassemble(...a: Parameters) { return this.svc.disassemble(...a); }
+ async evaluate(...a: Parameters) { return this.svc.evaluate(...a); }
+
+ private get svc(): DebugService {
+ if (!this.service) throw new Error('no program is loaded');
+ return this.service;
+ }
+
+ /** Work toward the goal a slice at a time until it stops. */
+ private loop() {
+ if (this.looping) return;
+ this.looping = true;
+ const slice = () => {
+ const svc = this.service;
+ if (!svc || !svc.running) { this.looping = false; return; }
+ try {
+ svc.advance(FRAMES_PER_SLICE);
+ } catch (e) {
+ this.looping = false;
+ this.outputListeners.forEach(fn => fn(`emulator error: ${e?.stack || e}\n`));
+ svc.pause();
+ return;
+ }
+ setImmediate(slice);
+ };
+ setImmediate(slice);
+ }
+}
diff --git a/src/tools/dapsession.ts b/src/tools/dapsession.ts
new file mode 100644
index 00000000..6f940c9f
--- /dev/null
+++ b/src/tools/dapsession.ts
@@ -0,0 +1,315 @@
+// The Debug Adapter Protocol side of the debugger: translates DAP requests
+// into DebugService calls. The service may be in this process (`8bws dap`,
+// tests) or behind an RPC (the VS Code extension's emulator worker); either
+// way it is reached through a DebugBackend, whose calls are all async.
+//
+// The machine has one thread. Its call stack is one frame: where the PC is.
+
+import {
+ Breakpoint as DapBreakpoint, CapabilitiesEvent, InitializedEvent, LoggingDebugSession, OutputEvent,
+ Scope, Source, StackFrame, StoppedEvent, TerminatedEvent, Thread,
+} from '@vscode/debugadapter';
+import { DebugProtocol } from '@vscode/debugprotocol';
+import * as path from 'path';
+import type { Breakpoint } from '../common/breakpoints';
+import type { StopEvent } from '../common/debugcontroller';
+import { hex } from '../common/util';
+import type { DebugCapabilities, DebugService } from './debugservice';
+
+/** launch.json's arguments for a `8bitworkshop` debug session. */
+export interface LaunchArgs extends DebugProtocol.LaunchRequestArguments {
+ /** the source file or ROM to run */
+ program?: string;
+ /** the same, as the extension's launch configurations call it */
+ mainFile?: string;
+ platform?: string;
+ stopOnEntry?: boolean;
+ /** run-script commands to run once loaded, as with `8bws run -e` */
+ script?: string;
+}
+
+export interface LaunchResult {
+ capabilities: DebugCapabilities;
+ /** the directory the build's project paths are relative to */
+ root: string;
+}
+
+type Async = { [K in keyof T]: T[K] extends (...args: infer A) => infer R ? (...args: A) => Promise : never };
+
+/** A DebugService, wherever it runs, plus loading a program into it. */
+export type DebugBackend = Async> & {
+ /** Build or load the program, and leave it stopped at its start. */
+ launch(args: LaunchArgs): Promise;
+ terminate(): Promise;
+ /** every stop, from any command */
+ onStop(fn: (e: StopEvent) => void): void;
+ onOutput?(fn: (text: string) => void): void;
+ };
+
+const THREAD_ID = 1;
+const REGISTERS_REF = 1;
+
+export class EmuDebugSession extends LoggingDebugSession {
+ private root = '';
+ private stopOnEntry = false;
+ private sourceBps = new Map();
+ private functionBps: Breakpoint[] = [];
+ private instructionBps: Breakpoint[] = [];
+ private nextBpId = 1;
+ private launched: Promise;
+ private resolveLaunched: () => void;
+
+ constructor(private backend: DebugBackend) {
+ super();
+ this.setDebuggerLinesStartAt1(true);
+ this.setDebuggerColumnsStartAt1(true);
+ this.launched = new Promise(resolve => this.resolveLaunched = resolve);
+ backend.onStop(e => this.stopped(e));
+ backend.onOutput?.(text => this.sendEvent(new OutputEvent(text, 'console')));
+ }
+
+ protected initializeRequest(response: DebugProtocol.InitializeResponse): void {
+ response.body = {
+ supportsConfigurationDoneRequest: true,
+ supportsConditionalBreakpoints: true,
+ supportsFunctionBreakpoints: true,
+ supportsInstructionBreakpoints: true,
+ supportsSteppingGranularity: true,
+ supportsReadMemoryRequest: true,
+ supportsDisassembleRequest: true,
+ supportsEvaluateForHovers: true,
+ supportsTerminateRequest: true,
+ // until launch says whether this platform can
+ supportsStepBack: false,
+ };
+ this.sendResponse(response);
+ }
+
+ protected async launchRequest(response: DebugProtocol.LaunchResponse, args: LaunchArgs) {
+ await this.run(response, async () => {
+ const r = await this.backend.launch(args);
+ this.root = r.root;
+ this.stopOnEntry = !!args.stopOnEntry;
+ if (r.capabilities.rewind) this.sendEvent(new CapabilitiesEvent({ supportsStepBack: true }));
+ if (!r.capabilities.step) {
+ this.sendEvent(new OutputEvent(`This platform stops only between frames: stepping and breakpoints are frame by frame.\n`, 'console'));
+ }
+ this.resolveLaunched();
+ // breakpoints come next, then configurationDone
+ this.sendEvent(new InitializedEvent());
+ });
+ }
+
+ protected async configurationDoneRequest(response: DebugProtocol.ConfigurationDoneResponse) {
+ await this.run(response, async () => {
+ await this.launched;
+ if (this.stopOnEntry) this.stopped({ ...(await this.backend.location()), reason: 'entry' });
+ else await this.backend.continue();
+ });
+ }
+
+ //// breakpoints: the backend holds one list, so each request sends them all
+
+ protected async setBreakPointsRequest(response: DebugProtocol.SetBreakpointsResponse, args: DebugProtocol.SetBreakpointsArguments) {
+ await this.run(response, async () => {
+ const file = this.toProjectPath(args.source.path || args.source.name);
+ const bps = (args.breakpoints || []).map(b => this.newBreakpoint({ type: 'source', file, line: b.line, condition: b.condition }));
+ this.sourceBps.set(file, bps);
+ response.body = { breakpoints: await this.sendBreakpoints(bps) };
+ });
+ }
+
+ protected async setFunctionBreakPointsRequest(response: DebugProtocol.SetFunctionBreakpointsResponse, args: DebugProtocol.SetFunctionBreakpointsArguments) {
+ await this.run(response, async () => {
+ this.functionBps = args.breakpoints.map(b => this.newBreakpoint({ type: 'address', target: b.name, condition: b.condition }));
+ response.body = { breakpoints: await this.sendBreakpoints(this.functionBps) };
+ });
+ }
+
+ protected async setInstructionBreakpointsRequest(response: DebugProtocol.SetInstructionBreakpointsResponse, args: DebugProtocol.SetInstructionBreakpointsArguments) {
+ await this.run(response, async () => {
+ this.instructionBps = args.breakpoints.map(b => this.newBreakpoint({
+ type: 'address', target: '$' + hex(parseAddress(b.instructionReference) + (b.offset || 0), 4), condition: b.condition,
+ }));
+ response.body = { breakpoints: await this.sendBreakpoints(this.instructionBps) };
+ });
+ }
+
+ private newBreakpoint(bp: Omit): Breakpoint {
+ return { id: this.nextBpId++, enabled: true, ...bp };
+ }
+
+ /** Send every breakpoint; report on the ones in `mine`. */
+ private async sendBreakpoints(mine: Breakpoint[]): Promise {
+ await this.launched;
+ const all = [...[...this.sourceBps.values()].flat(), ...this.functionBps, ...this.instructionBps];
+ const results = await this.backend.setBreakpoints(all);
+ const byId = new Map(results.map(r => [r.id, r]));
+ return mine.map(bp => {
+ const r = byId.get(bp.id);
+ const out: DebugProtocol.Breakpoint = new DapBreakpoint(!!r?.verified, r?.line ?? bp.line);
+ out.id = bp.id;
+ if (r?.message) out.message = r.message;
+ if (r?.pc != null) out.instructionReference = addressRef(r.pc);
+ return out;
+ });
+ }
+
+ //// running
+
+ protected async continueRequest(response: DebugProtocol.ContinueResponse) {
+ await this.run(response, () => this.backend.continue());
+ }
+
+ protected async nextRequest(response: DebugProtocol.NextResponse, args: DebugProtocol.NextArguments) {
+ await this.run(response, () => this.backend.step('over', granularity(args.granularity)));
+ }
+
+ protected async stepInRequest(response: DebugProtocol.StepInResponse, args: DebugProtocol.StepInArguments) {
+ await this.run(response, () => this.backend.step('in', granularity(args.granularity)));
+ }
+
+ protected async stepOutRequest(response: DebugProtocol.StepOutResponse) {
+ await this.run(response, () => this.backend.step('out'));
+ }
+
+ protected async stepBackRequest(response: DebugProtocol.StepBackResponse, args: DebugProtocol.StepBackArguments) {
+ await this.run(response, () => this.backend.stepBack(granularity(args.granularity)));
+ }
+
+ protected async reverseContinueRequest(response: DebugProtocol.ReverseContinueResponse) {
+ await this.run(response, () => this.backend.reverseContinue());
+ }
+
+ protected async pauseRequest(response: DebugProtocol.PauseResponse) {
+ await this.run(response, () => this.backend.pause());
+ }
+
+ private stopped(e: StopEvent) {
+ const reason = e.reason === 'halt' ? 'pause' : e.reason;
+ const ev = new StoppedEvent(reason, THREAD_ID, e.reason === 'exception' ? e.message : undefined) as DebugProtocol.StoppedEvent;
+ ev.body.allThreadsStopped = true;
+ if (e.reason === 'halt') ev.body.description = `Program ended${e.message ? ': ' + e.message : ''}`;
+ else if (e.message) ev.body.description = e.message;
+ if (e.breakpoints) ev.body.hitBreakpointIds = e.breakpoints;
+ this.sendEvent(ev);
+ }
+
+ //// where it is
+
+ protected threadsRequest(response: DebugProtocol.ThreadsResponse): void {
+ response.body = { threads: [new Thread(THREAD_ID, 'CPU')] };
+ this.sendResponse(response);
+ }
+
+ protected async stackTraceRequest(response: DebugProtocol.StackTraceResponse) {
+ await this.run(response, async () => {
+ const loc = await this.backend.location();
+ const pc = loc.pc ?? 0;
+ const name = loc.symbol ? loc.symbol.name + (loc.symbol.offset ? `+${loc.symbol.offset}` : '') : '$' + hex(pc, 4);
+ const frame: DebugProtocol.StackFrame = loc.source
+ ? new StackFrame(0, name, this.toSource(loc.source.path), loc.source.line, 1)
+ : new StackFrame(0, name);
+ frame.instructionPointerReference = addressRef(pc);
+ response.body = { stackFrames: [frame], totalFrames: 1 };
+ });
+ }
+
+ protected scopesRequest(response: DebugProtocol.ScopesResponse): void {
+ response.body = { scopes: [new Scope('Registers', REGISTERS_REF, false)] };
+ this.sendResponse(response);
+ }
+
+ protected async variablesRequest(response: DebugProtocol.VariablesResponse, args: DebugProtocol.VariablesArguments) {
+ await this.run(response, async () => {
+ const regs = args.variablesReference === REGISTERS_REF ? await this.backend.registers() : [];
+ response.body = {
+ variables: regs.map(r => ({ name: r.name, value: r.text, variablesReference: 0, memoryReference: addressRef(r.value) })),
+ };
+ });
+ }
+
+ protected async readMemoryRequest(response: DebugProtocol.ReadMemoryResponse, args: DebugProtocol.ReadMemoryArguments) {
+ await this.run(response, async () => {
+ const addr = parseAddress(args.memoryReference) + (args.offset || 0);
+ const bytes = await this.backend.readMemory(addr, Math.min(args.count, 0x10000));
+ response.body = { address: addressRef(addr), data: Buffer.from(bytes).toString('base64') };
+ });
+ }
+
+ protected async disassembleRequest(response: DebugProtocol.DisassembleResponse, args: DebugProtocol.DisassembleArguments) {
+ await this.run(response, async () => {
+ const addr = parseAddress(args.memoryReference) + (args.offset || 0);
+ const lines = await this.backend.disassemble(addr, args.instructionOffset || 0, args.instructionCount);
+ response.body = {
+ instructions: lines.map(d => {
+ const insn: DebugProtocol.DisassembledInstruction = {
+ address: addressRef(d.addr), instructionBytes: d.bytes, instruction: d.text,
+ };
+ if (d.symbol) insn.symbol = d.symbol;
+ if (d.source) { insn.location = this.toSource(d.source.path); insn.line = d.source.line; }
+ return insn;
+ }),
+ };
+ });
+ }
+
+ /** The Debug Console runs run-script commands; watches and hovers are expressions. */
+ protected async evaluateRequest(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) {
+ await this.run(response, async () => {
+ const r = await this.backend.evaluate(args.expression, args.context || 'repl');
+ response.body = { result: r.result, variablesReference: 0 };
+ if (r.value != null) response.body.memoryReference = addressRef(r.value);
+ if (r.moved) this.stopped({ ...(await this.backend.location()), reason: 'goto' });
+ });
+ }
+
+ protected async terminateRequest(response: DebugProtocol.TerminateResponse) {
+ await this.run(response, async () => {
+ await this.backend.terminate();
+ this.sendEvent(new TerminatedEvent());
+ });
+ }
+
+ protected async disconnectRequest(response: DebugProtocol.DisconnectResponse) {
+ await this.run(response, () => this.backend.terminate());
+ }
+
+ //// helpers
+
+ /** Answer the request, or report what went wrong with it. */
+ private async run(response: DebugProtocol.Response, body: () => Promise) {
+ try {
+ await body();
+ this.sendResponse(response);
+ } catch (e) {
+ this.sendErrorResponse(response, { id: 1, format: String(e?.message || e), showUser: false });
+ }
+ }
+
+ private toProjectPath(p: string): string {
+ if (!this.root || !path.isAbsolute(p)) return p;
+ return path.relative(this.root, p).split(path.sep).join('/');
+ }
+
+ private toSource(projectPath: string): Source {
+ const abs = this.root ? path.resolve(this.root, projectPath) : projectPath;
+ return new Source(path.basename(projectPath), abs);
+ }
+}
+
+function granularity(g?: DebugProtocol.SteppingGranularity): 'line' | 'instruction' {
+ return g === 'instruction' ? 'instruction' : 'line';
+}
+
+function addressRef(addr: number): string {
+ return '0x' + hex(addr, 4);
+}
+
+function parseAddress(ref: string): number {
+ const n = ref.startsWith('$') ? parseInt(ref.slice(1), 16) : Number(ref);
+ if (isNaN(n)) throw new Error(`not an address: ${ref}`);
+ return n;
+}
diff --git a/src/tools/debugservice.ts b/src/tools/debugservice.ts
new file mode 100644
index 00000000..1d68dbc9
--- /dev/null
+++ b/src/tools/debugservice.ts
@@ -0,0 +1,244 @@
+// The debugger as a service: everything a debug adapter asks of the machine,
+// with plain-data arguments and results so it can sit behind an RPC. The
+// VS Code emulator worker and `8bws dap` each host one.
+//
+// Every stop, whatever caused it, goes out through `onStop`. Forward commands
+// (continue, step) only set a goal; the host's loop calls advance() to work
+// toward it, a few frames at a time, so it can still take a pause request.
+
+import { Breakpoint, makeCondContext } from '../common/breakpoints';
+import { compileExpression } from '../common/breakcond';
+import { buildDebugContext, DebugController, DebugLocation, StepGranularity, StopEvent } from '../common/debugcontroller';
+import { lookupSymbol } from '../common/symbols/symbolfile';
+import { Granularity, Timestamp } from '../common/timeline';
+import { hex } from '../common/util';
+import type { CodeListingMap } from '../common/workertypes';
+import type { EmuTarget } from './emutarget';
+import { RUN_SCRIPT_HELP, RunScript } from './runscript';
+
+export type StepKind = 'in' | 'over' | 'out';
+
+export interface DebugCapabilities {
+ /** can stop between instructions */
+ step: boolean;
+ /** can go backwards */
+ rewind: boolean;
+ /** what a step is: a clock, an instruction, or a whole frame */
+ granularity: Granularity | 'frame';
+}
+
+export interface BreakpointResult {
+ id: number;
+ verified: boolean;
+ pc?: number;
+ /** the source line it ended up on */
+ line?: number;
+ message?: string;
+}
+
+export interface RegisterValue {
+ name: string;
+ value: number;
+ text: string;
+}
+
+export interface DisasmResult {
+ addr: number;
+ bytes: string;
+ text: string;
+ symbol?: string;
+ source?: { path: string, line: number };
+}
+
+export interface EvalResult {
+ result: string;
+ /** a number the result can be shown as memory from */
+ value?: number;
+ /** the command moved the machine, so views need refreshing */
+ moved?: boolean;
+}
+
+export interface TimelineInfo {
+ first: number;
+ last: number;
+ now: Timestamp;
+ past: boolean;
+}
+
+export interface BuildInfo {
+ listings?: CodeListingMap;
+ symbols?: { [name: string]: number };
+ mainPath: string;
+ paths?: string[];
+}
+
+// how far before an address to start disassembling, per instruction wanted
+const MAX_INSN_BYTES = 4;
+
+export class DebugService {
+ readonly debug: DebugController;
+ private script: RunScript;
+ private scriptOut = '';
+
+ constructor(readonly target: EmuTarget, private onStop: (e: StopEvent) => void) {
+ this.debug = new DebugController(target);
+ this.script = new RunScript(target, s => { this.scriptOut += s; }, this.debug);
+ }
+
+ /** A new build: its listings and symbols. */
+ setBuild(build: BuildInfo) {
+ this.script.setDebugContext(buildDebugContext(build));
+ }
+
+ capabilities(): DebugCapabilities {
+ const t = this.target;
+ return {
+ step: t.supportsStep,
+ rewind: t.supportsRewind,
+ granularity: t.history ? t.history.core.granularity : (t.supportsStep ? 'insn' : 'frame'),
+ };
+ }
+
+ setBreakpoints(bps: Breakpoint[]): BreakpointResult[] {
+ return this.debug.setBreakpoints(bps).map(r => {
+ const res: BreakpointResult = { id: r.bp.id, verified: !r.error && r.pc >= 0 };
+ if (r.error) res.message = r.error;
+ if (r.pc >= 0) {
+ res.pc = r.pc;
+ const src = this.debug.sourceAt(r.pc);
+ if (src) res.line = src.line;
+ }
+ return res;
+ });
+ }
+
+ get running(): boolean { return this.debug.running; }
+
+ continue() { this.debug.continue(); }
+
+ step(kind: StepKind, granularity: StepGranularity = 'line') {
+ if (kind === 'in') this.debug.stepIn(granularity);
+ else if (kind === 'over') this.debug.stepOver(granularity);
+ else this.debug.stepOut();
+ }
+
+ pause() { this.onStop(this.debug.pause()); }
+
+ /** Work toward the goal for up to `frames` frames. */
+ advance(frames: number): StopEvent | null {
+ const stop = this.debug.run(frames);
+ if (stop) this.onStop(stop);
+ return stop;
+ }
+
+ stepBack(granularity: StepGranularity = 'line') {
+ const stop = this.debug.stepBack(granularity);
+ if (stop) return this.onStop(stop);
+ // nothing further back: say where it still is
+ const { at, pc } = this.debug.location();
+ this.onStop({ reason: 'entry', at, pc, message: 'start of recording' });
+ }
+
+ reverseContinue() { this.onStop(this.debug.reverseContinue()); }
+
+ seekFrame(frame: number) { this.onStop(this.debug.seekFrame(frame)); }
+
+ location(): DebugLocation { return this.debug.location(); }
+
+ timeline(): TimelineInfo | null {
+ const h = this.target.history;
+ if (!h) return null;
+ return { first: h.first().frame, last: h.last().frame, now: h.now(), past: h.isInPast() };
+ }
+
+ registers(): RegisterValue[] {
+ const state: any = this.target.getCPUState();
+ if (!state) return [];
+ const regs: RegisterValue[] = [];
+ for (const [name, value] of Object.entries(state)) {
+ if (typeof value !== 'number') continue;
+ const wide = /^(E?PC|SP|[A-Z]{2}|IX|IY)$/.test(name) || value > 0xff;
+ regs.push({ name, value, text: '$' + hex(value, wide ? 4 : 2) });
+ }
+ return regs;
+ }
+
+ readMemory(addr: number, count: number): number[] {
+ const bytes: number[] = [];
+ for (let i = 0; i < count; i++) bytes.push(this.target.read((addr + i) & 0xffff) & 0xff);
+ return bytes;
+ }
+
+ /**
+ * `count` instructions starting `insnOffset` instructions from `addr`
+ * (which may be negative). Instructions vary in length, so going back
+ * tries each start that disassembles into `addr` and keeps the one that
+ * lands on it.
+ */
+ disassemble(addr: number, insnOffset: number, count: number): DisasmResult[] {
+ let start = addr;
+ if (insnOffset < 0) start = this.alignBack(addr, -insnOffset);
+ else for (let i = 0; i < insnOffset; i++) start += this.insnSize(start);
+ const out: DisasmResult[] = [];
+ let a = start;
+ for (let i = 0; i < count; i++) {
+ const d = this.target.disassemble(a & 0xffff);
+ const n = d ? d.nbytes : 1;
+ let bytes = '';
+ for (let b = 0; b < n; b++) bytes += hex(this.target.read((a + b) & 0xffff), 2) + ' ';
+ const line: DisasmResult = { addr: a & 0xffff, bytes: bytes.trim(), text: d ? d.line : '???' };
+ const sym = this.debug.symbolAt(a & 0xffff);
+ if (sym && sym.offset === 0) line.symbol = sym.name;
+ const src = this.debug.sourceAt(a & 0xffff);
+ if (src) line.source = { path: src.path, line: src.line };
+ out.push(line);
+ a += n;
+ }
+ return out;
+ }
+
+ private insnSize(addr: number): number {
+ const d = this.target.disassemble(addr & 0xffff);
+ return d ? d.nbytes : 1;
+ }
+
+ private alignBack(addr: number, n: number): number {
+ for (let start = addr - n * MAX_INSN_BYTES; start < addr; start++) {
+ let a = start, count = 0;
+ while (a < addr) { a += this.insnSize(a); count++; }
+ if (a === addr && count >= n) {
+ // skip the extra instructions at the front
+ for (let i = 0; i < count - n; i++) start += this.insnSize(start);
+ return start;
+ }
+ }
+ return addr - n;
+ }
+
+ /**
+ * `repl`: a run-script command line (`mem $200 16`, `back 3`, `help`).
+ * Anything else: an expression, as in a breakpoint condition (`[score]`,
+ * `A`, `#mem16[ptr]`), or a symbol.
+ */
+ evaluate(expr: string, context: string): EvalResult {
+ expr = expr.trim();
+ if (context === 'repl') return this.runScript(expr);
+ const ctx = makeCondContext(this.debug.context);
+ let value: number;
+ const sym = lookupSymbol(this.debug.context.symbols, expr);
+ if (sym != null) value = sym;
+ else value = compileExpression(expr, ctx)(this.target.getCPUState());
+ if (typeof value !== 'number' || isNaN(value)) throw new Error(`'${expr}' has no value`);
+ return { result: `$${hex(value, value > 0xff ? 4 : 2)} (${value})`, value };
+ }
+
+ private runScript(line: string): EvalResult {
+ if (line === 'help' || line === '?') return { result: RUN_SCRIPT_HELP };
+ const before = this.target.now();
+ this.scriptOut = '';
+ this.script.run(line);
+ const after = this.target.now();
+ const moved = before.frame !== after.frame || before.step !== after.step;
+ return { result: this.scriptOut.replace(/\n$/, ''), moved };
+ }
+}
diff --git a/test/unit/testdapsession.ts b/test/unit/testdapsession.ts
new file mode 100644
index 00000000..23a6c00c
--- /dev/null
+++ b/test/unit/testdapsession.ts
@@ -0,0 +1,165 @@
+import assert from "assert";
+import { before, describe, it } from "mocha";
+import { DebugProtocol } from "@vscode/debugprotocol";
+import { EmuDebugSession } from "../../src/tools/dapsession";
+import { LocalDebugBackend } from "../../src/tools/daplocal";
+import { loadPlatform } from "../../src/tools/emutarget";
+import { compileSourceFile, preload } from "../../src/tools/testlib";
+
+// The debug adapter, driven with DAP messages as VS Code sends them, over the
+// in-process backend `8bws dap` uses.
+
+const ROOT = '/proj';
+let build: any;
+
+/** Just enough of a DAP client: requests by sequence number, events by name. */
+class Client {
+ private seq = 1;
+ private pending = new Map void>();
+ private events: DebugProtocol.Event[] = [];
+ private waiting: { name: string, resolve: (e: DebugProtocol.Event) => void }[] = [];
+
+ constructor(readonly session: EmuDebugSession) {
+ session.onDidSendMessage((m: any) => {
+ if (m.type === 'response') this.pending.get(m.request_seq)?.(m);
+ else if (m.type === 'event') {
+ const w = this.waiting.findIndex(w => w.name === m.event);
+ if (w >= 0) this.waiting.splice(w, 1)[0].resolve(m);
+ else this.events.push(m);
+ }
+ });
+ }
+
+ request(command: string, args: any = {}): Promise {
+ const seq = this.seq++;
+ return new Promise((resolve, reject) => {
+ this.pending.set(seq, (r) => r.success ? resolve(r.body) : reject(new Error(r.message)));
+ this.session.handleMessage({ seq, type: 'request', command, arguments: args } as any);
+ });
+ }
+
+ /** The next event of this name, including one that already came. */
+ event(name: string): Promise {
+ const i = this.events.findIndex(e => e.event === name);
+ if (i >= 0) return Promise.resolve(this.events.splice(i, 1)[0].body);
+ return new Promise(resolve => this.waiting.push({ name, resolve: e => resolve(e.body) }));
+ }
+
+ async where() {
+ const { stackFrames } = await this.request('stackTrace', { threadId: 1 });
+ return stackFrames[0];
+ }
+}
+
+async function launch(): Promise {
+ const backend = new LocalDebugBackend(async () => {
+ const target = await loadPlatform('mw8080bw');
+ await target.start();
+ await target.loadROM(build.output);
+ return { target, root: ROOT, debugInfo: { listings: build.listings, symbols: build.symbolmap, mainPath: 'game2.c' } };
+ });
+ const c = new Client(new EmuDebugSession(backend));
+ const caps = await c.request('initialize', { adapterID: '8bitworkshop', linesStartAt1: true, columnsStartAt1: true, pathFormat: 'path' });
+ assert.ok(caps.supportsDisassembleRequest);
+ await c.request('launch', { program: 'game2.c' });
+ await c.event('initialized');
+ assert.strictEqual((await c.event('capabilities')).capabilities.supportsStepBack, true);
+ return c;
+}
+
+describe('Debug adapter', function () {
+ this.timeout(120000);
+
+ before(async function () {
+ await preload('sdcc', 'mw8080bw');
+ build = await compileSourceFile('sdcc', 'mw8080bw', 'presets/mw8080bw/game2.c');
+ assert.deepStrictEqual(build.errors || [], []);
+ });
+
+ it('stops at a source breakpoint, steps by line, and steps back', async function () {
+ const c = await launch();
+ const { breakpoints } = await c.request('setBreakpoints', { source: { path: `${ROOT}/game2.c` }, breakpoints: [{ line: 166 }] });
+ assert.strictEqual(breakpoints[0].verified, true);
+ assert.strictEqual(breakpoints[0].line, 166);
+ await c.request('configurationDone');
+ const hit = await c.event('stopped');
+ assert.strictEqual(hit.reason, 'breakpoint');
+ assert.deepStrictEqual(hit.hitBreakpointIds, [breakpoints[0].id]);
+ let frame = await c.where();
+ assert.strictEqual(frame.source.path, `${ROOT}/game2.c`);
+ assert.strictEqual(frame.line, 166);
+ assert.strictEqual(frame.name, '_draw_string+18');
+
+ await c.request('next', { threadId: 1 });
+ assert.strictEqual((await c.event('stopped')).reason, 'step');
+ assert.strictEqual((await c.where()).line, 167);
+
+ await c.request('stepBack', { threadId: 1 });
+ assert.strictEqual((await c.event('stopped')).reason, 'step');
+ frame = await c.where();
+ assert.strictEqual(frame.line, 166);
+ assert.strictEqual(frame.name, '_draw_string+18');
+ });
+
+ it('shows registers, memory and disassembly around the PC', async function () {
+ const c = await launch();
+ await c.request('setFunctionBreakpoints', { breakpoints: [{ name: 'draw_char' }] });
+ await c.request('configurationDone');
+ assert.strictEqual((await c.event('stopped')).reason, 'breakpoint');
+ const frame = await c.where();
+ const pc = parseInt(frame.instructionPointerReference, 16);
+ assert.strictEqual(pc, build.symbolmap['_draw_char']);
+
+ const { scopes } = await c.request('scopes', { frameId: frame.id });
+ const { variables } = await c.request('variables', { variablesReference: scopes[0].variablesReference });
+ const reg = variables.find((v: any) => v.name === 'PC');
+ assert.strictEqual(parseInt(reg.value.slice(1), 16), pc);
+
+ const mem = await c.request('readMemory', { memoryReference: frame.instructionPointerReference, count: 4 });
+ assert.strictEqual(Buffer.from(mem.data, 'base64').length, 4);
+
+ const { instructions } = await c.request('disassemble', { memoryReference: frame.instructionPointerReference, instructionOffset: -3, instructionCount: 6 });
+ assert.strictEqual(instructions.length, 6);
+ assert.strictEqual(parseInt(instructions[3].address, 16), pc);
+ assert.strictEqual(instructions[3].symbol, '_draw_char');
+ });
+
+ it('runs run-script commands in the Debug Console, and expressions in watches', async function () {
+ const c = await launch();
+ await c.request('setFunctionBreakpoints', { breakpoints: [{ name: 'draw_char' }] });
+ await c.request('configurationDone');
+ await c.event('stopped');
+ const hover = await c.request('evaluate', { expression: 'SP', context: 'hover' });
+ assert.match(hover.result, /^\$[0-9A-F]{4} \(\d+\)$/);
+ const back = await c.request('evaluate', { expression: 'back 3', context: 'repl' });
+ assert.match(back.result, /back 3: PC=/);
+ assert.strictEqual((await c.event('stopped')).reason, 'goto');
+ const now = await c.request('evaluate', { expression: 'now', context: 'repl' });
+ assert.match(now.result, /past; recorded/);
+ await assert.rejects(c.request('evaluate', { expression: 'bogus', context: 'repl' }), /unknown command/);
+ });
+
+ it('runs backwards to the previous breakpoint hit', async function () {
+ const c = await launch();
+ await c.request('setFunctionBreakpoints', { breakpoints: [{ name: 'draw_char' }] });
+ await c.request('configurationDone');
+ await c.event('stopped');
+ const first = await c.where();
+ await c.request('continue', { threadId: 1 });
+ await c.event('stopped');
+ await c.request('reverseContinue', { threadId: 1 });
+ assert.strictEqual((await c.event('stopped')).reason, 'breakpoint');
+ await c.request('reverseContinue', { threadId: 1 });
+ assert.strictEqual((await c.event('stopped')).reason, 'entry');
+ assert.strictEqual(first.name, '_draw_char');
+ });
+
+ it('pauses a running program', async function () {
+ const c = await launch();
+ await c.request('configurationDone');
+ await new Promise(resolve => setTimeout(resolve, 50));
+ await c.request('pause', { threadId: 1 });
+ assert.strictEqual((await c.event('stopped')).reason, 'pause');
+ assert.ok((await c.where()).instructionPointerReference);
+ });
+});