This project is educational and Open Source. No code is copied from other emulators. Implementation based solely on technical documentation and permitted tests.
Frame-Ready + VRAM Address Sanity + Buffer Swap
Summary
Critical fix to the semantics ofMMU::read_vram(): The method expects absolute addresses (0x8000-0x9FFF), but the PPU was passing offsets (calculated astile_map_base - 0x8000). This caused out of range reads that returned 0xFF or 0, resulting in empty tiles and framebuffer at 0. Fixed all calls toread_vram()inPPU.cppto pass absolute addresses, changedrun_one_frame()to useget_frame_ready_and_reset()instead of fixed cycles, and VRAM sanity checks were added to the tests. Result: ✅ Corrections applied. ⚠️ Tests still fail - framebuffer returns 0 (requires more research on timing/rendering).
Hardware Concept
Identified problem: The framebuffer in tests comes out all at 0, even though tiles are written correctly. The plan identified 4 possible causes:
- (A) read_vram() used with absolute addr vs offset- Most likely:
MMU::read_vram()expects absolute address (0x8000-0x9FFF), but PPU passed offsets - (B) frame timing / frame_ready- Tests used 70224 canned cycles instead of waiting
frame_ready - (C) wrong front/back swap or getter -
get_framebuffer_indices()could return incorrect buffer - (D) BG not really rendering- Incorrect status condition (LCDC/STAT)
Semantics of MMU::read_vram(): The method expects an absolute address in the range 0x8000-0x9FFF. Internally it calculates the offset:offset = addr - 0x8000. If an offset is passed directly (e.g. 0x1800 for 0x9800), the method attempts to read from0x8000 + 0x1800 = 0x9800, but the validationif (addr< 0x8000 || addr >0x9FFF)fails if the offset is less than 0x8000, returning 0xFF.
Frame-Ready vs Fixed Cycles: Using 70224 cycles as a universal truth is incorrect because the actual timing depends on the state of the PPU. It is better to useget_frame_ready_and_reset()that returnstruewhen LY goes from 143 to 144 (start of V-Blank), indicating that a full frame has been rendered.
Reference: Pan Docs - VRAM Access, PPU Modes, Frame Timing. Step 0123 - Frame-ready C++-Python communication.
Implementation
The fix was implemented in five phases according to the plan:
Phase A: Frame-Ready instead of Fixed Cycles
It was modifiedrun_one_frame()to useget_frame_ready_and_reset():
def run_one_frame(self):
"""Helper: Run until PPU declares frame ready.
It does not use 70224 as a universal truth. Step until frame_ready == True.
Put a cap (maximum 4 frames-worth) to avoid infinite loops.
"""
max_cycles = 70224 * 4 # Cap: maximum 4 frames-worth
cycles_accumulated = 0
frame_ready = False
while not frame_ready and cycles_accumulated< max_cycles:
cycles = self.cpu.step()
cycles_accumulated += cycles
self.timer.step(cycles)
self.ppu.step(cycles)
# Verificar si hay frame listo
frame_ready = self.ppu.get_frame_ready_and_reset()
# Assert que se completó un frame
assert frame_ready, \
f"Frame no se completó después de {cycles_accumulated} ciclos (máximo {max_cycles})"
return cycles_accumulated
Phase B: VRAM Sanity Checks in Tests
Added VRAM checks usingread_raw()before rendering:
# Sanity check: Verify that VRAM contains what was written (using read_raw)
assert self.mmu.read_raw(0x8000) == 0x55, \
f"Tile 0 byte1 at 0x8000 must be 0x55, it is 0x{self.mmu.read_raw(0x8000):02X}"
assert self.mmu.read_raw(0x8001) == 0x33, \
f"Tile 0 byte2 at 0x8001 must be 0x33, it is 0x{self.mmu.read_raw(0x8001):02X}"
assert self.mmu.read_raw(0x9800) == 0x00, \
f"Tilemap 0x9800[0] must be 0x00, it is 0x{self.mmu.read_raw(0x9800):02X}"
assert self.mmu.read_raw(0x9C00) == 0x01, \
f"Tilemap 0x9C00[0] must be 0x01, it is 0x{self.mmu.read_raw(0x9C00):02X}"
Also added verification that the framebuffer is not all at 0:
# Verify that everything is not at 0
non_zero_count = sum(1 for i in range(160 * 144) if (indices[i] & 0x03) != 0)
assert non_zero_count > 0, \
f"Framebuffer is all 0 ({non_zero_count} non-zero pixels of {160*144})"
Phase C: Correct Semantics of read_vram() - CRITICAL
Fixed all calls toread_vram()inPPU.cppto pass absolute addresses (not offsets). Found and fixed 12+ occurrences:
// BEFORE (INCORRECT - passes offset):
uint16_t tile_map_offset = (tile_map_base - 0x8000) + i;
if (tile_map_offset< 0x2000) {
tile_ids_sample[i] = mmu_->read_vram(tile_map_offset); // ❌ Offset
}
// AFTER (CORRECT - passes addr absolute):
uint16_t tile_map_addr = tile_map_base + i;
if (tile_map_addr >= 0x8000 && tile_map_addr<= 0x9FFF) {
tile_ids_sample[i] = mmu_->read_vram(tile_map_addr); // ✅ Absolute Addr
}
Fixed places:
- Line 2198-2200: Tilemap diagnostics (sample of 16 tile IDs)
- Line 2256-2259: Immediate tilemap verification when there are tiles
- Line 2341-2344: tilemap-tiles correspondence analysis
- Line 2402-2405: Verification of tile IDs in correspondence
- Line 2436-2439: Tilemap verification (first 4 tiles)
- Line 2479-2482: Tilemap inspection (first 32 bytes)
- Line 2493-2496: Tilemap checksum
- Line 2549-2552: Diagnostic frame 676
- Line 2580-2583: Verification always active
- Line 2617-2620: Tilemap visual dump
- Line 2846-2849: BG rendering (critical - production code)
- Line 3065-3068: Tile addr verification
- Line 3097-3100: Verification of rendering with real tiles
Phase D: Verify Framebuffer Getter
It was verified thatget_framebuffer_indices()returns the correct buffer (front post-swap). The method was already correct: returnsframebuffer_front_which is the buffer presented after the swap.
Phase E: Check BG Status (LCDC/STAT)
The tests already correctly configure LCDC (bit 7 = LCD ON, bit 0 = BG ON). No status issues were found.
Affected Files
tests/test_bg_tilemap_base_and_scroll_0464.py- Modifiedrun_one_frame()to useget_frame_ready_and_reset(), added VRAM sanity checks withread_raw(), and non-zero framebuffer checkingsrc/core/cpp/PPU.cpp- Fixed 12+ calls toread_vram()to pass absolute addresses (not offsets). Changes in lines: 2198-2200, 2256-2259, 2341-2344, 2402-2405, 2436-2439, 2479-2482, 2493-2496, 2549-2552, 2580-2583, 2617-2620, 2846-2849, 3065-3068, 3097-3100
Tests and Verification
Command executed: pytest tests/test_bg_tilemap_base_and_scroll_0464.py -v
Result: ⚠️ Tests fail - framebuffer returns 0 (0 non-zero pixels out of 23040)
Diagnosis: Although the semantic fixes ofread_vram()are applied, the framebuffer still returns 0. This suggests that the problem may be:
- Timing: The frame does not complete correctly before reading the framebuffer
- Rendering: The PPU is not rendering the BG due to some unmet condition
- Swap: Buffer swap does not occur or occurs after reading
Test Code:
def run_one_frame(self):
"""Helper: Run until PPU declares frame ready."""
max_cycles = 70224 * 4
cycles_accumulated = 0
frame_ready = False
while not frame_ready and cycles_accumulated< max_cycles:
cycles = self.cpu.step()
cycles_accumulated += cycles
self.timer.step(cycles)
self.ppu.step(cycles)
frame_ready = self.ppu.get_frame_ready_and_reset()
assert frame_ready, f"Frame no se completó después de {cycles_accumulated} ciclos"
return cycles_accumulated
def test_tilemap_base_select_9800(self):
"""Test 1: tilemap base select (0x9800 vs 0x9C00) - Caso 0x9800."""
# ... setup tiles y tilemap ...
# Sanity check: Verificar que VRAM contiene lo escrito
assert self.mmu.read_raw(0x8000) == 0x55
assert self.mmu.read_raw(0x9800) == 0x00
# Correr 1 frame (usar helper que espera frame_ready)
cycles = self.run_one_frame()
# Verificar framebuffer
indices = self.ppu.get_framebuffer_indices()
non_zero_count = sum(1 for i in range(160 * 144) if (indices[i] & 0x03) != 0)
assert non_zero_count >0, f"Framebuffer is all at 0"
Native Validation: Validation of compiled C++ module by correcting semantics ofread_vram()in 12+ critical places in the rendering code.
Results
Completed deployments:
- ✅
run_one_frame()modified to useget_frame_ready_and_reset()instead of fixed cycles - ✅ VRAM Sanity checks added in tests (verification with
read_raw()) - ✅ All calls to
read_vram()inPPU.cppcorrected to pass absolute addresses (12+ places) - ✅ Framebuffer getter check (was already correct)
- ✅ BG status verification (LCDC/STAT correct in tests)
Known issues:
- ⚠️ Tests still fail - framebuffer returns 0 (requires more research on timing/rendering)
Cause identified and corrected: (A) read_vram() used with absolute addr vs offset- All incorrect calls were corrected. The remaining issue (framebuffer at 0) suggests that there is another factor (timing, rendering conditions, etc.) that requires further investigation.
Next Steps
- Investigate why the framebuffer keeps returning 0 after fixing
read_vram() - Check frame timing: does it actually complete before reading the framebuffer?
- Check BG rendering conditions: are all necessary conditions met?
- Consider adding gated logging to diagnose the rendering flow