模拟器错误:真希 - 天地无用

这可能是一系列关于在我的一个模拟器中暴露漏洞的游戏/软件的帖子的开始,取决于我找到多少有趣到值得写的内容。大多数情况下,有趣的部分通常是触发漏洞的游戏行为,而不是漏洞本身。

这里没有突破性的研究 - 这只是关于我个人遇到的一个问题的记录,其他更强大的模拟器多年前就解决了这个问题。

所讨论的游戏是 Super Famicom 上的《Kishin Douji Zenki: Tenchi Meidou》,这是基于 90 年代晦涩的漫画/动漫系列《Zenki》的五款授权游戏中的最后一款。

问题

问题很明显:游戏无法启动,在黑屏处永远挂起,从未显示任何图形。从经验来看,游戏无法启动几乎总是由以下两种情况之一引起:要么游戏陷入无限循环等待永远不会发生的事情,要么某个漏洞导致程序流程完全偏离轨道(例如跳转到非代码内存地址)。通过查看反汇编跟踪日志的末尾,通常很容易判断发生了哪种情况,尽管诊断根本原因可能非常复杂。这里是反汇编日志:

da:03d4    lda #$10
da:03d6    sta $2141
da:03d9    lda $2140
da:03dc    cmp $2140
da:03df    bne -8      ; $03d9
da:03e1    cmp #$aa
da:03e3    bne -17     ; $03d4
da:03d4    lda #$10
da:03d6    sta $2141
da:03d9    lda $2140
da:03dc    cmp $2140
da:03df    bne -8      ; $03d9
da:03e1    cmp #$aa
da:03e3    bne -17     ; $03d4
da:03d4    lda #$10
...

这是一个循环,看到 65816 无限循环轮询地址 $2140-$2143 是一个非常不好的迹象。

背景:SNES APU

SNES APU 是一个独立的子系统,有几个不同的组件。重要的是,主 SNES CPU(65816)不能直接与音频播放硬件接口。相反,APU 有自己的嵌入式可编程 CPU 来驱动播放硬件。这个嵌入式 CPU 是索尼 SPC700,由 Ken Kutaragi(是的,那个 Ken Kutaragi)与其他 SNES 音频硬件一起设计。SPC700 基于6502,但有自己的专有扩展,以新的指令和寻址模式的形式存在。与 65816 不同,SPC700 对 6502 ISA 进行了足够的更改,使其与 6502 软件不兼容 - 它是自己独特的 6502 变体。65816 和 SPC700 只能通过一组八个 8 位通信端口进行通信:四个从 65816 到 SPC700 的单向端口,以及四个相反方向的单向端口。所有通信和数据传输都必须通过这些通信端口 - 没有其他共享内存(或任何共享硬件)可供两个 CPU 访问。在 65816 方面,这些通信端口映射到内存银行 $00-$3F 和 $80-$BF 中的 $2140-$2143。从这些地址读取访问 SPC700 到 65816 的端口,写入访问 65816 到 SPC700 的端口。当游戏因 65816 无限轮询 APU 通信端口而挂起时,通常表明 65816 主程序和 SPC700 音频驱动程序之间存在某种不同步。这些问题调试起来可能非常困难。一个潜在的不同步问题的原因是,许多游戏有非常脆弱的 65816/SPC700 通信代码,充满了竞争条件,但由于所涉及处理器的非常精确的时序,在实际硬件上恰好可以工作。(剧透:这个游戏就是其中之一。)

反汇编

清理 65816 反汇编以使其更清晰:

<br> 1<br> 2<br> 3<br> 4<br> 5<br> 6<br> 7<br> 8<br> 9<br>10<br>11<br>12<br>13<br><br>; DBR (data bank) = $00<br>; M 和 X 标志都设置(8 位寄存器和内存访问)<br>loop:<br> lda #$10<br> sta $2141 ; APU 写端口 1<br><br>read:<br> lda $2140 ; APU 读端口 0<br> cmp $2140 ; APU 读端口 0<br> bne read<br><br> cmp #$aa<br> bne loop<br>

65816 循环
它将 0x10 写入 APU 端口 1(与 SPC700 通信),然后从 APU 端口 0(从 SPC700 通信)读取,如果读取到除 0xAA 之外的任何值,则循环。lda $214x 后跟 cmp $214xbne 是读取 APU 端口的非常常见的模式。一个 CPU 如果在另一个 CPU 写入端口的同时读取端口,理论上可能会读取未定义的值,因此双读是确保值稳定的检查。

SPC700 也在一个循环中:

0965    lda $1d
0967    ora #$30
0969    sta $f1
096b    nop
096c    lda $f4
096e    ora $f5
0970    ora $f6
0972    ora $f7
0974    bne -17    ; $0965
0965    lda $1d
0967    ora #$30
0969    sta $f1
096b    nop
096c    lda $f4
096e    ora $f5
0970    ora $f6
0972    ora $f7
0974    bne -17    ; $0965
0965    lda $1d
...

SPC700 循环
它首先从 RAM 加载一个值,将其与 0x30 进行或运算,然后将新值写入 APU 控制寄存器,以清除所有 4 个 APU 通信端口锁存器。然后它验证所有 4 个通信端口都读取为 0,如果不是,则循环。

65816 正在重复写入 0x10 到 APU 端口 1,而 SPC700 被卡在一个循环中,清除所有 4 个 APU 端口并验证它们已被清除,但验证总是失败,因为 APU 端口 1($F5)在 SPC700 读取时始终保持 0x10。这看起来像是一个活锁!

CPU 间时序

从交错执行跟踪日志中可以看出,游戏非常接近摆脱这个活锁。65816 的 sta $2141 写入将端口值从 0x00 更改为 0x10 有时会在 SPC700 的 ora $f5 读取之前立即执行,这意味着时序与游戏似乎依赖的时序相差一个 SPC700 CPU 周期。计算周期后发现,SPC700 甚至无法从该端口读取 0,时间差异约等于 7 个 65816 周期或 2 个 SPC700 周期。但存在一个遗漏,SNES 会在每个扫描线中停滞 65816 40 个主时钟周期以刷新控制台的工作 RAM,包含内存刷新的循环迭代要长得多。这使得 65816 循环仍然稍微太快,无法让 SPC700 从该端口读取 0,但时间差异现在对于两个 CPU 来说都小于 1 个时钟周期。

修复

有几种方法可以修复这个问题而不会破坏其他游戏。一种是使 SPC700 内存读取和写入在 CPU 时钟周期内的不同时间发生,读取稍早于写入。使读取比写入提前四分之一周期就足以修复这个游戏。也可以在 SPC700 清除通信端口锁存器时丢弃 65816 对已清除端口的所有写入,直到下一个 SPC700 周期,这会缩短锁存清除和 $F5 读取之间的时间。

已有研究

ares及其前身 bsnes 和 higan 一直是最准确的 SNES 模拟器,在弄清楚这个问题后,发现它们通过使 SPC700 对通信端口的读取比其他内存读取和写入提前半周期来使这个游戏工作,并且这种时序行为是专门为这个游戏引入并在实际硬件上测试的。

阅读 146
0 条评论