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    /// S1.W: when `true`, draw phases that are about to read a voxel
140    /// at z=MAXZDIM-1 (=255, voxlap's bedrock z) bail to `AfterDelete`
141    /// instead of writing it to the radar. The ray then column-steps
142    /// past the bedrock until either an in-bounds slab fires or
143    /// `gxmax` triggers `Startsky` — which in turn fills the radar
144    /// with `skycast` (solid OR textured sky, depending on the
145    /// `SkyRef` binding).
146    ///
147    /// **Why this exists:** voxlap's `delslab` clamps every carve's
148    /// `y1` to `MAXZDIM-1`, so z=255 is ALWAYS solid post-pack
149    /// regardless of what the dense grid says. For typical voxlap
150    /// scenes that's fine — terrain reaches the bottom and the
151    /// "bedrock" voxel is hidden inside a multi-voxel slab. For
152    /// thin-floor / sparse scenes (and ANY scene viewed from outside
153    /// the XY footprint), the bedrock voxel is exposed and renders
154    /// as whatever color it carries. Without an explicit color
155    /// assignment that's `colfunc(x, y, 255) = 0` → BLACK pentagon
156    /// under the world.
157    ///
158    /// Default `false` to keep the 12 oracle hashes byte-identical;
159    /// host enables it when the scene's "below-the-world" expected
160    /// to look like sky.
161    pub treat_z_max_as_air: bool,
162}
163
164impl ScanScratch {
165    /// Allocate a scratch buffer sized for an `xres × yres`
166    /// framebuffer. Voxlap's per-frame `radar` buffer is
167    /// `MAXXDIM * 6 * 256` `castdat` entries (`voxlap5.c:206`-area
168    /// declaration). Sized as `xres * max(6*256, yres*2)` — the
169    /// `yres*2` branch activates on `HiDPI` screens where per-quadrant
170    /// radar consumption exceeds the classic `6*256` per-column budget
171    /// due to corner-cut fan expansion at steep camera angles.
172    /// `uurend` / `lastx` are sized to fit `xres` / `max(yres, vsid)`
173    /// entries respectively (R4.1f4b consumers).
174    #[must_use]
175    pub fn new_for_size(xres: u32, yres: u32, vsid: u32) -> Self {
176        // Radar holds all per-ray castdat entries for one quadrant (gscanptr
177        // resets per-quadrant). When the camera tilts down, cy >> yres makes
178        // the top/bottom fan wider than xres (corner-cut expansion), so more
179        // rays are cast and each center ray spans ~yres pixels. The original
180        // factor 6*256 = 1536 sufficed for yres ≤ 768 (classic 640×480 /
181        // 800×600); HiDPI screens (yres > 768) need proportionally more.
182        // Voxlap C side-stepped this by using a fixed MAXXDIM=2880 constant
183        // regardless of actual resolution.
184        let per_col_budget = std::cmp::max(6 * 256, (yres as usize) * 2);
185        let radar_cap = (xres as usize) * per_col_budget;
186        let angstart_cap = (xres as usize) * 4;
187        let half_stride = xres as usize;
188        let lastx_cap = std::cmp::max(yres, vsid) as usize;
189        Self {
190            radar: vec![CastDat::default(); radar_cap],
191            angstart: vec![0isize; angstart_cap],
192            gscanptr: 0,
193            sky_cur_lng: -1,
194            sky_cur_dir: 0,
195            sky_off: 0,
196            lastx: vec![0i32; lastx_cap],
197            uurend: vec![0i32; half_stride * 2],
198            uurend_half_stride: half_stride,
199            cf: vec![crate::grouscan::CfType::default(); crate::grouscan::CF_LEN],
200            gpz: [0; 2],
201            gdz: [0; 2],
202            gixy: [0; 2],
203            gxmax: 0,
204            gi0: 0,
205            gi1: 0,
206            skycast: CastDat::default(),
207            fog_col: 0,
208            foglut: Vec::new(),
209            gcsub: [0x00ff_00ff_00ff_00ff; 9],
210            sideshademode: false,
211            treat_z_max_as_air: false,
212        }
213    }
214
215    /// Engine-side setter for per-side shading intensities, mirror
216    /// of voxlap's `setsideshades(top, bot, left, right, up, down)`
217    /// (`voxlap5.c`). Each `i8` parameter is the high byte stamped
218    /// onto `gcsub[2..7]`; the low 7 bytes keep the
219    /// `0x00ff00ff00ff00ff` saturate-zero pattern. Pass `(0,…,0)` to
220    /// disable shading (the default), or moderate positive values
221    /// (15..31) for visible side darkening like voxlap's classic
222    /// games use.
223    pub fn set_side_shades(&mut self, top: i8, bot: i8, left: i8, right: i8, up: i8, down: i8) {
224        // High byte of an i64 (LE) is byte 7. Voxlap writes
225        // `((char *)&gcsub[k])[7] = sxx;` directly — bit-equivalent
226        // to reinterpreting the i8 as u8 and stamping it into the
227        // top byte. The sign-loss `as u8` is intentional (mirrors
228        // the C cast).
229        let pack = |intensity: i8| -> i64 {
230            #[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
231            let high = u64::from(intensity as u8) << 56;
232            #[allow(clippy::cast_possible_wrap)]
233            {
234                (high | 0x00ff_00ff_00ff_00ff_u64) as i64
235            }
236        };
237        self.gcsub[0] = 0x00ff_00ff_00ff_00ff;
238        self.gcsub[1] = 0x00ff_00ff_00ff_00ff;
239        self.gcsub[2] = pack(top);
240        self.gcsub[3] = pack(bot);
241        self.gcsub[4] = pack(left);
242        self.gcsub[5] = pack(right);
243        self.gcsub[6] = pack(up);
244        self.gcsub[7] = pack(down);
245        self.gcsub[8] = 0x00ff_00ff_00ff_00ff;
246        // Voxlap5.c:2535-2540: `if (!(sto|sbo|sle|sri|sup|sdo))` →
247        // sideshademode = 0; else sideshademode = 1. Any non-zero
248        // arg flips on the per-ray gcsub[0]/[1] swap in gline.
249        self.sideshademode =
250            top != 0 || bot != 0 || left != 0 || right != 0 || up != 0 || down != 0;
251    }
252
253    /// Engine-side setter for the sky `(col, dist)` pair. Engine
254    /// owns `Engine::sky_color`; this is the wire it writes to so
255    /// `grouscan`'s startsky has the right value at fill time.
256    pub fn set_skycast(&mut self, col: i32, dist: i32) {
257        self.skycast = CastDat { col, dist };
258    }
259
260    /// Engine-side setter for fog. Voxlap5.c:11151-11185.
261    /// `max_scan_dist <= 0` disables fog (clears the table).
262    /// Otherwise builds the 2048-entry fog falloff table:
263    /// `foglut[k] = (acc >> 16) & 32767` where `acc` accumulates
264    /// `step = i32::MAX / max_scan_dist` per entry. After the
265    /// accumulation overflows, remaining entries pad with `32767`
266    /// (full fog).
267    //
268    // The C version stores per-entry as a 4-lane packed `int64`
269    // (`hi16` repeated four times) for the asm's MMX path. The
270    // scalar fallback only reads the low 15 bits; we mirror the
271    // scalar form, storing `i32` per entry.
272    pub fn set_fog(&mut self, col: i32, max_scan_dist: i32) {
273        if max_scan_dist <= 0 {
274            self.fog_col = 0;
275            self.foglut.clear();
276            return;
277        }
278        self.fog_col = col;
279        // Pad with full-fog (32767) so OOB / past-overflow entries
280        // saturate at maximum fog.
281        self.foglut = vec![32767; 2048];
282        let step = i32::MAX / max_scan_dist;
283        let mut acc: i32 = 0;
284        for entry in self.foglut.iter_mut().take(2048) {
285            let Some(next) = acc.checked_add(step) else {
286                break;
287            };
288            // hi16 = (acc >> 16) treated as u16 then widened — for
289            // acc in [0, i32::MAX), hi16 is in [0, 32767].
290            #[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
291            let hi16 = ((acc as u32) >> 16) as i32;
292            *entry = hi16;
293            acc = next;
294        }
295    }
296
297    /// Reset cursors at the start of a new quadrant scan.
298    pub fn reset_for_quadrant(&mut self, sky_cur_dir: i32) {
299        self.gscanptr = 0;
300        self.sky_cur_lng = -1;
301        self.sky_cur_dir = sky_cur_dir;
302        // sky_off stays 0 until gline updates it; the textured-sky
303        // path checks `sky_off != 0` to decide whether to texture-
304        // fill, so resetting here keeps the first-ray-of-quadrant
305        // path honest.
306        self.sky_off = 0;
307    }
308}
309
310/// N-slot pool of [`ScanScratch`] buffers — one slot per render
311/// thread.
312///
313/// R12.0 introduced this as the host-owned ownership root for
314/// per-thread scratch. R12.1 wires it through [`crate::opticast()`]
315/// (single slot in use). R12.2 will fan slots 0..4 across the four
316/// quadrant scan loops via `rayon::join`; R12.3 will distribute
317/// slots across N row strips via `par_iter`.
318///
319/// Per-frame setters ([`Self::set_skycast`] / [`Self::set_fog`] /
320/// [`Self::set_side_shades`]) broadcast to every slot — so once
321/// R12.2 fans out, each thread already sees the current frame's
322/// fog / sky / shading state on its private slot.
323///
324/// One slot is ~7.6 MB at 640 × 480 / vsid = 2048 (see
325/// `PORTING-MULTICORE.md` § "Memory cost"). Hosts allocate one
326/// pool at startup and reuse it across frames; the rasterizer is
327/// the per-frame object that borrows the framebuffer / zbuffer.
328#[derive(Debug)]
329pub struct ScratchPool {
330    scratches: Vec<ScanScratch>,
331}
332
333impl ScratchPool {
334    /// Single-slot pool — single-threaded rendering. Equivalent to
335    /// one [`ScanScratch::new_for_size`] allocation. The R12.0
336    /// default; preserves the pre-R12 single-threaded shape.
337    #[must_use]
338    pub fn new(xres: u32, yres: u32, vsid: u32) -> Self {
339        Self::new_parallel(xres, yres, vsid, 1)
340    }
341
342    /// `n_threads`-slot pool. Each slot holds its own ~7.6 MB
343    /// `ScanScratch`. Pass the value the host wants `rayon` to
344    /// fan out across; `n_threads = 0` is treated as 1 so
345    /// [`Self::slot_mut`]`(0)` is always valid.
346    ///
347    /// R12.1 only consumes slot 0; later sub-substages
348    /// (R12.2 / R12.3) start indexing additional slots.
349    #[must_use]
350    pub fn new_parallel(xres: u32, yres: u32, vsid: u32, n_threads: usize) -> Self {
351        let n = n_threads.max(1);
352        Self {
353            scratches: (0..n)
354                .map(|_| ScanScratch::new_for_size(xres, yres, vsid))
355                .collect(),
356        }
357    }
358
359    /// Number of slots in this pool — one per render thread.
360    #[must_use]
361    pub fn n_threads(&self) -> usize {
362        self.scratches.len()
363    }
364
365    /// Read-only access to one slot.
366    ///
367    /// # Panics
368    /// If `idx >= self.n_threads()`.
369    #[must_use]
370    pub fn slot(&self, idx: usize) -> &ScanScratch {
371        &self.scratches[idx]
372    }
373
374    /// Mutable access to one slot.
375    ///
376    /// # Panics
377    /// If `idx >= self.n_threads()`.
378    #[must_use]
379    pub fn slot_mut(&mut self, idx: usize) -> &mut ScanScratch {
380        &mut self.scratches[idx]
381    }
382
383    /// Mutable iterator over slots — used by the per-frame
384    /// broadcasters below.
385    pub(crate) fn slots_mut(&mut self) -> std::slice::IterMut<'_, ScanScratch> {
386        self.scratches.iter_mut()
387    }
388
389    /// Mutable slice over all slots — the parallel-strip dispatch
390    /// in [`crate::opticast`] (R12.3.1) calls
391    /// `pool.slots_mut_slice().par_iter_mut()` to fan strips across
392    /// rayon workers. `pub(crate)` because the slot count + strip
393    /// invariants are opticast's contract, not the public API's.
394    pub(crate) fn slots_mut_slice(&mut self) -> &mut [ScanScratch] {
395        &mut self.scratches
396    }
397
398    /// Per-frame sky `(col, dist)` push, broadcast to every slot.
399    /// See [`ScanScratch::set_skycast`].
400    pub fn set_skycast(&mut self, col: i32, dist: i32) {
401        for s in self.slots_mut() {
402            s.set_skycast(col, dist);
403        }
404    }
405
406    /// Per-frame fog push, broadcast to every slot. See
407    /// [`ScanScratch::set_fog`].
408    pub fn set_fog(&mut self, col: i32, max_scan_dist: i32) {
409        for s in self.slots_mut() {
410            s.set_fog(col, max_scan_dist);
411        }
412    }
413
414    /// Per-frame side-shading push, broadcast to every slot. See
415    /// [`ScanScratch::set_side_shades`].
416    pub fn set_side_shades(&mut self, top: i8, bot: i8, left: i8, right: i8, up: i8, down: i8) {
417        for s in self.slots_mut() {
418            s.set_side_shades(top, bot, left, right, up, down);
419        }
420    }
421
422    /// Toggle the "treat z=MAXZDIM-1 as air" mode. See
423    /// [`ScanScratch::treat_z_max_as_air`] for the full rationale.
424    pub fn set_treat_z_max_as_air(&mut self, on: bool) {
425        for s in self.slots_mut() {
426            s.treat_z_max_as_air = on;
427        }
428    }
429}
430
431/// Callback surface for the column-scan loop dispatch.
432///
433/// - `gline` is voxlap's `gline` (R4.3 = grouscan): casts a ray of
434///   `length` cells from `(x0, y0)` to `(x1, y1)` in screen space,
435///   writing hit records into `scratch.radar` starting at
436///   `scratch.gscanptr`.
437/// - `hrend` is the horizontal-scan rasterizer (`hrendzsse` etc.):
438///   given a row `sy` and column range `sx..p1`, looks up the right
439///   `angstart` entries in `scratch` and writes a band of pixels.
440/// - `vrend` is the vertical-scan rasterizer (`vrendzsse` etc.).
441///
442/// Test code can implement a recording stub that just remembers the
443/// arguments — useful for verifying the scan loops dispatch the right
444/// calls without involving any rasterization.
445//
446// Voxlap's hrend / vrend / gline take 6-8 positional arguments each.
447// Boxing them in a struct would just add noise — the names match
448// voxlap's parameter names so the trait body stays one-to-one with
449// the C source it's tracking.
450#[allow(clippy::too_many_arguments)]
451pub trait Rasterizer {
452    /// Called once per frame, before the four-quadrant scan loops
453    /// run, with the per-frame derived state. Concrete rasterizers
454    /// override this to cache whatever they need from the
455    /// projection / ray-step / prelude triple (a default-noop stub
456    /// is fine for recording / counting test rasterizers).
457    fn frame_setup(&mut self, _ctx: &crate::scan_loops::ScanContext<'_>) {}
458
459    fn gline(&mut self, scratch: &mut ScanScratch, length: u32, x0: f32, y0: f32, x1: f32, y1: f32);
460
461    fn hrend(
462        &mut self,
463        scratch: &mut ScanScratch,
464        sx: i32,
465        sy: i32,
466        p1: i32,
467        plc: i32,
468        incr: i32,
469        j: i32,
470    );
471
472    fn vrend(&mut self, scratch: &mut ScanScratch, sx: i32, sy: i32, p1: i32, iplc: i32, iinc: i32);
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    /// Minimal Rasterizer that records every call into a flat list
480    /// for the per-quadrant scan-loop tests R4.1f3+ will land.
481    #[derive(Debug, Default)]
482    struct RecordingRasterizer {
483        events: Vec<&'static str>,
484    }
485
486    impl Rasterizer for RecordingRasterizer {
487        fn gline(&mut self, _: &mut ScanScratch, _: u32, _: f32, _: f32, _: f32, _: f32) {
488            self.events.push("gline");
489        }
490        fn hrend(&mut self, _: &mut ScanScratch, _: i32, _: i32, _: i32, _: i32, _: i32, _: i32) {
491            self.events.push("hrend");
492        }
493        fn vrend(&mut self, _: &mut ScanScratch, _: i32, _: i32, _: i32, _: i32, _: i32) {
494            self.events.push("vrend");
495        }
496    }
497
498    #[test]
499    fn scratch_initial_state() {
500        let s = ScanScratch::new_for_size(640, 480, 2048);
501        assert_eq!(s.gscanptr, 0);
502        assert_eq!(s.sky_cur_lng, -1);
503        assert_eq!(s.sky_cur_dir, 0);
504        assert!(!s.radar.is_empty());
505        assert!(!s.angstart.is_empty());
506    }
507
508    #[test]
509    fn scratch_reset_for_quadrant_keeps_buffers() {
510        let mut s = ScanScratch::new_for_size(640, 480, 2048);
511        let radar_cap = s.radar.len();
512        let angstart_cap = s.angstart.len();
513        // Pretend the previous quadrant filled in some scratch.
514        s.gscanptr = 12345;
515        s.sky_cur_lng = 7;
516        s.reset_for_quadrant(-1);
517        assert_eq!(s.gscanptr, 0);
518        assert_eq!(s.sky_cur_lng, -1);
519        assert_eq!(s.sky_cur_dir, -1);
520        // Buffers are not reallocated.
521        assert_eq!(s.radar.len(), radar_cap);
522        assert_eq!(s.angstart.len(), angstart_cap);
523    }
524
525    #[test]
526    fn set_side_shades_zero_keeps_mode_off() {
527        // Voxlap5.c:2535-2540: all-zero args ⇒ sideshademode = 0 and
528        // gcsub[0]/[1] high byte zeroed. Roxlap re-stamps the whole
529        // i64 with the `0x00ff_00ff_00ff_00ff` baseline so cs[7] = 0.
530        let mut s = ScanScratch::new_for_size(64, 64, 64);
531        s.set_side_shades(0, 0, 0, 0, 0, 0);
532        assert!(!s.sideshademode);
533        assert_eq!(s.gcsub[0], 0x00ff_00ff_00ff_00ff);
534        assert_eq!(s.gcsub[1], 0x00ff_00ff_00ff_00ff);
535    }
536
537    #[test]
538    fn set_side_shades_nonzero_flips_mode_on() {
539        // Any non-zero arg ⇒ sideshademode = 1 (voxlap5.c:2540). The
540        // gline body's per-ray swap reads this flag.
541        let mut s = ScanScratch::new_for_size(64, 64, 64);
542        s.set_side_shades(15, 15, 15, 15, 15, 15);
543        assert!(s.sideshademode);
544        // Lanes 4..7 carry the per-side intensity in their high byte.
545        assert_eq!((s.gcsub[4] >> 56) & 0xff, 15);
546        assert_eq!((s.gcsub[7] >> 56) & 0xff, 15);
547        // Lanes 0/1 stay at the baseline; the per-ray swap in gline
548        // populates them from 4..7 based on gixy sign.
549        assert_eq!(s.gcsub[0], 0x00ff_00ff_00ff_00ff);
550        assert_eq!(s.gcsub[1], 0x00ff_00ff_00ff_00ff);
551    }
552
553    #[test]
554    fn set_side_shades_one_arg_nonzero_flips_mode_on() {
555        // Voxlap derives the flag from `sto|sbo|sle|sri|sup|sdo`;
556        // a single non-zero arg is enough.
557        let mut s = ScanScratch::new_for_size(64, 64, 64);
558        s.set_side_shades(0, 0, 0, 0, 0, 1);
559        assert!(s.sideshademode);
560    }
561
562    #[test]
563    fn rasterizer_trait_object_dispatch() {
564        // Confirms the trait object surface is callable — the scan
565        // loops in R4.1f3+ will hold &mut dyn Rasterizer.
566        let mut rec = RecordingRasterizer::default();
567        let mut scratch = ScanScratch::new_for_size(64, 64, 64);
568        let r: &mut dyn Rasterizer = &mut rec;
569        r.gline(&mut scratch, 4, 0.0, 0.0, 1.0, 1.0);
570        r.hrend(&mut scratch, 0, 0, 10, 0, 1, 0);
571        r.vrend(&mut scratch, 0, 0, 10, 0, 1);
572        assert_eq!(rec.events, ["gline", "hrend", "vrend"]);
573    }
574}