jsbasic/dos.js
Joshua Bell d7ebf753db Thunderclock: Tweak DOS/BASIC handling of colons
This is ugly, but you should see what real BASIC/DOS do under the hood.
2024-02-11 15:16:39 -08:00

609 lines
17 KiB
JavaScript

//
// DOS Emulation
// Usage:
// var dos = new DOS( tty ) // hooks tty's writeChar/readChar/readLine
// dos.reset() // Close all open buffers
function DOS(tty) {
var DOSErrors = {
LANGUAGE_NOT_AVAILABLE: [1, "Language not available"],
RANGE_ERROR: [2, 'Range error'],
WRITE_PROTECTED: [4, 'Write protected'],
END_OF_DATA: [5, 'End of data'],
FILE_NOT_FOUND: [6, 'File not found'],
VOLUME_MISMATCH: [7, 'Volume mismatch'],
IO_ERROR: [8, 'I/O error'],
DISK_FULL: [9, 'Disk full'],
FILE_LOCKED: [10, 'File locked'],
INVALID_OPTION: [11, 'Invalid option'],
NO_BUFFERS_AVAILABLE: [12, 'No buffers available'],
FILE_TYPE_MISMATCH: [13, 'File type mismatch'],
PROGRAM_TOO_LARGE: [14, 'Program too large'],
NOT_DIRECT_COMMAND: [15, 'Not direct command'],
// Re-used
SYNTAX_ERROR: [16, "Syntax error"]
},
STORAGE_PREFIX = 'vfs/',
// For MON/NOMON
MON_I = 1,
MON_C = 2,
MON_O = 4,
// Original versions of hooked I/O routines
tty_readLine,
tty_readChar,
tty_writeChar,
// Hooked I/O routines
hooked_readLine,
hooked_readChar,
hooked_writeChar,
// character output state
commandBuffer = "",
commandMode = false,
// I/O buffers
buffers = {},
activebuffer = null,
mode = "",
// other state
monico = 0;
function doserror(msg) {
throw new basic.RuntimeError(msg[1], msg[0]);
}
// Internal - crack arguments e.g. ",S6,D1"
function parseArgs(str, opts) {
str = str || '';
opts = opts || '';
// Set these to zero so they're always defined when passed into command handlers
var args = {
V: 0, // Volume
D: 0, // Drive
S: 0, // Slot
L: 0, // Length
R: 0, // Record/Relative
B: 0, // Byte
A: 0, // Address
C: undefined, // Echo Commands
I: undefined, // Echo Input
O: undefined // Echo Output
};
var m;
while ((m = str.match(/^,?\s*([VDSLRBACIO])\s*([0-9]+|\$[0-9A-Fa-f]+)?\s*([\x20-\x7E]*)/))) {
if (opts.indexOf(m[1]) === -1) {
doserror(DOSErrors.INVALID_OPTION);
}
args[m[1]] = Number(m[2]);
str = m[3];
}
if (str.length > 0) {
doserror(DOSErrors.INVALID_OPTION);
}
return args;
}
//----------------------------------------------------------------------
// Browser-side VFS
//----------------------------------------------------------------------
function vfs_set(key, value) {
return window.localStorage.setItem(STORAGE_PREFIX + key, encodeURIComponent(value));
}
function vfs_get(key) {
var item = window.localStorage.getItem(STORAGE_PREFIX + key);
return item !== null ? decodeURIComponent(item) : null;
}
function vfs_remove(key) {
return window.localStorage.removeItem(STORAGE_PREFIX + key);
}
//----------------------------------------------------------------------
// Implementation
//----------------------------------------------------------------------
this.reset = function reset() {
buffers = {};
activebuffer = null;
mode = "";
};
function unlink(filename) {
var item = vfs_get(filename);
if (item === null) {
doserror(DOSErrors.FILE_NOT_FOUND);
}
vfs_remove(filename);
}
function rename(oldname, newname) {
var item = vfs_get(oldname);
if (item === null) {
doserror(DOSErrors.FILE_NOT_FOUND);
}
vfs_remove(oldname);
vfs_set(newname, item);
}
function open(filename, recordlength) {
if (recordlength === 0) {
// Sequential access
recordlength = 1;
}
// Peek in the VFS cache first
var file = vfs_get(filename),
req, url, async;
if (file === null) {
// Not cached - do a synchronous XmlHttpRequest for the file here
req = new XMLHttpRequest();
url = "vfs/" + encodeURIComponent(filename.replace(/\./g, '_')) + ".txt";
async = false;
req.open("GET", url, async);
req.send(null);
if (req.status === 200 || req.status === 0) { // 0 for file:// protocol
file = req.responseText.replace(/\r\n/g, "\r");
vfs_set(filename, file);
}
}
// Create a buffer for the file
buffers[filename] = {
file: file,
recordlength: recordlength,
recordnum: 0,
filepointer: 0
};
}
function append(filename, recordlength) {
// Normal open logic
open(filename, recordlength);
if (!Object.prototype.hasOwnProperty.call(buffers, filename)) {
doserror(DOSErrors.FILE_NOT_FOUND);
}
var buf = buffers[filename];
// Then seek to the end of the file
buf.filepointer = buf.file.length;
buf.recordnum = Math.floor(buf.filepointer / buf.recordlength);
}
function close(filename) {
var buf, fn;
// If not specified, close all buffers
if (!filename) {
for (fn in buffers) {
if (Object.prototype.hasOwnProperty.call(buffers, fn)) {
close(fn);
}
}
return;
}
buf = buffers[filename];
if (buf) {
// flush changes to "disk"
vfs_set(filename, buf.file);
delete buffers[filename];
if (buf === activebuffer) {
activebuffer = null;
mode = "";
}
}
}
function read(filename, recordnum, bytenum) {
var buf = buffers[filename];
if (!buf) {
// Open file if no such named buffer, but don't create it
open(filename, 0);
buf = buffers[filename];
}
if (buf.file === null) {
doserror(DOSErrors.FILE_NOT_FOUND);
}
// Set the file position
buf.recordnum = recordnum;
buf.filepointer = buf.recordlength * recordnum + bytenum;
// Set the active buffer into read mode
activebuffer = buf;
mode = "r";
}
function write(filename, recordnum, bytenum) {
var buf = buffers[filename];
if (!buf) {
// Must open the file before writing
doserror(DOSErrors.FILE_NOT_FOUND);
}
if (buf.file === null) {
// If we still don't have it, create in VFS if necessary
vfs_set(filename, '');
buf.file = '';
}
// Set up the file position
buf.recordnum = recordnum;
if (buf.recordlength > 1) {
buf.filepointer = buf.recordlength * recordnum;
}
buf.filepointer += bytenum;
// Set the active buffer into write mode
activebuffer = buf;
mode = "w";
}
function position(filename, records) {
var buf = buffers[filename];
if (!buf) {
// Open file if no such named buffer, but don't create it
open(filename, 0, false);
buf = buffers[filename];
}
// Set up the file position
buf.recordnum += records;
buf.filepointer += buf.recordlength * records;
}
//----------------------------------------------------------------------
// Command Dispatch
//----------------------------------------------------------------------
function executeCommand(command) {
// Delegate to various commands
// http://www.xs4all.nl/~fjkraan/comp/apple2faq/app2doscmdfaq.html
// http://www.textfiles.com/apple/ANATOMY/
var filename, filename2, args, slot;
if (monico & MON_C && tty) {
tty.writeString(command + "\r");
}
var m;
if ((m = command.match(/^MON([\x20-\x7E]*)/))) {
// MON[,C][,I][,O] Traces DOS 3.3 commands ('Commands', 'Input' and 'Output')
args = parseArgs(m[1], 'ICO');
if (args.I !== undefined) {
monico |= MON_I;
}
if (args.C !== undefined) {
monico |= MON_C;
}
if (args.O !== undefined) {
monico |= MON_O;
}
} else if ((m = command.match(/^NOMON([\x20-\x7E]*)/))) {
// NOMON[,C][,I][,O] Cancels tracing of DOS 3.3 commands ('Commands', 'Input' and 'Output')
args = parseArgs(m[1], 'ICO');
if (args.I !== undefined) {
monico &= ~MON_I;
}
if (args.C !== undefined) {
monico &= ~MON_C;
}
if (args.O !== undefined) {
monico &= ~MON_O;
}
} else if ((m = command.match(/^OPEN\s*([\x20-\x2B\x2D-\x7E]+)(,[\x20-\x7E]*)?/))) {
// OPEN filename[,Llen] Opens a text file.
filename = m[1];
args = parseArgs(m[2], 'L');
open(filename, args.L);
} else if ((m = command.match(/^APPEND\s*([\x20-\x2B\x2D-\x7E]+)(,[\x20-\x7E]*)?/))) {
// APPEND filename Appends to a text file.
filename = m[1];
args = parseArgs(m[2]);
append(filename, args.L);
} else if ((m = command.match(/^CLOSE\s*([\x20-\x2B\x2D-\x7E]+)?(,[\x20-\x7E]*)?/))) {
// CLOSE [filename] Closes specified (or all) open text files.
filename = m[1];
close(filename);
} else if ((m = command.match(/^POSITION\s*([\x20-\x2B\x2D-\x7E]+)(,[\x20-\x7E]*)?/))) {
// POSITION filename[,Rnum] Advances position in text file.
filename = m[1];
args = parseArgs(m[2], 'R');
position(filename, args.R);
} else if ((m = command.match(/^READ\s*([\x20-\x2B\x2D-\x7E]+)(,[\x20-\x7E]*)?/))) {
// READ filename[,Rnum][,Bbyte] Reads from a text file.
filename = m[1];
args = parseArgs(m[2], 'RB');
read(filename, args.R, args.B);
} else if ((m = command.match(/^WRITE\s*([\x20-\x2B\x2D-\x7E]+)(,[\x20-\x7E]*)?/))) {
// WRITE filename[,Rnum][,Bbyte] Writes to a text file.
filename = m[1];
args = parseArgs(m[2], 'RB');
write(filename, args.R, args.B);
} else if ((m = command.match(/^DELETE\s*([\x20-\x2B\x2D-\x7E]+)(,[\x20-\x7E]*)?/))) {
// DELETE filename Delete a file
filename = m[1];
args = parseArgs(m[2]);
unlink(filename);
} else if ((m = command.match(/^RENAME\s*([\x20-\x2B\x2D-\x7E]+),\s*([\x20-\x2B\x2D-\x7E]+)(,[\x20-\x7E]*)?/))) {
// RENAME filename,filename Rename a file
filename = m[1];
filename2 = m[2];
args = parseArgs(m[3]);
rename(filename, filename2);
} else if ((m = command.match(/^PR#\s*([\x20-\x2B\x2D-\x7E]+)(,[\x20-\x7E]*)?/))) {
// PR# slot Direct output to slot
slot = Number(m[1]);
args = parseArgs(m[2]);
if (slot === 0) {
if (tty.setFirmwareActive) { tty.setFirmwareActive(false); }
hooked_writeChar = tty_writeChar;
} else if (slot === 3) {
if (tty.setFirmwareActive) { tty.setFirmwareActive(true); }
hooked_writeChar = tty_writeChar;
} else if (slot === 4) {
hooked_writeChar = clock_writeChar;
} else {
doserror(DOSErrors.RANGE_ERROR);
}
} else if ((m = command.match(/^IN#\s*([\x20-\x2B\x2D-\x7E]+)(,[\x20-\x7E]*)?/))) {
// IN# slot Direct input to slot
slot = Number(m[1]);
args = parseArgs(m[2]);
if (slot === 0 || slot === 3) {
hooked_readLine = tty_readLine;
hooked_readChar = tty_readChar;
} else if (slot === 4) {
hooked_readLine = clock_readLine;
hooked_readChar = clock_readChar;
} else {
doserror(DOSErrors.RANGE_ERROR);
}
} else if ((m = command.match(/^$/))) {
// Null command - terminates a READ/WRITE, but doesn't CLOSE
// (leaves record length intact on open buffer)
activebuffer = null;
mode = "";
} else {
doserror(DOSErrors.SYNTAX_ERROR);
}
}
//----------------------------------------------------------------------
// Install TTY Hooks
//----------------------------------------------------------------------
tty_readLine = tty.readLine;
tty_readChar = tty.readChar;
tty_writeChar = tty.writeChar;
hooked_readLine = tty_readLine;
hooked_readChar = tty_readChar;
hooked_writeChar = tty_writeChar;
tty.readLine = dos_readLine;
tty.readChar = dos_readChar;
tty.writeChar = dos_writeChar;
function dos_readLine(callback, prompt) {
var string = "", c, data, len, fp, buffer;
if (mode === "r") {
// Cache for performance
data = activebuffer.file;
len = data.length;
fp = activebuffer.filepointer;
if (fp >= len) {
doserror(DOSErrors.END_OF_DATA);
}
buffer = [];
while (fp < len) {
// Sequential Access
c = data[fp];
fp += 1;
if (c === "\r" || c === "\n" || c === "\x00") {
break;
} else {
buffer.push(c);
}
}
activebuffer.filepointer = fp;
string = buffer.join("");
if (monico & MON_I) {
tty.writeString(prompt + string + "\r");
}
// Suppress BASIC parsing of colons
string = Object.assign(new String(string), {ignoreColons: true});
// Non-blocking return
setTimeout(function() { callback(string); }, 0);
} else {
hooked_readLine(callback, prompt);
}
}
function dos_readChar(callback) {
var character = "";
if (mode === "r") {
if (activebuffer.filepointer >= activebuffer.file.length) {
doserror(DOSErrors.END_OF_DATA);
}
character = activebuffer.file[activebuffer.filepointer];
activebuffer.filepointer += 1;
if (monico & MON_I && tty) {
hooked_writeChar(character);
}
// Non-blocking return
setTimeout(function() { callback(character); }, 0);
} else {
hooked_readChar(callback);
}
}
function dos_writeChar(c) {
if (commandMode) {
if (c === "\r") {
commandMode = false;
executeCommand(commandBuffer);
commandBuffer = "";
} else {
commandBuffer += c;
}
return;
} else if (c === "\x04") {
commandBuffer = "";
commandMode = true;
return;
}
if (mode === "w") {
var buf, d;
if (monico & MON_O) {
hooked_writeChar(c);
}
buf = activebuffer;
// Extend file to necessary length
while (buf.filepointer > buf.file.length) {
buf.file += "\x00";
}
// Append or insert character
if (buf.filepointer === buf.file.length) {
buf.file += c;
} else {
d = buf.file.substring(0, buf.filepointer);
d += c;
d += buf.file.substring(buf.filepointer + 1);
buf.file = d;
}
buf.filepointer += 1;
} else {
hooked_writeChar(c);
}
}
//----------------------------------------------------------------------
// Clock routine - vaguely ThunderClock compatible
//----------------------------------------------------------------------
var clockbuf = '';
function clock_writeChar(c) {
var DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
var MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN',
'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
function spad2(s) {
return (' ' + String(s)).slice(-2);
}
function zpad2(s) {
return ('00' + String(s)).slice(-2);
}
function zpad3(s) {
return ('000' + String(s)).slice(-3);
}
var now = new Date();
switch (c) {
default:
case '%': // AM/PM ASCII string mode - e.g. "TUE MAY 12 4:32:55 PM"
case '>': // AM/PM ASCII string mode - e.g. "TUE MAY 12 4:32:55 PM"
clockbuf =
DAYS[now.getDay()] + ' ' +
MONTHS[now.getMonth()] + ' ' +
spad2(now.getDate()) + ' ' +
spad2((now.getHours() === 0 ? 12 : now.getHours() > 12 ? now.getHours() - 12 : now.getHours())) + ':' +
zpad2(now.getMinutes()) + ':' +
zpad2(now.getSeconds()) + ' ' +
(now.getHours() < 12 ? 'AM' : 'PM');
break;
case '&': // 24 hour ASCII string - e.g. "TUE MAY 12 16:32:55"
case '<': // 24 hour ASCII string - e.g. "TUE MAY 12 16:32:55"
clockbuf =
DAYS[now.getDay()] + ' ' +
MONTHS[now.getMonth()] + ' ' +
spad2(now.getDate()) + ' ' +
spad2(now.getHours()) + ':' +
zpad2(now.getMinutes()) + ':' +
zpad2(now.getSeconds()) + ' ' +
(now.getHours() < 12 ? 'AM' : 'PM');
break;
case ' ': // Mountain Computer Apple Clock Format - e.g. "05/12 16;32;55.000"
clockbuf =
zpad2(now.getMonth()+1) + '/' +
zpad2(now.getDate()) + ' ' +
zpad2(now.getHours()) + ';' +
zpad2(now.getMinutes()) + ';' +
zpad2(now.getSeconds()) + '.' +
zpad3(now.getMilliseconds());
break;
case '#': // Numeric format, e.g. MO,DW,DT,HR,MN,SEC
clockbuf = [
now.getMonth()+1,
now.getDay(),
now.getDate(),
now.getHours(),
now.getMinutes(),
now.getSeconds()
].join(',');
break;
}
clockbuf += '\r';
}
function clock_readLine(callback, prompt) {
tty.writeString(prompt); // TODO: Correct? Newline?
var tmp = clockbuf;
clockbuf = '';
// Suppress BASIC parsing of colons
tmp = Object.assign(new String(tmp), {ignoreColons: true});
setTimeout(function() { callback(tmp); }, 0);
}
function clock_readChar(callback) {
if (!clockbuf.length) {
setTimeout(function() { callback('\r'); }, 0);
} else {
var c = clockbuf.substring(0, 1);
clockbuf = clockbuf.slice(1);
setTimeout(function() { callback(c); }, 0);
}
}
}