mirror of
https://github.com/whscullin/apple2js.git
synced 2024-01-12 14:14:38 +00:00
2IMG Download support.
This commit is contained in:
parent
99ba052597
commit
27dd604534
|
@ -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,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,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
|
||||
|
|
10
js/util.ts
10
js/util.ts
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue
Block a user