Preact mass storage (#125)

The major impetus for rewriting in UI, at least. Still some ironing to do, but much nicer than my attempt to do this using the old UI "framework".
This commit is contained in:
Will Scullin 2022-06-05 10:57:04 -07:00 committed by GitHub
parent 15b7f1e123
commit 66f3e04d8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 612 additions and 133 deletions

View File

@ -31,7 +31,6 @@
],
"no-var": "error",
"no-use-before-define": "off",
"no-dupe-class-members": "off",
"no-console": [
"error",
{
@ -213,4 +212,4 @@
"version": "16"
}
}
}
}

BIN
css/green-off-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 B

BIN
css/green-off-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
css/green-on-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

BIN
css/green-on-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

0
css/red-off-16.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 764 B

After

Width:  |  Height:  |  Size: 764 B

BIN
css/red-off-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

0
css/red-on-16.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 869 B

After

Width:  |  Height:  |  Size: 869 B

BIN
css/red-on-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

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,18 @@ 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[] = [];
private busy: boolean[] = [];
private busyTimeout: ReturnType<typeof setTimeout>[] = [];
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;
@ -139,11 +152,23 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
// debug.apply(this, arguments);
}
private driveLight(drive: DriveNumber) {
if (!this.busy[drive]) {
this.busy[drive] = true;
this.callbacks?.driveLight(drive, true);
}
clearTimeout(this.busyTimeout[drive]);
this.busyTimeout[drive] = setTimeout(() => {
this.busy[drive] = false;
this.callbacks?.driveLight(drive, false);
}, 100);
}
/*
* dumpBlock
*/
dumpBlock(drive: number, block: number) {
dumpBlock(drive: DriveNumber, block: number) {
let result = '';
let b;
let jdx;
@ -178,7 +203,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 +221,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)}`);
@ -209,6 +234,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
}
// debug('read', '\n' + dumpBlock(drive, block));
this.driveLight(drive);
for (let idx = 0; idx < 512; idx++) {
buffer.writeByte(this.disks[drive].blocks[block][idx]);
@ -223,7 +249,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)}`);
@ -243,6 +269,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
}
// debug('write', '\n' + dumpBlock(drive, block));
this.driveLight(drive);
for (let idx = 0; idx < 512; idx++) {
this.disks[drive].blocks[block][idx] = buffer.readByte();
@ -256,7 +283,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 +379,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 +402,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 +472,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 +547,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 +562,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

@ -1,10 +1,11 @@
import { h } from 'preact';
import cs from 'classnames';
import {useEffect, useRef, useState } from 'preact/hooks';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Apple2 as Apple2Impl } from '../apple2';
import Apple2IO from '../apple2io';
import CPU6502 from '../cpu6502';
import { ControlStrip } from './ControlStrip';
import { ErrorModal } from './ErrorModal';
import { Inset } from './Inset';
import { Keyboard } from './Keyboard';
import { Mouse } from './Mouse';
@ -12,7 +13,7 @@ import { Screen } from './Screen';
import { Drives } from './Drives';
import { Slinky } from './Slinky';
import { ThunderClock } from './ThunderClock';
import { ErrorModal } from './ErrorModal';
import { noAwait, Ready } from './util/promises';
import styles from './css/Apple2.module.css';
@ -38,12 +39,13 @@ export interface Apple2Props {
* @returns
*/
export const Apple2 = (props: Apple2Props) => {
const { e, sectors } = props;
const { e, enhanced, sectors } = props;
const screen = useRef<HTMLCanvasElement>(null);
const [apple2, setApple2] = useState<Apple2Impl>();
const [io, setIO] = useState<Apple2IO>();
const [cpu, setCPU] = useState<CPU6502>();
const [error, setError] = useState<unknown>();
const drivesReady = useMemo(() => new Ready(setError), []);
useEffect(() => {
if (screen.current) {
@ -53,26 +55,30 @@ export const Apple2 = (props: Apple2Props) => {
...props,
};
const apple2 = new Apple2Impl(options);
apple2.ready.then(() => {
setApple2(apple2);
const io = apple2.getIO();
const cpu = apple2.getCPU();
setIO(io);
setCPU(cpu);
apple2.reset();
apple2.run();
}).catch((e) => setError(e));
noAwait((async () => {
try {
await apple2.ready;
setApple2(apple2);
setIO(apple2.getIO());
setCPU(apple2.getCPU());
await drivesReady.ready;
apple2.reset();
apple2.run();
} catch (e) {
setError(e);
}
}))();
}
}, [props]);
}, [props, drivesReady]);
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} enhanced={enhanced} ready={drivesReady} />
</Inset>
<ControlStrip apple2={apple2} e={e} />
<Inset>

View File

@ -0,0 +1,74 @@
import { h } from 'preact';
import { useCallback, useState } from 'preact/hooks';
import cs from 'classnames';
import SmartPort from '../cards/smartport';
import { BlockFileModal } from './BlockFileModal';
import { ErrorModal } from './ErrorModal';
import styles from './css/BlockDisk.module.css';
/**
* Storage structure for drive state returned via callbacks.
*/
export interface BlockDiskData {
number: 1 | 2;
on: boolean;
name?: string;
}
/**
* Interface for BlockDisk.
*/
export interface BlockDiskProps extends BlockDiskData {
smartPort: SmartPort | undefined;
}
/**
* BlockDisk component
*
* Includes drive light, disk name and side, and UI for loading disks.
*
* @param smartPort SmartPort 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);
const [error, setError] = useState<unknown>();
const doClose = useCallback(() => {
setModalOpen(false);
}, []);
const onOpenModal = useCallback(() => {
setModalOpen(true);
}, []);
return (
<div className={styles.disk}>
<ErrorModal error={error} setError={setError} />
<BlockFileModal
smartPort={smartPort}
number={number}
onClose={doClose}
isOpen={modalOpen}
/>
<div
id={`disk${number}`}
className={cs(styles.diskLight, { [styles.on]: on })}
/>
<button title="Load Disk" onClick={onOpenModal}>
<i className="fas fa-folder-open" />
</button>
<div
id={`disk-label${number}`}
className={styles.diskLabel}
>
{name}
</div>
</div>
);
};

View File

@ -0,0 +1,77 @@
import { h, Fragment } from 'preact';
import { useCallback, useState } from 'preact/hooks';
import { DriveNumber, BLOCK_FORMATS } from '../formats/types';
import { ErrorModal } from './ErrorModal';
import { FileChooser } from './FileChooser';
import { Modal, ModalContent, ModalFooter } from './Modal';
import { loadLocalBlockFile, getHashParts, setHashParts } from './util/files';
import SmartPort from 'js/cards/smartport';
import { useHash } from './hooks/useHash';
import { noAwait } from './util/promises';
import styles from './css/BlockFileModal.module.css';
const DISK_TYPES: FilePickerAcceptType[] = [
{
description: 'Disk Images',
accept: { 'application/octet-stream': BLOCK_FORMATS.map(x => '.' + x) },
}
];
interface BlockFileModalProps {
isOpen: boolean;
smartPort: SmartPort | undefined;
number: DriveNumber;
onClose: (closeBox?: boolean) => void;
}
export const BlockFileModal = ({ smartPort, number, onClose, isOpen } : BlockFileModalProps) => {
const [handles, setHandles] = useState<FileSystemFileHandle[]>();
const [busy, setBusy] = useState<boolean>(false);
const [empty, setEmpty] = useState<boolean>(true);
const [error, setError] = useState<unknown>();
const hash = useHash();
const doCancel = useCallback(() => onClose(true), [onClose]);
const doOpen = useCallback(async () => {
const hashParts = getHashParts(hash);
if (smartPort && handles?.length === 1) {
hashParts[number] = '';
setBusy(true);
try {
await loadLocalBlockFile(smartPort, number, await handles[0].getFile());
} catch (error) {
setError(error);
} finally {
setBusy(false);
onClose();
}
}
setHashParts(hashParts);
}, [handles, hash, smartPort, number, onClose]);
const onChange = useCallback((handles: FileSystemFileHandle[]) => {
setEmpty(handles.length === 0);
setHandles(handles);
}, []);
return (
<>
<Modal title="Open File" isOpen={isOpen} onClose={onClose}>
<ModalContent>
<div className={styles.modalContent}>
<FileChooser onChange={onChange} accept={DISK_TYPES} />
</div>
</ModalContent>
<ModalFooter>
<button onClick={doCancel}>Cancel</button>
<button onClick={noAwait(doOpen)} disabled={busy || empty}>Open</button>
</ModalFooter>
</Modal>
<ErrorModal error={error} setError={setError} />
</>
);
};

View File

@ -1,11 +1,9 @@
import { h } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
import { useCallback, 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 { ErrorModal } from './ErrorModal';
import { useHash } from './hooks/useHash';
import { FileModal } from './FileModal';
import styles from './css/DiskII.module.css';
@ -30,7 +28,6 @@ export interface DiskIIProps extends DiskIIData {
* 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
@ -43,28 +40,6 @@ export const DiskII = ({ disk2, number, on, name, side }: DiskIIProps) => {
const label = side ? `${name} - ${side}` : name;
const [modalOpen, setModalOpen] = useState(false);
const [error, setError] = useState<unknown>();
const [currentHash, setCurrentHash] = useState<string>();
const hash = useHash();
useEffect(() => {
const hashParts = getHashParts(hash);
const newHash = hashParts[number];
if (disk2 && newHash) {
const hashPart = decodeURIComponent(newHash);
if (hashPart !== currentHash) {
if (hashPart.match(/^https?:/)) {
loadHttpFile(disk2, number, hashPart)
.catch((e) => setError(e));
} else {
const filename = `/json/disks/${hashPart}.json`;
loadJSON(disk2, number, filename)
.catch((e) => setError(e));
}
setCurrentHash(hashPart);
}
}
}, [currentHash, disk2, hash, number]);
const doClose = useCallback(() => {
setModalOpen(false);

View File

@ -1,26 +1,53 @@
import { h, Fragment } from 'preact';
import {useEffect, useState } from 'preact/hooks';
import { h } from 'preact';
import { useCallback, 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';
import { ErrorModal } from './ErrorModal';
import { ProgressModal } from './ProgressModal';
import { loadHttpUnknownFile, getHashParts, loadJSON } from './util/files';
import { useHash } from './hooks/useHash';
import { DriveNumber } from 'js/formats/types';
import { Ready } from './util/promises';
import styles from './css/Drives.module.css';
/**
* Interface for Drives component.
*/
export interface DrivesProps {
cpu: CPU6502 | undefined;
io: Apple2IO | undefined;
enhanced: boolean;
sectors: number;
ready: Ready;
}
/**
* Drive interface component. Presents the interface to load disks.
* Provides the callback to the Disk2 object to update the DiskII
* components.
* Provides the callback to the Disk2 and SmartPort objects to update
* the DiskII and BlockDisk components.
* Handles initial loading of disks specified in the hash.
*
* @cpu CPU object
* @param io Apple I/O object
* @param sectors 13 or 16 sector rom mode
* @enhanced Whether to create a SmartPort ROM device
* @ready Signal disk availability
* @returns Drives component
*/
export const Drives = ({ io, sectors }: DrivesProps) => {
export const Drives = ({ cpu, io, sectors, enhanced, ready }: DrivesProps) => {
const [current, setCurrent] = useState(0);
const [error, setError] = useState<unknown>();
const [total, setTotal] = useState(0);
const onProgress = useCallback((current: number, total: number) => {
setCurrent(current);
setTotal(total);
}, []);
const [disk2, setDisk2] = useState<Disk2>();
const [data1, setData1] = useState<DiskIIData>({
on: false,
@ -32,9 +59,54 @@ export const Drives = ({ io, sectors }: DrivesProps) => {
number: 2,
name: 'Disk 2',
});
const [smartData1, setSmartData1] = useState<DiskIIData>({
on: false,
number: 1,
name: 'HD 1'
});
const [smartData2, setSmartData2] = useState<DiskIIData>({
on: false,
number: 2,
name: 'HD 2'
});
const [smartPort, setSmartPort] = useState<SmartPort>();
const hash = useHash();
useEffect(() => {
if (smartPort && disk2) {
const hashParts = getHashParts(hash);
let loading = 0;
for (const drive of [1, 2] as DriveNumber[]) {
if (hashParts && hashParts[drive]) {
const hashPart = decodeURIComponent(hashParts[drive]);
if (hashPart.match(/^https?:/)) {
loading++;
loadHttpUnknownFile(disk2, smartPort, drive, hashPart, onProgress)
.catch((e) => setError(e))
.finally(() => {
if (--loading === 0) {
ready.onReady();
}
setCurrent(0);
setTotal(0);
});
} else {
const url = `/json/disks/${hashPart}.json`;
loadJSON(disk2, drive, url).catch((e) => setError(e));
}
}
}
if (!loading) {
ready.onReady();
}
}
}, [hash, onProgress, disk2, ready, smartPort]);
useEffect(() => {
const setData = [setData1, setData2];
const setSmartData = [setSmartData1, setSmartData2];
const callbacks: Callbacks = {
driveLight: (drive, on) => {
setData[drive - 1]?.(data => ({...data, on }));
@ -51,17 +123,42 @@ 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 ?? `HD ${drive}`,
side,
}));
},
dirty: () => {/* Unused */}
};
if (cpu && io) {
const disk2 = new Disk2(io, callbacks, sectors);
io.setSlot(6, disk2);
setDisk2(disk2);
const smartPort = new SmartPort(cpu, smartPortCallbacks, { block: !enhanced });
io.setSlot(7, smartPort);
setSmartPort(smartPort);
}
}, [io, sectors]);
}, [cpu, enhanced, io, sectors]);
return (
<>
<DiskII disk2={disk2} {...data1} />
<DiskII disk2={disk2} {...data2} />
</>
<div className={styles.drives}>
<ProgressModal current={current} total={total} title="Loading..." />
<ErrorModal error={error} setError={setError} />
<div className={styles.driveBay}>
<DiskII disk2={disk2} {...data1} />
<DiskII disk2={disk2} {...data2} />
</div>
<div className={styles.driveBay}>
<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

@ -0,0 +1,29 @@
import { h } from 'preact';
import { Modal, ModalContent } from './Modal';
import styles from './css/ProgressModal.module.css';
export interface ErrorProps {
title: string;
current: number | undefined;
total: number | undefined;
}
export const ProgressModal = ({ title, current, total } : ErrorProps) => {
if (current && total) {
return (
<Modal title={title} isOpen={true}>
<ModalContent>
<div className={styles.progressContainer}>
<div
className={styles.progressBar}
style={{ width: Math.floor(320 * (current / total)) }}
/>
</div>
</ModalContent>
</Modal>
);
} else {
return null;
}
};

View File

@ -0,0 +1,38 @@
.disk {
align-items: center;
display: flex;
flex-grow: 1;
}
.diskLight {
margin: 5px;
background-image: url(../../../css/green-off-16.png);
background-size: 16px 16px;
flex-shrink: 0;
width: 16px;
height: 16px;
}
.diskLight.on {
background-image: url(../../../css/green-on-16.png);
}
.diskLabel {
color: #000;
font-family: sans-serif;
font-weight: bold;
margin-right: 0.5em;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
flex-grow: 1;
}
@media only screen and (min-resolution: 1.25dppx) {
.diskLight {
background-image: url(../../../css/green-off-32.png);
}
.diskLight.on {
background-image: url(../../../css/green-on-32.png);
}
}

View File

@ -0,0 +1,3 @@
.modalContent {
width: 320px;
}

View File

@ -2,12 +2,12 @@
align-items: center;
display: flex;
flex-grow: 1;
max-width: 50%;
}
.diskLight {
margin: 5px;
background-image: url(../../../css/red-off-16.png);
background-size: 16px 16px;
flex-shrink: 0;
width: 16px;
height: 16px;
@ -27,3 +27,12 @@
white-space: nowrap;
flex-grow: 1;
}
@media only screen and (min-resolution: 1.25dppx) {
.diskLight {
background-image: url(../../../css/red-off-32.png);
}
.diskLight.on {
background-image: url(../../../css/red-on-32.png);
}
}

View File

@ -0,0 +1,10 @@
.drives {
display: flex;
width: 100%;
}
.driveBay {
display: flex;
flex-direction: column;
flex: 1 1 50%;
}

View File

@ -11,7 +11,6 @@
display: none;
}
.inset button {
min-width: 36px;
margin: 0 2px;

View File

@ -0,0 +1,10 @@
.progressContainer {
width: 320px;
height: 20px;
background: #000
}
.progressBar {
height: 20px;
background: #0f0;
}

View File

@ -1,11 +1,19 @@
import { includes } from 'js/types';
import { initGamepad } from 'js/ui/gamepad';
import {
BlockFormat,
BLOCK_FORMATS,
DISK_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';
type ProgressCallback = (current: number, total: number) => void;
/**
* Routine to split a legacy hash into parts for disk loading
@ -26,17 +34,19 @@ 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 getNameAndExtension = (url: string) => {
const urlParts = url.split('/');
const file = urlParts.pop() || url;
const fileParts = file.split('.');
const ext = fileParts.pop()?.toLowerCase() || '[none]';
const name = decodeURIComponent(fileParts.join('.'));
return { name, ext };
};
export const loadLocalFile = (
disk2: DiskII,
storage: MassStorage<NibbleFormat|BlockFormat>,
formats: typeof NIBBLE_FORMATS | typeof BLOCK_FORMATS,
number: DriveNumber,
file: File,
) => {
@ -48,16 +58,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 +73,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,37 +109,27 @@ 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}`);
}
const data = await response.json() as JSONDisk;
if (!includes(NIBBLE_FORMATS, data.type)) {
throw new Error(`Type ${data.type} not recognized.`);
throw new Error(`Type "${data.type}" not recognized.`);
}
disk2.setDisk(number, data);
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,
number: DriveNumber,
url: string,
) => {
if (url.endsWith('.json')) {
return loadJSON(disk2, number, url);
}
onProgress?: ProgressCallback
): Promise<ArrayBuffer> => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error loading: ${response.statusText}`);
@ -116,13 +138,16 @@ export const loadHttpFile = async (
throw new Error('Error loading: no body');
}
const reader = response.body.getReader();
const contentLength = parseInt(response.headers.get('content-length') || '0', 10);
let received = 0;
const chunks: Uint8Array[] = [];
let result = await reader.read();
onProgress?.(1, contentLength);
while (!result.done) {
chunks.push(result.value);
received += result.value.length;
onProgress?.(received, contentLength);
result = await reader.read();
}
@ -133,14 +158,87 @@ export const loadHttpFile = async (
offset += chunk.length;
}
const urlParts = url.split('/');
const file = urlParts.pop() || url;
const fileParts = file.split('.');
const ext = fileParts.pop()?.toLowerCase() || '[none]';
const name = decodeURIComponent(fileParts.join('.'));
return data.buffer;
};
/**
* 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 = async (
smartPort: SmartPort,
number: DriveNumber,
url: string,
onProgress?: ProgressCallback
): Promise<boolean> => {
const { name, ext } = getNameAndExtension(url);
if (!includes(BLOCK_FORMATS, ext)) {
throw new Error(`Extension "${ext}" not recognized.`);
}
const data = await loadHttpFile(url, onProgress);
smartPort.setBinary(number, name, ext, data);
initGamepad();
return true;
};
/**
* 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 = async (
disk2: Disk2,
number: DriveNumber,
url: string,
onProgress?: ProgressCallback
) => {
if (url.endsWith('.json')) {
return loadJSON(disk2, number, url);
}
const { name, ext } = getNameAndExtension(url);
if (!includes(NIBBLE_FORMATS, ext)) {
throw new Error(`Extension "${ext}" not recognized.`);
}
const data = await loadHttpFile(url, onProgress);
disk2.setBinary(number, name, ext, data);
initGamepad();
return loadHttpFile(url, onProgress);
};
export const loadHttpUnknownFile = async (
disk2: Disk2,
smartPort: SmartPort,
number: DriveNumber,
url: string,
onProgress?: ProgressCallback,
) => {
const data = await loadHttpFile(url, onProgress);
const { name, ext } = getNameAndExtension(url);
if (includes(DISK_FORMATS, ext)) {
if (data.byteLength >= 800 * 1024) {
if (includes(BLOCK_FORMATS, ext)) {
smartPort.setBinary(number, name, ext, data);
} else {
throw new Error(`Unable to load "${name}"`);
}
} else if (includes(NIBBLE_FORMATS, ext)) {
disk2.setBinary(number, name, ext, data);
} else {
throw new Error(`Unable to load "${name}"`);
}
} else {
throw new Error(`Extension "${ext}" not recognized.`);
}
};

View File

@ -161,7 +161,6 @@ const uiKitMap = {
'UIKeyInputEscape': 0x1B
} as const;
export const isUiKitKey = (k: string): k is KnownKeys<typeof uiKitMap> => {
return k in uiKitMap;
};

View File

@ -10,3 +10,22 @@ export type NoAwait<F extends (...args: unknown[]) => Promise<unknown>> =
export function noAwait<F extends (...args: unknown[]) => Promise<unknown>>(f: F): NoAwait<F> {
return f as NoAwait<F>;
}
/**
* Utility class that allows a promise to be passed to a
* service to be resolved.
*/
export class Ready {
onError: (value?: unknown) => void;
onReady: (value?: unknown) => void;
ready: Promise<unknown>;
constructor(private errorHandler = console.error) {
this.ready = new Promise((resolve, reject) => {
this.onReady = resolve;
this.onError = reject;
}).catch(this.errorHandler);
}
}

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;
@ -270,8 +271,8 @@ export function doLoad(event: MouseEvent|KeyboardEvent) {
} else if (url) {
let filename;
MicroModal.close('load-modal');
if (url.substr(0, 6) === 'local:') {
filename = url.substr(6);
if (url.slice(0, 6) === 'local:') {
filename = url.slice(6);
if (filename === '__manage') {
openManage();
} else {
@ -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);
});

View File

@ -354,7 +354,7 @@ export default class KeyBoard {
const buildLabel = (k: string) => {
const span = document.createElement('span');
span.innerHTML = k;
if (k.length > 1 && k.substr(0, 1) !== '&')
if (k.length > 1 && k.slice(0, 1) !== '&')
span.classList.add('small');
return span;
};