模拟器调试:Area 5150 的 Lake Effect
摘要
本文详细介绍了在MartyPC模拟器上调试Area5150演示中“Lake”效应的过程,解释了需要特定标题hack的原因,以及通过总线嗅探和动态时钟实现周期精确CGA模拟的后续修复方法。
<p><a href="https://lobste.rs/s/9qgahc/emulator_debugging_area_5150_s_lake">评论</a></p>
查看缓存全文
缓存时间: 2026/06/14 07:37
# 模拟器调试:Area 5150 的“湖面”特效
来源:https://martypc.blogspot.com/2025/05/emulator-debugging-area-5150s-lake.html
我之前关于 IBM 5150 总线嗅探的几篇文章([第一篇](https://martypc.blogspot.com/2023/10/bus-sniffing-ibm-5150.html),[第二篇](https://martypc.blogspot.com/2023/10/bus-sniffing-ibm-5150-writing-8088.html))都在为此文做铺垫。今天我们将利用总线嗅探器及其解码器,最终调试 Area5150 的“湖面”特效。
没错,但我有个肮脏的小秘密需要向全世界坦白。尽管 MartyPC 因超高的精确度备受赞誉,但它需要一个特殊的、针对 Area5150 的“补丁”才能运行最后两个循环计数的特效:“Wibble”和“Lake”。“Wibble”是那个有查理·卓别林、绿色小人和大象的场景。“Lake”则是带有水面特效和 PCM 音频播放的片尾字幕场景。
换句话说,我作弊了。
其实我也没怎么刻意隐瞒,如果你在 MartyPC 运行时观察控制台,就能看到:
好吧,也许我对自己有点苛刻。针对特定游戏的补丁在模拟器世界并不新鲜。历史上,许多著名模拟器都依赖游戏补丁来绕过错误或不精确之处,以让游戏能够运行。随着模拟器精确度的提升,以及研究揭示了更多系统细节,这些游戏补丁逐渐变得不那么必要了。
我得为自己辩护一下:MartyPC 一直能精确到指令周期地运行特效本身——但让特效真正*开始*运转才是真正的难点。本文将展示其原因,以及我如何修复它。
Area5150(以及之前的 8088MPH)之所以令人惊叹,并不完全是因为它展示了演示场景中前所未有的特效,而是因为这些特效是在 IBM CGA 上实现的——这在以前被认为是完全不可能的。
IBM CGA 适配器是一种功能极其有限的设备。它不适合游戏,原因之一就是缺少[垂直消隐中断](https://en.wikipedia.org/wiki/Vertical_blank_interrupt)。其他计算机系统和大多数游戏机都有某种中断,每帧或每帧多次触发,以告知运行中的程序或游戏 CRT 的光栅在屏幕上的某个已知位置。这对于避免在屏幕扫描输出时绘制或擦除显存内容非常有用——这样做会导致闪烁,或者更糟,在 CGA 上产生视觉伪影(“雪花”)。
对于 Area5150 来说,后者至关重要,因为其绝大多数特效都在 80 列文本模式下执行,而雪花是一个严峻的隐患。因此,演示中的每个特效都必须小心,避免在水平或垂直消隐期之外访问显存。其他特效则必须精确地在每条扫描线更新显存起始地址。那么,它们是如何做到的呢?
由于没有可用的中断,确定屏幕位置最直接的方法就是轮询 CGA 的状态寄存器(地址 03DAh)。状态寄存器中有两个相关位:位 3 指示是否处于垂直消隐期,位 0 是 CRTC 的“显示使能”(Display Enable)信号取反。当该位置 1 时,电子束位于可见显示区域之外。这不一定就是水平消隐,但包括了所有不在“显示矩形”内的区域,如水平消隐、垂直消隐或过扫描区域。
这里没有足够篇幅详细介绍 CRTC 屏幕几何原理,但如果你感兴趣,我计划将来深入探究 CGA。等我写好了,会坐时光机回来在这里放个链接!
MartyPC 实现高精度 CGA 的技巧之一是我称之为动态时钟的技术:基本上,当一条指令执行过程中对 CGA 的某个寄存器进行写入时,CGA 会以全分辨率(14MHz)进行时钟滴答,直到更新完成,然后再继续,直到我们赶上 CGA 的字符时钟,此时 CGA 可以恢复一次绘制 8 个像素。这为我们提供了 Area5150 苛刻特效所需的指令周期精度,而无需模拟的 CGA 始终按每个系统时钟滴答进行时钟滴答。
我们可以利用这个“追赶”阶段来可视化轮询过程。以 Area5150 中的一个特效为例,它通过扫描线轮询来为每条扫描线设置显存起始地址。我们可以在这些 IO 操作进行时,将模拟器的颜色强制设为洋红色:
这是 MartyPC 的一个特殊调试视图,显示整个显示区域——绿色表示水平消隐,黄色表示垂直同步。很容易看到该特效对 CGA 状态寄存器的疯狂轮询。如此高速的轮询下很难再做其他事情,这限制了需要此技术的任何演示特效的复杂度。
“湖面”特效需要逐扫描线重新编程起始地址,但其主特效本身并不轮询 CGA 状态寄存器。考虑到它需要做的工作——显示字幕图形、实现水波特效以及播放声音——根本没有足够的时间去不断读取端口 3DA。
因此,该特效采用了循环计数方式,类似于 8088MPH 中的 Kefrens 条特效。追赶光束(racing the beam)的思路是:程序员不是通过轮询来确定屏幕上的当前位置,而是通过测量代码消耗的 CPU 周期数来推算。由于 IBM PC 上 CPU 和 CGA 共享系统时钟,一个 CPU 周期恰好等于 CGA 显示上的 3 个像素(或“hdots”)。因此,如果我们从一个已知的参考点开始,并且知道代码执行所需的时间,那么就应该精确知道在任何指令执行时光栅光束的位置。很简单,对吧?
这个技巧的第二部分是精确控制每条扫描线的显示内容——我们将垂直总长(Vertical Total)设为最小值 1,这实际上创建了一个只有两像素高的屏幕。我们可以控制这个“迷你屏幕”的起始地址来实现各种效果,而如果将垂直同步位置(Vertical Sync)设置为大于垂直总长的值,就能阻止 VSYNC 发生。
理解这种技术的一个好方法是考虑一下,如果我们每帧只重新编程两次垂直总长会发生什么:
当 CRTC 达到垂直总长时,它认为屏幕绘制完毕,并再次锁存起始地址。这使我们能够同时在屏幕上显示显存的两个不同区域。“湖面”特效所做的就是将这种技术发挥到极致。
通常情况下,这个技巧只能让我们每两条扫描线有一个唯一的起始地址——因为从 CRTC 的角度来看,一帧的最小高度是两个逻辑行。“湖面”特效通过将这两条逻辑行并排放置,成功实现了每条扫描线设置一个新的起始地址。
它是怎么做到的?通过疯狂地重新编程 CRTC 芯片,每条扫描线 8 次。
通过这种方式,代码能够精确控制每条扫描线和每个显示的像素,只需让代码执行与显示扫描输出完美同步即可。相当惊人,对吧?
“湖面”特效只部分采用了循环计数。图形部分首先与 CGA 完美同步绘制,然后音频部分在显示区域结束后以非循环计数方式处理。这就产生了一个问题——如果我们停止精确计数周期,就会失去对屏幕位置的跟踪,而特效每帧都需要从完全相同的像素开始。那么我们该如何解决这个问题呢?
## 自制 VSYNC 中断
CGA 硬件没有提供 VSYNC 中断,但这并不意味着你不能自己造一个。Intel 8253 可编程间隔定时器的定时器通道 0 通常被 BIOS 用来维护系统时钟,但没有什么能阻止程序员将其用于自己的目的,许多游戏和应用程序都是这么做的。定时器通道 0 连接到 IRQ0,按惯例映射到中断 8h。如果你在中断向量表的第 8 个槽位放置一个例程的地址,那么每次定时器递减到 0 时,该例程就会被调用。这使你能够以大致精确的时间间隔执行代码。
我说“大致精确”,是因为中断实际上不能中断正在执行的指令,只有当一条指令执行完毕且中断没有被某种方式抑制时(比如 CPU 中断标志被禁用,或者你刚刚操作了一个段寄存器),中断才会触发。这意味着我们的中断可能会因触发时正在执行的 CPU 指令长度不同而产生可变的延迟。一种解决方法是在处理完毕后直接执行 HLT 指令,等待下一次中断触发。HLT 会停止 CPU,直到下一次中断发生,并且它以更精确的方式唤醒。
由于 IBM PC 使用单时钟,定时器的控制时钟也只是系统时钟的一个分频,本例中分频系数为 12。定时器时钟的推导为 (315/22)/12 或 1.1931 MHz。这意味着每个像素/hdot 对应 12 个定时器滴答。定时器通过接受一个重载值来工作,每经过一个定时器时钟滴答,它就从这个值向下递减到 0。
一个标准的 CGA 屏幕是 912 hdots × 262 扫描线,即 238944 hdots。这意味着如果我们给定时器设置重载值为 238944/12 = 19912,我们就会在每帧屏幕的完全相同位置收到一个定时器中断。
唯一的技巧是在正确的时间设置定时器,使其有用。需要一些初始轮询。一个典型的轮询序列可能如下:
- 读取 3DA 的值。
- 如果 vsync 位为 1,则继续轮询 3DA,直到其变为 0。
- 轮询 3DA,直到 vsync 位为 1。
- 轮询 3DA,直到 vsync 位不再为 1。
经过这个序列后,我们可以相当确信自己处于刚刚完成 vsync 后的短暂时间内,即我们刚刚开始新的一帧。然后我们可以快速编程定时器通道 0,这样我们就有了自己的 vsync 中断——至少,延迟时间为执行 IN 和 OUT 指令(用于轮询状态和编程定时器)所需的时间。
然而,如果你正在追赶光束,这可能不是你想要的,尤其是如果你想从可见帧的起始处开始。vsync 结束意味着我们处于顶部过扫描区域——不在屏幕的可见部分。因此需要进一步的轮询循环,轮询 3DA 直到位 0 从 1 翻转为 0,表明我们现在进入了显示区域。
但同样,我们只能在这个条件发生后才能检测到它,并且会因设置定时器通道 0 所花费的时间而产生延迟。
如果你需要一个精确在显示区域起始处触发的 vsync 中断呢?或者更糟——如果你需要中断在显示区域起始处*之前*触发呢?并且如果你需要它以*像素级精度*触发呢?
## “湖面”特效的 ISR 设置
事实证明,这个问题的答案*并不简单*。“湖面”特效使用了不少于*八个*不同的定时器中断服务例程,串成一个链条,以精确定位运行主特效的最终中断服务例程。
最终的中断必须非常精确地触发:

如果我们离开 VSYNC 时从 0 开始计数扫描线,这是扫描线 20 的末尾。如果我们离开 HSYNC 时从 0 开始计数列,这大约是列 723。需要注意的是,这只是中断触发的时间点——即 CPU 的 INTR 线变为高电平。实际的特效 ISR 会延迟几个周期。让我们看看嗅探器跟踪以了解时序:

垂直标记指示 INTR 的上升沿。注意我们并没有立即从 HALT 中唤醒;沉睡的 CPU 需要几个周期才能反应过来,本例中是 7 个周期。即使如此,我们的 ISR 还必须等待两个 INTA 总线周期,并从 IVT 中获取 ISR 地址——这个过程反过来又会被 DRAM 刷新 DMA 打断(注意青色表示的 CPU 等待状态)。直到 90 个周期后,我们才真正开始执行 ISR 的 MOV 指令。如果你在计时,这相当于 30 hdots,足够我们完全穿过 HBLANK,最终落在下一条扫描线 21 上,恰好位于显示使能开始的上方一条扫描线。特效利用这第一条扫描线来设置 CRTC 寄存器,然后实际特效从下一条扫描线开始,并持续接下来的 200 条扫描线。
幸运的是,这个链条中的前四个中断更多是为了同步系统中的各种时钟——一种称为实现*锁步*的技术——而非为了在屏幕上定位特效中断。所以我们主要关注 ISR4 及之后的例程。
## 可视化嗅探器跟踪
我们已经用多种方式解码并可视化了嗅探器跟踪,从使用 sigrok PulseView 到 Excel。但有一件事我们还没做。由于我们处理的所有逻辑都围绕 CGA,因此将嗅探器跟踪中的事件可视化为虚拟屏幕上的点,会很有意义。
事实证明,这相当直接。由于我们已经捕获了 VSYNC 和 HSYNC 信号,我们可以编写另一个 Python 程序,生成一张宽度为 (912 hdots / 3) 像素、长度为 HSYNC 总数的图像。对于每个 CPU 时钟上升沿,我们可以推进我们的“像素时钟”,在图像的横向边界处进行回绕。我们可以检测某些事件,然后至少在这些事件位置处标记不同颜色的像素。
我不打算在这里展示所有代码;它很长,而本文已经够长了。不过所有代码都会在文章底部的链接中提供。它会生成类似下图的图像:

红点代表 INTR 的上升沿,而黄线代表中断的完整宽度——相应的 ISR 实际上在每条黄线的末尾开始。这是一个非常有用的可视化工具——如果 ISR 正在轮询状态寄存器(如 ISR0 所做的那样),你能看出轮询中断触发位置与实际 ISR 开始位置之间的差异吗?这决定了是能检测到 HBLANK 还是不能。
## 从模拟器到分析器
查看总线嗅探器的解码输出固然很酷,但只有在我们能将其与模拟器实际运行情况进行比较时才真正有用,而模拟器会以自定义文本格式生成周期日志。
但是,如果我们添加一种新的周期日志格式,能够输出逻辑分析仪所需的信号——20 条地址线、3 条总线状态线、2 条队列状态线、INTR、READY、VSYNC、HSYNC 和显示使能(DEN)呢?
```rust
if let Some(video) = self.bus().video() {
let (vs_b, hs_b, den_b, brd_b) = video.get_sync();
vs = if vs_b { 1
相似文章
微控制器电路调试冒险记
作者详细描述了Floppy Emu微控制器板神秘故障的故障排除过程,调查了潜在原因,如不良芯片或组装缺陷。
C64演示效果详解:Rodents In The Attic
一篇关于Commodore 64演示的技术解析,该演示利用边框开启技巧、VIC芯片幽灵字节故障以及精确的光栅时间编码,将彩色精灵放置在边框区域。
x86仿真器团队曾遇到一段代码糟糕到他们在仿真过程中直接修复
一个关于Windows x86仿真器团队的故事:他们遇到一个程序,其初始化循环完全展开了64KB(65,536条指令),于是添加了特殊优化,将其替换为一个紧凑循环。
从零开始的反编译项目:Claude Code与2001年GBA游戏的51%
文章详细介绍了使用Claude Code启动一个2001年GBA游戏的反编译项目,实现了51%的反编译并发现了一个隐藏的彩蛋。
MartyPC – 一款周期精确的 IBM PC/XT 模拟器
MartyPC 是一款针对早期 IBM PC 的周期精确模拟器,专为复古 PC 开发而设计,配备丰富的调试工具,并通过与真实硬件的验证,实现高精度。