⚠️ 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 and STAT Register Modes

Date:2025-12-18 StepID:0047 State: Verified

Summary

It was implementedPPU mode state machine(Mode 0, 1, 2, 3) which controls the life cycle of each scan line. The PPU now dynamically updates its mode based on line timing, allowing Let games detect when it is safe to access VRAM. It was integratedSTAT register (0xFF41)in the MMU so that games can read the current PPU mode and configure mode-based interrupts. This implementation is critical because many games wait for STAT to change dynamically before continuing. with initialization or rendering.

Hardware Concept

The 4 Modes of the PPU: Each scan line of 456 T-Cycles is divided into states indicating What is the PPU doing at all times:

  • Mode 2 (OAM Search): First ~80 cycles. The PPU looks for sprites in OAM (Object Attribute Memory) that intersect with the current line. During this mode, the CPU isblocked from accessing OAM(0xFE00-0xFE9F) to avoid access conflicts.
  • Mode 3 (Pixel Transfer): Next ~172 cycles (80-251). The PPU draws the pixels of the line reading VRAM tiles and applying palettes. During this mode, the CPU isblocked from accessing VRAM(0x8000-0x9FFF) and OAM to prevent data corruption during rendering.
  • Mode 0 (H-Blank): Rest of the line (~204 cycles, 252-455). Horizontal rest after drawing the line. During this mode, the CPUcan freely access VRAM and OAMto update tiles, tilemaps, sprites, etc.
  • Mode 1 (V-Blank): Lines 144-153 complete (10 lines). Vertical rest after drawing all the visible lines. During this mode, the CPUcan freely access VRAM and OAM. It's time ideal for updating graphics as there is no active rendering.

STAT register (0xFF41): Games constantly read this register to know what mode the PPU is in:

  • Bits 0-1: Current PPU mode (00=H-Blank, 01=V-Blank, 10=OAM Search, 11=Pixel Transfer).read-only.
  • Bit 2: LYC=LY Coincidence Flag (LY == LYC). Indicates whether the current line matches LYC.
  • Bit 3: Mode 0 (H-Blank) Interrupt Enable. If active, generates interrupt when entering H-Blank.
  • Bit 4: Mode 1 (V-Blank) Interrupt Enable. If active, generates interrupt when entering V-Blank.
  • Bit 5: Mode 2 (OAM Search) Interrupt Enable. If active, generates interrupt when entering OAM Search.
  • Bit 6: LYC=LY Coincidence Interrupt Enable. If active, generates interrupt when LY == LYC.
  • Bit 7: Not used (always 0).

Identified problem: If the STAT register is not updated dynamically, polling games STATs wait forever for the PPU to enter a safe mode (H-Blank or V-Blank) before continuing. This causes the game freezes with the LCD off (LCDC=0x00), waiting for a signal that never arrives.

Fountain: Pan Docs - LCD Status Register (STAT), PPU Modes, LCD Timing

Implementation

1. PPU Mode State Machine

Attribute addedmodeto classPPUand the method was implemented_update_mode()which calculates the current mode based on the point on the line (line_cycles) and LY:

def _update_mode(self) -> None:
    """Updates the current PPU mode based on the point on the line."""
    # If we are in V-Blank (lines 144-153), always Mode 1
    if self.ly >= VBLANK_START:
        self.mode = PPU_MODE_1_VBLANK
        return
    
    # For visible lines (0-143), the mode depends on the cycles within the line
    line_cycles = self.clock
    
    if line_cycles< MODE_2_CYCLES:  # 0-79
        self.mode = PPU_MODE_2_OAM_SEARCH
    elif line_cycles < (MODE_2_CYCLES + MODE_3_CYCLES):  # 80-251
        self.mode = PPU_MODE_3_PIXEL_TRANSFER
    else:  # 252-455
        self.mode = PPU_MODE_0_HBLANK

The methodstep()now call_update_mode()before and after processing complete lines to ensure that the mode always reflects the current state of the PPU.

2. STAT register in MMU

Added STAT register read/write interception (0xFF41) in the MMU:

  • Reading: Callppu.get_stat()which combines the current mode (bits 0-1) with the bits configurable (2-6) stored in memory.
  • Writing: Saves only the configurable bits (2-6) in memory, ignoring bits 0-1 which are read only.

The methodget_stat()on the PPU reads directly frommmu._memory[0xFF41]to avoid recursion infinite (since the MMU callsppu.get_stat()when 0xFF41 is read).

3. Mode and Timing Constants

Constants were added for the PPU modes and the cycles of each mode:

PPU_MODE_0_HBLANK = 0 # H-Blank
PPU_MODE_1_VBLANK = 1 # V-Blank
PPU_MODE_2_OAM_SEARCH = 2 # OAM Search
PPU_MODE_3_PIXEL_TRANSFER = 3 # Pixel Transfer

MODE_2_CYCLES = 80 # OAM Search: first 80 cycles
MODE_3_CYCLES = 172 # Pixel Transfer: next 172 cycles (80-251)
MODE_0_CYCLES = 204 # H-Blank: rest (252-455)

Components created/modified

  • src/gpu/ppu.py(modified):
    • Added attributemodeto store the current PPU mode
    • Method_update_mode(): Calculate the mode according to line_cycles and LY
    • Methodstep(): Updated to call_update_mode()before and after processing lines
    • Methodget_mode(): Returns the current PPU mode
    • Methodget_stat(): Returns the value of the STAT register combining mode and configurable bits
    • Added constant modes and timing
  • src/memory/mmu.py(modified):
    • STAT Read Intercept (0xFF41): Callppu.get_stat()
    • STAT write intercept (0xFF41): Saves only configurable bits (2-6), ignores bits 0-1
  • tests/test_ppu_modes.py(new):
    • 7 comprehensive tests validating mode transitions, V-Blank, STAT read/write

Design decisions

Direct access to _memory in get_stat(): To avoid infinite recursion,get_stat()access directly tommu._memory[0xFF41]instead of usingmmu.read_byte(0xFF41). This is a detail implementation required, but it is explicitly documented in the code.

Mode update before and after processing lines: The mode is updated both before processing complete lines (to reflect the state during the line) and after (to reflect the residual state if there are any left cycles). This ensures that the mode is always correct, even when processing multiple lines in a single call tostep().

STAT bits 0-1 are read-only: Although the software may attempt to write to bits 0-1, these always reflect the current PPU mode and cannot be modified. The MMU ignores these bits when writing, saving only the configurable bits (2-6).

Affected Files

  • src/gpu/ppu.py(modified):
    • Added attributemodeand mode/timing constants
    • Method_update_mode(): Calculate the current PPU mode
    • Methodstep(): Updated to keep synchronized mode
    • Methodsget_mode()andget_stat(): New public methods
  • src/memory/mmu.py(modified):
    • STAT read/write intercept (0xFF41)
  • tests/test_ppu_modes.py(new):
    • 7 complete tests to validate PPU modes and STAT registration

Tests and Verification

Unit Tests (pytest)

Command executed: pytest -q tests/test_ppu_modes.py

Around: Windows 10, Python 3.13.5

Result: 7 passedin 0.25s

How valid:

  • Mode transitions during visible line: Verify that the modes change correctly from 2 → 3 → 0 according to the cycles within the line (0-79: Mode 2, 80-251: Mode 3, 252-455: Mode 0).
  • V-Blank Mode: Verifies that lines 144-153 are always in Mode 1, regardless of the cycles.
  • New line mode reset: Verifies that at the start of each new visible line, the mode resets to Mode 2.
  • Reading STAT: Verifies that the STAT register returns the correct mode in bits 0-1.
  • Writing to STAT: Verifies that writing to STAT preserves the configurable bits (2-6) but ignores bits 0-1.
  • Multiple lines: Verifies that the mode cycle repeats correctly across multiple visible lines.

Test code (essential fragment):

def test_mode_transitions_visible_line(self) -> None:
    """Test: Modes change correctly during a visible line."""
    mmu = MMU(None)
    ppu = PPU(mmu)
    mmu.set_ppu(ppu)
    
    # At the beginning of the line, it must be Mode 2 (OAM Search)
    assert ppu.get_mode() == PPU_MODE_2_OAM_SEARCH
    
    # Advance 80 cycles -> must change to Mode 3
    ppu.step(80)
    assert ppu.get_mode() == PPU_MODE_3_PIXEL_TRANSFER
    
    # Advance 172 more cycles -> must change to Mode 0
    ppu.step(172)
    assert ppu.get_mode() == PPU_MODE_0_HBLANK

Why this test demonstrates something about the hardware: The Game Boy hardware divides each line of 456 cycles in 3 different modes (OAM Search, Pixel Transfer, H-Blank) with specific timing. This test verifies that the emulation respects these exact times, which is critical because games poll STAT to know when they can access to VRAM safely. If the timing is incorrect, games may attempt to write to VRAM during Pixel Transfer, causing data corruption or unpredictable behavior.

Validation with Real ROM (Pending)

Next step: Run a real ROM (Tetris DX, Pokémon Red, etc.) to verify that the game detects The mode changes to STAT correctly and you can continue with initialization. The game is expected to turn on the LCD (LCDC=0x80 or 0x91) after detecting that the PPU is in a safe mode.

Sources consulted

  • Bread Docs: LCD Status Register (STAT) - Complete description of the STAT register, bits 0-6, mode-based interrupts
  • Bread Docs: PPU Modes - Description of the 4 PPU modes (Mode 0, 1, 2, 3), timing of each mode within a line
  • Bread Docs: LCD Timing - Timing of scanlines (456 T-Cycles per line), visible lines (0-143), V-Blank (144-153)

Educational Integrity

What I Understand Now

  • PPU State Machine: The PPU is not a static component that only counts lines. It's a machine of states that dynamically changes between 4 modes according to the timing of the line. Games are critically dependent of these mode changes to know when they can safely access VRAM.
  • STAT register as communication interface: STAT is not just a status register, it is an interface communication between the CPU and the PPU. Games read STAT constantly to sync with rendering and avoid writing to VRAM during Pixel Transfer (which would cause data corruption).
  • VRAM Access Lock: During Mode 3 (Pixel Transfer), the CPU is blocked from accessing VRAM because the PPU is actively reading tiles and palette data. If the CPU attempts to write during this mode, it may cause visual artifacts or unpredictable behavior. The actual hardware physically blocks access, but in a emulator we need to simulate this by updating STAT correctly so that games know when not to write.

What remains to be confirmed

  • LYC=LY Coincidence Flag (stat bit 2): Not implemented yet. This bit is set when LY == LYC (LY Compare, register 0xFF45). Games can use this to generate interruptions on specific lines (effects scroll, screen splits, etc.).
  • Interrupts based on STAT modes: STAT bits 3-6 allow interrupts to be enabled when the PPU enters a specific mode. The STAT interrupt system is not implemented yet, only the register is readable/writeable.
  • Actual VRAM Access Lock: We currently only update STAT, but do not physically block access to VRAM during Mode 3. On real hardware, writing to VRAM during Pixel Transfer can cause artifacts. In an emulator precise, we should detect these accesses and handle them appropriately (ignore, delay, or generate visual artifacts).

Hypotheses and Assumptions

Mode Timing: The exact times for each mode (80, 172, 204 cycles) are based on Pan Docs, but not I have verified with real hardware or test ROMs if these times are exact or if there are variations. On real hardware, the timing may vary slightly depending on the rendered content (number of sprites, tilemap complexity, etc.), but for a basic emulator, using fixed times is a reasonable approximation.

Mode update during step(): I update the mode before and after processing full lines to ensure that it always reflects the current state. This may not be exactly how the actual hardware works (which updates mode continuously), but it is a sufficient approximation for games to detect mode changes correctly.

Next Steps

  • [ ] Validate with real ROM (Tetris DX, Pokémon Red) that the game correctly detects mode changes and turns on the LCD
  • [ ] Implement LYC=LY Coincidence Flag (STAT bit 2) to allow interrupts on specific lines
  • [ ] Implement interrupts based on STAT modes (bits 3-6) so that games can use H-Blank interrupts, OAM Search, etc.
  • [ ] Consider actual blocking of VRAM access during Mode 3 (Pixel Transfer) for greater precision