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        for (chunk, &c) in out.chunks_exact_mut(4).zip(self.pixels.iter()) {
88            chunk.copy_from_slice(&lut[(c & 0x0f) as usize]);
89        }
90    }
91
92    /// Set the camera offset applied to all subsequent draw operations.
93    pub fn camera(&mut self, x: i32, y: i32) {
94        self.camera_x = x;
95        self.camera_y = y;
96    }
97
98    /// Restrict drawing to a screen-space rectangle.
99    pub fn clip(&mut self, x: i32, y: i32, w: i32, h: i32) {
100        let x0 = x.clamp(0, WIDTH);
101        let y0 = y.clamp(0, HEIGHT);
102        let x1 = (x + w).clamp(0, WIDTH);
103        let y1 = (y + h).clamp(0, HEIGHT);
104        self.clip = (x0, y0, x1, y1);
105    }
106
107    /// Remove the clip rectangle.
108    pub fn clip_reset(&mut self) {
109        self.clip = (0, 0, WIDTH, HEIGHT);
110    }
111
112    /// Reset camera and clip to defaults (used between host UI and cart frames).
113    pub fn reset_state(&mut self) {
114        self.camera_x = 0;
115        self.camera_y = 0;
116        self.clip_reset();
117        self.draw_pal = IDENTITY_PALETTE;
118        self.display_pal = IDENTITY_PALETTE;
119        self.transparent = DEFAULT_TRANSPARENT;
120        self.fill_pattern = 0;
121        self.fill_secondary = 0;
122        self.fill_transparent = false;
123        self.pen_color = 6;
124        self.cursor_x = 0;
125        self.cursor_y = 0;
126    }
127
128    /// Make a palette color transparent (or opaque) for sprite draws.
129    pub fn set_transparent_color(&mut self, color: u8, transparent: bool) {
130        let bit = 1u16 << (color & 0x0f);
131        if transparent {
132            self.transparent |= bit;
133        } else {
134            self.transparent &= !bit;
135        }
136    }
137
138    /// Reset transparency to the default (only color 0 transparent).
139    pub fn reset_transparency(&mut self) {
140        self.transparent = DEFAULT_TRANSPARENT;
141    }
142
143    /// Remap a draw-palette color: later draws of `from` are written as `to`.
144    pub fn remap_color(&mut self, from: u8, to: u8) {
145        self.draw_pal[(from & 0x0f) as usize] = to & 0x0f;
146    }
147
148    /// Remap a display-palette color: `from` is shown as `to` at upload time.
149    pub fn remap_display_color(&mut self, from: u8, to: u8) {
150        self.display_pal[(from & 0x0f) as usize] = to & 0x0f;
151    }
152
153    /// Reset both the draw and display palettes to identity.
154    pub fn reset_palette(&mut self) {
155        self.draw_pal = IDENTITY_PALETTE;
156        self.display_pal = IDENTITY_PALETTE;
157    }
158
159    /// Configure the fill pattern for the filled shape primitives. `pattern` is
160    /// a 4x4 bitmask (bit 15 = top-left). Pattern-0 pixels take the shape's
161    /// color; pattern-1 pixels take `secondary`, or are skipped when
162    /// `transparent`. A `pattern` of 0 fills solid.
163    pub fn set_fill_pattern(&mut self, pattern: u16, secondary: u8, transparent: bool) {
164        self.fill_pattern = pattern;
165        self.fill_secondary = secondary & 0x0f;
166        self.fill_transparent = transparent;
167    }
168
169    /// The color a fill should write at framebuffer pixel `(x, y)`, or `None`
170    /// when the transparent pattern skips it. `x`/`y` are post-camera.
171    fn fill_color_at(&self, x: i32, y: i32, primary: u8) -> Option<u8> {
172        if self.fill_pattern == 0 {
173            return Some(primary);
174        }
175        let idx = ((y & 3) * 4 + (x & 3)) as u16;
176        if (self.fill_pattern >> (15 - idx)) & 1 == 0 {
177            Some(primary)
178        } else if self.fill_transparent {
179            None
180        } else {
181            Some(self.fill_secondary)
182        }
183    }
184
185    /// Like `raw_pset` but honoring the fill pattern. `x`/`y` are post-camera.
186    fn raw_pset_fill(&mut self, x: i32, y: i32, primary: u8) {
187        if let Some(c) = self.fill_color_at(x, y, primary) {
188            self.raw_pset(x, y, c);
189        }
190    }
191
192    /// Fill a solid horizontal run on row `y` from `x0..=x1` (inclusive,
193    /// POST-camera), clipped to the clip rect. Applies the draw palette. Used by
194    /// the solid (non-patterned) fill path: clipping the span once and writing
195    /// it as one memset is far cheaper than clipping every pixel.
196    fn fill_span(&mut self, x0: i32, x1: i32, y: i32, color: u8) {
197        let (cx0, cy0, cx1, cy1) = self.clip;
198        if y < cy0 || y >= cy1 {
199            return;
200        }
201        let xa = x0.max(cx0);
202        let xb = x1.min(cx1 - 1);
203        if xa > xb {
204            return;
205        }
206        let c = self.draw_pal[(color & 0x0f) as usize] & 0x0f;
207        let start = (y * WIDTH + xa) as usize;
208        let end = (y * WIDTH + xb + 1) as usize;
209        self.pixels[start..end].fill(c);
210    }
211
212    /// Fill the whole screen with a color. Does not touch camera/clip.
213    pub fn cls(&mut self, color: u8) {
214        self.pixels.fill(color & 0x0f);
215    }
216
217    #[inline]
218    fn raw_pset(&mut self, x: i32, y: i32, color: u8) {
219        let (cx0, cy0, cx1, cy1) = self.clip;
220        if x >= cx0 && x < cx1 && y >= cy0 && y < cy1 {
221            let c = self.draw_pal[(color & 0x0f) as usize] & 0x0f;
222            self.pixels[(y * WIDTH + x) as usize] = c;
223        }
224    }
225
226    /// Set one pixel (camera-relative, like all draw ops).
227    pub fn pset(&mut self, x: i32, y: i32, color: u8) {
228        self.raw_pset(x - self.camera_x, y - self.camera_y, color);
229    }
230
231    /// Read one pixel in screen space. Out-of-bounds reads return 0.
232    pub fn pget(&self, x: i32, y: i32) -> u8 {
233        if (0..WIDTH).contains(&x) && (0..HEIGHT).contains(&y) {
234            self.pixels[(y * WIDTH + x) as usize]
235        } else {
236            0
237        }
238    }
239
240    /// Bresenham line between two points, inclusive.
241    pub fn line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: u8) {
242        let (mut x0, mut y0) = (x0 - self.camera_x, y0 - self.camera_y);
243        let (x1, y1) = (x1 - self.camera_x, y1 - self.camera_y);
244        let dx = (x1 - x0).abs();
245        let dy = -(y1 - y0).abs();
246        let sx = if x0 < x1 { 1 } else { -1 };
247        let sy = if y0 < y1 { 1 } else { -1 };
248        let mut err = dx + dy;
249        loop {
250            self.raw_pset(x0, y0, color);
251            if x0 == x1 && y0 == y1 {
252                break;
253            }
254            let e2 = 2 * err;
255            if e2 >= dy {
256                err += dy;
257                x0 += sx;
258            }
259            if e2 <= dx {
260                err += dx;
261                y0 += sy;
262            }
263        }
264    }
265
266    /// Rectangle outline with inclusive corners, like PICO-8's `rect`.
267    pub fn rect(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: u8) {
268        let (xa, xb) = (x0.min(x1), x0.max(x1));
269        let (ya, yb) = (y0.min(y1), y0.max(y1));
270        self.line(xa, ya, xb, ya, color);
271        self.line(xa, yb, xb, yb, color);
272        self.line(xa, ya, xa, yb, color);
273        self.line(xb, ya, xb, yb, color);
274    }
275
276    /// Filled rectangle with inclusive corners.
277    pub fn rectfill(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: u8) {
278        let (xa, xb) = (x0.min(x1), x0.max(x1));
279        let (ya, yb) = (y0.min(y1), y0.max(y1));
280        if self.fill_pattern == 0 {
281            // Solid fill: each row is one clipped memset (xa/xb are pre-camera).
282            for y in ya..=yb {
283                self.fill_span(
284                    xa - self.camera_x,
285                    xb - self.camera_x,
286                    y - self.camera_y,
287                    color,
288                );
289            }
290        } else {
291            for y in ya..=yb {
292                for x in xa..=xb {
293                    self.raw_pset_fill(x - self.camera_x, y - self.camera_y, color);
294                }
295            }
296        }
297    }
298
299    /// Circle outline (midpoint algorithm).
300    pub fn circ(&mut self, cx: i32, cy: i32, r: i32, color: u8) {
301        self.circle_impl(cx, cy, r.max(0), color, false);
302    }
303
304    /// Filled circle.
305    pub fn circfill(&mut self, cx: i32, cy: i32, r: i32, color: u8) {
306        self.circle_impl(cx, cy, r.max(0), color, true);
307    }
308
309    fn circle_impl(&mut self, cx: i32, cy: i32, r: i32, color: u8, fill: bool) {
310        let (cx, cy) = (cx - self.camera_x, cy - self.camera_y);
311        let mut x = r;
312        let mut y = 0;
313        let mut err = 1 - r;
314        while x >= y {
315            if fill && self.fill_pattern == 0 {
316                // Solid fill: each scanline of the disc is one clipped memset
317                // (cx/cy are already post-camera here).
318                self.fill_span(cx - x, cx + x, cy + y, color);
319                self.fill_span(cx - x, cx + x, cy - y, color);
320                self.fill_span(cx - y, cx + y, cy + x, color);
321                self.fill_span(cx - y, cx + y, cy - x, color);
322            } else if fill {
323                for px in (cx - x)..=(cx + x) {
324                    self.raw_pset_fill(px, cy + y, color);
325                    self.raw_pset_fill(px, cy - y, color);
326                }
327                for px in (cx - y)..=(cx + y) {
328                    self.raw_pset_fill(px, cy + x, color);
329                    self.raw_pset_fill(px, cy - x, color);
330                }
331            } else {
332                for (px, py) in [
333                    (cx + x, cy + y),
334                    (cx - x, cy + y),
335                    (cx + x, cy - y),
336                    (cx - x, cy - y),
337                    (cx + y, cy + x),
338                    (cx - y, cy + x),
339                    (cx + y, cy - x),
340                    (cx - y, cy - x),
341                ] {
342                    self.raw_pset(px, py, color);
343                }
344            }
345            y += 1;
346            if err < 0 {
347                err += 2 * y + 1;
348            } else {
349                x -= 1;
350                err += 2 * (y - x) + 1;
351            }
352        }
353    }
354
355    /// Ellipse outline within the inclusive bounding box `(x0,y0)-(x1,y1)`.
356    pub fn oval(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: u8) {
357        self.oval_impl(x0, y0, x1, y1, color, false);
358    }
359
360    /// Filled ellipse within the inclusive bounding box `(x0,y0)-(x1,y1)`.
361    pub fn ovalfill(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: u8) {
362        self.oval_impl(x0, y0, x1, y1, color, true);
363    }
364
365    fn oval_impl(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: u8, fill: bool) {
366        let (xa, xb) = (x0.min(x1), x0.max(x1));
367        let (ya, yb) = (y0.min(y1), y0.max(y1));
368        let cx = (xa + xb) as f32 / 2.0;
369        let cy = (ya + yb) as f32 / 2.0;
370        let a = (xb - xa) as f32 / 2.0;
371        let b = (yb - ya) as f32 / 2.0;
372        if fill {
373            for y in ya..=yb {
374                let dy = if b > 0.0 { (y as f32 - cy) / b } else { 0.0 };
375                let s = 1.0 - dy * dy;
376                if s < 0.0 {
377                    continue;
378                }
379                let dx = a * s.sqrt();
380                let left = (cx - dx).round() as i32;
381                let right = (cx + dx).round() as i32;
382                if self.fill_pattern == 0 {
383                    // Solid fill: one clipped memset per scanline of the oval.
384                    self.fill_span(
385                        left - self.camera_x,
386                        right - self.camera_x,
387                        y - self.camera_y,
388                        color,
389                    );
390                } else {
391                    for x in left..=right {
392                        self.raw_pset_fill(x - self.camera_x, y - self.camera_y, color);
393                    }
394                }
395            }
396        } else {
397            // Plot the extremes along each axis so the outline has no gaps.
398            for y in ya..=yb {
399                let dy = if b > 0.0 { (y as f32 - cy) / b } else { 0.0 };
400                let s = 1.0 - dy * dy;
401                if s < 0.0 {
402                    continue;
403                }
404                let dx = a * s.sqrt();
405                self.pset((cx - dx).round() as i32, y, color);
406                self.pset((cx + dx).round() as i32, y, color);
407            }
408            for x in xa..=xb {
409                let dx = if a > 0.0 { (x as f32 - cx) / a } else { 0.0 };
410                let s = 1.0 - dx * dx;
411                if s < 0.0 {
412                    continue;
413                }
414                let dy = b * s.sqrt();
415                self.pset(x, (cy - dy).round() as i32, color);
416                self.pset(x, (cy + dy).round() as i32, color);
417            }
418        }
419    }
420
421    /// Print text with the built-in font. Returns the x position after the
422    /// last character.
423    pub fn print(&mut self, text: &str, x: i32, y: i32, color: u8) -> i32 {
424        let mut cx = x;
425        let mut cy = y;
426        for ch in text.chars() {
427            if ch == '\n' {
428                cx = x;
429                cy += font::GLYPH_H;
430                continue;
431            }
432            let rows = font::glyph(ch);
433            for (ry, row) in rows.iter().enumerate() {
434                for rx in 0..3 {
435                    if row & (0b100 >> rx) != 0 {
436                        self.pset(cx + rx, cy + ry as i32, color);
437                    }
438                }
439            }
440            cx += font::GLYPH_W;
441        }
442        cx
443    }
444
445    /// Set the persistent pen color used by `print_pen`.
446    pub fn set_pen_color(&mut self, color: u8) {
447        self.pen_color = color & 0x0f;
448    }
449
450    /// Set the persistent text cursor used by `print_pen`.
451    pub fn set_cursor(&mut self, x: i32, y: i32) {
452        self.cursor_x = x;
453        self.cursor_y = y;
454    }
455
456    /// Print at the cursor in the pen color, then advance the cursor one line
457    /// down. Returns the x position after the last glyph.
458    pub fn print_pen(&mut self, text: &str) -> i32 {
459        let (x, y) = (self.cursor_x, self.cursor_y);
460        let end = self.print(text, x, y, self.pen_color);
461        self.cursor_y = y + font::GLYPH_H;
462        end
463    }
464
465    /// Draw sprite `n` (and the `w x h`-pixel block to its right and below)
466    /// from a sheet. Color 0 is transparent, matching the classic default.
467    /// `w`/`h` are pixel extents: `w = 4` draws a 4-pixel-wide slice.
468    #[allow(clippy::too_many_arguments)]
469    pub fn spr(
470        &mut self,
471        sheet: &SpriteSheet,
472        n: u32,
473        x: i32,
474        y: i32,
475        w: i32,
476        h: i32,
477        flip_x: bool,
478        flip_y: bool,
479    ) {
480        // `w`/`h` are pixel extents; a partial last cell is clipped mid-sprite.
481        let pw = w.max(0);
482        let ph = h.max(0);
483
484        // Clip the destination rectangle to the clip rect once, up front, then
485        // walk only the visible sub-rectangle. This skips the per-pixel clip
486        // test entirely and fast-rejects fully off-screen sprites (a big win
487        // for `map`, which calls `spr` once per tile). The destination spans
488        // `[dx0, dx0 + pw)` x `[dy0, dy0 + ph)` in post-camera space; the `px`
489        // range is where that lands inside `[cx0, cx1)`.
490        let (dx0, dy0) = (x - self.camera_x, y - self.camera_y);
491        let (cx0, cy0, cx1, cy1) = self.clip;
492        let px_lo = (cx0 - dx0).max(0);
493        let px_hi = (cx1 - dx0).min(pw);
494        let py_lo = (cy0 - dy0).max(0);
495        let py_hi = (cy1 - dy0).min(ph);
496        if px_lo >= px_hi || py_lo >= py_hi {
497            return;
498        }
499
500        // Decode the sprite's sheet origin once instead of per pixel. The flip
501        // still mirrors about the FULL sprite extent (`pw`/`ph`), and source
502        // reads may run past 8 into neighboring sprites for multi-sprite draws,
503        // exactly as `sprite_pixel` does.
504        let n = (n as usize) % SPRITE_COUNT;
505        let base_sx = (n % SPRITES_PER_ROW * SPRITE_SIZE) as i32;
506        let base_sy = (n / SPRITES_PER_ROW * SPRITE_SIZE) as i32;
507        for py in py_lo..py_hi {
508            let sy = if flip_y { ph - 1 - py } else { py };
509            for px in px_lo..px_hi {
510                let sx = if flip_x { pw - 1 - px } else { px };
511                let c = sheet.get(base_sx + sx, base_sy + sy);
512                if ((self.transparent >> c) & 1) == 0 {
513                    // In bounds by construction: `px`/`py` lie within the clip.
514                    let px_dst = dx0 + px;
515                    let py_dst = dy0 + py;
516                    self.pixels[(py_dst * WIDTH + px_dst) as usize] =
517                        self.draw_pal[(c & 0x0f) as usize] & 0x0f;
518                }
519            }
520        }
521    }
522
523    /// Draw a sheet rectangle `(sx,sy,sw,sh)` stretched into a screen rectangle
524    /// `(dx,dy,dw,dh)` with nearest-neighbor sampling. Honors per-color
525    /// transparency and the draw palette.
526    #[allow(clippy::too_many_arguments)]
527    pub fn sspr(
528        &mut self,
529        sheet: &SpriteSheet,
530        sx: i32,
531        sy: i32,
532        sw: i32,
533        sh: i32,
534        dx: i32,
535        dy: i32,
536        dw: i32,
537        dh: i32,
538        flip_x: bool,
539        flip_y: bool,
540    ) {
541        if sw <= 0 || sh <= 0 || dw <= 0 || dh <= 0 {
542            return;
543        }
544        for py in 0..dh {
545            for px in 0..dw {
546                let fx = if flip_x { dw - 1 - px } else { px };
547                let fy = if flip_y { dh - 1 - py } else { py };
548                let c = sheet.get(sx + fx * sw / dw, sy + fy * sh / dh);
549                if ((self.transparent >> c) & 1) == 0 {
550                    self.pset(dx + px, dy + py, c);
551                }
552            }
553        }
554    }
555
556    /// Draw a region of the tile map. `layers` is a flag mask: when nonzero,
557    /// only tiles whose flags intersect the mask are drawn. Tile 0 is empty.
558    #[allow(clippy::too_many_arguments)]
559    pub fn map(
560        &mut self,
561        map: &MapData,
562        sheet: &SpriteSheet,
563        cel_x: i32,
564        cel_y: i32,
565        sx: i32,
566        sy: i32,
567        cel_w: i32,
568        cel_h: i32,
569        layers: u8,
570    ) {
571        for ty in 0..cel_h {
572            for tx in 0..cel_w {
573                let tile = map.get(cel_x + tx, cel_y + ty);
574                if tile == 0 {
575                    continue;
576                }
577                if layers != 0 && sheet.flags(tile as u32) & layers == 0 {
578                    continue;
579                }
580                self.spr(
581                    sheet,
582                    tile as u32,
583                    sx + tx * SPRITE_SIZE as i32,
584                    sy + ty * SPRITE_SIZE as i32,
585                    SPRITE_SIZE as i32,
586                    SPRITE_SIZE as i32,
587                    false,
588                    false,
589                );
590            }
591        }
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn cls_fills_screen() {
601        let mut fb = Framebuffer::new();
602        fb.cls(7);
603        assert!(fb.pixels().iter().all(|&p| p == 7));
604    }
605
606    #[test]
607    fn pset_pget_roundtrip() {
608        let mut fb = Framebuffer::new();
609        fb.pset(10, 20, 8);
610        assert_eq!(fb.pget(10, 20), 8);
611        assert_eq!(fb.pget(11, 20), 0);
612    }
613
614    #[test]
615    fn out_of_bounds_is_safe() {
616        let mut fb = Framebuffer::new();
617        fb.pset(-1, 0, 5);
618        fb.pset(0, 99999, 5);
619        fb.line(-50, -50, 200, 200, 6);
620        fb.circfill(0, 0, 300, 3);
621        assert_eq!(fb.pget(-1, 0), 0);
622    }
623
624    #[test]
625    fn camera_offsets_draws() {
626        let mut fb = Framebuffer::new();
627        fb.camera(10, 0);
628        fb.pset(15, 5, 9);
629        assert_eq!(fb.pget(5, 5), 9);
630        fb.reset_state();
631        fb.pset(15, 5, 9);
632        assert_eq!(fb.pget(15, 5), 9);
633    }
634
635    #[test]
636    fn clip_constrains_drawing() {
637        let mut fb = Framebuffer::new();
638        fb.clip(0, 0, 4, 4);
639        fb.rectfill(0, 0, 127, 127, 7);
640        assert_eq!(fb.pget(3, 3), 7);
641        assert_eq!(fb.pget(4, 4), 0);
642    }
643
644    #[test]
645    fn rect_outline_is_hollow() {
646        let mut fb = Framebuffer::new();
647        fb.rect(0, 0, 4, 4, 7);
648        assert_eq!(fb.pget(0, 0), 7);
649        assert_eq!(fb.pget(4, 4), 7);
650        assert_eq!(fb.pget(2, 2), 0);
651    }
652
653    #[test]
654    fn print_advances_cursor() {
655        let mut fb = Framebuffer::new();
656        let end = fb.print("abc", 0, 0, 7);
657        assert_eq!(end, 3 * font::GLYPH_W);
658    }
659
660    #[test]
661    fn partial_pixel_sprite_draws_a_partial_slice() {
662        let mut fb = Framebuffer::new();
663        let mut sheet = SpriteSheet::default();
664        // Fill sprite 0 (the top-left 8x8 cell) solid.
665        for y in 0..8 {
666            for x in 0..8 {
667                sheet.set(x, y, 7);
668            }
669        }
670        // A 4px width draws only the left four columns.
671        fb.spr(&sheet, 0, 0, 0, 4, 8, false, false);
672        assert_eq!(fb.pget(3, 4), 7, "left half is drawn");
673        assert_eq!(fb.pget(4, 4), 0, "right half is untouched");
674        assert_eq!(fb.pget(7, 7), 0, "bottom-right corner is untouched");
675    }
676
677    #[test]
678    fn spr_clipped_flip_mirrors_about_full_sprite() {
679        // Flip must mirror about the FULL 8x8 sprite, then the clip rect cuts
680        // the result — not the other way round. Mark the four source corners
681        // with distinct colors; double-flip swaps each corner to the opposite
682        // one, and a 4x4 top-left clip should keep exactly one of them.
683        let mut fb = Framebuffer::new();
684        let mut sheet = SpriteSheet::default();
685        sheet.set(0, 0, 8); // top-left  -> dest (7,7)
686        sheet.set(7, 0, 9); // top-right -> dest (0,7)
687        sheet.set(0, 7, 11); // bottom-left  -> dest (7,0)
688        sheet.set(7, 7, 12); // bottom-right -> dest (0,0)
689        fb.clip(0, 0, 4, 4);
690        fb.spr(&sheet, 0, 0, 0, 8, 8, true, true);
691        assert_eq!(
692            fb.pget(0, 0),
693            12,
694            "source bottom-right mirrors to dest (0,0) and survives the clip"
695        );
696        assert_eq!(fb.pget(7, 7), 0, "dest (7,7) is outside the 4x4 clip");
697        assert_eq!(fb.pget(0, 7), 0, "dest (0,7) is outside the 4x4 clip");
698        assert_eq!(fb.pget(7, 0), 0, "dest (7,0) is outside the 4x4 clip");
699    }
700
701    #[test]
702    fn spr_partly_offscreen_under_camera_aligns_source() {
703        // With the camera pushing the sprite up-and-left, only its bottom-right
704        // part is on screen; the visible pixels must come from the matching
705        // source columns/rows (no wrap), starting at dest (0,0).
706        let mut fb = Framebuffer::new();
707        let mut sheet = SpriteSheet::default();
708        for y in 0..8 {
709            for x in 0..8 {
710                sheet.set(x, y, 7);
711            }
712        }
713        sheet.set(2, 3, 9); // the source pixel that lands on dest (0,0)
714        fb.camera(2, 3);
715        fb.spr(&sheet, 0, 0, 0, 8, 8, false, false);
716        assert_eq!(
717            fb.pget(0, 0),
718            9,
719            "source (2,3) lands at the top-left corner"
720        );
721        assert_eq!(fb.pget(1, 0), 7, "source (3,3) is the next column");
722        assert_eq!(fb.pget(5, 4), 7, "the rest of the on-screen part is drawn");
723        // Nothing wrapped to the far edges of the screen.
724        assert_eq!(fb.pget(127, 127), 0, "no wrap to the opposite corner");
725    }
726
727    #[test]
728    fn spr_partial_pixel_width_meets_clip_edge() {
729        // A 4px-wide sprite slice further trimmed by a 2px-wide clip: only the
730        // first two destination columns survive.
731        let mut fb = Framebuffer::new();
732        let mut sheet = SpriteSheet::default();
733        for y in 0..8 {
734            for x in 0..8 {
735                sheet.set(x, y, 7);
736            }
737        }
738        fb.clip(0, 0, 2, 128);
739        fb.spr(&sheet, 0, 0, 0, 4, 8, false, false);
740        assert_eq!(fb.pget(1, 3), 7, "inside the clip and the partial width");
741        assert_eq!(fb.pget(2, 3), 0, "clipped away at x=2");
742        assert_eq!(fb.pget(4, 3), 0, "beyond the 4px width anyway");
743    }
744
745    #[test]
746    fn transparency_mask_controls_sprite_pixels() {
747        let mut fb = Framebuffer::new();
748        let mut sheet = SpriteSheet::default();
749        for y in 0..8 {
750            for x in 0..8 {
751                sheet.set(x, y, 8); // a solid red sprite
752            }
753        }
754        // Default: nonzero colors draw.
755        fb.spr(&sheet, 0, 0, 0, 8, 8, false, false);
756        assert_eq!(fb.pget(1, 1), 8);
757        // Make red transparent: redrawing over green leaves green showing.
758        fb.cls(3);
759        fb.set_transparent_color(8, true);
760        fb.spr(&sheet, 0, 0, 0, 8, 8, false, false);
761        assert_eq!(fb.pget(1, 1), 3, "red made transparent");
762        // reset_transparency restores the default; red draws again.
763        fb.reset_transparency();
764        fb.spr(&sheet, 0, 0, 0, 8, 8, false, false);
765        assert_eq!(fb.pget(1, 1), 8);
766    }
767
768    #[test]
769    fn color_zero_can_be_made_opaque() {
770        let mut fb = Framebuffer::new();
771        let sheet = SpriteSheet::default(); // all color 0
772        fb.cls(7);
773        fb.set_transparent_color(0, false);
774        fb.spr(&sheet, 0, 0, 0, 8, 8, false, false);
775        assert_eq!(fb.pget(3, 3), 0, "color 0 now drawn over white");
776    }
777
778    #[test]
779    fn draw_palette_remaps_writes() {
780        let mut fb = Framebuffer::new();
781        fb.remap_color(8, 12); // draw red as blue
782        fb.pset(5, 5, 8);
783        assert_eq!(fb.pget(5, 5), 12);
784        fb.reset_palette();
785        fb.pset(6, 6, 8);
786        assert_eq!(fb.pget(6, 6), 8);
787    }
788
789    #[test]
790    fn cls_ignores_draw_palette() {
791        let mut fb = Framebuffer::new();
792        fb.remap_color(0, 8);
793        fb.cls(0);
794        assert_eq!(fb.pget(10, 10), 0, "cls clears to the literal color");
795    }
796
797    #[test]
798    fn display_palette_remaps_at_upload() {
799        let mut fb = Framebuffer::new();
800        fb.pset(0, 0, 8); // red stored
801        fb.remap_display_color(8, 12); // show red as blue
802        let mut out = vec![0u8; (WIDTH * HEIGHT * 4) as usize];
803        fb.write_rgba(&mut out);
804        assert_eq!(&out[0..4], &palette::rgba(12), "pixel uploaded as blue");
805        assert_eq!(fb.pget(0, 0), 8, "stored index is unchanged");
806    }
807
808    #[test]
809    fn ovalfill_fills_center_not_corner() {
810        let mut fb = Framebuffer::new();
811        fb.ovalfill(0, 0, 10, 6, 7);
812        assert_eq!(fb.pget(5, 3), 7, "center filled");
813        assert_eq!(fb.pget(0, 0), 0, "bounding-box corner stays empty");
814    }
815
816    #[test]
817    fn oval_outline_is_hollow() {
818        let mut fb = Framebuffer::new();
819        fb.oval(0, 0, 10, 10, 7);
820        assert_eq!(fb.pget(5, 0), 7, "top of the outline is set");
821        assert_eq!(fb.pget(5, 5), 0, "center is hollow");
822    }
823
824    #[test]
825    fn two_color_fill_pattern_alternates() {
826        let mut fb = Framebuffer::new();
827        // bit 15 (top-left) = 1, bit 14 = 0, ...
828        fb.set_fill_pattern(0b1010_0101_1010_0101, 12, false);
829        fb.rectfill(0, 0, 3, 3, 7);
830        assert_eq!(
831            fb.pget(0, 0),
832            12,
833            "pattern-1 pixel uses the secondary color"
834        );
835        assert_eq!(fb.pget(1, 0), 7, "pattern-0 pixel uses the primary color");
836    }
837
838    #[test]
839    fn transparent_fill_pattern_skips_pixels() {
840        let mut fb = Framebuffer::new();
841        fb.cls(3);
842        fb.set_fill_pattern(0xffff, 0, true); // every pixel is pattern-1, transparent
843        fb.rectfill(0, 0, 3, 3, 7);
844        assert_eq!(
845            fb.pget(1, 1),
846            3,
847            "all pattern-1 pixels skipped; background shows"
848        );
849    }
850
851    #[test]
852    fn zero_pattern_fills_solid() {
853        let mut fb = Framebuffer::new();
854        fb.set_fill_pattern(0, 0, false);
855        fb.rectfill(0, 0, 3, 3, 7);
856        assert_eq!(fb.pget(2, 2), 7);
857    }
858
859    #[test]
860    fn sspr_upscales_with_nearest_neighbor() {
861        let mut fb = Framebuffer::new();
862        let mut sheet = SpriteSheet::default();
863        sheet.set(0, 0, 8); // single red source pixel
864        fb.sspr(&sheet, 0, 0, 1, 1, 10, 10, 4, 4, false, false);
865        assert_eq!(fb.pget(10, 10), 8);
866        assert_eq!(
867            fb.pget(13, 13),
868            8,
869            "the whole 4x4 block is the source pixel"
870        );
871    }
872
873    #[test]
874    fn sspr_respects_transparency() {
875        let mut fb = Framebuffer::new();
876        let sheet = SpriteSheet::default(); // all color 0
877        fb.cls(3);
878        fb.sspr(&sheet, 0, 0, 2, 2, 0, 0, 4, 4, false, false);
879        assert_eq!(fb.pget(1, 1), 3, "color 0 is transparent by default");
880    }
881
882    #[test]
883    fn sspr_flips_horizontally() {
884        let mut fb = Framebuffer::new();
885        let mut sheet = SpriteSheet::default();
886        sheet.set(0, 0, 8);
887        sheet.set(1, 0, 9);
888        fb.sspr(&sheet, 0, 0, 2, 1, 0, 0, 2, 1, true, false);
889        assert_eq!(
890            fb.pget(1, 0),
891            8,
892            "flip puts the source-left pixel on the right"
893        );
894        assert_eq!(fb.pget(0, 0), 9);
895    }
896
897    #[test]
898    fn print_pen_matches_print_at_cursor() {
899        let mut a = Framebuffer::new();
900        let mut b = Framebuffer::new();
901        a.set_pen_color(9);
902        a.set_cursor(10, 20);
903        let end_a = a.print_pen("hi");
904        let end_b = b.print("hi", 10, 20, 9);
905        assert_eq!(end_a, end_b);
906        assert_eq!(
907            a.pixels(),
908            b.pixels(),
909            "print_pen draws identically to print"
910        );
911    }
912
913    #[test]
914    fn print_pen_advances_cursor_one_line() {
915        let mut fb = Framebuffer::new();
916        fb.set_cursor(5, 5);
917        fb.print_pen("x");
918        fb.print_pen("y");
919        let mut expect = Framebuffer::new();
920        expect.print("x", 5, 5, 6); // default pen color is 6
921        expect.print("y", 5, 5 + font::GLYPH_H, 6);
922        assert_eq!(fb.pixels(), expect.pixels());
923    }
924}