mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 09:01:14 +00:00
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:
parent
fbd6b7f9ba
commit
4f343f39b9
26 changed files with 1618 additions and 373 deletions
|
|
@ -1,44 +1,183 @@
|
|||
/**
|
||||
* DOSBox extension for pi
|
||||
*
|
||||
* Features:
|
||||
* - Persistent DOSBox instance running in background
|
||||
* - QuickBASIC 4.5 mounted at C:\QB
|
||||
* - /dosbox command to view and interact with DOSBox
|
||||
* - dosbox tool for agent to send keys, read screen, take screenshots
|
||||
*
|
||||
* Usage: pi --extension ./examples/extensions/pi-dosbox
|
||||
* Command: /dosbox [bundle.jsdos]
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { DosboxComponent } from "./src/dosbox-component.js";
|
||||
import { DosboxInstance } from "./src/dosbox-instance.js";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("dosbox", {
|
||||
description: "Run DOSBox emulator",
|
||||
// Start DOSBox instance at session start
|
||||
pi.on("session_start", async () => {
|
||||
try {
|
||||
await DosboxInstance.getInstance();
|
||||
} catch (error) {
|
||||
console.error("Failed to start DOSBox:", error);
|
||||
}
|
||||
});
|
||||
|
||||
handler: async (args, ctx) => {
|
||||
// Clean up on session shutdown
|
||||
pi.on("session_shutdown", async () => {
|
||||
await DosboxInstance.destroyInstance();
|
||||
});
|
||||
|
||||
// Register /dosbox command to view DOSBox
|
||||
pi.registerCommand("dosbox", {
|
||||
description: "View and interact with DOSBox (Ctrl+Q to detach)",
|
||||
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("DOSBox requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const bundlePath = args?.trim();
|
||||
let bundleData: Uint8Array | undefined;
|
||||
if (bundlePath) {
|
||||
try {
|
||||
const resolvedPath = resolve(ctx.cwd, bundlePath);
|
||||
bundleData = await readFile(resolvedPath);
|
||||
} catch (error) {
|
||||
ctx.ui.notify(
|
||||
`Failed to load bundle: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Ensure instance is running
|
||||
const instance = DosboxInstance.getInstanceSync();
|
||||
if (!instance || !instance.isReady()) {
|
||||
ctx.ui.notify("DOSBox is not running. It should start automatically.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.ui.custom((tui, theme, _kb, done) => {
|
||||
const fallbackColor = (s: string) => theme.fg("warning", s);
|
||||
return new DosboxComponent(tui, fallbackColor, () => done(undefined), bundleData);
|
||||
return new DosboxComponent(tui, fallbackColor, () => done(undefined));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Register dosbox tool for agent interaction
|
||||
pi.registerTool({
|
||||
name: "dosbox",
|
||||
label: "DOSBox",
|
||||
description: `Interact with DOSBox emulator running QuickBASIC 4.5.
|
||||
Actions:
|
||||
- send_keys: Send keystrokes to DOSBox. Use \\n for Enter, \\t for Tab.
|
||||
- screenshot: Get a PNG screenshot of the current DOSBox screen.
|
||||
- read_text: Read text-mode screen content (returns null in graphics mode).
|
||||
|
||||
QuickBASIC 4.5 is mounted at C:\\QB. Run "C:\\QB\\QB.EXE" to start it.`,
|
||||
parameters: Type.Object({
|
||||
action: StringEnum(["send_keys", "screenshot", "read_text"] as const, {
|
||||
description: "The action to perform",
|
||||
}),
|
||||
keys: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"For send_keys: the keys to send. Use \\n for Enter, \\t for Tab, or special:<key> for special keys (enter, backspace, tab, escape, up, down, left, right, f5)",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
|
||||
const { action, keys } = params;
|
||||
|
||||
const instance = DosboxInstance.getInstanceSync();
|
||||
if (!instance || !instance.isReady()) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: DOSBox is not running" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case "send_keys": {
|
||||
if (!keys) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: keys parameter required for send_keys action" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Handle special keys
|
||||
if (keys.startsWith("special:")) {
|
||||
const specialKey = keys.slice(8) as
|
||||
| "enter"
|
||||
| "backspace"
|
||||
| "tab"
|
||||
| "escape"
|
||||
| "up"
|
||||
| "down"
|
||||
| "left"
|
||||
| "right"
|
||||
| "f5";
|
||||
instance.sendSpecialKey(specialKey);
|
||||
return {
|
||||
content: [{ type: "text", text: `Sent special key: ${specialKey}` }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Handle escape sequences
|
||||
const processedKeys = keys.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\r/g, "\r");
|
||||
|
||||
instance.sendKeys(processedKeys);
|
||||
return {
|
||||
content: [{ type: "text", text: `Sent ${processedKeys.length} characters` }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
case "screenshot": {
|
||||
const screenshot = instance.getScreenshot();
|
||||
if (!screenshot) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: No frame available yet" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
data: screenshot.base64,
|
||||
mimeType: "image/png",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `Screenshot: ${screenshot.width}x${screenshot.height} pixels`,
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
case "read_text": {
|
||||
const text = instance.readScreenText();
|
||||
if (text === null) {
|
||||
const state = instance.getState();
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Screen is in graphics mode (${state.width}x${state.height}). Use screenshot action to see the display.`,
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: text || "(empty screen)" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: Unknown action: ${action}` }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "npx tsx src/main.ts",
|
||||
"clean": "echo 'nothing to clean'",
|
||||
"build": "echo 'nothing to build'",
|
||||
"check": "echo 'nothing to check'"
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,71 @@
|
|||
'***
|
||||
' QB.BI - Assembly Support Include File
|
||||
'
|
||||
' Copyright <C> 1987 Microsoft Corporation
|
||||
'
|
||||
' Purpose:
|
||||
' This include file defines the types and gives the DECLARE
|
||||
' statements for the assembly language routines ABSOLUTE,
|
||||
' INTERRUPT, INTERRUPTX, INT86OLD, and INT86XOLD.
|
||||
'
|
||||
'***************************************************************************
|
||||
'
|
||||
' Define the type needed for INTERRUPT
|
||||
'
|
||||
TYPE RegType
|
||||
ax AS INTEGER
|
||||
bx AS INTEGER
|
||||
cx AS INTEGER
|
||||
dx AS INTEGER
|
||||
bp AS INTEGER
|
||||
si AS INTEGER
|
||||
di AS INTEGER
|
||||
flags AS INTEGER
|
||||
END TYPE
|
||||
'
|
||||
' Define the type needed for INTERUPTX
|
||||
'
|
||||
TYPE RegTypeX
|
||||
ax AS INTEGER
|
||||
bx AS INTEGER
|
||||
cx AS INTEGER
|
||||
dx AS INTEGER
|
||||
bp AS INTEGER
|
||||
si AS INTEGER
|
||||
di AS INTEGER
|
||||
flags AS INTEGER
|
||||
ds AS INTEGER
|
||||
es AS INTEGER
|
||||
END TYPE
|
||||
'
|
||||
' DECLARE statements for the 5 routines
|
||||
' -------------------------------------
|
||||
'
|
||||
' Generate a software interrupt, loading all but the segment registers
|
||||
'
|
||||
DECLARE SUB INTERRUPT (intnum AS INTEGER,inreg AS RegType,outreg AS RegType)
|
||||
'
|
||||
' Generate a software interrupt, loading all registers
|
||||
'
|
||||
DECLARE SUB INTERRUPTX (intnum AS INTEGER,inreg AS RegTypeX, outreg AS RegTypeX)
|
||||
'
|
||||
' Call a routine at an absolute address.
|
||||
' NOTE: If the routine called takes parameters, then they will have to
|
||||
' be added to this declare statement before the parameter given.
|
||||
'
|
||||
DECLARE SUB ABSOLUTE (address AS INTEGER)
|
||||
'
|
||||
' Generate a software interrupt, loading all but the segment registers
|
||||
' (old version)
|
||||
'
|
||||
DECLARE SUB INT86OLD (intnum AS INTEGER,_
|
||||
inarray(1) AS INTEGER,_
|
||||
outarray(1) AS INTEGER)
|
||||
'
|
||||
' Gemerate a software interrupt, loading all the registers
|
||||
' (old version)
|
||||
'
|
||||
DECLARE SUB INT86XOLD (intnum AS INTEGER,_
|
||||
inarray(1) AS INTEGER,_
|
||||
outarray(1) AS INTEGER)
|
||||
'
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,198 @@
|
|||
'
|
||||
' QUICKBASIC SUPPORT ROUTINES FOR THEDRAW OBJECT FILES
|
||||
'-----------------------------------------------------------------------------
|
||||
' Compatible with Microsoft QuickBasic v4.0 and v4.5 text modes.
|
||||
'-----------------------------------------------------------------------------
|
||||
'
|
||||
' There are a few routines within the QB4UTIL.LIB file. These are
|
||||
' (along with brief descriptions):
|
||||
'
|
||||
' UNCRUNCH - Flash display routine for crunched image files.
|
||||
' ASCIIDISPLAY - Display routine for ascii only image files.
|
||||
' NORMALDISPLAY - Display routine for normal full binary image files.
|
||||
' INITSCREENARRAY - Maps a dynamic integer array to the physical video
|
||||
' memory.
|
||||
'
|
||||
'=============================================================================
|
||||
' UNCRUNCH (imagedata,video offset)
|
||||
' ASCIIDISPLAY (imagedata,video offset)
|
||||
' NORMALDISPLAY (imagedata,video offset)
|
||||
'=============================================================================
|
||||
'
|
||||
' These three subroutines operate similarly. Each takes a specific data
|
||||
' format (TheDraw crunched data, ascii only, or normal binary) and displays
|
||||
' the image on the screen. Monochrome and color text video displays are
|
||||
' supported. The integer offset parameter is useful with block images,
|
||||
' giving control over where the block appears.
|
||||
'
|
||||
' Example calls:
|
||||
' CALL UNCRUNCH (ImageData&,vidoffset%) <- for crunched data
|
||||
' CALL ASCIIDISPLAY (ImageData&,vidoffset%) <- for ascii-only data
|
||||
' CALL NORMALDISPLAY (ImageData&,vidoffset%) <- for normal binary data
|
||||
'
|
||||
' The parameter IMAGEDATA is the identifier you assign when saving
|
||||
' a QuickBasic object file with TheDraw. ImageData actually becomes a
|
||||
' short function returning information Uncrunch, AsciiDisplay, and
|
||||
' NormalDisplay use to find the screen contents. In addition, three
|
||||
' other related integer functions are created. Assuming the identifier
|
||||
' IMAGEDATA, these are:
|
||||
'
|
||||
' IMAGEDATAWIDTH%
|
||||
' IMAGEDATADEPTH%
|
||||
' IMAGEDATALENGTH%
|
||||
'
|
||||
' The width and depth functions return the size of the block in final
|
||||
' form (ie: a full screen would yield the numbers 80 and 25 respectfully).
|
||||
' The length function returns the size of the stored data. For crunched
|
||||
' files and block saves this might be very small. For a 80x25 full screen
|
||||
' binary image it will be 4000 bytes. The integer functions are useful for
|
||||
' computing screen or window dimensions, etc...
|
||||
'
|
||||
' You must declare all four functions in your Basic source code before
|
||||
' they can be used (naturally). The following code example illustrates.
|
||||
' The identifier used is IMAGEDATA. The data is a 40 character by 10 line
|
||||
' block saved as normal binary.
|
||||
'
|
||||
' ----------------------------------------------------------------------
|
||||
' REM $INCLUDE: 'QB4UTIL.BI'
|
||||
' DECLARE FUNCTION ImageData& ' Important! Do not neglect
|
||||
' DECLARE FUNCTION ImageDataWidth% ' the "&" and "%" symbols
|
||||
' DECLARE FUNCTION ImageDataDepth% ' after the function names.
|
||||
' DECLARE FUNCTION ImageDataLength%
|
||||
'
|
||||
' CALL NORMALDISPLAY (ImageData&, 34 *2+( 5 *160)-162)
|
||||
' ----------------------------------------------------------------------
|
||||
'
|
||||
' That's it! The above displays the 40x10 block at screen coordinates
|
||||
' column 34, line 5 (note these two numbers in above example). If the
|
||||
' data was crunched or ascii use the corresponding routine.
|
||||
'
|
||||
' Note: The ascii-only screen image does not have any color controls.
|
||||
' Whatever the on-screen colors were before, they will be after.
|
||||
' You might want to insert COLOR and CLS statements before calling
|
||||
' the ASCIIDISPLAY routine.
|
||||
'
|
||||
' Regardless of which routine used, each remembers the original horizontal
|
||||
' starting column when it goes to the next line. This permits a block to
|
||||
' be displayed correctly anywhere on the screen. ie:
|
||||
'
|
||||
' +-------------------------------------------------+
|
||||
' | |
|
||||
' | | <- Pretend this
|
||||
' | | is the video
|
||||
' | ÚÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄ¿ | display.
|
||||
' | ³ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ³ |
|
||||
' | ³ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ³ |
|
||||
' | ³ÛÛ ImageData block ÛÛ³ |
|
||||
' | ³ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ³ |
|
||||
' | ³ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ³ |
|
||||
' | ³ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ³ |
|
||||
' | ÀÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÙ |
|
||||
' | |
|
||||
' | |
|
||||
' | |
|
||||
' +-------------------------------------------------+
|
||||
'
|
||||
'
|
||||
' The ImageData block could be shown in the upper-left corner of the
|
||||
' screen by changing the call to:
|
||||
'
|
||||
' CALL NORMALDISPLAY (ImageData&,0)
|
||||
'
|
||||
' Notice the video offset has been removed, since we want the upper-left
|
||||
' corner. To display the block in the lower-right corner you would use:
|
||||
'
|
||||
' CALL NORMALDISPLAY (ImageData&, 40 *2+( 15 *160)-162)
|
||||
'
|
||||
' The block is 40 characters wide by 10 lines deep. Therefore to display
|
||||
' such a large block, we must display the block at column 40, line 15.
|
||||
' (column 80 minus 40, line 25 minus 10).
|
||||
'
|
||||
'
|
||||
' NOTES ON THE UNCRUNCH ROUTINE
|
||||
' --------------------------------------------------------------------------
|
||||
'
|
||||
' Many people favor "crunching" screens with TheDraw because the size
|
||||
' of the data generally goes down. When uncrunching an image however,
|
||||
' there is no guarantee what was previously on-screen will be replaced.
|
||||
'
|
||||
' In particular, the uncruncher assumes the screen is previously erased to
|
||||
' black thus permitting better data compression. For instance, assume the
|
||||
' video completely filled with blocks, overwritten by an uncrunched image:
|
||||
'
|
||||
' ÚÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄ¿ ÚÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄ¿
|
||||
' ³ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ³ ³tetetetetetÛÛÛÛÛÛÛÛÛÛ³
|
||||
' ³ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ³ ³ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ³
|
||||
' ³ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ³ ³ eteteteteteteÛÛÛÛ³
|
||||
' ³ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ³ ³tetetetetÛÛÛÛÛÛÛÛÛÛÛÛ³
|
||||
' ³ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ³ ³ eteÛÛÛÛÛÛÛÛÛ³
|
||||
' ³ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ³ ³ etetetetetetÛÛ³
|
||||
' ÀÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÙ ÀÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÙ
|
||||
' before uncrunch after uncrunch
|
||||
'
|
||||
' By omitting a CLS statement, the new text appears surrounded by bits of
|
||||
' the previous screen. Proper usage would typically be:
|
||||
'
|
||||
' ----------------------------------------------------------------------
|
||||
' REM $INCLUDE: 'QB4UTIL.BI'
|
||||
' DECLARE FUNCTION ImageData& ' Important! Do not neglect
|
||||
' DECLARE FUNCTION ImageDataWidth% ' the "&" and "%" symbols
|
||||
' DECLARE FUNCTION ImageDataDepth% ' after the function names.
|
||||
' DECLARE FUNCTION ImageDataLength%
|
||||
'
|
||||
' COLOR 15,0 : CLS ' Clear to black screen
|
||||
' CALL UNCRUNCH (ImageData&, 34 *2+( 5 *160)-162)
|
||||
' ----------------------------------------------------------------------
|
||||
'
|
||||
'
|
||||
'=============================================================================
|
||||
' INITSCREENARRAY
|
||||
'=============================================================================
|
||||
'
|
||||
' To directly access the video screen memory requires you to use the
|
||||
' PEEK/POKE statements after setting the DEF SEG value. A cumbersome
|
||||
' and compiler inefficient approach. In addition, you must have some
|
||||
' way of determining if a monochrome or color video is being used before
|
||||
' the DEF SEG can be set properly.
|
||||
'
|
||||
' This subroutine offers a simpler approach, by effectively mapping or
|
||||
' placing an integer array over the video screen. Instead of PEEK/POKE,
|
||||
' you merely reference an array element. ie:
|
||||
'
|
||||
' ----------------------------------------------------------------------
|
||||
' REM $INCLUDE: 'QB4UTIL.BI'
|
||||
'
|
||||
' REM $DYNAMIC <- very important to place this before DIM statement
|
||||
' DIM S%(0)
|
||||
' CALL INITSCREENARRAY (S%())
|
||||
'
|
||||
' S%(0) = ASC("H") + 15 *256 + 1 *4096
|
||||
' S%(1) = ASC("E") + 15 *256 + 1 *4096
|
||||
' S%(2) = ASC("L") + 15 *256 + 1 *4096
|
||||
' S%(3) = ASC("L") + 15 *256 + 1 *4096
|
||||
' S%(4) = ASC("O") + 15 *256 + 1 *4096
|
||||
' ----------------------------------------------------------------------
|
||||
'
|
||||
' The above example directly places the message "HELLO" on the screen
|
||||
' for you, in white lettering (the 15*256) on a blue background (1*4096).
|
||||
' To alter the foreground color, change the 15's to some other number.
|
||||
' Change the 1's for the background color.
|
||||
'
|
||||
' Each array element contains both the character to display plus the
|
||||
' color information. This explains the bit of math following each
|
||||
' ASC statement. You could minimize this using a FOR/NEXT loop.
|
||||
'
|
||||
' The S% array has 2000 elements (0 to 1999) representing the entire
|
||||
' 80 by 25 line video. If in an EGA/VGA screen mode change the 1999 to
|
||||
' 3439 or 3999 respectfully.
|
||||
'
|
||||
' There is no pressing reason to use the array approach, however it
|
||||
' does free up the DEFSEG/PEEK/POKE combination for other uses. In
|
||||
' any case, enjoy!
|
||||
'
|
||||
'
|
||||
DECLARE SUB UNCRUNCH (X&, Z%)
|
||||
DECLARE SUB ASCIIDISPLAY (X&, Z%)
|
||||
DECLARE SUB NORMALDISPLAY (X&, Z%)
|
||||
DECLARE SUB INITSCREENARRAY (A%())
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,545 @@
|
|||
README.DOC File
|
||||
|
||||
Release Notes for Microsoft (R) QuickBASIC
|
||||
|
||||
Version 4.50
|
||||
|
||||
(C) Copyright Microsoft Corporation, 1990
|
||||
|
||||
Product Serial Number: 00-007-1450-26147102
|
||||
|
||||
|
||||
This document contains release notes for version 4.50 of the Microsoft (R)
|
||||
QuickBASIC for MS-DOS (R). The information in this document is more
|
||||
up-to-date than that in the manuals.
|
||||
|
||||
|
||||
================================================================================
|
||||
Contents
|
||||
================================================================================
|
||||
|
||||
|
||||
Part Description
|
||||
---- -----------
|
||||
|
||||
1 Using QuickBASIC on a Two-Floppy System
|
||||
|
||||
2 Using Your Mouse with QuickBASIC
|
||||
|
||||
3 Supplementary Information on Mixed-Language Programming
|
||||
|
||||
4 Using Btrieve with QuickBASIC
|
||||
|
||||
5 Using the DOS 3.2 Patch for Math Accuracy
|
||||
|
||||
6 Miscellaneous Information About Using QuickBASIC
|
||||
|
||||
|
||||
================================================================================
|
||||
Part 1: Using QuickBASIC on a Two-Floppy System
|
||||
================================================================================
|
||||
|
||||
|
||||
Installing QuickBASIC on Floppy Disks
|
||||
-------------------------------------
|
||||
|
||||
The SETUP program can install QuickBASIC on floppy disks for use
|
||||
with a two-floppy system. You must run SETUP to install QuickBASIC
|
||||
on floppy disks. You cannot run QuickBASIC from the disks provided,
|
||||
because the files are stored in a compressed format.
|
||||
|
||||
Before you install QuickBASIC on your two-floppy system, be sure
|
||||
you have enough blank, formatted disks. If you have 360K disk
|
||||
drives, you will need five blank disks. For 720K disk drives, you
|
||||
will need three blank disks.
|
||||
|
||||
To install QuickBASIC, put Disk #1 in drive A. Type A:\SETUP and
|
||||
press Enter.
|
||||
|
||||
When your installation is complete, you should label each disk with
|
||||
the names of the files that are on that disk. QuickBASIC will ask
|
||||
you to swap disks when it cannot find a file that it needs, and
|
||||
you will need to know which disk the file is on.
|
||||
|
||||
If you use 360K disks, label them as follows:
|
||||
|
||||
PROGRAM:
|
||||
QB.EXE QB45QCK.HLP
|
||||
|
||||
UTILITIES:
|
||||
BC.EXE LINK.EXE
|
||||
BQLB45.LIB LIB.EXE
|
||||
BRUN45.EXE QB.QLB
|
||||
BRUN45.LIB QB.LIB
|
||||
|
||||
UTILITIES 2:
|
||||
BCOM45.LIB QB45ENER.HLP
|
||||
|
||||
ADVISOR:
|
||||
QB45ADVR.HLP
|
||||
|
||||
EXAMPLES
|
||||
QB.BI BASIC examples
|
||||
|
||||
If you use 720K disks, label them as follows:
|
||||
|
||||
PROGRAM/EXAMPLES:
|
||||
QB.EXE QB45QCK.HLP
|
||||
QB.BI BASIC examples
|
||||
|
||||
UTILITIES:
|
||||
BC.EXE LINK.EXE
|
||||
BQLB45.LIB LIB.EXE
|
||||
BRUN45.EXE QB.QLB
|
||||
BRUN45.LIB QB.LIB
|
||||
BCOM45.LIB
|
||||
|
||||
ADVISOR:
|
||||
QB45ADVR.HLP QB45ENER.HLP
|
||||
|
||||
|
||||
Running QuickBASIC from Floppy Disks
|
||||
------------------------------------
|
||||
|
||||
During some operations, QuickBASIC asks you to swap disks one
|
||||
or more times. You can minimize disk swapping by following the
|
||||
procedures in this section.
|
||||
|
||||
Since the disks that you installed QuickBASIC on are nearly full, you
|
||||
should keep your BASIC source-code (.BAS) files on a separate disk.
|
||||
Label this disk SOURCE.
|
||||
|
||||
Copy the run-time module BRUN45.EXE from the UTILITIES disk to your
|
||||
SOURCE disk. QuickBASIC needs this file to run executable programs
|
||||
compiled with the run-time support option.
|
||||
|
||||
When you use QuickBASIC, a disk containing source-code (.BAS) files
|
||||
should always be in drive B. If you want to run existing BASIC
|
||||
programs (such as the example programs provided with QuickBASIC),
|
||||
remove the SOURCE disk from drive B and insert the disk containing
|
||||
these files.
|
||||
|
||||
To run QuickBASIC:
|
||||
|
||||
1. Insert the SOURCE disk in drive B.
|
||||
|
||||
2. To make drive B the current drive, type B: and press Enter.
|
||||
|
||||
3. Insert the PROGRAM disk (the disk containing QB.EXE) in drive A.
|
||||
|
||||
4. Type the following command:
|
||||
|
||||
A:QB.EXE
|
||||
|
||||
To insure that QuickBASIC always looks on both disk drives for the
|
||||
files it needs, follow these steps:
|
||||
|
||||
1. From the Options menu, choose Set Paths.
|
||||
|
||||
2. Make sure each of the path settings includes both disk drives. The
|
||||
following line should be in all four text boxes:
|
||||
|
||||
A:\;B:\
|
||||
|
||||
3. Choose OK.
|
||||
|
||||
QuickBASIC saves these path settings in the QB.INI file, so you will
|
||||
not have to enter them again.
|
||||
|
||||
When you exit QuickBASIC or shell to DOS, you will be prompted to
|
||||
insert a disk containing the file COMMAND.COM. Remove the PROGRAM
|
||||
disk from drive A, insert a system disk, and press Enter.
|
||||
|
||||
|
||||
Using Help from Floppy Disks
|
||||
----------------------------
|
||||
|
||||
When you use the QuickBASIC Advisor online help system, you may need
|
||||
to swap disks. For example, if you choose "Details" or "Example" on a
|
||||
help screen, QuickBASIC will inform you that it cannot find the help
|
||||
file QB45ADVR.HLP. Put the disk that contains this file in drive A and
|
||||
choose Retry.
|
||||
|
||||
|
||||
Compiling Your Programs from Floppy Disks
|
||||
-----------------------------------------
|
||||
|
||||
To compile your program from within QuickBASIC:
|
||||
|
||||
1. From the Run menu, choose Make EXE File.
|
||||
|
||||
2. Choose Make EXE. QuickBASIC displays the message "Cannot find file
|
||||
(BC.EXE)."
|
||||
|
||||
3. Insert the UTILITIES disk (the disk containing BC.EXE) in drive A.
|
||||
Type A: and press Enter.
|
||||
|
||||
If the program compiles successfully, QuickBASIC invokes the LINK
|
||||
utility. If LINK cannot find the library, it displays the following
|
||||
message:
|
||||
|
||||
LINK : warning L4051 : BCOM45.LIB : cannot find library
|
||||
Enter new file spec:
|
||||
|
||||
4. Insert the disk containing the requested library (BCOM45.LIB or
|
||||
BRUN45.LIB) in drive A.
|
||||
|
||||
Note: The requested library may be located on the UTILITIES disk
|
||||
already in drive A. If this is the case, leave this disk in drive A.
|
||||
|
||||
5. Type A: and press Enter. After the LINK utility finishes creating
|
||||
your executable program, QuickBASIC displays the message "Cannot
|
||||
find file (QB.EXE)."
|
||||
|
||||
6. Insert the PROGRAM disk in drive A.
|
||||
|
||||
7. Type A: and press Enter.
|
||||
|
||||
If no errors occur during compiling or linking, your compiled program
|
||||
(.EXE) is created on drive B. QuickBASIC also creates an object-module
|
||||
(.OBJ) file. To save space, you can delete object-module files.
|
||||
|
||||
|
||||
================================================================================
|
||||
Part 2: Using Your Mouse with QuickBASIC
|
||||
================================================================================
|
||||
|
||||
|
||||
New Mouse Driver for Use with QuickBASIC
|
||||
----------------------------------------
|
||||
|
||||
QuickBASIC Version 4.5 can be used with any mouse that is 100%
|
||||
compatible with the Microsoft Mouse. However, you must use a
|
||||
Microsoft Mouse driver Version 6.00 or later. Earlier versions may
|
||||
cause unpredictable behavior when used with QuickBASIC. MOUSE.COM,
|
||||
Version 6.24 is supplied with QuickBASIC Version 4.5.
|
||||
|
||||
Especially if you are writing programs that use the mouse, you
|
||||
should use the supplied version of the mouse driver when working in
|
||||
QuickBASIC. Previous versions have included MOUSE.SYS, which is
|
||||
installed by including the line DEVICE=MOUSE.SYS in your CONFIG.SYS
|
||||
file. This version of QuickBASIC includes MOUSE.COM, which is not
|
||||
installed via CONFIG.SYS. To install MOUSE.COM, just type MOUSE at
|
||||
the DOS prompt. To include MOUSE.COM automatically when your machine
|
||||
boots, make sure MOUSE.COM is in your search path, then put the line
|
||||
|
||||
MOUSE
|
||||
|
||||
in your AUTOEXEC.BAT file. To free up memory, you can remove the
|
||||
mouse driver at any time by typing MOUSE OFF at the DOS prompt.
|
||||
This will restore between 9K and 10.5K of memory with Version 6.11.
|
||||
|
||||
|
||||
Using Mouse Function Calls from QuickBASIC Programs
|
||||
---------------------------------------------------
|
||||
|
||||
If you are programming for the Microsoft Mouse, you should obtain
|
||||
the Microsoft Mouse Programmer's Reference Guide and the library
|
||||
MOUSE.LIB that comes with it. (These are not included in QuickBASIC
|
||||
or Mouse package and must be ordered separately). Most of the
|
||||
information in the Mouse Programmer's Reference Guide applies
|
||||
directly to QuickBASIC Version 4.5. However, the following additional
|
||||
restrictions must be observed:
|
||||
|
||||
Certain Mouse function calls (Functions 9 & 16) require you to set
|
||||
up an integer array and pass the address of the array to the mouse
|
||||
driver. For previous versions, the only restriction on this array
|
||||
was that it had to be $STATIC (the default array type). In QuickBASIC
|
||||
Version 4.5, however, the array also must be in a COMMON block if you
|
||||
will be making the Mouse function call from within the QuickBASIC
|
||||
environment. In addition, it is recommended that the support code
|
||||
for the Mouse call be in a Quick library or linked into the
|
||||
executable file when making Mouse function calls from QuickBASIC.
|
||||
|
||||
To produce a Quick library for using Mouse function calls from
|
||||
within the QuickBASIC environment, use the following command line
|
||||
(produces MOUSE.QLB):
|
||||
|
||||
LINK MOUSE.LIB/QU,MOUSE.QLB,,BQLB40.LIB/NOE;
|
||||
|
||||
An example from PIANO.BAS (included with the Microsoft Mouse
|
||||
Programmer's Reference) for using Mouse function call 9:
|
||||
|
||||
DEFINT A-Z
|
||||
DECLARE SUB MOUSE (M1, M2, M3, M4)
|
||||
DIM Cursor(15, 1)
|
||||
COMMON Cursor() 'Ensures array data is in DGROUP
|
||||
.
|
||||
. (set up Cursor() for mouse cursor shape desired)
|
||||
.
|
||||
M1 = 9: M2 = 6: M3 = 0
|
||||
CALL MOUSE(M1, M2, M3, VARPTR(Cursor(0, 0)))
|
||||
|
||||
In addition to the above, note that Mouse function calls 21-23
|
||||
require dynamically allocated storage out of the home data segment.
|
||||
The recommended way to do this is to allocate space in a dynamic
|
||||
string variable based on the return value from function call 21,
|
||||
using the STRING$ or SPACE$ function. Then use VARPTR on this string
|
||||
variable just prior to calling Mouse function call 22 or 23.
|
||||
|
||||
|
||||
================================================================================
|
||||
Part 3: Supplementary Information on Mixed-Language Programming
|
||||
================================================================================
|
||||
|
||||
|
||||
Linking from Within QuickC or with QCL
|
||||
--------------------------------------
|
||||
|
||||
Microsoft QuickC and the QCL command both set the /NOI linker
|
||||
by default. Therefore, you should not link from within QuickC, or
|
||||
with QCL, when your program contains modules written in a case-
|
||||
insensitive language such as BASIC. Use LINK to link your program
|
||||
from the command line.
|
||||
|
||||
|
||||
Pascal and FORTRAN Modules in QuickBASIC Programs
|
||||
-------------------------------------------------
|
||||
|
||||
Modules compiled with Microsoft Pascal or FORTRAN can be linked with
|
||||
BASIC programs, as described in the Microsoft Mixed-Language
|
||||
Programming Guide. They can also be incorporated in Quick libraries.
|
||||
However, QuickBASIC programs containing code compiled with Microsoft
|
||||
Pascal must allocate at least 2K near-heap space for Pascal. This can
|
||||
be done by using the DIM statement to allocate a static array of 2K or
|
||||
greater in the NMALLOC named common block, for example, as follows:
|
||||
|
||||
DIM name%(2048)
|
||||
COMMON SHARED /NMALLOC/name%()
|
||||
|
||||
The Pascal run-time assumes it always has at least 2K of near-heap
|
||||
space available. If the Pascal code cannot allocate the required
|
||||
space, QuickBASIC may crash. This applies to Pascal code in Quick
|
||||
libraries as well as Pascal code linked into executable files. The
|
||||
situation is similar for FORTRAN I/O, which also requires near
|
||||
buffer space, and which can be provided by the same means as the
|
||||
Pascal near malloc space.
|
||||
|
||||
|
||||
STATIC Array Allocation
|
||||
-----------------------
|
||||
|
||||
If you are writing assembly-language modules for use in QuickBASIC
|
||||
programs, see Section 2.3.3, "Variable Storage Allocation," in the
|
||||
BASIC Language Reference. Assembly-language code should not assume
|
||||
data is in a particular segment. To avoid problems, pass data using
|
||||
the SEG or CALLS keywords, or use FAR pointers. Alternatively, you
|
||||
can declare all arrays dynamic (still using far pointers) since
|
||||
dynamic arrays are handled identically by BC and within QuickBASIC.
|
||||
|
||||
|
||||
Quick Libraries with Leading Zeros in the First Code Segment
|
||||
------------------------------------------------------------
|
||||
|
||||
A Quick library containing leading zeros in the first CODE segment
|
||||
is invalid, causing the message "Error in loading file <name> -
|
||||
Invalid format" when you try to load it in QuickBASIC. For example,
|
||||
this can occur if an assembly-language routine puts data that is
|
||||
initialized to zero in the first CODE segment, and it is subsequently
|
||||
listed first on the LINK command line when you make a Quick library.
|
||||
If you have this problem, do either of the following:
|
||||
(1) link with a BASIC module first on the LINK command line, or
|
||||
(2) make sure that, in whatever module comes first on the LINK
|
||||
command line, the first code segment starts with a non-zero byte.
|
||||
|
||||
|
||||
References to DGROUP in Extended Run-Time Modules
|
||||
-------------------------------------------------
|
||||
|
||||
For mixed-language programs that use the CHAIN command, you should
|
||||
make sure that any code built into an extended run-time module does not
|
||||
contain any references to DGROUP. (The CHAIN command causes DGROUP to
|
||||
move, but does not update references to DGROUP.) This rule applies
|
||||
only to mixed-language programs; because BASIC routines never refer
|
||||
to DGROUP, you can ignore this caution for programs written entirely
|
||||
in BASIC.
|
||||
|
||||
To avoid this problem, you can use the value of SS, since BASIC always
|
||||
assumes that SS coincides with DGROUP.
|
||||
|
||||
|
||||
================================================================================
|
||||
Part 4: Using Btrieve
|
||||
================================================================================
|
||||
|
||||
|
||||
Using Btrieve in OS/2 Protected Mode
|
||||
------------------------------------
|
||||
|
||||
In OS/2 protected mode, a BASIC program that uses Btrieve must do a
|
||||
Btrieve reset call (function 28) before executing the CHAIN statement.
|
||||
The program must also reopen all Btrieve files when the destination of
|
||||
the CHAIN starts to run.
|
||||
|
||||
|
||||
Using Btrieve with QuickBASIC
|
||||
-----------------------------
|
||||
|
||||
If you use Btrieve with QuickBASIC, you must make a small change to
|
||||
your programs for QuickBASIC Version 4.5. Currently your programs
|
||||
contain a statement that obtains the address of the field buffer for
|
||||
an open file. For example:
|
||||
|
||||
OPEN "NUL" AS #1
|
||||
FIELD #1, 20 AS CITY$, 10 AS STATE$
|
||||
FCB.ADDR% = VARPTR(#1) 'This statement obtains the address
|
||||
|
||||
In QuickBASIC Version 4.5, you should change the indicated statement
|
||||
to return the address of the first variable in your field buffer minus
|
||||
a constant, as follows:
|
||||
|
||||
OPEN "NUL" AS #1
|
||||
FIELD #1, 20 AS CITY$, 10 AS STATE$
|
||||
FCB.ADDR% = SADD(CITY$) - 188 ' CITY$ is the first field
|
||||
' buffer variable
|
||||
|
||||
The following example shows how to obtain the same address for a
|
||||
user-defined type:
|
||||
|
||||
TYPE ADDRESS
|
||||
CITY AS STRING * 20
|
||||
STATE AS STRING * 10
|
||||
END TYPE
|
||||
|
||||
DIM ADD1 AS ADDRESS
|
||||
|
||||
FCB.ADDR% = VARPTR(ADD1) - 188
|
||||
' or, you can use FCB.ADDR% = VARPTR(ADD1.CITY) - 188
|
||||
|
||||
Your programs should function correctly with Btrieve with this change.
|
||||
|
||||
|
||||
================================================================================
|
||||
Part 5: DOS 3.20 Patch
|
||||
================================================================================
|
||||
|
||||
This information is important only if your system has all of the
|
||||
following characteristics:
|
||||
|
||||
1. Uses MS-DOS version 3.20
|
||||
2. Boots from a hard disk drive
|
||||
3. Has a math coprocessor (for instance, an 8087 chip)
|
||||
4. Runs programs that use floating-point math
|
||||
|
||||
For systems that satisfy all of the preceding conditions, you may be
|
||||
able to eliminate floating-point math problems by installing a small
|
||||
patch in DOS. If you are not sure whether you need the patch, perform
|
||||
the following steps:
|
||||
|
||||
1. Copy the program PATCH87.EXE (included in this release) to the root
|
||||
directory of your hard-disk drive.
|
||||
|
||||
2. Reboot your system from the hard disk, and do not perform any floppy-
|
||||
disk operations after rebooting. It is very important that you avoid
|
||||
floppy-disk I/O after rebooting, since that will affect the
|
||||
reliability of the diagnostic test that you are about to perform.
|
||||
|
||||
3. If necessary, use the CD command to move to the root directory of
|
||||
your hard-disk drive.
|
||||
|
||||
4. Run the PATCH87.EXE program by entering this command at the DOS
|
||||
prompt:
|
||||
|
||||
PATCH87
|
||||
|
||||
5. The program performs a diagnostic test on your system to determine
|
||||
whether it needs the DOS patch, and if the patch is needed,
|
||||
whether the patch can be installed successfully. If the program
|
||||
tells you that you need to install the DOS patch, and that it can be
|
||||
done, follow the procedure described in the next section.
|
||||
|
||||
Note: The floating-point problem has been eliminated in versions of
|
||||
MS-DOS higher than 3.20. This includes MS-DOS versions 3.21 and 3.30.
|
||||
|
||||
If you performed the preceding test and determined that you should
|
||||
install the DOS patch on your system, perform the following steps:
|
||||
|
||||
1. Format a blank floppy disk. (Do NOT use the /s formatting option to
|
||||
transfer system files to the disk.)
|
||||
|
||||
2. Use the SYS command to copy IO.SYS and MSDOS.SYS from the root
|
||||
directory of your hard disk to the new floppy disk. For instance, if
|
||||
you boot from drive C:, you would enter the following commands:
|
||||
|
||||
C:
|
||||
SYS A:
|
||||
|
||||
3. Use the COPY command to copy COMMAND.COM and SYS.COM to the same
|
||||
floppy disk.
|
||||
|
||||
4. Use the COPY command to copy the program PATCH87.EXE (included in
|
||||
this release) to the same floppy disk.
|
||||
|
||||
5. Change the current drive and directory to the floppy disk, by
|
||||
entering the following command:
|
||||
|
||||
A:
|
||||
|
||||
7. Install the DOS patch by entering the following command:
|
||||
|
||||
PATCH87 /F
|
||||
|
||||
WARNING: If you experience any disk errors during steps 2 through 7,
|
||||
do not proceed with step 8. Reboot from your hard disk and repeat the
|
||||
entire process.
|
||||
|
||||
8. If you have not experienced any errors, use the SYS command to
|
||||
transfer the files IO.SYS and MSDOS.SYS from the floppy disk back to
|
||||
your hard disk. For instance, if the boot directory of your system
|
||||
is the root directory of drive C:, you would enter the following
|
||||
command at the DOS prompt:
|
||||
|
||||
A:
|
||||
SYS C:
|
||||
|
||||
9. The DOS patch has been installed. Reboot the system.
|
||||
|
||||
|
||||
================================================================================
|
||||
Part 6: Miscellaneous Information About Using QuickBASIC
|
||||
================================================================================
|
||||
|
||||
|
||||
Using FIXSHIFT.COM Utility
|
||||
--------------------------
|
||||
|
||||
Some keyboards have an extra set of DIRECTION (i.e. arrow) keys, in
|
||||
addition to those on the numeric keypad. A bug in the ROM BIOS of
|
||||
some machines with these keyboards can interfere with the QuickBASIC
|
||||
editor. The Utilities 2 disk includes a program, FIXSHIFT.COM, that
|
||||
fixes this bug. If you have such a keyboard, run this program by typing
|
||||
FIXSHIFT. If your machine does not have the bug, FIXSHIFT displays a
|
||||
message telling you so. Otherwise FIXSHIFT prompts you for the proper
|
||||
actions. FIXSHIFT takes about 450 bytes of memory. Except for the BIOS
|
||||
bug, it has no effect on other programs you run.
|
||||
|
||||
|
||||
Note on VGA Display Adapters
|
||||
----------------------------
|
||||
|
||||
If you install an IBM (R) Personal System/2 (TM) Video Graphics
|
||||
Array display adapter (VGA) in a non-PS/2 machine, the VGA adapter
|
||||
should be the only adapter in the system, and you should not use
|
||||
monochrome modes (SCREEN 10) if you have a color monitor. Similarly,
|
||||
you should not use color modes (SCREEN 1, 2, 7, 8, 9, 11, 12, 13) if
|
||||
you have a monochrome monitor.
|
||||
|
||||
|
||||
Note on Using QuickBASIC with DOS 2.1
|
||||
-------------------------------------
|
||||
|
||||
To use QuickBASIC with a two-floppy system under DOS 2.1, you must
|
||||
put a copy of COMMAND.COM on each disk containing an executable
|
||||
file ( a file with the .EXE extension).
|
||||
|
||||
|
||||
PTR86, LOF, Naming SUB Procedures and Variables
|
||||
-----------------------------------------------
|
||||
|
||||
PTR86 is no longer supported. Use VARSEG and VARPTR instead.
|
||||
Also, when used with a communications device, LOF now returns the
|
||||
amount of space remaining (in bytes) in the output buffer. In
|
||||
previous versions this was returned in the input buffer. Also, note
|
||||
that a variable and SUB procedure could have the same name in
|
||||
previous versions. In Version 4.5, this causes a "Duplicate
|
||||
definition" error message.
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
Please check "Use folder names." when you extract files (Using Winzip) or use /d option with PKZip because all the path in the INI files are set to C:\QB directory.
|
||||
If you extract it in a different directory, set new paths in
|
||||
Help>Set Paths from the Basic menu.
|
||||
|
||||
<-- Other Basic versions and many other compilers and files are available
|
||||
at http://members.xoom.com/qb_best/ -->
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
qb/l
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue