Toutes les pages de formats ↓
Conteneurs et enveloppes

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
.gp3 La surcouche du patch 3.1 (rev31.gp3) Montée en premier ; les autres *.gp3 sont ignorées
.ccx Données de Core Contingency / Battle Tactics (btdata.ccx, ccdata.ccx) Après rev31.gp3
.ufo Extensions d'unités ou d'éléments de décor (AFark.ufo) Après toutes les .ccx ; sans limite de nombre
.hpi Archives 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 :

  1. rev31.gp3 ;
  2. toutes les *.ccx ;
  3. toutes les *.ufo ;
  4. les dix premières *.hpi qui s'ouvrent ;
  5. 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.ufo avant zeta.ufo). Quand il résout un chemin comme units/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 *.hpi sont pris par tactics1–8.hpi, totala1.hpi et totala2.hpi. totala3.hpi, totala4.hpi (les cartes d'origine) et worlds.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 *.hpi ajouté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 tnt et le serveur MCP. La page Home et l'onglet Layering de l'onglet Files, les commandes archives et stats de kbot mount et l'outil MCP vfs_game_data listent 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 de IPData.hpi sur data.hpi de ce jeu.

La règle « les fichiers libres l'emportent » explique pourquoi kbot mount flatten produit 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/*.tdf et units/*.fbi (premier niveau seulement — un sous-dossier weapons/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. Le CarScar05 d'origine est défini dans features/urban/cars2.tdf et de nouveau dans cars.tdf, et cars2.tdf est stocké en premier, donc le jeu le dessine à partir de anims/cars2.gaf. Le studio (palette d'éléments, bac à sable, packs, listes d'unités et d'armes), kbot tnt preview et kbot 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 :

  1. Lire l'en-tête de 20 octets. Vérifier le nombre magique "HAPI".
  2. Dériver de DecryptKey la clé XOR propre au fichier.
  3. Déchiffrer et lire l'arborescence du répertoire.
  4. 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 forme len − 2 (les correspondances font donc 2 à 17 octets).
  • Terminateur — une correspondance avec windowOffset == 0 termine 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 : lire DecompressedSize octets tels quels.
  • Sinon : les CompressedSize octets 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 le CompMethod du 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 de FileSize.

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 CompressedSize comme 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.
  • .gp3 exige 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 enregistrements HpiEntry2 (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 — DecryptKey vaut toujours 0 en v2. Même quand un chunk SQSH a Encrypted == 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

Voir aussi

  • GAF, PCX, 3DO, TNT, COB, TDF — les types de fichiers que contiennent les archives HPI.
  • Glossaire — définitions de système de fichiers virtuel, superposition et d'autres termes liés aux HPI.