Files
UnheardCity/tests/test_lines.py

148 lines
7.5 KiB
Python

"""Speech lines across languages (ackit/lines.py) on a small two-language game built like the real one:
one localized AkBank export holds, per declared language, the BKPK table of the speech entry and a bank whose
Sound objects (same IDs in every language) name that language's recordings."""
import hashlib
import os
import struct
from ackit import banks
from ackit.bnk import fnv1
from ackit.catalog import build_lines
from ackit.game import Game
from ackit.wem import fill
from ackit.workspace import ORIGINAL, MODIFIED, Workspace
import synth
ENTRY, COPY_ENTRY = 0x51000001, 0x52000001
EV_A, EV_B = "Speech_Lines_A", "Speech_Lines_B"
# media: line A has its own ID per language; line B uses one ID in both languages (as cinematics do)
A = {"INT": 0x10, "ITA": 0x20}
B = 0x77
def _table(entry, rows):
"""BKPK table: rows [(media, wem bytes, offset in the entry)]."""
t = bytearray(0x48)
struct.pack_into("<4sII", t, 0, b"BKPK", 0x01020000, entry)
end = max(o + len(w) for _, w, o in rows)
struct.pack_into("<IIIIII", t, 0x10, len(rows), 0, 36, 0x48, synth._a(end, 0x800), 0)
return bytes(t) + b"".join(struct.pack("<III", m, len(w), o) for m, w, o in rows)
def _offsets(wems):
out, pos = [], 0
for w in wems:
pos = synth._a(pos, 0x800)
out.append(pos)
pos += len(w)
return out
def make(root, whole_copy=False):
"""whole_copy: INT's bank also stores INT's recording of line A whole, in memory (same media ID)."""
cooked = os.path.join(root, "BmGame", "CookedPCConsole")
a = {"INT": synth.wem(0.6, 400), "ITA": synth.wem(0.7, 420)}
b = {"INT": synth.wem(1.1, 600), "ITA": synth.wem(0.9, 640)}
# WEMs are stored in media-ID order: INT [A 0x10, B 0x77], ITA [A 0x20, B 0x77] -> here INT puts A first,
# ITA is written the other way round to show that positions do not matter
order = {"INT": [a["INT"], b["INT"]], "ITA": [b["ITA"], a["ITA"]]}
rows = {}
for L, ws in order.items():
offs = _offsets(ws)
media = [A[L], B] if L == "INT" else [B, A[L]]
rows[L] = list(zip(media, ws, offs))
wads = {"INT": ("English(US)", "28CCF006_LOC_INT.wad"), "ITA": ("Italian", "49D84887_LOC_ITA.wad")}
for L, (folder, name) in wads.items():
payloads = {ENTRY: synth.grouped(order[L])}
if L == "INT":
payloads[COPY_ENTRY] = a["INT"] # the same recording stored a second time
synth.write_wad(os.path.join(cooked, folder, name), payloads)
# the localized export: language list, then per language its BKPK table(s) and its bank
lst = struct.pack("<I", 2) + synth._fstr("INT") + synth._fstr("ITA")
blob = bytes(4) + struct.pack("<HHiiii", 9, 0x84, 0, 0, len(lst), 0) + lst
bank_id = fnv1("Speech_Lines")
for L in ("INT", "ITA"):
blob += _table(ENTRY, rows[L])
mem = {A[L]: a[L]} if whole_copy and L == "INT" else {}
blob += synth.bank(bank_id, mem, name="Speech_Lines", events=[(EV_A, A[L]), (EV_B, B)]) + bytes(16)
# the copy entry's table sits in a second localized export (INT and ITA; ITA has no such entry: no rows)
blob2 = bytes(4) + struct.pack("<HHiiii", 9, 0x84, 0, 0, len(lst), 0) + lst
blob2 += _table(COPY_ENTRY, [(A["INT"], a["INT"], 0)]) + bytes(32) + _table(COPY_ENTRY, [(0x999, b"x" * 8, 0)])
synth.ue_package(os.path.join(cooked, "Lines.upk"),
[("AkEvent", EV_A, bytes(8)), ("AkEvent", EV_B, bytes(8)),
("AkBank", "Speech_Lines", blob), ("AkBank", "Speech_Lines_Copy", blob2)])
with open(os.path.join(cooked, "CookedBanks.txt"), "w", newline="\r\n") as f:
f.write("Speech_Lines\n")
os.makedirs(os.path.join(root, "Binaries", "Win32"), exist_ok=True)
open(os.path.join(root, "Binaries", "Win32", "BatmanAC.exe"), "wb").write(b"MZ")
return a, b
def test_tables_take_the_language_their_export_declares(tmp_path):
root = str(tmp_path / "game")
a, b = make(root)
sc = banks.scan(Game(root).cooked)
assert set(sc.packs[ENTRY]) == {"INT", "ITA"}
# the shared media ID has a different position in each language: nothing is merged
assert sc.packs[ENTRY]["INT"][B] != sc.packs[ENTRY]["ITA"][B]
assert sc.packs[ENTRY]["INT"][B][0] == synth._a(len(a["INT"]), 0x800)
assert sc.packs[ENTRY]["ITA"][B][0] == 0
assert B in sc.lang_media["INT"] and B in sc.lang_media["ITA"] and A["ITA"] in sc.lang_media["ITA"]
def test_lines_are_one_to_one_across_languages(tmp_path):
root = str(tmp_path / "game")
make(root)
game = Game(root)
lines = build_lines(game, banks.scan(game.cooked))
assert not [i for i in lines.issues if i[0] != "row without WEM"] # (the dummy ITA copy-table row)
la, lb = lines.line_of["INT:%08X:0" % ENTRY], lines.line_of["INT:%08X:1" % ENTRY]
assert la != lb
assert lines.line_of["ITA:%08X:1" % ENTRY] == la # A: position 1 in ITA
assert lines.line_of["ITA:%08X:0" % ENTRY] == lb # B: same media ID, position 0
assert lines.members[la] == {"INT": "INT:%08X:0" % ENTRY, "ITA": "ITA:%08X:1" % ENTRY}
assert lines.copy_of == {"INT:%08X" % COPY_ENTRY: "INT:%08X:0" % ENTRY}
assert lines.line_of["INT:%08X" % COPY_ENTRY] == la
assert lines.stats["lines_in_every_language"] == 2
def test_names_and_copies_through_the_workspace(tmp_path):
root = str(tmp_path / "game")
a, _ = make(root)
ws = Workspace(root, str(tmp_path / "store"), ["INT"])
s = ws.catalog.by_key["INT:%08X:0" % ENTRY]
ita = Workspace(root, str(tmp_path / "store_ita"), ["ITA"]).catalog.by_key["ITA:%08X:1" % ENTRY]
assert s.name == ita.name == EV_A and s.line == ita.line # one name, one line, any position
copy = ws.catalog.by_key["INT:%08X" % COPY_ENTRY]
assert copy.line == s.line and [c.key for c in ws.copies(s)] == [copy.key]
wad = ws.wad_path(s)
before = hashlib.sha1(open(wad, "rb").read()).hexdigest()
new = synth.wem(0.3, 900)
ws.apply([(s, new, {})]) # written into both places
assert ws.current_bytes(s) == new and ws.current_bytes(copy) == new
assert ws.state(s) == MODIFIED and ws.state(copy) == MODIFIED
ws.revert([s]) # and reverted together
assert ws.state(s) == ORIGINAL and ws.state(copy) == ORIGINAL and not ws.store.entries
assert hashlib.sha1(open(wad, "rb").read()).hexdigest() == before
assert ws.current_bytes(copy) == a["INT"]
def test_whole_recording_in_a_bank_is_written_with_its_line(tmp_path):
"""A streamed recording can also be stored whole in a bank (same media ID, e.g. the game-over taunts): like
a prefetch head, that copy belongs to the WAD line and is written with it; it is not a sound of its own."""
root = str(tmp_path / "game")
a, _ = make(root, whole_copy=True)
ws = Workspace(root, str(tmp_path / "store"), ["INT", "BANK"])
assert "BANK:%08X" % A["INT"] not in ws.catalog.by_key
s = ws.catalog.by_key["INT:%08X:0" % ENTRY]
(l,) = ws.locations(s)
assert l.kind == banks.PREFETCH_HEAD and l.size == len(a["INT"]) and l.file == "Lines.upk"
pkg = os.path.join(ws.game.cooked, "Lines.upk")
before = hashlib.sha1(open(pkg, "rb").read()).hexdigest()
new = synth.fitting_wem(ws, s, 0.3, 900)
ws.apply([(s, new, {})])
assert banks.open_file(pkg).read(l.offset, l.size) == fill(new, l.size) # filled exactly, not padded
assert ws.current_bytes(s) == new
ws.revert([s])
assert hashlib.sha1(open(pkg, "rb").read()).hexdigest() == before