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