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