From a1b26e86997d527d3783c72ea69544ea19413876 Mon Sep 17 00:00:00 2001 From: Pascal Garber Date: Sat, 4 Jul 2026 11:47:20 +0200 Subject: [PATCH] Cli: headless 6502 frontend (assemble/run/disasm/hexdump) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A command-line frontend for Learn6502 — the "fourth frontend" alongside the GNOME, web and Android apps, built on the SAME shared libraries rather than sitting underneath them: `@learn6502/core` (assembler + simulator) and `@learn6502/common-ui` (the `DisplayWidget` contract, `createSimulatorStack`, `DEFAULT_COLOR_PALETTE`). It doubles as an executable proof that the core is genuinely UI-independent. Commands (`packages/cli/src/`): - `assemble ` — assemble and print the assembler's own result message - `run ` — assemble, run to completion (via `debugExecStep`, with a step cap for programs that loop/await input), and render the display - `hexdump ` — assemble and print a hexdump - `disasm ` — assemble and print the disassembly `AnsiDisplay` implements the shared `DisplayWidget` interface — the terminal is just another platform. The 32×32 grid of memory `$0200-$05FF` prints as truecolor ANSI using the upper half-block `▀` (foreground = top pixel, background = bottom), so the screen stays roughly square. Built node-free-ready via `gjsify build --app node`. The batch commands (assemble/disasm/hexdump) are thin wrappers over core; only `run` reaches for common-ui — an honest split between batch and interactive use. The GUIs remain library consumers; nothing depends on this CLI. --- packages/cli/package.json | 29 ++++++++++ packages/cli/src/ansi-display.ts | 69 ++++++++++++++++++++++++ packages/cli/src/commands.ts | 90 ++++++++++++++++++++++++++++++++ packages/cli/src/main.ts | 39 ++++++++++++++ packages/cli/tsconfig.json | 19 +++++++ 5 files changed, 246 insertions(+) create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/ansi-display.ts create mode 100644 packages/cli/src/commands.ts create mode 100644 packages/cli/src/main.ts create mode 100644 packages/cli/tsconfig.json diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..e9375921 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,29 @@ +{ + "name": "@learn6502/cli", + "version": "0.7.0", + "description": "Headless 6502 assembler / simulator — the command-line frontend built on the shared core + common-ui", + "type": "module", + "private": true, + "bin": { + "learn6502": "./dist/cli.js" + }, + "scripts": { + "build": "gjsify build src/main.ts --app node --outfile dist/cli.js", + "check": "gjsify tsc --noEmit", + "start": "node dist/cli.js" + }, + "author": "Pascal Garber", + "license": "GPL-3.0", + "gjsify": { + "shebang": "/usr/bin/env node" + }, + "devDependencies": { + "@gjsify/cli": "^0.16.3", + "@types/node": "^25.9.1", + "typescript": "^6.0.3" + }, + "dependencies": { + "@learn6502/common-ui": "^0.7.0", + "@learn6502/core": "^0.7.0" + } +} diff --git a/packages/cli/src/ansi-display.ts b/packages/cli/src/ansi-display.ts new file mode 100644 index 00000000..3aa52cc6 --- /dev/null +++ b/packages/cli/src/ansi-display.ts @@ -0,0 +1,69 @@ +import { DEFAULT_COLOR_PALETTE, DEFAULT_DISPLAY_CONFIG, type DisplayWidget } from "@learn6502/common-ui"; +import { DisplayAddressRange, type Memory } from "@learn6502/core"; + +const RESET = "\x1b[0m"; + +/** Parse an Adwaita palette `#rrggbb` string into an [r, g, b] triple. */ +function hexToRgb(hex: string): [number, number, number] { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]; +} + +/** + * AnsiDisplay — the 6502 console screen rendered to a terminal. + * + * It implements the SAME `DisplayWidget` interface from `@learn6502/common-ui` + * that the GTK (`Gtk.DrawingArea` + cairo), web (``) and Android + * displays implement — the terminal is just another platform. The 32×32 grid of + * memory `$0200-$05FF` is drawn with the shared 16-entry `DEFAULT_COLOR_PALETTE` + * as truecolor ANSI. Two vertical pixels share one character cell via the upper + * half-block `▀` (foreground = top pixel, background = bottom), so the 32×32 + * screen prints as 32 columns × 16 rows and stays roughly square in a terminal. + */ +export class AnsiDisplay implements DisplayWidget { + private memory: Memory | null = null; + private readonly numX = DEFAULT_DISPLAY_CONFIG.numX; + private readonly numY = DEFAULT_DISPLAY_CONFIG.numY; + private readonly palette = DEFAULT_COLOR_PALETTE.map(hexToRgb); + + // --- DisplayWidget interface --- + + /** Bind to memory. The terminal repaints on demand (see `render`), not per pixel. */ + public initialize(memory: Memory): void { + this.memory = memory; + } + + public reset(): void { + // A terminal frame is produced on demand by `render()`; nothing to clear. + } + + public updatePixel(_addr: number): void { + // On-demand rendering reads memory directly; no per-pixel bookkeeping. + } + + public drawAllPixels(): void { + // On-demand rendering; the caller decides when to `render()`. + } + + /** Produce the current display memory as a block of truecolor ANSI half-blocks. */ + public render(): string { + const memory = this.memory; + if (!memory) return ""; + + const rgbAt = (x: number, y: number): [number, number, number] => { + const addr = DisplayAddressRange.START + y * this.numX + x; + return this.palette[memory.get(addr) & 0x0f]; + }; + + let out = ""; + for (let y = 0; y < this.numY; y += 2) { + for (let x = 0; x < this.numX; x++) { + const [tr, tg, tb] = rgbAt(x, y); + const [br, bg, bb] = rgbAt(x, y + 1); + out += `\x1b[38;2;${tr};${tg};${tb}m\x1b[48;2;${br};${bg};${bb}m▀`; + } + out += `${RESET}\n`; + } + return out; + } +} diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts new file mode 100644 index 00000000..f93a1ce0 --- /dev/null +++ b/packages/cli/src/commands.ts @@ -0,0 +1,90 @@ +import { readFileSync } from "node:fs"; + +import { createSimulatorStack } from "@learn6502/common-ui"; +import { formatMessage } from "@learn6502/core"; + +import { AnsiDisplay } from "./ansi-display.js"; + +/** Default safety cap so a program with an unbounded loop (e.g. a game waiting + * for input the CLI never sends) still terminates instead of spinning forever. */ +const DEFAULT_MAX_STEPS = 500_000; + +function readSource(file: string): string { + return readFileSync(file, "utf8"); +} + +/** Assemble the source, printing the assembler's own success/failure message. */ +export function assemble(file: string): number { + const { assembler } = createSimulatorStack(); + let message = ""; + assembler.on("assemble-success", (event) => { + message = formatMessage(event.message ?? "", event.params ?? []); + }); + assembler.on("assemble-failure", (event) => { + message = formatMessage(event.message ?? "", event.params ?? []); + }); + + const ok = assembler.assembleCode(readSource(file)); + if (ok) console.log(message); + else console.error(message || "Assembly failed."); + return ok ? 0 : 1; +} + +/** Assemble, run to completion (or the step cap), and render the display. */ +export function run(file: string, maxSteps = DEFAULT_MAX_STEPS): number { + const { assembler, simulator, memory } = createSimulatorStack(); + + let failureMessage = ""; + assembler.on("assemble-failure", (event) => { + failureMessage = formatMessage(event.message ?? "", event.params ?? []); + }); + if (!assembler.assembleCode(readSource(file))) { + console.error(failureMessage || "Assembly failed."); + return 1; + } + + const display = new AnsiDisplay(); + display.initialize(memory); + + simulator.reset(); + let completed = false; + simulator.on("stop", () => { + completed = true; + }); + + let steps = 0; + while (!completed && steps < maxSteps) { + simulator.debugExecStep(); + steps++; + } + + process.stdout.write(display.render()); + if (completed) { + console.log(`Program completed after ${steps} steps.`); + } else { + console.log(`Stopped at the ${maxSteps}-step cap — the program may loop or wait for input.`); + } + return 0; +} + +/** Assemble and print a hexdump of the machine code. */ +export function hexdump(file: string): number { + const { assembler } = createSimulatorStack(); + if (!assembler.assembleCode(readSource(file))) { + console.error("Assembly failed."); + return 1; + } + console.log(assembler.hexdump({ includeAddress: true, includeSpaces: true, includeNewline: true })); + return 0; +} + +/** Assemble and print the disassembly of the machine code. */ +export function disasm(file: string): number { + const { assembler } = createSimulatorStack(); + if (!assembler.assembleCode(readSource(file))) { + console.error("Assembly failed."); + return 1; + } + console.log(assembler.disassemble().formatted); + return 0; +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts new file mode 100644 index 00000000..86c99699 --- /dev/null +++ b/packages/cli/src/main.ts @@ -0,0 +1,39 @@ +import { assemble, disasm, hexdump, run } from "./commands.js"; + +const USAGE = `learn6502 — headless 6502 assembler / simulator + +The command-line frontend of Learn6502, built on the SAME @learn6502/core +(assembler + simulator) and @learn6502/common-ui (the DisplayWidget contract) +that the GNOME, web and Android apps use. + +Usage: + learn6502 assemble Assemble and report the result + learn6502 run Assemble, run, and render the display + learn6502 hexdump Assemble and print a hexdump + learn6502 disasm Assemble and print the disassembly +`; + +function main(): number { + const [command, file] = process.argv.slice(2); + + if (!command || command === "--help" || command === "-h") { + console.log(USAGE); + return command ? 0 : 1; + } + + const commands: Record number> = { assemble, run, hexdump, disasm }; + const handler = commands[command]; + if (!handler) { + console.error(`Unknown command "${command}".\n\n${USAGE}`); + return 1; + } + + if (!file) { + console.error(`Missing for "${command}".\n\n${USAGE}`); + return 1; + } + + return handler(file); +} + +process.exit(main()); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 00000000..09ce2b2b --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "types": ["node"], + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "verbatimModuleSyntax": true, + "isolatedModules": true + }, + "include": ["src/**/*"] +}