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