diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60adfd48f7..68526eb785 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -176,6 +176,9 @@ jobs: with: dotnet-version: '10.0.x' + - name: Install Rust cross-compilation targets + run: rustup target add x86_64-apple-darwin aarch64-apple-darwin + - name: Publish macOS self-contained application shell: bash run: | diff --git a/.gitignore b/.gitignore index 48beef8bd8..14a9f17378 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ obj3/ /.claude/settings.local.json /CLAUDE.md **/TestResults/ + +# cargo build output for manual runs in rust/ (MSBuild builds under obj/) +rust/target/ diff --git a/LICENSES/dflog-NOTICE.txt b/LICENSES/dflog-NOTICE.txt new file mode 100644 index 0000000000..9760dd424e --- /dev/null +++ b/LICENSES/dflog-NOTICE.txt @@ -0,0 +1,19 @@ +dflog +===== + +Mission Planner optionally builds and bundles the dflog native dataflash log +parser (dflog_ffi.dll / libdflog_ffi.so / libdflog_ffi.dylib) from the Rust +sources vendored in the top-level rust/ directory. dflog is first-party code +licensed under GNU GPL version 3, the same license as this application; the +complete GPLv3 text is provided in the top-level LICENSE file. + +Canonical source (vendored from): +https://github.com/userepo/MissionPlanner/tree/rust/dflog-core (rust/) + +Statically linked third-party Rust crates: + +- memmap2 (https://crates.io/crates/memmap2) - MIT OR Apache-2.0; used under + Apache-2.0, whose text is provided in this directory (Apache-2.0.txt). +- The Rust standard library is statically linked and is licensed + MIT OR Apache-2.0 (https://github.com/rust-lang/rust); used under + Apache-2.0 as above. diff --git a/MissionPlanner.csproj b/MissionPlanner.csproj index 8539c6ab61..f6aa759c93 100644 --- a/MissionPlanner.csproj +++ b/MissionPlanner.csproj @@ -172,6 +172,31 @@ + + + x86_64-pc-windows-msvc + x86_64-unknown-linux-gnu + x86_64-apple-darwin + aarch64-apple-darwin + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(BaseIntermediateOutputPath)dflog')) + $(DflogNativeDir)/$(DflogRustTriple)/release + $(DflogNativeDir)/release + --target $(DflogRustTriple) + dflog_ffi.dll + libdflog_ffi.so + libdflog_ffi.dylib + + @@ -290,6 +315,62 @@ + + + + + + + + + + + + + + $(DflogInstalledTargets.Contains('$(DflogRustTriple)')) + true + + + + + + + + + + PreserveNewest + PreserveNewest + + + + ), +} + +impl Value { + /// numeric view; integers convert like an `as f64` cast + pub fn as_f64(&self) -> Option { + match self { + Value::I64(v) => Some(*v as f64), + Value::U64(v) => Some(*v as f64), + Value::F64(v) => Some(*v), + _ => None, + } + } + + pub fn as_str(&self) -> Option<&str> { + match self { + Value::Str(s) => Some(s), + _ => None, + } + } +} + +fn field_size(code: u8) -> usize { + match code { + b'b' | b'B' | b'M' => 1, + b'h' | b'H' | b'c' | b'C' | b'g' => 2, + b'i' | b'I' | b'e' | b'E' | b'L' | b'f' => 4, + b'q' | b'Q' | b'd' => 8, + b'n' => 4, + b'N' => 16, + b'Z' => 64, + b'a' => 64, + _ => 0, + } +} + +fn zero_padded64(payload: &[u8], at: usize) -> [u8; 64] { + let mut buf = [0u8; 64]; + if at < payload.len() { + let take = (payload.len() - at).min(64); + buf[..take].copy_from_slice(&payload[at..at + take]); + } + buf +} + +fn ascii_lossy_trim(bytes: &[u8]) -> String { + let mapped: String = bytes + .iter() + .map(|&b| if b > 0x7F { '?' } else { b as char }) + .collect(); + mapped.trim_matches('\0').to_string() +} + +/// None for format chars with no known decoding +fn decode_value(code: u8, payload: &[u8], at: usize) -> Option { + let b = zero_padded64(payload, at); + Some(match code { + b'b' => Value::I64((b[0] as i8) as i64), + b'B' | b'M' => Value::U64(b[0] as u64), + b'h' => Value::I64(i16::from_le_bytes([b[0], b[1]]) as i64), + b'H' => Value::U64(u16::from_le_bytes([b[0], b[1]]) as u64), + b'i' => Value::I64(i32::from_le_bytes([b[0], b[1], b[2], b[3]]) as i64), + b'I' => Value::U64(u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as u64), + b'q' => Value::I64(i64::from_le_bytes(b[..8].try_into().unwrap())), + b'Q' => Value::U64(u64::from_le_bytes(b[..8].try_into().unwrap())), + b'f' => Value::F64(f32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64), + b'd' => Value::F64(f64::from_le_bytes(b[..8].try_into().unwrap())), + b'g' => Value::F64(half_to_f32(u16::from_le_bytes([b[0], b[1]])) as f64), + b'c' => Value::F64(i16::from_le_bytes([b[0], b[1]]) as f64 / 100.0), + b'C' => Value::F64(u16::from_le_bytes([b[0], b[1]]) as f64 / 100.0), + b'e' => Value::F64(i32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64 / 100.0), + b'E' => Value::F64(u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64 / 100.0), + b'L' => Value::F64(i32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64 / 10000000.0), + b'n' => Value::Str(ascii_lossy_trim(&b[..4])), + b'N' => Value::Str(ascii_lossy_trim(&b[..16])), + b'Z' => Value::Str(ascii_lossy_trim(&b[..64])), + b'a' => Value::Shorts( + b.as_chunks::<2>() + .0 + .iter() + .map(|p| i16::from_le_bytes(*p)) + .collect(), + ), + _ => return None, + }) +} + +/// one record, decoded lazily field by field +#[derive(Debug)] +pub struct Record<'a> { + pub lineno: u64, + pub fmt: &'a FmtDef, + payload: &'a [u8], +} + +impl<'a> Record<'a> { + pub fn type_name(&self) -> &str { + &self.fmt.name + } + + /// decoded value of the named field (first matching label, like the + /// managed FindMessageOffset); None for unknown labels and undecodable + /// format chars + pub fn value(&self, field: &str) -> Option { + let codes = self.fmt.format.as_bytes(); + let pos = self.fmt.labels.iter().position(|l| l == field)?; + if pos >= codes.len() { + return None; + } + let at: usize = codes[..pos].iter().map(|&c| field_size(c)).sum(); + decode_value(codes[pos], self.payload, at) + } + + /// (label, value) pairs for every decodable field, in format order + pub fn values(&self) -> Vec<(&str, Value)> { + let codes = self.fmt.format.as_bytes(); + let mut out = Vec::with_capacity(codes.len()); + let mut at = 0usize; + for (pos, &code) in codes.iter().enumerate() { + if let (Some(label), Some(value)) = ( + self.fmt.labels.get(pos), + decode_value(code, self.payload, at), + ) { + out.push((label.as_str(), value)); + } + at += field_size(code); + } + out + } +} + +/// records in log order; `type_filter` limits to those type ids +#[derive(Debug)] +pub struct RecordIter<'a> { + log: &'a LogFile, + pos: usize, + type_filter: Option<[bool; 256]>, +} + +impl<'a> Iterator for RecordIter<'a> { + type Item = Record<'a>; + + fn next(&mut self) -> Option> { + while self.pos < self.log.index.types.len() { + let i = self.pos; + self.pos += 1; + if let Some(filter) = &self.type_filter { + if !filter[self.log.index.types[i] as usize] { + continue; + } + } + if let Some(record) = self.log.record_at(i) { + return Some(record); + } + // record of a type with no FMT: not decodable + } + None + } +} + +impl LogFile { + /// the decodable record at position `i` of the scan index; None when + /// out of range or the record's type has no FMT + pub fn record_at(&self, i: usize) -> Option> { + let t = *self.index.types.get(i)?; + let fmt = self.fmts.get(&t)?; + let data = self.data(); + let start = self.index.offsets[i] as usize + 3; + let size = fmt.length.saturating_sub(3); + let end = (start + size).min(data.len()); + let payload = if start < data.len() { + &data[start..end] + } else { + &[] + }; + Some(Record { + lineno: i as u64, + fmt, + payload, + }) + } + + /// every decodable record in log order + pub fn records(&self) -> RecordIter<'_> { + RecordIter { + log: self, + pos: 0, + type_filter: None, + } + } + + /// records of the named types, in log order; unknown names are ignored + pub fn records_of(&self, types: &[&str]) -> RecordIter<'_> { + let mut filter = [false; 256]; + for name in types { + if let Some(&id) = self.name_to_id.get(*name) { + filter[id as usize] = true; + } + } + RecordIter { + log: self, + pos: 0, + type_filter: Some(filter), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn corpus(name: &str) -> LogFile { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../testdata") + .join(name); + LogFile::open(&path).expect("corpus log") + } + + /// record values must agree with the columnar decoders row for row + #[test] + fn records_agree_with_columns() { + let log = corpus("copter.bin"); + let cols = crate::columns::get_columns(&log, "ATT", &["TimeUS", "Roll", "Pitch"]).unwrap(); + + let mut row = 0usize; + for record in log.records_of(&["ATT"]) { + assert_eq!(record.lineno, cols.linenos[row]); + let rows = cols.rows as usize; + assert_eq!( + record.value("TimeUS").unwrap().as_f64().unwrap(), + cols.values[row] + ); + assert_eq!( + record.value("Roll").unwrap().as_f64().unwrap(), + cols.values[rows + row] + ); + assert_eq!( + record.value("Pitch").unwrap().as_f64().unwrap(), + cols.values[2 * rows + row] + ); + row += 1; + } + + assert_eq!(row as u64, cols.rows); + } + + #[test] + fn string_fields_decode_as_text() { + let log = corpus("copter.bin"); + let first_msg = log.records_of(&["MSG"]).next().expect("MSG record"); + let text = first_msg.value("Message").unwrap(); + let text = text.as_str().unwrap(); + assert!( + text.starts_with("ArduCopter"), + "unexpected MSG text: {text}" + ); + + // PARM names are 'N' strings + let first_parm = log.records_of(&["PARM"]).next().expect("PARM record"); + assert!(!first_parm + .value("Name") + .unwrap() + .as_str() + .unwrap() + .is_empty()); + } + + #[test] + fn full_iteration_covers_all_known_records() { + let log = corpus("copter.bin"); + let known: usize = log + .index + .types + .iter() + .filter(|t| log.fmts.contains_key(t)) + .count(); + assert_eq!(log.records().count(), known); + + // every record decodes every field without panicking + for record in log.records() { + let _ = record.values(); + } + } + + /// random access must see exactly what iteration sees + #[test] + fn record_at_matches_iteration() { + let log = corpus("copter.bin"); + for record in log.records_of(&["ATT"]).take(50) { + let direct = log.record_at(record.lineno as usize).expect("record_at"); + assert_eq!(direct.type_name(), record.type_name()); + assert_eq!(direct.values(), record.values()); + } + assert!(log.record_at(usize::MAX).is_none()); + } +} diff --git a/rust/crates/dflog-core/src/columns.rs b/rust/crates/dflog-core/src/columns.rs new file mode 100644 index 0000000000..09acb02495 --- /dev/null +++ b/rust/crates/dflog-core/src/columns.rs @@ -0,0 +1,509 @@ +//! Typed columnar extraction by the native dflog library. +//! +//! Decodes all records of one message type into `f64` columns for the +//! requested field labels, plus the global record index ("line number") per +//! row so callers can align with the existing DFLogBuffer numbering. +//! +//! Semantics mirror the C# `BinaryLog.GetObjectFromMessage` decoders: +//! little-endian primitives; `c`/`C`/`e`/`E` scaled by 1/100; `L` by 1e-7; +//! `g` is an IEEE half; `M` is the raw mode byte; a record whose payload runs +//! past end-of-file decodes the missing bytes as zero, exactly like the C# +//! partial `Stream.Read` into a zeroed buffer. +//! +//! Precision note (deliberate, documented in the plan): these are the *raw* +//! decoded values. The legacy graphing path round-trips through 7-significant +//! -digit strings, so it loses float precision that this path keeps. + +use crate::LogFile; + +#[derive(Debug)] +pub enum ColumnError { + UnknownType(String), + UnknownField { + field: String, + available: String, + }, + /// field exists but has no numeric decoding (n/N/Z strings, `a` arrays) + NotNumeric { + field: String, + code: char, + }, + /// field is not an `a` (int16[32]) array + NotArray { + field: String, + code: char, + }, + /// FMT format/labels column counts disagree; no stable field mapping + MalformedFormat(String), + /// an instance filter was requested for a type without an instance + /// field (no '#' unit id in its FMTU) + NoInstanceField(String), +} + +impl std::fmt::Display for ColumnError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ColumnError::UnknownType(t) => write!(f, "unknown message type {t}"), + ColumnError::UnknownField { field, available } => { + write!(f, "unknown field {field}; available: {available}") + } + ColumnError::NotNumeric { field, code } => { + write!(f, "field {field} (format '{code}') is not numeric") + } + ColumnError::NotArray { field, code } => { + write!(f, "field {field} (format '{code}') is not an int16 array") + } + ColumnError::MalformedFormat(t) => write!(f, "malformed FMT for {t}"), + ColumnError::NoInstanceField(t) => { + write!(f, "message type {t} has no instance field") + } + } + } +} + +impl std::error::Error for ColumnError {} + +/// Column-major result: `values[col * rows + row]`. +#[derive(Debug)] +pub struct Columns { + pub rows: u64, + pub cols: u32, + pub linenos: Vec, + pub values: Vec, +} + +fn field_size(code: char) -> Option { + Some(match code { + 'b' | 'B' | 'M' => 1, + 'h' | 'H' | 'c' | 'C' | 'g' => 2, + 'i' | 'I' | 'e' | 'E' | 'L' | 'f' => 4, + 'q' | 'Q' | 'd' => 8, + 'n' => 4, + 'N' => 16, + 'Z' => 64, + 'a' => 64, + _ => return None, + }) +} + +pub(crate) fn half_to_f32(bits: u16) -> f32 { + // IEEE 754 binary16 -> binary32 + let sign = ((bits >> 15) & 1) as u32; + let exp = ((bits >> 10) & 0x1F) as u32; + let frac = (bits & 0x3FF) as u32; + let out = match exp { + 0 => { + if frac == 0 { + sign << 31 + } else { + // subnormal: normalize + let mut e = 127 - 15 + 1; + let mut f = frac; + while f & 0x400 == 0 { + f <<= 1; + e -= 1; + } + (sign << 31) | ((e as u32) << 23) | ((f & 0x3FF) << 13) + } + } + 0x1F => (sign << 31) | 0x7F80_0000 | (frac << 13), + _ => (sign << 31) | ((exp + 127 - 15) << 23) | (frac << 13), + }; + f32::from_bits(out) +} + +fn read_zero_padded(data: &[u8], start: usize, len: usize) -> [u8; 8] { + let mut buf = [0u8; 8]; + if start < data.len() { + let take = len.min(data.len() - start).min(8); + buf[..take].copy_from_slice(&data[start..start + take]); + } + buf +} + +fn decode(code: char, data: &[u8], at: usize) -> f64 { + let b = read_zero_padded(data, at, field_size(code).unwrap_or(0)); + match code { + 'b' => (b[0] as i8) as f64, + 'B' | 'M' => b[0] as f64, + 'h' => i16::from_le_bytes([b[0], b[1]]) as f64, + 'H' => u16::from_le_bytes([b[0], b[1]]) as f64, + 'i' => i32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64, + 'I' => u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64, + 'q' => i64::from_le_bytes(b) as f64, + 'Q' => u64::from_le_bytes(b) as f64, + 'f' => f32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64, + 'd' => f64::from_le_bytes(b), + 'g' => half_to_f32(u16::from_le_bytes([b[0], b[1]])) as f64, + 'c' => i16::from_le_bytes([b[0], b[1]]) as f64 / 100.0, + 'C' => u16::from_le_bytes([b[0], b[1]]) as f64 / 100.0, + 'e' => i32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64 / 100.0, + 'E' => u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64 / 100.0, + 'L' => i32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64 / 10000000.0, + _ => f64::NAN, + } +} + +/// Row linenos of `id` records, optionally limited to one instance value +/// (the field whose FMTU unit id is '#', compared on its decoded value). +fn collect_rows( + log: &LogFile, + type_name: &str, + id: u8, + codes: &[char], + instance: Option, +) -> Result, ColumnError> { + let instance_at = match instance { + None => None, + Some(wanted) => { + let index = log + .units() + .instance_field_index(id) + .filter(|&index| index < codes.len()) + .ok_or_else(|| ColumnError::NoInstanceField(type_name.into()))?; + let code = codes[index]; + if field_size(code).is_none() || matches!(code, 'n' | 'N' | 'Z' | 'a') { + return Err(ColumnError::NoInstanceField(type_name.into())); + } + let offset: usize = codes[..index] + .iter() + .map(|&c| field_size(c).unwrap_or(0)) + .sum(); + Some((offset, code, wanted as f64)) + } + }; + + let data = log.data(); + let mut linenos = Vec::new(); + for (i, &t) in log.index.types.iter().enumerate() { + if t != id { + continue; + } + if let Some((offset, code, wanted)) = instance_at { + let payload = log.index.offsets[i] as usize + 3; + if decode(code, data, payload + offset) != wanted { + continue; + } + } + linenos.push(i as u64); + } + Ok(linenos) +} + +/// Decode `fields` of every `type_name` record in the log. +pub fn get_columns( + log: &LogFile, + type_name: &str, + fields: &[&str], +) -> Result { + get_columns_filtered(log, type_name, fields, None) +} + +/// Decode `fields` of `type_name` records, limited to one `instance` value +/// when given (e.g. IMU instance 1); the whole log's records when None. +pub fn get_columns_filtered( + log: &LogFile, + type_name: &str, + fields: &[&str], + instance: Option, +) -> Result { + let &id = log + .name_to_id + .get(type_name) + .ok_or_else(|| ColumnError::UnknownType(type_name.into()))?; + let fmt = log + .fmts + .get(&id) + .ok_or_else(|| ColumnError::UnknownType(type_name.into()))?; + + let codes: Vec = fmt.format.chars().collect(); + if codes.len() != fmt.labels.len() { + return Err(ColumnError::MalformedFormat(type_name.into())); + } + + // label -> (payload byte offset, format code); first match wins like the + // C# FindMessageOffset lookup + let mut offsets = Vec::with_capacity(fields.len()); + for &field in fields { + let pos = fmt.labels.iter().position(|l| l == field).ok_or_else(|| { + ColumnError::UnknownField { + field: field.into(), + available: fmt.labels.join(","), + } + })?; + let code = codes[pos]; + if field_size(code).is_none() || matches!(code, 'n' | 'N' | 'Z' | 'a') { + return Err(ColumnError::NotNumeric { + field: field.into(), + code, + }); + } + let offset: usize = codes[..pos] + .iter() + .map(|&c| field_size(c).unwrap_or(0)) + .sum(); + offsets.push((offset, code)); + } + + let data = log.data(); + let linenos = collect_rows(log, type_name, id, &codes, instance)?; + + let rows = linenos.len(); + let mut values = vec![0f64; rows * fields.len()]; + for (col, &(field_offset, code)) in offsets.iter().enumerate() { + let out = &mut values[col * rows..(col + 1) * rows]; + for (row, &lineno) in linenos.iter().enumerate() { + let payload = log.index.offsets[lineno as usize] as usize + 3; + out[row] = decode(code, data, payload + field_offset); + } + } + + Ok(Columns { + rows: rows as u64, + cols: fields.len() as u32, + linenos, + values, + }) +} + +/// Elements per `a`-format field: int16_t[32]. +pub const ARRAY_ELEMS: usize = 32; + +/// Row-major result: `values[row * ARRAY_ELEMS + elem]`. +#[derive(Debug)] +pub struct ArrayColumn { + pub rows: u64, + pub linenos: Vec, + pub values: Vec, +} + +/// Decode the `a` (int16[32]) array `field` of every `type_name` record. +/// Bytes past end-of-file decode as zero, matching the C# partial read into +/// a zeroed buffer that backs `BinaryLog.UnionArray`. +pub fn get_array_column( + log: &LogFile, + type_name: &str, + field: &str, +) -> Result { + get_array_column_filtered(log, type_name, field, None) +} + +/// `get_array_column` limited to one `instance` value when given. +pub fn get_array_column_filtered( + log: &LogFile, + type_name: &str, + field: &str, + instance: Option, +) -> Result { + let &id = log + .name_to_id + .get(type_name) + .ok_or_else(|| ColumnError::UnknownType(type_name.into()))?; + let fmt = log + .fmts + .get(&id) + .ok_or_else(|| ColumnError::UnknownType(type_name.into()))?; + + let codes: Vec = fmt.format.chars().collect(); + if codes.len() != fmt.labels.len() { + return Err(ColumnError::MalformedFormat(type_name.into())); + } + + let pos = + fmt.labels + .iter() + .position(|l| l == field) + .ok_or_else(|| ColumnError::UnknownField { + field: field.into(), + available: fmt.labels.join(","), + })?; + if codes[pos] != 'a' { + return Err(ColumnError::NotArray { + field: field.into(), + code: codes[pos], + }); + } + + let field_offset: usize = codes[..pos] + .iter() + .map(|&c| field_size(c).unwrap_or(0)) + .sum(); + + let data = log.data(); + let linenos = collect_rows(log, type_name, id, &codes, instance)?; + + let rows = linenos.len(); + let mut values = vec![0i16; rows * ARRAY_ELEMS]; + for (row, &lineno) in linenos.iter().enumerate() { + let start = log.index.offsets[lineno as usize] as usize + 3 + field_offset; + let out = &mut values[row * ARRAY_ELEMS..(row + 1) * ARRAY_ELEMS]; + for (e, slot) in out.iter_mut().enumerate() { + let at = start + e * 2; + let b = read_zero_padded(data, at, 2); + *slot = i16::from_le_bytes([b[0], b[1]]); + } + } + + Ok(ArrayColumn { + rows: rows as u64, + linenos, + values, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn corpus(name: &str) -> LogFile { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../testdata") + .join(name); + LogFile::open(&path).expect("corpus log") + } + + /// per-instance extractions must partition the unfiltered rows exactly + #[test] + fn instance_filter_partitions_rows() { + let log = corpus("copter.bin"); + let all = get_columns(&log, "IMU", &["I", "GyrX"]).unwrap(); + let rows = all.rows as usize; + assert!(rows > 0); + + let mut instances: Vec = all.values[..rows].iter().map(|&v| v as i64).collect(); + instances.sort_unstable(); + instances.dedup(); + assert!( + instances.len() > 1, + "corpus IMU should have multiple instances" + ); + + let mut filtered_total = 0u64; + let mut seen_linenos = Vec::new(); + for &instance in &instances { + let one = get_columns_filtered(&log, "IMU", &["I", "GyrX"], Some(instance)).unwrap(); + let one_rows = one.rows as usize; + // every kept row carries the requested instance value + assert!(one.values[..one_rows].iter().all(|&v| v as i64 == instance)); + filtered_total += one.rows; + seen_linenos.extend_from_slice(&one.linenos); + } + + assert_eq!(filtered_total, all.rows); + seen_linenos.sort_unstable(); + assert_eq!(seen_linenos, all.linenos); + } + + #[test] + fn instance_filter_on_type_without_instances_errors() { + let log = corpus("copter.bin"); + // ATT has no '#' unit id in its FMTU + assert!(matches!( + get_columns_filtered(&log, "ATT", &["Roll"], Some(0)), + Err(ColumnError::NoInstanceField(_)) + )); + // an absent instance value filters to zero rows, not an error + let none = get_columns_filtered(&log, "IMU", &["GyrX"], Some(99)).unwrap(); + assert_eq!(none.rows, 0); + } + + #[test] + fn instance_field_resolution() { + let log = corpus("copter.bin"); + assert_eq!(log.instance_field("IMU").as_deref(), Some("I")); + assert_eq!(log.instance_field("GPS").as_deref(), Some("I")); + assert_eq!(log.instance_field("ATT"), None); + assert_eq!(log.instance_field("NOPE"), None); + } + + #[test] + fn half_conversion_basics() { + assert_eq!(half_to_f32(0x0000), 0.0); + assert_eq!(half_to_f32(0x3C00), 1.0); + assert_eq!(half_to_f32(0xC000), -2.0); + assert_eq!(half_to_f32(0x7BFF), 65504.0); + assert!(half_to_f32(0x7C00).is_infinite()); + } + + #[test] + fn decodes_int16_array_field() { + // synthetic ISBD-like type 0xAB "ISB" format "Ha" labels "N,x": + // one record with N=7 and x = [0,1,2,...,31] plus a second record + // whose array is cut off by end-of-file (zero-padded tail) + let mut data = vec![0xA3, 0x95, 0x80]; + let mut fmt = vec![0u8; 86]; + fmt[0] = 0xAB; + fmt[1] = (3 + 2 + 64) as u8; + fmt[2..5].copy_from_slice(b"ISB"); + fmt[6..8].copy_from_slice(b"Ha"); + fmt[22..25].copy_from_slice(b"N,x"); + data.extend_from_slice(&fmt); + + data.extend_from_slice(&[0xA3, 0x95, 0xAB]); + data.extend_from_slice(&7u16.to_le_bytes()); + for v in 0..32i16 { + data.extend_from_slice(&v.to_le_bytes()); + } + + data.extend_from_slice(&[0xA3, 0x95, 0xAB]); + data.extend_from_slice(&8u16.to_le_bytes()); + for v in 0..5i16 { + data.extend_from_slice(&(100 + v).to_le_bytes()); + } + // eof mid-array + + let dir = std::env::temp_dir().join(format!("dflog-arr-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("isb.bin"); + std::fs::write(&path, &data).unwrap(); + + let log = LogFile::open(&path).unwrap(); + let col = get_array_column(&log, "ISB", "x").unwrap(); + assert_eq!(col.rows, 2); + assert_eq!(col.linenos, vec![1, 2]); + assert_eq!(&col.values[..32], (0..32i16).collect::>().as_slice()); + assert_eq!(&col.values[32..37], &[100, 101, 102, 103, 104]); + assert!(col.values[37..].iter().all(|&v| v == 0)); + + assert!(matches!( + get_array_column(&log, "ISB", "N"), + Err(ColumnError::NotArray { .. }) + )); + + drop(log); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn decodes_scaled_and_primitive_fields() { + // synthetic log: one FMT for type 0xAA "TST" format "chL" labels "A,B,C" + // then one TST record: c=-1234 (=-12.34), h=1000, L=473566000 (=47.3566) + let mut data = vec![0xA3, 0x95, 0x80]; + let mut fmt = vec![0u8; 86]; + fmt[0] = 0xAA; + fmt[1] = 3 + 2 + 2 + 4; + fmt[2..5].copy_from_slice(b"TST"); + fmt[6..9].copy_from_slice(b"chL"); + fmt[22..27].copy_from_slice(b"A,B,C"); + data.extend_from_slice(&fmt); + data.extend_from_slice(&[0xA3, 0x95, 0xAA]); + data.extend_from_slice(&(-1234i16).to_le_bytes()); + data.extend_from_slice(&1000i16.to_le_bytes()); + data.extend_from_slice(&473566000i32.to_le_bytes()); + + let dir = std::env::temp_dir().join(format!("dflog-col-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("tst.bin"); + std::fs::write(&path, &data).unwrap(); + + let log = LogFile::open(&path).unwrap(); + let cols = get_columns(&log, "TST", &["A", "B", "C"]).unwrap(); + assert_eq!(cols.rows, 1); + assert_eq!(cols.linenos, vec![1]); + assert_eq!(cols.values, vec![-12.34, 1000.0, 47.3566]); + + drop(log); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/rust/crates/dflog-core/src/lib.rs b/rust/crates/dflog-core/src/lib.rs new file mode 100644 index 0000000000..4a2cbbb811 --- /dev/null +++ b/rust/crates/dflog-core/src/lib.rs @@ -0,0 +1,282 @@ +//! ArduPilot dataflash (`.bin`) log indexing. +//! +//! A byte-exact port of the index +//! scan performed by MissionPlanner's `BinaryLog.ReadMessageTypeOffset` / +//! `DFLogBuffer.setlinecount` (binary branch). The C# implementation is the +//! behavioral reference; every quirk below is intentional parity: +//! +//! - Records are found by scanning for the 0xA3 0x95 header with the same +//! three-state machine, so a corrupted stream resyncs at exactly the same +//! offsets as the C# scanner (including *inside* the payloads of message +//! types whose FMT has not been seen yet). +//! - A record whose type has no known length is still indexed, but its +//! payload is not skipped. +//! - An FMT record registers its target type's length even when nonsense; a +//! registered length of 1 or 2 makes later records of that type throw in +//! C# (`new byte[size - 3]`), which drops the record and resumes the scan - +//! mirrored here by not emitting the record. +//! - A record with type 0 at offset 0 is discarded (C# uses `(0, 0)` as its +//! end-of-stream sentinel). +//! - An FMT payload truncated by end-of-file is zero-padded, as the C# side's +//! partial `Stream.Read` into a zeroed array does. + +use std::collections::HashMap; +use std::fs::File; +use std::io; +use std::path::Path; + +use memmap2::Mmap; + +pub mod access; +pub mod columns; +pub mod render; +pub mod time; +pub mod units; + +pub const HEAD_BYTE1: u8 = 0xA3; +pub const HEAD_BYTE2: u8 = 0x95; +const FMT_TYPE: u8 = 0x80; +/// log_Format payload: type(1) + length(1) + name(4) + format(16) + labels(64) +const FMT_PAYLOAD_LEN: usize = 86; + +/// Index of every record found in a dataflash log. +#[derive(Debug, Default)] +pub struct LogIndex { + /// Byte offset of each record's 0xA3 header, in scan order. + pub offsets: Vec, + /// Message type byte of each record, parallel to `offsets`. + pub types: Vec, +} + +/// One FMT definition as the scanner saw it (last definition wins per name, +/// matching the C# dictionaries). +#[derive(Debug, Clone)] +pub struct FmtDef { + pub id: u8, + /// full record length including the 3 header bytes + pub length: usize, + pub name: String, + pub format: String, + pub labels: Vec, +} + +fn ascii_trim_nul(bytes: &[u8]) -> String { + let text: String = bytes.iter().map(|&b| b as char).collect(); + text.trim_matches('\0').to_string() +} + +/// A scanned log kept open for typed column queries. +#[derive(Debug)] +pub struct LogFile { + map: Mmap, + pub index: LogIndex, + /// FMT definitions by message type id, last definition per id winning. + pub fmts: HashMap, + /// message name -> type id, last FMT per name winning (C# logformat) + pub name_to_id: HashMap, +} + +impl LogFile { + pub fn open(path: &Path) -> io::Result { + let file = File::open(path)?; + // mmap of an empty file fails; give it one anonymous zero byte + let map = if file.metadata()?.len() == 0 { + memmap2::MmapMut::map_anon(1)?.make_read_only()? + } else { + // SAFETY: read-only mapping, same caveats as scan_file + unsafe { Mmap::map(&file)? } + }; + let len = file.metadata()?.len() as usize; + Self::build(map, len) + } + + /// Open an in-memory log image (fuzzing, and stream-backed callers that + /// have no file to map). + pub fn open_bytes(data: &[u8]) -> io::Result { + let mut map = memmap2::MmapMut::map_anon(data.len().max(1))?; + map[..data.len()].copy_from_slice(data); + let len = data.len(); + Self::build(map.make_read_only()?, len) + } + + fn build(map: Mmap, len: usize) -> io::Result { + let index = scan(&map[..len]); + + // re-read the FMT payloads the scan indexed (type 0x80 records) + let mut fmts = HashMap::new(); + let mut name_to_id = HashMap::new(); + let data = &map[..len]; + for (i, &t) in index.types.iter().enumerate() { + if t != FMT_TYPE { + continue; + } + let start = index.offsets[i] as usize + 3; + let take = FMT_PAYLOAD_LEN.min(data.len().saturating_sub(start)); + let mut payload = [0u8; FMT_PAYLOAD_LEN]; + payload[..take].copy_from_slice(&data[start..start + take]); + let def = FmtDef { + id: payload[0], + length: payload[1] as usize, + name: ascii_trim_nul(&payload[2..6]), + format: ascii_trim_nul(&payload[6..22]), + labels: ascii_trim_nul(&payload[22..86]) + .split(',') + .map(|s| s.trim().to_string()) + .collect(), + }; + name_to_id.insert(def.name.clone(), def.id); + fmts.insert(def.id, def); + } + + Ok(LogFile { + map, + index, + fmts, + name_to_id, + }) + } + + pub fn data(&self) -> &[u8] { + &self.map + } +} + +impl LogIndex { + pub fn len(&self) -> usize { + self.offsets.len() + } + + pub fn is_empty(&self) -> bool { + self.offsets.is_empty() + } +} + +/// Scan a complete in-memory log image. +pub fn scan(data: &[u8]) -> LogIndex { + // record length per message type, learned from FMT records in scan order + let mut lengths = [0usize; 256]; + let mut index = LogIndex::default(); + let len = data.len(); + let mut pos = 0usize; + + 'outer: while pos < len { + // header state machine, identical to the C# three-state scanner + let mut step = 0u8; + loop { + if pos >= len { + break 'outer; + } + let b = data[pos]; + pos += 1; + match step { + 0 => { + if b == HEAD_BYTE1 { + step = 1; + } + } + 1 => { + if b == HEAD_BYTE2 { + step = 2; + } else { + step = 0; + } + } + _ => { + let start = (pos - 3) as u64; + if b == FMT_TYPE { + let take = FMT_PAYLOAD_LEN.min(len - pos); + let mut payload = [0u8; FMT_PAYLOAD_LEN]; + payload[..take].copy_from_slice(&data[pos..pos + take]); + pos += take; + lengths[payload[0] as usize] = payload[1] as usize; + } else { + let size = lengths[b as usize]; + if size == 0 { + // unknown type: indexed, payload not skipped + } else if size < 3 { + // C# throws on new byte[size - 3]: record dropped + break; + } else { + pos = (pos + (size - 3)).min(len); + } + } + + if b == 0 && start == 0 { + // C# end-of-stream sentinel value: discarded + break; + } + + index.offsets.push(start); + index.types.push(b); + break; + } + } + } + } + + index +} + +/// Scan a log file via a memory map. +pub fn scan_file(path: &Path) -> io::Result { + let file = File::open(path)?; + if file.metadata()?.len() == 0 { + return Ok(LogIndex::default()); + } + // SAFETY: the mapping is read-only and lives only for the duration of the + // scan; concurrent truncation of the underlying file is undefined in the + // same way it is for the C# stream-based scanner. + let map = unsafe { Mmap::map(&file)? }; + Ok(scan(&map)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn testdata(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../testdata") + .join(name) + } + + /// Expected counts come from the phase-0 golden snapshots of the C# + /// parser (tests/MissionPlanner.Utilities.Tests/testdata/goldens). + #[test] + fn corpus_counts_match_csharp_goldens() { + for (name, expected) in [ + ("copter.bin", 31867u64), + ("plane.bin", 20885), + ("rover.bin", 26335), + ] { + let index = scan_file(&testdata(name)).expect(name); + assert_eq!(index.len() as u64, expected, "{name}"); + assert_eq!(index.offsets.len(), index.types.len(), "{name}"); + } + } + + #[test] + fn empty_input_yields_empty_index() { + assert!(scan(&[]).is_empty()); + assert!(scan(&[HEAD_BYTE1]).is_empty()); + assert!(scan(&[HEAD_BYTE1, HEAD_BYTE2]).is_empty()); + } + + #[test] + fn type_zero_at_offset_zero_is_discarded() { + // A3 95 00 at the very start matches the C# EOF sentinel and is dropped + let index = scan(&[HEAD_BYTE1, HEAD_BYTE2, 0x00, 0xFF]); + assert!(index.is_empty()); + } + + #[test] + fn unknown_type_indexed_without_payload_skip() { + // two adjacent unknown-type records; the second header begins + // immediately after the first type byte + let data = [HEAD_BYTE1, HEAD_BYTE2, 0x42, HEAD_BYTE1, HEAD_BYTE2, 0x43]; + let index = scan(&data); + assert_eq!(index.offsets, vec![0, 3]); + assert_eq!(index.types, vec![0x42, 0x43]); + } +} diff --git a/rust/crates/dflog-core/src/render.rs b/rust/crates/dflog-core/src/render.rs new file mode 100644 index 0000000000..29a9aa3e73 --- /dev/null +++ b/rust/crates/dflog-core/src/render.rs @@ -0,0 +1,531 @@ +//! Text rendering of a dataflash log, byte-compatible with the C# +//! `BinaryLog.ConvertBin`. +//! +//! The reference is `BinaryLog.ReadMessage` running headless (no +//! `onFlightMode` subscriber, so `M` fields render as the raw mode number - +//! inside the full app the legacy path substitutes mode names, a documented +//! divergence). Every quirk here is intentional parity: +//! +//! - one pass with the same resync state machine as the index scan, learning +//! FMT progressively; records of a type whose FMT has not been seen yet +//! produce no output and are rescanned mid-payload +//! - numbers format like .NET's `IConvertible.ToString(InvariantCulture)`: +//! integers plainly; float via legacy "G7", double (and the c/C/e/E/L +//! scaled fields) via legacy "G15" - fixed notation unless the decimal +//! exponent is < -4 or >= the precision, then `d.dddE+XX` +//! - `n`/`N` strings map non-ASCII bytes to '?' (ASCIIEncoding) and trim NULs +//! - `Z` additionally escapes backslash, \n, \r, \t and control chars as \xHH +//! - `a` renders as "[s0 s1 ... s31]" +//! - an unknown format char contributes an empty field and advances zero +//! bytes, exactly like the C# `(null, 0)` decoder result + +use std::io::{self, Write}; + +use crate::{FMT_PAYLOAD_LEN, FMT_TYPE, HEAD_BYTE1, HEAD_BYTE2}; + +#[derive(Debug)] +pub struct RenderStats { + pub records: u64, + pub dropped: u64, +} + +fn field_size(code: u8) -> usize { + match code { + b'b' | b'B' | b'M' => 1, + b'h' | b'H' | b'c' | b'C' | b'g' => 2, + b'i' | b'I' | b'e' | b'E' | b'L' | b'f' => 4, + b'q' | b'Q' | b'd' => 8, + b'n' => 4, + b'N' => 16, + b'Z' => 64, + b'a' => 64, + _ => 0, + } +} + +/// lay out a trimmed significant-digit string under the .NET "G" rules: +/// fixed notation unless the decimal exponent is < -4 or >= the precision, +/// then d.dddE+XX with at least two exponent digits +#[expect( + clippy::string_slice, + reason = "`digits` is ASCII 0-9 by construction (extracted from `format!(\"{:.e}\")` output), so byte indexes are always char boundaries" +)] +fn layout_digits(negative: bool, digits: &str, exponent: i32, prec: usize) -> String { + let mut out = String::new(); + if negative { + out.push('-'); + } + + if exponent < -4 || exponent >= prec as i32 { + out.push_str(&digits[..1]); + if digits.len() > 1 { + out.push('.'); + out.push_str(&digits[1..]); + } + out.push('E'); + if exponent < 0 { + out.push('-'); + } else { + out.push('+'); + } + out.push_str(&format!("{:02}", exponent.abs())); + } else if exponent >= 0 { + let int_len = exponent as usize + 1; + if digits.len() <= int_len { + out.push_str(digits); + out.push_str(&"0".repeat(int_len - digits.len())); + } else { + out.push_str(&digits[..int_len]); + out.push('.'); + out.push_str(&digits[int_len..]); + } + } else { + out.push_str("0."); + out.push_str(&"0".repeat((-exponent - 1) as usize)); + out.push_str(digits); + } + + out +} + +/// mantissa/exponent of a Rust `{:e}` / `{:.*e}` string +fn parse_sci(formatted: &str) -> (bool, String, i32) { + let (mantissa, exp_str) = formatted.split_once('e').expect("exponent"); + let exponent: i32 = exp_str.parse().expect("exponent value"); + let negative = mantissa.starts_with('-'); + let digits: String = mantissa.chars().filter(|c| c.is_ascii_digit()).collect(); + (negative, digits, exponent) +} + +/// reference path: 60 correctly-rounded significant digits, then round the +/// digit string half AWAY FROM ZERO at `prec` - the legacy .NET Framework +/// behavior (a shortest conversion rounds ties to even instead; exact +/// midpoints such as the float 94502.125 must format as 94502.13). The +/// 60-digit margin makes the >=5 cutoff test exact for every double: a +/// non-tie double cannot sit within 1e-40 of a decimal midpoint. +pub(crate) fn format_significant_exact(value: f64, prec: usize) -> String { + let (negative, digit_str, mut exponent) = parse_sci(&format!("{:.59e}", value)); + let mut digits: Vec = digit_str.bytes().map(|b| b - b'0').collect(); + + if digits.len() > prec { + let round_up = digits[prec] >= 5; + digits.truncate(prec); + if round_up { + let mut i = prec; + loop { + if i == 0 { + digits.insert(0, 1); + digits.truncate(prec); + exponent += 1; + break; + } + i -= 1; + if digits[i] == 9 { + digits[i] = 0; + } else { + digits[i] += 1; + break; + } + } + } + } + + while digits.len() > 1 && *digits.last().unwrap() == 0 { + digits.pop(); + } + + let digits: String = digits.iter().map(|&d| (d + b'0') as char).collect(); + layout_digits(negative, &digits, exponent, prec) +} + +/// The shortest round-trip digits equal the legacy half-away rounding of the +/// exact expansion at `prec` digits only when the binary grid is finer than +/// half the final decimal digit: |v - S| <= ulp/2 must be strictly below +/// 0.5 * 10^(exp - prec + 1). This excludes subnormals (coarse grid, short +/// shortest, long true expansion), the marginal near-9.999999 mantissas, and +/// exact decimal ties (which sit a full half-unit away from any shorter S). +/// The 0.9 margin absorbs powi rounding. +fn shortest_is_exact(ulp: f64, exponent: i32, prec: usize) -> bool { + ulp < 0.9 * 10f64.powi(exponent - prec as i32 + 1) +} + +fn ulp_f64(value: f64) -> f64 { + let bits = value.abs().to_bits(); + f64::from_bits(bits + 1) - f64::from_bits(bits) +} + +fn ulp_f32(value: f32) -> f64 { + let bits = value.abs().to_bits(); + (f32::from_bits(bits + 1) - f32::from_bits(bits)) as f64 +} + +/// legacy .NET Framework "G" format with `prec` significant digits (G15 for +/// double), invariant culture. Fast path: the shortest round-trip digits, +/// when they are provably the legacy output (see shortest_is_exact); +/// otherwise the 60-digit reference path. Verified equivalent by the +/// millions-of-bit-patterns property test below. +pub fn format_significant(value: f64, prec: usize) -> String { + if value.is_nan() { + return "NaN".into(); + } + if value.is_infinite() { + return if value > 0.0 { + "Infinity".into() + } else { + "-Infinity".into() + }; + } + if value == 0.0 { + return "0".into(); + } + + let (negative, digits, exponent) = parse_sci(&format!("{:e}", value)); + if digits.len() <= prec && shortest_is_exact(ulp_f64(value), exponent, prec) { + return layout_digits(negative, &digits, exponent, prec); + } + + format_significant_exact(value, prec) +} + +/// the C# float path: G7 of the widened double. The fast test uses the f32 +/// shortest representation and the f32 ulp - the f64 shortest of the same +/// value is ~17 digits and would never qualify. +fn format_significant_f32(value: f32) -> String { + if value.is_nan() { + return "NaN".into(); + } + if value.is_infinite() { + return if value > 0.0 { + "Infinity".into() + } else { + "-Infinity".into() + }; + } + if value == 0.0 { + return "0".into(); + } + + let (negative, digits, exponent) = parse_sci(&format!("{:e}", value)); + if digits.len() <= 7 && shortest_is_exact(ulp_f32(value), exponent, 7) { + return layout_digits(negative, &digits, exponent, 7); + } + + format_significant_exact(value as f64, 7) +} + +/// ASCIIEncoding semantics: bytes > 0x7F become '?', then trim NULs +fn ascii_lossy_trim(bytes: &[u8]) -> String { + let mapped: String = bytes + .iter() + .map(|&b| if b > 0x7F { '?' } else { b as char }) + .collect(); + mapped.trim_matches('\0').to_string() +} + +/// None when the trimmed string is empty: the C# escape chain ends in a +/// seedless LINQ Aggregate, which throws on an empty sequence and drops the +/// whole record line +fn escape_z(bytes: &[u8]) -> Option { + let s = ascii_lossy_trim(bytes); + let s = s.replace('\\', "\\\\"); + let s = s.replace('\n', "\\n"); + let s = s.replace('\r', "\\r"); + let s = s.replace('\t', "\\t"); + if s.is_empty() { + return None; + } + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if (c as u32) < 32 || (c as u32) > 127 { + out.push_str(&format!("\\x{:02X}", c as u32 as u8)); + } else { + out.push(c); + } + } + Some(out) +} + +/// stack-only zero-padded field bytes (fields are at most 64 bytes) +fn zero_padded(payload: &[u8], at: usize, len: usize) -> [u8; 64] { + let mut buf = [0u8; 64]; + if at < payload.len() { + let take = len.min(payload.len() - at).min(64); + buf[..take].copy_from_slice(&payload[at..at + take]); + } + buf +} + +/// false = the whole record line must be dropped (empty-Z quirk) +fn render_field(code: u8, payload: &[u8], at: usize, out: &mut String) -> bool { + use std::fmt::Write; + + let b = zero_padded(payload, at, field_size(code).max(1)); + match code { + b'b' => write!(out, "{}", b[0] as i8).unwrap(), + b'B' | b'M' => write!(out, "{}", b[0]).unwrap(), + b'h' => write!(out, "{}", i16::from_le_bytes([b[0], b[1]])).unwrap(), + b'H' => write!(out, "{}", u16::from_le_bytes([b[0], b[1]])).unwrap(), + b'i' => write!(out, "{}", i32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap(), + b'I' => write!(out, "{}", u32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap(), + b'q' => write!(out, "{}", i64::from_le_bytes(b[..8].try_into().unwrap())).unwrap(), + b'Q' => write!(out, "{}", u64::from_le_bytes(b[..8].try_into().unwrap())).unwrap(), + b'f' => out.push_str(&format_significant_f32(f32::from_le_bytes([ + b[0], b[1], b[2], b[3], + ]))), + b'd' => out.push_str(&format_significant( + f64::from_le_bytes(b[..8].try_into().unwrap()), + 15, + )), + b'g' => out.push_str(&format_significant_f32(crate::columns::half_to_f32( + u16::from_le_bytes([b[0], b[1]]), + ))), + b'c' => out.push_str(&format_significant( + i16::from_le_bytes([b[0], b[1]]) as f64 / 100.0, + 15, + )), + b'C' => out.push_str(&format_significant( + u16::from_le_bytes([b[0], b[1]]) as f64 / 100.0, + 15, + )), + b'e' => out.push_str(&format_significant( + i32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64 / 100.0, + 15, + )), + b'E' => out.push_str(&format_significant( + u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64 / 100.0, + 15, + )), + b'L' => out.push_str(&format_significant( + i32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64 / 10000000.0, + 15, + )), + b'n' => out.push_str(&ascii_lossy_trim(&b[..4])), + b'N' => out.push_str(&ascii_lossy_trim(&b[..16])), + b'Z' => match escape_z(&b[..64]) { + Some(s) => out.push_str(&s), + None => return false, + }, + b'a' => { + out.push('['); + for (i, pair) in b.as_chunks::<2>().0.iter().enumerate() { + if i > 0 { + out.push(' '); + } + write!(out, "{}", i16::from_le_bytes(*pair)).unwrap(); + } + out.push(']'); + } + _ => {} // unknown code: empty field, zero bytes consumed + } + + true +} + +#[derive(Clone, Default)] +struct FmtEntry { + length: usize, + name: String, + format: Vec, +} + +/// One-pass conversion of a binary log to the ConvertBin text form. +pub fn convert(data: &[u8], out: &mut W) -> io::Result { + let mut fmts: Vec = vec![FmtEntry::default(); 256]; + let mut stats = RenderStats { + records: 0, + dropped: 0, + }; + let len = data.len(); + let mut pos = 0usize; + let mut line = String::new(); + + let mut step = 0u8; + while pos < len { + let b = data[pos]; + pos += 1; + match step { + 0 => { + if b == HEAD_BYTE1 { + step = 1; + } + } + 1 => { + step = if b == HEAD_BYTE2 { 2 } else { 0 }; + } + _ => { + step = 0; + if b == FMT_TYPE { + let take = FMT_PAYLOAD_LEN.min(len - pos); + let mut payload = [0u8; FMT_PAYLOAD_LEN]; + payload[..take].copy_from_slice(&data[pos..pos + take]); + pos += take; + + let entry = FmtEntry { + length: payload[1] as usize, + name: ascii_lossy_trim(&payload[2..6]), + format: ascii_lossy_trim(&payload[6..22]).into_bytes(), + }; + fmts[payload[0] as usize] = entry; + + line.clear(); + line.push_str("FMT, "); + line.push_str(&payload[0].to_string()); + line.push_str(", "); + line.push_str(&payload[1].to_string()); + line.push_str(", "); + line.push_str(&ascii_lossy_trim(&payload[2..6])); + line.push_str(", "); + line.push_str(&ascii_lossy_trim(&payload[6..22])); + line.push_str(", "); + line.push_str(&ascii_lossy_trim(&payload[22..86])); + line.push_str("\r\n"); + out.write_all(line.as_bytes())?; + stats.records += 1; + } else { + let fmt = &fmts[b as usize]; + if fmt.length == 0 { + // unknown type: no output, rescan inside its payload + stats.dropped += 1; + continue; + } + if fmt.length < 3 { + // C# throws on new byte[size - 3]: record dropped + stats.dropped += 1; + continue; + } + + let size = fmt.length - 3; + let take = size.min(len - pos); + let payload = &data[pos..pos + take]; + pos += take; + + line.clear(); + line.push_str(&fmt.name); + let mut at = 0usize; + let mut ok = true; + for &code in &fmt.format { + line.push_str(", "); + if !render_field(code, payload, at, &mut line) { + ok = false; + break; + } + at += field_size(code); + } + + if ok { + line.push_str("\r\n"); + out.write_all(line.as_bytes())?; + stats.records += 1; + } else { + stats.dropped += 1; + } + } + } + } + } + + Ok(stats) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn g_format_basics() { + assert_eq!(format_significant(0.0, 15), "0"); + assert_eq!(format_significant(1.0, 15), "1"); + assert_eq!(format_significant(-12.34, 15), "-12.34"); + assert_eq!(format_significant(0.1, 15), "0.1"); + assert_eq!( + format_significant(1234567890123456.0, 15), + "1.23456789012346E+15" + ); + assert_eq!(format_significant(0.00001, 15), "1E-05"); + assert_eq!(format_significant(47.3566, 15), "47.3566"); + assert_eq!(format_significant(f64::NAN, 15), "NaN"); + assert_eq!(format_significant(-0.0, 15), "0"); + } + + #[test] + fn g7_float_basics() { + assert_eq!(format_significant_f32(0.0), "0"); + assert_eq!(format_significant_f32(1.5), "1.5"); + assert_eq!(format_significant_f32(1.2345678), "1.234568"); + assert_eq!(format_significant_f32(12345678.0), "1.234568E+07"); + assert_eq!(format_significant_f32(-0.001), "-0.001"); + assert_eq!(format_significant_f32(0.00001), "1E-05"); + // exact decimal midpoint: legacy rounds half away from zero + assert_eq!(format_significant_f32(94502.125), "94502.13"); + assert_eq!(format_significant_f32(-94502.125), "-94502.13"); + } + + /// the fast (shortest-first) path must agree with the 60-digit reference + /// for arbitrary bit patterns and for constructed near-tie values + #[test] + fn fast_path_matches_exact_path() { + let mut x: u64 = 0x243F6A8885A308D3; + let mut next = move || { + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + x.wrapping_mul(0x2545F4914F6CDD1D) + }; + + for _ in 0..500_000u32 { + let bits = next(); + let d = f64::from_bits(bits); + if d.is_finite() && d != 0.0 { + assert_eq!( + format_significant(d, 15), + format_significant_exact(d, 15), + "f64 {bits:#x}" + ); + } + + let fbits = bits as u32; + let f = f32::from_bits(fbits); + if f.is_finite() && f != 0.0 { + assert_eq!( + format_significant_f32(f), + format_significant_exact(f as f64, 7), + "f32 {fbits:#x}" + ); + } + } + + // scaled-field shapes (int/100) and decimal midpoints at the cutoff + for i in (i32::MIN..i32::MAX).step_by(9_999_991) { + let d = i as f64 / 100.0; + if d != 0.0 { + assert_eq!( + format_significant(d, 15), + format_significant_exact(d, 15), + "scaled {i}" + ); + } + } + + for mid in [94502.125f32, 0.15625, 1.5, 2.5e-7, 123456.75, -94502.125] { + assert_eq!( + format_significant_f32(mid), + format_significant_exact(mid as f64, 7), + "midpoint {mid}" + ); + } + } + + #[test] + fn z_escaping() { + let mut bytes = [0u8; 64]; + bytes[..7].copy_from_slice(b"a\\b\nc\td"); + assert_eq!(escape_z(&bytes).as_deref(), Some("a\\\\b\\nc\\td")); + let mut ctrl = [0u8; 64]; + ctrl[0] = b'x'; + ctrl[1] = 0x1B; + ctrl[2] = b'y'; + assert_eq!(escape_z(&ctrl).as_deref(), Some("x\\x1By")); + // empty after trim: the record line is dropped (C# Aggregate quirk) + assert_eq!(escape_z(&[0u8; 64]), None); + } +} diff --git a/rust/crates/dflog-core/src/time.rs b/rust/crates/dflog-core/src/time.rs new file mode 100644 index 0000000000..72316a6a1d --- /dev/null +++ b/rust/crates/dflog-core/src/time.rs @@ -0,0 +1,189 @@ +//! Wall-clock time correlation for dataflash logs: derives a UTC base from +//! the first valid GPS fix, so board-time fields (TimeUS/TimeMS) can be +//! mapped to real timestamps - the same correlation Mission Planner's DFLog +//! establishes, with its quirks preserved where they matter: +//! +//! - field offsets are resolved via the `GPS` format even for GPS2/GPSB +//! records (they share the layout in practice) +//! - a Status field of 0/1/2 (no 3D fix) rejects the record; an absent +//! Status field does not +//! - the board-time offset prefers TimeUS with INTEGER division by 1000, +//! falling back to the legacy `T` field +//! - at most 2000 GPS records are examined, like the managed warm-up pass +//! +//! One deliberate divergence: leap seconds come from the LOG's own GPS date +//! (historical table) rather than the host's current date, which the managed +//! code uses. Equal for every log recorded since 2017. + +use crate::LogFile; + +/// GPS->UTC leap second table: (gps week the count became effective, count). +/// GPS time began 1980-01-06 already 0 ahead; entries are cumulative. +const LEAP_TABLE: &[(i64, i64)] = &[ + (77, 1), // 1981-07 + (129, 2), // 1982-07 + (181, 3), // 1983-07 + (286, 4), // 1985-07 + (416, 5), // 1988-01 + (521, 6), // 1990-01 + (573, 7), // 1991-01 + (651, 8), // 1992-07 + (703, 9), // 1993-07 + (755, 10), // 1994-07 + (834, 11), // 1996-01 + (912, 12), // 1997-07 + (990, 13), // 1999-01 + (1356, 14), // 2006-01 + (1512, 15), // 2009-01 + (1695, 16), // 2012-07 + (1851, 17), // 2015-07 + (1930, 18), // 2017-01 +]; + +fn leap_seconds_for_week(week: i64) -> i64 { + let mut leap = 0; + for &(effective_week, count) in LEAP_TABLE { + if week >= effective_week { + leap = count; + } + } + leap +} + +/// unix epoch milliseconds of the GPS epoch 1980-01-06 00:00:00 UTC +const GPS_EPOCH_UNIX_MS: i64 = 315_964_800_000; + +/// the established correlation: a UTC base plus the board-time offset it +/// corresponds to +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TimeBase { + /// unix ms (UTC) of the correlation point + pub gps_start_unix_ms: i64, + /// board milliseconds at that point + pub ms_offset: i64, +} + +impl TimeBase { + /// board milliseconds -> unix ms (UTC) + pub fn wall_clock_unix_ms(&self, board_ms: f64) -> f64 { + self.gps_start_unix_ms as f64 + (board_ms - self.ms_offset as f64) + } +} + +impl LogFile { + /// Establish the wall-clock correlation from the first valid GPS fix + /// (GPS, GPS2 or GPSB records, in log order). None when the log has no + /// usable fix. + pub fn time_base(&self) -> Option { + // quirk: offsets resolved via the GPS format regardless of record type + let gps_fmt = self + .name_to_id + .get("GPS") + .and_then(|id| self.fmts.get(id))?; + + let label_pos = |name: &str| gps_fmt.labels.iter().position(|l| l == name); + + let status_pos = label_pos("Status"); + let ms_pos = label_pos("TimeMS").or_else(|| label_pos("GMS"))?; + let week_pos = label_pos("Week").or_else(|| label_pos("GWk"))?; + let timeus_pos = label_pos("TimeUS"); + let t_pos = label_pos("T"); + + let field = |record: &crate::access::Record, pos: usize| { + gps_fmt + .labels + .get(pos) + .and_then(|label| record.value(label)) + .and_then(|v| v.as_f64()) + }; + + for (examined, record) in self.records_of(&["GPS", "GPS2", "GPSB"]).enumerate() { + if examined >= 2000 { + break; + } + + if let Some(pos) = status_pos { + match field(&record, pos) { + Some(status) if status <= 2.0 => continue, + _ => {} + } + } + + let Some(week) = field(&record, week_pos) else { + continue; + }; + let Some(gms) = field(&record, ms_pos) else { + continue; + }; + + let week = week as i64; + let sec = gms / 1000.0; + if !(0..=5000).contains(&week) || !(0.0..(60.0 * 60.0 * 24.0 * 7.0)).contains(&sec) { + continue; + } + + let leap = leap_seconds_for_week(week); + let gps_start_unix_ms = + GPS_EPOCH_UNIX_MS + week * 7 * 86_400_000 + gms as i64 - leap * 1000; + + // board offset from the same record: TimeUS/1000 (integer + // division, like the managed long.Parse(...)/1000), else T + let ms_offset = if let Some(pos) = timeus_pos { + (field(&record, pos)? as i64) / 1000 + } else if let Some(pos) = t_pos { + field(&record, pos)? as i64 + } else { + 0 + }; + + return Some(TimeBase { + gps_start_unix_ms, + ms_offset, + }); + } + + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn corpus(name: &str) -> LogFile { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../testdata") + .join(name); + LogFile::open(&path).expect("corpus log") + } + + #[test] + fn leap_table_boundaries() { + assert_eq!(leap_seconds_for_week(0), 0); + assert_eq!(leap_seconds_for_week(76), 0); + assert_eq!(leap_seconds_for_week(77), 1); + assert_eq!(leap_seconds_for_week(1929), 17); + assert_eq!(leap_seconds_for_week(1930), 18); + assert_eq!(leap_seconds_for_week(4000), 18); + } + + #[test] + fn corpus_logs_have_a_time_base() { + for name in ["copter.bin", "plane.bin", "rover.bin", "copter-isbd.bin"] { + let log = corpus(name); + let base = log + .time_base() + .unwrap_or_else(|| panic!("{name}: no time base")); + // sanity: SITL simulates recent dates; well after 2020-01-01 UTC + assert!( + base.gps_start_unix_ms > 1_577_836_800_000, + "{name}: {base:?}" + ); + assert!(base.ms_offset > 0, "{name}: {base:?}"); + // monotonic mapping + let t0 = base.wall_clock_unix_ms(base.ms_offset as f64); + assert_eq!(t0 as i64, base.gps_start_unix_ms); + } + } +} diff --git a/rust/crates/dflog-core/src/units.rs b/rust/crates/dflog-core/src/units.rs new file mode 100644 index 0000000000..b67d73f8b0 --- /dev/null +++ b/rust/crates/dflog-core/src/units.rs @@ -0,0 +1,239 @@ +//! Units and multipliers metadata from the log's own UNIT / MULT / FMTU +//! records, exposed the way pymavlink's DFReader models it: pure metadata, +//! looked up per message field. Nothing here changes how values decode - +//! `access` and `columns` keep the legacy scaling; consumers apply +//! multipliers themselves if they want SI values. +//! +//! Semantics as written by ArduPilot: +//! - UNIT: unit id char -> unit name (e.g. 'd' -> "deg"). +//! - MULT: multiplier id char -> factor (e.g. 'B' -> 0.01); the table the +//! autopilot logs maps '-' to 0 and '?' to 1. +//! - FMTU: message type id -> one unit id and one mult id per field, in +//! field order; '-' means "none". FMT's format string is 16 chars, so +//! the 16-char FMTU strings always cover every field. + +use std::collections::HashMap; + +use crate::access::Value; +use crate::LogFile; + +/// units/multiplier metadata for one message field +#[derive(Debug, Clone, PartialEq)] +pub struct FieldMeta { + /// unit id char from FMTU; None when the log marks the field '-' + pub unit_id: Option, + /// unit name resolved through the UNIT table + pub unit: Option, + /// multiplier id char from FMTU; None when the log marks the field '-' + pub mult_id: Option, + /// factor resolved through the MULT table + pub multiplier: Option, +} + +/// the log's UNIT/MULT/FMTU tables, built once per log +#[derive(Debug, Default)] +pub struct UnitsTable { + /// unit id char -> unit name (UNIT records, last definition wins) + pub unit_names: HashMap, + /// multiplier id char -> factor (MULT records, last definition wins) + pub multipliers: HashMap, + /// message type id -> per-field unit id chars (FMTU records) + fmt_unit_ids: HashMap>, + /// message type id -> per-field multiplier id chars (FMTU records) + fmt_mult_ids: HashMap>, +} + +impl UnitsTable { + /// true when the log carries no units metadata at all + pub fn is_empty(&self) -> bool { + self.fmt_unit_ids.is_empty() && self.fmt_mult_ids.is_empty() + } + + /// index (format order) of the type's instance field - the first field + /// whose FMTU unit id is '#'; None when the type has no instances + pub fn instance_field_index(&self, type_id: u8) -> Option { + self.fmt_unit_ids + .get(&type_id)? + .iter() + .position(|&c| c == '#') + } + + /// metadata for field `index` (format order) of message type `type_id`; + /// fields the log does not annotate come back all-None + pub fn field_meta(&self, type_id: u8, index: usize) -> FieldMeta { + let id_at = |ids: &HashMap>| { + ids.get(&type_id) + .and_then(|chars| chars.get(index)) + .copied() + .filter(|&c| c != '-') + }; + + let unit_id = id_at(&self.fmt_unit_ids); + let mult_id = id_at(&self.fmt_mult_ids); + FieldMeta { + unit_id, + unit: unit_id.and_then(|c| self.unit_names.get(&c).cloned()), + mult_id, + multiplier: mult_id.and_then(|c| self.multipliers.get(&c).copied()), + } + } +} + +fn id_char(value: Option) -> Option { + // UNIT/MULT ids are logged as 'b' (int8); anything outside ASCII is + // not a usable id + match value { + Some(Value::I64(v)) if (0..=0x7F).contains(&v) => Some(v as u8 as char), + _ => None, + } +} + +impl LogFile { + /// build the units/multipliers tables from the log's UNIT, MULT and + /// FMTU records; empty tables when the log predates units metadata + pub fn units(&self) -> UnitsTable { + let mut table = UnitsTable::default(); + + for record in self.records_of(&["UNIT", "MULT", "FMTU"]) { + match record.type_name() { + "UNIT" => { + if let (Some(id), Some(Value::Str(label))) = + (id_char(record.value("Id")), record.value("Label")) + { + table.unit_names.insert(id, label); + } + } + "MULT" => { + if let (Some(id), Some(Value::F64(mult))) = + (id_char(record.value("Id")), record.value("Mult")) + { + table.multipliers.insert(id, mult); + } + } + _ => { + let fmt_type = match record.value("FmtType") { + Some(Value::U64(v)) if v <= 0xFF => v as u8, + _ => continue, + }; + if let Some(Value::Str(ids)) = record.value("UnitIds") { + table.fmt_unit_ids.insert(fmt_type, ids.chars().collect()); + } + if let Some(Value::Str(ids)) = record.value("MultIds") { + table.fmt_mult_ids.insert(fmt_type, ids.chars().collect()); + } + } + } + } + + table + } + + /// convenience lookup: metadata for `field` of message `name` (first + /// matching label, like the record accessors); None when the message + /// or field is unknown + pub fn field_meta(&self, name: &str, field: &str) -> Option { + let &id = self.name_to_id.get(name)?; + let fmt = self.fmts.get(&id)?; + let index = fmt.labels.iter().position(|l| l == field)?; + Some(self.units().field_meta(id, index)) + } + + /// label of the instance field of message `name` (e.g. "I" for IMU), or + /// None when the message is unknown or has no instances. Builds the + /// units table on each call - cache it for repeated lookups. + pub fn instance_field(&self, name: &str) -> Option { + let &id = self.name_to_id.get(name)?; + let fmt = self.fmts.get(&id)?; + let index = self.units().instance_field_index(id)?; + fmt.labels.get(index).cloned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn corpus(name: &str) -> LogFile { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../testdata") + .join(name); + LogFile::open(&path).expect("corpus log") + } + + /// values pinned from the ArduPilot-written tables in the SITL corpus + #[test] + fn known_fields_resolve_units_and_multipliers() { + let log = corpus("copter.bin"); + + let lat = log.field_meta("GPS", "Lat").expect("GPS.Lat"); + assert_eq!(lat.unit_id, Some('D')); + assert_eq!(lat.unit.as_deref(), Some("deglatitude")); + // ArduPilot logs MULT factors through a float cast; the table + // reports exactly what the log carries + assert_eq!(lat.mult_id, Some('G')); + assert_eq!(lat.multiplier, Some(1e-7f32 as f64)); + + let time_us = log.field_meta("ATT", "TimeUS").expect("ATT.TimeUS"); + assert_eq!(time_us.unit.as_deref(), Some("s")); + assert_eq!(time_us.multiplier, Some(1e-6f32 as f64)); + } + + /// '-' in FMTU means "no annotation", not a table lookup + #[test] + fn dash_ids_resolve_to_none() { + let log = corpus("copter.bin"); + let table = log.units(); + + // FMT's own FMTU row is "-b---": only field 1 has a unit + let fmt_id = log.name_to_id["FMT"]; + let type_field = table.field_meta(fmt_id, 0); + assert_eq!(type_field.unit_id, None); + assert_eq!(type_field.unit, None); + assert_eq!(type_field.mult_id, None); + assert_eq!(type_field.multiplier, None); + assert_eq!(table.field_meta(fmt_id, 1).unit_id, Some('b')); + } + + #[test] + fn corpus_logs_all_carry_units_metadata() { + for name in ["copter.bin", "plane.bin", "rover.bin", "copter-isbd.bin"] { + let log = corpus(name); + let table = log.units(); + assert!(!table.is_empty(), "{name}: no FMTU records"); + assert!(!table.unit_names.is_empty(), "{name}: no UNIT records"); + assert!(!table.multipliers.is_empty(), "{name}: no MULT records"); + + // the autopilot's own convention rows + assert_eq!( + table.unit_names.get(&'d').map(String::as_str), + Some("deg"), + "{name}" + ); + assert_eq!(table.multipliers.get(&'0').copied(), Some(1.0), "{name}"); + } + } + + /// unknown messages, unknown fields and out-of-range indexes are safe + #[test] + fn missing_metadata_is_none_not_panic() { + let log = corpus("copter.bin"); + assert!(log.field_meta("NOPE", "X").is_none()); + assert!(log.field_meta("GPS", "NoSuchField").is_none()); + + let table = log.units(); + let meta = table.field_meta(0xFE, 99); + assert_eq!( + meta, + FieldMeta { + unit_id: None, + unit: None, + mult_id: None, + multiplier: None + } + ); + + let empty = LogFile::open_bytes(&[]).unwrap(); + assert!(empty.units().is_empty()); + } +} diff --git a/rust/crates/dflog-core/tests/fuzz_smoke.rs b/rust/crates/dflog-core/tests/fuzz_smoke.rs new file mode 100644 index 0000000000..5110025217 --- /dev/null +++ b/rust/crates/dflog-core/tests/fuzz_smoke.rs @@ -0,0 +1,196 @@ +//! Deterministic mutation fuzzing: +//! a fast always-on regression net asserting that no input - corrupted, +//! truncated, spliced or random - can panic the scanner, the column +//! decoders or the text renderer. The coverage-guided campaign lives in +//! rust/fuzz (cargo fuzz, run under WSL); anything it finds gets distilled +//! into a case here. + +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use dflog_core::{columns, render, scan, LogFile}; + +/// xorshift64* - deterministic, no external deps, no wall clock +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545F4914F6CDD1D) + } + + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +fn mutate(data: &mut Vec, rng: &mut Rng) { + for _ in 0..1 + rng.below(16) { + match rng.below(6) { + 0 => { + // flip a byte + if !data.is_empty() { + let at = rng.below(data.len()); + data[at] ^= rng.next() as u8; + } + } + 1 => { + // truncate + data.truncate(rng.below(data.len() + 1)); + } + 2 => { + // insert random bytes + let at = rng.below(data.len() + 1); + for _ in 0..1 + rng.below(8) { + data.insert(at, rng.next() as u8); + } + } + 3 => { + // delete a span + if !data.is_empty() { + let at = rng.below(data.len()); + let n = 1 + rng.below((data.len() - at).min(32)); + data.drain(at..at + n); + } + } + 4 => { + // duplicate a span elsewhere + if data.len() >= 4 { + let at = rng.below(data.len() - 3); + let n = 1 + rng.below((data.len() - at).min(64)); + let span: Vec = data[at..at + n].to_vec(); + let to = rng.below(data.len() + 1); + for (i, b) in span.into_iter().enumerate() { + data.insert(to + i, b); + } + } + } + _ => { + // plant a header, sometimes an FMT header + let at = rng.below(data.len() + 1); + data.insert(at, 0xA3); + data.insert(at + 1, 0x95); + if rng.below(2) == 0 { + data.insert(at + 2, 0x80); + } + } + } + } +} + +/// run everything that parses over one input; must never panic +fn exercise(data: &[u8]) { + let index = scan(data); + assert_eq!(index.offsets.len(), index.types.len()); + + let mut sink = std::io::sink(); + let _ = render::convert(data, &mut sink); + + if let Ok(log) = LogFile::open_bytes(data) { + let names: Vec = log.name_to_id.keys().cloned().collect(); + for name in names.iter().take(8) { + if let Some(fmt) = log.name_to_id.get(name).and_then(|id| log.fmts.get(id)) { + let labels: Vec<&str> = fmt.labels.iter().map(|s| s.as_str()).take(3).collect(); + if !labels.is_empty() { + let _ = columns::get_columns(&log, name, &labels); + let _ = columns::get_array_column(&log, name, labels[0]); + let _ = columns::get_columns_filtered(&log, name, &labels, Some(0)); + } + } + } + + // the general access layer, the time correlation and units metadata + for record in log.records().take(64) { + let _ = record.values(); + } + let _ = log.time_base(); + let _ = log.units(); + } +} + +fn seeds() -> Vec> { + let corpus = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../testdata/copter.bin"); + let copter = std::fs::read(&corpus).expect("corpus copter.bin"); + + vec![ + copter[..8192.min(copter.len())].to_vec(), + copter[copter.len() / 2..(copter.len() / 2 + 8192).min(copter.len())].to_vec(), + vec![0xA3, 0x95, 0x80], + Vec::new(), + ] +} + +#[test] +fn mutated_inputs_never_panic() { + let seeds = seeds(); + let mut rng = Rng(0x9E3779B97F4A7C15); + + for iteration in 0..3000u32 { + let mut data = seeds[rng.below(seeds.len())].clone(); + mutate(&mut data, &mut rng); + + let result = catch_unwind(AssertUnwindSafe(|| exercise(&data))); + if result.is_err() { + let path = std::env::temp_dir().join(format!("dflog-fuzz-fail-{iteration}.bin")); + let _ = std::fs::write(&path, &data); + panic!( + "iteration {iteration} panicked; input saved to {}", + path.display() + ); + } + } +} + +/// distilled regression cases; grows with every fuzzer finding +#[test] +fn known_edge_inputs_never_panic() { + let cases: Vec> = vec![ + Vec::new(), + vec![0xA3], + vec![0xA3, 0x95], + vec![0xA3, 0x95, 0x80], + // FMT that declares its own type with a tiny length + { + let mut v = vec![0xA3, 0x95, 0x80]; + let mut fmt = [0u8; 86]; + fmt[0] = 0x80; + fmt[1] = 1; + v.extend_from_slice(&fmt); + v.extend_from_slice(&[0xA3, 0x95, 0x80]); + v + }, + // record type defined with length 2 (the C# new byte[-1] throw path) + { + let mut v = vec![0xA3, 0x95, 0x80]; + let mut fmt = [0u8; 86]; + fmt[0] = 0x42; + fmt[1] = 2; + fmt[2..5].copy_from_slice(b"BAD"); + v.extend_from_slice(&fmt); + v.extend_from_slice(&[0xA3, 0x95, 0x42, 1, 2, 3]); + v + }, + // format string longer than the payload + { + let mut v = vec![0xA3, 0x95, 0x80]; + let mut fmt = [0u8; 86]; + fmt[0] = 0x43; + fmt[1] = 5; // 2 payload bytes, but format wants 8 + fmt[2..5].copy_from_slice(b"SML"); + fmt[6..7].copy_from_slice(b"q"); + fmt[22..23].copy_from_slice(b"A"); + v.extend_from_slice(&fmt); + v.extend_from_slice(&[0xA3, 0x95, 0x43, 9, 9]); + v + }, + ]; + + for (i, case) in cases.iter().enumerate() { + let result = catch_unwind(AssertUnwindSafe(|| exercise(case))); + assert!(result.is_ok(), "edge case {i} panicked"); + } +} diff --git a/rust/crates/dflog-ffi/Cargo.toml b/rust/crates/dflog-ffi/Cargo.toml new file mode 100644 index 0000000000..a2a14031ff --- /dev/null +++ b/rust/crates/dflog-ffi/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dflog-ffi" +version = "0.7.1" +edition.workspace = true +license.workspace = true +description = "C ABI for dflog-core, consumed by Mission Planner over P/Invoke" + +[lib] +name = "dflog_ffi" +crate-type = ["cdylib"] + +[dependencies] +dflog-core = { path = "../dflog-core" } + +[lints] +workspace = true diff --git a/rust/crates/dflog-ffi/src/lib.rs b/rust/crates/dflog-ffi/src/lib.rs new file mode 100644 index 0000000000..7abcdab8ec --- /dev/null +++ b/rust/crates/dflog-ffi/src/lib.rs @@ -0,0 +1,476 @@ +//! C ABI over dflog-core for Mission Planner's P/Invoke bindings +//! (ExtLibs/Utilities/DFLogNative.cs). +//! +//! Contract: +//! - Every function returns 0 on success or a negative error code; call +//! `dflog_last_error` for a UTF-8 message describing the last failure on +//! the calling thread. +//! - `dflog_scan_file` allocates a `DflogIndex`; release it with +//! `dflog_index_free`. The `offsets`/`types` pointers stay valid until then. +//! - Panics never cross the boundary: they convert to `DFLOG_ERR_PANIC`. + +use std::cell::RefCell; +use std::ffi::CStr; +use std::os::raw::c_char; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::PathBuf; +use std::ptr; + +pub const DFLOG_OK: i32 = 0; +pub const DFLOG_ERR_BAD_ARGUMENT: i32 = -1; +pub const DFLOG_ERR_IO: i32 = -2; +pub const DFLOG_ERR_PANIC: i32 = -3; + +/// Bumped when the ABI changes shape; checked by the C# side. +pub const DFLOG_ABI_VERSION: u32 = 5; + +pub const DFLOG_ERR_NO_TIME_BASE: i32 = -5; + +pub const DFLOG_ERR_QUERY: i32 = -4; + +thread_local! { + static LAST_ERROR: RefCell = const { RefCell::new(String::new()) }; +} + +fn set_last_error(message: String) { + LAST_ERROR.with(|slot| *slot.borrow_mut() = message); +} + +#[repr(C)] +#[derive(Debug)] +pub struct DflogIndex { + pub count: u64, + pub offsets: *const u64, + pub types: *const u8, + // owned storage the raw pointers refer to; not part of the C layout + // contract - the C side must treat this struct as opaque beyond `types` + offsets_vec: Vec, + types_vec: Vec, +} + +#[no_mangle] +pub extern "C" fn dflog_abi_version() -> u32 { + DFLOG_ABI_VERSION +} + +/// Scan the dataflash log at `path_utf8` and return its record index. +/// +/// # Safety +/// `path_utf8` must be a valid NUL-terminated UTF-8 string and `out` a valid +/// pointer to receive the index. +#[no_mangle] +pub unsafe extern "C" fn dflog_scan_file( + path_utf8: *const c_char, + out: *mut *mut DflogIndex, +) -> i32 { + if path_utf8.is_null() || out.is_null() { + set_last_error("null argument".into()); + return DFLOG_ERR_BAD_ARGUMENT; + } + // SAFETY: `out` is non-null and valid per the caller contract + unsafe { *out = ptr::null_mut() }; + + // SAFETY: `path_utf8` is a non-null NUL-terminated string per the + // caller contract + let path = match unsafe { CStr::from_ptr(path_utf8) }.to_str() { + Ok(s) => PathBuf::from(s), + Err(_) => { + set_last_error("path is not valid UTF-8".into()); + return DFLOG_ERR_BAD_ARGUMENT; + } + }; + + let result = catch_unwind(AssertUnwindSafe(|| dflog_core::scan_file(&path))); + + match result { + Ok(Ok(index)) => { + let mut boxed = Box::new(DflogIndex { + count: index.offsets.len() as u64, + offsets: ptr::null(), + types: ptr::null(), + offsets_vec: index.offsets, + types_vec: index.types, + }); + boxed.offsets = boxed.offsets_vec.as_ptr(); + boxed.types = boxed.types_vec.as_ptr(); + // SAFETY: `out` is non-null and valid per the caller contract + unsafe { *out = Box::into_raw(boxed) }; + DFLOG_OK + } + Ok(Err(err)) => { + set_last_error(format!("{}: {}", path.display(), err)); + DFLOG_ERR_IO + } + Err(_) => { + set_last_error("panic in dflog_scan_file".into()); + DFLOG_ERR_PANIC + } + } +} + +/// Release an index returned by `dflog_scan_file`. +/// +/// # Safety +/// `index` must be a pointer previously returned via `dflog_scan_file`, and +/// must not be used after this call. Null is ignored. +#[no_mangle] +pub unsafe extern "C" fn dflog_index_free(index: *mut DflogIndex) { + if !index.is_null() { + // SAFETY: `index` came from Box::into_raw in dflog_scan_file and is + // not used again per the caller contract + drop(unsafe { Box::from_raw(index) }); + } +} + +/// An open log kept resident for typed column queries (phase B). +#[derive(Debug)] +pub struct DflogFile { + log: dflog_core::LogFile, +} + +/// Column-major query result: `values[col * rows + row]`. +#[repr(C)] +#[derive(Debug)] +pub struct DflogColumns { + pub rows: u64, + pub cols: u32, + pub linenos: *const u64, + pub values: *const f64, + // rust-owned storage; opaque to the C side beyond `values` + linenos_vec: Vec, + values_vec: Vec, +} + +/// Open the log at `path_utf8` for column queries; release with `dflog_close`. +/// +/// # Safety +/// `path_utf8` must be a valid NUL-terminated UTF-8 string and `out` a valid +/// pointer to receive the handle. +#[no_mangle] +pub unsafe extern "C" fn dflog_open(path_utf8: *const c_char, out: *mut *mut DflogFile) -> i32 { + if path_utf8.is_null() || out.is_null() { + set_last_error("null argument".into()); + return DFLOG_ERR_BAD_ARGUMENT; + } + // SAFETY: `out` is non-null and valid per the caller contract + unsafe { *out = ptr::null_mut() }; + + // SAFETY: `path_utf8` is a non-null NUL-terminated string per the + // caller contract + let path = match unsafe { CStr::from_ptr(path_utf8) }.to_str() { + Ok(s) => PathBuf::from(s), + Err(_) => { + set_last_error("path is not valid UTF-8".into()); + return DFLOG_ERR_BAD_ARGUMENT; + } + }; + + match catch_unwind(AssertUnwindSafe(|| dflog_core::LogFile::open(&path))) { + Ok(Ok(log)) => { + // SAFETY: `out` is non-null and valid per the caller contract + unsafe { *out = Box::into_raw(Box::new(DflogFile { log })) }; + DFLOG_OK + } + Ok(Err(err)) => { + set_last_error(format!("{}: {}", path.display(), err)); + DFLOG_ERR_IO + } + Err(_) => { + set_last_error("panic in dflog_open".into()); + DFLOG_ERR_PANIC + } + } +} + +/// Release a handle returned by `dflog_open`. +/// +/// # Safety +/// `file` must come from `dflog_open` and not be used afterwards. Null is +/// ignored. +#[no_mangle] +pub unsafe extern "C" fn dflog_close(file: *mut DflogFile) { + if !file.is_null() { + // SAFETY: `file` came from Box::into_raw in dflog_open and is not + // used again per the caller contract + drop(unsafe { Box::from_raw(file) }); + } +} + +/// Decode the comma-separated `fields_utf8` of every `type_utf8` record into +/// f64 columns. Release the result with `dflog_columns_free`. +/// +/// # Safety +/// `file` must be a live `dflog_open` handle; the strings must be valid +/// NUL-terminated UTF-8; `out` must be a valid pointer. +#[no_mangle] +pub unsafe extern "C" fn dflog_get_columns( + file: *const DflogFile, + type_utf8: *const c_char, + fields_utf8: *const c_char, + out: *mut *mut DflogColumns, +) -> i32 { + // SAFETY: forwarded caller contract + unsafe { get_columns_impl(file, type_utf8, fields_utf8, None, out) } +} + +/// `dflog_get_columns` limited to one instance value (the field whose FMTU +/// unit id is '#') when `has_instance` is non-zero. A type without an +/// instance field fails with `DFLOG_ERR_QUERY`. +/// +/// # Safety +/// `file` must be a live `dflog_open` handle; the strings must be valid +/// NUL-terminated UTF-8; `out` must be a valid pointer. +#[no_mangle] +pub unsafe extern "C" fn dflog_get_columns_filtered( + file: *const DflogFile, + type_utf8: *const c_char, + fields_utf8: *const c_char, + has_instance: i32, + instance: i64, + out: *mut *mut DflogColumns, +) -> i32 { + let instance = (has_instance != 0).then_some(instance); + // SAFETY: forwarded caller contract + unsafe { get_columns_impl(file, type_utf8, fields_utf8, instance, out) } +} + +/// # Safety +/// Same contract as `dflog_get_columns`. +unsafe fn get_columns_impl( + file: *const DflogFile, + type_utf8: *const c_char, + fields_utf8: *const c_char, + instance: Option, + out: *mut *mut DflogColumns, +) -> i32 { + if file.is_null() || type_utf8.is_null() || fields_utf8.is_null() || out.is_null() { + set_last_error("null argument".into()); + return DFLOG_ERR_BAD_ARGUMENT; + } + // SAFETY: `out` is non-null and valid per the caller contract + unsafe { *out = ptr::null_mut() }; + + // SAFETY: both strings are non-null and NUL-terminated per the caller + // contract + let (type_name, fields_csv) = match unsafe { + ( + CStr::from_ptr(type_utf8).to_str(), + CStr::from_ptr(fields_utf8).to_str(), + ) + } { + (Ok(t), Ok(f)) => (t, f), + _ => { + set_last_error("type/fields are not valid UTF-8".into()); + return DFLOG_ERR_BAD_ARGUMENT; + } + }; + + let fields: Vec<&str> = fields_csv.split(',').collect(); + // SAFETY: `file` is a live dflog_open handle per the caller contract + let log = unsafe { &(*file).log }; + + match catch_unwind(AssertUnwindSafe(|| { + dflog_core::columns::get_columns_filtered(log, type_name, &fields, instance) + })) { + Ok(Ok(cols)) => { + let mut boxed = Box::new(DflogColumns { + rows: cols.rows, + cols: cols.cols, + linenos: ptr::null(), + values: ptr::null(), + linenos_vec: cols.linenos, + values_vec: cols.values, + }); + boxed.linenos = boxed.linenos_vec.as_ptr(); + boxed.values = boxed.values_vec.as_ptr(); + // SAFETY: `out` is non-null and valid per the caller contract + unsafe { *out = Box::into_raw(boxed) }; + DFLOG_OK + } + Ok(Err(err)) => { + set_last_error(err.to_string()); + DFLOG_ERR_QUERY + } + Err(_) => { + set_last_error("panic in dflog_get_columns".into()); + DFLOG_ERR_PANIC + } + } +} + +/// Row-major array-column result: `values[row * elems + e]`, elems = 32 for +/// the `a` (int16[32]) format. +#[repr(C)] +#[derive(Debug)] +pub struct DflogArrayColumn { + pub rows: u64, + pub elems: u32, + pub linenos: *const u64, + pub values: *const i16, + // rust-owned storage; opaque to the C side beyond `values` + linenos_vec: Vec, + values_vec: Vec, +} + +/// Decode the `a` (int16[32]) array `field_utf8` of every `type_utf8` record. +/// Release the result with `dflog_array_column_free`. +/// +/// # Safety +/// `file` must be a live `dflog_open` handle; the strings must be valid +/// NUL-terminated UTF-8; `out` must be a valid pointer. +#[no_mangle] +pub unsafe extern "C" fn dflog_get_array_column( + file: *const DflogFile, + type_utf8: *const c_char, + field_utf8: *const c_char, + out: *mut *mut DflogArrayColumn, +) -> i32 { + if file.is_null() || type_utf8.is_null() || field_utf8.is_null() || out.is_null() { + set_last_error("null argument".into()); + return DFLOG_ERR_BAD_ARGUMENT; + } + // SAFETY: `out` is non-null and valid per the caller contract + unsafe { *out = ptr::null_mut() }; + + // SAFETY: both strings are non-null and NUL-terminated per the caller + // contract + let (type_name, field) = match unsafe { + ( + CStr::from_ptr(type_utf8).to_str(), + CStr::from_ptr(field_utf8).to_str(), + ) + } { + (Ok(t), Ok(f)) => (t, f), + _ => { + set_last_error("type/field are not valid UTF-8".into()); + return DFLOG_ERR_BAD_ARGUMENT; + } + }; + + // SAFETY: `file` is a live dflog_open handle per the caller contract + let log = unsafe { &(*file).log }; + + match catch_unwind(AssertUnwindSafe(|| { + dflog_core::columns::get_array_column(log, type_name, field) + })) { + Ok(Ok(col)) => { + let mut boxed = Box::new(DflogArrayColumn { + rows: col.rows, + elems: dflog_core::columns::ARRAY_ELEMS as u32, + linenos: ptr::null(), + values: ptr::null(), + linenos_vec: col.linenos, + values_vec: col.values, + }); + boxed.linenos = boxed.linenos_vec.as_ptr(); + boxed.values = boxed.values_vec.as_ptr(); + // SAFETY: `out` is non-null and valid per the caller contract + unsafe { *out = Box::into_raw(boxed) }; + DFLOG_OK + } + Ok(Err(err)) => { + set_last_error(err.to_string()); + DFLOG_ERR_QUERY + } + Err(_) => { + set_last_error("panic in dflog_get_array_column".into()); + DFLOG_ERR_PANIC + } + } +} + +/// Wall-clock correlation from the log's first valid GPS fix: +/// `gps_start_unix_ms` (UTC) and the board `ms_offset` it corresponds to. +/// Returns `DFLOG_ERR_NO_TIME_BASE` when the log has no usable fix. +/// +/// # Safety +/// `file` must be a live `dflog_open` handle; the out pointers must be valid. +#[no_mangle] +pub unsafe extern "C" fn dflog_time_base( + file: *const DflogFile, + gps_start_unix_ms: *mut i64, + ms_offset: *mut i64, +) -> i32 { + if file.is_null() || gps_start_unix_ms.is_null() || ms_offset.is_null() { + set_last_error("null argument".into()); + return DFLOG_ERR_BAD_ARGUMENT; + } + + // SAFETY: `file` is a live dflog_open handle per the caller contract + let log = unsafe { &(*file).log }; + match catch_unwind(AssertUnwindSafe(|| log.time_base())) { + Ok(Some(base)) => { + // SAFETY: the out pointers are non-null and valid per the + // caller contract + unsafe { + *gps_start_unix_ms = base.gps_start_unix_ms; + *ms_offset = base.ms_offset + }; + DFLOG_OK + } + Ok(None) => { + set_last_error("no usable gps fix in log".into()); + DFLOG_ERR_NO_TIME_BASE + } + Err(_) => { + set_last_error("panic in dflog_time_base".into()); + DFLOG_ERR_PANIC + } + } +} + +/// Release a result returned by `dflog_get_array_column`. +/// +/// # Safety +/// `column` must come from `dflog_get_array_column` and not be used +/// afterwards. Null is ignored. +#[no_mangle] +pub unsafe extern "C" fn dflog_array_column_free(column: *mut DflogArrayColumn) { + if !column.is_null() { + // SAFETY: `column` came from Box::into_raw in dflog_get_array_column + // and is not used again per the caller contract + drop(unsafe { Box::from_raw(column) }); + } +} + +/// Release a result returned by `dflog_get_columns`. +/// +/// # Safety +/// `columns` must come from `dflog_get_columns` and not be used afterwards. +/// Null is ignored. +#[no_mangle] +pub unsafe extern "C" fn dflog_columns_free(columns: *mut DflogColumns) { + if !columns.is_null() { + // SAFETY: `columns` came from Box::into_raw in dflog_get_columns and + // is not used again per the caller contract + drop(unsafe { Box::from_raw(columns) }); + } +} + +/// Copy the calling thread's last error message (UTF-8, NUL-terminated) into +/// `buf`. Returns the number of bytes written excluding the NUL, or the +/// required capacity as a negative number when `cap` is too small. +/// +/// # Safety +/// `buf` must point to at least `cap` writable bytes. +#[no_mangle] +pub unsafe extern "C" fn dflog_last_error(buf: *mut c_char, cap: usize) -> i32 { + if buf.is_null() || cap == 0 { + return DFLOG_ERR_BAD_ARGUMENT; + } + + LAST_ERROR.with(|slot| { + let message = slot.borrow(); + let bytes = message.as_bytes(); + if bytes.len() + 1 > cap { + return -(bytes.len() as i32 + 1); + } + // SAFETY: `buf` holds at least `cap` writable bytes per the caller + // contract, and bytes.len() + 1 <= cap was just checked + unsafe { + ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, bytes.len()); + *buf.add(bytes.len()) = 0 + }; + bytes.len() as i32 + }) +} diff --git a/rust/rust-toolchain.toml b/rust/rust-toolchain.toml new file mode 100644 index 0000000000..73cb934de4 --- /dev/null +++ b/rust/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/rust/rustfmt.toml b/rust/rustfmt.toml new file mode 100644 index 0000000000..bfdb1d704e --- /dev/null +++ b/rust/rustfmt.toml @@ -0,0 +1,3 @@ +# Pin the style edition so direct rustfmt invocations (editors, hooks) match +# `cargo fmt`, which infers it from `edition = "2021"` in Cargo.toml. +style_edition = "2021" diff --git a/rust/testdata/README.md b/rust/testdata/README.md new file mode 100644 index 0000000000..9afc9df34c --- /dev/null +++ b/rust/testdata/README.md @@ -0,0 +1,10 @@ +# rust/testdata + +SITL corpus logs used by the Rust crates' golden characterization tests. +Byte-for-byte copies of the canonical corpus maintained in the upstream fork +(userepo/MissionPlanner, `rust/testdata` on branch `rust/dflog-core`), which +in turn mirrors that fork's C# characterization corpus. + +If the canonical corpus is ever regenerated upstream, refresh these copies +too - several Rust tests pin exact values from them (record counts, GPS.Lat +units, MSG text), the same way C# characterization goldens do. diff --git a/rust/testdata/copter-isbd.bin b/rust/testdata/copter-isbd.bin new file mode 100644 index 0000000000..99ae81f67a Binary files /dev/null and b/rust/testdata/copter-isbd.bin differ diff --git a/rust/testdata/copter.bin b/rust/testdata/copter.bin new file mode 100644 index 0000000000..8831868213 Binary files /dev/null and b/rust/testdata/copter.bin differ diff --git a/rust/testdata/plane.bin b/rust/testdata/plane.bin new file mode 100644 index 0000000000..cccc56c359 Binary files /dev/null and b/rust/testdata/plane.bin differ diff --git a/rust/testdata/rover.bin b/rust/testdata/rover.bin new file mode 100644 index 0000000000..f0cd413746 Binary files /dev/null and b/rust/testdata/rover.bin differ