[JavaScript] 自作Z80コアでZX Spectrumエミュレーターを実装。48K/128K・AY-3-8910・Kempston対応の全記録
はじめに
自作Z80コアは、MSX1、SG-1000、PC-G850V、ColecoVisionと5台のシステムに載せ替えてきた。コアのバス分離設計(readByte/writeByte/inPort/outPort)がそのまま活き、コアには手を入れずにシステム層だけを差し替える形で動き続けている。
[JavaScript] 自作Z80コアでColecoVisionエミュレーターを実装。BIOS HLEで限界まで攻めて、実BIOSで完走した全記録
Z80コアの5台目。BIOS HLEと実ROM対応のデュアル構成。
lain-lab.com6台目のターゲットとして選んだのは ZX Spectrum。
ZX Spectrum - Wikipedia
The ZX Spectrum is an 8-bit personal home computer developed by Sinclair Research. It was released in the United Kingdom in 1982.
en.wikipedia.org1982年にSinclair Researchが発売したイギリスのホームコンピュータ。日本にはほぼ普及しなかったが、ヨーロッパで爆発的に売れ、今でもデモシーンが活発に続いている。
ZX Spectrumを選んだ理由は3つある。
1. ROMが合法的に配布できる。
著作権をAmsradが保有しており、エミュレーターと一緒に配布することを無償で許可している数少ないシステム。MSXのC-BIOSや、ColecoVisionのような代替BIOSなしで、公式ROMをそのまま同梱できる。
Sinclair ZX Spectrum
Sinclair ZX // Original ROM
speccy4ever.speccy.org2. スナップショットで即起動できる。
.z80スナップショット形式はZ80の全レジスタ状態とメモリをダンプしたもので、テープローディング不要でゲームを即起動できる。Homebrew作品の配布でも主流フォーマット。
Spectrum Computing - ZX Spectrum games, software and hardware
Spectrum Computing, a database of Sinclair ZX Spectrum games, software and hardware
spectrumcomputing.co.uk3. ハードウェアがシンプル。
TMS9918Aのような専用VDPチップがなく、ULA(Uncommitted Logic Array)という独自チップが画面・キーボード・サウンドをまとめて担当する。描画ロジックはTMS9918Aより単純。
Gate array - Wikipedia
A gate array is an approach to the design and manufacture of application-specific integrated circuits (ASICs) using a prefabricated chip with components that are later interconnected into logic devices (e.g., NAND gates, flip-flops, etc.) according to custom order by adding metal interconnect layers in the factory.
en.wikipedia.orgスクリーンショット
ZX Spectrum Emulator // 128K BASIC起動画面
ZX Spectrum Emulator // Athena (1987)
ZX Spectrum Emulator // The Addams Family (1992, Ocean, 128K)
動画(GIF)
BASIC起動 ➡ コード実行
ゲーム起動 // The Addams Family (Ocean, 128K)
Sample Data / CREDIT
ゲームデータは、「spectrum computing」のサイトで配布されてる
・Andrew P. Deakin (UK)
・Ivan Horn (UK)
・Jonathan Dunn (UK)
様作の、The Addams Familyをお借りしてます。
The Addams Family - Spectrum Computing
The Addams Family for ZX-Spectrum 128K (Ocean Software Ltd/UK, 1992) by Andrew P. Deakin, Ivan Horn, Jonathan Dunn
spectrumcomputing.co.uk・Andrew P. Deakin (UK)
・Ivan Horn (UK)
・Martin Galway (UK)
様作の、Athenaをお借りしてます。
Athena - Spectrum Computing
Athena for ZX-Spectrum 48K/128K (Imagine Software Ltd/UK, 1987) by Andrew P. Deakin, Ivan Horn, Martin Galway, Bob Wakelin
spectrumcomputing.co.ukシステム構成
| 項目 | 48K | 128K |
|---|---|---|
| CPU | Z80A @ 3.5MHz | Z80A @ 3.55MHz |
| RAM | 48KB (FFFF) | 128KB(8バンク×16KB) |
| ROM | 16KB (3FFF) | 32KB(2ページ×16KB) |
| VDP | ULA 256×192 + ボーダー | 同左 |
| サウンド | 1-bit ビーパー | ビーパー + AY-3-8910 |
| コントローラ | ZXキーボード | 同左 + Kempston |
| スナップショット | .z80 v1 / .sna | .z80 v2/v3 / .sna 128K |
ZX Spectrum独自の画面構成
ZX Spectrumの描画はULAが担当する。256×192ピクセルのモノクロビットマップ(57FF)に、8×8セルごとのカラーアトリビュート(5AFF)を重ねる方式。
// ZX Spectrum独自のインターリーブドアドレス計算
const third = y >> 6; // 画面3分割 (0, 1, 2)
const charRow = (y >> 3) & 0x07; // サードの中の行
const scanLine = y & 0x07; // キャラクタ内のスキャンライン
const addr = (third << 11) | (scanLine << 8) | (charRow << 5) | col;
TMS9918Aのネームテーブル方式と全く異なる独自のアドレッシング。このインターリーブがスクロールには不便だが、パレットなしで1バイトに8ピクセルを詰めて描画できる。
アトリビュートバイトの構造:
bit 7: FLASH(点滅)
bit 6: BRIGHT(輝度2倍)
bit 5-3: PAPER(背景色 0-7)
bit 2-0: INK(前景色 0-7)
1. 基本設計 — スナップショットファースト
ColecoVisionではBIOS HLEを先に実装してから実ROM対応を追加した。ZX Spectrumでは最初から「ROMを読み込んでスナップショットを展開するだけで動く」設計にした。テープローディングやカスタムローダーは後回し。
.z80スナップショット対応
.z80はVersion 1(48K専用)・Version 2・Version 3の3バリアントがある。
loadZ80Snapshot(data) {
const d = new Uint8Array(data);
// Header: 30 bytes common
const pcV1 = d[6] | (d[7] << 8); // 0なら v2/v3
if (pcV1 !== 0) {
// Version 1: 48K, PC in header, 48KB memory follows
this.z80.pc = pcV1;
this._decompressToRam48(d, 30, compressed);
} else {
// Version 2/3: extended header
const addLen = d[30] | (d[31] << 8);
this.z80.pc = d[32] | (d[33] << 8);
const hwMode = d[34];
// hwMode 3/4 = 128K
this.is128 = (hwMode === 3 || hwMode === 4);
if (this.is128) {
this.ramPage = d[35] & 0x07;
// AY registers at bytes 39-54
for (let i = 0; i < 16; i++) this.ayRegs[i] = d[39 + i];
}
this._loadV23Memory(d, 32 + addLen);
}
}
v2/v3の128K判定は hwMode バイト一本。メモリはページ番号付きブロックが連結されていて、ランレングス圧縮もある。
128Kメモリマッピング
$0000-$3FFF: ROM(pageレジスタで0/1切替)
$4000-$7FFF: バンク5 固定(通常スクリーン)
$8000-$BFFF: バンク2 固定
$C000-$FFFF: バンク0-7(ポート$7FFDで切替)
ポート $7FFD への OUT 1本で実現するシンプルなバンク切替:
// OUT $7FFD → RAMバンク切替
if (this.is128 && !this.pagingLocked && (port & 0xC002) === 0x4000) {
this.ramPage = val & 0x07; // $C000のバンク番号
this.screenPage = (val & 0x08) ? 7 : 5; // スクリーンバンク(通常/シャドウ)
this.romPage = (val >> 4) & 0x01; // ROM 0(128K BASIC) / 1(48K互換)
if (val & 0x20) this.pagingLocked = true; // ロック
}
2. 致命的バグ発見 — Z80コアのIN/OUT命令
BIOS(48K ROM)が起動してBASIC画面が表示されたが、キーボードを押しても一切反応しない。
ZX Spectrumのキーボード構造
ZX Spectrumのキーボードは8本のハーフロウ(行)×5キーのマトリクス。I/Oポートを使って読む方式だが、単純に $FE を読むのではない。
IN A, ($FE) // アドレスバス上位8ビット = A レジスタ
IN A, (C) / IN r, (C) // アドレスバス上位8ビット = B レジスタ
IN A, (n) 命令は、n だけでなくAレジスタを上位8ビットに結合した16ビットアドレスを使ってデバイスを選択する。ZX SpectrumはこのA8-A15で読む行(ハーフロウ)を選択している。
// 例: ハーフロウ読み取り
LD A, $FE // CAPS~V 行を選択(A0=0, A8=1, A9=0...)
IN A, ($FE) // ポートアドレス = (A << 8) | $FE = $FEFE
LD A, $FD
IN A, ($FE) // ポートアドレス = $FDFE → A~G 行
Z80コアのバグ
// 修正前(バグ)
case 3: { // IN A, (n)
const port = this.fetchByte();
this.a = this.inPort(port); // ← 下位8ビットのみ渡す
}
// 修正後(正しい)
case 3: {
const n = this.fetchByte();
const port = (this.a << 8) | n; // ← Aレジスタを上位に結合
this.a = this.inPort(port);
}
同様に IN r,(C) / OUT (C),r / INIR / OTIR 等のブロック命令も同様:
// 修正前
const val = this.inPort(this.c); // CレジスタだけでIN
// 修正後
const val = this.inPort(this.bc); // BCレジスタペア全体でIN
OUT (n),A も同様に修正。全6箇所。
影響範囲
このバグはMSX1・SG-1000・PC-G850V・ColecoVisionにも存在していた。これらは _inPort 内で port &= 0xFF していたため表面化しなかっただけ。ZEXDOC テストはレジスタ値とフラグの正しさのみを検証するため、このバグも通過していた。
ZX Spectrum実装でZ80コアの3つ目の隠れバグが発見された(1つ目: CBプレフィックスのサイクル欠落、2つ目: 今回のIN/OUT 16ビット未対応)。
3. AY-3-8910サウンド(128K)
ZX Spectrum 128KはAY-3-8910 PSGを搭載する。MSX1のPSG(AY-3-8910/8912相当)と同一チップのため、MSX1のAudioWorklet実装を流用して128Kのサウンドを追加した。
ポートアドレス
$FFFD (A15=1, A14=1, A1=0): AYレジスタ読み取り / レジスタ選択
$BFFD (A15=1, A14=0, A1=0): AYレジスタ書き込み
ZX Spectrumのポートデコードは全16ビットのアドレスを使う。port & 0xC002 でA15・A14・A1ビットの組み合わせを判定:
// AYレジスタ選択
if ((port & 0xC002) === 0x8000) { // A15=1, A14=0, A1=0
this.ayRegSelected = val & 0x0F;
}
// AYデータ書き込み
if ((port & 0xC002) === 0xC000) { // A15=1, A14=1, A1=0
this.ayRegs[this.ayRegSelected] = val;
this.ayWorkletNode.port.postMessage({ reg: this.ayRegSelected, val });
}
AudioWorkletへの転送で踏んだ罠
_syncAY() でAYレジスタ16本をまとめて転送するために postMessage(this.ayRegs) と書いたところ、Uint8Arrayはデフォルトでtransfer(所有権移転)される。送った後に this.ayRegs が空のバッファになり、以降のゲームの書き込みが全部消える。
// 修正前(バグ)
this.ayWorkletNode.port.postMessage(this.ayRegs); // 所有権転送で元が空に
// 修正後
this.ayWorkletNode.port.postMessage(this.ayRegs.slice()); // コピーを送る
4. Kempstonジョイスティック
ZX Spectrumにはハードウェアジョイスティックの標準規格がないが、Kempston社のインターフェースがデファクトスタンダードになっている。
ポート $1F(A5=0)を読むと1バイトのジョイスティック状態が返る:
Bit 0: Right
Bit 1: Left
Bit 2: Down
Bit 3: Up
Bit 4: Fire
実装はシンプル:
_inPort(port) {
// Kempston joystick: port $1F
if ((port & 0xFF) === 0x1F) return this.kempstonState;
// ULA keyboard: A0=0
if ((port & 0x01) === 0) { /* ... */ }
}
pressKempston(bit) { this.kempstonState |= (1 << bit); }
releaseKempston(bit) { this.kempstonState &= ~(1 << bit); }
矢印キーとSpaceをKempstonに、SHIFT/CTRL/アルファベット/数字をZXキーボードに同時対応させることで、ゲームの操作方式(キーボード / Kempston)に関係なく動かせる。
5. 1-bit ビーパー
ZX Spectrum 48Kにはサウンドチップがない。代わりにポート $FE のbit 4をソフトウェアでPWM制御して音を出す「ビーパー」がある。
// OUT $FE: bit4 = speaker
_outPort(port, val) {
if ((port & 0x01) === 0) { // ULA port
this.borderColor = val & 0x07;
this.speaker = (val >> 4) & 0x01;
}
}
ゲームループの中でCPUサイクルを計算しながらビーパーのON/OFFを切り替え、Web Audio ScriptProcessorNodeに流すことでリアルタイム再生:
// フレームループ内
this.audioCycleAcc += elapsed;
while (this.audioCycleAcc >= this.cyclesPerSample) {
this.audioCycleAcc -= this.cyclesPerSample;
this.beeperBuffer[this.beeperPos++] = this.speaker ? 0.12 : 0;
}
6. TAP(テープ)ローダー
.tap 形式はZX Spectrumのテープイメージで、ROMの $0556(LD-BYTES)ルーチンをトラップしてブロックを直接RAMに注入する方式で実装した。
_checkTapeTrap() {
if (this.z80.pc !== 0x0556) return;
if (!this.tapData) return;
const blockFlag = this.tapData[this.tapPointer + 2];
// A=0x00: ヘッダ期待、A=0xFF: データ期待
if (blockFlag !== this.z80.a) { /* 不一致ならスキップ */ }
// IX=転送先、DE=バイト数
for (let i = 0; i < reqLen; i++) {
this._writeByte(targetAddr + i, this.tapData[dataStart + i]);
}
// スタックからRETしてROMに戻す
this.z80.f |= 0x01; // Carry=1 (成功)
const ret = this._readByte(this.z80.sp) | ...;
this.z80.pc = ret;
}
ただし、多くの商用ゲームはターボローダー(カスタムローダー)を使っており、ROMの $0556 を呼ばずに直接EARポートをポーリングして音声信号をデコードする。このトラップは標準ローダーを使うゲームのみ有効。
到達点
動作確認済み
- 3D Monster Maze (1981) — 48K。ZX Spectrumを代表する初期タイトル
- The Addams Family (1992, Ocean Software) — 128K + AY-3-8910サウンド
- 10 Othello (2016, Homebrew) — 48K BASIC
Z80コアへのフィードバック
| バグ | 発見タイミング | 影響エミュレーター |
|---|---|---|
| CBプレフィックス命令のサイクル欠落 | ColecoVision実装中 | MSX1, SG-1000, PC-G850V, ColecoVision |
| IN/OUT命令の16ビットポートアドレス未対応 | ZX Spectrum実装中 | 全エミュレーター(他は8ビットでマスクしていたため表面化せず) |
新しいターゲットを載せるたびにZ80コアが強くなる。今回もZEXDOCが通過していたバグが実機動作で初めて露顕した。
学び
ZX Spectrumが「簡単」と言われる理由がわかった。VDPのような専用チップがなく、アドレスバスとデータバスを直接叩くULA設計はHLEが不要でシンプル。ColecoVisionのBIOS HLEで5日間戦ったのと比べると、描画とキーボードの基本動作まで数時間で到達できた。
一方でZ80の16ビットI/Oアドレスという「当たり前の仕様」が他のシステムでは隠蔽されていたことで、4台分の実装を通して気づかなかった。新しいターゲットが違う角度からコアを照らし出す — それがエミュレーター開発の面白さだと思う。
Z80コアを一度しっかり作り、バスをコールバックで分離するだけで、何でも動く。MSXの800行、SG-1000の400行、PC-G850Vの300行、ColecoVisionの500行、ZX Spectrumの650行。コアに手を入れたのはCBサイクルとIN/OUTの16ビット化の2回だけ。6台目でもこの設計は破綻していない。