桃太郎伝説 (FC) 攻略/解析

Published on: 2022-11-12

パスワード

筆者作のパスワードライブラリ momoden-password も必要に応じて参照されたい。

セーブデータ

パスワードには以下のゲーム状態が記録される。これを抽象的に「セーブデータ」と呼ぶ。

セーブデータは最小 117bit, 最大 159bit となる。

セーブデータのシリアライズ

セーブデータをビット列に変換することを「シリアライズ」、その逆変換を「デシリアライズ」と呼ぶ。

セーブデータ内の個別の値をシリアライズする際は上位ビットから順に読む。
たとえば、6bit 値 0b111001 はビット列 [1, 1, 1, 0, 0, 1] に変換される。

セーブデータは以下の形式でシリアライズされる:

bit数内容
8加齢タイマー上位バイト
8所持金 bit8-15
8年齢
8所持金 bit0-7
8経験値 bit0-7
6預金
8経験値 bit8-15
8術習得状態
5宝物所持状態
4復活地点
2ひえんブックマーク bit8-9
3お供存在状態
8ひえんブックマーク bit0-7
8イベント進行状態
2装備:兜
4装備:武器
4装備:鎧
3装備:靴
2装備:いでたち0
2装備:いでたち1
1装備:いでたち2
1装備:いでたち3
6-48インベントリ(アイテムIDの配列)
8 個未満の場合、6bit の 0 で終端する

デシリアライズ時は単にこのビット列をパースすればよい。

なお、ゲーム中ではインベントリは常に先頭に詰められている(アイテムとアイテムの間に空欄が挟まることはない)ので、 シリアライズされたインベントリに空欄が現れることはない。
チートで空欄を含むインベントリをシリアライズした場合は、空欄の内部値が 0 なので、それがインベントリ終端とみなされる。

シリアライズバッファ

シリアライズされたビット列にはさらに 2 種類の 6bit チェックサムが付加される(全体で最小 129bit, 最大 171bit)。
このビット列全体はいったんバイト列に格納される。この格納用バイト列を「シリアライズバッファ」と呼ぶ。

シリアライズバッファ内でのビット列の格納方法を示す。
まず、バッファ内の各バイトの上位 2bit は使われない。つまり各バイトは実質 6bit 値である。
そして、入力ビットは各バイトの上位ビットから順に格納される。図示すると以下のようになる:

+----------+----------+----------+
|  Byte0   |  Byte1   |  Byte2   |
+----------+----------+----------+ ...
| ..012345 | ..6789AB | ..CDEFGH |
+----------+----------+----------+

たとえば、ビット列の先頭 8bit が [1, 1, 1, 1, 0, 0, 0, 1] ならば、以下のように格納される:

+----------+----------+
|  Byte0   |  Byte1   |
+----------+----------+ ...
| ..111100 | ..01.... |
+----------+----------+

シリアライズバッファは可変長である。
セーブデータからシリアライズバッファに変換する場合、その長さは最後のビットが格納されたバイトまでとなる。
よって、この場合は最小 22 バイト、最大 29 バイトとなる(ビット数を 6 で切り上げ除算すると得られる)。

なお、シリアライズバッファはパスワードから変換することでも得られる。
詳細は後述するが、この場合バッファのバイト数はパスワードの文字数と等しくなる(最小 1 バイト、最大 38 バイト)。
これではデシリアライズの際にビット数が不足しうるが、その場合は不足したビット全てを 1 として扱う。
(パスワード「ふ」をロードした状態がほとんど 1 のビットなのはこれが理由)

チェックサム

シリアライズバッファの先頭 2 バイトはチェックサム格納領域となっており、残りはセーブデータをシリアライズしたビット列を格納している。

チェックサムは 2 種類あり、それぞれ「add チェックサム」「xor チェックサム」と呼ぶ。計算方法は次の通り:

いずれも 6bit 値となることに注意。
なお、パスワードから変換したシリアライズバッファで長さが 2 以下の場合、add/xor チェックサムはどちらも 0b111111 とする。

そして、シリアライズバッファの先頭 2 バイトには add チェックサム、xor チェックサムがこの順で格納される。
このチェックサムが一致しているとき、そのときに限り、シリアライズバッファからゲーム状態をデシリアライズできる。

パスワード

まず、0 文字のパスワードおよび文字 '?' (「いれる」などで発生する)を含むパスワードは入力時点で無効となる。
また、以下の「特殊パスワード」の空でない接頭辞であるようなパスワードは音楽室/美術室行きとなる:

以下、このようなケースは除外して考える。

パスワードの各文字は 6bit の内部値を持ち、これを「文字コード」と呼ぶ。対応表を以下に示す:

x0x1x2x3x4x5x6x7x8x9xAxBxCxDxExF
0x
1x
2x
3x

パスワードをシリアライズバッファに変換することを「デコード」、その逆変換を「エンコード」と呼ぶ。

パスワードのデコード

パスワードのデコードは 2 段階で行われる。以下、パスワードの文字コード配列を password と書く。

第 1 段階では、パスワードの末尾から順に以下の変換を行う(^ は XOR):

Rust では以下のように書ける:

for i in (1..password.len()).rev() {
    password[i] ^= password[i - 1];
}
password[0] ^= 0x1F;

これは大まかに言って累積 XOR の逆変換になっている。

第 2 段階では、パスワードの各文字について以下の変換を行う(減算は mod 64。% は剰余):

Rust では以下のように書ける:

for (i, b) in password.iter_mut().enumerate() {
    *b = b.wrapping_sub([0x05, 0x19, 0x32, 0x21][i % 4]);
    *b &= 0x3F;
}

この変換結果がそのままシリアライズバッファとなる。
エンコードについてはこれの逆を行うだけなので省略する。

以上より、1 文字の有効なパスワードは「ふ」しかないことがわかる。
パスワードの先頭 1 文字は add チェックサムに対応しており、これが 0b111111 になる必要があるが、デコード処理の内容から、そのような入力は 1 通りしかないため。
同様にして 2 文字の有効なパスワードが「ふえ」しかないこともわかる(対応するセーブデータは「ふ」と同一)。

また、実は 2 文字目の文字コード (password[1]) が偶数であるパスワードは必ず無効となることが示せる。

証明

add チェックサム、xor チェックサムをそれぞれ sum_add, sum_xor とおく。
一般に add と xor の偶奇は一致するので、sum_addsum_xor の偶奇が異なるなら有効なパスワードにはなりえない。

パスワードの先頭 2 文字を prefix とおくと、sum_add, sum_xor は次のように求まる:

ここで bit0 のみに注目すると、以下のようになっている:

prefix[0]prefix[1]sum_addsum_xorinvalid
00011
01000
10101
11110

よって、prefix[1] が偶数ならば有効なパスワードにはなりえない。

不正なセーブデータのロード処理

パスワードとセーブデータの相互変換は以上の通りだが、一部のパスワード(「ふ」など)は変換結果のセーブデータに不正な値が含まれる。
セーブデータが不正でもロード自体はできるが、不正な装備はロード時にある種の(意図しない)変換がなされるので、これについて述べる。

まず、セーブデータ内の各部位の装備の値はアイテムIDそのものではなく、各部位に割り当てられたアイテムテーブル内のインデックスとなっている。
これらのインデックスは固定ビット長で記録されるが、アイテムテーブルは必ずしもサイズが 2 のべき乗になっていないため、不正なインデックスを与えるとテーブルの範囲外を参照してしまう。

各部位のアイテムテーブルは (部位インデックスのビット長、アイテムリスト) の形式で連続して並んでいる。これをダンプしたものを示す:

部位内インデックス内容
兜:02 (兜インデックスのビット長)
兜:114 (はちまき)
兜:215 (はちがね)
0 (兜の終端)
武器:04 (武器インデックスのビット長)
武器:129 (ぼくとう)
武器:230 (かたな)
武器:331 (あすかのけん)
武器:432 (すざくのけん)
武器:533 (びゃっこのけん)
武器:634 (ひりゅうのけん)
武器:735 (あしゅらのけん)
武器:836 (ほうおうのけん)
武器:939 (オニのかなぼう)
武器:1041 (ゆうきのけん)
0 (武器の終端)
鎧:04 (鎧インデックスのビット長)
鎧:117 (たけのどう)
鎧:218 (あかどう)
鎧:319 (むつきのどう)
鎧:420 (きさらぎのどう)
鎧:521 (やよいのどう)
鎧:622 (うづきのどう)
鎧:723 (さつきのどう)
鎧:824 (みなづきのどう)
鎧:925 (ゆうきのどう)
0 (鎧の終端)
靴:03 (靴インデックスのビット長)
靴:18 (かんじき)
靴:226 (ウサギのたび)
靴:327 (シカのたび)
靴:428 (シシのたび)
0 (靴の終端)
いでたち0:02 (いでたち0インデックスのビット長)
いでたち0:19 (じんばおり)
いでたち0:210 (ツルのはおり)
0 (いでたち0の終端)
いでたち1:02 (いでたち1インデックスのビット長)
いでたち1:111 (カイロ)
いでたち1:247 (タカのツメ)
0 (いでたち1の終端)
いでたち2:01 (いでたち2インデックスのビット長)
いでたち2:113 (おまもり)
0 (いでたち2の終端)
いでたち3:01 (いでたち3インデックスのビット長)
いでたち3:116 (てっこう)
0 (いでたち3の終端)

なお、各部位のインデックスが 0 の場合は無装備となる。

これを見ると、ほとんどのアイテムテーブルはサイズが 2 のべき乗になっていないことがわかる。
たとえば武器インデックスは 4bit だが、値を 15 にすると武器テーブルの範囲外、つまり鎧テーブル内の「むつきのどう」を参照してしまう。

このときの挙動だが、ロード時の装備復元処理は「アイテムIDを受け取り、それを装備可能な部位に装備する」という実装になっている(現在どの部位を復元しているかは見ていない)。
よって、テーブル範囲外を参照した場合、それが別部位に装備可能なアイテムIDならばそのまま別部位に装備される。
どの部位にも装備できないアイテムID(空欄を表す 0 など)は単に無視される。

これだと同部位に重複装備が起こりうるが、その場合は後から装備されたものが優先される。
(各部位は兜、武器、鎧、靴、いでたち0, いでたち1, いでたち2, いでたち3 の順に処理される)

例としてパスワード「ふ」(装備変換前セーブデータの全ビットが 1)の装備復元処理を示す: