"""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 # lang: language of the bank holding this copy ('' = language-neutral bank); known after a scan only Location = namedtuple("Location", "media file offset size kind sound lang", 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.lang_media = defaultdict(set) # language -> media ids named by Sound objects of that language's banks # BKPK tables: WAD entry id -> {language: {media id: (offset in entry, size)}}. A table belongs to the # language its localized AkBank export declares ('' = outside such an export). The same media ID can be # used by several languages (at a different position in each language's WAD), so tables never merge. self.packs = defaultdict(_by_language) self.issues = [] # (kind, detail) _LANG = re.compile(rb"([\x01-\x10])\x00\x00\x00\x04\x00\x00\x00[A-Z]{3}\x00") def localized_exports(data, start, imports, exports): """[(begin, end, [languages])] of the localized AkBank exports of a package (offsets in data). Such an export starts with its list of languages (FStrings, e.g. INT FRA ITA DEU ESN) and then holds one block per language, in that order: a bank and/or the BKPK table of the speech WAD entry (docs/formats/banks.md).""" out = [] akbank = {-(i + 1) for i, (c, o, _) in enumerate(imports) if c == "Class" and o == "AkBank"} 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(" len(data) or 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 and PREFETCH_HEAD not in sc.media[m]["kinds"]: # heads belong to WAD lines 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"))