⚠️ 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.

PPU Phase D: PPU and STAT Register Modes in C++

Date:2025-12-19 StepID:0127 State: Verified

Summary

After Phase C, which implemented actual rendering of tiles from VRAM, the emulator showed awhite screen at 60 FPS. This behavior, although it may seem counterintuitive, is actually a positive sign: it means that the rendering engine is working correctly, but the game CPU is stuck in a waiting loop waiting for the PPU to report a "safe" mode (H-Blank or V-Blank) before writing graphics data to VRAM.

This step implements thePPU state machine (Modes 0-3)and theSTAT register (0xFF41)which allows the CPU to read the current state of the PPU. The implementation resolves a circular dependency between MMU and PPU through dependency injection, allowing the MMU to callPPU::get_stat()when the STAT register is read.

This is the step that shouldunlock graphics- When the CPU reads a dynamically changing STAT value, it will exit its wait loop and proceed to copy the tile and tilemap data to VRAM, allowing the PPU to render the actual game graphics.

Hardware Concept

The Game Boy PPU operates in4 different modesduring each frame, each with different memory access restrictions for the CPU:

PPU modes

  • Mode 0: H-Blank(Horizontal Blank): Occurs during the horizontal return period (252-455 cycles of each visible line). The CPU can freely access VRAM and OAM.
  • Mode 1: V-Blank(Vertical Blank): Occurs during lines 144-153 (10 lines). The CPU can freely access VRAM and OAM. It is the longest and safest period to update graphics.
  • Mode 2: OAM Search(Object Attribute Memory Search): Occurs during the first 80 cycles of each visible line. The CPU is blocked from accessing OAM (but can access VRAM).
  • Mode 3: Pixel Transfer: Occurs during cycles 80-251 of each visible line. The CPU is blocked from accessing both VRAM and OAM (the PPU is actively reading this data).

STAT register (0xFF41)

The STAT register (LCD Status) is a read/write register that reports the current status of the PPU:

  • Bits 0-1(read only): Current PPU mode (0, 1, 2 or 3)
  • Bit 2(read-only): LYC=LY Coincidence Flag (1 if LY == LYC, 0 otherwise)
  • Bit 3(read/write): H-Blank interrupt enable
  • Bit 4(read/write): V-Blank interrupt enable
  • Bit 5(read/write): OAM interrupt enable
  • Bit 6(read/write): LYC=LY interrupt enable
  • Bit 7(read only): Always 1 (not used)

CRITICAL: Bits 0-2 are read-only and are dynamically updated by the PPU. When the CPU reads the STAT register, it should obtain the updated value that reflects the actual state of the PPU at that time.

MMU-PPU Circular Dependency

To correctly read the STAT register, the MMU needs to callPPU::get_stat(), but the PPU also needs access to the MMU to read registers like LCDC. This creates a circular dependency that is resolved bydependency injection:

  • The PPU receives a pointer to MMU in its constructor (already implemented)
  • The MMU receives a pointer to PPU viaMMU::setPPU()(new)
  • When 0xFF41 is read, the MMU callsppu->get_stat()whether the PPU is connected

This pattern is common in emulators and allows you to maintain separation of responsibilities while resolving the necessary circular dependencies on the real hardware.

Fountain: Pan Docs - LCD Status Register (STAT), LCD Timing, Mode 0-3

Implementation

The implementation is divided into three main parts: updating the PPU to report its status, modifying the MMU to read STAT dynamically, and updating the Cython wrappers and Python code to connect both components.

Components created/modified

  • PPU.hpp / PPU.cpp: Added methodget_stat()which combines the writeable bits of STAT (from MMU) with the current state of the PPU (mode and LYC=LY).
  • MMU.hpp / MMU.cpp: Added methodsetPPU()and modification ofread()to handle reading STAT (0xFF41) by callingppu->get_stat().
  • mmu.pxd / mmu.pyx: Added methodset_ppu()to the Cython wrapper to connect the PPU to the MMU from Python.
  • ppu.pxd: Added declarationget_stat()for exposure to Cython.
  • viboy.py: Added call tommu.set_ppu(ppu)after creating both components to establish the connection.
  • viboy.py: Added PPU mode to the Heartbeat log for visual diagnosis.
  • tests/test_core_ppu_modes.py: Complete suite of tests to verify mode transitions and STAT reading.

Design decisions

Circular dependency resolution: Chosen dependency injection using pointers instead of having the MMU include the PPU header directly. This avoids circular compile-time dependencies and maintains separation of responsibilities. The pointer is set after both objects are created in Python, ensuring that both exist when the connection is established.

STAT update: The methodPPU::get_stat()reads the current value of STAT from the MMU (to preserve the writable bits) and then combines this value with the current state of the PPU (mode and LYC=LY). This ensures that the read-only bits always reflect the actual state, while the configurable bits are preserved.

Cython Wrapper: Was usedobjectsratherPyPPUin the signing ofset_ppu()to avoid circular compile-time dependencies. At runtime, the object will be an instance of PyPPU with the attribute_ppuaccessible.

Affected Files

  • src/core/cpp/PPU.hpp- Added methodget_stat()
  • src/core/cpp/PPU.cpp- Implementedget_stat()
  • src/core/cpp/MMU.hpp- Added PPU forward declaration methodsetPPU()and memberppu_
  • src/core/cpp/MMU.cpp- Included PPU.hpp, implementedsetPPU()and STAT management inread()
  • src/core/cython/ppu.pxd- Added declarationget_stat()
  • src/core/cython/mmu.pxd- Added forward declaration of PPU and methodsetPPU()
  • src/core/cython/mmu.pyx- Added methodset_ppu()to the wrapper
  • src/viboy.py- Added PPU-MMU connection and heartbeat mode
  • tests/test_core_ppu_modes.py- Test suite (4 tests)

Tests and Verification

A complete test suite was created intests/test_core_ppu_modes.pywhich verifies:

  • Mode transitions: Verifies that the PPU switches correctly between modes 0, 1, 2 and 3 during a scanline.
  • V-Blank Mode: Verifies that the PPU enters Mode 1 (V-Blank) on line 144.
  • Reading STAT: Verifies that the STAT register is read correctly with PPU modes and that the configurable bits are preserved.
  • LYC=LY Coincidence: Verifies that STAT bit 2 is updated correctly when LY == LYC.

Command executed:

pytest tests/test_core_ppu_modes.py -v

Expected result:

tests/test_core_ppu_modes.py::TestPPUModes::test_ppu_mode_transitions PASSED
tests/test_core_ppu_modes.py::TestPPUModes::test_ppu_vblank_mode PASSED
tests/test_core_ppu_modes.py::TestPPUModes::test_ppu_stat_register PASSED
tests/test_core_ppu_modes.py::TestPPUModes::test_ppu_stat_lyc_coincidence PASSED

4 passed in 0.05s

Test code (key fragment):

def test_ppu_mode_transitions(self):
    """Checks PPU mode transitions during a scanline."""
    mmu = PyMMU()
    ppu = PyPPU(mmu)
    mmu.set_ppu(ppu) # CRITICAL: Connect PPU to MMU
    
    mmu.write(0xFF40, 0x91) # LCD ON
    
    # Line 0, Start: Mode 2 (OAM Search)
    assert ppu.mode == 2
    
    # Advance 80 cycles: Mode 3 (Pixel Transfer)
    ppu.step(80 * 4)
    assert ppu.mode == 3
    
    # Advance 172 more cycles: Mode 0 (H-Blank)
    ppu.step(172 * 4)
    assert ppu.mode == 0

Native Validation: All tests validate the compiled C++ module using Cython wrappers. The C++ PPU updates its modes internally and the C++ MMU reads STAT dynamically by callingPPU::get_stat().

Sources consulted

  • Bread Docs: LCD Status Register (STAT)- Detailed description of STAT register bits and PPU modes
  • Bread Docs: LCD Timing- Timing of PPU modes within a scanline (80, 172, 204 cycles)
  • Bread Docs: Interrupts- STAT interrupts and how they are generated based on STAT bits

Educational Integrity

What I Understand Now

  • PPU state machine: The PPU operates in 4 different modes during each frame, each with different memory access restrictions. The modes change automatically according to the internal timing of the PPU (cycles within the line and line number).
  • STAT register: It is a hybrid register that combines read-only bits (updated by the PPU) with read/write bits (configurable by the CPU). The reading should be dynamic to reflect the current state.
  • Circular dependency: The MMU needs access to PPU to read STAT, and the PPU needs access to MMU to read registers. It is solved by dependency injection with pointers, establishing the connection after creating both objects.
  • STAT Polling: Games constantly poll the STAT register to wait for safe modes (H-Blank or V-Blank) before writing to VRAM. Without this functionality, the CPU is stuck waiting for a change that never happens.

What remains to be confirmed

  • Behavior on real hardware: Check if there is any specific timing or race condition between STAT writing and STAT reading that needs to be emulated.
  • STAT Interrupts: Although they are already implemented incheck_stat_interrupt(), verify that they fire correctly when the interrupt bits are active and the mode changes.

Hypotheses and Assumptions

STAT bit 7 always 1: According to Pan Docs, STAT bit 7 is always 1. This is implemented inget_stat()forcing bit 7 to 1. If this behavior is discovered to be different in the future, it can be easily adjusted.

STAT update on each read: It is assumed that each STAT read should get the updated value at that exact moment. This is consistent with the behavior of real hardware, where STAT is a register that reflects the current state of the PPU.

Next Steps

  • [ ] Check graphics unlock: Run the emulator with a test ROM (Tetris, Mario) and verify that the graphics appear correctly after this change.
  • [ ] Performance optimization: If STAT polling is very frequent, consider optimizations to reduce the overhead of calls toget_stat().
  • [ ] STAT Interrupts: Verify that STAT interrupts fire correctly when the interrupt bits are set and the mode changes.
  • [ ] Window and Sprites: Once the Background works correctly, implement the rendering of Window and Sprites (Phase E).