import DOS from './do'; import Nibble from './nib'; import ProDOS from './po'; import { BlockDisk, DiskOptions } from './types'; import { byte, 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; export enum FORMAT { DOS = 0, ProDOS = 1, NIB = 2, } 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 { const prefix = new DataView(rawData); const decoder = new TextDecoder('ascii'); const signature = decoder.decode(rawData.slice(OFFSETS.SIGNATURE, OFFSETS.SIGNATURE + 4)); if (signature !== '2IMG') { throw new Error(`Unrecognized 2mg signature: ${signature}`); } const creator = decoder.decode(rawData.slice(OFFSETS.CREATOR, OFFSETS.CREATOR + 4)); const headerLength = prefix.getInt16(OFFSETS.HEADER_LENGTH, true); if (headerLength !== 64) { throw new Error(`2mg header length is incorrect ${headerLength} !== 64`); } 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 = format === FORMAT.DOS ? 254 : 0; if (flags & FLAGS.VOLUME_VALID) { volume = flags & FLAGS.VOLUME_MASK; } return { bytes, creator, format, offset, readOnly, volume, ...extras }; } /** * 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; }; /** * 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; }