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:
@@ -53,21 +53,29 @@ def resample(x, rate, new_rate):
|
||||
|
||||
|
||||
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)
|
||||
cur = x.shape[1]
|
||||
if cur == channels:
|
||||
return x
|
||||
if channels == 1:
|
||||
return x.mean(axis=1, keepdims=True)
|
||||
if cur == 1:
|
||||
return np.repeat(x, channels, axis=1)
|
||||
out = np.empty((len(x), channels), dtype=np.float32)
|
||||
keep = min(cur, channels)
|
||||
out[:, :keep] = x[:, :keep]
|
||||
if channels > keep:
|
||||
out[:, keep:] = x.mean(axis=1, keepdims=True)
|
||||
return out
|
||||
if channels > cur:
|
||||
out = np.empty((len(x), channels), dtype=np.float32)
|
||||
out[:, :cur] = x
|
||||
out[:, cur:] = x.mean(axis=1, keepdims=True)
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
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
|
||||
kind 'm' = an in-memory sound, 'p' = the in-memory head of a streamed WAD sound (prefetch); for 'p' rows
|
||||
`sound` is the WAD sound's key (e.g. ITA:2B14E472:3): speech lines have per-language media IDs and heads.
|
||||
Replacements are written in place into every copy (<= original size, zero-padded), so bank and package
|
||||
layouts never change.
|
||||
kind 'm' = an in-memory sound, 'p' = a bank copy of a streamed WAD recording (the in-memory head of a prefetched
|
||||
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):
|
||||
speech lines have per-language media IDs and heads.
|
||||
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 gzip
|
||||
@@ -157,7 +159,7 @@ class Scan:
|
||||
self.actions = {} # id -> target id
|
||||
self.sound_media = defaultdict(set) # sound object id -> media ids
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Index of every sound in the game's WAD archives, with names, categories and characters
|
||||
(sounds stored inside soundbanks are not indexed yet: docs/formats/banks.md).
|
||||
"""Index of every sound of the game, with names, categories and characters: the WAD archives (one per voice
|
||||
language, SFX) and the in-memory sounds stored inside soundbanks (archive BANK, docs/formats/banks.md).
|
||||
|
||||
Sources, in order:
|
||||
1. data/catalog/<ARCHIVE>.csv -- shipped catalog (instant) when the WAD fingerprint matches;
|
||||
2. otherwise a header scan of the WAD (a few seconds, cached in the user cache), classified with
|
||||
`classify` and labelled with data/catalog/characters.csv (speaker per sound name).
|
||||
1. data/catalog/<ARCHIVE>.csv -- shipped catalog (instant) when the game's fingerprint matches;
|
||||
2. otherwise a scan of the game (WAD headers: seconds; banks: minutes; cached in the user cache), classified
|
||||
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
|
||||
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
|
||||
@@ -517,14 +517,16 @@ class Catalog:
|
||||
def __init__(self, game, archives=None, progress=None):
|
||||
self.game = 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.locations = {} # BANK media id -> [Location]
|
||||
self.heads = {} # WAD sound key -> [Location] of its prefetch heads
|
||||
self.bank_sha1 = {} # BANK media id -> original checksum ('' if unknown), every language
|
||||
bank_sounds_ = []
|
||||
if banks.ARCHIVE in avail:
|
||||
# always loaded: speech lines may have prefetch heads that must be written with them
|
||||
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 l in ls:
|
||||
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).
|
||||
|
||||
Everyday use, by sound name (see `ackit find`):
|
||||
ackit find joker tape search names, categories, characters
|
||||
ackit info Speech_TAPE_JOKER_1_4 details, size limit, modified state
|
||||
ackit extract Speech_TAPE_JOKER_1_4 orig.wav save the original (or --current)
|
||||
ackit replace Speech_TAPE_JOKER_1_4 line.wav encode + install a replacement
|
||||
ackit revert Speech_TAPE_JOKER_1_4 put the original back (--all for everything)
|
||||
ackit status what you have changed
|
||||
ackit find joker tape search names, categories, characters
|
||||
ackit info Speech_TAPE_JOKER_1_04 details, size limit, modified state
|
||||
ackit extract Speech_TAPE_JOKER_1_04 orig.wav save the original (or --current)
|
||||
ackit replace Speech_TAPE_JOKER_1_04 line.wav encode + install a replacement
|
||||
ackit revert Speech_TAPE_JOKER_1_04 put the original back (--all: every sound and text)
|
||||
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
|
||||
|
||||
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 os
|
||||
@@ -36,7 +38,7 @@ def _workspace(a, archives=None):
|
||||
st = settings.load()
|
||||
game = _game(a)
|
||||
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,
|
||||
full_backup=not getattr(a, "no_full_backup", False) and st.get("full_backup", True),
|
||||
progress=lambda n, t: None)
|
||||
@@ -73,7 +75,8 @@ def cmd_list(a):
|
||||
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())]
|
||||
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:
|
||||
from .catalog import write_csv
|
||||
write_csv(a.csv, rows)
|
||||
@@ -178,6 +181,23 @@ def cmd_revert(a):
|
||||
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):
|
||||
ws = _workspace(a)
|
||||
keys = ws.modified_keys()
|
||||
@@ -286,7 +306,7 @@ def cmd_game(a):
|
||||
from .workspace import default_store_root
|
||||
print("game folder: ", g.root)
|
||||
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("settings file: ", settings.path())
|
||||
|
||||
@@ -311,7 +331,7 @@ def cmd_verify(a):
|
||||
ok = selfcheck.run(ws, log=print)
|
||||
sys.exit(0 if ok else 1)
|
||||
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 "
|
||||
"test %s in the game" % (ws.game.language(), a.lang))
|
||||
if a.banks:
|
||||
@@ -371,9 +391,12 @@ def build_parser():
|
||||
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_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")
|
||||
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_argument("action", choices=["find", "show", "set", "revert"])
|
||||
s.add_argument("args", nargs="*")
|
||||
|
||||
@@ -123,6 +123,14 @@ class Game:
|
||||
def languages(self):
|
||||
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):
|
||||
try:
|
||||
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"))
|
||||
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):
|
||||
"""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 f in ("UserEngine.ini", "BmEngine.ini"):
|
||||
p = os.path.join(d, f)
|
||||
@@ -163,7 +179,7 @@ class Game:
|
||||
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")
|
||||
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()
|
||||
if "INT" in self.archives:
|
||||
return "INT"
|
||||
|
||||
@@ -64,6 +64,21 @@ def parse(raw, lang, fname):
|
||||
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):
|
||||
"""New file bytes with {entry key: new value} applied (keys not in the file raise TextError)."""
|
||||
text, enc = _decode(raw)
|
||||
@@ -73,8 +88,7 @@ def write(raw, lang, fname, values):
|
||||
e = by_key.get(key)
|
||||
if e is None:
|
||||
raise TextError("no text %s in %s" % (key, fname))
|
||||
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)")
|
||||
check_value(value, enc, fname)
|
||||
line = lines[e.line]
|
||||
end = line[len(line.rstrip("\r\n")):]
|
||||
head = line[:line.index("=") + 1]
|
||||
|
||||
@@ -41,7 +41,7 @@ def export_mod(ws, path, keys, name="", author="", description="", texts=()):
|
||||
e = ws.store.get(key)
|
||||
if not e or not e.get("applied"):
|
||||
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"])
|
||||
x, sr = audio.decode_wem(wem)
|
||||
buf = io.BytesIO()
|
||||
|
||||
@@ -36,12 +36,33 @@ class SubtitleError(ValueError):
|
||||
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):
|
||||
"""-> (text, end offset, enc)"""
|
||||
n = struct.unpack_from("<i", b, o)[0]
|
||||
if n >= 0:
|
||||
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])
|
||||
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")
|
||||
if enc == "a":
|
||||
try:
|
||||
raw = text.encode("cp1252")
|
||||
raw = _encode_cp1252(text)
|
||||
except UnicodeEncodeError:
|
||||
raise SubtitleError("this subtitle is stored as Western European (Windows-1252) text; '%s' cannot be "
|
||||
"written"
|
||||
@@ -72,7 +93,7 @@ def encode_fstring(text, size, enc):
|
||||
|
||||
def _cp1252(c):
|
||||
try:
|
||||
c.encode("cp1252")
|
||||
_encode_cp1252(c)
|
||||
return True
|
||||
except UnicodeEncodeError:
|
||||
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)
|
||||
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
|
||||
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 time
|
||||
|
||||
from . import loctext, subtitles
|
||||
from .catalog import load_subtitles
|
||||
from . import banks, loctext, subtitles
|
||||
from .catalog import _shipped_index, catalog_dir, load_subtitles
|
||||
from .game import GameError
|
||||
|
||||
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):
|
||||
def __init__(self, keys):
|
||||
self.keys = keys
|
||||
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])
|
||||
+ (" ..." if len(keys) > 5 else ""))
|
||||
"tool): %s.\nRevert them or confirm to overwrite."
|
||||
% (", ".join(keys[:5]) + (" ..." if len(keys) > 5 else "")))
|
||||
|
||||
|
||||
class Texts:
|
||||
def __init__(self, ws):
|
||||
self.ws = ws
|
||||
self._subs = {} # lang -> ([SubLine], {key: [SubLoc]})
|
||||
self._sub_text = {} # lang -> {(speech, line): original text} (catalog)
|
||||
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")
|
||||
|
||||
# --- languages and lists ---------------------------------------------------------------------
|
||||
@@ -37,7 +51,6 @@ class Texts:
|
||||
return loctext.languages(self.loc_root) if self.loc_root else []
|
||||
|
||||
def subtitle_languages(self):
|
||||
from .catalog import _shipped_index
|
||||
langs = sorted(_shipped_index().get("SUB", {}).get("lines", {}))
|
||||
return langs or self.menu_languages()
|
||||
|
||||
@@ -54,8 +67,11 @@ class Texts:
|
||||
|
||||
def for_sound(self, sound, lang=None):
|
||||
"""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."""
|
||||
lang = lang or (sound.languages[0] if sound.languages else sound.archive)
|
||||
their own voices link their subtitles to the INT sounds, so e.g. JPN subtitles are found for INT lines.
|
||||
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():
|
||||
return []
|
||||
return [s for s in self.subtitles(lang) if sound.key in s.sounds]
|
||||
@@ -77,20 +93,44 @@ class Texts:
|
||||
def modified(self):
|
||||
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):
|
||||
lang = key.split(":")[1]
|
||||
raw = key[len("MENU:"):]
|
||||
for e in loctext.load(self.loc_root, lang):
|
||||
if e.key == raw:
|
||||
return e
|
||||
raise GameError("no menu text %s" % raw)
|
||||
path = self._menu_path(key)
|
||||
e = self._menu_entries(path, key.split(":")[1])[1].get(key[len("MENU:"):])
|
||||
if e is None:
|
||||
raise GameError("no menu text %s" % key[len("MENU:"):])
|
||||
return e
|
||||
|
||||
def files(self, key):
|
||||
"""Absolute paths of the files holding a text."""
|
||||
if key.startswith("SUB:"):
|
||||
return sorted({self.ws.bank_path(l.file) for l in self.subtitle_locations(key)})
|
||||
e = self._menu_entry(key)
|
||||
return [loctext.files(self.loc_root, e.lang)[e.file]]
|
||||
self._menu_entry(key)
|
||||
return [self._menu_path(key)]
|
||||
|
||||
def current(self, key):
|
||||
"""The text in the game files now."""
|
||||
@@ -98,22 +138,20 @@ class Texts:
|
||||
locs = self.subtitle_locations(key)
|
||||
if not locs:
|
||||
raise GameError("no subtitle %s" % key)
|
||||
from .banks import open_file
|
||||
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 self._menu_entry(key).value
|
||||
|
||||
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:"):
|
||||
return None
|
||||
_, lang, rest = key.split(":", 2)
|
||||
speech, line = rest.rsplit(":", 1)
|
||||
for s in self.subtitles(lang):
|
||||
if s.speech == speech and s.line == int(line):
|
||||
return s.text
|
||||
return None
|
||||
if lang not in self._sub_text:
|
||||
self._sub_text[lang] = {(s.speech, s.line): s.text for s in self.subtitles(lang)}
|
||||
return self._sub_text[lang].get((speech, int(line)))
|
||||
|
||||
def original(self, key):
|
||||
r = self.records().get(key)
|
||||
@@ -141,24 +179,54 @@ class Texts:
|
||||
for l in self.subtitle_locations(key)), default=0)
|
||||
|
||||
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:"):
|
||||
for l in self.subtitle_locations(key):
|
||||
subtitles.encode_fstring(text, l.size, l.enc)
|
||||
else:
|
||||
e = self._menu_entry(key)
|
||||
path = loctext.files(self.loc_root, e.lang)[e.file]
|
||||
loctext.write(open(path, "rb").read(), e.lang, e.file, {e.key: text})
|
||||
path = self._menu_path(key)
|
||||
self._menu_entry(key)
|
||||
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 --------------------------------------------------------------------------------
|
||||
@_locked
|
||||
def apply(self, changes, force=False, progress=None, meta=None):
|
||||
"""changes: {key: new text}. Every file holding one of them is rebuilt once."""
|
||||
for k, v in changes.items():
|
||||
self.check(k, v)
|
||||
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)}
|
||||
changed = set(self.ws.changed_files(paths))
|
||||
conflicts += [k for k in changes if k not in conflicts and set(self.files(k)) & changed]
|
||||
files = {k: self.files(k) for k in changes}
|
||||
paths = {p for ps in files.values() for p in ps}
|
||||
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:
|
||||
raise TextConflictError(conflicts)
|
||||
for p in paths:
|
||||
@@ -167,12 +235,13 @@ class Texts:
|
||||
now = time.time()
|
||||
for k, v in changes.items():
|
||||
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}
|
||||
recs[k].update(applied=v, applied_at=now, meta=meta or {})
|
||||
self.ws.store.save()
|
||||
self._sync(paths, progress)
|
||||
|
||||
@_locked
|
||||
def revert(self, keys, progress=None):
|
||||
recs = self.records()
|
||||
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.
|
||||
"""
|
||||
import struct
|
||||
import threading
|
||||
from collections import OrderedDict, namedtuple
|
||||
|
||||
from . import lzo
|
||||
@@ -41,6 +42,7 @@ class Package:
|
||||
self.file_size = f.seek(0, 2)
|
||||
self._cache = OrderedDict()
|
||||
self._cache_n = cache_blocks
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# summary -----------------------------------------------------------------------------------
|
||||
def _parse_summary(self, b, f):
|
||||
@@ -157,9 +159,11 @@ class Package:
|
||||
|
||||
# reading -----------------------------------------------------------------------------------
|
||||
def _block(self, i, f=None):
|
||||
if i in self._cache:
|
||||
self._cache.move_to_end(i)
|
||||
return self._cache[i]
|
||||
with self._lock: # the editor reads one package from several background jobs
|
||||
data = self._cache.get(i)
|
||||
if data is not None:
|
||||
self._cache.move_to_end(i)
|
||||
return data
|
||||
bl = self.blocks[i]
|
||||
own = f is None
|
||||
f = f or open(self.path, "rb")
|
||||
@@ -169,9 +173,10 @@ class Package:
|
||||
finally:
|
||||
if own:
|
||||
f.close()
|
||||
self._cache[i] = data
|
||||
if len(self._cache) > self._cache_n:
|
||||
self._cache.popitem(last=False)
|
||||
with self._lock:
|
||||
self._cache[i] = data
|
||||
if len(self._cache) > self._cache_n:
|
||||
self._cache.popitem(last=False)
|
||||
return data
|
||||
|
||||
def _block_index(self, off):
|
||||
|
||||
@@ -399,8 +399,9 @@ def check_speakers(rep, rows, col):
|
||||
def check_menu(rep, game):
|
||||
"""Menu texts: the same keys in every language (facts; differences are the game's own)."""
|
||||
from .. import loctext
|
||||
root = os.path.join(game.root, "BmGame", "Localization")
|
||||
if not os.path.isdir(root):
|
||||
from ..texts import _find_dir
|
||||
root = _find_dir(game.root, "BmGame", "Localization") # case-insensitive (Linux)
|
||||
if not root:
|
||||
return
|
||||
keys = {}
|
||||
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_speakers(rep, rows_all, col)
|
||||
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()}
|
||||
# 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):
|
||||
|
||||
@@ -104,9 +104,14 @@ def running_game_pids():
|
||||
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():
|
||||
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=()):
|
||||
|
||||
@@ -65,7 +65,9 @@ def run(ws, sample=300, log=print):
|
||||
if n >= sample // max(1, len(ws.game.archives)):
|
||||
break
|
||||
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"]
|
||||
if bank:
|
||||
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
|
||||
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
|
||||
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 os
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
|
||||
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 .game import Game, GameError
|
||||
from .inject import inject, read_target, parse_target, pack_bnk_path, SizeLimitError
|
||||
@@ -41,25 +46,50 @@ class ConflictError(GameError):
|
||||
self.sounds = sounds
|
||||
super().__init__("These sounds were changed outside this tool (Steam update or 'verify', or "
|
||||
"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):
|
||||
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:
|
||||
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.store = OriginalsStore(store_root or default_store_root(self.game), self.game.root)
|
||||
self.catalog = Catalog(self.game, archives, progress)
|
||||
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)
|
||||
|
||||
# reading -----------------------------------------------------------------------------------
|
||||
def sound(self, 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):
|
||||
return self.game.wad(s.archive)
|
||||
|
||||
@@ -241,6 +271,55 @@ class Workspace:
|
||||
out.append(path)
|
||||
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):
|
||||
"""[(offset, bytes)] that the sound replacements applied now need in bank file `rel` (cooked-relative)."""
|
||||
out = []
|
||||
@@ -271,6 +350,7 @@ class Workspace:
|
||||
edits = self._audio_edits(rel) + self.texts.subtitle_edits(rel)
|
||||
return banks.open_file(orig).patched(edits) if edits else open(orig, "rb").read()
|
||||
|
||||
@locked
|
||||
def sync_files(self, paths, progress=None):
|
||||
"""Rebuild each derived file from its original copy and the edits applied now (atomic writes)."""
|
||||
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.sync_files(files, progress)
|
||||
|
||||
@locked
|
||||
def apply(self, changes, force=False, progress=None):
|
||||
"""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)."""
|
||||
@@ -326,7 +407,8 @@ class Workspace:
|
||||
for s, _, _ in changes:
|
||||
if self.state(s) in (CHANGED, FOREIGN):
|
||||
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
|
||||
if s not in conflicts and {self.bank_path(l.file) for l in self.locations(s)} & changed]
|
||||
if conflicts and not force:
|
||||
@@ -337,6 +419,7 @@ class Workspace:
|
||||
for a, items in by_arch.items():
|
||||
self._write(a, items, progress)
|
||||
|
||||
@locked
|
||||
def revert(self, sounds, progress=None):
|
||||
"""Put the stored originals back (sounds without a record are skipped). Returns reverted."""
|
||||
by_arch = defaultdict(list)
|
||||
@@ -354,31 +437,50 @@ class Workspace:
|
||||
self.store.gc()
|
||||
return done
|
||||
|
||||
@locked
|
||||
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]
|
||||
missing = [k for k in self.store.entries if k not in self.catalog.by_key]
|
||||
if missing:
|
||||
# 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]
|
||||
"""Revert every sound this store has changed (texts: Texts.revert_all)."""
|
||||
# records of archives not loaded in this catalog (e.g. another language) included
|
||||
sounds = [s for s in map(self.sound_for_key, list(self.store.entries)) if s]
|
||||
return self.revert(sounds, progress)
|
||||
|
||||
def restore_from_full_backup(self):
|
||||
"""Last-resort fallback: copy back every full backup taken by this store."""
|
||||
@locked
|
||||
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 = []
|
||||
for rel in self.store.data.get("full", {}):
|
||||
src = self.store.full_copy(rel)
|
||||
for rel in full:
|
||||
if rel in derived:
|
||||
continue
|
||||
dst = os.path.join(self.game.root, rel)
|
||||
self.game.check_writable(dst)
|
||||
tmp = dst + ".ackit-tmp"
|
||||
import shutil
|
||||
shutil.copy2(src, tmp)
|
||||
shutil.copy2(self.store.full_copy(rel), tmp)
|
||||
os.replace(tmp, dst)
|
||||
out.append(rel)
|
||||
for rec in self._derived().values():
|
||||
rec["written_fp"] = None
|
||||
entries = self.store.data["entries"]
|
||||
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["entries"] = {}
|
||||
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()
|
||||
return out
|
||||
return sorted(out + [rel for rel in derived if rel in full])
|
||||
|
||||
Reference in New Issue
Block a user