Skip to main content

eventcv_core/io/
aedat4.rs

1//! AEDAT 4.0 reader (iniVation DV format).
2//!
3//! The file is `#!AER-DAT4.0\r\n`, a 32-bit little-endian length and that many bytes of
4//! `IOHeader` FlatBuffer, then a run of packets. Each packet is an 8-byte `PacketHeader`
5//! (`i32` stream id, `i32` body size) followed by a compressed body which, once decompressed,
6//! is a size-prefixed FlatBuffer. A `FileDataTable` at the header's `dataTablePosition` indexes
7//! every packet — byte offset, element count and time span — so a recording is sliceable
8//! without reading it; when it is absent (an interrupted recording) the packets are walked once
9//! to rebuild it.
10//!
11//! The header's `infoNode` is a small XML document naming each stream: `EVTS` events, `FRME`
12//! APS frames, `IMUS` inertial samples, `TRIG` triggers (read past, not decoded).
13//!
14//! FlatBuffers are decoded by the bounds-checked reader below rather than by the `flatbuffers`
15//! crate. That crate's table accessors are `unsafe` and unchecked, and its safe entry points
16//! need verifier code that normally comes out of `flatc` — more machinery than these four small
17//! schemas are worth. Every read here returns `Option`, so a truncated or corrupt packet becomes
18//! an [`IoError::Format`] instead of reading past the end of a buffer.
19
20use std::fs::File;
21use std::io::{Read, Seek, SeekFrom, Write};
22use std::path::{Path, PathBuf};
23
24use rayon::prelude::*;
25
26use super::{ImuSample, IoError, LoadOptions, SliceSource};
27use crate::representation::{EventFrame, EventFrameData};
28use crate::{EventStream, EventStreamBuilder};
29
30/// AEDAT 4 timestamps are Unix microseconds.
31const TIMESTAMP_SCALE_MS: f64 = 0.001;
32
33const MAGIC: &[u8] = b"#!AER-DAT4.0\r\n";
34/// `PacketHeader { StreamID: int32; Size: int32 }`.
35const PACKET_HEADER: usize = 8;
36
37/// A `dv::Event` struct: `int64 timestamp`, `int16 x`, `int16 y`, `bool polarity`, padded to the
38/// struct's 8-byte alignment. Vectors of structs are packed inline, so this is the stride.
39const EVENT_STRIDE: usize = 16;
40
41/// `IMU` reports acceleration in g and rotation in degrees per second; [`ImuSample`] is SI.
42const STANDARD_GRAVITY: f64 = 9.806_65;
43
44/// FlatBuffer field slots, `4 + 2 * field_index` from the schemas in `dv-processing`'s
45/// `.fbs` files. Grouped by table so they can be checked against the schema by eye.
46mod slot {
47    // IOHeader { compression, dataTablePosition, infoNode }
48    pub const COMPRESSION: usize = 4;
49    pub const DATA_TABLE_POSITION: usize = 6;
50    pub const INFO_NODE: usize = 8;
51
52    // EventPacket / IMUPacket / TriggerPacket { elements }
53    pub const ELEMENTS: usize = 4;
54
55    // Frame { timestamp, timestampStartOfFrame, timestampEndOfFrame, timestampStartOfExposure,
56    //         timestampEndOfExposure, format, sizeX, sizeY, positionX, positionY, pixels, … }
57    pub const FRAME_TIMESTAMP: usize = 4;
58    pub const FRAME_FORMAT: usize = 14;
59    pub const FRAME_SIZE_X: usize = 16;
60    pub const FRAME_SIZE_Y: usize = 18;
61    pub const FRAME_PIXELS: usize = 24;
62
63    // IMU { timestamp, temperature, accelerometerX/Y/Z, gyroscopeX/Y/Z, magnetometerX/Y/Z }
64    pub const IMU_TIMESTAMP: usize = 4;
65    pub const IMU_ACCELEROMETER: usize = 8; // X; Y and Z follow at +2 and +4
66    pub const IMU_GYROSCOPE: usize = 14;
67
68    // FileDataDefinition { ByteOffset, PacketInfo, NumElements, TimestampStart, TimestampEnd }
69    pub const DEFINITION_BYTE_OFFSET: usize = 4;
70    pub const DEFINITION_PACKET_INFO: usize = 6;
71    pub const DEFINITION_NUM_ELEMENTS: usize = 8;
72    pub const DEFINITION_TIMESTAMP_START: usize = 10;
73    pub const DEFINITION_TIMESTAMP_END: usize = 12;
74
75    // FileDataTable { Table }
76    pub const TABLE: usize = 4;
77}
78
79/// `FrameFormat` values this reader decodes; the rest are rejected by name.
80const FORMAT_GREY: i8 = 0; // OPENCV_8U_C1
81const FORMAT_BGR: i8 = 16; // OPENCV_8U_C3
82const FORMAT_BGRA: i8 = 24; // OPENCV_8U_C4
83
84fn scalar<const N: usize>(buffer: &[u8], at: usize) -> Option<[u8; N]> {
85    buffer.get(at..at.checked_add(N)?)?.try_into().ok()
86}
87
88/// A FlatBuffer table: a position in a buffer, and a vtable saying where its fields are.
89#[derive(Clone, Copy)]
90struct Table<'a> {
91    buffer: &'a [u8],
92    at: usize,
93}
94
95impl<'a> Table<'a> {
96    /// The root of a size-prefixed buffer — the framing every AEDAT 4 packet body uses.
97    fn size_prefixed_root(buffer: &'a [u8]) -> Option<Self> {
98        Self::root_at(buffer, 4)
99    }
100
101    /// The root of a bare buffer; the `IOHeader` is stored this way, its length carried outside.
102    fn root(buffer: &'a [u8]) -> Option<Self> {
103        Self::root_at(buffer, 0)
104    }
105
106    fn root_at(buffer: &'a [u8], at: usize) -> Option<Self> {
107        let offset = u32::from_le_bytes(scalar(buffer, at)?) as usize;
108        let at = at.checked_add(offset)?;
109        (at < buffer.len()).then_some(Self { buffer, at })
110    }
111
112    /// Where field `slot` is stored, or `None` when the table omits it — meaning the writer left
113    /// it at the schema default, which is what the readers below fall back to.
114    fn field(&self, slot: usize) -> Option<usize> {
115        let soffset = i32::from_le_bytes(scalar(self.buffer, self.at)?);
116        let vtable = usize::try_from(i64::try_from(self.at).ok()? - i64::from(soffset)).ok()?;
117        let length = u16::from_le_bytes(scalar(self.buffer, vtable)?) as usize;
118        if slot + 2 > length {
119            return None; // written by an older schema that had no such field
120        }
121        let offset = u16::from_le_bytes(scalar(self.buffer, vtable.checked_add(slot)?)?) as usize;
122        (offset != 0).then(|| self.at.checked_add(offset)).flatten()
123    }
124
125    fn i8(&self, slot: usize, default: i8) -> i8 {
126        self.field(slot)
127            .and_then(|at| scalar::<1>(self.buffer, at))
128            .map_or(default, i8::from_le_bytes)
129    }
130
131    fn i16(&self, slot: usize) -> i16 {
132        self.field(slot)
133            .and_then(|at| scalar::<2>(self.buffer, at))
134            .map_or(0, i16::from_le_bytes)
135    }
136
137    fn i32(&self, slot: usize) -> i32 {
138        self.field(slot)
139            .and_then(|at| scalar::<4>(self.buffer, at))
140            .map_or(0, i32::from_le_bytes)
141    }
142
143    fn i64(&self, slot: usize, default: i64) -> i64 {
144        self.field(slot)
145            .and_then(|at| scalar::<8>(self.buffer, at))
146            .map_or(default, i64::from_le_bytes)
147    }
148
149    fn f32(&self, slot: usize) -> f32 {
150        self.field(slot)
151            .and_then(|at| scalar::<4>(self.buffer, at))
152            .map_or(0.0, f32::from_le_bytes)
153    }
154
155    /// Where an offset field (string, vector or table) points.
156    fn indirect(&self, slot: usize) -> Option<usize> {
157        let at = self.field(slot)?;
158        let offset = u32::from_le_bytes(scalar(self.buffer, at)?) as usize;
159        at.checked_add(offset)
160    }
161
162    fn string(&self, slot: usize) -> Option<&'a str> {
163        let (bytes, _) = self.vector(slot, 1)?;
164        std::str::from_utf8(bytes).ok()
165    }
166
167    /// A vector of inline `stride`-byte elements: its bytes and its element count, both checked
168    /// against the buffer so a bad length cannot make a slice run off the end.
169    fn vector(&self, slot: usize, stride: usize) -> Option<(&'a [u8], usize)> {
170        let at = self.indirect(slot)?;
171        let elements = u32::from_le_bytes(scalar(self.buffer, at)?) as usize;
172        let start = at.checked_add(4)?;
173        let end = start.checked_add(elements.checked_mul(stride)?)?;
174        Some((self.buffer.get(start..end)?, elements))
175    }
176
177    /// A vector of tables. Each element is an offset relative to its own position.
178    fn tables(&self, slot: usize) -> Vec<Table<'a>> {
179        let Some((bytes, elements)) = self.vector(slot, 4) else {
180            return Vec::new();
181        };
182        let start = bytes.as_ptr() as usize - self.buffer.as_ptr() as usize;
183        (0..elements)
184            .filter_map(|index| {
185                let at = start + index * 4;
186                let offset = u32::from_le_bytes(scalar(self.buffer, at)?) as usize;
187                Some(Table {
188                    buffer: self.buffer,
189                    at: at.checked_add(offset)?,
190                })
191            })
192            .collect()
193    }
194}
195
196/// How each packet body is compressed — `IOHeader.compression` on read, and the choice
197/// [`Aedat4EventSink`] writes with. LZ4 is the default because it is what DV itself writes.
198#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
199pub enum Compression {
200    None,
201    #[default]
202    Lz4,
203    Zstd,
204}
205
206impl Compression {
207    /// The `IOHeader.compression` value that names this scheme, the inverse of
208    /// [`from_header`](Self::from_header). The plain rather than the `_HIGH` variant: an event
209    /// packet is written once and read many times, and the extra effort buys little on data this
210    /// regular.
211    fn to_header(self) -> i32 {
212        match self {
213            Self::None => 0,
214            Self::Lz4 => 1,
215            Self::Zstd => 3,
216        }
217    }
218
219    /// Compresses one packet body for writing. LZ4 goes out in the *frame* format DV expects,
220    /// which is what [`decode_into`](Self::decode_into) reads back.
221    fn encode(self, body: &[u8]) -> Result<Vec<u8>, IoError> {
222        match self {
223            Self::None => Ok(body.to_vec()),
224            Self::Lz4 => {
225                let mut encoder = lz4::EncoderBuilder::new()
226                    .build(Vec::new())
227                    .map_err(IoError::Io)?;
228                encoder.write_all(body).map_err(IoError::Io)?;
229                let (out, result) = encoder.finish();
230                result.map_err(IoError::Io)?;
231                Ok(out)
232            }
233            Self::Zstd => zstd::stream::encode_all(body, 3).map_err(IoError::Io),
234        }
235    }
236
237    fn from_header(value: i32) -> Result<Self, IoError> {
238        match value {
239            0 => Ok(Self::None),
240            1 | 2 => Ok(Self::Lz4),   // LZ4, LZ4_HIGH — same decoder, different effort
241            3 | 4 => Ok(Self::Zstd),  // ZSTD, ZSTD_HIGH
242            other => Err(IoError::Unsupported(format!(
243                "unknown AEDAT4 compression type {other}"
244            ))),
245        }
246    }
247
248    /// Decompresses into a caller-owned buffer. A recording is thousands of packets of similar
249    /// size, so one buffer that keeps its capacity replaces thousands of Vecs that each grow to
250    /// the packet size by doubling, and `lz4` holds one decompression context for all of them.
251    fn decode_into(self, body: &[u8], lz4: &mut Lz4Frame, out: &mut Vec<u8>) -> Result<(), IoError> {
252        out.clear();
253        match self {
254            Self::None => out.extend_from_slice(body),
255            // DV writes LZ4 in its frame format, not raw blocks.
256            Self::Lz4 => lz4.decompress_into(body, out)?,
257            Self::Zstd => {
258                zstd::stream::copy_decode(body, &mut *out).map_err(|error| {
259                    IoError::Format(format!("AEDAT4 zstd packet is corrupt: {error}"))
260                })?;
261            }
262        }
263        Ok(())
264    }
265}
266
267/// One LZ4 frame decompression context, reused for every packet of a recording.
268///
269/// `lz4::Decoder` is a `Read` adapter: each instance creates an `LZ4F_dctx`, allocates a 32 KiB
270/// staging buffer, and pulls the compressed bytes through it in 32 KiB steps before handing them
271/// to the C decoder. Per packet that is a context create/free, a zeroed 32 KiB allocation, and a
272/// full copy of the compressed body — and a recording is thousands of packets. Holding the context
273/// and decompressing straight from the packet slice pays none of it.
274struct Lz4Frame {
275    ctx: lz4_sys::LZ4FDecompressionContext,
276}
277
278impl Lz4Frame {
279    fn new() -> Self {
280        let mut ctx = lz4_sys::LZ4FDecompressionContext(std::ptr::null_mut());
281        // SAFETY: `ctx` is a valid out-pointer and `LZ4F_VERSION` is the constant the C header
282        // requires. A failure leaves the null pointer, which `decompress_into` rejects.
283        let code = unsafe { lz4_sys::LZ4F_createDecompressionContext(&mut ctx, lz4_sys::LZ4F_VERSION) };
284        if unsafe { lz4_sys::LZ4F_isError(code) } != 0 {
285            ctx = lz4_sys::LZ4FDecompressionContext(std::ptr::null_mut());
286        }
287        Self { ctx }
288    }
289
290    /// Decompresses one whole LZ4 frame into `out`, which is left holding exactly the frame.
291    fn decompress_into(&mut self, src: &[u8], out: &mut Vec<u8>) -> Result<(), IoError> {
292        if self.ctx.0.is_null() {
293            return Err(IoError::Format(
294                "AEDAT4 LZ4 decompression context could not be created".to_owned(),
295            ));
296        }
297        // A DV packet decompresses to a few hundred KiB; starting there means most packets never
298        // grow at all, and the buffer keeps its capacity for the next one.
299        if out.capacity() == 0 {
300            out.reserve(256 * 1024);
301        }
302        let mut read = 0usize;
303        loop {
304            if out.len() == out.capacity() {
305                out.reserve(out.capacity().max(64 * 1024));
306            }
307            let written = out.len();
308            let mut dst_size = out.capacity() - written;
309            let mut src_size = src.len() - read;
310            // SAFETY: the context is non-null and owned by this struct; the destination is the
311            // uninitialised spare capacity of `out`, which `dst_size` is sized to and `set_len`
312            // only ever commits as far as the C library reports having written; the source is a
313            // live slice and `src_size` is what remains of it.
314            let hint = unsafe {
315                lz4_sys::LZ4F_decompress(
316                    self.ctx,
317                    out.as_mut_ptr().add(written),
318                    &mut dst_size,
319                    src.as_ptr().add(read),
320                    &mut src_size,
321                    std::ptr::null(),
322                )
323            };
324            if unsafe { lz4_sys::LZ4F_isError(hint) } != 0 {
325                // The context keeps the failed frame's state, so replace it rather than carry a
326                // corrupt one into the next packet.
327                *self = Self::new();
328                return Err(IoError::Format("AEDAT4 LZ4 packet is corrupt".to_owned()));
329            }
330            // SAFETY: the C library reports in `dst_size` how many bytes it wrote at `written`.
331            unsafe { out.set_len(written + dst_size) };
332            read += src_size;
333            if hint == 0 {
334                return Ok(()); // frame complete
335            }
336            if src_size == 0 && dst_size == 0 {
337                return Err(IoError::Format(
338                    "AEDAT4 LZ4 packet ended mid-frame".to_owned(),
339                ));
340            }
341        }
342    }
343}
344
345impl Drop for Lz4Frame {
346    fn drop(&mut self) {
347        if !self.ctx.0.is_null() {
348            // SAFETY: the context was created by `LZ4F_createDecompressionContext` and is freed
349            // exactly once, here.
350            unsafe { lz4_sys::LZ4F_freeDecompressionContext(self.ctx) };
351        }
352    }
353}
354
355impl Default for Lz4Frame {
356    fn default() -> Self {
357        Self::new()
358    }
359}
360
361/// One packet as the index sees it: where its body is, what it holds, and when.
362#[derive(Clone, Copy, Debug)]
363struct Packet {
364    offset: u64,
365    size: usize,
366    /// Elements in the stream before this packet — the cumulative count `slice_index` seeks on.
367    first: usize,
368    elements: usize,
369    start: i64,
370    end: i64,
371}
372
373/// What a stream carries, from its `typeIdentifier` in the header's XML.
374#[derive(Clone, Copy, Debug, PartialEq, Eq)]
375enum Kind {
376    Events,
377    Frames,
378    Imu,
379    Other,
380}
381
382impl Kind {
383    fn from_identifier(identifier: &str) -> Self {
384        match identifier {
385            "EVTS" => Self::Events,
386            "FRME" => Self::Frames,
387            "IMUS" => Self::Imu,
388            _ => Self::Other,
389        }
390    }
391}
392
393/// A stream declared by the header's `infoNode`.
394#[derive(Clone, Debug)]
395struct Stream {
396    id: i32,
397    kind: Kind,
398    size: Option<(usize, usize)>,
399}
400
401/// The streams the header declares, in the order they appear.
402///
403/// `infoNode` is a small XML document (a few kilobytes) listing numbered stream nodes, each with
404/// its type identifier and, for pixel streams, its resolution. It is scanned for those three
405/// attributes rather than parsed: DV is the only writer of this format, so the shape is fixed,
406/// and an XML crate would be a dependency earning its keep on two attributes.
407fn parse_streams(info: &str) -> Vec<Stream> {
408    const NODE: &str = "<node name=\"";
409    let mut marks: Vec<(usize, i32)> = Vec::new();
410    let mut at = 0;
411    while let Some(found) = info[at..].find(NODE) {
412        let start = at + found + NODE.len();
413        at = start;
414        let Some(end) = info[start..].find('"') else {
415            break;
416        };
417        // Stream nodes are named by their id; `info` and the document root are not.
418        if let Ok(id) = info[start..start + end].parse::<i32>() {
419            marks.push((start, id));
420        }
421    }
422    marks
423        .iter()
424        .enumerate()
425        .map(|(index, &(start, id))| {
426            let end = marks.get(index + 1).map_or(info.len(), |&(next, _)| next);
427            let block = &info[start..end];
428            Stream {
429                id,
430                kind: Kind::from_identifier(attribute(block, "typeIdentifier").unwrap_or("")),
431                size: attribute(block, "sizeX")
432                    .and_then(|value| value.parse().ok())
433                    .zip(attribute(block, "sizeY").and_then(|value| value.parse().ok())),
434            }
435        })
436        .collect()
437}
438
439/// The text of the `<attr key="…">value</attr>` naming `key`.
440fn attribute<'a>(block: &'a str, key: &str) -> Option<&'a str> {
441    let after = &block[block.find(&format!("key=\"{key}\""))?..];
442    let open = after.find('>')? + 1;
443    let close = after[open..].find('<')?;
444    Some(&after[open..open + close])
445}
446
447/// The parsed `IOHeader`, and where the first packet starts.
448struct Header {
449    compression: Compression,
450    data_table_position: i64,
451    streams: Vec<Stream>,
452    body_offset: u64,
453}
454
455fn read_header(file: &mut File) -> Result<Header, IoError> {
456    let mut magic = [0u8; MAGIC.len()];
457    file.read_exact(&mut magic).map_err(|_| {
458        IoError::Format("not an AEDAT 4 file (too short for a version line)".to_owned())
459    })?;
460    if magic != MAGIC {
461        return Err(IoError::Format(format!(
462            "not an AEDAT 4.0 file (version line is {:?})",
463            String::from_utf8_lossy(&magic).trim_end()
464        )));
465    }
466    let mut length = [0u8; 4];
467    file.read_exact(&mut length)?;
468    let length = u32::from_le_bytes(length) as usize;
469    let mut buffer = vec![0u8; length];
470    file.read_exact(&mut buffer)?;
471
472    let header = Table::root(&buffer)
473        .ok_or_else(|| IoError::Format("AEDAT4 header is not a readable FlatBuffer".to_owned()))?;
474    Ok(Header {
475        compression: Compression::from_header(header.i32(slot::COMPRESSION))?,
476        data_table_position: header.i64(slot::DATA_TABLE_POSITION, -1),
477        streams: parse_streams(header.string(slot::INFO_NODE).unwrap_or("")),
478        body_offset: (MAGIC.len() + 4 + length) as u64,
479    })
480}
481
482/// Reads `size` bytes at `offset` and decompresses them.
483fn read_body(
484    path: &Path,
485    compression: Compression,
486    offset: u64,
487    size: usize,
488) -> Result<Vec<u8>, IoError> {
489    let mut file = File::open(path)?;
490    let mut buffers = Buffers::default();
491    read_body_with(&mut file, compression, offset, size, &mut buffers)?;
492    Ok(std::mem::take(&mut buffers.plain))
493}
494
495/// The same, on a handle the caller already holds and a buffer it reuses. Reading a recording's
496/// events touches one packet after another, so opening the file per packet is one `open` and one
497/// `close` syscall per few thousand events, and `vec![0u8; size]` zeroes a buffer that is about to
498/// be overwritten in full.
499fn read_body_with(
500    file: &mut File,
501    compression: Compression,
502    offset: u64,
503    size: usize,
504    buffers: &mut Buffers,
505) -> Result<(), IoError> {
506    file.seek(SeekFrom::Start(offset))?;
507    // Not `clear()` first: `resize` only zeroes the bytes it adds, so once the buffer has seen one
508    // packet it is already long enough for the next and this costs nothing. Clearing first zeroed
509    // the whole body every time, immediately before `read_exact` overwrote all of it.
510    buffers.compressed.resize(size, 0);
511    buffers.compressed.truncate(size);
512    file.read_exact(&mut buffers.compressed)?;
513    let Buffers {
514        compressed,
515        plain,
516        lz4,
517    } = buffers;
518    compression.decode_into(compressed, lz4, plain)
519}
520
521/// The two buffers a packet-by-packet read reuses: the compressed bytes off disk, and the
522/// decompressed body they expand into.
523#[derive(Default)]
524struct Buffers {
525    compressed: Vec<u8>,
526    plain: Vec<u8>,
527    lz4: Lz4Frame,
528}
529
530/// Above this many packets, `slice_index` decodes them in parallel. One window of a recording is
531/// a packet or two, and paying for a thread-pool hand-off there would slow down the case a
532/// real-time loop actually runs.
533const PARALLEL_PACKET_THRESHOLD: usize = 8;
534
535/// One packet and the slice of each output column it alone writes: `(packet, lo, hi, x, y, t, p)`,
536/// where `lo`/`hi` bound the stream indices the caller asked for.
537type PacketTask<'a> = (&'a Packet, usize, usize, &'a mut [u16], &'a mut [u16], &'a mut [i64], &'a mut [bool]);
538
539/// Lazy, indexed source for an AEDAT 4 recording.
540pub struct Aedat4SliceSource {
541    path: PathBuf,
542    compression: Compression,
543    width: usize,
544    height: usize,
545    events: Vec<Packet>,
546    frames: Vec<Packet>,
547    imu: Vec<Packet>,
548    n_events: usize,
549    time_span: (i64, i64),
550}
551
552impl Aedat4SliceSource {
553    fn open(path: &Path, options: &LoadOptions) -> Result<Self, IoError> {
554        let mut file = File::open(path)?;
555        let header = read_header(&mut file)?;
556        let total = file.metadata()?.len();
557
558        let packets = match header.data_table_position {
559            position if position >= 0 && (position as u64) < total => read_data_table(
560                path,
561                header.compression,
562                position as u64,
563                total - position as u64,
564            )?,
565            // No table (an interrupted recording, or one still being written): walk the packets
566            // and decode each one's span, which is what DV itself falls back to.
567            _ => walk_packets(path, header.compression, header.body_offset, total)?,
568        };
569
570        let of_kind = |kind: Kind| -> Vec<Packet> {
571            let ids: Vec<i32> = header
572                .streams
573                .iter()
574                .filter(|stream| stream.kind == kind)
575                .map(|stream| stream.id)
576                .collect();
577            let mut selected: Vec<Packet> = packets
578                .iter()
579                .filter(|(id, _)| ids.contains(id))
580                .map(|(_, packet)| *packet)
581                .collect();
582            selected.sort_by_key(|packet| packet.start);
583            let mut first = 0;
584            for packet in &mut selected {
585                packet.first = first;
586                first += packet.elements;
587            }
588            selected
589        };
590        let events = of_kind(Kind::Events);
591        let (width, height) = options
592            .sensor_size
593            .or_else(|| {
594                header
595                    .streams
596                    .iter()
597                    .find(|stream| stream.kind == Kind::Events)
598                    .and_then(|stream| stream.size)
599            })
600            .ok_or(IoError::InvalidSensorSize)?;
601        if width == 0 || height == 0 {
602            return Err(IoError::InvalidSensorSize);
603        }
604
605        let n_events = events.last().map_or(0, |last| last.first + last.elements);
606        let time_span = match (events.first(), events.last()) {
607            (Some(first), Some(last)) => (first.start, last.end),
608            _ => (0, 0),
609        };
610        Ok(Self {
611            path: path.to_path_buf(),
612            compression: header.compression,
613            width,
614            height,
615            events,
616            frames: of_kind(Kind::Frames),
617            imu: of_kind(Kind::Imu),
618            n_events,
619            time_span,
620        })
621    }
622
623    fn body(&self, packet: &Packet) -> Result<Vec<u8>, IoError> {
624        read_body(&self.path, self.compression, packet.offset, packet.size)
625    }
626
627    /// `body`, reusing a handle and a scratch buffer across packets.
628    fn body_with(
629        &self,
630        file: &mut File,
631        buffers: &mut Buffers,
632        packet: &Packet,
633    ) -> Result<(), IoError> {
634        read_body_with(file, self.compression, packet.offset, packet.size, buffers)
635    }
636
637    /// Decodes one packet straight into its reserved slice of the output columns, returning how
638    /// many slots it filled — fewer than it was given only if an event fell outside the sensor.
639    ///
640    /// Shared by the serial and the parallel arms of [`slice_index`], so a one-thread read gets
641    /// exactly the same cursor writes a worker does rather than falling back on
642    /// `EventStreamBuilder::push` and its per-event capacity checks.
643    fn fill_slots(
644        &self,
645        file: &mut File,
646        buffers: &mut Buffers,
647        task: PacketTask<'_>,
648    ) -> Result<usize, IoError> {
649        let (packet, lo, hi, x, y, t, p) = task;
650        let mut kept = 0usize;
651        self.each_event_with(file, buffers, packet, |index, ex, ey, et, ep| {
652            if index >= lo
653                && index < hi
654                && usize::from(ex) < self.width
655                && usize::from(ey) < self.height
656            {
657                x[kept] = ex;
658                y[kept] = ey;
659                t[kept] = et;
660                p[kept] = ep;
661                kept += 1;
662            }
663        })?;
664        Ok(kept)
665    }
666
667    /// Decodes every event in `packet`, handing each to `visit` with its index in the stream,
668    /// on a file handle and scratch buffer the caller reuses across packets.
669    fn each_event_with(
670        &self,
671        file: &mut File,
672        buffers: &mut Buffers,
673        packet: &Packet,
674        mut visit: impl FnMut(usize, u16, u16, i64, bool),
675    ) -> Result<(), IoError> {
676        self.body_with(file, buffers, packet)?;
677        let body = &buffers.plain;
678        let table = Table::size_prefixed_root(body)
679            .ok_or_else(|| IoError::Format("AEDAT4 event packet is not readable".to_owned()))?;
680        let Some((bytes, elements)) = table.vector(slot::ELEMENTS, EVENT_STRIDE) else {
681            return Ok(());
682        };
683        let (events, _) = bytes.as_chunks::<EVENT_STRIDE>();
684        for (index, event) in events.iter().enumerate().take(elements) {
685            let t = i64::from_le_bytes(event[0..8].try_into().expect("eight bytes"));
686            let x = i16::from_le_bytes(event[8..10].try_into().expect("two bytes"));
687            let y = i16::from_le_bytes(event[10..12].try_into().expect("two bytes"));
688            if x < 0 || y < 0 {
689                continue;
690            }
691            visit(packet.first + index, x as u16, y as u16, t, event[12] != 0);
692        }
693        Ok(())
694    }
695}
696
697impl SliceSource for Aedat4SliceSource {
698    fn sensor_size(&self) -> (usize, usize) {
699        (self.width, self.height)
700    }
701
702    fn timestamp_scale_ms(&self) -> f64 {
703        TIMESTAMP_SCALE_MS
704    }
705
706    fn n_events(&self) -> usize {
707        self.n_events
708    }
709
710    fn time_span(&self) -> (i64, i64) {
711        self.time_span
712    }
713
714    fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError> {
715        let i0 = i0.min(self.n_events);
716        let i1 = i1.clamp(i0, self.n_events);
717        let selected: Vec<&Packet> = self
718            .events
719            .iter()
720            .filter(|packet| packet.first < i1 && packet.first + packet.elements > i0)
721            .collect();
722
723        // Every packet's output range is known from the data table before a byte is decompressed,
724        // so each one fills a disjoint slice of the final columns, whether that happens on one
725        // thread or twelve. Two things this replaced, both of which cost more than the decode:
726        // staging each packet into its own `Columns` and concatenating afterwards wrote every
727        // event twice and kept them all resident; and pushing through `EventStreamBuilder` paid
728        // four capacity checks and four length updates an event. On a 10 Mev recording the column
729        // stores alone were 87 ms of a 165 ms read, against 75 ms for the LZ4 decompression and
730        // 3.5 ms for pulling the fields out of the flatbuffer. Writing through a cursor into
731        // pre-sized columns takes that to 49 ms. The zeroed allocations below come from the
732        // allocator as untouched pages, so they cost nothing until something writes to them.
733        let total = i1 - i0;
734        let mut xs = vec![0u16; total];
735        let mut ys = vec![0u16; total];
736        let mut ts = vec![0i64; total];
737        let mut ps = vec![false; total];
738
739        // Only the first and last selected packet can be partially covered by [i0, i1); the rest
740        // contribute every event they hold. Splitting the columns in packet order hands each
741        // worker a slice it alone writes, so no two can collide.
742        let mut tasks = Vec::with_capacity(selected.len());
743        let mut slots = Vec::with_capacity(selected.len());
744        let (mut xs_rest, mut ys_rest) = (xs.as_mut_slice(), ys.as_mut_slice());
745        let (mut ts_rest, mut ps_rest) = (ts.as_mut_slice(), ps.as_mut_slice());
746        for packet in &selected {
747            let lo = i0.max(packet.first);
748            let hi = i1.min(packet.first + packet.elements);
749            let room = hi - lo;
750            let (x, x_rest) = xs_rest.split_at_mut(room);
751            let (y, y_rest) = ys_rest.split_at_mut(room);
752            let (t, t_rest) = ts_rest.split_at_mut(room);
753            let (p, p_rest) = ps_rest.split_at_mut(room);
754            xs_rest = x_rest;
755            ys_rest = y_rest;
756            ts_rest = t_rest;
757            ps_rest = p_rest;
758            slots.push(room);
759            tasks.push((*packet, lo, hi, x, y, t, p));
760        }
761
762        // An event can still be dropped for a negative or off-sensor coordinate, so each packet
763        // reports how many of its slots it actually filled.
764        //
765        // AEDAT 4 packets are self-contained -- a compressed body, a flatbuffer, and absolute
766        // timestamps -- so decoding them does not have to be sequential the way a stateful EVT3
767        // stream does. Above a handful of packets the decompression, which is most of the cost,
768        // goes wide. Below it, or on a pool of one, the thread-pool hand-off costs more than it
769        // saves, and that is the case that matters most: a robot pulling one window.
770        let wide = selected.len() >= PARALLEL_PACKET_THRESHOLD && rayon::current_num_threads() > 1;
771        let kept: Result<Vec<usize>, IoError> = if wide {
772            tasks
773                .into_par_iter()
774                .map_init(
775                    || (File::open(&self.path), Buffers::default()),
776                    |(file, buffers), task| {
777                        let file = file.as_mut().map_err(|error| {
778                            IoError::Io(std::io::Error::new(error.kind(), error.to_string()))
779                        })?;
780                        self.fill_slots(file, buffers, task)
781                    },
782                )
783                .collect()
784        } else {
785            let mut file = File::open(&self.path)?;
786            let mut buffers = Buffers::default();
787            tasks
788                .into_iter()
789                .map(|task| self.fill_slots(&mut file, &mut buffers, task))
790                .collect()
791        };
792        let kept = kept?;
793
794        // Nothing dropped is the ordinary case: the slices are already contiguous and in packet
795        // order, which is time order. Otherwise close the gaps each short packet left behind.
796        let filled: usize = kept.iter().sum();
797        if filled < total {
798            let (mut write, mut read) = (0usize, 0usize);
799            for (&n, &room) in kept.iter().zip(&slots) {
800                if write != read {
801                    xs.copy_within(read..read + n, write);
802                    ys.copy_within(read..read + n, write);
803                    ts.copy_within(read..read + n, write);
804                    ps.copy_within(read..read + n, write);
805                }
806                write += n;
807                read += room;
808            }
809        }
810        xs.truncate(filled);
811        ys.truncate(filled);
812        ts.truncate(filled);
813        ps.truncate(filled);
814
815        Ok(
816            EventStreamBuilder::from_columns(
817                self.width,
818                self.height,
819                TIMESTAMP_SCALE_MS,
820                xs,
821                ys,
822                ts,
823                ps,
824            )
825            .build(),
826        )
827    }
828
829    fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError> {
830        let mut builder = EventStreamBuilder::new(self.width, self.height, TIMESTAMP_SCALE_MS);
831        let mut file = File::open(&self.path)?;
832        let mut buffers = Buffers::default();
833        for packet in overlapping(&self.events, t0, t1) {
834            self.each_event_with(&mut file, &mut buffers, packet, |_, x, y, t, p| {
835                if t >= t0 && t < t1 {
836                    builder.push(x, y, t, p);
837                }
838            })?;
839        }
840        Ok(builder.build())
841    }
842
843    fn frames(&self, t0: i64, t1: i64) -> Result<Vec<(i64, EventFrame)>, IoError> {
844        let mut frames = Vec::new();
845        for packet in overlapping(&self.frames, t0, t1) {
846            let body = self.body(packet)?;
847            // A frame packet's root *is* the frame — `Frame` is its own `root_type`, with no
848            // wrapping element vector, unlike events and IMU samples.
849            let table = Table::size_prefixed_root(&body)
850                .ok_or_else(|| IoError::Format("AEDAT4 frame packet is not readable".to_owned()))?;
851            let t = table.i64(slot::FRAME_TIMESTAMP, 0);
852            if t < t0 || t >= t1 {
853                continue;
854            }
855            frames.push((t, decode_frame(&table)?));
856        }
857        Ok(frames)
858    }
859
860    fn imu(&self, t0: i64, t1: i64) -> Result<Vec<ImuSample>, IoError> {
861        let mut samples = Vec::new();
862        for packet in overlapping(&self.imu, t0, t1) {
863            let body = self.body(packet)?;
864            let table = Table::size_prefixed_root(&body)
865                .ok_or_else(|| IoError::Format("AEDAT4 IMU packet is not readable".to_owned()))?;
866            for element in table.tables(slot::ELEMENTS) {
867                let t = element.i64(slot::IMU_TIMESTAMP, 0);
868                if t < t0 || t >= t1 {
869                    continue;
870                }
871                // The schema states g and degrees per second; `ImuSample` is m/s² and rad/s.
872                let axes = |base: usize, scale: f64| {
873                    [0, 1, 2].map(|axis| f64::from(element.f32(base + axis * 2)) * scale)
874                };
875                samples.push(ImuSample {
876                    t_us: t,
877                    linear_acceleration: axes(slot::IMU_ACCELEROMETER, STANDARD_GRAVITY),
878                    angular_velocity: axes(slot::IMU_GYROSCOPE, std::f64::consts::PI / 180.0),
879                });
880            }
881        }
882        Ok(samples)
883    }
884}
885
886/// Packets whose span touches the half-open window `[t0, t1)`.
887fn overlapping(packets: &[Packet], t0: i64, t1: i64) -> impl Iterator<Item = &Packet> {
888    packets
889        .iter()
890        .filter(move |packet| packet.end >= t0 && packet.start < t1)
891}
892
893/// Turns a `Frame` table into a greyscale [`EventFrame`].
894fn decode_frame(table: &Table<'_>) -> Result<EventFrame, IoError> {
895    let width = table.i16(slot::FRAME_SIZE_X) as usize;
896    let height = table.i16(slot::FRAME_SIZE_Y) as usize;
897    let format = table.i8(slot::FRAME_FORMAT, FORMAT_GREY);
898    let channels = match format {
899        FORMAT_GREY => 1,
900        FORMAT_BGR => 3,
901        FORMAT_BGRA => 4,
902        other => {
903            return Err(IoError::Unsupported(format!(
904                "AEDAT4 frame format {other} is not an 8-bit image this reader decodes"
905            )))
906        }
907    };
908    let (pixels, _) = table
909        .vector(slot::FRAME_PIXELS, 1)
910        .ok_or_else(|| IoError::Format("AEDAT4 frame has no pixels".to_owned()))?;
911    if pixels.len() != width * height * channels {
912        return Err(IoError::Format(format!(
913            "AEDAT4 frame declares {width}x{height}x{channels} but carries {} bytes",
914            pixels.len()
915        )));
916    }
917    // OpenCV colour is BGR, so the channels are reversed before the luma weights are applied.
918    let samples: Vec<u8> = match channels {
919        1 => pixels.to_vec(),
920        _ => pixels
921            .chunks_exact(channels)
922            .map(|bgr| super::luma(&[bgr[2], bgr[1], bgr[0]]))
923            .collect(),
924    };
925    EventFrame::intensity(EventFrameData::U8(samples), width, height)
926        .map_err(|error| IoError::Format(format!("AEDAT4 frame: {error}")))
927}
928
929/// Reads the `FileDataTable` DV appends after the last packet: one entry per packet, with its
930/// byte offset, element count and time span already computed.
931fn read_data_table(
932    path: &Path,
933    compression: Compression,
934    position: u64,
935    size: u64,
936) -> Result<Vec<(i32, Packet)>, IoError> {
937    let body = read_body(path, compression, position, size as usize)?;
938    let table = Table::size_prefixed_root(&body)
939        .ok_or_else(|| IoError::Format("AEDAT4 data table is not readable".to_owned()))?;
940    Ok(table
941        .tables(slot::TABLE)
942        .iter()
943        .filter_map(|entry| {
944            // `PacketInfo` is an inline struct: `int32 StreamID`, `int32 Size`.
945            let info = entry.field(slot::DEFINITION_PACKET_INFO)?;
946            let id = i32::from_le_bytes(scalar(entry.buffer, info)?);
947            let size = i32::from_le_bytes(scalar(entry.buffer, info + 4)?);
948            Some((
949                id,
950                Packet {
951                    offset: entry.i64(slot::DEFINITION_BYTE_OFFSET, 0).try_into().ok()?,
952                    size: size.try_into().ok()?,
953                    first: 0,
954                    elements: entry
955                        .i64(slot::DEFINITION_NUM_ELEMENTS, 0)
956                        .try_into()
957                        .ok()?,
958                    start: entry.i64(slot::DEFINITION_TIMESTAMP_START, 0),
959                    end: entry.i64(slot::DEFINITION_TIMESTAMP_END, 0),
960                },
961            ))
962        })
963        .collect())
964}
965
966/// Rebuilds the index by reading every packet, for a file whose table is missing. Costs a full
967/// decode pass, which is why DV writes the table in the first place.
968fn walk_packets(
969    path: &Path,
970    compression: Compression,
971    body_offset: u64,
972    total: u64,
973) -> Result<Vec<(i32, Packet)>, IoError> {
974    let mut file = File::open(path)?;
975    file.seek(SeekFrom::Start(body_offset))?;
976    let mut packets = Vec::new();
977    let mut offset = body_offset;
978    let mut header = [0u8; PACKET_HEADER];
979    while offset + PACKET_HEADER as u64 <= total {
980        file.read_exact(&mut header)?;
981        let id = i32::from_le_bytes(header[0..4].try_into().expect("four bytes"));
982        let size = i32::from_le_bytes(header[4..8].try_into().expect("four bytes"));
983        let Ok(size) = usize::try_from(size) else {
984            break;
985        };
986        offset += PACKET_HEADER as u64;
987        if offset + size as u64 > total {
988            break; // truncated tail
989        }
990        let mut packet = Packet {
991            offset,
992            size,
993            first: 0,
994            elements: 0,
995            start: 0,
996            end: 0,
997        };
998        if let Ok(body) = read_body(path, compression, offset, size) {
999            (packet.elements, packet.start, packet.end) = span_of(&body);
1000        }
1001        packets.push((id, packet));
1002        offset += size as u64;
1003        file.seek(SeekFrom::Start(offset))?;
1004    }
1005    Ok(packets)
1006}
1007
1008/// `(elements, first timestamp, last timestamp)` of a decompressed packet, whichever of the
1009/// three shapes it is: a vector of event structs, a vector of tables, or a lone frame.
1010fn span_of(body: &[u8]) -> (usize, i64, i64) {
1011    let Some(table) = Table::size_prefixed_root(body) else {
1012        return (0, 0, 0);
1013    };
1014    if let Some((bytes, elements)) = table.vector(slot::ELEMENTS, EVENT_STRIDE) {
1015        if elements > 0 && bytes.len() >= EVENT_STRIDE {
1016            let at = |chunk: &[u8]| i64::from_le_bytes(chunk[0..8].try_into().expect("eight bytes"));
1017            return (
1018                elements,
1019                at(&bytes[..EVENT_STRIDE]),
1020                at(&bytes[bytes.len() - EVENT_STRIDE..]),
1021            );
1022        }
1023    }
1024    let elements = table.tables(slot::ELEMENTS);
1025    if let (Some(first), Some(last)) = (elements.first(), elements.last()) {
1026        return (
1027            elements.len(),
1028            first.i64(slot::IMU_TIMESTAMP, 0),
1029            last.i64(slot::IMU_TIMESTAMP, 0),
1030        );
1031    }
1032    let t = table.i64(slot::FRAME_TIMESTAMP, 0);
1033    (1, t, t)
1034}
1035
1036/// Opens an AEDAT 4 file for bounded-memory random slicing.
1037pub fn open_aedat4_slice(
1038    path: impl AsRef<Path>,
1039    options: &LoadOptions,
1040) -> Result<Aedat4SliceSource, IoError> {
1041    Aedat4SliceSource::open(path.as_ref(), options)
1042}
1043
1044/// Eagerly reads an AEDAT 4 (`.aedat4`) recording's events. Prefer [`open_aedat4_slice`] for
1045/// large recordings; APS frames and IMU samples are read with the reader's `frames`/`imu`.
1046pub fn read_aedat4(path: impl AsRef<Path>, options: &LoadOptions) -> Result<EventStream, IoError> {
1047    let source = open_aedat4_slice(path, options)?;
1048    let limit = options.max_events.unwrap_or(source.n_events());
1049    source.slice_index(0, limit)
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055
1056    /// The same 64x48 DAVIS recording written by iniVation's own DV under each compression it
1057    /// offers, so these tests check this reader against the format's reference implementation
1058    /// rather than against itself.
1059    const SAMPLE: &str = concat!(
1060        env!("CARGO_MANIFEST_DIR"),
1061        "/../../data/test/sample.aedat4"
1062    );
1063    const SAMPLE_ZSTD: &str = concat!(
1064        env!("CARGO_MANIFEST_DIR"),
1065        "/../../data/test/sample_zstd.aedat4"
1066    );
1067
1068    fn sample() -> Aedat4SliceSource {
1069        open_aedat4_slice(SAMPLE, &LoadOptions::default()).unwrap()
1070    }
1071
1072    #[test]
1073    fn reads_a_recording_written_by_dv() {
1074        let source = sample();
1075        assert_eq!(source.sensor_size(), (64, 48)); // from the header's infoNode
1076        assert_eq!(source.n_events(), 64);
1077        assert_eq!(source.time_span(), (1_000_000, 1_063_000));
1078
1079        let stream = source.slice_index(0, source.n_events()).unwrap();
1080        assert_eq!(
1081            (stream.xs()[0], stream.ys()[0], stream.ts()[0], stream.ps()[0]),
1082            (0, 0, 1_000_000, false)
1083        );
1084        assert_eq!(
1085            (
1086                stream.xs()[63],
1087                stream.ys()[63],
1088                stream.ts()[63],
1089                stream.ps()[63]
1090            ),
1091            (63, 45, 1_063_000, true)
1092        );
1093    }
1094
1095    #[test]
1096    fn slices_agree_with_a_whole_file_read() {
1097        let source = sample();
1098        let whole = source.slice_index(0, source.n_events()).unwrap();
1099        let head = source.slice_index(0, 10).unwrap();
1100        assert_eq!(head.ts(), &whole.ts()[..10]);
1101        let window = source.slice_time(1_010_000, 1_015_000).unwrap();
1102        assert_eq!(window.ts(), &[1_010_000, 1_011_000, 1_012_000, 1_013_000, 1_014_000]);
1103    }
1104
1105    #[test]
1106    fn frames_and_imu_come_back_beside_the_events() {
1107        let source = sample();
1108        let frames = source.frames(0, i64::MAX).unwrap();
1109        assert_eq!(frames.len(), 2);
1110        assert_eq!(frames[0].0, 1_000_000);
1111        assert_eq!(frames[0].1.shape(), (1, 48, 64));
1112        let EventFrameData::U8(pixels) = frames[0].1.data() else {
1113            panic!("an 8-bit APS frame");
1114        };
1115        // The fixture's image is `(index + 5k) % 251`, so its corners pin the row order down.
1116        assert_eq!((pixels[0], pixels[pixels.len() - 1]), (0, 59));
1117
1118        let samples = source.imu(0, i64::MAX).unwrap();
1119        assert_eq!(samples.len(), 8);
1120        assert_eq!(samples[0].t_us, 1_000_000);
1121        // The fixture records -1 g on Y and no rotation; the schema is g and °/s, this is SI.
1122        assert!((samples[0].linear_acceleration[1] + STANDARD_GRAVITY).abs() < 1e-6);
1123        assert_eq!(samples[0].angular_velocity, [0.0; 3]);
1124
1125        // Windows apply to the auxiliary streams too.
1126        assert_eq!(source.frames(1_020_000, i64::MAX).unwrap().len(), 1);
1127        assert_eq!(source.imu(0, 1_010_000).unwrap().len(), 2);
1128    }
1129
1130    #[test]
1131    fn compression_does_not_change_what_is_read() {
1132        // Same recording, written twice — once LZ4, once Zstd. Both decoders have to produce
1133        // the same bytes, which is what makes the compression an implementation detail.
1134        let lz4 = sample();
1135        let zstd = open_aedat4_slice(SAMPLE_ZSTD, &LoadOptions::default()).unwrap();
1136        assert_eq!(zstd.sensor_size(), lz4.sensor_size());
1137        assert_eq!(zstd.n_events(), lz4.n_events());
1138        assert_eq!(zstd.time_span(), lz4.time_span());
1139
1140        let (a, b) = (
1141            lz4.slice_index(0, lz4.n_events()).unwrap(),
1142            zstd.slice_index(0, zstd.n_events()).unwrap(),
1143        );
1144        assert_eq!((a.xs(), a.ys(), a.ts(), a.ps()), (b.xs(), b.ys(), b.ts(), b.ps()));
1145        assert_eq!(
1146            lz4.frames(0, i64::MAX).unwrap()[0].1.data(),
1147            zstd.frames(0, i64::MAX).unwrap()[0].1.data()
1148        );
1149        assert_eq!(lz4.imu(0, i64::MAX).unwrap(), zstd.imu(0, i64::MAX).unwrap());
1150    }
1151
1152    /// Builds a minimal uncompressed AEDAT 4 file: an `IOHeader` naming one event stream, then
1153    /// `events` in a single packet. `data_table` puts a `dataTablePosition` in the header;
1154    /// without one the reader has to walk the packets instead.
1155    fn minimal_file(events: &[(i64, i16, i16, bool)]) -> Vec<u8> {
1156        minimal_file_packets(&[events.to_vec()])
1157    }
1158
1159
1160    #[test]
1161    fn a_multi_packet_file_decodes_the_same_in_parallel_as_in_sequence() {
1162        // `slice_index` fans out above `PARALLEL_PACKET_THRESHOLD` packets, where workers write
1163        // into disjoint slices of the final columns. Every other fixture here is a single packet,
1164        // so without this the wide path is never executed by `cargo test`. (It needs a rayon pool
1165        // of more than one thread to take that branch; under `RAYON_NUM_THREADS=1` this still
1166        // passes, just against the sequential path.)
1167        let packets: Vec<Vec<(i64, i16, i16, bool)>> = (0..PARALLEL_PACKET_THRESHOLD + 4)
1168            .map(|packet| {
1169                (0..5)
1170                    .map(|index| {
1171                        let n = (packet * 5 + index) as i64;
1172                        (1_000 + n, (n % 8) as i16, (n % 4) as i16, n % 2 == 0)
1173                    })
1174                    .collect()
1175            })
1176            .collect();
1177        let total: usize = packets.iter().map(Vec::len).sum();
1178        let path = write_temporary(&minimal_file_packets(&packets), "multi_packet");
1179        let source = open_aedat4_slice(&path, &LoadOptions::default()).unwrap();
1180        assert_eq!(source.n_events(), total);
1181
1182        let whole = source.slice_index(0, total).unwrap();
1183        assert_eq!(whole.len(), total, "no event may be lost across packets");
1184        // The events were generated in stream order, so the columns must come back in it.
1185        let expected: Vec<(i64, u16, u16, bool)> = packets
1186            .iter()
1187            .flatten()
1188            .map(|&(t, x, y, p)| (t, x as u16, y as u16, p))
1189            .collect();
1190        for (index, &(t, x, y, p)) in expected.iter().enumerate() {
1191            assert_eq!(
1192                (whole.ts()[index], whole.xs()[index], whole.ys()[index], whole.ps()[index]),
1193                (t, x, y, p),
1194                "event {index}"
1195            );
1196        }
1197
1198        // A sub-range that starts and ends inside a packet exercises the partial first/last packet
1199        // bookkeeping the wide path sets up.
1200        let part = source.slice_index(7, total - 3).unwrap();
1201        assert_eq!(part.len(), total - 10);
1202        assert_eq!(part.ts()[0], expected[7].0);
1203        assert_eq!(part.xs()[0], expected[7].1);
1204        assert_eq!(*part.ts().last().unwrap(), expected[total - 4].0);
1205        std::fs::remove_file(path).ok();
1206    }
1207
1208    #[test]
1209    fn off_sensor_events_are_dropped_without_disturbing_the_rest() {
1210        // The wide path sizes its output from the packet index, which counts every element; an
1211        // event outside the 8x4 sensor is then dropped and leaves a hole that has to be closed.
1212        // Every packet here loses one, so the compaction runs for all of them at once.
1213        let packets: Vec<Vec<(i64, i16, i16, bool)>> = (0..PARALLEL_PACKET_THRESHOLD + 4)
1214            .map(|packet| {
1215                let base = (packet * 3) as i64;
1216                vec![
1217                    (1_000 + base, (packet % 8) as i16, 1, true),
1218                    (1_001 + base, 100, 1, false), // x past the 8-wide sensor
1219                    (1_002 + base, (packet % 8) as i16, 2, false),
1220                ]
1221            })
1222            .collect();
1223        let indexed: usize = packets.iter().map(Vec::len).sum();
1224        let path = write_temporary(&minimal_file_packets(&packets), "multi_packet_dropped");
1225        let source = open_aedat4_slice(&path, &LoadOptions::default()).unwrap();
1226        assert_eq!(source.n_events(), indexed, "the index counts every element");
1227
1228        let whole = source.slice_index(0, indexed).unwrap();
1229        let expected: Vec<(i64, u16, u16)> = packets
1230            .iter()
1231            .flatten()
1232            .filter(|&&(_, x, _, _)| x < 8)
1233            .map(|&(t, x, y, _)| (t, x as u16, y as u16))
1234            .collect();
1235        assert_eq!(whole.len(), expected.len(), "two survivors a packet");
1236        for (index, &(t, x, y)) in expected.iter().enumerate() {
1237            assert_eq!(
1238                (whole.ts()[index], whole.xs()[index], whole.ys()[index]),
1239                (t, x, y),
1240                "event {index} after compaction"
1241            );
1242        }
1243        assert!(
1244            whole.ts().windows(2).all(|w| w[0] <= w[1]),
1245            "compaction must not reorder"
1246        );
1247        std::fs::remove_file(path).ok();
1248    }
1249
1250    /// The same synthetic recording, split across several packets — the shape `slice_index` decodes
1251    /// in parallel, which a single-packet fixture can never reach.
1252    fn minimal_file_packets(packets: &[Vec<(i64, i16, i16, bool)>]) -> Vec<u8> {
1253        let info = "<node name=\"0\" path=\"/outInfo/0/\">\
1254                    <attr key=\"typeIdentifier\" type=\"string\">EVTS</attr>\
1255                    <node name=\"info\"><attr key=\"sizeX\" type=\"int\">8</attr>\
1256                    <attr key=\"sizeY\" type=\"int\">4</attr></node></node>";
1257
1258        // IOHeader: root offset, vtable, table, then the string it points at.
1259        let mut header = Vec::new();
1260        header.extend_from_slice(&14u32.to_le_bytes()); // root table at 14
1261        header.extend_from_slice(&10u16.to_le_bytes()); // vtable spans slots 4, 6 and 8
1262        header.extend_from_slice(&20u16.to_le_bytes()); // table length
1263        for offset in [4u16, 8, 16] {
1264            header.extend_from_slice(&offset.to_le_bytes());
1265        }
1266        header.extend_from_slice(&10i32.to_le_bytes()); // soffset: table 14 - vtable 4
1267        header.extend_from_slice(&0i32.to_le_bytes()); // compression NONE
1268        header.extend_from_slice(&(-1i64).to_le_bytes()); // no data table: force the walk
1269        header.extend_from_slice(&4u32.to_le_bytes()); // infoNode is the next thing written
1270        header.extend_from_slice(&(info.len() as u32).to_le_bytes());
1271        header.extend_from_slice(info.as_bytes());
1272        header.push(0); // strings are null-terminated
1273
1274        // EventPacket: size prefix, root offset, vtable, table, then the element vector.
1275        let build = |events: &[(i64, i16, i16, bool)]| {
1276            let mut packet = Vec::new();
1277            packet.extend_from_slice(&10u32.to_le_bytes()); // root table at 14, relative to 4
1278            packet.extend_from_slice(&6u16.to_le_bytes()); // vtable spans slot 4 only
1279            packet.extend_from_slice(&8u16.to_le_bytes()); // table length
1280            packet.extend_from_slice(&4u16.to_le_bytes()); // elements at table + 4
1281            packet.extend_from_slice(&6i32.to_le_bytes()); // soffset: table 14 - vtable 8
1282            packet.extend_from_slice(&4u32.to_le_bytes()); // the vector is the next thing written
1283            packet.extend_from_slice(&(events.len() as u32).to_le_bytes());
1284            for &(t, x, y, polarity) in events {
1285                packet.extend_from_slice(&t.to_le_bytes());
1286                packet.extend_from_slice(&x.to_le_bytes());
1287                packet.extend_from_slice(&y.to_le_bytes());
1288                packet.push(polarity as u8);
1289                packet.extend_from_slice(&[0; 3]); // struct padding to the 8-byte alignment
1290            }
1291            packet
1292        };
1293
1294        let mut file = MAGIC.to_vec();
1295        file.extend_from_slice(&(header.len() as u32).to_le_bytes());
1296        file.extend_from_slice(&header);
1297        // Each packet: an 8-byte `PacketHeader`, then the size-prefixed FlatBuffer body.
1298        for events in packets {
1299            let packet = build(events);
1300            file.extend_from_slice(&0i32.to_le_bytes()); // stream id
1301            file.extend_from_slice(&((packet.len() + 4) as i32).to_le_bytes()); // body size
1302            file.extend_from_slice(&(packet.len() as u32).to_le_bytes()); // FlatBuffer size prefix
1303            file.extend_from_slice(&packet);
1304        }
1305        file
1306    }
1307
1308    fn write_temporary(data: &[u8], tag: &str) -> PathBuf {
1309        use std::io::Write;
1310        let path = std::env::temp_dir().join(format!(
1311            "eventcv_aedat4_{}_{tag}.aedat4",
1312            std::process::id()
1313        ));
1314        File::create(&path).unwrap().write_all(data).unwrap();
1315        path
1316    }
1317
1318    #[test]
1319    fn a_file_without_a_data_table_is_indexed_by_walking_its_packets() {
1320        let events = [(10i64, 1i16, 2i16, true), (20, 3, 0, false), (30, 7, 3, true)];
1321        let path = write_temporary(&minimal_file(&events), "walk");
1322        let source = open_aedat4_slice(&path, &LoadOptions::default()).unwrap();
1323
1324        assert_eq!(source.sensor_size(), (8, 4));
1325        assert_eq!(source.n_events(), 3);
1326        assert_eq!(source.time_span(), (10, 30));
1327        let stream = source.slice_index(0, 3).unwrap();
1328        assert_eq!(stream.ts(), &[10, 20, 30]);
1329        assert_eq!(stream.xs(), &[1, 3, 7]);
1330        assert_eq!(stream.ps(), &[true, false, true]);
1331        std::fs::remove_file(&path).ok();
1332    }
1333
1334    #[test]
1335    fn a_truncated_file_errors_rather_than_reading_past_its_end() {
1336        let full = minimal_file(&[(10, 1, 2, true), (20, 3, 0, false)]);
1337        // Every prefix must fail cleanly — the point is that none of them panics.
1338        for cut in [4, MAGIC.len(), MAGIC.len() + 8, full.len() - 40, full.len() - 8] {
1339            let path = write_temporary(&full[..cut], "cut");
1340            match open_aedat4_slice(&path, &LoadOptions::default()) {
1341                Err(_) => {}
1342                Ok(source) => {
1343                    // A prefix that still parses must at least not invent events.
1344                    let _ = source.slice_index(0, source.n_events()).unwrap();
1345                }
1346            }
1347            std::fs::remove_file(&path).ok();
1348        }
1349    }
1350
1351    #[test]
1352    fn a_file_that_is_not_aedat4_is_a_format_error() {
1353        let path = write_temporary(b"#!AER-DAT2.0\r\nrubbish", "wrong");
1354        match open_aedat4_slice(&path, &LoadOptions::default()) {
1355            Err(IoError::Format(message)) => assert!(message.contains("AEDAT 4")),
1356            other => panic!("expected a format error, got {:?}", other.map(|_| ())),
1357        }
1358        std::fs::remove_file(&path).ok();
1359    }
1360
1361    #[test]
1362    fn the_header_xml_is_scanned_for_stream_types_and_resolution() {
1363        let info = "<node name=\"outInfo\"><node name=\"0\">\
1364                    <attr key=\"typeIdentifier\" type=\"string\">EVTS</attr>\
1365                    <node name=\"info\"><attr key=\"sizeX\" type=\"int\">640</attr>\
1366                    <attr key=\"sizeY\" type=\"int\">480</attr></node></node>\
1367                    <node name=\"1\"><attr key=\"typeIdentifier\" type=\"string\">FRME</attr></node>\
1368                    <node name=\"2\"><attr key=\"typeIdentifier\" type=\"string\">TRIG</attr></node>\
1369                    </node>";
1370        let streams = parse_streams(info);
1371        assert_eq!(streams.len(), 3); // `outInfo` and `info` are not stream nodes
1372        assert_eq!(streams[0].id, 0);
1373        assert_eq!(streams[0].kind, Kind::Events);
1374        assert_eq!(streams[0].size, Some((640, 480)));
1375        assert_eq!(streams[1].kind, Kind::Frames);
1376        assert_eq!(streams[1].size, None);
1377        assert_eq!(streams[2].kind, Kind::Other); // triggers are read past, not decoded
1378    }
1379
1380    #[test]
1381    fn unknown_compression_is_rejected_by_name() {
1382        assert_eq!(Compression::from_header(0).unwrap(), Compression::None);
1383        assert_eq!(Compression::from_header(2).unwrap(), Compression::Lz4);
1384        assert_eq!(Compression::from_header(4).unwrap(), Compression::Zstd);
1385        match Compression::from_header(9) {
1386            Err(IoError::Unsupported(message)) => assert!(message.contains("compression")),
1387            other => panic!("expected an unsupported-compression error, got {other:?}"),
1388        }
1389    }
1390
1391    #[test]
1392    fn table_reads_are_bounds_checked() {
1393        // A root offset past the end, a vtable past the end, and a vector claiming more
1394        // elements than the buffer holds: all `None`, none a panic.
1395        assert!(Table::root(&[0xFF, 0xFF, 0xFF, 0x7F]).is_none());
1396        let table = Table::root(&[4, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0x7F]).unwrap();
1397        assert!(table.field(slot::ELEMENTS).is_none());
1398        assert_eq!(table.i64(slot::ELEMENTS, -7), -7); // absent field falls back to the default
1399    }
1400}
1401
1402// ---------------------------------------------------------------------------------------------
1403// Writing
1404// ---------------------------------------------------------------------------------------------
1405
1406/// FlatBuffer file identifiers, the four bytes each root carries at offset 4. Taken from the files
1407/// DV writes rather than from the schemas, since the schemas are not shipped with a recording.
1408const IDENT_HEADER: &[u8; 4] = b"IOHE";
1409const IDENT_EVENTS: &[u8; 4] = b"EVTS";
1410const IDENT_TABLE: &[u8; 4] = b"FTAB";
1411
1412/// The one stream a written recording declares. Events only: a sink is handed an [`EventStream`],
1413/// which carries neither frames nor IMU.
1414const EVENT_STREAM_ID: i32 = 0;
1415
1416/// Events per packet. DV writes a packet per capture interval; a sink has no intervals, so this
1417/// bounds the working buffer instead — 100k events is ~1.6 MB uncompressed, which compresses in
1418/// one pass and keeps the data table short enough to stay a useful index.
1419const PACKET_EVENTS: usize = 100_000;
1420
1421/// A minimal FlatBuffer *writer*, the counterpart of the [`Table`] reader above.
1422///
1423/// Buffers are built back-to-front, which is what the format's self-relative offsets require, so
1424/// the bytes are accumulated reversed and flipped once at the end. Offsets are therefore counted
1425/// from the buffer's end: an object written when `len()` was `n` ends up at final position
1426/// `total - n`, and that single convention is what every offset below is expressed in.
1427///
1428/// This exists for the same reason the reader hand-rolls its decoding: the four schemas involved
1429/// are small and fixed, and the `flatbuffers` crate's builder would pull in a code generator to
1430/// emit what amounts to the hundred lines below.
1431struct FlatBuilder {
1432    /// The buffer reversed — `rev[0]` is the final buffer's last byte.
1433    rev: Vec<u8>,
1434    /// The strictest alignment anything asked for, used to pad the front at the end.
1435    min_align: usize,
1436}
1437
1438impl FlatBuilder {
1439    fn new() -> Self {
1440        Self {
1441            rev: Vec::new(),
1442            min_align: 4,
1443        }
1444    }
1445
1446    fn len(&self) -> usize {
1447        self.rev.len()
1448    }
1449
1450    /// Pads so that `size` more bytes land on an `align` boundary.
1451    fn pre_align(&mut self, size: usize, align: usize) {
1452        self.min_align = self.min_align.max(align);
1453        let pad = (align - ((self.len() + size) % align)) % align;
1454        self.rev.resize(self.len() + pad, 0);
1455    }
1456
1457    /// Writes `bytes` at the current position (reversed, so they read forwards in the result).
1458    fn push(&mut self, bytes: &[u8]) {
1459        self.rev.extend(bytes.iter().rev());
1460    }
1461
1462    /// Writes one aligned scalar and returns its offset.
1463    fn push_scalar(&mut self, bytes: &[u8]) -> usize {
1464        self.pre_align(bytes.len(), bytes.len());
1465        self.push(bytes);
1466        self.len()
1467    }
1468
1469    /// Writes a `uoffset` pointing at `target`, which must already have been written.
1470    fn push_uoffset(&mut self, target: usize) -> usize {
1471        self.pre_align(4, 4);
1472        let value = (self.len() + 4 - target) as u32;
1473        self.push(&value.to_le_bytes());
1474        self.len()
1475    }
1476
1477    /// Writes a length-prefixed UTF-8 string (NUL-terminated, as FlatBuffers stores them).
1478    fn push_string(&mut self, value: &str) -> usize {
1479        self.pre_align(value.len() + 1, 4);
1480        self.push(&[0]);
1481        self.push(value.as_bytes());
1482        self.push_scalar(&(value.len() as u32).to_le_bytes())
1483    }
1484
1485    /// Writes a vector of inline structs from `data` (already in final byte order), returning the
1486    /// offset of its length prefix.
1487    fn push_struct_vector(&mut self, data: &[u8], count: usize, align: usize) -> usize {
1488        self.pre_align(data.len(), align);
1489        self.push(data);
1490        self.push_scalar(&(count as u32).to_le_bytes())
1491    }
1492
1493    /// Closes a table whose fields have already been written.
1494    ///
1495    /// `start` is `len()` from before the first field, and `fields` pairs each occupied vtable slot
1496    /// with the offset the field was written at. Every table gets its own vtable — flatc's builder
1497    /// dedupes identical ones, which saves bytes but nothing a reader can tell apart.
1498    fn end_table(&mut self, start: usize, fields: &[(usize, usize)]) -> usize {
1499        self.pre_align(4, 4);
1500        self.push(&0i32.to_le_bytes()); // patched below, once the vtable's position is known
1501        let table = self.len();
1502
1503        let slots = fields.iter().map(|(slot, _)| *slot).max().unwrap_or(2);
1504        let entries = (slots.saturating_sub(4)) / 2 + 1;
1505        let mut vtable = vec![0u16; 2 + entries];
1506        vtable[0] = (vtable.len() * 2) as u16;
1507        vtable[1] = (table - start) as u16;
1508        for (slot, offset) in fields {
1509            vtable[2 + (slot - 4) / 2] = (table - offset) as u16;
1510        }
1511        self.pre_align(vtable.len() * 2, 2);
1512        for value in vtable.iter().rev() {
1513            self.push(&value.to_le_bytes());
1514        }
1515        let vtable_at = self.len();
1516
1517        // soffset = table position - vtable position, positive because the vtable precedes it.
1518        let soffset = (vtable_at - table) as i32;
1519        let bytes = soffset.to_le_bytes();
1520        self.rev[table - 4..table].copy_from_slice(&[bytes[3], bytes[2], bytes[1], bytes[0]]);
1521        table
1522    }
1523
1524    /// Emits the finished buffer: the root offset, the file identifier, and — for the packet
1525    /// bodies, which are framed that way — a leading length.
1526    fn finish(mut self, root: usize, identifier: &[u8; 4], size_prefixed: bool) -> Vec<u8> {
1527        let prefix = 4 + 4 + usize::from(size_prefixed) * 4;
1528        self.pre_align(prefix, self.min_align);
1529        self.push(identifier);
1530        self.push_uoffset(root);
1531        if size_prefixed {
1532            let size = self.len() as u32;
1533            self.push(&size.to_le_bytes());
1534        }
1535        self.rev.reverse();
1536        self.rev
1537    }
1538}
1539
1540/// The `infoNode` XML declaring the single event stream, in the shape DV writes and
1541/// [`parse_streams`] reads back.
1542fn info_node(width: usize, height: usize, compression: Compression) -> String {
1543    let compression = match compression {
1544        Compression::None => "NONE",
1545        Compression::Lz4 => "LZ4",
1546        Compression::Zstd => "ZSTD",
1547    };
1548    format!(
1549        "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
1550         <dv version=\"2.0\">\n\
1551         \x20   <node name=\"outInfo\" path=\"/outInfo/\">\n\
1552         \x20       <node name=\"{EVENT_STREAM_ID}\" path=\"/outInfo/{EVENT_STREAM_ID}/\">\n\
1553         \x20           <attr key=\"compression\" type=\"string\">{compression}</attr>\n\
1554         \x20           <attr key=\"originalModuleName\" type=\"string\">eventcv</attr>\n\
1555         \x20           <attr key=\"originalOutputName\" type=\"string\">events</attr>\n\
1556         \x20           <attr key=\"typeDescription\" type=\"string\">Event data.</attr>\n\
1557         \x20           <attr key=\"typeIdentifier\" type=\"string\">EVTS</attr>\n\
1558         \x20           <node name=\"info\" path=\"/outInfo/{EVENT_STREAM_ID}/info/\">\n\
1559         \x20               <attr key=\"sizeX\" type=\"int\">{width}</attr>\n\
1560         \x20               <attr key=\"sizeY\" type=\"int\">{height}</attr>\n\
1561         \x20               <attr key=\"source\" type=\"string\">eventcv</attr>\n\
1562         \x20           </node>\n\
1563         \x20       </node>\n\
1564         \x20   </node>\n\
1565         </dv>\n"
1566    )
1567}
1568
1569/// Builds the `IOHeader` FlatBuffer, and reports where its `dataTablePosition` field sits within
1570/// the buffer so the sink can patch it once the table has been written.
1571fn build_header(width: usize, height: usize, compression: Compression) -> (Vec<u8>, usize) {
1572    let mut builder = FlatBuilder::new();
1573    let info = builder.push_string(&info_node(width, height, compression));
1574    let start = builder.len();
1575    let compression_at = builder.push_scalar(&compression.to_header().to_le_bytes());
1576    // Written as -1 — "no table" — so a recording abandoned before `finish` still opens, by the
1577    // same packet walk the reader uses for an interrupted DV recording.
1578    let position_at = builder.push_scalar(&(-1i64).to_le_bytes());
1579    let info_at = builder.push_uoffset(info);
1580    let root = builder.end_table(
1581        start,
1582        &[
1583            (slot::COMPRESSION, compression_at),
1584            (slot::DATA_TABLE_POSITION, position_at),
1585            (slot::INFO_NODE, info_at),
1586        ],
1587    );
1588    let buffer = builder.finish(root, IDENT_HEADER, false);
1589    // Offsets were counted from the end; convert the field's to a position in the finished buffer.
1590    let field = buffer.len() - position_at;
1591    (buffer, field)
1592}
1593
1594/// One packet as the data table records it.
1595#[derive(Clone, Copy, Debug)]
1596struct Definition {
1597    byte_offset: i64,
1598    size: i32,
1599    elements: i64,
1600    start: i64,
1601    end: i64,
1602}
1603
1604/// Builds the trailing `FileDataTable` — the index that makes a recording sliceable without
1605/// reading it.
1606fn build_data_table(definitions: &[Definition]) -> Vec<u8> {
1607    let mut builder = FlatBuilder::new();
1608    // Children before parents: every definition table is written first, then the vector of
1609    // offsets to them, then the table that holds it.
1610    let mut offsets = Vec::with_capacity(definitions.len());
1611    for definition in definitions.iter().rev() {
1612        let start = builder.len();
1613        let byte_offset = builder.push_scalar(&definition.byte_offset.to_le_bytes());
1614        let elements = builder.push_scalar(&definition.elements.to_le_bytes());
1615        let start_at = builder.push_scalar(&definition.start.to_le_bytes());
1616        let end_at = builder.push_scalar(&definition.end.to_le_bytes());
1617        // `PacketInfo` is an inline struct of two `int32`s, so it is written in place.
1618        builder.pre_align(8, 4);
1619        builder.push(&definition.size.to_le_bytes());
1620        builder.push(&EVENT_STREAM_ID.to_le_bytes());
1621        let info = builder.len();
1622        offsets.push(builder.end_table(
1623            start,
1624            &[
1625                (slot::DEFINITION_BYTE_OFFSET, byte_offset),
1626                (slot::DEFINITION_PACKET_INFO, info),
1627                (slot::DEFINITION_NUM_ELEMENTS, elements),
1628                (slot::DEFINITION_TIMESTAMP_START, start_at),
1629                (slot::DEFINITION_TIMESTAMP_END, end_at),
1630            ],
1631        ));
1632    }
1633    offsets.reverse();
1634
1635    builder.pre_align(offsets.len() * 4, 4);
1636    let mut vector_at = builder.len();
1637    for offset in offsets.iter().rev() {
1638        vector_at = builder.push_uoffset(*offset);
1639    }
1640    let _ = vector_at;
1641    let vector = builder.push_scalar(&(offsets.len() as u32).to_le_bytes());
1642
1643    let start = builder.len();
1644    let table_at = builder.push_uoffset(vector);
1645    let root = builder.end_table(start, &[(slot::TABLE, table_at)]);
1646    builder.finish(root, IDENT_TABLE, true)
1647}
1648
1649/// Builds one `EventPacket` body from `stream[range]`.
1650fn build_event_packet(stream: &EventStream, range: std::ops::Range<usize>) -> Vec<u8> {
1651    let (xs, ys, ts, ps) = (stream.xs(), stream.ys(), stream.ts(), stream.ps());
1652    let mut elements = Vec::with_capacity(range.len() * EVENT_STRIDE);
1653    for index in range.clone() {
1654        elements.extend_from_slice(&ts[index].to_le_bytes());
1655        elements.extend_from_slice(&(xs[index] as i16).to_le_bytes());
1656        elements.extend_from_slice(&(ys[index] as i16).to_le_bytes());
1657        elements.push(u8::from(ps[index]));
1658        elements.extend_from_slice(&[0; 3]); // padding to the struct's 8-byte alignment
1659    }
1660    let mut builder = FlatBuilder::new();
1661    let vector = builder.push_struct_vector(&elements, range.len(), 8);
1662    let start = builder.len();
1663    let elements_at = builder.push_uoffset(vector);
1664    let root = builder.end_table(start, &[(slot::ELEMENTS, elements_at)]);
1665    builder.finish(root, IDENT_EVENTS, true)
1666}
1667
1668/// Writes an AEDAT 4.0 (`.aedat4`) recording a window at a time — the inverse of [`read_aedat4`].
1669///
1670/// The `IOHeader` goes out on the first non-empty append, because its `infoNode` names the sensor
1671/// size. Each append is then split into packets of at most [`PACKET_EVENTS`] events; every packet
1672/// is a size-prefixed `EventPacket` FlatBuffer, compressed, behind an 8-byte `PacketHeader`.
1673///
1674/// The `FileDataTable` is written at [`finish`](super::EventSink::finish) and its position patched
1675/// back into the header, which is what makes the result sliceable rather than merely readable. A
1676/// recording abandoned before then still opens: the header's `dataTablePosition` stays `-1`, and
1677/// the reader falls back to walking the packets — the same path it takes for a DV recording that
1678/// was interrupted.
1679pub struct Aedat4EventSink {
1680    file: File,
1681    compression: Compression,
1682    /// Byte offset of the header's `dataTablePosition` field; `None` until the header is written.
1683    position_field: Option<u64>,
1684    definitions: Vec<Definition>,
1685    n_events: usize,
1686}
1687
1688impl Aedat4EventSink {
1689    pub fn create(path: impl AsRef<Path>, compression: Compression) -> Result<Self, IoError> {
1690        Ok(Self {
1691            file: File::create(path.as_ref())?,
1692            compression,
1693            position_field: None,
1694            definitions: Vec::new(),
1695            n_events: 0,
1696        })
1697    }
1698
1699    /// Compresses and writes one packet, recording its data-table entry.
1700    fn write_packet(&mut self, stream: &EventStream, range: std::ops::Range<usize>) -> Result<(), IoError> {
1701        let ts = stream.ts();
1702        let body = self
1703            .compression
1704            .encode(&build_event_packet(stream, range.clone()))?;
1705        let size = i32::try_from(body.len()).map_err(|_| {
1706            IoError::Unsupported("an AEDAT4 packet body exceeds 2 GB".to_owned())
1707        })?;
1708        self.file.write_all(&EVENT_STREAM_ID.to_le_bytes())?;
1709        self.file.write_all(&size.to_le_bytes())?;
1710        let byte_offset = self.file.stream_position()?;
1711        self.file.write_all(&body)?;
1712        self.definitions.push(Definition {
1713            byte_offset: byte_offset as i64,
1714            size,
1715            elements: range.len() as i64,
1716            start: ts[range.start],
1717            end: ts[range.end - 1],
1718        });
1719        Ok(())
1720    }
1721}
1722
1723impl super::EventSink for Aedat4EventSink {
1724    fn append(&mut self, stream: &EventStream) -> Result<(), IoError> {
1725        if stream.is_empty() {
1726            return Ok(());
1727        }
1728        if self.position_field.is_none() {
1729            let (width, height) = stream.sensor_size();
1730            let (header, field) = build_header(width, height, self.compression);
1731            self.file.write_all(MAGIC)?;
1732            self.file.write_all(&(header.len() as u32).to_le_bytes())?;
1733            self.file.write_all(&header)?;
1734            self.position_field = Some((MAGIC.len() + 4 + field) as u64);
1735        }
1736        let mut start = 0;
1737        while start < stream.len() {
1738            let end = (start + PACKET_EVENTS).min(stream.len());
1739            self.write_packet(stream, start..end)?;
1740            start = end;
1741        }
1742        self.n_events += stream.len();
1743        Ok(())
1744    }
1745
1746    fn n_events(&self) -> usize {
1747        self.n_events
1748    }
1749
1750    fn flush(&mut self) -> Result<(), IoError> {
1751        self.file.flush().map_err(IoError::Io)
1752    }
1753
1754    fn finish(mut self: Box<Self>) -> Result<(), IoError> {
1755        let Some(field) = self.position_field else {
1756            // Nothing was ever appended: an empty header with no streams would not describe an
1757            // event recording, so write one for a 1x1 sensor — what the readers infer for an
1758            // empty stream anyway.
1759            let (header, _) = build_header(1, 1, self.compression);
1760            self.file.write_all(MAGIC)?;
1761            self.file.write_all(&(header.len() as u32).to_le_bytes())?;
1762            self.file.write_all(&header)?;
1763            return self.file.flush().map_err(IoError::Io);
1764        };
1765        let position = self.file.stream_position()?;
1766        let table = self.compression.encode(&build_data_table(&self.definitions))?;
1767        self.file.write_all(&table)?;
1768        self.file.seek(SeekFrom::Start(field))?;
1769        self.file.write_all(&(position as i64).to_le_bytes())?;
1770        self.file.flush().map_err(IoError::Io)
1771    }
1772}