UNPKG

zx-generation

Version:

A high-fidelity ZX Spectrum emulator in JavaScript — fully generated by a large language model (LLM) to explore the boundaries of AI in systems programming.

987 lines (842 loc) 33.7 kB
/** * ZX Spectrum Tape Emulation * Supports TAP and TZX tape formats * * Key findings from research: * - Each bit is represented by 2 pulses (complete square wave) * - Pilot tone: 8063 pulses for headers, 3223 for data blocks * - Standard timings: PILOT=2168, SYNC1=667, SYNC2=735, ZERO=855, ONE=1710 T-states * - Edge-triggered loading (polarity doesn't matter) * - TAP format is simple: 2-byte length + data (including flag + checksum) * - TZX format supports multiple block types for custom loaders */ export class Tape { constructor(spectrum) { this.spectrum = spectrum; this.cpu = spectrum.cpu; this.ula = spectrum.ula; // Tape state this.playing = false; this.paused = false; this.position = 0; this.data = null; this.format = null; // Current block info this.currentBlock = null; this.blockIndex = 0; this.bitPosition = 0; this.currentBit = 0; // Current bit value (0 or 1) this.lastEarBit = 0; // Last EAR bit state (0 or 1) // Timing this.nextEdgeCycle = 0; this.lastUpdateCycle = 0; // Block state machine this.state = 'IDLE'; this.pulseCount = 0; // Number of pulses generated this.edgeCount = 0; // Number of edges generated this.bytePosition = 0; this.pauseCycles = 0; // Pulse sequence state this.pulseIndex = 0; this.pulseCycles = 0; // Parsed blocks this.blocks = []; // TAP/TZX block types this.BLOCK_STANDARD = 0x10; this.BLOCK_TURBO = 0x11; this.BLOCK_PURE_TONE = 0x12; this.BLOCK_PULSE_SEQUENCE = 0x13; this.BLOCK_PURE_DATA = 0x14; this.BLOCK_PAUSE = 0x20; this.BLOCK_GROUP_START = 0x21; this.BLOCK_GROUP_END = 0x22; this.BLOCK_TEXT = 0x30; this.BLOCK_MESSAGE = 0x31; this.BLOCK_ARCHIVE_INFO = 0x32; this.BLOCK_HARDWARE_TYPE = 0x33; // Standard timing constants (in T-states) this.PILOT_PULSE = 2168; this.SYNC1_PULSE = 667; this.SYNC2_PULSE = 735; this.ZERO_PULSE = 855; this.ONE_PULSE = 1710; this.PILOT_PULSES_HEADER = 8063; // Number of pilot pulses for headers this.PILOT_PULSES_DATA = 3223; // Number of pilot pulses for data // Standard pause after block (in milliseconds) this.STANDARD_PAUSE = 500; // Reduced from 1000ms for better compatibility // Cycles per millisecond (3.5MHz for 48K Spectrum) this.CYCLES_PER_MS = 3500; } /** * Load a tape file * @param {ArrayBuffer} buffer - The tape file data * @param {string} filename - The filename to determine format */ load(buffer, filename) { this.data = new Uint8Array(buffer); this.position = 0; this.blockIndex = 0; this.reset(); // Determine format from extension const ext = filename.toLowerCase().split('.').pop(); if (ext === 'tap') { this.format = 'TAP'; this.parseTAP(); } else if (ext === 'tzx') { this.format = 'TZX'; this.parseTZX(); } else { throw new Error(`Unsupported tape format: ${ext}`); } console.log(`Loaded ${this.format} file: ${filename}`); console.log(`Total blocks: ${this.blocks.length}`); this.blocks.forEach((block, i) => { const type = this.getBlockTypeName(block.type); console.log(`Block ${i}: ${type}, ` + `${block.data ? block.data.length + ' bytes' : 'no data'}, ` + `pause=${block.pause || 0}ms`); }); } /** * Parse TAP format */ parseTAP() { this.blocks = []; let pos = 0; while (pos < this.data.length) { // Check if we have at least 2 bytes for length if (pos + 2 > this.data.length) { console.warn('TAP file truncated at position', pos); break; } // Read block length (little-endian) const length = this.data[pos] | (this.data[pos + 1] << 8); pos += 2; if (pos + length > this.data.length) { console.warn(`TAP file truncated: expected ${length} bytes at position ${pos}`); break; } // Create standard speed data block const blockData = this.data.slice(pos, pos + length); const flagByte = blockData[0]; // Different pause times for headers vs data blocks const pauseTime = (flagByte < 128) ? 100 : 500; // Shorter pause after headers const block = { type: this.BLOCK_STANDARD, data: blockData, pilotPulse: this.PILOT_PULSE, sync1Pulse: this.SYNC1_PULSE, sync2Pulse: this.SYNC2_PULSE, zeroPulse: this.ZERO_PULSE, onePulse: this.ONE_PULSE, pilotPulses: (flagByte < 128) ? this.PILOT_PULSES_HEADER : this.PILOT_PULSES_DATA, pause: pauseTime, // Different pauses for header/data usedBits: 8 // All bits used in TAP format }; this.blocks.push(block); // Log block info const blockType = flagByte === 0x00 ? 'Header' : 'Data'; console.log(`TAP Block ${this.blocks.length - 1}: ${blockType} (flag=0x${flagByte.toString(16).padStart(2, '0')}), ` + `${length} bytes`); pos += length; } } /** * Parse TZX format */ parseTZX() { this.blocks = []; // Check TZX header const header = String.fromCharCode(...this.data.slice(0, 7)); if (header !== 'ZXTape!') { throw new Error('Invalid TZX file header'); } const eofMarker = this.data[7]; if (eofMarker !== 0x1A) { throw new Error('Invalid TZX EOF marker'); } const majorVersion = this.data[8]; const minorVersion = this.data[9]; console.log(`TZX version ${majorVersion}.${minorVersion}`); // Skip header let pos = 10; while (pos < this.data.length) { const blockId = this.data[pos]; pos++; try { switch (blockId) { case this.BLOCK_STANDARD: pos = this.parseTZXStandardBlock(pos); break; case this.BLOCK_TURBO: pos = this.parseTZXTurboBlock(pos); break; case this.BLOCK_PURE_TONE: pos = this.parseTZXPureToneBlock(pos); break; case this.BLOCK_PULSE_SEQUENCE: pos = this.parseTZXPulseSequenceBlock(pos); break; case this.BLOCK_PURE_DATA: pos = this.parseTZXPureDataBlock(pos); break; case this.BLOCK_PAUSE: pos = this.parseTZXPauseBlock(pos); break; case this.BLOCK_GROUP_START: case this.BLOCK_GROUP_END: case this.BLOCK_TEXT: case this.BLOCK_MESSAGE: case this.BLOCK_ARCHIVE_INFO: case this.BLOCK_HARDWARE_TYPE: pos = this.skipTZXInfoBlock(pos, blockId); break; default: console.warn(`Unknown TZX block type: 0x${blockId.toString(16)} at position ${pos - 1}`); // Try to skip unknown block by looking for size if (pos + 4 <= this.data.length) { const size = this.readDWord(pos); pos += 4 + size; } else { pos = this.data.length; } } } catch (e) { console.error(`Error parsing TZX block 0x${blockId.toString(16)} at position ${pos - 1}:`, e); break; } } } /** * Parse TZX standard speed data block (ID 10h) */ parseTZXStandardBlock(pos) { if (pos + 4 > this.data.length) { throw new Error('Insufficient data for standard block'); } const pause = this.readWord(pos); const length = this.readWord(pos + 2); if (pos + 4 + length > this.data.length) { throw new Error('Insufficient data for standard block data'); } const blockData = this.data.slice(pos + 4, pos + 4 + length); const flagByte = blockData[0]; const block = { type: this.BLOCK_STANDARD, data: blockData, pilotPulse: this.PILOT_PULSE, sync1Pulse: this.SYNC1_PULSE, sync2Pulse: this.SYNC2_PULSE, zeroPulse: this.ZERO_PULSE, onePulse: this.ONE_PULSE, pilotPulses: (flagByte < 128) ? this.PILOT_PULSES_HEADER : this.PILOT_PULSES_DATA, pause: pause, usedBits: 8 }; this.blocks.push(block); return pos + 4 + length; } /** * Parse TZX turbo speed data block (ID 11h) */ parseTZXTurboBlock(pos) { if (pos + 18 > this.data.length) { throw new Error('Insufficient data for turbo block'); } const block = { type: this.BLOCK_TURBO, pilotPulse: this.readWord(pos), sync1Pulse: this.readWord(pos + 2), sync2Pulse: this.readWord(pos + 4), zeroPulse: this.readWord(pos + 6), onePulse: this.readWord(pos + 8), pilotPulses: this.readWord(pos + 10), usedBits: this.data[pos + 12], pause: this.readWord(pos + 13), dataLength: this.readTriple(pos + 15) }; const dataStart = pos + 18; if (dataStart + block.dataLength > this.data.length) { throw new Error('Insufficient data for turbo block data'); } block.data = this.data.slice(dataStart, dataStart + block.dataLength); this.blocks.push(block); return dataStart + block.dataLength; } /** * Parse TZX pure tone block (ID 12h) */ parseTZXPureToneBlock(pos) { if (pos + 4 > this.data.length) { throw new Error('Insufficient data for pure tone block'); } const block = { type: this.BLOCK_PURE_TONE, pulseLength: this.readWord(pos), pulseCount: this.readWord(pos + 2) }; this.blocks.push(block); return pos + 4; } /** * Parse TZX pulse sequence block (ID 13h) */ parseTZXPulseSequenceBlock(pos) { if (pos + 1 > this.data.length) { throw new Error('Insufficient data for pulse sequence block'); } const count = this.data[pos]; pos++; if (pos + count * 2 > this.data.length) { throw new Error('Insufficient data for pulse sequence'); } const block = { type: this.BLOCK_PULSE_SEQUENCE, pulses: [] }; for (let i = 0; i < count; i++) { block.pulses.push(this.readWord(pos + i * 2)); } this.blocks.push(block); return pos + count * 2; } /** * Parse TZX pure data block (ID 14h) */ parseTZXPureDataBlock(pos) { if (pos + 10 > this.data.length) { throw new Error('Insufficient data for pure data block'); } const block = { type: this.BLOCK_PURE_DATA, zeroPulse: this.readWord(pos), onePulse: this.readWord(pos + 2), usedBits: this.data[pos + 4], pause: this.readWord(pos + 5), dataLength: this.readTriple(pos + 7) }; const dataStart = pos + 10; if (dataStart + block.dataLength > this.data.length) { throw new Error('Insufficient data for pure data block data'); } block.data = this.data.slice(dataStart, dataStart + block.dataLength); this.blocks.push(block); return dataStart + block.dataLength; } /** * Parse TZX pause/silence block (ID 20h) */ parseTZXPauseBlock(pos) { if (pos + 2 > this.data.length) { throw new Error('Insufficient data for pause block'); } const pause = this.readWord(pos); // Pause of 0 means stop the tape if (pause === 0) { console.log('TZX: Stop the tape block encountered'); } this.blocks.push({ type: this.BLOCK_PAUSE, pause: pause }); return pos + 2; } /** * Skip TZX info blocks */ skipTZXInfoBlock(pos, blockId) { switch (blockId) { case this.BLOCK_GROUP_START: // Group start: length byte + text if (pos + 1 > this.data.length) return this.data.length; return pos + 1 + this.data[pos]; case this.BLOCK_GROUP_END: // Group end: no data return pos; case this.BLOCK_TEXT: // Text description: length byte + text if (pos + 1 > this.data.length) return this.data.length; return pos + 1 + this.data[pos]; case this.BLOCK_MESSAGE: // Message block: time byte + length byte + text if (pos + 2 > this.data.length) return this.data.length; return pos + 2 + this.data[pos + 1]; case this.BLOCK_ARCHIVE_INFO: // Archive info: length word + data if (pos + 2 > this.data.length) return this.data.length; return pos + 2 + this.readWord(pos); case this.BLOCK_HARDWARE_TYPE: // Hardware type: count byte + 3 bytes per entry if (pos + 1 > this.data.length) return this.data.length; return pos + 1 + (this.data[pos] * 3); default: return pos; } } /** * Read a 16-bit word (little-endian) */ readWord(pos) { return this.data[pos] | (this.data[pos + 1] << 8); } /** * Read a 24-bit triple (little-endian) */ readTriple(pos) { return this.data[pos] | (this.data[pos + 1] << 8) | (this.data[pos + 2] << 16); } /** * Read a 32-bit dword (little-endian) */ readDWord(pos) { return this.data[pos] | (this.data[pos + 1] << 8) | (this.data[pos + 2] << 16) | (this.data[pos + 3] << 24); } /** * Get block type name */ getBlockTypeName(type) { const names = { 0x10: 'Standard Speed Data', 0x11: 'Turbo Speed Data', 0x12: 'Pure Tone', 0x13: 'Pulse Sequence', 0x14: 'Pure Data', 0x20: 'Pause/Stop', 0x21: 'Group Start', 0x22: 'Group End', 0x30: 'Text Description', 0x31: 'Message', 0x32: 'Archive Info', 0x33: 'Hardware Type' }; return names[type] || `Unknown (0x${type.toString(16)})`; } /** * Reset tape state */ reset() { this.state = 'IDLE'; this.currentBlock = null; this.lastEarBit = 0; this.nextEdgeCycle = 0; this.pulseCount = 0; this.edgeCount = 0; this.bitPosition = 0; this.bytePosition = 0; this.currentBit = 0; this.pauseCycles = 0; this.pulseIndex = 0; this.pulseCycles = 0; } /** * Start playing the tape */ play() { if (!this.blocks || this.blocks.length === 0) { console.log('No blocks to play'); return; } console.log('Starting tape playback'); this.playing = true; this.paused = false; // Initialize timing this.lastUpdateCycle = this.cpu.cycles; if (!this.currentBlock) { this.nextBlock(); } } /** * Pause tape playback */ pause() { this.paused = true; console.log('Tape paused'); } /** * Stop tape playback */ stop() { this.playing = false; this.paused = false; this.blockIndex = 0; this.reset(); console.log('Tape stopped'); } /** * Rewind tape to beginning */ rewind() { this.stop(); this.blockIndex = 0; console.log('Tape rewound'); } /** * Move to next block */ nextBlock() { if (this.blockIndex >= this.blocks.length) { console.log('End of tape reached'); this.stop(); return; } this.currentBlock = this.blocks[this.blockIndex]; const blockType = this.getBlockTypeName(this.currentBlock.type); console.log(`\nStarting block ${this.blockIndex}: ${blockType}`); if (this.currentBlock.data) { const flagByte = this.currentBlock.data[0]; console.log(` Flag byte: 0x${flagByte.toString(16).padStart(2, '0')} (${flagByte < 128 ? 'Header' : 'Data'})`); console.log(` Data length: ${this.currentBlock.data.length} bytes`); console.log(` Pause after: ${this.currentBlock.pause || 0}ms`); } this.blockIndex++; // Reset block state this.bitPosition = 0; this.bytePosition = 0; this.pulseCount = 0; this.edgeCount = 0; this.pulseIndex = 0; this.pulseCycles = 0; // Initialize block state based on type switch (this.currentBlock.type) { case this.BLOCK_STANDARD: case this.BLOCK_TURBO: this.state = 'PILOT'; // Initialize next edge timing this.nextEdgeCycle = this.cpu.cycles + this.currentBlock.pilotPulse; console.log(` Starting PILOT state with ${this.currentBlock.pilotPulses} pulses`); break; case this.BLOCK_PAUSE: this.state = 'PAUSE'; this.pauseCycles = this.currentBlock.pause * this.CYCLES_PER_MS; // If pause is 0, stop the tape if (this.currentBlock.pause === 0) { console.log('Stop the tape command encountered'); this.stop(); } break; case this.BLOCK_PURE_TONE: this.state = 'TONE'; this.nextEdgeCycle = this.cpu.cycles + this.currentBlock.pulseLength; break; case this.BLOCK_PULSE_SEQUENCE: this.state = 'PULSES'; if (this.currentBlock.pulses.length > 0) { this.nextEdgeCycle = this.cpu.cycles + this.currentBlock.pulses[0]; } break; case this.BLOCK_PURE_DATA: this.state = 'DATA'; // Pure data block starts directly with data, no pilot or sync this.nextEdgeCycle = this.cpu.cycles; break; default: console.warn(`Unsupported block type: ${this.currentBlock.type}`); this.nextBlock(); } } /** * Update tape playback * @param {number} cycles - Current CPU cycle count * @returns {number} - Current tape input bit (0 or 1) */ update(cycles) { if (!this.playing || this.paused || !this.currentBlock) { return this.lastEarBit; } // Handle pause state first (can occur after any block type) if (this.state === 'PAUSE') { this.updatePauseState(cycles); this.lastUpdateCycle = cycles; return this.lastEarBit; } // Update based on current block type switch (this.currentBlock.type) { case this.BLOCK_STANDARD: case this.BLOCK_TURBO: this.updateDataBlock(cycles); break; case this.BLOCK_PAUSE: this.updatePauseBlock(cycles); break; case this.BLOCK_PURE_TONE: this.updateToneBlock(cycles); break; case this.BLOCK_PULSE_SEQUENCE: this.updatePulseSequenceBlock(cycles); break; case this.BLOCK_PURE_DATA: this.updatePureDataBlock(cycles); break; } this.lastUpdateCycle = cycles; return this.lastEarBit; } /** * Update standard/turbo data block */ updateDataBlock(cycles) { const block = this.currentBlock; // Check if it's time for next edge if (cycles < this.nextEdgeCycle) { return; } // Toggle EAR bit this.lastEarBit = 1 - this.lastEarBit; switch (this.state) { case 'PILOT': // Generate pilot tone this.nextEdgeCycle += block.pilotPulse; this.edgeCount++; // Each pulse consists of 2 edges if (this.edgeCount >= block.pilotPulses * 2) { console.log(`Pilot complete after ${this.edgeCount} edges`); this.state = 'SYNC1'; this.nextEdgeCycle = cycles + block.sync1Pulse; } break; case 'SYNC1': // First sync pulse this.state = 'SYNC2'; this.nextEdgeCycle = cycles + block.sync2Pulse; break; case 'SYNC2': // Second sync pulse - prepare for data this.state = 'DATA'; this.bytePosition = 0; this.bitPosition = 0; this.pulseCount = 0; // Start with first bit if (block.data && block.data.length > 0) { this.currentBit = (block.data[0] >> 7) & 1; const pulseLength = this.currentBit ? block.onePulse : block.zeroPulse; this.nextEdgeCycle = cycles + pulseLength; console.log(`Starting DATA state: ${block.data.length} bytes, first bit=${this.currentBit}`); } else { // No data, move to next block console.log('No data in block, moving to next'); this.handleBlockEnd(); } break; case 'DATA': // Output data bits this.pulseCount++; // Each bit consists of 2 pulses (4 edges) if (this.pulseCount < 2) { // Same bit, next pulse const pulseLength = this.currentBit ? block.onePulse : block.zeroPulse; this.nextEdgeCycle = cycles + pulseLength; } else { // Move to next bit this.pulseCount = 0; this.bitPosition++; if (this.bitPosition >= 8) { // Move to next byte this.bitPosition = 0; this.bytePosition++; if (this.bytePosition >= block.data.length) { // All data sent this.handleBlockEnd(); return; } } // Check if this is the last byte and we have limited bits const isLastByte = (this.bytePosition === block.data.length - 1); const bitsInByte = isLastByte ? block.usedBits : 8; if (this.bitPosition < bitsInByte) { // Get next bit const byte = block.data[this.bytePosition]; this.currentBit = (byte >> (7 - this.bitPosition)) & 1; const pulseLength = this.currentBit ? block.onePulse : block.zeroPulse; this.nextEdgeCycle = cycles + pulseLength; } else { // No more bits in last byte this.handleBlockEnd(); } } break; } } /** * Update pause state (can occur after any block) * * @private * @param {number} cycles - Current CPU cycle count * @returns {void} */ updatePauseState(cycles) { if (this.pauseCycles > 0) { const elapsed = cycles - this.lastUpdateCycle; this.pauseCycles -= elapsed; // During pause, keep EAR bit low (0) this.lastEarBit = 0; if (this.pauseCycles <= 0) { console.log(`Pause complete after ${elapsed} cycles, moving to next block`); console.log(`Current state: ${this.state}, Block index: ${this.blockIndex}/${this.blocks.length}`); this.pauseCycles = 0; this.state = 'IDLE'; // Reset state before moving to next block this.nextBlock(); } } else { // No pause cycles, move to next block immediately console.log('No pause cycles remaining, moving to next block'); this.state = 'IDLE'; this.nextBlock(); } } /** * Update pause block * * @private * @param {number} cycles - Current CPU cycle count * @returns {void} */ updatePauseBlock(cycles) { // For explicit pause blocks, delegate to updatePauseState this.updatePauseState(cycles); } /** * Update pure tone block * * @private * @param {number} cycles - Current CPU cycle count * @returns {void} */ updateToneBlock(cycles) { if (cycles >= this.nextEdgeCycle) { this.lastEarBit = 1 - this.lastEarBit; this.nextEdgeCycle += this.currentBlock.pulseLength; this.pulseCount++; if (this.pulseCount >= this.currentBlock.pulseCount) { console.log(`Pure tone complete after ${this.pulseCount} pulses`); this.nextBlock(); } } } /** * Update pulse sequence block * * @private * @param {number} cycles - Current CPU cycle count * @returns {void} */ updatePulseSequenceBlock(cycles) { if (cycles >= this.nextEdgeCycle) { this.lastEarBit = 1 - this.lastEarBit; if (this.pulseIndex < this.currentBlock.pulses.length) { this.nextEdgeCycle += this.currentBlock.pulses[this.pulseIndex]; this.pulseIndex++; } else { console.log('Pulse sequence complete'); this.nextBlock(); } } } /** * Update pure data block */ updatePureDataBlock(cycles) { const block = this.currentBlock; if (cycles >= this.nextEdgeCycle) { this.lastEarBit = 1 - this.lastEarBit; this.pulseCount++; // Each bit consists of 2 pulses if (this.pulseCount < 2) { // Same bit, next pulse const pulseLength = this.currentBit ? block.onePulse : block.zeroPulse; this.nextEdgeCycle = cycles + pulseLength; } else { // Move to next bit this.pulseCount = 0; this.bitPosition++; if (this.bitPosition >= 8) { // Move to next byte this.bitPosition = 0; this.bytePosition++; if (this.bytePosition >= block.data.length) { // All data sent this.handleBlockEnd(); return; } } // Check if this is the last byte and we have limited bits const isLastByte = (this.bytePosition === block.data.length - 1); const bitsInByte = isLastByte ? block.usedBits : 8; if (this.bitPosition < bitsInByte) { // Get next bit const byte = block.data[this.bytePosition]; this.currentBit = (byte >> (7 - this.bitPosition)) & 1; const pulseLength = this.currentBit ? block.onePulse : block.zeroPulse; this.nextEdgeCycle = cycles + pulseLength; } else { // No more bits in last byte this.handleBlockEnd(); } } } } /** * Handle end of current block * * @private * @returns {void} */ handleBlockEnd() { const block = this.currentBlock; console.log(`Block ${this.blockIndex - 1} complete: ${this.bytePosition} bytes sent`); // Check if there's a pause after this block if (block.pause && block.pause > 0) { this.state = 'PAUSE'; this.pauseCycles = block.pause * this.CYCLES_PER_MS; console.log(`Entering PAUSE state for ${block.pause}ms (${this.pauseCycles} cycles)`); console.log(`Next block will be ${this.blockIndex < this.blocks.length ? 'block ' + this.blockIndex : 'end of tape'}`); } else { // Move to next block immediately console.log('No pause, moving to next block immediately'); this.nextBlock(); } } /** * Get current tape position as percentage * * @returns {number} Position as percentage (0-100) */ getPosition() { if (!this.blocks || this.blocks.length === 0) { return 0; } const currentBlock = Math.max(0, this.blockIndex - 1); return (currentBlock / this.blocks.length) * 100; } /** * Get human-readable tape status * * @returns {string} Status message */ getStatus() { if (!this.blocks || this.blocks.length === 0) { return 'No tape loaded'; } if (!this.playing) { return 'Stopped'; } if (this.paused) { return 'Paused'; } const currentBlock = Math.max(0, this.blockIndex - 1); return `Playing block ${currentBlock + 1}/${this.blocks.length} (${this.state})`; } /** * Get current EAR bit for tape input * This is what the Spectrum reads from port 0xFE bit 6 * * @returns {number} Current EAR bit (0 or 1) */ getEarBit() { return this.lastEarBit; } }