[JavaScript] 自作Z80コアでPac-Manエミュレーターを実装。MAMEのビットプレーン解析からZIP ROMローダーまでの全記録
はじめに
自作Z80コアは、MSX1、SG-1000、PC-G850V、ColecoVision、ZX Spectrumと6台のシステムに載せてきた。今回は7台目として Namco Pac-Man (1980) を選んだ。
[JavaScript] 自作Z80コアでZX Spectrumエミュレーターを実装。48K/128K・AY-3-8910・Kempston対応の全記録
Z80コアの6台目。ZX Spectrum 48K/128K対応、AY-3-8910 PSGサウンド実装。
lain-lab.comPac-Manを選んだ理由は、LAIN RETRO ARCADEというレトロゲームコレクションのコンテンツとして追加したかったから。Z80コアを使い回せばCPU部分はすぐ動く — そういう目算だった。
結果から言うと、タイル描画・スプライト座標・カラーPROM・Namco WSGサウンドの実装まで一日でこなしたが、スプライトのピクセルパターンだけは最後まで完全には直せなかった。
スクリーンショット
開発途中のデバッグ画面
到達点
character データのバグを潰しきれず断念
システム構成
| 項目 | 仕様 |
|---|---|
| CPU | Z80A @ 3.072MHz |
| RAM | 2KB (47FF) |
| VRAM | 2KB tile (43FF) + 2KB color (47FF) |
| ROM | 16KB (3FFF、4本×4KB) |
| 画面 | 224×288ピクセル(物理モニターは90°CCW回転) |
| スプライト | 8個、16×16ピクセル |
| サウンド | Namco WSG(3チャンネル波形合成) |
| グラフィックROM | 5E(タイル)・5F(スプライト)各4KB |
| カラーPROM | 82s123.7f(32色テーブル)・82s126.4a(256エントリ参照) |
Pac-Manアーケード基板の特徴的な点は、物理モニターが90°CCW回転していること。ゲームロジックはポートレート座標(224×288)で動くが、スプライト座標系はこの回転を前提とした独自の計算になっている。
1. ビットプレーンデコード — MAMEのtilelayout解析
グラフィックROM(5E・5F)のデコード方式はMAMEのドライバ定義から読み解いた。
MAMEのtilelayout
// MAME pacman.cpp より
static const gfx_layout tilelayout =
{
8,8, // 8×8ピクセル
256, // 256タイル
2, // 2ビットプレーン
{ 0, 4 }, // plane 0 はbit[0-3]、plane 1 はbit[4-7](同一バイト内)
{ 8*8+0, 8*8+1, 8*8+2, 8*8+3, 0, 1, 2, 3 }, // colBits
{ 0*8, 1*8, 2*8, 3*8, 4*8, 5*8, 6*8, 7*8 } // rowBits
};
planes:{0,4} が意味するのは、2つのビットプレーンが同一バイトの下位4ビット・上位4ビットに同居しているということ。タイル1枚16バイトを半分に割り、後半8バイトが左4列・前半8バイトが右4列を担当する。
JavaScript実装
decodeTile(romData, tileIdx) {
const pixels = new Uint8Array(64);
const base = tileIdx * 16;
const colBits = [0,1,2,3, 64,65,66,67]; // 右4列→左4列の順
const rowBits = [0,8,16,24,32,40,48,56];
for (let rx = 0; rx < 8; rx++) {
for (let ry = 0; ry < 8; ry++) {
const bitPos = colBits[rx] + rowBits[ry];
const byteOff = bitPos >> 3;
const bitOff = bitPos & 7;
const b = romData[base + byteOff] || 0;
const p0 = (b >> bitOff) & 1; // 下位4bit側
const p1 = (b >> (bitOff + 4)) & 1; // 上位4bit側(同一バイト)
pixels[rx * 8 + ry] = p0 | (p1 << 1);
}
}
return pixels;
}
colBitsの初期実装は [64,65,66,67,0,1,2,3](MAMEの記述順そのまま)だったが、これだと左右が逆転したタイルになった。正しい順序は [0,1,2,3,64,65,66,67] — MAMEのcolBits配列の意味を誤読していた。
2. 画面レイアウト — VRAMアドレス変換
Pac-ManのVRAMは28列×36行のタイルグリッドだが、アドレス配置が直線的ではない。
getVramOffset(col, row) {
// 上下2行はVRAMの先頭・末尾に特別配置
if (row === 0) return 0x3c0 + col;
if (row === 35) return 0x3e0 + col;
// 残り34行は列ごとに連続
return col * 32 + row - 2;
}
上端・下端の2行(スコア表示エリア)はVRAMの後半に押し込まれている。この変換を誤ると文字が全域にランダム配置になる。
モニター回転による描画反転
物理モニターが90°CCW回転しているため、タイルを180°回転して描画する必要がある:
drawTile(x, y, tileNum, colorAttr) {
const tilePixels = this.tiles[tileNum];
const palIdx = colorAttr & 0x3f;
for (let py = 0; py < 8; py++) {
for (let px = 0; px < 8; px++) {
const ci = tilePixels[(7 - py) * 8 + (7 - px)]; // 180°回転
const rgb = this.getColor(palIdx, ci);
this.setPixel(x + px, y + py, rgb[0], rgb[1], rgb[2]);
}
}
}
列の描画順も左右反転が必要で、render() 内で (27 - col) * 8 としている。
3. スプライト座標系
スプライト座標はVRAMの $7f0-$7ff(色属性)と $ff0-$fff(XY座標)に格納される。
drawSprites() {
for (let i = 7; i >= 0; i--) {
const attr = this.ram[0x07f0 + i * 2];
const colorAttr = this.ram[0x07f0 + i * 2 + 1] & 0x3f;
const spriteNum = attr >> 2;
const flipX = !!(attr & 1);
const flipY = !!(attr & 2);
// モニター回転を考慮した座標変換
const sx = 239 - this.spriteCoords[i * 2];
const sy = 272 - this.spriteCoords[i * 2 + 1];
const sp = this.sprites[spriteNum];
if (!sp) continue;
for (let py = 0; py < 16; py++) {
for (let px = 0; px < 16; px++) {
const ci = sp[(flipY ? 15-py : py) * 16 + (flipX ? px : 15-px)];
if (ci === 0) continue;
const rgb = this.getColor(colorAttr, ci);
this.setPixel(sx + px, sy + (15 - py), rgb[0], rgb[1], rgb[2]);
}
}
}
}
sx = 239 - coords と sy = 272 - coords の変換はモニター回転の補正。この値を変えるとキャラクターが壁にめり込んだり画面外に飛んだりする。
4. スプライトのピクセルパターンバグ(未解決)
今回最も時間を費やして、最終的に諦めた部分。
症状
ゴーストのシルエットと位置は正しいが、ドット絵のパターンが崩れている。上下反転・左右反転・縦分断など、組み合わせを変えるたびに別の方向にバグる。
// spritelayoutのcolBits(MAMEより)
{ 8*8+0, 8*8+1, 8*8+2, 8*8+3, 8*8+4, 8*8+5, 8*8+6, 8*8+7,
0, 1, 2, 3, 4, 5, 6, 7 }
スプライトは16×16で4つの8×8タイルを組み合わせた構造。MAMEのspritelayoutのcolBits順序・flipX/flipYの適用タイミング・描画時のpx/py反転の組み合わせが複数あり、どれが正しいかを特定できなかった。
試した組み合わせを変えるたびにキャラクターが上下逆になったり、flipXが逆効果になったり、位置がズレたりと、一つ直すと別のものが壊れる状態になり袋小路に入った。
現状: ゴーストの形は認識できるレベルで描画できているが、内部のピクセルパターンは正確ではない。
5. カラーPROM
Pac-Manのカラーシステムは2段階のルックアップテーブルで構成される。
82s123.7f (32バイト) : RGBカラー定義テーブル(32色)
82s126.4a (256バイト): パレット番号 → カラーインデックスの変換テーブル
loadPalettePROMs(prom7f, prom4a) {
// 32色のRGBテーブルを構築
for (let i = 0; i < 32; i++) {
const b = prom7f[i];
// RRRGGGBBビットフィールド
const r = ((b>>5)&7) * 36;
const g = ((b>>2)&7) * 36;
const bl = (b & 3) * 85;
this.colorTable[i] = [r, g, bl];
}
// 256エントリのパレット変換テーブル
for (let i = 0; i < 256; i++) {
this.paletteLookup[i] = prom4a[i] & 0x0f;
}
}
getColor(palIdx, colorIdx) {
if (colorIdx === 0) return [0, 0, 0]; // 透明
const lutIdx = (palIdx << 2) | (colorIdx & 3);
const colorEntry = this.paletteLookup[lutIdx & 0xff];
return this.colorTable[colorEntry & 0x1f];
}
PROMs(82s123.7f・82s126.4a)が存在しない場合はデフォルトパレットにフォールバックする。
6. Namco WSGサウンド
Pac-ManのサウンドチップはNamco WSG(Waveform Sound Generator)。3チャンネルの波形合成で、ROM 5m(256バイト)に8種類の波形テーブルが格納されている。
loadWaveROM(romData) {
for (let waveIdx = 0; waveIdx < 8; waveIdx++) {
for (let i = 0; i < 32; i++) {
const byte = romData[waveIdx * 16 + (i >> 1)];
const sample4bit = (i & 1) ? (byte >> 4) : (byte & 0x0f);
// 4bit → Float32 正規化
this.waveTables[waveIdx][i] = (sample4bit / 7.5) - 1.0;
}
}
}
ScriptProcessorNodeで3チャンネルをリアルタイム合成:
processAudio(e) {
const output = e.outputBuffer.getChannelData(0);
output.fill(0);
for (let ch = 0; ch < 3; ch++) {
const base = ch * 5;
const volume = this.soundRAM[base + 0x13];
if (volume === 0) continue;
const freqDelta = (this.soundRAM[base + 0x15] << 16) |
(this.soundRAM[base + 0x14] << 8) |
this.soundRAM[base + 0x10];
const wave = this.waveTables[this.soundRAM[base + 0x11] & 0x07];
const volFactor = (volume / 15) * 0.15;
for (let i = 0; i < output.length; i++) {
this.phase[ch] += (freqDelta * 96000 / 0x100000) / this.audioCtx.sampleRate;
output[i] += wave[Math.floor(this.phase[ch]) % 32] * volFactor;
}
}
}
出音はしているが、Pac-Manらしいサウンドとは乖離がある。soundRAMのレジスタマッピングの解釈が不完全で、ノイズ系の音になっている。ZX SpectrumのAY-3-8910と比べて資料が少なく、深追いは断念した。
7. ZIP ROMローダー
アーケードROMは著作権の関係で配布できないため、ユーザーがMAMEのROMパック(ZIP)をドロップして起動する方式にした。
const ROM_MAP = {
'pacman.6e': ['pacman.6e', '6e', 'pm1-1.6e'],
'pacman.6f': ['pacman.6f', '6f', 'pm1-1.6f'],
// ...
'82s123.7f': ['82s123.7f', 'pacman.7f', 'pm1-1.7f', '7f'],
'82s126.4a': ['82s126.4a', 'pacman.4a', 'pm1-1.4a', '4a'],
};
function findInZip(zip, candidates) {
for (const name of candidates) {
const entry = Object.values(zip.files).find(f =>
!f.dir && f.name.split('/').pop().toLowerCase() === name.toLowerCase()
);
if (entry) return entry;
}
return null;
}
各ROMファイルには複数の名前候補(MAMEロムセット・クローン・短縮名)を持たせ、大文字小文字を無視して検索する。必須ROMが揃えば自動起動、不足があればログに赤でエラー表示。
JSZipはCDNから読み込むだけで、ビルド不要のシンプルな構成にした。
到達点
動作確認済み
- タイトル画面のテキスト・スコア表示
- CHARACTER / NICKNAME画面の文字列
- ゴースト4体のシルエットと位置(カラーは正常)
- カラーPROMによるパレット(BLINKY赤・PINKY桃・INKY青・CLYDE橙)
- コイン投入([5])・スタート([1])・方向キー入力
- WASDおよび矢印キーでのPac-Man操作
- ZIP drag-and-drop / クリックでのROMローダー
未解決
| 問題 | 状況 |
|---|---|
| スプライトのピクセルパターン | シルエットは正しいが内部のドット絵が崩れている |
| Namco WSGサウンド | 出音はするがPac-Manらしいサウンドにならない |
スプライトパターンについては、MAMEのspritelayout解釈・flipX/flipY適用順・描画時のビット反転の組み合わせが多く、一方を直すと別が壊れる状態になった。完全な修正は断念し、現状のまま公開している。
学び
Pac-Manはシンプルなアーキテクチャに見えて、ドキュメントがZ80汎用と専用HWの狭間にあるタイプのシステムだった。ZX Spectrumは公式ドキュメントが整備されているが、Pac-ManはMAMEのソースとコミュニティの解析情報が主な資料になる。
MAMEの tilelayout 構造体を読んでビットプレーンデコードを組む作業は、ColecoVisionのTMS9918A実装と似ているようで全然違う。planes:{0,4} という「同一バイト内の上位・下位4ビットが別プレーン」という設計は、このハードウェア固有の癖だった。
Z80コアは今回もほぼ手を入れていない。CPUが動く状態で始められるのはコアを分離して作り続けてきた恩恵で、詰まるのは全部グラフィック側だった。