One language setting; localized in-memory speech per language
This commit is contained in:
5
NOTES.md
5
NOTES.md
@@ -40,6 +40,11 @@
|
|||||||
--banks` PASS, `--ingame --texts` PASS (12/12 menu markers in memory), tools/e2e_editor.py PASS; game
|
--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
|
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).
|
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).
|
- 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;
|
- OPEN: channel order for >2 channels unverified; speech and in-memory sounds *heard* in-game (need gameplay;
|
||||||
posted keys do not drive the menu).
|
posted keys do not drive the menu).
|
||||||
|
|||||||
@@ -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.
|
game patch, no external programs.
|
||||||
|
|
||||||
- **Unheard City** (`app/`, [user guide](docs/user-guide.md)): the desktop editor for Windows and Linux. Browse and
|
- **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
|
search all ~21,000 sounds of one language (its voice lines, music, ambience, and the effects stored inside
|
||||||
stored inside soundbanks: footsteps, foley, combat, weapons, UI), filter by category and character, listen,
|
soundbanks: footsteps, foley, combat, weapons, UI), filter by category and character, listen, replace or
|
||||||
replace or record, apply and revert. Edit subtitles (11 languages, linked to their speech lines) and the menu
|
record, apply and revert. Edit subtitles (linked to their speech lines) and the menu text in their own
|
||||||
text in their own sections.
|
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.
|
- **`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
|
How it works in one paragraph: the game's Wwise 2011.1 decoder only knows the standard Vorbis codebooks, so ackit
|
||||||
|
|||||||
12
REPORT.md
12
REPORT.md
@@ -6,7 +6,7 @@ editor Unheard City (mission 2).
|
|||||||
## What works (verified)
|
## What works (verified)
|
||||||
- **Every sound of the game is cataloged and editable**: the speech WADs (5 languages x 10,816), the SFX WAD (651)
|
- **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,
|
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
|
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.
|
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.
|
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`).
|
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,
|
- **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
|
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),
|
- **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.
|
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,
|
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
|
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.
|
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
|
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
|
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).
|
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
|
- **Unlinked subtitles**: ~1 % of the subtitle strings have no event path to a sound (unused or placeholder
|
||||||
placeholder lines); they are listed and editable but not linked.
|
lines); they are listed and editable but not linked.
|
||||||
- **Unnamed sounds**: ~9,000 BANK sounds have no event name and are named `<Bank>_<n>`.
|
- **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
|
- 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.
|
unverified. M4A/AAC/WMA input needs an ffmpeg on the system.
|
||||||
|
|||||||
105
ackit/banks.py
105
ackit/banks.py
@@ -14,6 +14,7 @@ import gzip
|
|||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import struct
|
import struct
|
||||||
from collections import defaultdict, namedtuple
|
from collections import defaultdict, namedtuple
|
||||||
|
|
||||||
@@ -155,9 +156,40 @@ class Scan:
|
|||||||
self.issues = [] # (kind, detail)
|
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):
|
def _bkpk_tables(data, packs):
|
||||||
"""BKPK tables (docs/formats/wad.md) embedded in a package: WAD entry -> media offsets/sizes."""
|
"""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):
|
for m in re.finditer(b"BKPK", data):
|
||||||
p = m.start()
|
p = m.start()
|
||||||
if p + 0x48 > len(data) or struct.unpack_from("<I", data, p + 0x1C)[0] != 0x48:
|
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:
|
except (PackageError, OSError) as e:
|
||||||
sc.issues.append(("unreadable", "%s: %s" % (rel, e)))
|
sc.issues.append(("unreadable", "%s: %s" % (rel, e)))
|
||||||
continue
|
continue
|
||||||
|
lang_of = {}
|
||||||
if isinstance(r, Package):
|
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(names) # base names ...
|
||||||
sc.names.update(x.name for x in exports) # ... and numbered object names (Speech_X_04)
|
sc.names.update(x.name for x in exports) # ... and numbered object names (Speech_X_04)
|
||||||
_bkpk_tables(data, sc.packs)
|
_bkpk_tables(data, sc.packs)
|
||||||
if visit:
|
if visit:
|
||||||
visit(rel, r, start, data)
|
visit(rel, r, start, data)
|
||||||
banks = list(find_banks(data))
|
banks = list(find_banks(data))
|
||||||
|
lang_of = bank_languages(data, start, imports, exports, banks, sc.issues, rel)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
banks = [Bank(data, 0)]
|
banks = [Bank(data, 0)]
|
||||||
@@ -217,7 +251,8 @@ def scan(cooked, progress=None, cooked_banks=None, files=None, visit=None):
|
|||||||
if kind == PREFETCH_HEAD:
|
if kind == PREFETCH_HEAD:
|
||||||
sc.prefetch.add(m.id)
|
sc.prefetch.add(m.id)
|
||||||
e = sc.media.setdefault(m.id, {"size": m.size, "sha1": set(), "head": w[:0x60], "banks": set(),
|
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["sha1"].add(hashlib.sha1(w).hexdigest()[:16])
|
||||||
e["banks"].add(bk.id)
|
e["banks"].add(bk.id)
|
||||||
e["kinds"].add(kind)
|
e["kinds"].add(kind)
|
||||||
@@ -270,39 +305,57 @@ def event_media(sc):
|
|||||||
return out
|
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):
|
def media_names(sc):
|
||||||
"""{media id: (display name, primary bank name)}: named after the most specific 'Play' event that
|
"""{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)
|
ev = event_media(sc)
|
||||||
best = {}
|
best = {}
|
||||||
for name, ms in ev.items():
|
for name, ms in ev.items():
|
||||||
rank = (0 if name.lower().startswith("play") else 1, len(ms), name.lower())
|
groups = defaultdict(list)
|
||||||
for i, m in enumerate(ms):
|
for m in ms:
|
||||||
if m in sc.media and (m not in best or rank < best[m][0]):
|
if m in sc.media:
|
||||||
best[m] = (rank, name, i, len(ms))
|
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 = {}
|
out = {}
|
||||||
taken = set()
|
taken = set() # (language, lower-case name)
|
||||||
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)
|
def free(lang, nm):
|
||||||
if nm.lower() in taken:
|
k = nm.lower()
|
||||||
nm = "%s_%08X" % (nm, m)
|
if lang:
|
||||||
taken.add(nm.lower())
|
return (lang, k) not in taken and ("", k) not in taken
|
||||||
out[m] = nm
|
return not any(n == k for _, n in taken)
|
||||||
# the rest: by bank, numbered in media id order
|
|
||||||
per_bank = defaultdict(list)
|
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():
|
for m, e in sc.media.items():
|
||||||
bank = sorted(sc.bank_names.get(b, "%08X" % b) for b in e["banks"])[0]
|
bank = sorted(sc.bank_names.get(b, "%08X" % b) for b in e["banks"])[0]
|
||||||
per_bank[bank].append(m)
|
|
||||||
if m in out:
|
if m in out:
|
||||||
out[m] = (out[m], bank)
|
out[m] = (out[m], bank)
|
||||||
for bank, ms in per_bank.items():
|
else:
|
||||||
rest = sorted(m for m in ms if m not in out)
|
groups[(bank, media_language(sc, m))].append(m)
|
||||||
for i, m in enumerate(rest):
|
for (bank, lang), ms in sorted(groups.items()):
|
||||||
nm = "%s_%d" % (bank, i + 1)
|
for i, m in enumerate(sorted(ms)):
|
||||||
if nm.lower() in taken:
|
out[m] = (take(lang, "%s_%d" % (bank, i + 1), m, bank), bank)
|
||||||
nm = "%s_%08X" % (bank, m)
|
|
||||||
taken.add(nm.lower())
|
|
||||||
out[m] = (nm, bank)
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from .pack import Pack
|
|||||||
from .paths import catalog_dir, package_data, user_dir
|
from .paths import catalog_dir, package_data, user_dir
|
||||||
from .wad import Wad
|
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"]
|
"characters_inferred", "seconds", "rate", "channels", "size", "sha1", "notes"]
|
||||||
SCAN_VERSION = 2
|
SCAN_VERSION = 2
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ class Sound:
|
|||||||
group: str = "Unknown"
|
group: str = "Unknown"
|
||||||
category: str = "Unknown"
|
category: str = "Unknown"
|
||||||
bank: str = "" # BANK sounds: the soundbank holding it (name); WAD sounds: ''
|
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)
|
character: str = "" # verified speaker(s), '; '-separated (curated catalog)
|
||||||
characters_inferred: str = "" # characters/roles mentioned by the name (hint)
|
characters_inferred: str = "" # characters/roles mentioned by the name (hint)
|
||||||
seconds: float = 0.0
|
seconds: float = 0.0
|
||||||
@@ -98,7 +99,8 @@ def read_csv(path):
|
|||||||
for r in csv.DictReader(open(path, encoding="utf-8", newline="")):
|
for r in csv.DictReader(open(path, encoding="utf-8", newline="")):
|
||||||
out.append(Sound(archive=r["archive"], target=r["target"], name=r["name"],
|
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"],
|
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),
|
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),
|
rate=int(r["rate"] or 0), channels=int(r["channels"] or 0), size=int(r["size"] or 0),
|
||||||
sha1=r["sha1"], notes=r["notes"]))
|
sha1=r["sha1"], notes=r["notes"]))
|
||||||
@@ -216,7 +218,7 @@ def bank_sounds(sc, characters=None):
|
|||||||
name, bank = names[m]
|
name, bank = names[m]
|
||||||
rate, ch, secs = _hdr(e["head"])
|
rate, ch, secs = _hdr(e["head"])
|
||||||
s = Sound(banks.ARCHIVE, "%08X" % m, name, ("%s/%08X" % (bank, m),), bank=bank,
|
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"],
|
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 "")
|
sha1=next(iter(e["sha1"])) if len(e["sha1"]) == 1 else "")
|
||||||
s.group, s.category = classify.classify(name, bank=bank)
|
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):
|
def subtitle_links(game, sc, speech_names):
|
||||||
"""{speech event name: {language: WAD sound key}}: event -> actions -> containers -> sounds -> the
|
"""{speech event name: {language: sound key}}: event -> actions -> containers -> sounds -> the
|
||||||
per-language media of the streamed speech line (exact, no name matching)."""
|
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)
|
ev = banks.event_media(sc)
|
||||||
want = {m for n in speech_names for m in ev.get(n, ())}
|
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 = {}
|
out = {}
|
||||||
for n in speech_names:
|
for n in speech_names:
|
||||||
keys = {}
|
keys = {}
|
||||||
for m in ev.get(n, ()):
|
for m in ev.get(n, ()):
|
||||||
k = owners.get(m)
|
if m in owners:
|
||||||
if k:
|
lang, key = owners[m]
|
||||||
keys.setdefault(k.split(":")[0], k)
|
keys.setdefault(lang, key)
|
||||||
if keys:
|
if keys:
|
||||||
out[n] = keys
|
out[n] = keys
|
||||||
return out
|
return out
|
||||||
@@ -385,6 +392,9 @@ class Catalog:
|
|||||||
for l in ls:
|
for l in ls:
|
||||||
if l.kind == banks.PREFETCH_HEAD and l.sound:
|
if l.kind == banks.PREFETCH_HEAD and l.sound:
|
||||||
self.heads.setdefault(l.sound, []).append(l)
|
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:
|
for a in self.archives:
|
||||||
self.sounds += bank_sounds_ if a == banks.ARCHIVE else load_archive(game, a, progress)
|
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}
|
self.by_key = {s.key: s for s in self.sounds}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import os
|
|||||||
|
|
||||||
from .paths import user_dir
|
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,
|
"input_device": None, "output_device": None, "last_dir": None,
|
||||||
"background_enabled": True, "background_opacity": 0.1}
|
"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():
|
def path():
|
||||||
|
|||||||
@@ -52,10 +52,11 @@ class Texts:
|
|||||||
self.subtitles(lang)
|
self.subtitles(lang)
|
||||||
return self._subs[lang][1].get(key, [])
|
return self._subs[lang][1].get(key, [])
|
||||||
|
|
||||||
def for_sound(self, sound):
|
def for_sound(self, sound, lang=None):
|
||||||
"""Subtitle lines of a speech sound in its own language (or the INT line for other languages)."""
|
"""Subtitle lines of a speech sound, in `lang` (default: the sound's own language). Languages without
|
||||||
lang = sound.archive if sound.archive in self.subtitle_languages() else None
|
their own voices link their subtitles to the INT sounds, so e.g. JPN subtitles are found for INT lines."""
|
||||||
if not lang:
|
lang = lang or sound.language or sound.archive
|
||||||
|
if lang not in self.subtitle_languages():
|
||||||
return []
|
return []
|
||||||
return [s for s in self.subtitles(lang) if s.sound == sound.key]
|
return [s for s in self.subtitles(lang) if s.sound == sound.key]
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import os
|
|||||||
import random
|
import random
|
||||||
|
|
||||||
from .. import banks, classify, subtitles
|
from .. import banks, classify, subtitles
|
||||||
from ..catalog import (Catalog, available_archives, bank_sounds, hash_names, head_owners, is_shipped, load_characters,
|
from ..catalog import (Catalog, available_archives, bank_sounds, hash_names, head_owners, is_shipped,
|
||||||
scan_archive)
|
load_banks, load_characters, read_csv, scan_archive)
|
||||||
from ..inject import read_target
|
from ..inject import read_target
|
||||||
from ..wad import Wad
|
from ..wad import Wad
|
||||||
|
|
||||||
@@ -60,9 +60,10 @@ def _check_rows(rep, archive, rows, characters):
|
|||||||
if s.target in seen_t:
|
if s.target in seen_t:
|
||||||
rep.add(archive, "duplicate", s.key, "target")
|
rep.add(archive, "duplicate", s.key, "target")
|
||||||
seen_t.add(s.target)
|
seen_t.add(s.target)
|
||||||
if s.name.lower() in seen_n:
|
n = (s.language, s.name.lower()) # BANK: names are unique per language
|
||||||
rep.add(archive, "duplicate", s.key, "name %s (also %s)" % (s.name, seen_n[s.name.lower()]))
|
if n in seen_n or (s.language and ("", n[1]) in seen_n):
|
||||||
seen_n[s.name.lower()] = s.key
|
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)
|
g, c = _expected_category(s)
|
||||||
if (s.group, s.category) != (g, c):
|
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))
|
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()):
|
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, ()))))
|
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", {}))
|
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:
|
for lang in langs:
|
||||||
p = os.path.join(catalog_dir(), "SUB_%s.csv" % lang)
|
p = os.path.join(catalog_dir(), "SUB_%s.csv" % lang)
|
||||||
rows = subtitles.read_texts(p, lang)
|
rows = subtitles.read_texts(p, lang)
|
||||||
@@ -171,7 +173,11 @@ def check_subtitles(rep, game, col, files, quick):
|
|||||||
seen.add(k)
|
seen.add(k)
|
||||||
if k in texts and texts[k] != s.text:
|
if k in texts and texts[k] != s.text:
|
||||||
rep.add("SUB", "text", k, "catalog %r, game %r" % (s.text[:40], texts[k][:40]))
|
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)
|
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:
|
for a in archives:
|
||||||
log("checking %s ..." % a)
|
log("checking %s ..." % a)
|
||||||
if a == banks.ARCHIVE:
|
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:
|
else:
|
||||||
check_wad(rep, game, a, by_arch[a], characters)
|
check_wad(rep, game, a, by_arch[a], characters)
|
||||||
known = {n.lower() for s in cat.sounds for n in s.names()}
|
known = {n.lower() for s in cat.sounds for n in s.names()}
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ TABLE_MIN = 160 # the sound list never gets smaller than thi
|
|||||||
ANY = "Any"
|
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):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self, settings=None, runner=None, game_dir=None):
|
def __init__(self, settings=None, runner=None, game_dir=None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -40,6 +53,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.player.error.connect(lambda m: self.statusBar().showMessage(m, 5000))
|
self.player.error.connect(lambda m: self.statusBar().showMessage(m, 5000))
|
||||||
self.userdata = UserData()
|
self.userdata = UserData()
|
||||||
self.ws = None
|
self.ws = None
|
||||||
|
self.language = None # the editor's language (voices, subtitles, menu text)
|
||||||
self.loading = False
|
self.loading = False
|
||||||
self._build()
|
self._build()
|
||||||
self._apply_output_device()
|
self._apply_output_device()
|
||||||
@@ -50,7 +64,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.resize(1400, 900)
|
self.resize(1400, 900)
|
||||||
gd = game_dir or default_game_dir(self.settings)
|
gd = game_dir or default_game_dir(self.settings)
|
||||||
if gd:
|
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:
|
else:
|
||||||
self.show_no_game()
|
self.show_no_game()
|
||||||
|
|
||||||
@@ -74,11 +88,16 @@ class MainWindow(QMainWindow):
|
|||||||
tb.addWidget(title)
|
tb.addWidget(title)
|
||||||
tb.addSeparator()
|
tb.addSeparator()
|
||||||
self.cmb_lang = QComboBox()
|
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())
|
self.cmb_lang.activated.connect(lambda _: self.load_game(self.ws.game.root, self.cmb_lang.currentText())
|
||||||
if self.ws else None)
|
if self.ws else None)
|
||||||
tb.addWidget(QLabel(" Language "))
|
tb.addWidget(QLabel(" Language "))
|
||||||
tb.addWidget(self.cmb_lang)
|
tb.addWidget(self.cmb_lang)
|
||||||
|
self.lbl_voices = QLabel()
|
||||||
|
self.lbl_voices.setObjectName("Muted")
|
||||||
|
tb.addWidget(self.lbl_voices)
|
||||||
tb.addSeparator()
|
tb.addSeparator()
|
||||||
self.act_revert_all = tb.addAction(icons.icon("revert"), "Revert all", self.revert_all)
|
self.act_revert_all = tb.addAction(icons.icon("revert"), "Revert all", self.revert_all)
|
||||||
self.act_revert_all.setToolTip("Put every original sound back")
|
self.act_revert_all.setToolTip("Put every original sound back")
|
||||||
@@ -270,7 +289,7 @@ class MainWindow(QMainWindow):
|
|||||||
except GameError as e:
|
except GameError as e:
|
||||||
self.show_no_game(str(e))
|
self.show_no_game(str(e))
|
||||||
return
|
return
|
||||||
lang = language if language in game.languages else game.language()
|
lang, voices = editor_languages(game, language)
|
||||||
self.loading = True
|
self.loading = True
|
||||||
self.statusBar().showMessage("Loading sound list…")
|
self.statusBar().showMessage("Loading sound list…")
|
||||||
self.spinner.show()
|
self.spinner.show()
|
||||||
@@ -279,7 +298,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def work(job):
|
def work(job):
|
||||||
avail = available_archives(game)
|
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)))
|
progress=lambda n, t: job.report("scanning game audio: %d / %d" % (n, t)))
|
||||||
|
|
||||||
def done(ws):
|
def done(ws):
|
||||||
@@ -287,13 +306,14 @@ class MainWindow(QMainWindow):
|
|||||||
self.spinner.hide()
|
self.spinner.hide()
|
||||||
self.ws = ws
|
self.ws = ws
|
||||||
self.settings["game_dir"] = game.root
|
self.settings["game_dir"] = game.root
|
||||||
self.settings["language"] = lang
|
|
||||||
self.save_settings()
|
self.save_settings()
|
||||||
|
self.language = lang
|
||||||
self.cmb_lang.blockSignals(True)
|
self.cmb_lang.blockSignals(True)
|
||||||
self.cmb_lang.clear()
|
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.setCurrentText(lang)
|
||||||
self.cmb_lang.blockSignals(False)
|
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.populate()
|
||||||
self._text_loaded.clear()
|
self._text_loaded.clear()
|
||||||
self._tab_changed(self.tabs.currentIndex())
|
self._tab_changed(self.tabs.currentIndex())
|
||||||
@@ -482,9 +502,6 @@ class MainWindow(QMainWindow):
|
|||||||
def show_subtitle(self, key):
|
def show_subtitle(self, key):
|
||||||
"""Subtitles tab with `key` selected (from the sound editor)."""
|
"""Subtitles tab with `key` selected (from the sound editor)."""
|
||||||
self.tabs.setCurrentWidget(self.subs)
|
self.tabs.setCurrentWidget(self.subs)
|
||||||
lang = key.split(":")[1]
|
|
||||||
if self.subs.lang != lang:
|
|
||||||
self.subs.load(lang)
|
|
||||||
self.subs.search.clear()
|
self.subs.search.clear()
|
||||||
self.subs.cb_mod.setChecked(False)
|
self.subs.cb_mod.setChecked(False)
|
||||||
for cb in self.subs.filters.values():
|
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")
|
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")):
|
or self.settings.get("full_backup") != old.get("full_backup")):
|
||||||
if self.settings.get("game_dir"):
|
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):
|
def _apply_output_device(self):
|
||||||
name = self.settings.get("output_device")
|
name = self.settings.get("output_device")
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ class SubtitleBox(QWidget):
|
|||||||
self.lay.removeRow(e)
|
self.lay.removeRow(e)
|
||||||
self.edits, self.keys = [], []
|
self.edits, self.keys = [], []
|
||||||
ws = self.win.ws
|
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:
|
if not lines:
|
||||||
self.lbl.setText("No subtitle for this sound." if s else "")
|
self.lbl.setText("No subtitle for this sound." if s else "")
|
||||||
for b in (self.b_apply, self.b_revert, self.b_open):
|
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.setInterval(150)
|
||||||
self._timer.timeout.connect(self._filter)
|
self._timer.timeout.connect(self._filter)
|
||||||
self.search.textChanged.connect(lambda _: self._timer.start())
|
self.search.textChanged.connect(lambda _: self._timer.start())
|
||||||
self.cmb_lang = QComboBox()
|
self.lbl_lang = QLabel()
|
||||||
self.cmb_lang.setToolTip("Language of the texts")
|
self.lbl_lang.setObjectName("Muted")
|
||||||
self.cmb_lang.activated.connect(lambda _: self.load(self.cmb_lang.currentText()))
|
self.lbl_lang.setToolTip("Change the language with the Language list at the top of the window")
|
||||||
top.addWidget(self.search, 1)
|
top.addWidget(self.search, 1)
|
||||||
top.addWidget(QLabel("Language"))
|
top.addWidget(self.lbl_lang)
|
||||||
top.addWidget(self.cmb_lang)
|
|
||||||
self.filters = {}
|
self.filters = {}
|
||||||
names = ("Character", "Category") if self.kind == "subs" else ("File",)
|
names = ("Character", "Category") if self.kind == "subs" else ("File",)
|
||||||
for name in names:
|
for name in names:
|
||||||
@@ -349,20 +348,16 @@ class TextPanel(QWidget):
|
|||||||
return tx.subtitle_languages() if self.kind == "subs" else tx.menu_languages()
|
return tx.subtitle_languages() if self.kind == "subs" else tx.menu_languages()
|
||||||
|
|
||||||
def load(self, lang=None):
|
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()
|
ws = self.ws()
|
||||||
if not ws:
|
if not ws:
|
||||||
return
|
return
|
||||||
langs = self.languages()
|
lang = lang or self.win.language
|
||||||
lang = lang if lang in langs else (ws.game.language() if ws.game.language() in langs else
|
if lang not in self.languages():
|
||||||
(langs[0] if langs else None))
|
lang = 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)
|
|
||||||
self.lang = lang
|
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:
|
if not lang:
|
||||||
self.model.reset([], {})
|
self.model.reset([], {})
|
||||||
return
|
return
|
||||||
|
|||||||
30810
data/catalog/BANK.csv
30810
data/catalog/BANK.csv
File diff suppressed because it is too large
Load Diff
21634
data/catalog/DEU.csv
21634
data/catalog/DEU.csv
File diff suppressed because it is too large
Load Diff
21634
data/catalog/ESN.csv
21634
data/catalog/ESN.csv
File diff suppressed because it is too large
Load Diff
21634
data/catalog/FRA.csv
21634
data/catalog/FRA.csv
File diff suppressed because it is too large
Load Diff
21634
data/catalog/INT.csv
21634
data/catalog/INT.csv
File diff suppressed because it is too large
Load Diff
21634
data/catalog/ITA.csv
21634
data/catalog/ITA.csv
File diff suppressed because it is too large
Load Diff
@@ -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`) |
|
| `group` | Speech, Music, Ambience, SFX, Unknown (`docs/categories.md`) |
|
||||||
| `category` | finer category (`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 |
|
| `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 |
|
| `character` | verified speaker(s) from `characters.csv`, `; `-separated, empty if unknown |
|
||||||
| `characters_inferred` | characters/roles mentioned by the name (and bank) (hint only) |
|
| `characters_inferred` | characters/roles mentioned by the name (and bank) (hint only) |
|
||||||
| `seconds`, `rate`, `channels` | of the original sound |
|
| `seconds`, `rate`, `channels` | of the original sound |
|
||||||
@@ -50,7 +51,7 @@ heads (`docs/formats/banks.md`).
|
|||||||
| `text` | original subtitle text |
|
| `text` | original subtitle text |
|
||||||
| `room` | maximum length of a replacement in characters (it is written in place) |
|
| `room` | maximum length of a replacement in characters (it is written in place) |
|
||||||
| `enc` | `a` = cp1252 string, `w` = UTF-16 string |
|
| `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`.
|
`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`).
|
Menu text has no catalog: the localization files are read directly (`docs/formats/text.md`).
|
||||||
|
|||||||
1304
data/catalog/SFX.csv
1304
data/catalog/SFX.csv
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
@@ -98,17 +98,17 @@
|
|||||||
},
|
},
|
||||||
"speech_lines": 9016,
|
"speech_lines": 9016,
|
||||||
"linked": {
|
"linked": {
|
||||||
"DEU": 8884,
|
"DEU": 9537,
|
||||||
"ESM": 8712,
|
"ESM": 9360,
|
||||||
"ESN": 8741,
|
"ESN": 9375,
|
||||||
"FRA": 8790,
|
"FRA": 9430,
|
||||||
"INT": 8637,
|
"INT": 9265,
|
||||||
"ITA": 8623,
|
"ITA": 9243,
|
||||||
"JPN": 8318,
|
"JPN": 8904,
|
||||||
"KOR": 8510,
|
"KOR": 9116,
|
||||||
"POL": 8613,
|
"POL": 9238,
|
||||||
"POR": 8700,
|
"POR": 9335,
|
||||||
"RUS": 8724
|
"RUS": 9352
|
||||||
},
|
},
|
||||||
"copies": 168122,
|
"copies": 168122,
|
||||||
"issues": []
|
"issues": []
|
||||||
|
|||||||
@@ -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).
|
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.
|
- 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
|
## 5. Catalog
|
||||||
- Shipped CSVs per archive (names, categories, characters, original size + checksum) make loading instant and
|
- 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).
|
enable change detection; any other build is scanned at runtime (header reads only, cached).
|
||||||
|
|||||||
@@ -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,
|
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,
|
`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
|
`PHY` 73, `AMB` 65, `SBJ` 50, others < 30. The `Speech_` banks (e.g. `Speech_EMOTE_Batman`, `..._GRUNT`) hold
|
||||||
probably efforts/grunts rather than dialogue (there are no per-language packages); not verified by listening.
|
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),
|
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).
|
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
|
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.
|
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
|
## Banks inside packages
|
||||||
- The packages are LZO-compressed UE3 packages (`upk.md`); ackit decompresses them itself and searches the
|
- 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
|
uncompressed data for `BKHD` (banks) and `BKPK` (WAD indexes). (The first survey used Gildor's
|
||||||
|
|||||||
@@ -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
|
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`
|
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
|
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
|
media-ID order). Lines kept in memory link to the localized BANK sound of their language. Linked: ~99 % of
|
||||||
streamed media (unused or placeholder lines such as `..._TEMP_29`) and stay editable without a sound link.
|
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.
|
Event names with a number (`Speech_X_04`) exist only as export names, not in the name table.
|
||||||
|
|
||||||
## How ackit writes them
|
## How ackit writes them
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ City (PC, Steam)**. Everything you change can be put back with one click.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## 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
|
## Find a sound
|
||||||
- Type in the **search box** (Ctrl+F): words can be parts of the name, a category, a character, your own
|
- 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.
|
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*
|
sound's name (useful hints for the many unlabelled lines); tick *Include characters mentioned in names*
|
||||||
to filter on them too.
|
to filter on them too.
|
||||||
- Click a column header to sort (length, size, status, ...). Ctrl/Shift-click selects several rows.
|
- 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
|
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
|
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.
|
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.
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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.
|
changes** or **Forget them**. Do not use "verify" to undo mods; use Revert all.
|
||||||
|
|
||||||
## Subtitles
|
## 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),
|
- 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).
|
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
|
- 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.
|
- About 8 % of the subtitles belong to lines the game never plays (placeholders); they have no linked sound.
|
||||||
|
|
||||||
## Menu text
|
## 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
|
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
|
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).
|
as `\n` as they are).
|
||||||
|
|||||||
@@ -89,11 +89,12 @@ def pack(media):
|
|||||||
return bytes(hdr) + bank, bytes(payload)
|
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
|
"""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;
|
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);
|
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()
|
data = bytearray()
|
||||||
didx = b""
|
didx = b""
|
||||||
for m in sorted(media):
|
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))
|
2 if m in prefetch else 0))
|
||||||
objs += [x + (0,) for x in extra_sources]
|
objs += [x + (0,) for x in extra_sources]
|
||||||
from ackit.bnk import fnv1
|
from ackit.bnk import fnv1
|
||||||
|
obj = (bank_id & 0xFFFFF000) + 0x100 # HIRC object ids: unique per bank, like real Wwise ids
|
||||||
hirc_objs = []
|
hirc_objs = []
|
||||||
for i, (m, b, off, size, stream) in enumerate(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):
|
for i, (ev, m) in enumerate(events):
|
||||||
sid, aid = 0x300 + i, 0x400 + i
|
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((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((3, struct.pack("<III", aid, 0x4011, sid) + bytes(8)))
|
||||||
hirc_objs.append((4, struct.pack("<III", fnv1(ev), 1, aid)))
|
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))
|
hirc = struct.pack("<I", len(hirc_objs))
|
||||||
for t, body in hirc_objs:
|
for t, body in hirc_objs:
|
||||||
hirc += bytes([t]) + struct.pack("<I", len(body)) + body
|
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",
|
ev_bank = bank(fnv1("NPC_SynthDialogue"), {DLG_MEDIA: dlg_wem}, name="NPC_SynthDialogue",
|
||||||
events=[(SPEECH_EVENT, LINE_MEDIA[1])])
|
events=[(SPEECH_EVENT, LINE_MEDIA[1])])
|
||||||
objs = [("AkEvent", SPEECH_EVENT, bytes(8)), ("RDialogueEvent", "Radio_02", dlg)]
|
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))
|
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:
|
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" % m: w for m, w in list(foot.items()) + list(foley.items()) + list(gui.items())}
|
||||||
out["%08X" % DLG_MEDIA] = dlg_wem
|
out["%08X" % DLG_MEDIA] = dlg_wem
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
SPEECH_EVENT = "Speech_Synth_Radio_02"
|
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."],
|
SUBTITLES = {"INT": ["Scanner line two.", "Second part."], "ITA": ["Riga due dello scanner."],
|
||||||
"RUS": ["Строка два"]}
|
"RUS": ["Строка два"]}
|
||||||
DLG_MEDIA = 0x0E000001
|
DLG_MEDIA = 0x0E000001
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ def test_bank_sound_apply_revert_in_editor(win, tmp_path):
|
|||||||
w, root, originals = win
|
w, root, originals = win
|
||||||
before = files(root)
|
before = files(root)
|
||||||
w.cmb_arch.setCurrentText("BANK")
|
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")
|
assert w.select_name("CHR_SynthFoley_1")
|
||||||
ed = w.editor
|
ed = w.editor
|
||||||
assert "bank CHR_SynthFoley (2 copies)" in ed.meta.text()
|
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)
|
before = files(root)
|
||||||
w.tabs.setCurrentWidget(w.subs)
|
w.tabs.setCurrentWidget(w.subs)
|
||||||
p = 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.search.setText("scanner")
|
||||||
p._filter()
|
p._filter()
|
||||||
assert p.proxy.rowCount() == 1
|
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()
|
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):
|
def test_subtitles_find_replace(win):
|
||||||
w, root, _ = win
|
w, root, _ = win
|
||||||
before = files(root)
|
before = files(root)
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ def test_bank_catalog(synth_game, tmp_path):
|
|||||||
root, originals = synth_game
|
root, originals = synth_game
|
||||||
ws = _ws(root, tmp_path)
|
ws = _ws(root, tmp_path)
|
||||||
bank = {s.target: s for s in ws.catalog.sounds if s.archive == "BANK"}
|
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]]
|
foot = bank["%08X" % synth.FOOT[0]]
|
||||||
assert foot.bank == "CHR_SynthFootsteps_Media" and foot.name == "CHR_SynthFootsteps_Media_1"
|
assert foot.bank == "CHR_SynthFootsteps_Media" and foot.name == "CHR_SynthFootsteps_Media_1"
|
||||||
assert (foot.group, foot.category) == ("SFX", "Footsteps")
|
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 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"]
|
assert sorted(l.file for l in ws.locations(foley)) == ["Synth_A.upk", "Synth_B.upk"]
|
||||||
for s in bank.values():
|
for s in bank.values():
|
||||||
|
if s.language:
|
||||||
|
continue
|
||||||
assert ws.current_bytes(s) == originals[s.target]
|
assert ws.current_bytes(s) == originals[s.target]
|
||||||
assert all(_copy(ws, l) == originals[s.target] for l in ws.locations(s))
|
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
|
# the prefetch head belongs to INT variation 1 of the speech entry, not to the BANK archive
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ def test_shipped_csvs_are_consistent():
|
|||||||
keys = {r.key for r in rows}
|
keys = {r.key for r in rows}
|
||||||
assert len(keys) == len(rows), f
|
assert len(keys) == len(rows), f
|
||||||
assert all(r.group in classify.GROUPS for r in rows)
|
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)
|
# 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
|
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")))
|
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):
|
def test_real_game_uses_shipped_catalog(real_game_dir):
|
||||||
g = Game(real_game_dir)
|
g = Game(real_game_dir)
|
||||||
cat = Catalog(g, ["ITA", "SFX", "BANK"])
|
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")
|
s = cat.resolve("Speech_TAPE_JOKER_1_4")
|
||||||
assert s.category == "Interview tapes" and s.size == 44985
|
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
|
assert len(cat.heads) == 396 and sum(len(v) for v in cat.heads.values()) == 2101
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ def test_clean_synthetic_catalog_passes(synth_game):
|
|||||||
root, _ = synth_game
|
root, _ = synth_game
|
||||||
rep = catalogcheck.run(Game(root), log=lambda *a: None)
|
rep = catalogcheck.run(Game(root), log=lambda *a: None)
|
||||||
assert rep.ok, rep.issues
|
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):
|
def test_corrupted_catalog_is_reported(synth_game):
|
||||||
|
|||||||
@@ -56,11 +56,17 @@ def test_subtitle_catalog_and_link(synth_game, tmp_path):
|
|||||||
root, _ = synth_game
|
root, _ = synth_game
|
||||||
ws = _ws(root, tmp_path)
|
ws = _ws(root, tmp_path)
|
||||||
lines = ws.texts.subtitles("INT")
|
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.")]
|
(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]
|
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 [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 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 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.")
|
assert ws.texts.limit(SUB_KEY) == len("Scanner line two.")
|
||||||
|
|||||||
Reference in New Issue
Block a user