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