349. Синхронизированные scanlines в framebuffer

Составить один framebuffer из 240 завершённых синхронизированных состояний scanline.

Урок 349 из 356 · tests/chapter_13_scrolling/test_349_timed_scanlines_to_framebuffer.py

Файл для обновления

emulator/rendering/ppu_background_renderer.py

Ссылки

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

Зачем нужен этот шаг

Завершённый кадр может содержать разную горизонтальную позицию viewport для каждой видимой строки. Один снимок t конца кадра не может представить как фиксированную область статуса, так и движущуюся область геймплея, поэтому высокоуровневый рендерер должен составить каждую строку из соответствующего BackgroundScanlineState.

Для каждой строки назначения

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

Для каждого пикселя назначения

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

Исходная строка остаётся screen_y для этого горизонтального этапа. Полный выбор вертикальной исходной строки — это отдельная будущая работа.

Почему кэшировать исходные пары? Строки могут иметь разные значения viewport X при чтении одних и тех же двух nametable. Рендеринг обеих полных исходных nametable снова для каждой строки выполнил бы столько же, сколько 480 исходных рендеров. Локальный словарь вместо этого хранит каждую логическую пару один раз для этой композиции:

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

Кэш намеренно локален. Данные nametable, pattern, attribute или palette могут измениться перед более поздним кадром, поэтому кэширование между кадрами потребует явных и подверженных ошибкам правил инвалидации.

Важные инварианты

  • вход содержит ровно 240 завершённых состояний
  • размеры выхода ровно 256x240
  • индекс состояния, строка назначения и исходная строка — это один и тот же screen_y
  • каждая строка назначения получает ровно 256 пикселей
  • горизонтальный выбор переносится через 512-пиксельную логическую пару
  • каждая логическая исходная пара отрендеривается максимум один раз за композицию
  • изменения viewport X не инвалидируют и не дублируют кэшированную пару
  • зеркалирование картриджа остаётся поведением PpuBus внутри исходного рендеринга

Распространённое заблуждение

Не вызывайте существующий полнокадровый горизонтальный композитор viewport один раз за строку. Это построит 240 временных framebuffer 256x240 для сохранения только одной строки из каждого. Эта вспомогательная функция записывает каждую строку назначения непосредственно в один результирующий framebuffer.

Стратегия тестирования

Эти тесты заменяют рендеринг nametable синтетическими исходными framebuffer, чьи кортежи RGB кодируют логическую базу, исходный X и исходный Y. Это изолирует механику композиции строк от декодирования pattern, выбора palette, памяти PPU и зеркалирования.

Вне области действия

  • изменение публичного адаптера viewport
  • поведение fallback для недоступного синхронизированного кадра
  • композиция opacity-mask
  • полная вертикальная прокрутка исходной строки
  • постоянные кэши рендеринга

Полный пример реализации

# 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

Запустить этот урок

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