[JavaScript] Z80エミュレーターにMSXを載せる。割り込み・ブロックI/O・VDP・スプライトで実機起動まで
はじめに
前回の記事でZ80コアをゼロからJavaScriptで実装し、zexdoc全67テストをパスした。CPUコアはコールバック設計で外部バスと完全に分離されている——つまり、メモリとI/Oの接続先を差し替えるだけで任意のZ80システムを構築できる。
この記事では、そのCPUコアの上にMSXのハードウェアを実装し、C-BIOS起動→カートリッジROM実行→ゲームプレイまでを達成した過程を記録する。
[JavaScript] Z80エミュレーターをゼロから実装。レジスタ・フラグ・プレフィックスの完全制覇への道 // PROTOCOL.LAIN
Z80 CPUエミュレーターをJavaScriptでゼロから実装し、zexdocの全67テスト項目をパスするまでの実装記録。
lain-lab.comスクリーンショット
子供の頃、何度もプレイした思い出のゲームをプレイ。
まさか、CPUをゼロから自作してMSXを自力で動かす時が来るとは…。
動作確認サンプルとして、msxdevさんのサイトからSpacesugar様作の「Chambers of Thyrea」をお借りしています。
動画(GIF)
サンプルデータ
著作権フリーのC-BIOSは、「C-BIOS Association」様のサイトよりお借りしています。
MSXdev’20: #19 – Chambers of Thyrea – MSXdev Contest
C-BIOS is a BIOS compatible with the MSX BIOS
cbios.sourceforge.netフリーゲームROMはmsxdevさんのサイトからSpacesugar様作の「Chambers of Thyrea」をお借りしています。
MSXdev’20: #19 – Chambers of Thyrea – MSXdev Contest
Name of the game: Chambers of Thyrea , Game size: 32Kb , RAM size requirements: 16Kb , Genre: Sokoban-like puzzle , Name of the individual involved: Spacesugar
www.msxdev.org1. 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ポート |
|---|---|---|
| CPU | Z80A 3.58MHz | — |
| VDP | TMS9918A(16KB VRAM) | 0x98-0x99 |
| PSG | AY-3-8910 | 0xA0-0xA2 |
| PPI | 8255(スロット・キーボード) | 0xA8-0xAB |
| ROM | BIOS 32KB + カートリッジ | スロット経由 |
| RAM | 64KB | スロット経由 |
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のオープンソース互換実装。起動すると以下のシーケンスが走る。
- DI → SP設定 → RAM初期化
- VDPレジスタ初期化(SCREEN 1テキストモード)
- スロットスキャン(各スロットのPage 1を走査してABヘッダを探す)
- IM 1 → EI(ここから割り込み有効)
- カートリッジが見つかればそのINIT(ヘッダ+2のアドレス)にジャンプ
- 見つからなければ「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スプライトフラグ
- ジョイスティック入力