[JavaScript] 自作Z80コアでSG-1000エミュレーターを実装。BIOS不要・SN76489音源・TMS9918A共有設計の全記録

[JavaScript] 自作Z80コアでSG-1000エミュレーターを実装。BIOS不要・SN76489音源・TMS9918A共有設計の全記録

はじめに

前回の記事では、自作Z80コア上にMSX1のハードウェア一式(TMS9918A VDP、AY-3-8910 PSG、Konami SCC、MegaROMマッパー群)を構築し、ブラウザ上で完全動作するMSXエミュレーターを完成。

Z80コアをゼロから自作した設計方針の根底には、

「バスをコールバックで完全分離すれば、どんなZ80搭載機にも載せ替えられる」

という思想がある。それを証明する最初のターゲットとして、セガ初の家庭用ゲーム機 SG-1000 を選択しました。

SG-1000のハードウェア構成はZ80A + TMS9918A + SN76489という3チップ構成で、MSXと共通するコンポーネントが多い。 BIOSを持たないベアメタル設計のため、カートリッジROMをメモリ先頭にマップしてZ80のリセットベクタからそのまま実行を開始する、という極めてシンプルな起動シーケンスを持つ。

この記事では、既存のZ80コアとTMS9918Aモジュールを再利用しながら、SG-1000固有のメモリマップ・I/Oデコード・SN76489 PSGの実装、そしてデバッグ過程で遭遇した問題を記録する。

スクリーンショット

Palikat // helmha

smspowerさんのサイトより、helmha様作「Palikat」をお借りしています。


Arno Dash // under4mhz

smspowerさんのサイトより、under4mhz様作「Arno Dash」をお借りしています。

動画(GIF)

SG-1000のハードウェア概要

SG-1000は1983年7月15日、ファミコンと同日に発売されたセガ初の家庭用ゲーム機である。

項目仕様
CPUZilog Z80A @ 3.579545 MHz
VDPTexas Instruments TMS9918A(256×192、16色、スプライト32枚)
PSGTexas Instruments SN76489AN(矩形波3ch + ノイズ1ch)
RAM1KB(C000−C000-C3FF、C000−C000-FFFF にミラー)
VRAM16KB(VDP内蔵)
ROMカートリッジ(最大48KB、0000−0000-BFFF)
BIOSなし

MSXとの最大の違いは「BIOSが存在しない」ことと、PSGがAY-3-8910ではなくSN76489であること。VDPは同一チップだが、I/Oポートのアドレスが異なる。

1. コア資産の再利用設計

1-1. プロジェクト構成の階層分離

MSXエミュレーターと同一ディレクトリにコードが混在する状態を解消し、Z80コアを共有モジュールとして切り出すディレクトリ構成を採用した。

/public/z80-emulator/
  index.html              ← Z80プロジェクト総括ページ(多言語対応)
  core/
    z80.js                ← 共有Z80 CPUコア(~1,200行)
    tms9918.js            ← 共有TMS9918A VDPコア
    tests/
      z80-tests.js        ← ZEXDOC / INT・NMI 統合テスト
  msx1/
    index.html            ← MSX1エミュレーター
  sg-1000/
    index.html            ← SG-1000エミュレーター(本記事)

各エミュレーターは ../core/z80.js と ../core/tms9918.js を ES Modules の import で参照する。Z80コアにバグ修正が入れば、両エミュレーターに同時に反映される。

import { Z80 } from '../core/z80.js';
import { TMS9918 } from '../core/tms9918.js';

1-2. Z80コアのバス分離設計が効いた場面

Z80コアは設計時からバスアクセスをコンストラクタ注入のコールバックで完全に外部化している。

const cpu = new Z80({
  readByte: (addr) => { /* メモリ読み出し */ },
  writeByte: (addr, val) => { /* メモリ書き込み */ },
  inPort: (port) => { /* I/Oポート読み出し */ },
  outPort: (port, val) => { /* I/Oポート書き込み */ },
});

MSXからSG-1000への移植で変更が必要だったのは、この4つのコールバックの中身だけ。Z80コア自体は1行も変更していない。ZEXDOC全67項目・INT/NMI全34項目をパスした検証済みコアがそのまま動く安心感は大きい。


2. メモリマップの実装

SG-1000のメモリマップはMSXのスロット機構と比較すると極めて単純である。

$0000 - $BFFF : カートリッジROM(最大48KB)
$C000 - $FFFF : RAM 1KB($C000-$C3FF の実体が $FFFF まで繰り返しミラー)

BIOSもスロット切り替えもマッパーIC(大半のタイトル)も存在しない。リセット時、Z80は$0000番地から命令フェッチを開始し、そこにはカートリッジROMが直接マップされている。

let cartRom = null;
let cartSize = 0;
const ram = new Uint8Array(0x400); // 1KB

function readByte(addr) {
  addr &= 0xFFFF;
  if (addr < 0xC000) {
    if (!cartRom || addr >= cartSize) return 0xFF;
    return cartRom[addr];
  }
  return ram[addr & 0x3FF]; // 1KB ミラー
}

function writeByte(addr, val) {
  addr &= 0xFFFF;
  if (addr >= 0xC000) {
    ram[addr & 0x3FF] = val & 0xFF;
  }
  // $0000-$BFFF への書き込みは無視(ROM領域)
}

MSXの場合、PPI (8255) によるスロット選択、0xA8 ポートによるページ割り当て、MegaROMマッパーのバンク切り替えなど、メモリアクセスだけで200行近い処理が必要だった。SG-1000ではこれが10行で済んでいる。


3. I/Oポートの部分デコードと配線

3-1. SG-1000のI/Oマップ

SG-1000のI/Oアドレスデコードは「部分デコード」方式を採用しており、アドレスバスの全8ビットではなく一部のビットだけでデバイスを選択する。具体的には、ポート番号の Bit 7, Bit 6, Bit 0 の3ビットだけが有効である。

ポート範囲Bit7:6:0方向デバイス
7E−7E-7F01-0/1WriteSN76489 PSG
$7E01-0ReadV Counter(垂直走査位置)
$7F01-1ReadH Counter(水平走査位置)
$BE10-0R/WTMS9918A データポート
$BF10-1R/WTMS9918A コントロール/ステータス
$DC11-0Readコントローラー ポート1
$DD11-1Readコントローラー ポート2

MSXではVDPが $98 / $99、PSGが $A0 / $A1 / $A2 に配置されていたが、SG-1000ではまったく異なるアドレスに配線されている。しかしVDPチップ自体は同一のTMS9918Aなので、readData() / writeData() / readStatus() / writeControl() はそのまま流用できる。

3-2. 部分デコードの実装

function inPort(port) {
  port &= 0xFF;
  if ((port & 0xC1) === 0x40) return 0x00;      // $7E: V counter (stub)
  if ((port & 0xC1) === 0x41) return 0x00;      // $7F: H counter (stub)
  if ((port & 0xC1) === 0x80) return vdp.readData();   // $BE
  if ((port & 0xC1) === 0x81) return vdp.readStatus(); // $BF
  if ((port & 0xC1) === 0xC0) return controller1;      // $DC
  if ((port & 0xC1) === 0xC1) return 0xFF;             // $DD
  return 0xFF;
}

function outPort(port, val) {
  port &= 0xFF;
  if ((port & 0xC1) === 0x40 || (port & 0xC1) === 0x41) {
    writePSG(val);              // $7E/$7F → SN76489
  } else if ((port & 0xC1) === 0x80) {
    vdp.writeData(val);         // $BE
  } else if ((port & 0xC1) === 0x81) {
    vdp.writeControl(val);      // $BF
  }
}

port & 0xC1 というマスクで Bit 7, 6, 0 だけを抽出し、3ビットのパターンでデバイスを判別している。これにより $BE だけでなく $BC, $BA など偶数ポート全体がVDPデータポートとして応答する。実機の挙動に忠実な実装である。


4. SN76489 PSGの実装

4-1. SN76489 vs AY-3-8910

MSXのAY-3-8910とSG-1000のSN76489はどちらもPSGだが、設計思想が大きく異なる。

特性AY-3-8910 (MSX)SN76489 (SG-1000)
チャンネル構成トーン3ch + ノイズ1chトーン3ch + ノイズ1ch
音量制御4bit リニア + エンベロープジェネレーター4bit 対数減衰(2dBステップ)
レジスタアクセスアドレス/データの2ポート分離方式単一データポートへのラッチ/データ書き込み
I/Oポート汎用I/Oポート2本を内蔵なし(純粋な音源チップ)
エンベロープハードウェアエンベロープジェネレーター搭載なし(ソフトウェアで制御)

SN76489は「書き込み専用の単一ポート」という極めてシンプルなインターフェースを持つ。これがMSXのPSGと比べて実装コストを大幅に下げている。

4-2. ラッチ/データ書き込みプロトコル

SN76489への書き込みは1バイト単位で、最上位ビットで「ラッチバイト」か「データバイト」かを区別する。

ラッチバイト(Bit 7 = 1):

Bit 7   : 1(ラッチ識別)
Bit 6-5 : チャンネル番号(0-3)
Bit 4   : レジスタタイプ(0 = 周波数/ノイズ制御、1 = 音量)
Bit 3-0 : データ下位4ビット

データバイト(Bit 7 = 0):

Bit 7   : 0(データ識別)
Bit 5-0 : データ上位6ビット(直前にラッチしたレジスタに継続書き込み)

トーン周波数は10ビット値で、ラッチバイトの下位4ビットとデータバイトの下位6ビットを結合して構成される。

let psgLatchedChannel = 0;
let psgLatchedType = 0;
const psgToneRegs = new Uint16Array(4);
const psgVolRegs = new Uint8Array([0x0F, 0x0F, 0x0F, 0x0F]); // 15=消音

function writePSG(val) {
  val &= 0xFF;
  if (val & 0x80) {
    // ラッチバイト
    psgLatchedChannel = (val >> 5) & 3;
    psgLatchedType = (val >> 4) & 1;
    const data = val & 0x0F;
    if (psgLatchedType === 1) {
      psgVolRegs[psgLatchedChannel] = data;
    } else {
      if (psgLatchedChannel === 3) {
        psgToneRegs[3] = data & 0x07; // ノイズ制御レジスタ
      } else {
        // 下位4ビットのみ更新(上位は保持)
        psgToneRegs[psgLatchedChannel] =
          (psgToneRegs[psgLatchedChannel] & 0x3F0) | data;
      }
    }
  } else {
    // データバイト — 直前にラッチしたレジスタへ継続書き込み
    const data = val & 0x3F;
    if (psgLatchedType === 1) {
      psgVolRegs[psgLatchedChannel] = data & 0x0F;
    } else {
      if (psgLatchedChannel === 3) {
        psgToneRegs[3] = data & 0x07;
      } else {
        // 上位6ビットを更新(下位4ビットは保持)
        psgToneRegs[psgLatchedChannel] =
          (psgToneRegs[psgLatchedChannel] & 0x0F) | (data << 4);
      }
    }
  }
}

4-3. 音量の対数減衰テーブル

SN76489の音量は「減衰量」として指定される。0が最大音量、15が消音。各ステップは2dBの減衰に相当する。

// AudioWorklet内のボリュームテーブル
const volTable = new Float64Array(16);
for (let i = 0; i < 15; i++) {
  volTable[i] = Math.pow(10, -0.1 * i); // 2dBステップ
}
volTable[15] = 0.0; // 完全消音

AY-3-8910のリニアな音量制御と異なり、SN76489の対数減衰は人間の聴覚特性に合致しており、少ないビット数でも自然な音量変化を実現している。

4-4. ノイズチャンネルの実装

ノイズチャンネル(Ch3)は16ビットのLFSR(線形帰還シフトレジスタ)で擬似乱数パルスを生成する。

ノイズ制御レジスタ(3ビット):
  Bit 2   : フィードバックタイプ(0 = 周期性ノイズ、1 = ホワイトノイズ)
  Bit 1-0 : シフトレート
             00 = N/512,  01 = N/1024,  10 = N/2048
             11 = トーンCh2の周期に同期
// ホワイトノイズ: Bit 0 と Bit 3 の XOR をフィードバック
// 周期性ノイズ:   Bit 0 のみをフィードバック
const feedback = whiteFeedback
  ? ((this.lfsr & 1) ^ ((this.lfsr >> 3) & 1))
  : (this.lfsr & 1);
this.lfsr = (this.lfsr >> 1) | (feedback << 15);

シフトレート 11 の場合、ノイズの周波数がトーンCh2の周波数に同期する。これを利用してドラム音やエンジン音などの音色を作り出すテクニックが当時のゲームで多用された。

4-5. AudioWorkletによるリアルタイム合成

MSXエミュレーターと同様、音声合成はAudioWorkletスレッドで行い、メインスレッドの描画負荷から完全に分離している。

[ Main Thread ]                          [ AudioWorklet Thread ]
  Z80 CPU Step                             SN76489Processor
    │                                        │
    ├─ OUT ($7E), val                        │
    │   └─ writePSG(val)                     │
    │       └─ postMessage({toneRegs, volRegs}) ──> onmessage
    │                                        │     └─ レジスタ更新
    │                                        ├─ トーン3ch 矩形波生成
    │                                        ├─ ノイズ LFSR 生成
    │                                        ├─ ボリューム適用 & ミックス
    │                                        └─ Audio Buffer 出力

SN76489の内部クロックは入力クロック(3,579,545 Hz)を16分周した 223,721 Hz で動作する。AudioWorkletのサンプルレート(48,000 Hz)に変換するため、1サンプルあたりのカウンタ消費量を 223721.5625 / sampleRate として計算する。


5. コントローラー入力

SG-1000のコントローラーはポート $DC から読み出す。各ビットがアクティブロー(押下時 = 0)で対応する。

Port $DC (Read):
  Bit 0 : Player 1 — Up
  Bit 1 : Player 1 — Down
  Bit 2 : Player 1 — Left
  Bit 3 : Player 1 — Right
  Bit 4 : Player 1 — Button 1 (TL)
  Bit 5 : Player 1 — Button 2 (TR)
  Bit 6 : Player 2 — Up
  Bit 7 : Player 2 — Down

キーボードイベントからビットマスクを操作するだけの単純な実装で済む。MSXのPPIキーボードマトリクス(8行×11列のスキャンコード変換)と比較すると、桁違いにシンプルである。

let controller1 = 0xFF; // 全ボタン離し状態

const keyMap = {
  ArrowUp:    0x01,
  ArrowDown:  0x02,
  ArrowLeft:  0x04,
  ArrowRight: 0x08,
  KeyZ:       0x10, // Button 1
  KeyX:       0x20, // Button 2
};

document.addEventListener('keydown', (e) => {
  const bit = keyMap[e.code];
  if (bit) { controller1 &= ~bit; e.preventDefault(); }
});

document.addEventListener('keyup', (e) => {
  const bit = keyMap[e.code];
  if (bit) { controller1 |= bit; e.preventDefault(); }
});

6. VBlank割り込みとメインループ

SG-1000のCPUクロック(3,579,545 Hz)をNTSCのフレームレート(約60fps)で割ると、1フレームあたり約 59,659 T-states となる。これはMSX1と同一の値である(同じ水晶振動子を使用しているため当然ではある)。

const CYCLES_PER_FRAME = 59659;

function mainLoop() {
  let executedCycles = 0;
  while (executedCycles < CYCLES_PER_FRAME) {
    executedCycles += cpu.step();
  }

  // VBlank 割り込み発火
  vdp.statusRegister |= 0x80;
  if ((vdp.registers[1] & 0x20) !== 0) {
    cpu.interrupt(0xFF); // IM1: RST 38h
  }

  renderScreen();
  requestAnimationFrame(mainLoop);
}

SG-1000は割り込みモード1(IM1)を使用し、VBlank時に $0038 へジャンプする。MSXも同じ IM1 を使うが、MSXではBIOSのISR(割り込みサービスルーチン)がキーボードスキャンやVDPステータス読み出しを行うのに対し、SG-1000ではゲームプログラムが直接 $0038 に割り込みハンドラを配置する。


7. デバッグ事例:「The Castle」の初期化遅延問題

7-1. 症状

ほとんどのROMが即座に画面を表示する中、「The Castle (Japan)」だけが黒画面のまま動作しないように見える症状が発生した。コンソールエラーは出ておらず、フレームカウンターは回り続けている。

7-2. 調査

VDPレジスタとVRAMの状態をフレームごとにダンプするデバッグコードを挿入したところ、以下の事実が判明した。

  • Frame 0〜30: VDPレジスタ全て 0x00、VRAM書き込み 0バイト
  • Frame 30付近: I/O制御ポート $DE / $DF への書き込みが発生
  • Frame 60以降: VDPレジスタの初期化とVRAMへのデータ転送が開始

ROMの先頭バイトを確認すると F3 ED 56 C3 D6 11 で、これは以下のZ80命令列に対応する。

F3        DI              ; 割り込み禁止
ED 56     IM 1            ; 割り込みモード1設定
C3 D6 11  JP $11D6        ; メイン初期化ルーチンへジャンプ

$11D6 以降で大量のRAM/VRAMクリアやI/O初期化を割り込み禁止状態のまま実行しており、VDP初期化に到達するまでに約60フレーム分(約1秒)のCPU時間を消費していた。

7-3. 結論

これはエミュレーターのバグではなく、ROM側の初期化が重いだけだった。1秒ほど待てば正常に画面が表示される。他のROM(「Challenge Derby」「C_So!」など)では初期化が軽量なため即座に表示される。

この事例は「画面が出ない = エミュレーターのバグ」とは限らないという教訓を与えてくれた。デバッグの基本は、まず CPUが正しく命令を実行しているか → I/Oが正しくデバイスに届いているか → デバイスの内部状態が期待値か の順に切り分けることである。


8. MSXとSG-1000の実装コスト比較

最後に、両エミュレーターの実装規模を比較する。

コンポーネントMSX1SG-1000
メモリマップスロット機構 + PPI + MegaROMマッパー5種 (~200行)フラットROM + 1KB RAM (~10行)
PSGAY-3-8910(16レジスタ、2ポート、エンベロープ)SN76489(単一ポート、ラッチ方式)
BIOSC-BIOS 32KB 必要不要
キーボード/入力PPI 8255 キーボードマトリクスI/Oポート1個
VDPTMS9918A(共通)TMS9918A(共通)
追加音源Konami SCC(5chウェーブテーブル)なし
総コード量(HTML内)~800行~400行

Z80コアとTMS9918Aが共有できたことで、SG-1000固有の実装は約400行に収まった。そのうちSN76489の実装(メイン側 + AudioWorklet)が約150行、メモリマップ・I/O・コントローラーが約100行、UI・メインループが約150行という内訳になる。


到達点と今後の展望

項目ステータス
CPUZ80A(ZEXDOC全67項目パス、IM1割り込み)
VDPTMS9918A(Screen 1/2、スプライト、0xD0打ち切り)— MSXコア共有
PSGSN76489(トーン3ch + ノイズ、AudioWorkletリアルタイム合成)
メモリ48KB ROM + 1KB RAM(ミラー)
入力コントローラー1P(D-Pad + 2ボタン)
UI4:3レスポンシブ、フルスクリーン、Pause/Reset

今後の拡張候補

  • SC-3000対応: キーボードマトリクススキャンの追加のみ。カートリッジゲームはSG-1000と互換
  • Mark III / Master System 対応: VDP拡張(315-5124、Mode 4、パレットRAM)が主な工数。Z80コアはそのまま流用可能
  • セガマイカード対応: メモリマップの調整のみ

Z80コアを一度ゼロから作り、バスをコールバックで分離する設計にしたことで、「別のハードに載せる」コストが劇的に下がった。MSXで800行かかったものがSG-1000では400行。次のターゲット(Mark III)でもZ80コアとSN76489は完全に流用でき、追加実装はVDP拡張に集中できる。

自作エミュレーターの醍醐味は、ハードウェアの設計思想を身体で理解できることにある。SG-1000の「BIOSを持たない潔さ」は、MSXの「スロットとBIOSによる拡張性」と好対照を成しており、1983年当時のセガと業界標準規格の設計哲学の違いが浮き彫りになった。