4.3. テキスト

4.3.1. 戦闘モード
4.3.2. 移動モード
4.3.2.1. 素朴な解析手法
4.3.2.2. デバッガーを活用する
4.3.2.3. BRK 命令もメッセージ表示を行う
4.3.3. データ

本節ではテキスト出力について見ていく。 移動モードと戦闘モードとでテキスト処理系統を分離してあるのは前作同様だが、 前者が圧縮符号の復号処理を有するのに対し、後者は生の文字コードを取り扱うように簡略化された。 解読が容易な戦闘モードから述べる。 最後に両モードのテキストデータをそれぞれ提供する。

4.3.1. 戦闘モード

既に先人 [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 データ メッセージデータ

4.3.2. 移動モード

この文書では人々・動物・魔物の台詞やナレーション等の 「大きいフォントを用いて表示するテキスト」全般の解析手段の検討および実践について述べる。

4.3.2.1. 素朴な解析手法

SFC 版ドラクエ 6 で初登場した移動中の呪文コマンド「おもいだす」「わすれる」を組み合わせて、RAM Diff サーチを行う。 これにより、セリフ・メッセージの内容と ID の対応表を手作業で作ることが可能だと考える(実際可能であった)。 以下のような都合のよい仮定の下、サーチに励む:

  • 「おもいだす」用のデータ格納領域が存在する

  • そこには「おもいだす」の記憶リスト「1つめ」「2つめ」に対応するデータが直列している

  • ゲームをプレイした感じから、メッセージは ID で表現されている

  • メッセージ ID は 2 バイト 長である

長い時間とかなりの忍耐を要するが、以下のことが判明する:

  • ID と セリフ・メッセージの対応

  • セリフ・メッセージのおおまかな総数

4.3.2.2. デバッガーを活用する

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 がお互いに近いところにあるので、 これらの命令は一つのサブルーチンの中にあると考えるのが自然だ。 以下、このサブルーチン全体のアセンブリコードを詳しく見ていく。

4.3.2.2.1. メッセージ ID からメッセージデータ位置を特定するルーチン

このサブルーチンは大きく分けて二つのことを行っている。 その境目となるのが $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
4.3.2.2.2. 文字コード取得ルーチン

サブルーチン $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 データ メッセージデータ

4.3.2.3. BRK 命令もメッセージ表示を行う

逆アセンブリコードを目視で眺めていると気付くことではあるが、 プログラム全体を通して 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 バイトとしている。 それゆえ、このプログラムを逆アセンブルするときには専用の逆アセンブラーを製作するのが望ましい。

4.3.3. データ

抽出した戦闘モードおよび移動モードにおける全テキストデータそれぞれを UTF-8 テキストファイルとして 付録 B データ で提供する。

今まで見てきたサブルーチンおよびフォントテーブルを Python スクリプトで再現し、 没メッセージを含むすべての移動モード時のテキストを ROM イメージファイルから抽出することに成功した。 ソースコードは GitHub の記者のアカウント内のどこかのリポジトリーで公開中のはずだ。

テキスト抽出スクリプトを書くに当たっての最大の難関は、復号済みコードから UTF-8 文字へのコード変換表の実装だ。 ここは偉大な先人の成果をありがたく再利用させていただくことで果たせる。 具体的に言うと dq_analyzer [URL1] に同梱されている dq6decode.c の「メッセージ文字列用」配列を自作するソースファイル内に、 自作プログラムの言語仕様に合わせてコピー&ペーストする。 そうすると残りは上で述べたサブルーチン、 つまりメッセージ ID からデータ格納位置を取得するコードとハフマン復号をするコードを 65816 コードから C/C++ 言語なり Python なりに書き直すだけで済む。