[JavaScript] 自作Z80コアでZX Spectrumエミュレーターを実装。48K/128K・AY-3-8910・Kempston対応の全記録

[JavaScript] 自作Z80コアでZX Spectrumエミュレーターを実装。48K/128K・AY-3-8910・Kempston対応の全記録

はじめに

自作Z80コアは、MSX1、SG-1000、PC-G850V、ColecoVisionと5台のシステムに載せ替えてきた。コアのバス分離設計(readByte/writeByte/inPort/outPort)がそのまま活き、コアには手を入れずにシステム層だけを差し替える形で動き続けている。

6台目のターゲットとして選んだのは ZX Spectrum。

1982年にSinclair Researchが発売したイギリスのホームコンピュータ。日本にはほぼ普及しなかったが、ヨーロッパで爆発的に売れ、今でもデモシーンが活発に続いている。

ZX Spectrumを選んだ理由は3つある。

1. ROMが合法的に配布できる。

著作権をAmsradが保有しており、エミュレーターと一緒に配布することを無償で許可している数少ないシステム。MSXのC-BIOSや、ColecoVisionのような代替BIOSなしで、公式ROMをそのまま同梱できる。

2. スナップショットで即起動できる。

.z80スナップショット形式はZ80の全レジスタ状態とメモリをダンプしたもので、テープローディング不要でゲームを即起動できる。Homebrew作品の配布でも主流フォーマット。

3. ハードウェアがシンプル。

TMS9918Aのような専用VDPチップがなく、ULA(Uncommitted Logic Array)という独自チップが画面・キーボード・サウンドをまとめて担当する。描画ロジックはTMS9918Aより単純。

スクリーンショット

ZX Spectrum Emulator // 128K BASIC起動画面

ZX Spectrum Emulator // 128K BASIC起動画面

ZX Spectrum Emulator // Athena (1987)

ZX Spectrum Emulator // 128K BASIC起動画面 ZX Spectrum Emulator // 128K BASIC起動画面

ZX Spectrum Emulator // The Addams Family (1992, Ocean, 128K)

ZX Spectrum Emulator // 128K BASIC起動画面 ZX Spectrum Emulator // 128K BASIC起動画面

動画(GIF)

BASIC起動 ➡ コード実行

ZX Spectrum Emulator // 128K BASIC起動 ➡ コード実行

ゲーム起動 // The Addams Family (Ocean, 128K)

ZX Spectrum Emulator // 128K BASIC起動 ➡ コード実行

Sample Data / CREDIT

ゲームデータは、「spectrum computing」のサイトで配布されてる

・Andrew P. Deakin (UK)
・Ivan Horn (UK)
・Jonathan Dunn (UK)

様作の、The Addams Familyをお借りしてます。

・Andrew P. Deakin (UK)
・Ivan Horn (UK)
・Martin Galway (UK)

様作の、Athenaをお借りしてます。

システム構成

項目48K128K
CPUZ80A @ 3.5MHzZ80A @ 3.55MHz
RAM48KB (4000−4000-FFFF)128KB(8バンク×16KB)
ROM16KB (0000−0000-3FFF)32KB(2ページ×16KB)
VDPULA 256×192 + ボーダー同左
サウンド1-bit ビーパービーパー + AY-3-8910
コントローラZXキーボード同左 + Kempston
スナップショット.z80 v1 / .sna.z80 v2/v3 / .sna 128K

ZX Spectrum独自の画面構成

ZX Spectrumの描画はULAが担当する。256×192ピクセルのモノクロビットマップ(4000−4000-57FF)に、8×8セルごとのカラーアトリビュート(5800−5800-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台目でもこの設計は破綻していない。