Skip to main content

搜索此博客

Adventures in PC Emulation

模拟器调试:Area 5150 的 Lake Effect

2025年5月17日

我之前几篇关于总线嗅探 IBM 5150 的文章 文章都是为了这篇文章做铺垫。我们将利用我们的总线嗅探器和嗅探器解码器来调试 Area5150 的 "Lake" effect。

你可能会问:“等等,MartyPC 不是已经运行 Area5150 了吗?”

是的,它的确可以运行,但我有一个小秘密需要坦白。尽管 MartyPC 因其高度准确性而备受赞誉,但它需要一个特殊的、Area5150 专用的 hack 才能运行最后两个周期精确的效果 "Wibble" 和 "Lake"。"Wibble" 是包含 Charlie Chaplin、绿色家伙和大象的场景。"Lake" 是最后包含水波效果和 PCM 音频播放器的片尾字幕场景。

换句话说,我作弊了。

我并没有试图隐藏它,如果你在 MartyPC 运行时观察控制台,你会看到:

好吧,也许我对自己有点苛刻了。特定游戏的 hack 在模拟器领域并不新鲜。历史上,许多著名的模拟器都依赖于游戏补丁来解决错误或不准确之处,以使游戏能够运行。随着模拟器在准确性方面的提高以及对系统工作原理的更多细节的揭示,这些 hack 逐渐变得不那么必要。

无论如何,我和其他人一样,搜索 DOSBOX-X 的 repo 中 "hack" 这个词

为自己辩护一下,一旦效果真正_开始_,MartyPC 始终以周期精确的方式运行效果本身——真正困难的是首先启动效果。在本文中,我将向你展示原因,以及我如何修复它。

在 CGA 上创造奇迹

Area5150 (以及之前的 8088MPH) 令人惊叹的部分原因,不一定是它完成了 demoscene 中的新效果,而是它在 IBM CGA 上完成了这些效果,这在以前被认为是不可行的。

IBM CGA 适配器是一个非常有限的设备。它不适合游戏,原因之一是缺乏 vsync interrupt。其他计算机系统和大多数视频游戏机都具有某种中断,该中断每帧或每帧多次触发,以向正在运行的程序或游戏发出 CRT 光栅位于屏幕上的某个已知位置的信号。例如,如果你不想在屏幕扫描输出时绘制或擦除视频内存中的内容,这将非常有用——这样做会导致闪烁,或者在 CGA 的最坏情况下,会导致屏幕上出现视觉伪像(雪花)。

后一种情况对于 Area5150 至关重要,因为其大部分效果都是在 80 列文本模式下执行的,其中雪花是一个迫在眉睫的问题。因此,演示中的每个效果都必须小心,不要在水平和垂直消隐期之外访问视频内存。其他效果必须精确地更新每条扫描线的视频内存起始地址。那么,他们是如何做到这一点的呢?

由于我们没有可用的中断,因此确定我们在屏幕上的位置的最直接方法是通过轮询 CGA 的状态寄存器 03DAh。状态寄存器中有两位值得关注。第 3 位告诉我们我们是否处于垂直消隐期。第 0 位是 CRTC 的 "Display Enable" 行,反转。当设置此位时,光束位于可见显示区域之外的某个位置。这不一定是水平消隐,但它包括 "显示矩形" 之外的所有区域,无论是 hblank、vblank 还是过扫描区域。

一张图可能会有所启发:

CRTC 状态寄存器位表示

这里没有足够的空间来详细介绍 CRTC 屏幕几何的工作原理,但如果你有兴趣,我计划将来深入研究 CGA。当我写完时,我会及时更新并在这里添加链接!

MartyPC 实现 CGA 高精度的一个技巧是我所谓的动态时钟——基本上,当在指令期间写入 CGA 的寄存器之一时,CGA 会以全分辨率(14Mhz)进行计时,直到更新完成,然后直到我们赶上 CGA 的字符时钟,CGA 才能一次恢复绘制 8 个像素。这为 Area5150 的高要求效果提供了周期精确性,而无需模拟的 CGA 始终在每个系统时钟周期进行计时。

我们可以利用这个 "赶上" 阶段来可视化轮询。让我们从 Area5150 中选取一个效果,该效果执行扫描线轮询,以便为每条扫描线设置视频内存起始地址。当其中一个 IO 操作正在进行时,我们可以将模拟器颜色覆盖为洋红色:

CGA 状态寄存器轮询图示

这是 MartyPC 的一个特殊调试视图,显示了整个显示区域——绿色表示 hblank,黄色表示 vsync。很容易看到该效果疯狂地轮询 CGA 状态寄存器。在如此快速地轮询时,很难做其他事情,这限制了任何需要它的演示效果的复杂性。

与光束赛跑

Lake effect 需要对每条扫描线进行起始地址重新编程,但它本身并没有在主效果中轮询 CGA 状态寄存器。考虑到它需要完成的复杂性——显示信用图形、实现水波纹效果以及播放声音——根本没有足够的时间不断读取端口 3DA。

因此,该效果是周期计数的,类似于 8088MPH 中的 Kefrens bars effect。与光束赛跑背后的想法是,程序员不必尝试通过轮询来确定他们在屏幕上的任何给定时间的位置,而是可以通过测量代码花费的 CPU 周期数来计算出他们在屏幕上的位置。由于 IBM PC 上的 CPU 和 CGA 之间共享系统时钟,因此一个 CPU 周期恰好等于 CGA 显示器上的 3 个像素(或 'hdots')。因此,如果我们从已知的参考帧开始,并且我们知道我们的代码花费了多长时间,那么我们应该确切地知道光栅光束在任何给定指令处的位置。很简单,对吧?

Lake Effect 中的周期计数

这个技巧的第二部分是精确控制从扫描线到扫描线显示的内容——我们将 Vertical Total 设置为最小值 1,这有效地创建了一个两像素高的屏幕。我们可以控制这个 "迷你屏幕" 的起始地址以实现各种效果,并且如果我们将 Vertical Sync 位置编程为大于 Vertical Total 的值,我们可以防止 VSYNC 发生。

理解这个技术的一个好方法是考虑如果我们简单地每帧重新编程 Vertical Total 两次会发生什么:

MC6845 CRTC 上的帧堆叠

当 CRTC 达到 Vertical Total 时,它认为它已经完成了整个屏幕的绘制,并再次锁定起始地址。这允许我们在屏幕上同时显示两个独立的视频内存区域。Lake effect 所做的是简单地将这种技术发挥到极致。

通常,这个技巧只允许我们每两条扫描线都有一个唯一的起始地址——因为从 CRTC 的角度来看,帧的最小高度是两个逻辑行。Lake effect 通过将这两个逻辑行并排放置,设法为每条扫描线设置一个新的起始地址。

在一条扫描线上放置两行

它是如何做到的?通过疯狂地重新编程 CRTC 芯片,每条扫描线 8 次。

Lake effect 的扫描线中期 CRTC 更新

通过这种方式,代码可以精确控制每条扫描线和显示的每个像素,只需让代码执行与显示器的扫描输出完美同步即可。太不可思议了,对吧?

Lake 只是部分周期计数的。图形首先与 CGA 完美同步绘制,然后在显示区域结束后以非周期计数方式处理音频。这提出了一个问题——如果我们停止精确计数周期,我们就会失去对我们在屏幕上位置的跟踪,并且效果需要每帧都从完全相同的像素开始。那么我们该如何解决这个问题呢?

滚动你自己的 VSYNC 中断

仅仅因为 CGA 硬件不提供 VSYNC 中断,并不意味着你不能创建一个。Intel 8253 Programmable Interval Timer 的定时器通道 0 通常被 BIOS 用于维护系统时钟,但没有任何东西可以阻止程序员窃取它用于自己的目的,许多游戏和应用程序都这样做。定时器通道 0 连接到 IRQ0,按照惯例,IRQ0 映射到中断 8h。如果你将例程的地址放置在中断向量表中的第 8 个槽中,你可以让它在你的定时器倒计时到 0 时被调用。这允许你以某种程度上精确的间隔执行代码。

我说某种程度上精确,因为中断实际上无法中断正在进行的指令,中断仅在指令完成并且中断没有以某种方式被抑制时才会触发(例如,如果禁用了中断 CPU 标志,或者你只是摆弄了段寄存器)。这意味着我们的中断可能会根据中断触发时正在执行的 CPU 指令的长度而延迟可变量。解决此问题的一种方法是在我们完成处理并希望等待下一个中断触发时简单地执行 HLT 指令。HLT 将停止 CPU,直到发生下一个中断,并且它以更精确的方式唤醒。

由于 IBM PC 具有单个时钟,因此定时器的控制时钟也只是系统时钟的除数,在本例中为 12。定时器时钟的推导是 (315/22)/12 或 1.1931Mhz。这意味着每 12 个定时器滴答一个像素/hdot。定时器通过接受一个重载值来工作,它从该值开始倒计时到 0,每次定时器时钟滴答一次。

正常的 CGA 屏幕是 912 hdots * 262 扫描线或 238944 hdots。这意味着如果我们给我们的定时器一个 238944/12 或 19912 的重载值,我们将在每帧屏幕上的精确相同位置获得定时器中断。

唯一的诀窍是在正确的时间设置定时器,以便使其有用。需要一些初始轮询。典型的轮询序列可能如下所示:

在此序列之后,我们可以相当确定我们是在 vsync 完成后的一小段时间内,即我们刚刚开始一个新的帧。然后,我们可以快速编程定时器通道 0,我们将拥有我们的 vsync 中断——至少,延迟了执行轮询状态和编程定时器所需的 IN 和 OUT 指令的时间。

但是,如果你正在与光束赛跑,这可能不是你想要的,特别是如果你想从可见帧的开头开始。vsync 的结束意味着我们位于顶部过扫描区域——不在屏幕的可见部分。因此,需要进一步的轮询循环,轮询 3DA 直到位 0 从 1 翻转为 0,表明我们现在位于显示区域中。

但是同样,我们只能在这种情况发生后才能检测到这种情况,并且设置定时器通道 0 所花费的时间会产生延迟。

如果你需要在显示区域的精确起点处获得 vsync 中断怎么办?或者更糟糕的是——如果你需要在显示区域_之前_触发中断怎么办?如果你需要以_像素精度_触发它怎么办?

Lake Effect ISR 设置

事实证明,这个问题的答案_不容易_。Lake 使用了不少于_八个_单独的定时器中断服务例程链,以便精确定位运行主要效果的最终中断服务例程。

最终中断必须非常精确地触发:

如果我们从离开 VSYNC 时开始将扫描线计数为 0,则这是扫描线 20 的结尾。如果我们从离开 HSYNC 时开始将列计数为 0,则大约是第 723 列。重要的是要注意,这只是中断触发的时间——当到 CPU 的 INTR 线变高时。实际的效果 ISR 被延迟了几个周期。让我们看一下嗅探器跟踪以查看计时:

垂直标记指示 INTR 的上升沿。请注意,我们不会立即从 HALT 中唤醒;我们沉睡的 CPU 需要几个周期才能完成其工作,在本例中为 7 个周期。即使这样,我们的 ISR 也不得不等待两个 INTA 总线周期,以及从 IVT 获取 ISR 地址——这个过程反过来又被 DRAM 刷新 DMA 中断(请注意青色的 CPU 等待状态)。直到 90 个周期后,我们才真正开始带有 MOV 指令的 ISR。如果你一直在跟踪,那是 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 和 Display Enable (DEN) 怎么办?

    if let Some(video) = self.bus().video() {
      let (vs_b, hs_b, den_b, brd_b) = video.get_sync();
      vs = if vs_b { 1 } else { 0 };
      hs = if hs_b { 1 } else { 0 };
      den = if den_b { 1 } else { 0 };
      brd = if brd_b { 1 } else { 0 };
    }
    // Segment status bits are valid after ALE.
    if !self.i8288.ale {
      let seg_n = match self.bus_segment {
        Segment::ES => 0,
        Segment::SS => 1,
        Segment::CS | Segment::None => 2,
        Segment::DS => 3
      };
      self.address_bus = (self.address_bus & 0b1100_1111_1111_1111_1111) | (seg_n << 16);
    }
    // "Time(s),addr,clk,ready,qs,s,clk0,intr,dr0,vs,hs"
    // sigrok import string:
    // t,x20,l,l,x2,x3,l,l,l,l,l,l
    self.trace_emit(&format!(
      "{},{:05X},1,{},{},{},{},{},{},{},{},{},{}",
      self.t_stamp,
      self.address_bus,
      if self.ready { 1 } else { 0 },
      q,
      s,
      0,
      if self.intr { 1 } else { 0 },
      if matches!(self.dma_state, DmaState::Dreq) { 1 } else { 0 },
      vs,
      hs,
      den,
      brd
    ));

这需要一些调整,因为我们的模拟器之前并没有真正关心物理引脚状态的建模。我们必须在适当的时间将状态位和数据总线的值写回地址总线。在这里,PulseView 的一个很好的便利功能派上用场;它可以将十六进制表示法解释为打包的信号线;因此我们不需要将我们的地址总线分解为 20 个不同的字段。我们只需要为我们的地址列提供格式类型 'x20'。

我们还必须对 READY 信号进行建模——以某种方式向后进行:在真正的硬件上,READY 线决定等待状态,但为了这种日志格式的目的,我使用计算出的等待状态来设置 READY 线。

剩下要做的就是每个周期轮询视频设备,并每个周期吐出一行 CSV,然后我们可以将其导入到 PulseView 中,并进行一些主要是直接的比较。在这里,我们有 MartyPC 在顶部,硬件捕获在底部:

[](https://martypc.blogspot.com/2025/05/<https:/blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgDLqMb86DfrkJOcZGd5e7p38a8T87uX92O9