"""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 import threading from collections import OrderedDict, namedtuple from . import lzo TAG = 0x9E2A83C1 COMPRESS_LZO = 2 class PackageError(ValueError): pass Export = namedtuple("Export", "index cls outer name size offset entry") # entry: table position (uncompressed) 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 self._lock = threading.Lock() # summary ----------------------------------------------------------------------------------- def _parse_summary(self, b, f): if len(b) < 64 or struct.unpack_from("= 0: names.append(bytes(buf[o:o + n - 1]).decode("latin-1")) o += n else: names.append(bytes(buf[o:o - 2 * n - 2]).decode("utf-16-le", "replace")) o += -2 * n o += 8 # name flags imports = [] o = self.import_offset - start for _ in range(self.import_count): _pkg, _pn, cls, _cn, outer, nm, nn = struct.unpack_from("<7i", buf, o) imports.append((names[cls], names[nm], outer)) o += 28 exports = [] o = self.export_offset - start for i in range(self.export_count): cls, sup, outer, nm, nn, arch, _x = struct.unpack_from("<7i", buf, o) size, off = 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("