[JavaScript] 自作Z80コアでColecoVisionエミュレーターを実装。BIOS HLEで限界まで攻めて、実BIOSで完走した全記録

[JavaScript] 自作Z80コアでColecoVisionエミュレーターを実装。BIOS HLEで限界まで攻めて、実BIOSで完走した全記録

はじめに

自作Z80コアは、MSX1、SG-1000、PC-G850Vと3つのシステムに載せ替えてきた。いずれもZ80コアのバス分離設計(readByte/writeByte/inPort/outPort)がそのまま活き、コアには一切手を入れずにシステム層だけを差し替える形で動いてきた。

5台目のターゲットとして選んだのは ColecoVision。

1982年にColeco社が発売した家庭用ゲーム機で、ハードウェア構成はSG-1000とほぼ同一(Z80A 3.58MHz / TMS9918A / SN76489)だが、決定的に異なる点が1つある。

8KBのBIOS ROM。

SG-1000はBIOS不要でカートリッジROMを直接実行する。

ColecoVisionはBIOS ROMが$0000-$1FFFに鎮座し、起動シーケンスからコントローラ読み取り、サウンド再生、VDP初期化まで、ゲームの基盤となる機能をBIOSが提供する。MSXにとってのC-BIOSのような、オープンソースの互換BIOSも存在しない。

公式BIOSを同梱すれば話は早い。だがそれは著作権的にアウトだし、「BIOS不要で動くエミュレーター」を作れるかという技術的挑戦のほうが面白い。

この記事では、BIOS HLE(High Level Emulation)で限界まで攻めた過程、そこで発見した4つの致命的バグ、そして最終的に実BIOS ROM読み込みにも対応したデュアル構成の完成までを記録する。

スクリーンショット

[JavaScript] 自作Z80コアでColecoVisionエミュレーターを実装。BIOS HLEで限界まで攻めて、実BIOSで完走した全記録 [JavaScript] 自作Z80コアでColecoVisionエミュレーターを実装。BIOS HLEで限界まで攻めて、実BIOSで完走した全記録 [JavaScript] 自作Z80コアでColecoVisionエミュレーターを実装。BIOS HLEで限界まで攻めて、実BIOSで完走した全記録

動画(GIF)

[JavaScript] 自作Z80コアでColecoVisionエミュレーターを実装。BIOS HLEで限界まで攻めて、実BIOSで完走した全記録

CREDIT // Sample Data

sample romは、ArugulaZ様の「Barbarricade for Master System & ColecoVision」をお借りしています。

システム構成

項目仕様
CPUZ80A @ 3.58MHz(自作コア、ZEXDOC全67項目パス済み)
VDPTMS9918A(Mode 0/1/2/3 + スプライト)
PSGSN76489(3ch + ノイズ)
RAM1KB($6000-$7FFF、8回ミラー)
ROMカートリッジ 最大32KB($8000-$FFFF)
BIOS$0000-$1FFF(8KB)— HLEまたは実ROM
コントローラジョイスティック + テンキー(2モード切替)

SG-1000との差分

SG-1000のコードを流用できる部分が大きい。差分は3点のみ:

  1. BIOS ROM領域($0000-$1FFF)の存在
  2. コントローラのストローブ切替(ポート$80-$9F=キーパッド、$C0-$DF=ジョイスティック)
  3. VDPポートの範囲($A0-$BF、SG-1000と同じ$BE/$BF)

Z80コア、TMS9918A(I/O層)、SN76489は全て core/ から共有。ColecoVision固有のコードは colecovision.js 1ファイルに収まっている。


ファイル構成

z80-emulator/
├── core/
│   ├── z80.js              ← 共有Z80コア
│   ├── tms9918.js          ← TMS9918A I/O層(共有)
│   ├── tms9918-renderer.js ← TMS9918A 描画(共有)
│   └── sn76489.js          ← SN76489 PSG(共有)
├── coleco-vision/
│   ├── colecovision.js     ← ColecoVision固有(BIOS HLE + メモリマップ + I/O)
│   └── index.html          ← UI + コントローラ入力
├── msx1/
├── sg-1000/
└── pc-g850/

sn76489.js と tms9918-renderer.js はこの実装で新規作成し、core/ に配置した。SG-1000もMSX1も同じレンダラーとPSGを参照する。


1. BIOS HLE — 公式ROM無しで動かす試み

1-1. カートリッジヘッダ

ColecoVisionのカートリッジROMは $8000 から始まり、先頭にヘッダ構造を持つ。

$8000-$8001 : マーカー($55AA または $AA55)
$800A-$800B : エントリポイント(ゲーム開始アドレス)
$800C-$8020 : RST $08〜$38 ハンドラ(各3バイト、JP xxxx)
$8021-$8023 : NMIハンドラ(JP xxxx)
$8024〜     : ゲームタイトル文字列

$AA55 はタイトル画面をスキップして直接エントリポイントにジャンプ、$55AA はBIOSがタイトル画面を表示してからゲームに遷移する。

1-2. HLEの基本設計

BIOSの $0000-$1FFF に実コードを置く代わりに、Z80がBIOS関数のアドレスを実行しようとした瞬間にJavaScriptの関数でトラップする。

_readByte(addr) {
  if (addr < 0x2000) {
    // PCがBIOSエントリポイントにいる → HLE実行
    if (addr === this.z80.pc && this.biosHLE[addr]) {
      this.biosHLE[addr]();  // JSで処理を肩代わり
      return 0xC9;           // RET — Z80に「関数は終わった」と伝える
    }
    return this.biosStub[addr]; // それ以外はスタブROM
  }
  // ...
}

readByte がオペコードフェッチ(addr === PC)かつ既知のBIOSアドレスならHLE関数を実行し、0xC9(RET命令)を返す。Z80はRETを実行して呼び出し元に戻る。呼び出し側のゲームコードからは、普通にBIOS関数をCALLして返ってきたように見える。

1-3. 実装したBIOS関数(40個)

ColecoVisionのBIOSは $1F61〜$1FFD にジャンプテーブルを持つ。ゲームは CALL $1FBE(PUT_VRAM)のようにこのテーブルのアドレスを呼ぶ。

実装した主要関数:

アドレス名前内容
$1F82FILL_VRAMVRAMを指定値で埋める
$1F85MODE_1Graphics Mode 1の初期化
$1F7FLOAD_ASCIIASCIIフォントをVRAMにロード
$1FBEPUT_VRAMRAM→VRAM転送
$1FBBGET_VRAMVRAM→RAM転送
$1FD9WRITE_REGISTERVDPレジスタ書き込み
$1F76CONTROLLER_SCANコントローラ状態をRAMに保存
$1FEBPOLLERコントローラポーリング
$1FEESOUND_INITPSG初期化
$1FD6TURN_OFF_SOUND全チャンネル消音
$1FFDRAND_GEN疑似乱数生成
$1F7CGAME_OPTタイトル画面(スキップしてA=1を返す)

Pascal呼び出し規約の P サフィックス版も含めて約40関数を実装した。

1-4. NMIハンドラ — BIOSスタブ

NMI(VBlank割り込み)だけはHLEでは対応できない。理由は後述する「致命的バグ#1」で明らかになるが、最終的にはZ80の実コードをBIOSスタブに書き込む方式にした。

// $0066: NMI vector — 実Z80コード
this.biosStub[0x0066] = 0xDB; // IN A, ($BF)    — VDPステータス読み
this.biosStub[0x0067] = 0xBF;
this.biosStub[0x0068] = 0x32; // LD ($73C5), A  — ステータス保存
this.biosStub[0x0069] = 0xC5;
this.biosStub[0x006A] = 0x73;
this.biosStub[0x006B] = 0xC3; // JP $8021       — カートリッジNMIハンドラへ
this.biosStub[0x006C] = 0x21;
this.biosStub[0x006D] = 0x80;

VDPステータスを読んでIRQフラグをクリアし、カートリッジのNMIハンドラ($8021 にJP命令が書かれている)に転送する。6バイトの実コードで済む。


2. デバッグ地獄 — 4つの致命的バグ

HLEで「けっきょく南極大冒険」(KONAMI)が動いた時は感動した。しかしそれ以外のゲームがほぼ全滅。画面真っ黒、画面化け、フリーズ。ここからデバッグログとの戦いが始まった。

バグ#1: NMI HLEのPC書き換え競合

最初の実装ではNMIもHLEで処理していた。

// 初期実装(バグあり)
hle[0x0066] = () => {
  this.vdp.readStatus();           // VDPステータスクリア
  this.z80.pc = nmiTarget;         // カートリッジNMIハンドラに飛ばす
  return;                           // → readByteが0xC9(RET)を返す
};

これが壊滅的に間違っていた。処理の流れを追うと:

  1. Z80.nmi() がPCをスタックに退避して $0066 にジャンプ
  2. fetchByte で $0066 を読む → HLEが走ってPCを $83A1(ゲームのNMIハンドラ)に変更
  3. readByte が 0xC9(RET)を返す
  4. fetchByte がPCをインクリメント → $83A2
  5. Z80が 0xC9(RET)を実行 → スタックからpopして元のPCに戻る

カートリッジのNMIハンドラは一切実行されない。 VBlank処理が走らないからゲームのメインループが止まる。

修正はNMIをHLEから実Z80コードに切り替えること(前節のBIOSスタブ)。6バイトの実コードがHLEの複雑な分岐より正しく動く。

バグ#2: Z80コアのCBプレフィックス・サイクル欠落

デバッグログで elapsed=0 が頻発していた。

STUCK: elapsed=0 at PC=$83b8, halted=false

$83B8 のオペコードは E1(POP HL)。普通に動くはずの基本命令でelapsed=0。ROMのバイト列を追うと、直前に CB 7E(BIT 7, (HL))がある。

Z80コアの executeCB() を調べたら、サイクルカウントが一切実装されていなかった。

// BIT命令(修正前)
case 1:
  // ... フラグ計算 ...
  return; // ← tCycles加算なし!

BIT、RES、SET、全シフト/ローテート命令 — CBプレフィックスの全命令がサイクル0で実行されていた。ZEXDOCテストはレジスタ/フラグの正しさだけを検証するためパスしていたが、タイミングが全く狂っていた。

修正:

// BIT (8/12/20 T-cycles)
if (prefixAddress !== null) this.tCycles += 20;
else if (addr !== null)    this.tCycles += 12;
else                       this.tCycles += 8;

// RES/SET/シフト (8/15/23 T-cycles)
if (prefixAddress !== null) this.tCycles += 23;
else if (addr !== null)    this.tCycles += 15;
else                       this.tCycles += 8;

このバグはMSX1、SG-1000、PC-G850Vにも存在していた。 ColecoVisionのデバッグで発見し、core/z80.js に修正をフィードバック。全エミュレーターのタイミング精度が向上した。

バグ#3: TMS9918AレンダラーのpatternTable計算ミス

Mode 2(Graphics II)のパターンテーブルベースアドレスの計算が間違っていた。

// 修正前
const patternTable = (regs[4] & 0x04) * 0x200;  // → $0800(間違い)

// 修正後
const patternTable = (regs[4] & 0x04) ? 0x2000 : 0x0000;  // → $2000(正しい)

R4のbit2が立っている場合、パターンテーブルは $2000 に配置される。0x04 * 0x200 = 0x0800 は誤り。この修正で「けっきょく南極大冒険」の画面グリッチが完全に解消した。

同時に、Mode 2のマスク計算も修正。TMS9918Aではマスクはバイトオフセットに対して適用される:

// 修正前(charIndexに対してマスク → 間違い)
const colorMask = ((regs[3] & 0x7F) << 3) | 0x07;
const patAddr = patternTable + ((charIdx & patternMask) * 8) + line;

// 修正後(バイトオフセットに対してマスク → 正しい)
const colorMask = ((regs[3] & 0x7F) << 6) | 0x3F;
const offset = charIdx * 8 + line;
const patAddr = patternTable | (offset & patternMask);

R3=$FFの場合は両方同じ結果になるが、非標準のR3値(一部のゲームが使う)では完全に異なるアドレスを生成する。

バグ#4: キーパッド初期値の幽霊入力

コントローラが一切反応しない問題。原因はコンストラクタの初期値。

// 修正前
this.keypad = [0x0F, 0x0F];  // $0F = 0000 1111

// 修正後
this.keypad = [0xFF, 0xFF];  // $FF = 1111 1111(何も押されていない)

$0F はbit 6が0。ColecoVisionのコントローラはアクティブロー(0=押下)なので、bit 6 = 0 はファイアボタンが押されっぱなしを意味する。

ColecoVisionのゲームの大半は起動時に「全ボタンが離された状態」を確認してから入力受付を開始する。ファイアボタンが幽霊入力で押されていると、テンキーの入力が永遠に無視される。

修正後、テンキー 1 でゲーム開始が反応するようになった。


3. GAME_OPTのVDP初期化問題

4つのバグを潰した後も、多くのゲームが画面真っ黒だった。デバッグログでVDPレジスタを確認すると:

VDP regs: [$00, $e2, $00, $00, $00, $00, $00, $00]

R2〜R7が全部ゼロ。ネームテーブルもカラーテーブルも $0000 を指している。

原因は GAME_OPT($1F7C)のHLE実装にあった。実BIOSのGAME_OPTはタイトル画面表示のためにVDPをMode 1に初期化し、フォントをロードする。ゲームはGAME_OPTが残したVDP状態を引き継いで使う設計になっている。

初期のHLE実装は A=1 を返すだけで、VDPの初期化を一切行っていなかった。修正後はMode 1の標準レジスタ設定とASCIIフォントロードをGAME_OPT内で実行するようにした。


4. 実BIOS ROM対応

HLEだけでは限界があった。BIOSのPOLLER関数のデコード処理や、サウンドエンジン(PLAY_IT / PLAY_SONGS)のデータフォーマット解析は、1関数ずつ逆アセンブルして再実装するには時間がかかりすぎる。

最終的に、ユーザーが実BIOS ROMファイル(8KB)を持ち込める構成にした。

loadBIOS(data) {
  this.biosRom = new Uint8Array(data);
  this.useBiosRom = true;
}

_readByte(addr) {
  if (addr < 0x2000) {
    if (this.useBiosRom) {
      return this.biosRom[addr]; // 実BIOS → HLEバイパス
    }
    // HLEモード(従来通り)
    if (addr === this.z80.pc && this.biosHLE[addr]) {
      this.biosHLE[addr]();
      return 0xC9;
    }
    return this.biosStub[addr];
  }
}

実BIOSモードではHLEトラップが全部バイパスされ、Z80が $0000 から実BIOSのブートシーケンスを実行する。タイトル画面の表示、コントローラの初期化、サウンドエンジンの駆動 — 全てが実コードで走る。

結果、HLEでは動かなかったゲームが一発で動いた。


5. コントローラ実装

ColecoVisionのコントローラは2モード制。ゲームがI/Oポートに書き込んでモードを切り替える。

$80-$9F (Write) → キーパッドモードに切替
$C0-$DF (Write) → ジョイスティックモードに切替
$E0-$FF (Read)  → 現在のモードでコントローラ値を返す
                   A1ビット: 0=Player1, 1=Player2

ジョイスティックモード(アクティブロー)

Bit 0: 上     Bit 1: 右
Bit 2: 下     Bit 3: 左
Bit 6: Fire A(左ボタン)
$FF = 何も押されていない

キーパッドモード

Bits 3-0: キーコード
  $0F=なし, $0D=1, $07=2, $0C=3, $02=4,
  $03=5, $0E=6, $05=7, $01=8, $0B=9,
  $0A=0, $06=*, $09=#
Bit 6: Fire B(右ボタン)
$FF = 何も押されていない

キーボードマッピング:

キー機能
WASD / 矢印キージョイスティック
Z / SpaceFire A
XFire B
1-9, 0テンキー
-*
=#

画面下部にタッチ対応のソフトキーパッドも配置した。


到達点

HLEモード(BIOS ROM不要)

項目ステータス
BIOS関数約40個実装(VDP/コントローラ/サウンド/タイマー)
動作確認けっきょく南極大冒険(KONAMI)、Alcazar(Activision)
制限BIOS依存度が高いゲームは未対応

実BIOSモード

項目ステータス
BIOSユーザー持ち込みの8KB ROM($0000-$1FFF)
動作確認2010, Mahjong Solitaire, Barbarricade, 南極大冒険、他多数
タイトル画面実BIOSが表示(約12秒のロゴ + 起動音)

共有コア改善

修正影響範囲
CBプレフィックス・サイクル追加Z80コア全体(MSX1, SG-1000, PC-G850V)
TMS9918A patternTable計算修正MSX1, SG-1000, ColecoVision
TMS9918A Mode 2マスク計算修正同上

学び

ColecoVisionのBIOS HLEは「簡単だろう」と思って始めた。実際、SG-1000とハードウェアがほぼ同じだし、BIOSの関数テーブルもドキュメントが揃っている。

しかし、BIOS HLEは「関数の入出力を合わせれば動く」ほど単純ではなかった。実BIOSは関数の副作用としてVDPレジスタを設定し、RAMワーク領域にフラグを書き、コントローラの生データをデコードしてから格納する。ゲームはその副作用に依存している。

結果として学んだのは2つ:

  1. HLEを先にやったからこそ、コアのバグが見つかった。 実BIOSを最初から使っていたら「動くけどなぜ動くかわからない」状態で、CBサイクルバグもNMIのPC競合もpatternTableの計算ミスも埋まったまま放置されていた。

  2. 完璧なHLEより、実BIOS対応の方が1000倍速い。 40個のHLE関数を実装するのに丸1日かかったが、実BIOS読み込み対応は30分で完成して全ゲームが動いた。

HLEで苦しんだ時間は無駄ではなかった。だが、次に未知のシステムをエミュレートする時は、最初から実ROM対応を入れておく。


Z80コアを一度しっかり作り、バスをコールバックで分離するだけで、何でも動く。MSXの800行、SG-1000の400行、PC-G850Vの300行、ColecoVisionの500行(+HLE 200行)。コアに手を入れたのはCBサイクルの追加だけ。5台目でもこの設計は破綻していない。