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.
330 lines (282 loc) • 10 kB
JavaScript
/**
* ZX Spectrum ULA (Uncommitted Logic Array) Emulation
* Handles I/O ports, keyboard, border color, and speaker
*/
export class SpectrumULA {
constructor() {
this.borderColor = 1; // Blue border by default
this.speakerBit = 0;
this.micBit = 0;
// Keyboard matrix (8x5)
this.keyboardMatrix = new Uint8Array(8).fill(0xFF);
// Port FE is the main ULA port
this.lastPortFE = 0;
// Callback for speaker changes
this.onSpeakerChange = null;
// NEW: Callback for port writes with exact timing
this.onPortWrite = null;
// Tape input bit (EAR)
this.tapeInputBit = 1;
// Scanline tracking for border effects
this.scanline = 0;
this.scanlineBorderColors = new Uint8Array(312).fill(1);
this.borderChanged = false;
// Timing constants
this.SCANLINES_PER_FRAME = 312;
this.TSTATES_PER_SCANLINE = 224;
this.cycleCounter = 0;
// Interrupt generation
this.interruptPending = false;
}
readPort(port) {
// Port 0xFE - Keyboard and tape input
if ((port & 0x01) === 0) {
let result = 0xBF; // Initial value with bit 6 set (no tape input)
// Check keyboard rows based on high byte of port address
const highByte = (port >> 8) & 0xFF;
for (let row = 0; row < 8; row++) {
// Check if this row is selected (bit is 0)
if ((highByte & (1 << row)) === 0) {
// AND the result with this row's keys
result &= this.keyboardMatrix[row];
}
}
// Set bit 6 based on tape input (EAR)
if (this.tapeInputBit) {
result |= 0x40; // Set bit 6
} else {
result &= ~0x40; // Clear bit 6
}
return result;
}
return 0xFF;
}
writePort(port, value) {
port &= 0xFF;
value &= 0xFF;
// Port 0xFE - Border color and speaker
if ((port & 0x01) === 0) {
const previousPortFE = this.lastPortFE;
this.lastPortFE = value;
const newBorderColor = value & 0x07; // Bits 0-2: border color
const newSpeakerBit = (value & 0x10) >> 4; // Bit 4: speaker
this.micBit = (value & 0x08) >> 3; // Bit 3: mic
// Update border color and track change
if (newBorderColor !== this.borderColor) {
this.borderColor = newBorderColor;
this.borderChanged = true;
this.scanlineBorderColors[this.scanline] = newBorderColor;
}
// Notify about port write with the value (for accurate beeper tracking)
if (this.onPortWrite) {
this.onPortWrite(value);
}
// Legacy speaker change notification (kept for compatibility)
if (newSpeakerBit !== this.speakerBit) {
this.speakerBit = newSpeakerBit;
if (this.onSpeakerChange) {
this.onSpeakerChange(this.speakerBit);
}
}
}
}
// Additional method to set the port write callback
setPortWriteCallback(callback) {
this.onPortWrite = callback;
}
// Set key state (row 0-7, col 0-4)
setKey(row, col, pressed) {
if (row >= 0 && row < 8 && col >= 0 && col < 5) {
const oldValue = this.keyboardMatrix[row];
if (pressed) {
this.keyboardMatrix[row] &= ~(1 << col);
} else {
this.keyboardMatrix[row] |= (1 << col);
}
}
}
// Clear all keys
clearKeys() {
this.keyboardMatrix.fill(0xFF);
}
// Get current border color
getBorderColor() {
return this.borderColor;
}
// Get speaker state
getSpeakerState() {
return this.speakerBit;
}
// Add cycles for scanline tracking
addCycles(cycles) {
this.cycleCounter += cycles;
// Check if we've completed a scanline
while (this.cycleCounter >= this.TSTATES_PER_SCANLINE) {
this.cycleCounter -= this.TSTATES_PER_SCANLINE;
// Record border color for this scanline
this.scanlineBorderColors[this.scanline] = this.borderColor;
// Move to next scanline
this.scanline++;
if (this.scanline >= this.SCANLINES_PER_FRAME) {
this.scanline = 0;
// Generate interrupt at start of frame
this.interruptPending = true;
}
}
}
/**
* Get per-scanline border colors for multicolor effects
*
* @returns {Uint8Array} Array of 312 border colors (one per scanline)
*/
getScanlineBorderColors() {
return this.scanlineBorderColors;
}
/**
* Check if border color changed during this frame
*
* @returns {boolean} True if border color changed
*/
isBorderColorChanged() {
return this.borderChanged;
}
/**
* Reset border changed flag for new frame
*
* @returns {void}
*/
resetBorderChanged() {
this.borderChanged = false;
}
/**
* Check if interrupt should be generated
* Interrupts occur at the start of the vertical retrace period
*
* @returns {boolean} True if interrupt should be generated
*/
shouldGenerateInterrupt() {
if (this.interruptPending) {
this.interruptPending = false;
return true;
}
return false;
}
/**
* Set border color directly (used for snapshot loading)
*
* @param {number} color - Border color index (0-7)
* @returns {void}
*/
setBorderColor(color) {
this.borderColor = color & 0x07;
this.borderChanged = true;
// Update scanline border color for current scanline
this.scanlineBorderColors[this.scanline] = this.borderColor;
}
/**
* Set tape input bit (EAR)
*
* @param {number} bit - Tape input state (0 or 1)
* @returns {void}
*/
setTapeInput(bit) {
this.tapeInputBit = bit ? 1 : 0;
}
}
// ZX Spectrum keyboard mapping
export const SPECTRUM_KEYS = {
// Row 0 (CAPS SHIFT, Z, X, C, V)
CAPS_SHIFT: { row: 0, col: 0 },
Z: { row: 0, col: 1 },
X: { row: 0, col: 2 },
C: { row: 0, col: 3 },
V: { row: 0, col: 4 },
// Row 1 (A, S, D, F, G)
A: { row: 1, col: 0 },
S: { row: 1, col: 1 },
D: { row: 1, col: 2 },
F: { row: 1, col: 3 },
G: { row: 1, col: 4 },
// Row 2 (Q, W, E, R, T)
Q: { row: 2, col: 0 },
W: { row: 2, col: 1 },
E: { row: 2, col: 2 },
R: { row: 2, col: 3 },
T: { row: 2, col: 4 },
// Row 3 (1, 2, 3, 4, 5)
'1': { row: 3, col: 0 },
'2': { row: 3, col: 1 },
'3': { row: 3, col: 2 },
'4': { row: 3, col: 3 },
'5': { row: 3, col: 4 },
// Row 4 (0, 9, 8, 7, 6)
'0': { row: 4, col: 0 },
'9': { row: 4, col: 1 },
'8': { row: 4, col: 2 },
'7': { row: 4, col: 3 },
'6': { row: 4, col: 4 },
// Row 5 (P, O, I, U, Y)
P: { row: 5, col: 0 },
O: { row: 5, col: 1 },
I: { row: 5, col: 2 },
U: { row: 5, col: 3 },
Y: { row: 5, col: 4 },
// Row 6 (ENTER, L, K, J, H)
ENTER: { row: 6, col: 0 },
L: { row: 6, col: 1 },
K: { row: 6, col: 2 },
J: { row: 6, col: 3 },
H: { row: 6, col: 4 },
// Row 7 (SPACE, SYMBOL SHIFT, M, N, B)
SPACE: { row: 7, col: 0 },
SYMBOL_SHIFT: { row: 7, col: 1 },
M: { row: 7, col: 2 },
N: { row: 7, col: 3 },
B: { row: 7, col: 4 }
};
// PC to Spectrum key mappings with modifiers
export const PC_KEY_MAP = {
// Direct mappings
'Enter': 'ENTER',
' ': 'SPACE',
'Shift': 'CAPS_SHIFT',
'Control': 'SYMBOL_SHIFT',
'Alt': 'SYMBOL_SHIFT',
'Meta': 'SYMBOL_SHIFT', // Mac Command key
'AltGraph': 'SYMBOL_SHIFT', // Right Alt on some keyboards
// Arrow keys using Spectrum cursor keys (Caps Shift + 5,6,7,8)
'ArrowLeft': { keys: ['CAPS_SHIFT', '5'] }, // Caps Shift + 5
'ArrowDown': { keys: ['CAPS_SHIFT', '6'] }, // Caps Shift + 6
'ArrowUp': { keys: ['CAPS_SHIFT', '7'] }, // Caps Shift + 7
'ArrowRight': { keys: ['CAPS_SHIFT', '8'] }, // Caps Shift + 8
// Common symbols with Symbol Shift
'!': { keys: ['SYMBOL_SHIFT', '1'] },
'@': { keys: ['SYMBOL_SHIFT', '2'] },
'#': { keys: ['SYMBOL_SHIFT', '3'] },
'$': { keys: ['SYMBOL_SHIFT', '4'] },
'%': { keys: ['SYMBOL_SHIFT', '5'] },
'&': { keys: ['SYMBOL_SHIFT', '6'] },
"'": { keys: ['SYMBOL_SHIFT', '7'] },
'(': { keys: ['SYMBOL_SHIFT', '8'] },
')': { keys: ['SYMBOL_SHIFT', '9'] },
'_': { keys: ['SYMBOL_SHIFT', '0'] },
// Other symbols
'-': { keys: ['SYMBOL_SHIFT', 'J'] },
'+': { keys: ['SYMBOL_SHIFT', 'K'] },
'=': { keys: ['SYMBOL_SHIFT', 'L'] },
':': { keys: ['SYMBOL_SHIFT', 'Z'] },
';': { keys: ['SYMBOL_SHIFT', 'O'] },
'"': { keys: ['SYMBOL_SHIFT', 'P'] },
',': { keys: ['SYMBOL_SHIFT', 'N'] },
'.': { keys: ['SYMBOL_SHIFT', 'M'] },
'<': { keys: ['SYMBOL_SHIFT', 'R'] },
'>': { keys: ['SYMBOL_SHIFT', 'T'] },
'?': { keys: ['SYMBOL_SHIFT', 'C'] },
'/': { keys: ['SYMBOL_SHIFT', 'V'] },
'*': { keys: ['SYMBOL_SHIFT', 'B'] },
// Special keys
'Backspace': { keys: ['CAPS_SHIFT', '0'] }, // DELETE key on Spectrum
'Delete': { keys: ['CAPS_SHIFT', '0'] },
'Escape': { keys: ['CAPS_SHIFT', 'SPACE'] }, // BREAK
'Tab': { keys: ['CAPS_SHIFT', 'SYMBOL_SHIFT'] } // EXTENDED MODE
};