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.pyReferences
https://www.nesdev.org/wiki/PPU_rendering
https://www.nesdev.org/wiki/PPU_scrollingWhy 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 stateFor 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 256The 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) pairThe 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 resultRun this lesson
uv run pytest tests/chapter_13_scrolling/test_349_timed_scanlines_to_framebuffer.py -v