Edit subtitles and menu text
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user