本節ではテキスト出力について見ていく。 移動モードと戦闘モードとでテキスト処理系統を分離してあるのは前作同様だが、 前者が圧縮符号の復号処理を有するのに対し、後者は生の文字コードを取り扱うように簡略化された。 解読が容易な戦闘モードから述べる。 最後に両モードのテキストデータをそれぞれ提供する。
既に先人 [URL1] の手によって戦闘モードにおける全テキストデータの格納アドレスが判明しているが、 ここでは「どのようにすれば戦闘メッセージのデータ格納アドレスをサーチできるか」を考えてみよう。
以下のことを(都合が良いのだが)仮定し、Diff サーチを提供するエミュレーターを使用して探索作業を行う。
メッセージ ID 自体のサイズは 2 バイト
「○○○○が あらわれた!」 「○○○○の こうげき!」 「○○○○に ○○の ダメージ!」 「○○○○を たおした!」 等、表示メッセージが変化するたびに執拗に Diff サーチをすることにより、 それらしい値変化をしているアドレスを細かく見ていく。 すると次のようなアドレスの集合に収束していくことが観察できるはずだ:
$7E3056
$7E5966
$7E5998
$7E59A8
$7E59B8
$7E59BA
$7E59BC
それからは戦闘突入直後に、これらのアドレスひとつひとつに Break Point を置き、 それらがメッセージの表示と関係が本当にあるかどうかを調べる。ここからは運任せである。
$7E3056
は頻繁に Read されるので、メッセージの更新とは無関係とみなし、これを候補から除外する。
次に $7E5966
だが、これも同様な感じがするのでやはり除外する。
その次の $7E5998
でそれらしい挙動を見せるようになる。
GSD のアウトプットウィンドウの表示はこうなる:
$C0/2A36 8D 98 59 STA $5998 [$7E:5998] A:000B X:0042 Y:0000 D:1E1F DB:7E S:083E [...] $C0/27B8 AD 98 59 LDA $5998 [$7E:5998] A:0001 X:0004 Y:0000 D:1E1F DB:7E S:0837 [...] $C0/27C1 AD 98 59 LDA $5998 [$7E:5998] A:0003 X:0004 Y:0000 D:1E1F DB:7E S:0837 [...]
ここで、あらかじめ用意しておいた逆アセンブリーコードリストと照合する。
なお、バンクごとに逆アセンブリーコードを用意しておくのは解析人の鉄則だ。
すると $C0/27B8
と $C0/27C1
の命令を一つのサブルーチンが含んでいる。
これが戦闘用メッセージのデータを取得するものであると推論したい。
そのサブルーチンの逆アセンブリーコードは次のものだ(アセンブリーコード右側の擬似コードは記者の解釈による。以下同様):
C0/27B0: 08 PHP C0/27B1: C230 REP #$30 C0/27B3: F47E7E PEA $7E7E C0/27B6: AB PLB C0/27B7: AB PLB C0/27B8: AD9859 LDA $5998 C0/27BB: 290700 AND #$0007 C0/27BE: 8D1E5A STA $5A1E C0/27C1: AD9859 LDA $5998 C0/27C4: 4A LSR A C0/27C5: 4A LSR A C0/27C6: 4A LSR A C0/27C7: 48 PHA C0/27C8: 0A ASL A C0/27C9: 6301 ADC $01,S C0/27CB: AA TAX C0/27CC: 68 PLA ; x == 戦闘メッセージ ID C0/27CD: BFD15AC1 LDA $C15AD1,X ; 戦闘用テキスト 開始アドレス群 C0/27D1: 18 CLC C0/27D2: 69BDDE ADC #$DEBD C0/27D5: 85A0 STA $A0 $A0 = アドレス + DEBDh C0/27D7: BFD35AC1 LDA $C15AD3,X ; 戦闘用テキスト 開始アドレス群 C0/27DB: 29FF00 AND #$00FF C0/27DE: 69F600 ADC #$00F6 C0/27E1: 85A2 STA $A2 $A2 = (バンク & 0x00FF) + 00F6h C0/27E3: 64A4 STZ $A4 $A4 = 0000h; for(;;){ C0/27E5: AD1E5A LDA $5A1E C0/27E8: F013 BEQ $27FD if($5A1E) return do{ C0/27EA: 22FF27C0 JSR $C027FF ; $A0 にストアされているアドレス値を増加させる ; a = 文字コードをセット C0/27EE: C9AC00 CMP #$00AC C0/27F1: F005 BEQ $27F8 if(a == 00ACh) break C0/27F3: C9AE00 CMP #$00AE C0/27F6: D0F2 BNE $27EA }while(a != 00AEh) C0/27F8: CE1E5A DEC $5A1E --$5A1E C0/27FB: 80E8 BRA $27E5 } C0/27FD: 28 PLP C0/27FE: 6B RTL
このルーチンの LDA 命令をチェックしておくと、
$C15AD1
がいかにも戦闘用メッセージデータのアドレスの先頭であることが特定できる。
ソニタウン [URL2] の結果と一致して、まずは一安心である。
あとはこのアドレスから 1 バイトずつ SFC 版ドラクエ 6 の小フォント文字コードとみなして、
力にまかせてデコードしていけばよい。
こちらは dq_analyzer [URL1] のデコーダにある配列を流用すればよい。
最初から dq6decoder -s する場合、戦闘メッセージは出力ファイルの
655,599 行目の 39 半角文字目に現れる。
このコードを呼び出し構造の上側へとたどっていくことにより、 メッセージ ID を指定して、画面にテキストを描画するような高水準サブルーチンを発見する。 A レジスターの値を ID とするものと、呼び出し命令の直後に値をハードコードして ID を指定するものとがあることがわかる。 それらの呼び出し例を次に示す:
C2/B3BB: 8D285A STA $5A28 C2/B3BE: 22162AC0 JSR $C02A16 ; message #$01A1: [BC]なんと [B4]が おきあがり[AD]なかまに なりたそうに こちらをみている![AF] C2/B3C2: A101 C2/B3C4: 22393FC4 JSR $C43F39 ; パーティーメンバー数が限界かテストする C2/B3C8: 900D BCC $B3D7 if(パーティーメンバー数限界状態){ C2/B3CA: 22162AC0 JSR $C02A16 ; message #$01AB: [BC]しかし ばしゃも ルイーダも[AD]いっぱいだった![AF] C2/B3CE: AB01 C2/B3D0: 22162AC0 JSR $C02A16 ; message #$01AC: [BC][B4]は さびしそうに[AD]さっていった! C2/B3D4: AC01 C2/B3D6: 60 RTS return } C2/B3D7: 22162AC0 JSR $C02A16 ; message #$01A2: [BC][B2]という なまえ らしい。[AD]なかまに してあげますか?[AF] C2/B3DB: A201 C2/B3DD: A90400 LDA #$0004 C2/B3E0: 229881C3 JSR $C38198 C2/B3E4: 9007 BCC $B3ED if(!はい || キャンセル){ C2/B3E6: 22162AC0 JSR $C02A16 ; message #$01AA: [BC][B2]は さみしそうに さっていった。 C2/B3EA: AA01 C2/B3EC: 60 RTS return }
C2/8A9C: BFA58AC2 LDA $C28AA5,X ; 敵一体減ったときのメッセージ ID の配列 C2/8AA0: 22292AC0 JSR $C02A29 ; 戦闘テキスト出力 @a C2/8AA4: 60 RTS C2/8AA5: 2C00 ; [B8]は いなくなった! C2/8AA7: 2A00 ; [B8]は しんでしまった! C2/8AA9: 2B00 ; [B8]を たおした!
ここで紹介し切れなかったものも含め、戦闘モードのテキスト処理に関係する機能をまとめておこう:
表 4.16 重要な戦闘モードテキスト機能
アドレス | 分類 | 意味 |
---|---|---|
$C02671
|
サブルーチン | 戦闘モードテキスト出力共通部 |
$C027B0
|
サブルーチン | メッセージ ID を入力して、データ格納アドレスを $A0:$A3 に出力する |
$C02831
|
サブルーチン | 必要に応じて文字コードを変換する |
$C0297B
|
データ | 濁点文字コードから濁点を抜いた文字コードへの対応を与える配列 |
$C029A4
|
データ | 半濁点文字コードから半濁点を抜いた文字コードへの対応を与える配列 |
$C029AE
|
データ | ある文字コードから「~」や「+」へ変換するための配列 |
$C02A16
|
サブルーチン | 戦闘モードテキスト出力(実引数を ID とする) |
$C02A29
|
サブルーチン | 戦闘モードテキスト出力(A レジスター値を ID とする) |
$C15AD1
|
データ | メッセージ格納アドレス計算配列 |
$F6DEBD
|
データ | メッセージデータ |
この文書では人々・動物・魔物の台詞やナレーション等の 「大きいフォントを用いて表示するテキスト」全般の解析手段の検討および実践について述べる。
SFC 版ドラクエ 6 で初登場した移動中の呪文コマンド「おもいだす」「わすれる」を組み合わせて、RAM Diff サーチを行う。 これにより、セリフ・メッセージの内容と ID の対応表を手作業で作ることが可能だと考える(実際可能であった)。 以下のような都合のよい仮定の下、サーチに励む:
「おもいだす」用のデータ格納領域が存在する
そこには「おもいだす」の記憶リスト「1つめ」「2つめ」に対応するデータが直列している
ゲームをプレイした感じから、メッセージは ID で表現されている
メッセージ ID は 2 バイト 長である
長い時間とかなりの忍耐を要するが、以下のことが判明する:
ID と セリフ・メッセージの対応
セリフ・メッセージのおおまかな総数
GSD さえあれば、特定のセリフまたはメッセージと ID の対応がわかれば十分であり、憶えておく対応は一つでいい。 本節では記者の実体験、というより達人の追体験を比較的ゆっくりとしたペースで述べる。
例えば、ダーマの広間にいる熟練度を教える婆さんのセリフのしょっぱなの ID は #$199F
であることが判明しているとする。
この婆さんに話しかけるや否や、#$199F
になっているメモリーアドレスをサーチする。
そうすることで、プログラムが RAM 中のどのアドレスにメッセージ ID を格納するのかを特定したいのだ。
実際にサーチすると、3 つ程度
($7E3EEC
, $7E5998
, $7FB0AC
) しか該当しない。
この際バンクが $7F
のものはうさんくさいので、候補から除外する。
ここで一旦エミュレーターを GSD に切り替え、残り二つのアドレスを Read タイプの Break Point で監視する。
すると、$7E5998
のほうで有力な処理が見つかる:
$C0/2B69 AD 98 59 LDA $5998 [$7E:5998] A:0000 X:0051 Y:FFFF D:0000 DB:7E S:083B P:envmxdIZc [...] $C0/2B72 AD 98 59 LDA $5998 [$7E:5998] A:0007 X:0051 Y:FFFF D:0000 DB:7E S:083B P:envmxdIzc [...] $C6/E0E1 AD 98 59 LDA $5998 [$7E:5998] A:0001 X:0003 Y:0018 D:0000 DB:7E S:083A P:envmxdIZC [...]
$C02B69
, $C02B72
がお互いに近いところにあるので、
これらの命令は一つのサブルーチンの中にあると考えるのが自然だ。
以下、このサブルーチン全体のアセンブリーコードを詳しく見ていく。
このサブルーチンは大きく分けて二つのことを行っている。
その境目となるのが $C02BB4
だ。
アセンブリーコードをつぶさに整理していくと、プログラムが
$C02BB4
に到達する時点で満たされる条件は、次のようになっている:
$7E5998
にメッセージ ID が格納されている
$7E5A1E
にメッセージ ID の下位 3 ビットが格納されている
$A0
-$A3
には、アドレスらしきデータが格納
(3 バイト + #$00
); $F7175B
以上の値だ
$A4
には、$C02BCC
- $C02BD3
にある
1 バイト の値のどれかが格納されている
$A5
には #$00
が格納されている
C0/2B69: AD9859 LDA $5998 ; $7E5998 にメッセージ ID が格納されている C0/2B6C: 290700 AND #$0007 C0/2B6F: 8D1E5A STA $5A1E $7E5A1E = メッセージ ID & 0007h C0/2B72: AD9859 LDA $5998 C0/2B75: 4A LSR A C0/2B76: 4A LSR A C0/2B77: 4A LSR A C0/2B78: 48 PHA C0/2B79: 0A ASL A C0/2B7A: 6301 ADC $01,S C0/2B7C: AA TAX ; x1 = メッセージ ID の上位 13 bit ; x1 は $C15BB5 からのオフセットを意味する ; 8 個の連続した ID が一つのアドレスを見ているのがわかる C0/2B7D: 68 PLA C0/2B7E: BFB55BC1 LDA $C15BB5,X C0/2B82: 85A0 STA $A0 $A0 = $C15BB5,x1 C0/2B84: 290700 AND #$0007 C0/2B87: DA PHX C0/2B88: AA TAX x2 = $C15BB5,x1 & 0007h C0/2B89: BFCC2BC0 LDA $C02BCC,X C0/2B8D: 29FF00 AND #$00FF ; マスクビット取得 C0/2B90: 85A4 STA $A4 $A4 = $C02BCC,x2 & 00FFh C0/2B92: FA PLX C0/2B93: BFB75BC1 LDA $C15BB7,X C0/2B97: 29FF00 AND #$00FF C0/2B9A: 4A LSR A ; 24 ビット値の上位 17 ビットがアドレス値の情報なので C0/2B9B: 66A0 ROR $A0 ; シフト演算を駆使して先に得られた C0/2B9D: 4A LSR A ; $A0 の上位ビットに流し込む C0/2B9E: 66A0 ROR $A0 ; C0/2BA0: 4A LSR A ; C0/2BA1: 66A0 ROR $A0 ; C0/2BA3: 85A2 STA $A2 $A0:$A4 = メッセージ格納アドレス C0/2BA5: A5A0 LDA $A0 C0/2BA7: 18 CLC C0/2BA8: 695B17 ADC #$175B C0/2BAB: 85A0 STA $A0 C0/2BAD: A5A2 LDA $A2 C0/2BAF: 69F700 ADC #$00F7 C0/2BB2: 85A2 STA $A2 $A0:$A4 += 00F7175Bh ; ベースアドレスを加算
ここで $C15BB5
から格納されているデータを ROM イメージファイルからダンプしてみる。
この時点ではメッセージの個数がわからないため、データ領域の終端位置もわからない。
しかし、いざダンプリストを見ると、データが単調増加しているではないか。
これに注目すれば自ずと終端位置がわかる。すなわち、
データ一個につき、メッセージ 8 個が対応しているので、
最後のメッセージ ID の候補もあたりがつけられる。
C1/5BB5: 070000 ; ID 0000h:0008h に対応するデータ C1/5BB8: A60200 ; ID 0008h:0010h C1/5BBB: D90600 ; ID 0010h:0018h ; 3 byte の数値が昇順に配列されている…… C1/65E1: 986F23 ; ID 1B20h:1B28h C1/65E4: 817823 ; ID 1B28h:1B30h C1/65E7: 000060 ; ここからは明らかに異質なデータなので除外 ; ...
このサブルーチンの後半で、別のサブルーチンを複数回呼び出している。
これにより、$7E5A1E
の意味が「何らかの処理の反復回数」と判明する。
このループ+サブルーチン $C02BD4
がメッセージデータの取得部の核心だ。
C0/2BB4: AD1E5A LDA $5A1E while($7E5A1E){ ; == メッセージ ID & #$0007 C0/2BB7: F012 BEQ $2BCB do{ C0/2BB9: 20D42B JSR $2BD4 ; a = 大文字コード C0/2BBC: C9AC00 CMP #$00AC if(a == 00ACh) C0/2BBF: F005 BEQ $2BC6 break C0/2BC1: C9AE00 CMP #$00AE C0/2BC4: D0F3 BNE $2BB9 }while(a != 0x00AE) C0/2BC6: CE1E5A DEC $5A1E --$7E5A1E C0/2BC9: 80E9 BRA $2BB4 } C0/2BCB: 60 RTS
サブルーチン $C02BD4
も二つのパートにわかれているが、
今興味があるのは後半部分 $C02BFA
から $C02C26
までだ。
前作における対応サブルーチンについての考察
3.6.2.2 ハフマン符号を復号する を参照して欲しいが、ここで文字コードを逐次ハフマン木から取得している。
本サブルーチンの目的とは、$A0
が示すアドレスに格納されている値から文字コード 2 バイトを復号し、
A レジスターに格納する。さらに、$A0
の示すアドレスも必要に応じてインクリメントすることだ。
$A0
に格納されているアドレスに格納されているデータは、
「文字コードの配列を圧縮したもの」だとイメージすればよい。
圧縮してある故、各データの格納位置がビット単位で指定されていなければならず、
その「ベース」アドレスが $A0
-$A3
に、
オフセット・ビットを示す値が $A4
にストアされているのだと、ここで理解できる。
前項で触れたが、$A0
の初期値はメッセージ ID の下位 3 ビットから決まる、
配列 $C02BBC
のどれかとなる。
C0/2BCC: 01 C0/2BCD: 02 C0/2BCE: 04 C0/2BCF: 08 C0/2BD0: 10 C0/2BD1: 20 C0/2BD2: 40 C0/2BD3: 80
ここで、移動モードのテキスト処理に関係する機能を現在判明している分だけまとめておこう:
表 4.17 重要な移動モードテキスト機能
アドレス | 分類 | 意味 |
---|---|---|
$C02AE2
|
サブルーチン | (type 1) 移動モードテキストを出力する |
$C02B09
|
サブルーチン | (type 2) 移動モードテキストを出力する |
$C02B69
|
サブルーチン | メッセージ ID に対応するデータ格納アドレスと復号用マスクビットを得る |
$C02BCC
|
データ | マスクビット配列 |
$C02BD4
|
サブルーチン | データ格納アドレスと復号用マスクビットを基にハフマン符号を復号、取得する |
$C02C28
|
サブルーチン | 小フォント文字コードを対応する大フォント文字コードに変換する |
$C02DC2
|
サブルーチン | (type 1) 移動モードテキスト出力(実引数を ID とする) |
$C02DD8
|
サブルーチン | (type 1) 移動モードテキスト出力(実引数を ID とする) |
$C02DEE
|
サブルーチン | (type 1) 移動モードテキスト出力(A レジスター値を ID とする) |
$C02E05
|
サブルーチン | (type 1) 移動モードテキスト出力(A レジスター値を ID とする) |
$C02E1C
|
サブルーチン | (type 1) 移動モードテキスト出力(実引数を ID とする) |
$C02E32
|
サブルーチン | (type 1) 移動モードテキスト出力(実引数を ID とする) |
$C02E48
|
サブルーチン | (type 1) 移動モードテキスト出力(A レジスター値を ID とする) |
$C02E76
|
サブルーチン | (type 1) 移動モードテキスト出力(実引数を ID とする) |
$C02EC1
|
サブルーチン | (type 1) 移動モードテキスト出力(実引数を ID とする) |
$C02F0F
|
サブルーチン | (type 2) 移動モードテキスト出力(実引数を ID とする) |
$C02F59
|
サブルーチン | (type 1) 移動モードテキスト出力(A レジスター値を ID とする) |
$C02FA0
|
サブルーチン | (type 1) 移動モードテキスト出力(A レジスター値を ID とする) |
$C0FFA8
|
BRK ハンドラー | $C02E32 または $C02E1C を実行する |
$C11100
|
データ | 小から大への文字コード変換対応 |
$C15BB5
|
データ | メッセージ格納アドレス計算配列 |
$C167BE
|
データ | ハフマン木ノード (OFF) |
$C1700E
|
データ | ハフマン木ノード (ON) |
$C59969
|
サブルーチン | $C02E5F または $C02E48 を実行する |
$C59977
|
サブルーチン | $C02DD8 または $C02DC2 を実行する |
$C59985
|
サブルーチン | $C02E05 または $C02DEE を実行する |
$F7175B
|
データ | メッセージデータ |
逆アセンブリーコードを目視で眺めていると気付くことではあるが、 プログラム全体を通して BRK 命令のオペランドが 65816 の仕様とは異なり 2 バイトになっている。 以下、これまでに述べた事実を利用して、 すべての BRK 命令実行が、オペランドの値をメッセージ ID とした移動モードにおけるテキスト出力命令として振る舞うということを示す。
ROM ヘッダーを見るという基本調査により、BRK 命令のハンドラーがアドレス $C0FFA8
から始まる処理であることは直ちに判明する。
そのコードを見ると、別バンクのコードへ JMP するものに過ぎない:
C0/FFA8: 5C4299C5 JMP $C59942
ジャンプ先のアドレス $C59942
から始まるコードを見ると、
移動モードテキスト出力サブルーチンのいずれかを実行するらしいことがわかる。
C5/9942: 28 PLP ; BRK による状態レジスターを捨てる C5/9943: C230 REP #$30 C5/9945: 48 PHA C5/9946: A303 LDA $03,S ; BRK 本来の仕様を拡張するべく C5/9948: 3A DEC A ; 復帰アドレスを調整する C5/9949: 3A DEC A ; C5/994A: 8303 STA $03,S ; C5/994C: 68 PLA C5/994D: 229399C5 JSR $C59993 ; 何かを判定 C5/9951: 9004 BCC $9957 if(何かの条件){ C5/9953: 5C322EC0 JMP $C02E32 ; 移動モードテキスト出力(実引数を ID とする) ; RTL も含む } C5/9957: 5C1C2EC0 JMP $C02E1C ; 移動モードテキスト出力(実引数を ID とする) ; RTL も含む
ここでいう実引数とは、BRK 命令の呼び出しに伴う 2 バイトのオペランドだ。 テキスト出力ルーチンはスタックを経由してこの値を参照する。
実際の BRK 命令実行コードをいくつか確かめよう。 次の例は某所のウシの振る舞いを実装したものだ。 ゲームの進行度によって鳴き声を変えていることが読み取れる:
CA/1F0C: AD373D LDA $3D37 ; フラグ列 CA/1F0F: 298000 AND #$0080 CA/1F12: D003 BNE $1F17 CA/1F14: 4C1D1F JMP $1F1D CA/1F17: 004500 BRK #$0045 ; #$0045: モーッ モーッ! CA/1F1A: 4C311F JMP $1F31 CA/1F1D: AD2D3D LDA $3D2D ; フラグ列 CA/1F20: 298000 AND #$0080 CA/1F23: D003 BNE $1F28 CA/1F25: 4C2E1F JMP $1F2E CA/1F28: 004400 BRK #$0044 ; #$0044: モー モー。 CA/1F2B: 4C311F JMP $1F31 CA/1F2E: 004600 BRK #$0046 ; #$0046: モー。 CA/1F31: 6B RTL
余談だが、出来合いの 65816 逆アセンブラーは当然だが BRK 命令のオペランドを 1 バイトとしている。 それゆえ、このプログラムを逆アセンブルするときには専用の逆アセンブラーを製作するのが望ましい。
抽出した戦闘モードおよび移動モードにおける全テキストデータそれぞれを UTF-8 テキストファイルとして 付録 B データ で提供する。
今まで見てきたサブルーチンおよびフォントテーブルを Python スクリプトで再現し、 没メッセージを含むすべての移動モード時のテキストを ROM イメージファイルから抽出することに成功した。 ソースコードは GitHub の記者のアカウント内のどこかのリポジトリーで公開中のはずだ。
テキスト抽出スクリプトを書くに当たっての最大の難関は、復号済みコードから UTF-8 文字へのコード変換表の実装だ。
ここは偉大な先人の成果をありがたく再利用させていただくことで果たせる。
具体的に言うと dq_analyzer [URL1] に同梱されている
dq6decode.c
の「メッセージ文字列用」配列を自作するソースファイル内に、
自作プログラムの言語仕様に合わせてコピー&ペーストする。
そうすると残りは上で述べたサブルーチン、
つまりメッセージ ID からデータ格納位置を取得するコードとハフマン復号をするコードを
65816 コードから C/C++ 言語なり Python なりに書き直すだけで済む。