148 lines
7.5 KiB
Python
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
|