Todas as páginas de formatos ↓
Contentores e invólucros

HPI / UFO / CCX / GP3 — arquivos HAPI

Fonte: coreprime/kbot @ 0ab97bf · Editar no GitHub

O HAPI ("HPI") é o contentor cifrado e comprimido que a Cavedog usou para distribuir todos os recursos do jogo. A mesma estrutura no disco é reutilizada com extensões diferentes:

Extensão Finalidade Ordem de montagem no TA 3.1c
.gp3 A camada do patch 3.1 (rev31.gp3) Montado primeiro; os outros *.gp3 são ignorados
.ccx Dados do Core Contingency / Battle Tactics (btdata.ccx, ccdata.ccx) Depois de rev31.gp3
.ufo Complementos de unidades/elementos (AFark.ufo) Depois de todos os .ccx; sem limite de quantidade
.hpi Arquivos do jogo base (totala1.hpi, tactics1.hpi) Por último; só os primeiros dez que abrirem

Os quatro são formatos idênticos byte a byte: só muda a ordem de montagem. No arranque, o jogo junta-os num único sistema de ficheiros virtual, e ganha o primeiro arquivo montado que contenha um caminho.

Important

Dois dialetos no disco. O Total Annihilation escreve HPI v1 (Version == 0x00010000), a estrutura cifrada com vários blocos que esta página documenta de cima a baixo. O Total Annihilation: Kingdoms escreve HPI v2 (Version == 0x00020000), uma estrutura diferente e mais simples, sem cifra XOR e com um único bloco SQSH por ficheiro ou por bloco de diretório. Os pormenores da v2 estão numa secção própria, perto do fim desta página: HPI v2 do TA: Kingdoms. O TA 3.1c só monta arquivos v1.

Ordem de carregamento em camadas

O TA 3.1c só percorre o nível de topo da pasta do jogo (os arquivos em subpastas como Backup/ nunca são montados) e monta, por esta ordem:

  1. rev31.gp3;
  2. todos os *.ccx;
  3. todos os *.ufo;
  4. os primeiros dez *.hpi que abrirem;
  5. os ficheiros *.hpi do nível de topo do CD do jogo, sem limite de quantidade.

Dentro de cada grupo, o jogo pega nos nomes por ordem ASCII em maiúsculas (a ordem devolvida pela listagem da pasta: _ fica depois das letras, AFark.ufo antes de zeta.ufo). Quando resolve um caminho como units/ARMCOM.fbi, ganham os ficheiros soltos da instalação e, a seguir, o primeiro arquivo montado que contenha o caminho. Um arquivo só conta como montado se for um arquivo da versão 1, terminado pelo rodapé de direitos de autor da Cavedog (veja Rodapé), cujo diretório se consiga ler; qualquer outro ficheiro é ignorado e não ocupa nenhuma das dez posições *.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"

Na versão da GOG, as dez posições *.hpi são ocupadas por tactics1–8.hpi, totala1.hpi e totala2.hpi. totala3.hpi, totala4.hpi (os mapas comerciais) e worlds.hpi (as secções de mapa) são os arquivos do disco: uma instalação da GOG guarda-os na pasta do jogo, que faz as vezes do disco, por isso o jogo monta-os depois de todos os outros arquivos. O mesmo se aplica a qualquer *.hpi que um mod acrescente: um cujo nome fique ordenado depois da décima posição só é alcançado através dessa leitura do disco, abaixo de tudo o resto. Para o evitar, distribua os mods como .ufo (sem limite, acima de todos os .hpi).

O kbot monta os contextos do Total Annihilation da mesma maneira, em todo o lado: o studio, kbot mount, kbot mount flatten, kbot pack, kbot tnt e o servidor MCP. A página Home e o separador Layering do separador Files, os comandos archives e stats do kbot mount e a ferramenta MCP vfs_game_data listam a ordem de montagem e todos os arquivos que não foram montados, com o motivo. Os contextos do TA: Kingdoms mantêm a ordem de sobreposição do kbot (todos os arquivos por extensão e nome, com um posterior a substituir um anterior), de que precisa a sobreposição do IPData.hpi ao data.hpi nesse jogo.

A regra "os ficheiros soltos ganham" é a razão pela qual o kbot mount flatten produz uma pasta que se comporta exatamente como o conjunto em camadas: todos os ficheiros que o motor teria resolvido através da pilha de arquivos estão presentes como ficheiros soltos no mesmo caminho, por isso a resolução termina logo no topo da pilha.

Que definição ganha

Os elementos do mapa, as armas e as unidades são carregados a partir da listagem de uma pasta: features/**/*.tdf (com as subpastas), weapons/*.tdf e units/*.fbi (só o nível de topo — uma subpasta weapons/old/ nunca é lida). A listagem percorre primeiro os ficheiros soltos e depois cada arquivo montado, pela ordem de montagem e com as entradas pela ordem em que estão guardadas, e ganha a primeira definição de um nome; as seguintes são ignoradas. O CarScar05 da versão comercial está definido em features/urban/cars2.tdf e de novo em cars.tdf, e cars2.tdf está guardado primeiro, por isso o jogo desenha-o a partir de anims/cars2.gaf. O studio (paleta de elementos, sandbox, pacotes, listas de unidades e de armas), o kbot tnt preview e o kbot tnt lint, e as ferramentas MCP aplicam a mesma regra.


Visão geral

   ┌─── 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 ler um HPI, o processo é este:

  1. Leia o cabeçalho de 20 bytes. Verifique o número mágico "HAPI".
  2. Derive a chave XOR do arquivo a partir de DecryptKey.
  3. Decifre e leia a árvore de diretórios.
  4. Para cada ficheiro que quiser, vá até aos seus blocos comprimidos, decifre-os e depois trate-os conforme o CompMethod (LZ77, ZLib ou nenhum).

Tip

Inspecione qualquer HPI sem escrever um analisador.

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

Todas as opções estão na referência de kbot hpi da linha de comandos.

A partir de Go. Use 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

Cabeçalho (20 bytes, em claro)

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' em little-endian. A variante 'BANK' é usada pelos jogos guardados e não é tratada aqui.
Version Os arquivos comerciais do Total Annihilation usam 0x00010000 (v1, esta secção). Os arquivos do TA: Kingdoms usam 0x00020000 (v2); veja HPI v2 do TA: Kingdoms.
DirectorySize Inclui os 20 bytes do cabeçalho, por isso o blob do diretório ocupa na realidade DirectorySize − Start bytes a partir de Start.
DecryptKey Passe-o pela transformação abaixo para obter o byte usado como chave XOR. Para o TA 3.1c, os bytes de chave 0 e 0xFF significam ambos "não cifrado". Os arquivos comerciais da Cavedog usam 0xBF.
Start Onde começa o diretório. Foi sempre 0x14 nos ficheiros observados.

Derivar a chave da cifra

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

Cada byte cifrado é depois decifrado com a sua posição absoluta no ficheiro:

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

em que pos é o deslocamento absoluto de cipher[0] no ficheiro. Sim: a semente avança com a posição no ficheiro, por isso não é possível decifrar um buffer isoladamente; é preciso saber de onde veio.

Important

Se o byte da chave for 0 ou 0xFF, não aplique a cifra. O TA 3.1c lê ambos como texto simples (para 0xFF, a rotação acima daria 0xFF, mas o jogo não decifra). Alguns arquivos personalizados (e alguns mods da comunidade) são escritos sem cifra; aplicar a cifra XOR nesse caso baralha o que já é texto simples. É por isso que o kbot hpi pack recusa --key 255; use --key 0.


Árvore de diretórios

Um diretório é uma contagem mais um ponteiro para a sua primeira entrada:

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

Cada entrada tem exatamente 9 bytes:

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

Nos ficheiros, DataOffset aponta para:

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 os deslocamentos são absolutos dentro do ficheiro. É por isso que o analisador canónico reserva um buffer de DirectorySize bytes e escreve o diretório na posição Start: assim, os ponteiros indexam o buffer diretamente, sem contas.

Ler o diretório

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

As maiúsculas e minúsculas dos nomes de ficheiro são preservadas na escrita, mas ignoradas na correspondência durante a leitura. O Total Annihilation nasceu no Windows 9x; os mods misturam muitas vezes armcom.fbi e ARMCOM.FBI, até dentro do mesmo arquivo. O jogo só ignora a diferença entre maiúsculas e minúsculas nas letras ASCII, divide os caminhos tanto em \ como em / e, quando um diretório guarda várias entradas cujos nomes só diferem nas maiúsculas e minúsculas, lê a última; as outras ficam inacessíveis. Também trata . e .. como nomes normais. Por isso, o kbot hpi extract só escreve a entrada acessível de um grupo desses e ignora (com uma mensagem) qualquer entrada cujo caminho, tal como está guardado, não pudesse ser escrito dentro da pasta de destino, como ../../x.txt; o kbot mount flatten também nunca escreve fora da sua pasta de destino.


Blocos comprimidos (SQSH)

Os dados de um ficheiro são divididos em blocos de até 64 KiB descomprimidos. Cada bloco tem o seu próprio cabeçalho 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;

O número de blocos é ceil(FileSize / 65536). Imediatamente a seguir ao primeiro HPIFile.DataOffset do ficheiro encontra um vetor de tamanhos comprimidos uint32, um por bloco, seguido dos próprios blocos, uns a seguir aos outros.

A segunda camada XOR

Se Encrypted == 1, o corpo de cada bloco leva uma segunda passagem XOR:

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

Repare que a soma de verificação é calculada sobre os bytes ainda cifrados: verifique-a antes de desfazer a cifra.

LZ77 (CompMethod = 1)

Um LZ77 clássico de janela deslizante, com um histórico de 4 KiB:

  • Byte de controlo — 8 bits de flag, a começar pelo LSB. 0 ⇒ literal, 1 ⇒ referência para trás.
  • Literal — copia 1 byte tal e qual para a saída e para o histórico.
  • Correspondência — lê um uint16. Os 12 bits superiores são o deslocamento na janela (1–4095) e os 4 bits inferiores codificam o comprimento como len − 2 (por isso as correspondências têm 2–17 bytes).
  • Terminador — uma correspondência com windowOffset == 0 termina o fluxo.

ZLib (CompMethod = 2)

Fluxo zlib normal, em bruto. Passe o corpo do bloco ao inflate() depois de retirar a camada de cifra; DecompressedSize é o comprimento esperado.

Armazenado (CompMethod = 0)

Os bytes são escritos tal e qual: sem divisão em blocos, sem soma de verificação, sem segunda passagem XOR. Útil para ficheiros que não se comprimem (PCX/GAF já comprimidos), e é o único modo que o antigo "unit viewer" percebe.


Rodapé (trailer)

Todos os arquivos que o jogo monta terminam com uma linha ASCII de direitos de autor com 36 bytes:

Copyright 1997 Cavedog Entertainment

Nada no cabeçalho aponta para ela, mas não é opcional: antes de montar um arquivo, o TA 3.1c verifica se os seus últimos 36 bytes dizem Copyright ____ Cavedog Entertainment (os quatro caracteres do ano não são comparados). Um arquivo sem ela, ou com bytes acrescentados depois dela, é ignorado e não ocupa nenhuma das dez posições *.hpi: um mod empacotado sem o rodapé simplesmente não aparece no jogo, sem qualquer aviso.

Todos os arquivos que o kbot escreve mantêm-na: o kbot hpi pack e as exportações do studio (o Export mod de um workspace e a transferência de arquivo do editor de mapas). O kbot hpi info, o kbot hpi list -v, as ferramentas MCP hpi_info e hpi_list e os metadados de um arquivo no separador Files mostram uma linha TA 3.1c would mount: yes / no, <reason>.


TA: Kingdoms — HPI v2

Note

Esta secção trata só do que muda no TA:K. Tudo o que está acima diz respeito ao Total Annihilation (Version == 0x00010000). O TA: Kingdoms usa a mesma extensão .hpi, mas uma estrutura diferente no disco: leia esta secção do princípio ao fim antes de recorrer ao analisador v1.

O TA: Kingdoms mantém o número mágico 'HAPI' e o conceito de árvore de diretórios, mas de resto refaz o invólucro. As diferenças, de uma assentada:

Aspeto v1 (TA) v2 (TA: Kingdoms)
Campo da versão 0x00010000 0x00020000
Tamanho do cabeçalho 20 bytes (5 × uint32) 32 bytes (8 bytes de número mágico+versão + subcabeçalho de 24 bytes)
Cifra Cifra XOR com semente em DecryptKey, que avança com a posição no ficheiro Nenhuma — tudo está em texto simples
Localização do diretório + conjunto de nomes Logo a seguir ao cabeçalho (cifrado) Em deslocamentos arbitrários, normalmente perto do fim do ficheiro, cada um opcionalmente envolvido num único bloco SQSH
Divisão de cada ficheiro em blocos Dividido em blocos de ≤64 KiB com uma tabela de comprimentos; cada bloco tem o seu próprio cabeçalho SQSH + soma de verificação + segunda passagem XOR opcional Um único bloco SQSH com o payload inteiro, ou bytes armazenados se CompressedSize == 0; sem passagem XOR, sem tabela de comprimentos
Cifra por bloco (byte Encrypted no SQSH) Usada Não usada (continuamos a respeitar o byte se estiver definido, mas em todos os ficheiros do TAK que analisámos está a 0)

Cabeçalho v2

Depois do prólogo de 8 bytes ('HAPI' + 0x00020000) vêm seis campos de 32 bits little-endian; no total, o cabeçalho ocupa 32 bytes no 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 o blob do diretório como o blob dos nomes podem estar comprimidos em SQSH: se os primeiros quatro bytes em DirectoryBlock / NameBlock forem 'SQSH', trate essa zona como um único bloco (cabeçalho + payload) e descomprima-a. Caso contrário, os Size bytes no deslocamento indicado são o conteúdo descomprimido em bruto.

Entradas do diretório v2

O blob do diretório descomprimido é um vetor compacto de dois tipos de registo (sem tabelas de deslocamentos separadas, sem prefixo EntryCount: os filhos encontram-se através dos ponteiros FirstSubDirectory / FirstFile do pai):

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;

Payloads de cada ficheiro

Para uma entrada de ficheiro no deslocamento Start:

  • Se CompressedSize == 0: leia DecompressedSize bytes tal e qual.
  • Caso contrário, os CompressedSize bytes seguintes formam um único bloco SQSH (com o mesmo cabeçalho de 19 bytes da v1): leia-o, verifique a sua soma de verificação contra a soma dos bytes do payload, descodifique-o com o CompMethod do bloco (1 = LZ77, 2 = ZLib) e tem o ficheiro.

O esquema de vários blocos da v1 (tabela de tamanhos, blocos de ≤64 KiB, passagem de cifra) desapareceu. A v2 troca a compacidade por um leitor muito mais simples.

Como o kbot os trata

O kbot hpi list, o extract e o info suportam os dois dialetos de forma transparente: escolhem o caminho pela palavra Version do cabeçalho. A escrita de ida e volta (kbot hpi pack) por agora só produz v1; ainda não criamos arquivos v2.

O leitor v2 interno está em formats/hpi/hpi_v2.go. hpi.Reader.Version() indica se o ficheiro carregado é v1 ou v2.

Tip

Confirme que uma instalação do TAK carrega.

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

Exemplo comentado — aflakker.ufo

O guia original de Joe D. usava o complemento de unidade Flakker do ARM; reproduzimos aqui o cabeçalho que ele dissecou. Dump hexadecimal dos primeiros 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:

Deslocamento 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

Decifrar [0x14, 0x224) e percorrer a árvore dá:

.
├── 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 estrutura de nove ficheiros é a forma canónica de um complemento .ufo de uma só unidade; os arquivos base totala*.hpi usam o mesmo esquema em grande escala (~30k ficheiros).

Tip

Reproduza esta dissecação na sua máquina.

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

Reempacotar

Para criar um arquivo novo a partir de uma árvore de pastas:

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

Por omissão, o kbot usa compressão LZ77 em blocos com a chave de cabeçalho normal 0xBF e o rodapé da Cavedog, o que produz um binário que o motor comercial aceita. As opções mantêm isso assim:

Opção Efeito
--method lz77|zlib|none Compressão dos blocos; none escreve entradas armazenadas (sem compressão).
--key N Byte da chave do cabeçalho; 0 escreve um arquivo não cifrado. 255 é recusado: o jogo lê-o como "não cifrado".
--trailer "Copyright 1998 Cavedog Entertainment" Outro ano no rodapé; qualquer outro texto é recusado.
--no-trailer Omite o rodapé: o jogo não monta o resultado (é mostrado um aviso).
--format v2 Escreve um arquivo do TA: Kingdoms (--key, --trailer, --no-trailer e --encode-chunks não se aplicam).

Os nomes de pastas que só diferem nas maiúsculas e minúsculas (Units/ e units/ num disco que as distingue) são fundidos da mesma forma que o jogo compara os nomes; dois ficheiros cujos caminhos só diferem nas maiúsculas e minúsculas são ambos guardados, o jogo lê o que foi empacotado por último e o pack mostra um aviso.


Armadilhas

Warning

Os ponteiros são absolutos em relação ao início do ficheiro, não ao início do diretório. Não subtraia nada. O truque habitual das implementações é reservar um buffer de DirectorySize bytes e ler para ele o diretório decifrado no deslocamento Start, para que os ponteiros indexem o buffer diretamente.

  • Fórmula do número de blocos — ceil(FileSize / 65536). Não há um campo explícito com a contagem; tem de a calcular a partir de FileSize.

Tamanhos típicos

Arquivo Tamanho Ficheiros Notas
totala1.hpi (sons, animações) ~30 MB ~1900 Jogo base, o maior arquivo individual.
totala2.hpi (unidades) ~13 MB ~600 A maior parte dos metadados das unidades + scripts.
totala4.hpi (mapas) ~50 MB ~300 Todos os mapas comerciais.
worlds.hpi (secções) ~20 MB ~5000 Bibliotecas SCT do editor.
ccdata.ccx (Core Contingency) ~25 MB ~1500 Unidades/mapas da expansão.
.ufo típico de uma só unidade 10–150 KB 8–12 A maioria dos complementos de unidades de terceiros.
.ufo só com um mapa (com .tnt + .ota) 0.5–5 MB 2–4 Distribuição de um mapa de cada vez.
Taxa de compressão 35–55% — LZ77 na maioria dos ficheiros; ZLib nos maiores.
  • Alguns blocos crescem quando são comprimidos. Um bloco "comprimido" pode ter mais de 64 KiB se o LZ77 não tiver encontrado correspondências. Use CompressedSize como o comprimento de referência a copiar.
  • A soma de verificação é feita sobre os bytes cifrados. Se decifrar primeiro, a verificação falha.
  • Os jogos guardados (marcador BANK) são outra coisa: reutilizam o invólucro HPI, mas o conteúdo codificado é estado de jogo serializado, e não um sistema de ficheiros. O kbot não os descodifica.
  • O .gp3 precisa do patch rev3.1 para ser carregado. Por dentro continua a ser um HPI, mas o motor recusa-se a montá-lo em instalações sem o patch.

Apêndice — percurso hexadecimal completo de AFark.ufo

O AFark.ufo é o complemento com o Fark do ARM, um arquivo pequeno de 9 ficheiros, ideal para uma demonstração do princípio ao fim. Vamos percorrer o cabeçalho, decifrar o diretório, dissecar algumas entradas e depois espreitar os primeiros bytes de um bloco comprimido.

1. Cabeçalho (em claro)

Bytes em 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                                      ....

Descodificado:

Deslocamento 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

Derivar a chave da cifra:

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

2. Diretório decifrado (deslocamentos 0x14..0x224)

Depois de aplicar plain[i] = cipher[i] ^ (uint8(0x14 + i) ^ 0x9A) ao blob do diretório (e de o inserir num buffer de 548 bytes no deslocamento 0x14, para que os ponteiros indexem corretamente), os primeiros 96 bytes são:

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"

Em 0x14 temos 0x00000008, o número de entradas da raiz: 8 subdiretórios de nível de topo. Em 0x18, 0x0000001C, o deslocamento da primeira entrada do diretório.

3. A primeira entrada

Os 9 bytes em 0x1C descodificam-se assim:

Campo Bytes Valor
NameOffset 64 00 00 00 0x64 → cadeia "anims"
DataOffset 6a 00 00 00 0x6A → descritor de subdiretório
Flag 01 1 ⇒ subdiretório

Em 0x64:

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

A cadeia "anims\0", que confirma o nome do diretório.

Em 0x6A:

00000060  ....                  01 00 00 00 72 00      ........r.
                                ^^^^^^^^^^^ ^^^^
00000070  00 00 7b 00 00 00 ...                          ..{...
          ^^^^^
Campo Bytes Valor
EntryCount do subdiretório 01 00 00 00 1
EntriesOffset do subdiretório 72 00 00 00 0x72

Portanto, o diretório anims/ contém 1 ficheiro, descrito a partir de 0x72:

Campo Bytes Valor
NameOffset 7b 00 00 00 0x7B → nome do ficheiro
DataOffset 8e 00 00 00 0x8E → descritor do ficheiro
Flag 00 0 ⇒ ficheiro

O nome em 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".

O descritor de ficheiro em 0x8E é um 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 — o primeiro byte depois do diretório!
FileSize 48 48 00 00 0x4848 = 18504 bytes
CompMethod 01 1 ⇒ LZ77

Coincide com o que o kbot hpi list -v mostra:

anims/armfark_gadget.gaf    18504

4. Ler a árvore completa

Continuar pelas restantes 7 entradas de nível de topo dá:

.
├── 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 ficheiros, a forma canónica de um .ufo de uma só unidade.

5. Um bloco comprimido

O primeiro byte depois do diretório de 548 bytes está em 0x224. Decifrar essa zona (o cabeçalho do bloco também está cifrado com a cifra do diretório) revela:

00000220                          .. 53 51 53 48 02 01 01 cc      .SQSH....
Deslocamento (a partir de 0x224) Bytes Campo Valor
+0 53 51 53 48 Marker 'SQSH' ✓
+4 02 Version 0x02 (sempre)
+5 01 CompMethod 1 ⇒ LZ77
+6 01 Encrypted 1 ⇒ aplicar a segunda passagem XOR
+7.. … CompressedSize, DecompressedSize, Checksum, depois data[] …

Depois de desbaralhar o corpo do bloco com data[i] = (data[i] - i) ^ i e de passar os bytes resultantes pelo descodificador LZ77 descrito acima, obtém o GAF descomprimido, pronto a entregar a um analisador de GAF.

Tip

Reproduza este percurso na sua máquina.

# 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 — percurso hexadecimal completo de Jersey.hpi (HPI v2)

O equivalente, no TA: Kingdoms, do percurso pelo AFark. O Jersey.hpi é um HPI de 67 KB com um pacote de vozes, distribuído na instalação do TA:K da GoG: seis WAV de efeitos sonoros e mais nada. É pequeno o suficiente para ser percorrido do princípio ao fim e grande o suficiente para pôr à prova todos os mecanismos da v2 (cabeçalho de 32 bytes, conjunto de nomes, blob do diretório envolvido em SQSH, payloads de ficheiro num só bloco). O percurso da v1, acima, cobre tudo o que se mantém igual; esta secção só assinala as diferenças da v2.

1. O cabeçalho 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   @...[... ...?...
Deslocamento 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 — deslocamento do blob do diretório
0x0C ad 00 00 00 DirectorySize 0xAD = 173 bytes
0x10 40 06 01 00 NameBlock 0x00010640 — deslocamento do conjunto de nomes
0x14 5b 00 00 00 NameSize 0x5B = 91 bytes
0x18 20 00 00 00 Data 0x20 — os payloads dos ficheiros começam aqui, logo a seguir ao cabeçalho
0x1C 3f 07 01 00 Last78 0x0001073F — marcador de fim de arquivo (0x1073F = 67391 = tamanho do ficheiro − 78 bytes de rodapé)

Sem cifra XOR, sem transformações de decifração, sem semente por posição. Os valores do cabeçalho apontam para deslocamentos absolutos no ficheiro, para os quais pode fazer seek diretamente.

2. O conjunto de nomes (texto em bruto, perto do fim)

Em DirectoryBlock menos algumas centenas de bytes (o motor escreve o conjunto de nomes antes do diretório) fica a tabela de cadeias:

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 diretório e de ficheiro guarda um NamePtr, um deslocamento em bytes dentro deste bloco; seguindo o ponteiro e lendo até ao \0 seguinte obtém o nome. Byte a byte:

Deslocamento (relativo a NameBlock) Cadeia terminada em NUL
0x00 "" (diretório raiz — nome vazio)
0x01 "sounds" (o único subdiretório)
0x08 "Whodied.wav"
0x14 "Sure.wav"
0x1D "WhoahPally.wav"
0x2C "Forgetaboutit.wav"
0x3E "Yo.wav"
0x45 "Igotcher.wav"

Note

O tamanho nominal do conjunto de nomes inclui alguns bytes finais não usados. NameSize=0x5B vai até ao deslocamento 0x1069B, mas o último byte com significado (o NUL depois de Igotcher.wav) está em 0x10691. Os 9 bytes finais sobrepõem-se ao início do bloco SQSH do diretório e nunca são indexados por nenhum NamePtr; os leitores toleram-nos simplesmente porque nunca olham para eles.

3. O blob do diretório (envolvido em SQSH)

Em 0x10692, o blob do diretório começa com um cabeçalho de bloco SQSH, porque a v2 reutiliza o formato de bloco da 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…
Deslocamento Bytes Campo Valor
+0 53 51 53 48 Marker 'SQSH' ✓
+4 02 Version 2
+5 02 CompMethod 2 ⇒ ZLib
+6 01 Encrypted 1 — o corpo do bloco tem a segunda passagem XOR aplicada (raro no TAK; este arquivo tem-na)
+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 fluxo ZLib baralhado com XOR

19 + 154 = 173 = 0xAD = DirectorySize, por isso o bloco inteiro bate certo com o que o cabeçalho indica. Depois de desfazer a passagem XOR (o mesmo algoritmo da v1) e de passar tudo pelo inflate, obtém 184 bytes de registos de diretório compactados.

4. Descodificar os registos do diretório

O blob descomprimido contém um vetor compacto de dois tipos de registo: entradas de diretório de 20 bytes (HpiDir2) e entradas de ficheiro de 24 bytes (HpiEntry2). Não há prefixo de contagem nem tabela de deslocamentos: o diretório raiz está sempre no deslocamento 0 do blob, e os seus campos FirstSubDirectory / FirstFile apontam para os filhos.

O HpiDir2 da raiz (no deslocamento 0 dos dados descomprimidos, 20 bytes):

Deslocamento Bytes (descomprimidos) Campo Valor
+0 00 00 00 00 NamePtr 0 → "" (raiz)
+4 14 00 00 00 FirstSubDirectory 0x14 — um único registo de subdiretório de 20 bytes neste deslocamento
+8 01 00 00 00 SubCount 1 (o diretório sounds/)
+12 00 00 00 00 FirstFile 0 — sem ficheiros na raiz
+16 00 00 00 00 FileCount 0

Seguindo o ponteiro para 0x14, chega-se ao HpiDir2 de sounds:

  • NamePtr = 0x01 → "sounds"
  • FirstSubDirectory = 0, SubCount = 0 — sem subdiretórios aninhados
  • FirstFile = 0x28 — o primeiro de seis registos HpiEntry2 (24 bytes cada)
  • FileCount = 6

Cada registo de ficheiro em 0x28, 0x40, 0x58, … contém:

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

Descodificadas, as seis entradas correspondem exatamente ao que o kbot hpi list mostra:

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. O que NÃO está aqui (em relação à v1)

Se tem o percurso da v1 fresco na memória, as peças que faltam são precisamente o objetivo da simplificação da v2:

  • Sem derivação da cifra XOR — DecryptKey é sempre 0 na v2. Mesmo quando um bloco SQSH tem Encrypted == 1, a segunda passagem XOR dentro do bloco continua a ser aplicada, mas o invólucro do diretório e dos payloads está em texto simples.
  • Sem semente por posição — a cifra byte a byte que complicava os leitores v1 (a decifração depende do deslocamento absoluto no ficheiro) não existe ao nível do invólucro da v2.
  • Sem tabela de tamanhos dos blocos — os ficheiros v1 eram divididos em blocos de ≤64 KiB com um vetor inicial de tamanhos uint32. A v2 guarda cada ficheiro como um único bloco SQSH (ou bytes armazenados), por isso não há nada para pôr numa tabela de tamanhos.
  • Sem a convenção 0x14 de diretório logo a seguir ao cabeçalho — os blocos do diretório e dos nomes ficam onde quer que o escritor os tenha posto (normalmente perto do fim do ficheiro). O cabeçalho aponta para eles.

Tip

Reproduza este percurso na sua máquina.

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

Ver também

  • GAF, PCX, 3DO, TNT, COB, TDF — os tipos de ficheiros que os arquivos HPI contêm.
  • Glossário — definições de virtual filesystem, layering e de outros termos ligados ao HPI.