[CS講座 #12] テスト駆動エミュレーター開発 — ZEXDOC全通へのデバッグ格闘記
はじめに
第1回から第11回までで、レジスタ、バス、命令サイクル、割り込み、バンク切り替え、HLE、クロック同期、画面、音、複数CPUと、エミュレーターを組み立てる部品はすべて揃いました。
しかし、部品が揃っても、ひとつ答えられていない問いが残っています。
「このCPUコアは、本当に正しいのか?」
ゲームが起動した。キャラクターが動いた。音も鳴った。
それでも、どこか1命令のフラグが1ビットずれているだけで、あるゲームの3面のボス戦だけが進まない、ということが普通に起こります。そして、その原因を画面から逆算するのはほぼ不可能です。
最終回の第12回は、エミュレーター開発における「テスト」の話です。
Z80エミュレーター界の登竜門である命令エクササイザ ZEXDOC を中心に、正しさをどう定義し、どう確かめ、どう守り続けるのかを解き明かします。
前回の記事
[CS講座 #11] マルチCPUエミュレーション — 複数のZ80を同期させて動かす(アーケード基板の世界) // PROTOCOL.LAIN
並列(Parallel)と並行(Concurrent)の違い、マスター/スレーブ構成、クロックの異なるCPUを共通時間軸で進めるスケジューラー、クォンタムと同期誤差の関係、共有RAMのハンドシェイク問題、サウンドラッチとNMIによるコマンド通信、キャッチアップ同期とインタリーブの一時増加をJavaScriptコード付きで徹底解説。
lain-lab.comZ80 EMULATOR PROJECT
Z80 EMULATOR PROJECT
フルスクラッチ JavaScript Z80 コアと、その上に構築されたレトロシステムエミュレーター群
lain-lab.com6502 EMULATOR PROJECT
6502 EMULATOR PROJECT
フルスクラッチ JavaScript MOS 6502 コアと、その上に構築されたレトロシステムエミュレーター群
lain-lab.com1. 「正解」はどこにあるのか:テストオラクル問題
ソフトウェアテストでは、「この入力に対する正しい出力はこれだ」と判定してくれるものをテストオラクル(Test Oracle)と呼びます。普通のアプリなら、仕様書やテストを書く人の頭の中がオラクルです。
エミュレーターの場合、オラクルは1つしかありません。実機です。
仕様書は「実機の説明書」に過ぎない
Zilog が公開した Z80 のマニュアルは、実機の動作を完全には書いていません。
- ドキュメント化されていないフラグ: Fレジスタのビット3とビット5(第1回の図の
XとY)は「未使用」扱いですが、実機では演算結果に応じて毎回きちんと変化します。 - ドキュメント化されていない命令:
DDプレフィックスでH/LをIXH/IXLとして扱う命令や、CB 30〜CB 37(通称SLL)などは、マニュアルに載っていないのに実機では動きます。そして、実際のゲームはそれを使っています。 - 「不定」と書かれた結果: 例えば
BIT命令の S フラグと P/V フラグは、マニュアル上は結果が不定とされていますが、実機では決まった値になります。
つまり、マニュアルどおりに実装しても、実機どおりにはなりません。「仕様書駆動開発」といっても、エミュレーターの場合の仕様書とは、マニュアルではなく実機の振る舞いそのものです。
ここで問題になるのが、「実機の振る舞いを、手元に実機がない状態でどう知るか」です。その答えが、実機で記録された答えを内蔵したテストプログラムです。
2. テストの3階層
エミュレーターのテストは、大きく3つの階層に分けられます。
▲ 見つけにくいバグ / 原因特定が難しい
│
┌───────────────────────┐
│ 3. 実ソフト(ゲームROM) │ 動けばOK。どこが悪いかは教えてくれない
├───────────────────────┤
│ 2. 命令エクササイザ │ ZEXDOC / ZEXALL
│ │ 命令グループ単位で合否。CPUコアの総合試験
├───────────────────────┤
│ 1. 命令単体テスト │ SingleStepTests など
│ │ 1命令 × 入力1ケースで比較。どこがずれたか即わかる
└───────────────────────┘
│
▼ 実行が速い / 原因特定が簡単
下に行くほど細かく、速く、原因がわかりやすい。上に行くほど実際の使われ方に近いけれど、失敗したときに何が悪いのか教えてくれません。
ゲームが動かないからといって、いきなりゲームのコードをトレースするのは最後の手段です。まずは下の階層で潰せるバグを全部潰す。これがテスト駆動でエミュレーターを作るときの基本方針です。
3. ZEXDOCの仕組み
ZEXDOC(および兄弟の ZEXALL)は、1994年に Frank Cringle が書いた Z80 命令エクササイザ(Instruction Exerciser)です。CP/M 用の実行ファイル(.COM)として配布されていて、Z80 エミュレーターを作った人がほぼ必ず通る関門になっています。
ZEXDOC と ZEXALL の違い
| 検査するフラグ | 意味 | |
|---|---|---|
| ZEXDOC | ドキュメント化されたフラグのみ(ビット3・5を無視) | マニュアルどおりに作れば通る範囲 |
| ZEXALL | すべてのフラグ(X/Y含む) | 実機と完全に一致しないと通らない |
どちらも全67項目のテストで構成されています。まず ZEXDOC を全部通し、余力があれば ZEXALL に進むのが定番の順番です。
何をどう検査しているのか
1つのテスト項目は、「似た命令のグループ」を対象にします。例えば add hl,<bc,de,hl,sp> は、ADD HL,BC / ADD HL,DE / ADD HL,HL / ADD HL,SP の4命令をまとめて検査します。
各項目は、次の3つのデータを持っています。
- 基本状態(base): 命令バイト列、メモリオペランド、全レジスタとフラグの初期値
- 増分マスク(increment): 総当たりで変化させるビット
- シフトマスク(shift): 1ビットずつ順番に立てていくビット
増分マスクのビットはすべての組み合わせを、シフトマスクのビットは1つずつを試すため、1項目あたりの実行ケース数はおおよそ次のようになります。
命令を1ケース実行するたびに、実行後のレジスタ・フラグ・メモリオペランドを CRC-32 に流し込んでいきます。最後に得られた CRC を、実機の Z80 で同じテストを走らせて記録しておいた値と比べる。一致すれば OK、1ビットでもずれていれば ERROR です。
Z80doc instruction exerciser
<adc,sbc> hl,<bc,de,hl,sp>.... OK
add hl,<bc,de,hl,sp>.......... OK
add ix,<bc,de,ix,sp>.......... OK
...
aluop a,nn.................... ERROR **** crc expected:XXXXXXXX found:YYYYYYYY
...
Tests complete
ZEXDOCの長所と弱点
長所は、「実機の答え」を CRC という数十バイトに圧縮して持ち歩いていることです。手元に実機がなくても、実機との一致を確かめられます。
弱点は、CRC は合否しか教えてくれないことです。何万ケースのうち、どの入力で、どのレジスタの、どのビットがずれたのかは一切わかりません。ERROR の1行を前にして途方に暮れる。ここが「格闘記」になる理由です。
また、実行量も膨大です。全項目を実機の 3.58MHz で回すと数時間かかる規模で、JavaScript のエミュレーターでも数分単位の時間がかかります。
4. CP/Mのふりをする:テストハーネスの実装
ZEXDOC は CP/M のプログラムなので、そのままでは動きません。とはいえ、CP/M を丸ごとエミュレートする必要はありません。ZEXDOC が OS に求めているのは、次の3つだけです。
0x0100番地にプログラムが読み込まれていること(CP/M の TPA 先頭)0x0005番地の BDOS(OSのシステムコール)で文字が出力できること0x0006〜0x0007番地にメモリの上端アドレスが入っていること(ZEXDOC はここからスタックポインタを決める)
BDOS の機能も、2つしか使いません。
| C レジスタ | 機能 | 引数 |
|---|---|---|
| 2 | 1文字出力 | E レジスタの文字 |
| 9 | 文字列出力 | DE が指すアドレスから $ の手前まで |
これは第7回で扱った HLE トラップそのものです。PC が 0x0005 に来た瞬間に JavaScript 側で文字を出力し、そこに置いた RET 命令で呼び出し元に戻します。プログラムが 0x0000(CP/M のウォームブート)にジャンプしてきたら、テスト終了です。
テストはブラウザではなく Node.js で走らせます。画面も音も要らず、そのほうが速く、CI にも載せられるからです。
ZEXDOCハーネスの実装コード
// core/tests/zexdoc-runner.js
import { readFileSync } from 'node:fs';
import { Z80Core } from '../z80.js';
const mem = new Uint8Array(0x10000);
const com = readFileSync(process.argv[2] ?? 'zexdoc.com');
mem.set(com, 0x0100); // 1. TPA先頭に読み込む
mem[0x0005] = 0xc9; // 2. BDOSエントリ = RET(処理はJS側で代行)
mem[0x0006] = 0x00; // 3. (0x0006) = 0xF000
mem[0x0007] = 0xf0; // ZEXDOC は LD HL,(6) → LD SP,HL で使う
const cpu = new Z80Core({
readByte: (addr) => mem[addr],
writeByte: (addr, val) => { mem[addr] = val; },
inPort: () => 0xff,
outPort: () => {},
});
cpu.pc = 0x0100;
let log = '';
const print = (ch) => { process.stdout.write(ch); log += ch; };
// BDOS コールの代行(HLEトラップ)
function bdos() {
if (cpu.c === 2) {
print(String.fromCharCode(cpu.e));
} else if (cpu.c === 9) {
let addr = (cpu.d << 8) | cpu.e;
while (mem[addr] !== 0x24) { // '$' で終端
print(String.fromCharCode(mem[addr]));
addr = (addr + 1) & 0xffff;
}
}
}
const started = performance.now();
let instructions = 0;
for (;;) {
if (cpu.pc === 0x0005) bdos(); // 出力だけJSで行い、RET は CPU に実行させる
if (cpu.pc === 0x0000) break; // ウォームブート = テスト終了
cpu.step();
instructions++;
}
const sec = ((performance.now() - started) / 1000).toFixed(1);
const errors = (log.match(/ERROR/g) ?? []).length;
console.log(`\n${instructions.toLocaleString()} instructions / ${sec}s / errors: ${errors}`);
process.exitCode = errors ? 1 : 0; // CI 向けに終了コードで合否を返す
BDOS を JavaScript で代行しつつ、戻る処理は実際の RET 命令に任せているのがポイントです。スタック操作まで JavaScript で真似するより単純で、しかも RET 命令自体のテストにもなります。
5. ZEXDOCで落ちやすい場所
最初の実行で全項目 OK になることは、まずありません。そして、落ちる場所にはかなり共通の傾向があります。
| テスト項目 | よくある原因 |
|---|---|
<daa,cpl,scf,ccf> | DAA の補正ロジックと H フラグ。CCF で H に旧キャリーを入れ忘れる |
add hl,<bc,de,hl,sp> | 16ビット加算のハーフキャリーはビット3ではなくビット11から |
<adc,sbc> hl,<bc,de,hl,sp> | S/Z を16ビットの結果で判定していない、V フラグの判定漏れ |
bit n,<b,c,d,e,h,l,(hl),a> | マニュアルで「不定」の S と P/V を実機どおりに立てていない |
<inc,dec> <b,c,d,e,h,l,(hl),a> | C フラグを変化させてしまう(INC/DEC は C を保持する) |
<rlca,rrca,rla,rra> | CB 版のシフトと同じく S/Z/P を更新してしまう(こちらは保持) |
cpi<r> / cpd<r> | P/V が「BC≠0」、C は保持、という特殊なフラグ規則 |
shf/rot (<ix,iy>+1) | DD CB d op のバイト順(変位が命令コードより先に来る) |
ld <h,l>,(<ix,iy>+1) | DD 付きでも (IX+d) を使う命令では H/L は IXH/IXL にならない |
いくつかを実装コードで見てみます。フラグ定数は第1回で定義したもの(FLAG_C = 0x01 〜 FLAG_S = 0x80)を使います。
DAA:最大の難所
DAA(Decimal Adjust Accumulator)は、2進数の加減算の結果を BCD(2進化10進数)に補正する命令です。直前の演算が加算か減算か(N フラグ)、下位4ビットからの繰り上がりがあったか(H フラグ)で補正値が変わり、H フラグ自体の更新規則も加算と減算で違います。
// PARITY[v]: 1のビット数が偶数なら FLAG_PV を返すテーブル
daa() {
const a = this.a;
const n = this.f & FLAG_N;
const h = this.f & FLAG_H;
let carry = this.f & FLAG_C;
let diff = 0;
if (h || (a & 0x0f) > 9) diff |= 0x06;
if (carry || a > 0x99) { diff |= 0x60; carry = FLAG_C; }
const res = (n ? a - diff : a + diff) & 0xff;
// H フラグは加算と減算で規則が違う
const halfCarry = n ? (h && (a & 0x0f) < 6) : (a & 0x0f) > 9;
this.a = res;
this.f = (res & 0x80) // S
| (res === 0 ? FLAG_Z : 0) // Z
| (res & 0x28) // X/Y(ZEXALL向け)
| (halfCarry ? FLAG_H : 0) // H
| PARITY[res] // P/V はパリティ
| n // N は保持
| carry; // C
}
「0x99 を超えたら 0x60 を足す」という補正値の判定は元の A で行い、補正後の値では行わない。ここを取り違えるだけで ZEXDOC は落ちます。
BIT n,r:「不定」の中身
bitTest(n, value) {
const r = value & (1 << n);
let f = (this.f & FLAG_C) | FLAG_H; // C は保持、H は常に1、N は0
if (r === 0) f |= FLAG_Z | FLAG_PV; // 実機では P/V は Z と同じ値
if (r & 0x80) f |= FLAG_S; // n=7 でビットが立っていれば S
f |= value & 0x28; // X/Y(r 版はオペランドから。ZEXALL のみ検査)
this.f = f;
}
ZEXDOC が無視するのはビット3と5だけです。つまり、マニュアルで「不定」とされている S と P/V は ZEXDOC でも検査されます。「ドキュメント化されたフラグだけ」という ZEXDOC の説明を信じてマニュアルどおりに作ると、ここで落ちます。
16ビット加算のハーフキャリー
addHL(value) {
const hl = (this.h << 8) | this.l;
const res = hl + value;
let f = this.f & (FLAG_S | FLAG_Z | FLAG_PV); // S/Z/PV は変化しない
if (((hl & 0x0fff) + (value & 0x0fff)) > 0x0fff) f |= FLAG_H; // ビット11からの繰り上がり
if (res > 0xffff) f |= FLAG_C;
f |= (res >> 8) & 0x28; // X/Y は結果の上位バイトから
this.h = (res >> 8) & 0xff;
this.l = res & 0xff;
this.f = f; // N は0
}
8ビット加算の H フラグ(ビット3からの繰り上がり)をそのまま流用すると、16ビット版では間違いになります。
DD CB d op:バイト順の罠
第4回で扱ったプレフィックスの中で、DD CB / FD CB だけは特殊です。変位 d が、最後の命令コードより先に来ます。
// DD CB d op : (IX+d) に対するビット操作・シフト
this.ddTable[0xcb] = () => {
const d = (this.fetch8() << 24) >> 24; // 先に変位(符号付き8ビットに拡張)
const op = this.fetch8(); // 命令コードは最後
const addr = (this.ix + d) & 0xffff;
return this.executeIndexedCB(op, addr);
};
通常の CB プレフィックスと同じ感覚で「命令コード → 変位」の順に読むと、別の命令として解釈されてしまいます。
6. CRCの向こう側を見る:差分デバッグ
ZEXDOC が ERROR を出したとき、どの命令グループがおかしいかはわかります。しかし、何万ケースのうちどれがずれたかはわかりません。ここで第2節の階層を1つ下りて、命令単体のテストに切り替えます。
SingleStepTests:1命令×1ケースの答え合わせ
SingleStepTests(旧 Tom Harte の ProcessorTests)は、Z80 を含む多くの CPU について、「実行前の状態」と「1命令実行後の状態」の組を大量に収録したテストデータ集です。命令コードごとに JSON ファイルが分かれていて、1ファイルに1,000ケースほどが入っています。
1ケースの形は、おおよそ次のとおりです(抜粋・簡略化)。
{
"name": "80 0000",
"initial": {
"pc": 19935, "sp": 59438,
"a": 110, "f": 17, "b": 185, "c": 44, "d": 12, "e": 200, "h": 91, "l": 3,
"ix": 3112, "iy": 51200,
"ram": [[19935, 128]]
},
"final": {
"pc": 19936, "sp": 59438,
"a": 39, "f": 49, "b": 185, "c": 44, "d": 12, "e": 200, "h": 91, "l": 3,
"ix": 3112, "iy": 51200,
"ram": [[19935, 128]]
}
}
実際のデータには、裏レジスタ、I/R、割り込みフラグ、内部レジスタ(WZ など)、バスサイクルの記録まで含まれています。ZEXDOC と違い、「どのケースで」「どのレジスタが」「期待値いくつに対して実際いくつだったか」がそのままわかります。
命令単体テストのランナー
// core/tests/sst-runner.js
import { readFileSync, readdirSync } from 'node:fs';
import { Z80Core } from '../z80.js';
const REGS = ['a', 'b', 'c', 'd', 'e', 'h', 'l', 'sp', 'pc', 'ix', 'iy'];
const FLAG_NAMES = ['C', 'N', 'P/V', 'X', 'H', 'Y', 'Z', 'S'];
// ZEXDOC 相当なら X/Y を無視して 0xd7 でマスクする
const FLAG_MASK = process.argv.includes('--all-flags') ? 0xff : 0xd7;
const mem = new Uint8Array(0x10000);
const cpu = new Z80Core({
readByte: (addr) => mem[addr],
writeByte: (addr, val) => { mem[addr] = val; },
inPort: () => 0xff,
outPort: () => {},
});
const hex = (v, w = 2) => v.toString(16).padStart(w, '0');
// フラグの差分をビット名で表示する
function flagDiff(expected, actual) {
const bits = [];
for (let i = 0; i < 8; i++) {
const mask = 1 << i;
if ((expected & mask) !== (actual & mask) && (FLAG_MASK & mask)) {
bits.push(`${FLAG_NAMES[i]}=${(expected & mask) ? 1 : 0}→${(actual & mask) ? 1 : 0}`);
}
}
return bits.join(' ');
}
function runCase(t) {
for (const r of REGS) cpu[r] = t.initial[r];
cpu.f = t.initial.f;
for (const [addr, val] of t.initial.ram) mem[addr] = val;
cpu.step();
const diffs = [];
for (const r of REGS) {
if (cpu[r] !== t.final[r]) diffs.push(`${r}: expected ${hex(t.final[r])} got ${hex(cpu[r])}`);
}
if ((cpu.f & FLAG_MASK) !== (t.final.f & FLAG_MASK)) {
diffs.push(`f: ${flagDiff(t.final.f, cpu.f)} (期待値→実際)`);
}
for (const [addr, val] of t.final.ram) {
if (mem[addr] !== val) diffs.push(`[${hex(addr, 4)}]: expected ${hex(val)} got ${hex(mem[addr])}`);
}
return diffs;
}
const dir = process.argv[2];
let failed = 0;
for (const file of readdirSync(dir).filter((f) => f.endsWith('.json'))) {
const tests = JSON.parse(readFileSync(`${dir}/${file}`, 'utf8'));
for (const t of tests) {
const diffs = runCase(t);
if (diffs.length) {
failed++;
console.log(`✗ ${t.name}\n ${diffs.join('\n ')}`);
break; // 1命令につき最初の失敗だけ表示
}
}
}
console.log(`\nfailed opcodes: ${failed}`);
process.exitCode = failed ? 1 : 0;
失敗すると、こんな出力になります。
✗ 27 0412
f: H=0→1 (期待値→実際)
「0x27(DAA)の412番目のケースで、H フラグが期待値0なのに1になった」。ZEXDOC の ERROR 1行と比べると、情報量がまるで違います。初期状態の A とフラグを見れば、「減算後の補正で H の規則を加算と同じにしていた」といった原因まで一気にたどり着けます。
最初は比較対象を ZEXDOC と同じ範囲(X/Y を除いたフラグと主要レジスタ)に絞り、R レジスタや WZ などの内部レジスタは外しておくと、本当に重要なずれから順に潰せます。
トレース比較:最初の分岐点を探す
命令単体テストがすべて通っているのに、特定のソフトだけおかしいこともあります。そのときに使うのが、信頼できる別のエミュレーターとのトレース比較です。
[自作コア] [参照エミュレーター]
PC=0156 AF=3A44 BC=0010 DE=C000 HL=4000 PC=0156 AF=3A44 BC=0010 DE=C000 HL=4000
PC=0158 AF=3A44 BC=0010 DE=C000 HL=4001 PC=0158 AF=3A44 BC=0010 DE=C000 HL=4001
PC=015A AF=3A40 BC=000F DE=C000 HL=4001 PC=015A AF=3A42 BC=000F DE=C000 HL=4001 ← ここ
両方で1命令ごとに PC とレジスタをテキストに書き出し、diff にかけて最初に食い違った行を探します。最初の1行さえ見つかれば、あとはその直前の命令を調べるだけです。何万行もの実行の中から原因を探すのではなく、「正しかった最後の瞬間」と「最初に間違えた瞬間」の境界を探す。これが差分デバッグ(Diff Debugging)の考え方です。
7. ZEXDOC全通の先にあるもの
本シリーズの題材になっている Z80 コア(z80.js、約1,100行)も、ZEXDOC の全67項目をパスしています。しかし、それで CPU コアのバグがなくなったわけではありませんでした。ZEXDOC 全通の後、実際のハードウェアを載せていく中で見つかったバグがいくつもあります。
T-State(クロック数)は検査されない
ZEXDOC が見ているのは「実行結果」だけで、「何クロックかかったか」は見ていません。
実際、CB プレフィックス命令を処理する関数で、消費サイクルを返し忘れているバグが ZEXDOC 全通後に見つかりました。BIT / SET / RES / シフト系がすべて影響を受けていたにもかかわらず、演算結果が正しいので ZEXDOC は全部 OK を出していたのです。第8回で扱ったとおり、クロック数のずれはそのまま速度やタイミングのずれになります。見つかったのは ColecoVision を動かしていたときで、Z80 コアを共有していた全システムに影響するバグでした。
I/O命令は検査されない
ZEXDOC は CPU 単体の試験なので、IN / OUT 系はほとんど検査しません。ブロック I/O 命令(INI / IND / INIR / INDR / OUTI / OUTD / OTIR / OTDR)がまるごと未実装だったことにも、ZEXDOC 全通後に気づきました。
割り込みは検査されない
第5回の割り込み処理も ZEXDOC の対象外です。EI 直後の1命令は割り込みを受け付けない遅延、HALT 中の割り込みによる復帰、IM 0/1/2 の分岐、NMI と IFF2 の関係などは、自前でテストを書くしかありません。本シリーズのコアでは、割り込み専用のテストを34項目用意して検査しています。
バスの「外側」の仕様
さらに外側には、CPU ではなく基板の仕様があります。第5回のコードでは、IM 2 の割り込みでデータバスに乗る値を「仮に 0x00」としていました。ところがパックマンの基板では、この値が 0xFC です。CPU コアがどれほど正しくても、基板側がこの値を正しく供給しなければゲームは動きません。これはもう CPU のテストではなく、システムのテストの領域です。
ZEXDOC が保証する範囲
┌──────────────────────────────────────────┐
│ 命令の演算結果・フラグ(ドキュメント化された範囲)│
└──────────────────────────────────────────┘
ZEXALL で広がる範囲: + X/Y フラグ
命令単体テストで広がる範囲: + 内部レジスタ、バスサイクル、クロック数
自前テストが必要な範囲: 割り込み、HALT、I/O
システムテストが必要な範囲: 割り込みベクタの供給値、メモリミラー、周辺チップ
ZEXDOC 全通はゴールではなく、「CPU の演算部分はもう疑わなくていい」という許可証です。その許可証があるからこそ、ゲームが動かないときに「CPU ではなく、VDP かマッパーか基板の配線を疑おう」と切り分けられます。
8. 回帰を防ぐ:テストを資産にする
Z80 コアを1つ作ると、それを MSX、SG-1000、ColecoVision、Game Gear、パックマン基板と、多くのシステムで共有することになります。すると、「ColecoVision のために直した修正が、MSX のゲームを壊す」ということが起こり得ます。これが回帰(Regression)です。
回帰を防ぐ唯一の方法は、テストを捨てずに残し、変更のたびに回すことです。
{
"scripts": {
"test": "node core/tests/z80-tests.js sst",
"test:int": "node core/tests/z80-tests.js int-nmi",
"test:zexdoc": "node core/tests/z80-tests.js zexdoc",
"test:all": "npm test && npm run test:int && npm run test:zexdoc"
}
}
運用のコツは、テストを速さで分けることです。
- 命令単体テスト・割り込みテスト: 数秒で終わる。コアに触るたびに毎回回す。
- ZEXDOC: 数分かかる。まとまった修正の後や、公開前に回す。
- 実ソフト: 動作確認済みのゲームを一覧にしておき、公開前に一通り起動する。
そして、新しいバグを見つけたら、直す前にそのバグを再現するテストを1つ書く。直したら、そのテストが通ることを確認する。こうすると、同じバグが二度と戻ってこなくなります。テストの数は、そのままエミュレーターが踏んできた地雷の数になり、コアの信頼性の記録になります。
まとめ
- エミュレーターの テストオラクル は実機だけ。マニュアルには X/Y フラグやドキュメント化されていない命令、「不定」とされた結果の中身が書かれていない。
- テストは 命令単体テスト → 命令エクササイザ → 実ソフト の3階層。下の階層ほど速く、原因がわかりやすい。
- ZEXDOC は、命令グループごとに大量のケースを実行して CRC-32 を取り、実機で記録した値と比べる。ZEXALL は X/Y フラグまで検査する。
- ZEXDOC を動かすには、
0x0005の BDOS(C=2, C=9)を HLE トラップで代行し、0x0000への到達で終了を検出する小さな CP/M ハーネスを作る。 - 落ちやすいのは DAA、16ビット演算のハーフキャリー、
BITの S と P/V、DD CB d opのバイト順など。 - CRC の
ERRORから先は、SingleStepTests による命令単体の比較と、トレースの差分で「最初にずれた瞬間」を探す。 - ZEXDOC はクロック数・I/O・割り込み・基板の仕様を検査しない。全通はゴールではなく、CPU の演算部分を疑わなくていいという許可証。
- テストを残して変更のたびに回し、バグを見つけたら再現テストを先に書くことで、共有コアの回帰を防げる。
連載を終えて
第1回は、「なぜ8ビットで255までしか扱えないのか」という問いから始まりました。
8本の配線で表せる256通りの状態。それを組み合わせたレジスタとフラグ。レジスタとメモリをつなぐバス。命令を読んで解釈して実行するだけの無限ループ。そこに割り込みが外の世界を持ち込み、バンク切り替えが64KBの壁を越え、HLE が BIOS を置き換え、クロック同期が実時間と結びつけ、VDP が画面を、音源チップが音を、複数の CPU が並行する時間を作り出しました。
そして最終回で扱ったのは、それらが「本当に正しいのか」をどう確かめるか、という話でした。
エミュレーターを作るということは、数十年前の技術者が配線とシリコンで作った答えを、ソフトウェアで1ビットずつ再発見していく作業です。マニュアルに書かれていないフラグ1つにも、実機にはちゃんと理由があります。その理由にたどり着いたときに、ZEXDOC の ERROR が OK に変わる。そこにこの分野の面白さがあります。
全12回、お付き合いいただきありがとうございました。
シリーズ全目次
- 【基礎編】
- 第1回:レジスタとフラグの正体 — なぜ8ビットで255までしか扱えないのか?
- 第2回:メモリ空間とバス制御 — CPUはどうやって外部と会話するのか?
- 第3回:Fetch-Decode-Execute — CPUが命を宿す無限ループ
- 第4回:ビット演算の極意 — 複雑な命令セットを美しく捌く
- 【実践編】
- 第5回:割り込み(Interrupt)の仕組み — 非同期イベントを検知するハードウェアの割り込み
- 第6回:メモリバンキング(Bank Switching) — 64KBの壁を超えて巨大ROMを読み込む
- 第7回:高級言語によるHLEとレガシーの再現 — BIOS不要起動(Direct Boot)のからくり
- 第8回:クロック同期とメインループ — Webブラウザ上で正確な周波数を刻む
- 【応用編】
- 第9回:走査線とVDP描画エンジン — タイルマップとスプライトの画面レンダリング
- 第10回:音源チップの波形合成 — レトロゲーム音をWeb Audio APIで発声させる
- 第11回:マルチCPUエミュレーション — 複数のZ80を同期させて動かす(アーケード基板の世界)
- 第12回:テスト駆動エミュレーター開発 — ZEXDOC全通へのデバッグ格闘記(本記事)