2021-07-07 00:04:02 +00:00
|
|
|
|
import DOS from './do';
|
|
|
|
|
import Nibble from './nib';
|
|
|
|
|
import ProDOS from './po';
|
2022-06-22 03:34:19 +00:00
|
|
|
|
import { BlockDisk, DiskOptions } from './types';
|
2021-07-07 00:04:02 +00:00
|
|
|
|
|
2022-06-22 03:34:19 +00:00
|
|
|
|
import { byte, ReadonlyUint8Array } from 'js/types';
|
2021-07-07 00:04:02 +00:00
|
|
|
|
|
2022-06-07 01:10:06 +00:00
|
|
|
|
/**
|
|
|
|
|
* Offsets in bytes to the various header fields. All number fields are
|
|
|
|
|
* in little-endian order (least significant byte first). These values
|
|
|
|
|
* come from the spec at:
|
2022-06-22 03:34:19 +00:00
|
|
|
|
*
|
2022-06-07 01:10:06 +00:00
|
|
|
|
* https://apple2.org.za/gswv/a2zine/Docs/DiskImage_2MG_Info.txt
|
|
|
|
|
*/
|
2021-07-07 00:04:02 +00:00
|
|
|
|
const OFFSETS = {
|
2022-06-07 01:10:06 +00:00
|
|
|
|
/** File signature ('2IMG', 4 bytes) */
|
|
|
|
|
SIGNATURE: 0x00,
|
|
|
|
|
/** Creator ID (4 bytes) */
|
2021-07-07 00:04:02 +00:00
|
|
|
|
CREATOR: 0x04,
|
2022-06-07 01:10:06 +00:00
|
|
|
|
/** Header length (2 bytes) */
|
|
|
|
|
HEADER_LENGTH: 0x08,
|
|
|
|
|
/** Version number (2 bytes). (Version of what? Format? Image?). */
|
|
|
|
|
VERSION: 0x0A,
|
|
|
|
|
/** Image format ID (4 bytes) */
|
2021-07-07 00:04:02 +00:00
|
|
|
|
FORMAT: 0x0C,
|
2022-06-07 01:10:06 +00:00
|
|
|
|
/** Flags and DOS 3.3 volume number */
|
|
|
|
|
FLAGS: 0x10,
|
|
|
|
|
/**
|
|
|
|
|
* Number of ProDOS blocks (4 bytes). ProDOS blocks are 512 bytes each.
|
|
|
|
|
* This field must be zero if the image format is not 0x01 (ProDOS).
|
|
|
|
|
* (ASIMOV2 always fills in this field.)
|
|
|
|
|
*/
|
2021-07-07 00:04:02 +00:00
|
|
|
|
BLOCKS: 0x14,
|
2022-06-07 01:10:06 +00:00
|
|
|
|
/**
|
|
|
|
|
* Disk data start in bytes from the beginning of the image file
|
|
|
|
|
* (4 bytes).
|
|
|
|
|
*/
|
2021-07-07 00:04:02 +00:00
|
|
|
|
DATA_OFFSET: 0x18,
|
2022-06-07 01:10:06 +00:00
|
|
|
|
/**
|
|
|
|
|
* Length of disk data in bytes (4 bytes). (143,360 bytes for 5.25"
|
|
|
|
|
* floppies; 512 × blocks for ProDOS volumes.)
|
|
|
|
|
*/
|
|
|
|
|
DATA_LENGTH: 0x1C,
|
|
|
|
|
/**
|
|
|
|
|
* Comment start in bytes from the beginning of the image file (4 bytes).
|
|
|
|
|
* Must be zero if there is no comment. The comment must come after the
|
|
|
|
|
* disk data and before the creator data. The comment should be "raw text"
|
|
|
|
|
* with no terminating null. By "raw text", we assume UTF-8.
|
|
|
|
|
*/
|
|
|
|
|
COMMENT: 0x20,
|
|
|
|
|
/**
|
|
|
|
|
* Comment length in bytes (4 bytes). Must be zero if there is no comment.
|
|
|
|
|
*/
|
|
|
|
|
COMMENT_LENGTH: 0x24,
|
|
|
|
|
/**
|
|
|
|
|
* Optional creator data start in bytes from the beginning of the image
|
|
|
|
|
* file (4 bytes). Must be zero if there is no creator data.
|
|
|
|
|
*/
|
|
|
|
|
CREATOR_DATA: 0x28,
|
|
|
|
|
/**
|
|
|
|
|
* Creator data length in bytes (4 bytes). Must be zero if there is no
|
|
|
|
|
* creator data.
|
|
|
|
|
*/
|
|
|
|
|
CREATOR_DATA_LENGTH: 0x2C,
|
|
|
|
|
/** Padding (16 bytes). Must be zero. */
|
|
|
|
|
PADDING: 0x30,
|
|
|
|
|
} as const;
|
2021-07-07 00:04:02 +00:00
|
|
|
|
|
|
|
|
|
const FLAGS = {
|
|
|
|
|
READ_ONLY: 0x80000000,
|
|
|
|
|
VOLUME_VALID: 0x00000100,
|
|
|
|
|
VOLUME_MASK: 0x000000FF
|
2022-06-07 01:10:06 +00:00
|
|
|
|
} as const;
|
|
|
|
|
|
2022-06-22 03:34:19 +00:00
|
|
|
|
export enum FORMAT {
|
2022-06-07 01:10:06 +00:00
|
|
|
|
DOS = 0,
|
|
|
|
|
ProDOS = 1,
|
|
|
|
|
NIB = 2,
|
|
|
|
|
}
|
2021-07-07 00:04:02 +00:00
|
|
|
|
|
2022-06-22 03:34:19 +00:00
|
|
|
|
export interface HeaderData {
|
|
|
|
|
bytes: number;
|
|
|
|
|
creator: string;
|
|
|
|
|
format: FORMAT;
|
|
|
|
|
offset: number;
|
|
|
|
|
readOnly: boolean;
|
|
|
|
|
volume: byte;
|
|
|
|
|
comment?: string;
|
|
|
|
|
creatorData?: ReadonlyUint8Array;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function read2MGHeader(rawData: ArrayBuffer): HeaderData {
|
2021-07-07 00:04:02 +00:00
|
|
|
|
const prefix = new DataView(rawData);
|
2022-06-22 03:34:19 +00:00
|
|
|
|
const decoder = new TextDecoder('ascii');
|
|
|
|
|
const signature = decoder.decode(rawData.slice(OFFSETS.SIGNATURE, OFFSETS.SIGNATURE + 4));
|
2021-07-07 00:04:02 +00:00
|
|
|
|
if (signature !== '2IMG') {
|
2022-06-07 01:10:06 +00:00
|
|
|
|
throw new Error(`Unrecognized 2mg signature: ${signature}`);
|
|
|
|
|
}
|
2022-06-22 03:34:19 +00:00
|
|
|
|
const creator = decoder.decode(rawData.slice(OFFSETS.CREATOR, OFFSETS.CREATOR + 4));
|
2022-06-07 01:10:06 +00:00
|
|
|
|
const headerLength = prefix.getInt16(OFFSETS.HEADER_LENGTH, true);
|
|
|
|
|
if (headerLength !== 64) {
|
2022-06-22 03:34:19 +00:00
|
|
|
|
throw new Error(`2mg header length is incorrect ${headerLength} !== 64`);
|
2021-07-07 00:04:02 +00:00
|
|
|
|
}
|
|
|
|
|
const format = prefix.getInt32(OFFSETS.FORMAT, true);
|
|
|
|
|
const flags = prefix.getInt32(OFFSETS.FLAGS, true);
|
2022-06-07 01:10:06 +00:00
|
|
|
|
const blocks = prefix.getInt32(OFFSETS.BLOCKS, true);
|
|
|
|
|
const offset = prefix.getInt32(OFFSETS.DATA_OFFSET, true);
|
|
|
|
|
const bytes = prefix.getInt32(OFFSETS.DATA_LENGTH, true);
|
|
|
|
|
const commentOffset = prefix.getInt32(OFFSETS.COMMENT, true);
|
|
|
|
|
const commentLength = prefix.getInt32(OFFSETS.COMMENT_LENGTH, true);
|
|
|
|
|
const creatorDataOffset = prefix.getInt32(OFFSETS.CREATOR_DATA, true);
|
|
|
|
|
const creatorDataLength = prefix.getInt32(OFFSETS.CREATOR_DATA_LENGTH, true);
|
|
|
|
|
|
|
|
|
|
// Though the spec says that it should be zero if the format is not
|
|
|
|
|
// ProDOS, we don't check that since we know that it is violated.
|
|
|
|
|
// However we do check that it's correct if the image _is_ ProDOS.
|
|
|
|
|
if (format === FORMAT.ProDOS && blocks * 512 !== bytes) {
|
|
|
|
|
throw new Error(`2mg blocks does not match disk data length: ${blocks} * 512 !== ${bytes}`);
|
|
|
|
|
}
|
|
|
|
|
if (offset < headerLength) {
|
|
|
|
|
throw new Error(`2mg data offset is less than header length: ${offset} < ${headerLength}`);
|
|
|
|
|
}
|
|
|
|
|
if (offset + bytes > prefix.byteLength) {
|
|
|
|
|
throw new Error(`2mg data extends beyond disk image: ${offset} + ${bytes} > ${prefix.byteLength}`);
|
|
|
|
|
}
|
|
|
|
|
const dataEnd = offset + bytes;
|
|
|
|
|
if (commentOffset && commentOffset < dataEnd) {
|
|
|
|
|
throw new Error(`2mg comment is before the end of the disk data: ${commentOffset} < ${offset} + ${bytes}`);
|
|
|
|
|
}
|
|
|
|
|
const commentEnd = commentOffset ? commentOffset + commentLength : dataEnd;
|
|
|
|
|
if (commentEnd > prefix.byteLength) {
|
|
|
|
|
throw new Error(`2mg comment extends beyond disk image: ${commentEnd} > ${prefix.byteLength}`);
|
|
|
|
|
}
|
|
|
|
|
if (creatorDataOffset && creatorDataOffset < commentEnd) {
|
|
|
|
|
throw new Error(`2mg creator data is before the end of the comment: ${creatorDataOffset} < ${commentEnd}`);
|
|
|
|
|
}
|
|
|
|
|
const creatorDataEnd = creatorDataOffset ? creatorDataOffset + creatorDataLength : commentEnd;
|
|
|
|
|
if (creatorDataEnd > prefix.byteLength) {
|
|
|
|
|
throw new Error(`2mg creator data extends beyond disk image: ${creatorDataEnd} > ${prefix.byteLength}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const extras: { comment?: string; creatorData?: ReadonlyUint8Array } = {};
|
|
|
|
|
if (commentOffset) {
|
|
|
|
|
extras.comment = new TextDecoder('utf-8').decode(
|
|
|
|
|
new Uint8Array(rawData, commentOffset, commentLength));
|
|
|
|
|
}
|
|
|
|
|
if (creatorDataOffset) {
|
|
|
|
|
extras.creatorData = new Uint8Array(rawData, creatorDataOffset, creatorDataLength);
|
|
|
|
|
}
|
|
|
|
|
|
2021-07-07 00:04:02 +00:00
|
|
|
|
const readOnly = (flags & FLAGS.READ_ONLY) !== 0;
|
2022-06-22 03:34:19 +00:00
|
|
|
|
let volume = format === FORMAT.DOS ? 254 : 0;
|
2021-07-07 00:04:02 +00:00
|
|
|
|
if (flags & FLAGS.VOLUME_VALID) {
|
|
|
|
|
volume = flags & FLAGS.VOLUME_MASK;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
bytes,
|
|
|
|
|
creator,
|
|
|
|
|
format,
|
|
|
|
|
offset,
|
|
|
|
|
readOnly,
|
|
|
|
|
volume,
|
2022-06-07 01:10:06 +00:00
|
|
|
|
...extras
|
2021-07-07 00:04:02 +00:00
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2022-06-22 03:34:19 +00:00
|
|
|
|
/**
|
|
|
|
|
* Creates the prefix and suffix parts of a 2mg file. Will use
|
|
|
|
|
* default header values if headerData is null.
|
|
|
|
|
*
|
|
|
|
|
* Currently only supports blocks disks but should be adaptable
|
|
|
|
|
* for nibble formats.
|
|
|
|
|
*
|
|
|
|
|
* @param headerData 2mg header data
|
|
|
|
|
* @param blocks The number of blocks in a block volume
|
|
|
|
|
* @returns 2mg prefix and suffix for creating a 2mg disk image
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
export const create2MGFragments = (headerData: HeaderData | null, { blocks } : { blocks: number }) => {
|
|
|
|
|
if (!headerData) {
|
|
|
|
|
headerData = {
|
|
|
|
|
bytes: blocks * 512,
|
|
|
|
|
creator: 'A2JS',
|
|
|
|
|
format: FORMAT.ProDOS,
|
|
|
|
|
offset: 64,
|
|
|
|
|
readOnly: false,
|
|
|
|
|
volume: 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (headerData.format !== FORMAT.ProDOS) {
|
|
|
|
|
throw new Error('Nibble formats not supported yet');
|
|
|
|
|
}
|
|
|
|
|
if (headerData.bytes !== blocks * 512) {
|
|
|
|
|
throw new Error('Byte count does not match block count');
|
|
|
|
|
}
|
|
|
|
|
const prefix = new Uint8Array(64);
|
|
|
|
|
const prefixView = new DataView(prefix.buffer);
|
|
|
|
|
|
|
|
|
|
const volumeFlags = headerData.volume ? headerData.volume | FLAGS.VOLUME_VALID : 0;
|
|
|
|
|
const readOnlyFlag = headerData.readOnly ? FLAGS.READ_ONLY : 0;
|
|
|
|
|
const flags = volumeFlags | readOnlyFlag;
|
|
|
|
|
const prefixLength = prefix.length;
|
|
|
|
|
const dataLength = blocks * 512;
|
|
|
|
|
|
|
|
|
|
let commentOffset = 0;
|
|
|
|
|
let commentLength = 0;
|
|
|
|
|
let commentData = new Uint8Array(0);
|
|
|
|
|
if (headerData.comment) {
|
|
|
|
|
commentData = new TextEncoder().encode(headerData.comment);
|
|
|
|
|
commentOffset = prefixLength + dataLength;
|
|
|
|
|
commentLength = commentData.length;
|
|
|
|
|
}
|
|
|
|
|
let creatorDataOffset = 0;
|
|
|
|
|
let creatorDataLength = 0;
|
|
|
|
|
let creatorData = new Uint8Array(0);
|
|
|
|
|
if (headerData.creatorData) {
|
|
|
|
|
creatorData = new Uint8Array(headerData.creatorData);
|
|
|
|
|
creatorDataOffset = prefixLength + dataLength + commentLength;
|
|
|
|
|
creatorDataLength = headerData.creatorData.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const encoder = new TextEncoder();
|
|
|
|
|
|
|
|
|
|
prefix.set(encoder.encode('2IMG'), OFFSETS.SIGNATURE);
|
|
|
|
|
prefix.set(encoder.encode(headerData.creator.slice(0, 4)), OFFSETS.CREATOR);
|
|
|
|
|
prefixView.setInt32(OFFSETS.HEADER_LENGTH, 64, true);
|
|
|
|
|
prefixView.setInt16(OFFSETS.VERSION, 1, true);
|
|
|
|
|
prefixView.setInt32(OFFSETS.FORMAT, headerData.format, true);
|
|
|
|
|
prefixView.setInt32(OFFSETS.FLAGS, flags, true);
|
|
|
|
|
prefixView.setInt32(OFFSETS.BLOCKS, blocks, true);
|
|
|
|
|
prefixView.setInt32(OFFSETS.DATA_OFFSET, prefixLength, true);
|
|
|
|
|
prefixView.setInt32(OFFSETS.DATA_LENGTH, dataLength, true);
|
|
|
|
|
prefixView.setInt32(OFFSETS.COMMENT, commentOffset, true);
|
|
|
|
|
prefixView.setInt32(OFFSETS.COMMENT_LENGTH, commentLength, true);
|
|
|
|
|
prefixView.setInt32(OFFSETS.CREATOR_DATA, creatorDataOffset, true);
|
|
|
|
|
prefixView.setInt32(OFFSETS.CREATOR_DATA_LENGTH, creatorDataLength, true);
|
|
|
|
|
|
|
|
|
|
const suffix = new Uint8Array(commentLength + creatorDataLength);
|
|
|
|
|
suffix.set(commentData);
|
|
|
|
|
suffix.set(creatorData, commentLength);
|
|
|
|
|
|
|
|
|
|
return { prefix, suffix };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Creates a 2MG image from stored 2MG header data and a block disk. Will use
|
|
|
|
|
* default header values if headerData is null.
|
|
|
|
|
*
|
|
|
|
|
* @param headerData 2MG style header data
|
|
|
|
|
* @param blocks Prodos volume blocks
|
|
|
|
|
* @returns 2MS
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
export const create2MGFromBlockDisk = (headerData: HeaderData | null, { blocks }: BlockDisk): ArrayBuffer => {
|
|
|
|
|
const { prefix, suffix } = create2MGFragments(headerData, { blocks: blocks.length });
|
|
|
|
|
|
|
|
|
|
const imageLength = prefix.length + blocks.length * 512 + suffix.length;
|
|
|
|
|
const byteArray = new Uint8Array(imageLength);
|
|
|
|
|
byteArray.set(prefix);
|
|
|
|
|
for (let idx = 0; idx < blocks.length; idx++) {
|
|
|
|
|
byteArray.set(blocks[idx], prefix.length + idx * 512);
|
|
|
|
|
}
|
|
|
|
|
byteArray.set(suffix, prefix.length + blocks.length * 512);
|
|
|
|
|
|
|
|
|
|
return byteArray.buffer;
|
|
|
|
|
};
|
|
|
|
|
|
2021-07-07 00:04:02 +00:00
|
|
|
|
/**
|
|
|
|
|
* Returns a `Disk` object from a 2mg image.
|
|
|
|
|
* @param options the disk image and options
|
|
|
|
|
*/
|
|
|
|
|
export default function createDiskFrom2MG(options: DiskOptions) {
|
|
|
|
|
let { rawData } = options;
|
|
|
|
|
let disk;
|
|
|
|
|
|
|
|
|
|
if (!rawData) {
|
|
|
|
|
throw new Error('Requires rawData');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { bytes, format, offset, readOnly, volume } = read2MGHeader(rawData);
|
|
|
|
|
rawData = rawData.slice(offset, offset + bytes);
|
|
|
|
|
options = { ...options, rawData, readOnly, volume };
|
|
|
|
|
|
|
|
|
|
// Check image format.
|
|
|
|
|
// Sure, it's really 64 bits. But only 2 are actually used.
|
|
|
|
|
switch (format) {
|
2022-06-07 01:10:06 +00:00
|
|
|
|
case FORMAT.ProDOS: // PO
|
2021-07-07 00:04:02 +00:00
|
|
|
|
disk = ProDOS(options);
|
|
|
|
|
break;
|
2022-06-07 01:10:06 +00:00
|
|
|
|
case FORMAT.NIB: // NIB
|
2021-07-07 00:04:02 +00:00
|
|
|
|
disk = Nibble(options);
|
|
|
|
|
break;
|
2022-06-07 01:10:06 +00:00
|
|
|
|
case FORMAT.DOS: // dsk
|
2021-07-07 00:04:02 +00:00
|
|
|
|
default: // Something hinky, assume 'dsk'
|
|
|
|
|
disk = DOS(options);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return disk;
|
|
|
|
|
}
|