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