1
0
mirror of https://github.com/sehugg/8bitworkshop.git synced 2026-03-10 21:25:31 +00:00

cli: added --json and color mode, run w/ bitmap display, --platform

This commit is contained in:
Steven Hugg
2026-03-03 23:05:11 +01:00
parent e003fed52a
commit 4856b60dc0
5 changed files with 464 additions and 48 deletions

14
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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 <platform> [--tool <tool>] [--output <file>] <source>',
'check': 'check --platform <platform> [--tool <tool>] <source>',
'run': 'run --platform <platform> [--frames N] <rom>',
'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 <command> [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 <platform> [--tool <tool>] [--output <file>] <source>',
'check': 'check --platform <platform> [--tool <tool>] <source>',
'run': 'run (--platform <id> | --machine <module:ClassName>) [--frames N] [--output <file.png>] <rom>',
'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<void> {
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 <platform> <source> [--tool <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<void> {
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 <platform> <rom>'
error: 'Required: (--platform <id> | --machine <module:ClassName>) [--frames N] [--output <file.png>] <rom>'
});
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)

View File

@@ -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<typeof installHeadlessVideo>;
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<Platform> {
// Derive the base module name (e.g. "nes" from "nes-asm", "c64" from "c64.wasm")
var baseId = getRootBasePlatform(platformId);
// Dynamically load the platform module which registers into PLATFORMS
await import('../platform/' + baseId);
var PlatformClass = PLATFORMS[platformId];
if (!PlatformClass) {
// Try the base platform ID
PlatformClass = PLATFORMS[baseId];
}
if (!PlatformClass) {
var available = Object.keys(PLATFORMS).join(', ');
throw new Error(`Platform '${platformId}' not found. Available: ${available}`);
}
var platform = new PlatformClass(null);
return platform;
}
///
export class MachineRunner {
machine: Machine;
pixels: Uint32Array;
@@ -86,10 +188,13 @@ export class MachineRunner {
}
}
async function loadMachine(modname: string, clsname: string): Promise<Machine> {
export async function loadMachine(modname: string, clsname: string, ...ctorArgs: any[]): Promise<Machine> {
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;
}

102
src/tools/termimage.ts Normal file
View File

@@ -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;
}