Subtitles and menu text sections in the editor

This commit is contained in:
2026-10-09 22:42:50 +02:00
parent ef68edbe45
commit a10593ec24
9 changed files with 816 additions and 31 deletions

View File

@@ -32,7 +32,10 @@
events); menu text = Localization/<LANG>/*.int (`ackit/loctext.py`, ~7,000 strings/language). 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`. `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. 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. 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; - 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).

View File

@@ -1,12 +1,14 @@
# Arkham City Audio Toolkit # Arkham City Audio Toolkit
Replace any voice line, music track or ambience in **Batman: Arkham City (PC, Steam)** with your own audio: Replace any voice line, music track, ambience or sound effect in **Batman: Arkham City (PC, Steam)** with your own
by name, from a file or a recording, with one-click revert. No Wwise, no game patch, no external programs. 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 - **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 ~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, 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. - **`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

View File

@@ -133,7 +133,7 @@ class ExportModDialog(QDialog):
class ImportModDialog(QDialog): class ImportModDialog(QDialog):
"""Shows how each item of a package matches this game before installing.""" """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) super().__init__(parent)
self.setWindowTitle("Import mod package") self.setWindowTitle("Import mod package")
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
@@ -151,9 +151,15 @@ class ImportModDialog(QDialog):
"missing": im.note}[im.how] "missing": im.note}[im.how]
QTreeWidgetItem(self.tree, [im.item.get("name") or im.item["target"], im.how, note]) QTreeWidgetItem(self.tree, [im.item.get("name") or im.item["target"], im.how, note])
n_ok += im.how != "missing" 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) self.tree.resizeColumnToContents(0)
lay.addWidget(self.tree) 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 = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
bb.button(QDialogButtonBox.StandardButton.Ok).setText("Install") bb.button(QDialogButtonBox.StandardButton.Ok).setText("Install")
bb.button(QDialogButtonBox.StandardButton.Ok).setEnabled(n_ok > 0) bb.button(QDialogButtonBox.StandardButton.Ok).setEnabled(n_ok > 0)

View File

@@ -200,6 +200,14 @@ class EditorPanel(QWidget):
act.addWidget(w) act.addWidget(w)
act.addStretch(1) act.addStretch(1)
lay.addLayout(act) 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) lay.addStretch(1)
self.recorder.level.connect(lambda v: self.meter.setValue(int(min(1.0, v) * 100))) 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_inst.set_audio(None, 1, "")
self.wf_new.set_audio(None, 1, "load a file or record a replacement") self.wf_new.set_audio(None, 1, "load a file or record a replacement")
self.lbl_new.setText("") self.lbl_new.setText("")
self.subtitle.set_sound(s)
if s is None: if s is None:
self.title.setText("No sound selected") self.title.setText("No sound selected")
self.meta.setText("Search and select a sound in the list.") self.meta.setText("Search and select a sound in the list.")

View File

@@ -50,6 +50,13 @@ def smoke_test(game_dir=None, report=None):
assert read_target(out, s.target) == wem 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" 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))) % (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 return 0

View File

@@ -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 import os
from PySide6.QtCore import QByteArray, QEvent, QItemSelectionModel, QSize, Qt, QTimer from PySide6.QtCore import QByteArray, QEvent, QItemSelectionModel, QSize, Qt, QTimer
from PySide6.QtGui import QFont, QKeySequence, QShortcut from PySide6.QtGui import QFont, QKeySequence, QShortcut
from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QFileDialog, QFormLayout, from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QFileDialog, QFormLayout,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMainWindow, QMenu, QMessageBox, 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 import settings as settings_mod, workspace as W
from ackit.catalog import available_archives from ackit.catalog import available_archives
from ackit.game import Game, GameError, default_game_dir 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 . import icons, theme
from .audioio import Player, find_device, output_devices from .audioio import Player, find_device, output_devices
from .dialogs import ExportModDialog, ImportModDialog, SettingsDialog, pick_game_dir from .dialogs import ExportModDialog, ImportModDialog, SettingsDialog, pick_game_dir
from .editor import EditorPanel from .editor import EditorPanel
from .models import C_FAV, C_NAME, COLS, SoundFilter, SoundModel from .models import C_FAV, C_NAME, COLS, SoundFilter, SoundModel
from .spinner import Spinner from .spinner import Spinner
from .textpanel import TextPanel
from .userdata import UserData from .userdata import UserData
from .workers import Runner from .workers import Runner
@@ -201,12 +205,28 @@ class MainWindow(QMainWindow):
hs.addWidget(self.vsplit) hs.addWidget(self.vsplit)
hs.setStretchFactor(1, 1) hs.setStretchFactor(1, 1)
hs.setSizes([SIDEBAR_MAX, 100000]) 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() central = QWidget()
cl = QVBoxLayout(central) cl = QVBoxLayout(central)
cl.setContentsMargins(0, 0, 0, 0) cl.setContentsMargins(0, 0, 0, 0)
cl.setSpacing(0) cl.setSpacing(0)
cl.addWidget(self.banner) cl.addWidget(self.banner)
cl.addWidget(hs, 1) cl.addWidget(self.tabs, 1)
self.setCentralWidget(central) self.setCentralWidget(central)
self.spinner = Spinner(14) self.spinner = Spinner(14)
self.statusBar().addWidget(self.spinner) self.statusBar().addWidget(self.spinner)
@@ -275,6 +295,8 @@ class MainWindow(QMainWindow):
self.cmb_lang.setCurrentText(lang) self.cmb_lang.setCurrentText(lang)
self.cmb_lang.blockSignals(False) self.cmb_lang.blockSignals(False)
self.populate() self.populate()
self._text_loaded.clear()
self._tab_changed(self.tabs.currentIndex())
self.banner.hide() self.banner.hide()
self.lbl_game.setText("%s • store: %s" % (game.root, ws.store.root)) 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) 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.setCurrentText(cur)
self.cmb_tag.blockSignals(False) 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 ---------------------------------------------------------------------------------- # --- changes ----------------------------------------------------------------------------------
def _after_change(self, key=None): def _after_change(self, key=None):
self.model.refresh_keys(modified=self.ws.modified_keys()) self.model.refresh_keys(modified=self.ws.modified_keys())
@@ -468,6 +526,7 @@ class MainWindow(QMainWindow):
self.spinner.hide() self.spinner.hide()
self.statusBar().showMessage(ok_msg, 8000) self.statusBar().showMessage(ok_msg, 8000)
self._after_change() self._after_change()
self._texts_changed()
if self.editor.sound: if self.editor.sound:
self.editor.set_sound(self.editor.sound) self.editor.set_sound(self.editor.sound)
self.check_problems() self.check_problems()
@@ -493,11 +552,20 @@ class MainWindow(QMainWindow):
st = ws.state(s) st = ws.state(s)
if st in (W.LOST, W.CHANGED): if st in (W.LOST, W.CHANGED):
out.append((s, st)) 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): def done(res):
self.problems = problems problems, texts = res
if not problems: self.problems, self.text_problems = problems, texts
if not problems and not texts:
if self.banner.isVisible() and not self.b_choose.isVisible(): if self.banner.isVisible() and not self.b_choose.isVisible():
self.banner.hide() self.banner.hide()
return return
@@ -510,6 +578,9 @@ class MainWindow(QMainWindow):
if changed: if changed:
txt.append("%d sound(s) were changed by something else since you modified them: %s. " 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]))) "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.banner_text.setText("\n".join(txt))
self.b_reapply.show() self.b_reapply.show()
self.b_forget.show() self.b_forget.show()
@@ -522,15 +593,24 @@ class MainWindow(QMainWindow):
ws = self.ws ws = self.ws
items = [(s, ws.store.get_blob(ws.store.get(s.key)["applied"]), ws.store.get(s.key).get("meta", {})) 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")] for s, st in getattr(self, "problems", []) if ws.store.get(s.key) and ws.store.get(s.key).get("applied")]
if items: texts = {k: ws.texts.records()[k]["applied"] for k, st in getattr(self, "text_problems", [])
self._run_change("Re-applying…", lambda job: ws.apply(items, force=True, progress=job.report), if k in ws.texts.records() and ws.texts.records()[k].get("applied") is not None}
"Re-applied %d change(s)." % len(items))
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): 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 return
for s, _ in getattr(self, "problems", []): for s, _ in getattr(self, "problems", []):
self.ws.store.forget(s.key) 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.save()
self.ws.store.gc() self.ws.store.gc()
self.banner.hide() self.banner.hide()
@@ -541,11 +621,13 @@ class MainWindow(QMainWindow):
if not self.ws: if not self.ws:
return return
mods = self.ws.modified_keys() mods = self.ws.modified_keys()
if not mods: texts = self.ws.texts.modified()
self.inform("Export mod", "You have not changed any sound yet.") if not mods and not texts:
self.inform("Export mod", "You have not changed any sound or text yet.")
return return
if keys is None: 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, 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) dlg = ExportModDialog(names, self)
if dlg.exec() != QDialog.DialogCode.Accepted: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
@@ -555,8 +637,10 @@ class MainWindow(QMainWindow):
self.settings.get("last_dir") or "", (title or "my_mod") + ".acmod"), "Unheard City mod (*.acmod)") self.settings.get("last_dir") or "", (title or "my_mod") + ".acmod"), "Unheard City mod (*.acmod)")
if not path: if not path:
return return
self._run_change("Exporting…", lambda job: export_mod(self.ws, path, keys, title, author, description), text_keys = [k for k in keys if k.startswith(("SUB:", "MENU:"))]
"Saved %s" % path) 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): def import_mod_dialog(self, path=None, confirm=True):
if not self.ws: if not self.ws:
@@ -571,18 +655,27 @@ class MainWindow(QMainWindow):
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
self.inform("Import mod", "Cannot read this package: %s" % e) self.inform("Import mod", "Cannot read this package: %s" % e)
return 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 return
ws = self.ws ws = self.ws
def work(job): def work(job):
prepare_import(ws, path, items, progress=job.report) 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] source = {"source": "mod:" + os.path.basename(path)}
try: changes = [(im.sound, im.wem, source) for im in items if im.wem]
ws.apply(changes, progress=job.report) if changes:
except W.ConflictError: try:
ws.apply(changes, force=True, progress=job.report) ws.apply(changes, progress=job.report)
return len(changes) 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)) self._run_change("Installing mod…", work, "Installed %s" % os.path.basename(path))

556
app/textpanel.py Normal file
View 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)

View File

@@ -1,7 +1,7 @@
# Unheard City - user guide # Unheard City - user guide
Replace voice lines, music and ambience in **Batman: Arkham City (PC, Steam)**. Everything you change can be put Replace voice lines, music, ambience and sound effects, and edit subtitles and menu text in **Batman: Arkham
back with one click. City (PC, Steam)**. Everything you change can be put back with one click.
## Install and start ## Install and start
1. **Windows:** unzip the download and double-click **Install.cmd**. It installs Unheard City for your user (no 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 - 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. 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 ## Notes, tags, favorites
Right-click → **Notes & tags…** (F2): free text and comma-separated tags, searchable and filterable. 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. 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 - **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). 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. - **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
![Settings](img/settings.png) ![Settings](img/settings.png)

View File

@@ -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 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): def test_record_simulated_input(win):
w, root, _ = win w, root, _ = win
w.select_name("CHR_RadioScanner_1") w.select_name("CHR_RadioScanner_1")