Skip to main content

pixel8_runtime/
fb.rs

1//! The 128x128 indexed-color framebuffer and software drawing primitives.
2//!
3//! Everything Pixel8 puts on screen — running carts, the console, every
4//! editor — is drawn through this one software rasterizer into a buffer of
5//! palette indices. The GPU's only job is to scale the result up with
6//! nearest-neighbor filtering.
7
8use crate::{
9    assets::{MapData, SpriteSheet, SPRITES_PER_ROW, SPRITE_COUNT, SPRITE_SIZE},
10    font, palette,
11};
12
13/// Virtual screen width in pixels.
14pub const WIDTH: i32 = 128;
15/// Virtual screen height in pixels.
16pub const HEIGHT: i32 = 128;
17
18/// Identity color map: index `i` maps to color `i`.
19const IDENTITY_PALETTE: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
20/// Default transparency mask: only color 0 is transparent.
21const DEFAULT_TRANSPARENT: u16 = 0x0001;
22
23/// The virtual screen: one byte per pixel, each a palette index in `0..16`.
24pub struct Framebuffer {
25    pixels: Vec<u8>,
26    camera_x: i32,
27    camera_y: i32,
28    clip: (i32, i32, i32, i32),
29    draw_pal: [u8; 16],
30    display_pal: [u8; 16],
31    transparent: u16,
32    fill_pattern: u16,
33    fill_secondary: u8,
34    fill_transparent: bool,
35    pen_color: u8,
36    cursor_x: i32,
37    cursor_y: i32,
38}
39
40impl Default for Framebuffer {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl Framebuffer {
47    pub fn new() -> Self {
48        Self {
49            pixels: vec![0; (WIDTH * HEIGHT) as usize],
50            camera_x: 0,
51            camera_y: 0,
52            clip: (0, 0, WIDTH, HEIGHT),
53            draw_pal: IDENTITY_PALETTE,
54            display_pal: IDENTITY_PALETTE,
55            transparent: DEFAULT_TRANSPARENT,
56            fill_pattern: 0,
57            fill_secondary: 0,
58            fill_transparent: false,
59            pen_color: 6,
60            cursor_x: 0,
61            cursor_y: 0,
62        }
63    }
64
65    /// Raw palette-index pixels, row-major, `WIDTH * HEIGHT` long.
66    pub fn pixels(&self) -> &[u8] {
67        &self.pixels
68    }
69
70    /// The display palette: at present time, stored index `i` is shown as color
71    /// `display_palette()[i]`. Presenters apply this when expanding the indexed
72    /// framebuffer to RGB, exactly as `write_rgba` does for GPU upload.
73    pub fn display_palette(&self) -> &[u8; 16] {
74        &self.display_pal
75    }
76
77    /// Expand the indexed framebuffer into an RGBA8 buffer for GPU upload.
78    pub fn write_rgba(&self, out: &mut [u8]) {
79        // Fold the display palette into a 16-entry RGBA lookup table once, so
80        // the per-pixel loop is a plain table read plus a fixed-size copy. This
81        // drops the per-pixel `display_pal` + `rgba` work and the range-index
82        // bounds check, and autovectorizes cleanly.
83        let mut lut = [[0u8; 4]; 16];
84        for (i, entry) in lut.iter_mut().enumerate() {
85            *entry = palette::rgba(self.display_pal[i]);
86        }
87        let (chunks, _) = out.as_chunks_mut::<4>();
88        for (chunk, &c) in chunks.iter_mut().zip(self.pixels.iter()) {
89            *chunk = lut[(c & 0x0f) as usize];
90        }
91    }
92
93    /// Set the camera offset applied to all subsequent draw operations.
94    pub fn camera(&mut self, x: i32, y: i32) {
95        self.camera_x = x;
96        self.camera_y = y;
97    }
98
99    /// Restrict drawing to a screen-space rectangle.
100    pub fn clip(&mut self, x: i32, y: i32, w: i32, h: i32) {
101        let x0 = x.clamp(0, WIDTH);
102        let y0 = y.clamp(0, HEIGHT);
103        let x1 = (x + w).clamp(0, WIDTH);
104        let y1 = (y + h).clamp(0, HEIGHT);
105        self.clip = (x0, y0, x1, y1);
106    }
107
108    /// Remove the clip rectangle.
109    pub fn clip_reset(&mut self) {
110        self.clip = (0, 0, WIDTH, HEIGHT);
111    }
112
113    /// Reset camera and clip to defaults (used between host UI and cart frames).
114    pub fn reset_state(&mut self) {
115        self.camera_x = 0;
116        self.camera_y = 0;
117        self.clip_reset();
118        self.draw_pal = IDENTITY_PALETTE;
119        self.display_pal = IDENTITY_PALETTE;
120        self.transparent = DEFAULT_TRANSPARENT;
121        self.fill_pattern = 0;
122        self.fill_secondary = 0;
123        self.fill_transparent = false;
124        self.pen_color = 6;
125        self.cursor_x = 0;
126        self.cursor_y = 0;
127    }
128
129    /// Make a palette color transparent (or opaque) for sprite draws.
130    pub fn set_transparent_color(&mut self, color: u8, transparent: bool) {
131        let bit = 1u16 << (color & 0x0f);
132        if transparent {
133            self.transparent |= bit;
134        } else {
135            self.transparent &= !bit;
136        }
137    }
138
139    /// Reset transparency to the default (only color 0 transparent).
140    pub fn reset_transparency(&mut self) {
141        self.transparent = DEFAULT_TRANSPARENT;
142    }
143
144    /// Remap a draw-palette color: later draws of `from` are written as `to`.
145    pub fn remap_color(&mut self, from: u8, to: u8) {
146        self.draw_pal[(from & 0x0f) as usize] = to & 0x0f;
147    }
148
149    /// Remap a display-palette color: `from` is shown as `to` at upload time.
150    pub fn remap_display_color(&mut self, from: u8, to: u8) {
151        self.display_pal[(from & 0x0f) as usize] = to & 0x0f;
152    }
153
154    /// Reset both the draw and display palettes to identity.
155    pub fn reset_palette(&mut self) {
156        self.draw_pal = IDENTITY_PALETTE;
157        self.display_pal = IDENTITY_PALETTE;
158    }
159
160    /// Configure the fill pattern for the filled shape primitives. `pattern` is
161    /// a 4x4 bitmask (bit 15 = top-left). Pattern-0 pixels take the shape's
162    /// color; pattern-1 pixels take `secondary`, or are skipped when
163    /// `transparent`. A `pattern` of 0 fills solid.
164    pub fn set_fill_pattern(&mut self, pattern: u16, secondary: u8, transparent: bool) {
165        self.fill_pattern = pattern;
166        self.fill_secondary = secondary & 0x0f;
167        self.fill_transparent = transparent;
168    }
169
170    /// The color a fill should write at framebuffer pixel `(x, y)`, or `None`
171    /// when the transparent pattern skips it. `x`/`y` are post-camera.
172    fn fill_color_at(&self, x: i32, y: i32, primary: u8) -> Option<u8> {
173        if self.fill_pattern == 0 {
174            return Some(primary);
175        }
176        let idx = ((y & 3) * 4 + (x & 3)) as u16;
177        if (self.fill_pattern >> (15 - idx)) & 1 == 0 {
178            Some(primary)
179        } else if self.fill_transparent {
180            None
181        } else {
182            Some(self.fill_secondary)
183        }
184    }
185
186    /// Like `raw_pset` but honoring the fill pattern. `x`/`y` are post-camera.
187    fn raw_pset_fill(&mut self, x: i32, y: i32, primary: u8) {
188        if let Some(c) = self.fill_color_at(x, y, primary) {
189            self.raw_pset(x, y, c);
190        }
191    }
192
193    /// Fill a solid horizontal run on row `y` from `x0..=x1` (inclusive,
194    /// POST-camera), clipped to the clip rect. Applies the draw palette. Used by
195    /// the solid (non-patterned) fill path: clipping the span once and writing
196    /// it as one memset is far cheaper than clipping every pixel.
197    fn fill_span(&mut self, x0: i32, x1: i32, y: i32, color: u8) {
198        let (cx0, cy0, cx1, cy1) = self.clip;
199        if y < cy0 || y >= cy1 {
200            return;
201        }
202        let xa = x0.max(cx0);
203        let xb = x1.min(cx1 - 1);
204        if xa > xb {
205            return;
206        }
207        let c = self.draw_pal[(color & 0x0f) as usize] & 0x0f;
208        let start = (y * WIDTH + xa) as usize;
209        let end = (y * WIDTH + xb + 1) as usize;
210        self.pixels[start..end].fill(c);
211    }
212
213    /// `raw_pset` for a point whose post-camera coordinates need not fit an i32.
214    /// Line and circle walks are driven by cart-supplied geometry, which can put a
215    /// plot billions of pixels off screen; anything the clip rect rejects is dropped
216    /// before the narrowing cast.
217    fn plot_far(&mut self, x: i64, y: i64, color: u8) {
218        let (cx0, cy0, cx1, cy1) = self.clip;
219        if x >= i64::from(cx0) && x < i64::from(cx1) && y >= i64::from(cy0) && y < i64::from(cy1) {
220            self.raw_pset(x as i32, y as i32, color);
221        }
222    }
223
224    /// `fill_span` for a run whose ends need not fit an i32, the span counterpart of
225    /// `plot_far`. Returns the clipped run for the patterned path to walk.
226    fn clipped_span_far(&self, x0: i64, x1: i64, y: i64) -> Option<(i32, i32, i32)> {
227        let (cx0, cy0, cx1, cy1) = self.clip;
228        if y < i64::from(cy0) || y >= i64::from(cy1) {
229            return None;
230        }
231        let xa = x0.max(i64::from(cx0));
232        let xb = x1.min(i64::from(cx1) - 1);
233        if xa > xb {
234            return None;
235        }
236        Some((xa as i32, xb as i32, y as i32))
237    }
238
239    /// Intersect an inclusive, PRE-camera row span with the clip rect. An empty
240    /// result comes back as `lo > hi`, which every `lo..=hi` loop treats as empty.
241    /// Shape primitives take their extents from the cart, so trimming the sweep up
242    /// front is what keeps one host call from walking billions of no-op rows.
243    fn clipped_rows(&self, ya: i32, yb: i32) -> (i32, i32) {
244        let (_, cy0, _, cy1) = self.clip;
245        (
246            ya.max(cy0.saturating_add(self.camera_y)),
247            yb.min((cy1 - 1).saturating_add(self.camera_y)),
248        )
249    }
250
251    /// Intersect an inclusive, PRE-camera column span with the clip rect, the
252    /// column counterpart of `clipped_rows`.
253    fn clipped_cols(&self, xa: i32, xb: i32) -> (i32, i32) {
254        let (cx0, _, cx1, _) = self.clip;
255        (
256            xa.max(cx0.saturating_add(self.camera_x)),
257            xb.min((cx1 - 1).saturating_add(self.camera_x)),
258        )
259    }
260
261    /// Fill the whole screen with a color. Does not touch camera/clip.
262    pub fn cls(&mut self, color: u8) {
263        self.pixels.fill(color & 0x0f);
264    }
265
266    #[inline]
267    fn raw_pset(&mut self, x: i32, y: i32, color: u8) {
268        let (cx0, cy0, cx1, cy1) = self.clip;
269        if x >= cx0 && x < cx1 && y >= cy0 && y < cy1 {
270            let c = self.draw_pal[(color & 0x0f) as usize] & 0x0f;
271            self.pixels[(y * WIDTH + x) as usize] = c;
272        }
273    }
274
275    /// Set one pixel (camera-relative, like all draw ops).
276    pub fn pset(&mut self, x: i32, y: i32, color: u8) {
277        self.raw_pset(x - self.camera_x, y - self.camera_y, color);
278    }
279
280    /// Read one pixel in screen space. Out-of-bounds reads return 0.
281    pub fn pget(&self, x: i32, y: i32) -> u8 {
282        if (0..WIDTH).contains(&x) && (0..HEIGHT).contains(&y) {
283            self.pixels[(y * WIDTH + x) as usize]
284        } else {
285            0
286        }
287    }
288
289    /// Bresenham line between two points, inclusive.
290    pub fn line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: u8) {
291        // Both endpoints come from the cart, so their difference need not fit an i32.
292        let (ax, ay) = (
293            i64::from(x0) - i64::from(self.camera_x),
294            i64::from(y0) - i64::from(self.camera_y),
295        );
296        let (bx, by) = (
297            i64::from(x1) - i64::from(self.camera_x),
298            i64::from(y1) - i64::from(self.camera_y),
299        );
300        let (dx, dy) = ((bx - ax).abs(), (by - ay).abs());
301        let (sx, sy) = (if ax < bx { 1 } else { -1 }, if ay < by { 1 } else { -1 });
302        // Bresenham advances the major axis once per step and the minor axis
303        // `(2 * minor * k + major) / (2 * major)` times over the first `k` of them —
304        // the closed form of the error recurrence, so this plots exactly the pixels
305        // stepping the recurrence would. Because the major axis moves one pixel per
306        // step, solving it against the clip rect turns a walk the cart sized into at
307        // most a screen's width of steps, which is what stops one host call from
308        // spending billions of iterations off screen.
309        let steps = dx.max(dy);
310        let x_major = dx >= dy;
311        let (cx0, cy0, cx1, cy1) = self.clip;
312        let (start, step, lo, hi) = if x_major {
313            (ax, sx, i64::from(cx0), i64::from(cx1) - 1)
314        } else {
315            (ay, sy, i64::from(cy0), i64::from(cy1) - 1)
316        };
317        let (k_lo, k_hi) = if step > 0 {
318            (lo - start, hi - start)
319        } else {
320            (start - hi, start - lo)
321        };
322        let (major, minor) = if x_major { (dx, dy) } else { (dy, dx) };
323        for k in k_lo.max(0)..=k_hi.min(steps) {
324            // `2 * minor * k` needs the wider type: both factors can approach 2^33.
325            let m = if major == 0 {
326                0
327            } else {
328                ((2 * i128::from(minor) * i128::from(k) + i128::from(major))
329                    / (2 * i128::from(major))) as i64
330            };
331            let (px, py) = if x_major {
332                (ax + sx * k, ay + sy * m)
333            } else {
334                (ax + sx * m, ay + sy * k)
335            };
336            self.plot_far(px, py, color);
337        }
338    }
339
340    /// Rectangle outline with inclusive corners, like PICO-8's `rect`.
341    pub fn rect(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: u8) {
342        let (xa, xb) = (x0.min(x1), x0.max(x1));
343        let (ya, yb) = (y0.min(y1), y0.max(y1));
344        self.line(xa, ya, xb, ya, color);
345        self.line(xa, yb, xb, yb, color);
346        self.line(xa, ya, xa, yb, color);
347        self.line(xb, ya, xb, yb, color);
348    }
349
350    /// Filled rectangle with inclusive corners.
351    pub fn rectfill(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: u8) {
352        let (xa, xb) = (x0.min(x1), x0.max(x1));
353        let (ya, yb) = (y0.min(y1), y0.max(y1));
354        // The corners come from the cart, so trim the sweep to the rows (and, for the
355        // patterned path, the columns) that the clip rect can accept. Every skipped
356        // iteration would have been a no-op, and the fill pattern keys off the pixel
357        // coordinate rather than the loop index, so the result is untouched.
358        let (ra, rb) = self.clipped_rows(ya, yb);
359        if self.fill_pattern == 0 {
360            // Solid fill: each row is one clipped memset (xa/xb are pre-camera).
361            for y in ra..=rb {
362                self.fill_span(
363                    xa - self.camera_x,
364                    xb - self.camera_x,
365                    y - self.camera_y,
366                    color,
367                );
368            }
369        } else {
370            let (ca, cb) = self.clipped_cols(xa, xb);
371            for y in ra..=rb {
372                for x in ca..=cb {
373                    self.raw_pset_fill(x - self.camera_x, y - self.camera_y, color);
374                }
375            }
376        }
377    }
378
379    /// Circle outline (midpoint algorithm).
380    pub fn circ(&mut self, cx: i32, cy: i32, r: i32, color: u8) {
381        self.circle_impl(cx, cy, r.max(0), color, false);
382    }
383
384    /// Filled circle.
385    pub fn circfill(&mut self, cx: i32, cy: i32, r: i32, color: u8) {
386        self.circle_impl(cx, cy, r.max(0), color, true);
387    }
388
389    fn circle_impl(&mut self, cx: i32, cy: i32, r: i32, color: u8, fill: bool) {
390        let (cx, cy) = (
391            i64::from(cx) - i64::from(self.camera_x),
392            i64::from(cy) - i64::from(self.camera_y),
393        );
394        let r = i64::from(r);
395        let (cx0, cy0, cx1, cy1) = self.clip;
396        let (cx0, cy0, cx1, cy1) = (
397            i64::from(cx0),
398            i64::from(cy0),
399            i64::from(cx1) - 1,
400            i64::from(cy1) - 1,
401        );
402        // The radius is the cart's to choose, so drop a circle that cannot reach the
403        // clip rect before walking an arc proportional to it.
404        if cx + r < cx0 || cx - r > cx1 || cy + r < cy0 || cy - r > cy1 {
405            return;
406        }
407
408        // Even a circle that does reach the clip rect walks `y` from 0 to about
409        // `r / sqrt(2)`, which for a cart-sized radius is billions of steps for the
410        // single fuel unit the host call costs. Almost all of them plot nothing: the
411        // walk touches the screen only where `cy ± y` (the shallow octants) or
412        // `cy ± x` (the steep ones, swapped through `cx ± y`) can land inside the
413        // clip rect, which is a handful of short runs of `y`. Collect those runs and
414        // restart the walk on each, seeded by `arc_x` — the closed form of the same
415        // recurrence — so the pixels plotted are exactly the ones a full walk would
416        // have plotted.
417        // Shallow octants: rows `cy ± y`, so `y` must land within the clip rows.
418        let shallow = [(cy0 - cy, cy1 - cy), (cy - cy1, cy - cy0)];
419        let steep = if fill {
420            // Filled, the steep octants become spans on rows `cy ± x`, so it is `x`
421            // that must land within the clip rows; `arc_x` is non-increasing, so each
422            // range of rows maps back to one range of `y`.
423            [
424                arc_rows_to_y(r, cy0 - cy, cy1 - cy),
425                arc_rows_to_y(r, cy - cy1, cy - cy0),
426            ]
427        } else {
428            // As an outline the steep octants plot at columns `cx ± y`, so `y` itself
429            // must land within the clip columns.
430            [(cx0 - cx, cx1 - cx), (cx - cx1, cx - cx0)]
431        };
432        for (run_lo, run_hi) in merge_runs(shallow, steep, r) {
433            let mut y = run_lo;
434            let mut x = arc_x(r, y);
435            // The walk's error term is a function of its state: keeping the identity
436            // here lets a run start anywhere along the arc.
437            let mut err = x * x - r * r + y * y + 2 * y - x + 1;
438            while x >= y && y <= run_hi {
439                if fill && self.fill_pattern == 0 {
440                    // Solid fill: each scanline of the disc is one clipped memset.
441                    self.circle_span(cx - x, cx + x, cy + y, color);
442                    self.circle_span(cx - x, cx + x, cy - y, color);
443                    self.circle_span(cx - y, cx + y, cy + x, color);
444                    self.circle_span(cx - y, cx + y, cy - x, color);
445                } else if fill {
446                    self.circle_run(cx - x, cx + x, cy + y, color);
447                    self.circle_run(cx - x, cx + x, cy - y, color);
448                    self.circle_run(cx - y, cx + y, cy + x, color);
449                    self.circle_run(cx - y, cx + y, cy - x, color);
450                } else {
451                    for (px, py) in [
452                        (cx + x, cy + y),
453                        (cx - x, cy + y),
454                        (cx + x, cy - y),
455                        (cx - x, cy - y),
456                        (cx + y, cy + x),
457                        (cx - y, cy + x),
458                        (cx + y, cy - x),
459                        (cx - y, cy - x),
460                    ] {
461                        self.plot_far(px, py, color);
462                    }
463                }
464                y += 1;
465                if err < 0 {
466                    err += 2 * y + 1;
467                } else {
468                    x -= 1;
469                    err += 2 * (y - x) + 1;
470                }
471            }
472        }
473    }
474
475    /// One solid scanline of a circle, in the wider coordinates the walk uses.
476    fn circle_span(&mut self, x0: i64, x1: i64, y: i64, color: u8) {
477        if let Some((xa, xb, y)) = self.clipped_span_far(x0, x1, y) {
478            self.fill_span(xa, xb, y, color);
479        }
480    }
481
482    /// One patterned scanline of a circle. The pattern keys off the pixel
483    /// coordinate, so clipping the run up front leaves the result untouched.
484    fn circle_run(&mut self, x0: i64, x1: i64, y: i64, color: u8) {
485        let Some((xa, xb, y)) = self.clipped_span_far(x0, x1, y) else {
486            return;
487        };
488        for x in xa..=xb {
489            self.raw_pset_fill(x, y, color);
490        }
491    }
492
493    /// Ellipse outline within the inclusive bounding box `(x0,y0)-(x1,y1)`.
494    pub fn oval(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: u8) {
495        self.oval_impl(x0, y0, x1, y1, color, false);
496    }
497
498    /// Filled ellipse within the inclusive bounding box `(x0,y0)-(x1,y1)`.
499    pub fn ovalfill(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: u8) {
500        self.oval_impl(x0, y0, x1, y1, color, true);
501    }
502
503    fn oval_impl(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: u8, fill: bool) {
504        let (xa, xb) = (x0.min(x1), x0.max(x1));
505        let (ya, yb) = (y0.min(y1), y0.max(y1));
506        let cx = (xa + xb) as f32 / 2.0;
507        let cy = (ya + yb) as f32 / 2.0;
508        let a = (xb - xa) as f32 / 2.0;
509        let b = (yb - ya) as f32 / 2.0;
510        // The bounding box is the cart's, so sweep only the rows and columns the clip
511        // rect can accept. The ellipse's center and radii still come from the FULL
512        // box, so every row keeps the extent it had before.
513        let (ra, rb) = self.clipped_rows(ya, yb);
514        if fill {
515            for y in ra..=rb {
516                let dy = if b > 0.0 { (y as f32 - cy) / b } else { 0.0 };
517                let s = 1.0 - dy * dy;
518                if s < 0.0 {
519                    continue;
520                }
521                let dx = a * s.sqrt();
522                let left = (cx - dx).round() as i32;
523                let right = (cx + dx).round() as i32;
524                if self.fill_pattern == 0 {
525                    // Solid fill: one clipped memset per scanline of the oval.
526                    self.fill_span(
527                        left - self.camera_x,
528                        right - self.camera_x,
529                        y - self.camera_y,
530                        color,
531                    );
532                } else {
533                    let (ca, cb) = self.clipped_cols(left, right);
534                    for x in ca..=cb {
535                        self.raw_pset_fill(x - self.camera_x, y - self.camera_y, color);
536                    }
537                }
538            }
539        } else {
540            // Plot the extremes along each axis so the outline has no gaps.
541            for y in ra..=rb {
542                let dy = if b > 0.0 { (y as f32 - cy) / b } else { 0.0 };
543                let s = 1.0 - dy * dy;
544                if s < 0.0 {
545                    continue;
546                }
547                let dx = a * s.sqrt();
548                self.pset((cx - dx).round() as i32, y, color);
549                self.pset((cx + dx).round() as i32, y, color);
550            }
551            let (ca, cb) = self.clipped_cols(xa, xb);
552            for x in ca..=cb {
553                let dx = if a > 0.0 { (x as f32 - cx) / a } else { 0.0 };
554                let s = 1.0 - dx * dx;
555                if s < 0.0 {
556                    continue;
557                }
558                let dy = b * s.sqrt();
559                self.pset(x, (cy - dy).round() as i32, color);
560                self.pset(x, (cy + dy).round() as i32, color);
561            }
562        }
563    }
564
565    /// Print text with the built-in font. Returns the x position after the
566    /// last character.
567    pub fn print(&mut self, text: &str, x: i32, y: i32, color: u8) -> i32 {
568        let mut cx = x;
569        let mut cy = y;
570        for ch in text.chars() {
571            if ch == '\n' {
572                cx = x;
573                cy += font::GLYPH_H;
574                continue;
575            }
576            let rows = font::glyph(ch);
577            for (ry, row) in rows.iter().enumerate() {
578                for rx in 0..3 {
579                    if row & (0b100 >> rx) != 0 {
580                        self.pset(cx + rx, cy + ry as i32, color);
581                    }
582                }
583            }
584            cx += font::GLYPH_W;
585        }
586        cx
587    }
588
589    /// Set the persistent pen color used by `print_pen`.
590    pub fn set_pen_color(&mut self, color: u8) {
591        self.pen_color = color & 0x0f;
592    }
593
594    /// Set the persistent text cursor used by `print_pen`.
595    pub fn set_cursor(&mut self, x: i32, y: i32) {
596        self.cursor_x = x;
597        self.cursor_y = y;
598    }
599
600    /// Print at the cursor in the pen color, then advance the cursor one line
601    /// down. Returns the x position after the last glyph.
602    pub fn print_pen(&mut self, text: &str) -> i32 {
603        let (x, y) = (self.cursor_x, self.cursor_y);
604        let end = self.print(text, x, y, self.pen_color);
605        self.cursor_y = y + font::GLYPH_H;
606        end
607    }
608
609    /// Draw sprite `n` (and the `w x h`-pixel block to its right and below)
610    /// from a sheet. Color 0 is transparent, matching the classic default.
611    /// `w`/`h` are pixel extents: `w = 4` draws a 4-pixel-wide slice.
612    #[allow(clippy::too_many_arguments)]
613    pub fn spr(
614        &mut self,
615        sheet: &SpriteSheet,
616        n: u32,
617        x: i32,
618        y: i32,
619        w: i32,
620        h: i32,
621        flip_x: bool,
622        flip_y: bool,
623    ) {
624        // `w`/`h` are pixel extents; a partial last cell is clipped mid-sprite.
625        let pw = w.max(0);
626        let ph = h.max(0);
627
628        // Clip the destination rectangle to the clip rect once, up front, then
629        // walk only the visible sub-rectangle. This skips the per-pixel clip
630        // test entirely and fast-rejects fully off-screen sprites (a big win
631        // for `map`, which calls `spr` once per tile). The destination spans
632        // `[dx0, dx0 + pw)` x `[dy0, dy0 + ph)` in post-camera space; the `px`
633        // range is where that lands inside `[cx0, cx1)`.
634        let (dx0, dy0) = (x - self.camera_x, y - self.camera_y);
635        let (cx0, cy0, cx1, cy1) = self.clip;
636        let px_lo = (cx0 - dx0).max(0);
637        let px_hi = (cx1 - dx0).min(pw);
638        let py_lo = (cy0 - dy0).max(0);
639        let py_hi = (cy1 - dy0).min(ph);
640        if px_lo >= px_hi || py_lo >= py_hi {
641            return;
642        }
643
644        // Decode the sprite's sheet origin once instead of per pixel. The flip
645        // still mirrors about the FULL sprite extent (`pw`/`ph`), and source
646        // reads may run past 8 into neighboring sprites for multi-sprite draws,
647        // exactly as `sprite_pixel` does.
648        let n = (n as usize) % SPRITE_COUNT;
649        let base_sx = (n % SPRITES_PER_ROW * SPRITE_SIZE) as i32;
650        let base_sy = (n / SPRITES_PER_ROW * SPRITE_SIZE) as i32;
651        for py in py_lo..py_hi {
652            let sy = if flip_y { ph - 1 - py } else { py };
653            for px in px_lo..px_hi {
654                let sx = if flip_x { pw - 1 - px } else { px };
655                let c = sheet.get(base_sx + sx, base_sy + sy);
656                if ((self.transparent >> c) & 1) == 0 {
657                    // In bounds by construction: `px`/`py` lie within the clip.
658                    let px_dst = dx0 + px;
659                    let py_dst = dy0 + py;
660                    self.pixels[(py_dst * WIDTH + px_dst) as usize] =
661                        self.draw_pal[(c & 0x0f) as usize] & 0x0f;
662                }
663            }
664        }
665    }
666
667    /// Draw a sheet rectangle `(sx,sy,sw,sh)` stretched into a screen rectangle
668    /// `(dx,dy,dw,dh)` with nearest-neighbor sampling. Honors per-color
669    /// transparency and the draw palette.
670    #[allow(clippy::too_many_arguments)]
671    pub fn sspr(
672        &mut self,
673        sheet: &SpriteSheet,
674        sx: i32,
675        sy: i32,
676        sw: i32,
677        sh: i32,
678        dx: i32,
679        dy: i32,
680        dw: i32,
681        dh: i32,
682        flip_x: bool,
683        flip_y: bool,
684    ) {
685        if sw <= 0 || sh <= 0 || dw <= 0 || dh <= 0 {
686            return;
687        }
688
689        // The destination extent is whatever the cart asked for, and one host call
690        // costs it one unit of fuel however big that is — so walk only the part that
691        // can survive the clip rect. `px`/`py` keep their original destination-space
692        // values and the sampling below still divides by the FULL `dw`/`dh`, so
693        // narrowing the range cannot shift which source pixel a column samples.
694        let (dx0, dy0) = (
695            i64::from(dx) - i64::from(self.camera_x),
696            i64::from(dy) - i64::from(self.camera_y),
697        );
698        let (cx0, cy0, cx1, cy1) = self.clip;
699        let px_lo = (i64::from(cx0) - dx0).clamp(0, i64::from(dw)) as i32;
700        let px_hi = (i64::from(cx1) - dx0).clamp(0, i64::from(dw)) as i32;
701        let py_lo = (i64::from(cy0) - dy0).clamp(0, i64::from(dh)) as i32;
702        let py_hi = (i64::from(cy1) - dy0).clamp(0, i64::from(dh)) as i32;
703        if px_lo >= px_hi || py_lo >= py_hi {
704            return;
705        }
706
707        for py in py_lo..py_hi {
708            let fy = if flip_y { dh - 1 - py } else { py };
709            // `f * s / d` is in `0..s` and so always fits, but the product alone
710            // overflows an i32 once the cart asks for a huge destination.
711            let src_y = sy.saturating_add((i64::from(fy) * i64::from(sh) / i64::from(dh)) as i32);
712            let row = (dy0 + i64::from(py)) as usize * WIDTH as usize;
713            for px in px_lo..px_hi {
714                let fx = if flip_x { dw - 1 - px } else { px };
715                let src_x =
716                    sx.saturating_add((i64::from(fx) * i64::from(sw) / i64::from(dw)) as i32);
717                let c = sheet.get(src_x, src_y);
718                if ((self.transparent >> c) & 1) == 0 {
719                    // In bounds by construction: the loop range is the clip rect.
720                    let col = (dx0 + i64::from(px)) as usize;
721                    self.pixels[row + col] = self.draw_pal[(c & 0x0f) as usize] & 0x0f;
722                }
723            }
724        }
725    }
726
727    /// Draw a region of the tile map. `layers` is a flag mask: when nonzero,
728    /// only tiles whose flags intersect the mask are drawn. Tile 0 is empty.
729    #[allow(clippy::too_many_arguments)]
730    pub fn map(
731        &mut self,
732        map: &MapData,
733        sheet: &SpriteSheet,
734        cel_x: i32,
735        cel_y: i32,
736        sx: i32,
737        sy: i32,
738        cel_w: i32,
739        cel_h: i32,
740        layers: u8,
741    ) {
742        // Every cel is a `spr` call, so an unclipped `cel_w`/`cel_h` buys millions of
743        // them for the single fuel unit the host call costs. Narrow the cel range to
744        // those whose 8x8 destination can overlap the clip rect; `tx`/`ty` keep their
745        // original values, so both the map lookup and the placement are unchanged.
746        let (cel_w, cel_h) = (i64::from(cel_w.max(0)), i64::from(cel_h.max(0)));
747        // The side of one cel in pixels, as against `cel_w`/`cel_h`, which count cels.
748        let cel_px = SPRITE_SIZE as i64;
749        let (cx0, cy0, cx1, cy1) = self.clip;
750        let base_x = i64::from(sx) - i64::from(self.camera_x);
751        let base_y = i64::from(sy) - i64::from(self.camera_y);
752        // Cel `t` spans `[base + cel_px * t, base + cel_px * t + cel_px)`, so it is
753        // visible for `t` from `floor((c0 - base) / cel_px)` to
754        // `ceil((c1 - base) / cel_px)`.
755        let tx_lo = (i64::from(cx0) - base_x).div_euclid(cel_px).clamp(0, cel_w) as i32;
756        let tx_hi = (i64::from(cx1) - base_x + cel_px - 1)
757            .div_euclid(cel_px)
758            .clamp(0, cel_w) as i32;
759        let ty_lo = (i64::from(cy0) - base_y).div_euclid(cel_px).clamp(0, cel_h) as i32;
760        let ty_hi = (i64::from(cy1) - base_y + cel_px - 1)
761            .div_euclid(cel_px)
762            .clamp(0, cel_h) as i32;
763
764        for ty in ty_lo..ty_hi {
765            for tx in tx_lo..tx_hi {
766                let tile = map.get(cel_x + tx, cel_y + ty);
767                if tile == 0 {
768                    continue;
769                }
770                if layers != 0 && sheet.flags(tile as u32) & layers == 0 {
771                    continue;
772                }
773                self.spr(
774                    sheet,
775                    tile as u32,
776                    sx + tx * SPRITE_SIZE as i32,
777                    sy + ty * SPRITE_SIZE as i32,
778                    SPRITE_SIZE as i32,
779                    SPRITE_SIZE as i32,
780                    false,
781                    false,
782                );
783            }
784        }
785    }
786}
787
788/// Clamp four candidate `y` runs to `0..=cap`, drop the empty ones and merge the
789/// overlaps, so the circle walk visits each `y` at most once. An unused slot comes
790/// back as `(0, -1)`, which the walk's `y <= run_hi` guard skips.
791fn merge_runs(shallow: [(i64, i64); 2], steep: [(i64, i64); 2], cap: i64) -> [(i64, i64); 4] {
792    let mut runs = [(0i64, -1i64); 4];
793    let mut n = 0;
794    for (lo, hi) in shallow.into_iter().chain(steep) {
795        let (lo, hi) = (lo.max(0), hi.min(cap));
796        if lo <= hi {
797            runs[n] = (lo, hi);
798            n += 1;
799        }
800    }
801    runs[..n].sort_unstable();
802    let mut merged = [(0i64, -1i64); 4];
803    let mut m = 0;
804    for &(lo, hi) in &runs[..n] {
805        if m > 0 && lo <= merged[m - 1].1 + 1 {
806            merged[m - 1].1 = merged[m - 1].1.max(hi);
807        } else {
808            merged[m] = (lo, hi);
809            m += 1;
810        }
811    }
812    merged
813}
814
815/// The `y` range over which the circle walk's `x` stays inside `lo..=hi`. `arc_x`
816/// is non-increasing in `y`, so one range of `x` maps back to one range of `y`.
817fn arc_rows_to_y(r: i64, lo: i64, hi: i64) -> (i64, i64) {
818    // `x` is confined to `0..=r` whatever the caller asks for.
819    let (lo, hi) = (lo.max(0), hi.min(r));
820    if lo > hi {
821        return (0, -1);
822    }
823    // `arc_x(y) <= hi` exactly when `y * y >= r * r - hi * (hi + 1)`, and
824    // `arc_x(y) >= lo` exactly when `y * y < r * r - (lo - 1) * lo`.
825    let y_lo = ceil_sqrt((r * r - hi * (hi + 1)).max(0));
826    let y_hi = if lo == 0 {
827        r
828    } else {
829        ceil_sqrt((r * r - (lo - 1) * lo).max(0)) - 1
830    };
831    (y_lo, y_hi)
832}
833
834/// The `x` the midpoint circle walk holds at row `y`: the smallest `v >= 0` with
835/// `v * (v + 1) >= r * r - y * y`. Stepping the walk's recurrence from `y = 0`
836/// arrives at exactly this value, so it can seed a restart part way along the arc.
837/// Past the octant boundary the two can disagree, but only once `x < y`, where the
838/// walk stops under either.
839fn arc_x(r: i64, y: i64) -> i64 {
840    let m = r * r - y * y;
841    if m <= 0 {
842        return 0;
843    }
844    let v = ceil_sqrt(m);
845    if v > 0 && (v - 1) * v >= m {
846        v - 1
847    } else {
848        v
849    }
850}
851
852/// The smallest `s >= 0` with `s * s >= m`.
853fn ceil_sqrt(m: i64) -> i64 {
854    let s = m.isqrt();
855    if s * s < m {
856        s + 1
857    } else {
858        s
859    }
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865    use std::{sync::mpsc, thread, time::Duration};
866
867    #[test]
868    fn cls_fills_screen() {
869        let mut fb = Framebuffer::new();
870        fb.cls(7);
871        assert!(fb.pixels().iter().all(|&p| p == 7));
872    }
873
874    #[test]
875    fn pset_pget_roundtrip() {
876        let mut fb = Framebuffer::new();
877        fb.pset(10, 20, 8);
878        assert_eq!(fb.pget(10, 20), 8);
879        assert_eq!(fb.pget(11, 20), 0);
880    }
881
882    #[test]
883    fn out_of_bounds_is_safe() {
884        let mut fb = Framebuffer::new();
885        fb.pset(-1, 0, 5);
886        fb.pset(0, 99999, 5);
887        fb.line(-50, -50, 200, 200, 6);
888        fb.circfill(0, 0, 300, 3);
889        assert_eq!(fb.pget(-1, 0), 0);
890    }
891
892    #[test]
893    fn camera_offsets_draws() {
894        let mut fb = Framebuffer::new();
895        fb.camera(10, 0);
896        fb.pset(15, 5, 9);
897        assert_eq!(fb.pget(5, 5), 9);
898        fb.reset_state();
899        fb.pset(15, 5, 9);
900        assert_eq!(fb.pget(15, 5), 9);
901    }
902
903    #[test]
904    fn clip_constrains_drawing() {
905        let mut fb = Framebuffer::new();
906        fb.clip(0, 0, 4, 4);
907        fb.rectfill(0, 0, 127, 127, 7);
908        assert_eq!(fb.pget(3, 3), 7);
909        assert_eq!(fb.pget(4, 4), 0);
910    }
911
912    #[test]
913    fn rect_outline_is_hollow() {
914        let mut fb = Framebuffer::new();
915        fb.rect(0, 0, 4, 4, 7);
916        assert_eq!(fb.pget(0, 0), 7);
917        assert_eq!(fb.pget(4, 4), 7);
918        assert_eq!(fb.pget(2, 2), 0);
919    }
920
921    #[test]
922    fn print_advances_cursor() {
923        let mut fb = Framebuffer::new();
924        let end = fb.print("abc", 0, 0, 7);
925        assert_eq!(end, 3 * font::GLYPH_W);
926    }
927
928    #[test]
929    fn partial_pixel_sprite_draws_a_partial_slice() {
930        let mut fb = Framebuffer::new();
931        let mut sheet = SpriteSheet::default();
932        // Fill sprite 0 (the top-left 8x8 cell) solid.
933        for y in 0..8 {
934            for x in 0..8 {
935                sheet.set(x, y, 7);
936            }
937        }
938        // A 4px width draws only the left four columns.
939        fb.spr(&sheet, 0, 0, 0, 4, 8, false, false);
940        assert_eq!(fb.pget(3, 4), 7, "left half is drawn");
941        assert_eq!(fb.pget(4, 4), 0, "right half is untouched");
942        assert_eq!(fb.pget(7, 7), 0, "bottom-right corner is untouched");
943    }
944
945    #[test]
946    fn spr_clipped_flip_mirrors_about_full_sprite() {
947        // Flip must mirror about the FULL 8x8 sprite, then the clip rect cuts
948        // the result — not the other way round. Mark the four source corners
949        // with distinct colors; double-flip swaps each corner to the opposite
950        // one, and a 4x4 top-left clip should keep exactly one of them.
951        let mut fb = Framebuffer::new();
952        let mut sheet = SpriteSheet::default();
953        sheet.set(0, 0, 8); // top-left  -> dest (7,7)
954        sheet.set(7, 0, 9); // top-right -> dest (0,7)
955        sheet.set(0, 7, 11); // bottom-left  -> dest (7,0)
956        sheet.set(7, 7, 12); // bottom-right -> dest (0,0)
957        fb.clip(0, 0, 4, 4);
958        fb.spr(&sheet, 0, 0, 0, 8, 8, true, true);
959        assert_eq!(
960            fb.pget(0, 0),
961            12,
962            "source bottom-right mirrors to dest (0,0) and survives the clip"
963        );
964        assert_eq!(fb.pget(7, 7), 0, "dest (7,7) is outside the 4x4 clip");
965        assert_eq!(fb.pget(0, 7), 0, "dest (0,7) is outside the 4x4 clip");
966        assert_eq!(fb.pget(7, 0), 0, "dest (7,0) is outside the 4x4 clip");
967    }
968
969    #[test]
970    fn spr_partly_offscreen_under_camera_aligns_source() {
971        // With the camera pushing the sprite up-and-left, only its bottom-right
972        // part is on screen; the visible pixels must come from the matching
973        // source columns/rows (no wrap), starting at dest (0,0).
974        let mut fb = Framebuffer::new();
975        let mut sheet = SpriteSheet::default();
976        for y in 0..8 {
977            for x in 0..8 {
978                sheet.set(x, y, 7);
979            }
980        }
981        sheet.set(2, 3, 9); // the source pixel that lands on dest (0,0)
982        fb.camera(2, 3);
983        fb.spr(&sheet, 0, 0, 0, 8, 8, false, false);
984        assert_eq!(
985            fb.pget(0, 0),
986            9,
987            "source (2,3) lands at the top-left corner"
988        );
989        assert_eq!(fb.pget(1, 0), 7, "source (3,3) is the next column");
990        assert_eq!(fb.pget(5, 4), 7, "the rest of the on-screen part is drawn");
991        // Nothing wrapped to the far edges of the screen.
992        assert_eq!(fb.pget(127, 127), 0, "no wrap to the opposite corner");
993    }
994
995    #[test]
996    fn spr_partial_pixel_width_meets_clip_edge() {
997        // A 4px-wide sprite slice further trimmed by a 2px-wide clip: only the
998        // first two destination columns survive.
999        let mut fb = Framebuffer::new();
1000        let mut sheet = SpriteSheet::default();
1001        for y in 0..8 {
1002            for x in 0..8 {
1003                sheet.set(x, y, 7);
1004            }
1005        }
1006        fb.clip(0, 0, 2, 128);
1007        fb.spr(&sheet, 0, 0, 0, 4, 8, false, false);
1008        assert_eq!(fb.pget(1, 3), 7, "inside the clip and the partial width");
1009        assert_eq!(fb.pget(2, 3), 0, "clipped away at x=2");
1010        assert_eq!(fb.pget(4, 3), 0, "beyond the 4px width anyway");
1011    }
1012
1013    #[test]
1014    fn transparency_mask_controls_sprite_pixels() {
1015        let mut fb = Framebuffer::new();
1016        let mut sheet = SpriteSheet::default();
1017        for y in 0..8 {
1018            for x in 0..8 {
1019                sheet.set(x, y, 8); // a solid red sprite
1020            }
1021        }
1022        // Default: nonzero colors draw.
1023        fb.spr(&sheet, 0, 0, 0, 8, 8, false, false);
1024        assert_eq!(fb.pget(1, 1), 8);
1025        // Make red transparent: redrawing over green leaves green showing.
1026        fb.cls(3);
1027        fb.set_transparent_color(8, true);
1028        fb.spr(&sheet, 0, 0, 0, 8, 8, false, false);
1029        assert_eq!(fb.pget(1, 1), 3, "red made transparent");
1030        // reset_transparency restores the default; red draws again.
1031        fb.reset_transparency();
1032        fb.spr(&sheet, 0, 0, 0, 8, 8, false, false);
1033        assert_eq!(fb.pget(1, 1), 8);
1034    }
1035
1036    #[test]
1037    fn color_zero_can_be_made_opaque() {
1038        let mut fb = Framebuffer::new();
1039        let sheet = SpriteSheet::default(); // all color 0
1040        fb.cls(7);
1041        fb.set_transparent_color(0, false);
1042        fb.spr(&sheet, 0, 0, 0, 8, 8, false, false);
1043        assert_eq!(fb.pget(3, 3), 0, "color 0 now drawn over white");
1044    }
1045
1046    #[test]
1047    fn draw_palette_remaps_writes() {
1048        let mut fb = Framebuffer::new();
1049        fb.remap_color(8, 12); // draw red as blue
1050        fb.pset(5, 5, 8);
1051        assert_eq!(fb.pget(5, 5), 12);
1052        fb.reset_palette();
1053        fb.pset(6, 6, 8);
1054        assert_eq!(fb.pget(6, 6), 8);
1055    }
1056
1057    #[test]
1058    fn cls_ignores_draw_palette() {
1059        let mut fb = Framebuffer::new();
1060        fb.remap_color(0, 8);
1061        fb.cls(0);
1062        assert_eq!(fb.pget(10, 10), 0, "cls clears to the literal color");
1063    }
1064
1065    #[test]
1066    fn display_palette_remaps_at_upload() {
1067        let mut fb = Framebuffer::new();
1068        fb.pset(0, 0, 8); // red stored
1069        fb.remap_display_color(8, 12); // show red as blue
1070        let mut out = vec![0u8; (WIDTH * HEIGHT * 4) as usize];
1071        fb.write_rgba(&mut out);
1072        assert_eq!(&out[0..4], &palette::rgba(12), "pixel uploaded as blue");
1073        assert_eq!(fb.pget(0, 0), 8, "stored index is unchanged");
1074    }
1075
1076    #[test]
1077    fn ovalfill_fills_center_not_corner() {
1078        let mut fb = Framebuffer::new();
1079        fb.ovalfill(0, 0, 10, 6, 7);
1080        assert_eq!(fb.pget(5, 3), 7, "center filled");
1081        assert_eq!(fb.pget(0, 0), 0, "bounding-box corner stays empty");
1082    }
1083
1084    #[test]
1085    fn oval_outline_is_hollow() {
1086        let mut fb = Framebuffer::new();
1087        fb.oval(0, 0, 10, 10, 7);
1088        assert_eq!(fb.pget(5, 0), 7, "top of the outline is set");
1089        assert_eq!(fb.pget(5, 5), 0, "center is hollow");
1090    }
1091
1092    #[test]
1093    fn two_color_fill_pattern_alternates() {
1094        let mut fb = Framebuffer::new();
1095        // bit 15 (top-left) = 1, bit 14 = 0, ...
1096        fb.set_fill_pattern(0b1010_0101_1010_0101, 12, false);
1097        fb.rectfill(0, 0, 3, 3, 7);
1098        assert_eq!(
1099            fb.pget(0, 0),
1100            12,
1101            "pattern-1 pixel uses the secondary color"
1102        );
1103        assert_eq!(fb.pget(1, 0), 7, "pattern-0 pixel uses the primary color");
1104    }
1105
1106    #[test]
1107    fn transparent_fill_pattern_skips_pixels() {
1108        let mut fb = Framebuffer::new();
1109        fb.cls(3);
1110        fb.set_fill_pattern(0xffff, 0, true); // every pixel is pattern-1, transparent
1111        fb.rectfill(0, 0, 3, 3, 7);
1112        assert_eq!(
1113            fb.pget(1, 1),
1114            3,
1115            "all pattern-1 pixels skipped; background shows"
1116        );
1117    }
1118
1119    #[test]
1120    fn zero_pattern_fills_solid() {
1121        let mut fb = Framebuffer::new();
1122        fb.set_fill_pattern(0, 0, false);
1123        fb.rectfill(0, 0, 3, 3, 7);
1124        assert_eq!(fb.pget(2, 2), 7);
1125    }
1126
1127    #[test]
1128    fn sspr_upscales_with_nearest_neighbor() {
1129        let mut fb = Framebuffer::new();
1130        let mut sheet = SpriteSheet::default();
1131        sheet.set(0, 0, 8); // single red source pixel
1132        fb.sspr(&sheet, 0, 0, 1, 1, 10, 10, 4, 4, false, false);
1133        assert_eq!(fb.pget(10, 10), 8);
1134        assert_eq!(
1135            fb.pget(13, 13),
1136            8,
1137            "the whole 4x4 block is the source pixel"
1138        );
1139    }
1140
1141    #[test]
1142    fn sspr_respects_transparency() {
1143        let mut fb = Framebuffer::new();
1144        let sheet = SpriteSheet::default(); // all color 0
1145        fb.cls(3);
1146        fb.sspr(&sheet, 0, 0, 2, 2, 0, 0, 4, 4, false, false);
1147        assert_eq!(fb.pget(1, 1), 3, "color 0 is transparent by default");
1148    }
1149
1150    #[test]
1151    fn sspr_flips_horizontally() {
1152        let mut fb = Framebuffer::new();
1153        let mut sheet = SpriteSheet::default();
1154        sheet.set(0, 0, 8);
1155        sheet.set(1, 0, 9);
1156        fb.sspr(&sheet, 0, 0, 2, 1, 0, 0, 2, 1, true, false);
1157        assert_eq!(
1158            fb.pget(1, 0),
1159            8,
1160            "flip puts the source-left pixel on the right"
1161        );
1162        assert_eq!(fb.pget(0, 0), 9);
1163    }
1164
1165    /// One geometry case for the clipping-equivalence tests: source and
1166    /// destination rectangles plus the camera and clip rect they draw under.
1167    struct ClipCase {
1168        name: &'static str,
1169        src: (i32, i32, i32, i32),
1170        dst: (i32, i32, i32, i32),
1171        camera: (i32, i32),
1172        clip: (i32, i32, i32, i32),
1173    }
1174
1175    /// A camera offset and clip rect to draw a case under.
1176    struct ClipState {
1177        name: &'static str,
1178        camera: (i32, i32),
1179        clip: (i32, i32, i32, i32),
1180    }
1181
1182    /// Camera and clip states every span-sweeping primitive is checked against.
1183    const CLIP_STATES: [ClipState; 4] = [
1184        ClipState {
1185            name: "plain",
1186            camera: (0, 0),
1187            clip: (0, 0, WIDTH, HEIGHT),
1188        },
1189        ClipState {
1190            name: "camera",
1191            camera: (23, -17),
1192            clip: (0, 0, WIDTH, HEIGHT),
1193        },
1194        ClipState {
1195            name: "clip rect",
1196            camera: (0, 0),
1197            clip: (12, 20, 33, 41),
1198        },
1199        ClipState {
1200            name: "camera and clip",
1201            camera: (-9, 6),
1202            clip: (12, 20, 33, 41),
1203        },
1204    ];
1205
1206    /// A sheet with a varied, non-uniform pattern spanning the first two rows of
1207    /// sprites — including color 0, so transparency is exercised with the geometry.
1208    fn patterned_sheet() -> SpriteSheet {
1209        let mut sheet = SpriteSheet::default();
1210        for y in 0..24 {
1211            for x in 0..64 {
1212                sheet.set(x, y, ((x * 5 + y * 7) % 16) as u8);
1213            }
1214        }
1215        for n in 0..8 {
1216            sheet.set_flag(n, 0, n % 2 == 0);
1217        }
1218        sheet
1219    }
1220
1221    /// A map whose cels cycle through the first few sprites, with tile-0 holes so
1222    /// the empty-cel skip is exercised too.
1223    fn patterned_map() -> MapData {
1224        let mut map = MapData::default();
1225        for y in 0..20 {
1226            for x in 0..30 {
1227                map.set(x, y, ((x * 3 + y * 5) % 7) as u8);
1228            }
1229        }
1230        map
1231    }
1232
1233    /// A draw palette and transparency mask for a sweep to run under. `spr` and
1234    /// `sspr` write into the framebuffer directly instead of going through `pset`,
1235    /// so both have to be checked against a `pset`-based walk with the palette and
1236    /// the mask off their defaults.
1237    struct PenState {
1238        name: &'static str,
1239        remap: &'static [(u8, u8)],
1240        transparent: &'static [u8],
1241    }
1242
1243    const PEN_STATES: [PenState; 2] = [
1244        PenState {
1245            name: "default pen",
1246            remap: &[],
1247            transparent: &[],
1248        },
1249        PenState {
1250            name: "remapped pen",
1251            remap: &[(9, 3), (5, 14), (0, 7)],
1252            transparent: &[4, 11],
1253        },
1254    ];
1255
1256    /// A framebuffer staged for one case: a nonzero background so stray writes
1257    /// show up either way, plus the camera, clip rect and pen the case asks for.
1258    fn staged_fb(camera: (i32, i32), clip: (i32, i32, i32, i32), pen: &PenState) -> Framebuffer {
1259        let mut fb = Framebuffer::new();
1260        fb.cls(1);
1261        fb.camera(camera.0, camera.1);
1262        fb.clip(clip.0, clip.1, clip.2, clip.3);
1263        for &(from, to) in pen.remap {
1264            fb.remap_color(from, to);
1265        }
1266        for &color in pen.transparent {
1267            fb.set_transparent_color(color, true);
1268        }
1269        fb
1270    }
1271
1272    /// Assert two framebuffers match, naming the first differing pixel rather than
1273    /// dumping 16 K palette indices into the failure message.
1274    fn assert_same_pixels(got: &Framebuffer, want: &Framebuffer, case: &str) {
1275        let diff = got
1276            .pixels()
1277            .iter()
1278            .zip(want.pixels())
1279            .position(|(g, w)| g != w);
1280        if let Some(i) = diff {
1281            let (x, y) = (i as i32 % WIDTH, i as i32 / WIDTH);
1282            panic!(
1283                "{case}: pixel ({x}, {y}) is {}, want {}",
1284                got.pixels()[i],
1285                want.pixels()[i]
1286            );
1287        }
1288    }
1289
1290    /// How long a pathological one-call draw is given to finish. Clipped, each of
1291    /// the calls below returns in microseconds; unclipped they run for hours, so
1292    /// without a deadline a regression would stall the whole test run instead of
1293    /// naming the primitive that broke.
1294    const SWEEP_DEADLINE: Duration = Duration::from_secs(20);
1295
1296    /// Run one draw on a worker thread and hand back what it drew, failing the test
1297    /// if it has not finished within `SWEEP_DEADLINE`.
1298    fn drawn_within_deadline<F>(what: &str, draw: F) -> Framebuffer
1299    where
1300        F: FnOnce(&mut Framebuffer) + Send + 'static,
1301    {
1302        let (tx, rx) = mpsc::channel();
1303        thread::spawn(move || {
1304            let mut fb = Framebuffer::new();
1305            draw(&mut fb);
1306            let _ = tx.send(fb);
1307        });
1308        match rx.recv_timeout(SWEEP_DEADLINE) {
1309            Ok(fb) => fb,
1310            Err(_) => panic!("{what} did not finish within {SWEEP_DEADLINE:?}"),
1311        }
1312    }
1313
1314    /// `sspr` as it was before the destination range was clipped: walk every
1315    /// requested pixel and let `pset` reject whatever falls outside.
1316    fn sspr_unclipped(
1317        fb: &mut Framebuffer,
1318        sheet: &SpriteSheet,
1319        case: &ClipCase,
1320        flip_x: bool,
1321        flip_y: bool,
1322    ) {
1323        let (sx, sy, sw, sh) = case.src;
1324        let (dx, dy, dw, dh) = case.dst;
1325        if sw <= 0 || sh <= 0 || dw <= 0 || dh <= 0 {
1326            return;
1327        }
1328        for py in 0..dh {
1329            for px in 0..dw {
1330                let fx = if flip_x { dw - 1 - px } else { px };
1331                let fy = if flip_y { dh - 1 - py } else { py };
1332                let c = sheet.get(sx + fx * sw / dw, sy + fy * sh / dh);
1333                if ((fb.transparent >> c) & 1) == 0 {
1334                    fb.pset(dx + px, dy + py, c);
1335                }
1336            }
1337        }
1338    }
1339
1340    #[test]
1341    fn sspr_clipping_matches_the_unclipped_walk() {
1342        let sheet = patterned_sheet();
1343        let full = (0, 0, WIDTH, HEIGHT);
1344        let cases = [
1345            ClipCase {
1346                name: "fully on screen",
1347                src: (0, 0, 8, 8),
1348                dst: (10, 10, 16, 16),
1349                camera: (0, 0),
1350                clip: full,
1351            },
1352            ClipCase {
1353                name: "over the left edge",
1354                src: (0, 0, 8, 8),
1355                dst: (-9, 10, 16, 16),
1356                camera: (0, 0),
1357                clip: full,
1358            },
1359            ClipCase {
1360                name: "over the right edge",
1361                src: (0, 0, 8, 8),
1362                dst: (121, 10, 16, 16),
1363                camera: (0, 0),
1364                clip: full,
1365            },
1366            ClipCase {
1367                name: "over the top edge",
1368                src: (0, 0, 8, 8),
1369                dst: (10, -11, 16, 16),
1370                camera: (0, 0),
1371                clip: full,
1372            },
1373            ClipCase {
1374                name: "over the bottom edge",
1375                src: (0, 0, 8, 8),
1376                dst: (10, 119, 16, 16),
1377                camera: (0, 0),
1378                clip: full,
1379            },
1380            ClipCase {
1381                name: "entirely off to the right",
1382                src: (0, 0, 8, 8),
1383                dst: (200, 60, 16, 16),
1384                camera: (0, 0),
1385                clip: full,
1386            },
1387            ClipCase {
1388                name: "entirely off past the origin",
1389                src: (0, 0, 8, 8),
1390                dst: (-40, -40, 16, 16),
1391                camera: (0, 0),
1392                clip: full,
1393            },
1394            ClipCase {
1395                name: "negative dx and dy straddling the origin",
1396                src: (0, 0, 8, 8),
1397                dst: (-5, -3, 24, 24),
1398                camera: (0, 0),
1399                clip: full,
1400            },
1401            ClipCase {
1402                name: "downscale",
1403                src: (0, 0, 16, 16),
1404                dst: (60, 60, 3, 3),
1405                camera: (0, 0),
1406                clip: full,
1407            },
1408            ClipCase {
1409                name: "downscale over an edge",
1410                src: (0, 0, 16, 16),
1411                dst: (-2, 120, 5, 11),
1412                camera: (0, 0),
1413                clip: full,
1414            },
1415            ClipCase {
1416                name: "upscale with an awkward ratio",
1417                src: (1, 2, 3, 5),
1418                dst: (-2, 100, 37, 41),
1419                camera: (0, 0),
1420                clip: full,
1421            },
1422            ClipCase {
1423                // Bigger than the screen in both axes: clamping `dw`/`dh` instead of
1424                // narrowing the loop would change the sampling denominator here.
1425                name: "upscale larger than the screen",
1426                src: (0, 0, 8, 8),
1427                dst: (-30, -20, 200, 220),
1428                camera: (0, 0),
1429                clip: full,
1430            },
1431            ClipCase {
1432                name: "upscale far larger than the screen, then clipped",
1433                src: (0, 0, 5, 7),
1434                dst: (10, 10, 500, 400),
1435                camera: (0, 0),
1436                clip: (20, 20, 60, 60),
1437            },
1438            ClipCase {
1439                name: "pushed off screen by the camera",
1440                src: (0, 0, 8, 8),
1441                dst: (10, 10, 16, 16),
1442                camera: (30, 20),
1443                clip: full,
1444            },
1445            ClipCase {
1446                name: "pulled on screen by a negative camera",
1447                src: (0, 0, 8, 8),
1448                dst: (10, 10, 20, 20),
1449                camera: (-100, -115),
1450                clip: full,
1451            },
1452            ClipCase {
1453                name: "trimmed by a clip rect",
1454                src: (0, 0, 8, 8),
1455                dst: (0, 0, 32, 32),
1456                camera: (0, 0),
1457                clip: (4, 4, 20, 20),
1458            },
1459            ClipCase {
1460                name: "camera and clip together",
1461                src: (2, 3, 12, 9),
1462                dst: (0, 0, 40, 40),
1463                camera: (-5, 7),
1464                clip: (10, 10, 40, 40),
1465            },
1466            ClipCase {
1467                name: "one-pixel destination",
1468                src: (0, 0, 8, 8),
1469                dst: (64, 64, 1, 1),
1470                camera: (0, 0),
1471                clip: full,
1472            },
1473            ClipCase {
1474                name: "source origin outside the sheet",
1475                src: (-4, -4, 8, 8),
1476                dst: (20, 20, 16, 16),
1477                camera: (0, 0),
1478                clip: full,
1479            },
1480            ClipCase {
1481                name: "clip rect that rejects everything",
1482                src: (0, 0, 8, 8),
1483                dst: (0, 0, 16, 16),
1484                camera: (0, 0),
1485                clip: (100, 100, 4, 4),
1486            },
1487        ];
1488        for case in &cases {
1489            for pen in &PEN_STATES {
1490                for (flip_x, flip_y) in [(false, false), (true, false), (false, true), (true, true)]
1491                {
1492                    let mut got = staged_fb(case.camera, case.clip, pen);
1493                    let mut want = staged_fb(case.camera, case.clip, pen);
1494                    let (sx, sy, sw, sh) = case.src;
1495                    let (dx, dy, dw, dh) = case.dst;
1496                    got.sspr(&sheet, sx, sy, sw, sh, dx, dy, dw, dh, flip_x, flip_y);
1497                    sspr_unclipped(&mut want, &sheet, case, flip_x, flip_y);
1498                    assert_same_pixels(
1499                        &got,
1500                        &want,
1501                        &format!(
1502                            "{} / {} (flip_x {flip_x}, flip_y {flip_y})",
1503                            case.name, pen.name
1504                        ),
1505                    );
1506                }
1507            }
1508        }
1509    }
1510
1511    #[test]
1512    fn sspr_bounded_by_the_screen_not_the_request() {
1513        // 8x8 -> 4096x4096 is 16.7 M destination pixels bought with one host call, and
1514        // 8x8 -> a million square is 10^12 of them. Both have to be paid for at the
1515        // 16 K the screen can actually show, so the deadline is the assertion here:
1516        // walking the request instead paints the same visible pixels, it just takes
1517        // until next week. Every on-screen pixel maps back into the source, all of
1518        // which is color 9.
1519        for side in [4096, 1_000_000] {
1520            let fb = drawn_within_deadline(&format!("an 8x8 -> {side}x{side} sspr"), move |fb| {
1521                let mut sheet = SpriteSheet::default();
1522                for y in 0..8 {
1523                    for x in 0..8 {
1524                        sheet.set(x, y, 9);
1525                    }
1526                }
1527                fb.sspr(&sheet, 0, 0, 8, 8, 0, 0, side, side, false, false);
1528            });
1529            assert!(
1530                fb.pixels().iter().all(|&p| p == 9),
1531                "the visible part of a {side}-square stretch is drawn"
1532            );
1533        }
1534    }
1535
1536    #[test]
1537    fn sspr_survives_a_destination_at_the_i32_ceiling() {
1538        // Unclipped this is ~4.6e18 inner iterations, and the `fx * sw` product on
1539        // its own overflows an i32. The destination starts a million pixels off the
1540        // top-left, so the screen samples the far corner of the source sprite under
1541        // each flip — all of it color 9.
1542        let mut sheet = SpriteSheet::default();
1543        for y in 0..8 {
1544            for x in 0..8 {
1545                sheet.set(x, y, 9);
1546            }
1547        }
1548        for (flip_x, flip_y) in [(false, false), (true, false), (false, true), (true, true)] {
1549            let sheet = sheet.clone();
1550            let fb = drawn_within_deadline("an sspr at the i32 ceiling", move |fb| {
1551                fb.sspr(
1552                    &sheet,
1553                    0,
1554                    0,
1555                    8,
1556                    8,
1557                    -1_000_000,
1558                    -1_000_000,
1559                    i32::MAX,
1560                    i32::MAX,
1561                    flip_x,
1562                    flip_y,
1563                );
1564            });
1565            assert!(
1566                fb.pixels().iter().all(|&p| p == 9),
1567                "flip_x {flip_x}, flip_y {flip_y}"
1568            );
1569        }
1570    }
1571
1572    /// `spr` written the naive way: sample the sheet and hand each pixel to `pset`,
1573    /// which is where every other primitive picks up the draw palette.
1574    #[allow(clippy::too_many_arguments)]
1575    fn spr_via_pset(
1576        fb: &mut Framebuffer,
1577        sheet: &SpriteSheet,
1578        n: u32,
1579        x: i32,
1580        y: i32,
1581        w: i32,
1582        h: i32,
1583        flip_x: bool,
1584        flip_y: bool,
1585    ) {
1586        let (pw, ph) = (w.max(0), h.max(0));
1587        let n = (n as usize) % SPRITE_COUNT;
1588        let base_sx = (n % SPRITES_PER_ROW * SPRITE_SIZE) as i32;
1589        let base_sy = (n / SPRITES_PER_ROW * SPRITE_SIZE) as i32;
1590        for py in 0..ph {
1591            let sy = if flip_y { ph - 1 - py } else { py };
1592            for px in 0..pw {
1593                let sx = if flip_x { pw - 1 - px } else { px };
1594                let c = sheet.get(base_sx + sx, base_sy + sy);
1595                if ((fb.transparent >> c) & 1) == 0 {
1596                    fb.pset(x + px, y + py, c);
1597                }
1598            }
1599        }
1600    }
1601
1602    #[test]
1603    fn spr_writes_match_the_pset_path() {
1604        // `spr` writes into the framebuffer directly rather than through `pset`, so
1605        // the draw palette and transparency mask it applies on the way have to be
1606        // checked against the shared path it bypasses — `map` cannot do it, because
1607        // its reference walk calls `spr` too.
1608        let sheet = patterned_sheet();
1609        for pen in &PEN_STATES {
1610            for state in &CLIP_STATES {
1611                for (n, at, size) in [
1612                    (0u32, (10, 10), (8, 8)),
1613                    (3, (-4, 60), (8, 8)),
1614                    (9, (124, -2), (16, 16)),
1615                    (1, (30, 30), (5, 3)),
1616                ] {
1617                    for (flip_x, flip_y) in
1618                        [(false, false), (true, false), (false, true), (true, true)]
1619                    {
1620                        let mut got = staged_fb(state.camera, state.clip, pen);
1621                        let mut want = staged_fb(state.camera, state.clip, pen);
1622                        got.spr(&sheet, n, at.0, at.1, size.0, size.1, flip_x, flip_y);
1623                        spr_via_pset(
1624                            &mut want, &sheet, n, at.0, at.1, size.0, size.1, flip_x, flip_y,
1625                        );
1626                        assert_same_pixels(
1627                            &got,
1628                            &want,
1629                            &format!(
1630                                "sprite {n} at {at:?} / {} / {} (flip {flip_x} {flip_y})",
1631                                state.name, pen.name
1632                            ),
1633                        );
1634                    }
1635                }
1636            }
1637        }
1638    }
1639
1640    /// One `map` case: the cel rectangle, where it lands on screen, and the
1641    /// camera/clip state it draws under.
1642    struct MapCase {
1643        name: &'static str,
1644        cel: (i32, i32, i32, i32),
1645        at: (i32, i32),
1646        camera: (i32, i32),
1647        clip: (i32, i32, i32, i32),
1648    }
1649
1650    /// `map` as it was before the cel range was clipped.
1651    fn map_unclipped(
1652        fb: &mut Framebuffer,
1653        map: &MapData,
1654        sheet: &SpriteSheet,
1655        case: &MapCase,
1656        layers: u8,
1657    ) {
1658        let (cel_x, cel_y, cel_w, cel_h) = case.cel;
1659        let (sx, sy) = case.at;
1660        for ty in 0..cel_h {
1661            for tx in 0..cel_w {
1662                let tile = map.get(cel_x + tx, cel_y + ty);
1663                if tile == 0 {
1664                    continue;
1665                }
1666                if layers != 0 && sheet.flags(tile as u32) & layers == 0 {
1667                    continue;
1668                }
1669                fb.spr(
1670                    sheet,
1671                    tile as u32,
1672                    sx + tx * SPRITE_SIZE as i32,
1673                    sy + ty * SPRITE_SIZE as i32,
1674                    SPRITE_SIZE as i32,
1675                    SPRITE_SIZE as i32,
1676                    false,
1677                    false,
1678                );
1679            }
1680        }
1681    }
1682
1683    #[test]
1684    fn map_clipping_matches_the_unclipped_walk() {
1685        let map = patterned_map();
1686        let sheet = patterned_sheet();
1687        let full = (0, 0, WIDTH, HEIGHT);
1688        let cases = [
1689            MapCase {
1690                name: "fully on screen",
1691                cel: (0, 0, 8, 8),
1692                at: (0, 0),
1693                camera: (0, 0),
1694                clip: full,
1695            },
1696            MapCase {
1697                name: "wider than the screen",
1698                cel: (0, 0, 30, 20),
1699                at: (0, 0),
1700                camera: (0, 0),
1701                clip: full,
1702            },
1703            MapCase {
1704                name: "over the left edge",
1705                cel: (0, 0, 20, 16),
1706                at: (-20, 0),
1707                camera: (0, 0),
1708                clip: full,
1709            },
1710            MapCase {
1711                name: "over the top edge on a half-cel offset",
1712                cel: (0, 0, 20, 16),
1713                at: (-3, -13),
1714                camera: (0, 0),
1715                clip: full,
1716            },
1717            MapCase {
1718                name: "over the bottom-right corner",
1719                cel: (0, 0, 20, 16),
1720                at: (100, 100),
1721                camera: (0, 0),
1722                clip: full,
1723            },
1724            MapCase {
1725                name: "entirely off screen",
1726                cel: (0, 0, 20, 16),
1727                at: (-500, -500),
1728                camera: (0, 0),
1729                clip: full,
1730            },
1731            MapCase {
1732                name: "shifted by the camera",
1733                cel: (0, 0, 20, 16),
1734                at: (0, 0),
1735                camera: (37, 21),
1736                clip: full,
1737            },
1738            MapCase {
1739                name: "pulled back by a negative camera",
1740                cel: (0, 0, 20, 16),
1741                at: (0, 0),
1742                camera: (-19, -5),
1743                clip: full,
1744            },
1745            MapCase {
1746                name: "trimmed by a clip rect",
1747                cel: (0, 0, 20, 16),
1748                at: (0, 0),
1749                camera: (0, 0),
1750                clip: (10, 10, 30, 30),
1751            },
1752            MapCase {
1753                name: "cel origin offset into the map",
1754                cel: (5, 3, 10, 10),
1755                at: (2, 2),
1756                camera: (-6, 11),
1757                clip: (3, 7, 90, 60),
1758            },
1759            MapCase {
1760                name: "empty cel rectangle",
1761                cel: (0, 0, 0, 0),
1762                at: (0, 0),
1763                camera: (0, 0),
1764                clip: full,
1765            },
1766        ];
1767        for case in &cases {
1768            for pen in &PEN_STATES {
1769                for layers in [0u8, 1] {
1770                    let mut got = staged_fb(case.camera, case.clip, pen);
1771                    let mut want = staged_fb(case.camera, case.clip, pen);
1772                    let (cel_x, cel_y, cel_w, cel_h) = case.cel;
1773                    let (sx, sy) = case.at;
1774                    got.map(&map, &sheet, cel_x, cel_y, sx, sy, cel_w, cel_h, layers);
1775                    map_unclipped(&mut want, &map, &sheet, case, layers);
1776                    assert_same_pixels(
1777                        &got,
1778                        &want,
1779                        &format!("{} / {} (layers {layers})", case.name, pen.name),
1780                    );
1781                }
1782            }
1783        }
1784    }
1785
1786    #[test]
1787    fn map_bounded_by_the_screen_not_the_request() {
1788        // 100_000 x 100_000 cels is 10^10 tile lookups for one host call. Only the
1789        // 16x16 that fit the screen can draw anything, so the result must match
1790        // asking for exactly those.
1791        let got = drawn_within_deadline("a 100_000-cel map", |fb| {
1792            fb.map(
1793                &patterned_map(),
1794                &patterned_sheet(),
1795                0,
1796                0,
1797                0,
1798                0,
1799                100_000,
1800                100_000,
1801                0,
1802            );
1803        });
1804        let mut want = Framebuffer::new();
1805        want.map(&patterned_map(), &patterned_sheet(), 0, 0, 0, 0, 16, 16, 0);
1806        assert_same_pixels(&got, &want, "a 100_000-cel request");
1807    }
1808
1809    /// `line` as it was before the walk was solved against the clip rect: step the
1810    /// Bresenham error term from one endpoint to the other, however far that is.
1811    fn line_unclipped(fb: &mut Framebuffer, x0: i32, y0: i32, x1: i32, y1: i32, color: u8) {
1812        let (mut x0, mut y0) = (x0 - fb.camera_x, y0 - fb.camera_y);
1813        let (x1, y1) = (x1 - fb.camera_x, y1 - fb.camera_y);
1814        let dx = (x1 - x0).abs();
1815        let dy = -(y1 - y0).abs();
1816        let sx = if x0 < x1 { 1 } else { -1 };
1817        let sy = if y0 < y1 { 1 } else { -1 };
1818        let mut err = dx + dy;
1819        loop {
1820            fb.raw_pset(x0, y0, color);
1821            if x0 == x1 && y0 == y1 {
1822                break;
1823            }
1824            let e2 = 2 * err;
1825            if e2 >= dy {
1826                err += dy;
1827                x0 += sx;
1828            }
1829            if e2 <= dx {
1830                err += dx;
1831                y0 += sy;
1832            }
1833        }
1834    }
1835
1836    #[test]
1837    fn line_clipping_matches_the_unclipped_walk() {
1838        // Every ordered pair drawn from this spread: on screen, on each edge, just
1839        // outside it and far outside. That covers all eight octants, both degenerate
1840        // axes, the exact diagonal and the single-pixel line, under each camera and
1841        // clip rect.
1842        const ENDS: [i32; 7] = [-201, -1, 0, 37, 89, 127, 260];
1843        for state in CLIP_STATES {
1844            for x0 in ENDS {
1845                for y0 in ENDS {
1846                    for x1 in ENDS {
1847                        for y1 in ENDS {
1848                            let mut got = staged_fb(state.camera, state.clip, &PEN_STATES[0]);
1849                            let mut want = staged_fb(state.camera, state.clip, &PEN_STATES[0]);
1850                            got.line(x0, y0, x1, y1, 7);
1851                            line_unclipped(&mut want, x0, y0, x1, y1, 7);
1852                            assert_same_pixels(
1853                                &got,
1854                                &want,
1855                                &format!("({x0}, {y0}) - ({x1}, {y1}) / {}", state.name),
1856                            );
1857                        }
1858                    }
1859                }
1860            }
1861        }
1862    }
1863
1864    #[test]
1865    fn line_bounded_by_the_screen_not_the_endpoints() {
1866        // A line a billion pixels long is one host call billed one fuel unit, and
1867        // stepping it is a billion iterations. Solving the major axis against the clip
1868        // rect costs at most a screen's width instead — the deadline is what pins that
1869        // down, since an unclipped walk draws the same pixels, just not this decade.
1870        // The shapes are picked so the visible pixels follow from the geometry alone.
1871        /// One case: a name, the line's endpoints, and the pixels it must leave.
1872        type LineCase = (&'static str, (i32, i32, i32, i32), fn(&mut Framebuffer));
1873
1874        let far = 2_000_000_000;
1875        let cases: [LineCase; 5] = [
1876            ("rightwards along row 0", (0, 0, far, 0), |fb| {
1877                for x in 0..WIDTH {
1878                    fb.pset(x, 0, 7);
1879                }
1880            }),
1881            ("downwards along column 0", (0, 0, 0, far), |fb| {
1882                for y in 0..HEIGHT {
1883                    fb.pset(0, y, 7);
1884                }
1885            }),
1886            ("the main diagonal", (0, 0, far, far), |fb| {
1887                for i in 0..WIDTH {
1888                    fb.pset(i, i, 7);
1889                }
1890            }),
1891            (
1892                "the main diagonal, walked backwards",
1893                (far, far, 0, 0),
1894                |fb| {
1895                    for i in 0..WIDTH {
1896                        fb.pset(i, i, 7);
1897                    }
1898                },
1899            ),
1900            // Half slope: Bresenham puts row `(x + 1) / 2` on column `x`.
1901            ("half slope", (0, 0, far, far / 2), |fb| {
1902                for x in 0..WIDTH {
1903                    fb.pset(x, (x + 1) / 2, 7);
1904                }
1905            }),
1906        ];
1907        for (name, ends, expected) in cases {
1908            let got = drawn_within_deadline(name, move |fb| {
1909                fb.line(ends.0, ends.1, ends.2, ends.3, 7);
1910            });
1911            let mut want = Framebuffer::new();
1912            expected(&mut want);
1913            assert_same_pixels(&got, &want, name);
1914        }
1915    }
1916
1917    /// `rectfill` as it was before the sweep was clipped.
1918    fn rectfill_unclipped(fb: &mut Framebuffer, x0: i32, y0: i32, x1: i32, y1: i32, color: u8) {
1919        let (xa, xb) = (x0.min(x1), x0.max(x1));
1920        let (ya, yb) = (y0.min(y1), y0.max(y1));
1921        if fb.fill_pattern == 0 {
1922            for y in ya..=yb {
1923                fb.fill_span(xa - fb.camera_x, xb - fb.camera_x, y - fb.camera_y, color);
1924            }
1925        } else {
1926            for y in ya..=yb {
1927                for x in xa..=xb {
1928                    fb.raw_pset_fill(x - fb.camera_x, y - fb.camera_y, color);
1929                }
1930            }
1931        }
1932    }
1933
1934    #[test]
1935    fn rectfill_clipping_matches_the_unclipped_walk() {
1936        let rects = [
1937            ("fully on screen", (10, 10, 40, 30)),
1938            ("over the top-left", (-20, -30, 40, 30)),
1939            ("over the bottom-right", (100, 90, 200, 300)),
1940            ("entirely off screen", (300, 300, 400, 400)),
1941            ("inverted corners", (60, 70, 20, 10)),
1942            ("wider than the screen", (-50, 40, 400, 44)),
1943            ("a single pixel", (64, 64, 64, 64)),
1944        ];
1945        for pattern in [0u16, 0b1010_0101_1010_0101] {
1946            for (rect_name, (x0, y0, x1, y1)) in rects {
1947                for state in CLIP_STATES {
1948                    let (state_name, mut got, mut want) = (
1949                        state.name,
1950                        staged_fb(state.camera, state.clip, &PEN_STATES[0]),
1951                        staged_fb(state.camera, state.clip, &PEN_STATES[0]),
1952                    );
1953                    got.set_fill_pattern(pattern, 12, false);
1954                    want.set_fill_pattern(pattern, 12, false);
1955                    got.rectfill(x0, y0, x1, y1, 7);
1956                    rectfill_unclipped(&mut want, x0, y0, x1, y1, 7);
1957                    assert_same_pixels(
1958                        &got,
1959                        &want,
1960                        &format!("{rect_name} / {state_name} / pattern {pattern:#06x}"),
1961                    );
1962                }
1963            }
1964        }
1965    }
1966
1967    #[test]
1968    fn rectfill_bounded_by_the_screen_not_the_request() {
1969        // Corners near the i32 extremes are billions of rows unclipped; the visible
1970        // result is the same as filling exactly the screen. The extremes are
1971        // multiples of 4 so the 4x4 fill pattern lands identically either way.
1972        for pattern in [0u16, 0b1010_0101_1010_0101] {
1973            let got = drawn_within_deadline("a rectfill spanning the i32 range", move |fb| {
1974                fb.set_fill_pattern(pattern, 12, false);
1975                fb.rectfill(
1976                    i32::MIN / 2,
1977                    i32::MIN / 2,
1978                    i32::MAX / 2 + 1,
1979                    i32::MAX / 2 + 1,
1980                    7,
1981                );
1982            });
1983            let mut want = Framebuffer::new();
1984            want.set_fill_pattern(pattern, 12, false);
1985            want.rectfill(0, 0, WIDTH - 1, HEIGHT - 1, 7);
1986            assert_same_pixels(&got, &want, &format!("pattern {pattern:#06x}"));
1987        }
1988    }
1989
1990    /// `oval_impl` as it was before the sweep was clipped.
1991    fn oval_unclipped(
1992        fb: &mut Framebuffer,
1993        x0: i32,
1994        y0: i32,
1995        x1: i32,
1996        y1: i32,
1997        color: u8,
1998        fill: bool,
1999    ) {
2000        let (xa, xb) = (x0.min(x1), x0.max(x1));
2001        let (ya, yb) = (y0.min(y1), y0.max(y1));
2002        let cx = (xa + xb) as f32 / 2.0;
2003        let cy = (ya + yb) as f32 / 2.0;
2004        let a = (xb - xa) as f32 / 2.0;
2005        let b = (yb - ya) as f32 / 2.0;
2006        let half_extent = |v: f32, half: f32| -> Option<f32> {
2007            let d = if half > 0.0 { v / half } else { 0.0 };
2008            let s = 1.0 - d * d;
2009            if s < 0.0 {
2010                None
2011            } else {
2012                Some(s.sqrt())
2013            }
2014        };
2015        if fill {
2016            for y in ya..=yb {
2017                let Some(s) = half_extent(y as f32 - cy, b) else {
2018                    continue;
2019                };
2020                let left = (cx - a * s).round() as i32;
2021                let right = (cx + a * s).round() as i32;
2022                if fb.fill_pattern == 0 {
2023                    fb.fill_span(
2024                        left - fb.camera_x,
2025                        right - fb.camera_x,
2026                        y - fb.camera_y,
2027                        color,
2028                    );
2029                } else {
2030                    for x in left..=right {
2031                        fb.raw_pset_fill(x - fb.camera_x, y - fb.camera_y, color);
2032                    }
2033                }
2034            }
2035        } else {
2036            for y in ya..=yb {
2037                let Some(s) = half_extent(y as f32 - cy, b) else {
2038                    continue;
2039                };
2040                fb.pset((cx - a * s).round() as i32, y, color);
2041                fb.pset((cx + a * s).round() as i32, y, color);
2042            }
2043            for x in xa..=xb {
2044                let Some(s) = half_extent(x as f32 - cx, a) else {
2045                    continue;
2046                };
2047                fb.pset(x, (cy - b * s).round() as i32, color);
2048                fb.pset(x, (cy + b * s).round() as i32, color);
2049            }
2050        }
2051    }
2052
2053    #[test]
2054    fn oval_clipping_matches_the_unclipped_walk() {
2055        let boxes = [
2056            ("fully on screen", (10, 10, 60, 40)),
2057            ("over the top-left", (-30, -25, 30, 20)),
2058            ("over the bottom-right", (90, 80, 190, 200)),
2059            ("entirely off screen", (300, 300, 380, 360)),
2060            ("bigger than the screen", (-200, -200, 320, 320)),
2061            ("a degenerate line", (20, 50, 90, 50)),
2062        ];
2063        for pattern in [0u16, 0b1010_0101_1010_0101] {
2064            for fill in [false, true] {
2065                for (shape, (x0, y0, x1, y1)) in boxes {
2066                    for state in CLIP_STATES {
2067                        let (state_name, mut got, mut want) = (
2068                            state.name,
2069                            staged_fb(state.camera, state.clip, &PEN_STATES[0]),
2070                            staged_fb(state.camera, state.clip, &PEN_STATES[0]),
2071                        );
2072                        got.set_fill_pattern(pattern, 12, false);
2073                        want.set_fill_pattern(pattern, 12, false);
2074                        if fill {
2075                            got.ovalfill(x0, y0, x1, y1, 7);
2076                        } else {
2077                            got.oval(x0, y0, x1, y1, 7);
2078                        }
2079                        oval_unclipped(&mut want, x0, y0, x1, y1, 7, fill);
2080                        assert_same_pixels(
2081                            &got,
2082                            &want,
2083                            &format!(
2084                                "{shape} / {state_name} / fill {fill} / pattern {pattern:#06x}"
2085                            ),
2086                        );
2087                    }
2088                }
2089            }
2090        }
2091    }
2092
2093    #[test]
2094    fn oval_bounded_by_the_screen_not_the_request() {
2095        // A bounding box spanning most of the i32 range is billions of rows
2096        // unclipped. The screen sits deep inside the ellipse, so the fill covers it
2097        // completely while the outline runs a billion pixels away on every side.
2098        let (lo, hi) = (i32::MIN / 2 + 1, i32::MAX / 2);
2099        let filled = drawn_within_deadline("an ovalfill spanning the i32 range", move |fb| {
2100            fb.ovalfill(lo, lo, hi, hi, 7);
2101        });
2102        assert!(
2103            filled.pixels().iter().all(|&p| p == 7),
2104            "the screen is well inside the ellipse"
2105        );
2106        let outline = drawn_within_deadline("an oval spanning the i32 range", move |fb| {
2107            fb.oval(lo, lo, hi, hi, 7);
2108        });
2109        assert!(
2110            outline.pixels().iter().all(|&p| p == 0),
2111            "the outline never comes near the screen"
2112        );
2113    }
2114
2115    /// `circle_impl` as it was before the arc was rejected and its patterned fill
2116    /// runs were clipped.
2117    fn circle_unclipped(fb: &mut Framebuffer, cx: i32, cy: i32, r: i32, color: u8, fill: bool) {
2118        let (cx, cy) = (cx - fb.camera_x, cy - fb.camera_y);
2119        let (mut x, mut y, mut err) = (r.max(0), 0, 1 - r.max(0));
2120        while x >= y {
2121            if fill && fb.fill_pattern == 0 {
2122                fb.fill_span(cx - x, cx + x, cy + y, color);
2123                fb.fill_span(cx - x, cx + x, cy - y, color);
2124                fb.fill_span(cx - y, cx + y, cy + x, color);
2125                fb.fill_span(cx - y, cx + y, cy - x, color);
2126            } else if fill {
2127                for px in (cx - x)..=(cx + x) {
2128                    fb.raw_pset_fill(px, cy + y, color);
2129                    fb.raw_pset_fill(px, cy - y, color);
2130                }
2131                for px in (cx - y)..=(cx + y) {
2132                    fb.raw_pset_fill(px, cy + x, color);
2133                    fb.raw_pset_fill(px, cy - x, color);
2134                }
2135            } else {
2136                for (px, py) in [
2137                    (cx + x, cy + y),
2138                    (cx - x, cy + y),
2139                    (cx + x, cy - y),
2140                    (cx - x, cy - y),
2141                    (cx + y, cy + x),
2142                    (cx - y, cy + x),
2143                    (cx + y, cy - x),
2144                    (cx - y, cy - x),
2145                ] {
2146                    fb.raw_pset(px, py, color);
2147                }
2148            }
2149            y += 1;
2150            if err < 0 {
2151                err += 2 * y + 1;
2152            } else {
2153                x -= 1;
2154                err += 2 * (y - x) + 1;
2155            }
2156        }
2157    }
2158
2159    #[test]
2160    fn circle_clipping_matches_the_unclipped_walk() {
2161        let circles = [
2162            ("centered", (64, 64, 30)),
2163            ("over the top-left", (-5, -8, 24)),
2164            ("over the bottom-right", (130, 120, 40)),
2165            ("entirely off screen", (400, 400, 50)),
2166            ("bigger than the screen", (64, 64, 300)),
2167            ("zero radius", (64, 64, 0)),
2168            ("negative radius", (64, 64, -9)),
2169        ];
2170        for pattern in [0u16, 0b1010_0101_1010_0101] {
2171            for fill in [false, true] {
2172                for (shape, (cx, cy, r)) in circles {
2173                    for state in CLIP_STATES {
2174                        let (state_name, mut got, mut want) = (
2175                            state.name,
2176                            staged_fb(state.camera, state.clip, &PEN_STATES[0]),
2177                            staged_fb(state.camera, state.clip, &PEN_STATES[0]),
2178                        );
2179                        got.set_fill_pattern(pattern, 12, false);
2180                        want.set_fill_pattern(pattern, 12, false);
2181                        if fill {
2182                            got.circfill(cx, cy, r, 7);
2183                        } else {
2184                            got.circ(cx, cy, r, 7);
2185                        }
2186                        circle_unclipped(&mut want, cx, cy, r, 7, fill);
2187                        assert_same_pixels(
2188                            &got,
2189                            &want,
2190                            &format!(
2191                                "{shape} / {state_name} / fill {fill} / pattern {pattern:#06x}"
2192                            ),
2193                        );
2194                    }
2195                }
2196            }
2197        }
2198    }
2199
2200    /// Draw one circle both ways under a given camera, clip rect and fill pattern,
2201    /// and assert the results are pixel-identical.
2202    fn assert_circle_matches(
2203        cx: i32,
2204        cy: i32,
2205        r: i32,
2206        fill: bool,
2207        pattern: u16,
2208        state: &ClipState,
2209    ) {
2210        let (mut got, mut want) = (
2211            staged_fb(state.camera, state.clip, &PEN_STATES[0]),
2212            staged_fb(state.camera, state.clip, &PEN_STATES[0]),
2213        );
2214        got.set_fill_pattern(pattern, 12, false);
2215        want.set_fill_pattern(pattern, 12, false);
2216        if fill {
2217            got.circfill(cx, cy, r, 7);
2218        } else {
2219            got.circ(cx, cy, r, 7);
2220        }
2221        circle_unclipped(&mut want, cx, cy, r, 7, fill);
2222        assert_same_pixels(
2223            &got,
2224            &want,
2225            &format!(
2226                "({cx}, {cy}) r {r} / {} / fill {fill} / pattern {pattern:#06x}",
2227                state.name
2228            ),
2229        );
2230    }
2231
2232    #[test]
2233    fn circle_seeded_walk_matches_the_full_walk_at_every_small_radius() {
2234        // The clipped walk restarts the arc from `arc_x` rather than stepping it from
2235        // `y = 0`, so check the two agree at every radius up to a screen's worth, with
2236        // the centre inside the clip rect, on its corners and outside it.
2237        for r in 0..=130 {
2238            for (cx, cy) in [(64, 64), (0, 0), (-30, 70), (140, -12), (127, 127)] {
2239                for fill in [false, true] {
2240                    assert_circle_matches(cx, cy, r, fill, 0, &CLIP_STATES[0]);
2241                }
2242            }
2243        }
2244    }
2245
2246    #[test]
2247    fn circle_seeded_walk_matches_the_full_walk_at_a_large_radius() {
2248        // Radii large enough that the runs the walk visits are a sliver of the arc,
2249        // but still small enough to step in full for comparison. The centres put each
2250        // family of octants across the screen in turn: the middle of the arc for the
2251        // shallow ones, and either pole for the steep ones, which is the case the
2252        // filled walk has to invert `arc_x` to find.
2253        for r in [9_001, 120_000] {
2254            for (cx, cy) in [
2255                (64, 64),
2256                (64, 64 + r),
2257                (64, 64 - r),
2258                (64 + r, 64),
2259                (64 - r, 64),
2260                (40 - r, 90 + r),
2261            ] {
2262                for fill in [false, true] {
2263                    for state in &CLIP_STATES {
2264                        // Solid only: the reference's patterned fill walks the whole
2265                        // disc a pixel at a time, quadratic in `r` at this size.
2266                        assert_circle_matches(cx, cy, r, fill, 0, state);
2267                    }
2268                }
2269            }
2270        }
2271    }
2272
2273    #[test]
2274    fn circle_bounded_by_the_screen_not_the_radius() {
2275        // At this radius the full arc is 1.5e9 steps for the one fuel unit the host
2276        // call costs — three seconds of wall clock the meter never sees. Bounded by
2277        // the clip rect it is a few hundred, so the deadline is the assertion; the
2278        // geometry checks come along to show the right few hundred were walked.
2279        let r = 2_000_000_000;
2280        let filled = drawn_within_deadline("a circfill swallowing the screen", move |fb| {
2281            fb.circfill(64, 64, r, 7);
2282        });
2283        assert!(
2284            filled.pixels().iter().all(|&p| p == 7),
2285            "the screen is well inside the disc"
2286        );
2287        let outline = drawn_within_deadline("a circ swallowing the screen", move |fb| {
2288            fb.circ(64, 64, r, 7);
2289        });
2290        assert!(
2291            outline.pixels().iter().all(|&p| p == 0),
2292            "the ring runs a billion pixels off every edge"
2293        );
2294
2295        // Centre far below the screen, so the top of the circle just touches it: here
2296        // it is the steep octants that cross the clip rect.
2297        let capped = drawn_within_deadline("a circfill touching the screen at a pole", move |fb| {
2298            fb.circfill(64, 64 + r, r, 7);
2299        });
2300        assert_eq!(capped.pget(64, 64), 7, "the top of the disc is on screen");
2301        assert!(
2302            capped.pixels()[..(64 * WIDTH) as usize]
2303                .iter()
2304                .all(|&p| p == 0),
2305            "nothing above the disc is touched"
2306        );
2307        assert!(
2308            capped.pixels()[(127 * WIDTH) as usize..]
2309                .iter()
2310                .all(|&p| p == 7),
2311            "the bottom row is deep inside the disc"
2312        );
2313        let arc = drawn_within_deadline("a circ touching the screen at a pole", move |fb| {
2314            fb.circ(64, 64 + r, r, 7);
2315        });
2316        assert_eq!(arc.pget(64, 64), 7, "the top of the ring is on screen");
2317        assert!(
2318            arc.pixels()[..(64 * WIDTH) as usize]
2319                .iter()
2320                .all(|&p| p == 0),
2321            "nothing above the ring is touched"
2322        );
2323    }
2324
2325    #[test]
2326    fn circle_that_cannot_reach_the_clip_rect_is_dropped() {
2327        // The radius is the cart's to pick. A circle whose bounding box misses the
2328        // clip rect must be rejected outright instead of walking an arc
2329        // proportional to it (and overflowing the coordinates on the way).
2330        let mut fb = Framebuffer::new();
2331        fb.circ(2_000_000_000, 0, 1_000_000_000, 7);
2332        fb.circfill(0, -2_000_000_000, 1_000_000_000, 7);
2333        assert!(
2334            fb.pixels().iter().all(|&p| p == 0),
2335            "nothing on screen was touched"
2336        );
2337    }
2338
2339    #[test]
2340    fn print_pen_matches_print_at_cursor() {
2341        let mut a = Framebuffer::new();
2342        let mut b = Framebuffer::new();
2343        a.set_pen_color(9);
2344        a.set_cursor(10, 20);
2345        let end_a = a.print_pen("hi");
2346        let end_b = b.print("hi", 10, 20, 9);
2347        assert_eq!(end_a, end_b);
2348        assert_eq!(
2349            a.pixels(),
2350            b.pixels(),
2351            "print_pen draws identically to print"
2352        );
2353    }
2354
2355    #[test]
2356    fn print_pen_advances_cursor_one_line() {
2357        let mut fb = Framebuffer::new();
2358        fb.set_cursor(5, 5);
2359        fb.print_pen("x");
2360        fb.print_pen("y");
2361        let mut expect = Framebuffer::new();
2362        expect.print("x", 5, 5, 6); // default pen color is 6
2363        expect.print("y", 5, 5 + font::GLYPH_H, 6);
2364        assert_eq!(fb.pixels(), expect.pixels());
2365    }
2366}