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 .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.ufoComplementos de unidades/elementos ( AFark.ufo)Depois de todos os .ccx; sem limite de quantidade.hpiArquivos 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:
rev31.gp3;- todos os
*.ccx;- todos os
*.ufo;- os primeiros dez
*.hpique abrirem;- os ficheiros
*.hpido 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.ufoantes dezeta.ufo). Quando resolve um caminho comounits/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
*.hpisão ocupadas portactics1–8.hpi,totala1.hpietotala2.hpi.totala3.hpi,totala4.hpi(os mapas comerciais) eworlds.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*.hpique 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 tnte o servidor MCP. A página Home e o separador Layering do separador Files, os comandosarchivesestatsdokbot mounte a ferramenta MCPvfs_game_datalistam 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 doIPData.hpiaodata.hpinesse jogo.A regra "os ficheiros soltos ganham" é a razão pela qual o
kbot mount flattenproduz 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/*.tdfeunits/*.fbi(só o nível de topo — uma subpastaweapons/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. OCarScar05da versão comercial está definido emfeatures/urban/cars2.tdfe de novo emcars.tdf, ecars2.tdfestá guardado primeiro, por isso o jogo desenha-o a partir deanims/cars2.gaf. O studio (paleta de elementos, sandbox, pacotes, listas de unidades e de armas), okbot tnt previewe okbot 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:
- Leia o cabeçalho de 20 bytes. Verifique o número mágico
"HAPI". - Derive a chave XOR do arquivo a partir de
DecryptKey. - Decifre e leia a árvore de diretórios.
- 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 comolen − 2(por isso as correspondências têm 2–17 bytes). - Terminador — uma correspondência com
windowOffset == 0termina 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: leiaDecompressedSizebytes tal e qual. - Caso contrário, os
CompressedSizebytes 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 oCompMethoddo 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 deFileSize.
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
CompressedSizecomo 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
.gp3precisa 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 aninhadosFirstFile = 0x28— o primeiro de seis registosHpiEntry2(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é sempre0na v2. Mesmo quando um bloco SQSH temEncrypted == 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
0x14de 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