Mass storage WIP

This commit is contained in:
Will Scullin 2022-05-11 06:52:15 -07:00
parent 15b7f1e123
commit 1de57ab75a
No known key found for this signature in database
GPG Key ID: 26DCD1042C6638CD
13 changed files with 332 additions and 78 deletions

View File

@ -85,7 +85,7 @@ export interface CFFAState {
disks: Array<BlockDisk | null>;
}
export default class CFFA implements Card, MassStorage, Restorable<CFFAState> {
export default class CFFA implements Card, MassStorage<BlockFormat>, Restorable<CFFAState> {
// CFFA internal Flags

View File

@ -1,12 +1,13 @@
import { debug, toHex } from '../util';
import { rom as smartPortRom } from '../roms/cards/smartport';
import { Card, Restorable, byte, word, rom } from '../types';
import { MassStorage, BlockDisk, ENCODING_BLOCK } from '../formats/types';
import { MassStorage, BlockDisk, ENCODING_BLOCK, BlockFormat } from '../formats/types';
import CPU6502, { CpuState, flags } from '../cpu6502';
import { read2MGHeader } from '../formats/2mg';
import createBlockDisk from '../formats/block';
import { ProDOSVolume } from '../formats/prodos';
import { dump } from '../formats/prodos/utils';
import { DriveNumber } from '../formats/types';
const ID = 'SMARTPORT.J.S';
@ -18,6 +19,12 @@ export interface SmartPortOptions {
block: boolean;
}
export interface Callbacks {
driveLight: (drive: DriveNumber, on: boolean) => void;
dirty: (drive: DriveNumber, dirty: boolean) => void;
label: (drive: DriveNumber, name?: string, side?: string) => void;
}
class Address {
lo: byte;
hi: byte;
@ -118,12 +125,16 @@ const DEVICE_TYPE_SCSI_HD = 0x07;
// $0D: Printer
// $0E: Clock
// $0F: Modem
export default class SmartPort implements Card, MassStorage, Restorable<SmartPortState> {
export default class SmartPort implements Card, MassStorage<BlockFormat>, Restorable<SmartPortState> {
private rom: rom;
private disks: BlockDisk[] = [];
constructor(private cpu: CPU6502, options: SmartPortOptions) {
constructor(
private cpu: CPU6502,
private callbacks: Callbacks | null,
options: SmartPortOptions
) {
if (options?.block) {
const dumbPortRom = new Uint8Array(smartPortRom);
dumbPortRom[0x07] = 0x3C;
@ -143,7 +154,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
* dumpBlock
*/
dumpBlock(drive: number, block: number) {
dumpBlock(drive: DriveNumber, block: DriveNumber) {
let result = '';
let b;
let jdx;
@ -178,7 +189,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
* getDeviceInfo
*/
getDeviceInfo(state: CpuState, drive: number) {
getDeviceInfo(state: CpuState, drive: DriveNumber) {
if (this.disks[drive]) {
const blocks = this.disks[drive].blocks.length;
state.x = blocks & 0xff;
@ -196,7 +207,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
* readBlock
*/
readBlock(state: CpuState, drive: number, block: number, buffer: Address) {
readBlock(state: CpuState, drive: DriveNumber, block: number, buffer: Address) {
this.debug(`read drive=${drive}`);
this.debug(`read buffer=${buffer.toString()}`);
this.debug(`read block=$${toHex(block)}`);
@ -223,7 +234,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
* writeBlock
*/
writeBlock(state: CpuState, drive: number, block: number, buffer: Address) {
writeBlock(state: CpuState, drive: DriveNumber, block: number, buffer: Address) {
this.debug(`write drive=${drive}`);
this.debug(`write buffer=${buffer.toString()}`);
this.debug(`write block=$${toHex(block)}`);
@ -256,7 +267,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
* formatDevice
*/
formatDevice(state: CpuState, drive: number) {
formatDevice(state: CpuState, drive: DriveNumber) {
if (!this.disks[drive]?.blocks.length) {
debug('Drive', drive, 'is empty');
state.a = DEVICE_OFFLINE;
@ -352,7 +363,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
break;
case 3: // FORMAT
this.formatDevice(state, unit);
this.formatDevice(state, drive);
break;
}
} else if (off === smartOff && this.cpu.getSync()) {
@ -375,6 +386,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
const parameterCount = cmdListAddr.readByte();
unit = cmdListAddr.inc(1).readByte();
const drive = unit ? 2 : 1;
buffer = cmdListAddr.inc(2).readAddress();
let status;
@ -444,16 +456,16 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
case 0x01: // READ BLOCK
block = cmdListAddr.inc(4).readWord();
this.readBlock(state, unit, block, buffer);
this.readBlock(state, drive, block, buffer);
break;
case 0x02: // WRITE BLOCK
block = cmdListAddr.inc(4).readWord();
this.writeBlock(state, unit, block, buffer);
this.writeBlock(state, drive, block, buffer);
break;
case 0x03: // FORMAT
this.formatDevice(state, unit);
this.formatDevice(state, drive);
break;
case 0x04: // CONTROL
@ -519,7 +531,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
);
}
setBinary(drive: number, name: string, fmt: string, rawData: ArrayBuffer) {
setBinary(drive: DriveNumber, name: string, fmt: string, rawData: ArrayBuffer) {
const volume = 254;
const readOnly = false;
if (fmt === '2mg') {
@ -534,6 +546,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
};
this.disks[drive] = createBlockDisk(options);
this.callbacks?.label(drive, name);
const prodos = new ProDOSVolume(this.disks[drive]);
dump(prodos);

View File

@ -68,11 +68,11 @@ export const Apple2 = (props: Apple2Props) => {
return (
<div className={cs(styles.outer, { apple2e: e })}>
<Screen screen={screen} />
<Slinky io={io} slot={2} />
<Mouse cpu={cpu} screen={screen} io={io} slot={4} />
<Slinky io={io} slot={4} />
<ThunderClock io={io} slot={5} />
<Inset>
<Drives io={io} sectors={sectors} />
<Drives cpu={cpu} io={io} sectors={sectors} e={e} />
</Inset>
<ControlStrip apple2={apple2} e={e} />
<Inset>

View File

@ -0,0 +1,84 @@
import { h } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
import cs from 'classnames';
import SmartPort from '../cards/smartport';
import { BlockFileModal } from './BlockFileModal';
import { loadHttpBlockFile, getHashParts } from './util/files';
/**
* Storage structure for Disk II state returned via callbacks.
*/
export interface DiskIIData {
number: 1 | 2;
on: boolean;
name?: string;
}
/**
* Interface for Disk II component.
*/
export interface BlockDiskProps extends DiskIIData {
smartPort: SmartPort | undefined;
}
/**
* Disk II component
*
* Includes drive light, disk name and side, and UI for loading disks.
* Handles initial loading of disks specified in the hash.
*
* @param disk2 Disk2 object
* @param number Drive 1 or 2
* @param on Active state
* @param name Disk name identifier
* @param side Disk side identifier
* @returns BlockDisk component
*/
export const BlockDisk = ({ smartPort, number, on, name }: BlockDiskProps) => {
const [modalOpen, setModalOpen] = useState(false);
useEffect(() => {
const hashParts = getHashParts();
if (smartPort && hashParts && hashParts[number]) {
const hashPart = decodeURIComponent(hashParts[number]);
if (hashPart.match(/^https?:/)) {
loadHttpBlockFile(smartPort, number, hashPart)
.catch((error) =>
console.error(error)
);
}
}
}, [smartPort]);
const doClose = useCallback(() => {
setModalOpen(false);
}, []);
const onOpenModal = useCallback(() => {
setModalOpen(true);
}, []);
return (
<div className="disk">
<BlockFileModal
smartPort={smartPort}
number={number}
onClose={doClose}
isOpen={modalOpen}
/>
<div
id={`disk${number}`}
className={cs('disk-light', { on })}
/>
<button title="Load Disk">
<i class="fas fa-folder-open" onClick={onOpenModal} />
</button>
<div
id={`disk-label${number}`}
className="disk-label"
>
{name}
</div>
</div>
);
};

View File

@ -0,0 +1,62 @@
import { h } from 'preact';
import { useCallback, useRef, useState } from 'preact/hooks';
import { DriveNumber, NibbleFormat } from '../formats/types';
import { Modal, ModalContent, ModalFooter } from './Modal';
import { loadLocalBlockFile, getHashParts, setHashParts } from './util/files';
import SmartPort from 'js/cards/smartport';
export type NibbleFileCallback = (
name: string,
fmt: NibbleFormat,
rawData: ArrayBuffer
) => boolean;
interface BlockFileModalProps {
isOpen: boolean;
smartPort: SmartPort | undefined;
number: DriveNumber;
onClose: (closeBox?: boolean) => void;
}
export const BlockFileModal = ({ smartPort, number, onClose, isOpen } : BlockFileModalProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState<boolean>(false);
const [empty, setEmpty] = useState<boolean>(true);
const doCancel = useCallback(() => onClose(true), []);
const doOpen = useCallback(() => {
const hashParts = getHashParts();
if (smartPort && inputRef.current && inputRef.current.files?.length === 1) {
hashParts[number] = '';
setBusy(true);
loadLocalBlockFile(smartPort, number, inputRef.current.files[0])
.catch(console.error)
.finally(() => {
setBusy(false);
onClose();
});
}
setHashParts(hashParts);
}, [ smartPort, number, onClose ]);
const onChange = useCallback(() => {
if (inputRef) {
setEmpty(!inputRef.current?.files?.length);
}
}, [ inputRef ]);
return (
<Modal title="Open File" isOpen={isOpen}>
<ModalContent>
<input type="file" ref={inputRef} onChange={onChange} />
</ModalContent>
<ModalFooter>
<button onClick={doCancel}>Cancel</button>
<button onClick={doOpen} disabled={busy || empty}>Open</button>
</ModalFooter>
</Modal>
);
};

View File

@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'preact/hooks';
import cs from 'classnames';
import Disk2 from '../cards/disk2';
import { FileModal } from './FileModal';
import { loadJSON, loadHttpFile, getHashParts } from './util/files';
import { loadJSON, loadHttpNibbleFile, getHashParts } from './util/files';
import { ErrorModal } from './ErrorModal';
import { useHash } from './hooks/useHash';
@ -54,7 +54,7 @@ export const DiskII = ({ disk2, number, on, name, side }: DiskIIProps) => {
const hashPart = decodeURIComponent(newHash);
if (hashPart !== currentHash) {
if (hashPart.match(/^https?:/)) {
loadHttpFile(disk2, number, hashPart)
loadHttpNibbleFile(disk2, number, hashPart)
.catch((e) => setError(e));
} else {
const filename = `/json/disks/${hashPart}.json`;

View File

@ -1,14 +1,19 @@
import { h, Fragment } from 'preact';
import { h } from 'preact';
import {useEffect, useState } from 'preact/hooks';
import Disk2, { Callbacks } from '../cards/disk2';
import Apple2IO from '../apple2io';
import { DiskII, DiskIIData } from './DiskII';
import SmartPort from 'js/cards/smartport';
import CPU6502 from 'js/cpu6502';
import { BlockDisk } from './BlockDisk';
/**
* Interface for Drives component.
*/
export interface DrivesProps {
cpu: CPU6502 | undefined;
io: Apple2IO | undefined;
e: boolean;
sectors: number;
}
@ -20,7 +25,7 @@ export interface DrivesProps {
* @param io Apple I/O object
* @returns Drives component
*/
export const Drives = ({ io, sectors }: DrivesProps) => {
export const Drives = ({ cpu, io, sectors, e }: DrivesProps) => {
const [disk2, setDisk2] = useState<Disk2>();
const [data1, setData1] = useState<DiskIIData>({
on: false,
@ -32,9 +37,22 @@ export const Drives = ({ io, sectors }: DrivesProps) => {
number: 2,
name: 'Disk 2',
});
const [smartData1, setSmartData1] = useState<DiskIIData>({
on: false,
number: 1,
name: 'Disk 1'
});
const [smartData2, setSmartData2] = useState<DiskIIData>({
on: false,
number: 2,
name: 'Disk 2'
});
const [smartPort, setSmartPort] = useState<SmartPort>();
useEffect(() => {
const setData = [setData1, setData2];
const setSmartData = [setSmartData1, setSmartData2];
const callbacks: Callbacks = {
driveLight: (drive, on) => {
setData[drive - 1]?.(data => ({...data, on }));
@ -51,17 +69,40 @@ export const Drives = ({ io, sectors }: DrivesProps) => {
}
};
if (io) {
const smartPortCallbacks: Callbacks = {
driveLight: (drive, on) => {
setSmartData[drive - 1]?.(data => ({...data, on }));
},
label: (drive, name, side) => {
setSmartData[drive - 1]?.(data => ({
...data,
name: name ?? `Disk ${drive}`,
side,
}));
},
dirty: () => {}
};
if (cpu && io) {
const disk2 = new Disk2(io, callbacks, sectors);
io.setSlot(6, disk2);
setDisk2(disk2);
const smartPort = new SmartPort(cpu, smartPortCallbacks, { block: !e });
io.setSlot(7, smartPort);
setSmartPort(smartPort);
}
}, [io, sectors]);
return (
<>
<DiskII disk2={disk2} {...data1} />
<DiskII disk2={disk2} {...data2} />
</>
<div style={{display: 'flex', width: '100%'}}>
<div style={{display: 'flex', flexDirection: 'column', flex: '1 1 auto'}}>
<DiskII disk2={disk2} {...data1} />
<DiskII disk2={disk2} {...data2} />
</div>
<div style={{display: 'flex', flexDirection: 'column', flex: '1 1 auto'}}>
<BlockDisk smartPort={smartPort} {...smartData1} />
<BlockDisk smartPort={smartPort} {...smartData2} />
</div>
</div>
);
};

View File

@ -2,7 +2,7 @@ import { h, Fragment, JSX } from 'preact';
import { useCallback, useState } from 'preact/hooks';
import { DiskDescriptor, DriveNumber, NibbleFormat, NIBBLE_FORMATS } from '../formats/types';
import { Modal, ModalContent, ModalFooter } from './Modal';
import { loadLocalFile, loadJSON, getHashParts, setHashParts } from './util/files';
import { loadLocalNibbleFile, loadJSON, getHashParts, setHashParts } from './util/files';
import DiskII from '../cards/disk2';
import { ErrorModal } from './ErrorModal';
@ -66,7 +66,7 @@ export const FileModal = ({ disk2, number, onClose, isOpen }: FileModalProps) =>
try {
if (disk2 && handles?.length === 1) {
hashParts[number] = '';
await loadLocalFile(disk2, number, await handles[0].getFile());
await loadLocalNibbleFile(disk2, number, await handles[0].getFile());
}
if (disk2 && filename) {
const name = filename.match(/\/([^/]+).json$/) || ['', ''];

View File

@ -1,11 +1,16 @@
import { includes } from 'js/types';
import { initGamepad } from 'js/ui/gamepad';
import {
BlockFormat,
BLOCK_FORMATS,
DriveNumber,
JSONDisk,
NIBBLE_FORMATS
MassStorage,
NibbleFormat,
NIBBLE_FORMATS,
} from 'js/formats/types';
import DiskII from 'js/cards/disk2';
import Disk2 from 'js/cards/disk2';
import SmartPort from 'js/cards/smartport';
/**
* Routine to split a legacy hash into parts for disk loading
@ -26,17 +31,9 @@ export const setHashParts = (parts: string[]) => {
window.location.hash = `#${parts[1]}` + (parts[2] ? `|${parts[2]}` : '');
};
/**
* Local file loading routine. Allows a File object from a file
* selection form element to be loaded.
*
* @param disk2 Disk2 object
* @param number Drive number
* @param file Browser File object to load
* @returns true if successful
*/
export const loadLocalFile = (
disk2: DiskII,
storage: MassStorage<NibbleFormat|BlockFormat>,
formats: typeof NIBBLE_FORMATS | typeof BLOCK_FORMATS,
number: DriveNumber,
file: File,
) => {
@ -48,16 +45,12 @@ export const loadLocalFile = (
const ext = parts.pop()?.toLowerCase() || '[none]';
const name = parts.join('.');
if (includes(NIBBLE_FORMATS, ext)) {
if (result.byteLength >= 800 * 1024) {
reject(`Unable to load ${name}`);
if (includes(formats, ext)) {
initGamepad();
if (storage.setBinary(number, name, ext, result)) {
resolve(true);
} else {
initGamepad();
if (disk2.setBinary(number, name, ext, result)) {
resolve(true);
} else {
reject(`Unable to load ${name}`);
}
reject(`Unable to load ${name}`);
}
} else {
reject(`Extension "${ext}" not recognized.`);
@ -67,6 +60,32 @@ export const loadLocalFile = (
});
};
/**
* Local file loading routine. Allows a File object from a file
* selection form element to be loaded.
*
* @param smartPort SmartPort object
* @param number Drive number
* @param file Browser File object to load
* @returns true if successful
*/
export const loadLocalBlockFile = (smartPort: SmartPort, number: DriveNumber, file: File) => {
return loadLocalFile(smartPort, BLOCK_FORMATS, number, file);
};
/**
* Local file loading routine. Allows a File object from a file
* selection form element to be loaded.
*
* @param disk2 Disk2 object
* @param number Drive number
* @param file Browser File object to load
* @returns true if successful
*/
export const loadLocalNibbleFile = (disk2: Disk2, number: DriveNumber, file: File) => {
return loadLocalFile(disk2, NIBBLE_FORMATS, number, file);
};
/**
* JSON loading routine, loads a JSON file at the given URL. Requires
* proper cross domain loading headers if the URL is not on the same server
@ -77,7 +96,7 @@ export const loadLocalFile = (
* @param url URL, relative or absolute to JSON file
* @returns true if successful
*/
export const loadJSON = async (disk2: DiskII, number: DriveNumber, url: string) => {
export const loadJSON = async (disk2: Disk2, number: DriveNumber, url: string) => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error loading: ${response.statusText}`);
@ -90,24 +109,12 @@ export const loadJSON = async (disk2: DiskII, number: DriveNumber, url: string)
initGamepad(data.gamepad);
};
/**
* HTTP loading routine, loads a file at the given URL. Requires
* proper cross domain loading headers if the URL is not on the same server
* as the emulator. Only supports nibble based formats at the moment.
*
* @param disk2 Disk2 object
* @param number Drive number
* @param url URL, relative or absolute to JSON file
* @returns true if successful
*/
export const loadHttpFile = async (
disk2: DiskII,
const loadHttpFile = async (
storage: MassStorage<NibbleFormat|BlockFormat>,
formats: typeof NIBBLE_FORMATS | typeof BLOCK_FORMATS,
number: DriveNumber,
url: string,
) => {
if (url.endsWith('.json')) {
return loadJSON(disk2, number, url);
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error loading: ${response.statusText}`);
@ -138,9 +145,48 @@ export const loadHttpFile = async (
const fileParts = file.split('.');
const ext = fileParts.pop()?.toLowerCase() || '[none]';
const name = decodeURIComponent(fileParts.join('.'));
if (!includes(NIBBLE_FORMATS, ext)) {
if (!includes(formats, ext)) {
throw new Error(`Extension "${ext}" not recognized.`);
}
disk2.setBinary(number, name, ext, data);
storage.setBinary(number, name, ext, data);
initGamepad();
};
/**
* HTTP loading routine, loads a file at the given URL. Requires
* proper cross domain loading headers if the URL is not on the same server
* as the emulator.
*
* @param smartPort SmartPort object
* @param number Drive number
* @param url URL, relative or absolute to JSON file
* @returns true if successful
*/
export const loadHttpBlockFile = (
smartPort: SmartPort,
number: DriveNumber,
url: string,
) => {
return loadHttpFile(smartPort, BLOCK_FORMATS, number, url);
};
/**
* HTTP loading routine, loads a file at the given URL. Requires
* proper cross domain loading headers if the URL is not on the same server
* as the emulator.
*
* @param disk2 Disk2 object
* @param number Drive number
* @param url URL, relative or absolute to JSON file
* @returns true if successful
*/
export const loadHttpNibbleFile = (
disk2: Disk2,
number: DriveNumber,
url: string,
) => {
if (url.endsWith('.json')) {
return loadJSON(disk2, number, url);
}
return loadHttpFile(disk2, NIBBLE_FORMATS, number, url);
};

View File

@ -215,6 +215,6 @@ export type FormatWorkerResponse =
/**
* Block device common interface
*/
export interface MassStorage {
setBinary(drive: number, name: string, ext: BlockFormat, data: ArrayBuffer): boolean;
export interface MassStorage<T> {
setBinary(drive: number, name: string, ext: T, data: ArrayBuffer): boolean;
}

View File

@ -74,7 +74,7 @@ apple2.ready.then(() => {
const slinky = new RAMFactor(1024 * 1024);
const disk2 = new DiskII(io, driveLights, sectors);
const clock = new Thunderclock();
const smartport = new SmartPort(cpu, { block: true });
const smartport = new SmartPort(cpu, null, { block: true });
io.setSlot(0, lc);
io.setSlot(1, parallel);

View File

@ -63,7 +63,7 @@ apple2.ready.then(() => {
const slinky = new RAMFactor(1024 * 1024);
const disk2 = new DiskII(io, driveLights);
const clock = new Thunderclock();
const smartport = new SmartPort(cpu, { block: !enhanced });
const smartport = new SmartPort(cpu, null, { block: !enhanced });
const mouse = new Mouse(cpu, mouseUI);
io.setSlot(1, parallel);

View File

@ -13,7 +13,8 @@ import {
MassStorage,
NIBBLE_FORMATS,
JSONBinaryImage,
JSONDisk
JSONDisk,
BlockFormat
} from '../formats/types';
import { initGamepad } from './gamepad';
import KeyBoard from './keyboard';
@ -74,7 +75,7 @@ let stats: Stats;
let vm: VideoModes;
let tape: Tape;
let _disk2: DiskII;
let _massStorage: MassStorage;
let _massStorage: MassStorage<BlockFormat>;
let _printer: Printer;
let audio: Audio;
let screen: Screen;
@ -450,10 +451,8 @@ export function doLoadHTTP(drive: DriveNumber, url?: string) {
const name = decodeURIComponent(fileParts.join('.'));
if (includes(DISK_FORMATS, ext)) {
if (data.byteLength >= 800 * 1024) {
if (
includes(BLOCK_FORMATS, ext) &&
_massStorage.setBinary(drive, name, ext, data)
) {
if (includes(BLOCK_FORMATS, ext)) {
_massStorage.setBinary(drive, name, ext, data);
initGamepad();
}
} else {
@ -842,7 +841,12 @@ function hup() {
return results[1];
}
function onLoaded(apple2: Apple2, disk2: DiskII, massStorage: MassStorage, printer: Printer, e: boolean) {
function onLoaded(
apple2: Apple2,
disk2: DiskII,
massStorage: MassStorage<BlockFormat>,
printer: Printer, e: boolean
) {
_apple2 = apple2;
cpu = _apple2.getCPU();
io = _apple2.getIO();
@ -950,7 +954,11 @@ function onLoaded(apple2: Apple2, disk2: DiskII, massStorage: MassStorage, print
);
}
export function initUI(apple2: Apple2, disk2: DiskII, massStorage: MassStorage, printer: Printer, e: boolean) {
export function initUI(
apple2: Apple2,
disk2: DiskII,
massStorage: MassStorage<BlockFormat>,
printer: Printer, e: boolean) {
window.addEventListener('load', () => {
onLoaded(apple2, disk2, massStorage, printer, e);
});