From 61bbf26f3bb8a755e5aa680bfb7386704372897d Mon Sep 17 00:00:00 2001 From: Cristian Gora Date: Fri, 9 Oct 2026 19:38:57 +0200 Subject: [PATCH] Read and patch UE3 packages and soundbanks --- NOTES.md | 6 +- ackit/__init__.py | 3 +- ackit/bnk.py | 108 +++++++++++++++++ ackit/lzo.py | 142 ++++++++++++++++++++++ ackit/upk.py | 250 +++++++++++++++++++++++++++++++++++++++ docs/README.md | 1 + docs/formats/banks.md | 21 ++-- docs/formats/upk.md | 38 ++++++ tests/synth.py | 66 +++++++++++ tests/test_containers.py | 110 +++++++++++++++++ 10 files changed, 734 insertions(+), 11 deletions(-) create mode 100644 ackit/bnk.py create mode 100644 ackit/lzo.py create mode 100644 ackit/upk.py create mode 100644 docs/formats/upk.md create mode 100644 tests/test_containers.py diff --git a/NOTES.md b/NOTES.md index b6287a6..b3ab725 100644 --- a/NOTES.md +++ b/NOTES.md @@ -16,8 +16,10 @@ (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 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), - `ackit/bnk.py` (bank parsing: DIDX/DATA/HIRC sources, prefetch). Unit + real-game read-only tests. + 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). 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, names/categories, prefetch handling; `ackit verify --catalog` cross-check (missing/extra/duplicate/ character/category/language) + test. diff --git a/ackit/__init__.py b/ackit/__init__.py index e07e7f1..1ec7a7d 100644 --- a/ackit/__init__.py +++ b/ackit/__init__.py @@ -1,7 +1,8 @@ """ackit - Batman: Arkham City (PC) audio toolkit. 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) game paths, game (install discovery), catalog + classify (names/categories/characters), inject (WAD writing), originals (store), workspace (apply/revert), modpack (share) diff --git a/ackit/bnk.py b/ackit/bnk.py new file mode 100644 index 0000000..ce35e38 --- /dev/null +++ b/ackit/bnk.py @@ -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(" 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(" end: + raise BankError("HIRC overruns its chunk in bank %08X" % self.id) + t = self.buf[p] + ln = struct.unpack_from("= 28: + obj, _plugin, stream, mid, bank = struct.unpack_from("<5I", self.buf, p + 5) + off, size = struct.unpack_from("= 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 diff --git a/ackit/upk.py b/ackit/upk.py new file mode 100644 index 0000000..69cfc6d --- /dev/null +++ b/ackit/upk.py @@ -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(" 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(" 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"