mirror of
https://github.com/sehugg/8bitworkshop.git
synced 2026-10-24 08:02:27 +00:00
vscode: F5 debugs with step back; timeline slider in the emulator panel
This commit is contained in:
1 parent
7b9829dde3
commit
972b712ec6
11 files changed
+272
-60
No files matched your search
+1
-1
@@ -86,7 +86,7 @@ also appear in the editor title bar, the Explorer, or **Project Options**.
|
||||
| **Change Main File...** | Pick the main file from the workspace |
|
||||
| **Change Platform...** | Build the project for another platform |
|
||||
| **Detect Projects** | Scan the folder for projects and platforms |
|
||||
| **Add Launch Configuration** | Write a run configuration to `.vscode/launch.json` |
|
||||
| **Add Launch Configuration** | Write a run configuration to `.vscode/launch.json`; <kbd>F5</kbd> debugs it, with step back |
|
||||
| **Project Options** | Open the project menu |
|
||||
| **Build** | Build without running |
|
||||
| **Run** | Build and run in the emulator |
|
||||
|
||||
@@ -88,7 +88,7 @@ back, click the status bar and choose **Run Main File**.
|
||||
|
||||
To keep a list of programs to switch between, click the status bar and
|
||||
choose **Add Launch Configuration**. The program then appears in the
|
||||
**Run and Debug** view's list, and <kbd>F5</kbd> runs the one you chose.
|
||||
**Run and Debug** view's list, and <kbd>F5</kbd> debugs the one you chose.
|
||||
|
||||
**A folder of small programs**, like a set of exercises, has no main
|
||||
file. **Run** runs the program in the editor you're looking at. The
|
||||
|
||||
@@ -43,6 +43,49 @@ A build that fails leaves the last good version running.
|
||||
Some programs end, such as a BASIC program. Then the panel says
|
||||
**Halted** and the reason. Click **Run** to start again.
|
||||
|
||||
## Go back in time
|
||||
|
||||
The emulator records as it runs. While it's paused, a timeline appears
|
||||
under the screen. Drag it to see any recorded frame. **Resume** runs on
|
||||
from the frame you're looking at. Pressing a key there starts a new
|
||||
future and drops the one that was recorded.
|
||||
|
||||
The recording keeps about the last 50 seconds. The NES saves bigger
|
||||
states, so it keeps about 10 seconds.
|
||||
|
||||
## Debug
|
||||
|
||||
Press <kbd>F5</kbd> to build the program and debug it. Press
|
||||
<kbd>Ctrl</kbd>+<kbd>F5</kbd> (**Run Without Debugging**) to just run it.
|
||||
|
||||
- **Breakpoints:** click left of a line number in C or assembly. You can
|
||||
also add a breakpoint on a function by name (**+** in the Breakpoints
|
||||
view), or on an address in the Disassembly view. A condition such as
|
||||
`A == 3` or `[score] > 100` makes the breakpoint stop only when it's true.
|
||||
- **Stepping:** Step Over, Step Into and Step Out go by source line. In the
|
||||
Disassembly view they go by instruction.
|
||||
- **Step Back** and **Reverse Continue** run backwards through the
|
||||
recording, to the previous line or the previous breakpoint hit.
|
||||
- **Variables** shows the CPU registers. **Watch** and hovers take the
|
||||
same expressions as conditions: registers, symbols, `[addr]` for a byte
|
||||
of memory, `#mem16[addr]` for a word.
|
||||
- The **Debug Console** takes the commands of `8bws run -e`, such as
|
||||
`mem $0200 32`, `hist 10`, `back 5` and `rbreak main`. Type `help` to
|
||||
list them.
|
||||
|
||||
A launch configuration can also set:
|
||||
|
||||
| Setting | What it does |
|
||||
|---|---|
|
||||
| `stopOnEntry` | Stop before the first instruction instead of running |
|
||||
| `script` | Debug Console commands to run once the program loads, such as `"break main"` |
|
||||
|
||||
While you debug, a rebuild doesn't restart the program. Press
|
||||
<kbd>F5</kbd> again to debug the new build.
|
||||
|
||||
Some platforms, such as the Atari 2600 and Verilog, can only stop
|
||||
between frames. There, breakpoints and steps work a frame at a time.
|
||||
|
||||
## Sound
|
||||
|
||||
The emulator plays sound through the panel. Audio starts on your first click
|
||||
|
||||
@@ -78,7 +78,7 @@ To switch between programs with <kbd>F5</kbd>:
|
||||
2. Click the status bar and choose **Add Launch Configuration**.
|
||||
|
||||
The program appears in the **Run and Debug** view's list. Choose it there
|
||||
and press <kbd>F5</kbd> to build and run it. A launch configuration can
|
||||
and press <kbd>F5</kbd> to build and debug it. A launch configuration can
|
||||
also name another platform, so one program can run on `c64` and `vic20`:
|
||||
|
||||
```json
|
||||
@@ -91,7 +91,8 @@ also name another platform, so one program can run on `c64` and `vic20`:
|
||||
}
|
||||
```
|
||||
|
||||
<kbd>F5</kbd> runs the program but doesn't debug it yet.
|
||||
<kbd>F5</kbd> builds and debugs the program; <kbd>Ctrl</kbd>+<kbd>F5</kbd>
|
||||
runs it without the debugger. See [Debug](emulator.md#debug).
|
||||
|
||||
## Where your choices are saved
|
||||
|
||||
|
||||
@@ -1647,8 +1647,11 @@ debugger shouldn't become a third.
|
||||
`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
|
||||
`extension/src/debugbackend.ts` is its RPC backend. F5 debugs; Ctrl+F5
|
||||
(`noDebug`) runs as before. A debug launch builds, starts the worker
|
||||
paused, and sends the listings with `setBuild`. Rebuilds don't reload
|
||||
under the debugger; a plain Run ends the session. The panel's timeline
|
||||
slider (shown while stopped) calls `seekFrame`.)* 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
|
||||
|
||||
@@ -434,6 +434,13 @@
|
||||
"contents": "Make a program for a classic console or computer.\n[New 8bitworkshop Project](command:8bitworkshop.newProject)\n[Open 8bitworkshop Example](command:8bitworkshop.openExample)"
|
||||
}
|
||||
],
|
||||
"breakpoints": [
|
||||
{ "language": "c" },
|
||||
{ "language": "cpp" },
|
||||
{ "language": "8bws-6502" },
|
||||
{ "language": "8bws-z80" },
|
||||
{ "language": "8bws-6809" }
|
||||
],
|
||||
"debuggers": [
|
||||
{
|
||||
"type": "8bitworkshop",
|
||||
@@ -453,6 +460,15 @@
|
||||
"tool": {
|
||||
"type": "string",
|
||||
"description": "Tool for the main file, if not the usual one."
|
||||
},
|
||||
"stopOnEntry": {
|
||||
"type": "boolean",
|
||||
"description": "Stop before the program's first instruction instead of running.",
|
||||
"default": false
|
||||
},
|
||||
"script": {
|
||||
"type": "string",
|
||||
"description": "Commands to run once the program is loaded, as in the Debug Console (e.g. \"break main; key SPACE\")."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface PanelEvents {
|
||||
onKey(key: number, code: number, flags: number): void;
|
||||
/** the user showed or hid the controls bar */
|
||||
onControlsVisible(visible: boolean): void;
|
||||
/** the user dragged the timeline to a recorded frame */
|
||||
onSeek?(frame: number): void;
|
||||
onVisible(visible: boolean): void;
|
||||
onDispose(): void;
|
||||
}
|
||||
@@ -29,6 +31,7 @@ export class EmulatorPanel {
|
||||
if (msg.type === 'key') this.events.onKey(msg.key, msg.code, msg.flags);
|
||||
else if (msg.type === 'frameDone') this.frameInFlight = false;
|
||||
else if (msg.type === 'controlsVisible') this.events.onControlsVisible(!!msg.visible);
|
||||
else if (msg.type === 'seek') this.events.onSeek?.(msg.frame);
|
||||
});
|
||||
this.panel.onDidChangeViewState(e => this.events.onVisible(e.webviewPanel.visible));
|
||||
this.panel.onDidDispose(() => this.events.onDispose());
|
||||
@@ -102,11 +105,19 @@ function getHtml(controlsVisible: boolean) {
|
||||
#show { position: absolute; right: 6px; bottom: 4px; display: none; cursor: pointer; font: 11px var(--vscode-font-family);
|
||||
color: #ccc; background: rgba(0,0,0,0.5); border: 0; border-radius: 3px; padding: 2px 6px; }
|
||||
body.hascontrols:not(.controls) #show { display: block; }
|
||||
/* the timeline: shown while stopped, to scrub through the recording */
|
||||
#timeline { position: absolute; left: 0; right: 0; display: none; align-items: center; gap: 8px;
|
||||
padding: 2px 8px; box-sizing: border-box; background: rgba(0,0,0,0.7);
|
||||
font: 11px var(--vscode-font-family); color: #ccc; }
|
||||
body.stopped.haslog #timeline { display: flex; }
|
||||
#scrub { flex: 1; accent-color: var(--vscode-focusBorder); }
|
||||
#when { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="${controlsVisible ? 'controls' : ''}">
|
||||
<div id="wrap"><canvas id="screen" tabindex="0" width="1" height="1"></canvas></div>
|
||||
<div id="status"></div>
|
||||
<div id="timeline"><input id="scrub" type="range" min="0" max="0" value="0" title="Recorded frames: drag to go back in time"><span id="when"></span></div>
|
||||
<div id="bar"><span id="hints"></span><button id="hide" title="Hide controls">×</button></div>
|
||||
<button id="show" title="Show controls">Controls</button>
|
||||
<script nonce="${nonce}">
|
||||
@@ -223,6 +234,31 @@ function getHtml(controlsVisible: boolean) {
|
||||
fit();
|
||||
}
|
||||
|
||||
// the timeline: where the shown frame is in the recording. Dragging asks
|
||||
// the host to seek, one request at a time; the next frame answers it.
|
||||
const timelineEl = document.getElementById('timeline');
|
||||
const scrub = document.getElementById('scrub');
|
||||
const whenEl = document.getElementById('when');
|
||||
let seekPending = false, seekWanted = null;
|
||||
function showTimeline(t) {
|
||||
document.body.classList.toggle('haslog', !!t);
|
||||
if (!t) return;
|
||||
scrub.min = t.first;
|
||||
scrub.max = t.last;
|
||||
if (!seekPending && seekWanted == null) scrub.value = t.now.frame;
|
||||
whenEl.textContent = 'frame ' + t.now.frame + (t.now.step ? ':' + t.now.step : '') + ' / ' + t.last;
|
||||
}
|
||||
function requestSeek(frame) {
|
||||
if (seekPending) { seekWanted = frame; return; }
|
||||
seekPending = true;
|
||||
vscode.postMessage({ type: 'seek', frame });
|
||||
}
|
||||
function seekDone() {
|
||||
seekPending = false;
|
||||
if (seekWanted != null) { const f = seekWanted; seekWanted = null; requestSeek(f); }
|
||||
}
|
||||
scrub.addEventListener('input', () => requestSeek(Number(scrub.value)));
|
||||
|
||||
// scale the canvas to fill the panel, keeping its aspect ratio
|
||||
function fit() {
|
||||
const { w, h, rotate, aspect } = layout;
|
||||
@@ -231,6 +267,7 @@ function getHtml(controlsVisible: boolean) {
|
||||
let ratio = aspect || w / h;
|
||||
if (sideways) ratio = 1 / ratio;
|
||||
const barH = document.body.classList.contains('controls') ? bar.offsetHeight : 0;
|
||||
timelineEl.style.bottom = barH + 'px';
|
||||
document.getElementById('wrap').style.bottom = barH + 'px';
|
||||
const W = window.innerWidth, H = window.innerHeight - barH;
|
||||
let dw = W, dh = W / ratio;
|
||||
@@ -257,6 +294,8 @@ function getHtml(controlsVisible: boolean) {
|
||||
image.data.set(msg.pixels);
|
||||
ctx.putImageData(image, 0, 0);
|
||||
vscode.postMessage({ type: 'frameDone' });
|
||||
if (msg.timeline) showTimeline(msg.timeline);
|
||||
seekDone();
|
||||
} else if (msg.type === 'status') {
|
||||
const s = msg.status;
|
||||
if (s && JSON.stringify(s.controls) !== lastControls) {
|
||||
@@ -264,6 +303,9 @@ function getHtml(controlsVisible: boolean) {
|
||||
showControls(s.controls);
|
||||
}
|
||||
statusEl.textContent = !s ? '' : s.state == 'running' ? '' : s.state == 'halted' ? 'Halted: ' + (s.message || '') : 'Paused';
|
||||
document.body.classList.toggle('stopped', !!s && s.state != 'running');
|
||||
showTimeline(s && s.timeline);
|
||||
seekDone(); // a seek always ends with a status, even if its frame was dropped
|
||||
} else if (msg.type === 'audio') {
|
||||
onAudio(msg);
|
||||
} else if (msg.type === 'audioReset') {
|
||||
|
||||
+85
-12
@@ -11,6 +11,8 @@ import type { BuildArgs } from './buildworker';
|
||||
import type { AudioChunk, EmuStatus } from './emuworker';
|
||||
import { WorkerHandle } from './engine';
|
||||
import { EmulatorPanel } from './emulatorpanel';
|
||||
import { WorkerDebugBackend } from './debugbackend';
|
||||
import { EmuDebugSession, LaunchArgs } from '../../src/tools/dapsession';
|
||||
import { Project, findRootDir, isHeaderFile, isInside, isOwnExtension, isSourceFile } from './projectinfo';
|
||||
import { CONFIG, ProjectScope } from './projectscope';
|
||||
import { BuildReason, BuildScheduler } from './autobuild';
|
||||
@@ -54,6 +56,8 @@ let autoTarget: Target | undefined;
|
||||
let heldDiagnostics: { timer: NodeJS.Timeout, show: () => void } | undefined;
|
||||
/** Sound is muted; remembered across runs and webviews. */
|
||||
let muted = false;
|
||||
/** The debug session driving the emulator, if one is. */
|
||||
let debugging: { session: vscode.DebugSession, backend: WorkerDebugBackend } | undefined;
|
||||
|
||||
export function activate(ctx: vscode.ExtensionContext) {
|
||||
context = ctx;
|
||||
@@ -102,13 +106,24 @@ export function activate(ctx: vscode.ExtensionContext) {
|
||||
muted = ctx.globalState.get<boolean>('muted', false);
|
||||
vscode.commands.executeCommand('setContext', '8bitworkshop.muted', muted);
|
||||
|
||||
// F5 on an 8bitworkshop launch configuration runs it (no debugger yet)
|
||||
// F5 on an 8bitworkshop launch configuration debugs it; Ctrl+F5 (Run
|
||||
// Without Debugging) just runs it in the emulator panel
|
||||
ctx.subscriptions.push(vscode.debug.registerDebugConfigurationProvider('8bitworkshop', {
|
||||
resolveDebugConfiguration: async (folder, config) => {
|
||||
await runLaunchConfiguration(folder, config);
|
||||
return undefined; // no debug session; the emulator panel runs it
|
||||
if (config.noDebug) {
|
||||
await runLaunchConfiguration(folder, config);
|
||||
return undefined; // no debug session; the emulator panel runs it
|
||||
}
|
||||
// F5 with no launch.json: debug the current target
|
||||
return { type: '8bitworkshop', request: 'launch', name: 'Debug', ...config };
|
||||
},
|
||||
}));
|
||||
ctx.subscriptions.push(vscode.debug.registerDebugAdapterDescriptorFactory('8bitworkshop', {
|
||||
createDebugAdapterDescriptor: session => new vscode.DebugAdapterInlineImplementation(newDebugSession(session)),
|
||||
}));
|
||||
ctx.subscriptions.push(vscode.debug.onDidTerminateDebugSession(session => {
|
||||
if (debugging?.session === session) debugging = undefined;
|
||||
}));
|
||||
|
||||
ctx.subscriptions.push(
|
||||
vscode.workspace.onDidSaveTextDocument(doc => onSave(doc)),
|
||||
@@ -229,6 +244,7 @@ async function getEmu(platform: string): Promise<WorkerHandle> {
|
||||
emu.on('frame', frame => panel?.showFrame(frame));
|
||||
emu.on('audio', (chunk: AudioChunk) => panel?.showAudio(chunk));
|
||||
emu.on('audioReset', () => panel?.resetAudio());
|
||||
emu.on('stopped', e => debugging?.backend.handleStop(e));
|
||||
emu.on('status', (s: EmuStatus | null) => {
|
||||
emuStatus = s;
|
||||
panel?.showStatus(s);
|
||||
@@ -425,7 +441,10 @@ async function followActiveEditor() {
|
||||
await scope.setTargetChoice(project, 'follow');
|
||||
}
|
||||
|
||||
async function startEmulator(target: Target, build: BuildOutcome) {
|
||||
/** Start `build` in the emulator panel. Returns false if it didn't start. */
|
||||
async function startEmulator(target: Target, build: BuildOutcome, opts: { paused?: boolean } = {}): Promise<boolean> {
|
||||
// a plain Run replaces what the debugger was looking at
|
||||
if (debugging && !opts.paused) vscode.debug.stopDebugging(debugging.session);
|
||||
// each run gets a fresh worker: platforms keep global state (see emuworker)
|
||||
emu?.dispose();
|
||||
emu = undefined;
|
||||
@@ -437,7 +456,10 @@ async function startEmulator(target: Target, build: BuildOutcome) {
|
||||
onControlsVisible: visible => context.globalState.update('controlsVisible', visible),
|
||||
// don't burn CPU on a hidden screen
|
||||
onVisible: visible => { emu?.call('setVisible', visible); },
|
||||
onSeek: frame => { emu?.call('seekFrame', frame); },
|
||||
onDispose: () => {
|
||||
// closing the emulator ends the program, and any session debugging it
|
||||
if (debugging) vscode.debug.stopDebugging(debugging.session);
|
||||
panel = undefined;
|
||||
emuStatus = null;
|
||||
running = undefined;
|
||||
@@ -454,15 +476,17 @@ async function startEmulator(target: Target, build: BuildOutcome) {
|
||||
panel.setTitle(`${title} (${target.platform})`);
|
||||
panel.setMuted(muted);
|
||||
try {
|
||||
emuStatus = await worker.call<EmuStatus>('start', target.platform, build.output, build.files);
|
||||
emuStatus = await worker.call<EmuStatus>('start', target.platform, build.output, build.files, opts);
|
||||
worker.call('setMuted', muted);
|
||||
panel.showStatus(emuStatus);
|
||||
running = target;
|
||||
runningBuild = build;
|
||||
output.appendLine(`Running ${title} on ${target.platform}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
output.appendLine(`Emulator failed to start: ${e && e.stack || e}`);
|
||||
output.show(true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,6 +494,8 @@ async function startEmulator(target: Target, build: BuildOutcome) {
|
||||
async function reloadEmulator(target: Target, reason: BuildReason, result: BuildOutcome) {
|
||||
if (!panel || !emuStatus || !running || !result.output) return;
|
||||
if (running.main.toString() !== target.main.toString()) return;
|
||||
// the code under the debugger doesn't change beneath it; F5 again rebuilds
|
||||
if (debugging) return;
|
||||
var mode = config(target.main).get<string>('reloadOnBuild', 'always');
|
||||
if (mode === 'never' || (mode === 'onSave' && reason === 'type')) return;
|
||||
if (emuStatus.platform !== target.platform) {
|
||||
@@ -1102,18 +1128,65 @@ async function addLaunchConfiguration() {
|
||||
}
|
||||
|
||||
async function runLaunchConfiguration(folder: vscode.WorkspaceFolder | undefined, cfg: vscode.DebugConfiguration) {
|
||||
if (!cfg.mainFile) {
|
||||
// Ctrl+F5 with no launch.json: run the current target
|
||||
return buildCommand(true);
|
||||
}
|
||||
var target = await launchTarget(folder, cfg);
|
||||
await buildAndMaybeRun(target, true, 'command');
|
||||
}
|
||||
|
||||
/** What a launch configuration runs: its mainFile, or the current target. */
|
||||
async function launchTarget(folder: vscode.WorkspaceFolder | undefined, cfg: { name?: string, mainFile?: string, platform?: string, tool?: string }): Promise<Target> {
|
||||
var base = folder?.uri.fsPath || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
if (!cfg.mainFile || !base) {
|
||||
// F5 with no launch.json: run the current target
|
||||
return buildCommand(true);
|
||||
var current = currentTarget();
|
||||
if (!current) throw new Error('open a program to debug, or set mainFile in the launch configuration');
|
||||
return current;
|
||||
}
|
||||
var file = path.resolve(base, cfg.mainFile);
|
||||
var project = scope.projectFor(vscode.Uri.file(file));
|
||||
var platform = cfg.platform || project?.platform;
|
||||
if (!platform) {
|
||||
vscode.window.showErrorMessage(`8bitworkshop: launch configuration "${cfg.name}" needs a platform.`);
|
||||
return;
|
||||
}
|
||||
if (!platform) throw new Error(`launch configuration "${cfg.name}" needs a platform`);
|
||||
if (project) await scope.setTargetChoice(project, file);
|
||||
await buildAndMaybeRun({ platform, main: vscode.Uri.file(file), tool: cfg.tool, project }, true, 'command');
|
||||
return { platform, main: vscode.Uri.file(file), tool: cfg.tool, project };
|
||||
}
|
||||
|
||||
/**
|
||||
* A debug session over the emulator worker. The adapter (shared with
|
||||
* `8bws dap`) runs here in the extension host; the debugger itself runs in
|
||||
* the worker, next to the emulator.
|
||||
*/
|
||||
function newDebugSession(session: vscode.DebugSession): EmuDebugSession {
|
||||
// the worker this session launched; a later Run starts another
|
||||
var worker: WorkerHandle | undefined;
|
||||
var backend = new WorkerDebugBackend(
|
||||
(method, ...args) => {
|
||||
if (!worker || worker !== emu) throw new Error('the emulator this session started has stopped');
|
||||
return worker.call('debug', method, ...args);
|
||||
},
|
||||
async args => {
|
||||
var r = await launchForDebug(session.workspaceFolder, args);
|
||||
worker = emu;
|
||||
return r;
|
||||
},
|
||||
async () => {
|
||||
if (debugging?.session === session) debugging = undefined;
|
||||
// stopping the session stops its program, not one started since
|
||||
if (worker && worker === emu) panel?.dispose();
|
||||
});
|
||||
debugging = { session, backend };
|
||||
return new EmuDebugSession(backend);
|
||||
}
|
||||
|
||||
/** Build the program and load it stopped, with its listings for the debugger. */
|
||||
async function launchForDebug(folder: vscode.WorkspaceFolder | undefined, args: LaunchArgs): Promise<{ root: string }> {
|
||||
var target = await launchTarget(folder, args);
|
||||
var result = await runBuild(target, 'command');
|
||||
if (!result || !result.success) throw new Error('the build failed; see Problems');
|
||||
if (!result.output) throw new Error('the build produced no ROM to run');
|
||||
if (!await startEmulator(target, result, { paused: true })) throw new Error('the emulator failed to start; see Output');
|
||||
var mainPath = path.posix.basename(target.main.path);
|
||||
await emu!.call('debug', 'setBuild', { listings: result.listings, symbols: result.symbolmap, mainPath, paths: result.paths });
|
||||
return { root: path.dirname(target.main.fsPath) };
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import { Rpc } from '../src/rpc';
|
||||
import { findRootDir } from '../src/projectinfo';
|
||||
import { Builder, ProjectFileProvider } from '../src/buildcore';
|
||||
import type { AudioChunk, EmuStatus, FrameEvent } from '../src/emuworker';
|
||||
import { WorkerDebugBackend } from '../src/debugbackend';
|
||||
import { EmuDebugSession } from '../../src/tools/dapsession';
|
||||
import { DapClient } from '../../test/unit/dapclient';
|
||||
|
||||
// Drives out/emuworker.js in a worker thread, the way the extension host does.
|
||||
// Mocha runs from extension/, so BIOS fetches must resolve against ROOT.
|
||||
@@ -230,6 +233,33 @@ describe('extension emuworker', function () {
|
||||
assert.equal(statuses[statuses.length - 1].state, 'paused');
|
||||
});
|
||||
|
||||
it('drives the debug adapter through the worker, as the extension does', async function () {
|
||||
var backend = new WorkerDebugBackend((method, ...args) => rpc.call('debug', method, ...args), async () => {
|
||||
await rpc.call('start', 'mw8080bw', built.output, built.files, { paused: true });
|
||||
await rpc.call('debug', 'setBuild', { listings: built.listings, symbols: built.symbolmap, mainPath: 'game2.c', paths: built.paths });
|
||||
return { root: '/proj' };
|
||||
}, async () => { });
|
||||
rpc.on('stopped', e => backend.handleStop(e));
|
||||
var c = new DapClient(new EmuDebugSession(backend));
|
||||
await c.request('initialize', { adapterID: '8bitworkshop', pathFormat: 'path', linesStartAt1: true, columnsStartAt1: true });
|
||||
await c.request('launch', { mainFile: 'game2.c' });
|
||||
await c.event('initialized');
|
||||
var { breakpoints } = await c.request('setBreakpoints', { source: { path: '/proj/game2.c' }, breakpoints: [{ line: 166 }] });
|
||||
assert.ok(breakpoints[0].verified);
|
||||
await c.request('configurationDone');
|
||||
assert.equal((await c.event('stopped')).reason, 'breakpoint');
|
||||
var frame = await c.where();
|
||||
assert.equal(frame.source.path, '/proj/game2.c');
|
||||
assert.equal(frame.line, 166);
|
||||
await c.request('next', { threadId: 1 });
|
||||
await c.event('stopped');
|
||||
assert.equal((await c.where()).line, 167);
|
||||
await c.request('stepBack', { threadId: 1 });
|
||||
await c.event('stopped');
|
||||
assert.equal((await c.where()).line, 166);
|
||||
assert.match((await c.request('evaluate', { expression: 'now', context: 'repl' })).result, /past/);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// A test client for the debug adapter, shared by the root and extension tests.
|
||||
|
||||
import { DebugProtocol } from "@vscode/debugprotocol";
|
||||
import type { EmuDebugSession } from "../../src/tools/dapsession";
|
||||
|
||||
/** Just enough of a DAP client: requests by sequence number, events by name. */
|
||||
export class DapClient {
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "assert";
|
||||
import { before, describe, it } from "mocha";
|
||||
import { DebugProtocol } from "@vscode/debugprotocol";
|
||||
import { EmuDebugSession } from "../../src/tools/dapsession";
|
||||
import { DapClient } from "./dapclient";
|
||||
import { LocalDebugBackend } from "../../src/tools/daplocal";
|
||||
import { loadPlatform } from "../../src/tools/emutarget";
|
||||
import { compileSourceFile, preload } from "../../src/tools/testlib";
|
||||
@@ -12,53 +12,14 @@ import { compileSourceFile, preload } from "../../src/tools/testlib";
|
||||
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> {
|
||||
async function launch(): Promise<DapClient> {
|
||||
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 c = new DapClient(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' });
|
||||
|
||||
Reference in new issue
Block a user