2IMG Download support.

This commit is contained in:
Will Scullin 2022-06-16 19:47:49 -07:00
parent 99ba052597
commit 27dd604534
No known key found for this signature in database
GPG Key ID: 26DCD1042C6638CD
8 changed files with 314 additions and 33 deletions

View File

@ -1,7 +1,7 @@
import type { byte, Card, Restorable } from '../types';
import { debug, toHex } from '../util';
import { rom as readOnlyRom } from '../roms/cards/cffa';
import { read2MGHeader } from '../formats/2mg';
import { create2MGFromBlockDisk, HeaderData, read2MGHeader } from '../formats/2mg';
import { ProDOSVolume } from '../formats/prodos';
import createBlockDisk from '../formats/block';
import { dump } from '../formats/prodos/utils';
@ -138,6 +138,7 @@ export default class CFFA implements Card, MassStorage<BlockFormat>, Restorable<
];
private _name: string[] = [];
private _metadata: Array<HeaderData|null> = [];
constructor() {
debug('CFFA');
@ -416,6 +417,7 @@ export default class CFFA implements Card, MassStorage<BlockFormat>, Restorable<
this._sectors[drive] = [];
this._name[drive] = '';
this._metadata[drive] = null;
this._identity[drive][IDENTITY.SectorCountHigh] = 0;
this._identity[drive][IDENTITY.SectorCountLow] = 0;
@ -459,8 +461,12 @@ export default class CFFA implements Card, MassStorage<BlockFormat>, Restorable<
const readOnly = false;
if (ext === '2mg') {
const { bytes, offset } = read2MGHeader(rawData);
const headerData = read2MGHeader(rawData);
const { bytes, offset } = headerData;
this._metadata[drive - 1] = headerData;
rawData = rawData.slice(offset, offset + bytes);
} else {
this._metadata[drive - 1] = null;
}
const options = {
rawData,
@ -475,18 +481,28 @@ export default class CFFA implements Card, MassStorage<BlockFormat>, Restorable<
getBinary(drive: number): MassStorageData | null {
drive = drive - 1;
if (!this._sectors[drive]?.length) {
const blockDisk = this._partitions[drive]?.disk();
if (!blockDisk) {
return null;
}
const blocks = this._sectors[drive];
const data = new Uint8Array(blocks.length * 512);
for (let idx = 0; idx < blocks.length; idx++) {
data.set(blocks[idx], idx * 512);
const { name, blocks } = blockDisk;
let ext;
let data: ArrayBuffer;
if (this._metadata[drive]) {
ext = '2mg';
data = create2MGFromBlockDisk(this._metadata[drive - 1], blockDisk);
} else {
ext = 'po';
const dataArray = new Uint8Array(blocks.length * 512);
for (let idx = 0; idx < blocks.length; idx++) {
dataArray.set(blocks[idx], idx * 512);
}
data = dataArray.buffer;
}
return {
name: this._name[drive],
ext: 'po',
data: data.buffer,
name,
ext,
data,
};
}
}

View File

@ -3,7 +3,7 @@ import { rom as smartPortRom } from '../roms/cards/smartport';
import { Card, Restorable, byte, word, rom } from '../types';
import { MassStorage, BlockDisk, ENCODING_BLOCK, BlockFormat, MassStorageData } from '../formats/types';
import CPU6502, { CpuState, flags } from '../cpu6502';
import { read2MGHeader } from '../formats/2mg';
import { create2MGFromBlockDisk, HeaderData, read2MGHeader } from '../formats/2mg';
import createBlockDisk from '../formats/block';
import { ProDOSVolume } from '../formats/prodos';
import { dump } from '../formats/prodos/utils';
@ -132,6 +132,7 @@ export default class SmartPort implements Card, MassStorage<BlockFormat>, Restor
private busy: boolean[] = [];
private busyTimeout: ReturnType<typeof setTimeout>[] = [];
private ext: string[] = [];
private metadata: Array<HeaderData|null> = [];
constructor(
private cpu: CPU6502,
@ -552,8 +553,12 @@ export default class SmartPort implements Card, MassStorage<BlockFormat>, Restor
const volume = 254;
const readOnly = false;
if (fmt === '2mg') {
const { bytes, offset } = read2MGHeader(rawData);
const header = read2MGHeader(rawData);
this.metadata[drive] = header;
const { bytes, offset } = header;
rawData = rawData.slice(offset, offset + bytes);
} else {
this.metadata[drive] = null;
}
const options = {
rawData,
@ -576,15 +581,22 @@ export default class SmartPort implements Card, MassStorage<BlockFormat>, Restor
if (!this.disks[drive]) {
return null;
}
const blocks = this.disks[drive].blocks;
const data = new Uint8Array(blocks.length * 512);
for (let idx = 0; idx < blocks.length; idx++) {
data.set(blocks[idx], idx * 512);
const disk = this.disks[drive];
let data: ArrayBuffer;
if (this.ext[drive] === '2mg') {
data = create2MGFromBlockDisk(this.metadata[drive], disk);
} else {
const { blocks } = disk;
const byteArray = new Uint8Array(blocks.length * 512);
for (let idx = 0; idx < blocks.length; idx++) {
byteArray.set(blocks[idx], idx * 512);
}
data = byteArray.buffer;
}
return {
name: this.disks[drive].name,
ext: 'po',
data: data.buffer,
data,
};
}
}

View File

@ -9,7 +9,9 @@ body {
background: black;
}
button, a[role="button"] {
button,
a[role="button"],
input[type="file"]::file-selector-button {
background: #44372C;
color: #fff;
padding: 2px 8px;
@ -19,17 +21,23 @@ button, a[role="button"] {
min-width: 75px;
}
button:hover, a[role="button"]:hover {
button:hover,
a[role="button"]:hover,
input[type="file"]::file-selector-button {
background-color: #55473D;
border: 1px outset #66594E;
}
button:active, a[role="button"]:active {
button:active,
a[role="button"]:active,
input[type="file"]::file-selector-button {
background-color: #22150A;
border: 1px outset #44372C;
}
button:focus, a[role="button"]:focus {
button:focus,
a[role="button"]:focus,
input[type="file"]::file-selector-button {
outline: none;
}

View File

@ -3,6 +3,12 @@
margin: auto;
}
@media all and (display-mode: minimal-ui) {
.header {
display: none;
}
}
.header img {
border: none;
}

View File

@ -1,16 +1,16 @@
import DOS from './do';
import Nibble from './nib';
import ProDOS from './po';
import { DiskOptions } from './types';
import { BlockDisk, DiskOptions } from './types';
import { numToString } from '../util';
import { ReadonlyUint8Array } from 'js/types';
import { numToString, stringToNum } from '../util';
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 = {
@ -73,13 +73,24 @@ const FLAGS = {
VOLUME_MASK: 0x000000FF
} as const;
enum FORMAT {
export enum FORMAT {
DOS = 0,
ProDOS = 1,
NIB = 2,
}
export function read2MGHeader(rawData: ArrayBuffer) {
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 signature = numToString(prefix.getInt32(OFFSETS.SIGNATURE, true));
if (signature !== '2IMG') {
@ -87,7 +98,7 @@ export function read2MGHeader(rawData: ArrayBuffer) {
}
const headerLength = prefix.getInt16(OFFSETS.HEADER_LENGTH, true);
if (headerLength !== 64) {
throw new Error(`2mg header length is incorrect ${headerLength} !== 63`);
throw new Error(`2mg header length is incorrect ${headerLength} !== 64`);
}
const creator = numToString(prefix.getInt32(OFFSETS.CREATOR, true));
const format = prefix.getInt32(OFFSETS.FORMAT, true);
@ -130,6 +141,7 @@ export function read2MGHeader(rawData: ArrayBuffer) {
const extras: { comment?: string; creatorData?: ReadonlyUint8Array } = {};
if (commentOffset) {
extras.comment = new TextDecoder('utf-8').decode(
new Uint8Array(rawData, commentOffset, commentLength));
}
@ -138,7 +150,7 @@ export function read2MGHeader(rawData: ArrayBuffer) {
}
const readOnly = (flags & FLAGS.READ_ONLY) !== 0;
let volume = 254;
let volume = format === FORMAT.DOS ? 254 : 0;
if (flags & FLAGS.VOLUME_VALID) {
volume = flags & FLAGS.VOLUME_MASK;
}
@ -154,6 +166,105 @@ export function read2MGHeader(rawData: ArrayBuffer) {
};
}
/**
* 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;
}
prefixView.setInt32(OFFSETS.SIGNATURE, stringToNum('2IMG'), true);
prefixView.setInt32(OFFSETS.CREATOR, stringToNum(headerData.creator), true);
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

View File

@ -100,3 +100,13 @@ export function numToString(num: number) {
}
return result;
}
/** Packs an ASCII string into 32-bit integer in little-endian order. */
export function stringToNum(str: string): number {
let result = 0;
for (let idx = 3; idx >= 0; idx--) {
result <<= 8;
result |= str.charCodeAt(idx);
}
return result;
}

View File

@ -1,4 +1,11 @@
import { read2MGHeader } from 'js/formats/2mg';
import {
create2MGFragments,
create2MGFromBlockDisk,
FORMAT,
HeaderData,
read2MGHeader
} from 'js/formats/2mg';
import { BlockDisk, ENCODING_BLOCK } from 'js/formats/types';
import { concat } from 'js/util';
import { BYTES_BY_SECTOR_IMAGE } from './testdata/16sector';
@ -95,9 +102,101 @@ describe('2mg format', () => {
expect(header.offset).toBe(64);
expect(header.format).toBe(1);
expect(header.readOnly).toBeFalsy();
expect(header.volume).toBe(254);
expect(header.volume).toBe(0);
expect(header.comment).toBeUndefined();
expect(header.creatorData).toBeUndefined();
});
});
});
describe('create2MGFragments', () => {
it('creates a valid image from header data and blocks', () => {
const header = read2MGHeader(VALID_PRODOS_IMAGE.buffer);
const { prefix, suffix } = create2MGFragments(header, { blocks: header.bytes / 512 });
expect(prefix).toEqual(VALID_PRODOS_IMAGE.slice(0, 64));
expect(suffix).toEqual(new Uint8Array());
});
it('throws an error if block count does not match byte count', () => {
const headerData: HeaderData = {
creator: 'A2JS',
bytes: 32768,
format: FORMAT.ProDOS,
readOnly: false,
offset: 64,
volume: 0,
};
expect(() => create2MGFragments(headerData, { blocks: 63 })).toThrowError(/does not match/);
});
it('throws an error if not a ProDOS volume', () => {
const headerData: HeaderData = {
creator: 'A2JS',
bytes: 143_360,
format: FORMAT.DOS,
readOnly: false,
offset: 64,
volume: 254,
};
expect(() => create2MGFragments(headerData, { blocks: 280 })).toThrowError(/not supported/);
});
it('uses defaults', () => {
const { prefix, suffix } = create2MGFragments(null, { blocks: 280 });
const image = concat(prefix, BYTES_BY_SECTOR_IMAGE, suffix);
const headerData = read2MGHeader(image.buffer);
expect(headerData).toEqual({
creator: 'A2JS',
bytes: 143_360,
format: FORMAT.ProDOS,
readOnly: false,
offset: 64,
volume: 0,
});
});
it.each([
['Hello, sailor', undefined],
['Hieyz wizka', new Uint8Array([4, 3, 2, 1])],
[undefined, new Uint8Array([4, 3, 2, 1])]
])('can create comment %p and creator data %p', (testComment, testData) => {
const headerData: HeaderData = {
creator: 'A2JS',
bytes: 0,
format: FORMAT.ProDOS,
readOnly: false,
offset: 64,
volume: 254,
};
if (testComment) {
headerData.comment = testComment;
}
if (testData) {
headerData.creatorData = testData;
}
const { prefix, suffix } = create2MGFragments(headerData, { blocks: 0 });
const image = concat(prefix, suffix);
const { comment, creatorData } = read2MGHeader(image.buffer);
expect(comment).toEqual(testComment);
expect(creatorData).toEqual(testData);
});
});
describe('create2MGFromBlockDisk', () => {
it('can create a 2mg disk', () => {
const header = read2MGHeader(VALID_PRODOS_IMAGE.buffer);
console.log(BYTES_BY_SECTOR_IMAGE.length);
const blocks = [];
for (let idx = 0; idx < BYTES_BY_SECTOR_IMAGE.length; idx += 512) {
blocks.push(BYTES_BY_SECTOR_IMAGE.slice(idx, idx + 512));
}
const disk: BlockDisk = {
blocks,
name: 'Good disk',
readOnly: false,
encoding: ENCODING_BLOCK,
};
const image = create2MGFromBlockDisk(header, disk);
expect(VALID_PRODOS_IMAGE.buffer).toEqual(image);
});
});
});

View File

@ -1,6 +1,6 @@
/** @fileoverview Test for utils.ts. */
import { allocMem, allocMemPages, numToString, testables, toBinary, toHex } from '../../js/util';
import { allocMem, allocMemPages, numToString, stringToNum, testables, toBinary, toHex } from '../../js/util';
describe('garbage', () => {
it('returns 0 <= x <= 255', () => {
@ -98,3 +98,22 @@ describe('numToString', () => {
expect(numToString(0x4142434445)).toEqual('EDCB');
});
});
describe('stringToNum', () => {
it('packs a zero byte into a string of all zeros', () => {
expect(stringToNum('\0\0\0\0')).toEqual(0x00);
});
it('packs a byte in the printable ASCII range into a zero-padded string',
() => {
expect(stringToNum('A\0\0\0')).toEqual(0x41);
});
it('packs a word into a string', () => {
expect(stringToNum('BA\0\0')).toEqual(0x4142);
});
it('packs a 32-bit value into a string', () => {
expect(stringToNum('DCBA')).toEqual(0x41424344);
});
it('ignores more than 4 character', () => {
expect(stringToNum('EDCBA')).toEqual(0x42434445);
});
});