mirror of
https://github.com/sehugg/8bitworkshop.git
synced 2026-10-24 08:02:27 +00:00
debug adapter: DebugService, DAP session, 8bws dap, emuworker debug RPC
This commit is contained in:
1 parent
d09936b0f3
commit
7b9829dde3
15 files changed
+1163
-42
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Generated
+20
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user