349. Timed scanlines to framebuffer

Compose one framebuffer from 240 completed timed scanline states.

Lesson 349 of 356 · tests/chapter_13_scrolling/test_349_timed_scanlines_to_framebuffer.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

The completed frame can contain a different horizontal viewport position for every visible row. A single end-of-frame t snapshot cannot represent both a fixed status area and a moving gameplay area, so the high-level renderer must compose each row from its matching BackgroundScanlineState.

For each destination row

state = completed_scanline_scroll_states[screen_y]
left_base, right_base = logical pair selected by state
viewport_x = horizontal pixel position decoded from state

For each destination pixel

logical_x = (viewport_x + screen_x) % 512

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

The source row remains screen_y for this horizontal milestone. Full vertical source-row selection is separate future work.

Why cache source pairs? Rows can have different viewport X values while reading the same two nametables. Rendering both complete source nametables again for every row would perform as many as 480 source renders. A local dictionary instead stores each logical pair once for this composition:

$2000 key -> rendered ($2000, $2400) pair
$2800 key -> rendered ($2800, $2C00) pair

The cache is intentionally local. Nametable, pattern, attribute, or palette data may change before a later frame, so cross-frame caching would require explicit and error-prone invalidation rules.

Important invariants

  • input contains exactly 240 completed states
  • output dimensions are exactly 256x240
  • state index, destination row, and source row are the same screen_y
  • every destination row receives exactly 256 pixels
  • horizontal selection wraps across the 512-pixel logical pair
  • each logical source pair is rendered at most once per composition
  • viewport X changes do not invalidate or duplicate a cached pair
  • cartridge mirroring remains PpuBus behavior inside source rendering

Common misconception

Do not invoke the existing full-frame horizontal viewport compositor once per row. That would construct 240 temporary 256x240 framebuffers to retain only one row from each. This helper writes each destination row directly into one result framebuffer.

Testing strategy

These tests replace nametable rendering with synthetic source framebuffers whose RGB tuples encode logical base, source X, and source Y. This isolates row-composition mechanics from pattern decoding, palette selection, PPU memory, and mirroring.

Out of scope

  • changing the public viewport adapter
  • fallback behavior for an unavailable timed frame
  • opacity-mask composition
  • full vertical source-row scrolling
  • persistent rendering caches

Complete example implementation

# emulator/rendering/ppu_background_renderer.py

# --- UPDATED LINES: IMPORT FRAMEBUFFER DIMENSIONS ---
from emulator.rendering.background_viewport import (
    NAMETABLE_PIXEL_HEIGHT,
    NAMETABLE_PIXEL_WIDTH,
    ...
)

# --- NEW BLOCK: COMPOSE COMPLETED TIMED SCANLINES ---
def _timed_scanlines_to_framebuffer(ppu: PPU) -> Framebuffer:
    states = ppu.completed_scanline_scroll_states

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

    result = Framebuffer(
        width=NAMETABLE_PIXEL_WIDTH,
        height=NAMETABLE_PIXEL_HEIGHT,
    )
    pair_cache: dict[int, tuple[Framebuffer, Framebuffer]] = {}
    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_framebuffer(
                    ppu,
                    base_nametable_addr=left_base,
                ),
                ppu_background_to_framebuffer(
                    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.pixels[destination_index] = source.pixels[source_index]

    return result

Run this lesson

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