HPI / UFO / CCX / GP3 — HAPIアーカイブファイル
HAPI(「HPI」)は、Cavedogがゲームのアセットをすべて配布するのに使った、暗号化・圧縮済みのコンテナです。ディスク上の同じレイアウトが、拡張子を変えて使い回されています。
拡張子 用途 TA 3.1cでのマウント順 .gp33.1パッチのオーバーレイ( rev31.gp3)最初にマウント。ほかの *.gp3は無視される.ccxCore Contingency / Battle Tacticsのデータ( btdata.ccx、ccdata.ccx)rev31.gp3の次.ufoユニット・地形物のアドオン( AFark.ufo)すべての .ccxの後。数の制限なし.hpiベースゲームのアーカイブ( totala1.hpi、tactics1.hpi)最後。開けたもののうち最初の10個だけ 4つともバイト単位で同一の形式で、違うのはマウント順だけです。ゲームは起動時にこれらを1つの仮想ファイルシステムにまとめ、あるパスを持つアーカイブのうち、最初にマウントされたものが勝ちます。
Important
ディスク上の形式は2種類あります。Total AnnihilationはHPI v1(Version == 0x00010000)を書き出します。このページが最初から最後まで解説している、暗号化されたマルチチャンクのレイアウトです。Total Annihilation: KingdomsはHPI v2(Version == 0x00020000)を書き出します。こちらはXOR暗号がなく、ファイルまたはディレクトリのブロックごとにSQSHチャンクが1つだけという、別の、より単純なレイアウトです。v2の詳細は、このページの終わり近くにある専用の節TA: Kingdoms HPI v2にまとめています。TA 3.1cがマウントするのはv1のアーカイブだけです。
読み込み順によるレイヤー構造
TA 3.1cはゲームディレクトリの最上位だけを走査し(
Backup/のようなサブフォルダー内のアーカイブはマウントされません)、次の順にマウントします。
rev31.gp3- すべての
*.ccx- すべての
*.ufo- 開けた
*.hpiのうち最初の10個- ゲームCDの最上位にある
*.hpiファイル(数の制限なし)各グループの中では、ゲームは名前を大文字に変換したASCIIの順で取り出します(ディレクトリの一覧が返す順で、
_は英字の後に並び、AFark.ufoはzeta.ufoより前に来ます)。units/ARMCOM.fbiのようなパスを解決するときは、インストール先にある単体のファイルが勝ち、次にそのパスを持つ、最初にマウントされたアーカイブが使われます。アーカイブがマウント済みとみなされるのは、Cavedogの著作権表示のトレーラー(トレーラーを参照)で終わり、ディレクトリを読み取れるバージョン1のアーカイブである場合だけです。それ以外のファイルは飛ばされ、10個ある*.hpiの枠を消費しません。Query: units/ARMCOM.fbi priority ↑ loose files in the install root if found → use this │ ┌─────────────────────────────────────┐ │ │ rev31.gp3 (3.1 patch) │ else fall through │ ├─────────────────────────────────────┤ │ │ *.ccx in name order │ else fall through │ ├─────────────────────────────────────┤ │ │ *.ufo in name order │ else fall through │ ├─────────────────────────────────────┤ │ │ first ten *.hpi in name order │ else fall through │ ├─────────────────────────────────────┤ │ │ *.hpi on the disc (no limit) │ else fall through │ └─────────────────────────────────────┘ priority ↓ "not found"GOG版では、10個の
*.hpiの枠はtactics1〜8.hpi、totala1.hpi、totala2.hpiで埋まります。totala3.hpi、totala4.hpi(製品版のマップ)とworlds.hpi(マップのセクション)はディスクのアーカイブです。GOG版のインストールではこれらをゲームディレクトリに置いており、そこがディスクの代わりになるので、ゲームはこれらをほかのすべてのアーカイブの後にマウントします。MODが追加する*.hpiも同様で、名前の順が10番目の枠より後になるものは、このディスクの走査でしか読まれず、ほかのすべてより下に置かれます。これを避けるには、MODを.ufoとして配布してください(数の制限がなく、どの.hpiよりも上に来ます)。kbotはTotal Annihilationのコンテキストを、どこでも同じ方法でマウントします。スタジオ、
kbot mount、kbot mount flatten、kbot pack、kbot tnt、MCPサーバーのいずれも同じです。FilesタブのHomeページとLayeringタブ、kbot mountのarchivesとstatsコマンド、MCPのvfs_game_dataツールは、マウント順と、マウントされなかったすべてのアーカイブをその理由とともに一覧表示します。TA: Kingdomsのコンテキストでは、kbot独自のオーバーレイ順(すべてのアーカイブを拡張子と名前の順に並べ、後のものが前のものを上書きする)を保ちます。このゲームの、data.hpiの上にIPData.hpiを重ねるレイヤー構造にはこの順が必要だからです。「単体のファイルが勝つ」という規則があるからこそ、
kbot mount flattenが作るディレクトリは、レイヤー構造を持つアーカイブ一式とまったく同じようにふるまいます。エンジンがアーカイブの積み重ねを通じて解決したはずのファイルはすべて、同じパスに単体のファイルとして存在するので、解決は積み重ねの最上段で打ち切られます。
どの定義が勝つか
地形物、武器、ユニットは、ディレクトリを一覧して読み込まれます。対象は
features/**/*.tdf(サブフォルダーを含む)、weapons/*.tdf、units/*.fbi(最上位のみ。weapons/old/のようなサブフォルダーは読まれません)です。一覧はまず単体のファイル、次にマウントされた各アーカイブをマウント順に、そのエントリを格納順にたどり、ある名前の最初の定義が勝ちます。後の定義は無視されます。製品版のCarScar05はfeatures/urban/cars2.tdfとcars.tdfの両方で定義されていて、cars2.tdfのほうが先に格納されているので、ゲームはこれをanims/cars2.gafから描画します。スタジオ(地形物パレット、サンドボックス、パック、ユニットと武器の一覧)、kbot tnt previewとkbot tnt lint、MCPツールも同じ規則を適用します。
概要
┌─── 20-byte header (clear) ───┐ ┌─── encrypted directory ───┐ ┌─── encrypted file chunks ───┐
│ "HAPI" version dirSize ... │ │ tree of names + offsets │ │ SQSH chunks, ≤64 KiB each │
└──────────────────────────────┘ └───────────────────────────┘ └─────────────────────────────┘
▲
trailer (required by TA 3.1c):
"Copyright 1997 Cavedog Entertainment"
HPIを読む流れは次のとおりです。
- 20バイトのヘッダーを読み、
"HAPI"マジックを確認します。 DecryptKeyから、そのファイル用のXOR鍵を導きます。- ディレクトリツリーを復号しながら読みます。
- 必要なファイルごとに、その圧縮チャンクへシークして復号し、
CompMethod(LZ77、ZLib、無圧縮)に応じて処理を振り分けます。
Tip
パーサーを書かずにHPIの中身を調べる。
kbot hpi info totala1.hpi # header, counts, "TA 3.1c would mount: yes/no"
kbot hpi list totala1.hpi -v # files, sizes, compression
kbot hpi list totala1.hpi -p "*.bos"
kbot hpi extract totala1.hpi -p "units/ARMCOM.fbi" -t ./out
すべてのフラグについては、CLIのkbot hpiリファレンスを参照してください。
Goから使う場合はformats/hpiを使います。
import (
"io"
"github.com/coreprime/kbot/formats/hpi"
)
r, _ := hpi.OpenReader("totala1.hpi")
defer r.Close()
_ = r.Walk(func(e *hpi.Entry) error {
if !e.IsDir { fmt.Println(e.FullPath(), e.Size) }
return nil
})
rc, _ := r.Open("units/ARMCOM.fbi")
defer rc.Close()
data, _ := io.ReadAll(rc) // decompressed bytes
ヘッダー(20バイト、平文)
typedef struct {
uint32 Marker; // 'HAPI' = 0x49504148, "BANK" if save-game
uint32 Version; // Usually 0x00010000
uint32 DirectorySize; // Total directory size in bytes (includes header)
uint32 DecryptKey; // Seed for the XOR cipher (0 = no encryption)
uint32 Start; // File offset of the directory (almost always 0x14)
} HPIHeader;
| フィールド | 備考 |
|---|---|
Marker |
リトルエンディアンの'HAPI'。'BANK'の変種はセーブデータで使われ、ここでは扱いません。 |
Version |
製品版のTotal Annihilationのアーカイブは0x00010000(v1、この節)を使います。TA: Kingdomsのアーカイブは0x00020000(v2)を使います。TA: Kingdoms HPI v2を参照してください。 |
DirectorySize |
20バイトのヘッダーを含むので、実際のディレクトリのブロブはStartから始まるDirectorySize − Startバイトを占めます。 |
DecryptKey |
下記の変換を通して、XOR鍵として使うバイトを得ます。鍵のバイトが0と0xFFのどちらでも、TA 3.1cにとっては「暗号化なし」を意味します。Cavedogの製品版のアーカイブは0xBFを使います。 |
Start |
ディレクトリの開始位置。確認されたファイルでは常に0x14です。 |
暗号鍵の導出
key = (DecryptKey << 2) | (DecryptKey >> 6) // rotate-left by 2 bits
暗号化された各バイトは、ファイル内の絶対位置を使って元に戻します。
plain[i] = cipher[i] ^ (uint8(pos + i) ^ key)
ここでposは、ファイル内でのcipher[0]の絶対オフセットです。そう、シードはファイル内の位置とともに進みます。そのため、バッファーを単独で復号することはできず、それがどこから来たかを知っている必要があります。
Important
鍵のバイトが0か0xFFなら、暗号はまったく適用しないでください。TA 3.1cはどちらも平文として読みます(0xFFの場合、上の回転では0xFFになりますが、ゲームは復号しません)。一部の自作アーカイブ(といくつかのコミュニティMOD)は暗号化なしで書かれています。その場合にXOR暗号を適用すると、もともと平文のものをかき混ぜてしまいます。kbot hpi packが--key 255を拒否するのはこのためです。--key 0を使ってください。
ディレクトリツリー
ディレクトリは、エントリの数と、最初のエントリへのポインターの組です。
typedef struct { uint32 EntryCount; uint32 EntriesOffset; } HPIDir;
各エントリはちょうど9バイトです。
typedef struct {
uint32 NameOffset; // → NUL-terminated string
uint32 DataOffset; // → HPIDir (subdir) or HPIFile (leaf)
uint8 Flag; // 1 = subdirectory, 0 = file
} HPIEntry;
ファイルの場合、DataOffsetは次の構造体を指します。
typedef struct {
uint32 DataOffset; // Absolute file offset of the first compressed chunk
uint32 FileSize; // **Decompressed** size in bytes
uint8 CompMethod; // 0 = stored, 1 = LZ77, 2 = ZLib
} HPIFile;
オフセットはすべてファイル内の絶対位置です。そのため定番のパーサーは、DirectorySizeバイトのバッファーを確保し、位置Startにディレクトリを書き込みます。こうすると、ポインターで計算なしにバッファーを直接参照できます。
ディレクトリの読み込み
buf = bytearray(header.DirectorySize)
file.seek(header.Start)
encrypted = file.read(header.DirectorySize - header.Start)
buf[header.Start:] = decrypt(encrypted, key, seed=header.Start)
walk(buf, header.Start) # recurses on Flag == 1 entries
Note
ファイル名の大文字・小文字は、書き込み時には保たれ、読み込み時には区別せずに照合されます。Total AnnihilationはWindows 9xの時代に生まれたゲームで、MODでは同じアーカイブの中でさえarmcom.fbiとARMCOM.FBIが混在するのはよくあることです。ゲームはASCIIの英字についてだけ大文字・小文字をそろえ、パスを\と/のどちらでも区切ります。1つのディレクトリに、大文字・小文字だけが違う名前のエントリが複数あると、その最後のものを読み、ほかのものには到達できません。また、.と..も普通の名前として扱います。そのためkbot hpi extractは、そうしたグループのうち到達できるエントリだけを書き出し、../../x.txtのように、格納されたままのパスでは出力先フォルダーの中に書き込めないエントリは(メッセージを出して)飛ばします。kbot mount flattenも出力先の外に書き込むことはありません。
圧縮チャンク(SQSH)
ファイルのデータは、展開後で最大64 KiBのチャンクに分割されます。各チャンクには19バイトの独自のヘッダーがあります。
typedef struct {
uint32 Marker; // 'SQSH' = 0x48535153
uint8 Version; // Always 0x02 in observed files
uint8 CompMethod; // 1 = LZ77, 2 = ZLib
uint8 Encrypted; // 1 = chunk body is XOR-scrambled, 0 = plaintext
uint32 CompressedSize; // Length of the encrypted+compressed payload
uint32 DecompressedSize; // Length after CompMethod is undone
uint32 Checksum; // Sum of the encrypted payload bytes (uint32)
uint8 data[CompressedSize];
} HPIChunk;
チャンクの数はceil(FileSize / 65536)です。ファイルの最初のHPIFile.DataOffsetの直後には、チャンクごとに1つずつのuint32の圧縮サイズの配列があり、その後にチャンク本体が隙間なく続きます。
2つ目のXOR層
Encrypted == 1なら、各チャンクの本体には2回目のXOR処理がかかっています。
for (i = 0; i < CompressedSize; i++)
data[i] = (data[i] - i) ^ i;
チェックサムは、まだ暗号化されたままのバイトに対して計算されることに注意してください。暗号を解く前に検証します。
LZ77(CompMethod = 1)
4 KiBの履歴を持つ、教科書どおりのスライド窓式LZ77です。
- タグバイト — 8個のフラグビットで、LSBファーストです。
0⇒ リテラル、1⇒ 後方参照。 - リテラル — 1バイトをそのまま出力と履歴にコピーします。
- 一致(マッチ) —
uint16を読みます。上位12ビットが窓内のオフセット(1〜4095)、下位4ビットが長さをlen − 2として表します(つまり一致の長さは2〜17バイト)。 - 終端 —
windowOffset == 0の一致でストリームが終わります。
ZLib(CompMethod = 2)
標準のzlibの生ストリームです。暗号の層を取り除いた後、チャンクの本体をinflate()に渡します。DecompressedSizeが期待される長さです。
無圧縮(CompMethod = 0)
バイトはそのまま書かれます。チャンク分割も、チェックサムも、2回目のXOR処理もありません。圧縮が効かないファイル(圧縮済みのPCXやGAF)に便利で、旧来の「unit viewer」が理解できる唯一のモードでもあります。
トレーラー
ゲームがマウントするアーカイブはすべて、36バイトのASCIIの著作権表示の行で終わります。
Copyright 1997 Cavedog Entertainment
ヘッダーのどこからも指されていませんが、これは省略できません。TA 3.1cはアーカイブをマウントする前に、最後の36バイトがCopyright ____ Cavedog Entertainmentになっているかを確認します(年の4文字は比較しません)。これがないアーカイブや、その後ろに何かのバイトが付け足されたアーカイブは飛ばされ、10個ある*.hpiの枠も消費しません。トレーラーなしでまとめたMODは、ゲームの中では黙って存在しないことになります。
kbotが書き出すアーカイブはすべてこれを保ちます。kbot hpi packも、スタジオのエクスポート(ワークスペースのExport modと、マップエディターのアーカイブのダウンロード)も同じです。kbot hpi info、kbot hpi list -v、MCPのhpi_infoとhpi_listツール、Filesタブのアーカイブのメタデータは、TA 3.1c would mount: yes / no, <reason>という行を表示します。
TA: Kingdoms — HPI v2
Note
この節はTA:Kだけの差分です。ここまでの内容はTotal Annihilation(Version == 0x00010000)に当てはまります。TA: Kingdomsは同じ.hpi拡張子を使いますが、ディスク上のレイアウトが異なります。v1のパーサーに手を伸ばす前に、この節を最後まで読んでください。
TA: Kingdomsは'HAPI'マジックとディレクトリツリーという考え方を残していますが、それ以外は外側の構造を全面的に作り直しています。違いをひと息にまとめると次のとおりです。
| 項目 | v1(TA) | v2(TA: Kingdoms) |
|---|---|---|
| バージョンフィールド | 0x00010000 |
0x00020000 |
| ヘッダーのサイズ | 20バイト(5 × uint32) | 32バイト(マジックとバージョンの8バイト + 24バイトのサブヘッダー) |
| 暗号化 | DecryptKeyをシードとするXOR暗号。ファイル内の位置とともに進む |
なし — すべて平文 |
| ディレクトリと名前プールの位置 | ヘッダーの直後(暗号化あり) | ファイル内の任意のオフセット(通常は末尾近く)。それぞれ単一のSQSHチャンクで包まれていることがある |
| ファイルごとのチャンク分割 | 長さのテーブル付きで、64 KiB以下のチャンクに分割。各チャンクが独自のSQSHヘッダー、チェックサム、省略可能な2回目のXOR処理を持つ | ペイロード全体を収めた単一のSQSHチャンク、またはCompressedSize == 0なら無圧縮のバイト。XOR処理も長さのテーブルもなし |
チャンクごとの暗号(SQSHのEncryptedバイト) |
使われる | 使われない(このバイトがセットされていれば尊重するが、調べたTAKのファイルではどれも0) |
v2のヘッダー
8バイトのプロローグ('HAPI' + 0x00020000)の後に、32ビットのリトルエンディアンのフィールドが6つ続きます。ディスク上のヘッダーは全体で32バイトです。
// At offset 0 — same as v1.
typedef struct { uint32 Marker; uint32 Version; } HPIPrologue;
// At offset 8 — TA: Kingdoms only.
typedef struct {
int32 DirectoryBlock; // Absolute offset of the directory blob
int32 DirectorySize; // Length of the directory blob on disk
int32 NameBlock; // Absolute offset of the string pool
int32 NameSize; // Length of the string pool on disk
int32 Data; // Offset of the first file payload (typically 0x20)
int32 Last78; // 0, or an offset to a trailing 78-byte block
} HPIHeaderV2;
ディレクトリのブロブと名前のブロブは、どちらもSQSHで圧縮されていることがあります。DirectoryBlock / NameBlockにある最初の4バイトが'SQSH'なら、そのブロックを単一のチャンク(ヘッダー + ペイロード)として扱って展開します。そうでなければ、指定されたオフセットにあるSizeバイトが、展開済みの生の内容です。
v2のディレクトリエントリ
展開したディレクトリのブロブは、2種類のレコードを詰めて並べた配列です(独立したオフセットテーブルも、EntryCountの前置きもありません。子は親のFirstSubDirectory / FirstFileポインターでたどります)。
typedef struct { // 20 bytes — HpiDir2
int32 NamePtr; // Offset into NameBlock (NUL-terminated string)
int32 FirstSubDirectory; // Byte offset into the directory blob
int32 SubCount; // Number of subdirectories
int32 FirstFile; // Byte offset into the directory blob
int32 FileCount; // Number of files
} HpiDir2;
typedef struct { // 24 bytes — HpiEntry2
int32 NamePtr; // Offset into NameBlock
int32 Start; // Absolute file offset of the payload
int32 DecompressedSize;
int32 CompressedSize; // 0 ⇒ payload is stored verbatim, no SQSH wrapper
int32 Date; // time_t when packed (informational)
int32 Checksum; // Informational — the SQSH chunk carries its own
} HpiEntry2;
ファイルごとのペイロード
オフセットStartにあるファイルエントリについて:
CompressedSize == 0なら、DecompressedSizeバイトをそのまま読みます。- それ以外なら、続く
CompressedSizeバイトが単一のSQSHチャンク(v1と同じ19バイトのヘッダー)です。これを読み、ペイロードのバイトの合計とチェックサムを照合し、チャンクのCompMethod(1= LZ77、2= ZLib)でデコードすれば、ファイルが得られます。
v1のマルチチャンク方式(サイズのテーブル、64 KiB以下のチャンク、暗号化の処理)はなくなりました。v2はコンパクトさと引き換えに、はるかに単純な読み込み処理を手に入れています。
kbotでの扱い
kbot hpi list、extract、infoはどれも、両方の方言に透過的に対応しています。ヘッダーのVersionワードで処理を振り分けます。ラウンドトリップの書き出し(kbot hpi pack)は現在v1だけを出力し、v2のアーカイブはまだ作成できません。
内部のv2リーダーはformats/hpi/hpi_v2.goにあります。hpi.Reader.Version()は、読み込んだファイルがv1かv2かを報告します。
Tip
TAKのインストールが読み込めることを確認する。
kbot hpi list ~/games/takingdoms/data.hpi -v | head
kbot ctx add ~/games/takingdoms --alias tak --game takingdoms
kbot mount # walks every TAK .hpi as a single VFS
実例 — aflakker.ufo
Joe D.による元のウォークスルーは、ARMのフラッカーのユニットアドオンを使っていました。ここでは、そこで分解されたヘッダーを再現します。最初の32バイトの16進ダンプは次のとおりです。
00000000 48 41 50 49 00 00 01 00 24 02 00 00 7D 00 00 00 HAPI....$...}...
00000010 14 00 00 00 ... ...
フィールドごとに見ると次のとおりです。
| オフセット | バイト | フィールド | 値 |
|---|---|---|---|
0x00 |
48 41 50 49 |
Marker |
'HAPI' ✓ |
0x04 |
00 00 01 00 |
Version |
0x00010000 ✓ |
0x08 |
24 02 00 00 |
DirectorySize |
0x224 = 548バイト |
0x0C |
7D 00 00 00 |
DecryptKey |
0x7D → key = (0x7D << 2) | (0x7D >> 6) = 0xF5 |
0x10 |
14 00 00 00 |
Start |
0x14 |
[0x14, 0x224)を復号してツリーをたどると、次のようになります。
.
├── anims/
│ └── armflak_gadget.gaf
├── download/
│ └── ARMFLAK.TDF
├── features/corpses/
│ └── armflak_dead.tdf
├── objects3d/
│ ├── armflak.3do
│ └── armflak_dead.3do
├── scripts/
│ └── ARMFLAK.COB
├── unitpics/
│ └── ARMFLAK.PCX
├── units/
│ └── ARMFLAK.FBI
└── weapons/
└── armflak_weapon.tdf
この9ファイルの構成が、ユニット1体の.ufoアドオンの標準的な形です。ベースゲームのtotala*.hpiアーカイブも、同じ方式を大規模に(約30kファイル)使っています。
Tip
この分解を自分のマシンで再現する。
kbot hpi info path/to/aflakker.ufo
kbot hpi list path/to/aflakker.ufo -v
kbot hpi extract path/to/aflakker.ufo -t ./aflakker-out
アーカイブの作り直し
ディレクトリツリーから新しいアーカイブを作るには、次のようにします。
kbot hpi pack ./aflakker-out --target aflakker-new.ufo
kbotの既定はLZ77のチャンク圧縮で、ヘッダーの鍵は標準の0xBF、Cavedogのトレーラー付きです。これで製品版のエンジンが受け付けるバイナリができます。各フラグも、この状態を崩さないようになっています。
| フラグ | 効果 |
|---|---|
--method lz77|zlib|none |
チャンクの圧縮方式。noneは無圧縮のエントリを書き出します。 |
--key N |
ヘッダーの鍵のバイト。0で暗号化なしのアーカイブを書き出します。255は拒否されます。ゲームがこれを「暗号化なし」と読むためです。 |
--trailer "Copyright 1998 Cavedog Entertainment" |
トレーラーの年を変えます。それ以外のテキストは拒否されます。 |
--no-trailer |
トレーラーを省きます。ゲームは出来上がったアーカイブをマウントしません(警告が表示されます)。 |
--format v2 |
TA: Kingdomsのアーカイブを書き出します(--key、--trailer、--no-trailer、--encode-chunksは適用されません)。 |
大文字・小文字だけが違うディレクトリ名(大文字・小文字を区別するディスク上のUnits/とunits/)は、ゲームが名前を比較するのと同じ方法でまとめられます。パスが大文字・小文字だけ違う2つのファイルはどちらも格納されますが、ゲームが読むのは最後にまとめられたほうで、packは警告を表示します。
注意点
Warning
ポインターはディレクトリの先頭ではなく、ファイルの先頭からの絶対位置です。何も引かないでください。標準的な実装の工夫は、DirectorySizeバイトのバッファーを確保し、復号したディレクトリをオフセットStartの位置に読み込むことです。こうすれば、ポインターでバッファーを直接参照できます。
- チャンク数の式 —
ceil(FileSize / 65536)。数を明示するフィールドはありません。FileSizeから導く必要があります。
典型的なサイズ
| アーカイブ | サイズ | ファイル数 | 備考 |
|---|---|---|---|
totala1.hpi(サウンド、アニメーション) |
約30 MB | 約1900 | ベースゲーム。単体では最大のアーカイブ。 |
totala2.hpi(ユニット) |
約13 MB | 約600 | ユニットのメタデータとスクリプトの大半。 |
totala4.hpi(マップ) |
約50 MB | 約300 | 製品版のマップすべて。 |
worlds.hpi(セクション) |
約20 MB | 約5000 | エディター用のSCTライブラリ。 |
ccdata.ccx(Core Contingency) |
約25 MB | 約1500 | 拡張パックのユニットとマップ。 |
一般的なユニット1体の.ufo |
10〜150 KB | 8〜12 | サードパーティのユニットアドオンの大半。 |
マップだけの.ufo(.tnt + .ota入り) |
0.5〜5 MB | 2〜4 | マップごとの配布。 |
| 圧縮率 | 35〜55% | — | ほとんどのファイルはLZ77、最大級のものはZLib。 |
- 圧縮すると大きくなるチャンクもあります。LZ77が一致を見つけられなかった場合、「圧縮された」チャンクが64 KiBより大きくなることがあります。コピーする長さとしては
CompressedSizeを正としてください。 - チェックサムは暗号化されたバイトに対するものです。先に復号すると、チェックサムの検証に失敗します。
- セーブデータ(
BANKマーカー)は別物です。HPIの外側の構造を流用していますが、エンコードされた中身はファイルシステムではなく、シリアライズされたゲームの状態です。kbotはこれをデコードしません。 .gp3を読み込むにはrev3.1パッチが必要です。中身はやはりHPIですが、パッチを当てていないインストールでは、エンジンがマウントを拒否します。
付録 — AFark.ufoの16進ダンプを完全にたどる
AFark.ufoはARMのFARKのアドオンです。9ファイルだけの小さなアーカイブなので、端から端まで実演するのに最適です。ヘッダーをたどり、ディレクトリを復号し、いくつかのエントリを分解してから、圧縮チャンクの先頭のバイトをのぞいてみます。
1. ヘッダー(平文)
生のバイト0x00..0x14:
00000000: 48 41 50 49 00 00 01 00 24 02 00 00 a6 00 00 00 HAPI....$.......
00000010: 14 00 00 00 ....
デコード結果:
| オフセット | バイト | フィールド | 値 |
|---|---|---|---|
0x00 |
48 41 50 49 |
Marker |
'HAPI' ✓ |
0x04 |
00 00 01 00 |
Version |
0x00010000 |
0x08 |
24 02 00 00 |
DirectorySize |
0x224 = 548バイト |
0x0C |
a6 00 00 00 |
DecryptKey |
バイト0xA6 |
0x10 |
14 00 00 00 |
Start |
0x14 |
暗号鍵を導きます。
key = (0xA6 << 2) | (0xA6 >> 6)
= 0x98 | 0x02
= 0x9A
2. 復号したディレクトリ(オフセット0x14..0x224)
ディレクトリのブロブにplain[i] = cipher[i] ^ (uint8(0x14 + i) ^ 0x9A)を適用し、ポインターが正しく参照できるよう548バイトのバッファーのオフセット0x14に入れると、最初の96バイトは次のようになります。
00000010 08 00 00 00 1c 00 00 00 64 00 00 00
^^^^^^^^^^^ ^^^^^^^^^^^
00000020 6a 00 00 00 01 97 00 00 00 a0 00 00 00 01 c6 00
00000030 00 00 cf 00 00 00 01 13 01 00 00 1d 01 00 00 01
00000040 66 01 00 00 6e 01 00 00 01 94 01 00 00 9d 01 00
00000050 00 01 c3 01 00 00 c9 01 00 00 01 ef 01 00 00 f7
00000060 01 00 00 01 61 6e 69 6d 73 00 01 00 00 00 72 00
^^^^^^^^^^^^^^^^^ "anims"
0x14にあるのは、0x00000008、すなわちルートのエントリ数で、最上位のサブディレクトリが8個あることを示します。0x18にあるのは、0x0000001C、すなわち最初のディレクトリエントリのオフセットです。
3. 最初のエントリ
0x1Cにある9バイトは、次のようにデコードされます。
| フィールド | バイト | 値 |
|---|---|---|
NameOffset |
64 00 00 00 |
0x64 → 「anims」の文字列 |
DataOffset |
6a 00 00 00 |
0x6A → サブディレクトリの記述子 |
Flag |
01 |
1 ⇒ サブディレクトリ |
0x64の内容:
00000060 .... 61 6e 69 6d 73 00 ....anims.
^^^^^^^^^^^^^^^^^
文字列"anims\0"で、ディレクトリ名と一致します。
0x6Aの内容:
00000060 .... 01 00 00 00 72 00 ........r.
^^^^^^^^^^^ ^^^^
00000070 00 00 7b 00 00 00 ... ..{...
^^^^^
| フィールド | バイト | 値 |
|---|---|---|
サブディレクトリのEntryCount |
01 00 00 00 |
1 |
サブディレクトリのEntriesOffset |
72 00 00 00 |
0x72 |
つまりanims/ディレクトリにはファイルが1つあり、その記述は0x72から始まります。
| フィールド | バイト | 値 |
|---|---|---|
NameOffset |
7b 00 00 00 |
0x7B → ファイル名 |
DataOffset |
8e 00 00 00 |
0x8E → ファイルの記述子 |
Flag |
00 |
0 ⇒ ファイル |
0x7Bにある名前:
00000070 .. .. .. 61 72 6d 66 61 armfa
00000080 72 6b 5f 67 61 64 67 65 74 2e 67 61 66 00 rk_gadget.gaf.
→ "armfark_gadget.gaf"。
0x8Eにあるファイルの記述子は、9バイトのHPIFileです。
00000080 24 02 $.
00000090 00 00 48 48 00 00 01 ..HH...
| フィールド | バイト | 値 |
|---|---|---|
DataOffset |
24 02 00 00 |
0x0224 — ディレクトリ直後の最初のバイト! |
FileSize |
48 48 00 00 |
0x4848 = 18504バイト |
CompMethod |
01 |
1 ⇒ LZ77 |
これはkbot hpi list -vの出力と一致します。
anims/armfark_gadget.gaf 18504
4. ツリー全体の読み込み
残り7個の最上位エントリを読み進めると、次のようになります。
.
├── anims/
│ └── armfark_gadget.gaf (18504 B, LZ77)
├── download/
│ └── ARMFARK.TDF (84 B, LZ77)
├── features/corpses/
│ └── armfark_dead.tdf (619 B, LZ77)
├── objects3d/
│ ├── armfark.3do (7337 B, LZ77)
│ └── armfark_dead.3do (3551 B, LZ77)
├── scripts/
│ └── ARMFARK.COB (18988 B, LZ77)
├── unitpics/
│ └── ARMFARK.PCX (8728 B, LZ77)
├── units/
│ └── ARMFARK.FBI (1466 B, LZ77)
└── weapons/
└── armfark_weapon.tdf (430 B, LZ77)
9ファイル。ユニット1体の.ufoの標準的な形です。
5. 圧縮チャンク
548バイトのディレクトリの後の最初のバイトは0x224にあります。その領域を復号すると(チャンクのヘッダーもディレクトリと同じ暗号で暗号化されています)、次のようになります。
00000220 .. 53 51 53 48 02 01 01 cc .SQSH....
| オフセット(0x224から) | バイト | フィールド | 値 |
|---|---|---|---|
+0 |
53 51 53 48 |
Marker |
'SQSH' ✓ |
+4 |
02 |
Version |
0x02(常に) |
+5 |
01 |
CompMethod |
1 ⇒ LZ77 |
+6 |
01 |
Encrypted |
1 ⇒ 2回目のXOR処理を適用 |
+7.. |
… | CompressedSize、DecompressedSize、Checksum、その後にdata[] |
… |
チャンクの本体をdata[i] = (data[i] - i) ^ iで元に戻し、得られたバイトを前述のLZ77デコーダーに通せば、展開されたGAFが得られます。あとはGAFパーサーに渡すだけです。
Tip
このウォークスルーを自分のマシンで再現する。
# Show counts + key
kbot hpi info AFark.ufo
# Walk the tree
kbot hpi list AFark.ufo -v
# Pull a single file out, decompressed and ready to inspect
kbot hpi extract AFark.ufo -p "anims/armfark_gadget.gaf" -t ./out
kbot gaf list ./out/anims/armfark_gadget.gaf
付録 — Jersey.hpi(HPI v2)の16進ダンプを完全にたどる
AFarkのウォークスルーに相当するTA: Kingdoms版です。Jersey.hpiはGoG版TA:Kのインストールに含まれる67 KBのボイスパックのHPIで、中身は効果音のWAVが6つだけです。端から端までたどれるほど小さく、それでいてv2の仕組み(32バイトのヘッダー、名前プール、SQSHで包まれたディレクトリのブロブ、単一チャンクのファイルのペイロード)をすべて試せるだけの大きさがあります。変わらない部分は上のv1のウォークスルーで説明済みなので、この節ではv2での違いだけを取り上げます。
1. 32バイトのヘッダー
00000000: 48 41 50 49 00 00 02 00 92 06 01 00 ad 00 00 00 HAPI............
00000010: 40 06 01 00 5b 00 00 00 20 00 00 00 3f 07 01 00 @...[... ...?...
| オフセット | バイト | フィールド | 値 |
|---|---|---|---|
0x00 |
48 41 50 49 |
Marker |
'HAPI' ✓ |
0x04 |
00 00 02 00 |
Version |
0x00020000 — v2 |
0x08 |
92 06 01 00 |
DirectoryBlock |
0x00010692 — ディレクトリのブロブのオフセット |
0x0C |
ad 00 00 00 |
DirectorySize |
0xAD = 173バイト |
0x10 |
40 06 01 00 |
NameBlock |
0x00010640 — 名前プールのオフセット |
0x14 |
5b 00 00 00 |
NameSize |
0x5B = 91バイト |
0x18 |
20 00 00 00 |
Data |
0x20 — ファイルのペイロードはヘッダーの直後、ここから始まる |
0x1C |
3f 07 01 00 |
Last78 |
0x0001073F — アーカイブ末尾のマーカー(0x1073F = 67391 = ファイルサイズ − 78バイトのフッター) |
XOR暗号も、復号の変換も、位置ごとのシードもありません。ヘッダーの値はファイル内の絶対オフセットを指しているので、そのままseekできます。
2. 名前プール(生のテキスト、末尾近く)
DirectoryBlockから数百バイト手前(エンジンは名前プールをディレクトリより前に書き込みます)に、文字列テーブルがあります。
00010640: 00 73 6f 75 6e 64 73 00 57 68 6f 64 69 65 64 2e .sounds.Whodied.
00010650: 7761 7600 53 75 72 65 2e 77 61 76 00 57 68 6f wav.Sure.wav.Who
00010660: 6168 5061 6c 6c 79 2e 77 61 76 00 46 6f 72 67 ahPally.wav.Forg
00010670: 6574 6162 6f 75 74 69 74 2e 77 61 76 00 59 6f etaboutit.wav.Yo
00010680: 2e77 6176 00 49 67 6f 74 63 68 65 72 2e 77 61 .wav.Igotcher.wa
00010690: 76 00 v.
ディレクトリとファイルのエントリはどれも、このブロック内へのバイトオフセットNamePtrを持っています。ポインターをたどって次の\0まで読めば名前が得られます。1バイトずつたどると次のとおりです。
オフセット(NameBlockからの相対) |
NUL終端の文字列 |
|---|---|
0x00 |
""(ルートディレクトリ — 空の名前) |
0x01 |
"sounds"(唯一のサブディレクトリ) |
0x08 |
"Whodied.wav" |
0x14 |
"Sure.wav" |
0x1D |
"WhoahPally.wav" |
0x2C |
"Forgetaboutit.wav" |
0x3E |
"Yo.wav" |
0x45 |
"Igotcher.wav" |
Note
名前プールの名目上のサイズには、使われない末尾のバイトがいくつか含まれています。NameSize=0x5Bはオフセット0x1069Bまで及びますが、意味のある最後のバイト(Igotcher.wavの後のNUL)は0x10691にあります。末尾の9バイトはSQSHのディレクトリチャンクの先頭と重なっており、どのNamePtrからも参照されません。リーダーはそこを見ないことで、問題なくやり過ごせます。
3. ディレクトリのブロブ(SQSHで包まれている)
0x10692から始まるディレクトリのブロブは、SQSHチャンクのヘッダーで始まります。v2はv1のチャンク形式を再利用しています。
00010692: 53 51 53 48 02 02 01 9a 00 00 00 b8 00 00 00 9b SQSH............
000106a2: 4f 00 00 78 9e 63 … O..x.c…
| オフセット | バイト | フィールド | 値 |
|---|---|---|---|
+0 |
53 51 53 48 |
Marker |
'SQSH' ✓ |
+4 |
02 |
Version |
2 |
+5 |
02 |
CompMethod |
2 ⇒ ZLib |
+6 |
01 |
Encrypted |
1 — チャンクの本体に2回目のXOR処理がかかっている(TAKではまれだが、このアーカイブにはある) |
+7 |
9a 00 00 00 |
CompressedSize |
0x9A = 154バイト |
+11 |
b8 00 00 00 |
DecompressedSize |
0xB8 = 184バイト |
+15 |
9b 4f 00 00 |
Checksum |
0x4F9B |
+19..+172 |
… | data[] |
XORでかき混ぜられた154バイトのZLibストリーム |
19 + 154 = 173 = 0xAD = DirectorySizeなので、チャンク全体がヘッダーの示すサイズにぴったり収まっています。XOR処理を元に戻し(v1と同じアルゴリズム)、inflateに通すと、詰めて並べられたディレクトリのレコード184バイトが得られます。
4. ディレクトリのレコードのデコード
展開したブロブには、2種類のレコードを詰めて並べた配列が入っています。20バイトのディレクトリエントリ(HpiDir2)と、24バイトのファイルエントリ(HpiEntry2)です。数の前置きもオフセットテーブルもありません。ルートディレクトリは常にブロブのオフセット0にあり、そのFirstSubDirectory / FirstFileフィールドが子を指します。
ルートのHpiDir2(展開後のオフセット0、20バイト):
| オフセット | バイト(展開後) | フィールド | 値 |
|---|---|---|---|
+0 |
00 00 00 00 |
NamePtr |
0 → ""(ルート) |
+4 |
14 00 00 00 |
FirstSubDirectory |
0x14 — このオフセットに20バイトのサブディレクトリのレコードが1つ |
+8 |
01 00 00 00 |
SubCount |
1(sounds/ディレクトリ) |
+12 |
00 00 00 00 |
FirstFile |
0 — ルートにファイルはない |
+16 |
00 00 00 00 |
FileCount |
0 |
ポインターをたどって0x14に行くと、soundsのHpiDir2があります。
NamePtr = 0x01→"sounds"FirstSubDirectory = 0、SubCount = 0— 入れ子のサブディレクトリはなしFirstFile = 0x28— 6つあるHpiEntry2レコード(各24バイト)の最初FileCount = 6
0x28、0x40、0x58、…にある各ファイルのレコードは、次の内容を持ちます。
NamePtr // → name pool offset
Start // → absolute file offset of the SQSH chunk (or stored bytes)
DecompressedSize
CompressedSize // 0 ⇒ stored verbatim (no SQSH wrapper)
Date // time_t, informational
Checksum // informational
デコードすると、6つのエントリはkbot hpi listが報告するものと正確に一致します。
sounds/Whodied.wav 56474 bytes
sounds/Sure.wav 15248 bytes
sounds/WhoahPally.wav 24826 bytes
sounds/Forgetaboutit.wav 24826 bytes
sounds/Yo.wav 19368 bytes
sounds/Igotcher.wav 35980 bytes
5. ここにないもの(v1との比較)
v1のウォークスルーを覚えているなら、欠けている部分こそがv2の簡素化の要点です。
- XOR暗号の鍵の導出がない — v2では
DecryptKeyは常に0です。SQSHチャンクがEncrypted == 1を持つ場合でも、チャンク内の2回目のXOR処理は適用されますが、ディレクトリやペイロードを包む外側は平文です。 - 位置ごとのシードがない — v1のリーダーを複雑にしていた1バイトずつの暗号(復号がファイル内の絶対オフセットに依存する)は、v2の外側のレベルには存在しません。
- チャンクサイズのテーブルがない — v1のファイルは、先頭に
uint32のサイズの配列を置いたうえで64 KiB以下のブロックに分割されていました。v2は各ファイルを単一のSQSHチャンク(または無圧縮のバイト)として格納するので、サイズのテーブルを作る対象がありません。 - 「ディレクトリはヘッダー直後の
0x14」という慣例がない — ディレクトリと名前のブロックは、書き出す側が置いた場所(通常はファイルの末尾近く)にあります。ヘッダーがそれらを指しています。
Tip
このウォークスルーを自分のマシンで再現する。
kbot ctx add /path/to/tak --alias tak --game takingdoms
kbot hpi info /path/to/tak/Jersey.hpi
kbot hpi list /path/to/tak/Jersey.hpi -v
kbot hpi extract /path/to/tak/Jersey.hpi -t ./jersey-out
# And to peek at the raw bytes:
xxd -l 64 /path/to/tak/Jersey.hpi # 32-byte v2 header
xxd -s 0x10640 -l 96 /path/to/tak/Jersey.hpi # name pool
xxd -s 0x10692 -l 32 /path/to/tak/Jersey.hpi # SQSH directory chunk header