From a10593ec2402d949ba6b173f729090aec50dbbd6 Mon Sep 17 00:00:00 2001 From: Cristian Gora Date: Fri, 9 Oct 2026 22:42:50 +0200 Subject: [PATCH] Subtitles and menu text sections in the editor --- NOTES.md | 5 +- README.md | 8 +- app/dialogs.py | 10 +- app/editor.py | 9 + app/main.py | 7 + app/mainwindow.py | 139 ++++++++++-- app/textpanel.py | 556 +++++++++++++++++++++++++++++++++++++++++++++ docs/user-guide.md | 25 +- tests/test_app.py | 88 +++++++ 9 files changed, 816 insertions(+), 31 deletions(-) create mode 100644 app/textpanel.py diff --git a/NOTES.md b/NOTES.md index 66c6c67..b4139f3 100644 --- a/NOTES.md +++ b/NOTES.md @@ -32,7 +32,10 @@ events); menu text = Localization//*.int (`ackit/loctext.py`, ~7,000 strings/language). `Workspace.texts` (ackit/texts.py): states, apply/revert, conflicts, mod packages; CLI `ackit text`. Subtitles in place (NUL-padded, limit = original length); decision + reasons in decisions.md 4c. - 5. [ ] Editor: Subtitles section linked to audio, separate Menu text section, revert, mod export/import. + 5. [x] Editor: tabs Sounds / Subtitles / Menu text (app/textpanel.py: search, filters, edit with room + counter, apply/revert/revert all, find & replace on the filtered rows, go to sound); the sound editor + shows and edits the sound's subtitle (SubtitleBox); texts in mod export/import and the changed-outside + banner; smoke test loads texts. 15 GUI tests. 6. [ ] Final e2e (audio + text), packaged build, 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). diff --git a/README.md b/README.md index fbc4e8a..b2cc7ac 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # Arkham City Audio Toolkit -Replace any voice line, music track or ambience in **Batman: Arkham City (PC, Steam)** with your own audio: -by name, from a file or a recording, with one-click revert. No Wwise, no game patch, no external programs. +Replace any voice line, music track, ambience or sound effect in **Batman: Arkham City (PC, Steam)** with your own +audio (by name, from a file or a recording) and edit subtitles and menu text, with one-click revert. No Wwise, no +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. + replace or record, apply and revert. Edit subtitles (11 languages, linked to their speech lines) and the menu + text in their own sections. - **`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 diff --git a/app/dialogs.py b/app/dialogs.py index 7c67412..7632025 100644 --- a/app/dialogs.py +++ b/app/dialogs.py @@ -133,7 +133,7 @@ class ExportModDialog(QDialog): class ImportModDialog(QDialog): """Shows how each item of a package matches this game before installing.""" - def __init__(self, manifest, items, parent=None): + def __init__(self, manifest, items, parent=None, texts=()): super().__init__(parent) self.setWindowTitle("Import mod package") lay = QVBoxLayout(self) @@ -151,9 +151,15 @@ class ImportModDialog(QDialog): "missing": im.note}[im.how] QTreeWidgetItem(self.tree, [im.item.get("name") or im.item["target"], im.how, note]) n_ok += im.how != "missing" + t_ok = 0 + for key, _text, problem in texts: + QTreeWidgetItem(self.tree, [key, "text", problem or "will be applied"]) + t_ok += not problem self.tree.resizeColumnToContents(0) lay.addWidget(self.tree) - lay.addWidget(QLabel("%d of %d sounds will be installed." % (n_ok, len(items)))) + lay.addWidget(QLabel("%d of %d sounds and %d of %d texts will be installed." + % (n_ok, len(items), t_ok, len(texts)))) + n_ok += t_ok bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) bb.button(QDialogButtonBox.StandardButton.Ok).setText("Install") bb.button(QDialogButtonBox.StandardButton.Ok).setEnabled(n_ok > 0) diff --git a/app/editor.py b/app/editor.py index 2e7ea35..48df3aa 100644 --- a/app/editor.py +++ b/app/editor.py @@ -200,6 +200,14 @@ class EditorPanel(QWidget): act.addWidget(w) act.addStretch(1) lay.addLayout(act) + + # subtitle of this sound (edited side by side with the audio) ------------------------------- + from .textpanel import SubtitleBox + gs = QGroupBox("Subtitle") + gsl = QVBoxLayout(gs) + self.subtitle = SubtitleBox(self.ctx, gs) + gsl.addWidget(self.subtitle) + lay.addWidget(gs) lay.addStretch(1) self.recorder.level.connect(lambda v: self.meter.setValue(int(min(1.0, v) * 100))) @@ -252,6 +260,7 @@ class EditorPanel(QWidget): self.wf_inst.set_audio(None, 1, "") self.wf_new.set_audio(None, 1, "load a file or record a replacement") self.lbl_new.setText("") + self.subtitle.set_sound(s) if s is None: self.title.setText("No sound selected") self.meta.setText("Search and select a sound in the list.") diff --git a/app/main.py b/app/main.py index e9730a6..15b0d4c 100644 --- a/app/main.py +++ b/app/main.py @@ -50,6 +50,13 @@ def smoke_test(game_dir=None, report=None): assert read_target(out, s.target) == wem print("pipeline OK: %s (%s), %d sounds loaded, re-encoded %d bytes (limit %d), injected into a temp copy" % (s.name, s.archive, len(ws.catalog.sounds), len(wem), ws.limit(s))) + lang = ws.game.language() + b = next((x for x in ws.catalog.sounds if x.archive == "BANK"), None) + if b: + audio.decode_wem(ws.current_bytes(b)) + subs = ws.texts.subtitles(lang) if lang in ws.texts.subtitle_languages() else [] + print("texts OK: %d subtitles, %d menu texts (%s); in-memory sound decoded: %s" + % (len(subs), len(ws.texts.menu(lang)), lang, b.name if b else "none")) return 0 diff --git a/app/mainwindow.py b/app/mainwindow.py index b8bb6a8..f7831c5 100644 --- a/app/mainwindow.py +++ b/app/mainwindow.py @@ -1,22 +1,26 @@ -"""Main window: filters + sound table (browser) on top, single-sound editor below.""" +"""Main window. Tabs: Sounds (filters + sound table on top, single-sound editor below, with the sound's +subtitle), Subtitles and Menu text (app/textpanel.py).""" import os from PySide6.QtCore import QByteArray, QEvent, QItemSelectionModel, QSize, Qt, QTimer from PySide6.QtGui import QFont, QKeySequence, QShortcut from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QFileDialog, QFormLayout, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMainWindow, QMenu, QMessageBox, - QPushButton, QScrollArea, QSplitter, QTableView, QToolBar, QVBoxLayout, QWidget) + QPushButton, QScrollArea, QSplitter, QTableView, QTabWidget, QToolBar, QVBoxLayout, + QWidget) from ackit import settings as settings_mod, workspace as W from ackit.catalog import available_archives from ackit.game import Game, GameError, default_game_dir -from ackit.modpack import export_mod, match_mod, prepare_import +from ackit.modpack import export_mod, match_mod, match_texts, prepare_import +from ackit.texts import TextConflictError from . import icons, theme from .audioio import Player, find_device, output_devices from .dialogs import ExportModDialog, ImportModDialog, SettingsDialog, pick_game_dir from .editor import EditorPanel from .models import C_FAV, C_NAME, COLS, SoundFilter, SoundModel from .spinner import Spinner +from .textpanel import TextPanel from .userdata import UserData from .workers import Runner @@ -201,12 +205,28 @@ class MainWindow(QMainWindow): hs.addWidget(self.vsplit) hs.setStretchFactor(1, 1) hs.setSizes([SIDEBAR_MAX, 100000]) + # sections: sounds, subtitles, menu text + self.tabs = QTabWidget() + self.tabs.setDocumentMode(True) + self.tabs.addTab(hs, icons.icon("wave"), "Sounds") + self.subs = TextPanel(self, "subs") + self.menu = TextPanel(self, "menu") + self.tabs.addTab(self.subs, icons.icon("ab"), "Subtitles") + self.tabs.addTab(self.menu, icons.icon("settings"), "Menu text") + self._text_loaded = set() + for panel in (self.subs, self.menu): + panel.message.connect(lambda m: self.statusBar().showMessage(m, 8000)) + panel.changed.connect(self._texts_changed) + self.subs.go_to_sound.connect(self.show_sound) + self.editor.subtitle.changed.connect(self._texts_changed) + self.editor.subtitle.open_in_list.connect(self.show_subtitle) + self.tabs.currentChanged.connect(self._tab_changed) central = QWidget() cl = QVBoxLayout(central) cl.setContentsMargins(0, 0, 0, 0) cl.setSpacing(0) cl.addWidget(self.banner) - cl.addWidget(hs, 1) + cl.addWidget(self.tabs, 1) self.setCentralWidget(central) self.spinner = Spinner(14) self.statusBar().addWidget(self.spinner) @@ -275,6 +295,8 @@ class MainWindow(QMainWindow): self.cmb_lang.setCurrentText(lang) self.cmb_lang.blockSignals(False) self.populate() + self._text_loaded.clear() + self._tab_changed(self.tabs.currentIndex()) self.banner.hide() self.lbl_game.setText("%s • store: %s" % (game.root, ws.store.root)) self.statusBar().showMessage("Loaded %d sounds (%s)." % (len(ws.catalog.sounds), ", ".join(ws.catalog.archives)), 6000) @@ -434,6 +456,42 @@ class MainWindow(QMainWindow): self.cmb_tag.setCurrentText(cur) self.cmb_tag.blockSignals(False) + # --- text sections -------------------------------------------------------------------------- + def _tab_changed(self, i): + panel = self.tabs.widget(i) + if self.ws and panel in (self.subs, self.menu) and panel.kind not in self._text_loaded: + self._text_loaded.add(panel.kind) + panel.load() + + def _texts_changed(self): + for panel in (self.subs, self.menu): + if panel.kind in self._text_loaded: + panel.refresh() + self.editor.subtitle.set_sound(self.editor.sound) + + def show_sound(self, key): + """Sounds tab with `key` selected (from a subtitle).""" + s = self.ws.catalog.by_key.get(key) if self.ws else None + if not s: + self.inform("Sound", "%s is not in the sounds loaded now (choose its language at the top)." % key) + return + self.tabs.setCurrentIndex(0) + self.clear_filters() + self.select_name(s.name) + + 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(): + cb.setCurrentIndex(0) + self.subs._filter() + self.subs.select_key(key) + # --- changes ---------------------------------------------------------------------------------- def _after_change(self, key=None): self.model.refresh_keys(modified=self.ws.modified_keys()) @@ -468,6 +526,7 @@ class MainWindow(QMainWindow): self.spinner.hide() self.statusBar().showMessage(ok_msg, 8000) self._after_change() + self._texts_changed() if self.editor.sound: self.editor.set_sound(self.editor.sound) self.check_problems() @@ -493,11 +552,20 @@ class MainWindow(QMainWindow): st = ws.state(s) if st in (W.LOST, W.CHANGED): out.append((s, st)) - return out + texts = [] + for key in list(ws.texts.records()): + try: + st = ws.texts.state(key) + except (GameError, OSError, ValueError): + continue + if st in (W.LOST, W.CHANGED): + texts.append((key, st)) + return out, texts - def done(problems): - self.problems = problems - if not problems: + def done(res): + problems, texts = res + self.problems, self.text_problems = problems, texts + if not problems and not texts: if self.banner.isVisible() and not self.b_choose.isVisible(): self.banner.hide() return @@ -510,6 +578,9 @@ class MainWindow(QMainWindow): if changed: txt.append("%d sound(s) were changed by something else since you modified them: %s. " "Revert puts the original back." % (len(changed), ", ".join(changed[:4]))) + if texts: + txt.append("%d of your text changes were undone or changed outside this editor: %s" + % (len(texts), ", ".join(k for k, _ in texts[:3]) + (" …" if len(texts) > 3 else ""))) self.banner_text.setText("\n".join(txt)) self.b_reapply.show() self.b_forget.show() @@ -522,15 +593,24 @@ class MainWindow(QMainWindow): ws = self.ws items = [(s, ws.store.get_blob(ws.store.get(s.key)["applied"]), ws.store.get(s.key).get("meta", {})) for s, st in getattr(self, "problems", []) if ws.store.get(s.key) and ws.store.get(s.key).get("applied")] - if items: - self._run_change("Re-applying…", lambda job: ws.apply(items, force=True, progress=job.report), - "Re-applied %d change(s)." % len(items)) + texts = {k: ws.texts.records()[k]["applied"] for k, st in getattr(self, "text_problems", []) + if k in ws.texts.records() and ws.texts.records()[k].get("applied") is not None} + + def work(job): + if items: + ws.apply(items, force=True, progress=job.report) + if texts: + ws.texts.apply(texts, force=True, progress=job.report) + if items or texts: + self._run_change("Re-applying…", work, "Re-applied %d change(s)." % (len(items) + len(texts))) def forget_problems(self): - if not self.confirm("Forget", "Stop tracking these sounds? Their current game files are kept as they are."): + if not self.confirm("Forget", "Stop tracking these changes? The current game files are kept as they are."): return for s, _ in getattr(self, "problems", []): self.ws.store.forget(s.key) + for k, _ in getattr(self, "text_problems", []): + self.ws.texts.records().pop(k, None) self.ws.store.save() self.ws.store.gc() self.banner.hide() @@ -541,11 +621,13 @@ class MainWindow(QMainWindow): if not self.ws: return mods = self.ws.modified_keys() - if not mods: - self.inform("Export mod", "You have not changed any sound yet.") + texts = self.ws.texts.modified() + if not mods and not texts: + self.inform("Export mod", "You have not changed any sound or text yet.") return if keys is None: names = [(k, self.ws.catalog.by_key[k].name if k in self.ws.catalog.by_key else k) for k in mods] + names += [(k, "Text " + k) for k in texts] dlg = ExportModDialog(names, self) if dlg.exec() != QDialog.DialogCode.Accepted: return @@ -555,8 +637,10 @@ class MainWindow(QMainWindow): self.settings.get("last_dir") or "", (title or "my_mod") + ".acmod"), "Unheard City mod (*.acmod)") if not path: return - self._run_change("Exporting…", lambda job: export_mod(self.ws, path, keys, title, author, description), - "Saved %s" % path) + text_keys = [k for k in keys if k.startswith(("SUB:", "MENU:"))] + keys = [k for k in keys if k not in text_keys] + self._run_change("Exporting…", lambda job: export_mod(self.ws, path, keys, title, author, description, + texts=text_keys), "Saved %s" % path) def import_mod_dialog(self, path=None, confirm=True): if not self.ws: @@ -571,18 +655,27 @@ class MainWindow(QMainWindow): except Exception as e: # noqa: BLE001 self.inform("Import mod", "Cannot read this package: %s" % e) return - if confirm and ImportModDialog(man, items, self).exec() != QDialog.DialogCode.Accepted: + dlg = ImportModDialog(man, items, self, match_texts(self.ws, man)) if confirm else None + if dlg and dlg.exec() != QDialog.DialogCode.Accepted: return ws = self.ws def work(job): prepare_import(ws, path, items, progress=job.report) - changes = [(im.sound, im.wem, {"source": "mod:" + os.path.basename(path)}) for im in items if im.wem] - try: - ws.apply(changes, progress=job.report) - except W.ConflictError: - ws.apply(changes, force=True, progress=job.report) - return len(changes) + source = {"source": "mod:" + os.path.basename(path)} + changes = [(im.sound, im.wem, source) for im in items if im.wem] + if changes: + try: + ws.apply(changes, progress=job.report) + except W.ConflictError: + ws.apply(changes, force=True, progress=job.report) + texts = {k: v for k, v, problem in match_texts(ws, man) if not problem} + if texts: + try: + ws.texts.apply(texts, progress=job.report, meta=source) + except TextConflictError: + ws.texts.apply(texts, force=True, progress=job.report, meta=source) + return len(changes) + len(texts) self._run_change("Installing mod…", work, "Installed %s" % os.path.basename(path)) diff --git a/app/textpanel.py b/app/textpanel.py new file mode 100644 index 0000000..2abed9c --- /dev/null +++ b/app/textpanel.py @@ -0,0 +1,556 @@ +"""Text sections of the main window: Subtitles (linked to the speech sounds) and Menu text. + +One TextPanel per section: filters + table on top, the editor of the selected text below. Texts are written +through Workspace.texts (ackit/texts.py): subtitles in place into every copy (limited to the original's +length), menu texts into the localization files. Revert per text or all; changed-underneath warnings. +""" +from PySide6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt, QTimer, Signal +from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, + QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QSplitter, QTableView, + QVBoxLayout, QWidget) + +from ackit import subtitles +from ackit.texts import CHANGED, FOREIGN, LOST, MODIFIED, ORIGINAL, TextConflictError +from . import icons + +ANY = "Any" +STATE_TEXT = {ORIGINAL: "", MODIFIED: "modified", LOST: "lost", CHANGED: "changed outside", FOREIGN: "not original"} + + +class Row: + __slots__ = ("key", "cols", "text", "sound", "search") + + def __init__(self, key, cols, text, sound=None): + self.key, self.cols, self.text, self.sound = key, cols, text, sound + self.search = (" ".join(str(c) for c in cols) + " " + text).lower() + + +class TextModel(QAbstractTableModel): + def __init__(self, headers, parent=None): + super().__init__(parent) + self.headers = headers + ["Text", "State"] + self.rows = [] + self.modified = {} # key -> applied text + + def reset(self, rows, modified): + self.beginResetModel() + self.rows, self.modified = rows, dict(modified) + self.endResetModel() + + def set_modified(self, modified): + self.modified = dict(modified) + if self.rows: + self.dataChanged.emit(self.index(0, 0), self.index(len(self.rows) - 1, len(self.headers) - 1)) + + def rowCount(self, parent=QModelIndex()): + return 0 if parent.isValid() else len(self.rows) + + def columnCount(self, parent=QModelIndex()): + return len(self.headers) + + def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: + return self.headers[section] + return None + + def text_of(self, r): + return self.modified.get(r.key, r.text) + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + r = self.rows[index.row()] + c = index.column() + n = len(r.cols) + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole): + if c < n: + return r.cols[c] + if c == n: + return self.text_of(r) + return "modified" if r.key in self.modified else "" + if role == Qt.ItemDataRole.UserRole: + return r + if role == Qt.ItemDataRole.ForegroundRole and c == n + 1 and r.key in self.modified: + from PySide6.QtGui import QColor + return QColor(icons.ACCENT) + return None + + +class TextFilter(QSortFilterProxyModel): + def __init__(self, parent=None): + super().__init__(parent) + self.words, self.col_filters, self.only_modified = [], {}, False + self.setSortCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + + def refilter(self): + if hasattr(self, "beginFilterChange"): + self.beginFilterChange() + self.endFilterChange(QSortFilterProxyModel.Direction.Rows) + else: + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent): + m = self.sourceModel() + r = m.rows[row] + if self.only_modified and r.key not in m.modified: + return False + for c, v in self.col_filters.items(): + if v != ANY and r.cols[c] != v: + return False + if self.words: + hay = r.search + " " + m.modified.get(r.key, "").lower() + return all(w in hay for w in self.words) + return True + + def row_at(self, index): + return self.sourceModel().rows[self.mapToSource(index).row()] + + +class FindReplaceDialog(QDialog): + def __init__(self, count, parent=None): + super().__init__(parent) + self.setWindowTitle("Find and replace") + f = QFormLayout(self) + self.find, self.repl = QLineEdit(), QLineEdit() + self.case = QCheckBox("Match case") + f.addRow(QLabel("In the %d text(s) shown by the current filter:" % count)) + f.addRow("Find", self.find) + f.addRow("Replace with", self.repl) + f.addRow(self.case) + bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + bb.accepted.connect(self.accept) + bb.rejected.connect(self.reject) + f.addRow(bb) + + +def replace_in(text, find, repl, case): + if case: + return text.replace(find, repl) + import re + return re.sub(re.escape(find), lambda _: repl, text, flags=re.IGNORECASE) + + +class SubtitleBox(QWidget): + """The subtitle of the sound open in the sound editor (side by side with the audio).""" + changed = Signal() + open_in_list = Signal(str) # SUB key + + def __init__(self, win, parent=None): + super().__init__(parent) + self.win = win + self.keys = [] + self.lay = QFormLayout(self) + self.lay.setContentsMargins(0, 0, 0, 0) + self.edits = [] + self.lbl = QLabel() + self.lbl.setObjectName("Muted") + row = QHBoxLayout() + self.b_apply = QPushButton(icons.icon("apply"), "Apply subtitle") + self.b_revert = QPushButton(icons.icon("revert"), "Revert subtitle") + self.b_open = QPushButton(icons.icon("search"), "Show in Subtitles") + self.b_apply.clicked.connect(self.apply) + self.b_revert.clicked.connect(self.revert) + self.b_open.clicked.connect(lambda: self.keys and self.open_in_list.emit(self.keys[0])) + for b in (self.b_apply, self.b_revert, self.b_open): + row.addWidget(b) + row.addStretch(1) + self.row = row + self.lay.addRow(self.lbl) + self.lay.addRow(row) + + def set_sound(self, s): + for e in self.edits: + self.lay.removeRow(e) + self.edits, self.keys = [], [] + ws = self.win.ws + lines = ws.texts.for_sound(s) 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): + b.setEnabled(False) + return + tx = ws.texts + for i, ln in enumerate(lines): + k = subtitles.key(ln.lang, ln.speech, ln.line) + e = QLineEdit(tx.records().get(k, {}).get("applied") or ln.text) + e.setToolTip("Original: %s\nRoom: %d characters" % (ln.text, ln.room)) + e.textChanged.connect(self._check) + self.lay.insertRow(i, "Line %d" % (i + 1) if len(lines) > 1 else "Subtitle", e) + self.edits.append(e) + self.keys.append(k) + self.b_open.setEnabled(True) + self.b_revert.setEnabled(any(k in tx.records() for k in self.keys)) + self._check() + + def _check(self): + tx = self.win.ws.texts + msgs = [] + ok = True + for k, e in zip(self.keys, self.edits): + try: + tx.check(k, e.text()) + except ValueError as ex: + ok = False + msgs.append(str(ex)) + rooms = [tx.limit(k) for k in self.keys] + self.lbl.setText(" • ".join(msgs) if msgs else "Room: %s characters (%s)" % ( + " / ".join(str(r) for r in rooms), self.keys[0].split(":")[1])) + self.b_apply.setEnabled(ok) + + def values(self): + return {k: e.text() for k, e in zip(self.keys, self.edits)} + + def apply(self): + tx = self.win.ws.texts + changes = {k: v for k, v in self.values().items() if v != tx.original(k) or k in tx.records()} + if not changes: + return + self._run(lambda job, force: tx.apply(changes, force=force, progress=job.report), "Subtitle applied.") + + def revert(self): + tx = self.win.ws.texts + self._run(lambda job, force: tx.revert(self.keys, progress=job.report), "Subtitle reverted.") + + def _run(self, fn, ok_msg): + def done(_): + self.win.statusBar().showMessage(ok_msg, 6000) + self.changed.emit() + + def failed(e, tb): + if isinstance(e, TextConflictError) and self.win.confirm("Changed outside", "%s\n\nOverwrite anyway?" % e): + self.win.runner.start(lambda job: fn(job, True), done, failed) + return + self.win.inform("Error", str(e)) + + self.win.runner.start(lambda job: fn(job, False), done, failed) + + +class TextPanel(QWidget): + """kind 'subs' (subtitles) or 'menu' (menu text).""" + changed = Signal() + message = Signal(str) + go_to_sound = Signal(str) # sound key (subtitles) + + def __init__(self, win, kind): + super().__init__(win) + self.win, self.kind = win, kind + self.lang = None + self.current = None # selected Row + self._build() + + # --- UI --------------------------------------------------------------------------------------- + def _build(self): + v = QVBoxLayout(self) + v.setContentsMargins(6, 6, 6, 6) + top = QHBoxLayout() + self.search = QLineEdit() + self.search.setClearButtonEnabled(True) + self.search.setPlaceholderText("Search texts, names, characters…" if self.kind == "subs" else + "Search texts, files, keys…") + self.search.addAction(icons.icon("search", icons.MUTED), QLineEdit.ActionPosition.LeadingPosition) + self._timer = QTimer(self) + self._timer.setSingleShot(True) + 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())) + top.addWidget(self.search, 1) + top.addWidget(QLabel("Language")) + top.addWidget(self.cmb_lang) + self.filters = {} + names = ("Character", "Category") if self.kind == "subs" else ("File",) + for name in names: + cb = QComboBox() + cb.setMinimumWidth(120) + cb.currentTextChanged.connect(lambda _: self._filter()) + top.addWidget(QLabel(name)) + top.addWidget(cb) + self.filters[name] = cb + self.cb_mod = QCheckBox("Modified only") + self.cb_mod.toggled.connect(lambda _: self._filter()) + top.addWidget(self.cb_mod) + self.lbl_count = QLabel() + top.addWidget(self.lbl_count) + v.addLayout(top) + + headers = ["Speech line", "#", "Character", "Category", "Room"] if self.kind == "subs" else \ + ["File", "Section", "Key"] + self.model = TextModel(headers, self) + self.proxy = TextFilter(self) + self.proxy.setSourceModel(self.model) + self.table = QTableView() + self.table.setModel(self.proxy) + self.table.setSortingEnabled(True) + self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.table.verticalHeader().hide() + self.table.verticalHeader().setDefaultSectionSize(22) + self.table.setAlternatingRowColors(True) + self.table.setWordWrap(False) + h = self.table.horizontalHeader() + h.setSectionResizeMode(QHeaderView.ResizeMode.Interactive) + h.setStretchLastSection(False) + widths = (330, 30, 120, 130, 50) if self.kind == "subs" else (140, 150, 220) + for c, w in enumerate(widths): + self.table.setColumnWidth(c, w) + self.table.setColumnWidth(len(widths), 560) + self.table.selectionModel().currentRowChanged.connect(lambda cur, _: self._select(cur)) + + # editor + ed = QWidget() + f = QFormLayout(ed) + self.title = QLabel("No text selected") + self.title.setObjectName("EditorTitle") + self.lbl_orig = QLabel() + self.lbl_orig.setWordWrap(True) + self.lbl_orig.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.edit = QLineEdit() + self.edit.textChanged.connect(self._edited) + self.edit.returnPressed.connect(self.apply_current) + self.lbl_info = QLabel() + row = QHBoxLayout() + self.b_apply = QPushButton(icons.icon("apply"), "Apply") + self.b_revert = QPushButton(icons.icon("revert"), "Revert") + self.b_replace = QPushButton(icons.icon("search"), "Find and replace…") + self.b_revert_all = QPushButton(icons.icon("revert"), "Revert all texts here") + self.b_apply.clicked.connect(self.apply_current) + self.b_revert.clicked.connect(self.revert_current) + self.b_replace.clicked.connect(self.find_replace) + self.b_revert_all.clicked.connect(self.revert_all) + for b in (self.b_apply, self.b_revert): + row.addWidget(b) + if self.kind == "subs": + self.b_sound = QPushButton(icons.icon("play"), "Go to the sound") + self.b_sound.clicked.connect(lambda: self.current and self.current.sound and + self.go_to_sound.emit(self.current.sound)) + row.addWidget(self.b_sound) + row.addStretch(1) + row.addWidget(self.b_replace) + row.addWidget(self.b_revert_all) + f.addRow(self.title) + f.addRow("Original", self.lbl_orig) + f.addRow("Text", self.edit) + f.addRow("", self.lbl_info) + f.addRow(row) + split = QSplitter(Qt.Orientation.Vertical) + split.addWidget(self.table) + split.addWidget(ed) + split.setStretchFactor(0, 1) + split.setStretchFactor(1, 0) + v.addWidget(split, 1) + self._select(None) + + # --- data --------------------------------------------------------------------------------------- + def ws(self): + return self.win.ws + + def languages(self): + tx = self.ws().texts + 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).""" + 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) + self.lang = lang + if not lang: + self.model.reset([], {}) + return + tx = ws.texts + rows = [] + if self.kind == "subs": + by_key = ws.catalog.by_key + for s in tx.subtitles(lang): + snd = by_key.get(s.sound) # None when the line's archive is not loaded + who = (snd.character or snd.characters_inferred) if snd else "" + cat = snd.category if snd else "" + rows.append(Row(subtitles.key(lang, s.speech, s.line), + [s.speech, s.line + 1, who, cat, s.room], s.text, s.sound or None)) + else: + for e in tx.menu(lang): + rows.append(Row("MENU:" + e.key, [e.file, e.section, e.name + ("#%d" % e.occurrence if e.occurrence > 1 else "")], + self._menu_original(e))) + self.model.reset(rows, self._modified()) + for i, cb in enumerate(self.filters.values()): + col = (2 + i) if self.kind == "subs" else 0 + cur = cb.currentText() + cb.blockSignals(True) + cb.clear() + cb.addItems([ANY] + sorted({str(r.cols[col]) for r in rows if r.cols[col]})) + cb.setCurrentText(cur if cur else ANY) + cb.blockSignals(False) + self._filter() + + def _menu_original(self, e): + r = self.ws().texts.records().get("MENU:" + e.key) + return r["orig"] if r else e.value + + def _modified(self): + return {k: r["applied"] for k, r in self.ws().texts.records().items() + if r.get("applied") is not None and k.split(":")[1] == self.lang and + k.startswith("SUB:" if self.kind == "subs" else "MENU:")} + + def refresh(self): + self.model.set_modified(self._modified()) + self.proxy.refilter() + self._update_count() + if self.current: + self._show(self.current) + + def _filter(self): + self.proxy.words = [w for w in self.search.text().lower().split() if w] + self.proxy.only_modified = self.cb_mod.isChecked() + cols = (2, 3) if self.kind == "subs" else (0,) + self.proxy.col_filters = {c: cb.currentText() for c, cb in zip(cols, self.filters.values())} + self.proxy.refilter() + self._update_count() + + def _update_count(self): + self.lbl_count.setText("%d / %d" % (self.proxy.rowCount(), self.model.rowCount())) + + def visible_rows(self): + return [self.proxy.row_at(self.proxy.index(i, 0)) for i in range(self.proxy.rowCount())] + + def select_key(self, key): + for i in range(self.proxy.rowCount()): + if self.proxy.row_at(self.proxy.index(i, 0)).key == key: + self.table.selectRow(i) + self.table.scrollTo(self.proxy.index(i, 0)) + return True + return False + + # --- editing ------------------------------------------------------------------------------------ + def _select(self, index): + r = self.proxy.row_at(index) if index is not None and index.isValid() else None + self.current = r + self._show(r) + + def _show(self, r): + enabled = r is not None + for w in (self.edit, self.b_apply, self.b_revert): + w.setEnabled(enabled) + if self.kind == "subs": + self.b_sound.setEnabled(bool(r and r.sound)) + if not r: + self.title.setText("No text selected") + self.lbl_orig.setText("") + self.edit.setText("") + self.lbl_info.setText("") + return + tx = self.ws().texts + self.title.setText(r.key) + self.lbl_orig.setText(r.text) + try: + cur = tx.current(r.key) + st = tx.state(r.key, cur) + except Exception as e: # noqa: BLE001 - unreadable file: show it, do not crash + cur, st = r.text, "error: %s" % e + self.edit.blockSignals(True) + self.edit.setText(cur) + self.edit.blockSignals(False) + self._state = st + self.b_revert.setEnabled(r.key in tx.records()) + self._edited() + + def _edited(self): + r = self.current + if not r: + return + tx = self.ws().texts + lim = tx.limit(r.key) + n = len(self.edit.text()) + info = [] + if lim is not None: + info.append("%d / %d characters" % (n, lim)) + st = STATE_TEXT.get(self._state, self._state) + if st: + info.append("state: " + st) + try: + tx.check(r.key, self.edit.text()) + ok = True + except ValueError as e: + info.append(str(e)) + ok = False + self.lbl_info.setText(" • ".join(info)) + self.b_apply.setEnabled(ok) + + def _run(self, busy, fn, ok_msg): + def done(_): + self.message.emit(ok_msg) + self.refresh() + self.changed.emit() + + def failed(e, tb): + if isinstance(e, TextConflictError) and self.win.confirm( + "Changed outside", "%s\n\nOverwrite anyway?" % e): + self.win.runner.start(lambda job: fn(job, True), done, failed) + return + self.win.inform("Error", str(e)) + + self.message.emit(busy) + self.win.runner.start(lambda job: fn(job, False), done, failed) + + def apply_current(self): + r = self.current + if not r or not self.b_apply.isEnabled(): + return + text = self.edit.text() + self._run("Writing…", lambda job, force: self.ws().texts.apply({r.key: text}, force=force, progress=job.report), + "Applied %s" % r.key) + + def revert_current(self): + r = self.current + if r: + self._run("Reverting…", lambda job, force: self.ws().texts.revert([r.key], progress=job.report), + "Reverted %s" % r.key) + + def revert_all(self): + keys = list(self._modified()) + if not keys: + self.win.inform("Revert", "No %s text is modified." % self.lang) + return + if not self.win.confirm("Revert", "Put the original back for %d text(s)?" % len(keys)): + return + self._run("Reverting…", lambda job, force: self.ws().texts.revert(keys, progress=job.report), + "Reverted %d text(s)." % len(keys)) + + def find_replace(self, find=None, repl=None, case=False): + rows = self.visible_rows() + if find is None: + dlg = FindReplaceDialog(len(rows), self) + if dlg.exec() != QDialog.DialogCode.Accepted or not dlg.find.text(): + return + find, repl, case = dlg.find.text(), dlg.repl.text(), dlg.case.isChecked() + tx = self.ws().texts + changes, skipped = {}, [] + for r in rows: + old = self.model.text_of(r) + new = replace_in(old, find, repl, case) + if new == old: + continue + try: + tx.check(r.key, new) + changes[r.key] = new + except ValueError as e: + skipped.append("%s: %s" % (r.key, e)) + if not changes: + self.win.inform("Find and replace", "Nothing to replace." + ("\n" + "\n".join(skipped[:10]) if skipped else "")) + return + if skipped and not self.win.confirm("Find and replace", "%d text(s) cannot take the replacement and are " + "skipped:\n%s\n\nReplace in the other %d?" % ( + len(skipped), "\n".join(skipped[:10]), len(changes))): + return + self._run("Replacing in %d text(s)…" % len(changes), + lambda job, force: tx.apply(changes, force=force, progress=job.report), + "Replaced in %d text(s)." % len(changes)) + return len(changes) diff --git a/docs/user-guide.md b/docs/user-guide.md index c1730a2..6f9679c 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1,7 +1,7 @@ # Unheard City - user guide -Replace voice lines, music and ambience in **Batman: Arkham City (PC, Steam)**. Everything you change can be put -back with one click. +Replace voice lines, music, ambience and sound effects, and edit subtitles and menu text in **Batman: Arkham +City (PC, Steam)**. Everything you change can be put back with one click. ## Install and start 1. **Windows:** unzip the download and double-click **Install.cmd**. It installs Unheard City for your user (no @@ -65,6 +65,26 @@ than the original, since the game may cut it off. - If Steam "Verify integrity of game files" or an update undoes your changes, a bar offers **Re-apply my 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*). +- 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 + shows *used / room* characters). Latin-1 subtitles accept Latin-1 characters only (accents are fine); + Russian, Polish, Japanese and Korean subtitles accept any character. +- **Go to the sound** opens the speech line in the Sounds tab. In the Sounds tab, the editor of a speech line + shows its subtitle under the audio: edit both side by side and use **Apply subtitle**, or **Show in + Subtitles** to jump back. +- **Find and replace…** works on the rows the filters show (texts that would not fit are listed and skipped). +- **Revert** / **Revert all texts here** put the original text back (every copy in every game file). +- 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 +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). + ## Notes, tags, favorites Right-click → **Notes & tags…** (F2): free text and comma-separated tags, searchable and filterable. Click the **★** column to mark favorites. These are stored in your user data folder, not in the game. @@ -73,6 +93,7 @@ Click the **★** column to mark favorites. These are stored in your user data f - **Export mod…**: choose which changed sounds to include and save a `.acmod` file (the exact game data plus the audio, so it also works for another language whose original has a different size). - **Import mod…**: shows which sounds match this game, then installs them. You can revert them like any change. +- Changed subtitles and menu texts are exported and imported with the sounds (listed as *Text*). ## Settings ![Settings](img/settings.png) diff --git a/tests/test_app.py b/tests/test_app.py index a72e4e1..93d666a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -111,6 +111,94 @@ def test_bank_sound_apply_revert_in_editor(win, tmp_path): assert files(root) == before and not w.ws.store.entries +def test_subtitles_tab_edit_and_link(win): + w, root, _ = win + before = files(root) + w.tabs.setCurrentWidget(w.subs) + p = w.subs + assert p.lang == "INT" and p.model.rowCount() == 2 + p.search.setText("scanner") + p._filter() + assert p.proxy.rowCount() == 1 + p.table.selectRow(0) + assert p.edit.text() == "Scanner line two." and "/ 17 characters" in p.lbl_info.text() + p.edit.setText("x" * 30) + assert not p.b_apply.isEnabled() # longer than the room + p.edit.setText("Scan two.") + p.apply_current() + key = p.current.key + assert w.ws.texts.state(key) == "modified" and p.model.modified[key] == "Scan two." + p.go_to_sound.emit(p.current.sound) # "Go to the sound" + assert w.tabs.currentIndex() == 0 and w.editor.sound.name == "CHR_RadioScanner_2" + box = w.editor.subtitle + assert [e.text() for e in box.edits] == ["Scan two.", "Second part."] + box.edits[1].setText("Part two.") + box.apply() + assert w.ws.texts.current(key.replace(":0", ":1")) == "Part two." + box.open_in_list.emit(box.keys[1]) # back to the Subtitles tab + assert w.tabs.currentWidget() is p and p.current.key == box.keys[1] + box.revert() + assert files(root) == before and not w.ws.texts.records() + + +def test_subtitles_find_replace(win): + w, root, _ = win + before = files(root) + w.tabs.setCurrentWidget(w.subs) + p = w.subs + assert p.find_replace("line", "row") == 1 + assert w.ws.texts.current("SUB:INT:%s:0" % synth.SPEECH_EVENT) == "Scanner row two." + p.revert_all() + assert files(root) == before + + +def test_menu_text_tab(win): + w, root, _ = win + before = files(root) + w.tabs.setCurrentWidget(w.menu) + p = w.menu + assert p.model.rowCount() == 4 + p.filters["File"].setCurrentText("FRONTEND.int") + p.search.setText("press") + p._filter() + assert p.proxy.rowCount() == 1 + p.table.selectRow(0) + p.edit.setText("Press Enter to start") + p.apply_current() + assert w.ws.texts.current("MENU:INT:FRONTEND.int:Menu:Start") == "Press Enter to start" + p.cb_mod.setChecked(True) + assert p.proxy.rowCount() == 1 + p.revert_all() + assert files(root) == before + + +def test_mod_with_texts(win, tmp_path): + w, root, _ = win + tx = w.ws.texts + tx.apply({"SUB:INT:%s:0" % synth.SPEECH_EVENT: "Mod line.", "MENU:INT:FRONTEND.int:Menu:Quit": "Leave"}) + pkg = str(tmp_path / "texts.acmod") + w.export_mod_dialog(path=pkg, keys=tx.modified(), title="texts") + tx.revert_all() + w.import_mod_dialog(path=pkg, confirm=False) + assert tx.current("MENU:INT:FRONTEND.int:Menu:Quit") == "Leave" + assert tx.current("SUB:INT:%s:0" % synth.SPEECH_EVENT) == "Mod line." + tx.revert_all() + + +def test_text_undone_outside_shows_banner(win): + w, root, _ = win + tx = w.ws.texts + k = "MENU:INT:FRONTEND.int:Menu:Start" + tx.apply({k: "Go"}) + path = tx.files(k)[0] + open(path, "wb").write(open(w.ws.store.full_copy(w.ws.game.rel(path)), "rb").read()) # like 'verify files' + w.check_problems() + assert not w.banner.isHidden() and "text changes" in w.banner_text.text() + w.reapply_lost() + assert tx.current(k) == "Go" and tx.state(k) == "modified" + tx.revert_all() + + def test_record_simulated_input(win): w, root, _ = win w.select_name("CHR_RadioScanner_1")