#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SpritePixel {
pub colour_index: u8,
pub palette: u8,
pub cgb_palette: u8,
pub bg_priority: bool,
}
pub fn scan_oam_line(scanline: u8, oam: &[u8; 0xA0], lcdc: u8) -> Vec<usize> {
let height: u8 = if lcdc & 0x04 != 0 { 16 } else { 8 };
let mut result = Vec::new();
for i in 0..40usize {
let oam_y = oam[i * 4];
let screen_y = oam_y.wrapping_sub(16);
if scanline >= screen_y && scanline < screen_y.wrapping_add(height) {
result.push(i);
if result.len() >= 10 {
break;
}
}
}
result
}
pub fn fetch_sprite_pixel(
x: u32,
scanline: u8,
sprite_indices: &[usize],
oam: &[u8; 0xA0],
vram: &[u8; 0x2000],
lcdc: u8,
) -> Option<SpritePixel> {
let height: u8 = if lcdc & 0x04 != 0 { 16 } else { 8 };
let mut sorted = [0usize; 10];
let mut count = 0usize;
for &i in sprite_indices.iter().take(10) {
sorted[count] = i;
count += 1;
}
sorted[..count].sort_by_key(|&i| (oam[i * 4 + 1], i));
for &i in &sorted[..count] {
let oam_y = oam[i * 4];
let oam_x = oam[i * 4 + 1];
let tile_num = oam[i * 4 + 2];
let attrs = oam[i * 4 + 3];
let screen_y = oam_y.wrapping_sub(16);
let screen_x = oam_x.wrapping_sub(8);
if x < screen_x as u32 || x >= screen_x as u32 + 8 {
continue;
}
let y_flip = attrs & 0x40 != 0;
let x_flip = attrs & 0x20 != 0;
let palette = (attrs >> 4) & 1;
let bg_priority = attrs & 0x80 != 0;
let mut row = (scanline - screen_y) as usize;
if y_flip {
row = (height as usize - 1) - row;
}
let mut pixel_x = (x as u8).wrapping_sub(screen_x);
if x_flip {
pixel_x = 7 - pixel_x;
}
let tile_index = if height == 16 {
if row < 8 {
(tile_num & 0xFE) as usize
} else {
row -= 8;
(tile_num | 0x01) as usize
}
} else {
tile_num as usize
};
let tile_addr = tile_index * 16;
let low = vram[tile_addr + row * 2];
let high = vram[tile_addr + row * 2 + 1];
let bit = 7 - pixel_x;
let colour_index = ((high >> bit) & 1) << 1 | ((low >> bit) & 1);
if colour_index == 0 {
continue; }
return Some(SpritePixel {
colour_index,
palette,
cgb_palette: 0,
bg_priority,
});
}
None
}
#[allow(clippy::too_many_arguments)]
pub fn fetch_sprite_pixel_cgb(
x: u32,
scanline: u8,
sprite_indices: &[usize],
oam: &[u8; 0xA0],
vram: &[u8; 0x2000],
vram_bank1: &[u8; 0x2000],
lcdc: u8,
dmg_priority_mode: bool,
) -> Option<SpritePixel> {
let height: u8 = if lcdc & 0x04 != 0 { 16 } else { 8 };
let mut sorted = [0usize; 10];
let mut count = 0usize;
for &i in sprite_indices.iter().take(10) {
sorted[count] = i;
count += 1;
}
if dmg_priority_mode {
sorted[..count].sort_by_key(|&i| (oam[i * 4 + 1], i));
}
for &i in &sorted[..count] {
let oam_y = oam[i * 4];
let oam_x = oam[i * 4 + 1];
let tile_num = oam[i * 4 + 2];
let attrs = oam[i * 4 + 3];
let screen_y = oam_y.wrapping_sub(16);
let screen_x = oam_x.wrapping_sub(8);
if x < screen_x as u32 || x >= screen_x as u32 + 8 {
continue;
}
let y_flip = attrs & 0x40 != 0;
let x_flip = attrs & 0x20 != 0;
let cgb_palette = attrs & 0x07;
let tile_vram_bank = (attrs >> 3) & 0x01;
let bg_priority = attrs & 0x80 != 0;
let mut row = (scanline - screen_y) as usize;
if y_flip {
row = (height as usize - 1) - row;
}
let mut pixel_x = (x as u8).wrapping_sub(screen_x);
if x_flip {
pixel_x = 7 - pixel_x;
}
let tile_index = if height == 16 {
if row < 8 {
(tile_num & 0xFE) as usize
} else {
row -= 8;
(tile_num | 0x01) as usize
}
} else {
tile_num as usize
};
let tile_vram = if tile_vram_bank != 0 {
vram_bank1
} else {
vram
};
let tile_addr = tile_index * 16;
let low = tile_vram[tile_addr + row * 2];
let high = tile_vram[tile_addr + row * 2 + 1];
let bit = 7 - pixel_x;
let colour_index = ((high >> bit) & 1) << 1 | ((low >> bit) & 1);
if colour_index == 0 {
continue;
}
return Some(SpritePixel {
colour_index,
palette: 0,
cgb_palette,
bg_priority,
});
}
None
}
const OBJ_FETCH_DOTS: u16 = 6;
const BG_TILE_WIDTH: i16 = 8;
const OAM_X_OFFSCREEN: u8 = 168;
const MAX_TILE_WAIT: u16 = 5;
pub fn calculate_obj_penalty(sprite_indices: &[usize], oam: &[u8; 0xA0], scx: u8) -> u16 {
debug_assert!(
sprite_indices.len() <= 10,
"OAM scan is capped at 10 sprites per scanline"
);
if sprite_indices.is_empty() {
return 0;
}
let mut sorted: Vec<(usize, u8)> = sprite_indices
.iter()
.map(|&i| (i, oam[i * 4 + 1]))
.collect();
sorted.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
let mut total_penalty: u16 = 0;
let mut seen_tiles: [i16; 10] = [i16::MIN; 10];
let mut seen_count: usize = 0;
for &(_, oam_x) in &sorted {
if oam_x >= OAM_X_OFFSCREEN {
continue;
}
let screen_x = oam_x as i16 - 8; let bg_x = screen_x + scx as i16;
let tile_id = bg_x.div_euclid(BG_TILE_WIDTH);
if !seen_tiles[..seen_count].contains(&tile_id) {
total_penalty += tile_wait_penalty(oam_x, bg_x);
if seen_count < seen_tiles.len() {
seen_tiles[seen_count] = tile_id;
seen_count += 1;
}
}
total_penalty += OBJ_FETCH_DOTS;
}
total_penalty
}
fn tile_wait_penalty(oam_x: u8, bg_x: i16) -> u16 {
if oam_x == 0 {
return MAX_TILE_WAIT;
}
let pos_in_tile = bg_x.rem_euclid(BG_TILE_WIDTH) as u16;
MAX_TILE_WAIT.saturating_sub(pos_in_tile)
}
#[cfg(test)]
mod tests {
use super::*;
fn blank_oam() -> [u8; 0xA0] {
[0u8; 0xA0]
}
fn blank_vram() -> [u8; 0x2000] {
[0u8; 0x2000]
}
fn oam_with_sprite_at(oam_y: u8, oam_x: u8, tile: u8, attrs: u8) -> [u8; 0xA0] {
let mut oam = blank_oam();
oam[0] = oam_y;
oam[1] = oam_x;
oam[2] = tile;
oam[3] = attrs;
oam
}
#[test]
fn test_sprite_on_scanline_is_found() {
let oam = oam_with_sprite_at(16, 8, 0, 0);
let lcdc = 0x02u8; let indices = scan_oam_line(0, &oam, lcdc);
assert!(indices.contains(&0));
}
#[test]
fn test_sprite_above_scanline_is_not_found() {
let oam = oam_with_sprite_at(16, 8, 0, 0);
let lcdc = 0x02u8;
let indices = scan_oam_line(8, &oam, lcdc);
assert!(!indices.contains(&0));
}
#[test]
fn test_sprite_below_scanline_is_not_found() {
let oam = oam_with_sprite_at(17, 8, 0, 0);
let lcdc = 0x02u8;
let indices = scan_oam_line(0, &oam, lcdc);
assert!(!indices.contains(&0));
}
#[test]
fn test_oam_scan_limits_to_10_sprites_per_scanline() {
let mut oam = blank_oam();
for i in 0..40usize {
oam[i * 4] = 16; oam[i * 4 + 1] = (i as u8 + 1) * 2; oam[i * 4 + 2] = 0;
oam[i * 4 + 3] = 0;
}
let lcdc = 0x02u8;
let indices = scan_oam_line(0, &oam, lcdc);
assert!(indices.len() <= 10);
assert_eq!(indices.len(), 10);
}
#[test]
fn test_8x16_sprite_covers_two_tile_rows() {
let mut oam = blank_oam();
oam[0] = 16; oam[1] = 8;
oam[2] = 0;
oam[3] = 0;
let lcdc = 0x06u8; assert!(scan_oam_line(0, &oam, lcdc).contains(&0));
assert!(scan_oam_line(15, &oam, lcdc).contains(&0));
assert!(!scan_oam_line(16, &oam, lcdc).contains(&0));
}
#[test]
fn test_transparent_sprite_pixel_returns_none() {
let oam = oam_with_sprite_at(16, 8, 0, 0); let vram = blank_vram(); let lcdc = 0x02u8;
let indices = vec![0usize];
let result = fetch_sprite_pixel(0, 0, &indices, &oam, &vram, lcdc);
assert_eq!(result, None);
}
#[test]
fn test_opaque_sprite_pixel_returns_some() {
let mut vram = blank_vram();
vram[0x0010] = 0xFF; vram[0x0011] = 0x00; let oam = oam_with_sprite_at(16, 8, 1, 0);
let lcdc = 0x02u8;
let indices = vec![0usize];
let result = fetch_sprite_pixel(0, 0, &indices, &oam, &vram, lcdc);
assert!(result.is_some());
let px = result.unwrap();
assert_eq!(px.colour_index, 1);
assert_eq!(px.palette, 0);
assert!(!px.bg_priority);
}
fn overlapping_oam_and_vram() -> ([u8; 0xA0], [u8; 0x2000]) {
let mut oam = blank_oam();
oam[0] = 16;
oam[1] = 28;
oam[2] = 1;
oam[3] = 0;
oam[4] = 16;
oam[5] = 24;
oam[6] = 2;
oam[7] = 0;
let mut vram = blank_vram();
vram[0x0010] = 0x00; vram[0x0011] = 0xFF; vram[0x0020] = 0xFF; vram[0x0021] = 0x00;
(oam, vram)
}
#[test]
fn test_lower_oam_x_wins_over_lower_oam_index() {
let (oam, vram) = overlapping_oam_and_vram();
let lcdc = 0x02u8;
let indices = vec![0usize, 1usize];
let result = fetch_sprite_pixel(20, 0, &indices, &oam, &vram, lcdc);
assert!(result.is_some());
assert_eq!(
result.unwrap().colour_index,
1,
"sprite with lower OAM_X should win, not lower OAM index"
);
}
#[test]
fn test_equal_oam_x_lower_oam_index_wins() {
let mut oam = blank_oam();
oam[0] = 16;
oam[1] = 8;
oam[2] = 1;
oam[3] = 0;
oam[4] = 16;
oam[5] = 8;
oam[6] = 2;
oam[7] = 0;
let mut vram = blank_vram();
vram[0x0010] = 0x00;
vram[0x0011] = 0xFF; vram[0x0020] = 0xFF;
vram[0x0021] = 0x00; let lcdc = 0x02u8;
let indices = vec![0usize, 1usize];
let result = fetch_sprite_pixel(0, 0, &indices, &oam, &vram, lcdc);
assert!(result.is_some());
assert_eq!(
result.unwrap().colour_index,
2,
"on equal OAM_X, lower OAM index (sprite 0, colour 2) should win"
);
}
#[test]
fn test_sprite_palette_bit_selected_from_attr() {
let mut vram = blank_vram();
vram[0x0010] = 0xFF; vram[0x0011] = 0x00;
let oam = oam_with_sprite_at(16, 8, 1, 0x10); let lcdc = 0x02u8;
let indices = vec![0usize];
let result = fetch_sprite_pixel(0, 0, &indices, &oam, &vram, lcdc).unwrap();
assert_eq!(result.palette, 1);
}
fn oam_with_sprites(positions: &[(u8, u8)]) -> ([u8; 0xA0], Vec<usize>) {
let mut oam = blank_oam();
let mut indices = Vec::new();
for (i, &(y, x)) in positions.iter().enumerate() {
oam[i * 4] = y;
oam[i * 4 + 1] = x;
oam[i * 4 + 2] = 0x30 + i as u8; oam[i * 4 + 3] = 0;
indices.push(i);
}
(oam, indices)
}
fn penalty_sprites(x_positions: &[u8]) -> ([u8; 0xA0], Vec<usize>) {
let positions: Vec<(u8, u8)> = x_positions.iter().map(|&x| (0x52, x)).collect();
oam_with_sprites(&positions)
}
#[test]
fn test_obj_penalty_no_sprites_returns_zero() {
let oam = blank_oam();
assert_eq!(calculate_obj_penalty(&[], &oam, 0), 0);
}
#[test]
fn test_obj_penalty_single_sprite_at_x0_is_11_dots() {
let (oam, indices) = penalty_sprites(&[0]);
assert_eq!(calculate_obj_penalty(&indices, &oam, 0), 11);
}
#[test]
fn test_obj_penalty_single_sprite_at_x8_is_11_dots() {
let (oam, indices) = penalty_sprites(&[8]);
assert_eq!(calculate_obj_penalty(&indices, &oam, 0), 11);
}
#[test]
fn test_obj_penalty_single_sprite_at_x5_is_6_dots() {
let (oam, indices) = penalty_sprites(&[5]);
assert_eq!(calculate_obj_penalty(&indices, &oam, 0), 6);
}
#[test]
fn test_obj_penalty_single_sprite_at_x4_is_7_dots() {
let (oam, indices) = penalty_sprites(&[4]);
assert_eq!(calculate_obj_penalty(&indices, &oam, 0), 7);
}
#[test]
fn test_obj_penalty_single_sprite_at_x167_is_6_dots() {
let (oam, indices) = penalty_sprites(&[167]);
assert_eq!(calculate_obj_penalty(&indices, &oam, 0), 6);
}
#[test]
fn test_obj_penalty_sprite_at_x168_is_offscreen_no_penalty() {
let (oam, indices) = penalty_sprites(&[168]);
assert_eq!(calculate_obj_penalty(&indices, &oam, 0), 0);
}
#[test]
fn test_obj_penalty_two_sprites_at_x0_share_tile() {
let (oam, indices) = penalty_sprites(&[0, 0]);
assert_eq!(calculate_obj_penalty(&indices, &oam, 0), 17);
}
#[test]
fn test_obj_penalty_ten_sprites_at_x0() {
let (oam, indices) = penalty_sprites(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
assert_eq!(calculate_obj_penalty(&indices, &oam, 0), 65);
}
#[test]
fn test_obj_penalty_ten_sprites_spread_across_tiles() {
let (oam, indices) = penalty_sprites(&[0, 8, 16, 24, 32, 40, 48, 56, 64, 72]);
assert_eq!(calculate_obj_penalty(&indices, &oam, 0), 110);
}
#[test]
fn test_obj_penalty_reverse_oam_order_same_result() {
let (oam, indices) = penalty_sprites(&[72, 64, 56, 48, 40, 32, 24, 16, 8, 0]);
assert_eq!(calculate_obj_penalty(&indices, &oam, 0), 110);
}
#[test]
fn test_obj_penalty_two_groups_different_tiles() {
let (oam, indices) = penalty_sprites(&[0, 0, 0, 0, 0, 160, 160, 160, 160, 160]);
assert_eq!(calculate_obj_penalty(&indices, &oam, 0), 70);
}
#[test]
fn test_obj_penalty_scx_shifts_tile_boundaries() {
let (oam, indices) = penalty_sprites(&[8]);
assert_eq!(calculate_obj_penalty(&indices, &oam, 4), 7);
}
#[test]
fn test_obj_penalty_x0_exception_ignores_scx() {
let (oam, indices) = penalty_sprites(&[0]);
assert_eq!(calculate_obj_penalty(&indices, &oam, 3), 11);
assert_eq!(calculate_obj_penalty(&indices, &oam, 7), 11);
}
#[test]
fn test_cgb_sprite_palette_bits_extracted_from_attrs() {
let mut oam = blank_oam();
oam[0] = 16; oam[1] = 8; oam[2] = 0; oam[3] = 0x05; let mut vram = blank_vram();
vram[0x0000] = 0xFF; let bank1 = blank_vram();
let lcdc = 0x02u8; let indices = [0usize];
let result = fetch_sprite_pixel_cgb(0, 0, &indices, &oam, &vram, &bank1, lcdc, false);
assert!(result.is_some());
assert_eq!(result.unwrap().cgb_palette, 5);
}
#[test]
fn test_cgb_sprite_tile_data_read_from_vram_bank1_when_attr_bit3_set() {
let mut oam = blank_oam();
oam[0] = 16;
oam[1] = 8;
oam[2] = 0;
oam[3] = 0x08; let vram = blank_vram(); let mut bank1 = blank_vram();
bank1[0x0000] = 0xFF; let lcdc = 0x02u8;
let indices = [0usize];
let result = fetch_sprite_pixel_cgb(0, 0, &indices, &oam, &vram, &bank1, lcdc, false);
assert!(result.is_some(), "tile data should come from VRAM bank 1");
assert_eq!(result.unwrap().colour_index, 1);
}
#[test]
fn test_cgb_sprite_oam_order_priority_when_dmg_mode_false() {
let mut oam = blank_oam();
oam[0] = 16;
oam[1] = 9;
oam[2] = 0;
oam[3] = 0x01; oam[4] = 16;
oam[5] = 8;
oam[6] = 1;
oam[7] = 0x02; let mut vram = blank_vram();
vram[0x0000] = 0xFF; vram[0x0010] = 0xFF;
vram[0x0011] = 0xFF; let bank1 = blank_vram();
let lcdc = 0x02u8;
let indices = [0usize, 1usize];
let result = fetch_sprite_pixel_cgb(1, 0, &indices, &oam, &vram, &bank1, lcdc, false);
assert!(result.is_some());
assert_eq!(
result.unwrap().cgb_palette,
1,
"OAM-order: sprite 0 (palette 1) should win"
);
}
#[test]
fn test_cgb_sprite_dmg_xcoord_priority_when_dmg_mode_true() {
let mut oam = blank_oam();
oam[0] = 16;
oam[1] = 9;
oam[2] = 0;
oam[3] = 0x01; oam[4] = 16;
oam[5] = 8;
oam[6] = 1;
oam[7] = 0x02; let mut vram = blank_vram();
vram[0x0000] = 0xFF;
vram[0x0010] = 0xFF;
vram[0x0011] = 0xFF;
let bank1 = blank_vram();
let lcdc = 0x02u8;
let indices = [0usize, 1usize];
let result = fetch_sprite_pixel_cgb(1, 0, &indices, &oam, &vram, &bank1, lcdc, true);
assert!(result.is_some());
assert_eq!(
result.unwrap().cgb_palette,
2,
"X-coord priority: sprite 1 (palette 2, lower X) should win"
);
}
}