Усі сторінки форматів ↓
Контейнери й обгортки

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) — зашифровану структуру з багатьма фрагментами (chunks), яку ця сторінка описує від початку до кінця. 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 на верхньому рівні ігрового CD, без обмеження кількості.

У межах кожної групи гра бере імена в порядку 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. Сторінка Home і вкладка Layering у вкладці Files, команди 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. Виведіть із DecryptKey XOR-ключ для цього файлу.
  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

Усі прапорці див. у довіднику 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' у 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 бітів-прапорців, починаючи з молодшого. 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.
  • Глосарій — визначення virtual filesystem, layering та інших термінів, пов'язаних з HPI.