roxlap_core/world_query.rs
1//! Per-voxel world queries against a `.vxl` column-slab world.
2//!
3//! Port of voxlap's `getcube` (voxlap5.c:937-962) — the engine's
4//! random-access voxel lookup. Used by sphere/region edits
5//! (`meltsphere`, `setrect`, etc.) and any logic that needs to ask
6//! "what's at (x, y, z)?" without going through a ray cast.
7//!
8//! Voxlap's slab format (mirroring `roxlap-formats::vxl`):
9//!
10//! ```text
11//! slab record:
12//! byte 0 nextptr — advance to next slab in dwords (== 0 last slab)
13//! byte 1 z1 — top z of floor-colour list
14//! byte 2 z1c — bottom z of floor-colour list MINUS 1
15//! byte 3 z0 — ceiling z (additional slabs); dummy in first
16//! <floor colours> (z1c - z1 + 1) × 4 bytes (BGRA each)
17//! <ceil colours> n_ceil × 4 bytes — ceiling colours of the *next*
18//! slab (i.e. visible bottom of *this* slab's
19//! solid mass, observed from inside the air pocket
20//! between this slab and the next).
21//! ```
22//!
23//! The ceiling colours sit between this slab's floor colours and the
24//! next slab's header — so they're addressed at *negative* offsets
25//! from the next slab's header, which is what voxlap's getcube
26//! exploits via `&v[(z - v[3]) * 4]` with `z < v[3]`.
27//!
28//! `n_ceil` is derived as `n_ceil = nextptr - 2 - (z1c - z1)`. Voxlap
29//! stores the *negative* of this as `ceilnum = z1c - z1 - nextptr + 2`
30//! and uses it as a signed comparison threshold.
31
32// The slab walker is a pointer-arithmetic port; the casts mirror C's
33// implicit narrowing/sign-loss (e.g. voxlap's `(uint32_t)(x|y) >= VSID`
34// out-of-bounds test). Calls inside this module's loop are guarded by
35// the precondition checks above.
36#![allow(
37 clippy::cast_possible_truncation,
38 clippy::cast_possible_wrap,
39 clippy::cast_sign_loss
40)]
41
42/// Result of a [`getcube`] query.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Cube {
45 /// Empty space (sky, air pocket, or out-of-bounds (x, y)).
46 Air,
47 /// Solid material whose colour isn't stored in the slab list —
48 /// either deep below the deepest slab, or in the hidden interior
49 /// between a slab's floor and the next slab's ceiling colours.
50 UnexposedSolid,
51 /// Visible voxel; `0xAARRGGBB` in voxlap's storage convention
52 /// (the slab bytes are little-endian B,G,R,intensity → reading
53 /// as `u32_le` lands intensity in the high byte).
54 Color(u32),
55}
56
57/// Look up the voxel at `(x, y, z)` in a column-slab world.
58///
59/// `slab_buf` + `column_offsets` + `vsid` describe the world the same
60/// way the rasterizer takes them: `slab_buf` is the concatenated raw
61/// slab bytes, `column_offsets[i]..column_offsets[i + 1]` is column
62/// `i = y * vsid + x`'s slab range, and `vsid` is the square map
63/// dimension. `column_offsets.len() == vsid² + 1`.
64///
65/// Out-of-bounds `(x, y)` returns [`Cube::Air`] (matches voxlap C's
66/// `(uint32_t)(x|y) >= VSID` early return). `z` is *not* range-checked
67/// — the caller is expected to clamp to `[0, MAXZDIM)` if needed.
68///
69/// # Panics
70///
71/// Panics on a malformed column whose slab walker would step past the
72/// column's data range. Voxlap's loader (`roxlap-formats::vxl::parse`)
73/// validates slab structure on parse, so any `Vxl` that round-trips
74/// the parser is safe to query.
75#[must_use]
76pub fn getcube(slab_buf: &[u8], column_offsets: &[u32], vsid: u32, x: i32, y: i32, z: i32) -> Cube {
77 // Voxlap's `(uint32_t)(x|y) >= VSID` test: rejects negatives and
78 // anything past the map edge in one bitwise comparison.
79 if x < 0 || y < 0 || (x as u32) >= vsid || (y as u32) >= vsid {
80 return Cube::Air;
81 }
82 let col_idx = (y as u32 * vsid + x as u32) as usize;
83 let col_start = column_offsets[col_idx] as usize;
84 // The slab walker self-terminates on `nextptr == 0`, so we don't
85 // need an explicit end bound. Using `column_offsets[idx + 1]` as
86 // the end was wrong post-edit: voxalloc scatters columns across
87 // vbuf, so adjacent indices in the offset table are no longer
88 // adjacent in the buffer (and may even overlap or appear in
89 // reverse order). The slab walker reads only what each column's
90 // own nextptr chain says, so slicing to end-of-buffer is safe.
91 let col = &slab_buf[col_start..];
92
93 // Walk the slab chain. `pos` is the byte offset within `col` of
94 // the current slab's header.
95 let mut pos: usize = 0;
96 loop {
97 let nextptr = i32::from(col[pos]);
98 let z1 = i32::from(col[pos + 1]);
99 let z1c = i32::from(col[pos + 2]);
100 // col[pos + 3] is z0 (additional slab) or dummy (first slab);
101 // only consulted after the `pos += nextptr*4` advance below.
102
103 if z <= z1c {
104 if z < z1 {
105 // Above the visible top of this slab — air pocket.
106 return Cube::Air;
107 }
108 // Floor colour at byte offset (z - z1)*4 + 4 from header.
109 let off = pos + (z - z1) as usize * 4 + 4;
110 return Cube::Color(read_color(col, off));
111 }
112
113 // Voxlap's signed-stride trick: ceilnum is the *negative* of
114 // the actual ceiling-colour count of the next slab. Always
115 // ≤ 0; equal to 0 when the next slab has no ceiling colours.
116 let ceilnum = z1c - z1 - nextptr + 2;
117
118 if nextptr == 0 {
119 // Last slab and z > z1c: deepest unexposed material.
120 return Cube::UnexposedSolid;
121 }
122
123 pos += nextptr as usize * 4;
124 let next_z0 = i32::from(col[pos + 3]);
125 if z < next_z0 {
126 let dz = z - next_z0; // dz < 0 here.
127 if dz < ceilnum {
128 // Above the ceiling colour list — hidden interior.
129 return Cube::UnexposedSolid;
130 }
131 // Ceiling colour at *negative* byte offset dz*4 from the
132 // next slab's header. dz ∈ [ceilnum, -1].
133 let off = (pos as isize + (dz * 4) as isize) as usize;
134 return Cube::Color(read_color(col, off));
135 }
136 // z >= next_z0: still in the air pocket above next_z0's air
137 // gap, or below it. Fall through and re-test against the next
138 // slab as the new "current" slab on the next iteration.
139 }
140}
141
142#[inline]
143fn read_color(col: &[u8], off: usize) -> u32 {
144 u32::from_le_bytes([col[off], col[off + 1], col[off + 2], col[off + 3]])
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 /// Single solid slab covering z = 5..=14 (10 visible floor voxels).
152 /// Header [nextptr=0, z1=5, z1c=14, dummy=0]. Each colour record
153 /// encodes its z value in B so we can verify the lookup picks the
154 /// right offset.
155 fn single_slab_5_to_14() -> Vec<u8> {
156 let mut col = vec![0u8, 5, 14, 0];
157 for z in 5..=14u8 {
158 col.extend_from_slice(&[z, 0xaa, 0xbb, 0x80]);
159 }
160 col
161 }
162
163 /// Two slabs separated by a one-voxel air gap.
164 ///
165 /// Slab 0 = solid mass at z=10..28 (floor at z=10..14, hidden
166 /// interior z=15..25, ceiling at z=26..28). Air pocket at z=29.
167 /// Slab 1 = solid mass at z=30..39 (floor at z=30..39).
168 ///
169 /// Layout:
170 /// slab 0 header [nextptr=9, z1=10, z1c=14, dummy=0] 4 bytes
171 /// slab 0 floor z=10..14 (5 colours, B byte = z) 20 bytes
172 /// slab 1 ceil z=26..28 (3 colours, B byte = z) 12 bytes
173 /// slab 1 header [nextptr=0, z1=30, z1c=39, z0=29] 4 bytes
174 /// slab 1 floor z=30..39 (10 colours, B byte = z) 40 bytes
175 ///
176 /// `nextptr_0 = (4 + 20 + 12) / 4 = 9` dwords.
177 /// `ceilnum_0 = z1c - z1 - nextptr + 2 = 14 - 10 - 9 + 2 = -3`,
178 /// so `n_ceil = 3` ✓.
179 /// `z0_1 = 29` puts the ceiling colours at `z = z0 - 3 .. z0 - 1`
180 /// `= 26 .. 28` (matching the ceil-fill loop above).
181 fn two_slabs_with_ceiling() -> Vec<u8> {
182 let mut col = vec![9u8, 10, 14, 0];
183 for z in 10..=14u8 {
184 col.extend_from_slice(&[z, 0x10, 0x20, 0x80]);
185 }
186 for z in 26..=28u8 {
187 col.extend_from_slice(&[z, 0x30, 0x40, 0x80]);
188 }
189 col.extend_from_slice(&[0u8, 30, 39, 29]);
190 for z in 30..=39u8 {
191 col.extend_from_slice(&[z, 0x50, 0x60, 0x80]);
192 }
193 col
194 }
195
196 fn world_with(col: Vec<u8>) -> (Vec<u8>, Vec<u32>) {
197 // 1×1 world: column 0 = `col`, column_offset = [0, col.len()].
198 let len = col.len() as u32;
199 (col, vec![0, len])
200 }
201
202 #[test]
203 fn out_of_bounds_xy_returns_air() {
204 let (buf, off) = world_with(single_slab_5_to_14());
205 assert_eq!(getcube(&buf, &off, 1, -1, 0, 7), Cube::Air);
206 assert_eq!(getcube(&buf, &off, 1, 0, -1, 7), Cube::Air);
207 assert_eq!(getcube(&buf, &off, 1, 1, 0, 7), Cube::Air);
208 assert_eq!(getcube(&buf, &off, 1, 0, 1, 7), Cube::Air);
209 }
210
211 #[test]
212 fn above_first_slab_returns_air() {
213 let (buf, off) = world_with(single_slab_5_to_14());
214 // z = 0..4 is above z1=5 → air.
215 for z in 0..5 {
216 assert_eq!(getcube(&buf, &off, 1, 0, 0, z), Cube::Air, "z={z}");
217 }
218 }
219
220 #[test]
221 fn floor_color_returned_for_visible_voxels() {
222 let (buf, off) = world_with(single_slab_5_to_14());
223 // z=5..=14 are floor colours; B = z, G = 0xaa, R = 0xbb, alpha = 0x80.
224 for z in 5..=14 {
225 let want = u32::from_le_bytes([z as u8, 0xaa, 0xbb, 0x80]);
226 assert_eq!(getcube(&buf, &off, 1, 0, 0, z), Cube::Color(want), "z={z}");
227 }
228 }
229
230 #[test]
231 fn below_last_slab_returns_unexposed_solid() {
232 let (buf, off) = world_with(single_slab_5_to_14());
233 // z > z1c = 14 with nextptr = 0 → unexposed.
234 for z in 15..40 {
235 assert_eq!(
236 getcube(&buf, &off, 1, 0, 0, z),
237 Cube::UnexposedSolid,
238 "z={z}"
239 );
240 }
241 }
242
243 #[test]
244 fn second_slab_floor_resolved() {
245 let (buf, off) = world_with(two_slabs_with_ceiling());
246 // z=30..=39 are second slab's floor colours.
247 for z in 30..=39 {
248 let want = u32::from_le_bytes([z as u8, 0x50, 0x60, 0x80]);
249 assert_eq!(getcube(&buf, &off, 1, 0, 0, z), Cube::Color(want), "z={z}");
250 }
251 }
252
253 #[test]
254 fn slab0_ceiling_colors_resolved() {
255 let (buf, off) = world_with(two_slabs_with_ceiling());
256 // Slab 0's ceiling at z=26..=28 (visible bottom of slab 0,
257 // looking up from inside the air pocket below it).
258 for z in 26..=28 {
259 let want = u32::from_le_bytes([z as u8, 0x30, 0x40, 0x80]);
260 assert_eq!(getcube(&buf, &off, 1, 0, 0, z), Cube::Color(want), "z={z}");
261 }
262 }
263
264 #[test]
265 fn slab0_hidden_interior_returns_unexposed() {
266 let (buf, off) = world_with(two_slabs_with_ceiling());
267 // z=15..=25 is slab 0's hidden interior (between floor at
268 // z=14 and ceiling at z=26). Walker falls through slab 0's
269 // top, advances to slab 1, sees z < z0_next=29, dz < ceilnum
270 // = -3 so dz=z-29 ∈ [-14, -4] all satisfy dz < -3.
271 for z in 15..=25 {
272 assert_eq!(
273 getcube(&buf, &off, 1, 0, 0, z),
274 Cube::UnexposedSolid,
275 "z={z}"
276 );
277 }
278 }
279
280 #[test]
281 fn air_pocket_between_slabs_returns_air() {
282 let (buf, off) = world_with(two_slabs_with_ceiling());
283 // z=29 is the one-voxel air pocket between slab 0's bottom
284 // (z=28 ceiling) and slab 1's top (z=30 floor). z0_next=29
285 // so `z < z0_next` is false; loop continues with slab 1 as
286 // the new "current" slab. z <= z1c_1 = 39 and z < z1_1 = 30
287 // → Cube::Air.
288 assert_eq!(getcube(&buf, &off, 1, 0, 0, 29), Cube::Air);
289 }
290
291 #[test]
292 fn deep_below_last_slab_unexposed() {
293 let (buf, off) = world_with(two_slabs_with_ceiling());
294 // z >= 40 is below slab 1's floor (z1c=39) with nextptr=0.
295 for z in 40..50 {
296 assert_eq!(
297 getcube(&buf, &off, 1, 0, 0, z),
298 Cube::UnexposedSolid,
299 "z={z}"
300 );
301 }
302 }
303}