Skip to main content

roxlap_formats/
voxel_clip.rs

1//! Animated voxel-sprite clips (`.rvc`) — a "GIF/MP4 for voxel models".
2//!
3//! A [`VoxelClip`](crate::voxel_clip::VoxelClip) is a fixed-bounding-box sequence of voxel frames,
4//! encoded as **keyframes + inter-frame diffs** (like video I/P frames),
5//! for effects such as flame, spells, and muzzle flashes. See
6//! `PORTING-VOXEL-CLIP.md` for the full design (stage VCL).
7//!
8//! ## Frame representation
9//!
10//! A frame is stored in the same **dense-column layout** the GPU sprite
11//! model uses ([`roxlap-gpu`'s `SpriteModel`]): a per-`(x, y)`-column
12//! occupancy bitmask plus per-column ascending-z colour runs. Columns
13//! are indexed `col = x + y * dims[0]`; a column's occupancy is
14//! [`occ_words_per_col`](crate::voxel_clip::VoxelClip::occ_words_per_col) u32 words, bit
15//! `z & 31` of word `z >> 5`. This makes GPU upload a field move (no
16//! bucket-sort) and makes diffs clean (per column). Surface-normal
17//! `dir` indices are **recomputed at [`decode`](crate::voxel_clip::VoxelClip::decode)** from
18//! the reconstructed occupancy, so the on-disk codec carries only
19//! occupancy + colour.
20//!
21//! ## On-disk format (`.rvc`)
22//!
23//! ```text
24//! magic   b"RVCL"
25//! version u16 = 2
26//! chunks  [tag(4) | flags(u8) | len(u32) | payload]  until EOF; unknown
27//!         tags preserved. flags bit0 = payload is raw-deflated, stored as
28//!         raw_len(u32) | deflate_bytes (and `len` counts that). Each chunk
29//!         is deflated only when it shrinks; small ones stay raw.
30//!   META : dims[3] u32, pivot[3] f32, voxel_world_size f32,
31//!          loop_mode u8, default_frame_ms u32, frame_count u32
32//!   FRMS : per frame: kind u8 {Key=0, Delta=1}; Key = full frame
33//!          (occupancy + color_offsets + colors, each u32-len-prefixed);
34//!          Delta = changed_count u32 + per changed column
35//!          (col u32, occ_words_per_col × u32, color_run len+u32s)
36//!   TIME : optional per-frame durations (frame_count × u32 ms)
37//! ```
38//!
39//! Compression is per-chunk deflate (`miniz_oxide`): the occupancy
40//! bitmasks + colour runs compress well, while `META` / small chunks stay
41//! raw. **v1** (no `flags` byte, every payload raw) still parses.
42
43use crate::bytes::{Cursor, OutOfBounds};
44use crate::kv6::{compute_vis_dir, Kv6, Voxel};
45
46const MAGIC: [u8; 4] = *b"RVCL";
47/// Current on-disk version. v2 adds a per-chunk `flags` byte (deflate).
48const VERSION: u16 = 2;
49/// v1 had no per-chunk `flags` byte and stored every payload raw; still
50/// readable.
51const VERSION_LEGACY: u16 = 1;
52
53const TAG_META: [u8; 4] = *b"META";
54const TAG_FRMS: [u8; 4] = *b"FRMS";
55const TAG_TIME: [u8; 4] = *b"TIME";
56
57/// Chunk `flags` bit: the payload is raw-deflated (`raw_len(u32) | data`).
58const CHUNK_FLAG_DEFLATED: u8 = 0x01;
59/// Cap on a deflated chunk's claimed uncompressed size (decompression-bomb
60/// guard). A real `.rvc` chunk never approaches this; a larger claim is
61/// rejected before it can drive a giant allocation.
62const MAX_CHUNK_INFLATE: usize = 64 << 20; // 64 MiB
63
64/// QE.6b - per-axis clip dimension cap. Bounds every dims-derived
65/// allocation (`cols * occ_words_per_col` peaks at ~512 MiB of u32s at
66/// 4096 cubed - large but finite; real clips are orders of magnitude
67/// smaller) and keeps `cols * owpc` far from usize overflow on 32-bit
68/// targets (wasm).
69const MAX_CLIP_DIM: u32 = 4096;
70/// miniz_oxide deflate level for `.rvc` writes (clips are written once,
71/// read often — favour ratio over encode speed, but level 10 is overkill).
72const DEFLATE_LEVEL: u8 = 8;
73
74const FRAME_KIND_KEY: u8 = 0;
75const FRAME_KIND_DELTA: u8 = 1;
76
77/// How playback advances past the last frame.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum LoopMode {
80    /// Wrap back to frame 0 (the default for ambient effects).
81    Loop,
82    /// Hold the last frame (one-shot, e.g. a spell impact).
83    Once,
84    /// Bounce 0→N→0 (ping-pong).
85    PingPong,
86}
87
88impl LoopMode {
89    fn to_u8(self) -> u8 {
90        match self {
91            Self::Loop => 0,
92            Self::Once => 1,
93            Self::PingPong => 2,
94        }
95    }
96    fn from_u8(v: u8) -> Option<Self> {
97        match v {
98            0 => Some(Self::Loop),
99            1 => Some(Self::Once),
100            2 => Some(Self::PingPong),
101            _ => None,
102        }
103    }
104}
105
106/// One fully-reconstructed frame in the dense-column layout. Field shapes
107/// are validated against the clip's `dims` by [`VoxelFrame::validate`].
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct VoxelFrame {
110    /// Per-column occupancy bitmask, `dims[0] * dims[1] *
111    /// occ_words_per_col` words. Bit `z & 31` of word
112    /// `col * occ_words_per_col + (z >> 5)` is set iff voxel `(x, y, z)`
113    /// is solid, where `col = x + y * dims[0]`.
114    pub occupancy: Vec<u32>,
115    /// Voxel colours (voxlap-packed `0x80RRGGBB`), ascending z within
116    /// each column, columns in `col` order.
117    pub colors: Vec<u32>,
118    /// Prefix sums: `color_offsets[col]` is the first colour index of
119    /// column `col`; length `dims[0] * dims[1] + 1`.
120    pub color_offsets: Vec<u32>,
121}
122
123impl VoxelFrame {
124    /// Build one dense-column [`VoxelFrame`] from a `.kv6` model — the
125    /// authoring bridge from a voxel sprite to a clip frame. The frame's
126    /// dims are the kv6's `[xsiz, ysiz, zsiz]`; the kv6's pivot +
127    /// voxel-world-size travel at the clip level (see
128    /// [`VoxelClip::from_kv6_frames`]).
129    ///
130    /// `.kv6` already stores surface voxels per `(x, y)` column in
131    /// ascending z — the very layout a frame wants — so this is a re-index
132    /// from the kv6's x-major column order (`x · ysiz + y`) to the frame's
133    /// `col = x + y · xsiz`, packing each column's z's into the occupancy
134    /// bitmask. Each column is sorted by z so the colour run is ascending
135    /// even if the source isn't strictly ordered; voxels with `z >= zsiz`
136    /// are dropped (defensive against malformed input).
137    #[must_use]
138    #[allow(clippy::cast_possible_truncation)]
139    pub fn from_kv6(kv6: &Kv6) -> Self {
140        let dims = [kv6.xsiz, kv6.ysiz, kv6.zsiz];
141        let (nx, ny) = (dims[0] as usize, dims[1] as usize);
142        let cols = nx * ny;
143        let owpc = occ_words_per_col(dims) as usize;
144        let zmax = dims[2];
145
146        // Bucket the kv6's flat voxel stream into the frame's column index.
147        // Defensive against a malformed (e.g. externally-parsed) kv6 whose
148        // `ylen` / `voxels` don't agree with the header dims: every index is
149        // bounds-checked, and only columns inside `dims` are mapped (the rest
150        // are still consumed so the stream stays aligned).
151        let mut per_col: Vec<Vec<(u16, u32)>> = vec![Vec::new(); cols];
152        let mut vi = 0usize;
153        for x in 0..nx {
154            let col_counts = kv6.ylen.get(x).map_or(&[][..], Vec::as_slice);
155            for (y, &cnt) in col_counts.iter().enumerate() {
156                let start = vi.min(kv6.voxels.len());
157                let end = (start + cnt as usize).min(kv6.voxels.len());
158                if y < ny {
159                    let col = x + y * nx; // frame ordering (x-fastest)
160                    for v in &kv6.voxels[start..end] {
161                        if u32::from(v.z) < zmax {
162                            per_col[col].push((v.z, v.col));
163                        }
164                    }
165                }
166                vi = end;
167            }
168        }
169
170        let mut occupancy = vec![0u32; cols * owpc];
171        let mut colors = Vec::new();
172        let mut color_offsets = Vec::with_capacity(cols + 1);
173        color_offsets.push(0u32);
174        for (col, run) in per_col.iter_mut().enumerate() {
175            run.sort_by_key(|&(z, _)| z);
176            for &(z, c) in run.iter() {
177                let zi = z as usize;
178                occupancy[col * owpc + zi / 32] |= 1u32 << (zi % 32);
179                colors.push(c);
180            }
181            color_offsets.push(colors.len() as u32);
182        }
183
184        Self {
185            occupancy,
186            colors,
187            color_offsets,
188        }
189    }
190
191    /// Inverse of [`from_kv6`](Self::from_kv6): materialise this frame as a
192    /// flat-lit `.kv6` model (every voxel `vis = 63`, `dir = 0` — voxel
193    /// clips render full-bright, so per-face normals are unused). `dims` is
194    /// the clip bounding box, `pivot` becomes the kv6 pivot. Lets a single
195    /// streaming-clip frame drive `add_sprite_model` / `refresh_sprite_model`
196    /// (one model re-uploaded per frame) instead of an N-frame flipbook.
197    #[must_use]
198    #[allow(clippy::cast_possible_truncation)]
199    pub fn to_kv6(&self, dims: [u32; 3], pivot: [f32; 3]) -> Kv6 {
200        let (nx, ny) = (dims[0] as usize, dims[1] as usize);
201        let owpc = occ_words_per_col(dims) as usize;
202        let mut voxels = Vec::new();
203        let mut xlen = Vec::with_capacity(nx);
204        let mut ylen = Vec::with_capacity(nx);
205
206        // `.kv6` walks x-major, then y, then ascending z; the frame's column
207        // index is x-fastest (`col = x + y·nx`), so re-index back.
208        for x in 0..nx {
209            let mut col_counts: Vec<u16> = Vec::with_capacity(ny);
210            let mut xcount = 0u32;
211            for y in 0..ny {
212                let col = x + y * nx;
213                let run = self.column_colors(col);
214                let occ = &self.occupancy[col * owpc..(col + 1) * owpc];
215                let before = voxels.len();
216                let mut ci = 0usize;
217                for z in 0..dims[2] {
218                    if (occ[(z >> 5) as usize] >> (z & 31)) & 1 != 0 {
219                        voxels.push(Voxel {
220                            col: run[ci],
221                            z: z as u16,
222                            vis: 63,
223                            dir: 0,
224                        });
225                        ci += 1;
226                    }
227                }
228                let c = (voxels.len() - before) as u16;
229                col_counts.push(c);
230                xcount += u32::from(c);
231            }
232            xlen.push(xcount);
233            ylen.push(col_counts);
234        }
235
236        Kv6 {
237            xsiz: dims[0],
238            ysiz: dims[1],
239            zsiz: dims[2],
240            xpiv: pivot[0],
241            ypiv: pivot[1],
242            zpiv: pivot[2],
243            voxels,
244            xlen,
245            ylen,
246            palette: None,
247        }
248    }
249
250    /// Per-voxel surface-normal LUT indices (`dir`), parallel to `colors`,
251    /// recomputed from the occupancy — the same dirs [`decode`](VoxelClip::decode)
252    /// caches per frame. The GPU sprite-model upload carries these; lets an
253    /// in-place frame edit build a model identical to the register path
254    /// (rather than flat zeros). The frame must be valid for `dims`.
255    #[must_use]
256    pub fn dirs(&self, dims: [u32; 3]) -> Vec<u32> {
257        frame_dirs(self, dims, occ_words_per_col(dims) as usize)
258    }
259
260    /// Check the field shapes + per-column occupancy/colour agreement for
261    /// the given clip `dims`.
262    ///
263    /// # Errors
264    /// Returns the offending [`FrameError`] (wrong array length, broken
265    /// prefix-sum bounds/monotonicity, or a column whose occupancy
266    /// popcount disagrees with its colour-run length).
267    pub fn validate(&self, dims: [u32; 3]) -> Result<(), FrameError> {
268        let cols = (dims[0] as usize) * (dims[1] as usize);
269        let owpc = occ_words_per_col(dims) as usize;
270        if self.occupancy.len() != cols * owpc {
271            return Err(FrameError::OccupancyLen);
272        }
273        if self.color_offsets.len() != cols + 1 {
274            return Err(FrameError::OffsetsLen);
275        }
276        if self.color_offsets[0] != 0
277            || *self.color_offsets.last().unwrap() as usize != self.colors.len()
278        {
279            return Err(FrameError::OffsetsBounds);
280        }
281        for col in 0..cols {
282            let lo = self.color_offsets[col];
283            let hi = self.color_offsets[col + 1];
284            if hi < lo {
285                return Err(FrameError::OffsetsMonotonic);
286            }
287            let run = (hi - lo) as usize;
288            let mut popcount = 0usize;
289            for w in 0..owpc {
290                popcount += self.occupancy[col * owpc + w].count_ones() as usize;
291            }
292            if popcount != run {
293                return Err(FrameError::OccupancyColorMismatch(col));
294            }
295        }
296        Ok(())
297    }
298
299    /// The colours of column `col` (`colors[color_offsets[col]..[col+1]]`).
300    fn column_colors(&self, col: usize) -> &[u32] {
301        &self.colors[self.color_offsets[col] as usize..self.color_offsets[col + 1] as usize]
302    }
303
304    /// The occupancy words of column `col`.
305    fn column_occ(&self, col: usize, owpc: usize) -> &[u32] {
306        &self.occupancy[col * owpc..(col + 1) * owpc]
307    }
308}
309
310/// A per-column overwrite in a delta (P-) frame: the column's new
311/// occupancy words + new ascending-z colour run.
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct ColumnDelta {
314    pub col: u32,
315    pub occ: Vec<u32>,
316    pub colors: Vec<u32>,
317}
318
319/// One frame as stored in a [`VoxelClip`]: a full keyframe or a diff
320/// against the previous reconstructed frame.
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub enum EncodedFrame {
323    /// Full frame (I-frame).
324    Key(VoxelFrame),
325    /// Sparse list of changed columns relative to the previous frame.
326    Delta(Vec<ColumnDelta>),
327}
328
329/// On-disk animated voxel clip. Construct via [`VoxelClip::from_frames`]
330/// (the encoder) or [`VoxelClip::parse`]; expand to a runtime flipbook
331/// via [`VoxelClip::decode`].
332#[derive(Debug, Clone, PartialEq)]
333pub struct VoxelClip {
334    pub dims: [u32; 3],
335    pub pivot: [f32; 3],
336    pub voxel_world_size: f32,
337    pub loop_mode: LoopMode,
338    /// Frame duration used when `durations` is empty.
339    pub default_frame_ms: u32,
340    /// I/P frame stream. The first frame must be a `Key`.
341    pub frames: Vec<EncodedFrame>,
342    /// Per-frame durations (ms); empty ⇒ uniform `default_frame_ms`.
343    pub durations: Vec<u32>,
344    /// Unknown top-level chunks, preserved verbatim for forward-compat.
345    pub extra_chunks: Vec<([u8; 4], Vec<u8>)>,
346}
347
348/// A decoded clip: every frame expanded to a full [`VoxelFrame`] plus its
349/// recomputed `dirs` (parallel to `frames[i].colors`) and resolved
350/// durations. The runtime flipbook.
351#[derive(Debug, Clone)]
352pub struct DecodedClip {
353    pub dims: [u32; 3],
354    pub pivot: [f32; 3],
355    pub voxel_world_size: f32,
356    pub occ_words_per_col: u32,
357    pub loop_mode: LoopMode,
358    pub frames: Vec<VoxelFrame>,
359    /// Per-frame surface-normal LUT indices, parallel to
360    /// `frames[i].colors`.
361    pub dirs: Vec<Vec<u32>>,
362    pub durations: Vec<u32>,
363}
364
365impl DecodedClip {
366    #[must_use]
367    pub fn frame_count(&self) -> usize {
368        self.frames.len()
369    }
370
371    /// Total loop length in ms (sum of frame durations), saturating rather
372    /// than overflowing for absurdly long clips.
373    #[must_use]
374    pub fn total_ms(&self) -> u32 {
375        self.durations
376            .iter()
377            .fold(0u32, |acc, &d| acc.saturating_add(d))
378    }
379
380    /// Padding diagnostics — declared `dims` vs. the tight content bbox
381    /// across all frames (R3). See [`pad_stats`] / [`PadStats`].
382    #[must_use]
383    pub fn pad_stats(&self) -> PadStats {
384        pad_stats(self.dims, &self.frames)
385    }
386
387    /// The frame index to show after `elapsed_ms` of playback, honouring
388    /// the clip's [`LoopMode`] and per-frame durations. Pure — the host
389    /// (or the facade's clip-instance clocks) drives `set_clip_instance_frame`
390    /// from this. Empty clip ⇒ `0`.
391    ///
392    /// - [`LoopMode::Loop`]: wraps modulo the total length.
393    /// - [`LoopMode::Once`]: holds the last frame past the end.
394    /// - [`LoopMode::PingPong`]: bounces `0→N-1→0` over `2·total`.
395    #[must_use]
396    pub fn frame_at(&self, elapsed_ms: u32) -> usize {
397        frame_at(&self.durations, self.loop_mode, elapsed_ms)
398    }
399}
400
401/// The frame index to show after `elapsed_ms` of playback, given per-frame
402/// `durations` (ms) + a [`LoopMode`] — the pure playback math behind
403/// [`DecodedClip::frame_at`], usable on its own so a per-instance clock
404/// (e.g. a character clip attachment, VCL.6) can resolve a frame without
405/// holding the whole [`DecodedClip`]. Empty / single-frame ⇒ `0`.
406///
407/// - [`LoopMode::Loop`]: wraps modulo the total length.
408/// - [`LoopMode::Once`]: holds the last frame past the end.
409/// - [`LoopMode::PingPong`]: bounces `0→N-1→0` over `2·total`.
410#[must_use]
411pub fn frame_at(durations: &[u32], loop_mode: LoopMode, elapsed_ms: u32) -> usize {
412    let n = durations.len();
413    if n <= 1 {
414        return 0;
415    }
416    // u64 throughout: a long clip's total (and `2·total` for PingPong) can
417    // exceed u32.
418    let total: u64 = durations.iter().map(|&d| u64::from(d)).sum();
419    if total == 0 {
420        return 0;
421    }
422    let elapsed = u64::from(elapsed_ms);
423    // Position within one forward pass (after applying the loop mode).
424    let t = match loop_mode {
425        LoopMode::Loop => elapsed % total,
426        LoopMode::Once => elapsed.min(total - 1),
427        LoopMode::PingPong => {
428            let p = elapsed % (2 * total);
429            if p < total {
430                p
431            } else {
432                // Mirror the second half back: 2·total-1 → ~0.
433                2 * total - 1 - p
434            }
435        }
436    };
437    // Walk the duration prefix sums to find the frame holding `t`.
438    let mut acc = 0u64;
439    for (i, &d) in durations.iter().enumerate() {
440        acc += u64::from(d);
441        if t < acc {
442            return i;
443        }
444    }
445    n - 1
446}
447
448/// Inclusive prefix sums of `durations` (ms, u64) — `prefix[i]` is the
449/// clip time at which frame `i` ENDS. Build once per timeline and feed
450/// [`frame_at_prefix`] so a per-frame playback clock resolves its frame
451/// by binary search instead of re-summing the whole duration list
452/// (PF.13 / S4).
453#[must_use]
454pub fn duration_prefix_sums(durations: &[u32]) -> Vec<u64> {
455    let mut acc = 0u64;
456    durations
457        .iter()
458        .map(|&d| {
459            acc += u64::from(d);
460            acc
461        })
462        .collect()
463}
464
465/// [`frame_at`] over precomputed [`duration_prefix_sums`] — identical
466/// results (including zero-duration frame skipping and the `n-1` hold
467/// past the end), but O(log n) per call. `prefix` must be the inclusive
468/// prefix-sum vector of the same duration list.
469#[must_use]
470pub fn frame_at_prefix(prefix: &[u64], loop_mode: LoopMode, elapsed_ms: u32) -> usize {
471    let n = prefix.len();
472    if n <= 1 {
473        return 0;
474    }
475    let total = prefix[n - 1];
476    if total == 0 {
477        return 0;
478    }
479    let elapsed = u64::from(elapsed_ms);
480    let t = match loop_mode {
481        LoopMode::Loop => elapsed % total,
482        LoopMode::Once => elapsed.min(total - 1),
483        LoopMode::PingPong => {
484            let p = elapsed % (2 * total);
485            if p < total {
486                p
487            } else {
488                2 * total - 1 - p
489            }
490        }
491    };
492    // First frame whose (inclusive) end time exceeds `t` — the same
493    // frame the linear walk in `frame_at` lands on, because the prefix
494    // is non-decreasing.
495    prefix.partition_point(|&acc| acc <= t).min(n - 1)
496}
497
498/// u32 occupancy words per `(x, y)` column for a clip of `dims`.
499#[must_use]
500pub fn occ_words_per_col(dims: [u32; 3]) -> u32 {
501    dims[2].div_ceil(32).max(1)
502}
503
504/// Padding diagnostics for a clip (R3): a clip is a *fixed* bounding box, so
505/// every frame's occupancy bitmask is sized for `dims` even when its content
506/// only fills a corner — a clip with one big frame over-allocates the rest.
507/// [`pad_stats`] reports the declared `dims` vs. the tight `content_dims`;
508/// callers can warn (the encoder stays side-effect-free):
509///
510/// ```
511/// # use roxlap_formats::voxel_clip::{VoxelClip, LoopMode};
512/// # let frames = vec![];
513/// # let dims = [1, 1, 1];
514/// let stats = roxlap_formats::voxel_clip::pad_stats(dims, &frames);
515/// if stats.is_wasteful() {
516///     eprintln!("clip wastes padding: dims {:?} but content fits {:?} ({:.1}× volume)",
517///         stats.dims, stats.content_dims, stats.pad_ratio());
518/// }
519/// ```
520#[derive(Debug, Clone, Copy, PartialEq, Eq)]
521pub struct PadStats {
522    /// The clip's declared bounding box.
523    pub dims: [u32; 3],
524    /// Tight bounding box (extent) of all solid voxels across every frame —
525    /// the smallest dims that would still hold the content. `[0; 3]` for an
526    /// empty clip.
527    pub content_dims: [u32; 3],
528    /// Solid voxels summed across all frames (context for a warning).
529    pub solid_voxels: u64,
530}
531
532impl PadStats {
533    /// Voxel-volume ratio of the declared bbox to the tight content bbox:
534    /// `1.0` = perfectly tight, `8.0` = the declared box holds 8× the
535    /// content's span (⅞ of every frame's occupancy is pure padding). `1.0`
536    /// for an empty clip.
537    #[must_use]
538    pub fn pad_ratio(&self) -> f32 {
539        let content = vol(self.content_dims);
540        if content == 0 {
541            1.0
542        } else {
543            vol(self.dims) as f32 / content as f32
544        }
545    }
546
547    /// Whether the clip wastes significant space on padding — the declared
548    /// bbox is `≥ 2×` the content's span, so re-bounding the frames to
549    /// `content_dims` would at least halve the per-frame occupancy storage.
550    #[must_use]
551    pub fn is_wasteful(&self) -> bool {
552        self.pad_ratio() >= 2.0
553    }
554}
555
556fn vol(d: [u32; 3]) -> u64 {
557    u64::from(d[0]) * u64::from(d[1]) * u64::from(d[2])
558}
559
560/// Compute [`PadStats`] for a clip's `dims` + its full `frames` (the encoder
561/// has these before diffing; see also [`DecodedClip::pad_stats`]). Walks the
562/// occupancy of every frame to find the tightest content bbox. Empty columns
563/// are skipped, so it's cheap for sparse clips.
564#[must_use]
565pub fn pad_stats(dims: [u32; 3], frames: &[VoxelFrame]) -> PadStats {
566    let owpc = occ_words_per_col(dims) as usize;
567    let (mx, my, mz) = (dims[0], dims[1], dims[2]);
568    let mut min = [u32::MAX; 3];
569    let mut max = [0u32; 3];
570    let mut solid_voxels = 0u64;
571    let mut any = false;
572
573    for f in frames {
574        for y in 0..my {
575            for x in 0..mx {
576                let base = (x + y * mx) as usize * owpc;
577                let occ = match f.occupancy.get(base..base + owpc) {
578                    Some(o) if o.iter().any(|&w| w != 0) => o,
579                    _ => continue, // empty / malformed column
580                };
581                for z in 0..mz {
582                    if (occ[(z >> 5) as usize] >> (z & 31)) & 1 != 0 {
583                        any = true;
584                        solid_voxels += 1;
585                        min[0] = min[0].min(x);
586                        min[1] = min[1].min(y);
587                        min[2] = min[2].min(z);
588                        max[0] = max[0].max(x);
589                        max[1] = max[1].max(y);
590                        max[2] = max[2].max(z);
591                    }
592                }
593            }
594        }
595    }
596
597    let content_dims = if any {
598        [
599            max[0] - min[0] + 1,
600            max[1] - min[1] + 1,
601            max[2] - min[2] + 1,
602        ]
603    } else {
604        [0; 3]
605    };
606    PadStats {
607        dims,
608        content_dims,
609        solid_voxels,
610    }
611}
612
613/// A seekable, **O(1-frame)-memory** cursor over a [`VoxelClip`]'s I/P
614/// stream — the streaming alternative to [`DecodedClip`], which
615/// materialises *every* frame (and which the GPU/CPU flipbook then holds N
616/// volumes for). For a huge clip this keeps one reconstructed frame plus
617/// the compact encoded stream instead of N full frames.
618///
619/// Seeking to a frame replays deltas from the nearest preceding keyframe;
620/// stepping forward from the current frame is incremental. Drive it from
621/// [`frame_at`] like the flipbook, then rebuild a single sprite model from
622/// [`current_frame`](Self::current_frame) (+ [`current_dirs`](Self::current_dirs)
623/// for the GPU) each time the frame changes — e.g. via
624/// `roxlap_core::SpriteDense::from_voxel_frame` or
625/// `SceneRenderer::refresh_sprite_model`.
626#[derive(Debug, Clone)]
627pub struct StreamingClip {
628    dims: [u32; 3],
629    pivot: [f32; 3],
630    voxel_world_size: f32,
631    loop_mode: LoopMode,
632    owpc: usize,
633    cols: usize,
634    /// Owned copy of the encoded I/P stream (the compact representation).
635    frames: Vec<EncodedFrame>,
636    durations: Vec<u32>,
637    /// Ascending indices of the keyframes in `frames` (the seek points).
638    keyframes: Vec<usize>,
639    // --- cursor state ---
640    work_occ: Vec<u32>,
641    work_cols: Vec<Vec<u32>>,
642    /// Frame index currently reconstructed in the working set.
643    current: usize,
644    cur_frame: VoxelFrame,
645    cur_dirs: Vec<u32>,
646}
647
648impl StreamingClip {
649    /// Build a streaming cursor over `clip` and reconstruct frame 0.
650    ///
651    /// # Errors
652    /// [`DecodeError::DeltaBeforeKey`] if the stream is empty or doesn't
653    /// start with a keyframe; otherwise the same per-frame errors as
654    /// [`VoxelClip::decode`] (surfaced lazily while seeking).
655    pub fn new(clip: &VoxelClip) -> Result<Self, DecodeError> {
656        if !matches!(clip.frames.first(), Some(EncodedFrame::Key(_))) {
657            return Err(DecodeError::DeltaBeforeKey);
658        }
659        let owpc = occ_words_per_col(clip.dims) as usize;
660        let cols = (clip.dims[0] as usize) * (clip.dims[1] as usize);
661        let keyframes = clip
662            .frames
663            .iter()
664            .enumerate()
665            .filter_map(|(i, f)| matches!(f, EncodedFrame::Key(_)).then_some(i))
666            .collect();
667        let durations = if clip.durations.is_empty() {
668            vec![clip.default_frame_ms; clip.frames.len()]
669        } else {
670            clip.durations.clone()
671        };
672        let mut s = Self {
673            dims: clip.dims,
674            pivot: clip.pivot,
675            voxel_world_size: clip.voxel_world_size,
676            loop_mode: clip.loop_mode,
677            owpc,
678            cols,
679            frames: clip.frames.clone(),
680            durations,
681            keyframes,
682            work_occ: vec![0u32; cols * owpc],
683            work_cols: vec![Vec::new(); cols],
684            current: 0,
685            cur_frame: VoxelFrame {
686                occupancy: Vec::new(),
687                colors: Vec::new(),
688                color_offsets: Vec::new(),
689            },
690            cur_dirs: Vec::new(),
691        };
692        s.reconstruct(0)?;
693        Ok(s)
694    }
695
696    #[must_use]
697    pub fn frame_count(&self) -> usize {
698        self.frames.len()
699    }
700    #[must_use]
701    pub fn dims(&self) -> [u32; 3] {
702        self.dims
703    }
704    #[must_use]
705    pub fn pivot(&self) -> [f32; 3] {
706        self.pivot
707    }
708    #[must_use]
709    pub fn voxel_world_size(&self) -> f32 {
710        self.voxel_world_size
711    }
712    #[must_use]
713    pub fn loop_mode(&self) -> LoopMode {
714        self.loop_mode
715    }
716    #[must_use]
717    pub fn durations(&self) -> &[u32] {
718        &self.durations
719    }
720    /// Frame index currently reconstructed.
721    #[must_use]
722    pub fn current_index(&self) -> usize {
723        self.current
724    }
725    /// The currently-reconstructed frame.
726    #[must_use]
727    pub fn current_frame(&self) -> &VoxelFrame {
728        &self.cur_frame
729    }
730    /// Per-voxel `dir` LUT indices for the current frame, parallel to
731    /// `current_frame().colors` (for GPU sprite-model upload).
732    #[must_use]
733    pub fn current_dirs(&self) -> &[u32] {
734        &self.cur_dirs
735    }
736
737    /// Seek to `frame` (clamped to the last frame) and return the
738    /// reconstructed [`VoxelFrame`]. Forward seeks step incrementally;
739    /// backward / random seeks replay from the nearest preceding keyframe.
740    ///
741    /// # Errors
742    /// Per-frame [`DecodeError`]s from a malformed stream (out-of-range
743    /// delta column, invalid reconstructed frame).
744    pub fn seek(&mut self, frame: usize) -> Result<&VoxelFrame, DecodeError> {
745        let target = frame.min(self.frame_count() - 1);
746        if target != self.current || self.cur_frame.occupancy.is_empty() {
747            self.reconstruct(target)?;
748        }
749        Ok(&self.cur_frame)
750    }
751
752    /// Largest keyframe index `<= target` (frame 0 is always a keyframe).
753    fn keyframe_at_or_before(&self, target: usize) -> usize {
754        let pp = self.keyframes.partition_point(|&k| k <= target);
755        self.keyframes[pp - 1]
756    }
757
758    /// Rebuild the working set + materialised frame/dirs at `target`.
759    fn reconstruct(&mut self, target: usize) -> Result<(), DecodeError> {
760        // Step forward from the current frame when possible; otherwise reset
761        // the working set to the nearest preceding keyframe and replay.
762        let start = if target > self.current && !self.cur_frame.occupancy.is_empty() {
763            self.current + 1
764        } else {
765            let kf = self.keyframe_at_or_before(target);
766            let mut started = false;
767            apply_frame(
768                &self.frames[kf],
769                &mut self.work_occ,
770                &mut self.work_cols,
771                self.dims,
772                self.owpc,
773                self.cols,
774                &mut started,
775            )?;
776            kf + 1
777        };
778        let mut started = true;
779        for i in start..=target {
780            // Disjoint field borrows: `frames` (read) vs the working set.
781            let ef = &self.frames[i];
782            apply_frame(
783                ef,
784                &mut self.work_occ,
785                &mut self.work_cols,
786                self.dims,
787                self.owpc,
788                self.cols,
789                &mut started,
790            )?;
791        }
792        self.current = target;
793        self.cur_frame = flatten(&self.work_occ, &self.work_cols, self.cols);
794        self.cur_frame
795            .validate(self.dims)
796            .map_err(DecodeError::Frame)?;
797        self.cur_dirs = frame_dirs(&self.cur_frame, self.dims, self.owpc);
798        Ok(())
799    }
800}
801
802/// When the auto-encoder ([`VoxelClip::from_frames_auto`]) stores a frame as
803/// a keyframe instead of a delta: once the delta would be at least this
804/// percentage of the keyframe's size. A delta that big is barely a saving
805/// and a keyframe is independently seekable, so prefer the keyframe.
806const KEYFRAME_COST_PCT: usize = 60;
807
808/// Per-column diff of `frame` against `prev` (same `dims`): the columns whose
809/// occupancy or colour run changed, as [`ColumnDelta`]s. Shared by the
810/// interval + auto encoders.
811fn column_diff(
812    prev: &VoxelFrame,
813    frame: &VoxelFrame,
814    cols: usize,
815    owpc: usize,
816) -> Vec<ColumnDelta> {
817    let mut changed = Vec::new();
818    for col in 0..cols {
819        if prev.column_occ(col, owpc) != frame.column_occ(col, owpc)
820            || prev.column_colors(col) != frame.column_colors(col)
821        {
822            changed.push(ColumnDelta {
823                col: col as u32,
824                occ: frame.column_occ(col, owpc).to_vec(),
825                colors: frame.column_colors(col).to_vec(),
826            });
827        }
828    }
829    changed
830}
831
832/// Serialised size (in u32 words) of `frame` stored as a keyframe.
833fn key_words(frame: &VoxelFrame) -> usize {
834    // occupancy + color_offsets + colors arrays, each with a length prefix.
835    frame.occupancy.len() + frame.color_offsets.len() + frame.colors.len() + 3
836}
837
838/// Serialised size (in u32 words) of a `changed`-column delta.
839fn delta_words(changed: &[ColumnDelta], owpc: usize) -> usize {
840    // changed_count + per column: col + occ words + colour-run (len + data).
841    1 + changed
842        .iter()
843        .map(|d| 1 + owpc + 1 + d.colors.len())
844        .sum::<usize>()
845}
846
847impl VoxelClip {
848    /// u32 occupancy words per column for this clip.
849    #[must_use]
850    pub fn occ_words_per_col(&self) -> u32 {
851        occ_words_per_col(self.dims)
852    }
853
854    #[must_use]
855    pub fn frame_count(&self) -> usize {
856        self.frames.len()
857    }
858
859    /// Build a clip from a sequence of `.kv6` frames sharing one
860    /// `[xsiz, ysiz, zsiz]` — the authoring path from animated voxel
861    /// sprites to a `.rvc` clip. Each kv6 becomes a [`VoxelFrame`] via
862    /// [`VoxelFrame::from_kv6`], then the lot is encoded with
863    /// [`VoxelClip::from_frames`] (frame 0 + every `keyframe_interval`-th a
864    /// keyframe; `0` ⇒ only frame 0). The pivot comes from the first
865    /// frame's kv6; `voxel_world_size` is the render scale (`.kv6` carries
866    /// none). `durations` is per-frame ms (empty ⇒ uniform
867    /// `default_frame_ms`).
868    ///
869    /// # Errors
870    /// [`Kv6ImportError::Empty`] if `frames` is empty;
871    /// [`Kv6ImportError::DimsMismatch`] if any frame's dims differ from the
872    /// first (clips are fixed-bbox).
873    pub fn from_kv6_frames(
874        frames: &[Kv6],
875        voxel_world_size: f32,
876        loop_mode: LoopMode,
877        durations: &[u32],
878        default_frame_ms: u32,
879        keyframe_interval: u32,
880    ) -> Result<Self, Kv6ImportError> {
881        let (dims, pivot, vframes) = Self::kv6_frames_prepare(frames)?;
882        Ok(Self::from_frames(
883            dims,
884            pivot,
885            voxel_world_size,
886            loop_mode,
887            &vframes,
888            durations,
889            default_frame_ms,
890            keyframe_interval,
891        ))
892    }
893
894    /// Like [`from_kv6_frames`](Self::from_kv6_frames) but **auto-chooses**
895    /// keyframe vs. delta per frame via
896    /// [`from_frames_auto`](Self::from_frames_auto) (VCL.1) — the turnkey
897    /// "import `.kv6` frames into a well-encoded clip" path. `max_keyframe_gap`
898    /// caps keyframe spacing (`0` = fully cost-driven).
899    ///
900    /// # Errors
901    /// Same as [`from_kv6_frames`](Self::from_kv6_frames).
902    pub fn from_kv6_frames_auto(
903        frames: &[Kv6],
904        voxel_world_size: f32,
905        loop_mode: LoopMode,
906        durations: &[u32],
907        default_frame_ms: u32,
908        max_keyframe_gap: u32,
909    ) -> Result<Self, Kv6ImportError> {
910        let (dims, pivot, vframes) = Self::kv6_frames_prepare(frames)?;
911        Ok(Self::from_frames_auto(
912            dims,
913            pivot,
914            voxel_world_size,
915            loop_mode,
916            &vframes,
917            durations,
918            default_frame_ms,
919            max_keyframe_gap,
920        ))
921    }
922
923    /// Validate a `.kv6` frame sequence (non-empty + uniform dims) and map it
924    /// to `(dims, pivot, frames)` for the import encoders.
925    #[allow(clippy::type_complexity)]
926    fn kv6_frames_prepare(
927        frames: &[Kv6],
928    ) -> Result<([u32; 3], [f32; 3], Vec<VoxelFrame>), Kv6ImportError> {
929        let Some(first) = frames.first() else {
930            return Err(Kv6ImportError::Empty);
931        };
932        let dims = [first.xsiz, first.ysiz, first.zsiz];
933        for (i, k) in frames.iter().enumerate() {
934            let d = [k.xsiz, k.ysiz, k.zsiz];
935            if d != dims {
936                return Err(Kv6ImportError::DimsMismatch {
937                    frame: i,
938                    dims: d,
939                    expected: dims,
940                });
941            }
942        }
943        let pivot = [first.xpiv, first.ypiv, first.zpiv];
944        let vframes = frames.iter().map(VoxelFrame::from_kv6).collect();
945        Ok((dims, pivot, vframes))
946    }
947
948    /// Encode a sequence of full frames into a clip. Frame 0 (and every
949    /// `keyframe_interval`-th frame) is stored as a keyframe; the rest are
950    /// diffed against the previous frame. `keyframe_interval == 0` ⇒ only
951    /// frame 0 is a keyframe (smallest, but no mid-stream seek points).
952    ///
953    /// `durations` is per-frame ms; pass an empty slice for uniform
954    /// `default_frame_ms`.
955    ///
956    /// # Panics
957    /// If any frame fails [`VoxelFrame::validate`] for `dims`, or
958    /// `durations` is non-empty but not `frames.len()` long.
959    #[must_use]
960    pub fn from_frames(
961        dims: [u32; 3],
962        pivot: [f32; 3],
963        voxel_world_size: f32,
964        loop_mode: LoopMode,
965        frames: &[VoxelFrame],
966        durations: &[u32],
967        default_frame_ms: u32,
968        keyframe_interval: u32,
969    ) -> Self {
970        for (i, f) in frames.iter().enumerate() {
971            f.validate(dims)
972                .unwrap_or_else(|e| panic!("frame {i} invalid: {e:?}"));
973        }
974        assert!(
975            durations.is_empty() || durations.len() == frames.len(),
976            "durations must be empty or one per frame",
977        );
978        let owpc = occ_words_per_col(dims) as usize;
979        let cols = (dims[0] as usize) * (dims[1] as usize);
980
981        let mut encoded = Vec::with_capacity(frames.len());
982        for (i, frame) in frames.iter().enumerate() {
983            let is_key = i == 0 || (keyframe_interval != 0 && (i as u32) % keyframe_interval == 0);
984            if is_key {
985                encoded.push(EncodedFrame::Key(frame.clone()));
986            } else {
987                encoded.push(EncodedFrame::Delta(column_diff(
988                    &frames[i - 1],
989                    frame,
990                    cols,
991                    owpc,
992                )));
993            }
994        }
995
996        Self {
997            dims,
998            pivot,
999            voxel_world_size,
1000            loop_mode,
1001            default_frame_ms,
1002            frames: encoded,
1003            durations: durations.to_vec(),
1004            extra_chunks: Vec::new(),
1005        }
1006    }
1007
1008    /// Encode a sequence of full frames, **auto-choosing** keyframe vs. delta
1009    /// per frame (VCL.1) instead of a fixed interval — the codec's I-frame
1010    /// decision. A frame is stored as a keyframe when its delta would be
1011    /// large (a "scene change": at least `KEYFRAME_COST_PCT`% of the
1012    /// keyframe's size — a delta that big is barely a saving and a keyframe
1013    /// is independently seekable), or when `max_keyframe_gap` frames have
1014    /// passed since the last keyframe (a seekability cap; `0` = no cap, fully
1015    /// cost-driven). Frame 0 is always a keyframe. Otherwise identical to
1016    /// [`from_frames`](Self::from_frames).
1017    ///
1018    /// # Panics
1019    /// Same as [`from_frames`](Self::from_frames).
1020    #[must_use]
1021    pub fn from_frames_auto(
1022        dims: [u32; 3],
1023        pivot: [f32; 3],
1024        voxel_world_size: f32,
1025        loop_mode: LoopMode,
1026        frames: &[VoxelFrame],
1027        durations: &[u32],
1028        default_frame_ms: u32,
1029        max_keyframe_gap: u32,
1030    ) -> Self {
1031        for (i, f) in frames.iter().enumerate() {
1032            f.validate(dims)
1033                .unwrap_or_else(|e| panic!("frame {i} invalid: {e:?}"));
1034        }
1035        assert!(
1036            durations.is_empty() || durations.len() == frames.len(),
1037            "durations must be empty or one per frame",
1038        );
1039        let owpc = occ_words_per_col(dims) as usize;
1040        let cols = (dims[0] as usize) * (dims[1] as usize);
1041
1042        let mut encoded = Vec::with_capacity(frames.len());
1043        let mut since_key = 0u32;
1044        for (i, frame) in frames.iter().enumerate() {
1045            if i == 0 {
1046                encoded.push(EncodedFrame::Key(frame.clone()));
1047                since_key = 0;
1048                continue;
1049            }
1050            let changed = column_diff(&frames[i - 1], frame, cols, owpc);
1051            let gap_forces_key = max_keyframe_gap != 0 && since_key + 1 >= max_keyframe_gap;
1052            let delta_too_big =
1053                delta_words(&changed, owpc) * 100 >= key_words(frame) * KEYFRAME_COST_PCT;
1054            if gap_forces_key || delta_too_big {
1055                encoded.push(EncodedFrame::Key(frame.clone()));
1056                since_key = 0;
1057            } else {
1058                encoded.push(EncodedFrame::Delta(changed));
1059                since_key += 1;
1060            }
1061        }
1062
1063        Self {
1064            dims,
1065            pivot,
1066            voxel_world_size,
1067            loop_mode,
1068            default_frame_ms,
1069            frames: encoded,
1070            durations: durations.to_vec(),
1071            extra_chunks: Vec::new(),
1072        }
1073    }
1074
1075    /// Expand the I/P stream to full frames, compute per-frame `dirs`, and
1076    /// resolve durations — the runtime flipbook.
1077    ///
1078    /// # Errors
1079    /// [`DecodeError::DeltaBeforeKey`] if the stream doesn't start with a
1080    /// keyframe; [`DecodeError::ColumnOutOfRange`] if a delta names a
1081    /// column outside `dims`; [`DecodeError::Frame`] if a reconstructed
1082    /// frame fails validation.
1083    pub fn decode(&self) -> Result<DecodedClip, DecodeError> {
1084        let owpc = occ_words_per_col(self.dims) as usize;
1085        let cols = (self.dims[0] as usize) * (self.dims[1] as usize);
1086
1087        // Reconstruct incrementally via a per-column working set so a
1088        // delta is an O(changed columns) overwrite, not a flat-array splice.
1089        let mut work_occ = vec![0u32; cols * owpc];
1090        let mut work_cols: Vec<Vec<u32>> = vec![Vec::new(); cols];
1091        let mut frames: Vec<VoxelFrame> = Vec::with_capacity(self.frames.len());
1092        let mut started = false;
1093
1094        for ef in &self.frames {
1095            apply_frame(
1096                ef,
1097                &mut work_occ,
1098                &mut work_cols,
1099                self.dims,
1100                owpc,
1101                cols,
1102                &mut started,
1103            )?;
1104            frames.push(flatten(&work_occ, &work_cols, cols));
1105        }
1106
1107        // Per-frame dirs from the reconstructed occupancy.
1108        let mut dirs = Vec::with_capacity(frames.len());
1109        for f in &frames {
1110            f.validate(self.dims).map_err(DecodeError::Frame)?;
1111            dirs.push(frame_dirs(f, self.dims, owpc));
1112        }
1113
1114        let durations = if self.durations.is_empty() {
1115            vec![self.default_frame_ms; frames.len()]
1116        } else {
1117            self.durations.clone()
1118        };
1119
1120        Ok(DecodedClip {
1121            dims: self.dims,
1122            pivot: self.pivot,
1123            voxel_world_size: self.voxel_world_size,
1124            occ_words_per_col: owpc as u32,
1125            loop_mode: self.loop_mode,
1126            frames,
1127            dirs,
1128            durations,
1129        })
1130    }
1131
1132    /// Serialise to the `.rvc` byte form. Round-trips byte-equally with
1133    /// [`VoxelClip::parse`].
1134    #[must_use]
1135    pub fn serialize(&self) -> Vec<u8> {
1136        let mut out = Vec::new();
1137        out.extend_from_slice(&MAGIC);
1138        out.extend_from_slice(&VERSION.to_le_bytes());
1139
1140        write_chunk(&mut out, TAG_META, |b| {
1141            for v in self.dims {
1142                b.extend_from_slice(&v.to_le_bytes());
1143            }
1144            for v in self.pivot {
1145                b.extend_from_slice(&v.to_le_bytes());
1146            }
1147            b.extend_from_slice(&self.voxel_world_size.to_le_bytes());
1148            b.push(self.loop_mode.to_u8());
1149            b.extend_from_slice(&self.default_frame_ms.to_le_bytes());
1150            let fc = u32::try_from(self.frames.len()).expect("frame count fits u32");
1151            b.extend_from_slice(&fc.to_le_bytes());
1152        });
1153
1154        write_chunk(&mut out, TAG_FRMS, |b| {
1155            for ef in &self.frames {
1156                match ef {
1157                    EncodedFrame::Key(frame) => {
1158                        b.push(FRAME_KIND_KEY);
1159                        write_u32_vec(b, &frame.occupancy);
1160                        write_u32_vec(b, &frame.color_offsets);
1161                        write_u32_vec(b, &frame.colors);
1162                    }
1163                    EncodedFrame::Delta(changed) => {
1164                        b.push(FRAME_KIND_DELTA);
1165                        let n = u32::try_from(changed.len()).expect("changed count fits u32");
1166                        b.extend_from_slice(&n.to_le_bytes());
1167                        for d in changed {
1168                            b.extend_from_slice(&d.col.to_le_bytes());
1169                            // occ is a fixed occ_words_per_col count → no length prefix.
1170                            for w in &d.occ {
1171                                b.extend_from_slice(&w.to_le_bytes());
1172                            }
1173                            write_u32_vec(b, &d.colors);
1174                        }
1175                    }
1176                }
1177            }
1178        });
1179
1180        if !self.durations.is_empty() {
1181            write_chunk(&mut out, TAG_TIME, |b| {
1182                for d in &self.durations {
1183                    b.extend_from_slice(&d.to_le_bytes());
1184                }
1185            });
1186        }
1187
1188        for (tag, payload) in &self.extra_chunks {
1189            write_chunk(&mut out, *tag, |b| b.extend_from_slice(payload));
1190        }
1191
1192        out
1193    }
1194
1195    /// Parse the `.rvc` byte form.
1196    ///
1197    /// # Errors
1198    /// [`ParseError`] for a bad magic / unsupported version / truncation /
1199    /// missing required chunk / malformed frame stream.
1200    pub fn parse(bytes: &[u8]) -> Result<VoxelClip, ParseError> {
1201        let mut cur = Cursor::new(bytes);
1202        let magic = cur.read_bytes(4)?;
1203        if magic != MAGIC {
1204            return Err(ParseError::BadMagic {
1205                got: [magic[0], magic[1], magic[2], magic[3]],
1206            });
1207        }
1208        let version = cur.read_u16()?;
1209        if version != VERSION && version != VERSION_LEGACY {
1210            return Err(ParseError::UnsupportedVersion(version));
1211        }
1212        // v2 prefixes each chunk payload with a `flags` byte; v1 doesn't.
1213        let has_flags = version >= VERSION;
1214
1215        let mut meta: Option<Vec<u8>> = None;
1216        let mut frms: Option<Vec<u8>> = None;
1217        let mut time: Option<Vec<u8>> = None;
1218        let mut extra_chunks = Vec::new();
1219        while cur.remaining() > 0 {
1220            let tag_buf = cur.read_bytes(4)?;
1221            let tag = [tag_buf[0], tag_buf[1], tag_buf[2], tag_buf[3]];
1222            let flags = if has_flags { cur.read_u8()? } else { 0 };
1223            let len = cur.read_u32()? as usize;
1224            let stored = cur.read_bytes(len)?;
1225            let payload = if flags & CHUNK_FLAG_DEFLATED != 0 {
1226                inflate_chunk(stored)?
1227            } else {
1228                stored.to_vec()
1229            };
1230            match tag {
1231                TAG_META => meta = Some(payload),
1232                TAG_FRMS => frms = Some(payload),
1233                TAG_TIME => time = Some(payload),
1234                _ => extra_chunks.push((tag, payload)),
1235            }
1236        }
1237
1238        let meta = meta.ok_or(ParseError::MissingChunk(TAG_META))?;
1239        let frms = frms.ok_or(ParseError::MissingChunk(TAG_FRMS))?;
1240
1241        let (dims, pivot, voxel_world_size, loop_mode, default_frame_ms, frame_count) =
1242            parse_meta(&meta)?;
1243        let frames = parse_frms(&frms, dims, frame_count)?;
1244        let durations = match time {
1245            Some(p) => parse_time(&p, frame_count)?,
1246            None => Vec::new(),
1247        };
1248
1249        Ok(VoxelClip {
1250            dims,
1251            pivot,
1252            voxel_world_size,
1253            loop_mode,
1254            default_frame_ms,
1255            frames,
1256            durations,
1257            extra_chunks,
1258        })
1259    }
1260}
1261
1262// ---- decode helpers ------------------------------------------------------
1263
1264/// Apply one I/P frame to the per-column working set (`work_occ` +
1265/// `work_cols`): a keyframe overwrites the whole set, a delta rewrites only
1266/// its changed columns. Shared by [`VoxelClip::decode`] and
1267/// [`StreamingClip`]. `started` guards against a leading delta.
1268fn apply_frame(
1269    ef: &EncodedFrame,
1270    work_occ: &mut [u32],
1271    work_cols: &mut [Vec<u32>],
1272    dims: [u32; 3],
1273    owpc: usize,
1274    cols: usize,
1275    started: &mut bool,
1276) -> Result<(), DecodeError> {
1277    match ef {
1278        EncodedFrame::Key(frame) => {
1279            frame.validate(dims).map_err(DecodeError::Frame)?;
1280            work_occ.copy_from_slice(&frame.occupancy);
1281            for (col, wc) in work_cols.iter_mut().enumerate() {
1282                wc.clear();
1283                wc.extend_from_slice(frame.column_colors(col));
1284            }
1285            *started = true;
1286        }
1287        EncodedFrame::Delta(changed) => {
1288            if !*started {
1289                return Err(DecodeError::DeltaBeforeKey);
1290            }
1291            for d in changed {
1292                let col = d.col as usize;
1293                if col >= cols || d.occ.len() != owpc {
1294                    return Err(DecodeError::ColumnOutOfRange(d.col));
1295                }
1296                work_occ[col * owpc..(col + 1) * owpc].copy_from_slice(&d.occ);
1297                work_cols[col].clear();
1298                work_cols[col].extend_from_slice(&d.colors);
1299            }
1300        }
1301    }
1302    Ok(())
1303}
1304
1305/// Flatten per-column working state into a [`VoxelFrame`].
1306fn flatten(occ: &[u32], cols_colors: &[Vec<u32>], cols: usize) -> VoxelFrame {
1307    let mut color_offsets = Vec::with_capacity(cols + 1);
1308    let mut colors = Vec::new();
1309    for run in cols_colors {
1310        color_offsets.push(colors.len() as u32);
1311        colors.extend_from_slice(run);
1312    }
1313    color_offsets.push(colors.len() as u32);
1314    VoxelFrame {
1315        occupancy: occ.to_vec(),
1316        colors,
1317        color_offsets,
1318    }
1319}
1320
1321/// Per-voxel `dir` (surface-normal LUT index) for every voxel of `frame`,
1322/// ascending-z within each column — parallel to `frame.colors`.
1323fn frame_dirs(frame: &VoxelFrame, dims: [u32; 3], owpc: usize) -> Vec<u32> {
1324    let (mx, my, mz) = (dims[0] as i64, dims[1] as i64, dims[2] as i64);
1325    let solid = |x: i64, y: i64, z: i64| -> bool {
1326        if x < 0 || y < 0 || z < 0 || x >= mx || y >= my || z >= mz {
1327            return false;
1328        }
1329        let col = (x + y * mx) as usize;
1330        let word = frame.occupancy[col * owpc + (z >> 5) as usize];
1331        (word >> (z & 31)) & 1 != 0
1332    };
1333    let mut dirs = Vec::with_capacity(frame.colors.len());
1334    for y in 0..my {
1335        for x in 0..mx {
1336            let col = (x + y * mx) as usize;
1337            // Walk set bits ascending z to match the colour run order.
1338            for z in 0..mz {
1339                let word = frame.occupancy[col * owpc + (z >> 5) as usize];
1340                if (word >> (z & 31)) & 1 != 0 {
1341                    let (_vis, dir) = compute_vis_dir(&solid, x, y, z);
1342                    dirs.push(u32::from(dir));
1343                }
1344            }
1345        }
1346    }
1347    dirs
1348}
1349
1350// ---- serialize / parse helpers ------------------------------------------
1351
1352/// Write a v2 chunk: `tag(4) | flags(u8) | len(u32) | payload`. The body is
1353/// built into a scratch buffer, deflated, and stored compressed
1354/// (`CHUNK_FLAG_DEFLATED`, payload = `raw_len(u32) | deflate_bytes`) only
1355/// when that actually shrinks it — small/incompressible chunks stay raw.
1356fn write_chunk(out: &mut Vec<u8>, tag: [u8; 4], body: impl FnOnce(&mut Vec<u8>)) {
1357    let mut raw = Vec::new();
1358    body(&mut raw);
1359    out.extend_from_slice(&tag);
1360
1361    let deflated = miniz_oxide::deflate::compress_to_vec(&raw, DEFLATE_LEVEL);
1362    // `+4` accounts for the raw-length prefix a deflated payload carries.
1363    if deflated.len() + 4 < raw.len() {
1364        out.push(CHUNK_FLAG_DEFLATED);
1365        let len = u32::try_from(deflated.len() + 4).expect("chunk length fits u32");
1366        out.extend_from_slice(&len.to_le_bytes());
1367        let raw_len = u32::try_from(raw.len()).expect("raw length fits u32");
1368        out.extend_from_slice(&raw_len.to_le_bytes());
1369        out.extend_from_slice(&deflated);
1370    } else {
1371        out.push(0);
1372        let len = u32::try_from(raw.len()).expect("chunk length fits u32");
1373        out.extend_from_slice(&len.to_le_bytes());
1374        out.extend_from_slice(&raw);
1375    }
1376}
1377
1378/// Inflate a `CHUNK_FLAG_DEFLATED` payload (`raw_len(u32) | deflate_bytes`).
1379/// The stored `raw_len` bounds the output (decompression-bomb guard) and is
1380/// checked against the actual inflated length.
1381fn inflate_chunk(payload: &[u8]) -> Result<Vec<u8>, ParseError> {
1382    if payload.len() < 4 {
1383        return Err(ParseError::BadDeflate);
1384    }
1385    let raw_len = u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
1386    // The stored `raw_len` is untrusted (a crafted file could claim
1387    // `u32::MAX` → a ~4 GiB allocation). Reject anything beyond a sane cap so
1388    // it can't drive a decompression bomb.
1389    if raw_len > MAX_CHUNK_INFLATE {
1390        return Err(ParseError::BadDeflate);
1391    }
1392    let out = miniz_oxide::inflate::decompress_to_vec_with_limit(&payload[4..], raw_len)
1393        .map_err(|_| ParseError::BadDeflate)?;
1394    if out.len() != raw_len {
1395        return Err(ParseError::BadDeflate);
1396    }
1397    Ok(out)
1398}
1399
1400/// Length-prefixed (`u32`) array of `u32`s.
1401fn write_u32_vec(out: &mut Vec<u8>, v: &[u32]) {
1402    let n = u32::try_from(v.len()).expect("u32 array length fits u32");
1403    out.extend_from_slice(&n.to_le_bytes());
1404    for w in v {
1405        out.extend_from_slice(&w.to_le_bytes());
1406    }
1407}
1408
1409fn read_u32_vec(cur: &mut Cursor) -> Result<Vec<u32>, ParseError> {
1410    let n = cur.read_u32()? as usize;
1411    // QE.6b - clamp by remaining bytes (4 B/element): a lying count
1412    // fails as Truncated instead of allocation-bombing first (the 64
1413    // MiB inflate cap bounds the payload but not a crafted count).
1414    let mut v = Vec::with_capacity(cur.clamped_capacity(n, 4));
1415    for _ in 0..n {
1416        v.push(cur.read_u32()?);
1417    }
1418    Ok(v)
1419}
1420
1421#[allow(clippy::type_complexity)]
1422fn parse_meta(payload: &[u8]) -> Result<([u32; 3], [f32; 3], f32, LoopMode, u32, u32), ParseError> {
1423    let mut cur = Cursor::new(payload);
1424    let dims = [cur.read_u32()?, cur.read_u32()?, cur.read_u32()?];
1425    // QE.6b - reject degenerate/huge dims before anything derives an
1426    // allocation (or a usize overflow on wasm32) from them.
1427    if dims.iter().any(|&d| d == 0 || d > MAX_CLIP_DIM) {
1428        return Err(ParseError::BadDims { dims });
1429    }
1430    let pivot = [cur.read_f32()?, cur.read_f32()?, cur.read_f32()?];
1431    let voxel_world_size = cur.read_f32()?;
1432    let loop_mode = LoopMode::from_u8(cur.read_u8()?).ok_or(ParseError::BadLoopMode)?;
1433    let default_frame_ms = cur.read_u32()?;
1434    let frame_count = cur.read_u32()?;
1435    Ok((
1436        dims,
1437        pivot,
1438        voxel_world_size,
1439        loop_mode,
1440        default_frame_ms,
1441        frame_count,
1442    ))
1443}
1444
1445fn parse_frms(
1446    payload: &[u8],
1447    dims: [u32; 3],
1448    frame_count: u32,
1449) -> Result<Vec<EncodedFrame>, ParseError> {
1450    let owpc = occ_words_per_col(dims) as usize;
1451    let mut cur = Cursor::new(payload);
1452    let mut frames = Vec::with_capacity(frame_count as usize);
1453    for _ in 0..frame_count {
1454        let kind = cur.read_u8()?;
1455        match kind {
1456            FRAME_KIND_KEY => {
1457                let occupancy = read_u32_vec(&mut cur)?;
1458                let color_offsets = read_u32_vec(&mut cur)?;
1459                let colors = read_u32_vec(&mut cur)?;
1460                frames.push(EncodedFrame::Key(VoxelFrame {
1461                    occupancy,
1462                    colors,
1463                    color_offsets,
1464                }));
1465            }
1466            FRAME_KIND_DELTA => {
1467                let n = cur.read_u32()? as usize;
1468                let mut changed = Vec::with_capacity(n);
1469                for _ in 0..n {
1470                    let col = cur.read_u32()?;
1471                    let mut occ = Vec::with_capacity(owpc);
1472                    for _ in 0..owpc {
1473                        occ.push(cur.read_u32()?);
1474                    }
1475                    let colors = read_u32_vec(&mut cur)?;
1476                    changed.push(ColumnDelta { col, occ, colors });
1477                }
1478                frames.push(EncodedFrame::Delta(changed));
1479            }
1480            other => return Err(ParseError::BadFrameKind(other)),
1481        }
1482    }
1483    Ok(frames)
1484}
1485
1486fn parse_time(payload: &[u8], frame_count: u32) -> Result<Vec<u32>, ParseError> {
1487    let mut cur = Cursor::new(payload);
1488    let mut durations = Vec::with_capacity(frame_count as usize);
1489    for _ in 0..frame_count {
1490        durations.push(cur.read_u32()?);
1491    }
1492    Ok(durations)
1493}
1494
1495// ---- errors --------------------------------------------------------------
1496
1497/// Why [`VoxelClip::from_kv6_frames`] could not build a clip.
1498#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1499pub enum Kv6ImportError {
1500    /// No frames were supplied.
1501    Empty,
1502    /// A frame's dims differ from the first frame's (clips are fixed-bbox).
1503    DimsMismatch {
1504        frame: usize,
1505        dims: [u32; 3],
1506        expected: [u32; 3],
1507    },
1508}
1509
1510/// Why a [`VoxelFrame`] failed validation against a clip's `dims`.
1511#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1512pub enum FrameError {
1513    OccupancyLen,
1514    OffsetsLen,
1515    OffsetsBounds,
1516    OffsetsMonotonic,
1517    /// Column index whose occupancy popcount ≠ its colour-run length.
1518    OccupancyColorMismatch(usize),
1519}
1520
1521#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1522pub enum ParseError {
1523    BadMagic {
1524        got: [u8; 4],
1525    },
1526    UnsupportedVersion(u16),
1527    Truncated,
1528    MissingChunk([u8; 4]),
1529    BadLoopMode,
1530    BadFrameKind(u8),
1531    /// A `CHUNK_FLAG_DEFLATED` payload failed to inflate, or its inflated
1532    /// length disagreed with the stored `raw_len`.
1533    BadDeflate,
1534    /// QE.6b - a META chunk with a zero or > 4096 dimension (bounds
1535    /// every dims-derived allocation; see `MAX_CLIP_DIM`).
1536    BadDims {
1537        dims: [u32; 3],
1538    },
1539}
1540
1541impl From<OutOfBounds> for ParseError {
1542    fn from(_: OutOfBounds) -> Self {
1543        ParseError::Truncated
1544    }
1545}
1546
1547/// Why [`VoxelClip::decode`] failed.
1548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1549pub enum DecodeError {
1550    /// The frame stream began with a delta frame.
1551    DeltaBeforeKey,
1552    /// A delta named a column ≥ `dims[0]*dims[1]` or wrong occ length.
1553    ColumnOutOfRange(u32),
1554    /// A reconstructed frame failed validation.
1555    Frame(FrameError),
1556}
1557
1558#[cfg(test)]
1559mod tests {
1560    use super::*;
1561
1562    /// Build a full frame from a dense `solid(x,y,z) -> Option<color>`
1563    /// closure (the authoring shape demiurg / the encoder will use).
1564    fn frame_from_fn(dims: [u32; 3], fill: impl Fn(u32, u32, u32) -> Option<u32>) -> VoxelFrame {
1565        let owpc = occ_words_per_col(dims) as usize;
1566        let cols = (dims[0] as usize) * (dims[1] as usize);
1567        let mut occupancy = vec![0u32; cols * owpc];
1568        let mut color_offsets = vec![0u32; cols + 1];
1569        let mut colors = Vec::new();
1570        for y in 0..dims[1] {
1571            for x in 0..dims[0] {
1572                let col = (x + y * dims[0]) as usize;
1573                color_offsets[col] = colors.len() as u32;
1574                for z in 0..dims[2] {
1575                    if let Some(c) = fill(x, y, z) {
1576                        occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1577                        colors.push(c);
1578                    }
1579                }
1580            }
1581        }
1582        color_offsets[cols] = colors.len() as u32;
1583        VoxelFrame {
1584            occupancy,
1585            colors,
1586            color_offsets,
1587        }
1588    }
1589
1590    /// A small flame-ish clip: a flickering blob whose top voxel toggles
1591    /// per frame (so most columns are static, a few change — the diff
1592    /// codec's target).
1593    fn flame_clip(
1594        dims: [u32; 3],
1595        n_frames: u32,
1596        keyframe_interval: u32,
1597    ) -> (VoxelClip, Vec<VoxelFrame>) {
1598        let frames: Vec<VoxelFrame> = (0..n_frames)
1599            .map(|fi| {
1600                frame_from_fn(dims, |x, y, z| {
1601                    let cx = dims[0] / 2;
1602                    let cy = dims[1] / 2;
1603                    // a static stem in the centre column
1604                    let stem = x == cx && y == cy && z < dims[2] - 2;
1605                    // a flickering tip whose height depends on the frame
1606                    let tip = x == cx && y == cy && z == dims[2] - 2 - (fi % 2);
1607                    if stem || tip {
1608                        Some(0x80FF_8000 | (fi & 0xF)) // vary low bits per frame
1609                    } else {
1610                        None
1611                    }
1612                })
1613            })
1614            .collect();
1615        let clip = VoxelClip::from_frames(
1616            dims,
1617            [
1618                dims[0] as f32 * 0.5,
1619                dims[1] as f32 * 0.5,
1620                dims[2] as f32 * 0.5,
1621            ],
1622            1.0,
1623            LoopMode::Loop,
1624            &frames,
1625            &[],
1626            33,
1627            keyframe_interval,
1628        );
1629        (clip, frames)
1630    }
1631
1632    #[test]
1633    fn occ_words_per_col_matches_sprite_model() {
1634        assert_eq!(occ_words_per_col([8, 8, 1]), 1);
1635        assert_eq!(occ_words_per_col([8, 8, 32]), 1);
1636        assert_eq!(occ_words_per_col([8, 8, 33]), 2);
1637        assert_eq!(occ_words_per_col([8, 8, 256]), 8);
1638    }
1639
1640    #[test]
1641    fn frame_validate_catches_mismatch() {
1642        let dims = [4, 4, 8];
1643        let mut f = frame_from_fn(dims, |x, y, z| {
1644            (x == 0 && y == 0 && z < 3).then_some(0x8000_00FF)
1645        });
1646        assert!(f.validate(dims).is_ok());
1647        // Corrupt column 0: clear one occupancy bit but keep its colour run
1648        // (popcount 2 ≠ run 3).
1649        f.occupancy[0] &= !1u32;
1650        assert!(matches!(
1651            f.validate(dims),
1652            Err(FrameError::OccupancyColorMismatch(0))
1653        ));
1654    }
1655
1656    #[test]
1657    fn decode_reconstructs_every_frame() {
1658        let dims = [9, 9, 40];
1659        let (clip, original) = flame_clip(dims, 8, 4);
1660        let decoded = clip.decode().expect("decode");
1661        assert_eq!(decoded.frame_count(), original.len());
1662        for (i, (got, want)) in decoded.frames.iter().zip(&original).enumerate() {
1663            assert_eq!(got, want, "frame {i} mismatch");
1664            // dirs are parallel to colours.
1665            assert_eq!(
1666                decoded.dirs[i].len(),
1667                got.colors.len(),
1668                "frame {i} dirs len"
1669            );
1670        }
1671    }
1672
1673    #[test]
1674    fn diff_frames_are_smaller_than_keyframes() {
1675        let dims = [9, 9, 40];
1676        let (clip, _) = flame_clip(dims, 8, 0); // only frame 0 is a key
1677        let keys = clip
1678            .frames
1679            .iter()
1680            .filter(|f| matches!(f, EncodedFrame::Key(_)))
1681            .count();
1682        assert_eq!(keys, 1, "keyframe_interval=0 ⇒ exactly one keyframe");
1683        // Every non-key frame touches only a handful of columns (the tip),
1684        // far fewer than the dims[0]*dims[1] columns a keyframe rewrites.
1685        for f in &clip.frames {
1686            if let EncodedFrame::Delta(changed) = f {
1687                assert!(
1688                    changed.len() < (dims[0] * dims[1]) as usize,
1689                    "delta should be sparse, got {} columns",
1690                    changed.len()
1691                );
1692            }
1693        }
1694    }
1695
1696    #[test]
1697    fn serialize_parse_round_trips() {
1698        let dims = [9, 9, 40];
1699        let (clip, _) = flame_clip(dims, 8, 4);
1700        let bytes = clip.serialize();
1701        let parsed = VoxelClip::parse(&bytes).expect("parse");
1702        assert_eq!(parsed, clip);
1703        // Re-serialise is byte-identical.
1704        assert_eq!(parsed.serialize(), bytes);
1705        // And it still decodes to the same frames.
1706        let a = clip.decode().expect("decode a");
1707        let b = parsed.decode().expect("decode b");
1708        assert_eq!(a.frames, b.frames);
1709    }
1710
1711    #[test]
1712    fn durations_default_when_time_chunk_absent() {
1713        let dims = [4, 4, 8];
1714        let (clip, _) = flame_clip(dims, 4, 2);
1715        assert!(clip.durations.is_empty());
1716        let decoded = clip.decode().expect("decode");
1717        assert_eq!(decoded.durations, vec![33; 4]);
1718        assert_eq!(decoded.total_ms(), 33 * 4);
1719    }
1720
1721    #[test]
1722    fn explicit_durations_round_trip() {
1723        let dims = [4, 4, 8];
1724        let frames: Vec<VoxelFrame> = (0..3)
1725            .map(|fi| {
1726                frame_from_fn(dims, move |x, y, z| {
1727                    (x == 0 && y == 0 && z == fi).then_some(0x8011_2233)
1728                })
1729            })
1730            .collect();
1731        let clip = VoxelClip::from_frames(
1732            dims,
1733            [0.0; 3],
1734            1.0,
1735            LoopMode::Once,
1736            &frames,
1737            &[10, 20, 30],
1738            33,
1739            0,
1740        );
1741        let parsed = VoxelClip::parse(&clip.serialize()).expect("parse");
1742        assert_eq!(parsed.durations, vec![10, 20, 30]);
1743        assert_eq!(parsed.decode().unwrap().durations, vec![10, 20, 30]);
1744        assert_eq!(parsed.loop_mode, LoopMode::Once);
1745    }
1746
1747    #[test]
1748    fn unknown_chunks_preserved() {
1749        let dims = [4, 4, 8];
1750        let (mut clip, _) = flame_clip(dims, 2, 0);
1751        clip.extra_chunks.push((*b"XTRA", vec![1, 2, 3, 4, 5]));
1752        let parsed = VoxelClip::parse(&clip.serialize()).expect("parse");
1753        assert_eq!(parsed.extra_chunks, vec![(*b"XTRA", vec![1, 2, 3, 4, 5])]);
1754    }
1755
1756    #[test]
1757    fn bad_magic_and_version_rejected() {
1758        let dims = [4, 4, 8];
1759        let (clip, _) = flame_clip(dims, 2, 0);
1760        let mut bytes = clip.serialize();
1761        let good = bytes.clone();
1762        bytes[0] = b'X';
1763        assert!(matches!(
1764            VoxelClip::parse(&bytes),
1765            Err(ParseError::BadMagic { .. })
1766        ));
1767        let mut v = good.clone();
1768        v[4] = 9; // version low byte
1769        assert!(matches!(
1770            VoxelClip::parse(&v),
1771            Err(ParseError::UnsupportedVersion(_))
1772        ));
1773    }
1774
1775    #[test]
1776    fn frame_at_honours_loop_modes() {
1777        // 3 frames, 10 ms each (total 30).
1778        let dims = [4, 4, 8];
1779        let frames: Vec<VoxelFrame> = (0..3)
1780            .map(|fi| {
1781                frame_from_fn(dims, move |x, y, z| {
1782                    (x == 0 && y == 0 && z == fi).then_some(0x8011_2233)
1783                })
1784            })
1785            .collect();
1786        let mk = |mode| {
1787            VoxelClip::from_frames(dims, [0.0; 3], 1.0, mode, &frames, &[10, 10, 10], 33, 0)
1788                .decode()
1789                .unwrap()
1790        };
1791
1792        let loop_c = mk(LoopMode::Loop);
1793        assert_eq!(loop_c.frame_at(0), 0);
1794        assert_eq!(loop_c.frame_at(9), 0);
1795        assert_eq!(loop_c.frame_at(10), 1);
1796        assert_eq!(loop_c.frame_at(25), 2);
1797        assert_eq!(loop_c.frame_at(30), 0, "wraps at total");
1798        assert_eq!(loop_c.frame_at(45), 1);
1799
1800        let once = mk(LoopMode::Once);
1801        assert_eq!(once.frame_at(25), 2);
1802        assert_eq!(once.frame_at(1000), 2, "holds the last frame");
1803
1804        let ping = mk(LoopMode::PingPong);
1805        assert_eq!(ping.frame_at(5), 0);
1806        assert_eq!(ping.frame_at(25), 2);
1807        assert_eq!(ping.frame_at(35), 2, "mirror: 35→ frame 2");
1808        assert_eq!(ping.frame_at(55), 0, "mirror back to 0 near 2·total");
1809    }
1810
1811    #[test]
1812    fn delta_before_key_rejected() {
1813        let dims = [4, 4, 8];
1814        let clip = VoxelClip {
1815            dims,
1816            pivot: [0.0; 3],
1817            voxel_world_size: 1.0,
1818            loop_mode: LoopMode::Loop,
1819            default_frame_ms: 33,
1820            frames: vec![EncodedFrame::Delta(Vec::new())],
1821            durations: Vec::new(),
1822            extra_chunks: Vec::new(),
1823        };
1824        assert!(matches!(clip.decode(), Err(DecodeError::DeltaBeforeKey)));
1825    }
1826
1827    // ---- VCL.1: .kv6 → VoxelFrame / VoxelClip import ----------------------
1828
1829    /// A fill whose every solid voxel is isolated (no 6-neighbour solid),
1830    /// so `Kv6::from_fn` (surface-only) keeps all of them — letting the
1831    /// import be compared against the all-voxels `frame_from_fn` reference.
1832    /// Spaced on even coords; the colour encodes `(x, y, z)`.
1833    fn isolated_fill(x: u32, y: u32, z: u32) -> Option<u32> {
1834        (x % 2 == 0 && y % 2 == 0 && z % 2 == 0).then_some(0x8000_0000 | (x << 16) | (y << 8) | z)
1835    }
1836
1837    #[test]
1838    fn from_kv6_matches_dense_reference() {
1839        // Non-square xy exercises the x-major→x-fastest re-index; z = 41
1840        // (> 32) exercises the 2-word occupancy column.
1841        let dims = [3u32, 2, 41];
1842        let kv6 = Kv6::from_fn(dims[0], dims[1], dims[2], isolated_fill);
1843        let imported = VoxelFrame::from_kv6(&kv6);
1844        let expected = frame_from_fn(dims, isolated_fill);
1845        assert_eq!(imported, expected);
1846        imported.validate(dims).expect("imported frame is valid");
1847    }
1848
1849    #[test]
1850    fn from_kv6_packs_z_across_word_boundary() {
1851        // A single 1×1 column with voxels straddling the 32-bit word split.
1852        let kv6 = Kv6::from_fn(1, 1, 41, |_, _, z| match z {
1853            0 => Some(0x80FF_0000),
1854            5 => Some(0x8000_FF00),
1855            33 => Some(0x8000_00FF),
1856            40 => Some(0x80FF_FF00),
1857            _ => None,
1858        });
1859        let f = VoxelFrame::from_kv6(&kv6);
1860        // owpc = 2; word0 bits 0,5; word1 bits 1 (=33-32), 8 (=40-32).
1861        assert_eq!(f.occupancy, vec![(1 << 0) | (1 << 5), (1 << 1) | (1 << 8)]);
1862        // Colours ascending z.
1863        assert_eq!(
1864            f.colors,
1865            vec![0x80FF_0000, 0x8000_FF00, 0x8000_00FF, 0x80FF_FF00]
1866        );
1867        assert_eq!(f.color_offsets, vec![0, 4]);
1868        f.validate([1, 1, 41]).expect("valid");
1869    }
1870
1871    #[test]
1872    fn from_kv6_frames_round_trips_through_clip() {
1873        let dims = [2u32, 2, 3];
1874        // Two full xy layers at different z's — every voxel surface-exposed.
1875        let ka = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
1876            (z == 0).then_some(0x80FF_0000)
1877        });
1878        let kb = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
1879            (z == 2).then_some(0x8000_FF00)
1880        });
1881        let clip = VoxelClip::from_kv6_frames(
1882            &[ka.clone(), kb.clone()],
1883            2.0,
1884            LoopMode::Loop,
1885            &[100, 200],
1886            0,
1887            0,
1888        )
1889        .expect("import");
1890        assert_eq!(clip.dims, dims);
1891        assert_eq!(clip.voxel_world_size, 2.0);
1892        assert_eq!(clip.pivot, [ka.xpiv, ka.ypiv, ka.zpiv]);
1893        assert_eq!(clip.durations, vec![100, 200]);
1894
1895        let decoded = clip.decode().expect("decode");
1896        assert_eq!(decoded.frames.len(), 2);
1897        assert_eq!(decoded.frames[0], VoxelFrame::from_kv6(&ka));
1898        assert_eq!(decoded.frames[1], VoxelFrame::from_kv6(&kb));
1899    }
1900
1901    #[test]
1902    fn from_kv6_frames_rejects_empty() {
1903        let err = VoxelClip::from_kv6_frames(&[], 1.0, LoopMode::Loop, &[], 50, 0)
1904            .expect_err("empty must fail");
1905        assert_eq!(err, Kv6ImportError::Empty);
1906    }
1907
1908    #[test]
1909    fn from_kv6_frames_rejects_dims_mismatch() {
1910        let ka = Kv6::from_fn(2, 2, 2, |_, _, z| (z == 0).then_some(0x80FF_FFFF));
1911        let kb = Kv6::from_fn(3, 2, 2, |_, _, z| (z == 0).then_some(0x80FF_FFFF));
1912        let err = VoxelClip::from_kv6_frames(&[ka, kb], 1.0, LoopMode::Loop, &[], 50, 0)
1913            .expect_err("mismatch must fail");
1914        assert_eq!(
1915            err,
1916            Kv6ImportError::DimsMismatch {
1917                frame: 1,
1918                dims: [3, 2, 2],
1919                expected: [2, 2, 2],
1920            }
1921        );
1922    }
1923
1924    #[test]
1925    fn to_kv6_inverts_from_kv6() {
1926        // Solid below a per-column threshold (interior + surface voxels), a
1927        // z-run crossing the 32-bit word boundary, distinct colours.
1928        let dims = [3u32, 2, 40];
1929        let frame = frame_from_fn(dims, |x, y, z| {
1930            (z <= (x + y) * 6 + 3).then_some(0x8000_0000 | (z << 8) | (x * 16 + y))
1931        });
1932        let kv6 = frame.to_kv6(dims, [1.0, 0.5, 20.0]);
1933        assert_eq!([kv6.xsiz, kv6.ysiz, kv6.zsiz], dims);
1934        assert_eq!([kv6.xpiv, kv6.ypiv, kv6.zpiv], [1.0, 0.5, 20.0]);
1935        // from_kv6 ∘ to_kv6 reproduces occupancy + colours exactly.
1936        assert_eq!(VoxelFrame::from_kv6(&kv6), frame);
1937    }
1938
1939    #[test]
1940    fn voxel_frame_dirs_match_decoded() {
1941        // `VoxelFrame::dirs` (used by the single-frame edit) must equal the
1942        // dirs `decode` caches (the register path), so an edited frame's GPU
1943        // model is identical to a freshly-registered one.
1944        let dims = [4u32, 3, 8];
1945        let frame = frame_from_fn(dims, |x, y, z| (z <= x + y).then_some(0x80FF_0000));
1946        let clip = VoxelClip::from_frames(
1947            dims,
1948            [0.0; 3],
1949            1.0,
1950            LoopMode::Loop,
1951            std::slice::from_ref(&frame),
1952            &[],
1953            33,
1954            0,
1955        );
1956        let decoded = clip.decode().unwrap();
1957        assert_eq!(frame.dirs(dims), decoded.dirs[0]);
1958    }
1959
1960    // ---- compression (v2 per-chunk deflate) -------------------------------
1961
1962    #[test]
1963    fn compressed_clip_round_trips_and_shrinks() {
1964        // A fully-solid frame: every occupancy word all-set, one repeated
1965        // colour — maximally compressible.
1966        let dims = [16u32, 16, 32];
1967        let frame = frame_from_fn(dims, |_, _, _| Some(0x80AB_CDEF));
1968        let clip = VoxelClip::from_frames(
1969            dims,
1970            [8.0, 8.0, 16.0],
1971            1.0,
1972            LoopMode::Loop,
1973            &[frame],
1974            &[],
1975            33,
1976            0,
1977        );
1978        let bytes = clip.serialize();
1979        // The colour run alone is 16·16·32·4 = 32 KiB raw; deflate of a
1980        // single repeated colour collapses the whole file far under that.
1981        let raw_colors_bytes = (dims[0] * dims[1] * dims[2]) as usize * 4;
1982        assert!(
1983            bytes.len() < raw_colors_bytes / 4,
1984            "expected compression: {} serialized bytes vs {raw_colors_bytes} raw colour bytes",
1985            bytes.len(),
1986        );
1987        // Version is 2 and round-trips through parse byte-for-byte (deflate
1988        // is deterministic).
1989        assert_eq!(&bytes[4..6], &VERSION.to_le_bytes());
1990        let parsed = VoxelClip::parse(&bytes).expect("parse");
1991        assert_eq!(parsed, clip);
1992        assert_eq!(parsed.serialize(), bytes);
1993    }
1994
1995    /// Serialize a keyframe-only clip in the pre-v2 (v1) byte form: no
1996    /// per-chunk `flags` byte, every payload raw.
1997    fn serialize_v1(clip: &VoxelClip) -> Vec<u8> {
1998        fn chunk(out: &mut Vec<u8>, tag: [u8; 4], payload: &[u8]) {
1999            out.extend_from_slice(&tag);
2000            out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
2001            out.extend_from_slice(payload);
2002        }
2003        fn u32_vec(out: &mut Vec<u8>, v: &[u32]) {
2004            out.extend_from_slice(&(v.len() as u32).to_le_bytes());
2005            for w in v {
2006                out.extend_from_slice(&w.to_le_bytes());
2007            }
2008        }
2009        let mut out = Vec::new();
2010        out.extend_from_slice(b"RVCL");
2011        out.extend_from_slice(&1u16.to_le_bytes());
2012
2013        let mut meta = Vec::new();
2014        for v in clip.dims {
2015            meta.extend_from_slice(&v.to_le_bytes());
2016        }
2017        for v in clip.pivot {
2018            meta.extend_from_slice(&v.to_le_bytes());
2019        }
2020        meta.extend_from_slice(&clip.voxel_world_size.to_le_bytes());
2021        meta.push(clip.loop_mode.to_u8());
2022        meta.extend_from_slice(&clip.default_frame_ms.to_le_bytes());
2023        meta.extend_from_slice(&(clip.frames.len() as u32).to_le_bytes());
2024        chunk(&mut out, *b"META", &meta);
2025
2026        let mut frms = Vec::new();
2027        for ef in &clip.frames {
2028            let EncodedFrame::Key(f) = ef else {
2029                panic!("serialize_v1 test helper handles keyframes only");
2030            };
2031            frms.push(FRAME_KIND_KEY);
2032            u32_vec(&mut frms, &f.occupancy);
2033            u32_vec(&mut frms, &f.color_offsets);
2034            u32_vec(&mut frms, &f.colors);
2035        }
2036        chunk(&mut out, *b"FRMS", &frms);
2037        out
2038    }
2039
2040    #[test]
2041    fn legacy_v1_file_still_parses() {
2042        let dims = [2u32, 2, 3];
2043        let frame = frame_from_fn(dims, |_, _, z| (z == 0).then_some(0x80FF_0000));
2044        let clip =
2045            VoxelClip::from_frames(dims, [0.0; 3], 1.0, LoopMode::Once, &[frame], &[], 50, 0);
2046        let v1 = serialize_v1(&clip);
2047        assert_eq!(&v1[4..6], &1u16.to_le_bytes(), "helper writes version 1");
2048        let parsed = VoxelClip::parse(&v1).expect("v1 must still parse");
2049        assert_eq!(parsed, clip);
2050    }
2051
2052    #[test]
2053    fn bad_deflate_payload_is_rejected() {
2054        // v2 file whose META chunk is flagged deflated but holds garbage.
2055        let mut bytes = Vec::new();
2056        bytes.extend_from_slice(b"RVCL");
2057        bytes.extend_from_slice(&VERSION.to_le_bytes());
2058        bytes.extend_from_slice(b"META");
2059        bytes.push(CHUNK_FLAG_DEFLATED);
2060        let payload = [99u8, 0, 0, 0, 0xDE, 0xAD, 0xBE, 0xEF]; // raw_len=99, junk
2061        bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
2062        bytes.extend_from_slice(&payload);
2063        assert_eq!(VoxelClip::parse(&bytes), Err(ParseError::BadDeflate));
2064    }
2065
2066    // ---- StreamingClip (O(1-frame) seekable cursor) -----------------------
2067
2068    /// A 7-frame clip with a rising fill height + per-frame colour, so every
2069    /// frame differs from its neighbour (non-trivial deltas) and frame 6's
2070    /// height crosses the 32-bit occupancy word boundary. `keyframe_interval
2071    /// = 3` ⇒ keyframes at 0/3/6, deltas between (exercises replay).
2072    fn build_varied_clip() -> VoxelClip {
2073        let dims = [4u32, 3, 40];
2074        let frames: Vec<VoxelFrame> = (0..7u32)
2075            .map(|i| {
2076                let h = 5 + i * 5;
2077                frame_from_fn(dims, move |_x, _y, z| {
2078                    (z < h).then_some(0x8000_0000 | (i * 0x10))
2079                })
2080            })
2081            .collect();
2082        VoxelClip::from_frames(
2083            dims,
2084            [2.0, 1.5, 20.0],
2085            1.0,
2086            LoopMode::Loop,
2087            &frames,
2088            &[],
2089            33,
2090            3,
2091        )
2092    }
2093
2094    #[test]
2095    fn streaming_matches_decoded_forward_and_random() {
2096        let clip = build_varied_clip();
2097        let decoded = clip.decode().expect("decode");
2098        let mut stream = StreamingClip::new(&clip).expect("stream");
2099        assert_eq!(stream.frame_count(), decoded.frames.len());
2100        assert_eq!(stream.dims(), decoded.dims);
2101        assert_eq!(stream.pivot(), decoded.pivot);
2102
2103        // Sequential forward (incremental stepping).
2104        for (i, want) in decoded.frames.iter().enumerate() {
2105            let got = stream.seek(i).expect("seek").clone();
2106            assert_eq!(&got, want, "frame {i} (forward)");
2107            assert_eq!(
2108                stream.current_dirs(),
2109                decoded.dirs[i].as_slice(),
2110                "dirs {i}"
2111            );
2112            assert_eq!(stream.current_index(), i);
2113        }
2114        // Random + backward order (keyframe replay).
2115        for &i in &[6usize, 0, 4, 1, 5, 2, 3, 0, 6] {
2116            let got = stream.seek(i).expect("seek").clone();
2117            assert_eq!(&got, &decoded.frames[i], "frame {i} (random)");
2118            assert_eq!(stream.current_dirs(), decoded.dirs[i].as_slice());
2119        }
2120    }
2121
2122    #[test]
2123    fn streaming_seek_clamps_past_end() {
2124        let clip = build_varied_clip();
2125        let decoded = clip.decode().unwrap();
2126        let mut stream = StreamingClip::new(&clip).unwrap();
2127        let last = decoded.frames.len() - 1;
2128        let got = stream.seek(999).unwrap().clone();
2129        assert_eq!(got, decoded.frames[last]);
2130        assert_eq!(stream.current_index(), last);
2131    }
2132
2133    #[test]
2134    fn streaming_rejects_empty_and_delta_first() {
2135        let dims = [1u32, 1, 1];
2136        let mk = |frames: Vec<EncodedFrame>| VoxelClip {
2137            dims,
2138            pivot: [0.0; 3],
2139            voxel_world_size: 1.0,
2140            loop_mode: LoopMode::Loop,
2141            default_frame_ms: 1,
2142            frames,
2143            durations: Vec::new(),
2144            extra_chunks: Vec::new(),
2145        };
2146        assert_eq!(
2147            StreamingClip::new(&mk(Vec::new())).map(|_| ()),
2148            Err(DecodeError::DeltaBeforeKey),
2149        );
2150        assert_eq!(
2151            StreamingClip::new(&mk(vec![EncodedFrame::Delta(Vec::new())])).map(|_| ()),
2152            Err(DecodeError::DeltaBeforeKey),
2153        );
2154    }
2155
2156    // ---- pad diagnostics (R3, #5) -----------------------------------------
2157
2158    #[test]
2159    fn pad_stats_tight_clip_is_not_wasteful() {
2160        // Content reaches both far corners → content bbox == dims.
2161        let dims = [8u32, 8, 8];
2162        let frame = frame_from_fn(dims, |x, y, z| {
2163            ((x == 0 && y == 0 && z == 0) || (x == 7 && y == 7 && z == 7)).then_some(0x80FF_FFFF)
2164        });
2165        let s = pad_stats(dims, std::slice::from_ref(&frame));
2166        assert_eq!(s.content_dims, dims);
2167        assert_eq!(s.solid_voxels, 2);
2168        assert!((s.pad_ratio() - 1.0).abs() < 1e-6);
2169        assert!(!s.is_wasteful());
2170    }
2171
2172    #[test]
2173    fn pad_stats_padded_clip_is_wasteful() {
2174        // A 40³ bbox but content only in a 10³ corner across 3 frames.
2175        let dims = [40u32, 40, 40];
2176        let frames: Vec<VoxelFrame> = (0..3)
2177            .map(|_| {
2178                frame_from_fn(dims, |x, y, z| {
2179                    (x < 10 && y < 10 && z < 10).then_some(0x80FF_0000)
2180                })
2181            })
2182            .collect();
2183        let s = pad_stats(dims, &frames);
2184        assert_eq!(s.content_dims, [10, 10, 10]);
2185        assert_eq!(s.solid_voxels, 3 * 1000);
2186        // 40³ / 10³ = 64.
2187        assert!((s.pad_ratio() - 64.0).abs() < 1e-3);
2188        assert!(s.is_wasteful());
2189    }
2190
2191    #[test]
2192    fn pad_stats_empty_clip_is_not_wasteful() {
2193        let dims = [4u32, 4, 4];
2194        let empty = frame_from_fn(dims, |_, _, _| None);
2195        let s = pad_stats(dims, std::slice::from_ref(&empty));
2196        assert_eq!(s.content_dims, [0, 0, 0]);
2197        assert_eq!(s.solid_voxels, 0);
2198        assert!((s.pad_ratio() - 1.0).abs() < 1e-6);
2199        assert!(!s.is_wasteful());
2200    }
2201
2202    #[test]
2203    fn decoded_clip_pad_stats_delegates() {
2204        let dims = [20u32, 4, 4];
2205        let frame = frame_from_fn(dims, |x, _, _| (x < 4).then_some(0x80FF_FFFF));
2206        let clip =
2207            VoxelClip::from_frames(dims, [0.0; 3], 1.0, LoopMode::Loop, &[frame], &[], 33, 0);
2208        let s = clip.decode().unwrap().pad_stats();
2209        assert_eq!(s.content_dims, [4, 4, 4]);
2210        // 20·4·4 / 4·4·4 = 5.
2211        assert!((s.pad_ratio() - 5.0).abs() < 1e-3);
2212        assert!(s.is_wasteful());
2213    }
2214
2215    // ---- auto keyframe heuristic (VCL.1) ----------------------------------
2216
2217    fn is_key(e: &EncodedFrame) -> bool {
2218        matches!(e, EncodedFrame::Key(_))
2219    }
2220    fn key_positions(clip: &VoxelClip) -> Vec<usize> {
2221        clip.frames
2222            .iter()
2223            .enumerate()
2224            .filter_map(|(i, e)| is_key(e).then_some(i))
2225            .collect()
2226    }
2227
2228    #[test]
2229    fn from_frames_auto_round_trips() {
2230        let dims = [4u32, 3, 40];
2231        let frames: Vec<VoxelFrame> = (0..7u32)
2232            .map(|i| {
2233                let h = 5 + i * 5;
2234                frame_from_fn(dims, move |_, _, z| {
2235                    (z < h).then_some(0x8000_0000 | (i * 0x10))
2236                })
2237            })
2238            .collect();
2239        let clip =
2240            VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 0);
2241        // The key/delta choice never changes the decoded output.
2242        assert_eq!(clip.decode().unwrap().frames, frames);
2243        assert!(is_key(&clip.frames[0]), "frame 0 is always a keyframe");
2244    }
2245
2246    #[test]
2247    fn from_frames_auto_keyframes_scene_change_but_deltas_small_change() {
2248        let dims = [4u32, 4, 8];
2249        let a = frame_from_fn(dims, |_, _, z| (z < 4).then_some(0x80FF_0000));
2250        // Fully different (every column's occupancy + colour changes).
2251        let scene_cut = frame_from_fn(dims, |_, _, z| (z >= 4).then_some(0x8000_FF00));
2252        // `a` plus one extra voxel in a single column.
2253        let small = frame_from_fn(dims, |x, y, z| {
2254            ((z < 4) || (x == 0 && y == 0 && z == 4)).then_some(0x80FF_0000)
2255        });
2256
2257        let cut = VoxelClip::from_frames_auto(
2258            dims,
2259            [0.0; 3],
2260            1.0,
2261            LoopMode::Loop,
2262            &[a.clone(), scene_cut],
2263            &[],
2264            33,
2265            0,
2266        );
2267        assert!(is_key(&cut.frames[1]), "scene change → keyframe");
2268
2269        let tweak = VoxelClip::from_frames_auto(
2270            dims,
2271            [0.0; 3],
2272            1.0,
2273            LoopMode::Loop,
2274            &[a, small],
2275            &[],
2276            33,
2277            0,
2278        );
2279        assert!(!is_key(&tweak.frames[1]), "small change → delta");
2280    }
2281
2282    #[test]
2283    fn from_frames_auto_gap_caps_keyframe_spacing() {
2284        let dims = [2u32, 2, 4];
2285        let f = frame_from_fn(dims, |_, _, z| (z < 2).then_some(0x80FF_FFFF));
2286        let frames = vec![f; 7]; // identical → deltas are empty, never cost-forced
2287
2288        // No cap: only frame 0 is a keyframe.
2289        let none =
2290            VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 0);
2291        assert_eq!(key_positions(&none), vec![0]);
2292
2293        // Gap 3: keyframes at 0, 3, 6.
2294        let capped =
2295            VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 3);
2296        assert_eq!(key_positions(&capped), vec![0, 3, 6]);
2297        for (i, e) in capped.frames.iter().enumerate() {
2298            if let EncodedFrame::Delta(d) = e {
2299                assert!(d.is_empty(), "identical frame {i} → empty delta");
2300            }
2301        }
2302    }
2303
2304    #[test]
2305    fn from_kv6_frames_auto_round_trips() {
2306        let dims = [3u32, 3, 6];
2307        let ka = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
2308            (z == 0).then_some(0x80FF_0000)
2309        });
2310        let kb = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
2311            (z >= 3).then_some(0x8000_FF00)
2312        });
2313        let clip = VoxelClip::from_kv6_frames_auto(
2314            &[ka.clone(), kb.clone()],
2315            1.0,
2316            LoopMode::Loop,
2317            &[],
2318            33,
2319            0,
2320        )
2321        .expect("import");
2322        let decoded = clip.decode().unwrap();
2323        assert_eq!(decoded.frames[0], VoxelFrame::from_kv6(&ka));
2324        assert_eq!(decoded.frames[1], VoxelFrame::from_kv6(&kb));
2325    }
2326
2327    // ---- hardening (review fixes) -----------------------------------------
2328
2329    #[test]
2330    fn from_kv6_does_not_panic_on_malformed_kv6() {
2331        // `ylen` claims far more voxels than exist, has a column beyond `ny`,
2332        // and counts overrun `voxels` — an external/parsed kv6 could be bad.
2333        let bad = Kv6 {
2334            xsiz: 2,
2335            ysiz: 2,
2336            zsiz: 4,
2337            xpiv: 0.0,
2338            ypiv: 0.0,
2339            zpiv: 0.0,
2340            voxels: vec![Voxel {
2341                col: 0x80FF_FFFF,
2342                z: 0,
2343                vis: 63,
2344                dir: 0,
2345            }],
2346            xlen: vec![5, 5],                       // lies
2347            ylen: vec![vec![3, 3], vec![3, 3, 99]], // > ny + huge counts
2348            palette: None,
2349        };
2350        let f = VoxelFrame::from_kv6(&bad); // must not panic
2351                                            // The result is still a consistent frame (only the one real voxel,
2352                                            // mapped into column 0).
2353        f.validate([2, 2, 4]).expect("frame is well-formed");
2354        assert_eq!(f.colors, vec![0x80FF_FFFF]);
2355    }
2356
2357    #[test]
2358    fn inflate_rejects_oversized_raw_len() {
2359        // A deflated META chunk claiming `raw_len = u32::MAX` must be rejected
2360        // (decompression-bomb guard), not attempt a ~4 GiB allocation.
2361        let mut bytes = Vec::new();
2362        bytes.extend_from_slice(b"RVCL");
2363        bytes.extend_from_slice(&VERSION.to_le_bytes());
2364        bytes.extend_from_slice(b"META");
2365        bytes.push(CHUNK_FLAG_DEFLATED);
2366        let mut payload = u32::MAX.to_le_bytes().to_vec(); // raw_len = u32::MAX
2367        payload.extend_from_slice(&[0x01, 0x00, 0x00]); // junk deflate bytes
2368        bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
2369        bytes.extend_from_slice(&payload);
2370        assert_eq!(VoxelClip::parse(&bytes), Err(ParseError::BadDeflate));
2371    }
2372
2373    #[test]
2374    fn frame_at_no_overflow_for_huge_durations() {
2375        // total ≈ u32::MAX so `2·total` overflows u32; must stay correct in
2376        // u64 and return a valid frame index, not panic.
2377        let durations = vec![u32::MAX / 2, u32::MAX / 2];
2378        let f = frame_at(&durations, LoopMode::PingPong, u32::MAX);
2379        assert!(f < durations.len());
2380        // Loop + Once likewise.
2381        assert!(frame_at(&durations, LoopMode::Loop, u32::MAX) < durations.len());
2382        assert!(frame_at(&durations, LoopMode::Once, u32::MAX) < durations.len());
2383    }
2384
2385    /// PF.13 (S4): `frame_at_prefix` over `duration_prefix_sums` must agree
2386    /// with the linear-walk `frame_at` everywhere — including zero-duration
2387    /// frames (skipped), the exact frame-boundary instants, past-the-end
2388    /// holds, and degenerate (empty / single / all-zero) timelines.
2389    #[test]
2390    fn frame_at_prefix_matches_linear_walk() {
2391        let timelines: [&[u32]; 7] = [
2392            &[],
2393            &[100],
2394            &[100, 100, 100],
2395            &[50, 0, 50, 200],
2396            &[0, 0, 0],
2397            &[1, 1, 1, 1, 1],
2398            &[u32::MAX / 2, u32::MAX / 2],
2399        ];
2400        let modes = [LoopMode::Loop, LoopMode::Once, LoopMode::PingPong];
2401        for durations in timelines {
2402            let prefix = duration_prefix_sums(durations);
2403            for mode in modes {
2404                for t in (0u32..700).chain([u32::MAX - 1, u32::MAX]) {
2405                    assert_eq!(
2406                        frame_at_prefix(&prefix, mode, t),
2407                        frame_at(durations, mode, t),
2408                        "durations={durations:?} mode={mode:?} t={t}"
2409                    );
2410                }
2411            }
2412        }
2413    }
2414}