1use 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#[derive(Debug, Clone)]
31pub struct JumpOutcome {
32 pub culistid: u32,
34 pub keyframe_culistid: Option<u32>,
36 pub replayed: usize,
38}
39
40#[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#[derive(Debug, Clone)]
54struct CachedSection<P: CopperListTuple> {
55 entries: Vec<Arc<crate::copperlist::CopperList<P>>>,
56 timestamps: Vec<Option<CuTime>>,
57}
58
59const 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 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 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 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 #[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 fn find_section_for_time(&self, ts: CuTime) -> Option<usize> {
270 if self.sections.is_empty() {
271 return None;
272 }
273
274 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, };
289
290 if idx < self.sections.len() {
291 return Some(idx);
292 }
293
294 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 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(§ion_idx) {
347 self.touch_cache(section_idx);
348 return Ok(self.cache.get(§ion_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(§ion_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 §ion.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 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 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 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 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 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 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 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 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 pub fn total_entries(&self) -> usize {
536 self.total_entries
537 }
538
539 pub fn current_index(&self) -> Option<usize> {
541 self.current_idx
542 }
543
544 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 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 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 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 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(®istry)
604 }
605}
606#[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#[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 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
703fn 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
715fn 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
724fn 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 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 }
794 }
795 }
796
797 Ok((sections, keyframes, total_entries))
798}