Todas as páginas de formatos ↓
Contêineres e invólucros

HPI / UFO / CCX / GP3 — arquivos compactados HAPI

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

HAPI ("HPI") é o contêiner criptografado e compactado que a Cavedog usou para distribuir todos os recursos do jogo. A mesma estrutura no disco é reaproveitada 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 Add-ons de unidades/elementos (AFark.ufo) Depois de todos os .ccx; sem limite de quantidade
.hpi Arquivos compactados do jogo base (totala1.hpi, tactics1.hpi) Por último; só os dez primeiros que abrirem

Os quatro são formatos idênticos byte a byte — só a ordem de montagem muda. O jogo junta todos num único sistema de arquivos virtual ao iniciar, e vence o primeiro arquivo compactado montado que contém um caminho.

Important

Dois dialetos no disco. O Total Annihilation grava HPI v1 (Version == 0x00010000), a estrutura criptografada com vários chunks que esta página documenta de cima a baixo. O Total Annihilation: Kingdoms grava HPI v2 (Version == 0x00020000) — uma estrutura diferente e mais simples, sem cifra XOR e com um único chunk SQSH por arquivo ou bloco de diretório. Os detalhes do v2 ficam numa seção própria perto do fim desta página: HPI v2 do TA: Kingdoms. O TA 3.1c só monta arquivos compactados v1.

Camadas e ordem de carregamento

O TA 3.1c examina só o nível mais alto da pasta do jogo (arquivos compactados em subpastas como Backup/ nunca são montados) e monta, nesta ordem:

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

Dentro de cada grupo, o jogo pega os nomes em ordem ASCII de maiúsculas (a ordem que a listagem da pasta devolve: _ fica depois das letras, AFark.ufo antes de zeta.ufo). Quando resolve um caminho como units/ARMCOM.fbi, os arquivos soltos da instalação vencem, e depois o primeiro arquivo compactado montado que contém o caminho. Um arquivo compactado só conta como montado se for um arquivo compactado versão 1 que termina com o trailer de copyright da Cavedog (veja Trailer) e cujo diretório pode ser lido; qualquer outro arquivo é ignorado e não ocupa uma das dez vagas 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"

Na versão da GOG, as dez vagas de *.hpi são ocupadas por tactics1–8.hpi, totala1.hpi e totala2.hpi. totala3.hpi, totala4.hpi (os mapas da versão comercial) e worlds.hpi (as seções de mapa) são os arquivos compactados do disco: uma instalação da GOG os mantém na pasta do jogo, que faz as vezes do disco, então o jogo os monta depois de todos os outros arquivos compactados. O mesmo vale para qualquer *.hpi que um mod acrescente: um cujo nome fique, na ordem, depois da décima vaga só é alcançado por essa varredura do disco, abaixo de todo o resto. Distribua mods como .ufo (sem limite, acima de todo .hpi) para evitar isso.

O kbot monta os contextos do Total Annihilation da mesma forma em todo lugar: o studio, kbot mount, kbot mount flatten, kbot pack, kbot tnt e o servidor MCP. A página Home e a aba Layering da aba Files, os comandos archives e stats de kbot mount e a ferramenta MCP vfs_game_data listam a ordem de montagem e todos os arquivos compactados 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 compactados por extensão e nome, um posterior substituindo um anterior), que é o que a sobreposição de IPData.hpi sobre data.hpi desse jogo exige.

A regra de que "os arquivos soltos vencem" é o motivo pelo qual kbot mount flatten produz uma pasta que se comporta exatamente como o conjunto em camadas: todo arquivo que o motor teria resolvido pela pilha de arquivos compactados está presente como arquivo solto no mesmo caminho, então a resolução para logo no topo da pilha.

Qual definição vence

Elementos, armas e unidades são carregados listando uma pasta: features/**/*.tdf (inclusive subpastas), weapons/*.tdf e units/*.fbi (só o nível mais alto — uma subpasta weapons/old/ nunca é lida). A listagem percorre primeiro os arquivos soltos e depois cada arquivo compactado montado, na ordem de montagem, com suas entradas na ordem em que estão gravadas, e a primeira definição de um nome vence; as seguintes são ignoradas. O CarScar05 da versão comercial é definido em features/urban/cars2.tdf e de novo em cars.tdf, e cars2.tdf está gravado primeiro, então o jogo o desenha a partir de anims/cars2.gaf. O studio (paleta de elementos, sandbox, pacotes, listas de unidades e de armas), kbot tnt preview e 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"

O fluxo para ler um HPI é:

  1. Leia o cabeçalho de 20 bytes. Confira o número mágico "HAPI".
  2. Obtenha a chave XOR do arquivo a partir de DecryptKey.
  3. Descriptografe e leia a árvore de diretórios.
  4. Para cada arquivo desejado, posicione-se nos chunks compactados dele, descriptografe-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

Veja a referência de kbot hpi da linha de comando para todas as opções.

Em 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, sem criptografia)

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 Observações
Marker 'HAPI' em little-endian. A variante 'BANK' é usada pelos jogos salvos e não é tratada aqui.
Version Os arquivos compactados comerciais do Total Annihilation usam 0x00010000 (v1, esta seção). Os do TA: Kingdoms usam 0x00020000 (v2); veja HPI v2 do TA: Kingdoms.
DirectorySize Inclui os 20 bytes do cabeçalho, então o blob do diretório propriamente dito ocupa DirectorySize − Start bytes a partir de Start.
DecryptKey Passe pela transformação abaixo para obter o byte usado como chave XOR. Os bytes de chave 0 e 0xFF significam ambos "sem criptografia" para o TA 3.1c. Os arquivos compactados comerciais da Cavedog usam 0xBF.
Start Onde o diretório começa. Sempre foi 0x14 nos arquivos observados.

Como obter a chave de criptografia

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

Cada byte criptografado é então desembaralhado com a sua posição absoluta no arquivo:

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

em que pos é o offset absoluto de cipher[0] no arquivo. Sim — a semente avança com a posição no arquivo, então não dá para descriptografar um buffer isoladamente; é preciso saber de onde ele veio.

Important

Se o byte de chave for 0 ou 0xFF, não aplique a cifra. O TA 3.1c lê os dois como dados não criptografados (para 0xFF, a rotação acima daria 0xFF, mas o jogo não descriptografa). Alguns arquivos compactados personalizados (e alguns mods da comunidade) são gravados sem criptografia — aplicar a cifra XOR nesse caso embaralha o que já está em texto puro. Por esse motivo, 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 arquivos, 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 offsets são absolutos dentro do arquivo. É por isso que o analisador canônico aloca um buffer de DirectorySize bytes e grava o diretório na posição Start — assim os ponteiros podem indexar o buffer diretamente, sem cálculos.

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 arquivo são preservadas na gravação, mas a comparação na leitura não as diferencia. O Total Annihilation nasceu no Windows 9x; os mods costumam misturar armcom.fbi e ARMCOM.FBI até dentro do mesmo arquivo compactado. O jogo só iguala maiúsculas e minúsculas das letras ASCII, divide os caminhos tanto em \ quanto em / e, quando um diretório guarda várias entradas cujos nomes só diferem em maiúsculas/minúsculas, lê a última delas; as outras ficam inacessíveis. Ele também trata . e .. como nomes comuns. Por isso, kbot hpi extract grava só a entrada acessível de um grupo assim e ignora (com uma mensagem) qualquer entrada cujo caminho, tal como está gravado, não poderia ser escrito dentro da pasta de destino, como ../../x.txt; kbot mount flatten também nunca grava fora do destino.


Chunks compactados (SQSH)

Os dados de um arquivo são divididos em chunks de até 64 KiB descompactados. Cada chunk 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 chunks é ceil(FileSize / 65536). Logo depois do primeiro HPIFile.DataOffset do arquivo há um array de tamanhos compactados em uint32, um por chunk, seguido dos próprios chunks, um após o outro.

A segunda camada de XOR

Se Encrypted == 1, o corpo de cada chunk recebe uma segunda passada de XOR:

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

Observe que o checksum é calculado sobre os bytes ainda criptografados — verifique antes de desfazer a cifra.

LZ77 (CompMethod = 1)

Um LZ77 de manual, com janela deslizante e histórico de 4 KiB:

  • Byte de tag — 8 bits de flag, a partir do LSB. 0 ⇒ literal, 1 ⇒ referência para trás.
  • Literal — copia 1 byte tal como está para a saída e para o histórico.
  • Correspondência — lê um uint16. Os 12 bits altos são o offset na janela (1–4095); os 4 bits baixos codificam o comprimento como len − 2 (então as correspondências têm de 2 a 17 bytes).
  • Terminador — uma correspondência com windowOffset == 0 encerra o fluxo.

ZLib (CompMethod = 2)

Fluxo bruto padrão de zlib. Passe o corpo do chunk para inflate() depois de remover a camada de criptografia; DecompressedSize é o comprimento esperado.

Armazenado (CompMethod = 0)

Os bytes são gravados tal como estão — sem divisão em chunks, sem checksum, sem a segunda passada de XOR. Útil para arquivos que não se compactam (PCX/GAF já compactados) e é o único modo que o antigo "unit viewer" entende.


Trailer

Todo arquivo compactado que o jogo monta termina com uma linha de copyright em ASCII de 36 bytes:

Copyright 1997 Cavedog Entertainment

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

Todo arquivo compactado que o kbot grava o mantém: kbot hpi pack e as exportações do studio (o Export mod de um espaço de trabalho e o download do arquivo compactado no editor de mapas). kbot hpi info, kbot hpi list -v, as ferramentas MCP hpi_info e hpi_list e os metadados de um arquivo compactado na aba Files imprimem uma linha TA 3.1c would mount: yes / no, <reason>.


TA: Kingdoms — HPI v2

Note

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

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

Aspecto v1 (TA) v2 (TA: Kingdoms)
Campo de 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)
Criptografia Cifra XOR com semente em DecryptKey, que avança com a posição no arquivo Nenhuma — tudo é texto puro
Localização do diretório + pool de nomes Logo depois do cabeçalho (criptografados) Em offsets arbitrários perto do fim do arquivo (normalmente), cada um opcionalmente envolvido num único chunk SQSH
Divisão em chunks por arquivo Dividido em chunks de ≤64 KiB com uma tabela de comprimentos; cada chunk tem o seu próprio cabeçalho SQSH + checksum + segunda passada de XOR opcional Um único chunk SQSH com todos os dados, ou bytes armazenados se CompressedSize == 0; sem passada de XOR, sem tabela de comprimentos
Cifra por chunk (byte Encrypted do SQSH) Usada Não usada (ainda respeitamos o byte se estiver definido, mas todo arquivo do TAK que examinamos o tem em 0)

Cabeçalho v2

Depois do prólogo de 8 bytes ('HAPI' + 0x00020000) vêm seis campos de 32 bits little-endian — o cabeçalho ocupa 32 bytes no disco, no total:

// 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 quanto o blob de nomes podem estar compactados em SQSH — se os quatro primeiros bytes em DirectoryBlock / NameBlock forem 'SQSH', trate o bloco como um único chunk (cabeçalho + dados) e descompacte-o. Se não forem, os Size bytes no offset indicado são o conteúdo bruto já descompactado.

Entradas de diretório v2

O blob do diretório descompactado é um array compacto de dois tipos de registro (sem tabelas de offsets separadas, sem prefixo EntryCount — os filhos são localizados pelos 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;

Dados de cada arquivo

Para uma entrada de arquivo no offset Start:

  • Se CompressedSize == 0: leia DecompressedSize bytes tal como estão.
  • Senão: os próximos CompressedSize bytes formam um único chunk SQSH (com o mesmo cabeçalho de 19 bytes do v1) — leia-o, confira o checksum contra a soma dos bytes dos dados, decodifique com o CompMethod do chunk (1 = LZ77, 2 = ZLib) e pronto, você tem o arquivo.

O esquema de vários chunks do v1 (tabela de tamanhos, chunks de ≤64 KiB, passada de criptografia) desapareceu. O v2 troca a compacidade por um leitor muito mais simples.

O tratamento no kbot

kbot hpi list, extract e info aceitam os dois dialetos de forma transparente — eles escolhem pelo campo Version do cabeçalho. A gravação de ida e volta (kbot hpi pack) hoje só gera v1; ainda não criamos arquivos compactados v2.

O leitor interno do v2 está em formats/hpi/hpi_v2.go. hpi.Reader.Version() informa se o arquivo 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 passo a passo original de Joe D. usava o add-on da unidade Flakker do ARM; reproduzimos aqui o cabeçalho que ele dissecou. Dump hexadecimal dos 32 primeiros 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:

Offset 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

Descriptografar [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

Essa estrutura de nove arquivos é a forma canônica de um add-on .ufo de uma só unidade; os arquivos compactados base totala*.hpi usam o mesmo esquema em grande escala (~30 mil arquivos).

Tip

Reproduza esta dissecação na sua própria 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 novo arquivo compactado a partir de uma árvore de pastas:

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

Por padrão, o kbot usa compressão LZ77 em chunks, com a chave de cabeçalho padrão 0xBF e o trailer da Cavedog — o que produz um binário que o motor da versão comercial aceita. As opções mantêm isso:

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

Nomes de pastas que só diferem em maiúsculas/minúsculas (Units/ e units/ num disco que diferencia maiúsculas) são mesclados do jeito que o jogo compara os nomes; dois arquivos cujos caminhos só diferem em maiúsculas/minúsculas são ambos gravados, o jogo lê o que foi empacotado por último, e pack imprime um aviso.


Armadilhas

Warning

Os ponteiros são absolutos em relação ao início do arquivo, não ao início do diretório. Não subtraia nada. O truque padrão de implementação é alocar um buffer de DirectorySize bytes e ler nele o diretório descriptografado no offset Start, para que os ponteiros indexem o buffer diretamente.

  • Fórmula da quantidade de chunks — ceil(FileSize / 65536). Não existe um campo explícito de contagem; é preciso calculá-la a partir de FileSize.

Tamanhos típicos

Arquivo compactado Tamanho Arquivos Observações
totala1.hpi (sons, animações) ~30 MB ~1.900 Jogo base, o maior arquivo compactado individual.
totala2.hpi (unidades) ~13 MB ~600 A maior parte dos metadados + scripts das unidades.
totala4.hpi (mapas) ~50 MB ~300 Todos os mapas da versão comercial.
worlds.hpi (seções) ~20 MB ~5.000 Bibliotecas SCT do editor.
ccdata.ccx (Core Contingency) ~25 MB ~1.500 Unidades/mapas da expansão.
.ufo típico de uma só unidade 10–150 KB 8–12 A maioria dos add-ons de unidades de terceiros.
.ufo só de mapa (com .tnt + .ota) 0,5–5 MB 2–4 Distribuição por mapa.
Taxa de compressão 35–55% — LZ77 na maioria dos arquivos; ZLib nos maiores.
  • Alguns chunks crescem ao serem compactados. Um chunk "compactado" pode passar de 64 KiB se o LZ77 não encontrar correspondências. Trate CompressedSize como o comprimento confiável a copiar.
  • O checksum é calculado sobre os bytes criptografados. Se você descriptografar primeiro, o checksum vai falhar.
  • Os jogos salvos (marcador BANK) são outra história — eles reaproveitam o invólucro HPI, mas o conteúdo codificado é o estado do jogo serializado, não um sistema de arquivos. O kbot não os decodifica.
  • O .gp3 exige o patch rev3.1 para carregar. Por dentro continua sendo um HPI, mas o motor se recusa a montá-lo em instalações sem o patch.

Apêndice — percurso hexadecimal completo de AFark.ufo

AFark.ufo é o add-on do Fark ARM — um arquivo compactado pequeno, de 9 arquivos, ideal para uma demonstração de ponta a ponta. Vamos percorrer o cabeçalho, descriptografar o diretório, dissecar algumas entradas e depois espiar os bytes iniciais de um chunk compactado.

1. Cabeçalho (sem criptografia)

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

Decodificado:

Offset 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

Obtenha a chave de criptografia:

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

2. Diretório descriptografado (offsets 0x14..0x224)

Depois de aplicar plain[i] = cipher[i] ^ (uint8(0x14 + i) ^ 0x9A) ao blob do diretório — e inseri-lo num buffer de 548 bytes no offset 0x14 para que os ponteiros indexem corretamente —, os 96 primeiros bytes ficam assim:

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 — a contagem de entradas da raiz: 8 subdiretórios no nível mais alto. Em 0x18, 0x0000001C — o offset da primeira entrada de diretório.

3. A primeira entrada

Os 9 bytes em 0x1C são decodificados assim:

Campo Bytes Valor
NameOffset 64 00 00 00 0x64 → string "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 string "anims\0" — o 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

Ou seja, o diretório anims/ contém 1 arquivo, descrito a partir de 0x72:

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

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 arquivo 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 = 18.504 bytes
CompMethod 01 1 ⇒ LZ77

Isso bate com kbot hpi list -v:

anims/armfark_gadget.gaf    18504

4. Ler a árvore completa

Seguir pelas 7 entradas restantes do nível mais alto produz:

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

5. Um chunk compactado

O primeiro byte depois do diretório de 548 bytes está em 0x224. Descriptografar essa região (o cabeçalho do chunk também é criptografado pela cifra do diretório) revela:

00000220                          .. 53 51 53 48 02 01 01 cc      .SQSH....
Offset (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 passada de XOR
+7.. … CompressedSize, DecompressedSize, Checksum e depois data[] …

Depois de desembaralhar o corpo do chunk com data[i] = (data[i] - i) ^ i e passar os bytes resultantes pelo decodificador LZ77 descrito acima, você obtém o GAF descompactado — pronto para entregar a um analisador de GAF.

Tip

Reproduza este percurso na sua própria 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 do TA: Kingdoms ao percurso do AFark. Jersey.hpi é um HPI de 67 KB, um pacote de vozes que vem na instalação do TA:K da GOG — seis WAVs de efeitos sonoros e mais nada. É pequeno o bastante para ser percorrido de ponta a ponta e grande o bastante para exercitar todos os mecanismos do v2 (cabeçalho de 32 bytes, pool de nomes, blob do diretório envolvido em SQSH, dados de arquivo em chunk único). O percurso do v1 acima cobre tudo o que continua igual; esta seção destaca só as diferenças do 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   @...[... ...?...
Offset 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 — offset do blob do diretório
0x0C ad 00 00 00 DirectorySize 0xAD = 173 bytes
0x10 40 06 01 00 NameBlock 0x00010640 — offset do pool de nomes
0x14 5b 00 00 00 NameSize 0x5B = 91 bytes
0x18 20 00 00 00 Data 0x20 — os dados dos arquivos começam aqui, logo depois do cabeçalho
0x1C 3f 07 01 00 Last78 0x0001073F — marcador de fim do arquivo compactado (0x1073F = 67.391 = tamanho do arquivo − 78 bytes de rodapé)

Sem cifra XOR, sem transformações de descriptografia, sem semente por posição. Os valores do cabeçalho apontam para offsets absolutos no arquivo, para os quais você pode dar seek diretamente.

2. O pool de nomes (texto puro, perto do fim)

Algumas centenas de bytes antes de DirectoryBlock (o motor grava o pool de nomes antes do diretório) fica a tabela de strings:

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 arquivo guarda um NamePtr, um offset em bytes dentro deste bloco; seguir o ponteiro e ler até o próximo \0 dá o nome. Percorrendo byte a byte:

Offset (relativo a NameBlock) String 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 pool de nomes inclui alguns bytes finais não usados. NameSize=0x5B vai até o offset 0x1069B, mas o último byte com significado (o NUL depois de Igotcher.wav) está em 0x10691. Os 9 bytes finais invadem o início do chunk SQSH do diretório e nunca são indexados por nenhum NamePtr — os leitores os toleram simplesmente nunca olhando 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 chunk SQSH — o v2 reaproveita o formato de chunk do v1:

00010692: 53 51 53 48 02 02 01 9a 00 00 00 b8 00 00 00 9b   SQSH............
000106a2: 4f 00 00 78 9e 63 …                              O..x.c…
Offset 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 chunk tem a segunda passada de XOR aplicada (raro no TAK; este arquivo compactado tem)
+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 embaralhado com XOR

19 + 154 = 173 = 0xAD = DirectorySize, então o chunk inteiro cabe no que o cabeçalho declara. Depois de desfazer a passada de XOR (o mesmo algoritmo do v1) e passar por inflate, você obtém 184 bytes de registros de diretório compactados.

4. Decodificar os registros do diretório

O blob descompactado contém um array compacto de dois tipos de registro: entradas de diretório (HpiDir2) de 20 bytes e entradas de arquivo (HpiEntry2) de 24 bytes. Não há prefixo de contagem nem tabela de offsets — o diretório raiz fica sempre no offset 0 do blob, e os campos FirstSubDirectory / FirstFile dele apontam para os filhos.

O HpiDir2 da raiz (no offset descompactado 0, 20 bytes):

Offset Bytes (descompactados) Campo Valor
+0 00 00 00 00 NamePtr 0 → "" (raiz)
+4 14 00 00 00 FirstSubDirectory 0x14 — um único registro de subdiretório de 20 bytes neste offset
+8 01 00 00 00 SubCount 1 (o diretório sounds/)
+12 00 00 00 00 FirstFile 0 — nenhum arquivo na raiz
+16 00 00 00 00 FileCount 0

Seguir o ponteiro até 0x14 leva ao HpiDir2 de sounds:

  • NamePtr = 0x01 → "sounds"
  • FirstSubDirectory = 0, SubCount = 0 — nenhum subdiretório aninhado
  • FirstFile = 0x28 — o primeiro de seis registros HpiEntry2 (24 bytes cada)
  • FileCount = 6

Cada registro de arquivo em 0x28, 0x40, 0x58, … traz:

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

Decodificadas, as seis entradas correspondem exatamente ao que kbot hpi list informa:

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 ao v1)

Se o percurso do v1 ainda está fresco na memória, as peças que faltam são justamente o objetivo da simplificação do v2:

  • Nenhum cálculo de chave XOR — DecryptKey é sempre 0 no v2. Mesmo quando um chunk SQSH tem Encrypted == 1, a segunda passada de XOR dentro do chunk continua sendo aplicada, mas o invólucro do diretório/dos dados é texto puro.
  • Nenhuma semente por posição — a cifra byte a byte que complicava os leitores do v1 (a descriptografia depende do offset absoluto no arquivo) não existe no nível do invólucro do v2.
  • Nenhuma tabela de tamanhos de chunks — os arquivos do v1 eram divididos em blocos de ≤64 KiB, com um array inicial de tamanhos em uint32. O v2 guarda cada arquivo como um único chunk SQSH (ou bytes armazenados), então não há nada para pôr numa tabela de tamanhos.
  • Nenhuma convenção de diretório logo depois do cabeçalho em 0x14 — os blocos de diretório e de nomes ficam onde o gravador os deixou (normalmente perto do fim do arquivo). O cabeçalho aponta para eles.

Tip

Reproduza este percurso na sua própria 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

Veja também

  • GAF, PCX, 3DO, TNT, COB, TDF — os tipos de arquivo que os arquivos compactados HPI contêm.
  • Glossário — definições de sistema de arquivos virtual, sobreposição em camadas e outros termos ligados ao HPI.