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.
161 lines
7.4 KiB
Python
161 lines
7.4 KiB
Python
"""Subtitles (dialogue objects in packages) and menu text (localization files): parse, apply, revert."""
|
|
import hashlib
|
|
import struct
|
|
import os
|
|
|
|
import pytest
|
|
|
|
import synth
|
|
from ackit import loctext, subtitles
|
|
from ackit.game import Game
|
|
from ackit.texts import MODIFIED, ORIGINAL, TextConflictError
|
|
from ackit.workspace import Workspace
|
|
|
|
SUB_KEY = "SUB:INT:%s:0" % synth.SPEECH_EVENT
|
|
|
|
|
|
def _sha(p):
|
|
return hashlib.sha1(open(p, "rb").read()).hexdigest()
|
|
|
|
|
|
def _ws(root, tmp_path):
|
|
return Workspace(root, store_root=str(tmp_path / "store"), archives=["INT", "SFX", "BANK"])
|
|
|
|
|
|
def _files(root):
|
|
d = os.path.join(root, "BmGame")
|
|
return {os.path.join(a, f): _sha(os.path.join(a, f)) for a, _, fs in os.walk(d) for f in fs}
|
|
|
|
|
|
# --- formats ------------------------------------------------------------------------------------
|
|
def test_fstring_encoding():
|
|
b = subtitles.encode_fstring("Hi", 10, "a")
|
|
assert b == b"\x06\x00\x00\x00Hi\0\0\0\0" and subtitles.read_fstring(b, 0)[0] == "Hi"
|
|
w = subtitles.encode_fstring("Привет", 4 + 2 * 8, "w")
|
|
assert subtitles.read_fstring(w, 0) == ("Привет", 20, "w")
|
|
with pytest.raises(subtitles.SubtitleError):
|
|
subtitles.encode_fstring("too long", 10, "a")
|
|
with pytest.raises(subtitles.SubtitleError):
|
|
subtitles.encode_fstring("→", 10, "a") # not in cp1252
|
|
# every byte reads back as it was, also the 5 that Python's cp1252 leaves undefined (UTF-8 in two game strings)
|
|
raw = struct.pack("<i", 7) + "Уж".encode("utf-8") + b"\x81\x8d\0"
|
|
text = subtitles.read_fstring(raw, 0)[0]
|
|
assert subtitles.encode_fstring(text, len(raw), "a") == raw
|
|
|
|
|
|
def test_menu_parse_and_write():
|
|
fname, text = synth.MENU["INT"]
|
|
raw = b"\xff\xfe" + text.encode("utf-16-le")
|
|
es = {e.key: e for e in loctext.parse(raw, "INT", fname)}
|
|
assert es["INT:FRONTEND.int:Menu:Start"].value == "Press any key"
|
|
assert es["INT:FRONTEND.int:Menu:Quit"].quoted and es["INT:FRONTEND.int:Tips:Tip#2"].value == "Glide over the city"
|
|
assert loctext.write(raw, "INT", fname, {}) == raw
|
|
new = loctext.write(raw, "INT", fname, {"INT:FRONTEND.int:Menu:Quit": "Leave", "INT:FRONTEND.int:Tips:Tip#2": "Fly"})
|
|
assert new.decode("utf-16") == text.replace('"Quit game"', '"Leave"').replace("Glide over the city", "Fly")
|
|
with pytest.raises(loctext.TextError):
|
|
loctext.write(raw, "INT", fname, {"INT:FRONTEND.int:Menu:Start": "two\nlines"})
|
|
|
|
|
|
def test_menu_check_value():
|
|
loctext.check_value("Fine", "utf-16", "F.int")
|
|
loctext.check_value("Città", "cp1252", "F.int")
|
|
with pytest.raises(loctext.TextError):
|
|
loctext.check_value("two\nlines", "utf-16", "F.int")
|
|
with pytest.raises(loctext.TextError):
|
|
loctext.check_value("Привет", "cp1252", "F.int") # not in the file's encoding
|
|
|
|
|
|
# --- subtitles through the workspace ------------------------------------------------------------
|
|
def test_subtitle_catalog_and_link(synth_game, tmp_path):
|
|
root, _ = synth_game
|
|
ws = _ws(root, tmp_path)
|
|
lines = ws.texts.subtitles("INT")
|
|
assert [(s.speech, s.line, s.text) for s in lines] == [(synth.GRUNT_EVENT, 0, "Hey you!"),
|
|
(synth.SPEECH_EVENT, 0, "Scanner line two."),
|
|
(synth.SPEECH_EVENT, 1, "Second part.")]
|
|
# a line kept in memory: each language's subtitle links to that language's BANK sound
|
|
grunt = ws.catalog.by_key["BANK:%08X" % synth.GRUNT_MEDIA["INT"]]
|
|
assert [s.text for s in ws.texts.for_sound(grunt)] == ["Hey you!"]
|
|
ita = {s.speech: s.sound for s in ws.texts.subtitles("ITA")}
|
|
assert ita[synth.GRUNT_EVENT] == "BANK:%08X" % synth.GRUNT_MEDIA["ITA"]
|
|
line = ws.catalog.by_key["INT:%08X:1" % synth.SPEECH_ENTRY]
|
|
assert [s.text for s in ws.texts.for_sound(line)] == ["Scanner line two.", "Second part."]
|
|
assert ita[synth.SPEECH_EVENT] == line.key # no ITA speech WAD here: INT line
|
|
assert ws.texts.subtitles("RUS")[0].enc == "w"
|
|
assert sorted(os.path.basename(p) for p in ws.texts.files(SUB_KEY)) == ["Synth_Dialogue.upk", "Synth_Dialogue2.upk"]
|
|
assert ws.texts.limit(SUB_KEY) == len("Scanner line two.")
|
|
|
|
|
|
def test_subtitle_apply_revert_every_copy(synth_game, tmp_path):
|
|
root, _ = synth_game
|
|
ws = _ws(root, tmp_path)
|
|
before = _files(root)
|
|
ws.texts.apply({SUB_KEY: "Line two.", "SUB:RUS:%s:0" % synth.SPEECH_EVENT: "Две"})
|
|
assert ws.texts.current(SUB_KEY) == "Line two." and ws.texts.state(SUB_KEY) == MODIFIED
|
|
for p in ws.texts.files(SUB_KEY): # every copy
|
|
ws2 = _ws(root, tmp_path / "other")
|
|
assert ws2.texts.current(SUB_KEY) == "Line two."
|
|
with pytest.raises(subtitles.SubtitleError):
|
|
ws.texts.apply({SUB_KEY: "This line is far too long for the room"})
|
|
ws.texts.revert([SUB_KEY])
|
|
assert ws.texts.current(SUB_KEY) == "Scanner line two." and ws.texts.current("SUB:RUS:%s:0" % synth.SPEECH_EVENT) == "Две"
|
|
ws.texts.revert_all()
|
|
assert _files(root) == before and ws.texts.state(SUB_KEY) == ORIGINAL
|
|
|
|
|
|
def test_subtitle_and_sound_in_one_package(synth_game, tmp_path):
|
|
root, _ = synth_game
|
|
ws = _ws(root, tmp_path)
|
|
before = _files(root)
|
|
s = ws.catalog.by_key["BANK:%08X" % synth.DLG_MEDIA]
|
|
ws.apply([(s, synth.fitting_wem(ws, s, 0.1, 1900), {})])
|
|
ws.texts.apply({SUB_KEY: "Both."})
|
|
assert ws.texts.current(SUB_KEY) == "Both." and ws.state(s) == "modified"
|
|
ws.revert([s])
|
|
assert ws.texts.current(SUB_KEY) == "Both." and ws.state(s) == "original"
|
|
ws.texts.revert_all()
|
|
assert _files(root) == before
|
|
|
|
|
|
# --- menu text through the workspace ------------------------------------------------------------
|
|
def test_menu_apply_revert(synth_game, tmp_path):
|
|
root, _ = synth_game
|
|
ws = _ws(root, tmp_path)
|
|
before = _files(root)
|
|
assert "INT" in ws.texts.menu_languages()
|
|
keys = {e.key: e for e in ws.texts.menu("INT")}
|
|
assert "INT:Version.int:ID:Unreal" not in keys
|
|
k = "MENU:INT:FRONTEND.int:Menu:Start"
|
|
ws.texts.apply({k: "Press Enter"})
|
|
assert ws.texts.current(k) == "Press Enter" and ws.texts.state(k) == MODIFIED
|
|
assert {e.key: e.value for e in ws.texts.menu("INT")}["INT:FRONTEND.int:Menu:Start"] == "Press Enter"
|
|
ws.texts.revert([k])
|
|
assert _files(root) == before
|
|
|
|
|
|
def test_menu_changed_underneath(synth_game, tmp_path):
|
|
root, _ = synth_game
|
|
ws = _ws(root, tmp_path)
|
|
k = "MENU:INT:FRONTEND.int:Tips:Tip#2"
|
|
ws.texts.apply({k: "Glide"})
|
|
path = ws.texts.files(k)[0]
|
|
raw = open(path, "rb").read()
|
|
open(path, "wb").write(loctext.write(raw, "INT", "FRONTEND.int", {"INT:FRONTEND.int:Menu:Start": "Other tool"}))
|
|
with pytest.raises(TextConflictError):
|
|
ws.texts.apply({k: "Soar"})
|
|
ws.texts.apply({k: "Soar"}, force=True)
|
|
assert ws.texts.current(k) == "Soar"
|
|
ws.texts.revert_all()
|
|
assert open(path, "rb").read() == open(ws.store.full_copy(ws.game.rel(path)), "rb").read()
|
|
|
|
|
|
# --- real game (read-only) ----------------------------------------------------------------------
|
|
def test_real_subtitles_and_menu(real_game_dir, tmp_path):
|
|
ws = Workspace(Game(real_game_dir), store_root=str(tmp_path / "store"), archives=["ITA"])
|
|
lines = ws.texts.subtitles("ITA")
|
|
assert len(lines) > 9000
|
|
s = next(x for x in lines if x.sound)
|
|
assert ws.texts.current(subtitles.key("ITA", s.speech, s.line)) == s.text
|
|
menu = ws.texts.menu("ITA")
|
|
assert len(menu) > 7000 and all(e.lang == "ITA" for e in menu)
|