mirror of
https://github.com/sehugg/8bitworkshop.git
synced 2026-10-25 00:02:16 +00:00
cli: rewrite; symbols and tracing
This commit is contained in:
1 parent
22fc4b8219
commit
dea718b91a
8 files changed
+1346
-959
No files matched your search
+2
-1
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
"bin": {
|
||||
"8bws": "./gen/cli/8bws.js"
|
||||
"8bws": "./gen/tools/8bws.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.2",
|
||||
@@ -90,6 +90,7 @@
|
||||
"test-profile": "NODE_PATH=$(pwd) mocha --recursive --timeout 60000 --prof test/cli gen/test",
|
||||
"test-worker": "NODE_PATH=$(pwd) mocha --timeout 60000 test/cli/testworker.js",
|
||||
"test-platforms": "NODE_PATH=$(pwd) mocha --timeout 60000 test/cli/testplatforms.js",
|
||||
"test-cli": "NODE_PATH=$(pwd) mocha --timeout 60000 test/cli/testcli.js",
|
||||
"test-services": "NODE_PATH=$(pwd) mocha --timeout 60000 test/services",
|
||||
"test-verilog": "NODE_PATH=$(pwd) mocha --timeout 60000 --reporter mocha-simple-html-reporter --reporter-options output=test/output/verilog.html test/verilog/testverilog.js",
|
||||
"test-web-quick": "nightwatch -e chrome test/web/testembed.js",
|
||||
|
||||
+268
-755
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,201 @@
|
||||
// Terminal output helpers for the 8bws CLI.
|
||||
//
|
||||
// Every command returns a CLIResult; output() renders it either as JSON
|
||||
// (--json) or as colorized text. Keeping this out of 8bws.ts leaves the
|
||||
// command implementations free of ANSI noise.
|
||||
|
||||
import { hex } from '../common/util';
|
||||
|
||||
export interface CLIResult {
|
||||
success: boolean;
|
||||
command: string;
|
||||
data?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const c = {
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
white: '\x1b[37m',
|
||||
bgRed: '\x1b[41m',
|
||||
bgGreen: '\x1b[42m',
|
||||
};
|
||||
|
||||
let jsonMode = false;
|
||||
let emit = console.log;
|
||||
|
||||
/**
|
||||
* In --json mode stdout carries the CLIResult and nothing else, so the
|
||||
* compiler's chatter (plain console.log from the worker) is pushed to stderr.
|
||||
*/
|
||||
export function setJsonMode(on: boolean) {
|
||||
jsonMode = on;
|
||||
if (!on) return;
|
||||
emit = console.log.bind(console);
|
||||
console.log = (...args: any[]) => process.stderr.write(args.join(' ') + '\n');
|
||||
}
|
||||
export function isJsonMode() { return jsonMode; }
|
||||
|
||||
/** Human-facing output: stdout normally, stderr in --json mode. */
|
||||
export function write(s: string): void {
|
||||
(jsonMode ? process.stderr : process.stdout).write(s);
|
||||
}
|
||||
|
||||
// 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`);
|
||||
}
|
||||
|
||||
let emitted = false;
|
||||
|
||||
/** True once a CLIResult has been printed -- lets main() detect a silent exit. */
|
||||
export function hasOutput(): boolean { return emitted; }
|
||||
|
||||
export function output(result: CLIResult): void {
|
||||
emitted = true;
|
||||
if (jsonMode) {
|
||||
emit(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
process.stderr.write(result.success
|
||||
? `${c.bgGreen}${c.bold}${c.white} OK ${c.reset} `
|
||||
: `${c.bgRed}${c.bold}${c.white} FAIL ${c.reset} `);
|
||||
process.stderr.write(`${c.bold}${c.cyan}${result.command}${c.reset}\n`);
|
||||
if (result.error) {
|
||||
process.stderr.write(`${c.red}Error: ${result.error}${c.reset}\n`);
|
||||
}
|
||||
if (result.data) {
|
||||
(FORMATTERS[result.command] || formatGeneric)(result.data);
|
||||
}
|
||||
}
|
||||
|
||||
// exit with a formatted error message
|
||||
export function fail(command: string, error: string, data?: any): never {
|
||||
output({ success: false, command, error, data });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const FORMATTERS: { [command: string]: (data: any) => void } = {
|
||||
help: formatHelp,
|
||||
build: formatBuild,
|
||||
run: formatRun,
|
||||
'list-tools': formatList('tools', 'Available tools'),
|
||||
'list-platforms': formatPlatforms,
|
||||
};
|
||||
|
||||
function formatHelp(data: any): void {
|
||||
console.log(`\n${c.bold}Usage:${c.reset} 8bws <command> [options] <file>\n`);
|
||||
console.log(`${c.bold}Commands:${c.reset}`);
|
||||
for (const [cmd, usage] of Object.entries(data.commands || {})) {
|
||||
console.log(` ${c.green}${cmd.padEnd(14)}${c.reset}${c.dim}${usage}${c.reset}`);
|
||||
}
|
||||
if (data.options) {
|
||||
for (const [section, opts] of Object.entries(data.options as any)) {
|
||||
console.log(`\n${c.bold}${section}:${c.reset}`);
|
||||
for (const [flag, desc] of Object.entries(opts as any)) {
|
||||
console.log(` ${c.yellow}${flag.padEnd(22)}${c.reset}${c.dim}${desc}${c.reset}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.script) {
|
||||
console.log(`\n${c.bold}Script commands${c.reset} ${c.dim}(-e / --script)${c.reset}\n${data.script}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
function formatBuild(data: any): void {
|
||||
if (data.errors) {
|
||||
for (const err of data.errors) {
|
||||
let loc = '';
|
||||
if (err.path) loc += `${c.cyan}${err.path}${c.reset}`;
|
||||
if (err.line) loc += `${c.dim}:${c.reset}${c.yellow}${err.line}${c.reset}`;
|
||||
if (loc) loc += ` ${c.dim}-${c.reset} `;
|
||||
console.log(` ${c.red}●${c.reset} ${loc}${err.msg || err.message || JSON.stringify(err)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
field('Tool', data.tool, c.green);
|
||||
field('Platform', data.platform, c.green);
|
||||
field('Source', data.source, c.cyan);
|
||||
if (data.outputSize != null) field('Size', `${data.outputSize} bytes`, c.yellow);
|
||||
field('Output', data.outputFile, c.cyan);
|
||||
if (data.symbolmap) {
|
||||
console.log(`\n${c.bold}Symbols${c.reset} ${c.dim}(${Object.keys(data.symbolmap).length})${c.reset}`);
|
||||
for (const [name, addr] of Object.entries(data.symbolmap).sort((a: any, b: any) => a[1] - b[1])) {
|
||||
console.log(` ${c.cyan}$${hex(addr as number, 4)}${c.reset} ${name}`);
|
||||
}
|
||||
}
|
||||
if (data.segments) {
|
||||
console.log(`\n${c.bold}Segments${c.reset} ${c.dim}(${data.segments.length})${c.reset}`);
|
||||
for (const seg of data.segments) {
|
||||
console.log(` ${c.green}${seg.name.padEnd(16)}${c.reset} ${c.cyan}$${hex(seg.start, 4)}${c.reset} ${c.dim}size${c.reset} ${c.yellow}${seg.size}${c.reset}`);
|
||||
}
|
||||
}
|
||||
if (data.saveDir) {
|
||||
console.log(`\n${c.bold}Saved to${c.reset} ${c.cyan}${data.saveDir}${c.reset} ${c.dim}(${data.savedFiles.length} files)${c.reset}`);
|
||||
for (const f of data.savedFiles) console.log(` ${c.dim}●${c.reset} ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatRun(data: any): void {
|
||||
field('Platform', data.platform, c.green);
|
||||
field('ROM', data.rom, c.cyan);
|
||||
if (data.frames != null) field('Frames', data.frames, c.yellow);
|
||||
if (data.width) field('Video', `${data.width}x${data.height}`, c.yellow);
|
||||
field('Screenshot', data.png, c.cyan);
|
||||
}
|
||||
|
||||
function formatList(key: string, title: string) {
|
||||
return (data: any) => {
|
||||
console.log(`\n${c.bold}${title}${c.reset} ${c.dim}(${data[key].length})${c.reset}\n`);
|
||||
for (const item of data[key]) console.log(` ${c.green}●${c.reset} ${item}`);
|
||||
console.log();
|
||||
};
|
||||
}
|
||||
|
||||
function formatPlatforms(data: any): void {
|
||||
console.log(`\n${c.bold}Available platforms${c.reset} ${c.dim}(${data.count})${c.reset}\n`);
|
||||
const byArch: { [arch: string]: string[] } = {};
|
||||
for (const [name, info] of Object.entries(data.platforms) as [string, any][]) {
|
||||
(byArch[info.arch || 'unknown'] ||= []).push(name);
|
||||
}
|
||||
for (const [arch, platforms] of Object.entries(byArch).sort()) {
|
||||
console.log(` ${c.bold}${c.magenta}${arch}${c.reset}`);
|
||||
for (const p of platforms.sort()) console.log(` ${c.green}●${c.reset} ${p}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
function formatGeneric(data: any): void {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const v = (typeof value === 'object' && value !== null) ? JSON.stringify(value) : value;
|
||||
console.log(` ${c.dim}${key}:${c.reset} ${v}`);
|
||||
}
|
||||
}
|
||||
|
||||
function field(label: string, value: any, color: string): void {
|
||||
if (value == null || value === false) return;
|
||||
console.log(` ${c.dim}${(label + ':').padEnd(11)}${c.reset}${color}${value}${c.reset}`);
|
||||
}
|
||||
|
||||
// shared hexdump used by 'mem' and --memdump
|
||||
export function hexdump(read: (addr: number) => number, start: number, end: number, out = write): void {
|
||||
for (let ofs = 0; start + ofs <= end; ofs += 16) {
|
||||
let line = `${hex(start + ofs, 4)}:`;
|
||||
let ascii = '';
|
||||
for (let i = 0; i < 16 && start + ofs + i <= end; i++) {
|
||||
if (i === 8) line += ' ';
|
||||
const byte = read(start + ofs + i);
|
||||
line += ` ${hex(byte)}`;
|
||||
ascii += (byte >= 0x20 && byte < 0x7f) ? String.fromCharCode(byte) : '.';
|
||||
}
|
||||
out(`${line.padEnd(60)} ${ascii}\n`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
// 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.
|
||||
|
||||
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 { PLATFORMS } from "../common/emu";
|
||||
import * as emu from "../common/emu";
|
||||
import { getRootBasePlatform } from "../common/util";
|
||||
import { mockAudio, mockDOM, mockFetch, mockGlobals } from "./nodemock";
|
||||
|
||||
export interface VideoOutput {
|
||||
pixels: Uint32Array;
|
||||
width: number;
|
||||
height: 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() {
|
||||
let pixels: Uint32Array | null = null;
|
||||
let params: { width: number, height: number } | null = null;
|
||||
(emu as any).RasterVideo = function (_el: any, width: number, height: number) {
|
||||
const buffer = new ArrayBuffer(width * height * 4);
|
||||
const datau8 = new Uint8Array(buffer);
|
||||
const datau32 = new Uint32Array(buffer);
|
||||
params = { width, height };
|
||||
pixels = datau32;
|
||||
this.create = function () { this.width = width; this.height = height; };
|
||||
this.setKeyboardEvents = function () { };
|
||||
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 = {};
|
||||
};
|
||||
(emu as any).VectorVideo = function () {
|
||||
this.create = function () { this.drawops = 0; };
|
||||
this.setKeyboardEvents = function () { };
|
||||
this.clear = function () { };
|
||||
this.drawLine = function () { this.drawops++; };
|
||||
};
|
||||
(emu as any).AnimationTimer = function () {
|
||||
this.running = false;
|
||||
this.start = function () { };
|
||||
this.stop = function () { };
|
||||
this.isRunning = function () { return this.running; };
|
||||
};
|
||||
return {
|
||||
get(): VideoOutput | null {
|
||||
return pixels && params ? { pixels, width: params.width, height: params.height } : null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class EmuTarget {
|
||||
frameCount = 0;
|
||||
private video: ReturnType<typeof installHeadlessVideo>;
|
||||
|
||||
constructor(readonly id: string, readonly platform: Platform) {
|
||||
this.video = installHeadlessVideo();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() { await this.platform.start(); }
|
||||
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 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.get(); }
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** Load a platform module by ID (e.g. "nes", "c64.wasm", "atari8-5200"). */
|
||||
export async function loadPlatform(platformId: string): Promise<EmuTarget> {
|
||||
installNodeMocks();
|
||||
const baseId = getRootBasePlatform(platformId);
|
||||
await import('../platform/' + baseId);
|
||||
const PlatformClass = PLATFORMS[platformId] || PLATFORMS[baseId];
|
||||
if (!PlatformClass) {
|
||||
throw new Error(`Platform '${platformId}' not found. Available: ${Object.keys(PLATFORMS).sort().join(', ')}`);
|
||||
}
|
||||
return new EmuTarget(platformId, new PlatformClass(null));
|
||||
}
|
||||
|
||||
let mocksInstalled = false;
|
||||
|
||||
/** Stub out the browser APIs that the platform modules expect. */
|
||||
export function installNodeMocks() {
|
||||
if (mocksInstalled) return;
|
||||
mocksInstalled = true;
|
||||
mockGlobals();
|
||||
mockAudio();
|
||||
mockFetch();
|
||||
mockDOM();
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
|
||||
import { hasAudio, hasSerialIO, hasVideo, Machine, Platform } from "../common/baseplatform";
|
||||
import { SerialIOInterface } from "../common/devices";
|
||||
import { PLATFORMS } from "../common/emu";
|
||||
import * as emu from "../common/emu";
|
||||
import { getRootBasePlatform } from "../common/util";
|
||||
import { mockAudio, mockDOM, mockFetch, mockGlobals, NullAudio } from "./nodemock";
|
||||
|
||||
// TODO: merge with platform
|
||||
class SerialTestHarness implements SerialIOInterface {
|
||||
|
||||
bufferedRead: boolean = true;
|
||||
cyclesPerByte = 1000000 / (57600 / 8); // 138.88888 cycles
|
||||
maxOutputBytes = 4096;
|
||||
inputBytes: Uint8Array;
|
||||
|
||||
outputBytes: number[];
|
||||
inputIndex: number;
|
||||
clk: number;
|
||||
bufin: string;
|
||||
|
||||
clearToSend(): boolean {
|
||||
return this.outputBytes.length < this.maxOutputBytes;
|
||||
}
|
||||
sendByte(b: number) {
|
||||
if (this.clearToSend()) {
|
||||
this.outputBytes.push(b);
|
||||
}
|
||||
}
|
||||
byteAvailable(): boolean {
|
||||
return this.readIndex() > this.inputIndex;
|
||||
}
|
||||
recvByte(): number {
|
||||
var index = this.readIndex();
|
||||
this.inputIndex = index;
|
||||
var b = this.inputBytes[index] | 0;
|
||||
return b;
|
||||
}
|
||||
readIndex(): number {
|
||||
return this.bufferedRead ? (this.inputIndex + 1) : Math.floor(this.clk / this.cyclesPerByte);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.inputIndex = -1;
|
||||
this.clk = 0;
|
||||
this.outputBytes = [];
|
||||
this.bufin = '';
|
||||
}
|
||||
|
||||
advance(clocks: number) {
|
||||
this.clk += clocks;
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
|
||||
// Headless mock for RasterVideo/VectorVideo (used by Platform.start() in Node)
|
||||
// Patches the emu module exports so that platform modules pick up the mock.
|
||||
function installHeadlessVideo() {
|
||||
var lastPixels: Uint32Array | null = null;
|
||||
var lastVideoParams: { width: number; height: number } | null = null;
|
||||
(emu as any).RasterVideo = function (_mainElement: any, width: number, height: number, _options?: any) {
|
||||
var buffer = new ArrayBuffer(width * height * 4);
|
||||
var datau8 = new Uint8Array(buffer);
|
||||
var datau32 = new Uint32Array(buffer);
|
||||
lastVideoParams = { width, height };
|
||||
lastPixels = datau32;
|
||||
this.create = function () { this.width = width; this.height = height; };
|
||||
this.setKeyboardEvents = function () { };
|
||||
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 = {};
|
||||
};
|
||||
(emu as any).VectorVideo = function (_mainElement: any, _width: number, _height: number, _options?: any) {
|
||||
this.create = function () { this.drawops = 0; };
|
||||
this.setKeyboardEvents = function () { };
|
||||
this.clear = function () { };
|
||||
this.drawLine = function () { this.drawops++; };
|
||||
};
|
||||
// Also mock AnimationTimer to be a no-op in headless mode
|
||||
(emu as any).AnimationTimer = function (_fps: number, _callback: any) {
|
||||
this.running = false;
|
||||
this.start = function () { };
|
||||
this.stop = function () { };
|
||||
this.isRunning = function () { return this.running; };
|
||||
};
|
||||
return { getPixels: () => lastPixels, getVideoParams: () => lastVideoParams };
|
||||
}
|
||||
|
||||
///
|
||||
|
||||
export class PlatformRunner {
|
||||
platform: Platform;
|
||||
private headless: ReturnType<typeof installHeadlessVideo>;
|
||||
|
||||
constructor(platform: Platform) {
|
||||
mockGlobals();
|
||||
mockAudio();
|
||||
mockFetch();
|
||||
mockDOM();
|
||||
this.platform = platform;
|
||||
this.headless = installHeadlessVideo();
|
||||
}
|
||||
async start() {
|
||||
await this.platform.start();
|
||||
}
|
||||
loadROM(title: string, data: Uint8Array) {
|
||||
this.platform.loadROM(title, data);
|
||||
}
|
||||
run() {
|
||||
// nextFrame() is on BaseDebugPlatform, not the Platform interface
|
||||
if ((this.platform as any).nextFrame) {
|
||||
(this.platform as any).nextFrame();
|
||||
} else if (this.platform.advance) {
|
||||
this.platform.advance(false);
|
||||
}
|
||||
}
|
||||
get pixels(): Uint32Array | null {
|
||||
return this.headless.getPixels();
|
||||
}
|
||||
get videoParams(): { width: number; height: number } | null {
|
||||
return this.headless.getVideoParams();
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadPlatform(platformId: string): Promise<Platform> {
|
||||
// Derive the base module name (e.g. "nes" from "nes-asm", "c64" from "c64.wasm")
|
||||
var baseId = getRootBasePlatform(platformId);
|
||||
// Dynamically load the platform module which registers into PLATFORMS
|
||||
await import('../platform/' + baseId);
|
||||
var PlatformClass = PLATFORMS[platformId];
|
||||
if (!PlatformClass) {
|
||||
// Try the base platform ID
|
||||
PlatformClass = PLATFORMS[baseId];
|
||||
}
|
||||
if (!PlatformClass) {
|
||||
var available = Object.keys(PLATFORMS).join(', ');
|
||||
throw new Error(`Platform '${platformId}' not found. Available: ${available}`);
|
||||
}
|
||||
var platform = new PlatformClass(null);
|
||||
return platform;
|
||||
}
|
||||
|
||||
///
|
||||
|
||||
export class MachineRunner {
|
||||
machine: Machine;
|
||||
pixels: Uint32Array;
|
||||
serial: SerialTestHarness;
|
||||
|
||||
constructor(machine: Machine) {
|
||||
this.machine = machine;
|
||||
}
|
||||
setup() {
|
||||
if (hasVideo(this.machine)) {
|
||||
var vid = this.machine.getVideoParams();
|
||||
this.pixels = new Uint32Array(vid.width * vid.height);
|
||||
this.machine.connectVideo(this.pixels);
|
||||
}
|
||||
if (hasAudio(this.machine)) {
|
||||
this.machine.connectAudio(new NullAudio());
|
||||
}
|
||||
if (hasSerialIO(this.machine)) {
|
||||
this.serial = new SerialTestHarness();
|
||||
this.machine.connectSerialIO(this.serial);
|
||||
}
|
||||
this.machine.reset();
|
||||
}
|
||||
run() {
|
||||
this.machine.advanceFrame(null);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadMachine(modname: string, clsname: string, ...ctorArgs: any[]): Promise<Machine> {
|
||||
var mod = await import('../machine/' + modname);
|
||||
var cls = mod[clsname];
|
||||
if (!cls) {
|
||||
throw new Error(`Class '${clsname}' not found in module '../machine/${modname}'`);
|
||||
}
|
||||
var machine = new cls(...ctorArgs);
|
||||
return machine;
|
||||
}
|
||||
|
||||
async function runMachine() {
|
||||
var machine = await loadMachine(process.argv[2], process.argv[3]);
|
||||
var runner = new MachineRunner(machine);
|
||||
runner.setup();
|
||||
runner.run();
|
||||
console.log(runner.machine.saveState());
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
runMachine();
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// Run-script interpreter: a small command language for driving a headless
|
||||
// emulator. One command per line, separated by newlines or ';'; '#' or '//'
|
||||
// starts a comment.
|
||||
//
|
||||
// Everything here talks to an EmuTarget, so the same script works against a
|
||||
// Platform (--platform) or a bare Machine (--machine). Commands that need a
|
||||
// capability the target lacks report that instead of failing silently.
|
||||
|
||||
import { KeyFlags } from '../common/emu';
|
||||
import { ProbeFlags, ProbeRecorder } from '../common/probe';
|
||||
import { hex } from '../common/util';
|
||||
import { hexdump, write } from './cliformat';
|
||||
import { DEFAULT_MAX_FRAMES, EmuTarget } from './emutarget';
|
||||
|
||||
export const RUN_SCRIPT_HELP = [
|
||||
'Execution:',
|
||||
' run N | wait N - advance N frames (default 1)',
|
||||
' break ADDR [MAXFRAMES] - stop when PC==ADDR (alias: runto)',
|
||||
' step [N] - execute N instructions (default 1)',
|
||||
' trace [MAXLINES] ADDR - run until PC==ADDR, then log every',
|
||||
' instruction until the routine returns',
|
||||
' hist [MAXLINES] - last N instructions from the trace buffer',
|
||||
'Inspection & input:',
|
||||
' key KEY - press key (down, 3 frames, up)',
|
||||
' keydown KEY / keyup KEY - raw key down/up events',
|
||||
' mem START [LEN] - hexdump memory (default 16 bytes)',
|
||||
' screen [START] [COLS] [ROWS]- decode screen RAM to text (default $0400 40x25)',
|
||||
' pc [N] - print PC + disassembly of N instructions',
|
||||
' info - platform/machine debug info',
|
||||
' reset - reset the emulator',
|
||||
' echo TEXT - print message',
|
||||
'(ADDR: number or symbol name; KEY: char, ENTER/SPACE/arrows/F1.., $hex;',
|
||||
' prefixes SHIFT+, CTRL+)',
|
||||
].join('\n');
|
||||
|
||||
const KEY_NAMES: { [name: string]: number } = {
|
||||
'ENTER': 13, 'RETURN': 13, 'CR': 13,
|
||||
'SPACE': 32, 'ESC': 27, 'TAB': 9, 'BACKSPACE': 8, 'BS': 8,
|
||||
'DELETE': 46, 'DEL': 46, 'INSERT': 45,
|
||||
'LEFT': 37, 'UP': 38, 'RIGHT': 39, 'DOWN': 40,
|
||||
'HOME': 36, 'END': 35, 'PAGEUP': 33, 'PAGEDOWN': 34,
|
||||
'F1': 112, 'F2': 113, 'F3': 114, 'F4': 115, 'F5': 116, 'F6': 117,
|
||||
'F7': 118, 'F8': 119, 'F9': 120, 'F10': 121, 'F11': 122, 'F12': 123,
|
||||
};
|
||||
|
||||
export function parseNum(s: string): number {
|
||||
s = s.trim();
|
||||
if (s.startsWith('$')) return parseInt(s.substring(1), 16);
|
||||
if (/^0x[0-9a-f]+$/i.test(s)) return parseInt(s, 16);
|
||||
if (/^-?\d+$/.test(s)) return parseInt(s, 10);
|
||||
throw new Error(`bad number '${s}'`);
|
||||
}
|
||||
|
||||
function parseKeyValue(tok: string): { key: number; flags: number } {
|
||||
let flags = 0;
|
||||
let t = tok.toUpperCase();
|
||||
for (; ;) {
|
||||
if (t.startsWith('SHIFT+')) { flags |= KeyFlags.Shift; t = t.substring(6); }
|
||||
else if (t.startsWith('CTRL+')) { flags |= KeyFlags.Ctrl; t = t.substring(5); }
|
||||
else break;
|
||||
}
|
||||
if (KEY_NAMES[t] != null) return { key: KEY_NAMES[t], flags };
|
||||
if (t.length == 1) return { key: t.charCodeAt(0), flags };
|
||||
if (/^0X[0-9A-F]+$/.test(t)) return { key: parseInt(t, 16), flags };
|
||||
if (/^\d+$/.test(t)) return { key: parseInt(t, 10), flags };
|
||||
throw new Error(`unknown key '${tok}'`);
|
||||
}
|
||||
|
||||
// C64-style screen code -> ASCII (also close enough for VIC-20 et al)
|
||||
function screenCodeToChar(code: number): string {
|
||||
code &= 0xff;
|
||||
if (code >= 0x40) code &= 0x3f; // reverse-video / graphics variants
|
||||
if (code < 0x20) return '@ABCDEFGHIJKLMNOPQRSTUVWXYZ[£]^_'[code];
|
||||
return String.fromCharCode(code); // $20-$3F identical to ASCII
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers as "A=$12 X=$34 SP=$ff". CpuState carries whatever fields the CPU
|
||||
* happens to save, including internals, so pick the set that matches.
|
||||
*/
|
||||
const REG_SETS: { when: string[], show: string[], flags?: string }[] = [
|
||||
{ when: ['A', 'X', 'Y'], show: ['A', 'X', 'Y', 'SP'], flags: 'NVDIZC' }, // 6502
|
||||
{ when: ['AF', 'HL'], show: ['AF', 'BC', 'DE', 'HL', 'IX', 'IY', 'SP'] }, // Z80 / SM83
|
||||
{ when: ['A', 'B', 'DP'], show: ['A', 'B', 'X', 'Y', 'U', 'S', 'DP', 'CC'] }, // 6809
|
||||
];
|
||||
|
||||
export function formatRegs(state: any): string {
|
||||
if (!state) return '';
|
||||
const set = REG_SETS.find((s) => s.when.every((f) => state[f] != null));
|
||||
const names = set ? set.show : Object.keys(state).filter((k) => /^[A-Z]/.test(k) && k !== 'PC').slice(0, 6);
|
||||
const parts: string[] = [];
|
||||
for (const name of names) {
|
||||
const value = state[name];
|
||||
if (typeof value === 'number') parts.push(`${name}=$${hex(value, value > 0xff ? 4 : 2)}`);
|
||||
}
|
||||
if (set?.flags) {
|
||||
// set flags uppercase, clear flags lowercase
|
||||
const flags = [...set.flags].map((f) => (state[f] ? f : f.toLowerCase())).join('');
|
||||
parts.push(flags);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// Trace buffer size; the most recent half is kept once it fills up.
|
||||
const PROBE_BUFFER_SIZE = 0x400000;
|
||||
|
||||
export class RunScript {
|
||||
symbols: { [name: string]: number } = {};
|
||||
private addr2symbol: { [addr: number]: string } = {};
|
||||
private probe: ProbeRecorder | null = null;
|
||||
|
||||
constructor(readonly target: EmuTarget, private out = write) { }
|
||||
|
||||
addSymbols(symbols: { [name: string]: number }) {
|
||||
Object.assign(this.symbols, symbols);
|
||||
for (const [name, addr] of Object.entries(this.symbols)) this.addr2symbol[addr] = name;
|
||||
}
|
||||
|
||||
/** Start recording executed instructions, for the 'hist' command. */
|
||||
startTracing(): boolean {
|
||||
if (!this.target.machine) return false;
|
||||
const rec = new ProbeRecorder(this.target.machine as any, PROBE_BUFFER_SIZE);
|
||||
rec.singleFrame = false; // accumulate across frames
|
||||
if (!this.target.connectProbe(rec)) return false;
|
||||
this.probe = rec;
|
||||
return true;
|
||||
}
|
||||
|
||||
run(script: string) {
|
||||
for (let line of script.split(/\r?\n|;/)) {
|
||||
line = line.trim();
|
||||
if (!line || line.startsWith('#') || line.startsWith('//')) continue;
|
||||
const tokens = line.split(/\s+/);
|
||||
const cmd = tokens[0].toLowerCase();
|
||||
const handler = COMMANDS[cmd];
|
||||
if (!handler) {
|
||||
throw new Error(`unknown command '${cmd}'\nCommands:\n${RUN_SCRIPT_HELP}`);
|
||||
}
|
||||
try {
|
||||
handler.call(this, tokens, line);
|
||||
} catch (e: any) {
|
||||
throw new Error(`script error on '${line}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//// helpers used by the commands
|
||||
|
||||
private log(msg: string) { this.out(`[frame ${this.target.frameCount}] ${msg}\n`); }
|
||||
|
||||
private addr(tok: string): number {
|
||||
try { return parseNum(tok); }
|
||||
catch (e) {
|
||||
const v = this.symbols[tok] ?? this.symbols[tok.replace(/^\./, '')];
|
||||
if (v == null) throw new Error(`unknown address or symbol '${tok}'`);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
private advance(n: number) {
|
||||
// Keep the trace buffer from overflowing: drop the oldest half.
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = this.probe;
|
||||
if (p && p.idx >= p.buf.length - 0x1000) {
|
||||
const half = p.buf.length >> 1;
|
||||
p.buf.copyWithin(0, p.idx - half, p.idx);
|
||||
p.idx = half;
|
||||
}
|
||||
this.target.advanceFrame();
|
||||
}
|
||||
}
|
||||
|
||||
/** One disassembly line: "$ADDR bytes mnemonic" plus optional registers. */
|
||||
private disasmLine(addr: number, withRegs: boolean): string {
|
||||
const d = this.target.disassemble(addr);
|
||||
let bytes = '';
|
||||
if (d) for (let b = 0; b < d.nbytes; b++) bytes += hex(this.target.read(addr + b)) + ' ';
|
||||
const label = this.addr2symbol[addr] ? `${this.addr2symbol[addr]}:\n` : '';
|
||||
let line = `${label} $${hex(addr, 4)} ${bytes.padEnd(12)} ${(d ? d.line : '???').padEnd(20)}`;
|
||||
if (withRegs) line += ' ' + formatRegs(this.target.getCPUState());
|
||||
return line.replace(/\s+$/, '');
|
||||
}
|
||||
|
||||
private disasmBlock(addr: number, count: number) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const d = this.target.disassemble(addr);
|
||||
if (!d) { this.out(` $${hex(addr, 4)} (no disassembler for this target)\n`); return; }
|
||||
this.out(this.disasmLine(addr, false) + '\n');
|
||||
addr += d.nbytes;
|
||||
}
|
||||
}
|
||||
|
||||
private requireStep() {
|
||||
if (!this.target.supportsStep) {
|
||||
throw new Error(`'${this.target.id}' does not support instruction stepping`);
|
||||
}
|
||||
}
|
||||
|
||||
//// commands
|
||||
|
||||
cmdRun(tokens: string[]) {
|
||||
const n = tokens[1] ? parseNum(tokens[1]) : 1;
|
||||
this.advance(n);
|
||||
this.log(`ran ${n} frame${n == 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
cmdStep(tokens: string[]) {
|
||||
this.requireStep();
|
||||
this.target.settle();
|
||||
const n = tokens[1] ? parseNum(tokens[1]) : 1;
|
||||
for (let i = 0; i < n; i++) {
|
||||
this.out(this.disasmLine(this.target.getPC(), true) + '\n');
|
||||
this.target.stepInsn();
|
||||
}
|
||||
this.log(`PC=$${hex(this.target.getPC(), 4)}`);
|
||||
}
|
||||
|
||||
cmdBreak(tokens: string[]) {
|
||||
if (!tokens[1]) throw new Error('break requires an address');
|
||||
const addr = this.addr(tokens[1]);
|
||||
const maxFrames = tokens[2] ? parseNum(tokens[2]) : DEFAULT_MAX_FRAMES;
|
||||
const start = this.target.frameCount;
|
||||
const hit = this.target.runToPC(new Set([addr]), maxFrames);
|
||||
const pc = this.target.getPC();
|
||||
const where = pc != null ? '$' + hex(pc, 4) : '?';
|
||||
this.log(`break $${hex(addr, 4)}: ${hit ? 'HIT' : 'MISSED'} (pc=${where} after ${this.target.frameCount - start} frames)`);
|
||||
if (!hit && !this.target.supportsTrap) {
|
||||
this.out(` (note: '${this.target.id}' only checks the PC at frame boundaries)\n`);
|
||||
}
|
||||
}
|
||||
|
||||
cmdTrace(tokens: string[]) {
|
||||
this.requireStep();
|
||||
let i = 1;
|
||||
let maxLines = 5000;
|
||||
if (tokens.length > 2 && /^\d+$/.test(tokens[1])) maxLines = parseNum(tokens[i++]);
|
||||
if (!tokens[i]) throw new Error('trace requires an address (trace [MAXLINES] ADDR)');
|
||||
const targets = new Set<number>();
|
||||
for (; i < tokens.length; i++) targets.add(this.addr(tokens[i]));
|
||||
|
||||
const start = this.target.frameCount;
|
||||
if (!this.target.runToPC(targets)) {
|
||||
this.log(`trace: address not reached within ${this.target.frameCount - start} frames`);
|
||||
return;
|
||||
}
|
||||
const entrySP = (this.target.getCPUState() as any)?.SP ?? 0;
|
||||
this.log(`--- trace ON at $${hex(this.target.getPC(), 4)} ---`);
|
||||
let lines = 0;
|
||||
let done = 'ran out of frames';
|
||||
while (this.target.frameCount - start < DEFAULT_MAX_FRAMES) {
|
||||
this.out(this.disasmLine(this.target.getPC(), true) + '\n');
|
||||
if (++lines >= maxLines) { done = `line cap (${maxLines}) reached`; break; }
|
||||
this.target.stepInsn();
|
||||
// the routine returned once the stack has popped back past entry level
|
||||
const sp = (this.target.getCPUState() as any)?.SP;
|
||||
if (sp != null && sp > entrySP) { done = `returned after ${lines} instructions`; break; }
|
||||
}
|
||||
this.log(`--- trace OFF: ${done} ---`);
|
||||
}
|
||||
|
||||
cmdHist(tokens: string[]) {
|
||||
const p = this.probe;
|
||||
if (!p) throw new Error(`'${this.target.id}' has no trace buffer (needs Probeable)`);
|
||||
if (p.idx === 0) { this.out('(no trace buffer data)\n'); return; }
|
||||
const maxlines = tokens[1] ? parseNum(tokens[1]) : 20;
|
||||
// scan backwards for the start of the last `maxlines` events
|
||||
let start = p.idx, count = 0;
|
||||
while (start > 0 && count < maxlines) {
|
||||
start--;
|
||||
const op = p.buf[start] & 0xff000000;
|
||||
if (op === ProbeFlags.EXECUTE || op === ProbeFlags.INTERRUPT) count++;
|
||||
}
|
||||
let shown = 0;
|
||||
for (let i = start; i < p.idx && shown < maxlines; i++) {
|
||||
const w = p.buf[i];
|
||||
const op = w & 0xff000000;
|
||||
if (op === ProbeFlags.EXECUTE) { this.out(this.disasmLine(w & 0xffffff, false) + '\n'); shown++; }
|
||||
else if (op === ProbeFlags.INTERRUPT) { this.out(' --- INTERRUPT ---\n'); shown++; }
|
||||
}
|
||||
this.out(`(${shown} instructions shown, ${p.idx} events recorded)\n`);
|
||||
}
|
||||
|
||||
cmdKey(tokens: string[]) {
|
||||
if (!tokens[1]) throw new Error('key requires a key name');
|
||||
const { key, flags } = parseKeyValue(tokens[1]);
|
||||
this.target.setKeyInput(key, key, flags | KeyFlags.KeyDown);
|
||||
this.advance(3);
|
||||
this.target.setKeyInput(key, key, flags | KeyFlags.KeyUp);
|
||||
this.advance(1);
|
||||
this.log(`pressed ${tokens[1]} ($${hex(key, 2)})`);
|
||||
}
|
||||
|
||||
cmdKeyDown(tokens: string[]) { this.keyEvent(tokens, KeyFlags.KeyDown, 'keydown'); }
|
||||
cmdKeyUp(tokens: string[]) { this.keyEvent(tokens, KeyFlags.KeyUp, 'keyup'); }
|
||||
|
||||
private keyEvent(tokens: string[], flag: number, name: string) {
|
||||
if (!tokens[1]) throw new Error(`${name} requires a key name`);
|
||||
const { key, flags } = parseKeyValue(tokens[1]);
|
||||
this.target.setKeyInput(key, key, flags | flag);
|
||||
this.log(`${name} ${tokens[1]} ($${hex(key, 2)})`);
|
||||
}
|
||||
|
||||
cmdMem(tokens: string[]) {
|
||||
if (!tokens[1]) throw new Error('mem requires START [LEN]');
|
||||
const start = this.addr(tokens[1]);
|
||||
const len = tokens[2] ? parseNum(tokens[2]) : 16;
|
||||
this.log(`mem $${hex(start, 4)}+$${hex(len, 4)}:`);
|
||||
hexdump((a) => this.target.read(a), start, start + len - 1, this.out);
|
||||
}
|
||||
|
||||
cmdScreen(tokens: string[]) {
|
||||
const start = tokens[1] ? this.addr(tokens[1]) : 0x400;
|
||||
const cols = tokens[2] ? parseNum(tokens[2]) : 40;
|
||||
const rows = tokens[3] ? parseNum(tokens[3]) : 25;
|
||||
this.log(`screen at $${hex(start, 4)} (${cols}x${rows}):`);
|
||||
for (let y = 0; y < rows; y++) {
|
||||
let line = '';
|
||||
for (let x = 0; x < cols; x++) line += screenCodeToChar(this.target.read(start + y * cols + x));
|
||||
this.out(`|${line.replace(/\s+$/, '')}|\n`);
|
||||
}
|
||||
}
|
||||
|
||||
cmdPC(tokens: string[]) {
|
||||
this.target.settle();
|
||||
const pc = this.target.getPC();
|
||||
if (pc == null) throw new Error(`'${this.target.id}' does not report a PC`);
|
||||
this.log(`PC=$${hex(pc, 4)}`);
|
||||
this.disasmBlock(pc, tokens[1] ? parseNum(tokens[1]) : 8);
|
||||
}
|
||||
|
||||
cmdInfo() {
|
||||
this.target.settle();
|
||||
const sections = this.target.getDebugInfo();
|
||||
if (!sections.length) this.out(`(no debug info for '${this.target.id}')\n`);
|
||||
for (const { category, text } of sections) {
|
||||
this.out(`[${category}]\n${text}${text.endsWith('\n') ? '' : '\n'}`);
|
||||
}
|
||||
const pc = this.target.getPC();
|
||||
if (pc != null && this.target.disassemble(pc)) {
|
||||
this.out('[Disassembly]\n');
|
||||
this.disasmBlock(pc, 16);
|
||||
}
|
||||
}
|
||||
|
||||
cmdReset() { this.target.reset(); this.log('reset'); }
|
||||
cmdEcho(tokens: string[], line: string) { this.out(line.substring(tokens[0].length).trim() + '\n'); }
|
||||
}
|
||||
|
||||
type Command = (this: RunScript, tokens: string[], line: string) => void;
|
||||
|
||||
const COMMANDS: { [name: string]: Command } = {
|
||||
'run': RunScript.prototype.cmdRun,
|
||||
'wait': RunScript.prototype.cmdRun,
|
||||
'frames': RunScript.prototype.cmdRun,
|
||||
'step': RunScript.prototype.cmdStep,
|
||||
'break': RunScript.prototype.cmdBreak,
|
||||
'runto': RunScript.prototype.cmdBreak,
|
||||
'trace': RunScript.prototype.cmdTrace,
|
||||
'hist': RunScript.prototype.cmdHist,
|
||||
'key': RunScript.prototype.cmdKey,
|
||||
'press': RunScript.prototype.cmdKey,
|
||||
'keydown': RunScript.prototype.cmdKeyDown,
|
||||
'keyup': RunScript.prototype.cmdKeyUp,
|
||||
'mem': RunScript.prototype.cmdMem,
|
||||
'screen': RunScript.prototype.cmdScreen,
|
||||
'pc': RunScript.prototype.cmdPC,
|
||||
'info': RunScript.prototype.cmdInfo,
|
||||
'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;
|
||||
}
|
||||
@@ -73,11 +73,14 @@ function displayKitty(pngData: Uint8Array, w: number, h: number, scale: number):
|
||||
const cols = Math.ceil(w * scale / 8);
|
||||
const rows = Math.ceil(h * scale / 16);
|
||||
|
||||
// q=2 = silent mode: suppresses the terminal's OK/error acknowledgements.
|
||||
// Without it, the terminal writes responses (\x1b_Gi=<id>;OK\x1b\\) to stdin,
|
||||
// which leak into the shell's input buffer after we exit and garble the prompt.
|
||||
for (let i = 0; i < b64.length; i += chunkSize) {
|
||||
const chunk = b64.slice(i, i + chunkSize);
|
||||
const more = i + chunkSize < b64.length ? 1 : 0;
|
||||
if (i === 0) {
|
||||
process.stdout.write(`\x1b_Gf=100,a=T,c=${cols},r=${rows},m=${more};${chunk}\x1b\\`);
|
||||
process.stdout.write(`\x1b_Gf=100,a=T,c=${cols},r=${rows},q=2,m=${more};${chunk}\x1b\\`);
|
||||
} else {
|
||||
process.stdout.write(`\x1b_Gm=${more};${chunk}\x1b\\`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
|
||||
var assert = require('assert');
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var os = require('os');
|
||||
var { execFileSync } = require('child_process');
|
||||
|
||||
const CLI = path.join(__dirname, '../../gen/tools/8bws.js');
|
||||
|
||||
// run the CLI and return { stdout, json } -- --json puts the CLIResult on stdout
|
||||
function cli(...args) {
|
||||
var stdout = execFileSync('node', [CLI, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
return stdout;
|
||||
}
|
||||
// in --json mode stdout is the CLIResult and nothing else
|
||||
function cliJSON(...args) {
|
||||
return JSON.parse(cli(...args, '--json'));
|
||||
}
|
||||
function cliFails(...args) {
|
||||
try { cli(...args); } catch (e) { return e; }
|
||||
assert.fail('expected the command to fail: ' + args.join(' '));
|
||||
}
|
||||
|
||||
describe('8bws CLI', function () {
|
||||
|
||||
describe('discovery', function () {
|
||||
it('should list platforms', function () {
|
||||
var r = cliJSON('list-platforms');
|
||||
assert.ok(r.success);
|
||||
assert.ok(r.data.count > 20);
|
||||
assert.equal(r.data.platforms['nes'].arch, '6502');
|
||||
});
|
||||
it('should put nothing but JSON on stdout in --json mode', function () {
|
||||
// the compiler chatters on console.log; it must not corrupt the result
|
||||
var r = cliJSON('run', '--platform', 'gb', 'presets/gb/hello.c', '--frames', '5');
|
||||
assert.equal(r.data.frames, 5);
|
||||
});
|
||||
it('should list tools', function () {
|
||||
var r = cliJSON('list-tools');
|
||||
assert.ok(r.data.tools.includes('cc65'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('build', function () {
|
||||
it('should build a source file', function () {
|
||||
var out = path.join(os.tmpdir(), '8bws-test-hello.gb');
|
||||
var r = cliJSON('build', '--platform', 'gb', 'presets/gb/hello.c', '-o', out);
|
||||
assert.ok(r.success, r.error);
|
||||
assert.equal(r.data.tool, 'sdcc');
|
||||
assert.ok(r.data.outputSize > 0);
|
||||
assert.equal(fs.statSync(out).size, r.data.outputSize);
|
||||
});
|
||||
it('should check without writing output', function () {
|
||||
var r = cliJSON('build', '--platform', 'gb', 'presets/gb/hello.c', '--check');
|
||||
assert.ok(r.success, r.error);
|
||||
assert.equal(r.command, 'check');
|
||||
});
|
||||
it('should report compile errors', function () {
|
||||
var src = path.join(os.tmpdir(), '8bws-test-bad.c');
|
||||
fs.writeFileSync(src, 'int main() { this is not C }\n');
|
||||
var e = cliFails('build', '--platform', 'gb', src);
|
||||
assert.ok(e.status !== 0);
|
||||
});
|
||||
it('should require a platform', function () {
|
||||
cliFails('build', 'presets/gb/hello.c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('run --platform', function () {
|
||||
it('should run a ROM for N frames', function () {
|
||||
var r = cliJSON('run', '--platform', 'nes', '--frames', '30', 'test/roms/nes/shoot2.c.rom');
|
||||
assert.ok(r.success, r.error);
|
||||
assert.equal(r.data.frames, 30);
|
||||
assert.equal(r.data.width, 512);
|
||||
});
|
||||
it('should build and run a source file', function () {
|
||||
var r = cliJSON('run', '--platform', 'gb', 'presets/gb/hello.c', '--frames', '10');
|
||||
assert.ok(r.success, r.error);
|
||||
assert.equal(r.data.frames, 10);
|
||||
});
|
||||
it('should break on a symbol from the build', function () {
|
||||
var out = cli('run', '--platform', 'gb', 'presets/gb/hello.c', '-e', 'break _main 600; pc 2');
|
||||
assert.ok(/break \$[0-9A-F]+: HIT/.test(out), out);
|
||||
assert.ok(/_main:/.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);
|
||||
cliJSON('run', '--platform', 'nes', '--frames', '10', '--png', png, 'test/roms/nes/shoot2.c.rom');
|
||||
assert.ok(fs.statSync(png).size > 0);
|
||||
});
|
||||
it('should infer the platform from a ROM extension', function () {
|
||||
var gb = path.join(os.tmpdir(), '8bws-test-infer.gb');
|
||||
fs.copyFileSync('test/roms/gb/cpu_instrs.gb', gb);
|
||||
var r = cliJSON('run', gb, '--frames', '5');
|
||||
assert.equal(r.data.platform, 'gb');
|
||||
});
|
||||
it('should refuse an unrecognized ROM without a platform', function () {
|
||||
cliFails('run', 'test/roms/nes/shoot2.c.rom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('run: emulator control', function () {
|
||||
// these all reach through the Platform to its Machine (common/devices.ts)
|
||||
it('should reject an unknown platform', function () {
|
||||
cliFails('run', '--platform', 'nosuchplatform', '--frames', '1', 'test/roms/apple2/cosmic.c.rom');
|
||||
});
|
||||
it('should disassemble a 6502 platform', function () {
|
||||
var out = cli('run', '--platform', 'apple2', '-e', 'run 20; pc 2', 'test/roms/apple2/cosmic.c.rom');
|
||||
assert.ok(/PC=\$[0-9A-F]{4}/.test(out), out);
|
||||
assert.ok(/\$[0-9A-F]{4}\s+[0-9A-F]{2}/.test(out), out);
|
||||
});
|
||||
it('should disassemble a z80 platform', function () {
|
||||
var out = cli('run', '--platform', 'vicdual', '-e', 'run 20; pc 2', 'test/roms/vicdual/snake1.c.rom');
|
||||
assert.ok(/PC=\$[0-9A-F]{4}/.test(out), out);
|
||||
});
|
||||
it('should disassemble a 6809 platform', function () {
|
||||
var out = cli('run', '--platform', 'williams', '-e', 'run 20; pc 2', 'test/roms/williams/vidfill.asm.rom');
|
||||
assert.ok(!/no disassembler/.test(out), out);
|
||||
});
|
||||
it('should step one instruction at a time', function () {
|
||||
var out = cli('run', '--platform', 'apple2', '-e', 'run 20; step 3', 'test/roms/apple2/cosmic.c.rom');
|
||||
var addrs = out.match(/^\s+\$[0-9A-F]{4}\s/gm) || [];
|
||||
assert.equal(addrs.length, 3, out);
|
||||
// 6502 registers and flags should be shown while stepping
|
||||
assert.ok(/A=\$[0-9A-F]{2} X=\$[0-9A-F]{2} Y=\$[0-9A-F]{2} SP=\$[0-9A-F]{2} [NnVvDdIiZzCc]{6}/.test(out), out);
|
||||
});
|
||||
it('should record an instruction history', function () {
|
||||
var out = cli('run', '--platform', 'apple2', '-e', 'run 20; hist 5', 'test/roms/apple2/cosmic.c.rom');
|
||||
assert.ok(/instructions shown/.test(out), out);
|
||||
});
|
||||
it('should dump memory', function () {
|
||||
var out = cli('run', '--platform', 'apple2', '-f', '20', '--memdump', '400,40f', 'test/roms/apple2/cosmic.c.rom');
|
||||
assert.ok(/^0400:( [0-9A-F]{2}){8}/m.test(out), out);
|
||||
});
|
||||
});
|
||||
|
||||
describe('run script', function () {
|
||||
it('should reject an unknown command', function () {
|
||||
cliFails('run', '--platform', 'apple2', '-e', 'frobnicate', 'test/roms/apple2/cosmic.c.rom');
|
||||
});
|
||||
it('should run a script from a file', function () {
|
||||
var f = path.join(os.tmpdir(), '8bws-test.script');
|
||||
fs.writeFileSync(f, '# comment\nrun 5\necho hello-from-script\n');
|
||||
var out = cli('run', '--platform', 'apple2', '--script', f, 'test/roms/apple2/cosmic.c.rom');
|
||||
assert.ok(/hello-from-script/.test(out), out);
|
||||
});
|
||||
it('should resolve symbols from a label file', function () {
|
||||
var f = path.join(os.tmpdir(), '8bws-test.lbl');
|
||||
fs.writeFileSync(f, 'al 004000 .mysym\n');
|
||||
var out = cli('run', '--platform', 'apple2', '--symbols', f, '-e', 'break mysym 2', 'test/roms/apple2/cosmic.c.rom');
|
||||
assert.ok(/break \$4000/.test(out), out);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy aliases', function () {
|
||||
it('should accept compile, check and compilerun', function () {
|
||||
assert.equal(cliJSON('compile', '--platform', 'gb', 'presets/gb/hello.c', '--check').command, 'check');
|
||||
assert.equal(cliJSON('check', '--platform', 'gb', 'presets/gb/hello.c').command, 'check');
|
||||
assert.ok(cliJSON('compilerun', '--platform', 'gb', 'presets/gb/hello.c', '--frames', '5').success);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user