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.

1,011 lines (892 loc) 30.9 kB
/* global document, cancelAnimationFrame, requestAnimationFrame */ import { Z80 } from '../core/cpu.js'; import { SpectrumMemory } from './memory.js'; import { SpectrumULA, SPECTRUM_KEYS, PC_KEY_MAP } from './ula.js'; import { SpectrumDisplay } from './display.js'; import { SpectrumSound } from './sound.js'; import { SpectrumAudioWorklet } from './audio-worklet.js'; import { Z80SnapshotLoader } from './snapshot.js'; import { Tape } from './tape.js'; import { TouchKeyboard } from './touch-keyboard.js'; /** * ZXSpectrum - Main emulator class for the ZX Spectrum 48K * * @class ZXSpectrum * @example * const spectrum = new ZXSpectrum('#canvas', { * scale: 2, * sound: true, * autoStart: true * }); */ export class ZXSpectrum { /** * Create a new ZX Spectrum emulator instance * * @constructor * @param {string|HTMLCanvasElement} canvasOrSelector - Canvas element or CSS selector * @param {Object} [options={}] - Configuration options * @param {string|Uint8Array} [options.rom='https://cdn.jsdelivr.net/npm/zx-generation@latest/rom/48k.rom'] - ROM data or URL * @param {boolean} [options.autoStart=true] - Start emulation automatically after ROM loads * @param {boolean} [options.sound=true] - Enable sound emulation * @param {boolean} [options.useAudioWorklet=true] - Use AudioWorklet for better sound * @param {number|string} [options.scale='auto'] - Display scale factor * @param {boolean} [options.handleKeyboard=true] - Handle keyboard input automatically * @param {boolean|string} [options.touchKeyboard='auto'] - Touch keyboard support * @param {number} [options.fps=50] - Frames per second (PAL standard) * @param {Function} [options.onReady] - Callback when emulator is ready * @param {Function} [options.onError] - Error callback */ constructor(canvasOrSelector, options = {}) { // Initialize options with defaults this.options = { rom: 'https://cdn.jsdelivr.net/npm/zx-generation@latest/rom/48k.rom', autoStart: true, sound: true, useAudioWorklet: true, scale: 'auto', handleKeyboard: true, touchKeyboard: 'auto', // 'auto', true, false, or custom element/selector fps: 50, onReady: null, onError: null, ...options }; // Get or create canvas this.canvas = this._resolveCanvas(canvasOrSelector); this.ctx = this.canvas.getContext('2d'); // Initialize hardware components this.memory = new SpectrumMemory(); this.ula = new SpectrumULA(); this.display = new SpectrumDisplay(); // Initialize sound if enabled this.useAudioWorklet = this.options.sound && this.options.useAudioWorklet; if (this.options.sound) { this.sound = this.useAudioWorklet ? new SpectrumAudioWorklet() : new SpectrumSound(); } else { this.sound = null; } this.cpu = new Z80(this.memory, this.ula); this.tape = new Tape(this); // Setup sound callbacks if (this.sound) { this.ula.setPortWriteCallback((portValue) => { if (this.sound && this.sound.enabled && this.useAudioWorklet) { const tStateOffset = this.cpu.cycles - this.frameStartCycles; this.sound.setBeeperState(portValue, tStateOffset); } }); this.ula.onSpeakerChange = (speakerBit) => { if (this.sound && this.sound.enabled) { if (!this.useAudioWorklet && this.sound.beep) { this.sound.beep(speakerBit); } } }; } // Emulation timing this.FRAMES_PER_SECOND = this.options.fps; this.TSTATES_PER_FRAME = 69888; this.INTERRUPT_TSTATES = 32; // State this.running = false; this.frameCount = 0; this.lastFrameTime = 0; this.accumulatedTime = 0; this.frameStartCycles = 0; // Stats this.fps = 0; this.lastFpsUpdate = 0; this.framesSinceLastFps = 0; // Animation this.animationId = null; this.renderAnimationId = null; // Features this.turboMode = false; // Custom key mappings this.customKeyMap = {}; // Touch keyboard this.touchKeyboard = null; // Setup canvas this._setupCanvas(); // Setup keyboard handling if enabled if (this.options.handleKeyboard) { this._setupKeyboard(); } // Setup touch keyboard if needed if (this.options.touchKeyboard !== false) { this._setupTouchKeyboard(); } // Setup visibility handling this._setupVisibilityHandling(); // Initialize with ROM if provided if (this.options.rom) { this._initialize(); } } /** * Resolve canvas element from selector or element * * @private * @param {string|HTMLCanvasElement} canvasOrSelector - Canvas element or CSS selector * @returns {HTMLCanvasElement} Canvas element * @throws {Error} If canvas element not found or invalid */ _resolveCanvas(canvasOrSelector) { if (typeof canvasOrSelector === 'string') { // It's a selector const element = document.querySelector(canvasOrSelector); if (!element) { throw new Error(`Canvas element not found: ${canvasOrSelector}`); } if (element.tagName !== 'CANVAS') { // Create a canvas inside the element const canvas = document.createElement('canvas'); element.appendChild(canvas); return canvas; } return element; } else if (canvasOrSelector instanceof HTMLCanvasElement) { return canvasOrSelector; } else if (canvasOrSelector && canvasOrSelector.tagName === 'CANVAS') { return canvasOrSelector; } else { throw new Error('Invalid canvas parameter. Expected selector string or canvas element.'); } } /** * Setup canvas dimensions and rendering properties * * @private * @returns {void} */ _setupCanvas() { const displaySize = this.display.getDisplaySize(); // Set internal canvas size this.canvas.width = displaySize.width; this.canvas.height = displaySize.height; // Handle scaling if (this.options.scale === 'auto') { // Default 2x scale this.canvas.style.width = (displaySize.width * 2) + 'px'; this.canvas.style.height = (displaySize.height * 2) + 'px'; } else if (typeof this.options.scale === 'number') { this.canvas.style.width = (displaySize.width * this.options.scale) + 'px'; this.canvas.style.height = (displaySize.height * this.options.scale) + 'px'; } // Pixelated rendering this.canvas.style.imageRendering = 'pixelated'; this.canvas.style.imageRendering = '-moz-crisp-edges'; this.canvas.style.imageRendering = 'crisp-edges'; } /** * Setup keyboard event handlers * * @private * @returns {void} */ _setupKeyboard() { // Store bound functions for removal this._keyDownHandler = (e) => this._handleKeyDown(e); this._keyUpHandler = (e) => this._handleKeyUp(e); // Add event listeners document.addEventListener('keydown', this._keyDownHandler); document.addEventListener('keyup', this._keyUpHandler); } /** * Setup touch keyboard for mobile devices * * @private * @returns {void} */ _setupTouchKeyboard() { const shouldShow = this.options.touchKeyboard === 'auto' ? this._isTouchDevice() : this.options.touchKeyboard; if (shouldShow) { // Determine container let container; if (typeof this.options.touchKeyboard === 'string' && this.options.touchKeyboard !== 'auto') { container = this.options.touchKeyboard; } else { // Create container after canvas container = document.createElement('div'); container.className = 'zx-touch-container'; this.canvas.parentNode.insertBefore(container, this.canvas.nextSibling); } this.touchKeyboard = new TouchKeyboard(this, container); } } /** * Check if device supports touch input * * @private * @returns {boolean} True if touch device */ _isTouchDevice() { return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; } /** * Handle keyboard key down events * * @private * @param {KeyboardEvent} e - Keyboard event * @returns {void} */ _handleKeyDown(e) { if (!this.running) return; const handled = this._processKey(e.key, true); if (handled) { e.preventDefault(); } } /** * Handle keyboard key up events * * @private * @param {KeyboardEvent} e - Keyboard event * @returns {void} */ _handleKeyUp(e) { if (!this.running) return; const handled = this._processKey(e.key, false); if (handled) { e.preventDefault(); } } /** * Process key press/release for Spectrum keyboard mapping * * @private * @param {string} key - Key string * @param {boolean} isDown - True if key pressed, false if released * @returns {boolean} True if key was handled */ _processKey(key, isDown) { // Check custom mappings first const customMapping = this.customKeyMap[key]; if (customMapping) { if (typeof customMapping === 'string') { return this._processKey(customMapping, isDown); } else if (customMapping.keys) { customMapping.keys.forEach(k => this._processKey(k, isDown)); return true; } } // Check PC key mappings const pcMapping = PC_KEY_MAP[key]; if (pcMapping) { if (typeof pcMapping === 'string') { // Direct mapping to a Spectrum key const keyMapping = SPECTRUM_KEYS[pcMapping]; if (keyMapping) { this.ula.setKey(keyMapping.row, keyMapping.col, isDown); return true; } } else if (pcMapping.keys) { // Multiple keys need to be pressed pcMapping.keys.forEach(spectrumKey => { const keyMapping = SPECTRUM_KEYS[spectrumKey]; if (keyMapping) { this.ula.setKey(keyMapping.row, keyMapping.col, isDown); } }); return true; } } // Check direct Spectrum key mapping const keyMapping = SPECTRUM_KEYS[key] || SPECTRUM_KEYS[key.toUpperCase()]; if (keyMapping) { this.ula.setKey(keyMapping.row, keyMapping.col, isDown); return true; } return false; } /** * Setup page visibility handling for audio context * * @private * @returns {void} */ _setupVisibilityHandling() { if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { if (this.running && this.sound && this.sound.audioContext) { if (document.hidden) { this.sound.audioContext.suspend().catch(err => console.warn('Failed to suspend audio context:', err) ); } else { this.sound.audioContext.resume().catch(err => console.warn('Failed to resume audio context:', err) ); } } }); } } /** * Initialize emulator with ROM and options * * @private * @async * @returns {Promise<void>} */ async _initialize() { try { if (this.options.rom instanceof Uint8Array) { this.loadROM(this.options.rom); } else if (typeof this.options.rom === 'string') { await this.loadROMFromURL(this.options.rom); } if (this.options.onReady) { this.options.onReady(this); } if (this.options.autoStart) { await this.start(); } } catch (error) { console.error('Failed to initialize emulator:', error); if (this.options.onError) { this.options.onError(error); } } } /** * Load ROM data into the emulator * * @param {Uint8Array} romData - ROM data (must be 16384 bytes) * @throws {Error} If romData is not a Uint8Array */ loadROM(romData) { if (!(romData instanceof Uint8Array)) { throw new Error('ROM data must be a Uint8Array'); } this.memory.loadROM(romData); this.reset(); } /** * Load ROM from a URL * * @async * @param {string} url - URL to ROM file * @throws {Error} If ROM loading fails */ async loadROMFromURL(url) { try { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to load ROM: ${response.statusText}`); } const arrayBuffer = await response.arrayBuffer(); const romData = new Uint8Array(arrayBuffer); this.loadROM(romData); } catch (error) { throw new Error(`Error loading ROM from URL: ${error.message}`); } } /** * Reset the emulator to initial state */ reset() { this.cpu.reset(); this.memory.clearRAM(); this.ula.clearKeys(); this.ula.borderColor = 1; this.frameCount = 0; const attrs = this.memory.getAttributeMemory(); attrs.fill(0x38); if (this.sound && this.sound.reset) { this.sound.reset(); } const screenMem = this.memory.getScreenMemory(); const attrMem = this.memory.getAttributeMemory(); for (let i = 0; i < attrMem.length; i++) { attrMem[i] = 0x38; } } /** * Run a single frame of emulation (69888 T-states) * * @private * @returns {void} */ runFrame() { let tStates = 0; this.frameStartCycles = this.cpu.cycles; if (this.useAudioWorklet && this.sound && this.sound.startFrame) { this.sound.startFrame(); } while (tStates < this.TSTATES_PER_FRAME) { const beforeCycles = this.cpu.cycles; this.cpu.execute(); const cyclesExecuted = this.cpu.cycles - beforeCycles; this.ula.addCycles(cyclesExecuted); const tapeInputBit = this.tape.update(this.cpu.cycles); this.ula.setTapeInput(tapeInputBit); if (this.ula.shouldGenerateInterrupt()) { this.cpu.interrupt(); } tStates += cyclesExecuted; } if (this.useAudioWorklet && this.sound && this.sound.endFrame) { this.sound.endFrame(tStates); } this.frameCount++; const now = performance.now(); this.framesSinceLastFps++; if (now - this.lastFpsUpdate >= 1000) { this.fps = this.framesSinceLastFps; this.framesSinceLastFps = 0; this.lastFpsUpdate = now; } } /** * Start the emulation * * @async * @returns {Promise<void>} */ async start() { if (this.running) return; // Initialize sound if enabled if (this.sound) { if (this.useAudioWorklet) { try { let success = await this.sound.init(); if (!success && this.sound.audioContext && this.sound.audioContext.state === 'suspended') { try { await this.sound.audioContext.resume(); success = true; this.sound.enabled = true; } catch (err) { console.warn('Failed to resume audio context:', err); } } if (!success) { console.warn('AudioWorklet failed, falling back to basic sound'); this.useAudioWorklet = false; this.sound = new SpectrumSound(); try { await this.sound.start(); } catch (err) { console.warn('Basic sound also failed, continuing without audio:', err); this.sound.enabled = false; } } } catch (error) { console.warn('Audio initialization failed, continuing without audio:', error); this.sound.enabled = false; } } else { try { await this.sound.start(); } catch (error) { console.warn('Sound start failed, continuing without audio:', error); this.sound.enabled = false; } } } this.running = true; this.lastFrameTime = performance.now(); this.lastFpsUpdate = this.lastFrameTime; this.framesSinceLastFps = 0; // Start emulation loop this.emulationLoop(); // Start render loop this._startRenderLoop(); } /** * Stop the emulation */ stop() { this.running = false; if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; } if (this.renderAnimationId) { cancelAnimationFrame(this.renderAnimationId); this.renderAnimationId = null; } if (this.sound && this.sound.stop) { this.sound.stop(); } } /** * Main emulation loop - executes CPU cycles and manages timing * * @private * @returns {void} */ emulationLoop() { if (!this.running) { this.animationId = null; return; } const now = performance.now(); const deltaTime = this.lastFrameTime ? now - this.lastFrameTime : 0; this.lastFrameTime = now; this.accumulatedTime += deltaTime; const frameTime = 1000 / this.FRAMES_PER_SECOND; if (this.accumulatedTime >= frameTime) { this.runFrame(); this.accumulatedTime -= frameTime; if (this.accumulatedTime > frameTime * 2) { this.accumulatedTime = 0; } } this.animationId = requestAnimationFrame(() => this.emulationLoop()); } /** * Start the rendering loop * * @private * @returns {void} */ _startRenderLoop() { const render = () => { if (!this.running) { this.renderAnimationId = null; return; } this.renderDisplay(); this.draw(); this.renderAnimationId = requestAnimationFrame(render); }; render(); } /** * Render the display from memory to pixel buffer * * @private * @returns {Uint8Array} Display buffer */ renderDisplay() { const screenMemory = this.memory.getScreenMemory(); const attributeMemory = this.memory.getAttributeMemory(); const borderColor = this.ula.getBorderColor(); const scanlineBorderColors = this.ula.isBorderColorChanged() ? this.ula.getScanlineBorderColors() : null; this.ula.resetBorderChanged(); return this.display.render(screenMemory, attributeMemory, borderColor, scanlineBorderColors); } /** * Draw the display buffer to canvas * * @private * @param {HTMLCanvasElement} [canvas=null] - Target canvas (uses default if null) * @returns {void} */ draw(canvas = null) { const targetCanvas = canvas || this.canvas; const ctx = canvas ? canvas.getContext('2d') : this.ctx; const imageData = this.display.getImageData(); ctx.putImageData(imageData, 0, 0); } /** * Simulate a key press * * @param {string|KeyboardEvent} keyOrEvent - Key string or keyboard event */ keyDown(keyOrEvent) { const key = typeof keyOrEvent === 'string' ? keyOrEvent : keyOrEvent.key; this._processKey(key, true); } /** * Simulate a key release * * @param {string|KeyboardEvent} keyOrEvent - Key string or keyboard event */ keyUp(keyOrEvent) { const key = typeof keyOrEvent === 'string' ? keyOrEvent : keyOrEvent.key; this._processKey(key, false); } /** * Simulate a key press and release * * @param {string|KeyboardEvent} keyOrEvent - Key string or keyboard event * @param {number} [duration=50] - Duration of key press in milliseconds * @returns {Promise<void>} */ async keyPress(keyOrEvent, duration = 50) { this.keyDown(keyOrEvent); await new Promise(resolve => setTimeout(resolve, duration)); this.keyUp(keyOrEvent); } /** * Type text automatically with realistic timing * * @param {string} text - Text to type * @param {Object} [options={}] - Typing options * @param {number} [options.keyDelay=100] - Delay between key presses in milliseconds * @param {number} [options.keyDuration=50] - Duration of each key press in milliseconds * @returns {Promise<void>} */ async typeText(text, options = {}) { const { keyDelay = 100, keyDuration = 50 } = options; for (const char of text) { await this.keyPress(char, keyDuration); await new Promise(resolve => setTimeout(resolve, keyDelay)); } } /** * Load a snapshot from saved state data * * @param {Object} data - Snapshot data * @param {Uint8Array} [data.ram] - RAM contents * @param {Object} [data.cpu] - CPU state * @param {Object} [data.ula] - ULA state */ loadSnapshot(data) { if (data.ram && data.ram.length === 49152) { this.memory.ram.set(data.ram); } if (data.cpu) { this.cpu.setState(data.cpu); } if (data.ula) { this.ula.borderColor = data.ula.borderColor || 7; } } /** * Load a Z80 snapshot file * * @param {ArrayBuffer|Uint8Array} data - Z80 snapshot data */ loadZ80Snapshot(data) { const snapshotLoader = new Z80SnapshotLoader(this.memory, this.cpu, this.ula); snapshotLoader.load(data instanceof ArrayBuffer ? new Uint8Array(data) : data); } /** * Save current state as snapshot * * @returns {Object} Snapshot data with ram, cpu, and ula state */ saveSnapshot() { return { ram: new Uint8Array(this.memory.ram), cpu: this.cpu.getState(), ula: { borderColor: this.ula.borderColor } }; } /** * Get emulator statistics * * @returns {Object} Statistics object * @returns {number} .fps - Current frames per second * @returns {number} .frameCount - Total frames rendered * @returns {Object} .cpuState - Current CPU state * @returns {boolean} .running - Whether emulator is running * @returns {Object} .audioStats - Audio statistics (if available) */ getStats() { return { fps: this.fps, frameCount: this.frameCount, cpuState: this.cpu.getState(), running: this.running, audioStats: this.sound?.getStats ? this.sound.getStats() : null }; } /** * Write a byte to memory (POKE) * * @param {number} address - Memory address (0-65535) * @param {number} value - Value to write (0-255) * @returns {void} * * @example * spectrum.poke(23624, 0); // Clear keyboard buffer */ poke(address, value) { this.memory.write(address, value); } /** * Read a byte from memory (PEEK) * * @param {number} address - Memory address (0-65535) * @returns {number} Byte value (0-255) * * @example * const borderColor = spectrum.peek(23624); // Read current border color */ peek(address) { return this.memory.read(address); } /** * Enable or disable turbo mode * * @param {boolean} enabled - True to enable turbo mode * @returns {void} */ setTurboMode(enabled) { this.turboMode = enabled; } /** * Load a TAP file for tape emulation * * @param {ArrayBuffer|Uint8Array} buffer - TAP file data * @param {string} [filename] - Optional filename for display */ loadTape(buffer, filename) { this.tape.load(buffer, filename); } /** * Load a TAP file from URL * * @async * @param {string} url - URL to TAP file * @throws {Error} If tape loading fails */ async loadTapeFromURL(url) { try { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to load tape: ${response.statusText}`); } const arrayBuffer = await response.arrayBuffer(); const filename = url.split('/').pop(); this.loadTape(arrayBuffer, filename); } catch (error) { throw new Error(`Error loading tape from URL: ${error.message}`); } } /** * Start tape playback */ playTape() { this.tape.play(); } /** * Pause tape playback */ pauseTape() { this.tape.pause(); } /** * Stop tape playback */ stopTape() { this.tape.stop(); } /** * Rewind tape to beginning */ rewindTape() { this.tape.rewind(); } /** * Get current tape status * * @returns {Object} Tape status * @returns {string} .status - Current status message * @returns {number} .position - Current position in tape * @returns {boolean} .playing - Whether tape is playing * @returns {boolean} .paused - Whether tape is paused */ getTapeStatus() { return { status: this.tape.getStatus(), position: this.tape.getPosition(), playing: this.tape.playing, paused: this.tape.paused }; } /** * Set audio volume * * @param {number} volume - Volume level (0.0 to 1.0) * @returns {void} */ setVolume(volume) { if (this.sound && this.sound.setVolume) { this.sound.setVolume(volume); } } /** * Mute or unmute audio * * @param {boolean} muted - True to mute, false to unmute * @returns {void} */ setMuted(muted) { if (this.sound && this.sound.setMuted) { this.sound.setMuted(muted); } } /** * Enable or disable audio debug mode * * @param {boolean} enabled - True to enable debug mode * @returns {void} */ setAudioDebugMode(enabled) { if (this.sound && this.sound.setDebugMode) { this.sound.setDebugMode(enabled); } } /** * Set custom key mapping * * @param {string} pcKey - PC keyboard key * @param {string|Object} spectrumKey - Spectrum key or key combination * @returns {void} * * @example * spectrum.setKeyMapping('Tab', 'CAPS_SHIFT'); * spectrum.setKeyMapping('F1', { keys: ['CAPS_SHIFT', '1'] }); */ setKeyMapping(pcKey, spectrumKey) { this.customKeyMap[pcKey] = spectrumKey; } /** * Set multiple custom key mappings * * @param {Object} mappings - Object with PC key to Spectrum key mappings * @returns {void} * * @example * spectrum.setKeyMappings({ * 'Tab': 'CAPS_SHIFT', * 'F1': { keys: ['CAPS_SHIFT', '1'] } * }); */ setKeyMappings(mappings) { Object.assign(this.customKeyMap, mappings); } /** * Clear all custom key mappings * * @returns {void} */ clearCustomKeyMappings() { this.customKeyMap = {}; } /** * Clean up and destroy the emulator instance * * Stops emulation, removes event listeners, and cleans up resources */ destroy() { this.stop(); // Remove keyboard handlers if (this.options.handleKeyboard && this._keyDownHandler) { document.removeEventListener('keydown', this._keyDownHandler); document.removeEventListener('keyup', this._keyUpHandler); } // Destroy touch keyboard if (this.touchKeyboard) { this.touchKeyboard.destroy(); this.touchKeyboard = null; } // Clear canvas this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); } } export { SPECTRUM_KEYS, PC_KEY_MAP } from './ula.js';