Edit subtitles and menu text

This commit is contained in:
2026-10-09 22:36:46 +02:00
parent 5d6c2447f7
commit ef68edbe45
40 changed files with 105877 additions and 1291 deletions

View File

@@ -89,10 +89,11 @@ def pack(media):
return bytes(hdr) + bank, bytes(payload)
def bank(bank_id, media, name=None, extra_sources=(), prefetch=()):
def bank(bank_id, media, name=None, extra_sources=(), prefetch=(), events=()):
"""A v56 soundbank holding `media` {id: wem} in memory (DIDX/DATA, 16-byte aligned) with one HIRC
Sound object per media; extra_sources: [(media id, bank id, off, size)] for sources of other banks;
media ids in `prefetch` get a prefetch source (their DIDX data is the head of a streamed WEM)."""
media ids in `prefetch` get a prefetch source (their DIDX data is the head of a streamed WEM);
events: [(event name, streamed media id)] -> event -> action -> streamed Sound object."""
data = bytearray()
didx = b""
for m in sorted(media):
@@ -108,10 +109,18 @@ def bank(bank_id, media, name=None, extra_sources=(), prefetch=()):
objs.append((m, bank_id if m not in prefetch else 0x1234, data_pos + off, len(media[m]),
2 if m in prefetch else 0))
objs += [x + (0,) for x in extra_sources]
hirc = struct.pack("<I", len(objs))
from ackit.bnk import fnv1
hirc_objs = []
for i, (m, b, off, size, stream) in enumerate(objs):
body = struct.pack("<7I", 0x100 + i, 0x00040001, stream, m, b, off, size) + bytes(6)
hirc += bytes([2]) + struct.pack("<I", len(body)) + body
hirc_objs.append((2, struct.pack("<7I", 0x100 + i, 0x00040001, stream, m, b, off, size) + bytes(6)))
for i, (ev, m) in enumerate(events):
sid, aid = 0x300 + i, 0x400 + i
hirc_objs.append((2, struct.pack("<7I", sid, 0x00040001, 1, m, 0, 0, 0) + bytes(6)))
hirc_objs.append((3, struct.pack("<III", aid, 0x4011, sid) + bytes(8)))
hirc_objs.append((4, struct.pack("<III", fnv1(ev), 1, aid)))
hirc = struct.pack("<I", len(hirc_objs))
for t, body in hirc_objs:
hirc += bytes([t]) + struct.pack("<I", len(body)) + body
out = head + b"DATA" + struct.pack("<I", len(data)) + bytes(data) + b"HIRC" + struct.pack("<I", len(hirc)) + hirc
if name:
nm = name.encode()
@@ -120,9 +129,10 @@ def bank(bank_id, media, name=None, extra_sources=(), prefetch=()):
return out
def package(path, body, block_size=0x1000, chunk_blocks=3):
def package(path, body, block_size=0x1000, chunk_blocks=3, tables=(0, 0, 0, 0, 0, 0)):
"""A fully LZO-compressed UE3 package (docs/formats/upk.md) whose uncompressed data area is `body`.
Small blocks so that tests cross block and chunk boundaries."""
Small blocks so that tests cross block and chunk boundaries. `tables`: (NameCount, NameOffset,
ExportCount, ExportOffset, ImportCount, ImportOffset) as absolute uncompressed offsets."""
from ackit import lzo
folder = b"None\x00"
nblocks = max(1, _a(len(body), block_size) // block_size)
@@ -130,7 +140,7 @@ def package(path, body, block_size=0x1000, chunk_blocks=3):
def summary(chunks):
s = struct.pack("<IHHi", 0x9E2A83C1, 805, 101, 0) + struct.pack("<i", len(folder)) + folder
s += struct.pack("<12i", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + b"GUID-synthetic!!"
s += struct.pack("<12i", 0, *tables, 0, 0, 0, 0, 0) + b"GUID-synthetic!!"
s += struct.pack("<i3i", 1, 0, 0, 0) + struct.pack("<3I", 7748, 0, 2)
s += struct.pack("<i", len(chunks)) + b"".join(struct.pack("<4i", *c) for c in chunks)
return s + struct.pack("<Ii", 0x12345678, 0)
@@ -157,6 +167,69 @@ def package(path, body, block_size=0x1000, chunk_blocks=3):
return header_u
HEADER_U = None # uncompressed summary size of synthetic packages (fixed; computed on first use)
def _header_u():
global HEADER_U
if HEADER_U is None:
import tempfile
with tempfile.TemporaryDirectory() as d:
HEADER_U = package(os.path.join(d, "x.upk"), b"\0" * 16)
return HEADER_U
def ue_package(path, objects, prefix=b""):
"""A package with name/import/export tables. objects: [(class name, object name, bytes or callable)]:
a callable gets {name: index} and the export index map and returns the object's bytes.
Returns {object name: (absolute offset, size)}."""
classes = sorted({c for c, _, _ in objects})
names = ["None", "Core", "Class", "LocalizedSubtitles", "Subtitles"] + classes + [n for _, n, _ in objects]
idx = {n: i for i, n in enumerate(names)}
exp_index = {n: i + 1 for i, (_, n, _) in enumerate(objects)}
blobs = [o(idx, exp_index) if callable(o) else o for _, _, o in objects]
nt = b"".join(struct.pack("<i", len(n) + 1) + n.encode() + b"\0" + bytes(8) for n in names)
it = b"".join(struct.pack("<7i", idx["Core"], 0, idx["Class"], 0, 0, idx[c], 0) for c in classes)
base = _header_u() + len(prefix)
name_off, imp_off = base, base + len(nt)
exp_off = imp_off + len(it)
data_off = exp_off + 72 * len(objects)
et, pos, offs = b"", data_off, {}
for (c, n, _), b in zip(objects, blobs):
et += struct.pack("<7i", -(classes.index(c) + 1), 0, 0, idx[n], 0, 0, 0) + bytes(8)
et += struct.pack("<ii", len(b), pos) + bytes(4 + 4 + 16 + 4)
offs[n] = (pos, len(b))
pos += len(b)
body = prefix + nt + it + et + b"".join(blobs) + bytes(64)
package(path, body, tables=(len(names), name_off, len(objects), exp_off, len(classes), imp_off))
return offs
def _fstr(text, wide=False):
if wide:
return struct.pack("<i", -(len(text) + 1)) + text.encode("utf-16-le") + b"\0\0"
return struct.pack("<i", len(text) + 1) + text.encode("cp1252") + b"\0"
def dialogue(speech_object, texts, wide=()):
"""An RDialogueEvent export (compact tagged properties, docs/formats/subtitles.md) referencing the
export `speech_object`; texts: {lang: [line, ...]}; languages in `wide` are stored as UTF-16."""
def build(idx, exp_index):
content = struct.pack("<I", len(texts))
for lang, lines in texts.items():
sub = struct.pack("<I", len(lines))
for ln in lines:
sub += struct.pack("<HH", 0x0D, 0) + _fstr(ln, lang in wide) + struct.pack("<HHf", 4, 0x0C, 1.5)
sub += struct.pack("<H", 0)
content += struct.pack("<HH", 0x0D, 0) + _fstr(lang)
content += struct.pack("<HHiiii", 9, 0x0C, idx["Subtitles"], 0, len(sub), 0) + sub + struct.pack("<H", 0)
b = bytes(4) + struct.pack("<HHi", 0x10, 0x2C, exp_index[speech_object])
b += struct.pack("<HH", 0x0D, 0x34) + _fstr("Batman")
b += struct.pack("<HHiiii", 9, 0x9C, idx["LocalizedSubtitles"], 0, len(content), 0) + content
return b + struct.pack("<H", 0)
return build
def make_game(root):
"""Create the folder; returns the dict of original WEMs by target."""
cooked = os.path.join(root, "BmGame", "CookedPCConsole")
@@ -175,6 +248,7 @@ def make_game(root):
originals["%08X" % SINGLE_ENTRY] = single
originals.update({"%08X/%08X" % (PACK_ID, m): w for m, w in music.items()})
originals.update(make_banks(cooked, v))
make_localization(root)
return originals
@@ -213,5 +287,35 @@ def make_banks(cooked, speech_wems):
synth_b = filler[:1000] + bank(fnv1(names[3]), gui) + filler + bank(fnv1(names[2]), foley) + filler
package(os.path.join(cooked, "Synth_A.upk"), synth_a)
package(os.path.join(cooked, "Synth_B.upk"), synth_b)
# dialogue (subtitles) in two packages; the first one also holds a bank with an in-memory sound and the
# event that links the dialogue's speech event to INT variation 1 of SPEECH_ENTRY
dlg = dialogue(SPEECH_EVENT, SUBTITLES, wide=("RUS",))
dlg_wem = wem(0.3, 1400)
ev_bank = bank(fnv1("NPC_SynthDialogue"), {DLG_MEDIA: dlg_wem}, name="NPC_SynthDialogue",
events=[(SPEECH_EVENT, LINE_MEDIA[1])])
objs = [("AkEvent", SPEECH_EVENT, bytes(8)), ("RDialogueEvent", "Radio_02", dlg)]
ue_package(os.path.join(cooked, "Synth_Dialogue.upk"), objs + [("AkBank", "NPC_SynthDialogue", ev_bank)])
ue_package(os.path.join(cooked, "Synth_Dialogue2.upk"), objs, prefix=bytes(3000))
with open(os.path.join(cooked, "CookedBanks.txt"), "a", newline="\r\n") as f:
f.write("NPC_SynthDialogue\n")
out = {"%08X" % m: w for m, w in list(foot.items()) + list(foley.items()) + list(gui.items())}
out["%08X" % DLG_MEDIA] = dlg_wem
return out
SPEECH_EVENT = "Speech_Synth_Radio_02"
SUBTITLES = {"INT": ["Scanner line two.", "Second part."], "ITA": ["Riga due dello scanner."],
"RUS": ["Строка два"]}
DLG_MEDIA = 0x0E000001
MENU = {"INT": ("FRONTEND.int", "\r\n[Menu]\r\nStart=Press any key\r\nQuit=\"Quit game\"\r\n; comment\r\n"
"[Tips]\r\nTip=Use the grapnel\r\nTip=Glide over the city\r\n"),
"ITA": ("FRONTEND.ITA", "\r\n[Menu]\r\nStart=Premi un tasto\r\nQuit=\"Esci\"\r\n"
"[Tips]\r\nTip=Usa il rampino\r\nTip=Plana sulla citta\r\n")}
def make_localization(root):
for lang, (fname, text) in MENU.items():
d = os.path.join(root, "BmGame", "Localization", lang)
os.makedirs(d, exist_ok=True)
open(os.path.join(d, fname), "wb").write(b"\xff\xfe" + text.encode("utf-16-le"))
open(os.path.join(root, "BmGame", "Localization", "INT", "Version.int"), "wb").write(b"[ID]\r\nUnreal=SYNTH\r\n")