Read and patch UE3 packages and soundbanks

This commit is contained in:
2026-10-09 19:38:57 +02:00
parent b3c9b84dc7
commit 61bbf26f3b
10 changed files with 734 additions and 11 deletions

View File

@@ -16,8 +16,10 @@
(music heard in game ncc 1.0, reverts byte-identical, speech line apply/revert byte-identical). (music heard in game ncc 1.0, reverts byte-identical, speech line apply/revert byte-identical).
- Mission 3 (2026-10-09, brief: `docs/history/mission3_audio_text_prompt.md`): full-game audio, then subtitle - Mission 3 (2026-10-09, brief: `docs/history/mission3_audio_text_prompt.md`): full-game audio, then subtitle
and menu text editing. Plan (one commit per stage; a stage = code + tests + docs + this status): and menu text editing. Plan (one commit per stage; a stage = code + tests + docs + this status):
1. [ ] `ackit/lzo.py`, `ackit/upk.py` (UE3 package summary, compressed chunks, block-level read/patch), 1. [x] `ackit/lzo.py`, `ackit/upk.py` (UE3 package summary, compressed chunks, block-level read/patch),
`ackit/bnk.py` (bank parsing: DIDX/DATA/HIRC sources, prefetch). Unit + real-game read-only tests. `ackit/bnk.py` (bank parsing: DIDX/DATA/HIRC sources). Docs: formats/upk.md, banks.md. Findings: pure-Python
LZO ~47 MB/s (== decompress.exe on every block); 15,800 media IDs in 96,011 copies, 396 are prefetch heads
of WAD sounds (2,101 copies) -> 15,404 in-memory sounds; copies identical except 1C898731.
2. [ ] Catalog of in-memory sounds (archive `BANK`): build from loose banks + packages, location index, 2. [ ] Catalog of in-memory sounds (archive `BANK`): build from loose banks + packages, location index,
names/categories, prefetch handling; `ackit verify --catalog` cross-check (missing/extra/duplicate/ names/categories, prefetch handling; `ackit verify --catalog` cross-check (missing/extra/duplicate/
character/category/language) + test. character/category/language) + test.

View File

@@ -1,7 +1,8 @@
"""ackit - Batman: Arkham City (PC) audio toolkit. """ackit - Batman: Arkham City (PC) audio toolkit.
Library layers (low -> high): Library layers (low -> high):
formats wad, pack, wem, ogg, bits, codebooks, wwvorbis, allocsize, builder, convert, known_setups formats wad, pack, bnk (soundbanks), upk + lzo (UE3 packages), wem, ogg, bits, codebooks, wwvorbis,
allocsize, builder, convert, known_setups
audio audio (load/convert/decode), encoder (PCM -> game WEM) audio audio (load/convert/decode), encoder (PCM -> game WEM)
game paths, game (install discovery), catalog + classify (names/categories/characters), game paths, game (install discovery), catalog + classify (names/categories/characters),
inject (WAD writing), originals (store), workspace (apply/revert), modpack (share) inject (WAD writing), originals (store), workspace (apply/revert), modpack (share)

108
ackit/bnk.py Normal file
View File

@@ -0,0 +1,108 @@
"""Wwise soundbanks (bank version 56): chunks, in-memory media (DIDX/DATA) and HIRC sound sources.
Works on a buffer that may hold a bank anywhere (a loose .bnk file, a BKPK pack's embedded bank, or
an uncompressed .upk package with many banks). Offsets are relative to the start of the bank.
Format: docs/formats/banks.md.
"""
import re
import struct
from collections import namedtuple
CHUNKS = (b"BKHD", b"DIDX", b"DATA", b"HIRC", b"STID", b"STMG", b"ENVS", b"FXPR")
SOUND = 2 # HIRC object type of a Sound
IN_MEMORY, STREAMED, PREFETCH = 0, 1, 2
Media = namedtuple("Media", "id off size") # off: from bank start
Source = namedtuple("Source", "object stream media bank off size pos") # pos: of `off` field, from bank start
class BankError(ValueError):
pass
class Bank:
def __init__(self, buf, at=0):
self.buf, self.at = buf, at
self.chunks = {}
p = at
while p + 8 <= len(buf) and bytes(buf[p:p + 4]) in CHUNKS:
tag = bytes(buf[p:p + 4]).decode()
n = struct.unpack_from("<I", buf, p + 4)[0]
if p + 8 + n > len(buf) or tag in self.chunks:
break
self.chunks[tag] = (p + 8 - at, n)
p += 8 + n
if "BKHD" not in self.chunks or self.chunks["BKHD"][1] < 8:
raise BankError("no bank at 0x%X" % at)
self.size = p - at
o = at + self.chunks["BKHD"][0]
self.version, self.id = struct.unpack_from("<II", buf, o)
def _abs(self, tag):
o, n = self.chunks[tag]
return self.at + o, n
def media(self):
"""In-memory WEMs: [Media(id, offset from bank start, size)]."""
if "DIDX" not in self.chunks or "DATA" not in self.chunks:
return []
o, n = self._abs("DIDX")
data = self.chunks["DATA"][0]
out = []
for i in range(n // 12):
mid, off, size = struct.unpack_from("<III", self.buf, o + 12 * i)
out.append(Media(mid, data + off, size))
return out
def sources(self):
"""Sources of the HIRC Sound objects: [Source]. In-memory sources carry offset/size."""
if "HIRC" not in self.chunks:
return []
o, n = self._abs("HIRC")
end = o + n
count = struct.unpack_from("<I", self.buf, o)[0]
p = o + 4
out = []
for _ in range(count):
if p + 5 > end:
raise BankError("HIRC overruns its chunk in bank %08X" % self.id)
t = self.buf[p]
ln = struct.unpack_from("<I", self.buf, p + 1)[0]
if t == SOUND and ln >= 28:
obj, _plugin, stream, mid, bank = struct.unpack_from("<5I", self.buf, p + 5)
off, size = struct.unpack_from("<II", self.buf, p + 25)
out.append(Source(obj, stream, mid, bank, off, size, p + 25 - self.at))
p += 5 + ln
return out
def names(self):
"""STID: {bank id: name}."""
if "STID" not in self.chunks:
return {}
o, n = self._abs("STID")
count = struct.unpack_from("<I", self.buf, o + 4)[0]
p = o + 8
out = {}
for _ in range(count):
bid, ln = struct.unpack_from("<IB", self.buf, p)
out[bid] = bytes(self.buf[p + 5:p + 5 + ln]).decode("latin-1")
p += 5 + ln
return out
def find_banks(buf, start=0):
"""Every bank in `buf` (e.g. an uncompressed package): yields Bank."""
for m in re.finditer(b"BKHD", buf[start:] if start else buf):
try:
yield Bank(buf, m.start() + start)
except (BankError, struct.error):
continue
def fnv1(name):
"""Wwise ID of a name (bank, event, ...): FNV-1 32 of the lower-cased name."""
h = 2166136261
for c in name.lower().encode("utf-8"):
h = (h * 16777619) & 0xFFFFFFFF
h ^= c
return h

142
ackit/lzo.py Normal file
View File

@@ -0,0 +1,142 @@
"""LZO1X, as used by the game's compressed UE3 packages (docs/formats/upk.md).
`decompress` is a pure-Python LZO1X decoder (same semantics as lzo1x_decompress_safe; the packages
use 128 KiB blocks, so speed is fine for block-level access). `compress_stored` writes a valid LZO1X
stream that holds the data as one literal run (no matches): bigger than real compression, but any
LZO1X decoder (the game's included) reads it back exactly, and nothing else has to change.
"""
M2_MAX_OFFSET = 0x0800
class LzoError(ValueError):
pass
def _copy_match(out, dist, n):
start = len(out) - dist
if start < 0:
raise LzoError("match distance beyond output start")
if dist >= n:
out += out[start:start + n]
else: # overlapping: repeat the last `dist` bytes
pat = bytes(out[start:])
out += (pat * (n // dist + 1))[:n]
def decompress(src, out_len=None):
"""LZO1X block -> bytes. `out_len` (if given) is checked."""
src = bytes(src)
n = len(src)
out = bytearray()
ip = 0
def ext(t, base):
nonlocal ip
while src[ip] == 0:
t += 255
ip += 1
t += base + src[ip]
ip += 1
return t
try:
state = "loop"
if src[0] > 17:
t = src[0] - 17
ip = 1
if t < 4:
state = "match_next"
else:
out += src[ip:ip + t]
ip += t
state = "first_literal_run"
while True:
if state == "loop":
t = src[ip]
ip += 1
if t >= 16:
state = "match"
continue
if t == 0:
t = ext(0, 15)
out += src[ip:ip + t + 3]
ip += t + 3
state = "first_literal_run"
continue
if state == "first_literal_run":
t = src[ip]
ip += 1
if t >= 16:
state = "match"
continue
dist = 1 + M2_MAX_OFFSET + (t >> 2) + (src[ip] << 2)
ip += 1
_copy_match(out, dist, 3)
state = "match_done"
continue
if state == "match_next":
out += src[ip:ip + t]
ip += t
t = src[ip]
ip += 1
state = "match"
continue
if state == "match":
if t >= 64: # M2
dist = 1 + ((t >> 2) & 7) + (src[ip] << 3)
ip += 1
_copy_match(out, dist, (t >> 5) + 1)
elif t >= 32: # M3
t &= 31
if t == 0:
t = ext(0, 31)
dist = 1 + ((src[ip] | (src[ip + 1] << 8)) >> 2)
ip += 2
_copy_match(out, dist, t + 2)
elif t >= 16: # M4 (distance 0 = end of stream)
hi = (t & 8) << 11
t &= 7
if t == 0:
t = ext(0, 7)
dist = hi + ((src[ip] | (src[ip + 1] << 8)) >> 2)
ip += 2
if dist == 0:
break
_copy_match(out, dist + 0x4000, t + 2)
else: # M1 after a match: 2 bytes
dist = 1 + (t >> 2) + (src[ip] << 2)
ip += 1
_copy_match(out, dist, 2)
state = "match_done"
continue
# match_done: the low 2 bits of the instruction's last byte = trailing literals
t = src[ip - 2] & 3
if t == 0:
state = "loop"
else:
state = "match_next"
except IndexError:
raise LzoError("truncated LZO stream") from None
if ip != n:
raise LzoError("%d bytes after the end of the LZO stream" % (n - ip))
if out_len is not None and len(out) != out_len:
raise LzoError("LZO block decoded to %d bytes, expected %d" % (len(out), out_len))
return bytes(out)
EOF = b"\x11\x00\x00"
def compress_stored(data):
"""bytes -> LZO1X stream made of one literal run (decodes back to `data`)."""
data = bytes(data)
n = len(data)
if n == 0:
return EOF
if n <= 238:
return bytes([n + 17]) + data + EOF
# long literal run: 0x00, k zero bytes (255 each), last byte; length = 18 + 255*k + last
r = n - 18
k = (r - 1) // 255
last = r - 255 * k
return b"\x00" + b"\x00" * k + bytes([last]) + data + EOF

250
ackit/upk.py Normal file
View File

@@ -0,0 +1,250 @@
"""The game's UE3 packages (.upk): summary, LZO-compressed chunks, block-level reads and patches.
Offsets used by the rest of ackit are positions in the *uncompressed* package (what Gildor's
decompress.exe writes), so a sound's location does not depend on how the file is compressed.
Format and rules: docs/formats/upk.md.
Patching rewrites only the blocks that contain changed bytes (stored as one LZO literal run, see
lzo.compress_stored) and moves the chunks after them; every other byte of the file stays identical.
"""
import struct
from collections import OrderedDict
from . import lzo
TAG = 0x9E2A83C1
COMPRESS_LZO = 2
class PackageError(ValueError):
pass
class Block:
__slots__ = ("chunk", "uoff", "usize", "coff", "csize")
def __init__(self, chunk, uoff, usize, coff, csize):
self.chunk, self.uoff, self.usize, self.coff, self.csize = chunk, uoff, usize, coff, csize
class Package:
"""Read access to one package file. `blocks` covers the uncompressed range [header_size, size)."""
def __init__(self, path, cache_blocks=8):
self.path = path
with open(path, "rb") as f:
head = f.read(0x10000)
self._parse_summary(head, f)
self.file_size = f.seek(0, 2)
self._cache = OrderedDict()
self._cache_n = cache_blocks
# summary -----------------------------------------------------------------------------------
def _parse_summary(self, b, f):
if len(b) < 64 or struct.unpack_from("<I", b, 0)[0] != TAG:
raise PackageError("%s is not an Unreal package" % self.path)
self.version, self.licensee = struct.unpack_from("<HH", b, 4)
o = 12
n = struct.unpack_from("<i", b, o)[0]
o += 4 + max(n, 0)
# PackageFlags, NameCount/Offset, ExportCount/Offset, ImportCount/Offset, DependsOffset,
# ImportExportGuidsOffset, ImportGuidsCount, ExportGuidsCount, ThumbnailTableOffset
f12 = struct.unpack_from("<12i", b, o)
self.name_count, self.name_offset = f12[1], f12[2]
self.export_count, self.export_offset = f12[3], f12[4]
o += 48
self.guid = b[o:o + 16]
o += 16
ng = struct.unpack_from("<i", b, o)[0]
o += 4 + 12 * ng
self.engine, self.cooker, self.compression = struct.unpack_from("<3I", b, o)
o += 12
nc = struct.unpack_from("<i", b, o)[0]
o += 4
if nc and len(b) < o + 16 * nc:
f.seek(0)
b = f.read(o + 16 * nc)
self.chunk_table_offset = o
self.chunks = [list(struct.unpack_from("<4i", b, o + 16 * i)) for i in range(nc)] # uoff usize coff csize
if nc and self.compression != COMPRESS_LZO:
raise PackageError("%s: unsupported compression %d" % (self.path, self.compression))
self.blocks = []
if not nc:
return
for ci, (uoff, usize, coff, csize) in enumerate(self.chunks):
f.seek(coff)
h = f.read(16)
tag, bsize, sum_c, sum_u = struct.unpack("<4I", h)
if tag != TAG or sum_u != usize:
raise PackageError("%s: bad chunk header at 0x%X" % (self.path, coff))
nb = (sum_u + bsize - 1) // bsize
tbl = f.read(8 * nb)
p = coff + 16 + 8 * nb
u = uoff
for i in range(nb):
c, uu = struct.unpack_from("<2I", tbl, 8 * i)
self.blocks.append(Block(ci, u, uu, p, c))
p += c
u += uu
if p != coff + csize or u != uoff + usize:
raise PackageError("%s: inconsistent chunk %d" % (self.path, ci))
self.block_size = bsize
@property
def compressed(self):
return bool(self.chunks)
@property
def header_size(self):
"""Uncompressed size of the summary area (= start of the first chunk's data)."""
return self.chunks[0][0] if self.chunks else 0
@property
def size(self):
"""Uncompressed package size."""
if not self.chunks:
return self.file_size
return self.chunks[-1][0] + self.chunks[-1][1]
def fingerprint(self):
"""Cheap identity of the package contents: guid + chunk table (changes with any re-cook/patch)."""
import hashlib
h = hashlib.sha1(self.guid)
h.update(struct.pack("<q", self.file_size))
for c in self.chunks:
h.update(struct.pack("<4i", *c))
return h.hexdigest()[:16]
# reading -----------------------------------------------------------------------------------
def _block(self, i, f=None):
if i in self._cache:
self._cache.move_to_end(i)
return self._cache[i]
bl = self.blocks[i]
own = f is None
f = f or open(self.path, "rb")
try:
f.seek(bl.coff)
data = lzo.decompress(f.read(bl.csize), bl.usize)
finally:
if own:
f.close()
self._cache[i] = data
if len(self._cache) > self._cache_n:
self._cache.popitem(last=False)
return data
def _block_index(self, off):
lo, hi = 0, len(self.blocks) - 1
while lo <= hi:
mid = (lo + hi) // 2
bl = self.blocks[mid]
if off < bl.uoff:
hi = mid - 1
elif off >= bl.uoff + bl.usize:
lo = mid + 1
else:
return mid
raise PackageError("offset 0x%X is outside the package data" % off)
def read(self, off, n):
"""Uncompressed bytes [off, off+n)."""
if not self.chunks:
with open(self.path, "rb") as f:
f.seek(off)
return f.read(n)
if off < self.header_size:
raise PackageError("reading the package summary area is not supported")
out = bytearray()
i = self._block_index(off)
with open(self.path, "rb") as f:
while len(out) < n:
bl = self.blocks[i]
d = self._block(i, f)
s = off + len(out) - bl.uoff
out += d[s:s + n - len(out)]
i += 1
return bytes(out)
def iter_data(self):
"""(uncompressed offset, bytes) per block, in order (whole-package scans)."""
if not self.chunks:
with open(self.path, "rb") as f:
yield 0, f.read()
return
with open(self.path, "rb") as f:
for bl in self.blocks:
f.seek(bl.coff)
yield bl.uoff, lzo.decompress(f.read(bl.csize), bl.usize)
def read_all(self):
"""The whole uncompressed data area as one bytes object (offset `data_start`)."""
parts = list(self.iter_data())
return (parts[0][0] if parts else 0), b"".join(p for _, p in parts)
# patching ----------------------------------------------------------------------------------
def patched(self, edits):
"""edits: [(uncompressed offset, bytes)] (same length as what they overwrite) -> new file bytes.
Uncompressed packages are patched in place. For compressed ones the touched blocks are
re-encoded and the chunk headers and the summary's chunk table are updated."""
with open(self.path, "rb") as f:
raw = f.read()
if not self.chunks:
out = bytearray(raw)
for off, data in edits:
if off + len(data) > len(out):
raise PackageError("patch beyond the end of %s" % self.path)
out[off:off + len(data)] = data
return bytes(out)
touched = {}
for off, data in edits:
if off < self.header_size or off + len(data) > self.size:
raise PackageError("patch outside the package data (0x%X)" % off)
pos = 0
while pos < len(data):
i = self._block_index(off + pos)
bl = self.blocks[i]
if i not in touched:
touched[i] = bytearray(lzo.decompress(raw[bl.coff:bl.coff + bl.csize], bl.usize))
s = off + pos - bl.uoff
k = min(len(data) - pos, bl.usize - s)
touched[i][s:s + k] = data[pos:pos + k]
pos += k
# rebuild chunk by chunk
head = bytearray(raw[:self.chunks[0][2]])
body = bytearray()
by_chunk = {}
for i, bl in enumerate(self.blocks):
by_chunk.setdefault(bl.chunk, []).append(i)
for ci, (uoff, usize, coff, csize) in enumerate(self.chunks):
new_coff = len(head) + len(body)
idx = by_chunk[ci]
if not any(i in touched for i in idx):
body += raw[coff:coff + csize]
self._set_chunk(head, ci, uoff, usize, new_coff, csize)
continue
datas = []
for i in idx:
bl = self.blocks[i]
if i in touched:
datas.append((lzo.compress_stored(touched[i]), bl.usize))
else:
datas.append((raw[bl.coff:bl.coff + bl.csize], bl.usize))
hdr = struct.pack("<4I", TAG, self.block_size, sum(len(d) for d, _ in datas), usize)
hdr += b"".join(struct.pack("<2I", len(d), u) for d, u in datas)
chunk = hdr + b"".join(d for d, _ in datas)
body += chunk
self._set_chunk(head, ci, uoff, usize, new_coff, len(chunk))
return bytes(head + body)
def _set_chunk(self, head, ci, uoff, usize, coff, csize):
struct.pack_into("<4i", head, self.chunk_table_offset + 16 * ci, uoff, usize, coff, csize)
def is_package(path):
try:
with open(path, "rb") as f:
return struct.unpack("<I", f.read(4))[0] == TAG
except (OSError, struct.error):
return False

View File

@@ -8,6 +8,7 @@
| [building.md](building.md) | building the editor and its installer (Windows, Linux) | | [building.md](building.md) | building the editor and its installer (Windows, Linux) |
| [formats/wad.md](formats/wad.md) | WWAD archives, grouped entries, BKPK stream packs | | [formats/wad.md](formats/wad.md) | WWAD archives, grouped entries, BKPK stream packs |
| [formats/banks.md](formats/banks.md) | soundbanks and the in-memory sounds not covered yet (footsteps, foley, effects) | | [formats/banks.md](formats/banks.md) | soundbanks and the in-memory sounds not covered yet (footsteps, foley, effects) |
| [formats/upk.md](formats/upk.md) | UE3 packages: summary, LZO chunks, how ackit reads and patches them |
| [formats/wem.md](formats/wem.md) | WEM layout the game accepts, setup stripping, hash and decode-alloc formulas | | [formats/wem.md](formats/wem.md) | WEM layout the game accepts, setup stripping, hash and decode-alloc formulas |
| [reverse-engineering/decoder.md](reverse-engineering/decoder.md) | BatmanAC.exe decoder functions and addresses | | [reverse-engineering/decoder.md](reverse-engineering/decoder.md) | BatmanAC.exe decoder functions and addresses |
| [reverse-engineering/findings.md](reverse-engineering/findings.md) | root cause, findings, in-game experiment log (incl. failed hypotheses) | | [reverse-engineering/findings.md](reverse-engineering/findings.md) | root cause, findings, in-game experiment log (incl. failed hypotheses) |

View File

@@ -12,8 +12,10 @@ Survey of the installed game (buildid 3718300) on 2026-10-09, read-only.
| loose `SFX\*.bnk` (Batman/Catwoman footsteps, 32 surfaces each, + `TST_Prep_Media`) | 65 | 1,117 | 0 | | loose `SFX\*.bnk` (Batman/Catwoman footsteps, 32 surfaces each, + `TST_Prep_Media`) | 65 | 1,117 | 0 |
| banks embedded in `.upk` packages (in 1,432 of the 2,650 packages) | 3,340 | 15,641 (~276 MB) | 0 | | banks embedded in `.upk` packages (in 1,432 of the 2,650 packages) | 3,340 | 15,641 (~276 MB) | 0 |
The two sets share 119 media IDs: about **15,800 unique sounds** in total. The WADs, by comparison, are fully The two sets share 119 media IDs: 15,800 unique media IDs in 96,011 places (bank copies). 396 of them are
covered: the BKPK tables in the packages reference 2,543 WAD entries, all of them in the catalog. **prefetch heads** of streamed WAD sounds (below), so there are **15,404 in-memory sounds**. All copies of a
media ID are byte-identical except `1C898731` (two variants). The WADs, by comparison, are fully covered: the
BKPK tables in the packages reference 2,543 WAD entries, all of them in the catalog.
Embedded WEMs by bank name prefix: `Speech` 7,872, `CHR` (character foley, combat, bosses) 3,896, `PSO` 781, Embedded WEMs by bank name prefix: `Speech` 7,872, `CHR` (character foley, combat, bosses) 3,896, `PSO` 781,
`WPN` 664, `NPC` 475, `OBJ` 439, `SEQ` 265, `GUI` 208, `PSA` 203, `MTN` 196, `PX` 195, `DVC` 100, `GEN` 80, `WPN` 664, `NPC` 475, `OBJ` 439, `SEQ` 265, `GUI` 208, `PSA` 203, `MTN` 196, `PX` 195, `DVC` 100, `GEN` 80,
@@ -40,14 +42,17 @@ HIRC Sound object (type 2) payload: u32 object id, u32 plugin id (`0x00040001` =
(0 = in memory, 1 = streamed, 2 = prefetch), u32 media id, u32 bank id of the media; for in-memory sources (0 = in memory, 1 = streamed, 2 = prefetch), u32 media id, u32 bank id of the media; for in-memory sources
then u32 offset, u32 size, where offset = `DATA` payload position (from the start of the bank) + DIDX offset then u32 offset, u32 size, where offset = `DATA` payload position (from the start of the bank) + DIDX offset
and size = DIDX size (checked on every in-memory source of the footstep banks and of `BmGame.upk`). and size = DIDX size (checked on every in-memory source of the footstep banks and of `BmGame.upk`).
A few sources use media of another bank. Every streamed media ID found is in a BKPK table, i.e. a WAD sound Source kinds over all bank copies: 115,861 in-memory sources of the bank's own media; 2,233 in-memory sources
(`wad.md`). with bank id 0 whose media is not in the same bank (loaded by another bank); 133,075 streamed sources;
2,101 **prefetch** sources. Every streamed or prefetched media ID is in a BKPK table, i.e. a WAD sound
(`wad.md`). A prefetch source's media is also in the bank's DIDX: that in-memory copy is the **head** of the
streamed WEM (its size = the source's size), which the game plays while the rest is read from the WAD. 396
WAD sounds have such heads (2,101 copies); replacing one of them must rewrite the heads too.
## Banks inside packages ## Banks inside packages
- The packages are fully LZO-compressed (package version 805, licensee 101). For the survey they were unpacked - The packages are LZO-compressed UE3 packages (`upk.md`); ackit decompresses them itself and searches the
with Gildor's `decompress.exe` (gildor.org; used from the user's old modding folder, not part of this uncompressed data for `BKHD` (banks) and `BKPK` (WAD indexes). (The first survey used Gildor's
repository) and searched for `BKHD` (banks) and `BKPK` (WAD indexes). `UnrealX.upk` (512 bytes) is not a decompress.exe; `ackit/upk.py` gives identical data.)
readable package (wrong tag, probably encrypted).
- Bank blobs in packages: 41,798 for 3,340 bank IDs. Many banks are copied into several packages (e.g. - Bank blobs in packages: 41,798 for 3,340 bank IDs. Many banks are copied into several packages (e.g.
`CHR_CatwomanFoleySuit` 31 times, `CHR_BatmanFoleyBelt` 26). 302 bank IDs occur as several blobs with `CHR_CatwomanFoleySuit` 31 times, `CHR_BatmanFoleyBelt` 26). 302 bank IDs occur as several blobs with
*different* media subsets (often one WEM each, several per package), so a bank's media set is the union *different* media subsets (often one WEM each, several per package), so a bank's media set is the union

38
docs/formats/upk.md Normal file
View File

@@ -0,0 +1,38 @@
# UE3 packages (`*.upk`)
Arkham City's 2,650 packages in `BmGame\CookedPCConsole` are Unreal Engine 3 packages, version 805,
licensee 101. 2,647 are fully LZO-compressed, 2 are stored uncompressed, and `UnrealX.upk` (512 bytes)
is not a readable package. ackit reads and patches them with `ackit/upk.py` and `ackit/lzo.py` (no external
tools). Soundbanks inside them: `banks.md`.
## Summary (file start, little-endian)
| field | notes |
|-------|-------|
| u32 tag `0x9E2A83C1`, u16 version 805, u16 licensee 101, i32 TotalHeaderSize | |
| FString FolderName | i32 length incl. NUL, then bytes (`None`) |
| 12 x i32 | PackageFlags, NameCount, NameOffset, ExportCount, ExportOffset, ImportCount, ImportOffset, DependsOffset, ImportExportGuidsOffset, ImportGuidsCount, ExportGuidsCount, ThumbnailTableOffset |
| 16 bytes Guid | |
| i32 N + N x {ExportCount, NameCount, NetObjectCount} | generations (N = 1) |
| u32 EngineVersion 7748, u32 CookerVersion, u32 CompressionFlags | 2 = LZO, 0 = none |
| i32 C + C x {i32 UncompressedOffset, UncompressedSize, CompressedOffset, CompressedSize} | chunk table |
| u32 PackageSource, ... | not used by ackit |
All offsets in the summary (names, exports, ...) refer to the **uncompressed** package. The uncompressed
package = the summary without the chunk table, followed by the chunks' data at their UncompressedOffset
(the first chunk starts right after the summary, at NameOffset).
## Compressed chunks
Each chunk (at CompressedOffset): `u32 tag, u32 block size (0x20000), u32 compressed sum, u32 uncompressed
sum`, then one `{u32 compressed size, u32 uncompressed size}` per block, then the blocks. Every block is an
independent LZO1X stream. In all game packages the chunks follow each other without gaps, start right
after the summary and end at the end of the file (checked for every package).
## How ackit reads and writes them
- Positions of sounds are offsets in the uncompressed package, the same as in a decompressed copy
(e.g. Gildor's decompress.exe output). Reading decompresses only the blocks that are needed.
- Writing (`Package.patched`): every changed block is stored again as a single LZO literal run (a valid
LZO1X stream without matches, `lzo.compress_stored`), the chunk headers of the changed chunks and the
CompressedOffset/CompressedSize of all following chunks are updated; every other byte is copied, so a
package without changes is rebuilt byte-for-byte. Replacements never change the uncompressed layout
(sounds are replaced in place), so names, exports and their offsets stay valid.

View File

@@ -89,6 +89,72 @@ def pack(media):
return bytes(hdr) + bank, bytes(payload) return bytes(hdr) + bank, bytes(payload)
def bank(bank_id, media, name=None, extra_sources=()):
"""A v56 soundbank holding `media` {id: wem} in memory (DIDX/DATA, 16-byte aligned) with one HIRC
Sound object per media; extra_sources: [(media id, bank id, off, size)] for sources of other banks."""
data = bytearray()
didx = b""
for m in sorted(media):
data += bytes(_a(len(data), 16) - len(data))
didx += struct.pack("<III", m, len(data), len(media[m]))
data += media[m]
bkhd = struct.pack("<II", 56, bank_id) + bytes(8)
head = b"BKHD" + struct.pack("<I", len(bkhd)) + bkhd + b"DIDX" + struct.pack("<I", len(didx)) + didx
data_pos = len(head) + 8 # DATA payload, from the start of the bank
objs = []
for i, m in enumerate(sorted(media)):
off = struct.unpack_from("<I", didx, 12 * i + 4)[0]
objs.append((m, bank_id, data_pos + off, len(media[m])))
objs += list(extra_sources)
hirc = struct.pack("<I", len(objs))
for i, (m, b, off, size) in enumerate(objs):
body = struct.pack("<7I", 0x100 + i, 0x00040001, 0, m, b, off, size) + bytes(6)
hirc += bytes([2]) + struct.pack("<I", len(body)) + body
out = head + b"DATA" + struct.pack("<I", len(data)) + bytes(data) + b"HIRC" + struct.pack("<I", len(hirc)) + hirc
if name:
nm = name.encode()
stid = struct.pack("<II", 1, 1) + struct.pack("<IB", bank_id, len(nm)) + nm
out += b"STID" + struct.pack("<I", len(stid)) + stid
return out
def package(path, body, block_size=0x1000, chunk_blocks=3):
"""A fully LZO-compressed UE3 package (docs/formats/upk.md) whose uncompressed data area is `body`.
Small blocks so that tests cross block and chunk boundaries."""
from ackit import lzo
folder = b"None\x00"
nblocks = max(1, _a(len(body), block_size) // block_size)
nchunks = _a(nblocks, chunk_blocks) // chunk_blocks
def summary(chunks):
s = struct.pack("<IHHi", 0x9E2A83C1, 805, 101, 0) + struct.pack("<i", len(folder)) + folder
s += struct.pack("<12i", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + b"GUID-synthetic!!"
s += struct.pack("<i3i", 1, 0, 0, 0) + struct.pack("<3I", 7748, 0, 2)
s += struct.pack("<i", len(chunks)) + b"".join(struct.pack("<4i", *c) for c in chunks)
return s + struct.pack("<Ii", 0x12345678, 0)
header_u = len(summary([])) + 16 # uncompressed header (some spare bytes)
chunks, blobs = [], []
coff = len(summary([(0, 0, 0, 0)] * nchunks)) + 16
u = header_u
for c in range(nchunks):
part = body[(u - header_u):(u - header_u) + block_size * chunk_blocks]
blocks = [part[i:i + block_size] for i in range(0, len(part), block_size)] or [b""]
comp = [lzo.compress_stored(b) for b in blocks]
hdr = struct.pack("<4I", 0x9E2A83C1, block_size, sum(map(len, comp)), len(part))
hdr += b"".join(struct.pack("<2I", len(cb), len(b)) for cb, b in zip(comp, blocks))
blob = hdr + b"".join(comp)
chunks.append((u, len(part), coff, len(blob)))
blobs.append(blob)
coff += len(blob)
u += len(part)
s = summary(chunks)
out = s + bytes(16) + b"".join(blobs)
assert len(s) + 16 == chunks[0][2]
os.makedirs(os.path.dirname(path), exist_ok=True)
open(path, "wb").write(out)
return header_u
def make_game(root): def make_game(root):
"""Create the folder; returns the dict of original WEMs by target.""" """Create the folder; returns the dict of original WEMs by target."""
cooked = os.path.join(root, "BmGame", "CookedPCConsole") cooked = os.path.join(root, "BmGame", "CookedPCConsole")

110
tests/test_containers.py Normal file
View File

@@ -0,0 +1,110 @@
"""LZO, UE3 packages and soundbanks (ackit/lzo.py, upk.py, bnk.py)."""
import os
import pytest
import synth
from ackit import lzo
from ackit.bnk import Bank, find_banks, fnv1, IN_MEMORY
from ackit.upk import Package
# --- LZO ----------------------------------------------------------------------------------------
@pytest.mark.parametrize("n", [0, 1, 3, 4, 17, 18, 19, 238, 239, 273, 274, 529, 0x20000])
def test_lzo_stored_roundtrip(n):
data = os.urandom(n)
assert lzo.decompress(lzo.compress_stored(data), n) == data
def test_lzo_matches():
# "abc" + M3 match (9 bytes at distance 3) + end
assert lzo.decompress(bytes([20]) + b"abc" + bytes([39, 0x08, 0x00]) + lzo.EOF) == b"abc" * 4
# "abcd" + M2 match (4 bytes at distance 4) + end
assert lzo.decompress(bytes([21]) + b"abcd" + bytes([108, 0]) + lzo.EOF) == b"abcd" * 2
def test_lzo_errors():
with pytest.raises(lzo.LzoError):
lzo.decompress(bytes([25]) + b"abc") # truncated
with pytest.raises(lzo.LzoError):
lzo.decompress(lzo.compress_stored(b"hello") + b"x") # trailing bytes
with pytest.raises(lzo.LzoError):
lzo.decompress(lzo.compress_stored(b"hello"), 6) # wrong size
# --- packages -----------------------------------------------------------------------------------
@pytest.fixture
def pkg(tmp_path):
body = bytes(range(256)) * 80 # 20480 bytes: 5 blocks, 2 chunks
p = str(tmp_path / "Synth.upk")
start = synth.package(p, body)
return p, body, start
def test_package_read(pkg):
p, body, start = pkg
pk = Package(p)
assert pk.compressed and pk.version == 805 and pk.header_size == start
assert pk.size == start + len(body) and len(pk.chunks) == 2 and len(pk.blocks) == 5
assert pk.read(start + 4000, 300) == body[4000:4300] # crosses a block boundary
assert pk.read_all() == (start, body)
def test_package_patch(pkg):
p, body, start = pkg
raw = open(p, "rb").read()
pk = Package(p)
assert pk.patched([]) == raw # nothing changed -> same bytes
new = pk.patched([(start + 16334, b"X" * 100)]) # second chunk, across two blocks
q = p + ".new"
open(q, "wb").write(new)
pk2 = Package(q)
exp = bytearray(body)
exp[16334:16434] = b"X" * 100
assert pk2.read_all()[1] == bytes(exp)
# the first chunk is copied verbatim
c0 = pk.chunks[0]
assert new[c0[2]:c0[2] + c0[3]] == raw[c0[2]:c0[2] + c0[3]]
with pytest.raises(ValueError):
pk.patched([(start + len(body) - 10, b"too long" * 4)])
# --- banks --------------------------------------------------------------------------------------
def test_bank_parse():
media = {0x111: b"RIFF" + bytes(41), 0x222: b"RIFF" + bytes(100)}
b = synth.bank(fnv1("CHR_Test"), media, name="CHR_Test")
bk = Bank(b)
assert bk.id == fnv1("CHR_Test") and bk.version == 56 and bk.size == len(b)
assert bk.names() == {bk.id: "CHR_Test"}
m = {x.id: x for x in bk.media()}
assert b[m[0x222].off:m[0x222].off + m[0x222].size] == media[0x222]
assert m[0x222].off % 16 == (bk.chunks["DATA"][0]) % 16
src = {s.media: s for s in bk.sources()}
assert src[0x111].stream == IN_MEMORY and (src[0x111].off, src[0x111].size) == (m[0x111].off, m[0x111].size)
def test_find_banks_in_buffer():
a = synth.bank(1, {5: b"x" * 20})
b = synth.bank(2, {6: b"y" * 30})
buf = b"junkBKHDjunk" + a + bytes(7) + b
assert [x.id for x in find_banks(buf)] == [1, 2]
def test_fnv1():
assert fnv1("Init") == 0x50C63A23 and fnv1("Music") == 0xEDF036D6
# --- real game (read-only) ----------------------------------------------------------------------
def test_real_package_blocks(real_game_dir):
from ackit.game import Game
p = os.path.join(Game(real_game_dir).cooked, "BmGame.upk")
pk = Package(p)
assert pk.compressed and pk.patched([]) == open(p, "rb").read()
start, data = pk.read_all()
banks = list(find_banks(data))
assert len(banks) > 20
bk = banks[0]
m = bk.media()[0]
off = start + bk.at + m.off
assert pk.read(off, m.size) == data[bk.at + m.off:bk.at + m.off + m.size]
assert pk.read(off, 4) == b"RIFF"