Tutte le pagine dei formati ↓
Contenitori e involucri

HPI / UFO / CCX / GP3 — File di archivio HAPI

Fonte: coreprime/kbot @ 0ab97bf · Modifica su GitHub

HAPI ("HPI") è il contenitore cifrato e compresso con cui Cavedog distribuiva ogni asset del gioco. La stessa struttura su disco viene riutilizzata con estensioni diverse:

Estensione Scopo Ordine di montaggio in TA 3.1c
.gp3 L'overlay della patch 3.1 (rev31.gp3) Montato per primo; gli altri *.gp3 vengono ignorati
.ccx Dati di Core Contingency / Battle Tactics (btdata.ccx, ccdata.ccx) Dopo rev31.gp3
.ufo Componenti aggiuntivi di unità ed elementi (AFark.ufo) Dopo ogni .ccx; nessun limite di numero
.hpi Archivi del gioco base (totala1.hpi, tactics1.hpi) Per ultimi; solo i primi dieci che si aprono

Tutti e quattro sono formati identici byte per byte: cambia solo l'ordine di montaggio. All'avvio il gioco li fonde in un unico filesystem virtuale, e vince il primo archivio montato che contiene un dato percorso.

Important

Due dialetti su disco. Total Annihilation scrive HPI v1 (Version == 0x00010000), la struttura cifrata a più chunk che questa pagina documenta da cima a fondo. Total Annihilation: Kingdoms scrive HPI v2 (Version == 0x00020000), una struttura diversa e più semplice, senza cifratura XOR e con un solo chunk SQSH per file o blocco di directory. I dettagli di v2 hanno una sezione tutta loro verso il fondo di questa pagina: TA: Kingdoms HPI v2. TA 3.1c monta solo archivi v1.

Stratificazione per ordine di caricamento

TA 3.1c esamina solo il livello superiore della sua cartella di gioco (gli archivi nelle sottocartelle, come Backup/, non vengono mai montati) e monta, in quest'ordine:

  1. rev31.gp3;
  2. ogni *.ccx;
  3. ogni *.ufo;
  4. i primi dieci *.hpi che si aprono;
  5. i file *.hpi al livello superiore del CD del gioco, senza limite di numero.

All'interno di ciascun gruppo il gioco prende i nomi in ordine ASCII maiuscolo (l'ordine restituito dal suo elenco della directory: _ viene dopo le lettere, AFark.ufo prima di zeta.ufo). Quando risolve un percorso come units/ARMCOM.fbi, vincono i file sciolti dell'installazione, poi il primo archivio montato che contiene il percorso. Un archivio conta come montato solo se è un archivio versione 1 che termina con il trailer di copyright di Cavedog (vedi Trailer) e la cui directory si legge correttamente; qualsiasi altro file viene saltato e non occupa uno dei dieci slot *.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"

Nella release GOG i dieci slot *.hpi sono occupati da tactics1–8.hpi, totala1.hpi e totala2.hpi. totala3.hpi, totala4.hpi (le mappe originali) e worlds.hpi (le sezioni di mappa) sono gli archivi del CD: un'installazione GOG li tiene nella cartella del gioco, che fa le veci del CD, quindi il gioco li monta dopo tutti gli altri archivi. Lo stesso vale per qualsiasi *.hpi aggiunto da una mod: uno il cui nome viene ordinato dopo il decimo slot viene raggiunto solo tramite quella scansione del CD, sotto tutto il resto. Per evitarlo, distribuisci le mod come .ufo (nessun limite, sopra ogni .hpi).

kbot monta i contesti di Total Annihilation allo stesso modo, ovunque: lo studio, kbot mount, kbot mount flatten, kbot pack, kbot tnt e il server MCP. La pagina Home e la scheda Layering della scheda Files, i comandi archives e stats di kbot mount e lo strumento MCP vfs_game_data elencano l'ordine di montaggio e ogni archivio che non è stato montato, con il motivo. I contesti di TA: Kingdoms mantengono l'ordine di overlay di kbot (ogni archivio per estensione e nome, con quello successivo che sovrascrive il precedente), di cui ha bisogno la stratificazione di IPData.hpi sopra data.hpi di quel gioco.

La regola "vincono i file sciolti" è il motivo per cui kbot mount flatten produce una directory che si comporta in modo identico all'insieme stratificato: ogni file che il motore avrebbe risolto attraverso la pila di archivi è presente come file sciolto allo stesso percorso, quindi la risoluzione si ferma subito in cima alla pila.

Quale definizione vince

Elementi, armi e unità vengono caricati elencando una directory: features/**/*.tdf (sottocartelle incluse), weapons/*.tdf e units/*.fbi (solo il livello superiore: una sottocartella weapons/old/ non viene mai letta). L'elenco scorre prima i file sciolti, poi ogni archivio montato nell'ordine di montaggio, con le sue voci nell'ordine di memorizzazione, e vince la prima definizione di un nome; le successive vengono ignorate. Il CarScar05 originale è definito in features/urban/cars2.tdf e di nuovo in cars.tdf, e cars2.tdf è memorizzato per primo, quindi il gioco lo disegna da anims/cars2.gaf. Lo studio (palette degli elementi, sandbox, pacchetti, elenchi di unità e armi), kbot tnt preview e kbot tnt lint, e gli strumenti MCP applicano la stessa regola.


In breve

   ┌─── 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"

Il procedimento per leggere un HPI è:

  1. Leggi l'intestazione di 20 byte. Verifica il magic "HAPI".
  2. Ricava da DecryptKey la chiave XOR del file.
  3. Decifra e leggi l'albero delle directory.
  4. Per ogni file che ti interessa, posizionati sui suoi chunk compressi, decifrali, poi procedi in base a CompMethod (LZ77, ZLib o nessuna compressione).

Tip

Esamina qualsiasi HPI senza scrivere un parser.

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

Consulta il riferimento CLI di kbot hpi per tutte le opzioni.

Da Go. Usa 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

Intestazione (20 byte, in chiaro)

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;
Campo Note
Marker 'HAPI' little-endian. La variante 'BANK' è usata dai salvataggi e non è trattata qui.
Version Gli archivi originali di Total Annihilation usano 0x00010000 (v1, questa sezione). Gli archivi di TA: Kingdoms usano 0x00020000 (v2); vedi TA: Kingdoms HPI v2.
DirectorySize Include i 20 byte dell'intestazione, quindi il blob della directory vero e proprio occupa DirectorySize − Start byte a partire da Start.
DecryptKey Va fatto passare per la trasformazione qui sotto per ottenere il byte usato come chiave XOR. Per TA 3.1c, i byte di chiave 0 e 0xFF significano entrambi "non cifrato". Gli archivi originali di Cavedog usano 0xBF.
Start Dove inizia la directory. Nei file osservati è sempre stato 0x14.

Ricavare la chiave di cifratura

key = (DecryptKey << 2) | (DecryptKey >> 6)   // rotate-left by 2 bits

Ogni byte cifrato viene poi decodificato usando la sua posizione assoluta nel file:

plain[i] = cipher[i] ^ (uint8(pos + i) ^ key)

dove pos è l'offset assoluto di cipher[0] nel file. Sì: il seme avanza con la posizione nel file, quindi non puoi decifrare un buffer isolato; devi sapere da dove proviene.

Important

Se il byte di chiave è 0 o 0xFF, non applicare affatto la cifratura. TA 3.1c li legge entrambi come testo in chiaro (per 0xFF la rotazione qui sopra darebbe 0xFF, ma il gioco non decifra). Alcuni archivi personalizzati (e una manciata di mod della comunità) sono scritti senza cifratura: applicare la cifratura XOR in quel caso rimescola ciò che è già in chiaro. Per questo motivo kbot hpi pack rifiuta --key 255; usa --key 0.


Albero delle directory

Una directory è un conteggio più un puntatore alla sua prima voce:

typedef struct { uint32 EntryCount; uint32 EntriesOffset; } HPIDir;

Ogni voce è lunga esattamente 9 byte:

typedef struct {
    uint32 NameOffset;    // → NUL-terminated string
    uint32 DataOffset;    // → HPIDir (subdir) or HPIFile (leaf)
    uint8  Flag;          // 1 = subdirectory, 0 = file
} HPIEntry;

Per i file, DataOffset punta a:

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;

Tutti gli offset sono assoluti all'interno del file. Per questo il parser canonico alloca un buffer di DirectorySize byte e scrive la directory alla posizione Start: così i puntatori possono indicizzare direttamente il buffer, senza calcoli.

Leggere la directory

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

Le maiuscole e minuscole dei nomi di file vengono conservate in scrittura, ma il confronto in lettura non distingue tra maiuscole e minuscole. Total Annihilation è nato su Windows 9x; le mod mescolano spesso armcom.fbi e ARMCOM.FBI persino nello stesso archivio. Il gioco uniforma solo le lettere ASCII, divide i percorsi sia su \ sia su / e, quando una directory contiene più voci i cui nomi differiscono solo per maiuscole e minuscole, legge l'ultima; le altre sono irraggiungibili. Tratta inoltre . e .. come nomi qualsiasi. Per questo kbot hpi extract scrive solo la voce raggiungibile di un gruppo del genere e salta (con un messaggio) qualsiasi voce il cui percorso, così come è memorizzato, non potrebbe essere scritto dentro la cartella di destinazione, come ../../x.txt; nemmeno kbot mount flatten scrive mai fuori dalla propria destinazione.


Chunk compressi (SQSH)

I dati di un file sono suddivisi in chunk di al massimo 64 KiB decompressi. Ogni chunk ha una propria intestazione di 19 byte:

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;

Il numero di chunk è ceil(FileSize / 65536). Subito dopo il primo HPIFile.DataOffset del file trovi un array di dimensioni compresse uint32, una per chunk, seguito dai chunk veri e propri, uno dopo l'altro.

Il secondo livello XOR

Se Encrypted == 1, al corpo di ogni chunk è applicato un secondo passaggio XOR:

for (i = 0; i < CompressedSize; i++)
    data[i] = (data[i] - i) ^ i;

Nota che il checksum è calcolato sui byte ancora cifrati: verificalo prima di annullare la cifratura.

LZ77 (CompMethod = 1)

Un LZ77 da manuale, a finestra scorrevole, con una cronologia di 4 KiB:

  • Byte di tag: 8 bit di flag, a partire dal bit meno significativo. 0 ⇒ letterale, 1 ⇒ riferimento all'indietro.
  • Letterale: copia 1 byte così com'è nell'output e nella cronologia.
  • Corrispondenza: leggi un uint16. I 12 bit alti sono l'offset nella finestra (1–4095), i 4 bit bassi codificano la lunghezza come len − 2 (quindi le corrispondenze sono di 2–17 byte).
  • Terminatore: una corrispondenza con windowOffset == 0 chiude il flusso.

ZLib (CompMethod = 2)

Flusso zlib grezzo standard. Passa il corpo del chunk a inflate() dopo aver rimosso il livello di cifratura; DecompressedSize è la lunghezza attesa.

Non compresso (CompMethod = 0)

I byte vengono scritti così come sono: nessuna suddivisione in chunk, nessun checksum, nessun secondo passaggio XOR. Utile per i file che non si comprimono (PCX/GAF già compressi), ed è l'unica modalità che il vecchio "unit viewer" comprende.


Trailer

Ogni archivio montato dal gioco termina con una riga di copyright ASCII di 36 byte:

Copyright 1997 Cavedog Entertainment

Nell'intestazione nulla punta a essa, ma non è facoltativa: prima di montare un archivio, TA 3.1c controlla che i suoi ultimi 36 byte siano Copyright ____ Cavedog Entertainment (i quattro caratteri dell'anno non vengono confrontati). Un archivio che ne è privo, o con qualsiasi byte accodato dopo di essa, viene saltato e non occupa uno dei dieci slot *.hpi: una mod impacchettata senza il trailer è semplicemente assente nel gioco, senza alcun avviso.

Ogni archivio scritto da kbot lo conserva: kbot hpi pack e le esportazioni dello studio (l'esportazione della mod di un workspace e il download dell'archivio dall'editor di mappe). kbot hpi info, kbot hpi list -v, gli strumenti MCP hpi_info e hpi_list e i metadati di un archivio nella scheda Files stampano una riga TA 3.1c would mount: yes / no, <reason>.


TA: Kingdoms — HPI v2

Note

Questa sezione riguarda solo le differenze di TA:K. Tutto ciò che precede vale per Total Annihilation (Version == 0x00010000). TA: Kingdoms usa la stessa estensione .hpi ma una struttura su disco diversa: leggi questa sezione per intero prima di ricorrere al parser v1.

TA: Kingdoms mantiene il magic 'HAPI' e il concetto di albero delle directory, ma per il resto rivoluziona l'involucro. Le differenze in breve:

Aspetto v1 (TA) v2 (TA: Kingdoms)
Campo della versione 0x00010000 0x00020000
Dimensione dell'intestazione 20 byte (5 × uint32) 32 byte (8 byte di magic+versione + sotto-intestazione di 24 byte)
Cifratura Cifratura XOR con seme DecryptKey, che avanza con la posizione nel file Nessuna: è tutto in chiaro
Posizione della directory e del pool dei nomi Subito dopo l'intestazione (cifrate) A offset arbitrari, (di solito) vicino alla fine del file, ciascuna facoltativamente avvolta in un unico chunk SQSH
Suddivisione dei file in chunk Suddivisi in chunk ≤64 KiB con una tabella delle lunghezze; ogni chunk ha la propria intestazione SQSH + checksum + secondo passaggio XOR facoltativo Un unico chunk SQSH con l'intero payload, oppure byte non compressi se CompressedSize == 0; nessun passaggio XOR, nessuna tabella delle lunghezze
Cifratura per chunk (byte Encrypted in SQSH) Usata Non usata (rispettiamo comunque il byte se è impostato, ma ogni file di TAK che abbiamo campionato lo ha a 0)

Intestazione v2

Dopo il prologo di 8 byte ('HAPI' + 0x00020000) vengono sei campi little-endian a 32 bit: in totale l'intestazione su disco è di 32 byte:

// 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;

Il blob della directory e quello dei nomi possono essere entrambi compressi in SQSH: se i primi quattro byte a DirectoryBlock / NameBlock sono 'SQSH', tratta il blocco come un unico chunk (intestazione + payload) e decomprimilo. Altrimenti, i Size byte all'offset indicato sono il contenuto decompresso grezzo.

Voci della directory v2

Il blob della directory decompresso è un array compatto di due tipi di record (niente tabelle di offset separate, niente prefisso EntryCount: i figli si trovano tramite i puntatori FirstSubDirectory / FirstFile del genitore):

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;

Payload dei singoli file

Per una voce di file all'offset Start:

  • Se CompressedSize == 0: leggi DecompressedSize byte così come sono.
  • Altrimenti: i CompressedSize byte successivi formano un unico chunk SQSH (stessa intestazione di 19 byte di v1); leggilo, verificane il checksum rispetto alla somma dei byte del payload, decodificalo con il CompMethod del chunk (1 = LZ77, 2 = ZLib) e hai il file.

Lo schema a più chunk di v1 (tabella delle dimensioni, chunk ≤64 KiB, passaggio di cifratura) è sparito. v2 rinuncia alla compattezza in cambio di un lettore molto più semplice.

Come lo gestisce kbot

kbot hpi list, extract e info supportano tutti e due i dialetti in modo trasparente: scelgono in base alla parola Version nell'intestazione. La scrittura in round-trip (kbot hpi pack) per ora emette solo v1; non creiamo ancora archivi v2.

Il lettore v2 interno si trova in formats/hpi/hpi_v2.go. hpi.Reader.Version() indica se il file che hai caricato è v1 o v2.

Tip

Verifica che un'installazione di TAK si carichi.

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

Esempio svolto — aflakker.ufo

L'analisi originale di Joe D. usava il componente aggiuntivo dell'unità Arm Flakker; qui ne riproduciamo l'intestazione sezionata. Dump esadecimale dei primi 32 byte:

00000000  48 41 50 49 00 00 01 00 24 02 00 00 7D 00 00 00   HAPI....$...}...
00000010  14 00 00 00 ...                                   ...

Campo per campo:

Offset Byte Campo Valore
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

Decifrando [0x14, 0x224) e percorrendo l'albero si ottiene:

.
├── 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

Questa struttura a nove file è la forma canonica di un componente aggiuntivo .ufo per una singola unità; gli archivi di base totala*.hpi usano lo stesso schema su larga scala (~30k file).

Tip

Riproduci questa analisi sulla tua macchina.

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

Reimpacchettare

Per costruire un nuovo archivio a partire da un albero di directory:

kbot hpi pack ./aflakker-out --target aflakker-new.ufo

Per impostazione predefinita kbot usa la compressione LZ77 a chunk, con la chiave d'intestazione standard 0xBF e il trailer di Cavedog: il risultato è un binario che il motore originale accetta. Le opzioni lo mantengono tale:

Opzione Effetto
--method lz77|zlib|none Compressione dei chunk; none scrive voci non compresse.
--key N Byte di chiave dell'intestazione; 0 scrive un archivio non cifrato. 255 viene rifiutato: il gioco lo interpreta come "non cifrato".
--trailer "Copyright 1998 Cavedog Entertainment" Un altro anno nel trailer; qualsiasi altro testo viene rifiutato.
--no-trailer Omette il trailer: il gioco non monterà il risultato (viene stampato un avviso).
--format v2 Scrive un archivio di TA: Kingdoms (--key, --trailer, --no-trailer e --encode-chunks non si applicano).

I nomi di directory che differiscono solo per maiuscole e minuscole (Units/ e units/ su un disco che le distingue) vengono uniti nel modo in cui il gioco confronta i nomi; due file i cui percorsi differiscono solo per maiuscole e minuscole vengono memorizzati entrambi, il gioco legge quello impacchettato per ultimo e pack stampa un avviso.


Insidie

Warning

I puntatori sono assoluti rispetto all'inizio del file, non all'inizio della directory. Non va sottratto nulla. L'accorgimento implementativo standard è allocare un buffer di DirectorySize byte e leggervi la directory decifrata all'offset Start, così i puntatori indicizzano direttamente il buffer.

  • Formula del numero di chunk: ceil(FileSize / 65536). Non esiste un campo esplicito per il conteggio; devi ricavarlo da FileSize.

Dimensioni tipiche

Archivio Dimensione File Note
totala1.hpi (suoni, animazioni) ~30 MB ~1900 Gioco base, il singolo archivio più grande.
totala2.hpi (unità) ~13 MB ~600 La maggior parte dei metadati e degli script delle unità.
totala4.hpi (mappe) ~50 MB ~300 Tutte le mappe originali.
worlds.hpi (sezioni) ~20 MB ~5000 Librerie SCT dell'editor.
ccdata.ccx (Core Contingency) ~25 MB ~1500 Unità e mappe dell'espansione.
.ufo tipico per una singola unità 10–150 KB 8–12 La maggior parte dei componenti aggiuntivi di unità di terze parti.
.ufo con sola mappa (con .tnt + .ota) 0,5–5 MB 2–4 Distribuzione di una singola mappa.
Rapporto di compressione 35–55% — LZ77 per la maggior parte dei file; ZLib per i più grandi.
  • Alcuni chunk si espandono quando vengono compressi. Un chunk "compresso" può superare i 64 KiB se LZ77 non ha trovato corrispondenze. Considera CompressedSize come la lunghezza autorevole da copiare.
  • Il checksum è calcolato sui byte cifrati. Se decifri prima, il checksum non tornerà.
  • I salvataggi (marcatore BANK) sono tutta un'altra storia: riutilizzano l'involucro HPI, ma il contenuto codificato è lo stato di gioco serializzato, non un filesystem. kbot non li decodifica.
  • .gp3 richiede la patch rev3.1 per essere caricato. Dentro è comunque un HPI, ma il motore si rifiuta di montarlo sulle installazioni senza la patch.

Appendice — Analisi esadecimale completa di AFark.ufo

AFark.ufo è il componente aggiuntivo dell'Arm Fark: un piccolo archivio di 9 file, ideale per una dimostrazione dall'inizio alla fine. Analizzeremo l'intestazione, decifreremo la directory, sezioneremo un paio di voci e daremo un'occhiata ai byte iniziali di un chunk compresso.

1. Intestazione (in chiaro)

Byte grezzi 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                                      ....

Decodificati:

Offset Byte Campo Valore
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

Ricava la chiave di cifratura:

key = (0xA6 << 2) | (0xA6 >> 6)
    = 0x98 | 0x02
    = 0x9A

2. Directory decifrata (offset 0x14..0x224)

Dopo aver applicato plain[i] = cipher[i] ^ (uint8(0x14 + i) ^ 0x9A) al blob della directory, e averlo inserito in un buffer di 548 byte all'offset 0x14 perché i puntatori indicizzino correttamente, i primi 96 byte sono:

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"

A 0x14 c'è 0x00000008, il numero di voci della radice: 8 sottodirectory di primo livello. A 0x18, 0x0000001C: l'offset della prima voce della directory.

3. La prima voce

I 9 byte a 0x1C si decodificano così:

Campo Byte Valore
NameOffset 64 00 00 00 0x64 → stringa "anims"
DataOffset 6a 00 00 00 0x6A → descrittore della sottodirectory
Flag 01 1 ⇒ sottodirectory

A 0x64:

00000060  ....  61 6e 69 6d 73 00     ....anims.
                ^^^^^^^^^^^^^^^^^

La stringa "anims\0", che conferma il nome della directory.

A 0x6A:

00000060  ....                  01 00 00 00 72 00      ........r.
                                ^^^^^^^^^^^ ^^^^
00000070  00 00 7b 00 00 00 ...                          ..{...
          ^^^^^
Campo Byte Valore
EntryCount della sottodirectory 01 00 00 00 1
EntriesOffset della sottodirectory 72 00 00 00 0x72

Quindi la directory anims/ contiene 1 file, descritto a partire da 0x72:

Campo Byte Valore
NameOffset 7b 00 00 00 0x7B → nome del file
DataOffset 8e 00 00 00 0x8E → descrittore del file
Flag 00 0 ⇒ file

Il nome a 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".

Il descrittore del file a 0x8E è un HPIFile di 9 byte:

00000080                                              24 02              $.
00000090  00 00 48 48 00 00 01                                ..HH...
Campo Byte Valore
DataOffset 24 02 00 00 0x0224: il primo byte dopo la directory!
FileSize 48 48 00 00 0x4848 = 18504 byte
CompMethod 01 1 ⇒ LZ77

Corrisponde a quanto riporta kbot hpi list -v:

anims/armfark_gadget.gaf    18504

4. Leggere l'albero completo

Proseguendo con le restanti 7 voci di primo livello si ottiene:

.
├── 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)

Nove file, la forma canonica di un .ufo per una singola unità.

5. Un chunk compresso

Il primo byte dopo la directory di 548 byte si trova a 0x224. Decifrando quella regione (anche l'intestazione del chunk è cifrata con la cifratura della directory) si ottiene:

00000220                          .. 53 51 53 48 02 01 01 cc      .SQSH....
Offset (da 0x224) Byte Campo Valore
+0 53 51 53 48 Marker 'SQSH' ✓
+4 02 Version 0x02 (sempre)
+5 01 CompMethod 1 ⇒ LZ77
+6 01 Encrypted 1 ⇒ applica il secondo passaggio XOR
+7.. … CompressedSize, DecompressedSize, Checksum, poi data[] …

Una volta decodificato il corpo del chunk con data[i] = (data[i] - i) ^ i e fatti passare i byte risultanti per il decodificatore LZ77 descritto sopra, ottieni il GAF decompresso, pronto da consegnare a un parser GAF.

Tip

Riproduci questa analisi sulla tua macchina.

# 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

Appendice — Analisi esadecimale completa di Jersey.hpi (HPI v2)

L'equivalente per TA: Kingdoms dell'analisi di AFark. Jersey.hpi è un HPI di 67 KB con un pacchetto di voci, incluso nell'installazione GOG di TA:K: sei WAV di effetti sonori e nient'altro. Abbastanza piccolo da percorrerlo da cima a fondo, abbastanza grande da mettere alla prova ogni meccanismo di v2 (intestazione di 32 byte, pool dei nomi, blob della directory avvolto in SQSH, payload dei file in un solo chunk). L'analisi di v1 qui sopra copre tutto ciò che resta invariato; questa sezione mette in evidenza solo le differenze di v2.

1. L'intestazione di 32 byte

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 Byte Campo Valore
0x00 48 41 50 49 Marker 'HAPI' ✓
0x04 00 00 02 00 Version 0x00020000: v2
0x08 92 06 01 00 DirectoryBlock 0x00010692: offset del blob della directory
0x0C ad 00 00 00 DirectorySize 0xAD = 173 byte
0x10 40 06 01 00 NameBlock 0x00010640: offset del pool dei nomi
0x14 5b 00 00 00 NameSize 0x5B = 91 byte
0x18 20 00 00 00 Data 0x20: i payload dei file iniziano qui, subito dopo l'intestazione
0x1C 3f 07 01 00 Last78 0x0001073F: marcatore di fine archivio (0x1073F = 67391 = dimensione del file − 78 byte di piè di pagina)

Nessuna cifratura XOR, nessuna trasformazione di decifratura, nessun seme legato alla posizione. I valori dell'intestazione puntano a offset assoluti nel file su cui puoi fare seek direttamente.

2. Il pool dei nomi (testo grezzo, vicino alla fine)

A DirectoryBlock meno qualche centinaio di byte (il motore scrive il pool dei nomi prima della directory) si trova la tabella delle stringhe:

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.

Ogni voce di directory e di file memorizza un offset in byte NamePtr in questo blocco; seguendo il puntatore e leggendo fino al \0 successivo si ottiene il nome. Byte per byte:

Offset (relativo a NameBlock) Stringa terminata da NUL
0x00 "" (directory radice: nome vuoto)
0x01 "sounds" (l'unica sottodirectory)
0x08 "Whodied.wav"
0x14 "Sure.wav"
0x1D "WhoahPally.wav"
0x2C "Forgetaboutit.wav"
0x3E "Yo.wav"
0x45 "Igotcher.wav"

Note

La dimensione nominale del pool dei nomi include alcuni byte finali inutilizzati. NameSize=0x5B arriva fino all'offset 0x1069B, ma l'ultimo byte significativo (il NUL dopo Igotcher.wav) è a 0x10691. I 9 byte finali si sovrappongono all'inizio del chunk SQSH della directory e nessun NamePtr li indicizza mai: i lettori li tollerano semplicemente non guardandoli mai.

3. Il blob della directory (avvolto in SQSH)

A 0x10692 il blob della directory inizia con un'intestazione di chunk SQSH: v2 riutilizza il formato dei chunk di 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 Byte Campo Valore
+0 53 51 53 48 Marker 'SQSH' ✓
+4 02 Version 2
+5 02 CompMethod 2 ⇒ ZLib
+6 01 Encrypted 1: al corpo del chunk è applicato il secondo passaggio XOR (raro in TAK; questo archivio lo ha)
+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 di flusso ZLib rimescolato con XOR

19 + 154 = 173 = 0xAD = DirectorySize, quindi l'intero chunk rientra in quanto dichiarato dall'intestazione. Dopo aver annullato il passaggio XOR (stesso algoritmo di v1) e averlo fatto passare per inflate, ottieni 184 byte di record di directory compatti.

4. Decodificare i record della directory

Il blob decompresso contiene un array compatto di due tipi di record: voci di directory di 20 byte (HpiDir2) e voci di file di 24 byte (HpiEntry2). Non c'è un prefisso di conteggio né una tabella di offset: la directory radice si trova sempre all'offset 0 del blob, e i suoi campi FirstSubDirectory / FirstFile puntano ai figli.

L'HpiDir2 radice (all'offset decompresso 0, 20 byte):

Offset Byte (decompressi) Campo Valore
+0 00 00 00 00 NamePtr 0 → "" (radice)
+4 14 00 00 00 FirstSubDirectory 0x14: un singolo record di sottodirectory di 20 byte a questo offset
+8 01 00 00 00 SubCount 1 (la directory sounds/)
+12 00 00 00 00 FirstFile 0: nessun file nella radice
+16 00 00 00 00 FileCount 0

Seguendo il puntatore a 0x14 si arriva all'HpiDir2 di sounds:

  • NamePtr = 0x01 → "sounds"
  • FirstSubDirectory = 0, SubCount = 0: nessuna sottodirectory annidata
  • FirstFile = 0x28: il primo di sei record HpiEntry2 (24 byte ciascuno)
  • FileCount = 6

Ogni record di file a 0x28, 0x40, 0x58, … contiene:

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

Decodificate, le sei voci corrispondono esattamente a quanto riporta 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. Che cosa NON c'è (rispetto a v1)

Se hai ancora fresca in mente l'analisi di v1, i pezzi mancanti sono proprio il senso della semplificazione di v2:

  • Nessuna derivazione della chiave XOR: DecryptKey è sempre 0 per v2. Anche quando un chunk SQSH ha Encrypted == 1, il secondo passaggio XOR dentro il chunk viene comunque applicato, ma l'involucro di directory e payload è in chiaro.
  • Nessun seme legato alla posizione: la cifratura byte per byte che complicava i lettori v1 (la decifratura dipende dall'offset assoluto nel file) non esiste al livello dell'involucro di v2.
  • Nessuna tabella delle dimensioni dei chunk: in v1 i file erano suddivisi in blocchi ≤64 KiB con un array iniziale di dimensioni uint32. v2 memorizza ogni file come un unico chunk SQSH (o come byte non compressi), quindi non c'è nulla da mettere in una tabella delle dimensioni.
  • Nessuna convenzione della directory a 0x14 subito dopo l'intestazione: i blocchi della directory e dei nomi stanno dovunque lo scrittore li abbia messi (di solito vicino alla fine del file). L'intestazione punta a essi.

Tip

Riproduci questa analisi sulla tua macchina.

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

Vedi anche

  • GAF, PCX, 3DO, TNT, COB, TDF: i tipi di file contenuti negli archivi HPI.
  • Glossario: definizioni di filesystem virtuale, stratificazione (layering) e altri termini legati agli HPI.