Initial commit
This commit is contained in:
10
ackit/__init__.py
Normal file
10
ackit/__init__.py
Normal 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
3
ackit/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cli import main
|
||||
|
||||
main()
|
||||
161
ackit/allocsize.py
Normal file
161
ackit/allocsize.py
Normal 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
136
ackit/audio.py
Normal 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
67
ackit/bits.py
Normal 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
128
ackit/builder.py
Normal 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
262
ackit/catalog.py
Normal 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
107
ackit/classify.py
Normal 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
335
ackit/cli.py
Normal 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
118
ackit/codebooks.py
Normal 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
56
ackit/convert.py
Normal 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))
|
||||
BIN
ackit/data/game_codebooks.bin
Normal file
BIN
ackit/data/game_codebooks.bin
Normal file
Binary file not shown.
19063
ackit/data/hashlist.txt
Normal file
19063
ackit/data/hashlist.txt
Normal file
File diff suppressed because it is too large
Load Diff
278
ackit/data/known_setups.json
Normal file
278
ackit/data/known_setups.json
Normal 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
155
ackit/encoder.py
Normal 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
205
ackit/game.py
Normal 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
133
ackit/inject.py
Normal 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
25
ackit/known_setups.py
Normal 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
120
ackit/modpack.py
Normal 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
72
ackit/ogg.py
Normal 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
143
ackit/originals.py
Normal 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
92
ackit/pack.py
Normal 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
44
ackit/paths.py
Normal 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
32
ackit/settings.py
Normal 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
0
ackit/verify/__init__.py
Normal file
183
ackit/verify/debugger.py
Normal file
183
ackit/verify/debugger.py
Normal 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
121
ackit/verify/ingame.py
Normal 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
87
ackit/verify/loopback.py
Normal 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
93
ackit/verify/selfcheck.py
Normal 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
122
ackit/wad.py
Normal 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
133
ackit/wem.py
Normal 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
197
ackit/workspace.py
Normal 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
221
ackit/wwvorbis.py
Normal 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
|
||||
Reference in New Issue
Block a user