refactored some more stuff for headless

This commit is contained in:
Steven Hugg committed 2026-09-26 04:55:35 +02:00
1 parent 03d98e784c
commit 4ddbc7ccec
12 files changed
+611 -434

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
// Starts each platform in out/emuworker.js and reports whether frames arrive.
// usage: node scripts/survey.js [platform...] (after npm run build)
const { Worker } = require('worker_threads');
const fs = require('fs'), path = require('path');
const ROOT = path.resolve(__dirname, '../..');
const OUT = ROOT + '/extension/out';
function rpcWorker(file) {
const w = new Worker(path.join(OUT, file), { workerData: { rootDir: ROOT }, stdout: true, stderr: true });
let id = 1; const pending = new Map(); const events = [];
w.on('message', m => {
if (m.t === 'result') { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.value); }
else if (m.t === 'event') events.push(m);
});
w.on('error', e => { for (const p of pending.values()) p.rej(e); });
return { w, events, call: (method, ...args) => new Promise((res, rej) => { const i = id++; pending.set(i, { res, rej }); w.postMessage({ t: 'call', id: i, method, args }); }) };
}
function romFor(id) {
for (const d of [id, id.split('-')[0], id.split('.')[0]]) {
const dir = path.join(ROOT, 'test/roms', d);
if (fs.existsSync(dir)) { const f = fs.readdirSync(dir).filter(f => /\.rom$|\.bin$/.test(f))[0]; if (f) return { rom: new Uint8Array(fs.readFileSync(path.join(dir, f))), name: d + '/' + f }; }
}
return { rom: new Uint8Array(+(process.env.ROMSIZE || 0x8000)), name: 'zeros' };
}
const withTimeout = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms))]);
(async () => {
const b = rpcWorker('buildworker.js');
const ids = process.argv[2] ? process.argv.slice(2) : await b.call('listPlatforms');
await b.w.terminate();
for (const id of ids) {
const { rom, name } = romFor(id);
const e = rpcWorker('emuworker.js');
let result;
try {
await withTimeout(e.call('start', id, rom), 8000);
await new Promise(r => setTimeout(r, 1500));
const frames = e.events.filter(x => x.name === 'frame');
const halted = e.events.find(x => x.name === 'status' && x.data?.state === 'halted');
if (halted) result = 'HALTED: ' + halted.data.message;
else if (!frames.length) result = 'NO FRAMES';
else {
const px = new Uint32Array(frames[frames.length-1].data.pixels);
result = `ok ${frames.length} frames ${frames[0].data.width}x${frames[0].data.height}` + (px.some(p => p !== px[0]) ? '' : ' (blank)');
}
} catch (err) { result = 'FAIL: ' + String(err.message).split('\n')[0].slice(0, 140); }
console.log(`${id}\t${name}\t${result}`);
await e.w.terminate().catch(() => {});
}
process.exit(0);
})();
File renamed without changes.
+97
View File
@@ -0,0 +1,97 @@
// Breakpoints and how they resolve to an address, shared by every host (the
// IDE, the CLI, the VS Code debug adapter). Two kinds:
// - 'source' -- file + line, resolved to a PC via the build listing
// - 'address' -- a target that's a symbol, $hex or decimal address
// Both support an optional condition expression (see breakcond.ts).
// The IDE's store (persistence, gutter markers) is in src/ide/breakpoints.ts.
import type { Platform, SymbolMap } from "./baseplatform";
import type { CodeListing } from "./workertypes";
import { CondFn, CondContext, compileCondition, parseTarget } from "./breakcond";
import { lookupSymbol } from "./symbols/symbolfile";
export interface Breakpoint {
id: number;
type: 'source' | 'address';
file?: string; // source: project path
line?: number; // source: 1-based line number
target?: string; // address: symbol, $hex or decimal
enabled: boolean;
condition?: string;
}
export interface ResolvedBreakpoint {
bp: Breakpoint;
pc?: number; // resolved address (undefined if not resolvable now)
condFn?: CondFn;
error?: string; // why the breakpoint can't resolve right now
}
// What resolving a breakpoint needs from the host.
export interface BreakpointContext {
/** name -> address, from the build or a label file */
symbols?: SymbolMap;
/** the build's listing for a source file, for source breakpoints */
getListingForFile?(path: string): CodeListing | undefined;
/** CPU fields, memory and raster position, for conditions */
platform?: Platform;
}
function makeCondContext(ctx: BreakpointContext): CondContext {
let platform = ctx.platform;
let cpuFields = new Set<string>();
try {
let c = platform && platform.getCPUState && platform.getCPUState();
if (c) cpuFields = new Set(Object.keys(c));
} catch (e) {
}
// platform accessors reachable with the '#' sigil; the value is read live
// at eval time so it reflects the current raster position, etc.
let hw: { [name: string]: () => number | undefined } = null;
if (platform) {
if (platform.getRasterScanline) {
hw = hw || {};
hw['scanline'] = () => platform.getRasterScanline();
}
if (platform.getRasterLineClock) {
hw = hw || {};
hw['lineclock'] = () => platform.getRasterLineClock();
}
}
return {
cpuFields,
symbol: (name) => lookupSymbol(ctx.symbols, name),
readMem: platform && platform.readAddress ? (a) => platform.readAddress(a) : undefined,
readVRAM: platform && platform.readVRAMAddress ? (a) => platform.readVRAMAddress(a) : undefined,
hw,
};
}
// Can this platform actually stop at a breakpoint?
export function canUseBreakpoints(platform: Platform): boolean {
return !!(platform && (platform.runEval || platform.runToPC || platform.runEvalAtPC));
}
export function resolveBreakpoint(bp: Breakpoint, ctx: BreakpointContext): ResolvedBreakpoint {
try {
let pc: number;
if (bp.type == 'source') {
let lst = ctx.getListingForFile && ctx.getListingForFile(bp.file);
let sf = lst && (lst.sourcefile || lst.assemblyfile);
if (!sf) return { bp, error: "no debug info (build first?)" };
pc = sf.line2offset.get(bp.line);
if (!(pc >= 0)) return { bp, error: "line has no code" };
} else {
let r = parseTarget(bp.target, (name) => lookupSymbol(ctx.symbols, name));
if (r.error) return { bp, error: r.error };
pc = r.pc;
}
let condFn: CondFn = undefined;
if (bp.condition && bp.condition.trim()) {
condFn = compileCondition(bp.condition, makeCondContext(ctx));
}
return { bp, pc, condFn };
} catch (e) {
return { bp, error: String(e.message || e) };
}
}
+341
View File
@@ -0,0 +1,341 @@
// Unified headless driver for the emulators in this codebase.
//
// A *Platform* (src/platform/*) is the full object the IDE drives: it owns its
// own video/audio/timer and implements the Platform interface in
// common/baseplatform.ts. Most platforms are built on a *Machine* (src/machine/*),
// the bare emulation core made of the interfaces in common/devices.ts -- Bus,
// FrameBased, HasCPU, VideoSource and friends.
//
// EmuCore drives a Platform, reaching through to its Machine when there is
// one for the things only the core can do (exact traps, single stepping, the
// probe). The CLI and the run-script interpreter never have to ask which of
// those a target supports; the capability sniffing lives here.
//
// No DOM and no Node: start() swaps in headless video for the platform, and a
// host that already started the platform (the IDE) can wrap it without
// calling start(). The Node side (mocks, loading platform modules) is in
// src/tools/emutarget.ts.
import {
CpuState, DisasmLine, EmuState, Machine, Platform, hasProbe, isDebuggable,
} from "./baseplatform";
import { ProbeAll, TrapCondition } from "./devices";
import { disassemble6502 } from "./cpu/disasm6502";
import { disassembleZ80 } from "./cpu/disasmz80";
import { disassembleSM83 } from "./cpu/disasmSM83";
import { disassembleF8 } from "./cpu/disasmF8";
import { disassembleHuC6280 } from "./cpu/disasmHuC6280";
import { CPU6809 } from "./cpu/6809";
import * as emu from "./emu";
export interface VideoOutput {
pixels: Uint32Array;
width: number;
height: number;
/** display rotation in degrees (RasterVideo's `rotate` option) */
rotate?: number;
/** display aspect ratio, if not width/height */
aspect?: number;
}
export interface DebugSection {
category: string;
text: string;
}
/** Disassemblers, keyed by the `arch` name used in the worker's platform params. */
export const DISASSEMBLERS: { [arch: string]: (addr: number, read: (a: number) => number) => DisasmLine } = {
'6502': (a, r) => disassemble6502(a, r(a), r(a + 1), r(a + 2)),
'huc6280': (a, r) => disassembleHuC6280(a, r(a), r(a + 1), r(a + 2)),
'z80': (a, r) => disassembleZ80(a, r(a), r(a + 1), r(a + 2), r(a + 3)),
'gbz80': (a, r) => disassembleSM83(a, r(a), r(a + 1), r(a + 2)),
'sm83': (a, r) => disassembleSM83(a, r(a), r(a + 1), r(a + 2)),
'f8': (a, r) => disassembleF8(a, r(a), r(a + 1), r(a + 2)),
'6809': (a, r) => Object.create(CPU6809()).disasm(r(a), r(a + 1), r(a + 2), r(a + 3), r(a + 4), a),
};
/** CPU class name -> arch, for the platforms that don't implement disassemble(). */
const CPU_ARCH: { [cls: string]: string } = {
'MOS6502': '6502', 'HuC6280': 'huc6280',
'Z80': 'z80', 'ZilogZ80': 'z80',
'SM83': 'sm83', 'F8CPU': 'f8', 'CPU6809': '6809',
};
/** Work out which disassembler a CPU object wants. */
function archOf(cpu: any): string {
const arch = CPU_ARCH[cpu?.constructor?.name];
if (arch) return arch;
// CPU6809 is a factory returning a plain object, so it has no class name to
// match; it is also the only CPU here that carries its own disassembler.
if (typeof cpu?.disasm === 'function') return '6809';
return '';
}
// Frames to run before giving up on a `runUntil` predicate that never fires.
export const DEFAULT_MAX_FRAMES = 1000;
// Upper bound on clocks in one instruction, so stepping can't spin forever.
const MAX_CYCLES_PER_INSN = 64;
/**
* Headless stand-ins for RasterVideo/VectorVideo/AnimationTimer. Platform
* modules pick these up because Platform.start() reads them off the emu module.
*/
function installHeadlessVideo() {
// Platform.start() builds its video/timer by reading these classes off the
// emu module, so we swap in headless stand-ins for the duration of start()
// and put the real ones back afterwards. Leaving the stubs installed would
// leak into every other module sharing the (cached) emu import -- which is
// exactly what happens when mocha reuses a worker across test files.
let pixels: Uint32Array | null = null;
let params: { width: number, height: number, rotate?: number, aspect?: number } | null = null;
let frameRate = 60;
// the handler a platform registers on its canvas; it's how the IDE
// delivers keys, whether or not there's a Machine behind the platform
let keyHandler: ((key: number, code: number, flags: number) => void) | null = null;
const setKeyboardEvents = function (callback) { keyHandler ??= callback; };
const RasterVideo: any = function (_el: any, width: number, height: number, options?: { rotate?: number, aspect?: number }) {
const buffer = new ArrayBuffer(width * height * 4);
const datau8 = new Uint8Array(buffer);
const datau32 = new Uint32Array(buffer);
// the first one is the screen; later ones are debug views (nes nametables)
if (!pixels) {
params = { width, height, rotate: options?.rotate, aspect: options?.aspect };
pixels = datau32;
}
this.create = function () { this.width = width; this.height = height; };
this.setKeyboardEvents = setKeyboardEvents;
this.getFrameData = function () { return datau32; };
this.getImageData = function () { return { data: datau8, width, height }; };
this.updateFrame = function () { };
this.clearRect = function () { };
this.setupMouseEvents = function () { };
this.canvas = this;
this.getContext = function () { return this; };
this.fillRect = function () { };
this.fillStyle = '';
this.putImageData = function () { };
this.style = {};
};
const VectorVideo: any = function () {
this.create = function () { this.drawops = 0; };
this.setKeyboardEvents = setKeyboardEvents;
this.clear = function () { };
this.drawLine = function () { this.drawops++; };
};
const AnimationTimer: any = function (fps: number) {
if (fps > 0) frameRate = fps;
this.running = false;
this.start = function () { };
this.stop = function () { };
this.isRunning = function () { return this.running; };
};
const original = emu.setVideoClasses({ RasterVideo, VectorVideo, AnimationTimer });
return {
get(): VideoOutput | null {
return pixels && params ? { pixels, ...params } : null;
},
get frameRate() { return frameRate; },
get keyHandler() { return keyHandler; },
restore() {
emu.setVideoClasses(original);
}
};
}
export class EmuCore {
frameCount = 0;
private video: ReturnType<typeof installHeadlessVideo> | null = null;
constructor(readonly id: string, readonly platform: Platform) {
}
/**
* The underlying Machine, if there is one. Platforms built on
* BaseMachinePlatform expose theirs, which is what lets us stop mid-frame
* through the TrapCondition the FrameBased interface already defines,
* instead of guessing at cycle counts.
*/
get machine(): Machine | null {
return (this.platform as any).machine || null;
}
async start() {
// start() is where platforms construct their video and timer, so install
// the headless stand-ins just for that call, then restore the real classes.
const headless = installHeadlessVideo();
this.video = headless;
try {
await this.platform.start();
} finally {
headless.restore();
}
}
reset() { this.platform.reset(); }
loadROM(data: Uint8Array, title = 'ROM') { this.platform.loadROM(title, data); }
loadBIOS(data: Uint8Array, title = 'BIOS'): boolean {
if (!this.platform.loadBIOS) return false;
this.platform.loadBIOS(title, data);
return true;
}
read(addr: number): number {
if (this.platform.readAddress) return this.platform.readAddress(addr);
const m = this.machine;
if (m) return m.readConst ? m.readConst(addr) : m.read(addr);
throw new Error(`platform '${this.id}' cannot read memory`);
}
getCPUState(): CpuState | null {
try { return this.platform.getCPUState ? this.platform.getCPUState() : null; }
catch (e) { return null; }
}
getPC(): number | null {
// Platforms may correct the PC (see debugPCDelta), so prefer getPC().
try { if (this.platform.getPC) return this.platform.getPC(); } catch (e) { }
const s = this.getCPUState();
return s ? s.PC : null;
}
disassemble(addr: number): DisasmLine | null {
const read = (a: number) => this.read(a);
try {
if (this.platform.disassemble) return this.platform.disassemble(addr, read);
// a few platforms leave it out; fall back to the machine's CPU
const disasm = DISASSEMBLERS[archOf(this.machine?.cpu)];
return disasm ? disasm(addr, read) : null;
} catch (e) { return null; }
}
setKeyInput(key: number, code: number, flags: number) {
const handler = this.video?.keyHandler;
if (handler) return handler(key, code, flags);
const target: any = this.machine || this.platform;
if (typeof target.setKeyInput !== 'function') {
throw new Error(`platform '${this.id}' does not accept key input`);
}
target.setKeyInput(key, code, flags);
}
connectProbe(probe: ProbeAll | null): boolean {
const m = this.machine;
if (!m || !hasProbe(m)) return false;
m.connectProbe(probe);
return true;
}
getVideo(): VideoOutput | null { return this.video ? this.video.get() : null; }
/** Frames per second the platform's timer asked for (60 if unknown). */
get frameRate(): number { return this.video ? this.video.frameRate : 60; }
saveState(): EmuState | null { return this.platform.saveState ? this.platform.saveState() : null; }
getDebugInfo(): DebugSection[] {
const state = this.saveState();
const p: any = this.platform;
if (!state || !isDebuggable(p) || !p.getDebugCategories) return [];
const sections: DebugSection[] = [];
for (const category of p.getDebugCategories() || []) {
try {
const text = p.getDebugInfo(category, state);
if (text) sections.push({ category, text });
} catch (e) { /* a category that doesn't apply to this state */ }
}
return sections;
}
//// execution control, driven by what the target turns out to support
/** True if execution can be stopped mid-frame at an exact instruction. */
get supportsTrap(): boolean { return this.machine != null; }
/** True if single instructions can be stepped. */
get supportsStep(): boolean { return this.supportsTrap; }
/**
* A function that advances the CPU by the smallest amount the machine
* supports -- one clock for ClockBased CPUs, one instruction for
* InstructionBased ones -- without disturbing the frame loop. Null if the
* machine only knows how to run whole frames.
*/
private clockStepper(): (() => void) | null {
const m = this.machine as any;
if (!m) return null;
if (typeof m.advanceCPU === 'function') return () => m.advanceCPU(); // BasicHeadlessMachine
if (typeof m.advanceFrameClock === 'function') return () => m.advanceFrameClock(null, 1); // WASM
return null;
}
/**
* Finish any partly-executed instruction, so getPC() names a real
* instruction. Frames can end mid-instruction on clock-based CPUs.
*/
settle(): void {
const clock = this.clockStepper();
if (!clock) return;
for (let i = 0; i < MAX_CYCLES_PER_INSN && !this.isStable(); i++) clock();
}
advanceFrame(trap?: TrapCondition | null): void {
const m = this.machine;
// With a trap we drive the Machine directly -- Platform.advance() only
// honors traps registered as breakpoints, and installing/removing those
// rewinds BaseDebugPlatform's saved state.
if (trap && m) {
m.advanceFrame(trap);
} else {
const p = this.platform as any;
if (p.nextFrame) p.nextFrame();
else if (p.advance) p.advance(false);
}
this.frameCount++;
}
isStable(): boolean {
return this.machine ? this.machine.cpu.isStable() : true;
}
/** Execute exactly one instruction. Returns false if the target can't step. */
stepInsn(): boolean {
if (!this.machine) return false;
const clock = this.clockStepper();
if (clock) {
this.settle(); // don't count an in-flight instruction as this step
clock();
for (let i = 0; i < MAX_CYCLES_PER_INSN && !this.isStable(); i++) clock();
return true;
}
// No clock-level entry point: stop at the next instruction boundary via
// the frame trap. This restarts the frame, so it is the last resort.
let boundaries = 0;
for (let frames = 0; frames < 2 && boundaries < 2; frames++) {
this.advanceFrame(() => this.isStable() && ++boundaries >= 2);
}
return true;
}
/**
* Run until `pred` is true, up to `maxFrames` frames. Exact to the
* instruction on targets with a Machine; frame-granular otherwise.
*/
runUntil(pred: () => boolean, maxFrames = DEFAULT_MAX_FRAMES): boolean {
const start = this.frameCount;
while (this.frameCount - start < maxFrames) {
if (this.supportsTrap) {
let hit = false;
this.advanceFrame(() => (hit = this.isStable() && pred()));
if (hit) return true;
} else {
this.advanceFrame();
if (pred()) return true;
}
}
return false;
}
/** Run until the PC reaches one of `addrs`. */
runToPC(addrs: Set<number>, maxFrames = DEFAULT_MAX_FRAMES): boolean {
return this.runUntil(() => addrs.has(this.getPC()), maxFrames);
}
}
+28
View File
@@ -0,0 +1,28 @@
import type { SymbolMap } from "../baseplatform";
/** Parse a cc65/ca65 or VICE label file into a symbol map. */
export function parseSymbolFile(text: string): SymbolMap {
const symbols: SymbolMap = {};
for (const line of text.split(/\r?\n/)) {
const m1 = line.match(/^\s*([A-Za-z_][\w]*)\s*=\s*\$?([0-9A-Fa-f]+)\s*;/); // ca65/cc65 list
const m2 = line.match(/^\s*(?:al|add_label)\s+([0-9A-Fa-f]+)\s+\.?([A-Za-z_][\w]*)/); // VICE
if (m1) symbols[m1[1]] = parseInt(m1[2], 16);
else if (m2) symbols[m2[2]] = parseInt(m2[1], 16);
}
return symbols;
}
/**
* Look up a symbol the way a user types it: exactly, then with the leading
* underscore C compilers add (`main` finds `_main`), then without a leading
* '.' (VICE labels).
*/
export function lookupSymbol(symbols: SymbolMap | null | undefined, name: string): number | undefined {
if (!symbols) return undefined;
if (name in symbols) return symbols[name];
if ('_' + name in symbols) return symbols['_' + name];
const bare = name.replace(/^\./, '');
if (bare !== name && bare in symbols) return symbols[bare];
return undefined;
}
+16 -81
View File
@@ -1,30 +1,14 @@
// Unified breakpoint store, shared by the editor gutter markers and the
// Breakpoints window. Two kinds of breakpoints:
// - 'source' -- file + line, resolved to a PC via the build listing
// (these render as gutter markers in the editor)
// - 'address' -- a target that's a symbol, $hex or decimal address
// Both support an optional condition expression (see breakcond.ts).
// The store persists to localStorage, scoped by platform + main file.
// The IDE's breakpoint store, shared by the editor gutter markers and the
// Breakpoints window. It persists to localStorage, scoped by platform + main
// file. Breakpoint types and resolution are in src/common/breakpoints.ts.
import { current_project, platform } from "./ui";
import { CondFn, CondContext, compileCondition, parseTarget } from "./breakcond";
import {
Breakpoint, BreakpointContext, ResolvedBreakpoint,
canUseBreakpoints as platformCanUseBreakpoints, resolveBreakpoint as resolveIn,
} from "../common/breakpoints";
export interface Breakpoint {
id: number;
type: 'source' | 'address';
file?: string; // source: project path
line?: number; // source: 1-based line number
target?: string; // address: symbol, $hex or decimal
enabled: boolean;
condition?: string;
}
export interface ResolvedBreakpoint {
bp: Breakpoint;
pc?: number; // resolved address (undefined if not resolvable now)
condFn?: CondFn;
error?: string; // why the breakpoint can't resolve right now
}
export type { Breakpoint, ResolvedBreakpoint };
const STORAGE_KEY = '8bitworkshop.breakpoints.v1';
@@ -145,73 +129,24 @@ class BreakpointStore {
export const bpStore = new BreakpointStore();
function makeSymbolLookup(): (name: string) => number | undefined {
return (name: string): number | undefined => {
let sm = platform && platform.debugSymbols && platform.debugSymbols.symbolmap;
if (!sm) return undefined;
// C symbols often get a leading underscore from the compiler
return (name in sm) ? sm[name] : sm['_' + name];
};
}
function makeCondContext(): CondContext {
let cpuFields = new Set<string>();
try {
let c = platform && platform.getCPUState && platform.getCPUState();
if (c) cpuFields = new Set(Object.keys(c));
} catch (e) {
}
// platform accessors reachable with the '#' sigil; the value is read live
// at eval time so it reflects the current raster position, etc.
let hw: { [name: string]: () => number | undefined } = null;
if (platform) {
if (platform.getRasterScanline) {
hw = hw || {};
hw['scanline'] = () => platform.getRasterScanline();
}
if (platform.getRasterLineClock) {
hw = hw || {};
hw['lineclock'] = () => platform.getRasterLineClock();
}
}
function ideContext(): BreakpointContext {
return {
cpuFields,
symbol: makeSymbolLookup(),
readMem: platform && platform.readAddress ? (a) => platform.readAddress(a) : undefined,
readVRAM: platform && platform.readVRAMAddress ? (a) => platform.readVRAMAddress(a) : undefined,
hw,
symbols: platform && platform.debugSymbols && platform.debugSymbols.symbolmap,
getListingForFile: (path) => current_project && current_project.getListingForFile(path),
platform,
};
}
// Can this platform actually stop at a breakpoint?
export function canUseBreakpoints(): boolean {
return !!(platform && (platform.runEval || platform.runToPC || platform.runEvalAtPC));
return platformCanUseBreakpoints(platform);
}
export function resolveBreakpoint(bp: Breakpoint): ResolvedBreakpoint {
try {
let pc: number;
if (bp.type == 'source') {
let lst = current_project.getListingForFile(bp.file);
let sf = lst && (lst.sourcefile || lst.assemblyfile);
if (!sf) return { bp, error: "no debug info (build first?)" };
pc = sf.line2offset.get(bp.line);
if (!(pc >= 0)) return { bp, error: "line has no code" };
} else {
let r = parseTarget(bp.target, makeSymbolLookup());
if (r.error) return { bp, error: r.error };
pc = r.pc;
}
let condFn: CondFn = undefined;
if (bp.condition && bp.condition.trim()) {
condFn = compileCondition(bp.condition, makeCondContext());
}
return { bp, pc, condFn };
} catch (e) {
return { bp, error: String(e.message || e) };
}
return resolveIn(bp, ideContext());
}
export function resolveBreakpoints(): ResolvedBreakpoint[] {
return bpStore.getAll().map(bp => resolveBreakpoint(bp));
let ctx = ideContext();
return bpStore.getAll().map(bp => resolveIn(bp, ctx));
}
+1 -1
View File
@@ -38,7 +38,7 @@ import { ProjectWindows } from "./windows";
import { setupSplits, showTab } from "./layout";
export { setupSplits, showTab };
import { bpStore, canUseBreakpoints, resolveBreakpoints } from "./breakpoints";
import { CondFn } from "./breakcond";
import { CondFn } from "../common/breakcond";
import { findListingLocation as findListingLocationPure } from "./search/listinglocation";
import DOMPurify = require("dompurify");
+2 -1
View File
@@ -16,7 +16,8 @@ import * as path from 'path';
import { isProbablyBinary } from '../common/util';
import { fail, hasOutput, note, output, setJsonMode } from './cliformat';
import { EmuTarget, loadPlatform } from './emutarget';
import { RUN_SCRIPT_HELP, RunScript, parseNum, parseSymbolFile } from './runscript';
import { RUN_SCRIPT_HELP, RunScript, parseNum } from './runscript';
import { parseSymbolFile } from '../common/symbols/symbolfile';
import type { CompileResult } from './testlib';
interface Args {
+8 -335
View File
@@ -1,343 +1,16 @@
// Unified headless driver for the emulators in this codebase.
//
// A *Platform* (src/platform/*) is the full object the IDE drives: it owns its
// own video/audio/timer and implements the Platform interface in
// common/baseplatform.ts. Most platforms are built on a *Machine* (src/machine/*),
// the bare emulation core made of the interfaces in common/devices.ts -- Bus,
// FrameBased, HasCPU, VideoSource and friends.
//
// EmuTarget drives a Platform headlessly, reaching through to its Machine when
// there is one for the things only the core can do (exact traps, single
// stepping, the probe). The CLI and the run-script interpreter never have to
// ask which of those a target supports; the capability sniffing lives here.
// The Node side of headless emulation: browser mocks and platform loading.
// The emulator driver itself is EmuCore (src/common/emucore.ts); EmuTarget is
// the name the CLI and the extension know it by.
import {
CpuState, DisasmLine, EmuState, Machine, Platform, hasProbe, isDebuggable,
} from "../common/baseplatform";
import { ProbeAll, TrapCondition } from "../common/devices";
import { disassemble6502 } from "../common/cpu/disasm6502";
import { disassembleZ80 } from "../common/cpu/disasmz80";
import { disassembleSM83 } from "../common/cpu/disasmSM83";
import { disassembleF8 } from "../common/cpu/disasmF8";
import { disassembleHuC6280 } from "../common/cpu/disasmHuC6280";
import { CPU6809 } from "../common/cpu/6809";
import { EmuCore } from "../common/emucore";
import { PLATFORMS } from "../common/emu";
import * as emu from "../common/emu";
import { getRootBasePlatform } from "../common/util";
import { importPlatform } from "../platform/_index";
import { mockAudio, mockDOM, mockFetch, mockGlobals } from "./nodemock";
export interface VideoOutput {
pixels: Uint32Array;
width: number;
height: number;
/** display rotation in degrees (RasterVideo's `rotate` option) */
rotate?: number;
/** display aspect ratio, if not width/height */
aspect?: number;
}
export interface DebugSection {
category: string;
text: string;
}
/** Disassemblers, keyed by the `arch` name used in the worker's platform params. */
export const DISASSEMBLERS: { [arch: string]: (addr: number, read: (a: number) => number) => DisasmLine } = {
'6502': (a, r) => disassemble6502(a, r(a), r(a + 1), r(a + 2)),
'huc6280': (a, r) => disassembleHuC6280(a, r(a), r(a + 1), r(a + 2)),
'z80': (a, r) => disassembleZ80(a, r(a), r(a + 1), r(a + 2), r(a + 3)),
'gbz80': (a, r) => disassembleSM83(a, r(a), r(a + 1), r(a + 2)),
'sm83': (a, r) => disassembleSM83(a, r(a), r(a + 1), r(a + 2)),
'f8': (a, r) => disassembleF8(a, r(a), r(a + 1), r(a + 2)),
'6809': (a, r) => Object.create(CPU6809()).disasm(r(a), r(a + 1), r(a + 2), r(a + 3), r(a + 4), a),
};
/** CPU class name -> arch, for the platforms that don't implement disassemble(). */
const CPU_ARCH: { [cls: string]: string } = {
'MOS6502': '6502', 'HuC6280': 'huc6280',
'Z80': 'z80', 'ZilogZ80': 'z80',
'SM83': 'sm83', 'F8CPU': 'f8', 'CPU6809': '6809',
};
/** Work out which disassembler a CPU object wants. */
function archOf(cpu: any): string {
const arch = CPU_ARCH[cpu?.constructor?.name];
if (arch) return arch;
// CPU6809 is a factory returning a plain object, so it has no class name to
// match; it is also the only CPU here that carries its own disassembler.
if (typeof cpu?.disasm === 'function') return '6809';
return '';
}
// Frames to run before giving up on a `runUntil` predicate that never fires.
export const DEFAULT_MAX_FRAMES = 1000;
// Upper bound on clocks in one instruction, so stepping can't spin forever.
const MAX_CYCLES_PER_INSN = 64;
/**
* Headless stand-ins for RasterVideo/VectorVideo/AnimationTimer. Platform
* modules pick these up because Platform.start() reads them off the emu module.
*/
function installHeadlessVideo() {
// Platform.start() builds its video/timer by reading these classes off the
// emu module, so we swap in headless stand-ins for the duration of start()
// and put the real ones back afterwards. Leaving the stubs installed would
// leak into every other module sharing the (cached) emu import -- which is
// exactly what happens when mocha reuses a worker across test files.
let pixels: Uint32Array | null = null;
let params: { width: number, height: number, rotate?: number, aspect?: number } | null = null;
let frameRate = 60;
// the handler a platform registers on its canvas; it's how the IDE
// delivers keys, whether or not there's a Machine behind the platform
let keyHandler: ((key: number, code: number, flags: number) => void) | null = null;
const setKeyboardEvents = function (callback) { keyHandler ??= callback; };
const RasterVideo: any = function (_el: any, width: number, height: number, options?: { rotate?: number, aspect?: number }) {
const buffer = new ArrayBuffer(width * height * 4);
const datau8 = new Uint8Array(buffer);
const datau32 = new Uint32Array(buffer);
// the first one is the screen; later ones are debug views (nes nametables)
if (!pixels) {
params = { width, height, rotate: options?.rotate, aspect: options?.aspect };
pixels = datau32;
}
this.create = function () { this.width = width; this.height = height; };
this.setKeyboardEvents = setKeyboardEvents;
this.getFrameData = function () { return datau32; };
this.getImageData = function () { return { data: datau8, width, height }; };
this.updateFrame = function () { };
this.clearRect = function () { };
this.setupMouseEvents = function () { };
this.canvas = this;
this.getContext = function () { return this; };
this.fillRect = function () { };
this.fillStyle = '';
this.putImageData = function () { };
this.style = {};
};
const VectorVideo: any = function () {
this.create = function () { this.drawops = 0; };
this.setKeyboardEvents = setKeyboardEvents;
this.clear = function () { };
this.drawLine = function () { this.drawops++; };
};
const AnimationTimer: any = function (fps: number) {
if (fps > 0) frameRate = fps;
this.running = false;
this.start = function () { };
this.stop = function () { };
this.isRunning = function () { return this.running; };
};
const original = emu.setVideoClasses({ RasterVideo, VectorVideo, AnimationTimer });
return {
get(): VideoOutput | null {
return pixels && params ? { pixels, ...params } : null;
},
get frameRate() { return frameRate; },
get keyHandler() { return keyHandler; },
restore() {
emu.setVideoClasses(original);
}
};
}
export class EmuTarget {
frameCount = 0;
private video: ReturnType<typeof installHeadlessVideo> | null = null;
constructor(readonly id: string, readonly platform: Platform) {
}
/**
* The underlying Machine, if there is one. Platforms built on
* BaseMachinePlatform expose theirs, which is what lets us stop mid-frame
* through the TrapCondition the FrameBased interface already defines,
* instead of guessing at cycle counts.
*/
get machine(): Machine | null {
return (this.platform as any).machine || null;
}
async start() {
// start() is where platforms construct their video and timer, so install
// the headless stand-ins just for that call, then restore the real classes.
const headless = installHeadlessVideo();
this.video = headless;
try {
await this.platform.start();
} finally {
headless.restore();
}
}
reset() { this.platform.reset(); }
loadROM(data: Uint8Array, title = 'ROM') { this.platform.loadROM(title, data); }
loadBIOS(data: Uint8Array, title = 'BIOS'): boolean {
if (!this.platform.loadBIOS) return false;
this.platform.loadBIOS(title, data);
return true;
}
read(addr: number): number {
if (this.platform.readAddress) return this.platform.readAddress(addr);
const m = this.machine;
if (m) return m.readConst ? m.readConst(addr) : m.read(addr);
throw new Error(`platform '${this.id}' cannot read memory`);
}
getCPUState(): CpuState | null {
try { return this.platform.getCPUState ? this.platform.getCPUState() : null; }
catch (e) { return null; }
}
getPC(): number | null {
// Platforms may correct the PC (see debugPCDelta), so prefer getPC().
try { if (this.platform.getPC) return this.platform.getPC(); } catch (e) { }
const s = this.getCPUState();
return s ? s.PC : null;
}
disassemble(addr: number): DisasmLine | null {
const read = (a: number) => this.read(a);
try {
if (this.platform.disassemble) return this.platform.disassemble(addr, read);
// a few platforms leave it out; fall back to the machine's CPU
const disasm = DISASSEMBLERS[archOf(this.machine?.cpu)];
return disasm ? disasm(addr, read) : null;
} catch (e) { return null; }
}
setKeyInput(key: number, code: number, flags: number) {
const handler = this.video?.keyHandler;
if (handler) return handler(key, code, flags);
const target: any = this.machine || this.platform;
if (typeof target.setKeyInput !== 'function') {
throw new Error(`platform '${this.id}' does not accept key input`);
}
target.setKeyInput(key, code, flags);
}
connectProbe(probe: ProbeAll | null): boolean {
const m = this.machine;
if (!m || !hasProbe(m)) return false;
m.connectProbe(probe);
return true;
}
getVideo(): VideoOutput | null { return this.video ? this.video.get() : null; }
/** Frames per second the platform's timer asked for (60 if unknown). */
get frameRate(): number { return this.video ? this.video.frameRate : 60; }
saveState(): EmuState | null { return this.platform.saveState ? this.platform.saveState() : null; }
getDebugInfo(): DebugSection[] {
const state = this.saveState();
const p: any = this.platform;
if (!state || !isDebuggable(p) || !p.getDebugCategories) return [];
const sections: DebugSection[] = [];
for (const category of p.getDebugCategories() || []) {
try {
const text = p.getDebugInfo(category, state);
if (text) sections.push({ category, text });
} catch (e) { /* a category that doesn't apply to this state */ }
}
return sections;
}
//// execution control, driven by what the target turns out to support
/** True if execution can be stopped mid-frame at an exact instruction. */
get supportsTrap(): boolean { return this.machine != null; }
/** True if single instructions can be stepped. */
get supportsStep(): boolean { return this.supportsTrap; }
/**
* A function that advances the CPU by the smallest amount the machine
* supports -- one clock for ClockBased CPUs, one instruction for
* InstructionBased ones -- without disturbing the frame loop. Null if the
* machine only knows how to run whole frames.
*/
private clockStepper(): (() => void) | null {
const m = this.machine as any;
if (!m) return null;
if (typeof m.advanceCPU === 'function') return () => m.advanceCPU(); // BasicHeadlessMachine
if (typeof m.advanceFrameClock === 'function') return () => m.advanceFrameClock(null, 1); // WASM
return null;
}
/**
* Finish any partly-executed instruction, so getPC() names a real
* instruction. Frames can end mid-instruction on clock-based CPUs.
*/
settle(): void {
const clock = this.clockStepper();
if (!clock) return;
for (let i = 0; i < MAX_CYCLES_PER_INSN && !this.isStable(); i++) clock();
}
advanceFrame(trap?: TrapCondition | null): void {
const m = this.machine;
// With a trap we drive the Machine directly -- Platform.advance() only
// honors traps registered as breakpoints, and installing/removing those
// rewinds BaseDebugPlatform's saved state.
if (trap && m) {
m.advanceFrame(trap);
} else {
const p = this.platform as any;
if (p.nextFrame) p.nextFrame();
else if (p.advance) p.advance(false);
}
this.frameCount++;
}
isStable(): boolean {
return this.machine ? this.machine.cpu.isStable() : true;
}
/** Execute exactly one instruction. Returns false if the target can't step. */
stepInsn(): boolean {
if (!this.machine) return false;
const clock = this.clockStepper();
if (clock) {
this.settle(); // don't count an in-flight instruction as this step
clock();
for (let i = 0; i < MAX_CYCLES_PER_INSN && !this.isStable(); i++) clock();
return true;
}
// No clock-level entry point: stop at the next instruction boundary via
// the frame trap. This restarts the frame, so it is the last resort.
let boundaries = 0;
for (let frames = 0; frames < 2 && boundaries < 2; frames++) {
this.advanceFrame(() => this.isStable() && ++boundaries >= 2);
}
return true;
}
/**
* Run until `pred` is true, up to `maxFrames` frames. Exact to the
* instruction on targets with a Machine; frame-granular otherwise.
*/
runUntil(pred: () => boolean, maxFrames = DEFAULT_MAX_FRAMES): boolean {
const start = this.frameCount;
while (this.frameCount - start < maxFrames) {
if (this.supportsTrap) {
let hit = false;
this.advanceFrame(() => (hit = this.isStable() && pred()));
if (hit) return true;
} else {
this.advanceFrame();
if (pred()) return true;
}
}
return false;
}
/** Run until the PC reaches one of `addrs`. */
runToPC(addrs: Set<number>, maxFrames = DEFAULT_MAX_FRAMES): boolean {
return this.runUntil(() => addrs.has(this.getPC()), maxFrames);
}
}
export { EmuCore as EmuTarget, DISASSEMBLERS, DEFAULT_MAX_FRAMES } from "../common/emucore";
export type { VideoOutput, DebugSection } from "../common/emucore";
type EmuTarget = EmuCore;
/** Load a platform module by ID (e.g. "nes", "c64.wasm", "atari8-5200"). */
export async function loadPlatform(platformId: string): Promise<EmuTarget> {
@@ -349,7 +22,7 @@ export async function loadPlatform(platformId: string): Promise<EmuTarget> {
if (!PlatformClass) {
throw new Error(`Platform '${platformId}' not found. Available: ${Object.keys(PLATFORMS).sort().join(', ')}`);
}
return new EmuTarget(platformId, new PlatformClass(null));
return new EmuCore(platformId, new PlatformClass(null));
}
let mocksInstalled = false;
+5 -15
View File
@@ -9,6 +9,8 @@
import { KeyFlags } from '../common/emu';
import { ProbeFlags, ProbeRecorder } from '../common/probe';
import { hex } from '../common/util';
import type { SymbolMap } from '../common/baseplatform';
import { lookupSymbol } from '../common/symbols/symbolfile';
import { hexdump, write } from './cliformat';
import { DEFAULT_MAX_FRAMES, EmuTarget } from './emutarget';
@@ -105,13 +107,13 @@ export function formatRegs(state: any): string {
const PROBE_BUFFER_SIZE = 0x400000;
export class RunScript {
symbols: { [name: string]: number } = {};
symbols: SymbolMap = {};
private addr2symbol: { [addr: number]: string } = {};
private probe: ProbeRecorder | null = null;
constructor(readonly target: EmuTarget, private out = write) { }
addSymbols(symbols: { [name: string]: number }) {
addSymbols(symbols: SymbolMap) {
Object.assign(this.symbols, symbols);
for (const [name, addr] of Object.entries(this.symbols)) this.addr2symbol[addr] = name;
}
@@ -151,7 +153,7 @@ export class RunScript {
private addr(tok: string): number {
try { return parseNum(tok); }
catch (e) {
const v = this.symbols[tok] ?? this.symbols[tok.replace(/^\./, '')];
const v = lookupSymbol(this.symbols, tok);
if (v == null) throw new Error(`unknown address or symbol '${tok}'`);
return v;
}
@@ -368,15 +370,3 @@ const COMMANDS: { [name: string]: Command } = {
'reset': RunScript.prototype.cmdReset,
'echo': RunScript.prototype.cmdEcho,
};
/** Parse a cc65/ca65 or VICE label file into a symbol map. */
export function parseSymbolFile(text: string): { [name: string]: number } {
const symbols: { [name: string]: number } = {};
for (const line of text.split(/\r?\n/)) {
const m1 = line.match(/^\s*([A-Za-z_][\w]*)\s*=\s*\$?([0-9A-Fa-f]+)\s*;/); // ca65/cc65 list
const m2 = line.match(/^\s*(?:al|add_label)\s+([0-9A-Fa-f]+)\s+\.?([A-Za-z_][\w]*)/); // VICE
if (m1) symbols[m1[1]] = parseInt(m1[2], 16);
else if (m2) symbols[m2[2]] = parseInt(m2[1], 16);
}
return symbols;
}
+4
View File
@@ -85,6 +85,10 @@ describe('8bws CLI', function () {
assert.ok(/break \$[0-9A-F]+: HIT/.test(out), out);
assert.ok(/_main:/.test(out), out);
});
it('should find a C symbol without its underscore', function () {
var out = cli('run', '--platform', 'gb', 'presets/gb/hello.c', '-e', 'break main 600; pc 1');
assert.ok(/break \$[0-9A-F]+: HIT/.test(out), out);
});
it('should write a screenshot', function () {
var png = path.join(os.tmpdir(), '8bws-test-shot.png');
if (fs.existsSync(png)) fs.unlinkSync(png);
+60 -1
View File
@@ -1,5 +1,8 @@
import { describe } from "mocha";
import { compileCondition, parseTarget } from "../../src/ide/breakcond";
import { compileCondition, parseTarget } from "../../src/common/breakcond";
import { Breakpoint, BreakpointContext, canUseBreakpoints, resolveBreakpoint } from "../../src/common/breakpoints";
import { lookupSymbol, parseSymbolFile } from "../../src/common/symbols/symbolfile";
import { SourceFile } from "../../src/common/workertypes";
import assert from "assert";
const ctx = {
@@ -142,3 +145,59 @@ describe('Breakpoints', () => {
assert.ok(parseTarget('12ab', sym).error); // neither number nor symbol
});
});
describe('Breakpoint resolution (shared by IDE, CLI, debug adapter)', () => {
const symbols = { _main: 0x1234, mainloop: 0x800, vblank: 0x900 };
const sourcefile = new SourceFile([{ line: 10, offset: 0x1234 } as any], '');
const ctx: BreakpointContext = {
symbols,
getListingForFile: (path) => path == 'game.c' ? { lines: [], sourcefile } as any : undefined,
platform: {
getCPUState: () => ({ PC: 0x1234, A: 7 }),
readAddress: (a: number) => (a == 0x10 ? 0x2a : 0),
} as any,
};
const bp = (patch: Partial<Breakpoint>): Breakpoint => ({ id: 1, type: 'address', enabled: true, ...patch });
it('should resolve a symbol, with or without the C underscore', () => {
assert.equal(resolveBreakpoint(bp({ target: 'mainloop' }), ctx).pc, 0x800);
assert.equal(resolveBreakpoint(bp({ target: '_main' }), ctx).pc, 0x1234);
assert.equal(resolveBreakpoint(bp({ target: 'main' }), ctx).pc, 0x1234);
assert.ok(resolveBreakpoint(bp({ target: 'nosuch' }), ctx).error);
});
it('should resolve a source line through the listing', () => {
assert.equal(resolveBreakpoint(bp({ type: 'source', file: 'game.c', line: 10 }), ctx).pc, 0x1234);
assert.equal(resolveBreakpoint(bp({ type: 'source', file: 'game.c', line: 11 }), ctx).error, 'line has no code');
assert.equal(resolveBreakpoint(bp({ type: 'source', file: 'other.c', line: 10 }), ctx).error, 'no debug info (build first?)');
});
it('should compile a condition against the host platform', () => {
let r = resolveBreakpoint(bp({ target: '$800', condition: 'A == 7 && [$10] == 42' }), ctx);
assert.equal(r.pc, 0x800);
assert.equal(r.condFn({ A: 7 }), true);
assert.equal(r.condFn({ A: 6 }), false);
});
it('should work without a platform or listings', () => {
assert.equal(resolveBreakpoint(bp({ target: '$c000' }), {}).pc, 0xc000);
assert.ok(resolveBreakpoint(bp({ type: 'source', file: 'game.c', line: 10 }), {}).error);
});
it('should report whether a platform can stop at breakpoints', () => {
assert.equal(canUseBreakpoints(null), false);
assert.equal(canUseBreakpoints({ runEval: () => { } } as any), true);
});
});
describe('Symbol files', () => {
it('should parse ca65 and VICE label files', () => {
let syms = parseSymbolFile('main = $1234 ;\nal 00C000 .vblank\nadd_label 0800 loop\n');
assert.deepEqual(syms, { main: 0x1234, vblank: 0xc000, loop: 0x800 });
});
it('should look up names as a user types them', () => {
let syms = { _main: 1, loop: 2 };
assert.equal(lookupSymbol(syms, '_main'), 1);
assert.equal(lookupSymbol(syms, 'main'), 1);
assert.equal(lookupSymbol(syms, '.loop'), 2);
assert.equal(lookupSymbol(syms, 'nope'), undefined);
assert.equal(lookupSymbol(null, 'main'), undefined);
});
});