/** @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 { 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'; jest.mock('js/apple2io'); jest.mock('js/videomodes'); type Phase = 0 | 1 | 2 | 3; // not exported from DiskII const STEPS_PER_TRACK = 4; const PHASES_PER_TRACK = 2; function setTrack(diskII: DiskII, track: number) { const initialState = diskII.getState(); initialState.drives[0].track = track * STEPS_PER_TRACK; initialState.drives[0].phase = (track * PHASES_PER_TRACK) % 4 as Phase; diskII.setState(initialState); } function setWriteProtected(diskII: DiskII, isWriteProtected: boolean) { const initialState = diskII.getState(); initialState.drives[0].readOnly = isWriteProtected; diskII.setState(initialState); } describe('DiskII', () => { const mockApple2IO = new Apple2IO({} as unknown as CPU6502, {} as unknown as VideoModes); const callbacks: Callbacks = { driveLight: jest.fn(), dirty: jest.fn(), label: jest.fn(), }; beforeEach(() => { jest.resetAllMocks(); }); it('is constructable', () => { const diskII = new DiskII(mockApple2IO, callbacks); expect(diskII).not.toBeNull(); }); it('round-trips the state when there are no changes', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); const state = diskII.getState(); diskII.setState(state); expect(diskII.getState()).toEqual(state); }); it('round-trips the state when there are changes', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); diskII.setBinary(2, 'BYTES_BY_SECTOR', 'po', BYTES_BY_SECTOR_IMAGE); const state = diskII.getState(); // These are just arbitrary changes, not an exhaustive list of fields. state.drive = 2; state.skip = 1; state.latch = 0x42; state.on = true; state.writeMode = true; state.drives[1].tracks[14][12] = 0x80; state.drives[1].head = 1000; state.drives[1].phase = 3; diskII.setState(state); expect(diskII.getState()).toEqual(state); }); it('calls all of the callbacks when state is restored', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); jest.resetAllMocks(); const state = diskII.getState(); diskII.setState(state); expect(callbacks.driveLight).toHaveBeenCalledTimes(2); expect(callbacks.driveLight).toHaveBeenCalledWith(1, false); expect(callbacks.driveLight).toHaveBeenCalledWith(2, false); expect(callbacks.label).toHaveBeenCalledTimes(2); expect(callbacks.label).toHaveBeenCalledWith(1, 'BYTES_BY_TRACK', undefined); expect(callbacks.label).toHaveBeenCalledWith(2, 'Disk 2', undefined); expect(callbacks.dirty).toHaveBeenCalledTimes(2); expect(callbacks.dirty).toHaveBeenCalledWith(1, true); expect(callbacks.dirty).toHaveBeenCalledWith(2, false); }); describe('drive lights', () => { it('turns on drive light 1 when the motor is turned on', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.ioSwitch(0x89); // turn on the motor expect(callbacks.driveLight).toBeCalledTimes(1); expect(callbacks.driveLight).toBeCalledWith(1, true); }); it('turns off drive light 1 when the motor is turned off', () => { jest.useFakeTimers(); const diskII = new DiskII(mockApple2IO, callbacks); diskII.ioSwitch(0x89); // turn on the motor mocked(callbacks.driveLight).mockReset(); diskII.ioSwitch(0x88); // turn off the motor jest.runAllTimers(); expect(callbacks.driveLight).toBeCalledTimes(1); expect(callbacks.driveLight).toBeCalledWith(1, false); jest.useRealTimers(); }); it('turns on drive light 2 when drive 2 is selected and the motor is turned on', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.ioSwitch(0x8B); // select drive 2 diskII.ioSwitch(0x89); // turn on the motor expect(callbacks.driveLight).toBeCalledTimes(1); expect(callbacks.driveLight).toBeCalledWith(2, true); }); it('turns off drive light 2 when drive 2 is selected and the motor is turned off', () => { jest.useFakeTimers(); const diskII = new DiskII(mockApple2IO, callbacks); diskII.ioSwitch(0x8B); // select drive 2 diskII.ioSwitch(0x89); // turn on the motor mocked(callbacks.driveLight).mockReset(); diskII.ioSwitch(0x88); // turn off the motor jest.runAllTimers(); expect(callbacks.driveLight).toBeCalledTimes(1); expect(callbacks.driveLight).toBeCalledWith(2, false); jest.useRealTimers(); }); it('turns off drive light 1 and turns on drive light two when drive 2 is selected', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.ioSwitch(0x89); // turn on the motor diskII.ioSwitch(0x8B); // select drive 2 expect(callbacks.driveLight).toBeCalledTimes(3); expect(callbacks.driveLight).toHaveBeenNthCalledWith(1, 1, true); expect(callbacks.driveLight).toHaveBeenNthCalledWith(2, 1, false); expect(callbacks.driveLight).toHaveBeenNthCalledWith(3, 2, true); }); }); describe('head positioning', () => { it('does not allow head positioning when the drive is off', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); diskII.ioSwitch(0x81); // coil 0 on 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(0); expect(state.drives[0].track).toBe(0); }); it('allows head positioning when the drive is on', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); 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(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); expect(state.drives[0].track).toBe(4); }); it('moves the head to track 2 from track 0 when all phases are cycled', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); 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(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 diskII.ioSwitch(0x86); // coil 3 off diskII.ioSwitch(0x80); // coil 0 off const state = diskII.getState(); expect(state.drives[0].phase).toBe(0); expect(state.drives[0].track).toBe(2 * STEPS_PER_TRACK); }); it('moves the head to track 10 from track 8 when all phases are cycled', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); setTrack(diskII, 8); 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(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 diskII.ioSwitch(0x86); // coil 3 off diskII.ioSwitch(0x80); // coil 0 off const state = diskII.getState(); expect(state.drives[0].phase).toBe(0); expect(state.drives[0].track).toBe(10 * STEPS_PER_TRACK); }); it('stops the head at track 34', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); setTrack(diskII, 33); diskII.ioSwitch(0x89); // turn on the motor diskII.ioSwitch(0x85); // coil 2 on 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(0x87); // coil 3 on diskII.ioSwitch(0x84); // coil 2 off diskII.ioSwitch(0x81); // coil 0 on diskII.ioSwitch(0x86); // coil 3 off diskII.ioSwitch(0x80); // coil 0 off const state = diskII.getState(); expect(state.drives[0].phase).toBe(0); // The emulated Disk II puts data for track n on the // 4 quarter-tracks starting with n * STEPS_PER_TRACK. // On a real Disk II, the data would likely be on 3 // quarter-tracks starting with n * STEPS_PER_TRACK - 1, // leaving 1 essentially blank quarter track at the // half-track. expect(state.drives[0].track).toBe(35 * STEPS_PER_TRACK - 1); }); it('moves a half track when only one phase is activated', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); setTrack(diskII, 15); diskII.ioSwitch(0x89); // turn on the motor diskII.ioSwitch(0x85); // coil 2 on diskII.ioSwitch(0x87); // coil 3 on diskII.ioSwitch(0x84); // coil 2 off diskII.ioSwitch(0x86); // coil 3 off const state = diskII.getState(); expect(state.drives[0].phase).toBe(3); expect(state.drives[0].track).toBe(15 * STEPS_PER_TRACK + 2); }); it('moves backward one track when phases are cycled in reverse', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); setTrack(diskII, 15); diskII.ioSwitch(0x89); // turn on the motor diskII.ioSwitch(0x85); // coil 2 on diskII.ioSwitch(0x83); // coil 1 on diskII.ioSwitch(0x84); // coil 2 off diskII.ioSwitch(0x81); // coil 0 on diskII.ioSwitch(0x82); // coil 1 off diskII.ioSwitch(0x80); // coil 0 off const state = diskII.getState(); expect(state.drives[0].phase).toBe(0); expect(state.drives[0].track).toBe(14 * STEPS_PER_TRACK); }); it('moves backward two tracks when all phases are cycled in reverse', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); setTrack(diskII, 15); diskII.ioSwitch(0x89); // turn on the motor diskII.ioSwitch(0x85); // coil 2 on diskII.ioSwitch(0x83); // coil 1 on diskII.ioSwitch(0x84); // coil 2 off diskII.ioSwitch(0x81); // coil 0 on diskII.ioSwitch(0x82); // coil 1 off diskII.ioSwitch(0x87); // coil 3 on diskII.ioSwitch(0x80); // coil 0 off diskII.ioSwitch(0x85); // coil 2 on diskII.ioSwitch(0x86); // coil 3 off diskII.ioSwitch(0x84); // coil 2 off const state = diskII.getState(); expect(state.drives[0].phase).toBe(2); expect(state.drives[0].track).toBe(13 * STEPS_PER_TRACK); }); it('does not move backwards past track 0', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); setTrack(diskII, 1); diskII.ioSwitch(0x89); // turn on the motor diskII.ioSwitch(0x85); // coil 2 on diskII.ioSwitch(0x83); // coil 1 on diskII.ioSwitch(0x84); // coil 2 off diskII.ioSwitch(0x81); // coil 0 on diskII.ioSwitch(0x82); // coil 1 off diskII.ioSwitch(0x87); // coil 3 on diskII.ioSwitch(0x80); // coil 0 off diskII.ioSwitch(0x85); // coil 2 on diskII.ioSwitch(0x86); // coil 3 off diskII.ioSwitch(0x84); // coil 2 off const state = diskII.getState(); expect(state.drives[0].phase).toBe(2); expect(state.drives[0].track).toBe(0); }); it('moves backward one half track', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); setTrack(diskII, 15); diskII.ioSwitch(0x89); // turn on the motor diskII.ioSwitch(0x85); // coil 2 on diskII.ioSwitch(0x83); // coil 1 on diskII.ioSwitch(0x84); // coil 2 off diskII.ioSwitch(0x82); // coil 1 off const state = diskII.getState(); expect(state.drives[0].phase).toBe(1); expect(state.drives[0].track).toBe(14.5 * STEPS_PER_TRACK); }); // The emulated Disk II is not able to step quarter tracks because // it does not track when phases are turned off. // eslint-disable-next-line jest/no-disabled-tests it.skip('moves a quarter track when two neighboring phases are activated and held', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); setTrack(diskII, 15); diskII.ioSwitch(0x89); // turn on the motor diskII.ioSwitch(0x85); // coil 2 on diskII.ioSwitch(0x87); // coil 3 on const state = diskII.getState(); expect(state.drives[0].phase).toBe(3); expect(state.drives[0].track).toBe(15 * STEPS_PER_TRACK + 1); }); // The emulated Disk II is not able to step quarter tracks because // it does not track when phases are turned off. // eslint-disable-next-line jest/no-disabled-tests it.skip('moves backward one quarter track', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); setTrack(diskII, 15); diskII.ioSwitch(0x89); // turn on the motor diskII.ioSwitch(0x85); // coil 2 on diskII.ioSwitch(0x83); // coil 1 on const state = diskII.getState(); expect(state.drives[0].phase).toBe(1); expect(state.drives[0].track).toBe(14.25 * STEPS_PER_TRACK); }); }); describe('reading nibble-based disks', () => { it('spins the disk', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); diskII.ioSwitch(0x89); // turn on the motor diskII.ioSwitch(0x8e); // read mode // Just check for changing nibbles let spinning = false; const firstNibble = diskII.ioSwitch(0x8c); // read data for (let i = 0; i < 512; i++) { const thisNibble = diskII.ioSwitch(0x8c); // read data if (thisNibble >= 0x80 && firstNibble !== thisNibble) { spinning = true; } } expect(spinning).toBeTruthy(); }); it('after reading the data, the data register is set to zero', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); diskII.ioSwitch(0x89); // turn on the motor diskII.ioSwitch(0x8e); // read mode // Find address field prolog let nibble = diskII.ioSwitch(0x8c); // read data for (let i = 0; i < 512 && nibble !== 0xD5; i++) { nibble = diskII.ioSwitch(0x8c); // read data } expect(nibble).toBe(0xD5); nibble = diskII.ioSwitch(0x8c); // read data // expect next read to be a zero because the sequencer is waiting // for data expect(nibble).toBe(0x00); }); it('after reading the data, then zero, there is new data', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); diskII.ioSwitch(0x89); // turn on the motor diskII.ioSwitch(0x8e); // read mode // Find address field prolog let nibble = diskII.ioSwitch(0x8c); // read data for (let i = 0; i < 512 && nibble !== 0xD5; i++) { nibble = diskII.ioSwitch(0x8c); // read data } expect(nibble).toBe(0xD5); nibble = diskII.ioSwitch(0x8c); // read data // expect next read to be a zero expect(nibble).toBe(0x00); // expect next read to be new data nibble = diskII.ioSwitch(0x8c); // read data expect(nibble).toBe(0xAA); }); it('read write protect status', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); setWriteProtected(diskII, true); diskII.ioSwitch(0x89); // turn on the motor diskII.ioSwitch(0x8E); // read mode diskII.ioSwitch(0x8D); // read write protect if read const isWriteProtected = diskII.ioSwitch(0x8E); // read data expect(isWriteProtected).toBe(0xff); }); }); describe('writing nibble-based disks', () => { it('writes a nibble to the disk', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); let track0 = diskII.getState().drives[0].tracks[0]; expect(track0[0]).toBe(0xFF); diskII.ioSwitch(0x89); // turn on the motor diskII.ioSwitch(0x8F, 0x80); // write diskII.ioSwitch(0x8C); // shift track0 = diskII.getState().drives[0].tracks[0]; expect(track0[0]).toBe(0x80); }); it('writes two nibbles to the disk', () => { const diskII = new DiskII(mockApple2IO, callbacks); diskII.setBinary(1, 'BYTES_BY_TRACK', 'po', BYTES_BY_TRACK_IMAGE); let track0 = diskII.getState().drives[0].tracks[0]; expect(track0[0]).toBe(0xFF); diskII.ioSwitch(0x89); // turn on the motor diskII.ioSwitch(0x8F, 0x80); // write diskII.ioSwitch(0x8C); // shift diskII.ioSwitch(0x8F, 0x81); // write diskII.ioSwitch(0x8C); // shift track0 = diskII.getState().drives[0].tracks[0]; 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(); }); 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; } }