From 4856b60dc0a97d0e26ed6f35391fbbfd0add9ad4 Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Tue, 3 Mar 2026 23:05:11 +0100 Subject: [PATCH] cli: added --json and color mode, run w/ bitmap display, --platform --- package-lock.json | 14 ++ package.json | 1 + src/tools/8bws.ts | 284 +++++++++++++++++++++++++++++++++------- src/tools/runmachine.ts | 111 +++++++++++++++- src/tools/termimage.ts | 102 +++++++++++++++ 5 files changed, 464 insertions(+), 48 deletions(-) create mode 100644 src/tools/termimage.ts diff --git a/package-lock.json b/package-lock.json index 158803a8..ba6772a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "lzg": "^1.0.x", "mocha": "^10.7.3", "mocha-simple-html-reporter": "^2.0.0", + "supports-terminal-graphics": "^0.1.0", "typescript": "^5.9.2", "typescript-formatter": "^7.2.2" }, @@ -7872,6 +7873,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/supports-terminal-graphics": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/supports-terminal-graphics/-/supports-terminal-graphics-0.1.0.tgz", + "integrity": "sha512-+KdfozhS0Fw8y5Sghw8kkZNGT8nWYzJ1EzcoIvVjxhl+26TJTs26y02yfBgvc1jh5AS/c8jcI3xtahhR95KRyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/package.json b/package.json index 181d355f..45a1d87b 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "lzg": "^1.0.x", "mocha": "^10.7.3", "mocha-simple-html-reporter": "^2.0.0", + "supports-terminal-graphics": "^0.1.0", "typescript": "^5.9.2", "typescript-formatter": "^7.2.2" }, diff --git a/src/tools/8bws.ts b/src/tools/8bws.ts index f52e3de6..3ddf016a 100644 --- a/src/tools/8bws.ts +++ b/src/tools/8bws.ts @@ -12,26 +12,142 @@ interface CLIResult { error?: string; } -function outputJSON(result: CLIResult): void { - console.log(JSON.stringify(result, null, 2)); +// 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', +}; + +var jsonMode = false; + +function output(result: CLIResult): void { + if (jsonMode) { + console.log(JSON.stringify(result, null, 2)); + } else { + outputPretty(result); + } } -function usage(): void { - outputJSON({ - success: false, - command: 'help', - data: { - commands: { - 'compile': 'compile --platform [--tool ] [--output ] ', - 'check': 'check --platform [--tool ] ', - 'run': 'run --platform [--frames N] ', - 'list-tools': 'list-tools', - 'list-platforms': 'list-platforms', - } - }, - error: 'No command specified' - }); - process.exit(1); +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 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; + } +} + +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(); + } +} + +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}`); +} + +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}`); + } + } } function parseArgs(argv: string[]): { command: string; args: { [key: string]: string }; positional: string[] } { @@ -55,6 +171,24 @@ function parseArgs(argv: string[]): { command: string; args: { [key: string]: st return { command, args, positional }; } +function usage(): void { + output({ + success: false, + command: 'help', + data: { + commands: { + 'compile': 'compile --platform [--tool ] [--output ] ', + 'check': 'check --platform [--tool ] ', + 'run': 'run (--platform | --machine ) [--frames N] [--output ] ', + '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']; @@ -62,7 +196,7 @@ async function doCompile(args: { [key: string]: string }, positional: string[], var sourceFile = positional[0]; if (!platform || !sourceFile) { - outputJSON({ + output({ success: false, command: checkOnly ? 'check' : 'compile', error: 'Required: --platform [--tool ]' @@ -76,7 +210,7 @@ async function doCompile(args: { [key: string]: string }, positional: string[], } if (!TOOLS[tool]) { - outputJSON({ + output({ success: false, command: checkOnly ? 'check' : 'compile', error: `Unknown tool: ${tool}. Use list-tools to see available tools.` @@ -94,7 +228,7 @@ async function doCompile(args: { [key: string]: string }, positional: string[], var result = await compileSourceFile(tool, platform, sourceFile); if (!result.success) { - outputJSON({ + output({ success: false, command: checkOnly ? 'check' : 'compile', data: { errors: result.errors } @@ -103,7 +237,7 @@ async function doCompile(args: { [key: string]: string }, positional: string[], } if (checkOnly) { - outputJSON({ + output({ success: true, command: 'check', data: { @@ -133,7 +267,7 @@ async function doCompile(args: { [key: string]: string }, positional: string[], outputSize = result.output.code ? result.output.code.length : result.output.length; } - outputJSON({ + output({ success: true, command: 'compile', data: { @@ -149,42 +283,97 @@ async function doCompile(args: { [key: string]: string }, positional: string[], } async function doRun(args: { [key: string]: string }, positional: string[]): Promise { - var platform = args['platform']; + var platformId = args['platform']; + var machine = args['machine']; var frames = parseInt(args['frames'] || '1'); + var outputFile = args['output']; var romFile = positional[0]; - if (!platform || !romFile) { - outputJSON({ + if ((!machine && !platformId) || !romFile) { + output({ success: false, command: 'run', - error: 'Required: --platform ' + error: 'Required: (--platform | --machine ) [--frames N] [--output ] ' }); process.exit(1); } - // Dynamic import of MachineRunner - try { - var runmachine = require('./runmachine.js'); - var MachineRunner = runmachine.MachineRunner; - } catch (e) { - outputJSON({ - success: false, - command: 'run', - error: `Could not load MachineRunner: ${e.message}` - }); - process.exit(1); + var romData = new Uint8Array(fs.readFileSync(romFile)); + var pixels: Uint32Array | null = null; + var vid: { width: number; height: number } | null = null; + + if (platformId) { + // Platform mode: load platform module, mock video, run via Platform API + var { PlatformRunner, loadPlatform } = await import('./runmachine'); + var 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(':'); + 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'); + var machineInstance = await loadMachine(modname, clsname); + var 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; } - outputJSON({ - success: false, + // 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); + } + + output({ + success: true, command: 'run', - error: 'Run command not yet fully implemented (requires platform machine loading)' + data: { + platform: platformId || null, + machine: machine || null, + rom: romFile, + frames: frames, + width: vid ? vid.width : null, + height: vid ? vid.height : null, + outputFile: outputFile || null, + } }); + + // 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); + } } function doListTools(): void { var tools = listTools(); - outputJSON({ + output({ success: true, command: 'list-tools', data: { @@ -202,7 +391,7 @@ function doListPlatforms(): void { arch: PLATFORM_PARAMS[p].arch || 'unknown', }; } - outputJSON({ + output({ success: true, command: 'list-platforms', data: { @@ -219,6 +408,11 @@ async function main() { 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; + } + try { switch (command) { case 'compile': @@ -241,7 +435,7 @@ async function main() { doListPlatforms(); break; default: - outputJSON({ + output({ success: false, command: command, error: `Unknown command: ${command}` @@ -249,7 +443,7 @@ async function main() { process.exit(1); } } catch (e) { - outputJSON({ + output({ success: false, command: command, error: e.message || String(e) diff --git a/src/tools/runmachine.ts b/src/tools/runmachine.ts index 3e4a1ee1..15fba9a9 100644 --- a/src/tools/runmachine.ts +++ b/src/tools/runmachine.ts @@ -1,9 +1,19 @@ -import { hasAudio, hasSerialIO, hasVideo, Machine } from "../common/baseplatform"; +import { hasAudio, hasSerialIO, hasVideo, Machine, Platform } from "../common/baseplatform"; import { SampledAudioSink, SerialIOInterface } from "../common/devices"; +import { PLATFORMS } from "../common/emu"; +import * as emu from "../common/emu"; +import { getRootBasePlatform } from "../common/util"; global.atob = require('atob'); global.btoa = require('btoa'); +if (typeof window === 'undefined') { + (global as any).window = global; + (global as any).window.addEventListener = (global as any).window.addEventListener || function () { }; + (global as any).window.removeEventListener = (global as any).window.removeEventListener || function () { }; + (global as any).document = (global as any).document || { addEventListener() { }, removeEventListener() { } }; +} +try { (global as any).navigator = (global as any).navigator || {}; } catch (e) { } class NullAudio implements SampledAudioSink { feedSample(value: number, count: number): void { @@ -58,6 +68,98 @@ class SerialTestHarness implements SerialIOInterface { /// +// 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 () { }; + }; + (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) { + 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; @@ -86,10 +188,13 @@ export class MachineRunner { } } -async function loadMachine(modname: string, clsname: string): Promise { +export async function loadMachine(modname: string, clsname: string, ...ctorArgs: any[]): Promise { var mod = await import('../machine/' + modname); var cls = mod[clsname]; - var machine = new cls(); + if (!cls) { + throw new Error(`Class '${clsname}' not found in module '../machine/${modname}'`); + } + var machine = new cls(...ctorArgs); return machine; } diff --git a/src/tools/termimage.ts b/src/tools/termimage.ts new file mode 100644 index 00000000..3091c470 --- /dev/null +++ b/src/tools/termimage.ts @@ -0,0 +1,102 @@ +// Terminal image display using Kitty or iTerm2 protocols. +// Uses supports-terminal-graphics for detection; rendering has no npm deps. + +import supportsTerminalGraphics from 'supports-terminal-graphics'; + +/** + * Auto-pick a nearest-neighbor scale (2x, 3x, or 4x) so the image + * is large enough to see but fits roughly within the terminal. + */ +function autoScale(imageWidth: number, imageHeight: number): number { + const termCols = process.stdout.columns || 80; + // Assume ~8px per cell width; aim to fill about half the terminal width + const targetPx = termCols * 4; + const scale = Math.max(1, Math.min(4, Math.round(targetPx / imageWidth))); + return scale; +} + +/** + * Detect terminal type and display a PNG image inline. + * imageWidth/imageHeight are the original pixel dimensions (avoids re-decoding for Kitty/iTerm2). + * Returns true if the image was displayed, false otherwise. + */ +export function displayImageInTerminal(pngData: Uint8Array, imageWidth?: number, imageHeight?: number): boolean { + if (!process.stdout.isTTY) return false; + + // If dimensions not provided, read from PNG header + if (!imageWidth || !imageHeight) { + const dims = readPNGDimensions(pngData); + imageWidth = dims.width; + imageHeight = dims.height; + } + if (!imageWidth || !imageHeight) return false; + + const scale = autoScale(imageWidth, imageHeight); + const support = supportsTerminalGraphics.stdout; + + if (support.kitty) { + displayKitty(pngData, imageWidth, imageHeight, scale); + return true; + } + + if (support.iterm2) { + displayITerm2(pngData, imageWidth, imageHeight, scale); + return true; + } + + /* + if (support.sixel) { + displaySixel(pngData); + return true; + } + */ + + return false; +} + +/** Read width/height from a PNG IHDR chunk without full decode. */ +function readPNGDimensions(data: Uint8Array): { width: number; height: number } { + if (data.length >= 24 && data[12] === 0x49 && data[13] === 0x48 && data[14] === 0x44 && data[15] === 0x52) { + return { width: readU32(data, 16), height: readU32(data, 20) }; + } + return { width: 0, height: 0 }; +} + +/** + * Kitty graphics protocol: transmit PNG data as base64 chunks. + * c= and r= specify display size in terminal cells (~8px wide, ~16px tall). + * https://sw.kovidgoyal.net/kitty/graphics-protocol/ + */ +function displayKitty(pngData: Uint8Array, w: number, h: number, scale: number): void { + const b64 = Buffer.from(pngData).toString('base64'); + const chunkSize = 4096; + const cols = Math.ceil(w * scale / 8); + const rows = Math.ceil(h * scale / 16); + + 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\\`); + } else { + process.stdout.write(`\x1b_Gm=${more};${chunk}\x1b\\`); + } + } + process.stdout.write('\n'); +} + +/** + * iTerm2 inline image protocol with pixel-level width/height. + * https://iterm2.com/documentation-images.html + */ +function displayITerm2(pngData: Uint8Array, w: number, h: number, scale: number): void { + const b64 = Buffer.from(pngData).toString('base64'); + const pw = w * scale; + const ph = h * scale; + process.stdout.write(`\x1b]1337;File=inline=1;size=${pngData.length};width=${pw}px;height=${ph}px:${b64}\x07\n`); +} + +function readU32(data: Uint8Array, offset: number): number { + return (data[offset] << 24 | data[offset + 1] << 16 | data[offset + 2] << 8 | data[offset + 3]) >>> 0; +} +