はじめに——「設計図」から「音」へ
Vol.9「サウンド編」で、私はひとつの願いを書いた。 失われたゲーム音楽文化を「サルベージ」したい——と。 汎用のDAWではなく、ゲーム専用に設計されたFM音源ドライバとMML。 その「ドライバのロジックが生む命」を、現代の技術で蘇らせたい、と。
あれは、いわば設計図の話だった。今回は、その設計図が実際に音を出しはじめた話をしたい。
題材は、1990年代の8bitパソコン時代の名作ゲームの中に積まれていた、FM音源ドライバ達だ。 ディスクイメージのなかで眠っていたZ80や68000のサウンドドライバを分析し、楽曲データをMMLとして抽出する—— そして、その音源ドライバをUnity(C#)へ移植するという挑戦を始めた。 ゲームエンジンの中で、当時のチップと同じ振る舞いで、もう一度あの音を鳴らすために。
分析とは、過去のコードを「読む」こと。
移植とは、過去のコードに、もう一度「息を吹き込む」ことだ。
第1章:なぜ「再生」ではなく「移植」なのか
抽出した楽曲を鳴らすだけなら、近道はいくらでもある。 MMLをMIDIに変換してDAWで鳴らす。あるいは近似のロジックを実装して後は手動で微調整し、そのまま再生する。
だが、それでは足りない。聴感を頼りに数値を手で補正したプレイヤーは、 「それらしく鳴る」けれど、元のドライバが本当はどう鳴らしていたのかを保証してくれない。 そして、できあがったものは、「なにかが違う」ものになる。 それを避けるため、開発計画の冒頭には、その方針をはっきり決めた。
最優先は、Unity上で「それらしく鳴る」実装ではなく、
Z80/68000 driver semantics を再現する tick-driven core を作ることである。
ゴールは、ゲームのなかで使える本物の音源エンジンだ。 当時のチップ(PSGとFM音源、PCM)のレジスタを、当時のドライバと同じ順序・同じタイミングで叩く—— その意味論(semantics)の再現を基準に据える。 「聴いて似ているか」ではなく、「ドライバとして正しく動いているか」を物差しにするのだ。
第2章:3層アーキテクチャ——コアとUnityを分ける
音源エンジンは、責務をはっきり分けた3層構造で設計した。
FM-MmlParser
抽出 MML を event list に変換する。
FM-DriverCore
tick ごとに channel state を進め、
仮想 OPLL/PSG register write を発行する。
FM-AudioEngine(PSG + OPLL 合成)
register/state から PCM を生成し、
Unity の OnAudioFilterRead へ渡す。
パーサーは「テキストを構造に変える」だけ。ドライバコアは「時間を進めてレジスタを叩く」だけ。 合成エンジンは「レジスタの状態から波形を作る」だけ。 それぞれが隣の層のことを知りすぎないようにしてある。
この分離には、明確な狙いがある。Unity固有の処理を再生コアから切り離しておくことだ。
いまはUnityのOnAudioFilterReadへPCMを流し込んでいるが、
将来的にはこのコアを、過去記事(Vol.13)で触れたZ80 VMの上へ移植することも見据えている。
Unityに依存しているのは一番外側の層だけなので、内側のコアはそのまま運んでいける。
いま書いているのはUnityのコードだが、
本当に書いているのは「ドライバそのもの」だ。
だから、Unityの都合をコアに持ち込まない。
第3章:60Hzのtick——時間を刻む心臓
当時のサウンドドライバは、画面の垂直同期割り込み——おおむね毎秒60回——のタイミングで呼ばれ、 その1回ごとに「次に何をするか」を進めていた。音楽の時間は、この60Hzの鼓動の上に乗っている。
移植版でも、この60Hz tick駆動をそのまま基準にした。 オーディオの出力レート(48000Hzなど)とは無関係に、コアは1tick=1/60秒で時間を進める。 音を作るサンプルレートと、曲の時間を刻むtickレートを切り離すことで、 出力環境が変わってもテンポが狂わない。
各チャンネルが今どの音を鳴らし、あと何tick伸ばすのか——こうした演奏状態は、
チャンネルごとのstateとして保持する。
これは元のドライバが、チャンネルごとの作業領域(Z80のIXレジスタが指すメモリブロック)に
状態を持っていたのと、ちょうど対応している。移植の地図がそのまま引き継がれているわけだ。
第4章:MMLバイトコードの意味論——timingが命
抽出されたMMLは、人が読むための楽譜であると同時に、 元はZ80が1バイトずつ解釈していたバイトコードでもある。 移植でまず固めたのは、曲全体のタイミングを決める命令群だ。
FA xx 持続 duration seed(音の長さの種。次の音以降も持続)
0x62-0x71 ループ開始(繰り返し回数 = opcode - 0x62)
0x62 無限ループ
0x72 ループ終了 / フレーム状態の復元
0x00 ストリーム終端(休符ではない、純粋な終わり)
FF00/FF01 スラー(タイ)on / off
FE xx ピッチ・音量エフェクトのLUT
FC xx 文脈依存の拡張命令(音色切替・PSGノイズ制御など)
ここで効いてくるのが、解析段階で何度もつまずいた知見だ。 たとえばループは、単純な「カウンタひとつ」では正しく鳴らない。 ループの中にループがある入れ子(最大4段)を、スタックとして積まないと、 内側のループに入った瞬間に外側の状態が壊れ、曲が本来より短くなってしまう。
0x00を「休符」と誤解すれば、曲尾に余計な無音が挟まる。
FAを無視すれば、長い音符がごっそり詰まってテンポが崩れる。
どれも「聴けばなんとなく合っている」ように錯覚しやすい罠であり、
だからこそ意味論を物差しにする方針が活きてくる。
音色は、間違っていてもすぐ気づく。
だがタイミングの誤差は、耳をすり抜けて忍び込む。
timingこそ、最初に固めるべき土台だ。
第5章:仮想PSG——まずは単純な方から
音源は2系統ある。矩形波を鳴らすPSG(AY-3-8910互換)と、 FM音源のOPLL(YM2413)やOPN(YM2203)だ。 実装は、構造が単純なPSGから手をつけた。ドライバコアの検証台として、ちょうどよい。
; PSG レジスタの役割
R0-R5 トーン周期(3ch分の音程)
R6 ノイズ周期
R7 ミキサー(トーン/ノイズの有効・無効)
R8-R10 各chの音量
PSGは3チャンネル。おもしろいのは、同じPSGでもチャンネルごとに「音の切り方(gate)」が違うことだ。 元ドライバを解析すると、AチャンネルとBチャンネルとCチャンネルで、 音を鳴らす長さと余韻に残す長さの配分が異なっていた。 この微妙な差が、和音の歯切れやリズムの質感を作っている。移植版でもこの差をそのまま再現する。
検証曲は、抽出済みのBGMをいくつか選んで、集中して精査した。 まずPSGだけが、正しいタイミングと音程で鳴ること——そこを最初の合格ラインにした。
第6章:仮想OPLL——emu2413をC#へ移植する
FM音源のYM2413(OPLL)は、PSGよりずっと複雑だ。 オペレータ、エンベロープ、ビブラート、リズム音源……これを一から書き起こすのは現実的でない。 そこで、まずは定評あるオープンソースのYM2413エミュレータemu2413(Mitsutaka Okazaki氏作・MITライセンス)の 合成コアを、C#へ移植して取り込むことにした。
// C# port of the core YM2413/OPLL synthesis path from emu2413 v1.5.9.
// Original: https://github.com/digital-sound-antiques/emu2413
// Copyright (C) 2001-2019 Mitsutaka Okazaki, MIT License.
public sealed class FM-Emu2413Opll
{
public const uint MasterClock = 3579545; // 約3.58MHz
...
}
ネイティブプラグインに頼らず、純粋なマネージドC#として移植したのがポイントだ。
プラットフォームを選ばず、Unityのスクリプトとしてそのまま動く。
ドライバコアは、OPLLのレジスタ(音程のf-numberとblock、
key-on/off、音色と音量)をこのエミュレータへ書き込むだけで、本物のYM2413と同じ理屈で音が鳴る。
ただし計画では、最初から完璧な音色再現は狙わない。 まずレジスタ操作の順序とタイミングを正しくすること。 音色を作り込むのは、土台が安定したいちばん最後の工程と決めている。
音色を先に追い込むと、
「鳴り方が変なのは音色のせいか、タイミングのせいか」が切り分けられなくなる。
だから、美しさは最後に足す。
第7章:Unityへ——コンポーネントとして鳴らす
いちばん外側の層が、Unity統合だ。
再生はFM-MusicPlayerというMonoBehaviourが担当し、
AudioSourceとともに動く。
曲データはMMLファイルを包んだScriptableObject(FM-SongAsset)として持たせ、
コードを触らずに曲を差し替えられるようにした。
音の出口は、UnityのOnAudioFilterReadコールバックだ。
オーディオスレッドがバッファを要求するたびに、tickを進め、PSGとOPLLのサンプルを合成して流し込む。
開発を支えるために、診断用のミックスモードも仕込んだ。 インスペクタから、FMだけ・PSGだけ・リズムだけ・リズム以外、と 鳴らす要素を切り替えて聴き分けられる。
public enum FM-DiagnosticMixMode
{
Full, // 全部鳴らす
OpllMelodyOnly, // FMメロディだけ
PsgOnly, // PSGだけ
RhythmOnly, // リズムだけ
NoRhythm, // リズム以外
}
「全体ではなんとなく違うけれど、どのパートが原因か分からない」—— この聴き分けの仕組みが、移植のデバッグでは何より頼りになる。
第8章:耳だけで検証しない——比較ハーネス
移植がうまくいっているかは、最終的には耳で確かめる。 だが、耳だけを頼りにすると、いつのまにか「慣れ」が正解をゆがめてしまう。 そこで、機械的に答え合わせをする検証ハーネスを用意した。
ドライバコアが発行したレジスタ操作は、一つひとつログとして記録できる。 どのtickで、どのチップの、どのレジスタに、どんな値を書いたか——。 これをCSVに書き出し、解析側のPythonレンダラや、実機シミュレーションから採取した 「お手本」のレジスタ列と突き合わせる。
; レジスタ操作ログの一例
t000123 OPLL R20=15 ch=3 role=Fm0 key-on
t000123 OPLL R30=2A ch=3 role=Fm0 inst/vol
t000124 AY R08=0F ch=0 role=PsgA volume
合格基準はシンプルだ。レジスタ操作の並びとタイミングが、お手本から大きく外れないこと。 音色の微差より先に、この「骨格の一致」を取りにいく。 骨格さえ合っていれば、あとは音色を磨くだけで本物に近づいていける。
おわりに——設計図が、音になる
Vol.9で「サルベージしたい」と書いた構想は、いま少しずつ形になりつつある。 ディスクの底に眠っていたバイトコードを読み解き、その意味論をC#へ移し、 ゲームエンジンのなかで、当時と同じ理屈の音が鳴りはじめた。
まだ道半ばだ。FM音色の作り込み、リズム音源の安定化、表現系(ビブラートやスラー)の詰め—— やるべきことは残っている。 だが、土台となる「ドライバの意味論を基準にする」という方針は、揺らいでいない。
パーサー、60Hzのtick、チャンネルステート、仮想PSGと仮想FM音源チップ、そして比較ハーネス。 これらはすべて、Unityのためだけに作っているのではない。 いつか同じコアを、Z80 VMの上で——あるいは別のどこかで——もう一度動かすための、移植可能な「核」だ。
あの時代の音楽は、決して「古い」のではない。
まだ、正しく受け継がれていないだけだ。
——その受け継ぎ方を、いま、コードで書いている。