Full correctness check against the docs: safer writes, reverts and texts
Safety - Writes are serialized (Workspace.lock): two background jobs rebuilding one package (a sound and its subtitle applied side by side) lost an edit. - A store copies a bank file or package as "original" only if it matches the shipped catalog (Workspace.unoriginal_files: in-memory sounds, bank copies of voice lines, subtitles); the editor refuses to switch stores while changes are applied. A second store could otherwise resurrect a reverted sound. - Mod import in the editor asks before overwriting files changed outside the tool (it forced silently). - Revert all in the editor reverts texts too; the uninstaller counts text edits before offering to delete the store. - ackit restore-backup: the full-backup restore decisions.md described; it keeps the sounds of WADs without a full copy recorded. - build_catalog also stops when a subtitle differs from the catalog. Languages and texts - Game.language() returns languages without voices (JPN, RUS, ...) and Game.voice_archive() their INT voices; ackit text --lang JPN works. - Menu lookups parse one cached file (find & replace: minutes -> ms). - Subtitles decode cp1252 as Windows does: the UTF-8-in-cp1252 line Speech_TAPE_J_Answer_1_00 (KOR/POL/POR/RUS) is written back byte-for-byte; SUB_KOR/SUB_RUS rebuilt from the original game (only those rows changed). - Mods exported while another language is loaded keep name and line. Other fixes - Down-mix folds extra channels in at -3 dB (a 5.1 centre voice was dropped). - e2e_editor no longer overwrites the user's settings.json; kill_game waits for the game to exit before reverts. - Recorder handles Int32/UInt8 input and split frames; package block cache locked; conflict messages, CLI help examples, stale docstrings and doc references fixed; tests no longer read this PC's game language. Docs (decisions, user guide, README, subtitles.md, banks.md, catalog README, REPORT, NOTES, CLAUDE.md) updated; check_docs: 102 claims match. 148 tests; e2e_features 9/9, e2e_editor and verify PASS; game original afterwards.
This commit is contained in:
@@ -5,7 +5,8 @@ Batman: Arkham City (PC) audio toolkit + editor. Read `NOTES.md` first (status,
|
|||||||
- Layout: `ackit/` library + CLI (`python -m ackit -h`), `app/` PySide6 editor, `tests/` pytest, `data/catalog/`
|
- Layout: `ackit/` library + CLI (`python -m ackit -h`), `app/` PySide6 editor, `tests/` pytest, `data/catalog/`
|
||||||
CSVs (+ user's original lists in `data/catalog/source/`, never edit them), `docs/`, `tools/` build scripts.
|
CSVs (+ user's original lists in `data/catalog/source/`, never edit them), `docs/`, `tools/` build scripts.
|
||||||
- Dev env: `.venv` (Python 3.14, `pip install -e .[app,dev,verify]`). Run tests with `.venv/Scripts/python -m pytest -q`.
|
- Dev env: `.venv` (Python 3.14, `pip install -e .[app,dev,verify]`). Run tests with `.venv/Scripts/python -m pytest -q`.
|
||||||
- Writing game files goes through `ackit.workspace.Workspace` only (originals store, atomic writes, conflict checks).
|
- Writing game files goes through `ackit.workspace.Workspace` only (originals store, atomic writes, conflict checks,
|
||||||
|
one write at a time: `Workspace.lock`, held by every apply/revert/rebuild incl. `Texts`).
|
||||||
Speech/ambience: in place, never larger than the original WEM; music packs: any size (`docs/formats/wad.md`).
|
Speech/ambience: in place, never larger than the original WEM; music packs: any size (`docs/formats/wad.md`).
|
||||||
In-memory sounds (archive BANK, loose `.bnk` + `.upk` packages): in place into every copy; a bank file is always
|
In-memory sounds (archive BANK, loose `.bnk` + `.upk` packages): in place into every copy; a bank file is always
|
||||||
rebuilt from the store's original copy + the applied replacements (`docs/formats/banks.md`, `upk.md`).
|
rebuilt from the store's original copy + the applied replacements (`docs/formats/banks.md`, `upk.md`).
|
||||||
@@ -15,7 +16,7 @@ Batman: Arkham City (PC) audio toolkit + editor. Read `NOTES.md` first (status,
|
|||||||
`ackit verify --ingame [--banks | --texts]` (Windows; needs Steam running and logged in, WinDbg; ~1.5 min; reverts and
|
`ackit verify --ingame [--banks | --texts]` (Windows; needs Steam running and logged in, WinDbg; ~1.5 min; reverts and
|
||||||
checks byte-identity itself).
|
checks byte-identity itself).
|
||||||
- Rebuild catalogs only from original game files: `python tools/build_catalog.py` (~5 min, scans all packages;
|
- Rebuild catalogs only from original game files: `python tools/build_catalog.py` (~5 min, scans all packages;
|
||||||
stops if a sound differs from the current catalog). Voice lines are identified by the game's Wwise Sound
|
stops if a sound or subtitle differs from the current catalog). Voice lines are identified by the game's Wwise Sound
|
||||||
objects (`ackit/lines.py`, column `line`; one recording per language), never by WAD position or name:
|
objects (`ackit/lines.py`, column `line`; one recording per language), never by WAD position or name:
|
||||||
positions differ per language (`docs/decisions.md` 5, `docs/formats/banks.md` *Lines*).
|
positions differ per language (`docs/decisions.md` 5, `docs/formats/banks.md` *Lines*).
|
||||||
- Figures in the docs are checked, not copied: after changing data, code or docs run `python tools/check_docs.py`
|
- Figures in the docs are checked, not copied: after changing data, code or docs run `python tools/check_docs.py`
|
||||||
|
|||||||
36
NOTES.md
36
NOTES.md
@@ -126,6 +126,42 @@
|
|||||||
action bar level with the others; A/B and Loop are icons next to the volume. Fits a 1000-px window
|
action bar level with the others; A/B and Loop are icons next to the volume. Fits a 1000-px window
|
||||||
without horizontal scrolling (minimum 984 px). Docs: recordings the game shares between languages (user saw
|
without horizontal scrolling (minimum 984 px). Docs: recordings the game shares between languages (user saw
|
||||||
English lines "leaking" into other languages: the game's own data, banks.md *Lines*).
|
English lines "leaking" into other languages: the game's own data, banks.md *Lines*).
|
||||||
|
- 2026-10-13 (user: "full check of the source code for correctness, also against the documentation"): figures
|
||||||
|
all matched (`check_docs --scan`), the formats code matched the docs; fixed: editor **Revert all** reverted
|
||||||
|
sounds only (docs and uninstaller promised everything; the uninstaller also counted only sound records and
|
||||||
|
could offer to delete the store while texts were modified) -> sounds + texts, uninstaller counts texts;
|
||||||
|
concurrent background writes to one package lost an edit (reproduced) -> `Workspace.lock` serializes every
|
||||||
|
write; editor mod import forced over conflicts silently -> asks; a second originals store (Settings / `--store`)
|
||||||
|
copied a package modified by the first as its "original" (reproduced: a replaced sound survived both reverts)
|
||||||
|
-> `Workspace.unoriginal_files` (catalog checksums, heads, subtitles; 1,447 files, no false alarm, ~15 s for
|
||||||
|
all) makes it a conflict and the editor refuses a store change while changes exist; `ackit text --lang JPN`
|
||||||
|
(text-only languages) failed -> `Game.voice_archive`; menu lookups reloaded all ~37 files per call (find &
|
||||||
|
replace of "the" in INT: ~4 min frozen) -> one cached file per lookup (0.04 s for 1,193 keys);
|
||||||
|
build_catalog's safety check ignored subtitles -> also compares them; `ackit restore-backup` added (decisions
|
||||||
|
named a restore that nothing exposed; it also forgot WAD sounds without a full copy); recorder handles
|
||||||
|
Int32/UInt8 input and split frames; stale docstrings, `ackit -h` examples, conflict message, case-insensitive
|
||||||
|
Localization path in `verify --catalog`. 146 tests. During the checks an e2e_editor game launch failed
|
||||||
|
("code never decrypted") and its revert found the WAD still locked by the dying game (left Music_393BC53A
|
||||||
|
modified; reverted by hand, SFX catalog OK): `debugger.kill_game` now waits until the game has exited.
|
||||||
|
- 2026-10-13 second full check (user: "do this again to make sure"): codec (wwvorbis, codebooks, allocsize, ogg),
|
||||||
|
classify vs categories.md, verify harness, tools and widgets re-read; fixed: `tools/e2e_editor.py` saved its own
|
||||||
|
settings over the user's settings.json (lost store/device/background settings; it now uses the user's
|
||||||
|
settings and never saves); `Game.language()` ignored languages without voices (a JPN game opened the editor
|
||||||
|
and `ackit text` in INT; now the game's language, `Game.voice_archive` for its speech archive) and the tests
|
||||||
|
read this PC's UserEngine.ini (conftest isolates it); a mod exported while another language was loaded lost
|
||||||
|
name + line of that language's changes (`Workspace.sound_for_key`); down-mix dropped the extra channels (a
|
||||||
|
5.1 centre-only voice became silent stereo; now folded in at -3 dB, peak-limited); `Texts.for_sound` default
|
||||||
|
for shared in-memory lines; package block cache locked (concurrent readers); `catalog._whole_recordings` ->
|
||||||
|
`whole_recordings` in docs/comments; the user guide no longer sends app users to the CLI-only restore.
|
||||||
|
148 tests.
|
||||||
|
- 2026-10-13 third check (property checks on the real game data): every menu file rewrites byte-identically,
|
||||||
|
no ambiguous menu keys, every subtitle copy has the catalog's room; 2 of 103,658 subtitle strings could not be
|
||||||
|
written back as stored: one line (`Speech_TAPE_J_Answer_1_00`) is UTF-8 bytes in a cp1252 string in KOR, POL,
|
||||||
|
POR, RUS (the game's data; KOR's damaged), and the RUS/KOR ones contain the 5 bytes Python's cp1252 leaves
|
||||||
|
undefined (decoded as U+FFFD, lossy) -> cp1252 as Windows maps it (lossless; all 103,658 reproduce), catalog
|
||||||
|
rebuilt from the original game (`--accept-changes`: exactly those 2 rows changed), subtitles.md documents it
|
||||||
|
(claim derived by check_docs). User guide: Space plays the replacement once one is loaded (not always the
|
||||||
|
original); tooltip too. App smoke test on the real game PASS.
|
||||||
- DONE by the user in game: replaced voice lines, edited subtitles and replaced footsteps (after the fill fix).
|
- DONE by the user in game: replaced voice lines, edited subtitles and replaced footsteps (after the fill fix).
|
||||||
- OPEN: channel order for >2 channels unverified; 19 speaker labels to check by listening; the Linux build was
|
- OPEN: channel order for >2 channels unverified; 19 speaker labels to check by listening; the Linux build was
|
||||||
never built or run (no Linux here).
|
never built or run (no Linux here).
|
||||||
|
|||||||
@@ -38,13 +38,14 @@ localization files. Details in `docs/`.
|
|||||||
ackit find joker tape # search by name, category or character
|
ackit find joker tape # search by name, category or character
|
||||||
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 (every sound and text)
|
||||||
ackit text find --lang INT protocol # subtitles and menu texts (keys SUB:... / MENU:...)
|
ackit text find --lang INT protocol # subtitles and menu texts (keys SUB:... / MENU:...; any of the
|
||||||
|
# 11 languages, e.g. --lang JPN)
|
||||||
ackit text set "MENU:INT:FRONTEND.int:Buttonpress:DoubleTap" "Tap twice"
|
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
|
||||||
install, language, store). `ackit -h` lists all commands. The game folder is found through Steam (Windows, Linux, Flatpak, Proton); otherwise pass `--game DIR`.
|
install, language, store), `ackit restore-backup` (last resort if the originals store is damaged). `ackit -h` lists all commands. The game folder is found through Steam (Windows, Linux, Flatpak, Proton); otherwise pass `--game DIR`.
|
||||||
|
|
||||||
Rules worth knowing: speech/effects replacements must fit in the original's size (the tool squeezes or refuses
|
Rules worth knowing: speech/effects replacements must fit in the original's size (the tool squeezes or refuses
|
||||||
with the maximum length); music can be any length. Subtitles must fit the original text's length (menu text:
|
with the maximum length); music can be any length. Subtitles must fit the original text's length (menu text:
|
||||||
|
|||||||
14
REPORT.md
14
REPORT.md
@@ -41,8 +41,14 @@ editor Unheard City (mission 2).
|
|||||||
export/import with texts, changed-outside banner for sounds and texts.
|
export/import with texts, changed-outside banner for sounds and texts.
|
||||||
- **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 (also when
|
||||||
- **Tests**: 135 automated tests (unit, synthetic game with packages/banks/dialogues/localization, a
|
installing a mod in the editor). The original copy is taken only from a file that matches the catalog
|
||||||
|
(another tool's or another originals store's change is a conflict; checked on all 1,447 files with sounds or
|
||||||
|
subtitles: no false alarm), and the editor does not switch stores while changes are applied. Writes are
|
||||||
|
serialized, so a sound and its subtitle applied at the same time both reach the package. **Revert all**
|
||||||
|
(editor and `ackit revert --all`) reverts sounds and texts; `ackit restore-backup` is the last resort for a
|
||||||
|
damaged store.
|
||||||
|
- **Tests**: 148 automated tests (unit, synthetic game with packages/banks/dialogues/localization, a
|
||||||
two-language game for the lines (incl. a recording also stored whole in a bank), the shipped catalog's cross-references, real-game read-only (every
|
two-language game for the lines (incl. a recording also stored whole in a bank), the shipped catalog's cross-references, real-game read-only (every
|
||||||
name of every language's catalog resolves to its own sound), offscreen GUI incl. list order, selection and
|
name of every language's catalog resolves to its own sound), offscreen GUI incl. list order, selection and
|
||||||
loading overlays) pass. `ackit verify` PASS (incl.
|
loading overlays) pass. `ackit verify` PASS (incl.
|
||||||
@@ -60,7 +66,9 @@ editor Unheard City (mission 2).
|
|||||||
- **Package**: `dist/UnheardCity-3.2.3-windows-amd64.zip` (86 MB, catalogs included). With `PATH` reduced to
|
- **Package**: `dist/UnheardCity-3.2.3-windows-amd64.zip` (86 MB, catalogs included). With `PATH` reduced to
|
||||||
the Windows folders the packaged exe passes its smoke test (in the game's language - Italian on the test PC:
|
the Windows folders the packaged exe passes its smoke test (in the game's language - Italian on the test PC:
|
||||||
21,015 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
|
21,015 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); the
|
||||||
|
uninstaller now also counts text edits before offering to delete the user data (2026-10-13; that count was
|
||||||
|
tested, the full uninstall cycle was not run again).
|
||||||
|
|
||||||
## Unresolved / limitations
|
## Unresolved / limitations
|
||||||
- **Checked by a person in game** (2026-10-10), not automatically (nothing of this plays or shows on the title
|
- **Checked by a person in game** (2026-10-10), not automatically (nothing of this plays or shows on the title
|
||||||
|
|||||||
@@ -53,21 +53,29 @@ def resample(x, rate, new_rate):
|
|||||||
|
|
||||||
|
|
||||||
def to_channels(x, channels):
|
def to_channels(x, channels):
|
||||||
"""Up/down-mix. Mono is copied to every channel; extra channels get the average."""
|
"""Up/down-mix. Up: mono is copied to every channel, other sources keep their channels and the new ones get
|
||||||
|
the average. Down: nothing is dropped - every channel goes into the mono sum, or the channels beyond the
|
||||||
|
kept ones (e.g. the centre and surrounds of 5.1) are added to every kept one at -3 dB (the standard
|
||||||
|
fold-down); a result louder than the source's peak is scaled back to it, so it does not clip."""
|
||||||
x = _as2d(x)
|
x = _as2d(x)
|
||||||
cur = x.shape[1]
|
cur = x.shape[1]
|
||||||
if cur == channels:
|
if cur == channels:
|
||||||
return x
|
return x
|
||||||
if channels == 1:
|
|
||||||
return x.mean(axis=1, keepdims=True)
|
|
||||||
if cur == 1:
|
if cur == 1:
|
||||||
return np.repeat(x, channels, axis=1)
|
return np.repeat(x, channels, axis=1)
|
||||||
out = np.empty((len(x), channels), dtype=np.float32)
|
if channels > cur:
|
||||||
keep = min(cur, channels)
|
out = np.empty((len(x), channels), dtype=np.float32)
|
||||||
out[:, :keep] = x[:, :keep]
|
out[:, :cur] = x
|
||||||
if channels > keep:
|
out[:, cur:] = x.mean(axis=1, keepdims=True)
|
||||||
out[:, keep:] = x.mean(axis=1, keepdims=True)
|
return out
|
||||||
return out
|
if channels == 1:
|
||||||
|
out = x.sum(axis=1, keepdims=True)
|
||||||
|
else:
|
||||||
|
out = x[:, :channels] + 0.7071 * x[:, channels:].sum(axis=1, keepdims=True)
|
||||||
|
peak_in, peak_out = float(np.max(np.abs(x), initial=0.0)), float(np.max(np.abs(out), initial=0.0))
|
||||||
|
if peak_out > peak_in > 0:
|
||||||
|
out = out * (peak_in / peak_out)
|
||||||
|
return out.astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
def trim(x, rate, start=None, end=None):
|
def trim(x, rate, start=None, end=None):
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ in the .upk packages (docs/formats/banks.md, upk.md). Catalog archive `BANK`, ta
|
|||||||
A media ID can be stored many times (the same bank copied into several packages, or several banks
|
A media ID can be stored many times (the same bank copied into several packages, or several banks
|
||||||
holding the same media). The location index (data/catalog/BANK_locations.csv.gz) lists every copy:
|
holding the same media). The location index (data/catalog/BANK_locations.csv.gz) lists every copy:
|
||||||
media, file (relative to CookedPCConsole, '/'-separated), offset (in the *uncompressed* file), size, kind
|
media, file (relative to CookedPCConsole, '/'-separated), offset (in the *uncompressed* file), size, kind
|
||||||
kind 'm' = an in-memory sound, 'p' = the in-memory head of a streamed WAD sound (prefetch); for 'p' rows
|
kind 'm' = an in-memory sound, 'p' = a bank copy of a streamed WAD recording (the in-memory head of a prefetched
|
||||||
`sound` is the WAD sound's key (e.g. ITA:2B14E472:3): speech lines have per-language media IDs and heads.
|
sound, or a whole copy with the same media ID); for 'p' rows `sound` is the WAD sound's key (e.g. ITA:2B14E472:3):
|
||||||
Replacements are written in place into every copy (<= original size, zero-padded), so bank and package
|
speech lines have per-language media IDs and heads.
|
||||||
layouts never change.
|
Replacements are written in place into every copy, no larger than the smallest; a whole in-memory WEM fills its
|
||||||
|
slot exactly (wem.fill: the game plays a zero-padded one as silence), a head gets the start of the new
|
||||||
|
recording. So bank and package layouts never change.
|
||||||
"""
|
"""
|
||||||
import csv
|
import csv
|
||||||
import gzip
|
import gzip
|
||||||
@@ -157,7 +159,7 @@ class Scan:
|
|||||||
self.actions = {} # id -> target id
|
self.actions = {} # id -> target id
|
||||||
self.sound_media = defaultdict(set) # sound object id -> media ids
|
self.sound_media = defaultdict(set) # sound object id -> media ids
|
||||||
self.prefetch = set() # media ids whose bank copies belong to a WAD recording (prefetch
|
self.prefetch = set() # media ids whose bank copies belong to a WAD recording (prefetch
|
||||||
# heads; whole recordings: catalog._whole_recordings)
|
# heads; whole recordings: catalog.whole_recordings)
|
||||||
self.lang_media = defaultdict(set) # language -> media ids named by Sound objects of that language's banks
|
self.lang_media = defaultdict(set) # language -> media ids named by Sound objects of that language's banks
|
||||||
# BKPK tables: WAD entry id -> {language: {media id: (offset in entry, size)}}. A table belongs to the
|
# BKPK tables: WAD entry id -> {language: {media id: (offset in entry, size)}}. A table belongs to the
|
||||||
# language its localized AkBank export declares ('' = outside such an export). The same media ID can be
|
# language its localized AkBank export declares ('' = outside such an export). The same media ID can be
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""Index of every sound in the game's WAD archives, with names, categories and characters
|
"""Index of every sound of the game, with names, categories and characters: the WAD archives (one per voice
|
||||||
(sounds stored inside soundbanks are not indexed yet: docs/formats/banks.md).
|
language, SFX) and the in-memory sounds stored inside soundbanks (archive BANK, docs/formats/banks.md).
|
||||||
|
|
||||||
Sources, in order:
|
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 game's fingerprint matches;
|
||||||
2. otherwise a header scan of the WAD (a few seconds, cached in the user cache), classified with
|
2. otherwise a scan of the game (WAD headers: seconds; banks: minutes; cached in the user cache), classified
|
||||||
`classify` and labelled with data/catalog/characters.csv (speaker per sound name).
|
with `classify` and labelled with data/catalog/characters.csv (speaker per sound name).
|
||||||
Names come from the game: a WAD entry ID is the Wwise ID (FNV-1) of a soundbank listed in the game's
|
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
|
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
|
||||||
@@ -517,14 +517,16 @@ class Catalog:
|
|||||||
def __init__(self, game, archives=None, progress=None):
|
def __init__(self, game, archives=None, progress=None):
|
||||||
self.game = game
|
self.game = game
|
||||||
avail = available_archives(game)
|
avail = available_archives(game)
|
||||||
self.archives = archives or [a for a in (game.language(), "SFX", banks.ARCHIVE) if a in avail]
|
self.archives = archives or [a for a in (game.voice_archive(game.language()), "SFX", banks.ARCHIVE) if a in avail]
|
||||||
self.sounds = []
|
self.sounds = []
|
||||||
self.locations = {} # BANK media id -> [Location]
|
self.locations = {} # BANK media id -> [Location]
|
||||||
self.heads = {} # WAD sound key -> [Location] of its prefetch heads
|
self.heads = {} # WAD sound key -> [Location] of its prefetch heads
|
||||||
|
self.bank_sha1 = {} # BANK media id -> original checksum ('' if unknown), every language
|
||||||
bank_sounds_ = []
|
bank_sounds_ = []
|
||||||
if banks.ARCHIVE in avail:
|
if banks.ARCHIVE in avail:
|
||||||
# always loaded: speech lines may have prefetch heads that must be written with them
|
# always loaded: speech lines may have prefetch heads that must be written with them
|
||||||
bank_sounds_, self.locations = load_banks(game, progress)
|
bank_sounds_, self.locations = load_banks(game, progress)
|
||||||
|
self.bank_sha1 = {int(s.target, 16): s.sha1 for s in bank_sounds_}
|
||||||
for ls in self.locations.values():
|
for ls in self.locations.values():
|
||||||
for l in ls:
|
for l in ls:
|
||||||
if l.kind == banks.PREFETCH_HEAD and l.sound:
|
if l.kind == banks.PREFETCH_HEAD and l.sound:
|
||||||
|
|||||||
49
ackit/cli.py
49
ackit/cli.py
@@ -1,15 +1,17 @@
|
|||||||
"""ackit - Batman: Arkham City audio toolkit (command line).
|
"""ackit - Batman: Arkham City audio toolkit (command line).
|
||||||
|
|
||||||
Everyday use, by sound name (see `ackit find`):
|
Everyday use, by sound name (see `ackit find`):
|
||||||
ackit find joker tape search names, categories, characters
|
ackit find joker tape search names, categories, characters
|
||||||
ackit info Speech_TAPE_JOKER_1_4 details, size limit, modified state
|
ackit info Speech_TAPE_JOKER_1_04 details, size limit, modified state
|
||||||
ackit extract Speech_TAPE_JOKER_1_4 orig.wav save the original (or --current)
|
ackit extract Speech_TAPE_JOKER_1_04 orig.wav save the original (or --current)
|
||||||
ackit replace Speech_TAPE_JOKER_1_4 line.wav encode + install a replacement
|
ackit replace Speech_TAPE_JOKER_1_04 line.wav encode + install a replacement
|
||||||
ackit revert Speech_TAPE_JOKER_1_4 put the original back (--all for everything)
|
ackit revert Speech_TAPE_JOKER_1_04 put the original back (--all: every sound and text)
|
||||||
ackit status what you have changed
|
ackit status what you have changed
|
||||||
|
ackit text find --lang JPN batman subtitles and menu text (any of the 11 languages)
|
||||||
ackit export-mod my.acmod / import-mod my.acmod share your changes
|
ackit export-mod my.acmod / import-mod my.acmod share your changes
|
||||||
|
|
||||||
The game folder is detected from Steam (Windows, Linux, Proton); override with --game DIR.
|
The game folder is detected from Steam (Windows, Linux, Proton); override with --game DIR. --lang picks the
|
||||||
|
language (default: the game's); languages without their own voices use the INT voice lines, like the game.
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
@@ -36,7 +38,7 @@ def _workspace(a, archives=None):
|
|||||||
st = settings.load()
|
st = settings.load()
|
||||||
game = _game(a)
|
game = _game(a)
|
||||||
if archives is None and getattr(a, "lang", None):
|
if archives is None and getattr(a, "lang", None):
|
||||||
archives = [a.lang, "SFX", "BANK"]
|
archives = [game.voice_archive(a.lang), "SFX", "BANK"]
|
||||||
return Workspace(game, a.store or st.get("store_root"), archives,
|
return Workspace(game, a.store or st.get("store_root"), archives,
|
||||||
full_backup=not getattr(a, "no_full_backup", False) and st.get("full_backup", True),
|
full_backup=not getattr(a, "no_full_backup", False) and st.get("full_backup", True),
|
||||||
progress=lambda n, t: None)
|
progress=lambda n, t: None)
|
||||||
@@ -73,7 +75,8 @@ def cmd_list(a):
|
|||||||
c = a.character.lower()
|
c = a.character.lower()
|
||||||
rows = [s for s in rows if c in s.character.lower() or (a.inferred and c in s.characters_inferred.lower())]
|
rows = [s for s in rows if c in s.character.lower() or (a.inferred and c in s.characters_inferred.lower())]
|
||||||
if a.modified:
|
if a.modified:
|
||||||
rows = [s for s in rows if s.key in ws.modified_keys()]
|
mod = set(ws.modified_keys())
|
||||||
|
rows = [s for s in rows if s.key in mod]
|
||||||
if a.csv:
|
if a.csv:
|
||||||
from .catalog import write_csv
|
from .catalog import write_csv
|
||||||
write_csv(a.csv, rows)
|
write_csv(a.csv, rows)
|
||||||
@@ -178,6 +181,23 @@ def cmd_revert(a):
|
|||||||
print("reverted %d sound(s)" % len(done))
|
print("reverted %d sound(s)" % len(done))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_restore_backup(a):
|
||||||
|
ws = _workspace(a)
|
||||||
|
full = sorted(ws.store.data.get("full", {}))
|
||||||
|
if not full:
|
||||||
|
print("no full copies in this store (%s)" % ws.store.root)
|
||||||
|
return
|
||||||
|
if not a.yes:
|
||||||
|
print("would copy back (or rebuild) %d file(s) from the full copies in %s:" % (len(full), ws.store.root))
|
||||||
|
for rel in full:
|
||||||
|
print(" " + rel)
|
||||||
|
print("Sounds and texts in these files are forgotten; sounds of WADs without a full copy stay recorded.\n"
|
||||||
|
"Use this only if the originals store is damaged (normally: ackit revert --all). Run again with --yes.")
|
||||||
|
return
|
||||||
|
done = ws.restore_from_full_backup(progress=print)
|
||||||
|
print("restored %d file(s); %d sound(s) still recorded" % (len(done), len(ws.store.entries)))
|
||||||
|
|
||||||
|
|
||||||
def cmd_status(a):
|
def cmd_status(a):
|
||||||
ws = _workspace(a)
|
ws = _workspace(a)
|
||||||
keys = ws.modified_keys()
|
keys = ws.modified_keys()
|
||||||
@@ -286,7 +306,7 @@ def cmd_game(a):
|
|||||||
from .workspace import default_store_root
|
from .workspace import default_store_root
|
||||||
print("game folder: ", g.root)
|
print("game folder: ", g.root)
|
||||||
print("archives: ", ", ".join("%s=%s" % (k, g.rel(v)) for k, v in g.archives.items()))
|
print("archives: ", ", ".join("%s=%s" % (k, g.rel(v)) for k, v in g.archives.items()))
|
||||||
print("speech language: ", g.language())
|
print("language: ", "%s (voices: %s)" % (g.language(), g.voice_archive(g.language())))
|
||||||
print("originals store: ", a.store or st.get("store_root") or default_store_root(g))
|
print("originals store: ", a.store or st.get("store_root") or default_store_root(g))
|
||||||
print("settings file: ", settings.path())
|
print("settings file: ", settings.path())
|
||||||
|
|
||||||
@@ -311,7 +331,7 @@ def cmd_verify(a):
|
|||||||
ok = selfcheck.run(ws, log=print)
|
ok = selfcheck.run(ws, log=print)
|
||||||
sys.exit(0 if ok else 1)
|
sys.exit(0 if ok else 1)
|
||||||
from .verify import ingame
|
from .verify import ingame
|
||||||
if a.lang and a.lang != ws.game.language():
|
if a.lang and a.lang != ws.game.language(): # voices and texts follow the game's setting
|
||||||
raise GameError("the game plays its own language (%s); set it in Steam (game properties > Language) to "
|
raise GameError("the game plays its own language (%s); set it in Steam (game properties > Language) to "
|
||||||
"test %s in the game" % (ws.game.language(), a.lang))
|
"test %s in the game" % (ws.game.language(), a.lang))
|
||||||
if a.banks:
|
if a.banks:
|
||||||
@@ -371,9 +391,12 @@ def build_parser():
|
|||||||
s.add_argument("name"); s.add_argument("audio")
|
s.add_argument("name"); s.add_argument("audio")
|
||||||
s = add("apply", cmd_apply, "replace every sound named by the files in a folder", (enc,))
|
s = add("apply", cmd_apply, "replace every sound named by the files in a folder", (enc,))
|
||||||
s.add_argument("folder")
|
s.add_argument("folder")
|
||||||
s = add("revert", cmd_revert, "put original sounds back", aliases=["restore"])
|
s = add("revert", cmd_revert, "put original sounds back (--all: every sound and text)", aliases=["restore"])
|
||||||
s.add_argument("names", nargs="*"); s.add_argument("--all", action="store_true")
|
s.add_argument("names", nargs="*"); s.add_argument("--all", action="store_true")
|
||||||
add("status", cmd_status, "list the sounds you modified")
|
add("status", cmd_status, "list the sounds and texts you modified")
|
||||||
|
s = add("restore-backup", cmd_restore_backup, "last resort if the originals store is damaged: put back the "
|
||||||
|
"full file copies it keeps (lists them; --yes to do it)")
|
||||||
|
s.add_argument("--yes", action="store_true")
|
||||||
s = add("text", cmd_text, "subtitles and menu text: find WORDS | show KEY | set KEY TEXT | revert KEY/--all")
|
s = add("text", cmd_text, "subtitles and menu text: find WORDS | show KEY | set KEY TEXT | revert KEY/--all")
|
||||||
s.add_argument("action", choices=["find", "show", "set", "revert"])
|
s.add_argument("action", choices=["find", "show", "set", "revert"])
|
||||||
s.add_argument("args", nargs="*")
|
s.add_argument("args", nargs="*")
|
||||||
|
|||||||
@@ -123,6 +123,14 @@ class Game:
|
|||||||
def languages(self):
|
def languages(self):
|
||||||
return [a for a in self.archives if a != "SFX"]
|
return [a for a in self.archives if a != "SFX"]
|
||||||
|
|
||||||
|
def voice_archive(self, lang):
|
||||||
|
"""The speech archive used for a language: its own, or INT for the languages the game has no voices for
|
||||||
|
(the game plays the INT voices with their own subtitles and menu text)."""
|
||||||
|
lang = (lang or "INT").upper()
|
||||||
|
if lang in self.languages:
|
||||||
|
return lang
|
||||||
|
return "INT" if "INT" in self.languages else self.languages[0]
|
||||||
|
|
||||||
def wad(self, archive):
|
def wad(self, archive):
|
||||||
try:
|
try:
|
||||||
return self.archives[archive.upper()]
|
return self.archives[archive.upper()]
|
||||||
@@ -153,8 +161,16 @@ class Game:
|
|||||||
dirs.append(os.path.join(pfx, docs, "WB Games", "Batman Arkham City GOTY", "BmGame", "Config"))
|
dirs.append(os.path.join(pfx, docs, "WB Games", "Batman Arkham City GOTY", "BmGame", "Config"))
|
||||||
return [d for d in dirs if os.path.isdir(d)]
|
return [d for d in dirs if os.path.isdir(d)]
|
||||||
|
|
||||||
|
def all_languages(self):
|
||||||
|
"""Every language of the game: the voice languages (speech WADs) and the text languages (folders of
|
||||||
|
BmGame/Localization; the game plays the INT voices for those without their own)."""
|
||||||
|
loc = _iglob_one(self.root, "BmGame", "Localization")
|
||||||
|
texts = [d.upper() for d in os.listdir(loc) if os.path.isdir(os.path.join(loc, d))] if loc else []
|
||||||
|
return sorted(set(self.languages) | set(texts))
|
||||||
|
|
||||||
def language(self):
|
def language(self):
|
||||||
"""Speech language the game is set to (falls back to INT, then the first available)."""
|
"""The language the game is set to (voices or texts only, e.g. JPN; `voice_archive` gives its speech
|
||||||
|
archive); falls back to INT, then the first voice language."""
|
||||||
for d in self.config_dirs():
|
for d in self.config_dirs():
|
||||||
for f in ("UserEngine.ini", "BmEngine.ini"):
|
for f in ("UserEngine.ini", "BmEngine.ini"):
|
||||||
p = os.path.join(d, f)
|
p = os.path.join(d, f)
|
||||||
@@ -163,7 +179,7 @@ class Game:
|
|||||||
raw = open(p, "rb").read()
|
raw = open(p, "rb").read()
|
||||||
txt = raw.decode("utf-16") if raw[:2] in (b"\xff\xfe", b"\xfe\xff") else raw.decode("latin-1")
|
txt = raw.decode("utf-16") if raw[:2] in (b"\xff\xfe", b"\xfe\xff") else raw.decode("latin-1")
|
||||||
m = re.search(r"^\s*Language\s*=\s*(\w+)", txt, re.M | re.I)
|
m = re.search(r"^\s*Language\s*=\s*(\w+)", txt, re.M | re.I)
|
||||||
if m and m.group(1).upper() in self.archives:
|
if m and m.group(1).upper() in self.all_languages():
|
||||||
return m.group(1).upper()
|
return m.group(1).upper()
|
||||||
if "INT" in self.archives:
|
if "INT" in self.archives:
|
||||||
return "INT"
|
return "INT"
|
||||||
|
|||||||
@@ -64,6 +64,21 @@ def parse(raw, lang, fname):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def encoding(raw):
|
||||||
|
"""Encoding of a file's bytes: 'utf-16', 'utf-8' or 'cp1252'."""
|
||||||
|
return "utf-16" if raw[:2] == b"\xff\xfe" else "utf-8" if raw[:3] == b"\xef\xbb\xbf" else "cp1252"
|
||||||
|
|
||||||
|
|
||||||
|
def check_value(value, enc, fname):
|
||||||
|
"""Raise TextError if `value` cannot be written into a file of encoding `enc` (what `write` refuses)."""
|
||||||
|
if "\r" in value or "\n" in value:
|
||||||
|
raise TextError("a menu text cannot contain line breaks (use the game's own markers, e.g. \\n)")
|
||||||
|
try:
|
||||||
|
_encode(value, enc)
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
raise TextError("%s is stored as %s and cannot hold these characters" % (fname, enc)) from None
|
||||||
|
|
||||||
|
|
||||||
def write(raw, lang, fname, values):
|
def write(raw, lang, fname, values):
|
||||||
"""New file bytes with {entry key: new value} applied (keys not in the file raise TextError)."""
|
"""New file bytes with {entry key: new value} applied (keys not in the file raise TextError)."""
|
||||||
text, enc = _decode(raw)
|
text, enc = _decode(raw)
|
||||||
@@ -73,8 +88,7 @@ def write(raw, lang, fname, values):
|
|||||||
e = by_key.get(key)
|
e = by_key.get(key)
|
||||||
if e is None:
|
if e is None:
|
||||||
raise TextError("no text %s in %s" % (key, fname))
|
raise TextError("no text %s in %s" % (key, fname))
|
||||||
if "\r" in value or "\n" in value:
|
check_value(value, enc, fname)
|
||||||
raise TextError("a menu text cannot contain line breaks (use the game's own markers, e.g. \\n)")
|
|
||||||
line = lines[e.line]
|
line = lines[e.line]
|
||||||
end = line[len(line.rstrip("\r\n")):]
|
end = line[len(line.rstrip("\r\n")):]
|
||||||
head = line[:line.index("=") + 1]
|
head = line[:line.index("=") + 1]
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def export_mod(ws, path, keys, name="", author="", description="", texts=()):
|
|||||||
e = ws.store.get(key)
|
e = ws.store.get(key)
|
||||||
if not e or not e.get("applied"):
|
if not e or not e.get("applied"):
|
||||||
raise ValueError("%s is not modified" % key)
|
raise ValueError("%s is not modified" % key)
|
||||||
s = ws.catalog.by_key.get(key)
|
s = ws.sound_for_key(key) # also a change made in another language (its name and line)
|
||||||
wem = ws.store.get_blob(e["applied"])
|
wem = ws.store.get_blob(e["applied"])
|
||||||
x, sr = audio.decode_wem(wem)
|
x, sr = audio.decode_wem(wem)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
|
|||||||
@@ -36,12 +36,33 @@ class SubtitleError(ValueError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Windows-1252 as Windows itself maps it: the 5 bytes Python's cp1252 leaves undefined stand for the same code
|
||||||
|
# points (U+0081 ...). One line is UTF-8 bytes in a cp1252 FString in 4 languages (Speech_TAPE_J_Answer_1_00,
|
||||||
|
# docs/formats/subtitles.md); the RUS and KOR ones contain such bytes. Read and written back losslessly, as stored.
|
||||||
|
_CP1252_UNDEFINED = frozenset((0x81, 0x8D, 0x8F, 0x90, 0x9D))
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_cp1252(raw):
|
||||||
|
try:
|
||||||
|
return raw.decode("cp1252")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return "".join(chr(x) if x in _CP1252_UNDEFINED else bytes([x]).decode("cp1252") for x in raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_cp1252(text):
|
||||||
|
"""UnicodeEncodeError for a character that is not in Windows-1252."""
|
||||||
|
try:
|
||||||
|
return text.encode("cp1252")
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
return b"".join(bytes([ord(c)]) if ord(c) in _CP1252_UNDEFINED else c.encode("cp1252") for c in text)
|
||||||
|
|
||||||
|
|
||||||
def read_fstring(b, o):
|
def read_fstring(b, o):
|
||||||
"""-> (text, end offset, enc)"""
|
"""-> (text, end offset, enc)"""
|
||||||
n = struct.unpack_from("<i", b, o)[0]
|
n = struct.unpack_from("<i", b, o)[0]
|
||||||
if n >= 0:
|
if n >= 0:
|
||||||
raw = bytes(b[o + 4:o + 4 + n])
|
raw = bytes(b[o + 4:o + 4 + n])
|
||||||
return raw.split(b"\0", 1)[0].decode("cp1252", "replace"), o + 4 + n, "a"
|
return _decode_cp1252(raw.split(b"\0", 1)[0]), o + 4 + n, "a"
|
||||||
raw = bytes(b[o + 4:o + 4 - 2 * n])
|
raw = bytes(b[o + 4:o + 4 - 2 * n])
|
||||||
return raw.decode("utf-16-le", "replace").split("\0", 1)[0], o + 4 - 2 * n, "w"
|
return raw.decode("utf-16-le", "replace").split("\0", 1)[0], o + 4 - 2 * n, "w"
|
||||||
|
|
||||||
@@ -53,7 +74,7 @@ def encode_fstring(text, size, enc):
|
|||||||
raise SubtitleError("the text must not contain NUL characters")
|
raise SubtitleError("the text must not contain NUL characters")
|
||||||
if enc == "a":
|
if enc == "a":
|
||||||
try:
|
try:
|
||||||
raw = text.encode("cp1252")
|
raw = _encode_cp1252(text)
|
||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
raise SubtitleError("this subtitle is stored as Western European (Windows-1252) text; '%s' cannot be "
|
raise SubtitleError("this subtitle is stored as Western European (Windows-1252) text; '%s' cannot be "
|
||||||
"written"
|
"written"
|
||||||
@@ -72,7 +93,7 @@ def encode_fstring(text, size, enc):
|
|||||||
|
|
||||||
def _cp1252(c):
|
def _cp1252(c):
|
||||||
try:
|
try:
|
||||||
c.encode("cp1252")
|
_encode_cp1252(c)
|
||||||
return True
|
return True
|
||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
return False
|
return False
|
||||||
|
|||||||
131
ackit/texts.py
131
ackit/texts.py
@@ -5,31 +5,45 @@ Keys (also used in mod packages):
|
|||||||
MENU:<LANG>:<File>:<Section>:<Key>[#n] a menu/UI text (docs/formats/text.md)
|
MENU:<LANG>:<File>:<Section>:<Key>[#n] a menu/UI text (docs/formats/text.md)
|
||||||
Records live in the originals store (data["texts"]): {key: {"orig", "applied", "files", ...}}. The files
|
Records live in the originals store (data["texts"]): {key: {"orig", "applied", "files", ...}}. The files
|
||||||
holding the texts are derived files of the Workspace: each is rebuilt from its original copy plus every edit
|
holding the texts are derived files of the Workspace: each is rebuilt from its original copy plus every edit
|
||||||
applied now (sounds and texts), so reverting the last edit restores it byte-for-byte.
|
applied now (sounds and texts), so reverting the last edit restores it byte-for-byte. Writes hold the
|
||||||
|
workspace's lock (Workspace.lock).
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from . import loctext, subtitles
|
from . import banks, loctext, subtitles
|
||||||
from .catalog import load_subtitles
|
from .catalog import _shipped_index, catalog_dir, load_subtitles
|
||||||
from .game import GameError
|
from .game import GameError
|
||||||
|
|
||||||
ORIGINAL, MODIFIED, LOST, CHANGED, FOREIGN = "original", "modified", "lost", "changed", "foreign"
|
ORIGINAL, MODIFIED, LOST, CHANGED, FOREIGN = "original", "modified", "lost", "changed", "foreign"
|
||||||
|
|
||||||
|
|
||||||
|
def _locked(f):
|
||||||
|
"""Run a Texts method holding the workspace's write lock."""
|
||||||
|
def run(self, *a, **kw):
|
||||||
|
with self.ws.lock:
|
||||||
|
return f(self, *a, **kw)
|
||||||
|
run.__name__, run.__doc__ = f.__name__, f.__doc__
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
class TextConflictError(GameError):
|
class TextConflictError(GameError):
|
||||||
def __init__(self, keys):
|
def __init__(self, keys):
|
||||||
self.keys = keys
|
self.keys = keys
|
||||||
super().__init__("These texts were changed outside this tool (Steam update or 'verify', or another mod "
|
super().__init__("These texts were changed outside this tool (Steam update or 'verify', or another mod "
|
||||||
"tool): %s.\nRevert them or confirm to overwrite." % ", ".join(keys[:5])
|
"tool): %s.\nRevert them or confirm to overwrite."
|
||||||
+ (" ..." if len(keys) > 5 else ""))
|
% (", ".join(keys[:5]) + (" ..." if len(keys) > 5 else "")))
|
||||||
|
|
||||||
|
|
||||||
class Texts:
|
class Texts:
|
||||||
def __init__(self, ws):
|
def __init__(self, ws):
|
||||||
self.ws = ws
|
self.ws = ws
|
||||||
self._subs = {} # lang -> ([SubLine], {key: [SubLoc]})
|
self._subs = {} # lang -> ([SubLine], {key: [SubLoc]})
|
||||||
|
self._sub_text = {} # lang -> {(speech, line): original text} (catalog)
|
||||||
self._menu = {} # lang -> [Entry]
|
self._menu = {} # lang -> [Entry]
|
||||||
|
self._menu_paths = {} # lang -> {file name: path}
|
||||||
|
self._menu_file = {} # path -> ((size, mtime), encoding, {entry key: Entry})
|
||||||
|
self._shipped_subs = None # (by file: [SubLoc], {(lang, speech, line): text}) of the shipped catalog
|
||||||
self.loc_root = _find_dir(ws.game.root, "BmGame", "Localization")
|
self.loc_root = _find_dir(ws.game.root, "BmGame", "Localization")
|
||||||
|
|
||||||
# --- languages and lists ---------------------------------------------------------------------
|
# --- languages and lists ---------------------------------------------------------------------
|
||||||
@@ -37,7 +51,6 @@ class Texts:
|
|||||||
return loctext.languages(self.loc_root) if self.loc_root else []
|
return loctext.languages(self.loc_root) if self.loc_root else []
|
||||||
|
|
||||||
def subtitle_languages(self):
|
def subtitle_languages(self):
|
||||||
from .catalog import _shipped_index
|
|
||||||
langs = sorted(_shipped_index().get("SUB", {}).get("lines", {}))
|
langs = sorted(_shipped_index().get("SUB", {}).get("lines", {}))
|
||||||
return langs or self.menu_languages()
|
return langs or self.menu_languages()
|
||||||
|
|
||||||
@@ -54,8 +67,11 @@ class Texts:
|
|||||||
|
|
||||||
def for_sound(self, sound, lang=None):
|
def for_sound(self, sound, lang=None):
|
||||||
"""Subtitle lines of a speech sound, in `lang` (default: the sound's own language). Languages without
|
"""Subtitle lines of a speech sound, in `lang` (default: the sound's own language). Languages without
|
||||||
their own voices link their subtitles to the INT sounds, so e.g. JPN subtitles are found for INT lines."""
|
their own voices link their subtitles to the INT sounds, so e.g. JPN subtitles are found for INT lines.
|
||||||
lang = lang or (sound.languages[0] if sound.languages else sound.archive)
|
A sound shared by every language (an in-memory line stored once) defaults to the game's language."""
|
||||||
|
game = self.ws.game
|
||||||
|
lang = lang or (sound.languages[0] if sound.languages else
|
||||||
|
sound.archive if sound.archive in game.languages else game.language())
|
||||||
if lang not in self.subtitle_languages():
|
if lang not in self.subtitle_languages():
|
||||||
return []
|
return []
|
||||||
return [s for s in self.subtitles(lang) if sound.key in s.sounds]
|
return [s for s in self.subtitles(lang) if sound.key in s.sounds]
|
||||||
@@ -77,20 +93,44 @@ class Texts:
|
|||||||
def modified(self):
|
def modified(self):
|
||||||
return [k for k, r in self.records().items() if r.get("applied") is not None]
|
return [k for k, r in self.records().items() if r.get("applied") is not None]
|
||||||
|
|
||||||
|
def _menu_path(self, key):
|
||||||
|
"""Path of the file holding menu text `key` (MENU:<LANG>:<File>:...)."""
|
||||||
|
parts = key.split(":", 3)
|
||||||
|
if len(parts) < 4 or not self.loc_root:
|
||||||
|
raise GameError("no menu text %s" % key[len("MENU:"):])
|
||||||
|
lang = parts[1]
|
||||||
|
if lang not in self._menu_paths:
|
||||||
|
self._menu_paths[lang] = loctext.files(self.loc_root, lang)
|
||||||
|
path = self._menu_paths[lang].get(parts[2])
|
||||||
|
if not path:
|
||||||
|
raise GameError("no menu text %s" % key[len("MENU:"):])
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _menu_entries(self, path, lang):
|
||||||
|
"""(encoding, {entry key: Entry}) of one menu file as it is now (parsed again only when it changed)."""
|
||||||
|
st = os.stat(path)
|
||||||
|
stamp = (st.st_size, st.st_mtime_ns)
|
||||||
|
hit = self._menu_file.get(path)
|
||||||
|
if not hit or hit[0] != stamp:
|
||||||
|
raw = open(path, "rb").read()
|
||||||
|
hit = (stamp, loctext.encoding(raw),
|
||||||
|
{e.key: e for e in loctext.parse(raw, lang.upper(), os.path.basename(path))})
|
||||||
|
self._menu_file[path] = hit
|
||||||
|
return hit[1], hit[2]
|
||||||
|
|
||||||
def _menu_entry(self, key):
|
def _menu_entry(self, key):
|
||||||
lang = key.split(":")[1]
|
path = self._menu_path(key)
|
||||||
raw = key[len("MENU:"):]
|
e = self._menu_entries(path, key.split(":")[1])[1].get(key[len("MENU:"):])
|
||||||
for e in loctext.load(self.loc_root, lang):
|
if e is None:
|
||||||
if e.key == raw:
|
raise GameError("no menu text %s" % key[len("MENU:"):])
|
||||||
return e
|
return e
|
||||||
raise GameError("no menu text %s" % raw)
|
|
||||||
|
|
||||||
def files(self, key):
|
def files(self, key):
|
||||||
"""Absolute paths of the files holding a text."""
|
"""Absolute paths of the files holding a text."""
|
||||||
if key.startswith("SUB:"):
|
if key.startswith("SUB:"):
|
||||||
return sorted({self.ws.bank_path(l.file) for l in self.subtitle_locations(key)})
|
return sorted({self.ws.bank_path(l.file) for l in self.subtitle_locations(key)})
|
||||||
e = self._menu_entry(key)
|
self._menu_entry(key)
|
||||||
return [loctext.files(self.loc_root, e.lang)[e.file]]
|
return [self._menu_path(key)]
|
||||||
|
|
||||||
def current(self, key):
|
def current(self, key):
|
||||||
"""The text in the game files now."""
|
"""The text in the game files now."""
|
||||||
@@ -98,22 +138,20 @@ class Texts:
|
|||||||
locs = self.subtitle_locations(key)
|
locs = self.subtitle_locations(key)
|
||||||
if not locs:
|
if not locs:
|
||||||
raise GameError("no subtitle %s" % key)
|
raise GameError("no subtitle %s" % key)
|
||||||
from .banks import open_file
|
|
||||||
l = locs[0]
|
l = locs[0]
|
||||||
b = open_file(self.ws.bank_path(l.file)).read(l.pos, l.size)
|
b = banks.open_file(self.ws.bank_path(l.file)).read(l.pos, l.size)
|
||||||
return subtitles.read_fstring(b, 0)[0]
|
return subtitles.read_fstring(b, 0)[0]
|
||||||
return self._menu_entry(key).value
|
return self._menu_entry(key).value
|
||||||
|
|
||||||
def catalog_text(self, key):
|
def catalog_text(self, key):
|
||||||
"""Original text according to the shipped catalog (subtitles only), or None."""
|
"""Original text according to the catalog (subtitles only), or None."""
|
||||||
if not key.startswith("SUB:"):
|
if not key.startswith("SUB:"):
|
||||||
return None
|
return None
|
||||||
_, lang, rest = key.split(":", 2)
|
_, lang, rest = key.split(":", 2)
|
||||||
speech, line = rest.rsplit(":", 1)
|
speech, line = rest.rsplit(":", 1)
|
||||||
for s in self.subtitles(lang):
|
if lang not in self._sub_text:
|
||||||
if s.speech == speech and s.line == int(line):
|
self._sub_text[lang] = {(s.speech, s.line): s.text for s in self.subtitles(lang)}
|
||||||
return s.text
|
return self._sub_text[lang].get((speech, int(line)))
|
||||||
return None
|
|
||||||
|
|
||||||
def original(self, key):
|
def original(self, key):
|
||||||
r = self.records().get(key)
|
r = self.records().get(key)
|
||||||
@@ -141,24 +179,54 @@ class Texts:
|
|||||||
for l in self.subtitle_locations(key)), default=0)
|
for l in self.subtitle_locations(key)), default=0)
|
||||||
|
|
||||||
def check(self, key, text):
|
def check(self, key, text):
|
||||||
"""Raise SubtitleError / TextError if `text` cannot be written for `key`."""
|
"""Raise SubtitleError / TextError (ValueError) if `text` cannot be written for `key`, GameError if
|
||||||
|
there is no such text."""
|
||||||
if key.startswith("SUB:"):
|
if key.startswith("SUB:"):
|
||||||
for l in self.subtitle_locations(key):
|
for l in self.subtitle_locations(key):
|
||||||
subtitles.encode_fstring(text, l.size, l.enc)
|
subtitles.encode_fstring(text, l.size, l.enc)
|
||||||
else:
|
else:
|
||||||
e = self._menu_entry(key)
|
path = self._menu_path(key)
|
||||||
path = loctext.files(self.loc_root, e.lang)[e.file]
|
self._menu_entry(key)
|
||||||
loctext.write(open(path, "rb").read(), e.lang, e.file, {e.key: text})
|
loctext.check_value(text, self._menu_entries(path, key.split(":")[1])[0], os.path.basename(path))
|
||||||
|
|
||||||
|
def unoriginal_subtitles(self, rel):
|
||||||
|
"""Subtitle strings in package `rel` (cooked-relative) that differ from the shipped catalog: [SUB key]
|
||||||
|
(none if the catalog is not the game's)."""
|
||||||
|
if self._shipped_subs is None:
|
||||||
|
self._shipped_subs = ({}, {})
|
||||||
|
shipped = _shipped_index().get("SUB", {})
|
||||||
|
loc = os.path.join(catalog_dir(), subtitles.LOCATIONS)
|
||||||
|
if shipped.get("fingerprint") == banks.game_fingerprint(self.ws.game.cooked) and os.path.exists(loc):
|
||||||
|
by_file, texts = self._shipped_subs
|
||||||
|
for ls in subtitles.read_locations(loc).values():
|
||||||
|
for l in ls:
|
||||||
|
by_file.setdefault(l.file, []).append(l)
|
||||||
|
for lang in shipped.get("lines", {}):
|
||||||
|
p = os.path.join(catalog_dir(), "SUB_%s.csv" % lang)
|
||||||
|
if os.path.exists(p):
|
||||||
|
texts.update({(lang, s.speech, s.line): s.text for s in subtitles.read_texts(p, lang)})
|
||||||
|
by_file, texts = self._shipped_subs
|
||||||
|
out = []
|
||||||
|
locs = sorted(by_file.get(rel, []), key=lambda l: l.pos)
|
||||||
|
if locs:
|
||||||
|
r = banks.open_file(self.ws.bank_path(rel))
|
||||||
|
for l in locs:
|
||||||
|
want = texts.get((l.lang, l.speech, l.line))
|
||||||
|
if want is not None and subtitles.read_fstring(r.read(l.pos, l.size), 0)[0] != want:
|
||||||
|
out.append(subtitles.key(l.lang, l.speech, l.line))
|
||||||
|
return out
|
||||||
|
|
||||||
# --- writing --------------------------------------------------------------------------------
|
# --- writing --------------------------------------------------------------------------------
|
||||||
|
@_locked
|
||||||
def apply(self, changes, force=False, progress=None, meta=None):
|
def apply(self, changes, force=False, progress=None, meta=None):
|
||||||
"""changes: {key: new text}. Every file holding one of them is rebuilt once."""
|
"""changes: {key: new text}. Every file holding one of them is rebuilt once."""
|
||||||
for k, v in changes.items():
|
for k, v in changes.items():
|
||||||
self.check(k, v)
|
self.check(k, v)
|
||||||
conflicts = [k for k in changes if self.state(k) in (CHANGED, FOREIGN)]
|
conflicts = [k for k in changes if self.state(k) in (CHANGED, FOREIGN)]
|
||||||
paths = {p for k in changes for p in self.files(k)}
|
files = {k: self.files(k) for k in changes}
|
||||||
changed = set(self.ws.changed_files(paths))
|
paths = {p for ps in files.values() for p in ps}
|
||||||
conflicts += [k for k in changes if k not in conflicts and set(self.files(k)) & changed]
|
changed = set(self.ws.changed_files(paths)) | set(self.ws.unoriginal_files(paths))
|
||||||
|
conflicts += [k for k in changes if k not in conflicts and set(files[k]) & changed]
|
||||||
if conflicts and not force:
|
if conflicts and not force:
|
||||||
raise TextConflictError(conflicts)
|
raise TextConflictError(conflicts)
|
||||||
for p in paths:
|
for p in paths:
|
||||||
@@ -167,12 +235,13 @@ class Texts:
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
for k, v in changes.items():
|
for k, v in changes.items():
|
||||||
if k not in recs:
|
if k not in recs:
|
||||||
recs[k] = {"orig": self.current(k), "files": [self.ws.game.rel(p) for p in self.files(k)],
|
recs[k] = {"orig": self.current(k), "files": [self.ws.game.rel(p) for p in files[k]],
|
||||||
"first_modified": now, "applied": None}
|
"first_modified": now, "applied": None}
|
||||||
recs[k].update(applied=v, applied_at=now, meta=meta or {})
|
recs[k].update(applied=v, applied_at=now, meta=meta or {})
|
||||||
self.ws.store.save()
|
self.ws.store.save()
|
||||||
self._sync(paths, progress)
|
self._sync(paths, progress)
|
||||||
|
|
||||||
|
@_locked
|
||||||
def revert(self, keys, progress=None):
|
def revert(self, keys, progress=None):
|
||||||
recs = self.records()
|
recs = self.records()
|
||||||
keys = [k for k in keys if k in recs]
|
keys = [k for k in keys if k in recs]
|
||||||
|
|||||||
17
ackit/upk.py
17
ackit/upk.py
@@ -8,6 +8,7 @@ Patching rewrites only the blocks that contain changed bytes (stored as one LZO
|
|||||||
lzo.compress_stored) and moves the chunks after them; every other byte of the file stays identical.
|
lzo.compress_stored) and moves the chunks after them; every other byte of the file stays identical.
|
||||||
"""
|
"""
|
||||||
import struct
|
import struct
|
||||||
|
import threading
|
||||||
from collections import OrderedDict, namedtuple
|
from collections import OrderedDict, namedtuple
|
||||||
|
|
||||||
from . import lzo
|
from . import lzo
|
||||||
@@ -41,6 +42,7 @@ class Package:
|
|||||||
self.file_size = f.seek(0, 2)
|
self.file_size = f.seek(0, 2)
|
||||||
self._cache = OrderedDict()
|
self._cache = OrderedDict()
|
||||||
self._cache_n = cache_blocks
|
self._cache_n = cache_blocks
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
# summary -----------------------------------------------------------------------------------
|
# summary -----------------------------------------------------------------------------------
|
||||||
def _parse_summary(self, b, f):
|
def _parse_summary(self, b, f):
|
||||||
@@ -157,9 +159,11 @@ class Package:
|
|||||||
|
|
||||||
# reading -----------------------------------------------------------------------------------
|
# reading -----------------------------------------------------------------------------------
|
||||||
def _block(self, i, f=None):
|
def _block(self, i, f=None):
|
||||||
if i in self._cache:
|
with self._lock: # the editor reads one package from several background jobs
|
||||||
self._cache.move_to_end(i)
|
data = self._cache.get(i)
|
||||||
return self._cache[i]
|
if data is not None:
|
||||||
|
self._cache.move_to_end(i)
|
||||||
|
return data
|
||||||
bl = self.blocks[i]
|
bl = self.blocks[i]
|
||||||
own = f is None
|
own = f is None
|
||||||
f = f or open(self.path, "rb")
|
f = f or open(self.path, "rb")
|
||||||
@@ -169,9 +173,10 @@ class Package:
|
|||||||
finally:
|
finally:
|
||||||
if own:
|
if own:
|
||||||
f.close()
|
f.close()
|
||||||
self._cache[i] = data
|
with self._lock:
|
||||||
if len(self._cache) > self._cache_n:
|
self._cache[i] = data
|
||||||
self._cache.popitem(last=False)
|
if len(self._cache) > self._cache_n:
|
||||||
|
self._cache.popitem(last=False)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def _block_index(self, off):
|
def _block_index(self, off):
|
||||||
|
|||||||
@@ -399,8 +399,9 @@ def check_speakers(rep, rows, col):
|
|||||||
def check_menu(rep, game):
|
def check_menu(rep, game):
|
||||||
"""Menu texts: the same keys in every language (facts; differences are the game's own)."""
|
"""Menu texts: the same keys in every language (facts; differences are the game's own)."""
|
||||||
from .. import loctext
|
from .. import loctext
|
||||||
root = os.path.join(game.root, "BmGame", "Localization")
|
from ..texts import _find_dir
|
||||||
if not os.path.isdir(root):
|
root = _find_dir(game.root, "BmGame", "Localization") # case-insensitive (Linux)
|
||||||
|
if not root:
|
||||||
return
|
return
|
||||||
keys = {}
|
keys = {}
|
||||||
for lang in loctext.languages(root):
|
for lang in loctext.languages(root):
|
||||||
@@ -443,7 +444,7 @@ def run(game, archives=None, quick=False, log=print, progress=None):
|
|||||||
check_subtitle_lines(rep, rows_all)
|
check_subtitle_lines(rep, rows_all)
|
||||||
check_speakers(rep, rows_all, col)
|
check_speakers(rep, rows_all, col)
|
||||||
check_menu(rep, game)
|
check_menu(rep, game)
|
||||||
characters = load_characters(lang=game.language())
|
characters = load_characters(lang=game.voice_archive(game.language()))
|
||||||
known = {n.lower() for s in cat.sounds for n in s.names()}
|
known = {n.lower() for s in cat.sounds for n in s.names()}
|
||||||
# speaker names are the same in every language; only meaningful for the build the lists were made for
|
# speaker names are the same in every language; only meaningful for the build the lists were made for
|
||||||
if any(a in game.languages and is_shipped(game, a) for a in archives):
|
if any(a in game.languages and is_shipped(game, a) for a in archives):
|
||||||
|
|||||||
@@ -104,9 +104,14 @@ def running_game_pids():
|
|||||||
return [int(m.group(1)) for m in re.finditer(r'"BatmanAC\.exe","(\d+)"', out)]
|
return [int(m.group(1)) for m in re.finditer(r'"BatmanAC\.exe","(\d+)"', out)]
|
||||||
|
|
||||||
|
|
||||||
def kill_game():
|
def kill_game(timeout=20):
|
||||||
|
"""Kill the game and wait until it has exited: it keeps the WADs and packages open until then, so a revert
|
||||||
|
right after taskkill would find them locked."""
|
||||||
for pid in running_game_pids():
|
for pid in running_game_pids():
|
||||||
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True)
|
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True)
|
||||||
|
t0 = time.time()
|
||||||
|
while running_game_pids() and time.time() - t0 < timeout:
|
||||||
|
time.sleep(0.25)
|
||||||
|
|
||||||
|
|
||||||
def launch_game(exe, extra_args=()):
|
def launch_game(exe, extra_args=()):
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ def run(ws, sample=300, log=print):
|
|||||||
if n >= sample // max(1, len(ws.game.archives)):
|
if n >= sample // max(1, len(ws.game.archives)):
|
||||||
break
|
break
|
||||||
log("%-4s layout %s, rebuilt %d/%d sample WEMs byte-identical" % (arch, "OK" if not probs else probs[:2], good, n))
|
log("%-4s layout %s, rebuilt %d/%d sample WEMs byte-identical" % (arch, "OK" if not probs else probs[:2], good, n))
|
||||||
ok &= not probs and good >= n - 2 # 4 looping music tracks are known exceptions
|
# known exceptions (docs/formats/wem.md): a few SFX WEMs (music seek tables and sample counts, 4-channel
|
||||||
|
# ambience with a loop chunk) and one INT speech WEM (avg-bytes/s field) are not reproduced byte-for-byte
|
||||||
|
ok &= not probs and good >= n - 2
|
||||||
bank = [s for s in ws.catalog.sounds if s.archive == "BANK"]
|
bank = [s for s in ws.catalog.sounds if s.archive == "BANK"]
|
||||||
if bank:
|
if bank:
|
||||||
n = good = 0
|
n = good = 0
|
||||||
|
|||||||
@@ -10,14 +10,19 @@ In-memory sounds (archive BANK) and the prefetch heads of speech lines live in l
|
|||||||
packages (ackit/banks.py); a replacement fills each in-memory slot exactly (wem.fill). Before the first
|
packages (ackit/banks.py); a replacement fills each in-memory slot exactly (wem.fill). Before the first
|
||||||
change to such a file a verified full copy is kept, and the
|
change to such a file a verified full copy is kept, and the
|
||||||
file is always rebuilt as "original copy + every replacement currently applied", so reverting the last
|
file is always rebuilt as "original copy + every replacement currently applied", so reverting the last
|
||||||
change restores it byte-for-byte and any later operation repairs an interrupted one.
|
change restores it byte-for-byte and any later operation repairs an interrupted one. That copy is taken only
|
||||||
|
from a file that is original according to the catalog (`unoriginal_files`), else the write is a conflict.
|
||||||
|
Writes are serialized (`Workspace.lock`): the editor runs them as background jobs, and two rebuilds of one
|
||||||
|
file at the same time would lose one of the edits.
|
||||||
"""
|
"""
|
||||||
|
import functools
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from . import audio, banks
|
from . import audio, banks
|
||||||
from .catalog import Catalog
|
from .catalog import Catalog, available_archives, is_shipped, load_archive
|
||||||
from .encoder import EncodeError, encode_fit
|
from .encoder import EncodeError, encode_fit
|
||||||
from .game import Game, GameError
|
from .game import Game, GameError
|
||||||
from .inject import inject, read_target, parse_target, pack_bnk_path, SizeLimitError
|
from .inject import inject, read_target, parse_target, pack_bnk_path, SizeLimitError
|
||||||
@@ -41,25 +46,50 @@ class ConflictError(GameError):
|
|||||||
self.sounds = sounds
|
self.sounds = sounds
|
||||||
super().__init__("These sounds were changed outside this tool (Steam update or 'verify', or "
|
super().__init__("These sounds were changed outside this tool (Steam update or 'verify', or "
|
||||||
"another mod tool): %s.\nRevert them or confirm to overwrite."
|
"another mod tool): %s.\nRevert them or confirm to overwrite."
|
||||||
% ", ".join(s.name for s in sounds[:5]) + (" ..." if len(sounds) > 5 else ""))
|
% (", ".join(s.name for s in sounds[:5]) + (" ..." if len(sounds) > 5 else "")))
|
||||||
|
|
||||||
|
|
||||||
def default_store_root(game):
|
def default_store_root(game):
|
||||||
return os.path.join(user_dir("data"), "stores", game.store_id)
|
return os.path.join(user_dir("data"), "stores", game.store_id)
|
||||||
|
|
||||||
|
|
||||||
|
def locked(f):
|
||||||
|
"""Run a Workspace method holding its write lock (Texts has the same wrapper: texts._locked)."""
|
||||||
|
@functools.wraps(f)
|
||||||
|
def run(self, *a, **kw):
|
||||||
|
with self.lock:
|
||||||
|
return f(self, *a, **kw)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
class Workspace:
|
class Workspace:
|
||||||
def __init__(self, game_dir, store_root=None, archives=None, full_backup=True, progress=None):
|
def __init__(self, game_dir, store_root=None, archives=None, full_backup=True, progress=None):
|
||||||
self.game = game_dir if isinstance(game_dir, Game) else Game(game_dir)
|
self.game = game_dir if isinstance(game_dir, Game) else Game(game_dir)
|
||||||
self.store = OriginalsStore(store_root or default_store_root(self.game), self.game.root)
|
self.store = OriginalsStore(store_root or default_store_root(self.game), self.game.root)
|
||||||
self.catalog = Catalog(self.game, archives, progress)
|
self.catalog = Catalog(self.game, archives, progress)
|
||||||
self.full_backup = full_backup
|
self.full_backup = full_backup
|
||||||
|
self.lock = threading.RLock() # one write (apply, revert, file rebuild) at a time
|
||||||
|
self._by_file = None # cooked-relative file -> [Location] (unoriginal_files)
|
||||||
|
self._shipped = None # the bank catalog is the shipped one (has checksums)
|
||||||
|
self._wad_rows = {} # voice archive -> {key: Sound} (heads' recordings)
|
||||||
|
self._other = {} # archive not loaded here -> Catalog (sound_for_key)
|
||||||
self.texts = Texts(self)
|
self.texts = Texts(self)
|
||||||
|
|
||||||
# reading -----------------------------------------------------------------------------------
|
# reading -----------------------------------------------------------------------------------
|
||||||
def sound(self, name_or_key):
|
def sound(self, name_or_key):
|
||||||
return self.catalog.resolve(name_or_key)
|
return self.catalog.resolve(name_or_key)
|
||||||
|
|
||||||
|
def sound_for_key(self, key):
|
||||||
|
"""The Sound of a key, also from an archive not loaded here (a change made in another language), or
|
||||||
|
None."""
|
||||||
|
if key in self.catalog.by_key:
|
||||||
|
return self.catalog.by_key[key]
|
||||||
|
archive = key.split(":")[0]
|
||||||
|
if archive not in self._other:
|
||||||
|
self._other[archive] = Catalog(self.game, [archive]) if archive in available_archives(self.game) \
|
||||||
|
else None
|
||||||
|
return self._other[archive].by_key.get(key) if self._other[archive] else None
|
||||||
|
|
||||||
def wad_path(self, s):
|
def wad_path(self, s):
|
||||||
return self.game.wad(s.archive)
|
return self.game.wad(s.archive)
|
||||||
|
|
||||||
@@ -241,6 +271,55 @@ class Workspace:
|
|||||||
out.append(path)
|
out.append(path)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def unoriginal_files(self, paths):
|
||||||
|
"""Bank files and packages this store has never written that are not the game's original according to
|
||||||
|
the shipped catalog: an in-memory sound, the bank copy of a voice recording or a subtitle in them differs
|
||||||
|
(another originals store or another tool changed them). Their current content must not become this
|
||||||
|
store's "original copy". Menu files and other game builds have no catalog checksums: not checked."""
|
||||||
|
out = []
|
||||||
|
for path in sorted(set(paths)):
|
||||||
|
if self._derived().get(self.game.rel(path)) or self.texts.is_menu_file(path):
|
||||||
|
continue
|
||||||
|
rel = os.path.relpath(path, self.game.cooked).replace(os.sep, "/")
|
||||||
|
if not self._bank_copies_original(rel) or self.texts.unoriginal_subtitles(rel):
|
||||||
|
out.append(path)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _bank_copies_original(self, rel):
|
||||||
|
if self._by_file is None:
|
||||||
|
self._by_file = defaultdict(list)
|
||||||
|
for ls in self.catalog.locations.values():
|
||||||
|
for l in ls:
|
||||||
|
self._by_file[l.file].append(l)
|
||||||
|
locs = sorted(self._by_file.get(rel, []), key=lambda l: l.offset)
|
||||||
|
if self._shipped is None:
|
||||||
|
self._shipped = is_shipped(self.game, banks.ARCHIVE)
|
||||||
|
if not locs or not self._shipped:
|
||||||
|
return True
|
||||||
|
r = banks.open_file(self.bank_path(rel))
|
||||||
|
for l in locs:
|
||||||
|
cur = r.read(l.offset, l.size)
|
||||||
|
if l.kind == banks.MEMORY:
|
||||||
|
want = self.catalog.bank_sha1.get(l.media)
|
||||||
|
if want and sha1_16(cur) != want:
|
||||||
|
return False
|
||||||
|
elif l.sound: # a bank copy of a WAD recording: the start of the original
|
||||||
|
orig = self._wad_original(l.sound)
|
||||||
|
if orig is None or cur != (orig + bytes(l.size))[:l.size]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _wad_original(self, key):
|
||||||
|
"""Original bytes of a WAD recording (this store's record, or the game's when it matches the catalog)."""
|
||||||
|
if self.store.get(key):
|
||||||
|
return self.store.original(key)
|
||||||
|
archive, _, target = key.partition(":")
|
||||||
|
if archive not in self._wad_rows:
|
||||||
|
self._wad_rows[archive] = {s.key: s for s in load_archive(self.game, archive)}
|
||||||
|
s = self._wad_rows[archive].get(key)
|
||||||
|
cur = read_target(self.game.wad(archive), target)
|
||||||
|
return None if s and s.sha1 and sha1_16(cur) != s.sha1 else cur
|
||||||
|
|
||||||
def _audio_edits(self, rel):
|
def _audio_edits(self, rel):
|
||||||
"""[(offset, bytes)] that the sound replacements applied now need in bank file `rel` (cooked-relative)."""
|
"""[(offset, bytes)] that the sound replacements applied now need in bank file `rel` (cooked-relative)."""
|
||||||
out = []
|
out = []
|
||||||
@@ -271,6 +350,7 @@ class Workspace:
|
|||||||
edits = self._audio_edits(rel) + self.texts.subtitle_edits(rel)
|
edits = self._audio_edits(rel) + self.texts.subtitle_edits(rel)
|
||||||
return banks.open_file(orig).patched(edits) if edits else open(orig, "rb").read()
|
return banks.open_file(orig).patched(edits) if edits else open(orig, "rb").read()
|
||||||
|
|
||||||
|
@locked
|
||||||
def sync_files(self, paths, progress=None):
|
def sync_files(self, paths, progress=None):
|
||||||
"""Rebuild each derived file from its original copy and the edits applied now (atomic writes)."""
|
"""Rebuild each derived file from its original copy and the edits applied now (atomic writes)."""
|
||||||
for path in sorted(set(paths)):
|
for path in sorted(set(paths)):
|
||||||
@@ -310,6 +390,7 @@ class Workspace:
|
|||||||
self.store.save() # the store says what the files must contain before they change
|
self.store.save() # the store says what the files must contain before they change
|
||||||
self.sync_files(files, progress)
|
self.sync_files(files, progress)
|
||||||
|
|
||||||
|
@locked
|
||||||
def apply(self, changes, force=False, progress=None):
|
def apply(self, changes, force=False, progress=None):
|
||||||
"""changes: [(sound, wem_bytes, meta dict)]. One atomic rewrite per WAD / bank file. A recording stored
|
"""changes: [(sound, wem_bytes, meta dict)]. One atomic rewrite per WAD / bank file. A recording stored
|
||||||
in two places of a WAD gets the same bytes in both (Workspace.copies)."""
|
in two places of a WAD gets the same bytes in both (Workspace.copies)."""
|
||||||
@@ -326,7 +407,8 @@ class Workspace:
|
|||||||
for s, _, _ in changes:
|
for s, _, _ in changes:
|
||||||
if self.state(s) in (CHANGED, FOREIGN):
|
if self.state(s) in (CHANGED, FOREIGN):
|
||||||
conflicts.append(s)
|
conflicts.append(s)
|
||||||
changed = set(self.changed_files([self.bank_path(l.file) for s, _, _ in changes for l in self.locations(s)]))
|
paths = [self.bank_path(l.file) for s, _, _ in changes for l in self.locations(s)]
|
||||||
|
changed = set(self.changed_files(paths)) | set(self.unoriginal_files(paths))
|
||||||
conflicts += [s for s, _, _ in changes
|
conflicts += [s for s, _, _ in changes
|
||||||
if s not in conflicts and {self.bank_path(l.file) for l in self.locations(s)} & changed]
|
if s not in conflicts and {self.bank_path(l.file) for l in self.locations(s)} & changed]
|
||||||
if conflicts and not force:
|
if conflicts and not force:
|
||||||
@@ -337,6 +419,7 @@ class Workspace:
|
|||||||
for a, items in by_arch.items():
|
for a, items in by_arch.items():
|
||||||
self._write(a, items, progress)
|
self._write(a, items, progress)
|
||||||
|
|
||||||
|
@locked
|
||||||
def revert(self, sounds, progress=None):
|
def revert(self, sounds, progress=None):
|
||||||
"""Put the stored originals back (sounds without a record are skipped). Returns reverted."""
|
"""Put the stored originals back (sounds without a record are skipped). Returns reverted."""
|
||||||
by_arch = defaultdict(list)
|
by_arch = defaultdict(list)
|
||||||
@@ -354,31 +437,50 @@ class Workspace:
|
|||||||
self.store.gc()
|
self.store.gc()
|
||||||
return done
|
return done
|
||||||
|
|
||||||
|
@locked
|
||||||
def revert_all(self, progress=None):
|
def revert_all(self, progress=None):
|
||||||
sounds = [self.catalog.by_key[k] for k in list(self.store.entries) if k in self.catalog.by_key]
|
"""Revert every sound this store has changed (texts: Texts.revert_all)."""
|
||||||
missing = [k for k in self.store.entries if k not in self.catalog.by_key]
|
# records of archives not loaded in this catalog (e.g. another language) included
|
||||||
if missing:
|
sounds = [s for s in map(self.sound_for_key, list(self.store.entries)) if s]
|
||||||
# records of archives not loaded in this catalog (e.g. another language)
|
|
||||||
other = Catalog(self.game, sorted({k.split(":")[0] for k in missing}))
|
|
||||||
sounds += [other.by_key[k] for k in missing if k in other.by_key]
|
|
||||||
return self.revert(sounds, progress)
|
return self.revert(sounds, progress)
|
||||||
|
|
||||||
def restore_from_full_backup(self):
|
@locked
|
||||||
"""Last-resort fallback: copy back every full backup taken by this store."""
|
def forget(self, keys=(), text_keys=()):
|
||||||
|
"""Stop tracking changes (the game files are kept as they are)."""
|
||||||
|
for k in keys:
|
||||||
|
self.store.forget(k)
|
||||||
|
for k in text_keys:
|
||||||
|
self.texts.records().pop(k, None)
|
||||||
|
self.store.save()
|
||||||
|
self.store.gc()
|
||||||
|
|
||||||
|
@locked
|
||||||
|
def restore_from_full_backup(self, progress=None):
|
||||||
|
"""Last resort when the originals store is damaged (`ackit restore-backup`): put back the full copies.
|
||||||
|
A WAD (and its pack indexes) is copied back and its sounds are forgotten; bank files, packages and menu
|
||||||
|
files (always copied) are rebuilt from their copies with the edits of the sounds still recorded, i.e.
|
||||||
|
those of WADs without a full copy (full backups off), which stay modified and revertible. Returns the
|
||||||
|
restored files (game-relative)."""
|
||||||
|
import shutil
|
||||||
|
full = self.store.data.get("full", {})
|
||||||
|
derived = self._derived()
|
||||||
out = []
|
out = []
|
||||||
for rel in self.store.data.get("full", {}):
|
for rel in full:
|
||||||
src = self.store.full_copy(rel)
|
if rel in derived:
|
||||||
|
continue
|
||||||
dst = os.path.join(self.game.root, rel)
|
dst = os.path.join(self.game.root, rel)
|
||||||
self.game.check_writable(dst)
|
self.game.check_writable(dst)
|
||||||
tmp = dst + ".ackit-tmp"
|
tmp = dst + ".ackit-tmp"
|
||||||
import shutil
|
shutil.copy2(self.store.full_copy(rel), tmp)
|
||||||
shutil.copy2(src, tmp)
|
|
||||||
os.replace(tmp, dst)
|
os.replace(tmp, dst)
|
||||||
out.append(rel)
|
out.append(rel)
|
||||||
for rec in self._derived().values():
|
entries = self.store.data["entries"]
|
||||||
rec["written_fp"] = None
|
for k, e in list(entries.items()):
|
||||||
|
if e["archive"] == banks.ARCHIVE or e.get("wad") in full:
|
||||||
|
del entries[k]
|
||||||
self.store.data["texts"] = {}
|
self.store.data["texts"] = {}
|
||||||
self.store.data["entries"] = {}
|
|
||||||
self.store.save()
|
self.store.save()
|
||||||
|
paths = [os.path.join(self.game.root, rel) for rel in derived if rel in full]
|
||||||
|
self.sync_files(paths, progress)
|
||||||
self.store.gc()
|
self.store.gc()
|
||||||
return out
|
return sorted(out + [rel for rel in derived if rel in full])
|
||||||
|
|||||||
@@ -163,7 +163,8 @@ class Recorder(QObject):
|
|||||||
self.chunks = []
|
self.chunks = []
|
||||||
self.rate = 48000
|
self.rate = 48000
|
||||||
self.channels = 1
|
self.channels = 1
|
||||||
self.fmt_float = False
|
self.sample = "<i2" # numpy type of the device's samples
|
||||||
|
self._rest = b"" # bytes of an incomplete frame, completed by the next read
|
||||||
self._sim = None
|
self._sim = None
|
||||||
|
|
||||||
def set_device(self, device):
|
def set_device(self, device):
|
||||||
@@ -173,7 +174,7 @@ class Recorder(QObject):
|
|||||||
return self.source is not None or self._sim is not None
|
return self.source is not None or self._sim is not None
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
self.chunks = []
|
self.chunks, self._rest = [], b""
|
||||||
dev = self.device if (self.device is not None and not self.device.isNull()) else (
|
dev = self.device if (self.device is not None and not self.device.isNull()) else (
|
||||||
QMediaDevices.defaultAudioInput() if audio_enabled() else None)
|
QMediaDevices.defaultAudioInput() if audio_enabled() else None)
|
||||||
if dev is None or dev.isNull():
|
if dev is None or dev.isNull():
|
||||||
@@ -186,7 +187,8 @@ class Recorder(QObject):
|
|||||||
if not dev.isFormatSupported(fmt):
|
if not dev.isFormatSupported(fmt):
|
||||||
fmt = dev.preferredFormat()
|
fmt = dev.preferredFormat()
|
||||||
self.rate, self.channels = fmt.sampleRate(), fmt.channelCount()
|
self.rate, self.channels = fmt.sampleRate(), fmt.channelCount()
|
||||||
self.fmt_float = fmt.sampleFormat() == QAudioFormat.SampleFormat.Float
|
F = QAudioFormat.SampleFormat
|
||||||
|
self.sample = {F.Float: "<f4", F.Int32: "<i4", F.UInt8: "u1"}.get(fmt.sampleFormat(), "<i2")
|
||||||
self.source = QAudioSource(dev, fmt, self)
|
self.source = QAudioSource(dev, fmt, self)
|
||||||
self.io = self.source.start()
|
self.io = self.source.start()
|
||||||
if self.io is None:
|
if self.io is None:
|
||||||
@@ -202,13 +204,20 @@ class Recorder(QObject):
|
|||||||
self._ingest(bytes(self.io.readAll()))
|
self._ingest(bytes(self.io.readAll()))
|
||||||
|
|
||||||
def _ingest(self, raw):
|
def _ingest(self, raw):
|
||||||
|
raw = self._rest + raw
|
||||||
|
frame = np.dtype(self.sample).itemsize * self.channels
|
||||||
|
n = len(raw) // frame * frame
|
||||||
|
raw, self._rest = raw[:n], raw[n:]
|
||||||
if not raw:
|
if not raw:
|
||||||
return
|
return
|
||||||
if self.fmt_float:
|
x = np.frombuffer(raw, dtype=self.sample)
|
||||||
x = np.frombuffer(raw, dtype="<f4")
|
if self.sample == "<i2":
|
||||||
else:
|
x = x.astype(np.float32) / 32768.0
|
||||||
x = np.frombuffer(raw[: len(raw) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
|
elif self.sample == "<i4":
|
||||||
x = x[: len(x) // self.channels * self.channels].reshape(-1, self.channels)
|
x = (x / 2147483648.0).astype(np.float32)
|
||||||
|
elif self.sample == "u1":
|
||||||
|
x = (x.astype(np.float32) - 128.0) / 128.0
|
||||||
|
x = x.reshape(-1, self.channels)
|
||||||
self.chunks.append(x)
|
self.chunks.append(x)
|
||||||
self.level.emit(float(np.max(np.abs(x))) if len(x) else 0.0)
|
self.level.emit(float(np.max(np.abs(x))) if len(x) else 0.0)
|
||||||
self.elapsed.emit(sum(len(c) for c in self.chunks) / float(self.rate))
|
self.elapsed.emit(sum(len(c) for c in self.chunks) / float(self.rate))
|
||||||
@@ -216,8 +225,8 @@ class Recorder(QObject):
|
|||||||
# simulated input (tests / no microphone) ----------------------------------------------------
|
# simulated input (tests / no microphone) ----------------------------------------------------
|
||||||
def start_simulated(self, x, rate, realtime=True):
|
def start_simulated(self, x, rate, realtime=True):
|
||||||
"""Feed `x` as if it came from a microphone (20 ms blocks)."""
|
"""Feed `x` as if it came from a microphone (20 ms blocks)."""
|
||||||
self.chunks = []
|
self.chunks, self._rest = [], b""
|
||||||
self.rate, self.channels, self.fmt_float = rate, x.shape[1], True
|
self.rate, self.channels, self.sample = rate, x.shape[1], "<f4"
|
||||||
blocks = [x[i:i + rate // 50] for i in range(0, len(x), rate // 50)]
|
blocks = [x[i:i + rate // 50] for i in range(0, len(x), rate // 50)]
|
||||||
if not realtime:
|
if not realtime:
|
||||||
for b in blocks:
|
for b in blocks:
|
||||||
|
|||||||
@@ -100,7 +100,9 @@ class EditorPanel(QWidget):
|
|||||||
gl.setVerticalSpacing(4)
|
gl.setVerticalSpacing(4)
|
||||||
self.wf_orig = self._wave("orig")
|
self.wf_orig = self._wave("orig")
|
||||||
self.wf_inst = self._wave("inst")
|
self.wf_inst = self._wave("inst")
|
||||||
self.b_play_orig = self._btn("Original", "play", lambda: self.play("orig"), "Play the original (Space)", icons.STEEL)
|
self.b_play_orig = self._btn("Original", "play", lambda: self.play("orig"),
|
||||||
|
"Play the original (double-click the row; Space until a replacement is loaded)",
|
||||||
|
icons.STEEL)
|
||||||
self.b_play_inst = self._btn("Installed", "play", lambda: self.play("inst"), "Play what the game has now", "#8f4a4e")
|
self.b_play_inst = self._btn("Installed", "play", lambda: self.play("inst"), "Play what the game has now", "#8f4a4e")
|
||||||
self.b_stop = self._btn("Stop", "stop", self.stop, "Stop (Esc)")
|
self.b_stop = self._btn("Stop", "stop", self.stop, "Stop (Esc)")
|
||||||
# playback options next to the volume: icons with tooltips
|
# playback options next to the volume: icons with tooltips
|
||||||
|
|||||||
@@ -39,8 +39,7 @@ def editor_languages(game, language=None):
|
|||||||
"""(language, voices): the selected language (default: the game's) and the speech archive used for it
|
"""(language, voices): the selected language (default: the game's) and the speech archive used for it
|
||||||
(the language itself, or INT for languages the game has no voices for)."""
|
(the language itself, or INT for languages the game has no voices for)."""
|
||||||
lang = (language or game.language() or "INT").upper()
|
lang = (language or game.language() or "INT").upper()
|
||||||
voices = lang if lang in game.languages else ("INT" if "INT" in game.languages else game.languages[0])
|
return lang, game.voice_archive(lang)
|
||||||
return lang, voices
|
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
@@ -100,7 +99,7 @@ class MainWindow(QMainWindow):
|
|||||||
tb.addWidget(self.lbl_voices)
|
tb.addWidget(self.lbl_voices)
|
||||||
tb.addSeparator()
|
tb.addSeparator()
|
||||||
self.act_revert_all = tb.addAction(icons.icon("revert"), "Revert all", self.revert_all)
|
self.act_revert_all = tb.addAction(icons.icon("revert"), "Revert all", self.revert_all)
|
||||||
self.act_revert_all.setToolTip("Put every original sound back")
|
self.act_revert_all.setToolTip("Put every original sound and text back (all languages)")
|
||||||
tb.addAction(icons.icon("export"), "Export mod…", self.export_mod_dialog)
|
tb.addAction(icons.icon("export"), "Export mod…", self.export_mod_dialog)
|
||||||
tb.addAction(icons.icon("import"), "Import mod…", self.import_mod_dialog)
|
tb.addAction(icons.icon("import"), "Import mod…", self.import_mod_dialog)
|
||||||
tb.addSeparator()
|
tb.addSeparator()
|
||||||
@@ -528,35 +527,52 @@ class MainWindow(QMainWindow):
|
|||||||
"Reverted %d sound(s)." % len(sounds))
|
"Reverted %d sound(s)." % len(sounds))
|
||||||
|
|
||||||
def revert_all(self):
|
def revert_all(self):
|
||||||
|
"""Every change of this store: sounds, subtitles and menu texts (every language)."""
|
||||||
if not self.ws:
|
if not self.ws:
|
||||||
return
|
return
|
||||||
n = len(self.ws.store.entries)
|
n, t = len(self.ws.store.entries), len(self.ws.texts.records())
|
||||||
if not n:
|
if not n and not t:
|
||||||
self.inform("Revert all", "All sounds are already original.")
|
self.inform("Revert all", "All sounds and texts are already original.")
|
||||||
return
|
return
|
||||||
if not self.confirm("Revert all", "Put the original back for all %d changed sound(s)?" % n):
|
if not self.confirm("Revert all", "Put the original back for all %d changed sound(s) and %d changed "
|
||||||
|
"text(s)?" % (n, t)):
|
||||||
return
|
return
|
||||||
self._run_change("Reverting everything…", lambda job: self.ws.revert_all(job.report),
|
|
||||||
"All sounds are original again.")
|
|
||||||
|
|
||||||
def _run_change(self, busy, fn, ok_msg):
|
def work(job):
|
||||||
|
self.ws.revert_all(job.report)
|
||||||
|
self.ws.texts.revert_all(job.report)
|
||||||
|
self._run_change("Reverting everything…", work, "All sounds and texts are original again.")
|
||||||
|
|
||||||
|
def _run_change(self, busy, fn, ok_msg, ask_force=False):
|
||||||
|
"""Run a change as a background job. ask_force: fn(job, force); when files were changed outside this
|
||||||
|
tool, ask the user and run it again with force=True."""
|
||||||
self.statusBar().showMessage(busy)
|
self.statusBar().showMessage(busy)
|
||||||
self.spinner.show()
|
self.spinner.show()
|
||||||
|
|
||||||
def done(_):
|
def done(_):
|
||||||
self.spinner.hide()
|
self.spinner.hide()
|
||||||
self.statusBar().showMessage(ok_msg, 8000)
|
self.statusBar().showMessage(ok_msg, 8000)
|
||||||
self._after_change()
|
self._refresh_after_change()
|
||||||
self._texts_changed()
|
|
||||||
if self.editor.sound:
|
|
||||||
self.editor.set_sound(self.editor.sound)
|
|
||||||
self.check_problems()
|
self.check_problems()
|
||||||
|
|
||||||
def failed(e, tb):
|
def failed(e, tb):
|
||||||
self.spinner.hide()
|
self.spinner.hide()
|
||||||
|
if self.ws:
|
||||||
|
self._refresh_after_change() # part of the change may have been written
|
||||||
|
if ask_force and isinstance(e, (W.ConflictError, TextConflictError)):
|
||||||
|
if self.confirm("Changed outside this tool", "%s\n\nOverwrite them?" % e):
|
||||||
|
self._run_change(busy, lambda job: fn(job, True), ok_msg)
|
||||||
|
return
|
||||||
self.inform("Error", str(e))
|
self.inform("Error", str(e))
|
||||||
|
|
||||||
self.runner.start(fn, done, failed, lambda m: self.statusBar().showMessage(m))
|
self.runner.start((lambda job: fn(job, False)) if ask_force else fn, done, failed,
|
||||||
|
lambda m: self.statusBar().showMessage(m))
|
||||||
|
|
||||||
|
def _refresh_after_change(self):
|
||||||
|
self._after_change()
|
||||||
|
self._texts_changed()
|
||||||
|
if self.editor.sound:
|
||||||
|
self.editor.set_sound(self.editor.sound)
|
||||||
|
|
||||||
# --- changed underneath -----------------------------------------------------------------------
|
# --- changed underneath -----------------------------------------------------------------------
|
||||||
def check_problems(self):
|
def check_problems(self):
|
||||||
@@ -628,12 +644,8 @@ class MainWindow(QMainWindow):
|
|||||||
def forget_problems(self):
|
def forget_problems(self):
|
||||||
if not self.confirm("Forget", "Stop tracking these changes? The current game files are kept as they are."):
|
if not self.confirm("Forget", "Stop tracking these changes? The current game files are kept as they are."):
|
||||||
return
|
return
|
||||||
for s, _ in getattr(self, "problems", []):
|
self.ws.forget([s.key for s, _ in getattr(self, "problems", [])],
|
||||||
self.ws.store.forget(s.key)
|
[k for k, _ in getattr(self, "text_problems", [])])
|
||||||
for k, _ in getattr(self, "text_problems", []):
|
|
||||||
self.ws.texts.records().pop(k, None)
|
|
||||||
self.ws.store.save()
|
|
||||||
self.ws.store.gc()
|
|
||||||
self.banner.hide()
|
self.banner.hide()
|
||||||
self._after_change()
|
self._after_change()
|
||||||
|
|
||||||
@@ -680,25 +692,22 @@ class MainWindow(QMainWindow):
|
|||||||
if dlg and dlg.exec() != QDialog.DialogCode.Accepted:
|
if dlg and dlg.exec() != QDialog.DialogCode.Accepted:
|
||||||
return
|
return
|
||||||
ws = self.ws
|
ws = self.ws
|
||||||
|
prepared = []
|
||||||
|
|
||||||
def work(job):
|
def work(job, force):
|
||||||
prepare_import(ws, path, items, progress=job.report)
|
if not prepared: # encode once (a confirmed overwrite runs this again)
|
||||||
|
prepare_import(ws, path, items, progress=job.report)
|
||||||
|
prepared.append(True)
|
||||||
source = {"source": "mod:" + os.path.basename(path)}
|
source = {"source": "mod:" + os.path.basename(path)}
|
||||||
changes = [(im.sound, im.wem, source) for im in items if im.wem]
|
changes = [(im.sound, im.wem, source) for im in items if im.wem]
|
||||||
if changes:
|
if changes:
|
||||||
try:
|
ws.apply(changes, force=force, progress=job.report)
|
||||||
ws.apply(changes, progress=job.report)
|
|
||||||
except W.ConflictError:
|
|
||||||
ws.apply(changes, force=True, progress=job.report)
|
|
||||||
texts = {k: v for k, v, problem in match_texts(ws, man) if not problem}
|
texts = {k: v for k, v, problem in match_texts(ws, man) if not problem}
|
||||||
if texts:
|
if texts:
|
||||||
try:
|
ws.texts.apply(texts, force=force, progress=job.report, meta=source)
|
||||||
ws.texts.apply(texts, progress=job.report, meta=source)
|
|
||||||
except TextConflictError:
|
|
||||||
ws.texts.apply(texts, force=True, progress=job.report, meta=source)
|
|
||||||
return len(changes) + len(texts)
|
return len(changes) + len(texts)
|
||||||
|
|
||||||
self._run_change("Installing mod…", work, "Installed %s" % os.path.basename(path))
|
self._run_change("Installing mod…", work, "Installed %s" % os.path.basename(path), ask_force=True)
|
||||||
|
|
||||||
# --- settings ---------------------------------------------------------------------------------
|
# --- settings ---------------------------------------------------------------------------------
|
||||||
def settings_dialog(self):
|
def settings_dialog(self):
|
||||||
@@ -706,7 +715,16 @@ class MainWindow(QMainWindow):
|
|||||||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||||
return
|
return
|
||||||
old = dict(self.settings)
|
old = dict(self.settings)
|
||||||
self.settings.update(dlg.values())
|
new = dlg.values()
|
||||||
|
if self.ws and (new.get("store_root") or None) != (old.get("store_root") or None) and (
|
||||||
|
self.ws.store.entries or self.ws.texts.records()):
|
||||||
|
# the changes are recorded in the current store only; another store would take the modified files
|
||||||
|
# as originals (and could not revert these changes)
|
||||||
|
self.inform("Settings", "The originals store keeps the originals of the changes you have applied. "
|
||||||
|
"Use Revert all before choosing another store folder; the store was not "
|
||||||
|
"changed.")
|
||||||
|
new["store_root"] = old.get("store_root")
|
||||||
|
self.settings.update(new)
|
||||||
self.save_settings()
|
self.save_settings()
|
||||||
self._apply_output_device()
|
self._apply_output_device()
|
||||||
self.sidebar.configure(self.settings)
|
self.sidebar.configure(self.settings)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ scans the game itself and applies the same rules (no checksums then; the bank sc
|
|||||||
to the lines, so a label then applies to the same recording in every language.
|
to the lines, so a label then applies to the same recording in every language.
|
||||||
3. Run `python tools/build_catalog.py` - it scans every archive and all soundbanks of the installed game (all
|
3. Run `python tools/build_catalog.py` - it scans every archive and all soundbanks of the installed game (all
|
||||||
languages present; ~5 minutes) and rewrites the CSVs, the location indexes, `characters.csv` and `index.json`.
|
languages present; ~5 minutes) and rewrites the CSVs, the location indexes, `characters.csv` and `index.json`.
|
||||||
It stops before writing anything if a sound differs from the current catalog (a modified game); after a game
|
It stops before writing anything if a sound or a subtitle differs from the current catalog (a modified game); after a game
|
||||||
update run it with `--accept-changes`.
|
update run it with `--accept-changes`.
|
||||||
4. Run `ackit verify --catalog` (no issues expected).
|
4. Run `ackit verify --catalog` (no issues expected).
|
||||||
New character labels need a display name in `ackit/classify.py: USER_LABELS` (otherwise the file name is used).
|
New character labels need a display name in `ackit/classify.py: USER_LABELS` (otherwise the file name is used).
|
||||||
|
|||||||
@@ -8138,7 +8138,7 @@ Speech_TAPE_JOKER_5_16,0,아예 샤피를 미치게 만든 건 네가 아니라
|
|||||||
Speech_TAPE_JOKER_5_17,0,고맙네.,4,w,INT:3F21770A:5
|
Speech_TAPE_JOKER_5_17,0,고맙네.,4,w,INT:3F21770A:5
|
||||||
Speech_TAPE_JOKER_5_18,0,"자, 네 과거를 사람들에게 알리고 싶지 않다면 남의 과거를 캐묻고 다니는 일은 그만둬.",48,w,INT:3F21770A:12
|
Speech_TAPE_JOKER_5_18,0,"자, 네 과거를 사람들에게 알리고 싶지 않다면 남의 과거를 캐묻고 다니는 일은 그만둬.",48,w,INT:3F21770A:12
|
||||||
Speech_TAPE_JOKER_5_19,0,의사를 하나 더 보내줘. 다른 의사의 진단도 받아보고 싶군.,33,w,INT:3F21770A:0
|
Speech_TAPE_JOKER_5_19,0,의사를 하나 더 보내줘. 다른 의사의 진단도 받아보고 싶군.,33,w,INT:3F21770A:0
|
||||||
Speech_TAPE_J_Answer_1_00,0,"한 통ì<EFBFBD>˜ ë¶€ìž,중 ì „í™”ê°€ 있습니다.",44,a,
|
Speech_TAPE_J_Answer_1_00,0,"한 í†µì˜ ë¶€ìž,중 ì „í™”ê°€ 있습니다.",44,a,
|
||||||
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_01,0,왜 진작에 이 생각을 못 했을까? 세상에 둘도 없는 최고의 친구와 직통 회선을 연결하는 거 말야.,54,w,INT:4709E5BE:0
|
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_01,0,왜 진작에 이 생각을 못 했을까? 세상에 둘도 없는 최고의 친구와 직통 회선을 연결하는 거 말야.,54,w,INT:4709E5BE:0
|
||||||
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,0,생각해 봐. 심심하면 언제든 통화 할 수 있어. 우리 사인 한층 각별해지고 있지. 이젠 이메일을 주고받거나 낭만적인 저녁 식사를 하는 일만 남았어.,82,w,INT:4709E5BE:1
|
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,0,생각해 봐. 심심하면 언제든 통화 할 수 있어. 우리 사인 한층 각별해지고 있지. 이젠 이메일을 주고받거나 낭만적인 저녁 식사를 하는 일만 남았어.,82,w,INT:4709E5BE:1
|
||||||
Speech_TAPE_J_Answer_2_01,0,한 통의 부재중 전화가 있습니다.,18,w,INT:4709E5BD:1
|
Speech_TAPE_J_Answer_2_01,0,한 통의 부재중 전화가 있습니다.,18,w,INT:4709E5BD:1
|
||||||
|
|||||||
|
Can't render this file because it is too large.
|
@@ -8345,7 +8345,7 @@ Speech_TAPE_JOKER_5_16,0,"Теперь еще попробуй рассказа
|
|||||||
Speech_TAPE_JOKER_5_17,0,Благодарю.,10,w,INT:3F21770A:5
|
Speech_TAPE_JOKER_5_17,0,Благодарю.,10,w,INT:3F21770A:5
|
||||||
Speech_TAPE_JOKER_5_18,0,"Вот тебе совет. Если не хочешь, чтобы кто-то узнал о твоем прошлом, перестань совать нос в мое.",95,w,INT:3F21770A:12
|
Speech_TAPE_JOKER_5_18,0,"Вот тебе совет. Если не хочешь, чтобы кто-то узнал о твоем прошлом, перестань совать нос в мое.",95,w,INT:3F21770A:12
|
||||||
Speech_TAPE_JOKER_5_19,0,"И не забудь прислать еще одного дока, док. Нужна консультация.",62,w,INT:3F21770A:0
|
Speech_TAPE_JOKER_5_19,0,"И не забудь прислать еще одного дока, док. Нужна консультация.",62,w,INT:3F21770A:0
|
||||||
Speech_TAPE_J_Answer_1_00,0,У ваÑ<EFBFBD> один пропущенный звонок,54,a,
|
Speech_TAPE_J_Answer_1_00,0,У Ð²Ð°Ñ Ð¾Ð´Ð¸Ð½ пропущенный звонок,54,a,
|
||||||
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_01,0,И почему я раньше об этом не подумал? Прямая связь с моим лучшим на свете другом!,81,w,INT:4709E5BE:0
|
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_01,0,И почему я раньше об этом не подумал? Прямая связь с моим лучшим на свете другом!,81,w,INT:4709E5BE:0
|
||||||
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,0,"Только представь - я смогу позвонить тебе сразу, как только заскучаю! Мы так близки друг другу.",95,w,INT:4709E5BE:1
|
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,0,"Только представь - я смогу позвонить тебе сразу, как только заскучаю! Мы так близки друг другу.",95,w,INT:4709E5BE:1
|
||||||
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,1,Следующий шаг - переписка по электронной почте и романтические ужины.,69,w,INT:4709E5BE:1
|
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,1,Следующий шаг - переписка по электронной почте и романтические ужины.,69,w,INT:4709E5BE:1
|
||||||
|
|||||||
|
Can't render this file because it is too large.
|
@@ -38,19 +38,32 @@ loopback); it is a developer tool and not part of the editor.
|
|||||||
- **Originals store** per game folder: `<user data>/UnheardCity/stores/<id>/` (Windows `%APPDATA%`, Linux
|
- **Originals store** per game folder: `<user data>/UnheardCity/stores/<id>/` (Windows `%APPDATA%`, Linux
|
||||||
`~/.local/share/unheardcity`), id = hash of the game path; configurable (`--store`, editor settings).
|
`~/.local/share/unheardcity`), id = hash of the game path; configurable (`--store`, editor settings).
|
||||||
Before a sound is first changed, its original WEM bytes (SHA-256-named blob) and metadata are saved;
|
Before a sound is first changed, its original WEM bytes (SHA-256-named blob) and metadata are saved;
|
||||||
the applied replacement is saved too. **Revert** re-injects the stored original; **Revert all** does this for
|
the applied replacement is saved too. **Revert** re-injects the stored original; **Revert all** (editor toolbar,
|
||||||
every record. Stream-pack padding bytes are stored so pack reverts are byte-identical.
|
`ackit revert --all`) does this for every record of the store, sounds and texts, in every language. Stream-pack
|
||||||
|
padding bytes are stored so pack reverts are byte-identical. The uninstaller counts sound and text records and
|
||||||
|
does not offer to delete the store while any is left.
|
||||||
|
- **One store per game at a time:** the changes are recorded only in the store that made them. The editor
|
||||||
|
refuses to switch to another store folder while changes are applied (Revert all first). A store takes the
|
||||||
|
full copy of a soundbank or package only from a file that matches the shipped catalog (every in-memory
|
||||||
|
sound's checksum, every bank copy of a voice recording, every subtitle; `Workspace.unoriginal_files`), so a
|
||||||
|
second store (`--store`) or another tool's change is a conflict, never a new "original". (Menu files and other
|
||||||
|
game builds have no catalog checksums to check.)
|
||||||
- **Full-file fallback:** before the first change to a WAD (and its pack index), one verified full copy is kept
|
- **Full-file fallback:** before the first change to a WAD (and its pack index), one verified full copy is kept
|
||||||
in the store (`full/`), default on, can be disabled. Used by "Restore from full backup" if the store is damaged.
|
in the store (`full/`), default on, can be disabled. If the store is damaged, `ackit restore-backup --yes`
|
||||||
|
copies the WADs back (their sounds are forgotten) and rebuilds the soundbanks, packages and localization files
|
||||||
|
from their copies, keeping the sounds of WADs without a full copy recorded and revertible.
|
||||||
Soundbank files, packages and localization files always get this copy (they are rebuilt from it, 4b/4c).
|
Soundbank files, packages and localization files always get this copy (they are rebuilt from it, 4b/4c).
|
||||||
- **Why the user data folder:** it survives app updates and reinstalls, needs no write access next to the game,
|
- **Why the user data folder:** it survives app updates and reinstalls, needs no write access next to the game,
|
||||||
and works the same on Linux/Proton. Steam never touches it.
|
and works the same on Linux/Proton. Steam never touches it.
|
||||||
- **Safe writes:** every WAD is rebuilt into a temp file and atomically renamed over the original, pack indexes
|
- **Safe writes:** every WAD is rebuilt into a temp file and atomically renamed over the original, pack indexes
|
||||||
first. Several sounds of one WAD are written in one pass. A locked WAD (game running) gives a clear message.
|
first. Several sounds of one WAD are written in one pass. A locked WAD (game running) gives a clear message.
|
||||||
|
Writes are serialized (`Workspace.lock`): the editor runs them as background jobs, and two rebuilds of one
|
||||||
|
package at once (e.g. a sound and its subtitle applied side by side) would otherwise lose one edit.
|
||||||
- **Changed underneath:** each sound's state is computed from its bytes: original / modified (ours) / lost
|
- **Changed underneath:** each sound's state is computed from its bytes: original / modified (ours) / lost
|
||||||
(ours was replaced by the original, e.g. Steam "verify") / changed (neither: update or another tool) /
|
(ours was replaced by the original, e.g. Steam "verify") / changed (neither: update or another tool) /
|
||||||
foreign (never touched by us, but not the catalog's original checksum). Writing over changed/foreign sounds
|
foreign (never touched by us, but not the catalog's original checksum). Writing over changed/foreign sounds
|
||||||
requires explicit confirmation; the catalog is used only when the WAD fingerprint matches.
|
or texts requires explicit confirmation (editor: a question, also when installing a mod; CLI: `--force`); the
|
||||||
|
catalog is used only when the game's fingerprint matches.
|
||||||
|
|
||||||
## 4. Size rule
|
## 4. Size rule
|
||||||
Speech/ambience variations are addressed by offset+size from BKPK tables inside compressed `.upk` packages,
|
Speech/ambience variations are addressed by offset+size from BKPK tables inside compressed `.upk` packages,
|
||||||
@@ -77,7 +90,7 @@ tables): high risk for little gain, not done.
|
|||||||
optional like the WAD copies: the file is rebuilt from it). The file content is always "original copy +
|
optional like the WAD copies: the file is rebuilt from it). The file content is always "original copy +
|
||||||
replacements applied now", so reverting the last one restores the original bytes exactly, and an interrupted
|
replacements applied now", so reverting the last one restores the original bytes exactly, and an interrupted
|
||||||
operation is repaired by the next one. Conflicts: the whole-file SHA-1 must be the original's or the last one
|
operation is repaired by the next one. Conflicts: the whole-file SHA-1 must be the original's or the last one
|
||||||
ackit wrote.
|
ackit wrote, and before the copy is taken the file must match the catalog (section 3).
|
||||||
- **Prefetch heads and whole copies:** 396 voice recordings (81 lines, 78-80 recordings per language) have
|
- **Prefetch heads and whole copies:** 396 voice recordings (81 lines, 78-80 recordings per language) have
|
||||||
their first bytes copied into banks, and 49 more are stored whole in banks (same media ID, identical bytes);
|
their first bytes copied into banks, and 49 more are stored whole in banks (same media ID, identical bytes);
|
||||||
a replacement of such a recording rewrites those copies too, otherwise the game could play the old line.
|
a replacement of such a recording rewrites those copies too, otherwise the game could play the old line.
|
||||||
@@ -130,8 +143,9 @@ copy per voice language) is filtered the same way, so the sound list never mixes
|
|||||||
WAD voice lines), numbered `NAME#2` in the event's own order when it plays two versions; the other 637 keep
|
WAD voice lines), numbered `NAME#2` in the event's own order when it plays two versions; the other 637 keep
|
||||||
the name of their English (INT) recording's position. The position in each language stays an alias (`NAME#i`).
|
the name of their English (INT) recording's position. The position in each language stays an alias (`NAME#i`).
|
||||||
- A recording stored twice in a WAD (5 per language) is written and reverted in both places.
|
- A recording stored twice in a WAD (5 per language) is written and reverted in both places.
|
||||||
- The catalog records the ORIGINAL bytes: `tools/build_catalog.py` stops before writing anything if a sound
|
- The catalog records the ORIGINAL bytes and texts: `tools/build_catalog.py` stops before writing anything if a
|
||||||
differs from the current catalog (a modified game), unless `--accept-changes` (a game update).
|
sound or a subtitle differs from the current catalog (a modified game), unless `--accept-changes` (a game
|
||||||
|
update).
|
||||||
- Category scheme: `docs/categories.md`.
|
- Category scheme: `docs/categories.md`.
|
||||||
|
|
||||||
## 6. Repository cleanup choices
|
## 6. Repository cleanup choices
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ WAD sounds have such heads (2,101 copies); replacing one of them must rewrite th
|
|||||||
story lines) are
|
story lines) are
|
||||||
also stored **whole** in banks: an in-memory source with the WAD recording's own media ID, byte-identical to
|
also stored **whole** in banks: an in-memory source with the WAD recording's own media ID, byte-identical to
|
||||||
the WAD WEM (84 copies). They are handled like heads: part of the WAD line, written and reverted with it, not
|
the WAD WEM (84 copies). They are handled like heads: part of the WAD line, written and reverted with it, not
|
||||||
sounds of their own (`catalog._whole_recordings`). Heads and whole copies together: 445 recordings, 2,185
|
sounds of their own (`catalog.whole_recordings`). Heads and whole copies together: 445 recordings, 2,185
|
||||||
copies (`BANK_locations.csv.gz` kind `p`).
|
copies (`BANK_locations.csv.gz` kind `p`).
|
||||||
|
|
||||||
## Localized banks
|
## Localized banks
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ the UE3 packages (`upk.md`), holding the subtitle text of all 11 languages. acki
|
|||||||
to 5 lines.
|
to 5 lines.
|
||||||
- Strings are stored as cp1252 (positive FString length) or UTF-16 (negative length): almost all RUS, JPN and
|
- Strings are stored as cp1252 (positive FString length) or UTF-16 (negative length): almost all RUS, JPN and
|
||||||
KOR strings and 85 % of POL are UTF-16; the other languages have 17-56 UTF-16 strings each.
|
KOR strings and 85 % of POL are UTF-16; the other languages have 17-56 UTF-16 strings each.
|
||||||
|
4 cp1252 strings hold UTF-8 bytes instead (the same line in KOR, POL, POR, RUS: `Speech_TAPE_J_Answer_1_00`;
|
||||||
|
the KOR one with a damaged byte; the game's own data). ackit reads cp1252 as Windows maps it (every byte, also
|
||||||
|
the 5 that Python's cp1252 leaves undefined, which the RUS and KOR ones contain), so they are shown as stored
|
||||||
|
and every string is written back byte-for-byte.
|
||||||
|
|
||||||
## Scripted subtitles (`RSubtitle`)
|
## Scripted subtitles (`RSubtitle`)
|
||||||
41 more subtitles (radio and encryption-card lines) are `RSubtitle` objects in `Startup.upk`: the same layout,
|
41 more subtitles (radio and encryption-card lines) are `RSubtitle` objects in `Startup.upk`: the same layout,
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ the same line and are changed and reverted together.
|
|||||||
|
|
||||||
## Listen
|
## Listen
|
||||||
The editor under the list shows the selected sound in three columns side by side (drag the dividers to
|
The editor under the list shows the selected sound in three columns side by side (drag the dividers to
|
||||||
resize them): **Listen**, **Replace** and **Subtitle**. Select a row and press **Space** (or double-click) to
|
resize them): **Listen**, **Replace** and **Subtitle**. Select a row and press **Space** to play the original
|
||||||
play the original. Each column ends with its own buttons on one line. In **Listen**:
|
(once a replacement is loaded, Space plays the replacement; Space again stops), or double-click it. Each column
|
||||||
|
ends with its own buttons on one line. In **Listen**:
|
||||||
- **Original** / **Installed** play the game's original and what is currently in the game; **Stop** (Esc).
|
- **Original** / **Installed** play the game's original and what is currently in the game; **Stop** (Esc).
|
||||||
Click on a waveform to play from that point.
|
Click on a waveform to play from that point.
|
||||||
- Above them: **A/B** (switch between the original and your replacement at the same position, to compare),
|
- Above them: **A/B** (switch between the original and your replacement at the same position, to compare),
|
||||||
@@ -82,14 +83,22 @@ than the original, since the game may cut it off.
|
|||||||
|
|
||||||
## Undo
|
## Undo
|
||||||
- **Revert** in the editor puts that sound back exactly.
|
- **Revert** in the editor puts that sound back exactly.
|
||||||
- Right-click rows → **Revert selected**; toolbar **Revert all** puts every original back.
|
- Right-click rows → **Revert selected**; toolbar **Revert all** puts every original back: all sounds,
|
||||||
|
subtitles and menu texts you changed, in every language.
|
||||||
- Originals are kept in your user data folder (`%APPDATA%\UnheardCity` on Windows,
|
- Originals are kept in your user data folder (`%APPDATA%\UnheardCity` on Windows,
|
||||||
`~/.local/share/unheardcity` on Linux), so reverting works after updating or reinstalling the app too.
|
`~/.local/share/unheardcity` on Linux), so reverting works after updating or reinstalling the app too.
|
||||||
Uninstalling keeps this folder; use **Revert all** before uninstalling if you want the game back to original.
|
Uninstalling keeps this folder; use **Revert all** before uninstalling if you want the game back to original.
|
||||||
Before the first change to a game file, a full copy of it is kept there too: optional for the WAD archives
|
Before the first change to a game file, a full copy of it is kept there too: optional for the WAD archives
|
||||||
(Settings), always for soundbanks, packages and text files (they are rebuilt from that copy).
|
(Settings), always for soundbanks, packages and text files (they are rebuilt from that copy). Only files the
|
||||||
|
game ships unchanged are copied; a file another tool (or another originals store) has changed is reported as
|
||||||
|
*changed outside this tool* first.
|
||||||
|
- The originals of your changes are only in the store that made them, so the store folder (Settings) cannot be
|
||||||
|
changed while changes are applied: use **Revert all** first.
|
||||||
- If Steam "Verify integrity of game files" or an update undoes your changes, a bar offers **Re-apply my
|
- If Steam "Verify integrity of game files" or an update undoes your changes, a bar offers **Re-apply my
|
||||||
changes** or **Forget them**. Do not use "verify" to undo mods; use Revert all.
|
changes** or **Forget them**. Do not use "verify" to undo mods; use Revert all.
|
||||||
|
- If the store folder itself is damaged, the full copies can still be put back with the command-line toolkit
|
||||||
|
that Unheard City is built on (`ackit restore-backup`, see the project README; not part of the editor
|
||||||
|
download).
|
||||||
|
|
||||||
## Subtitles
|
## Subtitles
|
||||||
The **Subtitles** tab lists the subtitle of every speech line in the selected language (see *Language*).
|
The **Subtitles** tab lists the subtitle of every speech line in the selected language (see *Language*).
|
||||||
@@ -123,6 +132,7 @@ Click the **★** column to mark favorites. These are stored in your user data f
|
|||||||
- **Export mod…**: choose which changed sounds and texts to include and save a `.acmod` file (the exact game
|
- **Export mod…**: choose which changed sounds and texts to include and save a `.acmod` file (the exact game
|
||||||
data plus the audio, so it also works for another language whose original has a different size).
|
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.
|
||||||
|
If a sound or text the mod replaces was changed outside the editor, you are asked before it is overwritten.
|
||||||
- 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 (a French subtitle edit changes the French 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
|
||||||
@@ -131,7 +141,8 @@ Click the **★** column to mark favorites. These are stored in your user data f
|
|||||||
|
|
||||||
## Settings
|
## Settings
|
||||||

|

|
||||||
Game folder, where originals are kept, the full-backup option and the playback device. The recording device
|
Game folder, where originals are kept (changeable only while nothing is modified), the full-backup option and
|
||||||
|
the playback device. The recording device
|
||||||
is chosen next to the Record button. **Background**: show or hide the Arkham City wallpaper behind the filter
|
is chosen next to the Record button. **Background**: show or hide the Arkham City wallpaper behind the filter
|
||||||
panel; **Background strength**: how visible it is (10 % by default).
|
panel; **Background strength**: how visible it is (10 % by default).
|
||||||
The editor panel opens tall enough to show all its controls; drag the divider above it to change that.
|
The editor panel opens tall enough to show all its controls; drag the divider above it to change that.
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def isolated_home(tmp_path, monkeypatch):
|
def isolated_home(tmp_path, monkeypatch):
|
||||||
"""User data/config/cache go to a temp folder (never touch the real settings or stores)."""
|
"""User data/config/cache go to a temp folder (never touch the real settings or stores), and the language
|
||||||
|
the game is set to on this machine (Documents\\WB Games\\...\\UserEngine.ini) does not leak into tests."""
|
||||||
|
from ackit import game as G
|
||||||
monkeypatch.setenv("ACKIT_HOME", str(tmp_path / "home"))
|
monkeypatch.setenv("ACKIT_HOME", str(tmp_path / "home"))
|
||||||
|
real = G.Game.config_dirs
|
||||||
|
mine = os.path.normcase(os.path.join(os.path.expanduser("~"), "Documents", "WB Games"))
|
||||||
|
monkeypatch.setattr(G.Game, "config_dirs",
|
||||||
|
lambda self: [d for d in real(self) if not os.path.normcase(d).startswith(mine)])
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -223,6 +223,60 @@ def test_mod_with_texts(win, tmp_path):
|
|||||||
tx.revert_all()
|
tx.revert_all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_revert_all_reverts_sounds_and_texts(win):
|
||||||
|
w, root, _ = win
|
||||||
|
before = files(root)
|
||||||
|
tx = w.ws.texts
|
||||||
|
tx.apply({"SUB:INT:%s:0" % synth.SPEECH_EVENT: "Undo me.", "MENU:INT:FRONTEND.int:Menu:Quit": "Leave"})
|
||||||
|
s = w.ws.sound("CHR_RadioScanner_1")
|
||||||
|
w.ws.apply([(s, synth.wem(0.5, 1200), {})])
|
||||||
|
w.revert_all()
|
||||||
|
assert files(root) == before and not w.ws.store.entries and not tx.records()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mod_import_asks_before_overwriting(win, tmp_path):
|
||||||
|
"""Files changed outside the editor are overwritten by a mod only when the user confirms."""
|
||||||
|
w, root, originals = win
|
||||||
|
s = w.ws.sound("CHR_RadioScanner_1")
|
||||||
|
w.ws.apply([(s, synth.wem(0.5, 1200), {})])
|
||||||
|
pkg = str(tmp_path / "m.acmod")
|
||||||
|
w.export_mod_dialog(path=pkg, keys=[s.key], title="m")
|
||||||
|
other = synth.wem(0.4, 150)
|
||||||
|
inject(w.ws.wad_path(s), {s.target: other}, {s.target: w.ws.limit(s)}) # another tool writes the slot
|
||||||
|
asked = []
|
||||||
|
w.confirm = lambda *a: asked.append(a) or False
|
||||||
|
w.import_mod_dialog(path=pkg, confirm=False)
|
||||||
|
assert asked and w.ws.current_bytes(s) == other # declined: left alone
|
||||||
|
w.confirm = lambda *a: True
|
||||||
|
w.import_mod_dialog(path=pkg, confirm=False)
|
||||||
|
assert w.ws.state(s) == W.MODIFIED
|
||||||
|
w.revert_all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_cannot_change_while_changes_are_applied(win, tmp_path, monkeypatch):
|
||||||
|
from app import mainwindow
|
||||||
|
w, root, _ = win
|
||||||
|
s = w.ws.sound("CHR_RadioScanner_1")
|
||||||
|
w.ws.apply([(s, synth.wem(0.5, 1200), {})])
|
||||||
|
store = w.ws.store.root
|
||||||
|
|
||||||
|
class Dlg:
|
||||||
|
def __init__(self, settings, parent):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def exec(self):
|
||||||
|
return mainwindow.QDialog.DialogCode.Accepted
|
||||||
|
|
||||||
|
def values(self):
|
||||||
|
return dict(w.settings, store_root=str(tmp_path / "elsewhere"))
|
||||||
|
monkeypatch.setattr(mainwindow, "SettingsDialog", Dlg)
|
||||||
|
told = []
|
||||||
|
w.inform = lambda *a: told.append(a)
|
||||||
|
w.settings_dialog()
|
||||||
|
assert told and w.settings.get("store_root") != str(tmp_path / "elsewhere") and w.ws.store.root == store
|
||||||
|
w.revert_all()
|
||||||
|
|
||||||
|
|
||||||
def test_text_undone_outside_shows_banner(win):
|
def test_text_undone_outside_shows_banner(win):
|
||||||
w, root, _ = win
|
w, root, _ = win
|
||||||
tx = w.ws.texts
|
tx = w.ws.texts
|
||||||
@@ -387,3 +441,18 @@ def test_editor_panel_fits_its_contents(win):
|
|||||||
need = w.editor.sizeHint().height()
|
need = w.editor.sizeHint().height()
|
||||||
assert w.scroll.viewport().height() >= need - 2, (w.scroll.viewport().height(), need)
|
assert w.scroll.viewport().height() >= need - 2, (w.scroll.viewport().height(), need)
|
||||||
assert w.vsplit.sizes()[0] >= 160
|
assert w.vsplit.sizes()[0] >= 160
|
||||||
|
|
||||||
|
|
||||||
|
def test_recorder_sample_formats(qapp):
|
||||||
|
"""Input devices may deliver 16/32-bit integer, 8-bit or float samples, and reads can split a frame."""
|
||||||
|
import numpy as np
|
||||||
|
from app.audioio import Recorder
|
||||||
|
r = Recorder()
|
||||||
|
x = np.array([[0.5, -0.25], [0.125, -1.0]], np.float32)
|
||||||
|
for sample, raw in (("<i2", (x * 32768).astype("<i2").tobytes()), ("<i4", (x * 2147483648.0).astype("<i4").tobytes()),
|
||||||
|
("u1", (x * 128 + 128).astype("u1").tobytes()), ("<f4", x.astype("<f4").tobytes())):
|
||||||
|
r.chunks, r._rest, r.channels, r.sample = [], b"", 2, sample
|
||||||
|
r._ingest(raw[:3])
|
||||||
|
r._ingest(raw[3:])
|
||||||
|
got, _ = r.stop()
|
||||||
|
assert np.allclose(got, x, atol=1 / 128), sample
|
||||||
|
|||||||
@@ -180,3 +180,19 @@ def test_every_name_resolves_to_its_sound(real_game_dir):
|
|||||||
for lang in g.languages:
|
for lang in g.languages:
|
||||||
cat = Catalog(g, [lang, "SFX", "BANK"])
|
cat = Catalog(g, [lang, "SFX", "BANK"])
|
||||||
assert all(cat.resolve(s.name) is s for s in cat.sounds), lang
|
assert all(cat.resolve(s.name) is s for s in cat.sounds), lang
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_catalog_stops_on_changed_subtitles(tmp_path):
|
||||||
|
"""tools/build_catalog.py refuses to record a modified game: changed subtitles count like changed sounds."""
|
||||||
|
import importlib.util
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from ackit import subtitles
|
||||||
|
spec = importlib.util.spec_from_file_location("build_catalog", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tools", "build_catalog.py"))
|
||||||
|
bc = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(bc)
|
||||||
|
row = subtitles.SubLine("INT", "Speech_X", 0, "Original.", 12, "a", "")
|
||||||
|
subtitles.write_texts(str(tmp_path / "SUB_INT.csv"), [row])
|
||||||
|
game = SimpleNamespace(archives={})
|
||||||
|
assert bc.changed_since_catalog(game, str(tmp_path), [], {"INT": [row]}) == []
|
||||||
|
assert bc.changed_since_catalog(game, str(tmp_path), [], {"INT": [row._replace(text="Edited.")]}) == \
|
||||||
|
["SUB:INT:Speech_X:0"]
|
||||||
|
|||||||
@@ -35,6 +35,26 @@ def test_cli_workflow(synth_game, tmp_path, capsys):
|
|||||||
run(capsys, "revert", "Speech_Synth_Radio_02", *g)
|
run(capsys, "revert", "Speech_Synth_Radio_02", *g)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_text_language_without_voices(synth_game, capsys):
|
||||||
|
"""--lang works for the languages that have only texts (INT voices are loaded for them, like the editor)."""
|
||||||
|
root, _ = synth_game
|
||||||
|
out = run(capsys, "text", "find", "--kind", "subs", "--game", root, "--lang", "RUS", "Строка")
|
||||||
|
assert "SUB:RUS:%s:0" % synth.SPEECH_EVENT in out and "1 match" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_restore_backup(synth_game, tmp_path, capsys):
|
||||||
|
root, _ = synth_game
|
||||||
|
g = ["--game", root, "--lang", "INT", "--store", str(tmp_path / "store")]
|
||||||
|
wav = str(tmp_path / "line.wav")
|
||||||
|
audio.save_wav(wav, *synth.tone(1.0, 44100, 660))
|
||||||
|
run(capsys, "replace", "Speech_Synth_Radio_02", wav, *g)
|
||||||
|
out = run(capsys, "restore-backup", *g)
|
||||||
|
assert "would copy back" in out and "--yes" in out # lists only
|
||||||
|
assert "Speech_Synth_Radio_02" in run(capsys, "status", *g)
|
||||||
|
assert "restored" in run(capsys, "restore-backup", "--yes", *g)
|
||||||
|
assert "game files are original" in run(capsys, "status", *g)
|
||||||
|
|
||||||
|
|
||||||
def test_cli_errors_are_friendly(synth_game, capsys):
|
def test_cli_errors_are_friendly(synth_game, capsys):
|
||||||
root, _ = synth_game
|
root, _ = synth_game
|
||||||
with pytest.raises(SystemExit) as e:
|
with pytest.raises(SystemExit) as e:
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ def test_audio_helpers():
|
|||||||
assert audio.to_channels(x, 1).shape == (48000, 1)
|
assert audio.to_channels(x, 1).shape == (48000, 1)
|
||||||
assert audio.to_channels(x[:, :1], 6).shape == (48000, 6)
|
assert audio.to_channels(x[:, :1], 6).shape == (48000, 6)
|
||||||
assert abs(audio.peak_db(audio.normalize(x, -3.0)) + 3.0) < 0.01
|
assert abs(audio.peak_db(audio.normalize(x, -3.0)) + 3.0) < 0.01
|
||||||
|
# down-mix keeps every channel: a voice only in the centre of 5.1 goes to both stereo sides at -3 dB (the
|
||||||
|
# standard fold-down) and to mono at its own level
|
||||||
|
c = np.zeros((100, 6), np.float32)
|
||||||
|
c[:, 2] = 0.5
|
||||||
|
assert np.allclose(audio.to_channels(c, 2), 0.5 * 0.7071) and np.allclose(audio.to_channels(c, 1), 0.5)
|
||||||
|
same = np.repeat(x[:, :1], 2, axis=1) # identical L/R: mono = either side, no clipping
|
||||||
|
assert np.allclose(audio.to_channels(same, 1)[:, 0], x[:, 0], atol=1e-6)
|
||||||
assert audio.trim(x, sr, 0.25, 0.75).shape[0] == 24000
|
assert audio.trim(x, sr, 0.25, 0.75).shape[0] == 24000
|
||||||
assert audio.resample(x, 48000, 24000).shape[0] == 24000
|
assert audio.resample(x, 48000, 24000).shape[0] == 24000
|
||||||
|
|
||||||
|
|||||||
@@ -46,3 +46,17 @@ def test_not_a_game_folder(tmp_path):
|
|||||||
import pytest
|
import pytest
|
||||||
with pytest.raises(G.GameError):
|
with pytest.raises(G.GameError):
|
||||||
G.Game(str(tmp_path))
|
G.Game(str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
def test_language_without_voices(synth_game, tmp_path, monkeypatch):
|
||||||
|
"""A game set to a language with texts only (e.g. JPN) is in that language, with the INT voices."""
|
||||||
|
root, _ = synth_game
|
||||||
|
os.makedirs(os.path.join(root, "BmGame", "Localization", "JPN"), exist_ok=True)
|
||||||
|
cfg = tmp_path / "cfg"
|
||||||
|
cfg.mkdir()
|
||||||
|
(cfg / "UserEngine.ini").write_bytes("[Engine.Engine]\r\nLanguage=Jpn\r\n".encode("utf-16"))
|
||||||
|
monkeypatch.setattr(G.Game, "config_dirs", lambda self: [str(cfg)])
|
||||||
|
g = G.Game(root)
|
||||||
|
assert g.language() == "JPN" and g.voice_archive(g.language()) == "INT"
|
||||||
|
from ackit.catalog import Catalog
|
||||||
|
assert "INT" in Catalog(g).archives # the default catalog loads the INT voices
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ def test_stream_packs_noop_identical(real_game_dir):
|
|||||||
|
|
||||||
def test_builder_reproduces_game_wems(real_game_dir):
|
def test_builder_reproduces_game_wems(real_game_dir):
|
||||||
g = Game(real_game_dir)
|
g = Game(real_game_dir)
|
||||||
lang = g.language()
|
lang = g.voice_archive(g.language())
|
||||||
w = Wad(g.wad(lang))
|
w = Wad(g.wad(lang))
|
||||||
rnd = random.Random(5)
|
rnd = random.Random(5)
|
||||||
n = 0
|
n = 0
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Subtitles (dialogue objects in packages) and menu text (localization files): parse, apply, revert."""
|
"""Subtitles (dialogue objects in packages) and menu text (localization files): parse, apply, revert."""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import struct
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -36,6 +37,10 @@ def test_fstring_encoding():
|
|||||||
subtitles.encode_fstring("too long", 10, "a")
|
subtitles.encode_fstring("too long", 10, "a")
|
||||||
with pytest.raises(subtitles.SubtitleError):
|
with pytest.raises(subtitles.SubtitleError):
|
||||||
subtitles.encode_fstring("→", 10, "a") # not in cp1252
|
subtitles.encode_fstring("→", 10, "a") # not in cp1252
|
||||||
|
# every byte reads back as it was, also the 5 that Python's cp1252 leaves undefined (UTF-8 in two game strings)
|
||||||
|
raw = struct.pack("<i", 7) + "Уж".encode("utf-8") + b"\x81\x8d\0"
|
||||||
|
text = subtitles.read_fstring(raw, 0)[0]
|
||||||
|
assert subtitles.encode_fstring(text, len(raw), "a") == raw
|
||||||
|
|
||||||
|
|
||||||
def test_menu_parse_and_write():
|
def test_menu_parse_and_write():
|
||||||
@@ -51,6 +56,15 @@ def test_menu_parse_and_write():
|
|||||||
loctext.write(raw, "INT", fname, {"INT:FRONTEND.int:Menu:Start": "two\nlines"})
|
loctext.write(raw, "INT", fname, {"INT:FRONTEND.int:Menu:Start": "two\nlines"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_menu_check_value():
|
||||||
|
loctext.check_value("Fine", "utf-16", "F.int")
|
||||||
|
loctext.check_value("Città", "cp1252", "F.int")
|
||||||
|
with pytest.raises(loctext.TextError):
|
||||||
|
loctext.check_value("two\nlines", "utf-16", "F.int")
|
||||||
|
with pytest.raises(loctext.TextError):
|
||||||
|
loctext.check_value("Привет", "cp1252", "F.int") # not in the file's encoding
|
||||||
|
|
||||||
|
|
||||||
# --- subtitles through the workspace ------------------------------------------------------------
|
# --- subtitles through the workspace ------------------------------------------------------------
|
||||||
def test_subtitle_catalog_and_link(synth_game, tmp_path):
|
def test_subtitle_catalog_and_link(synth_game, tmp_path):
|
||||||
root, _ = synth_game
|
root, _ = synth_game
|
||||||
|
|||||||
@@ -119,6 +119,101 @@ def test_full_backup_taken_once(synth_game):
|
|||||||
assert rel in restored and ws.state(s) == W.ORIGINAL
|
assert rel in restored and ws.state(s) == W.ORIGINAL
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_from_full_backup_keeps_wads_without_a_copy(synth_game, tmp_path):
|
||||||
|
"""Without full WAD backups, a restore puts the packages back but keeps the WAD sounds recorded (they stay
|
||||||
|
modified and revertible)."""
|
||||||
|
root, _ = synth_game
|
||||||
|
before = files(root)
|
||||||
|
ws = W.Workspace(root, store_root=str(tmp_path / "store"), archives=["INT", "SFX", "BANK"], full_backup=False)
|
||||||
|
wad_s = ws.sound("CHR_RadioScanner_1")
|
||||||
|
bank_s = ws.catalog.by_key["BANK:%08X" % synth.DLG_MEDIA]
|
||||||
|
ws.apply([(wad_s, synth.wem(0.5, 1200), {}), (bank_s, synth.fitting_wem(ws, bank_s, 0.1, 1900), {})])
|
||||||
|
ws.texts.apply({"SUB:INT:%s:0" % synth.SPEECH_EVENT: "Restored."})
|
||||||
|
ws.restore_from_full_backup()
|
||||||
|
assert ws.state(bank_s) == W.ORIGINAL and not ws.texts.records()
|
||||||
|
assert ws.state(wad_s) == W.MODIFIED and list(ws.store.entries) == [wad_s.key]
|
||||||
|
ws.revert_all()
|
||||||
|
assert files(root) == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_writes_are_serialized(synth_game, tmp_path, monkeypatch):
|
||||||
|
"""The editor runs writes as background jobs: a sound and a subtitle written into one package at the same
|
||||||
|
time both end up in it (a slow rebuild of the first must not overwrite the second)."""
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
root, _ = synth_game
|
||||||
|
ws = W.Workspace(root, store_root=str(tmp_path / "store"), archives=["INT", "SFX", "BANK"])
|
||||||
|
s = ws.catalog.by_key["BANK:%08X" % synth.DLG_MEDIA]
|
||||||
|
wem = synth.fitting_wem(ws, s, 0.1, 1900)
|
||||||
|
build = W.Workspace._build
|
||||||
|
|
||||||
|
def slow(self, path, orig):
|
||||||
|
out = build(self, path, orig)
|
||||||
|
time.sleep(0.5 if threading.current_thread().name == "sound" else 0)
|
||||||
|
return out
|
||||||
|
monkeypatch.setattr(W.Workspace, "_build", slow)
|
||||||
|
key = "SUB:INT:%s:0" % synth.SPEECH_EVENT
|
||||||
|
a = threading.Thread(name="sound", target=lambda: ws.apply([(s, wem, {})]))
|
||||||
|
b = threading.Thread(target=lambda: (time.sleep(0.15), ws.texts.apply({key: "Both."})))
|
||||||
|
a.start(), b.start()
|
||||||
|
a.join(), b.join()
|
||||||
|
assert ws.state(s) == W.MODIFIED and ws.texts.state(key) == "modified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_another_store_does_not_take_modified_files_as_originals(synth_game, tmp_path):
|
||||||
|
"""A second originals store (Settings > Originals store changed, `--store`) must not copy a package that the
|
||||||
|
first store modified as its "original": the catalog's checksums and texts show it is not original."""
|
||||||
|
from ackit.originals import sha1_16
|
||||||
|
from ackit.texts import TextConflictError
|
||||||
|
root, _ = synth_game
|
||||||
|
A = W.Workspace(root, store_root=str(tmp_path / "A"), archives=["INT", "SFX", "BANK"])
|
||||||
|
s = A.catalog.by_key["BANK:%08X" % synth.DLG_MEDIA]
|
||||||
|
orig_sha = sha1_16(A.current_bytes(s))
|
||||||
|
sub = "SUB:INT:%s:0" % synth.SPEECH_EVENT
|
||||||
|
rel = A.locations(s)[0].file
|
||||||
|
A.apply([(s, synth.fitting_wem(A, s, 0.1, 1900), {})])
|
||||||
|
A.texts.apply({sub: "Edited."})
|
||||||
|
|
||||||
|
def store_b():
|
||||||
|
# the synthetic game has no shipped catalog: give B the checksums and texts a shipped one would have
|
||||||
|
B = W.Workspace(root, store_root=str(tmp_path / "B"), archives=["INT", "SFX", "BANK"])
|
||||||
|
B._shipped = True
|
||||||
|
B.catalog.bank_sha1 = {synth.DLG_MEDIA: orig_sha}
|
||||||
|
B.texts._shipped_subs = ({}, {})
|
||||||
|
return B
|
||||||
|
B = store_b()
|
||||||
|
assert B.unoriginal_files([B.bank_path(rel)]) == [B.bank_path(rel)]
|
||||||
|
with pytest.raises(TextConflictError):
|
||||||
|
B.texts.apply({"SUB:INT:%s:1" % synth.SPEECH_EVENT: "Other."})
|
||||||
|
A.revert([s])
|
||||||
|
B = store_b()
|
||||||
|
by_file = {}
|
||||||
|
for l in B.texts.subtitle_locations(sub):
|
||||||
|
by_file.setdefault(l.file, []).append(l)
|
||||||
|
B.texts._shipped_subs = (by_file, {("INT", synth.SPEECH_EVENT, 0): "Scanner line two."})
|
||||||
|
assert B.texts.unoriginal_subtitles(rel) == [sub] # A's subtitle is still applied
|
||||||
|
A.texts.revert_all()
|
||||||
|
assert B.unoriginal_files([B.bank_path(rel)]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_mod_export_of_a_change_made_in_another_language(synth_game, tmp_path):
|
||||||
|
"""A change made while another language was loaded keeps its name and line identity in a mod package."""
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
root, _ = synth_game
|
||||||
|
store = str(tmp_path / "store")
|
||||||
|
every = W.Workspace(root, store_root=store, archives=["SFX", "BANK"]) # every language's lines
|
||||||
|
s = every.catalog.by_key["BANK:%08X" % synth.GRUNT_MEDIA["ITA"]]
|
||||||
|
every.apply([(s, synth.fitting_wem(every, s, 0.1, 900), {})])
|
||||||
|
ws = W.Workspace(root, store_root=store, archives=["INT", "SFX", "BANK"]) # now INT is loaded
|
||||||
|
assert s.key not in ws.catalog.by_key
|
||||||
|
pkg = str(tmp_path / "m.acmod")
|
||||||
|
export_mod(ws, pkg, ws.modified_keys(), "m")
|
||||||
|
it = json.loads(zipfile.ZipFile(pkg).read("manifest.json"))["items"][0]
|
||||||
|
assert (it["name"], it["line"]) == (s.name, s.line) and s.line
|
||||||
|
assert ws.revert_all() and not ws.store.entries
|
||||||
|
|
||||||
|
|
||||||
def test_mod_from_another_language_matched_by_line(synth_game, tmp_path):
|
def test_mod_from_another_language_matched_by_line(synth_game, tmp_path):
|
||||||
"""A voice line from another language is found through the game's line identity (its WAD position there
|
"""A voice line from another language is found through the game's line identity (its WAD position there
|
||||||
can differ); a voice line from a package without line identities is never guessed by name."""
|
can differ); a voice line from a package without line identities is never guessed by name."""
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ Outputs
|
|||||||
data/catalog/SUB_locations.csv.gz every stored copy of each subtitle string
|
data/catalog/SUB_locations.csv.gz every stored copy of each subtitle string
|
||||||
data/catalog/index.json fingerprints the CSVs were built from + counts
|
data/catalog/index.json fingerprints the CSVs were built from + counts
|
||||||
Run it only on clean game files (`ackit status` must say nothing is modified). It stops before writing
|
Run it only on clean game files (`ackit status` must say nothing is modified). It stops before writing
|
||||||
anything if a sound differs from the current catalog (a modified game); after a game update use --accept-changes.
|
anything if a sound or a subtitle differs from the current catalog (a modified game); after a game update use
|
||||||
|
--accept-changes. (Menu text has no catalog.)
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import collections
|
import collections
|
||||||
@@ -88,9 +89,16 @@ def build_characters(game, src_root, final):
|
|||||||
return rows, unmatched, ambiguous, skipped
|
return rows, unmatched, ambiguous, skipped
|
||||||
|
|
||||||
|
|
||||||
def changed_since_catalog(game, out_dir, bank_snd):
|
def changed_since_catalog(game, out_dir, bank_snd, texts=None):
|
||||||
"""Keys whose original checksum in the existing catalog differs from the game files now."""
|
"""Keys whose original in the existing catalog differs from the game files now: sounds (checksums) and
|
||||||
|
subtitles (texts: {lang: [SubLine]} scanned now)."""
|
||||||
out = []
|
out = []
|
||||||
|
for lang, rows in (texts or {}).items():
|
||||||
|
p = os.path.join(out_dir, "SUB_%s.csv" % lang)
|
||||||
|
if os.path.exists(p):
|
||||||
|
old = {(s.speech, s.line): s.text for s in subtitles.read_texts(p, lang)}
|
||||||
|
out += [subtitles.key(lang, s.speech, s.line) for s in rows
|
||||||
|
if (s.speech, s.line) in old and old[(s.speech, s.line)] != s.text]
|
||||||
for arch, path in game.archives.items():
|
for arch, path in game.archives.items():
|
||||||
p = os.path.join(out_dir, "%s.csv" % arch)
|
p = os.path.join(out_dir, "%s.csv" % arch)
|
||||||
if not os.path.exists(p):
|
if not os.path.exists(p):
|
||||||
@@ -133,11 +141,12 @@ def main():
|
|||||||
for k, d in lines.issues[:10]:
|
for k, d in lines.issues[:10]:
|
||||||
print(" issue:", k, d)
|
print(" issue:", k, d)
|
||||||
|
|
||||||
# safety: the catalog records ORIGINAL bytes. A sound that differs from the current catalog means a modified
|
# safety: the catalog records ORIGINAL bytes and texts. A sound or subtitle that differs from the current
|
||||||
# game (an applied change, another tool) unless the game itself was updated -> stop before writing anything.
|
# catalog means a modified game (an applied change, another tool) unless the game itself was updated -> stop
|
||||||
changed = changed_since_catalog(game, out_dir, bank_snd)
|
# before writing anything.
|
||||||
|
changed = changed_since_catalog(game, out_dir, bank_snd, texts)
|
||||||
if changed and not a.accept_changes:
|
if changed and not a.accept_changes:
|
||||||
print("STOP: %d sound(s) differ from the current catalog, e.g. %s." % (len(changed), changed[:5]))
|
print("STOP: %d sound(s) or subtitle(s) differ from the current catalog, e.g. %s." % (len(changed), changed[:5]))
|
||||||
print("Revert all changes (ackit status / ackit revert --all) and run again; after a game update use "
|
print("Revert all changes (ackit status / ackit revert --all) and run again; after a game update use "
|
||||||
"--accept-changes.")
|
"--accept-changes.")
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
@@ -193,7 +202,7 @@ def main():
|
|||||||
"fingerprint": banks.game_fingerprint(game.cooked), "sounds": len(sounds), "groups": dict(g),
|
"fingerprint": banks.game_fingerprint(game.cooked), "sounds": len(sounds), "groups": dict(g),
|
||||||
"named_by_event": by_event, "named_by_bank": len(sounds) - by_event,
|
"named_by_event": by_event, "named_by_bank": len(sounds) - by_event,
|
||||||
"copies": sum(1 for l in locs if l.kind == banks.MEMORY), "files": len(sc.files),
|
"copies": sum(1 for l in locs if l.kind == banks.MEMORY), "files": len(sc.files),
|
||||||
# bank copies of WAD recordings: prefetch heads and whole in-memory copies (catalog._whole_recordings)
|
# bank copies of WAD recordings: prefetch heads and whole in-memory copies (catalog.whole_recordings)
|
||||||
"prefetch_heads": {"recordings": len({l.media for l in heads}), "copies": len(heads),
|
"prefetch_heads": {"recordings": len({l.media for l in heads}), "copies": len(heads),
|
||||||
"unmapped": len({l.media for l in heads if not l.sound})},
|
"unmapped": len({l.media for l in heads if not l.sound})},
|
||||||
"issues": ["%s: %s" % i for i in sc.issues]}
|
"issues": ["%s: %s" % i for i in sc.issues]}
|
||||||
|
|||||||
@@ -240,6 +240,16 @@ def catalog_claims(cat):
|
|||||||
west = [enc[L]["w"] for L in langs if L not in ("RUS", "JPN", "KOR", "POL")]
|
west = [enc[L]["w"] for L in langs if L not in ("RUS", "JPN", "KOR", "POL")]
|
||||||
claim("docs/formats/subtitles.md", "%d %% of POL are UTF-16; the other languages have %s UTF-16 strings each"
|
claim("docs/formats/subtitles.md", "%d %% of POL are UTF-16; the other languages have %s UTF-16 strings each"
|
||||||
% (pol, rng(west)))
|
% (pol, rng(west)))
|
||||||
|
utf8 = [] # cp1252 strings whose bytes are UTF-8 text (the game's data)
|
||||||
|
for L, t in sorted(texts.items()):
|
||||||
|
for s in t:
|
||||||
|
if s.enc == "a":
|
||||||
|
u = subtitles._encode_cp1252(s.text).decode("utf-8", "replace")
|
||||||
|
multi = sum(1 for c in u if ord(c) > 0x7F and c != "\ufffd") # valid multi-byte characters
|
||||||
|
if multi and u.count("\ufffd") <= multi // 4: # (one corrupt byte tolerated)
|
||||||
|
utf8.append((L, s.speech))
|
||||||
|
claim("docs/formats/subtitles.md", "%d cp1252 strings hold UTF-8 bytes instead (the same line in %s: `%s`"
|
||||||
|
% (len(utf8), ", ".join(L for L, _ in utf8), "`, `".join(sorted({sp for _, sp in utf8}))))
|
||||||
with gzip.open(os.path.join(cat, subtitles.LOCATIONS), "rt", newline="") as f:
|
with gzip.open(os.path.join(cat, subtitles.LOCATIONS), "rt", newline="") as f:
|
||||||
sublocs = sum(1 for _ in csv.DictReader(f))
|
sublocs = sum(1 for _ in csv.DictReader(f))
|
||||||
claim("REPORT.md", "Edited in place in every copy (%s copies)" % n(sublocs))
|
claim("REPORT.md", "Edited in place in every copy (%s copies)" % n(sublocs))
|
||||||
@@ -505,6 +515,21 @@ def wem_claims():
|
|||||||
% (n(total - len(bad)), n(total), exceptions))
|
% (n(total - len(bad)), n(total), exceptions))
|
||||||
|
|
||||||
|
|
||||||
|
def originality_claims(game):
|
||||||
|
"""Workspace.unoriginal_files on every file with sounds or subtitles of the (original) game: no false alarm."""
|
||||||
|
import tempfile
|
||||||
|
from ackit import subtitles
|
||||||
|
from ackit.workspace import Workspace
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
ws = Workspace(game, store_root=td, archives=[game.voice_archive("INT"), "SFX", "BANK"])
|
||||||
|
rels = {l.file for ls in ws.catalog.locations.values() for l in ls}
|
||||||
|
rels |= {l.file for ls in subtitles.read_locations(os.path.join(catalog_dir(), subtitles.LOCATIONS)).values()
|
||||||
|
for l in ls}
|
||||||
|
bad = ws.unoriginal_files([ws.bank_path(r) for r in rels])
|
||||||
|
claim("REPORT.md", "checked on all %s files with sounds or subtitles: %s" % (
|
||||||
|
n(len(rels)), "no false alarm" if not bad else "%d false alarms" % len(bad)), "Workspace.unoriginal_files")
|
||||||
|
|
||||||
|
|
||||||
def test_claims():
|
def test_claims():
|
||||||
import subprocess
|
import subprocess
|
||||||
out = subprocess.run([sys.executable, "-m", "pytest", "--collect-only", "-q", "-p", "no:cacheprovider"],
|
out = subprocess.run([sys.executable, "-m", "pytest", "--collect-only", "-q", "-p", "no:cacheprovider"],
|
||||||
@@ -535,6 +560,7 @@ def main():
|
|||||||
if not game:
|
if not game:
|
||||||
raise SystemExit("--scan needs the game")
|
raise SystemExit("--scan needs the game")
|
||||||
scan_claims(game)
|
scan_claims(game)
|
||||||
|
originality_claims(game)
|
||||||
if a.wems:
|
if a.wems:
|
||||||
if not game:
|
if not game:
|
||||||
raise SystemExit("--wems needs the game")
|
raise SystemExit("--wems needs the game")
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
3. start the game under cdb + speaker loopback: the probe must be heard, no crash, setup parsed;
|
3. start the game under cdb + speaker loopback: the probe must be heard, no crash, setup parsed;
|
||||||
4. press Revert (EditorPanel.revert) and check the WAD + pack index are byte-identical to before;
|
4. press Revert (EditorPanel.revert) and check the WAD + pack index are byte-identical to before;
|
||||||
5. also apply/revert a speech line in place and check byte identity.
|
5. also apply/revert a speech line in place and check byte identity.
|
||||||
|
Uses the user's settings and originals store (an interrupted run shows up in `ackit status`); the settings file
|
||||||
|
is never written.
|
||||||
|
|
||||||
python tools/e2e_editor.py
|
python tools/e2e_editor.py
|
||||||
"""
|
"""
|
||||||
@@ -29,11 +31,15 @@ def sha(p):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from ackit import settings as settings_mod
|
||||||
from app.mainwindow import MainWindow
|
from app.mainwindow import MainWindow
|
||||||
from app.workers import Runner
|
from app.workers import Runner
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
w = MainWindow(settings={"full_backup": True}, runner=Runner(sync=True))
|
# the user's settings (same game folder and originals store as `ackit status`), never saved: the window
|
||||||
|
# stores its settings on load and on close, which would overwrite the user's file with this test's ones
|
||||||
|
w = MainWindow(settings=dict(settings_mod.load(), full_backup=True), runner=Runner(sync=True))
|
||||||
|
w.save_settings = lambda: None
|
||||||
w.confirm = lambda *a: True
|
w.confirm = lambda *a: True
|
||||||
w.editor.confirm = lambda *a: True
|
w.editor.confirm = lambda *a: True
|
||||||
errors = []
|
errors = []
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ $Data = @((Join-Path $env:APPDATA "UnheardCity"), (Join-Path $env:LOCALAPPDATA "
|
|||||||
function Say($msg) { if (-not $Quiet) { Write-Host $msg } }
|
function Say($msg) { if (-not $Quiet) { Write-Host $msg } }
|
||||||
|
|
||||||
function Modified-Count {
|
function Modified-Count {
|
||||||
# sounds still changed in the game, according to the originals stores
|
# sounds and texts (subtitles, menu text) still changed in the game, according to the originals stores
|
||||||
$n = 0
|
$n = 0
|
||||||
Get-ChildItem -Path (Join-Path $Data[0] "stores\*\store.json") -ErrorAction SilentlyContinue | ForEach-Object {
|
Get-ChildItem -Path (Join-Path $Data[0] "stores\*\store.json") -ErrorAction SilentlyContinue | ForEach-Object {
|
||||||
try { $n += @((Get-Content -Raw -LiteralPath $_.FullName | ConvertFrom-Json).entries.PSObject.Properties).Count } catch {}
|
try {
|
||||||
|
$s = Get-Content -Raw -LiteralPath $_.FullName | ConvertFrom-Json
|
||||||
|
$n += @($s.entries.PSObject.Properties).Count
|
||||||
|
if ($s.texts) { $n += @($s.texts.PSObject.Properties).Count }
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
return $n
|
return $n
|
||||||
}
|
}
|
||||||
@@ -26,8 +30,8 @@ try {
|
|||||||
if (Get-Process -Name "UnheardCity" -ErrorAction SilentlyContinue) { throw "$Name is running. Close it first." }
|
if (Get-Process -Name "UnheardCity" -ErrorAction SilentlyContinue) { throw "$Name is running. Close it first." }
|
||||||
$mods = Modified-Count
|
$mods = Modified-Count
|
||||||
if ($mods -gt 0 -and -not $Quiet) {
|
if ($mods -gt 0 -and -not $Quiet) {
|
||||||
Write-Host "$mods sound(s) are still modified in the game. To restore the originals, open $Name and use"
|
Write-Host "$mods sound(s) or text(s) are still modified in the game. To restore the originals, open $Name and"
|
||||||
Write-Host "'Revert all' BEFORE uninstalling (your originals store is kept either way)."
|
Write-Host "use 'Revert all' BEFORE uninstalling (your originals store is kept either way)."
|
||||||
if ((Read-Host "Uninstall anyway? [y/N]") -notmatch '^(y|yes)$') { exit 1 }
|
if ((Read-Host "Uninstall anyway? [y/N]") -notmatch '^(y|yes)$') { exit 1 }
|
||||||
}
|
}
|
||||||
if (-not $Quiet -and -not $RemoveUserData -and $mods -eq 0 -and (Test-Path $Data[0])) {
|
if (-not $Quiet -and -not $RemoveUserData -and $mods -eq 0 -and (Test-Path $Data[0])) {
|
||||||
|
|||||||
Reference in New Issue
Block a user