Edit subtitles and menu text
This commit is contained in:
@@ -155,23 +155,6 @@ class Scan:
|
||||
self.issues = [] # (kind, detail)
|
||||
|
||||
|
||||
def _package_names(pkg, start, data):
|
||||
"""Name table of an uncompressed package."""
|
||||
out = []
|
||||
o = pkg.name_offset - start
|
||||
for _ in range(pkg.name_count):
|
||||
n = struct.unpack_from("<i", data, o)[0]
|
||||
o += 4
|
||||
if n > 0:
|
||||
out.append(bytes(data[o:o + n - 1]).decode("latin-1"))
|
||||
o += n
|
||||
else:
|
||||
out.append(bytes(data[o:o - 2 * n - 2]).decode("utf-16-le", "replace"))
|
||||
o += -2 * n
|
||||
o += 8
|
||||
return out
|
||||
|
||||
|
||||
def _bkpk_tables(data, packs):
|
||||
"""BKPK tables (docs/formats/wad.md) embedded in a package: WAD entry -> media offsets/sizes."""
|
||||
import re
|
||||
@@ -187,8 +170,9 @@ def _bkpk_tables(data, packs):
|
||||
packs[entry][mid] = (off, size)
|
||||
|
||||
|
||||
def scan(cooked, progress=None, cooked_banks=None, files=None):
|
||||
"""Scan every loose bank and package of the game (minutes), or only `files`. Returns a `Scan`."""
|
||||
def scan(cooked, progress=None, cooked_banks=None, files=None, visit=None):
|
||||
"""Scan every loose bank and package of the game (minutes), or only `files`. Returns a `Scan`.
|
||||
`visit(rel, package, start, data)` is called for every package (other scans share the decompression)."""
|
||||
sc = Scan()
|
||||
files = bank_files(cooked) if files is None else files
|
||||
if cooked_banks is None:
|
||||
@@ -206,8 +190,12 @@ def scan(cooked, progress=None, cooked_banks=None, files=None):
|
||||
sc.issues.append(("unreadable", "%s: %s" % (rel, e)))
|
||||
continue
|
||||
if isinstance(r, Package):
|
||||
sc.names.update(_package_names(r, start, data))
|
||||
names, _, exports = r.tables(data, start)
|
||||
sc.names.update(names) # base names ...
|
||||
sc.names.update(x.name for x in exports) # ... and numbered object names (Speech_X_04)
|
||||
_bkpk_tables(data, sc.packs)
|
||||
if visit:
|
||||
visit(rel, r, start, data)
|
||||
banks = list(find_banks(data))
|
||||
else:
|
||||
try:
|
||||
|
||||
@@ -18,7 +18,7 @@ import re
|
||||
import struct
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
from . import banks, classify
|
||||
from . import banks, classify, subtitles
|
||||
from .game import wad_fingerprint
|
||||
from .inject import pack_bnk_path
|
||||
from .pack import Pack
|
||||
@@ -228,9 +228,13 @@ def bank_sounds(sc, characters=None):
|
||||
|
||||
|
||||
def head_owners(game, sc, archives=None):
|
||||
"""{prefetch media id: WAD sound key} -- the speech line (one language) each prefetch head belongs to,
|
||||
found through the per-language BKPK tables of the packages (docs/formats/banks.md)."""
|
||||
want = {m for m, e in sc.media.items() if banks.PREFETCH_HEAD in e["kinds"]}
|
||||
"""{prefetch media id: WAD sound key} -- the speech line (one language) each prefetch head belongs to."""
|
||||
return media_owners(game, sc, {m for m, e in sc.media.items() if banks.PREFETCH_HEAD in e["kinds"]}, archives)
|
||||
|
||||
|
||||
def media_owners(game, sc, want, archives=None):
|
||||
"""{media id: WAD sound key} for streamed speech media, found through the per-language BKPK tables of
|
||||
the packages (docs/formats/banks.md): every language has its own media IDs for the same WAD entry."""
|
||||
out = {}
|
||||
for a in archives or [x for x in game.archives if x != "SFX"]:
|
||||
wad = Wad(game.wad(a))
|
||||
@@ -250,14 +254,61 @@ def head_owners(game, sc, archives=None):
|
||||
return out
|
||||
|
||||
|
||||
def scan_banks(game, progress=None, characters=None):
|
||||
def scan_banks(game, progress=None, characters=None, visit=None):
|
||||
"""Full scan of the game's banks -> ([Sound], [Location] with WAD keys on prefetch heads, Scan)."""
|
||||
sc = banks.scan(game.cooked, progress)
|
||||
sc = banks.scan(game.cooked, progress, visit=visit)
|
||||
owners = head_owners(game, sc)
|
||||
locs = [l._replace(sound=owners.get(l.media, "")) if l.kind == banks.PREFETCH_HEAD else l for l in sc.locations]
|
||||
return bank_sounds(sc, characters), locs, sc
|
||||
|
||||
|
||||
def subtitle_links(game, sc, speech_names):
|
||||
"""{speech event name: {language: WAD sound key}}: event -> actions -> containers -> sounds -> the
|
||||
per-language media of the streamed speech line (exact, no name matching)."""
|
||||
ev = banks.event_media(sc)
|
||||
want = {m for n in speech_names for m in ev.get(n, ())}
|
||||
owners = media_owners(game, sc, want)
|
||||
out = {}
|
||||
for n in speech_names:
|
||||
keys = {}
|
||||
for m in ev.get(n, ()):
|
||||
k = owners.get(m)
|
||||
if k:
|
||||
keys.setdefault(k.split(":")[0], k)
|
||||
if keys:
|
||||
out[n] = keys
|
||||
return out
|
||||
|
||||
|
||||
def scan_text(game, progress=None, characters=None):
|
||||
"""One pass over every package: (bank sounds, bank locations, Scan, {lang: [SubLine]}, [SubLoc], issues)."""
|
||||
col = subtitles.Collector()
|
||||
snd, locs, sc = scan_banks(game, progress, characters, visit=col)
|
||||
links = subtitle_links(game, sc, list(col.dialogues))
|
||||
texts, sublocs = subtitles.rows(col, links)
|
||||
return snd, locs, sc, texts, sublocs, col.issues
|
||||
|
||||
|
||||
def load_subtitles(game, lang, progress=None):
|
||||
"""([SubLine], {SUB key: [SubLoc]}) of one language: shipped catalog when the game matches it, else a
|
||||
cached or fresh scan of the packages (several minutes)."""
|
||||
fp = banks.game_fingerprint(game.cooked)
|
||||
shipped = _shipped_index().get("SUB", {})
|
||||
t = os.path.join(catalog_dir(), "SUB_%s.csv" % lang)
|
||||
loc = os.path.join(catalog_dir(), subtitles.LOCATIONS)
|
||||
if shipped.get("fingerprint") == fp and os.path.exists(t) and os.path.exists(loc):
|
||||
return subtitles.read_texts(t, lang), subtitles.read_locations(loc, lang)
|
||||
cache = os.path.join(user_dir("cache"), "scan_SUB_%s_v%d" % (fp, SCAN_VERSION))
|
||||
if not os.path.exists(cache + ".locations.csv.gz"):
|
||||
_, _, _, texts, sublocs, _ = scan_text(game, progress)
|
||||
for lg, rows in texts.items():
|
||||
subtitles.write_texts(cache + "_%s.csv" % lg, rows)
|
||||
subtitles.write_locations(cache + ".locations.csv.gz", sublocs)
|
||||
t = cache + "_%s.csv" % lang
|
||||
return (subtitles.read_texts(t, lang) if os.path.exists(t) else []), \
|
||||
subtitles.read_locations(cache + ".locations.csv.gz", lang)
|
||||
|
||||
|
||||
def load_banks(game, progress=None):
|
||||
"""([Sound], {media: [Location]}) of the in-memory sounds: shipped catalog when the game's bank set
|
||||
matches it, else a cached or fresh scan (several minutes)."""
|
||||
|
||||
65
ackit/cli.py
65
ackit/cli.py
@@ -171,37 +171,82 @@ def cmd_revert(a):
|
||||
ws = _workspace(a)
|
||||
if a.all:
|
||||
done = ws.revert_all(progress=print)
|
||||
else:
|
||||
done = ws.revert([ws.sound(n) for n in a.names], progress=print)
|
||||
texts = ws.texts.revert_all(progress=print)
|
||||
print("reverted %d sound(s) and %d text(s)" % (len(done), len(texts)))
|
||||
return
|
||||
done = ws.revert([ws.sound(n) for n in a.names], progress=print)
|
||||
print("reverted %d sound(s)" % len(done))
|
||||
|
||||
|
||||
def cmd_status(a):
|
||||
ws = _workspace(a)
|
||||
keys = ws.modified_keys()
|
||||
if not keys:
|
||||
print("no sounds modified; game audio is original")
|
||||
texts = ws.texts.modified()
|
||||
if not keys and not texts:
|
||||
print("no sounds or texts modified; game files are original")
|
||||
return
|
||||
for k in keys:
|
||||
s = ws.catalog.by_key.get(k)
|
||||
st = ws.state(s) if s else "(other language)"
|
||||
print("%-50s %s" % (s.name if s else k, st))
|
||||
for k in texts:
|
||||
print("%-50s %s" % (k, ws.texts.state(k)))
|
||||
|
||||
|
||||
def cmd_export_mod(a):
|
||||
from .modpack import export_mod
|
||||
ws = _workspace(a)
|
||||
keys = [ws.sound(n).key for n in a.names] if a.names else ws.modified_keys()
|
||||
texts = [] if a.names else ws.texts.modified()
|
||||
n = export_mod(ws, a.out, keys, a.title or os.path.splitext(os.path.basename(a.out))[0], a.author or "",
|
||||
a.description or "")
|
||||
print("wrote %s (%d sounds)" % (a.out, n))
|
||||
a.description or "", texts=texts)
|
||||
print("wrote %s (%d sounds, %d texts)" % (a.out, len(keys), n - len(keys)))
|
||||
|
||||
|
||||
def cmd_import_mod(a):
|
||||
from .modpack import import_mod
|
||||
ws = _workspace(a)
|
||||
for im in import_mod(ws, a.file, force=a.force, progress=print):
|
||||
items, texts = import_mod(ws, a.file, force=a.force, progress=print)
|
||||
for im in items:
|
||||
print("%-8s %-46s %s" % (im.how, im.item.get("name"), im.note))
|
||||
for k, _, problem in texts:
|
||||
print("%-8s %-46s %s" % ("text", k, problem or "applied"))
|
||||
|
||||
|
||||
def cmd_text(a):
|
||||
"""Subtitles and menu text: find, show, set, revert."""
|
||||
ws = _workspace(a)
|
||||
tx = ws.texts
|
||||
lang = (a.lang or ws.game.language()).upper()
|
||||
if a.action == "find":
|
||||
words = [w.lower() for w in a.args]
|
||||
rows = []
|
||||
if a.kind in ("all", "subs"):
|
||||
rows += [(subtitles_key(s), s.text) for s in tx.subtitles(lang)]
|
||||
if a.kind in ("all", "menu"):
|
||||
rows += [("MENU:" + e.key, e.value) for e in tx.menu(lang)]
|
||||
hits = [(k, v) for k, v in rows if all(w in (k + " " + v).lower() for w in words)]
|
||||
for k, v in hits[:a.limit]:
|
||||
print("%s\n %s" % (k, v))
|
||||
print("%d match(es)%s" % (len(hits), "" if len(hits) <= a.limit else " (first %d shown)" % a.limit))
|
||||
elif a.action == "show":
|
||||
for k in a.args:
|
||||
print("%s\n now: %s\n original: %s\n state: %s%s" % (
|
||||
k, tx.current(k), tx.original(k), tx.state(k),
|
||||
"" if tx.limit(k) is None else "\n room: %d characters" % tx.limit(k)))
|
||||
elif a.action == "set":
|
||||
if len(a.args) != 2:
|
||||
raise GameError("usage: ackit text set KEY \"new text\"")
|
||||
tx.apply({a.args[0]: a.args[1]}, force=a.force, progress=print)
|
||||
print("set %s" % a.args[0])
|
||||
elif a.action == "revert":
|
||||
done = tx.revert_all(progress=print) if a.all else tx.revert(a.args, progress=print)
|
||||
print("reverted %d text(s)" % len(done))
|
||||
|
||||
|
||||
def subtitles_key(s):
|
||||
from .subtitles import key
|
||||
return key(s.lang, s.speech, s.line)
|
||||
|
||||
|
||||
# --- advanced ---------------------------------------------------------------------------------
|
||||
@@ -321,6 +366,12 @@ def build_parser():
|
||||
s = add("revert", cmd_revert, "put original sounds back", aliases=["restore"])
|
||||
s.add_argument("names", nargs="*"); s.add_argument("--all", action="store_true")
|
||||
add("status", cmd_status, "list the sounds you modified")
|
||||
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="*")
|
||||
s.add_argument("--kind", choices=["all", "subs", "menu"], default="all", help="with find: what to search")
|
||||
s.add_argument("--limit", type=int, default=40); s.add_argument("--all", action="store_true")
|
||||
s.add_argument("--force", action="store_true", help="overwrite texts changed outside this tool")
|
||||
s = add("export-mod", cmd_export_mod, "save your changes as a shareable .acmod package")
|
||||
s.add_argument("out"); s.add_argument("names", nargs="*", help="default: everything you modified")
|
||||
s.add_argument("--title"); s.add_argument("--author"); s.add_argument("--description")
|
||||
|
||||
110
ackit/loctext.py
Normal file
110
ackit/loctext.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Menu and UI text: the game's localization files BmGame/Localization/<LANG>/<File>.<lang>
|
||||
(UE3 .ini format; docs/formats/text.md).
|
||||
|
||||
Files are UTF-16LE with a BOM and CRLF line ends (two small ones are plain ASCII). Lines are `[Section]`,
|
||||
`Key=Value` (the value may be in double quotes) or comments (`;`). A key can repeat inside a section, so an
|
||||
entry is identified by (file, section, key, occurrence). Writing changes only the value part of the edited
|
||||
lines; every other byte of the file (BOM, encoding, quotes, line ends, comments) stays as it was.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from collections import namedtuple
|
||||
|
||||
# key: "<LANG>:<File>:<Section>:<Key>" + ("#n" for the n-th repeat, n >= 2)
|
||||
Entry = namedtuple("Entry", "key lang file section name occurrence value quoted line")
|
||||
|
||||
LANG_DIRS = ("INT", "ITA", "FRA", "DEU", "ESN", "ESM", "POL", "POR", "RUS", "JPN", "KOR")
|
||||
|
||||
|
||||
class TextError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _decode(raw):
|
||||
if raw[:2] == b"\xff\xfe":
|
||||
return raw[2:].decode("utf-16-le"), "utf-16"
|
||||
if raw[:3] == b"\xef\xbb\xbf":
|
||||
return raw[3:].decode("utf-8"), "utf-8"
|
||||
return raw.decode("cp1252"), "cp1252"
|
||||
|
||||
|
||||
def _encode(text, enc):
|
||||
if enc == "utf-16":
|
||||
return b"\xff\xfe" + text.encode("utf-16-le")
|
||||
if enc == "utf-8":
|
||||
return b"\xef\xbb\xbf" + text.encode("utf-8")
|
||||
return text.encode("cp1252")
|
||||
|
||||
|
||||
_KV = re.compile(r"^([^=;\[\s][^=]*?)=(.*)$")
|
||||
|
||||
|
||||
def parse(raw, lang, fname):
|
||||
"""Entries of one file's bytes."""
|
||||
text, _ = _decode(raw)
|
||||
out = []
|
||||
section = ""
|
||||
seen = {}
|
||||
for i, line in enumerate(re.split(r"(?<=\n)", text)):
|
||||
body = line.rstrip("\r\n")
|
||||
s = body.strip()
|
||||
if s.startswith("[") and s.endswith("]"):
|
||||
section = s[1:-1]
|
||||
continue
|
||||
m = _KV.match(body)
|
||||
if not m or s.startswith(";"):
|
||||
continue
|
||||
name, value = m.group(1), m.group(2)
|
||||
quoted = len(value) >= 2 and value[0] == '"' and value[-1] == '"'
|
||||
if quoted:
|
||||
value = value[1:-1]
|
||||
k = (section.lower(), name.lower())
|
||||
seen[k] = seen.get(k, 0) + 1
|
||||
n = seen[k]
|
||||
key = "%s:%s:%s:%s" % (lang, fname, section, name) + ("#%d" % n if n > 1 else "")
|
||||
out.append(Entry(key, lang, fname, section, name, n, value, quoted, i))
|
||||
return out
|
||||
|
||||
|
||||
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)
|
||||
lines = re.split(r"(?<=\n)", text)
|
||||
by_key = {e.key: e for e in parse(raw, lang, fname)}
|
||||
for key, value in values.items():
|
||||
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)")
|
||||
line = lines[e.line]
|
||||
end = line[len(line.rstrip("\r\n")):]
|
||||
head = line[:line.index("=") + 1]
|
||||
lines[e.line] = head + ('"%s"' % value if e.quoted else value) + end
|
||||
out = "".join(lines)
|
||||
try:
|
||||
return _encode(out, enc)
|
||||
except UnicodeEncodeError:
|
||||
raise TextError("%s is stored as %s and cannot hold these characters" % (fname, enc)) from None
|
||||
|
||||
|
||||
def files(loc_root, lang):
|
||||
"""{file name: path} of one language folder (case-insensitive): localization files, without the
|
||||
Version*.int build IDs (not UI text)."""
|
||||
d = next((os.path.join(loc_root, x) for x in os.listdir(loc_root) if x.upper() == lang.upper()), None)
|
||||
if not d:
|
||||
return {}
|
||||
return {f: os.path.join(d, f) for f in sorted(os.listdir(d))
|
||||
if os.path.splitext(f)[1].lower() == "." + lang.lower() and not f.lower().startswith("version")}
|
||||
|
||||
|
||||
def languages(loc_root):
|
||||
return sorted(x.upper() for x in os.listdir(loc_root) if os.path.isdir(os.path.join(loc_root, x)))
|
||||
|
||||
|
||||
def load(loc_root, lang):
|
||||
"""All entries of a language: [Entry]."""
|
||||
out = []
|
||||
for f, p in files(loc_root, lang).items():
|
||||
out += parse(open(p, "rb").read(), lang.upper(), f)
|
||||
return out
|
||||
@@ -6,6 +6,8 @@ A package is a zip:
|
||||
items/NNN.flac the same audio, decoded (used to re-encode when the target differs, e.g. another
|
||||
language whose original has a different size)
|
||||
Each item records archive, target, name, group and the SHA-1 of the original it replaced.
|
||||
"texts": [{"key", "text", "original"}] edited subtitles / menu texts (keys: ackit/texts.py); optional,
|
||||
so packages without texts stay valid
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
@@ -22,9 +24,17 @@ from .originals import sha1_16
|
||||
FORMAT = 1
|
||||
|
||||
|
||||
def export_mod(ws, path, keys, name="", author="", description=""):
|
||||
"""Write the applied versions of `keys` (from the originals store) into a package."""
|
||||
def export_mod(ws, path, keys, name="", author="", description="", texts=()):
|
||||
"""Write the applied versions of sound `keys` and text keys `texts` (from the originals store) into a
|
||||
package. Returns the number of entries."""
|
||||
items = []
|
||||
recs = ws.texts.records()
|
||||
text_items = []
|
||||
for k in texts:
|
||||
r = recs.get(k)
|
||||
if not r or r.get("applied") is None:
|
||||
raise ValueError("%s is not modified" % k)
|
||||
text_items.append({"key": k, "text": r["applied"], "original": r["orig"]})
|
||||
with zipfile.ZipFile(path + ".tmp", "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for i, key in enumerate(keys):
|
||||
e = ws.store.get(key)
|
||||
@@ -43,9 +53,10 @@ def export_mod(ws, path, keys, name="", author="", description=""):
|
||||
"wem": base + ".wem", "audio": base + ".flac", "meta": e.get("meta", {})})
|
||||
z.writestr("manifest.json", json.dumps({"format": FORMAT, "name": name, "author": author,
|
||||
"description": description, "created": time.time(),
|
||||
"game": "Batman: Arkham City (PC)", "items": items}, indent=1))
|
||||
"game": "Batman: Arkham City (PC)", "items": items,
|
||||
"texts": text_items}, indent=1, ensure_ascii=False))
|
||||
os.replace(path + ".tmp", path)
|
||||
return len(items)
|
||||
return len(items) + len(text_items)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -89,6 +100,20 @@ def match_mod(ws, path):
|
||||
return man, out
|
||||
|
||||
|
||||
def match_texts(ws, man):
|
||||
"""[(key, text, problem or '')] for the package's texts in this game (problem: why it cannot be applied)."""
|
||||
out = []
|
||||
for it in man.get("texts", []):
|
||||
k, text = it["key"], it["text"]
|
||||
try:
|
||||
ws.texts.current(k)
|
||||
ws.texts.check(k, text)
|
||||
out.append((k, text, ""))
|
||||
except Exception as e: # missing key, too long, characters the file cannot hold
|
||||
out.append((k, text, str(e)))
|
||||
return out
|
||||
|
||||
|
||||
def prepare_import(ws, path, items, progress=None):
|
||||
"""Fill `wem` for matched items: reuse the packaged WEM when it targets the very same original,
|
||||
otherwise re-encode the packaged audio for the local sound."""
|
||||
@@ -110,11 +135,15 @@ def prepare_import(ws, path, items, progress=None):
|
||||
|
||||
|
||||
def import_mod(ws, path, force=False, progress=None):
|
||||
"""Match, prepare and apply a package. Returns the list of ImportItem (see .how/.note)."""
|
||||
_, items = match_mod(ws, path)
|
||||
"""Match, prepare and apply a package. Returns ([ImportItem] (see .how/.note), [(key, text, problem)])."""
|
||||
man, items = match_mod(ws, path)
|
||||
prepare_import(ws, path, items, progress)
|
||||
changes = [(im.sound, im.wem, {"source": "mod:" + path.replace("\\", "/").split("/")[-1]})
|
||||
for im in items if im.wem]
|
||||
source = {"source": "mod:" + path.replace("\\", "/").split("/")[-1]}
|
||||
changes = [(im.sound, im.wem, source) for im in items if im.wem]
|
||||
if changes:
|
||||
ws.apply(changes, force=force, progress=progress)
|
||||
return items
|
||||
texts = match_texts(ws, man)
|
||||
ok = {k: v for k, v, problem in texts if not problem}
|
||||
if ok:
|
||||
ws.texts.apply(ok, force=force, progress=progress, meta=source)
|
||||
return items, texts
|
||||
|
||||
255
ackit/subtitles.py
Normal file
255
ackit/subtitles.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""Subtitles: the RDialogueEvent objects inside the game's packages (docs/formats/subtitles.md).
|
||||
|
||||
Each dialogue object references its speech event (an export named like the speech line, e.g.
|
||||
'Speech_CHURCH_B1_Ch2_POST_Explosion_BM_Cash_04') and holds the subtitle text of every language:
|
||||
LocalizedSubtitles: array of {Language: FString, Subtitles: array of {Text: FString, (float @0x0c)}}
|
||||
in Rocksteady's compact tagged-property format (u16 type, u16 field offset, value; structs and arrays with
|
||||
a byte size; struct elements end with a u16 0).
|
||||
|
||||
A dialogue object can be stored in several packages; a subtitle edit is written into every copy, in place:
|
||||
the new text replaces the bytes of the old FString and is padded with NUL characters (the game stops at the
|
||||
first NUL), so no size field, export or package layout changes. The text must therefore fit the original
|
||||
string's bytes (Latin-1/cp1252 strings: one byte per character; UTF-16 strings: two).
|
||||
"""
|
||||
import csv
|
||||
import gzip
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
from collections import defaultdict, namedtuple
|
||||
|
||||
from .upk import Package
|
||||
|
||||
CLASS = "RDialogueEvent"
|
||||
T_STRUCT, T_STRING, T_OBJECT = 0x09, 0x0D, 0x10
|
||||
|
||||
# one subtitle string inside a package: pos = absolute uncompressed offset of the FString length field,
|
||||
# size = bytes of the whole FString (length field included), enc 'a' (cp1252) or 'w' (UTF-16)
|
||||
Field = namedtuple("Field", "lang index text pos size enc")
|
||||
Dialogue = namedtuple("Dialogue", "speech export fields") # speech: name of the referenced speech event
|
||||
|
||||
|
||||
class SubtitleError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
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"
|
||||
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"
|
||||
|
||||
|
||||
def encode_fstring(text, size, enc):
|
||||
"""The FString bytes (length field included) for `text`, exactly `size` bytes long (NUL padded).
|
||||
Raises SubtitleError when the text does not fit."""
|
||||
if "\0" in text:
|
||||
raise SubtitleError("the text must not contain NUL characters")
|
||||
if enc == "a":
|
||||
try:
|
||||
raw = text.encode("cp1252")
|
||||
except UnicodeEncodeError:
|
||||
raise SubtitleError("this subtitle is stored as Latin-1 text; '%s' cannot be written"
|
||||
% next(c for c in text if not _cp1252(c))) from None
|
||||
n = size - 4
|
||||
if len(raw) + 1 > n:
|
||||
raise SubtitleError("the text is %d characters long but this subtitle has room for %d"
|
||||
% (len(raw), n - 1))
|
||||
return struct.pack("<i", n) + raw + bytes(n - len(raw))
|
||||
raw = text.encode("utf-16-le")
|
||||
n = (size - 4) // 2
|
||||
if len(raw) // 2 + 1 > n:
|
||||
raise SubtitleError("the text is %d characters long but this subtitle has room for %d" % (len(raw) // 2, n - 1))
|
||||
return struct.pack("<i", -n) + raw + bytes(2 * n - len(raw))
|
||||
|
||||
|
||||
def _cp1252(c):
|
||||
try:
|
||||
c.encode("cp1252")
|
||||
return True
|
||||
except UnicodeEncodeError:
|
||||
return False
|
||||
|
||||
|
||||
def room(field):
|
||||
"""Maximum number of characters for a subtitle string."""
|
||||
return (field.size - 4 - 1) if field.enc == "a" else (field.size - 4) // 2 - 1
|
||||
|
||||
|
||||
def _parse_lines(b, o, end, base, lang):
|
||||
"""Subtitles array content [o, end) -> [Field]."""
|
||||
count = struct.unpack_from("<I", b, o)[0]
|
||||
o += 4
|
||||
out = []
|
||||
for i in range(count):
|
||||
# element: props until u16 0; the text is the string at field offset 0
|
||||
while True:
|
||||
t, fo = struct.unpack_from("<HH", b, o)
|
||||
if t == 0:
|
||||
o += 2
|
||||
break
|
||||
if t == T_STRING:
|
||||
text, e, enc = read_fstring(b, o + 4)
|
||||
if fo == 0:
|
||||
out.append(Field(lang, i, text, base + o + 4, e - (o + 4), enc))
|
||||
o = e
|
||||
elif t == 0x04: # 4-byte value (the line's time)
|
||||
o += 8
|
||||
else:
|
||||
raise SubtitleError("unexpected property type %#x in a subtitle line" % t)
|
||||
if o > end:
|
||||
raise SubtitleError("subtitle line overruns its array")
|
||||
if o != end:
|
||||
raise SubtitleError("subtitle array size mismatch")
|
||||
return out
|
||||
|
||||
|
||||
def parse_dialogue(b, base, names_idx):
|
||||
"""Fields of one RDialogueEvent export (bytes `b` starting at uncompressed offset `base`)."""
|
||||
ls, sub = names_idx
|
||||
i = b.find(struct.pack("<II", ls, 0))
|
||||
if i < 4 or struct.unpack_from("<H", b, i - 4)[0] != T_STRUCT:
|
||||
return []
|
||||
size = struct.unpack_from("<i", b, i + 8)[0]
|
||||
o, end = i + 16, i + 16 + size
|
||||
count = struct.unpack_from("<I", b, o)[0]
|
||||
o += 4
|
||||
fields = []
|
||||
for _ in range(count):
|
||||
lang = None
|
||||
while True:
|
||||
t, fo = struct.unpack_from("<HH", b, o)
|
||||
if t == 0:
|
||||
o += 2
|
||||
break
|
||||
if t == T_STRING:
|
||||
lang, o, _ = read_fstring(b, o + 4)
|
||||
elif t == T_STRUCT:
|
||||
nm, _nn, ssize, _ai = struct.unpack_from("<iiii", b, o + 4)
|
||||
c0 = o + 20
|
||||
if nm == sub:
|
||||
fields += _parse_lines(b, c0, c0 + ssize, base, lang)
|
||||
o = c0 + ssize
|
||||
else:
|
||||
raise SubtitleError("unexpected property type %#x in a localized subtitle" % t)
|
||||
if o != end:
|
||||
raise SubtitleError("localized subtitle size mismatch")
|
||||
return fields
|
||||
|
||||
|
||||
def _speech_ref(b, exports, imports):
|
||||
"""Name of the object referenced by the dialogue's first object property (its speech event)."""
|
||||
m = re.search(rb"\x10\x00\x2c\x00", b)
|
||||
if not m:
|
||||
return ""
|
||||
ref = struct.unpack_from("<i", b, m.start() + 4)[0]
|
||||
if ref > 0 and ref <= len(exports):
|
||||
return exports[ref - 1].name
|
||||
if ref < 0 and -ref <= len(imports):
|
||||
return imports[-ref - 1][1]
|
||||
return ""
|
||||
|
||||
|
||||
class Collector:
|
||||
"""`visit` hook for banks.scan: collects the dialogue objects of every package."""
|
||||
|
||||
def __init__(self):
|
||||
self.dialogues = defaultdict(list) # speech name -> [(file, Dialogue)]
|
||||
self.issues = []
|
||||
|
||||
def __call__(self, rel, pkg, start, data):
|
||||
try:
|
||||
for d in scan_package(pkg, data, start):
|
||||
self.dialogues[d.speech].append((rel, d))
|
||||
except (SubtitleError, struct.error, ValueError, IndexError) as e:
|
||||
self.issues.append(("subtitles", "%s: %s" % (rel, e)))
|
||||
|
||||
|
||||
# --- catalog files: SUB_<LANG>.csv (texts) + SUB_locations.csv.gz (every copy) ------------------
|
||||
LOCATIONS = "SUB_locations.csv.gz"
|
||||
TEXT_FIELDS = ["speech", "line", "text", "room", "enc", "sound"]
|
||||
SubLine = namedtuple("SubLine", "lang speech line text room enc sound") # sound: linked WAD sound key
|
||||
SubLoc = namedtuple("SubLoc", "lang speech line file pos size enc")
|
||||
|
||||
|
||||
def key(lang, speech, line):
|
||||
return "SUB:%s:%s:%d" % (lang, speech, line)
|
||||
|
||||
|
||||
def rows(collector, links):
|
||||
"""({lang: [SubLine]}, [SubLoc]) from a Collector; links: {speech: {lang: sound key}}."""
|
||||
texts = defaultdict(list)
|
||||
locs = []
|
||||
for speech in sorted(collector.dialogues):
|
||||
copies = collector.dialogues[speech]
|
||||
rel0, d0 = copies[0]
|
||||
for f in d0.fields:
|
||||
ln = links.get(speech, {})
|
||||
snd = ln.get(f.lang) or ln.get("INT", "")
|
||||
texts[f.lang].append(SubLine(f.lang, speech, f.index, f.text, room(f), f.enc, snd))
|
||||
for rel, d in copies:
|
||||
for f in d.fields:
|
||||
locs.append(SubLoc(f.lang, speech, f.index, rel, f.pos, f.size, f.enc))
|
||||
return dict(texts), locs
|
||||
|
||||
|
||||
def write_texts(path, lines):
|
||||
with open(path + ".tmp", "w", encoding="utf-8", newline="") as fh:
|
||||
w = csv.writer(fh, lineterminator="\n")
|
||||
w.writerow(TEXT_FIELDS)
|
||||
for s in lines:
|
||||
w.writerow([s.speech, s.line, s.text, s.room, s.enc, s.sound])
|
||||
os.replace(path + ".tmp", path)
|
||||
|
||||
|
||||
def read_texts(path, lang):
|
||||
with open(path, encoding="utf-8", newline="") as fh:
|
||||
return [SubLine(lang, r["speech"], int(r["line"]), r["text"], int(r["room"]), r["enc"], r["sound"])
|
||||
for r in csv.DictReader(fh)]
|
||||
|
||||
|
||||
def write_locations(path, locs):
|
||||
raw = io.StringIO()
|
||||
w = csv.writer(raw, lineterminator="\n")
|
||||
w.writerow(["lang", "speech", "line", "file", "pos", "size", "enc"])
|
||||
for l in sorted(locs, key=lambda l: (l.lang, l.speech, l.line, l.file, l.pos)):
|
||||
w.writerow([l.lang, l.speech, l.line, l.file, "%X" % l.pos, l.size, l.enc])
|
||||
with open(path + ".tmp", "wb") as fh:
|
||||
with gzip.GzipFile(fileobj=fh, mode="wb", mtime=0) as gz:
|
||||
gz.write(raw.getvalue().encode("utf-8"))
|
||||
os.replace(path + ".tmp", path)
|
||||
|
||||
|
||||
def read_locations(path, lang=None):
|
||||
"""{SUB key: [SubLoc]} (only `lang` if given)."""
|
||||
out = defaultdict(list)
|
||||
with gzip.open(path, "rt", encoding="utf-8", newline="") as fh:
|
||||
for r in csv.DictReader(fh):
|
||||
if lang and r["lang"] != lang:
|
||||
continue
|
||||
l = SubLoc(r["lang"], r["speech"], int(r["line"]), r["file"], int(r["pos"], 16), int(r["size"]), r["enc"])
|
||||
out[key(l.lang, l.speech, l.line)].append(l)
|
||||
return dict(out)
|
||||
|
||||
|
||||
def scan_package(pkg, data=None, start=None, tables=None):
|
||||
"""[Dialogue] of one package (empty if it has none)."""
|
||||
if data is None:
|
||||
start, data = pkg.read_all()
|
||||
names, imports, exports = tables or pkg.tables(data, start)
|
||||
cls = {-(i + 1) for i, (c, o, _) in enumerate(imports) if c == "Class" and o == CLASS}
|
||||
if not cls or "LocalizedSubtitles" not in names or "Subtitles" not in names:
|
||||
return []
|
||||
idx = (names.index("LocalizedSubtitles"), names.index("Subtitles"))
|
||||
out = []
|
||||
for e in exports:
|
||||
if e.cls in cls and e.size > 0:
|
||||
b = data[e.offset - start:e.offset - start + e.size]
|
||||
fields = parse_dialogue(b, e.offset, idx)
|
||||
if fields:
|
||||
out.append(Dialogue(_speech_ref(b, exports, imports), e.name, fields))
|
||||
return out
|
||||
222
ackit/texts.py
Normal file
222
ackit/texts.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Subtitles and menu text: reading, states, apply and revert (shared by the CLI and the editor).
|
||||
|
||||
Keys (also used in mod packages):
|
||||
SUB:<LANG>:<speech event>:<line> a subtitle string (docs/formats/subtitles.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
|
||||
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.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
|
||||
from . import loctext, subtitles
|
||||
from .catalog import load_subtitles
|
||||
from .game import GameError
|
||||
|
||||
ORIGINAL, MODIFIED, LOST, CHANGED, FOREIGN = "original", "modified", "lost", "changed", "foreign"
|
||||
|
||||
|
||||
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 ""))
|
||||
|
||||
|
||||
class Texts:
|
||||
def __init__(self, ws):
|
||||
self.ws = ws
|
||||
self._subs = {} # lang -> ([SubLine], {key: [SubLoc]})
|
||||
self._menu = {} # lang -> [Entry]
|
||||
self.loc_root = _find_dir(ws.game.root, "BmGame", "Localization")
|
||||
|
||||
# --- languages and lists ---------------------------------------------------------------------
|
||||
def menu_languages(self):
|
||||
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()
|
||||
|
||||
def subtitles(self, lang, progress=None):
|
||||
"""[SubLine] of a language (original texts from the catalog; use `current` for the game's text)."""
|
||||
if lang not in self._subs:
|
||||
self._subs[lang] = load_subtitles(self.ws.game, lang, progress)
|
||||
return self._subs[lang][0]
|
||||
|
||||
def subtitle_locations(self, key):
|
||||
lang = key.split(":")[1]
|
||||
self.subtitles(lang)
|
||||
return self._subs[lang][1].get(key, [])
|
||||
|
||||
def for_sound(self, sound):
|
||||
"""Subtitle lines of a speech sound in its own language (or the INT line for other languages)."""
|
||||
lang = sound.archive if sound.archive in self.subtitle_languages() else None
|
||||
if not lang:
|
||||
return []
|
||||
return [s for s in self.subtitles(lang) if s.sound == sound.key]
|
||||
|
||||
def menu(self, lang):
|
||||
"""[loctext.Entry] of the game's current files (the values the game shows now)."""
|
||||
if lang not in self._menu:
|
||||
self._menu[lang] = loctext.load(self.loc_root, lang) if self.loc_root else []
|
||||
return self._menu[lang]
|
||||
|
||||
def is_menu_file(self, path):
|
||||
return bool(self.loc_root) and os.path.normcase(os.path.abspath(path)).startswith(
|
||||
os.path.normcase(os.path.abspath(self.loc_root)) + os.sep)
|
||||
|
||||
# --- values and states ----------------------------------------------------------------------
|
||||
def records(self):
|
||||
return self.ws.store.data.setdefault("texts", {})
|
||||
|
||||
def modified(self):
|
||||
return [k for k, r in self.records().items() if r.get("applied") is not None]
|
||||
|
||||
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)
|
||||
|
||||
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]]
|
||||
|
||||
def current(self, key):
|
||||
"""The text in the game files now."""
|
||||
if key.startswith("SUB:"):
|
||||
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)
|
||||
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."""
|
||||
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
|
||||
|
||||
def original(self, key):
|
||||
r = self.records().get(key)
|
||||
return r["orig"] if r else self.current(key)
|
||||
|
||||
def state(self, key, current=None):
|
||||
cur = self.current(key) if current is None else current
|
||||
r = self.records().get(key)
|
||||
if r:
|
||||
if r.get("applied") is not None and cur == r["applied"]:
|
||||
return MODIFIED
|
||||
if cur == r["orig"]:
|
||||
return LOST if r.get("applied") is not None else ORIGINAL
|
||||
return CHANGED
|
||||
cat = self.catalog_text(key)
|
||||
if cat is not None and cur != cat:
|
||||
return FOREIGN
|
||||
return ORIGINAL
|
||||
|
||||
def limit(self, key):
|
||||
"""Maximum characters for a subtitle (None = no limit: menu text)."""
|
||||
if not key.startswith("SUB:"):
|
||||
return None
|
||||
return min((subtitles.room(subtitles.Field(l.lang, l.line, "", l.pos, l.size, l.enc))
|
||||
for l in self.subtitle_locations(key)), default=0)
|
||||
|
||||
def check(self, key, text):
|
||||
"""Raise SubtitleError / TextError if `text` cannot be written for `key`."""
|
||||
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})
|
||||
|
||||
# --- writing --------------------------------------------------------------------------------
|
||||
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]
|
||||
if conflicts and not force:
|
||||
raise TextConflictError(conflicts)
|
||||
for p in paths:
|
||||
self.ws._keep_original(p)
|
||||
recs = self.records()
|
||||
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)],
|
||||
"first_modified": now, "applied": None}
|
||||
recs[k].update(applied=v, applied_at=now, meta=meta or {})
|
||||
self.ws.store.save()
|
||||
self._sync(paths, progress)
|
||||
|
||||
def revert(self, keys, progress=None):
|
||||
recs = self.records()
|
||||
keys = [k for k in keys if k in recs]
|
||||
paths = {p for k in keys for p in self.files(k)}
|
||||
for k in keys:
|
||||
recs.pop(k)
|
||||
self.ws.store.save()
|
||||
self._sync(paths, progress)
|
||||
return keys
|
||||
|
||||
def revert_all(self, progress=None):
|
||||
return self.revert(list(self.records()), progress)
|
||||
|
||||
def _sync(self, paths, progress):
|
||||
self._menu.clear()
|
||||
self.ws.sync_files(paths, progress)
|
||||
|
||||
# --- derived-file hooks (called by Workspace.sync_files) ------------------------------------
|
||||
def subtitle_edits(self, rel):
|
||||
out = []
|
||||
for k, r in self.records().items():
|
||||
if r.get("applied") is None or not k.startswith("SUB:"):
|
||||
continue
|
||||
for l in self.subtitle_locations(k):
|
||||
if l.file == rel:
|
||||
out.append((l.pos, subtitles.encode_fstring(r["applied"], l.size, l.enc)))
|
||||
return out
|
||||
|
||||
def menu_content(self, path, orig_raw):
|
||||
fname = os.path.basename(path)
|
||||
lang = os.path.basename(os.path.dirname(path)).upper()
|
||||
values = {}
|
||||
for k, r in self.records().items():
|
||||
if r.get("applied") is None or not k.startswith("MENU:"):
|
||||
continue
|
||||
_, lg, f, _rest = k.split(":", 3)
|
||||
if lg == lang and f == fname:
|
||||
values[k[len("MENU:"):]] = r["applied"]
|
||||
return loctext.write(orig_raw, lang, fname, values) if values else orig_raw
|
||||
|
||||
|
||||
def _find_dir(root, *parts):
|
||||
d = root
|
||||
for p in parts:
|
||||
if not d or not os.path.isdir(d):
|
||||
return None
|
||||
d = next((os.path.join(d, x) for x in os.listdir(d) if x.lower() == p.lower()), None)
|
||||
return d if d and os.path.isdir(d) else None
|
||||
42
ackit/upk.py
42
ackit/upk.py
@@ -8,7 +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
|
||||
from collections import OrderedDict
|
||||
from collections import OrderedDict, namedtuple
|
||||
|
||||
from . import lzo
|
||||
|
||||
@@ -20,6 +20,9 @@ class PackageError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
Export = namedtuple("Export", "index cls outer name size offset entry") # entry: table position (uncompressed)
|
||||
|
||||
|
||||
class Block:
|
||||
__slots__ = ("chunk", "uoff", "usize", "coff", "csize")
|
||||
|
||||
@@ -52,6 +55,7 @@ class Package:
|
||||
f12 = struct.unpack_from("<12i", b, o)
|
||||
self.name_count, self.name_offset = f12[1], f12[2]
|
||||
self.export_count, self.export_offset = f12[3], f12[4]
|
||||
self.import_count, self.import_offset = f12[5], f12[6]
|
||||
o += 48
|
||||
self.guid = b[o:o + 16]
|
||||
o += 16
|
||||
@@ -106,6 +110,42 @@ class Package:
|
||||
return self.file_size
|
||||
return self.chunks[-1][0] + self.chunks[-1][1]
|
||||
|
||||
# tables (need the uncompressed data: `read_all`) ---------------------------------------------
|
||||
def tables(self, data=None, start=None):
|
||||
"""(names, imports, exports) of the package. imports: [(class name, object name, outer)],
|
||||
exports: [Export]. Layouts: docs/formats/upk.md (licensee 101 adds one u32 to each export)."""
|
||||
if data is None:
|
||||
start, data = self.read_all()
|
||||
buf = memoryview(data)
|
||||
names = []
|
||||
o = self.name_offset - start
|
||||
for _ in range(self.name_count):
|
||||
n = struct.unpack_from("<i", buf, o)[0]
|
||||
o += 4
|
||||
if n >= 0:
|
||||
names.append(bytes(buf[o:o + n - 1]).decode("latin-1"))
|
||||
o += n
|
||||
else:
|
||||
names.append(bytes(buf[o:o - 2 * n - 2]).decode("utf-16-le", "replace"))
|
||||
o += -2 * n
|
||||
o += 8 # name flags
|
||||
imports = []
|
||||
o = self.import_offset - start
|
||||
for _ in range(self.import_count):
|
||||
_pkg, _pn, cls, _cn, outer, nm, nn = struct.unpack_from("<7i", buf, o)
|
||||
imports.append((names[cls], names[nm], outer))
|
||||
o += 28
|
||||
exports = []
|
||||
o = self.export_offset - start
|
||||
for i in range(self.export_count):
|
||||
cls, sup, outer, nm, nn, arch, _x = struct.unpack_from("<7i", buf, o)
|
||||
size, off = struct.unpack_from("<ii", buf, o + 36)
|
||||
ngen = struct.unpack_from("<i", buf, o + 48)[0]
|
||||
exports.append(Export(i + 1, cls, outer, names[nm] + ("_%d" % (nn - 1) if nn else ""), size, off,
|
||||
o + start))
|
||||
o += 72 + 4 * ngen
|
||||
return names, imports, exports
|
||||
|
||||
def fingerprint(self):
|
||||
"""Cheap identity of the package contents: guid + chunk table (changes with any re-cook/patch)."""
|
||||
import hashlib
|
||||
|
||||
@@ -16,7 +16,7 @@ import hashlib
|
||||
import os
|
||||
import random
|
||||
|
||||
from .. import banks, classify
|
||||
from .. import banks, classify, subtitles
|
||||
from ..catalog import (Catalog, available_archives, bank_sounds, hash_names, head_owners, is_shipped, load_characters,
|
||||
scan_archive)
|
||||
from ..inject import read_target
|
||||
@@ -102,7 +102,9 @@ def check_banks(rep, game, rows, locations, characters, quick=False, sample=40,
|
||||
pk = [f for f in files if f.lower().endswith(".upk")]
|
||||
rnd = random.Random(1)
|
||||
files = [f for f in files if f.startswith("SFX/")] + sorted(rnd.sample(pk, min(sample, len(pk))))
|
||||
sc = banks.scan(game.cooked, progress, files=files)
|
||||
col = subtitles.Collector()
|
||||
sc = banks.scan(game.cooked, progress, files=files, visit=col)
|
||||
check_subtitles(rep, game, col, set(files), quick)
|
||||
owners = head_owners(game, sc)
|
||||
index = collections.defaultdict(set)
|
||||
for m, ls in locations.items():
|
||||
@@ -135,6 +137,44 @@ def check_banks(rep, game, rows, locations, characters, quick=False, sample=40,
|
||||
rep.add(banks.ARCHIVE, "copies", "%08X" % m, "prefetch head without a speech line")
|
||||
|
||||
|
||||
def check_subtitles(rep, game, col, files, quick):
|
||||
"""Subtitle copies found in the scanned packages vs SUB_locations + the SUB_<LANG>.csv texts."""
|
||||
from ..catalog import _shipped_index, catalog_dir
|
||||
loc_path = os.path.join(catalog_dir(), subtitles.LOCATIONS)
|
||||
if not os.path.exists(loc_path) or not is_shipped(game, banks.ARCHIVE):
|
||||
return
|
||||
index = collections.defaultdict(set)
|
||||
for k, ls in subtitles.read_locations(loc_path).items():
|
||||
for l in ls:
|
||||
if l.file in files:
|
||||
index[k].add((l.file, l.pos, l.size))
|
||||
found, texts = collections.defaultdict(set), {}
|
||||
for speech, copies in col.dialogues.items():
|
||||
for rel, d in copies:
|
||||
for f in d.fields:
|
||||
k = subtitles.key(f.lang, speech, f.index)
|
||||
found[k].add((rel, f.pos, f.size))
|
||||
texts[k] = f.text
|
||||
for k in sorted(set(found) | set(index)):
|
||||
if found.get(k, set()) != index.get(k, set()):
|
||||
rep.add("SUB", "copies", k, "index %d, game %d" % (len(index.get(k, ())), len(found.get(k, ()))))
|
||||
langs = sorted(_shipped_index().get("SUB", {}).get("lines", {}))
|
||||
for lang in langs:
|
||||
p = os.path.join(catalog_dir(), "SUB_%s.csv" % lang)
|
||||
rows = subtitles.read_texts(p, lang)
|
||||
rep.checked["SUB_" + lang] += len(rows)
|
||||
seen = set()
|
||||
for s in rows:
|
||||
k = subtitles.key(lang, s.speech, s.line)
|
||||
if k in seen:
|
||||
rep.add("SUB", "duplicate", k)
|
||||
seen.add(k)
|
||||
if k in texts and texts[k] != s.text:
|
||||
rep.add("SUB", "text", k, "catalog %r, game %r" % (s.text[:40], texts[k][:40]))
|
||||
if s.sound and not s.sound.startswith(lang + ":") and not s.sound.startswith("INT:"):
|
||||
rep.add("SUB", "language", k, "linked to %s" % s.sound)
|
||||
|
||||
|
||||
def run(game, archives=None, quick=False, log=print, progress=None):
|
||||
"""Check the given archives (default: all). Returns a Report."""
|
||||
rep = Report()
|
||||
|
||||
@@ -22,6 +22,7 @@ from .game import Game, GameError
|
||||
from .inject import inject, read_target, parse_target, pack_bnk_path, SizeLimitError
|
||||
from .originals import OriginalsStore, sha256, sha1_16
|
||||
from .pack import Pack
|
||||
from .texts import Texts
|
||||
from .paths import user_dir
|
||||
from .wad import Wad
|
||||
|
||||
@@ -51,6 +52,7 @@ class Workspace:
|
||||
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.texts = Texts(self)
|
||||
|
||||
# reading -----------------------------------------------------------------------------------
|
||||
def sound(self, name_or_key):
|
||||
@@ -153,9 +155,9 @@ class Workspace:
|
||||
"""items: [(sound, new_bytes, meta, is_revert)] all in one archive."""
|
||||
if archive == banks.ARCHIVE:
|
||||
return self._write_banks(items, progress)
|
||||
heads = sorted({l.file for s, _, _, _ in items for l in self.locations(s)})
|
||||
for rel in heads:
|
||||
self._keep_original_file(rel)
|
||||
heads = sorted({self.bank_path(l.file) for s, _, _, _ in items for l in self.locations(s)})
|
||||
for path in heads:
|
||||
self._keep_original(path)
|
||||
wad = self.game.wad(archive)
|
||||
self.game.check_writable(wad)
|
||||
packs = {parse_target(s.target)[0] for s, _, _, _ in items if s.is_pack_media}
|
||||
@@ -181,43 +183,44 @@ class Workspace:
|
||||
else:
|
||||
self.store.record_applied(s.key, data, meta)
|
||||
self.store.save()
|
||||
self._sync_bank_files(heads, progress)
|
||||
self.sync_files(heads, progress)
|
||||
|
||||
# bank files (in-memory sounds, prefetch heads) ----------------------------------------------
|
||||
def _bank_files(self):
|
||||
return self.store.data.setdefault("bank_files", {})
|
||||
# derived files: bank files and packages (in-memory sounds, prefetch heads, subtitles) and the
|
||||
# localization files (menu text). Each is rebuilt from its original copy + every edit applied now.
|
||||
def _derived(self):
|
||||
return self.store.data.setdefault("derived", {})
|
||||
|
||||
def _fingerprint(self, path):
|
||||
"""Content hash of a bank file (whole file: any change by another tool is detected)."""
|
||||
"""Content hash of a file (whole file: any change by another tool is detected)."""
|
||||
h = hashlib.sha1()
|
||||
with open(path, "rb") as f:
|
||||
for c in iter(lambda: f.read(1 << 22), b""):
|
||||
h.update(c)
|
||||
return h.hexdigest()
|
||||
|
||||
def _keep_original_file(self, rel):
|
||||
"""Before the first change to a bank file: verified full copy + its fingerprint."""
|
||||
rec = self._bank_files().get(rel)
|
||||
def _keep_original(self, path):
|
||||
"""Before the first change to a derived file: verified full copy + its fingerprint."""
|
||||
rel = self.game.rel(path)
|
||||
rec = self._derived().get(rel)
|
||||
if rec:
|
||||
return rec
|
||||
path = self.bank_path(rel)
|
||||
self.game.check_writable(path)
|
||||
self.store.full_backup(path, self.game.rel(path))
|
||||
rec = self._bank_files()[rel] = {"orig_fp": self._fingerprint(path), "written_fp": None}
|
||||
self.store.full_backup(path, rel)
|
||||
rec = self._derived()[rel] = {"orig_fp": self._fingerprint(path), "written_fp": None}
|
||||
self.store.save()
|
||||
return rec
|
||||
|
||||
def changed_files(self, sounds):
|
||||
"""Bank files of `sounds` changed since we last wrote them (Steam update/verify, other tool)."""
|
||||
def changed_files(self, paths):
|
||||
"""Derived files changed since we last wrote them (Steam update/verify, another tool)."""
|
||||
out = []
|
||||
for rel in sorted({l.file for s in sounds for l in self.locations(s)}):
|
||||
rec = self._bank_files().get(rel)
|
||||
if rec and self._fingerprint(self.bank_path(rel)) not in (rec["orig_fp"], rec["written_fp"]):
|
||||
out.append(rel)
|
||||
for path in sorted(set(paths)):
|
||||
rec = self._derived().get(self.game.rel(path))
|
||||
if rec and self._fingerprint(path) not in (rec["orig_fp"], rec["written_fp"]):
|
||||
out.append(path)
|
||||
return out
|
||||
|
||||
def _edits(self, rel):
|
||||
"""[(offset, bytes)] that the replacements applied now need in bank file `rel`."""
|
||||
def _audio_edits(self, rel):
|
||||
"""[(offset, bytes)] that the sound replacements applied now need in bank file `rel` (cooked-relative)."""
|
||||
out = []
|
||||
for key, e in self.store.entries.items():
|
||||
if not e.get("applied"):
|
||||
@@ -235,24 +238,31 @@ class Workspace:
|
||||
out.append((l.offset, payload))
|
||||
return out
|
||||
|
||||
def _sync_bank_files(self, rels, progress=None):
|
||||
"""Rebuild each bank file from its original copy and the replacements applied now."""
|
||||
for rel in rels:
|
||||
path = self.bank_path(rel)
|
||||
rec = self._keep_original_file(rel)
|
||||
def _build(self, path, orig):
|
||||
"""Content of derived file `path` from its original copy `orig`."""
|
||||
if self.texts.is_menu_file(path):
|
||||
return self.texts.menu_content(path, open(orig, "rb").read())
|
||||
rel = os.path.relpath(path, self.game.cooked).replace(os.sep, "/")
|
||||
edits = self._audio_edits(rel) + self.texts.subtitle_edits(rel)
|
||||
return banks.open_file(orig).patched(edits) if edits else open(orig, "rb").read()
|
||||
|
||||
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)):
|
||||
rec = self._keep_original(path)
|
||||
orig = self.store.full_copy(self.game.rel(path))
|
||||
if orig is None:
|
||||
raise GameError("the original copy of %s is missing from the store" % rel)
|
||||
raise GameError("the original copy of %s is missing from the store" % self.game.rel(path))
|
||||
if progress:
|
||||
progress("writing %s" % rel)
|
||||
edits = self._edits(rel)
|
||||
data = banks.open_file(orig).patched(edits) if edits else open(orig, "rb").read()
|
||||
progress("writing %s" % os.path.basename(path))
|
||||
data = self._build(path, orig)
|
||||
self.game.check_writable(path)
|
||||
tmp = path + ".ackit-tmp"
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(data)
|
||||
os.replace(tmp, path)
|
||||
rec["written_fp"] = self._fingerprint(path) if edits else None
|
||||
same = data == open(orig, "rb").read()
|
||||
rec["written_fp"] = None if same else self._fingerprint(path)
|
||||
self.store.save()
|
||||
|
||||
def _write_banks(self, items, progress=None):
|
||||
@@ -262,9 +272,9 @@ class Workspace:
|
||||
raise GameError("no copy of %s was found in the game's banks" % s.name)
|
||||
if not is_revert and len(data) > min(l.size for l in locs):
|
||||
raise SizeLimitError(s.target, min(l.size for l in locs), len(data))
|
||||
files = sorted({l.file for s, _, _, _ in items for l in self.locations(s)})
|
||||
for rel in files:
|
||||
self._keep_original_file(rel)
|
||||
files = sorted({self.bank_path(l.file) for s, _, _, _ in items for l in self.locations(s)})
|
||||
for path in files:
|
||||
self._keep_original(path)
|
||||
for s, data, meta, is_revert in items:
|
||||
if is_revert:
|
||||
self.store.forget(s.key)
|
||||
@@ -273,7 +283,7 @@ class Workspace:
|
||||
self.current_bytes(s))
|
||||
self.store.record_applied(s.key, data, meta)
|
||||
self.store.save() # the store says what the files must contain before they change
|
||||
self._sync_bank_files(files, progress)
|
||||
self.sync_files(files, progress)
|
||||
|
||||
def apply(self, changes, force=False, progress=None):
|
||||
"""changes: [(sound, wem_bytes, meta dict)]. One atomic rewrite per WAD / bank file."""
|
||||
@@ -281,9 +291,9 @@ class Workspace:
|
||||
for s, _, _ in changes:
|
||||
if self.state(s) in (CHANGED, FOREIGN):
|
||||
conflicts.append(s)
|
||||
changed = set(self.changed_files([s for s, _, _ in changes]))
|
||||
changed = set(self.changed_files([self.bank_path(l.file) for s, _, _ in changes for l in self.locations(s)]))
|
||||
conflicts += [s for s, _, _ in changes
|
||||
if s not in conflicts and {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:
|
||||
raise ConflictError(conflicts)
|
||||
by_arch = defaultdict(list)
|
||||
@@ -328,8 +338,9 @@ class Workspace:
|
||||
shutil.copy2(src, tmp)
|
||||
os.replace(tmp, dst)
|
||||
out.append(rel)
|
||||
for rec in self._bank_files().values():
|
||||
for rec in self._derived().values():
|
||||
rec["written_fp"] = None
|
||||
self.store.data["texts"] = {}
|
||||
self.store.data["entries"] = {}
|
||||
self.store.save()
|
||||
self.store.gc()
|
||||
|
||||
Reference in New Issue
Block a user