모든 형식 페이지 ↓
컨테이너·래퍼 형식

HPI / UFO / CCX / GP3 — HAPI 아카이브 파일

출처: coreprime/kbot @ 0ab97bf · GitHub에서 편집

HAPI("HPI")는 Cavedog이 모든 게임 에셋을 배포하는 데 쓴 암호화·압축 컨테이너입니다. 같은 디스크상 구조가 확장자만 달리하여 재사용됩니다.

확장자 용도 TA 3.1c 마운트 순서
.gp3 3.1 패치 오버레이(rev31.gp3) 가장 먼저 마운트. 다른 *.gp3는 무시됨
.ccx Core Contingency / Battle Tactics 데이터(btdata.ccx, ccdata.ccx) rev31.gp3 다음
.ufo 유닛/지형지물 애드온(AFark.ufo) 모든 .ccx 다음. 개수 제한 없음
.hpi 기본 게임 아카이브(totala1.hpi, tactics1.hpi) 마지막. 열리는 것 중 처음 10개만

네 가지 모두 바이트 단위로 같은 형식이며, 마운트 순서만 다릅니다. 게임은 부팅할 때 이들을 하나의 가상 파일 시스템으로 합치며, 어떤 경로를 가진 아카이브 중 가장 먼저 마운트된 것이 이깁니다.

Important

디스크상 방언이 두 가지입니다. Total Annihilation은 HPI v1 (Version == 0x00010000)을 씁니다. 이 페이지가 처음부터 끝까지 다루는, 암호화된 다중 청크 구조입니다. Total Annihilation: Kingdoms는 HPI v2(Version == 0x00020000)를 씁니다 — XOR 암호가 없고 파일이나 디렉터리 블록마다 SQSH 청크가 하나뿐인, 다르고 더 단순한 구조입니다. v2의 세부 사항은 이 페이지 아래쪽의 별도 절에 있습니다: TA: Kingdoms HPI v2. TA 3.1c는 v1 아카이브만 마운트합니다.

로드 순서에 따른 계층화

TA 3.1c는 게임 디렉터리의 최상위만 훑으며(Backup/ 같은 하위 폴더의 아카이브는 절대 마운트되지 않음), 다음 순서로 마운트합니다.

  1. rev31.gp3;
  2. 모든 *.ccx;
  3. 모든 *.ufo;
  4. 열리는 *.hpi 중 처음 10개;
  5. 게임 CD 최상위의 *.hpi 파일, 개수 제한 없음.

각 그룹 안에서 게임은 ASCII 대문자 순서로 이름을 받아들입니다(디렉터리 목록이 반환하는 순서: _는 알파벳 뒤에 정렬되고, AFark.ufo가 zeta.ufo보다 앞). units/ARMCOM.fbi 같은 경로를 찾을 때는 설치본의 낱개 파일(loose file)이 이기고, 그다음은 해당 경로를 가진 아카이브 중 가장 먼저 마운트된 것입니다. 아카이브가 마운트된 것으로 인정되려면 버전 1 아카이브이면서 Cavedog 저작권 트레일러로 끝나고(트레일러 참고) 디렉터리를 읽을 수 있어야 합니다. 그 밖의 파일은 건너뛰며, *.hpi 슬롯 10개 중 하나를 차지하지도 않습니다.

   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"

GOG 판에서는 tactics1–8.hpi, totala1.hpi, totala2.hpi가 *.hpi 슬롯 10개를 차지합니다. totala3.hpi, totala4.hpi(정식 맵)와 worlds.hpi(맵 섹션)는 디스크 아카이브입니다. GOG 설치본은 이들을 디스크를 대신하는 게임 디렉터리에 두므로, 게임은 이들을 다른 모든 아카이브 다음에 마운트합니다. 모드가 추가하는 *.hpi도 마찬가지입니다. 이름이 열 번째 슬롯 뒤로 정렬되는 파일은 디스크 검색을 통해서만, 다른 모든 것보다 아래에서 닿습니다. 이를 피하려면 모드를 .ufo로 배포하세요(개수 제한 없음, 모든 .hpi보다 위).

kbot은 Total Annihilation 컨텍스트를 어디에서나 같은 방식으로 마운트합니다: 스튜디오, kbot mount, kbot mount flatten, kbot pack, kbot tnt, MCP 서버. Files 탭의 Home 페이지와 Layering 탭, kbot mount의 archives와 stats 명령, MCP vfs_game_data 도구는 마운트 순서와, 마운트되지 않은 모든 아카이브를 그 이유와 함께 보여 줍니다. TA: Kingdoms 컨텍스트는 kbot의 오버레이 순서(모든 아카이브를 확장자와 이름 순으로, 나중 것이 앞의 것을 덮어씀)를 유지하는데, 이 게임의 data.hpi 위에 IPData.hpi를 겹치는 계층화에 필요하기 때문입니다.

"낱개 파일이 이긴다"는 규칙 덕분에 kbot mount flatten이 만든 디렉터리는 계층화된 아카이브 집합과 똑같이 동작합니다. 엔진이 아카이브 스택을 통해 찾았을 모든 파일이 같은 경로에 낱개 파일로 있으므로, 탐색이 스택 맨 위에서 바로 끝납니다.

어느 정의가 이기는가

지형지물, 무기, 유닛은 디렉터리 목록을 읽어 로드합니다: features/**/*.tdf(하위 폴더 포함), weapons/*.tdf, units/*.fbi(최상위만 — weapons/old/ 하위 폴더는 절대 읽지 않음). 목록은 낱개 파일을 먼저, 그다음 마운트된 아카이브를 마운트 순서대로 훑으며 각 아카이브의 항목은 저장된 순서로 읽고, 같은 이름의 정의는 처음 것이 이기며 나중 것은 무시됩니다. 정식판의 CarScar05는 features/urban/cars2.tdf에 정의되고 cars.tdf에도 다시 정의되는데, cars2.tdf가 먼저 저장되어 있으므로 게임은 이를 anims/cars2.gaf에서 그립니다. 스튜디오(지형지물 팔레트, 샌드박스, 팩, 유닛·무기 목록), kbot tnt preview와 kbot tnt lint, MCP 도구도 같은 규칙을 적용합니다.


한눈에 보기

   ┌─── 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"

HPI를 읽는 흐름은 다음과 같습니다.

  1. 20바이트 헤더를 읽습니다. "HAPI" 매직을 확인합니다.
  2. DecryptKey에서 파일별 XOR 키를 유도합니다.
  3. 디렉터리 트리를 복호화하면서 읽습니다.
  4. 원하는 파일마다 압축된 청크로 이동해 복호화한 뒤, CompMethod(LZ77, ZLib, 없음)에 따라 처리합니다.

Tip

파서를 짜지 않고도 어떤 HPI든 살펴볼 수 있습니다.

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

모든 플래그는 CLI kbot hpi 레퍼런스를 참고하세요.

Go에서 쓰기. 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

헤더(20바이트, 평문)

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;
필드 비고
Marker 리틀 엔디언 'HAPI'. 'BANK' 변형은 저장 게임에 쓰이며 여기서는 다루지 않습니다.
Version 정식판 Total Annihilation 아카이브는 0x00010000(v1, 이 절)을 씁니다. TA: Kingdoms 아카이브는 0x00020000(v2)을 씁니다. TA: Kingdoms HPI v2 참고.
DirectorySize 헤더 20바이트를 포함하므로, 실제 디렉터리 블롭은 Start부터 DirectorySize − Start바이트를 차지합니다.
DecryptKey 아래 변환을 거쳐 XOR 키로 쓸 바이트를 얻습니다. 키 바이트 0과 0xFF는 TA 3.1c에서 모두 "암호화되지 않음"을 뜻합니다. Cavedog의 정식판 아카이브는 0xBF를 씁니다.
Start 디렉터리가 시작하는 위치. 관찰된 파일에서는 항상 0x14였습니다.

암호 키 유도

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

그다음 암호화된 각 바이트를 파일 안의 절대 위치를 이용해 풀어냅니다.

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

여기서 pos는 파일 안에서 cipher[0]의 절대 오프셋입니다. 그렇습니다 — 시드가 파일 위치에 따라 바뀝니다. 그래서 버퍼만 따로 떼어 복호화할 수는 없고, 그것이 파일의 어디에서 왔는지 알아야 합니다.

Important

키 바이트가 0이나 0xFF라면 암호를 전혀 적용하지 마세요. TA 3.1c는 두 경우 모두 평문으로 읽습니다(0xFF라면 위의 회전 결과도 0xFF가 되겠지만, 게임은 복호화하지 않습니다). 일부 커스텀 아카이브(와 몇몇 커뮤니티 모드)는 암호화 없이 쓰여 있는데, 이 경우 XOR 암호를 적용하면 이미 평문인 데이터를 뒤섞어 버립니다. 이 때문에 kbot hpi pack은 --key 255를 거부합니다. --key 0을 쓰세요.


디렉터리 트리

디렉터리는 항목 개수와 첫 항목을 가리키는 포인터로 이루어집니다.

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

각 항목은 정확히 9바이트입니다.

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

파일의 경우 DataOffset이 가리키는 것은 다음과 같습니다.

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;

모든 오프셋은 파일 안의 절대 오프셋입니다. 그래서 표준 파서는 DirectorySize바이트 버퍼를 할당하고 디렉터리를 Start 위치에 써 넣습니다 — 그러면 계산 없이 포인터로 버퍼를 바로 인덱싱할 수 있습니다.

디렉터리 읽기

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

파일 이름의 대소문자는 쓸 때 보존되지만 읽을 때는 대소문자를 구분하지 않고 비교합니다. Total Annihilation은 Windows 9x 시절에 태어났기 때문에, 모드에서는 같은 아카이브 안에서도 armcom.fbi와 ARMCOM.FBI가 흔히 섞여 있습니다. 게임은 ASCII 글자의 대소문자만 무시하고, 경로를 \와 / 모두로 나누며, 한 디렉터리에 대소문자만 다른 이름의 항목이 여러 개 있으면 그중 마지막 것을 읽습니다. 나머지에는 접근할 수 없습니다. 또한 .과 ..을 평범한 이름으로 취급합니다. 따라서 kbot hpi extract는 그런 그룹에서 접근 가능한 항목만 쓰고, ../../x.txt처럼 저장된 그대로는 대상 폴더 안에 쓸 수 없는 경로의 항목은 (메시지를 출력하며) 건너뜁니다. kbot mount flatten도 대상 폴더 밖에는 절대 쓰지 않습니다.


압축 청크(SQSH)

파일의 데이터는 압축 해제 기준 최대 64 KiB의 청크로 나뉩니다. 각 청크에는 19바이트짜리 자체 헤더가 있습니다.

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;

청크 개수는 ceil(FileSize / 65536)입니다. 파일의 첫 HPIFile.DataOffset 바로 뒤에는 청크마다 하나씩 압축 크기를 담은 uint32 배열이 있고, 그 뒤로 청크들이 연달아 이어집니다.

두 번째 XOR 계층

Encrypted == 1이면 각 청크 본문에 두 번째 XOR 처리가 적용되어 있습니다.

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

체크섬은 아직 암호화된 상태의 바이트에 대해 계산된다는 점에 주의하세요 — 암호를 풀기 전에 검증해야 합니다.

LZ77(CompMethod = 1)

4 KiB 히스토리를 쓰는 교과서적인 슬라이딩 윈도 LZ77입니다.

  • 태그 바이트 — 플래그 비트 8개, LSB부터. 0 ⇒ 리터럴, 1 ⇒ 역참조.
  • 리터럴 — 1바이트를 그대로 출력과 히스토리에 복사합니다.
  • 매치 — uint16을 읽습니다. 상위 12비트는 윈도 오프셋(1–4095)이고, 하위 4비트는 길이를 len − 2로 인코딩합니다(따라서 매치는 2–17바이트).
  • 종결자 — windowOffset == 0인 매치가 스트림을 끝냅니다.

ZLib(CompMethod = 2)

표준 zlib 원시 스트림입니다. 암호화 계층을 제거한 뒤 청크 본문을 inflate()에 넣으면 됩니다. DecompressedSize가 기대되는 길이입니다.

무압축 저장(CompMethod = 0)

바이트가 그대로 쓰여 있습니다 — 청크 분할도, 체크섬도, 두 번째 XOR 처리도 없습니다. 압축이 잘 되지 않는 파일(이미 압축된 PCX/GAF)에 유용하며, 옛 "unit viewer"가 이해하는 유일한 모드입니다.


트레일러

게임이 마운트하는 모든 아카이브는 36바이트 ASCII 저작권 문구로 끝납니다.

Copyright 1997 Cavedog Entertainment

헤더의 어떤 필드도 이를 가리키지 않지만, 생략할 수 없습니다. TA 3.1c는 아카이브를 마운트하기 전에 마지막 36바이트가 Copyright ____ Cavedog Entertainment인지 확인합니다(연도 네 글자는 비교하지 않음). 트레일러가 없거나 그 뒤에 바이트가 덧붙은 아카이브는 건너뛰며, *.hpi 슬롯 10개 중 하나를 차지하지도 않습니다 — 트레일러 없이 패키징한 모드는 게임에서 아무 말 없이 사라집니다.

kbot이 쓰는 모든 아카이브는 트레일러를 유지합니다: kbot hpi pack과 스튜디오의 내보내기(워크스페이스의 Export mod와 맵 에디터의 아카이브 다운로드). kbot hpi info, kbot hpi list -v, MCP hpi_info·hpi_list 도구, Files 탭의 아카이브 메타데이터는 TA 3.1c would mount: yes / no, <reason> 줄을 출력합니다.


TA: Kingdoms — HPI v2

Note

이 절은 TA:K 전용 차이점입니다. 위의 내용은 모두 Total Annihilation (Version == 0x00010000)에 적용됩니다. TA: Kingdoms는 같은 .hpi 확장자를 쓰지만 디스크상 구조가 다릅니다 — v1 파서를 꺼내 들기 전에 이 절을 끝까지 읽으세요.

TA: Kingdoms는 'HAPI' 매직과 디렉터리 트리 개념은 유지하지만, 그 밖의 컨테이너 구조는 전면 개편했습니다. 차이를 한 번에 정리하면:

항목 v1(TA) v2(TA: Kingdoms)
버전 필드 0x00010000 0x00020000
헤더 크기 20바이트(5 × uint32) 32바이트(매직+버전 8바이트 + 24바이트 서브헤더)
암호화 DecryptKey를 시드로 하고 파일 위치에 따라 바뀌는 XOR 암호 없음 — 모두 평문
디렉터리 + 이름 풀 위치 헤더 바로 뒤(암호화됨) 임의의 오프셋(보통 파일 끝부분 근처). 각각 SQSH 청크 하나로 감쌀 수 있음
파일별 청크 분할 길이 테이블과 함께 64 KiB 이하 청크로 나뉨. 청크마다 자체 SQSH 헤더 + 체크섬 + 선택적인 두 번째 XOR 처리 페이로드 전체를 담은 SQSH 청크 하나, 또는 CompressedSize == 0이면 무압축 저장 바이트. XOR 처리도 길이 테이블도 없음
청크별 암호(SQSH의 Encrypted 바이트) 사용됨 사용되지 않음(바이트가 설정되어 있으면 여전히 따르지만, 우리가 표본 조사한 TAK 파일은 모두 0)

v2 헤더

8바이트 프롤로그('HAPI' + 0x00020000) 뒤에 32비트 리틀 엔디언 필드 여섯 개가 옵니다 — 디스크상 헤더 전체는 32바이트입니다.

// 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;

디렉터리 블롭과 이름 블롭은 둘 다 SQSH로 압축되어 있을 수 있습니다 — DirectoryBlock / NameBlock 위치의 처음 4바이트가 'SQSH'라면 그 블록을 청크 하나(헤더 + 페이로드)로 보고 압축을 풉니다. 아니라면 주어진 오프셋의 Size 바이트가 압축 해제된 원본 내용입니다.

v2 디렉터리 항목

압축을 푼 디렉터리 블롭은 두 가지 레코드 형식이 빽빽하게 이어진 배열입니다(별도의 오프셋 테이블도, EntryCount 접두도 없습니다 — 자식은 부모의 FirstSubDirectory / FirstFile 포인터로 찾습니다).

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;

파일별 페이로드

오프셋 Start에 있는 파일 항목의 경우:

  • CompressedSize == 0이면: DecompressedSize바이트를 그대로 읽습니다.
  • 그렇지 않으면: 이어지는 CompressedSize바이트가 SQSH 청크 하나(v1과 같은 19바이트 헤더)를 이룹니다 — 이를 읽고, 페이로드의 바이트 합으로 체크섬을 검증하고, 청크의 CompMethod(1 = LZ77, 2 = ZLib)로 디코딩하면 파일이 나옵니다.

v1의 다중 청크 방식(크기 테이블, 64 KiB 이하 청크, 암호화 처리)은 사라졌습니다. v2는 조밀함을 내주고 훨씬 단순한 리더를 얻었습니다.

kbot의 처리

kbot hpi list, extract, info는 모두 두 방언을 투명하게 지원합니다 — 헤더의 Version 워드에 따라 분기합니다. 왕복 쓰기(kbot hpi pack)는 현재 v1만 출력하며, v2 아카이브는 아직 만들지 않습니다.

내부 v2 리더는 formats/hpi/hpi_v2.go에 있습니다. hpi.Reader.Version()은 로드한 파일이 v1인지 v2인지 알려 줍니다.

Tip

TAK 설치본이 로드되는지 확인하세요.

kbot hpi list ~/games/takingdoms/data.hpi -v | head
kbot ctx add ~/games/takingdoms --alias tak --game takingdoms
kbot mount  # walks every TAK .hpi as a single VFS

실제 예제 — aflakker.ufo

Joe D.의 원래 해설은 Arm Flakker 유닛 애드온을 사용했습니다. 여기서는 그가 분석한 헤더를 재현해 봅니다. 처음 32바이트의 헥스 덤프:

00000000  48 41 50 49 00 00 01 00 24 02 00 00 7D 00 00 00   HAPI....$...}...
00000010  14 00 00 00 ...                                   ...

필드별로 보면:

오프셋 바이트 필드 값
0x00 48 41 50 49 Marker 'HAPI' ✓
0x04 00 00 01 00 Version 0x00010000 ✓
0x08 24 02 00 00 DirectorySize 0x224 = 548바이트
0x0C 7D 00 00 00 DecryptKey 0x7D → key = (0x7D << 2) | (0x7D >> 6) = 0xF5
0x10 14 00 00 00 Start 0x14

[0x14, 0x224)를 복호화하고 트리를 따라가면 다음이 나옵니다.

.
├── 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

이 9개 파일 구성이 유닛 하나짜리 .ufo 애드온의 전형적인 형태입니다. 기본 totala*.hpi 아카이브도 같은 방식을 대규모로(약 3만 개 파일) 씁니다.

Tip

이 분석을 자신의 컴퓨터에서 재현해 보세요.

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

다시 패키징하기

디렉터리 트리에서 새 아카이브를 만들려면:

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

kbot은 기본적으로 표준 0xBF 헤더 키와 Cavedog 트레일러를 붙인 LZ77 청크 압축을 씁니다 — 정식판 엔진이 받아들이는 바이너리가 만들어집니다. 플래그도 그 상태를 유지합니다.

플래그 효과
--method lz77|zlib|none 청크 압축 방식. none은 무압축 저장 항목을 씁니다.
--key N 헤더 키 바이트. 0이면 암호화되지 않은 아카이브를 씁니다. 255는 거부됩니다: 게임이 이를 "암호화되지 않음"으로 읽기 때문입니다.
--trailer "Copyright 1998 Cavedog Entertainment" 트레일러에 다른 연도를 씁니다. 그 밖의 문구는 거부됩니다.
--no-trailer 트레일러를 생략합니다 — 게임은 결과물을 마운트하지 않습니다(경고가 출력됨).
--format v2 TA: Kingdoms 아카이브를 씁니다(--key, --trailer, --no-trailer, --encode-chunks는 적용되지 않음).

대소문자만 다른 디렉터리 이름(대소문자를 구분하는 디스크의 Units/와 units/)은 게임이 이름을 비교하는 방식대로 합쳐집니다. 대소문자만 다른 경로의 두 파일은 둘 다 저장되지만, 게임은 마지막에 패키징된 것을 읽으며 pack은 경고를 출력합니다.


주의할 점

Warning

포인터는 디렉터리 시작이 아니라 파일 시작 기준의 절대 위치입니다. 아무것도 빼지 마세요. 표준 구현 요령은 DirectorySize바이트 버퍼를 할당하고, 복호화한 디렉터리를 오프셋 Start에 읽어 넣어 포인터로 버퍼를 바로 인덱싱하는 것입니다.

  • 청크 개수 공식 — ceil(FileSize / 65536). 명시적인 개수 필드는 없으므로 FileSize에서 유도해야 합니다.

일반적인 크기

아카이브 크기 파일 수 비고
totala1.hpi(사운드, 애니메이션) 약 30 MB 약 1900 기본 게임, 단일 아카이브로는 가장 큼.
totala2.hpi(유닛) 약 13 MB 약 600 유닛 메타데이터 + 스크립트 대부분.
totala4.hpi(맵) 약 50 MB 약 300 정식 맵 전부.
worlds.hpi(섹션) 약 20 MB 약 5000 에디터용 SCT 라이브러리.
ccdata.ccx(Core Contingency) 약 25 MB 약 1500 확장팩 유닛/맵.
일반적인 유닛 하나짜리 .ufo 10–150 KB 8–12 서드파티 유닛 애드온 대부분.
맵만 담은 .ufo(.tnt + .ota) 0.5–5 MB 2–4 맵별 배포.
압축률 35–55% — 대부분의 파일은 LZ77, 가장 큰 파일은 ZLib.
  • 어떤 청크는 압축하면 오히려 커집니다. LZ77이 매치를 하나도 찾지 못하면 "압축된" 청크가 64 KiB보다 클 수 있습니다. 복사할 길이는 CompressedSize를 기준으로 삼으세요.
  • 체크섬은 암호화된 바이트에 대해 계산됩니다. 먼저 복호화하면 체크섬 검증에 실패합니다.
  • 저장 게임(BANK 마커)은 전혀 다른 물건입니다 — HPI 컨테이너 형식을 재사용하지만 인코딩된 내용은 파일 시스템이 아니라 직렬화된 게임 상태입니다. kbot은 이를 디코딩하지 않습니다.
  • .gp3를 로드하려면 rev3.1 패치가 필요합니다. 내부는 여전히 HPI지만, 패치하지 않은 설치본에서는 엔진이 마운트를 거부합니다.

부록 — AFark.ufo 전체 헥스 분석

AFark.ufo는 Arm Fark 애드온입니다 — 9개 파일로 된 작은 아카이브라 처음부터 끝까지 시연하기에 이상적입니다. 헤더를 살펴보고, 디렉터리를 복호화하고, 항목 두어 개를 분석한 다음, 압축 청크의 첫 바이트들을 들여다봅니다.

1. 헤더(평문)

원시 바이트 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                                      ....

디코딩 결과:

오프셋 바이트 필드 값
0x00 48 41 50 49 Marker 'HAPI' ✓
0x04 00 00 01 00 Version 0x00010000
0x08 24 02 00 00 DirectorySize 0x224 = 548바이트
0x0C a6 00 00 00 DecryptKey 바이트 0xA6
0x10 14 00 00 00 Start 0x14

암호 키 유도:

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

2. 복호화한 디렉터리(오프셋 0x14..0x224)

디렉터리 블롭에 plain[i] = cipher[i] ^ (uint8(0x14 + i) ^ 0x9A)를 적용하고 — 포인터가 제대로 인덱싱되도록 548바이트 버퍼의 오프셋 0x14에 넣은 뒤 — 처음 96바이트를 읽으면:

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"

0x14에는 0x00000008 — 루트 항목 개수, 즉 최상위 하위 디렉터리 8개가 있습니다. 0x18에는 0x0000001C — 첫 디렉터리 항목의 오프셋이 있습니다.

3. 첫 번째 항목

0x1C의 9바이트를 디코딩하면:

필드 바이트 값
NameOffset 64 00 00 00 0x64 → "anims" 문자열
DataOffset 6a 00 00 00 0x6A → 하위 디렉터리 기술자
Flag 01 1 ⇒ 하위 디렉터리

0x64 위치:

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

문자열 "anims\0" — 디렉터리 이름이 확인됩니다.

0x6A 위치:

00000060  ....                  01 00 00 00 72 00      ........r.
                                ^^^^^^^^^^^ ^^^^
00000070  00 00 7b 00 00 00 ...                          ..{...
          ^^^^^
필드 바이트 값
하위 디렉터리 EntryCount 01 00 00 00 1
하위 디렉터리 EntriesOffset 72 00 00 00 0x72

따라서 anims/ 디렉터리에는 파일 1개가 있으며, 그 설명은 0x72에서 시작합니다.

필드 바이트 값
NameOffset 7b 00 00 00 0x7B → 파일 이름
DataOffset 8e 00 00 00 0x8E → 파일 기술자
Flag 00 0 ⇒ 파일

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".

0x8E의 파일 기술자는 9바이트 HPIFile입니다.

00000080                                              24 02              $.
00000090  00 00 48 48 00 00 01                                ..HH...
필드 바이트 값
DataOffset 24 02 00 00 0x0224 — 디렉터리 바로 다음 바이트!
FileSize 48 48 00 00 0x4848 = 18504바이트
CompMethod 01 1 ⇒ LZ77

이는 kbot hpi list -v의 출력과 일치합니다.

anims/armfark_gadget.gaf    18504

4. 전체 트리 읽기

나머지 최상위 항목 7개도 계속 읽어 나가면 다음과 같습니다.

.
├── 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)

파일 9개, 유닛 하나짜리 .ufo의 전형적인 형태입니다.

5. 압축 청크

548바이트 디렉터리 다음의 첫 바이트는 0x224에 있습니다. 그 영역을 복호화하면(청크 헤더도 디렉터리 암호로 암호화되어 있음) 다음이 나타납니다.

00000220                          .. 53 51 53 48 02 01 01 cc      .SQSH....
오프셋(0x224 기준) 바이트 필드 값
+0 53 51 53 48 Marker 'SQSH' ✓
+4 02 Version 0x02(항상)
+5 01 CompMethod 1 ⇒ LZ77
+6 01 Encrypted 1 ⇒ 두 번째 XOR 처리를 적용
+7.. … CompressedSize, DecompressedSize, Checksum, 이어서 data[] …

data[i] = (data[i] - i) ^ i로 청크 본문을 풀고 그 결과를 위에서 설명한 LZ77 디코더에 통과시키면 압축 해제된 GAF가 나옵니다 — 이제 GAF 파서에 넘기면 됩니다.

Tip

이 분석을 자신의 컴퓨터에서 재현해 보세요.

# 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

부록 — Jersey.hpi 전체 헥스 분석(HPI v2)

AFark 해설의 TA: Kingdoms판입니다. Jersey.hpi는 GoG TA:K 설치본에 들어 있는 67 KB짜리 음성 팩 HPI로, 효과음 WAV 여섯 개 외에는 아무것도 없습니다. 처음부터 끝까지 따라가기에 충분히 작으면서도, v2의 모든 메커니즘(32바이트 헤더, 이름 풀, SQSH로 감싼 디렉터리 블롭, 청크 하나짜리 파일 페이로드)을 다루기에 충분히 큽니다. 그대로인 부분은 위의 v1 해설이 모두 다루므로, 이 절에서는 v2의 차이점만 짚습니다.

1. 32바이트 헤더

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   @...[... ...?...
오프셋 바이트 필드 값
0x00 48 41 50 49 Marker 'HAPI' ✓
0x04 00 00 02 00 Version 0x00020000 — v2
0x08 92 06 01 00 DirectoryBlock 0x00010692 — 디렉터리 블롭의 오프셋
0x0C ad 00 00 00 DirectorySize 0xAD = 173바이트
0x10 40 06 01 00 NameBlock 0x00010640 — 이름 풀의 오프셋
0x14 5b 00 00 00 NameSize 0x5B = 91바이트
0x18 20 00 00 00 Data 0x20 — 파일 페이로드가 헤더 바로 뒤인 여기서 시작
0x1C 3f 07 01 00 Last78 0x0001073F — 아카이브 끝 표지(0x1073F = 67391 = 파일 크기 − 꼬리 78바이트)

XOR 암호도, 복호화 변환도, 위치별 시드도 없습니다. 헤더 값은 바로 seek할 수 있는 파일 기준 절대 오프셋을 가리킵니다.

2. 이름 풀(원시 텍스트, 파일 끝부분 근처)

DirectoryBlock에서 수백 바이트 앞(엔진은 이름 풀을 디렉터리보다 먼저 씀)에 문자열 테이블이 있습니다.

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.

모든 디렉터리 항목과 파일 항목은 이 블록 안의 바이트 오프셋인 NamePtr를 저장합니다. 포인터를 따라가 다음 \0까지 읽으면 이름이 나옵니다. 바이트 단위로 따라가 보면:

오프셋(NameBlock 기준) 널 종료 문자열
0x00 "" (루트 디렉터리 — 빈 이름)
0x01 "sounds" (유일한 하위 디렉터리)
0x08 "Whodied.wav"
0x14 "Sure.wav"
0x1D "WhoahPally.wav"
0x2C "Forgetaboutit.wav"
0x3E "Yo.wav"
0x45 "Igotcher.wav"

Note

이름 풀의 명목상 크기에는 쓰이지 않는 꼬리 바이트 몇 개가 포함되어 있습니다. NameSize=0x5B는 오프셋 0x1069B까지 이어지지만, 의미 있는 마지막 바이트(Igotcher.wav 뒤의 NUL)는 0x10691에 있습니다. 꼬리 9바이트는 SQSH 디렉터리 청크의 시작 부분과 겹치며 어떤 NamePtr도 이를 가리키지 않습니다 — 리더는 그냥 들여다보지 않는 것으로 이를 허용합니다.

3. 디렉터리 블롭(SQSH로 감쌈)

0x10692에서 시작하는 디렉터리 블롭은 SQSH 청크 헤더로 시작합니다 — v2는 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…
오프셋 바이트 필드 값
+0 53 51 53 48 Marker 'SQSH' ✓
+4 02 Version 2
+5 02 CompMethod 2 ⇒ ZLib
+6 01 Encrypted 1 — 청크 본문에 두 번째 XOR 처리가 적용됨(TAK에서는 드물지만 이 아카이브에는 있음)
+7 9a 00 00 00 CompressedSize 0x9A = 154바이트
+11 b8 00 00 00 DecompressedSize 0xB8 = 184바이트
+15 9b 4f 00 00 Checksum 0x4F9B
+19..+172 … data[] XOR로 뒤섞인 ZLib 스트림 154바이트

19 + 154 = 173 = 0xAD = DirectorySize가 성립하므로 청크 전체가 헤더가 말한 크기에 꼭 맞습니다. XOR 처리를 되돌리고(v1과 같은 알고리즘) inflate에 통과시키면 빽빽하게 채워진 디렉터리 레코드 184바이트가 나옵니다.

4. 디렉터리 레코드 디코딩

압축을 푼 블롭은 두 가지 레코드 형식이 빽빽하게 이어진 배열입니다: 20바이트 디렉터리 항목(HpiDir2)과 24바이트 파일 항목(HpiEntry2). 개수 접두나 오프셋 테이블은 없습니다 — 루트 디렉터리는 항상 블롭의 오프셋 0에 있고, 그 FirstSubDirectory / FirstFile 필드가 자식을 가리킵니다.

루트 HpiDir2(압축 해제 오프셋 0, 20바이트):

오프셋 바이트(압축 해제 후) 필드 값
+0 00 00 00 00 NamePtr 0 → ""(루트)
+4 14 00 00 00 FirstSubDirectory 0x14 — 이 오프셋에 20바이트 하위 디렉터리 레코드 하나
+8 01 00 00 00 SubCount 1(sounds/ 디렉터리)
+12 00 00 00 00 FirstFile 0 — 루트에는 파일 없음
+16 00 00 00 00 FileCount 0

포인터를 따라 0x14로 가면 sounds의 HpiDir2가 나옵니다.

  • NamePtr = 0x01 → "sounds"
  • FirstSubDirectory = 0, SubCount = 0 — 중첩된 하위 디렉터리 없음
  • FirstFile = 0x28 — HpiEntry2 레코드 여섯 개(각 24바이트) 중 첫 번째
  • FileCount = 6

0x28, 0x40, 0x58, …의 각 파일 레코드는 다음을 담습니다.

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

디코딩한 여섯 항목은 kbot hpi list가 보고하는 내용과 정확히 일치합니다.

sounds/Whodied.wav        56474 bytes
sounds/Sure.wav           15248 bytes
sounds/WhoahPally.wav     24826 bytes
sounds/Forgetaboutit.wav  24826 bytes
sounds/Yo.wav             19368 bytes
sounds/Igotcher.wav       35980 bytes

5. 여기에 없는 것(v1 대비)

v1 해설을 막 읽었다면, 빠진 부분들이야말로 v2가 단순해진 핵심입니다.

  • XOR 암호 유도 없음 — v2에서 DecryptKey는 항상 0입니다. SQSH 청크의 Encrypted == 1일 때도 청크 안의 두 번째 XOR 처리는 여전히 적용되지만, 디렉터리/페이로드의 바깥 구조는 평문입니다.
  • 위치별 시드 없음 — v1 리더를 복잡하게 만들던 바이트 단위 암호(복호화가 파일 기준 절대 오프셋에 의존)는 v2의 바깥 구조 수준에는 존재하지 않습니다.
  • 청크 크기 테이블 없음 — v1 파일은 앞쪽에 uint32 크기 배열을 두고 64 KiB 이하 블록으로 나뉘었습니다. v2는 각 파일을 SQSH 청크 하나(또는 무압축 저장 바이트)로 저장하므로 크기 테이블로 만들 것이 없습니다.
  • 디렉터리가 헤더 바로 뒤 0x14에 온다는 관례 없음 — 디렉터리 블록과 이름 블록은 라이터가 놓아 둔 곳 어디에나 있습니다(보통 파일 끝부분 근처). 헤더가 이들을 가리킵니다.

Tip

이 분석을 자신의 컴퓨터에서 재현해 보세요.

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

함께 보기

  • GAF, PCX, 3DO, TNT, COB, TDF — HPI 아카이브에 담기는 파일 종류들.
  • 용어집 — 가상 파일 시스템, 계층화 등 HPI와 관련된 용어의 정의.