feat(pi-dosbox): persistent DOSBox with QBasic and agent tool

- DOSBox now starts at session_start and persists in background
- /dosbox command attaches UI to running instance (Ctrl+Q detaches)
- Added dosbox tool with actions: send_keys, screenshot, read_text
- Bundled QuickBASIC 4.5 files, mounted at C:\QB on startup
- Agent can interact with DOSBox programmatically via tool

Use: pi -e ./examples/extensions/pi-dosbox
Then: /dosbox to view, or let agent use the dosbox tool
This commit is contained in:
Mario Zechner
2026-01-22 05:03:39 +01:00
parent fbd6b7f9ba
commit 4f343f39b9
26 changed files with 1618 additions and 373 deletions
@@ -2,11 +2,9 @@
* DOSBox TUI Component
*
* Renders DOSBox framebuffer as an image in the terminal.
* Connects to the persistent DosboxInstance.
*/
import { createRequire } from "node:module";
import { dirname } from "node:path";
import { deflateSync } from "node:zlib";
import type { Component } from "@mariozechner/pi-tui";
import {
allocateImageId,
@@ -18,293 +16,12 @@ import {
matchesKey,
truncateToWidth,
} from "@mariozechner/pi-tui";
import type { CommandInterface, Emulators } from "emulators";
import { DosboxInstance } from "./dosbox-instance.js";
const MAX_WIDTH_CELLS = 120;
let emulatorsInstance: Emulators | undefined;
async function getEmulators(): Promise<Emulators> {
if (!emulatorsInstance) {
const require = createRequire(import.meta.url);
const distPath = dirname(require.resolve("emulators"));
// The emulators package assigns to global.emulators, not module.exports
await import("emulators");
const g = globalThis as unknown as { emulators: Emulators };
const emu = g.emulators;
emu.pathPrefix = `${distPath}/`;
emu.pathSuffix = "";
emulatorsInstance = emu;
}
return emulatorsInstance;
}
export class DosboxComponent implements Component {
private tui: { requestRender: () => void };
private onClose: () => void;
private ci: CommandInterface | null = null;
private image: Image | null = null;
private imageTheme: ImageTheme;
private frameWidth = 0;
private frameHeight = 0;
private loadingMessage = "Loading DOSBox...";
private errorMessage: string | null = null;
private cachedLines: string[] = [];
private cachedWidth = 0;
private cachedVersion = -1;
private version = 0;
private disposed = false;
private bundleData?: Uint8Array;
private kittyPushed = false;
private imageId: number;
wantsKeyRelease = true;
constructor(
tui: { requestRender: () => void },
fallbackColor: (s: string) => string,
onClose: () => void,
bundleData?: Uint8Array,
) {
this.tui = tui;
this.onClose = onClose;
this.bundleData = bundleData;
this.imageTheme = { fallbackColor };
this.imageId = allocateImageId();
void this.init();
}
private async init(): Promise<void> {
try {
const emu = await getEmulators();
const initData = this.bundleData ?? (await this.createDefaultBundle(emu));
this.ci = await emu.dosboxDirect(initData);
const events = this.ci.events();
events.onFrameSize((width: number, height: number) => {
this.frameWidth = width;
this.frameHeight = height;
this.version++;
this.tui.requestRender();
});
events.onFrame((rgb: Uint8Array | null, rgba: Uint8Array | null) => {
this.updateFrame(rgb, rgba);
});
events.onExit(() => {
this.dispose();
this.onClose();
});
// Push Kitty enhanced mode for proper key press/release
process.stdout.write("\x1b[>15u");
this.kittyPushed = true;
} catch (error) {
this.errorMessage = error instanceof Error ? error.message : String(error);
this.tui.requestRender();
}
}
private async createDefaultBundle(emu: Emulators): Promise<Uint8Array> {
const bundle = await emu.bundle();
bundle.autoexec(
"@echo off",
"cls",
"echo pi DOSBox",
"echo.",
"echo Pass a .jsdos bundle to run a game",
"echo.",
"dir",
);
return bundle.toUint8Array(true);
}
private updateFrame(rgb: Uint8Array | null, rgba: Uint8Array | null): void {
if (!this.frameWidth || !this.frameHeight) {
if (this.ci) {
this.frameWidth = this.ci.width();
this.frameHeight = this.ci.height();
}
if (!this.frameWidth || !this.frameHeight) return;
}
const rgbaFrame = rgba ?? (rgb ? expandRgbToRgba(rgb) : null);
if (!rgbaFrame) return;
const png = encodePng(this.frameWidth, this.frameHeight, rgbaFrame);
const base64 = png.toString("base64");
this.image = new Image(
base64,
"image/png",
this.imageTheme,
{ maxWidthCells: MAX_WIDTH_CELLS, imageId: this.imageId },
{ widthPx: this.frameWidth, heightPx: this.frameHeight },
);
this.version++;
this.tui.requestRender();
}
handleInput(data: string): void {
const released = isKeyRelease(data);
if (!released && matchesKey(data, Key.ctrl("q"))) {
this.dispose();
this.onClose();
return;
}
if (!this.ci) return;
const parsed = parseKeyWithModifiers(data);
if (!parsed) return;
const { keyCode, shift, ctrl, alt } = parsed;
if (shift) this.ci.sendKeyEvent(KBD.leftshift, !released);
if (ctrl) this.ci.sendKeyEvent(KBD.leftctrl, !released);
if (alt) this.ci.sendKeyEvent(KBD.leftalt, !released);
this.ci.sendKeyEvent(keyCode, !released);
}
invalidate(): void {
this.cachedWidth = 0;
}
render(width: number): string[] {
if (this.errorMessage) {
return [truncateToWidth(`DOSBox error: ${this.errorMessage}`, width)];
}
if (!this.ci) {
return [truncateToWidth(this.loadingMessage, width)];
}
if (!this.image) {
return [truncateToWidth("Waiting for DOSBox frame...", width)];
}
if (width === this.cachedWidth && this.cachedVersion === this.version) {
return this.cachedLines;
}
const imageLines = this.image.render(width);
const footer = truncateToWidth("\x1b[2mCtrl+Q to quit\x1b[22m", width);
const lines = [...imageLines, footer];
this.cachedLines = lines;
this.cachedWidth = width;
this.cachedVersion = this.version;
return lines;
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
// Delete the terminal image
process.stdout.write(deleteKittyImage(this.imageId));
if (this.kittyPushed) {
process.stdout.write("\x1b[<u");
this.kittyPushed = false;
}
if (this.ci) {
// Suppress emulators exit logging
const origLog = console.log;
const origError = console.error;
console.log = () => {};
console.error = () => {};
const ci = this.ci;
this.ci = null;
void ci
.exit()
.catch(() => undefined)
.finally(() => {
// Restore after a delay to catch async logging
setTimeout(() => {
console.log = origLog;
console.error = origError;
}, 100);
});
}
}
}
function expandRgbToRgba(rgb: Uint8Array): Uint8Array {
const rgba = new Uint8Array((rgb.length / 3) * 4);
for (let i = 0, j = 0; i < rgb.length; i += 3, j += 4) {
rgba[j] = rgb[i] ?? 0;
rgba[j + 1] = rgb[i + 1] ?? 0;
rgba[j + 2] = rgb[i + 2] ?? 0;
rgba[j + 3] = 255;
}
return rgba;
}
const CRC_TABLE = createCrcTable();
function encodePng(width: number, height: number, rgba: Uint8Array): Buffer {
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let y = 0; y < height; y++) {
const rowOffset = y * (stride + 1);
raw[rowOffset] = 0;
raw.set(rgba.subarray(y * stride, y * stride + stride), rowOffset + 1);
}
const compressed = deflateSync(raw);
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 6;
header[10] = 0;
header[11] = 0;
header[12] = 0;
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = createChunk("IHDR", header);
const idat = createChunk("IDAT", compressed);
const iend = createChunk("IEND", Buffer.alloc(0));
return Buffer.concat([signature, ihdr, idat, iend]);
}
function createChunk(type: string, data: Buffer): Buffer {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const typeBuffer = Buffer.from(type, "ascii");
const crcBuffer = Buffer.concat([typeBuffer, data]);
const crc = crc32(crcBuffer);
const crcOut = Buffer.alloc(4);
crcOut.writeUInt32BE(crc, 0);
return Buffer.concat([length, typeBuffer, data, crcOut]);
}
function crc32(buffer: Buffer): number {
let crc = 0xffffffff;
for (const byte of buffer) {
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function createCrcTable(): Uint32Array {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
if (c & 1) {
c = 0xedb88320 ^ (c >>> 1);
} else {
c >>>= 1;
}
}
table[i] = c >>> 0;
}
return table;
}
// js-dos key codes (from js-dos/src/window/dos/controls/keys.ts)
const KBD = {
// js-dos key codes
const KBD: Record<string, number> = {
enter: 257,
backspace: 259,
tab: 258,
@@ -340,6 +57,209 @@ const KBD = {
f12: 301,
};
export class DosboxComponent implements Component {
private tui: { requestRender: () => void };
private onClose: () => void;
private instance: DosboxInstance | null = null;
private image: Image | null = null;
private imageTheme: ImageTheme;
private loadingMessage = "Connecting to DOSBox...";
private errorMessage: string | null = null;
private cachedLines: string[] = [];
private cachedWidth = 0;
private cachedVersion = -1;
private version = 0;
private disposed = false;
private imageId: number;
private kittyPushed = false;
private frameListener: ((rgba: Uint8Array, width: number, height: number) => void) | null = null;
wantsKeyRelease = true;
constructor(tui: { requestRender: () => void }, fallbackColor: (s: string) => string, onClose: () => void) {
this.tui = tui;
this.onClose = onClose;
this.imageTheme = { fallbackColor };
this.imageId = allocateImageId();
void this.connect();
}
private async connect(): Promise<void> {
try {
this.instance = await DosboxInstance.getInstance();
// Set up frame listener
this.frameListener = (rgba: Uint8Array, width: number, height: number) => {
this.updateFrame(rgba, width, height);
};
this.instance.addFrameListener(this.frameListener);
// Get initial state
const state = this.instance.getState();
if (state.lastFrame && state.width && state.height) {
this.updateFrame(state.lastFrame, state.width, state.height);
}
// Push Kitty enhanced mode for proper key press/release
process.stdout.write("\x1b[>15u");
this.kittyPushed = true;
this.tui.requestRender();
} catch (error) {
this.errorMessage = error instanceof Error ? error.message : String(error);
this.tui.requestRender();
}
}
private updateFrame(rgba: Uint8Array, width: number, height: number): void {
const png = this.encodePng(width, height, rgba);
const base64 = png.toString("base64");
this.image = new Image(
base64,
"image/png",
this.imageTheme,
{ maxWidthCells: MAX_WIDTH_CELLS, imageId: this.imageId },
{ widthPx: width, heightPx: height },
);
this.version++;
this.tui.requestRender();
}
private encodePng(width: number, height: number, rgba: Uint8Array): Buffer {
const { deflateSync } = require("node:zlib");
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let y = 0; y < height; y++) {
const rowOffset = y * (stride + 1);
raw[rowOffset] = 0;
raw.set(rgba.subarray(y * stride, y * stride + stride), rowOffset + 1);
}
const compressed = deflateSync(raw);
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 6;
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = this.createChunk("IHDR", header);
const idat = this.createChunk("IDAT", compressed);
const iend = this.createChunk("IEND", Buffer.alloc(0));
return Buffer.concat([signature, ihdr, idat, iend]);
}
private createChunk(type: string, data: Buffer): Buffer {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const typeBuffer = Buffer.from(type, "ascii");
const crcBuffer = Buffer.concat([typeBuffer, data]);
const crc = this.crc32(crcBuffer);
const crcOut = Buffer.alloc(4);
crcOut.writeUInt32BE(crc, 0);
return Buffer.concat([length, typeBuffer, data, crcOut]);
}
private crc32(buffer: Buffer): number {
let crc = 0xffffffff;
for (const byte of buffer) {
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
handleInput(data: string): void {
const released = isKeyRelease(data);
if (!released && matchesKey(data, Key.ctrl("q"))) {
this.dispose();
this.onClose();
return;
}
const ci = this.instance?.getCommandInterface();
if (!ci) return;
const parsed = parseKeyWithModifiers(data);
if (!parsed) return;
const { keyCode, shift, ctrl, alt } = parsed;
if (shift) ci.sendKeyEvent(KBD.leftshift, !released);
if (ctrl) ci.sendKeyEvent(KBD.leftctrl, !released);
if (alt) ci.sendKeyEvent(KBD.leftalt, !released);
ci.sendKeyEvent(keyCode, !released);
}
invalidate(): void {
this.cachedWidth = 0;
}
render(width: number): string[] {
if (this.errorMessage) {
return [truncateToWidth(`DOSBox error: ${this.errorMessage}`, width)];
}
if (!this.instance?.isReady()) {
return [truncateToWidth(this.loadingMessage, width)];
}
if (!this.image) {
return [truncateToWidth("Waiting for DOSBox frame...", width)];
}
if (width === this.cachedWidth && this.cachedVersion === this.version) {
return this.cachedLines;
}
const imageLines = this.image.render(width);
const footer = truncateToWidth("\x1b[2mCtrl+Q to detach (DOSBox keeps running)\x1b[22m", width);
const lines = [...imageLines, footer];
this.cachedLines = lines;
this.cachedWidth = width;
this.cachedVersion = this.version;
return lines;
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
// Delete the terminal image
process.stdout.write(deleteKittyImage(this.imageId));
if (this.kittyPushed) {
process.stdout.write("\x1b[<u");
this.kittyPushed = false;
}
// Remove frame listener but DON'T dispose the instance
if (this.instance && this.frameListener) {
this.instance.removeFrameListener(this.frameListener);
}
}
}
const CRC_TABLE = createCrcTable();
function createCrcTable(): Uint32Array {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
if (c & 1) {
c = 0xedb88320 ^ (c >>> 1);
} else {
c >>>= 1;
}
}
table[i] = c >>> 0;
}
return table;
}
interface ParsedKey {
keyCode: number;
shift: boolean;
@@ -357,7 +277,6 @@ function decodeModifiers(modifierField: number): { shift: boolean; ctrl: boolean
}
function parseKeyWithModifiers(data: string): ParsedKey | null {
// Kitty CSI u sequences: \x1b[codepoint(:shifted(:base))?;modifier(:event)?u
if (data.startsWith("\x1b[") && data.endsWith("u")) {
const body = data.slice(2, -1);
const [keyPart, modifierPart] = body.split(";");
@@ -374,7 +293,6 @@ function parseKeyWithModifiers(data: string): ParsedKey | null {
}
}
// CSI sequences: \x1b[<num>;<modifier>:<event><suffix>
const csiMatch = data.match(/^\x1b\[(\d+);(\d+)(?::\d+)?([~A-Za-z])$/);
if (csiMatch) {
const code = parseInt(csiMatch[1], 10);
@@ -386,7 +304,6 @@ function parseKeyWithModifiers(data: string): ParsedKey | null {
return { keyCode, shift, ctrl, alt };
}
// Legacy/simple input
const keyCode = mapKeyToJsDos(data);
if (keyCode === null) return null;
const shift = data.length === 1 && data >= "A" && data <= "Z";
@@ -399,9 +316,9 @@ function codepointToJsDosKey(codepoint: number): number | null {
if (codepoint === 27) return KBD.esc;
if (codepoint === 8 || codepoint === 127) return KBD.backspace;
if (codepoint === 32) return KBD.space;
if (codepoint >= 97 && codepoint <= 122) return codepoint - 32; // a-z -> A-Z
if (codepoint >= 65 && codepoint <= 90) return codepoint; // A-Z
if (codepoint >= 48 && codepoint <= 57) return codepoint; // 0-9
if (codepoint >= 97 && codepoint <= 122) return codepoint - 32;
if (codepoint >= 65 && codepoint <= 90) return codepoint;
if (codepoint >= 48 && codepoint <= 57) return codepoint;
return null;
}
@@ -445,14 +362,6 @@ function mapCsiKeyToJsDos(code: number, suffix: string): number | null {
return KBD.pageup;
case 6:
return KBD.pagedown;
case 11:
return KBD.f1;
case 12:
return KBD.f2;
case 13:
return KBD.f3;
case 14:
return KBD.f4;
case 15:
return KBD.f5;
case 17:
@@ -0,0 +1,428 @@
/**
* Persistent DOSBox Instance Manager
*
* Manages a singleton DOSBox instance that runs in the background.
* Provides API for sending keys, reading screen, and taking screenshots.
*/
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { deflateSync } from "node:zlib";
import type { CommandInterface, Emulators } from "emulators";
const __dirname = dirname(fileURLToPath(import.meta.url));
let emulatorsInstance: Emulators | undefined;
async function getEmulators(): Promise<Emulators> {
if (!emulatorsInstance) {
const require = createRequire(import.meta.url);
const distPath = dirname(require.resolve("emulators"));
await import("emulators");
const g = globalThis as unknown as { emulators: Emulators };
const emu = g.emulators;
emu.pathPrefix = `${distPath}/`;
emu.pathSuffix = "";
emulatorsInstance = emu;
}
return emulatorsInstance;
}
export interface DosboxState {
width: number;
height: number;
lastFrame: Uint8Array | null;
isGraphicsMode: boolean;
}
export class DosboxInstance {
private static instance: DosboxInstance | null = null;
private ci: CommandInterface | null = null;
private state: DosboxState = {
width: 0,
height: 0,
lastFrame: null,
isGraphicsMode: false,
};
private frameListeners: Set<(rgba: Uint8Array, width: number, height: number) => void> = new Set();
private initPromise: Promise<void> | null = null;
private disposed = false;
private constructor() {}
static async getInstance(): Promise<DosboxInstance> {
if (!DosboxInstance.instance) {
DosboxInstance.instance = new DosboxInstance();
await DosboxInstance.instance.init();
}
return DosboxInstance.instance;
}
static getInstanceSync(): DosboxInstance | null {
return DosboxInstance.instance;
}
static async destroyInstance(): Promise<void> {
if (DosboxInstance.instance) {
await DosboxInstance.instance.dispose();
DosboxInstance.instance = null;
}
}
private async init(): Promise<void> {
if (this.initPromise) return this.initPromise;
this.initPromise = (async () => {
const emu = await getEmulators();
const bundle = await this.createBundle(emu);
this.ci = await emu.dosboxDirect(bundle);
// Mount QBasic files to C:
await this.mountQBasic();
const events = this.ci.events();
events.onFrameSize((width: number, height: number) => {
this.state.width = width;
this.state.height = height;
});
events.onFrame((rgb: Uint8Array | null, rgba: Uint8Array | null) => {
if (!this.state.width || !this.state.height) {
if (this.ci) {
this.state.width = this.ci.width();
this.state.height = this.ci.height();
}
}
const rgbaFrame = rgba ?? (rgb ? this.expandRgbToRgba(rgb) : null);
if (rgbaFrame) {
this.state.lastFrame = rgbaFrame;
// Detect graphics mode by checking if we're in standard text resolution
// Text mode is typically 640x400 or 720x400
this.state.isGraphicsMode = this.state.width !== 640 && this.state.width !== 720;
for (const listener of this.frameListeners) {
listener(rgbaFrame, this.state.width, this.state.height);
}
}
});
events.onExit(() => {
this.disposed = true;
DosboxInstance.instance = null;
});
})();
return this.initPromise;
}
private async createBundle(emu: Emulators): Promise<Uint8Array> {
const bundle = await emu.bundle();
bundle.autoexec(
"@echo off",
"mount c c:",
"c:",
"cls",
"echo QuickBASIC 4.5 mounted at C:\\QB",
"echo.",
"dir /w",
);
return bundle.toUint8Array(true);
}
private async mountQBasic(): Promise<void> {
if (!this.ci) return;
const fs = (this.ci as unknown as { transport: { module: { FS: EmscriptenFS } } }).transport.module.FS;
const rescan = (this.ci as unknown as { transport: { module: { _rescanFilesystem: () => void } } }).transport
.module._rescanFilesystem;
// Create directory structure
try {
fs.mkdir("/c");
} catch {
/* exists */
}
try {
fs.mkdir("/c/QB");
} catch {
/* exists */
}
// Read QBasic files from the extension directory
const qbasicDir = join(__dirname, "..", "qbasic");
const { readdirSync, readFileSync } = await import("node:fs");
const files = readdirSync(qbasicDir);
for (const file of files) {
if (file.startsWith(".")) continue;
try {
const data = readFileSync(join(qbasicDir, file));
fs.writeFile(`/c/QB/${file.toUpperCase()}`, data);
} catch (e) {
console.error(`Failed to mount ${file}:`, e);
}
}
// Rescan so DOS sees the new files
rescan();
}
private expandRgbToRgba(rgb: Uint8Array): Uint8Array {
const rgba = new Uint8Array((rgb.length / 3) * 4);
for (let i = 0, j = 0; i < rgb.length; i += 3, j += 4) {
rgba[j] = rgb[i] ?? 0;
rgba[j + 1] = rgb[i + 1] ?? 0;
rgba[j + 2] = rgb[i + 2] ?? 0;
rgba[j + 3] = 255;
}
return rgba;
}
isReady(): boolean {
return this.ci !== null && !this.disposed;
}
getState(): DosboxState {
return { ...this.state };
}
getCommandInterface(): CommandInterface | null {
return this.ci;
}
addFrameListener(listener: (rgba: Uint8Array, width: number, height: number) => void): void {
this.frameListeners.add(listener);
}
removeFrameListener(listener: (rgba: Uint8Array, width: number, height: number) => void): void {
this.frameListeners.delete(listener);
}
/**
* Send key events to DOSBox
*/
sendKeys(keys: string): void {
if (!this.ci) return;
for (const key of keys) {
const keyCode = this.charToKeyCode(key);
if (keyCode !== null) {
const needsShift = this.needsShift(key);
if (needsShift) {
this.ci.sendKeyEvent(KBD.leftshift, true);
}
this.ci.sendKeyEvent(keyCode, true);
this.ci.sendKeyEvent(keyCode, false);
if (needsShift) {
this.ci.sendKeyEvent(KBD.leftshift, false);
}
}
}
}
/**
* Send a special key (enter, backspace, etc.)
*/
sendSpecialKey(key: "enter" | "backspace" | "tab" | "escape" | "up" | "down" | "left" | "right" | "f5"): void {
if (!this.ci) return;
const keyCode = KBD[key];
if (keyCode) {
this.ci.sendKeyEvent(keyCode, true);
this.ci.sendKeyEvent(keyCode, false);
}
}
/**
* Read text-mode screen content
*/
readScreenText(): string | null {
if (!this.ci) return null;
try {
// Try to get screen text from emulators API
const text = (this.ci as unknown as { screenText?: () => string }).screenText?.();
return text ?? null;
} catch {
return null;
}
}
/**
* Get screenshot as PNG base64
*/
getScreenshot(): { base64: string; width: number; height: number } | null {
if (!this.state.lastFrame || !this.state.width || !this.state.height) {
return null;
}
const png = this.encodePng(this.state.width, this.state.height, this.state.lastFrame);
return {
base64: png.toString("base64"),
width: this.state.width,
height: this.state.height,
};
}
private encodePng(width: number, height: number, rgba: Uint8Array): Buffer {
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let y = 0; y < height; y++) {
const rowOffset = y * (stride + 1);
raw[rowOffset] = 0;
raw.set(rgba.subarray(y * stride, y * stride + stride), rowOffset + 1);
}
const compressed = deflateSync(raw);
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 6;
header[10] = 0;
header[11] = 0;
header[12] = 0;
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = this.createChunk("IHDR", header);
const idat = this.createChunk("IDAT", compressed);
const iend = this.createChunk("IEND", Buffer.alloc(0));
return Buffer.concat([signature, ihdr, idat, iend]);
}
private createChunk(type: string, data: Buffer): Buffer {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const typeBuffer = Buffer.from(type, "ascii");
const crcBuffer = Buffer.concat([typeBuffer, data]);
const crc = this.crc32(crcBuffer);
const crcOut = Buffer.alloc(4);
crcOut.writeUInt32BE(crc, 0);
return Buffer.concat([length, typeBuffer, data, crcOut]);
}
private crc32(buffer: Buffer): number {
let crc = 0xffffffff;
for (const byte of buffer) {
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
private charToKeyCode(char: string): number | null {
const lower = char.toLowerCase();
if (lower >= "a" && lower <= "z") {
return lower.charCodeAt(0) - 32; // A-Z = 65-90
}
if (char >= "0" && char <= "9") {
return char.charCodeAt(0); // 0-9 = 48-57
}
if (char === " ") return KBD.space;
if (char === "\n" || char === "\r") return KBD.enter;
if (char === "\t") return KBD.tab;
// Common punctuation
const punct: Record<string, number> = {
".": 46,
",": 44,
";": 59,
":": 59, // shift
"'": 39,
'"': 39, // shift
"-": 45,
_: 45, // shift
"=": 61,
"+": 61, // shift
"[": 91,
"]": 93,
"\\": 92,
"/": 47,
"!": 49, // shift+1
"@": 50, // shift+2
"#": 51, // shift+3
$: 52, // shift+4
"%": 53, // shift+5
"^": 54, // shift+6
"&": 55, // shift+7
"*": 56, // shift+8
"(": 57, // shift+9
")": 48, // shift+0
};
return punct[char] ?? null;
}
private needsShift(char: string): boolean {
if (char >= "A" && char <= "Z") return true;
return '~!@#$%^&*()_+{}|:"<>?'.includes(char);
}
async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
this.frameListeners.clear();
if (this.ci) {
const origLog = console.log;
const origError = console.error;
console.log = () => {};
console.error = () => {};
try {
await this.ci.exit();
} catch {
/* ignore */
}
setTimeout(() => {
console.log = origLog;
console.error = origError;
}, 100);
this.ci = null;
}
}
}
// js-dos key codes
const KBD: Record<string, number> = {
enter: 257,
backspace: 259,
tab: 258,
escape: 256,
space: 32,
leftshift: 340,
up: 265,
down: 264,
left: 263,
right: 262,
f5: 294,
};
const CRC_TABLE = createCrcTable();
function createCrcTable(): Uint32Array {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
if (c & 1) {
c = 0xedb88320 ^ (c >>> 1);
} else {
c >>>= 1;
}
}
table[i] = c >>> 0;
}
return table;
}
// Emscripten FS type
interface EmscriptenFS {
mkdir(path: string): void;
writeFile(path: string, data: Uint8Array | Buffer): void;
readFile(path: string): Uint8Array;
readdir(path: string): string[];
unlink(path: string): void;
}
@@ -1,51 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Standalone DOSBox TUI app
*
* Usage: npx tsx src/main.ts [bundle.jsdos]
*/
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
import { DosboxComponent } from "./dosbox-component.js";
async function main() {
const bundlePath = process.argv[2];
let bundleData: Uint8Array | undefined;
if (bundlePath) {
try {
const resolvedPath = resolve(process.cwd(), bundlePath);
bundleData = await readFile(resolvedPath);
console.log(`Loading bundle: ${resolvedPath}`);
} catch (error) {
console.error(`Failed to load bundle: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
const terminal = new ProcessTerminal();
const tui = new TUI(terminal);
const fallbackColor = (s: string) => `\x1b[33m${s}\x1b[0m`;
const component = new DosboxComponent(
tui,
fallbackColor,
() => {
tui.stop();
process.exit(0);
},
bundleData,
);
tui.addChild(component);
tui.setFocus(component);
tui.start();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});