321. Ppu sets scheduled sprite zero hit

Let PPU timing set sprite 0 hit at a previously detected screen position.

Lesson 321 of 356 · tests/chapter_11_sprite_zero_hit/test_321_ppu_sets_scheduled_sprite_zero_hit.py

File to update

emulator/ppu/ppu.py

Why this step exists

Step 320 can find the first overlapping sprite 0/background pixel and return its screen position:

(screen_x, screen_y)

The PPU must not set PPUSTATUS bit 6 immediately when that position is discovered. It should store the position and set the flag only when emulated PPU timing reaches the corresponding visible pixel.

Simplified coordinate mapping

screen y -> PPU scanline y
screen x -> PPU cycle x + 1

Visible framebuffer coordinates begin at x=0, while this project's simplified PPU timing treats visible output as beginning at cycle 1:

screen x=0 -> PPU cycle 1
screen x=1 -> PPU cycle 2
screen x=40 -> PPU cycle 41

Suggested implementation changes

# --- NEW LINE ---
SpriteZeroHitPosition = tuple[int, int]
# --- END NEW LINE ---


@dataclass
class PPU:
    ...
    scanline: int = 0
    frame: int = 0
    nmi_requested: bool = False
    # --- NEW BLOCK ---
    sprite_zero_hit_position: SpriteZeroHitPosition | None = None

    def set_sprite_zero_hit_position(
        self,
        position: SpriteZeroHitPosition | None,
    ) -> None:
        self.sprite_zero_hit_position = position
    # --- END NEW BLOCK ---

    def step(self, cycles: int = 1) -> None:
        ...
        for _ in range(cycles):
            self.cycle += 1

            # --- NEW BLOCK ---
            if self.sprite_zero_hit_position is not None:
                hit_x, hit_y = self.sprite_zero_hit_position

                if self.scanline == hit_y and self.cycle == hit_x + 1:
                    self.status |= SPRITE_ZERO_HIT
                    self.sprite_zero_hit_position = None
            # --- END NEW BLOCK ---

            ...

Why consume the position? The position describes one future timing event. After the event fires, setting it to None prevents the same scheduled event from firing again in a later frame. The PPUSTATUS flag itself remains set until Step 319's pre-render clear.

Important distinction

set_sprite_zero_hit_position((x, y))
    stores a future position

PPU.step()
    sets SPRITE_ZERO_HIT when timing reaches that position

Important boundary

PPU receives only a neutral tuple[int, int] | None. It must not import rendering modules or know how CHR/background overlap was detected.

Out of scope

  • Console wiring
  • calling find_sprite_zero_hit_position()
  • selecting sprite/background pattern tables
  • PPUMASK rendering-enable rules
  • x=255 hardware exception
  • OAM Y+1 correction
  • Super Mario Bros. validation

Run this lesson

uv run pytest tests/chapter_11_sprite_zero_hit/test_321_ppu_sets_scheduled_sprite_zero_hit.py -v