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;
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/// Metadata for one copperlist section (no payload kept).
51#[derive(Debug, Clone)]
52pub(crate) struct SectionIndexEntry {
53    pos: LogPosition,
54    start_idx: usize,
55    len: usize,
56    first_id: u64,
57    last_id: u64,
58    first_ts: Option<CuTime>,
59    last_ts: Option<CuTime>,
60}
61
62/// Cached copperlists for one section.
63#[derive(Debug, Clone)]
64struct CachedSection<P: CopperListTuple> {
65    entries: Vec<Arc<crate::copperlist::CopperList<P>>>,
66    timestamps: Vec<Option<CuTime>>,
67}
68
69/// A reusable debugging session that can time-travel within a recorded log.
70///
71/// `CB` builds a simulation callback for a specific copperlist entry. This keeps the
72/// API generic: the caller can replay recorded outputs, poke clocks, or inject extra
73/// assertions inside the callback. `TF` extracts a timestamp from a copperlist to
74/// support time-based seeking.
75const DEFAULT_SECTION_CACHE_CAP: usize = 8;
76pub struct CuDebugSession<App, P, CB, TF, S, L>
77where
78    P: CopperListTuple,
79    S: SectionStorage,
80    L: UnifiedLogWrite<S> + 'static,
81{
82    app: App,
83    robot_clock: RobotClock,
84    clock_mock: RobotClockMock,
85    log_reader: UnifiedLoggerRead,
86    sections: Vec<SectionIndexEntry>,
87    total_entries: usize,
88    keyframes: Vec<KeyFrame>,
89    started: bool,
90    current_idx: Option<usize>,
91    last_keyframe: Option<u64>,
92    build_callback: CB,
93    time_of: TF,
94    // Tiny LRU cache of decoded sections
95    cache: HashMap<usize, CachedSection<P>>,
96    cache_order: VecDeque<usize>,
97    cache_cap: usize,
98    cache_hits: u64,
99    cache_misses: u64,
100    cache_evictions: u64,
101    phantom: PhantomData<(S, L)>,
102}
103
104impl<App, P, CB, TF, S, L> CuDebugSession<App, P, CB, TF, S, L>
105where
106    App: CuSimApplication<S, L>,
107    L: UnifiedLogWrite<S> + 'static,
108    S: SectionStorage,
109    P: CopperListTuple,
110    CB: for<'a> Fn(
111        &'a crate::copperlist::CopperList<P>,
112        RobotClock,
113    ) -> Box<dyn for<'z> FnMut(App::Step<'z>) -> SimOverride + 'a>,
114    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime> + Clone,
115{
116    /// Build a session directly from a unified log on disk (streaming index, no bulk load).
117    pub fn from_log(
118        log_base: &Path,
119        app: App,
120        robot_clock: RobotClock,
121        clock_mock: RobotClockMock,
122        build_callback: CB,
123        time_of: TF,
124    ) -> CuResult<Self> {
125        let (sections, keyframes, total_entries) = index_log::<P, _>(log_base, &time_of)?;
126        let log_reader = build_read_logger(log_base)?;
127        Ok(Self::new(
128            log_reader,
129            app,
130            robot_clock,
131            clock_mock,
132            sections,
133            total_entries,
134            keyframes,
135            build_callback,
136            time_of,
137        ))
138    }
139
140    /// Build a session directly from a log, with an explicit cache size.
141    pub fn from_log_with_cache_cap(
142        log_base: &Path,
143        app: App,
144        robot_clock: RobotClock,
145        clock_mock: RobotClockMock,
146        build_callback: CB,
147        time_of: TF,
148        cache_cap: usize,
149    ) -> CuResult<Self> {
150        let (sections, keyframes, total_entries) = index_log::<P, _>(log_base, &time_of)?;
151        let log_reader = build_read_logger(log_base)?;
152        Ok(Self::new_with_cache_cap(
153            log_reader,
154            app,
155            robot_clock,
156            clock_mock,
157            sections,
158            total_entries,
159            keyframes,
160            build_callback,
161            time_of,
162            cache_cap,
163        ))
164    }
165
166    /// Create a new session from prebuilt indices.
167    #[allow(clippy::too_many_arguments)]
168    pub(crate) fn new(
169        log_reader: UnifiedLoggerRead,
170        app: App,
171        robot_clock: RobotClock,
172        clock_mock: RobotClockMock,
173        sections: Vec<SectionIndexEntry>,
174        total_entries: usize,
175        keyframes: Vec<KeyFrame>,
176        build_callback: CB,
177        time_of: TF,
178    ) -> Self {
179        Self::new_with_cache_cap(
180            log_reader,
181            app,
182            robot_clock,
183            clock_mock,
184            sections,
185            total_entries,
186            keyframes,
187            build_callback,
188            time_of,
189            DEFAULT_SECTION_CACHE_CAP,
190        )
191    }
192
193    #[allow(clippy::too_many_arguments)]
194    pub(crate) fn new_with_cache_cap(
195        log_reader: UnifiedLoggerRead,
196        app: App,
197        robot_clock: RobotClock,
198        clock_mock: RobotClockMock,
199        sections: Vec<SectionIndexEntry>,
200        total_entries: usize,
201        keyframes: Vec<KeyFrame>,
202        build_callback: CB,
203        time_of: TF,
204        cache_cap: usize,
205    ) -> Self {
206        Self {
207            app,
208            robot_clock,
209            clock_mock,
210            log_reader,
211            sections,
212            total_entries,
213            keyframes,
214            started: false,
215            current_idx: None,
216            last_keyframe: None,
217            build_callback,
218            time_of,
219            cache: HashMap::new(),
220            cache_order: VecDeque::new(),
221            cache_cap: cache_cap.max(1),
222            cache_hits: 0,
223            cache_misses: 0,
224            cache_evictions: 0,
225            phantom: PhantomData,
226        }
227    }
228
229    fn ensure_started(&mut self) -> CuResult<()> {
230        if self.started {
231            return Ok(());
232        }
233        let mut noop = |_step: App::Step<'_>| SimOverride::ExecuteByRuntime;
234        self.app.start_all_tasks(&mut noop)?;
235        self.started = true;
236        Ok(())
237    }
238
239    fn nearest_keyframe(&self, target_culistid: u64) -> Option<KeyFrame> {
240        self.keyframes
241            .iter()
242            .filter(|kf| kf.culistid <= target_culistid)
243            .max_by_key(|kf| kf.culistid)
244            .cloned()
245    }
246
247    fn restore_keyframe(&mut self, kf: &KeyFrame) -> CuResult<()> {
248        self.app.restore_keyframe(kf)?;
249        self.clock_mock.set_value(kf.timestamp.as_nanos());
250        self.last_keyframe = Some(kf.culistid);
251        Ok(())
252    }
253
254    fn find_section_for_index(&self, idx: usize) -> Option<usize> {
255        self.sections
256            .binary_search_by(|s| {
257                if idx < s.start_idx {
258                    std::cmp::Ordering::Greater
259                } else if idx >= s.start_idx + s.len {
260                    std::cmp::Ordering::Less
261                } else {
262                    std::cmp::Ordering::Equal
263                }
264            })
265            .ok()
266    }
267
268    fn find_section_for_culistid(&self, culistid: u64) -> Option<usize> {
269        self.sections
270            .binary_search_by(|s| {
271                if culistid < s.first_id {
272                    std::cmp::Ordering::Greater
273                } else if culistid > s.last_id {
274                    std::cmp::Ordering::Less
275                } else {
276                    std::cmp::Ordering::Equal
277                }
278            })
279            .ok()
280    }
281
282    /// Lower-bound lookup: return the first section whose `first_ts >= ts`.
283    /// If `ts` is earlier than the first section, return the first section.
284    /// Return `None` only when `ts` is beyond the last section's range.
285    fn find_section_for_time(&self, ts: CuTime) -> Option<usize> {
286        if self.sections.is_empty() {
287            return None;
288        }
289
290        // Fast path when all sections carry timestamps.
291        if self.sections.iter().all(|s| s.first_ts.is_some()) {
292            let idx = match self.sections.binary_search_by(|s| {
293                let a = s.first_ts.unwrap();
294                if a < ts {
295                    std::cmp::Ordering::Less
296                } else if a > ts {
297                    std::cmp::Ordering::Greater
298                } else {
299                    std::cmp::Ordering::Equal
300                }
301            }) {
302                Ok(i) => i,
303                Err(i) => i, // insertion point = first first_ts >= ts
304            };
305
306            if idx < self.sections.len() {
307                return Some(idx);
308            }
309
310            // ts is after the last section start; allow selecting the last section
311            // if the timestamp still lies inside its recorded range.
312            let last = self.sections.last().unwrap();
313            if let Some(last_ts) = last.last_ts
314                && ts <= last_ts
315            {
316                return Some(self.sections.len() - 1);
317            }
318            return None;
319        }
320
321        // Fallback for sections missing timestamps: choose first window that contains ts;
322        // if ts is earlier than the first timestamped section, pick that section; otherwise
323        // only return None when ts is past the last known range.
324        if let Some(first_ts) = self.sections.first().and_then(|s| s.first_ts)
325            && ts <= first_ts
326        {
327            return Some(0);
328        }
329
330        if let Some(idx) = self
331            .sections
332            .iter()
333            .position(|s| match (s.first_ts, s.last_ts) {
334                (Some(a), Some(b)) => a <= ts && ts <= b,
335                (Some(a), None) => a <= ts,
336                _ => false,
337            })
338        {
339            return Some(idx);
340        }
341
342        let last = self.sections.last().unwrap();
343        match last.last_ts {
344            Some(b) if ts <= b => Some(self.sections.len() - 1),
345            _ => None,
346        }
347    }
348
349    fn touch_cache(&mut self, key: usize) {
350        if let Some(pos) = self.cache_order.iter().position(|k| *k == key) {
351            self.cache_order.remove(pos);
352        }
353        self.cache_order.push_back(key);
354        while self.cache_order.len() > self.cache_cap {
355            if let Some(old) = self.cache_order.pop_front()
356                && self.cache.remove(&old).is_some()
357            {
358                self.cache_evictions = self.cache_evictions.saturating_add(1);
359            }
360        }
361    }
362
363    fn load_section(&mut self, section_idx: usize) -> CuResult<&CachedSection<P>> {
364        if self.cache.contains_key(&section_idx) {
365            self.cache_hits = self.cache_hits.saturating_add(1);
366            self.touch_cache(section_idx);
367            // SAFETY: key exists, unwrap ok.
368            return Ok(self.cache.get(&section_idx).unwrap());
369        }
370        self.cache_misses = self.cache_misses.saturating_add(1);
371
372        let entry = &self.sections[section_idx];
373        let (header, data) = read_section_at(&mut self.log_reader, entry.pos)?;
374        if header.entry_type != UnifiedLogType::CopperList {
375            return Err(CuError::from(
376                "Section type mismatch while loading copperlists",
377            ));
378        }
379
380        let (entries, timestamps) = decode_copperlists::<P, _>(&data, &self.time_of)?;
381        let cached = CachedSection {
382            entries,
383            timestamps,
384        };
385        self.cache.insert(section_idx, cached);
386        self.touch_cache(section_idx);
387        Ok(self.cache.get(&section_idx).unwrap())
388    }
389
390    fn copperlist_at(
391        &mut self,
392        idx: usize,
393    ) -> CuResult<(Arc<crate::copperlist::CopperList<P>>, Option<CuTime>)> {
394        let section_idx = self
395            .find_section_for_index(idx)
396            .ok_or_else(|| CuError::from("Index outside copperlist log"))?;
397        let start_idx = self.sections[section_idx].start_idx;
398        let section = self.load_section(section_idx)?;
399        let local = idx - start_idx;
400        let cl = section
401            .entries
402            .get(local)
403            .ok_or_else(|| CuError::from("Corrupt section index vs cache"))?
404            .clone();
405        let ts = section.timestamps.get(local).copied().unwrap_or(None);
406        Ok((cl, ts))
407    }
408
409    fn index_for_culistid(&mut self, culistid: u64) -> CuResult<usize> {
410        let section_idx = self
411            .find_section_for_culistid(culistid)
412            .ok_or_else(|| CuError::from("Requested culistid not present in log"))?;
413        let start_idx = self.sections[section_idx].start_idx;
414        let section = self.load_section(section_idx)?;
415        for (offset, cl) in section.entries.iter().enumerate() {
416            if cl.id == culistid {
417                return Ok(start_idx + offset);
418            }
419        }
420        Err(CuError::from("culistid not found inside indexed section"))
421    }
422
423    fn index_for_time(&mut self, ts: CuTime) -> CuResult<usize> {
424        let section_idx = self
425            .find_section_for_time(ts)
426            .ok_or_else(|| CuError::from("No copperlist at or after requested timestamp"))?;
427        let start_idx = self.sections[section_idx].start_idx;
428        let section = self.load_section(section_idx)?;
429        let idx = start_idx;
430        for (i, maybe) in section.timestamps.iter().enumerate() {
431            if matches!(maybe, Some(t) if *t >= ts) {
432                return Ok(idx + i);
433            }
434        }
435        Err(CuError::from("Timestamp not found within section"))
436    }
437
438    fn replay_range(&mut self, start: usize, end: usize) -> CuResult<usize> {
439        let mut replayed = 0usize;
440        for idx in start..=end {
441            let (entry, ts) = self.copperlist_at(idx)?;
442            if let Some(ts) = ts {
443                self.clock_mock.set_value(ts.as_nanos());
444            }
445            let clock_for_cb = self.robot_clock.clone();
446            let mut cb = (self.build_callback)(entry.as_ref(), clock_for_cb);
447            self.app.run_one_iteration(&mut cb)?;
448            replayed += 1;
449            self.current_idx = Some(idx);
450        }
451        Ok(replayed)
452    }
453
454    fn goto_index(&mut self, target_idx: usize) -> CuResult<JumpOutcome> {
455        self.ensure_started()?;
456        if target_idx >= self.total_entries {
457            return Err(CuError::from("Target index outside log"));
458        }
459        let (target_cl, _) = self.copperlist_at(target_idx)?;
460        let target_culistid = target_cl.id;
461
462        let keyframe_used: Option<u64>;
463        let replay_start: usize;
464
465        // Fast path: forward stepping from current state.
466        if let Some(current) = self.current_idx {
467            if target_idx == current {
468                return Ok(JumpOutcome {
469                    culistid: target_culistid,
470                    keyframe_culistid: self.last_keyframe,
471                    replayed: 0,
472                });
473            }
474
475            if target_idx >= current {
476                replay_start = current + 1;
477                keyframe_used = self.last_keyframe;
478            } else {
479                // Need to rewind to nearest keyframe
480                let Some(kf) = self.nearest_keyframe(target_culistid) else {
481                    return Err(CuError::from("No keyframe available to rewind"));
482                };
483                self.restore_keyframe(&kf)?;
484                keyframe_used = Some(kf.culistid);
485                replay_start = self.index_for_culistid(kf.culistid)?;
486            }
487        } else {
488            // First jump: align to nearest keyframe
489            let Some(kf) = self.nearest_keyframe(target_culistid) else {
490                return Err(CuError::from("No keyframe found in log"));
491            };
492            self.restore_keyframe(&kf)?;
493            keyframe_used = Some(kf.culistid);
494            replay_start = self.index_for_culistid(kf.culistid)?;
495        }
496
497        if replay_start > target_idx {
498            return Err(CuError::from(
499                "Replay start past target index; log ordering issue",
500            ));
501        }
502
503        let replayed = self.replay_range(replay_start, target_idx)?;
504
505        Ok(JumpOutcome {
506            culistid: target_culistid,
507            keyframe_culistid: keyframe_used,
508            replayed,
509        })
510    }
511
512    /// Jump to a copperlist by id.
513    pub fn goto_cl(&mut self, culistid: u64) -> CuResult<JumpOutcome> {
514        let idx = self.index_for_culistid(culistid)?;
515        self.goto_index(idx)
516    }
517
518    /// Jump to the first copperlist at or after a timestamp.
519    pub fn goto_time(&mut self, ts: CuTime) -> CuResult<JumpOutcome> {
520        let idx = self.index_for_time(ts)?;
521        self.goto_index(idx)
522    }
523
524    /// Step relative to the current cursor. Negative values rewind via keyframe.
525    pub fn step(&mut self, delta: i32) -> CuResult<JumpOutcome> {
526        let current =
527            self.current_idx
528                .ok_or_else(|| CuError::from("Cannot step before any jump"))? as i32;
529        let target = current + delta;
530        if target < 0 || target as usize >= self.total_entries {
531            return Err(CuError::from("Step would move outside log bounds"));
532        }
533        self.goto_index(target as usize)
534    }
535
536    /// Access the copperlist at the current cursor, if any (cloned).
537    pub fn current_cl(&mut self) -> CuResult<Option<Arc<crate::copperlist::CopperList<P>>>> {
538        match self.current_idx {
539            Some(idx) => Ok(Some(self.copperlist_at(idx)?.0)),
540            None => Ok(None),
541        }
542    }
543
544    /// Access a copperlist by absolute index in the log (cloned).
545    pub fn cl_at(&mut self, idx: usize) -> CuResult<Option<Arc<crate::copperlist::CopperList<P>>>> {
546        if idx >= self.total_entries {
547            return Ok(None);
548        }
549        Ok(Some(self.copperlist_at(idx)?.0))
550    }
551
552    /// Total number of copperlists indexed in this session.
553    pub fn total_entries(&self) -> usize {
554        self.total_entries
555    }
556
557    /// The nearest keyframe (<= target CL), if any.
558    pub fn nearest_keyframe_culistid(&self, target_culistid: u64) -> Option<u64> {
559        self.nearest_keyframe(target_culistid).map(|kf| kf.culistid)
560    }
561
562    /// Returns section-cache statistics for this session.
563    pub fn section_cache_stats(&self) -> SectionCacheStats {
564        SectionCacheStats {
565            cap: self.cache_cap,
566            entries: self.cache.len(),
567            hits: self.cache_hits,
568            misses: self.cache_misses,
569            evictions: self.cache_evictions,
570        }
571    }
572
573    /// Current absolute cursor index, if initialized.
574    pub fn current_index(&self) -> Option<usize> {
575        self.current_idx
576    }
577
578    /// Borrow the underlying application for inspection (e.g., task state asserts).
579    pub fn with_app<R>(&mut self, f: impl FnOnce(&mut App) -> R) -> R {
580        f(&mut self.app)
581    }
582}
583
584impl<App, P, CB, TF, S, L> CuDebugSession<App, P, CB, TF, S, L>
585where
586    App: CuSimApplication<S, L> + ReflectTaskIntrospection,
587    L: UnifiedLogWrite<S> + 'static,
588    S: SectionStorage,
589    P: CopperListTuple,
590    CB: for<'a> Fn(
591        &'a crate::copperlist::CopperList<P>,
592        RobotClock,
593    ) -> Box<dyn for<'z> FnMut(App::Step<'z>) -> SimOverride + 'a>,
594    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime> + Clone,
595{
596    /// Returns a reflected view of the current task instance by task id.
597    pub fn reflected_task(&self, task_id: &str) -> CuResult<&dyn crate::reflect::Reflect> {
598        self.app
599            .reflect_task(task_id)
600            .ok_or_else(|| CuError::from(format!("Task '{task_id}' was not found.")))
601    }
602
603    /// Mutable reflected task view by task id.
604    pub fn reflected_task_mut(
605        &mut self,
606        task_id: &str,
607    ) -> CuResult<&mut dyn crate::reflect::Reflect> {
608        self.app
609            .reflect_task_mut(task_id)
610            .ok_or_else(|| CuError::from(format!("Task '{task_id}' was not found.")))
611    }
612
613    /// Dumps the reflected runtime state of one task.
614    pub fn dump_reflected_task(&self, task_id: &str) -> CuResult<String> {
615        let task = self.reflected_task(task_id)?;
616        #[cfg(not(feature = "reflect"))]
617        {
618            let _ = task;
619            Err(CuError::from(
620                "Task introspection is disabled. Rebuild with the `reflect` feature.",
621            ))
622        }
623
624        #[cfg(feature = "reflect")]
625        {
626            Ok(format!("{task:#?}"))
627        }
628    }
629
630    /// Dumps reflected schemas registered by this application.
631    pub fn dump_reflected_task_schemas(&self) -> String {
632        #[cfg(feature = "reflect")]
633        let mut registry = TypeRegistry::default();
634        #[cfg(not(feature = "reflect"))]
635        let mut registry = TypeRegistry;
636        <App as ReflectTaskIntrospection>::register_reflect_types(&mut registry);
637        dump_type_registry_schema(&registry)
638    }
639}
640/// Decode all copperlists contained in a single unified-log section.
641#[allow(clippy::type_complexity)]
642fn decode_copperlists<
643    P: CopperListTuple,
644    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
645>(
646    section: &[u8],
647    time_of: &TF,
648) -> CuResult<(
649    Vec<Arc<crate::copperlist::CopperList<P>>>,
650    Vec<Option<CuTime>>,
651)> {
652    let mut cursor = std::io::Cursor::new(section);
653    let mut entries = Vec::new();
654    let mut timestamps = Vec::new();
655    loop {
656        match decode_from_std_read::<crate::copperlist::CopperList<P>, _, _>(
657            &mut cursor,
658            standard(),
659        ) {
660            Ok(cl) => {
661                timestamps.push(time_of(&cl));
662                entries.push(Arc::new(cl));
663            }
664            Err(DecodeError::UnexpectedEnd { .. }) => break,
665            Err(DecodeError::Io { inner, .. }) if inner.kind() == io::ErrorKind::UnexpectedEof => {
666                break;
667            }
668            Err(e) => {
669                return Err(CuError::new_with_cause(
670                    "Failed to decode CopperList section",
671                    e,
672                ));
673            }
674        }
675    }
676    Ok((entries, timestamps))
677}
678
679/// Scan a copperlist section for metadata only.
680#[allow(clippy::type_complexity)]
681fn scan_copperlist_section<
682    P: CopperListTuple,
683    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
684>(
685    section: &[u8],
686    time_of: &TF,
687) -> CuResult<(usize, u64, u64, Option<CuTime>, Option<CuTime>)> {
688    let mut cursor = std::io::Cursor::new(section);
689    let mut count = 0usize;
690    let mut first_id = None;
691    let mut last_id = None;
692    let mut first_ts = None;
693    let mut last_ts = None;
694    loop {
695        match decode_from_std_read::<crate::copperlist::CopperList<P>, _, _>(
696            &mut cursor,
697            standard(),
698        ) {
699            Ok(cl) => {
700                let ts = time_of(&cl);
701                if ts.is_none() {
702                    #[cfg(feature = "std")]
703                    eprintln!(
704                        "CuDebug index warning: missing timestamp on culistid {}; time-based seek may be less accurate",
705                        cl.id
706                    );
707                }
708                if first_id.is_none() {
709                    first_id = Some(cl.id);
710                    first_ts = ts;
711                }
712                // Recover first_ts if the first entry lacked a timestamp but a later one has it.
713                if first_ts.is_none() {
714                    first_ts = ts;
715                }
716                last_id = Some(cl.id);
717                last_ts = ts.or(last_ts);
718                count += 1;
719            }
720            Err(DecodeError::UnexpectedEnd { .. }) => break,
721            Err(DecodeError::Io { inner, .. }) if inner.kind() == io::ErrorKind::UnexpectedEof => {
722                break;
723            }
724            Err(e) => {
725                return Err(CuError::new_with_cause(
726                    "Failed to scan copperlist section",
727                    e,
728                ));
729            }
730        }
731    }
732    let first_id = first_id.ok_or_else(|| CuError::from("Empty copperlist section"))?;
733    let last_id = last_id.unwrap_or(first_id);
734    Ok((count, first_id, last_id, first_ts, last_ts))
735}
736
737/// Build a reusable read-only unified logger for this session.
738fn build_read_logger(log_base: &Path) -> CuResult<UnifiedLoggerRead> {
739    let logger = UnifiedLoggerBuilder::new()
740        .file_base_name(log_base)
741        .build()
742        .map_err(|e| CuError::new_with_cause("Failed to open unified log", e))?;
743    let UnifiedLogger::Read(dl) = logger else {
744        return Err(CuError::from("Expected read-only unified logger"));
745    };
746    Ok(dl)
747}
748
749/// Read a specific section at a given position from disk using an existing handle.
750fn read_section_at(
751    log_reader: &mut UnifiedLoggerRead,
752    pos: LogPosition,
753) -> CuResult<(SectionHeader, Vec<u8>)> {
754    log_reader.seek(pos)?;
755    log_reader.raw_read_section()
756}
757
758/// Build a section-level index in one pass (copperlists + keyframes).
759fn index_log<P, TF>(
760    log_base: &Path,
761    time_of: &TF,
762) -> CuResult<(Vec<SectionIndexEntry>, Vec<KeyFrame>, usize)>
763where
764    P: CopperListTuple,
765    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
766{
767    let logger = UnifiedLoggerBuilder::new()
768        .file_base_name(log_base)
769        .build()
770        .map_err(|e| CuError::new_with_cause("Failed to open unified log", e))?;
771    let UnifiedLogger::Read(mut dl) = logger else {
772        return Err(CuError::from("Expected read-only unified logger"));
773    };
774
775    let mut sections = Vec::new();
776    let mut keyframes = Vec::new();
777    let mut total_entries = 0usize;
778
779    loop {
780        let pos = dl.position();
781        let (header, data) = dl.raw_read_section()?;
782        if header.entry_type == UnifiedLogType::LastEntry {
783            break;
784        }
785
786        match header.entry_type {
787            UnifiedLogType::CopperList => {
788                let (len, first_id, last_id, first_ts, last_ts) =
789                    scan_copperlist_section::<P, _>(&data, time_of)?;
790                if len == 0 {
791                    continue;
792                }
793                sections.push(SectionIndexEntry {
794                    pos,
795                    start_idx: total_entries,
796                    len,
797                    first_id,
798                    last_id,
799                    first_ts,
800                    last_ts,
801                });
802                total_entries += len;
803            }
804            UnifiedLogType::FrozenTasks => {
805                // Read all keyframes in this section
806                let mut cursor = std::io::Cursor::new(&data);
807                loop {
808                    match decode_from_std_read::<KeyFrame, _, _>(&mut cursor, standard()) {
809                        Ok(kf) => keyframes.push(kf),
810                        Err(DecodeError::UnexpectedEnd { .. }) => break,
811                        Err(DecodeError::Io { inner, .. })
812                            if inner.kind() == io::ErrorKind::UnexpectedEof =>
813                        {
814                            break;
815                        }
816                        Err(e) => {
817                            return Err(CuError::new_with_cause(
818                                "Failed to decode keyframe section",
819                                e,
820                            ));
821                        }
822                    }
823                }
824            }
825            _ => {
826                // ignore other sections
827            }
828        }
829    }
830
831    Ok((sections, keyframes, total_entries))
832}