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