Все страницы форматов ↓
Контейнеры и обёртки

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/ не монтируются никогда) и монтирует в таком порядке:

  1. rev31.gp3;
  2. все *.ccx;
  3. все *.ufo;
  4. первые десять *.hpi, которые удалось открыть;
  5. файлы *.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 и инструмент MCP vfs_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:

  1. Прочитать 20-байтовый заголовок. Проверить магическое число "HAPI".
  2. Вывести XOR-ключ этого файла из DecryptKey.
  3. Расшифровать и прочитать дерево каталогов.
  4. Для каждого нужного файла перейти к его сжатым фрагментам, расшифровать их, а затем действовать в зависимости от 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. Даже когда у фрагмента SQSH Encrypted == 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

См. также

  • GAF, PCX, 3DO, TNT, COB, TDF — какие файлы бывают внутри архивов HPI.
  • Глоссарий — определения виртуальной файловой системы, наложения слоёв и других терминов, связанных с HPI.