HPI / UFO / CCX / GP3 — File di archivio HAPI
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 .gp3L'overlay della patch 3.1 ( rev31.gp3)Montato per primo; gli altri *.gp3vengono ignorati.ccxDati di Core Contingency / Battle Tactics ( btdata.ccx,ccdata.ccx)Dopo rev31.gp3.ufoComponenti aggiuntivi di unità ed elementi ( AFark.ufo)Dopo ogni .ccx; nessun limite di numero.hpiArchivi 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:
rev31.gp3;- ogni
*.ccx;- ogni
*.ufo;- i primi dieci
*.hpiche si aprono;- i file
*.hpial 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.ufoprima dizeta.ufo). Quando risolve un percorso comeunits/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
*.hpisono occupati datactics1–8.hpi,totala1.hpietotala2.hpi.totala3.hpi,totala4.hpi(le mappe originali) eworlds.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*.hpiaggiunto 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 tnte il server MCP. La pagina Home e la scheda Layering della scheda Files, i comandiarchivesestatsdikbot mounte lo strumento MCPvfs_game_dataelencano 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 diIPData.hpisopradata.hpidi quel gioco.La regola "vincono i file sciolti" è il motivo per cui
kbot mount flattenproduce 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/*.tdfeunits/*.fbi(solo il livello superiore: una sottocartellaweapons/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. IlCarScar05originale è definito infeatures/urban/cars2.tdfe di nuovo incars.tdf, ecars2.tdfè memorizzato per primo, quindi il gioco lo disegna daanims/cars2.gaf. Lo studio (palette degli elementi, sandbox, pacchetti, elenchi di unità e armi),kbot tnt previewekbot 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 è:
- Leggi l'intestazione di 20 byte. Verifica il magic
"HAPI". - Ricava da
DecryptKeyla chiave XOR del file. - Decifra e leggi l'albero delle directory.
- 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 comelen − 2(quindi le corrispondenze sono di 2–17 byte). - Terminatore: una corrispondenza con
windowOffset == 0chiude 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: leggiDecompressedSizebyte così come sono. - Altrimenti: i
CompressedSizebyte 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 ilCompMethoddel 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 daFileSize.
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
CompressedSizecome 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. .gp3richiede 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 annidataFirstFile = 0x28: il primo di sei recordHpiEntry2(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è sempre0per v2. Anche quando un chunk SQSH haEncrypted == 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
0x14subito 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