[JavaScript] MOS 6502エミュレーターをゼロから実装。functional_testとdecimal_testを完走するまでの全記録

[JavaScript] MOS 6502エミュレーターをゼロから実装。functional_testとdecimal_testを完走するまでの全記録

はじめに

Z80エミュレーターの実装・zexdoc完走に続き、今回はレトロCPUのもう一つの雄である MOS 6502 の完全自作に取り組んだ。

6502はレジスタ数が少なく命令セットも洗練されているため、一見するとZ80よりも実装が容易に思える。しかし、実際には 「13種類におよぶアドレッシングモードの正確なアドレス計算」 、 「JMP ($xxFF) に代表されるハードウェアバグの再現」 、そして 「NMOS 6502特有の複雑なBCD(10進数)モード」 など、数々の地雷が潜んでいる。

本記事では、JavaScriptでゼロから6502 CPUコアを構築し、事実上の標準テストスイートである 6502_functional_test.bin および 6502_decimal_test.bin の両方を完全に突破するまでの実装テクニックとバグ潰しの全記録をまとめる。

関連する過去記事

1. 全体構成

完成した m6502.js は約850行。構成の概要は以下の通り。

セクション行数(概算)内容
ステータスフラグ定数~15行N, V, B, D, I, Z, C フラグのビットマスク定義
M6502クラス・レジスタ定義~60行A, X, Y, SP, PC, P の個別変数化と初期化
スタック操作・メモリ操作~40行push/pop, fetchByte/Word
アドレッシングモード解析~120行13種類のアドレッシングモードのアドレス計算ルーチン
ALU(演算ユニット)~100行ADC, SBC, CMP, BIT, シフト・ローテート命令
メインデコーダー (Step)~515行全256オペコードの switch 判定と実行ロジック

2. レジスタ設計とステータスフラグ(P)

6502のレジスタ構成は非常にシンプルだ。Z80のような裏レジスタや16ビットペアの複雑さはないため、すべてV8エンジンの最適化に最も有利な 個別変数方式 で保持する。

export class M6502 {
  constructor(bus) {
    this.bus = bus; // メモリ・I/O読み書き用バスコールバック

    // レジスタ(8ビット)
    this.a = 0x00;
    this.x = 0x00;
    this.y = 0x00;
    this.sp = 0xfd; // スタックポインタ ($0100 - $01FF にマップ)

    // レジスタ(16ビット)
    this.pc = 0x0000;

    // フラグ (Status Register P)
    this.flagC = 0; // Carry (bit 0)
    this.flagZ = 0; // Zero (bit 1)
    this.flagI = 1; // Interrupt Disable (bit 2)
    this.flagD = 0; // Decimal Mode (bit 3)
    this.flagB = 0; // Break Command (bit 4)
    this.flagUnused = 1; // Unused (bit 5) Always 1 when pushed
    this.flagV = 0; // Overflow (bit 6)
    this.flagN = 0; // Negative (bit 7)
  }

  // ステータスレジスタPの合成
  getP() {
    return (
      (this.flagN ? 0x80 : 0) |
      (this.flagV ? 0x40 : 0) |
      0x20 | // Bit 5 は常時1
      (this.flagB ? 0x10 : 0) |
      (this.flagD ? 0x08 : 0) |
      (this.flagI ? 0x04 : 0) |
      (this.flagZ ? 0x02 : 0) |
      (this.flagC ? 0x01 : 0)
    );
  }

  setP(val) {
    this.flagN = (val & 0x80) ? 1 : 0;
    this.flagV = (val & 0x40) ? 1 : 0;
    // Bit 5 は無視
    this.flagB = (val & 0x10) ? 1 : 0;
    this.flagD = (val & 0x08) ? 1 : 0;
    this.flagI = (val & 0x04) ? 1 : 0;
    this.flagZ = (val & 0x02) ? 1 : 0;
    this.flagC = (val & 0x01) ? 1 : 0;
  }
}

スタック領域とPHP/PLP命令の罠

6502のスタックはメモリの $0100〜$01FF に固定されている。SP レジスタはその下位8ビットを指し、Pushするたびに減算(降順スタック)される。

ここで重要なのが PHP(Push P)や割り込み発生時のフラグ退避 だ。実機6502では、PHP 命令や BRK 命令によってスタックに P レジスタが積まれる際、Bit 4 (Bフラグ) と Bit 5 が強制的に 1 としてプッシュされる 仕様になっている。一方、ハードウェア割り込み(IRQ/NMI)で退避される際は Bit 4 が 0 になる。この違いを忠実に模倣しないとテストスイートで即座に落ちる。

push(val) {
  this.bus.writeByte(0x0100 | (this.sp & 0xff), val & 0xff);
  this.sp = (this.sp - 1) & 0xff;
}

pushPWithB(bValue) {
  let p = this.getP();
  if (bValue) p |= 0x10; else p &= ~0x10;
  p |= 0x20; // Bit 5 は常に 1
  this.push(p);
}

3. 13種類のアドレッシングモードの共通化

6502の命令デコードを美しく書くキーパーツが アドレッシングモードの切り出し だ。各命令の実行部分(ALUロジック)と「データがどこにあるか(実効アドレスの算出)」を完全に分離する。

// アドレッシングモードのヘルパー関数群
getZeroPageAddr() {
  return this.fetchByte();
}

getZeroPageXAddr() {
  return (this.fetchByte() + this.x) & 0xff; // ゼロページ内でラップアラウンド
}

getAbsoluteAddr() {
  return this.fetchWord();
}

getAbsoluteXAddr() {
  return (this.fetchWord() + this.x) & 0xffff;
}

// (Indirect, X) Indexed Indirect: ゼロページポインタにXを加算してから16ビットアドレスを取得
getIndexedIndirectAddr() {
  const base = (this.fetchByte() + this.x) & 0xff;
  const low = this.bus.readByte(base);
  const high = this.bus.readByte((base + 1) & 0xff); // ゼロページバウンダリをラップ
  return (high << 8) | low;
}

// (Indirect), Y Indirect Indexed: ゼロページポインタの指す16ビットアドレスにYを加算
getIndirectIndexedAddr() {
  const base = this.fetchByte();
  const low = this.bus.readByte(base);
  const high = this.bus.readByte((base + 1) & 0xff);
  return (((high << 8) | low) + this.y) & 0xffff;
}

JMP ($xxFF) の有名なハードウェアバグの再現

6502エミュレーターを自作する上で絶対に避けて通れないのが、間接ジャンプ命令 JMP ($xxxx) に存在する 「ページ境界跨ぎのバグ(Page Boundary Bug)」 だ。

通常、JMP ($1234) は $1234 から下位バイト、$1235 から上位バイトを読み込んでジャンプ先を決定する。しかし、指定アドレスが $12FF のようにページ末尾だった場合、NMOS 6502の内部カウンターが上位バイトへ繰り上がらず、上位バイトを $1300 からではなく同ページの $1200 から読み込んでしまう。

getIndirectJmpAddr() {
  const ptr = this.fetchWord();
  const low = this.bus.readByte(ptr);
  let high;

  // バグの再現: 下位バイトが 0xFF の場合、上位バイトは同じページの 0x00 から読み込む
  if ((ptr & 0x00ff) === 0x00ff) {
    high = this.bus.readByte(ptr & 0xff00); // 例: 0x12FF -> 0x1200
  } else {
    high = this.bus.readByte(ptr + 1);
  }
  return (high << 8) | low;
}

この挙動を再現していないと、6502_functional_test.bin の該当テストセクションで永久ループに陥ることになる。

4. ALUの最難所:ADC/SBC と Overflow(V) フラグ

6502の算術演算(ADC 加算 / SBC 減算)は、エミュレータ開発者にとって最初の大きな障壁だ。特に Overflow(V)フラグの計算 は直感に反するビット演算が必要になる。

Vフラグは 「符号付き8ビット整数(-128 〜 +127)として計算した結果がオーバーフローしたか」 を表す。

Overflow (V) フラグの導出式

加算結果 Result=A+M+CResult = A + M + C において、オーバーフローが発生するのは以下の条件に限られる。

  • 正数 + 正数 = 負数 になった場合
  • 負数 + 負数 = 正数 になった場合

これをビット演算に落とし込むと、次の1行で表現できる。

const v = ~((this.a ^ val) & 0x80) && ((this.a ^ result) & 0x80);
  • ~(a ^ val) & 0x80: Aとオペランド(val)の最上位ビット(符号)が同じであることを判定
  • (a ^ result) & 0x80: Aと計算結果(result)の符号が反転したことを判定

SBC(減算)を ADC(加算)に統合する

SBC(キャリー付き減算)は、「オペランドのビットを反転(1の補数)させて ADC を実行する」 ことで、全く同じ加算ルーチンに一本化できる。

SBC=A−M−(1−C)=A+(∼M)+CSBC = A - M - (1 - C) = A + (\sim M) + C

adc(val) {
  if (this.flagD) {
    this.adcDecimal(val); // 10進数モード処理 (後述)
    return;
  }

  const sum = this.a + val + this.flagC;
  const result = sum & 0xff;

  // Vフラグ判定: (A ^ result) & (val ^ result) & 0x80
  this.flagV = (~(this.a ^ val) & (this.a ^ sum) & 0x80) ? 1 : 0;
  this.flagC = sum > 0xff ? 1 : 0;
  this.setNZ(result);
  this.a = result;
}

sbc(val) {
  if (this.flagD) {
    this.sbcDecimal(val); // 10進数モード処理 (後述)
    return;
  }
  // SBC は val のビットを反転して ADC を呼ぶだけ!
  this.adc((~val) & 0xff);
}

5. 6502_functional_test.bin 突破への道のり

CPUコアの骨組みができたら、Klaus Dormann氏が作成した世界的標準テスト 6502_functional_test.bin を実行する。

このテストは、全命令・全アドレッシングモード・全フラグ変化を網羅的にテストし、どこかで失敗するとそのテスト番号の場所で無限ループ(自己ジャンプ JMP *)するように設計されている。

Node.js 用テストランナーの構築

// test_runner.js
import fs from 'fs';
import { M6502 } from './m6502.js';

const ram = new Uint8Array(65536);
const rom = fs.readFileSync('./6502_functional_test.bin');

// バイナリを 0x0000 〜 にロード
ram.set(rom, 0x0000);

const cpu = new M6502({
  readByte: (addr) => ram[addr],
  writeByte: (addr, val) => { ram[addr] = val; }
});

// functional_test の開始アドレスは 0x0400
cpu.pc = 0x0400;

let lastPc = -1;
let samePcCount = 0;

for (let i = 0; i < 100_000_000; i++) {
  const currentPc = cpu.pc;

  // 成功判定アドレス (0x3469 に到達して JMP 0x3469 に陥れば合格)
  if (currentPc === 0x3469) {
    console.log(`\n🎉 SUCCESS! Passed 6502_functional_test at PC: 0x${currentPc.toString(16)}`);
    process.exit(0);
  }

  // 同じアドレスで永久ループ=テスト失敗の検知
  if (currentPc === lastPc) {
    samePcCount++;
    if (samePcCount > 10) {
      console.error(`\n❌ TEST FAILED at PC: 0x${currentPc.toString(16)}`);
      process.exit(1);
    }
  } else {
    lastPc = currentPc;
    samePcCount = 0;
  }

  cpu.step();
}

潰した主なバグ

  1. 相対ジャンプ(BCC/BCS/BEQ等)の符号付きオフセット計算ミス
  • オフセット(1バイト)を単純加算してしまい、逆方向ジャンプ($FE = -2)でアドレスが跳ね飛んでいた。
  • 修正: offset = offset > 0x7f ? offset - 256 : offset;
  1. RTS / RTI 命令における PC 補正の違い
  • RTS (Return from Subroutine) はスタックから戻り先アドレスを復元した後、PC に +1 しなければならない(CALL時に PC+2 が積まれるため)。
  • RTI (Return from Interrupt) はスタックに正確な戻り先が退避されているため、+1 してはいけない。
  1. BIT 命令の不完全なフラグセット
  • BIT 命令は A & M の結果で Z フラグを更新するが、N フラグと V フラグには「メモリの値の Bit 7 と Bit 6」がそのままコピーされる という特殊仕様を失念していた。

6. 最難関 6502_decimal_test.bin と BCD(10進数モード)の完全攻略

6502_functional_test が通った後、エミュレータ開発者を最後に苦しめるのが Bruce Clark氏の 6502_decimal_test.bin だ。

ファミコン(NES)やRicoh 2A03ではオミットされている Dフラグ(Decimal Mode) だが、本来のNMOS 6502のBCDロジックは非常に奇妙かつ繊細な挙動を示す。

NMOS 6502 特有の BCD 補正アルゴリズム

単に「10進数として足し算・引き算する」だけではテストを通過できない。NMOS 6502の実機は、バイナリ演算を行いつつ、下位ニブル(4bit)と上位ニブル(4bit)が 9 を超えた場合に独自の補正(+6 / -6)を行う回路 が組まれており、その際の N, V, Z フラグの変化が非常に特殊なのだ。

NMOS 6502 準拠の ADC BCD 実装

adcDecimal(val) {
  const a = this.a;
  const c = this.flagC;

  // 1. 下位ニブルと上位ニブルの計算
  let al = (a & 0x0f) + (val & 0x0f) + c;
  let ah = (a >> 4) + (val >> 4);

  // 2. N, V, Z フラグは「補正前/補正途中の特定の状態」に基づいてセットされる (NMOSの挙動)
  if (al > 9) {
    al += 6;
  }
  if (al > 0x0f) {
    ah += 1;
  }

  // Vフラグの判定(バイナリ結果相当の計算で判定)
  const binarySum = a + val + c;
  this.flagV = (~(a ^ val) & (a ^ binarySum) & 0x80) ? 1 : 0;

  // N, Z フラグは補正途中のアルゴリズム結果で決定
  this.flagN = (ah & 0x08) ? 1 : 0;
  this.flagZ = ((a + val + c) & 0xff) === 0 ? 1 : 0;

  // 3. 上位ニブルの補正とキャリー確定
  if (ah > 9) {
    ah += 6;
  }
  this.flagC = ah > 0x0f ? 1 : 0;

  this.a = ((ah << 4) | (al & 0x0f)) & 0xff;
}

NMOS 6502 準拠の SBC BCD 実装

sbcDecimal(val) {
  const a = this.a;
  const c = this.flagC;
  const diff = a - val - (1 - c);

  let al = (a & 0x0f) - (val & 0x0f) - (1 - c);
  let ah = (a >> 4) - (val >> 4);

  if (al < 0) {
    al -= 6;
    ah -= 1;
  }
  if (ah < 0) {
    ah -= 6;
  }

  this.flagC = diff >= 0 ? 1 : 0;
  this.flagV = ((a ^ val) & (a ^ diff) & 0x80) ? 1 : 0;
  this.setNZ(diff & 0xff);

  this.a = ((ah << 4) & 0xf0) | (al & 0x0f);
}

この補正ロジックをミリ単位で合わせた結果、泥沼のデバッグを抜け出すことができた。

7. テスト結果

1. 6502_functional_test.bin

$ node run_functional_test.js
Starting 6502 Functional Test...
PC: 0x0400 ...
PC: 0x1000 ...
PC: 0x2000 ...
PC: 0x3000 ...

🎉 SUCCESS! Passed 6502_functional_test at PC: 0x3469
Total Executed Instructions: 96,241,083

2. 6502_decimal_test.bin

6502_decimal_test.bin はエラーが発生するとゼロページ $000B にエラーコードを書き込んで停止する。正常終了すると $0200 付近のループに達し、エラー箇所が 0 のまま完走する。

$ node run_decimal_test.js
Starting 6502 Decimal Test...
Executing Bruce Clark's Decimal Test...

🎉 SUCCESS! 6502_decimal_test completed with NO ERRORS!
Zero Page Error Code (0x000B): 0x00

両方のテストスイートで 完全ノーエラー・全テスト合格 を達成した。

8. まとめ

JavaScriptでゼロからMOS 6502 CPUコアを書き上げ、2大テストスイートを完走したことで、「どのプラットフォームにも移植可能な、完全精度に近い6502コア」 が手に入った。

今回の実装から得られた知見:

  • JMP ($xxFF) や Bit 4/5 のフラグ挙動など、ハードウェアの不具合や仕様の癖までエミュレートしないとテストスイートは絶対に通らない。
  • ADC / SBC の Overflow(V) フラグは ~(A ^ val) & (A ^ result) & 0x80 のビット演算に集約できる。
  • BCDモード(Dフラグ)の完全再現には、バイナリ演算の途中経過とニブル補正の段階的適用が必要。

さて、CPUコアの精度が100%証明されたとなれば、次にやるべきことは決まっている。

巨大な専用チップ群の同期に悩まされるファミコンではなく、「スティーブ・ウォズニアックの天才的シンプル回路設計」 の集大成である Apple II のエミュレーションへと駒を進めよう。