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

@@ -40,6 +40,11 @@
--banks` PASS, `--ingame --texts` PASS (12/12 menu markers in memory), tools/e2e_editor.py PASS; game
original after. Version 3.0.0; dist/UnheardCity-3.0.0-windows-amd64.zip (86 MB) smoke + GUI start PASS
with PATH = C:\Windows. 121 tests. REPORT.md rewritten (unresolved items).
- 2026-10-09 (after user test): in-memory speech is localized (one AkBank export = language list + one bank
per language; ~1,460 lines per voice language): BANK.csv has a `language` column, the catalog shows shared
sounds + the loaded language, names unique per language, subtitles of in-memory lines link to them (links
92 % -> 99 %). Editor: one Language setting at the top (always starts at the game's language; languages
without voices use INT voices), text tabs follow it.
- NEXT (needs a person): play a mission with a replaced footstep/foley sound and an edited subtitle (REPORT.md).
- OPEN: channel order for >2 channels unverified; speech and in-memory sounds *heard* in-game (need gameplay;
posted keys do not drive the menu).

View File

@@ -5,10 +5,10 @@ audio (by name, from a file or a recording) and edit subtitles and menu text, wi
game patch, no external programs.
- **Unheard City** (`app/`, [user guide](docs/user-guide.md)): the desktop editor for Windows and Linux. Browse and
search all ~26,900 sounds of the game (the voice lines of one language, music, ambience, and ~15,400 effects
stored inside soundbanks: footsteps, foley, combat, weapons, UI), filter by category and character, listen,
replace or record, apply and revert. Edit subtitles (11 languages, linked to their speech lines) and the menu
text in their own sections.
search all ~21,000 sounds of one language (its voice lines, music, ambience, and the effects stored inside
soundbanks: footsteps, foley, combat, weapons, UI), filter by category and character, listen, replace or
record, apply and revert. Edit subtitles (linked to their speech lines) and the menu text in their own
sections. One Language setting (default: the game's) applies to sounds, subtitles and menu text.
- **`ackit`** (`ackit/`): the Python library and command-line tool behind it.
How it works in one paragraph: the game's Wwise 2011.1 decoder only knows the standard Vorbis codebooks, so ackit

View File

@@ -6,7 +6,7 @@ editor Unheard City (mission 2).
## What works (verified)
- **Every sound of the game is cataloged and editable**: the speech WADs (5 languages x 10,816), the SFX WAD (651)
and, new, the **15,404 in-memory sounds** stored inside soundbanks (archive `BANK`: footsteps, foley, combat,
weapons, UI, efforts) in loose `.bnk` files and in 1,499 `.upk` packages. ackit reads and patches the
weapons, UI, efforts; 8,098 shared by all languages + ~1,460 localized speech lines per voice language) in loose `.bnk` files and in 1,499 `.upk` packages. ackit reads and patches the
LZO-compressed UE3 packages itself (`ackit/upk.py`, `lzo.py`; no external tools). A replacement goes into every
copy (93,910 copies); 396 speech lines with prefetch heads in banks get their heads rewritten too.
Names: 6,456 BANK sounds are named after their Wwise event (`Play_...`), the rest after their bank.
@@ -15,10 +15,12 @@ editor Unheard City (mission 2).
on the full game. It found and fixed 2 duplicate names per language (variation shown as `NAME#i`).
- **Subtitles**: found in `RDialogueEvent` objects inside the packages (9,016 speech lines x 11 languages,
168,122 stored copies), parsed, cataloged (`SUB_<LANG>.csv`) and linked to their speech sound through the
soundbanks (92 % of the strings; the rest are unused/placeholder lines). Edited in place in every copy.
soundbanks (~99 % of the strings, e.g. INT 9,265 / 9,379; the rest are unused/placeholder lines). Edited in
place in every copy.
- **Menu text**: the localization files (`BmGame\Localization\<LANG>\*.int`, ~7,000 strings per language),
edited value by value; everything else in the files stays byte-identical.
- **Editor**: tabs *Sounds* / *Subtitles* / *Menu text*. The sound editor shows and edits the selected speech
- **Editor**: one *Language* setting (default: the game's language) for voices, subtitles and menu text
(languages without voices use the INT ones, like the game); tabs *Sounds* / *Subtitles* / *Menu text*. The sound editor shows and edits the selected speech
line's subtitle side by side; the subtitle list links back to the sound. Search, filters (character,
category, file, modified), room counter, apply/revert/revert all, find & replace on the filtered rows, mod
export/import with texts, changed-outside banner for sounds and texts.
@@ -46,8 +48,8 @@ editor Unheard City (mission 2).
text's length ("room"); cp1252 subtitles accept cp1252 characters only. Lifting these limits means moving
objects inside the packages (new export offsets/sizes, added chunks): designed but not done, because it could
not be verified automatically in the game (decisions 4c).
- **Unlinked subtitles**: ~8 % of the subtitle strings have no event path to a streamed sound (unused or
placeholder lines); they are listed and editable but not linked.
- **Unlinked subtitles**: ~1 % of the subtitle strings have no event path to a sound (unused or placeholder
lines); they are listed and editable but not linked.
- **Unnamed sounds**: ~9,000 BANK sounds have no event name and are named `<Bank>_<n>`.
- Two copies of media `1C898731` differ; its checksum is left empty. Channel order for 4-6 channel sounds is
unverified. M4A/AAC/WMA input needs an ffmpeg on the system.

View File

@@ -14,6 +14,7 @@ import gzip
import hashlib
import io
import os
import re
import struct
from collections import defaultdict, namedtuple
@@ -155,9 +156,40 @@ class Scan:
self.issues = [] # (kind, detail)
_LANG = re.compile(rb"([\x01-\x10])\x00\x00\x00\x04\x00\x00\x00[A-Z]{3}\x00")
def bank_languages(data, start, imports, exports, banks, issues=None, rel=""):
"""{bank offset in data: language} for localized banks. A localized AkBank export starts with its list of
languages (FStrings, e.g. INT FRA ITA DEU ESN) and holds one bank per language, in that order
(docs/formats/banks.md). Banks of other exports are language-neutral (not in the result)."""
out = {}
akbank = {-(i + 1) for i, (c, o, _) in enumerate(imports) if c == "Class" and o == "AkBank"}
starts = sorted(bk.at for bk in banks)
for x in exports:
if x.cls not in akbank or x.size <= 0:
continue
o0 = x.offset - start
m = _LANG.search(data, o0, min(o0 + 256, o0 + x.size))
if not m:
continue
n = m.group(1)[0]
langs, o = [], m.start() + 4
for _ in range(n):
ln = struct.unpack_from("<i", data, o)[0]
langs.append(bytes(data[o + 4:o + 3 + ln]).decode("latin-1"))
o += 4 + ln
inside = [a for a in starts if o0 <= a < o0 + x.size]
if len(inside) != len(langs):
if issues is not None:
issues.append(("languages", "%s: %s has %d banks for %d languages" % (rel, x.name, len(inside), len(langs))))
continue
out.update(zip(inside, langs))
return out
def _bkpk_tables(data, packs):
"""BKPK tables (docs/formats/wad.md) embedded in a package: WAD entry -> media offsets/sizes."""
import re
for m in re.finditer(b"BKPK", data):
p = m.start()
if p + 0x48 > len(data) or struct.unpack_from("<I", data, p + 0x1C)[0] != 0x48:
@@ -189,14 +221,16 @@ def scan(cooked, progress=None, cooked_banks=None, files=None, visit=None):
except (PackageError, OSError) as e:
sc.issues.append(("unreadable", "%s: %s" % (rel, e)))
continue
lang_of = {}
if isinstance(r, Package):
names, _, exports = r.tables(data, start)
names, imports, exports = r.tables(data, start)
sc.names.update(names) # base names ...
sc.names.update(x.name for x in exports) # ... and numbered object names (Speech_X_04)
_bkpk_tables(data, sc.packs)
if visit:
visit(rel, r, start, data)
banks = list(find_banks(data))
lang_of = bank_languages(data, start, imports, exports, banks, sc.issues, rel)
else:
try:
banks = [Bank(data, 0)]
@@ -217,7 +251,8 @@ def scan(cooked, progress=None, cooked_banks=None, files=None, visit=None):
if kind == PREFETCH_HEAD:
sc.prefetch.add(m.id)
e = sc.media.setdefault(m.id, {"size": m.size, "sha1": set(), "head": w[:0x60], "banks": set(),
"kinds": set()})
"kinds": set(), "langs": set()})
e["langs"].add(lang_of.get(bk.at, ""))
e["sha1"].add(hashlib.sha1(w).hexdigest()[:16])
e["banks"].add(bk.id)
e["kinds"].add(kind)
@@ -270,39 +305,57 @@ def event_media(sc):
return out
def media_language(sc, m):
"""Language of a localized in-memory media ('' = language-neutral)."""
langs = sc.media[m].get("langs", set()) - {""}
return next(iter(langs)) if len(langs) == 1 else ""
def media_names(sc):
"""{media id: (display name, primary bank name)}: named after the most specific 'Play' event that
reaches the media, otherwise after its bank (<Bank>_<n>)."""
reaches the media, otherwise after its bank (<Bank>_<n>). Localized speech has one media per language
for the same line: they share the name, variations are numbered within a language. Names are unique
among the language-neutral media plus any one language (what a catalog shows at a time)."""
ev = event_media(sc)
best = {}
for name, ms in ev.items():
rank = (0 if name.lower().startswith("play") else 1, len(ms), name.lower())
for i, m in enumerate(ms):
if m in sc.media and (m not in best or rank < best[m][0]):
best[m] = (rank, name, i, len(ms))
groups = defaultdict(list)
for m in ms:
if m in sc.media:
groups[media_language(sc, m)].append(m)
for g in groups.values():
rank = (0 if name.lower().startswith("play") else 1, len(g), name.lower())
for i, m in enumerate(g):
if m not in best or rank < best[m][0]:
best[m] = (rank, name, i, len(g))
out = {}
taken = set()
for m, (_, name, i, n) in sorted(best.items(), key=lambda x: (x[1][1].lower(), x[1][2])):
nm = name if n == 1 else "%s_%d" % (name, i + 1)
if nm.lower() in taken:
nm = "%s_%08X" % (nm, m)
taken.add(nm.lower())
out[m] = nm
# the rest: by bank, numbered in media id order
per_bank = defaultdict(list)
taken = set() # (language, lower-case name)
def free(lang, nm):
k = nm.lower()
if lang:
return (lang, k) not in taken and ("", k) not in taken
return not any(n == k for _, n in taken)
def take(lang, nm, m, base):
if not free(lang, nm):
nm = "%s_%08X" % (base, m)
taken.add((lang, nm.lower()))
return nm
for m, (_, name, i, n) in sorted(best.items(), key=lambda x: (x[1][1].lower(), x[1][2], x[0])):
out[m] = take(media_language(sc, m), name if n == 1 else "%s_%d" % (name, i + 1), m, name)
# the rest: by bank (and language), numbered in media id order
groups = defaultdict(list)
for m, e in sc.media.items():
bank = sorted(sc.bank_names.get(b, "%08X" % b) for b in e["banks"])[0]
per_bank[bank].append(m)
if m in out:
out[m] = (out[m], bank)
for bank, ms in per_bank.items():
rest = sorted(m for m in ms if m not in out)
for i, m in enumerate(rest):
nm = "%s_%d" % (bank, i + 1)
if nm.lower() in taken:
nm = "%s_%08X" % (bank, m)
taken.add(nm.lower())
out[m] = (nm, bank)
else:
groups[(bank, media_language(sc, m))].append(m)
for (bank, lang), ms in sorted(groups.items()):
for i, m in enumerate(sorted(ms)):
out[m] = (take(lang, "%s_%d" % (bank, i + 1), m, bank), bank)
return out

View File

@@ -25,7 +25,7 @@ from .pack import Pack
from .paths import catalog_dir, package_data, user_dir
from .wad import Wad
FIELDS = ["archive", "target", "name", "aliases", "group", "category", "bank", "character",
FIELDS = ["archive", "target", "name", "aliases", "group", "category", "bank", "language", "character",
"characters_inferred", "seconds", "rate", "channels", "size", "sha1", "notes"]
SCAN_VERSION = 2
@@ -39,6 +39,7 @@ class Sound:
group: str = "Unknown"
category: str = "Unknown"
bank: str = "" # BANK sounds: the soundbank holding it (name); WAD sounds: ''
language: str = "" # BANK sounds: language of a localized speech line ('' = any language)
character: str = "" # verified speaker(s), '; '-separated (curated catalog)
characters_inferred: str = "" # characters/roles mentioned by the name (hint)
seconds: float = 0.0
@@ -98,7 +99,8 @@ def read_csv(path):
for r in csv.DictReader(open(path, encoding="utf-8", newline="")):
out.append(Sound(archive=r["archive"], target=r["target"], name=r["name"],
aliases=tuple(a for a in r["aliases"].split("|") if a), group=r["group"],
category=r["category"], bank=r.get("bank") or "", character=r["character"],
category=r["category"], bank=r.get("bank") or "", language=r.get("language") or "",
character=r["character"],
characters_inferred=r["characters_inferred"], seconds=float(r["seconds"] or 0),
rate=int(r["rate"] or 0), channels=int(r["channels"] or 0), size=int(r["size"] or 0),
sha1=r["sha1"], notes=r["notes"]))
@@ -216,7 +218,7 @@ def bank_sounds(sc, characters=None):
name, bank = names[m]
rate, ch, secs = _hdr(e["head"])
s = Sound(banks.ARCHIVE, "%08X" % m, name, ("%s/%08X" % (bank, m),), bank=bank,
seconds=secs, rate=rate, channels=ch,
language=banks.media_language(sc, m), seconds=secs, rate=rate, channels=ch,
size=min(l.size for l in sc.locations if l.media == m) if len(e["sha1"]) > 1 else e["size"],
sha1=next(iter(e["sha1"])) if len(e["sha1"]) == 1 else "")
s.group, s.category = classify.classify(name, bank=bank)
@@ -263,18 +265,23 @@ def scan_banks(game, progress=None, characters=None, visit=None):
def subtitle_links(game, sc, speech_names):
"""{speech event name: {language: WAD sound key}}: event -> actions -> containers -> sounds -> the
per-language media of the streamed speech line (exact, no name matching)."""
"""{speech event name: {language: sound key}}: event -> actions -> containers -> sounds -> the
per-language media of the line (exact, no name matching). The media is a streamed WAD sound, or an
in-memory BANK sound of a localized bank."""
ev = banks.event_media(sc)
want = {m for n in speech_names for m in ev.get(n, ())}
owners = media_owners(game, sc, want)
owners = {m: (k.split(":")[0], k) for m, k in media_owners(game, sc, want).items()} # m -> (lang, key)
for m in want - set(owners):
lang = banks.media_language(sc, m) if m in sc.media else ""
if lang:
owners[m] = (lang, "%s:%08X" % (banks.ARCHIVE, m))
out = {}
for n in speech_names:
keys = {}
for m in ev.get(n, ()):
k = owners.get(m)
if k:
keys.setdefault(k.split(":")[0], k)
if m in owners:
lang, key = owners[m]
keys.setdefault(lang, key)
if keys:
out[n] = keys
return out
@@ -385,6 +392,9 @@ class Catalog:
for l in ls:
if l.kind == banks.PREFETCH_HEAD and l.sound:
self.heads.setdefault(l.sound, []).append(l)
# localized in-memory speech: only the languages loaded here (all of them if none is)
langs = [a for a in self.archives if a in game.languages] or None
bank_sounds_ = [s for s in bank_sounds_ if not s.language or not langs or s.language in langs]
for a in self.archives:
self.sounds += bank_sounds_ if a == banks.ARCHIVE else load_archive(game, a, progress)
self.by_key = {s.key: s for s in self.sounds}

View File

@@ -4,10 +4,10 @@ import os
from .paths import user_dir
DEFAULTS = {"game_dir": None, "store_root": None, "full_backup": True, "language": None,
DEFAULTS = {"game_dir": None, "store_root": None, "full_backup": True,
"input_device": None, "output_device": None, "last_dir": None,
"background_enabled": True, "background_opacity": 0.1}
OBSOLETE = ("theme", "background_image")
OBSOLETE = ("theme", "background_image", "language") # the editor starts in the game's language
def path():

View File

@@ -52,10 +52,11 @@ class Texts:
self.subtitles(lang)
return self._subs[lang][1].get(key, [])
def for_sound(self, sound):
"""Subtitle lines of a speech sound in its own language (or the INT line for other languages)."""
lang = sound.archive if sound.archive in self.subtitle_languages() else None
if not lang:
def for_sound(self, sound, lang=None):
"""Subtitle lines of a speech sound, in `lang` (default: the sound's own language). Languages without
their own voices link their subtitles to the INT sounds, so e.g. JPN subtitles are found for INT lines."""
lang = lang or sound.language or sound.archive
if lang not in self.subtitle_languages():
return []
return [s for s in self.subtitles(lang) if s.sound == sound.key]

View File

@@ -17,8 +17,8 @@ import os
import random
from .. import banks, classify, subtitles
from ..catalog import (Catalog, available_archives, bank_sounds, hash_names, head_owners, is_shipped, load_characters,
scan_archive)
from ..catalog import (Catalog, available_archives, bank_sounds, hash_names, head_owners, is_shipped,
load_banks, load_characters, read_csv, scan_archive)
from ..inject import read_target
from ..wad import Wad
@@ -60,9 +60,10 @@ def _check_rows(rep, archive, rows, characters):
if s.target in seen_t:
rep.add(archive, "duplicate", s.key, "target")
seen_t.add(s.target)
if s.name.lower() in seen_n:
rep.add(archive, "duplicate", s.key, "name %s (also %s)" % (s.name, seen_n[s.name.lower()]))
seen_n[s.name.lower()] = s.key
n = (s.language, s.name.lower()) # BANK: names are unique per language
if n in seen_n or (s.language and ("", n[1]) in seen_n):
rep.add(archive, "duplicate", s.key, "name %s (also %s)" % (s.name, seen_n.get(n) or seen_n[("", n[1])]))
seen_n[n] = s.key
g, c = _expected_category(s)
if (s.group, s.category) != (g, c):
rep.add(archive, "category", s.key, "%s/%s, rules give %s/%s" % (s.group, s.category, g, c))
@@ -159,6 +160,7 @@ def check_subtitles(rep, game, col, files, quick):
if found.get(k, set()) != index.get(k, set()):
rep.add("SUB", "copies", k, "index %d, game %d" % (len(index.get(k, ())), len(found.get(k, ()))))
langs = sorted(_shipped_index().get("SUB", {}).get("lines", {}))
bank_lang = {s.key: s.language for s in read_csv(os.path.join(catalog_dir(), "BANK.csv"))}
for lang in langs:
p = os.path.join(catalog_dir(), "SUB_%s.csv" % lang)
rows = subtitles.read_texts(p, lang)
@@ -171,7 +173,11 @@ def check_subtitles(rep, game, col, files, quick):
seen.add(k)
if k in texts and texts[k] != s.text:
rep.add("SUB", "text", k, "catalog %r, game %r" % (s.text[:40], texts[k][:40]))
if s.sound and not s.sound.startswith(lang + ":") and not s.sound.startswith("INT:"):
if s.sound.startswith(banks.ARCHIVE + ":"):
b = bank_lang.get(s.sound)
if b is not None and b not in (lang, "INT"): # INT: languages without own voices
rep.add("SUB", "language", k, "linked to %s (%s)" % (s.sound, b or "language-neutral"))
elif s.sound and not s.sound.startswith(lang + ":") and not s.sound.startswith("INT:"):
rep.add("SUB", "language", k, "linked to %s" % s.sound)
@@ -187,7 +193,8 @@ def run(game, archives=None, quick=False, log=print, progress=None):
for a in archives:
log("checking %s ..." % a)
if a == banks.ARCHIVE:
check_banks(rep, game, by_arch[a], cat.locations, characters, quick=quick, progress=progress)
rows = load_banks(game, progress)[0] # every language, not only the loaded one
check_banks(rep, game, rows, cat.locations, characters, quick=quick, progress=progress)
else:
check_wad(rep, game, a, by_arch[a], characters)
known = {n.lower() for s in cat.sounds for n in s.names()}

View File

@@ -30,6 +30,19 @@ TABLE_MIN = 160 # the sound list never gets smaller than thi
ANY = "Any"
def all_languages(ws):
"""Every language the editor offers: speech WADs + subtitle and menu text languages."""
return sorted(set(ws.game.languages) | set(ws.texts.menu_languages()) | set(ws.texts.subtitle_languages()))
def editor_languages(game, language=None):
"""(language, voices): the selected language (default: the game's) and the speech archive used for it
(the language itself, or INT for languages the game has no voices for)."""
lang = (language or game.language() or "INT").upper()
voices = lang if lang in game.languages else ("INT" if "INT" in game.languages else game.languages[0])
return lang, voices
class MainWindow(QMainWindow):
def __init__(self, settings=None, runner=None, game_dir=None):
super().__init__()
@@ -40,6 +53,7 @@ class MainWindow(QMainWindow):
self.player.error.connect(lambda m: self.statusBar().showMessage(m, 5000))
self.userdata = UserData()
self.ws = None
self.language = None # the editor's language (voices, subtitles, menu text)
self.loading = False
self._build()
self._apply_output_device()
@@ -50,7 +64,7 @@ class MainWindow(QMainWindow):
self.resize(1400, 900)
gd = game_dir or default_game_dir(self.settings)
if gd:
QTimer.singleShot(0, lambda: self.load_game(gd, self.settings.get("language")))
QTimer.singleShot(0, lambda: self.load_game(gd)) # always starts in the game's language
else:
self.show_no_game()
@@ -74,11 +88,16 @@ class MainWindow(QMainWindow):
tb.addWidget(title)
tb.addSeparator()
self.cmb_lang = QComboBox()
self.cmb_lang.setToolTip("Speech language archive to browse")
self.cmb_lang.setToolTip("The one language setting of the editor: the voice lines in the Sounds tab, the "
"subtitles and the menu text.\nLanguages without their own voices (e.g. JPN, RUS) "
"use the English (INT) voice lines, like the game.")
self.cmb_lang.activated.connect(lambda _: self.load_game(self.ws.game.root, self.cmb_lang.currentText())
if self.ws else None)
tb.addWidget(QLabel(" Language "))
tb.addWidget(self.cmb_lang)
self.lbl_voices = QLabel()
self.lbl_voices.setObjectName("Muted")
tb.addWidget(self.lbl_voices)
tb.addSeparator()
self.act_revert_all = tb.addAction(icons.icon("revert"), "Revert all", self.revert_all)
self.act_revert_all.setToolTip("Put every original sound back")
@@ -270,7 +289,7 @@ class MainWindow(QMainWindow):
except GameError as e:
self.show_no_game(str(e))
return
lang = language if language in game.languages else game.language()
lang, voices = editor_languages(game, language)
self.loading = True
self.statusBar().showMessage("Loading sound list…")
self.spinner.show()
@@ -279,7 +298,7 @@ class MainWindow(QMainWindow):
def work(job):
avail = available_archives(game)
return W.Workspace(game, store, [a for a in (lang, "SFX", "BANK") if a in avail], full_backup=full,
return W.Workspace(game, store, [a for a in (voices, "SFX", "BANK") if a in avail], full_backup=full,
progress=lambda n, t: job.report("scanning game audio: %d / %d" % (n, t)))
def done(ws):
@@ -287,13 +306,14 @@ class MainWindow(QMainWindow):
self.spinner.hide()
self.ws = ws
self.settings["game_dir"] = game.root
self.settings["language"] = lang
self.save_settings()
self.language = lang
self.cmb_lang.blockSignals(True)
self.cmb_lang.clear()
self.cmb_lang.addItems(game.languages)
self.cmb_lang.addItems(all_languages(ws))
self.cmb_lang.setCurrentText(lang)
self.cmb_lang.blockSignals(False)
self.lbl_voices.setText("" if voices == lang else " voices: %s (no %s voices in the game)" % (voices, lang))
self.populate()
self._text_loaded.clear()
self._tab_changed(self.tabs.currentIndex())
@@ -482,9 +502,6 @@ class MainWindow(QMainWindow):
def show_subtitle(self, key):
"""Subtitles tab with `key` selected (from the sound editor)."""
self.tabs.setCurrentWidget(self.subs)
lang = key.split(":")[1]
if self.subs.lang != lang:
self.subs.load(lang)
self.subs.search.clear()
self.subs.cb_mod.setChecked(False)
for cb in self.subs.filters.values():
@@ -692,7 +709,7 @@ class MainWindow(QMainWindow):
if (self.settings.get("game_dir") != old.get("game_dir") or self.settings.get("store_root") != old.get("store_root")
or self.settings.get("full_backup") != old.get("full_backup")):
if self.settings.get("game_dir"):
self.load_game(self.settings["game_dir"], self.settings.get("language"))
self.load_game(self.settings["game_dir"], self.language)
def _apply_output_device(self):
name = self.settings.get("output_device")

View File

@@ -161,7 +161,7 @@ class SubtitleBox(QWidget):
self.lay.removeRow(e)
self.edits, self.keys = [], []
ws = self.win.ws
lines = ws.texts.for_sound(s) if ws and s else []
lines = ws.texts.for_sound(s, self.win.language) if ws and s else []
if not lines:
self.lbl.setText("No subtitle for this sound." if s else "")
for b in (self.b_apply, self.b_revert, self.b_open):
@@ -251,12 +251,11 @@ class TextPanel(QWidget):
self._timer.setInterval(150)
self._timer.timeout.connect(self._filter)
self.search.textChanged.connect(lambda _: self._timer.start())
self.cmb_lang = QComboBox()
self.cmb_lang.setToolTip("Language of the texts")
self.cmb_lang.activated.connect(lambda _: self.load(self.cmb_lang.currentText()))
self.lbl_lang = QLabel()
self.lbl_lang.setObjectName("Muted")
self.lbl_lang.setToolTip("Change the language with the Language list at the top of the window")
top.addWidget(self.search, 1)
top.addWidget(QLabel("Language"))
top.addWidget(self.cmb_lang)
top.addWidget(self.lbl_lang)
self.filters = {}
names = ("Character", "Category") if self.kind == "subs" else ("File",)
for name in names:
@@ -349,20 +348,16 @@ class TextPanel(QWidget):
return tx.subtitle_languages() if self.kind == "subs" else tx.menu_languages()
def load(self, lang=None):
"""(Re)load the rows of a language (default: the game's language)."""
"""(Re)load the rows of a language (default: the window's language)."""
ws = self.ws()
if not ws:
return
langs = self.languages()
lang = lang if lang in langs else (ws.game.language() if ws.game.language() in langs else
(langs[0] if langs else None))
self.cmb_lang.blockSignals(True)
self.cmb_lang.clear()
self.cmb_lang.addItems(langs)
if lang:
self.cmb_lang.setCurrentText(lang)
self.cmb_lang.blockSignals(False)
lang = lang or self.win.language
if lang not in self.languages():
lang = None
self.lang = lang
self.lbl_lang.setText("Language: %s" % lang if lang else "No %s in this language" % (
"subtitles" if self.kind == "subs" else "menu text"))
if not lang:
self.model.reset([], {})
return

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@ All CSVs are UTF-8 with a header row. They contain only names and metadata (no g
| `group` | Speech, Music, Ambience, SFX, Unknown (`docs/categories.md`) |
| `category` | finer category (`docs/categories.md`) |
| `bank` | BANK: the soundbank holding the sound (first by name if several do); empty for WAD sounds |
| `language` | BANK: language of a localized speech line (`INT`, `FRA`, `ITA`, `DEU`, `ESN`); empty = shared by all languages. The editor shows the shared sounds + the selected language's lines; names are unique per language |
| `character` | verified speaker(s) from `characters.csv`, `; `-separated, empty if unknown |
| `characters_inferred` | characters/roles mentioned by the name (and bank) (hint only) |
| `seconds`, `rate`, `channels` | of the original sound |
@@ -50,7 +51,7 @@ heads (`docs/formats/banks.md`).
| `text` | original subtitle text |
| `room` | maximum length of a replacement in characters (it is written in place) |
| `enc` | `a` = cp1252 string, `w` = UTF-16 string |
| `sound` | key of the linked speech sound (this language's WAD, or `INT:` for languages without speech WADs); empty when no event reaches a sound |
| `sound` | key of the linked speech sound: this language's WAD line or in-memory `BANK:` line (the INT ones for languages without voices); empty when no event reaches a sound |
`SUB_locations.csv.gz`: `lang, speech, line, file, pos` (hex, uncompressed offset of the FString), `size`, `enc`.
Menu text has no catalog: the localization files are read directly (`docs/formats/text.md`).

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -98,17 +98,17 @@
},
"speech_lines": 9016,
"linked": {
"DEU": 8884,
"ESM": 8712,
"ESN": 8741,
"FRA": 8790,
"INT": 8637,
"ITA": 8623,
"JPN": 8318,
"KOR": 8510,
"POL": 8613,
"POR": 8700,
"RUS": 8724
"DEU": 9537,
"ESM": 9360,
"ESN": 9375,
"FRA": 9430,
"INT": 9265,
"ITA": 9243,
"JPN": 8904,
"KOR": 9116,
"POL": 9238,
"POR": 9335,
"RUS": 9352
},
"copies": 168122,
"issues": []

View File

@@ -92,6 +92,13 @@ tables): high risk for little gain, not done.
file is rebuilt from its original copy + the applied edits (sound and text edits combine in one package).
- Keys (`SUB:<LANG>:<speech>:<line>`, `MENU:<LANG>:<File>:<Section>:<Key>`) are stable and used in mod packages.
## 4d. One language setting
The editor has a single *Language* list (top bar) for voices, subtitles and menu text, starting at the language
the game is set to on every launch (not remembered: the game's setting is what the player hears). The game
has voices in 5 languages and texts in 11; for the other 6 it plays the INT voices, so the editor loads the INT
voice lines with that language's texts and says so next to the list. Localized in-memory speech (one bank
copy per voice language) is filtered the same way, so the sound list never mixes languages.
## 5. Catalog
- Shipped CSVs per archive (names, categories, characters, original size + checksum) make loading instant and
enable change detection; any other build is scanned at runtime (header reads only, cached).

View File

@@ -19,8 +19,9 @@ BKPK tables in the packages reference 2,543 WAD entries, all of them in the cata
Embedded WEMs by bank name prefix: `Speech` 7,872, `CHR` (character foley, combat, bosses) 3,896, `PSO` 781,
`WPN` 664, `NPC` 475, `OBJ` 439, `SEQ` 265, `GUI` 208, `PSA` 203, `MTN` 196, `PX` 195, `DVC` 100, `GEN` 80,
`PHY` 73, `AMB` 65, `SBJ` 50, others < 30. The `Speech_` banks (e.g. `Speech_EMOTE_Batman`, `..._GRUNT`) are
probably efforts/grunts rather than dialogue (there are no per-language packages); not verified by listening.
`PHY` 73, `AMB` 65, `SBJ` 50, others < 30. The `Speech_` banks (e.g. `Speech_EMOTE_Batman`, `..._GRUNT`) hold
speech: grunts and efforts (language-neutral, 165) and **localized lines** (about 1,460 per language, see
*Localized banks*).
All WEMs seen are Vorbis (format tag 0xFFFF), the same codec as the WADs: the footsteps are mono (4 stereo),
the embedded ones mono or stereo (~22 % stereo in a 2,533-WEM sample).
@@ -49,6 +50,14 @@ with bank id 0 whose media is not in the same bank (loaded by another bank); 133
streamed WEM (its size = the source's size), which the game plays while the rest is read from the WAD. 396
WAD sounds have such heads (2,101 copies); replacing one of them must rewrite the heads too.
## Localized banks
Speech banks are localized inside the (language-neutral) packages: one `AkBank` export starts with a language
list (FStrings `INT FRA ITA DEU ESN`) and then holds one complete bank per language, in that order, all with
the same bank ID; each language's copy has its own media IDs (checked on 215 banks against the WAD languages
of their streamed lines). ackit labels every in-memory media with its language (`BANK.csv` column `language`):
8,098 language-neutral sounds, ~1,460 per voice language. A catalog shows the neutral sounds plus the loaded
language's lines; names are unique per language.
## Banks inside packages
- The packages are LZO-compressed UE3 packages (`upk.md`); ackit decompresses them itself and searches the
uncompressed data for `BKHD` (banks) and `BKPK` (WAD indexes). (The first survey used Gildor's

View File

@@ -33,8 +33,9 @@ The speech event name is hashed (FNV-1) to the Wwise event ID, followed through
action -> containers -> Sound) to the streamed media, and that media ID is looked up in the per-language
BKPK tables (`banks.md`) to find the WAD entry and variation of each language. This is exact; the `_NN`
suffix of the event name does **not** match the variation order inside the WAD entry (WEMs are stored in
media-ID order). Linked: ~92 % of the strings (e.g. INT 8,637 / 9,379); the rest have no event that reaches a
streamed media (unused or placeholder lines such as `..._TEMP_29`) and stay editable without a sound link.
media-ID order). Lines kept in memory link to the localized BANK sound of their language. Linked: ~99 % of
the strings (e.g. INT 9,265 / 9,379); the rest have no event that reaches a sound (unused or placeholder
lines such as `..._TEMP_29`) and stay editable without a sound link.
Event names with a number (`Speech_X_04`) exist only as export names, not in the name table.
## How ackit writes them

View File

@@ -16,6 +16,16 @@ City (PC, Steam)**. Everything you change can be put back with one click.
![Main window](img/main.png)
## Language
There is **one language setting**: the **Language** list at the top of the window. It starts at the language the
game is set to (Steam > game properties > Language) and applies to everything you edit:
- **Sounds**: the voice lines of that language, plus the sounds every language shares (music, ambience,
effects). The game has voices in English (INT), French, Italian, German and Spanish; for the other languages
(Latin American Spanish, Polish, Portuguese, Russian, Japanese, Korean) it plays the English voices, so the
editor shows those and says *voices: INT* next to the list.
- **Subtitles** and **Menu text**: the texts of that language.
Changing it reloads the lists; the next start is again in the game's language.
## Find a sound
- Type in the **search box** (Ctrl+F): words can be parts of the name, a category, a character, your own
notes or tags, or an ID like `b49cdf48`. All words must match.
@@ -26,11 +36,10 @@ City (PC, Steam)**. Everything you change can be put back with one click.
sound's name (useful hints for the many unlabelled lines); tick *Include characters mentioned in names*
to filter on them too.
- Click a column header to sort (length, size, status, ...). Ctrl/Shift-click selects several rows.
- Archive **BANK** holds the ~15,400 short effects stored inside soundbanks (footsteps, foley, combat,
- Archive **BANK** holds the short sounds stored inside soundbanks (footsteps, foley, combat,
weapons, menu sounds, grunts). Many are named after the game event that plays them (`Play_...`), the
rest after their bank (`CHR_Bane_3`). The editor shows the bank and how many copies the game keeps of
the sound; a replacement is written into every copy.
- **Language** (top left) switches the speech language. Speech is read from the language the game is set to.
![Browsing by category](img/browse.png)
@@ -66,7 +75,7 @@ than the original, since the game may cut it off.
changes** or **Forget them**. Do not use "verify" to undo mods; use Revert all.
## Subtitles
The **Subtitles** tab lists the subtitle of every speech line (11 languages; choose one with *Language*).
The **Subtitles** tab lists the subtitle of every speech line in the selected language (see *Language*).
- Search by text or line name, filter by **Character** and **Category** (taken from the linked speech sound),
or show **Modified only**. Select a row to edit its text below and press **Apply** (or Enter).
- Each subtitle replaces the original *in place*, so it can be at most as long as the original (the counter
@@ -80,7 +89,7 @@ The **Subtitles** tab lists the subtitle of every speech line (11 languages; cho
- About 8 % of the subtitles belong to lines the game never plays (placeholders); they have no linked sound.
## Menu text
The **Menu text** tab lists every menu, HUD, bio, riddle and credits string of the chosen language (about
The **Menu text** tab lists every menu, HUD, bio, riddle and credits string of the selected language (about
7,000), grouped by **File** (e.g. `FRONTEND`, `GFxUI`, `Bios`). Search, edit, apply and revert work like
subtitles; menu texts can be of any length but cannot contain line breaks (keep the game's own markers such
as `\n` as they are).

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.")