[Astro] #131 SID Player v3.2 — RSID IRQ駆動再生対応・CIA1/VIC-II エミュレーション追加

[Astro] #131 SID Player v3.2 — RSID IRQ駆動再生対応・CIA1/VIC-II エミュレーション追加

概要

SID Player に RSID フォーマット(playAddr=0)の IRQ 駆動再生を追加しました。

これまでの SID Player は PSID(Play SID)形式、つまり playAddr が指定されている .sid ファイルのみ対応していました。PSID では 50Hz(PAL)/ 60Hz(NTSC)の固定間隔で play ルーチンを JSR 呼び出しするだけで音楽が再生されます。

しかし HVSC(High Voltage SID Collection)には RSID(Real SID) 形式のファイルも多数収録されており、特にゲーム音楽(Konami 等)にこの形式が使われています。RSID で playAddr=0 のファイルは、init ルーチンが自前で IRQ ハンドラを設定し、CPU 割り込みによって音楽を駆動する仕組みです。

スクリーンショット

Nemesis(1987 Konami)— RSID v2, playAddr=0, PAL/6581, IRQ 駆動再生成功

SID Player — Nemesis RSID再生

デバッグログ — IRQ 発火カウント・$0314/$0315 ベクタ・CPU ステート確認

SID Player — デバッグログ出力

問題の発見

Nemesis.sid(1987 Konami)を再生しようとしたところ、波形が一切表示されず無音だった。

HVSC(High Voltage SID Collection)でファイルの詳細を確認すると:

フィールド意味
File FormatRSIDReal SID(厳密なC64互換が必要)
Format Version2v2ヘッダー(flags フィールドあり)
Load Address$0000ヘッダーに格納されず、データ先頭2バイトから取得
Init Address$5500init ルーチンのアドレス
Play Address$0000play ルーチンなし — IRQ 駆動
ClockPAL50Hz
SID Model6581

PSID vs RSID の違い

PSID(Play SID)— 従来の対応

Host: 50/60Hz間隔で → JSR play_addr → 6502がSIDレジスタに書き込み → 音が出る

play_addr が指定されているので、ホスト側が固定間隔で呼ぶだけ。CPU エミュレーションは play ルーチン実行時のみ。

RSID(Real SID)— playAddr=0

init → IRQハンドラを\$0314/\$0315に設定 → CPU常時実行 → IRQ割り込みで音楽駆動

play_addr=0 は「play ルーチンを直接呼ぶな、IRQ で自走する」という意味。実機の C64 と同様に:

  1. init ルーチンが IRQ ベクタ($0314/$0315)に自前のプレイヤーアドレスを設定
  2. CPU がアイドルループで待機
  3. 50/60Hz で IRQ が発火
  4. IRQ ディスパッチャ($FF48)→ JMP ($0314) → プレイヤー実行
  5. RTI でアイドルループに復帰

修正内容

1. C64 カーネル環境構築

RSID の init ルーチンが期待する C64 カーネルの最低限の環境を RAM 上に構築:

static void setup_c64_environment() {
    // IRQディスパッチャ at $FF48: JMP ($0314)
    g_ram[0xFF48] = 0x6C; // JMP indirect
    g_ram[0xFF49] = 0x14;
    g_ram[0xFF4A] = 0x03;

    // ハードウェアIRQベクタ → $FF48
    g_ram[0xFFFE] = 0x48;
    g_ram[0xFFFF] = 0xFF;

    // デフォルトIRQハンドラ(RTI)— initが$0314/$0315を上書きする
    g_ram[0xEA31] = 0x40; // RTI
    g_ram[0x0314] = 0x31;
    g_ram[0x0315] = 0xEA;

    // NMIベクタ → RTI
    g_ram[0xFE47] = 0x40;
    g_ram[0xFFFA] = 0x47;
    g_ram[0xFFFB] = 0xFE;
}

実機 C64 のメモリマップ:

アドレス役割
$0314/$0315ソフトウェア IRQ ベクタ(RAM、init が上書き)
$FF48IRQ ディスパッチャ(JMP ($0314))
$FFFE/$FFFFハードウェア IRQ ベクタ(→ $FF48)
$FFFA/$FFFBNMI ベクタ
$EA31デフォルト IRQ ハンドラ(RTI)

2. CPU アイドルループ

init 完了後、playAddr=0 の場合は CPU がアイドルループで待機するよう設定:

static void setup_idle_loop() {
    // CLI + JMP self at $EA7E(KERNALエリア、SIDデータと衝突しない)
    g_ram[0xEA7E] = 0x58; // CLI — 割り込み許可
    g_ram[0xEA7F] = 0x4C; // JMP $EA7E
    g_ram[0xEA80] = 0x7E;
    g_ram[0xEA81] = 0xEA;
    pc = 0xEA7E;
    status &= ~0x04; // Iフラグクリア(IRQ許可)
}

3. 手動 IRQ トリガー

fake6502 の irq6502() に頼らず、手動でスタック操作と PC 変更を行う IRQ トリガーを実装:

static void trigger_irq_manual() {
    if (status & 0x04) return; // I flag set → skip

    // CIA1 + VIC-II 割り込みステータスをセット
    g_cia1_icr_data = 0x81; // Timer A + IRQ
    g_ram[0xD019] = 0x81;   // Raster + IRQ

    // Push PC (hi, lo) + Status onto stack
    g_ram[0x0100 + sp] = (pc >> 8) & 0xFF; sp--;
    g_ram[0x0100 + sp] = pc & 0xFF;        sp--;
    g_ram[0x0100 + sp] = (status & ~0x10) | 0x20; sp--;

    status |= 0x04; // Set I flag
    pc = g_ram[0xFFFE] | (g_ram[0xFFFF] << 8); // Jump to IRQ vector
}

4. CIA1 / VIC-II 最低限エミュレーション

多くの RSID チューンの IRQ ハンドラは、割り込み原因を確認するために $DC0D(CIA1)や $D019(VIC-II)を読む。これらが 0 を返すとハンドラが音楽処理をスキップする問題があった。

// CIA1 ($DC0D) — 読むとクリア(実機挙動)
uint8_t read6502(uint16_t address) {
    if (address == 0xDC0D) {
        uint8_t val = g_cia1_icr_data;
        g_cia1_icr_data = 0;
        return val;
    }
    return g_ram[address];
}

// VIC-II ($D019) — 書き込みでアクノリッジ(ビットクリア)
void write6502(uint16_t address, uint8_t value) {
    if (address == 0xD019) {
        g_ram[0xD019] &= ~(value & 0x0F);
        if ((g_ram[0xD019] & 0x0F) == 0) g_ram[0xD019] &= 0x7F;
        return;
    }
    // ...
}
レジスタ役割ハンドラの典型的な使用
$DC0DCIA1 割り込みステータスLDA $DC0D; AND #$01; BEQ skip
$D019VIC-II ラスタ割り込みステータスLDA $D019; STA $D019(アクノリッジ)

5. レンダーループの分岐

playAddr の値に応じて処理を分岐:

if (g_play_addr != 0) {
    // PSID: 従来通り JSR play_addr
    jsr6502(g_play_addr);
} else {
    // RSID IRQ駆動: CPU継続実行 + IRQ定期発火
    exec6502(cycles);
    if (/* 50/60Hz タイミング */) {
        trigger_irq_manual();
    }
}

6. jsr6502() の修正

従来の jsr6502() は RTS トラップを $FFFF に配置していたため、IRQ ベクタ $FFFE/$FFFF を破壊していた。トラップ先を $FFFC(RESET ベクタ、実行時には使用しない)に変更し、save/restore するようにした。

7. parseSidHeader の修正(JS 側)

SID ヘッダーの dataOffset を正しく読み取り、loadAddr=0 の場合にデータ先頭 2 バイト(リトルエンディアン)から実アドレスを取得するよう修正。

var dataOffset = (data[6] << 8) | data[7];
var loadAddr = (data[8] << 8) | data[9];

if (loadAddr === 0 && data.length > dataOffset + 1) {
    loadAddr = data[dataOffset] | (data[dataOffset + 1] << 8);
}

デバッグ記録

問題1: WASM モジュール読み込み失敗

症状: ドロップしても反応なし 原因: await init() がモジュール読み込み失敗でスクリプト全体がブロック 解決: import パスを修正(Vite ベアモジュール指定子)

問題2: IRQ が発火してもハンドラが実行されない

症状: IRQ カウントは増加するが SID レジスタが全 00 原因: setup_c64_environment() を init の後に再度呼んでいたため、init が設定した $0314/$0315 を RTI($EA31)で上書きしていた 解決: init 後の setup_c64_environment() 呼び出しを削除

問題3: IRQ ハンドラが音楽処理をスキップ

症状: IRQ 発火・ハンドラ実行中だが SID に書き込みなし(Metal_Gear.sid) 原因: IRQ ハンドラが $DC0D(CIA1)や $D019(VIC-II)を読んで割り込み原因を確認 → 0 が返るため音楽処理をスキップ 解決: CIA1 / VIC-II の最低限エミュレーション追加。IRQ 発火時に $DC0D=0x81 / $D019=0x81 をセット

問題4: Metal_Gear.sid — ラスタチェーン

症状: Nemesis.sid は再生できるが Metal_Gear.sid は一瞬鳴って止まる 原因: Konami のプレイヤーが複数のラスタ割り込みをチェーンさせる方式(各 IRQ 内で次の $D012 を書き換え、フレーム内で複数回 IRQ を発生させる)を使用している可能性。単純な 50/60Hz 固定発火では再現できない 状態: 未解決 — 本格的な VIC-II ラスタラインエミュレーションが必要。DeepSID(WebSid エミュレータ)では正常再生を確認済み

デバッグ用エクスポート関数

今回のデバッグで追加した WASM エクスポート関数:

関数戻り値
sid_debug_irq_state()[31:16]=IRQ_hard ( $FFFE ), [15:0]=IRQ_soft ( $0314 )
sid_debug_cpu_state()[31:16]=PC, [15:8]=SP, [7:0]=Status
sid_debug_irq_count()IRQ 発火回数の累計

対応状況

ファイル形式playAddr割り込み方式結果
Commando.sidPSID$1003✅ 再生可能(従来通り)
Gradius.sidPSID$C858✅ 再生可能(従来通り)
Nemesis.sidRSID$0000CIA1 ($DC0D)✅ 今回対応
Metal_Gear.sidRSID$0000ラスタチェーン?❌ 未対応(一瞬鳴って停止)

既知の制限事項

  • ラスタチェーン方式の RSID — $D012 を動的に書き換えて1フレーム内に複数 IRQ を発生させる方式は未対応。VIC-II のラスタラインカウンタエミュレーションが必要
  • CIA タイマー — CIA1 Timer A/B ($DC04-$DC07) のカウントダウンは未実装。固定 50/60Hz のタイミングで代替
  • NMI 駆動の RSID — CIA2 ($DD0D) による NMI 方式は未テスト

技術スタック

  • reSID — SID チップエミュレーション(C++, MOS 6581/8580)
  • fake6502 — 6502 CPU エミュレーション(C, パブリックドメイン)
  • Emscripten — C/C++ → WASM ビルド(STANDALONE_WASM, 84KB)
  • AudioWorklet — リアルタイム音声処理スレッド
  • Astro — ページ構築

更新履歴

  • [v3.2] (2026-09-07): RSID IRQ 駆動再生対応
    • C64 カーネル環境構築(IRQ ディスパッチャ $FF48、ベクタ $FFFE/$FFFF、$0314/$0315)
    • CPU アイドルループ(CLI + JMP self at $EA7E
    • 手動 IRQ トリガー(fake6502 の irq6502() に依存しない実装)
    • CIA1 割り込み制御レジスタ ( $DC0D ) エミュレーション(read-clear 挙動)
    • VIC-II ラスタ割り込みステータス ( $D019 ) エミュレーション(write-acknowledge 挙動)
    • レンダーループ分岐(playAddr=0 時は exec6502 + trigger_irq_manual)
    • jsr6502() のトラップ先を $FFFF → $FFFC に変更(IRQ ベクタ破壊防止)
    • parseSidHeader 修正(dataOffset 読み取り、loadAddr=0 対応)
    • デバッグ用エクスポート関数追加(sid_debug_irq_state / cpu_state / irq_count)
    • ビルドスクリプト build_sid.sh 作成