Add tests for WOZ disks (#136)

* Add a test for the dirty callback on writes

This new test just checks that a clean disk becomes dirty after a
write _and_ that the dirty callback is fired.

* Add tests for WOZ disks

The new tests verify the basic read behavior of the state sequencer on
well-behaved disks, including sync bytes and so on.  Write tests are
still to come.

There's also a change to the Woz format to return the info chunk data
as well.
This commit is contained in:
Ian Flanigan 2022-06-20 04:52:06 +02:00 committed by GitHub
parent 466a7eed78
commit 99ba052597
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 229 additions and 22 deletions

View File

@ -1,4 +1,4 @@
import { base64_encode} from '../base64'; import { base64_encode } from '../base64';
import type { import type {
byte, byte,
Card, Card,
@ -28,7 +28,7 @@ import {
createDiskFromJsonDisk createDiskFromJsonDisk
} from '../formats/create_disk'; } from '../formats/create_disk';
import { debug, toHex } from '../util'; import { toHex } from '../util';
import { jsonDecode, jsonEncode, readSector } from '../formats/format_utils'; import { jsonDecode, jsonEncode, readSector } from '../formats/format_utils';
import { BOOTSTRAP_ROM_16, BOOTSTRAP_ROM_13 } from '../roms/cards/disk2'; import { BOOTSTRAP_ROM_16, BOOTSTRAP_ROM_13 } from '../roms/cards/disk2';
@ -201,7 +201,7 @@ interface NibbleDrive extends BaseDrive {
type Drive = WozDrive | NibbleDrive; type Drive = WozDrive | NibbleDrive;
function isNibbleDrive(drive: Drive): drive is NibbleDrive { function isNibbleDrive(drive: Drive): drive is NibbleDrive {
return drive.encoding === ENCODING_NIBBLE; return drive.encoding === ENCODING_NIBBLE;
} }
@ -395,12 +395,18 @@ export default class DiskII implements Card<State> {
this.lastCycles = this.io.cycles(); this.lastCycles = this.io.cycles();
this.bootstrapRom = this.sectors === 16 ? BOOTSTRAP_ROM_16 : BOOTSTRAP_ROM_13; this.bootstrapRom = this.sectors === 16 ? BOOTSTRAP_ROM_16 : BOOTSTRAP_ROM_13;
this.sequencerRom = this.sectors === 16 ? SEQUENCER_ROM_16 : SEQUENCER_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(); this.initWorker();
} }
private debug(..._args: unknown[]) { private debug(..._args: unknown[]) {
// debug.apply(this, arguments); // debug(..._args);
} }
// Only used for WOZ disks // Only used for WOZ disks
@ -422,7 +428,7 @@ export default class DiskII implements Card<State> {
if (this.clock === 4) { if (this.clock === 4) {
pulse = track[this.cur.head]; pulse = track[this.cur.head];
if (!pulse) { if (!pulse) {
// More that 2 zeros can not be read reliably. // More than 2 zeros can not be read reliably.
if (++this.zeros > 2) { if (++this.zeros > 2) {
pulse = Math.random() >= 0.5 ? 1 : 0; pulse = Math.random() >= 0.5 ? 1 : 0;
} }
@ -440,9 +446,7 @@ export default class DiskII implements Card<State> {
const command = this.sequencerRom[idx]; const command = this.sequencerRom[idx];
if (this.on && this.q7) { this.debug(`clock: ${this.clock} state: ${toHex(this.state)} pulse: ${pulse} command: ${toHex(command)} q6: ${this.q6} latch: ${toHex(this.latch)}`);
debug('clock:', this.clock, 'command:', toHex(command), 'q6:', this.q6);
}
switch (command & 0xf) { switch (command & 0xf) {
case 0x0: // CLR case 0x0: // CLR
@ -461,11 +465,13 @@ export default class DiskII implements Card<State> {
break; break;
case 0xB: // LD case 0xB: // LD
this.latch = this.bus; this.latch = this.bus;
debug('Loading', toHex(this.latch), 'from bus'); this.debug('Loading', toHex(this.latch), 'from bus');
break; break;
case 0xD: // SL1 case 0xD: // SL1
this.latch = ((this.latch << 1) | 0x01) & 0xff; this.latch = ((this.latch << 1) | 0x01) & 0xff;
break; break;
default:
this.debug(`unknown command: ${toHex(command & 0xf)}`);
} }
this.state = (command >> 4 & 0xF) as nibble; this.state = (command >> 4 & 0xF) as nibble;
@ -473,7 +479,7 @@ export default class DiskII implements Card<State> {
if (this.on) { if (this.on) {
if (this.q7) { if (this.q7) {
track[this.cur.head] = this.state & 0x8 ? 0x01 : 0x00; 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) { if (++this.cur.head >= track.length) {
@ -544,6 +550,10 @@ export default class DiskII implements Card<State> {
this.cur.phase = phase; 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) const maxTrack = isNibbleDrive(this.cur)
? this.cur.tracks.length * 4 - 1 ? this.cur.tracks.length * 4 - 1
: this.cur.trackMap.length - 1; : this.cur.trackMap.length - 1;

View File

@ -1,5 +1,6 @@
import type { byte, memory, MemberOf, word } from '../types'; import type { byte, memory, MemberOf, word } from '../types';
import type { GamepadConfiguration } from '../ui/types'; import type { GamepadConfiguration } from '../ui/types';
import { InfoChunk } from './woz';
export const DRIVE_NUMBERS = [1, 2] as const; export const DRIVE_NUMBERS = [1, 2] as const;
export type DriveNumber = MemberOf<typeof DRIVE_NUMBERS>; export type DriveNumber = MemberOf<typeof DRIVE_NUMBERS>;
@ -70,6 +71,7 @@ export interface WozDisk extends FloppyDisk {
encoding: typeof ENCODING_BITSTREAM; encoding: typeof ENCODING_BITSTREAM;
trackMap: number[]; trackMap: number[];
rawTracks: Uint8Array[]; rawTracks: Uint8Array[];
info: InfoChunk | undefined;
} }
export interface BlockDisk extends Disk { export interface BlockDisk extends Disk {

View File

@ -155,6 +155,9 @@ export class TrksChunk2 extends TrksChunk {
const end = start + trk.blockCount * 512; const end = start + trk.blockCount * 512;
const slice = bits.slice(start, end); const slice = bits.slice(start, end);
const trackData = new Uint8Array(slice); 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++) { for (let jdx = 0; jdx < trk.bitCount; jdx++) {
const byteIndex = jdx >> 3; const byteIndex = jdx >> 3;
const bitIndex = 7 - (jdx & 0x07); const bitIndex = 7 - (jdx & 0x07);
@ -284,9 +287,9 @@ export default function createDiskFromWoz(options: DiskOptions): WozDisk {
debug('Invalid woz header'); debug('Invalid woz header');
} }
debug(chunks); // debug(chunks);
const { meta, tmap, trks } = chunks; const { meta, tmap, trks, info } = chunks;
const disk: WozDisk = { const disk: WozDisk = {
encoding: ENCODING_BITSTREAM, encoding: ENCODING_BITSTREAM,
@ -296,6 +299,7 @@ export default function createDiskFromWoz(options: DiskOptions): WozDisk {
readOnly: true, //chunks.info.writeProtected === 1; readOnly: true, //chunks.info.writeProtected === 1;
name: meta?.values['title'] || options.name, name: meta?.values['title'] || options.name,
side: meta?.values['side_name'] || meta?.values['side'], side: meta?.values['side_name'] || meta?.values['side'],
info
}; };
return disk; return disk;

Binary file not shown.

View File

@ -1,7 +1,10 @@
/** @jest-environment jsdom */ /** @jest-environment jsdom */
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 { byte } from 'js/types';
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';
@ -248,6 +251,7 @@ describe('DiskII', () => {
diskII.ioSwitch(0x83); // coil 1 on diskII.ioSwitch(0x83); // coil 1 on
diskII.ioSwitch(0x80); // coil 0 off diskII.ioSwitch(0x80); // coil 0 off
diskII.ioSwitch(0x85); // coil 2 on diskII.ioSwitch(0x85); // coil 2 on
diskII.ioSwitch(0x82); // coil 1 off
diskII.ioSwitch(0x87); // coil 3 on diskII.ioSwitch(0x87); // coil 3 on
diskII.ioSwitch(0x84); // coil 2 off diskII.ioSwitch(0x84); // coil 2 off
diskII.ioSwitch(0x81); // coil 0 on diskII.ioSwitch(0x81); // coil 0 on
@ -499,5 +503,196 @@ describe('DiskII', () => {
expect(track0[0]).toBe(0x80); expect(track0[0]).toBe(0x80);
expect(track0[1]).toBe(0x81); 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();
});
}); });
}); });

View File

@ -19,7 +19,7 @@ describe('woz', () => {
rawData: mockWoz1 rawData: mockWoz1
}; };
const disk = createDiskFromWoz(options) ; const disk = createDiskFromWoz(options);
expect(disk).toEqual({ expect(disk).toEqual({
name: 'Mock Woz 1', name: 'Mock Woz 1',
readOnly: true, readOnly: true,
@ -31,9 +31,7 @@ describe('woz', () => {
1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 1, 1, 0,
])], ])],
tracks: [new Uint8Array([0xD5, 0xAA, 0x96])], tracks: [new Uint8Array([0xD5, 0xAA, 0x96])],
}); info: {
expect(console.log).toHaveBeenCalledWith(expect.objectContaining({
info: {
bitTiming: 0, bitTiming: 0,
bootSector: 0, bootSector: 0,
cleaned: 0, cleaned: 0,
@ -47,7 +45,7 @@ describe('woz', () => {
version: 1, version: 1,
writeProtected: 0 writeProtected: 0
} }
})); });
}); });
it('can parse Woz version 2', () => { it('can parse Woz version 2', () => {
@ -58,7 +56,7 @@ describe('woz', () => {
rawData: mockWoz2 rawData: mockWoz2
}; };
const disk = createDiskFromWoz(options) ; const disk = createDiskFromWoz(options);
expect(disk).toEqual({ expect(disk).toEqual({
name: 'Mock Woz 2', name: 'Mock Woz 2',
side: 'B', side: 'B',
@ -71,9 +69,7 @@ describe('woz', () => {
1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 1, 1, 0,
])], ])],
tracks: [new Uint8Array([0xD5, 0xAA, 0x96])], tracks: [new Uint8Array([0xD5, 0xAA, 0x96])],
}); info: {
expect(console.log).toHaveBeenCalledWith(expect.objectContaining({
info: {
bitTiming: 0, bitTiming: 0,
bootSector: 0, bootSector: 0,
cleaned: 0, cleaned: 0,
@ -87,6 +83,6 @@ describe('woz', () => {
version: 2, version: 2,
writeProtected: 0 writeProtected: 0
} }
})); });
}); });
}); });