[JavaScript] Atari 2600エミュレーターを自作。真っ黒画面からTIA・RIOT・バンク切替を積み上げてMYST Demakeが動くまで

[JavaScript] Atari 2600エミュレーターを自作。真っ黒画面からTIA・RIOT・バンク切替を積み上げてMYST Demakeが動くまで

はじめに

前回はMOS 6502コアの上にApple IIを積み上げた。同じ6502(正確には廉価版の6507)を心臓に持つもう一つの名機が Atari 2600 (VCS) だ。

ただし、Apple IIとは設計思想が真逆になる。Apple IIはVRAMを持ち、CPUがメモリに絵を書けばビデオ回路が勝手に表示してくれる。Atari 2600には VRAMが存在しない。RAMはたった128バイトで、画面はTIAというチップのレジスタを、電子ビームが走っている「今この瞬間」に書き換えることで描く。いわゆる「Racing the Beam」だ。

つまりエミュレーターも、CPUとビデオチップのタイミングを1クロック単位で合わせないと絵が崩れる。Apple IIの「バスにif文を足していけば動く」世界とはまったく違う難しさがあった。

本記事では、真っ黒画面から始まり、Imagicの『Atlantis』が遊べるようになり、最終的に16KB E7カートリッジで作られたMYSTのデメイク版が最後まで動くようになるまでを記録する。

前提記事

Z80 EMULATOR PROJECT

6502 EMULATOR PROJECT

スクリーンショット

Atlantis (Europe)

掲載しているスクショ画面は、手持ちのROMイメージ(Atlantis (Europe).a26)を使用して動作検証を行ったものです。 各著作権および商標権は、Imagic社ならびに各権利所有者に帰属します。

Atari 2600エミュレーター // Atlantis Atari 2600エミュレーター // Atlantis

動画

Atari 2600エミュレーター // Atlantis

Atari VCS Myst “Demake”

Vince Weaver (deater) 氏が公開している16KB E7カートリッジ版MYSTのデメイク(v2.6)を使用しています。 MYSTの商標および著作物はCyan社に帰属します。本作はCyan社の公式製品ではありません。

Atari 2600エミュレーター // MYST タイトル Atari 2600エミュレーター // MYST 島

動画

Atari 2600エミュレーター // MYST 島 (VIDEO)

1. 全体構成

m6502-emulator/
├── Atari2600/
│   ├── index.html               # エミュレーター&デバッガー画面
│   ├── atari2600.js             # メインループ・ビーム同期・入力・描画
│   ├── tia.js                   # TIA(映像・衝突判定・サウンドレジスタ)
│   ├── riot.js                  # RIOT 6532(RAM・タイマー・I/O)
│   ├── a2600bus.js              # バス+カートリッジマッパー
│   └── tia-audio-processor.js   # AudioWorklet TIAサウンド
└── core/
    └── m6502.js                 # 6502 CPUコア(Apple IIと共用)
コンポーネント実装量担当ファイル
TIA(映像・衝突・パレット)~330行tia.js
RIOT 6532~75行riot.js
バス+マッパー7種+自動判別~270行a2600bus.js
ループ・ビーム同期・入力・UI~280行atari2600.js
TIAサウンド~120行tia-audio-processor.js
非公式命令(CPUコアへの追加)~100行m6502.js

CPUコアは前回までのものをそのまま流用している。追加したのは非公式命令だけで、それもフラグで有効化する形にしたのでApple II側には影響しない。

2. Atari 2600のハードウェア

メモリマップ

6507はアドレス線が13本しかないので、見えるのは8KB($0000-$1FFF)だけ。これ以上のアドレスはすべてミラーになる。

アドレスA12A9A7用途
$0000-$007F0-0TIA(映像・音声・入力)
$0080-$00FF001RIOT RAM 128バイト(ゼロページ兼スタック)
$0280-$0297011RIOT I/O・タイマー
$1000-$1FFF1--カートリッジROM 4KB

判定はアドレスビットだけで済む。

readByte(addr) {
  addr &= 0x1fff;
  if (addr & 0x1000) return this.cart.read(addr & 0x0fff);  // ROM
  if (addr & 0x0080) return this.riot.read(addr);           // RIOT
  return this.tia.read(addr);                               // TIA
}

1ラインの構造

TIAは1ラインを228カラークロックで描く。CPUは3カラークロックで1サイクル進むので、1ラインは76 CPUサイクルになる。

カラークロック内容
0-67水平ブランク(HBLANK)
68-227可視領域 160ピクセル

縦方向はNTSCなら262ライン、PALなら312ライン。何ライン目で何を描くかは、すべてゲーム側のプログラムがWSYNC・VSYNC・VBLANKを叩いて自分で管理する。

3. 真っ黒画面:最初に踏んだ地雷たち

最初の実装ではROMをロードしても画面が真っ黒のままだった。CPUのレジスタは変化しているのでCPUは動いている。原因は描画側とRIOT側にあった。

パレットの引き方ミス

TIAのカラーレジスタは8ビットで、上位4ビットが色相、ビット1-3が輝度、ビット0は未使用。つまり有効な色は128色で、インデックスは 値 >> 1 になる。

最初のコードは NTSC_PALETTE[値] とそのまま引いていたため、$80 以上の色はすべて undefined になり、フォールバックの黒に化けていた。PAL版Atlantisの空の青は $B0 台なので、画面ごと消えていた。

// NG: 128色テーブルを 0-255 で引いている
const color = NTSC_PALETTE[buf[i]] || '#000000';

// OK: >> 1 でインデックス化し、事前にABGRへ変換したLUTを引く
pix32[i] = lut[buf[i] >> 1];

ついでに、毎ピクセル parseInt で16進文字列を分解していたのもやめた。パレットを Uint32Array の ABGR に変換しておき、ImageData のバッファに Uint32Array で直接書き込む。

フレーム境界をVSYNCで取る

最初はフレームを「19912サイクル=262ライン」で固定していた。しかしPAL版は312ラインで、しかもライン数はゲームが自分で決める。VSYNCビットの立ち上がりでフレームを区切り、VBLANKが解除されたラインを可視領域の先頭とするよう変更した。

case 0x00: { // VSYNC
  const on = (val & 0x02) !== 0;
  if (on && !this.vsync) this.endFrame();  // 立ち上がりでフレーム確定
  this.vsync = on;
  break;
}
case 0x01: { // VBLANK
  const on = (val & 0x02) !== 0;
  if (!on && this.vblank && this.firstVisible < 0) this.firstVisible = this.scanline;
  this.vblank = on;
  break;
}

1フレームのライン数から、PALとNTSCのパレットも自動で切り替えている(290ライン超ならPAL)。

RIOTのバグ3つ

バグ症状
addr & 0x05 でI/Oを判定していたSWCHB(コンソールスイッチ)を読むとSWCHA(ジョイスティック)が返る
$0280-$0283 への書き込みでもタイマーがセットされるI/Oポートの設定でタイマーが壊れる
タイマーが0で止まるBIT TIMINT で待つROMが永久ループ

特に3つ目が重要。実機のINTIMは 0 の次に $FF へ回り込み、そこからは毎サイクル1ずつ減る。同時にTIMINTフラグが立つ。

updateTimer(cycles) {
  this.acc += cycles;
  while (this.acc >= this.interval) {
    this.acc -= this.interval;
    this.intim = (this.intim - 1) & 0xff;
    if (this.intim === 0xff) {       // アンダーフロー
      this.interval = 1;             // 以降は1サイクルごとに減算
      this.timint |= 0xc0;
    }
  }
}

診断用ログ

原因を切り分けるため、TIAの各レジスタへの書き込み回数を1秒ごとにログへ出すようにした。

F60 lines=313 VSYNC:2 VBLANK:2 WSYNC:194 COLUBK:6 COLUPF:4 PF:21 GRP:252

COLUBK や PF が0ならCPU側が描画コードに到達していない。0でなければTIA側が悪い。この1行で判断できる。

4. プレイフィールドとオブジェクト

プレイフィールド

背景の40ビット(片側20ビット)はPF0・PF1・PF2の3レジスタで表現され、1ビットが4ピクセルになる。厄介なのはビット順がレジスタごとにバラバラなこと。

レジスタ使うビット左から右への順
PF0上位4ビットbit4 → bit7
PF18ビットbit7 → bit0
PF28ビットbit0 → bit7

右半分は、CTRLPFのbit0で「繰り返し」か「鏡像」かを選ぶ。

const pfX = x < 80 ? x : (this.ctrlpf & 0x01) ? (159 - x) : (x - 80);
let pfBit;
if (pfX < 16)      pfBit = this.pf0 & (1 << (4 + (pfX >> 2)));
else if (pfX < 48) pfBit = this.pf1 & (1 << (7 - ((pfX - 16) >> 2)));
else               pfBit = this.pf2 & (1 << ((pfX - 48) >> 2));

プレイヤー・ミサイル・ボール

背景だけ描いた状態のAtlantisは、地形は出るものの宇宙船も建物も消えていた。ログを見るとGRP0/GRP1への書き込みは毎フレーム252回あり、処理は止まっていない。描いていないだけだった。

Atari 2600のスプライトは5つ。

オブジェクト幅主なレジスタ
プレイヤー0/18ピクセルGRP0/1、NUSIZ0/1、REFP0/1、VDELP0/1
ミサイル0/11/2/4/8ピクセルENAM0/1、NUSIZの上位ビット、RESMP0/1
ボール1/2/4/8ピクセルENABL、CTRLPFの上位ビット、VDELBL

座標レジスタは存在しない。RESP0に書き込んだ瞬間のビーム位置がそのままX座標になる。微調整はHMP0などに -8〜+7 の移動量を入れ、HMOVEで一斉に適用する。

// 可視領域で RESP0 を叩くと「現在のクロック - 68 + 5」がX座標
resPos(visibleOffset, hblankPos) {
  return this.clock < 68 ? hblankPos : (this.clock - 68 + visibleOffset) % 160;
}

case 0x10: this.posP0 = this.resPos(5, 3); break;  // RESP0
case 0x12: this.posM0 = this.resPos(4, 2); break;  // RESM0

case 0x2a: // HMOVE: 上位ニブルを符号付きで適用(正の値=左へ)
  this.posP0 = (this.posP0 - hmSigned(this.hmp0) + 160) % 160;
  // ... P1, M0, M1, BL も同様
  if (this.clock < 68) this.hmoveBlank = true;  // 左端8pxの黒帯
  break;

NUSIZの下位3ビットは、コピーの数と間隔、または拡大率を決める。

const COPIES = [[0], [0, 16], [0, 32], [0, 16, 32], [0, 64], [0], [0, 32, 64], [0]];
const PSCALE = [1, 1, 1, 1, 1, 2, 1, 4];

VDEL(垂直遅延)

VDELP0が立っていると、GRP0は「新しい値」ではなく「GRP1が書かれた時点でのGRP0の値」を表示する。1ラインおきにしか更新できない制約を回避するための機能で、48ピクセル幅の文字表示(6スプライト並べ)では必須になる。

case 0x1b: this.grp0 = val; this.grp1old = this.grp1; break;  // GRP0書き込みでGRP1をラッチ
case 0x1c: this.grp1 = val; this.grp0old = this.grp0; this.enablOld = this.enabl; break;

優先順位と衝突判定

各ピクセルで6オブジェクト(P0/P1/M0/M1/BL/PF)の有無を6ビットのマスクにし、事前計算したLUTで15種類の衝突ラッチに変換する。

const CX_LUT = new Uint16Array(64);
for (let m = 0; m < 64; m++) {
  const h = (a, b) => (m & a) && (m & b);
  let c = 0;
  if (h(M0, P1)) c |= 1 << 0;  if (h(M0, P0)) c |= 1 << 1;   // CXM0P
  // ... 全15組
  CX_LUT[m] = c;
}

// renderPixel 内
this.cx |= CX_LUT[m];

色の優先順位はCTRLPFのbit2で切り替わる。通常は「P0/M0 > P1/M1 > PF/BL > 背景」で、bit2が立つと「PF/BL」が最前面になる。

5. ビームとの同期:Racing the Beam の本丸

オブジェクトを描けるようになっても、Javatariと並べて比べると地上物が4〜5ピクセル左にずれていた。ここからがAtari 2600エミュレーションの本題になる。

TIAアクセスは命令の最終サイクルで起きる

最初の実装は cpu.step() で1命令を丸ごと実行してから、そのサイクル数ぶんTIAを進めていた。これだと STA RESP0 の書き込みは、命令の頭のビーム位置で反映されてしまう。

実機の6502は、書き込みを命令の最終サイクルで行う。STA RESP0(3サイクル)なら、3サイクル目の時点のビーム位置が正しい。

CPUコアを1サイクル単位に作り直すのは大工事になる。そこで、バスに「TIA/RIOTに触れる直前に呼ばれるフック」を仕込み、その瞬間に命令終了時点までビームを先に進める方式にした。

// 命令内で最初に TIA/RIOT に触れた瞬間、命令終了時点までビームとタイマーを進める
let curOp = 0, inInstr = false, ticked = 0;
bus.onIO = () => {
  if (!inInstr || ticked) return;
  ticked = CYC[curOp] + (PX[curOp] && cpu.pageCrossed ? 1 : 0);
  tia.tick(ticked);
  riot.updateTimer(ticked);
};

function execOne() {
  if (tia.wsync) {                    // WSYNC中は行末まで一気に進める
    const c = tia.cyclesToLineEnd();
    tia.tick(c); riot.updateTimer(c);
    return c;
  }
  curOp = bus.peek(cpu.pc);           // 副作用なしでオペコードを先読み
  inInstr = true; ticked = 0;
  const cycles = cpu.step();
  inInstr = false;
  const rest = cycles - ticked;       // I/Oに触れなかった命令は後から進める
  if (rest > 0) { tia.tick(rest); riot.updateTimer(rest); }
  return cycles;
}

命令のサイクル数はオペコード表 CYC[] から引く。ページ跨ぎのペナルティは、CPUコアがアドレス計算時に立てる pageCrossed フラグをそのまま使う。コアはアドレスを計算してから readByte を呼ぶので、フックが呼ばれた時点でフラグはもう確定している。

これは読み出しにも効く。VBLANK中に LDA INTIM でタイマーを待つループは、読み出しのタイミングが数サイクルずれるだけで1ライン前後ぶれる。これが「数秒おきに画面全体が上下に振動する」症状の原因だった。

罠:行の最終サイクルで書かれたWSYNC

ビーム同期を入れた直後に、Atlantisの画面下の「©1982 IMAGIC」が崩れ、1フレームのライン数が314から323に増えた。

原因はWSYNC。76サイクルぴったりで1ラインを回すカーネルは、行の最後のサイクルで STA WSYNC を打つ。命令終了時点までビームを進めると、その時点ではもう次の行頭(clock=0)に来ている。そこでWSYNCを立てると、丸々1ライン余計に待ってしまう。

// 行の最終サイクルで書かれた WSYNC (= 既に次の行頭) は待たない
case 0x02: if (this.clock !== 0) this.wsync = true; break;

228クロック=76サイクルは3で割り切れるので、サイクル境界はどのラインでも同じ位相に来る。clock === 0 の判定1つで済む。

RIOTタイマーの書き込み直後

実機のタイマーは、書き込んだ次のサイクルで最初の1回目の減算が起きる。

this.intim = val & 0xff;
this.interval = INTERVALS[addr & 0x03];
this.acc = this.interval - 1;   // 書き込み直後の次サイクルで最初の -1

テスト用の自作ROM(VSYNC 3ライン+WSYNCループ53+256ライン)で、ちょうど312ラインになることを確認した。

6. アスペクト比

Atari 2600は縦長画面ではない。横160ピクセルしかないので1ピクセルが横長(約2:1)で、テレビ上では4:3になる。縦横2倍で表示していたときは縦に伸びていた。横4倍・縦2倍(640×456)に変更した。

7. 入力

キー役割レジスタ
矢印キージョイスティックSWCHA 上位4ビット(0で押下)
Space / ZFIREINPT4 bit7(0で押下)
EnterGAME RESETSWCHB bit0
ShiftGAME SELECTSWCHB bit1
const JOY = { ArrowRight: 0x80, ArrowLeft: 0x40, ArrowDown: 0x20, ArrowUp: 0x10 };
riot.swcha = down ? (riot.swcha & ~JOY[e.key]) : (riot.swcha | JOY[e.key]);

8. TIAサウンド

音については、最初にGeminiが書いてくれた実装があった。それっぽく鳴ってはいたが、AUDCのモードの半分ほどが間違っていた。

AUDC正しい動作最初の実装
4/5純音(1/2分周の矩形波)5bitポリの出力(濁る・音程がずれる)
12/13純音 1/6分周1/3分周なし
14純音 1/93分周(Div31×3)1/3分周なし
0/11出力High固定(音量直出し)無音

Ron Friesの TIASound.c(昔のStellaが使っていたアルゴリズム)の方式で書き直した。AUDCの各ビットには意味がある。

ビット意味
bit1=0分周後、毎回クロック
bit1=1, bit0=0Div31でゲート
bit1=1, bit0=15bitポリでゲート
bit2=1純音(トグル)
bit3=15bitポリ(AUDC=8のみ9bitノイズ)
bit2,3=04bitポリ
bit2,3=1さらに1/3分周
clock() {
  if (this.divMax === 0) return;            // DC モード (AUDC 0/11)
  if (--this.divCnt > 0) return;
  this.divCnt = this.divMax;                // AUDF+1(上位2bit=11なら×3)

  const c = this.audc;
  this.p5 = (this.p5 + 1) % 31;
  const gate = (c & 0x02) === 0 ||
               ((c & 0x01) === 0 && DIV31[this.p5]) ||
               ((c & 0x01) === 1 && BIT5[this.p5]);
  if (!gate) return;

  if (c & 0x04) {                           // 純音
    this.out = this.out ? 0 : this.audv;
  } else if (c & 0x08) {
    if (c === 0x08) {                       // 9bit ノイズ
      this.p9 = (this.p9 + 1) % 511;
      this.out = BIT9[this.p9] ? this.audv : 0;
    } else {                                // 5bit ポリ
      this.out = BIT5[this.p5] ? this.audv : 0;
    }
  } else {                                  // 4bit ポリ
    this.p4 = (this.p4 + 1) % 15;
    this.out = BIT4[this.p4] ? this.audv : 0;
  }
}

オーディオクロックもPAL(3.546894MHz / 114 ≒ 31,113Hz)とNTSC(3.579545MHz / 114 ≒ 31,400Hz)で切り替える。出力は1サンプル区間のTIAクロックを平均してエイリアスを抑え、実機の出力段(AC結合)に相当するDCカットを通している。

AUDC=4・AUDF=9のとき、理論値 31400 / 10 / 2 ≒ 1570Hz に対して実測1569Hzを確認した。

レジスタは1フレームに1回まとめてWorkletへ送っているので、1フレーム中にAUDVを連打するPCM音声はまだ再生できない。

9. カートリッジマッパー

6507から見えるROM領域は4KBしかない。それ以上の容量のゲームは、カートリッジ側の回路で「特定のアドレスに触れるとバンクが切り替わる」仕組み(ホットスポット)を持っている。方式はメーカーごとにバラバラだ。

方式容量切り替え方代表例
F8 / F6 / F48K / 16K / 32K$1FF8〜等へのアクセスで4Kバンク切替Atari純正の大半
SuperChip (SC)+128B RAM$1000 書込 / $1080 読出F8SC / F6SC / F4SC
FA12K + 256B RAM$1FF8-$1FFACBS RAM+
FE8K$01FE アクセス直後のデータバスbit5Activision (Decathlon 等)
3F可変TIA $00-$3F への書き込み値Tigervision
E08K1Kスライス×4、$1FE0-$1FF7Parker Bros
E716K + 2K RAM$1FE0-$1FEBM-Network

マッパーはクラスに分け、バスからは read / write / peek の3メソッドだけで扱う。FEだけは全バスアクセスを監視する必要があるので snoop、3FはTIA書き込みを監視するので tiaWrite を追加で持つ。

// FE (Activision): $01FE アクセス直後のデータバス bit5 でバンク決定
class CartFE {
  snoop(addr, v) {
    if (this.lastFE) this.bank = (v & 0x20) ? 0 : 1;
    this.lastFE = addr === 0x01fe;
  }
}

JSR/RTSのスタック操作でバンクを切り替えるという、Activisionらしい変態回路だ。

自動判別

容量だけでは方式が決まらない(16KはF6かもしれないしE7かもしれない)。Stellaのシグネチャ判定を簡略化して移植した。

const SIG_E7 = [[0xad, 0xe2, 0xff], [0xad, 0xe5, 0xff], [0xad, 0xe5, 0x1f], [0xad, 0xe7, 0x1f],
                [0x0c, 0xe7, 0x1f], [0x8d, 0xe7, 0xff], [0x8d, 0xe7, 0x1f]];
// LDA $FFE2 / NOP $1FE7 / STA $1FE7 など、E7のホットスポットを叩く命令列

if (len === 16384) {
  if (anySig(rom, SIG_E7)) return new CartE7(rom);
  if (is3F(rom)) return new Cart3F(rom);
  return makeF(rom, 'F6');
}

SuperChipは「各4Kバンクの先頭256バイトがすべて同じ値」で判定する(RAM領域にはROMのデータを置けないので、ダミーで埋まっている)。拡張子が .e7 や .f8 などのときは、そちらを優先する。

E7とMYST

最初の実装にもE7はあったが、3箇所間違っていた。

項目正しい動作最初の実装
256B RAM書き込み$1800-$18FF$1400-$15FF に書いていた
固定バンク$1A00-$1FFF = 最終バンクの後半1.5K(オフセット $200-$7FF)先頭から読んでいて $200 ずれていた
1K RAMモード$1FE7 で下位2Kが1K RAMに($1000 書込 / $1400 読出)未実装
peek(o) {
  if (o < 0x800) {
    if (this.bank === 7) return this.ram1k[o & 0x3ff];     // 1K RAM モード
    return this.rom[this.bank * 2048 + o];
  }
  if (o < 0xa00) return this.ram256[this.ramBank * 256 + (o & 0xff)];
  return this.rom[this.last * 2048 + (o & 0x7ff)];         // 固定バンク
}

MYSTデメイクの作者ページにも「JavatariのE7実装にバグがあって、数歩進むと落ちる(修正はコントリビュート済み)」と書かれている。E7はRAMの書き込みポートと読み出しポートが別アドレスで、固定バンクも半端な位置にある。どこか1つ間違えるだけでMYSTは死ぬ。

作者のGitHubリポジトリ(deater/vcs)からソースをcc65とzx02でビルドし、ヘッドレス実行で「タイトル → リンクブック → 島 → 次のシーン」まで進むことを確認してから渡した。

10. 非公式命令

2600のゲームは、NMOS 6502の非公式命令(Illegal Opcodes)をそこそこ使う。128バイトのRAMと76サイクルのラインで勝負する世界なので、2命令を1命令にできるなら使う。

命令動作用途
LAXA = X = Mロード1回で2レジスタ
SAXM = A & XSAX WSYNC などフラグを汚さない書き込み
DCPM—, CMP Mカウンタ減算+比較
ISBM++, SBC M
SLO / RLA / SRE / RRAシフト+論理演算
ANC / ALR / ARR / SBX即値系
NOP zp / abs / abs,X読み出しだけ行うNOP $1FE7 でバンク切替

NOP系は「何もしない」のではなく、実際にメモリを読むように実装した。NOP $1FE7(0C E7 1F)でE7のバンクを切り替えるROMがあり、実際にStellaの判定シグネチャにも入っている。

RMW系の6命令は、オペコードの下位5ビットでアドレッシングモードが決まる規則性を利用してまとめた。

// xx03 (ind,X) / xx07 zp / xx0F abs / xx13 (ind),Y / xx17 zp,X / xx1B abs,Y / xx1F abs,X
const a = this.rmwAddr(op);
const m = this.readByte(a);
switch (op & 0xe0) {
  case 0x00: r = this.asl(m); this.writeByte(a, r); this.a |= r; this.setZN(this.a); break; // SLO
  case 0xc0: r = (m - 1) & 0xff; this.writeByte(a, r); this.cmp(this.a, r); break;        // DCP
  // ...
}

Apple II側を壊さない

CPUコアはApple IIと共用している。コアには 0xDB をテスト終了用の STP_HALT として扱う処理がある。しかしNMOSでは 0xDB は DCP abs,Y だ。

そこで new M6502({ ..., illegal: true }) のときだけ非公式命令を有効にし、既定では従来どおりの挙動にした。

11. まとめ

Apple IIが「メモリに書けば絵が出る」マシンだったのに対し、Atari 2600は「ビームの位置に合わせて書かないと絵が出ない」マシンだった。同じ6502でも、エミュレーターの難所はまったく違う。

得られた知見:

  • TIAのパレットは 値 >> 1 で引く。そのまま引くと上半分の色が全部消える
  • フレームはサイクル数ではなくVSYNCで区切る。ライン数はゲームが決める
  • RIOTタイマーは0で止まらず $FF に回り込む。ここを間違えると BIT TIMINT 待ちで永久ループする
  • TIA/RIOTへのアクセスは命令の最終サイクルで起きる。読み出しも書き込みも、その時点までビームを進めてから処理する
  • 行の最終サイクルで書かれたWSYNCは待たない。これを間違えると48pxカーネルが崩れ、ライン数が増えて画面が揺れる
  • E7はRAMの書き込み/読み出しポートと固定バンクの位置が罠。MYSTはここ1箇所で動かなくなる
  • 非公式命令のNOPは実際にメモリを読む。バンク切替に使われている

未実装

完璧を目指す企画ではないので、ここで一区切りとした。残りは以下。

  • ラインの途中でのHMOVE(現在は即時適用の近似。Cosmic Arkのスターフィールドなどで崩れる)
  • パドル(INPT0-3)
  • PCM音声(1フレーム中のAUDV連打)
  • DPC(Pitfall II)などの特殊チップ

1977年に発売された、VRAMもないマシンでMYSTが歩き回れる。それを自作のエミュレーターで確認できたのが今日一番の収穫だった。