/** * @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); } } 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); } } } 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.toHexWord(cbNearest); } break; } } } return sSymbol || this.sName + '+' + Str.toHexLong(off); } } /* * 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;