[JavaScript] 自作Z80コアでAmstrad CPC 464エミュレーターを実装。Gate Array・CRTC・PPI・FDCの協調動作と、HALT命令フリーズの壁
はじめに
自作Z80コアの10台目のターゲットとして、Amstrad CPC 464(1984年)を選んだ。
Amstrad CPC - Wikipedia
The Amstrad CPC (short for Colour Personal Computer) is a series of 8-bit home computers produced by Amstrad between 1984 and 1990. The CPC 464 was the first model, featuring 64 KB RAM, a built-in cassette tape deck, and a dedicated monitor.
en.wikipedia.orgこれまで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命令待機中にフリーズする問題が残った。
前回の記事
[JavaScript] 自作Z80コアでセガ・マスターシステムエミュレーターを実装。Homebrew完走と『ソニック』動作不可から学んだVDP割り込みのシビアさ
Z80コアの9台目。256×192フル画面描画やSegaマッパー、PSGサウンドを達成。Homebrewが完璧に動作する一方、商用ROMで直面したVDP割り込みの壁。
lain-lab.comスクリーンショット & 動作実績
BAISC コードの実行
動画
DEAD BY DAWN // Malcolm Kirk
Malcolm Kirk様作の「DEAD BY DAWN」。
Homebrew.AT // DEAD BY DAWN // Malcolm Kirk
Bruce Charcoal accidentally invoked a horde of demons in our dimension. You'll have to guide him to retrieve the twelve pages of the book of spells scattered in a gloomy house but also the lair of the monsters to ban them in this game inspired by the fantasy movie Evil Dead 2 from Sam RAIMI.
homebrew.amstradtoday.com
動画
LALA THE MAGICAL PROLOGUE // The Mojon Twins
The Mojon Twins様作の「LALA THE MAGICAL PROLOGUE」。
Homebrew.AT // LALA THE MAGICAL PROLOGUE // The Mojon Twins
Second title from the Mojon Twins using their engine named The churrera, LALA PROLOGUE capitalize on the idea of the quest for items which SIR ABABOL had already used. you are Lala, a clumsy witch apprentice who scattered to the four corners of the school her mistress potions.
homebrew.amstradtoday.com
動画
システム構成
| 項目 | Amstrad CPC 464 | 参考: ZX Spectrum | 参考: MSX1 |
|---|---|---|---|
| CPU | Z80A @ 4.0MHz | Z80A @ 3.5MHz | Z80A @ 3.58MHz |
| RAM | 64KB | 48KB | 16-64KB |
| VRAM | 16KB(メインRAMと共有) | 6.75KB(メインRAMと共有) | 16KB(専用VRAM) |
| ROM | 32KB(OS 16KB + BASIC 16KB) | 16KB | 32KB |
| 解像度 | 384×272(ボーダー含む) | 256×192 | 256×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 Array | ULA | TMS9918A VDP |
| I/O制御 | PPI 8255 | ULA | PPI 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バス信号マトリクス
| BDIR | BC1 | 動作 |
|---|---|---|
| 0 | 0 | Inactive(何もしない) |
| 0 | 1 | Read(PSGからデータ読み出し) |
| 1 | 0 | Write(PSGへデータ書き込み) |
| 1 | 1 | Latch 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 Array | A14=1 (A15=0) | $7Fxx |
| CRTC 6845 | A14=0 | BDxx |
| PPI 8255 | A11=0 | F7xx |
| FDCモーター | A15:12=1111, A10=0, A8=0 | $FA7E |
| FDCデータ | A15:12=1111, A10=0, A8=1 | $FB7F |
| Upper ROM Page | A13=0, A15=1 | $DFxx |
一般的な「アドレス上位バイトで一意にデコード」とは違い、特定ビットの状態だけを見るため、1つのOUT命令が複数デバイスに同時に作用する可能性がある。実装ではif文の順序と条件を注意深く設計する必要があった。
12.【技術的考察】なぜ betiled.dsk はフリーズするのか?
大半のSNAスナップショットやDSKゲームが正常に動作する中、betiled.dsk のみがテキスト表示後のキーボード入力待ちでBEEP音を出してフリーズする問題が発生した。
症状
- ゲームが起動し、タイトル画面が正常に表示される
- テキストメッセージが表示される
- キーボード入力を待つ段階でBEEP音が鳴り続ける
- その後、一切の入力を受け付けずフリーズ
推定原因: 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エミュレーター実装を通じ、以下の重要な知見が得られた。
-
Gate Arrayの中央集権的設計: CPCのGate Arrayは、パレット管理・ROM/RAMバンク・スクリーンモード・割り込み生成を1チップで統括する。このため実装も「Gate Arrayの仕様を正しく実装すればシステムが動く」という明確な軸がある一方、割り込みカウンタの1 T-stateのズレが致命的なフリーズを引き起こすという脆さも持つ。
-
PPI 8255を介したPSGバス制御の複雑さ: BDIR/BC1シグナルによるPSGバスプロトコルは、MSX1やZX Spectrumには存在しない複雑さだ。キーボード読み取りのためにPPI→PSG→マトリクスという3段間接参照が必要になる設計は、実機のハードウェアコスト削減のための設計判断だろうが、エミュレーター実装者にとっては各チップ間の状態遷移を正確に追従する必要がある。
-
ピクセルビットの非線形配置: Mode 0/1のビット散在パターンは、Z80エミュレーター開発で初めて遭遇した仕様だ。ドキュメントなしにリバースエンジニアリングで解読するのは極めて困難で、実機の技術資料に忠実に実装することの重要性を改めて実感した。
-
HALT命令と割り込みの微妙な関係: betiled.dskのフリーズ問題は、Z80コアのHALT実装とGate Array割り込みカウンタの連携が、特定の条件下で破綻することを示している。他の9台のターゲットでは問題にならなかったエッジケースが、CPCの割り込み体系で初めて顕在化した。
Z80コアとサウンドエンジンは10台目にして完全に成熟しており、新システムの実装は周辺IC(Gate Array、CRTC、PPI、FDC)の理解と実装に集中できる段階に達している。次の課題はHALT/割り込み問題の根本解決だが、影響範囲が1タイトルに限定されていることから、まずは現状のまま公開し、フィードバックを得ながら改善していく方針とする。