Initial commit

This commit is contained in:
2026-10-09 02:48:24 +02:00
commit e05d973a8d
133 changed files with 101507 additions and 0 deletions

10
ackit/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
"""ackit - Batman: Arkham City (PC) audio toolkit.
Library layers (low -> high):
formats wad, pack, 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)
tools cli (`ackit` command), verify/ (offline self-check, in-game test on Windows)
"""
__version__ = "2.0.0"

3
ackit/__main__.py Normal file
View File

@@ -0,0 +1,3 @@
from .cli import main
main()

161
ackit/allocsize.py Normal file
View File

@@ -0,0 +1,161 @@
"""Model of the game's codec-setup bump-allocator usage (dwDecodeAllocSize).
Reversed from the dumped BatmanAC.exe (see docs/decoder_re.md):
WwiseVorbisParseSetupHeader (runtime 0x010B4D10) and its callees are a Tremor-lowmem
derivative; every pool allocation is rounded up to 4 bytes.
"""
from .bits import BitReader, ilog, book_maptype1_quantvals
from .codebooks import CodebookLibrary
def a4(n):
return (n + 3) & ~3
def _node_bytes(used, leafwidth):
if used < 2:
return 4
if leafwidth == 3:
leafwidth = 4
if ilog(3 * used - 6) + 1 <= leafwidth * 4:
return leafwidth // 2 if leafwidth // 2 else 1
return leafwidth
def _leaf_words(nodeb, leafwidth):
return 2 if leafwidth > nodeb else 1
def _decode_table(used, nodeb, leafw):
if nodeb == 4:
return used * 8 + 4
return a4((used * (leafw + 1) - 2) * nodeb)
def codebook_alloc(blob: bytes) -> int:
"""Pool bytes used when unpacking one compact Wwise codebook."""
r = BitReader(blob)
dims = r.get(4)
entries = r.get(14)
if r.get(1): # ordered
r.get(5)
cur = 0
while cur < entries:
cur += r.get(ilog(entries - cur))
used = entries
else:
cwll = r.get(3)
sparse = r.get(1)
used = 0
for _ in range(entries):
if not sparse or r.get(1):
r.get(cwll)
used += 1
maptype = r.get(1)
if maptype == 0:
lw = ilog(entries) // 8 + 1
nodeb = _node_bytes(used, lw)
return _decode_table(used, nodeb, _leaf_words(nodeb, lw))
r.get(32); r.get(32)
qbits = r.get(4) + 1
quantvals = book_maptype1_quantvals(entries, dims)
total1 = (qbits * dims + 8) // 8
total2 = (ilog(quantvals - 1) * dims + 8) // 8 + (qbits + 7) // 8
if total1 <= 4 and total1 <= total2:
nodeb = _node_bytes(used, total1)
return _decode_table(used, nodeb, _leaf_words(nodeb, total1))
qv = a4(quantvals) if qbits <= 8 else a4(quantvals * 2)
lw = (ilog(quantvals - 1) * dims + 8) // 8
nodeb = _node_bytes(used, lw)
return qv + _decode_table(used, nodeb, _leaf_words(nodeb, lw))
def setup_alloc(ww_setup: bytes, channels: int, lib: CodebookLibrary) -> dict:
"""Walk a stripped Wwise setup and sum pool allocations, by section."""
r = BitReader(ww_setup)
out = {}
nb = r.get(8) + 1
total = nb * 0x3C
cb = 0
for _ in range(nb):
cb += codebook_alloc(lib.books[r.get(10)])
out["codebooks"] = total + cb
nf = r.get(6) + 1
fl = nf * 0x24
for _ in range(nf):
parts = r.get(5)
classes = [r.get(4) for _ in range(parts)]
maxc = max(classes) if classes else -1
dims = []
for _ in range(maxc + 1):
dims.append(r.get(3) + 1)
sub = r.get(2)
if sub:
r.get(8)
for _ in range(1 << sub):
r.get(8)
r.get(2)
rb = r.get(4)
posts = sum(dims[c] for c in classes)
r.pos += posts * rb
fl += a4(parts) + a4(11 * (maxc + 1)) + a4(2 * (posts + 2)) + a4(posts + 2) + 2 * a4(posts)
out["floors"] = fl
floors_count = nf
nr = r.get(6) + 1
rs = nr * 0x1C
for _ in range(nr):
r.get(2); r.get(24); r.get(24); r.get(24)
ncls = r.get(6) + 1
r.get(8)
casc = []
for _ in range(ncls):
lo = r.get(3)
hi = r.get(5) if r.get(1) else 0
casc.append(hi * 8 + lo)
for c in casc:
r.pos += 8 * bin(c & 0xFF).count("1")
rs += a4(ncls) + 8 * ncls
out["residues"] = rs
nm = r.get(6) + 1
ms = nm * 0x14
for _ in range(nm):
submaps = r.get(4) + 1 if r.get(1) else 1
if r.get(1):
steps = r.get(8) + 1
b = ilog(channels - 1)
r.pos += steps * 2 * b
ms += a4(2 * steps)
r.get(2)
if submaps > 1:
r.pos += 4 * channels
ms += a4(channels)
r.pos += 24 * submaps
ms += a4(2 * submaps)
out["mappings"] = ms
nmodes = r.get(6) + 1
out["modes"] = a4(2 * nmodes)
out["total"] = sum(out.values())
out["floors_count"] = floors_count
return out
def fnv1_32(b: bytes) -> int:
"""uHashCodebook: FNV-1 32-bit over the stripped setup packet (verified on all 24 game setups)."""
h = 0x811C9DC5
for x in b:
h = (h * 0x01000193) & 0xFFFFFFFF
h ^= x
return h
def decode_alloc(ww_setup: bytes, channels: int, lib: CodebookLibrary):
"""(dwDecodeAllocSize, dwDecodeX64AllocSize) as the 2011.1 encoder writes them.
32-bit: 0x30 (setup info) + parser allocations + 4*nbooks + 8 -> exact for all 24 game setups.
x64: fitted (alloc + 8*nbooks + 40 + 52*nfloors); not read by the 32-bit game.
"""
a = setup_alloc(ww_setup, channels, lib)
nbooks = ww_setup[0] + 1
alloc = 0x30 + a["total"] + 4 * nbooks + 8
nfloors = a["floors_count"]
return alloc, alloc + 8 * nbooks + 40 + 52 * nfloors

136
ackit/audio.py Normal file
View File

@@ -0,0 +1,136 @@
"""Portable audio helpers (no external programs needed).
Audio is handled as float32 numpy arrays shaped (frames, channels).
Decoding uses libsndfile (WAV, FLAC, OGG/Vorbis/Opus, MP3, AIFF, ...); other formats (M4A, WMA, ...)
fall back to an ffmpeg found on the system, if any. Resampling uses soxr.
"""
import io
import os
import shutil
import subprocess
import numpy as np
import soundfile as sf
READABLE = (".wav", ".flac", ".ogg", ".oga", ".opus", ".mp3", ".aif", ".aiff", ".au", ".w64", ".caf")
FFMPEG_ONLY = (".m4a", ".aac", ".wma", ".mp4", ".webm", ".mkv", ".ac3")
class AudioError(Exception):
pass
def _as2d(x):
x = np.asarray(x, dtype=np.float32)
return x[:, None] if x.ndim == 1 else x
def load(path):
"""Read an audio file -> (float32 array (n, ch), rate)."""
try:
x, sr = sf.read(path, dtype="float32", always_2d=True)
return x, sr
except (sf.LibsndfileError, RuntimeError, TypeError) as e:
ff = shutil.which("ffmpeg")
if not ff:
raise AudioError("Cannot read '%s' (%s). Supported: WAV, FLAC, OGG, OPUS, MP3, AIFF. "
"Convert the file to one of these, or install ffmpeg for other formats."
% (os.path.basename(path), e))
raw = subprocess.run([ff, "-v", "error", "-i", path, "-f", "wav", "-"], capture_output=True)
if raw.returncode:
raise AudioError("ffmpeg could not read '%s': %s" % (path, raw.stderr.decode(errors="replace")[-300:]))
x, sr = sf.read(io.BytesIO(raw.stdout), dtype="float32", always_2d=True)
return x, sr
def save_wav(path, x, rate):
sf.write(path, _as2d(x), rate, subtype="PCM_16")
def resample(x, rate, new_rate):
x = _as2d(x)
if rate == new_rate or len(x) == 0:
return x
import soxr
return soxr.resample(x, rate, new_rate, quality="HQ").astype(np.float32)
def to_channels(x, channels):
"""Up/down-mix. Mono is copied to every channel; extra channels get the average."""
x = _as2d(x)
cur = x.shape[1]
if cur == channels:
return x
if channels == 1:
return x.mean(axis=1, keepdims=True)
if cur == 1:
return np.repeat(x, channels, axis=1)
out = np.empty((len(x), channels), dtype=np.float32)
keep = min(cur, channels)
out[:, :keep] = x[:, :keep]
if channels > keep:
out[:, keep:] = x.mean(axis=1, keepdims=True)
return out
def trim(x, rate, start=None, end=None):
x = _as2d(x)
a = int(round((start or 0) * rate))
b = len(x) if end is None else int(round(end * rate))
return x[max(0, a):max(a, min(len(x), b))]
def gain(x, db):
return (_as2d(x) * (10 ** (db / 20.0))).astype(np.float32)
def peak_db(x):
p = float(np.max(np.abs(x))) if len(x) else 0.0
return -120.0 if p <= 0 else 20 * np.log10(p)
def rms_db(x):
r = float(np.sqrt(np.mean(np.square(x)))) if len(x) else 0.0
return -120.0 if r <= 0 else 20 * np.log10(r)
def normalize(x, peak=-1.0):
"""Scale so the highest peak is `peak` dBFS."""
p = peak_db(x)
return _as2d(x) if p <= -119 else gain(x, peak - p)
def match_loudness(x, reference):
"""Scale x to the RMS loudness of `reference` (limited so it does not clip)."""
g = rms_db(reference) - rms_db(x)
g = min(g, -0.3 - peak_db(x))
return gain(x, g)
def trim_silence(x, threshold_db=-50.0, pad=0.02, rate=48000):
x = _as2d(x)
lvl = np.max(np.abs(x), axis=1)
idx = np.nonzero(lvl > 10 ** (threshold_db / 20.0))[0]
if len(idx) == 0:
return x
p = int(pad * rate)
return x[max(0, idx[0] - p):min(len(x), idx[-1] + p)]
def conform(x, rate, new_rate, channels):
"""Resample + channel conversion + clip guard, ready for encoding."""
y = to_channels(resample(x, rate, new_rate), channels)
return np.clip(y, -1.0, 1.0)
def decode_wem(data: bytes):
"""Decode a game WEM -> (float32 array (n, ch), rate), using the game's codebooks."""
from .convert import wem_to_ogg
from .wem import Wem
ogg, _ = wem_to_ogg(Wem.parse(data))
x, sr = sf.read(io.BytesIO(ogg), dtype="float32", always_2d=True)
return x, sr
def duration(x, rate):
return len(x) / float(rate) if rate else 0.0

67
ackit/bits.py Normal file
View File

@@ -0,0 +1,67 @@
"""LSB-first bit reader/writer (Vorbis bit packing order)."""
class BitReader:
def __init__(self, data: bytes, bitpos: int = 0):
self.data = data
self.pos = bitpos
def get(self, n: int) -> int:
v = 0
for i in range(n):
byte = self.data[(self.pos >> 3)] if (self.pos >> 3) < len(self.data) else 0
v |= ((byte >> (self.pos & 7)) & 1) << i
self.pos += 1
return v
def bits_left(self) -> int:
return len(self.data) * 8 - self.pos
class BitWriter:
def __init__(self):
self.buf = bytearray()
self.pos = 0
def put(self, n: int, v: int):
for i in range(n):
if (self.pos >> 3) >= len(self.buf):
self.buf.append(0)
if (v >> i) & 1:
self.buf[self.pos >> 3] |= 1 << (self.pos & 7)
self.pos += 1
def put_bits_from(self, r: BitReader, n: int):
"""Copy n bits from a reader (fast path in 32-bit chunks)."""
while n > 0:
k = 32 if n >= 32 else n
self.put(k, r.get(k))
n -= k
def bytes(self) -> bytes:
return bytes(self.buf)
def ilog(v: int) -> int:
r = 0
while v:
r += 1
v >>= 1
return r
def book_maptype1_quantvals(entries: int, dims: int) -> int:
bits = ilog(entries)
vals = entries >> ((bits - 1) * (dims - 1) // dims)
while True:
acc = 1
acc1 = 1
for _ in range(dims):
acc *= vals
acc1 *= vals + 1
if acc <= entries < acc1:
return vals
if acc > entries:
vals -= 1
else:
vals += 1

128
ackit/builder.py Normal file
View File

@@ -0,0 +1,128 @@
"""Build an Arkham City compatible WEM from a standard Ogg Vorbis stream.
Field rules were derived from all 2467 WEMs of the ITA WAD and are validated by
rebuilding original files byte-for-byte (scripts/validate_builder.py).
"""
import struct
from .ogg import read_packets
from .wem import Wem, Chunk
from .wwvorbis import V2W, transcode_setup, vorbis_packet_to_ww
from .convert import packet_samples, library
from .known_setups import lookup_setup
from .allocsize import fnv1_32, decode_alloc
SEEK_INTERVAL = 8192
def build_seek_table(pkt_offsets, pkt_end_granules, interval=SEEK_INTERVAL):
"""pkt_offsets: offset of each audio packet relative to the setup start (= post seek-table)."""
out = []
prev_g, prev_o = 0, 0
target = interval
for off, g in zip(pkt_offsets, pkt_end_granules):
if g >= target:
out.append((g - prev_g, off - prev_o))
prev_g, prev_o = g, off
target = g + interval
return out
def build_wem(channels, rate, bs0_exp, bs1_exp, ww_setup, ww_packets, blockflags, total_frames,
codebook_hash, decode_alloc, decode_alloc_x64, channel_mask, seek=True, seek_interval=SEEK_INTERVAL, valid_bits=0x18,
extra_chunks=()):
samples = packet_samples(blockflags, 1 << bs0_exp, 1 << bs1_exp)
cum = []
g = 0
for s in samples:
g += s
cum.append(g)
last_extra = g - total_frames
if not 0 <= last_extra < (1 << bs1_exp):
raise ValueError("inconsistent total_frames (extra=%d)" % last_extra)
setup_blob = struct.pack("<H", len(ww_setup)) + ww_setup
offs = []
pos = len(setup_blob)
audio = bytearray()
for p in ww_packets:
if len(p) > 0xFFFF:
raise ValueError("packet too large")
offs.append(pos)
audio += struct.pack("<H", len(p)) + p
pos += 2 + len(p)
seek_entries = build_seek_table(offs, cum, seek_interval) if seek else []
for dg, do in seek_entries:
if dg > 0xFFFF or do > 0xFFFF:
raise ValueError("seek delta overflow")
seek_blob = b"".join(struct.pack("<HH", a, b) for a, b in seek_entries)
data = seek_blob + setup_blob + bytes(audio)
st = len(seek_blob)
w = Wem([Chunk(b"fmt ", b"")] + list(extra_chunks) + [Chunk(b"data", data)])
w.channels = channels
w.rate = rate
w.avg_bps = (len(data) - st) * rate // total_frames
w.valid_bits = valid_bits
w.channel_mask = channel_mask
w.vorb = dict(total_frames=total_frames,
loop_start_pkt=len(setup_blob),
loop_end_pkt=len(data) - st,
loop_begin_extra=0,
loop_end_extra=last_extra,
seek_table_size=st,
vorbis_data_offset=st + len(setup_blob),
max_packet_size=max(len(p) for p in ww_packets),
last_granule_extra=last_extra,
decode_alloc=decode_alloc,
decode_alloc_x64=decode_alloc_x64,
codebook_hash=codebook_hash,
bs0_exp=bs0_exp, bs1_exp=bs1_exp)
return w.to_bytes()
DEFAULT_MASKS = {1: 0x4, 2: 0x3, 4: 0x33, 5: 0x37, 6: 0x3F}
def ogg_to_wem(ogg: bytes, total_frames=None, allow_unknown_setup=True):
"""Convert a libvorbis Ogg stream into a game-compatible WEM (bytes)."""
lib = library()
pk = read_packets(ogg)
idh = pk[0][0]
if idh[:7] != b"\x01vorbis":
raise ValueError("not Ogg Vorbis")
channels = idh[11]
rate = struct.unpack_from("<I", idh, 12)[0]
bs0_exp, bs1_exp = idh[28] & 15, idh[28] >> 4
ww_setup, info = transcode_setup(pk[2][0], V2W, channels, lib)
ww_packets, flags = [], []
last_gran = None
for p, g in pk[3:]:
if not p:
continue
wp, bf = vorbis_packet_to_ww(p, info, bs0_exp != bs1_exp)
ww_packets.append(wp)
flags.append(bf)
if g is not None:
last_gran = g
if total_frames is None:
total_frames = last_gran
hdr = setup_header_values(ww_setup, channels, allow_unknown_setup)
return build_wem(channels, rate, bs0_exp, bs1_exp, ww_setup, ww_packets, flags, total_frames,
hdr["hash"], hdr["alloc"], hdr["alloc64"],
DEFAULT_MASKS.get(channels, (1 << channels) - 1))
def setup_header_values(ww_setup: bytes, channels: int, allow_unknown_setup=True) -> dict:
"""hash/alloc values for a setup: from the game's own files when known, else computed
with the reversed formulas (identical results on all known setups)."""
known = lookup_setup(ww_setup)
h = fnv1_32(ww_setup)
alloc, alloc64 = decode_alloc(ww_setup, channels, library())
if known is not None:
if (known["hash"], known["alloc"]) != (h, alloc):
raise AssertionError("formula mismatch on known setup")
return dict(hash=known["hash"], alloc=known["alloc"], alloc64=known["alloc64"], known=True)
if not allow_unknown_setup:
raise ValueError("setup not used by any game file")
return dict(hash=h, alloc=alloc, alloc64=alloc64, known=False)

262
ackit/catalog.py Normal file
View File

@@ -0,0 +1,262 @@
"""Index of every sound in the game, with names, categories and characters.
Sources, in order:
1. data/catalog/<ARCHIVE>.csv -- shipped catalog (instant) when the WAD fingerprint matches;
2. otherwise a header scan of the WAD (a few seconds, cached in the user cache), classified with
`classify` and labelled with data/catalog/characters.csv (speaker per sound name).
Names follow the community hash list (ackit/data/hashlist.txt): an entry with one WEM is NAME
(alias NAME_1), entries with n variations are NAME_1..NAME_n (alias NAME#i); stream-pack media are
named by their ID or <Pack>_<ID>.
"""
import csv
import difflib
import json
import os
import re
import struct
from dataclasses import dataclass, asdict
from . import classify
from .game import wad_fingerprint
from .inject import pack_bnk_path
from .pack import Pack
from .paths import catalog_dir, package_data, user_dir
from .wad import Wad
FIELDS = ["archive", "target", "name", "aliases", "group", "category", "character",
"characters_inferred", "seconds", "rate", "channels", "size", "sha1", "notes"]
SCAN_VERSION = 1
@dataclass
class Sound:
archive: str
target: str
name: str
aliases: tuple = ()
group: str = "Unknown"
category: str = "Unknown"
character: str = "" # verified speaker(s), '; '-separated (curated catalog)
characters_inferred: str = "" # characters/roles mentioned by the name (hint)
seconds: float = 0.0
rate: int = 0
channels: int = 0
size: int = 0 # size of the ORIGINAL WEM (bytes) when known from the catalog
sha1: str = "" # first 16 hex digits of the original WEM's SHA-1 ('' if unknown)
notes: str = ""
@property
def key(self):
return "%s:%s" % (self.archive, self.target)
@property
def is_pack_media(self):
return "/" in self.target
@property
def limit(self):
"""Max replacement size in bytes (None = any size)."""
return None if self.is_pack_media else self.size
def names(self):
return (self.name,) + tuple(self.aliases)
# --- static data ---------------------------------------------------------------------------
_names = None
def hash_names():
global _names
if _names is None:
_names = {}
for ln in open(package_data("hashlist.txt"), encoding="latin-1"):
p = ln.rstrip("\r\n").split("\t")
if len(p) >= 2 and p[1]:
try:
_names[int(p[0], 16)] = p[1]
except ValueError:
pass
return _names
def load_characters(path=None):
"""{sound name (lower): 'Speaker' or 'A; B'} from data/catalog/characters.csv."""
path = path or os.path.join(catalog_dir(), "characters.csv")
out = {}
if os.path.exists(path):
for row in csv.DictReader(open(path, encoding="utf-8")):
out[row["name"].lower()] = row["character"]
return out
def read_csv(path):
out = []
for r in csv.DictReader(open(path, encoding="utf-8", newline="")):
out.append(Sound(archive=r["archive"], target=r["target"], name=r["name"],
aliases=tuple(a for a in r["aliases"].split("|") if a), group=r["group"],
category=r["category"], character=r["character"],
characters_inferred=r["characters_inferred"], seconds=float(r["seconds"] or 0),
rate=int(r["rate"] or 0), channels=int(r["channels"] or 0), size=int(r["size"] or 0),
sha1=r["sha1"], notes=r["notes"]))
return out
def write_csv(path, sounds):
with open(path, "w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, FIELDS, lineterminator="\n")
w.writeheader()
for s in sounds:
d = asdict(s)
d["aliases"] = "|".join(s.aliases)
d["seconds"] = "%.3f" % s.seconds
w.writerow({k: d[k] for k in FIELDS})
# --- scanning ------------------------------------------------------------------------------
def _hdr(b):
"""(rate, channels, seconds) from the first bytes of a WEM (fmt is always the first chunk)."""
if len(b) >= 0x30 and b[:4] == b"RIFF" and b[12:16] == b"fmt ":
ch, rate = struct.unpack_from("<HI", b, 22)
frames = struct.unpack_from("<I", b, 44)[0]
return rate, ch, (frames / float(rate) if rate else 0.0)
return 0, 0, 0.0
def _walk_entry(f, offset, size):
"""Offsets/lengths of the WEMs inside one WAD entry, reading only headers."""
out = []
pos = 0
while pos + 12 <= size:
f.seek(offset + pos)
h = f.read(0x30)
if h[:4] != b"RIFF" or h[8:12] != b"WAVE":
break
ln = 8 + struct.unpack_from("<I", h, 4)[0]
out.append((pos, ln, h))
# next WEM starts at the first non-zero byte after this one (zero padding in between)
cand = pos + ln
f.seek(offset + cand)
nxt = None
while cand < size and nxt is None:
chunk = f.read(min(0x10000, size - cand))
if not chunk:
break
stripped = chunk.lstrip(b"\0")
if stripped:
nxt = cand + len(chunk) - len(stripped)
cand += len(chunk)
if nxt is None:
break
pos = nxt
return out
def scan_archive(archive, wad_path, characters=None, progress=None):
"""Header scan of one WAD -> [Sound]."""
names = hash_names()
characters = characters if characters is not None else load_characters()
wad = Wad(wad_path)
out = []
with open(wad_path, "rb") as f:
for n, e in enumerate(wad.entries):
if progress and n % 200 == 0:
progress(n, len(wad.entries))
bnk = pack_bnk_path(wad_path, e.hash)
if bnk and open(bnk, "rb").read(4) == b"BKPK":
pk = Pack(open(bnk, "rb").read())
pname = names.get(e.hash, "%08X" % e.hash)
for mid, size, off in pk.entries:
f.seek(e.offset + off)
rate, ch, secs = _hdr(f.read(0x30))
out.append(Sound(archive, "%08X/%08X" % (e.hash, mid), names.get(mid, "%s_%08X" % (pname, mid)),
seconds=secs, rate=rate, channels=ch, size=size))
out[-1].group, out[-1].category = classify.classify(out[-1].name, pack=pname)
continue
wems = _walk_entry(f, e.offset, e.size)
base = names.get(e.hash, "%08X" % e.hash)
for i, (off, ln, h) in enumerate(wems):
rate, ch, secs = _hdr(h)
if len(wems) == 1:
s = Sound(archive, "%08X" % e.hash, base, (base + "_1",))
else:
s = Sound(archive, "%08X:%d" % (e.hash, i), "%s_%d" % (base, i + 1), ("%s#%d" % (base, i + 1),))
s.seconds, s.rate, s.channels, s.size = secs, rate, ch, ln
s.group, s.category = classify.classify(s.name)
out.append(s)
for s in out:
s.characters_inferred = "; ".join(classify.infer_characters(s.name))
s.character = next((characters[a.lower()] for a in s.names() if a.lower() in characters), "")
return out
# --- catalog -------------------------------------------------------------------------------
def _shipped_index():
p = os.path.join(catalog_dir(), "index.json")
try:
return json.load(open(p))
except (OSError, ValueError):
return {}
def load_archive(game, archive, progress=None):
"""[Sound] for one archive of a game (shipped CSV, user-cache scan, or fresh scan)."""
path = game.wad(archive)
fp = wad_fingerprint(path)
shipped = _shipped_index().get(archive, {})
csv_path = os.path.join(catalog_dir(), "%s.csv" % archive)
if shipped.get("fingerprint") == fp and os.path.exists(csv_path):
return read_csv(csv_path)
cache = os.path.join(user_dir("cache"), "scan_%s_%s_v%d.csv" % (archive, fp, SCAN_VERSION))
if os.path.exists(cache):
return read_csv(cache)
sounds = scan_archive(archive, path, progress=progress)
# unknown build: no original checksums (states then rely on the originals store only)
write_csv(cache, sounds)
return sounds
class Catalog:
"""All sounds of the selected archives (default: the game's speech language + SFX)."""
def __init__(self, game, archives=None, progress=None):
self.game = game
self.archives = archives or [a for a in (game.language(), "SFX") if a in game.archives]
self.sounds = []
for a in self.archives:
self.sounds += load_archive(game, a, progress)
self.by_key = {s.key: s for s in self.sounds}
self._by_name = {}
for s in self.sounds:
for n in s.names():
self._by_name.setdefault(n.lower(), []).append(s)
def get(self, key):
return self.by_key[key]
def resolve(self, name):
"""One sound by name, alias, key ('ITA:B49CDF48:11') or file name ('X_3.wav')."""
if name in self.by_key:
return self.by_key[name]
key = os.path.splitext(os.path.basename(name))[0].lower() if re.search(r"\.\w{2,4}$", name) else name.lower()
hits = self._by_name.get(key, [])
uniq = list({id(s): s for s in hits}.values())
if len(uniq) == 1:
return uniq[0]
if len(uniq) > 1:
raise LookupError("'%s' is ambiguous; use one of: %s" % (name, ", ".join(
next((a for a in s.names() if a.lower() != key), s.key) for s in uniq)))
near = [s.name for s in self.search(key, limit=8)] or self.suggest(key)
raise LookupError("no sound named '%s'.%s" % (name, ("\n did you mean: " + ", ".join(near)) if near else ""))
def search(self, text, limit=None):
"""All words must appear in the name, category or character (case-insensitive)."""
words = [w for w in re.split(r"[\s_]+", text.lower()) if w]
res = [s for s in self.sounds
if all(w in s.name.lower() or w in s.category.lower() or w in s.character.lower() for w in words)]
res.sort(key=lambda s: (len(s.name), s.name))
return res[:limit] if limit else res
def suggest(self, text, n=8):
names = {s.name.lower(): s.name for s in self.sounds}
return [names[k] for k in difflib.get_close_matches(text.lower(), list(names), n=n, cutoff=0.6)]

107
ackit/classify.py Normal file
View File

@@ -0,0 +1,107 @@
"""Classify sounds into group / category and infer characters from Wwise names.
Scheme (documented in docs/categories.md):
group Speech | Music | Ambience | SFX | Unknown
category finer bucket derived from the name prefix (e.g. "Interview tapes", "Combat barks")
Characters: the authoritative speaker comes from the curated catalog (data/catalog/characters.csv,
built from the user's per-character lists). For other lines `infer_characters` returns the
characters/roles that the *name* mentions -- a hint, not a speaker label (conversations alternate
speakers between variations).
"""
import re
# (regex on the upper-case name, group, category) -- first match wins
_RULES = [
(r"^(CHR_RADIOSCANNER|PSO_CARRADIO|SPEECH_RADIO|SPEECH_TV|SPEECH_CAMERAS|DIA_)", "Speech", "Radio & TV"),
(r"^SPEECH_TAPE_", "Speech", "Interview tapes"),
(r"^SPEECH_INTERROGATION_", "Speech", "Interrogations"),
(r"^SPEECH_(CINEMATIC|NEW)_", "Speech", "Cinematics"),
(r"^SPEECH_BOSS_", "Speech", "Boss fights"),
(r"^SPEECH_RIDDLE(R|S)_", "Speech", "Riddler"),
(r"^SPEECH_SIDE_", "Speech", "Side missions"),
(r"^SPEECH_DLC_", "Speech", "DLC"),
(r"^SPEECH_PRED_", "Speech", "Predator chatter"),
(r"^SPEECH_(COMBAT|BULLY)_", "Speech", "Combat barks"),
(r"^SPEECH_EMOTE_", "Speech", "Efforts & emotes"),
(r"^SPEECH_REACT_", "Speech", "Reactions"),
(r"^SPEECH_(STREETS|ZONES|OUTCAST|VERTICAL)_", "Speech", "Open-world chatter"),
(r"^SPEECH_CHAR_", "Speech", "Character lines"),
(r"^SPEECH_(CH|SEQUENCE|STEEL|MUSEUM|CHURCH|COURT|UNDER|GCPD|ZP1)_", "Speech", "Story missions"),
(r"^SPEECH_(GAME|MAP|AUGMENTED|FORENSICS|TEMP)_", "Speech", "Gameplay & hints"),
(r"^NPC_", "Ambience", "Crowds"),
(r"^SPEECH_", "Speech", "Other speech"),
(r"^MUSIC_PDLC", "Music", "Music (DLC)"),
(r"^MUSIC", "Music", "Music"),
(r"^(AMB|PSA)_", "Ambience", "Ambience"),
(r"^WND_", "Ambience", "Wind"),
(r"^P?VOL_", "Ambience", "Room tone"),
(r"^SEQ_", "SFX", "Scripted sequences"),
(r"^OBJ_", "SFX", "Objects"),
(r"^WPN_", "SFX", "Weapons"),
(r"^CHR_", "SFX", "Character foley"),
(r"^GLOBAL", "SFX", "Global"),
]
_RULES = [(re.compile(p), g, c) for p, g, c in _RULES]
GROUPS = ("Speech", "Music", "Ambience", "SFX", "Unknown")
def classify(name: str, pack: str = None):
"""-> (group, category). `pack` = name of the stream pack a media belongs to, if any."""
n = name.upper()
if pack:
p = pack.upper()
if p.startswith("MUSIC"):
return "Music", "Music (DLC)" if "PDLC" in p else "Music"
for rx, g, c in _RULES:
if rx.search(n):
return g, c
return "Unknown", "Unknown"
# --- characters -------------------------------------------------------------------------------
# display names for the labels used in the user's catalog files (data/catalog/source/ita/*.txt)
USER_LABELS = {
"Alfred": "Alfred", "Azrael": "Azrael", "Bane": "Bane", "Batman": "Batman",
"Calendar": "Calendar Man", "Catwoman": "Catwoman", "Croc": "Killer Croc", "Deadshot": "Deadshot",
"Freeze": "Mr. Freeze", "Gordon": "Gordon", "Ivy": "Poison Ivy", "Joker": "Joker",
"Madhatter": "Mad Hatter", "Oracle": "Oracle", "Penguin": "Penguin", "Quinn": "Harley Quinn",
"Ras": "Ra's al Ghul", "Riddler": "Riddler", "Robin": "Robin", "Strange": "Hugo Strange",
"Talia": "Talia al Ghul", "Twoface": "Two-Face", "Unknown": "Unknown", "Vicky": "Vicki Vale",
"Zsazz": "Victor Zsasz",
}
# name tokens -> character / role (matched on whole "_"-separated tokens, digits ignored)
_TOKENS = {
"Batman": ("BATMAN", "BM"), "Catwoman": ("CATWOMAN", "CAT", "CW"), "Joker": ("JOKER",),
"Penguin": ("PENGUIN", "PENG"), "Two-Face": ("TWOFACE", "TF"), "Riddler": ("RIDDLER",),
"Mr. Freeze": ("FREEZE", "MRFREEZE", "FREEZESUIT"), "Poison Ivy": ("IVY",),
"Hugo Strange": ("STRANGE", "HUGO"), "Robin": ("ROBIN",), "Oracle": ("ORACLE",),
"Alfred": ("ALFRED", "ALF"), "Harley Quinn": ("QUINN", "HARLEY"), "Ra's al Ghul": ("RAS",),
"Talia al Ghul": ("TALIA",), "Victor Zsasz": ("ZSASZ", "ZSAZZ"), "Bane": ("BANE",),
"Deadshot": ("DEADSHOT",), "Mad Hatter": ("MADHATTER", "HATTER"), "Calendar Man": ("CALENDAR", "CM"),
"Hush": ("HUSH",), "Clayface": ("CLAYFACE", "CLAY"), "Solomon Grundy": ("GRUNDY",),
"Azrael": ("AZRAEL",), "Vicki Vale": ("VALE", "VICKI", "VICKY", "VIC"), "Gordon": ("GORDON",),
"Nightwing": ("NIGHTWING", "NW"), "Killer Croc": ("CROC",),
# roles
"Thug": ("THUG", "THUGS", "GENTHUG", "JTHUG", "JTHUGS", "PTHUG", "PTHUGS", "BULLY"),
"TYGER guard": ("TYGER",), "Ninja": ("NINJA", "ASSASSIN"), "Doctor": ("DOC", "DOCTOR"),
"Cop": ("COP", "COPS", "GCPD"), "Political prisoner": ("HOBO", "OUTCAST", "OC"),
"Hostage": ("HOST", "HOSTAGE"), "Guard": ("GUARD", "GUARDS"),
}
_TOK2CHAR = {t: c for c, toks in _TOKENS.items() for t in toks}
_STRIP_DIGITS = re.compile(r"\d+$")
def infer_characters(name: str):
"""Characters/roles mentioned by a sound name (ordered, unique). Empty if none."""
n = name.upper()
out = []
if n.startswith("SPEECH_TAPE_"):
out.append("Hugo Strange") # Strange's interview tapes
for tok in re.split(r"[_\s]+", n):
t = _STRIP_DIGITS.sub("", tok)
c = _TOK2CHAR.get(t) or _TOK2CHAR.get(tok)
if c and c not in out:
out.append(c)
return out

335
ackit/cli.py Normal file
View File

@@ -0,0 +1,335 @@
"""ackit - Batman: Arkham City audio toolkit (command line).
Everyday use, by sound name (see `ackit find`):
ackit find joker tape search names, categories, characters
ackit info Speech_TAPE_JOKER_1_4 details, size limit, modified state
ackit extract Speech_TAPE_JOKER_1_4 orig.wav save the original (or --current)
ackit replace Speech_TAPE_JOKER_1_4 line.wav encode + install a replacement
ackit revert Speech_TAPE_JOKER_1_4 put the original back (--all for everything)
ackit status what you have changed
ackit export-mod my.acmod / import-mod my.acmod share your changes
The game folder is detected from Steam (Windows, Linux, Proton); override with --game DIR.
"""
import argparse
import os
import sys
from . import audio, settings
from .game import Game, GameError, find_installs, default_game_dir
def _workspace(a, archives=None):
from .workspace import Workspace
st = settings.load()
gd = a.game or default_game_dir(st)
if not gd:
raise GameError("Arkham City was not found. Pass the install folder with --game DIR "
"(the folder that contains BmGame and Binaries).")
if a.game:
st["game_dir"] = os.path.abspath(a.game)
settings.save(st)
game = Game(gd)
if archives is None and getattr(a, "lang", None):
archives = [a.lang, "SFX"]
return Workspace(game, a.store or st.get("store_root"), archives,
full_backup=not getattr(a, "no_full_backup", False) and st.get("full_backup", True),
progress=lambda n, t: None)
def _row(s, ws=None):
mod = " *" if ws and s.key in ws.store.entries and ws.store.entries[s.key].get("applied") else ""
who = s.character or (("~" + s.characters_inferred) if s.characters_inferred else "")
return "%-46s %-4s %7.2fs %-20s %s%s" % (s.name, s.archive, s.seconds, s.category[:20], who[:28], mod)
# --- everyday ---------------------------------------------------------------------------------
def cmd_find(a):
ws = _workspace(a)
res = ws.catalog.search(" ".join(a.words))
if not res:
near = ws.catalog.suggest("_".join(a.words))
print("nothing found." + (" Did you mean: " + ", ".join(near) if near else ""))
return
for s in (res if a.all else res[:40]):
print(_row(s, ws))
if not a.all and len(res) > 40:
print("... %d more (--all)" % (len(res) - 40))
def cmd_list(a):
ws = _workspace(a)
rows = ws.catalog.sounds
if a.group:
rows = [s for s in rows if s.group.lower() == a.group.lower()]
if a.category:
rows = [s for s in rows if a.category.lower() in s.category.lower()]
if a.character:
c = a.character.lower()
rows = [s for s in rows if c in s.character.lower() or (a.inferred and c in s.characters_inferred.lower())]
if a.modified:
rows = [s for s in rows if s.key in ws.modified_keys()]
if a.csv:
from .catalog import write_csv
write_csv(a.csv, rows)
print("wrote %s (%d sounds)" % (a.csv, len(rows)))
return
for s in rows:
print(_row(s, ws))
print("%d sound(s)" % len(rows))
def cmd_info(a):
if a.name.lower().endswith(".wem") and os.path.exists(a.name):
from .wem import Wem
m = Wem.parse(open(a.name, "rb").read())
print("rate %d, channels %d, mask 0x%x" % (m.rate, m.channels, m.channel_mask))
for k, v in m.vorb.items():
print(" %-20s %s" % (k, hex(v) if k == "codebook_hash" else v))
return
ws = _workspace(a)
s = ws.sound(a.name)
for k in ("name", "key", "group", "category", "character", "characters_inferred", "seconds", "rate",
"channels"):
print("%-20s %s" % (k, getattr(s, k)))
print("%-20s %s" % ("aliases", ", ".join(s.aliases)))
lim = ws.limit(s)
print("%-20s %s" % ("max size", "any" if lim is None else "%d bytes (the original's size)" % lim))
print("%-20s %s" % ("state", ws.state(s)))
def cmd_extract(a):
ws = _workspace(a)
s = ws.sound(a.name)
data = ws.current_bytes(s) if a.current else ws.original_bytes(s)
if a.out.lower().endswith(".wem"):
open(a.out, "wb").write(data)
else:
x, sr = audio.decode_wem(data)
audio.save_wav(a.out, x, sr)
print("wrote %s (%s, %.2f s)" % (a.out, s.name, s.seconds))
def _process(a, x, sr, ws, s):
if a.trim:
start, _, end = a.trim.partition(":")
x = audio.trim(x, sr, float(start or 0), float(end) if end else None)
if a.trim_silence:
x = audio.trim_silence(x, rate=sr)
if a.match_loudness:
ref, _ = ws.original_audio(s)
x = audio.match_loudness(x, ref)
if a.normalize:
x = audio.normalize(x)
if a.gain:
x = audio.gain(x, a.gain)
return x
def cmd_replace(a):
ws = _workspace(a)
s = ws.sound(a.name)
x, sr = audio.load(a.audio)
x = _process(a, x, sr, ws, s)
secs = audio.duration(x, sr)
if not s.is_pack_media and secs > s.seconds * 1.15 + 0.25:
print("warning: your audio (%.2f s) is longer than the original (%.2f s); it will be squeezed into the "
"original's size and the game may cut it off" % (secs, s.seconds))
wem, info = ws.prepare(s, x, sr, a.quality, progress=print)
ws.apply([(s, wem, {"source": os.path.basename(a.audio)})], force=a.force, progress=print)
print("replaced %s (%d Hz, q%.1f, %d bytes). Undo: ackit revert %s" % (s.name, info["rate"], info["quality"],
len(wem), s.name))
def cmd_apply(a):
ws = _workspace(a)
changes, errors = [], []
for f in sorted(os.listdir(a.folder)):
p = os.path.join(a.folder, f)
if not os.path.isfile(p) or f.startswith("."):
continue
try:
s = ws.sound(f)
x, sr = audio.load(p)
wem, _ = ws.prepare(s, x, sr, a.quality)
changes.append((s, wem, {"source": f}))
print("ok %-40s -> %s" % (f, s.name))
except (LookupError, ValueError, audio.AudioError) as e:
errors.append(f)
print("skip %-40s %s" % (f, e))
if changes:
ws.apply(changes, force=a.force)
print("%d applied, %d skipped" % (len(changes), len(errors)))
def cmd_revert(a):
ws = _workspace(a)
if a.all:
done = ws.revert_all(progress=print)
else:
done = ws.revert([ws.sound(n) for n in a.names], progress=print)
print("reverted %d sound(s)" % len(done))
def cmd_status(a):
ws = _workspace(a)
keys = ws.modified_keys()
if not keys:
print("no sounds modified; game audio is original")
return
for k in keys:
s = ws.catalog.by_key.get(k)
st = ws.state(s) if s else "(other language)"
print("%-50s %s" % (s.name if s else k, st))
def cmd_export_mod(a):
from .modpack import export_mod
ws = _workspace(a)
keys = [ws.sound(n).key for n in a.names] if a.names else ws.modified_keys()
n = export_mod(ws, a.out, keys, a.title or os.path.splitext(os.path.basename(a.out))[0], a.author or "",
a.description or "")
print("wrote %s (%d sounds)" % (a.out, n))
def cmd_import_mod(a):
from .modpack import import_mod
ws = _workspace(a)
for im in import_mod(ws, a.file, force=a.force, progress=print):
print("%-8s %-46s %s" % (im.how, im.item.get("name"), im.note))
# --- advanced ---------------------------------------------------------------------------------
def cmd_encode(a):
from .encoder import encode
if a.like:
ws = _workspace(a)
s = ws.sound(a.like)
rate, ch = s.rate, s.channels
else:
rate, ch = a.rate, a.channels
x, sr = audio.load(a.audio)
wem, info = encode(x, sr, rate, ch, a.quality)
open(a.out, "wb").write(wem)
print("wrote %s (%d Hz, %d ch, q%.1f, %d bytes)" % (a.out, info["rate"], ch, info["quality"], len(wem)))
def cmd_inject(a):
ws = _workspace(a)
s = ws.sound(a.name)
ws.apply([(s, open(a.wem, "rb").read(), {"source": os.path.basename(a.wem)})], force=a.force)
print("injected %s" % s.name)
def cmd_decode(a):
x, sr = audio.decode_wem(open(a.wem, "rb").read())
audio.save_wav(a.out, x, sr)
print("wrote %s (%.2f s, %d Hz, %d ch)" % (a.out, len(x) / sr, sr, x.shape[1]))
def cmd_game(a):
st = settings.load()
print("installs found:", ", ".join(find_installs()) or "none")
gd = a.game or default_game_dir(st)
if gd:
g = Game(gd)
from .workspace import default_store_root
print("game folder: ", g.root)
print("archives: ", ", ".join("%s=%s" % (k, g.rel(v)) for k, v in g.archives.items()))
print("speech language: ", g.language())
print("originals store: ", a.store or st.get("store_root") or default_store_root(g))
print("settings file: ", settings.path())
def cmd_verify(a):
from .verify import selfcheck
ws = _workspace(a)
if not a.ingame:
ok = selfcheck.run(ws, log=print)
sys.exit(0 if ok else 1)
from .verify import ingame
if a.baseline:
rep = ingame.run(ws, a.target, mode="baseline", seconds=a.seconds)
elif a.installed:
rep = ingame.run(ws, a.target, mode="installed", seconds=a.seconds)
else:
s = ws.sound(a.target)
x, sr = audio.load(a.audio) if a.audio else ingame.probe_signal()
wem, _ = ws.prepare(s, x, sr)
rep = ingame.run(ws, a.target, wem=wem, mode="modded", seconds=a.seconds)
for k, v in rep.items():
print("%-24s %s" % (k, v))
sys.exit(0 if rep["result"] == "PASS" else 1)
def build_parser():
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--game", metavar="DIR", help="Arkham City install folder (remembered)")
common.add_argument("--lang", type=str.upper, help="speech language archive, e.g. ITA, INT (default: game setting)")
common.add_argument("--store", metavar="DIR", help="originals store folder (default: user data folder)")
enc = argparse.ArgumentParser(add_help=False)
enc.add_argument("--quality", default="auto", help="auto (default) or libvorbis quality 0..10")
enc.add_argument("--force", action="store_true", help="overwrite sounds changed outside this tool")
enc.add_argument("--no-full-backup", action="store_true", help="skip the one-time full WAD backup")
proc = argparse.ArgumentParser(add_help=False)
proc.add_argument("--trim", metavar="START:END", help="keep only this part (seconds), e.g. 0.5:4.2")
proc.add_argument("--trim-silence", action="store_true", help="cut leading/trailing silence")
proc.add_argument("--gain", type=float, metavar="DB", help="volume change in dB")
proc.add_argument("--normalize", action="store_true", help="peak-normalize to -1 dBFS")
proc.add_argument("--match-loudness", action="store_true", help="match the original's loudness")
p = argparse.ArgumentParser(prog="ackit", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sp = p.add_subparsers(dest="cmd", required=True, metavar="COMMAND")
def add(name, f, help, parents=(), **kw):
s = sp.add_parser(name, parents=[common] + list(parents), help=help, description=help, **kw)
s.set_defaults(f=f)
return s
s = add("find", cmd_find, "search sounds by name, category or character")
s.add_argument("words", nargs="+"); s.add_argument("--all", action="store_true")
s = add("list", cmd_list, "list sounds with filters (or export them as CSV)")
s.add_argument("--group"); s.add_argument("--category"); s.add_argument("--character")
s.add_argument("--inferred", action="store_true", help="also match characters inferred from names")
s.add_argument("--modified", action="store_true"); s.add_argument("--csv", metavar="FILE")
s = add("info", cmd_info, "show details of a sound (or of a .wem file)")
s.add_argument("name")
s = add("extract", cmd_extract, "save a sound as .wav (decoded) or .wem")
s.add_argument("name"); s.add_argument("out"); s.add_argument("--current", action="store_true",
help="the installed version instead of the original")
s = add("replace", cmd_replace, "replace a sound with an audio file (WAV/FLAC/OGG/MP3/...)", (enc, proc))
s.add_argument("name"); s.add_argument("audio")
s = add("apply", cmd_apply, "replace every sound named by the files in a folder", (enc,))
s.add_argument("folder")
s = add("revert", cmd_revert, "put original sounds back", aliases=["restore"])
s.add_argument("names", nargs="*"); s.add_argument("--all", action="store_true")
add("status", cmd_status, "list the sounds you modified")
s = add("export-mod", cmd_export_mod, "save your changes as a shareable .acmod package")
s.add_argument("out"); s.add_argument("names", nargs="*", help="default: everything you modified")
s.add_argument("--title"); s.add_argument("--author"); s.add_argument("--description")
s = add("import-mod", cmd_import_mod, "install a .acmod package", (enc,))
s.add_argument("file")
add("game", cmd_game, "show the detected game folder, archives, language and store")
s = add("verify", cmd_verify, "self-check (offline), or test in the real game with --ingame (Windows)")
s.add_argument("--ingame", action="store_true"); s.add_argument("--target", default="SFX:EDF036D6/393BC53A")
s.add_argument("--audio"); s.add_argument("--baseline", action="store_true")
s.add_argument("--installed", action="store_true"); s.add_argument("--seconds", type=float, default=40)
# advanced
s = add("encode", cmd_encode, "[advanced] audio -> .wem file", (enc,))
s.add_argument("audio"); s.add_argument("out"); s.add_argument("--like", metavar="NAME")
s.add_argument("--rate", type=int, default=28000); s.add_argument("--channels", type=int, default=1)
s = add("inject", cmd_inject, "[advanced] install a prepared .wem file", (enc,))
s.add_argument("name"); s.add_argument("wem")
s = add("decode", cmd_decode, "[advanced] .wem file -> .wav")
s.add_argument("wem"); s.add_argument("out")
return p
def main(argv=None):
a = build_parser().parse_args(argv)
try:
a.f(a)
except (GameError, LookupError, ValueError, audio.AudioError) as e:
sys.exit("error: %s" % e)
except PermissionError as e:
sys.exit("error: cannot write %s - close the game and try again" % getattr(e, "filename", "a game file"))

118
ackit/codebooks.py Normal file
View File

@@ -0,0 +1,118 @@
"""Wwise packed codebook library (ww2ogg 'packed_codebooks.bin' format).
The game's built-in table (runtime 0x0158E748, 598 entries) is byte-identical to
ww2ogg's standard packed_codebooks.bin; see docs/wem_format.md.
"""
import os
import struct
from .bits import BitReader, BitWriter, ilog, book_maptype1_quantvals
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_LIB = os.path.join(HERE, "data", "game_codebooks.bin")
def load_library(path: str = DEFAULT_LIB) -> list:
b = open(path, "rb").read()
table = struct.unpack_from("<I", b, len(b) - 4)[0]
n = (len(b) - table) // 4
offs = struct.unpack_from("<%dI" % n, b, table)
return [b[offs[i]:offs[i + 1]] for i in range(n - 1)]
def save_library(path: str, books: list):
out = bytearray()
offs = []
for bk in books:
offs.append(len(out))
out += bk
offs.append(len(out)) # table start == end of last book
out += struct.pack("<%dI" % len(offs), *offs)
open(path, "wb").write(bytes(out))
def wwise_book_to_vorbis(blob: bytes, w: BitWriter):
"""Expand a compact Wwise codebook into a standard Vorbis codebook (ww2ogg rebuild)."""
r = BitReader(blob)
w.put(24, 0x564342)
dims = r.get(4); w.put(16, dims)
entries = r.get(14); w.put(24, entries)
ordered = r.get(1); w.put(1, ordered)
if ordered:
w.put(5, r.get(5))
cur = 0
while cur < entries:
nb = ilog(entries - cur)
num = r.get(nb); w.put(nb, num)
cur += num
else:
cwll = r.get(3)
sparse = r.get(1); w.put(1, sparse)
if cwll == 0 or cwll > 5:
raise ValueError("bad codeword length length")
for _ in range(entries):
present = 1
if sparse:
present = r.get(1); w.put(1, present)
if present:
w.put(5, r.get(cwll))
lookup = r.get(1); w.put(4, lookup)
if lookup == 1:
w.put(32, r.get(32)); w.put(32, r.get(32))
vlen = r.get(4); w.put(4, vlen)
w.put(1, r.get(1))
for _ in range(book_maptype1_quantvals(entries, dims)):
w.put(vlen + 1, r.get(vlen + 1))
elif lookup != 0:
raise ValueError("bad lookup type")
return r.pos
def skip_vorbis_book(r: BitReader):
"""Advance reader past one standard Vorbis codebook; returns (dims, entries)."""
if r.get(24) != 0x564342:
raise ValueError("codebook sync lost")
dims = r.get(16)
entries = r.get(24)
if r.get(1): # ordered
r.get(5)
cur = 0
while cur < entries:
cur += r.get(ilog(entries - cur))
else:
sparse = r.get(1)
for _ in range(entries):
if not sparse or r.get(1):
r.get(5)
lookup = r.get(4)
if lookup in (1, 2):
r.get(32); r.get(32)
vlen = r.get(4); r.get(1)
n = book_maptype1_quantvals(entries, dims) if lookup == 1 else entries * dims
r.pos += n * (vlen + 1)
elif lookup != 0:
raise ValueError("bad lookup type %d" % lookup)
return dims, entries
def book_bits_key(data: bytes, start: int, end: int) -> tuple:
"""Hashable representation of a bit range [start,end)."""
n = end - start
v = int.from_bytes(data, "little") >> start
return (n, v & ((1 << n) - 1))
class CodebookLibrary:
def __init__(self, path: str = DEFAULT_LIB):
self.books = load_library(path)
self.by_vorbis = {}
self.vorbis = []
for i, blob in enumerate(self.books):
w = BitWriter()
wwise_book_to_vorbis(blob, w)
key = (w.pos, int.from_bytes(w.bytes(), "little") & ((1 << w.pos) - 1))
self.vorbis.append(key)
self.by_vorbis.setdefault(key, i)
def lookup(self, key):
return self.by_vorbis.get(key)

56
ackit/convert.py Normal file
View File

@@ -0,0 +1,56 @@
"""WEM <-> Ogg Vorbis conversion using the game's codebook library."""
from .codebooks import CodebookLibrary
from .ogg import OggWriter
from .wem import Wem
from .wwvorbis import (W2V, transcode_setup, build_id_header, build_comment_header,
ww_packet_to_vorbis)
_LIB = None
def library() -> CodebookLibrary:
global _LIB
if _LIB is None:
_LIB = CodebookLibrary()
return _LIB
def packet_samples(blockflags, bs0, bs1):
"""Samples produced by each packet (Vorbis overlap-add rule)."""
out = []
prev = None
for bf in blockflags:
cur = bs1 if bf else bs0
out.append(0 if prev is None else (prev // 4 + cur // 4))
prev = cur
return out
def wem_to_ogg(wem: Wem):
"""Returns (ogg_bytes, details dict)."""
lib = library()
v = wem.vorb
setup, info = transcode_setup(wem.setup_packet(), W2V, wem.channels, lib)
pkts, end = wem.audio_packets()
out_pkts = []
flags = []
prev_bf = 0
for i, (_, p) in enumerate(pkts):
nxt = pkts[i + 1][1][0] if i + 1 < len(pkts) else None
op, bf = ww_packet_to_vorbis(p, nxt, info, prev_bf, v["bs0_exp"] != v["bs1_exp"])
out_pkts.append(op)
flags.append(bf)
prev_bf = bf
bs0, bs1 = 1 << v["bs0_exp"], 1 << v["bs1_exp"]
samples = packet_samples(flags, bs0, bs1)
ow = OggWriter()
ow.page([build_id_header(wem.channels, wem.rate, v["bs0_exp"], v["bs1_exp"])], 0, bos=True)
ow.page([build_comment_header(), setup], 0)
g = 0
total = v["total_frames"]
for i, op in enumerate(out_pkts):
g += samples[i]
last = i == len(out_pkts) - 1
ow.page([op], min(g, total) if last else g, eos=last)
return ow.bytes(), dict(info=info, flags=flags, samples=samples, packets=pkts, data_end=end,
sum_samples=sum(samples))

Binary file not shown.

19063
ackit/data/hashlist.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,278 @@
[
{
"setup": "1f5482728054b348c111846215e38891cdcd019f34495c82560edc58311c610c7e714201ef9817e31e41800920304050f08510102382109921120aab608141193438cc03800788088900203141917671015d06b8a08bbb0e8410842004b1388002127070c20d4fbce109373841a7a8d4410000008000000f000007051011d15c85c5054686c606478747000000c003001f0000c7071011d15c85c5054686c6064787470000000000101010001800",
"hash": 2329567961,
"alloc": 15492,
"alloc64": 15892,
"count": 8819,
"formats": {
"28000/1ch/8-11": 8593,
"28000/5ch/8-11": 176,
"28000/4ch/8-11": 50
},
"conflict": false
},
{
"setup": "1f5482728054b348c111846215e38891cdcd019f34495c82560edc58311c610c7e714201ef9817e31e41800920304050f08510102382109921120aab608141193438cc03800788088900203141917671815906b8a08bbb0e841084200495378002127070c20d4fbce109373289a6288c410000006000000f000007051011d15c85c5054686c606478747000000e002001f0000c7071011d15c85c5054686c6064787470000000000101010001800",
"hash": 1136153196,
"alloc": 15492,
"alloc64": 15892,
"count": 2338,
"formats": {
"44100/5ch/8-11": 70,
"44100/1ch/8-11": 2242,
"44100/4ch/8-11": 8,
"44100/3ch/8-11": 18
},
"conflict": false
},
{
"setup": "16e5805702114f621065896afd16605711f468c4647a39075522fc21a90d6020426602810228309001000708095200406181a174a10b4288205d04593c70e1c48d276e38a1430b040000001c00f001007030001111cd35383c4044484c50545800000000000000",
"hash": 953446082,
"alloc": 13464,
"alloc64": 13740,
"count": 631,
"formats": {
"24000/1ch/10-10": 568,
"24000/5ch/10-10": 59,
"24000/4ch/10-10": 4
},
"conflict": false
},
{
"setup": "22870c218c290e39866353e5805702114f621065896afd166057e44d970f4a4af9925032e76c31937c35b1e45720202400c00041c10c00303840183910e8082070680d44c84c6050080d0e3201e00122422a00484c50942e74410811a48b208b072e9cb8f1c40d277468830000000002003e0000120a2022229ab90a8b0b8c0c8d0d8e0e8f0f90900000000004003e000012112022229ab90a8b0b8c0c8d0d8e0e8f0f90900000000000202020003000",
"hash": 3571053844,
"alloc": 17692,
"alloc64": 18116,
"count": 436,
"formats": {
"24000/4ch/9-10": 92,
"24000/1ch/9-10": 219,
"24000/5ch/9-10": 125
},
"conflict": false
},
{
"setup": "1f5482728054b348c111846215e38891cdcd019f34495c82560edc58311c610c7e714201ef9817e31e41800920304050f08510102382109921120aab608141193438cc03800788088900203141917671015d06b8a08bbb0e8410842004b1388002127070c20d4fbce109373841a7a8d4410000007000000f000007051011d15c85c5054686c6064787470000004003001f0000c7071011d15c85c5054686c6064787470000000000101010001800",
"hash": 2903324457,
"alloc": 15492,
"alloc64": 15892,
"count": 71,
"formats": {
"32000/1ch/8-11": 71
},
"conflict": false
},
{
"setup": "2251ec90561827a2f14482527004a158c53862647373c0274d1297a095c33f4206918e66427463d11a1956955420202400c00041c10c00303840f81c049d00c1d12208911922d1b0101c1e540244c454009098a0900b00151617691717d065800bbab8eb4008410842108b032820010727dcf0c41b9e708313748a4a1d040000000700f00000705c001111cd6164686c7074787c8084040000003800f001007088001111cd6164686c7074787c80840400000000000101018001",
"hash": 3644545348,
"alloc": 16192,
"alloc64": 16616,
"count": 39,
"formats": {
"32000/4ch/8-11": 38,
"32000/5ch/8-11": 1
},
"conflict": false
},
{
"setup": "2251ec90561827a2f14482527004a158c53862647373c0274d1297a095c33f4206918e66427463d11a1956955420202400c00041c10c00303840f81c049d00c1d12208911922d1b0101c1e540244c454009098a0900b001516176917179865800bbab8eb40084108425079032820010727dcf0c41b9e702393688ac218040000000600f00000705c001111cd6164686c7074787c8084040000002c00f001007088001111cd6164686c7074787c80840400000000000101018001",
"hash": 2446091980,
"alloc": 16192,
"alloc64": 16616,
"count": 15,
"formats": {
"48000/1ch/8-11": 14,
"48000/4ch/8-11": 1
},
"conflict": false
},
{
"setup": "2251ec90561827a2f14482527004a158c53862647373c0274d1297a095831c3130d181385c9472d1816328a14320202400c00041c10c00303840f81c049d00c1d12208911922d1b0101c1e540244c454009098a0900b00151617691717d065800bbab8eb4008410842108b032820010727dcf0c41b9e708313748a4a1d040000000800f00000705c001111cd6164686c7074787c8084040000003c00f001007088001111cd6164686c7074787c80840400000000000101018001",
"hash": 1876663221,
"alloc": 16872,
"alloc64": 17296,
"count": 12,
"formats": {
"32000/4ch/8-11": 12
},
"conflict": false
},
{
"setup": "0f3244050615634958db0f703c960d8bcfe9d74f3540800920304050300a418030020100008003007c00001c0540444473060708090a0b0c0d0e0f00000000000000",
"hash": 3867129568,
"alloc": 12084,
"alloc64": 12304,
"count": 5,
"formats": {
"6000/1ch/9-9": 5
},
"conflict": false
},
{
"setup": "1f5482728054b348c111846215e38891cdcd019f34495c82560edc58311c610c7e714201ef9817e31e41800920304050f08510102382109921120aab608141193438cc03800788088900203141917671815906b8a08bbb0e841084200495378002127070c20d4fbce109373289a6288c410000006000000f000007051011d15c85c5054686c606478747000000a002001f0000c7071011d15c85c5054686c6064787470000000000101010001800",
"hash": 3242781484,
"alloc": 15492,
"alloc64": 15892,
"count": 5,
"formats": {
"48000/1ch/8-11": 4,
"48000/5ch/8-11": 1
},
"conflict": false
},
{
"setup": "215482728054b348c111846215e38891cdcd019f34495c82560e5869d2d088b6e0f21f00210e02d372c9a514049800020304055f0801312208911922a1b00a1618944183c33c007880889008001213146917179865800bbab8eb40084108425079032820010727dcf0c41b9e702393688ac218080000000c00f000008050001111cd5c85c5054686c6064787c70788000000c005001f000048081011d1cc55585c6064686c7074787c800402080000800002101010001800",
"hash": 3080104147,
"alloc": 13300,
"alloc64": 13716,
"count": 5,
"formats": {
"44100/2ch/8-11": 5
},
"conflict": false
},
{
"setup": "269e95744f1611aec48f756c45e64485527004a158c53862647373c0274d1297a09543905f0d164c1f2246879165c751e05d40206402810228309001000708095200406181a143840810a3c0c0b8b8b40842648648442c068909d54051311d002c2e30e4034086c646dac5057419e0822eee3a1042108210c4e2000a48c0c109373cf18627dce0049da252070100000002003c00001c1b404444731c1d1e1f2021222324250100000010007c00001c26404444731c1d1e1f2021222324250100000000404040006000",
"hash": 3462290664,
"alloc": 18180,
"alloc64": 18636,
"count": 5,
"formats": {
"32000/4ch/8-11": 5
},
"conflict": false
},
{
"setup": "299e95744f1611aec48f756c45e64485527004a158c53862647373c0274d1297a095837f9b68309b42c8d538d406a8e0d7176c79741710089940a0000a0c6400c00142821400505860e8102102c42830302e2e2d8210992112118b4162423550544c07008b0b0cf90090a1b1917671015d06b8a08bbb0e8410842004b1388002127070c20d4fbce109373841a7a8d481000000d000000f0000c906101111cd1c4787c7074888c8084989c9098a0000002006001f0000490a101111cd1c4787c7074888c8084989c9094a20800000000820000101018001",
"hash": 73756765,
"alloc": 16080,
"alloc64": 16560,
"count": 5,
"formats": {
"48000/2ch/8-11": 5
},
"conflict": false
},
{
"setup": "12324405061563a147550072b5a6da831842219e351646821d106002080c10148c4210208c400000000001001f00004901101111cd9cc1014282c2024383c303448404000000000000",
"hash": 1050329265,
"alloc": 13788,
"alloc64": 14032,
"count": 4,
"formats": {
"12000/1ch/9-9": 4
},
"conflict": false
},
{
"setup": "215482728054b348c111846215e38891cdcd019f34495c82560e88e0955e5e6f2c894d403bfec14a95e14016049800020304055f0801312208911922a1b00a1618944183c33c00788088900800121314691717d065800bbab8eb4008410842108b032820010727dcf0c41b9e708313748a4a1d080000000d00f000008050001111cd5c85c5054686c6064787c707880000008006001f000048081011d1cc55585c6064686c7074787c800402080000800002101010001800",
"hash": 2982013646,
"alloc": 15116,
"alloc64": 15532,
"count": 3,
"formats": {
"32000/2ch/8-11": 3
},
"conflict": false
},
{
"setup": "2551ec90561827a2f14482527004a158c53862647373c0274d1297a09503319ce8b498461e9ab89483ec48c0804841fd14080809003040503003000c0e103e0741274070b40842648648342c048707950011311500242628e4024085c545dac5057419e0822eee3a1042108210c4e2000a48c0c109373cf18627dce0049da252070200008003003c00002417404444347318191a1b1c1d1e1f2021222324020000001c007c00002425404444347318191a1b1c1d1e1f20212223248100020000208000040404000600",
"hash": 3568494628,
"alloc": 14316,
"alloc64": 14764,
"count": 3,
"formats": {
"32000/2ch/8-11": 3
},
"conflict": false
},
{
"setup": "2551ec90561827a2f14482527004a158c53862647373c0274d1297a095432339b5a2d842b8e592da44ebd145a0726f9d15080809003040503003000c0e103e0741274070b40842648648342c048707950011311500242628e4024085c545dac5057419e0822eee3a1042108210c4e2000a48c0c109373cf18627dce0049da25207020000c003003c00002417404444347318191a1b1c1d1e1f2021222324020000001e007c00002425404444347318191a1b1c1d1e1f20212223248100020000208000040404000600",
"hash": 1680511206,
"alloc": 14540,
"alloc64": 14988,
"count": 2,
"formats": {
"32000/2ch/8-11": 2
},
"conflict": false
},
{
"setup": "16e5805702114f621065896afd16605711f468c4647a39075522fc21a90d6020426602810228309001000708095200406181a174a10b4288205d04593c70e1c48d276e38a1430b040000001a00f001007030001111cd35383c4044484c50545800000000000000",
"hash": 3765588556,
"alloc": 13464,
"alloc64": 13740,
"count": 2,
"formats": {
"24000/1ch/10-10": 2
},
"conflict": false
},
{
"setup": "21870c218c290e39866353e5805702114f621065896afd166057ee31421b2db01c79d38a0d04928047770918080809003040503003000c0e10460e043a02081c5a0311321318144283834c00788088900a00121314a50b5d104204e922c8e2810b276e3c71c3091dda400000000001800f0080840208888866aec2e20223436383a3c3e303440000000002800f0080240408888866aec2e20223436383a3c3e303241040000000041080808000c000",
"hash": 2310130245,
"alloc": 13076,
"alloc64": 13492,
"count": 2,
"formats": {
"24000/2ch/9-10": 2
},
"conflict": false
},
{
"setup": "2551ec90561827a2f14482527004a158c53862647373c0274d1297a09503319ce8b498461e9ab89483ec48c0804841fd14080809003040503003000c0e103e0741274070b40842648648342c048707950011311500242628e4024085c545dac5056619e0822eee3a104210821054de000a48c0c109373cf18627dcc8249aa23006020000c002003c00002417404444347318191a1b1c1d1e1f20212223240200000016007c00002425404444347318191a1b1c1d1e1f20212223248100020000208000040404000600",
"hash": 1844255845,
"alloc": 14316,
"alloc64": 14764,
"count": 2,
"formats": {
"48000/2ch/8-11": 2
},
"conflict": false
},
{
"setup": "0f3244050615634958db0f703c960d8bcfe9d74f3540800920304050300a418030020100000003007c00001c0540444473060708090a0b0c0d0e0f00000000000000",
"hash": 1823403872,
"alloc": 12084,
"alloc64": 12304,
"count": 1,
"formats": {
"11025/1ch/9-9": 1
},
"conflict": false
},
{
"setup": "215482728054b348c111846215e38891cdcd019f34495c82560e5869d2d088b6e0f21f00210e02d372c9a514049800020304055f0801312208911922a1b00a1618944183c33c00788088900800121314691717d065800bbab8eb4008410842108b032820010727dcf0c41b9e708313748a4a1d080000000d00f000008050001111cd5c85c5054686c6064787c707880000008006001f000048081011d1cc55585c6064686c7074787c800402080000800002101010001800",
"hash": 2761019796,
"alloc": 13300,
"alloc64": 13716,
"count": 1,
"formats": {
"32000/2ch/8-11": 1
},
"conflict": false
},
{
"setup": "1f0d86028427660d044e695b7d849a08083147e36e1cf620dd3a78947554782d4413926bb93155087741800920304050300a4180302208911922a1b00a1618944183c33c00788088900800121314ad2e5c802e0670812edce580100441100461d1001420010e9ce0064f788327b88113e8284aea200000006000800f00808302888868aec2e20223436383a3c3230000001003800f0080e303888868aec2e20223436383a3c3230000000000080808000c00",
"hash": 1422405756,
"alloc": 17884,
"alloc64": 18284,
"count": 1,
"formats": {
"32000/5ch/9-12": 1
},
"conflict": false
},
{
"setup": "269e95744f1611aec48f756c45e64485527004a158c53862647373c0274d1297a09543905f0d164c1f2246879165c751e05d40206402810228309001000708095200406181a143840810a3c0c0b8b8b40842648648442c068909d54051311d002c2e30e4034086c646dac5057419e0822eee3a1042108210c4e2000a48c0c109373cf18627dce0049da25207010000c001003c00001c1b404444731c1d1e1f202122232425010000800c007c00001c26404444731c1d1e1f2021222324250100000000404040006000",
"hash": 2465811007,
"alloc": 18180,
"alloc64": 18636,
"count": 1,
"formats": {
"48000/1ch/8-11": 1
},
"conflict": false
}
]

155
ackit/encoder.py Normal file
View File

@@ -0,0 +1,155 @@
"""Encode audio into game-compatible WEMs (portable: libvorbis via libsndfile, no Wwise, no ffmpeg).
Pipeline: float32 PCM -> libvorbis (Ogg) -> `builder.ogg_to_wem` (Wwise 2011.1 layout, game codebook
IDs, FNV-1 hash, decode-alloc size). The quality is chosen so that every codebook libvorbis uses exists
in the game's built-in library (docs/formats/wem.md); setups the game itself ships are preferred.
"""
import io
import json
import os
import numpy as np
import soundfile as sf
from . import audio
from .builder import ogg_to_wem
from .convert import library
from .known_setups import lookup_setup
from .ogg import read_packets
from .paths import user_dir
from .wwvorbis import V2W, transcode_setup
QUALITIES = [q / 2 for q in range(20, -1, -1)] # 10.0 .. 0.0 (libvorbis q; libsndfile cannot do q<0)
AUTO_MAX_Q_KNOWN = 6.0 # highest q considered when looking for a setup the game ships
AUTO_MAX_Q = 4.0 # otherwise, highest compatible q (game files sit around q3-5)
FALLBACK_RATES = (32000, 28000, 24000) # e.g. 6 channels at 44.1/48 kHz
FIT_RATES = (24000, 22050, 16000, 12000, 11025, 8000)
class EncodeError(ValueError):
pass
def vorbis_ogg(x, rate, quality):
"""Encode float32 PCM to Ogg Vorbis bytes at libvorbis quality `quality` (0..10)."""
b = io.BytesIO()
level = min(1.0, max(0.0, 1.0 - quality / 10.0))
x = np.ascontiguousarray(audio._as2d(x))
# write in blocks: libsndfile's Vorbis writer overflows the C stack on very large single writes
with sf.SoundFile(b, "w", rate, x.shape[1], format="OGG", subtype="VORBIS", compression_level=level) as f:
for i in range(0, len(x), 4096):
f.write(x[i:i + 4096])
return b.getvalue()
# --- setup probing (which q values are usable at a rate/channel count) ------------------------
_cache = None
def _cache_path():
return os.path.join(user_dir("cache"), "vorbis_probe.json")
def _probe_cache():
global _cache
if _cache is None:
try:
c = json.load(open(_cache_path()))
_cache = c if c.get("_lib") == sf.__libsndfile_version__ else None
except (OSError, ValueError):
_cache = None
_cache = _cache or {"_lib": sf.__libsndfile_version__}
return _cache
def probe_setup(rate, channels, quality):
"""(stripped Wwise setup or None, error) for libvorbis at these settings (cached)."""
key = "%d,%d,%.1f" % (rate, channels, quality)
c = _probe_cache()
if key not in c:
noise = (np.random.default_rng(7).standard_normal((rate, channels)) * 0.1).astype(np.float32)
pk = read_packets(vorbis_ogg(noise, rate, quality))
try:
s, _ = transcode_setup(pk[2][0], V2W, channels, library())
c[key] = [s.hex(), None]
except ValueError as e:
c[key] = [None, str(e)]
try:
json.dump(c, open(_cache_path(), "w"))
except OSError:
pass
s, err = c[key]
return (bytes.fromhex(s) if s else None), err
def choose_quality(rate, channels, quality="auto"):
"""-> (quality, known) for a rate/channel count, or raise EncodeError."""
if quality != "auto":
q = float(quality)
s, err = probe_setup(rate, channels, q)
if s is None:
raise EncodeError("quality %s cannot be used at %d Hz / %d ch: %s" % (quality, rate, channels, err))
return q, bool(lookup_setup(s))
compatible = None
for q in [x for x in QUALITIES if x <= AUTO_MAX_Q_KNOWN]:
s, _ = probe_setup(rate, channels, q)
if s is None:
continue
if lookup_setup(s):
return q, True
if compatible is None and q <= AUTO_MAX_Q:
compatible = q
if compatible is None:
raise EncodeError("no libvorbis quality yields game-compatible codebooks at %d Hz / %d ch" % (rate, channels))
return compatible, False
def pick_format(rate, channels, quality="auto"):
"""Find a usable (rate, quality). Falls back to lower rates when needed (Wwise resamples every
source, so playback speed is unaffected)."""
try:
return (rate,) + choose_quality(rate, channels, quality)
except EncodeError as first:
for r in FALLBACK_RATES:
if r < rate:
try:
return (r,) + choose_quality(r, channels, quality)
except EncodeError:
pass
raise first
def encode(x, src_rate, rate, channels, quality="auto"):
"""PCM -> WEM bytes in the requested format. Returns (wem, info)."""
r, q, known = pick_format(rate, channels, quality)
pcm = audio.conform(x, src_rate, r, channels)
if len(pcm) < 64:
raise EncodeError("the audio is empty")
wem = ogg_to_wem(vorbis_ogg(pcm, r, q))
return wem, dict(rate=r, quality=q, known_setup=known, size=len(wem), channels=channels,
seconds=len(pcm) / float(r), requested_rate=rate)
def encode_fit(x, src_rate, rate, channels, limit=None, quality="auto", progress=None):
"""Like `encode`, but if the result is larger than `limit` bytes lower the quality step by step,
then the sample rate, until it fits. Raises EncodeError if it cannot fit."""
wem, info = encode(x, src_rate, rate, channels, quality)
if limit is None or len(wem) <= limit:
return wem, info
first = len(wem)
for r in [info["rate"]] + [f for f in FIT_RATES if f < info["rate"]]:
pcm = audio.conform(x, src_rate, r, channels)
for q in QUALITIES:
if r == info["rate"] and q >= info["quality"]:
continue
if probe_setup(r, channels, q)[0] is None:
continue
if progress:
progress("shrinking: %d Hz, quality %.1f" % (r, q))
w = ogg_to_wem(vorbis_ogg(pcm, r, q))
if len(w) <= limit:
info.update(rate=r, quality=q, size=len(w), shrunk_from=first,
known_setup=bool(lookup_setup(probe_setup(r, channels, q)[0])))
return w, info
raise EncodeError("the audio is too long for this sound even at the lowest quality "
"(%d bytes, maximum %d). Shorten it." % (first, limit))

205
ackit/game.py Normal file
View File

@@ -0,0 +1,205 @@
"""The game installation: locating it (Steam on Windows / Linux / Proton), its WADs and language.
A `Game` is built from any folder path; nothing here is hard-coded to one machine.
"""
import glob
import hashlib
import os
import re
import struct
import sys
APPID = 200260
FOLDER_NAMES = ("Batman Arkham City GOTY", "Batman Arkham City")
LANGS = ("INT", "ITA", "FRA", "DEU", "ESN", "ESM", "POL", "RUS", "JPN", "KOR", "POR")
class GameError(Exception):
"""User-facing problem with the game folder (message is meant to be shown as-is)."""
def _iglob_one(base, *parts):
"""Case-insensitive path join (Linux file systems are case sensitive)."""
cur = base
for p in parts:
if not os.path.isdir(cur):
return None
hit = next((e for e in os.listdir(cur) if e.lower() == p.lower()), None)
if hit is None:
return None
cur = os.path.join(cur, hit)
return cur
# --- Steam discovery ------------------------------------------------------------------------
def steam_roots():
roots = []
if sys.platform == "win32":
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Valve\Steam") as k:
roots.append(winreg.QueryValueEx(k, "SteamPath")[0])
except OSError:
pass
roots += [r"C:\Program Files (x86)\Steam", r"C:\Program Files\Steam"]
else:
h = os.path.expanduser("~")
roots += [os.path.join(h, ".steam", "steam"), os.path.join(h, ".local", "share", "Steam"),
os.path.join(h, ".var", "app", "com.valvesoftware.Steam", ".local", "share", "Steam"),
os.path.join(h, "snap", "steam", "common", ".local", "share", "Steam")]
out = []
for r in roots:
r = os.path.normpath(r)
if os.path.isdir(r) and r not in out:
out.append(r)
return out
def steam_libraries():
libs = []
for root in steam_roots():
libs.append(root)
vdf = _iglob_one(root, "steamapps", "libraryfolders.vdf")
if vdf:
for m in re.finditer(r'"path"\s+"([^"]+)"', open(vdf, encoding="utf-8", errors="replace").read()):
libs.append(m.group(1).replace("\\\\", "\\"))
seen, out = set(), []
for l in libs:
n = os.path.normcase(os.path.normpath(l))
if n not in seen and os.path.isdir(l):
seen.add(n)
out.append(os.path.normpath(l))
return out
def _true_case(path):
"""Windows paths from the registry may be lower-cased; rebuild the real spelling."""
path = os.path.normpath(path)
drive, rest = os.path.splitdrive(path)
cur = drive.upper() + os.sep if drive else os.sep
for part in [p for p in rest.split(os.sep) if p]:
hit = next((e for e in os.listdir(cur) if e.lower() == part.lower()), part) if os.path.isdir(cur) else part
cur = os.path.join(cur, hit)
return cur
def find_installs():
"""All Arkham City folders found in Steam libraries."""
out = []
for lib in steam_libraries():
for name in FOLDER_NAMES:
p = _iglob_one(lib, "steamapps", "common", name)
if p and is_game_dir(p) and _true_case(p) not in out:
out.append(_true_case(p))
return out
def is_game_dir(path):
return bool(path) and _iglob_one(path, "BmGame", "CookedPCConsole") is not None
# --- Game ------------------------------------------------------------------------------------
class Game:
def __init__(self, root):
root = os.path.normpath(os.path.abspath(root))
if not is_game_dir(root):
raise GameError("'%s' is not a Batman: Arkham City folder (BmGame\\CookedPCConsole is missing)." % root)
self.root = root
self.cooked = _iglob_one(root, "BmGame", "CookedPCConsole")
self.archives = self._find_archives()
if not self.archives:
raise GameError("No audio archives (.wad) found in %s." % self.cooked)
def _find_archives(self):
arch = {}
for p in glob.glob(os.path.join(self.cooked, "*", "*.wad")):
m = re.search(r"_LOC_([A-Za-z]{3})\.wad$", p)
if m:
arch[m.group(1).upper()] = p
elif os.path.basename(os.path.dirname(p)).upper() == "SFX":
arch["SFX"] = p
return dict(sorted(arch.items()))
@property
def languages(self):
return [a for a in self.archives if a != "SFX"]
def wad(self, archive):
try:
return self.archives[archive.upper()]
except KeyError:
raise GameError("This game has no '%s' audio archive (available: %s)."
% (archive, ", ".join(self.archives)))
def rel(self, path):
return os.path.relpath(path, self.root).replace("\\", "/")
@property
def exe(self):
return _iglob_one(self.root, "Binaries", "Win32", "BatmanAC.exe")
@property
def store_id(self):
"""Stable id of this install, used to name its originals store."""
return hashlib.sha1(os.path.normcase(self.root).encode("utf-8")).hexdigest()[:12]
# language -------------------------------------------------------------------------------
def config_dirs(self):
"""Possible BmGame\\Config folders (Windows Documents, Proton prefix)."""
dirs = [os.path.join(os.path.expanduser("~"), "Documents", "WB Games", "Batman Arkham City GOTY",
"BmGame", "Config")]
steamapps = os.path.dirname(os.path.dirname(self.root)) # .../steamapps
pfx = os.path.join(steamapps, "compatdata", str(APPID), "pfx", "drive_c", "users", "steamuser")
for docs in ("Documents", "My Documents"):
dirs.append(os.path.join(pfx, docs, "WB Games", "Batman Arkham City GOTY", "BmGame", "Config"))
return [d for d in dirs if os.path.isdir(d)]
def language(self):
"""Speech language the game is set to (falls back to INT, then the first available)."""
for d in self.config_dirs():
for f in ("UserEngine.ini", "BmEngine.ini"):
p = os.path.join(d, f)
if not os.path.exists(p):
continue
raw = open(p, "rb").read()
txt = raw.decode("utf-16") if raw[:2] in (b"\xff\xfe", b"\xfe\xff") else raw.decode("latin-1")
m = re.search(r"^\s*Language\s*=\s*(\w+)", txt, re.M | re.I)
if m and m.group(1).upper() in self.archives:
return m.group(1).upper()
if "INT" in self.archives:
return "INT"
return self.languages[0] if self.languages else "SFX"
# safety -----------------------------------------------------------------------------------
def check_writable(self, path):
"""Raise GameError if the file cannot be replaced (game running, read-only, ...)."""
try:
with open(path, "r+b"):
pass
except PermissionError:
raise GameError("Cannot write %s.\nClose the game (and anything else using the file) and try again."
% os.path.basename(path))
d = os.path.dirname(path)
if not os.access(d, os.W_OK):
raise GameError("The folder %s is not writable." % d)
def wad_fingerprint(path):
"""Identity of a WAD's game build: SHA-1 over its entry IDs and the sizes of all entries except
stream packs (whose size legitimately changes when music is replaced). In-place sound edits do not
change it; a different game build or language does."""
from .inject import is_pack
from .wad import Wad
w = Wad(path)
h = hashlib.sha1()
for e in w.entries:
h.update(struct.pack("<II", e.hash, 0 if is_pack(path, e.hash) else e.size))
return "%d-%s" % (len(w.entries), h.hexdigest()[:16])
def default_game_dir(settings=None):
"""Last used folder from settings, else the first Steam install found."""
if settings and settings.get("game_dir") and is_game_dir(settings["game_dir"]):
return settings["game_dir"]
found = find_installs()
return found[0] if found else None

133
ackit/inject.py Normal file
View File

@@ -0,0 +1,133 @@
"""Read and replace WEMs inside WAD archives.
Target syntax (stable ids used everywhere, also in mod packages):
'HASH' WAD entry holding one WEM e.g. 800BE4F0
'HASH:i' i-th WEM (0-based) of an entry holding several e.g. B49CDF48:11
'PACK/MEDIA' streamed WEM inside a BKPK stream pack (SFX) e.g. EDF036D6/393BC53A
Rules (see docs/formats/wad.md):
* entries / variations are indexed from inside the game's compressed .upk packages, so they are
replaced IN PLACE and must not exceed the original WEM size (`limit`); the WAD layout never moves;
* stream-pack media may change size: the pack index (SFX\\<PACK>.bnk) is rewritten too.
Writes go to a temporary file which atomically replaces the original (never half-written).
"""
import os
from .pack import Pack
from .wad import Wad, write_wad
from .wem import Wem, split_concatenated
class SizeLimitError(ValueError):
def __init__(self, target, limit, size):
super().__init__("the new sound is %d bytes but the original is %d bytes; the game reads exactly the "
"original size, so the replacement must not be larger (shorten the audio or lower "
"the quality)" % (size, limit))
self.target, self.limit, self.size = target, limit, size
def parse_target(t: str):
"""-> (entry_hash, index, media_id or None)"""
if "/" in t:
p, m = t.split("/", 1)
return int(p, 16), 0, int(m, 16)
h, _, idx = t.partition(":")
return int(h, 16), int(idx or 0), None
def pack_bnk_path(wad_path, pack_id):
"""SFX\\<PACK>.bnk next to the WAD (case-insensitive), or None."""
d = os.path.dirname(wad_path)
want = ("%08X.bnk" % pack_id).lower()
hit = next((f for f in os.listdir(d) if f.lower() == want), None)
return os.path.join(d, hit) if hit else None
def is_pack(wad_path, entry_hash):
p = pack_bnk_path(wad_path, entry_hash)
if not p:
return False
with open(p, "rb") as f:
return f.read(4) == b"BKPK"
def read_target(wad_path: str, target: str, wad: Wad = None) -> bytes:
"""Bytes of one WEM (seeks directly; does not load whole packs)."""
wad = wad or Wad(wad_path)
h, idx, mid = parse_target(target)
if h not in wad.index:
raise KeyError("entry %08X not in %s" % (h, os.path.basename(wad_path)))
e = wad.index[h]
if mid is not None:
pk = Pack(open(pack_bnk_path(wad_path, h), "rb").read())
_, size, off = pk.entries[pk.index[mid]]
with open(wad_path, "rb") as f:
f.seek(e.offset + off)
return f.read(size)
riffs, _ = split_concatenated(wad.read(h))
if idx >= len(riffs):
raise IndexError("entry %08X holds %d WEM(s)" % (h, len(riffs)))
return riffs[idx][1]
def replace_entry_payload(payload: bytes, index: int, new_wem: bytes, limit: int, target="") -> bytes:
"""Replace WEM `index` in place, zero-filled up to `limit` bytes; nothing else moves."""
riffs, _ = split_concatenated(payload)
if index >= len(riffs):
raise IndexError("entry holds %d WEM(s); index %d invalid" % (len(riffs), index))
if len(new_wem) > limit:
raise SizeLimitError(target, limit, len(new_wem))
off = riffs[index][0]
if off + limit > len(payload):
raise ValueError("entry is shorter than the original size limit (unexpected WAD state)")
return payload[:off] + new_wem + bytes(limit - len(new_wem)) + payload[off + limit:]
def inject(wad_path: str, replacements: dict, limits: dict, out_path: str = None, pack_gaps: dict = None):
"""Write several WEMs into one WAD in a single atomic rewrite.
replacements: {target: wem_bytes}; limits: {target: original size} for non-pack targets.
out_path: write a copy elsewhere instead of replacing the WAD (tests/dry runs).
pack_gaps: {pack hash: Pack.gaps()} original padding, so reverts restore packs byte-for-byte.
Returns the list of files written."""
src = Wad(wad_path)
payloads, pack_repl = {}, {}
for target, data in replacements.items():
Wem.parse(data) # must be a game-format WEM
h, idx, mid = parse_target(target)
if h not in src.index:
raise KeyError("entry %08X not in %s" % (h, os.path.basename(wad_path)))
if mid is not None:
pack_repl.setdefault(h, {})[mid] = data
continue
if limits.get(target) is None:
raise ValueError("no size limit given for %s" % target)
base = payloads.get(h) or src.read(h)
payloads[h] = replace_entry_payload(base, idx, data, limits[target], target)
new_bnks = {}
for h, media in pack_repl.items():
bpath = pack_bnk_path(wad_path, h)
new_bnks[bpath], payloads[h] = Pack(open(bpath, "rb").read()).rebuild(
src.read(h), media, (pack_gaps or {}).get(h))
dst = out_path or wad_path
bnk_dsts = {b: (b if out_path is None else os.path.join(os.path.dirname(out_path), os.path.basename(b)))
for b in new_bnks}
tmps = [dst + ".ackit-tmp"] + [d + ".ackit-tmp" for d in bnk_dsts.values()]
try:
write_wad(src, tmps[0], payloads)
problems = Wad(tmps[0]).check()
if problems:
raise RuntimeError("rebuilt WAD failed validation: %s" % problems[:3])
for b, d in bnk_dsts.items():
with open(d + ".ackit-tmp", "wb") as f:
f.write(new_bnks[b])
# commit: pack indexes first (tiny), then the WAD; each os.replace is atomic
for d in bnk_dsts.values():
os.replace(d + ".ackit-tmp", d)
os.replace(tmps[0], dst)
finally:
for p in tmps:
if os.path.exists(p):
os.remove(p)
return [dst] + list(bnk_dsts.values())

25
ackit/known_setups.py Normal file
View File

@@ -0,0 +1,25 @@
"""Database of Vorbis setups used by the game's own WEMs (from all shipped WADs).
Each entry: stripped Wwise setup (hex), its codebook hash, decode alloc sizes, and
formats seen. Reusing a known setup guarantees hash/alloc values the game accepts.
"""
import json
import os
PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "known_setups.json")
_DB = None
def db():
global _DB
if _DB is None:
_DB = json.load(open(PATH))
return _DB
def lookup_setup(ww_setup: bytes):
h = ww_setup.hex()
for e in db():
if e["setup"] == h:
return e
return None

120
ackit/modpack.py Normal file
View File

@@ -0,0 +1,120 @@
"""Portable mod packages (*.acmod): share replaced sounds between machines and languages.
A package is a zip:
manifest.json {"format": 1, "name", "author", "description", "created", "items": [...]}
items/NNN.wem the exact WEM that was applied (used as-is when the target matches)
items/NNN.flac the same audio, decoded (used to re-encode when the target differs, e.g. another
language whose original has a different size)
Each item records archive, target, name, group and the SHA-1 of the original it replaced.
"""
import io
import json
import os
import time
import zipfile
from dataclasses import dataclass
import soundfile as sf
from . import audio
from .originals import sha1_16
FORMAT = 1
def export_mod(ws, path, keys, name="", author="", description=""):
"""Write the applied versions of `keys` (from the originals store) into a package."""
items = []
with zipfile.ZipFile(path + ".tmp", "w", zipfile.ZIP_DEFLATED) as z:
for i, key in enumerate(keys):
e = ws.store.get(key)
if not e or not e.get("applied"):
raise ValueError("%s is not modified" % key)
s = ws.catalog.by_key.get(key)
wem = ws.store.get_blob(e["applied"])
x, sr = audio.decode_wem(wem)
buf = io.BytesIO()
sf.write(buf, x, sr, format="FLAC")
base = "items/%03d" % i
z.writestr(base + ".wem", wem)
z.writestr(base + ".flac", buf.getvalue())
items.append({"archive": e["archive"], "target": e["target"], "name": s.name if s else "",
"group": s.group if s else "", "original_sha1": e["orig_sha1"],
"wem": base + ".wem", "audio": base + ".flac", "meta": e.get("meta", {})})
z.writestr("manifest.json", json.dumps({"format": FORMAT, "name": name, "author": author,
"description": description, "created": time.time(),
"game": "Batman: Arkham City (PC)", "items": items}, indent=1))
os.replace(path + ".tmp", path)
return len(items)
@dataclass
class ImportItem:
item: dict
sound: object = None # matched Sound or None
how: str = "" # 'exact' | 'by name' | 'missing'
wem: bytes = None # ready-to-apply bytes (after `prepare_import`)
note: str = ""
def read_mod(path):
z = zipfile.ZipFile(path)
man = json.loads(z.read("manifest.json"))
if man.get("format") != FORMAT:
raise ValueError("unsupported mod package format %r" % man.get("format"))
return z, man
def match_mod(ws, path):
"""Match package items to sounds of this workspace. -> (manifest, [ImportItem])"""
z, man = read_mod(path)
out = []
for it in man["items"]:
key = "%s:%s" % (it["archive"], it["target"])
s = ws.catalog.by_key.get(key)
if s and it.get("name") and s.name != it["name"]:
s = None
if s:
out.append(ImportItem(it, s, "exact"))
continue
try:
cands = [c for c in ws.catalog.search(it["name"]) if c.name == it["name"]] if it.get("name") else []
except LookupError:
cands = []
if len(cands) == 1:
out.append(ImportItem(it, cands[0], "by name"))
else:
out.append(ImportItem(it, None, "missing", note="no sound named %r in this game/language" % it.get("name")))
z.close()
return man, out
def prepare_import(ws, path, items, progress=None):
"""Fill `wem` for matched items: reuse the packaged WEM when it targets the very same original,
otherwise re-encode the packaged audio for the local sound."""
z, _ = read_mod(path)
for im in items:
if not im.sound:
continue
orig = ws.original_bytes(im.sound)
wem = z.read(im.item["wem"])
lim = ws.limit(im.sound)
if im.how == "exact" and sha1_16(orig) == im.item.get("original_sha1") and (lim is None or len(wem) <= lim):
im.wem, im.note = wem, "exact copy"
else:
x, sr = sf.read(io.BytesIO(z.read(im.item["audio"])), dtype="float32", always_2d=True)
im.wem, info = ws.prepare(im.sound, x, sr, progress=progress)
im.note = "re-encoded (%d Hz, q%.1f)" % (info["rate"], info["quality"])
z.close()
return items
def import_mod(ws, path, force=False, progress=None):
"""Match, prepare and apply a package. Returns the list of ImportItem (see .how/.note)."""
_, items = match_mod(ws, path)
prepare_import(ws, path, items, progress)
changes = [(im.sound, im.wem, {"source": "mod:" + path.replace("\\", "/").split("/")[-1]})
for im in items if im.wem]
if changes:
ws.apply(changes, force=force, progress=progress)
return items

72
ackit/ogg.py Normal file
View File

@@ -0,0 +1,72 @@
"""Minimal Ogg page reader/writer."""
import struct
def _crc_table():
t = []
for i in range(256):
r = i << 24
for _ in range(8):
r = ((r << 1) ^ 0x04C11DB7) if r & 0x80000000 else (r << 1)
t.append(r & 0xFFFFFFFF)
return t
_T = _crc_table()
def ogg_crc(b: bytes) -> int:
c = 0
for x in b:
c = ((c << 8) & 0xFFFFFFFF) ^ _T[((c >> 24) & 0xFF) ^ x]
return c
def read_packets(b: bytes):
"""Return list of (packet_bytes, granule_of_page_if_packet_ends_page_else_None)."""
pos = 0
out = []
cur = b""
while pos + 27 <= len(b):
if b[pos:pos + 4] != b"OggS":
raise ValueError("lost Ogg sync at %d" % pos)
htype, gran, serial, seq, crc, nseg = struct.unpack_from("<BqIIIB", b, pos + 5)
segs = b[pos + 27: pos + 27 + nseg]
p = pos + 27 + nseg
ends = []
for s in segs:
cur += b[p:p + s]
p += s
if s < 255:
ends.append(cur)
cur = b""
for i, pk in enumerate(ends):
out.append((pk, gran if i == len(ends) - 1 else None))
pos = p
return out
class OggWriter:
def __init__(self, serial=0x41434B54):
self.serial = serial
self.seq = 0
self.out = bytearray()
def page(self, packets, granule, bos=False, eos=False):
segs = bytearray()
body = bytearray()
for pk in packets:
n = len(pk)
while n >= 255:
segs.append(255); n -= 255
segs.append(n)
body += pk
hdr = struct.pack("<4sBBqIIIB", b"OggS", 0, (2 if bos else 0) | (4 if eos else 0),
granule, self.serial, self.seq, 0, len(segs)) + bytes(segs)
page = bytearray(hdr + body)
struct.pack_into("<I", page, 22, ogg_crc(page))
self.out += page
self.seq += 1
def bytes(self):
return bytes(self.out)

143
ackit/originals.py Normal file
View File

@@ -0,0 +1,143 @@
"""Originals store: keeps the game's original bytes of every sound you modify, so any change can be
reverted exactly, without keeping full copies of the 0.4-0.5 GB WADs.
Layout (one store per game folder, in the user data folder by default; see docs/decisions.md):
<root>/store.json records: {key: {...}} + per-WAD info
<root>/blobs/<sha256>.wem original WEMs and the replacements you applied
<root>/gaps/<PACK>.json original non-zero padding of stream packs (byte-exact pack reverts)
<root>/full/<rel path> optional full copies of WADs / pack indexes (fallback, see `full_backup`)
Writes to store.json are atomic (temp file + rename).
"""
import hashlib
import json
import os
import shutil
import time
STORE_VERSION = 1
def sha256(b):
return hashlib.sha256(b).hexdigest()
def sha1_16(b):
return hashlib.sha1(b).hexdigest()[:16]
class OriginalsStore:
def __init__(self, root, game_root=None):
self.root = root
os.makedirs(os.path.join(root, "blobs"), exist_ok=True)
self.path = os.path.join(root, "store.json")
if os.path.exists(self.path):
self.data = json.load(open(self.path, encoding="utf-8"))
else:
self.data = {"version": STORE_VERSION, "game_root": game_root, "created": time.time(),
"entries": {}, "wads": {}}
self.save()
# persistence -------------------------------------------------------------------------------
def save(self):
tmp = self.path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=1)
os.replace(tmp, self.path)
def put_blob(self, b):
h = sha256(b)
p = os.path.join(self.root, "blobs", h + ".wem")
if not os.path.exists(p):
tmp = p + ".tmp"
with open(tmp, "wb") as f:
f.write(b)
os.replace(tmp, p)
return h
def get_blob(self, h):
p = os.path.join(self.root, "blobs", h + ".wem")
b = open(p, "rb").read()
if sha256(b) != h:
raise IOError("originals store is damaged: %s" % p)
return b
# records ---------------------------------------------------------------------------------
@property
def entries(self):
return self.data["entries"]
def get(self, key):
return self.entries.get(key)
def original(self, key):
e = self.get(key)
return self.get_blob(e["orig"]) if e else None
def remember_original(self, key, archive, target, wad_rel, data):
"""Store the original bytes the first time a sound is modified (no-op afterwards)."""
if key not in self.entries:
self.entries[key] = {"archive": archive, "target": target, "wad": wad_rel,
"orig": self.put_blob(data), "orig_size": len(data),
"orig_sha1": sha1_16(data), "first_modified": time.time(),
"applied": None}
return self.entries[key]
def record_applied(self, key, data, meta=None):
e = self.entries[key]
e["applied"] = self.put_blob(data)
e["applied_at"] = time.time()
e["meta"] = meta or {}
def forget(self, key):
self.entries.pop(key, None)
def gc(self):
"""Delete blobs no record refers to."""
used = {e[k] for e in self.entries.values() for k in ("orig", "applied") if e.get(k)}
d = os.path.join(self.root, "blobs")
for f in os.listdir(d):
if f.endswith(".wem") and f[:-4] not in used:
os.remove(os.path.join(d, f))
# pack padding ------------------------------------------------------------------------------
def pack_gaps(self, pack_id):
p = os.path.join(self.root, "gaps", "%08X.json" % pack_id)
if not os.path.exists(p):
return None
return {int(k): bytes.fromhex(v) for k, v in json.load(open(p)).items()}
def remember_pack_gaps(self, pack_id, gaps):
p = os.path.join(self.root, "gaps", "%08X.json" % pack_id)
if os.path.exists(p):
return
os.makedirs(os.path.dirname(p), exist_ok=True)
json.dump({str(k): v.hex() for k, v in gaps.items()}, open(p, "w"))
# full-file fallback backups ----------------------------------------------------------------
def full_backup(self, src, rel):
"""Copy a game file once (before its first modification), verified by SHA-1."""
dst = os.path.join(self.root, "full", rel)
if os.path.exists(dst):
return dst
os.makedirs(os.path.dirname(dst), exist_ok=True)
tmp = dst + ".tmp"
shutil.copy2(src, tmp)
if _file_sha1(tmp) != _file_sha1(src):
os.remove(tmp)
raise IOError("backup copy of %s does not match the original" % rel)
os.replace(tmp, dst)
self.data.setdefault("full", {})[rel] = {"size": os.path.getsize(dst), "time": time.time()}
self.save()
return dst
def full_copy(self, rel):
p = os.path.join(self.root, "full", rel)
return p if os.path.exists(p) else None
def _file_sha1(p):
h = hashlib.sha1()
with open(p, "rb") as f:
for c in iter(lambda: f.read(1 << 22), b""):
h.update(c)
return h.hexdigest()

92
ackit/pack.py Normal file
View File

@@ -0,0 +1,92 @@
"""BKPK stream packs: SFX\\<ID>.bnk ("BKPK" header + embedded bank) indexes the streamed WEMs
stored inside the SFX WAD entry with the same ID (e.g. EDF036D6 'Music', E8F980BE 'Music_PDLC',
57572DAC 'Global', FCA4F778).
BKPK header (u32 LE):
0x00 'BKPK' 0x04 version 0x01020000 0x08 pack id (== WAD entry hash)
0x10 media count N 0x18 table size (12N) 0x1C table offset (0x48)
0x20 total stream size (== WAD entry size; end of last media aligned to 0x800)
0x2C end of table (0x48 + 12N) 0x38 embedded bank size 0x3C embedded bank offset
table: N x {u32 media id, u32 size, u32 offset-in-WAD-entry}, sorted by id; each media
starts at the next 0x800-aligned offset after the previous one.
See docs/wad_format.md.
"""
import struct
ALIGN = 0x800
def _a(x):
return (x + ALIGN - 1) // ALIGN * ALIGN
class Pack:
def __init__(self, bnk: bytes):
if bnk[:4] != b"BKPK":
raise ValueError("not a BKPK pack")
self.raw = bnk
self.id = struct.unpack_from("<I", bnk, 8)[0]
n = struct.unpack_from("<I", bnk, 0x10)[0]
self.table_off = struct.unpack_from("<I", bnk, 0x1C)[0]
self.total = struct.unpack_from("<I", bnk, 0x20)[0]
self.entries = [list(struct.unpack_from("<III", bnk, self.table_off + 12 * i)) for i in range(n)]
self.index = {e[0]: i for i, e in enumerate(self.entries)}
def media(self, payload: bytes, mid: int) -> bytes:
_, size, off = self.entries[self.index[mid]]
return payload[off:off + size]
def gaps(self, payload: bytes) -> dict:
"""Non-zero padding between media: {start offset: bytes}. Saved with the originals so that a
full revert restores the pack byte-for-byte."""
out = {}
for i, (mid, size, off) in enumerate(self.entries):
a = off + size
b = self.entries[i + 1][2] if i + 1 < len(self.entries) else self.total
g = payload[a:b]
if g.strip(b"\0"):
out[a] = g
return out
def rebuild(self, payload: bytes, replacements: dict, gaps: dict = None):
"""Returns (new_bnk_bytes, new_wad_payload).
Media are laid out by the pack rules (sorted order, 0x800-aligned, total aligned). Padding is
taken from `gaps` (original non-zero padding) when its position matches, else from the current
payload when that gap did not move, else zeros."""
missing = set(replacements) - set(self.index)
if missing:
raise KeyError("media not in pack: %s" % ", ".join("%08X" % m for m in missing))
gaps = gaps or {}
cur_gaps = {}
for i, (mid, size, off) in enumerate(self.entries):
nxt = self.entries[i + 1][2] if i + 1 < len(self.entries) else self.total
cur_gaps[off + size] = nxt
out = bytearray()
new_entries = []
def pad(upto):
a, n = len(out), upto - len(out)
if n <= 0:
return
g = gaps.get(a)
if g is not None and len(g) == n:
out.extend(g)
elif cur_gaps.get(a) == upto:
out.extend(payload[a:upto])
else:
out.extend(bytes(n))
for mid, size, off in self.entries:
pad(_a(len(out)))
blob = replacements[mid] if mid in replacements else payload[off:off + size]
new_entries.append((mid, len(blob), len(out)))
out += blob
total = _a(len(out))
pad(total)
bnk = bytearray(self.raw)
struct.pack_into("<I", bnk, 0x20, total)
for i, e in enumerate(new_entries):
struct.pack_into("<III", bnk, self.table_off + 12 * i, *e)
return bytes(bnk), bytes(out)

44
ackit/paths.py Normal file
View File

@@ -0,0 +1,44 @@
"""Filesystem locations: bundled resources and per-user data/config/cache folders.
Works from a source checkout and from a PyInstaller bundle (sys._MEIPASS). Override all user
folders with ACKIT_HOME (used by tests).
"""
import os
import sys
APP_NAME = "UnheardCity"
def resource_root():
"""Folder that contains `data/` (repo root, or the PyInstaller bundle root)."""
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
return sys._MEIPASS
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def package_data(*parts):
"""Files shipped inside the package (codebooks, known setups, hash list)."""
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", *parts)
def catalog_dir():
return os.path.join(resource_root(), "data", "catalog")
def _base(kind):
home = os.environ.get("ACKIT_HOME")
if home:
return os.path.join(home, kind)
if sys.platform == "win32":
root = os.environ.get("LOCALAPPDATA" if kind == "cache" else "APPDATA") or os.path.expanduser("~")
return os.path.join(root, APP_NAME, kind if kind == "cache" else "")
xdg = {"data": ("XDG_DATA_HOME", "~/.local/share"), "config": ("XDG_CONFIG_HOME", "~/.config"),
"cache": ("XDG_CACHE_HOME", "~/.cache")}[kind]
return os.path.join(os.environ.get(xdg[0]) or os.path.expanduser(xdg[1]), APP_NAME.lower())
def user_dir(kind):
"""kind: 'data' (originals store, notes), 'config' (settings), 'cache' (indexes)."""
p = os.path.normpath(_base(kind))
os.makedirs(p, exist_ok=True)
return p

32
ackit/settings.py Normal file
View File

@@ -0,0 +1,32 @@
"""Small JSON settings file shared by the CLI and the editor (user config folder)."""
import json
import os
from .paths import user_dir
DEFAULTS = {"game_dir": None, "store_root": None, "full_backup": True, "language": None,
"input_device": None, "output_device": None, "last_dir": None,
"background_enabled": True, "background_opacity": 0.1}
OBSOLETE = ("theme", "background_image")
def path():
return os.path.join(user_dir("config"), "settings.json")
def load():
s = dict(DEFAULTS)
try:
s.update(json.load(open(path(), encoding="utf-8")))
except (OSError, ValueError):
pass
for k in OBSOLETE:
s.pop(k, None)
return s
def save(s):
tmp = path() + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(s, f, indent=1)
os.replace(tmp, path())

0
ackit/verify/__init__.py Normal file
View File

183
ackit/verify/debugger.py Normal file
View File

@@ -0,0 +1,183 @@
"""Windows only. Unattended game run under cdb (WinDbg console debugger) with logging breakpoints.
Launch method (tested, see NOTES.md): with Steam running, BatmanAC.exe can be started directly
with SteamAppId=200260 in the environment (no relaunch through Steam). SteamStub decrypts .text
at runtime, so we wait until a known code signature is readable, then attach cdb non-intrusively
except for int3 breakpoints placed on already-decrypted code.
"""
import ctypes
import ctypes.wintypes as wt
import os
import re
import subprocess
import time
APPID = 200260
def find_cdb():
"""x86 cdb.exe from the WinDbg Store package (its path changes on every auto-update)."""
if os.environ.get("ACKIT_CDB"):
return os.environ["ACKIT_CDB"]
out = subprocess.run(["powershell", "-NoProfile", "-Command",
"(Get-AppxPackage Microsoft.WinDbg).InstallLocation"],
capture_output=True, text=True).stdout.strip()
for base in out.splitlines()[::-1]:
p = os.path.join(base.strip(), "x86", "cdb.exe")
if os.path.exists(p):
return p
for p in (r"C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe",):
if os.path.exists(p):
return p
raise FileNotFoundError("x86 cdb.exe not found (install WinDbg or set ACKIT_CDB)")
# Runtime addresses (image base 0x400000; ASLR off for this exe). See docs/decoder_re.md.
ADDR = dict(
get_or_create_setup=0x010B3400, # __thiscall(cache, AkSrcInfo*, ...) ; info+0x9c hash
parse_setup=0x010B4D10, # WwiseVorbisParseSetupHeader(info, channels, reader, pool) -> 0 ok
parse_setup_ret=0x010B351A, # in get_or_create_setup, right after the call (eax = result)
sig_site=0x010B347B, # mov ebx,[ebp+0x94] (decryption probe)
)
SIG = bytes.fromhex("8B9D94000000")
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
PROCESS_VM_READ = 0x0010
PROCESS_QUERY_INFORMATION = 0x0400
k32.OpenProcess.restype = wt.HANDLE
k32.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)]
def read_mem(pid, addr, n):
h = k32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
return None
try:
buf = ctypes.create_string_buffer(n)
got = ctypes.c_size_t()
if not k32.ReadProcessMemory(h, ctypes.c_void_p(addr), buf, n, ctypes.byref(got)):
return None
return buf.raw[:got.value]
finally:
k32.CloseHandle(h)
u32 = ctypes.WinDLL("user32", use_last_error=True)
WM_KEYDOWN, WM_KEYUP = 0x0100, 0x0101
VK = dict(enter=0x0D, space=0x20, esc=0x1B, up=0x26, down=0x28, left=0x25, right=0x27, e=0x45)
def game_windows(pid):
"""Top-level windows of the game process."""
found = []
cb_t = ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPARAM)
def cb(hwnd, _):
p = wt.DWORD()
u32.GetWindowThreadProcessId(hwnd, ctypes.byref(p))
if p.value == pid and u32.IsWindowVisible(hwnd):
found.append(hwnd)
return True
u32.EnumWindows(cb_t(cb), 0)
return found
def post_key(pid, key, hold=0.08):
"""Post a key press to the game's window only (never global SendInput)."""
vk = VK[key] if isinstance(key, str) else key
sc = u32.MapVirtualKeyW(vk, 0)
n = 0
for hwnd in game_windows(pid):
u32.PostMessageW(hwnd, WM_KEYDOWN, vk, 1 | (sc << 16))
time.sleep(hold)
u32.PostMessageW(hwnd, WM_KEYUP, vk, 1 | (sc << 16) | (3 << 30))
n += 1
return n
def running_game_pids():
out = subprocess.run(["tasklist", "/FI", "IMAGENAME eq BatmanAC.exe", "/FO", "CSV", "/NH"],
capture_output=True, text=True).stdout
return [int(m.group(1)) for m in re.finditer(r'"BatmanAC\.exe","(\d+)"', out)]
def kill_game():
for pid in running_game_pids():
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True)
def launch_game(exe, extra_args=()):
"""Start BatmanAC.exe directly (Steam must be running; SteamAppId makes steam_api accept it)."""
env = dict(os.environ, SteamAppId=str(APPID), SteamGameId=str(APPID))
return subprocess.Popen([exe] + list(extra_args), cwd=os.path.dirname(exe), env=env)
def wait_decrypted(pid, timeout=60):
t0 = time.time()
while time.time() - t0 < timeout:
if read_mem(pid, ADDR["sig_site"], len(SIG)) == SIG:
return time.time() - t0
time.sleep(0.05)
raise TimeoutError("code never decrypted (signature not found)")
def cdb_script(extra_bps="", dump_info=False):
"""Debugger command file: log codec setup creation + parse result; log 2nd-chance crashes."""
a = ADDR
dump = "dd poi(@esp+4) L0x30; " if dump_info else ""
return "\n".join([
".echotime",
"sxd av",
'sxd -c2 ".echo ACKIT_CRASH; .echotime; r; kv 30; .ecxr; q" av',
"sxn ch", "sxn sse", "sxn eh", "sxn clr", "sxn ld", "sxn ud",
"sxi ibp",
# stream codec creation: log the AkVorbisInfo fields of the stream being opened
'bp %#x ".printf \\"ACKIT_SETUP hash=%%08x frames=%%u alloc=%%u ch=%%u rate=%%u\\\\n\\", '
'poi(poi(@esp+4)+0x9c), poi(poi(@esp+4)+0x78), poi(poi(@esp+4)+0x94), '
'wo(poi(@esp+4)+0x5a), poi(poi(@esp+4)+0x5c); %sgc"' % (a["get_or_create_setup"], dump),
# setup parse result (0 = OK)
'bp %#x ".printf \\"ACKIT_PARSE ret=%%x\\\\n\\", @eax; gc"' % a["parse_setup_ret"],
extra_bps,
".echo ACKIT_ATTACHED",
"g",
])
class Run:
def __init__(self, exe, log_path, script_extra="", dump_info=False):
self.exe = exe
self.log_path = log_path
self.script_extra = script_extra
self.dump_info = dump_info
self.game = None
self.dbg = None
def start(self):
kill_game()
self.t0 = time.time()
self.game = launch_game(self.exe)
pid = self.game.pid
dt = wait_decrypted(pid)
script = self.log_path + ".cdbcmds"
open(script, "w").write(cdb_script(self.script_extra, self.dump_info))
self.dbg = subprocess.Popen([find_cdb(), "-p", str(pid), "-pd", "-logo", self.log_path,
"-y", "", "-cf", script],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
return dict(pid=pid, decrypt_s=dt)
def alive(self):
return self.game.poll() is None
def stop(self):
kill_game()
if self.dbg:
try:
self.dbg.wait(15)
except subprocess.TimeoutExpired:
self.dbg.kill()
def log(self):
try:
return open(self.log_path, encoding="utf-8", errors="replace").read()
except FileNotFoundError:
return ""

121
ackit/verify/ingame.py Normal file
View File

@@ -0,0 +1,121 @@
"""Automated in-game test (Windows): does a replaced sound actually play?
Flow: apply the replacement through `Workspace.apply` (the same code path as the editor) ->
start the game under cdb with logging breakpoints on the decoder + record the speakers (WASAPI
loopback) -> check: no crash, the codec setup of the injected WEM was created and parsed OK, and the
injected audio is heard -> kill the game -> revert and check the files are byte-identical again.
Default target: SFX music EDF036D6/393BC53A (intro/title music), which the game starts ~5 s after
launch without input. That slot ignores sources shorter than ~10 s, so probes are 30 s long.
Only correlation scores of the recording are kept (the loopback may contain other apps' audio).
"""
import hashlib
import json
import os
import re
import time
import numpy as np
from .. import audio
from ..originals import sha256
DEFAULT_TARGET = "SFX:EDF036D6/393BC53A"
RATE = 48000
def probe_signal(seconds=30.0, rate=RATE, seed=1234):
"""Pink-ish noise + vibrato tone: easy to recognise in a recording."""
rng = np.random.default_rng(seed)
n = int(seconds * rate)
white = rng.standard_normal(n)
pink = np.cumsum(white) * 0.002
pink -= np.convolve(pink, np.ones(2048) / 2048, mode="same")
t = np.arange(n) / rate
tone = 0.25 * np.sin(2 * np.pi * (440 + 20 * np.sin(2 * np.pi * 0.5 * t)) * t)
x = (pink / (np.max(np.abs(pink)) + 1e-9) * 0.35 + tone).astype(np.float32)
return x[:, None], rate
def _mono48(wem):
x, sr = audio.decode_wem(wem)
return audio.to_channels(audio.resample(x, sr, RATE), 1)[:, 0]
def _sha1_file(p):
h = hashlib.sha1()
with open(p, "rb") as f:
for c in iter(lambda: f.read(1 << 22), b""):
h.update(c)
return h.hexdigest()
def run(ws, key=DEFAULT_TARGET, wem=None, mode="modded", seconds=40.0, threshold=0.5,
logdir=None, revert_after=True, log=print):
"""mode: 'modded' (apply `wem`, test, revert), 'baseline' (nothing), 'installed' (test the
current state). Returns a report dict with report['result'] in ('PASS', 'FAIL')."""
from .debugger import Run, kill_game
from .loopback import LoopbackRecorder, detect_chunks
from ..inject import pack_bnk_path, parse_target
s = ws.catalog.resolve(key)
wad = ws.wad_path(s)
files = [wad] + ([pack_bnk_path(wad, parse_target(s.target)[0])] if s.is_pack_media else [])
logdir = logdir or os.path.join(os.getcwd(), "logs")
os.makedirs(logdir, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
logp = os.path.join(logdir, "ingame_%s.log" % stamp)
kill_game()
report = dict(target=s.key, name=s.name, mode=mode)
before = {f: _sha1_file(f) for f in files} if mode == "modded" else {}
if mode == "modded":
ws.apply([(s, wem, {"source": "ingame-test"})], progress=log)
expect = ws.current_bytes(s)
orig = ws.original_bytes(s)
from ..wem import Wem
report["expect_hash"] = "%08x" % Wem.parse(expect).vorb["codebook_hash"]
ref_expect, ref_orig = _mono48(expect), _mono48(orig)
rec = LoopbackRecorder(seconds + 10)
rec.start()
runner = Run(ws.game.exe, logp)
try:
report.update(runner.start())
while time.time() - runner.t0 < seconds and runner.alive():
time.sleep(0.5)
if re.search(r"^ACKIT_CRASH", runner.log(), re.M):
break
report["alive_at_end"] = runner.alive()
finally:
runner.stop()
rec.stop_flag = True
rec.join(5)
if mode == "modded" and revert_after:
ws.revert([s], progress=log)
report["reverted_byte_identical"] = all(_sha1_file(f) == h for f, h in before.items())
text = runner.log()
report["crash"] = bool(re.search(r"^ACKIT_CRASH", text, re.M))
ev = []
for ln in text.splitlines():
m = re.match(r"ACKIT_SETUP hash=([0-9a-f]+)", ln)
if m:
ev.append([m.group(1), "cached"])
m = re.match(r"ACKIT_PARSE ret=([0-9a-f]+)", ln)
if m and ev and ev[-1][1] == "cached":
ev[-1][1] = m.group(1)
report["setups"] = ["%s:%s" % tuple(e) for e in ev]
mine = [r for h, r in ev if h == report["expect_hash"]]
report["setup_ok"] = bool(mine) and all(r in ("0", "cached") for r in mine)
cap = rec.audio()
report["capture_error"] = rec.error
report["ncc_expected"] = round(detect_chunks(cap, ref_expect)[0], 3)
report["ncc_original"] = round(detect_chunks(cap, ref_orig)[0], 3)
ok = (not report["crash"] and report["alive_at_end"] and report["setup_ok"]
and report["ncc_expected"] >= threshold
and (mode == "baseline" or sha256(expect) == sha256(orig) or report["ncc_expected"] > report["ncc_original"])
and report.get("reverted_byte_identical", True))
report["result"] = "PASS" if ok else "FAIL"
json.dump(report, open(logp.replace(".log", ".json"), "w"), indent=1)
return report

87
ackit/verify/loopback.py Normal file
View File

@@ -0,0 +1,87 @@
"""WASAPI loopback capture of the default speaker + detection of a reference sound in it."""
import threading
import warnings
import numpy as np
RATE = 48000
class LoopbackRecorder(threading.Thread):
def __init__(self, seconds, rate=RATE):
super().__init__(daemon=True)
self.seconds = seconds
self.rate = rate
self.chunks = []
self.stop_flag = False
self.error = None
def run(self):
try:
import soundcard as sc
sp = sc.default_speaker()
mic = sc.get_microphone(id=str(sp.name), include_loopback=True)
block = self.rate // 10
with warnings.catch_warnings():
warnings.simplefilter("ignore") # "data discontinuity" warnings
with mic.recorder(samplerate=self.rate, blocksize=block) as rec:
n = int(self.seconds * 10)
for _ in range(n):
if self.stop_flag:
break
self.chunks.append(rec.record(numframes=block))
except Exception as e: # noqa
self.error = repr(e)
def audio(self):
if not self.chunks:
return np.zeros(0, dtype=np.float32)
a = np.concatenate(self.chunks, axis=0)
return a.mean(axis=1).astype(np.float32) if a.ndim == 2 else a.astype(np.float32)
def resample(x, src_rate, dst_rate):
if src_rate == dst_rate:
return x.astype(np.float32)
n = int(round(len(x) * dst_rate / src_rate))
t = np.linspace(0, len(x) - 1, n)
return np.interp(t, np.arange(len(x)), x).astype(np.float32)
def detect(capture, ref, rate=RATE):
"""Max normalized cross-correlation of ref against every same-length window of capture.
Returns (score 0..1, time_offset_seconds, ref_rms_in_window)."""
if len(capture) < len(ref) or len(ref) == 0:
return 0.0, None, 0.0
ref = ref - ref.mean()
rn = np.linalg.norm(ref)
if rn == 0:
return 0.0, None, 0.0
n = 1 << int(np.ceil(np.log2(len(capture) + len(ref))))
xc = np.fft.irfft(np.fft.rfft(capture, n) * np.conj(np.fft.rfft(ref, n)), n)[:len(capture) - len(ref) + 1]
c2 = np.concatenate([[0.0], np.cumsum(capture.astype(np.float64) ** 2)])
win = np.sqrt(np.maximum(c2[len(ref):] - c2[:-len(ref)], 1e-12))
ncc = xc / (rn * win)
# ignore near-silent windows
ncc[win < 1e-3 * np.sqrt(len(ref))] = 0
i = int(np.argmax(np.abs(ncc)))
return float(abs(ncc[i])), i / rate, float(win[i] / np.sqrt(len(ref)))
def detect_chunks(capture, ref, rate=RATE, chunk_s=1.0, step_s=0.5):
"""Best NCC over 1 s chunks of the reference (robust to partial playback and resampler drift).
Returns (best score, chunk start in ref [s], position in capture [s])."""
c = int(chunk_s * rate)
s = int(step_s * rate)
best = (0.0, None, None)
if len(ref) <= c:
sc, t, _ = detect(capture, ref, rate)
return sc, 0.0, t
for i in range(0, len(ref) - c + 1, s):
seg = ref[i:i + c]
if np.sqrt(np.mean(seg ** 2)) < 1e-3:
continue
sc, t, _ = detect(capture, seg, rate)
if sc > best[0]:
best = (sc, i / rate, t)
return best

93
ackit/verify/selfcheck.py Normal file
View File

@@ -0,0 +1,93 @@
"""Offline self-check against the installed game (no game launch, nothing is modified).
1. every audio archive parses and follows the WAD layout rules;
2. a sample of game WEMs is rebuilt byte-for-byte from the derived format rules;
3. a test signal is encoded for a speech and a music sound and decoded again (correlation check);
4. the originals store is consistent (blobs intact, recorded states).
"""
import random
import numpy as np
from .. import audio
from ..builder import build_wem, setup_header_values
from ..convert import library
from ..inject import is_pack
from ..wad import Wad
from ..wem import Wem, split_concatenated
from ..wwvorbis import transcode_setup, W2V
def rebuild_identical(r):
"""True if a game WEM is reproduced byte-for-byte by the builder."""
m = Wem.parse(r)
v = m.vorb
s = m.setup_packet()
_, info = transcode_setup(s, W2V, m.channels, library())
pk, _ = m.audio_packets()
mb = info.mode_bits
if v["bs0_exp"] != v["bs1_exp"]:
flags = [info.mode_blockflags[p[0] & ((1 << mb) - 1)] if mb else info.mode_blockflags[0] for _, p in pk]
else:
flags = [info.mode_blockflags[(p[0] >> 1) & ((1 << mb) - 1)] if mb else info.mode_blockflags[0] for _, p in pk]
hv = setup_header_values(s, m.channels)
extra = [c for c in m.chunks if c.tag not in (b"fmt ", b"data")]
for interval in (8192, 1024, 2048, 4096, 16384, 32768, 512, 256):
out = build_wem(m.channels, m.rate, v["bs0_exp"], v["bs1_exp"], s, [p for _, p in pk], flags,
v["total_frames"], hv["hash"], hv["alloc"], hv["alloc64"], m.channel_mask,
seek=v["seek_table_size"] > 0, seek_interval=interval, valid_bits=m.valid_bits,
extra_chunks=extra)
if out == r:
return True
if v["seek_table_size"] == 0:
break
return False
def run(ws, sample=300, log=print):
ok = True
# 1 + 2
for arch, path in ws.game.archives.items():
w = Wad(path)
probs = w.check()
rnd = random.Random(1)
ents = rnd.sample(w.entries, min(len(w.entries), 60))
n = good = 0
for e in ents:
if is_pack(path, e.hash): # stream packs are checked by the tests (they are large)
continue
for _, r in split_concatenated(w.read(e.hash))[0][:5]:
n += 1
try:
good += rebuild_identical(r)
except Exception:
pass
if n >= sample // max(1, len(ws.game.archives)):
break
log("%-4s layout %s, rebuilt %d/%d sample WEMs byte-identical" % (arch, "OK" if not probs else probs[:2], good, n))
ok &= not probs and good >= n - 2 # 4 looping music tracks are known exceptions
# 3
t = np.arange(48000 * 2) / 48000.0
sig = (0.3 * np.sin(2 * np.pi * 330 * t) * np.sin(2 * np.pi * 1.5 * t)).astype(np.float32)[:, None]
for s in [x for x in ws.catalog.sounds if x.group == "Speech"][:1] + \
[x for x in ws.catalog.sounds if x.group == "Music"][:1]:
wem, info = ws.prepare(s, sig, 48000)
y, sr = audio.decode_wem(wem)
ref = audio.to_channels(audio.resample(sig, 48000, sr), y.shape[1])
n = min(len(ref), len(y))
c = float(np.corrcoef(ref[:n, 0], y[:n, 0])[0, 1])
log("encode/decode %-30s %d Hz q%.1f corr %.3f" % (s.name[:30], info["rate"], info["quality"], c))
ok &= c > 0.95
# 4
bad = 0
for k, e in ws.store.entries.items():
try:
ws.store.get_blob(e["orig"])
if e.get("applied"):
ws.store.get_blob(e["applied"])
except (OSError, IOError):
bad += 1
log("originals store: %d record(s), %d damaged" % (len(ws.store.entries), bad))
ok &= bad == 0
log("SELF-CHECK %s" % ("PASS" if ok else "FAIL"))
return ok

122
ackit/wad.py Normal file
View File

@@ -0,0 +1,122 @@
"""Arkham City 'WWAD' container (SFX and *_LOC_*.wad).
Layout (little endian):
0x00 'WWAD'
0x04 u32 version (0x01020000)
0x08 u32 entry count N
0x0C u32 table offset (0x1C)
0x10 u32 table size (N*12)
0x14 u32 data start (header area size, 0x1000 or 0x8000; aligned)
0x18 u32 data size (file size - data start)
0x1C N x {u32 hash, u32 size, u32 offset} sorted by hash as *signed* int32
Entries are stored in table order, each at a 0x1000-aligned offset; 'size' is the
entry payload size padded to 0x800. Padding bytes are zero; the file ends 0x1000-aligned.
See docs/wad_format.md.
"""
import struct
from dataclasses import dataclass
MAGIC = b"WWAD"
ENTRY_ALIGN = 0x1000
SIZE_ALIGN = 0x800
def _s32(x):
return struct.unpack("<i", struct.pack("<I", x))[0]
def align(x, a):
return (x + a - 1) // a * a
@dataclass
class Entry:
hash: int
size: int
offset: int
class Wad:
def __init__(self, path):
self.path = path
with open(path, "rb") as f:
hdr = f.read(0x1C)
(magic, self.version, n, self.table_off, self.table_size,
self.data_start, self.data_size) = struct.unpack("<4s6I", hdr)
if magic != MAGIC:
raise ValueError("not a WWAD file")
f.seek(self.table_off)
tbl = f.read(12 * n)
f.seek(0)
self.header_area = f.read(self.data_start)
self.entries = [Entry(*struct.unpack_from("<III", tbl, 12 * i)) for i in range(n)]
self.index = {e.hash: e for e in self.entries}
def read(self, h) -> bytes:
e = self.index[h]
with open(self.path, "rb") as f:
f.seek(e.offset)
return f.read(e.size)
def iter_entries(self):
with open(self.path, "rb") as f:
for e in self.entries:
f.seek(e.offset)
yield e, f.read(e.size)
def check(self):
"""Validate structural invariants; returns list of problems (empty = OK)."""
probs = []
hs = [_s32(e.hash) for e in self.entries]
if hs != sorted(hs):
probs.append("table not sorted by signed hash")
if self.table_size != 12 * len(self.entries):
probs.append("table size mismatch")
pos = self.data_start
for e in self.entries:
if e.offset != align(pos, ENTRY_ALIGN):
probs.append("entry %08X at %x, expected %x" % (e.hash, e.offset, align(pos, ENTRY_ALIGN)))
if e.size % SIZE_ALIGN:
probs.append("entry %08X size not 0x800-aligned" % e.hash)
pos = e.offset + e.size
return probs
def write_wad(src: Wad, out_path: str, replacements: dict):
"""Rebuild a WAD, replacing entry payloads (hash -> bytes). Layout rules are reproduced
exactly so an unmodified rebuild is byte-identical to the original."""
n = len(src.entries)
payloads = {}
for h, data in replacements.items():
if h not in src.index:
raise KeyError("hash %08X not in WAD" % h)
payloads[h] = data + b"\0" * (align(len(data), SIZE_ALIGN) - len(data))
new_entries = []
pos = src.data_start
for e in src.entries:
size = len(payloads[e.hash]) if e.hash in payloads else e.size
off = align(pos, ENTRY_ALIGN)
new_entries.append(Entry(e.hash, size, off))
pos = off + size
end = pos
total = align(end, ENTRY_ALIGN)
hdr = bytearray(src.header_area)
struct.pack_into("<4s6I", hdr, 0, MAGIC, src.version, n, src.table_off, 12 * n, src.data_start,
total - src.data_start)
for i, e in enumerate(new_entries):
struct.pack_into("<III", hdr, src.table_off + 12 * i, e.hash, e.size, e.offset)
with open(src.path, "rb") as fi, open(out_path, "wb") as fo:
fo.write(hdr)
for old, new in zip(src.entries, new_entries):
fo.write(b"\0" * (new.offset - fo.tell()))
if old.hash in payloads:
fo.write(payloads[old.hash])
else:
fi.seek(old.offset)
remaining = old.size
while remaining:
chunk = fi.read(min(remaining, 1 << 22))
fo.write(chunk)
remaining -= len(chunk)
fo.write(b"\0" * (total - fo.tell()))
return new_entries

133
ackit/wem.py Normal file
View File

@@ -0,0 +1,133 @@
"""Wwise RIFF (.wem) container for Arkham City (Wwise 2011.1, fmt size 0x42, Vorbis 0xFFFF)."""
import re
import struct
from dataclasses import dataclass, field
VORB_FMT = "<IIIHHIIHHIII2B" # AkVorbisInfo as stored at fmt+0x18 (42 bytes)
VORB_FIELDS = ["total_frames", "loop_start_pkt", "loop_end_pkt", "loop_begin_extra", "loop_end_extra",
"seek_table_size", "vorbis_data_offset", "max_packet_size", "last_granule_extra",
"decode_alloc", "decode_alloc_x64", "codebook_hash", "bs0_exp", "bs1_exp"]
@dataclass
class Chunk:
tag: bytes
data: bytes
@dataclass
class Wem:
chunks: list
channels: int = 0
rate: int = 0
avg_bps: int = 0
valid_bits: int = 0
channel_mask: int = 0
vorb: dict = field(default_factory=dict)
# ---- parsing ----
@classmethod
def parse(cls, b: bytes) -> "Wem":
if b[:4] != b"RIFF" or b[8:12] != b"WAVE":
raise ValueError("not RIFF/WAVE")
riff_end = 8 + struct.unpack_from("<I", b, 4)[0]
pos = 12
chunks = []
while pos + 8 <= min(riff_end, len(b)):
tag, ln = struct.unpack_from("<4sI", b, pos)
chunks.append(Chunk(tag, b[pos + 8:pos + 8 + ln]))
pos += 8 + ln + (ln & 1)
w = cls(chunks)
fmt = w.get(b"fmt ")
if fmt is None or len(fmt) != 0x42:
raise ValueError("not a Wwise Vorbis WEM (fmt size %s)" % (None if fmt is None else hex(len(fmt))))
fmt_tag, w.channels, w.rate, w.avg_bps, balign, bps, cb = struct.unpack_from("<HHIIHHH", fmt, 0)
if fmt_tag != 0xFFFF or len(fmt) != 0x42 or cb != 0x30:
raise ValueError("unsupported fmt (tag=%x size=%x)" % (fmt_tag, len(fmt)))
w.valid_bits, w.channel_mask = struct.unpack_from("<HI", fmt, 0x12)
w.vorb = dict(zip(VORB_FIELDS, struct.unpack_from(VORB_FMT, fmt, 0x18)))
return w
def get(self, tag: bytes):
for c in self.chunks:
if c.tag == tag:
return c.data
return None
def set(self, tag: bytes, data: bytes):
for c in self.chunks:
if c.tag == tag:
c.data = data
return
self.chunks.append(Chunk(tag, data))
# ---- data chunk layout ----
@property
def data(self) -> bytes:
return self.get(b"data")
def seek_table(self):
n = self.vorb["seek_table_size"] // 4
return [struct.unpack_from("<HH", self.data, 4 * i) for i in range(n)]
def setup_packet(self) -> bytes:
d = self.data
off = self.vorb["seek_table_size"]
ln = struct.unpack_from("<H", d, off)[0]
return d[off + 2: off + 2 + ln]
def audio_packets(self):
"""List of (offset_in_data, payload) for audio packets (2-byte size headers)."""
d = self.data
pos = self.vorb["vorbis_data_offset"]
out = []
while pos + 2 <= len(d):
ln = struct.unpack_from("<H", d, pos)[0]
if ln == 0 or pos + 2 + ln > len(d):
break
out.append((pos, d[pos + 2: pos + 2 + ln]))
pos += 2 + ln
return out, pos
# ---- serialization ----
def build_fmt(self) -> bytes:
v = self.vorb
return (struct.pack("<HHIIHHH", 0xFFFF, self.channels, self.rate, self.avg_bps, 0, 0, 0x30)
+ struct.pack("<HI", self.valid_bits, self.channel_mask)
+ struct.pack(VORB_FMT, *[v[k] for k in VORB_FIELDS]))
def to_bytes(self) -> bytes:
self.set(b"fmt ", self.build_fmt())
body = b"WAVE"
for c in self.chunks:
# the game's WEMs never pad odd-sized chunks (all chunks before 'data' are even)
body += struct.pack("<4sI", c.tag, len(c.data)) + c.data
return b"RIFF" + struct.pack("<I", len(body)) + body
SUB_ALIGN = 0x800
_NONZERO = re.compile(rb"[^\x00]")
def split_concatenated(b: bytes):
"""All WEMs of a WAD entry, as [(offset, riff_bytes)], plus the end of the last one.
LOC entries group variations of a line (e.g. CHR_RadioScanner = 63 WEMs): each WEM starts at the
next 0x800 boundary after the previous one (zero padding); a few entries pack them back to back."""
out = []
pos = 0
end = 0
while pos + 12 <= len(b) and b[pos:pos + 4] == b"RIFF" and b[pos + 8:pos + 12] == b"WAVE":
end = pos + 8 + struct.unpack_from("<I", b, pos + 4)[0]
out.append((pos, b[pos:end]))
nxt = (end + SUB_ALIGN - 1) // SUB_ALIGN * SUB_ALIGN
if b[end:end + 4] == b"RIFF":
pos = end
elif b[nxt:nxt + 4] == b"RIFF":
pos = nxt
else: # zero padding of arbitrary length (e.g. after an in-place replacement)
m = _NONZERO.search(b, end)
if not m:
break
pos = m.start()
return out, end

197
ackit/workspace.py Normal file
View File

@@ -0,0 +1,197 @@
"""High-level operations shared by the CLI and the editor: apply / revert sounds safely.
Safety rules (docs/decisions.md):
* before the first change to a sound its original bytes go to the originals store;
* optionally a full copy of each WAD (and pack index) is taken once, as a fallback;
* each WAD is rewritten atomically; several sounds of one WAD are written in one pass;
* if a sound in the game is neither its original nor what we applied (Steam update, "verify
integrity", another tool), we refuse to write it unless forced, and report it.
"""
import os
from collections import defaultdict
from . import audio
from .catalog import Catalog
from .encoder import encode_fit
from .game import Game, GameError
from .inject import inject, read_target, parse_target, pack_bnk_path
from .originals import OriginalsStore, sha256, sha1_16
from .pack import Pack
from .paths import user_dir
from .wad import Wad
# sound states
ORIGINAL = "original" # untouched
MODIFIED = "modified" # contains what we applied
LOST = "lost" # we applied something, but the game has the original again (e.g. Steam verify)
CHANGED = "changed" # neither original nor ours: changed underneath (update / other tool)
FOREIGN = "foreign" # never touched by us, but differs from the catalog's original checksum
class ConflictError(GameError):
def __init__(self, sounds):
self.sounds = sounds
super().__init__("These sounds were changed outside this tool (Steam update or 'verify', or "
"another mod tool): %s.\nRevert them or confirm to overwrite."
% ", ".join(s.name for s in sounds[:5]) + (" ..." if len(sounds) > 5 else ""))
def default_store_root(game):
return os.path.join(user_dir("data"), "stores", game.store_id)
class Workspace:
def __init__(self, game_dir, store_root=None, archives=None, full_backup=True, progress=None):
self.game = game_dir if isinstance(game_dir, Game) else Game(game_dir)
self.store = OriginalsStore(store_root or default_store_root(self.game), self.game.root)
self.catalog = Catalog(self.game, archives, progress)
self.full_backup = full_backup
# reading -----------------------------------------------------------------------------------
def sound(self, name_or_key):
return self.catalog.resolve(name_or_key)
def wad_path(self, s):
return self.game.wad(s.archive)
def current_bytes(self, s):
return read_target(self.wad_path(s), s.target)
def original_bytes(self, s):
return self.store.original(s.key) or self.current_bytes(s)
def state(self, s, current=None):
cur = current if current is not None else self.current_bytes(s)
e = self.store.get(s.key)
if e:
h = sha256(cur)
if e.get("applied") and h == e["applied"]:
return MODIFIED
if h == e["orig"]:
return LOST if e.get("applied") else ORIGINAL
return CHANGED
if s.sha1 and sha1_16(cur) != s.sha1:
return FOREIGN
return ORIGINAL
def modified_keys(self):
return [k for k, e in self.store.entries.items() if e.get("applied")]
def limit(self, s):
"""Max size in bytes for a replacement (None = any size)."""
if s.is_pack_media:
return None
e = self.store.get(s.key)
if e:
return e["orig_size"]
if s.sha1 and s.size:
return s.size
return len(self.current_bytes(s))
def original_audio(self, s):
return audio.decode_wem(self.original_bytes(s))
def current_audio(self, s):
return audio.decode_wem(self.current_bytes(s))
# encoding ----------------------------------------------------------------------------------
def prepare(self, s, x, src_rate, quality="auto", progress=None):
"""Encode PCM for sound `s` (its rate/channels, fitted to its size limit) -> (wem, info)."""
return encode_fit(x, src_rate, s.rate or src_rate, s.channels or x.shape[1], self.limit(s),
quality, progress)
def prepare_file(self, s, path, quality="auto", progress=None):
x, sr = audio.load(path)
return self.prepare(s, x, sr, quality, progress)
# writing -----------------------------------------------------------------------------------
def _backup(self, wad, pack_hashes):
if not self.full_backup:
return
self.store.full_backup(wad, self.game.rel(wad))
for h in pack_hashes:
b = pack_bnk_path(wad, h)
self.store.full_backup(b, self.game.rel(b))
def _write(self, archive, items, progress=None):
"""items: [(sound, new_bytes, meta, is_revert)] all in one archive."""
wad = self.game.wad(archive)
self.game.check_writable(wad)
packs = {parse_target(s.target)[0] for s, _, _, _ in items if s.is_pack_media}
self._backup(wad, packs)
w = Wad(wad)
for h in packs:
if self.store.pack_gaps(h) is None:
self.store.remember_pack_gaps(h, Pack(open(pack_bnk_path(wad, h), "rb").read()).gaps(w.read(h)))
repl, limits = {}, {}
for s, data, meta, is_revert in items:
if not is_revert:
self.store.remember_original(s.key, s.archive, s.target, self.game.rel(wad),
read_target(wad, s.target, w))
repl[s.target] = data
limits[s.target] = self.limit(s)
self.store.save() # originals are safe on disk before the game file changes
if progress:
progress("writing %s" % os.path.basename(wad))
inject(wad, repl, limits, pack_gaps={h: self.store.pack_gaps(h) for h in packs})
for s, data, meta, is_revert in items:
if is_revert:
self.store.forget(s.key)
else:
self.store.record_applied(s.key, data, meta)
self.store.save()
def apply(self, changes, force=False, progress=None):
"""changes: [(sound, wem_bytes, meta dict)]. One atomic rewrite per WAD."""
conflicts = []
for s, _, _ in changes:
if self.state(s) in (CHANGED, FOREIGN):
conflicts.append(s)
if conflicts and not force:
raise ConflictError(conflicts)
by_arch = defaultdict(list)
for s, data, meta in changes:
by_arch[s.archive].append((s, data, meta, False))
for a, items in by_arch.items():
self._write(a, items, progress)
def revert(self, sounds, progress=None):
"""Put the stored originals back (sounds without a record are skipped). Returns reverted."""
by_arch = defaultdict(list)
done = []
for s in sounds:
orig = self.store.original(s.key)
if orig is None:
continue
by_arch[s.archive].append((s, orig, None, True))
done.append(s)
for a, items in by_arch.items():
self._write(a, items, progress)
self.store.gc()
return done
def revert_all(self, progress=None):
sounds = [self.catalog.by_key[k] for k in list(self.store.entries) if k in self.catalog.by_key]
missing = [k for k in self.store.entries if k not in self.catalog.by_key]
if missing:
# records of archives not loaded in this catalog (e.g. another language)
other = Catalog(self.game, sorted({k.split(":")[0] for k in missing}))
sounds += [other.by_key[k] for k in missing if k in other.by_key]
return self.revert(sounds, progress)
def restore_from_full_backup(self):
"""Last-resort fallback: copy back every full backup taken by this store."""
out = []
for rel in self.store.data.get("full", {}):
src = self.store.full_copy(rel)
dst = os.path.join(self.game.root, rel)
self.game.check_writable(dst)
tmp = dst + ".ackit-tmp"
import shutil
shutil.copy2(src, tmp)
os.replace(tmp, dst)
out.append(rel)
self.store.data["entries"] = {}
self.store.save()
self.store.gc()
return out

221
ackit/wwvorbis.py Normal file
View File

@@ -0,0 +1,221 @@
"""Wwise 2011 ("v56"-style) Vorbis <-> standard Vorbis conversion.
Wwise stores a stripped setup header (codebooks referenced by 10-bit IDs into the
built-in library, narrowed fields) and "modified" audio packets (no packet-type bit,
no prev/next window flags). See docs/wem_format.md.
"""
from .bits import BitReader, BitWriter, ilog
from .codebooks import CodebookLibrary, wwise_book_to_vorbis, skip_vorbis_book, book_bits_key
W2V = "w2v" # Wwise -> Vorbis
V2W = "v2w" # Vorbis -> Wwise
class SetupInfo:
def __init__(self):
self.codebook_ids = []
self.codebook_dims_entries = []
self.mode_blockflags = []
self.floor_count = 0
self.residue_count = 0
self.mapping_count = 0
@property
def mode_bits(self):
return ilog(len(self.mode_blockflags) - 1)
def transcode_setup(src: bytes, direction: str, channels: int, lib: CodebookLibrary) -> (bytes, SetupInfo):
"""Convert a setup packet between the Wwise stripped form and the standard Vorbis form."""
info = SetupInfo()
w = BitWriter()
if direction == W2V:
r = BitReader(src)
w.put(8, 5)
for c in b"vorbis":
w.put(8, c)
else:
if src[:7] != b"\x05vorbis":
raise ValueError("not a vorbis setup packet")
r = BitReader(src, 7 * 8)
def f(ww_bits, vb_bits=None):
"""Transcode one field; returns its value."""
if vb_bits is None:
vb_bits = ww_bits
if direction == W2V:
v = r.get(ww_bits); w.put(vb_bits, v)
else:
v = r.get(vb_bits); w.put(ww_bits, v)
if v >> ww_bits:
raise ValueError("value %d does not fit in %d Wwise bits" % (v, ww_bits))
return v
def const(vb_bits, value, what):
"""Field present only in Vorbis form, with a fixed value."""
if direction == W2V:
w.put(vb_bits, value)
else:
v = r.get(vb_bits)
if v != value:
raise ValueError("%s must be %d for Wwise, got %d" % (what, value, v))
# codebooks
nbooks = f(8) + 1
for _ in range(nbooks):
if direction == W2V:
cid = r.get(10)
if cid >= len(lib.books):
raise ValueError("codebook id %d out of range" % cid)
wwise_book_to_vorbis(lib.books[cid], w)
else:
start = r.pos
de = skip_vorbis_book(r)
info.codebook_dims_entries.append(de)
cid = lib.lookup(book_bits_key(src, start, r.pos))
if cid is None:
raise ValueError("codebook #%d not in game library (dims=%d entries=%d)" % (len(info.codebook_ids), de[0], de[1]))
w.put(10, cid)
info.codebook_ids.append(cid)
# time domain transforms (Vorbis only: count-1=0, one 16-bit zero)
if direction == W2V:
w.put(6, 0); w.put(16, 0)
else:
tc = r.get(6) + 1
for _ in range(tc):
if r.get(16) != 0:
raise ValueError("nonzero time domain value")
# floors
nfloors = f(6) + 1
info.floor_count = nfloors
for _ in range(nfloors):
const(16, 1, "floor type")
parts = f(5)
classes = [f(4) for _ in range(parts)]
maxc = max(classes) if classes else -1
dims = []
for _ in range(maxc + 1):
dims.append(f(3) + 1)
sub = f(2)
if sub:
f(8)
for _ in range(1 << sub):
f(8)
f(2)
rb = f(4)
for c in classes:
for _ in range(dims[c]):
f(rb)
# residues
nres = f(6) + 1
info.residue_count = nres
for _ in range(nres):
rtype = f(2, 16)
if rtype > 2:
raise ValueError("bad residue type")
f(24); f(24); f(24)
ncls = f(6) + 1
f(8)
cascade = []
for _ in range(ncls):
lo = f(3)
hi = f(5) if f(1) else 0
cascade.append(hi * 8 + lo)
for c in cascade:
for k in range(8):
if c & (1 << k):
f(8)
# mappings
nmap = f(6) + 1
info.mapping_count = nmap
for _ in range(nmap):
const(16, 0, "mapping type")
submaps = f(4) + 1 if f(1) else 1
if f(1):
steps = f(8) + 1
b = ilog(channels - 1)
for _ in range(steps):
f(b); f(b)
if f(2) != 0:
raise ValueError("mapping reserved != 0")
if submaps > 1:
for _ in range(channels):
f(4)
for _ in range(submaps):
f(8); f(8); f(8)
# modes
nmodes = f(6) + 1
for _ in range(nmodes):
bf = f(1)
const(16, 0, "window type")
const(16, 0, "transform type")
f(8)
info.mode_blockflags.append(bf)
if direction == W2V:
w.put(1, 1) # framing
else:
if r.get(1) != 1:
raise ValueError("framing bit missing")
return w.bytes(), info
def build_id_header(channels: int, rate: int, bs0_exp: int, bs1_exp: int) -> bytes:
import struct
return (b"\x01vorbis" + struct.pack("<IBIiii", 0, channels, rate, 0, 0, 0)
+ bytes([(bs1_exp << 4) | bs0_exp, 1]))
def build_comment_header(vendor: bytes = b"ackit") -> bytes:
import struct
return b"\x03vorbis" + struct.pack("<I", len(vendor)) + vendor + struct.pack("<I", 0) + b"\x01"
def ww_packet_to_vorbis(pkt: bytes, next_first: int, info: SetupInfo, prev_bf: int,
modified: bool = True) -> (bytes, int):
"""Rebuild a standard audio packet from a Wwise modified packet. Returns (packet, blockflag).
When blocksizes are equal Wwise keeps standard packets (modified=False)."""
mb = info.mode_bits
if not modified:
mode = (pkt[0] >> 1) & ((1 << mb) - 1)
return pkt, info.mode_blockflags[mode]
v = int.from_bytes(pkt, "little")
nbits = len(pkt) * 8
mode = v & ((1 << mb) - 1)
bf = info.mode_blockflags[mode]
out = 0 # packet type bit 0
pos = 1
out |= mode << pos; pos += mb
if bf:
nbf = 0 if next_first is None else info.mode_blockflags[next_first & ((1 << mb) - 1)]
out |= prev_bf << pos; pos += 1
out |= nbf << pos; pos += 1
rest = v >> mb
out |= rest << pos
pos += nbits - mb
return out.to_bytes((pos + 7) // 8, "little"), bf
def vorbis_packet_to_ww(pkt: bytes, info: SetupInfo, modified: bool = True) -> (bytes, int):
"""Strip packet-type bit and window flags. Returns (packet, blockflag)."""
mb = info.mode_bits
if not modified:
if pkt[0] & 1:
raise ValueError("not an audio packet")
return pkt, info.mode_blockflags[(pkt[0] >> 1) & ((1 << mb) - 1)]
v = int.from_bytes(pkt, "little")
nbits = len(pkt) * 8
if v & 1:
raise ValueError("not an audio packet")
mode = (v >> 1) & ((1 << mb) - 1)
bf = info.mode_blockflags[mode]
drop = 1 + mb + (2 if bf else 0)
rest = v >> drop
out = mode | (rest << mb)
obits = nbits - (drop - mb)
return out.to_bytes((obits + 7) // 8, "little"), bf