346. 完成扫描线滚动帧

发布一份完整的定时扫描线帧,并重置当前记录缓冲区。

346 / 356 · tests/chapter_13_scrolling/test_346_complete_scanline_scroll_frame.py

要更新的文件

emulator/ppu/ppu.py

参考资料

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

为什么需要这一步

PPU 时序会把可见扫描线状态记录到一个可变的当前帧列表中。高层渲染器必须消费来自已结束那一帧的稳定数据,而不是 PPU 仍在修改的列表。

因此 PPU 拥有两个不同的值

current_scanline_scroll_states:
    mutable list used while the active frame is being stepped

completed_scanline_scroll_states:
    immutable tuple published after the frame finishes

在帧边界处

all 240 entries exist:
    publish a 240-state tuple

any entry is missing or the list length is not 240:
    publish an empty tuple

after either result:
    replace current state with a fresh [None] * 240 list

为什么对不完整的数据发布空元组?未知的行绝不能继承一个猜测出来的地址。空元组成为一个明确的信号:后续渲染在这一帧应继续使用现有的帧级兼容路径。

直观模型

current list     = notebook still being written
completed tuple  = sealed notebook safe for the renderer

重要不变量

  • 完成的数据恰好包含 240 个状态或零个状态
  • 完成的数据是不可变的
  • 当前容器和完成容器不是同一个对象
  • 记录下一帧不会改变已完成的帧
  • 发布发生在预渲染完成之后、计数器进入帧 0 之前

常见误解

帧完成并不发生在扫描线 241 处 VBlank 开始的时候。预渲染扫描线 261 仍属于模拟器进入下一帧之前的时序序列。

超出范围

  • 在帧缓冲渲染中消费已完成的状态
  • 不透明度掩码合成
  • 精灵零命中的相关变更

完整示例实现

# emulator/ppu/ppu.py

@dataclass
class PPU:
    ...
    current_scanline_scroll_states: list[BackgroundScanlineState | None] = field(
            default_factory= lambda: [None] * 240
    )

    # --- NEW LINE: LAST COMPLETE TIMED SCANLINE FRAME ---
    completed_scanline_scroll_states: tuple[
        BackgroundScanlineState, ...
    ] = ()

    ...

    # --- NEW BLOCK: PUBLISH AND RESET SCANLINE STATES ---
    def _complete_scanline_scroll_frame(self) -> None:
        current = self.current_scanline_scroll_states

        if (
            len(current) == 240
            and all(state is not None for state in current)
        ):
            self.completed_scanline_scroll_states = tuple(
                state
                for state in current
                if state is not None
            )
        else:
            self.completed_scanline_scroll_states = ()

        self.current_scanline_scroll_states = [None] * 240

    def step(self, cycles: int = 1) -> None:
        ...

        if self.cycle >= PPU_CYCLES_PER_SCANLINE:
            self.cycle = 0
            self.scanline += 1

            if self.scanline >= PPU_SCANLINES_PER_FRAME:
                # --- NEW LINE: PUBLISH BEFORE ENTERING THE NEXT FRAME ---
                self._complete_scanline_scroll_frame()
                self.scanline = 0
                self.frame += 1

        ...

运行本课

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