HPI / UFO / CCX / GP3 – HAPI-Archivdateien
HAPI („HPI“) ist der verschlüsselte, komprimierte Container, in dem Cavedog jedes Spiel-Asset ausgeliefert hat. Derselbe Aufbau auf der Platte wird unter verschiedenen Endungen wiederverwendet:
Endung Zweck Einhängereihenfolge in TA 3.1c .gp3Das Overlay des Patches 3.1 ( rev31.gp3)Wird zuerst eingehängt; andere *.gp3werden ignoriert.ccxDaten von Core Contingency / Battle Tactics ( btdata.ccx,ccdata.ccx)Nach rev31.gp3.ufoAdd-ons mit Einheiten/Kartenobjekten ( AFark.ufo)Nach jedem .ccx; keine Mengenbegrenzung.hpiArchive des Grundspiels ( totala1.hpi,tactics1.hpi)Zuletzt; nur die ersten zehn, die sich öffnen lassen Alle vier sind byteweise dasselbe Format – nur die Einhängereihenfolge unterscheidet sich. Das Spiel fasst sie beim Start zu einem einzigen virtuellen Dateisystem zusammen, und das zuerst eingehängte Archiv, das einen Pfad enthält, gewinnt.
Important
Zwei Dialekte auf der Platte. Total Annihilation schreibt HPI v1
(Version == 0x00010000), den verschlüsselten Aufbau aus mehreren Chunks,
den diese Seite von oben bis unten dokumentiert. Total Annihilation:
Kingdoms schreibt HPI v2 (Version == 0x00020000) – einen anderen,
einfacheren Aufbau ohne XOR-Chiffre und mit einem einzigen SQSH-Chunk pro
Datei oder Verzeichnisblock. Die Einzelheiten von v2 stehen in einem eigenen
Abschnitt weiter unten auf dieser Seite:
TA: Kingdoms HPI v2. TA 3.1c hängt nur
v1-Archive ein.
Schichtung nach Ladereihenfolge
TA 3.1c durchsucht nur die oberste Ebene seines Spielverzeichnisses (Archive in Unterordnern wie
Backup/werden nie eingehängt) und hängt in dieser Reihenfolge ein:
rev31.gp3;- jedes
*.ccx;- jedes
*.ufo;- die ersten zehn
*.hpi, die sich öffnen lassen;- die
*.hpi-Dateien auf der obersten Ebene der Spiel-CD, ohne Mengenbegrenzung.Innerhalb jeder Gruppe nimmt das Spiel die Namen in ASCII-Reihenfolge der Großbuchstaben (der Reihenfolge, die seine Verzeichnisauflistung liefert:
_wird nach den Buchstaben einsortiert,AFark.ufovorzeta.ufo). Löst es einen Pfad wieunits/ARMCOM.fbiauf, gewinnen lose Dateien in der Installation, danach das erste eingehängte Archiv, das den Pfad enthält. Ein Archiv zählt nur dann als eingehängt, wenn es ein Archiv der Version 1 ist, das mit dem Copyright-Trailer von Cavedog endet (siehe Trailer) und dessen Verzeichnis sich lesen lässt; jede andere Datei wird übersprungen und belegt keinen der zehn*.hpi-Plätze.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"In der GOG-Version belegen
tactics1–8.hpi,totala1.hpiundtotala2.hpidie zehn*.hpi-Plätze.totala3.hpi,totala4.hpi(die Karten der Verkaufsversion) undworlds.hpi(die Kartensektionen) sind die Archive der Disc: Eine GOG-Installation hält sie im Spielverzeichnis, das die Disc vertritt, also hängt das Spiel sie nach allen anderen Archiven ein. Dasselbe gilt für jedes*.hpi, das ein Mod hinzufügt: Eines, dessen Name hinter dem zehnten Platz einsortiert wird, wird nur über diesen Disc-Scan erreicht, unterhalb von allem anderen. Liefere Mods als.ufoaus (keine Begrenzung, über jedem.hpi), um das zu vermeiden.kbot hängt Kontexte von Total Annihilation überall auf dieselbe Weise ein: im Studio, in
kbot mount,kbot mount flatten,kbot pack,kbot tntund im MCP-Server. Die Seite Home und der Tab Layering im Tab Files, die Befehlearchivesundstatsvonkbot mountsowie das MCP-Toolvfs_game_datalisten die Einhängereihenfolge und jedes nicht eingehängte Archiv mit dem Grund auf. Kontexte von TA: Kingdoms behalten kbots Overlay-Reihenfolge (jedes Archiv nach Endung und Name, ein späteres überschreibt ein früheres), die die Schichtung vonIPData.hpiüberdata.hpiin diesem Spiel braucht.Die Regel „lose Dateien gewinnen“ ist der Grund, warum
kbot mount flattenein Verzeichnis erzeugt, das sich genau wie der geschichtete Satz verhält: Jede Datei, die die Engine über den Archivstapel aufgelöst hätte, liegt als lose Datei unter demselben Pfad vor, sodass die Auflösung schon an der Spitze des Stapels endet.
Welche Definition gewinnt
Kartenobjekte, Waffen und Einheiten werden geladen, indem ein Verzeichnis aufgelistet wird:
features/**/*.tdf(mit Unterordnern),weapons/*.tdfundunits/*.fbi(nur die oberste Ebene – ein Unterordnerweapons/old/wird nie gelesen). Die Auflistung geht zuerst die losen Dateien durch, dann jedes eingehängte Archiv in Einhängereihenfolge mit seinen Einträgen in gespeicherter Reihenfolge, und die erste Definition eines Namens gewinnt; spätere werden ignoriert.CarScar05aus der Verkaufsversion ist infeatures/urban/cars2.tdfund noch einmal incars.tdfdefiniert, undcars2.tdfist zuerst gespeichert, also zeichnet das Spiel es ausanims/cars2.gaf. Das Studio (Kartenobjekt-Palette, Sandbox, Packs, Einheiten- und Waffenlisten),kbot tnt previewundkbot tnt lintsowie die MCP-Tools wenden dieselbe Regel an.
Auf einen Blick
┌─── 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"
So liest man ein HPI:
- Den 20-Byte-Header lesen. Die Magic Number
"HAPI"prüfen. - Aus
DecryptKeyden XOR-Schlüssel der Datei ableiten. - Den Verzeichnisbaum entschlüsseln und lesen.
- Für jede gewünschte Datei zu ihren komprimierten Chunks springen, sie
entschlüsseln und dann anhand von
CompMethodverzweigen (LZ77, ZLib oder keine).
Tip
Jedes HPI untersuchen, ohne einen Parser zu schreiben.
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
Alle Flags stehen in der
kbot hpi-Referenz der CLI.
Aus Go heraus. Verwende 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
Header (20 Byte, unverschlüsselt)
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;
| Feld | Anmerkungen |
|---|---|
Marker |
'HAPI' in Little-Endian. Die Variante 'BANK' wird von Spielständen verwendet und ist hier nicht abgedeckt. |
Version |
Archive der Verkaufsversion von Total Annihilation verwenden 0x00010000 (v1, dieser Abschnitt). Archive von TA: Kingdoms verwenden 0x00020000 (v2); siehe TA: Kingdoms HPI v2. |
DirectorySize |
Schließt die 20 Header-Bytes ein, der eigentliche Verzeichnis-Blob belegt also DirectorySize − Start Byte ab Start. |
DecryptKey |
Durch die Transformation unten schicken, um das Byte zu erhalten, das als XOR-Schlüssel dient. Die Schlüsselbytes 0 und 0xFF bedeuten für TA 3.1c beide „nicht verschlüsselt“. Cavedogs Archive der Verkaufsversion verwenden 0xBF. |
Start |
Wo das Verzeichnis beginnt. War in allen untersuchten Dateien immer 0x14. |
Den Chiffrierschlüssel ableiten
key = (DecryptKey << 2) | (DecryptKey >> 6) // rotate-left by 2 bits
Jedes verschlüsselte Byte wird dann mit seiner absoluten Dateiposition entschlüsselt:
plain[i] = cipher[i] ^ (uint8(pos + i) ^ key)
wobei pos der absolute Offset von cipher[0] in der Datei ist. Ja – der
Seed läuft mit der Dateiposition mit, du kannst einen Puffer also nicht
isoliert entschlüsseln; du musst wissen, woher er stammt.
Important
Ist das Schlüsselbyte 0 oder 0xFF, wende die Chiffre überhaupt nicht an.
TA 3.1c liest beide als Klartext (bei 0xFF ergäbe die Rotation oben
0xFF, aber das Spiel entschlüsselt nicht). Manche selbst erstellten
Archive (und eine Handvoll Community-Mods) sind ohne Verschlüsselung
geschrieben – die XOR-Chiffre in diesem Fall anzuwenden, verwürfelt, was
bereits Klartext ist. kbot hpi pack verweigert aus diesem Grund
--key 255; verwende --key 0.
Verzeichnisbaum
Ein Verzeichnis ist eine Anzahl plus ein Zeiger auf seinen ersten Eintrag:
typedef struct { uint32 EntryCount; uint32 EntriesOffset; } HPIDir;
Jeder Eintrag ist genau 9 Byte groß:
typedef struct {
uint32 NameOffset; // → NUL-terminated string
uint32 DataOffset; // → HPIDir (subdir) or HPIFile (leaf)
uint8 Flag; // 1 = subdirectory, 0 = file
} HPIEntry;
Bei Dateien zeigt DataOffset auf:
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;
Alle Offsets sind absolut innerhalb der Datei. Deshalb legt der
kanonische Parser einen Puffer von DirectorySize Byte an und schreibt das
Verzeichnis an die Position Start – so können Zeiger den Puffer direkt
indizieren, ohne Umrechnung.
Das Verzeichnis lesen
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
Die Groß-/Kleinschreibung von Dateinamen bleibt beim Schreiben erhalten,
beim Lesen wird sie aber ignoriert. Total Annihilation ist unter
Windows 9x groß geworden; Mods mischen oft armcom.fbi und ARMCOM.FBI,
sogar im selben Archiv. Das Spiel gleicht nur die Schreibweise von
ASCII-Buchstaben an, trennt Pfade sowohl an \ als auch an /, und wenn
ein Verzeichnis mehrere Einträge enthält, deren Namen sich nur in der
Schreibweise unterscheiden, liest es den letzten davon; die anderen sind
unerreichbar. Außerdem behandelt es . und .. als gewöhnliche Namen.
kbot hpi extract schreibt daher von einer solchen Gruppe nur den
erreichbaren Eintrag und überspringt (mit einer Meldung) jeden Eintrag,
dessen gespeicherter Pfad sich nicht innerhalb des Zielordners schreiben
ließe, etwa ../../x.txt; auch kbot mount flatten schreibt nie außerhalb
seines Ziels.
Komprimierte Chunks (SQSH)
Die Daten einer Datei werden in Chunks von bis zu 64 KiB (dekomprimiert) zerlegt. Jeder Chunk hat einen eigenen 19-Byte-Header:
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;
Die Anzahl der Chunks ist ceil(FileSize / 65536). Direkt am ersten
HPIFile.DataOffset der Datei findest du ein Array von komprimierten Größen
als uint32, eine pro Chunk, gefolgt von den Chunks selbst, lückenlos
hintereinander.
Die zweite XOR-Schicht
Ist Encrypted == 1, wird auf jeden Chunk-Rumpf ein zweiter XOR-Durchgang
angewendet:
for (i = 0; i < CompressedSize; i++)
data[i] = (data[i] - i) ^ i;
Beachte: Die Prüfsumme wird über die noch verschlüsselten Bytes gebildet – prüfe sie, bevor du die Chiffre rückgängig machst.
LZ77 (CompMethod = 1)
Ein LZ77 wie aus dem Lehrbuch, mit Sliding Window und 4 KiB Verlauf:
- Tag-Byte – 8 Flag-Bits, LSB zuerst.
0⇒ Literal,1⇒ Rückverweis. - Literal – 1 Byte unverändert in die Ausgabe und den Verlauf kopieren.
- Match – ein
uint16lesen. Die oberen 12 Bits sind der Fensteroffset (1–4095), die unteren 4 Bits kodieren die Länge alslen − 2(Matches sind also 2–17 Byte lang). - Endmarke – ein Match mit
windowOffset == 0beendet den Strom.
ZLib (CompMethod = 2)
Ein gewöhnlicher roher zlib-Strom. Gib den Chunk-Rumpf an inflate(),
nachdem die Verschlüsselungsschicht entfernt wurde; DecompressedSize ist
die erwartete Länge.
Unkomprimiert gespeichert (CompMethod = 0)
Die Bytes werden unverändert geschrieben – keine Chunks, keine Prüfsumme, kein zweiter XOR-Durchgang. Nützlich für Dateien, die sich nicht komprimieren lassen (bereits komprimierte PCX/GAF), und der einzige Modus, den der alte „Unit Viewer“ versteht.
Trailer
Jedes Archiv, das das Spiel einhängt, endet mit einer 36 Byte langen ASCII-Copyright-Zeile:
Copyright 1997 Cavedog Entertainment
Nichts im Header zeigt darauf, aber sie ist nicht optional: Bevor TA 3.1c
ein Archiv einhängt, prüft es, dass dessen letzte 36 Byte
Copyright ____ Cavedog Entertainment lauten (die vier Zeichen der
Jahreszahl werden nicht verglichen). Ein Archiv ohne diese Zeile oder mit
irgendwelchen Bytes dahinter wird übersprungen und belegt keinen der zehn
*.hpi-Plätze – ein ohne Trailer gepacktes Mod fehlt im Spiel
stillschweigend.
Jedes Archiv, das kbot schreibt, behält ihn bei: kbot hpi pack und die
Exporte des Studios (Export mod eines Workspace und der Archiv-Download des
Karteneditors). kbot hpi info, kbot hpi list -v, die MCP-Tools
hpi_info und hpi_list sowie die Metadaten eines Archivs im Tab Files
geben eine Zeile TA 3.1c would mount: yes / no, <reason> aus.
TA: Kingdoms – HPI v2
Note
Dieser Abschnitt beschreibt nur, was in TA:K anders ist. Alles oben
gilt für Total Annihilation (Version == 0x00010000). TA: Kingdoms
liefert dieselbe Endung .hpi aus, aber mit einem anderen Aufbau auf der
Platte – lies diesen Abschnitt vollständig, bevor du zum v1-Parser greifst.
TA: Kingdoms behält die Magic Number 'HAPI' und das Konzept des
Verzeichnisbaums bei, baut die Hülle aber ansonsten um. Die Unterschiede in
aller Kürze:
| Aspekt | v1 (TA) | v2 (TA: Kingdoms) |
|---|---|---|
| Versionsfeld | 0x00010000 |
0x00020000 |
| Header-Größe | 20 Byte (5 × uint32) | 32 Byte (8 Byte Magic Number + Version + 24-Byte-Sub-Header) |
| Verschlüsselung | XOR-Chiffre mit DecryptKey als Seed, läuft mit der Dateiposition mit |
Keine – alles ist Klartext |
| Lage von Verzeichnis + Namens-Pool | Direkt hinter dem Header (verschlüsselt) | An beliebigen Offsets (typischerweise) nahe dem Dateiende, jeweils optional in einen einzelnen SQSH-Chunk verpackt |
| Aufteilung pro Datei | In Chunks ≤64 KiB mit Längentabelle zerlegt; jeder Chunk hat einen eigenen SQSH-Header + Prüfsumme + optionalen zweiten XOR-Durchgang | Ein einziger SQSH-Chunk mit den gesamten Nutzdaten, oder unkomprimierte Bytes, wenn CompressedSize == 0; kein XOR-Durchgang, keine Längentabelle |
Chiffre pro Chunk (Byte Encrypted in SQSH) |
Verwendet | Nicht verwendet (wir beachten das Byte trotzdem, falls es gesetzt ist, aber in jeder untersuchten TAK-Datei ist es 0) |
v2-Header
Auf den 8-Byte-Prolog ('HAPI' + 0x00020000) folgen sechs
32-Bit-Felder in Little-Endian – der Header ist auf der Platte insgesamt
32 Byte groß:
// 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;
Verzeichnis-Blob und Namens-Blob können beide SQSH-komprimiert sein –
sind die ersten vier Bytes an DirectoryBlock / NameBlock gleich
'SQSH', behandle den Block als einzelnen Chunk (Header + Nutzdaten) und
dekomprimiere ihn. Andernfalls sind die Size Bytes am angegebenen Offset
der rohe, dekomprimierte Inhalt.
v2-Verzeichniseinträge
Der dekomprimierte Verzeichnis-Blob ist ein gepacktes Array aus zwei
Datensatztypen (keine eigenen Offset-Tabellen, kein vorangestelltes
EntryCount – Kinder werden über die Zeiger FirstSubDirectory /
FirstFile des Elternteils gefunden):
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;
Nutzdaten pro Datei
Für einen Dateieintrag am Offset Start:
- Ist
CompressedSize == 0:DecompressedSizeBytes unverändert lesen. - Andernfalls: Die nächsten
CompressedSizeBytes bilden einen einzigen SQSH-Chunk (mit demselben 19-Byte-Header wie in v1) – lies ihn, prüfe seine Prüfsumme gegen die Bytesumme der Nutzdaten, dekodiere mit derCompMethoddes Chunks (1= LZ77,2= ZLib), und du hast die Datei.
Das v1-Schema mit mehreren Chunks (Größentabelle, Chunks ≤64 KiB, Verschlüsselungsdurchgang) entfällt. v2 tauscht die Kompaktheit gegen einen erheblich einfacheren Leser.
Wie kbot damit umgeht
kbot hpi list, extract und info unterstützen beide Dialekte
transparent – sie verzweigen anhand des Worts Version im Header. Das
Schreiben im Round-Trip (kbot hpi pack) erzeugt derzeit nur v1; v2-Archive
erstellen wir noch nicht.
Der interne v2-Leser steht in formats/hpi/hpi_v2.go.
hpi.Reader.Version() meldet, ob die geladene Datei v1 oder v2 ist.
Tip
Prüfen, ob eine TAK-Installation lädt.
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
Beispiel im Detail – aflakker.ufo
Die ursprüngliche Beschreibung von Joe D. verwendete das Einheiten-Add-on Arm Flakker; wir zerlegen hier dessen Header noch einmal. Hexdump der ersten 32 Bytes:
00000000 48 41 50 49 00 00 01 00 24 02 00 00 7D 00 00 00 HAPI....$...}...
00000010 14 00 00 00 ... ...
Feld für Feld:
| Offset | Bytes | Feld | Wert |
|---|---|---|---|
0x00 |
48 41 50 49 |
Marker |
'HAPI' ✓ |
0x04 |
00 00 01 00 |
Version |
0x00010000 ✓ |
0x08 |
24 02 00 00 |
DirectorySize |
0x224 = 548 Byte |
0x0C |
7D 00 00 00 |
DecryptKey |
0x7D → key = (0x7D << 2) | (0x7D >> 6) = 0xF5 |
0x10 |
14 00 00 00 |
Start |
0x14 |
Entschlüsselt man [0x14, 0x224) und durchläuft den Baum, ergibt sich:
.
├── 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
Dieser Aufbau aus neun Dateien ist die kanonische Form eines .ufo-Add-ons
mit einer einzelnen Einheit; die Basisarchive totala*.hpi verwenden
dasselbe Schema in großem Maßstab (~30.000 Dateien).
Tip
Diese Zerlegung auf dem eigenen Rechner nachvollziehen.
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
Neu packen
Um aus einem Verzeichnisbaum ein neues Archiv zu bauen:
kbot hpi pack ./aflakker-out --target aflakker-new.ufo
kbot verwendet standardmäßig LZ77-Kompression in Chunks mit dem üblichen
Header-Schlüssel 0xBF und dem Cavedog-Trailer – das ergibt eine Binärdatei,
die die Engine der Verkaufsversion akzeptiert. Die Flags sorgen dafür, dass
das so bleibt:
| Flag | Wirkung |
|---|---|
--method lz77|zlib|none |
Chunk-Kompression; none schreibt unkomprimiert gespeicherte Einträge. |
--key N |
Schlüsselbyte im Header; 0 schreibt ein unverschlüsseltes Archiv. 255 wird abgelehnt: Das Spiel liest es als „nicht verschlüsselt“. |
--trailer "Copyright 1998 Cavedog Entertainment" |
Eine andere Jahreszahl im Trailer; jeder andere Text wird abgelehnt. |
--no-trailer |
Lässt den Trailer weg – das Spiel hängt das Ergebnis nicht ein (eine Warnung wird ausgegeben). |
--format v2 |
Schreibt ein Archiv für TA: Kingdoms (--key, --trailer, --no-trailer und --encode-chunks gelten dann nicht). |
Verzeichnisnamen, die sich nur in der Groß-/Kleinschreibung unterscheiden
(Units/ und units/ auf einem Dateisystem, das sie unterscheidet), werden
so zusammengeführt, wie das Spiel Namen vergleicht; zwei Dateien, deren Pfade
sich nur in der Schreibweise unterscheiden, werden beide gespeichert, das
Spiel liest die zuletzt gepackte, und pack gibt eine Warnung aus.
Stolperfallen
Warning
Zeiger sind absolut zum Dateianfang, nicht zum Verzeichnisanfang.
Nichts abziehen. Der übliche Implementierungstrick ist, einen Puffer von
DirectorySize Byte anzulegen und das entschlüsselte Verzeichnis am
Offset Start hineinzulesen, sodass die Zeiger den Puffer direkt indizieren.
- Formel für die Chunk-Anzahl –
ceil(FileSize / 65536). Es gibt kein ausdrückliches Anzahlfeld; du musst sie ausFileSizeableiten.
Typische Größen
| Archiv | Größe | Dateien | Anmerkungen |
|---|---|---|---|
totala1.hpi (Sounds, Animationen) |
~30 MB | ~1.900 | Grundspiel, größtes einzelnes Archiv. |
totala2.hpi (Einheiten) |
~13 MB | ~600 | Die meisten Einheiten-Metadaten + Skripte. |
totala4.hpi (Karten) |
~50 MB | ~300 | Alle Karten der Verkaufsversion. |
worlds.hpi (Sektionen) |
~20 MB | ~5.000 | SCT-Bibliotheken des Editors. |
ccdata.ccx (Core Contingency) |
~25 MB | ~1.500 | Einheiten/Karten der Erweiterung. |
Typisches .ufo mit einer Einheit |
10–150 KB | 8–12 | Die meisten Einheiten-Add-ons von Drittanbietern. |
.ufo nur mit Karte (mit .tnt + .ota) |
0,5–5 MB | 2–4 | Verbreitung einzelner Karten. |
| Kompressionsrate | 35–55 % | – | LZ77 für die meisten Dateien; ZLib für die größten. |
- Manche Chunks werden durch Kompression größer. Ein „komprimierter“ Chunk
kann größer als 64 KiB sein, wenn LZ77 keine Matches gefunden hat. Nimm
CompressedSizeals maßgebliche Länge zum Kopieren. - Die Prüfsumme läuft über verschlüsselte Bytes. Entschlüsselst du zuerst, schlägt die Prüfsumme fehl.
- Spielstände (Marker
BANK) sind etwas ganz anderes – sie verwenden die HPI-Hülle wieder, aber der kodierte Inhalt ist serialisierter Spielzustand, kein Dateisystem. kbot dekodiert sie nicht. .gp3braucht zum Laden den Patch rev3.1. Innen ist es weiterhin ein HPI, aber die Engine weigert sich, es in ungepatchten Installationen einzuhängen.
Anhang – Vollständiger Hex-Durchgang durch AFark.ufo
AFark.ufo ist das Add-on Arm Fark – ein kleines Archiv mit 9 Dateien, ideal
für eine Vorführung von Anfang bis Ende. Wir gehen den Header durch,
entschlüsseln das Verzeichnis, zerlegen ein paar Einträge und werfen dann
einen Blick auf die ersten Bytes eines komprimierten Chunks.
1. Header (unverschlüsselt)
Rohe Bytes 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 ....
Dekodiert:
| Offset | Bytes | Feld | Wert |
|---|---|---|---|
0x00 |
48 41 50 49 |
Marker |
'HAPI' ✓ |
0x04 |
00 00 01 00 |
Version |
0x00010000 |
0x08 |
24 02 00 00 |
DirectorySize |
0x224 = 548 Byte |
0x0C |
a6 00 00 00 |
DecryptKey |
Byte 0xA6 |
0x10 |
14 00 00 00 |
Start |
0x14 |
Den Chiffrierschlüssel ableiten:
key = (0xA6 << 2) | (0xA6 >> 6)
= 0x98 | 0x02
= 0x9A
2. Entschlüsseltes Verzeichnis (Offsets 0x14..0x224)
Nachdem plain[i] = cipher[i] ^ (uint8(0x14 + i) ^ 0x9A) über den
Verzeichnis-Blob gelaufen ist – und er am Offset 0x14 in einen
548-Byte-Puffer eingesetzt wurde, damit die Zeiger korrekt indizieren –,
lauten die ersten 96 Bytes:
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"
Bei 0x14 steht 0x00000008 – die Anzahl der Einträge in der
Wurzel: 8 Unterverzeichnisse auf oberster Ebene. Bei 0x18 steht
0x0000001C – der Offset des ersten Verzeichniseintrags.
3. Der erste Eintrag
Die 9 Bytes bei 0x1C dekodieren sich so:
| Feld | Bytes | Wert |
|---|---|---|
NameOffset |
64 00 00 00 |
0x64 → String „anims“ |
DataOffset |
6a 00 00 00 |
0x6A → Deskriptor eines Unterverzeichnisses |
Flag |
01 |
1 ⇒ Unterverzeichnis |
Bei 0x64:
00000060 .... 61 6e 69 6d 73 00 ....anims.
^^^^^^^^^^^^^^^^^
Der String "anims\0" – das bestätigt den Verzeichnisnamen.
Bei 0x6A:
00000060 .... 01 00 00 00 72 00 ........r.
^^^^^^^^^^^ ^^^^
00000070 00 00 7b 00 00 00 ... ..{...
^^^^^
| Feld | Bytes | Wert |
|---|---|---|
EntryCount des Unterverzeichnisses |
01 00 00 00 |
1 |
EntriesOffset des Unterverzeichnisses |
72 00 00 00 |
0x72 |
Das Verzeichnis anims/ enthält also 1 Datei, beschrieben ab 0x72:
| Feld | Bytes | Wert |
|---|---|---|
NameOffset |
7b 00 00 00 |
0x7B → Dateiname |
DataOffset |
8e 00 00 00 |
0x8E → Dateideskriptor |
Flag |
00 |
0 ⇒ Datei |
Der Name bei 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".
Der Dateideskriptor bei 0x8E ist ein 9 Byte großes HPIFile:
00000080 24 02 $.
00000090 00 00 48 48 00 00 01 ..HH...
| Feld | Bytes | Wert |
|---|---|---|
DataOffset |
24 02 00 00 |
0x0224 – das erste Byte hinter dem Verzeichnis! |
FileSize |
48 48 00 00 |
0x4848 = 18.504 Byte |
CompMethod |
01 |
1 ⇒ LZ77 |
Das stimmt mit kbot hpi list -v überein:
anims/armfark_gadget.gaf 18504
4. Den ganzen Baum lesen
Geht man die übrigen 7 Einträge der obersten Ebene weiter durch, ergibt sich:
.
├── 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)
Neun Dateien, die kanonische Form eines .ufo mit einer einzelnen Einheit.
5. Ein komprimierter Chunk
Das erste Byte hinter dem 548 Byte großen Verzeichnis liegt bei 0x224.
Entschlüsselt man diesen Bereich (der Chunk-Header ist ebenfalls mit der
Chiffre des Verzeichnisses verschlüsselt), zeigt sich:
00000220 .. 53 51 53 48 02 01 01 cc .SQSH....
| Offset (ab 0x224) | Bytes | Feld | Wert |
|---|---|---|---|
+0 |
53 51 53 48 |
Marker |
'SQSH' ✓ |
+4 |
02 |
Version |
0x02 (immer) |
+5 |
01 |
CompMethod |
1 ⇒ LZ77 |
+6 |
01 |
Encrypted |
1 ⇒ zweiten XOR-Durchgang anwenden |
+7.. |
… | CompressedSize, DecompressedSize, Checksum, danach data[] |
… |
Hast du den Chunk-Rumpf mit data[i] = (data[i] - i) ^ i entwürfelt und die
resultierenden Bytes durch den oben beschriebenen LZ77-Decoder geschickt,
erhältst du das dekomprimierte GAF – bereit für einen GAF-Parser.
Tip
Diesen Durchgang auf dem eigenen Rechner nachvollziehen.
# 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
Anhang – Vollständiger Hex-Durchgang durch Jersey.hpi (HPI v2)
Das Gegenstück zum AFark-Durchgang für TA: Kingdoms. Jersey.hpi ist ein
67 KB großes HPI mit einem Sprachpaket, das in der GoG-Installation von
TA:K enthalten ist – sechs Soundeffekt-WAVs und sonst nichts. Klein genug,
um es von Anfang bis Ende durchzugehen, groß genug, um jede Mechanik von v2
zu zeigen (32-Byte-Header, Namens-Pool, in SQSH verpackter
Verzeichnis-Blob, Nutzdaten aus einem einzigen Chunk pro Datei). Der
v1-Durchgang oben deckt alles ab, was gleich bleibt; dieser Abschnitt hebt
nur die Unterschiede von v2 hervor.
1. Der 32-Byte-Header
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 | Bytes | Feld | Wert |
|---|---|---|---|
0x00 |
48 41 50 49 |
Marker |
'HAPI' ✓ |
0x04 |
00 00 02 00 |
Version |
0x00020000 – v2 |
0x08 |
92 06 01 00 |
DirectoryBlock |
0x00010692 – Offset des Verzeichnis-Blobs |
0x0C |
ad 00 00 00 |
DirectorySize |
0xAD = 173 Byte |
0x10 |
40 06 01 00 |
NameBlock |
0x00010640 – Offset des Namens-Pools |
0x14 |
5b 00 00 00 |
NameSize |
0x5B = 91 Byte |
0x18 |
20 00 00 00 |
Data |
0x20 – hier beginnen die Nutzdaten der Dateien, direkt hinter dem Header |
0x1C |
3f 07 01 00 |
Last78 |
0x0001073F – Markierung für das Archivende (0x1073F = 67.391 = Dateigröße − 78 Byte Fußbereich) |
Keine XOR-Chiffre, keine Entschlüsselungstransformationen, kein Seed pro
Position. Die Header-Werte zeigen auf absolute Datei-Offsets, die du direkt
per seek anspringen kannst.
2. Der Namens-Pool (roher Text, nahe dem Ende)
Bei DirectoryBlock minus ein paar hundert Bytes (die Engine schreibt den
Namens-Pool vor das Verzeichnis) liegt die String-Tabelle:
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.
Jeder Verzeichnis- und Dateieintrag speichert einen NamePtr, einen
Byte-Offset in diesen Block; folgt man dem Zeiger und liest bis zum nächsten
\0, erhält man den Namen. Byte für Byte:
Offset (relativ zu NameBlock) |
NUL-terminierter String |
|---|---|
0x00 |
"" (Wurzelverzeichnis – leerer Name) |
0x01 |
"sounds" (das einzige Unterverzeichnis) |
0x08 |
"Whodied.wav" |
0x14 |
"Sure.wav" |
0x1D |
"WhoahPally.wav" |
0x2C |
"Forgetaboutit.wav" |
0x3E |
"Yo.wav" |
0x45 |
"Igotcher.wav" |
Note
Die nominelle Größe des Namens-Pools schließt ein paar ungenutzte Bytes
am Ende ein. NameSize=0x5B reicht bis zum Offset 0x1069B, aber das
letzte bedeutsame Byte (die NUL hinter Igotcher.wav) liegt bei 0x10691.
Die 9 überzähligen Bytes ragen in den Anfang des SQSH-Chunks des
Verzeichnisses hinein und werden von keinem NamePtr indiziert – Leser
tolerieren sie einfach, indem sie sie nie ansehen.
3. Der Verzeichnis-Blob (in SQSH verpackt)
Bei 0x10692 beginnt der Verzeichnis-Blob mit einem SQSH-Chunk-Header – v2
verwendet das Chunk-Format von v1 wieder:
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 | Bytes | Feld | Wert |
|---|---|---|---|
+0 |
53 51 53 48 |
Marker |
'SQSH' ✓ |
+4 |
02 |
Version |
2 |
+5 |
02 |
CompMethod |
2 ⇒ ZLib |
+6 |
01 |
Encrypted |
1 – auf den Chunk-Rumpf ist der zweite XOR-Durchgang angewendet (selten in TAK; dieses Archiv hat ihn) |
+7 |
9a 00 00 00 |
CompressedSize |
0x9A = 154 Byte |
+11 |
b8 00 00 00 |
DecompressedSize |
0xB8 = 184 Byte |
+15 |
9b 4f 00 00 |
Checksum |
0x4F9B |
+19..+172 |
… | data[] |
154 Byte XOR-verwürfelter ZLib-Strom |
19 + 154 = 173 = 0xAD = DirectorySize, der ganze Chunk passt also zur
Angabe im Header. Nachdem der XOR-Durchgang rückgängig gemacht (derselbe
Algorithmus wie in v1) und das Ergebnis durch inflate geschickt wurde,
erhältst du 184 Byte gepackter Verzeichnisdatensätze.
4. Die Verzeichnisdatensätze dekodieren
Der dekomprimierte Blob enthält ein gepacktes Array aus zwei
Datensatztypen: 20 Byte große Verzeichniseinträge (HpiDir2) und 24 Byte
große Dateieinträge (HpiEntry2). Es gibt kein vorangestelltes
Anzahlfeld und keine Offset-Tabelle – das Wurzelverzeichnis liegt immer am
Offset 0 des Blobs, und seine Felder FirstSubDirectory / FirstFile
zeigen auf die Kinder.
Das HpiDir2 der Wurzel (am dekomprimierten Offset 0, 20 Byte):
| Offset | Bytes (dekomprimiert) | Feld | Wert |
|---|---|---|---|
+0 |
00 00 00 00 |
NamePtr |
0 → "" (Wurzel) |
+4 |
14 00 00 00 |
FirstSubDirectory |
0x14 – einzelner 20-Byte-Datensatz eines Unterverzeichnisses an diesem Offset |
+8 |
01 00 00 00 |
SubCount |
1 (das Verzeichnis sounds/) |
+12 |
00 00 00 00 |
FirstFile |
0 – keine Dateien in der Wurzel |
+16 |
00 00 00 00 |
FileCount |
0 |
Folgt man dem Zeiger nach 0x14, landet man beim HpiDir2 von sounds:
NamePtr = 0x01→"sounds"FirstSubDirectory = 0,SubCount = 0– keine verschachtelten UnterverzeichnisseFirstFile = 0x28– der erste von sechsHpiEntry2-Datensätzen (je 24 Byte)FileCount = 6
Jeder Dateidatensatz bei 0x28, 0x40, 0x58, … enthält:
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
Dekodiert entsprechen die sechs Einträge genau dem, was kbot hpi list
meldet:
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. Was hier FEHLT (im Vergleich zu v1)
Wenn du den v1-Durchgang noch frisch im Kopf hast: Die fehlenden Teile sind genau der Sinn der Vereinfachung in v2:
- Keine Ableitung einer XOR-Chiffre –
DecryptKeyist bei v2 immer0. Auch wenn ein SQSH-ChunkEncrypted == 1hat, wird der zweite XOR-Durchgang innerhalb des Chunks zwar weiterhin angewendet, die Hülle aus Verzeichnis und Nutzdaten ist aber Klartext. - Kein Seed pro Position – die byteweise Chiffre, die v1-Leser verkompliziert hat (die Entschlüsselung hängt vom absoluten Datei-Offset ab), gibt es auf der Ebene der v2-Hülle nicht.
- Keine Tabelle der Chunk-Größen – v1-Dateien waren in Blöcke ≤64 KiB mit einem vorangestellten Array aus uint32-Größen zerlegt. v2 speichert jede Datei als einen einzigen SQSH-Chunk (oder als unkomprimierte Bytes), es gibt also nichts, wofür eine Größentabelle nötig wäre.
- Keine Konvention „Verzeichnis direkt hinter dem Header bei
0x14“ – Verzeichnis- und Namensblock liegen dort, wo der Writer sie abgelegt hat (typischerweise nahe dem Dateiende). Der Header zeigt auf sie.
Tip
Diesen Durchgang auf dem eigenen Rechner nachvollziehen.
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