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); + }); +});