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 clock_for_cb = self.robot_clock.clone();
651            let clock_mock_for_cb = self.clock_mock.clone();
652            let mut cb = (self.build_callback)(entry.as_ref(), clock_for_cb, clock_mock_for_cb);
653            self.app.run_one_iteration(&mut cb)?;
654            self.normalize_runtime_copperlist_snapshot(entry.as_ref())?;
655            replayed += 1;
656            self.current_idx = Some(idx);
657        }
658        Ok(replayed)
659    }
660
661    pub(crate) fn goto_index(&mut self, target_idx: usize) -> CuResult<JumpOutcome>
662    where
663        App: CurrentRuntimeCopperList<P>,
664    {
665        self.ensure_started()?;
666        if target_idx >= self.total_entries {
667            return Err(CuError::from("Target index outside log"));
668        }
669        let (target_cl, _) = self.copperlist_at(target_idx)?;
670        let target_culistid = target_cl.id;
671
672        let keyframe_used: Option<u64>;
673        let replay_start: usize;
674
675        // Fast path: forward stepping from current state.
676        if let Some(current) = self.current_idx {
677            if target_idx == current {
678                return Ok(JumpOutcome {
679                    culistid: target_culistid,
680                    keyframe_culistid: self.last_keyframe,
681                    replayed: 0,
682                });
683            }
684
685            if target_idx >= current {
686                let nearest_keyframe = self.nearest_keyframe(target_culistid);
687                let nearest_keyframe_idx = nearest_keyframe
688                    .as_ref()
689                    .and_then(|kf| self.index_for_culistid(kf.culistid).ok());
690
691                if let (Some(kf), Some(kf_idx)) = (nearest_keyframe, nearest_keyframe_idx)
692                    && kf_idx > current
693                {
694                    self.restore_keyframe(&kf)?;
695                    self.clear_runtime_copperlist_snapshot();
696                    keyframe_used = Some(kf.culistid);
697                    replay_start = kf_idx;
698                } else {
699                    replay_start = current + 1;
700                    keyframe_used = self.last_keyframe;
701                }
702            } else {
703                // Need to rewind to nearest keyframe
704                let Some(kf) = self.nearest_keyframe(target_culistid) else {
705                    return Err(CuError::from("No keyframe available to rewind"));
706                };
707                self.restore_keyframe(&kf)?;
708                self.clear_runtime_copperlist_snapshot();
709                keyframe_used = Some(kf.culistid);
710                replay_start = self.index_for_culistid(kf.culistid)?;
711            }
712        } else {
713            // First jump: align to nearest keyframe
714            let Some(kf) = self.nearest_keyframe(target_culistid) else {
715                return Err(CuError::from("No keyframe found in log"));
716            };
717            self.restore_keyframe(&kf)?;
718            self.clear_runtime_copperlist_snapshot();
719            keyframe_used = Some(kf.culistid);
720            replay_start = self.index_for_culistid(kf.culistid)?;
721        }
722
723        if replay_start > target_idx {
724            return Err(CuError::from(
725                "Replay start past target index; log ordering issue",
726            ));
727        }
728
729        let replayed = self.replay_range(replay_start, target_idx)?;
730
731        Ok(JumpOutcome {
732            culistid: target_culistid,
733            keyframe_culistid: keyframe_used,
734            replayed,
735        })
736    }
737
738    /// Jump to a copperlist by id.
739    pub fn goto_cl(&mut self, culistid: u64) -> CuResult<JumpOutcome>
740    where
741        App: CurrentRuntimeCopperList<P>,
742    {
743        let idx = self.resolve_index_for_culistid(culistid, IndexedResolveMode::Exact)?;
744        self.goto_index(idx)
745    }
746
747    /// Jump to the first copperlist at or after a timestamp.
748    pub fn goto_time(&mut self, ts: CuTime) -> CuResult<JumpOutcome>
749    where
750        App: CurrentRuntimeCopperList<P>,
751    {
752        let idx = self.index_for_time(ts)?;
753        self.goto_index(idx)
754    }
755
756    /// Step relative to the current cursor. Negative values rewind via keyframe.
757    pub fn step(&mut self, delta: i32) -> CuResult<JumpOutcome>
758    where
759        App: CurrentRuntimeCopperList<P>,
760    {
761        let current =
762            self.current_idx
763                .ok_or_else(|| CuError::from("Cannot step before any jump"))? as i32;
764        let target = current + delta;
765        if target < 0 || target as usize >= self.total_entries {
766            return Err(CuError::from("Step would move outside log bounds"));
767        }
768        self.goto_index(target as usize)
769    }
770
771    /// Access the copperlist at the current cursor, if any (cloned).
772    pub fn current_cl(&mut self) -> CuResult<Option<Arc<crate::copperlist::CopperList<P>>>> {
773        match self.current_idx {
774            Some(idx) => Ok(Some(self.copperlist_at(idx)?.0)),
775            None => Ok(None),
776        }
777    }
778
779    /// Access a copperlist by absolute index in the log (cloned).
780    pub fn cl_at(&mut self, idx: usize) -> CuResult<Option<Arc<crate::copperlist::CopperList<P>>>> {
781        if idx >= self.total_entries {
782            return Ok(None);
783        }
784        Ok(Some(self.copperlist_at(idx)?.0))
785    }
786
787    /// Total number of copperlists indexed in this session.
788    pub fn total_entries(&self) -> usize {
789        self.total_entries
790    }
791
792    /// The nearest keyframe (<= target CL), if any.
793    pub fn nearest_keyframe_culistid(&self, target_culistid: u64) -> Option<u64> {
794        self.nearest_keyframe(target_culistid).map(|kf| kf.culistid)
795    }
796
797    /// Whether the log contains an exact keyframe for this copperlist id.
798    pub fn is_keyframe_culistid(&self, target_culistid: u64) -> bool {
799        self.keyframes
800            .iter()
801            .any(|kf| kf.culistid == target_culistid)
802    }
803
804    /// Returns section-cache statistics for this session.
805    pub fn section_cache_stats(&self) -> SectionCacheStats {
806        SectionCacheStats {
807            cap: self.cache_cap,
808            entries: self.cache.len(),
809            hits: self.cache_hits,
810            misses: self.cache_misses,
811            evictions: self.cache_evictions,
812        }
813    }
814
815    /// Current absolute cursor index, if initialized.
816    pub fn current_index(&self) -> Option<usize> {
817        self.current_idx
818    }
819
820    /// Borrow the underlying application for inspection (e.g., task state asserts).
821    pub fn with_app<R>(&mut self, f: impl FnOnce(&mut App) -> R) -> R {
822        f(&mut self.app)
823    }
824}
825
826impl<App, P, CB, TF, S, L> CuDebugSession<App, P, CB, TF, S, L>
827where
828    App: CuSimApplication<S, L> + ReflectTaskIntrospection,
829    L: UnifiedLogWrite<S> + 'static,
830    S: SectionStorage,
831    P: CopperListTuple,
832    CB: for<'a> Fn(
833        &'a crate::copperlist::CopperList<P>,
834        RobotClock,
835        RobotClockMock,
836    ) -> Box<dyn for<'z> FnMut(App::Step<'z>) -> SimOverride + 'a>,
837    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime> + Clone,
838{
839    /// Returns a reflected view of the current task instance by task id.
840    pub fn reflected_task(&self, task_id: &str) -> CuResult<&dyn crate::reflect::Reflect> {
841        self.app
842            .reflect_task(task_id)
843            .ok_or_else(|| CuError::from(format!("Task '{task_id}' was not found.")))
844    }
845
846    /// Mutable reflected task view by task id.
847    pub fn reflected_task_mut(
848        &mut self,
849        task_id: &str,
850    ) -> CuResult<&mut dyn crate::reflect::Reflect> {
851        self.app
852            .reflect_task_mut(task_id)
853            .ok_or_else(|| CuError::from(format!("Task '{task_id}' was not found.")))
854    }
855
856    /// Borrows the current typed debug-state view for one task.
857    pub fn with_debug_state<R>(
858        &self,
859        task_id: &str,
860        f: impl FnOnce(&dyn crate::reflect::Reflect) -> R,
861    ) -> CuResult<R> {
862        self.app
863            .with_debug_state(task_id, f)
864            .ok_or_else(|| CuError::from(format!("Task '{task_id}' was not found.")))
865    }
866
867    /// Dumps the reflected runtime state of one task.
868    pub fn dump_reflected_task(&self, task_id: &str) -> CuResult<String> {
869        let task = self.reflected_task(task_id)?;
870        #[cfg(not(feature = "reflect"))]
871        {
872            let _ = task;
873            Err(CuError::from(
874                "Task introspection is disabled. Rebuild with the `reflect` feature.",
875            ))
876        }
877
878        #[cfg(feature = "reflect")]
879        {
880            Ok(format!("{task:#?}"))
881        }
882    }
883
884    /// Dumps reflected schemas registered by this application.
885    pub fn dump_reflected_task_schemas(&self) -> String {
886        #[cfg(feature = "reflect")]
887        let mut registry = TypeRegistry::default();
888        #[cfg(not(feature = "reflect"))]
889        let mut registry = TypeRegistry;
890        <App as ReflectTaskIntrospection>::register_reflect_types(&mut registry);
891        dump_type_registry_schema(&registry)
892    }
893}
894/// Decode all copperlists contained in a single unified-log section.
895#[allow(clippy::type_complexity)]
896pub(crate) fn decode_copperlists<
897    P: CopperListTuple,
898    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
899>(
900    section: &[u8],
901    time_of: &TF,
902) -> CuResult<(
903    Vec<Arc<crate::copperlist::CopperList<P>>>,
904    Vec<Option<CuTime>>,
905)> {
906    let mut cursor = std::io::Cursor::new(section);
907    let mut entries = Vec::new();
908    let mut timestamps = Vec::new();
909    loop {
910        match decode_from_std_read::<crate::copperlist::CopperList<P>, _, _>(
911            &mut cursor,
912            standard(),
913        ) {
914            Ok(cl) => {
915                timestamps.push(time_of(&cl));
916                entries.push(Arc::new(cl));
917            }
918            Err(DecodeError::UnexpectedEnd { .. }) => break,
919            Err(DecodeError::Io { inner, .. }) if inner.kind() == io::ErrorKind::UnexpectedEof => {
920                break;
921            }
922            Err(e) => {
923                return Err(CuError::new_with_cause(
924                    "Failed to decode CopperList section",
925                    e,
926                ));
927            }
928        }
929    }
930    Ok((entries, timestamps))
931}
932
933/// Scan a copperlist section for metadata only.
934#[allow(clippy::type_complexity)]
935fn scan_copperlist_section<
936    P: CopperListTuple,
937    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
938>(
939    section: &[u8],
940    time_of: &TF,
941) -> CuResult<(usize, u64, u64, Option<CuTime>, Option<CuTime>)> {
942    let mut cursor = std::io::Cursor::new(section);
943    let mut count = 0usize;
944    let mut first_id = None;
945    let mut last_id = None;
946    let mut first_ts = None;
947    let mut last_ts = None;
948    loop {
949        match decode_from_std_read::<crate::copperlist::CopperList<P>, _, _>(
950            &mut cursor,
951            standard(),
952        ) {
953            Ok(cl) => {
954                let ts = time_of(&cl);
955                if ts.is_none() {
956                    #[cfg(feature = "std")]
957                    eprintln!(
958                        "CuDebug index warning: missing timestamp on culistid {}; time-based seek may be less accurate",
959                        cl.id
960                    );
961                }
962                if first_id.is_none() {
963                    first_id = Some(cl.id);
964                    first_ts = ts;
965                }
966                // Recover first_ts if the first entry lacked a timestamp but a later one has it.
967                if first_ts.is_none() {
968                    first_ts = ts;
969                }
970                last_id = Some(cl.id);
971                last_ts = ts.or(last_ts);
972                count += 1;
973            }
974            Err(DecodeError::UnexpectedEnd { .. }) => break,
975            Err(DecodeError::Io { inner, .. }) if inner.kind() == io::ErrorKind::UnexpectedEof => {
976                break;
977            }
978            Err(e) => {
979                return Err(CuError::new_with_cause(
980                    "Failed to scan copperlist section",
981                    e,
982                ));
983            }
984        }
985    }
986    let first_id = first_id.ok_or_else(|| CuError::from("Empty copperlist section"))?;
987    let last_id = last_id.unwrap_or(first_id);
988    Ok((count, first_id, last_id, first_ts, last_ts))
989}
990
991/// Build a reusable read-only unified logger for this session.
992pub(crate) fn build_read_logger(log_base: &Path) -> CuResult<UnifiedLoggerRead> {
993    let logger = UnifiedLoggerBuilder::new()
994        .file_base_name(log_base)
995        .build()
996        .map_err(|e| CuError::new_with_cause("Failed to open unified log", e))?;
997    let UnifiedLogger::Read(dl) = logger else {
998        return Err(CuError::from("Expected read-only unified logger"));
999    };
1000    Ok(dl)
1001}
1002
1003/// Read a specific section at a given position from disk using an existing handle.
1004pub(crate) fn read_section_at(
1005    log_reader: &mut UnifiedLoggerRead,
1006    pos: LogPosition,
1007) -> CuResult<(SectionHeader, Vec<u8>)> {
1008    log_reader.seek(pos)?;
1009    log_reader.raw_read_section()
1010}
1011
1012/// Build a section-level index in one pass (copperlists + keyframes).
1013pub(crate) fn index_log<P, TF>(
1014    log_base: &Path,
1015    time_of: &TF,
1016) -> CuResult<(Vec<SectionIndexEntry>, Vec<KeyFrame>, usize)>
1017where
1018    P: CopperListTuple,
1019    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
1020{
1021    index_log_with_progress(log_base, time_of, |_| {})
1022}
1023
1024fn index_log_with_progress<P, TF, PF>(
1025    log_base: &Path,
1026    time_of: &TF,
1027    mut progress: PF,
1028) -> CuResult<(Vec<SectionIndexEntry>, Vec<KeyFrame>, usize)>
1029where
1030    P: CopperListTuple,
1031    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
1032    PF: FnMut(LogIndexProgress),
1033{
1034    let sizing_logger = UnifiedLoggerBuilder::new()
1035        .file_base_name(log_base)
1036        .build()
1037        .map_err(|e| CuError::new_with_cause("Failed to open unified log", e))?;
1038    let UnifiedLogger::Read(mut sizing_reader) = sizing_logger else {
1039        return Err(CuError::from("Expected read-only unified logger"));
1040    };
1041    let mut total_bytes = 0u64;
1042    loop {
1043        let header = sizing_reader.raw_skip_section()?;
1044        if header.entry_type == UnifiedLogType::LastEntry {
1045            break;
1046        }
1047        total_bytes = total_bytes.saturating_add(header.used as u64);
1048    }
1049
1050    let logger = UnifiedLoggerBuilder::new()
1051        .file_base_name(log_base)
1052        .build()
1053        .map_err(|e| CuError::new_with_cause("Failed to open unified log", e))?;
1054    let UnifiedLogger::Read(mut dl) = logger else {
1055        return Err(CuError::from("Expected read-only unified logger"));
1056    };
1057
1058    let mut sections = Vec::new();
1059    let mut keyframes = Vec::new();
1060    let mut total_entries = 0usize;
1061    let mut scanned_bytes = 0u64;
1062    progress(LogIndexProgress {
1063        scanned_bytes,
1064        total_bytes,
1065        indexed_entries: total_entries,
1066    });
1067
1068    loop {
1069        let pos = dl.position();
1070        let (header, data) = dl.raw_read_section()?;
1071        if header.entry_type == UnifiedLogType::LastEntry {
1072            break;
1073        }
1074
1075        match header.entry_type {
1076            UnifiedLogType::CopperList => {
1077                let (len, first_id, last_id, first_ts, last_ts) =
1078                    scan_copperlist_section::<P, _>(&data, time_of)?;
1079                if len > 0 {
1080                    sections.push(SectionIndexEntry {
1081                        pos,
1082                        start_idx: total_entries,
1083                        len,
1084                        first_id,
1085                        last_id,
1086                        first_ts,
1087                        last_ts,
1088                    });
1089                    total_entries += len;
1090                }
1091            }
1092            UnifiedLogType::FrozenTasks => {
1093                // Read all keyframes in this section
1094                let mut cursor = std::io::Cursor::new(&data);
1095                loop {
1096                    match decode_from_std_read::<KeyFrame, _, _>(&mut cursor, standard()) {
1097                        Ok(kf) => keyframes.push(kf),
1098                        Err(DecodeError::UnexpectedEnd { .. }) => break,
1099                        Err(DecodeError::Io { inner, .. })
1100                            if inner.kind() == io::ErrorKind::UnexpectedEof =>
1101                        {
1102                            break;
1103                        }
1104                        Err(e) => {
1105                            return Err(CuError::new_with_cause(
1106                                "Failed to decode keyframe section",
1107                                e,
1108                            ));
1109                        }
1110                    }
1111                }
1112            }
1113            _ => {
1114                // ignore other sections
1115            }
1116        }
1117        scanned_bytes = scanned_bytes.saturating_add(header.used as u64);
1118        progress(LogIndexProgress {
1119            scanned_bytes,
1120            total_bytes,
1121            indexed_entries: total_entries,
1122        });
1123    }
1124
1125    Ok((sections, keyframes, total_entries))
1126}
1127
1128fn nearest_replay_keyframe(keyframes: &[KeyFrame], target_culistid: u64) -> Option<KeyFrame> {
1129    keyframes
1130        .iter()
1131        .filter(|kf| kf.culistid <= target_culistid)
1132        .max_by_key(|kf| kf.culistid)
1133        .cloned()
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138    use super::*;
1139
1140    fn keyframe(culistid: u64) -> KeyFrame {
1141        KeyFrame {
1142            culistid,
1143            timestamp: CuTime::from_nanos(culistid),
1144            serialized_tasks: Vec::new(),
1145        }
1146    }
1147
1148    #[test]
1149    fn replay_keyframe_selects_nearest_keyframe_at_or_before_target() {
1150        let keyframes = [keyframe(0), keyframe(100), keyframe(500)];
1151
1152        let keyframe = nearest_replay_keyframe(&keyframes, 533).expect("replay keyframe");
1153
1154        assert_eq!(keyframe.culistid, 500);
1155    }
1156
1157    #[test]
1158    fn replay_keyframe_uses_nearest_available_nonzero_keyframe() {
1159        let keyframes = [keyframe(100), keyframe(500), keyframe(900)];
1160
1161        let keyframe = nearest_replay_keyframe(&keyframes, 533).expect("replay keyframe");
1162
1163        assert_eq!(keyframe.culistid, 500);
1164        assert!(nearest_replay_keyframe(&keyframes, 99).is_none());
1165    }
1166}