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.
This commit is contained in:
parent
66f3e04d8e
commit
c9b7987c4c
|
@ -3,42 +3,146 @@ import Nibble from './nib';
|
|||
import ProDOS from './po';
|
||||
import { DiskOptions } from './types';
|
||||
|
||||
import { numToString, debug } from '../util';
|
||||
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,
|
||||
FLAGS: 0x0A,
|
||||
/** 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,
|
||||
BYTES: 0x1C,
|
||||
};
|
||||
/**
|
||||
* 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(0x0, true));
|
||||
const signature = numToString(prefix.getInt32(OFFSETS.SIGNATURE, true));
|
||||
if (signature !== '2IMG') {
|
||||
throw new Error('Unrecognized 2mg signature: ' + signature);
|
||||
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 bytes = prefix.getInt32(OFFSETS.BYTES, true);
|
||||
const offset = prefix.getInt32(OFFSETS.DATA_OFFSET, 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;
|
||||
}
|
||||
|
||||
debug('created by', creator);
|
||||
|
||||
return {
|
||||
bytes,
|
||||
creator,
|
||||
|
@ -46,6 +150,7 @@ export function read2MGHeader(rawData: ArrayBuffer) {
|
|||
offset,
|
||||
readOnly,
|
||||
volume,
|
||||
...extras
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -68,13 +173,13 @@ export default function createDiskFrom2MG(options: DiskOptions) {
|
|||
// Check image format.
|
||||
// Sure, it's really 64 bits. But only 2 are actually used.
|
||||
switch (format) {
|
||||
case 1: // PO
|
||||
case FORMAT.ProDOS: // PO
|
||||
disk = ProDOS(options);
|
||||
break;
|
||||
case 2: // NIB
|
||||
case FORMAT.NIB: // NIB
|
||||
disk = Nibble(options);
|
||||
break;
|
||||
case 0: // dsk
|
||||
case FORMAT.DOS: // dsk
|
||||
default: // Something hinky, assume 'dsk'
|
||||
disk = DOS(options);
|
||||
break;
|
||||
|
|
11
js/util.ts
11
js/util.ts
|
@ -44,6 +44,17 @@ export function bytify(ary: number[]): memory {
|
|||
return new Uint8Array(ary);
|
||||
}
|
||||
|
||||
/** Returns a new Uint8Array with the concatenated data from the inputs. */
|
||||
export function concat(...arys: Array<byte[] | Uint8Array>) {
|
||||
const result = new Uint8Array(arys.reduce((l, ary) => l + ary.length, 0));
|
||||
let offset = 0;
|
||||
for (let i = 0; i < arys.length; i++) {
|
||||
result.set(arys[i], offset);
|
||||
offset += arys[i].length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Writes to the console. */
|
||||
export function debug(...args: unknown[]): void {
|
||||
console.log(...args);
|
||||
|
|
|
@ -0,0 +1,103 @@
|
|||
import { read2MGHeader } from 'js/formats/2mg';
|
||||
import { concat } from 'js/util';
|
||||
import { BYTES_BY_SECTOR_IMAGE } from './testdata/16sector';
|
||||
|
||||
const INVALID_SIGNATURE_IMAGE = new Uint8Array([
|
||||
0x11, 0x22, 0x33, 0x44
|
||||
]);
|
||||
|
||||
const INVALID_HEADER_LENGTH_IMAGE = new Uint8Array([
|
||||
// ID
|
||||
0x32, 0x49, 0x4d, 0x47,
|
||||
// Creator ID
|
||||
0x58, 0x47, 0x53, 0x21,
|
||||
// Header size
|
||||
0x0a, 0x00
|
||||
]);
|
||||
|
||||
const VALID_PRODOS_IMAGE = concat(new Uint8Array([
|
||||
// ID
|
||||
0x32, 0x49, 0x4d, 0x47,
|
||||
// Creator ID
|
||||
0x58, 0x47, 0x53, 0x21,
|
||||
// Header size
|
||||
0x40, 0x00,
|
||||
// Version number
|
||||
0x01, 0x00,
|
||||
// Image format (ProDOS)
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
// Flags
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
// ProDOS blocks
|
||||
0x18, 0x01, 0x00, 0x00,
|
||||
// Data offset
|
||||
0x40, 0x00, 0x00, 0x00,
|
||||
// Data length (in bytes)
|
||||
0x00, 0x30, 0x02, 0x00,
|
||||
// Comment offset
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
// Comment length
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
// Creator data offset
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
// Creator data length
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
// Padding
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
]), BYTES_BY_SECTOR_IMAGE);
|
||||
|
||||
describe('2mg format', () => {
|
||||
describe('read2MGHeader', () => {
|
||||
it('throws if the signature is invalid', () => {
|
||||
expect(() => read2MGHeader(INVALID_SIGNATURE_IMAGE.buffer)).toThrow(/signature/);
|
||||
});
|
||||
|
||||
it('throws if the header length is invalid', () => {
|
||||
expect(() => read2MGHeader(INVALID_HEADER_LENGTH_IMAGE.buffer)).toThrowError(/header length/);
|
||||
});
|
||||
|
||||
it('throws if block count is not correct for ProDOS image', () => {
|
||||
const image = new Uint8Array(VALID_PRODOS_IMAGE);
|
||||
image[0x14] = image[0x14] + 1;
|
||||
expect(() => read2MGHeader(image.buffer)).toThrowError(/blocks/);
|
||||
});
|
||||
|
||||
it('throws if comment comes before end of disk data', () => {
|
||||
const image = new Uint8Array(VALID_PRODOS_IMAGE);
|
||||
image[0x20] = 1;
|
||||
expect(() => read2MGHeader(image.buffer)).toThrowError(/is before/);
|
||||
});
|
||||
|
||||
it('throws if creator data comes before end of disk data', () => {
|
||||
const image = new Uint8Array(VALID_PRODOS_IMAGE);
|
||||
image[0x28] = 1;
|
||||
expect(() => read2MGHeader(image.buffer)).toThrowError(/is before/);
|
||||
});
|
||||
|
||||
it('throws if data length is too big for file', () => {
|
||||
const image = new Uint8Array(VALID_PRODOS_IMAGE);
|
||||
image[0x1D] += 2; // Increment byte length by 512
|
||||
image[0x14] += 1; // Increment block length by 1
|
||||
expect(() => read2MGHeader(image.buffer)).toThrowError(/extends beyond/);
|
||||
});
|
||||
|
||||
it('returns a header for a valid ProDOS image', () => {
|
||||
expect(read2MGHeader(VALID_PRODOS_IMAGE.buffer)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns a filled-in header for a valid ProDOS image', () => {
|
||||
const header = read2MGHeader(VALID_PRODOS_IMAGE.buffer);
|
||||
expect(header.creator).toBe('XGS!');
|
||||
expect(header.bytes).toBe(143_360);
|
||||
expect(header.offset).toBe(64);
|
||||
expect(header.format).toBe(1);
|
||||
expect(header.readOnly).toBeFalsy();
|
||||
expect(header.volume).toBe(254);
|
||||
expect(header.comment).toBeUndefined();
|
||||
expect(header.creatorData).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,4 +1,4 @@
|
|||
import { BYTES_BY_SECTOR, BYTES_BY_TRACK, BYTES_IN_ORDER } from './16sector';
|
||||
import { BYTES_BY_SECTOR, BYTES_BY_SECTOR_IMAGE, BYTES_BY_TRACK, BYTES_BY_TRACK_IMAGE, BYTES_IN_ORDER } from './16sector';
|
||||
|
||||
describe('BYTES_IN_ORDER', () => {
|
||||
it('has the correct bytes in track 0, sector 0, byte 0 and byte 1', () => {
|
||||
|
@ -58,6 +58,35 @@ describe('BYTES_BY_SECTOR', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('BYTES_BY_SECTOR_IMAGE', () => {
|
||||
it('has the correct bytes in track 0, sector 0, byte 0 and byte 1', () => {
|
||||
const image = BYTES_BY_SECTOR_IMAGE;
|
||||
expect(image[0]).toBe(0);
|
||||
expect(image[1]).toBe(0);
|
||||
});
|
||||
|
||||
it('has the correct bytes in track 0, sector 0', () => {
|
||||
const image = BYTES_BY_SECTOR_IMAGE;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(image[i]).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('has the correct bytes in track 1, sector 0', () => {
|
||||
const image = BYTES_BY_SECTOR_IMAGE;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(image[1 * 16 * 256 + i]).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('has the correct bytes in track 30, sector 11', () => {
|
||||
const disk = BYTES_BY_SECTOR_IMAGE;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(disk[((30 * 16) + 11) * 256 + i]).toBe(11);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('BYTES_BY_TRACK', () => {
|
||||
it('has the correct bytes in track 0, sector 0, byte 0 and byte 1', () => {
|
||||
const disk = BYTES_BY_TRACK;
|
||||
|
@ -85,4 +114,33 @@ describe('BYTES_BY_TRACK', () => {
|
|||
expect(disk[30][11][i]).toBe(30);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BYTES_BY_TRACK_IMAGE', () => {
|
||||
it('has the correct bytes in track 0, sector 0, byte 0 and byte 1', () => {
|
||||
const image = BYTES_BY_TRACK_IMAGE;
|
||||
expect(image[0]).toBe(0);
|
||||
expect(image[1]).toBe(0);
|
||||
});
|
||||
|
||||
it('has the correct bytes in track 0, sector 0', () => {
|
||||
const image = BYTES_BY_TRACK_IMAGE;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(image[i]).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('has the correct bytes in track 1, sector 0', () => {
|
||||
const image = BYTES_BY_TRACK_IMAGE;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(image[i + 256 * 16]).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('has the correct bytes in track 30, sector 11', () => {
|
||||
const image = BYTES_BY_TRACK_IMAGE;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(image[i + ((30 * 16) + 11) * 256]).toBe(30);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { memory } from 'js/types';
|
||||
import { concat } from 'js/util';
|
||||
|
||||
function generateBytesInOrder() {
|
||||
const data: memory[][] = [];
|
||||
|
@ -53,3 +54,25 @@ function generateBytesByTrack() {
|
|||
}
|
||||
|
||||
export const BYTES_BY_TRACK: memory[][] = generateBytesByTrack();
|
||||
|
||||
function toImage(disk: memory[][]) {
|
||||
const tracks: Uint8Array[] = [];
|
||||
for (let t = 0; t < disk.length; t++) {
|
||||
const track = concat(...disk[t]);
|
||||
tracks.push(track);
|
||||
}
|
||||
return concat(...tracks);
|
||||
}
|
||||
|
||||
export const BYTES_BY_SECTOR_IMAGE = toImage(BYTES_BY_SECTOR);
|
||||
export const BYTES_BY_TRACK_IMAGE = toImage(BYTES_BY_TRACK);
|
||||
|
||||
function randomImage() {
|
||||
const result = new Uint8Array(35 * 16 * 256);
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
result[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const RANDOM_IMAGE = randomImage();
|
||||
|
|
Loading…
Reference in New Issue