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