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