HPI / UFO / CCX / GP3: archivos comprimidos HAPI
Fuente: coreprime/kbot @ 0ab97bf · Editar en GitHub
HAPI («HPI») es el contenedor cifrado y comprimido con el que Cavedog distribuyó todos los recursos del juego. La misma estructura en disco se reutiliza con distintas extensiones:
Extensión Propósito Orden de montaje en TA 3.1c .gp3La capa del parche 3.1 ( rev31.gp3)Se monta la primera; los demás *.gp3se ignoran.ccxDatos de Core Contingency / Battle Tactics ( btdata.ccx,ccdata.ccx)Después de rev31.gp3.ufoComplementos de unidades y elementos ( AFark.ufo)Después de todos los .ccx; sin límite de número.hpiArchivos comprimidos del juego base ( totala1.hpi,tactics1.hpi)Los últimos; solo los diez primeros que se abren Los cuatro tienen exactamente el mismo formato byte a byte; solo cambia el orden de montaje. Al arrancar, el juego los reúne en un único sistema de archivos virtual, y gana el primer archivo comprimido montado que contiene una ruta.
Important
Dos dialectos en disco. Total Annihilation escribe HPI v1
(Version == 0x00010000), la estructura cifrada de varios chunks que esta
página documenta de principio a fin. Total Annihilation: Kingdoms
escribe HPI v2 (Version == 0x00020000), una estructura distinta y
más sencilla, sin cifrado XOR y con un único chunk SQSH por archivo o por
bloque de directorio. Los detalles de v2 tienen su propia sección cerca
del final de esta página: HPI v2 de TA: Kingdoms.
TA 3.1c solo monta archivos comprimidos v1.
Capas según el orden de carga
TA 3.1c solo explora el nivel superior de su directorio de juego (los archivos comprimidos en subcarpetas como
Backup/no se montan nunca) y monta, en este orden:
rev31.gp3;- todos los
*.ccx;- todos los
*.ufo;- los diez primeros
*.hpique se abren;- los archivos
*.hpidel nivel superior del CD del juego, sin límite de número.Dentro de cada grupo, el juego toma los nombres en orden ASCII en mayúsculas (el orden en que los devuelve el listado de su directorio:
_va después de las letras,AFark.ufoantes quezeta.ufo). Cuando resuelve una ruta comounits/ARMCOM.fbi, ganan los archivos sueltos de la instalación y, después, el primer archivo comprimido montado que contiene la ruta. Un archivo comprimido solo cuenta como montado si es de la versión 1, termina con el trailer de copyright de Cavedog (consulta Trailer) y su directorio se puede leer; cualquier otro archivo se omite y no ocupa ninguna de las diez plazas de*.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"En la versión de GOG, las diez plazas de
*.hpilas ocupantactics1–8.hpi,totala1.hpiytotala2.hpi.totala3.hpi,totala4.hpi(los mapas de la versión comercial) yworlds.hpi(las secciones de mapa) son los archivos del disco: una instalación de GOG los guarda en el directorio del juego, que hace las veces de disco, así que el juego los monta después de todos los demás archivos comprimidos. Lo mismo ocurre con cualquier*.hpique añada un mod: si su nombre va detrás de la décima plaza, solo se alcanza mediante esa exploración del disco, por debajo de todo lo demás. Para evitarlo, distribuye los mods como.ufo(sin límite y por encima de todos los.hpi).kbot monta los contextos de Total Annihilation de la misma manera en todas partes: el studio,
kbot mount,kbot mount flatten,kbot pack,kbot tnty el servidor MCP. La página Home y la pestaña Layering de la pestaña Files, los comandosarchivesystatsdekbot mounty la herramienta MCPvfs_game_datamuestran el orden de montaje y todos los archivos comprimidos que no se montaron, con el motivo. Los contextos de TA: Kingdoms mantienen el orden de superposición propio de kbot (todos los archivos comprimidos por extensión y nombre, y uno posterior sobrescribe a uno anterior), que es lo que necesita la superposición deIPData.hpisobredata.hpide ese juego.La regla de que «ganan los archivos sueltos» es la razón por la que
kbot mount flattenproduce un directorio que se comporta igual que el conjunto por capas: cada archivo que el motor habría resuelto a través de la pila de archivos comprimidos está presente como archivo suelto en la misma ruta, de modo que la resolución termina en lo alto de la pila.
Qué definición gana
Los elementos, las armas y las unidades se cargan listando un directorio:
features/**/*.tdf(subcarpetas incluidas),weapons/*.tdfyunits/*.fbi(solo el nivel superior: una subcarpetaweapons/old/no se lee nunca). El listado recorre primero los archivos sueltos y después cada archivo comprimido montado, en orden de montaje y con sus entradas en el orden en que están almacenadas, y gana la primera definición de un nombre; las posteriores se ignoran. ElCarScar05de la versión comercial está definido enfeatures/urban/cars2.tdfy otra vez encars.tdf, ycars2.tdfestá almacenado primero, así que el juego lo dibuja a partir deanims/cars2.gaf. El studio (paleta de elementos, sandbox, paquetes, listas de unidades y de armas),kbot tnt previewykbot tnt lint, y las herramientas MCP aplican la misma regla.
De un vistazo
┌─── 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"
Para leer un HPI hay que:
- Leer la cabecera de 20 bytes. Comprobar el número mágico
"HAPI". - Derivar de
DecryptKeyla clave XOR del archivo. - Descifrar y leer el árbol de directorios.
- Para cada archivo que quieras, saltar a sus chunks comprimidos,
descifrarlos y despachar según
CompMethod(LZ77, ZLib o ninguno).
Tip
Examina cualquier HPI sin escribir un analizador.
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 la referencia de kbot hpi
de la CLI para ver todas las opciones.
Desde 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
Cabecera (20 bytes, sin cifrar)
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 | Notas |
|---|---|
Marker |
'HAPI' en little-endian. La variante 'BANK' la usan las partidas guardadas y no se trata aquí. |
Version |
Los archivos comprimidos de la versión comercial de Total Annihilation usan 0x00010000 (v1, esta sección). Los de TA: Kingdoms usan 0x00020000 (v2); consulta HPI v2 de TA: Kingdoms. |
DirectorySize |
Incluye los 20 bytes de la cabecera, así que el blob del directorio ocupa en realidad DirectorySize − Start bytes a partir de Start. |
DecryptKey |
Pásalo por la transformación de abajo para obtener el byte que se usa como clave XOR. Para TA 3.1c, los bytes de clave 0 y 0xFF significan ambos «sin cifrar». Los archivos comprimidos de la versión comercial de Cavedog usan 0xBF. |
Start |
Dónde empieza el directorio. Siempre ha sido 0x14 en los archivos observados. |
Cómo derivar la clave de cifrado
key = (DecryptKey << 2) | (DecryptKey >> 6) // rotate-left by 2 bits
Después, cada byte cifrado se descifra con su posición absoluta en el archivo:
plain[i] = cipher[i] ^ (uint8(pos + i) ^ key)
donde pos es el desplazamiento absoluto de cipher[0] en el archivo.
Sí: la semilla avanza con la posición en el archivo, así que no puedes
descifrar un búfer de forma aislada; tienes que saber de dónde salió.
Important
Si el byte de clave es 0 o 0xFF, no apliques el cifrado en
absoluto. TA 3.1c lee ambos como texto en claro (con 0xFF la rotación
de arriba daría 0xFF, pero el juego no descifra). Algunos archivos
comprimidos personalizados (y un puñado de mods de la comunidad) se
escriben sin cifrar; aplicar el cifrado XOR en ese caso desordena lo que
ya está en claro. Por eso kbot hpi pack rechaza --key 255; usa
--key 0.
Árbol de directorios
Un directorio es un recuento más un puntero a su primera entrada:
typedef struct { uint32 EntryCount; uint32 EntriesOffset; } HPIDir;
Cada entrada ocupa exactamente 9 bytes:
typedef struct {
uint32 NameOffset; // → NUL-terminated string
uint32 DataOffset; // → HPIDir (subdir) or HPIFile (leaf)
uint8 Flag; // 1 = subdirectory, 0 = file
} HPIEntry;
En los archivos, DataOffset apunta 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;
Todos los desplazamientos son absolutos dentro del archivo. Por eso el
analizador canónico reserva un búfer de DirectorySize bytes y escribe el
directorio en la posición Start: así los punteros pueden indexar el búfer
directamente, sin aritmética.
Cómo leer el directorio
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
Las mayúsculas y minúsculas de los nombres de archivo se conservan al
escribir, pero se comparan sin distinguirlas al leer. Total
Annihilation nació en Windows 9x; es habitual que los mods mezclen
armcom.fbi y ARMCOM.FBI incluso dentro del mismo archivo comprimido.
El juego solo iguala mayúsculas y minúsculas de las letras ASCII, separa
las rutas tanto por \ como por /, y cuando un directorio guarda varias
entradas cuyos nombres solo difieren en mayúsculas y minúsculas, lee la
última; las demás son inalcanzables. También trata . y .. como
nombres normales. Por eso kbot hpi extract solo escribe la entrada
alcanzable de un grupo así, y omite (con un mensaje) cualquier entrada
cuya ruta, tal como está almacenada, no se podría escribir dentro de la
carpeta de destino, como ../../x.txt; kbot mount flatten tampoco
escribe nunca fuera de su destino.
Chunks comprimidos (SQSH)
Los datos de un archivo se dividen en chunks de hasta 64 KiB una vez descomprimidos. Cada chunk tiene su propia cabecera de 19 bytes:
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;
El número de chunks es ceil(FileSize / 65536). Inmediatamente después
del primer HPIFile.DataOffset del archivo encontrarás un array de tamaños
comprimidos uint32, uno por chunk, seguido de los propios chunks, uno
detrás de otro.
La segunda capa XOR
Si Encrypted == 1, a cada cuerpo de chunk se le aplica una segunda
pasada XOR:
for (i = 0; i < CompressedSize; i++)
data[i] = (data[i] - i) ^ i;
Ten en cuenta que la suma de comprobación se calcula sobre los bytes todavía cifrados: verifícala antes de deshacer el cifrado.
LZ77 (CompMethod = 1)
Un LZ77 de ventana deslizante de manual, con un historial de 4 KiB:
- Byte de etiqueta: 8 bits de flag, empezando por el LSB.
0⇒ literal,1⇒ referencia hacia atrás. - Literal: copia 1 byte tal cual a la salida y al historial.
- Coincidencia: lee un
uint16. Los 12 bits altos son el desplazamiento en la ventana (1–4095) y los 4 bits bajos codifican la longitud comolen − 2(así que las coincidencias miden de 2 a 17 bytes). - Terminador: una coincidencia con
windowOffset == 0termina el flujo.
ZLib (CompMethod = 2)
Un flujo zlib raw estándar. Pasa el cuerpo del chunk a inflate() una
vez eliminada la capa de cifrado; DecompressedSize es la longitud
esperada.
Sin comprimir (CompMethod = 0)
Los bytes se escriben tal cual: sin dividir en chunks, sin suma de comprobación y sin segunda pasada XOR. Es útil para archivos que no se comprimen (PCX/GAF ya comprimidos), y es el único modo que entiende el antiguo «visor de unidades».
Trailer
Todo archivo comprimido que monta el juego termina con una línea de copyright ASCII de 36 bytes:
Copyright 1997 Cavedog Entertainment
Nada en la cabecera apunta a ella, pero no es opcional: antes de montar
un archivo comprimido, TA 3.1c comprueba que sus últimos 36 bytes digan
Copyright ____ Cavedog Entertainment (los cuatro caracteres del año no se
comparan). Un archivo comprimido sin ella, o con cualquier byte añadido
después, se omite y no ocupa ninguna de las diez plazas de *.hpi: un mod
empaquetado sin el trailer desaparece del juego sin avisar.
Todos los archivos comprimidos que escribe kbot lo conservan: kbot hpi pack y las exportaciones del studio (la exportación de mod de un espacio
de trabajo y la descarga del archivo comprimido del editor de mapas).
kbot hpi info, kbot hpi list -v, las herramientas MCP hpi_info y
hpi_list y los metadatos de un archivo comprimido en la pestaña Files
muestran una línea TA 3.1c would mount: yes / no, <reason>.
HPI v2 de TA: Kingdoms
Note
Esta sección recoge solo las diferencias de TA:K. Todo lo anterior se
aplica a Total Annihilation (Version == 0x00010000). TA: Kingdoms
usa la misma extensión .hpi, pero una estructura en disco distinta: lee
esta sección entera antes de recurrir al analizador de v1.
TA: Kingdoms conserva el número mágico 'HAPI' y el concepto de árbol de
directorios, pero por lo demás rehace el envoltorio. Las diferencias, de un
tirón:
| Aspecto | v1 (TA) | v2 (TA: Kingdoms) |
|---|---|---|
| Campo de versión | 0x00010000 |
0x00020000 |
| Tamaño de la cabecera | 20 bytes (5 × uint32) | 32 bytes (8 bytes de número mágico + versión + subcabecera de 24 bytes) |
| Cifrado | Cifrado XOR con semilla en DecryptKey, que avanza con la posición en el archivo |
Ninguno: todo está en claro |
| Ubicación del directorio y del pool de nombres | Justo después de la cabecera (cifrados) | En desplazamientos arbitrarios (normalmente cerca del final del archivo), cada uno opcionalmente envuelto en un único chunk SQSH |
| División de cada archivo en chunks | En chunks de ≤64 KiB con una tabla de longitudes; cada chunk tiene su propia cabecera SQSH + suma de comprobación + segunda pasada XOR opcional | Un único chunk SQSH con todos los datos, o bytes sin comprimir si CompressedSize == 0; sin pasada XOR ni tabla de longitudes |
Cifrado por chunk (byte Encrypted de SQSH) |
Se usa | No se usa (seguimos respetando el byte si está activado, pero todos los archivos de TAK que hemos muestreado lo tienen a 0) |
Cabecera v2
Tras el prólogo de 8 bytes ('HAPI' + 0x00020000) vienen seis campos
de 32 bits little-endian; en total, la cabecera ocupa 32 bytes en disco:
// 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;
Tanto el blob del directorio como el de nombres pueden estar
comprimidos con SQSH: si los cuatro primeros bytes en DirectoryBlock /
NameBlock son 'SQSH', trata el bloque como un único chunk (cabecera +
datos) y descomprímelo. Si no, los Size bytes del desplazamiento indicado
son el contenido en bruto, ya descomprimido.
Entradas de directorio v2
El blob del directorio descomprimido es un array compacto de dos tipos de
registro (sin tablas de desplazamientos aparte ni prefijo EntryCount: los
hijos se localizan mediante los punteros FirstSubDirectory / FirstFile
del padre):
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;
Datos de cada archivo
Para una entrada de archivo en el desplazamiento Start:
- Si
CompressedSize == 0: leeDecompressedSizebytes tal cual. - Si no: los
CompressedSizebytes siguientes forman un único chunk SQSH (con la misma cabecera de 19 bytes que en v1); léelo, verifica su suma de comprobación contra la suma de los bytes de los datos, decodifica con elCompMethoddel chunk (1= LZ77,2= ZLib) y ya tienes el archivo.
El esquema de varios chunks de v1 (tabla de tamaños, chunks de ≤64 KiB, pasada de cifrado) desaparece. v2 sacrifica compacidad a cambio de un lector muchísimo más sencillo.
Cómo lo trata kbot
kbot hpi list, extract e info admiten los dos dialectos de forma
transparente: despachan según la palabra Version de la cabecera. La
escritura de ida y vuelta (kbot hpi pack) por ahora solo emite v1;
todavía no creamos archivos comprimidos v2.
El lector interno de v2 está en formats/hpi/hpi_v2.go.
hpi.Reader.Version() indica si el archivo que has cargado es v1 o v2.
Tip
Comprueba que se carga una instalación de TAK.
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
Ejemplo desarrollado: aflakker.ufo
El recorrido original de Joe D. usaba el complemento de la unidad Arm Flakker; aquí reproducimos su cabecera diseccionada. Volcado hexadecimal de los primeros 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 ... ...
Campo a campo:
| Desplazamiento | Bytes | Campo | Valor |
|---|---|---|---|
0x00 |
48 41 50 49 |
Marker |
'HAPI' ✓ |
0x04 |
00 00 01 00 |
Version |
0x00010000 ✓ |
0x08 |
24 02 00 00 |
DirectorySize |
0x224 = 548 bytes |
0x0C |
7D 00 00 00 |
DecryptKey |
0x7D → key = (0x7D << 2) | (0x7D >> 6) = 0xF5 |
0x10 |
14 00 00 00 |
Start |
0x14 |
Al descifrar [0x14, 0x224) y recorrer el árbol se obtiene:
.
├── 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
Esta estructura de nueve archivos es la forma canónica de un complemento
.ufo de una sola unidad; los archivos comprimidos base totala*.hpi
usan el mismo esquema a gran escala (~30 000 archivos).
Tip
Reproduce esta disección en tu propio ordenador.
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
Reempaquetar
Para crear un archivo comprimido nuevo a partir de un árbol de directorios:
kbot hpi pack ./aflakker-out --target aflakker-new.ufo
Por defecto, kbot usa compresión LZ77 por chunks con la clave de
cabecera estándar 0xBF y el trailer de Cavedog, lo que produce un binario
que acepta el motor de la versión comercial. Las opciones lo mantienen así:
| Opción | Efecto |
|---|---|
--method lz77|zlib|none |
Compresión de los chunks; none escribe entradas sin comprimir. |
--key N |
Byte de clave de la cabecera; 0 escribe un archivo comprimido sin cifrar. 255 se rechaza: el juego lo interpreta como «sin cifrar». |
--trailer "Copyright 1998 Cavedog Entertainment" |
Otro año en el trailer; cualquier otro texto se rechaza. |
--no-trailer |
Omite el trailer: el juego no montará el resultado (se muestra un aviso). |
--format v2 |
Escribe un archivo comprimido de TA: Kingdoms (--key, --trailer, --no-trailer y --encode-chunks no se aplican). |
Los nombres de directorio que solo difieren en mayúsculas y minúsculas
(Units/ y units/ en un disco que las distingue) se fusionan igual que
compara los nombres el juego; dos archivos cuyas rutas solo difieren en
mayúsculas y minúsculas se guardan ambos, el juego lee el último
empaquetado y pack muestra un aviso.
Escollos
Warning
Los punteros son absolutos respecto al inicio del archivo, no al del
directorio. No hay que restar nada. El truco habitual de las
implementaciones es reservar un búfer de DirectorySize bytes y leer en
él el directorio descifrado en el desplazamiento Start, para que los
punteros indexen el búfer directamente.
- Fórmula del número de chunks:
ceil(FileSize / 65536). No hay un campo explícito con el recuento; hay que derivarlo deFileSize.
Tamaños habituales
| Archivo comprimido | Tamaño | Archivos | Notas |
|---|---|---|---|
totala1.hpi (sonidos, animaciones) |
~30 MB | ~1900 | Juego base, el archivo comprimido individual más grande. |
totala2.hpi (unidades) |
~13 MB | ~600 | La mayoría de los metadatos y scripts de unidades. |
totala4.hpi (mapas) |
~50 MB | ~300 | Todos los mapas de la versión comercial. |
worlds.hpi (secciones) |
~20 MB | ~5000 | Bibliotecas SCT del editor. |
ccdata.ccx (Core Contingency) |
~25 MB | ~1500 | Unidades y mapas de la expansión. |
.ufo típico de una sola unidad |
10–150 KB | 8–12 | La mayoría de los complementos de unidades de terceros. |
.ufo solo de mapa (con .tnt + .ota) |
0,5–5 MB | 2–4 | Distribución mapa a mapa. |
| Tasa de compresión | 35–55 % | — | LZ77 en la mayoría de los archivos; ZLib en los más grandes. |
- Algunos chunks crecen al comprimirse. Un chunk «comprimido» puede
superar los 64 KiB si LZ77 no encontró coincidencias. Toma
CompressedSizecomo la longitud que manda a la hora de copiar. - La suma de comprobación se calcula sobre los bytes cifrados. Si descifras primero, la comprobación fallará.
- Las partidas guardadas (marcador
BANK) son otra historia: reutilizan el envoltorio HPI, pero el contenido codificado es el estado serializado de la partida, no un sistema de archivos. kbot no las decodifica. .gp3necesita el parche rev3.1 para cargarse. Por dentro sigue siendo un HPI, pero el motor se niega a montarlo en instalaciones sin parchear.
Apéndice: recorrido hexadecimal completo de AFark.ufo
AFark.ufo es el complemento Arm Fark, un archivo comprimido pequeño de 9
archivos, ideal para una demostración de principio a fin. Recorreremos la
cabecera, descifraremos el directorio, diseccionaremos un par de
entradas y luego echaremos un vistazo a los primeros bytes de un chunk
comprimido.
1. Cabecera (sin cifrar)
Bytes en bruto 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 ....
Decodificados:
| Desplazamiento | Bytes | Campo | Valor |
|---|---|---|---|
0x00 |
48 41 50 49 |
Marker |
'HAPI' ✓ |
0x04 |
00 00 01 00 |
Version |
0x00010000 |
0x08 |
24 02 00 00 |
DirectorySize |
0x224 = 548 bytes |
0x0C |
a6 00 00 00 |
DecryptKey |
byte 0xA6 |
0x10 |
14 00 00 00 |
Start |
0x14 |
Derivamos la clave de cifrado:
key = (0xA6 << 2) | (0xA6 >> 6)
= 0x98 | 0x02
= 0x9A
2. Directorio descifrado (desplazamientos 0x14..0x224)
Tras aplicar plain[i] = cipher[i] ^ (uint8(0x14 + i) ^ 0x9A) al blob del
directorio, e insertarlo en un búfer de 548 bytes en el desplazamiento
0x14 para que los punteros indexen correctamente, los primeros 96 bytes
son:
00000010 08 00 00 00 1c 00 00 00 64 00 00 00
^^^^^^^^^^^ ^^^^^^^^^^^
00000020 6a 00 00 00 01 97 00 00 00 a0 00 00 00 01 c6 00
00000030 00 00 cf 00 00 00 01 13 01 00 00 1d 01 00 00 01
00000040 66 01 00 00 6e 01 00 00 01 94 01 00 00 9d 01 00
00000050 00 01 c3 01 00 00 c9 01 00 00 01 ef 01 00 00 f7
00000060 01 00 00 01 61 6e 69 6d 73 00 01 00 00 00 72 00
^^^^^^^^^^^^^^^^^ "anims"
En 0x14 tenemos 0x00000008, el número de entradas de la raíz: 8
subdirectorios de nivel superior. En 0x18, 0x0000001C, el
desplazamiento de la primera entrada del directorio.
3. La primera entrada
Los 9 bytes en 0x1C se decodifican así:
| Campo | Bytes | Valor |
|---|---|---|
NameOffset |
64 00 00 00 |
0x64 → la cadena "anims" |
DataOffset |
6a 00 00 00 |
0x6A → descriptor de subdirectorio |
Flag |
01 |
1 ⇒ subdirectorio |
En 0x64:
00000060 .... 61 6e 69 6d 73 00 ....anims.
^^^^^^^^^^^^^^^^^
La cadena "anims\0", que confirma el nombre del directorio.
En 0x6A:
00000060 .... 01 00 00 00 72 00 ........r.
^^^^^^^^^^^ ^^^^
00000070 00 00 7b 00 00 00 ... ..{...
^^^^^
| Campo | Bytes | Valor |
|---|---|---|
EntryCount del subdirectorio |
01 00 00 00 |
1 |
EntriesOffset del subdirectorio |
72 00 00 00 |
0x72 |
Así que el directorio anims/ contiene 1 archivo, descrito a partir de
0x72:
| Campo | Bytes | Valor |
|---|---|---|
NameOffset |
7b 00 00 00 |
0x7B → nombre del archivo |
DataOffset |
8e 00 00 00 |
0x8E → descriptor del archivo |
Flag |
00 |
0 ⇒ archivo |
El nombre en 0x7B:
00000070 .. .. .. 61 72 6d 66 61 armfa
00000080 72 6b 5f 67 61 64 67 65 74 2e 67 61 66 00 rk_gadget.gaf.
→ "armfark_gadget.gaf".
El descriptor del archivo en 0x8E es un HPIFile de 9 bytes:
00000080 24 02 $.
00000090 00 00 48 48 00 00 01 ..HH...
| Campo | Bytes | Valor |
|---|---|---|
DataOffset |
24 02 00 00 |
0x0224: ¡el primer byte después del directorio! |
FileSize |
48 48 00 00 |
0x4848 = 18 504 bytes |
CompMethod |
01 |
1 ⇒ LZ77 |
Coincide con kbot hpi list -v:
anims/armfark_gadget.gaf 18504
4. Lectura del árbol completo
Al continuar con las 7 entradas de nivel superior restantes se obtiene:
.
├── 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)
Nueve archivos: la forma canónica de un .ufo de una sola unidad.
5. Un chunk comprimido
El primer byte después del directorio de 548 bytes está en 0x224. Al
descifrar esa zona (la cabecera del chunk también está cifrada con el
cifrado del directorio) aparece:
00000220 .. 53 51 53 48 02 01 01 cc .SQSH....
| Desplazamiento (desde 0x224) | Bytes | Campo | Valor |
|---|---|---|---|
+0 |
53 51 53 48 |
Marker |
'SQSH' ✓ |
+4 |
02 |
Version |
0x02 (siempre) |
+5 |
01 |
CompMethod |
1 ⇒ LZ77 |
+6 |
01 |
Encrypted |
1 ⇒ aplicar la segunda pasada XOR |
+7.. |
… | CompressedSize, DecompressedSize, Checksum y después data[] |
… |
Una vez que descifras el cuerpo del chunk con data[i] = (data[i] - i) ^ i
y pasas los bytes resultantes por el decodificador LZ77 descrito arriba,
obtienes el GAF descomprimido, listo para entregárselo a un analizador de
GAF.
Tip
Reproduce este recorrido en tu propio ordenador.
# 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
Apéndice: recorrido hexadecimal completo de Jersey.hpi (HPI v2)
El equivalente en TA: Kingdoms del recorrido de AFark. Jersey.hpi es un
HPI de 67 KB, un paquete de voces incluido en la instalación de GoG de
TA:K: seis WAV de efectos de sonido y nada más. Es lo bastante pequeño para
recorrerlo de principio a fin y lo bastante grande para ejercitar todos
los mecanismos de v2 (cabecera de 32 bytes, pool de nombres, blob del
directorio envuelto en SQSH, datos de archivo en un solo chunk). El
recorrido de v1 de arriba cubre todo lo que no cambia; esta sección solo
señala las diferencias de v2.
1. La cabecera de 32 bytes
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 @...[... ...?...
| Desplazamiento | Bytes | Campo | Valor |
|---|---|---|---|
0x00 |
48 41 50 49 |
Marker |
'HAPI' ✓ |
0x04 |
00 00 02 00 |
Version |
0x00020000: v2 |
0x08 |
92 06 01 00 |
DirectoryBlock |
0x00010692: desplazamiento del blob del directorio |
0x0C |
ad 00 00 00 |
DirectorySize |
0xAD = 173 bytes |
0x10 |
40 06 01 00 |
NameBlock |
0x00010640: desplazamiento del pool de nombres |
0x14 |
5b 00 00 00 |
NameSize |
0x5B = 91 bytes |
0x18 |
20 00 00 00 |
Data |
0x20: los datos de los archivos empiezan aquí, justo después de la cabecera |
0x1C |
3f 07 01 00 |
Last78 |
0x0001073F: marcador de fin de archivo (0x1073F = 67 391 = tamaño del archivo − 78 bytes de pie) |
Sin cifrado XOR, sin transformaciones de descifrado, sin semilla por
posición. Los valores de la cabecera apuntan a desplazamientos absolutos en
el archivo a los que puedes saltar directamente con seek.
2. El pool de nombres (texto en bruto, cerca del final)
Unos cientos de bytes antes de DirectoryBlock (el motor escribe el pool
de nombres antes que el directorio) está la tabla de cadenas:
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.
Cada entrada de directorio y de archivo guarda un NamePtr, un
desplazamiento en bytes dentro de este bloque; si sigues el puntero y lees
hasta el siguiente \0, obtienes el nombre. Byte a byte:
Desplazamiento (relativo a NameBlock) |
Cadena terminada en NUL |
|---|---|
0x00 |
"" (directorio raíz: nombre vacío) |
0x01 |
"sounds" (el único subdirectorio) |
0x08 |
"Whodied.wav" |
0x14 |
"Sure.wav" |
0x1D |
"WhoahPally.wav" |
0x2C |
"Forgetaboutit.wav" |
0x3E |
"Yo.wav" |
0x45 |
"Igotcher.wav" |
Note
El tamaño nominal del pool de nombres incluye unos cuantos bytes finales
sin usar. NameSize=0x5B llega hasta el desplazamiento 0x1069B, pero
el último byte con significado (el NUL tras Igotcher.wav) está en
0x10691. Los 9 bytes finales se solapan con el inicio del chunk SQSH del
directorio y ningún NamePtr los indexa: los lectores los toleran
simplemente porque nunca los miran.
3. El blob del directorio (envuelto en SQSH)
En 0x10692 el blob del directorio empieza con una cabecera de chunk SQSH;
v2 reutiliza el formato de chunk de 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…
| Desplazamiento | Bytes | Campo | Valor |
|---|---|---|---|
+0 |
53 51 53 48 |
Marker |
'SQSH' ✓ |
+4 |
02 |
Version |
2 |
+5 |
02 |
CompMethod |
2 ⇒ ZLib |
+6 |
01 |
Encrypted |
1: al cuerpo del chunk se le ha aplicado la segunda pasada XOR (raro en TAK; este archivo comprimido la tiene) |
+7 |
9a 00 00 00 |
CompressedSize |
0x9A = 154 bytes |
+11 |
b8 00 00 00 |
DecompressedSize |
0xB8 = 184 bytes |
+15 |
9b 4f 00 00 |
Checksum |
0x4F9B |
+19..+172 |
… | data[] |
154 bytes de flujo ZLib desordenado con XOR |
19 + 154 = 173 = 0xAD = DirectorySize, así que el chunk entero cuadra con
lo que declara la cabecera. Tras deshacer la pasada XOR (con el mismo
algoritmo que en v1) y pasarlo por inflate, obtienes 184 bytes de
registros de directorio compactos.
4. Cómo decodificar los registros del directorio
El blob descomprimido contiene un array compacto de dos tipos de registro:
entradas de directorio de 20 bytes (HpiDir2) y entradas de
archivo de 24 bytes (HpiEntry2). No hay prefijo de recuento ni tabla de
desplazamientos: el directorio raíz siempre está en el desplazamiento 0
del blob, y sus campos FirstSubDirectory / FirstFile apuntan a los
hijos.
El HpiDir2 raíz (en el desplazamiento descomprimido 0, 20 bytes):
| Desplazamiento | Bytes (descomprimidos) | Campo | Valor |
|---|---|---|---|
+0 |
00 00 00 00 |
NamePtr |
0 → "" (raíz) |
+4 |
14 00 00 00 |
FirstSubDirectory |
0x14: un único registro de subdirectorio de 20 bytes en este desplazamiento |
+8 |
01 00 00 00 |
SubCount |
1 (el directorio sounds/) |
+12 |
00 00 00 00 |
FirstFile |
0: no hay archivos en la raíz |
+16 |
00 00 00 00 |
FileCount |
0 |
Al seguir el puntero hasta 0x14 se llega al HpiDir2 de sounds:
NamePtr = 0x01→"sounds"FirstSubDirectory = 0,SubCount = 0: sin subdirectorios anidadosFirstFile = 0x28: el primero de seis registrosHpiEntry2(de 24 bytes cada uno)FileCount = 6
Cada registro de archivo en 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
Una vez decodificadas, las seis entradas corresponden exactamente a lo que
muestra 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. Lo que NO está aquí (frente a v1)
Si tienes fresco el recorrido de v1, las piezas que faltan son precisamente el objetivo de la simplificación de v2:
- No hay derivación de la clave XOR:
DecryptKeysiempre es0en v2. Aunque un chunk SQSH tengaEncrypted == 1y la pasada XOR interna del chunk se siga aplicando, el envoltorio del directorio y de los datos está en claro. - No hay semilla por posición: el cifrado byte a byte que complicaba los lectores de v1 (el descifrado depende del desplazamiento absoluto en el archivo) no existe en el envoltorio de v2.
- No hay tabla de tamaños de chunks: los archivos de v1 se dividían en bloques de ≤64 KiB con un array inicial de tamaños uint32. v2 guarda cada archivo como un único chunk SQSH (o como bytes sin comprimir), así que no hay nada que poner en una tabla de tamaños.
- No hay convención de directorio inmediatamente después de la cabecera
en
0x14: los bloques de directorio y de nombres están donde los dejó el escritor (normalmente cerca del final del archivo). La cabecera apunta a ellos.
Tip
Reproduce este recorrido en tu propio ordenador.
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