use crate::app::CuSimApplication;
use crate::curuntime::KeyFrame;
use crate::reflect::{ReflectTaskIntrospection, TypeRegistry, dump_type_registry_schema};
use crate::simulation::SimOverride;
use bincode::config::standard;
use bincode::decode_from_std_read;
use bincode::error::DecodeError;
use cu29_clock::{CuTime, RobotClock, RobotClockMock};
use cu29_traits::{CopperListTuple, CuError, CuResult, UnifiedLogType};
use cu29_unifiedlog::{
LogPosition, SectionHeader, SectionStorage, UnifiedLogRead, UnifiedLogWrite, UnifiedLogger,
UnifiedLoggerBuilder, UnifiedLoggerRead,
};
use std::collections::{HashMap, VecDeque};
use std::io;
use std::marker::PhantomData;
use std::path::Path;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct JumpOutcome {
pub culistid: u64,
pub keyframe_culistid: Option<u64>,
pub replayed: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct SectionCacheStats {
pub cap: usize,
pub entries: usize,
pub hits: u64,
pub misses: u64,
pub evictions: u64,
}
#[derive(Debug, Clone)]
pub(crate) struct SectionIndexEntry {
pub(crate) pos: LogPosition,
pub(crate) start_idx: usize,
pub(crate) len: usize,
pub(crate) first_id: u64,
pub(crate) last_id: u64,
pub(crate) first_ts: Option<CuTime>,
pub(crate) last_ts: Option<CuTime>,
}
#[derive(Debug, Clone)]
struct CachedSection<P: CopperListTuple> {
entries: Vec<Arc<crate::copperlist::CopperList<P>>>,
timestamps: Vec<Option<CuTime>>,
}
const DEFAULT_SECTION_CACHE_CAP: usize = 8;
pub struct CuDebugSession<App, P, CB, TF, S, L>
where
P: CopperListTuple,
S: SectionStorage,
L: UnifiedLogWrite<S> + 'static,
{
app: App,
robot_clock: RobotClock,
clock_mock: RobotClockMock,
log_reader: UnifiedLoggerRead,
sections: Vec<SectionIndexEntry>,
total_entries: usize,
keyframes: Vec<KeyFrame>,
started: bool,
current_idx: Option<usize>,
last_keyframe: Option<u64>,
build_callback: CB,
time_of: TF,
cache: HashMap<usize, CachedSection<P>>,
cache_order: VecDeque<usize>,
cache_cap: usize,
cache_hits: u64,
cache_misses: u64,
cache_evictions: u64,
phantom: PhantomData<(S, L)>,
}
impl<App, P, CB, TF, S, L> CuDebugSession<App, P, CB, TF, S, L>
where
App: CuSimApplication<S, L>,
L: UnifiedLogWrite<S> + 'static,
S: SectionStorage,
P: CopperListTuple,
CB: for<'a> Fn(
&'a crate::copperlist::CopperList<P>,
RobotClock,
) -> Box<dyn for<'z> FnMut(App::Step<'z>) -> SimOverride + 'a>,
TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime> + Clone,
{
pub fn from_log(
log_base: &Path,
app: App,
robot_clock: RobotClock,
clock_mock: RobotClockMock,
build_callback: CB,
time_of: TF,
) -> CuResult<Self> {
let (sections, keyframes, total_entries) = index_log::<P, _>(log_base, &time_of)?;
let log_reader = build_read_logger(log_base)?;
Ok(Self::new(
log_reader,
app,
robot_clock,
clock_mock,
sections,
total_entries,
keyframes,
build_callback,
time_of,
))
}
pub fn from_log_with_cache_cap(
log_base: &Path,
app: App,
robot_clock: RobotClock,
clock_mock: RobotClockMock,
build_callback: CB,
time_of: TF,
cache_cap: usize,
) -> CuResult<Self> {
let (sections, keyframes, total_entries) = index_log::<P, _>(log_base, &time_of)?;
let log_reader = build_read_logger(log_base)?;
Ok(Self::new_with_cache_cap(
log_reader,
app,
robot_clock,
clock_mock,
sections,
total_entries,
keyframes,
build_callback,
time_of,
cache_cap,
))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
log_reader: UnifiedLoggerRead,
app: App,
robot_clock: RobotClock,
clock_mock: RobotClockMock,
sections: Vec<SectionIndexEntry>,
total_entries: usize,
keyframes: Vec<KeyFrame>,
build_callback: CB,
time_of: TF,
) -> Self {
Self::new_with_cache_cap(
log_reader,
app,
robot_clock,
clock_mock,
sections,
total_entries,
keyframes,
build_callback,
time_of,
DEFAULT_SECTION_CACHE_CAP,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn new_with_cache_cap(
log_reader: UnifiedLoggerRead,
app: App,
robot_clock: RobotClock,
clock_mock: RobotClockMock,
sections: Vec<SectionIndexEntry>,
total_entries: usize,
keyframes: Vec<KeyFrame>,
build_callback: CB,
time_of: TF,
cache_cap: usize,
) -> Self {
Self {
app,
robot_clock,
clock_mock,
log_reader,
sections,
total_entries,
keyframes,
started: false,
current_idx: None,
last_keyframe: None,
build_callback,
time_of,
cache: HashMap::new(),
cache_order: VecDeque::new(),
cache_cap: cache_cap.max(1),
cache_hits: 0,
cache_misses: 0,
cache_evictions: 0,
phantom: PhantomData,
}
}
fn ensure_started(&mut self) -> CuResult<()> {
if self.started {
return Ok(());
}
let mut noop = |_step: App::Step<'_>| SimOverride::ExecuteByRuntime;
self.app.start_all_tasks(&mut noop)?;
self.started = true;
Ok(())
}
fn nearest_keyframe(&self, target_culistid: u64) -> Option<KeyFrame> {
self.keyframes
.iter()
.filter(|kf| kf.culistid <= target_culistid)
.max_by_key(|kf| kf.culistid)
.cloned()
}
fn restore_keyframe(&mut self, kf: &KeyFrame) -> CuResult<()> {
self.app.restore_keyframe(kf)?;
self.clock_mock.set_value(kf.timestamp.as_nanos());
self.last_keyframe = Some(kf.culistid);
Ok(())
}
fn find_section_for_index(&self, idx: usize) -> Option<usize> {
self.sections
.binary_search_by(|s| {
if idx < s.start_idx {
std::cmp::Ordering::Greater
} else if idx >= s.start_idx + s.len {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Equal
}
})
.ok()
}
fn find_section_for_culistid(&self, culistid: u64) -> Option<usize> {
self.sections
.binary_search_by(|s| {
if culistid < s.first_id {
std::cmp::Ordering::Greater
} else if culistid > s.last_id {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Equal
}
})
.ok()
}
fn find_section_for_time(&self, ts: CuTime) -> Option<usize> {
if self.sections.is_empty() {
return None;
}
if self.sections.iter().all(|s| s.first_ts.is_some()) {
let idx = match self.sections.binary_search_by(|s| {
let a = s.first_ts.unwrap();
if a < ts {
std::cmp::Ordering::Less
} else if a > ts {
std::cmp::Ordering::Greater
} else {
std::cmp::Ordering::Equal
}
}) {
Ok(i) => i,
Err(i) => i, };
if idx < self.sections.len() {
return Some(idx);
}
let last = self.sections.last().unwrap();
if let Some(last_ts) = last.last_ts
&& ts <= last_ts
{
return Some(self.sections.len() - 1);
}
return None;
}
if let Some(first_ts) = self.sections.first().and_then(|s| s.first_ts)
&& ts <= first_ts
{
return Some(0);
}
if let Some(idx) = self
.sections
.iter()
.position(|s| match (s.first_ts, s.last_ts) {
(Some(a), Some(b)) => a <= ts && ts <= b,
(Some(a), None) => a <= ts,
_ => false,
})
{
return Some(idx);
}
let last = self.sections.last().unwrap();
match last.last_ts {
Some(b) if ts <= b => Some(self.sections.len() - 1),
_ => None,
}
}
fn touch_cache(&mut self, key: usize) {
if let Some(pos) = self.cache_order.iter().position(|k| *k == key) {
self.cache_order.remove(pos);
}
self.cache_order.push_back(key);
while self.cache_order.len() > self.cache_cap {
if let Some(old) = self.cache_order.pop_front()
&& self.cache.remove(&old).is_some()
{
self.cache_evictions = self.cache_evictions.saturating_add(1);
}
}
}
fn load_section(&mut self, section_idx: usize) -> CuResult<&CachedSection<P>> {
if self.cache.contains_key(§ion_idx) {
self.cache_hits = self.cache_hits.saturating_add(1);
self.touch_cache(section_idx);
return Ok(self.cache.get(§ion_idx).unwrap());
}
self.cache_misses = self.cache_misses.saturating_add(1);
let entry = &self.sections[section_idx];
let (header, data) = read_section_at(&mut self.log_reader, entry.pos)?;
if header.entry_type != UnifiedLogType::CopperList {
return Err(CuError::from(
"Section type mismatch while loading copperlists",
));
}
let (entries, timestamps) = decode_copperlists::<P, _>(&data, &self.time_of)?;
let cached = CachedSection {
entries,
timestamps,
};
self.cache.insert(section_idx, cached);
self.touch_cache(section_idx);
Ok(self.cache.get(§ion_idx).unwrap())
}
fn copperlist_at(
&mut self,
idx: usize,
) -> CuResult<(Arc<crate::copperlist::CopperList<P>>, Option<CuTime>)> {
let section_idx = self
.find_section_for_index(idx)
.ok_or_else(|| CuError::from("Index outside copperlist log"))?;
let start_idx = self.sections[section_idx].start_idx;
let section = self.load_section(section_idx)?;
let local = idx - start_idx;
let cl = section
.entries
.get(local)
.ok_or_else(|| CuError::from("Corrupt section index vs cache"))?
.clone();
let ts = section.timestamps.get(local).copied().unwrap_or(None);
Ok((cl, ts))
}
fn index_for_culistid(&mut self, culistid: u64) -> CuResult<usize> {
let section_idx = self
.find_section_for_culistid(culistid)
.ok_or_else(|| CuError::from("Requested culistid not present in log"))?;
let start_idx = self.sections[section_idx].start_idx;
let section = self.load_section(section_idx)?;
for (offset, cl) in section.entries.iter().enumerate() {
if cl.id == culistid {
return Ok(start_idx + offset);
}
}
Err(CuError::from("culistid not found inside indexed section"))
}
fn index_for_time(&mut self, ts: CuTime) -> CuResult<usize> {
let section_idx = self
.find_section_for_time(ts)
.ok_or_else(|| CuError::from("No copperlist at or after requested timestamp"))?;
let start_idx = self.sections[section_idx].start_idx;
let section = self.load_section(section_idx)?;
let idx = start_idx;
for (i, maybe) in section.timestamps.iter().enumerate() {
if matches!(maybe, Some(t) if *t >= ts) {
return Ok(idx + i);
}
}
Err(CuError::from("Timestamp not found within section"))
}
fn replay_range(&mut self, start: usize, end: usize) -> CuResult<usize> {
let mut replayed = 0usize;
for idx in start..=end {
let (entry, ts) = self.copperlist_at(idx)?;
if let Some(ts) = ts {
self.clock_mock.set_value(ts.as_nanos());
}
let clock_for_cb = self.robot_clock.clone();
let mut cb = (self.build_callback)(entry.as_ref(), clock_for_cb);
self.app.run_one_iteration(&mut cb)?;
replayed += 1;
self.current_idx = Some(idx);
}
Ok(replayed)
}
fn goto_index(&mut self, target_idx: usize) -> CuResult<JumpOutcome> {
self.ensure_started()?;
if target_idx >= self.total_entries {
return Err(CuError::from("Target index outside log"));
}
let (target_cl, _) = self.copperlist_at(target_idx)?;
let target_culistid = target_cl.id;
let keyframe_used: Option<u64>;
let replay_start: usize;
if let Some(current) = self.current_idx {
if target_idx == current {
return Ok(JumpOutcome {
culistid: target_culistid,
keyframe_culistid: self.last_keyframe,
replayed: 0,
});
}
if target_idx >= current {
replay_start = current + 1;
keyframe_used = self.last_keyframe;
} else {
let Some(kf) = self.nearest_keyframe(target_culistid) else {
return Err(CuError::from("No keyframe available to rewind"));
};
self.restore_keyframe(&kf)?;
keyframe_used = Some(kf.culistid);
replay_start = self.index_for_culistid(kf.culistid)?;
}
} else {
let Some(kf) = self.nearest_keyframe(target_culistid) else {
return Err(CuError::from("No keyframe found in log"));
};
self.restore_keyframe(&kf)?;
keyframe_used = Some(kf.culistid);
replay_start = self.index_for_culistid(kf.culistid)?;
}
if replay_start > target_idx {
return Err(CuError::from(
"Replay start past target index; log ordering issue",
));
}
let replayed = self.replay_range(replay_start, target_idx)?;
Ok(JumpOutcome {
culistid: target_culistid,
keyframe_culistid: keyframe_used,
replayed,
})
}
pub fn goto_cl(&mut self, culistid: u64) -> CuResult<JumpOutcome> {
let idx = self.index_for_culistid(culistid)?;
self.goto_index(idx)
}
pub fn goto_time(&mut self, ts: CuTime) -> CuResult<JumpOutcome> {
let idx = self.index_for_time(ts)?;
self.goto_index(idx)
}
pub fn step(&mut self, delta: i32) -> CuResult<JumpOutcome> {
let current =
self.current_idx
.ok_or_else(|| CuError::from("Cannot step before any jump"))? as i32;
let target = current + delta;
if target < 0 || target as usize >= self.total_entries {
return Err(CuError::from("Step would move outside log bounds"));
}
self.goto_index(target as usize)
}
pub fn current_cl(&mut self) -> CuResult<Option<Arc<crate::copperlist::CopperList<P>>>> {
match self.current_idx {
Some(idx) => Ok(Some(self.copperlist_at(idx)?.0)),
None => Ok(None),
}
}
pub fn cl_at(&mut self, idx: usize) -> CuResult<Option<Arc<crate::copperlist::CopperList<P>>>> {
if idx >= self.total_entries {
return Ok(None);
}
Ok(Some(self.copperlist_at(idx)?.0))
}
pub fn total_entries(&self) -> usize {
self.total_entries
}
pub fn nearest_keyframe_culistid(&self, target_culistid: u64) -> Option<u64> {
self.nearest_keyframe(target_culistid).map(|kf| kf.culistid)
}
pub fn section_cache_stats(&self) -> SectionCacheStats {
SectionCacheStats {
cap: self.cache_cap,
entries: self.cache.len(),
hits: self.cache_hits,
misses: self.cache_misses,
evictions: self.cache_evictions,
}
}
pub fn current_index(&self) -> Option<usize> {
self.current_idx
}
pub fn with_app<R>(&mut self, f: impl FnOnce(&mut App) -> R) -> R {
f(&mut self.app)
}
}
impl<App, P, CB, TF, S, L> CuDebugSession<App, P, CB, TF, S, L>
where
App: CuSimApplication<S, L> + ReflectTaskIntrospection,
L: UnifiedLogWrite<S> + 'static,
S: SectionStorage,
P: CopperListTuple,
CB: for<'a> Fn(
&'a crate::copperlist::CopperList<P>,
RobotClock,
) -> Box<dyn for<'z> FnMut(App::Step<'z>) -> SimOverride + 'a>,
TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime> + Clone,
{
pub fn reflected_task(&self, task_id: &str) -> CuResult<&dyn crate::reflect::Reflect> {
self.app
.reflect_task(task_id)
.ok_or_else(|| CuError::from(format!("Task '{task_id}' was not found.")))
}
pub fn reflected_task_mut(
&mut self,
task_id: &str,
) -> CuResult<&mut dyn crate::reflect::Reflect> {
self.app
.reflect_task_mut(task_id)
.ok_or_else(|| CuError::from(format!("Task '{task_id}' was not found.")))
}
pub fn dump_reflected_task(&self, task_id: &str) -> CuResult<String> {
let task = self.reflected_task(task_id)?;
#[cfg(not(feature = "reflect"))]
{
let _ = task;
Err(CuError::from(
"Task introspection is disabled. Rebuild with the `reflect` feature.",
))
}
#[cfg(feature = "reflect")]
{
Ok(format!("{task:#?}"))
}
}
pub fn dump_reflected_task_schemas(&self) -> String {
#[cfg(feature = "reflect")]
let mut registry = TypeRegistry::default();
#[cfg(not(feature = "reflect"))]
let mut registry = TypeRegistry;
<App as ReflectTaskIntrospection>::register_reflect_types(&mut registry);
dump_type_registry_schema(®istry)
}
}
#[allow(clippy::type_complexity)]
pub(crate) fn decode_copperlists<
P: CopperListTuple,
TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
>(
section: &[u8],
time_of: &TF,
) -> CuResult<(
Vec<Arc<crate::copperlist::CopperList<P>>>,
Vec<Option<CuTime>>,
)> {
let mut cursor = std::io::Cursor::new(section);
let mut entries = Vec::new();
let mut timestamps = Vec::new();
loop {
match decode_from_std_read::<crate::copperlist::CopperList<P>, _, _>(
&mut cursor,
standard(),
) {
Ok(cl) => {
timestamps.push(time_of(&cl));
entries.push(Arc::new(cl));
}
Err(DecodeError::UnexpectedEnd { .. }) => break,
Err(DecodeError::Io { inner, .. }) if inner.kind() == io::ErrorKind::UnexpectedEof => {
break;
}
Err(e) => {
return Err(CuError::new_with_cause(
"Failed to decode CopperList section",
e,
));
}
}
}
Ok((entries, timestamps))
}
#[allow(clippy::type_complexity)]
fn scan_copperlist_section<
P: CopperListTuple,
TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
>(
section: &[u8],
time_of: &TF,
) -> CuResult<(usize, u64, u64, Option<CuTime>, Option<CuTime>)> {
let mut cursor = std::io::Cursor::new(section);
let mut count = 0usize;
let mut first_id = None;
let mut last_id = None;
let mut first_ts = None;
let mut last_ts = None;
loop {
match decode_from_std_read::<crate::copperlist::CopperList<P>, _, _>(
&mut cursor,
standard(),
) {
Ok(cl) => {
let ts = time_of(&cl);
if ts.is_none() {
#[cfg(feature = "std")]
eprintln!(
"CuDebug index warning: missing timestamp on culistid {}; time-based seek may be less accurate",
cl.id
);
}
if first_id.is_none() {
first_id = Some(cl.id);
first_ts = ts;
}
if first_ts.is_none() {
first_ts = ts;
}
last_id = Some(cl.id);
last_ts = ts.or(last_ts);
count += 1;
}
Err(DecodeError::UnexpectedEnd { .. }) => break,
Err(DecodeError::Io { inner, .. }) if inner.kind() == io::ErrorKind::UnexpectedEof => {
break;
}
Err(e) => {
return Err(CuError::new_with_cause(
"Failed to scan copperlist section",
e,
));
}
}
}
let first_id = first_id.ok_or_else(|| CuError::from("Empty copperlist section"))?;
let last_id = last_id.unwrap_or(first_id);
Ok((count, first_id, last_id, first_ts, last_ts))
}
pub(crate) fn build_read_logger(log_base: &Path) -> CuResult<UnifiedLoggerRead> {
let logger = UnifiedLoggerBuilder::new()
.file_base_name(log_base)
.build()
.map_err(|e| CuError::new_with_cause("Failed to open unified log", e))?;
let UnifiedLogger::Read(dl) = logger else {
return Err(CuError::from("Expected read-only unified logger"));
};
Ok(dl)
}
pub(crate) fn read_section_at(
log_reader: &mut UnifiedLoggerRead,
pos: LogPosition,
) -> CuResult<(SectionHeader, Vec<u8>)> {
log_reader.seek(pos)?;
log_reader.raw_read_section()
}
pub(crate) fn index_log<P, TF>(
log_base: &Path,
time_of: &TF,
) -> CuResult<(Vec<SectionIndexEntry>, Vec<KeyFrame>, usize)>
where
P: CopperListTuple,
TF: Fn(&crate::copperlist::CopperList<P>) -> Option<CuTime>,
{
let logger = UnifiedLoggerBuilder::new()
.file_base_name(log_base)
.build()
.map_err(|e| CuError::new_with_cause("Failed to open unified log", e))?;
let UnifiedLogger::Read(mut dl) = logger else {
return Err(CuError::from("Expected read-only unified logger"));
};
let mut sections = Vec::new();
let mut keyframes = Vec::new();
let mut total_entries = 0usize;
loop {
let pos = dl.position();
let (header, data) = dl.raw_read_section()?;
if header.entry_type == UnifiedLogType::LastEntry {
break;
}
match header.entry_type {
UnifiedLogType::CopperList => {
let (len, first_id, last_id, first_ts, last_ts) =
scan_copperlist_section::<P, _>(&data, time_of)?;
if len == 0 {
continue;
}
sections.push(SectionIndexEntry {
pos,
start_idx: total_entries,
len,
first_id,
last_id,
first_ts,
last_ts,
});
total_entries += len;
}
UnifiedLogType::FrozenTasks => {
let mut cursor = std::io::Cursor::new(&data);
loop {
match decode_from_std_read::<KeyFrame, _, _>(&mut cursor, standard()) {
Ok(kf) => keyframes.push(kf),
Err(DecodeError::UnexpectedEnd { .. }) => break,
Err(DecodeError::Io { inner, .. })
if inner.kind() == io::ErrorKind::UnexpectedEof =>
{
break;
}
Err(e) => {
return Err(CuError::new_with_cause(
"Failed to decode keyframe section",
e,
));
}
}
}
}
_ => {
}
}
}
Ok((sections, keyframes, total_entries))
}