Todas las páginas de formatos ↓
Contenedores y envoltorios

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
.gp3 La capa del parche 3.1 (rev31.gp3) Se monta la primera; los demás *.gp3 se ignoran
.ccx Datos de Core Contingency / Battle Tactics (btdata.ccx, ccdata.ccx) Después de rev31.gp3
.ufo Complementos de unidades y elementos (AFark.ufo) Después de todos los .ccx; sin límite de número
.hpi Archivos 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:

  1. rev31.gp3;
  2. todos los *.ccx;
  3. todos los *.ufo;
  4. los diez primeros *.hpi que se abren;
  5. los archivos *.hpi del 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.ufo antes que zeta.ufo). Cuando resuelve una ruta como units/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 *.hpi las ocupan tactics1–8.hpi, totala1.hpi y totala2.hpi. totala3.hpi, totala4.hpi (los mapas de la versión comercial) y worlds.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 *.hpi que 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 tnt y el servidor MCP. La página Home y la pestaña Layering de la pestaña Files, los comandos archives y stats de kbot mount y la herramienta MCP vfs_game_data muestran 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 de IPData.hpi sobre data.hpi de ese juego.

La regla de que «ganan los archivos sueltos» es la razón por la que kbot mount flatten produce 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/*.tdf y units/*.fbi (solo el nivel superior: una subcarpeta weapons/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. El CarScar05 de la versión comercial está definido en features/urban/cars2.tdf y otra vez en cars.tdf, y cars2.tdf está almacenado primero, así que el juego lo dibuja a partir de anims/cars2.gaf. El studio (paleta de elementos, sandbox, paquetes, listas de unidades y de armas), kbot tnt preview y kbot 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:

  1. Leer la cabecera de 20 bytes. Comprobar el número mágico "HAPI".
  2. Derivar de DecryptKey la clave XOR del archivo.
  3. Descifrar y leer el árbol de directorios.
  4. 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 como len − 2 (así que las coincidencias miden de 2 a 17 bytes).
  • Terminador: una coincidencia con windowOffset == 0 termina 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: lee DecompressedSize bytes tal cual.
  • Si no: los CompressedSize bytes 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 el CompMethod del 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 de FileSize.

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 CompressedSize como 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.
  • .gp3 necesita 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 anidados
  • FirstFile = 0x28: el primero de seis registros HpiEntry2 (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: DecryptKey siempre es 0 en v2. Aunque un chunk SQSH tenga Encrypted == 1 y 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

Véase también

  • GAF, PCX, 3DO, TNT, COB, TDF: los tipos de archivo que contienen los archivos comprimidos HPI.
  • Glosario: definiciones de virtual filesystem, layering y otros términos relacionados con HPI.