import { RP2040, GPIOPinState, ConsoleLogger, LogLevel, USBCDC } from 'rp2040js'; import type { RPI2C } from 'rp2040js'; import { PinManager } from './PinManager'; import { I2CBusManager, wireRpI2cToBus, nullI2CMaster } from './I2CBusManager'; import type { I2CDevice } from './I2CBusManager'; import { bootromB1 } from './rp2040-bootrom'; import { loadUF2, loadUserFiles, getFirmware } from './MicroPythonLoader'; import { Cyw43Emulator, PioBusSniffer, type Cyw43Bridge, type LedEvent, type PacketOutEvent, } from './cyw43'; /** * RP2040Simulator — Emulates Raspberry Pi Pico (RP2040) using rp2040js * * Features: * - ARM Cortex-M0+ dual-core Cortex-M0+ CPU at 125 MHz (single-core emulated) * - 30 GPIO pins (GPIO0-GPIO29) xc fv nn * - 2× UART, 2× SPI, 2× I2C * - ADC on GPIO26-GPIO29 (A0-A3) + internal temp sensor (ch4) * - PWM on any GPIO * - LED_BUILTIN on GPIO25 * - Full bootrom B1 for proper boot sequence * * Arduino-pico pin mapping (Earle Philhower's core): * D0 = GPIO0 … D29 = GPIO29 * A0 = GPIO26 … A3 = GPIO29 * LED_BUILTIN = GPIO25 * Default Serial → UART0 (GPIO0=TX, GPIO1=RX) * Default I2C → I2C0 (GPIO4=SDA, GPIO5=SCL) * Default SPI → SPI0 (GPIO16=MISO, GPIO19=MOSI, GPIO18=SCK, GPIO17=CS) */ const F_CPU = 125_000_000; // 125 MHz const CYCLE_NANOS = 1e9 / F_CPU; // nanoseconds per cycle (~8 ns) const FPS = 60; const CYCLES_PER_MS = F_CPU / 1000; // 125 000 cycles per simulated millisecond /** Minimal structural view of the rp2040js clock we drive. */ interface SimClock { readonly nanosToNextAlarm: number; tick(nanos: number): void; } // Real-time scheduler. The RP2040 core is ~8x heavier to emulate than the // AVR (125 MHz vs 16 MHz), so a host that cannot execute 125 M instructions // per second of wall-clock would otherwise run the simulation in slow motion: // a `delay(1000)` blink renders every 4-5 s. Two mechanisms keep sim-time // locked to wall-time: // 1. The frame budget is derived from the MEASURED wall-clock delta (like // AVRSimulator), not a fixed 1/60 s, so the sim never silently falls // behind the assumed 60 fps. // 2. A `delay()` busy-wait spins reading the timer without putting the core // to sleep (no WFI), so the WFI fast-path never triggers and the emulator // grinds every idle cycle. IdleSpinDetector recognises such a // side-effect-free spin and we advance the clock over it instead of // executing it — exactly what the WFI path already does for sleep(). const MAX_DELTA_MS = 50; // clamp the wall-clock delta (paused/backgrounded tab) // When an idle spin is elided with no timer alarm to anchor the jump, advance // at most this many cycles before letting the firmware re-check its deadline. // Bounds the delay overshoot to ~1 ms; with an alarm pending we stop exactly // at the alarm (no overshoot). const IDLE_SLICE_CYCLES = CYCLES_PER_MS; // 1 ms /** * Detects a side-effect-free busy-wait spin (e.g. arduino-pico `delay()`, * which polls the timer in a tight loop instead of sleeping). Fed the PC * about to execute on every instruction; reads the GPIO snapshot lazily, only * when a backward branch closes a loop iteration, so the hot path stays cheap. * * Reports a spin only once the SAME loop has iterated `threshold` times with * NO GPIO change (input or output) — so a bit-bang loop (toggles a pin every * iteration) and an input-poll that just saw its pin move are never elided, * and neither is a loop that calls out (long forward jump resets the count). * A false positive is bounded-harmless: we only ever advance time up to the * wall-clock budget, never past the next timer alarm or scheduled pin change. */ export class IdleSpinDetector { private prevPc = -1; private loopTarget = -1; private iters = 0; private gpioAtLastIter = -1; constructor( private readonly threshold = 32, private readonly maxStride = 256, ) {} /** * @param pc program counter about to execute * @param gpio thunk returning the current GPIO snapshot (called only on a * backward branch, so the 30-pin scan stays off the hot path) * @returns true when a stable, side-effect-free spin is detected */ observe(pc: number, gpio: () => number): boolean { const prev = this.prevPc; this.prevPc = pc; if (prev === -1) return false; if (pc < prev) { // Backward branch — one loop iteration just closed. const g = gpio(); if (this.loopTarget !== pc) { // First time we land on this loop top (or the loop moved): start over. this.loopTarget = pc; this.gpioAtLastIter = g; this.iters = 1; return false; } if (g !== this.gpioAtLastIter) { // A pin changed during the iteration — real work (bit-bang) or an // input arrived. Not idle; restart the count from this iteration. this.gpioAtLastIter = g; this.iters = 1; return false; } this.iters++; return this.iters >= this.threshold; } if (pc > prev + this.maxStride) { // Long forward jump (call / loop exit) — left the tight spin. this.reset(); } return false; } /** Call right after eliding a slice so the firmware re-checks its deadline * (executes the loop body again) before the next jump. */ noteElided(): void { this.iters = 0; } reset(): void { this.prevPc = -1; this.loopTarget = -1; this.iters = 0; this.gpioAtLastIter = -1; } } /** * Backward-compatible alias for the unified `I2CDevice` shape used by * both AVR and RP2040 buses now that I2CBusManager is the canonical * abstraction. Existing call sites that import `RP2040I2CDevice` keep * working without changes. */ export type RP2040I2CDevice = I2CDevice; export class RP2040Simulator { private rp2040: RP2040 | null = null; private running = false; private animationFrame: number | null = null; public pinManager: PinManager; private speed = 1.0; private gpioUnsubscribers: Array<() => void> = []; private flashCopy: Uint8Array | null = null; private totalCycles = 0; private scheduledPinChanges: Array<{ cycle: number; pin: number; state: boolean }> = []; private pioStepAccum = 0; private usbCDC: USBCDC | null = null; private micropythonMode = false; // Real-time scheduler state (see IdleSpinDetector + runFrameForTime). private lastTimestamp = 0; private readonly idleDetector = new IdleSpinDetector(); // ── Pico W WiFi (CYW43439) — only attached when boardKind === 'pi-pico-w'. private cyw43: Cyw43Emulator | null = null; private cyw43Sniffer: PioBusSniffer | null = null; private cyw43Bridge: Cyw43Bridge | null = null; private cyw43HookedFifos: Array<{ restore: () => void }> = []; /** Serial output callback — fires for each byte the Pico sends on UART0 (or USBCDC in MicroPython mode) */ public onSerialData: ((char: string) => void) | null = null; /** * Generic SPI bus adapter — same shape as AVRSimulator.spi so SPI parts * (ILI9341, SD cards, custom chips) can hook the bus uniformly across * boards. Defaults to RP2040 SPI0; firmware that uses SPI1 will need to * wrap rp2040.spi[1] manually until we add a .spi1 alias. * * Lazy-initialised so the rp2040.spi[0].onTransmit is only overridden * once a part actually accesses .spi (avoiding clobbering the default * loopback handler if no SPI part is on the canvas). */ private _spiAdapter: { onByte: ((mosi: number) => void) | null; completeTransfer: (miso: number) => void } | null = null; public get spi(): { onByte: ((mosi: number) => void) | null; completeTransfer: (miso: number) => void } { if (!this._spiAdapter) { const adapter = { onByte: null as ((mosi: number) => void) | null, completeTransfer: (miso: number) => { this.rp2040?.spi[0].completeTransmit(miso & 0xff); }, }; // Re-route SPI0's onTransmit through our adapter when initMCU / // initMicroPython runs. Until rp2040 is constructed (mcu=null) the // setter just stages the handler — we wire it in start(). this._spiAdapter = adapter; if (this.rp2040) { this.rp2040.spi[0].onTransmit = (v: number) => adapter.onByte?.(v); } } return this._spiAdapter; } /** Fires when the on-board LED on Pico W (driven through the CYW43, not GPIO 25) toggles. */ public onPicoWLed: ((on: boolean) => void) | null = null; /** Fires whenever the chip emits a Wi-Fi link-up event for the synthetic AP. */ public onPicoWWifiUp: ((ssid: string) => void) | null = null; /** * Fires for every GPIO pin transition with a millisecond timestamp. * Used by the oscilloscope / logic analyzer. * timeMs is derived from the RP2040 cycle counter (cycles / F_CPU * 1000). */ public onPinChangeWithTime: ((pin: number, state: boolean, timeMs: number) => void) | null = null; /** * Track whether the first byte has been transmitted on each UART since * the firmware booted. Used to seed the oscilloscope baseline at idle * HIGH the first time a frame goes out, mirroring how real silicon * idles the TX line HIGH once UARTEN is asserted. */ private uartTxSeeded: [boolean, boolean] = [false, false]; /** * One `I2CBusManager` per hardware I2C controller (RP2040 has two: * I2C0/Wire and I2C1/Wire1). Constructed up-front in the * simulator's constructor with a placeholder master so that cross- * board bridges + device registrations can land BEFORE firmware * loads. The real RPI2C peripheral takes over in `wireI2C()` via * `attachMaster` + `wireRpI2cToBus`. */ private i2cBuses: [I2CBusManager, I2CBusManager]; constructor(pinManager: PinManager) { this.pinManager = pinManager; this.i2cBuses = [ new I2CBusManager(nullI2CMaster()), new I2CBusManager(nullI2CMaster()), ]; } /** * Load a compiled binary into the RP2040 flash memory. * Accepts a base64-encoded string of the raw .bin file output by arduino-cli. */ loadBinary(base64: string): void { console.log('[RP2040] Loading binary...'); const binaryStr = atob(base64); const bytes = new Uint8Array(binaryStr.length); for (let i = 0; i < binaryStr.length; i++) { bytes[i] = binaryStr.charCodeAt(i); } console.log(`[RP2040] Binary size: ${bytes.length} bytes`); this.flashCopy = bytes; this.initMCU(bytes); console.log('[RP2040] CPU initialized with bootrom, UART, I2C, SPI, GPIO'); } /** Same interface as AVRSimulator for store compatibility */ // eslint-disable-next-line @typescript-eslint/no-unused-vars loadHex(_hexContent: string): void { console.warn('[RP2040] loadHex() called on RP2040Simulator — use loadBinary() instead'); } /** * Load MicroPython firmware + user .py files into RP2040 flash. * Uses USBCDC for serial (REPL) instead of UART. */ async loadMicroPython( files: Array<{ name: string; content: string }>, onProgress?: (loaded: number, total: number) => void, ): Promise { // Pico W needs the RPI_PICO_W build (network + CYW43 driver + bigger, // higher LittleFS). The cyw43 emulator is attached (via attachCyw43) only // for pi-pico-w boards, so its presence selects the firmware variant. const variant = this.cyw43 ? 'pico-w' : 'pico'; console.log(`[RP2040] Loading MicroPython firmware (${variant})...`); // 1. Get MicroPython UF2 firmware (cached in IndexedDB) const firmware = await getFirmware(variant, onProgress); // 2. Create fresh RP2040 instance this.rp2040 = new RP2040(); this.rp2040.logger = new ConsoleLogger(LogLevel.Error, false); this.rp2040.loadBootrom(bootromB1); // 3. Load UF2 firmware into flash loadUF2(firmware, this.rp2040.flash); console.log(`[RP2040] MicroPython UF2 loaded (${firmware.length} bytes)`); // 4. Create LittleFS with user files and load into flash (variant-specific // flash offset — the Pico W FS lives higher than the plain Pico's). await loadUserFiles(files, this.rp2040.flash, variant); console.log(`[RP2040] LittleFS loaded with ${files.length} file(s)`); // Keep a flash copy for reset this.flashCopy = new Uint8Array(this.rp2040.flash); // 5. Set up USBCDC for serial REPL (instead of UART) this.usbCDC = new USBCDC(this.rp2040.usbCtrl); this.usbCDC.onDeviceConnected = () => { // Send newline to trigger the REPL prompt this.usbCDC!.sendSerialByte('\r'.charCodeAt(0)); this.usbCDC!.sendSerialByte('\n'.charCodeAt(0)); }; this.usbCDC.onSerialData = (buffer: Uint8Array) => { for (const byte of buffer) { if (this.onSerialData) { this.onSerialData(String.fromCharCode(byte)); } } }; // 6. Set PC to flash start this.rp2040.core.PC = 0x10000000; // 7. Wire peripherals (I2C, SPI, ADC, PIO, GPIO — same as Arduino mode) // But skip UART serial wiring since MicroPython uses USBCDC this.rp2040.uart[1].onByte = (value: number) => { if (this.onSerialData) this.onSerialData(String.fromCharCode(value)); }; this.wireI2C(0); this.wireI2C(1); // Default loopback for SPI0 — overridden by the generic .spi adapter // if a SPI part later accesses simulator.spi. The adapter routes // onTransmit into adapter.onByte and uses completeTransmit to drive // MISO when the part calls completeTransfer. this.rp2040.spi[0].onTransmit = (v: number) => { if (this._spiAdapter && this._spiAdapter.onByte) { this._spiAdapter.onByte(v); } else { this.rp2040!.spi[0].completeTransmit(v); } }; this.rp2040.spi[1].onTransmit = (v: number) => { this.rp2040!.spi[1].completeTransmit(v); }; this.rp2040.adc.channelValues[0] = 2048; this.rp2040.adc.channelValues[1] = 2048; this.rp2040.adc.channelValues[2] = 2048; this.rp2040.adc.channelValues[3] = 2048; this.rp2040.adc.channelValues[4] = 876; // Patch PIO (same as initMCU) // eslint-disable-next-line @typescript-eslint/no-explicit-any for (const pio of (this.rp2040 as any).pio) { // eslint-disable-next-line @typescript-eslint/no-explicit-any pio.run = function (this: any) { if (this.runTimer) { clearTimeout(this.runTimer); this.runTimer = null; } }; } this.pioStepAccum = 0; // Pico W: attachCyw43 installed the gSPI PIO-FIFO hooks on the RP2040 // instance that existed at board-creation time. loadMicroPython just swapped // in a fresh RP2040, so those hooks now point at the discarded instance. // Re-install them on the new PIO FIFOs or the cyw43 driver's bit-banged // traffic never reaches the emulator and WiFi never connects. if (this.cyw43) { this.cyw43HookedFifos = []; this.installCyw43PioHooks(); } this.setupGpioListeners(); this.micropythonMode = true; console.log('[RP2040] MicroPython ready'); } /** Returns true if currently in MicroPython mode */ isMicroPythonMode(): boolean { return this.micropythonMode; } // ── Pico W (CYW43439) attachment ──────────────────────────────────────── /** * Wire a CYW43 chip emulator onto this RP2040 instance. Should only be * called for ``pi-pico-w`` boards. Idempotent — calling twice is a no-op. * * The emulator observes outbound PIO TX FIFO writes (which the cyw43 * driver uses to bit-bang the gSPI bus) and feeds back synthesised * responses. When a Cyw43Bridge is supplied, outbound Ethernet frames * are forwarded to the backend network bridge and inbound packets * coming back from the bridge are queued for the chip to deliver. */ attachCyw43(bridge: Cyw43Bridge | null = null): Cyw43Emulator { if (this.cyw43) return this.cyw43; const emu = new Cyw43Emulator(); const sniffer = new PioBusSniffer(); // The sniffer de-swaps commands/data per the chip's word-order regime, // which the emulator owns (flips at the SPI_BUS_CONTROL write). sniffer.setModeProvider(() => emu.isBigEndian()); this.cyw43 = emu; this.cyw43Sniffer = sniffer; this.cyw43Bridge = bridge; emu.onLed((ev: LedEvent) => { this.onPicoWLed?.(ev.on); }); emu.onConnect((ev) => { this.onPicoWWifiUp?.(ev.ssid); }); emu.onPacketOut((ev: PacketOutEvent) => { this.cyw43Bridge?.sendPacket(ev.ether); }); // Drive WL_HOST_WAKE (GPIO24, active-high). The driver gates poll_device on // this pin until it has received its first packet, so without it the first // IOCTL response is never read and wifi_on stalls. emu.onHostWake((active: boolean) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any try { (this.rp2040 as any)?.gpio?.[24]?.setInputValue(active); } catch { /* noop */ } }); if (bridge) { bridge.onPacketIn = (p) => emu.injectPacket(p.ether); // A real backend bridge owns the network, so disable the built-in // DHCP/ARP responder to avoid answering on its behalf. emu.setVirtualNet(null); } this.installCyw43PioHooks(); return emu; } /** Detach the CYW43 emulator (called from teardown). */ detachCyw43(): void { for (const h of this.cyw43HookedFifos) h.restore(); this.cyw43HookedFifos = []; this.cyw43 = null; this.cyw43Sniffer = null; this.cyw43Bridge = null; } /** Read access for tests / debug panels. */ getCyw43(): Cyw43Emulator | null { return this.cyw43; } /** * Hook every PIO state machine's ``txFIFO.push`` so the CYW43 emulator * sees every word the cyw43 driver bit-bangs onto the bus, and * mirror responses back into ``rxFIFO`` so the driver's reads land * without needing a real chip on the wire. */ private installCyw43PioHooks(): void { if (!this.rp2040 || !this.cyw43 || !this.cyw43Sniffer) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const pios: any[] = (this.rp2040 as any).pio; for (const pio of pios) { // eslint-disable-next-line @typescript-eslint/no-explicit-any for (const sm of pio.machines as any[]) { const tx = sm.txFIFO; const rx = sm.rxFIFO; if (!tx || !rx) continue; const sniffer = this.cyw43Sniffer; // Make the TX FIFO NON-DROPPING (head-pointer queue). rp2040js's 4-deep // FIFO silently drops words once full, which truncates the 260-word F2 // IOCTL writes (clm_load, the connect ioctls) so the chip never sees a // complete frame. Real hardware paces the DMA with DREQ and never drops. // To keep the ~224 KB firmware download cheap we still discard the bulk // of each firmware/backplane write (inDiscardableWriteData): the PIO // drains the few kept words, raises TXSTALL, and the driver moves on. const q: number[] = []; let head = 0; const origFull = Object.getOwnPropertyDescriptor(tx, 'full'); const origEmpty = Object.getOwnPropertyDescriptor(tx, 'empty'); const origItem = Object.getOwnPropertyDescriptor(tx, 'itemCount'); const origPush: (v: number) => void = tx.push.bind(tx); const origPull: () => number = tx.pull.bind(tx); const origPeek = tx.peek?.bind(tx); const origReset = tx.reset?.bind(tx); Object.defineProperty(tx, 'full', { get: () => false, configurable: true }); Object.defineProperty(tx, 'empty', { get: () => head >= q.length, configurable: true }); Object.defineProperty(tx, 'itemCount', { get: () => q.length - head, configurable: true }); tx.peek = () => (head < q.length ? q[head] : 0); tx.reset = () => { q.length = 0; head = 0; }; tx.push = (value: number) => { if (sniffer.inDiscardableWriteData()) { if (q.length - head < 4) q.push(value >>> 0); // keep a few so the PIO TXSTALLs return; } // Feed the gSPI sniffer; commands that produce a response queue it // for on-demand delivery (see the rxFIFO.pull hook below). this.feedCyw43Word(value); q.push(value >>> 0); }; tx.pull = () => { if (head >= q.length) return 0; const v = q[head++]; if (head > 8192 && head * 2 > q.length) { q.splice(0, head); head = 0; } // compact return v; }; this.cyw43HookedFifos.push({ restore: () => { if (origFull) Object.defineProperty(tx, 'full', origFull); else delete tx.full; if (origEmpty) Object.defineProperty(tx, 'empty', origEmpty); else delete tx.empty; if (origItem) Object.defineProperty(tx, 'itemCount', origItem); else delete tx.itemCount; tx.push = origPush; tx.pull = origPull; if (origPeek) tx.peek = origPeek; if (origReset) tx.reset = origReset; }, }); // Reset the gSPI framing at each transfer boundary. cyw43_spi_transfer // does pio_sm_restart before pushing the count words, so this keeps the // sniffer deterministic even across the firmware-stream fast-path. // eslint-disable-next-line @typescript-eslint/no-explicit-any if (typeof (sm as any).restart === 'function') { // eslint-disable-next-line @typescript-eslint/no-explicit-any const origRestart: () => void = (sm as any).restart.bind(sm); // eslint-disable-next-line @typescript-eslint/no-explicit-any (sm as any).restart = () => { this.cyw43Sniffer?.reset(); return origRestart(); }; this.cyw43HookedFifos.push({ // eslint-disable-next-line @typescript-eslint/no-explicit-any restore: () => { (sm as any).restart = origRestart; }, }); } // Serve the chip's response when the driver's DMA actually reads the // RX FIFO. Pushing into the FIFO eagerly raced the async DMA/PIO and // the data arrived late or was lost; serving on pull keeps it in lock // step with the driver. // eslint-disable-next-line @typescript-eslint/no-explicit-any const origRxPull: () => number = (rx as any).pull.bind(rx); // eslint-disable-next-line @typescript-eslint/no-explicit-any (rx as any).pull = () => this.cyw43RxQueue.length > 0 ? (this.cyw43RxQueue.shift() as number) : origRxPull(); this.cyw43HookedFifos.push({ restore: () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (rx as any).pull = origRxPull; }, }); } } // Re-sync WL_HOST_WAKE: loadMicroPython swaps in a fresh RP2040 (GPIO reset // to low) while the chip's frame queue — and thus its host-wake level — // persists. onHostWake only fires on changes, so push the current level now. try { // eslint-disable-next-line @typescript-eslint/no-explicit-any (this.rp2040 as any)?.gpio?.[24]?.setInputValue(this.cyw43.hostWakeLevel()); } catch { /* noop */ } } private cyw43RxQueue: number[] = []; private feedCyw43Word(word: number): void { if (!this.cyw43Sniffer || !this.cyw43) return; for (const ev of this.cyw43Sniffer.feedWord(word)) { if (ev.kind === 'payload') { const reply = this.cyw43.onCommand(ev.cmd, ev.payload, ev.readBytes); if (reply && reply.length > 0) this.queueCyw43Reply(reply); } } } private queueCyw43Reply(reply: Uint8Array): void { // 32-bit big-endian repacking with the same halfword swap the PIO // program does on input. We push host-byte-order words; the SM's // shift register puts them on the wire LSB-first per the gSPI spec. for (let i = 0; i + 4 <= reply.length; i += 4) { const w = ((reply[i + 3] << 24) | (reply[i + 2] << 16) | (reply[i + 1] << 8) | reply[i]) >>> 0; this.cyw43RxQueue.push(w); } if (reply.length % 4 !== 0) { // Pad to 4 bytes with zeros — the driver discards trailing bytes // it didn't request. const tail = reply.subarray(reply.length - (reply.length % 4)); let w = 0; for (let i = 0; i < tail.length; i++) w |= tail[i] << (i * 8); this.cyw43RxQueue.push(w >>> 0); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any getADC(): any { return this.rp2040?.adc ?? null; } /** Get underlying RP2040 instance (for advanced usage / tests) */ getMCU(): RP2040 | null { return this.rp2040; } // ── Private initialization ─────────────────────────────────────────────── private initMCU(programBytes: Uint8Array): void { this.rp2040 = new RP2040(); // Suppress noisy internal logs (only show errors) this.rp2040.logger = new ConsoleLogger(LogLevel.Error, false); // Load RP2040 B1 bootrom — needed for proper boot sequence this.rp2040.loadBootrom(bootromB1); // Load binary into flash starting at offset 0 (maps to 0x10000000) this.rp2040.flash.set(programBytes, 0); // Set PC to flash start (boot vector) this.rp2040.core.PC = 0x10000000; // ── Wire UART0 (default Serial port for Arduino-Pico) ──────────── let serialBuffer = ''; this.rp2040.uart[0].onByte = (value: number) => { const ch = String.fromCharCode(value); serialBuffer += ch; if (ch === '\n') { console.log('[RP2040 UART0]', serialBuffer.trimEnd()); serialBuffer = ''; } if (this.onSerialData) { this.onSerialData(ch); } // Synthesize the bit-level waveform on the UART0 TX pin so an // oscilloscope on it sees a real frame — rp2040js doesn't drive the // GPIO when the UART transmits. See emitUartTxFrame(). this.emitUartTxFrame(0, value); }; // ── Wire UART1 (Serial1) — also forward to onSerialData for now ── this.rp2040.uart[1].onByte = (value: number) => { if (this.onSerialData) { this.onSerialData(String.fromCharCode(value)); } this.emitUartTxFrame(1, value); }; // ── Wire I2C0 and I2C1 ─────────────────────────────────────────── this.wireI2C(0); this.wireI2C(1); // ── Wire SPI0 and SPI1 ──────────────────────────────────────────── // SPI0 must check for a registered .spi adapter on every byte. If a // part on the canvas (ILI9341, custom chip, …) accessed simulator.spi // BEFORE this initMCU runs, the adapter is already staged but // _adapter.onByte points at the part's handler — we have to route // the byte through it. Without this, SPI parts see nothing and the // canvas stays black (real regression — Pico Doom shipped with this // bug for months because the same wiring in initMicroPython was // adapter-aware but this Arduino path wasn't). this.rp2040.spi[0].onTransmit = (v: number) => { if (this._spiAdapter && this._spiAdapter.onByte) { this._spiAdapter.onByte(v); } else { this.rp2040!.spi[0].completeTransmit(v); } }; this.rp2040.spi[1].onTransmit = (value: number) => { this.rp2040!.spi[1].completeTransmit(value); // loopback }; // ── Set default ADC values ─────────────────────────────────────── // Channel 0-3: GPIO26-29, channel 4: internal temp sensor // Default to mid-range (~1.65V on 3.3V ref, 12-bit) this.rp2040.adc.channelValues[0] = 2048; this.rp2040.adc.channelValues[1] = 2048; this.rp2040.adc.channelValues[2] = 2048; this.rp2040.adc.channelValues[3] = 2048; // Internal temp sensor: T = 27 - (V - 0.706) / 0.001721 // For 27°C: V = 0.706V → ADC = 0.706/3.3 * 4095 ≈ 876 this.rp2040.adc.channelValues[4] = 876; // ── Patch PIO to use synchronous stepping instead of setTimeout ── // rp2040js PIO uses setTimeout(() => this.run(), 0) which deadlocks // when the CPU busy-waits for PIO FIFO space (e.g. pio_sm_put_blocking). // We step PIO synchronously in the execute loop instead. // eslint-disable-next-line @typescript-eslint/no-explicit-any for (const pio of (this.rp2040 as any).pio) { // eslint-disable-next-line @typescript-eslint/no-explicit-any pio.run = function (this: any) { if (this.runTimer) { clearTimeout(this.runTimer); this.runTimer = null; } // No-op: execute loop calls pio.step() synchronously }; } this.pioStepAccum = 0; // ── Set up GPIO listeners ──────────────────────────────────────── this.setupGpioListeners(); } /** * Resolve the GPIO index currently routed to a given UART's TX line. * * The RP2040 GPIO function-select register decides which signal each pad * carries; UART has FUNCSEL == 2. Per datasheet, UART0_TX can land on * GP0 / GP12 / GP16 / GP28 and UART1_TX on GP4 / GP8 / GP20 / GP24. We * walk the candidates and pick the first whose function select is UART. * If none is mapped (rare — the firmware hasn't called `Serial.begin()` * properly) fall back to the default for that UART (GP0 / GP4). */ private rp2040UartTxPin(uartIdx: 0 | 1): number { const FUNCTION_UART = 2; const candidates = uartIdx === 0 ? [0, 12, 16, 28] : [4, 8, 20, 24]; if (this.rp2040) { for (const g of candidates) { const pin = this.rp2040.gpio[g]; if (pin && (pin as unknown as { functionSelect: number }).functionSelect === FUNCTION_UART) { return g; } } } return uartIdx === 0 ? 0 : 4; } /** * Synthesize a bit-level UART frame on the TX pin so the oscilloscope * sees a real waveform during `Serial.print` / `Serial1.print`. * * rp2040js's UART peripheral fires `onByte(value)` per transmitted byte * but never toggles the corresponding GPIO — the same gap closed in * AVRSimulator.emitUartTxFrame(). Here we do the same: build the frame * (start LOW + data LSB-first + stop HIGH) using the UART's live * `baudRate` and `bitsPerChar`, then push one transition per bit-change * through `onPinChangeWithTime` so the scope draws the waveform at the * actual silicon-equivalent baud rate. * * Time is taken from the RP2040 clock (nanos counter), matching the * existing GPIO-listener path in `setupGpioListeners()` — UART * waveforms therefore stack consistently with any other pin trace. */ private emitUartTxFrame(uartIdx: 0 | 1, byte: number): void { if (!this.rp2040 || !this.onPinChangeWithTime) return; const uart = this.rp2040.uart[uartIdx]; if (!uart) return; const baud = uart.baudRate; if (!baud || baud <= 0) return; const txPin = this.rp2040UartTxPin(uartIdx); const dataBits = uart.bitsPerChar; const clk = (this.rp2040 as unknown as { clock?: { nanos: number } }).clock; const startMs = clk ? clk.nanos / 1_000_000 : 0; const bitMs = 1000 / baud; // First frame after boot: seed an explicit idle HIGH one bit-period // before the start bit so the scope has a HIGH baseline to draw the // start-bit transition against. Subsequent frames inherit the HIGH // baseline from the previous frame's stop bit. if (!this.uartTxSeeded[uartIdx]) { this.onPinChangeWithTime(txPin, true, Math.max(0, startMs - bitMs)); this.uartTxSeeded[uartIdx] = true; } const bits: boolean[] = [false]; // start bit for (let i = 0; i < dataBits; i++) { bits.push(((byte >> i) & 1) !== 0); } bits.push(true); // stop bit (rp2040js doesn't expose 2-stop-bit selection // cleanly; default to 1 — same behaviour as 8N1 sketches) let prevState = true; for (let i = 0; i < bits.length; i++) { if (bits[i] !== prevState) { this.onPinChangeWithTime(txPin, bits[i], startMs + i * bitMs); prevState = bits[i]; } } } private wireI2C(bus: 0 | 1): void { if (!this.rp2040) return; const i2c: RPI2C = this.rp2040.i2c[bus]; // Swap in the real RPI2C peripheral and route its per-callback // events into the existing bus manager. Any devices + bridges // registered before the firmware loaded are preserved. const busManager = this.i2cBuses[bus]; busManager.attachMaster(i2c); wireRpI2cToBus(i2c, busManager); } private setupGpioListeners(): void { this.gpioUnsubscribers.forEach((fn) => fn()); this.gpioUnsubscribers = []; if (!this.rp2040) return; for (let gpioIdx = 0; gpioIdx < 30; gpioIdx++) { const pin = gpioIdx; const gpio = this.rp2040.gpio[gpioIdx]; if (!gpio) continue; const unsub = gpio.addListener((state: GPIOPinState) => { const isHigh = state === GPIOPinState.High || state === GPIOPinState.InputPullUp; this.pinManager.triggerPinChange(pin, isHigh, 'mcu'); if (this.onPinChangeWithTime && this.rp2040) { // IClock interface exposes `nanos` (not `timeUs`) // eslint-disable-next-line @typescript-eslint/no-explicit-any const clk = (this.rp2040 as any).clock; const timeMs = clk ? (clk.nanos as number) / 1_000_000 : 0; this.onPinChangeWithTime(pin, isHigh, timeMs); } }); this.gpioUnsubscribers.push(unsub); } } // ── Public API ─────────────────────────────────────────────────────────── start(): void { if (this.running || !this.rp2040) { console.warn('[RP2040] Already running or not initialized'); return; } this.running = true; this.lastTimestamp = 0; this.idleDetector.reset(); console.log('[RP2040] Starting simulation at 125 MHz...'); const execute = (timestamp: number) => { if (!this.running || !this.rp2040) return; // Derive this frame's cycle budget from the MEASURED wall-clock delta // (mirrors AVRSimulator) so the sim cannot silently run in slow motion // by assuming a perfect 60 fps. First frame falls back to one frame; the // upper clamp (paused/backgrounded tab) is applied in runFrameForTime. const deltaMs = this.lastTimestamp === 0 ? 1000 / FPS : timestamp - this.lastTimestamp; this.lastTimestamp = timestamp; try { this.runFrameForTime(deltaMs); } catch (error) { console.error('[RP2040] Simulation error:', error); this.stop(); return; } this.animationFrame = requestAnimationFrame(execute); }; this.animationFrame = requestAnimationFrame(execute); } /** * Run one frame's worth of simulation for `deltaMs` of wall-clock time. * Returns counters for tests. Keeps simulated time locked to wall-clock: * idle spins (busy-wait `delay()`) and WFI sleeps advance the clock instead * of executing every idle cycle, so timing stays correct even when the host * cannot emulate 125 MHz in real time. Exposed (not private) so the * real-time scheduler can be driven deterministically in tests without rAF. */ runFrameForTime(deltaMs: number): { cyclesAdvanced: number; instructionsExecuted: number } { if (!this.rp2040) return { cyclesAdvanced: 0, instructionsExecuted: 0 }; // Guard against NaN/negative deltas and clamp the upper bound so a single // frame never simulates more than MAX_DELTA_MS of CPU time (a paused or // backgrounded tab must not trigger a multi-second catch-up burst). let dt = deltaMs > 0 ? deltaMs : 1000 / FPS; if (dt > MAX_DELTA_MS) dt = MAX_DELTA_MS; const cyclesTarget = Math.max(1, Math.floor(CYCLES_PER_MS * dt * this.speed)); const { core } = this.rp2040; const clock = (this.rp2040 as unknown as { clock?: SimClock }).clock ?? null; const pioDiv = this.getPIOClockDiv(); const gpioSnapshot = () => this.rp2040!.gpioValues; let cyclesDone = 0; let instructionsExecuted = 0; while (cyclesDone < cyclesTarget) { if (core.waiting) { // CPU asleep (WFI/WFE): jump to the next timer alarm, but never past // this frame's wall-clock budget, so a long sleep advances at real // time across frames rather than leaping ahead. if (!clock || clock.nanosToNextAlarm <= 0) { this.stepPIO(); // nothing scheduled to wake it this frame break; } const jumped = this.advanceClock(cyclesTarget - cyclesDone, pioDiv, clock); if (jumped <= 0) break; cyclesDone += jumped; } else if (this.idleDetector.observe(core.PC, gpioSnapshot)) { // Detected a side-effect-free busy-wait spin (e.g. delay()): advance // the clock over it instead of grinding every cycle. Capped at the // next alarm/scheduled pin change inside advanceClock, and to a small // slice so the firmware re-checks its deadline (bounds overshoot). const budget = Math.min(cyclesTarget - cyclesDone, IDLE_SLICE_CYCLES); const jumped = this.advanceClock(budget, pioDiv, clock); if (jumped <= 0) { cyclesDone += this.execOne(core, clock, pioDiv); instructionsExecuted++; } else { cyclesDone += jumped; this.idleDetector.noteElided(); } } else { cyclesDone += this.execOne(core, clock, pioDiv); instructionsExecuted++; } } return { cyclesAdvanced: cyclesDone, instructionsExecuted }; } /** Execute one ARM instruction in the production loop, advancing the clock * and stepping PIO. Returns the cycles it took. */ private execOne( core: { executeInstruction(): number }, clock: SimClock | null, pioDiv: number, ): number { const cycles: number = core.executeInstruction(); if (clock) clock.tick(cycles * CYCLE_NANOS); this.totalCycles += cycles; this.pioStepAccum += cycles; while (this.pioStepAccum >= pioDiv) { this.pioStepAccum -= pioDiv; this.stepPIO(); } this.flushScheduledPinChanges(); return cycles; } /** * Advance the simulated clock by up to `budgetCycles` WITHOUT executing * instructions, stepping PIO at the PIO clock rate so GPIO timestamps stay * accurate. Never advances past the next timer alarm or the next scheduled * pin change (so those still fire at their exact simulated time). Returns * the number of cycles actually advanced. */ private advanceClock(budgetCycles: number, pioDiv: number, clock: SimClock | null): number { if (budgetCycles <= 0 || !clock) return 0; const alarmNanos: number = clock.nanosToNextAlarm ?? 0; const alarmCycles = alarmNanos > 0 ? Math.ceil(alarmNanos / CYCLE_NANOS) : Infinity; const nextPin = this.scheduledPinChanges.length > 0 ? this.scheduledPinChanges[0].cycle - this.totalCycles : Infinity; let jumped = Math.min(budgetCycles, alarmCycles, nextPin > 0 ? nextPin : Infinity); if (!Number.isFinite(jumped) || jumped <= 0) return 0; jumped = Math.ceil(jumped); const totalNanos = jumped * CYCLE_NANOS; const nanoPerPioStep = pioDiv * CYCLE_NANOS; const pioSteps = Math.min(Math.floor(jumped / pioDiv), 50000); let nanosStepped = 0; for (let i = 0; i < pioSteps; i++) { clock.tick(nanoPerPioStep); nanosStepped += nanoPerPioStep; this.stepPIO(); } const remaining = totalNanos - nanosStepped; if (remaining > 0) clock.tick(remaining); this.totalCycles += jumped; this.flushScheduledPinChanges(); return jumped; } stop(): void { if (!this.running) return; this.running = false; if (this.animationFrame !== null) { cancelAnimationFrame(this.animationFrame); this.animationFrame = null; } // Force a new idle-HIGH seed on the next byte: the scope buffer is // typically cleared on stop/start, so the previous run's "seeded" // flag would suppress the baseline sample for the next session. this.uartTxSeeded = [false, false]; this.lastTimestamp = 0; this.idleDetector.reset(); console.log('[RP2040] Simulation stopped'); } reset(): void { this.stop(); this.totalCycles = 0; this.scheduledPinChanges = []; this.idleDetector.reset(); if (this.rp2040 && this.flashCopy) { if (this.micropythonMode) { // In MicroPython mode, restore the full flash snapshot (UF2 + LittleFS) this.rp2040 = new RP2040(); this.rp2040.logger = new ConsoleLogger(LogLevel.Error, false); this.rp2040.loadBootrom(bootromB1); this.rp2040.flash.set(this.flashCopy); this.rp2040.core.PC = 0x10000000; // Re-wire USBCDC this.usbCDC = new USBCDC(this.rp2040.usbCtrl); this.usbCDC.onDeviceConnected = () => { this.usbCDC!.sendSerialByte('\r'.charCodeAt(0)); this.usbCDC!.sendSerialByte('\n'.charCodeAt(0)); }; this.usbCDC.onSerialData = (buffer: Uint8Array) => { for (const byte of buffer) { if (this.onSerialData) this.onSerialData(String.fromCharCode(byte)); } }; // Re-wire peripherals (skipping UART0 serial) this.rp2040.uart[1].onByte = (value: number) => { if (this.onSerialData) this.onSerialData(String.fromCharCode(value)); }; this.wireI2C(0); this.wireI2C(1); this.rp2040.spi[0].onTransmit = (v: number) => { this.rp2040!.spi[0].completeTransmit(v); }; this.rp2040.spi[1].onTransmit = (v: number) => { this.rp2040!.spi[1].completeTransmit(v); }; // eslint-disable-next-line @typescript-eslint/no-explicit-any for (const pio of (this.rp2040 as any).pio) { // eslint-disable-next-line @typescript-eslint/no-explicit-any pio.run = function (this: any) { if (this.runTimer) { clearTimeout(this.runTimer); this.runTimer = null; } }; } this.pioStepAccum = 0; this.setupGpioListeners(); } else { this.initMCU(this.flashCopy); } console.log('[RP2040] CPU reset'); } } isRunning(): boolean { return this.running; } setSpeed(speed: number): void { this.speed = Math.max(0.1, Math.min(10.0, speed)); } getSpeed(): number { return this.speed; } /** Returns the CPU clock frequency in Hz. */ getClockHz(): number { return F_CPU; } /** Returns total CPU cycles executed since last reset/load. */ getCurrentCycles(): number { return this.totalCycles; } /** * Schedule a GPIO pin state change at a specific future cycle count. * Enables cycle-accurate protocol simulation (e.g. HC-SR04 echo timing). */ schedulePinChange(pin: number, state: boolean, atCycle: number): void { let i = this.scheduledPinChanges.length; while (i > 0 && this.scheduledPinChanges[i - 1].cycle > atCycle) i--; this.scheduledPinChanges.splice(i, 0, { cycle: atCycle, pin, state }); } /** Get the PIO clock divider from the first enabled state machine. */ private getPIOClockDiv(): number { if (!this.rp2040) return 64; // eslint-disable-next-line @typescript-eslint/no-explicit-any for (const pio of (this.rp2040 as any).pio) { if (pio.stopped) continue; for (const m of pio.machines) { if (m.enabled) { return Math.max(1, m.clockDivInt || 1); } } } return 64; // default } /** Step PIO state machines synchronously (prevents setTimeout deadlock). */ private stepPIO(): void { if (!this.rp2040) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const pio = (this.rp2040 as any).pio; if (pio[0] && !pio[0].stopped) pio[0].step(); if (pio[1] && !pio[1].stopped) pio[1].step(); } private flushScheduledPinChanges(): void { if (this.scheduledPinChanges.length === 0) return; while ( this.scheduledPinChanges.length > 0 && this.scheduledPinChanges[0].cycle <= this.totalCycles ) { const { pin, state } = this.scheduledPinChanges.shift()!; this.setPinState(pin, state); } } /** * Drive a GPIO pin externally (e.g. from a button or slider). * GPIO n = Arduino D(n) for Raspberry Pi Pico. */ setPinState(arduinoPin: number, state: boolean): void { if (!this.rp2040) return; const gpio = this.rp2040.gpio[arduinoPin]; if (gpio) { gpio.setInputValue(state); } } /** * Send text to UART0 RX (or USBCDC in MicroPython mode). */ serialWrite(text: string): void { if (!this.rp2040) return; if (this.micropythonMode && this.usbCDC) { for (let i = 0; i < text.length; i++) { this.usbCDC.sendSerialByte(text.charCodeAt(i)); } } else { for (let i = 0; i < text.length; i++) { this.rp2040.uart[0].feedByte(text.charCodeAt(i)); } } } /** * Send a raw byte to the serial interface (for control characters like Ctrl+C). */ serialWriteByte(byte: number): void { if (!this.rp2040) return; if (this.micropythonMode && this.usbCDC) { this.usbCDC.sendSerialByte(byte); } else { this.rp2040.uart[0].feedByte(byte); } } /** * Register a virtual I2C device on the specified bus (0 or 1). * Default bus 0 = Wire, bus 1 = Wire1. Devices are added directly * to the bus manager (which exists from construction time, with a * placeholder master until the real RPI2C is wired in start()). */ addI2CDevice(device: I2CDevice, bus: 0 | 1 = 0): void { this.i2cBuses[bus].addDevice(device); } /** Remove an I2C device by address from the given bus. */ removeI2CDevice(address: number, bus: 0 | 1 = 0): void { this.i2cBuses[bus].removeDevice(address); } /** * Get the I2CBusManager for a given hardware bus (0 or 1). * Available from construction time so Interconnect can install * cross-board bridges before firmware loads. */ getI2CBus(bus: 0 | 1 = 0): I2CBusManager { return this.i2cBuses[bus]; } /** * Execute one ARM instruction synchronously and return the number * of CPU cycles it took. Mirrors `AVRSimulator.step()` for tests * that need deterministic single-stepping outside the * `requestAnimationFrame` loop used in production. No-op if the * firmware has not been loaded. * * Does NOT advance PIO or fire scheduled pin changes — for those * use the production `start()` loop or call `stepCycles(n)`. */ step(): number { if (!this.rp2040) return 0; const core = this.rp2040.core; const clock = this.rp2040.clock; if (core.waiting) { // CPU is in WFE/WFI — advance clock to the next alarm so an // interrupt can wake it. Without this, single-stepping a // waiting CPU spins indefinitely. const jump = clock?.nanosToNextAlarm ?? CYCLE_NANOS; if (jump > 0 && clock) clock.tick(jump); this.totalCycles += Math.ceil((jump || CYCLE_NANOS) / CYCLE_NANOS); return Math.ceil((jump || CYCLE_NANOS) / CYCLE_NANOS); } const cycles: number = core.executeInstruction(); if (clock) clock.tick(cycles * CYCLE_NANOS); this.totalCycles += cycles; return cycles; } /** * Drive the CPU forward by approximately `targetCycles` cycles, * synchronously. Useful for test harnesses that want bounded, * deterministic execution without depending on * `requestAnimationFrame`. Returns the actual number of cycles * consumed (may exceed targetCycles by at most the cost of one * instruction). */ stepCycles(targetCycles: number): number { let consumed = 0; while (consumed < targetCycles) { const c = this.step(); if (c === 0) break; // firmware not loaded consumed += c; } return consumed; } /** * Set ADC channel value (0-4095 for 12-bit). * Channels 0-3 = GPIO26-29, channel 4 = internal temperature sensor. */ setADCValue(channel: number, value: number): void { if (!this.rp2040) return; if (channel >= 0 && channel < 5) { this.rp2040.adc.channelValues[channel] = Math.max(0, Math.min(4095, value)); } } /** * Set SPI onTransmit handler for a bus (0 or 1). * callback receives TX byte and must call completeTransmit on the SPI instance. */ setSPIHandler(bus: 0 | 1, handler: (value: number) => number): void { if (!this.rp2040) return; const spi = this.rp2040.spi[bus]; spi.onTransmit = (value: number) => { const response = handler(value); spi.completeTransmit(response); }; } // ── Generic sensor registration (board-agnostic API) ────────────────────── // RP2040 handles all sensor protocols locally via schedulePinChange, // so these return false / no-op — the sensor runs its own frontend logic. registerSensor(_type: string, _pin: number, _props: Record): boolean { return false; } updateSensor(_pin: number, _props: Record): void {} unregisterSensor(_pin: number): void {} }