diff --git a/js/applesoft/decompiler.ts b/js/applesoft/decompiler.ts index d09ccd1..85a28b5 100644 --- a/js/applesoft/decompiler.ts +++ b/js/applesoft/decompiler.ts @@ -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); diff --git a/js/cards/cffa.ts b/js/cards/cffa.ts index 2c89d43..aee0677 100644 --- a/js/cards/cffa.ts +++ b/js/cards/cffa.ts @@ -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, 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, 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, Restorable< name, ext, data, + readOnly, }; } } diff --git a/js/cards/disk2.ts b/js/cards/disk2.ts index a569719..e15e2d6 100644 --- a/js/cards/disk2.ts +++ b/js/cards/disk2.ts @@ -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 { +export default class DiskII implements Card, MassStorage { private drives: Drive[] = [ { // Drive 1 @@ -415,15 +416,15 @@ export default class DiskII implements Card { /** * 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 { }); } - // 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 { } 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)) { diff --git a/js/cards/smartport.ts b/js/cards/smartport.ts index 75e6188..6cea143 100644 --- a/js/cards/smartport.ts +++ b/js/cards/smartport.ts @@ -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, Restor private busy: boolean[] = []; private busyTimeout: ReturnType[] = []; private ext: string[] = []; - private metadata: Array = []; + private metadata: Array = []; constructor( private cpu: CPU6502, @@ -550,12 +548,14 @@ export default class SmartPort implements Card, MassStorage, 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, 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, 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, Restor name, ext, data, + readOnly, }; } } diff --git a/js/components/Apple2.tsx b/js/components/Apple2.tsx index 58ab4f0..c784023 100644 --- a/js/components/Apple2.tsx +++ b/js/components/Apple2.tsx @@ -51,7 +51,7 @@ export const Apple2 = (props: Apple2Props) => { const [apple2, setApple2] = useState(); const [error, setError] = useState(); 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(); diff --git a/js/components/css/Tabs.module.css b/js/components/css/Tabs.module.css index 9a243c0..3a3e703 100644 --- a/js/components/css/Tabs.module.css +++ b/js/components/css/Tabs.module.css @@ -20,4 +20,5 @@ flex-direction: row; border-bottom: 2px groove; margin-bottom: 6px; + user-select: none; } diff --git a/js/components/debugger/Debugger.tsx b/js/components/debugger/Debugger.tsx index a928689..82b74c6 100644 --- a/js/components/debugger/Debugger.tsx +++ b/js/components/debugger/Debugger.tsx @@ -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) => { CPU Video Memory + Disks Applesoft
{selected === 0 ? : null} {selected === 1 ? : null} {selected === 2 ? : null} - {selected === 3 ? : null} + {selected === 3 ? : null} + {selected === 4 ? : null}
); diff --git a/js/components/debugger/Disks.tsx b/js/components/debugger/Disks.tsx new file mode 100644 index 0000000..e01c273 --- /dev/null +++ b/js/components/debugger/Disks.tsx @@ -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 ( + + + {'| '.repeat(depth)} + {deleted ? + : + + } + {' '} + {fileEntry.name} + + {FILE_TYPES[fileEntry.fileType] ?? `$${toHex(fileEntry.fileType)}`} + {`$${toHex(fileEntry.auxType, 4)}`} + {fileEntry.blocksUsed} + {formatDate(fileEntry.creation)} + {formatDate(fileEntry.lastMod)} + + ); +}; + +/** + * 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 ( + <> + + setOpen((open) => !open)} + title={dirEntry.name} + > + {'| '.repeat(depth)} + + {' '} + {dirEntry.name} + + + + + {formatDate(dirEntry.creation)} + + + {open && dirEntry.entries.map((fileEntry, idx) => { + if (fileEntry.storageType === STORAGE_TYPES.DIRECTORY) { + const dirEntry = new Directory(volume, fileEntry); + return ; + } else { + return ; + } + })} + + ); +}; + +/** + * 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 ( + + + {fileEntry.locked && } + {' '} + {fileEntry.name} + + {fileEntry.type} + {fileEntry.size} + + + ); +}; + +/** + * 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) => ( + + ))} + + ); +}; + +/** + * Props for DiskInfo component + */ +interface DiskInfoProps { + massStorage: MassStorage; + 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 ( +
+ + + + + + + + + + + + + + + + + + + + + +
FilenameTypeAuxBlocksCreatedModified
Blocks Free: {freeCount}Used: {usedCount}Total: {totalBlocks}
+
+ ); + } + } else { + const dos = new DOS33(disk); + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
FilenameTypeSectors
Volume Number:{dos.getVolumeNumber()}
Used Sectors:{dos.usedSectorCount()}
Free Sectors:{dos.freeSectorCount()}
+
+ ); + } + } catch (error) { + console.error(error); + return
Unknown volume
; + } + } + return
No disk
; +}; + +/** + * 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(null); + const io = apple2.getIO(); + const cards: MassStorage[] = []; + + 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 ( +
+ {cards.map((card, idx) => ( +
+
+ {card.constructor.name} - 1 +
+ +
+ {card.constructor.name} - 2 +
+ +
+ ))} + +
+ ); +}; diff --git a/js/components/debugger/FileViewer.tsx b/js/components/debugger/FileViewer.tsx new file mode 100644 index 0000000..86470a3 --- /dev/null +++ b/js/components/debugger/FileViewer.tsx @@ -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 ( + <> + + +
+                        {text}
+                    
+
+ + + Download Raw + + + Download Text + + + +
+ + ); +}; diff --git a/js/components/debugger/css/Disks.module.css b/js/components/debugger/css/Disks.module.css new file mode 100644 index 0000000..b7d0bf8 --- /dev/null +++ b/js/components/debugger/css/Disks.module.css @@ -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; +} diff --git a/js/components/debugger/css/FileViewer.module.css b/js/components/debugger/css/FileViewer.module.css new file mode 100644 index 0000000..3adebf3 --- /dev/null +++ b/js/components/debugger/css/FileViewer.module.css @@ -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; +} diff --git a/js/formats/dos/dos33.ts b/js/formats/dos/dos33.ts new file mode 100644 index 0000000..0b096fd --- /dev/null +++ b/js/formats/dos/dos33.ts @@ -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; +} diff --git a/js/formats/format_utils.ts b/js/formats/format_utils.ts index 5f42aec..463c40b 100644 --- a/js/formats/format_utils.ts +++ b/js/formats/format_utils.ts @@ -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; diff --git a/js/formats/po.ts b/js/formats/po.ts index 8308def..23de4c2 100644 --- a/js/formats/po.ts +++ b/js/formats/po.ts @@ -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, diff --git a/js/formats/prodos/base_file.ts b/js/formats/prodos/base_file.ts new file mode 100644 index 0000000..cd5a057 --- /dev/null +++ b/js/formats/prodos/base_file.ts @@ -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; +} diff --git a/js/formats/prodos/bit_map.ts b/js/formats/prodos/bit_map.ts index 536f7ad..2ee141f 100644 --- a/js/formats/prodos/bit_map.ts +++ b/js/formats/prodos/bit_map.ts @@ -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); } diff --git a/js/formats/prodos/constants.ts b/js/formats/prodos/constants.ts index 651628d..32247e2 100644 --- a/js/formats/prodos/constants.ts +++ b/js/formats/prodos/constants.ts @@ -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 = { 0x00: 'UNK', // Typeless file (SOS and ProDOS) 0x01: 'BAD', // Bad block file 0x02: 'PDC', // Pascal code file diff --git a/js/formats/prodos/directory.ts b/js/formats/prodos/directory.ts index bd45f0a..abeed3b 100644 --- a/js/formats/prodos/directory.ts +++ b/js/formats/prodos/directory.ts @@ -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) { diff --git a/js/formats/prodos/file_entry.ts b/js/formats/prodos/file_entry.ts index c768e2d..ae82946 100644 --- a/js/formats/prodos/file_entry.ts +++ b/js/formats/prodos/file_entry.ts @@ -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; } } diff --git a/js/formats/prodos/sapling_file.ts b/js/formats/prodos/sapling_file.ts index e1fefbc..11107cb 100644 --- a/js/formats/prodos/sapling_file.ts +++ b/js/formats/prodos/sapling_file.ts @@ -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++; diff --git a/js/formats/prodos/seedling_file.ts b/js/formats/prodos/seedling_file.ts index 167e10b..8061f4a 100644 --- a/js/formats/prodos/seedling_file.ts +++ b/js/formats/prodos/seedling_file.ts @@ -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(); } diff --git a/js/formats/prodos/tree_file.ts b/js/formats/prodos/tree_file.ts index 6011a69..78880c7 100644 --- a/js/formats/prodos/tree_file.ts +++ b/js/formats/prodos/tree_file.ts @@ -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++; diff --git a/js/formats/prodos/utils.ts b/js/formats/prodos/utils.ts index 57facc6..b5274ed 100644 --- a/js/formats/prodos/utils.ts +++ b/js/formats/prodos/utils.ts @@ -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; } diff --git a/js/formats/types.ts b/js/formats/types.ts index 80a7f73..7ca8692 100644 --- a/js/formats/types.ts +++ b/js/formats/types.ts @@ -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; export type BlockFormat = MemberOf; @@ -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 { setBinary(drive: number, name: string, ext: T, data: ArrayBuffer): boolean; - getBinary(drive: number): MassStorageData | null; + getBinary(drive: number, ext?: T): MassStorageData | null; } diff --git a/js/intbasic/decompiler.ts b/js/intbasic/decompiler.ts index dfc7866..b24c0a5 100644 --- a/js/intbasic/decompiler.ts +++ b/js/intbasic/decompiler.ts @@ -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 = { + 0x00: 'HIMEM:', + 0x01: '$01', 0x02: '_', 0x03: ':', 0x04: 'LOAD', @@ -45,8 +47,8 @@ const TOKENS: Record = { 0x25: 'THEN', 0x26: ',', 0x27: ',', - 0x28: '\'', - 0x29: '\'', + 0x28: '"', + 0x29: '"', 0x2A: '(', 0x2B: '!', 0x2C: '!', @@ -114,6 +116,7 @@ const TOKENS: Record = { 0x6A: ',', 0x6B: 'AT', 0x6C: 'VLIN', + 0x6D: ',', 0x6E: 'AT', 0x6F: 'VTAB', 0x70: '=', @@ -121,6 +124,7 @@ const TOKENS: Record = { 0x72: ')', 0x73: ')', 0x74: 'LIST', + 0x75: ',', 0x76: 'LIST', 0x77: 'POP', 0x78: 'NODSP', @@ -133,15 +137,11 @@ const TOKENS: Record = { 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); diff --git a/test/js/applesoft/decompiler.spec.ts b/test/js/applesoft/decompiler.spec.ts index 9c18085..534f79e 100644 --- a/test/js/applesoft/decompiler.spec.ts +++ b/test/js/applesoft/decompiler.spec.ts @@ -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'); }); -}); \ No newline at end of file +});