Version 3.1.0: voice lines linked across all languages from the game data

This commit is contained in:
2026-10-10 14:10:03 +02:00
parent ea3e44000a
commit fbde809370
56 changed files with 74728 additions and 73360 deletions

124
tests/test_lines.py Normal file
View File

@@ -0,0 +1,124 @@
"""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.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):
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])
blob += synth.bank(bank_id, {}, 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"]