[CS講座 #12] テスト駆動エミュレーター開発 — ZEXDOC全通へのデバッグ格闘記

[CS講座 #12] テスト駆動エミュレーター開発 — ZEXDOC全通へのデバッグ格闘記

はじめに

第1回から第11回までで、レジスタ、バス、命令サイクル、割り込み、バンク切り替え、HLE、クロック同期、画面、音、複数CPUと、エミュレーターを組み立てる部品はすべて揃いました。

しかし、部品が揃っても、ひとつ答えられていない問いが残っています。

「このCPUコアは、本当に正しいのか?」

ゲームが起動した。キャラクターが動いた。音も鳴った。

それでも、どこか1命令のフラグが1ビットずれているだけで、あるゲームの3面のボス戦だけが進まない、ということが普通に起こります。そして、その原因を画面から逆算するのはほぼ不可能です。

最終回の第12回は、エミュレーター開発における「テスト」の話です。

Z80エミュレーター界の登竜門である命令エクササイザ ZEXDOC を中心に、正しさをどう定義し、どう確かめ、どう守り続けるのかを解き明かします。

前回の記事

Z80 EMULATOR PROJECT

6502 EMULATOR PROJECT

1. 「正解」はどこにあるのか:テストオラクル問題

ソフトウェアテストでは、「この入力に対する正しい出力はこれだ」と判定してくれるものをテストオラクル(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項目あたりの実行ケース数はおおよそ次のようになります。

cases=2 popcount(increment)×(popcount(shift)+1)\text{cases} = 2^{\,\text{popcount(increment)}} \times \left(\text{popcount(shift)} + 1\right)

命令を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つだけです。

  1. 0x0100 番地にプログラムが読み込まれていること(CP/M の TPA 先頭)
  2. 0x0005 番地の BDOS(OSのシステムコール)で文字が出力できること
  3. 0x0006〜0x0007 番地にメモリの上端アドレスが入っていること(ZEXDOC はここからスタックポインタを決める)

BDOS の機能も、2つしか使いません。

C レジスタ機能引数
21文字出力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"
  }
}

運用のコツは、テストを速さで分けることです。

  1. 命令単体テスト・割り込みテスト: 数秒で終わる。コアに触るたびに毎回回す。
  2. ZEXDOC: 数分かかる。まとまった修正の後や、公開前に回す。
  3. 実ソフト: 動作確認済みのゲームを一覧にしておき、公開前に一通り起動する。

そして、新しいバグを見つけたら、直す前にそのバグを再現するテストを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全通へのデバッグ格闘記(本記事)