Subtitles and menu text sections in the editor
This commit is contained in:
5
NOTES.md
5
NOTES.md
@@ -32,7 +32,10 @@
|
||||
events); menu text = Localization/<LANG>/*.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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
556
app/textpanel.py
Normal file
556
app/textpanel.py
Normal file
@@ -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)
|
||||
@@ -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
|
||||

|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user