|
| 1 | +import { readFile } from 'fs/promises'; |
| 2 | +import { fileURLToPath } from 'url'; |
| 3 | +import { dirname, join } from 'path'; |
| 4 | +import { TAPFile } from './tap-file.js'; |
| 5 | + |
| 6 | +const __filename = fileURLToPath(import.meta.url); |
| 7 | +const __dirname = dirname(__filename); |
| 8 | + |
| 9 | +const FRAME_BUFFER_SIZE = 0x6600; |
| 10 | + |
| 11 | +export interface EmulatorCore { |
| 12 | + memory: WebAssembly.Memory; |
| 13 | + FRAME_BUFFER: number; |
| 14 | + REGISTERS: number; |
| 15 | + TAPE_PULSES: number; |
| 16 | + TAPE_PULSES_LENGTH: number; |
| 17 | + MACHINE_MEMORY: number; |
| 18 | + AUDIO_BUFFER_LEFT: number; |
| 19 | + AUDIO_BUFFER_RIGHT: number; |
| 20 | + runFrame(): number; |
| 21 | + resumeFrame(): number; |
| 22 | + setMachineType(type: number): void; |
| 23 | + reset(): void; |
| 24 | + keyDown(row: number, mask: number): void; |
| 25 | + keyUp(row: number, mask: number): void; |
| 26 | + poke(addr: number, value: number): void; |
| 27 | + setPC(pc: number): void; |
| 28 | + setIFF1(value: number): void; |
| 29 | + setIFF2(value: number): void; |
| 30 | + setIM(value: number): void; |
| 31 | + setHalted(value: number): void; |
| 32 | + writePort(port: number, value: number): void; |
| 33 | + setTStates(tstates: number): void; |
| 34 | + setAudioSamplesPerFrame(samples: number): void; |
| 35 | + getTapePulseBufferTstateCount(): number; |
| 36 | + getTapePulseWriteIndex(): number; |
| 37 | + setTapePulseBufferState(writeIndex: number, tstateCount: number): void; |
| 38 | + setTapeTraps(enabled: number): void; |
| 39 | +} |
| 40 | + |
| 41 | +export class Emulator { |
| 42 | + private core: EmulatorCore | null = null; |
| 43 | + private memoryData: Uint8Array | null = null; |
| 44 | + private frameData: Uint8Array | null = null; |
| 45 | + private registerPairs: Uint16Array | null = null; |
| 46 | + private tape: TAPFile | null = null; |
| 47 | + |
| 48 | + async loadCore(): Promise<void> { |
| 49 | + const wasmPath = join(__dirname, '../../..', 'apps/web/public/dist/jsspeccy-core.wasm'); |
| 50 | + const wasmBuffer = await readFile(wasmPath); |
| 51 | + const results: any = await WebAssembly.instantiate(wasmBuffer, {}); |
| 52 | + this.core = results.instance.exports as unknown as EmulatorCore; |
| 53 | + this.memoryData = new Uint8Array(this.core.memory.buffer); |
| 54 | + this.frameData = this.memoryData.subarray(this.core.FRAME_BUFFER, this.core.FRAME_BUFFER + FRAME_BUFFER_SIZE); |
| 55 | + this.registerPairs = new Uint16Array(this.core.memory.buffer, this.core.REGISTERS, 12); |
| 56 | + } |
| 57 | + |
| 58 | + async loadRom(romPath: string, page: number): Promise<void> { |
| 59 | + if (!this.core || !this.memoryData) { |
| 60 | + throw new Error('Core not loaded'); |
| 61 | + } |
| 62 | + const romData = await readFile(romPath); |
| 63 | + this.memoryData.set(romData, this.core.MACHINE_MEMORY + page * 0x4000); |
| 64 | + } |
| 65 | + |
| 66 | + async loadRoms(): Promise<void> { |
| 67 | + const romsPath = join(__dirname, '../../..', 'apps/web/public/roms'); |
| 68 | + await this.loadRom(join(romsPath, '128-0.rom'), 8); |
| 69 | + await this.loadRom(join(romsPath, '128-1.rom'), 9); |
| 70 | + await this.loadRom(join(romsPath, '48.rom'), 10); |
| 71 | + await this.loadRom(join(romsPath, 'pentagon-0.rom'), 12); |
| 72 | + await this.loadRom(join(romsPath, 'trdos.rom'), 13); |
| 73 | + } |
| 74 | + |
| 75 | + setMachineType(type: number): void { |
| 76 | + if (!this.core) { |
| 77 | + throw new Error('Core not loaded'); |
| 78 | + } |
| 79 | + this.core.setMachineType(type); |
| 80 | + } |
| 81 | + |
| 82 | + reset(): void { |
| 83 | + if (!this.core) { |
| 84 | + throw new Error('Core not loaded'); |
| 85 | + } |
| 86 | + this.core.reset(); |
| 87 | + } |
| 88 | + |
| 89 | + setTapeTraps(enabled: boolean): void { |
| 90 | + if (!this.core) { |
| 91 | + throw new Error('Core not loaded'); |
| 92 | + } |
| 93 | + this.core.setTapeTraps(enabled ? 1 : 0); |
| 94 | + } |
| 95 | + |
| 96 | + loadTAPFile(tapData: Buffer): void { |
| 97 | + this.tape = new TAPFile(tapData); |
| 98 | + } |
| 99 | + |
| 100 | + private trapTapeLoad(): void { |
| 101 | + if (!this.tape || !this.core || !this.registerPairs) { |
| 102 | + console.log('trapTapeLoad: missing tape, core, or registers'); |
| 103 | + return; |
| 104 | + } |
| 105 | + |
| 106 | + const block = this.tape.getNextLoadableBlock(); |
| 107 | + if (!block) { |
| 108 | + console.log('trapTapeLoad: no block available'); |
| 109 | + return; |
| 110 | + } |
| 111 | + console.log(`trapTapeLoad: loading block of ${block.length} bytes`); |
| 112 | + |
| 113 | + const af_ = this.registerPairs[4]; |
| 114 | + const expectedBlockType = af_ >> 8; |
| 115 | + const shouldLoad = af_ & 0x0001; |
| 116 | + let addr = this.registerPairs[8]; |
| 117 | + const requestedLength = this.registerPairs[2]; |
| 118 | + const actualBlockType = block[0]; |
| 119 | + |
| 120 | + console.log(`Tape trap details: expected type=${expectedBlockType}, actual type=${actualBlockType}, shouldLoad=${shouldLoad}, addr=0x${addr.toString(16)}, length=${requestedLength}`); |
| 121 | + |
| 122 | + let success = true; |
| 123 | + if (expectedBlockType !== actualBlockType) { |
| 124 | + success = false; |
| 125 | + } else { |
| 126 | + if (shouldLoad) { |
| 127 | + let offset = 1; |
| 128 | + let loadedBytes = 0; |
| 129 | + let checksum = actualBlockType; |
| 130 | + while (loadedBytes < requestedLength) { |
| 131 | + if (offset >= block.length) { |
| 132 | + success = false; |
| 133 | + break; |
| 134 | + } |
| 135 | + const byte = block[offset++]; |
| 136 | + loadedBytes++; |
| 137 | + this.core.poke(addr, byte); |
| 138 | + addr = (addr + 1) & 0xffff; |
| 139 | + checksum ^= byte; |
| 140 | + } |
| 141 | + |
| 142 | + success = success && (offset < block.length); |
| 143 | + if (success) { |
| 144 | + const expectedChecksum = block[offset]; |
| 145 | + success = (checksum === expectedChecksum); |
| 146 | + } |
| 147 | + } else { |
| 148 | + success = true; |
| 149 | + } |
| 150 | + } |
| 151 | + |
| 152 | + if (success) { |
| 153 | + this.registerPairs[0] |= 0x0001; |
| 154 | + console.log(`Trap successful, AF=${this.registerPairs[0].toString(16)}, setting PC to 0x05e2`); |
| 155 | + } else { |
| 156 | + this.registerPairs[0] &= 0xfffe; |
| 157 | + console.log(`Trap failed, AF=${this.registerPairs[0].toString(16)}, setting PC to 0x05e2`); |
| 158 | + } |
| 159 | + this.core.setPC(0x05e2); |
| 160 | + } |
| 161 | + |
| 162 | + runFrame(): Uint8Array { |
| 163 | + if (!this.core || !this.frameData) { |
| 164 | + throw new Error('Core not loaded'); |
| 165 | + } |
| 166 | + this.core.setAudioSamplesPerFrame(0); |
| 167 | + let status = this.core.runFrame(); |
| 168 | + let trapCount = 0; |
| 169 | + while (status) { |
| 170 | + trapCount++; |
| 171 | + switch (status) { |
| 172 | + case 1: |
| 173 | + throw new Error('Unrecognized opcode'); |
| 174 | + case 2: |
| 175 | + console.log(`Tape trap #${trapCount} in this frame`); |
| 176 | + this.trapTapeLoad(); |
| 177 | + break; |
| 178 | + default: |
| 179 | + throw new Error(`runFrame returned unexpected result: ${status}`); |
| 180 | + } |
| 181 | + status = this.core.resumeFrame(); |
| 182 | + } |
| 183 | + return new Uint8Array(this.frameData); |
| 184 | + } |
| 185 | + |
| 186 | + loadMemoryPage(page: number, data: Uint8Array): void { |
| 187 | + if (!this.core || !this.memoryData) { |
| 188 | + throw new Error('Core not loaded'); |
| 189 | + } |
| 190 | + this.memoryData.set(data, this.core.MACHINE_MEMORY + page * 0x4000); |
| 191 | + } |
| 192 | + |
| 193 | + async loadSnapshot(snapshot: any): Promise<void> { |
| 194 | + if (!this.core || !this.registerPairs) { |
| 195 | + throw new Error('Core not loaded'); |
| 196 | + } |
| 197 | + this.core.setMachineType(snapshot.model); |
| 198 | + for (const page in snapshot.memoryPages) { |
| 199 | + this.loadMemoryPage(Number(page), snapshot.memoryPages[page]); |
| 200 | + } |
| 201 | + ['AF', 'BC', 'DE', 'HL', 'AF_', 'BC_', 'DE_', 'HL_', 'IX', 'IY', 'SP', 'IR'].forEach( |
| 202 | + (r, i) => { |
| 203 | + if (this.registerPairs) { |
| 204 | + this.registerPairs[i] = snapshot.registers[r]; |
| 205 | + } |
| 206 | + } |
| 207 | + ); |
| 208 | + this.core.setPC(snapshot.registers.PC); |
| 209 | + this.core.setIFF1(snapshot.registers.iff1); |
| 210 | + this.core.setIFF2(snapshot.registers.iff2); |
| 211 | + this.core.setIM(snapshot.registers.im); |
| 212 | + this.core.setHalted(snapshot.halted ? 1 : 0); |
| 213 | + |
| 214 | + this.core.writePort(0x00fe, snapshot.ulaState.borderColour); |
| 215 | + if (snapshot.model !== 48) { |
| 216 | + this.core.writePort(0x7ffd, snapshot.ulaState.pagingFlags); |
| 217 | + } |
| 218 | + |
| 219 | + this.core.setTStates(snapshot.tstates); |
| 220 | + } |
| 221 | +} |
0 commit comments