[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 - Wikipedia
The Game Gear[a] is a handheld game console developed and marketed by Sega. It was released in Japan on October 6, 1990, in North America and Europe in April 1991, and in Australia in 1992.
en.wikipedia.orgGame Gearを選んだ理由は、SG-1000の実装資産を最大限に活かせるから。Game GearはマスターシステムのほぼCPU互換機で、VDPはTMS9918AのMode 4拡張、サウンドはSN76489そのもの。SG-1000の経験があれば「差分実装」で動くはず — そういう目算だった。
結果から言うと、ソニック・ザ_ヘッジホッグGGがSEGAロゴ(おなじみの「セーガー♪」ボイス付き)・タイトル画面・BGMまで完璧に動作した。
前回の記事
[JavaScript] 自作Z80コアでPac-Manエミュレーターを実装。MAMEのビットプレーン解析からZIP ROMローダーまでの全記録
Z80コアの7台目。MAMEのtilelayout解析からNamco WSGサウンドまで。
lain-lab.comスクリーンショット
SEGAロゴ起動画面
ソニック・ザ・ヘッジホッグGG タイトル画面
Wolfenstein // under4mhz
動画
SMS Powerさんのサイトより、under4mhz様作の「Wolfenstein」をお借りしてます。
Wolfenstein // under4mhz
Captured by the enemy, it's up to you to escape from Castle Wolfenstein and inform the Allies of the dark plans of the Axis. Escape your cell and find your way to the next level, collecting weapons, ammo and treasure along the way. Defeat the mutant general to escape the castle and to reveal your discoveries and save the world.
www.smspower.orgMai Nurse // lunoka
動画
SMS Powerさんのサイトより、lunoka様作の「Mai Nurse」をお借りしてます。
Mai Nurse // lunoka
Help Mai get rid of viruses with pills, line up 4 slots of the same color horizontally or vertically to destroy them and progress through the levels in this classic puzzle game! Made with GBDK2020. Game Gear version also available!
www.smspower.orgシステム構成
| 項目 | Game Gear | 参考: SG-1000 |
|---|---|---|
| CPU | Z80A @ 3.58MHz | Z80A @ 3.58MHz |
| RAM | 8KB (DFFF、$E000ミラー) | 1KB (C3FF) |
| VRAM | 16KB | 16KB |
| 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)のフォーマット。
| SMS | Game 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資産を活用しつつも、以下の重要な学びが得られた。
- VDP Mode 4の描画仕様とスクロール計算:
スクロールレジスタ(R8)の「増加=右シフト」という仕様を正しく理解し、
+ hScrollで計算することで正確な画面スクロールを実現できた。 - PSGによるPCM音声再生: 単なるBGM演奏だけでなく、CPUサイクル単位で音量レジスタの書き込みを同期させることで、PSGを用いた「SEGAボイス」の再生に対応できた。
Z80コアを汎用的に設計し、周辺回路をバスコールバックで分離しておく設計の強みが、8台目のターゲットでも遺憾なく発揮された開発となった。