"""In-memory sounds: WEMs held inside soundbanks, i.e. the loose SFX\\*.bnk files and the banks embedded in the .upk packages (docs/formats/banks.md, upk.md). Catalog archive `BANK`, target = media ID. A media ID can be stored many times (the same bank copied into several packages, or several banks holding the same media). The location index (data/catalog/BANK_locations.csv.gz) lists every copy: media, file (relative to CookedPCConsole, '/'-separated), offset (in the *uncompressed* file), size, kind kind 'm' = an in-memory sound, 'p' = the in-memory head of a streamed WAD sound (prefetch); for 'p' rows `sound` is the WAD sound's key (e.g. ITA:2B14E472:3): speech lines have per-language media IDs and heads. Replacements are written in place into every copy (<= original size, zero-padded), so bank and package layouts never change. """ import csv import gzip import hashlib import io import os import re import struct from collections import defaultdict, namedtuple from .bnk import Bank, BankError, find_banks, fnv1, PREFETCH from .upk import Package, PackageError, is_package ARCHIVE = "BANK" LOCATIONS = "BANK_locations.csv.gz" SOUND, MEMORY, PREFETCH_HEAD = 2, "m", "p" EVENT, ACTION = 4, 3 CONTAINERS = (5, 6, 7, 9) # random/sequence, switch, actor-mixer, layer Location = namedtuple("Location", "media file offset size kind sound", defaults=("",)) # --- files ---------------------------------------------------------------------------------------- def bank_files(cooked): """Relative paths ('/'-separated) of every loose regular bank and every package.""" out = [] sfx = os.path.join(cooked, "SFX") if os.path.isdir(sfx): for f in sorted(os.listdir(sfx)): if f.lower().endswith(".bnk"): with open(os.path.join(sfx, f), "rb") as fh: if fh.read(4) == b"BKHD": out.append("SFX/" + f) for f in sorted(os.listdir(cooked)): if f.lower().endswith(".upk") and is_package(os.path.join(cooked, f)): out.append(f) return out class _Plain: """Uniform read access for a loose bank (offsets = file offsets).""" def __init__(self, path): self.path = path def read(self, off, n): with open(self.path, "rb") as f: f.seek(off) return f.read(n) def read_all(self): return 0, open(self.path, "rb").read() def fingerprint(self): return file_fingerprint(self.path) def patched(self, edits): out = bytearray(open(self.path, "rb").read()) for off, data in edits: out[off:off + len(data)] = data return bytes(out) def file_fingerprint(path): """Identity of a loose bank (small files: content hash).""" return hashlib.sha1(open(path, "rb").read()).hexdigest()[:16] _open_cache = {} def open_file(path): """A reader for a bank file or package (cached while the file is unchanged).""" st = os.stat(path) key = (os.path.normcase(os.path.abspath(path)), st.st_size, st.st_mtime_ns) r = _open_cache.get(key) if r is None: r = Package(path) if path.lower().endswith(".upk") else _Plain(path) if len(_open_cache) > 64: _open_cache.clear() _open_cache[key] = r return r def file_path(cooked, rel): return os.path.join(cooked, *rel.split("/")) def riff_trim(b): """A WEM slot without the zero padding after a smaller replacement (RIFF length).""" if len(b) >= 8 and b[:4] == b"RIFF": n = 8 + struct.unpack_from(" end: return t = buf[p] ln = struct.unpack_from(" fingerprint self.media = {} # id -> {"size", "sha1": set, "head": bytes, "banks": set} self.bank_names = {} # bank id -> name self.names = set() # identifier-like names seen in packages (for event names) self.objects = {} # HIRC id -> type self.containers = {} # id -> payload self.events = {} # id -> [action ids] self.actions = {} # id -> target id self.sound_media = defaultdict(set) # sound object id -> media ids self.prefetch = set() # media ids whose in-memory copy is a prefetch head self.packs = defaultdict(dict) # WAD entry id -> {media id: (offset in entry, size)} (BKPK tables) self.issues = [] # (kind, detail) _LANG = re.compile(rb"([\x01-\x10])\x00\x00\x00\x04\x00\x00\x00[A-Z]{3}\x00") def bank_languages(data, start, imports, exports, banks, issues=None, rel=""): """{bank offset in data: language} for localized banks. A localized AkBank export starts with its list of languages (FStrings, e.g. INT FRA ITA DEU ESN) and holds one bank per language, in that order (docs/formats/banks.md). Banks of other exports are language-neutral (not in the result).""" out = {} akbank = {-(i + 1) for i, (c, o, _) in enumerate(imports) if c == "Class" and o == "AkBank"} starts = sorted(bk.at for bk in banks) for x in exports: if x.cls not in akbank or x.size <= 0: continue o0 = x.offset - start m = _LANG.search(data, o0, min(o0 + 256, o0 + x.size)) if not m: continue n = m.group(1)[0] langs, o = [], m.start() + 4 for _ in range(n): ln = struct.unpack_from(" media offsets/sizes.""" for m in re.finditer(b"BKPK", data): p = m.start() if p + 0x48 > len(data) or struct.unpack_from(" 100000 or p + 0x48 + 12 * n > len(data): continue for i in range(n): mid, size, off = struct.unpack_from("= 8: n = struct.unpack_from("= 8 + 4 * n: sc.events[oid] = list(struct.unpack_from("<%dI" % n, pl, 8)) elif t == ACTION and len(pl) >= 12: sc.actions[oid] = struct.unpack_from("_). Localized speech has one media per language for the same line: they share the name, variations are numbered within a language. Names are unique among the language-neutral media plus any one language (what a catalog shows at a time).""" ev = event_media(sc) best = {} for name, ms in ev.items(): groups = defaultdict(list) for m in ms: if m in sc.media: groups[media_language(sc, m)].append(m) for g in groups.values(): rank = (0 if name.lower().startswith("play") else 1, len(g), name.lower()) for i, m in enumerate(g): if m not in best or rank < best[m][0]: best[m] = (rank, name, i, len(g)) out = {} taken = set() # (language, lower-case name) def free(lang, nm): k = nm.lower() if lang: return (lang, k) not in taken and ("", k) not in taken return not any(n == k for _, n in taken) def take(lang, nm, m, base): if not free(lang, nm): nm = "%s_%08X" % (base, m) taken.add((lang, nm.lower())) return nm for m, (_, name, i, n) in sorted(best.items(), key=lambda x: (x[1][1].lower(), x[1][2], x[0])): out[m] = take(media_language(sc, m), name if n == 1 else "%s_%d" % (name, i + 1), m, name) # the rest: by bank (and language), numbered in media id order groups = defaultdict(list) for m, e in sc.media.items(): bank = sorted(sc.bank_names.get(b, "%08X" % b) for b in e["banks"])[0] if m in out: out[m] = (out[m], bank) else: groups[(bank, media_language(sc, m))].append(m) for (bank, lang), ms in sorted(groups.items()): for i, m in enumerate(sorted(ms)): out[m] = (take(lang, "%s_%d" % (bank, i + 1), m, bank), bank) return out # --- location index ------------------------------------------------------------------------------- def write_locations(path, locs): raw = io.StringIO() w = csv.writer(raw, lineterminator="\n") w.writerow(["media", "file", "offset", "size", "kind", "sound"]) for l in sorted(locs, key=lambda l: (l.media, l.file, l.offset)): w.writerow(["%08X" % l.media, l.file, "%X" % l.offset, l.size, l.kind, l.sound]) data = raw.getvalue().encode("utf-8") with open(path + ".tmp", "wb") as f: # mtime=0: identical content gives identical bytes (no spurious git changes) with gzip.GzipFile(fileobj=f, mode="wb", mtime=0) as gz: gz.write(data) os.replace(path + ".tmp", path) def read_locations(path): """{media id: [Location]}""" out = defaultdict(list) with gzip.open(path, "rt", encoding="utf-8", newline="") as f: for r in csv.DictReader(f): m = int(r["media"], 16) out[m].append(Location(m, r["file"], int(r["offset"], 16), int(r["size"]), r["kind"], r["sound"])) return dict(out) def game_fingerprint(cooked): """Cheap identity of the game's bank set: CookedBanks.txt + names/sizes of the loose banks. (Each file is checked again before it is written: `file_fingerprint` / Package.fingerprint.)""" h = hashlib.sha1() p = os.path.join(cooked, "CookedBanks.txt") if os.path.exists(p): h.update(open(p, "rb").read()) sfx = os.path.join(cooked, "SFX") if os.path.isdir(sfx): for f in sorted(os.listdir(sfx)): if f.lower().endswith(".bnk"): h.update(("%s:%d;" % (f.upper(), os.path.getsize(os.path.join(sfx, f)))).encode()) return h.hexdigest()[:16] def has_banks(cooked): return os.path.exists(os.path.join(cooked, "CookedBanks.txt"))