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:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [12.x]
|
node-version: [14.x]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { byte, Card, Restorable } from '../types';
|
import type { byte, Card, Restorable } from '../types';
|
||||||
import { debug, toHex } from '../util';
|
import { debug, toHex } from '../util';
|
||||||
import { rom as readOnlyRom } from '../roms/cards/cffa';
|
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 { ProDOSVolume } from '../formats/prodos';
|
||||||
import createBlockDisk from '../formats/block';
|
import createBlockDisk from '../formats/block';
|
||||||
import { dump } from '../formats/prodos/utils';
|
import { dump } from '../formats/prodos/utils';
|
||||||
|
@ -138,6 +138,7 @@ export default class CFFA implements Card, MassStorage<BlockFormat>, Restorable<
|
||||||
];
|
];
|
||||||
|
|
||||||
private _name: string[] = [];
|
private _name: string[] = [];
|
||||||
|
private _metadata: Array<HeaderData|null> = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
debug('CFFA');
|
debug('CFFA');
|
||||||
|
@ -416,6 +417,7 @@ export default class CFFA implements Card, MassStorage<BlockFormat>, Restorable<
|
||||||
|
|
||||||
this._sectors[drive] = [];
|
this._sectors[drive] = [];
|
||||||
this._name[drive] = '';
|
this._name[drive] = '';
|
||||||
|
this._metadata[drive] = null;
|
||||||
|
|
||||||
this._identity[drive][IDENTITY.SectorCountHigh] = 0;
|
this._identity[drive][IDENTITY.SectorCountHigh] = 0;
|
||||||
this._identity[drive][IDENTITY.SectorCountLow] = 0;
|
this._identity[drive][IDENTITY.SectorCountLow] = 0;
|
||||||
|
@ -459,8 +461,12 @@ export default class CFFA implements Card, MassStorage<BlockFormat>, Restorable<
|
||||||
const readOnly = false;
|
const readOnly = false;
|
||||||
|
|
||||||
if (ext === '2mg') {
|
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);
|
rawData = rawData.slice(offset, offset + bytes);
|
||||||
|
} else {
|
||||||
|
this._metadata[drive - 1] = null;
|
||||||
}
|
}
|
||||||
const options = {
|
const options = {
|
||||||
rawData,
|
rawData,
|
||||||
|
@ -475,18 +481,28 @@ export default class CFFA implements Card, MassStorage<BlockFormat>, Restorable<
|
||||||
|
|
||||||
getBinary(drive: number): MassStorageData | null {
|
getBinary(drive: number): MassStorageData | null {
|
||||||
drive = drive - 1;
|
drive = drive - 1;
|
||||||
if (!this._sectors[drive]?.length) {
|
const blockDisk = this._partitions[drive]?.disk();
|
||||||
|
if (!blockDisk) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const blocks = this._sectors[drive];
|
const { name, blocks } = blockDisk;
|
||||||
const data = new Uint8Array(blocks.length * 512);
|
let ext;
|
||||||
for (let idx = 0; idx < blocks.length; idx++) {
|
let data: ArrayBuffer;
|
||||||
data.set(blocks[idx], idx * 512);
|
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 {
|
return {
|
||||||
name: this._name[drive],
|
name,
|
||||||
ext: 'po',
|
ext,
|
||||||
data: data.buffer,
|
data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { rom as smartPortRom } from '../roms/cards/smartport';
|
||||||
import { Card, Restorable, byte, word, rom } from '../types';
|
import { Card, Restorable, byte, word, rom } from '../types';
|
||||||
import { MassStorage, BlockDisk, ENCODING_BLOCK, BlockFormat, MassStorageData } from '../formats/types';
|
import { MassStorage, BlockDisk, ENCODING_BLOCK, BlockFormat, MassStorageData } from '../formats/types';
|
||||||
import CPU6502, { CpuState, flags } from '../cpu6502';
|
import CPU6502, { CpuState, flags } from '../cpu6502';
|
||||||
import { read2MGHeader } from '../formats/2mg';
|
import { create2MGFromBlockDisk, HeaderData, read2MGHeader } from '../formats/2mg';
|
||||||
import createBlockDisk from '../formats/block';
|
import createBlockDisk from '../formats/block';
|
||||||
import { ProDOSVolume } from '../formats/prodos';
|
import { ProDOSVolume } from '../formats/prodos';
|
||||||
import { dump } from '../formats/prodos/utils';
|
import { dump } from '../formats/prodos/utils';
|
||||||
|
@ -132,6 +132,7 @@ export default class SmartPort implements Card, MassStorage<BlockFormat>, Restor
|
||||||
private busy: boolean[] = [];
|
private busy: boolean[] = [];
|
||||||
private busyTimeout: ReturnType<typeof setTimeout>[] = [];
|
private busyTimeout: ReturnType<typeof setTimeout>[] = [];
|
||||||
private ext: string[] = [];
|
private ext: string[] = [];
|
||||||
|
private metadata: Array<HeaderData|null> = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private cpu: CPU6502,
|
private cpu: CPU6502,
|
||||||
|
@ -552,8 +553,12 @@ export default class SmartPort implements Card, MassStorage<BlockFormat>, Restor
|
||||||
const volume = 254;
|
const volume = 254;
|
||||||
const readOnly = false;
|
const readOnly = false;
|
||||||
if (fmt === '2mg') {
|
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);
|
rawData = rawData.slice(offset, offset + bytes);
|
||||||
|
} else {
|
||||||
|
this.metadata[drive] = null;
|
||||||
}
|
}
|
||||||
const options = {
|
const options = {
|
||||||
rawData,
|
rawData,
|
||||||
|
@ -576,15 +581,24 @@ export default class SmartPort implements Card, MassStorage<BlockFormat>, Restor
|
||||||
if (!this.disks[drive]) {
|
if (!this.disks[drive]) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const blocks = this.disks[drive].blocks;
|
const disk = this.disks[drive];
|
||||||
const data = new Uint8Array(blocks.length * 512);
|
const ext = this.ext[drive];
|
||||||
for (let idx = 0; idx < blocks.length; idx++) {
|
const { name } = disk;
|
||||||
data.set(blocks[idx], idx * 512);
|
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 {
|
return {
|
||||||
name: this.disks[drive].name,
|
name,
|
||||||
ext: 'po',
|
ext,
|
||||||
data: data.buffer,
|
data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,9 @@ body {
|
||||||
background: black;
|
background: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
button, a[role="button"] {
|
button,
|
||||||
|
a[role="button"],
|
||||||
|
input[type="file"]::file-selector-button {
|
||||||
background: #44372C;
|
background: #44372C;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
|
@ -19,17 +21,23 @@ button, a[role="button"] {
|
||||||
min-width: 75px;
|
min-width: 75px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover, a[role="button"]:hover {
|
button:hover,
|
||||||
|
a[role="button"]:hover,
|
||||||
|
input[type="file"]::file-selector-button {
|
||||||
background-color: #55473D;
|
background-color: #55473D;
|
||||||
border: 1px outset #66594E;
|
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;
|
background-color: #22150A;
|
||||||
border: 1px outset #44372C;
|
border: 1px outset #44372C;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:focus, a[role="button"]:focus {
|
button:focus,
|
||||||
|
a[role="button"]:focus,
|
||||||
|
input[type="file"]::file-selector-button {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,12 @@
|
||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media all and (display-mode: minimal-ui) {
|
||||||
|
.header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.header img {
|
.header img {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,15 @@
|
||||||
import DOS from './do';
|
import DOS from './do';
|
||||||
import Nibble from './nib';
|
import Nibble from './nib';
|
||||||
import ProDOS from './po';
|
import ProDOS from './po';
|
||||||
import { DiskOptions } from './types';
|
import { BlockDisk, DiskOptions } from './types';
|
||||||
|
|
||||||
import { numToString } from '../util';
|
import { byte, ReadonlyUint8Array } from 'js/types';
|
||||||
import { ReadonlyUint8Array } from 'js/types';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offsets in bytes to the various header fields. All number fields are
|
* Offsets in bytes to the various header fields. All number fields are
|
||||||
* in little-endian order (least significant byte first). These values
|
* in little-endian order (least significant byte first). These values
|
||||||
* come from the spec at:
|
* come from the spec at:
|
||||||
*
|
*
|
||||||
* https://apple2.org.za/gswv/a2zine/Docs/DiskImage_2MG_Info.txt
|
* https://apple2.org.za/gswv/a2zine/Docs/DiskImage_2MG_Info.txt
|
||||||
*/
|
*/
|
||||||
const OFFSETS = {
|
const OFFSETS = {
|
||||||
|
@ -73,23 +72,35 @@ const FLAGS = {
|
||||||
VOLUME_MASK: 0x000000FF
|
VOLUME_MASK: 0x000000FF
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
enum FORMAT {
|
export enum FORMAT {
|
||||||
DOS = 0,
|
DOS = 0,
|
||||||
ProDOS = 1,
|
ProDOS = 1,
|
||||||
NIB = 2,
|
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 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') {
|
if (signature !== '2IMG') {
|
||||||
throw new Error(`Unrecognized 2mg signature: ${signature}`);
|
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);
|
const headerLength = prefix.getInt16(OFFSETS.HEADER_LENGTH, true);
|
||||||
if (headerLength !== 64) {
|
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 format = prefix.getInt32(OFFSETS.FORMAT, true);
|
||||||
const flags = prefix.getInt32(OFFSETS.FLAGS, true);
|
const flags = prefix.getInt32(OFFSETS.FLAGS, true);
|
||||||
const blocks = prefix.getInt32(OFFSETS.BLOCKS, true);
|
const blocks = prefix.getInt32(OFFSETS.BLOCKS, true);
|
||||||
|
@ -138,7 +149,7 @@ export function read2MGHeader(rawData: ArrayBuffer) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const readOnly = (flags & FLAGS.READ_ONLY) !== 0;
|
const readOnly = (flags & FLAGS.READ_ONLY) !== 0;
|
||||||
let volume = 254;
|
let volume = format === FORMAT.DOS ? 254 : 0;
|
||||||
if (flags & FLAGS.VOLUME_VALID) {
|
if (flags & FLAGS.VOLUME_VALID) {
|
||||||
volume = flags & FLAGS.VOLUME_MASK;
|
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.
|
* Returns a `Disk` object from a 2mg image.
|
||||||
* @param options the disk image and options
|
* @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;
|
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"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 14"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"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 { concat } from 'js/util';
|
||||||
import { BYTES_BY_SECTOR_IMAGE } from './testdata/16sector';
|
import { BYTES_BY_SECTOR_IMAGE } from './testdata/16sector';
|
||||||
|
|
||||||
|
@ -95,9 +102,100 @@ describe('2mg format', () => {
|
||||||
expect(header.offset).toBe(64);
|
expect(header.offset).toBe(64);
|
||||||
expect(header.format).toBe(1);
|
expect(header.format).toBe(1);
|
||||||
expect(header.readOnly).toBeFalsy();
|
expect(header.readOnly).toBeFalsy();
|
||||||
expect(header.volume).toBe(254);
|
expect(header.volume).toBe(0);
|
||||||
expect(header.comment).toBeUndefined();
|
expect(header.comment).toBeUndefined();
|
||||||
expect(header.creatorData).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. */
|
/** @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', () => {
|
describe('garbage', () => {
|
||||||
it('returns 0 <= x <= 255', () => {
|
it('returns 0 <= x <= 255', () => {
|
||||||
|
@ -80,21 +80,3 @@ describe('hup', () => {
|
||||||
// untestable due to direct reference to window.location
|
// 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