diff --git a/js/cards/disk2.ts b/js/cards/disk2.ts index 7e44cbc..810ea98 100644 --- a/js/cards/disk2.ts +++ b/js/cards/disk2.ts @@ -2,7 +2,6 @@ import { base64_encode } from '../base64'; import type { byte, Card, - nibble, ReadonlyUint8Array } from '../types'; @@ -10,7 +9,7 @@ import { DISK_PROCESSED, DriveNumber, DRIVE_NUMBERS, FloppyDisk, FloppyFormat, FormatWorkerMessage, FormatWorkerResponse, isNibbleDisk, isNoFloppyDisk, isWozDisk, JSONDisk, MassStorage, - MassStorageData, NibbleDisk, NibbleFormat, NoFloppyDisk, NO_DISK, PROCESS_BINARY, PROCESS_JSON, PROCESS_JSON_DISK, SupportedSectors, WozDisk + MassStorageData, NibbleDisk, NibbleFormat, NO_DISK, PROCESS_BINARY, PROCESS_JSON, PROCESS_JSON_DISK, SupportedSectors, WozDisk } from '../formats/types'; import { @@ -19,11 +18,16 @@ import { } from '../formats/create_disk'; import { jsonDecode, jsonEncode, readSector, _D13O, _DO, _PO } from '../formats/format_utils'; -import { toHex } from '../util'; import Apple2IO from '../apple2io'; + import { BOOTSTRAP_ROM_13, BOOTSTRAP_ROM_16 } from '../roms/cards/disk2'; +import { EmptyDriver } from './drivers/EmptyDriver'; +import { NibbleDiskDriver } from './drivers/NibbleDiskDriver'; +import { ControllerState, DiskDriver, Drive, DriverState, Phase } from './drivers/types'; +import { WozDiskDriver } from './drivers/WozDiskDriver'; + /** Softswitch locations */ const LOC = { // Disk II Controller Commands @@ -111,7 +115,7 @@ const SEQUENCER_ROM_16 = [ ] as const; /** Contents of the P6 sequencer ROM. */ -const SEQUENCER_ROM: Record> = { +export const SEQUENCER_ROM: Record> = { 13: SEQUENCER_ROM_13, 16: SEQUENCER_ROM_16, } as const; @@ -122,10 +126,6 @@ const BOOTSTRAP_ROM: Record = { 16: BOOTSTRAP_ROM_16, } as const; -type LssClockCycle = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; -type LssState = nibble; -type Phase = 0 | 1 | 2 | 3; - /** * How far the head moves, in quarter tracks, when in phase X and phase Y is * activated. For example, if in phase 0 (top row), turning on phase 3 would @@ -155,49 +155,6 @@ const PHASE_DELTA = [ [1, -2, -1, 0] ] as const; -/** - * State of the controller. - */ -interface ControllerState { - /** Sectors supported by the controller. */ - sectors: SupportedSectors; - - /** Is the active drive powered on? */ - on: boolean; - - /** The active drive. */ - driveNo: DriveNumber; - - /** The 8-cycle LSS clock. */ - clock: LssClockCycle; - /** Current state of the Logic State Sequencer. */ - state: nibble; - - /** Q6 (Shift/Load) */ - q6: boolean; - /** Q7 (Read/Write) */ - q7: boolean; - - /** Last data from the disk drive. */ - latch: byte; - /** Last data written by the CPU to card softswitch 0x8D. */ - bus: byte; -} - -/** Interface for drivers for various disk types. */ -interface DiskDriver { - tick(): void; - onQ6Low(): void; - onQ6High(readMode: boolean): void; - onDriveOn(): void; - onDriveOff(): void; - clampTrack(): void; - getState(): DriverState; - setState(state: DriverState): void; -} - -type DriverState = EmptyDriverState | NibbleDiskDriverState | WozDiskDriverState; - /** Callbacks triggered by events of the drive or controller. */ export interface Callbacks { /** Called when a drive turns on or off. */ @@ -212,20 +169,6 @@ export interface Callbacks { label: (driveNo: DriveNumber, name?: string, side?: string) => void; } -/** Common information for Nibble and WOZ disks. */ -interface Drive { - /** Whether the drive write protect is on. */ - readOnly: boolean; - /** Quarter track position of read/write head. */ - track: byte; - /** Position of the head on the track. */ - head: byte; - /** Current active coil in the head stepper motor. */ - phase: Phase; - /** Whether the drive has been written to since it was loaded. */ - dirty: boolean; -} - interface DriveState { disk: FloppyDisk; driver: DriverState; @@ -237,17 +180,11 @@ interface DriveState { } /** State of the controller for saving/restoring. */ -// TODO(flan): It's unclear whether reusing ControllerState here is a good idea. interface State { drives: DriveState[]; - driver: DriverState[]; controllerState: ControllerState; } -function getDiskState(disk: NoFloppyDisk): NoFloppyDisk; -function getDiskState(disk: NibbleDisk): NibbleDisk; -function getDiskState(disk: WozDisk): WozDisk; -function getDiskState(disk: FloppyDisk): FloppyDisk; function getDiskState(disk: FloppyDisk): FloppyDisk { if (isNoFloppyDisk(disk)) { const { encoding, metadata, readOnly } = disk; @@ -294,429 +231,6 @@ function getDiskState(disk: FloppyDisk): FloppyDisk { throw new Error('Unknown drive state'); } -interface EmptyDriverState { } - -class EmptyDriver implements DiskDriver { - constructor(private readonly drive: Drive) { } - - tick(): void { - // do nothing - } - - onQ6Low(): void { - // do nothing - } - - onQ6High(_readMode: boolean): void { - // do nothing - } - - onDriveOn(): void { - // do nothing - } - - onDriveOff(): void { - // do nothing - } - - clampTrack(): void { - // For empty drives, the emulator clamps the track to 0 to 34, - // but real Disk II drives can seek past track 34 by at least a - // half track, usually a full track. Some 3rd party drives can - // seek to track 39. - if (this.drive.track < 0) { - this.drive.track = 0; - } - if (this.drive.track > 34) { - this.drive.track = 34; - } - } - - getState() { - return {}; - } - - setState(_state: EmptyDriverState): void { - // do nothing - } -} - -abstract class BaseDiskDriver implements DiskDriver { - constructor( - protected readonly driveNo: DriveNumber, - protected readonly drive: Drive, - protected readonly disk: NibbleDisk | WozDisk, - protected readonly controller: ControllerState) { } - - /** Called frequently to ensure the disk is spinning. */ - abstract tick(): void; - - /** Called when Q6 is set LOW. */ - abstract onQ6Low(): void; - - /** Called when Q6 is set HIGH. */ - abstract onQ6High(readMode: boolean): void; - - /** - * Called when drive is turned on. This is guaranteed to be called - * only when the associated drive is toggled from off to on. This - * is also guaranteed to be called when a new disk is inserted when - * the drive is already on. - */ - abstract onDriveOn(): void; - - /** - * Called when drive is turned off. This is guaranteed to be called - * only when the associated drive is toggled from on to off. - */ - abstract onDriveOff(): void; - - debug(..._args: unknown[]) { - // debug(...args); - } - - /** - * Called every time the head moves to clamp the track to a valid - * range. - */ - abstract clampTrack(): void; - - isOn(): boolean { - return this.controller.on && this.controller.driveNo === this.driveNo; - } - - isWriteProtected(): boolean { - return this.drive.readOnly; - } - - abstract getState(): DriverState; - - abstract setState(state: DriverState): void; -} - -interface NibbleDiskDriverState { - skip: number; - nibbleCount: number; -} - -class NibbleDiskDriver extends BaseDiskDriver { - /** - * When `1`, the next nibble will be available for read; when `0`, - * the card is pretending to wait for data to be shifted in by the - * sequencer. - */ - private skip: number = 0; - /** Number of nibbles reads since the drive was turned on. */ - private nibbleCount: number = 0; - - constructor( - driveNo: DriveNumber, - drive: Drive, - readonly disk: NibbleDisk, - controller: ControllerState, - private readonly onDirty: () => void) { - super(driveNo, drive, disk, controller); - } - - tick(): void { - // do nothing - } - - onQ6Low(): void { - const drive = this.drive; - const disk = this.disk; - if (this.isOn() && (this.skip || this.controller.q7)) { - const track = disk.tracks[drive.track >> 2]; - if (track && track.length) { - if (drive.head >= track.length) { - drive.head = 0; - } - - if (this.controller.q7) { - const writeProtected = disk.readOnly; - if (!writeProtected) { - track[drive.head] = this.controller.bus; - drive.dirty = true; - this.onDirty(); - } - } else { - this.controller.latch = track[drive.head]; - this.nibbleCount++; - } - - ++drive.head; - } - } else { - this.controller.latch = 0; - } - this.skip = (++this.skip % 2); - } - - onQ6High(readMode: boolean): void { - const drive = this.drive; - if (readMode && !this.controller.q7) { - const writeProtected = drive.readOnly; - if (writeProtected) { - this.controller.latch = 0xff; - this.debug('Setting readOnly'); - } else { - this.controller.latch >>= 1; - this.debug('Clearing readOnly'); - } - } - } - - onDriveOn(): void { - this.nibbleCount = 0; - } - - onDriveOff(): void { - this.debug('nibbles read', this.nibbleCount); - } - - clampTrack(): void { - // For NibbleDisks, the emulator clamps the track to the available - // range. - if (this.drive.track < 0) { - this.drive.track = 0; - } - const lastTrack = 35 * 4 - 1; - if (this.drive.track > lastTrack) { - this.drive.track = lastTrack; - } - } - - getState(): NibbleDiskDriverState { - const { skip, nibbleCount } = this; - return { skip, nibbleCount }; - } - - setState(state: NibbleDiskDriverState) { - this.skip = state.skip; - this.nibbleCount = state.nibbleCount; - } -} - -interface WozDiskDriverState { - clock: LssClockCycle; - state: LssState; - lastCycles: number; - zeros: number; -} - -class WozDiskDriver extends BaseDiskDriver { - /** Logic state sequencer clock cycle. */ - private clock: LssClockCycle; - /** Logic state sequencer state. */ - private state: LssState; - /** Current CPU cycle count. */ - private lastCycles: number = 0; - /** - * Number of zeros read in a row. The Disk ][ can only read two zeros in a - * row reliably; above that and the drive starts reporting garbage. See - * "Freaking Out Like a MC3470" in the WOZ spec. - */ - private zeros = 0; - - constructor( - driveNo: DriveNumber, - drive: Drive, - readonly disk: WozDisk, - controller: ControllerState, - private readonly onDirty: () => void, - private readonly io: Apple2IO) { - super(driveNo, drive, disk, controller); - - // From the example in UtA2e, p. 9-29, col. 1, para. 1., this is - // essentially the start of the sequencer loop and produces - // correctly synced nibbles immediately. Starting at state 0 - // would introduce a spurrious 1 in the latch at the beginning, - // which requires reading several more sync bytes to sync up. - this.state = 2; - this.clock = 0; - } - - onDriveOn(): void { - this.lastCycles = this.io.cycles(); - } - - onDriveOff(): void { - // nothing - } - - /** - * 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 - * represents one full rotation of the disk and that each - * bit is evenly spaced. - * * Writing will not change the track length. This means - * that short tracks stay short. - * * The read head picks up the next bit when the sequencer - * clock === 4. - * * Head position X on track T is equivalent to head position - * X on track T′. (This is not the recommendation in the WOZ - * spec.) - * * Unspecified tracks contain a single zero bit. (A very - * short track, indeed!) - * * Two zero bits are sufficient to cause the MC3470 to freak - * out. When freaking out, it returns 0 and 1 with equal - * probability. - * * Any softswitch changes happen before `moveHead`. This is - * important because it means that if the clock is ever - * advanced more than one cycle between calls, the - * softswitch changes will appear to happen at the very - * beginning, not just before the last cycle. - */ - private moveHead() { - // TODO(flan): Short-circuit if the drive is not on. - const cycles = this.io.cycles(); - - // Spin the disk the number of elapsed cycles since last call - let workCycles = (cycles - this.lastCycles) * 2; - this.lastCycles = cycles; - - const drive = this.drive; - const disk = this.disk; - const controller = this.controller; - - // TODO(flan): Improve unformatted track behavior. The WOZ - // documentation suggests using an empty track of 6400 bytes - // (51,200 bits). - const track = - disk.rawTracks[disk.trackMap[drive.track]] || [0]; - - while (workCycles-- > 0) { - let pulse: number = 0; - if (this.clock === 4) { - pulse = track[drive.head]; - if (!pulse) { - // More than 2 zeros can not be read reliably. - // TODO(flan): Revisit with the new MC3470 - // suggested 4-bit window behavior. - if (++this.zeros > 2) { - const r = Math.random(); - pulse = r >= 0.5 ? 1 : 0; - } - } else { - this.zeros = 0; - } - } - - let idx = 0; - idx |= pulse ? 0x00 : 0x01; - idx |= controller.latch & 0x80 ? 0x02 : 0x00; - idx |= controller.q6 ? 0x04 : 0x00; - idx |= controller.q7 ? 0x08 : 0x00; - idx |= this.state << 4; - - const command = SEQUENCER_ROM[controller.sectors][idx]; - - this.debug(`clock: ${this.clock} state: ${toHex(this.state)} pulse: ${pulse} command: ${toHex(command)} q6: ${controller.q6} latch: ${toHex(controller.latch)}`); - - switch (command & 0xf) { - case 0x0: // CLR - controller.latch = 0; - break; - case 0x8: // NOP - break; - case 0x9: // SL0 - controller.latch = (controller.latch << 1) & 0xff; - break; - case 0xA: // SR - controller.latch >>= 1; - if (this.isWriteProtected()) { - controller.latch |= 0x80; - } - break; - case 0xB: // LD - controller.latch = controller.bus; - this.debug('Loading', toHex(controller.latch), 'from bus'); - break; - case 0xD: // SL1 - controller.latch = ((controller.latch << 1) | 0x01) & 0xff; - break; - default: - this.debug(`unknown command: ${toHex(command & 0xf)}`); - } - this.state = (command >> 4 & 0xF) as LssState; - - if (this.clock === 4) { - if (this.isOn()) { - if (controller.q7) { - // TODO(flan): This assumes that writes are happening in - // a "friendly" way, namely where the track was originally - // written. To do this correctly, the virtual head should - // actually keep track of the current quarter track plus - // the one on each side. Then, when writing, it should - // check that all three are actually the same rawTrack. If - // they aren't, then the trackMap has to be updated as - // well. - track[drive.head] = this.state & 0x8 ? 0x01 : 0x00; - this.debug('Wrote', this.state & 0x8 ? 0x01 : 0x00); - drive.dirty = true; - this.onDirty(); - } - - if (++drive.head >= track.length) { - drive.head = 0; - } - } - } - - if (++this.clock > 7) { - this.clock = 0; - } - } - } - - tick(): void { - this.moveHead(); - } - - onQ6High(_readMode: boolean): void { - // nothing? - } - - onQ6Low(): void { - // nothing? - } - - clampTrack(): void { - // For NibbleDisks, the emulator clamps the track to the available - // range. - if (this.drive.track < 0) { - this.drive.track = 0; - } - const lastTrack = this.disk.trackMap.length - 1; - if (this.drive.track > lastTrack) { - this.drive.track = lastTrack; - } - } - - getState(): WozDiskDriverState { - const { clock, state, lastCycles, zeros } = this; - return { clock, state, lastCycles, zeros }; - } - - setState(state: WozDiskDriverState) { - this.clock = state.clock; - this.state = state.state; - this.lastCycles = state.lastCycles; - this.zeros = state.zeros; - } -} - /** * Emulates the 16-sector and 13-sector versions of the Disk ][ drive and controller. */ @@ -1015,7 +529,6 @@ export default class DiskII implements Card, MassStorage { getState(): State { const result = { drives: [] as DriveState[], - driver: [] as DriverState[], controllerState: { ...this.state }, }; result.drives[1] = this.getDriveState(1); diff --git a/js/cards/drivers/BaseDiskDriver.ts b/js/cards/drivers/BaseDiskDriver.ts new file mode 100644 index 0000000..f7957a3 --- /dev/null +++ b/js/cards/drivers/BaseDiskDriver.ts @@ -0,0 +1,63 @@ +import { DriveNumber, NibbleDisk, WozDisk } from '../../formats/types'; +import { ControllerState, DiskDriver, Drive, DriverState } from './types'; + + +/** + * Common logic for both `NibbleDiskDriver` and `WozDiskDriver`. + */ +export abstract class BaseDiskDriver implements DiskDriver { + constructor( + protected readonly driveNo: DriveNumber, + protected readonly drive: Drive, + protected readonly disk: NibbleDisk | WozDisk, + protected readonly controller: ControllerState) { } + + /** Called frequently to ensure the disk is spinning. */ + abstract tick(): void; + + /** Called when Q6 is set LOW. */ + abstract onQ6Low(): void; + + /** Called when Q6 is set HIGH. */ + abstract onQ6High(readMode: boolean): void; + + /** + * Called when drive is turned on. This is guaranteed to be called + * only when the associated drive is toggled from off to on. This + * is also guaranteed to be called when a new disk is inserted when + * the drive is already on. + */ + abstract onDriveOn(): void; + + /** + * Called when drive is turned off. This is guaranteed to be called + * only when the associated drive is toggled from on to off. + */ + abstract onDriveOff(): void; + + debug(..._args: unknown[]) { + // debug(...args); + } + + /** + * Called every time the head moves to clamp the track to a valid + * range. + */ + abstract clampTrack(): void; + + /** Returns `true` if the controller is on and this drive is selected. */ + isOn(): boolean { + return this.controller.on && this.controller.driveNo === this.driveNo; + } + + /** Returns `true` if the drive's write protect switch is enabled. */ + isWriteProtected(): boolean { + return this.drive.readOnly; + } + + /** Returns the current state of the driver as a serializable object. */ + abstract getState(): DriverState; + + /** Sets the state of the driver from the given `state`. */ + abstract setState(state: DriverState): void; +} diff --git a/js/cards/drivers/EmptyDriver.ts b/js/cards/drivers/EmptyDriver.ts new file mode 100644 index 0000000..a5265f1 --- /dev/null +++ b/js/cards/drivers/EmptyDriver.ts @@ -0,0 +1,53 @@ +import { DiskDriver, Drive, DriverState } from './types'; + +/** Returned state for an empty drive. */ +export interface EmptyDriverState extends DriverState { } + +/** + * Driver for empty drives. This implementation does nothing except keep + * the head clamped between tracks 0 and 34. + */ +export class EmptyDriver implements DiskDriver { + constructor(private readonly drive: Drive) { } + + tick(): void { + // do nothing + } + + onQ6Low(): void { + // do nothing + } + + onQ6High(_readMode: boolean): void { + // do nothing + } + + onDriveOn(): void { + // do nothing + } + + onDriveOff(): void { + // do nothing + } + + clampTrack(): void { + // For empty drives, the emulator clamps the track to 0 to 34, + // but real Disk II drives can seek past track 34 by at least a + // half track, usually a full track. Some 3rd party drives can + // seek to track 39. + if (this.drive.track < 0) { + this.drive.track = 0; + } + if (this.drive.track > 34) { + this.drive.track = 34; + } + } + + getState() { + return {}; + } + + setState(_state: EmptyDriverState): void { + // do nothing + } +} diff --git a/js/cards/drivers/NibbleDiskDriver.ts b/js/cards/drivers/NibbleDiskDriver.ts new file mode 100644 index 0000000..bf04fea --- /dev/null +++ b/js/cards/drivers/NibbleDiskDriver.ts @@ -0,0 +1,106 @@ +import { DriveNumber, NibbleDisk } from '../../formats/types'; +import { BaseDiskDriver } from './BaseDiskDriver'; +import { ControllerState, Drive, DriverState } from './types'; + +interface NibbleDiskDriverState extends DriverState { + skip: number; + nibbleCount: number; +} + +export class NibbleDiskDriver extends BaseDiskDriver { + /** + * When `1`, the next nibble will be available for read; when `0`, + * the card is pretending to wait for data to be shifted in by the + * sequencer. + */ + private skip: number = 0; + /** Number of nibbles reads since the drive was turned on. */ + private nibbleCount: number = 0; + + constructor( + driveNo: DriveNumber, + drive: Drive, + readonly disk: NibbleDisk, + controller: ControllerState, + private readonly onDirty: () => void) { + super(driveNo, drive, disk, controller); + } + + tick(): void { + // do nothing + } + + onQ6Low(): void { + const drive = this.drive; + const disk = this.disk; + if (this.isOn() && (this.skip || this.controller.q7)) { + const track = disk.tracks[drive.track >> 2]; + if (track && track.length) { + if (drive.head >= track.length) { + drive.head = 0; + } + + if (this.controller.q7) { + const writeProtected = disk.readOnly; + if (!writeProtected) { + track[drive.head] = this.controller.bus; + drive.dirty = true; + this.onDirty(); + } + } else { + this.controller.latch = track[drive.head]; + this.nibbleCount++; + } + + ++drive.head; + } + } else { + this.controller.latch = 0; + } + this.skip = (++this.skip % 2); + } + + onQ6High(readMode: boolean): void { + const drive = this.drive; + if (readMode && !this.controller.q7) { + const writeProtected = drive.readOnly; + if (writeProtected) { + this.controller.latch = 0xff; + this.debug('Setting readOnly'); + } else { + this.controller.latch >>= 1; + this.debug('Clearing readOnly'); + } + } + } + + onDriveOn(): void { + this.nibbleCount = 0; + } + + onDriveOff(): void { + this.debug('nibbles read', this.nibbleCount); + } + + clampTrack(): void { + // For NibbleDisks, the emulator clamps the track to the available + // range. + if (this.drive.track < 0) { + this.drive.track = 0; + } + const lastTrack = 35 * 4 - 1; + if (this.drive.track > lastTrack) { + this.drive.track = lastTrack; + } + } + + getState(): NibbleDiskDriverState { + const { skip, nibbleCount } = this; + return { skip, nibbleCount }; + } + + setState(state: NibbleDiskDriverState) { + this.skip = state.skip; + this.nibbleCount = state.nibbleCount; + } +} diff --git a/js/cards/drivers/WozDiskDriver.ts b/js/cards/drivers/WozDiskDriver.ts new file mode 100644 index 0000000..f118a5e --- /dev/null +++ b/js/cards/drivers/WozDiskDriver.ts @@ -0,0 +1,225 @@ +import Apple2IO from '../../apple2io'; +import { DriveNumber, WozDisk } from '../../formats/types'; +import { toHex } from '../../util'; +import { SEQUENCER_ROM } from '../disk2'; +import { BaseDiskDriver } from './BaseDiskDriver'; +import { ControllerState, Drive, DriverState, LssClockCycle, LssState } from './types'; + +interface WozDiskDriverState extends DriverState { + clock: LssClockCycle; + state: LssState; + lastCycles: number; + zeros: number; +} + +export class WozDiskDriver extends BaseDiskDriver { + /** Logic state sequencer clock cycle. */ + private clock: LssClockCycle; + /** Logic state sequencer state. */ + private state: LssState; + /** Current CPU cycle count. */ + private lastCycles: number = 0; + /** + * Number of zeros read in a row. The Disk ][ can only read two zeros in a + * row reliably; above that and the drive starts reporting garbage. See + * "Freaking Out Like a MC3470" in the WOZ spec. + */ + private zeros = 0; + + constructor( + driveNo: DriveNumber, + drive: Drive, + readonly disk: WozDisk, + controller: ControllerState, + private readonly onDirty: () => void, + private readonly io: Apple2IO) { + super(driveNo, drive, disk, controller); + + // From the example in UtA2e, p. 9-29, col. 1, para. 1., this is + // essentially the start of the sequencer loop and produces + // correctly synced nibbles immediately. Starting at state 0 + // would introduce a spurrious 1 in the latch at the beginning, + // which requires reading several more sync bytes to sync up. + this.state = 2; + this.clock = 0; + } + + onDriveOn(): void { + this.lastCycles = this.io.cycles(); + } + + onDriveOff(): void { + // nothing + } + + /** + * 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 + * represents one full rotation of the disk and that each + * bit is evenly spaced. + * * Writing will not change the track length. This means + * that short tracks stay short. + * * The read head picks up the next bit when the sequencer + * clock === 4. + * * Head position X on track T is equivalent to head position + * X on track T′. (This is not the recommendation in the WOZ + * spec.) + * * Unspecified tracks contain a single zero bit. (A very + * short track, indeed!) + * * Two zero bits are sufficient to cause the MC3470 to freak + * out. When freaking out, it returns 0 and 1 with equal + * probability. + * * Any softswitch changes happen before `moveHead`. This is + * important because it means that if the clock is ever + * advanced more than one cycle between calls, the + * softswitch changes will appear to happen at the very + * beginning, not just before the last cycle. + */ + private moveHead() { + // TODO(flan): Short-circuit if the drive is not on. + const cycles = this.io.cycles(); + + // Spin the disk the number of elapsed cycles since last call + let workCycles = (cycles - this.lastCycles) * 2; + this.lastCycles = cycles; + + const drive = this.drive; + const disk = this.disk; + const controller = this.controller; + + // TODO(flan): Improve unformatted track behavior. The WOZ + // documentation suggests using an empty track of 6400 bytes + // (51,200 bits). + const track = disk.rawTracks[disk.trackMap[drive.track]] || [0]; + + while (workCycles-- > 0) { + let pulse: number = 0; + if (this.clock === 4) { + pulse = track[drive.head]; + if (!pulse) { + // More than 2 zeros can not be read reliably. + // TODO(flan): Revisit with the new MC3470 + // suggested 4-bit window behavior. + if (++this.zeros > 2) { + const r = Math.random(); + pulse = r >= 0.5 ? 1 : 0; + } + } else { + this.zeros = 0; + } + } + + let idx = 0; + idx |= pulse ? 0x00 : 0x01; + idx |= controller.latch & 0x80 ? 0x02 : 0x00; + idx |= controller.q6 ? 0x04 : 0x00; + idx |= controller.q7 ? 0x08 : 0x00; + idx |= this.state << 4; + + const command = SEQUENCER_ROM[controller.sectors][idx]; + + this.debug(`clock: ${this.clock} state: ${toHex(this.state)} pulse: ${pulse} command: ${toHex(command)} q6: ${controller.q6} latch: ${toHex(controller.latch)}`); + + switch (command & 0xf) { + case 0x0: // CLR + controller.latch = 0; + break; + case 0x8: // NOP + break; + case 0x9: // SL0 + controller.latch = (controller.latch << 1) & 0xff; + break; + case 0xA: // SR + controller.latch >>= 1; + if (this.isWriteProtected()) { + controller.latch |= 0x80; + } + break; + case 0xB: // LD + controller.latch = controller.bus; + this.debug('Loading', toHex(controller.latch), 'from bus'); + break; + case 0xD: // SL1 + controller.latch = ((controller.latch << 1) | 0x01) & 0xff; + break; + default: + this.debug(`unknown command: ${toHex(command & 0xf)}`); + } + this.state = (command >> 4 & 0xF) as LssState; + + if (this.clock === 4) { + if (this.isOn()) { + if (controller.q7) { + // TODO(flan): This assumes that writes are happening in + // a "friendly" way, namely where the track was originally + // written. To do this correctly, the virtual head should + // actually keep track of the current quarter track plus + // the one on each side. Then, when writing, it should + // check that all three are actually the same rawTrack. If + // they aren't, then the trackMap has to be updated as + // well. + track[drive.head] = this.state & 0x8 ? 0x01 : 0x00; + this.debug('Wrote', this.state & 0x8 ? 0x01 : 0x00); + drive.dirty = true; + this.onDirty(); + } + + if (++drive.head >= track.length) { + drive.head = 0; + } + } + } + + if (++this.clock > 7) { + this.clock = 0; + } + } + } + + tick(): void { + this.moveHead(); + } + + onQ6High(_readMode: boolean): void { + // nothing? + } + + onQ6Low(): void { + // nothing? + } + + clampTrack(): void { + // For NibbleDisks, the emulator clamps the track to the available + // range. + if (this.drive.track < 0) { + this.drive.track = 0; + } + const lastTrack = this.disk.trackMap.length - 1; + if (this.drive.track > lastTrack) { + this.drive.track = lastTrack; + } + } + + getState(): WozDiskDriverState { + const { clock, state, lastCycles, zeros } = this; + return { clock, state, lastCycles, zeros }; + } + + setState(state: WozDiskDriverState) { + this.clock = state.clock; + this.state = state.state; + this.lastCycles = state.lastCycles; + this.zeros = state.zeros; + } +} diff --git a/js/cards/drivers/types.ts b/js/cards/drivers/types.ts new file mode 100644 index 0000000..caaa6e6 --- /dev/null +++ b/js/cards/drivers/types.ts @@ -0,0 +1,66 @@ +import { DriveNumber, SupportedSectors } from 'js/formats/types'; +import { byte, nibble } from 'js/types'; + +export type LssClockCycle = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; +export type LssState = nibble; + +export type Phase = 0 | 1 | 2 | 3; + +/** + * State of the controller. + */ +export interface ControllerState { + /** Sectors supported by the controller. */ + sectors: SupportedSectors; + + /** Is the active drive powered on? */ + on: boolean; + + /** The active drive. */ + driveNo: DriveNumber; + + /** The 8-cycle LSS clock. */ + clock: LssClockCycle; + + /** Current state of the Logic State Sequencer. */ + state: LssState; + + /** Q6 (Shift/Load) */ + q6: boolean; + /** Q7 (Read/Write) */ + q7: boolean; + + /** Last data from the disk drive. */ + latch: byte; + /** Last data written by the CPU to card softswitch 0x8D. */ + bus: byte; +} + +/** Common information for Nibble and WOZ disks. */ +export interface Drive { + /** Whether the drive write protect is on. */ + readOnly: boolean; + /** Quarter track position of read/write head. */ + track: byte; + /** Position of the head on the track. */ + head: byte; + /** Current active coil in the head stepper motor. */ + phase: Phase; + /** Whether the drive has been written to since it was loaded. */ + dirty: boolean; +} + +/** Base interface for disk driver states. */ +export interface DriverState {} + +/** Interface for drivers for various disk types. */ +export interface DiskDriver { + tick(): void; + onQ6Low(): void; + onQ6High(readMode: boolean): void; + onDriveOn(): void; + onDriveOff(): void; + clampTrack(): void; + getState(): DriverState; + setState(state: DriverState): void; +}