This project is educational and Open Source. No code is copied from other emulators. Implementation based solely on technical documentation and permitted tests.
CGB RGB888 Rendering with Palettes
Summary
Implementation of the full CGB RGB888 rendering pipeline using native CGB palettes (BGR555) without depending on BGP. Added helpers in MMU to read CGB palettes without side effects (read_bg_palette_data(), read_obj_palette_data()). Implemented dual-buffered RGB888 framebuffer on PPU (160×144×3 = 69120 bytes) for true color rendering. Functionconvert_framebuffer_to_rgb()converts color indices (0-3) to RGB888 using CGB palettes with BGR555→RGB888 conversion according to Pan Docs. Cython Wrapperget_framebuffer_rgb()exposes RGB framebuffer with zero-copy access from Python. Successful build. Tests show that Tetris DX progresses correctly (GameplayState=YES, TileData=56.6%, writes to VBK detected), while Zelda DX and Pokémon Red require boot ROM or improved initialization (BGP=0x00, TileData=0%). System prepared for dual-mode rendering (DMG with indices+BGP, CGB with RGB+native palettes).
Hardware Concept (Pan Docs - CGB Palettes, Color Format)
CGB Pallet System
Game Boy Color introduces a 15-bit palette system (BGR555) that allows 32768 simultaneous colors. Unlike DMG which uses 2-bit (4 shades of gray) palettes defined in BGP (0xFF47), CGB has dedicated palettes stored in internal RAM.
Pallet Organization
- BG Palettes (Background): 8 palettes × 4 colors × 2 bytes = 64 bytes
- OBJ Palettes (Sprites): 8 palettes × 4 colors × 2 bytes = 64 bytes
- Access: Via BCPS/BCPD (FF68/FF69) registers for BG, OCPS/OCPD (FF6A/FF6B) for OBJ
- Autoincrement: Bit 7 of BCPS/OCPS activates index auto-increment after each read/write
BGR555 Color Format
Each CGB color occupies 2 bytes (Little Endian):
Byte 0 (low): GGGRRRRR
Byte 1 (high): XBBBBBGG
Where:
- R: 5 Network bits (0-31)
- G: 5 Green bits (0-31, distributed over 2 bytes)
- B: 5 Blue bits (0-31)
- X: 1 unused bit (always 0)
BGR555 → RGB888 conversion
To display on modern displays (RGB888, 8 bits per channel), we convert:
uint16_t color_bgr555 = lo | (hi<< 8);
uint8_t r5 = (color_bgr555 >> 0) & 0x1F;
uint8_t g5 = (color_bgr555 >> 5) & 0x1F;
uint8_t b5 = (color_bgr555 >> 10) & 0x1F;
// Scalar from 5 bits (0-31) to 8 bits (0-255)
uint8_t r8 = (r5 * 255) / 31;
uint8_t g8 = (g5 * 255) / 31;
uint8_t b8 = (b5 * 255) / 31;
Dual-Mode Rendering (DMG vs CGB)
The emulator maintains two rendering pipelines:
- DMG Mode: Framebuffer indexes (0-3) → Python applies BGP palette (0xFF47)
- CGB Mode:Index framebuffer → C++ converts to RGB using CGB palettes → Python reads RGB directly
Advantage: Full DMG support (no regression) while adding real CGB support.
Tile Attributes (VRAM Bank 1)
In CGB, each tile in the tilemap has associated attributes (VRAM Bank 1):
Bit 7: BG/Win Priority
Bit 6: Y-Flip
Bit 5: X-Flip
Bit 4: VRAM Bank (0 or 1)
Bit 3: CGB Palette (high bit)
Bit 2-0: CGB Palette (low bits, 0-7)
Current Implementation: For simplicity, this version uses palette 0 for all tiles. Reading tile attributes from VRAM Bank 1 will be implemented in future steps.
Implementation
3.1. CGB Palette Access Helpers (MMU)
Archive: src/core/cpp/MMU.hpp, MMU.cpp
Added inline methods for direct access to palettes without side effects:
// MMU.hpp
inline uint8_t read_bg_palette_data(uint8_t index) const {
if (index< 0x40) {
return bg_palette_data_[index];
}
return 0xFF;
}
inline uint8_t read_obj_palette_data(uint8_t index) const {
if (index < 0x40) {
return obj_palette_data_[index];
}
return 0xFF;
}
Why is it necessary: Access to pallets via BCPS/BCPD has auto-increment (BCPS bit 7). In order for the PPU to read palettes during rendering without affecting the state of the CPU, we need direct access to the palette RAM.
3.2. RGB888 Framebuffer (PPU)
Archive: src/core/cpp/PPU.hpp, PPU.cpp
Added double RGB buffer:
// PPU.hpp
std::vector<uint8_t> framebuffer_rgb_front_; // 160*144*3 = 69120 bytes
std::vector<uint8_t> framebuffer_rgb_back_; // 160*144*3 = 69120 bytes
uint8_t* get_framebuffer_rgb_ptr(); // Return pointer to front buffer
Double Buffering: Same pattern as index framebuffer (front/back swap) to avoid race conditions between C++ (writes) and Python (reads).
3.3. BGR555 → RGB888 conversion
Archive: src/core/cpp/PPU.cpp
void PPU::convert_framebuffer_to_rgb() {
if (mmu_ == nullptr) {
return;
}
// Read palette 0 from BG (simplified: all tiles use palette 0)
uint16_t cgb_palette[4];
for (int i = 0; i< 4; i++) {
uint8_t lo = mmu_->read_bg_palette_data(i * 2);
uint8_t hi = mmu_->read_bg_palette_data(i * 2 + 1);
cgb_palette[i] = lo | (hi<< 8);
}
// Convertir cada píxel del framebuffer de índices a RGB
for (size_t i = 0; i < FRAMEBUFFER_SIZE; i++) {
uint8_t color_index = framebuffer_front_[i];
if (color_index >3) color_index = 0;
uint16_t bgr555 = cgb_palette[color_index];
// Extract BGR555 components
uint8_t r5 = (bgr555 >> 0) & 0x1F;
uint8_t g5 = (bgr555 >> 5) & 0x1F;
uint8_t b5 = (bgr555 >> 10) & 0x1F;
// Convert to RGB888
uint8_t r8 = (r5 * 255) / 31;
uint8_t g8 = (g5 * 255) / 31;
uint8_t b8 = (b5 * 255) / 31;
// Write to RGB framebuffer
framebuffer_rgb_front_[i * 3 + 0] = r8; // Network
framebuffer_rgb_front_[i * 3 + 1] = g8; //Green
framebuffer_rgb_front_[i * 3 + 2] = b8; //Blue
}
}
Future Optimization: This function will be called automatically at the end of each frame when CGB mode is detected. For now, Python can call it explicitly before reading the RGB framebuffer.
3.4. Cython Wrapper
Files: src/core/cython/ppu.pxd, ppu.pyx
# ppu.pxd
cdef extern from "PPU.hpp":
cdef cppclass PPU:
uint8_t* get_framebuffer_rgb_ptr()
void convert_framebuffer_to_rgb()
# ppu.pyx
def get_framebuffer_rgb(self):
"""
Gets the RGB888 framebuffer as memoryview (Zero-Copy).
Size: 160 * 144 * 3 = 69120 bytes (R, G, B per pixel).
"""
if self._ppu == NULL:
return None
cdef uint8_t* ptr = self._ppu.get_framebuffer_rgb_ptr()
if ptr == NULL:
return None
cdef unsigned char[:] view = <unsigned char[:144*160*3]>ptr
return view
Zero-Copy: The memoryview allows direct access to C++ memory without intermediate copies.
Tests and Verification
4.1. Compilation
Command:
python3 setup.py build_ext --inplace
Result:✅ Compilation successful. Generated file:viboy_core.cpython-312-x86_64-linux-gnu.so(2.7MB)
Validation: Expected warnings (printf format, unused variables) do not affect functionality. Compiled C++ module validation successful.
4.2. Controlled Tests (30s timeout)
Command:
timeout 30s python3 main.py roms/tetris_dx.gbc > logs/step0405_tetris_dx_rgb.log 2>&1
timeout 30s python3 main.py roms/Oro.gbc > logs/step0405_zelda_dx_rgb.log 2>&1
timeout 30s python3 main.py roms/pkmn.gb > logs/step0405_pkmn_dmg.log 2>&1
Result: Tetris DX (CGB)
- ✅ GameplayState: YES (reached at frame 720)
- ✅ BGP: 0xFC → 0xE4 (changed in frame 711)
- ✅ TileData: 56.6% (3479/6144 bytes) in frame 840
- ✅ Unique Tiles: 185/256
- ✅ VBK Writes: Detected (PC:0x0590, VBK ← 0x00)
- ✅ HDMA5: 0xFF (idle, does not use HDMA)
- Conclusion: Tetris DX progresses correctly with CGB initialization of Step 0404
Result: Zelda DX (Gold.gbc - CGB)
- ⚠️ GameplayState: NO (remains in initialization)
- ⚠️ BGP: 0x00 (not initialized)
- ⚠️ TileData: 0/6144 (0.0%) - No tiles loaded
- ⚠️ TileMap: 100% (possibly cleaned/initialized)
- ⚠️ IE/IF: 0x1F/0x07 (multiple IRQs active but not served)
- ⚠️ VBK Writes: Not detected
- Conclusion: Zelda DX requires boot ROM or enhanced initialization (polling in wait-loop waiting for specific condition)
Result: Pokémon Red (DMG)
- ⚠️ GameplayState: NO
- ⚠️ BGP: 0x00 (not initialized without boot ROM)
- ⚠️ TileData: 0/6144 (0.0%)
- Conclusion: Pattern similar to Zelda DX. DMG games also require boot ROM for full initialization
4.3. Comparative Analysis (Tetris DX vs Zelda DX)
Key differences:
| Aspect | Tetris DX | Zelda DX |
|---|---|---|
| BGP Initial | 0xFC (correct) | 0x00 (incorrect) |
| LCDC | 0x91 → 0x81 | 0xE3 (constant) |
| IE Handling | 0x00 → VBlank enabled | 0x1F (all active) |
| Progression | Linear, reach gameplay | Stuck on initialization |
Hypothesis: Tetris DX has more robust initialization that does not depend on specific post-boot state. Zelda DX expects exact conditions that only boot ROM provides.
Next Steps
- Python RGB Renderer: Update
src/gpu/renderer.pyto useget_framebuffer_rgb()whenhardware_mode == CGB - Tile Attributes (VRAM Bank 1): Read tile attributes to determine correct palette per tile (currently uses palette 0 for everything)
- Auto-conversion: Call
convert_framebuffer_to_rgb()automatically at the end of each frame in CGB mode - Boot ROM Legal: Document how to obtain/use real boot ROM (according to Step 0403 guide) to resolve Zelda DX/Pokémon Red initialization
- Directed Instrumentation: Specific monitors to detect which registers/conditions are missing in Zelda DX (based on differences vs Tetris DX)
Git Commands
git add src/core/cpp/MMU.hpp src/core/cpp/MMU.cpp \
src/core/cpp/PPU.hpp src/core/cpp/PPU.cpp \
src/core/cython/ppu.pxd src/core/cython/ppu.pyx \
logs/step0405_*.log \
docs/bitacora/entries/2026-01-01__0405__renderizado-cgb-rgb888-paletas.html \
docs/bitacora/index.html \
docs/report_phase_2/
git commit -m "feat(ppu+cgb): RGB888 rendering with BGR555 CGB palettes (Step 0405)
- Added helpers in MMU: read_bg_palette_data(), read_obj_palette_data()
- Implemented RGB888 double buffer framebuffer (69120 bytes) in PPU
- convert_framebuffer_to_rgb() function: indices → RGB888 with CGB palettes
- Cython wrapper get_framebuffer_rgb() with zero-copy access
- BGR555 → RGB888 conversion according to Pan Docs (5-bit → 8-bit scale)
- Tests: Tetris DX ✅ progress (GameplayState=YES, TileData=56.6%)
- Tests: Zelda DX/Pokémon Red ⚠️ require boot ROM (BGP=0x00, TileData=0%)
- System prepared for dual-mode rendering (DMG indices, CGB RGB)
Source: Pan Docs - CGB Palettes, Color Format"
git push