Skip to main content

roxlap_gpu/
decompress.rs

1//! GPU.2 — Vxl → (occupancy bitmap, colour offsets, packed colour
2//! array). Pure CPU; no wgpu deps in this module. Shape:
3//!
4//! * `occupancy[x, y]` is 8 contiguous u32 words covering z=0..256,
5//!   one bit per voxel with z-innermost ordering. Bit position of
6//!   voxel `(x, y, z)` is `z + (x + y*vsid)*CHUNK_Z`; the word
7//!   index is `(x + y*vsid)*8 + z/32` and the bit-in-word is `z & 31`.
8//!   This packs each column's 256 z-bits into 8 contiguous u32s so
9//!   the GPU shader can rank-count solid voxels in O(8 popcount)
10//!   instead of O(z) sequential bit fetches.
11//! * `color_offsets[x + y*vsid]` — u32 = base index into `colors`
12//!   for that column's voxels in ascending z. `vsid*vsid + 1`
13//!   entries; trailing sentinel = `colors.len()`.
14//! * `colors[..]` — packed u32 per occupied voxel, ordered first by
15//!   column index then by ascending z within the column.
16//!
17//! The voxlap slab format interleaves floor and ceiling colour
18//! ranges across slab boundaries, with implicit "bedrock" voxels
19//! filling the gap between a slab's textured floor and the next
20//! slab's air-gap top. Bedrock has no per-voxel colour in the slab
21//! data — voxlap stores only textured surfaces.
22//!
23//! **Bedrock-as-solid** (cliff-face fix): each [`MipUpload`] carries
24//! *two* bitmaps — `occupancy` (textured voxels only, for the colour
25//! rank) and `solid_occupancy` (textured surfaces **plus** the
26//! implicit bedrock interior below them). The marcher hit-tests
27//! `solid_occupancy`, so vertical wall/cliff faces are opaque; a
28//! bedrock hit (solid but uncoloured) inherits the colour of the
29//! textured surface above it. Bedrock is still stored as **1 bit**,
30//! not a per-voxel colour, so the colour array stays
31//! `O(textured voxels)` — storing bedrock colours would balloon a
32//! vsid=128 chunk from ~80 KiB to ~10 MiB. The cost is one extra
33//! occupancy bitmap (occupancy storage doubles; colours unchanged).
34//!
35//! (Originally "bedrock-as-air", GPU.4: bedrock was dropped entirely,
36//! which left cliff faces transparent to the sky.)
37//!
38//! This is `O(textured voxels)` work; not on the render hot path.
39
40#![allow(
41    clippy::cast_sign_loss,
42    clippy::cast_possible_truncation,
43    clippy::cast_possible_wrap,
44    clippy::many_single_char_names,
45    clippy::missing_panics_doc,
46    clippy::verbose_bit_mask
47)]
48
49use roxlap_formats::vxl::Vxl;
50
51/// Z-extent of every voxlap column — matches `roxlap_formats`'
52/// private `MAXZDIM` (`voxlap5.h:10`). Re-declared here so this
53/// module stays a pure consumer of the public `Vxl` surface.
54pub const CHUNK_Z: u32 = 256;
55
56/// Historic sentinel BGRA for bedrock voxels — kept exported so
57/// callers that want voxlap-CPU bedrock parity can render their own
58/// pass. **Not used by the default GPU decompressor**: the
59/// "bedrock-as-air" refactor (GPU.4 prereq) skips bedrock entirely.
60pub const BEDROCK_RGB: u32 = 0x0040_4040;
61
62/// CPU-decompressed chunk ready to upload to the GPU. Each field
63/// maps onto one storage buffer in GPU.3+; for GPU.2 the buffers
64/// also serve the read-back validator.
65#[derive(Debug, Clone)]
66pub struct ChunkUpload {
67    /// XY extent of the chunk in voxels — typically `roxlap-scene`'s
68    /// `CHUNK_SIZE_XY = 128`. Same as `Vxl::vsid`.
69    pub vsid: u32,
70    /// 1 bit per voxel, packed little-endian within each u32.
71    /// `bit(x, y, z) = (occupancy[i >> 5] >> (i & 31)) & 1`
72    /// where `i = x + y*vsid + z*vsid*vsid`.
73    pub occupancy: Vec<u32>,
74    /// `vsid*vsid + 1` entries. Column `(x, y)`'s colours live at
75    /// `colors[offsets[x + y*vsid] .. offsets[x + y*vsid + 1]]`,
76    /// in ascending-z order across all solid voxels of that column.
77    pub color_offsets: Vec<u32>,
78    /// Packed BGRA u32 per solid voxel (textured + bedrock).
79    pub colors: Vec<u32>,
80    /// GPU.11 — the full mip ladder, finest (mip-0) first. `mips[0]`
81    /// is identical to the [`Self::occupancy`] / [`Self::color_offsets`]
82    /// / [`Self::colors`] fields above (which the older single-chunk
83    /// and single-grid GPU paths still read directly). The scene
84    /// path concatenates every mip per slot; the shader marches the
85    /// mip its LOD picker selects. Always at least one entry; count
86    /// is [`gpu_mip_count`]`(vsid)`.
87    pub mips: Vec<MipUpload>,
88}
89
90/// Number of u32 words per column in the occupancy bitmap
91/// (`CHUNK_Z` bits packed 32-per-word). With `CHUNK_Z = 256` this is
92/// exactly 8 — the rank-count loop in the GPU shader runs in 8
93/// iterations max.
94pub const OCC_WORDS_PER_COLUMN: u32 = CHUNK_Z / 32;
95
96/// GPU.11 — number of mip levels [`decompress_chunk`] builds per
97/// chunk (capped by the chunk's own `vsid` / `CHUNK_Z` halving).
98/// Matches the CPU demo's `OpticastSettings::mip_levels = 6`, so the
99/// GPU mip ladder reaches the same ray-depth as the CPU path
100/// (`mip_scan_dist · 2⁵`). The per-mip relative-offset tables in
101/// [`crate::scene::GridStaticMeta`] are sized to this.
102pub const GPU_MAX_MIPS: u32 = 6;
103
104/// GPU.11 — how many mip levels a chunk of side `vsid` actually
105/// yields under [`GPU_MAX_MIPS`]. Mirrors the stopping rule in
106/// [`Vxl::generate_mips`] (`src_vsid > 1 && src_z > 1 && n < max`)
107/// so the upload, the per-slot stride math in [`crate::scene`], and
108/// the shader all agree on the level count for a given `vsid`.
109/// Always `>= 1` (mip-0).
110#[must_use]
111pub fn gpu_mip_count(vsid: u32) -> u32 {
112    let mut n = 1u32;
113    let mut v = vsid;
114    let mut z = CHUNK_Z as i32;
115    while v > 1 && z > 1 && n < GPU_MAX_MIPS {
116        v >>= 1;
117        z >>= 1;
118        n += 1;
119    }
120    n
121}
122
123/// GPU.11 — number of occupancy u32 words per column at a given mip
124/// (`(CHUNK_Z >> mip)` bits packed 32-per-word, min 1). Mip-0 is
125/// [`OCC_WORDS_PER_COLUMN`] (= 8).
126#[must_use]
127pub fn occ_words_per_column_for_mip(mip: u32) -> u32 {
128    (CHUNK_Z >> mip).div_ceil(32).max(1)
129}
130
131/// GPU.11 — one mip level of a chunk in the GPU upload shape. Mip-N
132/// has `(vsid >> mip)²` columns spanning z = 0..`CHUNK_Z >> mip`.
133///
134/// `color_offsets` are **absolute within the chunk's whole colour
135/// block** (cumulative across mips): mip-0's run [0..n0], mip-1's
136/// run [n0..n0+n1], etc. So `colors` of all mips concatenated in
137/// level order index directly via `chunk_colors_base + offset +
138/// rank` — the same formula the shader already uses for mip-0,
139/// independent of which mip is being read.
140#[derive(Debug, Clone)]
141pub struct MipUpload {
142    /// XY column extent at this mip = `vsid >> mip`.
143    pub vsid: u32,
144    /// Z-extent at this mip = `CHUNK_Z >> mip`.
145    pub cz: u32,
146    /// Occupancy words per column at this mip = `cz.div_ceil(32)`.
147    pub occ_words_per_col: u32,
148    /// `vsid² * occ_words_per_col` packed **textured** occupancy bits
149    /// (one set bit per voxel that has an explicit colour). The shader
150    /// rank-counts these for the colour lookup.
151    pub occupancy: Vec<u32>,
152    /// Same shape as `occupancy`, but one set bit per **solid** voxel
153    /// — textured surfaces *and* the implicit bedrock interior below
154    /// them. The marcher hit-tests against this so vertical
155    /// wall/cliff faces are opaque; bedrock hits inherit the colour of
156    /// the textured surface above them. (Fixes the "cliff face shows
157    /// sky" bedrock-as-air artifact.)
158    pub solid_occupancy: Vec<u32>,
159    /// `vsid² + 1` cumulative-within-chunk colour offsets.
160    pub color_offsets: Vec<u32>,
161    /// This mip's packed BGRA colours (ascending z within a column,
162    /// columns in `x + y*vsid` order).
163    pub colors: Vec<u32>,
164}
165
166impl ChunkUpload {
167    /// Helper for tests / debug — looks up the colour at `(x, y, z)`
168    /// if solid, else `None`. CPU-side mirror of what the GPU shader
169    /// computes.
170    #[must_use]
171    pub fn voxel_at(&self, x: u32, y: u32, z: u32) -> Option<u32> {
172        if x >= self.vsid || y >= self.vsid || z >= CHUNK_Z {
173            return None;
174        }
175        let col_idx = (x + y * self.vsid) as usize;
176        let col_word_base = col_idx * OCC_WORDS_PER_COLUMN as usize;
177        let z_word = (z / 32) as usize;
178        let z_bit = z & 31;
179        let bit = (self.occupancy[col_word_base + z_word] >> z_bit) & 1;
180        if bit == 0 {
181            return None;
182        }
183        // Rank-count solid voxels at z' < z in the same column —
184        // popcount of `z_word` full words + masked partial.
185        let mut rank = 0u32;
186        for w in 0..z_word {
187            rank += self.occupancy[col_word_base + w].count_ones();
188        }
189        let mask = if z_bit == 0 {
190            0u32
191        } else {
192            (1u32 << z_bit) - 1
193        };
194        rank += (self.occupancy[col_word_base + z_word] & mask).count_ones();
195
196        let base = self.color_offsets[col_idx];
197        Some(self.colors[(base + rank) as usize])
198    }
199}
200
201/// Decompress a `Vxl` chunk into the GPU upload shape, building the
202/// full mip ladder ([`gpu_mip_count`]`(vsid)` levels). Caller
203/// guarantees `vxl` is shaped as a roxlap-scene chunk (`vsid`
204/// square). If `vxl` already carries at least that many mips (the
205/// common scene path — the bake generates 6), they are read
206/// directly; otherwise the chunk is cloned and re-mipped so the
207/// upload always carries a deterministic, vsid-uniform level count.
208///
209/// `mips[0]` is the legacy mip-0 data, also mirrored into the
210/// top-level [`ChunkUpload`] fields for the older single-chunk /
211/// single-grid paths.
212#[must_use]
213pub fn decompress_chunk(vxl: &Vxl) -> ChunkUpload {
214    let vsid = vxl.vsid;
215    let target = gpu_mip_count(vsid);
216
217    // Ensure `target` mips are available without mutating the
218    // caller's borrow. The terrain + streaming bake already builds
219    // 6, so the fast path takes the existing tables (no clone).
220    let owned;
221    let src: &Vxl = if vxl.mip_count() >= target {
222        vxl
223    } else {
224        let mut c = vxl.clone();
225        c.generate_mips(GPU_MAX_MIPS);
226        owned = c;
227        &owned
228    };
229
230    let mut mips: Vec<MipUpload> = Vec::with_capacity(target as usize);
231    let mut color_base = 0u32;
232    for m in 0..target {
233        let mip = decompress_mip(src, m, color_base);
234        color_base = *mip.color_offsets.last().expect("offsets non-empty");
235        mips.push(mip);
236    }
237
238    let m0 = &mips[0];
239    ChunkUpload {
240        vsid,
241        occupancy: m0.occupancy.clone(),
242        color_offsets: m0.color_offsets.clone(),
243        colors: m0.colors.clone(),
244        mips,
245    }
246}
247
248/// Decompress a single mip level `mip` of `src` into a [`MipUpload`].
249/// `color_base` is the cumulative colour count of all finer mips
250/// (mips `< mip`) so this level's `color_offsets` stay absolute
251/// within the chunk's whole colour block.
252#[must_use]
253fn decompress_mip(src: &Vxl, mip: u32, color_base: u32) -> MipUpload {
254    let vsid = src.vsid >> mip;
255    let cz = CHUNK_Z >> mip;
256    let occ_words_per_col = occ_words_per_column_for_mip(mip);
257    let vsid_usize = vsid as usize;
258    let n_cols = vsid_usize * vsid_usize;
259    let n_occ_words = n_cols * (occ_words_per_col as usize);
260
261    let mut occupancy = vec![0u32; n_occ_words];
262    let mut solid_occupancy = vec![0u32; n_occ_words];
263    let mut color_offsets = vec![0u32; n_cols + 1];
264    let mut colors: Vec<u32> = Vec::with_capacity(n_cols * 4);
265
266    for y in 0..vsid {
267        for x in 0..vsid {
268            let col_idx = (y as usize) * vsid_usize + (x as usize);
269            color_offsets[col_idx] =
270                color_base + u32::try_from(colors.len()).expect("colours fit in u32");
271
272            let slab = src.column_data_for_mip(mip, col_idx);
273            decompress_column(
274                slab,
275                x,
276                y,
277                vsid,
278                cz,
279                occ_words_per_col,
280                &mut occupancy,
281                &mut solid_occupancy,
282                &mut colors,
283            );
284        }
285    }
286    color_offsets[n_cols] = color_base + u32::try_from(colors.len()).expect("colours fit in u32");
287
288    MipUpload {
289        vsid,
290        cz,
291        occ_words_per_col,
292        occupancy,
293        solid_occupancy,
294        color_offsets,
295        colors,
296    }
297}
298
299/// Walk one column's slab chain, producing two bitmaps + the colour
300/// list:
301/// * `occupancy` — one bit per **textured** voxel (has an explicit
302///   colour); pushed into `colors` in ascending z. The shader
303///   rank-counts these for the colour lookup.
304/// * `solid_occupancy` — one bit per **solid** voxel: textured
305///   surfaces *and* the implicit bedrock interior below them. The
306///   marcher hit-tests this, so cliff/wall faces are opaque.
307///
308/// Bedrock (solid but uncoloured) is marked solid only *after* the
309/// column's first real textured surface, so the `empty_chunk_vxl`
310/// all-zero placeholder columns stay fully air (no spurious black
311/// floor/ceiling). `cz` is the column z-extent at this mip; the runs
312/// from [`expand_solid_runs`] already exclude overhang air gaps.
313#[allow(clippy::too_many_arguments)]
314fn decompress_column(
315    slab: &[u8],
316    x: u32,
317    y: u32,
318    vsid: u32,
319    cz: u32,
320    occ_words_per_col: u32,
321    occupancy: &mut [u32],
322    solid_occupancy: &mut [u32],
323    colors: &mut Vec<u32>,
324) {
325    let vsid_usize = vsid as usize;
326    let runs = expand_solid_runs(slab, cz);
327    let ranges = build_color_ranges(slab);
328
329    let col_idx = (x as usize) + (y as usize) * vsid_usize;
330    let col_word_base = col_idx * (occ_words_per_col as usize);
331    // Once a column has a real textured surface, everything solid
332    // below it is bedrock to fill (opaque). Before the first surface
333    // the run voxels are placeholder/air — leave them clear.
334    let mut have_surface = false;
335
336    let mut range_cursor = 0usize;
337    for (top, bot) in runs {
338        for z in top..bot {
339            while range_cursor < ranges.len() && z >= ranges[range_cursor].z_end {
340                range_cursor += 1;
341            }
342            let in_range = range_cursor < ranges.len() && z >= ranges[range_cursor].z_start;
343            // A textured voxel has a non-zero RGB inside a colour
344            // range. `empty_chunk_vxl`'s placeholder keeps RGB 0
345            // ([0,0,0,0]); treat it as untextured so unbaked columns
346            // don't paint a black surface.
347            let mut rgb = 0u32;
348            if in_range {
349                let off = ((z - ranges[range_cursor].z_start) as usize) * 4;
350                let bytes = &ranges[range_cursor].colours[off..off + 4];
351                rgb = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
352            }
353            let z_word = (z as usize) / 32;
354            let z_bit = (z as u32) & 31;
355
356            if in_range && (rgb & 0x00ff_ffff) != 0 {
357                // Textured surface voxel: solid + coloured.
358                occupancy[col_word_base + z_word] |= 1u32 << z_bit;
359                solid_occupancy[col_word_base + z_word] |= 1u32 << z_bit;
360                colors.push(rgb);
361                have_surface = true;
362            } else if !in_range && have_surface {
363                // GENUINE bedrock — uncoloured solid below a surface
364                // (no colour range covers it). Mark solid; it inherits
365                // the surface colour above at render time. A voxel that
366                // IS in a colour range but reads RGB 0 is the
367                // `empty_chunk_vxl` placeholder (the column's z=255
368                // air filler) — leave it air, else floating objects
369                // grow a spurious floor plane below them.
370                solid_occupancy[col_word_base + z_word] |= 1u32 << z_bit;
371            }
372        }
373    }
374}
375
376/// Port of `expandrle` (voxlap5.c:4131) but emitting `(top, bot)`
377/// pairs as half-open ranges instead of the in-place `uind` layout.
378/// Solid for `z ∈ [top, bot)`. Last run's `bot` is always `cz` (the
379/// column's z-extent at this mip — matches the voxlap "implicit
380/// bedrock below" assumption, halved per mip level).
381fn expand_solid_runs(slab: &[u8], cz: u32) -> Vec<(i32, i32)> {
382    // Worst case = MAXZDIM/2 alternating solid/air runs (mip-0 bound;
383    // coarser mips use fewer entries).
384    let mut uind = [0i32; (CHUNK_Z as usize) + 2];
385    uind[0] = i32::from(slab[1]);
386    let mut i = 2usize;
387    let mut v = 0usize;
388    while slab[v] != 0 {
389        v += usize::from(slab[v]) * 4;
390        if slab[v + 3] >= slab[v + 1] {
391            continue;
392        }
393        uind[i - 1] = i32::from(slab[v + 3]);
394        uind[i] = i32::from(slab[v + 1]);
395        i += 2;
396    }
397    uind[i - 1] = cz as i32;
398
399    let n_runs = i / 2;
400    let mut runs = Vec::with_capacity(n_runs);
401    for k in 0..n_runs {
402        runs.push((uind[2 * k], uind[2 * k + 1]));
403    }
404    runs
405}
406
407/// One colour-record range = colours for voxels at `z ∈ [z_start, z_end)`.
408struct ColorRange<'s> {
409    z_start: i32,
410    z_end: i32,
411    colours: &'s [u8],
412}
413
414/// Build the per-column colour lookup table — port of voxlap's
415/// `compilerle` colour-table loop (voxlap5.c:4163-4174) + the
416/// matching ceiling-colour walk. Mirrors `roxlap-formats`'
417/// private `build_color_table` field-for-field.
418fn build_color_ranges(slab: &[u8]) -> Vec<ColorRange<'_>> {
419    let mut ranges: Vec<ColorRange<'_>> = Vec::new();
420    let mut v = 0usize;
421    loop {
422        let z_start = i32::from(slab[v + 1]);
423        let z1c = i32::from(slab[v + 2]);
424        let z_end = z1c + 1;
425        let n_voxels = usize::try_from((z_end - z_start).max(0)).expect("non-negative");
426        let off = v + 4;
427        ranges.push(ColorRange {
428            z_start,
429            z_end,
430            colours: &slab[off..off + n_voxels * 4],
431        });
432
433        let nextptr = slab[v];
434        if nextptr == 0 {
435            break;
436        }
437        let prev_z1 = z_start;
438        let prev_z1c = z1c;
439        let prev_nextptr = i32::from(nextptr);
440        v += usize::from(nextptr) * 4;
441
442        // Ceiling colour list for the NEW slab — stored in the tail
443        // of the previous slab's bytes, between its floor colours
444        // and the next slab's header.
445        let ze = i32::from(slab[v + 3]);
446        let ceil_z_start = ze + prev_z1c - prev_z1 - prev_nextptr + 2;
447        let ceil_z_end = ze;
448        let ceil_n = usize::try_from((ceil_z_end - ceil_z_start).max(0)).expect("non-negative");
449        let ceil_start = v - ceil_n * 4;
450        ranges.push(ColorRange {
451            z_start: ceil_z_start,
452            z_end: ceil_z_end,
453            colours: &slab[ceil_start..v],
454        });
455    }
456    ranges
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use roxlap_formats::vxl::Vxl;
463
464    /// Build a tiny `Vxl` (4×4 columns) where every column is a
465    /// single-slab "one floor voxel at z=100" with colour
466    /// `0xAARRGGBB = 0x80ff_8000` (red-orange). Used as the
467    /// canonical fixture for both CPU and GPU round-trip tests.
468    pub(crate) fn fixture_one_voxel_per_column() -> Vxl {
469        let vsid: u32 = 4;
470        let n_cols = (vsid as usize) * (vsid as usize);
471        let mut data = Vec::with_capacity(n_cols * 8);
472        let mut column_offset = Vec::with_capacity(n_cols + 1);
473        // 0x80ff_8000 little-endian bytes = [0x00, 0x80, 0xff, 0x80]
474        // = [B=0x00, G=0x80, R=0xff, A=0x80 (neutral brightness)].
475        // Alpha=0x80 keeps the fixture non-empty under the
476        // alpha-zero placeholder filter in `decompress_column`.
477        let bgra = [0x00, 0x80, 0xff, 0x80];
478        for _ in 0..n_cols {
479            column_offset.push(u32::try_from(data.len()).expect("offset fits"));
480            data.extend_from_slice(&[0, 100, 100, 0]); // nextptr=0, z1=100, z1c=100, z0=0
481            data.extend_from_slice(&bgra);
482        }
483        column_offset.push(u32::try_from(data.len()).expect("offset fits"));
484
485        Vxl {
486            vsid,
487            ipo: [0.0; 3],
488            ist: [1.0, 0.0, 0.0],
489            ihe: [0.0, 0.0, 1.0],
490            ifo: [0.0, 1.0, 0.0],
491            data: data.into_boxed_slice(),
492            column_offset: column_offset.into_boxed_slice(),
493            mip_base_offsets: Box::new([0, n_cols + 1]),
494            vbit: Box::new([]),
495            vbiti: 0,
496        }
497    }
498
499    #[test]
500    fn fixture_textured_voxel_carries_slab_colour() {
501        let vxl = fixture_one_voxel_per_column();
502        let chunk = decompress_chunk(&vxl);
503        assert_eq!(chunk.voxel_at(1, 2, 100), Some(0x80ff_8000));
504    }
505
506    #[test]
507    fn fixture_air_above_textured_is_empty() {
508        let vxl = fixture_one_voxel_per_column();
509        let chunk = decompress_chunk(&vxl);
510        for z in 0..100 {
511            assert_eq!(chunk.voxel_at(1, 2, z), None, "z={z} expected air");
512        }
513    }
514
515    #[test]
516    fn fixture_below_textured_is_air_after_bedrock_strip() {
517        // Bedrock-as-air refactor (GPU.4 prereq): z>z1c is no
518        // longer reported as solid by the GPU decompressor.
519        let vxl = fixture_one_voxel_per_column();
520        let chunk = decompress_chunk(&vxl);
521        for z in 101..CHUNK_Z {
522            assert_eq!(
523                chunk.voxel_at(1, 2, z),
524                None,
525                "z={z} expected air (bedrock stripped)"
526            );
527        }
528    }
529
530    #[test]
531    fn only_textured_voxels_are_marked_solid() {
532        let vxl = fixture_one_voxel_per_column();
533        let chunk = decompress_chunk(&vxl);
534        // 1 textured voxel per column.
535        let solid: u32 = chunk.occupancy.iter().map(|w| w.count_ones()).sum();
536        let expected = chunk.vsid * chunk.vsid;
537        assert_eq!(solid, expected);
538    }
539
540    // ---- GPU.11 mip-ladder tests ------------------------------------
541
542    #[test]
543    fn gpu_mip_count_matches_generate_mips() {
544        // vsid=128 chunk supports the full GPU_MAX_MIPS=6.
545        assert_eq!(gpu_mip_count(128), 6);
546        // Small vsid caps the ladder where halving hits 1.
547        assert_eq!(gpu_mip_count(4), 3); // 4 -> 2 -> 1
548        assert_eq!(gpu_mip_count(2), 2); // 2 -> 1
549        assert_eq!(gpu_mip_count(1), 1);
550    }
551
552    #[test]
553    fn occ_words_per_column_halves_with_z() {
554        assert_eq!(occ_words_per_column_for_mip(0), 8); // 256/32
555        assert_eq!(occ_words_per_column_for_mip(1), 4); // 128/32
556        assert_eq!(occ_words_per_column_for_mip(2), 2); // 64/32
557        assert_eq!(occ_words_per_column_for_mip(3), 1); // 32/32
558        assert_eq!(occ_words_per_column_for_mip(4), 1); // 16 -> min 1
559        assert_eq!(occ_words_per_column_for_mip(5), 1); // 8 -> min 1
560    }
561
562    #[test]
563    fn mip0_mirrors_legacy_top_level_fields() {
564        let vxl = fixture_one_voxel_per_column();
565        let chunk = decompress_chunk(&vxl);
566        assert!(chunk.mips.len() >= 2, "fixture should build a mip ladder");
567        let m0 = &chunk.mips[0];
568        assert_eq!(m0.vsid, 4);
569        assert_eq!(m0.cz, CHUNK_Z);
570        assert_eq!(m0.occ_words_per_col, OCC_WORDS_PER_COLUMN);
571        assert_eq!(m0.occupancy, chunk.occupancy, "mip-0 occupancy == legacy");
572        assert_eq!(m0.colors, chunk.colors, "mip-0 colours == legacy");
573        assert_eq!(
574            m0.color_offsets, chunk.color_offsets,
575            "mip-0 offsets == legacy"
576        );
577        assert_eq!(m0.color_offsets[0], 0, "mip-0 starts at colour 0");
578    }
579
580    #[test]
581    fn each_mip_popcount_equals_color_count() {
582        // The 1:1 occupancy-bit ↔ colour invariant must hold at every
583        // mip level (the shader's rank-count colour lookup relies on
584        // it). The fixture's clone+generate_mips path exercises the
585        // coarse levels.
586        let vxl = fixture_one_voxel_per_column();
587        let chunk = decompress_chunk(&vxl);
588        assert_eq!(chunk.mips.len() as u32, gpu_mip_count(4));
589        for (m, mip) in chunk.mips.iter().enumerate() {
590            let solid: u32 = mip.occupancy.iter().map(|w| w.count_ones()).sum();
591            let in_mip = mip.colors.len() as u32;
592            assert_eq!(
593                solid, in_mip,
594                "mip {m}: {solid} solid bits but {in_mip} colours",
595            );
596            assert_eq!(mip.vsid, 4 >> m as u32);
597            assert_eq!(mip.cz, CHUNK_Z >> m as u32);
598            assert_eq!(
599                mip.occ_words_per_col,
600                occ_words_per_column_for_mip(m as u32)
601            );
602            assert_eq!(
603                mip.occupancy.len() as u32,
604                mip.vsid * mip.vsid * mip.occ_words_per_col,
605            );
606            assert_eq!(mip.color_offsets.len() as u32, mip.vsid * mip.vsid + 1);
607        }
608    }
609
610    #[test]
611    fn solid_occupancy_fills_bedrock_below_surface() {
612        // The cliff-face fix: every mip's `solid_occupancy` is solid
613        // from the textured surface down through the bedrock interior,
614        // while `occupancy` (textured) marks only the surface.
615        let vxl = fixture_one_voxel_per_column(); // textured at z=100
616        let chunk = decompress_chunk(&vxl);
617        let m0 = &chunk.mips[0];
618        let base = 0usize; // column (0,0)
619        let bit = |buf: &[u32], z: u32| (buf[base + (z / 32) as usize] >> (z & 31)) & 1 == 1;
620
621        assert!(bit(&m0.occupancy, 100), "surface textured");
622        assert!(bit(&m0.solid_occupancy, 100), "surface solid");
623        assert!(!bit(&m0.occupancy, 150), "bedrock is not textured");
624        assert!(
625            bit(&m0.solid_occupancy, 150),
626            "bedrock below surface is solid"
627        );
628        assert!(bit(&m0.solid_occupancy, 255), "bedrock fills to the bottom");
629        assert!(
630            !bit(&m0.solid_occupancy, 50),
631            "air above the surface stays air"
632        );
633        // The textured popcount still equals the colour count.
634        let solid: u32 = m0.solid_occupancy.iter().map(|w| w.count_ones()).sum();
635        let tex: u32 = m0.occupancy.iter().map(|w| w.count_ones()).sum();
636        assert!(solid > tex, "solid (surface + bedrock) exceeds textured");
637    }
638
639    #[test]
640    fn color_offsets_are_absolute_and_monotonic_across_mips() {
641        let vxl = fixture_one_voxel_per_column();
642        let chunk = decompress_chunk(&vxl);
643        let mut prev_end = 0u32;
644        for (m, mip) in chunk.mips.iter().enumerate() {
645            // Within a mip, offsets are non-decreasing.
646            for w in mip.color_offsets.windows(2) {
647                assert!(w[0] <= w[1], "mip {m} offsets not monotonic");
648            }
649            // First offset continues where the previous mip's colours
650            // ended (cumulative within the chunk's whole colour block).
651            assert_eq!(
652                mip.color_offsets[0], prev_end,
653                "mip {m} colour base not contiguous",
654            );
655            // Trailing sentinel == base + this mip's colour count.
656            assert_eq!(
657                *mip.color_offsets.last().unwrap(),
658                prev_end + mip.colors.len() as u32,
659            );
660            prev_end = *mip.color_offsets.last().unwrap();
661        }
662    }
663
664    #[test]
665    fn color_offsets_partition_colours_correctly() {
666        let vxl = fixture_one_voxel_per_column();
667        let chunk = decompress_chunk(&vxl);
668        let n_cols = (chunk.vsid * chunk.vsid) as usize;
669        assert_eq!(chunk.color_offsets.len(), n_cols + 1);
670        assert_eq!(chunk.color_offsets[0], 0);
671        // Bedrock is stripped — only the 1 textured voxel/column
672        // ends up in colours.
673        let per_col = 1;
674        for i in 0..=n_cols {
675            assert_eq!(
676                chunk.color_offsets[i],
677                u32::try_from(i).expect("test fixture small") * per_col,
678            );
679        }
680        assert_eq!(
681            *chunk.color_offsets.last().unwrap() as usize,
682            chunk.colors.len()
683        );
684    }
685}