← 返回測試報告

AprNes 測試方法論

透過硬體測試 ROM 驗證 NES 模擬器精確度

174
測試 ROM
30+
測試套件
5
子系統
<3 分鐘
完整執行

1. 測試內容

AprNes 使用硬體驗證測試 ROM,這些 ROM 最初是為了驗證真實 NES 主機行為而編寫的。 它們是實際的 NES 程式(6502 機器碼),用於測試特定硬體功能並回報通過/失敗結果。 所有主流 NES 模擬器專案都使用相同的 ROM 來衡量精確度。

測試涵蓋所有主要 NES 子系統:

2. 測試 ROM 來源

所有 ROM 來自 nes-test-roms 合集,主要作者包括:

這些 ROM 與 Mesen、Nestopia、FCEUX 及其他參考模擬器使用的完全相同。通過這些測試代表模擬已達到週期精確或接近週期精確的程度。

3. 測試執行器架構

Bash 腳本
run_tests_report.sh
MSBuild
編譯模擬器
無頭模擬器
TestRunner.cs
結果偵測
$6000 / 畫面掃描
輸出
stdout / JSON / HTML

無頭模式

模擬器內建 TestRunner.cs,以無頭模式運行 — 沒有視窗、沒有音效、沒有幀率限制。CPU/PPU/APU 全部以最高速度運行。單個測試 ROM 通常在 1 秒內完成。

兩個腳本、兩種用途

測試工作流使用兩個互補的腳本,測試清單完全一致(174 個 ROM):

報告腳本模式

run_tests_report.sh 固定將 PASS/FAIL 結果輸出到 stdout。可選參數控制額外輸出:

bash run_tests_report.sh                        # 快速:僅 stdout
bash run_tests_report.sh --json                 # + 儲存 report/results.json
bash run_tests_report.sh --screenshots          # + 擷取截圖(PNG→WebP)
bash run_tests_report.sh --json --screenshots   # 完整:JSON + 截圖 + HTML 報告
bash run_tests_report.sh --no-build             # 跳過 MSBuild 編譯步驟

完整流程(啟用所有參數)執行以下步驟:

  1. 使用 MSBuild 編譯專案(除非指定 --no-build
  2. 將 174 個 ROM 檔案逐一透過無頭模擬器執行
  3. 將每個測試的 PASS/FAIL 輸出到 stdout
  4. 若指定 --screenshots:擷取最終畫面為 PNG,轉換為無損 WebP
  5. 若指定 --json:將結果收集為 report/results.json
  6. 若同時指定 --json--screenshots:產生包含嵌入資料和截圖參照的單一 HTML 報告檔案(report/index.html

4. 結果偵測機制

機制 A:$6000 記憶體協定

現代 blargg 測試 ROM 使用記憶體映射狀態協定。測試執行器每幀輪詢位址 $6000

$6000 值意義動作
$80測試進行中繼續等待
$81要求重設等待 100ms 後執行軟重設
$00測試通過以代碼 0 結束
$01-$7F測試失敗(錯誤代碼 N)以代碼 N 結束

結果文字從 $6004+ 讀取,為以 null 結尾的 ASCII 字串。這提供了詳細的錯誤訊息,例如「Flag first set too late」或「Length counter not clocked correctly」。

機制 B:畫面穩定偵測

較舊的 blargg 測試(2005 年版)不使用 $6000 協定。它們將結果直接渲染到 PPU 名稱表。測試執行器透過多步驟啟發式方法處理這些情況:

  1. 在 120 幀(約 2 秒)後,開始每幀取樣畫面緩衝區
  2. 計算幀緩衝區的雜湊值(每隔 37 個像素取樣以提高速度)
  3. 當雜湊值連續 90 幀(約 1.5 秒)保持一致時,畫面判定為「穩定」
  4. 掃描 PPU 名稱表(字元映射)尋找已知結果字串:
    • "Passed" / "PASSED" → 通過
    • "Failed" / "FAILED" → 失敗
    • "$01"(畫面上的十六進位值)→ 通過
    • "$02" ~ "$FF"(畫面上的十六進位值)→ 失敗
    • "All tests complete" → 通過
    • " 0/"(零錯誤計數)→ 通過

此方法直接讀取 PPU 名稱表(而非對像素進行 OCR),因此快速且可靠。

機制 C:CRC 比對

部分測試 ROM 的結果取決於開機時隨機的 CPU-PPU 同步狀態,會產生多個有效 CRC 值之一。這些測試在畫面上顯示 CRC,但無法使用單一 check_crc 呼叫。--expected-crc 參數接受以逗號分隔的有效 CRC 清單:

--expected-crc "159A7A8F,5E3DF9C4"

測試執行器掃描 PPU 名稱表,尋找 8 個十六進位字元組成的字串(以非十六進位字元為邊界,避免部分匹配)。找到後與期望集合進行不區分大小寫的比對。此機制同時用於畫面穩定偵測路徑和逾時備援路徑。目前用於:

5. 自動化功能

自動軟重設

部分測試 ROM 會寫入 $81$6000 以要求主機重設(測試開機/重設行為)。執行器偵測到此情況後,會在延遲 100ms 後自動執行軟重設,模擬使用者按下重設按鈕的操作。每個 ROM 最多支援 10 次連續重設。

模擬控制器輸入

控制器讀取測試需要實際的按鈕輸入。--input 參數可排程定時按鈕事件:

--input "A:2.0,B:4.0,Select:6.0,Start:8.0,Up:10.0,Down:12.0,Left:14.0,Right:16.0"

每個按鈕在指定時間(秒)被按下,並持續 10 幀(約 166ms)。這使得 read_joy3/test_buttons 等測試能驗證所有 8 個按鈕是否被正確依序偵測。

截圖擷取(可選)

啟用 --screenshots 時,每個測試的最終畫面會被擷取為 256x240 PNG,然後轉換為無損 WebP(通常縮小 60-80%)。截圖作為視覺佐證 — 許多測試 ROM 會在畫面上以文字顯示結果,確切展示通過或失敗的項目。進行快速回歸檢查時可跳過截圖以節省時間。

逾時安全機制

每個 ROM 有可設定的 --max-wait 逾時時間(預設 30 秒,合併/多子測試 ROM 為 120 秒)。若測試 ROM 進入無限迴圈或當機,執行器會優雅地終止它並回報最後已知狀態。

6. 測試套件涵蓋範圍

4apu_mixer — 聲道混音
6apu_reset — APU 開機/重設
9apu_test — APU 影格計數器
11blargg_apu_2005 — APU 時序
2blargg_cpu_test5 — CPU 指令
5blargg_ppu_tests — PPU 基礎
3branch_timing — 分支週期計數
1cpu_dummy_reads — 虛擬讀取週期
2cpu_dummy_writes — 虛擬寫入週期
2cpu_exec_space — 從 I/O 執行
6cpu_interrupts_v2 — NMI/IRQ 互動
2cpu_reset — CPU 重設行為
1cpu_timing_test6 — 指令時序
5dmc_dma_during_read — DMC DMA 衝突
5instr_misc — 雜項指令測試
17instr_test-v3 — 全部 6502 指令
18instr_test-v5 — 全部 6502 指令(v5)
3instr_timing — 指令週期時序
6mmc3_irq_tests — MMC3 IRQ 計數器
6mmc3_test — MMC3 行為
6mmc3_test_2 — MMC3 行為(v2)
11nes_instr_test — CPU 指令(替代版)
1oam_read — OAM 讀取行為
1ppu_open_bus — PPU 開放匯流排
1ppu_read_buffer — PPU 讀取緩衝區
11ppu_vbl_nmi — VBlank/NMI 時序
4read_joy3 — 控制器讀取
2sprdma_and_dmc_dma — DMA 衝突
11sprite_hit_tests — 精靈 0 命中
5sprite_overflow — 精靈溢位
7vbl_nmi_timing — VBL/NMI 時序

7. 命令列介面

無頭測試執行器透過模擬器執行檔直接呼叫:

AprNes.exe --rom <file.nes> [選項]
選項說明
--rom <path>要載入的 ROM 檔案(必填)
--wait-result監控 $6000 / 畫面以偵測測試結果
--max-wait <sec>逾時秒數(預設:30)
--time <sec>精確執行 N 秒後停止
--screenshot <path>將最終畫面儲存為 PNG
--log <path>將結果寫入檔案
--soft-reset <sec>在第 N 秒觸發軟重設
--input <spec>排程按鈕輸入(例如 "A:2.0,B:4.0")
--expected-crc <list>以逗號分隔的有效 CRC 清單,用於 CRC-only 測試(例如 "159A7A8F,5E3DF9C4")
--debug-log <path>寫入 CPU 追蹤日誌

結束代碼:0 = 通過、1-127 = 失敗(測試錯誤代碼)、255 = 逾時/無結果。

8. 模擬器支援 QA 流程的設計介面

要採用此自動化 QA 工作流,模擬器必須開放一組設計介面。以下是基於 AprNes 架構的參考 — 此模式適用於任何 NES 模擬器,不限程式語言。

8.1 雙重進入點:GUI 與無頭模式

模擬器應從單一執行檔支援兩種運作模式。當有命令列參數時進入無頭測試模式;否則啟動正常 GUI。

// Program.cs — 進入點
static int Main(string[] args)
{
    if (args.Length > 0)
        return TestRunner.Run(args);    // 無頭模式,回傳結束代碼

    Application.Run(new MainForm());    // 正常 GUI 模式
    return 0;
}

重點:Main() 回傳 int(非 void),讓結束代碼可向呼叫腳本傳遞通過/失敗訊號。

8.2 無頭模式旗標

模擬器核心需要靜態旗標,在測試模式下抑制 GUI 和音訊子系統:

旗標用途效果
HeadlessMode = true抑制視窗建立不實例化 Form/Window;渲染仍然執行以填充幀緩衝區,但不產生顯示輸出
AudioEnabled = false抑制音訊輸出APU 仍然計時(時序測試需要),但不開啟音訊裝置
LimitFPS = false移除幀率限制器模擬以最高 CPU 速度執行;30 秒的測試在不到 1 秒的實際時間內完成
exit = true通知主迴圈停止由測試執行器在偵測到結果時設定;run() 迴圈每幀檢查此旗標

這些旗標讓 CPU/PPU/APU 繼續正常計時 — 只有 I/O 端點(螢幕、喇叭)被停用。這確保時序敏感的測試在無頭和 GUI 模式下產生相同的結果。

8.3 逐幀回呼(VideoOutput 事件)

模擬器必須提供每幀渲染完畢後觸發的掛鈎。測試執行器訂閱此事件來輪詢結果:

// 在 NesCore(模擬器核心)中:
public static event EventHandler VideoOutput;

// 在每個 PPU 幀結束時觸發(掃描線 240,VBlank 開始後):
VideoOutput?.Invoke(null, null);

// 在 TestRunner 中:
NesCore.VideoOutput += (sender, e) => {
    frameCount++;
    byte status = NesCore.NES_MEM[0x6000];  // 輪詢測試協定
    // ... 偵測結果,完成時設定 NesCore.exit = true
};

此回呼驅動的設計避免了緊密輪詢迴圈,並能整合 GUI(重繪畫面)和無頭(檢查測試狀態)兩種模式。

8.4 記憶體和 PPU RAM 存取

測試執行器需要直接讀取兩個記憶體區域:

記憶體區域存取方式用途
NES_MEM[0x6000..0x6FFF]CPU 位址空間(WRAM)讀取 $6000 狀態位元組和 $6004+ 結果文字(blargg 協定)
ppu_ram[0x2000..0x23BF]PPU 名稱表 0掃描畫面上的 "Passed"/"Failed" 文字(較舊的測試 ROM)
ScreenBuf1x[0..61439]已渲染幀緩衝區(256x240 ARGB)畫面穩定性雜湊 + 截圖擷取

這些必須以靜態指標或陣列方式公開 — 每幀不需複製。測試執行器在 VideoOutput 回呼內同步讀取它們,因此執行緒安全由幀邊界保證。

8.5 軟重設 API

部分測試 ROM 透過寫入 $81$6000 來要求主機重設。模擬器必須提供 SoftReset() 方法,重設 CPU/APU 狀態但不重新載入 ROM:

public static void SoftReset()
{
    // 重設 CPU:從 $FFFC/$FFFD 讀取重設向量,清除暫存器
    // 重設 APU:重新初始化影格計數器,靜音聲道
    // 不完全重設 PPU(某些測試依賴 PPU 狀態在重設後存留)
    // 不卸載 ROM,不重新初始化 mapper
}

這與硬重設(電源循環)不同。測試執行器在偵測到 $6000 == $81 後延遲 100ms(約 6 幀)呼叫 SoftReset()

8.6 控制器輸入注入

控制器測試需要程式化的按鈕操作。模擬器必須公開按下/釋放方法:

public static void P1_ButtonPress(byte buttonIndex);   // 0=A,1=B,2=Sel,3=Start,4=Up,5=Down,6=Left,7=Right
public static void P1_ButtonUnPress(byte buttonIndex);

測試執行器依幀號排程事件。每個按鈕在指定的幀按下,並在可設定的持續時間後釋放(預設 10 幀 ≈ 166ms)。

8.7 ROM 載入 API

模擬器需要基於位元組陣列的簡單 ROM 載入介面:

public static bool init(byte[] rom_bytes);  // 解析 iNES 標頭,設定 mapper,重設 CPU
public static void run();                    // 主模擬迴圈(阻塞直到 exit==true)

init() 在不支援的 mapper 或損壞標頭時回傳 falserun() 由測試執行器在背景執行緒上呼叫,透過 Thread.Join() 等待完成。

8.8 架構總覽

Program.cs
進入點
GUI / 無頭分流
TestRunner.cs
參數解析
逐幀回呼
結果偵測
NesCore
init() / run()
HeadlessMode 旗標
VideoOutput 事件

設計原則是最小耦合:測試執行器透過 8 個接觸點(3 個旗標、1 個事件、3 個記憶體區域、1 個重設方法)與模擬器核心互動。模擬器核心完全不需要了解測試執行器 — 它只需檢查 HeadlessMode 來跳過 GUI 建立,並在每幀觸發 VideoOutput。所有測試邏輯都在 TestRunner.cs 中。

介面方向類型
HeadlessModeTestRunner → Core靜態布林旗標
AudioEnabledTestRunner → Core靜態布林旗標
LimitFPSTestRunner → Core靜態布林旗標
exitTestRunner → Core靜態布林旗標
VideoOutputCore → TestRunner事件(逐幀回呼)
NES_MEM / ppu_ram / ScreenBuf1xCore → TestRunner靜態記憶體指標(唯讀)
SoftReset()TestRunner → Core靜態方法
P1_ButtonPress/UnPress()TestRunner → Core靜態方法
init(byte[]) / run()TestRunner → Core靜態方法

此架構使 QA 系統具有可移植性:任何開放這些介面的 NES 模擬器都能使用相同的 bash 腳本和測試 ROM 合集進行自動化回歸測試,不受其內部實作方式限制。

9. 以 Claude Code 驅動的 AI 輔助開發流程

9.1 概述

AprNes 使用 Claude Code(Anthropic 的 CLI 代理工具)作為 AI 配對程式設計師,直接呼叫測試 shell 腳本、讀取失敗輸出、診斷根因、編輯原始碼並驗證修復 — 全部在一個迭代迴圈中完成。第 1-8 節描述的測試基礎建設是此流程得以運作的基石。

TODO.md
選取下個 bug
分析
跑失敗測試
讀測試 ROM 原始碼
規劃
根因 + 修復設計
實作
編輯模擬器程式碼
驗證
編譯 & 跑測試
記錄
bugfix/ + TODO.md
git commit & push

9.2 前置條件

9.3 逐步流程

階段 1:任務選取

開發者告訴 Claude Code 讀取 TODO.md 並繼續下個任務。Claude 選取最高優先權的未完成 bug,識別相關的失敗測試。

User: 閱讀 TODO.MD,繼續後面任務
Claude: [讀取 TODO.md,識別 "Bug G — Sprite timing" 為下個目標]
        [跑 4 個失敗測試並截圖,記錄當前狀態]

階段 2:根因分析

Claude 利用測試基礎建設來診斷 bug:

# Claude 逐個跑失敗測試,看精確的錯誤訊息
./AprNes/bin/Debug/AprNes.exe --wait-result --max-wait 30 \
  --rom nes-test-roms-master/checked/sprite_hit_tests_2005.10.05/09.timing_basics.nes
# → FAIL #3: "upper-left corner too late"

階段 3:規劃模式

對於非簡單的修復,Claude 進入規劃模式(plan mode)— 一個唯讀狀態,在不修改任何檔案的情況下設計實作策略。規劃內容包括:

開發者審核並批准規劃後,才開始修改程式碼。

階段 4:實作

Claude 使用精確的文字替換編輯模擬器原始碼。每個變更都是有針對性且最小化的 — 只修改規劃中指定的內容。

階段 5:編譯與驗證

Claude 直接呼叫編譯工具鏈和測試腳本:

# 1. 編譯
powershell -NoProfile -Command "MSBuild.exe AprNes.sln /p:Configuration=Debug /t:Rebuild"

# 2. 跑目標測試(我們要修的那些)
./AprNes/bin/Debug/AprNes.exe --wait-result --max-wait 30 \
  --rom nes-test-roms-master/checked/sprite_hit_tests_2005.10.05/09.timing_basics.nes

# 3. 跑回歸套件(可能受影響的相關測試)
for rom in 01.basics.nes 02.alignment.nes ... 11.edge_timing.nes; do
  ./AprNes/bin/Debug/AprNes.exe --wait-result --max-wait 30 \
    --rom "nes-test-roms-master/checked/sprite_hit_tests_2005.10.05/$rom"
done

# 4. 完整回歸(全部 174 個測試)
bash run_tests.sh

如果有測試意外失敗,Claude 讀取失敗輸出、調整修復、重新執行。這個內部迴圈重複直到所有目標測試通過且零回歸。

階段 6:記錄與提交

驗證通過後,Claude:

階段 7:報告產生(按需)

開發者可隨時要求產生完整的 HTML 報告:

bash run_tests_report.sh --json --screenshots
# → report/index.html(含截圖的互動式儀表板)

9.4 流程中的關鍵檔案

檔案角色
TODO.md按優先權排序的 bug 清單 — 驅動每次工作的任務佇列
.claude/memory/MEMORY.md持久化 AI 記憶:架構、編譯指令、慣例
bugfix/YYYY-MM-DD_BUGFIXNN.md逐次修復的文件:問題、根因、變更、驗證
run_tests.sh快速驗證 — 跑全部 174 個測試,輸出 PASS/FAIL 計數
run_tests_report.sh完整報告 — JSON 資料 + 截圖 + HTML 儀表板
report/index.html產出的互動式測試報告,支援篩選和瀏覽

9.5 為何此流程有效

9.6 實際案例:BUGFIX17(Sprite 時序)

單次 Claude Code 對話從 161 PASS 進步到 165 PASS (+4),過程如下:

  1. 讀取 TODO.md → 識別 Bug G(4 個 sprite 測試失敗)
  2. 逐個跑失敗測試 → 擷取精確錯誤訊息(#3 "too late"、#5 "set too late"、#2 "byte offset bug")
  3. 閱讀測試 ROM 組語原始碼(09.timing_basics.asm、3.Timing.a、4.Obscure.a)→ 理解期望的 cycle 計數
  4. 閱讀 PPU.cs → 發現 phase 7 批次渲染(7-dot 延遲)和 cycle-257 overflow 偵測
  5. 進入規劃模式 → 設計 3 個修復:逐像素 hit 偵測、cycle 精確 overflow、硬體 overflow bug
  6. 在 PPU.cs 中實作全部 3 個修復(+120 行,-40 行)
  7. 編譯 → 跑 4 個目標測試(全 PASS)→ 跑 sprite 回歸(14 測試,全 PASS)→ 跑完整套件(165 PASS / 9 FAIL)
  8. 更新 TODO.md、建立 BUGFIX17.md、commit 並 push

總計:識別 3 個根因、實作 3 個修復、0 個回歸 — 全部在單次對話中完成。

9.7 實際案例:BUGFIX19(DMC DMA Cycle Stealing)

一個跨多次對話的 Claude Code 工作流從 169 PASS 進步到 171 PASS (+2),解決了架構上最具挑戰性的 bug — DMC DMA cycle stealing:

  1. 讀取 TODO.md → 識別 Bug F(5 個 DMC DMA 測試失敗,標記為「需要架構重構」)
  2. 研究 NESdev Wiki DMA 參考文件(來自 ref/ 目錄)→ 發現 Load DMA 與 Reload DMA 的差異
  3. 閱讀測試 ROM 組語原始碼(sync_dmc.s、sprdma_and_dmc_dma.s、dma_2007_read.s、dma_4016_read.s)
  4. 進入規劃模式 → 設計 5 部分修復:CPU bus state tracking、PPU-only stolen tick、Load/Reload cycle model、phantom reads、OAM DMA bus tracking
  5. 橫跨 3 個核心檔案實作(MEM.cs、APU.cs、PPU.cs)→ +200 行 cycle-accurate DMA 模擬
  6. 發現 2 個測試(dma_2007_read、double_2007_read)產生正確 CRC 但從不印 "Passed" → 在 TestRunner.cs 新增 --expected-crc 參數支援 CRC-only 測試
  7. 迭代除錯:基於 parity 的模型導致 sync_dmc 收斂失敗 → 替換為基於 Load/Reload 類型的模型
  8. 完整回歸:171 PASS / 3 FAIL,0 個回歸

此修復需要理解 NES DMA 的 bus-cycle 層級行為:GET/PUT 節奏、halt 排程、write-delay 規則、phantom read /OE 連續性。剩餘 1 個 DMC 測試失敗(double_2007_read)被識別為獨立的 PPU buffer latch 時序問題,而非 DMA bug。