東方紅魔郷スコアファイルの仕様まとめ

こんにちは！
N高等学校東方同好会構成員のえむけーと申します！
アドベントカレンダーということで、同好会活動の一環として調べようとしたけどイマイチ形にならなかった話題を記事にしたいと思います。

この記事はN高等学校アドベントカレンダー2018の2日めの記事です。

#東方紅魔郷スコアファイルってなに？

東方紅魔郷というのは、[上海アリス幻樂団](https://www16.big.or.jp/~zun/)というサークルが2002年に発表した同人ゲームです。同人ゲームというのはコミックマーケットとかで頒布している自主制作のゲームですね。この東方紅魔郷もコミックマーケットで頒布されました。
ゲーム内容は縦スクロールの弾幕シューティングです。敵が放つ弾幕を避けながら、自分もショットを撃って敵を倒すゲームです。
![WS000005.png](https://qiita-image-store.s3.amazonaws.com/0/127134/24e7b193-adc2-d462-1514-2c76e907af68.png)

上の画面にも写ってますが、シューティングゲームなのでハイスコアが記録されます。（右上の**最高得点**っていうところ）スコアの他にも後述するスペルカードの挑戦数と取得数も記録されます。
このように、ゲームを終了しても保存されていて、次にゲームを起動したときにも失われずに残っている各種データが `score.dat` というファイルに保存されています。
ファイルの場所はデフォルトで `C:\Program Files (x86)\東方紅魔郷` とか `C:\Program Files\東方紅魔郷` にあります。 
![WS000007.png](https://qiita-image-store.s3.amazonaws.com/0/127134/3b5a857e-6401-262b-cc7d-0009b106920c.png)

# スコアファイルの中身
前述の `score.dat` をバイナリエディタで開くと以下のようになっています。
![WS000008.png](https://qiita-image-store.s3.amazonaws.com/0/127134/4e6825ab-f976-f499-28b3-d8247c060bab.png)
なにがどうなってるのかさっぱりわからないですね。。。
ファイルの中身は全体を通して暗号化されています。テキストエディタとかで開いて簡単にスコアを修正されてしまうと、ゲームとしてよくないからだと思います。

## マスクを解除する
東方紅魔郷のスコアファイルは、ファイルの中身すべてに対し1バイト単位でマスクがかかっています。マスクの内容はこれまた1バイトごとに変わるのですが、とにかくデータを元通りにするための1バイトのマスク値を計算して、計算したマスク値と暗号化されたデータの排他的論理和を計算することで解除することができます。

```ruby
mask ^ data
```

##マスク値の計算
マスクの値を計算するのがややこしいです。
ファイルの先頭から1バイトずつマスクを解除していくのですが、「マスクを解除した後の1バイトデータ + 今回のマスク値」を計算し、そのデータの「上位3ビットと下位5ビットを入れ替えた値」が、次バイトのマスク値になります。

```ruby
mask = switch_bit(decripted + mask)

def switch_bit(byte)
  (byte >> 5) | (byte << 3)
end
```

<img width="858" alt="Untitled_1__New_Diagram_-_Cacoo.png" src="https://qiita-image-store.s3.amazonaws.com/0/127134/da6853cc-3d76-3df8-b136-87da791f4e2a.png">

##マスク解除後のデータ

マスク値を計算しながら1バイトずつデータとの排他的論理和を計算してファイルに出力したものが以下になります。
マスク解除前のデータと比べると、意味のありそうな単語がチラホラと見えるようになりました。
![WS000009.png](https://qiita-image-store.s3.amazonaws.com/0/127134/ee889826-965f-a0a0-3b44-4a21c12b142c.png)

さらに下の方を見てみると
![WS000010.png](https://qiita-image-store.s3.amazonaws.com/0/127134/4ad7d1bb-77c6-8895-80f8-42e55ed4d41a.png)
スペルカードの名前が見えます！
これはテンションあがりますね。

#マスク解除後のデータフォーマット
マスク解除後のデータはヘッダ部とボディ部に分けられます。ボディ部はさらに`TH6K` `HSCR` `CLRD` `CATK` `PSCR` という5種類のデータがそれぞれ複数存在する形式で構成されています。

|レコードの種類|サイズ(バイト)|
|:--|:--|
|ヘッダー|20|
|TH6K|12|
|HSCR|28|
|HSCR|28|
|HSCR|28|
|...||
|CLRD|24|
|CLRD|24|
|...||
|CATK|64|
|CATK|64|
|...||
|PSCR|20|
|PSCR|20|
|...||

##レコード種類の概要
5種類のレコード種類をざっくりと紹介します。詳細な仕様は後述します。

###TH6K
ボディ部全体のヘッダー的なレコードです（ヘッダー部とは別）。1レコードのみ存在し、特に意味のあるデータは含まれていません。

###HSCR
ハイスコアのデータです。
東方紅魔郷には自機が4種類あり、自機ごとの本編攻略時(+Extra)のスコアがそれぞれ上位10個まで保存されています。

![WS000011.png](https://qiita-image-store.s3.amazonaws.com/0/127134/7b07b4f4-f9c6-579c-8ca1-b0e325c1fab5.png)

###CLRD
クリア済の難易度・ステージを示すデータです。
東方紅魔郷の本編には`EASY` `NORMAL` `HARD` `LUNATIC`の難易度が存在します。また本編とは別におまけステージ的な`EXTRA`というステージが存在します。このステージは`NORMAL`以上の難易度を一度もコンティニューしないでクリアすると選べるようになります。さらにクリア有無は自機の種類ごとに管理されており、自機Aのみでノーコンティニュークリアを達成した場合、`EXTRA`ステージで選択できるのは自機Aのみとなり、BとCとDを選ぶことができません。
また、プラクティス（練習）モードというのが用意されており、ステージを選択して練習することができます。初期段階ではステージ1のみ練習可能で、ステージ2以降は本編でクリアしたら練習できるようになります。こちらはコンティニューしてクリアしても大丈夫。
この「EXTRAステージを選べるか否か」と「プラクティスで何面まで選べるようにするか」を判定するためのレコードです。

###CATK
東方紅魔郷にはスペルカードというシステムがあります。これは各ステージの中ボスやボスが放ってくる弾幕に名前をつけたもので、各ボスが複数持っている形です。スペルカードごとにボスの体力が設定されていて、開始から一度も被弾せず、かつボムを使わずに体力を削りきれば「スペルカードを取得した」ということになり、ボーナス点が入ります。
このスペルカードを取得した回数はゲーム内で記録されており、スコア画面から確認することができます。
![WS000012.png](https://qiita-image-store.s3.amazonaws.com/0/127134/29b2e66d-a581-2101-f1eb-555c5e60e35c.png)
東方紅魔郷では、スペルカードの挑戦数・取得数は自機の種類と難易度に関係なく記録されます。といっても難易度が変わると同じ場面で使ってくるスペルカードも変わったりするので、スペルカード自体が難易度を表している側面もあります。

###PSCR
プラクティス（練習）モードのスコアです。ステージごと・難易度ごと・自機の種類ごとに記録されます。
![WS000013.png](https://qiita-image-store.s3.amazonaws.com/0/127134/26090906-13e7-8077-c2f4-d7b057caa1d1.png)
*プラクティスあんまりやってないのがバレる…*

##レコード種類の詳細
ここからレコードごとの細かい仕様を書いていきます。
無機質な感じになるので実装するときにリファレンス的に参照してもらえればと思います。。。

###ヘッダー部
ヘッダー部はファイルの先頭から開始する長さ20バイトのレコードです。長さ20バイトと書きましたが、ヘッダー部のデータ長もデータの中に含まれています。データはJSON形式のテキスト…みたいなことにはなっておらず、サイズ固定の（主に）数値データが集まってできています。

私の`score.dat`をサンプルとして見てみると、先頭20バイトは以下のようになっています。

```
18 58 58 89 10 00 54 00  14 00 00 00 A8 2C 64 02
FC 17 00 00
```

|データの内容|データの型|データの長さ|データ例|
|:--|:--|--:|--:|
|不明||2|18 58|
|チェックサム|数値|2|58 89 (35,160)|
|バージョン|数値|2|10 00 (16)|
|不明||2|54 00|
|ヘッダー部の長さ|数値|4|14 00 00 00 (20)|
|不明||4|A8 2C 64 02|
|ファイル全体の長さ|数値|4|FC 17 00 00 (6,140)|

チェックサムは、スコアファイルの内容が改ざんされていないかチェックするための数値です。
具体的には、マスク解除した後のデータを先頭4バイトだけ除いてすべて整数にして足し、65,536で割った余りがチェックサムになります。ここの値と、実際に計算した値を比較して一致しなかったら「ファイルが改竄された可能性がある」ということでエラーにするのだと思います。

###ボディ部共通
ボディ部の各レコードは先頭8バイトまで全て共通したルールがあります。
先頭4バイトがレコードの種類(`HSCR`や`CLRD`など)で、次の2バイトがレコードの長さ（バイト数）です。さらに次の2バイトもレコードの長さです。同じ内容のデータが2回繰り返されています。

```:HSCRの例
48 53 43 52  1C 00 1C 00  10 00 00 00  D8 D6 7F 03
01 00 63 6D  6B 74 6F 68  6F 20 20 00
```

|データの内容|データの型|データの長さ|データ例|
|:--|:--|--:|:--|
|レコードの種類|文字列|4|48 53 43 52 (HSCR)|
|レコード長|数値|2|1C 00 (28)|
|レコード長|数値|2|1C 00 (28)|

 データを読み込む際は、以下のような処理を繰り返す感じになると思います。
1. 現在の位置から4バイト読み込んでレコードの種類を判別する
2. 更に2バイト読み込んでレコードの長さを取得する
3. 2で取得した長さだけ、最初の位置からファイルを読み込む
4. 1で取得したレコードの種類に応じてデータを処理する

###TH6K
ボディ部全体のヘッダー的なレコード。特にゲーム内容を記録しているデータはない（と思われる）

```:TH6Kレコード
54　48　36　4B 　0C　00　0C　00 　10　00　00　00
```

|データの内容|データの型|データの長さ|データ例|
|:--|:--|--:|:--|
|レコードの種類|文字列|4|54 48 36 4B (TH6K)|
|レコード長|数値|2|0C 00 (12)|
|レコード長|数値|2|0C 00 (12)|
|不明||4|10 00 00 00|

###HSCR
本編とEXTRAステージのハイスコア
登録されているスコアの数だけあります。

ゲーム開始時点では「Nanashi」という名前で100万点、90万点、、、というようにスコア画面に表示されているが、このスコアは`score.dat`に保存されていない。

```:HSCRレコード
48 53 43 52  1C 00 1C 00  10 00 00 00 14 DE 69 03
01 00 63 6D  6B 74 6F 68  6F 20 20 00
```

|データの内容|データの型|データの長さ|データ例|
|:--|:--|--:|:--|
|レコードの種類|文字列|4|48 53 43 52 (HSCR)|
|レコード長|数値|2|1C 00 (28)|
|レコード長|数値|2|1C 00 (28)|
|不明||4|10 00 00 00|
|スコア|数値|4|14 DE 69 03 (57,269,780)|
|自機|数値|1|01 (1 - 夢符霊夢)|
|難易度|数値|1|00 (0 - EASY)|
|進行度|数値|1|63 (99 - オールクリア)|
|名前|文字列|9|6D 6B 74 6F  68 6F 20 20  00 (mktoho  \0)|

スコアは4バイトの整数。

自機は0が霊符霊夢（ホーミング）、1が夢符霊夢（針）、2が魔符魔理沙（ミサイル）、3が恋符魔理沙（レーザー）

難易度は0がEASY、1がNORMAL、2がHARD、3がLUNATIC、4がEXTRA

進行度はゲームオーバーになったステージが1〜7の数値で保存される（7はEXTRAステージ）。オールクリアの場合は本編、EXTRAともに99。

名前はゲームオーバーまたはクリアしたときにつける名前。英数で8文字までつけることができる。9バイト目は終端文字列　(\0)

###CLRD
クリア済ステージを管理するレコード
自機の種類である4レコードあります。

```:CLRDレコード
43　4C　52　44　 18　00　18　00 　10　00　00　00　 63　63　03　01
06　63　63　63 　01　06　01　00
```

|データの内容|データの型|データの長さ|データ例|
|:--|:--|--:|:--|
|レコードの種類|文字列|4|43　4C　52　44 (CLRD)|
|レコード長|数値|2|18 00 (24)|
|レコード長|数値|2|18 00 (24)|
|不明||4|10 00 00 00|
|ノーコンティニュー到達ステージ(EASY)|数値|1|63 (99)|
|ノーコンティニュー到達ステージ(NORMAL)|数値|1|63 (99)|
|ノーコンティニュー到達ステージ(HARD)|数値|1|03 (3)|
|ノーコンティニュー到達ステージ(LUNATIC)|数値|1|01 (1)|
|ノーコンティニュー到達ステージ(EXTRA)|数値|1|06 (6)|
|コンティニュー有到達ステージ(EASY)|数値|1|63 (99)|
|コンティニュー有到達ステージ(NORMAL)|数値|1|63 (99)|
|コンティニュー有到達ステージ(HARD)|数値|1|63 (99)|
|コンティニュー有到達ステージ(LUNATIC)|数値|1|01 (1)|
|コンティニュー有到達ステージ(EXTRA)|数値|1|06 (6)|
|自機|数値|2|01 00 (1 - 夢符霊夢)|

コンティニューなしのクリア有無はEXTRAプレイ可否のために必要で、コンティニュー有の到達ステージはプラクティスのステージ開放のために必要なので分かれています。
EXTRAステージはコンティニューできないので、ノーコンティニューとコンティニュー有の値は常に同じになります。EXTRAステージはクリア済だと「6」になるようです。EXTRAステージを全ての自機でクリアしていたので、未クリアの場合にどんな値が入るのかがわかりませんでした。（自慢）

###CATK
スペルカードの挑戦数取得数を管理するレコード
スペルカードの数である64レコードあります。

```:CATKレコード
43 41 54 4B  40 00 40 00  10 8D 46 DA  60 AE 0A 00
3E 00 A3 01  24 2D 36 28  94 E9 92 65  81 75 82 BB
82 B5 82 C4  92 4E 82 E0  82 A2 82 C8  82 AD 82 C8
82 E9 82 A9  81 48 81 76  00 74 91 F4  39 00 01 00
```

|データの内容|データの型|データの長さ|データ例|
|:--|:--|--:|:--|
|レコードの種類|文字列|4|43 41 54 4B (CATK)|
|レコード長|数値|2|40 00 (64)|
|レコード長|数値|2|40 00 (64)|
|不明||8|略|
|スペルカードID|数値|2|3E 00 (62)|
|不明||6|略|
|スペルカード名|文字列|36|秘弾「そして誰もいなくなるか？」|
|挑戦数|数値|2|39 00 (57)|
|取得数|数値|2|01 00 (1)|

スペルカードIDは0から始まって63まであります。ゲーム画面では01から始まるので、1ずつずれています。

スペルカード名は`Windows-31J`の文字コードで36バイトの固定長で保存されています。開始位置から終端文字(00)が出てくるまで読み込むとスペルカード名になります。

###PSCR
プラクティスのスコアを管理するレコードです。
練習モードで練習した難易度、自機、ステージの数だけレコードがあります。

```:PSCR
50 53 43 52  14 00 14 00  10 00 00 00  CA 66 24 00
01 00 00 00
```

|データの内容|データの型|データの長さ|データ例|
|:--|:--|--:|:--|
|レコードの種類|文字列|4|43 41 54 4B (PSCR)|
|レコード長|数値|2|14 00 (20)|
|レコード長|数値|2|14 00 (20)|
|不明||4|10 00 00 00|
|スコア|数値|4|CA 66 24 00 (2,385,610)|
|自機|数値|1|01 (1 - 夢符霊夢)|
|難易度|数値|1|00 (0 - EASY)|
|ステージ|数値|2|00 00 (0 - ステージ1)|

#この記事の目的
この記事はスコアファイルの中身を見てハイスコアを改ざんしてワーイってするのが目的ではないです。。。
東方紅魔郷を始めとする東方Projectのシューティングゲームは、オンライン要素のない完全なオフラインゲームなので、イマドキのゲームに比べると友だちと交流する要素がないです。
私はありがたいことに学校の東方同好会で他のシューターの人と交流する機会があるのですが、友だちが「地霊殿のHARDモードをクリアしたー！」って言ってるのを見ると自分のモチベーションにもつながりますし、自分がクリアしたときに他の人に「おめでとー！」って言ってもらえるとすごく嬉しいので、そういう他シューターとの交流というのがゲームを活性化するすごく重要な要素だと思います。
PS4のトロフィーのように、フレンドがゲームをクリアしたり実績を解除したことがほんのりと伝わるように、東方紅魔郷でも難しいモードをクリアしたときにツイッターやマストドン、DiscordなどのSNSと連携して自動的にクリア報告ができるようなツールがあったらいいなあーと思いました。
私がひとりで作るのは大変ですが、（いつか挑戦してみたいですが、）スコアファイルの仕様をわかりやすく書いておけば、他の誰かが見つけて実装してくれるかもしれないし、私が欲しいものとは別の目的にでも役立てるかもしれないと思いました。

あまり整ってないですが、ファイルのロード処理を書いたリポジトリがあるのでリンクを置いておきます
https://github.com/mktoho12/toho-score

#宣伝
N高等学校の生徒でこの記事を最後まで読んでくれた方は、きっと東方Projectに興味があると思うので、ぜひN高の東方同好会に遊びに来てください！顧問の先生も募集しています！！
Slackのチャンネルは「#toho_daisuki」、Twitterは [@toho_daisuki_n](https://twitter.com/toho_daisuki_n) です！