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 の内部値を持ち、これを「文字コード」と呼ぶ。対応表を以下に示す:
x0 | x1 | x2 | x3 | x4 | x5 | x6 | x7 | x8 | x9 | xA | xB | xC | xD | xE | xF | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0x | あ | い | う | え | お | か | き | く | け | こ | さ | し | す | せ | そ | た |
1x | ち | つ | て | と | な | に | ぬ | ね | の | は | ひ | ふ | へ | ほ | ま | み |
2x | む | め | も | や | ゆ | よ | ら | り | る | れ | ろ | わ | が | ぎ | ぐ | げ |
3x | ご | ざ | じ | ず | ぜ | ぞ | ば | び | ぶ | べ | ぼ | ぱ | ぴ | ぷ | ぺ | ぽ |
パスワードをシリアライズバッファに変換することを「デコード」、その逆変換を「エンコード」と呼ぶ。
パスワードのデコードは 2 段階で行われる。以下、パスワードの文字コード配列を password
と書く。
第 1 段階では、パスワードの末尾から順に以下の変換を行う(^
は XOR):
password[i] ^= password[i-1]
(i > 0
)password[0] ^= 0x1F
Rust では以下のように書ける:
for i in (1..password.len()).rev() {
password[i] ^= password[i - 1];
}
password[0] ^= 0x1F;
これは大まかに言って累積 XOR の逆変換になっている。
第 2 段階では、パスワードの各文字について以下の変換を行う(減算は mod 64。%
は剰余):
password[i] -= [0x05, 0x19, 0x32, 0x21][i % 4]
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_add
と sum_xor
の偶奇が異なるなら有効なパスワードにはなりえない。
パスワードの先頭 2 文字を prefix
とおくと、sum_add
, sum_xor
は次のように求まる:
sum_add = ((prefix[0] ^ 0x1F) - 0x05) & 0x3F
sum_xor = ((prefix[1] ^ prefix[0]) - 0x19) & 0x3F
ここで bit0 のみに注目すると、以下のようになっている:
prefix[0] | prefix[1] | sum_add | sum_xor | invalid |
---|---|---|---|---|
0 | 0 | 0 | 1 | 1 |
0 | 1 | 0 | 0 | 0 |
1 | 0 | 1 | 0 | 1 |
1 | 1 | 1 | 1 | 0 |
よって、prefix[1]
が偶数ならば有効なパスワードにはなりえない。
パスワードとセーブデータの相互変換は以上の通りだが、一部のパスワード(「ふ」など)は変換結果のセーブデータに不正な値が含まれる。
セーブデータが不正でもロード自体はできるが、不正な装備はロード時にある種の(意図しない)変換がなされるので、これについて述べる。
まず、セーブデータ内の各部位の装備の値はアイテムIDそのものではなく、各部位に割り当てられたアイテムテーブル内のインデックスとなっている。
これらのインデックスは固定ビット長で記録されるが、アイテムテーブルは必ずしもサイズが 2 のべき乗になっていないため、不正なインデックスを与えるとテーブルの範囲外を参照してしまう。
各部位のアイテムテーブルは (部位インデックスのビット長、アイテムリスト) の形式で連続して並んでいる。これをダンプしたものを示す:
部位内インデックス | 内容 |
---|---|
兜:0 | 2 (兜インデックスのビット長) |
兜:1 | 14 (はちまき) |
兜:2 | 15 (はちがね) |
0 (兜の終端) | |
武器:0 | 4 (武器インデックスのビット長) |
武器:1 | 29 (ぼくとう) |
武器:2 | 30 (かたな) |
武器:3 | 31 (あすかのけん) |
武器:4 | 32 (すざくのけん) |
武器:5 | 33 (びゃっこのけん) |
武器:6 | 34 (ひりゅうのけん) |
武器:7 | 35 (あしゅらのけん) |
武器:8 | 36 (ほうおうのけん) |
武器:9 | 39 (オニのかなぼう) |
武器:10 | 41 (ゆうきのけん) |
0 (武器の終端) | |
鎧:0 | 4 (鎧インデックスのビット長) |
鎧:1 | 17 (たけのどう) |
鎧:2 | 18 (あかどう) |
鎧:3 | 19 (むつきのどう) |
鎧:4 | 20 (きさらぎのどう) |
鎧:5 | 21 (やよいのどう) |
鎧:6 | 22 (うづきのどう) |
鎧:7 | 23 (さつきのどう) |
鎧:8 | 24 (みなづきのどう) |
鎧:9 | 25 (ゆうきのどう) |
0 (鎧の終端) | |
靴:0 | 3 (靴インデックスのビット長) |
靴:1 | 8 (かんじき) |
靴:2 | 26 (ウサギのたび) |
靴:3 | 27 (シカのたび) |
靴:4 | 28 (シシのたび) |
0 (靴の終端) | |
いでたち0:0 | 2 (いでたち0インデックスのビット長) |
いでたち0:1 | 9 (じんばおり) |
いでたち0:2 | 10 (ツルのはおり) |
0 (いでたち0の終端) | |
いでたち1:0 | 2 (いでたち1インデックスのビット長) |
いでたち1:1 | 11 (カイロ) |
いでたち1:2 | 47 (タカのツメ) |
0 (いでたち1の終端) | |
いでたち2:0 | 1 (いでたち2インデックスのビット長) |
いでたち2:1 | 13 (おまもり) |
0 (いでたち2の終端) | |
いでたち3:0 | 1 (いでたち3インデックスのビット長) |
いでたち3:1 | 16 (てっこう) |
0 (いでたち3の終端) |
なお、各部位のインデックスが 0 の場合は無装備となる。
これを見ると、ほとんどのアイテムテーブルはサイズが 2 のべき乗になっていないことがわかる。
たとえば武器インデックスは 4bit だが、値を 15 にすると武器テーブルの範囲外、つまり鎧テーブル内の「むつきのどう」を参照してしまう。
このときの挙動だが、ロード時の装備復元処理は「アイテムIDを受け取り、それを装備可能な部位に装備する」という実装になっている(現在どの部位を復元しているかは見ていない)。
よって、テーブル範囲外を参照した場合、それが別部位に装備可能なアイテムIDならばそのまま別部位に装備される。
どの部位にも装備できないアイテムID(空欄を表す 0 など)は単に無視される。
これだと同部位に重複装備が起こりうるが、その場合は後から装備されたものが優先される。
(各部位は兜、武器、鎧、靴、いでたち0, いでたち1, いでたち2, いでたち3 の順に処理される)
例としてパスワード「ふ」(装備変換前セーブデータの全ビットが 1)の装備復元処理を示す: