fix(video): read BG maps and tiles through the 128 KiB VRAM mirror window

This commit is contained in:
0xrsydn 2026-10-07 10:06:48 +07:00
commit d7de5f7121
6 changed files with 60 additions and 50 deletions

View file

@ -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 {

View file

@ -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 {

View file

@ -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))
);
}
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};

View file

@ -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);
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();

View file

@ -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.

View file

@ -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).