diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 7cc77c0..755a8d0 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: - node-version: [12.x] + node-version: [14.x] steps: - uses: actions/checkout@v2 diff --git a/js/cards/cffa.ts b/js/cards/cffa.ts index c12ebae..2c89d43 100644 --- a/js/cards/cffa.ts +++ b/js/cards/cffa.ts @@ -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, Restorable< ]; private _name: string[] = []; + private _metadata: Array = []; constructor() { debug('CFFA'); @@ -416,6 +417,7 @@ export default class CFFA implements Card, MassStorage, 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, 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, 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, }; } } diff --git a/js/cards/smartport.ts b/js/cards/smartport.ts index e3bdd60..75e6188 100644 --- a/js/cards/smartport.ts +++ b/js/cards/smartport.ts @@ -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, Restor private busy: boolean[] = []; private busyTimeout: ReturnType[] = []; private ext: string[] = []; + private metadata: Array = []; constructor( private cpu: CPU6502, @@ -552,8 +553,12 @@ export default class SmartPort implements Card, MassStorage, 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, 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, }; } } diff --git a/js/components/css/App.module.css b/js/components/css/App.module.css index 2dae220..bcc54ed 100644 --- a/js/components/css/App.module.css +++ b/js/components/css/App.module.css @@ -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; } diff --git a/js/components/css/Header.module.css b/js/components/css/Header.module.css index 020534a..189cd58 100644 --- a/js/components/css/Header.module.css +++ b/js/components/css/Header.module.css @@ -3,6 +3,12 @@ margin: auto; } +@media all and (display-mode: minimal-ui) { + .header { + display: none; + } +} + .header img { border: none; } diff --git a/js/formats/2mg.ts b/js/formats/2mg.ts index bfd7e70..61297f2 100644 --- a/js/formats/2mg.ts +++ b/js/formats/2mg.ts @@ -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 diff --git a/js/util.ts b/js/util.ts index 3b51d22..70a3cce 100644 --- a/js/util.ts +++ b/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; -} diff --git a/package.json b/package.json index 5ed8a6f..8821835 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "test": "jest" }, "engines": { - "node": ">= 6" + "node": ">= 14" }, "repository": { "type": "git", diff --git a/test/js/formats/2mg.spec.ts b/test/js/formats/2mg.spec.ts index 877b02f..90dbe02 100644 --- a/test/js/formats/2mg.spec.ts +++ b/test/js/formats/2mg.spec.ts @@ -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(); }); }); -}); \ No newline at end of file + + 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); + }); + }); +}); diff --git a/test/js/util.test.ts b/test/js/util.test.ts index 133e0f0..f9b06b1 100644 --- a/test/js/util.test.ts +++ b/test/js/util.test.ts @@ -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'); - }); -});