2783 lines
115 KiB
JavaScript
2783 lines
115 KiB
JavaScript
/**
|
|
* @fileoverview Converts disk images to/from JSON
|
|
* @author <a href="mailto:Jeff@pcjs.org">Jeff Parsons</a> (@jeffpar)
|
|
* @version 1.0
|
|
* Created 2014-02-01
|
|
*
|
|
* Copyright © 2012-2015 Jeff Parsons <Jeff@pcjs.org>
|
|
*
|
|
* This file is part of the JavaScript Machines Project (aka JSMachines) at <http://jsmachines.net/>
|
|
* and <http://pcjs.org/>.
|
|
*
|
|
* JSMachines 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.
|
|
*
|
|
* JSMachines 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 JSMachines.
|
|
* If not, see <http://www.gnu.org/licenses/gpl.html>.
|
|
*
|
|
* You are required to include the above copyright notice in every source code file of every
|
|
* copy or modified version of this work, and to display that copyright notice on every screen
|
|
* that loads or runs any version of this software (see Computer.sCopyright).
|
|
*
|
|
* Some JSMachines 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 the
|
|
* JSMachines Project for purposes of the GNU General Public License, and the author does not claim
|
|
* any copyright as to their contents.
|
|
*/
|
|
|
|
/*
|
|
* See http://en.wikipedia.org/wiki/Design_of_the_FAT_file_system for more information.
|
|
*/
|
|
|
|
"use strict";
|
|
|
|
var fs = require("fs");
|
|
var path = require("path");
|
|
var http = require("http");
|
|
var mkdirp = require("mkdirp");
|
|
var crypto = require("crypto");
|
|
var defines = require("../../shared/lib/defines");
|
|
var net = require("../../shared/lib/netlib");
|
|
var proc = require("../../shared/lib/proclib");
|
|
var str = require("../../shared/lib/strlib");
|
|
var usr = require("../../shared/lib/usrlib");
|
|
var DiskAPI = require("../../shared/lib/diskapi");
|
|
var DumpAPI = require("../../shared/lib/dumpapi");
|
|
var X86 = require("../../pcjs/lib/x86");
|
|
|
|
/**
|
|
* @class exports
|
|
* @property {string} name
|
|
* @property {string} version
|
|
*/
|
|
var pkg = require("../../../package.json");
|
|
|
|
/*
|
|
* fConsole controls console messages; it is false by default but is enable by the CLI interface.
|
|
*/
|
|
var fConsole = false;
|
|
|
|
/*
|
|
* fDebug controls debug console messages; it is false by default but can be enabled from the command-line
|
|
* using "--debug".
|
|
*/
|
|
var fDebug = false;
|
|
|
|
/*
|
|
* logFile is passed from the web server through HTMLOut to us, allowing us to "mingle" our logConsole()
|
|
* output with the server's log (typically "./logs/node.log").
|
|
*/
|
|
var logFile = null;
|
|
|
|
/*
|
|
* fNormalize attempts to enforce consistency across multiple dump requests, including the order of files within every
|
|
* directory, the use of hard-coded volume label timestamps, etc. And since I assume that normalization is a wonderful
|
|
* thing, I don't provide any UI for turning it off.
|
|
*/
|
|
var fNormalize = true;
|
|
|
|
/**
|
|
* DiskDump()
|
|
*
|
|
* TODO: Honor the caller's mbHD size. At the moment, any hard disk build request translates to 10Mb,
|
|
* since we rely on a "canned" BPB in aDefaultBPBs.
|
|
*
|
|
* TODO: If sServerRoot is set, make sure the final sDiskPath refers to something in either /apps/ or /disks/,
|
|
* to prevent random enumeration of other server resources.
|
|
*
|
|
* @constructor
|
|
* @param {string|Array} sDiskPath
|
|
* @param {Array|null} [asExclude] contains filename exclusions, if any
|
|
* @param {string} [sFormat] is the output format, one of "json"|"data"|"hex"|"bytes"|"img"
|
|
* @param {boolean|string} [fComments] enables comments and other readability enhancements in the JSON output
|
|
* @param {string} [mbHD] specifies a hard disk size, in megabytes, when building a new image
|
|
* @param {string|null} [sServerRoot]
|
|
* @param {string} [sManifestFile]
|
|
* @param {Object} [argv] optional (experimental) arguments, if any
|
|
*/
|
|
function DiskDump(sDiskPath, asExclude, sFormat, fComments, mbHD, sServerRoot, sManifestFile, argv)
|
|
{
|
|
/*
|
|
* I used to set this.sServerRoot to "sServerRoot || process.cwd()", but in reality, the
|
|
* server (httpapi.js) always passes the web server's root directory; when called from the
|
|
* command-line, sServerRoot is a bit of a misnomer: it's basically blank if sDiskPath begins
|
|
* with a slash, and process.cwd() otherwise.
|
|
*/
|
|
this.sServerRoot = sServerRoot;
|
|
this.sDiskPath = (net.isRemote(sDiskPath)? sDiskPath : path.join(this.sServerRoot, sDiskPath));
|
|
this.asExclude = asExclude || DiskDump.asExclusions;
|
|
this.mbHD = mbHD? parseInt(mbHD, 10) : 0;
|
|
this.sFormat = (sFormat || DumpAPI.FORMAT.JSON);
|
|
this.fJSONNative = (this.sFormat == DumpAPI.FORMAT.JSON && !fComments);
|
|
this.nJSONIndent = 0;
|
|
this.fJSONComments = fComments;
|
|
this.sJSONWhitespace = (this.fJSONComments? " " : "");
|
|
this.fXDFSupport = (argv && argv['xdf']);
|
|
|
|
/*
|
|
* The dump operation itself doesn't care about sManifestFile, but we DO need some indication
|
|
* of whether MD5 checksums need to be computed for the individual files, so we use the filename
|
|
* as that indication.
|
|
*/
|
|
this.sManifestFile = sManifestFile;
|
|
|
|
/*
|
|
* If we have to enumerate one or more files during the buildImage() process, this array
|
|
* will save them, in case the caller wants to query that information later, in updateManifest().
|
|
*
|
|
* Originally, I thought each saved entry would be a subset of what the fileInfo objects contain,
|
|
* but it turns out I pretty much need everything. This, in turn, means that some of the original
|
|
* buildImage() functions could simply use this.aManifestInfo, instead of their own aFiles array,
|
|
* but sometimes they're using aFiles of subdirectories, so it's not quite that simple.
|
|
*/
|
|
this.aManifestInfo = [];
|
|
|
|
/*
|
|
* bufDisk is set by buildImage() (or by loadFile() if the file is NOT a ".json" file; otherwise
|
|
* loadFile() loads the file as string data and stores it in jsonDisk).
|
|
*
|
|
* In those cases where bufDisk is set, the caller must call convertToJSON() to obtain JSON, which
|
|
* will simply return jsonDisk if it was already set by loadFile() OR if it was already created by
|
|
* a previous convertToJSON() call.
|
|
*
|
|
* In those cases where jsonDisk is set, the caller must call convertToIMG() to obtain an IMG file.
|
|
* Since that function relies on dataDisk, it first calls JSON.parse() to convert jsonDisk to dataDisk,
|
|
* and then it builds bufDisk from dataDisk; if a previous call already created dataDisk and/or bufDisk,
|
|
* the previous values are used/returned.
|
|
*
|
|
* dataDisk is a native data object built by convertToJSON() and convertToIMG() as needed. In the
|
|
* first case, it's used to create JSON using JSON.stringify(), but only if fJSONNative is set (ie,
|
|
* the caller explicitly specifies FORMAT_JSON); that probably should be the default setting, but it
|
|
* wasn't an option in the original PHP code, so I added it as an option here in order to compare the
|
|
* output of both methods. fJSONNative still isn't an option for converting OSI disk images to JSON
|
|
* (and it may never be, as those images aren't very common).
|
|
*/
|
|
this.bufDisk = null;
|
|
this.jsonDisk = "";
|
|
this.dataDisk = undefined;
|
|
}
|
|
|
|
/**
|
|
* setLogFile(file)
|
|
*
|
|
* @param {Object} file
|
|
*/
|
|
DiskDump.setLogFile = function(file) {
|
|
logFile = file;
|
|
};
|
|
|
|
/*
|
|
* Class constants
|
|
*/
|
|
DiskDump.sAPIURL = "http://www.pcjs.org" + DumpAPI.ENDPOINT;
|
|
DiskDump.sCopyright = "© 2012-2015 by Jeff Parsons (@jeffpar)";
|
|
DiskDump.sNotice = DiskDump.sAPIURL + " " + DiskDump.sCopyright;
|
|
DiskDump.sUsage = "Usage: " + DiskDump.sAPIURL + "?" + DumpAPI.QUERY.PATH + "={url}&" + DumpAPI.QUERY.FORMAT + "=json|data|hex|bytes|img";
|
|
|
|
/*
|
|
* MY_VOL_LABEL is our default label, used whenever a more suitable label (eg, the disk image's folder name)
|
|
* is not available or not supplied, and MY_OEM_STRING is inserted into any DiskDump-generated diskette images.
|
|
*/
|
|
DiskDump.MY_VOL_LABEL = "PCJSDISK";
|
|
DiskDump.MY_OEM_STRING = "PCJS.ORG";
|
|
|
|
/**
|
|
* The BPBs that buildImage() currently supports; these BPBs should be in order of smallest to largest capacity,
|
|
* to help ensure we don't select a disk format larger than necessary.
|
|
*
|
|
* TODO: For now, the code that chooses a default BPB is starting with #1 instead of #0, because Windows 95 (at least
|
|
* when running under VMware) fails to read the contents of such disks correctly. Whether that's my fault or Windows 95's
|
|
* fault is still TBD (although it's probably mine -- perhaps 160Kb diskettes aren't supposed to have BPBs?) The simple
|
|
* work-around is to avoid creating 160Kb diskette images.
|
|
*/
|
|
DiskDump.aDefaultBPBs = [
|
|
[ // define BPB for 160Kb diskette
|
|
0xEB, 0xFE, 0x90, // 0x00: JMP instruction, following by 8-byte OEM signature
|
|
0x50, 0x43, 0x4A, 0x53, 0x2E, 0x4F, 0x52, 0x47, // MY_OEM_STRING
|
|
// 0x49, 0x42, 0x4D, 0x20, 0x20, 0x31, 0x2E, 0x30, // "IBM 1.0" (this is a fake OEM signature)
|
|
0x00, 0x02, // 0x0B: bytes per sector (0x200 or 512)
|
|
0x01, // 0x0D: sectors per cluster (1)
|
|
0x01, 0x00, // 0x0E: reserved sectors; ie, # sectors preceding the first FAT--usually just the boot sector (1)
|
|
0x02, // 0x10: FAT copies (2)
|
|
0x40, 0x00, // 0x11: root directory entries (0x40 or 64) 0x40 * 0x20 = 0x800 (1 sector is 0x200 bytes, total of 4 sectors)
|
|
0x40, 0x01, // 0x13: number of sectors (0x140 or 320)
|
|
0xFE, // 0x15: media type (eg, 0xFF: 320Kb, 0xFE: 160Kb, 0xFD: 360Kb, 0xFC: 180Kb)
|
|
0x01, 0x00, // 0x16: sectors per FAT (1)
|
|
0x08, 0x00, // 0x18: sectors per track (8)
|
|
0x01, 0x00, // 0x1A: number of heads (2)
|
|
0x00, 0x00, 0x00, 0x00 // 0x1C: number of hidden sectors (always 0 for non-partitioned media)
|
|
],
|
|
[ // define BPB for 360Kb diskette
|
|
0xEB, 0xFE, 0x90, // 0x00: JMP instruction, following by 8-byte OEM signature
|
|
0x50, 0x43, 0x4A, 0x53, 0x2E, 0x4F, 0x52, 0x47, // MY_OEM_STRING
|
|
// 0x49, 0x42, 0x4D, 0x20, 0x20, 0x32, 0x2E, 0x30, // "IBM 2.0" (this is a real OEM signature)
|
|
0x00, 0x02, // 0x0B: bytes per sector (0x200 or 512)
|
|
0x02, // 0x0D: sectors per cluster (2)
|
|
0x01, 0x00, // 0x0E: reserved sectors; ie, # sectors preceding the first FAT--usually just the boot sector (1)
|
|
0x02, // 0x10: FAT copies (2)
|
|
0x70, 0x00, // 0x11: root directory entries (0x70 or 112) 0x70 * 0x20 = 0xE00 (1 sector is 0x200 bytes, total of 7 sectors)
|
|
0xD0, 0x02, // 0x13: number of sectors (0x2D0 or 720)
|
|
0xFD, // 0x15: media type (eg, 0xFF: 320Kb, 0xFE: 160Kb, 0xFD: 360Kb, 0xFC: 180Kb)
|
|
0x02, 0x00, // 0x16: sectors per FAT (2)
|
|
0x09, 0x00, // 0x18: sectors per track (9)
|
|
0x02, 0x00, // 0x1A: number of heads (2)
|
|
0x00, 0x00, 0x00, 0x00 // 0x1C: number of hidden sectors (always 0 for non-partitioned media)
|
|
],
|
|
[ // define BPB for 1.2Mb diskette
|
|
0xEB, 0xFE, 0x90, // 0x00: JMP instruction, following by 8-byte OEM signature
|
|
0x50, 0x43, 0x4A, 0x53, 0x2E, 0x4F, 0x52, 0x47, // MY_OEM_STRING
|
|
// 0x49, 0x42, 0x4D, 0x20, 0x31, 0x30, 0x2E, 0x31, // "10.0" (which I believe was used on IBM OS/2 1.0 diskettes)
|
|
0x00, 0x02, // 0x0B: bytes per sector (0x200 or 512)
|
|
0x01, // 0x0D: sectors per cluster (1)
|
|
0x01, 0x00, // 0x0E: reserved sectors; ie, # sectors preceding the first FAT--usually just the boot sector (1)
|
|
0x02, // 0x10: FAT copies (2)
|
|
0xE0, 0x00, // 0x11: root directory entries (0xe0 or 224) 0xe0 * 0x20 = 0x1c00 (1 sector is 0x200 bytes, total of 14 sectors)
|
|
0x60, 0x09, // 0x13: number of sectors (0x960 or 2400)
|
|
0xF9, // 0x15: media type (0xF9 was used for 1228800-byte diskettes, and later for 737280-byte diskettes)
|
|
0x07, 0x00, // 0x16: sectors per FAT (7)
|
|
0x0f, 0x00, // 0x18: sectors per track (15)
|
|
0x02, 0x00, // 0x1A: number of heads (2)
|
|
0x00, 0x00, 0x00, 0x00 // 0x1C: number of hidden sectors (always 0 for non-partitioned media)
|
|
],
|
|
[ // define BPB for 1.44Mb diskette
|
|
0xEB, 0xFE, 0x90, // 0x00: JMP instruction, following by 8-byte OEM signature
|
|
0x50, 0x43, 0x4A, 0x53, 0x2E, 0x4F, 0x52, 0x47, // MY_OEM_STRING
|
|
// 0x4d, 0x53, 0x44, 0x4F, 0x53, 0x35, 0x2E, 0x30, // "MSDOS5.0" (an actual OEM signature, arbitrarily chosen for use here)
|
|
0x00, 0x02, // 0x0B: bytes per sector (0x200 or 512)
|
|
0x01, // 0x0D: sectors per cluster (1)
|
|
0x01, 0x00, // 0x0E: reserved sectors; ie, # sectors preceding the first FAT--usually just the boot sector (1)
|
|
0x02, // 0x10: FAT copies (2)
|
|
0xE0, 0x00, // 0x11: root directory entries (0xe0 or 224) 0xe0 * 0x20 = 0x1c00 (1 sector is 0x200 bytes, total of 14 sectors)
|
|
0x40, 0x0B, // 0x13: number of sectors (0xb40 or 2880)
|
|
0xF0, // 0x15: media type (0xF0 was used for 1474560-byte diskettes)
|
|
0x09, 0x00, // 0x16: sectors per FAT (9)
|
|
0x12, 0x00, // 0x18: sectors per track (18)
|
|
0x02, 0x00, // 0x1A: number of heads (2)
|
|
0x00, 0x00, 0x00, 0x00 // 0x1C: number of hidden sectors (always 0 for non-partitioned media)
|
|
],
|
|
[ // define BPB for 10Mb hard disk
|
|
0xEB, 0xFE, 0x90, // 0x00: JMP instruction, following by 8-byte OEM signature
|
|
0x50, 0x43, 0x4A, 0x53, 0x2E, 0x4F, 0x52, 0x47, // MY_OEM_STRING
|
|
// 0x49, 0x42, 0x4D, 0x20, 0x20, 0x32, 0x2E, 0x30, // "IBM 2.0" (this is a real OEM signature)
|
|
0x00, 0x02, // 0x0B: bytes per sector (0x200 or 512)
|
|
0x08, // 0x0D: sectors per cluster (8)
|
|
0x01, 0x00, // 0x0E: reserved sectors; ie, # sectors preceding the first FAT--usually just the boot sector (1)
|
|
0x02, // 0x10: FAT copies (2)
|
|
0x00, 0x02, // 0x11: root directory entries (0x200 or 512) 0x200 * 0x20 = 0x4000 (1 sector is 0x200 bytes, total of 0x20 or 32 sectors)
|
|
0x03, 0x51, // 0x13: number of sectors (0x5103 or 20739; * 512 bytes/sector = 10,618,368 bytes = 10,369Kb = 10Mb)
|
|
0xF8, // 0x15: media type (eg, 0xF8: hard disk w/FAT12)
|
|
0x08, 0x00, // 0x16: sectors per FAT (8)
|
|
// Wikipedia (http://en.wikipedia.org/wiki/File_Allocation_Table#BIOS_Parameter_Block) implies everything past this point was introduced
|
|
// post-DOS 2.0. I think that's wrong, because I just formatted a diskette with PC-DOS 2.0 and it properly initialized the next 3 fields as well.
|
|
0x11, 0x00, // 0x18: sectors per track (17)
|
|
0x04, 0x00, // 0x1A: number of heads (4)
|
|
// PC-DOS 2.0 actually stored 0x01, 0x00, 0x80, 0x00 here, so you can't always assume that anything past offset 0x1E is part of the BPB;
|
|
// requires further investigation.
|
|
0x01, 0x00, 0x00, 0x00 // 0x1C: number of hidden sectors (always 0 for non-partitioned media)
|
|
]
|
|
];
|
|
|
|
DiskDump.asExclusions = [".*", ".IMG"];
|
|
DiskDump.asTextFileExts = [".MD", ".ME", ".ASM", ".BAS", ".TXT", ".XML"];
|
|
|
|
/*
|
|
* Class methods
|
|
*/
|
|
|
|
/**
|
|
* CLI()
|
|
*
|
|
* Provides the command-line interface for the diskdump module.
|
|
*
|
|
* Usage
|
|
* ---
|
|
* diskdump --dir={directory} [--format=json|data|hex|bytes|img] [--comments] [--output={file}]
|
|
* diskdump --disk={disk image} [--format=json|data|hex|bytes|img] [--comments] [--output={file}]
|
|
* diskdump --path={file[;file]...} [--format=json|data|hex|bytes|img] [--comments] [--output={file}]
|
|
*
|
|
* NOTE: --img is permitted as an alias for --disk
|
|
*
|
|
* Arguments
|
|
* ---
|
|
* The default format is "json", which generates an array of signed 32-bit decimal values; "hex" is an older
|
|
* text format that consists entirely of 2-character hex values (deprecated), and "bytes" is a JSON-like format
|
|
* that also uses hex values (but with "0x" prefixes) and is normally used only when comments are enabled.
|
|
*
|
|
* Note that command-line arguments, if any, are not validated. For example, argv['comments'] may be any of
|
|
* boolean, string, or undefined, since the user may have typed "--comments" or "--comments=foo" or nothing at all.
|
|
*
|
|
* Additional command-line arguments include:
|
|
*
|
|
* --mbhd={number}: requests a hard disk image with the given number of megabytes (eg, 10 for a 10mb image)
|
|
* --exclude={filename}: specifies a filename that should be excluded from the image; repeat as often as needed
|
|
* --overwrite: allows the --output option to overwrite an existing file; default is to NOT overwrite
|
|
* --manifest[={filename}]: update the specified manifest.xml file with details about the disk image
|
|
* --xdf: enable support for XDF-formatted disk images (experimental)
|
|
*
|
|
* Examples
|
|
* ---
|
|
* node modules/diskdump/bin/diskdump --disk=../jsmachines/disks/pc/games/infocom/zork1/zork1.dsk
|
|
* node modules/diskdump/bin/diskdump --dir=./apps/pc/1981/visicalc/ --format=img --output=./apps/pc/1981/visicalc/disk.img
|
|
* node modules/diskdump/bin/diskdump --path=./apps/pc/1981/visicalc/bin/vc.com;../README.md --format=json --output=./apps/pc/1981/visicalc/disk.json
|
|
*/
|
|
DiskDump.CLI = function()
|
|
{
|
|
var err = null;
|
|
var args = proc.getArgs();
|
|
|
|
fConsole = true;
|
|
|
|
if (args.argc) {
|
|
var argv = args.argv;
|
|
|
|
if (argv['debug'] !== undefined) fDebug = argv['debug'];
|
|
|
|
if (fDebug) {
|
|
DiskDump.logConsole("cwd: " + process.cwd());
|
|
DiskDump.logConsole("args: " + JSON.stringify(argv));
|
|
}
|
|
|
|
var sDiskPath = null, sServerRoot = "";
|
|
var sDir = argv['dir'], sDisk = (argv['disk'] || argv['img']), sPath = argv['path'];
|
|
|
|
if (typeof sDir == "string") {
|
|
sDiskPath = sDir;
|
|
}
|
|
else if (typeof sDisk == "string") {
|
|
sDiskPath = sDisk;
|
|
}
|
|
else if (typeof sPath == "string") {
|
|
sDiskPath = sPath;
|
|
}
|
|
if (sDiskPath && sDiskPath.charAt(0) != '/') sServerRoot = process.cwd();
|
|
|
|
var asExclude = argv['exclude'];
|
|
if (asExclude && typeof asExclude == "string") asExclude = [asExclude];
|
|
|
|
/*
|
|
* Create some sensible defaults for --manifest and --output when no values are specified
|
|
*/
|
|
var sManifestFile = argv['manifest'];
|
|
if (typeof sManifestFile == "boolean") {
|
|
sManifestFile = "manifest.xml";
|
|
}
|
|
if (sManifestFile && sManifestFile.charAt(0) != '/') {
|
|
sManifestFile = path.join(process.cwd(), sManifestFile);
|
|
}
|
|
|
|
var sOutput = "";
|
|
var sOutputFile = argv['output'];
|
|
if (typeof sOutputFile == "string" && !str.endsWith(sOutputFile, ".img") && !str.endsWith(sOutputFile, ".json")) {
|
|
sOutput = sOutputFile;
|
|
sOutputFile = true;
|
|
}
|
|
if (typeof sOutputFile == "boolean") {
|
|
if (sDir || sDisk) {
|
|
sOutput = path.join(sOutput, path.basename(sDir || sDisk));
|
|
var i = sOutput.lastIndexOf('.');
|
|
if (i > 0) {
|
|
var sExt = sOutput.substr(i);
|
|
if (sExt == ".img" || sExt == ".json") sOutput = sOutput.substr(0, i);
|
|
}
|
|
} else {
|
|
sOutput = "disk";
|
|
}
|
|
sOutputFile = sOutput + '.' + argv['format'];
|
|
}
|
|
if (sOutputFile && sOutputFile.charAt(0) != '/') sOutputFile = path.join(process.cwd(), sOutputFile);
|
|
|
|
var fOverwrite = argv['overwrite'];
|
|
var sManifestTitle = argv['title'];
|
|
|
|
if (sDiskPath) {
|
|
var disk = new DiskDump(sDiskPath, asExclude, argv['format'], argv['comments'], argv['mbhd'], sServerRoot, sManifestFile, argv);
|
|
if (sDir) {
|
|
disk.buildImage(true, function(err) {
|
|
DiskDump.outputDisk(err, disk, sDiskPath, sOutputFile, fOverwrite, sManifestTitle);
|
|
});
|
|
}
|
|
else if (sDisk) {
|
|
disk.loadFile(function(err) {
|
|
DiskDump.outputDisk(err, disk, sDiskPath, sOutputFile, fOverwrite, sManifestTitle);
|
|
});
|
|
}
|
|
else if (sPath) {
|
|
disk.buildImage(false, function(err) {
|
|
DiskDump.outputDisk(err, disk, sDiskPath, sOutputFile, fOverwrite, sManifestTitle);
|
|
});
|
|
}
|
|
} else {
|
|
err = new Error("no dir|disk|path specified");
|
|
}
|
|
}
|
|
else {
|
|
DiskDump.logConsole("usage: diskdump --dir={dir}|--disk={disk}|--path={file}[;{file}...] [--format=json|data|hex|bytes|img] [--comments] [--output={file}] [--manifest={file}] [--xdf]");
|
|
}
|
|
|
|
if (err) {
|
|
DiskDump.logError(err);
|
|
process.exit(1);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* outputDisk(err, disk, sDiskPath, sOutputFile, fOverwrite, sManifestTitle)
|
|
*
|
|
* @param {Error} err
|
|
* @param {DiskDump} disk
|
|
* @param {string} sDiskPath
|
|
* @param {string} sOutputFile
|
|
* @param {boolean} fOverwrite
|
|
* @param {string} [sManifestTitle]
|
|
*/
|
|
DiskDump.outputDisk = function(err, disk, sDiskPath, sOutputFile, fOverwrite, sManifestTitle)
|
|
{
|
|
if (!err) {
|
|
/*
|
|
* The caller may have built an image (or loaded an IMG file), in which case
|
|
* bufDisk will be set (otherwise, jsonDisk will be set). We then look for pending
|
|
* conversions: if a disk image was built/loaded, but the requested format was not,
|
|
* call convertToJSON(). Similarly, if bufDisk is not set and a raw image was
|
|
* requested, call convertToIMG().
|
|
*/
|
|
var data = disk.bufDisk;
|
|
if (data) {
|
|
if (disk.sFormat != DumpAPI.FORMAT.IMG) {
|
|
data = disk.convertToJSON();
|
|
}
|
|
} else {
|
|
if (disk.sFormat == DumpAPI.FORMAT.IMG) {
|
|
data = disk.convertToIMG();
|
|
}
|
|
}
|
|
if (data) {
|
|
|
|
var cbDisk = (disk.bufDisk? disk.bufDisk.length : data.length);
|
|
|
|
if (sOutputFile) {
|
|
|
|
var fUnchanged;
|
|
var md5Disk = null, md5JSON = null;
|
|
if (disk.sManifestFile) {
|
|
if (typeof data == "string") {
|
|
md5JSON = crypto.createHash('md5').update(data).digest('hex');
|
|
}
|
|
if (disk.bufDisk) {
|
|
md5Disk = crypto.createHash('md5').update(disk.bufDisk).digest('hex');
|
|
}
|
|
fUnchanged = DiskDump.updateManifest(disk, disk.sManifestFile, sDiskPath, sOutputFile, true, sManifestTitle, md5Disk, md5JSON);
|
|
}
|
|
|
|
try {
|
|
if (fUnchanged) {
|
|
DiskDump.logConsole(sOutputFile + " unchanged");
|
|
} else {
|
|
if (fs.existsSync(sOutputFile) && !fOverwrite) {
|
|
DiskDump.logConsole(sOutputFile + " exists, use --overwrite to rewrite");
|
|
} else {
|
|
var sDirName = path.dirname(sOutputFile);
|
|
if (!fs.existsSync(sDirName)) mkdirp.sync(sDirName);
|
|
fs.writeFileSync(sOutputFile, data);
|
|
DiskDump.logConsole(cbDisk + "-byte disk image saved to " + sOutputFile);
|
|
}
|
|
}
|
|
} catch(e) {
|
|
err = e;
|
|
}
|
|
} else {
|
|
/*
|
|
* We'll dump JSON to the console, but not a raw disk buffer; we could add an option to
|
|
* "stringify" buffers, but if that's what the caller wants, they should use "--format=json".
|
|
*/
|
|
if (typeof data == "string") {
|
|
DiskDump.logConsole(data);
|
|
} else {
|
|
DiskDump.logConsole("specify --output={file} to save " + cbDisk + "-byte disk image");
|
|
}
|
|
}
|
|
} else {
|
|
err = new Error("unable to convert " + disk.sDiskPath);
|
|
}
|
|
}
|
|
|
|
if (err) {
|
|
DiskDump.logError(err);
|
|
process.exit(1);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* getManifestAttr(sID, sTag)
|
|
*
|
|
* @param sID
|
|
* @param sTag
|
|
* @return {string|null}
|
|
*/
|
|
DiskDump.getManifestAttr = function(sID, sTag)
|
|
{
|
|
var match = sTag.match(new RegExp(sID + '="([^"]*)"'));
|
|
if (match) return match[1];
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* updateManifest(disk, sManifestFile, sDiskPath, sOutputFile, fOverwrite, sTitle, md5Disk, md5JSON)
|
|
*
|
|
* This function reports a change if EITHER the md5Disk value does not match the original
|
|
* "md5" value recorded in the manifest OR the manifest itself has changed. If md5JSON is
|
|
* also provided, we require that to match as well.
|
|
*
|
|
* Since this function is for command-line use only, we use *Sync functions, so that we can
|
|
* return the results immediately.
|
|
*
|
|
* @param {DiskDump} disk
|
|
* @param {string} sManifestFile
|
|
* @param {string} sDiskPath
|
|
* @param {string} sOutputFile
|
|
* @param {boolean} fOverwrite
|
|
* @param {string} [sTitle]
|
|
* @param {string} [md5Disk] for the entire disk image
|
|
* @param {string} [md5JSON] for the entire JSON-encoded disk image, if any
|
|
* @return {boolean|undefined} true if disk has changed, false if not, undefined if unknown
|
|
*/
|
|
DiskDump.updateManifest = function(disk, sManifestFile, sDiskPath, sOutputFile, fOverwrite, sTitle, md5Disk, md5JSON)
|
|
{
|
|
var i, fUnchanged, fExists = false, sXML, err = null;
|
|
var sMatchDisk = null, sIDDisk = null, sMD5Disk = null, sMD5JSON = null;
|
|
|
|
try {
|
|
sXML = fs.readFileSync(sManifestFile, {encoding: "utf8"});
|
|
fExists = true;
|
|
} catch(e) {
|
|
var sPrefix = "";
|
|
if (!sTitle) {
|
|
sTitle = str.getBaseName(disk.sDiskPath);
|
|
if (sTitle) {
|
|
sTitle = sTitle.charAt(0).toUpperCase() + sTitle.substr(1);
|
|
}
|
|
}
|
|
if (sTitle) {
|
|
i = sTitle.indexOf(':');
|
|
if (i > 0) sPrefix = ' prefix="' + sTitle.substr(0, i) + '"';
|
|
}
|
|
sXML = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
sXML += '<?xml-stylesheet type="text/xsl" href="/versions/pcjs/' + pkg.version + '/manifest.xsl"?>\n';
|
|
sXML += '<manifest type="software">\n';
|
|
sXML += '\t<title' + sPrefix + '>' + sTitle + '</title>\n';
|
|
sXML += '</manifest>';
|
|
}
|
|
|
|
i = sOutputFile.indexOf("/disks/");
|
|
if (i > 0) {
|
|
sOutputFile = sOutputFile.substr(i);
|
|
} else {
|
|
i = sOutputFile.indexOf("/apps/");
|
|
if (i > 0) {
|
|
sOutputFile = sOutputFile.substr(i);
|
|
}
|
|
}
|
|
|
|
var match = sXML.match(new RegExp('[ \t]*<disk ([^>]*href="' + sOutputFile + '"[^>]*?)(>[\\s\\S]*?</disk>|/>)[ \t]*\n?'));
|
|
if (match) {
|
|
sMatchDisk = match[0];
|
|
sIDDisk = DiskDump.getManifestAttr("id", match[1]);
|
|
sMD5Disk = DiskDump.getManifestAttr("md5", match[1]);
|
|
sMD5JSON = DiskDump.getManifestAttr("md5json", match[1]);
|
|
}
|
|
|
|
if (!sIDDisk) {
|
|
for (i = 1; i < 1000; i++) {
|
|
sIDDisk = i.toString();
|
|
if (sIDDisk.length < 2) sIDDisk = '0' + sIDDisk;
|
|
sIDDisk = "disk" + sIDDisk;
|
|
if (sXML.indexOf(' id="' + sIDDisk + '"') < 0) break;
|
|
}
|
|
if (i == 1000) {
|
|
err = new Error("manifest already contains " + i + " disks");
|
|
}
|
|
}
|
|
|
|
if (!err) {
|
|
/*
|
|
* Thanks to buildImage(), fDir is true if a "dir" parameter was provided, false if a "path" parameter was provided,
|
|
* and undefined otherwise, which implies a "disk" parameter (or no parameter at all -- in which case, why are we even here?)
|
|
*/
|
|
var sParm = null;
|
|
if (disk.fDir === true) {
|
|
sParm = "dir";
|
|
} else if (disk.fDir === undefined) {
|
|
sParm = "img";
|
|
}
|
|
|
|
/*
|
|
* Build a "size" attribute with the total disk size in bytes and a "chs" attribute that describes the disk geometry; eg:
|
|
*
|
|
* size="368640" chs="40:2:9"
|
|
*/
|
|
var size = 0, sCHS = "";
|
|
if (disk.dataDisk) {
|
|
sCHS = disk.dataDisk.length + ':' + disk.dataDisk[0].length + ':' + disk.dataDisk[0][0].length;
|
|
size = disk.dataDisk.length * disk.dataDisk[0].length * disk.dataDisk[0][0].length * disk.dataDisk[0][0][0].length;
|
|
}
|
|
var sXMLDisk = '\t<disk id="' + sIDDisk + '"' + (size? ' size="' + size + '"' : '') + (sCHS? ' chs="' + sCHS + '"' : '') + (sParm? ' ' + sParm + '="' + sDiskPath + '"' : '') + ' href="' + sOutputFile + '"' + (md5Disk? ' md5="' + md5Disk + '"' : '') + (md5JSON? ' md5json="' + md5JSON + '"' : '') + '>\n';
|
|
|
|
var sName = "";
|
|
if (sMatchDisk && (match = sMatchDisk.match(/<name>([^>]*)<\/name>/))) {
|
|
sName = match[1];
|
|
}
|
|
if (!sName && sXML.indexOf("\n\t<name>") < 0) {
|
|
sName = str.getBaseName(sOutputFile, true).toUpperCase();
|
|
}
|
|
if (sName) {
|
|
sXMLDisk += '\t\t<name>' + sName + '</name>\n';
|
|
}
|
|
if (sMatchDisk && (match = sMatchDisk.match(/<from [^>]*?\/>/))) {
|
|
sXMLDisk += '\t\t' + match[0] + '\n';
|
|
}
|
|
var sBaseDir = null;
|
|
for (i = 0; i < disk.aManifestInfo.length; i++) {
|
|
var sAttrs = "";
|
|
var fileInfo = disk.aManifestInfo[i];
|
|
if (fileInfo.FILE_SIZE < 0) continue; // ignore non-file entries
|
|
var sDir = path.dirname(fileInfo.FILE_PATH) + '/';
|
|
if (sBaseDir === null) sBaseDir = sDir;
|
|
sAttrs += ' size="' + fileInfo.FILE_SIZE + '"';
|
|
sAttrs += ' time="' + usr.formatDate("Y-m-d H:i:s", fileInfo.FILE_TIME) + '"';
|
|
sAttrs += ' attr="0x' + fileInfo.FILE_ATTR.toString(16) + '"';
|
|
if (fileInfo.FILE_MD5) sAttrs += ' md5="' + fileInfo.FILE_MD5 + '"';
|
|
if (!sDir.indexOf(sBaseDir)) {
|
|
sDir = sDir.substr(sBaseDir.length);
|
|
if (sDir) {
|
|
sAttrs += ' dir="' + sDir + '"';
|
|
}
|
|
}
|
|
sXMLDisk += '\t\t<file' + sAttrs + '>' + fileInfo.FILE_NAME + '</file>\n';
|
|
}
|
|
sXMLDisk += '\t</disk>\n';
|
|
sXMLDisk = sXMLDisk.replace(/(<disk[^>]*)>\s*<\/disk>/, "$1/>");
|
|
if (!sMatchDisk) {
|
|
sMatchDisk = '</manifest>';
|
|
sXMLDisk += sMatchDisk;
|
|
}
|
|
if (sMatchDisk != sXMLDisk) {
|
|
fUnchanged = false;
|
|
sXML = sXML.replace(sMatchDisk, sXMLDisk);
|
|
if (fOverwrite || !fExists) {
|
|
try {
|
|
fs.writeFileSync(sManifestFile, sXML);
|
|
DiskDump.logConsole(sManifestFile + " updated");
|
|
} catch(e) {
|
|
err = e;
|
|
}
|
|
} else {
|
|
DiskDump.logConsole(sManifestFile + " exists, use --overwrite to rewrite");
|
|
if (fDebug) DiskDump.logConsole(sXML);
|
|
}
|
|
} else {
|
|
DiskDump.logConsole(sManifestFile + " unchanged");
|
|
fUnchanged = (!md5Disk || !sMD5Disk || (md5Disk == sMD5Disk && (!md5JSON || md5JSON == sMD5JSON)));
|
|
}
|
|
}
|
|
|
|
DiskDump.logError(err);
|
|
return fUnchanged;
|
|
};
|
|
|
|
/**
|
|
* logConsole(s)
|
|
*
|
|
* @param {string} s
|
|
* @return {string}
|
|
*/
|
|
DiskDump.logConsole = function(s)
|
|
{
|
|
if (fConsole) console.log(s);
|
|
if (logFile) logFile.write(s + "\n");
|
|
return s;
|
|
};
|
|
|
|
/**
|
|
* logError(err)
|
|
*
|
|
* Conditionally logs an error to the console
|
|
*
|
|
* @param {Error} err
|
|
* @return {string} the error message that was logged (or that would have been logged had logging been enabled)
|
|
*/
|
|
DiskDump.logError = function(err)
|
|
{
|
|
var sError = "";
|
|
if (err) {
|
|
sError = "diskdump error: " + err.message;
|
|
DiskDump.logConsole(sError);
|
|
}
|
|
return sError;
|
|
};
|
|
|
|
/**
|
|
* logWarning(s)
|
|
*
|
|
* Conditionally logs a warning to the console
|
|
*
|
|
* @param {string} s
|
|
* @return {string} the warning message that was logged (or that would have been logged had logging been enabled)
|
|
*/
|
|
DiskDump.logWarning = function(s)
|
|
{
|
|
var sWarning = "";
|
|
if (s) {
|
|
sWarning = "diskdump warning: " + s;
|
|
DiskDump.logConsole(sWarning);
|
|
}
|
|
return sWarning;
|
|
};
|
|
|
|
|
|
/**
|
|
* getStat(sPath, done)
|
|
*
|
|
* An alternative to fs.stat() that handles supported remote files, in addition to local files
|
|
*
|
|
* @param {string} sPath
|
|
* @param {function(Error,Object)} done
|
|
*/
|
|
DiskDump.getStat = function(sPath, done)
|
|
{
|
|
net.isRemote(sPath)? net.getStat(sPath, done) : fs.stat(sPath, done); // jshint ignore:line
|
|
};
|
|
|
|
/**
|
|
* readFile(sPath, sEncoding, done)
|
|
*
|
|
* An alternative to fs.readFile() that handles supported remote files, in addition to local files
|
|
*
|
|
* @param {string} sPath
|
|
* @param {string|null} sEncoding
|
|
* @param {function(Error,Buffer|string)} done
|
|
*/
|
|
DiskDump.readFile = function(sPath, sEncoding, done)
|
|
{
|
|
if (net.isRemote(sPath)) {
|
|
/*
|
|
* Just a quick verification that the getStat() function works...
|
|
*
|
|
net.getStat(sPath, function(err, stats) {
|
|
if (!err) {
|
|
DiskDump.logConsole(stats);
|
|
} else {
|
|
DiskDump.logError(err);
|
|
}
|
|
});
|
|
*/
|
|
net.getFile(sPath, sEncoding, function doneReadFileRemote(err, status, buf) {
|
|
done(err, buf);
|
|
});
|
|
} else {
|
|
fs.readFile(sPath, {encoding: sEncoding}, function doneReadFileLocal(err, buf) {
|
|
done(err, buf);
|
|
});
|
|
}
|
|
};
|
|
|
|
/*
|
|
* Object methods
|
|
*/
|
|
|
|
/**
|
|
* isExcluded(sName)
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {string} sName is the basename of a file under consideration
|
|
* @return {boolean} is true if the file should be excluded, false if not
|
|
*/
|
|
DiskDump.prototype.isExcluded = function(sName)
|
|
{
|
|
sName = sName.toUpperCase();
|
|
for (var i = 0; i < this.asExclude.length; i++) {
|
|
var sExclude = this.asExclude[i].toUpperCase();
|
|
if (sName == sExclude) return true;
|
|
if (sExclude.charAt(0) == '.') {
|
|
if (sExclude.charAt(1) == '*') {
|
|
if (sName.charAt(0) == '.') return true;
|
|
} else {
|
|
if (str.endsWith(sName, sExclude)) return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* loadFile(done)
|
|
*
|
|
* This used to be part of the DiskDump constructor, but I felt it would be safer to separate
|
|
* object creation from any I/O that the object may perform, to ensure that a callback can never
|
|
* be called before the caller has actually received the newly created object.
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {function(Error)} done
|
|
*/
|
|
DiskDump.prototype.loadFile = function(done)
|
|
{
|
|
/*
|
|
* When the 'encoding' property of the 'options' object is null (or the 'options'
|
|
* object is omitted altogether), the callback's 2nd parameter will be a Buffer object
|
|
* rather than a String.
|
|
*/
|
|
var obj = this;
|
|
var sEncoding = null;
|
|
if (this.sDiskPath.slice(-5) == ".json") sEncoding = "utf8";
|
|
DiskDump.readFile(this.sDiskPath, sEncoding, function doneLoadFile(err, buf) {
|
|
obj.setData(err, buf, done);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* setData(err, buf, done)
|
|
*
|
|
* Records the loaded disk data buffer
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {Error} err
|
|
* @param {Buffer|string} buf
|
|
* @param {function(Error)} done
|
|
*/
|
|
DiskDump.prototype.setData = function(err, buf, done)
|
|
{
|
|
if (err) {
|
|
DiskDump.logError(err); // DiskDump.logConsole("unable to read " + this.sDiskPath);
|
|
done(err);
|
|
return;
|
|
}
|
|
/*
|
|
* Record the disk data buffer, and then notify the caller
|
|
*/
|
|
if (typeof buf == "string") {
|
|
this.jsonDisk = buf;
|
|
/*
|
|
* The following code is non-essential, but it's handy for forcing existing JSON to be
|
|
* regenerated; here, we assume that it's unlikely any JSON stored on the server was stored
|
|
* with comments, so if the caller has requested comments, we immediately convert the
|
|
* JSON to a Buffer and throw the JSON away. The next convertToJSON() call will take care
|
|
* of the rest.
|
|
*
|
|
* We could also move this functionality into its own function, or wait until the
|
|
* caller actually calls convertToJSON() -- although if the caller inadvertently calls
|
|
* convertToJSON() multiple times, you don't want to be regenerating the JSON every time.
|
|
*/
|
|
if (this.fJSONComments) {
|
|
if (this.convertToIMG()) {
|
|
/*
|
|
* Since convertToIMG() succeeded, we can safely blow away jsonDisk.
|
|
*/
|
|
this.jsonDisk = null;
|
|
}
|
|
}
|
|
} else {
|
|
this.bufDisk = buf;
|
|
}
|
|
done(null);
|
|
};
|
|
|
|
/**
|
|
* dumpLine(nIndent, sLine, sComment)
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {number} [nIndent] is the relative number of characters to indent the given line (0 if none)
|
|
* @param {string} [sLine] is the given line
|
|
* @param {string} [sComment] is an optional comment to append to the line, if comment output is enabled
|
|
* @return {string} the indented/commented line
|
|
*/
|
|
DiskDump.prototype.dumpLine = function(nIndent, sLine, sComment)
|
|
{
|
|
if (nIndent < 0) {
|
|
this.nJSONIndent += nIndent;
|
|
}
|
|
if (this.fJSONComments) {
|
|
sLine = " ".substr(0, this.nJSONIndent) + (sLine? (sLine + (sComment? (" // " + sComment) : "") + "\n") : "");
|
|
}
|
|
if (nIndent > 0) {
|
|
this.nJSONIndent += nIndent;
|
|
}
|
|
return sLine;
|
|
};
|
|
|
|
/**
|
|
* dumpProp(sKey, value, fLast)
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {string} sKey
|
|
* @param {number|string|null} value
|
|
* @param {boolean} [fLast]
|
|
* @return {string} the indented property
|
|
*/
|
|
DiskDump.prototype.dumpProp = function(sKey, value, fLast)
|
|
{
|
|
var sDump = "";
|
|
if (value) {
|
|
sDump += this.dumpLine(0, '"' + sKey + '":' + this.sJSONWhitespace + (typeof value == 'string'? ("'" + value + "'") : value) + (fLast? "" : ","));
|
|
}
|
|
return sDump;
|
|
};
|
|
|
|
/**
|
|
* dumpBuffer(sKey, buf, len, cbItem, offData)
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {string|null} sKey is name of buffer data element
|
|
* @param {Buffer} buf is a Buffer containing the bytes to dump
|
|
* @param {number} len is the number of bytes to dump
|
|
* @param {number} cbItem is either 1 or 4, to dump bytes or dwords respectively
|
|
* @param {number} [offData] is a relative offset of this data within the parent (for display purposes only)
|
|
* @return {string} hex (or decimal) representation of the data
|
|
*/
|
|
DiskDump.prototype.dumpBuffer = function(sKey, buf, len, cbItem, offData)
|
|
{
|
|
var sDump = this.dumpLine(2, (sKey? '"' + sKey + '":' : "") + this.sJSONWhitespace + '[');
|
|
|
|
var sLine = "";
|
|
var sASCII = "";
|
|
var cMaxCols = 16 * cbItem;
|
|
if (offData === undefined) offData = 0;
|
|
|
|
/*
|
|
* TODO: Assert that off is always < buf.length as well.
|
|
*/
|
|
for (var off = 0; off < len; off += cbItem) {
|
|
|
|
var v = (cbItem == 1? buf.readUInt8(off) : buf.readInt32LE(off));
|
|
|
|
if (off) {
|
|
sLine += ",";
|
|
if ((off % cMaxCols) === 0) {
|
|
sDump += this.dumpLine(0, sLine, sASCII);
|
|
sLine = sASCII = "";
|
|
}
|
|
}
|
|
if (cbItem > 1) {
|
|
sLine += v;
|
|
}
|
|
else {
|
|
sLine += str.toHexByte(v);
|
|
if (!sASCII) sASCII = "0x" + str.toHex(offData + off) + " ";
|
|
sASCII += (v >= 0x20 && v < 0x7F && v != 0x3C && v != 0x3E? String.fromCharCode(v) : ".");
|
|
}
|
|
}
|
|
|
|
sDump += this.dumpLine(0, sLine + "]", sASCII);
|
|
this.dumpLine(-2);
|
|
|
|
return sDump;
|
|
};
|
|
|
|
/**
|
|
* dumpTrackOSI(sTrackSig, nTrackNum, nTrackType, nTrackLoad)
|
|
*
|
|
* Dumps track data for an OSI disk track
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {string} sTrackSig
|
|
* @param {number} nTrackNum
|
|
* @param {number} nTrackType
|
|
* @param {number} [nTrackLoad]
|
|
* @return {string}
|
|
*/
|
|
DiskDump.prototype.dumpTrackOSI = function(sTrackSig, nTrackNum, nTrackType, nTrackLoad)
|
|
{
|
|
var sDump = "";
|
|
nTrackNum = Math.floor(nTrackNum / 16) * 10 + (nTrackNum % 16);
|
|
sDump += this.dumpLine(2, "{");
|
|
sDump += this.dumpProp("trackSig", sTrackSig);
|
|
sDump += this.dumpProp("trackNum", nTrackNum);
|
|
sDump += this.dumpProp("trackType", nTrackType);
|
|
sDump += this.dumpProp("trackLoad", nTrackLoad);
|
|
sDump += this.dumpLine(2, '"sectors":' + this.sJSONWhitespace + '[');
|
|
return sDump;
|
|
};
|
|
|
|
/**
|
|
* dumpSectorOSI(nSectorSig, nSectorNum, nSectorPages, bufSector, sSectorEndSig, nSectorOffset)
|
|
*
|
|
* Dumps sector data for an OSI disk sector
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {number|null} nSectorSig
|
|
* @param {number} nSectorNum
|
|
* @param {number} nSectorPages
|
|
* @param {Buffer} bufSector
|
|
* @param {string|null} sSectorEndSig
|
|
* @param {number} nSectorOffset
|
|
* @return {string}
|
|
*/
|
|
DiskDump.prototype.dumpSectorOSI = function(nSectorSig, nSectorNum, nSectorPages, bufSector, sSectorEndSig, nSectorOffset)
|
|
{
|
|
var sDump = "";
|
|
sDump += this.dumpLine(2, "{");
|
|
sDump += this.dumpProp("sectorSig", nSectorSig);
|
|
sDump += this.dumpProp("sectorNum", nSectorNum);
|
|
sDump += this.dumpProp("sectorPages", nSectorPages);
|
|
sDump += this.dumpProp("sectorEndSig", sSectorEndSig);
|
|
sDump += this.dumpBuffer("sectorData", bufSector, bufSector.length, 1, nSectorOffset);
|
|
return sDump;
|
|
};
|
|
|
|
/**
|
|
* trimSector(buf, len)
|
|
*
|
|
* If dwPattern is not null, then cbBuffer is the number of unique bytes
|
|
* at the beginning of the sector, and dwPattern is the 32-bit pattern that
|
|
* fills out the rest of the sector.
|
|
*
|
|
* There are many compression schemes I could have adopted to reduce the size of
|
|
* JSON-encoded disk images, but for now, I keep it simple: trim all bytes from
|
|
* the end of each sector that match. This is easy for the simulator to deal with,
|
|
* since all it has to do is append zeros (or the specified pattern byte) to every
|
|
* under-sized sector.
|
|
*
|
|
* NOTE: The C1Pjs Simulator doesn't support this feature (yet), which is why
|
|
* trimSector() isn't used when dumping OSI disk images.
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {Buffer} buf
|
|
* @param {number} len
|
|
* @return {Array} containing [dwPattern, cbBuffer]
|
|
*/
|
|
DiskDump.prototype.trimSector = function(buf, len)
|
|
{
|
|
var cbTrim = 0;
|
|
var cbBuffer = buf.length;
|
|
var cbPattern = 4;
|
|
var dwPattern = null;
|
|
if (cbBuffer == len) { // sector must be full-size (we don't pad it with zeros first like convdisk.php did)
|
|
var off = cbBuffer - cbPattern;
|
|
dwPattern = buf.readInt32LE(off);
|
|
while ((off -= cbPattern) >= 0) {
|
|
var dw = buf.readInt32LE(off);
|
|
// if (fDebug) DiskDump.logConsole("0x" + str.toHex(off) + ": comparing 0x" + str.toHex(dw) + " to pattern 0x" + str.toHex(dwPattern));
|
|
if (dw != dwPattern) break;
|
|
cbTrim += cbPattern;
|
|
}
|
|
}
|
|
if (cbTrim < 8) {
|
|
dwPattern = null;
|
|
} else {
|
|
cbBuffer -= (cbTrim + cbPattern);
|
|
}
|
|
return [dwPattern, cbBuffer];
|
|
};
|
|
|
|
/*
|
|
* fileInfo objects have the following properties:
|
|
*
|
|
* FILE_NAME: the 8.3 name to use
|
|
* FILE_PATH: the fully-qualified host path, if any
|
|
* FILE_ATTR: the attribute bits to use (see the ATTR constants below)
|
|
* FILE_TIME: a Date object representing the file's modification date/time, null if unknown
|
|
* FILE_SIZE: the size of the file, in bytes (or -1, in which case FILE_DATA is another aFiles array)
|
|
* FILE_DATA: the file's data (either a string or a Buffer), which may either be pre-read or deferred to buildClusters()
|
|
* FILE_CLUS: the cluster to be assigned to the file, if any
|
|
*
|
|
* Next up: assorted FAT file system constants.
|
|
*/
|
|
DiskDump.ATTR_READONLY = 0x01;
|
|
DiskDump.ATTR_HIDDEN = 0x02;
|
|
DiskDump.ATTR_SYSTEM = 0x04;
|
|
DiskDump.ATTR_VOLUME = 0x08;
|
|
DiskDump.ATTR_SUBDIR = 0x10;
|
|
DiskDump.ATTR_ARCHIVE = 0x20;
|
|
|
|
/**
|
|
* validateTime(dateTime)
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {Date} dateTime
|
|
* @return {boolean} true if date/time modified, false if not
|
|
*/
|
|
DiskDump.prototype.validateTime = function(dateTime)
|
|
{
|
|
var fModified = false;
|
|
if (dateTime) {
|
|
var year = dateTime.getFullYear();
|
|
var month = dateTime.getMonth();
|
|
var day = dateTime.getDate();
|
|
var hours = dateTime.getHours();
|
|
var minutes = dateTime.getMinutes();
|
|
var seconds = dateTime.getSeconds();
|
|
/*
|
|
* The year in a DOS modification date occupies 7 bits and is interpreted as a non-negative value (0-127)
|
|
* that is added to the base year of 1980, so the range of valid years is 1980-2107. However, it's worth
|
|
* nothing that in PC-DOS 2.0, I observed a date with the largest possible year value (127) displayed as
|
|
* "12-31-:7" (an ASCII ':' is the next highest character after '0'). While that DOES distinguish the year
|
|
* 2007 from the year 2107, we probably shouldn't allow any year > 2099, to eliminate confusion.
|
|
*
|
|
* In fact, it might be worth setting the upper limit to 2079, otherwise a date like "12-31-81" is ambiguous
|
|
* (it could mean 1981 or 2081). But I'll stick to a limit of 2099 for now.
|
|
*/
|
|
if (year < 1980) {
|
|
year = 1980; month = 0; day = 1;
|
|
hours = 0; minutes = 0; seconds = 2; // PC-DOS 2.0 won't display times that are completely zero
|
|
fModified = true;
|
|
} else if (year > 2099) {
|
|
year = 2099; month = 11; day = 31;
|
|
hours = 23; minutes = 59; seconds = 2;
|
|
fModified = true;
|
|
}
|
|
if (fModified) {
|
|
dateTime.setFullYear(year, month, day);
|
|
dateTime.setHours(hours, minutes, seconds);
|
|
}
|
|
}
|
|
return fModified;
|
|
};
|
|
|
|
/**
|
|
* buildData(cb)
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {number} cb
|
|
* @param {Array.<number>} [abInit]
|
|
* @return {Array.<number>} of bytes zero-initialized
|
|
*/
|
|
DiskDump.prototype.buildData = function(cb, abInit)
|
|
{
|
|
var ab = new Array(cb);
|
|
for (var i = 0; i < cb; i++) {
|
|
ab[i] = (abInit && i < abInit.length? abInit[i] : 0);
|
|
}
|
|
return ab;
|
|
};
|
|
|
|
/**
|
|
* copyData(ab)
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {number} offDisk
|
|
* @param {Array.<number>} ab
|
|
* @return {number} number of bytes written
|
|
*/
|
|
DiskDump.prototype.copyData = function(offDisk, ab)
|
|
{
|
|
var buf = new Buffer(ab);
|
|
buf.copy(this.bufDisk, offDisk);
|
|
return ab.length;
|
|
};
|
|
|
|
/**
|
|
* addManifestInfo(fileInfo)
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {Object} fileInfo
|
|
*/
|
|
DiskDump.prototype.addManifestInfo = function(fileInfo)
|
|
{
|
|
this.aManifestInfo.push(fileInfo);
|
|
};
|
|
|
|
/**
|
|
* isTextFile(sFileName)
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {string} sFileName
|
|
* @return {boolean} true if the filename contains a known text file extension, false if unknown
|
|
*/
|
|
DiskDump.prototype.isTextFile = function(sFileName)
|
|
{
|
|
for (var i = 0; i < DiskDump.asTextFileExts.length; i++) {
|
|
if (str.endsWith(sFileName, DiskDump.asTextFileExts[i])) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* readDir(sDir, fRoot, done)
|
|
*
|
|
* Returns an array (aFiles) via the done() callback, where each entry is a fileInfo object.
|
|
* If fileInfo refers to a subdirectory, then FILE_SIZE is -1 and FILE_DATA entry is another aFiles array.
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {string} sDir is a fully-qualified directory name
|
|
* @param {boolean} [fRoot] should be true for the first directory read
|
|
* @param {function(Error,Array)} done
|
|
*/
|
|
DiskDump.prototype.readDir = function(sDir, fRoot, done)
|
|
{
|
|
var fileInfo;
|
|
var aFiles = [];
|
|
|
|
/*
|
|
* Use the directory name as a candidate for a volume label as well, if it's upper-case and
|
|
* 11 characters or less (after we remove any numeric prefix that we may have added to indicate
|
|
* disk order, that is).
|
|
*/
|
|
if (fRoot) {
|
|
fileInfo = this.buildVolLabel(sDir);
|
|
if (fileInfo) {
|
|
aFiles.push(fileInfo);
|
|
// this.addManifestInfo(fileInfo);
|
|
}
|
|
}
|
|
|
|
var obj = this;
|
|
var cCallbacks = 0;
|
|
fs.readdir(sDir, function doneReadDir(err, asFiles) {
|
|
var iFile;
|
|
if (err) {
|
|
done(err, null);
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* Sorting file names now (since they're just strings) is easier/faster than sorting the filtered
|
|
* aFiles array later (which would require the use of a compare function), so we do the sort now; it
|
|
* has no bearing on the outcome. Note that the lack of a stable sort in JavaScript also has no
|
|
* bearing, because we're sorting on name, and every name is different.
|
|
*
|
|
* However, it's not entirely clear whether this is strictly necessary. I think the variations in
|
|
* file name order that I was originally seeing may have simply been due to out-of-order fs.stat()
|
|
* calls, because I used to call addManifestInfo() in the callback.
|
|
*/
|
|
// if (fNormalize) asFiles.sort();
|
|
|
|
for (iFile = 0; iFile < asFiles.length; iFile++) {
|
|
var sFileName = asFiles[iFile];
|
|
/*
|
|
* fs.readdir() already excludes "." and ".." but there are also a wide variety of hidden
|
|
* files on *nix systems that begin with a period, which in general we should ignore, too.
|
|
*
|
|
* TODO: Consider an override option that will allow hidden file(s) to be included as well.
|
|
*/
|
|
if (sFileName.charAt(0) == '.') continue;
|
|
var sFilePath = path.join(sDir, sFileName);
|
|
fileInfo = {};
|
|
/*
|
|
* TODO: Verify that buildName() didn't change the name into one that already exists in this directory.
|
|
* In the normal case, the directory being read already contains files named according to DOS conventions,
|
|
* and therefore they will automatically be unique.
|
|
*/
|
|
if (obj.isExcluded(sFileName)) continue;
|
|
fileInfo.FILE_NAME = obj.buildName(sFileName);
|
|
fileInfo.FILE_PATH = sFilePath;
|
|
aFiles.push(fileInfo);
|
|
|
|
/*
|
|
* We add the fileInfo objects to the aManifestInfo array NOW, because the fs.stat() callbacks may
|
|
* occur out-of-order. The only downside is that non-file entries can now appear in the array, which
|
|
* means updateManifest() will want to check for those and ignore them.
|
|
*/
|
|
obj.addManifestInfo(fileInfo);
|
|
}
|
|
|
|
var errSave = null;
|
|
for (iFile = 0; iFile < aFiles.length; iFile++) {
|
|
if (!aFiles[iFile].FILE_PATH) continue;
|
|
(function readDirEntry(fileInfo) {
|
|
cCallbacks++;
|
|
fs.stat(fileInfo.FILE_PATH, function doneStat(err, stats) {
|
|
if (!err) {
|
|
fileInfo.FILE_TIME = stats.mtime; // NOTE: This is a Date object
|
|
obj.validateTime(fileInfo.FILE_TIME);
|
|
if (stats.isDirectory()) {
|
|
fileInfo.FILE_ATTR = DiskDump.ATTR_SUBDIR;
|
|
fileInfo.FILE_SIZE = -1;
|
|
obj.readDir(fileInfo.FILE_PATH, false, function(err, aFilesDir) {
|
|
fileInfo.FILE_DATA = aFilesDir;
|
|
if (err && !errSave) errSave = err;
|
|
if (!--cCallbacks) done(errSave, aFiles);
|
|
});
|
|
return;
|
|
} else {
|
|
fileInfo.FILE_ATTR = DiskDump.ATTR_ARCHIVE;
|
|
fileInfo.FILE_SIZE = stats.size;
|
|
if (obj.isTextFile(fileInfo.FILE_NAME)) {
|
|
fs.readFile(fileInfo.FILE_PATH, {encoding: "utf8"}, function doneReadDirEntry(err, s) {
|
|
if (!err) {
|
|
s = s.replace(/\n/g, "\r\n").replace(/\r\r/g, "\r");
|
|
fileInfo.FILE_DATA = s;
|
|
fileInfo.FILE_SIZE = s.length;
|
|
} else {
|
|
if (!errSave) errSave = err;
|
|
}
|
|
// obj.addManifestInfo(fileInfo);
|
|
if (!--cCallbacks) done(errSave, aFiles);
|
|
});
|
|
return;
|
|
}
|
|
// obj.addManifestInfo(fileInfo);
|
|
}
|
|
} else {
|
|
if (!errSave) errSave = err;
|
|
}
|
|
if (!--cCallbacks) done(errSave, aFiles);
|
|
});
|
|
}(aFiles[iFile])); // jshint ignore:line
|
|
}
|
|
if (!cCallbacks) done(errSave, aFiles);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* readPath(sPath, done)
|
|
*
|
|
* Returns an array (aFiles) via the done() callback, where each entry is a fileInfo object.
|
|
* If fileInfo refers to a subdirectory, then FILE_SIZE is -1 and FILE_DATA entry is another aFiles array.
|
|
*
|
|
* NOTE: sPath begins fully-qualified (see this.sDiskPath), but if any of the intermediate entries contains paths,
|
|
* it's our responsibility to join them with sServerRoot.
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {string} sPath contains series of semi-colon-separated files (local or remote)
|
|
* @param {function(Error,Array)} done
|
|
*/
|
|
DiskDump.prototype.readPath = function(sPath, done)
|
|
{
|
|
var aFiles = [];
|
|
var asFiles = sPath.split(';');
|
|
var sDefaultPath = "";
|
|
|
|
var fileInfo = this.buildVolLabel();
|
|
if (fileInfo) {
|
|
aFiles.push(fileInfo);
|
|
// this.addManifestInfo(fileInfo);
|
|
}
|
|
|
|
for (var iFile = 0; iFile < asFiles.length; iFile++) {
|
|
fileInfo = {};
|
|
var sFileName = asFiles[iFile];
|
|
var i = sFileName.lastIndexOf('/');
|
|
if (i >= 0) {
|
|
if (sFileName.indexOf("..") < 0) {
|
|
sDefaultPath = sFileName.substr(0, i);
|
|
/*
|
|
* The DiskDump constructor joins the beginning of sPath with sServerRoot,
|
|
* but if there are any intermediate paths, we have to join them ourselves.
|
|
*/
|
|
if (iFile > 0 && !net.isRemote(sDefaultPath)) {
|
|
sDefaultPath = path.join(this.sServerRoot, sDefaultPath);
|
|
}
|
|
sFileName = sFileName.substr(i+1);
|
|
} else {
|
|
/*
|
|
* TODO: We need to permit ".." without compromising the server...
|
|
*
|
|
var err = new Error('invalid file "' + sFileName + '"');
|
|
done(err, null);
|
|
return;
|
|
*/
|
|
}
|
|
}
|
|
/*
|
|
* Ordinarily, sFileName will already be the basename, except when it has a path element like "../"
|
|
*
|
|
* TODO: Verify that buildName() doesn't change the name into one that already exists.
|
|
* This is more of a problem than in readDir(), because all these names are user-supplied.
|
|
*/
|
|
var sBaseName = path.basename(sFileName);
|
|
if (this.isExcluded(sBaseName)) continue;
|
|
fileInfo.FILE_NAME = this.buildName(sBaseName);
|
|
fileInfo.FILE_PATH = path.join(sDefaultPath, sFileName);
|
|
fileInfo.FILE_TIME = null;
|
|
aFiles.push(fileInfo);
|
|
|
|
/*
|
|
* We add the fileInfo objects to the aManifestInfo array NOW, because the getStat() callbacks may
|
|
* occur out-of-order. The only downside is that non-file entries can now appear in the array, which
|
|
* means updateManifest() will want to check for those and ignore them.
|
|
*/
|
|
this.addManifestInfo(fileInfo);
|
|
}
|
|
|
|
var obj = this;
|
|
var cCallbacks = 0;
|
|
var errSave = null;
|
|
|
|
for (iFile = 0; iFile < aFiles.length; iFile++) {
|
|
if (!aFiles[iFile].FILE_PATH) continue;
|
|
(function readPathEntry(fileInfo) {
|
|
cCallbacks++;
|
|
var sFilePath = fileInfo.FILE_PATH;
|
|
/*
|
|
* TODO: See if we can eliminate some of the unfortunate redundancy between the code
|
|
* below and the very similar code in readDir(), such as the "README.md" pre-processing.
|
|
*
|
|
* However, in this case, because we want readPath() to support both local and remote
|
|
* paths, we call DiskDump.readFile() instead of fs.readFile().
|
|
*/
|
|
DiskDump.getStat(sFilePath, function doneReadPathStat(err, stats) {
|
|
if (!err) {
|
|
fileInfo.FILE_TIME = stats.mtime; // NOTE: This is a Date object
|
|
obj.validateTime(fileInfo.FILE_TIME);
|
|
if (!stats.remote && stats.isDirectory()) {
|
|
fileInfo.FILE_ATTR = DiskDump.ATTR_SUBDIR;
|
|
fileInfo.FILE_SIZE = -1;
|
|
obj.readDir(fileInfo.FILE_PATH, false, function(err, aFilesDir) {
|
|
fileInfo.FILE_DATA = aFilesDir;
|
|
if (err && !errSave) errSave = err;
|
|
if (!--cCallbacks) done(errSave, aFiles);
|
|
});
|
|
return;
|
|
} else {
|
|
fileInfo.FILE_ATTR = DiskDump.ATTR_ARCHIVE;
|
|
fileInfo.FILE_SIZE = stats.size;
|
|
if (obj.isTextFile(fileInfo.FILE_NAME)) {
|
|
DiskDump.readFile(sFilePath, "utf8", function doneReadPathEntry(err, sData) {
|
|
if (!err) {
|
|
sData = sData.replace(/\n/g, "\r\n").replace(/\r\r/g, "\r");
|
|
fileInfo.FILE_DATA = sData;
|
|
fileInfo.FILE_SIZE = sData.length;
|
|
// obj.addManifestInfo(fileInfo);
|
|
} else {
|
|
if (!errSave) errSave = err;
|
|
}
|
|
if (!--cCallbacks) done(errSave, aFiles);
|
|
});
|
|
return;
|
|
}
|
|
// obj.addManifestInfo(fileInfo);
|
|
}
|
|
} else {
|
|
if (!errSave) errSave = err;
|
|
}
|
|
if (!--cCallbacks) done(errSave, aFiles);
|
|
});
|
|
}(aFiles[iFile])); // jshint ignore:line
|
|
}
|
|
if (!cCallbacks) done(errSave, aFiles);
|
|
};
|
|
|
|
/**
|
|
* buildName(sFile)
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {string} sFile is the basename of a file
|
|
* @return {string} containing a corresponding FAT-compatible filename
|
|
*/
|
|
DiskDump.prototype.buildName = function(sFile)
|
|
{
|
|
var sName = sFile.toUpperCase();
|
|
var iExt = sName.lastIndexOf('.');
|
|
var sExt = "";
|
|
if (iExt >= 0) {
|
|
sExt = sName.substr(iExt+1);
|
|
sName = sName.substr(0, iExt);
|
|
}
|
|
sName = sName.substr(0, 8).trim();
|
|
sExt = sExt.substr(0, 3).trim();
|
|
var iPeriod = -1;
|
|
if (sExt) {
|
|
iPeriod = sName.length;
|
|
sName += '.' + sExt;
|
|
}
|
|
for (var i = 0; i < sName.length; i++) {
|
|
if (i == iPeriod) continue;
|
|
var ch = sName.charAt(i);
|
|
if ("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&'()-@^_`{}~".indexOf(ch) < 0) {
|
|
sName = sName.substr(0, i) + '_' + sName.substr(i+1);
|
|
}
|
|
}
|
|
return sName;
|
|
};
|
|
|
|
/**
|
|
* buildVolLabel(sDir)
|
|
*
|
|
* NOTE: When fileInfo is returned, there will be no FILE_PATH property, which means
|
|
* don't go looking for a corresponding entry in the host file system, because there isn't one.
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {string} [sDir]
|
|
* @return {Object|null} fileInfo (or null if no suitable volume label)
|
|
*/
|
|
DiskDump.prototype.buildVolLabel = function(sDir)
|
|
{
|
|
var sVolume = null;
|
|
var fileInfo = null;
|
|
if (sDir) {
|
|
sVolume = path.basename(sDir);
|
|
if (sVolume == sVolume.toUpperCase()) {
|
|
var i = sVolume.indexOf('-');
|
|
if (i > 0) {
|
|
var sPrefix = sVolume.substr(0, i);
|
|
if (!sPrefix.match(/^\d+$/))
|
|
sVolume = null;
|
|
else
|
|
sVolume = sVolume.substr(i+1);
|
|
}
|
|
}
|
|
}
|
|
if (!sVolume) {
|
|
sVolume = DiskDump.MY_VOL_LABEL;
|
|
}
|
|
if (sVolume && sVolume.length <= 11) {
|
|
fileInfo = {};
|
|
fileInfo.FILE_NAME = this.buildName(sVolume);
|
|
fileInfo.FILE_ATTR = DiskDump.ATTR_VOLUME;
|
|
/*
|
|
* I used to initialize the volume label's date with a simple "new Date()", but because that results
|
|
* in a different disk image every time we run DiskDump, I've opted for a hard-coded date/time (ie, the
|
|
* day the IBM PC was introduced, August 12, 1981, with an arbitrary time of 12pm).
|
|
*/
|
|
fileInfo.FILE_TIME = fNormalize? new Date(1981, 7, 12, 12) : new Date();
|
|
this.validateTime(fileInfo.FILE_TIME);
|
|
fileInfo.FILE_SIZE = 0;
|
|
}
|
|
return fileInfo;
|
|
};
|
|
|
|
/**
|
|
* buildFAT(abFAT, aFiles, iCluster, cbCluster)
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {Array.<number>} abFAT
|
|
* @param {Array} aFiles
|
|
* @param {number} iCluster
|
|
* @param {number} cbCluster
|
|
* @return {number}
|
|
*/
|
|
DiskDump.prototype.buildFAT = function(abFAT, aFiles, iCluster, cbCluster)
|
|
{
|
|
var cb;
|
|
var cSubDirs = 0;
|
|
for (var iFile = 0; iFile < aFiles.length; iFile++) {
|
|
cb = aFiles[iFile].FILE_SIZE;
|
|
if (cb < 0) {
|
|
cb = (aFiles[iFile].FILE_DATA.length + 2) * 32;
|
|
cSubDirs++;
|
|
}
|
|
var cFileClusters = ((cb + cbCluster - 1) / cbCluster) | 0;
|
|
if (!cFileClusters) {
|
|
aFiles[iFile].FILE_CLUS = 0;
|
|
} else {
|
|
aFiles[iFile].FILE_CLUS = iCluster;
|
|
while (cFileClusters-- > 0) {
|
|
var iNextCluster = iCluster + 1;
|
|
if (!cFileClusters) iNextCluster = 0xFFF;
|
|
// if (fDebug) DiskDump.logConsole(aFiles[iFile].FILE_NAME + ": setting cluster entry " + iCluster + " to " + str.toHexWord(iNextCluster));
|
|
this.buildFATEntry(abFAT, iCluster++, iNextCluster);
|
|
}
|
|
}
|
|
}
|
|
if (cSubDirs) {
|
|
for (iFile = 0; iFile < aFiles.length; iFile++) {
|
|
cb = aFiles[iFile].FILE_SIZE;
|
|
if (cb < 0) {
|
|
iCluster = this.buildFAT(abFAT, aFiles[iFile].FILE_DATA, iCluster, cbCluster);
|
|
}
|
|
}
|
|
}
|
|
return iCluster;
|
|
};
|
|
|
|
/**
|
|
* buildFATEntry(abFat, iFat, v)
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {Array.<number>} abFAT
|
|
* @param {number} iFAT
|
|
* @param {number} v
|
|
*/
|
|
DiskDump.prototype.buildFATEntry = function(abFAT, iFAT, v)
|
|
{
|
|
var iBit = iFAT * 12;
|
|
var iByte = (iBit >> 3);
|
|
if ((iBit % 8) === 0) {
|
|
abFAT[iByte] = v & 0xff;
|
|
iByte++;
|
|
if (abFAT[iByte] === undefined) abFAT[iByte] = 0;
|
|
abFAT[iByte] = (abFAT[iByte] & 0xF0) | (v >> 8);
|
|
}
|
|
else {
|
|
if (abFAT[iByte] === undefined) abFAT[iByte] = 0;
|
|
abFAT[iByte] = (abFAT[iByte] & 0x0F) | ((v & 0xF) << 4);
|
|
iByte++;
|
|
abFAT[iByte] = (v >> 4);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* buildDir(abDir, aFiles, dateMod, iCluster, iParentCluster)
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {Array.<number>} abDir
|
|
* @param {Array} aFiles
|
|
* @param {Date} [dateMod]
|
|
* @param {number} [iCluster]
|
|
* @param {number} [iParentCluster]
|
|
* @return {number} number of directory entries built
|
|
*/
|
|
DiskDump.prototype.buildDir = function(abDir, aFiles, dateMod, iCluster, iParentCluster)
|
|
{
|
|
if (dateMod === undefined) dateMod = null;
|
|
if (iCluster === undefined) iCluster = -1;
|
|
if (iParentCluster === undefined) iParentCluster = -1;
|
|
|
|
var offDir = 0;
|
|
var cEntries = 0;
|
|
if (iCluster >= 0) {
|
|
offDir += this.buildDirEntry(abDir, offDir, ".", 0, DiskDump.ATTR_SUBDIR, dateMod, iCluster);
|
|
offDir += this.buildDirEntry(abDir, offDir, "..", 0, DiskDump.ATTR_SUBDIR, dateMod, iParentCluster);
|
|
cEntries += 2;
|
|
}
|
|
for (var iFile = 0; iFile < aFiles.length; iFile++) {
|
|
if (aFiles[iFile].FILE_CLUS === undefined) {
|
|
if (fDebug) DiskDump.logConsole("file " + aFiles[iFile].FILE_NAME + " missing cluster, skipping");
|
|
continue;
|
|
}
|
|
offDir += this.buildDirEntry(abDir, offDir, aFiles[iFile].FILE_NAME, aFiles[iFile].FILE_SIZE, aFiles[iFile].FILE_ATTR, aFiles[iFile].FILE_TIME, aFiles[iFile].FILE_CLUS);
|
|
cEntries++;
|
|
}
|
|
return cEntries;
|
|
};
|
|
|
|
/**
|
|
* buildDirEntry(ab, off, sFile, cbFile, bAttr, dateMod, iCluster)
|
|
*
|
|
* TODO: Create constants that define the various directory entry fields, including the overall size (32 bytes).
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {Array.<number>} ab contains the bytes of a directory
|
|
* @param {number} off is the offset within ab to build the next directory entry
|
|
* @param {string} sFile is the file name
|
|
* @param {number} cbFile is the size of the file, in bytes
|
|
* @param {number} bAttr contains the attribute bits of the file
|
|
* @param {Date} dateMod contains the modification date of the file
|
|
* @param {number} iCluster is the starting cluster of the file
|
|
* @return {number} number of bytes added to the directory (normally 32)
|
|
*/
|
|
DiskDump.prototype.buildDirEntry = function(ab, off, sFile, cbFile, bAttr, dateMod, iCluster)
|
|
{
|
|
var offDir = off;
|
|
var sFileExt = "";
|
|
var i = sFile.indexOf('.');
|
|
|
|
if (i > 0) {
|
|
sFileExt = sFile.substr(i+1);
|
|
sFile = sFile.substr(0, i);
|
|
}
|
|
for (i = 0; i < 8; i++) {
|
|
ab[off++] = (i < sFile.length? sFile.charCodeAt(i) : 0x20);
|
|
}
|
|
for (i = 0; i < 3; i++) {
|
|
ab[off++] = (i < sFileExt.length? sFileExt.charCodeAt(i) : 0x20);
|
|
}
|
|
|
|
/*
|
|
* File attribute bits at offset 0x0B are next: (0x01 for read-only, 0x02 for hidden, 0x04 for system,
|
|
* 0x08 for volume label, 0x10 for subdirectory, and 0x20 for archive)
|
|
*/
|
|
ab[off++] = bAttr;
|
|
|
|
/*
|
|
* Skip 10 bytes, bringing us to offset 0x16: 2 bytes for modification time, plus 2 bytes for modification date.
|
|
*/
|
|
off += 10;
|
|
if (dateMod) {
|
|
var year = dateMod.getFullYear();
|
|
var month = dateMod.getMonth() + 1;
|
|
var day = dateMod.getDate();
|
|
var time = ((dateMod.getHours() & 0x1F) << 11) | ((dateMod.getMinutes() & 0x3F) << 5) | ((dateMod.getSeconds() >> 1) & 0x1F);
|
|
/*
|
|
* NOTE: If validateTime() is doing its job, then we should never have to do this. This is simple paranoia.
|
|
*/
|
|
if (year < 1980) {
|
|
year = 1980; month = 1; day = 1; time = 1;
|
|
} else if (year > 2099) {
|
|
year = 2099; month = 12; day = 31; time = 1;
|
|
}
|
|
ab[off++] = time & 0xff;
|
|
ab[off++] = time >> 8;
|
|
var date = (((year - 1980) & 0x7F) << 9) | (month << 5) | day;
|
|
ab[off++] = date & 0xff;
|
|
ab[off++] = date >> 8;
|
|
} else {
|
|
for (i = 0; i < 4; i++) {
|
|
ab[off++] = 0;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Now we're at offset 0x1A, where the starting cluster (2 bytes) and file size (4 bytes) are stored,
|
|
* completing the 32-byte directory entry.
|
|
*/
|
|
ab[off++] = iCluster & 0xff; // first file cluster (low byte)
|
|
ab[off++] = (iCluster >> 8) & 0xff; // first file cluster (high byte)
|
|
|
|
/*
|
|
* For subdirectories, we recorded a -1 rather than a 0, because unlike true 0-length files, they DO actually
|
|
* have a size, it's just not immediately known until we traverse the directory's contents. However, when it
|
|
* comes time to the write the directory entry for a subdirectory, the FAT convention is to record it as zero.
|
|
*/
|
|
if (cbFile < 0) cbFile = 0;
|
|
ab[off++] = cbFile & 0xff;
|
|
ab[off++] = (cbFile >> 8) & 0xff;
|
|
ab[off++] = (cbFile >> 16) & 0xff;
|
|
ab[off++] = (cbFile >> 24) & 0xff;
|
|
|
|
return off - offDir;
|
|
};
|
|
|
|
/**
|
|
* buildClusters(aFiles, offDisk, cbCluster, iParentCluster, done)
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {Array} aFiles
|
|
* @param {number} offDisk
|
|
* @param {number} cbCluster
|
|
* @param {number} iParentCluster
|
|
* @param {number} iLevel
|
|
* @param {function(Error)} done
|
|
* @return {number} number of clusters built
|
|
*/
|
|
DiskDump.prototype.buildClusters = function(aFiles, offDisk, cbCluster, iParentCluster, iLevel, done)
|
|
{
|
|
var obj = this;
|
|
var cSubDirs = 0;
|
|
var cClusters = 0;
|
|
|
|
if (!iLevel) {
|
|
this.cWritesPending = 0;
|
|
}
|
|
|
|
for (var iFile = 0; iFile < aFiles.length; iFile++) {
|
|
var bufData = null;
|
|
var cbData = aFiles[iFile].FILE_SIZE;
|
|
if (cbData > 0) {
|
|
var sData = aFiles[iFile].FILE_DATA;
|
|
if (!sData) {
|
|
this.cWritesPending++;
|
|
(function readClusters(file, cb, off) {
|
|
fs.readFile(file.FILE_PATH, function doneReadClusters(err, buf) {
|
|
/*
|
|
* If cWritesPending has been prematurely zeroed, we assume that's because the buildClusters()
|
|
* caller discovered a problem (eg, the total number of clusters exceeds what can fit in the image),
|
|
* so we bail.
|
|
*/
|
|
if (!obj.cWritesPending) return;
|
|
if (!err) {
|
|
if (fDebug && cb != buf.length) DiskDump.logConsole(file.FILE_NAME + ": initial size (" + cb + ") does not match actual size (" + buf.length + ")");
|
|
buf.copy(obj.bufDisk, off);
|
|
if (fDebug) DiskDump.logConsole("0x" + str.toHex(off) + ": 0x" + str.toHex(buf.length) + " bytes written for " + file.FILE_PATH);
|
|
if (obj.sManifestFile) file.FILE_MD5 = crypto.createHash('md5').update(buf).digest('hex');
|
|
}
|
|
if (!--obj.cWritesPending) done(err);
|
|
});
|
|
}(aFiles[iFile], cbData, offDisk)); // jshint ignore:line
|
|
} else {
|
|
cbData = sData.length;
|
|
bufData = new Buffer(sData);
|
|
if (this.sManifestFile) aFiles[iFile].FILE_MD5 = crypto.createHash('md5').update(bufData).digest('hex');
|
|
}
|
|
}
|
|
else if (cbData < 0) {
|
|
var abData = [];
|
|
cbData = this.buildDir(abData, aFiles[iFile].FILE_DATA, aFiles[iFile].FILE_TIME, aFiles[iFile].FILE_CLUS, iParentCluster) * 32;
|
|
bufData = new Buffer(this.buildData(cbData, abData));
|
|
cSubDirs++;
|
|
}
|
|
if (bufData) {
|
|
bufData.copy(this.bufDisk, offDisk);
|
|
if (fDebug) DiskDump.logConsole("0x" + str.toHex(offDisk) + ": 0x" + str.toHex(bufData.length) + " bytes IMMEDIATELY written for " + aFiles[iFile].FILE_PATH);
|
|
}
|
|
offDisk += cbData;
|
|
cClusters += ((cbData / cbCluster) | 0);
|
|
var cbPartial = (cbData % cbCluster);
|
|
if (cbPartial) {
|
|
cbPartial = cbCluster - cbPartial;
|
|
offDisk += cbPartial;
|
|
cClusters++;
|
|
}
|
|
}
|
|
|
|
if (cSubDirs > 0) {
|
|
for (iFile = 0; iFile < aFiles.length; iFile++) {
|
|
var cb = aFiles[iFile].FILE_SIZE;
|
|
if (cb < 0) {
|
|
if (fDebug) DiskDump.logConsole("0x" + str.toHex(offDisk) + ": buildClusters()");
|
|
var cSubClusters = this.buildClusters(aFiles[iFile].FILE_DATA, offDisk, cbCluster, aFiles[iFile].FILE_CLUS, iLevel + 1, done);
|
|
cClusters += cSubClusters;
|
|
offDisk += cSubClusters * cbCluster;
|
|
if (fDebug) DiskDump.logConsole("0x" + str.toHex(offDisk) + ": buildClusters() returned, writing " + cSubClusters + " clusters");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!iLevel) {
|
|
if (!this.cWritesPending) done(null);
|
|
}
|
|
|
|
return cClusters;
|
|
};
|
|
|
|
/**
|
|
* buildImage()
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {boolean} fDir
|
|
* @param {function(Error)} done
|
|
*/
|
|
DiskDump.prototype.buildImage = function(fDir, done)
|
|
{
|
|
var obj = this;
|
|
if ((this.fDir = fDir)) {
|
|
this.readDir(this.sDiskPath, true, function doneReadDir(err, aFiles) {
|
|
if (err) {
|
|
done(err);
|
|
return;
|
|
}
|
|
obj.buildImageFromFiles(aFiles, done);
|
|
});
|
|
} else {
|
|
this.readPath(this.sDiskPath, function doneReadPath(err, aFiles) {
|
|
if (err) {
|
|
done(err);
|
|
return;
|
|
}
|
|
obj.buildImageFromFiles(aFiles, done);
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* calcFileSizes(aFiles)
|
|
*
|
|
* WARNING: Our "total data" calculation should be rounding up to the next cluster,
|
|
* not the next sector, because data on the disk is cluster-granular, not sector-granular.
|
|
* But we have a chicken-and-egg problem: we won't know the cluster size until we've
|
|
* calculated total data and found a BPB we think will accommodate it. So, the code below
|
|
* will still have to be prepared for running out of disk space. This is just a good estimate.
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {Array} aFiles
|
|
* @return {number} of bytes required for all files, including all subdirectories
|
|
*/
|
|
DiskDump.prototype.calcFileSizes = function(aFiles)
|
|
{
|
|
var cbTotal = 0;
|
|
for (var iFile = 0; iFile < aFiles.length; iFile++) {
|
|
var cb = aFiles[iFile].FILE_SIZE;
|
|
var cbSubTotal = 0;
|
|
if (cb < 0) {
|
|
cb = (aFiles[iFile].FILE_DATA.length + 2) * 32;
|
|
cbSubTotal = this.calcFileSizes(aFiles[iFile].FILE_DATA);
|
|
}
|
|
cbTotal += cb;
|
|
if ((cb %= 512)) {
|
|
cbTotal += 512 - cb; // WARNING: rounding to next sector may not be enough (see above)
|
|
}
|
|
cbTotal += cbSubTotal;
|
|
}
|
|
return cbTotal;
|
|
};
|
|
|
|
/**
|
|
* buildMBR(cHeads, cSectorsPerTrack, cbSector, cTotalSectors)
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {number} cHeads
|
|
* @param {number} cSectorsPerTrack
|
|
* @param {number} cbSector
|
|
* @param {number} cTotalSectors
|
|
* @returns {Array.<number>}
|
|
*/
|
|
DiskDump.prototype.buildMBR = function(cHeads, cSectorsPerTrack, cbSector, cTotalSectors)
|
|
{
|
|
/*
|
|
* There are four 16-byte partition entries in the MBR, starting at offset 0x1BE,
|
|
* but we need only one, and like DOS 2.0, we'll use the last one, at offset 0x1EE.
|
|
*/
|
|
var offSector = 0x1EE;
|
|
var abSector = this.buildData(cbSector);
|
|
|
|
/*
|
|
* Next 1 byte: status + physical drive #
|
|
*/
|
|
abSector[offSector++] = 0x80; // 0x80 indicates an active partition entry
|
|
|
|
/*
|
|
* Next 3 bytes: CHS (Cylinder/Head/Sector) of first partition sector
|
|
*/
|
|
abSector[offSector++] = 0x00; // head: 0
|
|
abSector[offSector++] = 0x02; // sector: 1 (bits 0-5), cyclinder bits 8-9: 0 (bits 6-7)
|
|
abSector[offSector++] = 0x00; // cylinder bits 0-7: 0
|
|
|
|
/*
|
|
* Next 1 byte: partition ID
|
|
*/
|
|
abSector[offSector++] = 0x01; // partition ID: 0x01 (FAT12)
|
|
|
|
/*
|
|
* Next 3 bytes: CHS (Cylinder/Head/Sector) of last partition sector
|
|
*/
|
|
abSector[offSector++] = cHeads-1;
|
|
var cCylinders = (cTotalSectors / (cHeads * cSectorsPerTrack)) | 0;
|
|
abSector[offSector++] = cSectorsPerTrack | ((cCylinders & 0x300) >> 2);
|
|
abSector[offSector++] = cCylinders & 0xff;
|
|
|
|
/*
|
|
* Next 4 bytes: LBA (Logical Block Address) of first partition sector
|
|
*/
|
|
abSector[offSector++] = 1;
|
|
abSector[offSector++] = 0x00;
|
|
abSector[offSector++] = 0x00;
|
|
abSector[offSector++] = 0x00;
|
|
|
|
/*
|
|
* Next 4 bytes: Number of sectors in partition
|
|
*/
|
|
abSector[offSector++] = (cTotalSectors & 0xff);
|
|
abSector[offSector++] = ((cTotalSectors >> 8) & 0xff);
|
|
abSector[offSector++] = ((cTotalSectors >> 16) & 0xff);
|
|
abSector[offSector++] = ((cTotalSectors >> 24) & 0xff);
|
|
|
|
/*
|
|
* Since we should be at offset 0x1FE now, store the MBR signature bytes
|
|
*/
|
|
abSector[offSector++] = 0x55;
|
|
abSector[offSector] = 0xAA;
|
|
return abSector;
|
|
};
|
|
|
|
/**
|
|
* buildImageFromFiles(aFiles)
|
|
*
|
|
* Note, however, that even if this function returns true, you won't receive the buffer until
|
|
* all the writes to have it have finished.
|
|
*
|
|
* @this {DiskDump}
|
|
* @param {Array} aFiles
|
|
* @param {function(Error)} done
|
|
* @return {boolean} true if disk allocation successful, false if not
|
|
*/
|
|
DiskDump.prototype.buildImageFromFiles = function(aFiles, done)
|
|
{
|
|
var err;
|
|
if (!aFiles || !aFiles.length) {
|
|
done(null);
|
|
return false;
|
|
}
|
|
|
|
/*
|
|
* Put reasonable upper limits on both individual file sizes and the total size of all files.
|
|
*/
|
|
var cbMax = (this.mbHD? this.mbHD * 1024 * 1024 : 1440 * 1024);
|
|
var cbTotal = this.calcFileSizes(aFiles);
|
|
|
|
if (fDebug) DiskDump.logConsole("total calculated size for " + aFiles.length + " files/folders: " + cbTotal + " bytes (0x" + str.toHex(cbTotal) + ")");
|
|
|
|
if (cbTotal >= cbMax) {
|
|
err = new Error("file(s) too large (" + cbTotal + " bytes total, " + cbMax + " bytes maximum)");
|
|
done(err);
|
|
return false;
|
|
}
|
|
|
|
var abBoot, cbSector, cSectorsPerCluster, cbCluster, cFATs, cFATSectors;
|
|
var cRootEntries, cRootSectors, cTotalSectors, cSectorsPerTrack, cHeads, cDataSectors, cbAvail;
|
|
|
|
/*
|
|
* Find or build a BPB with enough capacity, and at the same time, calculate all
|
|
* the other values we'll need, including total number of data sectors (cDataSectors).
|
|
*/
|
|
for (var iBPB = 1; iBPB < DiskDump.aDefaultBPBs.length; iBPB++) {
|
|
/*
|
|
* If this BPB is for a hard disk but a hard disk size was not specified, skip it.
|
|
*/
|
|
abBoot = DiskDump.aDefaultBPBs[iBPB];
|
|
if ((abBoot[0x15] == 0xF8) != (this.mbHD > 0)) continue;
|
|
cbSector = abBoot[0x0B] | (abBoot[0x0C] << 8);
|
|
cSectorsPerCluster = abBoot[0x0D];
|
|
cbCluster = cbSector * cSectorsPerCluster;
|
|
cFATs = abBoot[0x10];
|
|
cFATSectors = abBoot[0x16] | (abBoot[0x17] << 8);
|
|
cRootEntries = abBoot[0x11] | (abBoot[0x12] << 8);
|
|
cRootSectors = (((cRootEntries * 0x20) + cbSector - 1) / cbSector) | 0;
|
|
cTotalSectors = abBoot[0x13] | (abBoot[0x14] << 8);
|
|
cSectorsPerTrack = abBoot[0x18] | (abBoot[0x19] << 8);
|
|
cHeads = abBoot[0x1A] | (abBoot[0x1B] << 8);
|
|
cDataSectors = cTotalSectors - cRootSectors - cFATs * cFATSectors + 1;
|
|
cbAvail = cDataSectors * cbSector;
|
|
if (cbTotal <= cbAvail) break; // found a BPB that works!
|
|
}
|
|
|
|
if (iBPB == DiskDump.aDefaultBPBs.length) {
|
|
err = new Error("file(s) too large for disk image (" + cbTotal + " vs. " + cbAvail + " bytes)");
|
|
done(err);
|
|
return false;
|
|
}
|
|
|
|
if (aFiles.length > cRootEntries) {
|
|
err = new Error("too many files for disk image (" + aFiles.length + " vs. " + cRootEntries + " max)");
|
|
done(err);
|
|
return false;
|
|
}
|
|
|
|
var abSector;
|
|
var offDisk = 0;
|
|
var cbDisk = cTotalSectors * cbSector;
|
|
|
|
/*
|
|
* TODO: Consider doing what convertToIMG() does, which is deferring setting this.bufDisk until the
|
|
* buffer is fully (and successfully) initialized. Here, however, the build process relies on worker
|
|
* functions that prefer not passing around temporary buffers. In the meantime, perhaps any catastrophic
|
|
* failures should set bufDisk back to null?
|
|
*/
|
|
this.bufDisk = new Buffer(cbDisk);
|
|
|
|
/*
|
|
* WARNING: Buffers are NOT zero-initialized, so we need explicitly fill bufDisk with zeros (this seems
|
|
* to be a reversal in the trend to zero buffers, when security concerns would trump performance concerns).
|
|
*/
|
|
this.bufDisk.fill(0);
|
|
|
|
/*
|
|
* Output a Master Boot Record (MBR), if a hard disk image was requested
|
|
*/
|
|
if (this.mbHD > 0) {
|
|
abSector = this.buildMBR(cHeads, cSectorsPerTrack, cbSector, cTotalSectors);
|
|
offDisk += this.copyData(offDisk, abSector);
|
|
}
|
|
|
|
/*
|
|
* Output a boot sector
|
|
*
|
|
* NOTE: I don't put a [0x55,0xAA] signature at the end, since it's not actually bootable.
|
|
*/
|
|
abSector = this.buildData(cbSector, abBoot);
|
|
offDisk += this.copyData(offDisk, abSector);
|
|
|
|
/*
|
|
* Build the FAT, noting the starting cluster number that each file will use along the way.
|
|
*/
|
|
var abFAT = [];
|
|
this.buildFATEntry(abFAT, 0, abBoot[0x15] | 0xF00);
|
|
this.buildFATEntry(abFAT, 1, 0xFFF);
|
|
this.buildFAT(abFAT, aFiles, 2, cbCluster);
|
|
|
|
/*
|
|
* Output the FAT sectors; we simplify the logic a bit by writing each FAT table as if it
|
|
* were one giant sector.
|
|
*/
|
|
while (cFATs--) {
|
|
abSector = this.buildData(cFATSectors * cbSector, abFAT);
|
|
offDisk += this.copyData(offDisk, abSector);
|
|
}
|
|
|
|
/*
|
|
* Build the root directory
|
|
*/
|
|
var abRoot = [];
|
|
var cEntries = this.buildDir(abRoot, aFiles);
|
|
|
|
/*
|
|
* PC-DOS 1.0 requires ALL unused directory entries to start with 0xE5; 0x00 isn't good enough,
|
|
* so we must loop through all the remaining directory entries and zap them with 0xE5.
|
|
*/
|
|
var offRoot = cEntries * 32;
|
|
while (cEntries++ < cRootEntries) {
|
|
abRoot[offRoot] = 0xE5;
|
|
offRoot += 32;
|
|
}
|
|
|
|
/*
|
|
* Output the root directory sectors (as before, as if they were one giant sector)
|
|
*/
|
|
abSector = this.buildData(cRootSectors * cbSector, abRoot);
|
|
offDisk += this.copyData(offDisk, abSector);
|
|
|
|
/*
|
|
* Output the file data clusters, which must be stored sequentially, mirroring the order in which
|
|
* we wrote the cluster sequences to the FAT, above.
|
|
*/
|
|
var cClusters = this.buildClusters(aFiles, offDisk, cbCluster, 0, 0, done);
|
|
offDisk += cClusters * cSectorsPerCluster * cbSector;
|
|
|
|
if (fDebug) DiskDump.logConsole(offDisk + " bytes written, " + cbDisk + " bytes available");
|
|
|
|
if (offDisk > cbDisk) {
|
|
err = new Error("too much data for disk image (" + cClusters + " clusters required)");
|
|
this.cWritesPending = 0;
|
|
done(err);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* convertToJSON()
|
|
*
|
|
* Converts the disk image data to JSON.
|
|
*
|
|
* @this {DiskDump}
|
|
* @return {string|null} containing a JSON representation of the disk image, or null if unrecognized/malformed
|
|
*/
|
|
DiskDump.prototype.convertToJSON = function()
|
|
{
|
|
if (this.jsonDisk) {
|
|
return this.jsonDisk;
|
|
}
|
|
|
|
/*
|
|
* TODO: Decide if we want to retain this usage info:
|
|
*/
|
|
if (!this.bufDisk) {
|
|
// DiskDump.logConsole("no data available in disk image");
|
|
// this.jsonDisk = "[ /* no data */ ]";
|
|
this.jsonDisk = "[\n /**\n * " + DiskDump.sNotice + "\n * " + DiskDump.sUsage + "\n */\n]";
|
|
return this.jsonDisk;
|
|
}
|
|
|
|
var json = null;
|
|
try {
|
|
var nHeads = 0;
|
|
var nCylinders = 0;
|
|
var nSectorsPerTrack = 0;
|
|
var aTracks = []; // track array (used only for disk images with track tables)
|
|
var iTrack, cbTrack, offTrack, bufTrack, bufSector;
|
|
var cbSector = 512; // default sector size
|
|
var offBootSector = 0;
|
|
var cbDiskData = this.bufDisk.length;
|
|
|
|
if (cbDiskData >= 3000000) { // arbitrary threshold between diskette image sizes and hard disk image sizes
|
|
/*
|
|
* In this case, the first sector should be an MBR; find the active partition entry,
|
|
* then read the LBA of the first partition sector to calculate the boot sector offset.
|
|
*/
|
|
for (var offEntry = 0x1BE; offEntry <= 0x1EE; offEntry += 0x10) {
|
|
if (this.bufDisk.readUInt8(offEntry) >= 0x80) {
|
|
offBootSector = this.bufDisk.readUInt16LE(offEntry + 0x08) * cbSector;
|
|
break;
|
|
}
|
|
}
|
|
/*
|
|
* If we failed to find an active entry, we'll fall into the BPB detection code, which
|
|
* should fail if the first sector really was an MBR. Otherwise, the BPB should give us
|
|
* the geometry info we need to dump the entire disk image, including the MBR and any
|
|
* other reserved sectors.
|
|
*/
|
|
}
|
|
|
|
var bByte0 = this.bufDisk.readUInt8(offBootSector + DiskAPI.BOOT.JMP_OPCODE);
|
|
var cbSectorBPB = this.bufDisk.readUInt16LE(offBootSector + DiskAPI.BPB.SECTOR_BYTES);
|
|
|
|
/*
|
|
* These checks are not only necessary for DOS 1.x diskette images (and other pre-BPB images),
|
|
* but also non-DOS diskette images (eg, CPM-86 diskettes).
|
|
*
|
|
* And we must perform these tests BEFORE checking for a BPB, because we want the PHYSICAL geometry
|
|
* of the disk, whereas any values in the BPB may only be LOGICAL. For example, DOS may only be using
|
|
* 8 sectors per track on diskette that's actually formatted with 9 sectors per track.
|
|
*
|
|
* Checking these common sizes insures we get the proper physical geometry for common disk formats,
|
|
* but at some point, we'll need to perform more general calculations to properly deal with ANY disk
|
|
* image whose logical format doesn't agree with its physical structure.
|
|
*/
|
|
var fXDFOutput = false;
|
|
var disketteFormat = DiskAPI.DISKETTE_FORMATS[cbDiskData];
|
|
if (disketteFormat) {
|
|
nCylinders = disketteFormat[0];
|
|
nHeads = disketteFormat[1];
|
|
nSectorsPerTrack = disketteFormat[2];
|
|
}
|
|
else {
|
|
/*
|
|
* See if the first sector of the image contains a valid DOS BPB. That begs the question: what IS a valid
|
|
* DOS BPB? For starters, the first word (at offset 0x0B) is invariably 0x0200, indicating a 512-byte sector
|
|
* size. I also check the first byte for an Intel JMP opcode (0xEB is JMP with a 1-byte displacement, and
|
|
* 0xE9 is JMP with a 2-byte displacement). What else?
|
|
*/
|
|
if ((bByte0 == X86.OPCODE.JMP || bByte0 == X86.OPCODE.JMPS) && cbSectorBPB == cbSector) {
|
|
|
|
var nHeadsBPB = this.bufDisk.readUInt16LE(offBootSector + DiskAPI.BPB.TOTAL_HEADS);
|
|
var nSectorsTotalBPB = this.bufDisk.readUInt16LE(offBootSector + DiskAPI.BPB.TOTAL_SECS);
|
|
var nSectorsPerTrackBPB = this.bufDisk.readUInt16LE(offBootSector + DiskAPI.BPB.TRACK_SECS);
|
|
|
|
if (nSectorsPerTrackBPB && nHeadsBPB) {
|
|
|
|
var nSectorsPerCylinderBPB = nSectorsPerTrackBPB * nHeadsBPB;
|
|
nHeads = nHeadsBPB;
|
|
nCylinders = Math.floor(nSectorsTotalBPB / nSectorsPerCylinderBPB);
|
|
nSectorsPerTrack = nSectorsPerTrackBPB;
|
|
|
|
/*
|
|
* OK, great, the disk appears to contain a valid BPB. But so do XDF disk images, which are
|
|
* diskette images with tracks containing:
|
|
*
|
|
* 1 8Kb sector (equivalent of 16 512-byte sectors)
|
|
* 1 2Kb sector (equivalent of 4 512-byte sectors)
|
|
* 1 1Kb sector (equivalent of 2 512-byte sectors)
|
|
* 1 512-byte sector (equivalent of, um, 1 512-byte sector)
|
|
*
|
|
* for a total of the equivalent of 23 512-byte sectors, or 11776 (0x2E00) bytes per track.
|
|
* For an 80-track diskette with 2 sides, that works out to a total of 3680 512-byte sectors,
|
|
* or 1884160 bytes, or 1.84Mb, which is the exact size of the (only) XDF diskette images we
|
|
* currently (try to) support.
|
|
*
|
|
* Moreover, the first two tracks (ie, the first cylinder) contain only 19 sectors each,
|
|
* rather than 23, but XDF disk images still pads those tracks with 4 unused sectors.
|
|
*
|
|
* So, data for the first track contains 1 boot sector ending at 512 (0x200), 11 FAT sectors
|
|
* ending at 6144 (0x1800), and 7 "micro-disk" sectors ending at 9728 (0x2600). Then there's
|
|
* 4 (useless?) sectors that end at 11776 (0x2E00).
|
|
*
|
|
* Data for the second track contains 7 root directory sectors ending at 15360 (0x3C00), followed
|
|
* by disk data.
|
|
*
|
|
* For more details, check out this helpful article: http://www.os2museum.com/wp/the-xdf-diskette-format/
|
|
*/
|
|
if (nSectorsTotalBPB == 3680 && this.fXDFSupport) {
|
|
DiskDump.logWarning("XDF diskette detected, experimental XDF output enabled");
|
|
fXDFOutput = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!nHeads) {
|
|
/*
|
|
* Next, check for a DSK header (an old private format I used to use, which begins with either
|
|
* 0x00 (read-write) or 0x01 (write-protected), followed by 7 more bytes):
|
|
*
|
|
* 0x01: # heads (1 byte)
|
|
* 0x02: # cylinders (2 bytes)
|
|
* 0x04: # sectors/track (2 bytes)
|
|
* 0x06: # bytes/sector (2 bytes)
|
|
*
|
|
* which may be followed by an array of track table entries if the words at 0x04 and 0x06 are zero.
|
|
* If the track table exists, each entry contains the following:
|
|
*
|
|
* 0x00: # sectors/track (2 bytes)
|
|
* 0x02: # bytes/sector (2 bytes)
|
|
* 0x04: file offset of track data (4 bytes)
|
|
*
|
|
* TODO: Our JSON disk format doesn't explicitly support a write-protect indicator. Instead, we
|
|
* (used to) include the string "write-protected" as a comment in the first line of the JSON data
|
|
* as a work-around, and if the FDC component sees that comment string, it will honor it; however,
|
|
* we now prefer that read-only disk images simply include a "-readonly" suffix in the filename.
|
|
*/
|
|
if (!(bByte0 & 0xFE)) {
|
|
var cbSectorDSK = this.bufDisk.readUInt16LE(offBootSector + 0x06);
|
|
if (!(cbSectorDSK & (cbSectorDSK - 1))) {
|
|
cbSector = cbSectorDSK;
|
|
nHeads = this.bufDisk.readUInt8(offBootSector + 0x01);
|
|
nCylinders = this.bufDisk.readUInt16LE(offBootSector + 0x02);
|
|
nSectorsPerTrack= this.bufDisk.readUInt16LE(offBootSector + 0x04);
|
|
var nTracks = nHeads * nCylinders;
|
|
cbTrack = nSectorsPerTrack * cbSector;
|
|
offTrack = 0x08;
|
|
if (!cbTrack) {
|
|
for (iTrack = 0; iTrack < nTracks; iTrack++) {
|
|
nSectorsPerTrack = this.bufDisk.readUInt16LE(offTrack);
|
|
cbSectorDSK = this.bufDisk.readUInt16LE(offTrack+2);
|
|
cbTrack = nSectorsPerTrack * cbSectorDSK;
|
|
offSector = this.bufDisk.readUInt32LE(offTrack+4);
|
|
bufTrack = this.bufDisk.slice(offSector, offSector + cbTrack);
|
|
aTracks[iTrack] = [nSectorsPerTrack, cbSectorDSK, bufTrack];
|
|
offTrack += 8;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (nHeads) {
|
|
/*
|
|
* Output the disk data as an array of cylinders, each containing an array of tracks (one track per head),
|
|
* and each track containing an array of sectors.
|
|
*/
|
|
iTrack = offTrack = 0;
|
|
cbTrack = nSectorsPerTrack * cbSector;
|
|
if (this.fJSONNative) {
|
|
this.dataDisk = new Array(nCylinders);
|
|
} else {
|
|
json = this.dumpLine(2, "[", "DiskDump of " + this.sDiskPath + " via " + DiskDump.sNotice);
|
|
}
|
|
for (var iCylinder=0; iCylinder < nCylinders; iCylinder++) {
|
|
|
|
var aHeads;
|
|
if (this.fJSONNative) {
|
|
aHeads = new Array(nHeads);
|
|
this.dataDisk[iCylinder] = aHeads;
|
|
} else {
|
|
json += this.dumpLine(2, "[", "cylinder: " + iCylinder);
|
|
}
|
|
var offHead = 0;
|
|
for (var iHead=0; iHead < nHeads; iHead++) {
|
|
|
|
if (aTracks.length) {
|
|
var aTrack = aTracks[iTrack++];
|
|
nSectorsPerTrack = aTrack[0];
|
|
cbSector = aTrack[1];
|
|
bufTrack = aTrack[2];
|
|
cbTrack = nSectorsPerTrack * cbSector;
|
|
} else {
|
|
bufTrack = this.bufDisk.slice(offTrack + offHead, offTrack + offHead + cbTrack);
|
|
}
|
|
|
|
var aSectors;
|
|
if (this.fJSONNative) {
|
|
aSectors = new Array(nSectorsPerTrack);
|
|
aHeads[iHead] = aSectors;
|
|
} else {
|
|
json += this.dumpLine(2, "[", "head:" + this.sJSONWhitespace + iHead + ", track:" + this.sJSONWhitespace + iCylinder);
|
|
}
|
|
|
|
/*
|
|
* For most disks, the size of every sector and the number of sectors/track are consistent, and the
|
|
* sector number encoded in every sector (nSector) matches the 1-based sector index (iSector) we use
|
|
* to "track" our progress through the current track. However, for XDF disk images, the above is
|
|
* NOT true beyond cylinder 0, which is why we have all these *ThisTrack variables, which would otherwise
|
|
* be unnecessary.
|
|
*/
|
|
var cbSectorThisTrack = cbSector;
|
|
var nSectorsThisTrack = nSectorsPerTrack;
|
|
|
|
/*
|
|
* Notes regarding XDF track layouts, from http://forum.kryoflux.com/viewtopic.php?f=3&t=234:
|
|
*
|
|
* Track 0, side 0: 19x512 bytes per sector, with standard numbering for the first 8 sectors, then custom numbering
|
|
* Track 0, side 1: 19x512 bytes per sector, with interleaved sector numbering 0x81...0x93
|
|
*
|
|
* Track 1 and up, side 0, 4 sectors per track:
|
|
* 1x1024, 1x512, 1x2048, 1x8192 bytes per sector (0x83, 0x82, 084, 0x86 as sector numbers)
|
|
*
|
|
* Track 1 and up, side 1, 4 sectors per track:
|
|
* 1x2048, 1x512, 1x1024, 1x8192 bytes per sector (0x84, 0x82, 083, 0x86 as sector numbers)
|
|
*
|
|
* Notes regarding the order in which XDF sectors are read (from http://mail.netbridge.at/cgi-bin/info2www?(fdutils)XDF),
|
|
* where each position column represents a (roughly) 128-byte section of the track:
|
|
*
|
|
* 1 2 3 4
|
|
* 1234567890123456789012345678901234567890 (position)
|
|
* ----------------------------------------
|
|
* 6633332244444446666666666666666666666666 (side 0)
|
|
* 6666444444422333366666666666666666666666 (side 1)
|
|
*
|
|
* where 2's contain a 512-byte sector, 3's contain a 1Kb sector, 4's contains a 2Kb sector, and 6's contain an 8Kb sector.
|
|
*
|
|
* Reading all the data on an XDF cylinder occurs in the following order, from the specified start to end positions:
|
|
*
|
|
* sector head start end
|
|
* 3 0 3 7
|
|
* 4 0 9 16
|
|
* 6 1 18 5 (1st wrap around)
|
|
* 2 0 7 9
|
|
* 2 1 12 14
|
|
* 6 0 16 3 (2nd wrap around)
|
|
* 4 1 5 12
|
|
* 3 1 14 18
|
|
*/
|
|
if (fXDFOutput) nSectorsThisTrack = (iCylinder? 4 : 19);
|
|
|
|
for (var iSector=1, offSector=0; iSector <= nSectorsThisTrack && offSector < cbTrack; iSector++, offSector += cbSectorThisTrack) {
|
|
|
|
var sector = {};
|
|
var nSector = iSector;
|
|
|
|
if (fXDFOutput && iCylinder) {
|
|
if (!iHead) {
|
|
cbSectorThisTrack = (iSector == 1? 1024 : (iSector == 2? 512 : (iSector == 3? 2048 : 8192)));
|
|
} else {
|
|
cbSectorThisTrack = (iSector == 1? 8192 : (iSector == 2? 2048 : (iSector == 3? 1024 : 512)));
|
|
}
|
|
nSector = (cbSectorThisTrack == 512? 2 : (cbSectorThisTrack == 1024? 3 : (cbSectorThisTrack == 2048? 4 : 6)));
|
|
}
|
|
|
|
bufSector = bufTrack.slice(offSector, offSector + cbSectorThisTrack);
|
|
|
|
if (this.fJSONNative) {
|
|
sector['sector'] = nSector;
|
|
sector['length'] = cbSectorThisTrack;
|
|
} else {
|
|
json += (iSector == 1? this.dumpLine(2, "{") : "");
|
|
json += this.dumpLine(0, '"sector":' + this.sJSONWhitespace + nSector + ",");
|
|
json += this.dumpLine(0, '"length":' + this.sJSONWhitespace + cbSectorThisTrack + ",");
|
|
}
|
|
|
|
var aTrim = this.trimSector(bufSector, cbSectorThisTrack);
|
|
var dwPattern = aTrim[0];
|
|
var cbBuffer = cbSectorThisTrack;
|
|
if (dwPattern !== null) {
|
|
cbBuffer = aTrim[1];
|
|
if (this.fJSONNative) {
|
|
sector['pattern'] = dwPattern;
|
|
} else {
|
|
json += this.dumpLine(0, '"pattern":' + this.sJSONWhitespace + dwPattern + ",");
|
|
}
|
|
}
|
|
|
|
if (this.fJSONNative) {
|
|
var dataSector = [];
|
|
sector['data'] = dataSector;
|
|
for (var off = 0; off < cbBuffer; off += 4) {
|
|
dataSector.push(bufSector.readInt32LE(off));
|
|
}
|
|
aSectors[iSector-1] = sector;
|
|
} else {
|
|
if (this.sFormat == DumpAPI.FORMAT.BYTES) {
|
|
json += this.dumpBuffer("bytes", bufSector, cbBuffer, 1, offSector);
|
|
} else {
|
|
/*
|
|
* TODO: Assert that sFormat is FORMAT_JSON or FORMAT_DATA (both use the same dword format)
|
|
*/
|
|
json += this.dumpBuffer("data", bufSector, cbBuffer, 4, offSector);
|
|
}
|
|
json += (iSector < nSectorsThisTrack? this.dumpLine(0, "},{") : this.dumpLine(-2, "}"));
|
|
}
|
|
}
|
|
if (!this.fJSONNative) json += this.dumpLine(-2, "]" + (iHead+1 == nHeads? "" : ","));
|
|
offHead += cbTrack; // end of head {iHead}, track {iCylinder}
|
|
}
|
|
if (!this.fJSONNative) json += this.dumpLine(-2, "]" + (iCylinder+1 == nCylinders? "" : ","));
|
|
offTrack += offHead; // end of cylinder {iCylinder}
|
|
}
|
|
/*
|
|
* Here's where I used to output the following comment:
|
|
*
|
|
* // write-protected
|
|
*
|
|
* as the first line of the JSON stream if the disk was marked write-protected (ie, if (bByte0 & 0x1) != 0).
|
|
*
|
|
* But since that makes JSON.parse() sad, the preferred solution is to name read-only JSON disk images with a
|
|
* "-readonly" suffix.
|
|
*/
|
|
if (this.fJSONNative) {
|
|
json = JSON.stringify(this.dataDisk);
|
|
} else {
|
|
json += this.dumpLine(-2, "]");
|
|
}
|
|
this.jsonDisk = json;
|
|
}
|
|
else if (this.bufDisk.readUInt16BE(0x900) == 0x4357) {
|
|
this.jsonDisk = this.convertOSIDiskToJSON();
|
|
}
|
|
} catch(err) {
|
|
DiskDump.logError(err);
|
|
}
|
|
return this.jsonDisk;
|
|
};
|
|
|
|
/**
|
|
* convertOSIDiskToJSON()
|
|
*
|
|
* This is called when we detect a "CW" signature at offset 0x900 of bufDisk, so we'll try parsing the data
|
|
* as an OSI disk image, and output the data in JSON as an array of heads, each containing an array of tracks,
|
|
* like so:
|
|
*
|
|
* [ [ {
|
|
* trackSig:"CW",
|
|
* trackNum:0x01,
|
|
* trackType:0x58,
|
|
* trackLoad:0xnnnn,
|
|
* sectors:[
|
|
* { sectorSig:0x76,
|
|
* sectorNum:0x01,
|
|
* sectorPages:0x01,
|
|
* sectorEndSig:"GS",
|
|
* sectorData: [0x52,0x41,0x43,0x4b,...]
|
|
* },...
|
|
* ]
|
|
* },
|
|
* {
|
|
* trackSig:"CW",
|
|
* ...
|
|
* }
|
|
* ] ]
|
|
*
|
|
* TODO: If we ever add support for OSI drives/disk images with more than one head, we should change the disk image
|
|
* format to match that used by DOS disk images and PCjs; ie, an array of cylinders, each containing an array of heads,
|
|
* each containing an array of tracks. It's largely just a matter of swapping the two outermost array elements, both
|
|
* here and in the C1Pjs disk module.
|
|
*
|
|
* @this {DiskDump}
|
|
* @return {string|null} containing a JSON representation of the disk image, or null if unrecognized/malformed
|
|
*/
|
|
DiskDump.prototype.convertOSIDiskToJSON = function()
|
|
{
|
|
var json = null;
|
|
try {
|
|
var iTrack = 0;
|
|
var offTrack = 0;
|
|
var cbTrack = 0x900; // this is the raw track length for a 40-track 5.25-inch disk image
|
|
|
|
if (this.fJSONNative) {
|
|
json = "";
|
|
} else {
|
|
json = "/*\n * OSI DiskDump of " + this.sDiskPath + " via " + DiskDump.sNotice + "\n */\n";
|
|
}
|
|
json += this.dumpLine(2, "[");
|
|
json += this.dumpLine(2, "["); // begin array of heads
|
|
|
|
while (true) {
|
|
var bufSector;
|
|
var bufTrack = this.bufDisk.slice(offTrack, offTrack + cbTrack);
|
|
if (!bufTrack.length) {
|
|
if (iTrack) {
|
|
json += this.dumpLine(-2, "}");
|
|
}
|
|
break;
|
|
}
|
|
var nSectorPages;
|
|
if (!iTrack) {
|
|
/*
|
|
* Track 0 is first, with this format:
|
|
*
|
|
* 0x0000: track load address (high and low bytes of 16-bit address, respectively)
|
|
* 0x0002: number of pages (up to 8)
|
|
*/
|
|
var nTrackLoad = bufTrack.readUInt16BE(0);
|
|
json += this.dumpTrackOSI("", 0, null, nTrackLoad);
|
|
/*
|
|
* Track 0 supports only 1 sector; it has no nSectorSig (hence the first null), an implied
|
|
* sector number of 1, and no end signature (hence the second null).
|
|
*/
|
|
nSectorPages = bufTrack.readUInt8(2);
|
|
bufSector = bufTrack.slice(3, 3 + nSectorPages * 256);
|
|
json += this.dumpSectorOSI(null, 1, nSectorPages, bufSector, null, nTrackLoad);
|
|
json += this.dumpLine(-2, "}");
|
|
json += this.dumpLine(-2, "]");
|
|
}
|
|
else {
|
|
/*
|
|
* Track N is next, with this format:
|
|
*
|
|
* 0x0000: start-of-track signature "CW" (0x43,0x57)
|
|
* 0x0002: track number (in BCD); eg, 0x01
|
|
* 0x0003: track type code (0x58)
|
|
* <sector info begins>
|
|
* 0x0004: sector start code (0x76)
|
|
* 0x0005: sector number (in binary); eg, 0x01
|
|
* 0x0006: sector length (no. of pages, in binary); eg, 0x08
|
|
* 0x0007: <sector data begins>
|
|
* 0xnnnn: end-of-sector signature "GS" (0x47,0x53); eg, 0xnnnn is 0x0807, using the above examples.
|
|
*
|
|
* The next track is typically stored at the next page boundary (eg, 0x0900), which is why
|
|
* cbTrack is hard-coded to 0x900 above.
|
|
*
|
|
* Note that anything from 1 (large) sector to multiple (smaller) sectors can be stored in a single track,
|
|
* if the sector length byte at 0x0006 is less than 8; for example, if the first sector's length was only 1 page,
|
|
* then this would follow:
|
|
*
|
|
* 0x0107: end-of-sector signature "GS"
|
|
* <sector info begins>
|
|
* 0x0109: sector start code (0x76)
|
|
* 0x010A: sector number (in binary); eg, 0x02
|
|
* 0x010B: sector length (no. of pages, in binary); eg, 0x01
|
|
* 0x010C: <sector data begins>
|
|
* 0xnnnn: next end-of-sector signature "GS" (0x47,0x53); eg, 0x020C
|
|
*/
|
|
if (bufTrack.readUInt16BE(0) == 0x4357) {
|
|
var nSectorOffset = 0;
|
|
json += this.dumpLine(-2, "},");
|
|
json += this.dumpTrackOSI("CW", bufTrack.readUInt8(2), bufTrack.readUInt8(3));
|
|
bufTrack = bufTrack.slice(4);
|
|
while (bufTrack.length > 5 && bufTrack.readUInt8(0) == 0x76) {
|
|
nSectorPages = bufTrack.readUInt8(2);
|
|
var cbSector = nSectorPages * 256;
|
|
bufSector = bufTrack.slice(3, cbSector+3);
|
|
var sSectorEndSig = bufTrack.slice(cbSector+3, cbSector+5).toString("ascii");
|
|
if (nSectorOffset) json += this.dumpLine(-2, "},");
|
|
json += this.dumpSectorOSI(bufTrack.readUInt8(0), bufTrack.readUInt8(1), nSectorPages, bufSector, sSectorEndSig, nSectorOffset);
|
|
bufTrack = bufTrack.slice(cbSector+5);
|
|
nSectorOffset += cbSector;
|
|
}
|
|
json += this.dumpLine(-2, "}");
|
|
json += this.dumpLine(-2, "]");
|
|
}
|
|
else {
|
|
DiskDump.logError(new Error("unrecognized OSI disk track at 0x" + str.toHex(offTrack)));
|
|
break;
|
|
}
|
|
}
|
|
offTrack += cbTrack;
|
|
iTrack++;
|
|
}
|
|
json += this.dumpLine(-2, "]");
|
|
json += this.dumpLine(-2, "]");
|
|
} catch(err) {
|
|
DiskDump.logError(err);
|
|
}
|
|
return json;
|
|
};
|
|
|
|
/**
|
|
* convertToIMG()
|
|
*
|
|
* Converts the disk image data to a Buffer.
|
|
*
|
|
* TODO: Consider creating a caching mechanism for these requests (ie, stash a limited number of these
|
|
* disk images under /tmp, using a name based on a hash of the source path).
|
|
*
|
|
* @this {DiskDump}
|
|
* @return {Buffer|null} containing the disk image's raw data, or null if no data available (or parse error)
|
|
*/
|
|
DiskDump.prototype.convertToIMG = function()
|
|
{
|
|
if (!this.bufDisk) {
|
|
|
|
if (!this.dataDisk) {
|
|
if (!this.jsonDisk) {
|
|
return null;
|
|
}
|
|
try {
|
|
/*
|
|
* These replacements provide compatibility with older JSON disk images
|
|
* that were generated by convdisk.php and weren't entirely JSON-compatible.
|
|
*/
|
|
this.jsonDisk = this.jsonDisk.replace(/(sector|length|bytes|data|pattern):/g, '"$1":');
|
|
/*
|
|
* Comments can appear even when comments weren't requested; the only situation
|
|
* where that currently may occur is when a write-protected .DSK file is converted,
|
|
* requiring us to output a "write-protected" comment on the first line.
|
|
* A better solution requires revamping the disk image format or, better yet, updating
|
|
* the FDC component to implement a user-configurable option for write-protecting media.
|
|
*/
|
|
this.jsonDisk = this.jsonDisk.replace(/\/\/[^\n]*/g, "");
|
|
/*
|
|
* There are also some old files that also contain hex constants; eg:
|
|
*
|
|
* "pattern": 0xe5e5e5e5
|
|
*
|
|
* which must be converted to decimal before JSON.parse() will be happy.
|
|
* While I could sit here and search for all hex patterns and replace them,
|
|
* the proper solution is to simply reconvert those disk images.
|
|
*
|
|
* TODO: Generate a clear warning whenever "this.jsonDisk.indexOf("0x") >= 0".
|
|
*
|
|
* TODO: Remove the above transformations once we can be sure there are no more
|
|
* disk images with those legacy features.
|
|
*/
|
|
this.dataDisk = JSON.parse(this.jsonDisk);
|
|
} catch(err) {
|
|
DiskDump.logError(err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* The following code was adapted from the mount() method in disk.js, and assumes a homogeneous disk
|
|
* format with 512-byte sectors.
|
|
*
|
|
* TODO: Rework this code to support non-homogeneous disk formats (eg, variable sector sizes, variable
|
|
* sectors per track, etc).
|
|
*/
|
|
var buf = null;
|
|
|
|
try {
|
|
/*
|
|
* We need to be prepared for any number of errors due to malformed data; in fact, it's entirely
|
|
* possible the JSON we just parsed is NOT a disk image, which means nCylinders, nHeads, etc may be
|
|
* undefined, in which case an exception will occur almost immediately.
|
|
*/
|
|
var nCylinders = this.dataDisk.length;
|
|
var nHeads = this.dataDisk[0].length;
|
|
var nSectorsPerTrack = this.dataDisk[0][0].length;
|
|
var cbDisk = nCylinders * nHeads * nSectorsPerTrack * 512;
|
|
|
|
var off = 0;
|
|
buf = new Buffer(cbDisk);
|
|
|
|
/*
|
|
* WARNING: Buffers are NOT zero-initialized, so we need explicitly fill it with zeros (this seems to
|
|
* be a reversal in the trend to zero buffers, when security concerns used to trump performance concerns).
|
|
*/
|
|
buf.fill(0);
|
|
|
|
for (var iCylinder = 0; iCylinder < nCylinders; iCylinder++) {
|
|
for (var iHead = 0; iHead < this.dataDisk[iCylinder].length; iHead++) {
|
|
for (var iSector = 0; iSector < this.dataDisk[iCylinder][iHead].length; iSector++) {
|
|
var idw;
|
|
var sector = this.dataDisk[iCylinder][iHead][iSector];
|
|
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) {
|
|
/*
|
|
* It would be odd if there was neither a 'bytes' nor 'data' array; I'm just
|
|
* being paranoid. It's more likely that the 'bytes' array is simply empty,
|
|
* in which case we need only create an empty 'data' array and turn the byte
|
|
* pattern, if any, into a dword pattern.
|
|
*/
|
|
adw = [];
|
|
// if (DEBUG) this.assert((dwPattern & 0xff) == dwPattern);
|
|
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 ib;
|
|
var cb = length << 2;
|
|
for (ib = ab.length; ib < cb; ib++) {
|
|
ab[ib] = dwPattern; // the pattern for byte-arrays was only a byte
|
|
}
|
|
ib = 0;
|
|
adw = new Array(length);
|
|
for (idw = 0; idw < adw.length; idw++) {
|
|
adw[idw] = ab[ib] | (ab[ib + 1] << 8) | (ab[ib + 2] << 16) | (ab[ib + 3] << 24);
|
|
ib += 4;
|
|
}
|
|
}
|
|
delete sector['bytes'];
|
|
sector['data'] = adw;
|
|
}
|
|
/*
|
|
* Now the current sector has ALL of the following 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
|
|
*
|
|
* TODO: Honor the 'sector' property and dump the sectors in sector-number order.
|
|
*/
|
|
for (idw = 0; idw < length; idw++) {
|
|
var dw = (idw < adw.length? adw[idw] : dwPattern);
|
|
buf.writeInt32LE(dw, off);
|
|
off += 4;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/*
|
|
* Since there's no way (and rightly so) of setting fDebug via the API, I've added the check for
|
|
* fJSONComments as another way of disabling "branding" via the API; requesting an IMG file with comments
|
|
* is otherwise a nonsensical request.
|
|
*/
|
|
if (!fDebug && !this.fJSONComments && buf.length < 3000000) { // arbitrary size threshold between diskette images and hard disk images
|
|
/*
|
|
* Mimic the BPB test in convertToJSON(), because we don't want to blast an OEM string into non-DOS diskette images
|
|
*/
|
|
var bByte0 = buf.readUInt8(DiskAPI.BOOT.JMP_OPCODE);
|
|
var cbSectorBPB = buf.readUInt16LE(DiskAPI.BPB.SECTOR_BYTES);
|
|
if ((bByte0 == X86.OPCODE.JMP || bByte0 == X86.OPCODE.JMPS) && cbSectorBPB == 512) {
|
|
/*
|
|
* Overwrite the OEM string with our own, so that people know how the image originated
|
|
*/
|
|
buf.write(DiskDump.MY_OEM_STRING, DiskAPI.BOOT.OEM_STRING, DiskDump.MY_OEM_STRING.length);
|
|
}
|
|
}
|
|
} catch(err) {
|
|
DiskDump.logError(err);
|
|
return null;
|
|
}
|
|
|
|
this.bufDisk = buf;
|
|
}
|
|
return this.bufDisk;
|
|
};
|
|
|
|
module.exports = DiskDump;
|