Skip to main content

roxlap_formats/
kvx.rs

1//! `.kvx` voxel-sprite format (Build-engine voxel sprites).
2//!
3//! Reference: voxlaptest's `setkvx` in `voxlap/voxlap5.c`. File layout
4//! (all multi-byte fields are little-endian):
5//!
6//! ```text
7//! offset  size                        description
8//! 0x00    u32                         numbytes (header + offsets + slabs, excluding palette)
9//! 0x04    u32                         xsiz
10//! 0x08    u32                         ysiz
11//! 0x0c    u32                         zsiz
12//! 0x10    u32                         xpivot (8.8 fixed-point voxel units)
13//! 0x14    u32                         ypivot
14//! 0x18    u32                         zpivot
15//! 0x1c    u32 × (xsiz+1)              xoffset table
16//! ...     u16 × xsiz × (ysiz+1)       xyoffset table
17//! ...     variable                    slab data (per (x, y) column)
18//! end-768 256 × [r6 g6 b6]            palette (each component 0..=63)
19//! ```
20//!
21//! Slab data per (x, y) column is a sequence of `[ztop:u8, zleng:u8,
22//! vis:u8, colors:[u8; zleng]]` records; the byte length of column
23//! (x, y)'s slab list is `xyoffset[x][y+1] - xyoffset[x][y]`.
24//!
25//! This module preserves `xoffset` and `xyoffset` verbatim so a parsed
26//! `Kvx` round-trips byte-equally. Synthesising a fresh `Kvx` from
27//! voxel data (without reusing existing offset tables) is left for a
28//! future stage and is out of R2.1's scope.
29
30use core::fmt;
31
32use crate::bytes::{Cursor, OutOfBounds};
33use crate::Rgb6;
34
35/// One run of consecutive voxels at a fixed (x, y) column.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Slab {
38    /// Top z coordinate of the run (the "ztop" byte in the file format).
39    pub ztop: u8,
40    /// Visibility-flags byte; bits encode which of the six cube faces of
41    /// the run are exposed to air. Bit 4 (`0x10`) is the "back-face
42    /// suppress" flag voxlaptest's `setkvx` reads at offset +2 of each
43    /// slab.
44    pub vis: u8,
45    /// Per-voxel palette indices. `colors.len()` is the run length
46    /// ("zleng" in the file format).
47    pub colors: Vec<u8>,
48}
49
50/// Parsed `.kvx` model. Round-trips byte-equally via [`parse`] +
51/// [`serialize`].
52#[derive(Debug, Clone)]
53pub struct Kvx {
54    pub xsiz: u32,
55    pub ysiz: u32,
56    pub zsiz: u32,
57    /// Pivot point in 8.8 fixed-point voxel units (i.e. divide by 256
58    /// to get fractional voxels).
59    pub xpivot: u32,
60    pub ypivot: u32,
61    pub zpivot: u32,
62    /// xoffset table, length `xsiz + 1`. Stored verbatim from the file;
63    /// not interpreted by this crate beyond round-trip.
64    pub xoffset: Vec<u32>,
65    /// xyoffset table, dimensions `[xsiz][ysiz + 1]`. Slab list byte
66    /// length for column (x, y) is `xyoffset[x][y+1] - xyoffset[x][y]`.
67    pub xyoffset: Vec<Vec<u16>>,
68    /// Slab lists per column. Outer index is x in `0..xsiz`, inner is y
69    /// in `0..ysiz`. Inner-most `Vec<Slab>` is the column's slab list,
70    /// in file order.
71    pub columns: Vec<Vec<Vec<Slab>>>,
72    /// 256-entry palette. Indexed by `Slab::colors[i]`.
73    pub palette: [Rgb6; 256],
74}
75
76/// Errors returned by [`parse`].
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum ParseError {
79    /// File too small to contain even the 28-byte header + 768-byte
80    /// palette.
81    TooSmall { got: usize },
82    /// A read of `need` bytes at offset `at` would run past the end of
83    /// the buffer.
84    Truncated { at: usize, need: usize },
85    /// xyoffset values for column `x` are non-monotonic (would imply a
86    /// negative slab list length).
87    NonMonotonicOffsets { x: u32, y: u32 },
88    /// A slab record's declared length runs past the end of its
89    /// column's slab list.
90    SlabOverrun { x: u32, y: u32 },
91}
92
93impl fmt::Display for ParseError {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        match *self {
96            Self::TooSmall { got } => write!(
97                f,
98                "kvx file too small ({got} bytes; need at least 28 byte header + 768 byte palette)"
99            ),
100            Self::Truncated { at, need } => {
101                write!(f, "kvx truncated: need {need} bytes at offset {at}")
102            }
103            Self::NonMonotonicOffsets { x, y } => write!(
104                f,
105                "kvx column (x={x}, y={y}): xyoffset table is non-monotonic"
106            ),
107            Self::SlabOverrun { x, y } => write!(
108                f,
109                "kvx column (x={x}, y={y}): slab declared zleng overruns column byte budget"
110            ),
111        }
112    }
113}
114
115impl std::error::Error for ParseError {}
116
117impl From<OutOfBounds> for ParseError {
118    fn from(e: OutOfBounds) -> Self {
119        Self::Truncated {
120            at: e.at,
121            need: e.need,
122        }
123    }
124}
125
126const HEADER_LEN: usize = 28;
127const PALETTE_LEN: usize = 768;
128
129/// Parse a `.kvx` file's bytes into a [`Kvx`].
130///
131/// # Errors
132///
133/// Returns [`ParseError`] if `bytes` is too short for the fixed header
134/// or palette, if a sequential read would run past EOF, or if a slab
135/// list's recorded `xyoffset` differences are non-monotonic or imply a
136/// declared zleng that overruns the column's byte budget.
137pub fn parse(bytes: &[u8]) -> Result<Kvx, ParseError> {
138    if bytes.len() < HEADER_LEN + PALETTE_LEN {
139        return Err(ParseError::TooSmall { got: bytes.len() });
140    }
141
142    let mut cur = Cursor::new(bytes);
143    let _numbytes = cur.read_u32()?;
144    let xsiz = cur.read_u32()?;
145    let ysiz = cur.read_u32()?;
146    let zsiz = cur.read_u32()?;
147    let xpivot = cur.read_u32()?;
148    let ypivot = cur.read_u32()?;
149    let zpivot = cur.read_u32()?;
150
151    let xoff_len = xsiz as usize + 1;
152    let mut xoffset = Vec::with_capacity(xoff_len);
153    for _ in 0..xoff_len {
154        xoffset.push(cur.read_u32()?);
155    }
156
157    let yoff_len = ysiz as usize + 1;
158    let mut xyoffset = Vec::with_capacity(xsiz as usize);
159    for _ in 0..xsiz {
160        let mut row = Vec::with_capacity(yoff_len);
161        for _ in 0..yoff_len {
162            row.push(cur.read_u16()?);
163        }
164        xyoffset.push(row);
165    }
166
167    // Slab data spans from `cur.pos` to `bytes.len() - PALETTE_LEN`.
168    let slab_region_start = cur.pos;
169    let slab_region_end = bytes
170        .len()
171        .checked_sub(PALETTE_LEN)
172        .ok_or(ParseError::TooSmall { got: bytes.len() })?;
173
174    let mut columns = Vec::with_capacity(xsiz as usize);
175    let mut slabs_cur = Cursor::new(&bytes[..slab_region_end]);
176    slabs_cur.pos = slab_region_start;
177
178    for x in 0..xsiz {
179        let mut col = Vec::with_capacity(ysiz as usize);
180        for y in 0..ysiz {
181            let lo = u32::from(xyoffset[x as usize][y as usize]);
182            let hi = u32::from(xyoffset[x as usize][y as usize + 1]);
183            if hi < lo {
184                return Err(ParseError::NonMonotonicOffsets { x, y });
185            }
186            let nbytes = (hi - lo) as usize;
187            let mut budget = nbytes;
188            let mut slabs = Vec::new();
189            while budget > 0 {
190                if budget < 3 {
191                    return Err(ParseError::SlabOverrun { x, y });
192                }
193                let ztop = slabs_cur.read_u8()?;
194                let zleng = slabs_cur.read_u8()? as usize;
195                let vis = slabs_cur.read_u8()?;
196                budget -= 3;
197                if zleng > budget {
198                    return Err(ParseError::SlabOverrun { x, y });
199                }
200                let mut colors = vec![0u8; zleng];
201                for c in &mut colors {
202                    *c = slabs_cur.read_u8()?;
203                }
204                budget -= zleng;
205                slabs.push(Slab { ztop, vis, colors });
206            }
207            col.push(slabs);
208        }
209        columns.push(col);
210    }
211
212    // Palette is the last 768 bytes.
213    let mut palette = [Rgb6 { r: 0, g: 0, b: 0 }; 256];
214    let pal = &bytes[bytes.len() - PALETTE_LEN..];
215    for (i, e) in palette.iter_mut().enumerate() {
216        e.r = pal[i * 3];
217        e.g = pal[i * 3 + 1];
218        e.b = pal[i * 3 + 2];
219    }
220
221    Ok(Kvx {
222        xsiz,
223        ysiz,
224        zsiz,
225        xpivot,
226        ypivot,
227        zpivot,
228        xoffset,
229        xyoffset,
230        columns,
231        palette,
232    })
233}
234
235/// Serialise a [`Kvx`] back to bytes. The output round-trips byte-
236/// equally with the input that produced this `Kvx` via [`parse`].
237///
238/// # Panics
239///
240/// Panics if the encoded file size exceeds `u32::MAX` (a `.kvx` file
241/// larger than 4 GiB cannot represent its `numbytes` header field) or
242/// if any [`Slab::colors`] has length > 255 (the on-disk `zleng` field
243/// is a single byte). Both are file-format limits, not runtime errors;
244/// `Kvx` values produced by [`parse`] always satisfy them.
245#[must_use]
246pub fn serialize(kvx: &Kvx) -> Vec<u8> {
247    // Compute slab data size first so we can fill in `numbytes`.
248    let slab_bytes_total: usize = kvx
249        .columns
250        .iter()
251        .flatten()
252        .flatten()
253        .map(|s| 3 + s.colors.len())
254        .sum();
255
256    let offset_table_bytes =
257        kvx.xoffset.len() * 4 + kvx.xyoffset.iter().map(|row| row.len() * 2).sum::<usize>();
258    let numbytes = HEADER_LEN - 4 + offset_table_bytes + slab_bytes_total;
259    let numbytes_u32 = u32::try_from(numbytes).expect("kvx file >= 4 GiB is not representable");
260
261    let mut out =
262        Vec::with_capacity(HEADER_LEN + offset_table_bytes + slab_bytes_total + PALETTE_LEN);
263
264    out.extend_from_slice(&numbytes_u32.to_le_bytes());
265    out.extend_from_slice(&kvx.xsiz.to_le_bytes());
266    out.extend_from_slice(&kvx.ysiz.to_le_bytes());
267    out.extend_from_slice(&kvx.zsiz.to_le_bytes());
268    out.extend_from_slice(&kvx.xpivot.to_le_bytes());
269    out.extend_from_slice(&kvx.ypivot.to_le_bytes());
270    out.extend_from_slice(&kvx.zpivot.to_le_bytes());
271    for v in &kvx.xoffset {
272        out.extend_from_slice(&v.to_le_bytes());
273    }
274    for row in &kvx.xyoffset {
275        for v in row {
276            out.extend_from_slice(&v.to_le_bytes());
277        }
278    }
279    for col in &kvx.columns {
280        for slabs in col {
281            for s in slabs {
282                // zleng (run length) is a 1-byte field, so colors.len()
283                // must fit in a u8. Slabs from `parse` always satisfy
284                // this; user-constructed slabs that don't are a bug.
285                let zleng = u8::try_from(s.colors.len())
286                    .expect("kvx slab zleng must fit in u8 (file format limit)");
287                out.push(s.ztop);
288                out.push(zleng);
289                out.push(s.vis);
290                out.extend_from_slice(&s.colors);
291            }
292        }
293    }
294    for e in &kvx.palette {
295        out.push(e.r);
296        out.push(e.g);
297        out.push(e.b);
298    }
299
300    out
301}
302
303// --- tests --------------------------------------------------------------
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    /// `assets/coco.kvx`, the same fixture `voxlaptest`'s oracle loads
310    /// for its `sprite_coco` pose.
311    const COCO_KVX: &[u8] = include_bytes!("../../../assets/coco.kvx");
312
313    #[test]
314    fn parse_coco_header() {
315        let kvx = parse(COCO_KVX).expect("parse coco.kvx");
316        assert_eq!(kvx.xsiz, 9);
317        assert_eq!(kvx.ysiz, 11);
318        assert_eq!(kvx.zsiz, 9);
319        assert_eq!(kvx.xpivot, 0x200);
320        assert_eq!(kvx.ypivot, 0x300);
321        assert_eq!(kvx.zpivot, 0x900);
322        assert_eq!(kvx.columns.len(), 9);
323        assert_eq!(kvx.columns[0].len(), 11);
324    }
325
326    #[test]
327    fn coco_palette_widening() {
328        let kvx = parse(COCO_KVX).expect("parse coco.kvx");
329        // First palette entry from the hex dump is r=0x3f g=0x19 b=0x19.
330        let p0 = kvx.palette[0];
331        assert_eq!((p0.r, p0.g, p0.b), (0x3f, 0x19, 0x19));
332        // Voxlap-style packing matches setkvx's longpal[i] for the same
333        // bytes: ((0x3f << 18) | (0x19 << 10) | (0x19 << 2)) | 0x80000000.
334        let want = 0x8000_0000u32 | (0x3fu32 << 18) | (0x19u32 << 10) | (0x19u32 << 2);
335        assert_eq!(p0.to_voxlap_argb(), want);
336    }
337
338    #[test]
339    fn coco_roundtrips_byte_equal() {
340        let kvx = parse(COCO_KVX).expect("parse coco.kvx");
341        let out = serialize(&kvx);
342        assert_eq!(out.len(), COCO_KVX.len(), "length differs");
343        assert_eq!(out.as_slice(), COCO_KVX, "byte content differs");
344    }
345
346    #[test]
347    fn parse_truncated_fails() {
348        let r = parse(&[0u8; 16]);
349        assert!(matches!(r, Err(ParseError::TooSmall { .. })));
350    }
351}