2IMG Download support. (#137)

* 2IMG Download support.

* Use string encoder/decoder
This commit is contained in:
Will Scullin 2022-06-21 20:34:19 -07:00 committed by GitHub
parent 99ba052597
commit f283dae7e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 293 additions and 67 deletions

View File

@ -9,7 +9,7 @@ jobs:
strategy:
matrix:
node-version: [12.x]
node-version: [14.x]
steps:
- uses: actions/checkout@v2

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,24 @@ 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];
const ext = this.ext[drive];
const { name } = disk;
let data: ArrayBuffer;
if (ext === '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,
name,
ext,
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,15 @@
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 { 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,23 +72,35 @@ 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));
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} !== 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);
const flags = prefix.getInt32(OFFSETS.FLAGS, true);
const blocks = prefix.getInt32(OFFSETS.BLOCKS, true);
@ -138,7 +149,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 +165,107 @@ 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;
}
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

View File

@ -90,13 +90,3 @@ export function toBinary(v: byte) {
}
return result;
}
/** Packs a 32-bit integer into a string in little-endian order. */
export function numToString(num: number) {
let result = '';
for (let idx = 0; idx < 4; idx++) {
result += String.fromCharCode(num & 0xff);
num >>= 8;
}
return result;
}

View File

@ -11,7 +11,7 @@
"test": "jest"
},
"engines": {
"node": ">= 6"
"node": ">= 14"
},
"repository": {
"type": "git",

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,100 @@ 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);
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, testables, toBinary, toHex } from '../../js/util';
describe('garbage', () => {
it('returns 0 <= x <= 255', () => {
@ -80,21 +80,3 @@ describe('hup', () => {
// untestable due to direct reference to window.location
});
describe('numToString', () => {
it('packs a zero byte into a string of all zeros', () => {
expect(numToString(0x00)).toEqual('\0\0\0\0');
});
it('packs a byte in the printable ASCII range into a zero-padded string',
() => {
expect(numToString(0x41)).toEqual('A\0\0\0');
});
it('packs a word into a string', () => {
expect(numToString(0x4142)).toEqual('BA\0\0');
});
it('packs a 32-bit value into a string', () => {
expect(numToString(0x41424344)).toEqual('DCBA');
});
it('ignores more than 32 bits', () => {
expect(numToString(0x4142434445)).toEqual('EDCB');
});
});