diff --git a/crates/core/src/boot/esp32c3_rom.rs b/crates/core/src/boot/esp32c3_rom.rs index 7ddcca29..d3aa8cdd 100644 --- a/crates/core/src/boot/esp32c3_rom.rs +++ b/crates/core/src/boot/esp32c3_rom.rs @@ -439,12 +439,16 @@ pub fn build_rom_boot_machine C>( // C3 SYSTIMER_TARGET0 routes through the interrupt matrix on source // 37 (TARGET1/2 at 38/39), unlike the S3's 57; the FreeRTOS tick // alarm fires on that source. - Box::new( - crate::peripherals::esp32s3::systimer::Systimer::new_with_source_legacy_tick( - 160_000_000, - 37, - ), - ), + // + // Walk-free (C3 SYSTIMER batch): scheduler mode. The free-running + // counter advances lazily (write-path `sync_to` + the OP-update snapshot + // pulling the bus-published `CycleClock`, both to the batch-start + // cycle), and alarms fire as scheduled events at their exact expiry + // cycle, delivered through the C3 interrupt matrix by + // `apply_event_result`'s C3 routing arm. This un-pins SYSTIMER from the + // per-cycle walk; delivery stays cycle-identical to the legacy walk at + // a given tick interval (differential-gated). + Box::new(crate::peripherals::esp32s3::systimer::Systimer::new_with_source(160_000_000, 37)), ); // RTC_CNTL main timer (0x6000_8000): the free-running slow-clock counter // the IDF reads via rtc_time_get (set TIME_UPDATE @0x0C bit31 to latch, diff --git a/crates/core/src/bus/from_config.rs b/crates/core/src/bus/from_config.rs index 7de2c547..9c76bf87 100644 --- a/crates/core/src/bus/from_config.rs +++ b/crates/core/src/bus/from_config.rs @@ -84,6 +84,7 @@ impl SystemBus { esp32c3_interrupt_core0_idx: None, esp32c3_irq_cache: None, esp32c3_asserted_sources: [0; 2], + esp32c3_sched_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 f8a39b0d..ee4608fa 100644 --- a/crates/core/src/bus/mod.rs +++ b/crates/core/src/bus/mod.rs @@ -284,6 +284,17 @@ pub struct SystemBus { /// 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], + /// C3 matrix sources asserted by SCHEDULER-driven peripherals (currently + /// the SYSTIMER alarm once migrated off the walk). The per-cycle walk + /// rebuilds `esp32c3_asserted_sources` from scratch each tick and skips + /// scheduler-driven peripherals, so their level would drop every tick; + /// this bitmap is re-derived from `Peripheral::matrix_irq_sources` at the + /// event path (`apply_event_result`) and the walk-tick aggregation, and + /// OR-ed with `esp32c3_asserted_sources` in `recompute_esp32c3_irq_lines`. + /// Same level semantics (a source that stops asserting drops out at the + /// next re-derivation), so delivery matches the legacy walk cycle-for-cycle + /// at a given tick interval. + esp32c3_sched_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. @@ -2527,6 +2538,7 @@ impl SystemBus { esp32c3_interrupt_core0_idx: None, esp32c3_irq_cache: None, esp32c3_asserted_sources: [0; 2], + esp32c3_sched_asserted_sources: [0; 2], esp32s3_irq_routing: false, esp32s3_intmatrix_idx: None, flash_models_ops: false, @@ -2584,6 +2596,7 @@ impl SystemBus { esp32c3_interrupt_core0_idx: None, esp32c3_irq_cache: None, esp32c3_asserted_sources: [0; 2], + esp32c3_sched_asserted_sources: [0; 2], esp32s3_irq_routing: false, esp32s3_intmatrix_idx: None, flash_models_ops: false, @@ -5029,6 +5042,7 @@ peripherals: esp32c3_interrupt_core0_idx: None, esp32c3_irq_cache: None, esp32c3_asserted_sources: [0; 2], + esp32c3_sched_asserted_sources: [0; 2], esp32s3_irq_routing: false, esp32s3_intmatrix_idx: None, flash_models_ops: false, @@ -5110,6 +5124,7 @@ peripherals: esp32c3_interrupt_core0_idx: None, esp32c3_irq_cache: None, esp32c3_asserted_sources: [0; 2], + esp32c3_sched_asserted_sources: [0; 2], esp32s3_irq_routing: false, esp32s3_intmatrix_idx: None, flash_models_ops: false, @@ -5342,6 +5357,7 @@ peripherals: esp32c3_interrupt_core0_idx: None, esp32c3_irq_cache: None, esp32c3_asserted_sources: [0; 2], + esp32c3_sched_asserted_sources: [0; 2], esp32s3_irq_routing: false, esp32s3_intmatrix_idx: None, flash_models_ops: false, @@ -5573,6 +5589,7 @@ peripherals: esp32c3_interrupt_core0_idx: None, esp32c3_irq_cache: None, esp32c3_asserted_sources: [0; 2], + esp32c3_sched_asserted_sources: [0; 2], esp32s3_irq_routing: false, esp32s3_intmatrix_idx: None, flash_models_ops: false, @@ -5658,6 +5675,7 @@ peripherals: esp32c3_interrupt_core0_idx: None, esp32c3_irq_cache: None, esp32c3_asserted_sources: [0; 2], + esp32c3_sched_asserted_sources: [0; 2], esp32s3_irq_routing: false, esp32s3_intmatrix_idx: None, flash_models_ops: false, diff --git a/crates/core/src/bus/tick.rs b/crates/core/src/bus/tick.rs index 33248b34..cd3a8d9d 100644 --- a/crates/core/src/bus/tick.rs +++ b/crates/core/src/bus/tick.rs @@ -471,6 +471,10 @@ impl SystemBus { } } self.esp32c3_asserted_sources = asserted; + // Re-derive scheduler-driven peripheral levels (SYSTIMER once migrated + // off the walk) so their level-sensitive matrix IRQ persists across + // walk ticks and de-asserts the tick after firmware clears it. + self.refresh_esp32c3_sched_sources(); if self.esp32c3_irq_cache.is_some() { self.recompute_esp32c3_irq_lines(); @@ -515,6 +519,17 @@ impl SystemBus { for &src in source_ids { route_source(src); } + // Scheduler-driven peripheral levels (SYSTIMER off the walk) — refreshed + // into the persistent bitmap above. + let sched = self.esp32c3_sched_asserted_sources; + for (word, bits) in sched.iter().enumerate() { + let mut bits = *bits; + while bits != 0 { + let bit = bits.trailing_zeros(); + route_source(word as u32 * 64 + bit); + bits &= !(1u64 << bit); + } + } for (addr, src) in FROM_CPU { let from_cpu = self .esp32c3_system_idx @@ -562,8 +577,13 @@ impl SystemBus { } }; - for (word, &bits) in self.esp32c3_asserted_sources.iter().enumerate() { - let mut bits = bits; + for word in 0..self.esp32c3_asserted_sources.len() { + // Union of walk-emitted level sources (rebuilt each tick) and + // scheduler-driven peripheral level sources (re-derived from + // `matrix_irq_sources`), so a SYSTIMER migrated off the walk keeps + // its level-sensitive alarm IRQ routed. + let mut bits = + self.esp32c3_asserted_sources[word] | self.esp32c3_sched_asserted_sources[word]; while bits != 0 { let bit = bits.trailing_zeros(); route_source(word as u32 * 64 + bit); @@ -579,6 +599,34 @@ impl SystemBus { self.riscv_irq_lines = mask; } + /// Re-derive the C3 matrix sources asserted by SCHEDULER-driven peripherals + /// (skipped by the per-cycle walk) from their live level + /// (`Peripheral::matrix_irq_sources`). Rebuilt from scratch — level + /// semantics — so a source that stops asserting (e.g. after the SYSTIMER + /// alarm's INT_CLR) drops out on the next re-derivation. Called from the + /// event path (`Machine::apply_event_result`, exact-cycle delivery) and the + /// walk-tick aggregation (steady-state persistence + de-assert). No-op + /// unless C3 routing is active. Does NOT recompute — the caller decides + /// when to fold this into `riscv_irq_lines`. + pub(crate) fn refresh_esp32c3_sched_sources(&mut self) { + if !self.esp32c3_irq_routing { + return; + } + let mut asserted = [0u64; 2]; + for p in &self.peripherals { + if !p.dev.uses_scheduler() { + continue; + } + for src in p.dev.matrix_irq_sources() { + let idx = (src / 64) as usize; + if idx < asserted.len() { + asserted[idx] |= 1u64 << (src % 64); + } + } + } + self.esp32c3_sched_asserted_sources = asserted; + } + 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 @@ -909,3 +957,140 @@ mod walk_free_campaign { ); } } + +/// Walk-free C3 SYSTIMER batch — the interrupt-matrix ROUTING identity. +/// +/// A SYSTIMER migrated off the per-cycle walk delivers its alarm as a scheduled +/// event; the C3 routing arm (`Machine::apply_event_result` → this module's +/// `refresh_esp32c3_sched_sources` + `recompute_esp32c3_irq_lines`) must route +/// that level to `riscv_irq_lines` EXACTLY as the legacy walk did when the +/// SYSTIMER re-emitted source 37 every tick (`aggregate_esp32c3_irqs`). This +/// pins that equivalence at the bus level (the OLED-lab gate proves it +/// end-to-end through the real FreeRTOS tick). +#[cfg(all(test, feature = "event-scheduler"))] +mod c3_systimer_matrix_routing { + use crate::bus::SystemBus; + use crate::peripherals::esp32s3::systimer::Systimer; + use crate::Peripheral; + use labwired_config::{ChipDescriptor, SystemManifest}; + use std::path::PathBuf; + + const SYSTIMER_BASE: u64 = 0x6002_3000; + const INTMATRIX: u64 = 0x600C_2000; + const SYSTIMER_TARGET0_SOURCE: u64 = 37; + const LINE: u32 = 5; + + /// Build a devkit C3 bus (real `interrupt_core0` → INTC cache), enable C3 + /// routing, swap the declarative SYSTIMER stub for a real scheduler-driven + /// `Systimer`, and route SYSTIMER_TARGET0 (source 37) → CPU line 5. + fn setup() -> SystemBus { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + 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 devkit bus"); + + // Enable the RISC-V interrupt routing (the ROM-boot path sets this; the + // from_config bus does not) and rebuild the INTC cache. + bus.esp32c3_irq_routing = true; + bus.refresh_peripheral_index(); + + // Swap the declarative SYSTIMER stub for the real scheduler model and + // hand it the bus clock (as `add_peripheral` would). + let idx = bus + .find_peripheral_index_by_name("systimer") + .expect("devkit bus carries a systimer"); + let mut dev = Systimer::new_with_source(160_000_000, SYSTIMER_TARGET0_SOURCE as u32); + dev.attach_cycle_clock(bus.cycle_clock.clone()); + bus.peripherals[idx].dev = Box::new(dev); + bus.refresh_peripheral_index(); + + // Route source 37 → line 5, priority 1, threshold 1, line enabled. + bus.write_u32(INTMATRIX + SYSTIMER_TARGET0_SOURCE * 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(); + + // Arm TARGET0 in target mode at 3 SYSTIMER ticks, enable its IRQ. + bus.write_u32(SYSTIMER_BASE + 0x64, 1).unwrap(); // INT_ENA bit0 + bus.write_u32(SYSTIMER_BASE + 0x1C, 0).unwrap(); // TARGET0_HI + bus.write_u32(SYSTIMER_BASE + 0x20, 3).unwrap(); // TARGET0_LO + bus.write_u32(SYSTIMER_BASE + 0x50, 1).unwrap(); // COMP0_LOAD + let conf = bus.read_u32(SYSTIMER_BASE).unwrap(); + bus.write_u32(SYSTIMER_BASE, conf | (1 << 24)).unwrap(); // TARGET0_WORK_EN + bus + } + + fn systimer_mut(bus: &mut SystemBus) -> &mut Systimer { + let idx = bus.find_peripheral_index_by_name("systimer").unwrap(); + bus.peripherals[idx] + .dev + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + } + + /// The scheduler routing arm and the legacy walk aggregation produce the + /// SAME `riscv_irq_lines` for the SAME SYSTIMER level. + #[test] + fn scheduler_routing_matches_walk_routing_for_same_level() { + let mut bus = setup(); + + // Advance the SYSTIMER past the target so the alarm latches + // pending && int_ena → the model asserts matrix source 37. + systimer_mut(&mut bus).sync_to(10_000); + assert_eq!( + systimer_mut(&mut bus).matrix_irq_sources(), + vec![SYSTIMER_TARGET0_SOURCE as u32], + "armed+fired SYSTIMER must assert matrix source 37" + ); + + // Scheduler routing arm (what `apply_event_result` runs on the C3 bus). + bus.refresh_esp32c3_sched_sources(); + bus.recompute_esp32c3_irq_lines(); + let scheduler_lines = bus.riscv_irq_lines; + assert_eq!( + scheduler_lines, + 1 << LINE, + "scheduler routing must assert the routed CPU line for source 37" + ); + + // Legacy walk routing reference: source 37 re-emitted by the walk. Must + // land on the identical line mask. + bus.aggregate_esp32c3_irqs(&[SYSTIMER_TARGET0_SOURCE as u32]); + assert_eq!( + bus.riscv_irq_lines, scheduler_lines, + "walk aggregation and scheduler routing must produce identical riscv_irq_lines" + ); + } + + /// Clearing the SYSTIMER level (INT_CLR) de-asserts the routed line on the + /// next re-derivation — same level semantics as the walk (which stops + /// re-emitting the source the tick after INT_CLR). + #[test] + fn clearing_level_deasserts_routed_line() { + let mut bus = setup(); + systimer_mut(&mut bus).sync_to(10_000); + bus.refresh_esp32c3_sched_sources(); + bus.recompute_esp32c3_irq_lines(); + assert_eq!(bus.riscv_irq_lines, 1 << LINE); + + // INT_CLR bit0 clears the pending latch → level drops. + bus.write_u32(SYSTIMER_BASE + 0x6C, 1).unwrap(); + assert!( + systimer_mut(&mut bus).matrix_irq_sources().is_empty(), + "after INT_CLR the SYSTIMER asserts no matrix source" + ); + bus.refresh_esp32c3_sched_sources(); + bus.recompute_esp32c3_irq_lines(); + assert_eq!( + bus.riscv_irq_lines, 0, + "routed line must de-assert once the SYSTIMER level clears" + ); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index af99b3c0..5739db99 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -707,6 +707,19 @@ pub trait Peripheral: std::fmt::Debug + Send { /// 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) {} + + /// Walk-free plan (ESP32-C3): the interrupt-matrix source IDs this + /// peripheral is asserting RIGHT NOW (level-sensitive). The per-cycle walk + /// normally re-emits a level source every tick via `PeripheralTickResult:: + /// explicit_irqs`, and the bus rebuilds the C3 asserted-source bitmap from + /// that each tick. A scheduler-driven peripheral is skipped by the walk, so + /// the bus re-derives its live level from this method instead + /// (`SystemBus::refresh_esp32c3_sched_sources`, called from the event path + /// and the walk-tick aggregation). Default empty — only scheduler-driven + /// peripherals that raise C3 matrix IRQs override it. + fn matrix_irq_sources(&self) -> Vec { + Vec::new() + } } /// Trait representing the system bus @@ -1797,8 +1810,23 @@ impl Machine { self.bus.pend_irq_for_event(irq, &mut fallthrough); } } - for irq in &result.explicit_irqs { - self.bus.pend_irq_for_event(*irq, &mut fallthrough); + // ESP32-C3 interrupt-matrix routing arm — beside the Cortex-M + // `system_exception` arm below (B1's SysTick path). C3 peripheral IRQs + // do NOT go through an NVIC; they assert a LEVEL-sensitive matrix source + // that the per-cycle walk normally re-emits each tick. A scheduler- + // driven SYSTIMER is skipped by the walk, so the event path owns its + // level: re-derive every scheduler-driven peripheral's live matrix + // sources (`matrix_irq_sources`) and fold them into `riscv_irq_lines` + // here, at the exact firing cycle. `pend_irq_for_event` (NVIC) would + // mis-route a matrix source ID as a Cortex-M exception on RISC-V, so the + // NVIC path is taken only on non-C3 buses. + if self.bus.esp32c3_irq_routing { + self.bus.refresh_esp32c3_sched_sources(); + self.bus.recompute_esp32c3_irq_lines(); + } else { + for irq in &result.explicit_irqs { + self.bus.pend_irq_for_event(*irq, &mut fallthrough); + } } // Phase 2B.3b: route DMA signals exactly as the legacy tick path does. if !result.dma_signals.is_empty() { diff --git a/crates/core/src/peripherals/esp32s3/systimer.rs b/crates/core/src/peripherals/esp32s3/systimer.rs index bf225ce7..ac5e5017 100644 --- a/crates/core/src/peripherals/esp32s3/systimer.rs +++ b/crates/core/src/peripherals/esp32s3/systimer.rs @@ -90,7 +90,7 @@ //! That worked for the integration test because the test wrote 79 directly, //! but esp-hal binds SYSTIMER_TARGET0 at the PAC-defined source 57.) -use crate::{Peripheral, PeripheralTickResult, SimResult}; +use crate::{CycleClock, Peripheral, PeripheralTickResult, SimResult}; const SYSTIMER_CLOCK_HZ: u64 = 16_000_000; @@ -192,6 +192,13 @@ pub struct Systimer { /// peripherals before reads, so C3 ROM boot keeps this false and uses the /// legacy per-cycle tick path for fidelity. scheduler_enabled: bool, + /// Bus-published cycle clock (walk-free plan Part 1). `Some` once + /// `SystemBus::add_peripheral` attaches it. In scheduler mode the OP-update + /// snapshot latch pulls "now" from it so a counter read is fresh even + /// though the per-cycle walk no longer ticks this model. Not serialized — + /// re-attached by the bus on restore. + #[serde(skip)] + clock: Option, } #[derive(serde::Serialize, serde::Deserialize)] @@ -250,6 +257,7 @@ impl SystimerSnapshotV2 { target0_source: self.target0_source, last_tick: self.last_tick, scheduler_enabled: true, + clock: None, } } } @@ -268,6 +276,7 @@ impl SystimerSnapshotV1 { target0_source: self.target0_source, last_tick: 0, scheduler_enabled: true, + clock: None, } } } @@ -306,6 +315,7 @@ impl Systimer { target0_source, last_tick: 0, scheduler_enabled: true, + clock: None, } } @@ -319,6 +329,66 @@ impl Systimer { timer } + /// Test/differential knob: pin this instance back onto the legacy per-cycle + /// walk (`uses_scheduler() == false`). Used by the walk-on-vs-scheduler + /// differential gate to build the reference config from the same bus + /// assembly (mirrors `Esp32c3RtcTimer::force_legacy_walk`). + pub fn force_legacy_walk(&mut self) { + self.scheduler_enabled = false; + } + + /// Scheduler mode advances the counter lazily. Pull "now" from the + /// bus-published clock and advance the free-running counters (no alarm + /// evaluation — alarms fire at their exact scheduled cycle via `on_event`, + /// never off a mere counter read). Idempotent with the write-path + /// `sync_to`, which already advanced to the same cycle before an MMIO + /// write is observed; this keeps the OP-update snapshot fresh for any read + /// path that reaches the latch without a preceding bus sync. No-op in + /// legacy mode (the walk owns the counter) or without an attached clock. + fn sync_counters_from_clock(&mut self) { + if !self.scheduler_enabled { + return; + } + let Some(now) = self.clock.as_ref().map(|c| c.now()) else { + return; + }; + if now > self.last_tick { + self.advance_cycles(now - self.last_tick); + self.last_tick = now; + } + } + + /// Interrupt-matrix source IDs this SYSTIMER is asserting RIGHT NOW — + /// per-alarm `pending && INT_ENA`, the same level condition + /// `evaluate_alarms` emits on the legacy walk. In scheduler mode the + /// per-cycle walk no longer re-emits this level every tick, so the bus + /// re-derives it from here (`SystemBus::refresh_esp32c3_sched_sources`) to + /// keep the C3 interrupt matrix level-accurate. + fn asserted_matrix_sources(&self) -> Vec { + let mut out = Vec::new(); + for (i, alarm) in self.unit0_alarms.iter().enumerate() { + if alarm.pending && (self.int_ena & (1 << i) != 0) { + out.push(self.target0_source + i as u32); + } + } + out + } + + /// Restore the (non-serialized) cycle clock and re-anchor the scheduler + /// counter to the live clock. A snapshot is typically resumed on a FRESH + /// machine whose cycle count restarts near zero; trusting the persisted + /// `last_tick` (potentially millions of cycles ahead) would make the next + /// `sync_counters_from_clock` see `now <= last_tick` and freeze the counter + /// — the same stale-anchor trap the rtc_timer resume fix (#516) avoids. The + /// restored counter *value* (what boot-log timestamps depend on) is kept; + /// only the advance anchor is rebased to "now". + fn reanchor_after_restore(&mut self, clock: Option) { + if let Some(clock) = clock { + self.last_tick = clock.now(); + self.clock = Some(clock); + } + } + fn unit0_running(&self) -> bool { self.conf & (1 << 30) != 0 } @@ -556,9 +626,11 @@ impl Systimer { self.sync_alarm_enables_from_conf(); } 0x04 if value & (1 << 30) != 0 => { + self.sync_counters_from_clock(); self.unit0.snapshot = self.unit0.counter; } 0x08 if value & (1 << 30) != 0 => { + self.sync_counters_from_clock(); self.unit1.snapshot = self.unit1.counter; } @@ -699,6 +771,24 @@ impl Peripheral for Systimer { self.sync_to_tick(tick_now); } + fn attach_cycle_clock(&mut self, clock: CycleClock) { + // Anchor at the clock's current value so cycles elapsed before attach + // (normally zero — attach happens at bus assembly) are not + // retroactively credited to the counter (mirrors the rtc_timer #516 + // re-anchor contract). + self.last_tick = clock.now(); + self.clock = Some(clock); + } + + /// C3 interrupt-matrix level: the source IDs this SYSTIMER is asserting + /// now. Empty unless an alarm is `pending && INT_ENA`. The bus polls this + /// from the event path and the walk-tick aggregation so a scheduler-driven + /// SYSTIMER keeps its level-sensitive IRQ routed even though the per-cycle + /// walk no longer re-emits it. + fn matrix_irq_sources(&self) -> Vec { + self.asserted_matrix_sources() + } + fn take_scheduled_events(&mut self) -> Vec<(u64, u32)> { if !self.scheduler_enabled { return Vec::new(); @@ -741,9 +831,11 @@ impl Peripheral for Systimer { fn restore_runtime_snapshot(&mut self, bytes: &[u8]) -> SimResult<()> { let scheduler_enabled = self.scheduler_enabled; + let clock = self.clock.clone(); if let Ok(restored) = bincode::deserialize::(bytes) { *self = restored.into_systimer(); self.scheduler_enabled = scheduler_enabled; + self.reanchor_after_restore(clock); return Ok(()); } let restored = bincode::deserialize::(bytes).map_err(|e| { @@ -751,6 +843,7 @@ impl Peripheral for Systimer { })?; *self = restored.into_systimer(); self.scheduler_enabled = scheduler_enabled; + self.reanchor_after_restore(clock); Ok(()) } } diff --git a/crates/core/tests/esp32c3_walk_differential.rs b/crates/core/tests/esp32c3_walk_differential.rs index b39312b5..09aa4491 100644 --- a/crates/core/tests/esp32c3_walk_differential.rs +++ b/crates/core/tests/esp32c3_walk_differential.rs @@ -30,8 +30,9 @@ //! (`uses_scheduler() == true`), and after the C3/ESP32 Class-A inert //! sweep (`needs_legacy_walk() == false` on the verified-inert models) the //! remaining pinners are EXACTLY the verified real workers -//! (systimer / i2c0 / ledc / spi2 / apb_saradc / wifi_mac), asserted as an -//! exact set so the campaign report stays honest. +//! (i2c0 / ledc / spi2 / apb_saradc / wifi_mac), asserted as an +//! exact set so the campaign report stays honest. SYSTIMER is now +//! scheduler-driven (walk-free C3 SYSTIMER batch) and no longer pins. #![cfg(feature = "event-scheduler")] @@ -88,8 +89,10 @@ struct OledLab { /// 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 { +/// per-cycle walk via `force_legacy_walk`); `systimer_legacy` does the same for +/// the SYSTIMER (the FreeRTOS-tick source), so a walk-on-vs-scheduler +/// differential can isolate the SYSTIMER migration. +fn build_oled_lab(tick_interval: u32, rtc_legacy: bool, systimer_legacy: bool) -> OledLab { let chip = ChipDescriptor::from_file(root().join("../../configs/chips/esp32c3.yaml")) .expect("load esp32c3 chip yaml"); let manifest = @@ -172,6 +175,23 @@ fn build_oled_lab(tick_interval: u32, rtc_legacy: bool) -> OledLab { .force_legacy_walk(); } + if systimer_legacy { + // Find the real scheduler `Systimer` by type (the rom-boot bus also + // carries a declarative `systimer` stub at the same base, registered + // first; the real model is appended by `esp32c3_rom` and wins MMIO). + let systimer = machine + .bus + .peripherals + .iter_mut() + .find_map(|p| { + p.dev.as_any_mut().and_then(|a| { + a.downcast_mut::() + }) + }) + .expect("rom-boot bus registers a real Systimer"); + systimer.force_legacy_walk(); + } + machine.config.peripheral_tick_interval = tick_interval; machine.bus.config.peripheral_tick_interval = tick_interval; @@ -227,8 +247,9 @@ const MIN_LIT: usize = 400; #[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); + let (walk_cycles, walk_fb, walk_serial) = run_lab(build_oled_lab(1, true, false), PAINT_BUDGET); + let (sched_cycles, sched_fb, sched_serial) = + run_lab(build_oled_lab(1, false, false), PAINT_BUDGET); assert!( lit_pixels(&walk_fb) >= MIN_LIT, @@ -259,8 +280,8 @@ fn oled_lab_walk_on_vs_scheduler_rtc_is_byte_identical_at_interval_1() { #[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); + let (_, fb_1, _) = run_lab(build_oled_lab(1, false, false), PAINT_BUDGET); + let (_, fb_64, serial_64) = run_lab(build_oled_lab(64, false, false), PAINT_BUDGET); assert!( lit_pixels(&fb_1) >= MIN_LIT, @@ -276,6 +297,53 @@ fn oled_lab_framebuffer_is_byte_identical_across_tick_intervals() { ); } +/// SYSTIMER walk-free gate (the fidelity contract for THIS batch): the +/// SYSTIMER is the FreeRTOS-tick source, so the OLED demo's serial log, total +/// cycles and painted framebuffer are all downstream of its alarm delivery. +/// Run the REAL demo with the SYSTIMER on the legacy per-cycle walk +/// (reference) vs scheduler-driven (lazy counter + scheduled alarms routed +/// through the C3 interrupt matrix), at BOTH interval 1 and interval 64 — RTC +/// scheduler in both so the SYSTIMER is the only variable. Every observable +/// must be BYTE-IDENTICAL at each interval: the alarm fires at the same cycle +/// and the tick ISR is entered at the same instruction boundary, so nothing +/// downstream can differ. This is the cycle-exact identity that licenses +/// un-pinning the SYSTIMER from the walk. +#[test] +#[ignore = "runs the real C3 bootloader + app (~4x30M steps); run with --release --ignored"] +fn oled_lab_systimer_walk_on_vs_scheduler_is_byte_identical() { + for interval in [1u32, 64] { + // reference: SYSTIMER on the legacy walk; test: SYSTIMER scheduler. + let (walk_cycles, walk_fb, walk_serial) = + run_lab(build_oled_lab(interval, false, true), PAINT_BUDGET); + let (sched_cycles, sched_fb, sched_serial) = + run_lab(build_oled_lab(interval, false, false), PAINT_BUDGET); + + assert!( + lit_pixels(&walk_fb) >= MIN_LIT, + "reference (SYSTIMER walk) must paint the OLED at interval {interval} (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 (SYSTIMER walk vs scheduler) at interval {interval}" + ); + assert!( + walk_serial == sched_serial, + "serial stream must be byte-identical (SYSTIMER walk vs scheduler) at interval \ + {interval}\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 (SYSTIMER walk vs scheduler) at \ + interval {interval}" + ); + } +} + /// 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` → @@ -340,7 +408,9 @@ fn esp32c3_irq_choke_reaggregates_on_write() { /// 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). +/// interval the deploy currently pins). `LABWIRED_MIPS_SYSTIMER_LEGACY=1` pins +/// the SYSTIMER back onto the per-cycle walk (the pre-migration "before" +/// baseline) so the batch's shrunk-walk effect can be wall-clocked. #[test] #[ignore = "wall-clock throughput probe; run 3x with --release --nocapture"] fn oled_lab_native_mips_probe() { @@ -348,8 +418,9 @@ fn oled_lab_native_mips_probe() { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(1); + let systimer_legacy = std::env::var("LABWIRED_MIPS_SYSTIMER_LEGACY").as_deref() == Ok("1"); const STEPS: u64 = 50_000_000; - let mut lab = build_oled_lab(interval, false); + let mut lab = build_oled_lab(interval, false, systimer_legacy); let start = std::time::Instant::now(); let mut steps = 0u64; while steps < STEPS { @@ -358,8 +429,9 @@ fn oled_lab_native_mips_probe() { } let secs = start.elapsed().as_secs_f64(); eprintln!( - "oled-lab native: {STEPS} instructions, interval {interval}: {:.2}s = {:.2} MIPS \ - (total_cycles {})", + "oled-lab native: {STEPS} instructions, interval {interval}, systimer {}: \ + {:.2}s = {:.2} MIPS (total_cycles {})", + if systimer_legacy { "walk" } else { "scheduler" }, secs, STEPS as f64 / secs / 1.0e6, lab.machine.total_cycles @@ -372,8 +444,8 @@ fn oled_lab_native_mips_probe() { /// the remaining pinners are EXACTLY the verified real workers — models whose /// tick genuinely mutates state or asserts a level IRQ from the walk: /// -/// - `systimer` — free-running counter + FreeRTOS tick alarm, legacy-tick -/// mode on the C3 (real per-tick counter/alarm work); +/// (`systimer` — the free-running counter + FreeRTOS tick alarm — is now +/// scheduler-driven and no longer here.) /// - `i2c0` — bit-level wire engine advances mid-transfer in /// `tick_elapsed` (num/den module-clock fraction) + level IRQ; /// - `ledc` — the four low-speed timers run as live up-counters clocked by @@ -399,10 +471,9 @@ fn oled_lab_walk_pinners_after_rtc_migration() { /// newly marked `needs_legacy_walk() == false` or migrated to the /// scheduler must shrink this set; a model that starts pinning again is a /// regression. - const EXPECTED_PINNERS: &[&str] = - &["apb_saradc", "i2c0", "ledc", "spi2", "systimer", "wifi_mac"]; + const EXPECTED_PINNERS: &[&str] = &["apb_saradc", "i2c0", "ledc", "spi2", "wifi_mac"]; - let lab = build_oled_lab(1, false); + let lab = build_oled_lab(1, false, false); let bus = &lab.machine.bus; let mut pinners: Vec<&str> = Vec::new(); diff --git a/docs/boards/VALIDATION_STATUS.md b/docs/boards/VALIDATION_STATUS.md index 04807c2f..d46a7be1 100644 --- a/docs/boards/VALIDATION_STATUS.md +++ b/docs/boards/VALIDATION_STATUS.md @@ -15,7 +15,7 @@ Machine-generated from `validation/manifest.yaml`. CI regenerates this on every | `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) | | `stm32f407` | 🟢 silicon-smoke | 2026-06-20 | 2026-07-11 | ⚠ drift acked 2026-07-11 (re-capture pending) | -| `esp32s3` | 🟢 silicon-verified | 2026-06-20 | 2026-07-11 | ⚠ drift acked 2026-07-11 (re-capture pending) | +| `esp32s3` | 🟢 silicon-verified | 2026-06-20 | 2026-07-12 | ⚠ drift acked 2026-07-12 (re-capture pending) | | `stm32f401` | 🟡 smoke-manual | — | 2026-06-27 | no silicon capture | | `stm32wba52` | 🟡 smoke-manual | — | 2026-06-27 | no silicon capture | | `nrf52832` | ⚪ structural | — | 2026-06-27 | no silicon capture | @@ -97,7 +97,7 @@ Machine-generated from `validation/manifest.yaml`. CI regenerates this on every - Silicon: **2026-06-20** on USB-JTAG built-in (ESP32-S3-Zero, USB 303a:1001, openocd-esp32, Tensilica tap 0x120034e5) — Live re-capture after the v0.17.0 merge: reset-state oracle 9/9 match live silicon (SYSTIMER CONF 0x46000000 + I2C0 timing block TO/FIFO_CONF/SCL holds/FILTER_CFG), and esp32s3_reset_conformance (sim vs frozen) passes. Supersedes the 2026-06-19 drift_ack. - offline (CI): esp32s3_reset_conformance (9 reset regs vs live silicon, firmware-path bus) - offline (CI): e2e_i2c_tmp102 / e2e_hello_world / xtensa_exec / e2e_esp32_epaper (sim) -- Drift status: **⚠ drift acked 2026-07-11 (re-capture pending)** +- Drift status: **⚠ drift acked 2026-07-12 (re-capture pending)** ## `stm32f401` — 🟡 smoke-manual diff --git a/validation/manifest.yaml b/validation/manifest.yaml index 8345d23e..383799a2 100644 --- a/validation/manifest.yaml +++ b/validation/manifest.yaml @@ -517,7 +517,16 @@ boards: # access, tick() is the trait-default no-op). Pure walk-scheduling metadata — # no register, reset-value, timing or IRQ change; the 9-reg reset-state # silicon anchor is unaffected. No live re-capture warranted. - drift_ack: 2026-07-11 + # 2026-07-12: walk-free C3 SYSTIMER batch — the shared SYSTIMER model + # (esp32s3/systimer.rs) gained a bus-published CycleClock handle (fresh + # OP-update snapshot on read) + matrix_irq_sources() (live level query) so a + # C3 SYSTIMER can migrate off the per-cycle walk: alarms fire as scheduled + # events routed through the C3 interrupt matrix. Register layout, reset + # values (CONF=0x46000000), alarm/level-IRQ semantics and one-cycle tick + # equivalence are preserved (elapsed-vs-repeated + walk-vs-scheduler + # differential gates). S3 default construction is unchanged. No live + # re-capture warranted. + drift_ack: 2026-07-12 models: - crates/core/src/peripherals/esp32s3 - crates/core/src/peripherals/esp_xtensa_common