This project is educational and Open Source. No code is copied from other emulators. Implementation based solely on technical documentation and permitted tests.
DMA/OAM Correctness + Sprite Visible Test + Freeze Hardware Contracts
Summary
End-to-end validation of OAM DMA (0xFF46): creation of clean-room tests that verify that DMA correctly copies 160 bytes from source to OAM, and that the PPU consumes real OAM (sprites appear when OAM has data). Added 2 main tests that freeze the hardware contract: (1) DMA copy correct, (2) Sprites render (at least 1 sprite visible). Optionally, added OAM metrics to headless tool for future diagnosis.
Hardware Concept
DMA (Direct Memory Access) OAM Transfer (0xFF46): The Game Boy includes a DMA mechanism that allows data to be copied to the OAM (Object Attribute Memory) without direct intervention from the CPU. Write a valueXXin0xFF46initiates a transfer that copies 160 bytes from the addressXX00until0xFE00-0xFE9F(OAM).
On real hardware, the transfer takes approximately 160 microseconds (640 T-cycles), and during this time the CPU can only access HRAM (0xFF80-0xFFFE). However, for this Step we focus on thedata correctness(that the copy is correct) before the exact timing.
OAM (Object Attribute Memory): The OAM contains data for up to 40 sprites. Each sprite occupies 4 bytes:
- Byte 0: Y position (0-255, offset 16 for display)
- Byte 1: X position (0-255, offset 8 for display)
- Byte 2: Tile ID (0-255, tile index in VRAM)
- Byte 3: Flags (palette, flip X/Y, priority, etc.)
Sprite Rendering: The PPU reads OAM during OAM Search mode (Mode 2) to determine which sprites are visible in the current scanline. If OAM has valid data (Y/X within range, tile data present in VRAM), the sprite should appear in the framebuffer.
Fountain: Pan Docs - "DMA Transfer", "OAM (Object Attribute Memory)", "Sprite Rendering"
Implementation
Previous Audit: Verified that DMA OAM (0xFF46) already exists insrc/core/cpp/MMU.cpplines 967-1004. The current implementation:
- Detect writing to
0xFF46 - Calculate
source_base = value<< 8 - Runs loop that copies 160 bytes from
source_addrto OAM (0xFE00-0xFE9F) - Use
read(source_addr)to read (correct: can be ROM/VRAM/WRAM) - Write directly to
memory_[0xFE00 + i](correct: special bypass according to Pan Docs) - Model: instantaneous (no delay/timing)
Conclusion: Correct basic implementation. We need to validate it with clean-room tests.
Phase A - Clean-Room Test: "DMA Copies 160 Bytes"
Archive: tests/test_dma_oam_copy_0444.py (< 80 líneas)
Test 1:test_dma_oam_copies_160_bytes():
- Prepare incremental pattern in WRAM (0xC000-0xC09F): 0x00, 0x01, 0x02, ..., 0x9F
- Verify that OAM is clean (0xFE00-0xFE9F = 0)
- Activate DMA: write source page (0xC0) to 0xFF46
- Verify that DMA copied correctly:
mem[0xFE00+i] == pattern[i]fori=0..0x9F - Verify that source has not changed (DMA is read-only on source)
Test 2:test_dma_oam_from_different_source():
- Validate that DMA works from different sources (alternative WRAM 0xD000)
- Different pattern:
0xAA+(i & 0x0F) - Verify correct copy
Phase B - Clean-Room Test: "Single Sprite Visible"
Archive: tests/test_sprite_visible_0444.py (< 100 líneas)
Test:test_single_sprite_visible():
- Load tile data for sprite (Tile ID 0x00, address 0x8000): checkerboard pattern (0xAA/0x55 alternating)
- Set OAM entry for sprite: y=36 (scanline 20), x=38 (column 30), tile_id=0x00, flags=0x00
- Activate LCD and sprites: LCDC=0x83 (LCD on, sprites on, BG on)
- Run 3 full frames (70224 T-cycles each)
- Check framebuffer: look for non-white pixels in the sprite's bounding box (scanlines 20-27, columns 30-37)
- Validation: there must be at least 10 non-white pixels in the bounding box
Metric used: "bounding box nonwhite" instead of "exact image" (allows transparencies and palette variations)
Phase C - OAM Metrics in Headless Tool (Optional)
Archive: tools/rom_smoke_0442.py(modified)
Added method:_sample_oam_nonzero():
- Samples every 4th byte in OAM (0xFE00-0xFE9F): 40 samples
- Non-zero byte count
- Estimate total by multiplying by 4
Updated_collect_metrics(): Includes fieldoam_nonzeroin metrics dictionary
Updated_print_summary(): Show nonzero OAM statistics (min, max, average)
Updated periodic dump: Sampleoam_nzin frame output
Design Decisions
- Correctness > Timing: Focus on validating that DMA copies data correctly before exact timing (640 T-cycles). Fine timing can be added in dedicated step if necessary.
- Small tests: Both tests are< 100 líneas, sin PNG, sin pygame. Ejecutan rápido y son fáciles de mantener.
- Bounding box nonwhite: Instead of validating the exact sprite image, we validate that there are non-white pixels in the expected bounding box. This is more robust and allows for palette/transparency variations.
- Optional OAM Metrics: Added only if it costs little (< 30 líneas), útil para diagnóstico futuro pero no crítico para este Step.
Affected Files
tests/test_dma_oam_copy_0444.py(new) - Clean-room test that validates DMA copies 160 bytes correctlytests/test_sprite_visible_0444.py(new) - Clean-room test that validates visible sprite in framebuffertools/rom_smoke_0442.py(modified) - Added OAM metrics for future diagnostics
Tests and Verification
Command executed: pytest tests/test_dma_oam_copy_0444.py -v
Result: 2 passed in 0.24s
Command executed: pytest tests/test_sprite_visible_0444.py -v
Result: 2 passed in 0.45s
Command executed: pytest -q
Result: 537 passed(full suite)
C++ Compiled Module Validation: ✅ Build successful, test_build.py passes
Key Test Code
def test_dma_oam_copies_160_bytes():
"""Validates that DMA correctly copies 160 bytes from WRAM to OAM."""
# Prepare pattern in WRAM (0xC000-0xC09F)
source_base = 0xC000
pattern = [i & 0xFF for i in range(160)]
for i, byte_value in enumerate(pattern):
mmu.write(source_base + i, byte_value)
# Enable DMA: write source page (0xC0) to 0xFF46
dma_source_page = 0xC0
mmu.write(0xFF46, dma_source_page)
# Verify that DMA copied correctly
for i in range(160):
expected = pattern[i]
current = mmu.read(0xFE00 + i)
assert current == expected, f"DMA copy failed on byte {i}"
def test_single_sprite_visible():
"""Validates that a visible sprite appears in the framebuffer."""
# Configure OAM entry for sprite
oam_addr = 0xFE00
mmu.write(oam_addr + 0, 16 + 20) # y = 36
mmu.write(oam_addr + 1, 8 + 30) # x = 38
mmu.write(oam_addr + 2, 0x00) # tile_id = 0
mmu.write(oam_addr + 3, 0x00) # flags = 0
# Run 3 frames
for frame in range(3):
frame_cycles = 0
while frame_cycles< CYCLES_PER_FRAME:
cycles = cpu.step()
ppu.step(cycles)
timer.step(cycles)
frame_cycles += cycles
# Verificar framebuffer: píxeles non-white en bounding box
framebuffer = ppu.get_framebuffer_rgb()
nonwhite_count = 0
for y in range(20, 28):
for x in range(30, 38):
idx = (y * 160 + x) * 3
r, g, b = framebuffer[idx], framebuffer[idx+1], framebuffer[idx+2]
if r < 200 or g < 200 or b < 200:
nonwhite_count += 1
assert nonwhite_count >= 10, f"Sprite not visible: only {nonwhite_count} non-white pixels"
Sources consulted
- Bread Docs:DMA Transfer
- Bread Docs:OAM (Object Attribute Memory)
- Bread Docs:Sprite Rendering
Educational Integrity
What I Understand Now
- DMA OAM Correctness: DMA copies 160 bytes from source (value<< 8) a OAM (0xFE00-0xFE9F). La implementación actual es instantánea (sin timing), pero la correctness de datos es correcta. Tests clean-room confirman que la copia funciona correctamente.
- Sprite Rendering: The PPU consumes real OAM during OAM Search (Mode 2). If OAM has valid data (Y/X within range, tile data present), the sprite appears in the framebuffer. Test clean-room confirms which sprites appear when OAM has data.
- Hardware Contracts Frozen: Tests freeze hardware contract: (1) DMA copy correct, (2) Sprites render. This makes it possible to detect future regressions.
What remains to be confirmed
- DMA Timing: Exact DMA timing (640 T-cycles) and memory access blocking during DMA (except HRAM). This can be added in dedicated step if needed for specific games.
- Sprite Priority: Sprite priority (OBP flags) and rendering order. Current tests use flags=0x00 (normal priority).
- Sprite Flip: Flip X/Y of sprites (flags bits). Current tests do not validate flip.
Hypotheses and Assumptions
Instant DMA Model: We assume that DMA is instantaneous (without delay) for this Step. On real hardware, DMA takes 640 T-cycles and blocks memory access (except HRAM). If specific games (e.g. Pokémon) require exact timing, it can be added in a dedicated step.
Bounding Box Nonwhite: We assume that validating "at least 10 non-white pixels in bounding box" is enough to confirm that sprite is visible. This allows palette/transparency variations without requiring exact image.
Next Steps
- [ ] If Pokémon or other games are still weird after this Step, attack fine timing of DMA/locks in dedicated step
- [ ] Validate sprite priority and flip X/Y with additional tests if necessary
- [ ] Use headless tool OAM metrics to diagnose sprite issues in real ROMs