[JavaScript] 自作Z80コアでセガ・マスターシステムエミュレーターを実装。Homebrew完走と『ソニック』動作不可から学んだVDP割り込みのシビアさ

[JavaScript] 自作Z80コアでセガ・マスターシステムエミュレーターを実装。Homebrew完走と『ソニック』動作不可から学んだVDP割り込みのシビアさ

はじめに

自作Z80コアの9台目のターゲットとして、SEGA Master System(セガ・マスターシステム / マークIII互換)を選んだ。

前回実装したGame Gear(1990)は、内部構造的にはマスターシステムの携帯版であり、画面描画を160×144にクロップした構成だった。

今回は本来の「256×192のフル解像度テレビ出力機」としてのマスターシステム環境を構築する。

結果として、Web Audio API (AudioWorklet) によるPSGサウンドは実機と遜色ないレベルまで仕上がり、SMS Power!で公開されている最新のHomebrewデモや作品(最大4.5MBの超大容量ROMを含む)が完璧に動作した。しかしその一方で、商用ROMの金字塔である『ソニック・ザ・ヘッジホッグ』の壁にぶつかることとなった。

前回の記事

スクリーンショット & 動作実績(Homebrew作品)

SMS Power! で公開されている素晴らしいHomebrew作品群をお借りして動作検証を行った。

Bad Apple 3D Full Length // GliGli

Master System Emulator // Bad Apple 3D Full Length Master System Emulator // Bad Apple 3D Full Length Master System Emulator // Bad Apple 3D Full Length

4.5MBにおよぶ大容量ROM。Segaマッパーの高速なバンクセレクトと毎フレームの超大量VRAM転送に処理が追従できているかを確認する最高のテストベンチ。

Astro Climber // badcomputer & LOVEPENGUIN

Master System Emulator // Astro Climber Master System Emulator // Astro Climber Master System Emulator // Astro Climber

マークIII / SMS発売40周年を記念してリリースされた縦スクロールアクション。右側のステータスUI分離表示やスプライト優先度、背景タイルのレイアウトが正確に表示されている。

El Viento Opening Demake // lunoka

Master System Emulator // El Viento Opening Demake Master System Emulator // El Viento Opening Demake Master System Emulator // El Viento Opening Demake

メガドライブ用ソフト『エル・ヴィエント』のオープニングをSMS向けに移植・デメイクした作品。タイルのアトリビュート管理やカラーパレット(CRAM)指定が完璧でないとこのビジュアル表現は実現しない。


システム構成

項目Master System (SMS)参考: Game Gear参考: SG-1000
CPUZ80A @ 3.58MHzZ80A @ 3.58MHzZ80A @ 3.58MHz
RAM8KB (C000−C000-DFFF)8KB (C000−C000-DFFF)1KB (C000−C000-C3FF)
VRAM16KB16KB16KB
ROM最大4MB以上(Segaマッパー)最大512KB等最大32KB
解像度256×192(フル表示)160×144(クロップ表示)256×192
パレットCRAM 32色(RGB222, 6bit)CRAM 32色(RGB444, 12bit)TMS9918A固定16色
サウンドSN76489 PSG(モノラル)SN76489 PSG(ステレオ拡張)SN76489 PSG

1. 256×192 フルスクリーン描画と RGB222 CRAM

Game Gear実装では画面中央の160×144ピクセルのみをビューポートとして切り出していたが、Master Systemでは256×192ピクセルをフル描画する。

CRAM(カラーRAM)の比較

Game Gearが1色につき2バイト(RGB444)を使用していたのに対し、SMSは1色につき1バイト(RGB222)を使用する。

// Master SystemのCRAM書き込み(1バイト1色)
writeData(val) {
  if (this.codeRegister === 3) {
    const addr = this.vramAddress & 0x1F; // 32色
    this.cram[addr] = val;

    // RGB222 (BBGG22RR) -> RGBA8888 変換
    const r = (val & 0x03) * 85;         // 0-3 -> 0-255 (0, 85, 170, 255)
    const g = ((val >> 2) & 0x03) * 85;
    const b = ((val >> 4) & 0x03) * 85;

    this.palette[addr] = 0xFF000000 | (b << 16) | (g << 8) | r;
  }
}

2ビット値(0〜3)を8ビット(0〜255)へ展開するために * 85 (255 / 3) を使用している。


2. 大容量ROMを動かす Sega Mapper(バンクセレクト)

Bad Apple 3D Full Length のような4.5MB(36メガビット)に達する規格外のROMを扱うには、Segaマッパーの正確なバンクセレクト実装が不可欠となる。

$0000-$03FF : ROM Page 0 先頭1KB(固定領域)
$0400-$3FFF : Slot 0($FFFDで切り替え、デフォルト: Page 0)
$4000-$7FFF : Slot 1($FFFEで切り替え、デフォルト: Page 1)
$8000-$BFFF : Slot 2($FFFFで切り替え、デフォルト: Page 2)
$C000-$DFFF : システムRAM (8KB)
$E000-$FFFF : システムRAM ミラー ($FFFC-$FFFFはマッパー制御)
function writeByte(addr, val) {
  if (addr >= 0xFFFC) {
    ram[addr & 0x1FFF] = val;
    const reg = addr - 0xFFFC;
    if (reg === 1) {
      mapperSlots[0] = val % totalPages; // Slot 0
    } else if (reg === 2) {
      mapperSlots[1] = val % totalPages; // Slot 1
    } else if (reg === 3) {
      mapperSlots[2] = val % totalPages; // Slot 2
    }
    return;
  }

  if (addr >= 0xC000) {
    ram[addr & 0x1FFF] = val;
  }
}

大容量ROMを読み込む際、バンク番号がROMの最大ページ数を超えないよう % totalPages のマスク処理を入れることで、ページ溢れによるクラッシュを防いでいる。


3. PSGサウンドと実機同等の再生品質

SN76489 PSGの音響処理に関しては、前回確立した 「CPUサイクルタイムスタンプ付き書き込みバッファ」 方式をそのまま踏襲した。

Web Audio API(AudioWorklet)側でサンプル単位のサイクル経過をトラッキングし、Z80がレジスタに書き込んだタイミングと正確に同期させることで、音化けやノイズのないクリアなPSGサウンドを実現できた。Homebrew作品のBGM再生においても、実機と見分けがつかないレベルの精度を維持できている。


4. 【技術的考察】なぜ商用ROM『ソニック・ザ・ヘッジホッグ』は動かないのか?

Homebrewデモや最新の作品が完璧に動作する一方で、任天堂でいう『スーパーマリオブラザーズ』にあたるSEGAの看板タイトル『ソニック・ザ・ヘッジホッグ(SMS版)』を実行すると、画面が化けるか即座にフリーズする問題が発生した。

近年のHomebrew(devkitSMS等のモダンな開発環境でビルドされた作品)はコードが極めて素直であり、ハードウェアの境界線上のトリッキーな挙動に依存しない。それに対し、商用ゲームの極限までチューニングされたコードは「VDPの1サイクル単位の挙動」を前提に設計されている。

検証の結果、原因はVDPの割り込み(H-INT / V-INT)周辺のミリ単位のロジック不備に集約されることが判明した。

原因1: ライン割り込み(H-INT)のデクリメント条件の誤差

SMSのVDP Register 10には、ライン割り込み(H-INT)の発生周期を決めるダウンカウンタ値が設定される。

  • 誤った実装(現在のコード): 毎ライン一律で lineCounter-- を実行していた。
  • 実機の正確な仕様: カウンタが減算されるのはアクティブ表示ライン(ライン0〜191)の間のみ。VBlank期間中(ライン192〜261)は減算が行われず、Register 10の値でリロードされ続ける。

ソニックは画面下のHUD(リング数やスコア)を固定表示するためにH-INTでレジスタを書き換えているため、VBlank中にカウンタがずれるとZ80が無限ループに突入してしまう。

原因2: VDP Port $81 (Status Register) 読み出し時のフラグ消去タイミング

Z80がVDPステータスポート(Port $81)を読み出した際、VBlankフラグ(bit 7)やスプライト衝突フラグ(bit 5/6)は即座にクリア(0リセット)される必要がある。

// VDP Status Read
readStatus() {
  const val = this.statusRegister;
  // 読み出しと同時にVBlankフラグおよび各種フラグをクリアしなければならない
  this.statusRegister &= ~0x80; // Bit 7 clear
  this.statusRegister &= ~0x20; // Bit 5 clear
  this.pendingInterrupt = false;
  return val;
}

ソニックの割り込みハンドラは、割り込みが入った瞬間にPort $81 を読みに行って割り込み要因(H-INTなのかV-INTなのか)を判定する。ここでフラグのクリア漏れやタイミングのズレが生じると、Z80が割り込みから抜け出せなくなりフリーズを引き起こす。


到達点

動作状況まとめ

  • Homebrew・デモ作品: 『Bad Apple 3D』『Astro Climber』『El Viento Demake』等がサウンド含めて100%完璧に動作。
  • 商用ROM: VDPの精密なタイミング/割り込みフラグ依存により、『ソニック・ザ・ヘッジホッグ』等の特定の技術的限界を突くタイトルで課題が残る。

まとめ

今回のマスターシステムエミュレーター実装を通じ、以下の重要な知見が得られた。

  1. Homebrewと商用ROMの要求精度の違い: モダンなツールチェーンで書かれたHomebrewはVDPやZ80の基本機能が正しければ素直に動くが、商用ROMはハードウェアの裏仕様や未定義動作、1サイクル単位の割り込みタイミングに依存している。
  2. VDP割り込み(H-INT / V-INT)の厳密さ: 画面分割や特殊スクロールを行うゲームの完全動作には、VBlank期間中のラインカウンタ挙動やステータスレジスタ読み出し時のフラグ消去タイミングの徹底的な再現が不可欠である。

基礎コアとしてのZ80およびサウンドエンジンは完全に完成域に達しているため、次回はVDP割り込み周りの数行のタイミング補正を行い、ソニックリベンジを果たしたい。