[JavaScript] MSXエミュレーターの「音のもたつき」を追う。割り込みのレベルトリガ化とZ80サイクル精度の修正
はじめに
前回の記事で、MegaROMマッパーとPSG/SCC音源を実装し、MSXエミュレーターはひとまず完成した。
[JavaScript] MSXエミュレーターを完成させる。MegaROMマッパー・PSG/SCC音源・AudioWorkletでのサウンド合成
MegaROMマッパー(Konami / SCC / ASCII)の実装、PSGおよびSCC音源のAudioWorkletによるリアルタイム合成、スプライト打ち切り挙動の修正までを解説。
lain-lab.comところが、『グラディウス2』を動かしてみると、オープニングデモの画像が切り替わる重いシーンだけ、BGMがほんの一瞬もたつく。普段は気付かない程度だが、一度気になると耳から離れない。
Nemesis 2 (MSX) - Wikipedia
Nemesis 2, released as Gradius 2 (グラディウス2, Guradiusu Tsū) in Japan, is a side-scrolling shoot 'em up video game released for the MSX computer in 1987 by Konami.
en.wikipedia.orgこの記事は、その「見えない敵」を追いかけた記録である。結論から言うと、原因は1つではなかった。Z80コアの割り込み処理とサイクルカウントに複数のバグが潜んでおり、それらを1つずつ潰していくことになった。
1. 最初の仮説:割り込みの取りこぼし
1-1. 旧実装の構造
MSXのゲームは、VDP(TMS9918)が1フレームに1回発生させるVBlank割り込みの中で、サウンドドライバを駆動している。つまり、割り込みが1回消えると、BGMのティックが1回飛ぶ。
旧実装のメインループは、1フレーム分(59,659 T-state)のZ80を実行し終えた後に、割り込みを1回だけ要求していた。
// 旧:フレーム末尾で1回だけ割り込みを要求
vdp.statusRegister |= 0x80;
if ((vdp.registers[1] & 0x20) !== 0) {
cpu.interrupt(0xFF);
}
// 旧:受理できなければ 0 を返して終わり(要求は消える)
interrupt(dataBus = 0xff) {
if (!this.iff1 || this.eiPending > 0) return 0;
// ... 受理処理
}
問題は、その瞬間にCPUが DI(割り込み禁止)状態だった場合である。受理できない割り込みは、そのまま捨てられていた。
重いシーンのゲームは、VRAMへの大量転送を DI 状態で行うことが多い。フレーム境界でたまたま DI 中だと、VBlankが丸ごと消えてしまう。
1-2. ラッチ化による修正
実機のZ80のINT端子は、要求が受理されるまで信号を出し続ける。そこで、割り込み要求をラッチしておき、毎命令の実行前にチェックする方式に変えた。
interrupt(dataBus = 0xff) {
this.intPending = true;
this.intDataBus = dataBus;
return this.checkInterrupt();
}
checkInterrupt() {
if (!this.intPending || !this.iff1 || this.eiPending > 0) return 0;
this.intPending = false; // 受理
// ... IM 0/1/2 の分岐
}
これで、もたつきはかなり改善した。ただし、完全には消えなかった。
2. ラッチ化で入り込んだEI遅延バグ
step() にチェック処理を追加したとき、順番を間違えていた。
// バグあり
step() {
if (this.eiPending > 0) this.eiPending--; // ① 先にデクリメント
const intCycles = this.checkInterrupt(); // ② その後にチェック
// ...
}
Z80の EI は、直後の1命令を実行し終えるまで割り込みを受け付けない。これは EI; RET や EI; HALT といった定型コードを安全に動かすための仕様である。
ところが上のコードでは、EI で eiPending = 1 になっても、次の step() の冒頭で即座に 0 に戻る。その結果、直後の命令を実行する前に割り込みが入ってしまう。MSX BIOSの割り込みハンドラは EI; RET で終わるので、ここで割り込みがネストしうる。
旧実装ではメインループ側でチェックしていたため、偶然この遅延が守られていた。修正は、チェックとデクリメントの順番を入れ替えるだけである。
step() {
const intCycles = this.checkInterrupt(); // eiPending > 0 ならここで弾かれる
if (intCycles > 0) return intCycles;
if (this.eiPending > 0) this.eiPending--; // EI 直後の1命令はこの後に実行される
if (this.halted) {
this.r = (this.r & 0x80) | ((this.r + 1) & 0x7f);
this.tCycles += 4;
return 4;
}
const startCycles = this.tCycles;
this.executeOpcode(this.fetchByte());
return this.tCycles - startCycles;
}
ただし、これは正しさのための修正であって、もたつきは変わらなかった。
3. 計測で切り分ける:ホスト側はシロ
次に疑ったのは、ブラウザ側のタイミングである。
このエミュレーターは、requestAnimationFrame のたびに必要なフレーム数だけZ80を一気に実行し、PSG/SCCへの書き込みを postMessage でAudioWorkletに送っている。rAFが遅れて2フレーム分を一度に実行すると、2フレーム分の書き込みが同時に届き、発音タイミングが潰れるはずだ。
推測で直す前に、数字を取った。
let burstCount = 0, maxStepMs = 0; // mainLoop の外に置く
// mainLoop 内
if (framesToRun > 1) burstCount++;
const t0 = performance.now();
// ... フレーム実行
const dt = performance.now() - t0;
if (dt > maxStepMs) maxStepMs = dt;
結果は burst: 11(2,100フレーム中)、max: 6.9ms。もたつく瞬間に burst は増えず、処理時間も16.6msに対して十分な余裕があった。
ホスト側(rAF・postMessage・描画)は完全にシロ。原因は、エミュレーション内部にあることが確定した。
4. 実機寄りのエミュレーターと比較する
エミュ内部の問題なのか、それともゲーム本来の挙動なのか。これを確かめるため、WebMSXで同じシーンを再生した。
ここで1つ気付いたことがある。WebMSXのデフォルト機種ではオープニングが英語で表示される。グラディウス2は、BIOSの 0x002B にある地域設定バイトを見て、日本語と英語を切り替えているらしい。欧州版『Nemesis 2』と同じROMで両対応しているわけだ。
デフォルト機種は海外仕様なので、VBlankの周波数やVDPが異なる可能性がある。比較のために、機種を日本版MSX1に揃えた。
https://webmsx.org/?MACHINE=MSX1J
結果、WebMSXでは全くもたつかない。つまり、原因は確実にこちらのエミュレーターの中にある。
5. サイクルカウントのバグ
エミュ内でVBlankが遅れるなら、1フレームあたりに実行できる命令数が実機より少ない可能性がある。つまり、どこかでサイクルを多く数えすぎている。Z80コアを見直したところ、3つのバグが見つかった。
5-1. DD/FD CB 命令のサイクル二重加算
SET n,(IX+d) や BIT n,(IX+d) といった DD/FD CB 命令は、executeCB() にアドレスを渡して処理している。executeCB() の中ですでに 23T(BITは20T)を加算しているのに、呼び出し元でも 23T を足していた。
if (opcode === 0xcb) {
const d = fetchDisplacement();
const addr = (getIXY() + d) & 0xffff;
this.wz = addr;
this.executeCB(addr);
// this.tCycles += 23; ← 二重加算。削除
return;
}
これで、1命令が 46T として数えられていた。Konamiのゲームは、オブジェクト管理で (IX+d) のビット操作を多用する。敵や演出が多いシーンほど、エミュ内の時間が実機より速く進んでしまう。
5-2. LD A,(nn) / LD (nn),A の過大カウント
x=0, z=2 の命令群で、p=3(LD (nn),A / LD A,(nn))まで p=2(LD (nn),HL)と同じ16Tで数えていた。正しくは13Tである。
// 修正前
this.tCycles += (p >= 2) ? 16 : 7;
// 修正後
this.tCycles += (p === 2) ? 16 : (p === 3) ? 13 : 7;
この2命令は、どんなゲームでも大量に使われる。1回あたり3Tの差でも、積み重なれば大きい。
5-3. EX (SP),IX の未実装
DD E3(EX (SP),IX)がデコーダーに無く、executeOpcode() にフォールスルーしていた。その結果、IXではなくHLを交換していた。サイクルの問題ではなく、動作が壊れるバグである。
} else if (opcode === 0xe3) {
const lo = this.readByte(this.sp);
const hi = this.readByte((this.sp + 1) & 0xffff);
const v = getIXY();
this.writeByte(this.sp, v & 0xff);
this.writeByte((this.sp + 1) & 0xffff, (v >> 8) & 0xff);
setIXY((hi << 8) | lo);
this.wz = (hi << 8) | lo;
this.tCycles += 23;
}
5-4. 補足:M1ウェイト
MSXの実機は、Z80のM1サイクル(オペコードフェッチ)ごとに1Tのウェイトが入る。本エミュレーターはこれを再現していないため、実機より約10%速く動く。
これは「処理が間に合わない」方向とは逆のズレである。だからこそ、実機より遅くなる原因は、上記の過大カウントのような数え間違いしか考えられなかった。
6. 割り込みの遅延を計測する
サイクルを修正した段階で、割り込みがどれだけ遅れているかを直接計測した。
interrupt(dataBus = 0xff) {
if (this.intPending) this.intMerged = (this.intMerged || 0) + 1; // 未受理のまま次が来た
this.intPending = true;
this.intDataBus = dataBus;
this.intRaisedAt = this.tCycles;
return this.checkInterrupt();
}
// checkInterrupt 内、受理直後
const delay = this.tCycles - this.intRaisedAt;
if (delay > (this.maxIntDelay || 0)) this.maxIntDelay = delay;
結果は merged: 1、maxDelay: 27842T。
割り込みが潰れたのは1回だけで、最大遅延も約0.47フレーム(約7.8ms)。耳で分かるレベルではない。
それでもまだ音が止まる。ということは、この計測に映らない経路で割り込みが消えていることになる。
7. 本命:TMS9918のINTはレベルトリガ
7-1. IEビットが落ちている間のVBlank
メインループをもう一度見直すと、問題はここにあった。
if ((vdp.registers[1] & 0x20) !== 0) {
cpu.interrupt(0xFF);
}
フレーム末尾でVDPのIEビット(R#1 bit 5)が0だと、割り込みを要求すらしない。だから merged にもカウントされない。
Konamiのゲームは、大きな画像をVRAMへ転送する間、R#1を書き換えて表示や割り込みを止めることがある。
実機のTMS9918では、IEが0の間もVBlankでステータスレジスタのFフラグ(bit 7)が立ち続ける。そして、IEを1に戻した瞬間にINT信号が出る。1ティック遅れるだけで、消えはしない。
こちらの実装では、その割り込みが完全に消えていた。画像が切り替わるタイミングで音が止まるという症状と、ぴったり一致する。
7-2. レベルトリガとして再現する
TMS9918のINT出力は、次の式で表せる。
Fフラグは、ステータスレジスタを読むとクリアされる。つまり、INTはどちらかの条件が崩れるまで出続ける「レベル」信号である。
そこで、CPUが毎命令ごとに「INT線が今アクティブか」をホストへ問い合わせる方式に変えた。Z80コアは10システムで共有しているため、他のシステムに影響が出ないようオプション扱いにしている。
// z80.js constructor
this.intLine = bus.intLine || null;
// z80.js
checkInterrupt() {
if (!this.iff1 || this.eiPending > 0) return 0;
const line = this.intLine ? this.intLine() : this.intPending;
if (!line) return 0;
this.intPending = false;
// ... IM 0/1/2 の分岐
}
// MSX 側
const cpu = new Z80({
readByte: readMemory,
writeByte: writeMemory,
intLine: () => (vdp.statusRegister & 0x80) !== 0 && (vdp.registers[1] & 0x20) !== 0,
// ...
});
// メインループではFフラグを立てるだけ
vdp.statusRegister |= 0x80;
チップ側の状態が唯一の真実になり、ラッチの持ち越しや消失という問題そのものが無くなる。
注意点として、vdp.readStatus() がbit 7をクリアしていないと、INTが出っぱなしになってゲームが固まる。レベルトリガ化するなら、ここは必ず確認しておく必要がある。
8. SCCへの切り分け
ここまで直しても、ごくわずかなもたつきが残った。今度は音源側を疑う。
8-1. マッパーを変えて比較する
マッパーを「Konami SCC」ではなく「Konami」(SCC非対応)で起動してみた。
このとき、SCC有効化の書き込み(0x9000への 0x3F)はただのバンク切り替えとして扱われる。SCCは鳴らず、PSGだけで演奏される。CPUの負荷やタイミングはほぼ同じだ。
結果、PSGだけなら全く止まらない。原因はSCC周りにあることがかなり強く示された。
さらに、映像処理がより凝っている『ネメシス3』(256KB)では、SCCでも全く問題が起きなかった。つまり、映像の重さではなく、グラディウス2(128KB、最初期のSCCタイトル)特有のSCCの使い方が引き金になっている。
8-2. SCCレジスタのミラー領域
実機のSCCはアドレスを全部デコードしていないため、同じレジスタが複数のアドレスから見える。周波数・音量・チャンネルON/OFFのレジスタ(0x9880〜0x988F)は、0x9890〜0x989Fにもミラーされている。
旧実装は addr & 0xFF をそのまま格納していたため、ミラー側への書き込みはどこにも使われない場所に入り、実質的に消えていた。
function writeSCC(addr, val) {
let r = addr & 0xFF;
if (r >= 0xA0) return; // 0xA0-0xDF 未使用 / 0xE0-0xFF 変形レジスタ(未対応)
if (r >= 0x80) r = 0x80 | (r & 0x0F); // 0x90-0x9F → 0x80-0x8F に折り返す
sccRam[r] = val;
if (audioWorkletNode) {
audioWorkletNode.port.postMessage({ type: 'scc', addr: r, val: val });
}
}
8-3. 残された仮説
もう1つの候補は、バンク切り替えのタイミングである。
グラディウス2は容量が小さいため、画像データを0x8000〜0x9FFFのバンク(SCCと同じ0x9000のレジスタで切り替える領域)に置き、切り替えながら読んでいる可能性がある。その間にVBlankのサウンドドライバがSCCへ書き込むと、SCCが無効なので書き込みが捨てられる。
実機でも同じ挙動のはずだが、バンク切り替えとドライバの書き込みの順番がわずかにずれていれば差が出る。これを追うには、実機並みのタイミング精度が必要になる。グラディウス2のオープニングの一部のためだけに掘る価値は薄いと判断し、今回はここで打ち切った。
修正一覧
| 箇所 | 内容 | 影響範囲 |
|---|---|---|
| Z80 割り込み | 捨てていた割り込み要求をラッチ化 → 最終的にレベルトリガ化(intLine) | 全システム(オプション) |
Z80 step() | EI直後の1命令遅延が機能していなかった問題を修正 | 全システム |
| Z80 DD/FD CB | サイクルの二重加算を修正(46T → 23T、BITは20T) | 全システム |
Z80 LD A,(nn) / LD (nn),A | 16T → 13T | 全システム |
Z80 EX (SP),IX/IY | 未実装(HLを交換していた)→ 実装 | 全システム |
| MSX VDP INT | IE無効中のVBlankが消失していた問題を修正 | MSX |
| MSX SCC | レジスタのミラー領域(0x9890〜0x989F)に対応 | MSX |
Z80コアは core/ フォルダで10システムが共有している。どれも「今まで偶然動いていた」ものを崩す可能性がある修正だったため、各システムで代表的なROMを起動して回帰がないことを確認した。
まとめ
振り返ると、最初の「割り込みのラッチ化」は正しい方向だったが、それだけでは足りなかった。実際には次の問題が重なっていた。
- 割り込み要求が捨てられる
- サイクルの過大カウントで、エミュ内の時間が実機より速く進む
- IEビットが落ちている間のVBlankが消える
- SCCのミラー領域への書き込みが消える
どれか1つでは症状を説明しきれず、1つ直すたびに「少し良くなった気がする」を繰り返すことになった。
この手の問題は、推測で直し続けると終わりが見えなくなる。効いたのは、次の3つだった。
- 計測で切り分ける:ホスト側(
burst/maxStepMs)か、エミュ内部(merged/maxDelay)か。 - 実機寄りのエミュレーターと比較する:ただし、機種(地域・周波数)を揃える。
- 条件を1つだけ変えて比較する:マッパー(SCCの有無)や、別のタイトル(ネメシス3)で。
エミュレーター開発は、見えない敵との戦いである。だからこそ、まず数字で輪郭を付けることが大事だと実感した。