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 <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-19 18:10:08 -03:00
parent a9285c6698
commit 603b791daa
8 changed files with 321 additions and 109 deletions

View File

@ -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(

View File

@ -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 = `
<style>
:host { display: inline-block; line-height: 0; }
@ -122,23 +78,16 @@ class Attiny85Element extends HTMLElement {
pointer-events: none;
}
.pin-label.power { fill: #aaa; }
.pin-label.led { fill: #ffd066; }
</style>
<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<!-- Fritzing ATtiny85 DIP-8 artwork (CC-BY-SA). Scaled to fill
the 160×132 box; aspect ratio matches the source 1.211:1. -->
the 160x132 box; aspect ratio matches the source 1.211:1. -->
<image href="/component-svgs/attiny85.svg" x="0" y="0" width="${W}" height="${H}" />
<!-- Built-in LED on PB1 (Digispark convention) -->
<circle id="attiny-led1"
cx="${ledX}" cy="${ledY}" r="5"
fill="${ledFill}" stroke="${ledStroke}" stroke-width="1"
style="filter: ${ledFilter};" />
<!-- Pin labels overlaid above/below the connector pads. -->
<text class="pin-label power" x="${PIN_X[0]}" y="${TOP_Y - 22}">VCC</text>
<text class="pin-label" x="${PIN_X[1]}" y="${TOP_Y - 22}">PB2</text>
<text class="pin-label led" x="${PIN_X[2]}" y="${TOP_Y - 22}">PB1</text>
<text class="pin-label" x="${PIN_X[2]}" y="${TOP_Y - 22}">PB1</text>
<text class="pin-label" x="${PIN_X[3]}" y="${TOP_Y - 22}">PB0</text>
<text class="pin-label" x="${PIN_X[0]}" y="${BOT_Y + 12}">PB5</text>

View File

@ -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',
},
],

View File

@ -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: <avr/iotnx5.h> 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: <avr/iotnx5.h>; 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);

View File

@ -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 ─────────────────────────────────────────────────────────────────────

View File

@ -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;

View File

@ -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);
}

View File

@ -48,7 +48,15 @@ const ADC_PIN_MAP: Partial<Record<BoardKind, Array<{ pinName: string; channel: n
'arduino-uno': ADC_6CH,
'arduino-nano': ADC_8CH,
'arduino-mega': ADC_16CH,
attiny85: adcRange('A', 0, 4),
// ATtiny85: ADC channels live on PORTB pins, not on A0..An.
// Wires reference the chip's own pin names (PB5/PB2/PB4/PB3).
// PB5 -> 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': [