import { CPU, AVRTimer, timer0Config, timer1Config, timer2Config, AVRUSART, usart0Config, AVRIOPort, portAConfig, portBConfig, portCConfig, portDConfig, portEConfig, portFConfig, portGConfig, portHConfig, portJConfig, portKConfig, portLConfig, avrInstruction, AVRADC, adcConfig, AVRSPI, spiConfig, AVRTWI, twiConfig, ATtinyTimer1, attinyTimer1Config, } from 'avr8js'; import type { AVRTimerConfig } from 'avr8js/dist/esm/peripherals/timer'; import type { ADCConfig, ADCMuxConfiguration } from 'avr8js/dist/esm/peripherals/adc'; import { ADCMuxInputType, ADCReference } from 'avr8js/dist/esm/peripherals/adc'; import { PinManager } from './PinManager'; import { hexToUint8Array } from '../utils/hexParser'; import { I2CBusManager, nullI2CMaster } from './I2CBusManager'; import type { I2CDevice } from './I2CBusManager'; /** * AVRSimulator - Emulates Arduino Uno (ATmega328p) using avr8js * * Features: * - CPU emulation at 16MHz * - Timer0/Timer1/Timer2 support (enables millis(), delay(), PWM) * - USART support (Serial) * - GPIO ports (PORTB, PORTC, PORTD) * - ADC support (analogRead()) * - PWM monitoring via OCR register polling * - Pin state tracking via PinManager */ // OCR register addresses → Arduino pin mapping for PWM (ATmega328P / Uno / Nano) const PWM_PINS_UNO = [ { ocrAddr: 0x47, pin: 6, label: 'OCR0A' }, // Timer0A → D6 { ocrAddr: 0x48, pin: 5, label: 'OCR0B' }, // Timer0B → D5 { ocrAddr: 0x88, pin: 9, label: 'OCR1AL' }, // Timer1A low byte → D9 { ocrAddr: 0x8a, pin: 10, label: 'OCR1BL' }, // Timer1B low byte → D10 { ocrAddr: 0xb3, pin: 11, label: 'OCR2A' }, // Timer2A → D11 { ocrAddr: 0xb4, pin: 3, label: 'OCR2B' }, // Timer2B → D3 ]; // OCR register addresses → Arduino Mega pin mapping for PWM (ATmega2560) // Timers 0/1/2 same addresses; Timers 3/4/5 at higher addresses. const PWM_PINS_MEGA = [ { ocrAddr: 0x47, pin: 13, label: 'OCR0A' }, // Timer0A → D13 { ocrAddr: 0x48, pin: 4, label: 'OCR0B' }, // Timer0B → D4 { ocrAddr: 0x88, pin: 11, label: 'OCR1AL' }, // Timer1A → D11 { ocrAddr: 0x8a, pin: 12, label: 'OCR1BL' }, // Timer1B → D12 { ocrAddr: 0xb3, pin: 10, label: 'OCR2A' }, // Timer2A → D10 { ocrAddr: 0xb4, pin: 9, label: 'OCR2B' }, // Timer2B → D9 // Timer3 (0x80–0x8D, but OCR3A/B/C at 0x98/0x9A/0x9C) { ocrAddr: 0x98, pin: 5, label: 'OCR3AL' }, // Timer3A → D5 { ocrAddr: 0x9a, pin: 2, label: 'OCR3BL' }, // Timer3B → D2 { ocrAddr: 0x9c, pin: 3, label: 'OCR3CL' }, // Timer3C → D3 // Timer4 (OCR4A/B/C at 0xA8/0xAA/0xAC) { ocrAddr: 0xa8, pin: 6, label: 'OCR4AL' }, // Timer4A → D6 { ocrAddr: 0xaa, pin: 7, label: 'OCR4BL' }, // Timer4B → D7 { ocrAddr: 0xac, pin: 8, label: 'OCR4CL' }, // Timer4C → D8 // Timer5 (OCR5A/B/C at 0x128/0x12A/0x12C — extended I/O) { ocrAddr: 0x128, pin: 46, label: 'OCR5AL' }, // Timer5A → D46 { ocrAddr: 0x12a, pin: 45, label: 'OCR5BL' }, // Timer5B → D45 { ocrAddr: 0x12c, pin: 44, label: 'OCR5CL' }, // Timer5C → D44 ]; /** * ATmega2560 port-bit → Arduino Mega pin mapping. * Index = bit position (0–7). -1 = not exposed on the Arduino Mega header. */ const MEGA_PORT_BIT_MAP: Record = { // PA0-PA7 → D22-D29 PORTA: [22, 23, 24, 25, 26, 27, 28, 29], // PB0=D53(SS), PB1=D52(SCK), PB2=D51(MOSI), PB3=D50(MISO), PB4-PB7=D10-D13 PORTB: [53, 52, 51, 50, 10, 11, 12, 13], // PC0-PC7 → D37, D36, D35, D34, D33, D32, D31, D30 (reversed) PORTC: [37, 36, 35, 34, 33, 32, 31, 30], // PD0=D21(SCL), PD1=D20(SDA), PD2=D19(RX1), PD3=D18(TX1), PD7=D38 PORTD: [21, 20, 19, 18, -1, -1, -1, 38], // PE0=D0(RX0), PE1=D1(TX0), PE3=D5, PE4=D2, PE5=D3 PORTE: [0, 1, -1, 5, 2, 3, -1, -1], // PF0-PF7 → A0-A7 (pin numbers 54-61) PORTF: [54, 55, 56, 57, 58, 59, 60, 61], // PG0=D41, PG1=D40, PG2=D39, PG5=D4 PORTG: [41, 40, 39, -1, -1, 4, -1, -1], // PH0=D17(RX2), PH1=D16(TX2), PH3=D6, PH4=D7, PH5=D8, PH6=D9 PORTH: [17, 16, -1, 6, 7, 8, 9, -1], // PJ0=D15(RX3), PJ1=D14(TX3) PORTJ: [15, 14, -1, -1, -1, -1, -1, -1], // PK0-PK7 → A8-A15 (pin numbers 62-69) PORTK: [62, 63, 64, 65, 66, 67, 68, 69], // PL0=D49, PL1=D48, PL2=D47, PL3=D46, PL4=D45, PL5=D44, PL6=D43, PL7=D42 PORTL: [49, 48, 47, 46, 45, 44, 43, 42], }; /** * Reverse of MEGA_PORT_BIT_MAP: Arduino Mega pin → { portName, bit }. * Pre-built for fast setPinState() lookups. */ const MEGA_PIN_TO_PORT = (() => { const map: Record = {}; for (const [portName, pins] of Object.entries(MEGA_PORT_BIT_MAP)) { pins.forEach((pin, bit) => { if (pin >= 0) map[pin] = { portName, bit }; }); } return map; })(); // OCR register addresses → ATtiny85 pin mapping for PWM // Timer0: OC0A→PB0, OC0B→PB1 (ATtiny85 Timer0 OCR regs at 0x56, 0x5C) // Timer1: OC1A→PB1, OC1B→PB4 (ATtinyTimer1 OCR regs from attinyTimer1Config) const PWM_PINS_TINY85 = [ { ocrAddr: 0x56, pin: 0, label: 'OCR0A' }, // Timer0A → PB0 { ocrAddr: 0x5c, pin: 1, label: 'OCR0B' }, // Timer0B → PB1 { ocrAddr: 0x4e, pin: 1, label: 'OCR1A' }, // Timer1A → PB1 (attinyTimer1Config.OCR1A) { ocrAddr: 0x4b, pin: 4, label: 'OCR1B' }, // Timer1B → PB4 (attinyTimer1Config.OCR1B) ]; /** * ATtiny85 PORTB config — registers are at different addresses than ATmega328P. * ATtiny85: PINB=0x36, DDRB=0x37, PORTB=0x38 (vs ATmega: 0x23/0x24/0x25) */ const attiny85PortBConfig = { PIN: 0x36, DDR: 0x37, PORT: 0x38, externalInterrupts: [] as never[], }; /** * ATtiny85 Timer0 config — Arduino `millis()` / `delay()` rely on the * TIMER0_OVF interrupt to tick the millisecond counter. avr8js's generic * `AVRTimer` is fully data-driven, so we just supply ATtiny85's register * addresses (different from the ATmega328P defaults in `timer0Config`) * and the right interrupt vector offsets. * * Refs: for register addresses; ATtiny25/45/85 datasheet * (Atmel-2586) for vector indices. * _VECTOR(5) → TIMER0_OVF → word 0x0A * _VECTOR(10) → TIMER0_COMPA → word 0x14 * _VECTOR(11) → TIMER0_COMPB → word 0x16 */ /** * ATtiny85 ADC config — required because the chip's ADC registers live at * completely different memory addresses than the ATmega328P defaults that * avr8js's `adcConfig` ships with. Without this, `analogRead()` writes * ADSC at ATtiny85's ADCSRA (0x26) and polls forever because avr8js is * listening at 0x7A instead. * * Refs: ; ATtiny25/45/85 datasheet (Atmel-2586) sec. 17. * ADMUX = 0x07 (I/O) -> 0x27 (mem) * ADCSRA = 0x06 -> 0x26 * ADCSRB = 0x03 -> 0x23 * ADCL = 0x04 -> 0x24 * ADCH = 0x05 -> 0x25 * DIDR0 = 0x14 -> 0x34 * ADC_vect = _VECTOR(8) -> word 0x10 * * MUX field is 4 bits (bits 3:0). Single-ended channels 0..3 = PB5/PB2/PB4/PB3. * Reference bits REFS1:REFS0 at ADMUX[7:6] select VCC/AREF/Internal1V1 by default; * full REFS2 extension lives at ADMUX[4] but the avr8js helper checks bit 3, * so the rare 2.56 V internal reference is currently unsupported — every * default-ref sketch (`analogReference(DEFAULT)`) works fine. */ const attiny85AdcChannels: ADCMuxConfiguration = { 0: { type: ADCMuxInputType.SingleEnded, channel: 0 }, // PB5 1: { type: ADCMuxInputType.SingleEnded, channel: 1 }, // PB2 2: { type: ADCMuxInputType.SingleEnded, channel: 2 }, // PB4 3: { type: ADCMuxInputType.SingleEnded, channel: 3 }, // PB3 12: { type: ADCMuxInputType.Constant, voltage: 1.1 }, // VBG 13: { type: ADCMuxInputType.Constant, voltage: 0 }, // GND 15: { type: ADCMuxInputType.Temperature }, }; const attiny85AdcConfig: ADCConfig = { ADMUX: 0x27, ADCSRA: 0x26, ADCSRB: 0x23, ADCL: 0x24, ADCH: 0x25, DIDR0: 0x34, // ATtiny85 vectors are 1-word RJMP (vs ATmega328P's 2-word JMP) so the // avr8js "address" field is the raw vector index, not vector*2. adcInterrupt: 0x08, // _VECTOR(8) ADC_vect numChannels: 4, muxInputMask: 0xf, muxChannels: attiny85AdcChannels, adcReferences: [ ADCReference.AVCC, // 00 = VCC ADCReference.AREF, // 01 = external AREF (PB0) ADCReference.Internal1V1, // 10 = internal 1.1 V ADCReference.Reserved, // 11 = reserved ], }; const attiny85Timer0Config: AVRTimerConfig = { bits: 8, captureInterrupt: 0, // ATtiny85 vectors are 1-word RJMP (vs ATmega328P's 2-word JMP) so the // avr8js "address" field is the raw vector index, not vector*2. compAInterrupt: 0x0a, // _VECTOR(10) TIMER0_COMPA_vect compBInterrupt: 0x0b, // _VECTOR(11) TIMER0_COMPB_vect compCInterrupt: 0, ovfInterrupt: 0x05, // _VECTOR(5) TIMER0_OVF_vect TIFR: 0x58, OCRA: 0x56, OCRB: 0x5c, OCRC: 0, ICR: 0, TCNT: 0x52, TCCRA: 0x4f, TCCRB: 0x53, TCCRC: 0, TIMSK: 0x59, TOV: 0b00000010, OCFA: 0b00010000, OCFB: 0b00001000, OCFC: 0, TOIE: 0b00000010, OCIEA: 0b00010000, OCIEB: 0b00001000, OCIEC: 0, compPortA: 0x38, compPinA: 0, compPortB: 0x38, compPinB: 1, compPortC: 0, compPinC: 0, externalClockPort: 0x36, externalClockPin: 2, dividers: { 0: 0, 1: 1, 2: 8, 3: 64, 4: 256, 5: 1024, 6: 0, 7: 0 }, }; /** Ordered list of Mega ports with their avr8js configs */ const MEGA_PORT_CONFIGS = [ { name: 'PORTA', config: portAConfig }, { name: 'PORTB', config: portBConfig }, { name: 'PORTC', config: portCConfig }, { name: 'PORTD', config: portDConfig }, { name: 'PORTE', config: portEConfig }, { name: 'PORTF', config: portFConfig }, { name: 'PORTG', config: portGConfig }, { name: 'PORTH', config: portHConfig }, { name: 'PORTJ', config: portJConfig }, { name: 'PORTK', config: portKConfig }, { name: 'PORTL', config: portLConfig }, ]; export class AVRSimulator { private cpu: CPU | null = null; /** Peripherals kept alive by reference so GC doesn't collect their CPU hooks */ private peripherals: unknown[] = []; /** * Pending RX bytes waiting to be fed to the USART. avr8js's writeByte * rejects (returns false, drops the byte) whenever rxBusyValue is set * — and rxBusyValue stays set for `cyclesPerChar` after each call. * A naive `for c of text: usart.writeByte(c)` loop therefore only * delivers the first character. We buffer the rest here and drain * one byte at a time on each frame's tick. */ private serialRxQueue: number[] = []; private portB: AVRIOPort | null = null; private portC: AVRIOPort | null = null; private portD: AVRIOPort | null = null; /** Extra ports used by the Mega (A, E–L); keyed by port name */ private megaPorts: Map = new Map(); private megaPortValues: Map = new Map(); private adc: AVRADC | null = null; public spi: AVRSPI | null = null; public usart: AVRUSART | null = null; public twi: AVRTWI | null = null; public i2cBus!: I2CBusManager; private program: Uint16Array | null = null; private running = false; private animationFrame: number | null = null; public pinManager: PinManager; private speed = 1.0; /** 'uno' for ATmega328P boards (Uno, Nano); 'mega' for ATmega2560; 'tiny85' for ATtiny85 */ private boardVariant: 'uno' | 'mega' | 'tiny85'; /** Cycle-accurate pin change queue — used by timing-sensitive peripherals (e.g. DHT22). */ private scheduledPinChanges: Array<{ cycle: number; pin: number; state: boolean }> = []; /** Serial output buffer — subscribers receive each byte or line */ public onSerialData: ((char: string) => void) | null = null; /** Fires whenever the sketch changes Serial baud rate (Serial.begin) */ public onBaudRateChange: ((baudRate: number) => void) | null = null; /** * Fires for every digital pin transition with a millisecond timestamp * derived from the CPU cycle counter (cycles / CPU_HZ * 1000). * Used by the oscilloscope / logic analyzer. */ public onPinChangeWithTime: ((pin: number, state: boolean, timeMs: number) => void) | null = null; private lastPortBValue = 0; private lastPortCValue = 0; private lastPortDValue = 0; private lastOcrValues: number[] = []; /** * Last known TXEN bit value, used to detect 0→1 transitions and seed the * TX pin baseline at idle HIGH the moment the firmware enables the USART. * Without this seed the oscilloscope shows a floating/LOW baseline until * the first byte transmits, which doesn't match real hardware. */ private lastTxEnable = false; constructor(pinManager: PinManager, boardVariant: 'uno' | 'mega' | 'tiny85' = 'uno') { this.pinManager = pinManager; this.boardVariant = boardVariant; // Create the bus up-front with a placeholder master so that // Interconnect can install cross-board bridges and parts can // register devices BEFORE the firmware loads. The real AVRTWI // takes over via `i2cBus.attachMaster(twi)` inside loadHex. this.i2cBus = new I2CBusManager(nullI2CMaster()); } private get pwmPins() { if (this.boardVariant === 'mega') return PWM_PINS_MEGA; if (this.boardVariant === 'tiny85') return PWM_PINS_TINY85; return PWM_PINS_UNO; } /** * Load compiled hex file into simulator */ loadHex(hexContent: string): void { console.log('Loading HEX file...'); const bytes = hexToUint8Array(hexContent); // ATmega328P: 32 KB = 16 384 words. ATmega2560: 256 KB = 131 072 words. // ATtiny85: 8 KB = 4 096 words, 512 bytes SRAM. const progWords = this.boardVariant === 'mega' ? 131072 : this.boardVariant === 'tiny85' ? 4096 : 16384; // ATmega2560 data space: 0x0000–0x21FF = 8704 bytes total. // avr8js: data.length = sramBytes + registerSpace (0x100 = 256). // So sramBytes must be >= 8704 − 256 = 8448 to fit RAMEND=0x21FF on the stack. // ATmega328P RAMEND = 0x08FF; default 8192 is already a safe over-alloc. // ATtiny85 RAMEND = 0x025F; 512 bytes SRAM. const sramBytes = this.boardVariant === 'mega' ? 8448 : this.boardVariant === 'tiny85' ? 512 : 8192; this.program = new Uint16Array(progWords); for (let i = 0; i < bytes.length; i += 2) { this.program[i >> 1] = (bytes[i] || 0) | ((bytes[i + 1] || 0) << 8); } console.log(`Loaded ${bytes.length} bytes into program memory`); this.cpu = new CPU(this.program, sramBytes); if (this.boardVariant === 'tiny85') { // ATtiny85: PORTB only (PB0-PB5). Timer0 powers millis()/delay() in // ATTinyCore via TIMER0_OVF. Timer1 is the high-speed 8-bit PWM // timer (PLL clock). No hardware USART on this chip. // // Known limitation (task #116): the Timer0 OVF interrupt does fire at // the correct cadence (1.024 ms simulated, verified via debug // instrumentation), but real ATTinyCore-compiled `delay()` does not // observably advance — the LED stays stuck either HIGH or LOW // depending on which phase the firmware was in when the first OVF // hit. Likely a subtle interaction between the avr8js clearInterrupt // semantics (only clears the pending queue entry, leaves TIFR bit // set) and ATTinyCore's ISR relying on hardware auto-clear of TOV0. // Workaround attempts (manual TIFR clear after ISR entry) did not // change the visible behavior. Needs a deeper avr8js dive. this.portB = new AVRIOPort(this.cpu, attiny85PortBConfig as typeof portBConfig); this.adc = new AVRADC(this.cpu, attiny85AdcConfig); this.peripherals = [ new AVRTimer(this.cpu, attiny85Timer0Config), new ATtinyTimer1(this.cpu, attinyTimer1Config), ]; // usart stays null — ATtiny85 has no hardware USART } else { // ATmega2560 has more vectors before the timers/USART (8 external INTs, etc.), // so the interrupt WORD addresses differ from ATmega328P. // // avr8js config values are WORD addresses = _VECTOR(N) * 2 // (each JMP vector = 4 bytes = 2 words; cpu.pc * 2 == byte address). // // ATmega2560 word addresses (_VECTOR(N) → N * 2): // TIMER2_COMPA=_V(13)→0x1A TIMER2_COMPB=_V(14)→0x1C TIMER2_OVF=_V(15)→0x1E // TIMER1_CAPT=_V(16)→0x20 TIMER1_COMPA=_V(17)→0x22 TIMER1_COMPB=_V(18)→0x24 // TIMER1_COMPC=_V(19)→0x26 TIMER1_OVF=_V(20)→0x28 // TIMER0_COMPA=_V(21)→0x2A TIMER0_COMPB=_V(22)→0x2C TIMER0_OVF=_V(23)→0x2E // SPI_STC=_V(24)→0x30 USART0_RX=_V(25)→0x32 // USART0_UDRE=_V(26)→0x34 USART0_TX=_V(27)→0x36 // TWI=_V(39)→0x4E const isMega = this.boardVariant === 'mega'; const activeTimer0Config = isMega ? { ...timer0Config, compAInterrupt: 0x2a, compBInterrupt: 0x2c, ovfInterrupt: 0x2e } : timer0Config; const activeTimer1Config = isMega ? { ...timer1Config, captureInterrupt: 0x20, compAInterrupt: 0x22, compBInterrupt: 0x24, ovfInterrupt: 0x28, } : timer1Config; const activeTimer2Config = isMega ? { ...timer2Config, compAInterrupt: 0x1a, compBInterrupt: 0x1c, ovfInterrupt: 0x1e } : timer2Config; const activeUsart0Config = isMega ? { ...usart0Config, rxCompleteInterrupt: 0x32, dataRegisterEmptyInterrupt: 0x34, txCompleteInterrupt: 0x36, } : usart0Config; const activeSpiConfig = isMega ? { ...spiConfig, spiInterrupt: 0x30 } : spiConfig; const activeTwiConfig = isMega ? { ...twiConfig, twiInterrupt: 0x4e } : twiConfig; this.spi = new AVRSPI(this.cpu, activeSpiConfig, 16000000); this.spi.onByte = (value) => { this.spi!.completeTransfer(value); }; this.usart = new AVRUSART(this.cpu, activeUsart0Config, 16000000); this.usart.onByteTransmit = (value: number) => { if (this.onSerialData) this.onSerialData(String.fromCharCode(value)); // Synthesize the UART frame on PD1 so the oscilloscope sees a real // waveform during Serial.print. See emitUartTxFrame() for details. this.emitUartTxFrame(value); }; this.usart.onRxComplete = () => this.drainSerialRxQueue(); this.usart.onConfigurationChange = () => { if (this.onBaudRateChange && this.usart) this.onBaudRateChange(this.usart.baudRate); // Seed idle HIGH on the TX pin the first time TXEN flips on. this.handleUartConfigChange(); }; this.twi = new AVRTWI(this.cpu, activeTwiConfig, 16000000); // Attach the real AVRTWI to the bus created in the constructor; // any devices already registered + bridges already installed are // preserved across firmware (re)loads. this.i2cBus.attachMaster(this.twi); this.peripherals = [ new AVRTimer(this.cpu, activeTimer0Config), new AVRTimer(this.cpu, activeTimer1Config), new AVRTimer(this.cpu, activeTimer2Config), this.usart, this.spi, this.twi, ]; this.adc = new AVRADC(this.cpu, adcConfig); // ── GPIO ports ────────────────────────────────────────────────────── this.portB = new AVRIOPort(this.cpu, portBConfig); this.portC = new AVRIOPort(this.cpu, portCConfig); this.portD = new AVRIOPort(this.cpu, portDConfig); if (this.boardVariant === 'mega') { this.megaPorts.clear(); this.megaPortValues.clear(); for (const { name, config } of MEGA_PORT_CONFIGS) { this.megaPorts.set(name, new AVRIOPort(this.cpu, config)); this.megaPortValues.set(name, 0); } } } this.lastPortBValue = 0; this.lastPortCValue = 0; this.lastPortDValue = 0; this.lastOcrValues = new Array(this.pwmPins.length).fill(0); this.setupPinHooks(); const boardName = this.boardVariant === 'mega' ? 'ATmega2560' : this.boardVariant === 'tiny85' ? 'ATtiny85' : 'ATmega328P'; console.log(`AVR CPU initialized (${boardName}, ${this.peripherals.length} peripherals)`); } /** * Expose ADC instance so components (potentiometer, etc.) can inject voltages */ getADC(): AVRADC | null { return this.adc; } /** Returns the CPU clock frequency in Hz (16 MHz for AVR). */ getClockHz(): number { return 16_000_000; } /** * Returns the current CPU cycle count. * Used by timing-sensitive peripherals to schedule future pin changes. */ getCurrentCycles(): number { return this.cpu?.cycles ?? 0; } /** * Schedule a pin state change at a specific future CPU cycle count. * The change fires between AVR instructions, enabling cycle-accurate protocol simulation. * Used by DHT22 and other timing-sensitive single-wire peripherals. */ schedulePinChange(pin: number, state: boolean, atCycle: number): void { // Callers are expected to push entries in ascending cycle order. // Insert at the correct position to maintain sort (linear scan from end, O(1) for ordered pushes). let i = this.scheduledPinChanges.length; while (i > 0 && this.scheduledPinChanges[i - 1].cycle > atCycle) i--; this.scheduledPinChanges.splice(i, 0, { cycle: atCycle, pin, state }); } /** * Synthesize a real bit-level UART frame on the TX pin so an oscilloscope * sees a waveform during Serial.print, matching real ATmega328P / ATmega2560 * behavior. avr8js's USART only intercepts the byte at the UDR0 register * level — it never toggles PD1 (Uno/Nano) / PE1 (Mega), so without this * shim the TX pin is flat in the scope while real hardware would show the * UART frame at the configured baud rate. * * Frame layout (8N1, the Arduino default): * [start LOW] [data LSB ... data MSB] [parity?] [stop1] [stop2?] * * We honour avr8js's USART configuration getters (bitsPerChar, parityEnabled, * parityOdd, stopBits, baudRate) so unusual configurations stay accurate. * * Each transition is emitted via onPinChangeWithTime so the oscilloscope * stamps it with simulator time (cpu.cycles / 16_000 ms), giving bit-level * timing that holds at any sweep speed. */ private emitUartTxFrame(byte: number): void { const usart = this.usart; if (!usart || !this.cpu || !this.onPinChangeWithTime) return; if (!usart.txEnable) return; const baud = usart.baudRate; if (!baud || baud <= 0) return; // ATmega328P (Uno/Nano) UART0: TX = PD1 → Arduino pin 1 // ATmega2560 (Mega) UART0: TX = PE1 → Arduino pin 1 (Mega TX0) // ATtiny85 has no hardware USART so this method is never called. const txPin = 1; const freqHz = 16_000_000; const cyclesPerBit = freqHz / baud; const startCycle = this.cpu.cycles; // Build the frame bit-by-bit. UART idles HIGH; start = LOW; data LSB first; // optional parity; stop bit(s) HIGH. Idle->start gives the first transition. const dataBits = usart.bitsPerChar; // typically 8 const bits: boolean[] = [false]; // start bit let onesCount = 0; for (let i = 0; i < dataBits; i++) { const b = (byte >> i) & 1; bits.push(b !== 0); onesCount += b; } if (usart.parityEnabled) { // Even parity = bit that makes total ones even; odd = total ones odd. const parity = usart.parityOdd ? (onesCount % 2 === 0) : (onesCount % 2 !== 0); bits.push(parity); } for (let i = 0; i < usart.stopBits; i++) bits.push(true); // Emit only the bits that change state to keep buffer churn minimal. // The "previous" state at startCycle is idle HIGH. let prevState = true; for (let i = 0; i < bits.length; i++) { if (bits[i] !== prevState) { const timeMs = (startCycle + i * cyclesPerBit) / 16_000; this.onPinChangeWithTime(txPin, bits[i], timeMs); prevState = bits[i]; } } // After the stop bit(s) the line is already HIGH (idle) so no trailing // transition is needed — the next byte will start from HIGH automatically. } /** * Seed the TX pin at idle HIGH when the firmware sets TXEN for the first * time (typically inside Serial.begin). Without this seed the scope's * "initial state before the first byte" defaults to LOW, hiding the start * bit transition of the very first byte sent. */ private handleUartConfigChange(): void { if (!this.usart || !this.cpu) return; const tx = this.usart.txEnable; if (tx && !this.lastTxEnable && this.onPinChangeWithTime) { const timeMs = this.cpu.cycles / 16_000; this.onPinChangeWithTime(1, true, timeMs); } this.lastTxEnable = tx; } /** Flush all scheduled pin changes whose target cycle has been reached. */ private flushScheduledPinChanges(): void { if (this.scheduledPinChanges.length === 0 || !this.cpu) return; const now = this.cpu.cycles; while (this.scheduledPinChanges.length > 0 && this.scheduledPinChanges[0].cycle <= now) { const { pin, state } = this.scheduledPinChanges.shift()!; this.setPinState(pin, state); } } /** * Fire onPinChangeWithTime for every bit that differs between newVal and oldVal. * @param pinMap Optional explicit per-bit Arduino pin numbers (Mega). * @param offset Legacy pin offset (Uno/Nano): PORTB→8, PORTC→14, PORTD→0. */ private firePinChangeWithTime( newVal: number, oldVal: number, pinMap: number[] | null, offset = 0, ): void { if (!this.onPinChangeWithTime || !this.cpu) return; const timeMs = this.cpu.cycles / 16_000; const changed = newVal ^ oldVal; for (let bit = 0; bit < 8; bit++) { if (changed & (1 << bit)) { const pin = pinMap ? pinMap[bit] : offset + bit; if (pin < 0) continue; const state = (newVal & (1 << bit)) !== 0; this.onPinChangeWithTime(pin, state, timeMs); } } } /** * Monitor pin changes and update component states */ private setupPinHooks(): void { if (!this.cpu) return; console.log('Setting up pin hooks...'); // DDR register addresses (used to distinguish OUTPUT pins from // INPUT_PULLUP — see PinManager.updatePort ddrMask param). // ATmega328P/Uno/Nano: DDRB=0x24, DDRC=0x27, DDRD=0x2A // ATtiny85: DDRB=0x37 // ATmega2560: per-port table below const cpu = this.cpu; const readDdr = (addr: number) => cpu.data[addr] ?? 0; if (this.boardVariant === 'tiny85') { // ATtiny85: PORTB only, PB0-PB5 → pins 0-5 // Must pass an explicit pinMap so updatePort uses offset 0 instead of the // legacy PORTB offset (8) which would map PB1 → pin 9, etc. const TINY85_PIN_MAP = [0, 1, 2, 3, 4, 5, -1, -1]; this.portB!.addListener((value) => { if (value !== this.lastPortBValue) { this.pinManager.updatePort('PORTB', value, this.lastPortBValue, TINY85_PIN_MAP, readDdr(0x37)); this.firePinChangeWithTime(value, this.lastPortBValue, null, 0); this.lastPortBValue = value; } }); } else if (this.boardVariant === 'mega') { // Mega: use explicit per-bit pin maps for all 11 ports const MEGA_DDR_ADDRS: Record = { PORTA: 0x21, PORTB: 0x24, PORTC: 0x27, PORTD: 0x2A, PORTE: 0x2D, PORTF: 0x30, PORTG: 0x33, PORTH: 0x101, PORTJ: 0x104, PORTK: 0x107, PORTL: 0x10A, }; for (const [portName, port] of this.megaPorts) { const pinMap = MEGA_PORT_BIT_MAP[portName]; const ddrAddr = MEGA_DDR_ADDRS[portName]; this.megaPortValues.set(portName, 0); port.addListener((value) => { const old = this.megaPortValues.get(portName) ?? 0; if (value !== old) { this.pinManager.updatePort(portName, value, old, pinMap, ddrAddr ? readDdr(ddrAddr) : undefined); this.firePinChangeWithTime(value, old, pinMap); this.megaPortValues.set(portName, value); } }); } } else { // Uno / Nano: simple 3-port setup this.portB!.addListener((value) => { if (value !== this.lastPortBValue) { this.pinManager.updatePort('PORTB', value, this.lastPortBValue, undefined, readDdr(0x24)); this.firePinChangeWithTime(value, this.lastPortBValue, null, 8); this.lastPortBValue = value; } }); this.portC!.addListener((value) => { if (value !== this.lastPortCValue) { this.pinManager.updatePort('PORTC', value, this.lastPortCValue, undefined, readDdr(0x27)); this.firePinChangeWithTime(value, this.lastPortCValue, null, 14); this.lastPortCValue = value; } }); this.portD!.addListener((value) => { if (value !== this.lastPortDValue) { this.pinManager.updatePort('PORTD', value, this.lastPortDValue, undefined, readDdr(0x2A)); this.firePinChangeWithTime(value, this.lastPortDValue, null, 0); this.lastPortDValue = value; } }); } console.log('Pin hooks configured successfully'); } /** * Poll OCR registers and notify PinManager of PWM duty cycle changes */ private pollPwmRegisters(): void { if (!this.cpu) return; const pins = this.pwmPins; for (let i = 0; i < pins.length; i++) { const { ocrAddr, pin } = pins[i]; const ocrValue = this.cpu.data[ocrAddr]; if (ocrValue !== this.lastOcrValues[i]) { this.lastOcrValues[i] = ocrValue; this.pinManager.updatePwm(pin, ocrValue / 255); } } } /** * Start simulation loop */ start(): void { if (this.running || !this.cpu) { console.warn('Simulator already running or not initialized'); return; } this.running = true; console.log('Starting AVR simulation...'); // Browser-only debug hook. Guarded so node-side vitest runs don't // ReferenceError on `window` and spam stderr. if (typeof window !== 'undefined') { const dbg = (window as unknown as { __spiceDebug?: () => void }).__spiceDebug; if (typeof dbg === 'function') dbg(); else console.warn('[spice] __spiceDebug not attached — startSimulation never called'); } // ATmega328p @ 16MHz const CPU_HZ = 16_000_000; const CYCLES_PER_MS = CPU_HZ / 1000; // Cap: never execute more than 50ms worth of cycles in one frame. // This prevents a runaway burst when the tab was backgrounded and // then becomes visible again (browser may deliver a huge delta). const MAX_DELTA_MS = 50; let lastTimestamp = 0; let frameCount = 0; const execute = (timestamp: number) => { if (!this.running || !this.cpu) return; // Clamp delta so we never overshoot after a paused/backgrounded tab. // MAX_DELTA_MS already handles large initial deltas (e.g. first frame), // so no separate first-frame guard is needed. const rawDelta = timestamp - lastTimestamp; const deltaMs = Math.min(rawDelta, MAX_DELTA_MS); lastTimestamp = timestamp; const cyclesPerFrame = Math.floor(CYCLES_PER_MS * deltaMs * this.speed); try { for (let i = 0; i < cyclesPerFrame; i++) { avrInstruction(this.cpu); // Execute the AVR instruction this.cpu.tick(); // Update peripheral timers and cycles if (this.scheduledPinChanges.length > 0) this.flushScheduledPinChanges(); } // Poll PWM registers every frame this.pollPwmRegisters(); // Try to drain any pending RX byte every frame. The primary // drain path is onRxComplete (re-fires after each successful // delivery), but that callback only ever fires AFTER a byte was // accepted — if the very first delivery attempt fails (sketch // hasn't called Serial.begin yet, so rxEnable is false) nothing // would ever re-kick the queue and bytes from a sibling board // sit there forever. A per-frame retry is cheap (no-op when the // queue is empty or rxBusyValue is set) and makes the link // self-heal across both startup races and Serial.end()/begin() // toggles in the sketch. if (this.serialRxQueue.length > 0) this.drainSerialRxQueue(); frameCount++; if (frameCount % 60 === 0) { console.log(`[CPU] Frame ${frameCount}, PC: ${this.cpu.pc}, Cycles: ${this.cpu.cycles}`); } } catch (error) { console.error('Simulation error:', error); this.stop(); return; } this.animationFrame = requestAnimationFrame(execute); }; this.animationFrame = requestAnimationFrame(execute); } /** * Stop simulation */ stop(): void { if (!this.running) return; this.running = false; if (this.animationFrame !== null) { cancelAnimationFrame(this.animationFrame); this.animationFrame = null; } this.scheduledPinChanges = []; // Drop any bytes the previous run had queued for the sketch's RX // but never delivered (RX disabled, busy, or the sketch hadn't // reached Serial.begin yet). Without this the next run starts with // a stale tail that drains into the fresh USART before the sketch // is ready, and from the user's point of view the link is "dead". this.serialRxQueue = []; console.log('AVR simulation stopped'); } /** * Reset simulator (re-run program from scratch without recompiling) */ reset(): void { this.stop(); if (this.program) { // Re-use the stored hex content path: just reload const sramBytes = this.boardVariant === 'mega' ? 8448 : this.boardVariant === 'tiny85' ? 512 : 8192; console.log('Resetting AVR CPU...'); this.cpu = new CPU(this.program, sramBytes); if (this.boardVariant === 'tiny85') { this.portB = new AVRIOPort(this.cpu, attiny85PortBConfig as typeof portBConfig); this.adc = new AVRADC(this.cpu, attiny85AdcConfig); this.peripherals = [ new AVRTimer(this.cpu, attiny85Timer0Config), new ATtinyTimer1(this.cpu, attinyTimer1Config), ]; this.usart = null; } else { this.spi = new AVRSPI(this.cpu, spiConfig, 16000000); this.spi.onByte = (value) => { this.spi!.completeTransfer(value); }; this.usart = new AVRUSART(this.cpu, usart0Config, 16000000); this.usart.onByteTransmit = (value: number) => { if (this.onSerialData) this.onSerialData(String.fromCharCode(value)); this.emitUartTxFrame(value); }; this.usart.onRxComplete = () => this.drainSerialRxQueue(); this.usart.onConfigurationChange = () => { if (this.onBaudRateChange && this.usart) this.onBaudRateChange(this.usart.baudRate); this.handleUartConfigChange(); }; this.twi = new AVRTWI(this.cpu, twiConfig, 16000000); this.i2cBus.attachMaster(this.twi); this.peripherals = [ new AVRTimer(this.cpu, timer0Config), new AVRTimer(this.cpu, timer1Config), new AVRTimer(this.cpu, timer2Config), this.usart, this.spi, this.twi, ]; this.adc = new AVRADC(this.cpu, adcConfig); this.portB = new AVRIOPort(this.cpu, portBConfig); this.portC = new AVRIOPort(this.cpu, portCConfig); this.portD = new AVRIOPort(this.cpu, portDConfig); if (this.boardVariant === 'mega') { this.megaPorts.clear(); this.megaPortValues.clear(); for (const { name, config } of MEGA_PORT_CONFIGS) { this.megaPorts.set(name, new AVRIOPort(this.cpu, config)); this.megaPortValues.set(name, 0); } } } this.lastPortBValue = 0; this.lastPortCValue = 0; this.lastPortDValue = 0; this.lastOcrValues = new Array(this.pwmPins.length).fill(0); this.setupPinHooks(); console.log('AVR CPU reset complete'); } } isRunning(): boolean { return this.running; } setSpeed(speed: number): void { this.speed = Math.max(0.1, Math.min(10.0, speed)); console.log(`Simulation speed set to ${this.speed}x`); } getSpeed(): number { return this.speed; } step(): void { if (!this.cpu) return; avrInstruction(this.cpu); this.cpu.tick(); } /** * Set the state of an Arduino pin externally (e.g. from a UI button) */ setPinState(arduinoPin: number, state: boolean): void { if (this.boardVariant === 'mega') { const entry = MEGA_PIN_TO_PORT[arduinoPin]; if (entry) { const port = this.megaPorts.get(entry.portName); port?.setPin(entry.bit, state); } return; } if (this.boardVariant === 'tiny85') { // ATtiny85: PB0-PB5 = pins 0-5 if (arduinoPin >= 0 && arduinoPin <= 5 && this.portB) { this.portB.setPin(arduinoPin, state); } return; } // Uno / Nano if (arduinoPin >= 0 && arduinoPin <= 7 && this.portD) { this.portD.setPin(arduinoPin, state); } else if (arduinoPin >= 8 && arduinoPin <= 13 && this.portB) { this.portB.setPin(arduinoPin - 8, state); } else if (arduinoPin >= 14 && arduinoPin <= 19 && this.portC) { this.portC.setPin(arduinoPin - 14, state); } } /** * Send a byte to the Arduino serial port (RX) — as if typed in the Serial Monitor. * * AVR has no hardware RX FIFO, so avr8js's writeByte() rejects every * call while rxBusyValue is set (one full cyclesPerChar after the * previous byte). A naive loop would only deliver the first character. * Queue the bytes here and drain one at a time from onRxComplete. */ serialWrite(text: string): void { if (!this.usart) return; for (let i = 0; i < text.length; i++) { this.serialRxQueue.push(text.charCodeAt(i)); } this.drainSerialRxQueue(); } /** * Pump the next pending RX byte into the USART. Called once from * serialWrite() to kick the pipeline, then re-armed from * usart.onRxComplete after every byte the sketch actually receives. * The cyclesPerChar gap that avr8js enforces between writeByte calls * gives the sketch time to read UDR0 between bytes — same pacing the * real chip sees at the configured baud rate. */ private drainSerialRxQueue(): void { if (!this.usart) return; if (this.serialRxQueue.length === 0) return; const next = this.serialRxQueue[0]; if (this.usart.writeByte(next)) { this.serialRxQueue.shift(); } } /** * Register a virtual I2C device on the bus (e.g. RTC, sensor). */ addI2CDevice(device: I2CDevice): void { if (this.i2cBus) { this.i2cBus.addDevice(device); } } /** * Remove a virtual I2C device by address. Mirrors RP2040Simulator's * `removeI2CDevice(addr, bus)` shape so Interconnect / parts can use * the same uniform API across boards. */ removeI2CDevice(address: number, _bus: 0 | 1 = 0): void { this.i2cBus?.removeDevice(address); } /** * Get the I2CBusManager for a given hardware I2C bus. AVR has only * one TWI so `bus` is ignored. Available from construction time so * Interconnect can install cross-board I2C bridges immediately * (the bus's master peripheral is swapped in later by `loadHex`). */ getI2CBus(_bus: 0 | 1 = 0): I2CBusManager { return this.i2cBus; } // ── Generic sensor registration (board-agnostic API) ────────────────────── // AVR handles all sensor protocols locally via schedulePinChange, // so these return false / no-op — the sensor runs its own frontend logic. registerSensor(_type: string, _pin: number, _props: Record): boolean { return false; } updateSensor(_pin: number, _props: Record): void {} unregisterSensor(_pin: number): void {} }