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        let mut idx = start_idx;
416        for cl in &section.entries {
417            if cl.id == culistid {
418                return Ok(idx);
419            }
420            idx += 1;
421        }
422        Err(CuError::from("culistid not found inside indexed section"))
423    }
424
425    fn index_for_time(&mut self, ts: CuTime) -> CuResult<usize> {
426        let section_idx = self
427            .find_section_for_time(ts)
428            .ok_or_else(|| CuError::from("No copperlist at or after requested timestamp"))?;
429        let start_idx = self.sections[section_idx].start_idx;
430        let section = self.load_section(section_idx)?;
431        let idx = start_idx;
432        for (i, maybe) in section.timestamps.iter().enumerate() {
433            if matches!(maybe, Some(t) if *t >= ts) {
434                return Ok(idx + i);
435            }
436        }
437        Err(CuError::from("Timestamp not found within section"))
438    }
439
440    fn replay_range(&mut self, start: usize, end: usize) -> CuResult<usize> {
441        let mut replayed = 0usize;
442        for idx in start..=end {
443            let (entry, ts) = self.copperlist_at(idx)?;
444            if let Some(ts) = ts {
445                self.clock_mock.set_value(ts.as_nanos());
446            }
447            let clock_for_cb = self.robot_clock.clone();
448            let mut cb = (self.build_callback)(entry.as_ref(), clock_for_cb);
449            self.app.run_one_iteration(&mut cb)?;
450            replayed += 1;
451            self.current_idx = Some(idx);
452        }
453        Ok(replayed)
454    }
455
456    fn goto_index(&mut self, target_idx: usize) -> CuResult<JumpOutcome> {
457        self.ensure_started()?;
458        if target_idx >= self.total_entries {
459            return Err(CuError::from("Target index outside log"));
460        }
461        let (target_cl, _) = self.copperlist_at(target_idx)?;
462        let target_culistid = target_cl.id;
463
464        let keyframe_used: Option<u64>;
465        let replay_start: usize;
466
467        // Fast path: forward stepping from current state.
468        if let Some(current) = self.current_idx {
469            if target_idx == current {
470                return Ok(JumpOutcome {
471                    culistid: target_culistid,
472                    keyframe_culistid: self.last_keyframe,
473                    replayed: 0,
474                });
475            }
476
477            if target_idx >= current {
478                replay_start = current + 1;
479                keyframe_used = self.last_keyframe;
480            } else {
481                // Need to rewind to nearest keyframe
482                let Some(kf) = self.nearest_keyframe(target_culistid) else {
483                    return Err(CuError::from("No keyframe available to rewind"));
484                };
485                self.restore_keyframe(&kf)?;
486                keyframe_used = Some(kf.culistid);
487                replay_start = self.index_for_culistid(kf.culistid)?;
488            }
489        } else {
490            // First jump: align to nearest keyframe
491            let Some(kf) = self.nearest_keyframe(target_culistid) else {
492                return Err(CuError::from("No keyframe found in log"));
493            };
494            self.restore_keyframe(&kf)?;
495            keyframe_used = Some(kf.culistid);
496            replay_start = self.index_for_culistid(kf.culistid)?;
497        }
498
499        if replay_start > target_idx {
500            return Err(CuError::from(
501                "Replay start past target index; log ordering issue",
502            ));
503        }
504
505        let replayed = self.replay_range(replay_start, target_idx)?;
506
507        Ok(JumpOutcome {
508            culistid: target_culistid,
509            keyframe_culistid: keyframe_used,
510            replayed,
511        })
512    }
513
514    /// Jump to a copperlist by id.
515    pub fn goto_cl(&mut self, culistid: u64) -> CuResult<JumpOutcome> {
516        let idx = self.index_for_culistid(culistid)?;
517        self.goto_index(idx)
518    }
519
520    /// Jump to the first copperlist at or after a timestamp.
521    pub fn goto_time(&mut self, ts: CuTime) -> CuResult<JumpOutcome> {
522        let idx = self.index_for_time(ts)?;
523        self.goto_index(idx)
524    }
525
526    /// Step relative to the current cursor. Negative values rewind via keyframe.
527    pub fn step(&mut self, delta: i32) -> CuResult<JumpOutcome> {
528        let current =
529            self.current_idx
530                .ok_or_else(|| CuError::from("Cannot step before any jump"))? as i32;
531        let target = current + delta;
532        if target < 0 || target as usize >= self.total_entries {
533            return Err(CuError::from("Step would move outside log bounds"));
534        }
535        self.goto_index(target as usize)
536    }
537
538    /// Access the copperlist at the current cursor, if any (cloned).
539    pub fn current_cl(&mut self) -> CuResult<Option<Arc<crate::copperlist::CopperList<P>>>> {
540        match self.current_idx {
541            Some(idx) => Ok(Some(self.copperlist_at(idx)?.0)),
542            None => Ok(None),
543        }
544    }
545
546    /// Access a copperlist by absolute index in the log (cloned).
547    pub fn cl_at(&mut self, idx: usize) -> CuResult<Option<Arc<crate::copperlist::CopperList<P>>>> {
548        if idx >= self.total_entries {
549            return Ok(None);
550        }
551        Ok(Some(self.copperlist_at(idx)?.0))
552    }
553
554    /// Total number of copperlists indexed in this session.
555    pub fn total_entries(&self) -> usize {
556        self.total_entries
557    }
558
559    /// The nearest keyframe (<= target CL), if any.
560    pub fn nearest_keyframe_culistid(&self, target_culistid: u64) -> Option<u64> {
561        self.nearest_keyframe(target_culistid).map(|kf| kf.culistid)
562    }
563
564    /// Returns section-cache statistics for this session.
565    pub fn section_cache_stats(&self) -> SectionCacheStats {
566        SectionCacheStats {
567            cap: self.cache_cap,
568            entries: self.cache.len(),
569            hits: self.cache_hits,
570            misses: self.cache_misses,
571            evictions: self.cache_evictions,
572        }
573    }
574
575    /// Current absolute cursor index, if initialized.
576    pub fn current_index(&self) -> Option<usize> {
577        self.current_idx
578    }
579
580    /// Borrow the underlying application for inspection (e.g., task state asserts).
581    pub fn with_app<R>(&mut self, f: impl FnOnce(&mut App) -> R) -> R {
582        f(&mut self.app)
583    }
584}
585
586impl<App, P, CB, TF, S, L> CuDebugSession<App, P, CB, TF, S, L>
587where
588    App: CuSimApplication<S, L> + ReflectTaskIntrospection,
589    L: UnifiedLogWrite<S> + 'static,
590    S: SectionStorage,
591    P: CopperListTuple,
592    CB: for<'a> Fn(
593        &'a crate::copperlist::CopperList<P>,
594        RobotClock,
595    ) -> Box<dyn for<'z> FnMut(App::Step<'z>) -> SimOverride + 'a>,
596    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime> + Clone,
597{
598    /// Returns a reflected view of the current task instance by task id.
599    pub fn reflected_task(&self, task_id: &str) -> CuResult<&dyn crate::reflect::Reflect> {
600        self.app
601            .reflect_task(task_id)
602            .ok_or_else(|| CuError::from(format!("Task '{task_id}' was not found.")))
603    }
604
605    /// Mutable reflected task view by task id.
606    pub fn reflected_task_mut(
607        &mut self,
608        task_id: &str,
609    ) -> CuResult<&mut dyn crate::reflect::Reflect> {
610        self.app
611            .reflect_task_mut(task_id)
612            .ok_or_else(|| CuError::from(format!("Task '{task_id}' was not found.")))
613    }
614
615    /// Dumps the reflected runtime state of one task.
616    pub fn dump_reflected_task(&self, task_id: &str) -> CuResult<String> {
617        let task = self.reflected_task(task_id)?;
618        #[cfg(not(feature = "reflect"))]
619        {
620            let _ = task;
621            Err(CuError::from(
622                "Task introspection is disabled. Rebuild with the `reflect` feature.",
623            ))
624        }
625
626        #[cfg(feature = "reflect")]
627        {
628            Ok(format!("{task:#?}"))
629        }
630    }
631
632    /// Dumps reflected schemas registered by this application.
633    pub fn dump_reflected_task_schemas(&self) -> String {
634        #[cfg(feature = "reflect")]
635        let mut registry = TypeRegistry::default();
636        #[cfg(not(feature = "reflect"))]
637        let mut registry = TypeRegistry;
638        <App as ReflectTaskIntrospection>::register_reflect_types(&mut registry);
639        dump_type_registry_schema(&registry)
640    }
641}
642/// Decode all copperlists contained in a single unified-log section.
643#[allow(clippy::type_complexity)]
644fn decode_copperlists<
645    P: CopperListTuple,
646    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
647>(
648    section: &[u8],
649    time_of: &TF,
650) -> CuResult<(
651    Vec<Arc<crate::copperlist::CopperList<P>>>,
652    Vec<Option<CuTime>>,
653)> {
654    let mut cursor = std::io::Cursor::new(section);
655    let mut entries = Vec::new();
656    let mut timestamps = Vec::new();
657    loop {
658        match decode_from_std_read::<crate::copperlist::CopperList<P>, _, _>(
659            &mut cursor,
660            standard(),
661        ) {
662            Ok(cl) => {
663                timestamps.push(time_of(&cl));
664                entries.push(Arc::new(cl));
665            }
666            Err(DecodeError::UnexpectedEnd { .. }) => break,
667            Err(DecodeError::Io { inner, .. }) if inner.kind() == io::ErrorKind::UnexpectedEof => {
668                break;
669            }
670            Err(e) => {
671                return Err(CuError::new_with_cause(
672                    "Failed to decode CopperList section",
673                    e,
674                ));
675            }
676        }
677    }
678    Ok((entries, timestamps))
679}
680
681/// Scan a copperlist section for metadata only.
682#[allow(clippy::type_complexity)]
683fn scan_copperlist_section<
684    P: CopperListTuple,
685    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
686>(
687    section: &[u8],
688    time_of: &TF,
689) -> CuResult<(usize, u64, u64, Option<CuTime>, Option<CuTime>)> {
690    let mut cursor = std::io::Cursor::new(section);
691    let mut count = 0usize;
692    let mut first_id = None;
693    let mut last_id = None;
694    let mut first_ts = None;
695    let mut last_ts = None;
696    loop {
697        match decode_from_std_read::<crate::copperlist::CopperList<P>, _, _>(
698            &mut cursor,
699            standard(),
700        ) {
701            Ok(cl) => {
702                let ts = time_of(&cl);
703                if ts.is_none() {
704                    #[cfg(feature = "std")]
705                    eprintln!(
706                        "CuDebug index warning: missing timestamp on culistid {}; time-based seek may be less accurate",
707                        cl.id
708                    );
709                }
710                if first_id.is_none() {
711                    first_id = Some(cl.id);
712                    first_ts = ts;
713                }
714                // Recover first_ts if the first entry lacked a timestamp but a later one has it.
715                if first_ts.is_none() {
716                    first_ts = ts;
717                }
718                last_id = Some(cl.id);
719                last_ts = ts.or(last_ts);
720                count += 1;
721            }
722            Err(DecodeError::UnexpectedEnd { .. }) => break,
723            Err(DecodeError::Io { inner, .. }) if inner.kind() == io::ErrorKind::UnexpectedEof => {
724                break;
725            }
726            Err(e) => {
727                return Err(CuError::new_with_cause(
728                    "Failed to scan copperlist section",
729                    e,
730                ));
731            }
732        }
733    }
734    let first_id = first_id.ok_or_else(|| CuError::from("Empty copperlist section"))?;
735    let last_id = last_id.unwrap_or(first_id);
736    Ok((count, first_id, last_id, first_ts, last_ts))
737}
738
739/// Build a reusable read-only unified logger for this session.
740fn build_read_logger(log_base: &Path) -> CuResult<UnifiedLoggerRead> {
741    let logger = UnifiedLoggerBuilder::new()
742        .file_base_name(log_base)
743        .build()
744        .map_err(|e| CuError::new_with_cause("Failed to open unified log", e))?;
745    let UnifiedLogger::Read(dl) = logger else {
746        return Err(CuError::from("Expected read-only unified logger"));
747    };
748    Ok(dl)
749}
750
751/// Read a specific section at a given position from disk using an existing handle.
752fn read_section_at(
753    log_reader: &mut UnifiedLoggerRead,
754    pos: LogPosition,
755) -> CuResult<(SectionHeader, Vec<u8>)> {
756    log_reader.seek(pos)?;
757    log_reader.raw_read_section()
758}
759
760/// Build a section-level index in one pass (copperlists + keyframes).
761fn index_log<P, TF>(
762    log_base: &Path,
763    time_of: &TF,
764) -> CuResult<(Vec<SectionIndexEntry>, Vec<KeyFrame>, usize)>
765where
766    P: CopperListTuple,
767    TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
768{
769    let logger = UnifiedLoggerBuilder::new()
770        .file_base_name(log_base)
771        .build()
772        .map_err(|e| CuError::new_with_cause("Failed to open unified log", e))?;
773    let UnifiedLogger::Read(mut dl) = logger else {
774        return Err(CuError::from("Expected read-only unified logger"));
775    };
776
777    let mut sections = Vec::new();
778    let mut keyframes = Vec::new();
779    let mut total_entries = 0usize;
780
781    loop {
782        let pos = dl.position();
783        let (header, data) = dl.raw_read_section()?;
784        if header.entry_type == UnifiedLogType::LastEntry {
785            break;
786        }
787
788        match header.entry_type {
789            UnifiedLogType::CopperList => {
790                let (len, first_id, last_id, first_ts, last_ts) =
791                    scan_copperlist_section::<P, _>(&data, time_of)?;
792                if len == 0 {
793                    continue;
794                }
795                sections.push(SectionIndexEntry {
796                    pos,
797                    start_idx: total_entries,
798                    len,
799                    first_id,
800                    last_id,
801                    first_ts,
802                    last_ts,
803                });
804                total_entries += len;
805            }
806            UnifiedLogType::FrozenTasks => {
807                // Read all keyframes in this section
808                let mut cursor = std::io::Cursor::new(&data);
809                loop {
810                    match decode_from_std_read::<KeyFrame, _, _>(&mut cursor, standard()) {
811                        Ok(kf) => keyframes.push(kf),
812                        Err(DecodeError::UnexpectedEnd { .. }) => break,
813                        Err(DecodeError::Io { inner, .. })
814                            if inner.kind() == io::ErrorKind::UnexpectedEof =>
815                        {
816                            break;
817                        }
818                        Err(e) => {
819                            return Err(CuError::new_with_cause(
820                                "Failed to decode keyframe section",
821                                e,
822                            ));
823                        }
824                    }
825                }
826            }
827            _ => {
828                // ignore other sections
829            }
830        }
831    }
832
833    Ok((sections, keyframes, total_entries))
834}