[JavaScript] 自作Z80コアでSG-1000エミュレーターを実装。BIOS不要・SN76489音源・TMS9918A共有設計の全記録
はじめに
前回の記事では、自作Z80コア上にMSX1のハードウェア一式(TMS9918A VDP、AY-3-8910 PSG、Konami SCC、MegaROMマッパー群)を構築し、ブラウザ上で完全動作するMSXエミュレーターを完成。
[JavaScript] MSXエミュレーターを完成させる。MegaROMマッパー・PSG/SCC音源・AudioWorkletでのサウンド合成
MegaROMマッパー群の実装からPSG/SCC音源のAudioWorkletリアルタイム合成まで、MSX1エミュレーターを完成させた技術記録。
lain-lab.comZ80コアをゼロから自作した設計方針の根底には、
「バスをコールバックで完全分離すれば、どんなZ80搭載機にも載せ替えられる」
という思想がある。それを証明する最初のターゲットとして、セガ初の家庭用ゲーム機 SG-1000 を選択しました。
SG-1000 - Wikipedia
The SG-1000[a] is a home video game console manufactured by Sega. It was Sega's first entry into the home video game hardware business.
en.wikipedia.orgSG-1000のハードウェア構成はZ80A + TMS9918A + SN76489という3チップ構成で、MSXと共通するコンポーネントが多い。 BIOSを持たないベアメタル設計のため、カートリッジROMをメモリ先頭にマップしてZ80のリセットベクタからそのまま実行を開始する、という極めてシンプルな起動シーケンスを持つ。
TMS9918 - Wikipedia
The TMS9918 is a video display controller (VDC) manufactured by Texas Instruments and introduced in 1979
en.wikipedia.orgTexas Instruments SN76489 - Wikipedia
The Texas Instruments SN76489 is a programmable sound generator (PSG) chip released in 1979, used to create music and sound effects on computers and video game systems.
en.wikipedia.orgこの記事では、既存のZ80コアとTMS9918Aモジュールを再利用しながら、SG-1000固有のメモリマップ・I/Oデコード・SN76489 PSGの実装、そしてデバッグ過程で遭遇した問題を記録する。
スクリーンショット
Palikat // helmha
smspowerさんのサイトより、helmha様作「Palikat」をお借りしています。
Palikat - Homebrew - SMS Power!
Homebrew Sega Master System / Mark III / Game Gear SG-1000 / SC-3000 / SF-7000 / OMV / AI
www.smspower.orgArno Dash // under4mhz
smspowerさんのサイトより、under4mhz様作「Arno Dash」をお借りしています。
Arno Dash - Homebrew - SMS Power!
THE GAME: There are 16 CAVES, each compromised of several scrolling screens. Each CAVE has 5 Difficulty levels. To select a different CAVE, press the arrow keys (or move the joystick) left or right when you are in the menu screen. To select a different DIFFICULTY level, go to the menu screen and press arrow keys (or move the joystick up or down). The greater the difficulty level, the less time and the more jewels you have to collect. You may choose CAVE 1,5,9, or 13 on difficulty levels 1-3. On difficulty levels 4 and 5, you must start with cave 1.
www.smspower.org動画(GIF)
SG-1000のハードウェア概要
SG-1000は1983年7月15日、ファミコンと同日に発売されたセガ初の家庭用ゲーム機である。
| 項目 | 仕様 |
|---|---|
| CPU | Zilog Z80A @ 3.579545 MHz |
| VDP | Texas Instruments TMS9918A(256×192、16色、スプライト32枚) |
| PSG | Texas Instruments SN76489AN(矩形波3ch + ノイズ1ch) |
| RAM | 1KB(C3FF、FFFF にミラー) |
| VRAM | 16KB(VDP内蔵) |
| ROM | カートリッジ(最大48KB、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 | 方向 | デバイス |
|---|---|---|---|
| 7F | 01-0/1 | Write | SN76489 PSG |
| $7E | 01-0 | Read | V Counter(垂直走査位置) |
| $7F | 01-1 | Read | H Counter(水平走査位置) |
| $BE | 10-0 | R/W | TMS9918A データポート |
| $BF | 10-1 | R/W | TMS9918A コントロール/ステータス |
| $DC | 11-0 | Read | コントローラー ポート1 |
| $DD | 11-1 | Read | コントローラー ポート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の実装コスト比較
最後に、両エミュレーターの実装規模を比較する。
| コンポーネント | MSX1 | SG-1000 |
|---|---|---|
| メモリマップ | スロット機構 + PPI + MegaROMマッパー5種 (~200行) | フラットROM + 1KB RAM (~10行) |
| PSG | AY-3-8910(16レジスタ、2ポート、エンベロープ) | SN76489(単一ポート、ラッチ方式) |
| BIOS | C-BIOS 32KB 必要 | 不要 |
| キーボード/入力 | PPI 8255 キーボードマトリクス | I/Oポート1個 |
| VDP | TMS9918A(共通) | TMS9918A(共通) |
| 追加音源 | Konami SCC(5chウェーブテーブル) | なし |
| 総コード量(HTML内) | ~800行 | ~400行 |
Z80コアとTMS9918Aが共有できたことで、SG-1000固有の実装は約400行に収まった。そのうちSN76489の実装(メイン側 + AudioWorklet)が約150行、メモリマップ・I/O・コントローラーが約100行、UI・メインループが約150行という内訳になる。
到達点と今後の展望
| 項目 | ステータス |
|---|---|
| CPU | Z80A(ZEXDOC全67項目パス、IM1割り込み) |
| VDP | TMS9918A(Screen 1/2、スプライト、0xD0打ち切り)— MSXコア共有 |
| PSG | SN76489(トーン3ch + ノイズ、AudioWorkletリアルタイム合成) |
| メモリ | 48KB ROM + 1KB RAM(ミラー) |
| 入力 | コントローラー1P(D-Pad + 2ボタン) |
| UI | 4: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年当時のセガと業界標準規格の設計哲学の違いが浮き彫りになった。