From 603b791daadeec4219fa0ac28abcd3903723394c Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Tue, 19 May 2026 18:10:08 -0300 Subject: [PATCH] feat(attiny85+customchip): full ATtiny85 ADC/Timer0 + custom-chip pipeline fixes ATtiny85 (AVRSimulator + collectPinStates + connectAnalogInputsToMcu + SimulatorCanvas + Attiny85Element + examples): - Add attiny85AdcConfig with correct register addresses (ADMUX=0x27, ADCSRA=0x26, ADCSRB=0x23, ADCL=0x24, ADCH=0x25, DIDR0=0x34, adcInterrupt=0x08). Without this, analogRead() polled the wrong address forever and the firmware hung on first ADC read. - Add attiny85Timer0Config + instantiate AVRTimer so OVF fires at the ATTinyCore-expected ~1.024 ms cadence. delay() advance is still blocked on avr8js TIFR auto-clear semantics (separate upstream issue, see ATTINY85_TIMER0_UPSTREAM_ISSUE.md in velxio-prod test plan). - Map ATtiny85 ADC channels to PB-style pin names (PB5/PB2/PB4/PB3 -> 0..3) in connectAnalogInputsToMcu so SPICE node voltages reach the right ADC channel. - Recognise /^PB\d+$/ in collectPinStates.pinNameToArduinoPin so wires named "PB1" emit v_attiny85_pb1 V-source and the LED responds to MCU writes. Previously every PB-wire returned -1 and SPICE saw no source. - SimulatorCanvas: subscribe pin 1 (PB1) for the built-in LED on the attiny85 board kind (Digispark convention), instead of falling through to the pin-13 default. - Attiny85Element: remove the hand-drawn "yellow LED" circle that was floating above the chip. The bare DIP-8 has no on-board LED; examples wire a real wokwi-led + resistor instead. - examples.ts: add a real wokwi-led + 220 Ohm wokwi-resistor + wires to attiny85-blink, and add missing series resistors to attiny85-button-led and attiny85-ntc-sensor. attiny85-pwm-fade was already correct. Custom-chip pipeline (CustomChipPart + simulatorBridges): - Add a requestAnimationFrame loop that calls instance.tickTimers() every frame in CustomChipPart. Chips that register vx_timer_create (e.g. an i8080 stepping its core, or a sensor publishing samples) had timers added to the queue but nothing fired them; tickTimers was dead code. - Gate the ESP32 backend path with detectSimulatorKind(sim)==='esp32'. The previous `typeof sim.registerSensor === 'function'` check matched AVR and RP2040 simulators too (they expose registerSensor for I2C sensor proxies), routing client-side chips to a non-existent ESP32 worker on those boards. - Replace direct simulator.usart.writeByte calls in avrUartTx with a JS-level FIFO + setTimeout(1ms) drainer. avr8js writeByte drops bytes under burst load (a chip emitting print_string lost ~99% of bytes via non-immediate, or kept only the last byte via immediate). The drainer attempts one non-immediate write per tick and retries on RXC busy / RXEN off. Added a guard for ATtiny85 (no USART -> would queue forever). End-to-end verified: i8080-banner-streamer now prints the boot banner followed by "uptime ticks: 0xNN" lines stepping every ~50 ms, executing real Intel 8080 instructions inside the WASM chip. Co-Authored-By: Claude Opus 4.7 --- .../components/simulator/SimulatorCanvas.tsx | 5 +- .../velxio-components/Attiny85Element.ts | 63 +------- frontend/src/data/examples.ts | 118 ++++++++++----- frontend/src/simulation/AVRSimulator.ts | 136 +++++++++++++++++- .../customChips/simulatorBridges.ts | 63 +++++++- .../src/simulation/parts/CustomChipPart.ts | 30 +++- .../src/simulation/spice/collectPinStates.ts | 5 + .../spice/connectAnalogInputsToMcu.ts | 10 +- 8 files changed, 321 insertions(+), 109 deletions(-) diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 0653eb5b..d1ae203d 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -1100,8 +1100,11 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { case 'nano-rp2040': ledPin = 25; // GPIO25 break; + case 'attiny85': + ledPin = 1; // PB1 (Digispark convention) + break; default: - ledPin = 13; // Pin 13 for Arduino Uno/Nano/Mega, ATtiny85, etc. + ledPin = 13; // Pin 13 for Arduino Uno/Nano/Mega } unsubs.push( diff --git a/frontend/src/components/velxio-components/Attiny85Element.ts b/frontend/src/components/velxio-components/Attiny85Element.ts index 40d1121b..32b167bc 100644 --- a/frontend/src/components/velxio-components/Attiny85Element.ts +++ b/frontend/src/components/velxio-components/Attiny85Element.ts @@ -14,7 +14,7 @@ * pinInfo) but external components positioned to the right of the * chip may need to be moved manually for clean routing. * - * viewBox 28.801 × 23.768 mm scaled uniformly at 5.555 px/mm → 160 × 132 px. + * viewBox 28.801 x 23.768 mm scaled uniformly at 5.555 px/mm -> 160 x 132 px. * Pin terminal centres in the source SVG (mm), all 8 connectors: * row x y pin label * top 3.601 1.084 VCC (connector7) @@ -26,8 +26,8 @@ * bottom 18.000 22.687 PB4 (connector2) * bottom 25.201 22.687 GND (connector3) * - * Built-in LED on PB1 (Digispark convention) is overlaid as a circle in - * the outer SVG — drives off the `led1` attribute / property like before. + * No fake on-board LED is drawn. The bare DIP-8 has no PCB LED — examples + * wire a real wokwi-led + wokwi-resistor to PB1 like the c3-blink one does. */ const W = 160; @@ -40,7 +40,7 @@ const PIN_INFO: ReadonlyArray<{ name: string; x: number; y: number; description? // Top row — left to right { name: 'VCC', x: PIN_X[0], y: TOP_Y, description: 'VCC (Pin 8)' }, { name: 'PB2', x: PIN_X[1], y: TOP_Y, description: 'PB2 / SCK / SCL / ADC1 (Pin 7)' }, - { name: 'PB1', x: PIN_X[2], y: TOP_Y, description: 'PB1 / MISO / built-in LED (Pin 6)' }, + { name: 'PB1', x: PIN_X[2], y: TOP_Y, description: 'PB1 / MISO / OC0B / OC1A (Pin 6)' }, { name: 'PB0', x: PIN_X[3], y: TOP_Y, description: 'PB0 / MOSI / SDA / OC0A (Pin 5)' }, // Bottom row — left to right { name: 'PB5', x: PIN_X[0], y: BOT_Y, description: 'PB5 / RESET (Pin 1)' }, @@ -50,12 +50,6 @@ const PIN_INFO: ReadonlyArray<{ name: string; x: number; y: number; description? ]; class Attiny85Element extends HTMLElement { - static get observedAttributes(): string[] { - return ['led1']; - } - - private _led1 = false; - constructor() { super(); this.attachShadow({ mode: 'open' }); @@ -65,52 +59,14 @@ class Attiny85Element extends HTMLElement { this.render(); } - attributeChangedCallback(name: string, _old: string | null, value: string | null) { - if (name === 'led1') { - this._led1 = value !== null && value !== 'false'; - this.updateLed(); - } - } - /** Wires use this to find pin coordinates. */ get pinInfo() { return PIN_INFO; } - set led1(v: boolean) { - this._led1 = !!v; - this.updateLed(); - } - get led1(): boolean { - return this._led1; - } - - private updateLed() { - const led = this.shadowRoot?.getElementById('attiny-led1') as unknown as SVGElement | null; - if (!led) return; - if (this._led1) { - led.setAttribute('fill', '#ffee44'); - led.setAttribute('stroke', '#ffcc00'); - led.style.filter = 'drop-shadow(0 0 4px #ffcc00)'; - } else { - led.setAttribute('fill', '#333'); - led.setAttribute('stroke', '#555'); - led.style.filter = 'none'; - } - } - private render() { if (!this.shadowRoot) return; - const ledFill = this._led1 ? '#ffee44' : '#333'; - const ledStroke = this._led1 ? '#ffcc00' : '#555'; - const ledFilter = this._led1 ? 'drop-shadow(0 0 4px #ffcc00)' : 'none'; - - // Place the LED indicator just ABOVE the PB1 top-pin (PIN_X[2], TOP_Y), - // outside the chip body so it stays readable. - const ledX = PIN_X[2]; - const ledY = TOP_Y - 12; - this.shadowRoot.innerHTML = ` + the 160x132 box; aspect ratio matches the source 1.211:1. --> - - - VCC PB2 - PB1 + PB1 PB0 PB5 diff --git a/frontend/src/data/examples.ts b/frontend/src/data/examples.ts index d82ef668..ceb8ed6d 100644 --- a/frontend/src/data/examples.ts +++ b/frontend/src/data/examples.ts @@ -1550,33 +1550,21 @@ void loop() { `, components: [ { type: 'wokwi-arduino-uno', id: 'arduino-uno', x: 100, y: 100, properties: {} }, - { type: 'wokwi-ssd1306', id: 'ssd1306-1', x: 420, y: 120, properties: {} }, + { type: 'wokwi-ssd1306', id: 'ssd1306-1', x: 420, y: 100, properties: {} }, + { type: 'wokwi-ds1307', id: 'rtc1', x: 420, y: 280, properties: {} }, ], wires: [ - { - id: 'wire-sda', - start: { componentId: 'arduino-uno', pinName: 'A4' }, - end: { componentId: 'ssd1306-1', pinName: 'DATA' }, - color: '#2196f3', - }, - { - id: 'wire-scl', - start: { componentId: 'arduino-uno', pinName: 'A5' }, - end: { componentId: 'ssd1306-1', pinName: 'CLK' }, - color: '#ff9800', - }, - { - id: 'wire-gnd', - start: { componentId: 'arduino-uno', pinName: 'GND.1' }, - end: { componentId: 'ssd1306-1', pinName: 'GND' }, - color: '#000000', - }, - { - id: 'wire-vcc', - start: { componentId: 'arduino-uno', pinName: '5V' }, - end: { componentId: 'ssd1306-1', pinName: 'VIN' }, - color: '#ff0000', - }, + // SDA bus (shared) + { id: 'wire-sda', start: { componentId: 'arduino-uno', pinName: 'A4' }, end: { componentId: 'ssd1306-1', pinName: 'DATA' }, color: '#2196f3' }, + { id: 'wire-sda-rtc', start: { componentId: 'ssd1306-1', pinName: 'DATA' }, end: { componentId: 'rtc1', pinName: 'SDA' }, color: '#2196f3' }, + // SCL bus (shared) + { id: 'wire-scl', start: { componentId: 'arduino-uno', pinName: 'A5' }, end: { componentId: 'ssd1306-1', pinName: 'CLK' }, color: '#ff9800' }, + { id: 'wire-scl-rtc', start: { componentId: 'ssd1306-1', pinName: 'CLK' }, end: { componentId: 'rtc1', pinName: 'SCL' }, color: '#ff9800' }, + // Power + ground for both devices + { id: 'wire-gnd', start: { componentId: 'arduino-uno', pinName: 'GND.1' }, end: { componentId: 'ssd1306-1', pinName: 'GND' }, color: '#000000' }, + { id: 'wire-gnd-rtc', start: { componentId: 'ssd1306-1', pinName: 'GND' }, end: { componentId: 'rtc1', pinName: 'GND' }, color: '#000000' }, + { id: 'wire-vcc', start: { componentId: 'arduino-uno', pinName: '5V' }, end: { componentId: 'ssd1306-1', pinName: 'VIN' }, color: '#ff0000' }, + { id: 'wire-vcc-rtc', start: { componentId: 'ssd1306-1', pinName: 'VIN' }, end: { componentId: 'rtc1', pinName: 'VCC' }, color: '#ff0000' }, ], }, { @@ -1653,8 +1641,16 @@ void loop() { delay(1000); } `, - components: [{ type: 'wokwi-arduino-uno', id: 'arduino-uno', x: 100, y: 100, properties: {} }], - wires: [], + components: [ + { type: 'wokwi-arduino-uno', id: 'arduino-uno', x: 100, y: 100, properties: {} }, + { type: 'wokwi-ds1307', id: 'rtc1', x: 480, y: 200, properties: {} }, + ], + wires: [ + { id: 'w-sda', start: { componentId: 'arduino-uno', pinName: 'A4' }, end: { componentId: 'rtc1', pinName: 'SDA' }, color: '#0066cc' }, + { id: 'w-scl', start: { componentId: 'arduino-uno', pinName: 'A5' }, end: { componentId: 'rtc1', pinName: 'SCL' }, color: '#ffaa00' }, + { id: 'w-vcc', start: { componentId: 'arduino-uno', pinName: '5V' }, end: { componentId: 'rtc1', pinName: 'VCC' }, color: '#ff0000' }, + { id: 'w-gnd', start: { componentId: 'arduino-uno', pinName: 'GND' }, end: { componentId: 'rtc1', pinName: 'GND' }, color: '#000000' }, + ], }, { id: 'i2c-eeprom-rw', @@ -6997,7 +6993,7 @@ void loop() { id: 'attiny85-blink', title: 'ATtiny85: Blink LED', description: - 'Classic blink on the ATtiny85 built-in LED (PB1). Great first sketch for this tiny 8-bit chip.', + 'Blink an external LED on PB1 (Digispark pin 1) through a 220 Ohm current-limit resistor.', category: 'basics', difficulty: 'beginner', boardFilter: 'attiny85', @@ -7006,11 +7002,11 @@ void loop() { boardKind: 'attiny85', x: 120, y: 160, - code: `// ATtiny85 — Blink built-in LED on PB1 -// Pin 1 = PB1 = OC0B/OC1A (also Digispark LED) + code: `// ATtiny85 — Blink an LED wired to PB1 (Digispark pin 1) +// PB1 -> 220 Ohm -> LED anode; LED cathode -> GND void setup() { - pinMode(1, OUTPUT); // PB1 as output + pinMode(1, OUTPUT); } void loop() { @@ -7022,8 +7018,30 @@ void loop() { }, ], code: '', - components: [], - wires: [], + components: [ + { type: 'wokwi-led', id: 'tiny-led1', x: 360, y: 140, properties: { color: 'green' } }, + { type: 'wokwi-resistor', id: 'tiny-r1', x: 360, y: 220, properties: { resistance: '220' } }, + ], + wires: [ + { + id: 'tinyw1', + start: { componentId: 'attiny85', pinName: 'PB1' }, + end: { componentId: 'tiny-led1', pinName: 'A' }, + color: '#22cc22', + }, + { + id: 'tinyw2', + start: { componentId: 'tiny-led1', pinName: 'C' }, + end: { componentId: 'tiny-r1', pinName: '1' }, + color: '#888888', + }, + { + id: 'tinyw3', + start: { componentId: 'tiny-r1', pinName: '2' }, + end: { componentId: 'attiny85', pinName: 'GND' }, + color: '#000000', + }, + ], }, { @@ -7058,6 +7076,13 @@ void loop() { components: [ { type: 'wokwi-pushbutton', id: 'tiny-btn1', x: 380, y: 130, properties: { color: 'green' } }, { type: 'wokwi-led', id: 'tiny-led1', x: 380, y: 240, properties: { color: 'red' } }, + { + type: 'wokwi-resistor', + id: 'tiny-btn-r1', + x: 380, + y: 320, + properties: { resistance: '220' }, + }, ], wires: [ { @@ -7078,10 +7103,16 @@ void loop() { end: { componentId: 'tiny-led1', pinName: 'A' }, color: '#ff4444', }, + { + id: 'tb-led-r', + start: { componentId: 'tiny-led1', pinName: 'C' }, + end: { componentId: 'tiny-btn-r1', pinName: '1' }, + color: '#888888', + }, { id: 'tb-gnd2', - start: { componentId: 'attiny85', pinName: 'GND' }, - end: { componentId: 'tiny-led1', pinName: 'C' }, + start: { componentId: 'tiny-btn-r1', pinName: '2' }, + end: { componentId: 'attiny85', pinName: 'GND' }, color: '#000000', }, ], @@ -7217,6 +7248,13 @@ void loop() { properties: { temperature: '25' }, }, { type: 'wokwi-led', id: 'tiny-ntc-led', x: 380, y: 300, properties: { color: 'red' } }, + { + type: 'wokwi-resistor', + id: 'tiny-ntc-r1', + x: 380, + y: 380, + properties: { resistance: '220' }, + }, ], wires: [ { @@ -7243,10 +7281,16 @@ void loop() { end: { componentId: 'tiny-ntc-led', pinName: 'A' }, color: '#ff4444', }, + { + id: 'tn-led-r', + start: { componentId: 'tiny-ntc-led', pinName: 'C' }, + end: { componentId: 'tiny-ntc-r1', pinName: '1' }, + color: '#888888', + }, { id: 'tn-lgnd', - start: { componentId: 'attiny85', pinName: 'GND' }, - end: { componentId: 'tiny-ntc-led', pinName: 'C' }, + start: { componentId: 'tiny-ntc-r1', pinName: '2' }, + end: { componentId: 'attiny85', pinName: 'GND' }, color: '#000000', }, ], diff --git a/frontend/src/simulation/AVRSimulator.ts b/frontend/src/simulation/AVRSimulator.ts index d846a481..6d3d9098 100644 --- a/frontend/src/simulation/AVRSimulator.ts +++ b/frontend/src/simulation/AVRSimulator.ts @@ -28,6 +28,9 @@ import { 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'; @@ -143,6 +146,110 @@ const attiny85PortBConfig = { 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 }, @@ -245,10 +352,26 @@ export class AVRSimulator { this.cpu = new CPU(this.program, sramBytes); if (this.boardVariant === 'tiny85') { - // ATtiny85: PORTB only (PB0-PB5), Timer1 via ATtinyTimer1, no USART + // 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, adcConfig); - this.peripherals = [new ATtinyTimer1(this.cpu, attinyTimer1Config)]; + 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.), @@ -611,8 +734,11 @@ export class AVRSimulator { if (this.boardVariant === 'tiny85') { this.portB = new AVRIOPort(this.cpu, attiny85PortBConfig as typeof portBConfig); - this.adc = new AVRADC(this.cpu, adcConfig); - this.peripherals = [new ATtinyTimer1(this.cpu, attinyTimer1Config)]; + 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); diff --git a/frontend/src/simulation/customChips/simulatorBridges.ts b/frontend/src/simulation/customChips/simulatorBridges.ts index f4a925b7..2600e6d6 100644 --- a/frontend/src/simulation/customChips/simulatorBridges.ts +++ b/frontend/src/simulation/customChips/simulatorBridges.ts @@ -31,6 +31,13 @@ export interface SimulatorBridges { uartInstalled: boolean; /** Original onByteTransmit so non-chip listeners still receive bytes. */ uartPreviousOnByteTransmit: ((byte: number) => void) | null; + /** Pending bytes to inject into the AVR/RP2040 RX register, drained at + * ~baud rate by `uartDrainHandle`. Without this queue, chips that emit + * bursts (e.g. an i8080 printing a banner) overflow the 2-byte USART + * RX register and most bytes get silently dropped. */ + uartRxQueue: number[]; + /** setTimeout handle for the queue drainer (0 if not active). */ + uartDrainHandle: number; /** Shared SPI bus across all custom chips on this simulator. */ spiBus: SPIBus; @@ -49,6 +56,8 @@ export function getSimulatorBridges(simulator: any): SimulatorBridges { uartListeners: new Set(), uartInstalled: false, uartPreviousOnByteTransmit: null, + uartRxQueue: [], + uartDrainHandle: 0, spiBus: new SPIBus(), spiInstalled: false, spiPreviousOnByte: null, @@ -102,17 +111,57 @@ export function ensureUartBridge(simulator: any): void { /** * Inject a byte into the simulator's RX path so the sketch's `Serial.read()` - * returns it. Routes per simulator family. + * returns it. + * + * For AVR we go through a JS-level FIFO + setTimeout drainer instead of + * calling `simulator.usart.writeByte` directly. Two reasons: + * + * 1. The non-immediate form silently returns `false` for bytes that arrive + * while `rxBusyValue` is still set from the previous one — so chips + * that emit bursts (e.g. an i8080 print_string sequence) lose ~99% of + * their bytes. + * 2. The immediate form overwrites `rxByte` directly without waiting for + * the AVR sketch to drain it — same outcome, only the last byte of + * each burst survives. + * + * The drainer attempts one non-immediate write per tick (1 ms apart). On + * RXC busy / RXEN off it leaves the byte at the head of the queue and + * retries on the next tick. RP2040 has its own internal buffering, so we + * just forward to `serialWriteByte`. */ export function avrUartTx(simulator: any, byte: number): void { const kind = detectSimulatorKind(simulator); - try { - if (kind === 'avr' && simulator.usart && typeof simulator.usart.writeByte === 'function') { - simulator.usart.writeByte(byte); - } else if (kind === 'rp2040' && typeof simulator.serialWriteByte === 'function') { - simulator.serialWriteByte(byte); + if (kind === 'avr') { + // ATtiny85 has no hardware USART — silently drop instead of queueing + // forever. Users wiring a UART chip to a tiny85 need SoftwareSerial, + // which is a different bridge entirely (TODO). + if (!simulator.usart || typeof simulator.usart.writeByte !== 'function') return; + const b = getSimulatorBridges(simulator); + b.uartRxQueue.push(byte & 0xff); + if (!b.uartDrainHandle) { + const drain = () => { + const b2 = getSimulatorBridges(simulator); + if (b2.uartRxQueue.length === 0) { + b2.uartDrainHandle = 0; + return; + } + const next = b2.uartRxQueue[0]; + let accepted = false; + try { + accepted = simulator.usart?.writeByte?.(next) ?? false; + } catch { + accepted = false; + } + if (accepted) b2.uartRxQueue.shift(); + b2.uartDrainHandle = (setTimeout(drain, 1) as unknown) as number; + }; + b.uartDrainHandle = (setTimeout(drain, 0) as unknown) as number; } - } catch { /* swallow — peripheral may not be ready yet */ } + return; + } + if (kind === 'rp2040' && typeof simulator.serialWriteByte === 'function') { + try { simulator.serialWriteByte(byte); } catch { /* swallow */ } + } } // ── SPI ───────────────────────────────────────────────────────────────────── diff --git a/frontend/src/simulation/parts/CustomChipPart.ts b/frontend/src/simulation/parts/CustomChipPart.ts index d968926a..62b419f8 100644 --- a/frontend/src/simulation/parts/CustomChipPart.ts +++ b/frontend/src/simulation/parts/CustomChipPart.ts @@ -16,6 +16,7 @@ import { getSimulatorBridges, avrUartTx, getI2CBus, + detectSimulatorKind, } from '../customChips'; import { useSimulatorStore } from '../../store/useSimulatorStore'; @@ -88,7 +89,13 @@ PartSimulationRegistry.register('custom-chip', { // ── ESP32 path ────────────────────────────────────────────────────────── // The chip's WASM runs in the backend QEMU worker process so I2C events // are answered synchronously. See docs/wiki/custom-chips-esp32-backend-runtime.md. - if (typeof sim.registerSensor === 'function') { + // + // IMPORTANT: only take this path for actual ESP32 simulators. We cannot + // gate on `typeof sim.registerSensor === 'function'` because AVR and + // RP2040 simulators also expose `registerSensor` for I2C sensor proxies — + // taking that branch on AVR routes the chip to the (non-existent) ESP32 + // backend and the client-side ChipInstance never runs. + if (detectSimulatorKind(sim) === 'esp32' && typeof sim.registerSensor === 'function') { // Resolve each chip pin name → ESP32 GPIO via the diagram's wires. The // backend runtime uses this to call qemu_picsimlab_set_pin when the chip // does vx_pin_write, and to read live GPIO state for vx_pin_read. @@ -141,6 +148,7 @@ PartSimulationRegistry.register('custom-chip', { // when the user stops the simulation. let instance: ChipInstance | null = null; let uartListener: ((byte: number) => void) | null = null; + let rafHandle = 0; let disposed = false; (async () => { @@ -181,6 +189,24 @@ PartSimulationRegistry.register('custom-chip', { try { (el as any).paintFramebuffer(rgba, width, height); } catch { /* swallow */ } }); } + + // Drive the chip's timer-based execution every frame. Chips that + // register a periodic `vx_timer_create` (e.g. a CPU-emulator chip + // stepping its core, or a sensor publishing samples) need a + // host-side tick to fire those callbacks — without this loop the + // WASM is loaded but never executes anything past chip_setup(). + // We feed wall-clock nanoseconds; the chip's WasiShim already + // exposes the same epoch via vx_sim_now_nanos. + const tick = () => { + if (disposed || !instance) return; + try { + instance.tickTimers(BigInt(Math.floor(performance.now() * 1_000_000))); + } catch (e) { + console.error(`[custom-chip:${componentId}] tickTimers threw:`, e); + } + rafHandle = requestAnimationFrame(tick); + }; + rafHandle = requestAnimationFrame(tick); } catch (e) { console.error(`[custom-chip] ${componentId} failed to load:`, e); } @@ -188,6 +214,8 @@ PartSimulationRegistry.register('custom-chip', { return () => { disposed = true; + if (rafHandle) cancelAnimationFrame(rafHandle); + rafHandle = 0; if (uartListener) bridges.uartListeners.delete(uartListener); if (instance) instance.dispose(); instance = null; diff --git a/frontend/src/simulation/spice/collectPinStates.ts b/frontend/src/simulation/spice/collectPinStates.ts index fc8ccf2c..90fbdf77 100644 --- a/frontend/src/simulation/spice/collectPinStates.ts +++ b/frontend/src/simulation/spice/collectPinStates.ts @@ -31,6 +31,11 @@ function pinNameToArduinoPin(pinName: string, boardKind: BoardKind): number { if (/^A\d+$/.test(pinName)) { return 14 + parseInt(pinName.slice(1), 10); } + // ATtiny85 port-style names (PB0..PB5) map 1:1 to Arduino pin numbers + // because ATTinyCore's pinMap is identity for the tiny85 variant. + if (/^PB\d+$/.test(pinName)) { + return parseInt(pinName.slice(2), 10); + } if (/^\d+$/.test(pinName)) { return parseInt(pinName, 10); } diff --git a/frontend/src/simulation/spice/connectAnalogInputsToMcu.ts b/frontend/src/simulation/spice/connectAnalogInputsToMcu.ts index cd24d45e..97693b8f 100644 --- a/frontend/src/simulation/spice/connectAnalogInputsToMcu.ts +++ b/frontend/src/simulation/spice/connectAnalogInputsToMcu.ts @@ -48,7 +48,15 @@ const ADC_PIN_MAP: Partial ADC0 PB2 -> ADC1 PB4 -> ADC2 PB3 -> ADC3 + attiny85: [ + { pinName: 'PB5', channel: 0 }, + { pinName: 'PB2', channel: 1 }, + { pinName: 'PB4', channel: 2 }, + { pinName: 'PB3', channel: 3 }, + ], // RP2040 'raspberry-pi-pico': [