337. 规划带时序的 VRAM 滚动

在修改 PPU 步进之前,理解带时序的 v/t/x 滚动方案。

337 / 356 · tests/chapter_13_scrolling/test_337_plan_timed_vram_scrolling.py

阅读后需确认的文件

emulator/ppu/ppu.py

参考

https://www.nesdev.org/wiki/PPU_scrolling
https://www.nesdev.org/wiki/PPU_scrolling#During_rendering
https://www.nesdev.org/wiki/PPU_rendering#Line-by-line_timing

此阅读步骤存在的原因

PPU.step() 是一个对时序高度敏感的子系统。一个小失误就可能产生一幅看起来几乎正确、却使用了错误扫描线、名称表或滚动位置的画面。在修改它之前,我们需要一个稳定的心智模型和一个清晰的迁移方案。

当前的问题

现有的帧级视口在整帧结束后解码 temp_vram_addr(t)加细 X。这对简单的合成滚动可行,但对在一帧内改变滚动位置的游戏则会失效。

《超级马里奥兄弟》大致采用以下形式:

visible rows 0-30:
    fixed status-bar scroll

sprite-zero split:
    CPU prepares another horizontal position

visible rows 31-239:
    moving gameplay scroll

一帧结束后采样的单个值无法同时描述这两个区域。

还存在第二个问题:t 并非专用于滚动的值。$2006(PPUADDR)在游戏加载名称表和调色板时也会写入 t。例如:

CPU writes PPUADDR $3F00
    -> t becomes $3F00

把这个最终值当作滚动来解码,可能会短暂地选中错误的视口。这就是为什么启动或关卡切换时,屏幕全黑却看起来在快速滚动的原因。

四个内部滚动值

v = current VRAM address used by rendering
t = temporary VRAM address prepared by CPU register writes
x = fine horizontal pixel offset
w = first/second-write toggle for $2005 and $2006

直观模型

t is the next address configuration being prepared.
v is the address currently moving through rendering.
x is the 0-7 pixel offset inside the first tile.
w remembers which half of a two-write register comes next.

常见误解

t is the current scroll position for the entire frame.

正确模型

CPU writes assemble t and x.
PPU timing copies selected fields from t into v.
Rendering advances v while tiles and scanlines are processed.

重要的带时序操作

background-fetch dots, every 8 dots:
    increment horizontal v

dot 256:
    increment vertical v

dot 257:
    copy horizontal fields from t into v

pre-render dots 280-304:
    copy vertical fields from t into v

水平字段

coarse X
horizontal nametable bit

垂直字段

coarse Y
fine Y
vertical nametable bit

为什么复制是有选择性的

在第 257 点,只应刷新下一条扫描线的水平位置。复制整个 t 也会在错误的时机替换垂直状态。

为什么我们按扫描线记录

现有的名称表渲染器已经能生成正确的 RGB 源帧缓冲区。我们尚不需要用一个完整的逐点像素抓取流水线来替换它。相反,PPU 时序将为每条可见扫描线记录一次有效的 v + x 位置:

scanline 0  -> viewport X 0
scanline 1  -> viewport X 0
...
scanline 30 -> viewport X 0
scanline 31 -> viewport X 40
...

在整帧结束后,高层渲染器在恰好一次地复制每个输出行时使用这些记录的位置。

预取细节

在第 1 点,v 实际上已经领先两个图块,因为第 321-336 点已经把前两个背景图块抓取到了硬件移位寄存器中。扫描线快照必须补偿这两次粗 X 递增,才能推导出可见视口。否则整个背景看起来会偏移 16 像素。

兼容性方案

  • 保持现有的 v、t、x、w 寄存器行为和公开的渲染辅助函数不变
  • 在把纯地址操作应用到 PPU.step() 内部之前,先添加这些操作
  • 在迁移期间保留旧的帧级视口路径作为回退方案
  • 只有在存在完整一帧的状态之后,才添加带时序的逐扫描线渲染
  • 保持帧缓冲区和不透明度掩码的行选择方式不变
  • 在每个编号步骤之后运行 uv run pytest

接下来的专项步骤

  • 纯水平 v 递增与名称表环绕
  • 纯水平 t 到 v 字段复制
  • 水平抓取点递增与第 257 点复制
  • 带细 Y 及第 29-31 行的纯垂直递增
  • 纯垂直 t 到 v 字段复制
  • 第 256 点的垂直递增与预渲染阶段的垂直复制
  • 为每条可见扫描线记录有效的 v + 细 X 状态
  • 按行限定的帧缓冲区组合
  • 完全一致的按行限定不透明度掩码组合
  • 用于 sprite-zero-hit 调度的、感知扫描线的掩码
  • 手动验证《超级马里奥兄弟》的状态栏、移动、过场和帧率

精度边界

这种方案对滚动地址的时序和扫描线视口选择进行建模。它还不是一个完整的逐点背景抓取/移位寄存器渲染器。CPU 寄存器写入也仍以当前的指令步进粒度计时。

阅读后需要执行的操作

在 emulator/ppu/ppu.py 中,紧邻 vram_addr、temp_vram_addr、fine_x 和 second_write_toggle 字段处添加以下这段注释:

# pass_test_337

此步骤中不要实现任何滚动更改。该注释是通过测试 337 所需的唯一生产文件改动。

运行本课

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