[JavaScript] Commodore 64エミュレーターを自作。BASICが動くだけの状態から、VIC-II・SID・D64・PALを積み上げてゲームが遊べるまで
はじめに
Apple II、Atari 2600に続いて、6502ファミリーのもう一つの代表格 Commodore 64 に手を付けた。
Commodore 64 - Wikipedia
The Commodore 64, also known as the C64, is an 8-bit home computer introduced in January 1982 by Commodore International. It has been listed in the Guinness World Records as the highest-selling single computer model of all time.
en.wikipedia.orgC64はApple IIとAtari 2600のちょうど中間にいるマシンだ。Apple IIのようにVRAMを持ち、メモリに書けば絵が出る。一方で、Atari 2600のように「ビームの位置に合わせてレジスタを書き換える」技も多用される。ラスタ割り込みで画面の途中から色やモードを変え、ボーダーを開け、スプライトを使い回す。ゲームもデモも、ほぼすべてがVIC-IIとCIAのタイミングに依存している。
スタート地点は、互換ROMでBASICの READY. が出るだけの状態だった。ここから一日で、ゲームが音付きで遊べるところまで持っていった記録になる。
前提記事
[JavaScript] Atari 2600エミュレーターを自作。真っ黒画面からTIA・RIOT・バンク切替を積み上げてMYST Demakeが動くまで
自作MOS 6502 CPUコアを流用してAtari 2600 (VCS) をJavaScriptで再現。TIA・RIOT・マッパー・非公式命令までの全実装記録。
lain-lab.com6502 EMULATOR PROJECT
6502 EMULATOR PROJECT
フルスクラッチ JavaScript MOS 6502 コアと、その上に構築されたレトロシステムエミュレーター群
lain-lab.com使用ROM:Open ROMs
CommodoreのオリジナルROM(KERNAL / BASIC / 文字ROM)は今も著作権が生きているので使っていない。MEGA65プロジェクトが開発している互換ROM「Open ROMs」を使わせてもらった(LGPL-3.0、BASICの一部はMIT)。
MEGA65/open-roms
Free and open-source ROM replacements for the Commodore 64 / MEGA65
github.comスクリーンショット
掲載しているスクショ画面は、各作者が公開しているホームブリュー作品を使用して動作検証を行ったものです。 各著作権および商標権は、各作者ならびに各権利所有者に帰属します。
Lester (C64)
動画
itch.ioよりRGCD.DEV様の「TIGER CLAW(虎爪)」をお借りしています。
Tiger Claw by RGCD.DEV
A relentless beat 'em up game for the Commodore 64 and Commodore Amiga
rgcddev.itch.ioLester // knifegrinder
動画
itch.ioよりknifegrinder様の「Lester (C64)」をお借りしています。
Lester (C64)
The Space Station Factory orbiting the planet is undergoing an unprecedented mutiny.The main computer of the space station UNSS ASAMI has gone rougue during decommissioning and gained control of the guardian droids.
itch.io1. 全体構成
m6502-emulator/
├── c64/
│ ├── index.html # エミュレーター画面
│ ├── main.js # UI・キー入力・ファイル読み込み・フレームループ
│ ├── C64.js # 本体:CPU/VIC/CIA/SIDをラスタライン単位で駆動
│ ├── C64Bus.js # メモリマップ(PLA)・I/O振り分け・キーボード
│ ├── Vic2.js # VIC-II(描画・スプライト・ラスタIRQ)
│ ├── Cia.js # CIA 6526(タイマー・割り込み)
│ ├── C64Sid.js # reSID WASM ブリッジ
│ ├── sid-worklet.js # AudioWorklet(再生キュー)
│ ├── sid_core.wasm # reSID(SID Player用ビルドを流用)
│ ├── D64.js # D64ディスクイメージ
│ └── roms/ # Open ROMs
└── core/
├── m6502.js # 6502 CPUコア(Apple IIと共用)
└── m6510.js # 6510 = 6502 + 非公式命令 + 実機挙動の補正
| コンポーネント | 実装量 | 担当ファイル |
|---|---|---|
| VIC-II(5モード・スプライト・IRQ・ボーダー) | ~450行 | Vic2.js |
| 本体ループ・KERNAL LOADフック・リセット | ~230行 | C64.js |
| バス・PLA・キーボード/ジョイスティック | ~160行 | C64Bus.js |
| CIA 6526 | ~130行 | Cia.js |
| SIDブリッジ+Worklet | ~180行 | C64Sid.js / sid-worklet.js |
| D64パーサー | ~160行 | D64.js |
| 6510(非公式命令・RMW・JSR) | ~230行 | m6510.js |
2. スタート地点
最初の状態はこうだった。
- CPUは
m6502.jsを継承したm6510.js - 1フレーム17050サイクルを回し、最後に1回だけIRQを発行
- VICはテキストモードだけ。
$0400の画面と文字ROMを直接読んで320×200を描く $D011は常に$1Bを返し、$D012は読まれるたびに+1する
BASICは動く。しかし、ゲームを読み込むと絵は出るが止まる、チラつく、キャラが出ない。ここから順に積み上げていった。
3. VIC-II:描画モード
5つのモード
VIC-IIのモードは、$D011 のECM/BMMと $D016 のMCMの3ビットの組み合わせで決まる。
| ECM | BMM | MCM | モード |
|---|---|---|---|
| 0 | 0 | 0 | 標準テキスト |
| 0 | 0 | 1 | マルチカラーテキスト |
| 0 | 1 | 0 | 標準ビットマップ |
| 0 | 1 | 1 | マルチカラービットマップ |
| 1 | 0 | 0 | 拡張背景色(ECM) |
| 1 | * | * | 上記以外は無効モード(黒) |
const mode = ((d011 >> 4) & 6) | ((d016 >> 4) & 1); // ECM<<2 | BMM<<1 | MCM
switch (mode) {
case 0: hires(buf, fg, p, vread(charBase + (code << 3) + py), col, bg0); break;
case 1: // マルチカラーテキスト:カラーRAMのbit3が立っている文字だけMC
if (col & 8) multi(buf, fg, p, g, bg0, bg1, bg2, col & 7);
else hires(buf, fg, p, g, col & 7, bg0);
break;
case 2: hires(buf, fg, p, vread(bitmapBase + (vm << 3) + py), code >> 4, code & 15); break;
case 3: multi(buf, fg, p, vread(bitmapBase + (vm << 3) + py), bg0, code >> 4, code & 15, col); break;
case 4: hires(buf, fg, p, vread(charBase + ((code & 0x3F) << 3) + py), col, R[0x21 + (code >> 6)] & 15); break;
default: /* 無効モード → 黒 */
}
描画時に「前景ピクセルかどうか」も一緒に記録しておく。マルチカラーでは %10 と %11 だけが前景になる。これはスプライトの優先順位と衝突判定で使う。
メモリの見え方
VIC-IIは16KBの窓しか見えない。どの16KBを見るかは、CIA2の $DD00 の下位2ビットで決まる(反転している)。さらに、バンク0と2の $1000-$1FFF には、RAMではなく文字ROMが見える。
setupBank() {
const c = this.bus.cia2;
const bank = 3 - ((c.pra | ~c.ddra) & 3); // 入力ピンはプルアップで1
this.bankBase = bank << 14;
this.romVisible = (bank & 1) === 0; // バンク0/2 のみ
}
vread(a) {
a &= 0x3FFF;
if (this.romVisible && (a & 0x3000) === 0x1000) return this.bus.chargenRom[a & 0x0FFF];
return this.bus.ram[this.bankBase | a];
}
画面・文字・ビットマップの位置は $D018 で決まる。
const screenBase = (d018 & 0xF0) << 6; // (D018>>4) * $400
const charBase = (d018 & 0x0E) << 10; // (D018>>1&7) * $800
const bitmapBase = (d018 & 0x08) << 10; // bit3 ? $2000 : $0000
ついでに直したバス側のバグ
$D011 が常に $1B を返す実装だと、POKE 53265,PEEK(53265) OR 32 のような「読んで→ORして→書き戻す」操作で、ビットマップのフラグが消えてしまう。書き込んだ値を返し、bit7にだけラスタの第8ビットを乗せるようにした。
4. キー入力:ジョイスティック
最初に動かしたゲーム(Vegetables Deluxe)は、タイトルは出るのに「Press Fire」で先に進めなかった。フリーズではなく、ジョイスティックが未実装だっただけだった。
C64のジョイスティックはCIA1のポートに直結している。ポート2が $DC00(Port A)、ポート1が $DC01(Port B)で、キーボードのマトリクスと同じ線を共有している。
// $DC00: キーボード逆スキャン結果 & ジョイスティック2
return paOut & colVal & (0xE0 | this.joy2);
// $DC01: キーボードスキャン結果 & ジョイスティック1
return pbOut & rowVal & (0xE0 | this.joy1);
5. ラスタIRQ・CIA・スプライト
ジョイスティックを入れると次の症状が出た。
- ゲームが途中で止まる
- 画面の上下で分割しているゲーム(Tiger Claw)が点滅する
- キャラクターが一切表示されない
原因は、IRQが「1フレームに1回、無条件で発行」だったことと、スプライトが未実装だったことだ。
ラスタIRQ
$D012 に書いたラインにラスタが来たら、$D019 のbit0が立つ。$D01A で許可されていればIRQ線が下がる。$D019 に1を書いたビットがACK(クリア)になる。
startLine(line) {
this.line = line;
if (line === this.rasterCompare) this.irqFlags |= 0x01;
}
write(r, v) {
switch (r) {
case 0x11: this.setCompare((this.rasterCompare & 0xFF) | ((v & 0x80) << 1)); break;
case 0x12: this.setCompare((this.rasterCompare & 0x100) | v); break;
case 0x19: this.irqFlags &= ~v & 0x0F; break; // 1を書いたビットをACK
case 0x1A: this.irqMask = v & 0x0F; break;
}
}
get irqLine() { return (this.irqFlags & this.irqMask) !== 0; }
IRQは「エッジ」ではなく「レベル」で扱う。CPUの命令を1つ実行するたびに、VICかCIA1のIRQ線が下がっていて、かつIフラグが立っていなければ割り込みを受け付ける。
CIA 6526
KERNALは、CIA1のタイマーAで1/60秒ごとにIRQを起こし、キーボードをスキャンしている。ゲームは逆に $DC0D に $7F を書いてCIA割り込みを止め、ラスタIRQだけで動くことが多い。両方を正しく扱うには、CIAをきちんと作る必要がある。
tick(c) {
if (this.cra & 0x01) {
let t = this.ta - c;
while (t < 0) {
aUnder++;
this.icr |= 0x01;
if (this.cra & 0x08) { this.cra &= ~0x01; t = this.la; break; } // ワンショット
t += this.la + 1;
}
this.ta = t;
}
// タイマーBは φ2 またはタイマーAのアンダーフローでカウント
}
read(0x0D) { // ICR は読むとクリア
const v = (this.icr & 0x1F) | (this.irq ? 0x80 : 0);
this.icr = 0;
return v;
}
CIA2のIRQ出力はNMIに繋がっている。RESTOREキーもNMIなので、PageUpに割り当てた。
スプライト
8枚、それぞれ24×21ドット。マルチカラー、X/Y拡大、背景より奥に回す優先度、スプライト同士($D01E)とスプライト・背景($D01F)の衝突判定がある。
番号の小さいスプライトが手前になる。ピクセルごとに「最初に書いたスプライト」を記録し、2枚目以降が重なったら衝突にする。
const plot = (x, c) => {
const m = mask[x];
if (m) ss |= m | bit; // 既に誰かいる → スプライト同士の衝突
else { scol[x] = c; behind[x] = pri; } // 最初の1枚が表示される
mask[x] = m | bit;
if (outFg[x]) sb |= bit; // 前景ピクセルと重なった → 背景との衝突
};
// 合成:背景優先スプライトは前景ピクセルの下に潜る
if (mask[x] && !(behind[x] && outFg[x])) out[x] = scol[x];
ライン単位の駆動とバッドライン
メインループを、1フレーム一括から「1ラインずつCPUを回し、その都度VICに1ライン描かせる」方式に変えた。
for (let i = 0; i < lines; i++) {
const line = (i + start) % lines;
vic.startLine(line); // ラスタ比較
this.exec(RENDER_CYCLE); // ライン先頭の12サイクルぶんCPUを回す
vic.renderLine(line); // この時点のレジスタで1ラインを描く
// バッドライン(キャラデータを読むライン)は40サイクル、スプライト1枚につき2サイクル、CPUが止まる
const steal = (vic.isBadLine(line) ? 40 : 0) + vic.spriteDmaCount() * 2;
this.lineCycle += steal;
this.tick(steal);
this.exec(cpl); // ラインの残り
this.lineCycle -= cpl;
vic.endLine(line);
}
バッドラインは、表示区間内で (line & 7) === YSCROLL になるラインだ。VICが画面メモリを読むためにバスを占有するので、CPUは約40サイクル止まる。ここを入れないと、ラスタ割り込みで画面を分割するタイミングがずれる。
I/Oへの書き込みがRAMにも入っていた
もう一つ、見落としていたバグがあった。$D000-$DFFF がI/Oとして見えているとき、書き込みがI/OだけでなくRAMにも入っていた。VICのレジスタも ram[0xD000+...] を読んでいたので、ゲームがI/Oを外して $D000 以降をRAMとして使うと、VICのレジスタが書き換わって画面が壊れる。
VIC・SID・CIA・カラーRAMをそれぞれ独立した記憶領域に分け、C64のPLA(アドレスデコーダ)と同じ条件で振り分けるようにした。
// $D000-$DFFF に何が見えているか: 0=RAM, 1=CHARGEN, 2=I/O
get dxxxMap() {
const p = (this.ram[1] | ~this.ram[0]) & 7; // 6510ポートの実効値
if ((p & 3) === 0) return 0;
return (p & 4) ? 2 : 1;
}
6. SID:過去に作ったreSID WASMを流用
以前、C64の音楽ファイル(.sid)を再生するSID Playerを作ったときに、reSIDをEmscriptenでWASMにしていた。そのwasmが、再ビルドなしでそのまま使えた。
importが空のスタンドアロンビルドで、sid_write_reg と sid_render_float がexportされていた。
クロック切り替えの小技
問題は、wasmのSIDクロックの初期値がPAL(985kHz)で固定されていたこと。切り替えるAPIは、PSIDファイルのヘッダを読む sid_load_data しかない。
そこで、NTSC/PALのフラグだけを立てた最小のダミーPSID(中身は RTS 1バイト)を食わせてクロックを切り替えた。副作用として、wasm内蔵の6502が1/50〜1/60秒ごとにそのRTSを呼ぶが、実害はない。
function makeDummyPsid(ntsc) {
const h = new Uint8Array(0x7C + 1);
h.set([0x50, 0x53, 0x49, 0x44], 0); // 'PSID'
h[5] = 2; // version 2
h[7] = 0x7C; // dataOffset
h[8] = 0x10; h[10] = 0x10; h[12] = 0x10; // load/init/play = $1000
h[0x77] = ntsc ? 0x08 : 0x04; // flags: clock
h[0x7C] = 0x60; // RTS
return h;
}
440Hzを設定して、PALで440.1Hz、NTSCで439.7Hzが出ることを確認した(切り替えなしのPALのままだと、NTSCでは424Hzになる)。
サイクル同期レンダリング
reSIDはメインスレッドで動かす。CPUが進めたサイクル数を溜めておき、SIDレジスタへの書き込みの直前に、その分だけサンプルを生成してから書き込む。これで、書き込みタイミングがサイクル単位で正確になる。
clock(c) { this.pendingCycles += c; } // CPUの1命令ごとに加算
write(reg, v) {
this.flush(); // 書き込みの直前までの音を生成
this.ex.sid_write_reg(reg, v);
}
flush() {
const s = this.pendingCycles * this.samplesPerCycle + this.sampleFrac;
const n = Math.floor(s);
this.sampleFrac = s - n;
this.pendingCycles = 0;
this.ex.sid_render_float(this.wasmBuf, n);
// → frameBuf に追記
}
1フレーム分(48kHzなら約960サンプル)が溜まったら、AudioWorkletに postMessage で送る。Worklet側は届いた順に再生するだけのキューで、60msためてから鳴らし始め、200msを超えたら古いものから捨てる。
7. 上下ボーダーを開ける
別のエミュレーターと並べて比べると、Tiger Clawの画面上の「GAME OVER」のバナーと、画面下の「PRESS BUTTON TO START」が切れていた。
これらはスプライトで、上下のボーダーにまたがって置かれている。ゲームがボーダーを開けるトリックを使っていたのに、こちらは「ライン51〜250の外は無条件でボーダー色」で塗っていた。
縦ボーダーはフリップフロップ
実機の縦ボーダーは、「今が何ラインか」ではなくフリップフロップの状態で決まる。
- 下端の比較ライン(25行モードなら251、24行モードなら247)に来たらフラグを立てる
- 上端の比較ライン(51 / 55)に来て、DENが立っていたらフラグを下ろす
checkVBorder(line) {
const d011 = this.regs[0x11];
const rsel = d011 & 0x08;
if (line === (rsel ? 251 : 247)) this.vBorder = true;
else if (line === (rsel ? 51 : 55) && (d011 & 0x10)) this.vBorder = false;
}
ボーダー開けは、ライン249あたりでRSELを24行モードに切り替える技だ。すると、ライン251では比較値が247になっていて一致しない。247はもう過ぎている。フラグが立たないまま、ボーダーが開きっぱなしになる。
スプライトは状態を持たせる
開いたボーダーはNTSCではライン0〜12まで続く(ラスタが262から0に折り返す)。スプライトの行番号を毎回 line - Y で計算していると、折り返しで切れる。実機と同じく、「前のラインでY座標と一致したら描画開始、そこから21行」という状態を持たせた。
// 前ラインで Y が一致したスプライトを開始(Y は下位8ビットで比較)
const prev = (line === 0 ? this.lines - 1 : line - 1) & 0xFF;
for (let n = 0; n < 8; n++) {
if (this.sprOn[n] && ++this.sprRaw[n] >= (yExpand ? 42 : 21)) this.sprOn[n] = 0;
if (!this.sprOn[n] && enabled && R[1 + n * 2] === prev) { this.sprOn[n] = 1; this.sprRaw[n] = 0; }
}
8. D64ディスク
配布されているゲームは、ほとんどが .d64(1541ドライブのディスクイメージ)形式だ。
3つの選択肢
| 方式 | 対応範囲 | 必要なもの |
|---|---|---|
| D64から最初のファイルを取り出してメモリに展開 | 1ファイルで完結するゲーム | D64パーサーだけ |
KERNALのLOAD($FFD5)をフックしてD64から返す | 途中で追加ファイルを読むゲーム、LOAD"$",8 | +CPUフック |
| 1541ドライブ本体をエミュレート | 独自の高速ローダーを持つゲーム | +ドライブ側6502・VIA×2・GCR・1541 DOS ROM |
3つ目は1541のDOS ROMが必要になる。互換ROMは存在しないので、今回は2つ目までにした。
D64の構造
D64は35トラック(または40トラック)のセクタをそのまま並べたファイルで、トラックごとにセクタ数が違う。
function sectorsPerTrack(t) {
if (t <= 17) return 21;
if (t <= 24) return 19;
if (t <= 30) return 18;
return 17;
}
ディレクトリはトラック18・セクタ1から始まるチェーンで、1セクタに8エントリ入る。ファイル本体も、各セクタの先頭2バイトが「次のトラック・セクタ」になっているチェーンで、トラックが0なら最終セクタ(2バイト目が最終バイトの位置)になる。
KERNAL LOADのフック
Open ROMsのBASICは、LOAD 命令のときに JSR $FFD5 を呼んでいた。そこで、CPUのPCが $FFD5 に来て、KERNAL ROMが見えていて、デバイス番号が8〜11なら横取りする。
if (cpu.pc === 0xFFD5 && this.disk && (this.bus.bankBits & 2)) {
const dev = this.bus.ram[0xBA];
if (dev >= 8 && dev <= 11) { this.kernalLoad(); continue; }
}
kernalLoad() {
// ファイル名: $BB/$BC のポインタ、長さ $B7 / セカンダリアドレス $B9
const data = name === '$' ? this.disk.directoryListing() : this.disk.readFile(this.disk.find(name));
const start = sa === 0 ? (cpu.x | (cpu.y << 8)) : fileAddr; // SA=0 なら指定先、1 ならファイル自身のアドレス
// …書き込み…
ram[0xAE] = end & 0xFF; ram[0xAF] = end >> 8; // 終端アドレス
ram[0x90] = 0x40; // ステータス: EOF
cpu.x = end & 0xFF; cpu.y = end >> 8;
cpu.p &= ~0x01; // C=0 成功
cpu.pc = (cpu.popWord() + 1) & 0xFFFF; // RTS
}
LOAD"$",8 では、1541と同じ形式のディレクトリリストを、BASICプログラムとして生成して返している。ファイル名の * と ? のワイルドカード、0: のドライブ指定にも対応した。
9. 動かないゲームたち:PALと非公式命令とCPUの2つのバグ
D64に対応して配布物を片っ端から試すと、動かないものが多かった。
PALモード
配布物の大半はヨーロッパ製で、PAL(312ライン×63サイクル、985kHz、50Hz)前提だ。NTSC(263ライン×65サイクル)で動かすと、ライン263〜311に仕掛けたラスタIRQが一度も起きない。
VIC・SID・フレームループの定数を機種ごとにまとめて、PALを既定にした。
export const VIC_MODELS = {
PAL: { lines: 312, cycles: 63, clock: 985248, firstLine: 16, lastLine: 299, lastWrap: -1, frameStart: 300 },
NTSC: { lines: 263, cycles: 65, clock: 1022727, firstLine: 28, lastLine: 262, lastWrap: 12, frameStart: 13 },
};
Open ROMsはラスタを見てPAL/NTSCを判定するので、起動画面に正しく「PAL」と出る。
非公式命令と 0xDB
Atari 2600のときと同じく、NMOS 6502の非公式命令を実装した。今回は共通コアには手を入れず、M6510 クラスの step() で先に拾う形にした。Apple IIには一切影響しない。
step() {
const fn = ILLEGAL[this.readByte(this.pc)];
if (!fn) return super.step(); // 公式命令は M6502 に任せる
const start = this.cycles;
this.pc = (this.pc + 1) & 0xFFFF;
fn(this);
return this.cycles - start;
}
共通コアは 0xDB をテスト終了用の停止命令として扱い、例外を投げる。しかしC64では 0xDB は DCP abs,Y で、圧縮データの展開処理でよく使われる。ここも M6510 側で上書きした。
SingleStepTestsで全命令を検証
非公式命令は仕様の揺れが大きいので、SingleStepTestsで検証した。NMOS 6502の全256オペコードについて、実行前後のCPU状態・メモリ・サイクル数を1万ケースずつ持っているテスト集だ。
SingleStepTests/65x02
Tests for the 6502 and 65C02 processors
github.comJAMを除く244オペコード×1万ケースを流すと、非公式命令は全部通った。そのかわり、公式命令側のバグが2つ見つかった。
ADCの10進モードのN/Vフラグ
NMOS 6502は、10進モードのADCで、Zフラグは2進加算の結果から、N/Vフラグは下位4ビットを補正した後の途中結果から決まる。コアは2進加算の結果からN/Vを決めていた。Bruce Clarkのdecimal_testは、NMOS向けの既定設定ではAとCしか検査しないので、ここをすり抜けていた。
let t = (a & 0x0f) + (val & 0x0f) + c;
if (t > 9) t += 6;
t = (t & 0x0f) + (a & 0xf0) + (val & 0xf0) + (t > 0x0f ? 0x10 : 0);
Z = ((a + val + c) & 0xff) === 0; // Z は2進加算
N = t & 0x80; // N/V は中間結果
V = ((a ^ t) & 0x80) && !((a ^ val) & 0x80);
if ((t & 0x1f0) > 0x90) t += 0x60;
C = (t & 0xff0) > 0xf0;
これは共通コアのバグなので、m6502.js 側で直した。Apple IIにも効く修正になる。
JSRの読み出し順
実機のJSRは、戻り番地をスタックに積んだ「後」に、ジャンプ先の上位バイトを読む。スタックとオペランドが重なるという特殊なケースで差が出る。1万ケース中1件だけの失敗だった。
最後の大物:ASL $D019
PALと非公式命令を入れても、Lesterというゲームは画面の上下がチラつき、独自フォントで描くはずの文字がROMフォントで出ていた。Bruce Lee: Return of Fury+は、タイトル画面の文字が化けていた。
スクショだけでは原因が絞れないので、ヘッドレスで動かして、VICとCIA2への書き込みをライン番号付きでログに取った。
L110 c14 pc=9640 W D012=e2 ← 次のIRQをライン226に設定
L111 c41 W DD00=1 ← HUD用:VICバンク切替
L112 c0 pc=91e8 W D018=13 ← HUD用:文字セット切替
L131 c52 pc=922d W D012=5 ← ?? ライン131で上部用の処理が走っている
L133 c0 W DD00=2
...
L256 c31 pc=9628 R D012=0 ← ライン256でHUD用の処理
L280 c28 pc=922d W D012=5 ← ライン280で上部用の処理
このゲームは、ライン5で上の遊ぶ部分、ライン226でHUDに、VICのバンクと文字セットを切り替える作りだ。ところが実際には、109・131・256・280・300と、ばらばらのラインで割り込み処理が走っていた。割り込みが出っぱなしになっている。
$D019 への書き込みを調べると、割り込みハンドラの確認応答(ACK)はこうなっていた。
0E 19 D0 ASL $D019
C64でよく使われる書き方だ。
RMW命令の2回書き込み
実機のNMOS 6502は、ASL・LSR・ROL・ROR・INC・DECのような「読んで→書き換えて→書き戻す」命令(RMW)で、次のバスサイクルを踏む。
| サイクル | 動作 |
|---|---|
| 1-3 | オペコードとアドレスを読む |
| 4 | メモリを読む |
| 5 | 読んだ値をそのまま書き戻す(ダミー書き込み) |
| 6 | 書き換えた値を書く |
$D019 を読むと、$F1(bit7=IRQ発生、bit4-6は未使用で1、bit0=ラスタ)が返る。5サイクル目のダミー書き込みでは $F1 が書かれ、bit0が1なのでラスタ割り込みがACKされる。6サイクル目の $E2 にはbit0が無いので、ACKの効果はない。
自作のコアは6サイクル目の $E2 しか書いていなかった。だからACKされず、割り込みが出っぱなしになっていた。
// 公式・非公式の全 RMW 命令をダミー書き込み付きに
ILLEGAL[op] = (c) => {
const a = c[mode]();
const old = c.readByte(a);
c.writeByte(a, old); // ダミー書き込み(元の値)
c.writeByte(a, op(c, old));
c.cycles += cyc;
};
これでLesterもBruce Leeも、嘘のように正常に動いた。
Klausのfunctional_testは、命令の「結果」(レジスタとメモリに残る値)を検査するテストだ。途中で何回書き込むかは検査しない。RAMなら同じ値を2回書いても1回書いても結果は同じなので、差は出ない。しかし $D019 のように「書かれた瞬間に何かが起きる」I/Oレジスタでは、1回目の書き込みがあるかどうかで動作が変わる。C64はそれを前提にしたコードが多い。
10. 公開用の仕上げ
- ROMの読み込みパスを
import.meta.urlからの相対パスにした(Netlify・GitHub Pages・Vercelなど、公開先ごとにディレクトリ構成が変わっても読める) - ボタンで
.prg/.d64を読み込み、リセット → READY待ち →RUN($0801以外はSYS xxxx)をキーボードバッファ($0277/$C6)に入れて自動起動 - PAL/NTSC、ジョイスティックのポート1/2、音のON/OFF、RESTOREをUIで切り替え
- キャンバスの縦横比は、PAL(384×284)とNTSC(384×248)に合わせて自動で変わる
- モニタのリフレッシュレートに関係なく、実機のフレームレート(PAL 50.12Hz / NTSC 59.83Hz)で回す
11. まとめ
Apple IIは「メモリに書けば絵が出る」、Atari 2600は「ビームに合わせて書かないと絵が出ない」マシンだった。C64は、その両方をやるマシンだった。
得られた知見:
- VIC-IIは16KBの窓しか見えず、バンク0/2の
$1000-$1FFFには文字ROMが透けて見える $D000-$DFFFがI/Oのとき、書き込みはRAMに入らない。VICのレジスタをRAMと共有すると、I/Oを外すゲームで壊れる- IRQはレベルで扱う。VICとCIA1の両方がIRQ線を下げうる
- 縦ボーダーは「何ラインか」ではなくフリップフロップの状態。ボーダー開けはこれを利用している
- スプライトの行は状態を持たせる。ラスタの折り返しで切れないように
- 配布物の大半はPAL前提。NTSCでは存在しないラインにIRQを仕掛けているソフトがある
- RMW命令は2回書く。
ASL $D019/INC $D019のACKがこれに依存している - functional_testは結果しか見ない。バスへのアクセス回数まで見るなら、SingleStepTestsのようなテストが必要
未実装
- 1541ドライブ本体のエミュレーション(独自の高速ローダーを使う市販ゲームのクラック版が動かない)
- サイクル単位の描画(ラインの途中で色を変えるラスターバーや、左右ボーダー開けを使うデモは崩れる)
- SIDのボイス3の読み出し(
$D41Bは乱数、$D41Cは0を返す簡易版) .t64/.crt形式
1982年に発売され、世界で最も売れた単一機種のコンピューター。互換ROMとホームブリューのゲームで、ブラウザの中でその画面と音が鳴った。最後のバグがCPUの「1回余分な書き込み」だったのは、6502コアを自作したからこそ出会えた発見だった。
関連記事
[JavaScript] MOS 6502エミュレーターをゼロから実装。functional_testとdecimal_testを完走するまでの全記録
MOS 6502 CPUエミュレーターをJavaScriptでゼロから実装し、Klaus Dormannの6502_functional_testおよびBruce Clarkの6502_decimal_testをすべてパスするまでの実装記録。
lain-lab.com
[JavaScript] Apple IIエミュレーターを自作。6502コアの上にDisk II・HiResカラー・スピーカーを積み上げてデモディスクが動くまで
自作MOS 6502 CPUコアの上にApple IIのハードウェアをJavaScriptで再現。Disk II・HiResカラー・スピーカーまでの全実装記録。
lain-lab.comZ80 EMULATOR PROJECT
Z80 EMULATOR PROJECT
フルスクラッチ JavaScript Z80 コアと、その上に構築されたレトロシステムエミュレーター群
lain-lab.com