ディスクイメージ(D77)のゲームをテープイメージ(T77)に引っ越しさせて実機で動かす話 — D77 を T77 / WAV に変換するまで
ユリグミさんが、FM-7用に Dragon's Cave というゲームを作られていました。
FM-7のなんか敵がたくさん出てきて撃つゲーム、とりあえずできました。
— ユリグミ (@BitbltRilo) May 5, 2026
ちょこっと音をだして、面が進むと敵の基地?が増えて難しくなるようにとか、スコアとかの表示とか…一応遊べるかと。ホームページに載せておきます! pic.twitter.com/yciu6vzNg5
ゲームのダウンロード(D77形式)は こちら
FM-7/77AVサークル FM^2 内で、
「T77 化できないかな?」
という話が持ち上がったので、家族が晩御飯を準備してくれている間にサクっとやってみようか、となったのがコトの始まり🍚
「IPLで$0200~へ本体をロードして動かしている」という情報はご本人より伺っていたので、サクっといけるでしょ、と余裕かましていました😇
WAV形式にすればPCのオーディオ出力とFM-7実機のCMT入力を直結してPCでWAVを再生すれば、実機で動作確認出来るよね、という目論見。
そこから始まった、調査・解析・ローダ設計・テープエンコード・WAV 化までの旅の記録。
全部で 7 幕ある。
扱う形式は D77 (FM-7 ディスクイメージ)、T77 (FM-7 テープイメージ)、WAV (44.1 kHz / 8bit / mono)。FM-7本体のCPU は 6809。FM-7のハードウェア仕様はあちこちで解析結果が公開されているので今回は詳細割愛します。
幕 1: ともかく D77 を起動してみる
まずは何も考えずに、D77 をディスクとして起動してみる。
ブート直後、画面にタイトルが出てゲームが立ち上がった。
…で、終わってしまった。
「ディスクとして読み込み、起動できる」という事実だけは確認できたが、それだけだ。
いやそれで充分でしょう。さーて、サクっとT77化しますかね。
ここから取れる道は 2 つあった。
- (A) エミュレータでブレークしてメモリダンプを取り、リバースエンジニアリングする
- (B) D77 のバイナリそのものを解析して、ディスク上のセクタ列がどう使われているかを読み解く
(A) は重い。CPU 命令単位で追いかける必要があり、最低でも 1 日仕事になる。それにバリバリのリバースエンジニアリングなのでご本人に詳細な許諾を得る必要がある。
(B) は D77 の仕様さえ知っていれば、バイナリエディタとちょっとした Python で済むはず。晩ご飯前の腹減らしに最適😇
幸い D77 のフォーマット仕様は公開されている。まず (B) から行く。 ヘッダを読み、トラックの並びを見てみる。
幕 2: D77 のヘッダを解析してみる
2.1 D77 のレイアウト
D77 ファイルは、
+---------------------------------+
| disk header (32 B) | ディスク名、種別、サイズ
+---------------------------------+
| track offset table (656 B) | 164 個の 32bit LE オフセット
+---------------------------------+
| track data × N | 各トラック 4,352 B (= 0x1100)
+---------------------------------+
という構造をしている。バイナリエディタで開いて、まず先頭のディスクヘッダを読む。
| オフセット | サイズ | 内容 | この D77 の値 |
|---|---|---|---|
0x0000 |
17 B | ディスク名 (ASCII + 0x00) |
"DRAGONC" + 0 埋め |
0x0011 |
9 B | 予約 | 0 埋め |
0x001A |
1 B | ライトプロテクト |
0x00 (解除) |
0x001B |
1 B | ディスク種別 |
0x00 (2D) |
0x001C |
4 B | ディスクサイズ (32bit LE) |
0x000552B0 = 348,848 |
種別が 2D ということは、40 シリンダ × 2 ヘッド = 80 トラックの両面倍密ディスク。
1 トラックは 16 セクタ、1 セクタは 256 バイトのフォーマットが標準。
2.2 トラックオフセットテーブル
ディスクヘッダの直後 0x0020-0x02AF に、164 個の 32bit LE オフセットが並ぶ。
track_index = cyl * 2 + head でこのテーブルに添字すれば、そのトラックの D77 内開始オフセットが取れる。
読んでみると、最初の 80 個 (= 80 トラック分) には値が入っているが、それ以降は全部ゼロ。
2D ディスクなので 80 トラック分しか使われない、という形式上の正しい姿。
ぱっと眺めると、漸化式が見える:
track_offset(n) = 0x2B0 + n * 0x1100
0x1100 = 4,352 バイトが「1 トラック分の D77 上の消費量」。これは「16 セクタ × (16 B セクタヘッダ + 256 B データ) = 272 × 16 = 4,352」と一致する。仕様どおり。
2.3 セクタの中身
1 セクタは「16 B のセクタヘッダ + 256 B のデータ本体 = 272 B」。
+0x00 1B C (シリンダ番号)
+0x01 1B H (ヘッド番号)
+0x02 1B R (セクタ番号 1..16)
+0x03 1B N (セクタサイズコード、N=1 → 256B)
+0x04 2B トラック内セクタ数 (LE) = 0x0010
+0x06 1B 密度 (0x00 = MFM)
+0x07 1B DELETED DATA マーク (0x00 = 通常)
+0x08 1B ステータス (0x00 = OK)
+0x09 5B 予約
+0x0E 2B データサイズ (LE) = 0x0100
+0x10 256B セクタデータ本体
任意のセクタ (C, H, R) のデータ先頭は、
sector_data_offset(C, H, R)
= 0x2B0 + (C*2 + H) * 0x1100 + (R-1) * 272 + 16
例: C=2, H=1, R=8 のデータ先頭は 0x2B0 + 5 * 0x1100 + 7 * 272 + 16 = 0x5F30。
手で計算したら手で開いてみて、ステータスバイトや C/H/R が想定どおりに並んでいることを確認する。整合した。
2.4 使われているトラックは前 8 本だけ
全 80 トラックを舐めて、データのエントロピーを見る。
すると、Cyl 0..3 × Head 0..1 の最初の 8 トラックだけ に意味のあるデータが入っており、Cyl 4 以降は全セクタが 0xE5 (フォーマット直後の空セクタを示す慣例の値) で埋まっていることが分かった。
+----------------+--------------------------+---------------------------------------+
| disk header | Track 0..7 (使用) | Track 8..79 (未使用、0xE5 埋め) |
| + offset table | Cyl 0..3 × Head 0..1 | 72 tracks × 4,352 B |
| 0x000-0x2AF | 0x2B0 - 0x8AAF | 0x8AB0 - 0x552AF (313,344 B) |
| (688 B) | (35,328 B、128 セクタ) | |
+----------------+--------------------------+---------------------------------------+
↑ ↑
ここに実体がある 空っぽ
ということは、ゲームの実体は最大でも 128 セクタ × 256 B = 32,768 B。
F-BASIC ディスクシステム特有のディレクトリ構造らしきものは、各セクタを覗いて回っても見当たらない。クラスタチェーンも FAT もない。
2.5 IPL を読む
ディスクで起動したとき、最初に読み込まれるのは慣習的に Cyl 0 / Head 0 / Sector 1。覗いてみると、そこには 256 バイトのマシン語コードが入っていた。これが IPL (Initial Program Loader)。
中身を表面的にトレースすると、
- 自分自身を
$FE00付近のワーク領域に展開する - ループで Cyl 0..3 を舐め、Cyl 0 / H=0 / R=1 (= 自分自身) だけスキップして、残りの 127 セクタを
$0200から順に積み上げる - 最後に
JMP $0200で起動
ロード順をテーブルにすると以下のようになる。
| 読込順 | C | H | R | 展開先メモリ |
|---|---|---|---|---|
| 1 | 0 | 0 | 2 | $0200-$02FF |
| 2 | 0 | 0 | 3 | $0300-$03FF |
| ... | ||||
| 15 | 0 | 0 | 16 | $1000-$10FF |
| 16 | 0 | 1 | 1 | $1100-$11FF |
| ... | ||||
| 31 | 0 | 1 | 16 | $2000-$20FF |
| 32 | 1 | 0 | 1 | $2100-$21FF |
| ... | ||||
| 127 | 3 | 1 | 16 | $8000-$80FF |
合計 127 × 256 = 32,512 バイト = $0200-$80FF。
ロードアドレスも実行アドレスもただひとつ $0200。
これは助かる。ファイルシステムを解読する必要がない。
読み込み順だけ間違えなければ、32KB の連続バイナリ dragonc.bin が一発で抽出できる。 (A) のリバースエンジニアリングルートに進む必要は無くなった。
2.6 抽出スクリプト
仕様どおりに 16 バイトのセクタヘッダをスキャンしながら、IPL と同じ順で並べる。
import struct
def get_sector(data, c, h, s):
ti = c * 2 + h
tr_off = struct.unpack('<I', data[0x20 + ti*4 : 0x24 + ti*4])[0]
if tr_off == 0:
return None
off = tr_off
while off < len(data):
C, H, R, _N = data[off:off+4]
sz = struct.unpack('<H', data[off+14:off+16])[0]
if (C, H, R) == (c, h, s):
return data[off+16 : off+16+sz]
off += 16 + sz
def extract_loaded_image(data):
img = bytearray()
for s in range(2, 17): # C=0, H=0, R=2..16
img += get_sector(data, 0, 0, s)
for c in range(0, 4):
for h in range(0, 2):
if (c, h) == (0, 0): continue
for s in range(1, 17):
img += get_sector(data, c, h, s)
return bytes(img)
これで dragonc.bin (32,512 B、$0200-$80FF) が手に入った。
第一幕で必要だったメモリイメージは、第二幕の D77 構造解析だけで取れた。 触らずに済んでよかった。
幕 3: T77 化できそう、でも一発 LOADM は無理だった
連続した 32KB のメモリイメージが取れて、しかもロードアドレス = 実行アドレス = $0200 だと分かっている。
ここまで来たら話は単純なはずだ。
LOADM フォーマットで $0200-$80FF を 1 つのファイルに包む
↓
それを T77 にエンコードする
↓
T77 を WAV に変換する
↓
完成
実際、最初に試したのはまさにこれだった。
dragonc.loadm を作って T77 に詰めて、ロードを試した。
結果: ロード途中で固まる。
ハングしたあとのメモリを覗いてみると、$0200 付近に書き込まれたのは最初の数百バイトだけで、その後はぐちゃぐちゃ。
原因はすぐに見えた。
-
$0000-$07FF周辺は BASIC が動作中ずっと使い続けている
CLEARでユーザ用ワークの上限を下げても、システム側が触り続けている領域はそこに残る。ロード中にここを上書きするのは、走っているプログラムの足元を撃ち抜くのと同じ -
LOADMを呼ぶには BASIC ROM が見えていないといけない
「じゃあ BASIC ROM を切ってから LOADM すれば」と一瞬考えたが、LOADMを実行するためのコードは BASIC ROM の中にいる。切った瞬間にLOADM自身が消滅する
つまり、
- BASIC ROM が見えている状態でロードしないと
LOADMが回らない - BASIC ROM が見えている状態では、
$0200周辺は BASIC が現役で使っている - かといって 32KB を一度に「BASIC の邪魔にならない場所」に置こうとすると、空き RAM が連続して 32KB 取れない
自分の足元を撃ち抜きながらロードしている、と言えば伝わるだろうか。
ロード途中でこけるのは当然だった。
ここで「一発 LOADM」は完全に諦める。
計の根本になる結論が出る。
「LOADMひとつだけで完結させることは不可能」。
LOADM は BASIC ROM の中のルーチンなので、LOADM を走らせている最中に ROM を切ることはできない。逆に言えば、ROM 切替が必要な作業はすべて 「LOADM が終わってから走る別の機械語」 に任せるしかない。
そして — その「別の機械語」自身も、どこかから運んでくる必要がある。最も素直な答えは、LOADM のペイロードに機械語コードを忍ばせて、ゲームデータと一緒に運ばせる こと。LOADM がデータの最後の 1 バイトまで書き込み終わってから、EXEC で機械語に制御を渡せば、もう BASIC ROM を切っても誰も困らない。
このあとの設計はすべて、この「LOADM の payload に小さなローダを相乗りさせる」前提で組み立てていく。
その前に、まずは晩ご飯を頂こう🍚
幕 4: 分割して LOADM させよう
32KB を一気に置けないなら、半分にすればいい。
メモリイメージを 16KB ずつ前後半に切る。
game[0x0000..0x4000] → 最終配置 $0200-$41FF (16,384 B、前半)
game[0x4000..0x7F00] → 最終配置 $4200-$80FF (16,128 B、後半)
ロード作業用の中継エリアは $2200-$61FF (16KB) に固定。
CLEAR ,&H17FF で BASIC のワークを $17FF 以下に押し込めば、$1800 から上はある程度自由に使える。
中継エリアより上の $6200-$7FFF は LOADM 中の BASIC 動作に支障がない範囲、$8000 以降は BASIC ROM の見えている領域として温存する。
しかし、ここで第二の壁にぶつかる。
1 回目の LOADM で前半 16KB を $2200-$61FF に置く
↓
最終目的地は $0200-$41FF
↓
2 回目の LOADM で後半 16KB を $2200-$61FF に置く
↓
最終目的地は $4200-$80FF
1 回目の前半データを、2 回目の LOADM が上書きしてしまう。
2 回目のロード前にどこかに退避しないと、前半が消える。
退避先の候補:
-
$6200-$7FFF(中継より上、F-BASIC ROM より下) → 7,680 B しかなく、16KB に足りない -
$0000-$01FF(ゼロページ) → BASIC が現役で使っている -
$8000-$FBFFの 裏 RAM → ?
最後の選択肢の「?」が、次の幕の主役になる。
幕 5: 裏 RAM に退避させるローダを書く
ここがこの作業の技術的なヤマ場。
ハイライトは 2 つ。
ハイライト ①: ROM 切替のサンドイッチを LOADM の payload に忍ばせる
LOADM 自身は BASIC ROM が見えていないと走らないし、走っている最中に ROM を切ることもできない。だから、ROM 切替を含む処理はすべて「LOADM が運ぶ payload の中に機械語として埋めておく」しかない。流れはこうなる:
-
LOADMがゲームデータと一緒に小さな機械語 (= stager / relocator) を読み込む -
LOADMが完了して BASIC の OK プロンプトが戻る - ユーザが
EXECで機械語に制御を渡す (あるいはLOADM ,,Rで自動実行) - 機械語の中で ROM OFF → 裏 RAM に書き込み → ROM ON → BASIC に戻る という一連を完結させる
- BASIC の視界からは、何ごともなかったように見える
つまり、ROM の切替を「LOADM の外側」「BASIC が手に持つ手順の中」では絶対にやらない。ROM 切替は LOADM が運んだ機械語の中で開いて閉じる、サンドイッチ構造にする。
ハイライト ②: 最後の JMP $0200 のためにトランポリンが必要
ゲームは $0200 始まり。だが $0200 は BASIC の作業領域。
最終的に「$0200-$80FF にバイナリを並べた状態で、PC = $0200、ROM = OFF」にしないといけない。
ここで詰む。
- バイナリを
$0200に置く作業は、$0200の外から行う必要がある (自分が居る場所を上書きはできない) -
$0200への JMP も、$0200の外から行う必要がある (BASIC からは戻れない) - かといってその「外側のコード」を
$1800あたりの低位 RAM に置くと、コピー作業の途中で踏まれる可能性がある (実際$0200-$41FFのコピー先範囲とは離れているが、BASIC ワークと隣接していて気持ち悪い)
解は、最後の Move とその後の JMP $0200 を実行するコードを、最後まで生き残れる「安全地帯」に置く こと。
具体的には裏 RAM の高位 $C200 に逃がす。コピー先 $0200-$41FF にも $4200-$80FF にも被らず、ROM OFF 状態であれば 6809 がそこから命令を拾える。
これが「トランポリン」。
LOADM が運んできた機械語を、コピー作業中に消えない高位に転載してから、そこで本番の作業をやらせる。
5.1 裏 RAM の使い方
FM-7 の $8000-$FBFF は F-BASIC ROM / 裏 RAM の切替領域。
$FD0F を 書く と裏 RAM 側に切り替わり、$FD0F を 読む と F-BASIC ROM 側に戻る。
切替で見える顔が変わるだけで、裏 RAM の中身はどちらの状態でも物理的にそのまま居続ける。BASIC ROM を見せている間は単に「読めない」だけ。
これを使って、ハイライト ① のサンドイッチを組み立てる。
-
$FD0F書込みで裏 RAM に切替 →$8200-$C1FFに前半 16KB を書き込む -
$FD0F読出しで F-BASIC ROM に戻す → BASIC の視界が回復、LOADMが再び使える - 後半 16KB を中継エリアにロードする (この間、退避した前半は裏 RAM に静かに居続けている)
- 全部終わってからもう一度
$FD0F書込みで裏 RAM に切替 → 退避してあった前半が読み出せる - 退避と中継から、それぞれ最終位置に書き戻す
裏 RAM 側に切り替えている間は F-BASIC ROM が消えるので、BASIC の IRQ ハンドラも一時的にアクセス不能になる。
切替前に ORCC #$50 で FIRQ/IRQ をマスクし、ROM を戻してから ANDCC #$AF で解除するのを忘れない。
切替している時間は 16KB をループでコピーする間だけなので、割り込みを止めても実害は出ない。
5.2 メモリのダンス
3 つのフェーズで、データが「中継 → 退避 → 最終位置」と踊る。
5.3 stager (Phase 1.5 で動く 29 バイト)
LOADM "CAS:" の後に EXEC &H1800 で呼ばれる、$2200-$61FF を $8200-$C1FF に丸ごとコピーする小さなプログラム。
ORCC #$50 ; FIRQ/IRQ マスク
LDA #$00
STA $FD0F ; 裏 RAM に切替 (F-BASIC ROM が消える)
LDX #$2200
LDY #$8200
loop: LDA ,X+
STA ,Y+
CMPX #$6200
BNE loop ; 16KB コピー
LDA $FD0F ; F-BASIC ROM に戻す
ANDCC #$AF
RTS ; BASIC へ帰る
ポイント:
- コピーしている間だけ裏 RAM 側に切り替えるので、その瞬間は F-BASIC ROM が見えない。IRQ ハンドラも一緒に消えるので、
ORCC #$50で FIRQ/IRQ を止めておく - ROM を戻してから
RTS。EXECで積まれた戻り先に帰り、BASIC の OK プロンプトに復帰する (次のLOADMを打ってもらうため) - 裏 RAM に書き込んだ前半 16KB は、ROM を戻した後も裏 RAM 側に残っている (ROM ON は単に読み出しを ROM に切り替えるだけ)
5.4 relocator (Phase 3 で動く 65 バイト、2 段構成)
LOADM "CAS:",,R の自動実行で $1900 から走り出すコード。
これがハイライト ② で言った トランポリン構造 の実体。
中身は Stage 1 と Stage 2 の 2 段になっていて、
-
Stage 1 @
$1900(30 B): ROM をオフにして、Stage 2 を$C200に転載してからJMP $C200 -
Stage 2 @
$C200(35 B): 2 つの Move を実行して、最後にJMP $0200
つまり Stage 1 は「自分のコピーを安全地帯に飛ばすだけのトランポリン」。本番の仕事 (Move 1、Move 2、JMP $0200) は全部 Stage 2 がやる。Stage 2 さえ $C200 に居れば、Stage 1 の居場所 ($1900) がコピー作業で踏まれようがどうでもいい。
$C200 を Stage 2 の住所に選ぶ理由は、
- Move 1 の dst
$4200-$81FFにも Move 2 の dst$0200-$41FFにも被らない - 裏 RAM 側に切り替えてから走らせるので、
$C200の番地で 6809 が拾うのは退避した命令列そのもの - ゲームの最終配置
$0200-$80FFの外なので、起動後に踏まれることもない
Stage 2 が自分自身を踏み潰さない、安住の地。
Stage 2 が行う 2 つの Move:
; Move 1: $2200-$61FF → $4200-$81FF (16KB、逆向き)
LDX #$6200 ; src の 1 つ先
LDY #$8200 ; dst の 1 つ先
m1: LDA ,-X
STA ,-Y
CMPX #$2200
BNE m1
; Move 2: $8200-$C1FF → $0200-$41FF (16KB、順方向)
LDX #$8200
LDY #$0200
m2: LDA ,X+
STA ,Y+
CMPX #$C200
BNE m2
JMP $0200 ; ゲーム起動
5.5 ハマったポイント 2 つ
ハマり①: stager が一度も走らなかった
最初の手順では LOADM "CAS:" の後に EXEC (引数なし) で stager を起動させていた。
ところが、ロード後に $8200-$C1FF の中身を覗いたら 全部ゼロ。退避が一度も起きていない。
EXEC を引数なしで打ったときの挙動が手元の環境では当てにならなかった、というだけの話で、EXEC &H1800 と明示すれば一発で直った。たかが 6 文字、されど 6 文字。横着せずにアドレスを書く。
ハマり②: Move 1 が壊れる
Move 1 のソース $2200-$61FF と先 $4200-$81FF は、$4200-$61FF の範囲でガッツリ重なる。
順方向にコピーすると、まだ読んでいない $4200 付近のバイトが、先に Move 1 自身の書き込みで上書きされてしまう。
最初はそれに気付かず順方向で書いて、「ロードはするが起動した途端にゲームがめちゃくちゃになる」という症状にぶつかった。
ここで一旦お風呂休憩。湯船に浸かりながら「あ、これ memmove と memcpy の違いやんけ」と気付いて、上がってから 1 行直したら通った。memmove(3) と memcpy(3) の違いを久しぶりに痛感した瞬間。
幕 6: BASIC ローダは実機で打って SAVE する
ローダ部分は完成した。ただ、オペレータが実機の前で打つ手順は、
CLEAR ,&H17FF
LOADM "CAS:"
EXEC &H1800
LOADM "CAS:",,R
の 4 行で、これを毎回手で打たせるのは粋じゃない。
F-BASIC には RUN" というカセット用の万能コマンドがあって、テープから BASIC プログラムを読み込んでそのまま実行までやってくれる。一発で済むやつ。
幸い、LOADM は BASIC プログラムからも呼べる。EXEC も同様。
それなら、テープの先頭に小さな BASIC プログラムを 1 本足してしまえばいい。
10 CLEAR ,&H17FF
20 LOADM "CAS:"
30 EXEC &H1800
40 LOADM "CAS:",,R
実機で打って、実機に SAVE させる
BASIC 部分の音声を Python 側で組み立てるのは見送った。
たった 4 行のために BASIC ファイルを Python から作れる形にするのは、明らかに割に合わない。実機なら SAVE "CAS:" ひとつで正しいバイト列を吐いてくれるのだから、その音をそのまま使えばいい。
そういうわけで、実機の前に座ることにした。物理テープは介さない。FM-7 の CMT 端子と PC のオーディオ I/F を直結する だけ。
- PC 側で録音を開始する
- 実機で
OKプロンプトに上の 4 行をそのまま打ち、SAVE "CAS:"を実行 - FM-7 の CMT OUT → PC の LINE IN に流れた音がそのまま WAV として PC に落ちる
- その WAV (BASIC 部分) を、
t77_to_wav.pyで生成した LOADM 用 WAV の頭に Audacity で連結する - 最終的に出来上がる 1 本の WAV: BASIC → LOADM(1回目) → LOADM(2回目)
再生する側も同じケーブル接続で、PC の LINE OUT (= ヘッドホン端子) → FM-7 の CMT IN。
カセットテープもラジカセも一切介在しない、全部ケーブル直結 + 全部 WAV のシンプルな構成。
これで、
- BASIC 部分は実機に任せる (仕様にいちばん詳しいのは実機本人)
- LOADM 部分は Python で生成する (生のマシン語イメージを LOADM コンテナに包むだけ)
- 連結だけ Audacity でやる (4 行のために専用コードを書かなくて済む)
という、各層の得意分野に役割分担した形になった。
ハイブリッドだけど、いちばん筋が良い気がした。
Audacity プロジェクト (dragonc.aup3) は、
- Track 1: 実機 SAVE 録音 (BASIC、十数秒)
- Track 2: Python 生成の
dragonc.wav(約 5 分 30 秒)
を順に並べ、ファイル間の無音長を整えただけのシンプルな構成。
LOADM 部分の末尾には元 T77 由来の終端トレイラがそのまま残る。
受け側オペレータの操作
RUN" ← BASIC 部分を読んで、そのまま実行まで一発
↓ 10 CLEAR ,&H17FF
↓ 20 LOADM "CAS:" ← LOADM(1回目): stager + 前半をロード
↓ 30 EXEC &H1800 ← stager 実行 (前半を $8200-$C1FF に退避)
↓ 40 LOADM "CAS:",,R ← LOADM(2回目): Stage1+2 + 後半をロード + 自動実行
ゲーム起動
オペレータが触るのは RUN" の一発と、PC 側の WAV 再生を開始するクリックだけ。あとは PC が信号を流し終わるのを待てば起動する。
幕 7: 最後にまとめて WAV 化する
7.1 T77 のエンコード
T77 形式は、テープに流す信号を「半サイクルの長さ + 極性」の列にしたバイナリ。
1 エントリは 16bit big-endian で、
| ビット | 意味 |
|---|---|
| bit 15 | 極性 (1 なら高レベル、0 なら低レベル) |
| bit 14..0 | 半サイクル長 (T77 tick 単位、1 tick = CPU 16 cycle) |
| 0x0000 | 無音マーカ (ファイル間ギャップ用キュー) |
FM-7 のテープ I/O は UART 調歩同期で、1 バイトを次のように展開する:
スタートビット (0) → データ 8 ビット (LSB first) → ストップビット (1, 1)
各ビットは FSK で送られる。1 は短い半サイクル (高い周波数)、0 は長い半サイクル (低い周波数)。
bit "1" (短い半サイクル: 26 ticks ≒ 0.23 ms)
┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐
───┘ └──┘ └──┘ └──┘ └──┘ └──┘ └─── 高い周波数 → 短く均等な凹凸
bit "0" (長い半サイクル: 48 ticks ≒ 0.43 ms)
┌────┐ ┌────┐ ┌────┐ ┌────┐
───┘ └────┘ └────┘ └────┘ └── 低い周波数 → 長く間の空いた凹凸
1 バイト = 11 ビット = 22 個の半サイクル。これがざらっと並ぶ。
7.2 WAV のレンダリング
T77 → WAV は驚くほど素朴で、半サイクル 1 個ごとに「指定された秒数ぶん、HIGH か LOW のサンプル値を並べる」だけ。
| レベル | 8bit unsigned 値 |
|---|---|
| HIGH | 0xC0 |
| LOW | 0x40 |
| 無音 (DC センター) | 0x80 |
1 tick ≒ 8.92 µs、44.1 kHz だと 1 tick あたり 0.394 サンプル相当という非整数比なので、丸めをそのまま捨てると累積誤差が出る。
小数残差を次の半サイクルに持ち越すキャリー方式で処理する。
n_float = ticks * samples_per_tick + sample_carry
n = int(n_float)
sample_carry = n_float - n
これで 5 分超の WAV を作っても、最後までジッタが暴れずに通る。
0x0000 の無音キューが出てきたら、デフォルト 10 秒ぶんだけ DC センター値 (0x80) を埋める。BASIC からの自動チェイン (LOADM→EXEC→LOADM) がスムーズに繋がるよう、テープ送りのタイミングをここで吸収する。
7.3 全体パイプライン
「Python パス」と「実機パス」が Audacity で合流するハイブリッドが、結局いちばん少ない手数で済んだ。
7.4 実機で焼かずに正しさを確認する
FM-7 に音を流し込む前に、念のため End-to-End の検証スクリプトを書く。
FSK 復調 → UART デコード → LOADM パース → 仮想 64KB RAM 上で stager と relocator のセマンティクスを Python で模倣、最終状態の $0200..$80FF を dragonc.bin と比較する。
これで「実機で焼かなくても、変換が正しいことが分かる」状態になり、夜中に試行錯誤するハードルが下がった。
最終的に出来上がった dragonc.wav は約 14 MB、再生時間 5 分 30 秒前後。
無音キュー込みのフル尺版は約 32 MB。
エピローグ
書き終わってみると、本質的なポイントは 3 つだった。
-
ROM 切替を LOADM の payload に閉じ込める
LOADM自身は BASIC ROM が見えていないと走らないので、ROM 切替を含む作業はすべて「LOADM が運んできた機械語」の中で開いて閉じる。BASIC からは「ロードしてEXECして、戻ってきた」と見えるだけ。サンドイッチ構造。 -
最終位置
$0200への JMP のためにトランポリンを高位裏 RAM に置く
ゲームが$0200始まりなので、コピーコード自身も BASIC もそこを直接踏めない。コピー作業を実行し、その後JMP $0200を発行するコードは「コピー先のどこにも被らず、最後まで生き残れる」場所に置く必要があり、それは裏 RAM の$C200以降になった。Stage 1 → Stage 2 の 2 段トランポリンで、本番の作業を安全地帯に逃がしてから動かす。 -
BASIC 部分は実機に SAVE させて Audacity で繋ぐ、という割り切り
全部 Python で完結させるのが美しい設計に見えるが、4 行のためにそれをやるのは明らかに割に合わない。実機が正しい形式で SAVE してくれるなら、その音をそのまま使う。各ツールの得意分野に役割分担させたほうが、結果として手数も少なく、間違いも少ない。
派生して、
- 表 RAM が連続して 32KB 取れないので 16KB に分割せざるを得なかった
- Move 1 のソースと先がオーバーラップするので逆向きコピーが必須だった (お風呂でひらめいて 1 行で直った)
の 2 つも実装中の地味なポイントだった。
それ以外は、地味な仕様読みと地味なエンコードの組み合わせ。
D77 のヘッダ → トラックオフセットテーブル → セクタヘッダ → IPL の読み込み順 → 32KB メモリイメージ → 前後半分割 → ROM 退避ローダ → 実機 SAVE で焼いた BASIC ブートストラップ → Audacity で連結 → UART 調歩同期 → FSK 半サイクル列 → 矩形波 PCM。
どれも 30 年以上前のフォーマットで、今の感覚からすると驚くほど素朴。だからこそ、Python と実機と Audacity という雑多な道具で一通り扱えてしまうし、検証も Python 1 本で完結する。
最終的に出来上がった WAV 1 本を、PC で再生して FM-7 の CMT IN に流し込み、RUN" と一発打てば、5 分半待つだけでゲームが起動する。
サークルで「T77 化できないかな?」と話していた『Dragon's Cave』が、いま WAV 1 本に収まっている。
生成したT77及びWAVファイルは以下です
WAV - dragonc_full.wav- ~~dragonc.t77~~~~
※
無時に我が家のFM77AV40EXで動作しました🥰
補遺: もっと正確な WAV を作るなら
今回の最終 WAV は 「実機 SAVE 由来の BASIC 部分」+「Python 生成の LOADM 部分」 のハイブリッド。
Python 側は T77 のスペック通りに半サイクル長を整数 tick で並べているので、波形は十分綺麗で、実機もちゃんと読んでくれる。が、それでも実機がカセット I/O から吐き出す本物の波形とは微妙に違う。
具体的には:
- 実機側の周波数は厳密には CPU クロックから割り出されるが、PC で再生する WAV はサンプリングレートの量子化を必ず受ける
- 実機の MARK/SPACE には立ち上がり/立ち下がりの実アナログ特性が乗る (PC 生成の矩形波にはそれがない)
- リーダ長やブロック間ギャップを実機が選んだそのままにできる
「もっと正解に近い WAV が欲しい」 なら、
- 機械語部分も
SAVEM "CAS:",&H1800,&H61FF,&H1800のような形で 実機に SAVEM させ、その音を PC に直結録音する - 同様に Stage 1 部分 (
$1900-$21FFを含む File 2) も実機 SAVEM で録音する - それぞれ Audacity で頭から並べる
という、全部の区間を実機由来の波形で構成した WAV が作れる。
このとき Python 側は「ロード対象のバイナリを LOADM できる形に組み立てて、それを実機メモリ上に書く」という一回限りの仕事になり、テープ波形そのものは実機が責任を持つ形になる。
今回はそこまでやらなくても通ったので Python 生成版で止めたが、より厳密に「実機が読める保証された波形」が欲しい場合や、別環境への配布を考える場合は、「LOADM 部分も実機 SAVEM 録音」 が次の正解。
ハイブリッドを「BASIC だけ実機」から「全部実機」に倒すと、それだけ実機との親和性が上がる。
どうせなら、ということで汎用化してみました。こちらに色々書いていますのでご参考ください。
変換例
Dragon's Cave がジョイスティックに対応されたとのことなので WebM7 で確認しようとした際、折角なら、とT77で確認しました。