velxio/frontend/src/simulation/parts/ComplexParts.ts

1119 lines
42 KiB
TypeScript
Raw Normal View History

import { PartSimulationRegistry } from './PartSimulationRegistry';
import type { AnySimulator } from './PartSimulationRegistry';
import { RP2040Simulator } from '../RP2040Simulator';
import { getADC, setAdcVoltage, emitPropertyChange } from './partUtils';
import { registerSensorUpdate, unregisterSensorUpdate } from '../SensorUpdateRegistry';
// ─── Helpers ────────────────────────────────────────────────────────────────
// ─── RGB LED (PWM-aware) ─────────────────────────────────────────────────────
/**
* RGB LED implementation supports both digital and PWM (analogWrite) output.
* Falls back to digital mode if no PWM is detected.
*/
PartSimulationRegistry.register('rgb-led', {
attachEvents: (element, avrSimulator, getArduinoPinHelper, _componentId, getPinResolver) => {
const pinManager = (avrSimulator as any).pinManager;
if (!pinManager) return () => {};
const el = element as any;
const unsubscribers: (() => void)[] = [];
const useResolver = typeof getPinResolver === 'function';
// Digital path: prefer PinResolver so each channel works when driven
// through an active device (e.g. a P-MOSFET high-side switch).
type Channel = { pinName: 'R' | 'G' | 'B'; prop: 'ledRed' | 'ledGreen' | 'ledBlue' };
const channels: Channel[] = [
{ pinName: 'R', prop: 'ledRed' },
{ pinName: 'G', prop: 'ledGreen' },
{ pinName: 'B', prop: 'ledBlue' },
];
// Track Arduino pin numbers for the PWM hook below — analogWrite()
// override still needs the integer pin number because PinResolver
// doesn't (yet) expose PWM duty.
const pwmPins: Array<{ pin: number; prop: Channel['prop'] }> = [];
for (const { pinName, prop } of channels) {
if (useResolver) {
const resolver = getPinResolver!(pinName);
if (resolver) {
el[prop] = resolver.getCurrentState() === 'HIGH' ? 255 : 0;
unsubscribers.push(
resolver.onChange((state) => {
el[prop] = state === 'HIGH' ? 255 : 0;
}),
);
}
} else {
const pin = getArduinoPinHelper(pinName);
if (pin !== null) {
unsubscribers.push(
pinManager.onPinChange(pin, (_: number, state: boolean) => {
el[prop] = state ? 255 : 0;
}),
);
}
}
// PWM hook still uses the raw pin number — duty cycle handling
// doesn't live in PinResolver yet.
const rawPin = getArduinoPinHelper(pinName);
if (rawPin !== null) pwmPins.push({ pin: rawPin, prop });
}
// PWM override — analogWrite() value supersedes digital state.
for (const { pin, prop } of pwmPins) {
unsubscribers.push(
pinManager.onPwmChange(pin, (_: number, dc: number) => {
el[prop] = Math.round(dc * 255);
}),
);
}
return () => unsubscribers.forEach((u) => u());
},
});
// ─── Potentiometer (rotary) ──────────────────────────────────────────────────
PartSimulationRegistry.register('potentiometer', {
attachEvents: (element, simulator, getArduinoPinHelper, componentId) => {
const pin = getArduinoPinHelper('SIG');
// Determine reference voltage based on board type
const isRP2040 = simulator instanceof RP2040Simulator;
const isESP32 = typeof (simulator as any).setAdcVoltage === 'function';
const refVoltage = isRP2040 || isESP32 ? 3.3 : 5.0;
const onInput = () => {
const rawStr = (element as any).value ?? '0';
const raw = parseInt(rawStr, 10);
if (pin !== null) {
const volts = (raw / 1023.0) * refVoltage;
setAdcVoltage(simulator, pin, volts);
}
// Mirror to store so the SPICE netlist re-solves (op-amp
// comparators, divider-driven circuits etc. depend on this).
emitPropertyChange(componentId, 'value', raw);
};
onInput();
element.addEventListener('input', onInput);
return () => element.removeEventListener('input', onInput);
},
});
// ─── Slide Potentiometer ─────────────────────────────────────────────────────
PartSimulationRegistry.register('slide-potentiometer', {
attachEvents: (element, avrSimulator, getArduinoPinHelper, componentId) => {
const arduinoPin = getArduinoPinHelper('SIG') ?? getArduinoPinHelper('OUT');
const el = element as any;
const isRP2040 = avrSimulator instanceof RP2040Simulator;
const isESP32 = typeof (avrSimulator as any).setAdcVoltage === 'function';
const refVoltage = isRP2040 || isESP32 ? 3.3 : 5.0;
const onInput = () => {
const min = Number(el.min ?? 0);
const max = Number(el.max ?? 1023);
const value = Number(el.value ?? 0);
const normalized = (value - min) / (max - min || 1);
if (arduinoPin !== null) {
const volts = normalized * refVoltage;
setAdcVoltage(avrSimulator, arduinoPin, volts);
}
emitPropertyChange(componentId, 'value', value);
};
onInput();
element.addEventListener('input', onInput);
return () => element.removeEventListener('input', onInput);
},
});
// ─── Photoresistor Sensor ────────────────────────────────────────────────────
/**
* Photoresistor sensor the wokwi element does not emit input events,
* so we simulate light level with a slider drawn via the component's
* luminance property when available, or simply set a mid-range voltage.
*
* The element exposes `ledDO` and `ledPower` for display only.
* We inject a static mid-range voltage on the AO pin so analogRead()
* returns a valid value. Users can modify the element's `value` attribute.
*/
PartSimulationRegistry.register('photoresistor-sensor', {
attachEvents: (element, avrSimulator, getArduinoPinHelper, componentId) => {
const pinAO = getArduinoPinHelper('AO') ?? getArduinoPinHelper('A0');
const pinDO = getArduinoPinHelper('DO') ?? getArduinoPinHelper('D0');
const pinManager = (avrSimulator as any).pinManager;
const unsubscribers: (() => void)[] = [];
// Inject initial mid-range voltage (simulate moderate light, ~500 lux)
if (pinAO !== null) {
setAdcVoltage(avrSimulator, pinAO, 2.5);
}
// Watch element's 'input' events in case the element supports it
const onInput = () => {
const val = (element as any).value;
if (val !== undefined) {
if (pinAO !== null) {
const volts = (val / 1023.0) * 5.0;
setAdcVoltage(avrSimulator, pinAO, volts);
}
// Mirror to store — maps the slider 0-1023 back to lux 0-1000
// so the SPICE photoresistor handler re-computes its R_ldr.
emitPropertyChange(componentId, 'lux', Math.round((val / 1023) * 1000));
}
};
element.addEventListener('input', onInput);
unsubscribers.push(() => element.removeEventListener('input', onInput));
// DO (digital output) — if connected, update element's LED indicator
if (pinDO !== null && pinManager) {
unsubscribers.push(
pinManager.onPinChange(pinDO, (_: number, state: boolean) => {
(element as any).ledDO = state;
}),
);
}
// SensorControlPanel: lux 01000 → volts 05
registerSensorUpdate(componentId, (values) => {
if ('lux' in values) {
if (pinAO !== null) {
setAdcVoltage(avrSimulator, pinAO, ((values.lux as number) / 1000) * 5.0);
}
emitPropertyChange(componentId, 'lux', values.lux);
}
});
return () => {
unsubscribers.forEach((u) => u());
unregisterSensorUpdate(componentId);
};
},
});
// ─── Analog Joystick ─────────────────────────────────────────────────────────
/**
* Analog Joystick two axes (xValue/yValue 0-1023) + button press
* Wokwi pins: VRX (X axis), VRY (Y axis), SW (button)
*/
PartSimulationRegistry.register('analog-joystick', {
attachEvents: (element, avrSimulator, getArduinoPinHelper, componentId) => {
// wokwi-analog-joystick uses VERT/HORZ/SEL pin names
const pinX =
getArduinoPinHelper('VERT') ?? getArduinoPinHelper('VRX') ?? getArduinoPinHelper('XOUT');
const pinY =
getArduinoPinHelper('HORZ') ?? getArduinoPinHelper('VRY') ?? getArduinoPinHelper('YOUT');
const pinSW = getArduinoPinHelper('SEL') ?? getArduinoPinHelper('SW');
const el = element as any;
// wokwi-analog-joystick exposes xValue/yValue as DIRECTION (-1 / 0 / +1),
// not pot-style 0..1023. See @wokwi/elements analog-joystick-element.js:
// arrow-zone clicks call mousedown(e, dx, dy) where dx,dy ∈ {-1, 0, +1};
// mouseup snaps back to 0. Map that tri-state to an ADC voltage:
// -1 → 0 V | 0 → VCC/2 (center) | +1 → VCC
// AVR uses 5 V; everything else (RP2040, ESP32, ESP32-S3, …) runs at 3.3 V.
const isAvr = !(avrSimulator instanceof RP2040Simulator)
&& typeof (avrSimulator as any).setAdcVoltage !== 'function';
const vcc = isAvr ? 5.0 : 3.3;
const centerV = vcc / 2;
const dirToVolts = (d: number) => ((Math.max(-1, Math.min(1, d)) + 1) / 2) * vcc;
// Initialize to center position and button not pressed
if (pinX !== null) setAdcVoltage(avrSimulator, pinX, centerV);
if (pinY !== null) setAdcVoltage(avrSimulator, pinY, centerV);
if (pinSW !== null) avrSimulator.setPinState(pinSW, true); // HIGH = not pressed
const onMove = () => {
if (pinX !== null) {
setAdcVoltage(avrSimulator, pinX, dirToVolts(Number(el.xValue ?? 0)));
}
if (pinY !== null) {
setAdcVoltage(avrSimulator, pinY, dirToVolts(Number(el.yValue ?? 0)));
}
};
const onPress = () => {
if (pinSW !== null) avrSimulator.setPinState(pinSW, false); // Active LOW
el.pressed = true;
};
const onRelease = () => {
if (pinSW !== null) avrSimulator.setPinState(pinSW, true);
el.pressed = false;
};
element.addEventListener('input', onMove);
element.addEventListener('joystick-move', onMove);
element.addEventListener('button-press', onPress);
element.addEventListener('button-release', onRelease);
// SensorControlPanel: xAxis/yAxis -512..512 → voltage 0VCC (center = VCC/2)
registerSensorUpdate(componentId, (values) => {
if ('xAxis' in values && pinX !== null) {
setAdcVoltage(avrSimulator, pinX, (((values.xAxis as number) + 512) / 1023) * vcc);
}
if ('yAxis' in values && pinY !== null) {
setAdcVoltage(avrSimulator, pinY, (((values.yAxis as number) + 512) / 1023) * vcc);
}
});
return () => {
element.removeEventListener('input', onMove);
element.removeEventListener('joystick-move', onMove);
element.removeEventListener('button-press', onPress);
element.removeEventListener('button-release', onRelease);
unregisterSensorUpdate(componentId);
};
},
});
// ─── Servo ───────────────────────────────────────────────────────────────────
/**
* Servo motor measures actual PWM pulse width from pin state changes.
*
* Standard RC servo protocol:
* - 50 Hz signal (20 ms period)
* - Pulse width 544 µs 0°, 1472 µs 90°, 2400 µs 180°
* (Arduino Servo.h uses 5442400 µs, NOT the generic 10002000 µs range)
*
* Approach: subscribe to the servo's PWM pin state changes, record the CPU
* cycle count at the rising edge, then compute pulse width on the falling edge.
* avr8js re-schedules Timer1 every 8 CPU cycles (prescaler=8), so each HIGH
* and LOW transition fires in a separate count() call with a distinct cpu.cycles
* value the measurement is cycle-accurate.
*
* Fallback: if no wire is connected (pinSIG === null), poll OCR1A/ICR1 registers
* via requestAnimationFrame (less accurate but still functional).
*/
PartSimulationRegistry.register('servo', {
attachEvents: (element, avrSimulator, getArduinoPinHelper) => {
const pinSIG =
getArduinoPinHelper('PWM') ?? getArduinoPinHelper('SIG') ?? getArduinoPinHelper('1');
const el = element as any;
// Arduino Servo.h actual pulse range (544µs = 0°, 2400µs = 180°)
const MIN_PULSE_US = 544;
const MAX_PULSE_US = 2400;
const CPU_HZ = 16_000_000;
// ── RP2040 path: measure GPIO pulse timing via onPinChangeWithTime ───────
// Arduino-Pico Servo library uses PIO (not hardware PWM) — PIO toggles GPIO
// directly, which fires gpio.addListener → onPinChangeWithTime with the
// accurate simulation time from SimulationClock.nanosCounter.
if (avrSimulator instanceof RP2040Simulator && pinSIG !== null) {
let riseTimeMs = -1;
// Self-calibrating pulse range: the PIO clock divider may not match
// exactly, producing pulses offset from the standard 544-2400µs range.
// Track the minimum observed pulse (= 0° reference) and map using the
// known standard spread (MAX_PULSE_US - MIN_PULSE_US = 1856µs).
let observedMin = Infinity;
const EXPECTED_SPREAD = MAX_PULSE_US - MIN_PULSE_US; // 1856
avrSimulator.onPinChangeWithTime = (pin, state, timeMs) => {
if (pin !== pinSIG) return;
if (state) {
riseTimeMs = timeMs;
} else if (riseTimeMs >= 0) {
const pulseUs = (timeMs - riseTimeMs) * 1000;
riseTimeMs = -1;
// Reject noise: only consider pulses in a reasonable servo range
if (pulseUs < 100 || pulseUs > 25000) return;
// Update calibration baseline
if (pulseUs < observedMin) observedMin = pulseUs;
// Try standard range first
if (pulseUs >= MIN_PULSE_US && pulseUs <= MAX_PULSE_US) {
const angle = Math.round(((pulseUs - MIN_PULSE_US) / EXPECTED_SPREAD) * 180);
el.angle = Math.max(0, Math.min(180, angle));
} else if (observedMin < Infinity) {
// Self-calibrated range: use observedMin as 0° reference
const rangeMax = observedMin + EXPECTED_SPREAD;
if (pulseUs >= observedMin - 50 && pulseUs <= rangeMax + 200) {
const angle = Math.round(((pulseUs - observedMin) / EXPECTED_SPREAD) * 180);
el.angle = Math.max(0, Math.min(180, angle));
}
}
}
};
return () => {
avrSimulator.onPinChangeWithTime = null;
};
}
// ── ESP32 path: subscribe to LEDC PWM duty updates via PinManager ──
// Esp32BridgeShim has pinManager but getCurrentCycles() returns -1
// (no local CPU cycle counter — QEMU runs on the backend).
if (pinSIG !== null && !(avrSimulator instanceof RP2040Simulator)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pinManager = (avrSimulator as any).pinManager as
| import('../PinManager').PinManager
| undefined;
const hasCpuCycles =
typeof (avrSimulator as any).getCurrentCycles === 'function' &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(avrSimulator as any).getCurrentCycles() >= 0;
if (pinManager && !hasCpuCycles) {
// ESP32 Servo.h uses 50Hz PWM with pulse 544-2400µs
// dutyCycle here is 0.0-1.0 (fraction of PWM period = 20ms)
// 544µs = 2.72%, 2400µs = 12.0%
const MIN_DC = MIN_PULSE_US / 20000; // 0.0272
const MAX_DC = MAX_PULSE_US / 20000; // 0.12
const unsubscribe = pinManager.onPwmChange(pinSIG, (_pin, dutyCycle) => {
if (dutyCycle < 0.01 || dutyCycle > 0.2) return; // ignore out-of-range
const angle = Math.round(((dutyCycle - MIN_DC) / (MAX_DC - MIN_DC)) * 180);
el.angle = Math.max(0, Math.min(180, angle));
});
return () => {
unsubscribe();
};
}
}
// ── AVR primary: cycle-accurate pulse width measurement ────────────
if (pinSIG !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pinManager = (avrSimulator as any).pinManager as
| import('../PinManager').PinManager
| undefined;
if (pinManager) {
let riseTime = -1; // cycle count at last rising edge
const getCycles = () =>
typeof (avrSimulator as any).getCurrentCycles === 'function'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
((avrSimulator as any).getCurrentCycles() as number)
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
(((avrSimulator as any).cpu?.cycles ?? 0) as number);
const clockHz =
typeof (avrSimulator as any).getClockHz === 'function'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
((avrSimulator as any).getClockHz() as number)
: CPU_HZ;
const unsubscribe = pinManager.onPinChange(pinSIG, (_pin, state) => {
if (state) {
riseTime = getCycles();
} else if (riseTime >= 0) {
const pulseCycles = getCycles() - riseTime;
const pulseUs = (pulseCycles / clockHz) * 1_000_000;
riseTime = -1;
if (pulseUs >= MIN_PULSE_US && pulseUs <= MAX_PULSE_US) {
const angle = Math.round(
((pulseUs - MIN_PULSE_US) / (MAX_PULSE_US - MIN_PULSE_US)) * 180,
);
el.angle = angle;
}
}
});
return () => {
unsubscribe();
};
}
}
// ── Fallback: poll OCR1A/ICR1 registers when no wire is connected ──
// OCR1A low byte = 0x88, high byte = 0x89
// ICR1L = 0x86, ICR1H = 0x87
const OCR1AL = 0x88;
const OCR1AH = 0x89;
const ICR1L = 0x86;
const ICR1H = 0x87;
const SERVO_PERIOD_US = 20000;
let rafId: number | null = null;
let lastOcr1a = -1;
const poll = () => {
if (!avrSimulator.isRunning()) {
rafId = requestAnimationFrame(poll);
return;
}
const cpu = (avrSimulator as any).cpu;
if (!cpu) {
rafId = requestAnimationFrame(poll);
return;
}
const ocr1a = cpu.data[OCR1AL] | (cpu.data[OCR1AH] << 8);
if (ocr1a !== lastOcr1a) {
lastOcr1a = ocr1a;
const icr1 = cpu.data[ICR1L] | (cpu.data[ICR1H] << 8);
let pulseUs: number;
if (icr1 > 0) {
pulseUs = (ocr1a / icr1) * SERVO_PERIOD_US;
} else {
// prescaler 8, 16MHz → 0.5µs per tick
pulseUs = ocr1a * 0.5;
}
const clamped = Math.max(MIN_PULSE_US, Math.min(MAX_PULSE_US, pulseUs));
const angle = Math.round(((clamped - MIN_PULSE_US) / (MAX_PULSE_US - MIN_PULSE_US)) * 180);
el.angle = angle;
}
rafId = requestAnimationFrame(poll);
};
rafId = requestAnimationFrame(poll);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
};
},
});
// ─── Buzzer ──────────────────────────────────────────────────────────────────
/**
* Buzzer uses Web Audio API to generate a tone.
*
* Reads OCR2A (Timer2 CTC mode) to determine frequency:
* f = F_CPU / (2 × prescaler × (OCR2A + 1))
*
* Prescaler detected from TCCR2B[2:0] bits.
* Activates when duty cycle > 0 (pin is driven HIGH).
*/
PartSimulationRegistry.register('buzzer', {
attachEvents: (element, avrSimulator, getArduinoPinHelper, _componentId, getPinResolver) => {
const pinSIG =
getArduinoPinHelper('1') ?? getArduinoPinHelper('+') ?? getArduinoPinHelper('POS');
const pinManager = (avrSimulator as any).pinManager;
// PWM tracking still needs the integer pin number; resolver doesn't
// expose duty. The HIGH/LOW path migrates to PinResolver below.
const useResolver = typeof getPinResolver === 'function';
const sigResolver = useResolver
? getPinResolver!('1') ?? getPinResolver!('+') ?? getPinResolver!('POS')
: null;
let audioCtx: AudioContext | null = null;
let oscillator: OscillatorNode | null = null;
let gainNode: GainNode | null = null;
let isSounding = false;
const el = element as any;
// Timer2 register addresses
const OCR2A = 0xb3;
const TCCR2B = 0xb1;
const F_CPU = 16_000_000;
const prescalerTable: Record<number, number> = {
1: 1,
2: 8,
3: 32,
4: 64,
5: 128,
6: 256,
7: 1024,
};
function getFrequency(cpu: any): number {
const ocr2a = cpu.data[OCR2A] ?? 0;
const tccr2b = cpu.data[TCCR2B] ?? 0;
const csField = tccr2b & 0x07;
const prescaler = prescalerTable[csField] ?? 64;
// CTC mode: f = F_CPU / (2 × prescaler × (OCR2A + 1))
return F_CPU / (2 * prescaler * (ocr2a + 1));
}
function startTone(freq: number) {
if (!audioCtx) {
audioCtx = new AudioContext();
gainNode = audioCtx.createGain();
gainNode.gain.value = 0.1;
gainNode.connect(audioCtx.destination);
}
// Browser autoplay policy: AudioContext starts in 'suspended' state
// until a user gesture has occurred. Resume it here so sound plays.
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
if (oscillator) {
oscillator.frequency.setTargetAtTime(freq, audioCtx.currentTime, 0.01);
return;
}
oscillator = audioCtx.createOscillator();
oscillator.type = 'square';
oscillator.frequency.value = freq;
oscillator.connect(gainNode!);
oscillator.start();
isSounding = true;
if (el.playing !== undefined) el.playing = true;
}
function stopTone() {
if (oscillator) {
oscillator.stop();
oscillator.disconnect();
oscillator = null;
}
isSounding = false;
if (el.playing !== undefined) el.playing = false;
}
// Poll via PWM duty cycle on the buzzer pin
const unsubscribers: (() => void)[] = [];
if (pinSIG !== null && pinManager) {
unsubscribers.push(
pinManager.onPwmChange(pinSIG, (_: number, dc: number) => {
const cpu = (avrSimulator as any).cpu;
if (dc > 0) {
const freq = cpu ? getFrequency(cpu) : 440;
startTone(Math.max(20, Math.min(20000, freq)));
} else {
stopTone();
}
}),
);
// Also respond to digital HIGH/LOW (tone() toggles the pin).
// Prefer the resolver — a buzzer driven through a transistor sees
// the real collector voltage and threshold-converts via the board
// logic family.
if (sigResolver) {
unsubscribers.push(
sigResolver.onChange((state) => {
if (!isSounding && state === 'HIGH') {
const cpu = (avrSimulator as any).cpu;
const freq = cpu ? getFrequency(cpu) : 440;
startTone(Math.max(20, Math.min(20000, freq)));
}
// tone() produces a square wave — don't stop on every LOW;
// stop only when duty drops to 0 via onPwmChange.
}),
);
} else {
unsubscribers.push(
pinManager.onPinChange(pinSIG, (_: number, state: boolean) => {
if (!isSounding && state) {
const cpu = (avrSimulator as any).cpu;
const freq = cpu ? getFrequency(cpu) : 440;
startTone(Math.max(20, Math.min(20000, freq)));
}
}),
);
}
}
return () => {
stopTone();
if (audioCtx) {
audioCtx.close();
audioCtx = null;
}
unsubscribers.forEach((u) => u());
};
},
});
// ─── LCD 1602 / 2004 ─────────────────────────────────────────────────────────
function createLcdSimulation(cols: number, rows: number) {
return {
attachEvents: (
element: HTMLElement,
avrSimulator: AnySimulator,
getArduinoPinHelper: (pin: string) => number | null,
) => {
const el = element as any;
const ddram = new Uint8Array(128).fill(0x20);
let ddramAddress = 0;
let entryIncrement = true;
let displayOn = true;
let cursorOn = false;
let blinkOn = false;
let nibbleState: 'high' | 'low' = 'high';
let highNibble = 0;
let initialized = false;
let initCount = 0;
let rsState = false;
let eState = false;
let d4State = false;
let d5State = false;
let d6State = false;
let d7State = false;
const lineOffsets = rows >= 4 ? [0x00, 0x40, 0x14, 0x54] : [0x00, 0x40];
function ddramToLinear(addr: number): number {
for (let row = 0; row < rows; row++) {
const offset = lineOffsets[row];
if (addr >= offset && addr < offset + cols) {
return row * cols + (addr - offset);
}
}
return -1;
}
function refreshDisplay() {
if (!displayOn) {
el.characters = new Uint8Array(cols * rows).fill(0x20);
return;
}
const chars = new Uint8Array(cols * rows);
for (let row = 0; row < rows; row++) {
const offset = lineOffsets[row];
for (let col = 0; col < cols; col++) {
chars[row * cols + col] = ddram[offset + col];
}
}
el.characters = chars;
el.cursor = cursorOn;
el.blink = blinkOn;
const cursorLinear = ddramToLinear(ddramAddress);
if (cursorLinear >= 0) {
el.cursorX = cursorLinear % cols;
el.cursorY = Math.floor(cursorLinear / cols);
}
}
function processByte(rs: boolean, data: number) {
if (!rs) {
if (data & 0x80) {
ddramAddress = data & 0x7f;
} else if (data & 0x40) {
// CGRAM — not implemented
} else if (data & 0x20) {
initialized = true;
} else if (data & 0x10) {
const sc = (data >> 3) & 1;
const rl = (data >> 2) & 1;
if (!sc) {
ddramAddress = (ddramAddress + (rl ? 1 : -1)) & 0x7f;
}
} else if (data & 0x08) {
displayOn = !!(data & 0x04);
cursorOn = !!(data & 0x02);
blinkOn = !!(data & 0x01);
} else if (data & 0x04) {
entryIncrement = !!(data & 0x02);
} else if (data & 0x02) {
ddramAddress = 0;
} else if (data & 0x01) {
ddram.fill(0x20);
ddramAddress = 0;
}
} else {
ddram[ddramAddress & 0x7f] = data;
ddramAddress = entryIncrement ? (ddramAddress + 1) & 0x7f : (ddramAddress - 1) & 0x7f;
}
refreshDisplay();
}
function onEnableFallingEdge() {
const nibble =
(d4State ? 0x01 : 0) | (d5State ? 0x02 : 0) | (d6State ? 0x04 : 0) | (d7State ? 0x08 : 0);
if (!initialized) {
initCount++;
if (initCount >= 4) {
initialized = true;
nibbleState = 'high';
}
return;
}
if (nibbleState === 'high') {
highNibble = nibble << 4;
nibbleState = 'low';
} else {
processByte(rsState, highNibble | nibble);
nibbleState = 'high';
}
}
const pinRS = getArduinoPinHelper('RS');
const pinE = getArduinoPinHelper('E');
const pinD4 = getArduinoPinHelper('D4');
const pinD5 = getArduinoPinHelper('D5');
const pinD6 = getArduinoPinHelper('D6');
const pinD7 = getArduinoPinHelper('D7');
const pinManager = (avrSimulator as any).pinManager;
if (!pinManager) return () => {};
const unsubscribers: (() => void)[] = [];
if (pinRS !== null)
unsubscribers.push(
pinManager.onPinChange(pinRS, (_: number, s: boolean) => {
rsState = s;
}),
);
if (pinD4 !== null)
unsubscribers.push(
pinManager.onPinChange(pinD4, (_: number, s: boolean) => {
d4State = s;
}),
);
if (pinD5 !== null)
unsubscribers.push(
pinManager.onPinChange(pinD5, (_: number, s: boolean) => {
d5State = s;
}),
);
if (pinD6 !== null)
unsubscribers.push(
pinManager.onPinChange(pinD6, (_: number, s: boolean) => {
d6State = s;
}),
);
if (pinD7 !== null)
unsubscribers.push(
pinManager.onPinChange(pinD7, (_: number, s: boolean) => {
d7State = s;
}),
);
if (pinE !== null) {
unsubscribers.push(
pinManager.onPinChange(pinE, (_: number, s: boolean) => {
const wasHigh = eState;
eState = s;
if (wasHigh && !s) onEnableFallingEdge();
}),
);
}
refreshDisplay();
return () => {
unsubscribers.forEach((u) => u());
};
},
};
}
PartSimulationRegistry.register('lcd1602', createLcdSimulation(16, 2));
PartSimulationRegistry.register('lcd2004', createLcdSimulation(20, 4));
PartSimulationRegistry.register('lcd2002', createLcdSimulation(20, 2));
// ─── ILI9341 TFT Display (SPI) ───────────────────────────────────────────────
/**
* ILI9341 TFT display simulation via hardware SPI.
*
* Intercepts writes to SPDR (via AVRSPI) and decodes ILI9341 commands:
* - 0x2A CASET set column address window
* - 0x2B PASET set page (row) address window
* - 0x2C RAMWR stream RGB-565 pixel data
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently ignored every command except CASET/PASET/RAMWR/SWRESET. The block comment even bragged about it ("All others are silently accepted — init sequences, DISPON, MADCTL…"). That's fine for portrait sketches, but every landscape demo — including the new Pico Doom raycaster — calls tft.setRotation(1) or setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36 with the MV (row/column exchange) bit set, then issues CASET windows with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's bounds check `curX > colEnd` would let curX reach 319, but the buffer write `id.data[(curY*240 + curX)*4]` would land in a slot that belongs to a different row — and worse, the SCREEN_W=240 ceiling silently truncated everything past column 239. Net result: black screen for any rotated sketch. Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates. At pixel-write time, remap (curX, curY) → physical (px, py) using the MV/MX/MY bits, then write into the still-physical 240×320 imageData. SWRESET resets MADCTL back to portrait defaults (matches the datasheet's reset semantics). MADCTL bit Mask Meaning D7 MY 0x80 row mirror D6 MX 0x40 column mirror D5 MV 0x20 swap X/Y (landscape) Verified by rebuilding (vite OSS+pro). The fix is data-flow only — no API change, no new dependency. Pico Doom should now actually render its title screen + raycast frames in /examples on the raspberry-pi-pico board. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
* - 0x36 MADCTL memory access control (rotation MV / MX / MY bits)
* - 0x01 SWRESET clear display
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently ignored every command except CASET/PASET/RAMWR/SWRESET. The block comment even bragged about it ("All others are silently accepted — init sequences, DISPON, MADCTL…"). That's fine for portrait sketches, but every landscape demo — including the new Pico Doom raycaster — calls tft.setRotation(1) or setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36 with the MV (row/column exchange) bit set, then issues CASET windows with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's bounds check `curX > colEnd` would let curX reach 319, but the buffer write `id.data[(curY*240 + curX)*4]` would land in a slot that belongs to a different row — and worse, the SCREEN_W=240 ceiling silently truncated everything past column 239. Net result: black screen for any rotated sketch. Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates. At pixel-write time, remap (curX, curY) → physical (px, py) using the MV/MX/MY bits, then write into the still-physical 240×320 imageData. SWRESET resets MADCTL back to portrait defaults (matches the datasheet's reset semantics). MADCTL bit Mask Meaning D7 MY 0x80 row mirror D6 MX 0x40 column mirror D5 MV 0x20 swap X/Y (landscape) Verified by rebuilding (vite OSS+pro). The fix is data-flow only — no API change, no new dependency. Pico Doom should now actually render its title screen + raycast frames in /examples on the raspberry-pi-pico board. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
* - All others are silently accepted (DISPON, COLMOD, )
*
* Coordinates in CASET/PASET are LOGICAL driver libraries (Adafruit_
* ILI9341 etc.) call `setRotation(1|3)` which emits MADCTL with MV set
* and then writes CASET in 0..319 / PASET in 0..239. The emulator keeps
* the underlying canvas at the panel's native 240×320 and remaps each
* pixel through MV/MX/MY at write time. Without this, every landscape
* sketch (rotation 1 or 3) used to render to nothing because the X
* bound check filtered out anything past column 239.
*
* DC/RS pin: LOW = command byte, HIGH = data bytes.
*/
const ili9341Simulation = {
refactor(spi): unify SPI bus interface across all simulators Previous fix added an ESP32-specific code path inside ili9341Simulation to subscribe to the QEMU worker's spi_event stream. That made the LCD work on ESP32-CAM but left the underlying issue unsolved: every other SPI part (custom chips, future SD-card emulators, the SSD168x ePaper already in the codebase) would also need its own per-board branching. The right shape: every simulator exposes a `.spi` member matching the SAME SpiBusLike interface, and SPI parts hook .spi.onByte without caring which board they're attached to. AVRSimulator already had this — now everything else does too. frontend/src/simulation/SpiBus.ts (new) Defines the contract — `onByte: (mosi) => void | null` plus optional `completeTransfer(miso)`. Documents the single-listener semantics that AVR has had since day one. frontend/src/store/useSimulatorStore.ts Esp32BridgeShim gets a lazy `.spi` getter that wraps bridge.onSpiByte (the per-byte WS event from the QEMU worker). completeTransfer is a no-op because the worker drives MISO via its own _spi_response global. Covers ESP32 (Xtensa), ESP32-S3, ESP32-CAM, ESP32-C3 — every kind that routes through Esp32Bridge. frontend/src/simulation/RP2040Simulator.ts Adds a lazy `.spi` getter that re-routes rp2040.spi[0].onTransmit through the adapter. Default loopback (the prior behaviour) is preserved when no part has accessed `.spi` yet — only consumers that opt in see their handler invoked. Covers Pico and Pico W. frontend/src/simulation/parts/ComplexParts.ts ili9341Simulation no longer has an ESP32 special case. Single code path: `simulator.spi.onByte = handler`. Works on AVR, RP2040, all ESP32 variants. Same pattern is now available to every future SPI part — ssd1306, sd-card, oled, etc. The Esp32Bridge.ts spi_event field-name fix from 6afa62e (msg.data.event instead of the non-existent msg.data.data) stays in place — that's what makes the per-byte stream actually arrive in the bridge. Verified: ILI9341 + ESP32-CAM gallery example renders the live webcam preview after a hard refresh. The same simulation code works on Arduino Uno + ILI9341 (the existing ili9341-test-sketch in example_zip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:36:29 +07:00
attachEvents: (element, simulator, getArduinoPinHelper) => {
const el = element as any;
refactor(spi): unify SPI bus interface across all simulators Previous fix added an ESP32-specific code path inside ili9341Simulation to subscribe to the QEMU worker's spi_event stream. That made the LCD work on ESP32-CAM but left the underlying issue unsolved: every other SPI part (custom chips, future SD-card emulators, the SSD168x ePaper already in the codebase) would also need its own per-board branching. The right shape: every simulator exposes a `.spi` member matching the SAME SpiBusLike interface, and SPI parts hook .spi.onByte without caring which board they're attached to. AVRSimulator already had this — now everything else does too. frontend/src/simulation/SpiBus.ts (new) Defines the contract — `onByte: (mosi) => void | null` plus optional `completeTransfer(miso)`. Documents the single-listener semantics that AVR has had since day one. frontend/src/store/useSimulatorStore.ts Esp32BridgeShim gets a lazy `.spi` getter that wraps bridge.onSpiByte (the per-byte WS event from the QEMU worker). completeTransfer is a no-op because the worker drives MISO via its own _spi_response global. Covers ESP32 (Xtensa), ESP32-S3, ESP32-CAM, ESP32-C3 — every kind that routes through Esp32Bridge. frontend/src/simulation/RP2040Simulator.ts Adds a lazy `.spi` getter that re-routes rp2040.spi[0].onTransmit through the adapter. Default loopback (the prior behaviour) is preserved when no part has accessed `.spi` yet — only consumers that opt in see their handler invoked. Covers Pico and Pico W. frontend/src/simulation/parts/ComplexParts.ts ili9341Simulation no longer has an ESP32 special case. Single code path: `simulator.spi.onByte = handler`. Works on AVR, RP2040, all ESP32 variants. Same pattern is now available to every future SPI part — ssd1306, sd-card, oled, etc. The Esp32Bridge.ts spi_event field-name fix from 6afa62e (msg.data.event instead of the non-existent msg.data.data) stays in place — that's what makes the per-byte stream actually arrive in the bridge. Verified: ILI9341 + ESP32-CAM gallery example renders the live webcam preview after a hard refresh. The same simulation code works on Arduino Uno + ILI9341 (the existing ili9341-test-sketch in example_zip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:36:29 +07:00
const pinManager = (simulator as any).pinManager;
// Generic .spi accessor — every simulator (AVR, RP2040, ESP32 family)
// exposes a SpiBusLike object via this name (see frontend/src/simulation/
// SpiBus.ts). Single-listener channel: assign to spi.onByte and
// chain any prior handler in our cleanup.
const spi = (simulator as any).spi as
| { onByte: ((mosi: number) => void) | null;
completeTransfer?: (miso: number) => void }
| undefined;
if (!pinManager || !spi) return () => {};
// ── Canvas setup ──────────────────────────────────────────────────
const SCREEN_W = 240;
const SCREEN_H = 320;
const initCanvas = (): CanvasRenderingContext2D | null => {
// el.canvas is the getter defined in ili9341-element.ts:
// get canvas() { return this.shadowRoot?.querySelector('canvas'); }
// The element already sets width=240 height=320 in its LitElement template.
const canvas = el.canvas as HTMLCanvasElement | null;
if (!canvas) return null;
return canvas.getContext('2d');
};
let ctx = initCanvas();
const onCanvasReady = () => {
ctx = initCanvas();
};
el.addEventListener('canvas-ready', onCanvasReady);
// ── Shared ImageData buffer ───────────────────────────────────────
// Accumulate pixels here; flush to canvas once per animation frame.
let imageData: ImageData | null = null;
const getOrCreateImageData = (): ImageData => {
if (!ctx) ctx = initCanvas();
if (!imageData && ctx) imageData = ctx.createImageData(SCREEN_W, SCREEN_H);
return imageData!;
};
// Flush is debounced rather than rAF-pinned: TFT firmwares emit each
// frame as one long SPI burst that often takes >16 ms to drain
// (rp2040js is sub-realtime), so painting every rAF would snapshot
// the canvas mid-burst — the user would see only the pixels that
// happened to land before that tick. We instead wait for SPI silence
// (a real frame boundary), bounded by a hard cap so continuous-write
// sketches still update.
let pendingFlush = false;
let idleTimerId: number | null = null;
let firstWriteSinceFlush = 0;
const IDLE_FLUSH_MS = 16;
const MAX_FLUSH_INTERVAL_MS = 100;
const doFlush = () => {
if (idleTimerId !== null) {
clearTimeout(idleTimerId);
idleTimerId = null;
}
if (pendingFlush && ctx && imageData) {
ctx.putImageData(imageData, 0, 0);
pendingFlush = false;
firstWriteSinceFlush = 0;
}
};
const scheduleFlush = () => {
if (!pendingFlush) return;
const now = performance.now();
if (firstWriteSinceFlush === 0) firstWriteSinceFlush = now;
if (now - firstWriteSinceFlush >= MAX_FLUSH_INTERVAL_MS) {
doFlush();
return;
}
if (idleTimerId !== null) clearTimeout(idleTimerId);
idleTimerId = window.setTimeout(doFlush, IDLE_FLUSH_MS);
};
// ── ILI9341 state ─────────────────────────────────────────────────
let colStart = 0,
colEnd = SCREEN_W - 1;
let rowStart = 0,
rowEnd = SCREEN_H - 1;
let curX = 0,
curY = 0;
let currentCmd = -1;
let dataBytes: number[] = [];
let inRamWrite = false;
let pixelHiByte = 0;
let pixelByteCount = 0;
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently ignored every command except CASET/PASET/RAMWR/SWRESET. The block comment even bragged about it ("All others are silently accepted — init sequences, DISPON, MADCTL…"). That's fine for portrait sketches, but every landscape demo — including the new Pico Doom raycaster — calls tft.setRotation(1) or setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36 with the MV (row/column exchange) bit set, then issues CASET windows with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's bounds check `curX > colEnd` would let curX reach 319, but the buffer write `id.data[(curY*240 + curX)*4]` would land in a slot that belongs to a different row — and worse, the SCREEN_W=240 ceiling silently truncated everything past column 239. Net result: black screen for any rotated sketch. Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates. At pixel-write time, remap (curX, curY) → physical (px, py) using the MV/MX/MY bits, then write into the still-physical 240×320 imageData. SWRESET resets MADCTL back to portrait defaults (matches the datasheet's reset semantics). MADCTL bit Mask Meaning D7 MY 0x80 row mirror D6 MX 0x40 column mirror D5 MV 0x20 swap X/Y (landscape) Verified by rebuilding (vite OSS+pro). The fix is data-flow only — no API change, no new dependency. Pico Doom should now actually render its title screen + raycast frames in /examples on the raspberry-pi-pico board. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
// ── MADCTL state ──────────────────────────────────────────────────
// ILI9341 0x36 command bits we care about (datasheet §8.2.29). Set
// by setRotation() in every Adafruit-style driver; default is
// rotation 0 = all bits clear (portrait, no swap, no mirror).
let madMV = false; // row/column exchange — landscape orientation
let madMX = false; // column address mirror
let madMY = false; // row address mirror
// ── DC pin tracking ───────────────────────────────────────────────
let dcState = false; // LOW = command, HIGH = data
const pinDC = getArduinoPinHelper('D/C');
const unsubscribers: (() => void)[] = [];
if (pinDC !== null) {
unsubscribers.push(
pinManager.onPinChange(pinDC, (_: number, s: boolean) => {
dcState = s;
}),
);
}
// ── Pixel writer ──────────────────────────────────────────────────
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently ignored every command except CASET/PASET/RAMWR/SWRESET. The block comment even bragged about it ("All others are silently accepted — init sequences, DISPON, MADCTL…"). That's fine for portrait sketches, but every landscape demo — including the new Pico Doom raycaster — calls tft.setRotation(1) or setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36 with the MV (row/column exchange) bit set, then issues CASET windows with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's bounds check `curX > colEnd` would let curX reach 319, but the buffer write `id.data[(curY*240 + curX)*4]` would land in a slot that belongs to a different row — and worse, the SCREEN_W=240 ceiling silently truncated everything past column 239. Net result: black screen for any rotated sketch. Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates. At pixel-write time, remap (curX, curY) → physical (px, py) using the MV/MX/MY bits, then write into the still-physical 240×320 imageData. SWRESET resets MADCTL back to portrait defaults (matches the datasheet's reset semantics). MADCTL bit Mask Meaning D7 MY 0x80 row mirror D6 MX 0x40 column mirror D5 MV 0x20 swap X/Y (landscape) Verified by rebuilding (vite OSS+pro). The fix is data-flow only — no API change, no new dependency. Pico Doom should now actually render its title screen + raycast frames in /examples on the raspberry-pi-pico board. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
// curX / curY / col* / row* are LOGICAL coordinates — the values the
// driver thinks it's writing to. In rotation 0 logical = physical.
// In rotation 1/3 (MV set) the driver iterates X in 0..319 and Y in
// 0..239; we swap them at the last possible moment before touching
// the imageData buffer (which is always physically 240 wide × 320 tall).
//
// The mapping is rotation-specific because applying MX/MY/MV as three
// independent flags double-mirrors the output (we tried that in
// commit 6edc715 and the user saw "espejada" text). The four
// Adafruit_ILI9341 setRotation() values map cleanly to four explicit
// (curX, curY) → (physX, physY) formulae taken from the chip's
// datasheet section 8.2.29 (Memory Access Control):
//
// rot 0 M=0x48 (MX|BGR) : (curX, curY) [portrait]
// rot 1 M=0x28 (MV|BGR) : (curY, (319 - curX)) [landscape]
// rot 2 M=0x88 (MY|BGR) : ((239 - curX), (319 - curY)) [portrait flipped]
// rot 3 M=0xE8 (MX|MY|MV|BGR) : ((239 - curY), curX) [landscape flipped]
//
// The Adafruit driver computes the rotation register value, sends it
// once via MADCTL, then writes pixels in the rotated framebuffer's
// coordinate space — we mirror that on the receive side.
const writePixel = (hi: number, lo: number) => {
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently ignored every command except CASET/PASET/RAMWR/SWRESET. The block comment even bragged about it ("All others are silently accepted — init sequences, DISPON, MADCTL…"). That's fine for portrait sketches, but every landscape demo — including the new Pico Doom raycaster — calls tft.setRotation(1) or setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36 with the MV (row/column exchange) bit set, then issues CASET windows with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's bounds check `curX > colEnd` would let curX reach 319, but the buffer write `id.data[(curY*240 + curX)*4]` would land in a slot that belongs to a different row — and worse, the SCREEN_W=240 ceiling silently truncated everything past column 239. Net result: black screen for any rotated sketch. Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates. At pixel-write time, remap (curX, curY) → physical (px, py) using the MV/MX/MY bits, then write into the still-physical 240×320 imageData. SWRESET resets MADCTL back to portrait defaults (matches the datasheet's reset semantics). MADCTL bit Mask Meaning D7 MY 0x80 row mirror D6 MX 0x40 column mirror D5 MV 0x20 swap X/Y (landscape) Verified by rebuilding (vite OSS+pro). The fix is data-flow only — no API change, no new dependency. Pico Doom should now actually render its title screen + raycast frames in /examples on the raspberry-pi-pico board. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
if (curX > colEnd || curY > rowEnd) return;
// Map logical → physical via the (MV, MX, MY) rotation signature.
let physX: number, physY: number;
if (!madMV) {
// Portrait (rotations 0 or 2)
physX = madMY ? (SCREEN_W - 1) - curX : curX;
physY = madMY ? (SCREEN_H - 1) - curY : curY;
} else if (!madMX && !madMY) {
// Landscape rotation 1: m = MV | BGR. (curY, 319 - curX)
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently ignored every command except CASET/PASET/RAMWR/SWRESET. The block comment even bragged about it ("All others are silently accepted — init sequences, DISPON, MADCTL…"). That's fine for portrait sketches, but every landscape demo — including the new Pico Doom raycaster — calls tft.setRotation(1) or setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36 with the MV (row/column exchange) bit set, then issues CASET windows with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's bounds check `curX > colEnd` would let curX reach 319, but the buffer write `id.data[(curY*240 + curX)*4]` would land in a slot that belongs to a different row — and worse, the SCREEN_W=240 ceiling silently truncated everything past column 239. Net result: black screen for any rotated sketch. Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates. At pixel-write time, remap (curX, curY) → physical (px, py) using the MV/MX/MY bits, then write into the still-physical 240×320 imageData. SWRESET resets MADCTL back to portrait defaults (matches the datasheet's reset semantics). MADCTL bit Mask Meaning D7 MY 0x80 row mirror D6 MX 0x40 column mirror D5 MV 0x20 swap X/Y (landscape) Verified by rebuilding (vite OSS+pro). The fix is data-flow only — no API change, no new dependency. Pico Doom should now actually render its title screen + raycast frames in /examples on the raspberry-pi-pico board. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
physX = curY;
physY = (SCREEN_H - 1) - curX;
} else {
// Landscape rotation 3: m = MX | MY | MV | BGR. (239 - curY, curX)
physX = (SCREEN_W - 1) - curY;
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently ignored every command except CASET/PASET/RAMWR/SWRESET. The block comment even bragged about it ("All others are silently accepted — init sequences, DISPON, MADCTL…"). That's fine for portrait sketches, but every landscape demo — including the new Pico Doom raycaster — calls tft.setRotation(1) or setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36 with the MV (row/column exchange) bit set, then issues CASET windows with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's bounds check `curX > colEnd` would let curX reach 319, but the buffer write `id.data[(curY*240 + curX)*4]` would land in a slot that belongs to a different row — and worse, the SCREEN_W=240 ceiling silently truncated everything past column 239. Net result: black screen for any rotated sketch. Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates. At pixel-write time, remap (curX, curY) → physical (px, py) using the MV/MX/MY bits, then write into the still-physical 240×320 imageData. SWRESET resets MADCTL back to portrait defaults (matches the datasheet's reset semantics). MADCTL bit Mask Meaning D7 MY 0x80 row mirror D6 MX 0x40 column mirror D5 MV 0x20 swap X/Y (landscape) Verified by rebuilding (vite OSS+pro). The fix is data-flow only — no API change, no new dependency. Pico Doom should now actually render its title screen + raycast frames in /examples on the raspberry-pi-pico board. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
physY = curX;
}
if (physX < 0 || physX >= SCREEN_W || physY < 0 || physY >= SCREEN_H) {
curX++;
if (curX > colEnd) {
curX = colStart;
curY++;
}
return;
}
const id = getOrCreateImageData();
const color = (hi << 8) | lo;
const r = ((color >> 11) & 0x1f) * 8;
const g = ((color >> 5) & 0x3f) * 4;
const b = (color & 0x1f) * 8;
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently ignored every command except CASET/PASET/RAMWR/SWRESET. The block comment even bragged about it ("All others are silently accepted — init sequences, DISPON, MADCTL…"). That's fine for portrait sketches, but every landscape demo — including the new Pico Doom raycaster — calls tft.setRotation(1) or setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36 with the MV (row/column exchange) bit set, then issues CASET windows with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's bounds check `curX > colEnd` would let curX reach 319, but the buffer write `id.data[(curY*240 + curX)*4]` would land in a slot that belongs to a different row — and worse, the SCREEN_W=240 ceiling silently truncated everything past column 239. Net result: black screen for any rotated sketch. Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates. At pixel-write time, remap (curX, curY) → physical (px, py) using the MV/MX/MY bits, then write into the still-physical 240×320 imageData. SWRESET resets MADCTL back to portrait defaults (matches the datasheet's reset semantics). MADCTL bit Mask Meaning D7 MY 0x80 row mirror D6 MX 0x40 column mirror D5 MV 0x20 swap X/Y (landscape) Verified by rebuilding (vite OSS+pro). The fix is data-flow only — no API change, no new dependency. Pico Doom should now actually render its title screen + raycast frames in /examples on the raspberry-pi-pico board. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
const idx = (physY * SCREEN_W + physX) * 4;
id.data[idx] = r;
id.data[idx + 1] = g;
id.data[idx + 2] = b;
id.data[idx + 3] = 255;
pendingFlush = true;
curX++;
if (curX > colEnd) {
curX = colStart;
curY++;
}
};
// ── Command / data processing ─────────────────────────────────────
const processCommand = (cmd: number) => {
currentCmd = cmd;
dataBytes = [];
inRamWrite = cmd === 0x2c;
pixelByteCount = 0;
if (cmd === 0x01) {
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently ignored every command except CASET/PASET/RAMWR/SWRESET. The block comment even bragged about it ("All others are silently accepted — init sequences, DISPON, MADCTL…"). That's fine for portrait sketches, but every landscape demo — including the new Pico Doom raycaster — calls tft.setRotation(1) or setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36 with the MV (row/column exchange) bit set, then issues CASET windows with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's bounds check `curX > colEnd` would let curX reach 319, but the buffer write `id.data[(curY*240 + curX)*4]` would land in a slot that belongs to a different row — and worse, the SCREEN_W=240 ceiling silently truncated everything past column 239. Net result: black screen for any rotated sketch. Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates. At pixel-write time, remap (curX, curY) → physical (px, py) using the MV/MX/MY bits, then write into the still-physical 240×320 imageData. SWRESET resets MADCTL back to portrait defaults (matches the datasheet's reset semantics). MADCTL bit Mask Meaning D7 MY 0x80 row mirror D6 MX 0x40 column mirror D5 MV 0x20 swap X/Y (landscape) Verified by rebuilding (vite OSS+pro). The fix is data-flow only — no API change, no new dependency. Pico Doom should now actually render its title screen + raycast frames in /examples on the raspberry-pi-pico board. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
// SWRESET clear framebuffer + reset MADCTL to defaults
colStart = 0;
colEnd = SCREEN_W - 1;
rowStart = 0;
rowEnd = SCREEN_H - 1;
curX = 0;
curY = 0;
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently ignored every command except CASET/PASET/RAMWR/SWRESET. The block comment even bragged about it ("All others are silently accepted — init sequences, DISPON, MADCTL…"). That's fine for portrait sketches, but every landscape demo — including the new Pico Doom raycaster — calls tft.setRotation(1) or setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36 with the MV (row/column exchange) bit set, then issues CASET windows with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's bounds check `curX > colEnd` would let curX reach 319, but the buffer write `id.data[(curY*240 + curX)*4]` would land in a slot that belongs to a different row — and worse, the SCREEN_W=240 ceiling silently truncated everything past column 239. Net result: black screen for any rotated sketch. Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates. At pixel-write time, remap (curX, curY) → physical (px, py) using the MV/MX/MY bits, then write into the still-physical 240×320 imageData. SWRESET resets MADCTL back to portrait defaults (matches the datasheet's reset semantics). MADCTL bit Mask Meaning D7 MY 0x80 row mirror D6 MX 0x40 column mirror D5 MV 0x20 swap X/Y (landscape) Verified by rebuilding (vite OSS+pro). The fix is data-flow only — no API change, no new dependency. Pico Doom should now actually render its title screen + raycast frames in /examples on the raspberry-pi-pico board. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
madMV = false;
madMX = false;
madMY = false;
imageData = null;
if (ctx) ctx.clearRect(0, 0, SCREEN_W, SCREEN_H);
}
};
const processData = (value: number) => {
if (inRamWrite) {
// RGB-565: two bytes per pixel
if (pixelByteCount === 0) {
pixelHiByte = value;
pixelByteCount = 1;
} else {
writePixel(pixelHiByte, value);
scheduleFlush();
pixelByteCount = 0;
}
return;
}
dataBytes.push(value);
switch (currentCmd) {
case 0x2a: // CASET column address set
if (dataBytes.length === 2) colStart = (dataBytes[0] << 8) | dataBytes[1];
if (dataBytes.length === 4) {
colEnd = (dataBytes[2] << 8) | dataBytes[3];
curX = colStart;
}
break;
case 0x2b: // PASET page address set
if (dataBytes.length === 2) rowStart = (dataBytes[0] << 8) | dataBytes[1];
if (dataBytes.length === 4) {
rowEnd = (dataBytes[2] << 8) | dataBytes[3];
curY = rowStart;
}
break;
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently ignored every command except CASET/PASET/RAMWR/SWRESET. The block comment even bragged about it ("All others are silently accepted — init sequences, DISPON, MADCTL…"). That's fine for portrait sketches, but every landscape demo — including the new Pico Doom raycaster — calls tft.setRotation(1) or setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36 with the MV (row/column exchange) bit set, then issues CASET windows with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's bounds check `curX > colEnd` would let curX reach 319, but the buffer write `id.data[(curY*240 + curX)*4]` would land in a slot that belongs to a different row — and worse, the SCREEN_W=240 ceiling silently truncated everything past column 239. Net result: black screen for any rotated sketch. Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates. At pixel-write time, remap (curX, curY) → physical (px, py) using the MV/MX/MY bits, then write into the still-physical 240×320 imageData. SWRESET resets MADCTL back to portrait defaults (matches the datasheet's reset semantics). MADCTL bit Mask Meaning D7 MY 0x80 row mirror D6 MX 0x40 column mirror D5 MV 0x20 swap X/Y (landscape) Verified by rebuilding (vite OSS+pro). The fix is data-flow only — no API change, no new dependency. Pico Doom should now actually render its title screen + raycast frames in /examples on the raspberry-pi-pico board. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
case 0x36: // MADCTL memory access control (rotation / mirror)
if (dataBytes.length === 1) {
const m = dataBytes[0];
madMY = (m & 0x80) !== 0;
madMX = (m & 0x40) !== 0;
madMV = (m & 0x20) !== 0;
}
break;
// All other commands (DISPON, COLMOD…) just buffer data
}
};
refactor(spi): unify SPI bus interface across all simulators Previous fix added an ESP32-specific code path inside ili9341Simulation to subscribe to the QEMU worker's spi_event stream. That made the LCD work on ESP32-CAM but left the underlying issue unsolved: every other SPI part (custom chips, future SD-card emulators, the SSD168x ePaper already in the codebase) would also need its own per-board branching. The right shape: every simulator exposes a `.spi` member matching the SAME SpiBusLike interface, and SPI parts hook .spi.onByte without caring which board they're attached to. AVRSimulator already had this — now everything else does too. frontend/src/simulation/SpiBus.ts (new) Defines the contract — `onByte: (mosi) => void | null` plus optional `completeTransfer(miso)`. Documents the single-listener semantics that AVR has had since day one. frontend/src/store/useSimulatorStore.ts Esp32BridgeShim gets a lazy `.spi` getter that wraps bridge.onSpiByte (the per-byte WS event from the QEMU worker). completeTransfer is a no-op because the worker drives MISO via its own _spi_response global. Covers ESP32 (Xtensa), ESP32-S3, ESP32-CAM, ESP32-C3 — every kind that routes through Esp32Bridge. frontend/src/simulation/RP2040Simulator.ts Adds a lazy `.spi` getter that re-routes rp2040.spi[0].onTransmit through the adapter. Default loopback (the prior behaviour) is preserved when no part has accessed `.spi` yet — only consumers that opt in see their handler invoked. Covers Pico and Pico W. frontend/src/simulation/parts/ComplexParts.ts ili9341Simulation no longer has an ESP32 special case. Single code path: `simulator.spi.onByte = handler`. Works on AVR, RP2040, all ESP32 variants. Same pattern is now available to every future SPI part — ssd1306, sd-card, oled, etc. The Esp32Bridge.ts spi_event field-name fix from 6afa62e (msg.data.event instead of the non-existent msg.data.data) stays in place — that's what makes the per-byte stream actually arrive in the bridge. Verified: ILI9341 + ESP32-CAM gallery example renders the live webcam preview after a hard refresh. The same simulation code works on Arduino Uno + ILI9341 (the existing ili9341-test-sketch in example_zip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:36:29 +07:00
// ── Intercept SPI (board-agnostic) ────────────────────────────────
// Single hook regardless of board kind: every simulator's `.spi`
// exposes the same shape — settable onByte handler + optional
// completeTransfer to drive MISO. AVR and RP2040 actually use
// completeTransfer; ESP32 ignores it (worker drives MISO via
// its own _spi_response global).
const prevOnByte = spi.onByte;
spi.onByte = (value: number) => {
if (!dcState) processCommand(value);
else processData(value);
// Idle-byte response — the typical ILI9341 driver writes only,
// so any value works. 0xff matches what the prior AVR path
// returned to keep behaviour stable.
spi.completeTransfer?.(0xff);
};
// ── Cleanup ───────────────────────────────────────────────────────
return () => {
refactor(spi): unify SPI bus interface across all simulators Previous fix added an ESP32-specific code path inside ili9341Simulation to subscribe to the QEMU worker's spi_event stream. That made the LCD work on ESP32-CAM but left the underlying issue unsolved: every other SPI part (custom chips, future SD-card emulators, the SSD168x ePaper already in the codebase) would also need its own per-board branching. The right shape: every simulator exposes a `.spi` member matching the SAME SpiBusLike interface, and SPI parts hook .spi.onByte without caring which board they're attached to. AVRSimulator already had this — now everything else does too. frontend/src/simulation/SpiBus.ts (new) Defines the contract — `onByte: (mosi) => void | null` plus optional `completeTransfer(miso)`. Documents the single-listener semantics that AVR has had since day one. frontend/src/store/useSimulatorStore.ts Esp32BridgeShim gets a lazy `.spi` getter that wraps bridge.onSpiByte (the per-byte WS event from the QEMU worker). completeTransfer is a no-op because the worker drives MISO via its own _spi_response global. Covers ESP32 (Xtensa), ESP32-S3, ESP32-CAM, ESP32-C3 — every kind that routes through Esp32Bridge. frontend/src/simulation/RP2040Simulator.ts Adds a lazy `.spi` getter that re-routes rp2040.spi[0].onTransmit through the adapter. Default loopback (the prior behaviour) is preserved when no part has accessed `.spi` yet — only consumers that opt in see their handler invoked. Covers Pico and Pico W. frontend/src/simulation/parts/ComplexParts.ts ili9341Simulation no longer has an ESP32 special case. Single code path: `simulator.spi.onByte = handler`. Works on AVR, RP2040, all ESP32 variants. Same pattern is now available to every future SPI part — ssd1306, sd-card, oled, etc. The Esp32Bridge.ts spi_event field-name fix from 6afa62e (msg.data.event instead of the non-existent msg.data.data) stays in place — that's what makes the per-byte stream actually arrive in the bridge. Verified: ILI9341 + ESP32-CAM gallery example renders the live webcam preview after a hard refresh. The same simulation code works on Arduino Uno + ILI9341 (the existing ili9341-test-sketch in example_zip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:36:29 +07:00
spi.onByte = prevOnByte;
if (idleTimerId !== null) clearTimeout(idleTimerId);
el.removeEventListener('canvas-ready', onCanvasReady);
unsubscribers.forEach((u) => u());
};
},
};
PartSimulationRegistry.register('ili9341', ili9341Simulation);
// board-ili9341-cap-touch (Wokwi type) maps to 'ili9341-cap-touch' metadataId — same SPI simulation
PartSimulationRegistry.register('ili9341-cap-touch', ili9341Simulation);