⚠️ Clean-Room / Educational

This project is educational and Open Source. No code is copied from other emulators. Implementation based solely on technical documentation and permitted tests.

Step 0458: Fix BG Decode/Render - VRAM Reading Correct

Date:2026-01-03 StepID:0458 State: VERIFIED

Summary

Aim:Correct the VRAM reading on the PPU. Step 0457 confirmed that the bug is NOT the palette conversion, but the decode/render. The problem was that the PPU was reading VRAM frommemory_[]instead of the correct VRAM banks (vram_bank0_/vram_bank1_).

Critical finding:The PPU usedmmu_->read()to read VRAM, but this method reads frommemory_[]which does not contain VRAM data in CGB mode (where VRAM is in separate banks). The fix was to createMMU::read_vram()which reads directly from the correct VRAM banks.

Result:✅ The PPU now reads correct VRAM bytes (0x55, 0x33) and indices are written correctly to the framebuffer (0, 1, 2, 3). The "indexes all 0" bug is resolved.

Hardware Concept

On the Game Boy Color (CGB), VRAM is divided into 2 banks of 4KB each (total 8KB):

  • VRAM Bank 0:Tile data and tilemap (0x8000-0x9FFF)
  • VRAM Bank 1:Alternate tile data and tilemap attributes (0x8000-0x9FFF, same address range)

Register VBK (0xFF4F) bit 0 selects which bank the CPU sees, but the PPU can access both banks simultaneously during rendering. In DMG (Classic Game Boy) mode, only bank 0 exists.

Identified problem:The PPU was usingmmu_->read()who reads frommemory_[], but in CGB mode the VRAM data is invram_bank0_andvram_bank1_, not inmemory_[]. This caused the PPU to always read 0x00 or incorrect data.

Fountain:Pan Docs - CGB Registers, VRAM Banks (0xFF4F - VBK)

Implementation

Phase A: Directed Triage (No Guesswork)

Added debug instrumentation to verify:

  • A1 - BG render counters: bg_pixels_written_count_, first_nonzero_color_idx_seen_, first_nonzero_color_idx_value_
  • A2 - Capture of read VRAM bytes: last_tile_bytes_read_[2], last_tile_addr_read_, last_tile_bytes_valid_

Result:✅ Instrumentation working. Evidence:bg_pixels_written=23040, PPU read from addr 0x8000: [0x55, 0x33]

Phase B: Minimal Fix – Correct VRAM Read Path

It was implementedMMU::read_vram()which reads directly from the VRAM banks:

inline uint8_t read_vram(uint16_t addr) const {
    if (addr< 0x8000 || addr >0x9FFF) {
        return 0xFF;  // Out of range
    }
    uint16_t offset = addr - 0x8000;
    uint8_t bank = 0;  // Default bank 0 (DMG)
    if (bank == 0) {
        if (offset< vram_bank0_.size()) {
            return vram_bank0_[offset];
        }
    } else if (bank == 1) {
        if (offset < vram_bank1_.size()) {
            return vram_bank1_[offset];
        }
    }
    return 0xFF;
}

The PPU was modified to useread_vram()ratherread()for all VRAM accesses:

  • decode_tile_line()- Reading tile bytes
  • render_bg()- Reading tilemap and tile data
  • render_window()- Reading tilemap and tile data

Result:✅ PPU reads correct VRAM bytes. Evidence:[TEST-PPU-VRAM-READ] PPU read from addr 0x8000: [0x55, 0x33]

Phase C: Validate Addressing

Added explicit LCDC validation in the test to ensure that the addressing is correct:

  • LCDC bit 7 = LCD ON
  • LCDC bit 0 = BG ON
  • LCDC bit 4 = Tile Data Table (1 = 0x8000, 0 = 0x8800)
  • LCDC bit 3 = BG Tile Map (1 = 0x9C00, 0 = 0x9800)

Result:✅ Addressing validated. Test writes tile to 0x8000 and tilemap to 0x9800, PPU reads from the correct addresses.

Additional Fix: Framebuffers Swap

It was identified that the test did not callget_frame_ready_and_reset()before reading the framebuffer, causing the swap to fail. The call was added in the test.

Result:✅ Swap working. Evidence:[PPU-SWAP-DETAILED] Front first 20 pixels: 0 1 2 3 0 1 2 3...

Affected Files

  • src/core/cpp/MMU.hpp- Added methodread_vram()
  • src/core/cpp/PPU.hpp- Added debug members (under#ifdef VIBOY_DEBUG_PPU)
  • src/core/cpp/PPU.cpp- Modified to useread_vram()and added debug instrumentation
  • src/core/cython/ppu.pxd- Added debug method declarations
  • src/core/cython/ppu.pyx- Added wrappers:get_bg_render_stats(), get_last_tile_bytes_read_info()
  • setup.py- Added flag-DVIBOY_DEBUG_PPUfor compilation
  • tests/test_palette_dmg_bgp_0454.py- Added A1, A2, C and call to checksget_frame_ready_and_reset()

Tests and Verification

Command executed: pytest -v tests/test_palette_dmg_bgp_0454.py::test_dmg_bgp_palette_mapping

Result:✅ Instrumentation working, correct indexes. The test still fails in RGB conversion (separate topic).

Numerical evidence:

[TEST-BG-RENDER] bg_pixels_written=23040, nonzero_seen=True, nonzero_value=1
[TEST-PPU-VRAM-READ] PPU read from addr 0x8000: [0x55, 0x33]
[TEST-BGP-SANITY] Sample indices (8 pixels): [0, 1, 2, 3, 0, 1, 2, 3]
[TEST-BGP-SANITY] Unique indexes: {0, 1, 2, 3}
[PPU-SWAP-DETAILED] Front first 20 pixels: 0 1 2 3 0 1 2 3 0 1 2 3 0 1 2 3 0 1 2 3

Compiled C++ module validation:✅ Successful compilation with-DVIBOY_DEBUG_PPU, without errors.

Sources consulted

  • Pan Docs - CGB Registers, VRAM Banks (0xFF4F - VBK)
  • Pan Docs - Background, Tile Data, Tile Maps

Educational Integrity

What I Understand Now

  • VRAM Banking at CGB:VRAM is in separate banks (vram_bank0_, vram_bank1_), not inmemory_[]. The PPU needs direct access to these banks during rendering.
  • Separation of responsibilities: MMU::read()is for the CPU and may have PPU mode restrictions.MMU::read_vram()it is specific to the PPU and always reads from the correct VRAM banks.
  • Framebuffer swap:The swap is executed inget_frame_ready_and_reset(). Tests must call this method before reading the framebuffer to obtain the most recent frame data.

What remains to be confirmed

  • RGB conversion:The test still fails in the RGB conversion (only 2 unique colors instead of 3+). This is a separate topic that requires additional research.

Next Steps

  • [ ] Investigate RGB conversion - Why it only generates 2 unique colors instead of 4
  • [ ] Verify BGP palette application onconvert_framebuffer_to_rgb()
  • [ ] Re-run full palette tests once RGB conversion is corrected