[JavaScript] Apple IIエミュレーターを自作。6502コアの上にDisk II・HiResカラー・スピーカーを積み上げてデモディスクが動くまで

[JavaScript] Apple IIエミュレーターを自作。6502コアの上にDisk II・HiResカラー・スピーカーを積み上げてデモディスクが動くまで

はじめに

前回の記事で MOS 6502 CPUコアをゼロから実装し、functional_test と decimal_test の両方を完走させた。CPUの精度が証明されたなら、次にやることは決まっている。その6502が心臓部として搭載された実機のエミュレーションだ。

ターゲットに選んだのは Apple II。ファミコンのようにPPU・APUといった巨大な専用チップとの同期に悩む必要がなく、スティーブ・ウォズニアックの天才的なミニマル設計思想により、ほぼすべてのハードウェア機能が「メモリアドレスへのアクセス」だけで制御される。ソフトスイッチと呼ばれるこの仕組みのおかげで、CPUバスの readByte / writeByte にif文を追加していくだけでハードウェアが次々と動き出す。

本記事では、テキストモードでのBASIC動作からDisk IIによるDSKブート、HiResカラーグラフィックスでデモディスクが表示されるまでの全実装を記録する。

前提記事

スクリーンショット

BASIC

Apple IIエミュレーター // BASIC 実行

動画

Apple IIエミュレーター // BASIC 実行

Activision demo disk

掲載しているスクショ画面は、ftp.apple.asimov.net にて公開されているデモ用ディスクイメージ(Activision demo disk.dsk)を使用して動作検証を行ったものです。 各著作権および商標権は、Activision社ならびに各権利所有者に帰属します。

Apple IIエミュレーター // Activision demo disk Apple IIエミュレーター // Activision demo disk / ghost busters Apple IIエミュレーター // Activision demo disk / ghost busters

動画

Apple IIエミュレーター // Activision demo disk / ghost busters Apple IIエミュレーター // Activision demo disk

1. 全体構成

完成したApple IIエミュレーターのファイル構成は以下の通り。

m6502-emulator/
├── AppleII/
│   ├── index.html          # エミュレーター画面
│   ├── apple2.js           # メインエミュレーション(バス・描画・入力・ループ)
│   ├── disk2.js            # Disk IIコントローラ(ニブル化・ステッパー制御)
│   └── speaker-processor.js # AudioWorklet スピーカー音声合成
└── core/
    └── m6502.js            # 6502 CPUコア(前回記事で実装済み)

エミュレーター本体 apple2.js は約400行。Disk IIモジュール disk2.js が約180行。CPUコアを除いた周辺ハードウェアの総実装量は約600行に収まっている。

コンポーネント実装量担当ファイル
メモリ・バス・ソフトスイッチ~120行apple2.js
テキスト / LoRes / HiRes 描画~150行apple2.js
キーボード入力~40行apple2.js
スピーカー(AudioWorklet)~50行apple2.js + speaker-processor.js
パドル / ジョイスティック~30行apple2.js
Disk II コントローラ~180行disk2.js
エミュレーションループ~40行apple2.js

2. メモリマップとソフトスイッチ

Apple IIのメモリマップはシンプルだ。64KBのフラットなアドレス空間に、RAM・ROM・I/Oが配置される。

アドレス範囲用途
$0000-$00FFゼロページ(6502の高速アクセス領域)
$0100-$01FFスタック
$0400-$07FFテキスト / LoRes VRAM (PAGE 1)
$0800-$0BFFテキスト / LoRes VRAM (PAGE 2)
$2000-$3FFFHiRes VRAM (PAGE 1)
$4000-$5FFFHiRes VRAM (PAGE 2)
$C000-$C0FFI/O ソフトスイッチ
$C600-$C6FFDisk II コントローラ ROM (Slot 6)
$D000-$FFFFApple II ROM (12KB)

ソフトスイッチ:アドレスを読むだけでハードウェアが動く

Apple IIの最も特徴的な設計が「ソフトスイッチ」だ。特定のメモリアドレスにアクセスする(読み書き問わず)だけで、ハードウェアの状態が切り替わる。レジスタの書き込みではなく、アドレスバスにアドレスが乗ること自体がトリガーになる。

const bus = {
  readByte: (addr) => {
    addr &= 0xffff;

    if (addr >= 0xc000 && addr <= 0xc0ff) {
      if (addr === 0xc000) return keyBuffer;        // キーボード読み出し
      if (addr === 0xc010) { keyBuffer &= 0x7f; return 0x00; } // ストロボクリア
      if (addr === 0xc030) { toggleSpeaker(); return 0x00; }   // スピーカートグル
      // 画面モード切替
      if (addr === 0xc050) isTextMode = false;       // GR モード
      if (addr === 0xc051) isTextMode = true;        // TEXT モード
      if (addr === 0xc052) isMixedMode = false;      // フルスクリーン
      if (addr === 0xc053) isMixedMode = true;       // MIXED(下4行テキスト)
      if (addr === 0xc056) isHiRes = false;          // LoRes
      if (addr === 0xc057) isHiRes = true;           // HiRes
      return 0x00;
    }

    return ram[addr];
  }
};

重要なのは、writeByte 側にもまったく同じソフトスイッチ処理を入れる必要があること。Apple IIのROMやゲームは STA $C050 のようにwrite命令でソフトスイッチを叩くことが日常的にある。readだけ対応して数時間フリーズの原因を探す羽目になった。

3. テキストモード描画とVRAMの奇怪なアドレスマッピング

Apple IIのテキスト画面は40桁×24行。各行の先頭アドレスは連続しておらず、ウォズニアック設計の回路都合により以下の複雑なインターリーブ配置になっている。

行 0: $0400   行 8:  $0428   行 16: $0450
行 1: $0480   行 9:  $04A8   行 17: $04D0
行 2: $0500   行 10: $0528   行 18: $0550
行 3: $0580   行 11: $05A8   行 19: $05D0
行 4: $0600   行 12: $0628   行 20: $0650
行 5: $0680   行 13: $06A8   行 21: $06D0
行 6: $0700   行 14: $0728   行 22: $0750
行 7: $0780   行 15: $07A8   行 23: $07D0

この配置を数式化すると:

function getTextAddress(x, y) {
  const block = Math.floor(y / 8);   // 0, 1, 2 の3グループ
  const line = y % 8;                // グループ内の行番号
  return 0x0400 + (line * 0x80) + (block * 0x28) + x;
}

テキスト表示モードの判定

Apple IIのテキストVRAMに格納される1バイトは、文字コードだけでなく表示モードの情報も含んでいる。

値の範囲表示モード
$00-$3Fインバース(背景:緑、文字:黒)
$40-$7Fフラッシュ(約4Hzで通常/インバースが交互に切り替わる)
$80-$FFノーマル(背景:黒、文字:緑)

フラッシュモードの実装には、フレームカウンターで約15フレームごとに表示状態を反転させる。

let flashState = false;
let flashCounter = 0;

function renderTextScreen() {
  flashCounter++;
  if (flashCounter >= 15) {
    flashCounter = 0;
    flashState = !flashState;
  }
  // ... 描画処理
}

4. LoRes グラフィックス(40×48 / 16色)

LoResモードはテキストVRAMと同じ $0400-$07FF を共有する。1バイトが縦2ブロック分を表し、下位4ビットが上半分、上位4ビットが下半分の色(16色パレット)を指定する。

const LO_RES_COLORS = [
  '#000000', '#E00000', '#0000C0', '#E000E0',
  '#008000', '#808080', '#0000F8', '#50B0F8',
  '#805000', '#F88000', '#C0C0C0', '#F880F8',
  '#00E000', '#F8F800', '#80F880', '#FFFFFF'
];

function renderLoResScreen() {
  for (let y = 0; y < 24; y++) {
    for (let x = 0; x < 40; x++) {
      const addr = getTextAddress(x, y);
      const byteVal = ram[addr];
      const topColor = byteVal & 0x0f;
      const bottomColor = (byteVal >> 4) & 0x0f;
      // 上半分と下半分をそれぞれ描画
    }
  }
}

5. HiRes グラフィックスとNTSCアーティファクトカラー

VRAMアドレス計算

HiResモードは $2000-$3FFF の8KBを使い、280×192ドットを描画する。テキストモードと同様にインターリーブされたアドレス配置を持つが、さらに複雑になる。

function getHiResAddress(xByte, y) {
  const base = hiresPage2 ? 0x4000 : 0x2000;
  const line = y % 8;
  const block = Math.floor(y / 8) % 8;
  const group = Math.floor(y / 64);
  return base + (line * 0x0400) + (block * 0x0080) + (group * 0x0028) + xByte;
}

192本のスキャンラインが3つのグループ(各64ライン)に分かれ、各グループが8つのブロック(各8ライン)で構成される。このインターリーブはDRAMのリフレッシュ回路を兼ねたウォズニアックの設計上の工夫だ。

NTSCアーティファクトカラーの仕組み

Apple IIのHiResカラーは、本来モノクロの映像信号をNTSCテレビが誤解釈することで色が付くという、意図的な「バグ利用」だ。

各バイトの下位7ビットがピクセル(LSBが左端)、最上位ビット(bit 7)がカラーパレットの選択に使われる。

bit 7偶数ピクセル奇数ピクセル
0紫 (#C864FF)緑 (#38CB00)
1青 (#0078FF)橙 (#FF6200)

隣接する2ピクセルが両方ONの場合は白になる。

function renderHiResScreen() {
  for (let y = 0; y < 192; y++) {
    for (let xByte = 0; xByte < 40; xByte++) {
      const addr = getHiResAddress(xByte, y);
      const byteVal = ram[addr];
      const highBit = byteVal & 0x80;

      for (let bit = 0; bit < 7; bit++) {
        const px = xByte * 7 + bit;
        const pixelOn = (byteVal & (1 << bit)) !== 0;
        if (!pixelOn) continue;

        // 隣接ピクセルの判定(バイト境界を跨ぐ場合も考慮)
        let prevOn = false, nextOn = false;
        if (bit > 0) {
          prevOn = (byteVal & (1 << (bit - 1))) !== 0;
        } else if (xByte > 0) {
          prevOn = (ram[getHiResAddress(xByte - 1, y)] & 0x40) !== 0;
        }
        if (bit < 6) {
          nextOn = (byteVal & (1 << (bit + 1))) !== 0;
        } else if (xByte < 39) {
          nextOn = (ram[getHiResAddress(xByte + 1, y)] & 0x01) !== 0;
        }

        // 色の決定
        if (prevOn || nextOn) {
          ctx.fillStyle = '#FFFFFF'; // 白
        } else if (highBit) {
          ctx.fillStyle = (px % 2 === 0) ? '#0078FF' : '#FF6200'; // 青/橙
        } else {
          ctx.fillStyle = (px % 2 === 0) ? '#C864FF' : '#38CB00'; // 紫/緑
        }
        ctx.fillRect(px, y, 1, 1);
      }
    }
  }
}

6. スピーカー音声合成(AudioWorklet)

Apple IIのサウンドシステムは究極にシンプルだ。$C030 にアクセスするたびにスピーカーのコーン(膜)の位置がトグルされる。それだけ。外部音源チップは存在しない。

失敗した方式:OscillatorNode

最初はトグルのたびに新しい OscillatorNode を生成する方式を試したが、高速トグル時にホワイトノイズ化して使い物にならなかった。

成功した方式:AudioWorklet + サイクル単位トグルキュー

CPUのサイクルカウンターでトグルのタイミングを記録し、フレーム末にまとめてAudioWorkletに送信する方式に変更。Worklet側で絶対サンプル位置に変換してから波形を生成する。

// apple2.js側:トグルイベントをCPUサイクルで記録
const toggleQueue = [];

function toggleSpeaker() {
  toggleQueue.push(currentCycle);
}

// フレーム末にまとめて送信
if (toggleQueue.length > 0) {
  speakerNode.port.postMessage({
    toggles: [...toggleQueue],
    frameStart,
    cyclesPerFrame: cyclesToRun,
    sampleRate: audioCtx.sampleRate
  });
  toggleQueue.length = 0;
}
// speaker-processor.js:絶対サンプル位置でトグルを再現
class SpeakerProcessor extends AudioWorkletProcessor {
  constructor() {
    super();
    this._level = 0;
    this._toggles = [];
    this._sampleCount = 0;
    this.port.onmessage = (e) => {
      const { toggles, frameStart, cyclesPerFrame, sampleRate } = e.data;
      const samplesPerFrame = sampleRate / 60;
      for (const cycle of toggles) {
        const offset = Math.floor(
          ((cycle - frameStart) / cyclesPerFrame) * samplesPerFrame
        );
        this._toggles.push(this._sampleCount + offset);
      }
      this._toggles.sort((a, b) => a - b);
    };
  }
  process(inputs, outputs) {
    const output = outputs[0][0];
    for (let i = 0; i < output.length; i++) {
      const absPos = this._sampleCount + i;
      while (this._toggles.length > 0 && this._toggles[0] <= absPos) {
        this._level ^= 1;
        this._toggles.shift();
      }
      output[i] = this._level ? 0.08 : -0.08;
    }
    this._sampleCount += output.length;
    return true;
  }
}
registerProcessor('speaker-processor', SpeakerProcessor);

この方式により、BASICの PRINT CHR$(7) で実機に近い「ピッ」というビープ音が鳴るようになった。

7. Disk II コントローラの完全実装

Apple IIエミュレーターの最大の山場がDisk IIだ。ここが動かないとDSKファイルからゲームをブートできない。

必要な3要素

  1. 本物のDisk II ROM (P5, 341-0027) — $C600-$C6FF に配置される256バイトのブートストラップコード
  2. 6-and-2 ニブル化 — .dskファイルの生データをDisk II ROMが読める形式に変換
  3. ステッパーモーター制御 — ハーフトラック単位でのヘッド移動

Disk II P5 ROMの罠

ROMアーカイブサイトからダウンロードした 341-0027.bin のデータが壊れていた(ビットスワップ版)。先頭バイトが 0x52 だったのが決定的な手がかりで、正しいP5 ROMは 0xA2(LDX #$20)で始まる。同サイトの「with-D4-D7 data bits swapped」版が実は正しいデータだった。

6-and-2 エンコーディング

.dskファイルには256バイト×16セクター×35トラックの生データが格納されている。しかしDisk II ROMはこの生データを直接読めない。実機のフロッピーディスク上ではデータが「ニブル化」されており、Disk II ROMはそのニブルデータを前提としたデコードルーチンを持っている。

ニブル化では、256バイトのセクターデータが以下のように変換される。

  1. 256バイトを342個の6ビット値に展開(下位2ビットを別途パック)
  2. XORチェーンを施してから64エントリの変換テーブルで8ビットに変換
  3. アドレスフィールド(4-and-4エンコード)とデータフィールドをプロローグ/エピローグで囲む
  4. セクター間にギャップ(同期バイト 0xFF)を挿入
function nibblizeSector(data, volume, track, sector) {
  const nibbles = [];

  // ギャップ + アドレスフィールド(D5 AA 96 ... DE AA EB)
  for (let i = 0; i < 12; i++) nibbles.push(0xff);
  nibbles.push(0xd5, 0xaa, 0x96);
  nibbles.push(...encode44(volume), ...encode44(track),
               ...encode44(sector), ...encode44(volume ^ track ^ sector));
  nibbles.push(0xde, 0xaa, 0xeb);

  // ギャップ + データフィールド(D5 AA AD ... DE AA EB)
  for (let i = 0; i < 8; i++) nibbles.push(0xff);
  nibbles.push(0xd5, 0xaa, 0xad);

  // 6-and-2 プレニブル化
  const buf6 = new Uint8Array(342);
  for (let i = 0; i < 256; i++) {
    const slot = i % 86;
    const shift = Math.floor(i / 86) * 2;
    const twoBits = ((data[i] & 0x01) << 1) | ((data[i] & 0x02) >> 1);
    buf6[slot] |= (twoBits << shift);
  }
  for (let i = 0; i < 256; i++) {
    buf6[86 + i] = data[i] >> 2;
  }

  // XORチェーン + テーブル変換
  let prev = 0;
  for (let i = 0; i < 342; i++) {
    nibbles.push(WRITE_TABLE[(buf6[i] ^ prev) & 0x3f]);
    prev = buf6[i];
  }
  nibbles.push(WRITE_TABLE[prev & 0x3f]); // チェックサム

  nibbles.push(0xde, 0xaa, 0xeb);
  for (let i = 0; i < 16; i++) nibbles.push(0xff);
  return nibbles;
}

ステッパーモーターとハーフトラック

Disk IIのヘッド位置制御は4相のステッパーモーターで行われる。$C0E0-$C0E7 の偶数アドレスがフェーズOFF、奇数がフェーズONで、アクティブなフェーズの位置関係によってヘッドが前進・後退する。

_updateTrack() {
  const currentPhase = this.halfTrack & 3;
  for (let offset = 1; offset <= 2; offset++) {
    const nextPhase = (currentPhase + offset) & 3;
    const prevPhase = (currentPhase - offset + 4) & 3;
    if (this.phases[nextPhase]) {
      this.halfTrack = Math.min(69, this.halfTrack + offset);
      this.readPos = 0;
      return;
    }
    if (this.phases[prevPhase]) {
      this.halfTrack = Math.max(0, this.halfTrack - offset);
      this.readPos = 0;
      return;
    }
  }
}

DOS 3.3 セクターインターリーブ

DSKファイル内のセクターは論理順で並んでいるが、Disk II ROMは物理セクター順でアクセスする。DOS 3.3のインターリーブテーブルでマッピングを行う。

const SECTOR_INTERLEAVE = [0, 7, 14, 6, 13, 5, 12, 4, 11, 3, 10, 2, 9, 1, 8, 15];

8. パドル / ジョイスティック入力

Apple IIのパドル(ジョイスティック)はアナログ入力で、ソフトウェアタイマー方式で値を読み取る。

  1. $C070 にアクセスしてタイマーをリセット
  2. $C064-$C067 を繰り返しポーリングし、bit 7 が 1→0 に変化するまでのサイクル数を計測
  3. その経過サイクル数がパドルの位置(0-255)に対応
if (addr >= 0xc064 && addr <= 0xc067) {
  const idx = addr - 0xc064;
  const elapsed = currentCycle - paddleResetCycle;
  return (elapsed < paddleValues[idx] * 11) ? 0x80 : 0x00;
}
if (addr === 0xc070) {
  paddleResetCycle = currentCycle;
  return 0x00;
}

ここで致命的なバグがあった。メインループの currentCycle を命令実行ごとに +1 していたため、実際の経過サイクルの1/3〜1/5に圧縮されてしまい、パドルのニュートラル位置(128)が常に「右下に倒されている」と判定されていた。currentCycle += cpu.step() でサイクル数を正確に加算することで解決。

9. エミュレーションループと高速ディスクロード

メインループは requestAnimationFrame で60fps駆動し、実時間ベースでCPUサイクル数を計算する。

function loop(timestamp) {
  updatePaddles();

  const elapsed = Math.min(timestamp - lastTime, 33);
  lastTime = timestamp;

  // ディスクロード中は30倍速で高速実行
  const isLoading = disk2 && disk2.motorOn;
  const speedMultiplier = isLoading ? 30 : 1;
  const cyclesToRun = Math.floor(elapsed * 1023 * speedMultiplier);

  let ran = 0;
  while (ran < cyclesToRun) {
    const c = cpu.step();
    ran += c;
    currentCycle += c;
  }

  // ロード中は音声キューを破棄(爆音ノイズ防止)
  if (isLoading) {
    toggleQueue.length = 0;
  }

  renderScreen();
  requestAnimationFrame(loop);
}

実機のフロッピーディスク読み込みは体感で数秒〜十数秒かかるが、エミュレーターでその待ち時間を再現する必要はない。ディスクモーターがONの間はCPUを30倍速で回すことで、ゲームのロードが一瞬で完了する。

10. 動作確認

BASIC動作

ROMをロードすると APPLE ][ バナーとプロンプトが表示され、BASICプログラムの入力・実行が可能。

テキストモードゲーム

DSKからのブートに成功し、テキストモードのキャラクターグラフィックスで迷路・ドット・ゴースト・パックマンが表示される。キーボード操作(A/Z/←/→)でゲームプレイが可能。

HiResグラフィックス

HiResモードのNTSCアーティファクトカラーにより、青い空、白い富士山、白い空手家のキャラクターが実機に近い色合いで表示される。

11. まとめ

6502 CPUコアの上にApple IIのハードウェアを1つずつ積み上げていく作業は、ウォズニアックの設計思想を追体験する旅でもあった。

得られた知見:

  • Apple IIのハードウェア制御はすべてソフトスイッチ(メモリアクセス)で行われるため、readByte / writeByte の両方に同じ処理を入れないとフリーズする
  • Disk IIのブートには本物のP5 ROM (341-0027) が不可欠。偽のブートROMでは絶対に動かない
  • 6-and-2 ニブル化はDSKファイルのセクターデータをDisk II ROMが期待するフォーマットに変換する必須工程
  • HiResのカラーはNTSCアーティファクトであり、bit 7のパレット選択・ピクセル位置の偶奇・隣接ピクセルの白判定の3要素で決まる
  • パドルのタイマー計算にはCPUの正確なサイクルカウントが不可欠。命令数ではなくサイクル数でカウントしないと入力が壊れる

Apple IIは1977年のマシンだが、その設計の美しさとシンプルさは今でも色褪せない。エミュレーター実装の教材として、これ以上のターゲットはないだろう。