透過硬體測試 ROM 驗證 NES 模擬器精確度
AprNes 使用硬體驗證測試 ROM,這些 ROM 最初是為了驗證真實 NES 主機行為而編寫的。 它們是實際的 NES 程式(6502 機器碼),用於測試特定硬體功能並回報通過/失敗結果。 所有主流 NES 模擬器專案都使用相同的 ROM 來衡量精確度。
測試涵蓋所有主要 NES 子系統:
所有 ROM 來自 nes-test-roms 合集,主要作者包括:
這些 ROM 與 Mesen、Nestopia、FCEUX 及其他參考模擬器使用的完全相同。通過這些測試代表模擬已達到週期精確或接近週期精確的程度。
模擬器內建 TestRunner.cs,以無頭模式運行 — 沒有視窗、沒有音效、沒有幀率限制。CPU/PPU/APU 全部以最高速度運行。單個測試 ROM 通常在 1 秒內完成。
測試工作流使用兩個互補的腳本,測試清單完全一致(174 個 ROM):
run_tests.sh — 輕量驗證腳本。執行所有測試並將 PASS/FAIL 輸出到 stdout,附帶失敗摘要。用於開發過程中快速回歸檢查。run_tests_report.sh — 全功能報告產生器,透過命令列參數控制模組化輸出。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 編譯步驟
完整流程(啟用所有參數)執行以下步驟:
--no-build)--screenshots:擷取最終畫面為 PNG,轉換為無損 WebP--json:將結果收集為 report/results.json--json 和 --screenshots:產生包含嵌入資料和截圖參照的單一 HTML 報告檔案(report/index.html)現代 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」。
較舊的 blargg 測試(2005 年版)不使用 $6000 協定。它們將結果直接渲染到 PPU 名稱表。測試執行器透過多步驟啟發式方法處理這些情況:
"Passed" / "PASSED" → 通過"Failed" / "FAILED" → 失敗"$01"(畫面上的十六進位值)→ 通過"$02" ~ "$FF"(畫面上的十六進位值)→ 失敗"All tests complete" → 通過" 0/"(零錯誤計數)→ 通過此方法直接讀取 PPU 名稱表(而非對像素進行 OCR),因此快速且可靠。
部分測試 ROM 的結果取決於開機時隨機的 CPU-PPU 同步狀態,會產生多個有效 CRC 值之一。這些測試在畫面上顯示 CRC,但無法使用單一 check_crc 呼叫。--expected-crc 參數接受以逗號分隔的有效 CRC 清單:
--expected-crc "159A7A8F,5E3DF9C4"
測試執行器掃描 PPU 名稱表,尋找 8 個十六進位字元組成的字串(以非十六進位字元為邊界,避免部分匹配)。找到後與期望集合進行不區分大小寫的比對。此機制同時用於畫面穩定偵測路徑和逾時備援路徑。目前用於:
dma_2007_read — 2 個有效 CRC(取決於 CPU-PPU 同步)double_2007_read — 4 個有效 CRC(取決於 CPU-PPU 同步)部分測試 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 進入無限迴圈或當機,執行器會優雅地終止它並回報最後已知狀態。
無頭測試執行器透過模擬器執行檔直接呼叫:
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 = 逾時/無結果。
要採用此自動化 QA 工作流,模擬器必須開放一組設計介面。以下是基於 AprNes 架構的參考 — 此模式適用於任何 NES 模擬器,不限程式語言。
模擬器應從單一執行檔支援兩種運作模式。當有命令列參數時進入無頭測試模式;否則啟動正常 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),讓結束代碼可向呼叫腳本傳遞通過/失敗訊號。
模擬器核心需要靜態旗標,在測試模式下抑制 GUI 和音訊子系統:
| 旗標 | 用途 | 效果 |
|---|---|---|
HeadlessMode = true | 抑制視窗建立 | 不實例化 Form/Window;渲染仍然執行以填充幀緩衝區,但不產生顯示輸出 |
AudioEnabled = false | 抑制音訊輸出 | APU 仍然計時(時序測試需要),但不開啟音訊裝置 |
LimitFPS = false | 移除幀率限制器 | 模擬以最高 CPU 速度執行;30 秒的測試在不到 1 秒的實際時間內完成 |
exit = true | 通知主迴圈停止 | 由測試執行器在偵測到結果時設定;run() 迴圈每幀檢查此旗標 |
這些旗標讓 CPU/PPU/APU 繼續正常計時 — 只有 I/O 端點(螢幕、喇叭)被停用。這確保時序敏感的測試在無頭和 GUI 模式下產生相同的結果。
模擬器必須提供每幀渲染完畢後觸發的掛鈎。測試執行器訂閱此事件來輪詢結果:
// 在 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(重繪畫面)和無頭(檢查測試狀態)兩種模式。
測試執行器需要直接讀取兩個記憶體區域:
| 記憶體區域 | 存取方式 | 用途 |
|---|---|---|
NES_MEM[0x6000..0x6FFF] | CPU 位址空間(WRAM) | 讀取 $6000 狀態位元組和 $6004+ 結果文字(blargg 協定) |
ppu_ram[0x2000..0x23BF] | PPU 名稱表 0 | 掃描畫面上的 "Passed"/"Failed" 文字(較舊的測試 ROM) |
ScreenBuf1x[0..61439] | 已渲染幀緩衝區(256x240 ARGB) | 畫面穩定性雜湊 + 截圖擷取 |
這些必須以靜態指標或陣列方式公開 — 每幀不需複製。測試執行器在 VideoOutput 回呼內同步讀取它們,因此執行緒安全由幀邊界保證。
部分測試 ROM 透過寫入 $81 到 $6000 來要求主機重設。模擬器必須提供 SoftReset() 方法,重設 CPU/APU 狀態但不重新載入 ROM:
public static void SoftReset()
{
// 重設 CPU:從 $FFFC/$FFFD 讀取重設向量,清除暫存器
// 重設 APU:重新初始化影格計數器,靜音聲道
// 不完全重設 PPU(某些測試依賴 PPU 狀態在重設後存留)
// 不卸載 ROM,不重新初始化 mapper
}
這與硬重設(電源循環)不同。測試執行器在偵測到 $6000 == $81 後延遲 100ms(約 6 幀)呼叫 SoftReset()。
控制器測試需要程式化的按鈕操作。模擬器必須公開按下/釋放方法:
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)。
模擬器需要基於位元組陣列的簡單 ROM 載入介面:
public static bool init(byte[] rom_bytes); // 解析 iNES 標頭,設定 mapper,重設 CPU public static void run(); // 主模擬迴圈(阻塞直到 exit==true)
init() 在不支援的 mapper 或損壞標頭時回傳 false。run() 由測試執行器在背景執行緒上呼叫,透過 Thread.Join() 等待完成。
設計原則是最小耦合:測試執行器透過 8 個接觸點(3 個旗標、1 個事件、3 個記憶體區域、1 個重設方法)與模擬器核心互動。模擬器核心完全不需要了解測試執行器 — 它只需檢查 HeadlessMode 來跳過 GUI 建立,並在每幀觸發 VideoOutput。所有測試邏輯都在 TestRunner.cs 中。
| 介面 | 方向 | 類型 |
|---|---|---|
HeadlessMode | TestRunner → Core | 靜態布林旗標 |
AudioEnabled | TestRunner → Core | 靜態布林旗標 |
LimitFPS | TestRunner → Core | 靜態布林旗標 |
exit | TestRunner → Core | 靜態布林旗標 |
VideoOutput | Core → TestRunner | 事件(逐幀回呼) |
NES_MEM / ppu_ram / ScreenBuf1x | Core → TestRunner | 靜態記憶體指標(唯讀) |
SoftReset() | TestRunner → Core | 靜態方法 |
P1_ButtonPress/UnPress() | TestRunner → Core | 靜態方法 |
init(byte[]) / run() | TestRunner → Core | 靜態方法 |
此架構使 QA 系統具有可移植性:任何開放這些介面的 NES 模擬器都能使用相同的 bash 腳本和測試 ROM 合集進行自動化回歸測試,不受其內部實作方式限制。
AprNes 使用 Claude Code(Anthropic 的 CLI 代理工具)作為 AI 配對程式設計師,直接呼叫測試 shell 腳本、讀取失敗輸出、診斷根因、編輯原始碼並驗證修復 — 全部在一個迭代迴圈中完成。第 1-8 節描述的測試基礎建設是此流程得以運作的基石。
npm install -g @anthropic-ai/claude-code).claude/ 目錄中的 MEMORY.md 記錄架構、編譯指令和慣例,讓 AI 跨對話保留上下文run_tests.sh(快速驗證)和 run_tests_report.sh(含 JSON/截圖的完整報告)nes-test-roms-master/checked/ 包含所有測試套件開發者告訴 Claude Code 讀取 TODO.md 並繼續下個任務。Claude 選取最高優先權的未完成 bug,識別相關的失敗測試。
User: 閱讀 TODO.MD,繼續後面任務
Claude: [讀取 TODO.md,識別 "Bug G — Sprite timing" 為下個目標]
[跑 4 個失敗測試並截圖,記錄當前狀態]
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"
對於非簡單的修復,Claude 進入規劃模式(plan mode)— 一個唯讀狀態,在不修改任何檔案的情況下設計實作策略。規劃內容包括:
開發者審核並批准規劃後,才開始修改程式碼。
Claude 使用精確的文字替換編輯模擬器原始碼。每個變更都是有針對性且最小化的 — 只修改規劃中指定的內容。
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 讀取失敗輸出、調整修復、重新執行。這個內部迴圈重複直到所有目標測試通過且零回歸。
驗證通過後,Claude:
TODO.md — 將 bug 標記為已完成,更新基線數字bugfix/YYYY-MM-DD_BUGFIXNN.md — 詳細記錄問題、根因、修復和測試結果fix sprite timing: per-pixel hit + cycle-accurate overflow: 165 PASS / 9 FAIL (+4)開發者可隨時要求產生完整的 HTML 報告:
bash run_tests_report.sh --json --screenshots # → report/index.html(含截圖的互動式儀表板)
| 檔案 | 角色 |
|---|---|
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 | 產出的互動式測試報告,支援篩選和瀏覽 |
--wait-result 模式回傳機器可讀的 exit code(0=通過, 1=失敗),實現自動化驗證迴圈TODO.md 提供排序好的任務上下文;MEMORY.md 跨對話攜帶架構知識;bugfix/ 記錄每次變更背後的推理單次 Claude Code 對話從 161 PASS 進步到 165 PASS (+4),過程如下:
TODO.md → 識別 Bug G(4 個 sprite 測試失敗)總計:識別 3 個根因、實作 3 個修復、0 個回歸 — 全部在單次對話中完成。
一個跨多次對話的 Claude Code 工作流從 169 PASS 進步到 171 PASS (+2),解決了架構上最具挑戰性的 bug — DMC DMA cycle stealing:
TODO.md → 識別 Bug F(5 個 DMC DMA 測試失敗,標記為「需要架構重構」)ref/ 目錄)→ 發現 Load DMA 與 Reload DMA 的差異--expected-crc 參數支援 CRC-only 測試此修復需要理解 NES DMA 的 bus-cycle 層級行為:GET/PUT 節奏、halt 排程、write-delay 規則、phantom read /OE 連續性。剩餘 1 個 DMC 測試失敗(double_2007_read)被識別為獨立的 PPU buffer latch 時序問題,而非 DMA bug。