Skip to main content

cu29_runtime/
debug.rs

1//! CuDebug: lightweight time-travel debugger helpers on top of Copper logs.
2//!
3//! Design goals:
4//! - Do **not** load entire copperlists into memory (logs can be huge).
5//! - Build a compact section index in one streaming pass (copperlists + keyframes).
6//! - Keep keyframes in memory (much smaller) and lazily page copperlist sections
7//!   with a tiny LRU cache for snappy stepping.
8//! - Reuse the public `CuSimApplication` API and user-provided sim callbacks.
9
10use crate::app::{CuSimApplication, CurrentRuntimeCopperList};
11use crate::curuntime::KeyFrame;
12use crate::reflect::{ReflectTaskIntrospection, TypeRegistry, dump_type_registry_schema};
13use crate::simulation::SimOverride;
14use bincode::config::standard;
15use bincode::decode_from_std_read;
16use bincode::error::DecodeError;
17use cu29_clock::{CuTime, RobotClock, RobotClockMock};
18use cu29_traits::{CopperListTuple, CuError, CuResult, UnifiedLogType};
19use cu29_unifiedlog::{
20    LogPosition, SectionHeader, SectionStorage, UnifiedLogRead, UnifiedLogWrite, UnifiedLogger,
21    UnifiedLoggerBuilder, UnifiedLoggerRead,
22};
23use std::collections::{HashMap, VecDeque};
24use std::io;
25use std::marker::PhantomData;
26use std::path::Path;
27use std::sync::Arc;
28
29/// Result of a jump/step, useful for benchmarking cache effectiveness.
30#[derive(Debug, Clone)]
31pub struct JumpOutcome {
32    /// Copperlist id we landed on
33    pub culistid: u64,
34    /// Keyframe used to rewind (if any)
35    pub keyframe_culistid: Option<u64>,
36    /// Number of copperlists replayed after the keyframe
37    pub replayed: usize,
38}
39
40/// Section-cache statistics for a debug session.
41#[derive(Debug, Clone, Copy)]
42pub struct SectionCacheStats {
43    pub cap: usize,
44    pub entries: usize,
45    pub hits: u64,
46    pub misses: u64,
47    pub evictions: u64,
48}
49
50#[allow(dead_code)]
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub(crate) enum IndexedResolveMode {
53    Exact,
54    AtOrAfter,
55    AtOrBefore,
56}
57
58/// Metadata for one copperlist section (no payload kept).
59#[derive(Debug, Clone)]
60pub(crate) struct SectionIndexEntry {
61    pub(crate) pos: LogPosition,
62    pub(crate) start_idx: usize,
63    pub(crate) len: usize,
64    pub(crate) first_id: u64,
65    pub(crate) last_id: u64,
66    pub(crate) first_ts: Option<CuTime>,
67    pub(crate) last_ts: Option<CuTime>,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub(crate) struct LogIndexProgress {
72    pub(crate) scanned_bytes: u64,
73    pub(crate) total_bytes: u64,
74    pub(crate) indexed_entries: usize,
75}
76
77/// Cached copperlists for one section.
78#[derive(Debug, Clone)]
79struct CachedSection<P: CopperListTuple> {
80    entries: Vec<Arc<crate::copperlist::CopperList<P>>>,
81    timestamps: Vec<Option<CuTime>>,
82}
83
84/// A reusable debugging session that can time-travel within a recorded log.
85///
86/// `CB` builds a simulation callback for a specific copperlist entry. This keeps the
87/// API generic: the caller can replay recorded outputs, drive the mock clock inside a
88/// CopperList, or inject extra assertions inside the callback. `TF` extracts a
89/// timestamp from a copperlist to support time-based seeking.
90const DEFAULT_SECTION_CACHE_CAP: usize = 8;
91pub struct CuDebugSession<App, P, CB, TF, S, L>
92where
93    P: CopperListTuple,
94    S: SectionStorage,
95    L: UnifiedLogWrite<S> + 'static,
96{
97    app: App,
98    robot_clock: RobotClock,
99    clock_mock: RobotClockMock,
100    log_reader: UnifiedLoggerRead,
101    sections: Vec<SectionIndexEntry>,
102    total_entries: usize,
103    keyframes: Vec<KeyFrame>,
104    #[cfg(feature = "clock-sync")]
105    clock_replay: crate::clock_sync::ClockReplay,
106    started: bool,
107    current_idx: Option<usize>,
108    last_keyframe: Option<u64>,
109    build_callback: CB,
110    time_of: TF,
111    // Tiny LRU cache of decoded sections
112    cache: HashMap<usize, CachedSection<P>>,
113    cache_order: VecDeque<usize>,
114    cache_cap: usize,
115    cache_hits: u64,
116    cache_misses: u64,
117    cache_evictions: u64,
118    phantom: PhantomData<(S, L)>,
119}
120
121impl<App, P, CB, TF, S, L> CuDebugSession<App, P, CB, TF, S, L>
122where
123    App: CuSimApplication<S, L>,
124    L: UnifiedLogWrite<S> + 'static,
125    S: SectionStorage,
126    P: CopperListTuple + 'static,
127    CB: for<'a> Fn(
128        &'a crate::copperlist::CopperList<P>,
129        RobotClock,
130        RobotClockMock,
131    ) -> Box<dyn for<'z> FnMut(App::Step<'z>) -> SimOverride + 'a>,
132    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime> + Clone,
133{
134    /// Build a session directly from a unified log on disk (streaming index, no bulk load).
135    pub fn from_log(
136        log_base: &Path,
137        app: App,
138        robot_clock: RobotClock,
139        clock_mock: RobotClockMock,
140        build_callback: CB,
141        time_of: TF,
142    ) -> CuResult<Self> {
143        Self::from_log_with_progress(
144            log_base,
145            app,
146            robot_clock,
147            clock_mock,
148            build_callback,
149            time_of,
150            |_| {},
151        )
152    }
153
154    pub(crate) fn from_log_with_progress(
155        log_base: &Path,
156        app: App,
157        robot_clock: RobotClock,
158        clock_mock: RobotClockMock,
159        build_callback: CB,
160        time_of: TF,
161        mut progress: impl FnMut(LogIndexProgress),
162    ) -> CuResult<Self> {
163        let _ = crate::logcodec::seed_effective_config_from_log::<P>(log_base)?;
164        let (sections, keyframes, total_entries) =
165            index_log_with_progress::<P, _, _>(log_base, &time_of, &mut progress)?;
166        let log_reader = build_read_logger(log_base)?;
167        #[allow(unused_mut)]
168        let mut session = Self::new(
169            log_reader,
170            app,
171            robot_clock,
172            clock_mock,
173            sections,
174            total_entries,
175            keyframes,
176            build_callback,
177            time_of,
178        );
179        #[cfg(feature = "clock-sync")]
180        {
181            session.clock_replay = crate::clock_sync::ClockReplay::new(
182                crate::clock_sync::load_clock_sync_records(log_base)?,
183            );
184        }
185        Ok(session)
186    }
187
188    /// Build a session directly from a log, with an explicit cache size.
189    pub fn from_log_with_cache_cap(
190        log_base: &Path,
191        app: App,
192        robot_clock: RobotClock,
193        clock_mock: RobotClockMock,
194        build_callback: CB,
195        time_of: TF,
196        cache_cap: usize,
197    ) -> CuResult<Self> {
198        Self::from_log_with_cache_cap_and_progress(
199            log_base,
200            app,
201            robot_clock,
202            clock_mock,
203            build_callback,
204            time_of,
205            cache_cap,
206            |_| {},
207        )
208    }
209
210    #[allow(clippy::too_many_arguments)]
211    pub(crate) fn from_log_with_cache_cap_and_progress(
212        log_base: &Path,
213        app: App,
214        robot_clock: RobotClock,
215        clock_mock: RobotClockMock,
216        build_callback: CB,
217        time_of: TF,
218        cache_cap: usize,
219        mut progress: impl FnMut(LogIndexProgress),
220    ) -> CuResult<Self> {
221        let _ = crate::logcodec::seed_effective_config_from_log::<P>(log_base)?;
222        let (sections, keyframes, total_entries) =
223            index_log_with_progress::<P, _, _>(log_base, &time_of, &mut progress)?;
224        let log_reader = build_read_logger(log_base)?;
225        #[allow(unused_mut)]
226        let mut session = Self::new_with_cache_cap(
227            log_reader,
228            app,
229            robot_clock,
230            clock_mock,
231            sections,
232            total_entries,
233            keyframes,
234            build_callback,
235            time_of,
236            cache_cap,
237        );
238        #[cfg(feature = "clock-sync")]
239        {
240            session.clock_replay = crate::clock_sync::ClockReplay::new(
241                crate::clock_sync::load_clock_sync_records(log_base)?,
242            );
243        }
244        Ok(session)
245    }
246
247    /// Create a new session from prebuilt indices.
248    #[allow(clippy::too_many_arguments)]
249    pub(crate) fn new(
250        log_reader: UnifiedLoggerRead,
251        app: App,
252        robot_clock: RobotClock,
253        clock_mock: RobotClockMock,
254        sections: Vec<SectionIndexEntry>,
255        total_entries: usize,
256        keyframes: Vec<KeyFrame>,
257        build_callback: CB,
258        time_of: TF,
259    ) -> Self {
260        Self::new_with_cache_cap(
261            log_reader,
262            app,
263            robot_clock,
264            clock_mock,
265            sections,
266            total_entries,
267            keyframes,
268            build_callback,
269            time_of,
270            DEFAULT_SECTION_CACHE_CAP,
271        )
272    }
273
274    #[allow(clippy::too_many_arguments)]
275    pub(crate) fn new_with_cache_cap(
276        log_reader: UnifiedLoggerRead,
277        app: App,
278        robot_clock: RobotClock,
279        clock_mock: RobotClockMock,
280        sections: Vec<SectionIndexEntry>,
281        total_entries: usize,
282        keyframes: Vec<KeyFrame>,
283        build_callback: CB,
284        time_of: TF,
285        cache_cap: usize,
286    ) -> Self {
287        Self {
288            app,
289            robot_clock,
290            clock_mock,
291            log_reader,
292            sections,
293            total_entries,
294            keyframes,
295            #[cfg(feature = "clock-sync")]
296            clock_replay: crate::clock_sync::ClockReplay::new(Vec::new()),
297            started: false,
298            current_idx: None,
299            last_keyframe: None,
300            build_callback,
301            time_of,
302            cache: HashMap::new(),
303            cache_order: VecDeque::new(),
304            cache_cap: cache_cap.max(1),
305            cache_hits: 0,
306            cache_misses: 0,
307            cache_evictions: 0,
308            phantom: PhantomData,
309        }
310    }
311
312    #[inline]
313    pub fn app(&self) -> &App {
314        &self.app
315    }
316
317    #[inline]
318    pub fn app_mut(&mut self) -> &mut App {
319        &mut self.app
320    }
321
322    // Framework replay engine: drives the raw (app-deprecated) lifecycle on purpose.
323    #[allow(deprecated)]
324    fn ensure_started(&mut self) -> CuResult<()> {
325        if self.started {
326            return Ok(());
327        }
328        #[cfg(feature = "clock-sync")]
329        if self.total_entries > 0 {
330            let (entry, time) = self.copperlist_at(0)?;
331            if let Some(time) = time {
332                self.clock_replay
333                    .apply(&self.robot_clock, &self.clock_mock, entry.id, time)?;
334            }
335        }
336        let mut noop = |_step: App::Step<'_>| SimOverride::ExecuteByRuntime;
337        self.app.start_all_tasks(&mut noop)?;
338        self.started = true;
339        Ok(())
340    }
341
342    fn nearest_keyframe(&self, target_culistid: u64) -> Option<KeyFrame> {
343        nearest_replay_keyframe(&self.keyframes, target_culistid)
344    }
345
346    fn restore_keyframe(&mut self, kf: &KeyFrame) -> CuResult<()> {
347        self.app.restore_keyframe(kf)?;
348        #[cfg(feature = "clock-sync")]
349        self.clock_replay.apply(
350            &self.robot_clock,
351            &self.clock_mock,
352            kf.culistid,
353            kf.timestamp,
354        )?;
355        #[cfg(not(feature = "clock-sync"))]
356        self.clock_mock.set_value(kf.timestamp.as_nanos());
357        self.last_keyframe = Some(kf.culistid);
358        Ok(())
359    }
360
361    fn clear_runtime_copperlist_snapshot(&mut self)
362    where
363        App: CurrentRuntimeCopperList<P>,
364    {
365        self.app.set_current_runtime_copperlist_bytes(None);
366    }
367
368    fn normalize_runtime_copperlist_snapshot(
369        &mut self,
370        recorded: &crate::copperlist::CopperList<P>,
371    ) -> CuResult<()>
372    where
373        App: CurrentRuntimeCopperList<P>,
374    {
375        let normalized = self
376            .app
377            .current_runtime_copperlist_bytes()
378            .map(|bytes| {
379                let (mut runtime_cl, _) = bincode::decode_from_slice::<
380                    crate::copperlist::CopperList<P>,
381                    _,
382                >(bytes, standard())
383                .map_err(|e| {
384                    CuError::new_with_cause("Failed to decode runtime CopperList snapshot", e)
385                })?;
386                runtime_cl.id = recorded.id;
387                bincode::encode_to_vec(&runtime_cl, standard()).map_err(|e| {
388                    CuError::new_with_cause("Failed to encode normalized CopperList snapshot", e)
389                })
390            })
391            .transpose()?;
392        self.app.set_current_runtime_copperlist_bytes(normalized);
393        Ok(())
394    }
395
396    fn find_section_for_index(&self, idx: usize) -> Option<usize> {
397        self.sections
398            .binary_search_by(|s| {
399                if idx < s.start_idx {
400                    std::cmp::Ordering::Greater
401                } else if idx >= s.start_idx + s.len {
402                    std::cmp::Ordering::Less
403                } else {
404                    std::cmp::Ordering::Equal
405                }
406            })
407            .ok()
408    }
409
410    fn find_section_for_culistid(&self, culistid: u64) -> Option<usize> {
411        self.sections
412            .binary_search_by(|s| {
413                if culistid < s.first_id {
414                    std::cmp::Ordering::Greater
415                } else if culistid > s.last_id {
416                    std::cmp::Ordering::Less
417                } else {
418                    std::cmp::Ordering::Equal
419                }
420            })
421            .ok()
422    }
423
424    fn touch_cache(&mut self, key: usize) {
425        if let Some(pos) = self.cache_order.iter().position(|k| *k == key) {
426            self.cache_order.remove(pos);
427        }
428        self.cache_order.push_back(key);
429        while self.cache_order.len() > self.cache_cap {
430            if let Some(old) = self.cache_order.pop_front()
431                && self.cache.remove(&old).is_some()
432            {
433                self.cache_evictions = self.cache_evictions.saturating_add(1);
434            }
435        }
436    }
437
438    fn load_section(&mut self, section_idx: usize) -> CuResult<&CachedSection<P>> {
439        if self.cache.contains_key(&section_idx) {
440            self.cache_hits = self.cache_hits.saturating_add(1);
441            self.touch_cache(section_idx);
442            // SAFETY: key exists, unwrap ok.
443            return Ok(self.cache.get(&section_idx).unwrap());
444        }
445        self.cache_misses = self.cache_misses.saturating_add(1);
446
447        let entry = &self.sections[section_idx];
448        let (header, data) = read_section_at(&mut self.log_reader, entry.pos)?;
449        if header.entry_type != UnifiedLogType::CopperList {
450            return Err(CuError::from(
451                "Section type mismatch while loading copperlists",
452            ));
453        }
454
455        let (entries, timestamps) = decode_copperlists::<P, _>(&data, &self.time_of)?;
456        let cached = CachedSection {
457            entries,
458            timestamps,
459        };
460        self.cache.insert(section_idx, cached);
461        self.touch_cache(section_idx);
462        Ok(self.cache.get(&section_idx).unwrap())
463    }
464
465    fn copperlist_at(
466        &mut self,
467        idx: usize,
468    ) -> CuResult<(Arc<crate::copperlist::CopperList<P>>, Option<CuTime>)> {
469        let section_idx = self
470            .find_section_for_index(idx)
471            .ok_or_else(|| CuError::from("Index outside copperlist log"))?;
472        let start_idx = self.sections[section_idx].start_idx;
473        let section = self.load_section(section_idx)?;
474        let local = idx - start_idx;
475        let cl = section
476            .entries
477            .get(local)
478            .ok_or_else(|| CuError::from("Corrupt section index vs cache"))?
479            .clone();
480        let ts = section.timestamps.get(local).copied().unwrap_or(None);
481        Ok((cl, ts))
482    }
483
484    fn first_section_with_last_id_at_least(&self, culistid: u64) -> usize {
485        let mut left = 0usize;
486        let mut right = self.sections.len();
487        while left < right {
488            let mid = left + (right - left) / 2;
489            if self.sections[mid].last_id < culistid {
490                left = mid + 1;
491            } else {
492                right = mid;
493            }
494        }
495        left
496    }
497
498    fn first_section_with_first_id_greater_than(&self, culistid: u64) -> usize {
499        let mut left = 0usize;
500        let mut right = self.sections.len();
501        while left < right {
502            let mid = left + (right - left) / 2;
503            if self.sections[mid].first_id <= culistid {
504                left = mid + 1;
505            } else {
506                right = mid;
507            }
508        }
509        left
510    }
511
512    fn index_for_culistid_at_or_after(&mut self, culistid: u64) -> CuResult<usize> {
513        let mut section_idx = self.first_section_with_last_id_at_least(culistid);
514        while section_idx < self.sections.len() {
515            let start_idx = self.sections[section_idx].start_idx;
516            let section = self.load_section(section_idx)?;
517            for (offset, cl) in section.entries.iter().enumerate() {
518                if cl.id >= culistid {
519                    return Ok(start_idx + offset);
520                }
521            }
522            section_idx += 1;
523        }
524        Err(CuError::from(format!("No CL at/after target {culistid}")))
525    }
526
527    fn index_for_culistid_at_or_before(&mut self, culistid: u64) -> CuResult<usize> {
528        let mut section_idx = self.first_section_with_first_id_greater_than(culistid);
529        while section_idx > 0 {
530            section_idx -= 1;
531            let start_idx = self.sections[section_idx].start_idx;
532            let section = self.load_section(section_idx)?;
533            for (offset, cl) in section.entries.iter().enumerate().rev() {
534                if cl.id <= culistid {
535                    return Ok(start_idx + offset);
536                }
537            }
538        }
539        Err(CuError::from(format!("No CL at/before target {culistid}")))
540    }
541
542    fn index_for_culistid(&mut self, culistid: u64) -> CuResult<usize> {
543        let section_idx = self
544            .find_section_for_culistid(culistid)
545            .ok_or_else(|| CuError::from("Requested culistid not present in log"))?;
546        let start_idx = self.sections[section_idx].start_idx;
547        let section = self.load_section(section_idx)?;
548        for (offset, cl) in section.entries.iter().enumerate() {
549            if cl.id == culistid {
550                return Ok(start_idx + offset);
551            }
552        }
553        Err(CuError::from("culistid not found inside indexed section"))
554    }
555
556    pub(crate) fn resolve_index_for_culistid(
557        &mut self,
558        culistid: u64,
559        mode: IndexedResolveMode,
560    ) -> CuResult<usize> {
561        match mode {
562            IndexedResolveMode::Exact => self
563                .index_for_culistid(culistid)
564                .map_err(|_| CuError::from(format!("No exact CL target for {culistid}"))),
565            IndexedResolveMode::AtOrAfter => self.index_for_culistid_at_or_after(culistid),
566            IndexedResolveMode::AtOrBefore => self.index_for_culistid_at_or_before(culistid),
567        }
568    }
569
570    fn index_for_time_at_or_after(&mut self, ts: CuTime) -> CuResult<usize> {
571        for section_idx in 0..self.sections.len() {
572            let section_entry = &self.sections[section_idx];
573            if matches!(section_entry.last_ts, Some(last) if last < ts) {
574                continue;
575            }
576
577            let start_idx = section_entry.start_idx;
578            let section_first_ts = section_entry.first_ts;
579            let section = self.load_section(section_idx)?;
580            for (offset, maybe_ts) in section.timestamps.iter().enumerate() {
581                if matches!(maybe_ts, Some(entry_ts) if *entry_ts >= ts) {
582                    return Ok(start_idx + offset);
583                }
584            }
585
586            if matches!(section_first_ts, Some(first) if first > ts) {
587                break;
588            }
589        }
590
591        Err(CuError::from(format!(
592            "No timestamp at/after {}",
593            ts.as_nanos()
594        )))
595    }
596
597    fn index_for_time_at_or_before(&mut self, ts: CuTime) -> CuResult<usize> {
598        for section_idx in (0..self.sections.len()).rev() {
599            let section_entry = &self.sections[section_idx];
600            if matches!(section_entry.first_ts, Some(first) if first > ts) {
601                continue;
602            }
603
604            let start_idx = section_entry.start_idx;
605            let section = self.load_section(section_idx)?;
606            for (offset, maybe_ts) in section.timestamps.iter().enumerate().rev() {
607                if matches!(maybe_ts, Some(entry_ts) if *entry_ts <= ts) {
608                    return Ok(start_idx + offset);
609                }
610            }
611        }
612
613        Err(CuError::from(format!(
614            "No timestamp at/before {}",
615            ts.as_nanos()
616        )))
617    }
618
619    fn index_for_exact_time(&mut self, ts: CuTime) -> CuResult<usize> {
620        for section_idx in 0..self.sections.len() {
621            let section_entry = &self.sections[section_idx];
622            if matches!(section_entry.last_ts, Some(last) if last < ts) {
623                continue;
624            }
625            if matches!(section_entry.first_ts, Some(first) if first > ts) {
626                break;
627            }
628
629            let start_idx = section_entry.start_idx;
630            let section = self.load_section(section_idx)?;
631            for (offset, maybe_ts) in section.timestamps.iter().enumerate() {
632                if matches!(maybe_ts, Some(entry_ts) if *entry_ts == ts) {
633                    return Ok(start_idx + offset);
634                }
635            }
636        }
637
638        Err(CuError::from(format!(
639            "No exact timestamp target for {}",
640            ts.as_nanos()
641        )))
642    }
643
644    fn index_for_time(&mut self, ts: CuTime) -> CuResult<usize> {
645        self.resolve_index_for_time(ts, IndexedResolveMode::AtOrAfter)
646    }
647
648    pub(crate) fn resolve_index_for_time(
649        &mut self,
650        ts: CuTime,
651        mode: IndexedResolveMode,
652    ) -> CuResult<usize> {
653        match mode {
654            IndexedResolveMode::Exact => self.index_for_exact_time(ts),
655            IndexedResolveMode::AtOrAfter => self.index_for_time_at_or_after(ts),
656            IndexedResolveMode::AtOrBefore => self.index_for_time_at_or_before(ts),
657        }
658    }
659
660    // Framework replay engine: drives the raw (app-deprecated) lifecycle on purpose.
661    #[allow(deprecated)]
662    fn replay_range(&mut self, start: usize, end: usize) -> CuResult<usize>
663    where
664        App: CurrentRuntimeCopperList<P>,
665    {
666        let mut replayed = 0usize;
667        for idx in start..=end {
668            let (entry, ts) = self.copperlist_at(idx)?;
669            let restored = (idx == start)
670                .then_some(self.last_keyframe)
671                .flatten()
672                .filter(|boundary| *boundary == entry.id);
673            let expected = if idx > 0 {
674                self.copperlist_at(idx - 1)?
675                    .0
676                    .id
677                    .checked_add(1)
678                    .ok_or_else(|| CuError::from("Replay CopperList id overflow"))?
679            } else {
680                0
681            };
682            crate::continuity::validate_replay_continuity(expected, entry.id, restored)?;
683            if let Some(ts) = ts {
684                #[cfg(feature = "clock-sync")]
685                self.clock_replay
686                    .apply(&self.robot_clock, &self.clock_mock, entry.id, ts)?;
687                #[cfg(not(feature = "clock-sync"))]
688                self.clock_mock.set_value(ts.as_nanos());
689            }
690            let mut cb = build_replay_callback(
691                &self.build_callback,
692                entry.as_ref(),
693                self.robot_clock.clone(),
694                self.clock_mock.clone(),
695            );
696            self.app.run_one_iteration(&mut cb)?;
697            self.normalize_runtime_copperlist_snapshot(entry.as_ref())?;
698            replayed += 1;
699            self.current_idx = Some(idx);
700        }
701        Ok(replayed)
702    }
703
704    pub(crate) fn goto_index(&mut self, target_idx: usize) -> CuResult<JumpOutcome>
705    where
706        App: CurrentRuntimeCopperList<P>,
707    {
708        self.ensure_started()?;
709        if target_idx >= self.total_entries {
710            return Err(CuError::from("Target index outside log"));
711        }
712        let (target_cl, _) = self.copperlist_at(target_idx)?;
713        let target_culistid = target_cl.id;
714
715        let keyframe_used: Option<u64>;
716        let replay_start: usize;
717
718        // Fast path: forward stepping from current state.
719        if let Some(current) = self.current_idx {
720            if target_idx == current {
721                return Ok(JumpOutcome {
722                    culistid: target_culistid,
723                    keyframe_culistid: self.last_keyframe,
724                    replayed: 0,
725                });
726            }
727
728            if target_idx >= current {
729                let nearest_keyframe = self.nearest_keyframe(target_culistid);
730                let nearest_keyframe_idx = nearest_keyframe
731                    .as_ref()
732                    .and_then(|kf| self.index_for_culistid(kf.culistid).ok());
733
734                if let (Some(kf), Some(kf_idx)) = (nearest_keyframe, nearest_keyframe_idx)
735                    && kf_idx > current
736                {
737                    self.restore_keyframe(&kf)?;
738                    self.clear_runtime_copperlist_snapshot();
739                    keyframe_used = Some(kf.culistid);
740                    replay_start = kf_idx;
741                } else {
742                    replay_start = current + 1;
743                    keyframe_used = self.last_keyframe;
744                }
745            } else {
746                // Need to rewind to nearest keyframe
747                let Some(kf) = self.nearest_keyframe(target_culistid) else {
748                    return Err(CuError::from("No keyframe available to rewind"));
749                };
750                self.restore_keyframe(&kf)?;
751                self.clear_runtime_copperlist_snapshot();
752                keyframe_used = Some(kf.culistid);
753                replay_start = self.index_for_culistid(kf.culistid)?;
754            }
755        } else {
756            // First jump: align to nearest keyframe
757            let Some(kf) = self.nearest_keyframe(target_culistid) else {
758                return Err(CuError::from("No keyframe found in log"));
759            };
760            self.restore_keyframe(&kf)?;
761            self.clear_runtime_copperlist_snapshot();
762            keyframe_used = Some(kf.culistid);
763            replay_start = self.index_for_culistid(kf.culistid)?;
764        }
765
766        if replay_start > target_idx {
767            return Err(CuError::from(
768                "Replay start past target index; log ordering issue",
769            ));
770        }
771
772        let replayed = self.replay_range(replay_start, target_idx)?;
773
774        Ok(JumpOutcome {
775            culistid: target_culistid,
776            keyframe_culistid: keyframe_used,
777            replayed,
778        })
779    }
780
781    /// Jump to a copperlist by id.
782    pub fn goto_cl(&mut self, culistid: u64) -> CuResult<JumpOutcome>
783    where
784        App: CurrentRuntimeCopperList<P>,
785    {
786        let idx = self.resolve_index_for_culistid(culistid, IndexedResolveMode::Exact)?;
787        self.goto_index(idx)
788    }
789
790    /// Jump to the first copperlist at or after a timestamp.
791    pub fn goto_time(&mut self, ts: CuTime) -> CuResult<JumpOutcome>
792    where
793        App: CurrentRuntimeCopperList<P>,
794    {
795        let idx = self.index_for_time(ts)?;
796        self.goto_index(idx)
797    }
798
799    /// Step relative to the current cursor. Negative values rewind via keyframe.
800    pub fn step(&mut self, delta: i32) -> CuResult<JumpOutcome>
801    where
802        App: CurrentRuntimeCopperList<P>,
803    {
804        let current =
805            self.current_idx
806                .ok_or_else(|| CuError::from("Cannot step before any jump"))? as i32;
807        let target = current + delta;
808        if target < 0 || target as usize >= self.total_entries {
809            return Err(CuError::from("Step would move outside log bounds"));
810        }
811        self.goto_index(target as usize)
812    }
813
814    /// Access the copperlist at the current cursor, if any (cloned).
815    pub fn current_cl(&mut self) -> CuResult<Option<Arc<crate::copperlist::CopperList<P>>>> {
816        match self.current_idx {
817            Some(idx) => Ok(Some(self.copperlist_at(idx)?.0)),
818            None => Ok(None),
819        }
820    }
821
822    /// Access a copperlist by absolute index in the log (cloned).
823    pub fn cl_at(&mut self, idx: usize) -> CuResult<Option<Arc<crate::copperlist::CopperList<P>>>> {
824        if idx >= self.total_entries {
825            return Ok(None);
826        }
827        Ok(Some(self.copperlist_at(idx)?.0))
828    }
829
830    /// Total number of copperlists indexed in this session.
831    pub fn total_entries(&self) -> usize {
832        self.total_entries
833    }
834
835    /// The nearest keyframe (<= target CL), if any.
836    pub fn nearest_keyframe_culistid(&self, target_culistid: u64) -> Option<u64> {
837        self.nearest_keyframe(target_culistid).map(|kf| kf.culistid)
838    }
839
840    /// Whether the log contains an exact keyframe for this copperlist id.
841    pub fn is_keyframe_culistid(&self, target_culistid: u64) -> bool {
842        self.keyframes
843            .iter()
844            .any(|kf| kf.culistid == target_culistid)
845    }
846
847    /// Returns section-cache statistics for this session.
848    pub fn section_cache_stats(&self) -> SectionCacheStats {
849        SectionCacheStats {
850            cap: self.cache_cap,
851            entries: self.cache.len(),
852            hits: self.cache_hits,
853            misses: self.cache_misses,
854            evictions: self.cache_evictions,
855        }
856    }
857
858    /// Current absolute cursor index, if initialized.
859    pub fn current_index(&self) -> Option<usize> {
860        self.current_idx
861    }
862
863    /// Borrow the underlying application for inspection (e.g., task state asserts).
864    pub fn with_app<R>(&mut self, f: impl FnOnce(&mut App) -> R) -> R {
865        f(&mut self.app)
866    }
867}
868
869impl<App, P, CB, TF, S, L> CuDebugSession<App, P, CB, TF, S, L>
870where
871    App: CuSimApplication<S, L> + ReflectTaskIntrospection,
872    L: UnifiedLogWrite<S> + 'static,
873    S: SectionStorage,
874    P: CopperListTuple,
875    CB: for<'a> Fn(
876        &'a crate::copperlist::CopperList<P>,
877        RobotClock,
878        RobotClockMock,
879    ) -> Box<dyn for<'z> FnMut(App::Step<'z>) -> SimOverride + 'a>,
880    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime> + Clone,
881{
882    /// Returns a reflected view of the current task instance by task id.
883    pub fn reflected_task(&self, task_id: &str) -> CuResult<&dyn crate::reflect::Reflect> {
884        self.app
885            .reflect_task(task_id)
886            .ok_or_else(|| CuError::from(format!("Task '{task_id}' was not found.")))
887    }
888
889    /// Mutable reflected task view by task id.
890    pub fn reflected_task_mut(
891        &mut self,
892        task_id: &str,
893    ) -> CuResult<&mut dyn crate::reflect::Reflect> {
894        self.app
895            .reflect_task_mut(task_id)
896            .ok_or_else(|| CuError::from(format!("Task '{task_id}' was not found.")))
897    }
898
899    /// Borrows the current typed debug-state view for one task.
900    pub fn with_debug_state<R>(
901        &self,
902        task_id: &str,
903        f: impl FnOnce(&dyn crate::reflect::Reflect) -> R,
904    ) -> CuResult<R> {
905        self.app
906            .with_debug_state(task_id, f)
907            .ok_or_else(|| CuError::from(format!("Task '{task_id}' was not found.")))
908    }
909
910    /// Dumps the reflected runtime state of one task.
911    pub fn dump_reflected_task(&self, task_id: &str) -> CuResult<String> {
912        let task = self.reflected_task(task_id)?;
913        #[cfg(not(feature = "reflect"))]
914        {
915            let _ = task;
916            Err(CuError::from(
917                "Task introspection is disabled. Rebuild with the `reflect` feature.",
918            ))
919        }
920
921        #[cfg(feature = "reflect")]
922        {
923            Ok(format!("{task:#?}"))
924        }
925    }
926
927    /// Dumps reflected schemas registered by this application.
928    pub fn dump_reflected_task_schemas(&self) -> String {
929        #[cfg(feature = "reflect")]
930        let mut registry = TypeRegistry::default();
931        #[cfg(not(feature = "reflect"))]
932        let mut registry = TypeRegistry;
933        <App as ReflectTaskIntrospection>::register_reflect_types(&mut registry);
934        dump_type_registry_schema(&registry)
935    }
936}
937/// Decode all copperlists contained in a single unified-log section.
938#[allow(clippy::type_complexity)]
939pub(crate) fn decode_copperlists<
940    P: CopperListTuple,
941    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
942>(
943    section: &[u8],
944    time_of: &TF,
945) -> CuResult<(
946    Vec<Arc<crate::copperlist::CopperList<P>>>,
947    Vec<Option<CuTime>>,
948)> {
949    let mut cursor = std::io::Cursor::new(section);
950    let mut entries = Vec::new();
951    let mut timestamps = Vec::new();
952    loop {
953        match decode_from_std_read::<crate::copperlist::CopperList<P>, _, _>(
954            &mut cursor,
955            standard(),
956        ) {
957            Ok(cl) => {
958                timestamps.push(time_of(&cl));
959                entries.push(Arc::new(cl));
960            }
961            Err(DecodeError::UnexpectedEnd { .. }) => break,
962            Err(DecodeError::Io { inner, .. }) if inner.kind() == io::ErrorKind::UnexpectedEof => {
963                break;
964            }
965            Err(e) => {
966                return Err(CuError::new_with_cause(
967                    "Failed to decode CopperList section",
968                    e,
969                ));
970            }
971        }
972    }
973    Ok((entries, timestamps))
974}
975
976/// Scan a copperlist section for metadata only.
977#[allow(clippy::type_complexity)]
978fn scan_copperlist_section<
979    P: CopperListTuple,
980    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
981>(
982    section: &[u8],
983    time_of: &TF,
984) -> CuResult<(usize, u64, u64, Option<CuTime>, Option<CuTime>)> {
985    let mut cursor = std::io::Cursor::new(section);
986    let mut count = 0usize;
987    let mut first_id = None;
988    let mut last_id = None;
989    let mut first_ts = None;
990    let mut last_ts = None;
991    loop {
992        match decode_from_std_read::<crate::copperlist::CopperList<P>, _, _>(
993            &mut cursor,
994            standard(),
995        ) {
996            Ok(cl) => {
997                let ts = time_of(&cl);
998                if ts.is_none() {
999                    #[cfg(feature = "std")]
1000                    eprintln!(
1001                        "CuDebug index warning: missing timestamp on culistid {}; time-based seek may be less accurate",
1002                        cl.id
1003                    );
1004                }
1005                if first_id.is_none() {
1006                    first_id = Some(cl.id);
1007                    first_ts = ts;
1008                }
1009                // Recover first_ts if the first entry lacked a timestamp but a later one has it.
1010                if first_ts.is_none() {
1011                    first_ts = ts;
1012                }
1013                last_id = Some(cl.id);
1014                last_ts = ts.or(last_ts);
1015                count += 1;
1016            }
1017            Err(DecodeError::UnexpectedEnd { .. }) => break,
1018            Err(DecodeError::Io { inner, .. }) if inner.kind() == io::ErrorKind::UnexpectedEof => {
1019                break;
1020            }
1021            Err(e) => {
1022                return Err(CuError::new_with_cause(
1023                    "Failed to scan copperlist section",
1024                    e,
1025                ));
1026            }
1027        }
1028    }
1029    let first_id = first_id.ok_or_else(|| CuError::from("Empty copperlist section"))?;
1030    let last_id = last_id.unwrap_or(first_id);
1031    Ok((count, first_id, last_id, first_ts, last_ts))
1032}
1033
1034/// Build a reusable read-only unified logger for this session.
1035pub(crate) fn build_read_logger(log_base: &Path) -> CuResult<UnifiedLoggerRead> {
1036    let logger = UnifiedLoggerBuilder::new()
1037        .file_base_name(log_base)
1038        .build()
1039        .map_err(|e| CuError::new_with_cause("Failed to open unified log", e))?;
1040    let UnifiedLogger::Read(dl) = logger else {
1041        return Err(CuError::from("Expected read-only unified logger"));
1042    };
1043    Ok(dl)
1044}
1045
1046/// Read a specific section at a given position from disk using an existing handle.
1047pub(crate) fn read_section_at(
1048    log_reader: &mut UnifiedLoggerRead,
1049    pos: LogPosition,
1050) -> CuResult<(SectionHeader, Vec<u8>)> {
1051    log_reader.seek(pos)?;
1052    log_reader.raw_read_section()
1053}
1054
1055/// Build a section-level index in one pass (copperlists + keyframes).
1056pub(crate) fn index_log<P, TF>(
1057    log_base: &Path,
1058    time_of: &TF,
1059) -> CuResult<(Vec<SectionIndexEntry>, Vec<KeyFrame>, usize)>
1060where
1061    P: CopperListTuple,
1062    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
1063{
1064    index_log_with_progress(log_base, time_of, |_| {})
1065}
1066
1067fn index_log_with_progress<P, TF, PF>(
1068    log_base: &Path,
1069    time_of: &TF,
1070    mut progress: PF,
1071) -> CuResult<(Vec<SectionIndexEntry>, Vec<KeyFrame>, usize)>
1072where
1073    P: CopperListTuple,
1074    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
1075    PF: FnMut(LogIndexProgress),
1076{
1077    let sizing_logger = UnifiedLoggerBuilder::new()
1078        .file_base_name(log_base)
1079        .build()
1080        .map_err(|e| CuError::new_with_cause("Failed to open unified log", e))?;
1081    let UnifiedLogger::Read(mut sizing_reader) = sizing_logger else {
1082        return Err(CuError::from("Expected read-only unified logger"));
1083    };
1084    let mut total_bytes = 0u64;
1085    loop {
1086        let header = sizing_reader.raw_skip_section()?;
1087        if header.entry_type == UnifiedLogType::LastEntry {
1088            break;
1089        }
1090        total_bytes = total_bytes.saturating_add(header.used as u64);
1091    }
1092
1093    let logger = UnifiedLoggerBuilder::new()
1094        .file_base_name(log_base)
1095        .build()
1096        .map_err(|e| CuError::new_with_cause("Failed to open unified log", e))?;
1097    let UnifiedLogger::Read(mut dl) = logger else {
1098        return Err(CuError::from("Expected read-only unified logger"));
1099    };
1100
1101    let mut sections = Vec::new();
1102    let mut keyframes = Vec::new();
1103    let mut total_entries = 0usize;
1104    let mut scanned_bytes = 0u64;
1105    progress(LogIndexProgress {
1106        scanned_bytes,
1107        total_bytes,
1108        indexed_entries: total_entries,
1109    });
1110
1111    loop {
1112        let pos = dl.position();
1113        let (header, data) = dl.raw_read_section()?;
1114        if header.entry_type == UnifiedLogType::LastEntry {
1115            break;
1116        }
1117
1118        match header.entry_type {
1119            UnifiedLogType::CopperList => {
1120                let (len, first_id, last_id, first_ts, last_ts) =
1121                    scan_copperlist_section::<P, _>(&data, time_of)?;
1122                if len > 0 {
1123                    sections.push(SectionIndexEntry {
1124                        pos,
1125                        start_idx: total_entries,
1126                        len,
1127                        first_id,
1128                        last_id,
1129                        first_ts,
1130                        last_ts,
1131                    });
1132                    total_entries += len;
1133                }
1134            }
1135            UnifiedLogType::FrozenTasks => {
1136                // Read all keyframes in this section
1137                let mut cursor = std::io::Cursor::new(&data);
1138                loop {
1139                    match decode_from_std_read::<KeyFrame, _, _>(&mut cursor, standard()) {
1140                        Ok(kf) => keyframes.push(kf),
1141                        Err(DecodeError::UnexpectedEnd { .. }) => break,
1142                        Err(DecodeError::Io { inner, .. })
1143                            if inner.kind() == io::ErrorKind::UnexpectedEof =>
1144                        {
1145                            break;
1146                        }
1147                        Err(e) => {
1148                            return Err(CuError::new_with_cause(
1149                                "Failed to decode keyframe section",
1150                                e,
1151                            ));
1152                        }
1153                    }
1154                }
1155            }
1156            _ => {
1157                // ignore other sections
1158            }
1159        }
1160        scanned_bytes = scanned_bytes.saturating_add(header.used as u64);
1161        progress(LogIndexProgress {
1162            scanned_bytes,
1163            total_bytes,
1164            indexed_entries: total_entries,
1165        });
1166    }
1167
1168    Ok((sections, keyframes, total_entries))
1169}
1170
1171fn nearest_replay_keyframe(keyframes: &[KeyFrame], target_culistid: u64) -> Option<KeyFrame> {
1172    keyframes
1173        .iter()
1174        .filter(|kf| kf.culistid <= target_culistid)
1175        .max_by_key(|kf| kf.culistid)
1176        .cloned()
1177}
1178
1179// Keep the callback factory output generic to avoid a nightly borrow-checker ICE
1180// when normalizing the higher-ranked Step projection inside a boxed callback.
1181fn build_replay_callback<'a, P, CB, Callback>(
1182    build_callback: &CB,
1183    entry: &'a crate::copperlist::CopperList<P>,
1184    clock: RobotClock,
1185    clock_mock: RobotClockMock,
1186) -> Callback
1187where
1188    P: CopperListTuple,
1189    CB: Fn(&'a crate::copperlist::CopperList<P>, RobotClock, RobotClockMock) -> Callback,
1190{
1191    build_callback(entry, clock, clock_mock)
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196    use super::*;
1197
1198    fn keyframe(culistid: u64) -> KeyFrame {
1199        KeyFrame::from_serialized(culistid, CuTime::from_nanos(culistid), Vec::new())
1200    }
1201
1202    #[test]
1203    fn replay_keyframe_selects_nearest_keyframe_at_or_before_target() {
1204        let keyframes = [keyframe(0), keyframe(100), keyframe(500)];
1205
1206        let keyframe = nearest_replay_keyframe(&keyframes, 533).expect("replay keyframe");
1207
1208        assert_eq!(keyframe.culistid, 500);
1209    }
1210
1211    #[test]
1212    fn replay_keyframe_uses_nearest_available_nonzero_keyframe() {
1213        let keyframes = [keyframe(100), keyframe(500), keyframe(900)];
1214
1215        let keyframe = nearest_replay_keyframe(&keyframes, 533).expect("replay keyframe");
1216
1217        assert_eq!(keyframe.culistid, 500);
1218        assert!(nearest_replay_keyframe(&keyframes, 99).is_none());
1219    }
1220}