[JavaScript] Z80エミュレーターにMSXを載せる。割り込み・ブロックI/O・VDP・スプライトで実機起動まで

[JavaScript] Z80エミュレーターにMSXを載せる。割り込み・ブロックI/O・VDP・スプライトで実機起動まで

はじめに

前回の記事でZ80コアをゼロからJavaScriptで実装し、zexdoc全67テストをパスした。CPUコアはコールバック設計で外部バスと完全に分離されている——つまり、メモリとI/Oの接続先を差し替えるだけで任意のZ80システムを構築できる。

この記事では、そのCPUコアの上にMSXのハードウェアを実装し、C-BIOS起動→カートリッジROM実行→ゲームプレイまでを達成した過程を記録する。

スクリーンショット

子供の頃、何度もプレイした思い出のゲームをプレイ。
まさか、CPUをゼロから自作してMSXを自力で動かす時が来るとは…。

[JavaScript] Z80エミュレーターにMSXを載せる。割り込み・ブロックI/O・VDP・スプライトで実機起動まで

動作確認サンプルとして、msxdevさんのサイトからSpacesugar様作の「Chambers of Thyrea」をお借りしています。

[JavaScript] Z80エミュレーターにMSXを載せる。割り込み・ブロックI/O・VDP・スプライトで実機起動まで

動画(GIF)

[JavaScript] Z80エミュレーターにMSXを載せる。割り込み・ブロックI/O・VDP・スプライトで実機起動まで

サンプルデータ

著作権フリーのC-BIOSは、「C-BIOS Association」様のサイトよりお借りしています。

フリーゲームROMはmsxdevさんのサイトからSpacesugar様作の「Chambers of Thyrea」をお借りしています。

1. CPUコアの拡張:割り込みとブロックI/O

MSXを動かすには、zexdocが検証しない領域を2つ埋める必要がある。

1-1. INT/NMI割り込み処理

Z80の割り込みは2種類。マスカブル割り込み(INT)はプログラムで有効/無効を切り替えられ、3つのモードがある。ノンマスカブル割り込み(NMI)は常に受理される。

MSXではVDP(画面チップ)がVBlankごとにINTを発行し、BIOSの割り込みハンドラ(0x0038)がキーボードスキャンやタイマー更新を行う。これが動かないとBIOSが初期化ループから抜けられない。

interrupt(dataBus = 0xff) {
  if (!this.iff1 || this.eiPending > 0) return 0;

  this.iff1 = false;
  this.iff2 = false;
  if (this.halted) this.halted = false;

  switch (this.im) {
    case 0: // データバス上のオペコードを実行(8080互換)
      this.push(this.pc);
      this.pc = dataBus & 0x38;
      return 13;
    case 1: // 0x0038固定ジャンプ(MSXはこれ)
      this.push(this.pc);
      this.pc = 0x0038;
      return 13;
    case 2: // Iレジスタ+ベクタでジャンプテーブル参照
      const addr = ((this.i << 8) | (dataBus & 0xfe)) & 0xffff;
      this.push(this.pc);
      this.pc = (this.readByte(addr + 1) << 8) | this.readByte(addr);
      return 19;
  }
}

eiPendingはEI命令の遅延制御。Z80ではEI実行直後の1命令は割り込みを受け付けない実機挙動があり、これを正確に再現しないとBIOSの割り込みセットアップ(EI → HALTのシーケンス)が壊れる。

// EI命令
case 7: this.iff1 = true; this.iff2 = true; this.eiPending = 1; break;

// step()冒頭
step() {
  if (this.eiPending > 0) this.eiPending--;
  // ...
}

NMIは単純。IFF1をIFF2に退避し、0x0066にジャンプ。RETNでIFF1を復帰する。

1-2. ブロックI/O命令:zexdocが検出できない致命的バグ

ここが最大の教訓だった。

zexdocはCPU命令の正確性テストとしては事実上の標準だが、EDプレフィックスのブロックI/O命令(INI/IND/INIR/INDR/OUTI/OUTD/OTIR/OTDR)をテスト対象に含んでいない。理由は単純で、これらの命令はI/Oポートへの実際のアクセスを伴うため、CP/Mのコンソール環境ではテストしようがないからだ。

MSXのBIOSはVRAMへのデータ転送にOTIRを多用する。OTIRは「HLが指すメモリの内容をポートCに出力し、HLをインクリメント、Bをデクリメント、B≠0なら繰り返す」というブロック転送命令で、VDPへの連続データ書き込みに不可欠。

// OUTI/OUTD/OTIR/OTDR (ED prefix, z=3)
const val = this.readByte(this.hl);
this.b = (this.b - 1) & 0xff;
this.outPort(this.c, val);
this.hl = (this.hl + inc) & 0xffff;

if (repeat && this.b !== 0) {
  this.pc = (this.pc - 2) & 0xffff;  // 繰り返し
  this.tCycles += 21;
} else {
  this.tCycles += 16;
}

ブロックI/Oが未実装の状態でMSXを起動すると、VDPのステータス読み出しや割り込みは正常に動くため、BIOSの初期化シーケンス自体は進行する。バックドロップカラー(VDPレジスタ7)も正しく設定されるので、画面は青一色に変わる。しかし文字パターンもネームテーブルも書き込まれないため、青い画面から永遠に進まない。

エラーメッセージもクラッシュもない。静かに壊れる。これがzexdocだけに依存することの限界であり、「MSXを動かすこと自体が最大のテストスイート」という方針が正しかった証拠でもある。

2. MSXのハードウェア構成

MSX1のハードウェアは最小限で構成される。

コンポーネント型番/仕様I/Oポート
CPUZ80A 3.58MHz—
VDPTMS9918A(16KB VRAM)0x98-0x99
PSGAY-3-89100xA0-0xA2
PPI8255(スロット・キーボード)0xA8-0xAB
ROMBIOS 32KB + カートリッジスロット経由
RAM64KBスロット経由

2-1. スロット機構

MSXのメモリ空間(64KB)は4つのページ(各16KB)に分割され、各ページにどのスロットを割り当てるかをPPIのポートA8で制御する。

// メモリ読み出し
function readMemory(addr) {
  const page = (addr >> 14) & 3;            // 0-3
  const slot = (ppiPortA8 >> (page * 2)) & 3; // 各ページ2ビット

  if (slot === 0) return slot0[addr];       // BIOS ROM
  if (slot === 1) return slot1[addr - 0x4000]; // カートリッジ
  if (slot === 3) return ram[addr];          // RAM
  return 0xFF;
}

C-BIOS起動時のスロット遷移はステータス表示で追跡できる。初期値0x00(全ページBIOS)から始まり、BIOS初期化が進むにつれてスロットが切り替わり、最終的にPage 2-3がRAMに設定される。

2-2. キーボードマトリクス

MSXのキーボードは11行×8列のマトリクスで、PPI Port C(0xAA)の下位4ビットで行を選択し、PSGレジスタ14またはPPI Port B(0xA9)で列の状態を読み取る。

const keyMatrix = new Uint8Array(11);
keyMatrix.fill(0xFF);  // 全キー離し = all 1

// keydownイベント
document.addEventListener('keydown', (e) => {
  const m = KEY_MAP[e.code];
  if (m) keyMatrix[m[0]] &= ~(1 << m[1]);  // ビットを0に
});

// I/Oポート読み出し
if (p === 0xA9) {
  const row = ppiPortAA & 0x0F;
  return row < 11 ? keyMatrix[row] : 0xFF;
}

C-BIOSはVBlank割り込みハンドラ内でキーボードスキャンを行うため、割り込み処理が正しく動いていることが前提になる。

3. TMS9918 VDPエミュレーション

VDPはMSXの画面描画を担当する。CPU側から見たインターフェースはI/Oポート2本だけ——データポート(0x98)とコントロールポート(0x99)。

3-1. コントロールポートの2バイトラッチ

VDPへのレジスタ書き込みやVRAMアドレス設定は、コントロールポートへの2バイト連続書き込みで行われる。1バイト目をラッチに保持し、2バイト目の上位2ビットで操作を決定する。

writeControl(val) {
  if (this.firstByte === null) {
    this.firstByte = val;  // 1バイト目をラッチ
  } else {
    const cmd = val >> 6;
    if (cmd === 0b00) {
      // VRAM Read Address設定 + Read-Ahead
      this.vramAddress = (this.firstByte | ((val & 0x3F) << 8)) & 0x3FFF;
      this.readAheadBuffer = this.vram[this.vramAddress];
      this.vramAddress = (this.vramAddress + 1) & 0x3FFF;
    } else if (cmd === 0b01) {
      // VRAM Write Address設定
      this.vramAddress = (this.firstByte | ((val & 0x3F) << 8)) & 0x3FFF;
    } else {
      // レジスタ書き込み (R0-R7)
      this.registers[val & 0x07] = this.firstByte;
    }
    this.firstByte = null;
  }
}

データポートへの読み書きとステータスレジスタの読み出しは、いずれもラッチをリセットする。これを忘れると2バイト目の書き込みがズレて、VDPの状態が完全に壊れる。

3-2. ステータスレジスタとVBlank

ステータスレジスタのBit 7がVBlankフラグ。BIOSはこれを読んでVBlank待ちループを実行する。読み出し時にフラグをクリアしないと、BIOSが永遠にVBlank待ちから抜けない。

readStatus() {
  const status = this.statusRegister;
  this.statusRegister &= 0x1F;  // Bit 7,6,5 をクリア
  this.firstByte = null;
  return status;
}

メインループ側では、1フレーム分のCPUサイクル消費後にVBlankフラグをセットし、VDP割り込みイネーブル(R#1 Bit 5)が有効ならINTを発行する。

const CYCLES_PER_FRAME = 59659;  // 3.58MHz / 60fps

function mainLoop() {
  let cycles = 0;
  while (cycles < CYCLES_PER_FRAME) cycles += cpu.step();

  vdp.statusRegister |= 0x80;  // VBlankフラグ
  if (vdp.registers[1] & 0x20) cpu.interrupt(0xFF);

  renderScreen();
  requestAnimationFrame(mainLoop);
}

3-3. SCREEN 2(グラフィック2モード)のタイル描画

MSXのゲームが使うのは主にSCREEN 2(Graphics Mode 2)。256×192ピクセルの画面を8×8のタイルで構成するが、パターンテーブルとカラーテーブルが画面を3つのセクション(各8行)に分割して独立にアドレッシングする点がSCREEN 1との違い。

if (isScreen2) {
  const patternTableBase = (vdp.registers[4] & 0x04) << 11;
  const colorTableBase = (vdp.registers[3] & 0x80) << 6;

  for (let row = 0; row < 24; row++) {
    const section = Math.floor(row / 8);  // 0, 1, 2
    for (let col = 0; col < 32; col++) {
      const charIndex = vdp.vram[nameTableAddr + row * 32 + col];
      // セクションごとに2048バイトずれる
      const patternAddr = patternTableBase + (section << 11) + charIndex * 8;
      const colorAddr = colorTableBase + (section << 11) + charIndex * 8;
      // 以下、1行ずつパターンとカラーを読んでピクセルに展開
    }
  }
}

3-4. スプライト描画

MSXのスプライトは最大32枚、8×8または16×16サイズ、拡大(2倍)対応。スプライト属性テーブル(SAT)とスプライトパターンテーブルを参照して描画する。

16×16スプライトのパターン配置がTMS9918の最大の罠。4つの8×8パターンが上下→左右の順で格納される。

パターン番号 P (P & 0xFC で4の倍数にマスク):
  P+0: 左列・上半分 (row 0-7)
  P+1: 左列・下半分 (row 8-15)
  P+2: 右列・上半分 (row 0-7)
  P+3: 右列・下半分 (row 8-15)

直感的には左上→右上→左下→右下の順(ラスタースキャン順)を想像するが、TMS9918は左列→右列の順で、各列が上下に連続する。これを間違えると、16×16スプライトの右半分が描画されないか、パターンがずれて表示される。

for (let half = 0; half < 2; half++) {  // 0=左列, 1=右列
  const vertHalf = (py < 8) ? 0 : 1;   // 0=上, 1=下
  const row = py & 7;
  const patByte = vram[sprPatBase + (patIdx + vertHalf + half * 2) * 8 + row];
  // halfごとにX座標を8ピクセルずらして描画
}

4. C-BIOS起動とカートリッジ実行

C-BIOSはMSX BIOSのオープンソース互換実装。起動すると以下のシーケンスが走る。

  1. DI → SP設定 → RAM初期化
  2. VDPレジスタ初期化(SCREEN 1テキストモード)
  3. スロットスキャン(各スロットのPage 1を走査してABヘッダを探す)
  4. IM 1 → EI(ここから割り込み有効)
  5. カートリッジが見つかればそのINIT(ヘッダ+2のアドレス)にジャンプ
  6. 見つからなければ「No cartridge found.」表示

カートリッジROMのロードは単純。slot1にバイナリを書き込んでCPUをリセットするだけ。

romInput.addEventListener('change', async (e) => {
  const data = new Uint8Array(await file.arrayBuffer());
  slot1 = new Uint8Array(0x8000);
  slot1.set(data, 0x0000);
  slot1Size = data.length;
  cpu.reset();
});

C-BIOSはスロットスキャン中にPage 1(0x4000-0x7FFF)の先頭2バイトが 0x41 0x42(“AB”)であればカートリッジと認識する。

5. 実装で得た教訓

zexdocの守備範囲を知る

zexdocは命令の演算結果とフラグの正確性を検証するが、I/O命令のブロック転送(OTIR等)はテストしない。OTIRが未実装でもzexdocは全パスする。テストが全部通った ≠ 正しいという当たり前の事実が、エミュレーター開発では特に痛い形で顕在化する。

「MSXを動かすこと」がテストスイート

MSXのBIOS起動は、CPU命令・T-states・割り込み・VDP・スロット・I/Oを横断的に検証する統合テスト。画面に文字が出ればこれら全てが正しく連携している証拠であり、文字が出なければどこかが壊れている。エラーメッセージの代わりに「青い画面」「黒い画面」「文字化け」が症状として現れる。

静かに壊れるバグが最も危険

ブロックI/O未実装の症状は「画面が青いまま進まない」だった。クラッシュもエラーもない。BIOSの初期化は途中まで正常に進行し、VDPのバックドロップカラーは設定され、割り込みも動いている。しかし文字パターンのVRAM転送だけが無音で失敗している。

この種のバグは、仮にBIOS起動を飛ばしてVRAMに直接テストパターンを書き込むような「ショートカットテスト」では発見できない。実際のBIOSを完全に通して初めて露出する。

6. 現在の状態

項目状態
Z80コアzexdoc全67パス、INT/NMI、ブロックI/O実装済み
VDP (TMS9918)SCREEN 1/2 タイル描画、スプライト 8×8/16×16
スロットSlot 0 (BIOS), Slot 1 (カートリッジ 16/32KB), Slot 3 (RAM 64KB)
キーボード11行×8列マトリクス、PCキーボードからのマッピング
音声 (PSG)レジスタ書き込みのみ(発音未実装)
カートリッジ16KB/32KB フラットROM対応。MegaROM(バンク切り替え)未対応

動作確認済みソフトウェア

  • C-BIOS 0.29 — 起動画面表示、カートリッジ検出
  • Volguard (dB-SOFT, 1985) — ゲームプレイ可能
  • Chambers of Thyrea (Spacecorn, 2019) — ゲームプレイ可能

7. 次のステップ

  • PSG音声出力(Web Audio APIによるAY-3-8910エミュレーション)
  • MegaROMマッパー対応(Konami / ASCII 8KB / ASCII 16KB)
  • スプライト衝突検出・5thスプライトフラグ
  • ジョイスティック入力