apple2js/js/formats/2mg.ts
Will Scullin f283dae7e1
2IMG Download support. (#137)
* 2IMG Download support.

* Use string encoder/decoder
2022-06-21 20:34:19 -07:00

302 lines
11 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 { 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;
}