ナムコクラシック (FC) 攻略/解析

パスワード

本作は 40 文字のパスワードを用いてゲーム状態のセーブ/ロードを行う。ゲーム内で生成される正規パスワードの場合、64 種の文字が使われるため、1 文字あたりの情報量は 6bit で、全体の情報量は 240bit となる。ただし、正規でない文字も考慮するとパスワードの情報量は 240bit よりわずかに多くなる(後述)。

パスワードは RAM $0280-$02A7 に置かれる (この領域は他にも複数の用途を持つ)。ゲーム状態とパスワードの相互変換は以下のルーチンで行われる (PRG バンクは 0x2000 バイト単位):

アドレス 内容
PRG 28 $8767 ゲーム状態をパスワードにセーブする。
PRG 28 $884C ゲーム状態をパスワードからロードすることを試みる。

筆者作のパスワードライブラリ namcoclassic1_password も適宜参照されたい。

ゲーム状態

パスワードには以下の情報が記録される ("bits" は正規パスワード内での情報量):

識別子 アドレス bits 内容
player_name $0200-$0205 36 プレイヤー名 (文字コード 6 個の配列)。
正規の文字インデックスは 0〜0x39。
パスワード上での文字インデックスの値域は 0〜0x3F で、値が不正でもロードは成功する。
golfer $050B 4 プレイヤーの選手ID。
正規の値は 0〜11 で、0:なかのしま, 1:しゃぼん, 2:あかき, 3:ぽぱい, 4:すきっぱら, 5:にたら〜す, 6:これすてろる, 7:あんた〜, 8:いえ〜すまん, 9:ありゃこ, 10:くい〜ん, 11:なむこ(主人公)。
パスワード上での値域は 0〜15 だが、値が不正だとロードは失敗する。
missing_clubs $0207-$020B 25 プレイヤーのクラブセット内に存在しないクラブID 5 個の配列。
正規のクラブIDは 0〜18 で、0:1W, 1:2W, 2:3W, 3:4W, 4:5W, 5:1I, 6:2I, 7:3I, 8:4I, 9:5I, 10:6I, 11:7I, 12:8I, 13:9I, 14:PW, 15:PS, 16:SW, 17:JG, 18:PT。
パスワード上での値域は 0〜31 で、値が不正でもロードは成功する。
stage $45 5 再開時の面インデックス。
正規の値は 1〜30 で、30 が最終戦のマッチプレイ(ナムコクラシック)。
パスワード上での値域は 0〜31 で、値が不正でもロードは成功する。
prizes $05CC-$05E3 156 各選手の獲得賞金 (符号なし 13bit, 万単位、選手ID順)。
ゲーム内では符号なし 16bit で扱われているため、値が 8192 以上の場合はセーブ時に情報が失われる (通常プレイではまず起こらないとは思われる)。
key 8 暗号鍵。
正規パスワードにおける値は 0〜0x3F。
パスワード上での値域は 0〜0x46 (正規でない文字を使うことで実現可能)。
通常は 6bit 値で、上位 2bit はパスワード内に重複して格納されるため、情報量は 8bit となる。ただし、正規でない文字も含めると情報量はこれよりわずかに多くなる。
checksum 6 チェックサム。
正規パスワードにおける値は 0〜0x3F。
パスワード上での値域は 0〜0x46 (正規でない文字を使うことで実現可能) だが、値が不正だとロードは必ず失敗する。

プレイヤー名

パスワードに記録されるのは文字コードそのものではなく、プレイヤー名の文字インデックス (正規の値は 0〜0x39) である。文字インデックスと文字の対応表を以下に示す ("SP" は空白の意):

x0x1x2x3x4x5x6x7x8x9xAxBxCxDxExF
0x
1x
2x
3xSP

文字インデックス 0x3A〜0x3F は不正だが、ロードは問題なく行える (表示はバグる)。

クラブセット

パスワードに記録されるのはクラブセットそのものではなく、「クラブセット内に存在しないクラブ 5 本」である (正規パスワードの場合、順序はクラブIDの降順)。

クラブの総数は 19 で、クラブセットの容量は 14 なので、クラブセットが満杯ならばこれで問題ないが、クラブセットに空きがある場合はセーブ時に情報が失われる。この場合、「クラブセット内に存在しないクラブ」はIDの昇順で 5 本だけ記録され、それ以外のクラブはロード時に復活する。たとえば、クラブセットから 1W, 2W, 3W, 4W, 5W, 1I を外している場合、1W〜5W のみがパスワードに記録され、ロード時に 1I が復活する。

不正なクラブIDを記録したパスワードも問題なくロードできる。不正なクラブIDはどのクラブとも一致しないため、「クラブセット内に存在しないクラブ」が 5 本より少なくなるが、この場合、「クラブセット内に存在するクラブ」はIDの降順で 14 本だけロードされ、それ以外のクラブは捨てられる。たとえば、不正なクラブIDのみが 5 個記録されている場合、1W〜5W を除いたクラブセットがロードされる。

パスワードには「再開時の面インデックス」が記録されるので、正規の値は 1〜30 となるが、値が不正でもロードは問題なく行える。値が 0 の場合は最初の面、31 の場合はバグ面となる。

パスワード文字

パスワードとして入力可能な文字は全部で 71 種あるが、正規パスワードに現れる文字は 64 種のみである。これらを「正規文字」、残りの 7 種 (T〜Z) を「代替文字」と呼ぶ。代替文字は基本的には対応する正規文字と同等に扱われるが、例外もある(後述)。

パスワードバッファ $0280-$02A7 に格納されるのは文字コードそのものではなく、パスワード文字インデックス (0〜0x46) である。文字インデックスと文字の対応表を以下に示す (代替文字は斜体で表示し、対応する正規文字を付記している):

x0x1x2x3x4x5x6x7x8x9xAxBxCxDxExF
0x
1x
2xABC
3xDEFGHIJKLMNOPQRS
4xT(あ)U(い)V(う)W(え)X(お)Y(か)Z(き)

なお、パスワード入力時に文字を空白のままにした場合、その文字は「あ」として扱われる。

パスワードへのセーブ処理

ゲーム状態を記録した長さ 40 のバイト列を「セーブデータ」と呼ぶ。セーブデータはそのままパスワード文字インデックスの配列として扱われるので、実質的にはパスワードと等価である。

セーブデータは主に可変長のチャンクから構成される。また、セーブデータ内のバイトは一部を除いて固定鍵によるスクランブル処理が施される。まずこれらについて説明し、最後にセーブデータの構造とセーブ処理の流れを示す。なお、実装の詳細については多少見通しよく整理した形で記述している (原作との等価性は保たれている)。

セーブデータチャンク

セーブデータチャンクは、同じビット幅 W を持つ値 6 個をひとまとめにした長さ W の 6bit 値配列であり、「暗号鍵に依存したバイト単位ローテート」と「ビット転置」により構築される。

暗号鍵 key, およびビット幅 W の値 6 個からなる配列 src が与えられたとき、セーブデータチャンク dst を構築する手順を示す。

まず src をバイト単位で key & 3 回左ローテートする。たとえば、key == 3, src == [0,1,2,3,4,5] ならば src = [3,4,5,0,1,2] とする。

そして、src を 6 行 W 列のビット行列と見て転置した結果が dst となる。W == 5 の場合の例を示す:

src[0]: abcde
src[1]: fghij
src[2]: klmno
src[3]: pqrst
src[4]: uvwxy
src[5]: zABCD

      ↓

dst[0]: afkpuz
dst[1]: bglqvA
dst[2]: chmrwB
dst[3]: dinsxC
dst[4]: ejotyD

セーブデータのスクランブル処理

セーブデータが構築された後、末尾 2 バイトを除いた各バイトは固定鍵を用いたスクランブル処理が施される。これは単なる mod 64 での加算で、たとえば Rust では以下のように書ける:

// 固定鍵。
const S: [u8; 38] = [
    0x11, 0x00, 0x23, 0x2A, 0x05, 0x24, 0x37, 0x2E, 0x39, 0x08, 0x0B, 0x32, 0x2D, 0x2C, 0x1F, 0x36,
    0x21, 0x10, 0x33, 0x3A, 0x15, 0x34, 0x07, 0x3E, 0x09, 0x18, 0x1B, 0x02, 0x3D, 0x3C, 0x2F, 0x06,
    0x31, 0x20, 0x03, 0x0A, 0x25, 0x04,
];

for i in 0..38 {
    savedata[i] = savedata[i].wrapping_add(S[i]) & 0x3F;
}

セーブデータの構造

各選手の獲得賞金 prizes について、下位 8bit のみからなる配列を prizes_lo, 上位 5bit のみからなる配列を prizes_hi とおく。

セーブデータの構造を以下に示す。オフセット 0-37 についてはスクランブル処理が施されていることに注意 ("offset", "len" はバイト単位。チャンクの場合、"len" は元の値たちのビット幅でもある):

offset len 内容
0 6 セーブデータチャンク。
player_name (長さ 6 の 6bit 値配列) を変換したもの。
6 5 セーブデータチャンク。
missing_clubs, stage を結合した長さ 6 の 5bit 値配列を変換したもの。
11 8 セーブデータチャンク。
prizes_lo[0..=5] (長さ 6 の 8bit 値配列) を変換したもの。
19 8 セーブデータチャンク。
prizes_lo[6..=11] (長さ 6 の 8bit 値配列) を変換したもの。
27 5 セーブデータチャンク。
prizes_hi[0..=5] (長さ 6 の 5bit 値配列) を変換したもの。
32 5 セーブデータチャンク。
prizes_hi[6..=11] (長さ 6 の 5bit 値配列) を変換したもの。
37 1 選手IDおよび暗号鍵の一部を格納した 6bit 値。
bit0-3: 選手ID
bit4-5: 暗号鍵の bit4-5 (ロード時には無視される)
bit6-7: 0
38 1 暗号鍵 (スクランブル処理なし)。
39 1 チェックサム (スクランブル処理なし)。
セーブデータ内の対象領域全てについてスクランブル処理を施した後、オフセット 0-38 について mod 64 で総和をとった結果が入る。

セーブ処理

まず乱数インデックス $37 の下位 6bit をそのまま暗号鍵とする。そして、上記の通りにセーブデータを構築する。

パスワードからのロード処理

ロード処理は基本的にはセーブ処理の逆を行うだけである。チェックサムが一致しないか、または選手IDの値が不正である場合、ロードは失敗する。

選手IDを検査している理由の推測 本作はゲーム状態の検査を行うつもりがなかったと思われるが、検査を一切行わないと空パスワード (チェックサム 0) でも通ってしまう。そこで、選手ID (空パスワードの場合 12 となる) だけは検査することにしたのだと思われる。

パスワードに代替文字を用いた場合、オフセット 0-37 については対応する正規文字と同等に扱われる (スクランブル解除処理において mod 64 の減算が行われるため)。オフセット 38 (暗号鍵) については代替文字インデックスがそのまま使われる。オフセット 39 (チェックサム) に代替文字を用いるとロードは必ず失敗する (保持するチェックサムが 0x40 以上になるため)。

なお、トーナメントモードではキャディが変わることがあるが、この情報はパスワードには直接記録されていない。キャディはラウンド開始時の「12 人目の選手」の獲得賞金によって変わる。「12 人目の選手」は、プレイヤーの選手が主人公ならば主人公自身、さもなくばラウンド開始時点で打数が最下位の選手(ランダム)である。獲得賞金とキャディの対応を以下に示す:

獲得賞金 (万) キャディID 画像
0〜499 0 キャディID 0
500〜999 1 キャディID 1
1000〜1999 2 キャディID 2
2000〜 3 キャディID 3

チェックサムの実装から推測されるように、あるパスワードがロード可能な場合、そのパスワードの文字を並べ替えたものも大抵はロード可能となる (チェックサム自体が変わったり、選手IDが不正になる場合はロード不可)。