debug adapter: DebugService, DAP session, 8bws dap, emuworker debug RPC

This commit is contained in:
Steven Hugg committed 2026-09-28 18:57:04 +02:00
1 parent d09936b0f3
commit 7b9829dde3
15 files changed
+1163 -42

No files matched your search

+6 -1
View File
@@ -1643,7 +1643,12 @@ debugger shouldn't become a third.
`step`, `pc`, ...), and it gains `back [n]` and `seek <frame>`. 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
+57
View File
@@ -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<any>;
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<void>) { }
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<LaunchResult> {
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<DebugService['setBreakpoints']>) { return this.call('setBreakpoints', ...a); }
continue() { return this.call('continue'); }
step(...a: Parameters<DebugService['step']>) { return this.call('step', ...a); }
pause() { return this.call('pause'); }
stepBack(...a: Parameters<DebugService['stepBack']>) { return this.call('stepBack', ...a); }
reverseContinue() { return this.call('reverseContinue'); }
location() { return this.call('location'); }
registers() { return this.call('registers'); }
readMemory(...a: Parameters<DebugService['readMemory']>) { return this.call('readMemory', ...a); }
disassemble(...a: Parameters<DebugService['disassemble']>) { return this.call('disassemble', ...a); }
evaluate(...a: Parameters<DebugService['evaluate']>) { return this.call('evaluate', ...a); }
}
+87 -19
View File
@@ -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]);
}
+59
View File
@@ -183,4 +183,63 @@ describe('extension emuworker', function () {
await rpc.call('status');
});
});
describe('debugging', function () {
var built: Awaited<ReturnType<Builder['build']>>;
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<EmuStatus>('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<EmuStatus>('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);
});
});
});
+20
View File
@@ -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",
+2
View File
@@ -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",
+8 -3
View File
@@ -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();
+2 -1
View File
@@ -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<string>();
try {
+39 -4
View File
@@ -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)));
+58 -13
View File
@@ -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<void> {
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<ReturnType<typeof compileSource>>;
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<Program> {
if (!input) {
fail('run', 'Required: run --platform <id> <rom-or-source>');
fail(command, `Required: ${command} --platform <id> <rom-or-source>`);
}
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<void> {
let built: Awaited<ReturnType<typeof compileSource>> | 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<void> {
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<void> {
} 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<void> {
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<void> {
////////////////////////////////////////////////////////////////////////
// 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<void> {
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<void> {
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;
+18 -1
View File
@@ -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);
}
+83
View File
@@ -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<LoadedProgram>) { }
onStop(fn: (e: StopEvent) => void) { this.stopListeners.push(fn); }
onOutput(fn: (text: string) => void) { this.outputListeners.push(fn); }
async launch(args: LaunchArgs): Promise<LaunchResult> {
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<DebugService['setBreakpoints']>) { return this.svc.setBreakpoints(...a); }
async continue() { this.svc.continue(); this.loop(); }
async step(...a: Parameters<DebugService['step']>) { this.svc.step(...a); this.loop(); }
async pause() { this.svc.pause(); }
async stepBack(...a: Parameters<DebugService['stepBack']>) { 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<DebugService['readMemory']>) { return this.svc.readMemory(...a); }
async disassemble(...a: Parameters<DebugService['disassemble']>) { return this.svc.disassemble(...a); }
async evaluate(...a: Parameters<DebugService['evaluate']>) { 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);
}
}
+315
View File
@@ -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<T> = { [K in keyof T]: T[K] extends (...args: infer A) => infer R ? (...args: A) => Promise<R> : never };
/** A DebugService, wherever it runs, plus loading a program into it. */
export type DebugBackend = Async<Pick<DebugService,
'setBreakpoints' | 'continue' | 'step' | 'pause' | 'stepBack' | 'reverseContinue' | 'location' |
'registers' | 'readMemory' | 'disassemble' | 'evaluate'>> & {
/** Build or load the program, and leave it stopped at its start. */
launch(args: LaunchArgs): Promise<LaunchResult>;
terminate(): Promise<void>;
/** 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<string, Breakpoint[]>();
private functionBps: Breakpoint[] = [];
private instructionBps: Breakpoint[] = [];
private nextBpId = 1;
private launched: Promise<void>;
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, 'id' | 'enabled'>): Breakpoint {
return { id: this.nextBpId++, enabled: true, ...bp };
}
/** Send every breakpoint; report on the ones in `mine`. */
private async sendBreakpoints(mine: Breakpoint[]): Promise<DebugProtocol.Breakpoint[]> {
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<unknown>) {
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;
}
+244
View File
@@ -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 };
}
}
+165
View File
@@ -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<number, (r: DebugProtocol.Response) => 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<any> {
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<any> {
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<Client> {
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);
});
});