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 = i == 0 || (keyframe_interval != 0 && (i as u32) % keyframe_interval == 0);
1017            if is_key {
1018                encoded.push(EncodedFrame::Key(frame.clone()));
1019            } else {
1020                encoded.push(EncodedFrame::Delta(column_diff(
1021                    &frames[i - 1],
1022                    frame,
1023                    cols,
1024                    owpc,
1025                )));
1026            }
1027        }
1028
1029        Self {
1030            dims,
1031            pivot,
1032            voxel_world_size,
1033            loop_mode,
1034            default_frame_ms,
1035            frames: encoded,
1036            durations: durations.to_vec(),
1037            extra_chunks: Vec::new(),
1038        }
1039    }
1040
1041    /// Encode a sequence of full frames, **auto-choosing** keyframe vs. delta
1042    /// per frame (VCL.1) instead of a fixed interval — the codec's I-frame
1043    /// decision. A frame is stored as a keyframe when its delta would be
1044    /// large (a "scene change": at least `KEYFRAME_COST_PCT`% of the
1045    /// keyframe's size — a delta that big is barely a saving and a keyframe
1046    /// is independently seekable), or when `max_keyframe_gap` frames have
1047    /// passed since the last keyframe (a seekability cap; `0` = no cap, fully
1048    /// cost-driven). Frame 0 is always a keyframe. Otherwise identical to
1049    /// [`from_frames`](Self::from_frames).
1050    ///
1051    /// # Panics
1052    /// Same as [`from_frames`](Self::from_frames).
1053    #[must_use]
1054    pub fn from_frames_auto(
1055        dims: [u32; 3],
1056        pivot: [f32; 3],
1057        voxel_world_size: f32,
1058        loop_mode: LoopMode,
1059        frames: &[VoxelFrame],
1060        durations: &[u32],
1061        default_frame_ms: u32,
1062        max_keyframe_gap: u32,
1063    ) -> Self {
1064        for (i, f) in frames.iter().enumerate() {
1065            f.validate(dims)
1066                .unwrap_or_else(|e| panic!("frame {i} invalid: {e:?}"));
1067        }
1068        assert!(
1069            durations.is_empty() || durations.len() == frames.len(),
1070            "durations must be empty or one per frame",
1071        );
1072        let owpc = occ_words_per_col(dims) as usize;
1073        let cols = (dims[0] as usize) * (dims[1] as usize);
1074
1075        let mut encoded = Vec::with_capacity(frames.len());
1076        let mut since_key = 0u32;
1077        for (i, frame) in frames.iter().enumerate() {
1078            if i == 0 {
1079                encoded.push(EncodedFrame::Key(frame.clone()));
1080                since_key = 0;
1081                continue;
1082            }
1083            let changed = column_diff(&frames[i - 1], frame, cols, owpc);
1084            let gap_forces_key = max_keyframe_gap != 0 && since_key + 1 >= max_keyframe_gap;
1085            let delta_too_big =
1086                delta_words(&changed, owpc) * 100 >= key_words(frame) * KEYFRAME_COST_PCT;
1087            if gap_forces_key || delta_too_big {
1088                encoded.push(EncodedFrame::Key(frame.clone()));
1089                since_key = 0;
1090            } else {
1091                encoded.push(EncodedFrame::Delta(changed));
1092                since_key += 1;
1093            }
1094        }
1095
1096        Self {
1097            dims,
1098            pivot,
1099            voxel_world_size,
1100            loop_mode,
1101            default_frame_ms,
1102            frames: encoded,
1103            durations: durations.to_vec(),
1104            extra_chunks: Vec::new(),
1105        }
1106    }
1107
1108    /// Expand the I/P stream to full frames, compute per-frame `dirs`, and
1109    /// resolve durations — the runtime flipbook.
1110    ///
1111    /// # Errors
1112    /// [`DecodeError::DeltaBeforeKey`] if the stream doesn't start with a
1113    /// keyframe; [`DecodeError::ColumnOutOfRange`] if a delta names a
1114    /// column outside `dims`; [`DecodeError::Frame`] if a reconstructed
1115    /// frame fails validation.
1116    pub fn decode(&self) -> Result<DecodedClip, DecodeError> {
1117        let owpc = occ_words_per_col(self.dims) as usize;
1118        let cols = (self.dims[0] as usize) * (self.dims[1] as usize);
1119
1120        // Reconstruct incrementally via a per-column working set so a
1121        // delta is an O(changed columns) overwrite, not a flat-array splice.
1122        let mut work_occ = vec![0u32; cols * owpc];
1123        let mut work_cols: Vec<Vec<u32>> = vec![Vec::new(); cols];
1124        let mut frames: Vec<VoxelFrame> = Vec::with_capacity(self.frames.len());
1125        let mut started = false;
1126
1127        for ef in &self.frames {
1128            apply_frame(
1129                ef,
1130                &mut work_occ,
1131                &mut work_cols,
1132                self.dims,
1133                owpc,
1134                cols,
1135                &mut started,
1136            )?;
1137            frames.push(flatten(&work_occ, &work_cols, cols));
1138        }
1139
1140        // Per-frame dirs from the reconstructed occupancy.
1141        let mut dirs = Vec::with_capacity(frames.len());
1142        for f in &frames {
1143            f.validate(self.dims).map_err(DecodeError::Frame)?;
1144            dirs.push(frame_dirs(f, self.dims, owpc));
1145        }
1146
1147        let durations = if self.durations.is_empty() {
1148            vec![self.default_frame_ms; frames.len()]
1149        } else {
1150            self.durations.clone()
1151        };
1152
1153        Ok(DecodedClip {
1154            dims: self.dims,
1155            pivot: self.pivot,
1156            voxel_world_size: self.voxel_world_size,
1157            occ_words_per_col: owpc as u32,
1158            loop_mode: self.loop_mode,
1159            frames,
1160            dirs,
1161            durations,
1162        })
1163    }
1164
1165    /// Serialise to the `.rvc` byte form. Round-trips byte-equally with
1166    /// [`VoxelClip::parse`].
1167    #[must_use]
1168    pub fn serialize(&self) -> Vec<u8> {
1169        let mut out = Vec::new();
1170        out.extend_from_slice(&MAGIC);
1171        out.extend_from_slice(&VERSION.to_le_bytes());
1172
1173        write_chunk(&mut out, TAG_META, |b| {
1174            for v in self.dims {
1175                b.extend_from_slice(&v.to_le_bytes());
1176            }
1177            for v in self.pivot {
1178                b.extend_from_slice(&v.to_le_bytes());
1179            }
1180            b.extend_from_slice(&self.voxel_world_size.to_le_bytes());
1181            b.push(self.loop_mode.to_u8());
1182            b.extend_from_slice(&self.default_frame_ms.to_le_bytes());
1183            let fc = u32::try_from(self.frames.len()).expect("frame count fits u32");
1184            b.extend_from_slice(&fc.to_le_bytes());
1185        });
1186
1187        write_chunk(&mut out, TAG_FRMS, |b| {
1188            for ef in &self.frames {
1189                match ef {
1190                    EncodedFrame::Key(frame) => {
1191                        b.push(FRAME_KIND_KEY);
1192                        write_u32_vec(b, &frame.occupancy);
1193                        write_u32_vec(b, &frame.color_offsets);
1194                        write_u32_vec(b, &frame.colors);
1195                    }
1196                    EncodedFrame::Delta(changed) => {
1197                        b.push(FRAME_KIND_DELTA);
1198                        let n = u32::try_from(changed.len()).expect("changed count fits u32");
1199                        b.extend_from_slice(&n.to_le_bytes());
1200                        for d in changed {
1201                            b.extend_from_slice(&d.col.to_le_bytes());
1202                            // occ is a fixed occ_words_per_col count → no length prefix.
1203                            for w in &d.occ {
1204                                b.extend_from_slice(&w.to_le_bytes());
1205                            }
1206                            write_u32_vec(b, &d.colors);
1207                        }
1208                    }
1209                }
1210            }
1211        });
1212
1213        if !self.durations.is_empty() {
1214            write_chunk(&mut out, TAG_TIME, |b| {
1215                for d in &self.durations {
1216                    b.extend_from_slice(&d.to_le_bytes());
1217                }
1218            });
1219        }
1220
1221        for (tag, payload) in &self.extra_chunks {
1222            write_chunk(&mut out, *tag, |b| b.extend_from_slice(payload));
1223        }
1224
1225        out
1226    }
1227
1228    /// Parse the `.rvc` byte form.
1229    ///
1230    /// # Errors
1231    /// [`ParseError`] for a bad magic / unsupported version / truncation /
1232    /// missing required chunk / malformed frame stream.
1233    pub fn parse(bytes: &[u8]) -> Result<VoxelClip, ParseError> {
1234        let mut cur = Cursor::new(bytes);
1235        let magic = cur.read_bytes(4)?;
1236        if magic != MAGIC {
1237            return Err(ParseError::BadMagic {
1238                got: [magic[0], magic[1], magic[2], magic[3]],
1239            });
1240        }
1241        let version = cur.read_u16()?;
1242        if version != VERSION && version != VERSION_LEGACY {
1243            return Err(ParseError::UnsupportedVersion(version));
1244        }
1245        // v2 prefixes each chunk payload with a `flags` byte; v1 doesn't.
1246        let has_flags = version >= VERSION;
1247
1248        let mut meta: Option<Vec<u8>> = None;
1249        let mut frms: Option<Vec<u8>> = None;
1250        let mut time: Option<Vec<u8>> = None;
1251        let mut extra_chunks = Vec::new();
1252        while cur.remaining() > 0 {
1253            let tag_buf = cur.read_bytes(4)?;
1254            let tag = [tag_buf[0], tag_buf[1], tag_buf[2], tag_buf[3]];
1255            let flags = if has_flags { cur.read_u8()? } else { 0 };
1256            let len = cur.read_u32()? as usize;
1257            let stored = cur.read_bytes(len)?;
1258            let payload = if flags & CHUNK_FLAG_DEFLATED != 0 {
1259                inflate_chunk(stored)?
1260            } else {
1261                stored.to_vec()
1262            };
1263            match tag {
1264                TAG_META => meta = Some(payload),
1265                TAG_FRMS => frms = Some(payload),
1266                TAG_TIME => time = Some(payload),
1267                _ => extra_chunks.push((tag, payload)),
1268            }
1269        }
1270
1271        let meta = meta.ok_or(ParseError::MissingChunk(TAG_META))?;
1272        let frms = frms.ok_or(ParseError::MissingChunk(TAG_FRMS))?;
1273
1274        let (dims, pivot, voxel_world_size, loop_mode, default_frame_ms, frame_count) =
1275            parse_meta(&meta)?;
1276        let frames = parse_frms(&frms, dims, frame_count)?;
1277        let durations = match time {
1278            Some(p) => parse_time(&p, frame_count)?,
1279            None => Vec::new(),
1280        };
1281
1282        Ok(VoxelClip {
1283            dims,
1284            pivot,
1285            voxel_world_size,
1286            loop_mode,
1287            default_frame_ms,
1288            frames,
1289            durations,
1290            extra_chunks,
1291        })
1292    }
1293}
1294
1295// ---- decode helpers ------------------------------------------------------
1296
1297/// Apply one I/P frame to the per-column working set (`work_occ` +
1298/// `work_cols`): a keyframe overwrites the whole set, a delta rewrites only
1299/// its changed columns. Shared by [`VoxelClip::decode`] and
1300/// [`StreamingClip`]. `started` guards against a leading delta.
1301fn apply_frame(
1302    ef: &EncodedFrame,
1303    work_occ: &mut [u32],
1304    work_cols: &mut [Vec<u32>],
1305    dims: [u32; 3],
1306    owpc: usize,
1307    cols: usize,
1308    started: &mut bool,
1309) -> Result<(), DecodeError> {
1310    match ef {
1311        EncodedFrame::Key(frame) => {
1312            frame.validate(dims).map_err(DecodeError::Frame)?;
1313            work_occ.copy_from_slice(&frame.occupancy);
1314            for (col, wc) in work_cols.iter_mut().enumerate() {
1315                wc.clear();
1316                wc.extend_from_slice(frame.column_colors(col));
1317            }
1318            *started = true;
1319        }
1320        EncodedFrame::Delta(changed) => {
1321            if !*started {
1322                return Err(DecodeError::DeltaBeforeKey);
1323            }
1324            for d in changed {
1325                let col = d.col as usize;
1326                if col >= cols || d.occ.len() != owpc {
1327                    return Err(DecodeError::ColumnOutOfRange(d.col));
1328                }
1329                work_occ[col * owpc..(col + 1) * owpc].copy_from_slice(&d.occ);
1330                work_cols[col].clear();
1331                work_cols[col].extend_from_slice(&d.colors);
1332            }
1333        }
1334    }
1335    Ok(())
1336}
1337
1338/// Flatten per-column working state into a [`VoxelFrame`].
1339fn flatten(occ: &[u32], cols_colors: &[Vec<u32>], cols: usize) -> VoxelFrame {
1340    let mut color_offsets = Vec::with_capacity(cols + 1);
1341    let mut colors = Vec::new();
1342    for run in cols_colors {
1343        color_offsets.push(colors.len() as u32);
1344        colors.extend_from_slice(run);
1345    }
1346    color_offsets.push(colors.len() as u32);
1347    VoxelFrame {
1348        occupancy: occ.to_vec(),
1349        colors,
1350        color_offsets,
1351    }
1352}
1353
1354/// Per-voxel `dir` (surface-normal LUT index) for every voxel of `frame`,
1355/// ascending-z within each column — parallel to `frame.colors`.
1356fn frame_dirs(frame: &VoxelFrame, dims: [u32; 3], owpc: usize) -> Vec<u32> {
1357    let (mx, my, mz) = (dims[0] as i64, dims[1] as i64, dims[2] as i64);
1358    let solid = |x: i64, y: i64, z: i64| -> bool {
1359        if x < 0 || y < 0 || z < 0 || x >= mx || y >= my || z >= mz {
1360            return false;
1361        }
1362        let col = (x + y * mx) as usize;
1363        let word = frame.occupancy[col * owpc + (z >> 5) as usize];
1364        (word >> (z & 31)) & 1 != 0
1365    };
1366    let mut dirs = Vec::with_capacity(frame.colors.len());
1367    for y in 0..my {
1368        for x in 0..mx {
1369            let col = (x + y * mx) as usize;
1370            // Walk set bits ascending z to match the colour run order.
1371            for z in 0..mz {
1372                let word = frame.occupancy[col * owpc + (z >> 5) as usize];
1373                if (word >> (z & 31)) & 1 != 0 {
1374                    let (_vis, dir) = compute_vis_dir(&solid, x, y, z);
1375                    dirs.push(u32::from(dir));
1376                }
1377            }
1378        }
1379    }
1380    dirs
1381}
1382
1383// ---- serialize / parse helpers ------------------------------------------
1384
1385/// Write a v2 chunk: `tag(4) | flags(u8) | len(u32) | payload`. The body is
1386/// built into a scratch buffer, deflated, and stored compressed
1387/// (`CHUNK_FLAG_DEFLATED`, payload = `raw_len(u32) | deflate_bytes`) only
1388/// when that actually shrinks it — small/incompressible chunks stay raw.
1389fn write_chunk(out: &mut Vec<u8>, tag: [u8; 4], body: impl FnOnce(&mut Vec<u8>)) {
1390    let mut raw = Vec::new();
1391    body(&mut raw);
1392    out.extend_from_slice(&tag);
1393
1394    let deflated = miniz_oxide::deflate::compress_to_vec(&raw, DEFLATE_LEVEL);
1395    // `+4` accounts for the raw-length prefix a deflated payload carries.
1396    if deflated.len() + 4 < raw.len() {
1397        out.push(CHUNK_FLAG_DEFLATED);
1398        let len = u32::try_from(deflated.len() + 4).expect("chunk length fits u32");
1399        out.extend_from_slice(&len.to_le_bytes());
1400        let raw_len = u32::try_from(raw.len()).expect("raw length fits u32");
1401        out.extend_from_slice(&raw_len.to_le_bytes());
1402        out.extend_from_slice(&deflated);
1403    } else {
1404        out.push(0);
1405        let len = u32::try_from(raw.len()).expect("chunk length fits u32");
1406        out.extend_from_slice(&len.to_le_bytes());
1407        out.extend_from_slice(&raw);
1408    }
1409}
1410
1411/// Inflate a `CHUNK_FLAG_DEFLATED` payload (`raw_len(u32) | deflate_bytes`).
1412/// The stored `raw_len` bounds the output (decompression-bomb guard) and is
1413/// checked against the actual inflated length.
1414fn inflate_chunk(payload: &[u8]) -> Result<Vec<u8>, ParseError> {
1415    if payload.len() < 4 {
1416        return Err(ParseError::BadDeflate);
1417    }
1418    let raw_len = u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
1419    // The stored `raw_len` is untrusted (a crafted file could claim
1420    // `u32::MAX` → a ~4 GiB allocation). Reject anything beyond a sane cap so
1421    // it can't drive a decompression bomb.
1422    if raw_len > MAX_CHUNK_INFLATE {
1423        return Err(ParseError::BadDeflate);
1424    }
1425    let out = miniz_oxide::inflate::decompress_to_vec_with_limit(&payload[4..], raw_len)
1426        .map_err(|_| ParseError::BadDeflate)?;
1427    if out.len() != raw_len {
1428        return Err(ParseError::BadDeflate);
1429    }
1430    Ok(out)
1431}
1432
1433/// Length-prefixed (`u32`) array of `u32`s.
1434fn write_u32_vec(out: &mut Vec<u8>, v: &[u32]) {
1435    let n = u32::try_from(v.len()).expect("u32 array length fits u32");
1436    out.extend_from_slice(&n.to_le_bytes());
1437    for w in v {
1438        out.extend_from_slice(&w.to_le_bytes());
1439    }
1440}
1441
1442fn read_u32_vec(cur: &mut Cursor) -> Result<Vec<u32>, ParseError> {
1443    let n = cur.read_u32()? as usize;
1444    // QE.6b - clamp by remaining bytes (4 B/element): a lying count
1445    // fails as Truncated instead of allocation-bombing first (the 64
1446    // MiB inflate cap bounds the payload but not a crafted count).
1447    let mut v = Vec::with_capacity(cur.clamped_capacity(n, 4));
1448    for _ in 0..n {
1449        v.push(cur.read_u32()?);
1450    }
1451    Ok(v)
1452}
1453
1454#[allow(clippy::type_complexity)]
1455fn parse_meta(payload: &[u8]) -> Result<([u32; 3], [f32; 3], f32, LoopMode, u32, u32), ParseError> {
1456    let mut cur = Cursor::new(payload);
1457    let dims = [cur.read_u32()?, cur.read_u32()?, cur.read_u32()?];
1458    // QE.6b - reject degenerate/huge dims before anything derives an
1459    // allocation (or a usize overflow on wasm32) from them.
1460    if dims.iter().any(|&d| d == 0 || d > MAX_CLIP_DIM) {
1461        return Err(ParseError::BadDims { dims });
1462    }
1463    let pivot = [cur.read_f32()?, cur.read_f32()?, cur.read_f32()?];
1464    let voxel_world_size = cur.read_f32()?;
1465    let loop_mode = LoopMode::from_u8(cur.read_u8()?).ok_or(ParseError::BadLoopMode)?;
1466    let default_frame_ms = cur.read_u32()?;
1467    let frame_count = cur.read_u32()?;
1468    Ok((
1469        dims,
1470        pivot,
1471        voxel_world_size,
1472        loop_mode,
1473        default_frame_ms,
1474        frame_count,
1475    ))
1476}
1477
1478fn parse_frms(
1479    payload: &[u8],
1480    dims: [u32; 3],
1481    frame_count: u32,
1482) -> Result<Vec<EncodedFrame>, ParseError> {
1483    let owpc = occ_words_per_col(dims) as usize;
1484    let mut cur = Cursor::new(payload);
1485    let mut frames = Vec::with_capacity(frame_count as usize);
1486    for _ in 0..frame_count {
1487        let kind = cur.read_u8()?;
1488        match kind {
1489            FRAME_KIND_KEY => {
1490                let occupancy = read_u32_vec(&mut cur)?;
1491                let color_offsets = read_u32_vec(&mut cur)?;
1492                let colors = read_u32_vec(&mut cur)?;
1493                frames.push(EncodedFrame::Key(VoxelFrame {
1494                    occupancy,
1495                    colors,
1496                    color_offsets,
1497                }));
1498            }
1499            FRAME_KIND_DELTA => {
1500                let n = cur.read_u32()? as usize;
1501                let mut changed = Vec::with_capacity(n);
1502                for _ in 0..n {
1503                    let col = cur.read_u32()?;
1504                    let mut occ = Vec::with_capacity(owpc);
1505                    for _ in 0..owpc {
1506                        occ.push(cur.read_u32()?);
1507                    }
1508                    let colors = read_u32_vec(&mut cur)?;
1509                    changed.push(ColumnDelta { col, occ, colors });
1510                }
1511                frames.push(EncodedFrame::Delta(changed));
1512            }
1513            other => return Err(ParseError::BadFrameKind(other)),
1514        }
1515    }
1516    Ok(frames)
1517}
1518
1519fn parse_time(payload: &[u8], frame_count: u32) -> Result<Vec<u32>, ParseError> {
1520    let mut cur = Cursor::new(payload);
1521    let mut durations = Vec::with_capacity(frame_count as usize);
1522    for _ in 0..frame_count {
1523        durations.push(cur.read_u32()?);
1524    }
1525    Ok(durations)
1526}
1527
1528// ---- errors --------------------------------------------------------------
1529
1530/// Why [`VoxelClip::from_kv6_frames`] could not build a clip.
1531#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1532pub enum Kv6ImportError {
1533    /// No frames were supplied.
1534    Empty,
1535    /// A frame's dims differ from the first frame's (clips are fixed-bbox).
1536    DimsMismatch {
1537        /// Index of the offending frame in the input slice.
1538        frame: usize,
1539        /// That frame's `[xsiz, ysiz, zsiz]`.
1540        dims: [u32; 3],
1541        /// The first frame's dims, which every frame must match.
1542        expected: [u32; 3],
1543    },
1544}
1545
1546/// Why a [`VoxelFrame`] failed validation against a clip's `dims`.
1547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1548pub enum FrameError {
1549    /// `occupancy.len()` ≠ `dims[0] * dims[1] * occ_words_per_col`.
1550    OccupancyLen,
1551    /// `color_offsets.len()` ≠ `dims[0] * dims[1] + 1`.
1552    OffsetsLen,
1553    /// `color_offsets` doesn't start at 0 or doesn't end at
1554    /// `colors.len()`.
1555    OffsetsBounds,
1556    /// `color_offsets` decreases somewhere (a negative colour-run
1557    /// length).
1558    OffsetsMonotonic,
1559    /// Column index whose occupancy popcount ≠ its colour-run length.
1560    OccupancyColorMismatch(usize),
1561}
1562
1563/// Why [`VoxelClip::parse`] rejected a `.rvc` byte stream.
1564#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1565pub enum ParseError {
1566    /// First 4 bytes are not the `b"RVCL"` magic.
1567    BadMagic {
1568        /// The 4 bytes actually found.
1569        got: [u8; 4],
1570    },
1571    /// The version word is neither v1 (legacy, raw chunks) nor the
1572    /// current v2.
1573    UnsupportedVersion(u16),
1574    /// A read ran past the end of the file (or of a chunk payload).
1575    Truncated,
1576    /// A required chunk (`META` or `FRMS`) was absent.
1577    MissingChunk([u8; 4]),
1578    /// `META`'s `loop_mode` byte is not a known [`LoopMode`] (0..=2).
1579    BadLoopMode,
1580    /// A `FRMS` frame `kind` byte is neither `Key` (0) nor `Delta` (1).
1581    BadFrameKind(u8),
1582    /// A `CHUNK_FLAG_DEFLATED` payload failed to inflate, or its inflated
1583    /// length disagreed with the stored `raw_len`.
1584    BadDeflate,
1585    /// QE.6b - a META chunk with a zero or > 4096 dimension (bounds
1586    /// every dims-derived allocation; see `MAX_CLIP_DIM`).
1587    BadDims {
1588        /// The declared `[xsiz, ysiz, zsiz]`, in voxels.
1589        dims: [u32; 3],
1590    },
1591}
1592
1593impl From<OutOfBounds> for ParseError {
1594    fn from(_: OutOfBounds) -> Self {
1595        ParseError::Truncated
1596    }
1597}
1598
1599/// Why [`VoxelClip::decode`] failed.
1600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1601pub enum DecodeError {
1602    /// The frame stream began with a delta frame.
1603    DeltaBeforeKey,
1604    /// A delta named a column ≥ `dims[0]*dims[1]` or wrong occ length.
1605    ColumnOutOfRange(u32),
1606    /// A reconstructed frame failed validation.
1607    Frame(FrameError),
1608}
1609
1610#[cfg(test)]
1611mod tests {
1612    use super::*;
1613
1614    use crate::color::VoxColor;
1615
1616    /// Build a full frame from a dense `solid(x,y,z) -> Option<color>`
1617    /// closure (the authoring shape demiurg / the encoder will use).
1618    fn frame_from_fn(
1619        dims: [u32; 3],
1620        fill: impl Fn(u32, u32, u32) -> Option<VoxColor>,
1621    ) -> VoxelFrame {
1622        let owpc = occ_words_per_col(dims) as usize;
1623        let cols = (dims[0] as usize) * (dims[1] as usize);
1624        let mut occupancy = vec![0u32; cols * owpc];
1625        let mut color_offsets = vec![0u32; cols + 1];
1626        let mut colors = Vec::new();
1627        for y in 0..dims[1] {
1628            for x in 0..dims[0] {
1629                let col = (x + y * dims[0]) as usize;
1630                color_offsets[col] = colors.len() as u32;
1631                for z in 0..dims[2] {
1632                    if let Some(c) = fill(x, y, z) {
1633                        occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1634                        colors.push(c.0);
1635                    }
1636                }
1637            }
1638        }
1639        color_offsets[cols] = colors.len() as u32;
1640        VoxelFrame {
1641            occupancy,
1642            colors,
1643            color_offsets,
1644        }
1645    }
1646
1647    /// A small flame-ish clip: a flickering blob whose top voxel toggles
1648    /// per frame (so most columns are static, a few change — the diff
1649    /// codec's target).
1650    fn flame_clip(
1651        dims: [u32; 3],
1652        n_frames: u32,
1653        keyframe_interval: u32,
1654    ) -> (VoxelClip, Vec<VoxelFrame>) {
1655        let frames: Vec<VoxelFrame> = (0..n_frames)
1656            .map(|fi| {
1657                frame_from_fn(dims, |x, y, z| {
1658                    let cx = dims[0] / 2;
1659                    let cy = dims[1] / 2;
1660                    // a static stem in the centre column
1661                    let stem = x == cx && y == cy && z < dims[2] - 2;
1662                    // a flickering tip whose height depends on the frame
1663                    let tip = x == cx && y == cy && z == dims[2] - 2 - (fi % 2);
1664                    if stem || tip {
1665                        Some(VoxColor(0x80FF_8000 | (fi & 0xF))) // vary low bits per frame
1666                    } else {
1667                        None
1668                    }
1669                })
1670            })
1671            .collect();
1672        let clip = VoxelClip::from_frames(
1673            dims,
1674            [
1675                dims[0] as f32 * 0.5,
1676                dims[1] as f32 * 0.5,
1677                dims[2] as f32 * 0.5,
1678            ],
1679            1.0,
1680            LoopMode::Loop,
1681            &frames,
1682            &[],
1683            33,
1684            keyframe_interval,
1685        );
1686        (clip, frames)
1687    }
1688
1689    #[test]
1690    fn occ_words_per_col_matches_sprite_model() {
1691        assert_eq!(occ_words_per_col([8, 8, 1]), 1);
1692        assert_eq!(occ_words_per_col([8, 8, 32]), 1);
1693        assert_eq!(occ_words_per_col([8, 8, 33]), 2);
1694        assert_eq!(occ_words_per_col([8, 8, 256]), 8);
1695    }
1696
1697    #[test]
1698    fn frame_validate_catches_mismatch() {
1699        let dims = [4, 4, 8];
1700        let mut f = frame_from_fn(dims, |x, y, z| {
1701            (x == 0 && y == 0 && z < 3).then_some(VoxColor(0x8000_00FF))
1702        });
1703        assert!(f.validate(dims).is_ok());
1704        // Corrupt column 0: clear one occupancy bit but keep its colour run
1705        // (popcount 2 ≠ run 3).
1706        f.occupancy[0] &= !1u32;
1707        assert!(matches!(
1708            f.validate(dims),
1709            Err(FrameError::OccupancyColorMismatch(0))
1710        ));
1711    }
1712
1713    #[test]
1714    fn decode_reconstructs_every_frame() {
1715        let dims = [9, 9, 40];
1716        let (clip, original) = flame_clip(dims, 8, 4);
1717        let decoded = clip.decode().expect("decode");
1718        assert_eq!(decoded.frame_count(), original.len());
1719        for (i, (got, want)) in decoded.frames.iter().zip(&original).enumerate() {
1720            assert_eq!(got, want, "frame {i} mismatch");
1721            // dirs are parallel to colours.
1722            assert_eq!(
1723                decoded.dirs[i].len(),
1724                got.colors.len(),
1725                "frame {i} dirs len"
1726            );
1727        }
1728    }
1729
1730    #[test]
1731    fn diff_frames_are_smaller_than_keyframes() {
1732        let dims = [9, 9, 40];
1733        let (clip, _) = flame_clip(dims, 8, 0); // only frame 0 is a key
1734        let keys = clip
1735            .frames
1736            .iter()
1737            .filter(|f| matches!(f, EncodedFrame::Key(_)))
1738            .count();
1739        assert_eq!(keys, 1, "keyframe_interval=0 ⇒ exactly one keyframe");
1740        // Every non-key frame touches only a handful of columns (the tip),
1741        // far fewer than the dims[0]*dims[1] columns a keyframe rewrites.
1742        for f in &clip.frames {
1743            if let EncodedFrame::Delta(changed) = f {
1744                assert!(
1745                    changed.len() < (dims[0] * dims[1]) as usize,
1746                    "delta should be sparse, got {} columns",
1747                    changed.len()
1748                );
1749            }
1750        }
1751    }
1752
1753    #[test]
1754    fn serialize_parse_round_trips() {
1755        let dims = [9, 9, 40];
1756        let (clip, _) = flame_clip(dims, 8, 4);
1757        let bytes = clip.serialize();
1758        let parsed = VoxelClip::parse(&bytes).expect("parse");
1759        assert_eq!(parsed, clip);
1760        // Re-serialise is byte-identical.
1761        assert_eq!(parsed.serialize(), bytes);
1762        // And it still decodes to the same frames.
1763        let a = clip.decode().expect("decode a");
1764        let b = parsed.decode().expect("decode b");
1765        assert_eq!(a.frames, b.frames);
1766    }
1767
1768    #[test]
1769    fn durations_default_when_time_chunk_absent() {
1770        let dims = [4, 4, 8];
1771        let (clip, _) = flame_clip(dims, 4, 2);
1772        assert!(clip.durations.is_empty());
1773        let decoded = clip.decode().expect("decode");
1774        assert_eq!(decoded.durations, vec![33; 4]);
1775        assert_eq!(decoded.total_ms(), 33 * 4);
1776    }
1777
1778    #[test]
1779    fn explicit_durations_round_trip() {
1780        let dims = [4, 4, 8];
1781        let frames: Vec<VoxelFrame> = (0..3)
1782            .map(|fi| {
1783                frame_from_fn(dims, move |x, y, z| {
1784                    (x == 0 && y == 0 && z == fi).then_some(VoxColor(0x8011_2233))
1785                })
1786            })
1787            .collect();
1788        let clip = VoxelClip::from_frames(
1789            dims,
1790            [0.0; 3],
1791            1.0,
1792            LoopMode::Once,
1793            &frames,
1794            &[10, 20, 30],
1795            33,
1796            0,
1797        );
1798        let parsed = VoxelClip::parse(&clip.serialize()).expect("parse");
1799        assert_eq!(parsed.durations, vec![10, 20, 30]);
1800        assert_eq!(parsed.decode().unwrap().durations, vec![10, 20, 30]);
1801        assert_eq!(parsed.loop_mode, LoopMode::Once);
1802    }
1803
1804    #[test]
1805    fn unknown_chunks_preserved() {
1806        let dims = [4, 4, 8];
1807        let (mut clip, _) = flame_clip(dims, 2, 0);
1808        clip.extra_chunks.push((*b"XTRA", vec![1, 2, 3, 4, 5]));
1809        let parsed = VoxelClip::parse(&clip.serialize()).expect("parse");
1810        assert_eq!(parsed.extra_chunks, vec![(*b"XTRA", vec![1, 2, 3, 4, 5])]);
1811    }
1812
1813    #[test]
1814    fn bad_magic_and_version_rejected() {
1815        let dims = [4, 4, 8];
1816        let (clip, _) = flame_clip(dims, 2, 0);
1817        let mut bytes = clip.serialize();
1818        let good = bytes.clone();
1819        bytes[0] = b'X';
1820        assert!(matches!(
1821            VoxelClip::parse(&bytes),
1822            Err(ParseError::BadMagic { .. })
1823        ));
1824        let mut v = good.clone();
1825        v[4] = 9; // version low byte
1826        assert!(matches!(
1827            VoxelClip::parse(&v),
1828            Err(ParseError::UnsupportedVersion(_))
1829        ));
1830    }
1831
1832    #[test]
1833    fn frame_at_honours_loop_modes() {
1834        // 3 frames, 10 ms each (total 30).
1835        let dims = [4, 4, 8];
1836        let frames: Vec<VoxelFrame> = (0..3)
1837            .map(|fi| {
1838                frame_from_fn(dims, move |x, y, z| {
1839                    (x == 0 && y == 0 && z == fi).then_some(VoxColor(0x8011_2233))
1840                })
1841            })
1842            .collect();
1843        let mk = |mode| {
1844            VoxelClip::from_frames(dims, [0.0; 3], 1.0, mode, &frames, &[10, 10, 10], 33, 0)
1845                .decode()
1846                .unwrap()
1847        };
1848
1849        let loop_c = mk(LoopMode::Loop);
1850        assert_eq!(loop_c.frame_at(0), 0);
1851        assert_eq!(loop_c.frame_at(9), 0);
1852        assert_eq!(loop_c.frame_at(10), 1);
1853        assert_eq!(loop_c.frame_at(25), 2);
1854        assert_eq!(loop_c.frame_at(30), 0, "wraps at total");
1855        assert_eq!(loop_c.frame_at(45), 1);
1856
1857        let once = mk(LoopMode::Once);
1858        assert_eq!(once.frame_at(25), 2);
1859        assert_eq!(once.frame_at(1000), 2, "holds the last frame");
1860
1861        let ping = mk(LoopMode::PingPong);
1862        assert_eq!(ping.frame_at(5), 0);
1863        assert_eq!(ping.frame_at(25), 2);
1864        assert_eq!(ping.frame_at(35), 2, "mirror: 35→ frame 2");
1865        assert_eq!(ping.frame_at(55), 0, "mirror back to 0 near 2·total");
1866    }
1867
1868    #[test]
1869    fn delta_before_key_rejected() {
1870        let dims = [4, 4, 8];
1871        let clip = VoxelClip {
1872            dims,
1873            pivot: [0.0; 3],
1874            voxel_world_size: 1.0,
1875            loop_mode: LoopMode::Loop,
1876            default_frame_ms: 33,
1877            frames: vec![EncodedFrame::Delta(Vec::new())],
1878            durations: Vec::new(),
1879            extra_chunks: Vec::new(),
1880        };
1881        assert!(matches!(clip.decode(), Err(DecodeError::DeltaBeforeKey)));
1882    }
1883
1884    // ---- VCL.1: .kv6 → VoxelFrame / VoxelClip import ----------------------
1885
1886    /// A fill whose every solid voxel is isolated (no 6-neighbour solid),
1887    /// so `Kv6::from_fn` (surface-only) keeps all of them — letting the
1888    /// import be compared against the all-voxels `frame_from_fn` reference.
1889    /// Spaced on even coords; the colour encodes `(x, y, z)`.
1890    fn isolated_fill(x: u32, y: u32, z: u32) -> Option<crate::color::VoxColor> {
1891        (x % 2 == 0 && y % 2 == 0 && z % 2 == 0).then_some(crate::color::VoxColor(
1892            0x8000_0000 | (x << 16) | (y << 8) | z,
1893        ))
1894    }
1895
1896    #[test]
1897    fn from_kv6_matches_dense_reference() {
1898        // Non-square xy exercises the x-major→x-fastest re-index; z = 41
1899        // (> 32) exercises the 2-word occupancy column.
1900        let dims = [3u32, 2, 41];
1901        let kv6 = Kv6::from_fn(dims[0], dims[1], dims[2], isolated_fill);
1902        let imported = VoxelFrame::from_kv6(&kv6);
1903        let expected = frame_from_fn(dims, isolated_fill);
1904        assert_eq!(imported, expected);
1905        imported.validate(dims).expect("imported frame is valid");
1906    }
1907
1908    #[test]
1909    fn from_kv6_packs_z_across_word_boundary() {
1910        // A single 1×1 column with voxels straddling the 32-bit word split.
1911        let kv6 = Kv6::from_fn(1, 1, 41, |_, _, z| match z {
1912            0 => Some(VoxColor(0x80FF_0000)),
1913            5 => Some(VoxColor(0x8000_FF00)),
1914            33 => Some(VoxColor(0x8000_00FF)),
1915            40 => Some(VoxColor(0x80FF_FF00)),
1916            _ => None,
1917        });
1918        let f = VoxelFrame::from_kv6(&kv6);
1919        // owpc = 2; word0 bits 0,5; word1 bits 1 (=33-32), 8 (=40-32).
1920        assert_eq!(f.occupancy, vec![(1 << 0) | (1 << 5), (1 << 1) | (1 << 8)]);
1921        // Colours ascending z.
1922        assert_eq!(
1923            f.colors,
1924            vec![0x80FF_0000, 0x8000_FF00, 0x8000_00FF, 0x80FF_FF00]
1925        );
1926        assert_eq!(f.color_offsets, vec![0, 4]);
1927        f.validate([1, 1, 41]).expect("valid");
1928    }
1929
1930    #[test]
1931    fn from_kv6_frames_round_trips_through_clip() {
1932        let dims = [2u32, 2, 3];
1933        // Two full xy layers at different z's — every voxel surface-exposed.
1934        let ka = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
1935            (z == 0).then_some(VoxColor(0x80FF_0000))
1936        });
1937        let kb = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
1938            (z == 2).then_some(VoxColor(0x8000_FF00))
1939        });
1940        let clip = VoxelClip::from_kv6_frames(
1941            &[ka.clone(), kb.clone()],
1942            2.0,
1943            LoopMode::Loop,
1944            &[100, 200],
1945            0,
1946            0,
1947        )
1948        .expect("import");
1949        assert_eq!(clip.dims, dims);
1950        assert_eq!(clip.voxel_world_size, 2.0);
1951        assert_eq!(clip.pivot, [ka.xpiv, ka.ypiv, ka.zpiv]);
1952        assert_eq!(clip.durations, vec![100, 200]);
1953
1954        let decoded = clip.decode().expect("decode");
1955        assert_eq!(decoded.frames.len(), 2);
1956        assert_eq!(decoded.frames[0], VoxelFrame::from_kv6(&ka));
1957        assert_eq!(decoded.frames[1], VoxelFrame::from_kv6(&kb));
1958    }
1959
1960    #[test]
1961    fn from_kv6_frames_rejects_empty() {
1962        let err = VoxelClip::from_kv6_frames(&[], 1.0, LoopMode::Loop, &[], 50, 0)
1963            .expect_err("empty must fail");
1964        assert_eq!(err, Kv6ImportError::Empty);
1965    }
1966
1967    #[test]
1968    fn from_kv6_frames_rejects_dims_mismatch() {
1969        let ka = Kv6::from_fn(2, 2, 2, |_, _, z| (z == 0).then_some(VoxColor(0x80FF_FFFF)));
1970        let kb = Kv6::from_fn(3, 2, 2, |_, _, z| (z == 0).then_some(VoxColor(0x80FF_FFFF)));
1971        let err = VoxelClip::from_kv6_frames(&[ka, kb], 1.0, LoopMode::Loop, &[], 50, 0)
1972            .expect_err("mismatch must fail");
1973        assert_eq!(
1974            err,
1975            Kv6ImportError::DimsMismatch {
1976                frame: 1,
1977                dims: [3, 2, 2],
1978                expected: [2, 2, 2],
1979            }
1980        );
1981    }
1982
1983    #[test]
1984    fn to_kv6_inverts_from_kv6() {
1985        // Solid below a per-column threshold (interior + surface voxels), a
1986        // z-run crossing the 32-bit word boundary, distinct colours.
1987        let dims = [3u32, 2, 40];
1988        let frame = frame_from_fn(dims, |x, y, z| {
1989            (z <= (x + y) * 6 + 3).then_some(VoxColor(0x8000_0000 | (z << 8) | (x * 16 + y)))
1990        });
1991        let kv6 = frame.to_kv6(dims, [1.0, 0.5, 20.0]);
1992        assert_eq!([kv6.xsiz, kv6.ysiz, kv6.zsiz], dims);
1993        assert_eq!([kv6.xpiv, kv6.ypiv, kv6.zpiv], [1.0, 0.5, 20.0]);
1994        // from_kv6 ∘ to_kv6 reproduces occupancy + colours exactly.
1995        assert_eq!(VoxelFrame::from_kv6(&kv6), frame);
1996    }
1997
1998    #[test]
1999    fn voxel_frame_dirs_match_decoded() {
2000        // `VoxelFrame::dirs` (used by the single-frame edit) must equal the
2001        // dirs `decode` caches (the register path), so an edited frame's GPU
2002        // model is identical to a freshly-registered one.
2003        let dims = [4u32, 3, 8];
2004        let frame = frame_from_fn(dims, |x, y, z| {
2005            (z <= x + y).then_some(VoxColor(0x80FF_0000))
2006        });
2007        let clip = VoxelClip::from_frames(
2008            dims,
2009            [0.0; 3],
2010            1.0,
2011            LoopMode::Loop,
2012            std::slice::from_ref(&frame),
2013            &[],
2014            33,
2015            0,
2016        );
2017        let decoded = clip.decode().unwrap();
2018        assert_eq!(frame.dirs(dims), decoded.dirs[0]);
2019    }
2020
2021    // ---- compression (v2 per-chunk deflate) -------------------------------
2022
2023    #[test]
2024    fn compressed_clip_round_trips_and_shrinks() {
2025        // A fully-solid frame: every occupancy word all-set, one repeated
2026        // colour — maximally compressible.
2027        let dims = [16u32, 16, 32];
2028        let frame = frame_from_fn(dims, |_, _, _| Some(VoxColor(0x80AB_CDEF)));
2029        let clip = VoxelClip::from_frames(
2030            dims,
2031            [8.0, 8.0, 16.0],
2032            1.0,
2033            LoopMode::Loop,
2034            &[frame],
2035            &[],
2036            33,
2037            0,
2038        );
2039        let bytes = clip.serialize();
2040        // The colour run alone is 16·16·32·4 = 32 KiB raw; deflate of a
2041        // single repeated colour collapses the whole file far under that.
2042        let raw_colors_bytes = (dims[0] * dims[1] * dims[2]) as usize * 4;
2043        assert!(
2044            bytes.len() < raw_colors_bytes / 4,
2045            "expected compression: {} serialized bytes vs {raw_colors_bytes} raw colour bytes",
2046            bytes.len(),
2047        );
2048        // Version is 2 and round-trips through parse byte-for-byte (deflate
2049        // is deterministic).
2050        assert_eq!(&bytes[4..6], &VERSION.to_le_bytes());
2051        let parsed = VoxelClip::parse(&bytes).expect("parse");
2052        assert_eq!(parsed, clip);
2053        assert_eq!(parsed.serialize(), bytes);
2054    }
2055
2056    /// Serialize a keyframe-only clip in the pre-v2 (v1) byte form: no
2057    /// per-chunk `flags` byte, every payload raw.
2058    fn serialize_v1(clip: &VoxelClip) -> Vec<u8> {
2059        fn chunk(out: &mut Vec<u8>, tag: [u8; 4], payload: &[u8]) {
2060            out.extend_from_slice(&tag);
2061            out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
2062            out.extend_from_slice(payload);
2063        }
2064        fn u32_vec(out: &mut Vec<u8>, v: &[u32]) {
2065            out.extend_from_slice(&(v.len() as u32).to_le_bytes());
2066            for w in v {
2067                out.extend_from_slice(&w.to_le_bytes());
2068            }
2069        }
2070        let mut out = Vec::new();
2071        out.extend_from_slice(b"RVCL");
2072        out.extend_from_slice(&1u16.to_le_bytes());
2073
2074        let mut meta = Vec::new();
2075        for v in clip.dims {
2076            meta.extend_from_slice(&v.to_le_bytes());
2077        }
2078        for v in clip.pivot {
2079            meta.extend_from_slice(&v.to_le_bytes());
2080        }
2081        meta.extend_from_slice(&clip.voxel_world_size.to_le_bytes());
2082        meta.push(clip.loop_mode.to_u8());
2083        meta.extend_from_slice(&clip.default_frame_ms.to_le_bytes());
2084        meta.extend_from_slice(&(clip.frames.len() as u32).to_le_bytes());
2085        chunk(&mut out, *b"META", &meta);
2086
2087        let mut frms = Vec::new();
2088        for ef in &clip.frames {
2089            let EncodedFrame::Key(f) = ef else {
2090                panic!("serialize_v1 test helper handles keyframes only");
2091            };
2092            frms.push(FRAME_KIND_KEY);
2093            u32_vec(&mut frms, &f.occupancy);
2094            u32_vec(&mut frms, &f.color_offsets);
2095            u32_vec(&mut frms, &f.colors);
2096        }
2097        chunk(&mut out, *b"FRMS", &frms);
2098        out
2099    }
2100
2101    #[test]
2102    fn legacy_v1_file_still_parses() {
2103        let dims = [2u32, 2, 3];
2104        let frame = frame_from_fn(dims, |_, _, z| (z == 0).then_some(VoxColor(0x80FF_0000)));
2105        let clip =
2106            VoxelClip::from_frames(dims, [0.0; 3], 1.0, LoopMode::Once, &[frame], &[], 50, 0);
2107        let v1 = serialize_v1(&clip);
2108        assert_eq!(&v1[4..6], &1u16.to_le_bytes(), "helper writes version 1");
2109        let parsed = VoxelClip::parse(&v1).expect("v1 must still parse");
2110        assert_eq!(parsed, clip);
2111    }
2112
2113    #[test]
2114    fn bad_deflate_payload_is_rejected() {
2115        // v2 file whose META chunk is flagged deflated but holds garbage.
2116        let mut bytes = Vec::new();
2117        bytes.extend_from_slice(b"RVCL");
2118        bytes.extend_from_slice(&VERSION.to_le_bytes());
2119        bytes.extend_from_slice(b"META");
2120        bytes.push(CHUNK_FLAG_DEFLATED);
2121        let payload = [99u8, 0, 0, 0, 0xDE, 0xAD, 0xBE, 0xEF]; // raw_len=99, junk
2122        bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
2123        bytes.extend_from_slice(&payload);
2124        assert_eq!(VoxelClip::parse(&bytes), Err(ParseError::BadDeflate));
2125    }
2126
2127    // ---- StreamingClip (O(1-frame) seekable cursor) -----------------------
2128
2129    /// A 7-frame clip with a rising fill height + per-frame colour, so every
2130    /// frame differs from its neighbour (non-trivial deltas) and frame 6's
2131    /// height crosses the 32-bit occupancy word boundary. `keyframe_interval
2132    /// = 3` ⇒ keyframes at 0/3/6, deltas between (exercises replay).
2133    fn build_varied_clip() -> VoxelClip {
2134        let dims = [4u32, 3, 40];
2135        let frames: Vec<VoxelFrame> = (0..7u32)
2136            .map(|i| {
2137                let h = 5 + i * 5;
2138                frame_from_fn(dims, move |_x, _y, z| {
2139                    (z < h).then_some(VoxColor(0x8000_0000 | (i * 0x10)))
2140                })
2141            })
2142            .collect();
2143        VoxelClip::from_frames(
2144            dims,
2145            [2.0, 1.5, 20.0],
2146            1.0,
2147            LoopMode::Loop,
2148            &frames,
2149            &[],
2150            33,
2151            3,
2152        )
2153    }
2154
2155    #[test]
2156    fn streaming_matches_decoded_forward_and_random() {
2157        let clip = build_varied_clip();
2158        let decoded = clip.decode().expect("decode");
2159        let mut stream = StreamingClip::new(&clip).expect("stream");
2160        assert_eq!(stream.frame_count(), decoded.frames.len());
2161        assert_eq!(stream.dims(), decoded.dims);
2162        assert_eq!(stream.pivot(), decoded.pivot);
2163
2164        // Sequential forward (incremental stepping).
2165        for (i, want) in decoded.frames.iter().enumerate() {
2166            let got = stream.seek(i).expect("seek").clone();
2167            assert_eq!(&got, want, "frame {i} (forward)");
2168            assert_eq!(
2169                stream.current_dirs(),
2170                decoded.dirs[i].as_slice(),
2171                "dirs {i}"
2172            );
2173            assert_eq!(stream.current_index(), i);
2174        }
2175        // Random + backward order (keyframe replay).
2176        for &i in &[6usize, 0, 4, 1, 5, 2, 3, 0, 6] {
2177            let got = stream.seek(i).expect("seek").clone();
2178            assert_eq!(&got, &decoded.frames[i], "frame {i} (random)");
2179            assert_eq!(stream.current_dirs(), decoded.dirs[i].as_slice());
2180        }
2181    }
2182
2183    #[test]
2184    fn streaming_seek_clamps_past_end() {
2185        let clip = build_varied_clip();
2186        let decoded = clip.decode().unwrap();
2187        let mut stream = StreamingClip::new(&clip).unwrap();
2188        let last = decoded.frames.len() - 1;
2189        let got = stream.seek(999).unwrap().clone();
2190        assert_eq!(got, decoded.frames[last]);
2191        assert_eq!(stream.current_index(), last);
2192    }
2193
2194    #[test]
2195    fn streaming_rejects_empty_and_delta_first() {
2196        let dims = [1u32, 1, 1];
2197        let mk = |frames: Vec<EncodedFrame>| VoxelClip {
2198            dims,
2199            pivot: [0.0; 3],
2200            voxel_world_size: 1.0,
2201            loop_mode: LoopMode::Loop,
2202            default_frame_ms: 1,
2203            frames,
2204            durations: Vec::new(),
2205            extra_chunks: Vec::new(),
2206        };
2207        assert_eq!(
2208            StreamingClip::new(&mk(Vec::new())).map(|_| ()),
2209            Err(DecodeError::DeltaBeforeKey),
2210        );
2211        assert_eq!(
2212            StreamingClip::new(&mk(vec![EncodedFrame::Delta(Vec::new())])).map(|_| ()),
2213            Err(DecodeError::DeltaBeforeKey),
2214        );
2215    }
2216
2217    // ---- pad diagnostics (R3, #5) -----------------------------------------
2218
2219    #[test]
2220    fn pad_stats_tight_clip_is_not_wasteful() {
2221        // Content reaches both far corners → content bbox == dims.
2222        let dims = [8u32, 8, 8];
2223        let frame = frame_from_fn(dims, |x, y, z| {
2224            ((x == 0 && y == 0 && z == 0) || (x == 7 && y == 7 && z == 7))
2225                .then_some(VoxColor(0x80FF_FFFF))
2226        });
2227        let s = pad_stats(dims, std::slice::from_ref(&frame));
2228        assert_eq!(s.content_dims, dims);
2229        assert_eq!(s.solid_voxels, 2);
2230        assert!((s.pad_ratio() - 1.0).abs() < 1e-6);
2231        assert!(!s.is_wasteful());
2232    }
2233
2234    #[test]
2235    fn pad_stats_padded_clip_is_wasteful() {
2236        // A 40³ bbox but content only in a 10³ corner across 3 frames.
2237        let dims = [40u32, 40, 40];
2238        let frames: Vec<VoxelFrame> = (0..3)
2239            .map(|_| {
2240                frame_from_fn(dims, |x, y, z| {
2241                    (x < 10 && y < 10 && z < 10).then_some(VoxColor(0x80FF_0000))
2242                })
2243            })
2244            .collect();
2245        let s = pad_stats(dims, &frames);
2246        assert_eq!(s.content_dims, [10, 10, 10]);
2247        assert_eq!(s.solid_voxels, 3 * 1000);
2248        // 40³ / 10³ = 64.
2249        assert!((s.pad_ratio() - 64.0).abs() < 1e-3);
2250        assert!(s.is_wasteful());
2251    }
2252
2253    #[test]
2254    fn pad_stats_empty_clip_is_not_wasteful() {
2255        let dims = [4u32, 4, 4];
2256        let empty = frame_from_fn(dims, |_, _, _| None);
2257        let s = pad_stats(dims, std::slice::from_ref(&empty));
2258        assert_eq!(s.content_dims, [0, 0, 0]);
2259        assert_eq!(s.solid_voxels, 0);
2260        assert!((s.pad_ratio() - 1.0).abs() < 1e-6);
2261        assert!(!s.is_wasteful());
2262    }
2263
2264    #[test]
2265    fn decoded_clip_pad_stats_delegates() {
2266        let dims = [20u32, 4, 4];
2267        let frame = frame_from_fn(dims, |x, _, _| (x < 4).then_some(VoxColor(0x80FF_FFFF)));
2268        let clip =
2269            VoxelClip::from_frames(dims, [0.0; 3], 1.0, LoopMode::Loop, &[frame], &[], 33, 0);
2270        let s = clip.decode().unwrap().pad_stats();
2271        assert_eq!(s.content_dims, [4, 4, 4]);
2272        // 20·4·4 / 4·4·4 = 5.
2273        assert!((s.pad_ratio() - 5.0).abs() < 1e-3);
2274        assert!(s.is_wasteful());
2275    }
2276
2277    // ---- auto keyframe heuristic (VCL.1) ----------------------------------
2278
2279    fn is_key(e: &EncodedFrame) -> bool {
2280        matches!(e, EncodedFrame::Key(_))
2281    }
2282    fn key_positions(clip: &VoxelClip) -> Vec<usize> {
2283        clip.frames
2284            .iter()
2285            .enumerate()
2286            .filter_map(|(i, e)| is_key(e).then_some(i))
2287            .collect()
2288    }
2289
2290    #[test]
2291    fn from_frames_auto_round_trips() {
2292        let dims = [4u32, 3, 40];
2293        let frames: Vec<VoxelFrame> = (0..7u32)
2294            .map(|i| {
2295                let h = 5 + i * 5;
2296                frame_from_fn(dims, move |_, _, z| {
2297                    (z < h).then_some(VoxColor(0x8000_0000 | (i * 0x10)))
2298                })
2299            })
2300            .collect();
2301        let clip =
2302            VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 0);
2303        // The key/delta choice never changes the decoded output.
2304        assert_eq!(clip.decode().unwrap().frames, frames);
2305        assert!(is_key(&clip.frames[0]), "frame 0 is always a keyframe");
2306    }
2307
2308    #[test]
2309    fn from_frames_auto_keyframes_scene_change_but_deltas_small_change() {
2310        let dims = [4u32, 4, 8];
2311        let a = frame_from_fn(dims, |_, _, z| (z < 4).then_some(VoxColor(0x80FF_0000)));
2312        // Fully different (every column's occupancy + colour changes).
2313        let scene_cut = frame_from_fn(dims, |_, _, z| (z >= 4).then_some(VoxColor(0x8000_FF00)));
2314        // `a` plus one extra voxel in a single column.
2315        let small = frame_from_fn(dims, |x, y, z| {
2316            ((z < 4) || (x == 0 && y == 0 && z == 4)).then_some(VoxColor(0x80FF_0000))
2317        });
2318
2319        let cut = VoxelClip::from_frames_auto(
2320            dims,
2321            [0.0; 3],
2322            1.0,
2323            LoopMode::Loop,
2324            &[a.clone(), scene_cut],
2325            &[],
2326            33,
2327            0,
2328        );
2329        assert!(is_key(&cut.frames[1]), "scene change → keyframe");
2330
2331        let tweak = VoxelClip::from_frames_auto(
2332            dims,
2333            [0.0; 3],
2334            1.0,
2335            LoopMode::Loop,
2336            &[a, small],
2337            &[],
2338            33,
2339            0,
2340        );
2341        assert!(!is_key(&tweak.frames[1]), "small change → delta");
2342    }
2343
2344    #[test]
2345    fn from_frames_auto_gap_caps_keyframe_spacing() {
2346        let dims = [2u32, 2, 4];
2347        let f = frame_from_fn(dims, |_, _, z| (z < 2).then_some(VoxColor(0x80FF_FFFF)));
2348        let frames = vec![f; 7]; // identical → deltas are empty, never cost-forced
2349
2350        // No cap: only frame 0 is a keyframe.
2351        let none =
2352            VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 0);
2353        assert_eq!(key_positions(&none), vec![0]);
2354
2355        // Gap 3: keyframes at 0, 3, 6.
2356        let capped =
2357            VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 3);
2358        assert_eq!(key_positions(&capped), vec![0, 3, 6]);
2359        for (i, e) in capped.frames.iter().enumerate() {
2360            if let EncodedFrame::Delta(d) = e {
2361                assert!(d.is_empty(), "identical frame {i} → empty delta");
2362            }
2363        }
2364    }
2365
2366    #[test]
2367    fn from_kv6_frames_auto_round_trips() {
2368        let dims = [3u32, 3, 6];
2369        let ka = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
2370            (z == 0).then_some(VoxColor(0x80FF_0000))
2371        });
2372        let kb = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
2373            (z >= 3).then_some(VoxColor(0x8000_FF00))
2374        });
2375        let clip = VoxelClip::from_kv6_frames_auto(
2376            &[ka.clone(), kb.clone()],
2377            1.0,
2378            LoopMode::Loop,
2379            &[],
2380            33,
2381            0,
2382        )
2383        .expect("import");
2384        let decoded = clip.decode().unwrap();
2385        assert_eq!(decoded.frames[0], VoxelFrame::from_kv6(&ka));
2386        assert_eq!(decoded.frames[1], VoxelFrame::from_kv6(&kb));
2387    }
2388
2389    // ---- hardening (review fixes) -----------------------------------------
2390
2391    #[test]
2392    fn from_kv6_does_not_panic_on_malformed_kv6() {
2393        // `ylen` claims far more voxels than exist, has a column beyond `ny`,
2394        // and counts overrun `voxels` — an external/parsed kv6 could be bad.
2395        let bad = Kv6 {
2396            xsiz: 2,
2397            ysiz: 2,
2398            zsiz: 4,
2399            xpiv: 0.0,
2400            ypiv: 0.0,
2401            zpiv: 0.0,
2402            voxels: vec![Voxel {
2403                col: 0x80FF_FFFF,
2404                z: 0,
2405                vis: 63,
2406                dir: 0,
2407            }],
2408            xlen: vec![5, 5],                       // lies
2409            ylen: vec![vec![3, 3], vec![3, 3, 99]], // > ny + huge counts
2410            palette: None,
2411        };
2412        let f = VoxelFrame::from_kv6(&bad); // must not panic
2413                                            // The result is still a consistent frame (only the one real voxel,
2414                                            // mapped into column 0).
2415        f.validate([2, 2, 4]).expect("frame is well-formed");
2416        assert_eq!(f.colors, vec![0x80FF_FFFF]);
2417    }
2418
2419    #[test]
2420    fn inflate_rejects_oversized_raw_len() {
2421        // A deflated META chunk claiming `raw_len = u32::MAX` must be rejected
2422        // (decompression-bomb guard), not attempt a ~4 GiB allocation.
2423        let mut bytes = Vec::new();
2424        bytes.extend_from_slice(b"RVCL");
2425        bytes.extend_from_slice(&VERSION.to_le_bytes());
2426        bytes.extend_from_slice(b"META");
2427        bytes.push(CHUNK_FLAG_DEFLATED);
2428        let mut payload = u32::MAX.to_le_bytes().to_vec(); // raw_len = u32::MAX
2429        payload.extend_from_slice(&[0x01, 0x00, 0x00]); // junk deflate bytes
2430        bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
2431        bytes.extend_from_slice(&payload);
2432        assert_eq!(VoxelClip::parse(&bytes), Err(ParseError::BadDeflate));
2433    }
2434
2435    #[test]
2436    fn frame_at_no_overflow_for_huge_durations() {
2437        // total ≈ u32::MAX so `2·total` overflows u32; must stay correct in
2438        // u64 and return a valid frame index, not panic.
2439        let durations = vec![u32::MAX / 2, u32::MAX / 2];
2440        let f = frame_at(&durations, LoopMode::PingPong, u32::MAX);
2441        assert!(f < durations.len());
2442        // Loop + Once likewise.
2443        assert!(frame_at(&durations, LoopMode::Loop, u32::MAX) < durations.len());
2444        assert!(frame_at(&durations, LoopMode::Once, u32::MAX) < durations.len());
2445    }
2446
2447    /// PF.13 (S4): `frame_at_prefix` over `duration_prefix_sums` must agree
2448    /// with the linear-walk `frame_at` everywhere — including zero-duration
2449    /// frames (skipped), the exact frame-boundary instants, past-the-end
2450    /// holds, and degenerate (empty / single / all-zero) timelines.
2451    #[test]
2452    fn frame_at_prefix_matches_linear_walk() {
2453        let timelines: [&[u32]; 7] = [
2454            &[],
2455            &[100],
2456            &[100, 100, 100],
2457            &[50, 0, 50, 200],
2458            &[0, 0, 0],
2459            &[1, 1, 1, 1, 1],
2460            &[u32::MAX / 2, u32::MAX / 2],
2461        ];
2462        let modes = [LoopMode::Loop, LoopMode::Once, LoopMode::PingPong];
2463        for durations in timelines {
2464            let prefix = duration_prefix_sums(durations);
2465            for mode in modes {
2466                for t in (0u32..700).chain([u32::MAX - 1, u32::MAX]) {
2467                    assert_eq!(
2468                        frame_at_prefix(&prefix, mode, t),
2469                        frame_at(durations, mode, t),
2470                        "durations={durations:?} mode={mode:?} t={t}"
2471                    );
2472                }
2473            }
2474        }
2475    }
2476}