From 7e41c693664dc5e4997a71790be3634821f27844 Mon Sep 17 00:00:00 2001 From: Ian Flanigan Date: Thu, 23 Jun 2022 15:38:36 +0200 Subject: [PATCH] Add a basic write test for WOZ images (#138) * Add a basic write test for WOZ images The new test just tries to change some random nibbles at the beginning of the image and then verifies that the change has been recorded. This exposed a bug where `q7` was never set to `true` when write mode was toggled on. Also, the assumptions and limitations of `moveHead` are more clearly documented. * Address comments * Improved `moveHead` documentation a bit more. * Removed redundant variable in `readNibble`. * Refactored `findSector` and commented out the chatty log line. All tests pass. No lint warnings. --- js/cards/disk2.ts | 42 +++++++++++- test/js/cards/disk2.spec.ts | 132 ++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 2 deletions(-) diff --git a/js/cards/disk2.ts b/js/cards/disk2.ts index 9b58473..a569719 100644 --- a/js/cards/disk2.ts +++ b/js/cards/disk2.ts @@ -409,8 +409,46 @@ export default class DiskII implements Card { // debug(..._args); } - // Only used for WOZ disks + public head(): number { + return this.cur.head; + } + + /** + * 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 @@ -685,7 +723,7 @@ export default class DiskII implements Card { break; case LOC.DRIVEWRITEMODE: // 0x0f (Q7H) this.debug('Write Mode'); - this.q7 = false; + this.q7 = true; this.writeMode = true; break; diff --git a/test/js/cards/disk2.spec.ts b/test/js/cards/disk2.spec.ts index 62269b2..90df011 100644 --- a/test/js/cards/disk2.spec.ts +++ b/test/js/cards/disk2.spec.ts @@ -4,7 +4,9 @@ import fs from 'fs'; import Apple2IO from 'js/apple2io'; import DiskII, { Callbacks } from 'js/cards/disk2'; import CPU6502 from 'js/cpu6502'; +import { DriveNumber } from 'js/formats/types'; import { byte } from 'js/types'; +import { toHex } from 'js/util'; import { VideoModes } from 'js/videomodes'; import { mocked } from 'ts-jest/utils'; import { BYTES_BY_SECTOR_IMAGE, BYTES_BY_TRACK_IMAGE } from '../formats/testdata/16sector'; @@ -694,5 +696,135 @@ describe('DiskII', () => { } expect(equal).not.toBeTruthy(); }); + + it('disk spins at a consistent speed', () => { + const reader = new TestDiskReader(1, 'DOS 3.3 System Master', DOS33_SYSTEM_MASTER_IMAGE, mockApple2IO, callbacks); + + reader.diskII.ioSwitch(0x89); // turn on the motor + reader.diskII.ioSwitch(0x8e); // read mode + + // Find track 0, sector 0 + reader.findSector(0); + // Save the start cycles + let lastCycles = mockApple2IO.cycles(); + // Find track 0, sector 0 again + reader.findSector(0); + let currentCycles = reader.cycles; + expect(currentCycles - lastCycles).toBe(201216); + lastCycles = currentCycles; + // Find track 0, sector 0 once again + reader.findSector(0); + currentCycles = reader.cycles; + expect(currentCycles - lastCycles).toBe(201216); + }); + }); + + describe('writing WOZ-based disks', () => { + const DOS33_SYSTEM_MASTER_IMAGE = + fs.readFileSync('test/js/cards/data/DOS 3.3 System Master.woz').buffer; + + it('can write something', () => { + const reader = new TestDiskReader(1, 'DOS 3.3 System Master', DOS33_SYSTEM_MASTER_IMAGE, mockApple2IO, callbacks); + const diskII = reader.diskII; + const before = reader.rawTracks(); + + diskII.ioSwitch(0x89); // turn on the motor + + // emulate STA $C08F,X (5 CPU cycles) + reader.cycles += 4; // op + load address + work + diskII.tick(); + reader.cycles += 1; + diskII.ioSwitch(0x8F, 0x80); // write + // read $C08C,X + reader.cycles += 4; // op + load address + work + diskII.tick(); + reader.cycles += 1; + diskII.ioSwitch(0x8C); // shift + + reader.cycles += 29; // wait + diskII.tick(); // nop (make sure the change is applied) + + const after = reader.rawTracks(); + expect(before).not.toEqual(after); + }); }); }); + +class TestDiskReader { + cycles: number = 0; + nibbles = 0; + diskII: DiskII; + + constructor(drive: DriveNumber, label: string, image: ArrayBufferLike, apple2IO: Apple2IO, callbacks: Callbacks) { + mocked(apple2IO).cycles.mockImplementation(() => this.cycles); + + this.diskII = new DiskII(apple2IO, callbacks); + this.diskII.setBinary(drive, label, 'woz', image); + } + + readNibble(): byte { + let result: number = 0; + for (let i = 0; i < 100; i++) { + this.cycles++; + const nibble = this.diskII.ioSwitch(0x8c); // read data + if (nibble & 0x80) { + result = nibble; + } else if (result & 0x80) { + this.nibbles++; + return result; + } + } + throw new Error('Did not find a nibble in 100 clock cycles'); + } + + findAddressField() { + let s = ''; + for (let i = 0; i < 600; i++) { + let nibble = this.readNibble(); + if (nibble !== 0xD5) { + s += ` ${toHex(nibble)}`; + continue; + } + nibble = this.readNibble(); + if (nibble !== 0xAA) { + continue; + } + nibble = this.readNibble(); + if (nibble !== 0x96) { + continue; + } + return; + } + throw new Error(`Did not find an address field in 500 nibbles: ${s}`); + } + + nextSector() { + this.findAddressField(); + const volume = (this.readNibble() << 1 | 1) & this.readNibble(); + const track = (this.readNibble() << 1 | 1) & this.readNibble(); + const sector = (this.readNibble() << 1 | 1) & this.readNibble(); + // console.log(`vol: ${volume} trk: ${track} sec: ${thisSector} ${this.diskII.head()} ${this.nibbles}`); + return { volume, track, sector }; + } + + findSector(sector: byte) { + for (let i = 0; i < 32; i++) { + const { sector: thisSector } = this.nextSector(); + if (sector === thisSector) { + return; + } + } + throw new Error(`Did not find sector ${sector} in 32 sectors`); + } + + rawTracks() { + // NOTE(flan): Hack to access private properties. + const disk = this.diskII as unknown as { cur: { rawTracks: Uint8Array[] } }; + const result: Uint8Array[] = []; + for (let i = 0; i < disk.cur.rawTracks.length; i++) { + result[i] = disk.cur.rawTracks[i].slice(0); + } + + return result; + } +}