Files
UnheardCity/tests/synth.py

176 lines
7.6 KiB
Python

"""Build a small synthetic Arkham City folder (WADs, a stream pack) for tests that must not depend on
real game data. Layout rules follow docs/formats/wad.md; WEMs come from ackit's own encoder."""
import os
import struct
import numpy as np
from ackit import encoder
SPEECH_ENTRY = 0xB49CDF48 # CHR_RadioScanner (3 variations here)
SINGLE_ENTRY = 0x3F21770E # an entry with one WEM
PACK_ID = 0xEDF036D6 # 'Music' stream pack
MEDIA = (0x003CB311, 0x393BC53A, 0x40000001)
AMB_ENTRY = 0x98A82EFF # AMB_Steel_B3
def tone(seconds, rate=24000, freq=440.0, ch=1):
t = np.arange(int(seconds * rate)) / rate
x = (0.3 * np.sin(2 * np.pi * freq * t) * (0.6 + 0.4 * np.sin(2 * np.pi * 2 * t))).astype(np.float32)
return np.repeat(x[:, None], ch, axis=1), rate
def wem(seconds, freq, rate=24000, ch=1):
x, sr = tone(seconds, rate, freq, ch)
return encoder.encode(x, sr, rate, ch)[0]
def _a(x, n):
return (x + n - 1) // n * n
def write_wad(path, payloads):
"""payloads: {hash: bytes}"""
s32 = lambda h: struct.unpack("<i", struct.pack("<I", h))[0]
hashes = sorted(payloads, key=s32)
n = len(hashes)
data_start = _a(0x1C + 12 * n, 0x1000)
pos, table, body = data_start, [], []
for h in hashes:
off = _a(pos, 0x1000)
p = payloads[h] + bytes(_a(len(payloads[h]), 0x800) - len(payloads[h]))
table.append((h, len(p), off))
body.append((off, p))
pos = off + len(p)
total = _a(pos, 0x1000)
out = bytearray(total)
struct.pack_into("<4s6I", out, 0, b"WWAD", 0x01020000, n, 0x1C, 12 * n, data_start, total - data_start)
for i, t in enumerate(table):
struct.pack_into("<III", out, 0x1C + 12 * i, *t)
for off, p in body:
out[off:off + len(p)] = p
os.makedirs(os.path.dirname(path), exist_ok=True)
open(path, "wb").write(bytes(out))
def grouped(wems, junk_gap=False):
"""Several WEMs in one entry, each at the next 0x800 boundary (zero padding)."""
out = bytearray()
for w in wems:
out += bytes(_a(len(out), 0x800) - len(out))
out += w
return bytes(out)
def pack(media):
"""-> (bnk bytes, wad payload) for {media id: wem}. One gap gets non-zero bytes, like the real
'Music' pack, to test byte-exact reverts."""
ids = sorted(media)
payload = bytearray()
table = []
for i, m in enumerate(ids):
start = _a(len(payload), 0x800)
gap = bytearray(start - len(payload))
if i == 1 and len(gap) > 4:
gap[1:4] = b"\x11\x22\x33"
payload += gap
table.append((m, len(media[m]), len(payload)))
payload += media[m]
payload += bytes(_a(len(payload), 0x800) - len(payload))
bank = b"BKHD" + struct.pack("<I", 8) + struct.pack("<II", 56, PACK_ID)
n = len(ids)
hdr = bytearray(0x800)
struct.pack_into("<4sII", hdr, 0, b"BKPK", 0x01020000, PACK_ID)
struct.pack_into("<IIIIII", hdr, 0x10, n, 0, 12 * n, 0x48, len(payload), 0)
struct.pack_into("<I", hdr, 0x2C, 0x48 + 12 * n)
struct.pack_into("<II", hdr, 0x38, len(bank), 0x800)
for i, t in enumerate(table):
struct.pack_into("<III", hdr, 0x48 + 12 * i, *t)
return bytes(hdr) + bank, bytes(payload)
def bank(bank_id, media, name=None, extra_sources=()):
"""A v56 soundbank holding `media` {id: wem} in memory (DIDX/DATA, 16-byte aligned) with one HIRC
Sound object per media; extra_sources: [(media id, bank id, off, size)] for sources of other banks."""
data = bytearray()
didx = b""
for m in sorted(media):
data += bytes(_a(len(data), 16) - len(data))
didx += struct.pack("<III", m, len(data), len(media[m]))
data += media[m]
bkhd = struct.pack("<II", 56, bank_id) + bytes(8)
head = b"BKHD" + struct.pack("<I", len(bkhd)) + bkhd + b"DIDX" + struct.pack("<I", len(didx)) + didx
data_pos = len(head) + 8 # DATA payload, from the start of the bank
objs = []
for i, m in enumerate(sorted(media)):
off = struct.unpack_from("<I", didx, 12 * i + 4)[0]
objs.append((m, bank_id, data_pos + off, len(media[m])))
objs += list(extra_sources)
hirc = struct.pack("<I", len(objs))
for i, (m, b, off, size) in enumerate(objs):
body = struct.pack("<7I", 0x100 + i, 0x00040001, 0, m, b, off, size) + bytes(6)
hirc += bytes([2]) + struct.pack("<I", len(body)) + body
out = head + b"DATA" + struct.pack("<I", len(data)) + bytes(data) + b"HIRC" + struct.pack("<I", len(hirc)) + hirc
if name:
nm = name.encode()
stid = struct.pack("<II", 1, 1) + struct.pack("<IB", bank_id, len(nm)) + nm
out += b"STID" + struct.pack("<I", len(stid)) + stid
return out
def package(path, body, block_size=0x1000, chunk_blocks=3):
"""A fully LZO-compressed UE3 package (docs/formats/upk.md) whose uncompressed data area is `body`.
Small blocks so that tests cross block and chunk boundaries."""
from ackit import lzo
folder = b"None\x00"
nblocks = max(1, _a(len(body), block_size) // block_size)
nchunks = _a(nblocks, chunk_blocks) // chunk_blocks
def summary(chunks):
s = struct.pack("<IHHi", 0x9E2A83C1, 805, 101, 0) + struct.pack("<i", len(folder)) + folder
s += struct.pack("<12i", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + b"GUID-synthetic!!"
s += struct.pack("<i3i", 1, 0, 0, 0) + struct.pack("<3I", 7748, 0, 2)
s += struct.pack("<i", len(chunks)) + b"".join(struct.pack("<4i", *c) for c in chunks)
return s + struct.pack("<Ii", 0x12345678, 0)
header_u = len(summary([])) + 16 # uncompressed header (some spare bytes)
chunks, blobs = [], []
coff = len(summary([(0, 0, 0, 0)] * nchunks)) + 16
u = header_u
for c in range(nchunks):
part = body[(u - header_u):(u - header_u) + block_size * chunk_blocks]
blocks = [part[i:i + block_size] for i in range(0, len(part), block_size)] or [b""]
comp = [lzo.compress_stored(b) for b in blocks]
hdr = struct.pack("<4I", 0x9E2A83C1, block_size, sum(map(len, comp)), len(part))
hdr += b"".join(struct.pack("<2I", len(cb), len(b)) for cb, b in zip(comp, blocks))
blob = hdr + b"".join(comp)
chunks.append((u, len(part), coff, len(blob)))
blobs.append(blob)
coff += len(blob)
u += len(part)
s = summary(chunks)
out = s + bytes(16) + b"".join(blobs)
assert len(s) + 16 == chunks[0][2]
os.makedirs(os.path.dirname(path), exist_ok=True)
open(path, "wb").write(out)
return header_u
def make_game(root):
"""Create the folder; returns the dict of original WEMs by target."""
cooked = os.path.join(root, "BmGame", "CookedPCConsole")
v = [wem(1.0, 300), wem(1.6, 500), wem(0.7, 700)]
single = wem(2.0, 350)
write_wad(os.path.join(cooked, "English(US)", "28CCF006_LOC_INT.wad"),
{SPEECH_ENTRY: grouped(v), SINGLE_ENTRY: single})
music = {MEDIA[0]: wem(1.5, 600, 48000, 2), MEDIA[1]: wem(3.0, 250, 48000, 2), MEDIA[2]: wem(0.8, 900, 48000, 2)}
bnk, payload = pack(music)
amb = grouped([wem(1.2, 200, 32000, 2), wem(0.9, 260, 32000, 2)])
write_wad(os.path.join(cooked, "SFX", "17705D3E.wad"), {PACK_ID: payload, AMB_ENTRY: amb})
open(os.path.join(cooked, "SFX", "%08X.bnk" % PACK_ID), "wb").write(bnk)
os.makedirs(os.path.join(root, "Binaries", "Win32"), exist_ok=True)
open(os.path.join(root, "Binaries", "Win32", "BatmanAC.exe"), "wb").write(b"MZ")
originals = {"%08X:%d" % (SPEECH_ENTRY, i): w for i, w in enumerate(v)}
originals["%08X" % SINGLE_ENTRY] = single
originals.update({"%08X/%08X" % (PACK_ID, m): w for m, w in music.items()})
return originals