[JavaScript] MSXエミュレーターを完成させる。MegaROMマッパー・PSG/SCC音源・AudioWorkletでのサウンド合成

[JavaScript] MSXエミュレーターを完成させる。MegaROMマッパー・PSG/SCC音源・AudioWorkletでのサウンド合成

はじめに

前回の記事では、自作のZ80 CPUコア上にMSXの基礎ハードウェア(TMS9918 VDP、PPI、スロット機構)を組み上げ、32KBまでのフラットROMを動作させるところまでを達成した。

しかし、当時のMSXの名作ゲームたちの多くは128KB〜512KBといった大容量の「MegaROM」であり、特異なサウンドを奏でる波形メモリ音源「SCC」を搭載していた。

この記事では、32KBの制限を破るMegaROMマッパー群の実装、PSGおよびSCC音源のWeb Audio API(AudioWorklet)によるリアルタイム合成、そして画面描画精度の向上とレスポンシブ対応まで、MSXエミュレーターを完成へと導いた技術的詳細を記録する。

スクリーンショット

KONAMI(SCC)

[JavaScript] MSXエミュレーターを完成させる。MegaROMマッパー・PSG/SCC音源・AudioWorkletでのサウンド合成 [JavaScript] MSXエミュレーターを完成させる。MegaROMマッパー・PSG/SCC音源・AudioWorkletでのサウンド合成

The Menace from Triton / Santiago Ontañón (Brain Games)

[JavaScript] MSXエミュレーターを完成させる。MegaROMマッパー・PSG/SCC音源・AudioWorkletでのサウンド合成 [JavaScript] MSXエミュレーターを完成させる。MegaROMマッパー・PSG/SCC音源・AudioWorkletでのサウンド合成 [JavaScript] MSXエミュレーターを完成させる。MegaROMマッパー・PSG/SCC音源・AudioWorkletでのサウンド合成

動画(GIF) / The Menace from Triton / Santiago Ontañón (Brain Games)

[JavaScript] MSXエミュレーターを完成させる。MegaROMマッパー・PSG/SCC音源・AudioWorkletでのサウンド合成 [JavaScript] MSXエミュレーターを完成させる。MegaROMマッパー・PSG/SCC音源・AudioWorkletでのサウンド合成

サンプルデータ

著作権フリーのC-BIOSは、「C-BIOS Association」様のサイトよりお借りしています。

フリーゲームROMはmsxdevさんのサイトからSantiago Ontañón (Brain Games)様作の「The Menace from Triton」をお借りしています。

1. MegaROMマッパーの実装

Z80の論理アドレス空間は64KBしかない。128KBや512KBといった大容量ROMを読み込むため、MSXではカートリッジ内に「マッパーIC」を搭載し、特定のメモリ領域に書き込みを行うことでバンク(ページ)を動的に切り替える仕組みを採用している。

1-1. 主要マッパーの仕様と切り替えロジック

本エミュレーターでは、代表的な4つのマッパー仕様に対応した。

マッパー種別バンクサイズバンク数切り替え制御アドレス主な搭載タイトル
Konami8KB × 4Page 1, 2 (0x4000~0xBF3F)0x6000, 0x8000, 0xA000『グラディウス』『魔城伝説』
Konami SCC8KB × 4Page 1, 2 (0x4000~0xBF3F)0x5000, 0x7000, 0x9000, 0xB000『沙羅曼蛇』『スナッチャー』
ASCII 8KB8KB × 4Page 1, 2 (0x4000~0xBF3F)0x6000, 0x6800, 0x7000, 0x7800『ハイドライド3』『ゼビウス』
ASCII 16KB16KB × 2Page 1, 2 (0x4000~0xBF3F)0x6000, 0x7000『ウィザードリィ』

マッパーの実装は、メモリ書き込み(writeMemory)をインターセプトしてバンク番号を更新し、読み出し(readMemory)時にそのバンクを参照する構造にする。

// Konami SCC マッパーのメモリ書き込み制御
function writeMapperKonamiSCC(addr, val) {
  if (addr >= 0x4000 && addr <= 0x5FFF) {
    sccBank[0] = val % totalBanks;
  } else if (addr >= 0x6000 && addr <= 0x7FFF) {
    sccBank[1] = val % totalBanks;
  } else if (addr >= 0x8000 && addr <= 0x9FFF) {
    sccBank[2] = val % totalBanks;
    // 0x3F(Bit 5)が立つと0x9800-0x97FF領域がSCC音源レジスタに切り替わる
    sccEnabled = ((val & 0x3F) === 0x3F);
  } else if (addr >= 0xA000 && addr <= 0xBFFF) {
    sccBank[3] = val % totalBanks;
  }
}

1-2. バンク選択とメモリアクセスの高速化

毎アクセスごとに計算処理を行うコストを避けるため、各ページ(0x2000バイト単位)のポインタ配列を事前にインデックス化して解決している。

function readSlot1(addr) {
  // addr: 0x4000 ~ 0xBFFF (32KB領域)
  const page = (addr - 0x4000) >> 13; // 0 ~ 3 (8KB単位)
  const offset = addr & 0x1FFF;

  // Konami SCC音源レジスタの特殊判定
  if (page === 2 && sccEnabled && offset >= 0x1800 && offset <= 0x1FBF) {
    return readSCCRegister(offset & 0xFF);
  }

  const bankIndex = sccBank[page];
  return romBuffer[bankIndex * 0x2000 + offset];
}

2. PSG (AY-3-8910) サウンドエミュレーション

PSGは矩形波3チャンネル+ノイズ1チャンネルを備えたMSX標準の音源チップ。I/Oポート 0xA0(レジスタ選択)および 0xA1(データ書込)を介して制御される。

2-1. レジスタ構造と周波数計算

PSGは全16個のレジスタを持つ。

  • R#0 ~ R#5: チャンネルA/B/C の周期(Fine / Coarse)
  • R#6: ノイズ周期
  • R#7: ミキサー制御(各CHのトーン/ノイズ enable)
  • R#8 ~ R#10: チャンネルA/B/C の音量 / エンベロープ選択

クロック周波数 fclock=3.579545 MHzf_{clock} = 3.579545 \text{ MHz} とすると、生成される音の周波数 ff はレジスタに設定された12bitカウント値 TPTP から以下のように計算される。

f=fclock16×TPf = \frac{f_{clock}}{16 \times TP}

JavaScript側では、AudioWorkletのレンダリングループ内でこのカウント値を消費しながら出力信号の反転状態(1 または -1)を保持する。

// AudioWorkletProcessor 内でのPSGトーン生成
generateTone(ch, samples) {
  const tp = this.registers[ch * 2] | ((this.registers[ch * 2 + 1] & 0x0F) << 8);
  if (tp < 1) return 0;

  // サンプリングレート変換用のインクリメント値
  const periodInSamples = (tp * 16) / clockRate;
  this.toneCounters[ch] += 1;

  if (this.toneCounters[ch] >= periodInSamples) {
    this.toneCounters[ch] -= periodInSamples;
    this.toneStates[ch] = !this.toneStates[ch]; // 波形の反転
  }

  return this.toneStates[ch] ? 1 : -1;
}

2-2. ノイズ生成(LFSR)

PSGのノイズは17ビットの線形帰還シフトレジスタ(LFSR)によって擬似ランダムパルスを生成する。Bit 0 と Bit 3 のXOR結果をフィードバックすることで、爆発音や打楽器音を再現する。


3. 伝説の波形メモリ音源「SCC」の実装

コナミが独自開発したサウンドチップ SCC (Sound Creative Chip) は、5チャンネルのウェーブテーブル音源。各チャンネルが32バイト(5bit符号付き振幅値)の波形RAMを持ち、自由な波形をリアルタイムに書き換えることで独創的なサウンドを実現している。

3-1. SCCのメモリマップと構造

SCCは独立したI/Oポートを持たない。MegaROMの特定バンク(Bank 2 = 0x8000~0x9FFF)に 0x3F を書き込むと、0x9800 ~ 0x97FF 領域がROMからSCCレジスタへ切り替わる。

0x9800 - 0x981F : Channel 1 波形データ (32 bytes)
0x9820 - 0x983F : Channel 2 波形データ (32 bytes)
0x9840 - 0x985F : Channel 3 波形データ (32 bytes)
0x9860 - 0x987F : Channel 4 波形データ (32 bytes)
                  (※ Channel 5 も Ch4 の波形データを共有する)
0x9880 - 0x9889 : 各CH 周波数カウント値 (13bit × 5)
0x988A - 0x988E : 各CH 音量 (4bit × 5)
0x988F          : チャンネルOn/Offマスク (5bit)

3-2. ウェーブテーブル波形合成

SCCの波形データは8bitの符号付き整数(-128 ~ 127)として格納されている。これをAudioWorklet内で巡回読み出ししながらリアルタイムにミックスする。

// AudioWorklet 内でのSCC波形生成
generateSCC(ch) {
  if (!(this.sccMask & (1 << ch))) return 0;

  const tp = this.sccFreq[ch];
  if (tp < 9) return 0; // 実機同様、極端に短い周期はミュート

  // 波形ポインタの進幅計算
  const inc = (3579545 / 32) / (tp + 1) / sampleRate;
  this.sccPhase[ch] = (this.sccPhase[ch] + inc) % 32;

  const waveIdx = Math.floor(this.sccPhase[ch]);
  // Ch4 と Ch5 は同じ波形メモリ領域(Ch4のデータ)を参照する
  const waveTableOffset = (ch === 4 ? 3 : ch) * 32;
  const rawSample = this.sccWaveRAM[waveTableOffset + waveIdx];

  // 音量適用 (-1.0 ~ 1.0 に正規化)
  return (rawSample / 128.0) * (this.sccVolume[ch] / 15.0);
}

4. AudioWorkletによる低遅延リアルタイム音声合成

JavaScriptでの音源開発において最大の課題は、メインスレッド(描画・Z80 CPU実行)のレイテンシーやフレーム落ちが直接音飛びを引き起こす点にある。

これを解決するため、Web Audio API の AudioWorklet を採用した。

4-1. スレッド間通信アーキテクチャ

Z80のI/O処理やメモリ書き込みが発生した瞬間に、メインスレッドから port.postMessage でレジスタ変更情報をメッセージとしてAudioWorkletへ送信する。

[ Main Thread ]                            [ AudioWorklet Thread ]
 Z80 CPU Step                               AudioWorkletProcessor
   │                                           │
   ├─ OUT (0xA1), val ── (MessagePort) ───────>├─ Update Register
   ├─ Write VRAM (0x9800)                      │  Generate 128 samples
   │                                           ├─ Audio Buffer Output
// エミュレーター本体からのレジスタ更新通知
writePSG(reg, val) {
  this.psgRegisters[reg] = val;
  if (this.audioNode) {
    this.audioNode.port.postMessage({ type: 'psg', reg, val });
  }
}

これで、メインスレッドが描画重度なフレームで一瞬遅延しても、AudioWorklet側の独立した高優先度オーディオスレッドで波形生成が途切れることなく処理され、極めて低遅延かつ滑らかなサウンド出力を達成した。


5. VDPスプライト描画の精度向上(0xD0 打ち切り対応)

サウンド実装と並行して、画面描画(VDP)のバグ修正も行った。TMS9918には「スプライトアトリビュートテーブル(SAT)を走査中、Y座標に 208 (0xD0) が設定されたスプライトに遭遇すると、それ以降のスプライト描画を全て中止する」という仕様がある。

5-1. バグの原因と修正

従来のコードでは単に if (sy === 0xD0) continue; と書いており、1つだけをスキップして後ろのスプライトを描画していた。さらに、重なり順(番号が若いスプライトが手前)を正しく処理するために逆順でループしていたことが問題を複雑化させていた。

修正コードでは、「0番から順に走査して 0xD0 で break 打ち切りを行い、収集した有効なスプライトのみを逆順描画する」 という2段階の処理に変更した。

// 1. 0番から順に走査し、0xD0 (208) に遭遇したら打ち切る
const activeSprites = [];
for (let i = 0; i < 32; i++) {
  const base = sprAttrBase + i * 4;
  const sy = vdp.vram[base & 0x3FFF];
  if (sy === 0xD0) break; // 0xD0 以降のスプライトはすべて非表示
  activeSprites.push(i);
}

// 2. 重なり順(番号が小さい方が手前)を維持するため、逆順でキャンバスへ描画
for (let k = activeSprites.length - 1; k >= 0; k--) {
  const i = activeSprites[k];
  // 以下、パターンの読み出しとピクセル展開処理...
}

この修正により、スプライトのチラツキや画面外に消えたはずのキャラが残像として残る不具合が完全に解消された。


6. レスポンシブ&フルスクリーン対応

完成したエミュレーターのユーザー体験を高めるため、アスペクト比固定のレスポンシブデザインとフルスクリーンモード(Fullscreen API)を実装した。

6-1. CSSでの 4:3 レシオ維持

MSXの画面解像度(256×192)のアスペクト比は 4:3。モダンCSSの aspect-ratio と min() / calc() を用いることで、どんな画面サイズでも潰れることなくレスポンシブに追従させる。

canvas {
  width: 100%;
  max-width: min(100%, calc((100vh - 100px) * (4 / 3)));
  height: auto;
  aspect-ratio: 4 / 3;
  image-rendering: pixelated; /* ドット絵をぼかさずくっきり表示 */
  border: 4px solid #333;
  background: #000;
  box-sizing: border-box;
}

/* フルスクリーン表示時のスタイル */
canvas:fullscreen {
  width: 100vw;
  height: 100vh;
  max-width: none;
  border: none;
  object-fit: contain;
}

到達点と動作実績

今回の機能拡張により、本エミュレーター(z80-emulator v1)は実用的なスペックへ到達した。

項目ステータス
CPU / システムZ80A(全命令・割り込み・遅延EI対応)、PPI、C-BIOS
VDPTMS9918 (Screen 1/2、スプライト拡大・Early Clock・0xD0 打ち切り)
音源AY-3-8910 (PSG) + Konami SCC (5ch波形メモリ音源) リアルタイム合成
マッパーFlat (32KB), Konami, Konami SCC, ASCII 8KB, ASCII 16KB
UI / 表示4:3 レスポンシブ表示、Fullscreen API、ドラッグ&ドロップROMロード

動作確認タイトル

  • 『沙羅曼蛇』 (Konami, 1987 - MegaROM 128KB / Konami SCC)

  • オープニングの重厚なSCCサウンド、グラフィック描画、スムーズなスクロールを確認。

  • 『グラディウス』 (Konami, 1986 - MegaROM 128KB / Konami)

  • 空中戦BGM、パワーアップシステム、スプライト描画ともに完璧に動作。

  • 『ハイドライド3』 (T&E SOFT, 1987 - MegaROM 256KB / ASCII 8KB)

  • 大容量マッパーの切り替えとゲームプレイを確認。

ブラウザを立ち上げるだけで、外部ライブラリなしの純粋なJavaScriptとWeb Audio APIによってあの頃のMSXサウンドとグラフィックが蘇る。エミュレーター開発の醍醐味を凝縮したプロジェクトとなった。