Debugger disk info groundwork (#145)

This commit is contained in:
Will Scullin 2022-07-23 12:00:38 -07:00 committed by GitHub
parent 9dcc741305
commit e414f8d105
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1685 additions and 144 deletions

View File

@ -76,10 +76,29 @@ export default class ApplesoftDecompiler {
* fail.
*
* @param program The program bytes.
* @param base
* @param base Address of start of program, or 0 to compute start
*/
constructor(private readonly program: ReadonlyUint8Array,
private readonly base: word = 0x801) {
constructor(
private readonly program: ReadonlyUint8Array,
private readonly base: word = 0x801
) {
if (this.base === 0) {
// Signals that we're loading a file from disk, and
// addresses are arbitrarily absolute, so we compute
// base by taking the next line address and adjusting it to
// the actual beginning of the next line.
const nextLine = this.wordAt(0);
// Start at beginning of first line
let nextLineIndex = 4;
// Find 0 at end of line
while (program[nextLineIndex]) {
nextLineIndex++;
}
// Move to beginning of next line
nextLineIndex++;
// Adjust base
this.base = nextLine - nextLineIndex;
}
}
/** Returns the 2-byte word at the given offset. */
@ -99,8 +118,7 @@ export default class ApplesoftDecompiler {
*/
private forEachLine(
from: number, to: number,
callback: (offset: word) => void): void
{
callback: (offset: word) => void): void {
let count = 0;
let offset = 0;
let nextLineAddr = this.wordAt(offset);

View File

@ -4,7 +4,6 @@ import { rom as readOnlyRom } from '../roms/cards/cffa';
import { create2MGFromBlockDisk, HeaderData, read2MGHeader } from '../formats/2mg';
import { ProDOSVolume } from '../formats/prodos';
import createBlockDisk from '../formats/block';
import { dump } from '../formats/prodos/utils';
import {
BlockDisk,
BlockFormat,
@ -441,7 +440,6 @@ export default class CFFA implements Card, MassStorage<BlockFormat>, Restorable<
this._identity[drive][IDENTITY.SectorCountLow] = this._sectors[0].length >> 16;
const prodos = new ProDOSVolume(disk);
dump(prodos);
this._name[drive] = disk.name;
this._partitions[drive] = prodos;
@ -485,7 +483,7 @@ export default class CFFA implements Card, MassStorage<BlockFormat>, Restorable<
if (!blockDisk) {
return null;
}
const { name, blocks } = blockDisk;
const { name, blocks, readOnly } = blockDisk;
let ext;
let data: ArrayBuffer;
if (this._metadata[drive]) {
@ -503,6 +501,7 @@ export default class CFFA implements Card, MassStorage<BlockFormat>, Restorable<
name,
ext,
data,
readOnly,
};
}
}

View File

@ -20,6 +20,7 @@ import {
PROCESS_JSON_DISK,
PROCESS_JSON,
ENCODING_BITSTREAM,
MassStorage,
MassStorageData,
} from '../formats/types';
@ -309,7 +310,7 @@ function setDriveState(state: DriveState) {
/**
* Emulates the 16-sector and 13-sector versions of the Disk ][ drive and controller.
*/
export default class DiskII implements Card<State> {
export default class DiskII implements Card<State>, MassStorage<NibbleFormat> {
private drives: Drive[] = [
{ // Drive 1
@ -415,15 +416,15 @@ export default class DiskII implements Card<State> {
/**
* Spin the disk under the read/write head for WOZ images.
*
*
* This implementation emulates every clock cycle of the 2 MHz
* sequencer since the last time it was called in order to
* determine the current state. Because this is called on
* every access to the softswitches, the data in the latch
* will be correct on every read.
*
*
* The emulation of the disk makes a few simplifying assumptions:
*
*
* * The motor turns on instantly.
* * The head moves tracks instantly.
* * The length (in bits) of each track of the WOZ image
@ -962,24 +963,27 @@ export default class DiskII implements Card<State> {
});
}
// TODO(flan): Does not work with WOZ disks
getBinary(drive: DriveNumber): MassStorageData | null {
// TODO(flan): Does not work with WOZ or D13 disks
getBinary(drive: DriveNumber, ext?: NibbleFormat): MassStorageData | null {
const cur = this.drives[drive - 1];
if (!isNibbleDrive(cur)) {
return null;
}
// TODO(flan): Assumes 16-sectors
const len = (16 * cur.tracks.length * 256);
const { format, name, readOnly, tracks, volume } = cur;
const len = format === 'nib' ?
tracks.reduce((acc, track) => acc + track.length, 0) :
this.sectors * tracks.length * 256;
const data = new Uint8Array(len);
ext = ext ?? format;
let idx = 0;
for (let t = 0; t < cur.tracks.length; t++) {
if (cur.format === 'nib') {
data.set(cur.tracks[t], idx);
idx += cur.tracks[t].length;
for (let t = 0; t < tracks.length; t++) {
if (ext === 'nib') {
data.set(tracks[t], idx);
idx += tracks[t].length;
} else {
for (let s = 0; s < 0x10; s++) {
const sector = readSector(cur, t, s);
const sector = readSector({ ...cur, format: ext }, t, s);
data.set(sector, idx);
idx += sector.length;
}
@ -987,13 +991,15 @@ export default class DiskII implements Card<State> {
}
return {
ext: 'dsk',
name: cur.name,
data: data.buffer
ext,
name,
data: data.buffer,
readOnly,
volume,
};
}
// TODO(flan): Does not work with WOZ disks
// TODO(flan): Does not work with WOZ or D13 disks
getBase64(drive: DriveNumber) {
const cur = this.drives[drive - 1];
if (!isNibbleDrive(cur)) {

View File

@ -5,8 +5,6 @@ import { MassStorage, BlockDisk, ENCODING_BLOCK, BlockFormat, MassStorageData }
import CPU6502, { CpuState, flags } from '../cpu6502';
import { create2MGFromBlockDisk, HeaderData, 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';
@ -132,7 +130,7 @@ export default class SmartPort implements Card, MassStorage<BlockFormat>, Restor
private busy: boolean[] = [];
private busyTimeout: ReturnType<typeof setTimeout>[] = [];
private ext: string[] = [];
private metadata: Array<HeaderData|null> = [];
private metadata: Array<HeaderData | null> = [];
constructor(
private cpu: CPU6502,
@ -550,12 +548,14 @@ export default class SmartPort implements Card, MassStorage<BlockFormat>, Restor
}
setBinary(drive: DriveNumber, name: string, fmt: string, rawData: ArrayBuffer) {
const volume = 254;
const readOnly = false;
let volume = 254;
let readOnly = false;
if (fmt === '2mg') {
const header = read2MGHeader(rawData);
this.metadata[drive] = header;
const { bytes, offset } = header;
volume = header.volume;
readOnly = header.readOnly;
rawData = rawData.slice(offset, offset + bytes);
} else {
this.metadata[drive] = null;
@ -571,9 +571,6 @@ export default class SmartPort implements Card, MassStorage<BlockFormat>, Restor
this.disks[drive] = createBlockDisk(options);
this.callbacks?.label(drive, name);
const prodos = new ProDOSVolume(this.disks[drive]);
dump(prodos);
return true;
}
@ -583,7 +580,7 @@ export default class SmartPort implements Card, MassStorage<BlockFormat>, Restor
}
const disk = this.disks[drive];
const ext = this.ext[drive];
const { name } = disk;
const { name, readOnly } = disk;
let data: ArrayBuffer;
if (ext === '2mg') {
data = create2MGFromBlockDisk(this.metadata[drive], disk);
@ -599,6 +596,7 @@ export default class SmartPort implements Card, MassStorage<BlockFormat>, Restor
name,
ext,
data,
readOnly,
};
}
}

View File

@ -51,7 +51,7 @@ export const Apple2 = (props: Apple2Props) => {
const [apple2, setApple2] = useState<Apple2Impl>();
const [error, setError] = useState<unknown>();
const [ready, setReady] = useState(false);
const [showDebug, setShowDebug] = useState(false);
const [showDebug, setShowDebug] = useState(true);
const drivesReady = useMemo(() => new Ready(setError), []);
const io = apple2?.getIO();

View File

@ -20,4 +20,5 @@
flex-direction: row;
border-bottom: 2px groove;
margin-bottom: 6px;
user-select: none;
}

View File

@ -1,15 +1,17 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import { Inset } from '../Inset';
import { Tab, Tabs } from '../Tabs';
import { Apple2 } from 'js/apple2';
import { useState } from 'preact/hooks';
import { CPU } from './CPU';
import styles from './css/Debugger.module.css';
import { Applesoft } from './Applesoft';
import { CPU } from './CPU';
import { Disks } from './Disks';
import { Memory } from './Memory';
import { VideoModes } from './VideoModes';
import styles from './css/Debugger.module.css';
interface DebuggerProps {
apple2: Apple2 | undefined;
}
@ -27,13 +29,15 @@ export const Debugger = ({ apple2 }: DebuggerProps) => {
<Tab>CPU</Tab>
<Tab>Video</Tab>
<Tab>Memory</Tab>
<Tab>Disks</Tab>
<Tab>Applesoft</Tab>
</Tabs>
<div className={styles.debugger}>
{selected === 0 ? <CPU apple2={apple2} /> : null}
{selected === 1 ? <VideoModes apple2={apple2} /> : null}
{selected === 2 ? <Memory apple2={apple2} /> : null}
{selected === 3 ? <Applesoft apple2={apple2} /> : null}
{selected === 3 ? <Disks apple2={apple2} /> : null}
{selected === 4 ? <Applesoft apple2={apple2} /> : null}
</div>
</Inset>
);

View File

@ -0,0 +1,421 @@
import { h, Fragment } from 'preact';
import { useMemo } from 'preact/hooks';
import cs from 'classnames';
import { Apple2 as Apple2Impl } from 'js/apple2';
import { BlockDisk, DiskFormat, DriveNumber, MassStorage, NibbleDisk } from 'js/formats/types';
import { slot } from 'js/apple2io';
import DiskII from 'js/cards/disk2';
import SmartPort from 'js/cards/smartport';
import createDiskFrom2MG from 'js/formats/2mg';
import createBlockDisk from 'js/formats/block';
import { ProDOSVolume } from 'js/formats/prodos';
import { FILE_TYPES, STORAGE_TYPES } from 'js/formats/prodos/constants';
import { Directory } from 'js/formats/prodos/directory';
import { FileEntry } from 'js/formats/prodos/file_entry';
import { VDH } from 'js/formats/prodos/vdh';
import { toHex } from 'js/util';
import styles from './css/Disks.module.css';
import debuggerStyles from './css/Debugger.module.css';
import { useCallback, useState } from 'preact/hooks';
import { DOS33, FileEntry as DOSEntry, isMaybeDOS33 } from 'js/formats/dos/dos33';
import createDiskFromDOS from 'js/formats/do';
import { FileData, FileViewer } from './FileViewer';
/**
* Formats a short date string
*
* @param date Data object
* @returns Short string date
*/
const formatDate = (date: Date) => {
return date.toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
};
/**
* Guard for determining whether a disk is a nibble or block based disk
*
* @param disk NibbleDisk or BlockDisk
* @returns true if is BlockDisk
*/
function isBlockDisk(disk: NibbleDisk | BlockDisk): disk is BlockDisk {
return !!((disk as BlockDisk).blocks);
}
/**
* Props for FileListing component
*/
interface FileListingProps {
volume: ProDOSVolume;
fileEntry: FileEntry;
depth: number;
setFileData: (fileData: FileData) => void;
}
/**
* Renders a ProDOS file entry.
*
* @param depth Depth of listing from root
* @param fileEntry ProDOS file entry to display
* @returns FileListing component
*/
const FileListing = ({ depth, fileEntry, setFileData }: FileListingProps) => {
const deleted = fileEntry.storageType === STORAGE_TYPES.DELETED;
const doSetFileData = useCallback(() => {
const binary = fileEntry.getFileData();
const text = fileEntry.getFileText();
if (binary && text) {
setFileData({
binary,
text,
fileName: fileEntry.name,
});
}
}, [fileEntry, setFileData]);
return (
<tr>
<td
className={cs(styles.filename, { [styles.deleted]: deleted })}
title={fileEntry.name}
onClick={doSetFileData}
>
{'| '.repeat(depth)}
{deleted ?
<i className="fas fa-file-circle-xmark" /> :
<i className="fas fa-file" />
}
{' '}
{fileEntry.name}
</td>
<td>{FILE_TYPES[fileEntry.fileType] ?? `$${toHex(fileEntry.fileType)}`}</td>
<td>{`$${toHex(fileEntry.auxType, 4)}`}</td>
<td>{fileEntry.blocksUsed}</td>
<td>{formatDate(fileEntry.creation)}</td>
<td>{formatDate(fileEntry.lastMod)}</td>
</tr>
);
};
/**
* Props for DirectoryListing Component.
*/
interface DirectoryListingProps {
volume: ProDOSVolume;
dirEntry: VDH | Directory;
depth: number;
setFileData: (fileData: FileData) => void;
}
/**
* Displays information about a ProDOS directory, recursing through child
* directories.
*
* @param volume ProDOS volume
* @param depth Current directory depth
* @param dirEntry Current directory entry to display
* @returns DirectoryListing component
*/
const DirectoryListing = ({ volume, depth, dirEntry, setFileData }: DirectoryListingProps) => {
const [open, setOpen] = useState(depth === 0);
return (
<>
<tr>
<td
className={styles.filename}
onClick={() => setOpen((open) => !open)}
title={dirEntry.name}
>
{'| '.repeat(depth)}
<i className={cs('fas', { 'fa-folder-open': open, 'fa-folder-closed': !open })} />
{' '}
{dirEntry.name}
</td>
<td></td>
<td></td>
<td></td>
<td>{formatDate(dirEntry.creation)}</td>
<td></td>
</tr>
{open && dirEntry.entries.map((fileEntry, idx) => {
if (fileEntry.storageType === STORAGE_TYPES.DIRECTORY) {
const dirEntry = new Directory(volume, fileEntry);
return <DirectoryListing
key={idx}
depth={depth + 1}
volume={volume}
dirEntry={dirEntry}
setFileData={setFileData}
/>;
} else {
return <FileListing
key={idx}
depth={depth + 1}
volume={volume}
fileEntry={fileEntry}
setFileData={setFileData}
/>;
}
})}
</>
);
};
/**
* Props for CatalogEntry component
*/
interface CatalogEntryProps {
dos: DOS33;
fileEntry: DOSEntry;
setFileData: (fileData: FileData) => void;
}
/**
* Component for a single DOS 3.x catalog entry
*
* @param entry Catalog entry to display
* @returns CatalogEntry component
*/
const CatalogEntry = ({ dos, fileEntry, setFileData }: CatalogEntryProps) => {
const doSetFileData = useCallback(() => {
const { data } = dos.readFile(fileEntry);
setFileData({
binary: data,
text: dos.dumpFile(fileEntry),
fileName: fileEntry.name,
});
}, [dos, fileEntry, setFileData]);
return (
<tr onClick={doSetFileData}>
<td className={cs(styles.filename, { [styles.deleted]: fileEntry.deleted })}>
{fileEntry.locked && <i className="fas fa-lock" />}
{' '}
{fileEntry.name}
</td>
<td>{fileEntry.type}</td>
<td>{fileEntry.size}</td>
<td></td>
</tr>
);
};
/**
* Catalog component props
*/
interface CatalogProps {
dos: DOS33;
setFileData: (fileData: FileData) => void;
}
/**
* DOS 3.3 disk catalog component
*
* @param dos DOS 3.3 disk object
* @returns Catalog component
*/
const Catalog = ({ dos, setFileData }: CatalogProps) => {
const catalog = useMemo(() => dos.readCatalog(), [dos]);
return (
<>
{catalog.map((fileEntry, idx) => (
<CatalogEntry
key={idx}
dos={dos}
fileEntry={fileEntry}
setFileData={setFileData}
/>
))}
</>
);
};
/**
* Props for DiskInfo component
*/
interface DiskInfoProps {
massStorage: MassStorage<DiskFormat>;
drive: DriveNumber;
setFileData: (fileData: FileData) => void;
}
/**
* Top level disk info component, handles determining what sort of disk
* is present and using the appropriate sub-component depending on whether
* it's a ProDOS block disk or a DOS 3.3 disk.
*
* TODO(whscullin): Does not handle woz or 13 sector.
*
* @param massStorage The storage device
* @param drive The drive number
* @returns DiskInfo component
*/
const DiskInfo = ({ massStorage, drive, setFileData }: DiskInfoProps) => {
const disk = useMemo(() => {
const massStorageData = massStorage.getBinary(drive, 'po');
if (massStorageData) {
const { name, data, readOnly, ext } = massStorageData;
let disk: BlockDisk | NibbleDisk | null = null;
if (ext === '2mg') {
disk = createDiskFrom2MG({
name,
rawData: data,
readOnly,
volume: 254,
});
} else if (data.byteLength < 800 * 1024) {
const doData = massStorage.getBinary(drive, 'do');
if (doData) {
if (isMaybeDOS33(doData)) {
disk = createDiskFromDOS({
name,
rawData: doData.data,
readOnly,
volume: 254,
});
}
}
}
if (!disk) {
disk = createBlockDisk({
name,
rawData: data,
readOnly,
volume: 254,
});
}
return disk;
}
return null;
}, [massStorage, drive]);
if (disk) {
try {
if (isBlockDisk(disk)) {
if (disk.blocks.length) {
const prodos = new ProDOSVolume(disk);
const { totalBlocks } = prodos.vdh();
const freeCount = prodos.bitMap().freeBlocks().length;
const usedCount = totalBlocks - freeCount;
return (
<div className={styles.volume}>
<table>
<thead>
<tr>
<th className={styles.filename}>Filename</th>
<th className={styles.type}>Type</th>
<th className={styles.aux}>Aux</th>
<th className={styles.blocks}>Blocks</th>
<th className={styles.created}>Created</th>
<th className={styles.modified}>Modified</th>
</tr>
</thead>
<tbody>
<DirectoryListing
depth={0}
volume={prodos}
dirEntry={prodos.vdh()}
setFileData={setFileData}
/>
</tbody>
<tfoot>
<tr>
<td colSpan={1}>Blocks Free: {freeCount}</td>
<td colSpan={3}>Used: {usedCount}</td>
<td colSpan={2}>Total: {totalBlocks}</td>
</tr>
</tfoot>
</table>
</div>
);
}
} else {
const dos = new DOS33(disk);
return (
<div className={styles.volume}>
<table>
<thead>
<tr>
<th className={styles.filename}>Filename</th>
<th className={styles.type}>Type</th>
<th className={styles.sectors}>Sectors</th>
<th></th>
</tr>
</thead>
<tbody>
<Catalog dos={dos} setFileData={setFileData} />
</tbody>
<tfoot>
<tr>
<td>Volume Number:</td>
<td colSpan={3}>{dos.getVolumeNumber()}</td>
</tr>
<tr>
<td>Used Sectors:</td>
<td colSpan={3}>{dos.usedSectorCount()}</td>
</tr>
<tr>
<td>Free Sectors:</td>
<td colSpan={3}>{dos.freeSectorCount()}</td>
</tr>
</tfoot>
</table>
</div>
);
}
} catch (error) {
console.error(error);
return <pre>Unknown volume</pre>;
}
}
return <pre>No disk</pre>;
};
/**
* Disks component props
*/
export interface DisksProps {
apple2: Apple2Impl;
}
/**
* A debugger panel that displays information about currently mounted
* disks.
*
* @param apple2 The apple2 object
* @returns Disks component
*/
export const Disks = ({ apple2 }: DisksProps) => {
const [fileData, setFileData] = useState<FileData | null>(null);
const io = apple2.getIO();
const cards: MassStorage<DiskFormat>[] = [];
const onClose = useCallback(() => {
setFileData(null);
}, []);
for (let idx = 0; idx <= 7; idx++) {
const card = io.getSlot(idx as slot);
if (card instanceof DiskII || card instanceof SmartPort) {
cards.push(card);
}
}
return (
<div>
{cards.map((card, idx) => (
<div key={idx}>
<div className={debuggerStyles.subHeading}>
{card.constructor.name} - 1
</div>
<DiskInfo massStorage={card} drive={1} setFileData={setFileData} />
<div className={debuggerStyles.subHeading}>
{card.constructor.name} - 2
</div>
<DiskInfo massStorage={card} drive={2} setFileData={setFileData} />
</div>
))}
<FileViewer fileData={fileData} onClose={onClose} />
</div>
);
};

View File

@ -0,0 +1,74 @@
import { h, Fragment } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import { Modal, ModalContent, ModalFooter } from '../Modal';
import styles from './css/FileViewer.module.css';
export interface FileData {
fileName: string;
binary: Uint8Array;
text: string;
}
export interface FileViewerProps {
fileData: FileData | null;
onClose: () => void;
}
export const FileViewer = ({ fileData, onClose }: FileViewerProps) => {
const [binaryHref, setBinaryHref] = useState('');
const [textHref, setTextHref] = useState('');
useEffect(() => {
if (fileData) {
const { binary, text } = fileData;
const binaryBlob = new Blob(
[binary],
{ type: 'application/octet-stream' }
);
const binaryHref = window.URL.createObjectURL(binaryBlob);
setBinaryHref(binaryHref);
const textBlob = new Blob(
[text],
{ type: 'application/octet-stream' }
);
const textHref = window.URL.createObjectURL(textBlob);
setTextHref(textHref);
}
}, [fileData]);
if (!fileData) {
return null;
}
const { fileName, text } = fileData;
return (
<>
<Modal isOpen={true} onClose={onClose} title={fileName}>
<ModalContent>
<pre className={styles.fileViewer} tabIndex={-1} >
{text}
</pre>
</ModalContent>
<ModalFooter>
<a
download={`${fileName}.bin`}
href={binaryHref}
role="button"
>
Download Raw
</a>
<a
download={`${fileName}.txt`}
href={textHref}
role="button"
>
Download Text
</a>
<button onClick={onClose}>Close</button>
</ModalFooter>
</Modal>
</>
);
};

View File

@ -0,0 +1,50 @@
.volume {
font-family: monospace;
width: 100%;
border: 1px inset;
background-color: white;
overflow-y: auto;
height: 320px;
}
.volume table {
border-spacing: 0;
width: 100%;
}
.volume tfoot td {
border-top: 1px solid black;
}
.volume thead th {
border-bottom: 1px solid black;
padding: 2px;
}
.volume tbody {
line-height: 0.8em;
}
.filename {
width: 16em;
max-width: 16em;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.deleted {
text-decoration: line-through;
}
.type {
width: 2em;
}
.sectors {
width: 2em;
}
.aux {
width: 3em;
}

View File

@ -0,0 +1,10 @@
.fileViewer {
background: white;
font: monospace;
width: 60em;
padding: 1em;
height: 60vh;
overflow-y: auto;
border: 2px inset #f0edd0;
white-space: pre-wrap;
}

754
js/formats/dos/dos33.ts Normal file
View File

@ -0,0 +1,754 @@
import { byte, word } from 'js/types';
import { debug, toHex } from 'js/util';
import ApplesoftDump from 'js/applesoft/decompiler';
import IntegerBASICDump from 'js/intbasic/decompiler';
import { MassStorageData, NibbleDisk } from '../types';
import { readSector, writeSector } from '../format_utils';
/** Usual track for VTOC */
export const DEFAULT_VTOC_TRACK = 0x11;
/** Usual sector for VTOC */
export const DEFAULT_VTOC_SECTOR = 0x00;
/** Usual track, sector for VTOC */
export const DEFAULT_VTOC_TRACK_SECTOR = {
track: DEFAULT_VTOC_TRACK,
sector: DEFAULT_VTOC_SECTOR,
} as const;
/**
* VTOC sector offsets
*/
export const VTOC_OFFSETS = {
CATALOG_TRACK: 0x01,
CATALOG_SECTOR: 0x02,
VERSION: 0x03,
VOLUME: 0x06,
TRACK_SECTOR_LIST_SIZE: 0x27,
LAST_ALLOCATION_TRACK: 0x30,
ALLOCATION_DIRECTION: 0x31,
TRACK_COUNT: 0x34,
SECTOR_COUNT: 0x35,
SECTOR_BYTE_COUNT_LOW: 0x36,
SECTOR_BYTE_COUNT_HIGH: 0x37,
FREE_SECTOR_MAP: 0x38,
} as const;
/**
* Catalog sector offsets
*/
export const CATALOG_OFFSETS = {
NEXT_CATALOG_TRACK: 0x01,
NEXT_CATALOG_SECTOR: 0x02,
ENTRY1: 0x0B,
ENTRY2: 0x2E,
ENTRY3: 0x51,
ENTRY4: 0x74,
ENTRY5: 0x97,
ENTRY6: 0xBA,
ENTRY7: 0xDD,
} as const;
/**
* Catalog entry offsets
*/
export const CATALOG_ENTRY_OFFSETS = {
SECTOR_LIST_TRACK: 0x00,
SECTOR_LIST_SECTOR: 0x01,
FILE_TYPE: 0x02,
FILE_NAME: 0x03,
FILE_LENGTH_LOW: 0x21,
FILE_LENGTH_HIGH: 0x22,
} as const;
export const CATALOG_ENTRY_LENGTH = 0x23;
/**
* VTOC Data
*/
export interface VTOC {
catalog: {
track: byte;
sector: byte;
};
version: byte;
volume: byte;
trackSectorListSize: byte;
lastAllocationTrack: byte;
allocationDirection: byte;
trackCount: byte;
sectorCount: byte;
sectorByteCount: byte;
trackSectorMap: boolean[][];
}
/**
* Track and sector data
*/
export interface TrackSector {
track: byte;
sector: byte;
}
/**
* File entry data
*/
export interface FileEntry {
locked: boolean;
deleted: boolean;
type: string;
size: number;
name: string;
trackSectorList: TrackSector;
}
/**
* File data
*/
export interface FileData {
address: word;
data: Uint8Array;
}
function isNibbleDisk(disk: NibbleDisk | MassStorageData): disk is NibbleDisk {
return !!((disk as NibbleDisk).encoding);
}
/**
* DOS 3.3 Volume object.
*/
export class DOS33 {
private vtoc: VTOC;
private files: FileEntry[];
/**
* Constructor can take either a nibblized disk, or a raw data
* object returned by MassStorage.getBinary()
*
* @param disk Nibble disk or MassStorageData object
*/
constructor(private disk: NibbleDisk | MassStorageData) {
this.vtoc = this.readVolumeTOC();
}
/**
* Method to read or write a sector, could be overloaded to support other
* data types.
*
* @param track Track to read/write
* @param sector Sector to read/write
* @param data If present, sector data to write
*
* @returns data read or written
*/
rwts(track: byte, sector: byte, data?: Uint8Array): Uint8Array {
if (data) {
if (isNibbleDisk(this.disk)) {
writeSector(this.disk, track, sector, data);
} else {
const offset = track * 0x1000 + sector * 0x100;
new Uint8Array(this.disk.data).set(data, offset);
}
} else {
if (isNibbleDisk(this.disk)) {
data = readSector(this.disk, track, sector);
} else {
const offset = track * 0x1000 + sector * 0x100;
// Slice new array so modifications to apply to original track
data = new Uint8Array(this.disk.data.slice(offset, offset + 0x100));
}
}
return data;
}
/**
* Creates a classic hex and ascii dump of a sector
*
* @param track Track to dump
* @param sector Sector to dump
* @returns String representation of sector
*/
dumpSector(track: byte, sector: byte) {
let result = '';
const data = this.rwts(track, sector);
let b;
for (let idx = 0; idx < 16; idx++) {
result += toHex(idx << 4) + ': ';
for (let jdx = 0; jdx < 16; jdx++) {
b = data[idx * 16 + jdx];
result += toHex(b) + ' ';
}
result += ' ';
for (let jdx = 0; jdx < 16; jdx++) {
b = data[idx * 16 + jdx] & 0x7f;
if (b >= 0x20 && b < 0x7f) {
result += String.fromCharCode(b);
} else {
result += '.';
}
}
result += '\n';
}
return result;
}
/**
* Returns all the track sector pairs for a file.
*
* @param file File to read
* @param full Also return track sector map entries
* @returns Array of file track and sectors
*/
readFileTrackSectorList(file: FileEntry, full?: boolean) {
const fileTrackSectorList = [];
let { track, sector } = file.trackSectorList;
while (track || sector) {
if (full) {
fileTrackSectorList.push({ track, sector });
}
let jdx = 0; // offset in sector
const data = this.rwts(track, sector);
track = data[0x01];
sector = data[0x02];
let offset = 0x0C; // offset in data
while ((data[offset] || data[offset + 1]) && jdx < 121) {
fileTrackSectorList.push({
track: data[offset],
sector: data[offset + 1]
});
offset += 2;
jdx++;
}
}
return fileTrackSectorList;
}
/**
* Read a file from disk
*
* @param file File entry to read
* @returns Data for file, and load address if binary
*/
readFile(file: FileEntry): FileData {
let data: byte[] = [];
let idx;
const fileTrackSectorList = this.readFileTrackSectorList(file);
for (idx = 0; idx < fileTrackSectorList.length; idx++) {
const { track, sector } = fileTrackSectorList[idx];
data = data.concat([...this.rwts(track, sector)]);
}
let offset = 0;
let length = 0;
let address = 0;
switch (file.type) {
case 'I':
case 'A':
offset = 2;
length = data[0] | data[1] << 8;
break;
case 'T':
length = 0;
while (data[length]) { length++; }
break;
case 'B':
offset = 4;
address = data[0] | data[1] << 8;
length = data[2] | data[3] << 8;
break;
}
data = data.slice(offset, offset + length);
return { data: new Uint8Array(data), address };
}
/**
* Allocate a new sector for a file in the allocation list
*
* @returns track and sector pair
*/
allocateSector(): TrackSector {
const { vtoc } = this;
const findSector = (track: byte) => {
const sectorMap = vtoc.trackSectorMap[track];
return sectorMap.findIndex((sector: boolean) => sector);
};
const lastTrack = vtoc.lastAllocationTrack;
let track = lastTrack;
let sector = findSector(track);
while (sector === -1) {
if (vtoc.allocationDirection === 0x01) {
track = track - 1;
if (track < 0) {
track = vtoc.catalog.track;
vtoc.allocationDirection = 0xff;
}
} else {
track = track + 1;
if (track >= vtoc.trackCount) {
throw new Error('Insufficient free space');
}
}
sector = findSector(track);
}
vtoc.lastAllocationTrack = track;
vtoc.trackSectorMap[track][sector] = false;
return { track, sector };
}
/**
* Compute free sector count.
*
* @returns count of free sectors
*/
freeSectorCount() {
return this.vtoc.trackSectorMap.reduce((count, flags) => (
count + flags.reduce((count, flag) => (
count + (flag ? 1 : 0)
), 0)
), 0);
}
/**
* Compute used sector count
*
* @returns used sector count
*/
usedSectorCount() {
return this.vtoc.trackSectorMap.reduce((count, flags) => (
count + flags.reduce((count, flag) => (
count + (flag ? 0 : 1)
), 0)
), 0);
}
/**
* Writes a file to disk. If new file the file entry must also be
* added to the catalog.
*
* @param file File to write
* @param fileData File data to write, including address if binary
*/
writeFile(file: FileEntry, fileData: FileData) {
let prefix: byte[] = [];
let { data } = fileData;
switch (file.type) {
case 'A':
case 'I':
prefix = [
data.length % 0x100,
data.length >> 8
];
break;
case 'B':
prefix = [
fileData.address % 0x100,
fileData.address >> 8,
data.length % 0x100,
data.length >> 8
];
break;
}
data = new Uint8Array(prefix.length + data.length);
data.set(prefix);
data.set(data, prefix.length);
const { sectorByteCount, trackSectorListSize } = this.vtoc;
const dataRequiredSectors = Math.ceil(data.length / sectorByteCount);
const fileSectorListRequiredSectors = Math.ceil(dataRequiredSectors / trackSectorListSize);
const requiredSectors = dataRequiredSectors + fileSectorListRequiredSectors;
let idx;
let sectors: TrackSector[] = [];
if (file.trackSectorList) {
sectors = this.readFileTrackSectorList(file, true);
}
if (sectors.length > requiredSectors) {
for (idx = requiredSectors; idx < sectors.length; idx++) {
const { track, sector } = sectors[idx];
this.vtoc.trackSectorMap[track][sector] = true;
}
sectors = sectors.slice(0, requiredSectors);
}
if (sectors.length < requiredSectors) {
for (idx = sectors.length; idx < requiredSectors; idx++) {
sectors.push(this.allocateSector());
}
}
file.trackSectorList = { ...sectors[0] };
file.size = requiredSectors;
let jdx = 0;
let lastTrackSectorList = null;
for (idx = 0; idx < dataRequiredSectors; idx++) {
let sector: TrackSector;
let sectorData;
const { trackSectorListSize } = this.vtoc;
if (idx % trackSectorListSize === 0) {
sector = sectors.shift() as TrackSector;
sectorData = new Uint8Array();
if (lastTrackSectorList) {
lastTrackSectorList[0x01] = sector.track;
lastTrackSectorList[0x02] = sector.sector;
}
sectorData[0x05] = idx & 0xff;
sectorData[0x06] = idx >> 8;
for (jdx = 0; jdx < trackSectorListSize && jdx < sectors.length; jdx++) {
const offset = 0xC + jdx * 2;
sectorData[offset] = sectors[jdx].track;
sectorData[offset + 1] = sectors[jdx].sector;
}
lastTrackSectorList = sectorData;
this.rwts(sector.track, sector.sector, new Uint8Array(sectorData));
}
sector = sectors.shift() as TrackSector;
sectorData = new Uint8Array(0x100);
sectorData.set(data.slice(0, 0x100));
data = data.slice(0x100);
this.rwts(sector.track, sector.sector, sectorData);
}
this.writeVolumeTOC();
this.writeCatalog();
}
/**
* Convert a file into a string that can be displayed.
*
* @param file File to convert
* @returns A string representing the file
*/
dumpFile(file: FileEntry) {
let result = null;
const fileData = this.readFile(file);
switch (file.type) {
case 'A':
result = new ApplesoftDump(fileData.data, 0).decompile();
break;
case 'I':
result = new IntegerBASICDump(fileData.data).toString();
break;
case 'T':
result = '';
for (let idx = 0; idx < fileData.data.length; idx++) {
const char = fileData.data[idx] & 0x7f;
if (char < 0x20) {
if (char === 0xd) { // CR
result += '\n';
} else {
result += `$${toHex(char)}`;
}
} else {
result += String.fromCharCode(char);
}
}
break;
case 'B':
default: {
result = '';
let hex = '';
let ascii = '';
for (let idx = 0; idx < fileData.data.length; idx++) {
const val = fileData.data[idx];
if (idx % 16 === 0) {
if (idx !== 0) {
result += `${hex} ${ascii}\n`;
}
hex = '';
ascii = '';
result += `${toHex(fileData.address + idx, 4)}:`;
}
hex += ` ${toHex(val)}`;
ascii += (val & 0x7f) >= 0x20 ? String.fromCharCode(val & 0x7f) : '.';
}
result += '\n';
} break;
}
return result;
}
/**
* Read VTOC data from disk
*
* @param trackSector Track and sector to read from, or default
* @returns VTOC
*/
readVolumeTOC(trackSector: TrackSector = DEFAULT_VTOC_TRACK_SECTOR) {
const data = this.rwts(trackSector.track, trackSector.sector);
this.vtoc = {
catalog: {
track: data[VTOC_OFFSETS.CATALOG_TRACK],
sector: data[VTOC_OFFSETS.CATALOG_SECTOR]
},
version: data[VTOC_OFFSETS.VERSION],
volume: data[VTOC_OFFSETS.VOLUME],
trackSectorListSize: data[VTOC_OFFSETS.TRACK_SECTOR_LIST_SIZE],
lastAllocationTrack: data[VTOC_OFFSETS.LAST_ALLOCATION_TRACK],
allocationDirection: data[VTOC_OFFSETS.ALLOCATION_DIRECTION],
trackCount: data[VTOC_OFFSETS.TRACK_COUNT],
sectorCount: data[VTOC_OFFSETS.SECTOR_COUNT],
sectorByteCount: data[VTOC_OFFSETS.SECTOR_BYTE_COUNT_LOW] |
(data[VTOC_OFFSETS.SECTOR_BYTE_COUNT_HIGH] << 8),
trackSectorMap: []
};
for (let idx = 0; idx < this.vtoc.trackCount; idx++) {
const sectorMap = [];
const offset = 0x38 + idx * 4;
let bitmap =
(data[offset] << 24) |
(data[offset + 1] << 16) |
(data[offset + 2] << 8) |
data[offset + 3];
for (let jdx = 0; jdx < this.vtoc.sectorCount; jdx++) {
sectorMap.unshift(!!(bitmap & 0x80000000));
bitmap <<= 1;
}
this.vtoc.trackSectorMap.push(sectorMap);
}
debug(`DISK VOLUME ${this.vtoc.volume}`);
return this.vtoc;
}
/**
* Write VTOC data back to disk.
*
* @param trackSector Track and sector to read from, or default
*/
writeVolumeTOC(trackSector: TrackSector = DEFAULT_VTOC_TRACK_SECTOR) {
const { vtoc } = this;
const data = new Uint8Array(0x100).fill(0);
data[VTOC_OFFSETS.CATALOG_TRACK] = vtoc.catalog.track;
data[VTOC_OFFSETS.CATALOG_SECTOR] = vtoc.catalog.sector;
data[VTOC_OFFSETS.VERSION] = vtoc.version || 3;
data[VTOC_OFFSETS.VOLUME] = vtoc.volume || 0xFE;
data[VTOC_OFFSETS.TRACK_SECTOR_LIST_SIZE] = vtoc.trackSectorListSize || 0x7a;
data[VTOC_OFFSETS.LAST_ALLOCATION_TRACK] = vtoc.lastAllocationTrack;
data[VTOC_OFFSETS.ALLOCATION_DIRECTION] = vtoc.allocationDirection;
data[VTOC_OFFSETS.TRACK_COUNT] = vtoc.trackCount;
data[VTOC_OFFSETS.SECTOR_COUNT] = vtoc.sectorCount;
data[VTOC_OFFSETS.SECTOR_BYTE_COUNT_LOW] = vtoc.sectorByteCount & 0xff;
data[VTOC_OFFSETS.SECTOR_BYTE_COUNT_HIGH] = vtoc.sectorByteCount >> 8;
for (let idx = 0; idx < vtoc.trackSectorMap.length; idx++) {
const offset = 0x38 + idx * 4;
const sectorMap = vtoc.trackSectorMap[idx];
let mask = 0;
for (let jdx = 0; jdx < sectorMap.length; jdx++) {
mask >>= 1;
if (sectorMap[jdx]) {
mask |= 0x80000000;
}
}
data[offset] = (mask >> 24) & 0xff;
data[offset + 1] = (mask >> 16) & 0xff;
data[offset + 2] = (mask >> 8) & 0xff;
data[offset + 3] = mask & 0xff;
}
this.rwts(trackSector.track, trackSector.sector, data);
}
/**
* Reads catalog from disk.
*
* @returns Catalog entries
*/
readCatalog(): FileEntry[] {
const { catalog } = this.vtoc;
this.files = [];
let catTrack = catalog.track;
let catSector = catalog.sector;
while (catSector || catTrack) {
const data = this.rwts(catTrack, catSector);
catTrack = data[CATALOG_OFFSETS.NEXT_CATALOG_TRACK];
catSector = data[CATALOG_OFFSETS.NEXT_CATALOG_SECTOR];
for (let idx = CATALOG_OFFSETS.ENTRY1; idx < 0x100; idx += CATALOG_ENTRY_LENGTH) {
const file: FileEntry = {
locked: false,
deleted: false,
type: 'A',
size: 0,
name: '',
trackSectorList: { track: 0, sector: 0 },
};
let str = '';
const entry = data.slice(idx, idx + CATALOG_ENTRY_LENGTH);
if (!entry[CATALOG_ENTRY_OFFSETS.SECTOR_LIST_TRACK]) {
continue;
}
file.trackSectorList = {
track: entry[CATALOG_ENTRY_OFFSETS.SECTOR_LIST_TRACK],
sector: entry[CATALOG_ENTRY_OFFSETS.SECTOR_LIST_SECTOR]
};
if (file.trackSectorList.track === 0xff) {
file.deleted = true;
file.trackSectorList.track = entry[CATALOG_ENTRY_OFFSETS.FILE_NAME + 0x20];
}
// Locked
if (entry[CATALOG_ENTRY_OFFSETS.FILE_TYPE] & 0x80) {
file.locked = true;
}
str += file.locked ? '*' : ' ';
// File type
switch (entry[CATALOG_ENTRY_OFFSETS.FILE_TYPE] & 0x7f) {
case 0x00:
file.type = 'T';
break;
case 0x01:
file.type = 'I';
break;
case 0x02:
file.type = 'A';
break;
case 0x04:
file.type = 'B';
break;
case 0x08:
file.type = 'S';
break;
case 0x10:
file.type = 'R';
break;
case 0x20:
file.type = 'A';
break;
case 0x40:
file.type = 'B';
break;
}
str += file.type;
str += ' ';
// Size
file.size = entry[CATALOG_ENTRY_OFFSETS.FILE_LENGTH_LOW] |
entry[CATALOG_ENTRY_OFFSETS.FILE_LENGTH_HIGH] << 8;
str += Math.floor(file.size / 100);
str += Math.floor(file.size / 10) % 10;
str += file.size % 10;
str += ' ';
// Filename
for (let jdx = CATALOG_ENTRY_OFFSETS.FILE_NAME; jdx < 0x21; jdx++) {
file.name += String.fromCharCode(entry[jdx] & 0x7f);
}
str += file.name;
debug(str);
this.files.push(file);
}
}
return this.files;
}
/**
* Writes catalog back to disk
*/
writeCatalog() {
const { catalog } = this.vtoc;
let catTrack = catalog.track;
let catSector = catalog.sector;
while (catSector || catTrack) {
const data = this.rwts(catTrack, catSector);
for (let idx = CATALOG_OFFSETS.ENTRY1; idx < 0x100; idx += CATALOG_ENTRY_OFFSETS.SECTOR_LIST_TRACK) {
const file = this.files.shift();
if (!file?.trackSectorList) {
continue;
}
data[idx + CATALOG_ENTRY_OFFSETS.SECTOR_LIST_TRACK] = file.trackSectorList.track;
data[idx + CATALOG_ENTRY_OFFSETS.SECTOR_LIST_SECTOR] = file.trackSectorList.sector;
data[idx + CATALOG_ENTRY_OFFSETS.FILE_TYPE] = file.locked ? 0x80 : 0x00;
// File type
switch (file.type) {
case 'T':
break;
case 'I':
data[idx + CATALOG_ENTRY_OFFSETS.FILE_TYPE] |= 0x01;
break;
case 'A':
data[idx + CATALOG_ENTRY_OFFSETS.FILE_TYPE] |= 0x02;
break;
case 'B':
data[idx + CATALOG_ENTRY_OFFSETS.FILE_TYPE] |= 0x04;
break;
case 'S':
data[idx + CATALOG_ENTRY_OFFSETS.FILE_TYPE] |= 0x08;
break;
case 'R':
data[idx + CATALOG_ENTRY_OFFSETS.FILE_TYPE] |= 0x10;
break;
}
// Size
data[idx + CATALOG_ENTRY_OFFSETS.FILE_LENGTH_LOW] = file.size & 0xff;
data[idx + CATALOG_ENTRY_OFFSETS.FILE_LENGTH_HIGH] = file.size >> 8;
// Filename
for (let jdx = 0; jdx < 0x1E; jdx++) {
data[idx + CATALOG_ENTRY_OFFSETS.FILE_NAME + jdx] = file.name.charCodeAt(jdx) | 0x80;
}
}
this.rwts(catTrack, catSector, data);
catTrack = data[CATALOG_OFFSETS.NEXT_CATALOG_TRACK];
catSector = data[CATALOG_OFFSETS.NEXT_CATALOG_SECTOR];
}
}
/**
* Return the volume number from the VTOC
*
* @returns Volume number
*/
getVolumeNumber() {
return this.vtoc.volume;
}
}
/**
* Very lose check for DOS disks, currently simply checks for the
* version byte in the probable VTOC.
*
* @param disk Image to check for DOS
* @returns true if VTOC version byte is 3
*/
export function isMaybeDOS33(disk: NibbleDisk | MassStorageData) {
let data;
if (isNibbleDisk(disk)) {
data = readSector(disk, DEFAULT_VTOC_TRACK, DEFAULT_VTOC_SECTOR);
} else if (disk.data.byteLength > 0) {
data = new Uint8Array(
disk.data,
DEFAULT_VTOC_TRACK * 4096 + DEFAULT_VTOC_SECTOR * 0x100,
0x100
);
} else {
return false;
}
return data[VTOC_OFFSETS.VERSION] === 3;
}

View File

@ -319,8 +319,14 @@ export function explodeSector13(volume: byte, track: byte, sector: byte, data: m
return buf;
}
export interface TrackNibble {
track: byte;
sector: byte;
nibble: byte;
}
/**
* Reads a sector of data from a nibblized disk
* Finds a sector of data from a nibblized disk
*
* TODO(flan): Does not work on WOZ disks
*
@ -329,7 +335,7 @@ export function explodeSector13(volume: byte, track: byte, sector: byte, data: m
* @param sector sector number to read
* @returns An array of sector data bytes.
*/
export function readSector(disk: NibbleDisk, track: byte, sector: byte): memory {
export function findSector(disk: NibbleDisk, track: byte, sector: byte): TrackNibble | null {
const _sector = disk.format === 'po' ? _PO[sector] : _DO[sector];
let val, state = 0;
let idx = 0;
@ -352,7 +358,6 @@ export function readSector(disk: NibbleDisk, track: byte, sector: byte): memory
}
}
let t = 0, s = 0, v = 0, checkSum;
const data = new Uint8Array(256);
while (retry < 4) {
switch (state) {
case 0:
@ -380,38 +385,7 @@ export function readSector(disk: NibbleDisk, track: byte, sector: byte): memory
break;
case 4: // Data
if (s === _sector && t === track) {
const data2 = [];
let last = 0;
for (let jdx = 0x55; jdx >= 0; jdx--) {
val = detrans62[_readNext() - 0x80] ^ last;
data2[jdx] = val;
last = val;
}
for (let jdx = 0; jdx < 0x100; jdx++) {
val = detrans62[_readNext() - 0x80] ^ last;
data[jdx] = val;
last = val;
}
checkSum = detrans62[_readNext() - 0x80] ^ last;
if (checkSum) {
debug('Invalid data checksum:', toHex(v), toHex(t), toHex(s), toHex(checkSum));
}
for (let kdx = 0, jdx = 0x55; kdx < 0x100; kdx++) {
data[kdx] <<= 1;
if ((data2[jdx] & 0x01) !== 0) {
data[kdx] |= 0x01;
}
data2[jdx] >>= 1;
data[kdx] <<= 1;
if ((data2[jdx] & 0x01) !== 0) {
data[kdx] |= 0x01;
}
data2[jdx] >>= 1;
if (--jdx < 0) jdx = 0x55;
}
return data;
return { track, sector, nibble: idx };
}
else
_skipBytes(0x159); // Skip data, checksum and footer
@ -421,7 +395,92 @@ export function readSector(disk: NibbleDisk, track: byte, sector: byte): memory
break;
}
}
return new Uint8Array();
return null;
}
/**
* Reads a sector of data from a nibblized disk
*
* TODO(flan): Does not work on WOZ disks
*
* @param disk Nibble disk
* @param track track number to read
* @param sector sector number to read
* @returns An array of sector data bytes.
*/
export function readSector(disk: NibbleDisk, track: byte, sector: byte): Uint8Array {
const trackNibble = findSector(disk, track, sector);
if (!trackNibble) {
return new Uint8Array(0);
}
const { nibble } = trackNibble;
const cur = disk.tracks[track];
let idx = nibble;
function _readNext() {
const result = cur[idx++];
if (nibble >= cur.length) {
idx = 0;
}
return result;
}
const data = new Uint8Array(256);
const data2 = [];
let last = 0;
let val;
for (let jdx = 0x55; jdx >= 0; jdx--) {
val = detrans62[_readNext() - 0x80] ^ last;
data2[jdx] = val;
last = val;
}
for (let jdx = 0; jdx < 0x100; jdx++) {
val = detrans62[_readNext() - 0x80] ^ last;
data[jdx] = val;
last = val;
}
const checkSum = detrans62[_readNext() - 0x80] ^ last;
if (checkSum) {
debug('Invalid data checksum:', toHex(last), toHex(track), toHex(sector), toHex(checkSum));
}
for (let kdx = 0, jdx = 0x55; kdx < 0x100; kdx++) {
data[kdx] <<= 1;
if ((data2[jdx] & 0x01) !== 0) {
data[kdx] |= 0x01;
}
data2[jdx] >>= 1;
data[kdx] <<= 1;
if ((data2[jdx] & 0x01) !== 0) {
data[kdx] |= 0x01;
}
data2[jdx] >>= 1;
if (--jdx < 0) jdx = 0x55;
}
return data;
}
/**
* Reads a sector of data from a nibblized disk
*
* TODO(flan): Does not work on WOZ disks
*
* @param disk Nibble disk
* @param track track number to read
* @param sector sector number to read
* @returns An array of sector data bytes.
*/
export function writeSector(disk: NibbleDisk, track: byte, sector: byte, _data: Uint8Array): boolean {
const trackNibble = findSector(disk, track, sector);
if (!trackNibble) {
return false;
}
// Todo
return true;
}
/**
@ -497,7 +556,7 @@ export function jsonDecode(data: string): NibbleDisk {
*/
export function analyseDisk(disk: NibbleDisk) {
for (let track = 0; track < 35; track++) {
for (let track = 0; track < disk.tracks.length; track++) {
let outStr = `${toHex(track)}: `;
let val, state = 0;
let idx = 0;

View File

@ -11,7 +11,7 @@ import { NibbleDisk, DiskOptions, ENCODING_NIBBLE } from './types';
export default function createDiskFromProDOS(options: DiskOptions) {
const { data, name, side, rawData, volume, readOnly } = options;
const disk: NibbleDisk = {
format: 'nib',
format: 'po',
encoding: ENCODING_NIBBLE,
name,
side,

View File

@ -0,0 +1,14 @@
import { word } from 'js/types';
import { ProDOSVolume } from '.';
export interface ProDOSFileData {
data: Uint8Array;
address: word;
}
export abstract class ProDOSFile {
constructor(public volume: ProDOSVolume) { }
abstract read(): Uint8Array;
abstract write(data: Uint8Array): void;
}

View File

@ -13,11 +13,36 @@ export class BitMap {
this.blocks = volume.blocks();
}
allocBlock () {
freeBlocks() {
const free: word[] = [];
let blockOffset = 0;
let byteOffset = 0;
let bitOffset = 0;
let bitMapBlock = this.blocks[this.vdh.bitMapPointer + blockOffset];
for (let idx = 0; idx < this.vdh.totalBlocks; idx++) {
const blockOffset = this.vdh.bitMapPointer + Math.floor(idx / BLOCK_ENTRIES);
const bitMapBlock = this.blocks[blockOffset];
const currentByte = bitMapBlock[byteOffset];
const mask = 1 << bitOffset;
if (currentByte & mask) {
free.push(idx);
}
bitOffset += 1;
if (bitOffset > 7) {
bitOffset = 0;
byteOffset += 1;
if (byteOffset > (BLOCK_ENTRIES >> 3)) {
byteOffset = 0;
blockOffset += 1;
bitMapBlock = this.blocks[this.vdh.bitMapPointer + blockOffset];
}
}
}
return free;
}
allocBlock() {
for (let idx = 0; idx < this.vdh.totalBlocks; idx++) {
const blockOffset = Math.floor(idx / BLOCK_ENTRIES);
const bitMapBlock = this.blocks[this.vdh.bitMapPointer + blockOffset];
const byteOffset = (idx - blockOffset * BLOCK_ENTRIES) >> 8;
const bits = bitMapBlock[byteOffset];
if (bits !== 0xff) {
@ -38,11 +63,11 @@ export class BitMap {
if (block >= this.vdh.totalBlocks) {
throw new Error('Block out of range');
}
const blockOffset = this.vdh.bitMapPointer + Math.floor(block / BLOCK_ENTRIES);
const blockOffset = Math.floor(block / BLOCK_ENTRIES);
const byteOffset = (block - blockOffset * BLOCK_ENTRIES) >> 8;
const bitOffset = block & 0x7;
const bitMapBlock = this.blocks[blockOffset];
const bitMapBlock = this.blocks[this.vdh.bitMapPointer + blockOffset];
bitMapBlock[byteOffset] &= 0xff ^ (0x01 << bitOffset);
}

View File

@ -1,3 +1,5 @@
import { byte } from 'js/types';
export const BLOCK_SIZE = 512;
export const STORAGE_TYPES = {
@ -5,6 +7,8 @@ export const STORAGE_TYPES = {
SEEDLING: 0x1,
SAPLING: 0x2,
TREE: 0x3,
PASCAL: 0x4,
EXTENDED: 0x5,
DIRECTORY: 0xD,
SUBDIRECTORY_HEADER: 0xE,
VDH_HEADER: 0xF
@ -19,7 +23,7 @@ export const ACCESS_TYPES = {
ALL: 0xE3
} as const;
export const FILE_TYPES = {
export const FILE_TYPES: Record<byte, string> = {
0x00: 'UNK', // Typeless file (SOS and ProDOS)
0x01: 'BAD', // Bad block file
0x02: 'PDC', // Pascal code file

View File

@ -47,6 +47,7 @@ export class Directory {
constructor(private volume: ProDOSVolume, private fileEntry: FileEntry) {
this.blocks = this.volume.blocks();
this.vdh = this.volume.vdh();
this.read();
}
read(fileEntry?: FileEntry) {

View File

@ -1,9 +1,15 @@
import { dateToUint32, readFileName, writeFileName, uint32ToDate } from './utils';
import { STORAGE_TYPES, ACCESS_TYPES } from './constants';
import type { byte, word } from 'js/types';
import { toHex } from 'js/util';
import { ProDOSVolume } from '.';
import { VDH } from './vdh';
import { Directory } from './directory';
import { ProDOSFile } from './base_file';
import { SaplingFile } from './sapling_file';
import { SeedlingFile } from './seedling_file';
import { TreeFile } from './tree_file';
import ApplesoftDump from 'js/applesoft/decompiler';
const ENTRY_OFFSETS = {
STORAGE_TYPE: 0x00,
@ -39,6 +45,8 @@ export class FileEntry {
keyPointer: word = 0;
headerPointer: word = 0;
constructor(public volume: ProDOSVolume) { }
read(block: DataView, offset: word) {
this.block = block;
this.offset = offset;
@ -81,6 +89,60 @@ export class FileEntry {
this.block.setUint32(this.offset + ENTRY_OFFSETS.LAST_MOD, dateToUint32(this.lastMod), true);
this.block.setUint16(this.offset + ENTRY_OFFSETS.HEADER_POINTER, this.headerPointer, true);
}
getFileData() {
let file: ProDOSFile | null = null;
switch (this.storageType) {
case STORAGE_TYPES.SEEDLING:
file = new SeedlingFile(this.volume, this);
break;
case STORAGE_TYPES.SAPLING:
file = new SaplingFile(this.volume, this);
break;
case STORAGE_TYPES.TREE:
file = new TreeFile(this.volume, this);
break;
}
if (file) {
return file.read();
}
}
getFileText() {
const data = this.getFileData();
let result: string | null = null;
let address = 0;
if (data) {
if (this.fileType === 0xFC) { // BAS
result = new ApplesoftDump(data, 0).decompile();
} else {
if (this.fileType === 0x06) { // BIN
address = this.auxType;
}
result = '';
let hex = '';
let ascii = '';
for (let idx = 0; idx < data.length; idx++) {
const val = data[idx];
if (idx % 16 === 0) {
if (idx !== 0) {
result += `${hex} ${ascii}\n`;
}
hex = '';
ascii = '';
result += `${toHex(address + idx, 4)}:`;
}
hex += ` ${toHex(val)}`;
ascii += (val & 0x7f) >= 0x20 ? String.fromCharCode(val & 0x7f) : '.';
}
result += '\n';
}
}
return result;
}
}
export function readEntries(volume: ProDOSVolume, block: DataView, header: VDH | Directory) {
@ -90,17 +152,20 @@ export function readEntries(volume: ProDOSVolume, block: DataView, header: VDH |
let count = 2;
let next = header.next;
for (let idx = 0; idx < header.fileCount; idx++) {
const fileEntry = new FileEntry();
for (let idx = 0; idx < header.fileCount;) {
const fileEntry = new FileEntry(volume);
fileEntry.read(block, offset);
entries.push(fileEntry);
if (fileEntry.storageType !== STORAGE_TYPES.DELETED) {
idx++;
}
offset += header.entryLength;
count++;
if (count >= header.entriesPerBlock) {
if (count > header.entriesPerBlock) {
block = new DataView(blocks[next].buffer);
next = block.getUint16(0x02, true);
offset = 0x4;
count = 0;
count = 1;
}
}

View File

@ -2,15 +2,16 @@ import { ProDOSVolume } from '.';
import type { BitMap } from './bit_map';
import { BLOCK_SIZE, STORAGE_TYPES } from './constants';
import { FileEntry } from './file_entry';
import { ProDOSFile } from './base_file';
export class SaplingFile {
export class SaplingFile extends ProDOSFile {
blocks: Uint8Array[];
bitMap: BitMap;
constructor(private volume: ProDOSVolume, private fileEntry: FileEntry) {
constructor(volume: ProDOSVolume, private fileEntry: FileEntry) {
super(volume);
this.blocks = this.volume.blocks();
this.bitMap = this.volume.bitMap();
}
getBlockPointers() {
@ -19,7 +20,9 @@ export class SaplingFile {
const pointers = [this.fileEntry.keyPointer];
for (let idx = 0; idx < 256; idx++) {
const seedlingPointer = seedlingPointers.getUint16(idx * 2);
const seedlingPointer =
seedlingPointers.getUint8(idx) |
(seedlingPointers.getUint8(0x100 + idx) << 8);
if (seedlingPointer) {
pointers.push(seedlingPointer);
}
@ -27,16 +30,19 @@ export class SaplingFile {
return pointers;
}
// TODO(whscullin): Why did I not use getBlockPointers for these...
read() {
const saplingBlock = this.blocks[this.fileEntry.keyPointer];
const seedlingPointers = new DataView(saplingBlock);
const seedlingPointers = new DataView(saplingBlock.buffer);
let remainingLength = this.fileEntry.eof;
const data = new Uint8Array(remainingLength);
let offset = 0;
let idx = 0;
while (remainingLength > 0) {
const seedlingPointer = seedlingPointers.getUint16(idx * 2);
const seedlingPointer =
seedlingPointers.getUint8(idx) |
(seedlingPointers.getUint8(0x100 + idx) << 8);
if (seedlingPointer) {
const seedlingBlock = this.blocks[seedlingPointer];
const bytes = seedlingBlock.slice(0, Math.min(BLOCK_SIZE, remainingLength));
@ -63,7 +69,8 @@ export class SaplingFile {
while (remainingLength > 0) {
const seedlingPointer = this.bitMap.allocBlock();
seedlingPointers.setUint16(idx * 2, seedlingPointer, true);
seedlingPointers.setUint8(idx, seedlingPointer & 0xff);
seedlingPointers.setUint8(0x100 + idx, seedlingPointer >> 8);
const seedlingBlock = this.blocks[seedlingPointer];
seedlingBlock.set(data.slice(offset, Math.min(BLOCK_SIZE, remainingLength)));
idx++;

View File

@ -1,13 +1,15 @@
import type { ProDOSVolume } from '.';
import { ProDOSFile } from './base_file';
import { BitMap } from './bit_map';
import { STORAGE_TYPES } from './constants';
import { FileEntry } from './file_entry';
export class SeedlingFile {
export class SeedlingFile extends ProDOSFile {
blocks: Uint8Array[];
bitMap: BitMap;
constructor(volume: ProDOSVolume, private fileEntry: FileEntry) {
super(volume);
this.blocks = volume.blocks();
this.bitMap = volume.bitMap();
}

View File

@ -1,13 +1,15 @@
import type { ProDOSVolume } from '.';
import { ProDOSFile } from './base_file';
import { BitMap } from './bit_map';
import { BLOCK_SIZE, STORAGE_TYPES } from './constants';
import type { FileEntry } from './file_entry';
export class TreeFile {
export class TreeFile extends ProDOSFile {
private bitMap: BitMap;
private blocks: Uint8Array[];
constructor (volume: ProDOSVolume, private fileEntry: FileEntry) {
constructor(volume: ProDOSVolume, private fileEntry: FileEntry) {
super(volume);
this.blocks = volume.blocks();
this.bitMap = volume.bitMap();
}
@ -17,12 +19,16 @@ export class TreeFile {
const saplingPointers = new DataView(treeBlock);
const pointers = [];
for (let idx = 0; idx < 256; idx++) {
const saplingPointer = saplingPointers.getUint16(idx * 2);
const saplingPointer =
saplingPointers.getUint8(idx) |
(saplingPointers.getUint8(0x100 + idx) << 8);
if (saplingPointer) {
pointers.push(saplingPointer);
const seedlingPointers = new DataView(this.blocks[saplingPointer]);
for (let jdx = 0; jdx < 256; jdx++) {
const seedlingPointer = seedlingPointers.getUint16(idx * 2);
const seedlingPointer =
seedlingPointers.getUint8(idx) |
(seedlingPointers.getUint8(0x100 + idx) << 8);
if (seedlingPointer) {
pointers.push(seedlingPointer);
}
@ -32,6 +38,7 @@ export class TreeFile {
return pointers;
}
// TODO(whscullin): Why did I not use getBlockPointers for these...
read() {
const treeBlock = this.blocks[this.fileEntry.keyPointer];
const saplingPointers = new DataView(treeBlock);
@ -41,14 +48,18 @@ export class TreeFile {
let idx = 0;
while (remainingLength > 0) {
const saplingPointer = saplingPointers.getUint16(idx * 2, true);
const saplingPointer =
saplingPointers.getUint8(idx) |
(saplingPointers.getUint8(0x100 + idx) << 8);
let jdx = 0;
if (saplingPointer) {
const saplingBlock = this.blocks[saplingPointer];
const seedlingPointers = new DataView(saplingBlock);
while (jdx < 256 && remainingLength > 0) {
const seedlingPointer = seedlingPointers.getUint16(idx * 2, true);
const seedlingPointer =
seedlingPointers.getUint8(idx) |
(seedlingPointers.getUint8(0x100 + idx) << 8);
if (seedlingPointer) {
const seedlingBlock = this.blocks[seedlingPointer];
const bytes = seedlingBlock.slice(Math.min(BLOCK_SIZE, remainingLength));
@ -83,14 +94,16 @@ export class TreeFile {
while (remainingLength > 0) {
const saplingPointer = this.bitMap.allocBlock();
const saplingBlock = this.blocks[saplingPointer];
saplingPointers.setUint16(idx * 2, saplingPointer, true);
saplingPointers.setUint8(idx, saplingPointer & 0xff);
saplingPointers.setUint8(0x100 + idx, saplingPointer >> 8);
const seedlingPointers = new DataView(saplingBlock);
let jdx = 0;
while (jdx < 256 && remainingLength > 0) {
const seedlingPointer = this.bitMap.allocBlock();
seedlingPointers.setUint16(idx * 2, seedlingPointer, true);
seedlingPointers.setUint8(idx, seedlingPointer & 0xff);
seedlingPointers.setUint8(0x100 + idx, seedlingPointer >> 8);
const seedlingBlock = this.blocks[seedlingPointer];
seedlingBlock.set(data.slice(offset, Math.min(BLOCK_SIZE, remainingLength)));
jdx++;

View File

@ -1,4 +1,3 @@
import { debug } from '../../util';
import { STORAGE_TYPES } from './constants';
import { Directory } from './directory';
import type { byte, word } from 'js/types';
@ -19,7 +18,7 @@ export function uint32ToDate(val: word) {
const hour = hourMinute >> 8;
const min = hourMinute & 0xff;
return new Date(1900 + year, month - 1, day, hour, min);
return new Date(year < 70 ? 2000 + year : 1900 + year, month - 1, day, hour, min);
}
return new Date(0);
}
@ -50,9 +49,13 @@ export function readFileName(block: DataView, offset: word, nameLength: byte, ca
if (!(caseBits & 0x8000)) {
caseBits = 0;
}
// File is deleted, brute force old name
if (nameLength === 0) {
nameLength = 15;
}
for (let idx = 0; idx < nameLength; idx++) {
caseBits <<= 1;
const char = String.fromCharCode(block.getUint8(offset + idx));
const char = String.fromCharCode(block.getUint8(offset + idx) & 0x7f);
name += caseBits & 0x8000 ? char.toLowerCase() : char;
}
return name;
@ -74,27 +77,29 @@ export function writeFileName(block: DataView, offset: word, name: string) {
export function dumpDirectory(volume: ProDOSVolume, dirEntry: FileEntry, depth: string) {
const dir = new Directory(volume, dirEntry);
dir.read();
let str = '';
for (let idx = 0; idx < dir.entries.length; idx++) {
const fileEntry = dir.entries[idx];
if (fileEntry.storageType !== STORAGE_TYPES.DELETED) {
debug(depth, fileEntry.name);
str += depth + fileEntry.name + '\n';
if (fileEntry.storageType === STORAGE_TYPES.DIRECTORY) {
dumpDirectory(volume, fileEntry, depth + ' ');
str += dumpDirectory(volume, fileEntry, depth + ' ');
}
}
}
return str;
}
export function dump(volume: ProDOSVolume) {
const vdh = volume.vdh();
debug(vdh.name);
let str = vdh.name;
for (let idx = 0; idx < vdh.entries.length; idx++) {
const fileEntry = vdh.entries[idx];
debug(fileEntry.name);
str += fileEntry.name + '\n';
if (fileEntry.storageType === STORAGE_TYPES.DIRECTORY) {
dumpDirectory(volume, fileEntry, ' ');
str += dumpDirectory(volume, fileEntry, ' ');
}
}
return str;
}

View File

@ -100,7 +100,7 @@ export const BLOCK_FORMATS = [
'po',
] as const;
export const DISK_FORMATS = [...NIBBLE_FORMATS, ...BLOCK_FORMATS ] as const;
export const DISK_FORMATS = [...NIBBLE_FORMATS, ...BLOCK_FORMATS] as const;
export type NibbleFormat = MemberOf<typeof NIBBLE_FORMATS>;
export type BlockFormat = MemberOf<typeof BLOCK_FORMATS>;
@ -217,6 +217,8 @@ export type FormatWorkerResponse =
export interface MassStorageData {
name: string;
ext: string;
readOnly: boolean;
volume?: byte;
data: ArrayBuffer;
}
@ -225,5 +227,5 @@ export interface MassStorageData {
*/
export interface MassStorage<T> {
setBinary(drive: number, name: string, ext: T, data: ArrayBuffer): boolean;
getBinary(drive: number): MassStorageData | null;
getBinary(drive: number, ext?: T): MassStorageData | null;
}

View File

@ -1,12 +1,14 @@
import { byte, Memory, word } from 'js/types';
import { byte, word } from 'js/types';
const LETTERS =
' ' +
' !"#$%&\'()*+,-./0123456789:;<=>?' +
'@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_' +
'`abcdefghijklmnopqrstuvwxyz{|}~ ';
' ' +
' !"#$%&\'()*+,-./0123456789:;<=>?' +
'@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_' +
'`abcdefghijklmnopqrstuvwxyz{|}~ ';
const TOKENS: Record<byte, string> = {
0x00: 'HIMEM:',
0x01: '$01',
0x02: '_',
0x03: ':',
0x04: 'LOAD',
@ -45,8 +47,8 @@ const TOKENS: Record<byte, string> = {
0x25: 'THEN',
0x26: ',',
0x27: ',',
0x28: '\'',
0x29: '\'',
0x28: '"',
0x29: '"',
0x2A: '(',
0x2B: '!',
0x2C: '!',
@ -114,6 +116,7 @@ const TOKENS: Record<byte, string> = {
0x6A: ',',
0x6B: 'AT',
0x6C: 'VLIN',
0x6D: ',',
0x6E: 'AT',
0x6F: 'VTAB',
0x70: '=',
@ -121,6 +124,7 @@ const TOKENS: Record<byte, string> = {
0x72: ')',
0x73: ')',
0x74: 'LIST',
0x75: ',',
0x76: 'LIST',
0x77: 'POP',
0x78: 'NODSP',
@ -133,15 +137,11 @@ const TOKENS: Record<byte, string> = {
0x7F: 'IN#'
};
export default class IntBasicDump
{
constructor(private mem: Memory) {}
export default class IntBasicDump {
constructor(private data: Uint8Array) { }
private readByte(addr: word) {
const page = addr >> 8,
off = addr & 0xff;
return this.mem.read(page, off);
return this.data[addr];
}
private readWord(addr: word) {
@ -151,12 +151,14 @@ export default class IntBasicDump
return (msb << 8) | lsb;
}
toString () {
toString() {
let str = '';
let addr = this.readWord(0xca); // Start
const himem = this.readWord(0x4c);
let addr = 0;
const himem = this.data.length;
do {
/*var len = */this.readByte(addr++);
let inRem = false;
let inQuote = false;
/* const length = */ this.readByte(addr++);
const lineno = this.readWord(addr);
addr += 2;
@ -165,20 +167,18 @@ export default class IntBasicDump
let val = 0;
do {
val = this.readByte(addr++);
if (val >= 0xB0 && val <= 0xB9) {
if (!inRem && !inQuote && val >= 0xB0 && val <= 0xB9) {
str += this.readWord(addr);
addr += 2;
}
else if (val < 0x80 && val > 0x01) {
} else if (val < 0x80 && val > 0x01) {
const t = TOKENS[val];
if (t.length > 1)
str += ' ';
if (t.length > 1) { str += ' '; }
str += t;
if (t.length > 1)
str += ' ';
}
else if (val > 0x80)
str += LETTERS[val - 0x80];
if (t.length > 1) { str += ' '; }
if (val === 0x28) { inQuote = true; }
if (val === 0x29) { inQuote = false; }
if (val === 0x5d) { inRem = true; }
} else if (val > 0x80) { str += LETTERS[val - 0x80]; }
} while (val !== 0x01);
str += '\n';
} while (addr < himem);

View File

@ -34,6 +34,15 @@ describe('ApplesoftDecompiler', () => {
expect(program).toEqual(' 10 PRINT "Hello, World!"\n');
});
it('correctly computes the base address when 0 is passed in', () => {
const compiler = new ApplesoftCompiler();
compiler.compile('10 PRINT "Hello, World!"\n20 GOTO 10');
const decompiler = new ApplesoftDecompiler(compiler.program(), 0);
const program = decompiler.list();
expect(program).toEqual(' 10 PRINT "Hello, World!"\n 20 GOTO 10\n');
});
it('lists a program with a long line', () => {
const compiler = new ApplesoftCompiler();
compiler.compile('10 PRINT "Hello, World!"\n'
@ -219,4 +228,4 @@ describe('ApplesoftDecompiler', () => {
const program = decompiler.decompile({ style: 'pretty' });
expect(program).toEqual('10 HPLOT X, Y : GOTO 10');
});
});
});