351. Timed scanlines to opaque mask

Compose one background opacity mask from 240 completed scanline states.

Lesson 351 of 356 · tests/chapter_13_scrolling/test_351_timed_scanlines_to_opaque_mask.py

File to update

emulator/rendering/ppu_background_renderer.py

References

https://www.nesdev.org/wiki/PPU_rendering
https://www.nesdev.org/wiki/PPU_scrolling

Why this step exists

Timed framebuffer rows can show a fixed status area and a differently scrolled gameplay area in one frame. Sprite priority and sprite-zero-hit decisions also need background opacity at those exact screen coordinates. If RGB pixels use timed states while opacity uses one final t snapshot, sprites can appear in front of solid tiles that should occlude them.

A BackgroundOpaqueMask is a flat 256x240 list of Boolean values

True:  the decoded background pattern pixel is nonzero
False: the decoded background pattern pixel is transparent

Opacity is pattern information, not an RGB-color test. A visible pattern pixel can use a black palette color, while a transparent pattern pixel displays the universal background color.

The mask compositor must mirror framebuffer coordinate selection exactly:

state = completed_scanline_scroll_states[screen_y]
logical_x = (viewport_x + screen_x) % 512

logical X 0-255:   read the left source mask
logical X 256-511: read the right source mask after subtracting 256

For this horizontal milestone, source Y remains screen_y.

Why cache source masks? Viewport X may change on every scanline without changing the logical source pair.

The local cache builds each pair once per composition

$2000 key -> masks for ($2000, $2400)
$2800 key -> masks for ($2800, $2C00)

Important invariants

  • input contains exactly 240 completed states
  • output contains exactly 256 * 240 Boolean entries
  • state index, source row, and destination row use the same screen_y
  • horizontal coordinates wrap across the complete 512-pixel pair
  • each logical pair is built at most once per composition
  • viewport-X changes reuse the existing source pair
  • opacity-mask code never calls framebuffer rendering
  • framebuffer and opacity-mask coordinate mappings remain identical

Common misconception

Do not derive this mask from rendered RGB values. Sprite priority depends on whether the original background pattern value was zero, which cannot be reconstructed reliably from its final palette color.

Testing strategy

These tests replace PPU-backed mask construction with synthetic masks. Each Boolean value is a deterministic function of logical base address, source X, and source Y. That makes wrong pair selection, wrong row indexing, and horizontal-wrap errors observable without involving CHR decoding, palettes, or cartridge mirroring.

Out of scope

  • activating this helper in the public opacity-mask adapter
  • fallback selection for startup or incomplete frames
  • sprite-zero-hit integration
  • full vertical source-row scrolling

Complete example implementation (Very similar logic to _timed_scanlines_to_framebuffer):

# emulator/rendering/ppu_background_renderer.py

# --- NEW BLOCK: COMPOSE COMPLETED TIMED OPACITY ROWS ---
def _timed_scanlines_to_opaque_mask(
    ppu: PPU,
) -> BackgroundOpaqueMask:
    states = ppu.completed_scanline_scroll_states

    if len(states) != NAMETABLE_PIXEL_HEIGHT:
        raise ValueError(
            "Timed opacity mask requires exactly 240 scanline states"
        )

    result: BackgroundOpaqueMask = [False] * (
        NAMETABLE_PIXEL_WIDTH * NAMETABLE_PIXEL_HEIGHT
    )
    pair_cache: dict[
        int,
        tuple[BackgroundOpaqueMask, BackgroundOpaqueMask],
    ] = {}
    logical_width = NAMETABLE_PIXEL_WIDTH * 2

    for screen_y, state in enumerate(states):
        left_base, right_base = _scanline_horizontal_pair(state)

        if left_base not in pair_cache:
            pair_cache[left_base] = (
                ppu_background_to_opaque_mask(
                    ppu,
                    base_nametable_addr=left_base,
                ),
                ppu_background_to_opaque_mask(
                    ppu,
                    base_nametable_addr=right_base,
                ),
            )

        left, right = pair_cache[left_base]
        viewport_x = _scanline_viewport_x(state)
        destination_row = screen_y * NAMETABLE_PIXEL_WIDTH

        for screen_x in range(NAMETABLE_PIXEL_WIDTH):
            logical_x = (viewport_x + screen_x) % logical_width

            if logical_x < NAMETABLE_PIXEL_WIDTH:
                source = left
                source_x = logical_x
            else:
                source = right
                source_x = logical_x - NAMETABLE_PIXEL_WIDTH

            destination_index = destination_row + screen_x
            source_index = destination_row + source_x
            result[destination_index] = source[source_index]

    return result

Run this lesson

uv run pytest tests/chapter_13_scrolling/test_351_timed_scanlines_to_opaque_mask.py -v