diff --git a/js/formats/2mg.ts b/js/formats/2mg.ts index ea65dee..bfd7e70 100644 --- a/js/formats/2mg.ts +++ b/js/formats/2mg.ts @@ -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; diff --git a/js/util.ts b/js/util.ts index 3302ca3..3b51d22 100644 --- a/js/util.ts +++ b/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) { + 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); diff --git a/test/js/formats/2mg.spec.ts b/test/js/formats/2mg.spec.ts new file mode 100644 index 0000000..877b02f --- /dev/null +++ b/test/js/formats/2mg.spec.ts @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/test/js/formats/testdata/16sector.spec.ts b/test/js/formats/testdata/16sector.spec.ts index 96c9e0a..7308406 100644 --- a/test/js/formats/testdata/16sector.spec.ts +++ b/test/js/formats/testdata/16sector.spec.ts @@ -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); } }); -}); \ No newline at end of file +}); + +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); + } + }); +}); diff --git a/test/js/formats/testdata/16sector.ts b/test/js/formats/testdata/16sector.ts index f28d61b..a67fd5b 100644 --- a/test/js/formats/testdata/16sector.ts +++ b/test/js/formats/testdata/16sector.ts @@ -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();