apple2js/js/formats/dos/dos33.ts

799 lines
25 KiB
TypeScript

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, _DO } 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,
DELETED_FILE_TRACK: 0x20,
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. This uses the DOS logical to physical sector mapping.
*
* @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, _DO[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, _DO[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.DELETED_FILE_TRACK];
}
// 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, _DO[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;
}