diff --git a/crates/core/src/bus/accessors.rs b/crates/core/src/bus/accessors.rs index 9b42aed7..3879c972 100644 --- a/crates/core/src/bus/accessors.rs +++ b/crates/core/src/bus/accessors.rs @@ -302,7 +302,7 @@ impl crate::Bus for SystemBus { fn read_u32(&self, addr: u64) -> SimResult { // Debug (env-gated): trace the driver's reads of a freshly-injected RX // buffer, to RE the rx-control header format the RX callback parses. - if std::env::var("LABWIRED_RXBUF_TRACE").is_ok() { + if crate::peripherals::esp32c3::wifi_mac::rxbuf_trace_enabled() { let base = crate::peripherals::esp32c3::wifi_mac::RX_DBG_BUF .load(std::sync::atomic::Ordering::Relaxed) as u64; // Trace from 0x100 BEFORE the buffer (to catch the descriptor-list diff --git a/crates/core/src/bus/from_config.rs b/crates/core/src/bus/from_config.rs index 949ece0e..7de2c547 100644 --- a/crates/core/src/bus/from_config.rs +++ b/crates/core/src/bus/from_config.rs @@ -70,6 +70,7 @@ impl SystemBus { peripheral_hint: Cell::new(None), last_gpio_in: [0; 2], current_cycle: 0, + cycle_clock: crate::CycleClock::default(), pending_schedule: Vec::new(), legacy_walk_disabled: false, hcsr04: Vec::new(), @@ -82,6 +83,7 @@ impl SystemBus { esp32c3_system_idx: None, esp32c3_interrupt_core0_idx: None, esp32c3_irq_cache: None, + esp32c3_asserted_sources: [0; 2], esp32s3_irq_routing: false, esp32s3_intmatrix_idx: None, flash_models_ops: false, diff --git a/crates/core/src/bus/mod.rs b/crates/core/src/bus/mod.rs index 2e2497af..9452da34 100644 --- a/crates/core/src/bus/mod.rs +++ b/crates/core/src/bus/mod.rs @@ -206,7 +206,19 @@ pub struct SystemBus { /// lazily sync scheduler-driven peripherals (`uses_scheduler() == true`) /// to "now" before a register write observes their state. Only consulted /// under the `event-scheduler` feature; harmlessly 0 otherwise. + /// + /// Prefer [`Self::set_current_cycle`] over assigning this field directly: + /// the setter also publishes the value into [`Self::cycle_clock`], the + /// shared clock `&self` peripheral reads sync against. pub current_cycle: u64, + /// Walk-free plan Part 1: the shared cycle clock (`Arc`) + /// published in lock-step with `current_cycle` (via + /// [`Self::set_current_cycle`]) and handed to every peripheral at + /// [`Self::add_peripheral`] time via `Peripheral::attach_cycle_clock`. + /// Lets a `&self` MMIO read lazily sync `Cell`-held counter state to the + /// batch-start cycle — the read-side complement of the write-path + /// `sync_to`, with the identical "< one tick interval" freshness bound. + pub cycle_clock: crate::CycleClock, /// Phase 2B.3a (issue #192): write-context schedule requests buffered /// during MMIO writes. A scheduler-driven peripheral can't reach the /// scheduler from `write`, so after the write the bus collects its @@ -263,6 +275,15 @@ pub struct SystemBus { esp32c3_system_idx: Option, esp32c3_interrupt_core0_idx: Option, esp32c3_irq_cache: Option, + /// Bitmap (128 sources) of the interrupt-matrix source IDs asserted by the + /// most recent peripheral tick (`explicit_irqs` from the walk — e.g. the + /// SYSTIMER alarm on source 37). Stored so the write-choke re-aggregation + /// (`sync_esp32c3_irq_cache_write` → `recompute_esp32c3_irq_lines`) can + /// recombine them with the FROM_CPU/INTC state without waiting for the + /// next tick. Level semantics: rebuilt from scratch each tick, so a source + /// that stops asserting drops out at the next tick boundary (≤ one + /// `peripheral_tick_interval` — the same bound as the write path). + esp32c3_asserted_sources: [u64; 2], /// ESP32-S3 interrupt routing is present only when the S3 interrupt matrix /// peripheral is registered. Cached separately from C3's RISC-V routing so /// each chip model owns its own interrupt abstraction. @@ -1625,13 +1646,22 @@ impl SystemBus { /// aggregation, avoiding the phase-1 orchestration and its allocations every /// cycle. Only meaningful under the `event-scheduler` feature (the walk is /// never deleted otherwise). + /// + /// ESP32-C3 IRQ routing no longer pins this to `false` when the cached + /// aggregation is available: on a walk-deleted C3 bus there are no + /// tick-produced peripheral sources (nothing walks), and the remaining + /// routing inputs — INTC config + FROM_CPU IPI — are re-aggregated at + /// their MMIO write choke (`sync_esp32c3_irq_cache_write`), so the + /// per-cycle tick genuinely has nothing left to do. Without the cache + /// (hand-built buses) the per-tick register-read fallback is the only + /// aggregation point, so it keeps the walk-era behaviour. #[cfg(feature = "event-scheduler")] #[inline] fn per_cycle_tick_is_trivial(&self) -> bool { self.legacy_walk_disabled && self.bus_tick_indices.is_empty() && !self.nordic_gpio_service - && !self.esp32c3_irq_routing + && (!self.esp32c3_irq_routing || self.esp32c3_irq_cache.is_some()) && !self.esp32s3_irq_routing && self.can_diagnostic_testers.is_empty() && self.can_uds_testers.is_empty() @@ -2481,6 +2511,7 @@ impl SystemBus { peripheral_hint: Cell::new(None), last_gpio_in: [0; 2], current_cycle: 0, + cycle_clock: crate::CycleClock::default(), pending_schedule: Vec::new(), legacy_walk_disabled: false, reset_vector_offset: 0, @@ -2495,6 +2526,7 @@ impl SystemBus { esp32c3_system_idx: None, esp32c3_interrupt_core0_idx: None, esp32c3_irq_cache: None, + esp32c3_asserted_sources: [0; 2], esp32s3_irq_routing: false, esp32s3_intmatrix_idx: None, flash_models_ops: false, @@ -2536,6 +2568,7 @@ impl SystemBus { peripheral_hint: Cell::new(None), last_gpio_in: [0; 2], current_cycle: 0, + cycle_clock: crate::CycleClock::default(), pending_schedule: Vec::new(), legacy_walk_disabled: false, reset_vector_offset: 0, @@ -2550,6 +2583,7 @@ impl SystemBus { esp32c3_system_idx: None, esp32c3_interrupt_core0_idx: None, esp32c3_irq_cache: None, + esp32c3_asserted_sources: [0; 2], esp32s3_irq_routing: false, esp32s3_intmatrix_idx: None, flash_models_ops: false, @@ -2564,6 +2598,17 @@ impl SystemBus { bus } + /// Mirror `Machine::total_cycles` into the bus AND publish it on the + /// shared [`crate::CycleClock`] in one step, so the clock `&self` + /// peripheral reads sync against can never skew from `current_cycle`. + /// All engine refresh points (batch start/end, per-step, idle + /// fast-forward) go through here. + #[inline] + pub fn set_current_cycle(&mut self, cycle: u64) { + self.current_cycle = cycle; + self.cycle_clock.publish(cycle); + } + /// Append a peripheral to the bus at runtime. Useful for tests and /// dynamic configuration that bypasses `from_config`. /// @@ -2577,8 +2622,12 @@ impl SystemBus { base: u64, size: u64, irq: Option, - dev: Box, + mut dev: Box, ) { + // Attach choke point (walk-free plan Part 1): hand the peripheral the + // bus's shared cycle clock before it is registered, so read-side lazy + // sync is available from the first instruction. + dev.attach_cycle_clock(self.cycle_clock.clone()); self.peripherals.push(PeripheralEntry { name: name.to_string(), base, @@ -3044,11 +3093,17 @@ impl SystemBus { self.maybe_latch_dc(idx); let p = &mut self.peripherals[idx]; p.ticks_remaining = 0; - let r = p.dev.write_u32(addr - p.base, value); + let base = p.base; + let r = p.dev.write_u32(addr - base, value); self.maybe_arm_hcsr04(idx); #[cfg(feature = "event-scheduler")] self.collect_scheduled_events(idx); if r.is_ok() { + // Keep the C3 IRQ routing cache coherent on this inherent + // write path too (the Bus-trait accessors already do this) — + // host/tooling writes to INTC or FROM_CPU must re-aggregate + // exactly like CPU stores. + self.sync_esp32c3_irq_cache_write(idx, addr - base); self.refresh_legacy_tick_index(idx); self.refresh_bus_tick_index(idx); } @@ -3112,11 +3167,14 @@ impl SystemBus { self.maybe_latch_dc(idx); let p = &mut self.peripherals[idx]; p.ticks_remaining = 0; - let r = p.dev.write_u16(addr - p.base, value); + let base = p.base; + let r = p.dev.write_u16(addr - base, value); self.maybe_arm_hcsr04(idx); #[cfg(feature = "event-scheduler")] self.collect_scheduled_events(idx); if r.is_ok() { + // Cache coherence — see the write_u32 note above. + self.sync_esp32c3_irq_cache_write(idx, addr - base); self.refresh_legacy_tick_index(idx); self.refresh_bus_tick_index(idx); } @@ -4949,6 +5007,7 @@ peripherals: peripheral_hint: Cell::new(None), last_gpio_in: [0; 2], current_cycle: 0, + cycle_clock: crate::CycleClock::default(), pending_schedule: Vec::new(), legacy_walk_disabled: false, reset_vector_offset: 0, @@ -4963,6 +5022,7 @@ peripherals: esp32c3_system_idx: None, esp32c3_interrupt_core0_idx: None, esp32c3_irq_cache: None, + esp32c3_asserted_sources: [0; 2], esp32s3_irq_routing: false, esp32s3_intmatrix_idx: None, flash_models_ops: false, @@ -5028,6 +5088,7 @@ peripherals: peripheral_hint: Cell::new(None), last_gpio_in: [0; 2], current_cycle: 0, + cycle_clock: crate::CycleClock::default(), pending_schedule: Vec::new(), legacy_walk_disabled: false, reset_vector_offset: 0, @@ -5042,6 +5103,7 @@ peripherals: esp32c3_system_idx: None, esp32c3_interrupt_core0_idx: None, esp32c3_irq_cache: None, + esp32c3_asserted_sources: [0; 2], esp32s3_irq_routing: false, esp32s3_intmatrix_idx: None, flash_models_ops: false, @@ -5258,6 +5320,7 @@ peripherals: peripheral_hint: Cell::new(None), last_gpio_in: [0; 2], current_cycle: 0, + cycle_clock: crate::CycleClock::default(), pending_schedule: Vec::new(), legacy_walk_disabled: false, reset_vector_offset: 0, @@ -5272,6 +5335,7 @@ peripherals: esp32c3_system_idx: None, esp32c3_interrupt_core0_idx: None, esp32c3_irq_cache: None, + esp32c3_asserted_sources: [0; 2], esp32s3_irq_routing: false, esp32s3_intmatrix_idx: None, flash_models_ops: false, @@ -5487,6 +5551,7 @@ peripherals: peripheral_hint: Cell::new(None), last_gpio_in: [0; 2], current_cycle: 0, + cycle_clock: crate::CycleClock::default(), pending_schedule: Vec::new(), legacy_walk_disabled: false, reset_vector_offset: 0, @@ -5501,6 +5566,7 @@ peripherals: esp32c3_system_idx: None, esp32c3_interrupt_core0_idx: None, esp32c3_irq_cache: None, + esp32c3_asserted_sources: [0; 2], esp32s3_irq_routing: false, esp32s3_intmatrix_idx: None, flash_models_ops: false, @@ -5570,6 +5636,7 @@ peripherals: peripheral_hint: Cell::new(None), last_gpio_in: [0; 2], current_cycle: 0, + cycle_clock: crate::CycleClock::default(), pending_schedule: Vec::new(), legacy_walk_disabled: false, reset_vector_offset: 0, @@ -5584,6 +5651,7 @@ peripherals: esp32c3_system_idx: None, esp32c3_interrupt_core0_idx: None, esp32c3_irq_cache: None, + esp32c3_asserted_sources: [0; 2], esp32s3_irq_routing: false, esp32s3_intmatrix_idx: None, flash_models_ops: false, diff --git a/crates/core/src/bus/routing.rs b/crates/core/src/bus/routing.rs index 96a46432..5d85bdca 100644 --- a/crates/core/src/bus/routing.rs +++ b/crates/core/src/bus/routing.rs @@ -268,6 +268,11 @@ impl SystemBus { } self.esp32c3_irq_cache = Some(cache); + // Keep the routed line mask coherent with the rebuilt cache (no-op + // unless C3 routing is active — `recompute` early-outs without it). + if self.esp32c3_irq_routing { + self.recompute_esp32c3_irq_lines(); + } } pub(crate) fn sync_esp32c3_irq_cache_write(&mut self, idx: usize, offset: u64) { @@ -280,8 +285,10 @@ impl SystemBus { return; }; + let mut inputs_changed = false; if Some(idx) == self.esp32c3_interrupt_core0_idx { if let Some(cache) = &mut self.esp32c3_irq_cache { + inputs_changed = true; match aligned { 0x104 => cache.int_enable = value, 0x194 => cache.int_thresh = (value & 0xF) as u8, @@ -304,6 +311,7 @@ impl SystemBus { let slot = ((aligned - 0x28) / 4) as u8; if slot < 4 { if let Some(cache) = &mut self.esp32c3_irq_cache { + inputs_changed = true; if value & 1 != 0 { cache.from_cpu_pending |= 1 << slot; } else { @@ -312,6 +320,18 @@ impl SystemBus { } } } + + // Write-choke re-aggregation: a routing-input change (INTC config or + // FROM_CPU IPI) updates `riscv_irq_lines` at the write instruction + // instead of waiting for the next peripheral tick. At interval 1 the + // tick-end rebuild recomputes the same mask before the CPU's next + // interrupt check (byte-identical); at interval > 1 this removes the + // up-to-one-interval delivery latency for yield/critical-section + // transitions and lets a walk-free C3 bus keep IPI routing correct + // with no per-cycle aggregation at all. + if inputs_changed && self.esp32c3_irq_routing { + self.recompute_esp32c3_irq_lines(); + } } pub fn refresh_peripheral_index(&mut self) { diff --git a/crates/core/src/bus/tick.rs b/crates/core/src/bus/tick.rs index f8b9ae35..4cb243d0 100644 --- a/crates/core/src/bus/tick.rs +++ b/crates/core/src/bus/tick.rs @@ -433,56 +433,53 @@ impl SystemBus { /// pushes the per-source assertion bitmap into the intmatrix's /// PRO_INTR_STATUS_REG_n mirror via `set_pending_sources`. No-op for buses /// without an intmatrix peripheral. - /// ESP32-C3 (RISC-V) interrupt routing. Each tick, build the level-sensitive - /// bitmask of asserted CPU interrupt lines from the SYSTEM FROM_CPU IPI - /// registers (0x600C0028..0x34, bit0) — the mechanism FreeRTOS `vPortYield` - /// uses to request a context switch. Each asserted source is routed to a CPU - /// line via its INTERRUPT_CORE0 MAP register (0x600C2000 + source*4, low 5 - /// bits), gated by CPU_INT_ENABLE and per-line priority vs CPU_INT_THRESH. - /// The result lands in `riscv_irq_lines`, which the core ORs into `mip`. - /// No-op unless `esp32c3_irq_routing` is set (only the C3 rom-boot path sets - /// it). Peripheral `explicit_irqs` (e.g. the reused S3 SYSTIMER model) are - /// not routed yet — their source IDs don't match the C3 matrix. + /// ESP32-C3 (RISC-V) interrupt routing. Each tick, record the bitmap of + /// asserting peripheral interrupt-matrix sources (`explicit_irqs` from the + /// walk — e.g. the SYSTIMER tick alarm on source 37) and rebuild the + /// level-sensitive bitmask of asserted CPU interrupt lines from them plus + /// the SYSTEM FROM_CPU IPI registers (0x600C0028..0x34, bit0) — the + /// mechanism FreeRTOS `vPortYield` uses to request a context switch. Each + /// asserted source is routed to a CPU line via its INTERRUPT_CORE0 MAP + /// register (0x600C2000 + source*4, low 5 bits), gated by CPU_INT_ENABLE + /// and per-line priority vs CPU_INT_THRESH. The result lands in + /// `riscv_irq_lines`, which the core ORs into `mip`. No-op unless + /// `esp32c3_irq_routing` is set (only the C3 rom-boot path sets it). + /// + /// This tick-time pass is no longer the only aggregation point: MMIO + /// writes that change the routing inputs (INTC enable/threshold/priority/ + /// map, FROM_CPU IPI set/clear) re-aggregate immediately from the write + /// choke (`sync_esp32c3_irq_cache_write`), so at a tick interval above + /// one a mid-batch yield/critical-section change is reflected at the + /// write instruction instead of waiting for the tick boundary. Peripheral + /// source assert/de-assert stays tick-quantised (≤ one interval — the + /// same bound the write-path `sync_to` documents). At interval 1 the + /// tick-end rebuild below runs before the CPU's next instruction-boundary + /// interrupt check, so behaviour is byte-identical to the pre-choke code. fn aggregate_esp32c3_irqs(&mut self, source_ids: &[u32]) { if !self.esp32c3_irq_routing { return; } - const INTMATRIX_BASE: u64 = 0x600C_2000; - const FROM_CPU_SOURCE_BASE: u32 = 50; - // INTC control registers (offsets verified against interrupt_core0.yaml): - // CPU_INT_ENABLE 0x104, CPU_INT_PRI_n 0x114+n*4, CPU_INT_THRESH 0x194. - // A line fires only while it is enabled AND its priority >= threshold — - // the C3 enables/masks via these INTC registers, NOT the RISC-V `mie` - // CSR (FreeRTOS critical sections raise the threshold to mask). - if let Some(cache) = &self.esp32c3_irq_cache { - let mut mask = 0u32; - let mut route_source = |src: u32| { - let Some(&line) = cache.source_line.get(src as usize) else { - return; - }; - if line == 0 || (cache.int_enable & (1u32 << line)) == 0 { - return; - } - let pri = cache.line_pri.get(line as usize).copied().unwrap_or(0); - if pri >= cache.int_thresh { - mask |= 1u32 << line; - } - }; - - for &src in source_ids { - route_source(src); - } - let mut pending = cache.from_cpu_pending; - while pending != 0 { - let slot = pending.trailing_zeros(); - route_source(FROM_CPU_SOURCE_BASE + slot); - pending &= !(1 << slot); + // Record the level sources asserting THIS tick (rebuilt from scratch, + // so a de-asserting source drops out at the tick boundary), then + // recompute the routed line mask from the shared choke. + let mut asserted = [0u64; 2]; + for &src in source_ids { + let idx = (src / 64) as usize; + if idx < asserted.len() { + asserted[idx] |= 1u64 << (src % 64); } - self.riscv_irq_lines = mask; + } + self.esp32c3_asserted_sources = asserted; + + if self.esp32c3_irq_cache.is_some() { + self.recompute_esp32c3_irq_lines(); return; } + // Fallback for buses without the declarative INTC cache (hand-built + // test buses): read the routing registers directly each tick. + const INTMATRIX_BASE: u64 = 0x600C_2000; const FROM_CPU: [(u64, u32); 4] = [ (0x600C_0028, 50), (0x600C_002C, 51), @@ -503,16 +500,6 @@ impl SystemBus { // MAP register holds the destination CPU interrupt line (1..31). let line = read_intcore(self, (src as u64) * 4) & 0x1F; let pri = read_intcore(self, 0x114 + (line as u64) * 4) & 0xF; - if src == 0 && std::env::var("LABWIRED_RXBUF_TRACE").is_ok() { - use std::sync::atomic::{AtomicU32, Ordering}; - static N: AtomicU32 = AtomicU32::new(0); - if N.fetch_add(1, Ordering::Relaxed) < 3 { - eprintln!( - "[macirq] src0 line={line} enable_bit={} pri={pri} thresh={thresh}", - (enable >> line) & 1 - ); - } - } if line == 0 || (enable & (1 << line)) == 0 { return; } @@ -544,6 +531,54 @@ impl SystemBus { self.riscv_irq_lines = mask; } + /// Rebuild `riscv_irq_lines` from the cached C3 routing state: the INTC + /// register cache (enable/threshold/priority/map — maintained at the MMIO + /// write choke), the cached FROM_CPU IPI pending bits, and the peripheral + /// sources recorded by the most recent tick. The single aggregation body + /// shared by the per-tick pass and the write-choke re-aggregation, so both + /// produce identical masks from identical inputs. + /// + /// INTC control registers (offsets verified against interrupt_core0.yaml): + /// CPU_INT_ENABLE 0x104, CPU_INT_PRI_n 0x114+n*4, CPU_INT_THRESH 0x194. + /// A line fires only while it is enabled AND its priority >= threshold — + /// the C3 enables/masks via these INTC registers, NOT the RISC-V `mie` + /// CSR (FreeRTOS critical sections raise the threshold to mask). + pub(crate) fn recompute_esp32c3_irq_lines(&mut self) { + const FROM_CPU_SOURCE_BASE: u32 = 50; + let Some(cache) = &self.esp32c3_irq_cache else { + return; + }; + let mut mask = 0u32; + let mut route_source = |src: u32| { + let Some(&line) = cache.source_line.get(src as usize) else { + return; + }; + if line == 0 || (cache.int_enable & (1u32 << line)) == 0 { + return; + } + let pri = cache.line_pri.get(line as usize).copied().unwrap_or(0); + if pri >= cache.int_thresh { + mask |= 1u32 << line; + } + }; + + for (word, &bits) in self.esp32c3_asserted_sources.iter().enumerate() { + let mut bits = bits; + while bits != 0 { + let bit = bits.trailing_zeros(); + route_source(word as u32 * 64 + bit); + bits &= !(1u64 << bit); + } + } + let mut pending = cache.from_cpu_pending; + while pending != 0 { + let slot = pending.trailing_zeros(); + route_source(FROM_CPU_SOURCE_BASE + slot); + pending &= !(1 << slot); + } + self.riscv_irq_lines = mask; + } + fn aggregate_esp32s3_explicit_irqs(&mut self, source_ids: &[u32]) { // Rebuild the per-core routed pending bitmap as a faithful LEVEL // reflection of the sources asserting THIS tick — set while a source diff --git a/crates/core/src/cycle_clock.rs b/crates/core/src/cycle_clock.rs new file mode 100644 index 00000000..580d7b39 --- /dev/null +++ b/crates/core/src/cycle_clock.rs @@ -0,0 +1,64 @@ +// LabWired - Firmware Simulation Platform +// Copyright (C) 2026 Andrii Shylenko +// SPDX-License-Identifier: MIT + +//! Bus-published simulation cycle clock — the read-side freshness mechanism +//! from the walk-free plan (Part 1, option (b)+(c)). +//! +//! ## The problem it solves +//! +//! Scheduler-migrated peripherals advance lazily: the bus calls +//! `Peripheral::sync_to(current_cycle)` before an MMIO **write** observes +//! them. But firmware also polls free-running counters by **reading** them, +//! and the bus read path is `&self` — a read cannot call `sync_to(&mut …)`. +//! Making reads `&mut` was evaluated and rejected (134 `impl Peripheral` +//! blocks, ~1500 `Bus::read_*` call sites, and — fatally — the CPU holds +//! shared borrows of peripheral-backed buffers during instruction fetch). +//! +//! ## The mechanism +//! +//! The bus owns a [`CycleClock`] (an `Arc`) and publishes +//! `current_cycle` into it at exactly the points `bus.current_cycle` itself +//! is refreshed — batch start, batch end, per-step, and idle fast-forward +//! (see `SystemBus::set_current_cycle`). Peripherals receive a clone at +//! attach time via [`crate::Peripheral::attach_cycle_clock`] and may consult +//! it from a `&self` read, advancing `Cell`-held counter state to "now" +//! (the `Peripheral` trait is `Send`, not `Sync`, and a machine is +//! single-threaded, so interior mutability is sound; `Arc` rather +//! than `Rc` keeps the `Send` bound). +//! +//! ## The determinism contract (batch-boundary freshness) +//! +//! During a CPU batch `current_cycle` holds the **batch-start** cycle, so a +//! read synced to the published clock is exact at batch boundaries and +//! trails the true cycle by strictly less than one `peripheral_tick_interval` +//! mid-batch — **identical to the bound the write-path `sync_to` already +//! ships** (see the doc on `Peripheral::sync_to`). At interval 1 batches are +//! one instruction and the value is exact everywhere the legacy walk was. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +/// Shared, bus-published "now" in CPU cycles. Cheap to clone (one `Arc`); +/// one instance per [`crate::bus::SystemBus`], handed to peripherals at +/// attach time. +#[derive(Debug, Clone, Default)] +pub struct CycleClock { + inner: Arc, +} + +impl CycleClock { + /// The most recently published CPU cycle (the batch-start cycle during a + /// CPU batch — see the module docs for the freshness bound). + #[inline] + pub fn now(&self) -> u64 { + self.inner.load(Ordering::Relaxed) + } + + /// Publish `cycle` as "now". Called by the bus wherever + /// `bus.current_cycle` is refreshed; peripherals never publish. + #[inline] + pub fn publish(&self, cycle: u64) { + self.inner.store(cycle, Ordering::Relaxed); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 9f385156..af99b3c0 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -11,6 +11,7 @@ pub mod config; pub mod cosim; pub mod coverage; pub mod cpu; +pub mod cycle_clock; pub mod decoder; pub mod fidelity; pub mod inspect; @@ -34,6 +35,7 @@ pub mod vfi; pub mod world; pub use config::SimulationConfig; +pub use cycle_clock::CycleClock; use std::any::Any; use std::sync::Arc; @@ -691,6 +693,20 @@ pub trait Peripheral: std::fmt::Debug + Send { fn take_scheduled_events(&mut self) -> Vec<(u64, u32)> { Vec::new() } + + /// Walk-free plan Part 1: hand this peripheral the bus's shared + /// [`CycleClock`] so `&self` reads can lazily sync `Cell`-held counter + /// state to the published "now" (batch-boundary freshness — exact at + /// batch boundaries, < one `peripheral_tick_interval` stale mid-batch, + /// the same bound as the write-path [`Self::sync_to`]). + /// + /// Called once by `SystemBus::add_peripheral` at attach time. Default + /// no-op: peripherals that don't opt in never see it. A read-polled + /// counter model overrides this to store the clock; the conservative + /// contract is that WITHOUT an attached clock the model must stay on + /// its legacy walk path (`uses_scheduler() == false`), so hand-built + /// buses that bypass `add_peripheral` keep the old exact semantics. + fn attach_cycle_clock(&mut self, _clock: CycleClock) {} } /// Trait representing the system bus @@ -1249,7 +1265,7 @@ impl Machine { let skipped = budget.min(u32::MAX as u64) as u32; self.cpu.fast_forward_idle_cycles(skipped as u64); self.total_cycles += skipped as u64; - self.bus.current_cycle = self.total_cycles; + self.bus.set_current_cycle(self.total_cycles); self.bus.bus_trace.set_cycle(self.total_cycles); // Push-mode logic capture: stamp any pad writes made by the // scheduler events due at the end of the skipped window with the @@ -1463,8 +1479,9 @@ impl Machine { // Mirror the cycle count into the bus before the CPU executes, so // tick-time services can read "now": scheduler-driven peripheral sync // (event-scheduler) and the HC-SR04 echo-window timing (always). O(1) — - // a single field write, not the per-peripheral walk this phase removed. - self.bus.current_cycle = self.total_cycles; + // a field write + a relaxed atomic store (the shared read-sync clock), + // not the per-peripheral walk this phase removed. + self.bus.set_current_cycle(self.total_cycles); self.bus.bus_trace.set_cycle(self.total_cycles); // The cycle boundary this instruction's effects become observable at — // pad writes pushed through the logic tap stamp with it (single-step @@ -2026,7 +2043,7 @@ impl DebugControl for Machine { // Mirror the cycle count before the batch so MMIO writes inside it // (and tick-time services) can read "now". The batch is bounded by // `peripheral_tick_interval`, so intra-batch staleness is < one tick. - self.bus.current_cycle = current_cycles; + self.bus.set_current_cycle(current_cycles); self.bus.bus_trace.set_cycle(current_cycles); let tick_interval = self.config.peripheral_tick_interval as u64; let remaining_until_tick = (tick_interval - (current_cycles % tick_interval)) as u32; @@ -2137,7 +2154,7 @@ impl DebugControl for Machine { // fall would still read high and never transition). #[cfg(feature = "event-scheduler")] { - self.bus.current_cycle = self.total_cycles; + self.bus.set_current_cycle(self.total_cycles); } #[cfg(feature = "event-scheduler")] diff --git a/crates/core/src/peripherals/esp32c3/rtc_timer.rs b/crates/core/src/peripherals/esp32c3/rtc_timer.rs index 64561e78..351cebb6 100644 --- a/crates/core/src/peripherals/esp32c3/rtc_timer.rs +++ b/crates/core/src/peripherals/esp32c3/rtc_timer.rs @@ -20,15 +20,44 @@ //! reach its designed timeout and continue, exactly as silicon does when the //! calibration can't converge. //! -//! The counter advances one tick per simulated CPU step (`tick()` is called -//! every step at the default `peripheral_tick_interval = 1`). The absolute +//! ## Time source (walk-free plan Part 1 — first production user) +//! +//! The counter advances one tick per simulated CPU cycle; the absolute //! slow-clock rate is not modelled — only that time *advances* monotonically, -//! which is all the deadline comparisons observe. All other registers in the -//! window are register-backed (writes stored, reads return the last value) so -//! the rest of RTC_CNTL bring-up (reset-cause seed at `0x38`, ANA config, …) -//! behaves like the previous declarative stub. +//! which is all the deadline comparisons observe. Two coexisting drive modes: +//! +//! * **Scheduler mode** (`event-scheduler` feature + a [`crate::CycleClock`] +//! attached by `SystemBus::add_peripheral`): `uses_scheduler()` is true, the +//! per-cycle walk skips this peripheral entirely, and the counter advances +//! **lazily** — `advance_to(now)` runs from the write-path `sync_to` choke +//! and from the `TIME_UPDATE` latch itself, which pulls "now" from the +//! bus-published clock. Freshness contract: the latched value is exact at +//! batch boundaries and trails the true cycle by < one +//! `peripheral_tick_interval` mid-batch — the same quantisation the legacy +//! walk itself exhibits at that interval, so firmware delay loops (which +//! re-latch every poll iteration) terminate exactly as before. This is what +//! un-pins the walk: the old `uses_scheduler() == false` existed purely +//! because a `&self` read could not sync (the historical comment feared +//! "firmware delay loops observe stale time and spin forever"). +//! +//! * **Legacy mode** (feature off, or no clock attached — e.g. hand-built +//! test buses that bypass `add_peripheral`): the per-cycle walk drives +//! `tick_elapsed(cycles)` and the counter advances eagerly, byte-identical +//! to the historical behaviour. +//! +//! The two modes are mutually exclusive by construction: `tick_elapsed` is a +//! no-op while scheduler mode is active (the walk never calls it there — the +//! guard is defensive), and the lazy `advance_to` path is anchored so repeated +//! syncs to the same cycle are idempotent. The old code kept a parallel +//! `anchor_tick` bump inside `tick_elapsed` to feed a then-dead `sync_to`; +//! that was a double-count trap (relative walk anchor vs absolute cycle +//! anchor) and is gone — the anchor now belongs exclusively to the lazy path. +//! +//! All other registers in the window are register-backed (writes stored, +//! reads return the last value) so the rest of RTC_CNTL bring-up (reset-cause +//! seed at `0x38`, ANA config, …) behaves like the previous declarative stub. -use crate::{Peripheral, SimResult}; +use crate::{CycleClock, Peripheral, SimResult}; use std::cell::Cell; const TIME_UPDATE: u64 = 0x0C; // bit31 = latch request @@ -40,13 +69,19 @@ const TIME_UPDATE_BIT: u32 = 1 << 31; pub struct Esp32c3RtcTimer { /// Register-backed storage for the whole window (non-timer registers). regs: Vec, - /// Free-running 48-bit slow-clock counter, advanced once per step. + /// Free-running 48-bit slow-clock counter (one tick per CPU cycle). counter: Cell, /// Counter value latched by the most recent TIME_UPDATE write; what the /// TIME0/TIME1 readout registers return. latched: Cell, - /// Scheduler/elapsed-mode anchor in peripheral-tick units. + /// Lazy-path anchor: the absolute CPU cycle `counter` was last advanced + /// to. Owned exclusively by `advance_to` (scheduler mode); the legacy + /// walk never touches it. anchor_tick: Cell, + /// Bus-published cycle clock (walk-free plan Part 1). `Some` once + /// `SystemBus::add_peripheral` attaches it; `None` keeps the model on + /// the legacy walk path. + clock: Option, } impl Default for Esp32c3RtcTimer { @@ -63,12 +98,53 @@ impl Esp32c3RtcTimer { counter: Cell::new(0), latched: Cell::new(0), anchor_tick: Cell::new(0), + clock: None, } } pub fn new() -> Self { Self::new_sized(0x100) } + + /// True when the event scheduler owns this timer's time base (feature + /// on AND bus clock attached). Everything time-related branches on this + /// ONE predicate so the two drive modes can never mix. + #[inline] + fn scheduler_mode(&self) -> bool { + cfg!(feature = "event-scheduler") && self.clock.is_some() + } + + /// Lazy advance to absolute CPU cycle `now` — callable from `&self` + /// (all mutated state is in `Cell`). Idempotent: repeated calls with the + /// same `now` add nothing; `now` older than the anchor is ignored (the + /// clock is monotonic within a run; a stale read must never rewind). + fn advance_to(&self, now: u64) { + let anchor = self.anchor_tick.get(); + if now <= anchor { + return; + } + self.counter + .set(self.counter.get().wrapping_add(now - anchor)); + self.anchor_tick.set(now); + } + + /// Pull "now" from the bus-published clock and advance. No-op without an + /// attached clock (legacy mode — the walk advances the counter instead). + fn sync_from_clock(&self) { + if let Some(clock) = &self.clock { + if self.scheduler_mode() { + self.advance_to(clock.now()); + } + } + } + + /// Test/differential knob: detach the cycle clock, pinning the model to + /// the legacy walk path (`uses_scheduler() == false`). Used by the + /// walk-on-vs-scheduler differential gates to build the reference config + /// from the same bus assembly. + pub fn force_legacy_walk(&mut self) { + self.clock = None; + } } impl Peripheral for Esp32c3RtcTimer { @@ -94,7 +170,13 @@ impl Peripheral for Esp32c3RtcTimer { fn write_u32(&mut self, offset: u64, value: u32) -> SimResult<()> { if offset == TIME_UPDATE && (value & TIME_UPDATE_BIT) != 0 { - // Latch the current counter into the readout registers. + // Latch the current counter into the readout registers. In + // scheduler mode, first advance to the bus-published "now" so the + // latch is fresh-to-batch-start even though the walk no longer + // ticks this model. (The bus write path already ran `sync_to` + // before this write; the explicit sync keeps direct/unit-test + // writes correct too, and is idempotent.) + self.sync_from_clock(); self.latched.set(self.counter.get()); } if let Some(slot) = self.regs.get_mut((offset / 4) as usize) { @@ -107,29 +189,35 @@ impl Peripheral for Esp32c3RtcTimer { self.tick_elapsed(1) } + /// Legacy walk drive: one slow-clock tick per elapsed CPU cycle. Never + /// runs in scheduler mode (the walk skips `uses_scheduler()` peripherals; + /// the guard below keeps a stray direct call from double-counting against + /// the lazy anchor). fn tick_elapsed(&mut self, cycles: u64) -> crate::PeripheralTickResult { - // One slow-clock tick per simulated step — time advances monotonically. - self.counter.set(self.counter.get().wrapping_add(cycles)); - self.anchor_tick - .set(self.anchor_tick.get().wrapping_add(cycles)); + if !self.scheduler_mode() { + self.counter.set(self.counter.get().wrapping_add(cycles)); + } crate::PeripheralTickResult::default() } fn uses_scheduler(&self) -> bool { - // The bus read API is intentionally `&self`; until it can sync - // scheduler-driven peripherals before reads, this read-driven RTC must - // stay on the legacy tick path or firmware delay loops observe stale - // time and spin forever. - false + // True once the bus attached its cycle clock (event-scheduler builds): + // reads stay fresh through the lazy `advance_to` path, so the old + // "stale time → delay loops spin forever" blocker is gone. Without a + // clock (feature off / hand-built buses) stay on the legacy walk. + self.scheduler_mode() } - fn sync_to(&mut self, tick_now: u64) { - if tick_now <= self.anchor_tick.get() { - return; - } - let delta = tick_now - self.anchor_tick.get(); - self.counter.set(self.counter.get().wrapping_add(delta)); - self.anchor_tick.set(tick_now); + fn sync_to(&mut self, now_cycle: u64) { + self.advance_to(now_cycle); + } + + fn attach_cycle_clock(&mut self, clock: CycleClock) { + // Anchor at the clock's current value so cycles that elapsed before + // attach (normally zero — attach happens at bus assembly) are not + // retroactively credited to the counter. + self.anchor_tick.set(clock.now()); + self.clock = Some(clock); } fn as_any(&self) -> Option<&dyn std::any::Any> { @@ -162,7 +250,19 @@ impl Peripheral for Esp32c3RtcTimer { self.regs = snap.regs; self.counter.set(snap.counter); self.latched.set(snap.latched); - self.anchor_tick.set(snap.anchor_tick); + // Re-anchor rather than trusting the persisted anchor: a snapshot is + // typically resumed on a FRESH machine whose cycle count restarts at + // ~0, so a large persisted anchor would make `advance_to(now)` see + // `now <= anchor` and freeze the counter for millions of cycles — + // silently re-introducing the spin-forever failure the model exists + // to prevent. Anchoring to the live clock keeps the restored counter + // value (the part boot-log determinism depends on) and resumes + // monotonic advance from the resuming machine's "now". The persisted + // field is kept in the blob for format stability / legacy readers. + match &self.clock { + Some(clock) => self.anchor_tick.set(clock.now()), + None => self.anchor_tick.set(snap.anchor_tick), + } Ok(()) } } @@ -228,13 +328,94 @@ mod tests { } #[test] - fn rtc_timer_stays_on_legacy_tick_path() { + fn without_clock_stays_on_legacy_tick_path() { let t = Esp32c3RtcTimer::new(); - assert!( !t.uses_scheduler(), - "RTC timer reads are time-sensitive; until bus reads can sync scheduler-driven \ - peripherals, the C3 RTC must stay on the legacy tick path" + "no cycle clock attached → the model must stay on the legacy walk \ + (hand-built buses that bypass add_peripheral keep exact semantics)" + ); + } + + #[cfg(feature = "event-scheduler")] + #[test] + fn clock_attach_flips_to_scheduler_and_latch_tracks_published_clock() { + let clock = CycleClock::default(); + let mut t = Esp32c3RtcTimer::new(); + t.attach_cycle_clock(clock.clone()); + assert!( + t.uses_scheduler(), + "clock attached under event-scheduler → walk-independent" + ); + + // The walk no longer drives it; the latch must pull time from the + // published clock — this is the exact firmware delay-loop shape the + // old comment feared (poll = TIME_UPDATE write + TIME0/1 read). + clock.publish(1234); + assert_eq!(rtc_time_get(&mut t), 1234, "latch synced to published now"); + clock.publish(1234 + 4096); + assert_eq!(rtc_time_get(&mut t), 1234 + 4096, "monotonic re-latch"); + + // Idempotent: re-latching at the same published cycle adds nothing. + assert_eq!(rtc_time_get(&mut t), 1234 + 4096); + } + + #[cfg(feature = "event-scheduler")] + #[test] + fn scheduler_mode_write_sync_and_clock_sync_do_not_double_count() { + let clock = CycleClock::default(); + let mut t = Esp32c3RtcTimer::new(); + t.attach_cycle_clock(clock.clone()); + + clock.publish(500); + // Bus write path: sync_to(current_cycle) runs before the MMIO write… + t.sync_to(500); + // …then the TIME_UPDATE latch syncs from the clock again. Same cycle, + // so the counter must be advanced exactly once. + assert_eq!(rtc_time_get(&mut t), 500); + + // A stray legacy tick in scheduler mode must not double-count either. + t.tick_elapsed(64); + assert_eq!( + rtc_time_get(&mut t), + 500, + "tick_elapsed inert in scheduler mode" + ); + } + + #[cfg(feature = "event-scheduler")] + #[test] + fn resume_re_anchors_and_keeps_counting() { + // Cold machine ran to cycle 150M and snapshotted. + let cold_clock = CycleClock::default(); + let mut cold = Esp32c3RtcTimer::new(); + cold.attach_cycle_clock(cold_clock.clone()); + cold_clock.publish(150_000_000); + let cold_time = rtc_time_get(&mut cold); + assert_eq!(cold_time, 150_000_000); + let blob = cold.runtime_snapshot(); + + // Resume on a FRESH machine whose cycle count restarts near zero. + let warm_clock = CycleClock::default(); + let mut warm = Esp32c3RtcTimer::new(); + warm.attach_cycle_clock(warm_clock.clone()); + warm.restore_runtime_snapshot(&blob).unwrap(); + + // The restored counter value carries over… + warm_clock.publish(0); + assert_eq!( + rtc_time_get(&mut warm), + cold_time, + "counter survives resume" + ); + // …and time keeps advancing from the resuming machine's clock instead + // of freezing until it catches up to the persisted 150M anchor (the + // stale-anchor spin-forever trap). + warm_clock.publish(1_000); + assert_eq!( + rtc_time_get(&mut warm), + cold_time + 1_000, + "counter must keep advancing immediately after resume" ); } diff --git a/crates/core/src/peripherals/esp32c3/wifi_mac.rs b/crates/core/src/peripherals/esp32c3/wifi_mac.rs index 1ceeb474..c6851582 100644 --- a/crates/core/src/peripherals/esp32c3/wifi_mac.rs +++ b/crates/core/src/peripherals/esp32c3/wifi_mac.rs @@ -39,6 +39,17 @@ use std::sync::atomic::AtomicU32; /// format). 0 = no delivery yet. pub static RX_DBG_BUF: AtomicU32 = AtomicU32::new(0); +/// Process-cached `LABWIRED_RXBUF_TRACE` gate. The trace guard sits on the +/// hottest path in the engine (`Bus::read_u32` — every load instruction), and +/// `std::env::var` is a real syscall-backed lookup; checking it per read cost +/// measurable native/wasm throughput (profiling artifact found on the C3 OLED +/// lab). The env var is read ONCE per process — set it before launch, as with +/// any debug trace. +pub(crate) fn rxbuf_trace_enabled() -> bool { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *ENABLED.get_or_init(|| std::env::var("LABWIRED_RXBUF_TRACE").is_ok()) +} + const MAC_READY: u64 = 0xD14; // bit0 polled by hal_init const RX_RING_BASE: u64 = 0x88; // driver writes the RX descriptor-list head here /// RX descriptor-reload handshake (`hal_mac_rx_*_dscr_reload`): the driver sets @@ -346,7 +357,7 @@ impl Esp32c3WifiMac { self.regs[(0x8c / 4) as usize] = next; self.set_event(EVENT_RX_DONE); RX_DBG_BUF.store(buf, std::sync::atomic::Ordering::Relaxed); - if std::env::var("LABWIRED_RXBUF_TRACE").is_ok() { + if rxbuf_trace_enabled() { eprintln!( "[rxinj] desc={desc:#010x} buf={buf:#010x} total={total} (hdr48+frame{})", frame.len() diff --git a/crates/core/src/peripherals/hc_sr04.rs b/crates/core/src/peripherals/hc_sr04.rs index fae6e5b6..d1cfd818 100644 --- a/crates/core/src/peripherals/hc_sr04.rs +++ b/crates/core/src/peripherals/hc_sr04.rs @@ -347,17 +347,17 @@ mod tests { // Pulse TRIG high via BSRR, service at cycle 0 → arms window [200, 6000). bus.write_u32(GPIOA + 0x18, 1 << 8).unwrap(); - bus.current_cycle = 0; + bus.set_current_cycle(0); bus.service_hcsr04(); assert_eq!(echo(&bus), 0, "echo still low during trig→echo delay"); // Mid-window: ECHO driven high. - bus.current_cycle = 3000; + bus.set_current_cycle(3000); bus.service_hcsr04(); assert_eq!(echo(&bus), 1, "echo high mid-pulse"); // Past the window: ECHO back low. - bus.current_cycle = 7000; + bus.set_current_cycle(7000); bus.service_hcsr04(); assert_eq!(echo(&bus), 0, "echo low after pulse"); } diff --git a/crates/core/src/tests/esp32c3_rtc_delay_loop.rs b/crates/core/src/tests/esp32c3_rtc_delay_loop.rs new file mode 100644 index 00000000..0262acb6 --- /dev/null +++ b/crates/core/src/tests/esp32c3_rtc_delay_loop.rs @@ -0,0 +1,154 @@ +// LabWired - Firmware Simulation Platform +// Copyright (C) 2026 Andrii Shylenko +// SPDX-License-Identifier: MIT + +//! Differential gate for the ESP32-C3 RTC main-timer scheduler migration +//! (`perf/c3-walk-free`, Task B): the EXACT scenario the old +//! `uses_scheduler() == false` comment feared — *"firmware delay loops observe +//! stale time and spin forever"* — proven terminating and byte-identical to +//! the legacy walk. +//! +//! The firmware is the IDF `rtc_time_get` poll shape: write `TIME_UPDATE` +//! (latch), read `TIME0`, loop until the deadline passes. Run on the legacy +//! per-cycle walk (reference — `force_legacy_walk()` detaches the cycle +//! clock) and on the scheduler path (counter advanced lazily from the +//! bus-published `CycleClock`), at tick interval 1 and 64: +//! +//! * both paths TERMINATE (no stale-spin), and +//! * they terminate at the SAME cycle with the SAME final counter value — +//! the walk itself quantises the counter to the tick grid at interval > 1, +//! and the lazy read-sync reproduces exactly that grid (batch-start +//! freshness == walk-tick freshness), so this holds at interval 64 too. + +#![cfg(all(test, feature = "event-scheduler"))] + +use crate::cpu::RiscV; +use crate::peripherals::esp32c3::rtc_timer::Esp32c3RtcTimer; +use crate::{Cpu, DebugControl, Machine}; + +const RTC_BASE: u64 = 0x6000_8000; +/// The delay-loop deadline in RTC counter ticks (== CPU cycles in this model). +const DEADLINE: u32 = 0x2000; // 8192 +/// PC of the terminal `jal x0, 0` self-loop. +const DONE_PC: u32 = 0x18; + +/// Build a RISC-V machine whose firmware busy-polls the RTC counter: +/// +/// ```text +/// lui x1, 0x60008 ; x1 = RTC_CNTL base +/// lui x2, 0x80000 ; x2 = TIME_UPDATE latch bit (bit31) +/// lui x4, 0x2 ; x4 = deadline (0x2000 counter ticks) +/// loop: +/// sw x2, 0x0C(x1) ; TIME_UPDATE ← latch the live counter +/// lw x3, 0x10(x1) ; TIME0 → latched counter (low word) +/// bltu x3, x4, loop ; spin until the deadline passes +/// done: +/// jal x0, 0 ; park +/// ``` +fn build_machine(tick_interval: u32, legacy_walk: bool) -> Machine { + let mut bus = crate::bus::SystemBus::new(); + bus.flash.data = vec![0; 0x100]; + // add_peripheral attaches the bus cycle clock → scheduler mode under the + // event-scheduler feature. `force_legacy_walk` detaches it, restoring the + // reference per-cycle-walk drive. + bus.add_peripheral( + "rtc_cntl_timer", + RTC_BASE, + 0x100, + None, + Box::new(Esp32c3RtcTimer::new()), + ); + if legacy_walk { + let idx = bus.find_peripheral_index_by_name("rtc_cntl_timer").unwrap(); + bus.peripherals[idx] + .dev + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .force_legacy_walk(); + } + + bus.write_u32(0x00, 0x6000_80B7).unwrap(); // lui x1, 0x60008 + bus.write_u32(0x04, 0x8000_0137).unwrap(); // lui x2, 0x80000 + bus.write_u32(0x08, 0x0000_2237).unwrap(); // lui x4, 0x2 + bus.write_u32(0x0C, 0x0020_A623).unwrap(); // sw x2, 12(x1) + bus.write_u32(0x10, 0x0100_A183).unwrap(); // lw x3, 16(x1) + bus.write_u32(0x14, 0xFE41_ECE3).unwrap(); // bltu x3, x4, -8 + bus.write_u32(0x18, 0x0000_006F).unwrap(); // jal x0, 0 (done) + + let mut cpu = RiscV::new(); + cpu.pc = 0x0; + cpu.mtimecmp = u64::MAX; + let mut machine = Machine::new(cpu, bus); + machine.config.peripheral_tick_interval = tick_interval; + machine.bus.config.peripheral_tick_interval = tick_interval; + machine +} + +/// Run until the firmware parks at `done` (or a generous step budget runs +/// out) and return `(terminated, total_cycles, final_counter_read)`. +fn run_delay_loop(mut machine: Machine) -> (bool, u64, u32) { + // The loop needs ~DEADLINE cycles to satisfy its deadline; 40x headroom. + const BUDGET: u64 = (DEADLINE as u64) * 40; + while machine.total_cycles < BUDGET { + machine.run(Some(1_000)).unwrap(); + if machine.cpu.get_pc() == DONE_PC { + return (true, machine.total_cycles, machine.cpu.get_register(3)); + } + } + (false, machine.total_cycles, machine.cpu.get_register(3)) +} + +/// THE gate: the busy-poll delay loop terminates on both drive modes at +/// interval 1 AND 64 (no stale-spin), and the scheduler path is byte-identical +/// to the legacy walk — same termination cycle, same final counter read. +#[test] +fn rtc_delay_loop_terminates_and_matches_walk() { + for tick_interval in [1u32, 64] { + let (walk_done, walk_cycles, walk_cnt) = run_delay_loop(build_machine(tick_interval, true)); + let (sched_done, sched_cycles, sched_cnt) = + run_delay_loop(build_machine(tick_interval, false)); + + assert!( + walk_done, + "interval {tick_interval}: legacy-walk delay loop must terminate" + ); + assert!( + sched_done, + "interval {tick_interval}: scheduler delay loop must terminate — \ + this is the stale-read spin-forever case the migration must prevent" + ); + assert!( + sched_cnt >= DEADLINE, + "interval {tick_interval}: loop exited before the RTC deadline \ + (read {sched_cnt:#x} < {DEADLINE:#x})" + ); + assert_eq!( + (walk_cycles, walk_cnt), + (sched_cycles, sched_cnt), + "interval {tick_interval}: scheduler-driven RTC must be byte-identical \ + to the legacy walk (termination cycle + final counter read)" + ); + } +} + +/// The scheduler path must actually be scheduler-driven (walk-independent) and +/// the reference must actually be on the walk — guard the knob itself so the +/// differential above can't silently compare like against like. +#[test] +fn differential_knob_selects_distinct_paths() { + let sched = build_machine(1, false); + let idx = sched + .bus + .find_peripheral_index_by_name("rtc_cntl_timer") + .unwrap(); + assert!(sched.bus.peripherals[idx].dev.uses_scheduler()); + + let walk = build_machine(1, true); + let idx = walk + .bus + .find_peripheral_index_by_name("rtc_cntl_timer") + .unwrap(); + assert!(!walk.bus.peripherals[idx].dev.uses_scheduler()); +} diff --git a/crates/core/src/tests/integration.rs b/crates/core/src/tests/integration.rs index 1edada0c..b2a06388 100644 --- a/crates/core/src/tests/integration.rs +++ b/crates/core/src/tests/integration.rs @@ -2355,7 +2355,8 @@ pub mod integration_tests { // meaningful in BOTH feature modes. let interval = (bus.config.peripheral_tick_interval as u64).max(1); for _ in 0..500 { - bus.current_cycle += interval; + let next = bus.current_cycle + interval; + bus.set_current_cycle(next); bus.tick_peripherals_with_costs(); } diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs index 03ea9615..ac05b827 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -3,6 +3,8 @@ pub mod esp32; #[cfg(test)] pub mod esp32c3_i2c_waveform; #[cfg(test)] +pub mod esp32c3_rtc_delay_loop; +#[cfg(test)] pub mod hcsr04_event_tick_differential; #[cfg(test)] pub mod integration; diff --git a/crates/core/tests/esp32c3_walk_differential.rs b/crates/core/tests/esp32c3_walk_differential.rs new file mode 100644 index 00000000..9cb990d6 --- /dev/null +++ b/crates/core/tests/esp32c3_walk_differential.rs @@ -0,0 +1,409 @@ +// LabWired - Firmware Simulation Platform +// Copyright (C) 2026 Andrii Shylenko +// SPDX-License-Identifier: MIT + +//! Differential + structural gates for `perf/c3-walk-free` (the ESP32-C3 +//! walk-independence campaign) in the #511/#512 style: +//! +//! 1. `oled_lab_walk_on_vs_scheduler_rtc_is_byte_identical_at_interval_1` — +//! the REAL `esp32c3-oled-demo` lab (flash fast-start through the real +//! 2nd-stage bootloader, the exact assembly the browser uses), run with the +//! RTC main timer on the legacy per-cycle walk (reference) vs +//! scheduler-driven (lazy counter via the bus-published `CycleClock`), at +//! tick interval 1: serial stream, total cycles and the SSD1306 GDDRAM +//! framebuffer must be BYTE-IDENTICAL after the same instruction budget. +//! +//! 2. `oled_lab_framebuffer_is_byte_identical_across_tick_intervals` — the +//! same lab at tick interval 64 (scheduler RTC): raw counter reads are +//! quantised to the tick grid (bounded staleness, < one interval — the +//! write-path bound), but the OLED output is an event observable and must +//! not change: the painted framebuffer is byte-identical to interval 1. +//! +//! 3. `esp32c3_irq_choke_reaggregates_on_write` — Task C: FROM_CPU IPI and +//! INTC config writes re-aggregate `riscv_irq_lines` AT THE WRITE (through +//! `sync_esp32c3_irq_cache_write`), with no peripheral tick in between — +//! the property that lets a future walk-free C3 bus take the trivial +//! per-cycle tick path. +//! +//! 4. `oled_lab_walk_pinners_after_rtc_migration` — the endgame ledger: on +//! the real OLED rom-boot bus, `rtc_cntl_timer` no longer pins the walk +//! (`uses_scheduler() == true`), and the remaining pinners are exactly the +//! known set (systimer / timg / i2c / …), printed for the campaign report. + +#![cfg(feature = "event-scheduler")] + +use labwired_config::{ChipDescriptor, SystemManifest}; +use labwired_core::boot::esp32c3_rom::{ + build_rom_boot_machine, c3_rom_data_init_writes, inject_rom_regions, RomBootOpts, +}; +use labwired_core::boot::esp32s3_rom::RomImages; +use labwired_core::bus::SystemBus; +use labwired_core::cpu::RiscV; +use labwired_core::memory::ProgramImage; +use labwired_core::peripherals::components::Ssd1306; +use labwired_core::peripherals::esp32c3::i2c::Esp32c3I2c; +use labwired_core::peripherals::esp32c3::rtc_timer::Esp32c3RtcTimer; +use labwired_core::{Arch, Bus, Cpu, DebugControl, Machine}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +// ───────────────────────── flash fast-start assembly ───────────────────────── +// Mirrors `WasmSimulator::new_from_config_riscv_flash_fastboot` (the browser +// entry): real chip yaml + real oled-demo system yaml + vendored mask-ROM +// blobs + the curated merged flash image, skipping only the ~150M-step mask +// ROM replay by entering at the 2nd-stage bootloader (still the real boot +// chain: bootloader → SHA verify → app). + +const ESP_IMAGE_HEADER_LEN: usize = 24; +const ESP_IMAGE_MAGIC: u8 = 0xE9; + +fn esp32c3_bootloader_image(flash: &[u8]) -> ProgramImage { + assert!(flash.len() > ESP_IMAGE_HEADER_LEN, "flash image truncated"); + assert_eq!(flash[0], ESP_IMAGE_MAGIC, "bad bootloader image magic"); + let segment_count = flash[1] as usize; + let entry = u32::from_le_bytes(flash[4..8].try_into().unwrap()) as u64; + let mut program = ProgramImage::new(entry, Arch::RiscV); + let mut cursor = ESP_IMAGE_HEADER_LEN; + for _ in 0..segment_count { + let load_addr = u32::from_le_bytes(flash[cursor..cursor + 4].try_into().unwrap()) as u64; + let len = u32::from_le_bytes(flash[cursor + 4..cursor + 8].try_into().unwrap()) as usize; + cursor += 8; + program.add_segment(load_addr, flash[cursor..cursor + len].to_vec()); + cursor += len; + } + program +} + +struct OledLab { + machine: Machine, + serial: Arc>>, +} + +/// Build the OLED lab exactly as the browser fast-start does. `rtc_legacy` +/// selects the reference config (RTC main timer pinned back onto the +/// per-cycle walk via `force_legacy_walk`). +fn build_oled_lab(tick_interval: u32, rtc_legacy: bool) -> OledLab { + let chip = ChipDescriptor::from_file(root().join("../../configs/chips/esp32c3.yaml")) + .expect("load esp32c3 chip yaml"); + let manifest = + SystemManifest::from_file(root().join("../../configs/systems/esp32c3-oled-demo.yaml")) + .expect("load esp32c3-oled-demo system yaml"); + let mut bus = SystemBus::from_config(&chip, &manifest).expect("build oled bus"); + + let irom = std::fs::read(root().join("roms/esp32c3/esp32c3_rom.bin")).expect("read C3 IROM"); + let drom = std::fs::read(root().join("roms/esp32c3/esp32c3_drom.bin")).expect("read C3 DROM"); + let flash = std::fs::read(root().join("../wasm/tests/fixtures/esp32c3-oled-demo-flash.bin")) + .expect("read C3 OLED demo flash image"); + + assert!( + inject_rom_regions( + &mut bus, + &RomImages { + irom: irom.clone(), + drom + } + ), + "chip yaml must declare the C3 IROM region" + ); + // Fast-start skips the mask-ROM reset code; copy the ROM `.data` records + // the bootloader's ROM-helper calls depend on (same as the wasm path). + for (dst, bytes) in c3_rom_data_init_writes(&irom) { + for (i, b) in bytes.iter().enumerate() { + let _ = bus.write_u8(dst as u64 + i as u64, *b); + } + } + + let serial = Arc::new(Mutex::new(Vec::new())); + bus.attach_uart_tx_sink(serial.clone(), false); + + let bootloader = esp32c3_bootloader_image(&flash); + let mut machine = build_rom_boot_machine( + bus, + flash, + RomBootOpts { + efuse_mac: None, + usb_serial_sink: None, + }, + |c| c, + ); + + // Load the bootloader segments without a CPU reset and enter at its entry + // point (the fast-start seam). + for segment in &bootloader.segments { + if machine.bus.flash.load_from_segment(segment) + || machine.bus.ram.load_from_segment(segment) + || machine + .bus + .extra_mem + .iter_mut() + .any(|m| m.load_from_segment(segment)) + { + continue; + } + for (i, byte) in segment.data.iter().enumerate() { + machine + .bus + .write_u8(segment.start_addr + i as u64, *byte) + .expect("load bootloader segment"); + } + } + let sp_top = (chip.ram.base + labwired_config::parse_size(&chip.ram.size).unwrap_or(0)) as u32; + machine.cpu.set_sp(sp_top & !0xF); + machine.cpu.set_pc(bootloader.entry_point as u32); + + if rtc_legacy { + let idx = machine + .bus + .find_peripheral_index_by_name("rtc_cntl_timer") + .expect("rom-boot bus registers rtc_cntl_timer"); + machine.bus.peripherals[idx] + .dev + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .force_legacy_walk(); + } + + machine.config.peripheral_tick_interval = tick_interval; + machine.bus.config.peripheral_tick_interval = tick_interval; + + OledLab { machine, serial } +} + +fn ssd1306_framebuffer(machine: &Machine) -> Vec { + let idx = machine + .bus + .find_peripheral_index_by_name("i2c0") + .expect("oled bus exposes i2c0"); + machine.bus.peripherals[idx] + .dev + .as_any() + .expect("i2c0 downcastable") + .downcast_ref::() + .expect("i2c0 is the C3 command-list controller") + .attached_slaves() + .iter() + .filter(|d| d.address() == 0x3C) + .find_map(|d| d.as_any().and_then(|a| a.downcast_ref::())) + .expect("SSD1306 attached at 0x3C") + .framebuffer() + .to_vec() +} + +fn lit_pixels(fb: &[u8]) -> usize { + fb.iter().map(|b| b.count_ones() as usize).sum() +} + +/// Run exactly `budget` instructions (in chunks) and return the end state. +fn run_lab(mut lab: OledLab, budget: u64) -> (u64, Vec, Vec) { + const CHUNK: u32 = 1_000_000; + let mut steps = 0u64; + while steps < budget { + let n = CHUNK.min((budget - steps) as u32); + lab.machine.run(Some(n)).expect("run oled lab"); + steps += n as u64; + } + let fb = ssd1306_framebuffer(&lab.machine); + let serial = lab.serial.lock().unwrap().clone(); + (lab.machine.total_cycles, fb, serial) +} + +/// Instruction budget that comfortably covers bootloader + app + first OLED +/// paint on the fast-start path (the wasm gate paints well inside 40M). +const PAINT_BUDGET: u64 = 30_000_000; +/// "LabWired" wordmark + frame + bar → well over this many lit pixels. +const MIN_LIT: usize = 400; + +/// Gate 1 (interval 1): walk-on RTC vs scheduler RTC — event observables must +/// be byte-identical: same serial bytes, same total cycles, same framebuffer. +#[test] +#[ignore = "runs the real C3 bootloader + app (~2x30M steps); run with --release --ignored"] +fn oled_lab_walk_on_vs_scheduler_rtc_is_byte_identical_at_interval_1() { + let (walk_cycles, walk_fb, walk_serial) = run_lab(build_oled_lab(1, true), PAINT_BUDGET); + let (sched_cycles, sched_fb, sched_serial) = run_lab(build_oled_lab(1, false), PAINT_BUDGET); + + assert!( + lit_pixels(&walk_fb) >= MIN_LIT, + "reference run must paint the OLED (lit={}); serial:\n{}", + lit_pixels(&walk_fb), + String::from_utf8_lossy(&walk_serial) + ); + assert_eq!( + walk_cycles, sched_cycles, + "total_cycles must be byte-identical at interval 1" + ); + assert!( + walk_serial == sched_serial, + "serial stream must be byte-identical at interval 1\n--- walk ---\n{}\n--- sched ---\n{}", + String::from_utf8_lossy(&walk_serial), + String::from_utf8_lossy(&sched_serial) + ); + assert!( + walk_fb == sched_fb, + "SSD1306 framebuffer must be byte-identical at interval 1" + ); +} + +/// Gate 2 (interval 64, scheduler RTC): raw counter reads are quantised to +/// the tick grid (documented bounded staleness, < one interval — identical to +/// the write-path bound and to what the legacy walk itself does at interval +/// 64), but the SSD1306 output must not change: byte-identical framebuffer. +#[test] +#[ignore = "runs the real C3 bootloader + app (~2x30M steps); run with --release --ignored"] +fn oled_lab_framebuffer_is_byte_identical_across_tick_intervals() { + let (_, fb_1, _) = run_lab(build_oled_lab(1, false), PAINT_BUDGET); + let (_, fb_64, serial_64) = run_lab(build_oled_lab(64, false), PAINT_BUDGET); + + assert!( + lit_pixels(&fb_1) >= MIN_LIT, + "interval-1 run must paint the OLED" + ); + assert!( + fb_1 == fb_64, + "SSD1306 framebuffer must be byte-identical across tick intervals \ + (lit@1={}, lit@64={}); serial@64:\n{}", + lit_pixels(&fb_1), + lit_pixels(&fb_64), + String::from_utf8_lossy(&serial_64) + ); +} + +/// Task C gate: FROM_CPU IPI + INTC config writes re-aggregate the routed +/// RISC-V line mask AT THE WRITE — no peripheral tick in between. This is the +/// write-choke path (`sync_esp32c3_irq_cache_write` → +/// `recompute_esp32c3_irq_lines`) that makes C3 IRQ routing compatible with a +/// batched tick interval and (on a walk-free bus) the trivial per-cycle tick. +#[test] +fn esp32c3_irq_choke_reaggregates_on_write() { + const INTMATRIX: u64 = 0x600C_2000; + const FROM_CPU_0: u64 = 0x600C_0028; // routes as matrix source 50 + const SRC: u64 = 50; + const LINE: u32 = 5; + + let chip = ChipDescriptor::from_file(root().join("../../configs/chips/esp32c3.yaml")) + .expect("load esp32c3 chip yaml"); + let manifest = + SystemManifest::from_file(root().join("../../configs/systems/esp32c3-devkit.yaml")) + .expect("load esp32c3-devkit system yaml"); + let mut bus = SystemBus::from_config(&chip, &manifest).expect("build c3 bus"); + bus.esp32c3_irq_routing = true; + + // Route source 50 (FROM_CPU_0) → CPU line 5, priority 1, threshold 1, + // line enabled — all through ordinary MMIO writes. + bus.write_u32(INTMATRIX + SRC * 4, LINE).unwrap(); + bus.write_u32(INTMATRIX + 0x114 + (LINE as u64) * 4, 1) + .unwrap(); + bus.write_u32(INTMATRIX + 0x194, 1).unwrap(); + bus.write_u32(INTMATRIX + 0x104, 1 << LINE).unwrap(); + assert_eq!( + bus.riscv_irq_lines, 0, + "no source asserted yet → no routed lines" + ); + + // IPI set: the line must assert at the write, with NO tick in between. + bus.write_u32(FROM_CPU_0, 1).unwrap(); + assert_eq!( + bus.riscv_irq_lines, + 1 << LINE, + "FROM_CPU write must re-aggregate the routed line mask immediately" + ); + + // Masking via the INTC threshold (the FreeRTOS critical-section mechanism) + // must also take effect at the write. + bus.write_u32(INTMATRIX + 0x194, 2).unwrap(); + assert_eq!( + bus.riscv_irq_lines, 0, + "raising CPU_INT_THRESH above the line priority must mask at the write" + ); + bus.write_u32(INTMATRIX + 0x194, 1).unwrap(); + assert_eq!(bus.riscv_irq_lines, 1 << LINE, "unmask re-asserts"); + + // IPI clear: the line must de-assert at the write. + bus.write_u32(FROM_CPU_0, 0).unwrap(); + assert_eq!( + bus.riscv_irq_lines, 0, + "FROM_CPU clear must de-assert the routed line at the write" + ); +} + +/// Native throughput probe for the campaign report: the OLED lab (browser +/// fast-start assembly) run for 50M instructions through `Machine::run`, +/// wall-clocked. Run 3x and take the median: +/// cargo test -p labwired-core --release --features jit,event-scheduler \ +/// --test esp32c3_walk_differential mips_probe -- --ignored --nocapture +/// `LABWIRED_MIPS_INTERVAL` overrides the tick interval (default 1 — the +/// interval the deploy currently pins). +#[test] +#[ignore = "wall-clock throughput probe; run 3x with --release --nocapture"] +fn oled_lab_native_mips_probe() { + let interval: u32 = std::env::var("LABWIRED_MIPS_INTERVAL") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1); + const STEPS: u64 = 50_000_000; + let mut lab = build_oled_lab(interval, false); + let start = std::time::Instant::now(); + let mut steps = 0u64; + while steps < STEPS { + lab.machine.run(Some(1_000_000)).expect("run oled lab"); + steps += 1_000_000; + } + let secs = start.elapsed().as_secs_f64(); + eprintln!( + "oled-lab native: {STEPS} instructions, interval {interval}: {:.2}s = {:.2} MIPS \ + (total_cycles {})", + secs, + STEPS as f64 / secs / 1.0e6, + lab.machine.total_cycles + ); +} + +/// Endgame ledger: after the RTC migration, which peripherals still pin the +/// per-cycle walk on the REAL OLED rom-boot bus? Locks in that +/// `rtc_cntl_timer` is walk-independent and prints the remaining pinners +/// (run with `--nocapture` for the list). `max_safe_tick_interval` stays 1 +/// until the ledger empties — asserted so the report stays honest. +#[test] +#[ignore = "builds the full rom-boot bus (needs ROM blobs + flash fixture); run with --ignored"] +fn oled_lab_walk_pinners_after_rtc_migration() { + let lab = build_oled_lab(1, false); + let bus = &lab.machine.bus; + + let mut pinners: Vec = Vec::new(); + for p in &bus.peripherals { + let walk_independent = p.dev.uses_scheduler() || !p.dev.needs_legacy_walk(); + if !walk_independent { + pinners.push(format!( + "{} (uses_scheduler={}, needs_legacy_walk={}, legacy_tick_active={})", + p.name, + p.dev.uses_scheduler(), + p.dev.needs_legacy_walk(), + p.dev.legacy_tick_active() + )); + } + } + eprintln!("walk pinners on the esp32c3-oled-demo rom-boot bus:"); + for p in &pinners { + eprintln!(" - {p}"); + } + eprintln!("max_safe_tick_interval = {}", bus.max_safe_tick_interval()); + + assert!( + !pinners.iter().any(|p| p.starts_with("rtc_cntl_timer ")), + "rtc_cntl_timer must no longer pin the walk; pinners: {pinners:?}" + ); + assert!( + !pinners.is_empty(), + "ledger must stay honest: other pinners (systimer/timg/i2c/…) remain" + ); + assert_eq!( + bus.max_safe_tick_interval(), + 1, + "bus cannot recommend interval > 1 until every pinner is migrated" + ); +} diff --git a/docs/boards/VALIDATION_STATUS.md b/docs/boards/VALIDATION_STATUS.md index bce956d7..473cb802 100644 --- a/docs/boards/VALIDATION_STATUS.md +++ b/docs/boards/VALIDATION_STATUS.md @@ -10,7 +10,7 @@ Machine-generated from `validation/manifest.yaml`. CI regenerates this on every | `nrf52840` | 🟢 silicon-verified | 2026-06-17 | 2026-07-10 | ⚠ drift acked 2026-07-10 (re-capture pending) | | `seeed-xiao-nrf52840-sense` | 🟢 silicon-verified | 2026-06-17 | 2026-07-10 | ⚠ drift acked 2026-07-10 (re-capture pending) | | `stm32h563` | 🟢 silicon-verified | 2026-06-22 | 2026-07-11 | ⚠ drift acked 2026-07-11 (re-capture pending) | -| `esp32c3` | 🟢 silicon-verified | 2026-06-17 | 2026-07-10 | ⚠ drift acked 2026-07-10 (re-capture pending) | +| `esp32c3` | 🟢 silicon-verified | 2026-06-17 | 2026-07-11 | ⚠ drift acked 2026-07-11 (re-capture pending) | | `nucleo-l476rg` | 🟢 silicon-verified | 2026-06-20 | 2026-07-11 | ⚠ drift acked 2026-07-11 (re-capture pending) | | `nucleo-l073rz` | 🟢 silicon-verified | 2026-06-20 | 2026-07-11 | ⚠ drift acked 2026-07-11 (re-capture pending) | | `stm32f103` | 🟢 silicon-verified | 2026-06-20 | 2026-07-11 | ⚠ drift acked 2026-07-11 (re-capture pending) | @@ -52,7 +52,7 @@ Machine-generated from `validation/manifest.yaml`. CI regenerates this on every - Note: Reset-state oracle, not behavioural. ~40 peripherals declared (NOT 6 as the prose doc says). - Silicon: **2026-06-17** on USB-JTAG (built-in, openocd-esp32) — re-verified live — reset oracle re-captured live 2026-06-17: 1123/1123 static registers match committed baseline (13 deltas all in per-chip efuse + live USB-device state, none in the asserted set); esp32c3_reset_values_match_silicon passes - offline (CI): esp32c3_reset_conformance::esp32c3_reset_values_match_silicon (79 regs; 366/423 overlap matched silicon) -- Drift status: **⚠ drift acked 2026-07-10 (re-capture pending)** +- Drift status: **⚠ drift acked 2026-07-11 (re-capture pending)** ## `nucleo-l476rg` — 🟢 silicon-verified diff --git a/validation/manifest.yaml b/validation/manifest.yaml index 92381873..24a1ebae 100644 --- a/validation/manifest.yaml +++ b/validation/manifest.yaml @@ -251,7 +251,13 @@ boards: # 2026-07-09: universal logic analyzer — additive read_gpio_pad pad probe (ENABLE-mask direction) + I2C controller wraps attached slaves in TracingI2cDevice (shared bus trace) and signals START to the selected slave at address match so traces carry decodable address frames. Register map/reset values byte-for-byte untouched; conformance/e2e unchanged. No live re-capture. # 2026-07-10: bus-trace single-choke-point refactor (attach wraps at SystemBus::attach_i2c_slave/attach_spi_device; no per-callsite set_bus_trace) + cycle stamp on trace events + gpio_routing/pin_routing metadata. Additive; oracle output unchanged. # 2026-07-10: I2C0 (esp32c3/i2c.rs) upgraded to a cycle-driven BIT-LEVEL engine: command lists now execute over simulated cycles with SCL/SDA timing derived from the real CLK_CONF / SCL_*_PERIOD / SDA_HOLD / START / STOP counters (TRM reg+1 semantics; datasheet reset values when firmware leaves them unprogrammed). TRANS_COMPLETE/END_DETECT/NACK and COMD command_done assert at the derived wire time (no longer instantly) and SR.BUS_BUSY reads 1 while on the wire. Register map, reset values, FIFO semantics and the byte-level traced-slave contract are unchanged — the reset oracle is unaffected. esp32c3/gpio.rs additionally reads the shared I2C line levels for pads matrix-routed to I2CEXT0_SDA/SCL so read_gpio_pad carries the real waveform (logic analyzer). Covered by analytic wire-time unit tests, the waveform-vs-bus-trace agreement test, and the leo e2e (OLED paints identically). No live re-capture this session. - drift_ack: 2026-07-10 + # 2026-07-11: RTC timer walk-free migration (lazy cycle-clock advance replacing + # per-tick counting; TIME_UPDATE latch semantics unchanged; resume re-anchors to + # the live clock) + write-choke C3 IRQ re-aggregation + per-load env-var trace + # gate removal. Byte-identical to the walk path by committed differentials + # (esp32c3_walk_differential, esp32c3_rtc_delay_loop); reset oracle unchanged. + # No live re-capture this session. + drift_ack: 2026-07-11 - id: nucleo-l476rg doc: docs/boards/nucleo-l476rg.md