4.4. メッセージ解析

4.4.1. 解析手順
4.4.1.1. 素人の方法
4.4.1.2. 達人の方法
4.4.2. ダウンロード
4.4.2.1. ソースコード(不完全版)
4.4.2.2. データ

4.4.1. 解析手順

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

4.4.1.1. 素人の方法

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

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

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

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

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

長い時間とかなりの忍耐を要するが、以下のことが判明する。 なお、ここで紹介した素人の方法はお奨めできるものではないことを最後に断っておく。

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

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

4.4.1.2. 達人の方法

GSD さえあれば、特定のセリフまたはメッセージと ID の対応がわかればよい。 憶えておく対応は一つでいい。 以下、記者の実体験(というより、達人の追体験か)を説明する。

例えば、ダーマの広間にいる熟練度を教える婆さんのセリフのしょっぱなの ID は #$199F だ。 この婆さんに話しかけるや否や、#$199F になっているメモリアドレスをサーチする。 ラッキーなことに、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.4.1.2.1. メッセージ ID からメッセージデータ位置を特定するルーチン

このサブルーチンは大きく分けて二つのことを行っている。 その境目となるのが $C02BB4 だ。 アセンブリコードをつぶさに整理していくと、プログラムが $C02BB4 に到達する時点で満たされる条件は、次のようになっている:

  • $7E5998 にメッセージ ID が格納 (2 バイト)

  • $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 の上位13bit
// x1 は C15BB5 からのオフセットを意味する
// 8 個の連続した ID が一つのアドレスを見ているのがわかる
C0/2B7D:    68          PLA 
C0/2B7E:    BFB55BC1    LDA $C15BB5,X
C0/2B82:    85A0        STA $A0             $A0 = $(C15BB5 + x1);
// $A0-$A1: something data
C0/2B84:    290700      AND #$0007
C0/2B87:    DA          PHX
// x2: something data from C15BB5,X の下位 3bit
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
// $A4: mask from C02BCC,X
// $A5: 00h
C0/2B92:    FA          PLX
C0/2B93:    BFB75BC1    LDA $C15BB7,X
C0/2B97:    29FF00      AND #$00FF
C0/2B9A:    4A          LSR A               // LSR と ROR が入り乱れているわけだが
C0/2B9B:    66A0        ROR $A0             // carry bit を介して $C15BB7,X の
C0/2B9D:    4A          LSR A               // 下位 3 ビットが $A1 の上位 3 ビットになる
C0/2B9E:    66A0        ROR $A0             // 
C0/2BA0:    4A          LSR A               // 
C0/2BA1:    66A0        ROR $A0
C0/2BA3:    85A2        STA $A2             $A0 = cast<3byte>($(C15BB5 + x1)) >> 3;
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 += 00F7175Bh;
// $A0 に何らかのアドレス値がセットされた

ここで $C15BB5 から格納されているデータを ROM イメージファイルからダンプしてみる。 この時点ではメッセージの個数がわからないため、データ領域の終端位置もわからない。 しかし、いざダンプリストを見ると、データが単調増加しているではないか。 これに注目すれば自ずと終端位置がわかる。すなわち、 データ一個につき、メッセージ 8 個が対応しているので、 最後のメッセージ ID の候補もあたりがつけられる。

C1/5BB5:    070000  // ID 0000h - 0007h に対応するデータ
C1/5BB8:    A60200  // ID 0008h - 000Fh
C1/5BBB:    D90600  // ID 0010h - 0017h
// 3 byte の数値が昇順に配列されている……
C1/65E1:    986F23  // ID 1B20h - 1B27h
C1/65E4:    817823  // ID 1B28h - 1B30h
C1/65E7:    000060  // ここからは明らかに異質なデータなので除外
// ...

このサブルーチンの後半で、別のサブルーチンを複数回呼び出している。 これにより、$7E5A1E の意味が「何らかの処理の反復回数」と判明する。 このループ+サブルーチン $C02BD4 がメッセージデータの取得部の核心だ。

C0/2BB4:    AD1E5A      LDA $5A1E           while($7E5A1E){  // == メッセージ ID & 0007h;
C0/2BB7:    F012        BEQ $2BCB               do{
C0/2BB9:    20D42B      JSR $2BD4                   ☆サブルーチン呼び出し☆
C0/2BBC:    C9AC00      CMP #$00AC                  if(a == 00ACh)  // delimeter の役を果たす
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                 return;
4.4.1.2.2. 文字コード取得ルーチン

サブルーチン $C02BD4 も二つのパートにわかれているが、 今興味があるのは後半部分 $C02BFA から $C02C26 までだ。 アセンブリコードを以下に示す:

ハフマン復号というアルゴリズムにより、文字コードをハフマンツリーから取得している。 と、これはあるお方の受け売りだが。 とにかく、ルーチンとしては $A0 が示すアドレスに格納されている値から文字コード (2 バイト) を 復号し、アキュームレータに格納する。さらに、$A0 の示すアドレスも必要に応じてインクリメントする ……というのが、このサブルーチンの主目的のようだ。

$A0 に格納されているアドレスに格納されているデータは、 「文字コードの配列を圧縮したもの」だとイメージすればよい。 圧縮してある故、各データの格納位置がビット単位で指定されていなければならず、 その「ベース」アドレスが $A0-$A3 に、 オフセット・ビットを示す値が $A4 にストアされているのだと、ここで理解できる。 前項で触れたが、$A0 の初期値はメッセージ ID の下位 3 ビットから決まる、 配列 $C02BBC のどれかとなる。

C0/2BCC:    01   // == 1 << 0
C0/2BCD:    02   // == 1 << 1
C0/2BCE:    04   // == 1 << 2
C0/2BCF:    08   // == 1 << 3
C0/2BD0:    10   // == 1 << 4
C0/2BD1:    20   // == 1 << 5
C0/2BD2:    40   // == 1 << 6
C0/2BD3:    80   // == 1 << 7
4.4.1.2.3. 照合

前項で見たアキュームレータの値が、本当に文字コードを表すものかを確認すべく、 アセンブリコードを見るのではなく、デバッガの力を借りて検証する。 上記サブルーチンの呼び出し元に遡って、Exec ブレークポイントを一個セットする。 ここでセリフと文字とコードの対応がわかりやすいキャラに話しかけ、 文字一個一個をウィンドウにアウトプットするたびに、 ブレークポイントに到達すれば OK だ。

記者は、イヌに話しかけた。 *「わんっ わんっ わんっ! という、お誂え向けのセリフを持つイヌがモンストルにいるのだ。 以下、そのトレースである:

$C0/2C92 8D B2 59    STA $59B2  [$7E:59B2]   A:0511     不明
$C0/2C92 8D B2 59    STA $59B2  [$7E:59B2]   A:0577     不明
$C0/2C92 8D B2 59    STA $59B2  [$7E:59B2]   A:00AB     不明
$C0/2C92 8D B2 59    STA $59B2  [$7E:59B2]   A:0523     「わ」
$C0/2C92 8D B2 59    STA $59B2  [$7E:59B2]   A:04F7     「ん」
$C0/2C92 8D B2 59    STA $59B2  [$7E:59B2]   A:0620     「っ」
$C0/2C92 8D B2 59    STA $59B2  [$7E:59B2]   A:0200     スペース
$C0/2C92 8D B2 59    STA $59B2  [$7E:59B2]   A:0523     「わ」
$C0/2C92 8D B2 59    STA $59B2  [$7E:59B2]   A:04F7     「ん」
$C0/2C92 8D B2 59    STA $59B2  [$7E:59B2]   A:0620     「っ」
$C0/2C92 8D B2 59    STA $59B2  [$7E:59B2]   A:0200     スペース
$C0/2C92 8D B2 59    STA $59B2  [$7E:59B2]   A:0523     「わ」
$C0/2C92 8D B2 59    STA $59B2  [$7E:59B2]   A:04F7     「ん」
$C0/2C92 8D B2 59    STA $59B2  [$7E:59B2]   A:0620     「っ」
$C0/2C92 8D B2 59    STA $59B2  [$7E:59B2]   A:0551     「!」
$C0/2C92 8D B2 59    STA $59B2  [$7E:59B2]   A:00AC     00ACh は終端を意味する?

「わ」「ん」「っ」を見ると、 A レジスタの値と表示される文字が一対一対応している。 これにより、アキュームレータの値はコードと断定してよいことがわかる。 念を入れて、他の人物に話しかけてコードと表示される文字の対応をもう少し調べてもよい。

ここで、ドラクエビューア [URL2] が出力する SFC_DQ6gra.bmp のフォントテーブルと、「わ」「ん」等の位置=インデックスを照合してみる。

表 4.15 わんっ!

文字 A BMP
#$0523 #$0322
#$04F7 #$02F6
#$0620 #$041F
#$0551 #$0350

どの文字も差が定数 #$0201 になる。 デコードルーチンはこのフォントテーブルへのインデックス値を直接取得していると考えてよい。 定数分の差は、何か別のデータがフォントの直前にあり、 そこから生じたのものだと、とりあえず予想しておく。

4.4.1.2.4. メッセージデータ再現

今まで見てきたサブルーチンおよびフォントテーブルを C 言語で再現して、 SFC 版ドラクエ 6 に存在するすべてのメッセージデータを ROM イメージファイルから抽出する。

最大の難関は、文字コードから文字そのものに変換する仕組みの実装だが、 ここは偉大な先人の成果を再利用させていただくことで解決する。 dq_analyzer [URL1] に同梱されている dq6decode.c の「メッセージ文字列用」配列を、 これから書くソースファイルにコピー&ペーストする。

あとは上で述べたサブルーチンを 65816 コードではなく、C 言語で書き直せばよい。 それは、メッセージ ID からデータ格納位置を取得するコード、 ハフマン復号をするコード等だ。

4.4.2. ダウンロード

4.4.2.1. ソースコード(不完全版)

付録 B データ

達人の方法で述べたメッセージ解析手段を C 言語で表現したものだ。 文字コード配列は dq_analyzer [URL1] から 取得したものを使うのがよいと判断したため、ここは空白になっている。 「不完全」なのは、この一点のみだ(と信じている)。

まったくの余談だが、解析作業当時(現在もだが)記者は自宅にネット環境がないため、 dq_analyzer [URL1] がいつの間にかメッセージ抽出を実装していたことを知らなかった。 それ故、ドラクエビューア [URL2] が出力する SFC_DQ6gra.bmp を見ながら、大フォント版文字配列を自前で書いてしまった過去がある。 読者はこのようなおろかなことをしないように。

4.4.2.2. データ

付録 B データ