From d7de5f71216ca39e84451151a1568e6b5ab89aae Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Wed, 7 Oct 2026 10:06:48 +0700 Subject: [PATCH] fix(video): read BG maps and tiles through the 128 KiB VRAM mirror window --- crates/gba-core/src/video.rs | 23 +++++++---- crates/gba-core/src/video/affine.rs | 12 ++++-- crates/gba-core/tests/affine_backgrounds.rs | 22 ++++++---- crates/gba-core/tests/tiles.rs | 46 +++++++++------------ docs/hardware/video.md | 5 ++- docs/status.md | 2 +- 6 files changed, 60 insertions(+), 50 deletions(-) diff --git a/crates/gba-core/src/video.rs b/crates/gba-core/src/video.rs index 665b55f..e2d393e 100644 --- a/crates/gba-core/src/video.rs +++ b/crates/gba-core/src/video.rs @@ -43,11 +43,11 @@ impl fmt::Display for VideoError { ), Self::UnsupportedTileAddress(address) => write!( f, - "text background tile address {address:#x} exceeds the supported 64 KiB BG area" + "background tile address {address:#x} exceeds the supported 128 KiB BG VRAM window" ), Self::UnsupportedMapAddress(address) => write!( f, - "background map address {address:#x} exceeds the supported 64 KiB BG area" + "background map address {address:#x} exceeds the supported 128 KiB BG VRAM window" ), Self::UnsupportedObject { index, reason } => write!(f, "OBJ{index}: {reason}"), Self::UnsupportedLayers(control) => { @@ -250,6 +250,16 @@ fn halfword(bytes: &[u8], offset: usize) -> u16 { u16::from_le_bytes([bytes[offset], bytes[offset + 1]]) } +/// Translate a BG VRAM offset through the bus's 128 KiB mirror window. +fn vram_index(offset: usize) -> usize { + let offset = offset & 0x1ffff; + if offset >= 0x18000 { + offset - 0x8000 + } else { + offset + } +} + fn text_pixel( bg: Background, vram: &[u8], @@ -265,10 +275,10 @@ fn text_pixel( let y = (y + usize::from(bg.y)) % height; let block = x / 256 + (y / 256) * (width / 256); let map = ((control >> 8) & 31) * 0x800 + block * 0x800 + ((y / 8 % 32) * 32 + x / 8 % 32) * 2; - if map >= 0x10000 { + if map >= 0x20000 { return Err(VideoError::UnsupportedMapAddress(map)); } - let entry = usize::from(halfword(vram, map)); + let entry = usize::from(halfword(vram, vram_index(map))); let tx = (x & 7) ^ if entry & 0x400 != 0 { 7 } else { 0 }; let ty = (y & 7) ^ if entry & 0x800 != 0 { 7 } else { 0 }; let eight_bit = control & 0x80 != 0; @@ -276,11 +286,10 @@ fn text_pixel( let address = ((control >> 2) & 3) * 0x4000 + (entry & 0x3ff) * if eight_bit { 64 } else { 32 } + if eight_bit { pixel } else { pixel / 2 }; - // The real PPU has special behavior outside BG VRAM. Do not read OBJ tiles - // as ordinary BG data or silently wrap this unsupported case. - if address >= 0x10000 { + if address >= 0x20000 { return Err(VideoError::UnsupportedTileAddress(address)); } + let address = vram_index(address); let index = if eight_bit { usize::from(vram[address]) } else { diff --git a/crates/gba-core/src/video/affine.rs b/crates/gba-core/src/video/affine.rs index 7421b9a..7320594 100644 --- a/crates/gba-core/src/video/affine.rs +++ b/crates/gba-core/src/video/affine.rs @@ -2,7 +2,7 @@ //! Internal origins advance at line boundaries. Pixel-fetch and per-access timing //! remain outside this row-level model. -use super::{halfword, Background, VideoError, HEIGHT, WIDTH}; +use super::{halfword, vram_index, Background, VideoError, HEIGHT, WIDTH}; #[cfg(test)] use crate::display::{CYCLES_PER_FRAME, CYCLES_PER_LINE}; @@ -136,14 +136,18 @@ pub(super) fn pixel( } let (tx, ty) = (tx as usize, ty as usize); let map = ((control >> 8) & 31) * 0x800 + (ty / 8) * (size as usize / 8) + tx / 8; - if map >= 0x10000 { + if map >= 0x20000 { return Err(VideoError::UnsupportedMapAddress(map)); } // Affine maps have byte-sized indices and no flip/palette-bank attributes. // Every tile is 8bpp, regardless of BGCNT bit 7. + let address = vram_index(map); let address = - ((control >> 2) & 3) * 0x4000 + usize::from(vram[map]) * 64 + (ty % 8) * 8 + tx % 8; - let index = usize::from(vram[address]); // Maximum address is 0xffff. + ((control >> 2) & 3) * 0x4000 + usize::from(vram[address]) * 64 + (ty % 8) * 8 + tx % 8; + if address >= 0x20000 { + return Err(VideoError::UnsupportedTileAddress(address)); + } + let index = usize::from(vram[vram_index(address)]); Ok(if index == 0 { None } else { diff --git a/crates/gba-core/tests/affine_backgrounds.rs b/crates/gba-core/tests/affine_backgrounds.rs index 2916312..a1e883f 100644 --- a/crates/gba-core/tests/affine_backgrounds.rs +++ b/crates/gba-core/tests/affine_backgrounds.rs @@ -252,7 +252,7 @@ fn transformed_backgrounds_composite_with_regular_and_affine_sprites() { } #[test] -fn diagnostics_leave_output_unchanged_and_disabled_layers_skip_validation() { +fn unsupported_layers_leave_output_unchanged_and_disabled_layers_skip_validation() { let mut m = setup(2, 3, false); let mut f = Framebuffer::default(); f.clear(0x001f); @@ -262,15 +262,8 @@ fn diagnostics_leave_output_unchanged_and_disabled_layers_skip_validation() { m.render_frame(&mut f), Err(VideoError::UnsupportedLayers(control)) ); + assert!(f.pixels().iter().all(|&p| p == 0xff0000)); } - m.write16(DISPCNT, 0x402).unwrap(); - m.write16(BG2CNT, 0xdf00).unwrap(); // Map31, 1024 pixels. - m.write32(BG2Y, 128 * 256).unwrap(); // Flat row starts outside first64K. - assert_eq!( - m.render_frame(&mut f), - Err(VideoError::UnsupportedMapAddress(0x10000)) - ); - assert!(f.pixels().iter().all(|&p| p == 0xff0000)); m.write16(DISPCNT, 2).unwrap(); m.render_frame(&mut f).unwrap(); assert!(f.pixels().iter().all(|&p| p == rgb555_to_rgb888(0x4000))); @@ -279,6 +272,17 @@ fn diagnostics_leave_output_unchanged_and_disabled_layers_skip_validation() { assert!(f.pixels().iter().all(|&p| p == 0xffffff)); } +#[test] +fn affine_map_at_map_base_31_reads_high_vram_through_the_bus_mirror() { + let mut m = setup(2, 3, false); + m.write16(BG2CNT, 0xdf00).unwrap(); // Map31, 1024 pixels. + m.write32(BG2Y, 128 * 256).unwrap(); // Map row16 starts at offset0x10000. + m.write16(VRAM + 0x18000, 1).unwrap(); // Halfword bus mirror of the selected map byte. + let mut f = Framebuffer::default(); + m.render_frame(&mut f).unwrap(); + assert_eq!(f.pixels()[0], rgb555_to_rgb888(31)); +} + #[test] fn cpu_demo_pans_rotates_zooms_clips_cancels_opposites_and_resets() { use gba_core::input::{Button, Buttons}; diff --git a/crates/gba-core/tests/tiles.rs b/crates/gba-core/tests/tiles.rs index c23894d..df65641 100644 --- a/crates/gba-core/tests/tiles.rs +++ b/crates/gba-core/tests/tiles.rs @@ -184,48 +184,40 @@ fn priorities_ties_transparency_and_opaque_black_composite_all_four_layers() { fn character_base_and_high_tile_numbers_cross_character_blocks() { for eight in [false, true] { let mut m = setup(); - m.write16(BG0CNT, 0x1004 | if eight { 0x80 } else { 0 }) + m.write16(BG0CNT, 0x100c | if eight { 0x80 } else { 0 }) .unwrap(); m.write16(VRAM + 0x8000, 1023).unwrap(); - let address = 0x4000 + 1023 * if eight { 64 } else { 32 }; - if eight { - assert!(matches!( - m.render_frame(&mut Framebuffer::default()), - Err(VideoError::UnsupportedTileAddress(_)) - )); - } else { - tile(&mut m, address, 0x1111, 32); - assert_eq!(pixel(&m, 0, 0), 0xff0000); - } + let address = 0xc000 + 1023 * if eight { 64 } else { 32 }; + tile( + &mut m, + address, + if eight { 0x0101 } else { 0x1111 }, + if eight { 64 } else { 32 }, + ); + assert_eq!(pixel(&m, 0, 0), 0xff0000); } } #[test] -fn screen_map_outside_supported_bg_area_reports_error_without_wrapping() { +fn wide_screen_block_at_map_base_31_reads_beyond_64k() { let mut m = setup(); m.write16(BG0CNT, 0x5f04).unwrap(); // Map block31, wide; tile base1. - m.write16(BG0HOFS, 256).unwrap(); // Second block lies outside the BG area. - let mut frame = Framebuffer::default(); - frame.clear(0x001f); - assert_eq!( - m.render_frame(&mut frame), - Err(VideoError::UnsupportedMapAddress(0x10000)) - ); - assert!(frame.pixels().iter().all(|&p| p == 0xff0000)); + m.write16(BG0HOFS, 256).unwrap(); // First pixel selects screen block1 at 0x10000. + m.write16(VRAM + 0x18000, 1).unwrap(); // Bus mirror of the selected map entry. + tile(&mut m, 0x4020, 0x1111, 32); + assert_eq!(pixel(&m, 0, 0), 0xff0000); } #[test] -fn unsupported_features_leave_output_unchanged_and_forced_blank_bypasses_them() { +fn unsupported_modes_leave_output_unchanged_and_forced_blank_bypasses_them() { let mut m = setup(); let mut frame = Framebuffer::default(); frame.clear(0x001f); - m.write16(DISPCNT, 0x100).unwrap(); - m.write16(BG0CNT, 0x100c).unwrap(); - m.write16(VRAM + 0x8002, 1023).unwrap(); // Error after some valid pixels. - assert!(matches!( + m.write16(DISPCNT, 0x106).unwrap(); + assert_eq!( m.render_frame(&mut frame), - Err(VideoError::UnsupportedTileAddress(_)) - )); + Err(VideoError::UnsupportedMode(6)) + ); assert!(frame.pixels().iter().all(|&p| p == 0xff0000)); m.write16(DISPCNT, 0xf180).unwrap(); m.render_frame(&mut frame).unwrap(); diff --git a/docs/hardware/video.md b/docs/hardware/video.md index 1da79aa..bb36b56 100644 --- a/docs/hardware/video.md +++ b/docs/hardware/video.md @@ -66,7 +66,8 @@ Mode 0 supports all four text backgrounds, with these features: - Uncovered pixels use palette entry zero. Green swap runs after composition. Mode 0 supports mosaic independently on each background. Rendering errors leave the output buffer unchanged. -Background tile or map fetches outside the first 64 KiB return diagnostics. +Background tile or map fetches use the 128 KiB VRAM window; offsets `0x18000..0x1ffff` +mirror `0x10000..0x17fff`. Addresses at or beyond `0x20000` return diagnostics. This is not a complete picture processing unit (PPU). Affine background snapshots support these configurations: @@ -94,7 +95,7 @@ Map sizes are 128×128, 256×256, 512×512, or 1024×1024 pixels. BGCNT bit 13 selects wrapping or transparent edges in Modes 1 and 2. All bitmap modes ignore this bit. Palette index zero is transparent in tiled backgrounds and Mode 4. Mode 3/5 black bitmap pixels remain opaque. Background priorities, sprite composition, and green swap apply after sampling. -Map fetches beyond the supported 64 KiB background area still return diagnostics. +Map fetches use the same 128 KiB VRAM mirror. Addresses at or beyond `0x20000` return diagnostics. **Timing limit:** debug snapshots use one set of programmed registers for the whole image. Row capture instead samples from separate internal X/Y origins. diff --git a/docs/status.md b/docs/status.md index 8f2a40a..f4f583c 100644 --- a/docs/status.md +++ b/docs/status.md @@ -199,7 +199,7 @@ The replacement BIOS now returns for that empty stream. Full starter-selection p A later [visible-progress probe](research/emerald-visible-progress.md) renders the title screen, logo, NEW GAME/OPTION menu, Prof. Birch scene, and legible dialogue in attract mode. Sparse A/Start taps reach the gender prompt by 4,000 frames and the in-game start menu over the overworld by 12,000 frames without a diagnostic. A 40,000-frame tapped run passed the reported 37,101-frame trap point without a diagnostic; blind taps take a different menu path, so the identical scene is not confirmed visited. -A local second-game ROM faults in boot init on a halfword store to ROM mirror `0x09fe2ffe` past its 16 MiB EOF; without save hardware it faults earlier on a Flash unlock write. Both are recorded, not fixed. +Text/affine background map and tile fetches now follow the 128 KiB VRAM mirror window instead of faulting past 64 KiB; this fixes a user-reported window diagnostic at map address `0x10d00` after 23,024 captured frames. See [the startup result](research/emerald-reset.md) for the bounded run and next investigation. See [cartridge loading](hardware/cartridge.md) for file limits, step budgets, and exit status. The [ROM suite runner](rom-tests.md) has original regressions and a [pinned public ARM baseline](public-arm-tests.md).