From dea718b91a6366ece36c6d27e4e7727be3f17cf9 Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Sat, 22 Aug 2026 20:04:33 +0200 Subject: [PATCH] cli: rewrite; symbols and tracing --- package.json | 3 +- src/tools/8bws.ts | 1023 ++++++++++----------------------------- src/tools/cliformat.ts | 201 ++++++++ src/tools/emutarget.ts | 326 +++++++++++++ src/tools/runmachine.ts | 202 -------- src/tools/runscript.ts | 382 +++++++++++++++ src/tools/termimage.ts | 5 +- test/cli/testcli.js | 163 +++++++ 8 files changed, 1346 insertions(+), 959 deletions(-) create mode 100644 src/tools/cliformat.ts create mode 100644 src/tools/emutarget.ts delete mode 100644 src/tools/runmachine.ts create mode 100644 src/tools/runscript.ts create mode 100644 test/cli/testcli.js diff --git a/package.json b/package.json index 480abf65..12096343 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/tools/8bws.ts b/src/tools/8bws.ts index 737c8185..ae74cab6 100644 --- a/src/tools/8bws.ts +++ b/src/tools/8bws.ts @@ -1,822 +1,335 @@ #!/usr/bin/env node -// 8bws - 8bitworkshop CLI tool for compilation, ROM execution, and platform info +// 8bws - 8bitworkshop command line tool. +// +// 8bws build --platform c64 hello.c -o hello.prg +// 8bws run --platform c64 hello.c -e "run 100; screen" +// 8bws run --platform c64 hello.prg --png shot.png +// +// Two verbs: `build` compiles a source file, `run` executes a ROM -- or a +// source file, which it builds first. Emulation goes through EmuTarget +// (emutarget.ts). import * as fs from 'fs'; +import * as os from 'os'; 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'; +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 type { CompileResult } from './testlib'; -interface CLIResult { - success: boolean; - command: string; - data?: any; - error?: string; +interface Args { + [key: string]: string | true; } -// ANSI color helpers -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', +/** ROM extensions that name exactly one platform. */ +const ROM_PLATFORMS: { [ext: string]: string } = { + '.nes': 'nes', '.gb': 'gb', '.gbc': 'gb', '.a26': 'vcs', '.a78': 'atari7800', + '.sms': 'sms', '.col': 'coleco', '.vec': 'vector', }; -var jsonMode = false; +/** Extensions always treated as ROMs, even if the contents look like text. */ +const ROM_EXTS = new Set(['.rom', '.bin', ...Object.keys(ROM_PLATFORMS)]); -function output(result: CLIResult): void { - if (jsonMode) { - console.log(JSON.stringify(result, null, 2)); - } else { - outputPretty(result); +const SHORT_FLAGS: { [short: string]: string } = { + p: 'platform', t: 'tool', o: 'output', f: 'frames', e: 'eval', +}; + +// Flags that never take a value, per command. Everything else consumes the +// next argument unless that argument is another flag. +const BOOLEAN_FLAGS: { [command: string]: string[] } = { + build: ['check', 'symbols', 'save'], + run: ['info'], +}; + +const ALIASES: { [alias: string]: string } = { + compile: 'build', + check: 'build', + compilerun: 'run', +}; + +function parseArgs(argv: string[]): { command: string; args: Args; positional: string[] } { + let command = argv[2] || 'help'; + if (command.startsWith('-')) command = 'help'; + const resolved = ALIASES[command] || command; + const booleans = new Set(['json', ...(BOOLEAN_FLAGS[resolved] || [])]); + const args: Args = {}; + const positional: string[] = []; + if (command === 'check') args['check'] = true; + + for (let i = 3; i < argv.length; i++) { + const arg = argv[i]; + if (!arg.startsWith('-') || arg === '-') { positional.push(arg); continue; } + const key = arg.startsWith('--') ? arg.substring(2) : (SHORT_FLAGS[arg.substring(1)] || arg.substring(1)); + const next = argv[i + 1]; + if (!booleans.has(key) && next != null && !next.startsWith('--')) args[key] = argv[++i]; + else args[key] = true; } + return { command: resolved, args, positional }; } -function outputPretty(result: CLIResult): void { - // Status badge - if (result.success) { - process.stderr.write(`${c.bgGreen}${c.bold}${c.white} OK ${c.reset} `); - } else { - process.stderr.write(`${c.bgRed}${c.bold}${c.white} FAIL ${c.reset} `); - } - // Command name - process.stderr.write(`${c.bold}${c.cyan}${result.command}${c.reset}\n`); - - // Error message - if (result.error) { - process.stderr.write(`${c.red}Error: ${result.error}${c.reset}\n`); - } - - // Data - if (result.data) { - formatData(result.command, result.data); - } +function str(args: Args, key: string): string | undefined { + const v = args[key]; + return typeof v === 'string' ? v : undefined; } -function formatData(command: string, data: any): void { - switch (command) { - case 'help': - formatHelp(data); - break; - case 'compile': - case 'check': - formatCompile(data); - break; - case 'list-tools': - formatListTools(data); - break; - case 'list-platforms': - formatListPlatforms(data); - break; - default: - // Fallback: print as indented key-value pairs - formatGeneric(data); - break; - } +//////////////////////////////////////////////////////////////////////// +// build + +interface Build { + rom: Uint8Array; + symbolmap: { [name: string]: number }; + tool: string; + platform: string; + source: string; } -function formatHelp(data: any): void { - if (data.commands) { - console.log(`\n${c.bold}Usage:${c.reset} 8bws [options]\n`); - console.log(`${c.bold}Commands:${c.reset}`); - for (var [cmd, usage] of Object.entries(data.commands)) { - console.log(` ${c.green}${cmd}${c.reset}${c.dim} - ${usage}${c.reset}`); - } - console.log(`\n${c.bold}Global options:${c.reset}`); - console.log(` ${c.yellow}--json${c.reset}${c.dim} Output raw JSON instead of formatted text${c.reset}`); - console.log(` ${c.yellow}--save${c.reset}${c.dim} Save all intermediate build files to /tmp/8bws-${c.reset}`); - console.log(); - } -} - -function formatCompile(data: any): void { - if (data.errors) { - for (var err of data.errors) { - var 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; - } - if (data.tool) console.log(` ${c.dim}Tool:${c.reset} ${c.green}${data.tool}${c.reset}`); - if (data.platform) console.log(` ${c.dim}Platform:${c.reset} ${c.green}${data.platform}${c.reset}`); - if (data.source) console.log(` ${c.dim}Source:${c.reset} ${c.cyan}${data.source}${c.reset}`); - if (data.outputSize != null) console.log(` ${c.dim}Size:${c.reset} ${c.yellow}${data.outputSize}${c.reset} bytes`); - if (data.outputFile) console.log(` ${c.dim}Output:${c.reset} ${c.cyan}${data.outputFile}${c.reset}`); - if (data.hasListings) console.log(` ${c.dim}Listings:${c.reset} ${c.green}yes${c.reset}`); - if (data.hasSymbolmap) console.log(` ${c.dim}Symbols:${c.reset} ${c.green}yes${c.reset}`); - - // --symbols: dump symbol map - if (data.symbolmap) { - console.log(`\n${c.bold}Symbols${c.reset} ${c.dim}(${Object.keys(data.symbolmap).length})${c.reset}`); - var sorted = Object.entries(data.symbolmap).sort((a: any, b: any) => a[1] - b[1]); - for (var [name, addr] of sorted) { - console.log(` ${c.cyan}$${hex(addr as number, 4)}${c.reset} ${name}`); - } - } - - // --save: show saved files - 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 (var f of data.savedFiles) { - console.log(` ${c.dim}●${c.reset} ${f}`); - } - } - - // --symbols: dump segments - if (data.segments) { - console.log(`\n${c.bold}Segments${c.reset} ${c.dim}(${data.segments.length})${c.reset}`); - for (var 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}`); - } - } -} - -function formatListTools(data: any): void { - console.log(`\n${c.bold}Available tools${c.reset} ${c.dim}(${data.count})${c.reset}\n`); - for (var tool of data.tools) { - console.log(` ${c.green}●${c.reset} ${tool}`); - } - console.log(); -} - -function formatListPlatforms(data: any): void { - console.log(`\n${c.bold}Available platforms${c.reset} ${c.dim}(${data.count})${c.reset}\n`); - // Group by arch - let byArch: { [arch: string]: string[] } = {}; - for (let [name, info] of Object.entries(data.platforms) as [string, any][]) { - let arch = info.arch || 'unknown'; - if (!byArch[arch]) byArch[arch] = []; - byArch[arch].push(name); - } - for (let [arch, platforms] of Object.entries(byArch).sort()) { - console.log(` ${c.bold}${c.magenta}${arch}${c.reset}`); - for (let p of platforms.sort()) { - console.log(` ${c.green}●${c.reset} ${p}`); - } - } - console.log(); -} - -function formatGeneric(data: any): void { - for (var [key, value] of Object.entries(data)) { - if (typeof value === 'object' && value !== null) { - console.log(` ${c.dim}${key}:${c.reset} ${JSON.stringify(value)}`); - } else { - console.log(` ${c.dim}${key}:${c.reset} ${value}`); - } - } -} - -var BOOLEAN_FLAGS = new Set(['json', 'info', 'symbols', 'save']); - -function parseArgs(argv: string[]): { command: string; args: { [key: string]: string }; positional: string[] } { - var command = argv[2]; - var args: { [key: string]: string } = {}; - var positional: string[] = []; - - for (var i = 3; i < argv.length; i++) { - if (argv[i].startsWith('--')) { - var key = argv[i].substring(2); - if (BOOLEAN_FLAGS.has(key)) { - args[key] = 'true'; - } else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) { - args[key] = argv[++i]; - } else { - args[key] = 'true'; - } - } else { - positional.push(argv[i]); - } - } - - return { command, args, positional }; -} - -function usage(): void { - output({ - success: false, - command: 'help', - data: { - commands: { - 'compile': 'compile --platform [--tool ] [--output ] [--symbols] [--save] ', - 'check': 'check --platform [--tool ] ', - 'run': 'run (--platform | --machine ) [--frames N] [--script "cmds"|file] [--output ] [--memdump start,end] [--info] ', - 'script-cmds': RUN_SCRIPT_HELP, - 'list-tools': 'list-tools', - 'list-platforms': 'list-platforms', - } - }, - error: 'No command specified' - }); - process.exit(1); -} - -async function doCompile(args: { [key: string]: string }, positional: string[], checkOnly: boolean): Promise { - var tool = args['tool']; - var platform = args['platform']; - var outputFile = args['output']; - var sourceFile = positional[0]; - - if (!platform || !sourceFile) { - output({ - success: false, - command: checkOnly ? 'check' : 'compile', - error: 'Required: --platform [--tool ]' - }); - process.exit(1); - } - - // Auto-detect tool from filename if not specified - if (!tool) { - tool = getToolForFilename(sourceFile, platform); - } - +/** Compile one source file. Exits with the compiler's errors if it fails. */ +async function compileSource(args: Args, source: string, platform: string): Promise { + const { compileSourceFile, getToolForFilename, initialize, preload, TOOLS } = await import('./testlib'); + await initialize(); + const tool = str(args, 'tool') || getToolForFilename(source, platform); if (!TOOLS[tool]) { - output({ - success: false, - command: checkOnly ? 'check' : 'compile', - error: `Unknown tool: ${tool}. Use list-tools to see available tools.` - }); - process.exit(1); + fail('build', `Unknown tool: ${tool}. Use list-tools to see available tools.`); } - - // Preload the tool's filesystem if needed await preload(tool, platform); - - var result = await compileSourceFile(tool, platform, sourceFile); - + const result = await compileSourceFile(tool, platform, source); if (!result.success) { - output({ - success: false, - command: checkOnly ? 'check' : 'compile', - data: { errors: result.errors } - }); - process.exit(1); + fail('build', `${tool} failed on ${source}`, { errors: result.errors }); } + return { rom: romBytes(result), symbolmap: result.symbolmap || {}, tool, platform, source, result }; +} - if (checkOnly) { - output({ - success: true, - command: 'check', - data: { - tool: tool, - platform: platform, - source: sourceFile, - outputSize: result.output ? (result.output.code ? result.output.code.length : result.output.length) : 0, - } - }); - return; +async function doBuild(args: Args, positional: string[]): Promise { + const source = positional[0]; + const platform = str(args, 'platform'); + const checkOnly = !!args['check']; + if (!platform || !source) { + fail('build', 'Required: build --platform [--tool ] [-o ]'); } + const built = await compileSource(args, source, platform); - // Write output if requested - if (outputFile && result.output) { - var outData = result.output.code || result.output; - if (outData instanceof Uint8Array) { - fs.writeFileSync(outputFile, outData); - } else if (typeof outData === 'object') { - fs.writeFileSync(outputFile, JSON.stringify(outData)); - } else { - fs.writeFileSync(outputFile, outData); - } - } + const outputFile = str(args, 'output'); + if (outputFile && !checkOnly) fs.writeFileSync(outputFile, Buffer.from(built.rom)); - var outputSize = 0; - if (result.output) { - outputSize = result.output.code ? result.output.code.length : result.output.length; - } - - var compileData: any = { - tool: tool, - platform: platform, - source: sourceFile, - outputSize: outputSize, + const data: any = { + tool: built.tool, platform, source, + outputSize: built.rom.length, outputFile: outputFile || null, - hasListings: result.listings ? Object.keys(result.listings).length > 0 : false, - hasSymbolmap: !!result.symbolmap, }; - - if (args['symbols'] === 'true') { - if (result.symbolmap) compileData.symbolmap = result.symbolmap; - if (result.segments) compileData.segments = result.segments; + if (args['symbols']) { + if (built.result.symbolmap) data.symbolmap = built.result.symbolmap; + if (built.result.segments) data.segments = built.result.segments; } + if (args['save']) await saveBuildFiles(source, data); + output({ success: true, command: checkOnly ? 'check' : 'build', data }); +} - // --save: write all intermediate build files to /tmp/ - if (args['save'] === 'true') { - var baseName = path.basename(sourceFile, path.extname(sourceFile)); - var saveDir = path.join('/tmp', `8bws-${baseName}`); - fs.mkdirSync(saveDir, { recursive: true }); - var savedFiles: string[] = []; - for (var [filePath, entry] of Object.entries(store.workfs)) { - var outPath = path.join(saveDir, filePath); - fs.mkdirSync(path.dirname(outPath), { recursive: true }); - if (entry.data instanceof Uint8Array) { - fs.writeFileSync(outPath, entry.data); - } else { - fs.writeFileSync(outPath, entry.data); - } - savedFiles.push(filePath); +/** Dump every intermediate file the build produced to a temp directory. */ +async function saveBuildFiles(source: string, data: any): Promise { + const { store } = await import('./testlib'); + const saveDir = path.join(os.tmpdir(), `8bws-${path.basename(source, path.extname(source))}`); + fs.mkdirSync(saveDir, { recursive: true }); + data.savedFiles = []; + for (const [filePath, entry] of Object.entries(store.workfs)) { + const outPath = path.join(saveDir, filePath); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, entry.data as any); + data.savedFiles.push(filePath); + } + data.saveDir = saveDir; +} + +function romBytes(result: CompileResult): Uint8Array { + const out = result.output?.code ?? result.output; + if (out instanceof Uint8Array) return out; + if (typeof out === 'string') return new TextEncoder().encode(out); + throw new Error('compiler produced no ROM image'); +} + +//////////////////////////////////////////////////////////////////////// +// run + +/** A source file is anything that isn't obviously a ROM image. */ +function looksLikeROM(file: string): boolean { + if (ROM_EXTS.has(path.extname(file).toLowerCase())) return true; + return isProbablyBinary(file, fs.readFileSync(file)); +} + +async function openTarget(args: Args, platformId: string): Promise { + const target = await loadPlatform(platformId); + await target.start(); + const bios = str(args, 'bios'); + if (bios && !target.loadBIOS(new Uint8Array(fs.readFileSync(bios)))) { + fail('run', `'${target.id}' does not accept a BIOS image`); + } + return target; +} + +/** Turn the run flags into script commands, appended to any --script/-e text. */ +function buildScript(args: Args): string { + const parts: string[] = []; + if (args['frames']) parts.push(`run ${str(args, 'frames') ?? 1}`); + const script = str(args, 'eval') ?? str(args, 'script'); + if (script) parts.push(fs.existsSync(script) ? fs.readFileSync(script, 'utf8') : script); + if (args['info']) parts.push('info'); + const memdump = str(args, 'memdump'); + if (memdump) { + const [start, end] = memdump.split(',').map((s) => parseNum(s.startsWith('$') || /^0x/i.test(s) ? s : '$' + s)); + if (isNaN(start) || isNaN(end) || end < start) { + fail('run', `Invalid --memdump range: ${memdump} (use hex addresses like 0000,00ff)`); } - compileData.saveDir = saveDir; - compileData.savedFiles = savedFiles; + parts.push(`mem $${start.toString(16)} ${end - start + 1}`); } - - output({ - success: true, - command: 'compile', - data: compileData, - }); + return parts.length ? parts.join('\n') : 'run 1'; } -// -// 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; +async function doRun(args: Args, positional: string[]): Promise { + const input = positional[0]; + if (!input) { + fail('run', 'Required: run --platform '); } - 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}'`); -} + if (!fs.existsSync(input)) fail('run', `No such file: ${input}`); -// 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 { - var platformId = args['platform']; - var machine = args['machine']; - var frames = parseInt(args['frames'] || '1'); - var outputFile = args['output']; - var romFile = positional[0]; - - if ((!machine && !platformId) || !romFile) { - output({ - success: false, - command: 'run', - error: 'Required: (--platform | --machine ) [--frames N] [--output ] ' - }); - process.exit(1); + // A source file is built first; a ROM is loaded as-is. + let romFile = input; + let symbols: { [name: string]: number } = {}; + let platformId = str(args, 'platform') || ROM_PLATFORMS[path.extname(input).toLowerCase()]; + if (!looksLikeROM(input)) { + if (!platformId) fail('run', `Building ${input} requires --platform`); + const built = await compileSource(args, input, platformId); + romFile = path.join(os.tmpdir(), '8bws-' + path.basename(input).replace(/\.\w+$/, '') + '.rom'); + fs.writeFileSync(romFile, Buffer.from(built.rom)); + symbols = built.symbolmap; + note(`built ${input} with ${built.tool} -> ${romFile} (${built.rom.length} bytes)`); + } else if (!platformId) { + fail('run', `Cannot infer a platform from '${path.basename(input)}': pass --platform`); } - var romData = new Uint8Array(fs.readFileSync(romFile)); - var pixels: Uint32Array | null = null; - var vid: { width: number; height: number } | null = null; - var platformRunner: any = null; - var machineInstance: any = null; - var runner: any = null; + const target = await openTarget(args, platformId); + target.loadROM(new Uint8Array(fs.readFileSync(romFile)), path.basename(romFile)); - if (platformId) { - // Platform mode: load platform module, mock video, run via Platform API - var { PlatformRunner, loadPlatform } = await import('./runmachine'); - platformRunner = new PlatformRunner(await loadPlatform(platformId)); - await platformRunner.start(); - platformRunner.loadROM("ROM", romData); - } else { - // Machine mode: load machine class directly - var parts = machine.split(':'); - if (parts.length !== 2) { - output({ - success: false, - command: 'run', - error: 'Machine must be in format module:ClassName (e.g. apple2:AppleII)' - }); - process.exit(1); - } - var [modname, clsname] = parts; - var { MachineRunner, loadMachine } = await import('./runmachine'); - machineInstance = await loadMachine(modname, clsname); - runner = new MachineRunner(machineInstance); - runner.setup(); - machineInstance.loadROM(romData); - } - - // 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; + const script = new RunScript(target); + script.addSymbols(symbols); + const symbolFile = str(args, 'symbols'); + if (symbolFile) script.addSymbols(parseSymbolFile(fs.readFileSync(symbolFile, 'utf8'))); + script.startTracing(); + script.run(buildScript(args)); + const video = target.getVideo(); output({ success: true, command: 'run', data: { - platform: platformId || null, - machine: machine || null, + platform: target.id, rom: romFile, - frames: frames, - width: vid ? vid.width : null, - height: vid ? vid.height : null, - outputFile: outputFile || null, + frames: target.frameCount, + width: video?.width ?? null, + height: video?.height ?? null, + png: str(args, 'png') || null, } }); + await writeScreenshot(video, str(args, 'png')); +} - // --info: print debug info for all categories + disassembly at PC - if (args['info'] === 'true') { - var plat = platformId ? platformRunner.platform : null; - var mach = machine ? machineInstance : null; - var debugTarget: any = plat || mach; - if (debugTarget && isDebuggable(debugTarget)) { - var state = plat?.saveState?.() ?? mach?.saveState?.(); - if (state) { - var categories = debugTarget.getDebugCategories(); - for (var cat of categories) { - var info = debugTarget.getDebugInfo(cat, state); - if (info) { - process.stderr.write(`${c.bold}${c.magenta}[${cat}]${c.reset}\n`); - process.stderr.write(info); - if (!info.endsWith('\n')) process.stderr.write('\n'); - } - } - } - } - // Disassembly around current PC - if (debugTarget?.getPC && debugTarget?.disassemble && debugTarget?.readAddress) { - var pc = debugTarget.getPC(); - var readFn = (addr: number) => debugTarget.readAddress(addr); - process.stderr.write(`${c.bold}${c.magenta}[Disassembly]${c.reset}\n`); - var addr = pc; - for (var i = 0; i < 16; i++) { - var disasm = debugTarget.disassemble(addr, readFn); - var prefix = (addr === pc) ? `${c.green}>${c.reset}` : ' '; - // show hex bytes - var bytesStr = ''; - for (var b = 0; b < disasm.nbytes; b++) { - bytesStr += hex(readFn(addr + b)) + ' '; - } - process.stderr.write(`${prefix}${c.cyan}$${hex(addr, 4)}${c.reset} ${c.dim}${bytesStr.padEnd(12)}${c.reset} ${disasm.line}\n`); - addr += disasm.nbytes; - } - } - } - - // --memdump start,end: hexdump memory range - if (args['memdump']) { - var mdparts = args['memdump'].split(','); - var start = parseInt(mdparts[0], 16); - var end = parseInt(mdparts[1], 16); - if (isNaN(start) || isNaN(end) || end < start) { - output({ success: false, command: 'run', error: `Invalid --memdump range: ${args['memdump']} (use hex addresses like 0000,00ff)` }); - process.exit(1); - } - var plat2 = platformId ? platformRunner.platform : null; - var mach2 = machine ? machineInstance : null; - var readFn2: ((addr: number) => number) | null = null; - if (plat2?.readAddress) readFn2 = (addr) => plat2.readAddress(addr); - else if (mach2 && typeof (mach2 as any).read === 'function') readFn2 = (addr) => (mach2 as any).read(addr); - if (!readFn2) { - output({ success: false, command: 'run', error: 'Platform/machine does not support readAddress' }); - process.exit(1); - } - process.stdout.write(`memdump $${hex(start, 4)}-$${hex(end, 4)}:\n`); - hexdumpMem(readFn2, start, end); - } - - // Encode framebuffer as PNG if video is available - var pngData: Uint8Array | null = null; - if (pixels && vid) { - var { encode } = await import('fast-png'); - var rgba = new Uint8Array(pixels.buffer); - pngData = encode({ width: vid.width, height: vid.height, data: rgba, channels: 4 }); - } - - // Write PNG to file if requested - if (outputFile && pngData) { - fs.writeFileSync(outputFile, pngData); - } - - // Display image in terminal if connected to a TTY - if (pngData && process.stdout.isTTY) { - var { displayImageInTerminal } = await import('./termimage'); - displayImageInTerminal(pngData, vid.width, vid.height); +async function writeScreenshot(video: ReturnType, pngFile?: string): Promise { + if (!video) return; + const showInTerminal = process.stdout.isTTY; + if (!pngFile && !showInTerminal) return; + const { encode } = await import('fast-png'); + const png = encode({ + width: video.width, height: video.height, + data: new Uint8Array(video.pixels.buffer), channels: 4 + }); + if (pngFile) fs.writeFileSync(pngFile, png); + if (showInTerminal) { + const { displayImageInTerminal } = await import('./termimage'); + displayImageInTerminal(png, video.width, video.height); } } -function doListTools(): void { - var tools = listTools(); +//////////////////////////////////////////////////////////////////////// +// listings & help + +async function doList(command: string): Promise { + const { initialize, listPlatforms, listTools, PLATFORM_PARAMS } = await import('./testlib'); + await initialize(); + if (command === 'list-tools') { + output({ success: true, command, data: { tools: listTools() } }); + return; + } + const platforms: { [key: string]: any } = {}; + for (const p of listPlatforms()) platforms[p] = { arch: PLATFORM_PARAMS[p].arch || 'unknown' }; + output({ success: true, command, data: { platforms, count: Object.keys(platforms).length } }); +} + +function usage(error?: string): never { output({ - success: true, - command: 'list-tools', + success: !error, + command: 'help', + error, data: { - tools: tools, - count: tools.length + commands: { + 'build': 'compile a source file to a ROM', + 'run': 'run a ROM -- or a source file, built first', + 'list-platforms': 'platforms available to --platform', + 'list-tools': 'compilers and assemblers available to --tool', + }, + options: { + 'build options': { + '-p, --platform ': 'target platform (required)', + '-t, --tool ': 'compiler/assembler (default: from file extension)', + '-o, --output ': 'write the ROM here', + '--check': 'compile without writing anything', + '--symbols': 'dump the symbol table and segments', + '--save': 'save all intermediate build files to a temp dir', + }, + 'run options': { + '-p, --platform ': 'platform emulator (Platform interface)', + '-f, --frames ': 'advance N frames', + '-e ': 'inline run-script, e.g. -e "run 60; screen"', + '--script ': 'run-script file', + '--png ': 'write a screenshot of the last frame', + '--symbols ': 'load a .lbl/.sym file for symbolic addresses', + '--bios ': 'load a BIOS image', + '--info': 'dump debug info and disassembly when done', + '--memdump ': 'hexdump a hex address range', + }, + 'global options': { + '--json': 'machine-readable output on stdout', + }, + }, + script: RUN_SCRIPT_HELP, } }); + process.exit(error ? 1 : 0); } -function doListPlatforms(): void { - var platforms = listPlatforms(); - var details: { [key: string]: any } = {}; - for (var p of platforms) { - details[p] = { - arch: PLATFORM_PARAMS[p].arch || 'unknown', - }; - } - output({ - success: true, - command: 'list-platforms', - data: { - platforms: details, - count: platforms.length - } - }); -} +//////////////////////////////////////////////////////////////////////// async function main() { - if (process.argv.length < 3) { - usage(); - } - - var { command, args, positional } = parseArgs(process.argv); - - // Check for --json flag (can appear before or after the command) - if (args['json'] === 'true' || process.argv.includes('--json')) { - jsonMode = true; - } - + const { command, args, positional } = parseArgs(process.argv); + if (args['json']) setJsonMode(true); + // A platform whose start() never settles (usually one that needs a + // browser-only library) drains the event loop and would otherwise exit 0. + process.on('exit', () => { + if (hasOutput()) return; + output({ success: false, command, error: `${command} did not run to completion -- the emulator never finished starting` }); + process.exitCode = 1; + }); try { switch (command) { - case 'compile': - await initialize(); - await doCompile(args, positional, false); - break; - case 'check': - await initialize(); - await doCompile(args, positional, true); - break; - case 'run': - await doRun(args, positional); - break; + case 'build': await doBuild(args, positional); break; + case 'run': await doRun(args, positional); break; case 'list-tools': - await initialize(); - doListTools(); - break; - case 'list-platforms': - await initialize(); - doListPlatforms(); - break; - default: - output({ - success: false, - command: command, - error: `Unknown command: ${command}` - }); - process.exit(1); + case 'list-platforms': await doList(command); break; + case 'help': usage(); break; + default: usage(`Unknown command: ${command}`); } - } catch (e) { - console.log(e); - output({ - success: false, - command: command, - error: e.message || String(e) - }); + } catch (e: any) { + if (process.env.DEBUG) console.error(e); + output({ success: false, command, error: e.message || String(e) }); process.exit(1); } } diff --git a/src/tools/cliformat.ts b/src/tools/cliformat.ts new file mode 100644 index 00000000..cc225190 --- /dev/null +++ b/src/tools/cliformat.ts @@ -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 [options] \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`); + } +} diff --git a/src/tools/emutarget.ts b/src/tools/emutarget.ts new file mode 100644 index 00000000..64645e46 --- /dev/null +++ b/src/tools/emutarget.ts @@ -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; + + 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, 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 { + 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(); +} diff --git a/src/tools/runmachine.ts b/src/tools/runmachine.ts deleted file mode 100644 index 96ebbab9..00000000 --- a/src/tools/runmachine.ts +++ /dev/null @@ -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; - - 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 { - // 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 { - 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(); -} diff --git a/src/tools/runscript.ts b/src/tools/runscript.ts new file mode 100644 index 00000000..85583431 --- /dev/null +++ b/src/tools/runscript.ts @@ -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(); + 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; +} diff --git a/src/tools/termimage.ts b/src/tools/termimage.ts index 3091c470..a2ee8502 100644 --- a/src/tools/termimage.ts +++ b/src/tools/termimage.ts @@ -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=;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\\`); } diff --git a/test/cli/testcli.js b/test/cli/testcli.js new file mode 100644 index 00000000..237f3d5e --- /dev/null +++ b/test/cli/testcli.js @@ -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); + }); + }); +});