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

View File

@@ -56,7 +56,7 @@ def type_search(w, text):
def test_browse_search_filter(win):
w, root, _ = win
assert w.model.rowCount() == len(w.ws.catalog.sounds)
type_search(w, "radio scanner 2")
type_search(w, "synth radio 02")
assert w.proxy.rowCount() == 1
type_search(w, "")
w.cmb_group.setCurrentText("Music")
@@ -69,10 +69,10 @@ def test_browse_search_filter(win):
def test_replace_apply_revert_byte_identical(win, tmp_path):
w, root, originals = win
before = files(root)
type_search(w, "CHR_RadioScanner_2")
assert w.select_name("CHR_RadioScanner_2")
type_search(w, "Speech_Synth_Radio_02")
assert w.select_name("Speech_Synth_Radio_02")
ed = w.editor
assert ed.sound.name == "CHR_RadioScanner_2" and ed.orig is not None
assert ed.sound.name == "Speech_Synth_Radio_02" and ed.orig is not None
wav = str(tmp_path / "new.wav")
x, sr = synth.tone(1.0, 44100, 990)
audio.save_wav(wav, x, sr)
@@ -129,7 +129,7 @@ def test_subtitles_tab_edit_and_link(win):
key = p.current.key
assert w.ws.texts.state(key) == "modified" and p.model.modified[key] == "Scan two."
p.go_to_sound.emit(p.current.sound) # "Go to the sound"
assert w.tabs.currentIndex() == 0 and w.editor.sound.name == "CHR_RadioScanner_2"
assert w.tabs.currentIndex() == 0 and w.editor.sound.name == "Speech_Synth_Radio_02"
box = w.editor.subtitle
assert [e.text() for e in box.edits] == ["Scan two.", "Second part."]
box.edits[1].setText("Part two.")
@@ -151,7 +151,7 @@ def test_one_language_for_everything(win):
w.tabs.setCurrentWidget(w.subs)
assert w.subs.lang == "RUS" and "RUS" in w.subs.lbl_lang.text()
w.tabs.setCurrentIndex(0)
w.select_name("CHR_RadioScanner_2")
w.select_name("Speech_Synth_Radio_02")
assert [e.text() for e in w.editor.subtitle.edits] == ["Строка два"]
w.tabs.setCurrentWidget(w.menu)
assert w.menu.lang is None and w.menu.model.rowCount() == 0 # no RUS menu files in the synthetic game
@@ -251,7 +251,7 @@ def test_notes_tags_favorites(win):
def test_mod_export_import(win, tmp_path):
w, root, _ = win
w.select_name("CHR_RadioScanner_2")
w.select_name("Speech_Synth_Radio_02")
x, sr = synth.tone(1.0, 24000, 1200)
w.editor.set_source(x, sr, "tone")
w.editor.apply()

View File

@@ -38,9 +38,10 @@ def test_synthetic_catalog_names_and_search(synth_game):
root, _ = synth_game
cat = Catalog(Game(root), ["INT", "SFX"])
names = {s.name for s in cat.sounds}
assert {"CHR_RadioScanner_1", "CHR_RadioScanner_2", "CHR_RadioScanner_3", "Music_393BC53A"} <= names
assert {"CHR_RadioScanner_1", "Speech_Synth_Radio_02", "CHR_RadioScanner_3", "Music_393BC53A"} <= names
s = cat.resolve("CHR_RadioScanner#2")
assert s.target.endswith(":1") and cat.resolve("chr_radioscanner_2.wav") is s
assert s.target.endswith(":1") and cat.resolve("speech_synth_radio_02.wav") is s
assert s.line and s.name == "Speech_Synth_Radio_02" # the game's line: named after its event
assert cat.resolve(s.key) is s
assert [x.name for x in cat.search("radio scanner 3")] == ["CHR_RadioScanner_3"]
with pytest.raises(LookupError) as e:
@@ -108,8 +109,9 @@ def test_real_game_uses_shipped_catalog(real_game_dir):
g = Game(real_game_dir)
cat = Catalog(g, ["ITA", "SFX", "BANK"])
bank = read_csv(os.path.join(catalog_dir(), "BANK.csv"))
assert len(cat.sounds) == 10816 + 651 + sum(1 for s in bank if s.language in ("", "ITA"))
assert {s.language for s in cat.sounds if s.archive == "BANK"} == {"", "ITA"}
assert len(cat.sounds) == 10816 + 651 + sum(1 for s in bank if s.in_language("ITA"))
assert all(s.in_language("ITA") for s in cat.sounds if s.archive == "BANK")
assert all(s.line for s in cat.sounds if s.archive == "ITA" and s.group == "Speech") # every voice line
s = cat.resolve("Speech_TAPE_JOKER_1#4") # position 4 in the ITA entry (the user's list name)
assert s.category == "Interview tapes" and s.size == 44985 and s.name.startswith("Speech_TAPE_JOKER_1_")
assert len(cat.heads) == 396 and sum(len(v) for v in cat.heads.values()) == 2101

View File

@@ -15,24 +15,24 @@ def run(capsys, *args):
def test_cli_workflow(synth_game, tmp_path, capsys):
root, originals = synth_game
g = ["--game", root, "--lang", "INT"]
out = run(capsys, "find", "radioscanner", *g)
assert "CHR_RadioScanner_2" in out
out = run(capsys, "find", "synth radio", *g)
assert "Speech_Synth_Radio_02" in out
wav = str(tmp_path / "line.wav")
x, sr = synth.tone(1.0, 44100, 660)
audio.save_wav(wav, x, sr)
out = run(capsys, "replace", "CHR_RadioScanner_2", wav, *g)
assert "replaced CHR_RadioScanner_2" in out
assert "CHR_RadioScanner_2" in run(capsys, "status", *g)
assert "modified" in run(capsys, "info", "CHR_RadioScanner_2", *g)
out = run(capsys, "replace", "Speech_Synth_Radio_02", wav, *g)
assert "replaced Speech_Synth_Radio_02" in out
assert "Speech_Synth_Radio_02" in run(capsys, "status", *g)
assert "modified" in run(capsys, "info", "Speech_Synth_Radio_02", *g)
orig = str(tmp_path / "orig.wem")
run(capsys, "extract", "CHR_RadioScanner_2", orig, *g)
run(capsys, "extract", "Speech_Synth_Radio_02", orig, *g)
assert open(orig, "rb").read() == originals["%08X:1" % synth.SPEECH_ENTRY]
mod = str(tmp_path / "m.acmod")
assert "1 sounds" in run(capsys, "export-mod", mod, *g)
assert "reverted 1" in run(capsys, "revert", "--all", *g)
assert "original" in run(capsys, "status", *g)
assert "exact" in run(capsys, "import-mod", mod, *g)
run(capsys, "revert", "CHR_RadioScanner_2", *g)
run(capsys, "revert", "Speech_Synth_Radio_02", *g)
def test_cli_errors_are_friendly(synth_game, capsys):

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"]

View File

@@ -30,7 +30,7 @@ def test_apply_in_place_and_revert_byte_identical(synth_game):
root, originals = synth_game
before = files(root)
ws = ws_for(root)
s = ws.sound("CHR_RadioScanner_2")
s = ws.sound("Speech_Synth_Radio_02")
x, sr = synth.tone(1.2, 24000, 900)
wem, info = ws.prepare(s, x, sr)
assert len(wem) <= ws.limit(s)
@@ -74,7 +74,7 @@ def test_size_limit_enforced(synth_game):
def test_reapply_uses_original_limit(synth_game):
root, _ = synth_game
ws = ws_for(root)
s = ws.sound("CHR_RadioScanner_2")
s = ws.sound("Speech_Synth_Radio_02")
lim = ws.limit(s)
ws.apply([(s, synth.wem(0.3, 1000), {})]) # much smaller
assert ws.limit(s) == lim # still the original's size
@@ -119,10 +119,44 @@ def test_full_backup_taken_once(synth_game):
assert rel in restored and ws.state(s) == W.ORIGINAL
def test_mod_from_another_language_matched_by_line(synth_game, tmp_path):
"""A voice line from another language is found through the game's line identity (its WAD position there
can differ); a voice line from a package without line identities is never guessed by name."""
import json
import zipfile
root, _ = synth_game
ws = ws_for(root, store_root=str(tmp_path / "store"))
s = ws.catalog.by_key["INT:%08X:1" % synth.SPEECH_ENTRY]
assert s.line and s.name == synth.SPEECH_EVENT # the game's line, named after its event
ws.apply([(s, synth.wem(0.3, 1000), {})])
pkg = str(tmp_path / "other_lang.acmod")
export_mod(ws, pkg, ws.modified_keys(), "t")
ws.revert_all()
def rewrite(change):
with zipfile.ZipFile(pkg) as z:
files = {n: z.read(n) for n in z.namelist()}
man = json.loads(files["manifest.json"])
change(man["items"][0])
files["manifest.json"] = json.dumps(man).encode()
with zipfile.ZipFile(pkg, "w") as z:
for n, b in files.items():
z.writestr(n, b)
def other_language(it): # as if made on the ITA game, at another position
it["archive"], it["target"], it["name"] = "ITA", "%08X:2" % synth.SPEECH_ENTRY, "Speech_Synth_Radio_02"
rewrite(other_language)
_, items = match_mod(ws, pkg)
assert items[0].how == "same line" and items[0].sound is s
rewrite(lambda it: it.pop("line")) # an older package: names alone are no proof
_, items = match_mod(ws, pkg)
assert items[0].how == "missing" and "older version" in items[0].note
def test_mod_export_import_roundtrip(synth_game, synth_template, tmp_path):
root, _ = synth_game
ws = ws_for(root)
a = ws.sound("CHR_RadioScanner_2")
a = ws.sound("Speech_Synth_Radio_02")
b = ws.catalog.by_key["SFX:%08X/%08X" % (synth.PACK_ID, synth.MEDIA[0])]
wa, wb = synth.wem(1.0, 820), synth.wem(2.5, 410, 48000, 2)
ws.apply([(a, wa, {}), (b, wb, {})])