diff --git a/frontend/src/__tests__/rp2040-realtime.test.ts b/frontend/src/__tests__/rp2040-realtime.test.ts new file mode 100644 index 00000000..eb576a67 --- /dev/null +++ b/frontend/src/__tests__/rp2040-realtime.test.ts @@ -0,0 +1,164 @@ +/** + * RP2040 real-time scheduler + * + * The RP2040 core (125 MHz Cortex-M0) is ~8x heavier to emulate than the AVR. + * A `delay()` on the arduino-pico core BUSY-WAITS (polls the timer in a tight + * loop) instead of sleeping, so the WFI fast-path never triggers and a host + * that cannot sustain 125 M instr/s would render a 1 s blink every 4-5 s. + * + * Two mechanisms keep simulated time locked to wall-clock: + * 1. the frame budget is derived from the measured wall-clock delta, and + * 2. IdleSpinDetector recognises a side-effect-free busy-wait spin so the + * scheduler advances the clock over it instead of grinding every cycle. + * + * These tests cover the detector in isolation (the risky heuristic) and the + * end-to-end scheduler against a real rp2040js core running a hand-assembled + * busy-wait loop — no compiled firmware fixture required. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { RP2040 } from 'rp2040js'; +import { RP2040Simulator, IdleSpinDetector } from '../simulation/RP2040Simulator'; +import { PinManager } from '../simulation/PinManager'; + +// ── IdleSpinDetector — the heuristic that decides what is safe to skip ─────── +describe('IdleSpinDetector', () => { + const constGpio = () => 0; + + it('detects a stable, side-effect-free spin after the threshold', () => { + const d = new IdleSpinDetector(32); + // Drive the real PC sequence: L, L+2, L, L+2, ... (backward branch each loop) + const L = 0x100; + let detected = false; + d.observe(L, constGpio); + for (let i = 0; i < 40; i++) { + d.observe(L + 2, constGpio); + detected = d.observe(L, constGpio) || detected; // L < L+2 → backward branch + } + expect(detected).toBe(true); + }); + + it('never elides a bit-bang loop (GPIO changes every iteration)', () => { + const d = new IdleSpinDetector(32); + const L = 0x100; + let toggling = 0; + const changingGpio = () => (toggling ^= 1); // different value each read + let detected = false; + d.observe(L, changingGpio); + for (let i = 0; i < 200; i++) { + d.observe(L + 2, changingGpio); + detected = d.observe(L, changingGpio) || detected; + } + expect(detected).toBe(false); + }); + + it('never elides straight-line code (no backward branch)', () => { + const d = new IdleSpinDetector(8); + let detected = false; + for (let pc = 0x100; pc < 0x100 + 8 * 100; pc += 2) { + detected = d.observe(pc, constGpio) || detected; + } + expect(detected).toBe(false); + }); + + it('resets on a long forward jump (loop that calls out)', () => { + const d = new IdleSpinDetector(4); + const L = 0x100; + let detected = false; + // Each "iteration" jumps far away (a bl to a subroutine) then comes back. + for (let i = 0; i < 50; i++) { + d.observe(L, constGpio); + d.observe(L + 2, constGpio); + d.observe(L + 0x4000, constGpio); // long forward jump → reset + detected = d.observe(L, constGpio) || detected; // backward, but count was reset + } + expect(detected).toBe(false); + }); + + it('noteElided() makes it re-accumulate before signalling again', () => { + const d = new IdleSpinDetector(4); + const L = 0x100; + const tick = () => { + d.observe(L + 2, constGpio); + return d.observe(L, constGpio); + }; + d.observe(L, constGpio); + let detected = false; + for (let i = 0; i < 4; i++) detected = tick() || detected; + expect(detected).toBe(true); + d.noteElided(); + // Immediately after eliding, it must NOT re-signal until the loop runs again. + expect(tick()).toBe(false); + }); +}); + +// ── End-to-end scheduler against a real rp2040js core ──────────────────────── +describe('RP2040Simulator — real-time scheduler', () => { + const RAM = 0x20000000; + const NOP = 0xbf00; + const B_BACK_1 = 0xe7fd; // b .-2 (branch to the previous 16-bit instruction) + + let sim: RP2040Simulator; + let rp: RP2040; + + /** Build a simulator wrapping a fresh core preloaded with `opcodes` at RAM. */ + function withProgram(opcodes: number[]): void { + rp = new RP2040(); + opcodes.forEach((op, i) => rp.writeUint16(RAM + i * 2, op)); + rp.core.PC = RAM; + sim = new RP2040Simulator(new PinManager()); + // Inject the bare core directly — we are unit-testing the scheduler, not + // the bootrom/flash loader. + (sim as unknown as { rp2040: RP2040 }).rp2040 = rp; + } + + afterEach(() => { + vi.restoreAllMocks(); + sim?.stop(); + }); + + it('elides a busy-wait spin: sim-time tracks wall-time with few instructions', () => { + withProgram([NOP, B_BACK_1]); // nop; loop forever — a side-effect-free spin + const before = sim.getCurrentCycles(); + const { cyclesAdvanced, instructionsExecuted } = sim.runFrameForTime(16); // 16 ms + + // 16 ms of wall-clock at 125 MHz == 2 000 000 simulated cycles. + expect(cyclesAdvanced).toBeGreaterThan(1_900_000); + expect(sim.getCurrentCycles() - before).toBeGreaterThan(1_900_000); + // ...yet almost none of those cycles were actually executed (the win). + expect(instructionsExecuted).toBeLessThan(5_000); + }); + + it('does NOT elide when GPIO keeps changing (bit-bang safety)', () => { + withProgram([NOP, B_BACK_1]); + // Same loop, but make every GPIO snapshot differ — mimics a pin toggling + // each iteration. The detector must refuse to skip and grind every cycle. + let n = 0; + Object.defineProperty(rp, 'gpioValues', { configurable: true, get: () => n++ }); + + const { cyclesAdvanced, instructionsExecuted } = sim.runFrameForTime(1); // 1 ms + // No skipping: instructions executed are on the order of cycles advanced. + expect(instructionsExecuted).toBeGreaterThan(cyclesAdvanced / 4); + }); + + it('locks the cycle budget to the measured wall-clock delta', () => { + withProgram([NOP, B_BACK_1]); + const small = sim.runFrameForTime(4).cyclesAdvanced; + const big = sim.runFrameForTime(16).cyclesAdvanced; + // 4x the wall-time => ~4x the simulated cycles (within scheduling slack). + expect(big).toBeGreaterThan(small * 3); + // And the long-delta clamp keeps a backgrounded tab from over-running. + const clamped = sim.runFrameForTime(100_000).cyclesAdvanced; // 100 s wall-clock + expect(clamped).toBeLessThan(50 /*MAX_DELTA_MS*/ * 125_000 + 200_000); + }); + + it('still fires scheduled pin changes during an elided frame (not skipped past)', () => { + withProgram([NOP, B_BACK_1]); + const setPin = vi.spyOn(sim, 'setPinState'); + const at = sim.getCurrentCycles() + 1000; + sim.schedulePinChange(2, true, at); // external edge 1000 cycles into the future + sim.runFrameForTime(16); + // advanceClock caps each jump at the next scheduled change, so it is + // applied on time rather than swallowed by the idle skip. + expect(setPin).toHaveBeenCalledWith(2, true); + }); +}); diff --git a/frontend/src/simulation/RP2040Simulator.ts b/frontend/src/simulation/RP2040Simulator.ts index 34bd848d..7dd8b113 100644 --- a/frontend/src/simulation/RP2040Simulator.ts +++ b/frontend/src/simulation/RP2040Simulator.ts @@ -37,7 +37,110 @@ import { 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_FRAME = Math.floor(F_CPU / FPS); // ~2 083 333 +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 @@ -60,6 +163,9 @@ export class RP2040Simulator { 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; @@ -626,67 +732,22 @@ export class RP2040Simulator { } this.running = true; + this.lastTimestamp = 0; + this.idleDetector.reset(); console.log('[RP2040] Starting simulation at 125 MHz...'); - const execute = () => { + const execute = (timestamp: number) => { if (!this.running || !this.rp2040) return; - const cyclesTarget = Math.floor(CYCLES_PER_FRAME * this.speed); - const { core } = this.rp2040; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const clock = (this.rp2040 as any).clock; + // 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 { - let cyclesDone = 0; - const pioDiv = this.getPIOClockDiv(); - while (cyclesDone < cyclesTarget) { - if (core.waiting) { - if (clock) { - const jump: number = clock.nanosToNextAlarm; - if (jump <= 0) { - // No clock alarms — step PIO so it can unblock the CPU - // (e.g. PIO consuming FIFO data may generate an interrupt) - this.stepPIO(); - break; - } - const jumped = Math.ceil(jump / CYCLE_NANOS); - const pioSteps = Math.floor(jumped / pioDiv); - // Advance clock incrementally per PIO step so GPIO transitions - // get accurate timestamps (not all lumped at the end of the jump). - const nanoPerPioStep = pioDiv * CYCLE_NANOS; - const maxSteps = Math.min(pioSteps, 50000); - let nanosStepped = 0; - for (let i = 0; i < maxSteps; i++) { - clock.tick(nanoPerPioStep); - nanosStepped += nanoPerPioStep; - this.totalCycles += pioDiv; - this.stepPIO(); - } - // Tick any remaining nanoseconds not covered by PIO steps - const remaining = jump - nanosStepped; - if (remaining > 0) { - clock.tick(remaining); - this.totalCycles += Math.ceil(remaining / CYCLE_NANOS); - } - cyclesDone += jumped; - this.flushScheduledPinChanges(); - } else { - break; - } - } else { - const cycles: number = core.executeInstruction(); - if (clock) clock.tick(cycles * CYCLE_NANOS); - cyclesDone += cycles; - this.totalCycles += cycles; - // Step PIO synchronously at the PIO clock rate - this.pioStepAccum += cycles; - while (this.pioStepAccum >= pioDiv) { - this.pioStepAccum -= pioDiv; - this.stepPIO(); - } - this.flushScheduledPinChanges(); - } - } + this.runFrameForTime(deltaMs); } catch (error) { console.error('[RP2040] Simulation error:', error); this.stop(); @@ -699,6 +760,117 @@ export class RP2040Simulator { 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; @@ -710,6 +882,8 @@ export class RP2040Simulator { // 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'); } @@ -717,6 +891,7 @@ export class RP2040Simulator { 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)