349. Scanlines temporizados al framebuffer

Componer un framebuffer a partir de 240 estados de scanline temporizados completados.

Lección 349 de 356 · tests/chapter_13_scrolling/test_349_timed_scanlines_to_framebuffer.py

Archivo a actualizar

emulator/rendering/ppu_background_renderer.py

Referencias

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

Por qué existe este paso

El frame completado puede contener una posición de viewport horizontal distinta para cada fila visible. Una única instantánea de t al final del frame no puede representar a la vez un área de estado fija y un área de juego en movimiento, así que el renderizador de alto nivel debe componer cada fila a partir de su BackgroundScanlineState correspondiente.

Para cada fila de destino

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

Para cada píxel de destino

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

La fila de origen sigue siendo screen_y en este hito horizontal. La selección completa de la fila de origen vertical es trabajo futuro independiente.

¿Por qué almacenar en caché los pares de origen? Las filas pueden tener distintos valores de X de viewport mientras leen las mismas dos nametables. Volver a renderizar ambas nametables de origen completas en cada fila realizaría hasta 480 renderizados de origen. En su lugar, un diccionario local almacena cada par lógico una sola vez para esta composición:

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

La caché es intencionadamente local. Los datos de nametable, de patrones, de atributos o de paleta pueden cambiar antes de un frame posterior, así que una caché entre frames requeriría reglas de invalidación explícitas y propensas a errores.

Invariantes importantes

  • la entrada contiene exactamente 240 estados completados
  • las dimensiones de salida son exactamente 256x240
  • el índice de estado, la fila de destino y la fila de origen son el mismo screen_y
  • cada fila de destino recibe exactamente 256 píxeles
  • la selección horizontal se desborda dentro del par lógico de 512 píxeles
  • cada par de origen lógico se renderiza como máximo una vez por composición
  • los cambios de X de viewport no invalidan ni duplican un par en caché
  • el mirroring del cartucho sigue siendo comportamiento de PpuBus dentro del renderizado de origen

Concepto erróneo común

No invoques el compositor de viewport horizontal de frame completo existente una vez por fila. Eso construiría 240 framebuffers temporales de 256x240 para conservar solo una fila de cada uno. Este auxiliar escribe cada fila de destino directamente en un único framebuffer de resultado.

Estrategia de pruebas

Estas pruebas sustituyen el renderizado de nametables por framebuffers de origen sintéticos cuyas tuplas RGB codifican la base lógica, la X de origen y la Y de origen. Esto aísla la mecánica de composición de filas de la decodificación de patrones, la selección de paleta, la memoria de la PPU y el mirroring.

Fuera de alcance

  • cambiar el adaptador de viewport público
  • el comportamiento de respaldo cuando no hay un frame temporizado disponible
  • la composición de la máscara de opacidad
  • el desplazamiento vertical completo de la fila de origen
  • las cachés de renderizado persistentes

Implementación de ejemplo completa

# 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

Ejecutar esta lección

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