Version 3.1.0: voice lines linked across all languages from the game data
This commit is contained in:
124
tests/test_lines.py
Normal file
124
tests/test_lines.py
Normal 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"]
|
||||
Reference in New Issue
Block a user