[JavaScript] 自作Z80コアでAmstrad CPC 464エミュレーターを実装。Gate Array・CRTC・PPI・FDCの協調動作と、HALT命令フリーズの壁

[JavaScript] 自作Z80コアでAmstrad CPC 464エミュレーターを実装。Gate Array・CRTC・PPI・FDCの協調動作と、HALT命令フリーズの壁

はじめに

自作Z80コアの10台目のターゲットとして、Amstrad CPC 464(1984年)を選んだ。

これまでSG-1000、MSX1、ColecoVision、ZX Spectrum、PC-G850V、Pac-Man、Game Gear、Master Systemと、日本やアメリカのZ80マシンを中心に実装してきたが、CPC 464はヨーロッパ(特にイギリス、フランス、スペイン)で圧倒的なシェアを持っていた8ビット機だ。

CPC 464の特徴は、Amstrad独自設計のGate Arrayがシステムの心臓部として君臨する点にある。カラーパレット管理、ROM/RAMバンク制御、スクリーンモード切替、割り込みタイミング生成までを、この1チップが統括する。加えて、CRTC 6845による画面描画、PPI 8255によるキーボードスキャンとPSGバス制御、NEC µPD765によるフロッピーディスクアクセスと、独立した4つのコントローラが協調動作する構成は、これまで実装してきたどのシステムよりも複雑だった。

結果として、SNAスナップショットの即時実行と、DSKディスクイメージからのゲーム起動、AY-3-8910によるPSGサウンド再生を達成。大半のゲームが正常に動作する一方、特定の1タイトル(betiled.dsk)でHALT命令待機中にフリーズする問題が残った。

前回の記事

スクリーンショット & 動作実績

BAISC コードの実行

CPC 464 Emulator // BAISC コードの実行 CPC 464 Emulator // BAISC コードの実行

動画

CPC 464 Emulator Screenshot 2

DEAD BY DAWN // Malcolm Kirk

Malcolm Kirk様作の「DEAD BY DAWN」。

CPC 464 Emulator // DEAD BY DAWN //  Malcolm Kirk CPC 464 Emulator // DEAD BY DAWN //  Malcolm Kirk

動画

CPC 464 Emulator // DEAD BY DAWN //  Malcolm Kirk

LALA THE MAGICAL PROLOGUE // The Mojon Twins

The Mojon Twins様作の「LALA THE MAGICAL PROLOGUE」。

LALA THE MAGICAL PROLOGUE //  The Mojon Twins LALA THE MAGICAL PROLOGUE //  The Mojon Twins

動画

CPC 464 Emulator // DEAD BY DAWN //  Malcolm Kirk

システム構成

項目Amstrad CPC 464参考: ZX Spectrum参考: MSX1
CPUZ80A @ 4.0MHzZ80A @ 3.5MHzZ80A @ 3.58MHz
RAM64KB48KB16-64KB
VRAM16KB(メインRAMと共有)6.75KB(メインRAMと共有)16KB(専用VRAM)
ROM32KB(OS 16KB + BASIC 16KB)16KB32KB
解像度384×272(ボーダー含む)256×192256×192
パレット27色中16色+ボーダー(Gate Array)15色(8色×明暗)TMS9918A固定16色
サウンドAY-3-8910 PSG(3ch + Noise)Beeper(1bit)AY-3-8910 PSG
ストレージカセット / 3インチFDD(µPD765)カセットカセット / ROM カートリッジ
画面制御CRTC 6845 + Gate ArrayULATMS9918A VDP
I/O制御PPI 8255ULAPPI 8255

CPC 464は、同世代のZ80マシンの中で最もRAMが大きく(64KB)、専用カスタムチップ(Gate Array)による高度なパレット管理と、独立した4つのコントローラIC(CRTC、PPI、PSG、FDC)が協調動作する複雑なアーキテクチャを持つ。


1. Gate Array — 27色カラーパレットとハードウェアカラーマッピング

CPC 464のカラーシステムは、他のZ80マシンとは根本的に異なる。TMS9918Aのような固定パレットでもなく、Master SystemのCRAMのような直接RGBでもない。Gate Arrayが5ビットのハードウェアカラー値を27色パレットにマッピングするという独特の方式だ。

27色パレット

CPCの27色は、RGBそれぞれ3レベル(0, 0x80, 0xFF)の組み合わせで構成される。

// CPC 27色ハードウェアパレット (RGB, 3 levels per channel)
const CPC_HARDWARE_PALETTE = [
  [0x00,0x00,0x00], [0x00,0x00,0x80], [0x00,0x00,0xFF],  // 黒, 暗青, 青
  [0x80,0x00,0x00], [0x80,0x00,0x80], [0x80,0x00,0xFF],  // 暗赤, 暗紫, ...
  [0xFF,0x00,0x00], [0xFF,0x00,0x80], [0xFF,0x00,0xFF],  // 赤, ..., マゼンタ
  [0x00,0x80,0x00], [0x00,0x80,0x80], [0x00,0x80,0xFF],  // 暗緑, 暗シアン, ...
  [0x80,0x80,0x00], [0x80,0x80,0x80], [0x80,0x80,0xFF],  // 暗黄, グレー, ...
  [0xFF,0x80,0x00], [0xFF,0x80,0x80], [0xFF,0x80,0xFF],  // オレンジ, ピンク, ...
  [0x00,0xFF,0x00], [0x00,0xFF,0x80], [0x00,0xFF,0xFF],  // 緑, ..., シアン
  [0x80,0xFF,0x00], [0x80,0xFF,0x80], [0x80,0xFF,0xFF],  // 黄緑, ..., ...
  [0xFF,0xFF,0x00], [0xFF,0xFF,0x80], [0xFF,0xFF,0xFF]   // 黄, クリーム, 白
];

5ビットハードウェアカラー → 27色インデックス

Gate Arrayに書き込むカラー値は5ビット(0〜31)だが、27色パレットへの対応は線形ではなく、ハードウェアに焼き込まれた変換テーブルを経由する。

// Gate Array 5-bit hardware colour → 27-colour palette index
const GA_HARDWARE_COLORS = [
  13, 13, 19, 25, 1,  7, 10, 16, 7,  25, 24, 26, 6,  8, 15, 17,
  1,  19, 18, 20, 0,  2, 9,  11, 4,  22, 21, 23, 3,  5, 12, 14
];

ハードウェアカラー値0と1が同じパレットインデックス13(グレー)を指す、といった一対多の対応が存在する。SNAスナップショットからパレットを復元する際にも、このテーブルを介さなければ正しい色が出ない。

Gate ArrayのI/Oコマンド体系

Gate Arrayへの書き込みは、ポート$7Fxx(A15=0, A14=1)に対して行い、データの上位2ビット(bit7:6)がコマンドを決定する。

// Gate Array (bit15=0, bit14=1 → port $7Fxx)
if ((port & 0xC000) === 0x4000) {
  const cmd = (val >> 6) & 0x03;
  if (cmd === 0) {
    // コマンド0: ペン選択(16ペン + ボーダー)
    this.penSelect = val & 0x1F;
  } else if (cmd === 1) {
    // コマンド1: 選択されたペンにカラーを設定
    const hwCol = GA_HARDWARE_COLORS[val & 0x1F];
    if (this.penSelect & 0x10) {
      this.borderPen = hwCol;     // ボーダーカラー
    } else {
      this.pens[this.penSelect & 0x0F] = hwCol;  // 16ペンのいずれか
    }
  } else if (cmd === 2) {
    // コマンド2: ROM有効/無効 + スクリーンモード + 割り込みリセット
    this.lowerRomEnabled = !(val & 0x04);
    this.upperRomEnabled = !(val & 0x08);
    this.screenMode = val & 0x03;
    if (val & 0x10) this.gaInterruptCounter = 0;  // 割り込みカウンタリセット
  }
}

1つのポートアドレスで、上位2ビットによってペン選択・カラー設定・モード/ROM制御を切り替えるというコンパクトな設計。Master SystemのVDPのコントロール/データ2ポート方式とも、ZX SpectrumのULAの属性テーブル方式とも異なる、CPCならではの設計哲学だ。


2. CRTC 6845 と VRAMアドレス計算 — 非線形メモリマッピングの解読

CPC 464の画面描画は、汎用のCRTCコントローラ(MC6845)が生成するアドレスを、Gate Arrayが実際のVRAMアドレスに変換するという構成で行われる。このアドレス変換が非線形であることが、実装上の最大の難所となった。

VRAM アドレス計算式

bit15-14 = MA13:12  (16KB ページセレクト: CRTCレジスタR12)
bit13-11 = RA2:0    (キャラクタ行内のスキャンライン番号)
bit10-1  = MA9:0    (キャラクタ列位置)
bit0     = バイト内位置 (0=even, 1=odd)

CRTCが出力する**MA(Memory Address)**のbit13:12がVRAMの16KBページ(通常$C000起点)を決定し、**RA(Row Address)**がキャラクタ行内のスキャンラインを決定する。このため、画面上で隣接する2ラインのVRAMアドレスは$800(2048バイト)離れている。

// CRTC screen base address
const r12 = this.crtcRegs[12] & 0x3F;
const r13 = this.crtcRegs[13];
const screenBase = (r12 & 0x30) << 10;        // bits 15-14 → 16KB page
const startMA = ((r12 & 0x0F) << 8) | r13;    // lower 10 bits of start MA

// Per-scanline address
for (let charRow = 0; charRow < maxRows; charRow++) {
  for (let scanLine = 0; scanLine < 8; scanLine++) {
    for (let col = 0; col < charsPerLine; col++) {
      const ma = (startMA + charRow * charsPerLine + col) & 0x3FF;
      const wordAddr = screenBase | (scanLine << 11) | (ma << 1);
      const byte0 = ram[wordAddr & 0xFFFF];       // even byte
      const byte1 = ram[(wordAddr + 1) & 0xFFFF];  // odd byte
    }
  }
}

各MAポジションは2バイト(1ワード)に対応し、これがスクリーンモードに応じて異なるピクセル数を表現する。


3. 3つのスクリーンモードとピクセルビットの非線形配置

CPC 464は3つのスクリーンモードを持ち、いずれもバイト内のビット配置が非線形に散在している。これはZ80系エミュレーターで初めて遭遇した特殊な仕様で、単純なビットシフトでは正しく表示できない。

Mode 1(標準モード): 4px/byte, 2bits/pixel

最も一般的なモード。4色を使用し、1バイトから4ピクセルを取り出す。

// Mode 1: pixel bit extraction
//   p0 = bit3 | (bit7 << 1)
//   p1 = bit2 | (bit6 << 1)
//   p2 = bit1 | (bit5 << 1)
//   p3 = bit0 | (bit4 << 1)

_putMode1(fb, b, penRgb, x, y, W) {
  const p0 = ((b >> 3) & 1) | (((b >> 7) & 1) << 1);  // bit3, bit7
  const p1 = ((b >> 2) & 1) | (((b >> 6) & 1) << 1);  // bit2, bit6
  const p2 = ((b >> 1) & 1) | (((b >> 5) & 1) << 1);  // bit1, bit5
  const p3 = (b & 1)        | (((b >> 4) & 1) << 1);   // bit0, bit4
  // → penRgb[p0..p3] でパレット参照、フレームバッファに書き込み
}

ピクセル0のbit0はバイトのbit3から、bit1はbit7から取得する。ピクセルのビットが上位・下位に交互に散在している。

Mode 0(多色モード): 2px/byte, 4bits/pixel

16色すべてを使用でき、1バイトから2ピクセルを取り出す。ビット配置はさらに複雑。

// Mode 0: pixel bit extraction
//   p0: bit0=b.1  bit1=b.5  bit2=b.3  bit3=b.7
//   p1: bit0=b.0  bit1=b.4  bit2=b.2  bit3=b.6

_putMode0(fb, b, penRgb, x, y, W) {
  const p0 = ((b >> 1) & 1) | (((b >> 5) & 1) << 1)
            | (((b >> 3) & 1) << 2) | (((b >> 7) & 1) << 3);
  const p1 = (b & 1) | (((b >> 4) & 1) << 1)
            | (((b >> 2) & 1) << 2) | (((b >> 6) & 1) << 3);
  // 各ピクセルは画面上で2px幅に拡大して描画
}

4ビットのパレットインデックスを構成するために、バイトのbit1, bit5, bit3, bit7をかき集めるという、直感的には理解しがたいビット配列。これはGate Arrayのハードウェア設計に起因するもので、ソフトウェアエミュレーションでは実機のドキュメントと照合しながら正確に再現する必要がある。

Mode 2(高解像度モード): 8px/byte, 1bit/pixel

2色モード。1バイトから8ピクセル。ビット配置は比較的素直。

// Mode 2: 8 pixels per byte, 1 bit/pixel
_putMode2(fb, b, penRgb, x, y, W) {
  for (let i = 0; i < 4; i++) {
    const px = (b >> (7 - i * 2)) & 1;
    const c = penRgb[px];
    // フレームバッファに書き込み
  }
}

4. PPI 8255 と AY-3-8910 PSGバス制御 — BDIR/BC1 シグナル

CPC 464で最もユニークなのが、PSG(AY-3-8910)の制御方法だ。Z80のI/OポートからPSGに直接アクセスするのではなく、PPI 8255のPort Cのbit7(BDIR)とbit6(BC1)を操作してPSGバス信号を制御し、Port Aを介してデータを転送するという間接的なバスプロトコルを採用している。

PSGバス信号マトリクス

BDIRBC1動作
00Inactive(何もしない)
01Read(PSGからデータ読み出し)
10Write(PSGへデータ書き込み)
11Latch Address(レジスタ選択)

PPI Port C更新 → PSG操作の連鎖

_updatePPIC(val) {
  this.ppiPortC = val;
  this.kbLineSelect = val & 0x0F;  // 下位4ビットはキーボードライン選択

  // PSG bus control via BDIR (bit7) and BC1 (bit6)
  const bdir = (val >> 7) & 1;
  const bc1  = (val >> 6) & 1;

  if (bdir === 1 && bc1 === 1) {
    // Latch Address: PPI Port Aの値をPSGレジスタアドレスとして選択
    this.psg.writeAddress(this.ppiPortA);
  } else if (bdir === 1 && bc1 === 0) {
    // Write Data: PPI Port Aの値を選択中のPSGレジスタに書き込み
    this.psg.writeData(this.ppiPortA);
  }
  // Read (BDIR=0, BC1=1) は _inPort側で処理
}

キーボードマトリクス読み出しもPSG経由

CPCのキーボードスキャンは、PSGのレジスタ14(Port A入力)を通じて行われる。PPI Port Cの下位4ビットでキーボードライン(0〜9)を選択し、BDIR=0/BC1=1でPSGレジスタ14を読むと、選択されたラインのキー状態がアクティブロウで返る。

// PPI Port A read — PSG Read mode
if (bdir === 0 && bc1 === 1) {
  if (this.psg.selectedReg === 14) {
    // AY reg 14 = keyboard matrix (active-low)
    const line = this.kbLineSelect;
    return (line < 10) ? this.keyboardMatrix[line] : 0xFF;
  }
  return this.psg.readData();
}

MSX1でもPPI 8255とPSGの組み合わせは使われていたが、CPCほどバスプロトコルが複雑ではなかった。キーボード入力のためにPPI→PSG→キーボードマトリクスという3段階の間接参照が必要になるのは、CPC独自の設計だ。


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

AY-3-8910は3チャンネルのトーンジェネレータ + ノイズジェネレータを搭載するPSGで、MSX1でもお馴染みのサウンドチップだ。

対数ボリュームテーブル

実機のAY-3-8910は対数的な音量特性を持つ。デジタル値0〜15を線形に扱うと音量バランスが崩れるため、実測値に基づく対数テーブルを使用。

// Logarithmic volume table (matches real hardware characteristics)
this.volTable = [
  0.0, 0.01, 0.014, 0.02, 0.028, 0.04, 0.056, 0.08,
  0.11, 0.16, 0.23, 0.32, 0.45, 0.64, 0.82, 1.0
];

ノイズジェネレータ(17ビットLFSR)

// Noise generator — 17-bit Linear Feedback Shift Register
const bit0 = this.noiseSeed & 1;
const bit3 = (this.noiseSeed >> 3) & 1;
this.noiseSeed = (this.noiseSeed >> 1) | ((bit0 ^ bit3) << 16);

bit0とbit3のXORを17ビット目にフィードバックする方式で、実機と同じ擬似乱数系列を生成する。

Web Audio API (ScriptProcessor) による再生

this.soundNode = this.audioCtx.createScriptProcessor(2048, 0, 1);
this.soundNode.onaudioprocess = (e) => {
  const output = e.outputBuffer.getChannelData(0);
  for (let i = 0; i < output.length; i++) {
    output[i] = this.psg.nextSample();
  }
};

Master System実装ではAudioWorkletを使用したが、CPC 464ではScriptProcessor方式を採用した。サンプル単位でPSGのnextSample()を呼び出し、3チャンネルのトーン + ノイズのミキシング結果をオーディオバッファに書き込む。


6. NEC µPD765 FDCとDSKディスクイメージ

CPC 464にフロッピーディスクドライブを接続すると、NEC µPD765 FDCがディスクアクセスを管理する。エミュレーターでは、DSKディスクイメージファイルをパースして仮想ディスクとして提供する。

FDCステートマシン

µPD765は3フェーズのステートマシンで動作する。

COMMAND → EXECUTION_READ → RESULT

CPUはまずコマンドフェーズでコマンドバイトとパラメータを送信し、データが必要な場合はEXECUTIONフェーズで1バイトずつ読み出し、最後にRESULTフェーズで7バイトのステータスを受け取る。

実装したFDCコマンド

コマンド機能備考
SPECIFY (03h)ステップレート・ヘッドロード時間設定パラメータ受理のみ
SENSE DRIVE STATUS (04h)ドライブ状態取得ST3レジスタ返却
RECALIBRATE (07h)トラック0へシークST0 = 0x20(Seek End)
SEEK (0Fh)指定トラックへシーク割り込み保留フラグ設定
SENSE INTERRUPT STATUS (08h)割り込み要因取得保留なしなら0x80(Invalid)
READ DATA (06h)セクタデータ読み出しマルチセクタ対応
READ ID (0Ah)セクタIDフィールド読み出しトラック上の最初のセクタID返却
WRITE DATA (05h)セクタ書き込みリードオンリー(ST1 = 0x02)
FORMAT TRACK (0Dh)トラックフォーマットリードオンリー

CPCのセクタID体系

CPCのディスクフォーマットでは、セクタIDが$C1〜$C9(193〜201)から始まる。IBM PCの1始まりとは異なるため、DSKイメージのパース時にセクタIDをそのまま辞書のキーとして保持する設計とした。

// DSKImage.parse() — セクタIDをキーとしたマップ
const sectorMap = {};
for (let i = 0; i < numSectors; i++) {
  const r = data[secHeaderOffset + 2];  // Sector ID (R)
  sectorMap[r] = data.subarray(sectorDataOffset, sectorDataOffset + actualLen);
}
this.tracks[trackNum][sideNum] = { sectors: sectorMap };

µPD765のRフィールド仕様

結果フェーズのRフィールド(セクタ番号)は、µPD765の仕様に従い、成功時は「読み出したセクタ+1」、失敗時は「現在のセクタ番号をそのまま」返す。

// µPD765 spec: R field = next sector after successful read
resultBuffer = [
  st0, st1, st2,
  this.currentTrack, this.currentSide,
  success ? (this.currentSector + 1) : this.currentSector,
  2  // N = sector size code (2 = 512 bytes)
];

7. DSKディスクイメージフォーマット — StandardとExtended

DSKファイルには2つのフォーマットが存在する。

Standard DSK(“MV - CPCEMU”)

全トラックが同一サイズ。ヘッダのオフセット$32-$33に格納されたトラックサイズ(256バイトのTrack Information Block含む)がすべてのトラックに適用される。セクタのデータ長はNパラメータから128 << Nで計算。

Extended DSK(“EXTENDED CPC”)

トラックごとにサイズが異なる。オフセット$34以降にトラックサイズテーブル(上位バイトのみ、×256)が格納される。さらに、各セクタのセクタ情報ブロック内のバイト6-7に実際のデータ長が記録される。

if (this.isExtended) {
  // Extended: actual data length from sector info bytes 6-7
  actualLen = data[secHeaderOffset + 6] | (data[secHeaderOffset + 7] << 8);
} else {
  // Standard: size from N parameter
  actualLen = 128 << (n & 7);
}

コピープロテクトされたディスクイメージでは、意図的に異常なセクタサイズやオーバーラップするセクタが含まれることがあり、Extended DSK形式でなければ正確に表現できない。


8. SNAスナップショットローダー — CPCEMU V3フォーマット

SNAファイルは、Z80のレジスタ状態・Gate Arrayの全設定・CRTCレジスタ・PPI状態・PSGレジスタ・64KB RAMダンプを含む完全なマシン状態のスナップショットだ。

メモリレイアウト

$00-$07:  マジック "MV - SNA"
$10:      SNAバージョン
$11-$25:  Z80レジスタ (AF, BC, DE, HL, R, I, IFF0/1, IX, IY, SP, PC, IM)
$26-$2D:  シャドウレジスタ (AF', BC', DE', HL')
$2E:      Gate Array 選択ペン
$2F-$3F:  Gate Array パレット (16ペン + ボーダー = 17バイト)
$40:      Gate Array マルチコンフィグレーション (モード + ROM有効/無効)
$42:      CRTC 選択レジスタ
$43-$54:  CRTC レジスタ R0-R17
$55:      Upper ROM ページ
$56-$59:  PPI Port A, B, C, Control
$5A:      PSG 選択レジスタ
$5B-$6A:  PSG レジスタ R0-R15
$100+:    64KB RAM ダンプ

パレット復元時には、SNAに格納されている5ビットのハードウェアカラー値をGA_HARDWARE_COLORSテーブルで27色インデックスに変換する必要がある。

// Gate Array palette restoration from SNA
for (let i = 0; i < 16; i++) {
  this.pens[i] = GA_HARDWARE_COLORS[d[0x2F + i] & 0x1F];
}
this.borderPen = GA_HARDWARE_COLORS[d[0x3F] & 0x1F];

9. Per-Scanline モード/パレットスナップショット

CPC 464のソフトウェアでは、CRTC割り込みのタイミングで画面の途中からスクリーンモードやパレットを変更するスプリットスクリーン技法が広く使われている。ゲームのプレイエリアがMode 0(16色)で、ステータスバーがMode 1(4色)、といった構成だ。

これを正しく再現するため、フレーム内の各スキャンラインで現在のモードとパレットをスナップショットし、レンダリング時にスキャンラインごとに参照する。

// Per-scanline snapshot (change-detection optimization)
const scanline = (this.cyclesThisFrame / 256) | 0;
if (scanline !== prevScanline && scanline < 312) {
  const modeChanged = this.screenMode !== this._lastSnapMode;
  let pensChanged = modeChanged;
  if (!pensChanged) {
    for (let i = 0; i < 16; i++) {
      if (this.pens[i] !== this._lastSnapPens[i]) { pensChanged = true; break; }
    }
  }
  if (pensChanged || !this.scanlineModes[scanline]) {
    this.scanlineModes[scanline] = {
      mode: this.screenMode,
      pens: Uint8Array.from(this.pens),
      borderPen: this.borderPen
    };
  }
}

モードやパレットが実際に変化したスキャンラインでのみ新しいスナップショットを生成し、変化がなければ前のスキャンラインのデータを再利用するという変更検知最適化を行っている。312スキャンライン×毎フレームの完全コピーは重すぎるため、この最適化は不可欠だ。


10. Gate Array割り込みとフレームタイミング

PAL タイミング

CPC 464はPAL(50Hz)テレビ向けに設計されており、1フレームは312スキャンライン × 256 T-states = 79,872サイクルで構成される。

Gate Array割り込み — 52スキャンラインごと

Gate Arrayは内部カウンタを持ち、52スキャンライン(13,312 T-states)ごとにZ80に割り込みを発生させる。これはフレームあたり約6回の割り込みに相当し、ゲームのメインループやサウンドドライバのタイミング基準として使用される。

VSYNCの立ち上がりエッジでこのカウンタがリセットされる点も重要だ。

runFrame() {
  while (this.cyclesThisFrame < this.cyclesPerFrame) {
    const wasVsync = this.vsync;
    this.vsync = (this.cyclesThisFrame >= VSYNC_START && this.cyclesThisFrame < VSYNC_END);

    // Gate Array: VSYNC立ち上がりでカウンタリセット
    if (this.vsync && !wasVsync) {
      this.gaInterruptCounter = 0;
    }

    const prev = this.z80.tCycles;
    this.z80.step();
    const elapsed = this.z80.tCycles - prev;
    this.cyclesThisFrame += elapsed;

    // Gate Array割り込み: 13312 T-states (52 scanlines) ごと
    this.gaInterruptCounter += elapsed;
    if (this.gaInterruptCounter >= 13312) {
      this.gaInterruptCounter -= 13312;
      this.z80.interrupt(0xFF);
    }
  }
}

11. CPC I/Oポートアドレスデコーディング

CPC 464のI/Oアドレスデコーディングは、他のZ80マシンとは大きく異なる。特定のアドレスビットが0であること(アクティブロウ)でデバイスが選択される。

デバイス条件典型的ポート
Gate ArrayA14=1 (A15=0)$7Fxx
CRTC 6845A14=0BCxx/BCxx / BDxx
PPI 8255A11=0F4xx−F4xx - F7xx
FDCモーターA15:12=1111, A10=0, A8=0$FA7E
FDCデータA15:12=1111, A10=0, A8=1$FB7F
Upper ROM PageA13=0, A15=1$DFxx

一般的な「アドレス上位バイトで一意にデコード」とは違い、特定ビットの状態だけを見るため、1つのOUT命令が複数デバイスに同時に作用する可能性がある。実装ではif文の順序と条件を注意深く設計する必要があった。


12.【技術的考察】なぜ betiled.dsk はフリーズするのか?

大半のSNAスナップショットやDSKゲームが正常に動作する中、betiled.dsk のみがテキスト表示後のキーボード入力待ちでBEEP音を出してフリーズする問題が発生した。

症状

  1. ゲームが起動し、タイトル画面が正常に表示される
  2. テキストメッセージが表示される
  3. キーボード入力を待つ段階でBEEP音が鳴り続ける
  4. その後、一切の入力を受け付けずフリーズ

推定原因: HALT命令中のGate Array割り込みカウンタ

Z80のHALT命令は「次の割り込みが来るまでCPUを停止する」命令だ。CPC 464ではGate Arrayが52スキャンラインごとに割り込みを生成するため、通常はHALTから速やかに復帰する。

しかし、自作Z80コアのstep()メソッドがHALT命令を実行した場合、消費サイクル数として0を返すケースがある。この場合、runFrame()ループ内でelapsed === 0となり、Gate Array割り込みカウンタが進行しない。

// 問題のある状態:
const elapsed = this.z80.tCycles - prev;  // HALT → elapsed = 0
this.cyclesThisFrame += elapsed;           // → 0加算
this.gaInterruptCounter += elapsed;        // → 0加算 ← カウンタが進まない!

カウンタが進まなければ13,312 T-statesの閾値に永遠に到達せず、割り込みが発生しない。割り込みが来なければHALTから復帰できない。結果としてデッドロックが成立する。

現在の対処

elapsed === 0の場合に強制的に4 T-statesを加算するセーフティネットを実装している。

if (elapsed === 0) {
  this.z80.tCycles += 4;
  this.cyclesThisFrame += 4;
  this.gaInterruptCounter += 4;
  if (this.gaInterruptCounter >= 13312) {
    this.gaInterruptCounter -= 13312;
    this.z80.interrupt(0xFF);
  }
}

しかし、この対処を入れてもbetiled.dskのフリーズは解消されていない。根本原因はZ80コアのHALT実装か、割り込み受付条件(IFF1フラグの状態、割り込みモードの問題)にある可能性が高い。このタイトル以外のゲームはすべて正常に動作しているため、betiled固有のHALT/割り込みの使い方がエッジケースを踏んでいると考えられる。


到達点

動作状況まとめ

  • SNAスナップショット: Gate Arrayパレット・CRTCレジスタ・PSG状態含め正確に復元。即座にゲーム実行が可能。
  • DSKディスクイメージ: Standard / Extended 両フォーマットに対応。AMSDOS ROM経由でBASICからRUN"ファイル名"でゲーム起動可能。
  • サウンド: AY-3-8910 PSGの3チャンネルトーン + ノイズを Web Audio APIで再生。
  • キーボード/ジョイスティック: CPC 10行キーボードマトリクスの完全実装。ジョイスティックモードへの切り替え対応。
  • スプリットスクリーン: Per-scanlineモード/パレットスナップショットにより、画面途中でのモード切替に対応。
  • 既知の問題: betiled.dsk のみ、HALT命令中のフリーズが発生(他のすべてのゲームは正常動作)。

まとめ

今回のAmstrad CPC 464エミュレーター実装を通じ、以下の重要な知見が得られた。

  1. Gate Arrayの中央集権的設計: CPCのGate Arrayは、パレット管理・ROM/RAMバンク・スクリーンモード・割り込み生成を1チップで統括する。このため実装も「Gate Arrayの仕様を正しく実装すればシステムが動く」という明確な軸がある一方、割り込みカウンタの1 T-stateのズレが致命的なフリーズを引き起こすという脆さも持つ。

  2. PPI 8255を介したPSGバス制御の複雑さ: BDIR/BC1シグナルによるPSGバスプロトコルは、MSX1やZX Spectrumには存在しない複雑さだ。キーボード読み取りのためにPPI→PSG→マトリクスという3段間接参照が必要になる設計は、実機のハードウェアコスト削減のための設計判断だろうが、エミュレーター実装者にとっては各チップ間の状態遷移を正確に追従する必要がある。

  3. ピクセルビットの非線形配置: Mode 0/1のビット散在パターンは、Z80エミュレーター開発で初めて遭遇した仕様だ。ドキュメントなしにリバースエンジニアリングで解読するのは極めて困難で、実機の技術資料に忠実に実装することの重要性を改めて実感した。

  4. HALT命令と割り込みの微妙な関係: betiled.dskのフリーズ問題は、Z80コアのHALT実装とGate Array割り込みカウンタの連携が、特定の条件下で破綻することを示している。他の9台のターゲットでは問題にならなかったエッジケースが、CPCの割り込み体系で初めて顕在化した。

Z80コアとサウンドエンジンは10台目にして完全に成熟しており、新システムの実装は周辺IC(Gate Array、CRTC、PPI、FDC)の理解と実装に集中できる段階に達している。次の課題はHALT/割り込み問題の根本解決だが、影響範囲が1タイトルに限定されていることから、まずは現状のまま公開し、フィードバックを得ながら改善していく方針とする。