267. CPU.step() 返回周期数

让 CPU.step() 返回基本指令周期数。

267 / 356 · tests/chapter_04_ppu_timing_and_vblank/test_267_cpu_step_returns_cycles.py

参考资料

https://www.nesdev.org/wiki/Visual6502wiki/6502_all_256_Opcodes

需要更新的文件

emulator/cpu/cpu.py

此步骤存在的原因

模拟器现在已经有了独立的 OPCODE_CYCLES 表。下一步的时序桥接是让 CPU.step() 返回它所执行的操作码的基本周期数。

这为未来的 Console.step() 形态做准备:

cpu_cycles = cpu.step()
ppu.step(cpu_cycles * 3)
console.consume_nmi_if_requested()

什么是基本指令周期数?基本指令周期数是指在加上任何动态惩罚周期之前,一个操作码通常需要的 CPU 周期数。

最小示例

NOP implied      opcode $EA -> 2 cycles
LDA immediate   opcode $A9 -> 2 cycles
JSR absolute    opcode $20 -> 6 cycles

常见误解

周期数和取指的字节数并不相同。JSR 占 3 个字节,却需要 6 个周期。不要在 fetch_byte() 内部计算周期数。

建议的实现示例

from emulator.cpu.opcodes import OPCODE_CYCLES, OPCODE_TABLE


class CPU:
    ...

    def step(self) -> int:
        opcode = self.fetch_byte()
        handler = OPCODE_TABLE.get(opcode)
        if handler is None:
            raise NotImplementedError(f"Opcode {opcode:02X} not implemented")

        handler(self)
        return OPCODE_CYCLES[opcode]

为什么这样做能避免大规模重构

OPCODE_TABLE 仍然是原有的操作码到处理函数的映射。OPCODE_CYCLES 是一张并行的元数据表。旧的指令行为测试应该能够继续通过,因为 CPU.step() 仍然执行的是同一个处理函数。

重要限制

这里只返回基本周期数。动态时序在这一步中特意排除在外:

branch taken penalties
branch page-cross penalties
indexed load page-cross penalties

这些应该在之后作为单独的一步来建模。

不在本步骤范围内

  • 把 OPCODE_TABLE 重构为数据类条目
  • 在 fetch_byte() 中加入周期数
  • Console.step()
  • 按 CPU 周期数 * 3 来推进 PPU
  • 动态的额外周期

运行本课

uv run pytest tests/chapter_04_ppu_timing_and_vblank/test_267_cpu_step_returns_cycles.py -v