Skip to main content

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}