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.
This commit is contained in:
Ian Flanigan 2022-06-23 15:38:36 +02:00 committed by GitHub
parent f283dae7e1
commit 7e41c69366
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 172 additions and 2 deletions

View File

@ -409,8 +409,46 @@ export default class DiskII implements Card<State> {
// debug(..._args); // 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() { private moveHead() {
// TODO(flan): Short-circuit if the drive is not on.
const cycles = this.io.cycles(); const cycles = this.io.cycles();
// Spin the disk the number of elapsed cycles since last call // Spin the disk the number of elapsed cycles since last call
@ -685,7 +723,7 @@ export default class DiskII implements Card<State> {
break; break;
case LOC.DRIVEWRITEMODE: // 0x0f (Q7H) case LOC.DRIVEWRITEMODE: // 0x0f (Q7H)
this.debug('Write Mode'); this.debug('Write Mode');
this.q7 = false; this.q7 = true;
this.writeMode = true; this.writeMode = true;
break; break;

View File

@ -4,7 +4,9 @@ import fs from 'fs';
import Apple2IO from 'js/apple2io'; import Apple2IO from 'js/apple2io';
import DiskII, { Callbacks } from 'js/cards/disk2'; import DiskII, { Callbacks } from 'js/cards/disk2';
import CPU6502 from 'js/cpu6502'; import CPU6502 from 'js/cpu6502';
import { DriveNumber } from 'js/formats/types';
import { byte } from 'js/types'; import { byte } from 'js/types';
import { toHex } from 'js/util';
import { VideoModes } from 'js/videomodes'; import { VideoModes } from 'js/videomodes';
import { mocked } from 'ts-jest/utils'; import { mocked } from 'ts-jest/utils';
import { BYTES_BY_SECTOR_IMAGE, BYTES_BY_TRACK_IMAGE } from '../formats/testdata/16sector'; import { BYTES_BY_SECTOR_IMAGE, BYTES_BY_TRACK_IMAGE } from '../formats/testdata/16sector';
@ -694,5 +696,135 @@ describe('DiskII', () => {
} }
expect(equal).not.toBeTruthy(); 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;
}
}