roxlap_core/rasterizer.rs
1//! Rasterizer trait + per-frame scan scratch — the callback surface
2//! the four-quadrant scan loops dispatch into. R4.3 will provide the
3//! real implementation (`grouscan` for `gline`, the 4.7-scalar /
4//! 4.9-SSE rasterizers for `hrend` / `vrend`); test code can plug a
5//! recording stub here and exercise the scan loops without any
6//! actual world data.
7
8/// One ray-cast hit record. Voxlap calls this `castdat`
9/// (`voxlap5.c:124..127`):
10///
11/// ```c
12/// typedef struct { int32_t col, dist; } castdat;
13/// ```
14///
15/// `col` is a Voxlap-style packed colour (`0x80RRGGBB`); `dist` is a
16/// fixed-point distance to the hit slab.
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
18pub struct CastDat {
19 pub col: i32,
20 pub dist: i32,
21}
22
23/// Scratch state the scan loops share between `gline` (the ray
24/// caster, R4.3) and `hrend` / `vrend` (the scanline rasterizers, in
25/// roxlap-core's R5 SSE-recover companion).
26///
27/// In voxlap C this is several globals: a static `radar` buffer,
28/// `castdat *angstart[MAXXDIM*4]` pointers, `gscanptr` cursor, and
29/// the `skycurlng` / `skycurdir` sky-radar bookkeeping. The Rust
30/// port keeps them on a stack-allocatable struct so each render call
31/// owns its own scratch and the engine doesn't have hidden mutable
32/// global state.
33#[derive(Debug, Clone)]
34pub struct ScanScratch {
35 /// All ray-cast hit records, written by `gline` calls and read
36 /// indirectly by `hrend` / `vrend` via [`Self::angstart`].
37 pub radar: Vec<CastDat>,
38 /// Per-ray offset into [`Self::radar`] — voxlap stores this as a
39 /// `castdat*` array and computes entries via `gscanptr ± p0/p1`,
40 /// which can land *before* `radar[0]` (negative offset). The
41 /// scanline rasterizers add a per-pixel `plc` value on top before
42 /// the actual deref, and that combination is always in-range. We
43 /// keep the raw signed offset here to mirror voxlap's pointer
44 /// arithmetic exactly.
45 pub angstart: Vec<isize>,
46 /// Cursor into [`Self::radar`] for the next-to-be-written hit
47 /// record. Reset to 0 at the start of each quadrant scan.
48 pub gscanptr: usize,
49 /// Sky-radar bookkeeping cursor (`skycurlng` in voxlap). `-1`
50 /// when no sky pixel has been emitted yet.
51 pub sky_cur_lng: i32,
52 /// `+1` or `-1` — the sign of `-giforzsgn` that voxlap stamps on
53 /// each new quadrant entry. The scan loops will set this; for
54 /// now [`ScanScratch::new_for_size`] just initialises to `0`.
55 pub sky_cur_dir: i32,
56 /// Voxlap's `skyoff` — current row's pixel-index offset into the
57 /// sky texture (= `sky_cur_lng * (sky.xsiz + 1)`, computed in
58 /// gline's per-ray frustum prep). `0` means the textured-sky
59 /// path is inactive — `phase_startsky` falls back to solid-fill
60 /// `skycast`. Set by gline when an [`crate::sky::Sky`] is
61 /// loaded; reset to 0 each quadrant.
62 pub sky_off: i32,
63 /// Per-screen-row x-boundary, voxlap's
64 /// `int32_t lastx[max(MAXYDIM, VSID)]`. The right / left
65 /// quadrants populate this during their pass-2 column walk; the
66 /// `vrend` dispatch pass then reads `lastx[sy]` per row to know
67 /// where each vertical slice begins.
68 pub lastx: Vec<i32>,
69 /// Per-screen-column ray-index pair, voxlap's
70 /// `int32_t uurendmem[MAXXDIM*2 + 9]` viewed as
71 /// `[uurend[sx], uurend[sx + MAXXDIM]]`. The right / left
72 /// quadrants stamp `uurend[sx] = u` and
73 /// `uurend[sx + MAXXDIM] = ui` per column for the vertical
74 /// rasterizer to consume.
75 pub uurend: Vec<i32>,
76 /// Stride between the `uurend[sx]` half and the
77 /// `uurend[sx + half_stride]` half. Equals `MAXXDIM` in voxlap;
78 /// our port sizes the buffer exactly to the framebuffer width
79 /// rounded up.
80 pub uurend_half_stride: usize,
81
82 // ---------------------------------------------------------------
83 // grouscan (R4.3c+) per-ray state. Voxlap keeps these as globals;
84 // we group them on ScanScratch so each render call owns them and
85 // there is no hidden mutable global state.
86 // ---------------------------------------------------------------
87 /// `cf[129]` — voxlap's cfasm scratch. The seed slot at index
88 /// `CF_SEED_INDEX` (= 128) is filled by `gline` before each ray;
89 /// grouscan pops / pushes from there.
90 pub cf: Vec<crate::grouscan::CfType>,
91 /// `gpz[2]` — distance to next voxel-grid line per axis,
92 /// `PREC`-scaled. Set by gline per ray; grouscan walks it.
93 pub gpz: [i32; 2],
94 /// `gdz[2]` — per-column-step delta added to `gpz` after a
95 /// column advance. Constant per ray. Set by gline.
96 pub gdz: [i32; 2],
97 /// `gixy[2]` — voxel-column step in the ray's direction
98 /// (`±1` along x, `±vsid` along y). Set by gline.
99 pub gixy: [i32; 2],
100 /// `gxmax` — scan-distance ceiling, `PREC`-scaled. Set by gline
101 /// per ray (clipped against viewport edges and `gmaxscandist`).
102 pub gxmax: i32,
103 /// `gi0` — voxlap's per-pixel x step coefficient written by
104 /// gline; consumed by grouscan's column advance.
105 pub gi0: i32,
106 /// `gi1` — voxlap's per-pixel y step coefficient.
107 pub gi1: i32,
108
109 /// Voxlap's `skycast` — the `(col, dist)` pair grouscan's
110 /// startsky writes into every remaining radar slot when the
111 /// solid-sky branch fires (textured sky is R4.4 work). The
112 /// engine sets it via [`Self::set_skycast`] before invoking
113 /// the rasterizer; default is opaque black at far depth.
114 pub skycast: CastDat,
115 /// Fog colour (packed ARGB; the alpha byte isn't used by the
116 /// per-channel blend). Set by [`Self::set_fog`].
117 pub fog_col: i32,
118 /// Fog distance falloff table. Empty = fog disabled (voxlap's
119 /// `ofogdist < 0`). Otherwise `foglut[dist >> 20] & 32767`
120 /// gives the per-pixel blend factor (0 = no fog applied,
121 /// 32767 = full fog colour). Built by [`Self::set_fog`].
122 pub foglut: Vec<i32>,
123 /// Voxlap's `gcsub[9]` per-side shading table. Default pattern
124 /// is `0x00ff00ff00ff00ff` per entry — that's voxlap's
125 /// `setsideshades(0,0,0,0,0,0)` baseline (no per-side
126 /// darkening). The high byte of entries 2..7 is the per-side
127 /// intensity (top, bottom, left, right, up, down). Set via
128 /// [`Self::set_side_shades`]; rasterizers read it on every gline
129 /// call.
130 pub gcsub: [i64; 9],
131 /// Voxlap's `vx5.sideshademode` flag — derived by
132 /// [`Self::set_side_shades`]: false when all six args are zero
133 /// (oracle baseline, swap is dead), true otherwise. When true,
134 /// the per-ray gline body picks `gcsub[0]`/`gcsub[1]` from
135 /// `gcsub[4..7]` based on the sign of `gixy[0]`/`gixy[1]` so
136 /// wall faces get directional darkening (voxlap5.c:1230-1234).
137 pub sideshademode: bool,
138}
139
140impl ScanScratch {
141 /// Allocate a scratch buffer sized for an `xres × yres`
142 /// framebuffer. Voxlap's per-frame `radar` buffer is
143 /// `MAXXDIM * 6 * 256` `castdat` entries (`voxlap5.c:206`-area
144 /// declaration). Sized as `xres * max(6*256, yres*2)` — the
145 /// `yres*2` branch activates on HiDPI screens where per-quadrant
146 /// radar consumption exceeds the classic `6*256` per-column budget
147 /// due to corner-cut fan expansion at steep camera angles.
148 /// `uurend` / `lastx` are sized to fit `xres` / `max(yres, vsid)`
149 /// entries respectively (R4.1f4b consumers).
150 #[must_use]
151 pub fn new_for_size(xres: u32, yres: u32, vsid: u32) -> Self {
152 // Radar holds all per-ray castdat entries for one quadrant (gscanptr
153 // resets per-quadrant). When the camera tilts down, cy >> yres makes
154 // the top/bottom fan wider than xres (corner-cut expansion), so more
155 // rays are cast and each center ray spans ~yres pixels. The original
156 // factor 6*256 = 1536 sufficed for yres ≤ 768 (classic 640×480 /
157 // 800×600); HiDPI screens (yres > 768) need proportionally more.
158 // Voxlap C side-stepped this by using a fixed MAXXDIM=2880 constant
159 // regardless of actual resolution.
160 let per_col_budget = std::cmp::max(6 * 256, (yres as usize) * 2);
161 let radar_cap = (xres as usize) * per_col_budget;
162 let angstart_cap = (xres as usize) * 4;
163 let half_stride = xres as usize;
164 let lastx_cap = std::cmp::max(yres, vsid) as usize;
165 Self {
166 radar: vec![CastDat::default(); radar_cap],
167 angstart: vec![0isize; angstart_cap],
168 gscanptr: 0,
169 sky_cur_lng: -1,
170 sky_cur_dir: 0,
171 sky_off: 0,
172 lastx: vec![0i32; lastx_cap],
173 uurend: vec![0i32; half_stride * 2],
174 uurend_half_stride: half_stride,
175 cf: vec![crate::grouscan::CfType::default(); crate::grouscan::CF_LEN],
176 gpz: [0; 2],
177 gdz: [0; 2],
178 gixy: [0; 2],
179 gxmax: 0,
180 gi0: 0,
181 gi1: 0,
182 skycast: CastDat::default(),
183 fog_col: 0,
184 foglut: Vec::new(),
185 gcsub: [0x00ff_00ff_00ff_00ff; 9],
186 sideshademode: false,
187 }
188 }
189
190 /// Engine-side setter for per-side shading intensities, mirror
191 /// of voxlap's `setsideshades(top, bot, left, right, up, down)`
192 /// (`voxlap5.c`). Each `i8` parameter is the high byte stamped
193 /// onto `gcsub[2..7]`; the low 7 bytes keep the
194 /// `0x00ff00ff00ff00ff` saturate-zero pattern. Pass `(0,…,0)` to
195 /// disable shading (the default), or moderate positive values
196 /// (15..31) for visible side darkening like voxlap's classic
197 /// games use.
198 pub fn set_side_shades(&mut self, top: i8, bot: i8, left: i8, right: i8, up: i8, down: i8) {
199 // High byte of an i64 (LE) is byte 7. Voxlap writes
200 // `((char *)&gcsub[k])[7] = sxx;` directly — bit-equivalent
201 // to reinterpreting the i8 as u8 and stamping it into the
202 // top byte. The sign-loss `as u8` is intentional (mirrors
203 // the C cast).
204 let pack = |intensity: i8| -> i64 {
205 #[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
206 let high = u64::from(intensity as u8) << 56;
207 #[allow(clippy::cast_possible_wrap)]
208 {
209 (high | 0x00ff_00ff_00ff_00ff_u64) as i64
210 }
211 };
212 self.gcsub[0] = 0x00ff_00ff_00ff_00ff;
213 self.gcsub[1] = 0x00ff_00ff_00ff_00ff;
214 self.gcsub[2] = pack(top);
215 self.gcsub[3] = pack(bot);
216 self.gcsub[4] = pack(left);
217 self.gcsub[5] = pack(right);
218 self.gcsub[6] = pack(up);
219 self.gcsub[7] = pack(down);
220 self.gcsub[8] = 0x00ff_00ff_00ff_00ff;
221 // Voxlap5.c:2535-2540: `if (!(sto|sbo|sle|sri|sup|sdo))` →
222 // sideshademode = 0; else sideshademode = 1. Any non-zero
223 // arg flips on the per-ray gcsub[0]/[1] swap in gline.
224 self.sideshademode =
225 top != 0 || bot != 0 || left != 0 || right != 0 || up != 0 || down != 0;
226 }
227
228 /// Engine-side setter for the sky `(col, dist)` pair. Engine
229 /// owns `Engine::sky_color`; this is the wire it writes to so
230 /// `grouscan`'s startsky has the right value at fill time.
231 pub fn set_skycast(&mut self, col: i32, dist: i32) {
232 self.skycast = CastDat { col, dist };
233 }
234
235 /// Engine-side setter for fog. Voxlap5.c:11151-11185.
236 /// `max_scan_dist <= 0` disables fog (clears the table).
237 /// Otherwise builds the 2048-entry fog falloff table:
238 /// `foglut[k] = (acc >> 16) & 32767` where `acc` accumulates
239 /// `step = i32::MAX / max_scan_dist` per entry. After the
240 /// accumulation overflows, remaining entries pad with `32767`
241 /// (full fog).
242 //
243 // The C version stores per-entry as a 4-lane packed `int64`
244 // (`hi16` repeated four times) for the asm's MMX path. The
245 // scalar fallback only reads the low 15 bits; we mirror the
246 // scalar form, storing `i32` per entry.
247 pub fn set_fog(&mut self, col: i32, max_scan_dist: i32) {
248 if max_scan_dist <= 0 {
249 self.fog_col = 0;
250 self.foglut.clear();
251 return;
252 }
253 self.fog_col = col;
254 // Pad with full-fog (32767) so OOB / past-overflow entries
255 // saturate at maximum fog.
256 self.foglut = vec![32767; 2048];
257 let step = i32::MAX / max_scan_dist;
258 let mut acc: i32 = 0;
259 for entry in self.foglut.iter_mut().take(2048) {
260 let Some(next) = acc.checked_add(step) else {
261 break;
262 };
263 // hi16 = (acc >> 16) treated as u16 then widened — for
264 // acc in [0, i32::MAX), hi16 is in [0, 32767].
265 #[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
266 let hi16 = ((acc as u32) >> 16) as i32;
267 *entry = hi16;
268 acc = next;
269 }
270 }
271
272 /// Reset cursors at the start of a new quadrant scan.
273 pub fn reset_for_quadrant(&mut self, sky_cur_dir: i32) {
274 self.gscanptr = 0;
275 self.sky_cur_lng = -1;
276 self.sky_cur_dir = sky_cur_dir;
277 // sky_off stays 0 until gline updates it; the textured-sky
278 // path checks `sky_off != 0` to decide whether to texture-
279 // fill, so resetting here keeps the first-ray-of-quadrant
280 // path honest.
281 self.sky_off = 0;
282 }
283}
284
285/// N-slot pool of [`ScanScratch`] buffers — one slot per render
286/// thread.
287///
288/// R12.0 introduced this as the host-owned ownership root for
289/// per-thread scratch. R12.1 wires it through [`crate::opticast()`]
290/// (single slot in use). R12.2 will fan slots 0..4 across the four
291/// quadrant scan loops via `rayon::join`; R12.3 will distribute
292/// slots across N row strips via `par_iter`.
293///
294/// Per-frame setters ([`Self::set_skycast`] / [`Self::set_fog`] /
295/// [`Self::set_side_shades`]) broadcast to every slot — so once
296/// R12.2 fans out, each thread already sees the current frame's
297/// fog / sky / shading state on its private slot.
298///
299/// One slot is ~7.6 MB at 640 × 480 / vsid = 2048 (see
300/// `PORTING-MULTICORE.md` § "Memory cost"). Hosts allocate one
301/// pool at startup and reuse it across frames; the rasterizer is
302/// the per-frame object that borrows the framebuffer / zbuffer.
303#[derive(Debug)]
304pub struct ScratchPool {
305 scratches: Vec<ScanScratch>,
306}
307
308impl ScratchPool {
309 /// Single-slot pool — single-threaded rendering. Equivalent to
310 /// one [`ScanScratch::new_for_size`] allocation. The R12.0
311 /// default; preserves the pre-R12 single-threaded shape.
312 #[must_use]
313 pub fn new(xres: u32, yres: u32, vsid: u32) -> Self {
314 Self::new_parallel(xres, yres, vsid, 1)
315 }
316
317 /// `n_threads`-slot pool. Each slot holds its own ~7.6 MB
318 /// `ScanScratch`. Pass the value the host wants `rayon` to
319 /// fan out across; `n_threads = 0` is treated as 1 so
320 /// [`Self::slot_mut`]`(0)` is always valid.
321 ///
322 /// R12.1 only consumes slot 0; later sub-substages
323 /// (R12.2 / R12.3) start indexing additional slots.
324 #[must_use]
325 pub fn new_parallel(xres: u32, yres: u32, vsid: u32, n_threads: usize) -> Self {
326 let n = n_threads.max(1);
327 Self {
328 scratches: (0..n)
329 .map(|_| ScanScratch::new_for_size(xres, yres, vsid))
330 .collect(),
331 }
332 }
333
334 /// Number of slots in this pool — one per render thread.
335 #[must_use]
336 pub fn n_threads(&self) -> usize {
337 self.scratches.len()
338 }
339
340 /// Read-only access to one slot.
341 ///
342 /// # Panics
343 /// If `idx >= self.n_threads()`.
344 #[must_use]
345 pub fn slot(&self, idx: usize) -> &ScanScratch {
346 &self.scratches[idx]
347 }
348
349 /// Mutable access to one slot.
350 ///
351 /// # Panics
352 /// If `idx >= self.n_threads()`.
353 #[must_use]
354 pub fn slot_mut(&mut self, idx: usize) -> &mut ScanScratch {
355 &mut self.scratches[idx]
356 }
357
358 /// Mutable iterator over slots — used by the per-frame
359 /// broadcasters below.
360 pub(crate) fn slots_mut(&mut self) -> std::slice::IterMut<'_, ScanScratch> {
361 self.scratches.iter_mut()
362 }
363
364 /// Mutable slice over all slots — the parallel-strip dispatch
365 /// in [`crate::opticast`] (R12.3.1) calls
366 /// `pool.slots_mut_slice().par_iter_mut()` to fan strips across
367 /// rayon workers. `pub(crate)` because the slot count + strip
368 /// invariants are opticast's contract, not the public API's.
369 pub(crate) fn slots_mut_slice(&mut self) -> &mut [ScanScratch] {
370 &mut self.scratches
371 }
372
373 /// Per-frame sky `(col, dist)` push, broadcast to every slot.
374 /// See [`ScanScratch::set_skycast`].
375 pub fn set_skycast(&mut self, col: i32, dist: i32) {
376 for s in self.slots_mut() {
377 s.set_skycast(col, dist);
378 }
379 }
380
381 /// Per-frame fog push, broadcast to every slot. See
382 /// [`ScanScratch::set_fog`].
383 pub fn set_fog(&mut self, col: i32, max_scan_dist: i32) {
384 for s in self.slots_mut() {
385 s.set_fog(col, max_scan_dist);
386 }
387 }
388
389 /// Per-frame side-shading push, broadcast to every slot. See
390 /// [`ScanScratch::set_side_shades`].
391 pub fn set_side_shades(&mut self, top: i8, bot: i8, left: i8, right: i8, up: i8, down: i8) {
392 for s in self.slots_mut() {
393 s.set_side_shades(top, bot, left, right, up, down);
394 }
395 }
396}
397
398/// Callback surface for the column-scan loop dispatch.
399///
400/// - `gline` is voxlap's `gline` (R4.3 = grouscan): casts a ray of
401/// `length` cells from `(x0, y0)` to `(x1, y1)` in screen space,
402/// writing hit records into `scratch.radar` starting at
403/// `scratch.gscanptr`.
404/// - `hrend` is the horizontal-scan rasterizer (`hrendzsse` etc.):
405/// given a row `sy` and column range `sx..p1`, looks up the right
406/// `angstart` entries in `scratch` and writes a band of pixels.
407/// - `vrend` is the vertical-scan rasterizer (`vrendzsse` etc.).
408///
409/// Test code can implement a recording stub that just remembers the
410/// arguments — useful for verifying the scan loops dispatch the right
411/// calls without involving any rasterization.
412//
413// Voxlap's hrend / vrend / gline take 6-8 positional arguments each.
414// Boxing them in a struct would just add noise — the names match
415// voxlap's parameter names so the trait body stays one-to-one with
416// the C source it's tracking.
417#[allow(clippy::too_many_arguments)]
418pub trait Rasterizer {
419 /// Called once per frame, before the four-quadrant scan loops
420 /// run, with the per-frame derived state. Concrete rasterizers
421 /// override this to cache whatever they need from the
422 /// projection / ray-step / prelude triple (a default-noop stub
423 /// is fine for recording / counting test rasterizers).
424 fn frame_setup(&mut self, _ctx: &crate::scan_loops::ScanContext<'_>) {}
425
426 fn gline(&mut self, scratch: &mut ScanScratch, length: u32, x0: f32, y0: f32, x1: f32, y1: f32);
427
428 fn hrend(
429 &mut self,
430 scratch: &mut ScanScratch,
431 sx: i32,
432 sy: i32,
433 p1: i32,
434 plc: i32,
435 incr: i32,
436 j: i32,
437 );
438
439 fn vrend(&mut self, scratch: &mut ScanScratch, sx: i32, sy: i32, p1: i32, iplc: i32, iinc: i32);
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 /// Minimal Rasterizer that records every call into a flat list
447 /// for the per-quadrant scan-loop tests R4.1f3+ will land.
448 #[derive(Debug, Default)]
449 struct RecordingRasterizer {
450 events: Vec<&'static str>,
451 }
452
453 impl Rasterizer for RecordingRasterizer {
454 fn gline(&mut self, _: &mut ScanScratch, _: u32, _: f32, _: f32, _: f32, _: f32) {
455 self.events.push("gline");
456 }
457 fn hrend(&mut self, _: &mut ScanScratch, _: i32, _: i32, _: i32, _: i32, _: i32, _: i32) {
458 self.events.push("hrend");
459 }
460 fn vrend(&mut self, _: &mut ScanScratch, _: i32, _: i32, _: i32, _: i32, _: i32) {
461 self.events.push("vrend");
462 }
463 }
464
465 #[test]
466 fn scratch_initial_state() {
467 let s = ScanScratch::new_for_size(640, 480, 2048);
468 assert_eq!(s.gscanptr, 0);
469 assert_eq!(s.sky_cur_lng, -1);
470 assert_eq!(s.sky_cur_dir, 0);
471 assert!(!s.radar.is_empty());
472 assert!(!s.angstart.is_empty());
473 }
474
475 #[test]
476 fn scratch_reset_for_quadrant_keeps_buffers() {
477 let mut s = ScanScratch::new_for_size(640, 480, 2048);
478 let radar_cap = s.radar.len();
479 let angstart_cap = s.angstart.len();
480 // Pretend the previous quadrant filled in some scratch.
481 s.gscanptr = 12345;
482 s.sky_cur_lng = 7;
483 s.reset_for_quadrant(-1);
484 assert_eq!(s.gscanptr, 0);
485 assert_eq!(s.sky_cur_lng, -1);
486 assert_eq!(s.sky_cur_dir, -1);
487 // Buffers are not reallocated.
488 assert_eq!(s.radar.len(), radar_cap);
489 assert_eq!(s.angstart.len(), angstart_cap);
490 }
491
492 #[test]
493 fn set_side_shades_zero_keeps_mode_off() {
494 // Voxlap5.c:2535-2540: all-zero args ⇒ sideshademode = 0 and
495 // gcsub[0]/[1] high byte zeroed. Roxlap re-stamps the whole
496 // i64 with the `0x00ff_00ff_00ff_00ff` baseline so cs[7] = 0.
497 let mut s = ScanScratch::new_for_size(64, 64, 64);
498 s.set_side_shades(0, 0, 0, 0, 0, 0);
499 assert!(!s.sideshademode);
500 assert_eq!(s.gcsub[0], 0x00ff_00ff_00ff_00ff);
501 assert_eq!(s.gcsub[1], 0x00ff_00ff_00ff_00ff);
502 }
503
504 #[test]
505 fn set_side_shades_nonzero_flips_mode_on() {
506 // Any non-zero arg ⇒ sideshademode = 1 (voxlap5.c:2540). The
507 // gline body's per-ray swap reads this flag.
508 let mut s = ScanScratch::new_for_size(64, 64, 64);
509 s.set_side_shades(15, 15, 15, 15, 15, 15);
510 assert!(s.sideshademode);
511 // Lanes 4..7 carry the per-side intensity in their high byte.
512 assert_eq!((s.gcsub[4] >> 56) & 0xff, 15);
513 assert_eq!((s.gcsub[7] >> 56) & 0xff, 15);
514 // Lanes 0/1 stay at the baseline; the per-ray swap in gline
515 // populates them from 4..7 based on gixy sign.
516 assert_eq!(s.gcsub[0], 0x00ff_00ff_00ff_00ff);
517 assert_eq!(s.gcsub[1], 0x00ff_00ff_00ff_00ff);
518 }
519
520 #[test]
521 fn set_side_shades_one_arg_nonzero_flips_mode_on() {
522 // Voxlap derives the flag from `sto|sbo|sle|sri|sup|sdo`;
523 // a single non-zero arg is enough.
524 let mut s = ScanScratch::new_for_size(64, 64, 64);
525 s.set_side_shades(0, 0, 0, 0, 0, 1);
526 assert!(s.sideshademode);
527 }
528
529 #[test]
530 fn rasterizer_trait_object_dispatch() {
531 // Confirms the trait object surface is callable — the scan
532 // loops in R4.1f3+ will hold &mut dyn Rasterizer.
533 let mut rec = RecordingRasterizer::default();
534 let mut scratch = ScanScratch::new_for_size(64, 64, 64);
535 let r: &mut dyn Rasterizer = &mut rec;
536 r.gline(&mut scratch, 4, 0.0, 0.0, 1.0, 1.0);
537 r.hrend(&mut scratch, 0, 0, 10, 0, 1, 0);
538 r.vrend(&mut scratch, 0, 0, 10, 0, 1);
539 assert_eq!(rec.events, ["gline", "hrend", "vrend"]);
540 }
541}