Version 3.1.0: voice lines linked across all languages from the game data
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
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"]
|
||||
@@ -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, {})])
|
||||
|
||||
Reference in New Issue
Block a user