/**
* @fileoverview Implements disk image support for both FDC and HDC.
* @author Jeff Parsons
* @copyright © Jeff Parsons 2012-2017
*
* This file is part of PCjs, a computer emulation software project at .
*
* PCjs is free software: you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation, either version 3
* of the License, or (at your option) any later version.
*
* PCjs is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
* even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with PCjs. If not,
* see .
*
* You are required to include the above copyright notice in every modified copy of this work
* and to display that copyright notice when the software starts running; see COPYRIGHT in
* .
*
* Some PCjs files also attempt to load external resource files, such as character-image files,
* ROM files, and disk image files. Those external resource files are not considered part of PCjs
* for purposes of the GNU General Public License, and the author does not claim any copyright
* as to their contents.
*/
"use strict";
if (NODE) {
var Str = require("../../shared/lib/strlib");
var Usr = require("../../shared/lib/usrlib");
var Web = require("../../shared/lib/weblib");
var DiskAPI = require("../../shared/lib/diskapi");
var DumpAPI = require("../../shared/lib/dumpapi");
var Component = require("../../shared/lib/component");
var Messages = require("./messages");
}
/*
* The Disk component provides methods for:
*
* 1) creating an empty disk: create()
* 2) loading a disk image: load()
* 3) getting disk information: info()
* 4) seeking a disk sector: seek()
* 5) reading data from a sector: read()
* 6) writing data to a sector: write()
* 7) save disk deltas: save()
* 8) restore disk deltas: restore()
* 9) converting disk contents: toJSON()
*
* More functionality may be factored out of the FDC and HDC components later and moved here, to
* further reduce some of the duplication between them, but the above functionality is a good start.
*/
/*
* Client/Server Disk I/O
*
* To support large disks without consuming large amounts of client-side memory, and to push
* client-side disk changes back the server, we need a DiskIO API that can be used in place of
* the DiskDump API.
*
* Use of the DiskIO API and any associated disk images must be tightly coupled to per-user
* storage and specific machine configurations, to prevent the disk images from being corrupted
* by inconsistent I/O operations. Our basic User API (userapi.js) already provides some
* per-user storage that we can use to get the design rolling.
*
* The DiskIO API must also provide the ability to create new (empty) hard disk images in per-user
* storage and automatically associate them with the machine configurations that requested them.
*/
/*
* Principles
*
* Originally, when the Disk class was given a disk image to load and mount, it would request the
* ENTIRE disk image from the DiskDump module. That works well for small (floppy) disk images, but
* for larger disks -- let's just say anything stored on the server as an "img" file -- we'd prefer
* to interact with that disk using "On-Demand I/O". Any "img" file on the same server as the PCjs
* application should be a candidate for on-demand access.
*
* On-Demand I/O means that nothing is initially transferred from the server. As sectors are
* requested by the PCx86 machine, PCx86 requests them from the server, and maintains an MRU cache
* of sectors, periodically discarding the least-used clean sectors above a certain memory limit.
* Dirty sectors (ie, those that the PCx86 machine has written to) must be periodically sent
* back to the server and then marked as clean, so that they can be discarded like any other
* sector.
*
* We also support "local" init-only disk images, which means that dirty sectors are never sent
* back to the server and are instead retained by the client for the lifetime of the app; such
* images are "read-only" as far as the server is concerned, but "read-write" as far as the client
* is concerned. Reloading/restarting an app with an "local" disk will return the disk to its
* initial state.
*
* Practice
* ---
* Let's first look at what we *already* do for the HDC component:
*
* 1) Creating new (empty) disk images
* 2) Pre-loading pre-built JSON-encoded disk images (converting them to JSON on the fly as needed)
*
* An example of #1 is in /devices/pc/machine/5160/cga/256kb/demo/machine.xml:
*
*
*
* and an example of #2 is in /disks/pc/fixed/win101.xml:
*
*
*
* The HDC component expects an array of drive entries. Array position determines drive numbering
* (the first entry is drive 0, the second is drive 1, etc), and each entry contains the following
* properties:
*
* 'name': user-friendly name for the disk, if any
* 'path': URL of the disk image, if any
* 'type': a drive type
*
* Of those properties, only 'type' is required, which provides an index into an HDC "Drive Type"
* table that determines disk geometry and therefore disk size. As we add support for larger disks and
* newer disk controllers, the 'type' parameter will be superseded by either a user-defined 'geometry'
* parameter that will define number of heads, cylinders, tracks, sectors per track, and (max) bytes per
* sector, or perhaps a generic 'size' parameter that leaves geometry choices to the HDC component,
* which will then pass those decisions on to the Disk component.
*
* We will enable on-demand I/O for a disk image with a new 'mode' parameter that looks like:
*
* 'mode': one of "local", "preload", "demandrw", "demandro"
*
* "preload" means the disk image will be completely preloaded, exactly as before; "demandrw" enables
* full on-demand I/O support; and "demandro" enables on-demand I/O for reads only (all writes are retained
* and never written back to the server).
*
* "ro" will be the fallback for "rw" unless TWO other important criteria are met: 1) the user has a
* private user key, and therefore per-user storage; and 2) the disk image 'path' contains an asterisk (*)
* that the server can internally remap to a directory in the user's storage; eg:
*
* 'path': /10mb.img (path components following the asterisk are optional)
*
* If the disk image does not already exist, it will be created (but not formatted).
*
* This preserves the promise that EVERYTHING a user does within a PCx86 machine is private (ie, not
* visible to any other PCjs users). I don't want to be in the business of saving any user machine
* states or disk changes, but at least those operations are limited to users who have asked for (and
* received) a private user key.
*
* Another important consideration at this stage is dealing with multiple machines writing to the same
* disk image; even though we're limiting the "demandrw" mode to per-user images, a single user may still
* inadvertently start up multiple machines that refer to the same disk image.
*
* So, every PCx86 machine needs to generate a unique token and include that token with every Disk I/O API
* operation, so that the server can revoke a previous machine's "rw" access to a disk image when a new
* machine requests "rw" access to the same disk image.
*
* From the client's perspective, revocation can be quietly dealt with by reverting to "demandro" mode;
* that client becomes stuck with all their dirty sectors until they can reclaim "rw" access, which should
* only happen if no intervening writes to the disk image on the server have occurred (if I bother allowing
* reclamation at all).
*
* The real challenge here is avoiding revocation of a machine that still has critical changes to commit,
* but since we can't even solve the problem of a user closing their browser at an inopportune time
* and potentially leaving a disk image in an inconsistent state, premature revocation is the least of
* our problems. Since a real hard drive could suffer the same fate if the machine's power was turned off
* at the wrong time, you could say that we're simply providing a faithful simulation of reality.
*/
/**
* TODO: The Closure Compiler treats ES6 classes as 'struct' rather than 'dict' by default,
* which would force us to declare all class properties in the constructor, as well as prevent
* us from defining any named properties. So, for now, we mark all our classes as 'unrestricted'.
*
* @unrestricted
*/
class Disk extends Component {
/**
* Disk(controller, drive, mode)
*
* Disk contents are stored as an array (aDiskData) of cylinders, each of which is an array of
* heads, each of which is an array of sector objects; the latter contain sector numbers and
* sector data, where sector data is an array of dwords. The format does not impose any
* limitations on number of cylinders, number of heads, sectors per track, or bytes per sector.
*
* WARNING: All accesses to disk sector properties must be via their string names, not their
* "dot" names, otherwise code will break after it's been processed by the Closure Compiler,
* and any dumped disks may be unmountable. This is a side-effect of how we mount and dump
* disk images (ie, as JSON-encoded streams).
*
* This means, for example, that all references to "track[iSector].data" must actually appear as
* "track[iSector]['data']".
*
* @this {Disk}
* @param {HDC|FDC} controller
* @param {Object} drive
* @param {string} mode
*/
constructor(controller, drive, mode)
{
super("Disk", {'id': controller.idMachine + ".disk" + Str.toHex(++Disk.nDisks, 4)}, Messages.DISK);
this.controller = controller;
/*
* Route all non-Debugger messages (eg, notice() and println() calls) through
* this.controller (eg, controller.notice() and controller.println()), because
* the Computer component is unaware of any Disk objects and therefore will not
* set up the usual overrides when a Control Panel is installed.
*/
this.notice = controller.notice;
this.println = controller.println;
this.cmp = controller.cmp;
this.dbg = controller.dbg;
this.drive = drive;
/*
* We pull out a number of drive properties that we may or may not need as defaults
*/
this.sDiskName = drive.name;
this.fRemovable = drive.fRemovable;
this.fOnDemand = this.fRemote = false;
/*
* Initialize the disk contents
*/
this.create(mode, drive.nCylinders, drive.nHeads, drive.nSectors, drive.cbSector);
/*
* The following dirty sector and timer properties are used only with fOnDemand disks,
* assuming fRemote was successfully set.
*/
this.aDirtySectors = [];
this.aDirtyTimestamps = []; // this array is parallel to aDirtySectors
this.timerWrite = null; // REMOTE_WRITE_DELAY timer in effect, if any
this.msTimerWrite = 0; // the time that the write timer, if any, is set to fire
this.fWriteInProgress = false;
this.setReady();
}
/**
* initBus(cmp, bus, cpu, dbg)
*
* We have no real interest in this notification, other than to obtain a reference to the Debugger
* for every disk loaded BEFORE the initBus() phase; any disk loaded AFTER that point will get its Debugger
* reference, if any, from the disk controller passed to the Disk() constructor.
*
* @this {Disk}
* @param {Computer} cmp
* @param {Bus} bus
* @param {X86CPU} cpu
* @param {DebuggerX86} dbg
*/
initBus(cmp, bus, cpu, dbg)
{
this.dbg = dbg;
}
/**
* isRemote()
*
* @this {Disk}
* @return {boolean} true if remote disk, false if not
*/
isRemote()
{
/*
* Ironically, we can't rely on fRemote, because that is cleared and set across disconnect and
* reconnect operations. fOnDemand is the next best thing.
*/
return this.fOnDemand;
}
/**
* powerUp(data, fRepower)
*
* As with powerDown(), our sole concern here is for REMOTE disks: if a powerDown() call disconnected an
* "on-demand" disk, we need to get reconnected. Calling our own load() function should get the job done.
*
* The HDC component could have triggered this as well, but its powerUp() function only calls autoMount()
* in case of page (ie, application) reload, which is fine for local disks but insufficient for remote disks,
* which have a server connection that must be re-established.
*
* @this {Disk}
* @param {Object|null} data
* @param {boolean} [fRepower]
* @return {boolean} true if successful, false if failure
*/
powerUp(data, fRepower)
{
if (!fRepower) {
if (this.fOnDemand && !this.fRemote) {
this.setReady(false);
this.load(this.sDiskName, this.sDiskPath, null, this.donePowerUp, this);
}
}
return true;
}
/**
* donePowerUp(drive, disk, sDiskName, sDiskPath)
*
* This is a callback issued by the Disk component once the load() from powerUp() has finished.
*
* @this {Disk}
* @param {Object} drive
* @param {Disk} disk is set if the disk was successfully mounted, null if not
* @param {string} sDiskName
* @param {string} sDiskPath
*/
donePowerUp(drive, disk, sDiskName, sDiskPath)
{
this.setReady(true);
}
/**
* powerDown(fSave, fShutdown)
*
* Our sole concern here is for REMOTE disks, making sure any unwritten changes get flushed to
* the server during a shutdown. No local state is ever returned, so fSave is ignored.
*
* Local disks are managed by the controller (ie, FDC or HDC) that mounted them; the controller's
* powerDown() handler will take care of calling save() as needed.
*
* TODO: Consider taking responsibility for saving the state of local disks as well; the only reason
* the controllers still take care of them is historical, because this component originally didn't
* exist, and even after it was created, it didn't originally receive powerDown() notifications.
*
* @this {Disk}
* @param {boolean} [fSave]
* @param {boolean} [fShutdown]
* @return {Object|boolean}
*/
powerDown(fSave, fShutdown)
{
/*
* If we're connected to a remote disk, take this opportunity to flush any remaining unwritten
* changes and then close the connection.
*/
if (this.fRemote) {
var response;
var nErrorCode = 0;
if (this.fWriteInProgress) {
/*
* TODO: Verify that the Computer's powerOff() handler will actually honor a false return value.
*/
if (!Component.confirmUser("Disk writes are still in progress, shut down anyway?")) {
return false;
}
}
while ((response = this.findDirtySectors(false))) {
if ((nErrorCode = response[0])) {
this.notice('Unable to save "' + this.sDiskName + '" (error ' + nErrorCode + ')');
break;
}
}
if (fShutdown) {
this.disconnectRemoteDisk();
}
/*
* I only report that changes to the disk have been "saved" if fSave is true, to avoid confusing
* users who might not understand the difference between discarding local changes (which should restore
* all diskettes to their original state) and discarding remote changes (which could leave the remote disk
* in a bad state).
*/
if (!nErrorCode && fSave) this.notice(this.sDiskName + " saved");
}
return true;
}
/**
* create()
*
* @this {Disk}
* @param {string} mode
* @param {number} nCylinders
* @param {number} nHeads
* @param {number} nSectors (per track)
* @param {number} cbSector
*
* Initializes the disk contents according to the current drive mode and parameters.
*/
create(mode, nCylinders, nHeads, nSectors, cbSector)
{
this.mode = mode;
this.nCylinders = nCylinders;
this.nHeads = nHeads;
this.nSectors = nSectors;
this.cbSector = cbSector;
this.aDiskData = [];
/*
* If the drive is using PRELOAD mode, then it will use the load()/mount() process to initialize the disk contents;
* it wouldn't hurt to let create() do its thing, too, but it's a waste of time.
*/
if (this.mode != DiskAPI.MODE.PRELOAD) {
if (DEBUG && this.messageEnabled()) {
this.printMessage("blank disk for \"" + this.sDiskName + "\": " + this.nCylinders + " cylinders, " + this.nHeads + " head(s)");
}
var aCylinders = new Array(this.nCylinders);
for (var iCylinder = 0; iCylinder < aCylinders.length; iCylinder++) {
var aHeads = new Array(this.nHeads);
for (var iHead = 0; iHead < aHeads.length; iHead++) {
var aSectors = new Array(this.nSectors);
for (var iSector = 1; iSector <= aSectors.length; iSector++) {
/*
* Now that our read() and write() functions can deal with unallocated data
* arrays, and can read/write the specified pattern on-the-fly, we no longer need
* to pre-allocate and pre-initialize the 'data' array.
*
* For "local" disks, we can assume a 'pattern' of 0, but for "demandrw" and "demandro"
* disks, 'pattern' is set to null, as yet another indication that I/O is required to load
* the sector from the server (or to write it back to the server).
*/
aSectors[iSector - 1] = this.initSector(null, iCylinder, iHead, iSector, this.cbSector, (this.mode == DiskAPI.MODE.LOCAL? 0 : null));
}
aHeads[iHead] = aSectors;
}
aCylinders[iCylinder] = aHeads;
}
this.aDiskData = aCylinders;
}
this.dwChecksum = null;
}
/**
* load(sDiskName, sDiskPath, file, fnNotify)
*
* TODO: Figure out how we can strongly type fnNotify, because the Closure Compiler has issues with:
*
* param {function(Component,Object,Disk,string,string)} fnNotify
*
* for:
*
* this.fnNotify.call(this.controller, this.drive, disk, this.sDiskName, this.sDiskPath);
*
* Also, while we're at it, learn if there are ways to:
*
* 1) declare a function taking NO parameters (ie, generate a warning if any parameters are specified)
* 2) declare a type for a function's return value
*
* @this {Disk}
* @param {string} sDiskName
* @param {string} sDiskPath
* @param {File} [file] is set if there's an associated File object
* @param {function(...)} [fnNotify]
* @param {Component} [controller]
* @return {boolean} true if load completed (successfully or not), false if queued
*/
load(sDiskName, sDiskPath, file, fnNotify, controller)
{
var sDiskURL = sDiskPath;
/*
* We could use this.log() as well, but it wouldn't display which component initiated the load.
*/
if (DEBUG) {
var sMessage = 'load("' + sDiskName + '","' + sDiskPath + '")';
this.controller.log(sMessage);
this.printMessage(sMessage);
}
if (this.fnNotify) {
if (DEBUG) this.controller.log('too many load requests for "' + sDiskName + '" (' + sDiskPath + ')');
return true;
}
this.sDiskName = sDiskName;
this.sDiskPath = sDiskPath;
this.sDiskFile = Str.getBaseName(sDiskPath);
var disk = this;
this.fnNotify = fnNotify;
this.controllerNotify = controller || this.controller;
if (file) {
var reader = new FileReader();
reader.onload = function() {
disk.build(reader.result, true);
};
reader.readAsArrayBuffer(file);
return true;
}
/*
* If there's an occurrence of API_ENDPOINT anywhere in the path, we assume we can use it as-is;
* ie, that the user has already formed a URL of the type we use ourselves for unconverted disk images.
*/
if (sDiskPath.indexOf(DumpAPI.ENDPOINT) < 0) {
/*
* If the selected disk image has a "json" extension, then we assume it's a pre-converted
* JSON-encoded disk image, so we load it as-is; otherwise, we ask our server-side disk image
* converter to return the corresponding JSON-encoded data.
*/
var sDiskExt = Str.getExtension(sDiskPath);
if (sDiskExt == DumpAPI.FORMAT.JSON || sDiskExt == DumpAPI.FORMAT.JSON_GZ) {
sDiskURL = encodeURI(sDiskPath);
} else {
if (this.mode == DiskAPI.MODE.DEMANDRW || this.mode == DiskAPI.MODE.DEMANDRO) {
sDiskURL = this.connectRemoteDisk(sDiskPath);
this.fOnDemand = true;
} else {
var sDiskParm = DumpAPI.QUERY.PATH;
var sSizeParm = '&' + DumpAPI.QUERY.MBHD + "=10";
/*
* 'mbhd' is a new parm added for hard drive support. In the case of 'file' or 'dir' requests,
* 'mbhd' informs DumpAPI.ENDPOINT that it should create a hard disk image, and one not larger than
* the specified size (eg, 10mb). In fact, until DumpAPI.ENDPOINT is changed to create custom hard
* disk BPBs, you'll always get a standard PC XT 10mb disk image, so if the 'file' or 'dir' contains
* more than 10mb of data, the request will fail. Ultimately, I want to honor the controller's
* driveConfig 'size' parm, or to match the capacity required by the driveConfig 'type' parameter.
*
* If a 'disk' is specified, we pass mbhd=0, because the actual size will depend on the image.
* However, I don't currently have any "dsk" or "img" files containing hard disk images; those formats
* were really intended for floppy disk images. If I never create any hard disk image files, then
* we can simply eliminate sSizeParm in the 'disk' case.
*
* Added more extensions to the list of paths-treated-as-disk-images, so that URLs to files located here:
*
* ftp://ftp.oldskool.org/pub/TOPBENCH/dskimage/
*
* can be used as-is. TODO: There's a TODO in netlib.getFile() regarding remote support that needs
* to be resolved first; DiskDump relies on that function for its remote requests, and it currently
* supports only HTTP.
*/
if (!sDiskPath.indexOf("http:") || !sDiskPath.indexOf("ftp:") || ["dsk", "ima", "img", "360", "720", "12", "144"].indexOf(sDiskExt) >= 0) {
sDiskParm = DumpAPI.QUERY.DISK;
sSizeParm = '&' + DumpAPI.QUERY.MBHD + "=0";
} else if (Str.endsWith(sDiskPath, '/')) {
sDiskParm = DumpAPI.QUERY.DIR;
}
sDiskURL = Web.getHost() + DumpAPI.ENDPOINT + '?' + sDiskParm + '=' + encodeURIComponent(sDiskPath) + (this.fRemovable ? "" : sSizeParm) + "&" + DumpAPI.QUERY.FORMAT + "=" + DumpAPI.FORMAT.JSON;
}
}
}
var sProgress = "Loading " + sDiskURL + "...";
return !!Web.getResource(sDiskURL, null, true, function loadDone(sURL, sResponse, nErrorCode) {
disk.doneLoad(sURL, sResponse, nErrorCode);
}, function(nState) {
disk.println(sProgress, Component.TYPE.PROGRESS);
});
}
/**
* build(buffer, fModified)
*
* Builds a disk image from an ArrayBuffer (eg, from a FileReader object), rather than from JSON-encoded data.
*
* @this {Disk}
* @param {?} buffer (we KNOW this is an ArrayBuffer, but we can't seem to convince the Closure Compiler)
* @param {boolean} [fModified] is true if we should mark the entire disk modified (to ensure that we save/restore it)
*/
build(buffer, fModified)
{
var disk;
var cbDiskData = buffer? buffer.byteLength : 0;
var diskFormat = DiskAPI.GEOMETRIES[cbDiskData];
if (diskFormat) {
this.nCylinders = diskFormat[0];
this.nHeads = diskFormat[1];
this.nSectors = diskFormat[2];
this.cbSector = (diskFormat[3] || 512);
var cdw = this.cbSector >> 2, dwPattern = 0, dwChecksum = 0;
var ib = 0;
var dv = new DataView(buffer, 0, cbDiskData);
this.aDiskData = new Array(this.nCylinders);
for (var iCylinder = 0; iCylinder < this.aDiskData.length; iCylinder++) {
var cylinder = this.aDiskData[iCylinder] = new Array(this.nHeads);
for (var iHead = 0; iHead < cylinder.length; iHead++) {
var head = cylinder[iHead] = new Array(this.nSectors);
for (var iSector = 0; iSector < head.length; iSector++) {
var sector = this.initSector(null, iCylinder, iHead, iSector + 1, this.cbSector, dwPattern);
var adw = sector['data'];
for (var idw = 0; idw < cdw; idw++, ib += 4) {
var dw = adw[idw] = dv.getInt32(ib, true);
dwChecksum = (dwChecksum + dw) & (0xffffffff|0);
}
if (fModified) sector.cModify = cdw;
head[iSector] = sector;
}
}
}
this.dwChecksum = dwChecksum;
disk = this;
} else {
this.notice("Unrecognized disk format (" + cbDiskData + " bytes)");
}
if (this.fnNotify) {
this.fnNotify.call(this.controller, this.drive, disk, this.sDiskName, this.sDiskPath);
this.fnNotify = null;
}
}
/**
* doneLoad(sURL, sDiskData, nErrorCode)
*
* This function was originally called mount(). If the mount is successful, we pass the Disk object to the
* caller's fnNotify handler; otherwise, we pass null.
*
* @this {Disk}
* @param {string} sURL
* @param {string} sDiskData
* @param {number} nErrorCode (response from server if anything other than 200)
*/
doneLoad(sURL, sDiskData, nErrorCode)
{
var disk = null;
this.fWriteProtected = false;
var fPrintOnly = !!(nErrorCode < 0 && this.cmp && !this.cmp.flags.powered);
if (this.fOnDemand) {
if (!nErrorCode) {
if (DEBUG && this.messageEnabled()) {
this.printMessage('doneLoad("' + this.sDiskPath + '")');
}
this.fRemote = true;
if (BACKTRACK || SYMBOLS) this.buildFileTable();
disk = this;
} else {
this.notice('Unable to connect to disk "' + this.sDiskPath + '" (error ' + nErrorCode + ': ' + sDiskData + ')', fPrintOnly);
}
}
else if (nErrorCode) {
/*
* This can happen for innocuous reasons, such as the user switching away too quickly, forcing
* the request to be cancelled. And unfortunately, the browser cancels XMLHttpRequest requests
* BEFORE it notifies any page event handlers, so if the Computer's being powered down, we won't know
* that yet. For now, we rely on the lack of a specific error (nErrorCode < 0), and suppress the
* notify() alert if there's no specific error AND the computer is not powered up yet.
*/
this.notice("Unable to load disk \"" + this.sDiskName + "\" (error " + nErrorCode + ": " + sURL + ")", fPrintOnly);
} else {
if (DEBUG && this.messageEnabled()) {
this.printMessage('doneLoad("' + this.sDiskPath + '")');
}
Component.addMachineResource(this.controller.idMachine, sURL, sDiskData);
try {
/*
* The following code was a hack to turn on write-protection for a disk image if there was
* an initial comment line containing the string "write-protected". However, since comments
* are technically not allowed in JSON, I needed an alternative solution. So, if the basename
* contains the suffix "-readonly", then I'll turn on write-protection for that disk as well.
*
* TODO: Provide some UI for turning write-protection on/off for disks at will, and provide
* an XML-based solution (ie, a per-disk XML configuration option) for controlling it as well.
*/
var sBaseName = Str.getBaseName(this.sDiskFile, true).toLowerCase();
if (sBaseName.indexOf("-readonly") > 0) {
this.fWriteProtected = true;
} else {
var iEOL = sDiskData.indexOf("\n");
if (iEOL > 0 && iEOL < 1024) {
var sConfig = sDiskData.substring(0, iEOL);
if (sConfig.indexOf("write-protected") > 0) {
this.fWriteProtected = true;
}
}
}
/*
* The most likely source of any exception will be here, where we're parsing the disk data.
*/
var aDiskData;
if (sDiskData.substr(0, 1) == "<") { // if the "data" begins with a "<"...
/*
* Early server configs reported an error (via the nErrorCode parameter) if a disk URL was invalid,
* but more recent server configs now display a somewhat friendlier HTML error page. The downside,
* however, is that the original error has been buried, and we've received "data" that isn't actually
* disk data.
*
* So, if the data we've received appears to be "HTML-like", all we can really do is assume that the
* disk image is missing. And so we pretend we received an error message to that effect.
*/
aDiskData = ["Missing disk image: " + this.sDiskName];
} else {
/*
* TODO: IE9 is rather unfriendly and restrictive with regard to how much data it's willing to
* eval(). In particular, the 10Mb disk image we use for the Windows 1.01 demo config fails in
* IE9 with an "Out of memory" exception. One work-around would be to chop the data into chunks
* (perhaps one track per chunk, using regular expressions) and then manually re-assemble it.
*
* However, it turns out that using JSON.parse(sDiskData) instead of eval("(" + sDiskData + ")")
* is a much easier fix. The only drawback is that we must first quote any unquoted property names
* and remove any comments, because while eval() was cool with them, JSON.parse() is more particular;
* the following RegExp replacements take care of those requirements.
*
* The use of hex values is something else that eval() was OK with, but JSON.parse() is not, and
* while I've stopped using hex values in DumpAPI responses (at least when "format=json" is specified),
* I can't guarantee they won't show up in "legacy" images, and there's no simple RegExp replacement
* for transforming hex values into decimal values, so I cop out and fall back to eval() if I detect
* any hex prefixes ("0x") in the sequence. Ditto for error messages, which appear like so:
*
* ["unrecognized disk path: test.img"]
*/
if (sDiskData.indexOf("0x") < 0 && sDiskData.substr(0, 2) != "[\"") {
aDiskData = JSON.parse(sDiskData.replace(/([a-z]+):/gm, "\"$1\":").replace(/\/\/[^\n]*/gm, ""));
} else {
aDiskData = eval("(" + sDiskData + ")");
}
}
if (!aDiskData.length) {
Component.error("Empty disk image: " + this.sDiskName);
}
else if (aDiskData.length == 1) {
Component.error(aDiskData[0]);
}
/*
* aDiskData is an array of cylinders, each of which is an array of heads, each of which
* is an array of sector objects. The format does not impose any limitations on number of
* cylinders, number of heads, or number of bytes in any of the sector object byte-arrays.
*
* WARNING: All accesses to sector object properties must be via their string names, not their
* "dot" names, otherwise code will break after it's been processed by the Closure Compiler.
*
* Sector object properties include:
*
* 'sector' the sector number (1-based, not required to be sequential)
* 'length' the byte-length (ie, formatted length) of the sector
* 'data' the dword-array containing the sector data
* 'pattern' if the dword-array length is less than 'length'/4, this value must be used
* to pad out the sector; if no 'pattern' is specified, it's assumed to be zero
*
* We still support the older JSON encoding, where sector data was encoded as an array of 'bytes'
* rather than a dword 'data' array. However, our support is strictly limited to an on-the-fly
* conversion to a forward-compatible 'data' array.
*/
else {
if (DEBUG && this.messageEnabled(Messages.DISK | Messages.DATA)) {
var sCylinders = aDiskData.length + " track" + (aDiskData.length > 1 ? "s" : "");
var nHeads = aDiskData[0].length;
var sHeads = nHeads + " head" + (nHeads > 1 ? "s" : "");
var nSectorsPerTrack = aDiskData[0][0].length;
var sSectorsPerTrack = nSectorsPerTrack + " sector" + (nSectorsPerTrack > 1 ? "s" : "") + "/track";
this.printMessage(sCylinders + ", " + sHeads + ", " + sSectorsPerTrack);
}
/*
* Before the image is usable, we must "normalize" all the sectors. In the past, this meant
* "inflating" them all. However, that's no longer strictly necessary. Mainly, it just means
* setting 'length', 'data', and 'pattern' properties, so that all the sectors are well-defined.
* This includes detecting sector data in older formats (eg, the old array of 'bytes' instead
* of the new 'data' array of dwords) and converting them on-the-fly to the current format.
*/
this.nCylinders = aDiskData.length;
this.nHeads = aDiskData[0].length;
this.nSectors = aDiskData[0][0].length;
var sector = aDiskData[0][0][0];
this.cbSector = (sector && sector['length']) || 512;
var dwChecksum = 0;
for (var iCylinder = 0; iCylinder < this.nCylinders; iCylinder++) {
for (var iHead = 0; iHead < this.nHeads; iHead++) {
for (var iSector = 0; iSector < this.nSectors; iSector++) {
sector = aDiskData[iCylinder][iHead][iSector];
if (!sector) continue; // non-standard (eg, XDF) disk images may have "unused" (null) sectors
var length = sector['length'];
if (length === undefined) { // provide backward-compatibility with older JSON...
length = sector['length'] = 512;
}
length >>= 2; // convert length from a byte-length to a dword-length
var dwPattern = sector['pattern'];
if (dwPattern === undefined) {
dwPattern = sector['pattern'] = 0;
}
var adw = sector['data'];
if (adw === undefined) {
var ab = sector['bytes'];
if (ab === undefined || !ab.length) {
/*
* If there is neither a 'bytes' nor 'data' array, then our job is simple:
* create an empty 'data' array; it will be filled in with the dword pattern
* as needed later.
*
* The only wrinkle is if there *is* a 'bytes' array but it's empty, in which
* case we must assume that the pattern was a byte pattern, so convert it to a
* dword pattern.
*/
sector['data'] = adw = [];
if (ab) {
this.assert((dwPattern & 0xff) == dwPattern);
sector['pattern'] = (dwPattern | (dwPattern << 8) | (dwPattern << 16) | (dwPattern << 24));
}
}
else {
/*
* To keep the conversion code simple, we'll do any necessary pattern-filling first,
* to fully "inflate" the sector, eliminating the possibility of partial dwords and
* saving any code downstream from dealing with byte-size patterns.
*/
var cb = length << 2;
this.assert((dwPattern & 0xff) == dwPattern);
for (var ib = ab.length; ib < cb; ib++) {
ab[ib] = dwPattern; // the pattern for byte-arrays was only a byte
}
this.fill(sector, ab, 0);
}
delete sector['bytes'];
}
this.initSector(sector, iCylinder, iHead);
/*
* For the disk as a whole, we maintain a checksum of the original unmodified data:
*
* dwChecksum: summation of all dwords in all non-empty sectors
*
* Pattern-filling of sectors is deferred until absolutely necessary (eg, when a sector is
* being written). So all we need to do at this point is checksum all the initial sector data.
*/
for (var idw = 0; idw < adw.length; idw++) {
dwChecksum = (dwChecksum + adw[idw]) & (0xffffffff|0);
}
}
}
}
this.aDiskData = aDiskData;
this.dwChecksum = dwChecksum;
if (BACKTRACK || SYMBOLS) this.buildFileTable();
disk = this;
}
} catch (e) {
Component.error("Disk image error (" + sURL + "): " + e.message);
}
}
if (this.fnNotify) {
this.fnNotify.call(this.controllerNotify, this.drive, disk, this.sDiskName, this.sDiskPath);
this.fnNotify = null;
}
}
/**
* buildFileTable()
*
* This function builds (or rebuilds) a complete file table from the (first) FAT volume found on the current
* disk, and then updates all the sector objects to point back to the corresponding file. Used for BACKTRACK
* and SYMBOLS support. Because this is an expensive operation, in terms of both time and memory, it should
* only be called when a disk is mounted or has been modified (eg, by applying deltas from a saved machine state).
*
* More recently, the FileInfo objects in the table have been enhanced to include debugging information if
* the file is an EXE or DLL, which we determine merely by checking the file extension.
*
* Note that while most of the methods in this module use CHS-style parameters, because our primary clients
* are old disk controllers that deal exclusively with cylinder/head/sector values, here we use 0-based
* "logical" sector numbers for volume-relative block addresses (aka LBAs or Logical Block Addresses), and
* 0-based "physical" sector numbers for disk-relative block addresses (aka PBAs or Physical Block Addresses).
*
* Also, our use of the term LBA differs from that of more modern disk controllers; in the pre-modern world
* of PCx86, what we call PBA numbers are what those controllers would later call LBA numbers.
*
* @this {Disk}
* @return {Array.|undefined}
*/
buildFileTable()
{
if (BACKTRACK || SYMBOLS) {
var i, off, dir = {}, iSector;
if (this.aFileTable && this.aFileTable.length) {
/*
* In order for buildFileTable() to rebuild an existing table (eg, after deltas have been
* applied), we need to zap any and all existing file table references in the sector data.
*/
var aDiskData = this.aDiskData;
for (var iCylinder = 0; iCylinder < aDiskData.length; iCylinder++) {
for (var iHead = 0; iHead < aDiskData[iCylinder].length; iHead++) {
for (iSector = 0; iSector < aDiskData[iCylinder][iHead].length; iSector++) {
var sector = aDiskData[iCylinder][iHead][iSector];
if (sector) {
delete sector['file'];
delete sector.offFile;
}
}
}
}
}
this.aFileTable = [];
dir.pbaVolume = dir.lbaTotal = 0;
var cbDisk = this.nCylinders * this.nHeads * this.nSectors * this.cbSector;
/*
* At this point, if this is a remote disk, you may see some warning messages in your browser's console,
* like this message from Chrome:
*
* "Synchronous XMLHttpRequest on the main thread is deprecated because of its detrimental effects
* to the end user's experience. For more help, check http://xhr.spec.whatwg.org/."
*
* This is because I was lazy and made the buildFileTable() worker function getSector() use the synchronous
* form of seek(). For development purposes, that was fine, but... TODO: Eventually change buildFileTable()
* to use async I/O.
*/
if (this.fRemote) this.log("ignore any synchronous XMLHttpRequest warnings here (for now)");
var sectorBoot = this.getSector(0);
if (!sectorBoot) {
if (DEBUG && this.messageEnabled()) {
this.printMessage("buildFileTable(): unable to read boot sector");
}
return;
}
dir.cbSector = this.getSectorData(sectorBoot, DiskAPI.BPB.SECTOR_BYTES, 2);
var fValid = true;
if (dir.cbSector != this.cbSector) {
/*
* When the first sector doesn't appear to contain a valid BPB, the most likely explanations are:
*
* 1. The image is from a diskette formatted by DOS 1.xx, which didn't use BPBs
* 2. The image is a fixed (partitioned) disk and the first sector is actually an MBR
* 3. The image is from a diskette that used a non-standard sector size (ie, not 512)
*
* To start, if this is an 160Kb disk (circa DOS 1.00) or a 320Kb disk (circa DOS 1.10), then we'll
* assume it's a 12-bit FAT, set assorted BPB values accordingly, and see if our assumption holds up.
*/
fValid = false;
dir.lbaFAT = 1;
dir.nFATBits = 12;
dir.lbaRoot = dir.lbaFAT + 2; // both 160Kb and 320Kb disks contained 2 FATs, each containing 1 sector
dir.nClusterSecs = 1;
dir.cbSector = this.cbSector;
if (cbDisk == 160 * 1024 && this.getClusterEntry(dir, 0, 0) == DiskAPI.FAT.MEDIA_160KB) {
dir.lbaTotal = 320;
dir.nEntries = 64;
fValid = true;
}
else if (cbDisk == 320 * 1024 && this.getClusterEntry(dir, 0, 0) == DiskAPI.FAT.MEDIA_320KB) {
dir.lbaTotal = 640;
dir.nEntries = 112;
this.assert(this.nHeads == 2);
dir.nClusterSecs++; // 320Kb disks use 2 sectors/cluster
fValid = true;
}
else {
/*
* So, this is either a fixed (partitioned) disk, or a disk using a non-standard sector size; let's assume
* the former and check for an MBR. For now, we're only going to process the first active partition we find.
*/
off = DiskAPI.MBR.PARTITIONS.OFFSET;
for (i = 0; i < 4; i++) {
var bStatus = this.getSectorData(sectorBoot, off + DiskAPI.MBR.PARTITIONS.ENTRY.STATUS, 1);
if (bStatus == DiskAPI.MBR.PARTITIONS.STATUS.ACTIVE) {
dir.pbaVolume = this.getSectorData(sectorBoot, off + DiskAPI.MBR.PARTITIONS.ENTRY.LBA_FIRST, 4);
sectorBoot = this.getSector(dir.pbaVolume);
if (sectorBoot) fValid = true;
break;
}
off += DiskAPI.MBR.PARTITIONS.ENTRY.LENGTH;
}
}
if (!fValid) {
if (DEBUG && this.messageEnabled()) {
this.printMessage("buildFileTable(): unrecognized " + cbDisk + "-byte disk image with " + this.cbSector + "-byte sectors");
}
return;
}
}
if (!dir.lbaTotal) {
dir.lbaTotal = this.getSectorData(sectorBoot, DiskAPI.BPB.TOTAL_SECS, 2) || this.getSectorData(sectorBoot, DiskAPI.BPB.LARGE_SECS, 4);
dir.lbaFAT = this.getSectorData(sectorBoot, DiskAPI.BPB.RESERVED_SECS, 2);
dir.lbaRoot = dir.lbaFAT + this.getSectorData(sectorBoot, DiskAPI.BPB.FAT_SECS, 2) * this.getSectorData(sectorBoot, DiskAPI.BPB.TOTAL_FATS, 1);
dir.nEntries = this.getSectorData(sectorBoot, DiskAPI.BPB.ROOT_DIRENTS, 2);
dir.nClusterSecs = this.getSectorData(sectorBoot, DiskAPI.BPB.CLUSTER_SECS, 1);
}
dir.lbaData = dir.lbaRoot + (((dir.nEntries * DiskAPI.DIRENT.LENGTH + (dir.cbSector - 1)) / dir.cbSector) | 0);
dir.nClusters = (((dir.lbaTotal - dir.lbaData) / dir.nClusterSecs) | 0);
/*
* In all FATs, the first valid cluster number is 2, as 0 is used to indicate a free cluster and 1 is reserved.
*
* In a 12-bit FAT chain, the largest valid cluster number (iClusterMax) is 0xFF6; 0xFF7 is reserved for marking
* bad clusters and should NEVER appear in a cluster chain, and 0xFF8-0xFFF are used to indicate the end of a chain.
* Reports that cluster numbers 0xFF0-0xFF6 are "reserved" (eg, http://support.microsoft.com/KB/65541) should be
* ignored; those numbers may have been considered "reserved" at some early point in FAT's history, but no longer.
*
* Since 12 bits yield 4096 possible values, and since 11 of the values (0, 1, and 0xFF7-0xFFF) cannot be used to
* refer to an actual cluster, that leaves a theoretical maximum of 4085 clusters for a 12-bit FAT. However, for
* reasons that only a small (and shrinking -- RIP AAR) number of people know, the actual cut-off is 4084.
*
* So, a FAT volume with 4084 or fewer clusters uses a 12-bit FAT, a FAT volume with 4085 to 65524 clusters uses
* a 16-bit FAT, and a FAT volume with more than 65524 clusters uses a 32-bit FAT.
*
* TODO: Eventually add support for FAT32.
*/
dir.nFATBits = (dir.nClusters <= DiskAPI.FAT12.MAX_CLUSTERS? 12 : 16);
dir.iClusterMax = (dir.nFATBits == 12? DiskAPI.FAT12.CLUSNUM_MAX : DiskAPI.FAT16.CLUSNUM_MAX);
if (DEBUG && this.messageEnabled()) {
this.printMessage("buildFileTable()\n\tlbaFAT: " + dir.lbaFAT + "\n\tlbaRoot: " + dir.lbaRoot + "\n\tlbaData: " + dir.lbaData + "\n\tlbaTotal: " + dir.lbaTotal + "\n\tnClusterSecs: " + dir.nClusterSecs + "\n\tnClusters: " + dir.nClusters);
}
/*
* The following assertion is here only to catch anomalies; it is NOT a requirement that the number of data sectors
* be a perfect multiple of nClusterSecs, but if it ever happens, it's worth verifying we didn't miscalculate something.
*/
i = (dir.lbaTotal - dir.lbaData) % dir.nClusterSecs;
if (i) {
if (DEBUG && this.messageEnabled()) {
this.printMessage("buildFileTable(): " + cbDisk + "-byte disk image wasting " + i + " sectors");
}
}
/*
* Similarly, it is NOT a requirement that the size of all root directory entries be a perfect multiple of the sector
* size (cbSector), but it may indicate a problem if it's not. Note that when it comes time to read the root directory,
* we treat it exactly like any other directory; that is, we ignore the nEntries value and scan the entire contents of
* every sector allocated to the directory. TODO: Determine whether DOS reads all root sector contents or only nEntries
* (ie, create a test volume where nEntries * 32 is NOT a multiple of cbSector and watch what happens).
*/
this.assert(!((dir.nEntries * DiskAPI.DIRENT.LENGTH) % dir.cbSector));
var apba = [];
for (var lba = dir.lbaRoot; lba < dir.lbaData; lba++) apba.push(dir.pbaVolume + lba);
this.getDir(dir, this.sDiskFile, "", apba);
/*
* Create the sector-to-file mappings now.
*/
for (i = 0; i < this.aFileTable.length; i++) {
var file = this.aFileTable[i];
off = 0;
for (iSector = 0; iSector < file.apba.length; iSector++) {
this.updateSector(file, file.apba[iSector], off);
off += this.cbSector;
}
file.loadSymbols();
}
}
return this.aFileTable;
}
/**
* getModuleInfo(sModule, nSegment)
*
* If the given module and segment number is found, we return an Array of symbol offsets, indexed by symbol name.
*
* @this {Disk}
* @param {string} sModule
* @param {number} nSegment
* @return {Object}
*/
getModuleInfo(sModule, nSegment)
{
var aSymbols = {};
if (SYMBOLS && this.aFileTable) {
for (var iFile = 0; iFile < this.aFileTable.length; iFile++) {
var file = this.aFileTable[iFile];
if (file.sModule != sModule) continue;
var segment = file.aSegments[nSegment];
if (!segment) continue;
for (var iOrdinal in segment.aEntries) {
var entry = segment.aEntries[iOrdinal];
/*
* entry[1] is the symbol name, which becomes the index, and entry[0] is the offset.
*/
aSymbols[entry[1]] = entry[0];
}
break;
}
}
return aSymbols;
}
/**
* getSymbolInfo(sSymbol)
*
* For all whole or partial symbol matches, return them in an Array of entries:
*
* [symbol, file name, segment number, segment offset, segment size].
*
* TODO: This function has many limitations (ie, slow, case-sensitive), but it gets the job done for now.
*
* @this {Disk}
* @param {string} sSymbol
* @return {Array}
*/
getSymbolInfo(sSymbol)
{
var aInfo = [];
if (SYMBOLS && this.aFileTable) {
var sSymbolUpper = sSymbol.toUpperCase();
for (var iFile = 0; iFile < this.aFileTable.length; iFile++) {
var file = this.aFileTable[iFile];
for (var iSegment in file.aSegments) {
var segment = file.aSegments[iSegment];
for (var iOrdinal in segment.aEntries) {
var entry = segment.aEntries[iOrdinal];
if (entry[1] && entry[1].indexOf(sSymbolUpper) >= 0) {
aInfo.push([entry[1], file.sName, iSegment, entry[0], segment.offEnd - segment.offStart]);
}
}
}
}
}
return aInfo;
}
/**
* getDir(dir, sDisk, sDir, apba)
*
* @this {Disk}
* @param {Object} dir
* @param {string} sDisk
* @param {string} sDir
* @param {Array.} apba
*/
getDir(dir, sDisk, sDir, apba)
{
var file;
var iStart = this.aFileTable.length;
var nEntriesPerSector = (dir.cbSector / DiskAPI.DIRENT.LENGTH) | 0;
dir.sDir = sDir + "\\";
if (DEBUG && this.messageEnabled()) this.printMessage('getDir("' + sDisk + '","' + dir.sDir + '")');
for (var iSector = 0; iSector < apba.length; iSector++) {
var pba = apba[iSector];
for (var iEntry = 0; iEntry < nEntriesPerSector; iEntry++) {
if (!this.getDirEntry(dir, pba, iEntry)) {
iSector = apba.length;
break;
}
if (dir.sName == null || dir.sName == "." || dir.sName == "..") continue;
var sPath = dir.sDir + dir.sName;
if (DEBUG && this.messageEnabled(Messages.DISK | Messages.DATA)) {
this.printMessage('"' + sPath + '" size=' + dir.cbSize + ' cluster=' + dir.iCluster + ' sectors=' + JSON.stringify(dir.apba));
if (dir.apba.length) this.printMessage(this.dumpSector(this.getSector(dir.apba[0]), dir.apba[0], sPath));
}
file = new FileInfo(this, sPath, dir.sName, dir.bAttr, dir.cbSize, dir.apba);
this.aFileTable.push(file);
}
}
var iEnd = this.aFileTable.length;
for (var i = iStart; i < iEnd; i++) {
file = this.aFileTable[i];
if (file.bAttr & DiskAPI.ATTR.SUBDIR && file.apba.length) this.getDir(dir, sDisk, sDir + "\\" + file.sName, file.apba);
}
}
/**
* getDirEntry(dir, pba, i)
*
* This sets the following properties on the 'dir' object:
*
* sName (null if invalid/deleted entry)
* bAttr
* cbSize
* iCluster
* apba (ie, array of physical block addresses)
*
* On return, it's the caller's responsibility to copy out any data into a new object
* if it wants to preserve any of the above information.
*
* This function also caches the following properties in the 'dir' object:
*
* pbaDirCache (of the last directory sector read, if any)
* sectorDirCache (of the last directory sector read, if any)
*
* Also, the caller must also set the following 'dir' helper properties, so that clusters
* can be located and converted to sectors (see convertClusterToSectors):
*
* lbaFAT
* lbaData
* cbSector
* iClusterMax
* nClusterSecs
* nFATBits
*
* @this {Disk}
* @param {Object} dir (to be filled in)
* @param {number} pba (a sector of the directory)
* @param {number} i (an entry in the directory sector, 0-based)
* @returns {boolean} true if entry was returned (even if invalid/deleted), false if no more entries
*/
getDirEntry(dir, pba, i)
{
if (!dir.sectorDirCache || !dir.pbaDirCache || dir.pbaDirCache != pba) {
dir.pbaDirCache = pba;
dir.sectorDirCache = this.getSector(dir.pbaDirCache);
if (DEBUG && this.messageEnabled(Messages.DISK | Messages.DATA)) {
this.printMessage(this.dumpSector(dir.sectorDirCache, dir.pbaDirCache, dir.sDir));
}
}
if (dir.sectorDirCache) {
var off = i * DiskAPI.DIRENT.LENGTH;
var b = this.getSectorData(dir.sectorDirCache, off, 1);
if (b == DiskAPI.DIRENT.UNUSED) {
return false;
}
if (b == DiskAPI.DIRENT.INVALID) {
dir.sName = null;
return true;
}
dir.sName = Str.trim(this.getSectorString(dir.sectorDirCache, off + DiskAPI.DIRENT.NAME, 8));
var s = Str.trim(this.getSectorString(dir.sectorDirCache, off + DiskAPI.DIRENT.EXT, 3));
if (s.length) dir.sName += '.' + s;
dir.bAttr = this.getSectorData(dir.sectorDirCache, off + DiskAPI.DIRENT.ATTR, 1);
dir.cbSize = this.getSectorData(dir.sectorDirCache, off + DiskAPI.DIRENT.SIZE, 2);
dir.iCluster = this.getSectorData(dir.sectorDirCache, off + DiskAPI.DIRENT.CLUSTER, 2);
dir.apba = this.convertClusterToSectors(dir);
return true;
}
return false;
}
/**
* convertClusterToSectors(dir)
*
* @this {Disk}
* @param {Object} dir
* @return {Array.} of PBAs (physical block addresses)
*/
convertClusterToSectors(dir)
{
var apba = [];
var iCluster = dir.iCluster;
if (iCluster) {
do {
if (iCluster < DiskAPI.FAT12.CLUSNUM_MIN) {
this.assert(false);
break;
}
var lba = dir.lbaData + ((iCluster - DiskAPI.FAT12.CLUSNUM_MIN) * dir.nClusterSecs);
for (var i = 0; i < dir.nClusterSecs; i++) {
apba.push(dir.pbaVolume + lba++);
}
iCluster = this.getClusterEntry(dir, iCluster, 0) | this.getClusterEntry(dir, iCluster, 1);
} while (iCluster <= dir.iClusterMax);
this.assert(iCluster != dir.iClusterMax + 1); // make sure we never see CLUSNUM_BAD in a cluster chain
}
return apba;
}
/**
* getClusterEntry(dir, iCluster, iByte)
*
* @this {Disk}
* @param {Object} dir
* @param {number} iCluster
* @param {number} iByte (0 for low byte of cluster entry, 1 for high byte)
* @return {number}
*/
getClusterEntry(dir, iCluster, iByte)
{
var w = 0;
var cbitsSector = dir.cbSector * 8;
var offBits = dir.nFATBits * iCluster + (iByte? 8 : 0);
var iSector = (offBits / cbitsSector) | 0;
if (!dir.sectorFATCache || !dir.lbaFATCache || dir.lbaFATCache != dir.lbaFAT + iSector) {
dir.lbaFATCache = dir.lbaFAT + iSector;
dir.sectorFATCache = this.getSector(dir.pbaVolume + dir.lbaFATCache);
}
if (dir.sectorFATCache) {
offBits = (offBits % cbitsSector) | 0;
var off = (offBits >> 3);
w = this.getSectorData(dir.sectorFATCache, off, 1);
if (!iByte) {
if (offBits & 0x7) w >>= 4;
} else {
if (dir.nFATBits == 16) {
w <<= 8;
} else {
this.assert(dir.nFATBits == 12);
if (offBits & 0x7) {
w <<= 4;
} else {
w = (w & 0xf) << 8;
}
}
}
}
return w;
}
/**
* getSector(pba)
*
* @this {Disk}
* @param {number} pba (physical block address)
* @return {Object|null} sector
*/
getSector(pba)
{
var nSectorsPerCylinder = this.nHeads * this.nSectors;
var iCylinder = (pba / nSectorsPerCylinder) | 0;
if (iCylinder < this.nCylinders) {
var nSectorsRemaining = (pba % nSectorsPerCylinder);
var iHead = (nSectorsRemaining / this.nSectors) | 0;
/*
* PBA numbers are 0-based, but the sector numbers in CHS addressing are 1-based, so add one to iSector
*/
var iSector = (nSectorsRemaining % this.nSectors) + 1;
return this.seek(iCylinder, iHead, iSector);
}
return null;
}
/**
* getSectorData(sector, off, len)
*
* WARNING: This function is restricted to reading data contained ENTIRELY within the specified sector.
*
* NOTE: Yes, this function is not the most efficient way to read a byte/word/dword value from within a sector,
* but given the different states a sector may be in, it's certainly the simplest and safest, and since this is
* only used by buildFileTable() and its progeny, it's not clear that we need to be superfast anyway.
*
* @this {Disk}
* @param {Object} sector
* @param {number} off (byte offset)
* @param {number} len (1 to 4 bytes)
* @return {number}
*/
getSectorData(sector, off, len)
{
var dw = 0;
var nShift = 0;
this.assert(len > 0 && len <= 4);
while (len--) {
this.assert(off < sector['length']);
var b = this.read(sector, off++);
this.assert(b >= 0);
if (b < 0) break;
dw |= (b << nShift);
nShift += 8;
}
return dw;
}
/**
* getSectorString(sector, off, len)
*
* WARNING: This function is restricted to reading a string contained ENTIRELY within the specified sector.
*
* @this {Disk}
* @param {Object} sector
* @param {number} off (byte offset)
* @param {number} len (use -1 to read a null-terminated string)
* @return {string}
*/
getSectorString(sector, off, len)
{
var s = "";
while (len--) {
var b = this.read(sector, off++);
if (b <= 0) break;
s += String.fromCharCode(b);
}
return s;
}
/**
* updateSector(file, pba, off)
*
* Like getSector(), this must convert a PBA into CHS values; consider factoring that conversion code out.
*
* @this {Disk}
* @param {FileInfo} file
* @param {number} pba (physical block address from the file's apba)
* @param {number} off (file offset corresponding to the given pba of the given file)
* @return {boolean} true if successfully updated, false if not
*/
updateSector(file, pba, off)
{
var nSectorsPerCylinder = this.nHeads * this.nSectors;
var iCylinder = (pba / nSectorsPerCylinder) | 0;
var nSectorsRemaining = (pba % nSectorsPerCylinder);
var iHead = (nSectorsRemaining / this.nSectors) | 0;
var iSector = (nSectorsRemaining % this.nSectors);
var cylinder, head, sector;
if ((cylinder = this.aDiskData[iCylinder]) && (head = cylinder[iHead]) && (sector = head[iSector])) {
this.assert(sector['sector'] == iSector +1);
if (sector['file']) {
if (DEBUG && this.messageEnabled()) {
this.printMessage('"' + sector['file'].sPath + '" cross-linked at offset ' + sector['file'].offFile + ' with "' + file.sPath + '" at offset ' + off);
}
return false;
}
sector['file'] = file;
sector.offFile = off;
return true;
}
if (DEBUG && this.messageEnabled()) this.printMessage("unable to map PBA " + pba + " to CHS");
return false;
}
/**
* initSector(sector, iCylinder, iHead, iSector, cbSector, dwPattern)
*
* Ensures every sector has ALL the properties of a proper Sector object; ie:
*
* 'sector': sector number
* 'length': size of the sector, in bytes
* 'data': array of dwords
* 'pattern': dword pattern to use for empty or partial sectors (null for unread remote sectors)
*
* In addition, we will maintain the following information on a per-sector basis,
* as sectors are modified:
*
* iModify: index of first modified dword in sector
* cModify: number of modified dwords in sector
* fDirty: true if sector is dirty, false if clean (or cleaning in progress)
*
* @this {Disk}
* @param {Object} sector
* @param {number} iCylinder
* @param {number} iHead
* @param {number} [iSector]
* @param {number} [cbSector]
* @param {number|null} [dwPattern]
* @return {Object}
*/
initSector(sector, iCylinder, iHead, iSector, cbSector, dwPattern)
{
if (!sector) {
sector = {'sector': iSector, 'length': cbSector, 'data': [], 'pattern': dwPattern};
}
sector.iCylinder = iCylinder;
sector.iHead = iHead;
sector.iModify = sector.cModify = 0;
sector.fDirty = false;
return sector;
}
/**
* connectRemoteDisk(sDiskPath)
*
* Unlike disconnect(), we don't issue the connect request ourselves; instead, we piggyback on the existing
* preload code in load() to establish the connection. That, in turn, will trigger a call to mount(), which
* will check fOnDemand and set fRemote if the connection was successful.
*
* @this {Disk}
* @param {string} sDiskPath
* @return {string} is the URL connection string required to connect to sDiskPath
*/
connectRemoteDisk(sDiskPath)
{
var sParms = DiskAPI.QUERY.ACTION + '=' + DiskAPI.ACTION.OPEN;
sParms += '&' + DiskAPI.QUERY.VOLUME + '=' + sDiskPath;
sParms += '&' + DiskAPI.QUERY.MODE + '=' + this.mode;
sParms += '&' + DiskAPI.QUERY.CHS + '=' + this.nCylinders + ':' + this.nHeads + ':' + this.nSectors + ':' + this.cbSector;
sParms += '&' + DiskAPI.QUERY.MACHINE + '=' + this.controller.getMachineID();
sParms += '&' + DiskAPI.QUERY.USER + '=' + this.controller.getUserID();
return Web.getHost() + DiskAPI.ENDPOINT + '?' + sParms;
}
/**
* readRemoteSectors(iCylinder, iHead, iSector, nSectors, fAsync, done)
*
* @this {Disk}
* @param {number} iCylinder
* @param {number} iHead
* @param {number} iSector
* @param {number} nSectors (to read)
* @param {boolean} fAsync
* @param {function(number,boolean)} [done]
*/
readRemoteSectors(iCylinder, iHead, iSector, nSectors, fAsync, done)
{
if (DEBUG && this.messageEnabled()) {
this.printMessage("readRemoteSectors(CHS=" + iCylinder + ':' + iHead + ':' + iSector + ",N=" + nSectors + ")");
}
if (this.fRemote) {
var sParms = DiskAPI.QUERY.ACTION + '=' + DiskAPI.ACTION.READ;
sParms += '&' + DiskAPI.QUERY.VOLUME + '=' + this.sDiskPath;
sParms += '&' + DiskAPI.QUERY.CHS + '=' + this.nCylinders + ':' + this.nHeads + ':' + this.nSectors + ':' + this.cbSector;
sParms += '&' + DiskAPI.QUERY.ADDR + '=' + iCylinder + ':' + iHead + ':' + iSector + ':' + nSectors;
sParms += '&' + DiskAPI.QUERY.MACHINE + '=' + this.controller.getMachineID();
sParms += '&' + DiskAPI.QUERY.USER + '=' + this.controller.getUserID();
var disk = this;
var sDiskURL = Web.getHost() + DiskAPI.ENDPOINT + '?' + sParms;
Web.getResource(sDiskURL, null, fAsync, function(sURL, sResponse, nErrorCode) {
disk.doneReadRemoteSectors(sURL, sResponse, nErrorCode, [iCylinder, iHead, iSector, nSectors, fAsync, done]);
});
return;
}
if (done) done(-1, false);
}
/**
* doneReadRemoteSectors(sURLName, sURLData, nErrorCode, aRequest)
*
* @this {Disk}
* @param {string} sURLName
* @param {string} sURLData
* @param {number} nErrorCode
* @param {Array} aRequest ([iCylinder, iHead, iSector, nSectors, fAsync, done])
*/
doneReadRemoteSectors(sURLName, sURLData, nErrorCode, aRequest)
{
var fAsync = false;
var iCylinder = aRequest[0];
var iHead = aRequest[1];
var iSector = aRequest[2];
var nSectors = aRequest[3];
if (!nErrorCode) {
var abData = JSON.parse(sURLData);
var offData = 0;
while (nSectors--) {
/*
* We call seek with fWrite == true to prevent seek() from triggering another call
* to readRemoteSectors() and endlessly recursing. That also forces seek() to:
*
* 1) zero the sector's 'pattern'
* 2) disable warning about reading an uninitialized sector
*
* We KNOW this is an uninitialized sector, because we're about to initialize it.
*/
var sector = this.seek(iCylinder, iHead, iSector, true);
if (!sector) {
if (DEBUG && this.messageEnabled()) {
this.printMessage("doneReadRemoteSectors(): seek(CHS=" + iCylinder + ':' + iHead + ':' + iSector + ") failed");
}
break;
}
this.fill(sector, abData, offData);
offData += sector['length'];
/*
* We happen to know that when seek() calls readRemoteSectors(), it limits the number of sectors
* to the current track, so the only variable we need to advance is iSector.
*/
iSector++;
}
fAsync = aRequest[4];
} else {
if (DEBUG && this.messageEnabled()) {
this.printMessage("doneReadRemoteSectors(CHS=" + iCylinder + ':' + iHead + ':' + iSector + ",N=" + nSectors + ") returned error " + nErrorCode);
}
}
var done = aRequest[5];
if (done) done(nErrorCode, fAsync);
}
/**
* writeRemoteSectors(iCylinder, iHead, iSector, nSectors, abSectors, fAsync)
*
* Writes to a remote disk are performed on a timer-driven basis. When a sector is modified for the first time,
* a reference to that sector is "pushed" onto (ie, appended to the end of) aDirtySectors, and if aDirtySectors was
* originally empty, then a REMOTE_WRITE_DELAY timer is set.
*
* When the timer fires, the first batch of contiguous sectors is sent off the server, and when the server responds
* (ie, when cleanDirtySectors() is called), if the response indicates success, every sector that was sent is marked
* clean -- unless one or more writes to the sector occurred in the meantime, which we track through a per-sector
* fDirty flag.
*
* @this {Disk}
* @param {number} iCylinder
* @param {number} iHead
* @param {number} iSector
* @param {number} nSectors (to write)
* @param {Array.} abSectors
* @param {boolean} fAsync
* @return {boolean|Array}
*/
writeRemoteSectors(iCylinder, iHead, iSector, nSectors, abSectors, fAsync)
{
if (DEBUG && this.messageEnabled()) {
this.printMessage("writeRemoteSectors(CHS=" + iCylinder + ':' + iHead + ':' + iSector + ",N=" + nSectors + ")");
}
if (this.fRemote) {
var dataPost = {};
this.fWriteInProgress = true;
dataPost[DiskAPI.QUERY.ACTION] = DiskAPI.ACTION.WRITE;
dataPost[DiskAPI.QUERY.VOLUME] = this.sDiskPath;
dataPost[DiskAPI.QUERY.CHS] = this.nCylinders + ':' + this.nHeads + ':' + this.nSectors + ':' + this.cbSector;
dataPost[DiskAPI.QUERY.ADDR] = iCylinder + ':' + iHead + ':' + iSector + ':' + nSectors;
dataPost[DiskAPI.QUERY.MACHINE] = this.controller.getMachineID();
dataPost[DiskAPI.QUERY.USER] = this.controller.getUserID();
dataPost[DiskAPI.QUERY.DATA] = JSON.stringify(abSectors);
var disk = this;
var sDiskURL = Web.getHost() + DiskAPI.ENDPOINT;
Web.getResource(sDiskURL, dataPost, fAsync, function(sURL, sResponse, nErrorCode) {
disk.doneWriteRemoteSectors(sURL, sResponse, nErrorCode, [iCylinder, iHead, iSector, nSectors, fAsync]);
});
}
return false;
}
/**
* doneWriteRemoteSectors(sURLName, sURLData, nErrorCode, aRequest)
*
* @this {Disk}
* @param {string} sURLName
* @param {string} sURLData
* @param {number} nErrorCode
* @param {Array} aRequest ([iCylinder, iHead, iSector, nSectors, fAsync])
*/
doneWriteRemoteSectors(sURLName, sURLData, nErrorCode, aRequest)
{
var iCylinder = aRequest[0];
var iHead = aRequest[1];
var iSector = aRequest[2];
var nSectors = aRequest[3];
var fAsync = aRequest[4];
this.fWriteInProgress = false;
if (iCylinder >= 0 && iCylinder < this.aDiskData.length && iHead >= 0 && iHead < this.aDiskData[iCylinder].length) {
for (var i = iSector - 1; nSectors-- > 0 && i >= 0 && i < this.aDiskData[iCylinder][iHead].length; i++) {
var sector = this.aDiskData[iCylinder][iHead][i];
if (!nErrorCode) {
if (!sector.fDirty) {
sector.iModify = sector.cModify = 0;
}
} else {
if (DEBUG && this.messageEnabled()) {
this.printMessage("doneWriteRemoteSectors(CHS=" + iCylinder + ':' + iHead + ':' + sector['sector'] + ") returned error " + nErrorCode);
}
this.queueDirtySector(sector, false);
}
}
}
if (fAsync) this.updateWriteTimer();
}
/**
* disconnectRemoteDisk()
*
* This is called by our powerDown() notification handler. If fRemote is true, we issue the disconnect
* request and then immediately set fRemote to false; we don't wait for (or test) the response.
*
* @this {Disk}
*/
disconnectRemoteDisk()
{
if (this.fRemote) {
var sParms = DiskAPI.QUERY.ACTION + '=' + DiskAPI.ACTION.CLOSE;
sParms += '&' + DiskAPI.QUERY.VOLUME + '=' + this.sDiskPath;
sParms += '&' + DiskAPI.QUERY.MACHINE + '=' + this.controller.getMachineID();
sParms += '&' + DiskAPI.QUERY.USER + '=' + this.controller.getUserID();
var sDiskURL = Web.getHost() + DiskAPI.ENDPOINT + '?' + sParms;
Web.getResource(sDiskURL, null, true);
this.fRemote = false;
}
}
/**
* queueDirtySector(sector, fAsync)
*
* Mark the specified sector as dirty, add it to the queue (aDirtySectors) if not already added,
* and establish a timeout handler (findDirtySectors) if not already established.
*
* A freshly dirtied sector should sit in the queue for a short period of time (eg, 2 seconds)
* before we attempt to write it; that is, a REMOTE_WRITE_DELAY timer should start ticking again
* for any sector that is rewritten. However, there will be exceptions; for example, when a sector
* is finally written, we want to take advantage of the write request to write any additional dirty
* sectors that follow it, even if those additional sectors were written less than 2 seconds ago.
*
* @this {Disk}
* @param {Object} sector
* @param {boolean} fAsync (true to update write timer, false to not)
* @return {boolean} true if write timer set, false if not
*/
queueDirtySector(sector, fAsync)
{
sector.fDirty = true;
var j = this.aDirtySectors.indexOf(sector);
if (j >= 0) {
this.aDirtySectors.splice(j, 1);
this.aDirtyTimestamps.splice(j, 1);
}
this.aDirtySectors.push(sector);
this.aDirtyTimestamps.push(Usr.getTime());
if (DEBUG && this.messageEnabled()) {
this.printMessage("queueDirtySector(CHS=" + sector.iCylinder + ':' + sector.iHead + ':' + sector['sector'] + "): " + this.aDirtySectors.length + " dirty");
}
return fAsync && this.updateWriteTimer();
}
/**
* updateWriteTimer()
*
* If a timer is already active, make sure it's still valid (ie, the time the timer is scheduled to fire is
* >= the timestamp of the next dirty sector + REMOTE_WRITE_DELAY); if not, cancel the timer and start a new one.
*
* @this {Disk}
* @return {boolean} true if write timer set, false if not
*/
updateWriteTimer()
{
if (this.aDirtySectors.length) {
var msWrite = this.aDirtyTimestamps[0] + Disk.REMOTE_WRITE_DELAY;
if (this.timerWrite) {
if (this.msTimerWrite < msWrite) {
clearTimeout(this.timerWrite);
this.timerWrite = null;
}
}
if (!this.timerWrite) {
var obj = this;
var msNow = Usr.getTime();
var msDelay = msWrite - msNow;
if (msDelay < 0) msDelay = 0;
if (msDelay > Disk.REMOTE_WRITE_DELAY) msDelay = Disk.REMOTE_WRITE_DELAY;
this.timerWrite = setTimeout(function() {
obj.findDirtySectors(true);
}, msDelay);
this.msTimerWrite = msNow + msDelay;
}
} else {
if (this.timerWrite) {
clearTimeout(this.timerWrite);
this.timerWrite = null;
}
}
return this.timerWrite !== null;
}
/**
* findDirtySectors(fAsync)
*
* Starting with the oldest dirty sector in the queue (aDirtySectors), determine the longest contiguous stretch of
* dirty sectors (currently limited to the same track), mark them all as not dirty, and then call writeRemoteSectors().
*
* @this {Disk}
* @param {boolean} fAsync is true if this function is being called asynchronously, false otherwise
* @return {boolean|Array} false if no dirty sectors, otherwise true (or a response array if not fAsync)
*/
findDirtySectors(fAsync)
{
if (fAsync) {
this.timerWrite = null;
}
var sector = this.aDirtySectors[0];
if (sector) {
var iCylinder = sector.iCylinder;
var iHead = sector.iHead;
var iSector = sector['sector'];
var nSectors = 0;
var abSectors = [];
for (var i = iSector - 1; i < this.aDiskData[iCylinder][iHead].length; i++) {
var sectorNext = this.aDiskData[iCylinder][iHead][i];
if (!sectorNext.fDirty) break;
var j = this.aDirtySectors.indexOf(sectorNext);
this.assert(j >= 0, "findDirtySectors(CHS=" + iCylinder + ':' + iHead + ':' + sectorNext['sector'] + ") missing from aDirtySectors");
if (DEBUG && this.messageEnabled()) {
this.printMessage("findDirtySectors(CHS=" + iCylinder + ':' + iHead + ':' + sectorNext['sector'] + ")");
}
this.aDirtySectors.splice(j, 1);
this.aDirtyTimestamps.splice(j, 1);
abSectors = abSectors.concat(this.toBytes(sectorNext));
sectorNext.fDirty = false;
nSectors++;
}
this.assert(!!abSectors.length, "no data for dirty sector (CHS=" + iCylinder + ':' + iHead + ':' + sector['sector'] + ")");
var response = this.writeRemoteSectors(iCylinder, iHead, iSector, nSectors, abSectors, fAsync);
return fAsync || response;
}
return false;
}
/**
* info()
*
* @this {Disk}
* @return {Array} containing: [nCylinders, nHeads, nSectorsPerTrack, nBytesPerSector]
*/
info()
{
if (!this.aDiskData.length) {
return [0, 0, 0, 0];
}
return [this.aDiskData.length, this.aDiskData[0].length, this.aDiskData[0][0].length, this.aDiskData[0][0][0]['length']];
}
/**
* seek(iCylinder, iHead, iSector, fWrite, done)
*
* TODO: There's some dodgy code in seek() that allows floppy images to be dynamically
* reconfigured with more heads and/or sectors/track, and it does so by peeking at more drive
* properties. That code used to be in the FDC component, where it was perfectly reasonable
* to access those properties. We need a cleaner interface back to the drive, similar to the
* info() interface we provide to the controller.
*
* Whether or not the "dynamic reconfiguration" feature itself is perfectly reasonable is,
* of course, a separate question.
*
* @this {Disk}
* @param {number} iCylinder
* @param {number} iHead
* @param {number} iSector
* @param {boolean} [fWrite]
* @param {function(Object,boolean)} [done]
* @return {Object|null} is the requested sector, or null if not found (or not available yet)
*/
seek(iCylinder, iHead, iSector, fWrite, done)
{
var sector = null;
var drive = this.drive;
var cylinder = this.aDiskData[iCylinder];
if (cylinder) {
var i;
var track = cylinder[iHead];
/*
* The following code allows a single-sided diskette image to be reformatted (ie, "expanded")
* as a double-sided image, provided the drive has more than one head (see drive.nHeads).
*/
if (!track && drive.bFormatting && iHead < drive.nHeads) {
track = cylinder[iHead] = new Array(drive.bSectorEnd);
for (i = 0; i < track.length; i++) {
track[i] = this.initSector(null, iCylinder, iHead, i + 1, drive.nBytes, 0);
}
/*
* TODO: This is more dodginess, because we can't be certain that every cylinder on the disk
* will receive the same "expanded" treatment, but functions like getSector() rely on instance
* properties (eg, this.nHeads), on the assumption that the disk's geometry is homogeneous.
*/
if (this.nHeads <= iHead) this.nHeads = iHead + 1;
}
if (track) {
for (i = 0; i < track.length; i++) {
if (track[i] && track[i]['sector'] == iSector) {
/*
* If the sector's pattern is null, then this sector's true contents have not yet
* been fetched from the server.
*/
sector = track[i];
if (sector['pattern'] === null) {
if (fWrite) {
/*
* Optimization: if the caller has explicitly told us that they're about to WRITE to the
* sector, then we shouldn't need to read it from the server; assume a zero pattern and return.
*/
sector['pattern'] = 0;
} else {
var nSectors = 1;
/*
* We know we need to read at least 1 sector, but let's count the number of trailing sectors
* on the same track that may also be required.
*/
while (++i < track.length) {
if (track[i]['pattern'] === null) nSectors++;
}
this.readRemoteSectors(iCylinder, iHead, iSector, nSectors, done != null, function onReadRemoteComplete(err, fAsync) {
if (err) sector = null;
if (done) { //noinspection JSReferencingMutableVariableFromClosure
done(sector, fAsync);
}
});
return done? null : sector;
}
}
break;
}
}
/*
* The following code allows an 8-sector track to be reformatted (ie, "expanded") as a 9-sector track.
*/
if (!sector && drive.bFormatting && drive.bSector == 9) {
sector = track[i] = this.initSector(null, iCylinder, iHead, drive.bSector, drive.nBytes, 0);
/*
* TODO: This is more dodginess, because we can't be certain that every track on the disk
* will receive the same "expanded" treatment, but functions like getSector() rely on instance
* properties (eg, this.nSectors), on the assumption that the disk's geometry is homogeneous.
*/
if (this.nSectors < drive.bSector) this.nSectors = drive.bSector;
}
}
}
if (done) done(sector, false);
return sector;
}
/**
* fill(sector, ab, off)
*
* @this {Disk}
* @param {Object} sector
* @param {*} ab (technically, this should be typed as Array. but I'm having trouble coercing JSON.parse() to that)
* @param {number} off
*/
fill(sector, ab, off)
{
var cdw = sector['length'] >> 2;
var adw = new Array(cdw);
for (var idw = 0; idw < cdw; idw++) {
adw[idw] = ab[off] | (ab[off + 1] << 8) | (ab[off + 2] << 16) | (ab[off + 3] << 24);
off += 4;
}
sector['data'] = adw;
/*
* TODO: Consider taking this opportunity to shrink 'data' down by the number of dwords at the end of the buffer that
* contain the same pattern, and setting 'pattern' accordingly.
*/
}
/**
* toBytes(sector)
*
* @this {Disk}
* @param {Object} sector
* @return {Array.} is an array of bytes
*/
toBytes(sector)
{
var cb = sector['length'];
var ab = new Array(cb);
var ib = 0;
var cdw = cb >> 2;
var adw = sector['data'];
var dwPattern = sector['pattern'];
for (var idw = 0; idw < cdw; idw++) {
var dw = (idw < adw.length? adw[idw] : dwPattern);
ab[ib++] = dw & 0xff;
ab[ib++] = (dw >> 8) & 0xff;
ab[ib++] = (dw >> 16) & 0xff;
ab[ib++] = (dw >> 24) & 0xff;
}
return ab;
}
/**
* read(sector, ibSector, fCompare)
*
* @this {Disk}
* @param {Object} sector (returned from a previous seek)
* @param {number} ibSector a byte index within the given sector
* @param {boolean} [fCompare] is true if this write-compare read
* @return {number} the specified (unsigned) byte, or -1 if no more data in the sector
*/
read(sector, ibSector, fCompare)
{
var b = -1;
if (sector) {
if (DEBUG && !ibSector && !fCompare && this.messageEnabled()) {
this.printMessage('read("' + this.sDiskFile + '",CHS=' + sector.iCylinder + ':' + sector.iHead + ':' + sector['sector'] + ')');
}
if (ibSector < sector['length']) {
var adw = sector['data'];
var idw = ibSector >> 2;
var dw = (idw < adw.length ? adw[idw] : sector['pattern']);
b = ((dw >> ((ibSector & 0x3) << 3)) & 0xff);
}
}
return b;
}
/**
* write(sector, ibSector, b)
*
* @this {Disk}
* @param {Object} sector (returned from a previous seek)
* @param {number} ibSector a byte index within the given sector
* @param {number} b the byte value to write
* @return {boolean|null} true if write successful, false if write-protected, null if out of bounds
*/
write(sector, ibSector, b)
{
if (this.fWriteProtected)
return false;
if (DEBUG && !ibSector && this.messageEnabled()) {
this.printMessage('write("' + this.sDiskFile + '",CHS=' + sector.iCylinder + ':' + sector.iHead + ':' + sector['sector'] + ')');
}
if (ibSector < sector['length']) {
if (b != this.read(sector, ibSector, true)) {
var adw = sector['data'];
var dwPattern = sector['pattern'];
var idw = ibSector >> 2;
var nShift = (ibSector & 0x3) << 3;
/*
* Ensure every byte up to the specified byte is properly initialized.
*/
for (var i = adw.length; i <= idw; i++) adw[i] = dwPattern;
if (!sector.cModify) {
sector.iModify = idw;
sector.cModify = 1;
} else if (idw < sector.iModify) {
sector.cModify += sector.iModify - idw;
sector.iModify = idw;
} else if (idw >= sector.iModify + sector.cModify) {
sector.cModify += idw - (sector.iModify + sector.cModify) + 1;
}
adw[idw] = (adw[idw] & ~(0xff << nShift)) | (b << nShift);
if (this.fRemote) this.queueDirtySector(sector, true);
}
return true;
}
return null;
}
/**
* encodeAsBase64()
*
* @this {Disk}
* @return {string}
*/
encodeAsBase64()
{
/*
* Gross, but simple; more importantly, it works -- at least for disks of typical floppy magnitude.
*/
var s = "", pba = 0, sector;
while ((sector = this.getSector(pba++))) {
for (var off = 0, len = sector['length']; off < len; off++) {
s += String.fromCharCode(this.getSectorData(sector, off, 1));
}
}
return btoa(s);
}
/**
* save()
*
* The first array entry contains some disk information:
*
* [sDiskPath, dwChecksum, nCylinders, nHeads, nSectors, cbSector]
*
* Each subsequent entry in the returned array contains the following:
*
* [iCylinder, iHead, iSector, iModify, [...]]
*
* where [...] is an array of modified dword(s) in the corresponding sector.
*
* @this {Disk}
* @return {Array} of modified sectors
*/
save()
{
var i = 0;
var deltas = [];
deltas[i++] = [this.sDiskPath, this.dwChecksum, this.nCylinders, this.nHeads, this.nSectors, this.cbSector];
if (!this.fRemote && !this.fWriteProtected) {
var aDiskData = this.aDiskData;
for (var iCylinder = 0; iCylinder < aDiskData.length; iCylinder++) {
for (var iHead = 0; iHead < aDiskData[iCylinder].length; iHead++) {
for (var iSector = 0; iSector < aDiskData[iCylinder][iHead].length; iSector++) {
var sector = aDiskData[iCylinder][iHead][iSector];
if (sector && sector.cModify) {
var mods = [], n = 0;
var iModify = sector.iModify, iModifyLimit = sector.iModify + sector.cModify;
while (iModify < iModifyLimit) {
mods[n++] = sector['data'][iModify++];
}
deltas[i++] = [iCylinder, iHead, iSector, sector.iModify, mods];
}
}
}
}
}
if (DEBUG && this.messageEnabled()) {
this.printMessage('save("' + this.sDiskName + '"): saved ' + (deltas.length - 1) + ' change(s)');
}
return deltas;
}
/**
* restore(deltas)
*
* The first array entry contains some disk information:
*
* [sDiskPath, dwChecksum, nCylinders, nHeads, nSectors, cbSector]
*
* Each subsequent entry in the supplied array contains the following:
*
* [iCylinder, iHead, iSector, iModify, [...]]
*
* where [...] is an array of modified dword(s) in the corresponding sector.
*
* @this {Disk}
* @param {Array} deltas
* @return {number} 0 if no changes applied, -1 if an error occurred, otherwise the number of sectors modified
*/
restore(deltas)
{
/*
* If deltas is undefined, that's not necessarily an error; the controller may simply be (re)initializing
* itself (although neither controller should be calling restore() under those conditions anymore).
*/
var nChanges = 0;
var sReason = "unsupported restore format";
/*
* I originally added a check for aDiskData here on the assumption that if there was an error loading
* a disk image, we will have already notified the user, so any additional errors about differing checksums,
* failure to restore the disk state, etc, would just be annoying. HOWEVER, HDC will create an empty disk
* image if its initialization code discovers that no disk was loaded earlier (see verifyDrive). So while
* checking aDiskData is still a good idea, be aware that it won't necessarily avoid redundant error messages
* (at least in the case of HDC).
*/
if (deltas && deltas.length > 0) {
var i = 0;
var aDiskInfo = deltas[i++];
if (aDiskInfo && aDiskInfo.length >= 2) {
/*
* Before getting to the checksum, we have to deal with a new situation: restoring an uninitialized
* disk image from a complete set of deltas. And that is only possible if the disk was saved with the
* original disk geometry.
*/
if (!this.aDiskData.length && aDiskInfo.length >= 6) {
this.create(DiskAPI.MODE.LOCAL, aDiskInfo[2], aDiskInfo[3], aDiskInfo[4], aDiskInfo[5]);
/*
* TODO: Consider setting a flag here that we can check at the end of the restore() function
* that indicates we should recalculate dwChecksum, because we currently have an inconsistency
* between local disks that are mounted via build() and the same disks that are "remounted"
* later by this code; the former has the correct checksum, while the latter has a null checksum.
*
* As you can see below, we currently deal with this by simply ignoring null checksums....
*/
}
/*
* v1.01 failed to indicate an error if either one of these failure conditions occurred. Although maybe
* that's just as well, since v1.01 also failed to properly deal with situations where the user mounted
* different diskette(s) prior to exiting (hopefully fixed in v1.02).
*
* UPDATE: We also check aDiskInfo[0] first, because if it's null, then presumably there was no previous
* disk, and I'd like the addition of a disk to a machine to not be fatal to the restoration process.
*/
else if (aDiskInfo[0] != null) {
if (aDiskInfo[1] != null && this.dwChecksum != null && aDiskInfo[1] != this.dwChecksum) {
sReason = "original checksum (" + aDiskInfo[1] + ") differs from current checksum (" + this.dwChecksum + ")";
nChanges = -2;
}
/*
* Checksum is more important than disk path, and for now, I want the flexibility to move disk images.
*
* else if (aDiskInfo[0] != this.sDiskPath) {
* sReason = "original path '" + aDiskInfo[0] + "' differs from current path '" + this.sDiskPath + "'";
* nChanges = -1;
* }
*/
}
}
if (!this.aDiskData.length) nChanges = -1;
while (i < deltas.length && nChanges >= 0) {
var m = 0;
var mod = deltas[i++];
var iCylinder = mod[m++];
var iHead = mod[m++];
var iSector = mod[m++];
/*
* Note the buried test for write-protection. Yes, an invariant condition should be tested
* outside the loop, not inside, but (a) it's a trivial test, (b) the test should never fail
* because save() should never generate any mods for a write-protected disk, and (c) it
* centralizes all the failure conditions we're currently checking (which, admittedly, ain't much).
*/
if (iCylinder >= this.aDiskData.length || iHead >= this.aDiskData[iCylinder].length || iSector >= this.aDiskData[iCylinder][iHead].length) {
sReason = "sector (CHS=" + iCylinder + ':' + iHead + ':' + iSector + ") out of range (" + nChanges + " changes applied)";
nChanges = -1;
break;
}
if (this.fWriteProtected) {
sReason = "unable to modify write-protected disk";
nChanges = -1;
break;
}
var iModify = mod[m++];
var mods = mod[m++];
var iModifyLimit = iModify + mods.length;
var sector = this.aDiskData[iCylinder][iHead][iSector];
if (!sector) continue;
/*
* Since write() now deals with empty/partial sectors, we no longer need to completely "inflate"
* the sector prior to applying modifications. So let's just make sure that the sector is "inflated"
* up to iModify.
*/
var idw = sector['data'].length;
while (idw < iModify) {
sector['data'][idw++] = sector['pattern'];
}
var n = 0;
sector.iModify = iModify;
sector.cModify = mods.length;
while (iModify < iModifyLimit) {
sector['data'][iModify++] = mods[n++];
}
nChanges++;
}
}
if (nChanges < 0) {
/*
* We're suppressing checksum messages for the general public for now....
*/
if (DEBUG || nChanges != -2) {
this.notice("Unable to restore disk '" + this.sDiskName + ": " + sReason);
}
} else {
if (DEBUG && this.messageEnabled()) {
this.printMessage('restore("' + this.sDiskName + '"): restored ' + nChanges + ' change(s)');
}
/*
* Last but not least, rebuild the disk's file table if BACKTRACK or SYMBOLS support is enabled.
*/
if (BACKTRACK || SYMBOLS) this.buildFileTable();
}
return nChanges;
}
/**
* toJSON()
*
* We perform some RegExp massaging on the JSON data to eliminate unnecessary properties
* (eg, 'length' values of 512, 'pattern' values of 0, since those are defaults).
*
* In addition, we first check every sector to see if it can be "deflated". Sectors that were
* initially "deflated" should remain that way unless/until they were modified, so technically,
* we could call deflateSector() just for modified sectors, but this isn't a common operation,
* so it doesn't hurt to check every sector.
*
* @this {Disk}
* @return {string} containing the entire disk image as JSON-encoded data
*/
toJSON()
{
var s, pba = 0, sector, sectorLast;
while ((sector = this.getSector(pba++))) {
this.deflateSector(sector);
}
s = JSON.stringify(this.aDiskData, function(key, value) {
/*
* If BACKTRACK support is enabled, we have to filter out any 'file' properties that may
* be attached to the sector objects, lest we risk blowing the stack due to circular references.
*/
if (key == 'file') {
return undefined;
}
return value;
});
/*
* Eliminate unnecessary default properties (eg, 'length' values of 512, 'pattern' values of 0).
*/
s = s.replace(/,"length":512/gm, "").replace(/,"pattern":0/gm, "");
/*
* I don't really want to strip quotes from disk image property names, since I would have to put them
* back again during mount() -- or whenever JSON.parse() is used instead of eval(). But I still remove
* them temporarily, so that any remaining property names (eg, "iModify", "cModify", "fDirty") can
* easily be stripped out, by virtue of their being the only quoted properties left. We then "requote"
* all the property names that remain.
*/
s = s.replace(/"(sector|length|data|pattern)":/gm, "$1:");
/*
* The next line will remove any other numeric or boolean properties that were added at runtime, although
* they may have completely different ("minified") names if the code has been compiled.
*/
s = s.replace(/,"[^"]*":([0-9]+|true|false)/gm, "");
s = s.replace(/(sector|length|data|pattern):/gm, "\"$1\":");
/*
* Last but not least, insert line breaks after every object definition, to ease the pain on text editors.
*/
s = s.replace(/([\]}]),/gm, "$1,\n");
return s;
}
/**
* deflateSector(sector)
*
* This is just the first revision: it currently looks only at fully inflated sectors.
*
* @this {Disk}
* @param {Object} sector
*/
deflateSector(sector)
{
var adw = sector['data'];
var cdw = adw.length;
if ((cdw << 2) == sector['length']) {
var idw = cdw - 1;
var dwPattern = adw[idw], cDupes = 0;
while (idw--) {
if (adw[idw] !== dwPattern) break;
cDupes++;
}
if (cDupes++) {
adw.length = cdw - cDupes;
sector['pattern'] = dwPattern;
}
}
}
/**
* dumpSector(sector, pba, sDesc)
*
* @this {Disk}
* @param {Object} sector (returned from a previous seek)
* @param {number} [pba]
* @param {string} [sDesc]
* @return {string}
*/
dumpSector(sector, pba, sDesc)
{
var sDump = "";
if (DEBUG && sector) {
if (pba != null) sDump += "sector " + pba + (sDesc? (" for " + sDesc) : "") + ':';
var sBytes = "", sChars = "";
var cbSector = sector['length'];
var cdwData = sector['data'].length;
var dw = 0;
for (var i = 0; i < cbSector; i++) {
if ((i % 16) === 0) {
if (sDump) sDump += sBytes + ' ' + sChars + '\n';
sDump += Str.toHex(i, 4) + ": ";
sBytes = sChars = "";
}
if ((i % 4) === 0) {
var idw = i >> 2;
dw = (idw < cdwData? sector['data'][idw] : sector['pattern']);
}
var b = dw & 0xff;
dw >>>= 8;
sBytes += Str.toHex(b, 2) + (i % 16 == 7? "-" : " ");
sChars += (b >= 32 && b < 128? String.fromCharCode(b) : ".");
}
if (sBytes) sDump += sBytes + ' ' + sChars;
}
return sDump;
}
}
/**
* The default number of milliseconds to wait before writing a dirty sector back to a remote disk image
*
* @const {number}
*/
Disk.REMOTE_WRITE_DELAY = 2000; // 2-second delay
/*
* A global disk count, used to form unique Disk component IDs (totally optional; for debugging purposes only)
*/
Disk.nDisks = 0;
/**
* TODO: The Closure Compiler treats ES6 classes as 'struct' rather than 'dict' by default,
* which would force us to declare all class properties in the constructor, as well as prevent
* us from defining any named properties. So, for now, we mark all our classes as 'unrestricted'.
*
* @unrestricted
*/
class FileInfo {
/**
* FileInfo(disk, sPath, sName, bAttr, cbSize, apba)
*
* To the basic file information below, loadSymbols() may also add:
*
* sModule
* sDescription
* aSegments[]
*
* which is indexed by 1-based segment numbers, where each aSegments[] element is an object
* containing:
*
* offStart (file-relative offset of start of segment data)
* offEnd (file-relative offset of end of segment data)
* aEntries[]
*
* where aEntries is an array indexed by 1-based ordinals, where each aEntries[] element contains:
*
* [offset, symbol]
*
* where offset is relative to the segment's offStart value, and symbol is a string describing the
* entry.
*
* NOTE: Although aEntries arrays are similar to the Debugger's aOffsets arrays, they are not
* interchangeable data structures, because ours is ordered by ordinal, whereas aOffsets is
* ordered by offset. We provide an interface, getModuleInfo(), to the Debugger that converts
* our data into an intermediate array, aSymbols, which the Debugger then uses to build aOffsets.
* It would be nice to avoid building that intermediate representation, but it's a side-effect of
* the Debugger's earlier support for JSON-encoded MAP files.
*
* There will always be an offset at index 0 of an aEntries[] element, but some error or incomplete
* symbolic information could result in a missing symbol at index 1, because symbol name processing is
* separate from entry table processing.
*
* @param {Disk} disk
* @param {string} sPath
* @param {string} sName
* @param {number} bAttr
* @param {number} cbSize
* @param {Array.} apba
*/
constructor(disk, sPath, sName, bAttr, cbSize, apba)
{
this.disk = disk;
this.sPath = sPath;
this.sName = sName;
this.bAttr = bAttr;
this.cbSize = cbSize;
this.apba = apba;
}
/**
* loadValue(offset, length)
*
* @this {FileInfo}
* @param {number} offset
* @param {number} [length] (1, 2 or 4 bytes; default is 2)
* @return {number|undefined}
*/
loadValue(offset, length)
{
var l;
length = length || 2;
var iSector = offset >> 9;
var offSector = offset & 0x1ff;
var sector = this.disk.getSector(this.apba[iSector]);
if (sector) {
/*
* If the read is wholly contained within a sector, read it with one call.
*/
if (offSector + length <= sector['length']) {
return this.disk.getSectorData(sector, offSector, length);
}
/*
* The spans a sector boundary, so we just call ourselves one byte at a time.
*/
l = 0;
var shift = 0;
while (length--) {
l |= this.loadValue(offset++, 1) << shift;
shift += 8;
}
}
return l;
}
/**
* loadString(offset, length)
*
* @this {FileInfo}
* @param {number} offset
* @param {number} [length] (if omitted, then string must be zero-terminated)
* @return {string}
*/
loadString(offset, length)
{
var s = "";
if (!length) length = -1;
while (length--) {
var b = this.loadValue(offset++, 1);
if (!b) break;
s += String.fromCharCode(b);
}
return s;
}
/**
* loadField(aField, offset)
*
* @this {FileInfo}
* @param {Array.} aField
* @param {number} [offset] (0 if not specified)
* @return {number|undefined}
*/
loadField(aField, offset)
{
return this.loadValue(aField[0] + (offset || 0), aField[1]);
}
/**
* loadSegmentTable(offEntries, nEntries, nSegOffShift)
*
* @this {FileInfo}
* @param {number} offEntries
* @param {number} nEntries
* @param {number} nSegOffShift
*/
loadSegmentTable(offEntries, nEntries, nSegOffShift)
{
/*
* Read the Segment Table entries now.
*/
var iSegment = 1;
this.aSegments = [];
this.aOrdinals = []; // this is an optional array for quick ordinal-to-segment lookup
if (DEBUG && this.disk.messageEnabled(Messages.DISK | Messages.DATA)) {
this.disk.printMessage("loadSegmentTable(" + this.sPath + "," + Str.toHexLong(offEntries) + "," + Str.toHexWord(nEntries) + ")");
}
while (nEntries--) {
var offSegment = this.loadValue(offEntries) << nSegOffShift;
if (offSegment) {
var lenSegment = this.loadValue(offEntries + 2) || 0x10000; // 0 means 64K
if (DEBUG && this.disk.messageEnabled(Messages.DISK | Messages.DATA)) {
this.disk.printMessage("segment " + iSegment + ": offStart=" + Str.toHexLong(offSegment) + " offEnd=" + Str.toHexLong(offSegment + lenSegment));
}
this.aSegments[iSegment++] = {offStart: offSegment, offEnd: offSegment + lenSegment - 1, aEntries: []};
}
offEntries += 8;
}
/*
* Although not documented (at least not in any of the early Windows "New Executable" documents I've seen),
* the Entry Table may also contain entries whose bSegment field is 0xFE, which doesn't correspond to a valid
* segment number. That pseudo-segment number appears to be reserved for constants. Here are some examples
* from a 3.1-vintage KRNL386.EXE:
*
* cannot find segment 254 (offset 0xF000) for symbol __ROMBIOS with ordinal 173
* cannot find segment 254 (offset 0x0000) for symbol __0000H with ordinal 183
* cannot find segment 254 (offset 0x0040) for symbol __0040H with ordinal 193
* cannot find segment 254 (offset 0x0008) for symbol __AHINCR with ordinal 114
* cannot find segment 254 (offset 0x0003) for symbol __AHSHIFT with ordinal 113
* cannot find segment 254 (offset 0xA000) for symbol __A000H with ordinal 174
* cannot find segment 254 (offset 0xB000) for symbol __B000H with ordinal 181
* cannot find segment 254 (offset 0xC000) for symbol __C000H with ordinal 195
* cannot find segment 254 (offset 0xB800) for symbol __B800H with ordinal 182
* cannot find segment 254 (offset 0xD000) for symbol __D000H with ordinal 179
* cannot find segment 254 (offset 0xE000) for symbol __E000H with ordinal 190
* cannot find segment 254 (offset 0xF000) for symbol __F000H with ordinal 194
* cannot find segment 254 (offset 0x0001) for symbol __WINFLAGS with ordinal 178
*
* The simplest way to handle those Entry Table entries is creating an additional (fake) aSegments table entry.
*/
this.aSegments[0xFE] = {offStart: 0, offEnd: 0, aEntries: []};
}
/**
* loadEntryTable(offEntries, offEntriesEnd)
*
* @this {FileInfo}
* @param {number} offEntries
* @param {number} offEntriesEnd
*/
loadEntryTable(offEntries, offEntriesEnd)
{
var iOrdinal = 1;
if (DEBUG && this.disk.messageEnabled(Messages.DISK | Messages.DATA)) {
this.disk.printMessage("loadEntryTable(" + Str.toHexLong(offEntries) + "," + Str.toHexLong(offEntriesEnd) + ")");
}
while (offEntries < offEntriesEnd) {
var w = this.loadValue(offEntries);
var bEntries = w & 0xff;
if (!bEntries) break;
var bSegment = w >> 8, iSegment;
if (DEBUG && this.disk.messageEnabled(Messages.DISK | Messages.DATA)) {
this.disk.printMessage("bundle for segment " + bSegment + ": " + bEntries + " entries @" + Str.toHex(offEntries));
}
offEntries += 2;
/*
* bSegment 0x00 means all the entries spanned by bEntries are unused, so move on.
*/
if (!bSegment) {
iOrdinal += bEntries;
continue;
}
while (bEntries--) {
/*
* bSegment 0x01-0xFE means the next 3 bytes describe a fixed segment entry; the next
* byte contains flags indicating exported (0x1) and/or global/shared (0x2) data, and the
* next word is the offset within the segment.
*/
var offEntry;
var offDebug = offEntries;
var bFlags = this.loadValue(offEntries, 1);
if (bSegment <= 0xFE) {
iSegment = bSegment;
offEntry = this.loadValue(offEntries + 1);
offEntries += 3;
} else {
/*
* bSegment 0xFF means a movable segment entry, which is 6 bytes long: flags byte (which
* we've already read), an INT 0x3F (0xCD,0x3F), a 1-byte segment number, and a 2-byte offset.
*/
iSegment = this.loadValue(offEntries + 3, 1);
offEntry = this.loadValue(offEntries + 4);
offEntries += 6;
}
if (!this.aSegments[iSegment]) {
if (DEBUG && this.disk.messageEnabled(Messages.DISK | Messages.DATA)) {
this.disk.printMessage("invalid segment: " + iSegment);
}
} else {
this.aSegments[iSegment].aEntries[iOrdinal] = [offEntry];
if (DEBUG && this.disk.messageEnabled(Messages.DISK | Messages.DATA)) {
this.disk.printMessage("ordinal " + iOrdinal + ": segment=" + iSegment + " offset=" + Str.toHexLong(offEntry) + " @" + Str.toHex(offDebug));
}
}
this.aOrdinals[iOrdinal] = [iSegment, offEntry];
iOrdinal++;
}
}
}
/**
* loadNameTable(aField, offset)
*
* NOTE: If offset is omitted, we assume we're reading the Resident Name Table, and therefore
* the first name is the module name; otherwise, we assume it is the Non-Resident Name Table, and
* that the first name is the module description.
*
* @this {FileInfo}
* @param {number} offEntries
* @param {number} [offEntriesEnd] (if omitted, then the table must be null-terminated)
*/
loadNameTable(offEntries, offEntriesEnd)
{
var cNames = 0;
if (DEBUG && this.disk.messageEnabled(Messages.DISK | Messages.DATA)) {
this.disk.printMessage("loadNameTable(" + Str.toHexLong(offEntries) + (offEntriesEnd? ("," + Str.toHexLong(offEntriesEnd)) : "") + ")");
}
while (!offEntriesEnd || offEntries < offEntriesEnd) {
var offDebug = offEntries;
var bLength = this.loadValue(offEntries, 1);
if (!bLength) break;
var sSymbol = this.loadString(offEntries + 1, bLength);
if (!sSymbol) break; // an error must have occurred (this is not a natural way to end)
offEntries += 1 + bLength;
if (!cNames) {
if (!offEntriesEnd) {
this.sModule = sSymbol;
} else {
this.sDescription = sSymbol;
}
}
else {
var iOrdinal = this.loadValue(offEntries);
var tuple = this.aOrdinals[iOrdinal];
if (tuple) {
var iSegment = tuple[0]; // tuple[0] is the segment number and tuple[1] is the corresponding offEntry
if (this.aSegments[iSegment]) {
var aEntries = this.aSegments[iSegment].aEntries[iOrdinal];
this.disk.assert(aEntries && aEntries.length == 1);
aEntries.push(sSymbol);
if (DEBUG && this.disk.messageEnabled(Messages.DISK | Messages.DATA)) {
this.disk.printMessage("segment " + iSegment + " offset " + Str.toHexWord(aEntries[0]) + " ordinal " + iOrdinal + ": " + sSymbol + " @" + Str.toHex(offDebug));
}
} else {
if (DEBUG && this.disk.messageEnabled(Messages.DISK | Messages.DATA)) {
this.disk.printMessage(this.sPath + ": cannot find segment " + iSegment + " (offset " + Str.toHexWord(tuple[1]) + ") for symbol " + sSymbol + " with ordinal " + iOrdinal + " @" + Str.toHex(offDebug));
}
}
} else {
if (DEBUG && this.disk.messageEnabled(Messages.DISK | Messages.DATA)) {
this.disk.printMessage(this.sPath + ": cannot find ordinal " + iOrdinal + " for symbol " + sSymbol + " @" + Str.toHex(offDebug));
}
}
}
offEntries += 2;
cNames++;
}
}
/**
* loadSymbols()
*
* For files with NE headers, extract all available symbolic information from the file.
*
* @this {FileInfo}
*/
loadSymbols()
{
if (!Str.endsWith(this.sName, ".EXE") && !Str.endsWith(this.sName, ".DLL") && !Str.endsWith(this.sName, ".DRV")) {
return;
}
if (this.loadField(FileInfo.OE.oeSignature) != FileInfo.OE.SIG) {
return;
}
if (this.loadField(FileInfo.OE.oeRelocOffset) != FileInfo.OE.NE_SIG) {
return;
}
var offNEHeader = this.loadField(FileInfo.OE.oeNEHeader);
if (this.loadField(FileInfo.NE.neSignature, offNEHeader) != FileInfo.NE.SIG) {
return;
}
var nEntries = this.loadField(FileInfo.NE.neSTEntries, offNEHeader);
var offEntries = this.loadField(FileInfo.NE.neSTOffset, offNEHeader);
var nSegOffShift = this.loadField(FileInfo.NE.neSegOffShift, offNEHeader);
if (offEntries && nEntries) {
this.loadSegmentTable(offEntries + offNEHeader, nEntries, nSegOffShift || 0);
}
offEntries = this.loadField(FileInfo.NE.neETOffset, offNEHeader);
var cbEntries = this.loadField(FileInfo.NE.neETSize, offNEHeader);
if (offEntries && cbEntries) {
this.loadEntryTable(offEntries += offNEHeader, offEntries + cbEntries);
}
/*
* Time to walk the Resident Name Table and update the corresponding ordinals.
*/
offEntries = this.loadField(FileInfo.NE.neRNTOffset, offNEHeader);
if (offEntries) {
this.loadNameTable(offEntries + offNEHeader);
}
/*
* Ditto for the Non-Resident Name Table, which for some reason, uses a file-relative offset rather than
* an NE header-relative offset, and which is both sized AND null-terminated; we check both terminating
* conditions to be safe.
*/
offEntries = this.loadField(FileInfo.NE.neNRNTOffset, offNEHeader);
cbEntries = this.loadField(FileInfo.NE.neNRNTSize, offNEHeader);
if (offEntries && cbEntries) {
this.loadNameTable(offEntries, offEntries + cbEntries);
}
}
/**
* getSymbol(off, fNearest)
*
* @this {FileInfo}
* @param {number} off (offset relative to start of file)
* @param {boolean} [fNearest] (true to return nearest symbol if a segment with symbols is found)
* @return {string} symbol corresponding to file offset (of the file name + offset if no symbol found)
*/
getSymbol(off, fNearest)
{
var sSymbol = null;
if (this.aSegments) {
for (var iSegment in this.aSegments) {
var segment = this.aSegments[iSegment];
if (off >= segment.offStart && off <= segment.offEnd) {
/*
* This is the one and only segment we need to check, so we can make off segment-relative now.
*/
off -= segment.offStart;
/*
* To support fNearest, save the entry where (off - entry[0]) yields the smallest positive result.
*/
var cbNearest = off, entryNearest;
for (var iOrdinal in segment.aEntries) {
var entry = segment.aEntries[iOrdinal];
var cb = off - entry[0];
if (!cb) {
sSymbol = this.sModule + '!' + entry[1];
break;
}
if (fNearest && cb > 0 && cb < cbNearest) {
entryNearest = entry;
cbNearest = cb;
}
}
if (!sSymbol && entryNearest) {
sSymbol = this.sModule + '!' + entryNearest[1] + "+" + Str.toHex(cbNearest, 0, true);
}
break;
}
}
}
return sSymbol || this.sName + '+' + Str.toHex(off, 0, true);
}
}
/*
* Original (aka "Old") Executable MS-DOS File Format
*
* Relocation entries are pairs of 16-bit words:
*
* wOffset
* wSegment
*
* I've noticed that a "PKLITE" EXE may have a oeRelocOffset of 0x52, where the word at 0x001C is 0x210F and the
* bytes from 0x001E through 0x0051 are:
*
* "PKLITE Copr. 1990-92 PKWARE Inc. All Rights Reserved"
*
* Other EXEs have a oeRelocOffset of 0x1E, which begs the question: what is the word at 0x001C typically used for?
*
* It was not uncommon for there to be wasted space in the header; even an EXE with, say, 20 (0x14) entries would
* likely have a wHeaderParas value of 0x20, which is 512 (0x200) bytes. The desire, no doubt, was to align the
* start of the EXE segment(s) to a traditional sector boundary.
*/
FileInfo.OE = {
SIG: 0x5A4D,
oeSignature: [0x0000, 2], // "MZ" (0x4D,0x5A)
oeLastBytes: [0x0002, 2], // 0-511 (0 means the entire last block is used)
oeBlocks: [0x0004, 2], // number of blocks in the file
oeRelocEntries: [0x0006, 2], // number of relocation entries in the header
oeHeaderParas: [0x0008, 2], // number of (16-byte) paragraphs in the header
oeExtraParas: [0x000A, 2], // minimum number of additional paragraphs required at load-time
oeMaxParas: [0x000C, 2], // maximum number of additional paragraphs required at load-time
oeSSRel: [0x000E, 2], // relative value of SS
oeSPInit: [0x0010, 2], // initial value of SP
oeChecksum: [0x0012, 2], // checksum if non-zero (sum of all words, including this, should be zero)
oeIPInit: [0x0014, 2], // initial value of IP
oeCSRel: [0x0016, 2], // relative value of CS
oeRelocOffset: [0x0018, 2], // offset of first relocation item
oeOverlay: [0x001A, 2], // overlay number (normally zero, implying main program)
/*
* The following fields are accommodated by the NE format, but they were actually defined by "the DOS 4.0 group"
* as extensions to the OE format.
*/
oeDOS40Bits: [0x0020, 2], // DOS 4.0 behavior bits
oeUnusedBits: [0x0022, 2], // unused behavior bits
/*
* If oeRelocOffset (0x0018) is 0x40, then the file is considered an NE (New Executable) MS-DOS file, and
* the offset of the NE header (from the start of the file) is a 32-bit value stored at 0x003C. Note that early
* versions of Windows (aka "DOS 2.0 Windows") originally defined the NE header offset as a 16-bit value stored
* at 0x003E. And before that, it may have been a 16-bit value stored at 0x0024, which would have been immediately
* after the "behavior bits" fields shown above).
*/
oeNEHeader: [0x003C, 4], // offset from start of file to NE header
NE_SIG: 0x40
};
/*
* New Executable MS-DOS File Format
*
* Unless otherwise specified, all *Offset fields are relative to the start of the NE header, and all *Size fields
* are in bytes.
*/
FileInfo.NE = {
SIG: 0x454E,
neSignature: [0x0000, 2], // "NE" (0x4E,0x45)
neLinkerVer: [0x0002, 2], // (low byte is version, high byte is revision)
neETOffset: [0x0004, 2], // Entry Table offset
neETSize: [0x0006, 2], // Entry Table size
neChecksum: [0x0008, 4], // checksum (sum of all DWORDs in the file, excluding this one)
neFlags: [0x000C, 2],
neDataSeg: [0x000E, 2],
neHeapSize: [0x0010, 2],
neStackSize: [0x0012, 2],
neCSIP: [0x0014, 4],
neSSSP: [0x0018, 4],
neSTEntries: [0x001C, 2], // Segment Table entries
neMRTEntries: [0x001E, 2], // Module Reference Table entries
neNRNTSize: [0x0020, 2], // Non-Resident Name Table size
neSTOffset: [0x0022, 2], // Segment Table offset
neRTOffset: [0x0024, 2], // Resource Table offset
neRNTOffset: [0x0026, 2], // Resident Name Table offset
neMRTOffset: [0x0028, 2], // Module Reference Table offset
neINTOffset: [0x002A, 2], // Imported Names Table offset
neNRNTOffset: [0x002C, 4], // Non-Resident Name Table offset (relative to start of file)
neETMovable: [0x0030, 2], // number of movable entries in the Entry Table
neSegOffShift: [0x0032, 2], // logical sector alignment shift count, log(base 2) of the segment sector size (default 9)
/*
* Fields after this point are post "DOS 2.0 Windows"...
*/
neRTEntries: [0x0034, 2], // Resource Table entries
neEXEType: [0x0036, 1] // executable type (0x02 for Windows)
/*
* 0x37 through 0x3F is reserved.
*/
};
/**
* Every Sector object (once loaded, parsed, and "normalized") should have ALL of the following named properties:
*
* 'sector': sector number
* 'length': size of the sector, in bytes
* 'data': array of dwords
* 'pattern': dword pattern to use for empty or partial sectors (or null if sector still needs to be loaded)
*
* initSector() also sets the following properties, to help us quickly identify its location within aDiskData:
*
* iCylinder
* iHead
*
* In addition, we will maintain the following information on a per-sector basis, as sectors are modified:
*
* iModify: index of first modified dword in sector
* cModify: number of modified dwords in sector
* fDirty: true if sector is dirty, false if clean (or cleaning in progress)
*
* fDirty is used in conjunction with "demandrw" disks; it is set to true whenever the sector is modified, and is
* set to false whenever the sector has been sent to the server. If the server write succeeds and fDirty is still
* false, then the sector modifications are removed (cModify is set to zero). If the write succeeds but fDirty was
* set to true again in the meantime, then all the sector modifications (even those that were just written) remain
* in place (since we don't keep track of more than one modification range within a sector). And if the write failed,
* then fDirty is set back to true and again all modifications remain in place; the best we can do is schedule another
* write attempt.
*
* TODO: Perhaps we should also maintain a failure count and stop trying to write sectors that reach a certain
* threshold. Error-handling, as usual, is the thorniest problem.
*
* @typedef {{
* sector: number,
* length: number,
* data: Array.,
* pattern: (number|null),
* iCylinder: number,
* iHead: number,
* iModify: number,
* cModify: number,
* file: FileInfo,
* offFile: number
* }}
*/
var SectorInfo;
if (NODE) module.exports = Disk;