Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
69 changes: 69 additions & 0 deletions packages/cli/src/ansi-display.ts
Original file line number Diff line number Diff line change
@@ -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 (`<canvas>`) 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;
}
}
90 changes: 90 additions & 0 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
@@ -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;
}
39 changes: 39 additions & 0 deletions packages/cli/src/main.ts
Original file line number Diff line number Diff line change
@@ -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 <file.asm> Assemble and report the result
learn6502 run <file.asm> Assemble, run, and render the display
learn6502 hexdump <file.asm> Assemble and print a hexdump
learn6502 disasm <file.asm> 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<string, (file: string) => 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 <file.asm> for "${command}".\n\n${USAGE}`);
return 1;
}

return handler(file);
}

process.exit(main());
19 changes: 19 additions & 0 deletions packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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/**/*"]
}
Loading