apple2js/js/formats/2mg.ts
Ian Flanigan c9b7987c4c
Fix 2mg header parsing and add tests (#127)
Before, the offset for `FLAGS` in `2mg.ts` was `0x0A`, which is
incorrect according to the spec at:

https://apple2.org.za/gswv/a2zine/Docs/DiskImage_2MG_Info.txt

Now, all of the fields in the 2mg header are described, including
their lengths and any constraints.  These constraints are enforced by
`read2MGHeader` and tested by new tests.
2022-06-06 18:10:06 -07:00

190 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import DOS from './do';
import Nibble from './nib';
import ProDOS from './po';
import { DiskOptions } from './types';
import { numToString } from '../util';
import { ReadonlyUint8Array } from 'js/types';
/**
* 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:
*
* https://apple2.org.za/gswv/a2zine/Docs/DiskImage_2MG_Info.txt
*/
const OFFSETS = {
/** File signature ('2IMG', 4 bytes) */
SIGNATURE: 0x00,
/** Creator ID (4 bytes) */
CREATOR: 0x04,
/** Header length (2 bytes) */
HEADER_LENGTH: 0x08,
/** Version number (2 bytes). (Version of what? Format? Image?). */
VERSION: 0x0A,
/** Image format ID (4 bytes) */
FORMAT: 0x0C,
/** 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.)
*/
BLOCKS: 0x14,
/**
* Disk data start in bytes from the beginning of the image file
* (4 bytes).
*/
DATA_OFFSET: 0x18,
/**
* 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;
const FLAGS = {
READ_ONLY: 0x80000000,
VOLUME_VALID: 0x00000100,
VOLUME_MASK: 0x000000FF
} as const;
enum FORMAT {
DOS = 0,
ProDOS = 1,
NIB = 2,
}
export function read2MGHeader(rawData: ArrayBuffer) {
const prefix = new DataView(rawData);
const signature = numToString(prefix.getInt32(OFFSETS.SIGNATURE, true));
if (signature !== '2IMG') {
throw new Error(`Unrecognized 2mg signature: ${signature}`);
}
const headerLength = prefix.getInt16(OFFSETS.HEADER_LENGTH, true);
if (headerLength !== 64) {
throw new Error(`2mg header length is incorrect ${headerLength} !== 63`);
}
const creator = numToString(prefix.getInt32(OFFSETS.CREATOR, true));
const format = prefix.getInt32(OFFSETS.FORMAT, true);
const flags = prefix.getInt32(OFFSETS.FLAGS, true);
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);
}
const readOnly = (flags & FLAGS.READ_ONLY) !== 0;
let volume = 254;
if (flags & FLAGS.VOLUME_VALID) {
volume = flags & FLAGS.VOLUME_MASK;
}
return {
bytes,
creator,
format,
offset,
readOnly,
volume,
...extras
};
}
/**
* 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) {
case FORMAT.ProDOS: // PO
disk = ProDOS(options);
break;
case FORMAT.NIB: // NIB
disk = Nibble(options);
break;
case FORMAT.DOS: // dsk
default: // Something hinky, assume 'dsk'
disk = DOS(options);
break;
}
return disk;
}