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