Alle Formatseiten ↓
Container- und Hüllformate

HPI / UFO / CCX / GP3 – HAPI-Archivdateien

Aus coreprime/kbot @ 0ab97bf · Auf GitHub bearbeiten

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
.gp3 Das Overlay des Patches 3.1 (rev31.gp3) Wird zuerst eingehängt; andere *.gp3 werden ignoriert
.ccx Daten von Core Contingency / Battle Tactics (btdata.ccx, ccdata.ccx) Nach rev31.gp3
.ufo Add-ons mit Einheiten/Kartenobjekten (AFark.ufo) Nach jedem .ccx; keine Mengenbegrenzung
.hpi Archive 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:

  1. rev31.gp3;
  2. jedes *.ccx;
  3. jedes *.ufo;
  4. die ersten zehn *.hpi, die sich öffnen lassen;
  5. 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.ufo vor zeta.ufo). Löst es einen Pfad wie units/ARMCOM.fbi auf, 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.hpi und totala2.hpi die zehn *.hpi-Plätze. totala3.hpi, totala4.hpi (die Karten der Verkaufsversion) und worlds.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 .ufo aus (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 tnt und im MCP-Server. Die Seite Home und der Tab Layering im Tab Files, die Befehle archives und stats von kbot mount sowie das MCP-Tool vfs_game_data listen 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 von IPData.hpi über data.hpi in diesem Spiel braucht.

Die Regel „lose Dateien gewinnen“ ist der Grund, warum kbot mount flatten ein 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/*.tdf und units/*.fbi (nur die oberste Ebene – ein Unterordner weapons/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. CarScar05 aus der Verkaufsversion ist in features/urban/cars2.tdf und noch einmal in cars.tdf definiert, und cars2.tdf ist zuerst gespeichert, also zeichnet das Spiel es aus anims/cars2.gaf. Das Studio (Kartenobjekt-Palette, Sandbox, Packs, Einheiten- und Waffenlisten), kbot tnt preview und kbot tnt lint sowie 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:

  1. Den 20-Byte-Header lesen. Die Magic Number "HAPI" prüfen.
  2. Aus DecryptKey den XOR-Schlüssel der Datei ableiten.
  3. Den Verzeichnisbaum entschlüsseln und lesen.
  4. Für jede gewünschte Datei zu ihren komprimierten Chunks springen, sie entschlüsseln und dann anhand von CompMethod verzweigen (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 uint16 lesen. Die oberen 12 Bits sind der Fensteroffset (1–4095), die unteren 4 Bits kodieren die Länge als len − 2 (Matches sind also 2–17 Byte lang).
  • Endmarke – ein Match mit windowOffset == 0 beendet 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: DecompressedSize Bytes unverändert lesen.
  • Andernfalls: Die nächsten CompressedSize Bytes 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 der CompMethod des 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 aus FileSize ableiten.

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 CompressedSize als 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.
  • .gp3 braucht 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 Unterverzeichnisse
  • FirstFile = 0x28 – der erste von sechs HpiEntry2-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 – DecryptKey ist bei v2 immer 0. Auch wenn ein SQSH-Chunk Encrypted == 1 hat, 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

Siehe auch

  • GAF, PCX, 3DO, TNT, COB, TDF – die Arten von Dateien, die HPI-Archive enthalten.
  • Glossar – Definitionen von virtual filesystem, layering und weiteren Begriffen rund um HPI.