Version 3.2.0: fast lists with loading spinners, names from the game files, INT docs and screenshots, cleanup

This commit is contained in:
2026-10-10 14:58:17 +02:00
parent fbde809370
commit 1586abb8e9
40 changed files with 449 additions and 19279 deletions

View File

@@ -66,11 +66,27 @@
per language). `tools/e2e_features.py --lang all` 78/78; `--ingame`, `--banks`, `--texts`, e2e_editor PASS; per language). `tools/e2e_features.py --lang all` 78/78; `--ingame`, `--banks`, `--texts`, e2e_editor PASS;
127 tests; game original after. Speaker labels vs the dialogue speaker field: 2,766 agree, 19 differ (listed in 127 tests; game original after. Speaker labels vs the dialogue speaker field: 2,766 agree, 19 differ (listed in
categories.md, for the user to check by listening; the source lists are never edited). The user's guide files categories.md, for the user to check by listening; the source lists are never edited). The user's guide files
(`ArkhamCityModding/Audio/AbdAshes' ...`) hold no relation data: the hashlist equals ackit/data/hashlist.txt, (`ArkhamCityModding/Audio/AbdAshes' ...`) hold no relation data: their hashlist was ackit/data/hashlist.txt,
the scripts only unpack (wemscan = position names). `data/catalog/source/ita/Checkpoint.txt` was found deleted the scripts only unpack (wemscan = position names). `data/catalog/source/ita/Checkpoint.txt` was found deleted
(no code touches source/; cause unknown) and restored from git. Version 3.1.0 (minor): (no code touches source/; cause unknown) and restored from git. Version 3.1.0 (minor):
dist/UnheardCity-3.1.0-windows-amd64.zip smoke PASS. e2e_features now deletes its temporary stores (GBs of dist/UnheardCity-3.1.0-windows-amd64.zip smoke PASS. e2e_features now deletes its temporary stores (GBs of
full copies) when everything reverted. NEXT: UI fixes the user will name. full copies) when everything reverted.
- 2026-10-10 (user: order in the browser "messed up" after filter changes; redundant files): the proxy had
dynamic sorting off, so rows a filter showed again were appended at the end -> dynamic sort on (+ GUI test).
ackit/data/hashlist.txt removed: its 2,544 names the catalog uses are exactly the game's CookedBanks.txt bank
names (WAD entry ID = FNV-1 of the bank name), so names now come from the game (`catalog.bank_names`);
rebuilt catalogs identical.
- 2026-10-10 (user: filtering/sorting choppy, add spinners like the audio preview): QSortFilterProxyModel
replaced by `app/models.py: RowView` (Python filter + one sort over cached keys): sound list 1.3-1.5 s ->
10-20 ms per sort/filter, text lists 0.26-0.7 s -> ~10 ms. Spinner overlay (`spinner.BusyOverlay`) over the
sound list while it loads and over the subtitle/menu lists, which now load as background jobs (a selection
asked for meanwhile is applied when the rows arrive). GUI tests for order, selection and the overlays.
- 2026-10-10 version 3.2.0: docs use INT (the game's English) for examples and figures; facts measured on this
PC's Italian install stay labelled as such. User-guide screenshots in INT (`tools/screenshots.py --lang`,
default INT; the app itself still starts in the game's language). Every WEM of the 5 speech WADs rebuilt
from the rules: 54,079 / 54,080 identical (INT FE9963E8:74: avg-bytes/s field one lower, informational).
Removed unused code (helpers/constants nothing called, the key-posting debugger helper, a double refilter
after changes); old dist zips, logs/ and caches deleted.
- NEXT (needs a person): play a mission with a replaced footstep/foley sound and an edited subtitle (REPORT.md). - NEXT (needs a person): play a mission with a replaced footstep/foley sound and an edited subtitle (REPORT.md).
- OPEN: channel order for >2 channels unverified; speech and in-memory sounds *heard* in-game (need gameplay; - OPEN: channel order for >2 channels unverified; speech and in-memory sounds *heard* in-game (need gameplay;
posted keys do not drive the menu). posted keys do not drive the menu).

View File

@@ -37,8 +37,8 @@ ackit find joker tape # search by name, category or
ackit extract Speech_TAPE_JOKER_1_04 orig.wav # listen to the original (a line has one name in every language) ackit extract Speech_TAPE_JOKER_1_04 orig.wav # listen to the original (a line has one name in every language)
ackit replace Speech_TAPE_JOKER_1_04 my_line.wav --match-loudness ackit replace Speech_TAPE_JOKER_1_04 my_line.wav --match-loudness
ackit revert Speech_TAPE_JOKER_1_04 # or: ackit revert --all ackit revert Speech_TAPE_JOKER_1_04 # or: ackit revert --all
ackit text find --lang ITA protocollo # subtitles and menu texts (keys SUB:... / MENU:...) ackit text find --lang INT protocol # subtitles and menu texts (keys SUB:... / MENU:...)
ackit text set "MENU:ITA:FRONTEND.ITA:Menu:Start" "Premi Invio" ackit text set "MENU:INT:FRONTEND.int:Buttonpress:DoubleTap" "Tap twice"
``` ```
More: `ackit status`, `ackit list --character Joker`, `ackit export-mod my.acmod` / `ackit import-mod`, More: `ackit status`, `ackit list --character Joker`, `ackit export-mod my.acmod` / `ackit import-mod`,
`ackit verify` (offline self-check), `ackit verify --catalog` (catalog vs game files), `ackit game` (detected `ackit verify` (offline self-check), `ackit verify --catalog` (catalog vs game files), `ackit game` (detected

View File

@@ -18,7 +18,7 @@ editor Unheard City (mission 2).
- **Subtitles**: found in `RDialogueEvent` objects inside the packages (9,016 speech lines x 11 languages) and - **Subtitles**: found in `RDialogueEvent` objects inside the packages (9,016 speech lines x 11 languages) and
in 41 scripted `RSubtitle` objects (radio, encryption cards, Riddler; linked through `RSubtitleLookup`), in 41 scripted `RSubtitle` objects (radio, encryption cards, Riddler; linked through `RSubtitleLookup`),
parsed, cataloged (`SUB_<LANG>.csv`) and linked to their voice recordings through the soundbanks: 103,304 of parsed, cataloged (`SUB_<LANG>.csv`) and linked to their voice recordings through the soundbanks: 103,304 of
103,658 strings in 11 languages (ITA 9,346 of 9,377), each on the same line in every language; an event that 103,658 strings in 11 languages (INT 9,373 of 9,404), each on the same line in every language; an event that
plays two versions of a line links both. Edited in place in every copy (168,688 copies). plays two versions of a line links both. Edited in place in every copy (168,688 copies).
- **One recording per language, from the game data alone** (`docs/formats/banks.md`, *Lines*): every voice - **One recording per language, from the game data alone** (`docs/formats/banks.md`, *Lines*): every voice
recording is tied to its line by the game's Wwise Sound objects (same IDs in every language), and each recording is tied to its line by the game's Wwise Sound objects (same IDs in every language), and each
@@ -40,8 +40,9 @@ editor Unheard City (mission 2).
- **Safety**: every changed file (WAD, bank, package, localization file) is rebuilt from a verified original copy - **Safety**: every changed file (WAD, bank, package, localization file) is rebuilt from a verified original copy
in the store + the edits applied now; reverting the last edit restores it byte-for-byte; files changed by in the store + the edits applied now; reverting the last edit restores it byte-for-byte; files changed by
another tool or Steam are detected (whole-file SHA-1) and not overwritten without confirmation. another tool or Steam are detected (whole-file SHA-1) and not overwritten without confirmation.
- **Tests**: 127 automated tests (unit, synthetic game with packages/banks/dialogues/localization, a - **Tests**: 130 automated tests (unit, synthetic game with packages/banks/dialogues/localization, a
two-language game for the lines, real-game read-only, offscreen GUI) pass. `ackit verify` PASS (incl. two-language game for the lines, real-game read-only, offscreen GUI incl. list order, selection and loading
overlays) pass. `ackit verify` PASS (incl.
300/300 in-memory WEMs rebuilt byte-identically). 300/300 in-memory WEMs rebuilt byte-identically).
- **Real game** (2026-10-10), all reverting and checking byte-identity themselves: - **Real game** (2026-10-10), all reverting and checking byte-identity themselves:
`tools/e2e_features.py --lang all` 78/78 PASS in all 11 languages (every copy of an in-memory sound, each `tools/e2e_features.py --lang all` 78/78 PASS in all 11 languages (every copy of an in-memory sound, each
@@ -53,9 +54,9 @@ editor Unheard City (mission 2).
found in the game's memory, no crash); `tools/e2e_editor.py` PASS. Game files original at the end found in the game's memory, no crash); `tools/e2e_editor.py` PASS. Game files original at the end
(`ackit status`). The catalog builder refuses to run on a modified game, so the catalog always describes the (`ackit status`). The catalog builder refuses to run on a modified game, so the catalog always describes the
original files. original files.
- **Package**: `dist/UnheardCity-3.1.0-windows-amd64.zip` (86 MB, catalogs included). With `PATH` reduced to - **Package**: `dist/UnheardCity-3.2.0-windows-amd64.zip` (86 MB, catalogs included). With `PATH` reduced to
the Windows folders the packaged exe passes its smoke test (21,025 ITA sounds loaded, encode + inject, 9,377 the Windows folders the packaged exe passes its smoke test (in the game's language - Italian on the test PC:
subtitles and 7,020 menu texts loaded, an in-memory sound decoded from a package) and the GUI starts and 21,025 sounds loaded, encode + inject, 9,377 subtitles and 7,020 menu texts loaded, an in-memory sound decoded from a package) and the GUI starts and
quits cleanly. The installer is unchanged since mission 2 (install/upgrade/uninstall verified then). quits cleanly. The installer is unchanged since mission 2 (install/upgrade/uninstall verified then).
## Unresolved / limitations ## Unresolved / limitations

View File

@@ -8,4 +8,4 @@ Library layers (low -> high):
inject (WAD writing), originals (store), workspace (apply/revert), modpack (share) inject (WAD writing), originals (store), workspace (apply/revert), modpack (share)
tools cli (`ackit` command), verify/ (offline self-check, in-game test on Windows) tools cli (`ackit` command), verify/ (offline self-check, in-game test on Windows)
""" """
__version__ = "3.1.0" __version__ = "3.2.0"

View File

@@ -12,9 +12,6 @@ import subprocess
import numpy as np import numpy as np
import soundfile as sf import soundfile as sf
READABLE = (".wav", ".flac", ".ogg", ".oga", ".opus", ".mp3", ".aif", ".aiff", ".au", ".w64", ".caf")
FFMPEG_ONLY = (".m4a", ".aac", ".wma", ".mp4", ".webm", ".mkv", ".ac3")
class AudioError(Exception): class AudioError(Exception):
pass pass

View File

@@ -207,12 +207,6 @@ def _assign(positions, regions, issues, rel, what):
return out return out
def bank_languages(data, start, imports, exports, banks, issues=None, rel=""):
"""{bank offset in data: language} for localized banks (one bank per declared language, in that order).
Banks of other exports are language-neutral (not in the result)."""
return _assign(sorted(bk.at for bk in banks), localized_exports(data, start, imports, exports), issues, rel, "banks")
def bkpk_positions(data): def bkpk_positions(data):
"""Offsets of the BKPK tables (docs/formats/wad.md) embedded in a package.""" """Offsets of the BKPK tables (docs/formats/wad.md) embedded in a package."""
out = [] out = []

View File

@@ -14,9 +14,6 @@ class BitReader:
self.pos += 1 self.pos += 1
return v return v
def bits_left(self) -> int:
return len(self.data) * 8 - self.pos
class BitWriter: class BitWriter:
def __init__(self): def __init__(self):
@@ -31,13 +28,6 @@ class BitWriter:
self.buf[self.pos >> 3] |= 1 << (self.pos & 7) self.buf[self.pos >> 3] |= 1 << (self.pos & 7)
self.pos += 1 self.pos += 1
def put_bits_from(self, r: BitReader, n: int):
"""Copy n bits from a reader (fast path in 32-bit chunks)."""
while n > 0:
k = 32 if n >= 32 else n
self.put(k, r.get(k))
n -= k
def bytes(self) -> bytes: def bytes(self) -> bytes:
return bytes(self.buf) return bytes(self.buf)

View File

@@ -5,7 +5,8 @@ Sources, in order:
1. data/catalog/<ARCHIVE>.csv -- shipped catalog (instant) when the WAD fingerprint matches; 1. data/catalog/<ARCHIVE>.csv -- shipped catalog (instant) when the WAD fingerprint matches;
2. otherwise a header scan of the WAD (a few seconds, cached in the user cache), classified with 2. otherwise a header scan of the WAD (a few seconds, cached in the user cache), classified with
`classify` and labelled with data/catalog/characters.csv (speaker per sound name). `classify` and labelled with data/catalog/characters.csv (speaker per sound name).
Names follow the community hash list (ackit/data/hashlist.txt): an entry with one WEM is NAME Names come from the game: a WAD entry ID is the Wwise ID (FNV-1) of a soundbank listed in the game's
CookedPCConsole/CookedBanks.txt (`bank_names`); an entry with one WEM is NAME
(alias NAME_1), entries with n variations are NAME_1..NAME_n (alias NAME#i); stream-pack media are (alias NAME_1), entries with n variations are NAME_1..NAME_n (alias NAME#i); stream-pack media are
named by their ID or <Pack>_<ID>. named by their ID or <Pack>_<ID>.
""" """
@@ -21,10 +22,11 @@ from dataclasses import dataclass, asdict
from . import banks, classify, subtitles from . import banks, classify, subtitles
from . import lines as lines_mod from . import lines as lines_mod
from .game import wad_fingerprint from .game import wad_fingerprint
from .bnk import fnv1
from .lines import bank_langs from .lines import bank_langs
from .inject import pack_bnk_path from .inject import pack_bnk_path
from .pack import Pack from .pack import Pack
from .paths import catalog_dir, package_data, user_dir from .paths import catalog_dir, user_dir
from .wad import Wad from .wad import Wad
FIELDS = ["archive", "target", "name", "aliases", "group", "category", "bank", "language", "line", "character", FIELDS = ["archive", "target", "name", "aliases", "group", "category", "bank", "language", "line", "character",
@@ -78,21 +80,28 @@ class Sound:
# --- static data --------------------------------------------------------------------------- # --- static data ---------------------------------------------------------------------------
_names = None _names = {} # cooked folder -> {Wwise ID: bank name}
def hash_names(): def bank_names(cooked):
global _names """{Wwise ID: name} of the game's soundbanks, from its own CookedBanks.txt (ID = FNV-1 of the name). They
if _names is None: name the WAD entries (each entry holds the streamed media of one bank) and the stream packs."""
_names = {} key = os.path.normcase(os.path.abspath(cooked))
for ln in open(package_data("hashlist.txt"), encoding="latin-1"): if key not in _names:
p = ln.rstrip("\r\n").split("\t") out = {}
if len(p) >= 2 and p[1]: p = os.path.join(cooked, "CookedBanks.txt")
try: if os.path.exists(p):
_names[int(p[0], 16)] = p[1] for ln in open(p, encoding="latin-1"):
except ValueError: n = ln.strip()
pass if n:
return _names out.setdefault(fnv1(n), n)
_names[key] = out
return _names[key]
def cooked_of(wad_path):
"""CookedPCConsole folder of a WAD (WADs are in CookedPCConsole/<language or SFX>/)."""
return os.path.dirname(os.path.dirname(os.path.abspath(wad_path)))
def load_characters(path=None, lang=None): def load_characters(path=None, lang=None):
@@ -180,7 +189,7 @@ def _walk_entry(f, offset, size):
def scan_archive(archive, wad_path, characters=None, progress=None): def scan_archive(archive, wad_path, characters=None, progress=None):
"""Header scan of one WAD -> [Sound].""" """Header scan of one WAD -> [Sound]."""
names = hash_names() names = bank_names(cooked_of(wad_path))
characters = characters if characters is not None else load_characters(lang=archive) characters = characters if characters is not None else load_characters(lang=archive)
wad = Wad(wad_path) wad = Wad(wad_path)
out = [] out = []

View File

@@ -20,17 +20,6 @@ def load_library(path: str = DEFAULT_LIB) -> list:
return [b[offs[i]:offs[i + 1]] for i in range(n - 1)] return [b[offs[i]:offs[i + 1]] for i in range(n - 1)]
def save_library(path: str, books: list):
out = bytearray()
offs = []
for bk in books:
offs.append(len(out))
out += bk
offs.append(len(out)) # table start == end of last book
out += struct.pack("<%dI" % len(offs), *offs)
open(path, "wb").write(bytes(out))
def wwise_book_to_vorbis(blob: bytes, w: BitWriter): def wwise_book_to_vorbis(blob: bytes, w: BitWriter):
"""Expand a compact Wwise codebook into a standard Vorbis codebook (ww2ogg rebuild).""" """Expand a compact Wwise codebook into a standard Vorbis codebook (ww2ogg rebuild)."""
r = BitReader(blob) r = BitReader(blob)

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,6 @@ import sys
APPID = 200260 APPID = 200260
FOLDER_NAMES = ("Batman Arkham City GOTY", "Batman Arkham City") FOLDER_NAMES = ("Batman Arkham City GOTY", "Batman Arkham City")
LANGS = ("INT", "ITA", "FRA", "DEU", "ESN", "ESM", "POL", "RUS", "JPN", "KOR", "POR")
class GameError(Exception): class GameError(Exception):

View File

@@ -13,8 +13,6 @@ from collections import namedtuple
# key: "<LANG>:<File>:<Section>:<Key>" + ("#n" for the n-th repeat, n >= 2) # key: "<LANG>:<File>:<Section>:<Key>" + ("#n" for the n-th repeat, n >= 2)
Entry = namedtuple("Entry", "key lang file section name occurrence value quoted line") Entry = namedtuple("Entry", "key lang file section name occurrence value quoted line")
LANG_DIRS = ("INT", "ITA", "FRA", "DEU", "ESN", "ESM", "POL", "POR", "RUS", "JPN", "KOR")
class TextError(ValueError): class TextError(ValueError):
pass pass

View File

@@ -16,11 +16,6 @@ def resource_root():
return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def package_data(*parts):
"""Files shipped inside the package (codebooks, known setups, hash list)."""
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", *parts)
def catalog_dir(): def catalog_dir():
return os.path.join(resource_root(), "data", "catalog") return os.path.join(resource_root(), "data", "catalog")

View File

@@ -17,7 +17,7 @@ import os
import random import random
from .. import banks, classify, subtitles from .. import banks, classify, subtitles
from ..catalog import (Catalog, apply_lines, available_archives, bank_sounds, build_lines, character_of, hash_names, from ..catalog import (Catalog, apply_lines, available_archives, bank_names, bank_sounds, build_lines, character_of,
is_shipped, line_names, load_archive, load_banks, load_characters, read_csv, scan_archive) is_shipped, line_names, load_archive, load_banks, load_characters, read_csv, scan_archive)
from ..inject import read_target from ..inject import read_target
from ..paths import catalog_dir from ..paths import catalog_dir
@@ -44,16 +44,16 @@ class Report:
return not self.issues return not self.issues
def _expected_category(s): def _expected_category(s, names):
if s.archive == banks.ARCHIVE: if s.archive == banks.ARCHIVE:
return classify.classify(s.name, bank=s.bank) return classify.classify(s.name, bank=s.bank)
pack = None pack = None
if s.is_pack_media: if s.is_pack_media:
pack = hash_names().get(int(s.target.split("/")[0], 16), s.target.split("/")[0]) pack = names.get(int(s.target.split("/")[0], 16), s.target.split("/")[0])
return classify.classify(s.name, pack=pack) return classify.classify(s.name, pack=pack)
def _check_rows(rep, archive, rows, characters): def _check_rows(rep, archive, rows, characters, names):
seen_t, seen_n = set(), {} seen_t, seen_n = set(), {}
for s in rows: for s in rows:
rep.checked[archive] += 1 rep.checked[archive] += 1
@@ -66,7 +66,7 @@ def _check_rows(rep, archive, rows, characters):
if n in seen_n or (s.language and ("", n[1]) in seen_n): if n in seen_n or (s.language and ("", n[1]) in seen_n):
rep.add(archive, "duplicate", s.key, "name %s (also %s)" % (s.name, seen_n.get(n) or seen_n[("", n[1])])) rep.add(archive, "duplicate", s.key, "name %s (also %s)" % (s.name, seen_n.get(n) or seen_n[("", n[1])]))
seen_n[n] = s.key seen_n[n] = s.key
g, c = _expected_category(s) g, c = _expected_category(s, names)
if (s.group, s.category) != (g, c): if (s.group, s.category) != (g, c):
rep.add(archive, "category", s.key, "%s/%s, rules give %s/%s" % (s.group, s.category, g, c)) rep.add(archive, "category", s.key, "%s/%s, rules give %s/%s" % (s.group, s.category, g, c))
want = character_of(s, characters) want = character_of(s, characters)
@@ -82,7 +82,7 @@ def check_wad(rep, game, archive, rows, characters, lines=None):
folder = os.path.basename(os.path.dirname(path)).upper() folder = os.path.basename(os.path.dirname(path)).upper()
if FOLDER_LANG.get(folder, archive) != archive: if FOLDER_LANG.get(folder, archive) != archive:
rep.add(archive, "language", archive, "WAD in folder %s" % folder) rep.add(archive, "language", archive, "WAD in folder %s" % folder)
_check_rows(rep, archive, rows, characters) _check_rows(rep, archive, rows, characters, bank_names(game.cooked))
fresh_list = scan_archive(archive, path, characters=characters) fresh_list = scan_archive(archive, path, characters=characters)
if lines is not None and archive in lines.langs: if lines is not None and archive in lines.langs:
apply_lines(fresh_list, lines, lines.names) apply_lines(fresh_list, lines, lines.names)
@@ -117,7 +117,7 @@ def check_wad(rep, game, archive, rows, characters, lines=None):
def check_banks(rep, game, rows, locations, characters, quick=False, sample=40, progress=None): def check_banks(rep, game, rows, locations, characters, quick=False, sample=40, progress=None):
"""BANK rows, copies, prefetch heads and subtitles vs a scan of the packages. The full check (not quick) """BANK rows, copies, prefetch heads and subtitles vs a scan of the packages. The full check (not quick)
also derives the game's lines from that scan and returns them (else None).""" also derives the game's lines from that scan and returns them (else None)."""
_check_rows(rep, banks.ARCHIVE, rows, characters) _check_rows(rep, banks.ARCHIVE, rows, characters, bank_names(game.cooked))
files = banks.bank_files(game.cooked) files = banks.bank_files(game.cooked)
if quick: if quick:
pk = [f for f in files if f.lower().endswith(".upk")] pk = [f for f in files if f.lower().endswith(".upk")]

View File

@@ -98,39 +98,6 @@ def search_memory(pid, needles, limit=1 << 32):
return found return found
u32 = ctypes.WinDLL("user32", use_last_error=True)
WM_KEYDOWN, WM_KEYUP = 0x0100, 0x0101
VK = dict(enter=0x0D, space=0x20, esc=0x1B, up=0x26, down=0x28, left=0x25, right=0x27, e=0x45)
def game_windows(pid):
"""Top-level windows of the game process."""
found = []
cb_t = ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPARAM)
def cb(hwnd, _):
p = wt.DWORD()
u32.GetWindowThreadProcessId(hwnd, ctypes.byref(p))
if p.value == pid and u32.IsWindowVisible(hwnd):
found.append(hwnd)
return True
u32.EnumWindows(cb_t(cb), 0)
return found
def post_key(pid, key, hold=0.08):
"""Post a key press to the game's window only (never global SendInput)."""
vk = VK[key] if isinstance(key, str) else key
sc = u32.MapVirtualKeyW(vk, 0)
n = 0
for hwnd in game_windows(pid):
u32.PostMessageW(hwnd, WM_KEYDOWN, vk, 1 | (sc << 16))
time.sleep(hold)
u32.PostMessageW(hwnd, WM_KEYUP, vk, 1 | (sc << 16) | (3 << 30))
n += 1
return n
def running_game_pids(): def running_game_pids():
out = subprocess.run(["tasklist", "/FI", "IMAGENAME eq BatmanAC.exe", "/FO", "CSV", "/NH"], out = subprocess.run(["tasklist", "/FI", "IMAGENAME eq BatmanAC.exe", "/FO", "CSV", "/NH"],
capture_output=True, text=True).stdout capture_output=True, text=True).stdout

View File

@@ -58,12 +58,6 @@ class Wad:
f.seek(e.offset) f.seek(e.offset)
return f.read(e.size) return f.read(e.size)
def iter_entries(self):
with open(self.path, "rb") as f:
for e in self.entries:
f.seek(e.offset)
yield e, f.read(e.size)
def check(self): def check(self):
"""Validate structural invariants; returns list of problems (empty = OK).""" """Validate structural invariants; returns list of problems (empty = OK)."""
probs = [] probs = []

View File

@@ -66,10 +66,6 @@ class Wem:
def data(self) -> bytes: def data(self) -> bytes:
return self.get(b"data") return self.get(b"data")
def seek_table(self):
n = self.vorb["seek_table_size"] // 4
return [struct.unpack_from("<HH", self.data, 4 * i) for i in range(n)]
def setup_packet(self) -> bytes: def setup_packet(self) -> bytes:
d = self.data d = self.data
off = self.vorb["seek_table_size"] off = self.vorb["seek_table_size"]

View File

@@ -133,19 +133,12 @@ class Workspace:
def original_audio(self, s): def original_audio(self, s):
return audio.decode_wem(self.original_bytes(s)) return audio.decode_wem(self.original_bytes(s))
def current_audio(self, s):
return audio.decode_wem(self.current_bytes(s))
# encoding ---------------------------------------------------------------------------------- # encoding ----------------------------------------------------------------------------------
def prepare(self, s, x, src_rate, quality="auto", progress=None): def prepare(self, s, x, src_rate, quality="auto", progress=None):
"""Encode PCM for sound `s` (its rate/channels, fitted to its size limit) -> (wem, info).""" """Encode PCM for sound `s` (its rate/channels, fitted to its size limit) -> (wem, info)."""
return encode_fit(x, src_rate, s.rate or src_rate, s.channels or x.shape[1], self.limit(s), return encode_fit(x, src_rate, s.rate or src_rate, s.channels or x.shape[1], self.limit(s),
quality, progress) quality, progress)
def prepare_file(self, s, path, quality="auto", progress=None):
x, sr = audio.load(path)
return self.prepare(s, x, sr, quality, progress)
# writing ----------------------------------------------------------------------------------- # writing -----------------------------------------------------------------------------------
def _backup(self, wad, pack_hashes): def _backup(self, wad, pack_hashes):
if not self.full_backup: if not self.full_backup:

View File

@@ -19,7 +19,7 @@ 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 BusyOverlay, Spinner
from .textpanel import TextPanel from .textpanel import TextPanel
from .userdata import UserData from .userdata import UserData
from .workers import Runner from .workers import Runner
@@ -181,6 +181,7 @@ class MainWindow(QMainWindow):
self.proxy.setSourceModel(self.model) self.proxy.setSourceModel(self.model)
self.table = QTableView() self.table = QTableView()
self.table.setModel(self.proxy) self.table.setModel(self.proxy)
self.table_busy = BusyOverlay(self.table) # while the sound list loads (start, language change)
self.table.setSortingEnabled(True) self.table.setSortingEnabled(True)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
@@ -294,6 +295,7 @@ class MainWindow(QMainWindow):
self.loading = True self.loading = True
self.statusBar().showMessage("Loading sound list…") self.statusBar().showMessage("Loading sound list…")
self.spinner.show() self.spinner.show()
self.table_busy.start("Loading sounds…")
store = self.settings.get("store_root") store = self.settings.get("store_root")
full = self.settings.get("full_backup", True) full = self.settings.get("full_backup", True)
@@ -305,6 +307,7 @@ class MainWindow(QMainWindow):
def done(ws): def done(ws):
self.loading = False self.loading = False
self.spinner.hide() self.spinner.hide()
self.table_busy.stop()
self.ws = ws self.ws = ws
self.settings["game_dir"] = game.root self.settings["game_dir"] = game.root
self.save_settings() self.save_settings()
@@ -327,6 +330,7 @@ class MainWindow(QMainWindow):
def failed(e, tb): def failed(e, tb):
self.loading = False self.loading = False
self.spinner.hide() self.spinner.hide()
self.table_busy.stop()
self.show_no_game("Could not load the game audio: %s" % e) self.show_no_game("Could not load the game audio: %s" % e)
self.runner.start(work, done, failed, lambda m: self.statusBar().showMessage(m)) self.runner.start(work, done, failed, lambda m: self.statusBar().showMessage(m))
@@ -512,8 +516,7 @@ class MainWindow(QMainWindow):
# --- 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()) # the view refilters/re-sorts itself
self.proxy.refilter()
self._update_count() self._update_count()
def revert_sounds(self, sounds): def revert_sounds(self, sounds):

View File

@@ -1,5 +1,5 @@
"""Table model for the sound browser + a combined filter (search text and facet filters).""" """Table model for the sound browser + a combined filter (search text and facet filters)."""
from PySide6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt from PySide6.QtCore import QAbstractProxyModel, QAbstractTableModel, QModelIndex, Qt
from PySide6.QtGui import QFont, QPalette from PySide6.QtGui import QFont, QPalette
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
@@ -40,6 +40,44 @@ class SoundModel(QAbstractTableModel):
if self.sounds: if self.sounds:
self.dataChanged.emit(self.index(0, 0), self.index(len(self.sounds) - 1, len(COLS) - 1)) self.dataChanged.emit(self.index(0, 0), self.index(len(self.sounds) - 1, len(COLS) - 1))
def sort_key(self, row, c):
"""Value a column sorts by (plain Python values: the view sorts all rows in one Python sort)."""
s, ud = self.sounds[row], self.userdata
if c == C_FAV:
return 0 if ud and ud.favorite(s.key) else 1
if c == C_LEN:
return s.seconds
if c == C_SIZE:
return 1e12 if s.is_pack_media else s.size
if c == C_FMT:
return (s.rate, s.channels)
if c == C_STATUS:
return 0 if s.key in self.modified else 1
return self.display(s, c).lower()
def display(self, s, c):
ud = self.userdata
if c == C_NAME:
return s.name
if c == C_CAT:
return s.category
if c == C_CHAR:
return s.character or (("~ " + s.characters_inferred) if s.characters_inferred else "")
if c == C_LEN:
return "%.2f s" % s.seconds
if c == C_ARCH:
return s.archive
if c == C_FMT:
return "%d Hz %s" % (s.rate, {1: "mono", 2: "stereo"}.get(s.channels, "%d ch" % s.channels))
if c == C_SIZE:
return "any" if s.is_pack_media else "%.1f KB" % (s.size / 1024.0)
if c == C_STATUS:
return "Modified" if s.key in self.modified else ""
if c == C_NOTES and ud:
t = ud.tags(s.key)
return " ".join("#" + x for x in t) + (" " if t else "") + ud.note(s.key)
return ""
def rowCount(self, parent=QModelIndex()): def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.sounds) return 0 if parent.isValid() else len(self.sounds)
@@ -66,41 +104,9 @@ class SoundModel(QAbstractTableModel):
if role == SOUND_ROLE: if role == SOUND_ROLE:
return s return s
if role == Qt.ItemDataRole.DisplayRole: if role == Qt.ItemDataRole.DisplayRole:
if c == C_FAV: return self.display(s, c)
return ""
if c == C_NAME:
return s.name
if c == C_CAT:
return s.category
if c == C_CHAR:
return s.character or (("~ " + s.characters_inferred) if s.characters_inferred else "")
if c == C_LEN:
return "%.2f s" % s.seconds
if c == C_ARCH:
return s.archive
if c == C_FMT:
return "%d Hz %s" % (s.rate, {1: "mono", 2: "stereo"}.get(s.channels, "%d ch" % s.channels))
if c == C_SIZE:
return "any" if s.is_pack_media else "%.1f KB" % (s.size / 1024.0)
if c == C_STATUS:
return "Modified" if s.key in self.modified else ""
if c == C_NOTES:
if not ud:
return ""
t = ud.tags(s.key)
return " ".join("#" + x for x in t) + (" " if t else "") + ud.note(s.key)
if role == SORT_ROLE: if role == SORT_ROLE:
if c == C_FAV: return self.sort_key(index.row(), c)
return 0 if ud and ud.favorite(s.key) else 1
if c == C_LEN:
return s.seconds
if c == C_SIZE:
return 1e12 if s.is_pack_media else s.size
if c == C_FMT:
return (s.rate, s.channels)
if c == C_STATUS:
return 0 if s.key in self.modified else 1
return (self.data(index, Qt.ItemDataRole.DisplayRole) or "").lower()
if role == Qt.ItemDataRole.DecorationRole and c == C_FAV: if role == Qt.ItemDataRole.DecorationRole and c == C_FAV:
fav = bool(ud and ud.favorite(s.key)) fav = bool(ud and ud.favorite(s.key))
return icons.icon("star" if fav else "star-off", icons.ACCENT if fav else "#4a4a4a") return icons.icon("star" if fav else "star-off", icons.ACCENT if fav else "#4a4a4a")
@@ -122,9 +128,122 @@ class SoundModel(QAbstractTableModel):
return None return None
class SoundFilter(QSortFilterProxyModel): class RowView(QAbstractProxyModel):
"""The rows a table shows: the source rows a subclass accepts, in the chosen order. Computed in Python in
one pass (filter, then one sort over cached keys): ~10 ms for 21,000 sounds, where QSortFilterProxyModel
needed up to ~1.5 s per sort or widened filter (each comparison called back into Python). Selection and the
current row survive every change (persistent indexes are moved, as Qt's own proxies do).
Subclasses: source_len(), accepts(row), sort_key(row, column)."""
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.sort_column, self.sort_order = -1, Qt.SortOrder.AscendingOrder
self.rows = [] # proxy row -> source row
self.pos = {} # source row -> proxy row
self._keys = {} # column -> [sort key per source row] (until the source changes)
def source_len(self):
return self.sourceModel().rowCount()
def accepts(self, row):
return True
def sort_key(self, row, column):
v = self.sourceModel().index(row, column).data()
return v.lower() if isinstance(v, str) else v
# --- source model --------------------------------------------------------------------------------
def setSourceModel(self, model):
"""One source model for the view's lifetime."""
self.beginResetModel()
super().setSourceModel(model)
model.modelAboutToBeReset.connect(self.beginResetModel)
model.modelReset.connect(self._source_reset)
model.dataChanged.connect(self._source_changed)
self._recompute_all()
self.endResetModel()
def _recompute_all(self):
self._keys = {}
self.rows = self._compute()
self.pos = {r: i for i, r in enumerate(self.rows)}
def _source_reset(self):
self._recompute_all()
self.endResetModel()
def _source_changed(self, top_left, bottom_right, roles=()):
"""Values changed (status, notes, favorites, texts): sort keys and filters may depend on them."""
self._keys = {}
self.refilter()
if self.rows:
self.dataChanged.emit(self.index(0, 0), self.index(len(self.rows) - 1, self.columnCount() - 1))
# --- filter and order ----------------------------------------------------------------------------
def refilter(self):
"""Recompute the rows; the view keeps its selection and current row (layout change)."""
rows = self._compute()
if rows == self.rows:
return
self.layoutAboutToBeChanged.emit()
old = self.persistentIndexList()
src = [(self.rows[i.row()], i.column()) if i.isValid() and i.row() < len(self.rows) else None for i in old]
self.rows = rows
self.pos = {r: i for i, r in enumerate(rows)}
new = [self.index(self.pos[s[0]], s[1]) if s and s[0] in self.pos else QModelIndex() for s in src]
self.changePersistentIndexList(old, new)
self.layoutChanged.emit()
def sort(self, column, order=Qt.SortOrder.AscendingOrder):
self.sort_column, self.sort_order = column, order
self.refilter()
def _compute(self):
if self.sourceModel() is None:
return []
n = self.source_len()
rows = [r for r in range(n) if self.accepts(r)]
c = self.sort_column
if c >= 0:
if c not in self._keys:
self._keys[c] = [self.sort_key(r, c) for r in range(n)]
rows.sort(key=self._keys[c].__getitem__, reverse=self.sort_order == Qt.SortOrder.DescendingOrder)
return rows
# --- QAbstractProxyModel -------------------------------------------------------------------------
def index(self, row, column, parent=QModelIndex()):
if parent.isValid() or not (0 <= row < len(self.rows)) or not (0 <= column < self.columnCount()):
return QModelIndex()
return self.createIndex(row, column)
def parent(self, index=QModelIndex()):
return QModelIndex()
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.rows)
def columnCount(self, parent=QModelIndex()):
m = self.sourceModel()
return 0 if parent.isValid() or m is None else m.columnCount()
def mapToSource(self, proxy_index):
if not proxy_index.isValid() or proxy_index.row() >= len(self.rows):
return QModelIndex()
return self.sourceModel().index(self.rows[proxy_index.row()], proxy_index.column())
def mapFromSource(self, source_index):
if not source_index.isValid() or source_index.row() not in self.pos:
return QModelIndex()
return self.index(self.pos[source_index.row()], source_index.column())
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
return self.sourceModel().headerData(section, orientation, role)
class SoundFilter(RowView):
"""The sound browser's rows: search words and facet filters, sorted by any column."""
def __init__(self, parent=None):
self.words = [] self.words = []
self.group = None self.group = None
self.category = None self.category = None
@@ -134,15 +253,7 @@ class SoundFilter(QSortFilterProxyModel):
self.modified_only = False self.modified_only = False
self.favorites_only = False self.favorites_only = False
self.tag = None self.tag = None
self.setSortRole(SORT_ROLE) super().__init__(parent)
self.setDynamicSortFilter(False)
def refilter(self):
if hasattr(self, "beginFilterChange"): # Qt >= 6.10
self.beginFilterChange()
self.endFilterChange(QSortFilterProxyModel.Direction.Rows)
else:
self.invalidateRowsFilter()
def set(self, **kw): def set(self, **kw):
for k, v in kw.items(): for k, v in kw.items():
@@ -153,7 +264,13 @@ class SoundFilter(QSortFilterProxyModel):
self.words = [w for w in text.lower().replace("_", " ").split() if w] self.words = [w for w in text.lower().replace("_", " ").split() if w]
self.refilter() self.refilter()
def filterAcceptsRow(self, row, parent): def source_len(self):
return len(self.sourceModel().sounds)
def sort_key(self, row, column):
return self.sourceModel().sort_key(row, column)
def accepts(self, row):
m = self.sourceModel() m = self.sourceModel()
s = m.sounds[row] s = m.sounds[row]
if self.group and s.group != self.group: if self.group and s.group != self.group:
@@ -181,4 +298,4 @@ class SoundFilter(QSortFilterProxyModel):
return True return True
def sound_at(self, proxy_index): def sound_at(self, proxy_index):
return self.sourceModel().sounds[self.mapToSource(proxy_index).row()] return self.sourceModel().sounds[self.rows[proxy_index.row()]]

View File

@@ -1,6 +1,6 @@
"""Small activity spinner: a rotating accent-coloured arc. Runs only while visible.""" """Small activity spinner: a rotating accent-coloured arc. Runs only while visible."""
from PySide6.QtCore import QRectF, QTimer, Qt from PySide6.QtCore import QEvent, QRectF, QTimer, Qt
from PySide6.QtGui import QColor, QPainter, QPen from PySide6.QtGui import QColor, QPainter, QPalette, QPen
from PySide6.QtWidgets import QWidget from PySide6.QtWidgets import QWidget
from . import icons from . import icons
@@ -51,3 +51,68 @@ class Spinner(QWidget):
def paintEvent(self, _): def paintEvent(self, _):
p = QPainter(self) p = QPainter(self)
paint_arc(p, QRectF(self.rect()), self.angle) paint_arc(p, QRectF(self.rect()), self.angle)
class BusyOverlay(QWidget):
"""A list being (re)loaded: a light veil over the widget with the spinner and a message in its centre, the
same arc as the waveform's loading state. Covers the target and follows its size; blocks clicks while on."""
def __init__(self, target):
super().__init__(target)
self.target = target
self.message = ""
self.angle = 0
self.timer = QTimer(self)
self.timer.setInterval(STEP_MS)
self.timer.timeout.connect(self._step)
target.installEventFilter(self)
self.hide()
def start(self, message):
self.message = message
self.setGeometry(self.target.rect())
self.raise_()
self.show()
self.timer.start()
self.update()
def stop(self):
self.timer.stop()
self.hide()
@property
def busy(self):
return not self.isHidden() # on, whether or not the window is on screen yet
def _step(self):
self.angle = (self.angle + STEP_DEG) % 360
if self.isVisible():
self.update()
def eventFilter(self, obj, e):
if obj is self.target and e.type() == QEvent.Type.Resize:
self.setGeometry(self.target.rect())
return False
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
pal = self.palette()
veil = QColor(pal.color(QPalette.ColorRole.Base))
veil.setAlpha(210)
p.fillRect(self.rect(), veil)
fm = p.fontMetrics()
d, gap, pad = 20, 12, 16
tw = fm.horizontalAdvance(self.message)
w, h = pad + d + gap + tw + pad, max(d, fm.height()) + 2 * pad - 8
box = QRectF((self.width() - w) / 2, (self.height() - h) / 2, w, h)
border = QColor(pal.color(QPalette.ColorRole.Text))
border.setAlpha(40)
p.setPen(QPen(border, 1))
p.setBrush(pal.color(QPalette.ColorRole.Window))
p.drawRoundedRect(box, 8, 8)
y = box.center().y()
paint_arc(p, QRectF(box.left() + pad, y - d / 2, d, d), self.angle)
p.setPen(pal.color(QPalette.ColorRole.Text))
p.drawText(QRectF(box.left() + pad + d + gap, y - fm.height() / 2, tw + 2, fm.height()),
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, self.message)

View File

@@ -4,7 +4,7 @@ One TextPanel per section: filters + table on top, the editor of the selected te
through Workspace.texts (ackit/texts.py): subtitles in place into every copy (limited to the original's 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. 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.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer, Signal
from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QSplitter, QTableView, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QSplitter, QTableView,
QVBoxLayout, QWidget) QVBoxLayout, QWidget)
@@ -12,6 +12,8 @@ from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog,
from ackit import subtitles from ackit import subtitles
from ackit.texts import CHANGED, FOREIGN, LOST, MODIFIED, ORIGINAL, TextConflictError from ackit.texts import CHANGED, FOREIGN, LOST, MODIFIED, ORIGINAL, TextConflictError
from . import icons from . import icons
from .models import RowView
from .spinner import BusyOverlay
ANY = "Any" ANY = "Any"
STATE_TEXT = {ORIGINAL: "", MODIFIED: "modified", LOST: "lost", CHANGED: "changed outside", FOREIGN: "not original"} STATE_TEXT = {ORIGINAL: "", MODIFIED: "modified", LOST: "lost", CHANGED: "changed outside", FOREIGN: "not original"}
@@ -74,20 +76,24 @@ class TextModel(QAbstractTableModel):
return None return None
class TextFilter(QSortFilterProxyModel): class TextFilter(RowView):
"""The text table's rows: search words, column filters, modified only; sorted case-insensitively."""
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent)
self.words, self.col_filters, self.only_modified = [], {}, False self.words, self.col_filters, self.only_modified = [], {}, False
self.setSortCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) super().__init__(parent)
def refilter(self): def source_len(self):
if hasattr(self, "beginFilterChange"): return len(self.sourceModel().rows)
self.beginFilterChange()
self.endFilterChange(QSortFilterProxyModel.Direction.Rows)
else:
self.invalidateFilter()
def filterAcceptsRow(self, row, parent): def sort_key(self, row, column):
m = self.sourceModel()
r = m.rows[row]
n = len(r.cols)
v = r.cols[column] if column < n else m.text_of(r) if column == n else ("modified" if r.key in m.modified else "")
return v.lower() if isinstance(v, str) else v
def accepts(self, row):
m = self.sourceModel() m = self.sourceModel()
r = m.rows[row] r = m.rows[row]
if self.only_modified and r.key not in m.modified: if self.only_modified and r.key not in m.modified:
@@ -101,7 +107,7 @@ class TextFilter(QSortFilterProxyModel):
return True return True
def row_at(self, index): def row_at(self, index):
return self.sourceModel().rows[self.mapToSource(index).row()] return self.sourceModel().rows[self.rows[index.row()]]
class FindReplaceDialog(QDialog): class FindReplaceDialog(QDialog):
@@ -288,6 +294,9 @@ class TextPanel(QWidget):
self.proxy.setSourceModel(self.model) self.proxy.setSourceModel(self.model)
self.table = QTableView() self.table = QTableView()
self.table.setModel(self.proxy) self.table.setModel(self.proxy)
self.busy = BusyOverlay(self.table) # while the texts of a language load (background job)
self._load_gen = 0
self._pending_key = None
self.table.setSortingEnabled(True) self.table.setSortingEnabled(True)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
@@ -367,9 +376,28 @@ class TextPanel(QWidget):
self.lang = lang self.lang = lang
self.lbl_lang.setText("Language: %s" % lang if lang else "No %s in this language" % ( self.lbl_lang.setText("Language: %s" % lang if lang else "No %s in this language" % (
"subtitles" if self.kind == "subs" else "menu text")) "subtitles" if self.kind == "subs" else "menu text"))
self._load_gen += 1
gen = self._load_gen
if not lang: if not lang:
self.busy.stop()
self.model.reset([], {}) self.model.reset([], {})
return return
self.busy.start("Loading subtitles…" if self.kind == "subs" else "Loading menu text…")
def done(rows):
if gen == self._load_gen: # a newer load replaces this one
self.busy.stop()
self._show_rows(rows)
def failed(e, tb):
if gen == self._load_gen:
self.busy.stop()
self.message.emit("Could not load the texts: %s" % e)
self.win.runner.start(lambda job: self._rows(ws, lang), done, failed)
def _rows(self, ws, lang):
"""The table rows of a language (runs as a background job)."""
tx = ws.texts tx = ws.texts
rows = [] rows = []
if self.kind == "subs": if self.kind == "subs":
@@ -384,6 +412,9 @@ class TextPanel(QWidget):
for e in tx.menu(lang): 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 "")], 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._menu_original(e)))
return rows
def _show_rows(self, rows):
self.model.reset(rows, self._modified()) self.model.reset(rows, self._modified())
for i, cb in enumerate(self.filters.values()): for i, cb in enumerate(self.filters.values()):
col = (2 + i) if self.kind == "subs" else 0 col = (2 + i) if self.kind == "subs" else 0
@@ -394,6 +425,9 @@ class TextPanel(QWidget):
cb.setCurrentText(cur if cur else ANY) cb.setCurrentText(cur if cur else ANY)
cb.blockSignals(False) cb.blockSignals(False)
self._filter() self._filter()
if self._pending_key: # asked for while the rows were loading
key, self._pending_key = self._pending_key, None
self.select_key(key)
def _menu_original(self, e): def _menu_original(self, e):
r = self.ws().texts.records().get("MENU:" + e.key) r = self.ws().texts.records().get("MENU:" + e.key)
@@ -405,8 +439,7 @@ class TextPanel(QWidget):
k.startswith("SUB:" if self.kind == "subs" else "MENU:")} k.startswith("SUB:" if self.kind == "subs" else "MENU:")}
def refresh(self): def refresh(self):
self.model.set_modified(self._modified()) self.model.set_modified(self._modified()) # the view refilters/re-sorts itself
self.proxy.refilter()
self._update_count() self._update_count()
if self.current: if self.current:
self._show(self.current) self._show(self.current)
@@ -426,6 +459,9 @@ class TextPanel(QWidget):
return [self.proxy.row_at(self.proxy.index(i, 0)) for i in range(self.proxy.rowCount())] return [self.proxy.row_at(self.proxy.index(i, 0)) for i in range(self.proxy.rowCount())]
def select_key(self, key): def select_key(self, key):
if self.busy.busy: # rows still loading: select when they arrive
self._pending_key = key
return True
for i in range(self.proxy.rowCount()): for i in range(self.proxy.rowCount()):
if self.proxy.row_at(self.proxy.index(i, 0)).key == key: if self.proxy.row_at(self.proxy.index(i, 0)).key == key:
self.table.selectRow(i) self.table.selectRow(i)

View File

@@ -2,7 +2,7 @@
| file | content | | file | content |
|------|---------| |------|---------|
| `<ARCHIVE>.csv` | every sound of one audio archive: `ITA`, `INT`, `FRA`, `DEU`, `ESN` (speech WADs, one per language), `SFX` (music, ambience and effects streamed from the SFX WAD) and `BANK` (in-memory sounds stored inside soundbanks: footsteps, foley, combat, weapons, UI, efforts; `docs/formats/banks.md`) | | `<ARCHIVE>.csv` | every sound of one audio archive: `INT`, `FRA`, `ITA`, `DEU`, `ESN` (speech WADs, one per language), `SFX` (music, ambience and effects streamed from the SFX WAD) and `BANK` (in-memory sounds stored inside soundbanks: footsteps, foley, combat, weapons, UI, efforts; `docs/formats/banks.md`) |
| `BANK_locations.csv.gz` | every stored copy of the `BANK` sounds, and the prefetch heads of speech lines (below) | | `BANK_locations.csv.gz` | every stored copy of the `BANK` sounds, and the prefetch heads of speech lines (below) |
| `SUB_<LANG>.csv` | subtitles of the speech lines, one file per language (11 languages; `docs/formats/subtitles.md`) | | `SUB_<LANG>.csv` | subtitles of the speech lines, one file per language (11 languages; `docs/formats/subtitles.md`) |
| `SUB_locations.csv.gz` | every stored copy of each subtitle string (dialogue objects in the packages) | | `SUB_locations.csv.gz` | every stored copy of each subtitle string (dialogue objects in the packages) |
@@ -15,14 +15,14 @@ All CSVs are UTF-8 with a header row. They contain only names and metadata (no g
## `<ARCHIVE>.csv` columns ## `<ARCHIVE>.csv` columns
| column | meaning | | column | meaning |
|--------|---------| |--------|---------|
| `archive` | `ITA`, `INT`, ..., `SFX` or `BANK` | | `archive` | `INT`, `FRA`, ..., `SFX` or `BANK` |
| `target` | location: `HASH` (WAD entry with one WEM), `HASH:i` (i-th variation, 0-based), `PACK/MEDIA` (music stream pack), `MEDIA` (BANK: Wwise media ID) | | `target` | location: `HASH` (WAD entry with one WEM), `HASH:i` (i-th variation, 0-based), `PACK/MEDIA` (music stream pack), `MEDIA` (BANK: Wwise media ID) |
| `name` | display name. Speech: the line's name, the same in every language (`docs/decisions.md` 5): its speech event (e.g. `Speech_TAPE_JOKER_1_04`, `NAME#2` = second version an event plays) or, for lines no named event plays, the INT recording's position name. A recording stored twice keeps its position name in the second place. SFX: `NAME`, `NAME_1..n`. BANK: the Wwise event that plays it (`Play_..._i`) or `<Bank>_<n>` | | `name` | display name. Speech: the line's name, the same in every language (`docs/decisions.md` 5): its speech event (e.g. `Speech_TAPE_JOKER_1_04`, `NAME#2` = second version an event plays) or, for lines no named event plays, the INT recording's position name. A recording stored twice keeps its position name in the second place. SFX: `NAME`, `NAME_1..n`. BANK: the Wwise event that plays it (`Play_..._i`) or `<Bank>_<n>` |
| `aliases` | other accepted names, `\|`-separated: `NAME#i` = i-th WEM of the entry in this language's WAD (positions differ between languages), the entry name for single entries, `<Bank>/<MEDIA>` for BANK | | `aliases` | other accepted names, `\|`-separated: `NAME#i` = i-th WEM of the entry in this language's WAD (positions differ between languages), the entry name for single entries, `<Bank>/<MEDIA>` for BANK |
| `group` | Speech, Music, Ambience, SFX, Unknown (`docs/categories.md`) | | `group` | Speech, Music, Ambience, SFX, Unknown (`docs/categories.md`) |
| `category` | finer category (`docs/categories.md`) | | `category` | finer category (`docs/categories.md`) |
| `bank` | BANK: the soundbank holding the sound (first by name if several do); empty for WAD sounds | | `bank` | BANK: the soundbank holding the sound (first by name if several do); empty for WAD sounds |
| `language` | BANK: language(s) of a localized speech line, `;`-separated (`ITA`, or `ESN;FRA;INT;ITA` for one recording shared by those); empty = the same sound in every language. The editor shows the sounds of the selected language; names are unique per language | | `language` | BANK: language(s) of a localized speech line, `;`-separated (`INT`, or `ESN;FRA;INT;ITA` for one recording shared by those); empty = the same sound in every language. The editor shows the sounds of the selected language; names are unique per language |
| `line` | speech (WAD and BANK): the line's identity, the same in every language (smallest Wwise Sound object ID of the line; `ackit/lines.py`). Two rows of one archive with the same `line` are one recording stored twice (written together). Empty for non-speech sounds | | `line` | speech (WAD and BANK): the line's identity, the same in every language (smallest Wwise Sound object ID of the line; `ackit/lines.py`). Two rows of one archive with the same `line` are one recording stored twice (written together). Empty for non-speech sounds |
| `character` | verified speaker(s) from `characters.csv`, `; `-separated, empty if unknown | | `character` | verified speaker(s) from `characters.csv`, `; `-separated, empty if unknown |
| `characters_inferred` | characters/roles mentioned by the name (and bank) (hint only) | | `characters_inferred` | characters/roles mentioned by the name (and bank) (hint only) |
@@ -39,7 +39,7 @@ All CSVs are UTF-8 with a header row. They contain only names and metadata (no g
| `offset` | position of the WEM in the *uncompressed* file (hex; `docs/formats/upk.md`) | | `offset` | position of the WEM in the *uncompressed* file (hex; `docs/formats/upk.md`) |
| `size` | bytes reserved for it there | | `size` | bytes reserved for it there |
| `kind` | `m` = copy of a BANK sound, `p` = prefetch head of a speech line | | `kind` | `m` = copy of a BANK sound, `p` = prefetch head of a speech line |
| `sound` | `p` rows: key of that speech line (e.g. `ITA:2B14E472:3`); each language has its own lines and heads | | `sound` | `p` rows: key of that speech line (e.g. `INT:2B151272:0`); each language has its own lines and heads |
A replacement is written into every `m` copy of a BANK sound; replacing a speech line also rewrites its `p` A replacement is written into every `m` copy of a BANK sound; replacing a speech line also rewrites its `p`
heads (`docs/formats/banks.md`). heads (`docs/formats/banks.md`).

View File

@@ -62,10 +62,10 @@ Two separate fields, because Wwise names describe a *scene*, not the speaker of
alternates speakers between `NAME_1`, `NAME_2`, ...): alternates speakers between `NAME_1`, `NAME_2`, ...):
- **character** - the verified speaker, only from the curated lists in `data/catalog/source/<lang>/*.txt` - **character** - the verified speaker, only from the curated lists in `data/catalog/source/<lang>/*.txt`
(one file per character, listening-based). The lists name ITA sounds by their position in the ITA WAD (one file per character, listening-based; the lists so far were made on the Italian voices, `source/ita/`).
(wemscan names); positions differ between languages, so each listed recording is found at its ITA position A list names sounds by their position in its language's WAD (wemscan names); positions differ between
and the label goes to its line (`characters.csv`, keyed by the line's name), i.e. to the same recording in languages, so each listed recording is found at its position in that language and the label goes to its line
every language. A label applies to exactly the listed recordings (not to other versions of the same event). (`characters.csv`, keyed by the line's name), i.e. to the same recording in every language. A label applies to exactly the listed recordings (not to other versions of the same event).
Checked by `ackit verify --catalog` against the speaker field of the game's dialogue objects: 2,766 labelled Checked by `ackit verify --catalog` against the speaker field of the game's dialogue objects: 2,766 labelled
lines agree (spelling aside: Mister Freeze, Bruce Wayne, TwoFace, Mysterious_stranger = Azrael), 25 listed lines agree (spelling aside: Mister Freeze, Bruce Wayne, TwoFace, Mysterious_stranger = Azrael), 25 listed
as Unknown are named there, 19 differ (to listen to: `Batman_sword_raised_01-04` game: Ra's al Ghul, as Unknown are named there, 19 differ (to listen to: `Batman_sword_raised_01-04` game: Ra's al Ghul,

View File

@@ -77,7 +77,7 @@ tables): high risk for little gain, not done.
ackit wrote. ackit wrote.
- **Prefetch heads:** 396 speech lines (about 79 per language) have their first bytes copied into banks; a - **Prefetch heads:** 396 speech lines (about 79 per language) have their first bytes copied into banks; a
replacement of such a line rewrites those heads too, otherwise the start would play the old line. replacement of such a line rewrites those heads too, otherwise the start would play the old line.
- **Names:** the hash list names none of these media. Names come from Wwise events whose names are in the - **Names:** no list names these media. Names come from Wwise events whose names are in the
packages' name and export tables (FNV-1 of the name = event ID), following event -> action -> containers -> packages' name and export tables (FNV-1 of the name = event ID), following event -> action -> containers ->
sounds (6,456 sounds); the rest are numbered per bank. Categories use the bank name. A localized speech line sounds (6,456 sounds); the rest are numbered per bank. Categories use the bank name. A localized speech line
has the same name in every language (its line, section 5). has the same name in every language (its line, section 5).
@@ -111,6 +111,9 @@ copy per voice language) is filtered the same way, so the sound list never mixes
## 5. Catalog ## 5. Catalog
- Shipped CSVs per archive (names, categories, characters, original size + checksum) make loading instant and - Shipped CSVs per archive (names, categories, characters, original size + checksum) make loading instant and
enable change detection; any other build is scanned at runtime (header reads only, cached). enable change detection; any other build is scanned at runtime (header reads only, cached).
- Names come from the game files only: a WAD entry or stream pack is named after its soundbank in the game's
`CookedBanks.txt` (entry ID = FNV-1 of the bank name; this gives exactly the names of the community hash list
the toolkit used to ship, which was removed), speech lines after their events (below).
- Verified speakers come only from the user's lists; name-based character inference is kept in a separate field. - Verified speakers come only from the user's lists; name-based character inference is kept in a separate field.
- Voice lines are identified by the game's own data, not by names or positions (`docs/formats/banks.md`, - Voice lines are identified by the game's own data, not by names or positions (`docs/formats/banks.md`,
*Lines*): the recordings that are sources of the same Wwise Sound objects are one line, with exactly one *Lines*): the recordings that are sources of the same Wwise Sound objects are one line, with exactly one
@@ -159,8 +162,13 @@ copy per voice language) is filtered the same way, so the sound list never mixes
- Background: the wallpaper sits only behind the filter panel, scaled to the panel height so its left part fills - Background: the wallpaper sits only behind the filter panel, scaled to the panel height so its left part fills
the panel, under a light veil. Settings -> Background toggles it and sets its strength (default 10 %). the panel, under a light veil. Settings -> Background toggles it and sets its strength (default 10 %).
- Loading feedback stays where the work is, never modal: a spinner inside the waveform being loaded (sound or - Loading feedback stays where the work is, never modal: a spinner inside the waveform being loaded (sound or
replacement file, decoded off the GUI thread), next to the Apply/Revert buttons while encoding/writing, and in replacement file, decoded off the GUI thread), next to the Apply/Revert buttons while encoding/writing, over
the status bar for game loading and bulk changes. a list while it loads (the sound list at start and on a language change, the subtitles / menu text of a
language, read off the GUI thread), and in the status bar for game loading and bulk changes.
- Lists filter and sort in Python, not with Qt's QSortFilterProxyModel (`app/models.py: RowView`): the view
computes the visible rows in one pass and one sort over cached keys (~10-20 ms for 21,000 sounds; the Qt
proxy called back into Python for every comparison: up to 1.5 s per sort or widened filter). Rows a filter
shows again keep their sorted place; selection and the current row survive every change.
All filter selectors (including Character) are plain, non-editable drop-downs so they look and behave alike. All filter selectors (including Character) are plain, non-editable drop-downs so they look and behave alike.
## 8. Verification ## 8. Verification

View File

@@ -28,7 +28,7 @@ All WEMs seen are Vorbis (format tag 0xFFFF), the same codec as the WADs: the fo
the embedded ones mono or stereo (~22 % stereo in a 2,533-WEM sample). the embedded ones mono or stereo (~22 % stereo in a 2,533-WEM sample).
## Names ## Names
- The community hash list names none of these media IDs; only the bank names are known. - No list names these media IDs (the community hash list names only WAD entries and banks).
- `CookedPCConsole\CookedBanks.txt` lists all 3,345 cooked bank names. Bank ID = FNV-1 32 of the lowercased - `CookedPCConsole\CookedBanks.txt` lists all 3,345 cooked bank names. Bank ID = FNV-1 32 of the lowercased
name (the same hash Wwise uses for every name). 3,338 of them are embedded in packages, 4 are loose `.bnk` name (the same hash Wwise uses for every name). 3,338 of them are embedded in packages, 4 are loose `.bnk`
files (`Init`, `Global`, `Music`, `Music_PDLC`); `AMB_OW_A6`, `AMB_OW_C1`, `AMB_OW_E1`, files (`Init`, `Global`, `Music`, `Music_PDLC`); `AMB_OW_A6`, `AMB_OW_C1`, `AMB_OW_E1`,

View File

@@ -9,7 +9,7 @@ per language (INT, ITA, FRA, DEU, ESN, ESM, POL, POR, RUS, JPN, KOR), ~37 files
- UTF-16LE with a BOM (`FF FE`), CRLF line ends; the first line is empty. (`INT\Version.int` and - UTF-16LE with a BOM (`FF FE`), CRLF line ends; the first line is empty. (`INT\Version.int` and
`VersionData.int` are plain ASCII build IDs, not UI text: ackit ignores them.) `VersionData.int` are plain ASCII build IDs, not UI text: ackit ignores them.)
- `[Section]` lines, `Key=Value` lines, `;` comments. A few values are in double quotes (`Quit="Quit game"`). - `[Section]` lines, `Key=Value` lines, `;` comments. A few values are in double quotes (`Quit="Quit game"`).
- File extension = language code (`FRONTEND.int`, `FRONTEND.ITA`); case varies, lookups are case-insensitive. - File extension = language code (`FRONTEND.int`, `FRONTEND.FRA`); case varies, lookups are case-insensitive.
- Markers inside values (e.g. `\n`, button tags) are the game's own; ackit keeps them as plain text. - Markers inside values (e.g. `\n`, button tags) are the game's own; ackit keeps them as plain text.
## Keys and writing ## Keys and writing

View File

@@ -21,11 +21,13 @@ Rules:
- Each payload starts at the next **0x1000-aligned** offset after the previous one. - Each payload starts at the next **0x1000-aligned** offset after the previous one.
- `size` = payload length rounded up to **0x800**; padding is zero. - `size` = payload length rounded up to **0x800**; padding is zero.
- File ends 0x1000-aligned; header area after the table is zero. - File ends 0x1000-aligned; header area after the table is zero.
- `hash` = Wwise media/file ID (names: `hashlist.txt`, e.g. `CHR_RadioScanner`, `AMB_Steel_B3`, `Music`). - `hash` = Wwise ID of the soundbank whose streamed media the entry holds: FNV-1 of a bank name listed in
`CookedPCConsole\CookedBanks.txt` (e.g. `CHR_RadioScanner`, `AMB_Steel_B3`, `Music`); this names every
entry the game uses (2,544 of 2,547; ackit takes the names from there).
Payload kinds: Payload kinds:
- **LOC WADs**: each entry is a group of WEMs, the variations of one line (ITA: 10816 WEMs in - **LOC WADs**: each entry is a group of WEMs, the variations of one line (every language: 10,816 WEMs in
2464 entries; e.g. `CHR_RadioScanner` holds 91). Each WEM starts at the next 0x800 boundary after 2,464 entries; e.g. `CHR_RadioScanner` holds 91). Each WEM starts at the next 0x800 boundary after
the previous one (zero padding); 3 entries pack two WEMs back to back. Addressed `HASH:i`. The WEMs of an the previous one (zero padding); 3 entries pack two WEMs back to back. Addressed `HASH:i`. The WEMs of an
entry are stored in media-ID order and the languages have different media IDs, so variation `i` is a entry are stored in media-ID order and the languages have different media IDs, so variation `i` is a
different line in each language (75 % of the lines). Which recording is which line comes from the banks different line in each language (75 % of the lines). Which recording is which line comes from the banks

View File

@@ -1,7 +1,9 @@
# WEM format accepted by Arkham City (Wwise 2011.1, Vorbis) # WEM format accepted by Arkham City (Wwise 2011.1, Vorbis)
Derived from the game's WEMs; `ackit verify` / `tests/test_realgame.py` rebuild them byte-for-byte from these rules Derived from the game's WEMs; `ackit verify` / `tests/test_realgame.py` rebuild them byte-for-byte from these rules
alone (all 10816 ITA WEMs; in SFX only the 4 looping music tracks with loop points differ). alone. Checked on every WEM of the 5 speech WADs: 54,079 of 54,080 are identical; the exception (INT
`FE9963E8:74`) differs only in the informational avg-bytes/s field (one lower than the formula below). In SFX
only the 4 looping music tracks with loop points differ.
## RIFF layout ## RIFF layout
`RIFF` size `WAVE`, then `fmt ` (0x42 bytes), optional `cue `/`LIST` (37 files), `data`. `RIFF` size `WAVE`, then `fmt ` (0x42 bytes), optional `cue `/`LIST` (37 files), `data`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 191 KiB

View File

@@ -80,7 +80,7 @@ Conclusion: approach A works end-to-end in-game. Use >= 30 s probes for the intr
starts and runs, all 68 probe byte patterns are found in its memory (it loaded the patched packages), revert starts and runs, all 68 probe byte patterns are found in its memory (it loaded the patched packages), revert
byte-identical: PASS. Not shown: a probe being *heard*. Only 7 Vorbis streams start on the title screen byte-identical: PASS. Not shown: a probe being *heard*. Only 7 Vorbis streams start on the title screen
(setups 4f708975, 6ded1c65, 92f94a3f, d93b5944, d4b2e824: shared by hundreds of sounds, music included) and (setups 4f708975, 6ded1c65, 92f94a3f, d93b5944, d4b2e824: shared by hundreds of sounds, music included) and
none is a probe. Tried: Enter/arrow keys posted to the game window (`debugger.post_key`, WM_KEYDOWN) to reach none is a probe. Tried: Enter/arrow keys posted to the game window (WM_KEYDOWN/WM_KEYUP; that helper was removed later) to reach
the main menu: no UI sound followed (the game probably reads input another way; global SendInput is not the main menu: no UI sound followed (the game probably reads input another way; global SendInput is not
used on purpose); a breakpoint on the setup parser entry: no extra events. Hypotheses: no in-memory sound used on purpose); a breakpoint on the setup parser entry: no extra events. Hypotheses: no in-memory sound
plays without input; the decode path is the same as for WAD sounds (same WEM rules, 300/300 in-memory WEMs plays without input; the decode path is the same as for WAD sounds (same WEM rules, 300/300 in-memory WEMs

View File

@@ -28,7 +28,7 @@ Changing it reloads the lists; the next start is again in the game's language.
## Names ## Names
Every voice line has one name in every language, taken from the game itself (e.g. `Speech_TAPE_JOKER_1_04`, the Every voice line has one name in every language, taken from the game itself (e.g. `Speech_TAPE_JOKER_1_04`, the
same as in the Subtitles tab): the line `Speech_X` in Italian is the same line `Speech_X` in English, French, same as in the Subtitles tab): the line `Speech_X` in English is the same line `Speech_X` in French, Italian,
German and Spanish. Inside a WAD entry the game stores the variations of a line in a different order in each German and Spanish. Inside a WAD entry the game stores the variations of a line in a different order in each
language, so the position-based name is kept only as a second name (`Speech_TAPE_JOKER_1#4` = the 4th WEM of language, so the position-based name is kept only as a second name (`Speech_TAPE_JOKER_1#4` = the 4th WEM of
that entry in this language; search finds both). When the game plays one of two versions of a line, the that entry in this language; search finds both). When the game plays one of two versions of a line, the
@@ -118,7 +118,7 @@ Click the **★** column to mark favorites. These are stored in your user data f
data plus the audio, so it also works for another language whose original has a different size). 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. - **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*); a text stays - Changed subtitles and menu texts are exported and imported with the sounds (listed as *Text*); a text stays
in its language (an Italian subtitle edit changes the Italian subtitle). in its language (a French subtitle edit changes the French subtitle).
- In another language, voice lines are matched by the game's own identity of the line, so the same line is - In another language, voice lines are matched by the game's own identity of the line, so the same line is
replaced even where its position differs. Voice lines from packages made with versions before 3.1 carry no replaced even where its position differs. Voice lines from packages made with versions before 3.1 carry no
such identity: the import lists them instead of guessing (export the mod again with this version). such identity: the import lists them instead of guessing (export the mod again with this version).

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "ackit" name = "ackit"
version = "3.1.0" version = "3.2.0"
description = "Batman: Arkham City (PC) audio toolkit and editor" description = "Batman: Arkham City (PC) audio toolkit and editor"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@@ -269,7 +269,9 @@ HEAD = 0x200 # prefetch head size of variation 1
def make_banks(cooked, speech_wems): def make_banks(cooked, speech_wems):
"""Loose bank + two packages (one bank in both) + a prefetch head of INT SPEECH_ENTRY variation 1.""" """Loose bank + two packages (one bank in both) + a prefetch head of INT SPEECH_ENTRY variation 1."""
from ackit.bnk import fnv1 from ackit.bnk import fnv1
names = ["Init", "CHR_SynthFootsteps_Media", "CHR_SynthFoley", "GUI_Synth", "Speech_Synth"] names = ["Init", "CHR_SynthFootsteps_Media", "CHR_SynthFoley", "GUI_Synth", "Speech_Synth",
# banks whose streamed media are the WAD entries (entry ID = FNV-1 of the bank name)
"CHR_RadioScanner", "Speech_TAPE_JOKER_1", "Music", "AMB_Steel_B3"]
with open(os.path.join(cooked, "CookedBanks.txt"), "w", newline="\r\n") as f: with open(os.path.join(cooked, "CookedBanks.txt"), "w", newline="\r\n") as f:
f.write("\n".join(names) + "\n") f.write("\n".join(names) + "\n")
foot = {FOOT[0]: wem(0.4, 800), FOOT[1]: wem(0.3, 900)} foot = {FOOT[0]: wem(0.4, 800), FOOT[1]: wem(0.3, 900)}

View File

@@ -66,6 +66,28 @@ def test_browse_search_filter(win):
assert w.proxy.rowCount() == w.model.rowCount() assert w.proxy.rowCount() == w.model.rowCount()
def test_sort_order_survives_filter_changes(win):
"""Rows that a filter shows again go to their sorted place (not appended at the end)."""
from PySide6.QtCore import Qt
from app.models import C_NAME, SORT_ROLE
w, _, _ = win
def keys():
return [w.proxy.data(w.proxy.index(r, C_NAME), SORT_ROLE) for r in range(w.proxy.rowCount())]
w.table.sortByColumn(C_NAME, Qt.SortOrder.AscendingOrder)
assert keys() == sorted(keys())
w.cmb_group.setCurrentText("Music")
w.clear_filters()
type_search(w, "radio")
type_search(w, "")
assert w.proxy.rowCount() == w.model.rowCount() and keys() == sorted(keys())
w.table.sortByColumn(C_NAME, Qt.SortOrder.DescendingOrder)
w.cmb_group.setCurrentText("Speech")
w.clear_filters()
assert keys() == sorted(keys(), reverse=True)
def test_replace_apply_revert_byte_identical(win, tmp_path): def test_replace_apply_revert_byte_identical(win, tmp_path):
w, root, originals = win w, root, originals = win
before = files(root) before = files(root)
@@ -296,6 +318,39 @@ class HeldRunner:
self.real.start(*self.held.pop(0)) self.real.start(*self.held.pop(0))
def test_selection_survives_sort_and_filter(win):
from PySide6.QtCore import Qt
from app.models import C_LEN
w, _, _ = win
assert w.select_name("Speech_Synth_Radio_02")
s = w.editor.sound
w.table.sortByColumn(C_LEN, Qt.SortOrder.DescendingOrder)
type_search(w, "radio")
cur = w.table.currentIndex()
assert cur.isValid() and w.proxy.sound_at(cur) is s and [x.key for x in w.selected_sounds()] == [s.key]
type_search(w, "music") # the row is filtered out: no current row
assert not w.table.currentIndex().isValid() and not w.selected_sounds()
def test_list_spinners_while_loading(win):
"""The sound list and the text lists show the loading overlay while they load (background jobs); a subtitle
asked for meanwhile is selected when the rows arrive."""
from ackit import subtitles
w, root, _ = win
held = w.runner = HeldRunner(w.runner)
w.load_game(root, "INT")
assert w.table_busy.busy and w.table_busy.timer.isActive()
held.release()
assert not w.table_busy.busy and w.model.rowCount() == len(w.ws.catalog.sounds)
w._text_loaded.clear()
sub = w.ws.texts.subtitles("INT")[0]
key = subtitles.key("INT", sub.speech, sub.line)
w.show_subtitle(key)
assert w.subs.busy.busy and w.subs.busy.timer.isActive()
held.release()
assert not w.subs.busy.busy and w.subs.current is not None and w.subs.current.key == key
def test_spinners_while_loading(win, tmp_path): def test_spinners_while_loading(win, tmp_path):
w, root, _ = win w, root, _ = win
held = w.runner = HeldRunner(w.runner) held = w.runner = HeldRunner(w.runner)

View File

@@ -25,7 +25,7 @@ import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ackit import banks, settings, subtitles # noqa: E402 from ackit import banks, settings, subtitles # noqa: E402
from ackit.catalog import (apply_lines, character_of, hash_names, read_csv, scan_archive, scan_text, # noqa: E402 from ackit.catalog import (apply_lines, character_of, read_csv, scan_archive, scan_text, # noqa: E402
write_csv) write_csv)
from ackit.classify import USER_LABELS # noqa: E402 from ackit.classify import USER_LABELS # noqa: E402
from ackit.game import Game, default_game_dir, wad_fingerprint # noqa: E402 from ackit.game import Game, default_game_dir, wad_fingerprint # noqa: E402
@@ -119,7 +119,6 @@ def main():
game = Game(a.game or default_game_dir(settings.load())) game = Game(a.game or default_game_dir(settings.load()))
out_dir = catalog_dir() out_dir = catalog_dir()
src_root = os.path.join(out_dir, "source") src_root = os.path.join(out_dir, "source")
hash_names()
# 1. every package once: in-memory sounds, prefetch heads, subtitles, events (a few minutes) # 1. every package once: in-memory sounds, prefetch heads, subtitles, events (a few minutes)
t0 = time.time() t0 = time.time()

View File

@@ -80,8 +80,6 @@ def components():
"https://github.com/pyinstaller/pyinstaller", dist_file("pyinstaller", "COPYING.txt")), "https://github.com/pyinstaller/pyinstaller", dist_file("pyinstaller", "COPYING.txt")),
("Wwise codebook data (ackit/data/game_codebooks.bin)", "-", "derived from libvorbis codebooks (BSD-3-Clause)", ("Wwise codebook data (ackit/data/game_codebooks.bin)", "-", "derived from libvorbis codebooks (BSD-3-Clause)",
"https://github.com/xiph/vorbis", "(see libvorbis above)"), "https://github.com/xiph/vorbis", "(see libvorbis above)"),
("Community hash list (ackit/data/hashlist.txt)", "-", "no license stated (list of Wwise ID -> name pairs)",
"from the 'Batman_bnk' tool shipped with AbdAshes' Batman Arkham Audio Guide", ""),
] ]

View File

@@ -1,6 +1,9 @@
"""Render the user-guide screenshots headless (docs/img/*.png). Read-only: never applies anything. """Render the user-guide screenshots headless (docs/img/*.png). Read-only: never applies anything.
python tools/screenshots.py [--game DIR] python tools/screenshots.py [--game DIR] [--lang INT]
The app starts in the game's language as always; the screenshots then switch to --lang (default INT, the
game's English) the way a user does, through the Language list.
""" """
import argparse import argparse
import os import os
@@ -18,6 +21,7 @@ os.environ["ACKIT_NO_AUDIO"] = "1"
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--game") ap.add_argument("--game")
ap.add_argument("--lang", default="INT", help="language shown in the screenshots (default: INT)")
a = ap.parse_args() a = ap.parse_args()
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from ackit import audio from ackit import audio
@@ -38,7 +42,11 @@ def main():
w = MainWindow(settings=st, runner=Runner(sync=True), game_dir=a.game) w = MainWindow(settings=st, runner=Runner(sync=True), game_dir=a.game)
w.resize(1500, 950) w.resize(1500, 950)
w.show() w.show()
app.processEvents() # the game loads here app.processEvents() # the game loads here (in the game's language)
if a.lang and w.ws and w.language != a.lang.upper():
w.cmb_lang.setCurrentText(a.lang.upper()) # what choosing it in the Language list does
w.load_game(w.ws.game.root, a.lang.upper())
app.processEvents()
# status bar: show the store where users have it, not this temporary folder (no local user names) # status bar: show the store where users have it, not this temporary folder (no local user names)
w.lbl_game.setText(w.lbl_game.text().replace(os.path.join(td, "data"), r"%APPDATA%\UnheardCity")) w.lbl_game.setText(w.lbl_game.text().replace(os.path.join(td, "data"), r"%APPDATA%\UnheardCity"))
# 1: browser filtered by character # 1: browser filtered by character