[JavaScript] 自作Z80コアでゲームギアエミュレーターを実装。SMS VDP Mode 4・RGB444 CRAM・Segaマッパーを一発完走した全記録

[JavaScript] 自作Z80コアでゲームギアエミュレーターを実装。SMS VDP Mode 4・RGB444 CRAM・Segaマッパーを一発完走した全記録

はじめに

自作Z80コアは、MSX1、SG-1000、PC-G850V、ColecoVision、ZX Spectrum、Pac-Manと7台のシステムに載せてきた。今回は8台目として SEGA Game Gear (1990) を選んだ。

Game Gearを選んだ理由は、SG-1000の実装資産を最大限に活かせるから。Game GearはマスターシステムのほぼCPU互換機で、VDPはTMS9918AのMode 4拡張、サウンドはSN76489そのもの。SG-1000の経験があれば「差分実装」で動くはず — そういう目算だった。

結果から言うと、ソニック・ザ_ヘッジホッグGGがSEGAロゴ(おなじみの「セーガー♪」ボイス付き)・タイトル画面・BGMまで完璧に動作した。

前回の記事

スクリーンショット

SEGAロゴ起動画面

Game Gear Emulator // SEGAロゴ起動画面

ソニック・ザ・ヘッジホッグGG タイトル画面

Game Gear Emulator // ソニック・ザ・ヘッジホッグGG タイトル画面

Wolfenstein // under4mhz

Game Gear Emulator // Wolfenstein // under4mhz Game Gear Emulator // Wolfenstein // under4mhz

動画

Game Gear Emulator // Wolfenstein // under4mhz

SMS Powerさんのサイトより、under4mhz様作の「Wolfenstein」をお借りしてます。

Mai Nurse // lunoka

Game Gear Emulator // Mai Nurse // lunoka Game Gear Emulator // Mai Nurse // lunoka Game Gear Emulator // Mai Nurse // lunoka

動画

Game Gear Emulator // Wolfenstein // under4mhz

SMS Powerさんのサイトより、lunoka様作の「Mai Nurse」をお借りしてます。

システム構成

項目Game Gear参考: SG-1000
CPUZ80A @ 3.58MHzZ80A @ 3.58MHz
RAM8KB (C000−C000-DFFF、$E000ミラー)1KB (C000−C000-C3FF)
VRAM16KB16KB
ROM最大4Mbit(Segaマッパー)最大32KB
画面160×144(SMS 256×192からクロップ)256×192
タイル形式Mode 4: 4bpp planar(32バイト/タイル)TMS9918A: Graphics I/II
パレットCRAM 32色(RGB444、12bit/色)TMS9918A固定パレット(16色)
スプライト最大64個、8×8/8×16、ライン8個制限最大32個、8×8/8×16
サウンドSN76489 PSG(3音+ノイズ / 音量制御によるPCM対応)SN76489 PSG(3音+ノイズ)
コントローラD-Pad + 1/2ボタン + Startジョイスティック + 2ボタン

Game Gearの本質は「携帯版マスターシステム」。CPUは同一、VDPはMode 4のみに限定された互換チップ、サウンドチップもSN76489そのまま。SG-1000のTMS9918A(Graphics I/II)からMode 4への拡張が最大の差分で、残りは周辺の違いに過ぎない。


1. SMS VDP Mode 4 — 4bpp planarタイルとスクロール計算

SG-1000のTMS9918AはGraphics I/IIモードで1bppの色付きタイルを描画していた。Game Gear(SMS互換VDP)のMode 4は根本的に異なる。

タイルフォーマット

Mode 4のタイルは 4bpp planar。1タイル8×8ピクセルで32バイト。1行あたり4バイトで、各バイトが1ビットプレーンを担当する。


タイル1行(4バイト):
b0: ビットプレーン0(各ピクセルのbit 0)
b1: ビットプレーン1(各ピクセルのbit 1)
b2: ビットプレーン2(各ピクセルのbit 2)
b3: ビットプレーン3(各ピクセルのbit 3)

Pac-Manの planes:{0,4}(同一バイト内の上位・下位4ビットが別プレーン)と違い、Mode 4は素直に 1バイト1プレーン の構造。デコードが直感的に書ける。

ネームテーブルと水平スクロールのハマりポイント

Mode 4のネームテーブルは1エントリ2バイト。H/Vフリップやパレット選択、BG優先フラグが保持されている。

描画時の重要ポイントが レジスタ8(R8)による水平スクロール計算 だ。

// 水平スクロールを加味した描画位置の計算
const screenX = ((col * 8) + hScroll) & 0xFF;

スクロール計算の符号に関する備忘録

最初、以下のようにマイナスで計算してしまうバグを埋め込んでいた。

// 旧(バグ)
screenX = ((col * 8) - hScroll) & 0xFF;

// 新(正しい実装)
screenX = ((col * 8) + hScroll) & 0xFF;

SMS VDPの仕様として、水平スクロールレジスタ(R8)の値を増やすとディスプレイ全体が右にシフトする。 つまり、ゲームで右方向にスクロール(例: ソニックが右に進み、カメラが右へ追従)する場合、VDPのR8値は 48 → 47 → 45 → ... → 0 → 255 → 250 のように減少していく。

  • - hScroll にしてしまうと、R8が減少したときに画面描画が右にずれてしまい、カメラが逆方向に進んでしまう。
  • + hScroll にすることで、R8減少時に描画位置が左へずれるため、結果として「カメラが右へ追従する」正解の挙動になる。

2. CRAM — Game Gear固有のRGB444カラー

SMSとGame Gearの最大の違いがカラーRAM(CRAM)のフォーマット。

SMSGame Gear
CRAMサイズ32バイト(32色×1バイト)64バイト(32色×2バイト)
カラー形式RGB222(6bit、64色中)RGB444(12bit、4096色中)
書き込み1バイト/色2バイトラッチ方式

ラッチ方式の実装

Game GearのCRAMは2バイトで1色を表現する。VDPのデータポートに連続で2バイト書くと、1バイト目がラッチされ、2バイト目の書き込みで両方が確定する。

writeData(val) {
  if (this.codeRegister === 3) {
    // CRAM write
    const addr = this.vramAddress & 0x3F;
    if (addr & 1) {
      // 2バイト目 → ラッチと合わせて確定
      this.cram[addr] = val;
      this.cram[addr & 0x3E] = this.cramLatch;  // ラッチされた1バイト目

      // RGB444 → RGBA8888 変換
      const entry = (addr & 0x3E) >> 1;
      const lo = this.cramLatch;   // ----GGGGRRRR
      const hi = val;              // --------BBBB
      const r = (lo & 0x0F) * 17;         // 0-15 → 0-255
      const g = ((lo >> 4) & 0x0F) * 17;
      const b = (hi & 0x0F) * 17;
      this.palette[entry] = 0xFF000000 | (b << 16) | (g << 8) | r;
    } else {
      // 1バイト目 → ラッチに保持
      this.cramLatch = val;
    }
  }
}

* 17 は4ビット値(0-15)を8ビット(0-255)に線形拡張する定番の変換。0x0F * 17 = 0xFF で最大値が一致する。

このラッチ方式を正しく実装しないと、色が全部化けるかCRAMが半端に更新されて画面がめちゃくちゃになる。


3. Segaマッパー — バンク切替

SG-1000は最大32KBのROMをフラットにマッピングしていたが、Game GearはROMサイズが最大512KBに達するため、Segaマッパーによるバンク切替が必要になる。

メモリマップ

$0000-$03FF : ROM先頭1KB(固定、常にページ0の先頭)
$0400-$3FFF : Slot 0($FFFDで切替)
$4000-$7FFF : Slot 1($FFFEで切替)
$8000-$BFFF : Slot 2($FFFFで切替)
$C000-$DFFF : RAM 8KB
$E000-$FFFF : RAMミラー($FFFC-$FFFFはマッパー制御レジスタとしても機能)

実装

マッパーの制御レジスタはRAM領域 $FFFC-$FFFF への書き込みで設定する。

function writeByte(addr, val) {
  if (addr >= 0xFFFC) {
    ram[addr & 0x1FFF] = val;
    const reg = addr - 0xFFFC;
    if (reg === 0) {
      // $FFFC: RAM mapping control
      ramMappingEnabled = (val & 0x08) !== 0;
    } else if (reg === 1) {
      // $FFFD: Slot 0 ページ番号
      mapperSlots[0] = val & 0x3F;
    } else if (reg === 2) {
      // $FFFE: Slot 1 ページ番号
      mapperSlots[1] = val & 0x3F;
    } else if (reg === 3) {
      // $FFFF: Slot 2 ページ番号
      mapperSlots[2] = val & 0x3F;
    }
    return;
  }
  if (addr >= 0xC000) {
    ram[addr & 0x1FFF] = val;
  }
}

ポイントは $0000-$03FF の固定マッピング。この1KBはSlot 0のページ切替に関係なく常にROMの先頭が見える。RST割り込みベクタやブートコードがここに置かれるため、切替で壊れないようになっている。


4. スキャンライン描画と割り込み処理

Game Gearの描画はスキャンライン単位で行う。NTSC準拠で1フレーム262ライン、アクティブ描画は192ライン。

VDP Register 10に設定した値でラインカウンタが初期化され、アクティブラインごとにデクリメントされる。0を下回るとラインカウンタ割り込みが発生する。

for (let line = 0; line < TOTAL_LINES; line++) {
  vdp.vCounter = line < 256 ? line : line - 6;

  // CPU実行(1ライン分)
  let lineCycles = 0;
  while (lineCycles < CYCLES_PER_LINE) {
    lineCycles += cpu.step();
  }

  if (line < ACTIVE_LINES) {
    vdp.renderLine(line, smsFramebuffer, 256);

    // ラインカウンタ割り込み
    vdp.lineCounter--;
    if (vdp.lineCounter < 0) {
      vdp.lineCounter = vdp.lineCounterLatch;
      if (vdp.registers[0] & 0x10) {
        cpu.interrupt(0xFF);  // IRQ発生
      }
    }
  } else if (line === ACTIVE_LINES) {
    // VBlank開始
    vdp.statusRegister |= 0x80;
    if (vdp.registers[1] & 0x20) {
      cpu.interrupt(0xFF);
    }
    vdp.lineCounter = vdp.lineCounterLatch;
  }
}

ラインカウンタ割り込みはソニックGGのステータスバー表示など、画面上下での描画モード・スクロール切替(H-Blank処理)に不可欠だ。


5. GGビューポート — 256×192から160×144へ

Game Gearのハードウェアは内部的にSMSと同じ256×192の画面を描画するが、液晶ディスプレイには中央の160×144ピクセルだけが表示される。

function renderFrame() {
  if (displayMode === 'gg') {
    // 256×192の中央160×144をクロップ
    const srcOffX = 48;   // (256 - 160) / 2
    const srcOffY = 24;   // (192 - 144) / 2
    for (let y = 0; y < 144; y++) {
      const srcRow = (srcOffY + y) * 256 + srcOffX;
      const dstRow = y * 160;
      for (let x = 0; x < 160; x++) {
        buf32[dstRow + x] = smsFramebuffer[srcRow + x];
      }
    }
  }
}

描画パイプライン自体はSMS互換の256×192をフルに描画し、最後にビューポートでクロップする設計にしている。


6. SN76489 PSG と SEGAロゴのPCM音声再生

サウンドチップはSG-1000と同一のSN76489だが、Game Gear / SMSのゲーム(特にSEGAロゴ画面)ではPSGのボリュームレジスタをCPUで極めて高速に書き換えることでPCM音声(「セーガー♪」ボイス)を再生するテクニックが使われている。

サイクル単位のPSG書き込みバッファリング

通常フレーム単位で音声を処理すると、高頻度で書き込まれるボリューム変更が潰れてしまい、「セーガー♪」の音声がただのノイズや無音になってしまう。

これを正しく再現するため、メインループ内でPSG書き込みが発生した際、その時のCPUサイクル数とともにバッファへ保持し、AudioWorkletへ渡す実装を行った。

function writePSG(val) {
  // レジスタ更新処理...

  // サイクルタイムスタンプ付きで書き込みログを保持
  psgWriteBuffer.push({
    cycle: frameCycleCount,
    toneRegs: Array.from(psgToneRegs),
    volRegs: Array.from(psgVolRegs)
  });
}

AudioWorklet側の受取処理

AudioWorklet側では、受け取ったサイクル数に応じてリアルタイムにPSGレジスタ状態を更新し、波形を生成する。

// AudioWorkletProcessor内部の処理
for (let i = 0; i < len; i++) {
  if (this.currentFrame) {
    const writes = this.currentFrame.writes;
    // 現在のサンプルが到達したサイクルまでのPSG書き込みを適用
    while (this.writeIdx < writes.length && writes[this.writeIdx].cycle <= this.currentCycle) {
      const w = writes[this.writeIdx];
      this.toneRegs = w.toneRegs;
      this.volRegs = w.volRegs;
      this.writeIdx++;
    }
    this.currentCycle += cyclesPerSample;
  }

  // 周期0/1の特殊設定時は音量値を直接DACサンプル(PCM)として再生
  let sample = 0;
  for (let ch = 0; ch < 3; ch++) {
    const period = this.toneRegs[ch];
    if (period <= 1) {
      // 周期が極小の場合はボリュームレジスタ値をPCM直接出力として扱う
      sample += (1.0 - (this.volRegs[ch] / 15.0)) * 2.0 - 1.0;
    } else {
      // 通常の矩形波出力
      // ...
    }
  }
  out[i] = sample * 0.15;
}

このサイクル単位の精密なバッファリングにより、起動時の「セーガー♪」というSEGAロゴのボイスが見事にクリアに再生されるようになった。


7. I/Oポートとダイレクトブート

I/OポートではGame Gear固有のStartボタン(Port $00)に対応。また、BIOS ROMの読み込み処理をスキップし、ROMの $0000 から直接実行するダイレクトブート方式を採用した。

function inPort(port) {
  port &= 0xFF;

  // GG固有ポート
  if (port === 0x00) return ggStartButton | 0x40; // bit 7: Start (active low)
  if (port <= 0x06) return 0xFF;

  // VDP & コントローラ処理...
  return 0xFF;
}

到達点

動作確認済み機能

  • ソニック・ザ・ヘッジホッグGG — SEGAロゴ、「セーガー♪」PCMボイス、タイトル画面、ゲーム本編、BGM完全動作
  • ドラッグ&ドロップによる各種ROMファイル(.gg, .sms, .bin)のダイレクト読み込み
  • SMSモード(256×192)/ GGモード(160×144)の切替表示
  • 512バイトコピアヘッダの自動除去
  • フルスクリーン対応、スケーリング選択

まとめ

今回のGame Gearエミュレーター実装では、過去のSG-1000資産を活用しつつも、以下の重要な学びが得られた。

  1. VDP Mode 4の描画仕様とスクロール計算: スクロールレジスタ(R8)の「増加=右シフト」という仕様を正しく理解し、+ hScroll で計算することで正確な画面スクロールを実現できた。
  2. PSGによるPCM音声再生: 単なるBGM演奏だけでなく、CPUサイクル単位で音量レジスタの書き込みを同期させることで、PSGを用いた「SEGAボイス」の再生に対応できた。

Z80コアを汎用的に設計し、周辺回路をバスコールバックで分離しておく設計の強みが、8台目のターゲットでも遺憾なく発揮された開発となった。