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}