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 .gp3A camada do patch 3.1 ( rev31.gp3)Montado primeiro; os outros *.gp3são ignorados.ccxDados do Core Contingency / Battle Tactics ( btdata.ccx,ccdata.ccx)Depois de rev31.gp3.ufoAdd-ons de unidades/elementos ( AFark.ufo)Depois de todos os .ccx; sem limite de quantidade.hpiArquivos 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:
rev31.gp3;- todos os
*.ccx;- todos os
*.ufo;- os dez primeiros
*.hpique abrirem;- os arquivos
*.hpino 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.ufoantes dezeta.ufo). Quando resolve um caminho comounits/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
*.hpisão ocupadas portactics1–8.hpi,totala1.hpietotala2.hpi.totala3.hpi,totala4.hpi(os mapas da versão comercial) eworlds.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*.hpique 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 tnte o servidor MCP. A página Home e a aba Layering da aba Files, os comandosarchivesestatsdekbot mounte a ferramenta MCPvfs_game_datalistam 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 deIPData.hpisobredata.hpidesse jogo exige.A regra de que "os arquivos soltos vencem" é o motivo pelo qual
kbot mount flattenproduz 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/*.tdfeunits/*.fbi(só o nível mais alto — uma subpastaweapons/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. OCarScar05da versão comercial é definido emfeatures/urban/cars2.tdfe de novo emcars.tdf, ecars2.tdfestá gravado primeiro, então o jogo o desenha a partir deanims/cars2.gaf. O studio (paleta de elementos, sandbox, pacotes, listas de unidades e de armas),kbot tnt previewekbot tnt linte 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 é:
- Leia o cabeçalho de 20 bytes. Confira o número mágico
"HAPI". - Obtenha a chave XOR do arquivo a partir de
DecryptKey. - Descriptografe e leia a árvore de diretórios.
- 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 comolen − 2(então as correspondências têm de 2 a 17 bytes). - Terminador — uma correspondência com
windowOffset == 0encerra 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: leiaDecompressedSizebytes tal como estão. - Senão: os próximos
CompressedSizebytes 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 oCompMethoddo 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 deFileSize.
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
CompressedSizecomo 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
.gp3exige 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 aninhadoFirstFile = 0x28— o primeiro de seis registrosHpiEntry2(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é sempre0no v2. Mesmo quando um chunk SQSH temEncrypted == 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