cli: added simple scripting

This commit is contained in:
Steven Hugg committed 2026-08-22 13:52:06 +02:00
1 parent a4b1d593a9
commit 3e462b0bcd
1 file changed
+263 -24
+263 -24
View File
@@ -6,6 +6,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { initialize, compile, compileSourceFile, preload, listTools, listPlatforms, getToolForFilename, PLATFORM_PARAMS, TOOLS, store } from './testlib';
import { isDebuggable } from '../common/baseplatform';
import { KeyFlags } from '../common/emu';
import { hex } from '../common/util';
interface CLIResult {
@@ -212,7 +213,8 @@ function usage(): void {
commands: {
'compile': 'compile --platform <platform> [--tool <tool>] [--output <file>] [--symbols] [--save] <source>',
'check': 'check --platform <platform> [--tool <tool>] <source>',
'run': 'run (--platform <id> | --machine <module:ClassName>) [--frames N] [--output <file.png>] [--memdump start,end] [--info] <rom>',
'run': 'run (--platform <id> | --machine <module:ClassName>) [--frames N] [--script "cmds"|file] [--output <file.png>] [--memdump start,end] [--info] <rom>',
'script-cmds': RUN_SCRIPT_HELP,
'list-tools': 'list-tools',
'list-platforms': 'list-platforms',
}
@@ -338,6 +340,215 @@ async function doCompile(args: { [key: string]: string }, positional: string[],
});
}
//
// Run-script interpreter
//
// A simple command language for driving headless emulation, one command per
// line (separated by newlines or ';'). '#' starts a comment.
//
// run N | wait N | frames N advance N frames (default 1)
// runto ADDR [maxframes] advance until PC == ADDR (default cap 1000 frames)
// key KEY press key (down, 3 frames, up)
// keydown KEY | keyup KEY raw key events
// mem START LEN hexdump memory
// screen [START] [COLS] [ROWS] decode screen RAM to text (default $0400 40x25)
// pc print PC + disassembly at PC
// reset reset the platform/machine
// echo TEXT print a message
//
// KEY can be a single character ('A', '5'), a name (ENTER, SPACE, LEFT, F1...),
// or numeric ($41 / 65). Modifier prefixes: SHIFT+, CTRL+
// Numbers can be decimal, $hex or 0xhex.
interface RunScriptContext {
advance(): void;
frameno(): number;
readMem(addr: number): number;
getPC(): number | null;
sendKey(key: number, flags: number): void;
reset(): void;
disassemble(addr: number): { line: string; nbytes: number } | null;
}
const RUN_SCRIPT_HELP = [
'run N | wait N | frames N - advance N frames (default 1)',
'runto ADDR [maxframes] - run until PC==ADDR (cap default 1000 frames)',
'key KEY - press key (down, 3 frames, up)',
'keydown KEY / keyup KEY - raw key down/up events',
'mem START LEN - hexdump memory',
'screen [START] [COLS] [ROWS] - decode screen RAM to text (default $0400 40x25)',
'pc - print PC + disassembly at PC',
'reset - reset platform/machine',
'echo TEXT - print message',
'(KEY: char, ENTER/SPACE/LEFT/UP/RIGHT/DOWN/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,
};
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 } {
var flags = 0;
var 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) {
const special = '@ABCDEFGHIJKLMNOPQRSTUVWXYZ[£]^_';
return special[code];
}
return String.fromCharCode(code); // $20-$3F identical to ASCII
}
function hexdumpMem(readFn: (addr: number) => number, start: number, end: number): void {
var len = end - start + 1;
for (var ofs = 0; ofs < len; ofs += 16) {
var line = `${hex(start + ofs, 4)}:`;
var ascii = '';
for (var i = 0; i < 16 && ofs + i < len; i++) {
if (i === 8) line += ' ';
var byte = readFn(start + ofs + i);
line += ` ${hex(byte)}`;
ascii += (byte >= 0x20 && byte < 0x7f) ? String.fromCharCode(byte) : '.';
}
process.stdout.write(`${line} ${ascii}\n`);
}
}
function executeRunScript(script: string, ctx: RunScriptContext): void {
var lines = script.split(/\r?\n|;/);
for (var ln of lines) {
ln = ln.trim();
if (!ln || ln.startsWith('#') || ln.startsWith('//')) continue;
var tokens = ln.split(/\s+/);
var cmd = tokens[0].toLowerCase();
try {
switch (cmd) {
case 'run': case 'wait': case 'frames': {
var n = tokens[1] ? parseNum(tokens[1]) : 1;
for (var i = 0; i < n; i++) ctx.advance();
process.stdout.write(`[frame ${ctx.frameno()}] ran ${n} frame${n == 1 ? '' : 's'}\n`);
break;
}
case 'runto': {
if (!tokens[1]) throw new Error('runto requires an address');
var addr = parseNum(tokens[1]);
var maxf = tokens[2] ? parseNum(tokens[2]) : 1000;
var startFrame = ctx.frameno();
while (ctx.frameno() - startFrame < maxf) {
ctx.advance();
var pc = ctx.getPC();
if (pc == null || pc === addr) break;
}
var pc = ctx.getPC();
var hit = pc != null && pc === addr;
process.stdout.write(`[frame ${ctx.frameno()}] runto $${hex(addr, 4)}: ${hit ? 'HIT' : 'MISSED'} (pc=${pc != null ? '$' + hex(pc, 4) : '?'} after ${ctx.frameno() - startFrame} frames)\n`);
break;
}
case 'key': case 'press': {
if (!tokens[1]) throw new Error('key requires a key name');
var { key, flags } = parseKeyValue(tokens[1]);
ctx.sendKey(key, flags | KeyFlags.KeyDown);
for (var i = 0; i < 3; i++) ctx.advance();
ctx.sendKey(key, flags | KeyFlags.KeyUp);
ctx.advance();
process.stdout.write(`[frame ${ctx.frameno()}] pressed ${tokens[1]} ($${hex(key, 2)})\n`);
break;
}
case 'keydown': {
if (!tokens[1]) throw new Error('keydown requires a key name');
var { key, flags } = parseKeyValue(tokens[1]);
ctx.sendKey(key, flags | KeyFlags.KeyDown);
process.stdout.write(`[frame ${ctx.frameno()}] keydown ${tokens[1]} ($${hex(key, 2)})\n`);
break;
}
case 'keyup': {
if (!tokens[1]) throw new Error('keyup requires a key name');
var { key, flags } = parseKeyValue(tokens[1]);
ctx.sendKey(key, flags | KeyFlags.KeyUp);
process.stdout.write(`[frame ${ctx.frameno()}] keyup ${tokens[1]} ($${hex(key, 2)})\n`);
break;
}
case 'mem': {
if (!tokens[1]) throw new Error('mem requires START [LEN]');
var start = parseNum(tokens[1]);
var len = tokens[2] ? parseNum(tokens[2]) : 16;
process.stdout.write(`[frame ${ctx.frameno()}] mem $${hex(start, 4)}+$${hex(len, 4)}:\n`);
hexdumpMem(ctx.readMem, start, start + len - 1);
break;
}
case 'screen': {
var start = tokens[1] ? parseNum(tokens[1]) : 0x400;
var cols = tokens[2] ? parseNum(tokens[2]) : 40;
var rows = tokens[3] ? parseNum(tokens[3]) : 25;
process.stdout.write(`[frame ${ctx.frameno()}] screen at $${hex(start, 4)} (${cols}x${rows}):\n`);
for (var y = 0; y < rows; y++) {
var line = '';
for (var x = 0; x < cols; x++) line += screenCodeToChar(ctx.readMem(start + y * cols + x));
process.stdout.write(`|${line.replace(/\s+$/, '')}|\n`);
}
break;
}
case 'pc': {
var pc = ctx.getPC();
if (pc == null) throw new Error('no PC available');
process.stdout.write(`[frame ${ctx.frameno()}] PC=$${hex(pc, 4)}\n`);
var addr = pc;
for (var i = 0; i < 8; i++) {
var d = ctx.disassemble(addr);
if (!d) break;
var bytesStr = '';
for (var b = 0; b < d.nbytes; b++) bytesStr += hex(ctx.readMem(addr + b)) + ' ';
process.stdout.write(` $${hex(addr, 4)} ${bytesStr.padEnd(12)} ${d.line}\n`);
addr += d.nbytes;
}
break;
}
case 'reset': {
ctx.reset();
process.stdout.write(`[frame ${ctx.frameno()}] reset\n`);
break;
}
case 'echo': {
process.stdout.write(ln.substring(cmd.length).trim() + '\n');
break;
}
default:
throw new Error(`unknown command (try: ${RUN_SCRIPT_HELP.split('\n')[0].split(' ')[0]}, ...)`);
}
} catch (e: any) {
throw new Error(`script error on '${ln}': ${e.message}\nCommands:\n${RUN_SCRIPT_HELP}`);
}
}
}
async function doRun(args: { [key: string]: string }, positional: string[]): Promise<void> {
var platformId = args['platform'];
var machine = args['machine'];
@@ -359,6 +570,7 @@ async function doRun(args: { [key: string]: string }, positional: string[]): Pro
var vid: { width: number; height: number } | null = null;
var platformRunner: any = null;
var machineInstance: any = null;
var runner: any = null;
if (platformId) {
// Platform mode: load platform module, mock video, run via Platform API
@@ -366,11 +578,6 @@ async function doRun(args: { [key: string]: string }, positional: string[]): Pro
platformRunner = new PlatformRunner(await loadPlatform(platformId));
await platformRunner.start();
platformRunner.loadROM("ROM", romData);
for (var i = 0; i < frames; i++) {
platformRunner.run();
}
pixels = platformRunner.pixels;
vid = platformRunner.videoParams;
} else {
// Machine mode: load machine class directly
var parts = machine.split(':');
@@ -385,16 +592,58 @@ async function doRun(args: { [key: string]: string }, positional: string[]): Pro
var [modname, clsname] = parts;
var { MachineRunner, loadMachine } = await import('./runmachine');
machineInstance = await loadMachine(modname, clsname);
var runner = new MachineRunner(machineInstance);
runner = new MachineRunner(machineInstance);
runner.setup();
machineInstance.loadROM(romData);
for (var i = 0; i < frames; i++) {
runner.run();
}
pixels = runner.pixels;
vid = pixels ? (machineInstance as any).getVideoParams() : null;
}
// Build the execution context for frame loop / run-script
var frameno = 0;
var advance = () => {
if (platformRunner) platformRunner.run(); else runner.run();
frameno++;
};
var debugTarget: any = platformRunner ? platformRunner.platform : machineInstance;
var keyTarget: any = platformRunner ? (platformRunner.platform as any).machine : machineInstance;
var readMem = (addr: number): number => {
if (platformRunner) return platformRunner.platform.readAddress(addr);
var m = machineInstance as any;
return typeof m.readAddress === 'function' ? m.readAddress(addr) : m.readConst(addr);
};
var getPC = (): number | null => {
try { return typeof debugTarget.getPC === 'function' ? debugTarget.getPC() : null; }
catch (e) { return null; }
};
var sendKey = (key: number, flags: number) => {
if (keyTarget && typeof keyTarget.setKeyInput === 'function') keyTarget.setKeyInput(key, key, flags);
else if (typeof debugTarget.setKeyInput === 'function') debugTarget.setKeyInput(key, key, flags);
else throw new Error('platform/machine does not support setKeyInput');
};
var disassemble = (addr: number) => {
try { return typeof debugTarget.disassemble === 'function' ? debugTarget.disassemble(addr, readMem) : null; }
catch (e) { return null; }
};
if (args['script']) {
// --script: inline commands or a path to a script file
var scriptText = args['script'];
if (fs.existsSync(scriptText)) scriptText = fs.readFileSync(scriptText, 'utf8');
executeRunScript(scriptText, {
advance,
frameno: () => frameno,
readMem,
getPC,
sendKey,
reset: () => debugTarget.reset(),
disassemble,
});
} else {
for (var i = 0; i < frames; i++) advance();
}
pixels = platformRunner ? platformRunner.pixels : runner.pixels;
vid = pixels ? (platformRunner ? platformRunner.videoParams : (machineInstance as any).getVideoParams()) : null;
output({
success: true,
command: 'run',
@@ -466,18 +715,8 @@ async function doRun(args: { [key: string]: string }, positional: string[]): Pro
output({ success: false, command: 'run', error: 'Platform/machine does not support readAddress' });
process.exit(1);
}
var len = end - start + 1;
for (var ofs = 0; ofs < len; ofs += 16) {
var line = `${c.cyan}$${hex(start + ofs, 4)}${c.reset}:`;
var ascii = '';
for (var i = 0; i < 16 && ofs + i < len; i++) {
if (i === 8) line += ' ';
var byte = readFn2(start + ofs + i);
line += ` ${hex(byte)}`;
ascii += (byte >= 0x20 && byte < 0x7f) ? String.fromCharCode(byte) : '.';
}
process.stderr.write(`${line} ${c.dim}${ascii}${c.reset}\n`);
}
process.stdout.write(`memdump $${hex(start, 4)}-$${hex(end, 4)}:\n`);
hexdumpMem(readFn2, start, end);
}
// Encode framebuffer as PNG if video is available