use core::marker::PhantomData;
use crate::palette::{ColorIndex, GbColor, GbaColor, Palette, GRAYSCALE_PALETTE};
use crate::tile::{Tile, TILE_PIXELS};
use dotzuki_engine::render::Rgba;
use dotzuki_engine::render_config::RenderConfig;
pub const SCREEN_WIDTH: usize = 160;
pub const SCREEN_HEIGHT: usize = 144;
pub const fn index_bits<C: ColorIndex>() -> usize {
let bits = C::MAX.ilog2();
if bits < 1 {
1
} else {
bits as usize
}
}
const fn groups_per_row(width: usize) -> usize {
(width + 7) / 8
}
pub const fn packed_len<C: ColorIndex>(width: usize, height: usize) -> usize {
height * groups_per_row(width) * index_bits::<C>()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexedFrameBuffer<C: ColorIndex = GbColor, const LINEAR: bool = false> {
data: AlignedBytes,
width: usize,
height: usize,
#[doc(hidden)]
_phantom: PhantomData<C>,
}
#[inline(always)]
fn scroll_chunky_row(row: &mut [u8], dx: i32, clear: u8) {
debug_assert!(dx != 0 && (dx.unsigned_abs() as usize) < row.len());
let offset = dx.unsigned_abs() as usize;
let copy_width = row.len() - offset;
let source_x = if dx < 0 { offset } else { 0 };
let target_x = if dx > 0 { offset } else { 0 };
let row_ptr = row.as_mut_ptr();
if cfg!(target_endian = "little")
&& row.len() & 3 == 0
&& row_ptr as usize & 3 == 0
&& offset & 3 == 2
{
let words = row.len() / 4;
let word_offset = offset / 4;
let full_words = copy_width / 4;
let row_words = row_ptr as *mut u32;
if dx < 0 {
for target_word in 0..full_words {
let source_word = word_offset + target_word;
let lower = unsafe { row_words.add(source_word).read() };
let upper = unsafe { row_words.add(source_word + 1).read() };
unsafe {
row_words
.add(target_word)
.write((lower >> 16) | (upper << 16));
}
}
let tail = unsafe { row_words.add(words - 1).read() } >> 16;
unsafe { (row_ptr.add(full_words * 4) as *mut u16).write(tail as u16) };
row[copy_width..].fill(clear);
} else {
for source_word in (0..full_words).rev() {
let lower = unsafe { row_words.add(source_word).read() };
let upper = unsafe { row_words.add(source_word + 1).read() };
unsafe {
row_words
.add(word_offset + 1 + source_word)
.write((lower >> 16) | (upper << 16));
}
}
let prefix = unsafe { row_words.read() } as u16;
unsafe { (row_ptr.add(offset) as *mut u16).write(prefix) };
row[..offset].fill(clear);
}
return;
}
let source_address = unsafe { row_ptr.add(source_x) } as usize;
let target_address = unsafe { row_ptr.add(target_x) } as usize;
if (source_address | target_address | copy_width) & 3 == 0 {
let words = copy_width / 4;
let source = unsafe { row_ptr.add(source_x) as *const u32 };
let target = unsafe { row_ptr.add(target_x) as *mut u32 };
if dx > 0 {
for word in (0..words).rev() {
unsafe { target.add(word).write(source.add(word).read()) };
}
} else {
for word in 0..words {
unsafe { target.add(word).write(source.add(word).read()) };
}
}
} else if (source_address | target_address | copy_width) & 1 == 0 {
let halfwords = copy_width / 2;
let source = unsafe { row_ptr.add(source_x) as *const u16 };
let target = unsafe { row_ptr.add(target_x) as *mut u16 };
if dx > 0 {
for halfword in (0..halfwords).rev() {
unsafe { target.add(halfword).write(source.add(halfword).read()) };
}
} else {
for halfword in 0..halfwords {
unsafe { target.add(halfword).write(source.add(halfword).read()) };
}
}
} else if dx > 0 {
for byte in (0..copy_width).rev() {
unsafe {
row_ptr
.add(target_x + byte)
.write(row_ptr.add(source_x + byte).read())
};
}
} else {
for byte in 0..copy_width {
unsafe {
row_ptr
.add(target_x + byte)
.write(row_ptr.add(source_x + byte).read())
};
}
}
if dx > 0 {
row[..target_x].fill(clear);
} else {
row[copy_width..].fill(clear);
}
}
fn copy_chunky_rect_within(
pixels: &mut [u8],
framebuffer_width: usize,
source_x: usize,
source_y: usize,
copy_width: usize,
copy_height: usize,
destination_x: usize,
destination_y: usize,
) {
debug_assert!(framebuffer_width != 0);
debug_assert!(pixels.len() % framebuffer_width == 0);
debug_assert!(source_x + copy_width <= framebuffer_width);
debug_assert!(destination_x + copy_width <= framebuffer_width);
debug_assert!((source_y + copy_height) * framebuffer_width <= pixels.len());
debug_assert!((destination_y + copy_height) * framebuffer_width <= pixels.len());
if destination_y > source_y {
for row in (0..copy_height).rev() {
let source = (source_y + row) * framebuffer_width + source_x;
let destination = (destination_y + row) * framebuffer_width + destination_x;
pixels.copy_within(source..source + copy_width, destination);
}
} else {
for row in 0..copy_height {
let source = (source_y + row) * framebuffer_width + source_x;
let destination = (destination_y + row) * framebuffer_width + destination_x;
pixels.copy_within(source..source + copy_width, destination);
}
}
}
impl<C: ColorIndex, const LINEAR: bool> IndexedFrameBuffer<C, LINEAR> {
pub fn new(width: usize, height: usize, clear: C) -> Self {
let len = if LINEAR {
width * height
} else {
packed_len::<C>(width, height)
};
let data = AlignedBytes {
words: vec![0; (len + 3) / 4],
len,
};
let mut fb = Self {
data,
width,
height,
_phantom: PhantomData,
};
fb.clear(clear);
fb
}
#[inline]
pub const fn width(&self) -> usize {
self.width
}
#[inline]
pub const fn height(&self) -> usize {
self.height
}
#[inline]
pub fn len(&self) -> usize {
self.width * self.height
}
#[inline]
pub fn is_empty(&self) -> bool {
self.width == 0 || self.height == 0
}
#[inline]
pub fn clear(&mut self, color: C) {
if LINEAR {
self.clear_linear(color)
} else {
self.clear_packed(color)
}
}
#[inline]
fn clear_linear(&mut self, color: C) {
self.data
.words
.fill(u32::from_ne_bytes([color.to_index() as u8; 4]));
}
#[inline]
fn clear_packed(&mut self, color: C) {
let value = color.to_index();
let bits = index_bits::<C>();
if value == 0 || value + 1 == C::MAX {
self.data.fill(if value == 0 { 0x00 } else { 0xFF });
return;
}
for (i, byte) in self.data.iter_mut().enumerate() {
let plane = i % bits;
*byte = if (value >> plane) & 1 == 1 {
0xFF
} else {
0x00
};
}
}
#[inline]
pub fn set_pixel(&mut self, x: u32, y: u32, color: C) -> bool {
if LINEAR {
self.set_pixel_linear(x, y, color)
} else {
self.set_pixel_packed(x, y, color)
}
}
#[inline]
fn set_pixel_linear(&mut self, x: u32, y: u32, color: C) -> bool {
if x >= self.width as u32 || y >= self.height as u32 {
return false;
}
let index = y as usize * self.width + x as usize;
unsafe {
(self.data.as_mut_ptr() as *mut u8)
.add(index)
.write(color.to_index() as u8);
}
true
}
#[inline]
fn set_pixel_packed(&mut self, x: u32, y: u32, color: C) -> bool {
if x >= self.width as u32 || y >= self.height as u32 {
return false;
}
let value = color.to_index();
let bits = index_bits::<C>();
let group = (x as usize) / 8;
let bit = 7 - ((x as usize) % 8);
let base = ((y as usize) * groups_per_row(self.width) + group) * bits;
for plane in 0..bits {
let plane_bit = ((value >> plane) & 1) as u8;
let byte = &mut self.data[base + plane];
*byte = (*byte & !(1 << bit)) | (plane_bit << bit);
}
true
}
#[inline]
pub fn get_pixel(&self, x: u32, y: u32) -> Option<C> {
if LINEAR {
self.get_pixel_linear(x, y)
} else {
self.get_pixel_packed(x, y)
}
}
#[inline]
fn get_pixel_linear(&self, x: u32, y: u32) -> Option<C> {
if x >= self.width as u32 || y >= self.height as u32 {
return None;
}
let index = y as usize * self.width + x as usize;
let value = unsafe { (self.data.as_ptr() as *const u8).add(index).read() };
Some(C::from_u8(value))
}
#[inline]
fn get_pixel_packed(&self, x: u32, y: u32) -> Option<C> {
if x >= self.width as u32 || y >= self.height as u32 {
return None;
}
let bits = index_bits::<C>();
let group = (x as usize) / 8;
let bit = 7 - ((x as usize) % 8);
let base = ((y as usize) * groups_per_row(self.width) + group) * bits;
let mut value = 0usize;
for plane in 0..bits {
if (self.data[base + plane] >> bit) & 1 == 1 {
value |= 1 << plane;
}
}
Some(C::from_u8(value as u8))
}
#[inline]
pub fn fill_rect(&mut self, x: u32, y: u32, rect_width: u32, rect_height: u32, color: C) {
if LINEAR {
self.fill_rect_linear(x, y, rect_width, rect_height, color)
} else {
self.fill_rect_packed(x, y, rect_width, rect_height, color)
}
}
#[inline]
fn fill_rect_linear(&mut self, x: u32, y: u32, rect_width: u32, rect_height: u32, color: C) {
let x_start = (x as usize).min(self.width);
let y_start = (y as usize).min(self.height);
let x_end = (x.saturating_add(rect_width) as usize).min(self.width);
let y_end = (y.saturating_add(rect_height) as usize).min(self.height);
let value = color.to_index() as u8;
let pixels = unsafe {
core::slice::from_raw_parts_mut(self.data.as_mut_ptr() as *mut u8, self.len())
};
for row in y_start..y_end {
pixels[row * self.width + x_start..row * self.width + x_end].fill(value);
}
}
pub fn scroll(&mut self, dx: i32, dy: i32, clear: C) {
if dx == 0 && dy == 0 {
return;
}
if self.is_empty()
|| dx.unsigned_abs() as usize >= self.width
|| dy.unsigned_abs() as usize >= self.height
{
self.clear(clear);
return;
}
if LINEAR {
{
let width = self.width;
let height = self.height;
let copy_width = width - dx.unsigned_abs() as usize;
let source_x = if dx < 0 {
dx.unsigned_abs() as usize
} else {
0
};
let target_x = if dx > 0 { dx as usize } else { 0 };
let clear_value = clear.to_index() as u8;
let pixels = unsafe {
core::slice::from_raw_parts_mut(
self.data.as_mut_ptr() as *mut u8,
width * height,
)
};
if dx == 0 {
if dy > 0 {
let offset = dy as usize;
pixels.copy_within(0..(height - offset) * width, offset * width);
pixels[..offset * width].fill(clear_value);
} else {
let offset = dy.unsigned_abs() as usize;
pixels.copy_within(offset * width..height * width, 0);
pixels[(height - offset) * width..].fill(clear_value);
}
return;
}
if dy == 0 {
for y in 0..height {
let start = y * width;
scroll_chunky_row(&mut pixels[start..start + width], dx, clear_value);
}
return;
}
if dy > 0 {
let offset = dy as usize;
for source_y in (0..height - offset).rev() {
let target_y = source_y + offset;
let source = source_y * width + source_x;
let target = target_y * width + target_x;
pixels.copy_within(source..source + copy_width, target);
if dx > 0 {
pixels[target_y * width..target_y * width + target_x].fill(clear_value);
} else if dx < 0 {
pixels[target + copy_width..(target_y + 1) * width].fill(clear_value);
}
}
pixels[..offset * width].fill(clear_value);
} else {
let offset = dy.unsigned_abs() as usize;
for source_y in offset..height {
let target_y = source_y - offset;
let source = source_y * width + source_x;
let target = target_y * width + target_x;
pixels.copy_within(source..source + copy_width, target);
if dx > 0 {
pixels[target_y * width..target_y * width + target_x].fill(clear_value);
} else if dx < 0 {
pixels[target + copy_width..(target_y + 1) * width].fill(clear_value);
}
}
pixels[(height - offset) * width..].fill(clear_value);
}
}
}
if !LINEAR {
{
let source = self.clone();
self.clear(clear);
for y in 0..self.height as i32 {
let source_y = y - dy;
if source_y < 0 || source_y >= self.height as i32 {
continue;
}
for x in 0..self.width as i32 {
let source_x = x - dx;
if source_x < 0 || source_x >= self.width as i32 {
continue;
}
let color = source
.get_pixel(source_x as u32, source_y as u32)
.expect("scroll source is in bounds");
self.set_pixel(x as u32, y as u32, color);
}
}
}
}
}
pub fn copy_rect_from(
&mut self,
other: &Self,
x: u32,
y: u32,
rect_width: u32,
rect_height: u32,
) {
assert_eq!(self.width, other.width, "framebuffer width mismatch");
assert_eq!(self.height, other.height, "framebuffer height mismatch");
let x_start = (x as usize).min(self.width);
let y_start = (y as usize).min(self.height);
let x_end = (x.saturating_add(rect_width) as usize).min(self.width);
let y_end = (y.saturating_add(rect_height) as usize).min(self.height);
if x_start >= x_end || y_start >= y_end {
return;
}
if LINEAR {
{
let copy_width = x_end - x_start;
let destination = self.data.as_mut_ptr().cast::<u8>();
let source = other.data.as_ptr().cast::<u8>();
for row in y_start..y_end {
let offset = row * self.width + x_start;
unsafe {
core::ptr::copy_nonoverlapping(
source.add(offset),
destination.add(offset),
copy_width,
);
}
}
}
}
if !LINEAR {
for row in y_start..y_end {
for column in x_start..x_end {
let color = other
.get_pixel(column as u32, row as u32)
.expect("copy source is in bounds");
self.set_pixel(column as u32, row as u32, color);
}
}
}
}
pub fn copy_rect_within(
&mut self,
source_x: u32,
source_y: u32,
rect_width: u32,
rect_height: u32,
destination_x: u32,
destination_y: u32,
) {
let source_x = (source_x as usize).min(self.width);
let source_y = (source_y as usize).min(self.height);
let destination_x = (destination_x as usize).min(self.width);
let destination_y = (destination_y as usize).min(self.height);
let copy_width = (rect_width as usize)
.min(self.width - source_x)
.min(self.width - destination_x);
let copy_height = (rect_height as usize)
.min(self.height - source_y)
.min(self.height - destination_y);
if copy_width == 0 || copy_height == 0 {
return;
}
if LINEAR {
{
let pixels = unsafe {
core::slice::from_raw_parts_mut(
self.data.as_mut_ptr().cast::<u8>(),
self.width * self.height,
)
};
copy_chunky_rect_within(
pixels,
self.width,
source_x,
source_y,
copy_width,
copy_height,
destination_x,
destination_y,
);
}
}
if !LINEAR {
{
let source = self.clone();
for row in 0..copy_height {
for column in 0..copy_width {
let color = source
.get_pixel((source_x + column) as u32, (source_y + row) as u32)
.expect("copy source is in bounds");
self.set_pixel(
(destination_x + column) as u32,
(destination_y + row) as u32,
color,
);
}
}
}
}
}
#[inline]
fn fill_rect_packed(&mut self, x: u32, y: u32, rect_width: u32, rect_height: u32, color: C) {
let x_start = (x as usize).min(self.width);
let y_start = (y as usize).min(self.height);
let x_end = (x.saturating_add(rect_width) as usize).min(self.width);
let y_end = (y.saturating_add(rect_height) as usize).min(self.height);
if x_start >= x_end || y_start >= y_end {
return;
}
let value = color.to_index();
let bits = index_bits::<C>();
let groups = groups_per_row(self.width);
let first_group = x_start / 8;
let last_group = (x_end - 1) / 8;
for row in y_start..y_end {
for group in first_group..=last_group {
let group_x = group * 8;
let from = x_start.saturating_sub(group_x).min(8);
let until = x_end.saturating_sub(group_x).min(8);
let mask = (0xFFu8 >> from) & (0xFFu8 << (8 - until));
let base = (row * groups + group) * bits;
for plane in 0..bits {
let fill = if (value >> plane) & 1 == 1 {
0xFF
} else {
0x00
};
if mask == 0xFF {
self.data[base + plane] = fill;
} else if fill == 0xFF {
self.data[base + plane] |= mask;
} else {
self.data[base + plane] &= !mask;
}
}
}
}
}
pub fn to_rgba(&self, palette: &Palette<C>, out: &mut [u8]) -> bool {
let need = self.width * self.height * 4;
if out.len() < need {
return false;
}
let mut base = 0;
for y in 0..self.height {
for x in 0..self.width {
let index = self.get_pixel(x as u32, y as u32).expect("pixel in bounds");
out[base..base + 4].copy_from_slice(&palette.color(index).to_array());
base += 4;
}
}
true
}
}
impl<C: ColorIndex, const LINEAR: bool> Default for IndexedFrameBuffer<C, LINEAR> {
fn default() -> Self {
Self::new(SCREEN_WIDTH, SCREEN_HEIGHT, C::from_u8(0))
}
}
pub fn quantize<C: ColorIndex>(palette: &Palette<C>, color: Rgba) -> C {
let mut best = C::from_u8(0);
let mut best_dist = u32::MAX;
for i in 0..palette.count as usize {
let entry = palette.colors[i];
let dr = entry.r as i32 - color.r as i32;
let dg = entry.g as i32 - color.g as i32;
let db = entry.b as i32 - color.b as i32;
let da = entry.a as i32 - color.a as i32;
let dist = (dr * dr + dg * dg + db * db + da * da) as u32;
if dist < best_dist {
best_dist = dist;
best = C::from_u8(i as u8);
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn linear_and_packed_storage_agree_through_clipped_moves_and_blits() {
for (width, height) in [(0, 0), (1, 1), (5, 7), (16, 16), (19, 13)] {
let config = RenderConfig::new(width, height);
let mut packed = RgbaIndexedFrameBuffer::<GbColor>::new(config.clone(), Rgba::WHITE);
let mut linear = LinearRgbaIndexedFrameBuffer::<GbColor>::new(config, Rgba::WHITE);
assert_eq!(linear.indices().len(), width as usize * height as usize);
assert_eq!(linear.indices().as_ptr() as usize % 4, 0);
let equal = |p: &RgbaIndexedFrameBuffer, l: &LinearRgbaIndexedFrameBuffer| {
for y in 0..height {
for x in 0..width {
assert_eq!(p.get_index(x, y), l.get_index(x, y), "at {x},{y}");
}
}
};
for i in 0..40u32 {
let color = GbColor::from_u8((i % 4) as u8);
packed.indexed_mut().fill_rect(i % 21, i % 15, 9, 4, color);
linear.indexed_mut().fill_rect(i % 21, i % 15, 9, 4, color);
let dx = i as i32 % 7 - 3;
let dy = i as i32 % 5 - 2;
packed.scroll_indices(dx, dy, color);
linear.scroll_indices(dx, dy, color);
packed.copy_rect_within(1, 1, 11, 8, i % 4, i % 3);
linear.copy_rect_within(1, 1, 11, 8, i % 4, i % 3);
equal(&packed, &linear);
let tile = Tile::from_2bpp(&[
0x59, 0xA7, 0x18, 0xF0, 0xC3, 0x55, 0x0F, 0x88, 0xCC, 0x55, 0x87, 0xE1, 0x44,
0x23, 0xF8, 0x62,
]);
packed.blit_gb_tile_indices(dx, dy, &tile, i % 2 == 0, i % 3 == 0, i % 5 == 0);
linear.blit_gb_tile_indices(dx, dy, &tile, i % 2 == 0, i % 3 == 0, i % 5 == 0);
equal(&packed, &linear);
}
}
}
use crate::palette::{GbaColor, GRAYSCALE_PALETTE, GRAYSCALE_SPRITE_PALETTE};
use crate::tile::Tile;
#[test]
fn storage_sizes() {
assert_eq!(packed_len::<GbColor>(SCREEN_WIDTH, SCREEN_HEIGHT), 5760);
assert_eq!(packed_len::<GbColor>(160, 144), 5760);
assert_eq!(packed_len::<GbaColor>(160, 144), 11520);
assert_eq!(index_bits::<GbColor>(), 2);
assert_eq!(index_bits::<GbaColor>(), 4);
let fb = IndexedFrameBuffer::<GbColor>::new(160, 144, GbColor::White);
assert_eq!(fb.packed().len(), 5760);
let gba = IndexedFrameBuffer::<GbaColor>::new(160, 144, GbaColor(0));
assert_eq!(gba.packed().len(), 11520);
}
#[test]
fn default_is_screen_sized_cleared() {
let fb = IndexedFrameBuffer::<GbColor>::default();
assert_eq!(fb.width(), SCREEN_WIDTH);
assert_eq!(fb.height(), SCREEN_HEIGHT);
assert_eq!(fb.len(), 160 * 144);
assert_eq!(fb.get_pixel(0, 0), Some(GbColor::White));
assert_eq!(fb.get_pixel(159, 143), Some(GbColor::White));
let gba = IndexedFrameBuffer::<GbaColor>::default();
assert_eq!(gba.get_pixel(159, 143), Some(GbaColor(0)));
}
#[test]
fn packing_round_trip_gb() {
let mut fb = IndexedFrameBuffer::<GbColor>::new(16, 8, GbColor::White);
let pattern = [
GbColor::White,
GbColor::LightGray,
GbColor::DarkGray,
GbColor::Black,
];
for y in 0..8u32 {
for x in 0..16u32 {
fb.set_pixel(x, y, pattern[((x + y) as usize) % 4]);
}
}
for y in 0..8u32 {
for x in 0..16u32 {
assert_eq!(
fb.get_pixel(x, y),
Some(pattern[((x + y) as usize) % 4]),
"mismatch at ({x}, {y})"
);
}
}
}
#[test]
fn packing_round_trip_gba() {
let mut fb = IndexedFrameBuffer::<GbaColor>::new(8, 4, GbaColor(0));
for y in 0..4u32 {
for x in 0..8u32 {
fb.set_pixel(x, y, GbaColor(((x * 3 + y * 5) % 16) as u8));
}
}
for y in 0..4u32 {
for x in 0..8u32 {
assert_eq!(
fb.get_pixel(x, y),
Some(GbaColor(((x * 3 + y * 5) % 16) as u8))
);
}
}
}
#[test]
fn packing_round_trip_non_multiple_of_8() {
let mut fb = IndexedFrameBuffer::<GbColor>::new(10, 7, GbColor::White);
for y in 0..7u32 {
for x in 0..10u32 {
fb.set_pixel(x, y, GbColor::from_u8(((x + y) % 4) as u8));
}
}
for y in 0..7u32 {
for x in 0..10u32 {
assert_eq!(
fb.get_pixel(x, y),
Some(GbColor::from_u8(((x + y) % 4) as u8))
);
}
}
assert_eq!(fb.get_pixel(10, 0), None);
assert_eq!(fb.get_pixel(0, 7), None);
}
#[test]
fn packed_layout_is_gb_vram_bitplanes() {
let mut fb = IndexedFrameBuffer::<GbColor>::new(8, 1, GbColor::White);
let row = [1u8, 0, 3, 0, 2, 0, 1, 0];
for (x, &v) in row.iter().enumerate() {
fb.set_pixel(x as u32, 0, GbColor::from_u8(v));
}
assert_eq!(fb.packed(), &[0xA2, 0x28]);
}
#[test]
fn packed_data_feeds_tile_decoder() {
let mut fb = IndexedFrameBuffer::<GbColor>::new(8, 8, GbColor::White);
for y in 0..8u32 {
for x in 0..8u32 {
fb.set_pixel(x, y, GbColor::from_u8(((x * y) % 4) as u8));
}
}
let tile = Tile::from_2bpp(fb.packed());
for y in 0..8 {
for x in 0..8 {
assert_eq!(tile.pixels[y][x], ((x * y) % 4) as u8);
}
}
}
#[test]
fn bounds_are_checked() {
let mut fb = IndexedFrameBuffer::<GbColor>::new(8, 4, GbColor::White);
assert!(fb.set_pixel(7, 3, GbColor::Black));
assert!(!fb.set_pixel(8, 0, GbColor::Black));
assert!(!fb.set_pixel(0, 4, GbColor::Black));
assert!(!fb.set_pixel(u32::MAX, 0, GbColor::Black));
assert_eq!(fb.get_pixel(8, 0), None);
assert_eq!(fb.get_pixel(0, 4), None);
assert_eq!(fb.get_pixel(7, 3), Some(GbColor::Black));
}
#[test]
fn clear_fills_every_pixel() {
let mut fb = IndexedFrameBuffer::<GbColor>::new(10, 7, GbColor::Black);
fb.fill_rect(0, 0, 10, 7, GbColor::LightGray);
assert_eq!(fb.get_pixel(5, 3), Some(GbColor::LightGray));
fb.clear(GbColor::Black);
for y in 0..7u32 {
for x in 0..10u32 {
assert_eq!(fb.get_pixel(x, y), Some(GbColor::Black));
}
}
assert_eq!(fb.packed(), &[0xFF; packed_len::<GbColor>(10, 7)]);
}
#[test]
fn fill_rect_clamps_to_bounds() {
let mut fb = IndexedFrameBuffer::<GbColor>::new(8, 8, GbColor::White);
fb.fill_rect(4, 4, 100, 100, GbColor::Black);
assert_eq!(fb.get_pixel(3, 3), Some(GbColor::White));
assert_eq!(fb.get_pixel(4, 3), Some(GbColor::White));
assert_eq!(fb.get_pixel(3, 4), Some(GbColor::White));
assert_eq!(fb.get_pixel(4, 4), Some(GbColor::Black));
assert_eq!(fb.get_pixel(7, 7), Some(GbColor::Black));
fb.fill_rect(8, 8, 4, 4, GbColor::DarkGray);
assert_eq!(fb.get_pixel(7, 7), Some(GbColor::Black));
}
#[test]
fn fill_rect_preserves_pixels_outside_partial_groups() {
let mut fb = IndexedFrameBuffer::<GbColor>::new(19, 3, GbColor::White);
fb.fill_rect(3, 1, 13, 1, GbColor::DarkGray);
for y in 0..3 {
for x in 0..19 {
let expected = if y == 1 && (3..16).contains(&x) {
GbColor::DarkGray
} else {
GbColor::White
};
assert_eq!(fb.get_pixel(x, y), Some(expected), "({x}, {y})");
}
}
}
#[test]
fn chunky_row_scroll_matches_reference_for_every_offset() {
let mut storage = [0u32; 10];
let storage_bytes = unsafe {
core::slice::from_raw_parts_mut(storage.as_mut_ptr() as *mut u8, storage.len() * 4)
};
for width in [37, storage_bytes.len()] {
let row = &mut storage_bytes[..width];
let original: Vec<u8> = (0..row.len() as u8).collect();
for dx in -(row.len() as i32 - 1)..row.len() as i32 {
if dx == 0 {
continue;
}
row.copy_from_slice(&original);
scroll_chunky_row(row, dx, 0xff);
for (x, &actual) in row.iter().enumerate() {
let source_x = x as i32 - dx;
let expected = if (0..original.len() as i32).contains(&source_x) {
original[source_x as usize]
} else {
0xff
};
assert_eq!(actual, expected, "width {width}, offset {dx}, pixel {x}");
}
}
}
}
#[test]
fn scroll_moves_pixels_and_clears_exposed_edges() {
let mut original = IndexedFrameBuffer::<GbColor>::new(5, 4, GbColor::White);
for y in 0..4 {
for x in 0..5 {
original.set_pixel(x, y, GbColor::from_u8(((y * 5 + x) % 4) as u8));
}
}
for (dx, dy) in [(2, 1), (-2, -1), (1, -2), (-1, 2)] {
let mut shifted = original.clone();
shifted.scroll(dx, dy, GbColor::Black);
for y in 0..4i32 {
for x in 0..5i32 {
let source_x = x - dx;
let source_y = y - dy;
let expected = if (0..5).contains(&source_x) && (0..4).contains(&source_y) {
original
.get_pixel(source_x as u32, source_y as u32)
.unwrap()
} else {
GbColor::Black
};
assert_eq!(
shifted.get_pixel(x as u32, y as u32),
Some(expected),
"offset ({dx}, {dy}), pixel ({x}, {y})"
);
}
}
}
}
#[test]
fn scroll_clears_when_offset_exceeds_dimensions() {
let mut fb = IndexedFrameBuffer::<GbColor>::new(5, 4, GbColor::LightGray);
fb.scroll(5, 0, GbColor::DarkGray);
for y in 0..4 {
for x in 0..5 {
assert_eq!(fb.get_pixel(x, y), Some(GbColor::DarkGray));
}
}
}
#[test]
fn copy_rect_from_preserves_pixels_outside_the_rectangle() {
let mut source = IndexedFrameBuffer::<GbColor>::new(10, 7, GbColor::White);
for y in 0..7 {
for x in 0..10 {
source.set_pixel(x, y, GbColor::from_u8(((x + y * 3) % 4) as u8));
}
}
let mut destination = IndexedFrameBuffer::<GbColor>::new(10, 7, GbColor::Black);
destination.copy_rect_from(&source, 2, 1, 5, 4);
for y in 0..7 {
for x in 0..10 {
let expected = if (2..7).contains(&x) && (1..5).contains(&y) {
source.get_pixel(x, y).unwrap()
} else {
GbColor::Black
};
assert_eq!(destination.get_pixel(x, y), Some(expected), "({x}, {y})");
}
}
}
#[test]
fn copy_rect_within_is_overlap_safe_and_clipped() {
let mut original = IndexedFrameBuffer::<GbColor>::new(10, 7, GbColor::White);
for y in 0..7 {
for x in 0..10 {
original.set_pixel(x, y, GbColor::from_u8(((x + y * 3) % 4) as u8));
}
}
for &(source_x, source_y, width, height, destination_x, destination_y) in &[
(1, 1, 7, 5, 2, 0),
(2, 0, 7, 5, 1, 1),
(0, 0, 10, 6, 0, 1),
(0, 1, 10, 6, 0, 0),
(8, 5, 10, 10, 9, 6),
(20, 20, 4, 4, 0, 0),
] {
let mut actual = original.clone();
actual.copy_rect_within(
source_x,
source_y,
width,
height,
destination_x,
destination_y,
);
let mut expected = original.clone();
let copy_width = width
.min(10u32.saturating_sub(source_x))
.min(10u32.saturating_sub(destination_x));
let copy_height = height
.min(7u32.saturating_sub(source_y))
.min(7u32.saturating_sub(destination_y));
for row in 0..copy_height {
for column in 0..copy_width {
let color = original
.get_pixel(source_x + column, source_y + row)
.unwrap();
expected.set_pixel(destination_x + column, destination_y + row, color);
}
}
assert_eq!(
actual, expected,
"source ({source_x}, {source_y}), destination ({destination_x}, {destination_y})"
);
}
}
#[test]
fn chunky_rect_copy_is_overlap_safe_in_every_direction() {
let original: Vec<u8> = (0..70).map(|index| (index % 251) as u8).collect();
for &(source_x, source_y, width, height, destination_x, destination_y) in &[
(1, 1, 7, 5, 2, 0),
(2, 0, 7, 5, 1, 1),
(0, 0, 10, 6, 0, 1),
(0, 1, 10, 6, 0, 0),
] {
let mut actual = original.clone();
copy_chunky_rect_within(
&mut actual,
10,
source_x,
source_y,
width,
height,
destination_x,
destination_y,
);
let mut expected = original.clone();
for row in 0..height {
for column in 0..width {
expected[(destination_y + row) * 10 + destination_x + column] =
original[(source_y + row) * 10 + source_x + column];
}
}
assert_eq!(actual, expected);
}
}
#[test]
fn to_rgba_applies_palette() {
let mut fb = IndexedFrameBuffer::<GbColor>::new(4, 2, GbColor::White);
fb.set_pixel(0, 0, GbColor::Black);
fb.set_pixel(3, 1, GbColor::DarkGray);
let pal = GRAYSCALE_PALETTE;
let mut out = [0u8; 4 * 2 * 4];
assert!(fb.to_rgba(&pal, &mut out));
assert_eq!(&out[0..4], &Rgba::rgb(0x00, 0x00, 0x00).to_array());
assert_eq!(&out[1 * 4..2 * 4], &Rgba::rgb(0xFF, 0xFF, 0xFF).to_array());
assert_eq!(
&out[(3 + 1 * 4) * 4..(3 + 1 * 4) * 4 + 4],
&Rgba::rgb(0x55, 0x55, 0x55).to_array()
);
}
#[test]
fn to_rgba_rejects_short_slice() {
let fb = IndexedFrameBuffer::<GbColor>::new(4, 2, GbColor::White);
let mut out = [0u8; 4 * 2 * 4 - 1];
assert!(!fb.to_rgba(&GRAYSCALE_PALETTE, &mut out));
assert_eq!(out, [0u8; 4 * 2 * 4 - 1]); }
#[test]
fn quantize_exact_match() {
let pal = GRAYSCALE_PALETTE;
assert_eq!(quantize(&pal, Rgba::rgb(0xFF, 0xFF, 0xFF)), GbColor::White);
assert_eq!(
quantize(&pal, Rgba::rgb(0xAA, 0xAA, 0xAA)),
GbColor::LightGray
);
assert_eq!(
quantize(&pal, Rgba::rgb(0x55, 0x55, 0x55)),
GbColor::DarkGray
);
assert_eq!(quantize(&pal, Rgba::rgb(0x00, 0x00, 0x00)), GbColor::Black);
}
#[test]
fn quantize_picks_nearest() {
let pal = GRAYSCALE_PALETTE;
assert_eq!(quantize(&pal, Rgba::rgb(200, 200, 200)), GbColor::LightGray);
assert_eq!(
quantize(&pal, Rgba::rgb(0x7F, 0x7F, 0x7F)),
GbColor::DarkGray
);
assert_eq!(quantize(&pal, Rgba::rgb(30, 30, 30)), GbColor::Black);
}
#[test]
fn quantize_alpha_aware() {
let pal = GRAYSCALE_SPRITE_PALETTE;
assert_eq!(pal.colors[0], Rgba::TRANSPARENT);
assert_eq!(quantize(&pal, Rgba::rgb(0x00, 0x00, 0x00)), GbColor::Black);
assert_eq!(quantize(&pal, Rgba::TRANSPARENT), GbColor::White);
}
#[test]
fn quantize_gba_palette() {
let mut colors = [Rgba::BLACK; 16];
colors[0] = Rgba::rgb(0xFF, 0x00, 0x00);
colors[1] = Rgba::rgb(0x00, 0xFF, 0x00);
colors[2] = Rgba::rgb(0x00, 0x00, 0xFF);
let pal = Palette::<GbaColor>::from_gba_palette(colors);
assert_eq!(quantize(&pal, Rgba::rgb(0xFF, 0x00, 0x00)), GbaColor(0));
assert_eq!(quantize(&pal, Rgba::rgb(0x00, 0xFF, 0x00)), GbaColor(1));
assert_eq!(quantize(&pal, Rgba::rgb(0x00, 0x00, 0xFF)), GbaColor(2));
assert_eq!(quantize(&pal, Rgba::rgb(0xC0, 0x40, 0x00)), GbaColor(0));
}
}
pub trait DefaultPalette: ColorIndex {
fn default_palette() -> Palette<Self>;
}
impl DefaultPalette for GbColor {
fn default_palette() -> Palette<Self> {
GRAYSCALE_PALETTE
}
}
impl DefaultPalette for GbaColor {
fn default_palette() -> Palette<Self> {
let mut colors = [Rgba::BLACK; 16];
for i in 0..16 {
let v = (255 - i * 17) as u8;
colors[i] = Rgba::rgb(v, v, v);
}
Palette::<GbaColor>::from_gba_palette(colors)
}
}
pub trait FbSurface: Sized {
fn new_screen(width: u32, height: u32) -> Self;
fn width(&self) -> u32;
fn height(&self) -> u32;
fn set_pixel(&mut self, x: u32, y: u32, color: Rgba) -> bool;
fn get_pixel(&self, x: u32, y: u32) -> Option<Rgba>;
fn clear(&mut self, color: Rgba);
fn fill_rect(&mut self, x: u32, y: u32, rect_width: u32, rect_height: u32, color: Rgba);
fn blit_gb_tile(
&mut self,
x: i32,
y: i32,
tile: &Tile,
palette: &Palette,
transparent: bool,
flip_x: bool,
flip_y: bool,
) {
let width = self.width() as i32;
let height = self.height() as i32;
for dst_row in 0..TILE_PIXELS {
let dst_y = y + dst_row as i32;
if dst_y < 0 || dst_y >= height {
continue;
}
let src_row = if flip_y {
TILE_PIXELS - 1 - dst_row
} else {
dst_row
};
for dst_col in 0..TILE_PIXELS {
let dst_x = x + dst_col as i32;
if dst_x < 0 || dst_x >= width {
continue;
}
let src_col = if flip_x {
TILE_PIXELS - 1 - dst_col
} else {
dst_col
};
let color = palette.color(GbColor::from_u8(tile.pixels[src_row][src_col]));
if transparent && color == Rgba::TRANSPARENT {
continue;
}
self.set_pixel(dst_x as u32, dst_y as u32, color);
}
}
}
fn pixel_rgba(&self, x: u32, y: u32) -> Rgba {
self.get_pixel(x, y).unwrap_or(Rgba::TRANSPARENT)
}
fn present_into(&self, out: &mut [u8]);
}
impl FbSurface for dotzuki_engine::render::FrameBuffer {
fn new_screen(width: u32, height: u32) -> Self {
Self::new(RenderConfig::new(width, height), Rgba::BLACK)
}
fn width(&self) -> u32 {
self.width
}
fn height(&self) -> u32 {
self.height
}
fn set_pixel(&mut self, x: u32, y: u32, color: Rgba) -> bool {
Self::set_pixel(self, x, y, color)
}
fn get_pixel(&self, x: u32, y: u32) -> Option<Rgba> {
Self::get_pixel(self, x, y)
}
fn clear(&mut self, color: Rgba) {
Self::clear(self, color)
}
fn fill_rect(&mut self, x: u32, y: u32, rect_width: u32, rect_height: u32, color: Rgba) {
Self::fill_rect(self, x, y, rect_width, rect_height, color)
}
fn present_into(&self, out: &mut [u8]) {
assert!(out.len() >= self.data.len(), "present buffer too small");
out[..self.data.len()].copy_from_slice(&self.data);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RgbaIndexedFrameBuffer<C: ColorIndex = GbColor, const LINEAR: bool = false> {
buffer: IndexedFrameBuffer<C, LINEAR>,
base: Palette<C>,
fast_grayscale: bool,
pub palette: Palette<C>,
}
impl<C: ColorIndex, const LINEAR: bool> RgbaIndexedFrameBuffer<C, LINEAR> {
pub fn with_palette(config: RenderConfig, clear: Rgba, base: Palette<C>) -> Self {
let fast_grayscale = base.count == 4
&& base.colors[0] == Rgba::rgb(0xFF, 0xFF, 0xFF)
&& base.colors[1] == Rgba::rgb(0xAA, 0xAA, 0xAA)
&& base.colors[2] == Rgba::rgb(0x55, 0x55, 0x55)
&& base.colors[3] == Rgba::rgb(0x00, 0x00, 0x00);
let mut fb = Self {
buffer: IndexedFrameBuffer::new(
config.screen_width as usize,
config.screen_height as usize,
C::from_u8(0),
),
palette: base,
base,
fast_grayscale,
};
fb.clear(clear);
fb
}
#[inline]
pub fn display_palette(&self) -> &Palette<C> {
&self.palette
}
pub fn set_palette(&mut self, palette: Palette<C>) {
self.palette = palette;
}
pub fn reset_palette(&mut self) {
self.palette = self.base;
}
pub fn remap_shades(&mut self, map: &[u8]) {
let count = self.palette.count as usize;
for i in 0..count {
let mapped = map.get(i).copied().unwrap_or(i as u8) as usize % count;
self.palette.colors[i] = self.base.colors[mapped];
}
self.palette.count = self.base.count;
}
pub fn scale_shades(&mut self, scale: f32) {
let scale = scale.clamp(0.0, 1.0);
for i in 0..self.palette.count as usize {
let c = self.base.colors[i];
self.palette.colors[i] = Rgba::new(
(c.r as f32 * scale) as u8,
(c.g as f32 * scale) as u8,
(c.b as f32 * scale) as u8,
c.a,
);
}
}
#[inline]
pub fn indexed(&self) -> &IndexedFrameBuffer<C, LINEAR> {
&self.buffer
}
#[inline]
pub fn indexed_mut(&mut self) -> &mut IndexedFrameBuffer<C, LINEAR> {
&mut self.buffer
}
pub fn to_rgba(&self, out: &mut [u8]) -> bool {
self.buffer.to_rgba(&self.palette, out)
}
pub fn copy_from(&mut self, other: &Self) {
self.copy_from_with(other, |destination, source| {
destination.copy_from_slice(source);
});
}
pub fn copy_from_with<F>(&mut self, other: &Self, copy_pixels: F)
where
F: FnOnce(&mut [u8], &[u8]),
{
assert_eq!(self.width(), other.width(), "framebuffer width mismatch");
assert_eq!(self.height(), other.height(), "framebuffer height mismatch");
copy_pixels(self.buffer.bytes_mut(), other.buffer.bytes());
self.palette = other.palette;
self.base = other.base;
self.fast_grayscale = other.fast_grayscale;
}
pub fn copy_rect_from(
&mut self,
other: &Self,
x: u32,
y: u32,
rect_width: u32,
rect_height: u32,
) {
self.buffer
.copy_rect_from(&other.buffer, x, y, rect_width, rect_height);
}
pub fn copy_rect_within(
&mut self,
source_x: u32,
source_y: u32,
rect_width: u32,
rect_height: u32,
destination_x: u32,
destination_y: u32,
) {
self.buffer.copy_rect_within(
source_x,
source_y,
rect_width,
rect_height,
destination_x,
destination_y,
);
}
pub fn scroll_indices(&mut self, dx: i32, dy: i32, clear: C) {
self.buffer.scroll(dx, dy, clear);
}
pub fn set_pixel_index(&mut self, x: u32, y: u32, color: C) -> bool {
self.buffer.set_pixel(x, y, color)
}
pub fn get_index(&self, x: u32, y: u32) -> Option<C> {
self.buffer.get_pixel(x, y)
}
pub fn clear_index(&mut self, color: C) {
self.buffer.clear(color);
}
#[inline]
pub fn len(&self) -> usize {
self.buffer.len()
}
#[inline]
pub fn width(&self) -> u32 {
self.buffer.width() as u32
}
#[inline]
pub fn height(&self) -> u32 {
self.buffer.height() as u32
}
#[inline]
pub fn is_empty(&self) -> bool {
self.buffer.is_empty()
}
pub fn set_pixel(&mut self, x: u32, y: u32, color: Rgba) -> bool {
let index = self.quantize_color(color);
self.buffer.set_pixel(x, y, index)
}
pub fn get_pixel(&self, x: u32, y: u32) -> Option<Rgba> {
self.buffer.get_pixel(x, y).map(|i| self.palette.color(i))
}
pub fn clear(&mut self, color: Rgba) {
let index = self.quantize_color(color);
self.buffer.clear(index);
self.palette = self.base;
}
pub fn fill_rect(&mut self, x: u32, y: u32, rect_width: u32, rect_height: u32, color: Rgba) {
let index = self.quantize_color(color);
self.buffer.fill_rect(x, y, rect_width, rect_height, index);
}
#[inline]
pub fn blit_gb_tile(
&mut self,
x: i32,
y: i32,
tile: &Tile,
palette: &Palette,
transparent: bool,
flip_x: bool,
flip_y: bool,
) {
let rgba = [
palette.color(GbColor::White),
palette.color(GbColor::LightGray),
palette.color(GbColor::DarkGray),
palette.color(GbColor::Black),
];
let mapped = [
self.quantize_color(rgba[0]),
self.quantize_color(rgba[1]),
self.quantize_color(rgba[2]),
self.quantize_color(rgba[3]),
];
let width = self.width() as i32;
let height = self.height() as i32;
if LINEAR {
if !flip_x
&& !flip_y
&& x >= 0
&& y >= 0
&& x + TILE_PIXELS as i32 <= width
&& y + TILE_PIXELS as i32 <= height
{
let transparent_zero = transparent && rgba[0] == Rgba::TRANSPARENT;
let transparent_nonzero =
transparent && rgba[1..].iter().any(|color| *color == Rgba::TRANSPARENT);
let identity_mapping = mapped.iter().enumerate().all(|(index, color)| {
(index == 0 && transparent_zero) || color.to_index() == index
});
if identity_mapping && !transparent_nonzero {
let destination = self.buffer.data.as_mut_ptr() as *mut u8;
for row in 0..TILE_PIXELS {
let source = tile.pixels[row].as_ptr();
let target = unsafe {
destination.add((y as usize + row) * width as usize + x as usize)
};
if transparent_zero {
for column in 0..TILE_PIXELS {
let value = unsafe { source.add(column).read() };
if value != 0 {
unsafe { target.add(column).write(value) };
}
}
} else {
unsafe { core::ptr::copy_nonoverlapping(source, target, TILE_PIXELS) };
}
}
return;
}
}
}
let tile_size = TILE_PIXELS as i32;
let x_start = x.saturating_neg().clamp(0, tile_size) as usize;
let y_start = y.saturating_neg().clamp(0, tile_size) as usize;
let x_end = width.saturating_sub(x).clamp(0, tile_size) as usize;
let y_end = height.saturating_sub(y).clamp(0, tile_size) as usize;
if x_start >= x_end || y_start >= y_end {
return;
}
let destination = self.buffer.data.as_mut_ptr() as *mut u8;
for dst_row in y_start..y_end {
let src_row = if flip_y {
TILE_PIXELS - 1 - dst_row
} else {
dst_row
};
for dst_col in x_start..x_end {
let src_col = if flip_x {
TILE_PIXELS - 1 - dst_col
} else {
dst_col
};
let source = (tile.pixels[src_row][src_col] & 0x03) as usize;
if transparent && rgba[source] == Rgba::TRANSPARENT {
continue;
}
if LINEAR {
unsafe {
let offset = (y + dst_row as i32) as usize * width as usize
+ (x + dst_col as i32) as usize;
destination
.add(offset)
.write(mapped[source].to_index() as u8);
}
}
if !LINEAR {
self.buffer.set_pixel(
(x + dst_col as i32) as u32,
(y + dst_row as i32) as u32,
mapped[source],
);
}
}
}
}
pub fn blit_row(&mut self, x: u32, y: u32, src: &[u8], count: u32) -> bool {
if y >= self.height() || x >= self.width() {
return false;
}
let actual_count = count.min(self.width() - x) as usize;
let src_bytes = actual_count * 4;
if src.len() < src_bytes {
return false;
}
for i in 0..actual_count {
let off = i * 4;
let c = Rgba::new(src[off], src[off + 1], src[off + 2], src[off + 3]);
let index = self.quantize_color(c);
self.buffer.set_pixel(x + i as u32, y, index);
}
true
}
#[inline]
fn quantize_color(&self, color: Rgba) -> C {
if self.fast_grayscale && color.a == 0xFF && color.r == color.g && color.g == color.b {
let index = match color.r {
213..=255 => 0,
128..=212 => 1,
43..=127 => 2,
_ => 3,
};
C::from_u8(index)
} else {
quantize(&self.base, color)
}
}
#[cfg(any(feature = "gpu", feature = "image-assets"))]
pub fn save_png(&self, path: &std::path::Path) -> std::io::Result<()> {
use image::{ImageBuffer, Rgba as ImgRgba};
let w = self.width() as u32;
let h = self.height() as u32;
let mut rgba = vec![0u8; (w * h * 4) as usize];
self.to_rgba(&mut rgba);
let img: ImageBuffer<ImgRgba<u8>, _> =
ImageBuffer::from_raw(w, h, rgba).expect("framebuffer size mismatch");
img.save(path)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
}
}
impl<const LINEAR: bool> RgbaIndexedFrameBuffer<GbColor, LINEAR> {
#[inline]
pub fn blit_gb_tile_indices(
&mut self,
x: i32,
y: i32,
tile: &Tile,
transparent_zero: bool,
flip_x: bool,
flip_y: bool,
) {
let width = self.width() as i32;
let height = self.height() as i32;
if LINEAR {
if !transparent_zero
&& !flip_x
&& !flip_y
&& x >= 0
&& y >= 0
&& x + TILE_PIXELS as i32 <= width
&& y + TILE_PIXELS as i32 <= height
{
let destination = self.buffer.data.as_mut_ptr() as *mut u8;
let width = width as usize;
let x = x as usize;
let y = y as usize;
if width & 3 == 0 && x & 3 == 0 {
for row in 0..TILE_PIXELS {
unsafe {
let source = tile.pixels[row].as_ptr() as *const u32;
let destination = destination.add((y + row) * width + x) as *mut u32;
destination.write(source.read());
destination.add(1).write(source.add(1).read());
}
}
} else if width & 1 == 0 && x & 1 == 0 {
for row in 0..TILE_PIXELS {
unsafe {
let source = tile.pixels[row].as_ptr() as *const u16;
let destination = destination.add((y + row) * width + x) as *mut u16;
for halfword in 0..4 {
destination.add(halfword).write(source.add(halfword).read());
}
}
}
} else {
for row in 0..TILE_PIXELS {
unsafe {
core::ptr::copy_nonoverlapping(
tile.pixels[row].as_ptr(),
destination.add((y + row) * width + x),
TILE_PIXELS,
);
}
}
}
return;
}
}
let tile_size = TILE_PIXELS as i32;
let x_start = x.saturating_neg().clamp(0, tile_size) as usize;
let y_start = y.saturating_neg().clamp(0, tile_size) as usize;
let x_end = width.saturating_sub(x).clamp(0, tile_size) as usize;
let y_end = height.saturating_sub(y).clamp(0, tile_size) as usize;
if x_start >= x_end || y_start >= y_end {
return;
}
if LINEAR {
{
let destination = self.buffer.data.as_mut_ptr() as *mut u8;
if !transparent_zero && !flip_x && !flip_y {
let copy_width = x_end - x_start;
let destination_x = (x + x_start as i32) as usize;
let destination_y = (y + y_start as i32) as usize;
let source = unsafe { tile.pixels[y_start].as_ptr().add(x_start) };
let target =
unsafe { destination.add(destination_y * width as usize + destination_x) };
if width as usize & 3 == 0
&& (source as usize | target as usize | copy_width) & 3 == 0
{
let words = copy_width / 4;
for dst_row in y_start..y_end {
unsafe {
let source =
tile.pixels[dst_row].as_ptr().add(x_start) as *const u32;
let target = destination.add(
(y + dst_row as i32) as usize * width as usize + destination_x,
) as *mut u32;
for word in 0..words {
target.add(word).write(source.add(word).read());
}
}
}
} else if width as usize & 1 == 0
&& (source as usize | target as usize | copy_width) & 1 == 0
{
let halfwords = copy_width / 2;
for dst_row in y_start..y_end {
unsafe {
let source =
tile.pixels[dst_row].as_ptr().add(x_start) as *const u16;
let target = destination.add(
(y + dst_row as i32) as usize * width as usize + destination_x,
) as *mut u16;
for halfword in 0..halfwords {
target.add(halfword).write(source.add(halfword).read());
}
}
}
} else {
for dst_row in y_start..y_end {
unsafe {
core::ptr::copy_nonoverlapping(
tile.pixels[dst_row].as_ptr().add(x_start),
destination.add(
(y + dst_row as i32) as usize * width as usize
+ destination_x,
),
copy_width,
);
}
}
}
return;
}
for dst_row in y_start..y_end {
let src_row = if flip_y {
TILE_PIXELS - 1 - dst_row
} else {
dst_row
};
for dst_col in x_start..x_end {
let src_col = if flip_x {
TILE_PIXELS - 1 - dst_col
} else {
dst_col
};
let source = tile.pixels[src_row][src_col] & 0x03;
if transparent_zero && source == 0 {
continue;
}
unsafe {
let offset = (y + dst_row as i32) as usize * width as usize
+ (x + dst_col as i32) as usize;
destination.add(offset).write(source);
}
}
}
}
}
if !LINEAR {
for dst_row in y_start..y_end {
let src_row = if flip_y {
TILE_PIXELS - 1 - dst_row
} else {
dst_row
};
for dst_col in x_start..x_end {
let src_col = if flip_x {
TILE_PIXELS - 1 - dst_col
} else {
dst_col
};
let source = tile.pixels[src_row][src_col] & 0x03;
if transparent_zero && source == 0 {
continue;
}
self.buffer.set_pixel(
(x + dst_col as i32) as u32,
(y + dst_row as i32) as u32,
GbColor::from_u8(source),
);
}
}
}
}
}
impl<C: ColorIndex + DefaultPalette, const LINEAR: bool> RgbaIndexedFrameBuffer<C, LINEAR> {
pub fn new(config: RenderConfig, clear: Rgba) -> Self {
Self::with_palette(config, clear, C::default_palette())
}
}
impl<const LINEAR: bool> RgbaIndexedFrameBuffer<GbColor, LINEAR> {
pub fn apply_bgp(&mut self, bgp: u8) {
let mut remapped = [0u8; 4];
for i in 0..4 {
remapped[i] = (bgp >> (2 * i)) & 3;
}
self.remap_shades(&remapped);
}
}
impl<C: ColorIndex + DefaultPalette, const LINEAR: bool> FbSurface
for RgbaIndexedFrameBuffer<C, LINEAR>
{
fn new_screen(width: u32, height: u32) -> Self {
Self::new(RenderConfig::new(width, height), Rgba::BLACK)
}
fn width(&self) -> u32 {
self.buffer.width() as u32
}
fn height(&self) -> u32 {
self.buffer.height() as u32
}
fn set_pixel(&mut self, x: u32, y: u32, color: Rgba) -> bool {
self.set_pixel(x, y, color)
}
fn get_pixel(&self, x: u32, y: u32) -> Option<Rgba> {
self.get_pixel(x, y)
}
fn clear(&mut self, color: Rgba) {
self.clear(color)
}
fn fill_rect(&mut self, x: u32, y: u32, rect_width: u32, rect_height: u32, color: Rgba) {
self.fill_rect(x, y, rect_width, rect_height, color)
}
fn blit_gb_tile(
&mut self,
x: i32,
y: i32,
tile: &Tile,
palette: &Palette,
transparent: bool,
flip_x: bool,
flip_y: bool,
) {
self.blit_gb_tile(x, y, tile, palette, transparent, flip_x, flip_y)
}
fn present_into(&self, out: &mut [u8]) {
assert!(out.len() >= self.len() * 4, "present buffer too small");
self.to_rgba(out);
}
}
#[cfg(test)]
mod facade_tests {
use super::*;
use crate::palette::GRAYSCALE_SPRITE_PALETTE;
fn fb() -> RgbaIndexedFrameBuffer<GbColor> {
RgbaIndexedFrameBuffer::new(RenderConfig::new(160, 144), Rgba::WHITE)
}
#[test]
fn decoded_tiles_are_word_aligned_without_padding() {
assert_eq!(core::mem::align_of::<Tile>(), 4);
assert_eq!(core::mem::size_of::<Tile>(), 64);
}
#[test]
fn storage_is_packed() {
let fb = fb();
assert_eq!(fb.len(), 160 * 144);
assert_eq!(fb.packed().len(), 5760);
assert_eq!(fb.packed().len(), packed_len::<GbColor>(160, 144));
}
#[test]
fn grayscale_round_trips_exactly() {
let mut fb = fb();
let colors = [
Rgba::WHITE,
Rgba::rgb(0xAA, 0xAA, 0xAA),
Rgba::rgb(0x55, 0x55, 0x55),
Rgba::BLACK,
];
for (i, &c) in colors.iter().enumerate() {
assert!(fb.set_pixel(i as u32, 0, c));
}
for (i, &c) in colors.iter().enumerate() {
assert_eq!(fb.get_pixel(i as u32, 0), Some(c));
assert_eq!(fb.get_index(i as u32, 0), Some(GbColor::from_u8(i as u8)));
}
}
#[test]
fn fast_grayscale_quantizer_matches_generic_for_every_gray() {
let mut fb = RgbaIndexedFrameBuffer::<GbColor>::new(RenderConfig::new(256, 1), Rgba::WHITE);
assert!(fb.fast_grayscale);
for gray in 0..=u8::MAX {
let color = Rgba::rgb(gray, gray, gray);
assert!(fb.set_pixel(gray as u32, 0, color));
assert_eq!(
fb.get_index(gray as u32, 0),
Some(quantize(&GRAYSCALE_PALETTE, color)),
"gray {gray}"
);
}
}
#[test]
fn near_grays_quantize_to_nearest_shade() {
let mut fb = fb();
fb.set_pixel(0, 0, Rgba::rgb(0xC0, 0xC0, 0xC0));
fb.set_pixel(1, 0, Rgba::rgb(0x80, 0x80, 0x80));
fb.set_pixel(2, 0, Rgba::rgb(0x40, 0x40, 0x40));
assert_eq!(fb.get_index(0, 0), Some(GbColor::LightGray));
assert_eq!(fb.get_index(1, 0), Some(GbColor::LightGray));
assert_eq!(fb.get_index(2, 0), Some(GbColor::DarkGray));
}
#[test]
fn transparent_writes_pick_nearest_opaque_shade() {
let mut fb = fb();
fb.set_pixel(3, 3, Rgba::TRANSPARENT);
assert_eq!(fb.get_index(3, 3), Some(GbColor::Black));
}
#[test]
fn bounds_checked_rgba_facade() {
let mut fb = fb();
assert!(fb.set_pixel(159, 143, Rgba::BLACK));
assert!(!fb.set_pixel(160, 0, Rgba::BLACK));
assert!(!fb.set_pixel(0, 144, Rgba::BLACK));
assert_eq!(fb.get_pixel(160, 0), None);
assert_eq!(fb.pixel_rgba(160, 0), Rgba::TRANSPARENT);
}
#[test]
fn gb_tile_blit_maps_palette_and_preserves_transparency() {
let mut tile = Tile::blank();
tile.pixels[0] = [0, 1, 2, 3, 0, 1, 2, 3];
let palette = Palette::new(&[
Rgba::TRANSPARENT,
Rgba::BLACK,
Rgba::rgb(0x55, 0x55, 0x55),
Rgba::rgb(0xAA, 0xAA, 0xAA),
]);
let mut fb = RgbaIndexedFrameBuffer::<GbColor>::new(RenderConfig::new(8, 2), Rgba::WHITE);
fb.blit_gb_tile(0, 0, &tile, &palette, true, false, false);
assert_eq!(fb.get_index(0, 0), Some(GbColor::White));
assert_eq!(fb.get_index(1, 0), Some(GbColor::Black));
assert_eq!(fb.get_index(2, 0), Some(GbColor::DarkGray));
assert_eq!(fb.get_index(3, 0), Some(GbColor::LightGray));
}
#[test]
fn gb_tile_blit_clips_and_flips() {
let mut tile = Tile::blank();
tile.pixels[7][7] = 3;
let mut fb = RgbaIndexedFrameBuffer::<GbColor>::new(RenderConfig::new(4, 4), Rgba::WHITE);
fb.blit_gb_tile(-7, -7, &tile, &GRAYSCALE_PALETTE, false, true, true);
assert_eq!(fb.get_index(0, 0), Some(GbColor::White));
assert_eq!(fb.get_index(3, 3), Some(GbColor::White));
fb.blit_gb_tile(-7, -7, &tile, &GRAYSCALE_PALETTE, false, false, false);
assert_eq!(fb.get_index(0, 0), Some(GbColor::Black));
}
#[test]
fn gb_tile_index_blit_copies_indices_and_skips_zero() {
let mut tile = Tile::blank();
tile.pixels[0] = [0, 1, 2, 3, 0, 1, 2, 3];
let mut fb = RgbaIndexedFrameBuffer::<GbColor>::new(RenderConfig::new(8, 2), Rgba::BLACK);
fb.blit_gb_tile_indices(0, 0, &tile, false, false, false);
assert_eq!(fb.get_index(0, 0), Some(GbColor::White));
assert_eq!(fb.get_index(1, 0), Some(GbColor::LightGray));
assert_eq!(fb.get_index(2, 0), Some(GbColor::DarkGray));
assert_eq!(fb.get_index(3, 0), Some(GbColor::Black));
fb.clear_index(GbColor::Black);
fb.blit_gb_tile_indices(0, 0, &tile, true, false, false);
assert_eq!(fb.get_index(0, 0), Some(GbColor::Black));
assert_eq!(fb.get_index(1, 0), Some(GbColor::LightGray));
}
#[test]
fn gb_tile_index_blit_clips_opaque_edges() {
let mut tile = Tile::blank();
for (row, pixels) in tile.pixels.iter_mut().enumerate() {
for (column, pixel) in pixels.iter_mut().enumerate() {
*pixel = ((row * TILE_PIXELS + column) & 3) as u8;
}
}
for (tile_x, tile_y) in [(-6, 0), (6, 0), (0, -6), (0, 6)] {
let mut fb =
RgbaIndexedFrameBuffer::<GbColor>::new(RenderConfig::new(8, 8), Rgba::BLACK);
fb.blit_gb_tile_indices(tile_x, tile_y, &tile, false, false, false);
for y in 0..8i32 {
for x in 0..8i32 {
let source_x = x - tile_x;
let source_y = y - tile_y;
let expected = if (0..TILE_PIXELS as i32).contains(&source_x)
&& (0..TILE_PIXELS as i32).contains(&source_y)
{
GbColor::from_u8(tile.pixels[source_y as usize][source_x as usize])
} else {
GbColor::Black
};
assert_eq!(
fb.get_index(x as u32, y as u32),
Some(expected),
"tile ({tile_x}, {tile_y}), pixel ({x}, {y})"
);
}
}
}
}
#[test]
fn clear_and_fill_quantize() {
let mut fb = fb();
fb.fill_rect(0, 0, 100, 100, Rgba::rgb(0x55, 0x55, 0x55));
assert_eq!(fb.get_index(50, 50), Some(GbColor::DarkGray));
fb.clear(Rgba::BLACK);
assert_eq!(fb.get_index(0, 0), Some(GbColor::Black));
assert_eq!(fb.get_index(159, 143), Some(GbColor::Black));
}
#[test]
fn blit_row_quantizes_each_pixel() {
let mut fb = fb();
let row = [255u8, 255, 255, 255, 0, 0, 0, 0];
assert!(fb.blit_row(0, 0, &row, 2));
assert_eq!(fb.get_index(0, 0), Some(GbColor::White));
assert_eq!(fb.get_index(1, 0), Some(GbColor::Black));
assert!(!fb.blit_row(160, 0, &row, 2));
assert!(!fb.blit_row(0, 0, &row, 3)); }
#[test]
fn remap_shades_inverts() {
let mut fb = fb();
fb.fill_rect(0, 0, 8, 8, Rgba::BLACK);
fb.remap_shades(&[3, 2, 1, 0]);
assert_eq!(fb.get_pixel(0, 0), Some(Rgba::WHITE));
assert_eq!(fb.get_index(0, 0), Some(GbColor::Black));
fb.reset_palette();
assert_eq!(fb.get_pixel(0, 0), Some(Rgba::BLACK));
}
#[test]
fn apply_bgp_fade_to_black() {
let mut fb = fb();
fb.set_pixel(0, 0, Rgba::WHITE);
fb.apply_bgp(0b11111111);
assert_eq!(fb.get_pixel(0, 0), Some(Rgba::BLACK));
assert_eq!(fb.get_index(0, 0), Some(GbColor::White));
}
#[test]
fn scale_shades_dims_display() {
let mut fb = fb();
fb.fill_rect(0, 0, 8, 8, Rgba::WHITE);
fb.scale_shades(0.5);
assert_eq!(fb.get_pixel(0, 0), Some(Rgba::rgb(127, 127, 127)));
fb.scale_shades(0.0);
assert_eq!(fb.get_pixel(0, 0), Some(Rgba::BLACK));
}
#[test]
fn palette_swap_does_not_touch_draws() {
let mut fb = fb();
fb.set_pixel(4, 4, Rgba::rgb(0x55, 0x55, 0x55));
fb.apply_bgp(0b11100100); fb.set_pixel(5, 4, Rgba::rgb(0xAA, 0xAA, 0xAA));
fb.reset_palette();
assert_eq!(fb.get_pixel(5, 4), Some(Rgba::rgb(0xAA, 0xAA, 0xAA)));
}
#[test]
fn copy_from_copies_pixels_and_palette() {
let mut src = fb();
src.fill_rect(0, 0, 16, 16, Rgba::BLACK);
src.apply_bgp(0b00000000); let mut dst = fb();
dst.copy_from(&src);
assert_eq!(dst.get_index(8, 8), Some(GbColor::Black));
assert_eq!(dst.get_pixel(8, 8), Some(Rgba::WHITE));
assert_eq!(dst.packed(), src.packed());
}
#[test]
fn copy_from_with_delegates_storage_transfer() {
let mut src = fb();
src.fill_rect(3, 5, 9, 7, Rgba::BLACK);
src.apply_bgp(0b00000000); let mut dst = fb();
let mut calls = 0;
dst.copy_from_with(&src, |destination, source| {
calls += 1;
assert_eq!(destination.len(), source.len());
destination.copy_from_slice(source);
});
assert_eq!(calls, 1);
assert_eq!(dst.get_index(4, 6), Some(GbColor::Black));
assert_eq!(dst.get_pixel(4, 6), Some(Rgba::WHITE));
assert_eq!(dst.packed(), src.packed());
}
#[test]
fn copy_rect_from_preserves_destination_palette_and_other_pixels() {
let mut src = fb();
src.fill_rect(2, 3, 6, 5, Rgba::BLACK);
src.apply_bgp(0b00000000); let mut dst = fb();
dst.fill_rect(0, 0, 16, 16, Rgba::rgb(0x55, 0x55, 0x55));
dst.apply_bgp(0b11100100);
dst.copy_rect_from(&src, 2, 3, 6, 5);
assert_eq!(dst.get_index(4, 4), Some(GbColor::Black));
assert_eq!(dst.get_pixel(4, 4), Some(Rgba::BLACK));
assert_eq!(dst.get_index(1, 4), Some(GbColor::DarkGray));
assert_eq!(dst.get_pixel(1, 4), Some(Rgba::rgb(0x55, 0x55, 0x55)));
}
#[test]
fn copy_rect_within_preserves_palette_state() {
let mut fb = fb();
fb.fill_rect(2, 3, 6, 5, Rgba::BLACK);
fb.apply_bgp(0b00000000);
let palette = fb.palette;
fb.copy_rect_within(2, 3, 6, 5, 4, 5);
assert_eq!(fb.get_index(5, 6), Some(GbColor::Black));
assert_eq!(fb.palette, palette);
}
#[test]
fn clear_resets_display_palette() {
let mut fb = fb();
fb.apply_bgp(0b00000000); assert_eq!(fb.get_pixel(0, 0), Some(Rgba::WHITE));
fb.clear(Rgba::BLACK);
assert_eq!(fb.get_pixel(0, 0), Some(Rgba::BLACK));
fb.set_pixel(1, 0, Rgba::WHITE);
assert_eq!(fb.get_pixel(1, 0), Some(Rgba::WHITE));
}
#[test]
fn to_rgba_uses_display_palette() {
let mut fb = RgbaIndexedFrameBuffer::<GbColor>::new(RenderConfig::new(2, 1), Rgba::WHITE);
fb.set_pixel(0, 0, Rgba::WHITE);
fb.apply_bgp(0b00000000); let mut out = [0u8; 8];
assert!(fb.to_rgba(&mut out));
assert_eq!(&out[0..4], &[0xFF, 0xFF, 0xFF, 0xFF]);
}
#[test]
fn fb_surface_present_and_pixels() {
let mut fb = RgbaIndexedFrameBuffer::<GbColor>::new_screen(4, 2);
fb.set_pixel(1, 1, Rgba::WHITE);
assert_eq!(fb.width(), 4);
assert_eq!(fb.height(), 2);
assert_eq!(fb.pixel_rgba(1, 1), Rgba::WHITE);
assert_eq!(fb.pixel_rgba(0, 0), Rgba::BLACK);
let mut out = [0u8; 4 * 2 * 4];
fb.present_into(&mut out);
assert_eq!(&out[5 * 4..6 * 4], &[0xFF, 0xFF, 0xFF, 0xFF]);
}
#[test]
fn sprite_palette_quantization_matches_draw_palette() {
let mut fb = fb();
for (i, &c) in GRAYSCALE_SPRITE_PALETTE.colors[..4].iter().enumerate() {
fb.set_pixel(i as u32, 0, c);
let expected = if i == 0 {
GbColor::Black
} else {
GbColor::from_u8(i as u8)
};
assert_eq!(fb.get_index(i as u32, 0), Some(expected));
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AlignedBytes {
words: Vec<u32>,
len: usize,
}
impl core::ops::Deref for AlignedBytes {
type Target = [u8];
fn deref(&self) -> &[u8] {
unsafe { core::slice::from_raw_parts(self.words.as_ptr().cast(), self.len) }
}
}
impl core::ops::DerefMut for AlignedBytes {
fn deref_mut(&mut self) -> &mut [u8] {
unsafe { core::slice::from_raw_parts_mut(self.words.as_mut_ptr().cast(), self.len) }
}
}
pub type LinearIndexedFrameBuffer<C = GbColor> = IndexedFrameBuffer<C, true>;
pub type LinearRgbaIndexedFrameBuffer<C = GbColor> = RgbaIndexedFrameBuffer<C, true>;
impl<C: ColorIndex, const LINEAR: bool> IndexedFrameBuffer<C, LINEAR> {
pub(crate) fn bytes(&self) -> &[u8] {
&self.data
}
pub(crate) fn bytes_mut(&mut self) -> &mut [u8] {
&mut self.data
}
}
impl<C: ColorIndex> IndexedFrameBuffer<C, false> {
pub fn packed(&self) -> &[u8] {
&self.data
}
pub fn packed_mut(&mut self) -> &mut [u8] {
&mut self.data
}
}
impl<C: ColorIndex> IndexedFrameBuffer<C, true> {
pub fn indices(&self) -> &[u8] {
&self.data
}
pub fn indices_mut(&mut self) -> &mut [u8] {
&mut self.data
}
}
impl<C: ColorIndex> RgbaIndexedFrameBuffer<C, false> {
pub fn packed(&self) -> &[u8] {
self.buffer.packed()
}
pub fn packed_mut(&mut self) -> &mut [u8] {
self.buffer.packed_mut()
}
}
impl<C: ColorIndex> RgbaIndexedFrameBuffer<C, true> {
pub fn indices(&self) -> &[u8] {
self.buffer.indices()
}
pub fn indices_mut(&mut self) -> &mut [u8] {
self.buffer.indices_mut()
}
}