[JavaScript] Commodore 64エミュレーターを自作。BASICが動くだけの状態から、VIC-II・SID・D64・PALを積み上げてゲームが遊べるまで

[JavaScript] Commodore 64エミュレーターを自作。BASICが動くだけの状態から、VIC-II・SID・D64・PALを積み上げてゲームが遊べるまで

はじめに

Apple II、Atari 2600に続いて、6502ファミリーのもう一つの代表格 Commodore 64 に手を付けた。

C64はApple IIとAtari 2600のちょうど中間にいるマシンだ。Apple IIのようにVRAMを持ち、メモリに書けば絵が出る。一方で、Atari 2600のように「ビームの位置に合わせてレジスタを書き換える」技も多用される。ラスタ割り込みで画面の途中から色やモードを変え、ボーダーを開け、スプライトを使い回す。ゲームもデモも、ほぼすべてがVIC-IIとCIAのタイミングに依存している。

スタート地点は、互換ROMでBASICの READY. が出るだけの状態だった。ここから一日で、ゲームが音付きで遊べるところまで持っていった記録になる。

前提記事

6502 EMULATOR PROJECT

使用ROM:Open ROMs

CommodoreのオリジナルROM(KERNAL / BASIC / 文字ROM)は今も著作権が生きているので使っていない。MEGA65プロジェクトが開発している互換ROM「Open ROMs」を使わせてもらった(LGPL-3.0、BASICの一部はMIT)。

スクリーンショット

掲載しているスクショ画面は、各作者が公開しているホームブリュー作品を使用して動作検証を行ったものです。 各著作権および商標権は、各作者ならびに各権利所有者に帰属します。

Lester (C64)

C64エミュレーター // Tiger Claw タイトル C64エミュレーター // Tiger Claw ゲーム画面

動画

C64エミュレーター // Tiger Claw タイトル

itch.ioよりRGCD.DEV様の「TIGER CLAW(虎爪)」をお借りしています。

Lester // knifegrinder

C64エミュレーター // Lester C64エミュレーター // Lester

動画

C64エミュレーター // Lester(VIDEO)

itch.ioよりknifegrinder様の「Lester (C64)」をお借りしています。

1. 全体構成

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ビットの組み合わせで決まる。

ECMBMMMCMモード
000標準テキスト
001マルチカラーテキスト
010標準ビットマップ
011マルチカラービットマップ
100拡張背景色(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万ケースずつ持っているテスト集だ。

JAMを除く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コアを自作したからこそ出会えた発見だった。

関連記事

Z80 EMULATOR PROJECT