This project is educational and Open Source. No code is copied from other emulators. Implementation based solely on technical documentation and permitted tests.
Graphic Integration and Tiles Decoder
Summary
Historic visual milestone! Pygame was integrated to display graphics and the tile decoder in 2bpp format (2 bits per pixel) of the Game Boy was implemented. Now the emulator can "see" and display the contents of VRAM, decoding the graphics that the CPU writes to memory. The implementation includes the functiondecode_tile_line()which converts two bytes into 8 pixels with colors 0-3, and the classRendererwhich initializes Pygame and provides a debug mode to display all the VRAM tiles in a grid. The system integrates with the main loop, rendering when V-Blank is detected.
Hardware Concept
The Game Boy does not store complete images (bitmaps) in memory. Instead, use a system oftiles(tiles) of 8x8 pixels that combine to form backgrounds and sprites.
VRAM (Video RAM)
The graphics memory is in the range0x8000-0x9FFF(8KB = 8192 bytes). This area contains the tile data and later will also contain background maps and sprites.
2bpp format (2 Bits Per Pixel)
Each tile occupies16 bytes(2 bytes per line × 8 lines). The 2bpp format allows 4 different colors to be represented per pixel (00, 01, 10, 11).
For each 8 pixel horizontal line:
- Byte 1 (LSB): Contains the least significant (low) bits of each pixel
- Byte 2 (MSB): Contains the most significant (high) bits of each pixel
The color of each pixel is calculated by combining the corresponding bits:
color = (MSB<< 1) | LSB
This produces values from 0 to 3:
- Color 0 (0b00): LSB=0, MSB=0 → White (transparent in sprites)
- Color 1 (0b01): LSB=1, MSB=0 → Light gray
- Color 2 (0b10): LSB=0, MSB=1 → Dark gray
- Color 3 (0b11): LSB=1, MSB=1 → Black
Decoding Example
For a line with:
- Byte 1 (LSB):
0x3C=00111100 - Byte 2 (MSB):
0x7E=01111110
The leftmost pixel (bit 7):
- LSB = bit 7 of Byte 1 = 0
- MSB = bit 7 of Byte 2 = 0
- Color = (0<< 1) | 0 = 0
The second pixel (bit 6):
- LSB = bit 6 of Byte 1 = 0
- MSB = bit 6 of Byte 2 = 1
- Color = (1<< 1) | 0 = 2
Fountain:Pan Docs - Tile Data, 2bpp Format
Implementation
A complete graphical rendering system was implemented using Pygame, with a debug mode that displays all the VRAM tiles.
Components created/modified
src/gpu/renderer.py: New module with classRendererand the helper functiondecode_tile_line()src/viboy.py: Integration of the renderer in the main loop, handling of Pygame events, and rendering in V-Blanksrc/gpu/__init__.py: Conditional export ofRenderer(only if pygame is available)tests/test_gpu_tile_decoder.py: Complete TDD test suite for the 2bpp decoder
Functiondecode_tile_line()
Decodes an 8 pixel line from two bytes:
- Step through each bit from left to right (bit 7 to bit 0)
- Extract the low bit from byte1 and the high bit from byte2
- Calculate the color as:
(bit_high<< 1) | bit_low - Returns a list of 8 integers (0-3) representing colors
ClassRenderer
Responsibilities:
- Initialization: Configure Pygame, create scaled window (default 3x, resulting in 480x432 pixels)
render_vram_debug(): Decodes all VRAM tiles and draws them on a 32x16 tile grid_draw_tile(): Draw an individual 8x8 pixel tile using the gray palettehandle_events(): Handles Pygame events (especially window closing)quit(): Close Pygame cleanly
Integration inViboy
The renderer is optionally initialized (if pygame is available) in both__init__()as inload_cartridge(). In the main loop:
- Pygame events are handled before each loop
- V-Blank start (LY >= 144) detected via state transition
- When you enter V-Blank, you call
render_vram_debug()to refresh the screen - on the block
finally, the renderer closes cleanly
Design decisions
- Fixed gray palette: For now we use a simple palette (White, Light Grey, Dark Grey, Black) for display. The actual palette (BGP, OBP0, OBP1) will be implemented later
- Debug mode first: We implement first
render_vram_debug()to verify that the decoding works. Full game render will come later - V-Blank Rendering: We render only when we detect V-Blank so as not to overwhelm the system with constant updates
- Conditional import: The renderer is only loaded if pygame is available, allowing the emulator to work without graphics
Affected Files
src/gpu/renderer.py- New module with Renderer class and decode_tile_line() functionsrc/gpu/__init__.py- Conditional Renderer Exportsrc/viboy.py- Renderer integration, Pygame event handling, and V-Blank renderingtests/test_gpu_tile_decoder.py- New file with 6 unit tests for decode_tile_line()
Tests and Verification
A full suite of TDD tests was implemented to validate 2bpp decoding.
Unit tests
Command executed: python3 -m pytest tests/test_gpu_tile_decoder.py -v
Around:macOS (darwin 21.6.0), Python 3.9.6, pytest 8.4.2
Result:6 passed in 0.11s
What is valid:
- Basic decoding: Verifies that a line with bytes 0x3C and 0x7E produces the correct colors [0, 2, 3, 3, 3, 3, 2, 0]
- all colors: Validates that we can obtain the 4 possible values (0, 1, 2, 3) using different combinations of bytes
- Specific colors: Separate tests for Color 0 (both bytes 0x00), Color 1 (LSB=0xFF, MSB=0x00), Color 3 (both bytes 0xFF)
- Complex patterns: Checks an alternating pattern (0xAA and 0x55) that produces a sequence [1, 2, 1, 2, 1, 2, 1, 2]
Essential test code
def test_decode_2bpp_line_basic(self) -> None:
"""Basic test: decode a 2bpp tile line"""
byte1 = 0x3C #00111100 (LSB)
byte2 = 0x7E #01111110 (MSB)
result = decode_tile_line(byte1, byte2)
assert len(result) == 8
assert result[0] == 0 # Bit 7: LSB=0, MSB=0 -> 0
assert result[1] == 2 # Bit 6: LSB=0, MSB=1 -> 2
assert result[2] == 3 # Bit 5: LSB=1, MSB=1 -> 3
# ...more assertions
Full route: tests/test_gpu_tile_decoder.py
These tests demonstrate that 2bpp decoding works correctly according to the specification: byte1 contains the least significant bits, byte2 contains the most significant bits, and the color is calculated as(M.S.B.<< 1) | LSB.
Test with Tetris ROM
ROM:Tetris DX (user-contributed ROM, not distributed)
Execution mode:UI (Pygame), 3x scale (480x432 pixels)
Success Criterion:The renderer should initialize correctly and display the Pygame window when V-Blank is detected.
Observation:
- Pygame installed successfully (pygame-ce 2.5.6)
- The renderer initializes without errors:
INFO: Renderer initialized: 480x432 (scale=3) - The emulator runs 70,118 cycles before stopping
- Game stops at opcode0x1D (DEC E)which is not yet implemented
- The Pygame window is not displayed because the game stops before reaching the first V-Blank (you need to execute more instructions to reach LY=144)
Result: draft- The renderer works correctly (verified with independent testing), but the game needs additional opcodes (8-bit INC/DEC) to advance to the first V-Blank and render real tiles.
Legal notes:The Tetris DX ROM is user-contributed for local testing, it is not distributed or included in the repository.
Sources consulted
- Bread Docs:Tile Data, 2bpp Format - Specification of the 8x8 pixel tile format with 2 bits per pixel
- Bread Docs:Video RAM (VRAM) - Graphics memory location and structure (0x8000-0x9FFF)
- Bread Docs:LCD Timing, V-Blank - When is it safe to refresh the screen
Note: The implementation strictly follows the technical documentation. Code from other emulators was not consulted.
Educational Integrity
What I Understand Now
- 2bpp format:I understand that each pixel is encoded with 2 bits, allowing 4 colors. The bits are divided into two bytes: byte1 (LSB) and byte2 (MSB), and the color is calculated as
(M.S.B.<< 1) | LSB. - Tiles structure:Each 8x8 pixel tile occupies exactly 16 bytes (2 bytes per line). The VRAM can store up to 512 tiles (8192 bytes / 16 bytes per tile).
- V-Blank Rendering:It is safe to refresh the display only during V-Blank (LY >= 144), because during visible line rendering, the PPU is actively reading the VRAM.
- Color palette:Values 0-3 are color indices that are mapped to actual colors using palette registers (BGP, OBP0, OBP1). For now we use a fixed gray palette for debugging.
What remains to be confirmed
- Rendered with real game:The renderer is functional but could not be verified with real Tetris data because the game stops at opcode 0x1D (DEC E) before reaching the first V-Blank. The missing 8-bit INC/DEC opcodes need to be implemented so that the game can advance.
- Royal palette:The reading of the BGP, OBP0 and OBP1 registers needs to be implemented to correctly map the indices 0-3 to real colors (which may be different for background and sprites).
- Full render:This step only displays the tiles in a grid. The actual rendering of the screen using background maps (Tile Maps), scroll, window, and sprites remains to be implemented.
- OAM (Object Attribute Memory):We still need to fully understand how sprites are organized in OAM and how they are rendered against the background.
- Priority and transparency:The priority rules between background and sprites need to be implemented, and how Color 0 is transparent in sprites.
Hypotheses and Assumptions
Rendered on each V-Blank:For now we render every time we detect the start of V-Blank. This may be too frequent and could affect performance. Later we should consider rendering only when VRAM content changes significantly, or limiting the refresh rate.
Debug mode:The current display in debug mode shows all the tiles in a grid, not the actual rendering of the game. This is intentional to verify that the decoding works, but does not show what the game actually looks like.
Missing Opcodes:The game stops at DEC E (0x1D) before rendering. This confirms that we need to implement the remaining 8-bit INC/DEC opcodes (INC D/E/H/L and DEC D/E/H/L) so that the games can fully run. These opcodes follow the same pattern as those already implemented (DEC B, DEC C, DEC A), so they should be quick to add.
Next Steps
- [ ] Implement reading of palette registers (BGP, OBP0, OBP1) to map color indices to actual colors
- [ ] Implement background rendering using Tile Maps (0x9800-0x9BFF and 0x9C00-0x9FFF)
- [ ] Implement background scroll (SCX and SCY registers)
- [ ] Implement window rendering (Window) using WX and WY registers
- [ ] Implement sprite rendering from OAM (0xFE00-0xFE9F)
- [ ] Implement priorities and transparency (Color 0 is transparent on sprites)
- [ ] Optimize rendering so it only refreshes when necessary