[JavaScript] Atari 2600エミュレーターを自作。真っ黒画面からTIA・RIOT・バンク切替を積み上げてMYST Demakeが動くまで
はじめに
前回はMOS 6502コアの上にApple IIを積み上げた。同じ6502(正確には廉価版の6507)を心臓に持つもう一つの名機が Atari 2600 (VCS) だ。
Atari 2600 - Wikipedia
The Atari 2600 is a home video game console developed and produced by Atari, Inc. Released c. September 1977[a] as the Atari Video Computer System (Atari VCS), it popularized microprocessor-based hardware and games stored on swappable ROM cartridges, a format first used with the Fairchild Channel F in 1976.
en.wikipedia.orgただし、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のデメイク版が最後まで動くようになるまでを記録する。
前提記事
[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.com6502 EMULATOR PROJECT
6502 EMULATOR PROJECT
フルスクラッチ JavaScript MOS 6502 コアと、その上に構築されたレトロシステムエミュレーター群
lain-lab.comスクリーンショット
Atlantis (Europe)
掲載しているスクショ画面は、手持ちのROMイメージ(Atlantis (Europe).a26)を使用して動作検証を行ったものです。 各著作権および商標権は、Imagic社ならびに各権利所有者に帰属します。
動画
Atari VCS Myst “Demake”
Vince Weaver (deater) 氏が公開している16KB E7カートリッジ版MYSTのデメイク(v2.6)を使用しています。 MYSTの商標および著作物はCyan社に帰属します。本作はCyan社の公式製品ではありません。
Atari VCS Myst "Demake"
How much of MYST can you run on a 16k Atari 2600 cartridge?
deater.net
動画
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)だけ。これ以上のアドレスはすべてミラーになる。
| アドレス | A12 | A9 | A7 | 用途 |
|---|---|---|---|---|
$0000-$007F | 0 | - | 0 | TIA(映像・音声・入力) |
$0080-$00FF | 0 | 0 | 1 | RIOT RAM 128バイト(ゼロページ兼スタック) |
$0280-$0297 | 0 | 1 | 1 | RIOT I/O・タイマー |
$1000-$1FFF | 1 | - | - | カートリッジ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 |
| PF1 | 8ビット | bit7 → bit0 |
| PF2 | 8ビット | 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/1 | 8ピクセル | GRP0/1、NUSIZ0/1、REFP0/1、VDELP0/1 |
| ミサイル0/1 | 1/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 / Z | FIRE | INPT4 bit7(0で押下) |
| Enter | GAME RESET | SWCHB bit0 |
| Shift | GAME SELECT | SWCHB 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=0 | Div31でゲート |
| bit1=1, bit0=1 | 5bitポリでゲート |
| bit2=1 | 純音(トグル) |
| bit3=1 | 5bitポリ(AUDC=8のみ9bitノイズ) |
| bit2,3=0 | 4bitポリ |
| 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 / F4 | 8K / 16K / 32K | $1FF8〜等へのアクセスで4Kバンク切替 | Atari純正の大半 |
| SuperChip (SC) | +128B RAM | $1000 書込 / $1080 読出 | F8SC / F6SC / F4SC |
| FA | 12K + 256B RAM | $1FF8-$1FFA | CBS RAM+ |
| FE | 8K | $01FE アクセス直後のデータバスbit5 | Activision (Decathlon 等) |
| 3F | 可変 | TIA $00-$3F への書き込み値 | Tigervision |
| E0 | 8K | 1Kスライス×4、$1FE0-$1FF7 | Parker Bros |
| E7 | 16K + 2K RAM | $1FE0-$1FEB | M-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命令にできるなら使う。
| 命令 | 動作 | 用途 |
|---|---|---|
| LAX | A = X = M | ロード1回で2レジスタ |
| SAX | M = A & X | SAX WSYNC などフラグを汚さない書き込み |
| DCP | M—, CMP M | カウンタ減算+比較 |
| ISB | M++, 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が歩き回れる。それを自作のエミュレーターで確認できたのが今日一番の収穫だった。