HPI / UFO / CCX / GP3 — архивы HAPI
Источник: coreprime/kbot @ 0ab97bf · Редактировать на GitHub
HAPI («HPI») — зашифрованный сжатый контейнер, в котором Cavedog поставляла все ресурсы игры. Одна и та же структура на диске используется под разными расширениями:
Расширение Назначение Порядок монтирования в TA 3.1c .gp3Оверлей патча 3.1 ( rev31.gp3)Монтируется первым, остальные *.gp3игнорируются.ccxДанные Core Contingency / Battle Tactics ( btdata.ccx,ccdata.ccx)После rev31.gp3.ufoДополнения с юнитами и объектами ( AFark.ufo)После всех .ccx, без ограничения количества.hpiАрхивы основной игры ( totala1.hpi,tactics1.hpi)Последними, только первые десять, которые удалось открыть Все четыре — побайтно один и тот же формат, различается только порядок монтирования. При запуске игра сводит их в единую виртуальную файловую систему, и побеждает первый смонтированный архив, в котором есть нужный путь.
Important
Два диалекта на диске. Total Annihilation записывает HPI v1
(Version == 0x00010000) — зашифрованную структуру из множества
фрагментов, которую эта страница описывает от начала до конца.
Total Annihilation: Kingdoms записывает HPI v2
(Version == 0x00020000) — другую, более простую структуру без
XOR-шифра, где каждый файл или блок каталога лежит в одном фрагменте
SQSH. Особенностям v2 отведён отдельный раздел ближе к концу страницы:
HPI v2 в TA: Kingdoms. TA 3.1c монтирует только
архивы v1.
Наложение слоёв в порядке загрузки
TA 3.1c просматривает только верхний уровень своего каталога игры (архивы в подпапках вроде
Backup/не монтируются никогда) и монтирует в таком порядке:
rev31.gp3;- все
*.ccx;- все
*.ufo;- первые десять
*.hpi, которые удалось открыть;- файлы
*.hpiв корне игрового компакт-диска, без ограничения количества.Внутри каждой группы игра берёт имена в порядке ASCII после перевода в верхний регистр (в том порядке, в каком их возвращает листинг каталога:
_идёт после букв,AFark.ufo— раньшеzeta.ufo). Когда игра разрешает путь вродеunits/ARMCOM.fbi, побеждают файлы вне архивов в каталоге установки, а за ними — первый смонтированный архив, в котором есть этот путь. Архив считается смонтированным, только если это архив версии 1, который заканчивается строкой об авторских правах Cavedog (см. Трейлер) и каталог которого читается. Любой другой файл пропускается и не занимает ни одного из десяти слотов*.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 десять слотов
*.hpiзанимаютtactics1–8.hpi,totala1.hpiиtotala2.hpi.totala3.hpi,totala4.hpi(оригинальные карты) иworlds.hpi(секции карт) — это архивы с диска. В установке GOG они лежат в каталоге игры, который заменяет диск, поэтому игра монтирует их после всех остальных архивов. То же касается любого*.hpi, который добавляет мод: если его имя при сортировке оказывается после десятого слота, до него дойдёт только этот просмотр «диска», ниже всего остального. Чтобы этого избежать, выпускайте моды как.ufo(без ограничения количества, выше всех.hpi).kbot монтирует контексты Total Annihilation точно так же и везде: в студии, в
kbot mount,kbot mount flatten,kbot pack,kbot tntи на сервере MCP. Главная страница вкладки Files и вкладка Layering, командыarchivesиstatsвkbot mountи инструмент MCPvfs_game_dataпоказывают порядок монтирования и все архивы, которые не были смонтированы, с указанием причины. Контексты TA: Kingdoms сохраняют порядок наложения, принятый в kbot (все архивы по расширению и имени, более поздний перекрывает более ранний): он нужен этой игре, чтобыIPData.hpiнакладывался поверхdata.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". - Вывести XOR-ключ этого файла из
DecryptKey. - Расшифровать и прочитать дерево каталогов.
- Для каждого нужного файла перейти к его сжатым фрагментам,
расшифровать их, а затем действовать в зависимости от
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
Все флаги описаны в справке по 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' в little-endian. Вариант 'BANK' используется в сохранённых играх и здесь не рассматривается. |
Version |
Оригинальные архивы Total Annihilation используют 0x00010000 (v1, этот раздел). Архивы TA: Kingdoms используют 0x00020000 (v2), см. HPI v2 в TA: Kingdoms. |
DirectorySize |
Включает 20 байт заголовка, поэтому сам блоб каталога занимает DirectorySize − Start байт, начиная со Start. |
DecryptKey |
Пропустите через преобразование ниже, чтобы получить байт, который служит XOR-ключом. Для TA 3.1c байты ключа 0 и 0xFF оба означают «не зашифровано». В оригинальных архивах 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, но игра расшифровку не выполняет). Некоторые
самодельные архивы (и несколько модов сообщества) записаны без
шифрования — если применить к ним 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, и в модах armcom.fbi и ARMCOM.FBI часто соседствуют даже
в одном архиве. Игра приводит к одному регистру только буквы ASCII,
делит пути и по \, и по /, а если в одном каталоге хранится
несколько записей, имена которых различаются только регистром, читает
последнюю из них, остальные недоступны. Кроме того, . и .. она
считает обычными именами. Поэтому kbot hpi extract записывает из
такой группы только доступную запись и пропускает (с сообщением) любую
запись, путь которой в сохранённом виде вышел бы за пределы целевой
папки, например ../../x.txt. kbot mount flatten тоже никогда не
пишет за пределами своей целевой папки.
Сжатые фрагменты (SQSH)
Данные файла нарезаются на фрагменты размером до 64 КиБ в распакованном виде. У каждого фрагмента свой 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 файла находится массив сжатых размеров uint32, по
одному на фрагмент, а за ним вплотную друг к другу идут сами фрагменты.
Второй слой XOR
Если Encrypted == 1, к телу каждого фрагмента применён второй
проход XOR:
for (i = 0; i < CompressedSize; i++)
data[i] = (data[i] - i) ^ i;
Обратите внимание: контрольная сумма считается по ещё зашифрованным байтам, так что проверяйте её до того, как снимете шифр.
LZ77 (CompMethod = 1)
Хрестоматийный LZ77 со скользящим окном и историей в 4 КиБ:
- Байт-метка — 8 битов-флагов, начиная с младшего (LSB first).
0⇒ литерал,1⇒ обратная ссылка. - Литерал — 1 байт копируется как есть в выходной поток и в историю.
- Совпадение — читается
uint16. Старшие 12 бит — смещение в окне (1–4095), младшие 4 бита кодируют длину какlen − 2(так что совпадения бывают длиной 2–17 байт). - Завершение — совпадение с
windowOffset == 0завершает поток.
ZLib (CompMethod = 2)
Обычный сырой поток zlib. Когда слой шифрования снят, тело фрагмента
передаётся в inflate(), а DecompressedSize — ожидаемая длина.
Без сжатия (CompMethod = 0)
Байты записываются как есть: без нарезки на фрагменты, без контрольной суммы, без второго прохода XOR. Подходит для файлов, которые не сжимаются (уже сжатые PCX/GAF), и это единственный режим, который понимает старый «unit viewer».
Трейлер
Каждый архив, который монтирует игра, заканчивается 36-байтовой ASCII-строкой об авторских правах:
Copyright 1997 Cavedog Entertainment
Ничто в заголовке на неё не указывает, но она обязательна: перед
монтированием архива TA 3.1c проверяет, что его последние 36 байт
читаются как Copyright ____ Cavedog Entertainment (четыре символа года
не сравниваются). Архив без неё или с любыми байтами, дописанными после
неё, пропускается и не занимает ни одного из десяти слотов *.hpi, так
что мод, упакованный без трейлера, в игре молча отсутствует.
Все архивы, которые записывает 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-байтовый подзаголовок) |
| Шифрование | XOR-шифр с начальным значением из DecryptKey, сдвигается вместе с позицией в файле |
Нет — всё открытым текстом |
| Расположение каталога и пула имён | Сразу после заголовка (в зашифрованном виде) | По произвольным смещениям (обычно ближе к концу файла), каждый может быть завёрнут в один фрагмент SQSH |
| Нарезка файлов на фрагменты | Фрагменты по ≤64 КиБ с таблицей длин; у каждого фрагмента свой заголовок SQSH + контрольная сумма + необязательный второй проход XOR | Один фрагмент SQSH со всей полезной нагрузкой или байты без сжатия, если CompressedSize == 0; без прохода XOR, без таблицы длин |
Шифр на уровне фрагмента (байт Encrypted в SQSH) |
Используется | Не используется (мы всё равно учитываем этот байт, если он выставлен, но во всех изученных нами файлах TAK он равен 0) |
Заголовок v2
После 8-байтового пролога ('HAPI' + 0x00020000) идут шесть
32-битных полей в little-endian — всего заголовок занимает на диске
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 — это 'SQSH',
считайте блок одним фрагментом (заголовок + полезная нагрузка) и
распакуйте его. Если нет, то Size байт по указанному смещению — это
уже распакованное содержимое как есть.
Записи каталога v2
Распакованный блоб каталога — плотно упакованный массив записей двух
типов (без отдельных таблиц смещений и без префикса 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 (с тем же 19-байтовым заголовком, что и в v1): прочитайте его, сверьте контрольную сумму с суммой байтов полезной нагрузки, декодируйте согласноCompMethodфрагмента (1= LZ77,2= ZLib) — и файл готов.
Многофрагментной схемы v1 (таблица размеров, фрагменты по ≤64 КиБ, проход шифрования) больше нет. 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 Flakker; здесь мы повторим его разбор заголовка. Шестнадцатеричный дамп первых 32 байт:
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
Такая структура из девяти файлов — каноническая форма дополнения .ufo
с одним юнитом. Базовые архивы totala*.hpi устроены по той же схеме,
только в большем масштабе (~30 тыс. файлов).
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/ на диске с учётом регистра), объединяются так же, как игра
сравнивает имена. Два файла, пути которых различаются только регистром,
сохраняются оба, игра читает тот, что упакован последним, а pack
выводит предупреждение.
Подводные камни
Warning
Указатели отсчитываются от начала файла, а не от начала каталога.
Ничего вычитать не нужно. Стандартный приём реализации — выделить
буфер размером DirectorySize байт и прочитать в него расшифрованный
каталог со смещения Start, чтобы указатели напрямую служили
индексами в буфере.
- Формула числа фрагментов —
ceil(FileSize / 65536). Явного поля с количеством нет, его нужно вычислять изFileSize.
Типичные размеры
| Архив | Размер | Файлов | Примечания |
|---|---|---|---|
totala1.hpi (звуки, анимации) |
~30 МБ | ~1900 | Основная игра, самый большой отдельный архив. |
totala2.hpi (юниты) |
~13 МБ | ~600 | Большая часть метаданных юнитов + скрипты. |
totala4.hpi (карты) |
~50 МБ | ~300 | Все оригинальные карты. |
worlds.hpi (секции) |
~20 МБ | ~5000 | Библиотеки SCT для редактора. |
ccdata.ccx (Core Contingency) |
~25 МБ | ~1500 | Юниты и карты дополнения. |
Типичный .ufo с одним юнитом |
10–150 КБ | 8–12 | Большинство сторонних дополнений с юнитами. |
.ufo только с картой (.tnt + .ota) |
0,5–5 МБ | 2–4 | Распространение карт по одной. |
| Степень сжатия | 35–55% | — | LZ77 для большинства файлов, ZLib для самых больших. |
- Некоторые фрагменты при сжатии увеличиваются. «Сжатый» фрагмент
может оказаться больше 64 КиБ, если LZ77 не нашёл совпадений. Считайте
CompressedSizeединственно верной длиной для копирования. - Контрольная сумма считается по зашифрованным байтам. Если сначала расшифровать, проверка контрольной суммы не пройдёт.
- Сохранённые игры (маркер
BANK) — совсем другой зверь: они используют обёртку HPI, но внутри закодировано сериализованное состояние игры, а не файловая система. kbot их не декодирует. - Для загрузки
.gp3нужен патч rev3.1. Внутри это всё тот же HPI, но на установках без патча движок отказывается его монтировать.
Приложение — полный разбор дампа AFark.ufo
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. Первая запись
9 байт по адресу 0x1C расшифровываются так:
| Поле | Байты | Значение |
|---|---|---|
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)
Девять файлов — каноническая форма .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 ⇒ нужен второй проход 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)
Аналог разбора AFark для TA: Kingdoms. Jersey.hpi — HPI с голосовым
пакетом размером 67 КБ из установки TA:K в версии GoG: шесть WAV со
звуковыми эффектами, и больше ничего. Он достаточно мал, чтобы пройти
его целиком, и достаточно велик, чтобы задействовать все механизмы 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, получится имя. Побайтно:
Смещение (относительно 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, но последний
значащий байт (NUL после Igotcher.wav) находится по адресу 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 — к телу фрагмента применён второй проход 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[] |
154 байта потока ZLib, перемешанного XOR |
19 + 154 = 173 = 0xAD = DirectorySize, так что фрагмент целиком
укладывается в размер, заявленный в заголовке. Если снять проход XOR
(тем же алгоритмом, что и в v1) и пропустить данные через inflate,
получится 184 байта плотно упакованных записей каталога.
4. Декодирование записей каталога
В распакованном блобе лежит плотно упакованный массив записей двух
типов: 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-байтовая запись подкаталога |
+8 |
01 00 00 00 |
SubCount |
1 (каталог sounds/) |
+12 |
00 00 00 00 |
FirstFile |
0 — в корне файлов нет |
+16 |
00 00 00 00 |
FileCount |
0 |
Переход по указателю на 0x14 приводит к HpiDir2 каталога sounds:
NamePtr = 0x01→"sounds"FirstSubDirectory = 0,SubCount = 0— вложенных подкаталогов нетFirstFile = 0x28— первая из шести записей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
После декодирования шесть записей в точности совпадают с тем, что
выводит 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. Даже когда у фрагмента SQSHEncrypted == 1, второй проход XOR внутри фрагмента по-прежнему применяется, но внешняя оболочка каталога и полезной нагрузки — открытый текст. - Нет начального значения, зависящего от позиции — побайтового шифра, который усложнял читатели v1 (расшифровка зависит от абсолютного смещения в файле), на уровне внешней оболочки v2 не существует.
- Нет таблицы размеров фрагментов — в v1 файлы нарезались на блоки по ≤64 КиБ с предшествующим массивом размеров uint32. 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