Version 3.2.0: fast lists with loading spinners, names from the game files, INT docs and screenshots, cleanup
This commit is contained in:
20
NOTES.md
20
NOTES.md
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
13
REPORT.md
13
REPORT.md
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
19063
ackit/data/hashlist.txt
19063
ackit/data/hashlist.txt
File diff suppressed because it is too large
Load Diff
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
211
app/models.py
211
app/models.py
@@ -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()]]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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", ""),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user