diff --git a/js/cards/disk2.ts b/js/cards/disk2.ts index d17a19b..9b58473 100644 --- a/js/cards/disk2.ts +++ b/js/cards/disk2.ts @@ -1,4 +1,4 @@ -import { base64_encode} from '../base64'; +import { base64_encode } from '../base64'; import type { byte, Card, @@ -28,7 +28,7 @@ import { createDiskFromJsonDisk } from '../formats/create_disk'; -import { debug, toHex } from '../util'; +import { toHex } from '../util'; import { jsonDecode, jsonEncode, readSector } from '../formats/format_utils'; import { BOOTSTRAP_ROM_16, BOOTSTRAP_ROM_13 } from '../roms/cards/disk2'; @@ -201,7 +201,7 @@ interface NibbleDrive extends BaseDrive { type Drive = WozDrive | NibbleDrive; -function isNibbleDrive(drive: Drive): drive is NibbleDrive { +function isNibbleDrive(drive: Drive): drive is NibbleDrive { return drive.encoding === ENCODING_NIBBLE; } @@ -395,12 +395,18 @@ export default class DiskII implements Card { this.lastCycles = this.io.cycles(); this.bootstrapRom = this.sectors === 16 ? BOOTSTRAP_ROM_16 : BOOTSTRAP_ROM_13; this.sequencerRom = this.sectors === 16 ? SEQUENCER_ROM_16 : SEQUENCER_ROM_13; + // 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.initWorker(); } private debug(..._args: unknown[]) { - // debug.apply(this, arguments); + // debug(..._args); } // Only used for WOZ disks @@ -422,7 +428,7 @@ export default class DiskII implements Card { if (this.clock === 4) { pulse = track[this.cur.head]; if (!pulse) { - // More that 2 zeros can not be read reliably. + // More than 2 zeros can not be read reliably. if (++this.zeros > 2) { pulse = Math.random() >= 0.5 ? 1 : 0; } @@ -440,9 +446,7 @@ export default class DiskII implements Card { const command = this.sequencerRom[idx]; - if (this.on && this.q7) { - debug('clock:', this.clock, 'command:', toHex(command), 'q6:', this.q6); - } + this.debug(`clock: ${this.clock} state: ${toHex(this.state)} pulse: ${pulse} command: ${toHex(command)} q6: ${this.q6} latch: ${toHex(this.latch)}`); switch (command & 0xf) { case 0x0: // CLR @@ -461,11 +465,13 @@ export default class DiskII implements Card { break; case 0xB: // LD this.latch = this.bus; - debug('Loading', toHex(this.latch), 'from bus'); + this.debug('Loading', toHex(this.latch), 'from bus'); break; case 0xD: // SL1 this.latch = ((this.latch << 1) | 0x01) & 0xff; break; + default: + this.debug(`unknown command: ${toHex(command & 0xf)}`); } this.state = (command >> 4 & 0xF) as nibble; @@ -473,7 +479,7 @@ export default class DiskII implements Card { if (this.on) { if (this.q7) { track[this.cur.head] = this.state & 0x8 ? 0x01 : 0x00; - debug('Wrote', this.state & 0x8 ? 0x01 : 0x00); + this.debug('Wrote', this.state & 0x8 ? 0x01 : 0x00); } if (++this.cur.head >= track.length) { @@ -544,6 +550,10 @@ export default class DiskII implements Card { this.cur.phase = phase; } + // The emulator clamps the track to the valid track range available + // in the image, 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. const maxTrack = isNibbleDrive(this.cur) ? this.cur.tracks.length * 4 - 1 : this.cur.trackMap.length - 1; diff --git a/js/formats/types.ts b/js/formats/types.ts index c52d484..80a7f73 100644 --- a/js/formats/types.ts +++ b/js/formats/types.ts @@ -1,5 +1,6 @@ import type { byte, memory, MemberOf, word } from '../types'; import type { GamepadConfiguration } from '../ui/types'; +import { InfoChunk } from './woz'; export const DRIVE_NUMBERS = [1, 2] as const; export type DriveNumber = MemberOf; @@ -70,6 +71,7 @@ export interface WozDisk extends FloppyDisk { encoding: typeof ENCODING_BITSTREAM; trackMap: number[]; rawTracks: Uint8Array[]; + info: InfoChunk | undefined; } export interface BlockDisk extends Disk { diff --git a/js/formats/woz.ts b/js/formats/woz.ts index 12eeb60..dd3b4a1 100644 --- a/js/formats/woz.ts +++ b/js/formats/woz.ts @@ -155,6 +155,9 @@ export class TrksChunk2 extends TrksChunk { const end = start + trk.blockCount * 512; const slice = bits.slice(start, end); const trackData = new Uint8Array(slice); + if (trackNo === 0) { + // debug(`First bytes: ${toHex(trackData[0])} ${toHex(trackData[1])} ${toHex(trackData[2])} ${toHex(trackData[3])}`); + } for (let jdx = 0; jdx < trk.bitCount; jdx++) { const byteIndex = jdx >> 3; const bitIndex = 7 - (jdx & 0x07); @@ -284,9 +287,9 @@ export default function createDiskFromWoz(options: DiskOptions): WozDisk { debug('Invalid woz header'); } - debug(chunks); + // debug(chunks); - const { meta, tmap, trks } = chunks; + const { meta, tmap, trks, info } = chunks; const disk: WozDisk = { encoding: ENCODING_BITSTREAM, @@ -296,6 +299,7 @@ export default function createDiskFromWoz(options: DiskOptions): WozDisk { readOnly: true, //chunks.info.writeProtected === 1; name: meta?.values['title'] || options.name, side: meta?.values['side_name'] || meta?.values['side'], + info }; return disk; diff --git a/test/js/cards/data/DOS 3.3 System Master.woz b/test/js/cards/data/DOS 3.3 System Master.woz new file mode 100644 index 0000000..2f09782 Binary files /dev/null and b/test/js/cards/data/DOS 3.3 System Master.woz differ diff --git a/test/js/cards/disk2.spec.ts b/test/js/cards/disk2.spec.ts index d328483..62269b2 100644 --- a/test/js/cards/disk2.spec.ts +++ b/test/js/cards/disk2.spec.ts @@ -1,7 +1,10 @@ /** @jest-environment jsdom */ +import fs from 'fs'; + import Apple2IO from 'js/apple2io'; import DiskII, { Callbacks } from 'js/cards/disk2'; import CPU6502 from 'js/cpu6502'; +import { byte } from 'js/types'; import { VideoModes } from 'js/videomodes'; import { mocked } from 'ts-jest/utils'; import { BYTES_BY_SECTOR_IMAGE, BYTES_BY_TRACK_IMAGE } from '../formats/testdata/16sector'; @@ -248,6 +251,7 @@ describe('DiskII', () => { diskII.ioSwitch(0x83); // coil 1 on diskII.ioSwitch(0x80); // coil 0 off diskII.ioSwitch(0x85); // coil 2 on + diskII.ioSwitch(0x82); // coil 1 off diskII.ioSwitch(0x87); // coil 3 on diskII.ioSwitch(0x84); // coil 2 off diskII.ioSwitch(0x81); // coil 0 on @@ -499,5 +503,196 @@ describe('DiskII', () => { expect(track0[0]).toBe(0x80); expect(track0[1]).toBe(0x81); }); + + it('sets disk state to dirty and calls the dirty callback when written', () => { + const diskII = new DiskII(mockApple2IO, callbacks); + diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); + let state = diskII.getState(); + state.drives[0].dirty = false; + diskII.setState(state); + jest.resetAllMocks(); + + diskII.ioSwitch(0x89); // turn on the motor + diskII.ioSwitch(0x8F, 0x80); // write + diskII.ioSwitch(0x8C); // shift + + expect(callbacks.dirty).toHaveBeenCalledTimes(1); + expect(callbacks.dirty).toHaveBeenCalledWith(1, true); + + state = diskII.getState(); + expect(state.drives[0].dirty).toBeTruthy(); + }); + }); + + describe('reading WOZ-based disks', () => { + const DOS33_SYSTEM_MASTER_IMAGE = + fs.readFileSync('test/js/cards/data/DOS 3.3 System Master.woz').buffer; + + it('accepts WOZ-based disks', () => { + const diskII = new DiskII(mockApple2IO, callbacks); + diskII.setBinary(1, 'DOS 3.3 System Master', 'woz', DOS33_SYSTEM_MASTER_IMAGE); + + expect(true).toBeTruthy(); + }); + + it('stops the head at the end of the image', () => { + const diskII = new DiskII(mockApple2IO, callbacks); + diskII.setBinary(1, 'DOS 3.3 System Master', 'woz', DOS33_SYSTEM_MASTER_IMAGE); + setTrack(diskII, 33); + + diskII.ioSwitch(0x89); // turn on the motor + diskII.ioSwitch(0x85); // coil 2 on + for (let i = 0; i < 5; i++) { + diskII.ioSwitch(0x87); // coil 3 on + diskII.ioSwitch(0x84); // coil 2 off + diskII.ioSwitch(0x81); // coil 0 on + diskII.ioSwitch(0x86); // coil 3 off + diskII.ioSwitch(0x83); // coil 1 on + diskII.ioSwitch(0x80); // coil 0 off + diskII.ioSwitch(0x85); // coil 2 on + diskII.ioSwitch(0x82); // coil 1 off + } + diskII.ioSwitch(0x84); // coil 2 off + + const state = diskII.getState(); + expect(state.drives[0].phase).toBe(2); + // For WOZ images, the number of tracks is the number in the image. + // The DOS3.3 System Master was imaged on a 40 track drive, so it + // has data for all 40 tracks, even though the last few are garbage. + expect(state.drives[0].track).toBe(40 * STEPS_PER_TRACK - 1); + }); + + it('spins the disk when motor is on', () => { + let cycles: number = 0; + mocked(mockApple2IO).cycles.mockImplementation(() => cycles); + + const diskII = new DiskII(mockApple2IO, callbacks); + diskII.setBinary(1, 'DOS 3.3 System Master', 'woz', DOS33_SYSTEM_MASTER_IMAGE); + + let state = diskII.getState(); + expect(state.drives[0].head).toBe(0); + + diskII.ioSwitch(0x89); // turn on the motor + cycles += 10; + diskII.tick(); + + state = diskII.getState(); + expect(state.drives[0].head).toBeGreaterThan(0); + }); + + it('does not spin the disk when motor is off', () => { + let cycles: number = 0; + mocked(mockApple2IO).cycles.mockImplementation(() => cycles); + + const diskII = new DiskII(mockApple2IO, callbacks); + diskII.setBinary(1, 'DOS 3.3 System Master', 'woz', DOS33_SYSTEM_MASTER_IMAGE); + + let state = diskII.getState(); + expect(state.drives[0].head).toBe(0); + + cycles += 10; + diskII.tick(); + + state = diskII.getState(); + expect(state.drives[0].head).toBe(0); + }); + + it('reads an FF sync byte from the beginning of the image', () => { + let cycles: number = 0; + mocked(mockApple2IO).cycles.mockImplementation(() => cycles); + + const diskII = new DiskII(mockApple2IO, callbacks); + diskII.setBinary(1, 'DOS 3.3 System Master', 'woz', DOS33_SYSTEM_MASTER_IMAGE); + + diskII.ioSwitch(0x89); // turn on the motor + diskII.ioSwitch(0x8e); // read mode + + // The initial bytes in the image are: FF 3F CF F3 + // making the bit stream: + // + // 1111 1111 0011 1111 1100 1111 1111 0011 + // + // That's three FF sync bytes in a row. Assuming + // the sequencer is in state 2, each sync byte takes + // 32 clock cycles to read, is held for 8 clock + // cycles while the extra zeros are shifted in, then + // is held 8 more clock cycles while the sequencer + // reads the next two bits. + cycles += 40; // shift 10 bits + const nibble = diskII.ioSwitch(0x8c); // read data + expect(nibble).toBe(0xFF); + }); + + it('reads several FF sync bytes', () => { + let cycles: number = 0; + mocked(mockApple2IO).cycles.mockImplementation(() => cycles); + + const diskII = new DiskII(mockApple2IO, callbacks); + diskII.setBinary(1, 'DOS 3.3 System Master', 'woz', DOS33_SYSTEM_MASTER_IMAGE); + + diskII.ioSwitch(0x89); // turn on the motor + diskII.ioSwitch(0x8e); // read mode + + // The initial bytes in the image are: FF 3F CF F3 + // making the bit stream: + // + // 1111 1111 0011 1111 1100 1111 1111 0011 + // + // That's three FF sync bytes in a row. Assuming + // the sequencer is in state 2, each sync byte takes + // 32 clock cycles to read, is held for 8 clock + // cycles while the extra zeros are shifted in, then + // is held 8 more clock cycles while the sequencer + // reads the next two bits. This means that 3 sync + // bytes will be available for 3 * 40 + 8 cycles. + for (let i = 0; i < 3 * 40 + 8; i++) { + cycles++; + const nibble = diskII.ioSwitch(0x8c); // read data + if (nibble & 0x80) { + // Nibbles are only valid when the high bit is set. + // eslint-disable-next-line jest/no-conditional-expect + expect(nibble).toBe(0xFF); + } + } + }); + + it('reads random garbage on uninitialized tracks', () => { + let cycles: number = 0; + mocked(mockApple2IO).cycles.mockImplementation(() => cycles); + + const diskII = new DiskII(mockApple2IO, callbacks); + diskII.setBinary(1, 'DOS 3.3 System Master', 'woz', DOS33_SYSTEM_MASTER_IMAGE); + + // Step to track 0.5 + diskII.ioSwitch(0x89); // turn on the motor + diskII.ioSwitch(0x81); // coil 0 on + diskII.ioSwitch(0x83); // coil 1 on + diskII.ioSwitch(0x80); // coil 0 off + diskII.ioSwitch(0x82); // coil 1 off + diskII.ioSwitch(0x8e); // read mode + + // Read 5 nibbles + const nibbles: byte[] = []; + let read = false; + while (nibbles.length < 5) { + cycles++; + const nibble = diskII.ioSwitch(0x8c); // read data + const qa = nibble & 0x80; + if (qa && !read) { + nibbles.push(nibble); + read = true; + } + if (!qa && read) { + read = false; + } + } + // Test that the first doesn't equal any of the others. + // (Yes, this test could fail with some bad luck.) + let equal = false; + for (let i = 1; i < 5; i++) { + equal ||= nibbles[0] === nibbles[i]; + } + expect(equal).not.toBeTruthy(); + }); }); }); diff --git a/test/js/formats/woz.spec.ts b/test/js/formats/woz.spec.ts index c40fd09..673fd6b 100644 --- a/test/js/formats/woz.spec.ts +++ b/test/js/formats/woz.spec.ts @@ -19,7 +19,7 @@ describe('woz', () => { rawData: mockWoz1 }; - const disk = createDiskFromWoz(options) ; + const disk = createDiskFromWoz(options); expect(disk).toEqual({ name: 'Mock Woz 1', readOnly: true, @@ -31,9 +31,7 @@ describe('woz', () => { 1, 0, 0, 1, 0, 1, 1, 0, ])], tracks: [new Uint8Array([0xD5, 0xAA, 0x96])], - }); - expect(console.log).toHaveBeenCalledWith(expect.objectContaining({ - info: { + info: { bitTiming: 0, bootSector: 0, cleaned: 0, @@ -47,7 +45,7 @@ describe('woz', () => { version: 1, writeProtected: 0 } - })); + }); }); it('can parse Woz version 2', () => { @@ -58,7 +56,7 @@ describe('woz', () => { rawData: mockWoz2 }; - const disk = createDiskFromWoz(options) ; + const disk = createDiskFromWoz(options); expect(disk).toEqual({ name: 'Mock Woz 2', side: 'B', @@ -71,9 +69,7 @@ describe('woz', () => { 1, 0, 0, 1, 0, 1, 1, 0, ])], tracks: [new Uint8Array([0xD5, 0xAA, 0x96])], - }); - expect(console.log).toHaveBeenCalledWith(expect.objectContaining({ - info: { + info: { bitTiming: 0, bootSector: 0, cleaned: 0, @@ -87,6 +83,6 @@ describe('woz', () => { version: 2, writeProtected: 0 } - })); + }); }); });