凡是做模拟器开发的, 几乎都离不开用来避免回归的自动化测试。 常见做法是使用单元测试: 它们在隔离的环境下验证代码的细小模块,因此必须针对每个模拟器做专门适配。 作为补充,集成测试用于验证模拟器整体的行为。

将测试 ROM 作为集成测试

一种尤其有趣的集成测试形式是测试 ROM(Test ROM)。 它们直接在被模拟的平台上运行,针对性检查平台的具体行为。 可以说, 它们是从“硬件内部”发起的测试

一个 Game Boy 测试 ROM 可以例如:

已知测试套件

已经有成体系的 Game Boy 测试 ROM 集合, 也就是所谓的测试套件(Test Suite)。 其中最有名的一套是 Mooneye Test Suite, 由 Joonas Javanainen(Gekkio) 开发。 此外还有 Lior HalphonSameSuite 以及 Matt CurrieMealybug Tearoom Tests, 都非常值得一看。 另外还有很多其它套件, 限于篇幅在此不一一列出。

我在自己的一个 GitHub 仓库里整理了一份 不同作者的测试套件合集

相对单元测试的优缺点

相较于单元测试,Game Boy 测试 ROM 的入门门槛要高得多。 作为集成测试,它们运行成本更高: 需要启动完整模拟器, 加载并执行测试 ROM, 随后通过合适的方式 (例如比对屏幕、内存或 CPU 寄存器) 判定测试结果。 而且编写测试 ROM 本身也更复杂, 因为需要配套的工具链、单独编译 ROM,并且通常用汇编编写—— 这可不是一门“流行”的语言。

但测试 ROM 的优势也非常明显:

  1. 它们不依赖于模拟器的具体实现。 只要是同一平台的模拟器,都可以用同一批 ROM 来测试。
  2. 它们可以在真机上运行,任何人都能在原始硬件上验证其正确性。

开发 Game Boy 测试 ROM

制作 Game Boy 测试 ROM 需要合适的工具链。 我个人最喜欢 RGBDS 搭配一个 Makefile。 RGBDS 专为 Game Boy 开发打造,除了 汇编器链接器,还带有 图形转换头部修正等工具。 小项目甚至可以用 rgbds-live 直接在浏览器里开发和试跑。

RGBDS 的常见替代品有 WLA DX (支持多种 CPU 的交叉汇编器)以及 GBDK(用于 Game Boy 的 C 编译器)。

RGBDS 与 WLA DX 需要写汇编, 而 GBDK 也支持 C。 对 Game Boy 开发的新手来说,GBDK 可能更容易上手。 不过当使用 C 时,无法直接控制编译器最终生成的 CPU 指令。 对于测试 ROM来说, 由于经常需要精确到时序, 汇编更合适

在真机上运行

要在真正的 Game Boy 上试跑自制程序, 需要一个合适的卡带适配器。 我目前使用 EZ-FLASH Junior, 它可以通过 Micro SD 卡写入 ROM。 这类适配器还有不少其它选择。 在 Google 或亚马逊搜索 “Game Boy Cartridge Adapter” 就能找到合适的产品。

几乎所有适配器都能同时存放多个 ROM, 开机后在 Game Boy 上选择要运行的那个即可。

笔记本、Micro SD 卡、EZ-Flash Junior,以及 Game Boy Color 上的选择菜单

笔记本、Micro SD 卡、EZ-Flash Junior,以及 Game Boy Color 上的选择菜单

一个简单的测试 ROM 示例

作为示例,我们来看一个(非常简单的)用于 DIV 时序的测试 ROM。 它检查在一次 DIV 复位之后第一次递增所发生的那个时钟周期, 由三个部分组成:

  1. 初始化 VRAM 与 BGP
  2. 执行实际测试
  3. 显示结果(✓ / ╳)

动手试试的话,只需把下面清单 1 的代码复制到 rgbds-live 里运行 (把预填的 main.asm 全部替换)。 如果想让测试失败, 比如改动第 48 或 54 行中的数值即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
; main.asm
;
; 这个刻意保持简单的测试 ROM 用于检查
; 在一次 DIV 复位之后,第一次 DIV 递增所处的时钟周期。

INCLUDE "hardware.inc"

SECTION "Header", ROM0[$100]
  jp EntryPoint

SECTION "EntryPoint", ROM0[$150]
EntryPoint:
  ; ----- 1. 初始化 VRAM 与 BGP
  ;
  ; 等待 V-Blank,以便关闭 LCD
: ldh a, [rLY]
  cp LY_VBLANK
  jr c, :-
  ; 关闭 LCD
  ld a, LCDC_OFF
  ldh [rLCDC], a
  ; 初始化 VRAM
  ld de, VramData
  ld hl, STARTOF(VRAM)
  ld bc, VramData.end - VramData
: ld a, [de]
  inc de
  ld [hli], a
  dec bc
  ld a, b
  or a, c
  jr nz, :-
  ; 设置 BG 调色板
  ld a, %11_10_01_00
  ldh [rBGP], a

  ; ----- 2. 执行测试
  ;
MACRO NOPS
  REPT \1
    nop
  ENDR
ENDM
  ; 在第一次递增发生的前一个 M-cycle(机器周期)读取 rDIV
  ldh [rDIV], a ; 复位 rDIV,重新开始计数
  NOPS 60
  ldh a, [rDIV]
  cp a, 0       ; 在第一次递增前一刻,rDIV 应为 0
  jr nz, .fail
  ; 在第一次递增发生的 M-cycle 内读取 rDIV
  ldh [rDIV], a ; 复位 rDIV,重新开始计数
  NOPS 61
  ldh a, [rDIV]
  cp a, 1       ; 在第一次递增时刻,rDIV 应为 1
  jr nz, .fail

  ; ----- 3. 显示结果(✓ 或 ╳)
  ;
  ; 测试通过:✓
  ld a, 1
  jr .finish
  ; 测试失败:╳
.fail:
  ld a, 2
.finish:
  ; 显示结果:✓ 或 ╳
  ld [STARTOF(VRAM) + $1800], a
  ld a, LCDC_ON | LCDC_BLOCK01 | LCDC_BG_ON
  ldh [rLCDC], a
: jr :-

SECTION "VRAM data", ROM0
VramData:
  ; Tile 0:空
  ds 16, 0
  ; Tile 1:✓
  dw `00000000
  dw `00000033
  dw `00000333
  dw `00000330
  dw `03303300
  dw `03333300
  dw `00333000
  dw `00033000
  ; Tile 2:╳
  dw `00000000
  dw `03300033
  dw `03330333
  dw `00333330
  dw `00033300
  dw `00333330
  dw `03330333
  dw `03300033
  ; 其余 VRAM
  ds $2000 - 3 * 16, 0
.end

清单 1:可在 rgbds-live 上试跑的简单测试 ROM 示例

实现一个测试框架

上面的测试 ROM 大部分代码都在做初始化和结果展示。 真正的测试逻辑只占了不到 100 行中的大约 20 行。 如果要做成一整套测试套件,这些样板代码就会在每个测试 ROM 里重复一遍。 因此很值得 把这些通用部分抽到一个测试框架里, 供所有测试 ROM 复用。

通常,一个测试 ROM 会在自身测试代码之前包含这个框架。 框架负责程序入口和初始化, 并提供测试套件里经常用到的通用例程。 前面提到的测试套件 Mooneye Test SuiteSameSuiteMealybug Tearoom Tests 都采用了这个思路。

在测试 ROM 中进行结果判定

把示例测试 ROM 中的初始化与结果展示移入测试框架后, 剩下的就如清单 2 所示。 经过框架初始化后,测试 ROM 照常执行测试。 结果判定仍由测试 ROM 自身完成: 出错时调用 TestFail 结束 (第 16 和 23 行), 成功则调用 TestSuccess (第 26 行)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
; main.asm

INCLUDE "test-framework.inc"

MACRO NOPS
  REPT \1
    nop
  ENDR
ENDM

  ; 在第一次递增发生的前一个 M-cycle 读取 rDIV
  ldh [rDIV], a ; 复位 rDIV,重新开始计数
  NOPS 60
  ldh a, [rDIV]
  cp a, 0
  jp nz, TestFail

  ; 在第一次递增发生的 M-cycle 内读取 rDIV
  ldh [rDIV], a ; 复位 rDIV,重新开始计数
  NOPS 61
  ldh a, [rDIV]
  cp a, 1
  jp nz, TestFail
  
  ; 测试通过
  jp TestSuccess

清单 2:引入测试框架后的示例测试 ROM 代码

测试框架(清单 3)除了程序入口与初始化之外, 还提供了 TestSuccessTestFail 例程, 并负责显示最终结果。

这种做法能让每个测试更精简、也更易维护。 同时测试 ROM 仍然能完全自由地决定如何判定, 并可在需要时立即中止测试(“fail fast”)。

Mooneye Test Suite 就是以这种方式实现的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
; test-framework.inc

INCLUDE "hardware.inc"

SECTION "Header", ROM0[$100]
  jp EntryPoint

SECTION "Utilities", ROM0[$150]
; 测试通过:显示 ✓
TestSuccess:
  ld a, 1 ; ✓
  jr Finish

; 测试失败:显示 ╳
TestFail:
  ld a, 2 ; ╳
  jr Finish

Finish:
  ld [STARTOF(VRAM) + $1800], a
  ld a, LCDC_ON | LCDC_BLOCK01 | LCDC_BG_ON
  ldh [rLCDC], a
: jr :-

VramData:
  ; Tile 0:空
  ds 16, 0
  ; Tile 1:✓
  dw `00000000
  dw `00000033
  dw `00000333
  dw `00000330
  dw `03303300
  dw `03333300
  dw `00333000
  dw `00033000
  ; Tile 2:╳
  dw `00000000
  dw `03300033
  dw `03330333
  dw `00333330
  dw `00033300
  dw `00333330
  dw `03330333
  dw `03300033
  ; 其余 VRAM
  ds $2000 - 3 * 16, 0
.end

SECTION "EntryPoint", ROM0
EntryPoint:
  ; 等待 V-Blank,以便关闭 LCD
: ldh a, [rLY]
  cp LY_VBLANK
  jr c, :-
  ; 关闭 LCD
  ld a, LCDC_OFF
  ldh [rLCDC], a
  ; 初始化 VRAM
  ld de, VramData
  ld hl, STARTOF(VRAM)
  ld bc, VramData.end - VramData
: ld a, [de]
  inc de
  ld [hli], a
  dec bc
  ld a, b
  or a, c
  jr nz, :-
  ; 初始化 BG 调色板
  ld a, %11_10_01_00
  ldh [rBGP], a

  ; 从这里开始进入实际测试

清单 3:示例测试 ROM 所用的测试框架

在测试框架中进行结果比对

我们的示例测试 ROM 只检查了两个数据点: 在第一次 DIV 递增前一刻以及递增当刻的寄存器值。 这很简单,可以直接在测试 ROM 里判定。 但如果测试会产生上百甚至上千个数据点, 在测试 ROM 内部自行比对就会变得笨重。

另一种做法是把比对工作交给测试框架。 框架会在WRAM里预留一段区域。 每个测试 ROM 把“实际测得”的值写进去, 同时提供“期望结果”。 框架随后进行比对,并显示通过或失败。

这样做的好处是, 测试 ROM 可以更精简, 无需承担结果判定的逻辑。

缺点是, 测试必须完整跑完才能得出结果。 无法“fail fast”, 因为那需要在 ROM 内部即时判定。

例如 SameSuiteMealybug Tearoom Tests 就是这么做的。

在自动化集成测试中的用法

要把示例测试 ROM 用作自动化集成测试, 需要将其加载到待测模拟器中并运行一段时间, 最后自动检查结果。 要让这一流程可靠,需要回答两个问题:

  1. 示例测试 ROM 需要运行多久才会产生结果?
  2. 如何自动检查测试结果, 判定通过还是失败?

当然,这两个问题不仅适用于本示例, 而是适用于所有测试 ROM。

现行约定

Mooneye Test SuiteSameSuiteMealybug Tearoom Tests 都遵循同一套约定。 主导者大概是 Mooneye Test Suite, 它最初是 Mooneye GB 模拟器的一部分。

  1. 当执行到指令 ld b, b 时, 视为测试结束。 由于该指令除了消耗一个时钟周期之外没有任何副作用, 正常程序不会去使用它。

  2. 测试成功通过时,用一组特定的 CPU 寄存器取值来“打勾”—— 取自斐波那契数列的一段:

    • B = 3
    • C = 5
    • D = 8
    • E = 13
    • H = 21
    • L = 34

    若测试在结束时寄存器不是上述取值, 则视为未通过。

若要让我们的示例测试 ROM 遵循该约定, 只需按清单 4 修改测试框架即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
; test-framework.inc

; < ... >

; 测试通过:显示 ✓
TestSuccess:
  ld a, 1 ; ✓
  ld b, 3 ; 用 CPU 寄存器信号化“测试通过”
  ld c, 5
  ld d, 8
  ld e, 13
  ld h, 21
  ld l, 34
  jr Finish

; 测试失败:显示 ╳
TestFail:
  ld a, 2 ; ╳
  ld b, 0 ; 用 CPU 寄存器信号化“测试失败”
  jr Finish

Finish:
  ld b, b ; 信号化“测试结束”
  ld [STARTOF(VRAM) + $1800], a
  ld a, LCDC_ON | LCDC_BLOCK01 | LCDC_BG_ON
  ldh [rLCDC], a
: jr :-

; < ... >

清单 4:为信号化测试结果而需在测试框架中加入的片段

类比式(图像)结果

并非每个测试 ROM 都能自行判断结果。 例如视频输出: 测试 ROM 能生成画面, 但它自己没法验证画面是否被正确渲染。

在这种情形下,测试 ROM 仅用 ld b, b 来表示“已结束”。 真正的判定由运行集成测试的外部代码完成。 具体做法是给测试 ROM 配一个参考截图, 把模拟器的实际输出与参考图进行对比。

这类截图的颜色值也已有约定:

  • DMG 截图使用以下颜色:
    #000000, #555555, #AAAAAA, #FFFFFF
  • CGB 截图将 15 位 CGB 色(每个 R/G/B 通道 5 位)换算为 8 位:
    (X << 3) | (X >> 2)
  • “Non-CGB 模式”截图使用如下颜色:
    • BGP:#000000, #0063C6, #7BFF31, #FFFFFF
    • OBP:#000000, #943939, #FF8484, #FFFFFF

总结

  • 测试 ROM 非常适合做模拟器的集成测试: 它们直接验证被模拟硬件的行为,既能在模拟器上跑,也能在真机上跑。
  • 相比单元测试,它们在编写与运行上更费功夫, 但一旦实现,就能在各家模拟器间复用
  • 对于精确时序,汇编比 C 更合适
  • 通过测试框架可以减少样板代码,大幅提升大型测试套件的开发效率。
  • 借助 RGBDSWLA DXGBDK 等工具以及合适的卡带适配器 (例如 EZ-FLASH Junior), 可以从开发一路覆盖到在真机上测试的完整流程。