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