HPI / UFO / CCX / GP3 — fichiers d'archive HAPI
Source : coreprime/kbot @ 0ab97bf · Modifier sur GitHub
HAPI (« HPI ») est le conteneur chiffré et compressé dans lequel Cavedog a livré toutes les ressources du jeu. La même disposition sur le disque est réutilisée sous différentes extensions :
Extension Rôle Ordre de montage de TA 3.1c .gp3La surcouche du patch 3.1 ( rev31.gp3)Montée en premier ; les autres *.gp3sont ignorées.ccxDonnées de Core Contingency / Battle Tactics ( btdata.ccx,ccdata.ccx)Après rev31.gp3.ufoExtensions d'unités ou d'éléments de décor ( AFark.ufo)Après toutes les .ccx; sans limite de nombre.hpiArchives du jeu de base ( totala1.hpi,tactics1.hpi)En dernier ; seulement les dix premières qui s'ouvrent Les quatre sont des formats identiques à l'octet près : seul l'ordre de montage diffère. Au démarrage, le jeu les fusionne en un seul système de fichiers virtuel, et la première archive montée qui contient un chemin l'emporte.
Important
Deux dialectes sur le disque. Total Annihilation écrit le HPI v1
(Version == 0x00010000), la disposition chiffrée à plusieurs chunks que
cette page documente de bout en bout. Total Annihilation: Kingdoms écrit
le HPI v2 (Version == 0x00020000) — une disposition différente et plus
simple, sans chiffrement XOR et avec un seul chunk SQSH par fichier ou par
bloc de répertoire. Les spécificités de la v2 ont leur propre section vers le
bas de cette page : HPI v2 de TA: Kingdoms. TA 3.1c
ne monte que des archives v1.
Superposition selon l'ordre de chargement
TA 3.1c n'examine que la racine de son répertoire de jeu (les archives des sous-dossiers comme
Backup/ne sont jamais montées) et monte, dans cet ordre :
rev31.gp3;- toutes les
*.ccx;- toutes les
*.ufo;- les dix premières
*.hpiqui s'ouvrent ;- les fichiers
*.hpià la racine du CD du jeu, sans limite de nombre.Dans chaque groupe, le jeu prend les noms dans l'ordre ASCII en majuscules (l'ordre que renvoie son listage de répertoire :
_se range après les lettres,AFark.ufoavantzeta.ufo). Quand il résout un chemin commeunits/ARMCOM.fbi, les fichiers libres de l'installation l'emportent, puis la première archive montée qui contient le chemin. Une archive ne compte comme montée que si c'est une archive version 1 qui se termine par la signature de copyright de Cavedog (voir Signature de fin) et dont le répertoire se lit ; tout autre fichier est ignoré et n'occupe pas l'un des dix emplacements*.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"Sur la version GOG, les dix emplacements
*.hpisont pris partactics1–8.hpi,totala1.hpiettotala2.hpi.totala3.hpi,totala4.hpi(les cartes d'origine) etworlds.hpi(les sections de carte) sont les archives du disque : une installation GOG les garde dans le répertoire du jeu, qui tient lieu de disque, si bien que le jeu les monte après toutes les autres archives. Il en va de même pour toute*.hpiajoutée par un mod : celle dont le nom se range après le dixième emplacement n'est atteinte que par cet examen du disque, sous tout le reste. Livrez vos mods en.ufo(sans limite, au-dessus de toute.hpi) pour éviter cela.kbot monte les contextes Total Annihilation de la même façon, partout : le studio,
kbot mount,kbot mount flatten,kbot pack,kbot tntet le serveur MCP. La page Home et l'onglet Layering de l'onglet Files, les commandesarchivesetstatsdekbot mountet l'outil MCPvfs_game_datalistent l'ordre de montage et chaque archive qui n'a pas été montée, avec la raison. Les contextes TA: Kingdoms gardent l'ordre de surcouche de kbot (toutes les archives par extension et par nom, une archive plus tardive remplaçant une plus ancienne), dont a besoin la superposition deIPData.hpisurdata.hpide ce jeu.La règle « les fichiers libres l'emportent » explique pourquoi
kbot mount flattenproduit un répertoire qui se comporte exactement comme l'ensemble superposé : chaque fichier que le moteur aurait résolu à travers la pile d'archives est présent comme fichier libre au même chemin, donc la résolution s'arrête court au sommet de la pile.
Quelle définition l'emporte
Les éléments de décor, les armes et les unités sont chargés en listant un répertoire :
features/**/*.tdf(sous-dossiers compris),weapons/*.tdfetunits/*.fbi(premier niveau seulement — un sous-dossierweapons/old/n'est jamais lu). Le listage parcourt d'abord les fichiers libres, puis chaque archive montée dans l'ordre de montage, avec ses entrées dans l'ordre de stockage, et la première définition d'un nom l'emporte ; les suivantes sont ignorées. LeCarScar05d'origine est défini dansfeatures/urban/cars2.tdfet de nouveau danscars.tdf, etcars2.tdfest stocké en premier, donc le jeu le dessine à partir deanims/cars2.gaf. Le studio (palette d'éléments, bac à sable, packs, listes d'unités et d'armes),kbot tnt previewetkbot tnt lint, ainsi que les outils MCP, appliquent la même règle.
Vue d'ensemble
┌─── 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"
La lecture d'un HPI se déroule ainsi :
- Lire l'en-tête de 20 octets. Vérifier le nombre magique
"HAPI". - Dériver de
DecryptKeyla clé XOR propre au fichier. - Déchiffrer et lire l'arborescence du répertoire.
- Pour chaque fichier voulu, se positionner sur ses chunks compressés, les
déchiffrer, puis aiguiller selon
CompMethod(LZ77, ZLib ou aucune).
Tip
Examinez n'importe quel HPI sans écrire d'analyseur.
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
Consultez la référence de kbot hpi
de la CLI pour toutes les options.
Depuis Go. Utilisez 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
En-tête (20 octets, en clair)
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;
| Champ | Remarques |
|---|---|
Marker |
'HAPI' en little-endian. La variante 'BANK' est utilisée par les sauvegardes et n'est pas traitée ici. |
Version |
Les archives d'origine de Total Annihilation utilisent 0x00010000 (v1, cette section). Les archives de TA: Kingdoms utilisent 0x00020000 (v2) ; voir HPI v2 de TA: Kingdoms. |
DirectorySize |
Inclut les 20 octets de l'en-tête : le blob du répertoire occupe donc en réalité DirectorySize − Start octets à partir de Start. |
DecryptKey |
À faire passer par la transformation ci-dessous pour obtenir l'octet qui sert de clé XOR. Pour TA 3.1c, les octets de clé 0 et 0xFF signifient tous deux « non chiffré ». Les archives d'origine de Cavedog utilisent 0xBF. |
Start |
Le début du répertoire. Toujours 0x14 dans les fichiers observés. |
Dériver la clé de chiffrement
key = (DecryptKey << 2) | (DecryptKey >> 6) // rotate-left by 2 bits
Chaque octet chiffré est ensuite décodé à l'aide de sa position absolue dans le fichier :
plain[i] = cipher[i] ^ (uint8(pos + i) ^ key)
où pos est l'offset absolu de cipher[0] dans le fichier. Eh oui : la
graine avance avec la position dans le fichier, si bien qu'on ne peut pas
déchiffrer un tampon isolément ; il faut savoir d'où il vient.
Important
Si l'octet de clé vaut 0 ou 0xFF, n'appliquez pas du tout le
chiffrement. TA 3.1c lit les deux comme du texte en clair (pour 0xFF, la
rotation ci-dessus donnerait 0xFF, mais le jeu ne déchiffre pas).
Certaines archives faites maison (et une poignée de mods de la communauté)
sont écrites sans chiffrement ; appliquer le XOR dans ce cas brouillerait ce
qui est déjà en clair. kbot hpi pack refuse --key 255 pour cette raison ;
utilisez --key 0.
Arborescence du répertoire
Un répertoire est un nombre d'entrées plus un pointeur vers sa première entrée :
typedef struct { uint32 EntryCount; uint32 EntriesOffset; } HPIDir;
Chaque entrée fait exactement 9 octets :
typedef struct {
uint32 NameOffset; // → NUL-terminated string
uint32 DataOffset; // → HPIDir (subdir) or HPIFile (leaf)
uint8 Flag; // 1 = subdirectory, 0 = file
} HPIEntry;
Pour les fichiers, DataOffset pointe vers :
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;
Tous les offsets sont absolus dans le fichier. C'est pourquoi l'analyseur
canonique alloue un tampon de DirectorySize octets et y écrit le répertoire
à la position Start : ainsi, les pointeurs indexent directement le tampon,
sans arithmétique.
Lire le répertoire
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
La casse des noms de fichiers est conservée à l'écriture, mais comparée
sans tenir compte de la casse à la lecture. Total Annihilation a grandi
sous Windows 9x ; les mods mélangent couramment armcom.fbi et ARMCOM.FBI,
même au sein d'une même archive. Le jeu ne replie que la casse des lettres
ASCII, découpe les chemins sur \ comme sur /, et quand un répertoire
stocke plusieurs entrées dont les noms ne diffèrent que par la casse, il lit
la dernière ; les autres sont inaccessibles. Il traite aussi . et ..
comme des noms ordinaires. kbot hpi extract n'écrit donc que l'entrée
accessible d'un tel groupe, et ignore (avec un message) toute entrée dont le
chemin, tel que stocké, ne pourrait pas s'écrire dans le dossier cible,
comme ../../x.txt ; kbot mount flatten n'écrit jamais hors de sa cible
non plus.
Chunks compressés (SQSH)
Les données d'un fichier sont découpées en chunks d'au plus 64 Kio une fois décompressés. Chaque chunk a son propre en-tête de 19 octets :
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;
Le nombre de chunks est ceil(FileSize / 65536). Juste après le premier
HPIFile.DataOffset du fichier se trouve un tableau de tailles compressées
en uint32, une par chunk, suivi des chunks eux-mêmes, mis bout à bout.
La seconde couche XOR
Si Encrypted == 1, le corps de chaque chunk subit une seconde passe
XOR :
for (i = 0; i < CompressedSize; i++)
data[i] = (data[i] - i) ^ i;
Notez que la somme de contrôle porte sur les octets encore chiffrés : vérifiez-la avant d'annuler le chiffrement.
LZ77 (CompMethod = 1)
Un LZ77 classique à fenêtre glissante, avec un historique de 4 Kio :
- Octet d'étiquette — 8 bits de flags, bit de poids faible en premier.
0⇒ littéral,1⇒ référence arrière. - Littéral — copier 1 octet tel quel dans la sortie et l'historique.
- Correspondance — lire un
uint16. Les 12 bits de poids fort sont l'offset dans la fenêtre (1–4 095), les 4 bits de poids faible codent la longueur sous la formelen − 2(les correspondances font donc 2 à 17 octets). - Terminateur — une correspondance avec
windowOffset == 0termine le flux.
ZLib (CompMethod = 2)
Flux brut zlib standard. Donnez le corps du chunk à inflate() une fois la
couche de chiffrement retirée ; DecompressedSize est la longueur attendue.
Stocké (CompMethod = 0)
Les octets sont écrits tels quels : pas de découpage en chunks, pas de somme de contrôle, pas de seconde passe XOR. Utile pour les fichiers qui ne se compressent pas (PCX/GAF déjà compressés), et c'est le seul mode que comprend l'ancien « unit viewer ».
Signature de fin
Chaque archive que le jeu monte se termine par une ligne de copyright ASCII de 36 octets :
Copyright 1997 Cavedog Entertainment
Rien dans l'en-tête n'y renvoie, mais elle n'est pas facultative : avant
de monter une archive, TA 3.1c vérifie que ses 36 derniers octets se lisent
Copyright ____ Cavedog Entertainment (les quatre caractères de l'année ne
sont pas comparés). Une archive qui ne l'a pas, ou à laquelle des octets ont
été ajoutés après elle, est ignorée et n'occupe pas l'un des dix emplacements
*.hpi : un mod empaqueté sans la signature de fin est absent du jeu, sans
aucun message.
Chaque archive écrite par kbot la conserve : kbot hpi pack et les exports du
studio (l'Export mod d'un espace de travail et le téléchargement d'archive de
l'éditeur de cartes). kbot hpi info, kbot hpi list -v, les outils MCP
hpi_info et hpi_list et les métadonnées d'une archive dans l'onglet Files
affichent une ligne TA 3.1c would mount: yes / no, <reason>.
TA: Kingdoms — HPI v2
Note
Cette section ne décrit que les écarts propres à TA:K. Tout ce qui
précède s'applique à Total Annihilation (Version == 0x00010000). TA:
Kingdoms utilise la même extension .hpi, mais une autre disposition sur
le disque : lisez cette section en entier avant de dégainer l'analyseur v1.
TA: Kingdoms garde le nombre magique 'HAPI' et le principe d'une
arborescence de répertoire, mais refond l'enveloppe pour le reste. Les
différences, en bref :
| Aspect | v1 (TA) | v2 (TA: Kingdoms) |
|---|---|---|
| Champ de version | 0x00010000 |
0x00020000 |
| Taille de l'en-tête | 20 octets (5 × uint32) | 32 octets (8 octets de magique+version + sous-en-tête de 24 octets) |
| Chiffrement | XOR dont la graine est DecryptKey, qui avance avec la position dans le fichier |
Aucun : tout est en clair |
| Emplacement du répertoire et du pool de noms | Juste après l'en-tête (chiffrés) | À des offsets arbitraires, en général près de la fin du fichier, chacun éventuellement enveloppé dans un seul chunk SQSH |
| Découpage de chaque fichier | En chunks de ≤64 Kio avec une table des longueurs ; chaque chunk a son propre en-tête SQSH + somme de contrôle + seconde passe XOR facultative | Un seul chunk SQSH avec toute la charge utile, ou des octets stockés si CompressedSize == 0 ; pas de passe XOR, pas de table des longueurs |
Chiffrement par chunk (octet Encrypted du SQSH) |
Utilisé | Non utilisé (nous respectons quand même l'octet s'il est positionné, mais tous les fichiers TAK que nous avons examinés l'ont à 0) |
En-tête v2
Après le prologue de 8 octets ('HAPI' + 0x00020000) viennent six champs
de 32 bits en little-endian ; l'en-tête fait au total 32 octets sur le
disque :
// 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;
Le blob du répertoire et celui des noms peuvent tous deux être compressés
en SQSH : si les quatre premiers octets à DirectoryBlock / NameBlock sont
'SQSH', traitez le bloc comme un chunk unique (en-tête + charge utile) et
décompressez-le. Sinon, les Size octets à l'offset donné sont le contenu
brut, déjà décompressé.
Entrées de répertoire v2
Le blob du répertoire décompressé est un tableau compact de deux types
d'enregistrements (sans tables d'offsets séparées ni préfixe EntryCount :
les enfants sont trouvés par les pointeurs FirstSubDirectory / FirstFile
du parent) :
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;
Charges utiles des fichiers
Pour une entrée de fichier à l'offset Start :
- Si
CompressedSize == 0: lireDecompressedSizeoctets tels quels. - Sinon : les
CompressedSizeoctets suivants forment un seul chunk SQSH (même en-tête de 19 octets qu'en v1) — lisez-le, vérifiez sa somme de contrôle par rapport à la somme des octets de la charge utile, décodez avec leCompMethoddu chunk (1= LZ77,2= ZLib), et vous avez le fichier.
Le schéma v1 à plusieurs chunks (table des tailles, chunks de ≤64 Kio, passe de chiffrement) a disparu. La v2 échange la compacité contre un lecteur beaucoup plus simple.
Prise en charge par kbot
kbot hpi list, extract et info prennent tous en charge les deux
dialectes de façon transparente : ils aiguillent selon le mot Version de
l'en-tête. L'écriture en aller-retour (kbot hpi pack) n'émet pour l'instant
que du v1 ; nous ne produisons pas encore d'archives v2.
Le lecteur v2 interne se trouve dans formats/hpi/hpi_v2.go.
hpi.Reader.Version() indique si le fichier chargé est en v1 ou en v2.
Tip
Vérifiez qu'une installation de TAK se charge.
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
Exemple commenté — aflakker.ufo
Le parcours d'origine de Joe D. utilisait l'extension d'unité Arm Flakker ; nous reprenons ici son en-tête disséqué. Vidage hexadécimal des 32 premiers octets :
00000000 48 41 50 49 00 00 01 00 24 02 00 00 7D 00 00 00 HAPI....$...}...
00000010 14 00 00 00 ... ...
Champ par champ :
| Offset | Octets | Champ | Valeur |
|---|---|---|---|
0x00 |
48 41 50 49 |
Marker |
'HAPI' ✓ |
0x04 |
00 00 01 00 |
Version |
0x00010000 ✓ |
0x08 |
24 02 00 00 |
DirectorySize |
0x224 = 548 octets |
0x0C |
7D 00 00 00 |
DecryptKey |
0x7D → key = (0x7D << 2) | (0x7D >> 6) = 0xF5 |
0x10 |
14 00 00 00 |
Start |
0x14 |
Déchiffrer [0x14, 0x224) et parcourir l'arborescence donne :
.
├── 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
Cette disposition à neuf fichiers est la forme canonique d'une extension
.ufo à une seule unité ; les archives de base totala*.hpi suivent le même
schéma à grande échelle (~30k fichiers).
Tip
Refaites cette dissection sur votre machine.
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
Réempaquetage
Pour construire une nouvelle archive à partir d'une arborescence :
kbot hpi pack ./aflakker-out --target aflakker-new.ufo
Par défaut, kbot utilise une compression LZ77 découpée en chunks avec la
clé d'en-tête standard 0xBF et la signature de fin de Cavedog, ce qui
produit un binaire que le moteur d'origine accepte. Les options le
préservent :
| Option | Effet |
|---|---|
--method lz77|zlib|none |
Compression des chunks ; none écrit des entrées stockées (non compressées). |
--key N |
Octet de clé de l'en-tête ; 0 écrit une archive non chiffrée. 255 est refusé : le jeu le lit comme « non chiffré ». |
--trailer "Copyright 1998 Cavedog Entertainment" |
Une autre année dans la signature de fin ; tout autre texte est refusé. |
--no-trailer |
Omet la signature de fin : le jeu ne montera pas le résultat (un avertissement est affiché). |
--format v2 |
Écrit une archive TA: Kingdoms (--key, --trailer, --no-trailer et --encode-chunks ne s'appliquent pas). |
Les noms de répertoires qui ne diffèrent que par la casse (Units/ et
units/ sur un disque sensible à la casse) sont fusionnés comme le jeu
compare les noms ; deux fichiers dont les chemins ne diffèrent que par la
casse sont tous deux stockés, le jeu lit celui qui a été empaqueté en
dernier, et pack affiche un avertissement.
Pièges
Warning
Les pointeurs sont absolus par rapport au début du fichier, pas du
répertoire. Il n'y a rien à soustraire. L'astuce d'implémentation
habituelle consiste à allouer un tampon de DirectorySize octets et à y
lire le répertoire déchiffré à l'offset Start, pour que les pointeurs
indexent directement le tampon.
- Formule du nombre de chunks —
ceil(FileSize / 65536). Il n'y a aucun champ de comptage explicite ; il faut le déduire deFileSize.
Tailles typiques
| Archive | Taille | Fichiers | Remarques |
|---|---|---|---|
totala1.hpi (sons, animations) |
~30 Mo | ~1900 | Jeu de base, la plus grosse archive. |
totala2.hpi (unités) |
~13 Mo | ~600 | L'essentiel des métadonnées et des scripts des unités. |
totala4.hpi (cartes) |
~50 Mo | ~300 | Toutes les cartes d'origine. |
worlds.hpi (sections) |
~20 Mo | ~5 000 | Bibliothèques SCT de l'éditeur. |
ccdata.ccx (Core Contingency) |
~25 Mo | ~1 500 | Unités et cartes de l'extension. |
.ufo typique à une seule unité |
10–150 Ko | 8–12 | La plupart des extensions d'unités tierces. |
.ufo de carte seule (avec .tnt + .ota) |
0,5–5 Mo | 2–4 | Distribution carte par carte. |
| Taux de compression | 35–55 % | — | LZ77 pour la plupart des fichiers ; ZLib pour les plus gros. |
- Certains chunks grossissent à la compression. Un chunk « compressé »
peut dépasser 64 Kio si LZ77 n'a trouvé aucune correspondance. Considérez
CompressedSizecomme la longueur qui fait foi pour la copie. - La somme de contrôle porte sur les octets chiffrés. Si vous déchiffrez d'abord, la vérification échouera.
- Les sauvegardes (marqueur
BANK) sont une tout autre affaire : elles réutilisent l'enveloppe HPI, mais le contenu encodé est un état de partie sérialisé, pas un système de fichiers. kbot ne les décode pas. .gp3exige le patch rev3.1 pour être chargé. C'est toujours un HPI à l'intérieur, mais le moteur refuse de le monter sur une installation non patchée.
Annexe — parcours hexadécimal complet de AFark.ufo
AFark.ufo est l'extension Arm Fark : une petite archive de 9 fichiers,
idéale pour une démonstration de bout en bout. Nous allons parcourir
l'en-tête, déchiffrer le répertoire, disséquer quelques entrées, puis
jeter un œil aux premiers octets d'un chunk compressé.
1. En-tête (en clair)
Octets bruts 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 ....
Décodage :
| Offset | Octets | Champ | Valeur |
|---|---|---|---|
0x00 |
48 41 50 49 |
Marker |
'HAPI' ✓ |
0x04 |
00 00 01 00 |
Version |
0x00010000 |
0x08 |
24 02 00 00 |
DirectorySize |
0x224 = 548 octets |
0x0C |
a6 00 00 00 |
DecryptKey |
octet 0xA6 |
0x10 |
14 00 00 00 |
Start |
0x14 |
Dériver la clé de chiffrement :
key = (0xA6 << 2) | (0xA6 >> 6)
= 0x98 | 0x02
= 0x9A
2. Répertoire déchiffré (offsets 0x14..0x224)
Après avoir appliqué plain[i] = cipher[i] ^ (uint8(0x14 + i) ^ 0x9A) au blob
du répertoire — et l'avoir inséré dans un tampon de 548 octets à l'offset
0x14 pour que les pointeurs indexent correctement —, les 96 premiers octets
se lisent :
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"
En 0x14, on trouve 0x00000008 : le nombre d'entrées de la racine,
soit 8 sous-répertoires de premier niveau. En 0x18, 0x0000001C :
l'offset de la première entrée du répertoire.
3. La première entrée
Les 9 octets en 0x1C se décodent ainsi :
| Champ | Octets | Valeur |
|---|---|---|
NameOffset |
64 00 00 00 |
0x64 → chaîne « anims » |
DataOffset |
6a 00 00 00 |
0x6A → descripteur de sous-répertoire |
Flag |
01 |
1 ⇒ sous-répertoire |
En 0x64 :
00000060 .... 61 6e 69 6d 73 00 ....anims.
^^^^^^^^^^^^^^^^^
La chaîne "anims\0", qui confirme le nom du répertoire.
En 0x6A :
00000060 .... 01 00 00 00 72 00 ........r.
^^^^^^^^^^^ ^^^^
00000070 00 00 7b 00 00 00 ... ..{...
^^^^^
| Champ | Octets | Valeur |
|---|---|---|
EntryCount du sous-répertoire |
01 00 00 00 |
1 |
EntriesOffset du sous-répertoire |
72 00 00 00 |
0x72 |
Le répertoire anims/ contient donc 1 fichier, décrit à partir de
0x72 :
| Champ | Octets | Valeur |
|---|---|---|
NameOffset |
7b 00 00 00 |
0x7B → nom du fichier |
DataOffset |
8e 00 00 00 |
0x8E → descripteur de fichier |
Flag |
00 |
0 ⇒ fichier |
Le nom en 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".
Le descripteur de fichier en 0x8E est un HPIFile de 9 octets :
00000080 24 02 $.
00000090 00 00 48 48 00 00 01 ..HH...
| Champ | Octets | Valeur |
|---|---|---|
DataOffset |
24 02 00 00 |
0x0224 — le premier octet après le répertoire ! |
FileSize |
48 48 00 00 |
0x4848 = 18 504 octets |
CompMethod |
01 |
1 ⇒ LZ77 |
Ce qui correspond à kbot hpi list -v :
anims/armfark_gadget.gaf 18504
4. Lire toute l'arborescence
En continuant avec les 7 entrées de premier niveau restantes, on obtient :
.
├── 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)
Neuf fichiers : la forme canonique d'un .ufo à une seule unité.
5. Un chunk compressé
Le premier octet après le répertoire de 548 octets se trouve en 0x224.
Déchiffrer cette zone (l'en-tête du chunk est lui aussi chiffré par le
chiffrement du répertoire) révèle :
00000220 .. 53 51 53 48 02 01 01 cc .SQSH....
| Offset (depuis 0x224) | Octets | Champ | Valeur |
|---|---|---|---|
+0 |
53 51 53 48 |
Marker |
'SQSH' ✓ |
+4 |
02 |
Version |
0x02 (toujours) |
+5 |
01 |
CompMethod |
1 ⇒ LZ77 |
+6 |
01 |
Encrypted |
1 ⇒ appliquer la seconde passe XOR |
+7.. |
… | CompressedSize, DecompressedSize, Checksum, puis data[] |
… |
Une fois le corps du chunk décodé avec data[i] = (data[i] - i) ^ i et les
octets obtenus passés dans le décodeur LZ77 décrit plus haut, vous obtenez le
GAF décompressé, prêt à être confié à un analyseur GAF.
Tip
Refaites ce parcours sur votre machine.
# 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
Annexe — parcours hexadécimal complet de Jersey.hpi (HPI v2)
L'équivalent TA: Kingdoms du parcours d'AFark. Jersey.hpi est un HPI de
pack de voix de 67 Ko livré avec l'installation GOG de TA:K : six WAV
d'effets sonores et rien d'autre. Assez petit pour être parcouru de bout en
bout, assez gros pour exercer tous les mécanismes de la v2 (en-tête de
32 octets, pool de noms, blob de répertoire enveloppé en SQSH, charges utiles
de fichiers en un seul chunk). Le parcours v1 ci-dessus couvre tout ce qui
reste identique ; cette section ne signale que les écarts de la v2.
1. L'en-tête de 32 octets
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 @...[... ...?...
| Offset | Octets | Champ | Valeur |
|---|---|---|---|
0x00 |
48 41 50 49 |
Marker |
'HAPI' ✓ |
0x04 |
00 00 02 00 |
Version |
0x00020000 — v2 |
0x08 |
92 06 01 00 |
DirectoryBlock |
0x00010692 — offset du blob du répertoire |
0x0C |
ad 00 00 00 |
DirectorySize |
0xAD = 173 octets |
0x10 |
40 06 01 00 |
NameBlock |
0x00010640 — offset du pool de noms |
0x14 |
5b 00 00 00 |
NameSize |
0x5B = 91 octets |
0x18 |
20 00 00 00 |
Data |
0x20 — les charges utiles des fichiers commencent ici, juste après l'en-tête |
0x1C |
3f 07 01 00 |
Last78 |
0x0001073F — marqueur de fin d'archive (0x1073F = 67 391 = taille du fichier − 78 octets de pied) |
Pas de chiffrement XOR, pas de transformation de déchiffrement, pas de graine
liée à la position. Les valeurs de l'en-tête pointent vers des offsets
absolus dans le fichier, sur lesquels on peut se positionner directement
(seek).
2. Le pool de noms (texte brut, près de la fin)
À DirectoryBlock moins quelques centaines d'octets (le moteur écrit le pool
de noms avant le répertoire) se trouve la table des chaînes :
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.
Chaque entrée de répertoire et de fichier stocke un NamePtr, offset en
octets dans ce bloc ; suivre le pointeur et lire jusqu'au \0 suivant donne
le nom. Octet par octet :
Offset (relatif à NameBlock) |
Chaîne terminée par NUL |
|---|---|
0x00 |
"" (répertoire racine — nom vide) |
0x01 |
"sounds" (le seul sous-répertoire) |
0x08 |
"Whodied.wav" |
0x14 |
"Sure.wav" |
0x1D |
"WhoahPally.wav" |
0x2C |
"Forgetaboutit.wav" |
0x3E |
"Yo.wav" |
0x45 |
"Igotcher.wav" |
Note
La taille nominale du pool de noms inclut quelques octets finaux
inutilisés. NameSize=0x5B va jusqu'à l'offset 0x1069B, mais le dernier
octet utile (le NUL après Igotcher.wav) est en 0x10691. Les 9 octets
finaux débordent sur le début du chunk SQSH du répertoire et ne sont
indexés par aucun NamePtr : les lecteurs les tolèrent simplement en ne les
regardant jamais.
3. Le blob du répertoire (enveloppé en SQSH)
En 0x10692, le blob du répertoire commence par un en-tête de chunk SQSH :
la v2 réutilise le format de chunk de la 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…
| Offset | Octets | Champ | Valeur |
|---|---|---|---|
+0 |
53 51 53 48 |
Marker |
'SQSH' ✓ |
+4 |
02 |
Version |
2 |
+5 |
02 |
CompMethod |
2 ⇒ ZLib |
+6 |
01 |
Encrypted |
1 — le corps du chunk a subi la seconde passe XOR (rare dans TAK ; cette archive l'a) |
+7 |
9a 00 00 00 |
CompressedSize |
0x9A = 154 octets |
+11 |
b8 00 00 00 |
DecompressedSize |
0xB8 = 184 octets |
+15 |
9b 4f 00 00 |
Checksum |
0x4F9B |
+19..+172 |
… | data[] |
154 octets de flux ZLib brouillé par XOR |
19 + 154 = 173 = 0xAD = DirectorySize : le chunk entier correspond bien à ce
qu'annonce l'en-tête. Après avoir annulé la passe XOR (même algorithme qu'en
v1) et être passé par inflate, on obtient 184 octets d'enregistrements
de répertoire compacts.
4. Décoder les enregistrements du répertoire
Le blob décompressé contient un tableau compact de deux types
d'enregistrements : des entrées de répertoire de 20 octets (HpiDir2) et
des entrées de fichier de 24 octets (HpiEntry2). Il n'y a ni préfixe de
comptage ni table d'offsets : le répertoire racine se trouve toujours à
l'offset 0 du blob, et ses champs FirstSubDirectory / FirstFile
pointent vers les enfants.
Le HpiDir2 racine (à l'offset décompressé 0, 20 octets) :
| Offset | Octets (décompressés) | Champ | Valeur |
|---|---|---|---|
+0 |
00 00 00 00 |
NamePtr |
0 → "" (racine) |
+4 |
14 00 00 00 |
FirstSubDirectory |
0x14 — un unique enregistrement de sous-répertoire de 20 octets à cet offset |
+8 |
01 00 00 00 |
SubCount |
1 (le répertoire sounds/) |
+12 |
00 00 00 00 |
FirstFile |
0 — aucun fichier à la racine |
+16 |
00 00 00 00 |
FileCount |
0 |
Suivre le pointeur vers 0x14 mène au HpiDir2 de sounds :
NamePtr = 0x01→"sounds"FirstSubDirectory = 0,SubCount = 0— aucun sous-répertoire imbriquéFirstFile = 0x28— le premier des six enregistrementsHpiEntry2(24 octets chacun)FileCount = 6
Chaque enregistrement de fichier, en 0x28, 0x40, 0x58, …, contient :
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
Une fois décodées, les six entrées correspondent exactement à ce
qu'affiche 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. Ce qui n'est PAS là (par rapport à la v1)
Si le parcours v1 est encore frais dans votre esprit, les pièces manquantes sont tout l'intérêt de la simplification de la v2 :
- Pas de dérivation de clé XOR —
DecryptKeyvaut toujours0en v2. Même quand un chunk SQSH aEncrypted == 1, la seconde passe XOR à l'intérieur du chunk est bien appliquée, mais l'enveloppe répertoire/charge utile est en clair. - Pas de graine liée à la position — le chiffrement octet par octet qui compliquait les lecteurs v1 (le déchiffrement dépend de l'offset absolu dans le fichier) n'existe pas au niveau de l'enveloppe v2.
- Pas de table des tailles de chunks — en v1, les fichiers étaient découpés en blocs de ≤64 Kio précédés d'un tableau de tailles en uint32. La v2 stocke chaque fichier en un seul chunk SQSH (ou en octets stockés) : il n'y a donc rien à mettre dans une table des tailles.
- Pas de convention « répertoire en
0x14, juste après l'en-tête » — les blocs du répertoire et des noms se trouvent là où l'écrivain les a placés (en général près de la fin du fichier). L'en-tête pointe vers eux.
Tip
Refaites ce parcours sur votre machine.
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