One language setting; localized in-memory speech per language

This commit is contained in:
2026-10-09 23:09:07 +02:00
parent 98ec2fe9c4
commit f29af40b37
40 changed files with 77414 additions and 77240 deletions

View File

@@ -89,11 +89,12 @@ def pack(media):
return bytes(hdr) + bank, bytes(payload)
def bank(bank_id, media, name=None, extra_sources=(), prefetch=(), events=()):
def bank(bank_id, media, name=None, extra_sources=(), prefetch=(), events=(), mem_events=()):
"""A v56 soundbank holding `media` {id: wem} in memory (DIDX/DATA, 16-byte aligned) with one HIRC
Sound object per media; extra_sources: [(media id, bank id, off, size)] for sources of other banks;
media ids in `prefetch` get a prefetch source (their DIDX data is the head of a streamed WEM);
events: [(event name, streamed media id)] -> event -> action -> streamed Sound object."""
events: [(event name, streamed media id)] -> event -> action -> streamed Sound object;
mem_events: [(event name, media id of this bank)] -> event -> action -> that media's in-memory Sound."""
data = bytearray()
didx = b""
for m in sorted(media):
@@ -110,14 +111,19 @@ def bank(bank_id, media, name=None, extra_sources=(), prefetch=(), events=()):
2 if m in prefetch else 0))
objs += [x + (0,) for x in extra_sources]
from ackit.bnk import fnv1
obj = (bank_id & 0xFFFFF000) + 0x100 # HIRC object ids: unique per bank, like real Wwise ids
hirc_objs = []
for i, (m, b, off, size, stream) in enumerate(objs):
hirc_objs.append((2, struct.pack("<7I", 0x100 + i, 0x00040001, stream, m, b, off, size) + bytes(6)))
hirc_objs.append((2, struct.pack("<7I", obj + i, 0x00040001, stream, m, b, off, size) + bytes(6)))
for i, (ev, m) in enumerate(events):
sid, aid = 0x300 + i, 0x400 + i
hirc_objs.append((2, struct.pack("<7I", sid, 0x00040001, 1, m, 0, 0, 0) + bytes(6)))
hirc_objs.append((3, struct.pack("<III", aid, 0x4011, sid) + bytes(8)))
hirc_objs.append((4, struct.pack("<III", fnv1(ev), 1, aid)))
for i, (ev, m) in enumerate(mem_events):
aid = 0x500 + i
hirc_objs.append((3, struct.pack("<III", aid, 0x4011, obj + sorted(media).index(m)) + bytes(8)))
hirc_objs.append((4, struct.pack("<III", fnv1(ev), 1, aid)))
hirc = struct.pack("<I", len(hirc_objs))
for t, body in hirc_objs:
hirc += bytes([t]) + struct.pack("<I", len(body)) + body
@@ -294,16 +300,36 @@ def make_banks(cooked, speech_wems):
ev_bank = bank(fnv1("NPC_SynthDialogue"), {DLG_MEDIA: dlg_wem}, name="NPC_SynthDialogue",
events=[(SPEECH_EVENT, LINE_MEDIA[1])])
objs = [("AkEvent", SPEECH_EVENT, bytes(8)), ("RDialogueEvent", "Radio_02", dlg)]
ue_package(os.path.join(cooked, "Synth_Dialogue.upk"), objs + [("AkBank", "NPC_SynthDialogue", ev_bank)])
# a localized bank (one AkBank export: language list + one bank per language) with an in-memory line
loc = localized_bank(GRUNT_BANK, {lang: {m: wem(0.4, 500 + 100 * i)} for i, (lang, m) in enumerate(GRUNT_MEDIA.items())},
GRUNT_EVENT)
grunt = [("AkEvent", GRUNT_EVENT, bytes(8)), ("RDialogueEvent", "Grunt_01", dialogue(GRUNT_EVENT, GRUNT_SUBS)),
("AkBank", GRUNT_BANK, loc)]
ue_package(os.path.join(cooked, "Synth_Dialogue.upk"),
objs + [("AkBank", "NPC_SynthDialogue", ev_bank)] + grunt)
ue_package(os.path.join(cooked, "Synth_Dialogue2.upk"), objs, prefix=bytes(3000))
with open(os.path.join(cooked, "CookedBanks.txt"), "a", newline="\r\n") as f:
f.write("NPC_SynthDialogue\n")
f.write("NPC_SynthDialogue\n%s\n" % GRUNT_BANK)
out = {"%08X" % m: w for m, w in list(foot.items()) + list(foley.items()) + list(gui.items())}
out["%08X" % DLG_MEDIA] = dlg_wem
return out
SPEECH_EVENT = "Speech_Synth_Radio_02"
GRUNT_BANK, GRUNT_EVENT = "Speech_Synth_Grunt", "Speech_Synth_Grunt_01"
GRUNT_MEDIA = {"INT": 0x0F000001, "ITA": 0x0F000002} # one media per language (localized bank)
GRUNT_SUBS = {"INT": ["Hey you!"], "ITA": ["Ehi, tu!"]}
def localized_bank(name, per_lang, event):
"""AkBank export data of a localized bank: language list, then one bank per language (same id)."""
from ackit.bnk import fnv1
langs = list(per_lang)
lst = struct.pack("<I", len(langs)) + b"".join(_fstr(l) for l in langs)
head = bytes(4) + struct.pack("<HHiiii", 9, 0x84, 0, 0, len(lst), 0) + lst
blobs = b"".join(bank(fnv1(name), media, name=name, mem_events=[(event, next(iter(media)))]) + bytes(16)
for media in per_lang.values())
return head + blobs
SUBTITLES = {"INT": ["Scanner line two.", "Second part."], "ITA": ["Riga due dello scanner."],
"RUS": ["Строка два"]}
DLG_MEDIA = 0x0E000001

View File

@@ -97,7 +97,7 @@ def test_bank_sound_apply_revert_in_editor(win, tmp_path):
w, root, originals = win
before = files(root)
w.cmb_arch.setCurrentText("BANK")
assert w.proxy.rowCount() == 6
assert w.proxy.rowCount() == 7 # 6 neutral + the INT copy of a localized line
assert w.select_name("CHR_SynthFoley_1")
ed = w.editor
assert "bank CHR_SynthFoley (2 copies)" in ed.meta.text()
@@ -116,7 +116,7 @@ def test_subtitles_tab_edit_and_link(win):
before = files(root)
w.tabs.setCurrentWidget(w.subs)
p = w.subs
assert p.lang == "INT" and p.model.rowCount() == 2
assert p.lang == "INT" and p.model.rowCount() == 3
p.search.setText("scanner")
p._filter()
assert p.proxy.rowCount() == 1
@@ -141,6 +141,22 @@ def test_subtitles_tab_edit_and_link(win):
assert files(root) == before and not w.ws.texts.records()
def test_one_language_for_everything(win):
w, root, _ = win
assert w.language == "INT" and w.cmb_lang.currentText() == "INT" and not w.lbl_voices.text()
assert {"INT", "ITA", "RUS"} <= {w.cmb_lang.itemText(i) for i in range(w.cmb_lang.count())}
w.load_game(root, "RUS") # no Russian voices: INT speech, RUS texts
assert w.language == "RUS" and "voices: INT" in w.lbl_voices.text()
assert "INT" in w.ws.catalog.archives
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")
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
def test_subtitles_find_replace(win):
w, root, _ = win
before = files(root)

View File

@@ -32,7 +32,12 @@ def test_bank_catalog(synth_game, tmp_path):
root, originals = synth_game
ws = _ws(root, tmp_path)
bank = {s.target: s for s in ws.catalog.sounds if s.archive == "BANK"}
assert set(bank) == {"%08X" % m for m in synth.FOOT + synth.FOLEY + synth.GUI + (synth.DLG_MEDIA,)}
neutral = synth.FOOT + synth.FOLEY + synth.GUI + (synth.DLG_MEDIA,)
assert set(bank) == {"%08X" % m for m in neutral + (synth.GRUNT_MEDIA["INT"],)} # INT catalog: no ITA copy
grunt = bank["%08X" % synth.GRUNT_MEDIA["INT"]]
assert grunt.language == "INT" and grunt.name == synth.GRUNT_EVENT and grunt.group == "Speech"
ita = Workspace(root, store_root=str(tmp_path / "s2"), archives=["BANK"]).catalog # no language: all
assert {s.language for s in ita.sounds if s.bank == synth.GRUNT_BANK} == {"INT", "ITA"}
foot = bank["%08X" % synth.FOOT[0]]
assert foot.bank == "CHR_SynthFootsteps_Media" and foot.name == "CHR_SynthFootsteps_Media_1"
assert (foot.group, foot.category) == ("SFX", "Footsteps")
@@ -41,6 +46,8 @@ def test_bank_catalog(synth_game, tmp_path):
assert foley.rate == 32000 and foley.size == len(originals[foley.target])
assert sorted(l.file for l in ws.locations(foley)) == ["Synth_A.upk", "Synth_B.upk"]
for s in bank.values():
if s.language:
continue
assert ws.current_bytes(s) == originals[s.target]
assert all(_copy(ws, l) == originals[s.target] for l in ws.locations(s))
# the prefetch head belongs to INT variation 1 of the speech entry, not to the BANK archive

View File

@@ -69,7 +69,7 @@ def test_shipped_csvs_are_consistent():
keys = {r.key for r in rows}
assert len(keys) == len(rows), f
assert all(r.group in classify.GROUPS for r in rows)
assert len({r.name.lower() for r in rows}) == len(rows), f
assert len({(r.language, r.name.lower()) for r in rows}) == len(rows), f # per language
# no checksum only where the copies of a BANK sound differ (1C898731)
assert sum(len(r.sha1) != 16 for r in rows) <= (1 if f == "BANK.csv" else 0), f
hdr = next(csv.reader(open(os.path.join(catalog_dir(), "ITA.csv"), encoding="utf-8")))
@@ -84,7 +84,9 @@ def test_shipped_csvs_are_consistent():
def test_real_game_uses_shipped_catalog(real_game_dir):
g = Game(real_game_dir)
cat = Catalog(g, ["ITA", "SFX", "BANK"])
assert len(cat.sounds) == 10816 + 651 + 15404
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"}
s = cat.resolve("Speech_TAPE_JOKER_1_4")
assert s.category == "Interview tapes" and s.size == 44985
assert len(cat.heads) == 396 and sum(len(v) for v in cat.heads.values()) == 2101

View File

@@ -12,7 +12,7 @@ def test_clean_synthetic_catalog_passes(synth_game):
root, _ = synth_game
rep = catalogcheck.run(Game(root), log=lambda *a: None)
assert rep.ok, rep.issues
assert rep.checked["BANK"] == 6 and rep.checked["INT"] == 4
assert rep.checked["BANK"] == 8 and rep.checked["INT"] == 4 # all languages of the localized line
def test_corrupted_catalog_is_reported(synth_game):

View File

@@ -56,11 +56,17 @@ def test_subtitle_catalog_and_link(synth_game, tmp_path):
root, _ = synth_game
ws = _ws(root, tmp_path)
lines = ws.texts.subtitles("INT")
assert [(s.speech, s.line, s.text) for s in lines] == [(synth.SPEECH_EVENT, 0, "Scanner line two."),
assert [(s.speech, s.line, s.text) for s in lines] == [(synth.GRUNT_EVENT, 0, "Hey you!"),
(synth.SPEECH_EVENT, 0, "Scanner line two."),
(synth.SPEECH_EVENT, 1, "Second part.")]
# a line kept in memory: each language's subtitle links to that language's BANK sound
grunt = ws.catalog.by_key["BANK:%08X" % synth.GRUNT_MEDIA["INT"]]
assert [s.text for s in ws.texts.for_sound(grunt)] == ["Hey you!"]
ita = {s.speech: s.sound for s in ws.texts.subtitles("ITA")}
assert ita[synth.GRUNT_EVENT] == "BANK:%08X" % synth.GRUNT_MEDIA["ITA"]
line = ws.catalog.by_key["INT:%08X:1" % synth.SPEECH_ENTRY]
assert [s.text for s in ws.texts.for_sound(line)] == ["Scanner line two.", "Second part."]
assert ws.texts.subtitles("ITA")[0].sound == line.key # no ITA speech WAD here: INT line
assert ita[synth.SPEECH_EVENT] == line.key # no ITA speech WAD here: INT line
assert ws.texts.subtitles("RUS")[0].enc == "w"
assert sorted(os.path.basename(p) for p in ws.texts.files(SUB_KEY)) == ["Synth_Dialogue.upk", "Synth_Dialogue2.upk"]
assert ws.texts.limit(SUB_KEY) == len("Scanner line two.")