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/ніколи не монтуються) і монтує в такому порядку:
rev31.gp3;- усі
*.ccx;- усі
*.ufo;- перші десять
*.hpi, що відкрилися;- файли
*.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та інструмент 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". - Виведіть із
DecryptKeyXOR-ключ для цього файлу. - Розшифруйте й прочитайте дерево каталогів.
- Для кожного потрібного файлу перейдіть до його стиснених фрагментів,
розшифруйте їх і далі дійте залежно від
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