跳到正文
Hacker News· otistropo·· 7 天前AI 评分20

gase:用 Rust 编写的教育型多平台 Genesis / Mega Drive 模拟器

Gase – Educational multi-platform Genesis / Mega Drive emulator written in Rust

AI 导读

用 Rust 编写的 Sega Mega Drive / Genesis 模拟器 gase 发布,可在浏览器、桌面、Android 和 iOS 四个平台运行,核心零外部依赖。项目以教学为先,每个芯片独立成一个 crate,Z80 通过 1,604,000 条测试用例、68000 通过 1,310,060 条测试用例逐位比对验证,性能分析带来 1.5–1.9× 加速。

正文 · AI 翻译

gase 的诞生用 Rust 编写的 Sega Mega Drive / Genesis 模拟器2026 年 10 月

一台你能读懂的 Mega Drive。

一个模拟器如何被构建成可被理解的:每个芯片一个 crate,每个 CPU 都对照数百万条记录的测试用例进行验证,并且遵循一条规则——每条快速路径旁边都保留可读版本。

在浏览器中试用 或 下载它

Right 2 Repair, mid-fight: two cyan sword fighters on a field of rubble in front of a red city skyline, scores at the top.

图 0Right 2 Repair(Global Game Jam 2020),第 1,200 帧,由 gase 绘制:320 × 224 像素,屏幕上显示 15 种颜色,而主机总共可生成 512 种。以整数倍缩放显示,逐像素呈现。
  • Crate:每个芯片一个,然后是 app、ZIP 和外壳 12
  • 核心的外部依赖 0
  • 平台:桌面、浏览器、Android、iOS 4
  • Z80 测试用例通过数 1,604,000
  • 68000 测试用例运行数 1,310,060
  • 达成此成果的拉取请求数 33
  • 性能分析带来的加速 1.5–1.9×
  • unsafe 个块,全部位于手机入口点 2

目录

  1. 01可行吗?在写下第一行代码之前的研究。
  2. 02这台机器两个 CPU、一个视频芯片、两个声音芯片、一颗晶振。
  3. 03每个芯片一个 crate无需知道 VDP 的存在即可阅读 Z80。
  4. 04证明 CPU 的正确性数百万条记录的指令,逐位比较。
  5. 05第一缕光将芯片连接起来并运行真实软件。
  6. 06绘制一帧平面、精灵、优先级:拆解一幅画面。
  7. 07制造声音没有乘法器的 FM 合成。
  8. 08奇地址一次最终证明是正确的崩溃。
  9. 09时序就是一切访问槽、四字 FIFO,以及更慢的启动。
  10. 10存档、状态与倒带从电池 RAM 到位翻转 EEPROM。
  11. 11面向学习者的调试器观察主机的思考过程。
  12. 12快速,且依然可读可读的参考实现与经过测试的快速路径。
  13. 13工作是如何完成的分支、拉取请求、CI、帧哈希。
  14. 14一个界面,适配所有屏幕菜单、控制和触摸,只写一次。
  15. 15打开 ZIP 文件用几百行可读代码实现 DEFLATE。
  16. 16浏览器中的 Rust没有任何中间层的 WebAssembly。
  17. 17手机上的 Rust在 Android 和 iOS 上谁调用 main。
  18. 18别人代码中的崩溃安全 Rust、悬垂指针、冒烟测试。
  19. 19发布到每个平台一个标签、七个文件,以及一个什么都不做的 ROM。
  20. 20下一步值得构建的想法,以及已知的缺口。

第 01 章docs/VIABILITY.md

可行吗?

在写任何代码之前,一项简短的研究先问了一个问题:用 Rust 写一个 Mega Drive 模拟器是否是个明智的项目,它应该优先考虑什么。

答案是肯定的,理由塑造了后续的一切。Mega Drive 是那个时代文档最完善的机器之一:Genesis Plus GX、BlastEm、Exodus 和 clownmdemu 等成熟的开放模拟器已经存在,它们几乎测量并记录了硬件的每一种行为。无需猜测任何东西;只需要理解并清晰地写出来。

目标被排了优先级,而这个排序很重要。教学优先:任何人都应该能打开源码,了解这台主机是如何工作的。然后是性能、兼容性、易用性,最后是少依赖的最佳实践。当两个目标方向冲突时,优先级更高的那个胜出。

Rust 适合这些目标。硬件算术是显式的(wrapping_add、u16::from_be_bytes),因此代码展示了芯片对每一位做了什么。静态分派的泛型让 CPU 核心能以零成本与内存映射通信,同时保持为独立 crate。而 #![forbid(unsafe_code)] 被证明是现实的:这里没有任何东西需要它。

性能不是风险

7.67 MHz 的 68000 每秒执行一到两百万条指令。用 Rust 写的普通解释器只需现代单核的几个百分点就能处理。研究得出结论:速度可以花在精确性和诸如倒带之类的舒适功能上。真正的风险在别处:68000 的精确性、VDP 的时序技巧、YM2612 的声音、奇怪的卡带硬件,以及在不产生爆音的情况下保持音视频同步。每一项后来都有自己的章节。

部分Crate估计今天对比
68000gase-m68k3,000–4,0004,618
Z80gase-z80~2,0002,517
VDPgase-vdp1,500–2,0003,008
YM2612 + PSGgase-sound~1,1503,138
总线、I/O、卡带gase-core~8003,687
前端gase~6002,913
最大的超支带来了研究未计入的功能:调度器、存档状态、EEPROM 存档和调试器位于 gase-core 和 gase 中,声音 crate 还包含重采样器和滤波器。条形按 6,150 行缩放。

第 02 章docs/ARCHITECTURE.md §1–3

机器

两个处理器除了几扇门之外不共享任何东西,一个拥有自己内存的视频芯片,两个声音芯片,以及一个为它们所有人设定节拍的石英晶体。

68000 运行游戏。它在一个 24 位地址空间中看到卡带、64 KiB 工作 RAM、控制器端口和视频芯片的两个端口。Z80 是一台独立的 8 位计算机,拥有自己的 8 KiB RAM,驱动声音芯片;68000 可以停止它,将程序加载到它的 RAM 中并重新启动它,而 Z80 可以通过一个 32 KiB 窗口查看 68000 的内存。

VDP 从不被像内存一样寻址。它的 64 KiB VRAM、调色板(CRAM)和垂直滚动表(VSRAM)只能通过一个控制端口和一个数据端口访问,这就是为什么游戏代码中有那么多内容是关于在正确时机向这两个端口馈送数据的。

图 1gase 建模的 Mega Drive。实线是总线,金色虚线是主时钟。地址是 68000(上)和 Z80(下)找到各部分的位置。横向滚动以查看全部内容。
  • 主时钟 gase-core · system.rs

    NTSC 主机上为 53.693175 MHz(PAL 上为 53.203424 MHz)。每个芯片都以它的几分之一运行,因此 gase 以主时钟计时。

  • Motorola 68000 gase-m68k

    主 CPU 为主时钟 ÷ 7 ≈ 7.67 MHz。32 位寄存器、16 位数据总线、24 条地址线。运行游戏。

  • 卡带 gase-core · cartridge.rs

    ROM 位于 000000,外加电池供电的 SRAM 或串行 EEPROM 用于存档,以及用于超过 4 MiB ROM 的 SSF2 映射器。

  • 工作 RAM gase-core · bus.rs

    在 E00000–FFFFFF 处有 64 KiB,镜像映射。栈从其顶部向下增长。

  • I/O gase-core · io.rs

    版本寄存器(区域、PAL/NTSC)和控制器端口,包括 6 键手柄的定时协议。

  • 总线仲裁器 gase-core · bus.rs

    让 68000 接管 Z80 的总线(A11100)或将其保持在复位状态(A11200),并为 Z80 提供进入 68000 空间的分页窗口。

  • VDP,Sega 315-5313 gase-vdp

    两个滚动图块平面、一个窗口和 80 个精灵,屏幕上从 512 色中显示 64 色。每 3,420 个主时钟一条扫描线。

  • VRAM、CRAM、VSRAM gase-vdp

    64 KiB 的图块和表、64 个九位颜色、40 个垂直滚动值。只有 VDP 直接访问它们。

  • Zilog Z80 gase-z80

    声音 CPU,主频 ÷ 15 ≈ 3.58 MHz,带有 8 KiB 私有 RAM。将音乐数据和鼓采样流式传输到声音芯片。

  • 声音 RAM gase-core · bus.rs

    在 Z80 地址 0000 处有 8 KiB,镜像于 2000。声音驱动所在之处。

  • Yamaha YM2612 gase-sound

    六个 FM 通道,每个通道四个运算符,每 144 个时钟一个立体声采样(≈ 53,267 Hz)。通道 6 可以播放 8 位采样。

  • PSG,SN76489 gase-sound

    内置于 VDP:三个方波和一个噪声发生器,频率为主频 ÷ 15。

第 03 章Cargo 工作区

每个芯片一个 crate

源代码的组织方式就像电路板。你可以在不知道 VDP 存在的情况下阅读 Z80。

每个芯片都位于自己的 crate 中,模块文档写成了关于该芯片的简短课程:68000 的寄存器和寻址模式、Z80 的未记录标志、VDP 的图块格式、YM2612 实际计算 FM 合成的方式。这些 crate 是在不同的分支上并行构建的,之后才将它们连接起来。

让它们保持独立的是一个小 trait。每个 CPU 说明它需要从外部世界获得什么,仅此而已:

// gase-m68k: everything the 68000 needs to know about the console.
pub trait Bus {
    fn read_byte(&mut self, addr: u32) -> u8;
    fn read_word(&mut self, addr: u32) -> u16;
    fn write_byte(&mut self, addr: u32, value: u8);
    fn write_word(&mut self, addr: u32, value: u16);
    // interrupt acknowledge, RESET, TAS ... (with defaults)
}

impl M68k {
    /// Runs one instruction; returns the 68000 clock cycles it took.
    pub fn step<B: Bus>(&mut self, bus: &mut B) -> u32 { … }
}

因为 step 是泛型的,编译器会生成一个针对主机真实内存映射特化的副本,内存访问是直接的,并且通常是内联调用。测试则给同一个 CPU 一个扁平的 RAM 数组。

一个设计选择直接来自 Rust 的借用规则。当 68000 运行时,它持有硬件的可变借用,因此它不能同时位于硬件内部。因此 CPU 与 Hardware 并排放在 Genesis 结构体中,Z80 通过一个应用 Z80 自身内存映射的薄包装器访问同一硬件。这一约束最终记录下了这台机器:两个处理器,一组共享设备。

Crate它是什么其文档教什么
gase-savestate一种微小的二进制格式以固定顺序序列化状态,无需模式
gase-m68kMotorola 68000寄存器、12 种寻址模式、预取队列、异常、周期计数
gase-z80Zilog Z80前缀页、未记录的 X/Y 标志、WZ (MEMPTR)、Q 锁存器、中断模式
gase-vdpSega 315-5313 VDP图块、名称表、精灵、优先级、访问槽、FIFO、三种 DMA
gase-soundYM2612、SN76489、重采样器FM 合成、对数正弦表、包络、梯形效应、加窗 sinc 重采样
gase-core主机内存映射、调度器、卡带、I²C EEPROM、存档状态、倒带
gase程序音频驱动的帧定时、绘制到像素缓冲区中的调试器、手工实现 PNG 和 WAV

第 04 章crates/m68k/tests · crates/z80/tests

证明 CPU 的正确性

CPU 模拟器要么完全正确,要么悄悄出错。唯一知道的方法就是逐条指令地与真实硬件的记录进行比较。

测试向量是一条被冻结的指令。它记录了指令执行前每个寄存器和相关内存字节的状态,执行后同样的状态,以及其间总线上的活动。从“执行前”状态运行该指令;如果有一个比特或一个周期与“执行后”不同,测试就失败。

这是一个真实的例子,Z80 操作码 80 的 1,000 个用例中的第一个,ADD A,B,已缩短:

// target/test-vectors/z80/80.json, case "80 0000" (SingleStepTests)
initial: pc=51399  a=81  b=92  f=167  r=112  q=0    ram[51399]=128 (the opcode)
final:   pc=51400  a=173 b=92  f=172  r=113  q=172
cycles:  4        // one opcode fetch: read at 51399, then refresh

81 + 92 = 173。标志位变为 172:符号位和溢出位被置位(81 + 92 无法放入有符号字节),再加上结果第 5 位和第 3 位的未公开副本。刷新寄存器 R 每次取指时递增一次,隐藏的 Q 锁存器记住这条指令写入的标志位:SCF 和 CCF 稍后会读取它。gase 对这一切都进行了建模。

Z80 核心通过了全部 1,604,000 个 SingleStepTests 用例,比较了包括隐藏寄存器在内的每个寄存器、RAM、端口 I/O 和周期计数。它还通过了 ZEXDOC 和 ZEXALL,这两个经典测试程序在 CPU 本身上运行,并对数百万次操作的结果进行校验和。

68000 更难:数十种寻址模式、精确的周期计数、一个双字预取队列,以及会在指令执行中途中止的异常。gase 精确地建模了预取队列,并按照芯片消耗时间的方式计算时间,每次总线访问四个周期加上内部工作,因此周期计数在构造上就与 Motorola 的表格相符,而不是通过查表。

图 2gase 的 CPU 所对照检查的每个测试套件。Tom Harte 套件中带阴影的部分被豁免,原因见下文;没有任何失败是没有解释的。向量按需下载,每周在 CI 中运行。

当两个参考相互矛盾时

两个 68000 套件在某些地方相互矛盾,因此核心必须选择一方。它遵循从 MAME 的微码级 68000 生成的套件,该套件也与 Motorola 的手册一致,并通过了其全部 310,000 个用例。Tom Harte 的用例只有在属于已知且有记录的类别并且失败时才被豁免:地址错误的堆栈程序计数器和周期计数(约 178,000 个用例)、移位量超过操作数大小时的标志位、ADDQ.L 到地址寄存器的时序、少数除法边界情况、LINK A7,以及两个损坏的向量。在切换到地址错误的微码模型之前,核心通过了 Tom Harte 套件的约 99.5%。

关于地址错误的那个决定——商业游戏从未触及的一个角落——结果比预期更早地变得重要。见第 08 章。

第 05 章crates/core/src/system.rs

首次点亮

四个经过验证的芯片还不是一台游戏机。必须有东西来决定谁在什么时候运行。

gase-core 中的调度器逐扫描线运行游戏机。在每一行开始时,VDP 绘制该行。然后 68000 执行指令,直到该行上的下一个事件(垂直中断、水平消隐、行结束),并且在每条 68000 指令之后,Z80 追赶至相同的主时钟时间。两个 CPU 的偏差从不超过一条指令。

声音芯片在那个循环中根本不会被步进。它们是惰性更新的:只有当 CPU 写入它们时,以便写入恰好落在正确的采样点上,以及在帧结束时。当没有人在监听时,追赶不花费任何代价。

第一批真正的程序是五个自由许可的测试 ROM,选择它们是为了覆盖机器的不同部分。每一个都成为一项回归测试:运行固定数量的帧,用脚本控制按键,然后对画面取哈希。只有在逐帧目视检查之后,才会记录哈希值。

The 240p Test Suite main menu: a menu of tests on a grey panel with a cartoon portrait of the author.

240p Test Suite 1.23,第 300 帧。一个广泛的视频测试:图案、滚动、精灵、声音。GPL。

SMPTE colour bars in the Mega Drive's palette.

genmd-imgrom 测试图案,第 120 帧。与作者的参考截图在 DAC 级别内一致。MIT。

A blue SEGA logo on black.

Airstriker,第 60 帧。一个用 BasiEgaXorz 编译的射击游戏。免费软件。它的故事在第 08 章。

The Right 2 Repair title: a riveted metal sign and PRESS START.

Right 2 Repair,第 600 帧。一个带有 Z80 声音驱动程序的 SGDK 游戏。Global Game Jam 2020。

Hundreds of blue particles forming the letters RSE.

The Spiral,作者 Resistance,第 1,200 帧。一个 3.9 MiB 的演示,带有大量 DMA 和光栅效果。GPL。

crates/core/tests/test_roms.rs

回归用例帧
240p Test Suite,主菜单300
240p Test Suite,PLUGE(按两次 A)400
genmd-imgrom 测试图案120
Airstriker,SEGA 标志60
Airstriker,主菜单(宽松)700
Right 2 Repair,标题600
The Spiral1,200

每个用例都以画面的 64 位 FNV-1a 哈希结束。

第 06 章crates/vdp/src/render.rs

绘制一帧

没有可供绘制的帧缓冲区。VDP 从小块图块、图块映射和精灵列表实时构建每一行。

屏幕上的所有内容都由图块组成:8 × 8 像素,每像素四位,每块 32 字节。像素的四位从四个调色板之一中选择 16 种颜色之一;颜色 0 是透明的。两个平面,A 和 B,是可滚动的图块映射,其中每个条目选择一个图块、一个调色板、水平和垂直翻转以及一个优先级位。一个窗口可以在固定矩形中替换平面 A,用于不得滚动的状态栏。最上层是最多 80 个精灵,保存在一个链表中。

下面是 Right 2 Repair 的一帧真实画面被拆解开来。每一层都由 gase 自己的渲染器在其他层隐藏的情况下绘制,所以这正是 VDP 所组合的内容。

图 3与图 0 相同的帧,一次一层。取消勾选分解视图可将堆叠折叠成主机所显示的画面;取消勾选某一层可将其移除。由 scripts/docs-shots 制作,它会在隐藏其他层的情况下,在主机副本上重新运行 VDP 渲染器。

像素从何而来

所有这些都存在于 VDP 的 64 KiB VRAM 中:图块、两个名称表、窗口的映射、精灵表和水平滚动表,地址由游戏通过寄存器选择。下面是该帧时 VRAM 中全部 2,048 个图块槽位,每个都使用平面或精灵所用的调色板,以及 CRAM 的 64 种颜色。

仔细观察,拼图就显现出来:天际线和瓦砾被切割成图块,抖动的天空,字体的字母,战士的动画帧。靠近底部的条纹行根本不是图像。它们是名称表和精灵表,被当作图块来读取,提醒我们 VRAM 是一块共享内存,寄存器决定每一部分的含义。

All 2,048 VRAM tiles in a 64 by 32 grid: pieces of the city and rubble, a sky gradient, a font, fighter sprites, and noise-like rows that are tables rather than graphics.
图 4第 1,200 帧时的 VRAM,每行 64 个图块。透明像素为黑色。

逐行

gase 像硬件那样一次渲染一条扫描线。对每一行,它先绘制平面 B,然后绘制平面 A 或窗口,写入每像素一字节的缓冲区(优先级、调色板、颜色),评估哪些精灵跨越该行(在 320 像素模式下每行最多 20 个精灵、320 个精灵像素;超出后硬件会丢弃它们),并按优先级合成这三个缓冲区。游戏在水平消隐期间写入的寄存器会在下一行生效,这正是水波线和逐行调色板变化等光栅效果所依赖的。

第 07 章crates/sound

制作声音

Mega Drive 的嗓音是频率调制:一个正弦波弯曲另一个正弦波的相位。执行这一操作的芯片没有乘法器。

一个算子是一个带有音量包络的正弦振荡器。单独使用时,它发出纯净、沉闷的音调。John Chowning 在 1973 年发现并授权给 Yamaha 的技巧,是把一个算子的输出加到另一个算子的相位上。调制器来回弯曲载波并产生边带:当两个频率成简单比例时,边带是谐波,结果是丰富、有音高的音色;奇数比例则产生钟声和金属声。调制器的音量,即调制指数,决定亮度。

试试看。这是公式,实时绘制:out = sin(ωc·t + I · sin(ωm·t))。

图 5两个算子,用教科书公式绘制。指数为 0 时给出纯载波;提高它,波形就会挤压和拉伸。YM2612 用 10 位相位、对数正弦表和每个声部四个算子来实现这一点。
算法 0
算法 1
算法 2
算法 3
算法 4
算法 5
算法 6
算法 7

不乘法的正弦

YM2612 没有乘法器。它把四分之一波形的 −log₂(sin) 存储在 256 项表中,加上包络的衰减(已经是分贝,因此已经是对数),然后用一个 256 项的 2 的幂表和一次移位把和转回线性值。gase 从公式构建这两个表,其测试将它们与从芯片裸片上读出的值进行核对。

然后是模拟部分。早期主机中的 YM2612 有一个有缺陷的 DAC,会从静音声道泄漏少量电平,使安静的音符更响、更粗糙。这种“梯形效应”是 Mega Drive 声音的一部分,因此 gase 默认模拟它。SN76489 风格的 PSG 增加了三个方波和一个由 16 位移位寄存器构成的噪声发生器。最后,一个加窗 sinc 重采样器把芯片原生的约 53,267 样本/秒转换为你的声卡想要的 48,000,将比率微调最多半个百分点,以保持音频和视频同步而不丢帧。

图 6Airstriker 的标题音乐在 gase 中的计算结果:5.5 秒(上),以及其中 12 毫秒(下),可以看到 FM 声部的形状。用 --wav 录制,左右声道混合。

第 08 章PR #10 · PR #12

奇怪的地址

一款自制射击游戏在 gase 中崩溃,在其他地方运行正常。模拟器是对的,证明这一点需要一次跟踪、一次寄存器转储和一个比特。

Airstriker 启动到它的 SEGA 标志,然后几秒后停在一个黑屏上,并显示来自它自己运行时的消息:68K Address Error。它是一款用 BasiEgaXorz(一种 Mega Drive 的 BASIC 编译器)编写的小型免费游戏,已知在流行模拟器中可以运行。所以第一个嫌疑对象是 gase。

地址错误是指 68000 在奇数地址上拒绝字或长字访问。该芯片没有地址线 A0。16 位访问总是覆盖偶数地址处的两个字节,通过高字节和低字节选通来选择,因此无法请求从奇数字节开始的字。CPU 会在指令执行中途中止并触发异常 3,压入一个 14 字节的帧来描述出错的访问。gase 精确地实现了这一点,因为源自 MAME 的测试向量会逐位检查这些帧。

The SEGA logo.

A第 60 帧:启动正常。

A black screen with the text 68K Address Error and START reset in yellow at the bottom.

B第 400 帧:游戏自身的错误画面。

The Airstriker title screen: a blue logo, 1P Hiscore 0, Press Start, Copyright 2008 Electrokinesis Studios.

C第 600 帧,带有 --no-address-errors:标题画面。

跟踪显示了故障前的最后几条指令,在第 358 帧。代码想要接管 Z80 的总线,这意味着向 $A11100 写入 $0100。它把地址加载到 D0,复制到 A6,加载值,然后把指针复制到 A0 以进行写入:

; gase --headless --frames 359 --trace 20000000 Airstriker.md   (registers before each instruction)
00F980  move.l #$A11100,d0           D0=00000000  A0=00FF026E
00F986  movea.l d0,a6                D0=00A11100  A0=00FF026E   ; A6 = $A11100, the Z80 bus request
00F988  move.l #$100,d0              D0=00A11100  A0=00FF026E
00F98E  movea.l d6,a0                D0=00000100  A0=00FF026E   ; copies D6, not A6
00F990  move.w d0,(a0)               D0=00000100  A0=2FFFFFFF   ; a word write to an odd address
00254A  movea.l #$2674,a0            D0=00000100  A0=2FFFFFFF   ; exception 3: the address-error handler

D6 中保存的是 $2FFFFFFF,因此写入落到了奇数地址,真正的 68000 会恰好在这里出错。ROM 中的指令字节说明了其余部分。代码大概想要的 MOVEA.L D6,A0 和 MOVEA.L A6,A0 只差一位:

$20460010 000 001 000 110movea.l d6,a0 · ROM 中的内容

$204E0010 000 001 001 110movea.l a6,a0 · 代码需要的内容

指令的源字段是一个 3 位模式和一个 3 位寄存器。模式 000 表示数据寄存器,001 表示地址寄存器;寄存器 6 在两者中相同。编译器发出了错误的模式。不建模地址错误的模拟器会悄悄地把地址向下取整并继续运行,因此游戏在那里“能工作”。

随后的决定是这个项目优先级的缩影。默认行为保持忠实于硬件,因为一个供人学习的模拟器不应隐藏真正的 68000 会做什么。但添加了一个小型、有文档、需主动启用的模式:使用 --no-address-errors 时,奇数数据访问忽略第 0 位,跳转到奇数地址仍然出错,并且该设置不会保存在存档状态中。检查位于本就罕见的奇数地址分支中,因此其他情况下没有开销。启用后,Airstriker 可以到达标题画面和菜单。

即便如此,gase 也不假装这个 bug 是无害的。这次误写落在 $FFFFFE,即 RAM 顶部和栈底,破坏了最外层的返回地址。之后会发生什么取决于模拟器如何错误处理这次写入,而项目选择不去逐个复现另一个模拟器的内存布局 bug。

The gase debugger stopped at the address-error handler: 68000 registers with A0, A1, A2 and D5 to D7 all equal to 2FFFFFFF, the disassembly at 00254A, the decoded VDP registers, the palettes, the VRAM tiles and the sprite list.

FIG. 7调试器中的同一时刻(--break 254A --dump-debugger):第 358 帧,第 106 行,停在处理程序中。多个寄存器保存着相同的 2FFFFFFF。

Chapter 09crates/vdp/src/slots.rs · fifo.rs · dma.rs

时序就是一切

第一版 VDP 会立即完成每次写入。真正的 VDP 会让 CPU 等待其自身工作中的空隙,而游戏正是围绕这一点编写的。

在绘制一行时,VDP 几乎连续地读取 VRAM:名称表条目、图块图案、滚动值、精灵数据。VRAM 只有一个端口,因此每一行被划分为固定序列的内存槽,其中大部分分配给渲染器。只有少数外部访问槽留给 68000 和 DMA 引擎。

FIG. 8一条 H40 扫描线,3,420 个主时钟,按 slots.rs 中的表格按比例绘制。红色:VDP 绘制期间的 18 个外部槽,每 16 像素一个,每第四个被 DRAM 刷新占用,另有水平消隐期间的三个。下方:垂直消隐期间或关闭显示时,一行有 205 个槽。

对数据端口的写入会等待一个四条目 FIFO,直到有空位。四个条目可以吸收一次短突发,比如水平消隐期间的几个调色板条目,而 CPU 不会察觉。第五次写入会阻塞 68000,直到一个条目被释放,在活动显示期间,这每个字可能需要几百个主时钟。状态位 8 和 9 报告 FIFO 满和空,细心的程序会轮询它们。

DMA 有三种。68000 到 VDP 的传输将字推过同一个 FIFO,并冻结 68000,直到最后一个字进入。填充和复制在后台运行,同时状态位 1 表示“忙”;复制每个字节需要一个读槽和一个写槽,因此其运行速度是填充的一半。这就是为什么游戏在垂直消隐期间或关闭显示时上传图形:同样的传输在那里大约快十一倍。

gase 用一个惰性时钟模拟了所有这些。VDP 记住它在一行中已经使用了多少槽,并在每次端口访问之前以及每行结束时更新到最新状态。当没有排队时,追赶只是移动一个计数器。

测试程序活动显示关闭显示
4 次 VRAM 写入后对 FIFO 空的轮询5 次轮询1 次轮询
一次 24 字的突发,用 HV 计数器计时2 行0 行
一次 180 字的 68000 DMA(数据和寄存器已检查)19 行2 行
对 DMA 忙的轮询:4 KiB 填充 vs 复制—306 vs 611

模拟器变得更准确了,SGDK 游戏开始晚了十二帧。

SGDK,大多数自制软件所基于的 C 工具包,在启动时用 DMA 填充清除全部 64 KiB 的 VRAM,两次,且显示开启。在实际槽速度下,每次填充大约需要五帧半,因此在这个模型下,SGDK 软件完成启动大约晚十二帧,就像在主机上一样。两个回归哈希不得不重新审视。The Spiral 在其动画的稍后时间点显示了相同的粒子场景,其新哈希是在比较截图后记录的。240p Test Suite 的模式菜单现在在脚本化按键之后完成加载,因此按键从第 330–335 帧移动到第 345–350 帧;结果帧与旧帧逐位相同。

一个发生变化的回归测试,如果你能解释变化,就不是失败。这两个可以逐帧解释。

第 10 章crates/savestate · core/src/eeprom.rs · rewind.rs

存档、状态和倒带

保存游戏进度的三种方式:卡带自身的记忆、整个主机的快照,以及一个可以回退浏览的快照环。

保存状态就是整个主机被写下来:每个组件实现一个 trait,按固定顺序写入其字段并读回。格式是纯小端二进制,带有魔数、版本和游戏指纹,没有字段名和模式。加载是事务性的:损坏的状态不会影响正在运行的主机。

倒带因此几乎不需要额外构建成本:一个保存状态的环形缓冲区,每隔几帧取一个,按住键时弹出并加载。一个状态大约 150 KiB,因此十秒的历史只需几十兆字节。在性能优化期间,将字节切片一次性复制而不是逐字节保存,使这些快照的成本降低了约六倍。

有两根线的芯片

大多数卡带将存档保存到电池供电的 SRAM,即 $200000 处的普通内存。少数几十款则改用串行 EEPROM:一颗只有时钟和数据引脚的微型 I²C 芯片,游戏通过向一个地址写入锁存器来逐位驱动它,而该地址取决于主板制造商。gase 逐边沿地模拟这颗芯片(起始和停止条件、每第九个时钟的应答位、两种寻址模式、在页内回绕的页写入),并维护一张表记录哪些游戏使用哪颗芯片和哪种接线,因为 ROM 里没有任何信息说明这些。

第 11 章crates/gase/src/debugger · core/src/system/debug.rs

面向学习者的调试器

阅读一台机器的资料是一回事。在帧中途停下它并窥视内部则是另一回事。

在 gase 中按 F1,会打开第二个窗口,以芯片的视角展示主机:两个 CPU 的寄存器和反汇编、按名称解码的 VDP 寄存器、调色板、VRAM 中的每个图块或平面映射,以及按链接顺序排列的精灵列表。你可以暂停、单步执行一条 68000 指令、运行到帧末或下一个垂直消隐、设置断点,并静音每个 FM 和 PSG 声道以听出音乐是如何编排的。

The gase debugger on the 240p Test Suite at frame 300: 68000 registers and disassembly at the top left, the Z80 below, the decoded VDP registers at the top right, the four palettes, the VRAM tiles, and the sprite list.

图 9在第 300 帧对 240p Test Suite 运行的调试器,由无头运行器用 --dump-debugger 保存。它被绘制到一个普通像素缓冲区中,使用公有领域的 8 × 8 字体,因此无需窗口即可保存并进行单元测试。
  1. 68000 寄存器,状态寄存器的标志位已解码。
  2. 程序计数器附近的反汇编、断点和光标。
  3. Z80 寄存器和代码,以及谁占用其总线。
  4. 声音:哪些 FM 和 PSG 声道被静音。
  5. 按名称显示的 VDP 寄存器:平面地址、尺寸、滚动模式、DMA。
  6. CRAM:四个 16 色调色板。
  7. VRAM:全部 2,048 个图块,或平面 A、平面 B、窗口。
  8. 按链接顺序排列的精灵:位置、尺寸、图块、调色板、翻转。

可以尝试的实验

  • 跟踪启动过程。从 gase --debug game.bin 开始并按 S:大多数游戏首先读取 A10001 处的版本寄存器,然后设置 VDP(观察解码后的寄存器变化),接着清空 RAM。
  • 找到垂直消隐处理程序。按 V,然后按 S:68000 接受 6 级中断并跳转到存储在 $000078 处的地址。大多数游戏在那里完成所有 VRAM 更新。
  • 逐帧对比阅读。按 T 在图块、平面 A、平面 B 和窗口之间切换,并与精灵列表进行比较。
  • 聆听各声道。用 1–6 静音 FM 声道,用 7–0 静音 PSG。

第 12 章benchmarks/ · PR #17

快速,且仍然可读

一次由性能分析驱动的优化让 gase 快了 1.5 到 1.9 倍,没有改变任何一个输出位,也没有让代码变得更难学习。

这项工作始于测量,而非直觉。对五个测试 ROM 中的每一个都取两个数字:来自无头基准测试的每秒帧数,这是你能感受到的;以及在 callgrind 下 300 帧内执行的主机指令数,它不依赖于机器还在做什么,并能显示百分之零点几的变化。

性能剖析结果非常明确。扫描线渲染器占用了全部指令的 50–57%:它为每个平面的 320 个像素逐一重新计算滚动、单元格和像素,并用带分支的循环进行合成。其次是 68000 的操作数辅助函数,以行外调用方式执行,然后是 YM2612 计算静音通道、PSG 逐步推进,以及重采样器。

FIG. 10每秒帧数,无头模式,在共享的 2.1 GHz Xeon 单核上运行 7 轮交错、每轮 3,000 帧的中位数。在每秒 1,500 帧时,gase 的运行速度约为游戏机的 25 倍。数据:benchmarks/results/2026-10-10.csv。
FIG. 11模拟 Airstriker 300 帧所需的主机指令数,按该阶段每次提交的顺序排列。第一步,按瓦片行而非逐像素绘制平面,减少了超过三分之一的总工作量。

阅读参考实现以了解硬件如何工作。阅读快速路径以了解如何使其变快。

这是每一项优化的准则。直白的版本保留在代码中作为有文档的定义,快速路径在自己的文档中引用它,单元测试证明两者一致,通常是在数千个随机输入上。除此之外,每一项改动都必须让测试 ROM 的帧哈希、录制的音频和 CPU 测试向量保持逐位一致。没有 unsafe,也没有手写 SIMD:向量化由编译器完成。

快速路径可读的参考实现由以下证明相等
render_plane,瓦片行运行plane_pixel_reference,逐像素公式300 个随机 VRAM、VSRAM 和寄存器设置:所有滚动模式、平面尺寸、H32/H40、隔行扫描
pick_fast,字节运算(SIMD)pick,优先级规则每个平面 B 像素值
Psg::run,重载到重载Psg::tick + Psg::output3,000 次随机寄存器写入,批次从周期中间开始
跳过已淡出的算子Operator::eg_step,Channel::calc_operators200 个算子 × 4,095 个计数值;2,000 个随机通道
单次比较的 ROM 读取Cartridge::read_word_mapped奇数尺寸 ROM、映射器、ROM 内外的 EEPROM、SRAM
一次复制中的字节切片State::save_slice 的默认值字节完全相同的存档状态

Chapter 13.github/workflows

工作是如何完成的

每一项改动都以拉取请求的形式提交,其中包含自己的测试、本地 CI 运行,以及对其未完成事项的如实说明。

工作发生在从 develop 切出并经由拉取请求合并回来的 feature/… 和 fix/… 分支上;main 跟随发布。芯片在各自的分支上并行开发,每个芯片一个 crate 的布局让这变得毫无痛苦,并且每个拉取请求描述都列出了测试了什么、哪些是近似的,以及它会与哪些其他打开的拉取请求冲突,以及以什么顺序合并它们。

持续集成在每个拉取请求上运行,警告被视为错误:

  1. cargo fmt --all --check
  2. cargo clippy --workspace --all-targets,带有工作区 lint,其中包括 forbid(unsafe_code)
  3. cargo test --workspace:单元测试、手工汇编的整系统程序、调试器等价性
  4. cargo build -p gase --no-default-features,证明无头构建没有外部依赖
  5. cargo doc --workspace --no-deps,警告视为错误:文档是产品的一部分
  6. cargo clippy -p gase-web --target wasm32-unknown-unknown,以及针对五个手机目标的 clippy
  7. scripts/smoke-sdl.sh:优化后的程序在五秒后必须仍在运行(第 18 章)

单独的工作流构建浏览器版本和手机应用:一个 Android APK,以及用于设备和模拟器的 iOS 应用。

一个接口,然后同时支持三个平台

在模拟器本身之后,接口和移植是最大的一块工作,而且顺序很重要。无平台依赖的接口最先完成,它单独在一个分支上,带有桌面外壳(#22),因为其他一切都将建立在它的Platform契约之上。契约一确定,浏览器版本(#23)和手机应用(#24)就并行构建,各自在自己的工作副本中,分支堆叠在 #22 之上。它们在不同的目录中工作(crates/web 和 web/,crates/mobile 和 mobile/),因此不会互相踩到对方的代码;在它们确实交汇的地方,即 README 和 ARCHITECTURE.md 中,第二次合并产生了冲突,这些冲突是手工解决的。

每个移植也把修复反馈回来。第 18 章的崩溃是在接口分支上发现的,其修复在另外两个分支被审查之前就合并了进去;第 14 章的横屏 bug 是在为本网站拍照时发现的。有两次,后续提交在它的拉取请求被合并后一分钟才到达,于是它进入了一个新的小型拉取请求,而不是被丢失:快速工作意味着在合并后检查最后一句话,而不仅仅是在合并前。

gase 是在一个 AI 助手下编写的,该助手在自己的分支和拉取请求上工作;每一份都由一个人审查并合并。每个提交都在最后一行说明了这一点:Co-authored with AI。

大型测试套件对每次推送来说都太大了(仅 Z80 向量就约 1.3 GB),因此第二个工作流每周和按需运行它们,同时还有测试 ROM 回归:每个 ROM 运行固定数量的帧,并带有脚本化的按键,然后将画面的 FNV-1a 哈希与一个只有在查看过该帧后才记录的值进行比较。

PR它带来了什么分支合并时间(UTC)
#1工作区、存档状态 crate、可行性研究claude/vibrant-fermat-a8mz9q10 月 9 日 21:44
#2 #5VDP:移植、DMA、时序、扫描线渲染器feature/vdp10 月 9 日 22:02
#3 #4Z80,通过 SingleStepTests 和 ZEXALLfeature/z80-cpu10 月 9 日 22:02
#8声音:YM2612、PSG、重采样器、滤波器feature/sound10 月 9 日 22:06
#9每个拉取请求上的 CI,每周向量运行feature/ci10 月 9 日 22:09
#1068000,对照 MAME 和 Tom Harte 向量进行验证feature/m68k-cpu10 月 9 日 22:44
#11主机和前端:gase 可玩了feature/core-system10 月 9 日 22:44
#12可选的宽松地址错误feature/lenient-address-errors10 月 9 日 23:44
#13串行 EEPROM 存档feature/eeprom-saves10 月 10 日 06:59
#14VDP FIFO、访问槽、时间精确的 DMAfeature/vdp-fifo-dma-timing10 月 10 日 07:02
#15学习型调试器feature/debugger10 月 10 日 07:03
#16修复:让 FIFO 在颜色测试中排空fix/cram-rgb-test-fifo10 月 10 日 07:26
#17性能优化和 benchmarks/feature/performance10 月 10 日 08:36
#18发布 0.1.0 到 mainrelease/v0.1.010 月 10 日 09:28
#19ZIP ROM:自有的 DEFLATE 解码器feature/zip-roms10 月 10 日 09:30
#20本网站,制作过程feature/making-of10 月 10 日 09:30
#22接口、控制,以及 SDL 崩溃修复feature/app-ui10 月 10 日 14:03
#23浏览器版本,一个可安装的 PWAfeature/web10 月 10 日 14:08
#24Android 和 iOS 应用feature/mobile10 月 10 日 14:10
#25修复:横屏时 Start 和 Mode 移出画面fix/touch-landscape-start-mode10 月 10 日 14:46
#26本网站:接口和平台feature/making-of-update10 月 10 日 14:47
#27发布 0.2.0 到 mainrelease/v0.2.010 月 10 日 16:18
#30为每个平台发布的工作流feature/release-binaries10 月 10 日 16:29
#31修复:Windows 和 macOS 上的静态 SDLfix/release-cmake410 月 10 日 16:38
#33修复:旧版本的发布文件fix/release-older-versions10 月
已合并的拉取请求,按顺序排列。#6、#7、#21、#28、#29 和 #32 同步了 main 和 develop。#16 之所以存在,是因为两个正确的分支合并在一起后,破坏了一个假设 CRAM 写入会立即生效的测试。

第 14 章crates/app · PR #22

一个界面,适配所有屏幕

菜单、设置、文件浏览器和触摸控制,只编写一次并逐像素绘制,因此桌面端、浏览器和手机都运行同一份代码。

直到 0.1 版本,gase 还只是一个命令行加一个窗口。要让它好用,就意味着需要菜单、选择游戏的方式、重启后仍保留的设置、可重新映射的控制,以及在手机上位于拇指下方的按钮。如果按每个平台工具包各写一遍,写四遍,会让学习者需要阅读的代码量变成四倍。所以它放在一个 crate 里,gase-app,没有依赖也没有 I/O,就像核心一样。

应用自己绘制自己。每一帧它都用自带的位图字体把菜单画进一个普通的像素缓冲区,和调试器的方式一样。平台只需要显示这个缓冲区、播放声音,并上报按键、手柄按钮和手指操作。界面是即时模式的:没有需要保持同步的控件对象。每一帧都根据设置和主机的状态绘制当前屏幕,输入在绘制的同时被处理。

平台契约

平台必须提供的一切都装在一个 trait 里,Platform:读写一个具名文件、列出一个文件夹、请求一个 ROM、报告时间。平台交给应用的一切都是一个 Event:一个按键、一个手柄按钮或轴、一个指针、一个拖放的文件、应用进入后台。应用请求回去的一切都通过 Requests 传来:进入全屏、退出、打开系统的文档选择器。外壳就是在某个平台上兑现这一承诺的代码,共有三个:用于桌面端和手机的 SDL2,以及用于浏览器的 WebAssembly 版本。

同时支持所有控制器

键盘、任意数量的游戏手柄和触摸屏同时映射到主机的两个手柄上。每个设备各自保留自己按下的按钮集合,主机看到的是它们的并集。无需切换任何东西,松开一个键也不会释放游戏手柄仍按住的按钮。每个按键和手柄按钮都可以为两位玩家重新映射。

The gase home screen: the gase logo with ‘Mega Drive / Genesis’ beside it, an ‘Open ROM…’ entry, a recent list with Right 2 Repair, 240p Test Suite, Airstriker and The Spiral, then Settings and Quit. A hint at the bottom says a ROM file can be dropped on the window.

FIG. 14桌面端的主屏幕,由 gase --dump-ui 保存。最近列表顶部的 Right 2 Repair 是一个 .zip 文件(第 15 章)。同一个选项可以在不打开窗口的情况下绘制应用的任何屏幕,本章的每一张图片都是这样制作的,应用的测试也是这样查看屏幕的。

The built-in file browser listing a Games folder: 240p Test Suite.bin, Airstriker.md, Right 2 Repair.zip, The Spiral.bin.

打开 ROM。 桌面端内置的文件浏览器,可用游戏手柄操作。手机则使用系统的选择器。

The Controls settings screen: player 1 and 2 pad types set to 6 buttons, touch controls set to Auto, remapping entries for each player’s keyboard and gamepad, and a list of connected gamepads.

控制。 三键或六键手柄、触摸控制,以及为每位玩家的键盘和游戏手柄重新映射。

Remapping player 1’s keyboard: a dialog says ‘Press a key for B’, over the list of console buttons and their keys.

重新映射。 选择一个主机按钮,然后按下它的新按键或手柄按钮。

The Save state screen: ten slots in a grid, the first three showing small pictures of The Spiral demo at different moments, the others marked Empty.

带图片的存档状态。 十个槽位中的每一个都保留保存那一刻的缩略图,这里是 The Spiral 的三个时刻。状态是第 10 章的二进制格式;图片是旁边的一个小 PNG,由应用自带的 PNG 编码器写入。

The Video settings screen: aspect ratio set to Console 10:7, integer scaling off, fullscreen off, window size 3x, and a line explaining what integer scaling does.

会自我说明的设置。 选中的选项用一行说明它的作用。“Console 10:7” 保持像素为正方形,完全按主机绘制的方式(320 × 224 即 10:7);“TV 4:3” 则像电视机那样拉伸画面。

玻璃上的拇指

触摸板也是应用自带的,因此在 Android、iOS 和移动浏览器上完全一致。每根手指都在触摸板自己维护的一张小表里被单独跟踪。落在方向键上的手指即使滑出,也仍然继续操控它。落在两个按钮之间空隙的手指会同时按下两个按钮,所以一根拇指可以同时按住 B 和 C。所有尺寸都由屏幕尺寸推导而来,因此同一份代码既能服务小屏手机,也能服务平板。

布局有一条高于一切的规则:绝不遮挡游戏画面。写这一章时正好在那里发现了一个 bug。拍下下面的横屏照片时,发现 Start 和 Mode 被画在了底边正中间,正好压在 Right 2 Repair 的“PRESS START”上。代码里的注释写着“横屏时放在角落”,但代码并没有这么做。先写了一个失败的测试,然后才修复,见 pull request #25。现在这两个按钮位于黑色边框里,各自在拇指上方。

gase on a phone held upright: Right 2 Repair’s title screen at the top, and below it a d-pad, six round buttons (A B C, X Y Z), Mode and Start, and a menu button.

竖屏。游戏在上方,触摸板在下方。

gase on a phone held sideways: the game in the middle, the d-pad and Mode on the left border, the six buttons and Start on the right border, the menu button at the top right.

横屏。控件位于画面两侧的边框里。

The gase home screen laid out for a phone: the logo, Open ROM…, the recent list and Settings, with no Quit entry.

手机上的主屏幕。没有 Quit:手机关闭应用有自己的方式。

手机屏幕以每点两像素绘制,此处按 390 点宽手机的大小展示。它们来自 gase --mobile --dump-ui,它在桌面上运行手机外壳的布局。

第 15 章crates/zip · PR #19

打开 ZIP 文件

大多数 ROM 合集都是压缩成 ZIP 的。读取它们需要一个解压器,而自己写一个,则是一堂关于几乎所有压缩文件如何工作的小课。

引入一个库本会是轻松的做法。但到目前为止,少依赖这条规则一直坚持着,而 DEFLATE——ZIP 文件(以及 gzip 和 PNG)内部的压缩算法——事实证明很值得学习:除去测试,大约 900 行代码,结合了两个古老的想法。gase-zip 读取 ZIP 归档、解压 DEFLATE 流并校验 CRC-32 和。当 Cartridge::from_bytes 在文件开头看到字节 PK\3\4 时,就打开归档并取出里面的 ROM。每个平台都通过这一处检查获得 ZIP 支持。

第一个想法是 LZ77。压缩器不存储已经见过的文本,而是写下“回退 distance 字节,并从那里复制 length 字节”。解压器自己的输出就是字典。一次复制可以与其正在写的内容重叠:距离 1、长度 100,就是把一个字节重复一百次。试试看:

图 15每一段红色区间都是一个反向引用 ⟨length, distance⟩,而不是字面文本。小数字是它的距离。真正的 DEFLATE 最多回看 32 KB,并且搜索得更努力,但思路是一样的。

第二个想法是 霍夫曼编码:常见符号获得短位模式,罕见符号获得长位模式。DEFLATE 把字面字节、匹配长度和一个块结束标记放进一个 286 个符号的字母表,距离则放在第二个字母表里。每个块要么使用标准固定的编码,要么先发送自己的编码。为了节省空间,它把该编码作为一列位长度发送,而这列位长度本身又用第三个霍夫曼编码压缩。crates/zip/src/inflate.rs 里的注释逐表拆解了这些内容,即使你从不碰模拟器,也值得一读。

逐位解码是可读的参考实现。快速路径用一次对接下来 10 位的查找来解码大多数编码,测试会检查两者在 zlib 生成的和随机的流上给出完全相同的结果,包括错误情况:又是第 12 章的那条规则。快速路径解压一个 4 MiB 的 ROM 大约需要 19 ms。

第 16 章crates/web · web/ · PR #23

浏览器中的 Rust

同一个模拟器和同一个界面被编译为 WebAssembly,中间没有任何东西:没有绑定生成器,没有打包器,只有几个手写的 JavaScript 模块。

把 Rust 放进网页的常见方式是 wasm-bindgen,它会生成两种语言之间的粘合代码。这很好用,但它恰好隐藏了学习者想看的东西。所以 gase-web 不用它。该模块为 wasm32-unknown-unknown 构建,导出约二十个接收和返回数字的普通函数。页面反过来导入九个函数:读写文件、推送声音、发起请求。六个 JavaScript 模块按编写时的样子加载,完成其余工作。

在浏览器中玩 gase

一块共享内存

WebAssembly 模块的内存是一块 JavaScript 可见的 ArrayBuffer,而 Rust 指针只是其中的一个偏移量。所以图像或声音从不跨边界复制。Rust 将 vec.as_ptr() 作为数字返回,页面将这些字节包装成类型化数组并交给 canvas。每一帧之后,模块填充一个十七字的帧描述,告诉页面图像在哪里、有多大、画在哪里以及如何控制节奏。页面通过一个 Uint32Array 读取它,就像 C 结构体一样。

FIG. 16没有任何序列化。Rust 交给 JavaScript 数字(指针和长度),JavaScript 每次都在内存上创建一个新的类型化数组视图,因为增长内存会替换缓冲区。两种语言之间的边界是一串整数参数和一条共享内存。

gase’s home screen in a browser page, with a slim footer: ‘gase is a Mega Drive emulator written in Rust, running here as WebAssembly. Your games, saves and settings stay in this browser.’ and a link to play the 240p Test Suite.

FIG. 17无头 Chromium 中的播放器。手头没有游戏时,一次点击会从 GitHub 上的副本下载 GPL 许可的 240p Test Suite,并在运行前检查其 SHA-256。该页面是一个可安装的 PWA,可离线工作,手机上带有触摸控制。

浏览器让三件事变得困难

  • 保存。应用期望读取文件并立即取回,但浏览器的 IndexedDB 稍后才响应。因此启动时页面将所有已存储的内容复制到内存中。读取来自该副本,写入则在后台进入数据库,每帧一个事务。
  • 声音。音频在实时线程中播放,位于一个 AudioWorklet 内。为其提供数据最快的方式是共享内存中的环形缓冲区,但浏览器只允许在带有特殊隔离标头提供的页面上这样做,而 GitHub Pages 无法发送这些标头。因此页面也知道如何发送样本块,这就是本站使用的路径。
  • 节奏。浏览器按自己的时钟重绘。每次重绘欠经过时间 × 59.92个模拟帧,余数结转。声音队列只有在偏离目标超过两帧时才添加或跳过一个帧。早期版本会一直运行帧直到队列填满,结果每次重绘在零到两帧之间卡顿。

Chapter 17crates/mobile · mobile/ · PR #24

手机上的 Rust

SDL2 已经能在 Android 和 iOS 上运行,所以手机应用复用了桌面端的外壳。剩下的问题是:谁来调用 main。

两个手机系统都不通过调用 main 来启动程序。在 Android 上,Java 虚拟机启动一个 activity。SDL 的 SDLActivity 加载两个原生库 libSDL2.so 和 libmain.so,并在自己的线程上调用 C 函数 SDL_main。在 iOS 上,一个很小的 Objective-C main 将控制权交给 UIKit,应用启动后,SDL 调用同一个 SDL_main。gase-mobile crate 恰好导出该函数:为 Android 构建为共享库,为 iOS 应用链接为静态库。

从那里开始就是桌面程序,只是Shell不同。文件存放在应用的私有存储中,ROM 来自系统的文档选择器或“打开方式”,屏幕会旋转,而且没有退出功能。把选中的文件复制到应用里只需几十行 Java 和 Objective-C 代码。这段胶水代码随后把副本的路径作为普通的 SDL“文件拖放”事件交回,而外壳程序早已从桌面端理解了这种事件。

手机随时可能把你干掉

桌面程序会一直运行到被关闭为止。而一个被送到后台的手机应用可能再也不会被唤醒。所以当 gase 进入后台时,它会立刻保存游戏的电池 RAM 和设置,打开暂停菜单,停止绘制并休眠,不占用 CPU。回来时,它会丢弃过期的声音并恢复运行。如果系统收回了图形设备,SDL 会告知这一点,纹理便会被重新创建。

第 18 章crates/gase/src/sdl.rs · scripts/smoke-sdl.sh

别人代码里的崩溃

工作区禁止不安全代码。新窗口仍然会因段错误而崩溃,但只出现在优化构建中。

新接口的调试构建运行正常。发布构建在打开窗口后一秒内就因SIGSEGV而崩溃。所有单元测试都通过了,因为它们没有一个会打开窗口。gdb把崩溃定位在 SDL 内部,即把像素复制到纹理中。valgrind更为精确:一个由栈分配创建的未初始化值,随后是对不属于任何人的内存的写入。

把图片上传到 GPU 经过了sdl2的安全封装,即Texture::with_lock(rect, …)。在那个 crate 内部,它看起来是这样的:

let (rect_raw_ptr, height) = match rect.into() {
    Some(ref rect) => (rect.raw(), rect.height() as usize),
    None => (ptr::null(), q.height as usize),
};
// `rect.into()` was a temporary: it is gone here, and rect_raw_ptr points
// at the stack slot where it used to be.
let ret = sys::SDL_LockTexture(self.raw, rect_raw_ptr, &mut pixels, &mut pitch);
图 18sdl2 0.37,render.rs。Option<Rect>只存活到let语句结束,但指向它的裸指针却比它活得更久。在调试构建中,旧值通常还在那里。优化器会复用这个槽位,于是 SDL 读取到一个垃圾矩形,并计算出一个远在纹理之外的地址。

修复只需一个词:用None锁定整个纹理,这会传入一个空指针,且不需要临时变量。无论哪种方式,SDL 都会交回同一个缓冲区,所以没有任何代价。valgrind安静了下来。crates/gase/src/sdl.rs中upload的文档注释记录了为什么这个矩形绝不能回来。

仅仅修复崩溃还不够:下次还必须让人不可能忽略它。scripts/smoke-sdl.sh用 SDL 的虚拟视频和音频驱动运行优化后的程序,除非五秒后它仍在运行,否则就失败。在信任修复后的构建之前,它先针对损坏的构建进行了检查(退出码 139,段错误)。现在它会在每个拉取请求的 CI 中运行。

第 19 章.github/workflows/release.yml · PR #30–#33

发布到每个平台

在自己的机器上构建 gase 是一堂课。下载它不应该也是一堂课。一个工作流就能把版本标签变成每个平台的程序。

当 GitHub 上发布一个 release 时,Release工作流会检出带标签的提交,并同时在所有平台上构建:在 Linux、Windows 和 macOS 机器上构建桌面程序,为浏览器构建 WebAssembly 版本,以及用第 17 章的手机工作流构建 Android 和 iOS 版本。最后一个任务收集这些文件,添加它们的校验和,取CHANGELOG.md中该版本的部分作为发布说明,并附上所有内容。GitHub 会自行添加源代码。

这个工作流也可以手动通过标签名启动,然后它也会创建标签和发布。0.2.0 和更早的 0.1.0 就是这样事后获得文件的;0.1.0 早于浏览器和手机代码,所以工作流会查看提交包含什么,只构建那些平台。

文件(0.2.0)用于大小
linux-x86_64.tar.gzLinux,在 Ubuntu 22.04 上构建,以便较旧的系统也能运行2.5 MB
windows-x86_64.zipWindows 10 和 111.1 MB
macos-universal.tar.gzApple 芯片和 Intel 合为一个程序,用 lipo 连接2.3 MB
web.zip浏览器版本,可直接提供服务0.2 MB
android.apkAndroid 手机,用于手动安装3.3 MB
ios-unsigned.ipaiPhone,需用开发者账户签名后使用0.8 MB
0.2.0 发布版的文件,已压缩。整个模拟器,包括界面,比新闻网站上大多数单张图片还小。

无需安装其他东西

桌面程序需要 SDL2 来提供窗口、声音和游戏手柄,要求玩家先安装一个库会是很糟糕的欢迎方式。sdl2-sys crate 恰好附带 SDL 的完整源代码,两个特性让它用 CMake 编译该源代码并将其链接到可执行文件中:cargo build --features sdl2/bundled,sdl2/static-link。结果除了操作系统之外不依赖任何东西。在 Linux 上,SDL 启动时仍会像往常一样找到 X11 或 Wayland 以及声音系统,所以一个文件可在任何桌面上运行。

然后每个桌面构建都被单独复制到一个空文件夹中并启动:如果它仍然需要 SDL 库,系统会拒绝运行它。在 Linux 上,它还必须让窗口保持打开五秒钟。

三个意外,在它们造成影响之前就被发现

在修改它的拉取请求上,工作流会构建每个文件但不发布任何内容,所以在发布依赖它之前很久就已经过测试。Linux 立刻就能工作。Windows 和 macOS 失败了三次,每次失败都足够精确,只需几行就能修复:

位置构建输出原因与修复
Windows、macOSCompatibility with CMake < 3.5 has been removed这些机器现在有 CMake 4,而 SDL 2 的构建文件仍然要求 CMake 3.0。设置 CMAKE_POLICY_VERSION_MINIMUM=3.5 告诉 CMake 这没问题。
Windowsunresolved external symbol RegOpenKeyExWSDL 读取注册表,注册表位于 advapi32.lib 中,而该 crate 在链接 SDL 时忘记链接它。添加一个链接器参数即可。
macOSUndefined symbols: ___isPlatformVersionAtLeastSDL 的游戏手柄代码会问“这个 macOS 是否足够新?”,这会编译成对 clang 自身运行时库的调用。C 程序会自动获得该库;Rust 程序链接时没有它。指定该文件即可修复。
首次测试运行的三次失败,按顺序排列。它们都不在 gase 的代码中;它们都会阻止一次真正的发布。

一个永远什么都不做的游戏

五秒测试需要运行某个东西,而 0.1.0 程序没有游戏甚至无法启动。在每次构建中下载测试 ROM 会让发布依赖别人的服务器,所以工作流用一行 Python 自己写了一个,一千字节长:

offset  bytes          meaning
000000  00 FF FE 00    initial stack pointer: $FFFE00, in work RAM
000004  00 00 02 00    initial program counter: start at $000200
000100  53 45 47 41    "SEGA", where the header says it should be
000200  60 FE          BRA.S $200: branch to this very instruction
图 19其他所有内容都是零。60 是 68000 的短分支,FE 是它的距离:−2 字节,从两字节指令的末尾算起,正好落回指令本身。主机永远运行它,窗口保持打开,测试通过。gase --trace 展示了它:000200 bra.s $200,一遍又一遍。

手机文件附带一个发布说明中重复的警告。Android 在警告后安装 APK,因为它用开发密钥而不是商店密钥签名。iPhone 不会安装任何未经注册开发者签名的东西,所以 .ipa 是拥有此类账户的人的起点,或者是用他们的账户签名的工具。

第20章构想

接下来是什么

核心仍然不知道窗口是什么。下面的一切都可以构建在它之上,而每一项本身都是一课。

  • 观看

    图片的原貌

    CRT 和复合视频滤镜:扫描线、点蠕动和混合抖动,这些都是当时的艺术家为之绘制的。提前运行,以隐藏现代屏幕增加的延迟。

  • 游玩

    时间作为材料

    在可拖动的时间轴上倒带,输入录制用于回放和工具辅助速通,回滚网络对战(存档状态已经是确定性的),金手指,按游戏设置,从你停下的地方精确恢复。

  • 学习

    更多窥探内部的方式

    用于音乐的 VGM 录制器,每通道示波器,VRAM 编辑器,以及一种课程模式,在诸如第一次 DMA 或第一次中断等事件处暂停并解释它们。

  • 硬件

    家族的其余成员

    Master System 和 Game Gear 模式(Z80 已经在这里了),然后是 Mega CD 和 32X。光枪、鼠标和四人适配器。

  • 所有人

    无障碍

    连发和按住切换按钮,高对比度和更大的界面,以及屏幕阅读器可以跟随的菜单。

  • 已知差距

    gase 尚未做到的事

    行中光栅变化在下一行生效。VDP 槽位位置遵循文档结构而非测量位置,尽管计数是精确的。手机应用尚未在设备上运行,而且它们尚未避开刘海和圆角。

来源:Hacker News · bthuner.github.io