⚠️ 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 0257: Hardware Palette Bypass (C++)

Date:2025-12-23 StepID:0257 State: draft

Summary

This Step modifies `src/core/cpp/PPU.cpp` to force standard palette values ​​(`0xE4`) directly into the C++ rendering engine, completely ignoring the MMU's palette registers (BGP, OBP0, OBP1). The goal is to ensure that color indices (0-3) generated from VRAM are preserved in the framebuffer, regardless of the state of the palette registers in the MMU.

If the screen is still green after Step 0256 (debug palette in Python), it means that the C++ framebuffer is full of zeros. This can occur if the PPU is applying a palette with value `0x00` that converts all pixels (even black ones) to index `0` before writing them to the framebuffer. By forcing `0xE4` (identity mapping: 3→3, 2→2, 1→1, 0→0), we ensure that VRAM visual data is preserved in the framebuffer.

Hardware Concept

On the Game Boy, the PPU (Pixel Processing Unit) generates color indices (0-3) from tile data stored in VRAM. These indices are passed through the palette registers (BGP for background, OBP0/OBP1 for sprites) before being written to the PPU's internal framebuffer.

BGP record (0xFF47):Background palette. Each pair of bits (0-1, 2-3, 4-5, 6-7) maps a raw index (0-3) to a final index (0-3). The standard value is `0xE4` (11100100 in binary), which implements an identity mapping:

  • Bits 0-1 (00): Index 0 → Color 0
  • Bits 2-3 (01): Index 1 → Color 1
  • Bits 4-5 (10): Index 2 → Color 2
  • Bits 6-7 (11): Index 3 → Color 3

Critical Problem:If BGP is at `0x00` (00000000), all indexes are mapped to color 0:

  • Index 0 → Color 0 (white)
  • Index 1 → Color 0 (white)
  • Index 2 → Color 0 (white)
  • Index 3 → Color 0 (white)

This means that even if the VRAM contains valid data (tiles with black pixels, index 3), the PPU converts it to index 0 before writing it to the framebuffer. When Python reads the framebuffer, it only sees zeros, and the Python debug palette (Step 0256) maps index 0 to green/white.

Bypass Solution:By forcing `BGP = 0xE4` directly into the PPU's C++ code, we ignore any erroneous values ​​that may be in the MMU and ensure that color indices are preserved. If after this bypass we see black/gray shapes on the screen, we confirm that:

  1. VRAM contains valid data (properly loaded tools).
  2. The PPU is reading and decoding the tiles correctly.
  3. The problem was in the palette registers (BGP/OBP) in the MMU.

Fountain:Pan Docs - Palette Registers (BGP, OBP0, OBP1), Background Palette Register

Implementation

Changed two functions in `src/core/cpp/PPU.cpp` to force standard palette values ​​(`0xE4`) instead of reading them from the MMU:

1. Modification in `render_scanline()` (Background)

Added code to force `BGP=0xE4` and apply palette mapping before writing to the framebuffer:

// --- Step 0257: HARDWARE PALETTE BYPASS ---
// Force BGP = 0xE4 (identity mapping: 3->3, 2->2, 1->1, 0->0)
// This ensures that color indices are preserved in the framebuffer,
// regardless of the state of the palette registers in the MMU.
// uint8_t bgp = mmu_->read(IO_BGP); // COMMENTED: Ignore MMU
uint8_t bgp = 0xE4;  // 11 10 01 00 (Standard identity mapping)
// -------------------------------------------

// ... tile decoding code ...

// --- Step 0257: Apply forced palette (identity mapping) ---
// Apply BGP to map raw index to final index
// BGP = 0xE4 = 11 10 01 00
// color_index 0 -> (BGP >> 0) & 3 = 0
// color_index 1 -> (BGP >> 2) & 3 = 1
// color_index 2 -> (BGP >> 4) & 3 = 2
// color_index 3 -> (BGP >> 6) & 3 = 3
uint8_t final_color = (bgp >> (color_index * 2)) & 0x03;
framebuffer_[line_start_index + x] = end_color;
// -------------------------------------------------------------

2. Modification in `render_sprites()` (Sprites)

Added code to force `OBP0=0xE4` and `OBP1=0xE4` and apply palette mapping based on sprite attribute:

// --- Step 0257: HARDWARE PALETTE BYPASS ---
// Force OBP0 = 0xE4 and OBP1 = 0xE4 (identity mapping)
// This ensures that sprite color indices are preserved in the framebuffer,
// regardless of the state of the palette registers in the MMU.
// uint8_t obp0 = mmu_->read(IO_OBP0); // COMMENTED: Ignore MMU
// uint8_t obp1 = mmu_->read(IO_OBP1); // COMMENTED: Ignore MMU
uint8_t obp0 = 0xE4;  // 11 10 01 00 (Standard identity mapping)
uint8_t obp1 = 0xE4;  // 11 10 01 00 (Standard identity mapping)
// -------------------------------------------

// ... sprite rendering code ...

// --- Step 0257: Apply forced palette (identity mapping) ---
// Apply OBP0 or OBP1 depending on the sprite attribute
uint8_t palette = (palette_num == 0) ? obp0 : obp1;
// Apply palette to map raw color index to final index
// palette = 0xE4 = 11 10 01 00
// sprite_color_idx 0 -> (palette >> 0) & 3 = 0 (transparent, not drawn)
// sprite_color_idx 1 -> (palette >> 2) & 3 = 1
// sprite_color_idx 2 -> (palette >> 4) & 3 = 2
// sprite_color_idx 3 -> (palette >> 6) & 3 = 3
uint8_t final_sprite_color = (palette >> (sprite_color_idx * 2)) & 0x03;
framebuffer_line[final_x] = final_sprite_color;
// -------------------------------------------------------------

Design Decisions

  • Bypass in C++:We chose to force palette values ​​directly in C++ instead of just in Python (Step 0256) to ensure that the C++ framebuffer contains valid indices (0-3) from the beginning. This eliminates any point of failure in transferring data from C++ to Python.
  • Value 0xE4:`0xE4` was chosen because it is the standard value used by many Game Boy games and implements an identity mapping that preserves the original indices. This allows you to see the actual visual data from the VRAM without distortion.
  • Palette Application:Although the value is an identity mapping, full palette logic is applied to maintain consistency with real hardware. This makes it easier for future debugging when normal BGP/OBP reading is restored.
  • Explanatory Comments:Added detailed comments explaining the purpose of bypass and paddle mapping for easier understanding and future maintenance.

Affected Files

  • src/core/cpp/PPU.cpp- Modified `render_scanline()` and `render_sprites()` to force BGP = 0xE4 and OBP0/OBP1 = 0xE4 (Step 0257).

Tests and Verification

C++ Compiled Module Validation:

  1. Recompilation:Execute.\rebuild_cpp.ps1to recompile the Cython extension with the C++ changes.
  2. Execution:Executepython main.py roms/pkmn.gb(or any ROM with sprites).
  3. Observation:With the Python debug palette (Step 0256) + the C++ palette bypass (Step 0257), we should see:
    • ✅ SUCCESS:Black/gray shapes moving on the screen (GAME FREAK logo, Gengar vs Nidorino intro in Pokémon Red). This confirms that the VRAM contains valid data and the PPU is processing it correctly.
    • ❌ PROBLEM:If we continue to see everything green/white, the problem is in the VRAM itself (tiles not loaded) or in the reading of tiles from VRAM.

Test Command:

.\rebuild_cpp.ps1
python main.py roms/pkmn.gb

Expected Result:Screen with black and white graphics (or gray/green with the Python debug palette), showing correctly rendered sprites and background.

Sources consulted

Educational Integrity

What I Understand Now

  • Rendering Pipeline:VRAM → PPU (tile decoding) → Palette Application (BGP/OBP) → Framebuffer → Python (final palette application) → Display. Palette bypass in C++ ensures that color indices are preserved in the framebuffer, regardless of the state of the palette registers in the MMU.
  • Palette Mapping:Palette registers (BGP, OBP0, OBP1) map raw color indices (0-3) to final indices (0-3). The value `0xE4` implements an identity mapping that preserves the original indices, while `0x00` converts all indices to 0 (blank).
  • Diagnosis by Layers:By applying bypasses on different layers (Python in Step 0256, C++ in Step 0257), we can isolate the problem: if we see graphics after the C++ bypass, the problem is in the palette registers; If we don't see anything, the problem is in the VRAM or in the reading of tiles.

What remains to be confirmed

  • VRAM Status:Verify that the VRAM contains valid data (loaded tiles) when the game is run. If VRAM is empty, paddle bypass won't help.
  • Reading Tiles:Verify that the PPU is correctly reading the tiles from VRAM. If there is an error in tile decoding, palette bypass will not help.
  • Palette Records in MMU:Once we confirm that the VRAM and PPU are working correctly, we need to investigate why the palette registers (BGP, OBP0, OBP1) are at `0x00` or why the MMU is not serving them correctly.

Hypotheses and Assumptions

Main Hypothesis:The C++ framebuffer is full of zeros because the PPU is applying a palette with value `0x00` that converts all pixels (even black ones, index 3) to index 0 before writing them to the framebuffer. By forcing `0xE4` (identity mapping), the color indices are preserved and we should see graphics on the screen.

Assumption:We assume that the VRAM contains valid data and that the PPU is reading and decoding the tiles correctly. If after the paddle bypass we still see everything green/white, this assumption is incorrect and we should investigate the VRAM and the tile reading.

Next Steps

  • [ ] Execute.\rebuild_cpp.ps1to recompile the Cython extension with the C++ changes.
  • [ ] Executepython main.py roms/pkmn.gband observe the screen.
  • [ ] If we see black/gray shapes:
    • Confirm that the VRAM and PPU are working correctly.
    • Investigate why the palette registers (BGP, OBP0, OBP1) are at `0x00` or why the MMU is not serving them correctly.
    • Correct reading/writing of palette registers in the MMU.
    • Restore normal palette logic (remove bypass) and validate that colors are displayed correctly.
  • [ ] If we continue seeing everything green/white:
    • Verify that the C++ PPU framebuffer contains valid indexes (0-3) using a debugger or logs.
    • Verify that the VRAM contains valid data (loaded tiles) by inspecting the memory at runtime.
    • Investigate why the PPU is not generating pixels or why the framebuffer is empty.