2IMG Download support. (#137)
* 2IMG Download support. * Use string encoder/decoder
This commit is contained in:
parent
99ba052597
commit
f283dae7e1
|
@ -9,7 +9,7 @@ jobs:
|
|||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
node-version: [14.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,12 @@
|
|||
margin: auto;
|
||||
}
|
||||
|
||||
@media all and (display-mode: minimal-ui) {
|
||||
.header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.header img {
|
||||
border: none;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
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
|
||||
|
@ -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
|
||||
|
|
10
js/util.ts
10
js/util.ts
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"test": "jest"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
"node": ">= 14"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue