"""Prepare and reversibly install a small, offline Tomie UI translation.""" import argparse from contextlib import contextmanager import base64 import csv from datetime import datetime, timezone import hashlib import io import json import os from pathlib import Path import re import shutil import struct import subprocess import sys import time from uuid import uuid4 import zlib import httpx from .benchmark import write_json from .models import Project from .naninovel import parse_dictionary from .protection import protect DATA = Path("TomieWGM_Data") STATE = Path(".local-translator/state.json") MENU_LABELS = {"new game", "start", "continue", "load game", "exit", "quit", "credit", "credits", "gallery", "back", "return", "close", "new", "content\noption", "game guide\nwalkthrough", "achievements", "achievement", "mod folder", "translation", "savefile folder", "next series"} def digest(path): with Path(path).open("rb") as stream: return hashlib.file_digest(stream, "sha256").hexdigest() def inside(root, relative): relative = Path(relative) if relative.is_absolute() or ".." in relative.parts: raise ValueError("Unsafe relative path in patch manifest") target = (Path(root).resolve() / relative).resolve() if not target.is_relative_to(Path(root).resolve()): raise ValueError("Patch path escapes its root") return target def rewrite_dictionary(script, replacements): seen, lines = set(), [] for line in script.splitlines(keepends=True): body = line.rstrip("\r\n") ending = line[len(body):] key, separator, value = body.partition(":") if separator and key.strip() in replacements: key_name = key.strip() if key_name in seen: raise ValueError("Duplicate key in managed text: " + key_name) seen.add(key_name) translated = replacements[key_name] if not isinstance(translated, str) or not translated.strip() or "\n" in translated or "\r" in translated: raise ValueError("Managed text must contain a nonempty single-line value: " + key_name) spacing = value[:len(value) - len(value.lstrip())] body = key + separator + spacing + translated lines.append(body + ending) if set(replacements) - seen: raise ValueError("Translations refer to unknown managed text keys") return "".join(lines) def append_managed_records(script, records): existing = {row["id"] for row in parse_dictionary(script)} if existing.intersection(records): raise ValueError("Extra managed text records duplicate existing keys") newline = "\r\n" if "\r\n" in script else "\n" if records and script and not script.endswith(("\r", "\n")): script += newline for key, value in records.items(): if not value.strip() or any(char in value for char in "\r\n"): raise ValueError("Extra managed text values must be single-line") script += key + ": " + value + newline return script def bundle_crc(data): import UnityPy from UnityPy.streams import EndianBinaryReader bundle = next(iter(UnityPy.load(data).files.values())) reader = EndianBinaryReader(data) if reader.read_string_to_null() != "UnityFS": raise ValueError("Only UnityFS bundles are supported") reader.read_int() reader.read_string_to_null() reader.read_string_to_null() _, blocks = bundle.read_fs(reader) return zlib.crc32(blocks.bytes) & 0xffffffff def patch_catalog(document, old_hash, crc, size, new_hash): """Preserve binary record lengths and all offsets in Addressables metadata.""" raw = base64.b64decode(document["m_ExtraDataString"], validate=True) needle = ('"m_Hash":"' + old_hash + '"').encode("utf-16-le") if raw.count(needle) != 1: raise ValueError("Addressables bundle metadata is missing or ambiguous") position = raw.index(needle) start = raw.rfind(b"{\x00", 0, position) end = raw.find(b"}\x00", position) + 2 if start < 4 or end < 2 or struct.unpack(" len(match[2]): raise ValueError("New bundle metadata exceeds original numeric field width") return match[1] + value_text.ljust(len(match[2])) text, count = re.subn(r'("' + name + r'":)(\d+ *)', replace, text) if count != 1: raise ValueError("Missing Addressables field: " + name) text = text.replace(old_hash, new_hash) encoded = text.encode("utf-16-le") if len(encoded) != end - start: raise ValueError("Addressables record length changed") updated = dict(document) updated["m_ExtraDataString"] = base64.b64encode(raw[:start] + encoded + raw[end:]).decode() return updated, json.loads(original), json.loads(text) def game_running(): if os.name != "nt": return False result = subprocess.run(["tasklist", "/FI", "IMAGENAME eq TomieWGM.exe", "/FO", "CSV", "/NH"], capture_output=True, text=True, errors="replace", timeout=15, creationflags=0x08000000) if result.returncode: raise RuntimeError("Could not check whether the game is closed") return any(row and row[0].lower() == "tomiewgm.exe" for row in csv.reader(result.stdout.splitlines())) def atomic_copy(source, target, expected): target.parent.mkdir(parents=True, exist_ok=True) temporary = target.with_name(target.name + ".lt-" + uuid4().hex + ".tmp") try: shutil.copy2(source, temporary) if digest(temporary) != expected: raise ValueError("Copied file failed its checksum: " + str(target)) os.replace(temporary, target) finally: if temporary.exists(): temporary.unlink() def load_package(package): manifest = json.loads((Path(package) / "manifest.json").read_text(encoding="utf-8")) paths = [record["path"] for record in manifest["files"]] if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", manifest["id"]): raise ValueError("Invalid patch ID") if len(set(paths)) != len(paths) or not paths: raise ValueError("Patch manifest has duplicate paths or no files") for record in manifest["files"]: inside(Path(package) / "payload", record["path"]) if not record["path"].replace("\\", "/").startswith("TomieWGM_Data/"): raise ValueError("Patch may only modify TomieWGM_Data resources") return manifest @contextmanager def patch_lock(game): path = inside(game, ".local-translator/operation.lock") path.parent.mkdir(parents=True, exist_ok=True) with path.open("a+b") as stream: acquired = False try: if os.name == "nt": import msvcrt if stream.tell() == 0: stream.write(b"0") stream.flush() stream.seek(0) msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) else: import fcntl fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) acquired = True yield except OSError as exc: if not acquired: raise RuntimeError("Другой установщик уже работает с этой игрой.") from exc raise finally: if acquired: if os.name == "nt": stream.seek(0) msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) else: fcntl.flock(stream.fileno(), fcntl.LOCK_UN) def install(package, game, running_check=game_running): if running_check(): raise RuntimeError("Закрой игру перед установкой перевода.") with patch_lock(game): return _install(package, game, running_check) def _install(package, game, running_check): package, game = Path(package).resolve(), Path(game).resolve() if running_check(): raise RuntimeError("Закрой игру перед установкой перевода.") manifest = load_package(package) if not (game / "TomieWGM.exe").is_file(): raise ValueError("Game executable not found in selected directory") state_path = inside(game, STATE) if state_path.exists(): old = json.loads(state_path.read_text(encoding="utf-8")) if old["phase"] == "installed" and old["patch_id"] == manifest["id"]: if all(digest(inside(game, record["path"])) == record["after"] for record in old["files"]): print("Этот перевод уже установлен.") return if old["phase"] != "restored": raise RuntimeError("Сначала запусти откат предыдущего перевода.") # Validate the entire set before writing any game file. for record in manifest["files"]: if digest(inside(package / "payload", record["path"])) != record["after"]: raise ValueError("Patch payload checksum mismatch") if digest(inside(game, record["path"])) != record["before"]: raise ValueError("Game file differs from the supported original: " + record["path"]) records = [] for record in manifest["files"]: backup_relative = Path(".local-translator/backups") / manifest["id"] / record["path"] backup = inside(game, backup_relative) if backup.exists() and digest(backup) != record["before"]: raise ValueError("An existing backup has a different checksum") if not backup.exists(): atomic_copy(inside(game, record["path"]), backup, record["before"]) records.append({**record, "backup": backup_relative.as_posix()}) state = {"phase": "installing", "patch_id": manifest["id"], "files": records} write_json(state_path, state) try: for record in records: # Recheck each original immediately before replacing it. if digest(inside(game, record["path"])) != record["before"]: raise ValueError("Game file changed during installation") atomic_copy(inside(package / "payload", record["path"]), inside(game, record["path"]), record["after"]) except BaseException: _restore(game, running_check=lambda: False) raise state["phase"] = "installed" write_json(state_path, state) print("Перевод установлен. Запусти игру обычным способом. Оригиналы сохранены для отката.") def restore(game, running_check=game_running): if running_check(): raise RuntimeError("Закрой игру перед откатом.") with patch_lock(game): return _restore(game, running_check) def _restore(game, running_check): game = Path(game).resolve() if running_check(): raise RuntimeError("Закрой игру перед откатом.") state_path = inside(game, STATE) if not state_path.exists(): print("Установленного патча нет; файлы игры не менялись.") return state = json.loads(state_path.read_text(encoding="utf-8")) for record in state["files"]: if not record["path"].replace("\\", "/").startswith("TomieWGM_Data/") or not record["backup"].replace("\\", "/").startswith(".local-translator/backups/"): raise ValueError("Unsafe resource or backup path in installation journal") if digest(inside(game, record["backup"])) != record["before"]: raise ValueError("Backup checksum mismatch; refusing restore") current = digest(inside(game, record["path"])) if current not in {record["before"], record["after"]}: raise ValueError("Game file was modified by another tool; refusing to overwrite it") state["phase"] = "restoring" write_json(state_path, state) for record in state["files"]: atomic_copy(inside(game, record["backup"]), inside(game, record["path"]), record["before"]) state["phase"] = "restored" write_json(state_path, state) print("Оригинальные файлы восстановлены. Резервные копии сохранены.") def snapshot(environment): return {(obj.assets_file.name, obj.path_id): hashlib.sha256(obj.get_raw_data()).hexdigest() for obj in environment.objects} def ui_text_template(environment): for obj in environment.objects: if obj.type.name != "MonoBehaviour": continue data = obj.read(check_read=False) try: script = data.m_Script.read() except (ValueError, AttributeError, FileNotFoundError): continue if script.m_Namespace == "UnityEngine.UI" and script.m_ClassName == "Text": return obj.serialized_type.node raise ValueError("Unity UI Text schema not found in bundle") def ui_text_components(environment, node): for obj in environment.objects: if obj.type.name != "MonoBehaviour": continue try: script = obj.read(check_read=False).m_Script.read() except (ValueError, AttributeError, FileNotFoundError): continue if script.m_Namespace == "UnityEngine.UI" and script.m_ClassName == "Text": # A known Text with an incompatible layout must fail the build, # rather than silently disappear from the translation coverage. yield obj, obj.read_typetree(node) def original_sources(game, inputs): sources = {path: path for path in inputs} live_hashes = {path: digest(path) for path in inputs} state_path = game / STATE if state_path.exists(): state = json.loads(state_path.read_text(encoding="utf-8")) if state["phase"] == "installed": records = {record["path"]: record for record in state["files"]} for path in inputs: record = records[path.relative_to(game).as_posix()] backup = inside(game, record["backup"]) if live_hashes[path] != record["after"] or digest(backup) != record["before"]: raise ValueError("Installed files or original backups changed; refusing rebuild") sources[path] = backup print("Для новой сборки использую проверенные резервные копии оригиналов.", flush=True) elif state["phase"] != "restored": raise RuntimeError("Сначала заверши откат через scripts/windows/tomie/restore_tomie_original.bat") return sources, live_hashes def font_fixes(environment, required): from fontTools.ttLib import TTFont fonts = [obj for obj in environment.objects if obj.type.name == "Font"] donor = next(obj.read_typetree() for obj in fonts if obj.read().m_Name == "Candara") changed = set() for obj in fonts: data = obj.read_typetree() font = TTFont(io.BytesIO(bytes(data["m_FontData"]))) cmap = font.getBestCmap() missing = [char for char in required if ord(char) not in cmap and char.isalpha()] font.close() if not missing: continue for key in ("m_FontData", "m_FontNames", "m_LineSpacing", "m_Ascent", "m_Descent", "m_PixelScale"): data[key] = donor[key] obj.save_typetree(data) changed.add((obj.assets_file.name, obj.path_id)) return changed def verify_bundle(path, before, allowed): import UnityPy environment = UnityPy.load(str(path)) after = snapshot(environment) if before.keys() != after.keys(): raise ValueError("Unity object IDs changed while packing") changed = {key for key in before if before[key] != after[key]} if changed - allowed: raise ValueError("Unrelated Unity objects changed while packing") return {"objects_checked": len(before), "changed_objects": [list(key) for key in sorted(changed)], "untouched_objects": len(before) - len(changed)} def ensure_api(root, base): from urllib.parse import urlsplit url = urlsplit(base) if url.scheme != "http" or url.hostname not in {"127.0.0.1", "localhost"} or url.username or url.password: raise ValueError("This game adapter only uses a local translator API") def ready(): try: value = httpx.get(base + "/health", timeout=2, trust_env=False).json() return value.get("worker_running") is True except (httpx.HTTPError, ValueError): return False if ready(): return print("Запускаю локальный API; ожидаю готовности...", flush=True) log_dir = root / "data/tomie" log_dir.mkdir(parents=True, exist_ok=True) with (log_dir / "adapter-api.log").open("ab") as log: subprocess.Popen([sys.executable, "-m", "local_translator", "--config", str(root / "config.toml")], cwd=root, stdout=log, stderr=log, stdin=subprocess.DEVNULL, creationflags=0x08000000 if os.name == "nt" else 0) deadline = time.monotonic() + 120 while time.monotonic() < deadline: if ready(): return time.sleep(0.5) raise RuntimeError("API не готов. Проверь scripts/windows/start.bat и data/tomie/adapter-api.log") def prepare(config, root, result_path=None): import UnityPy from .benchmark import request game = (root / config["game"]).resolve() output_root = (root / config["output"]).resolve() if output_root.is_relative_to(game): raise ValueError("Build output must be outside the game directory") package = output_root / (datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + uuid4().hex[:8]) package.mkdir(parents=True, exist_ok=False) main_path = game / DATA / "data.unity3d" bundle_path = next((game / DATA / "StreamingAssets/aa").rglob("naninovel_assets_all*.bundle")) catalog_path = game / DATA / "StreamingAssets/aa/catalog.json" inputs = [main_path, bundle_path, catalog_path] sources, live_hashes = original_sources(game, inputs) original_hashes = {str(path): digest(sources[path]) for path in inputs} print("Читаю словарь и кнопки интерфейса...", flush=True) main = UnityPy.load(str(sources[main_path])) main_before = snapshot(main) asset = next(obj for obj in main.objects if obj.type.name == "TextAsset" and obj.read().m_Name == "DefaultUI") script = asset.read().m_Script entries = parse_dictionary(script) bundle = UnityPy.load(str(sources[bundle_path])) bundle_before = snapshot(bundle) text_node = ui_text_template(bundle) source_values = {row["text"].casefold() for row in entries} literals = set() for obj, tree in list(ui_text_components(bundle, text_node)) + list(ui_text_components(main, text_node)): text = tree.get("m_Text") if isinstance(text, str) and text.strip().casefold() in source_values | MENU_LABELS: literals.add(text.strip()) items = [{"id": row["id"], "text": row["text"]} for row in entries] items += [{"id": f"literal.{index}", "text": text} for index, text in enumerate(sorted(literals))] quality_objects = [obj for obj in main.objects if obj.type.name == "QualitySettings"] quality_names = sorted({setting["name"] for obj in quality_objects for setting in obj.read_typetree()["m_QualitySettings"]}) items += [{"id": f"quality.{index}", "text": name} for index, name in enumerate(quality_names)] write_json(package / "input.json", items) project = Project.model_validate(json.loads((root / config["project_file"]).read_text(encoding="utf-8-sig"))) if result_path: job = json.loads(Path(result_path).read_text(encoding="utf-8-sig")) expected = {item["id"]: item["text"] for item in items} rows = job.get("items", []) if len(rows) != len(items) or {row["id"]: row["source"] for row in rows} != expected or any(row.get("status") != "completed" or not isinstance(row.get("translation"), str) for row in rows): raise ValueError("Saved result does not match the current game's complete input dataset") print("Использую указанный файл переводов без повторного запроса модели.", flush=True) else: base = config["api_base"].rstrip("/") ensure_api(root, base) with httpx.Client(base_url=base, timeout=40, trust_env=False) as client: project_data = request(client, "POST", "/projects", project.model_dump()) job = request(client, "POST", f"/projects/{project_data['id']}/jobs", {"items": items, "batch_size": config.get("batch_size", 10)}) write_json(package / "job-reference.json", {"job_id": job["id"], "project_id": project_data["id"], "api_base": base}) deadline = time.monotonic() + 900 last_progress = None while job["status"] in {"queued", "running"}: if time.monotonic() > deadline: raise RuntimeError("Перевод ещё не завершён; ID задания сохранён в job-reference.json") if job["progress"] != last_progress: print(f"Перевод: {job['progress']}", flush=True) last_progress = job["progress"] time.sleep(0.5) job = request(client, "GET", "/jobs/" + job["id"]) write_json(package / "translations.json", job) if job["status"] != "completed": raise RuntimeError("Есть ошибки перевода. Патч не собран; подробности в " + str(package / "translations.json")) translations = {row["id"]: row["translation"] for row in job["items"]} for item in items: source = protect(item["text"], project) target = protect(translations[item["id"]], project) if list(source.tokens.values()) != list(target.tokens.values()): raise ValueError("Translation changed protected syntax: " + item["id"]) replacements = {row["id"]: translations[row["id"]] for row in entries} updated_script = rewrite_dictionary(script, replacements) quality_translations = {name: translations[f"quality.{index}"] for index, name in enumerate(quality_names)} extra_records = {} for obj in quality_objects: for index, setting in enumerate(obj.read_typetree()["m_QualitySettings"]): key = f"Naninovel.UI.GameSettingsGraphicsDropdown.GraphicOption{index + 1}" if key not in replacements: extra_records[key] = quality_translations[setting["name"]] updated_script = append_managed_records(updated_script, extra_records) data = asset.read() data.m_Script = updated_script data.save() write_json(package / "ui-preview.json", {**replacements, **extra_records}) (package / "DefaultUI.ru.txt").write_text(updated_script, encoding="utf-8", newline="") main_allowed = {(asset.assets_file.name, asset.path_id)} required = set("".join(translations.values())) main_allowed |= font_fixes(main, required) literal_translations = {text: translations[f"literal.{index}"] for index, text in enumerate(sorted(literals))} bundle_allowed = font_fixes(bundle, required) button_count = 0 for environment, allowed in ((bundle, bundle_allowed), (main, main_allowed)): for obj, tree in ui_text_components(environment, text_node): text = tree.get("m_Text") if isinstance(text, str) and text.strip() in literal_translations: translated = literal_translations[text.strip()] if text.strip() == translated: continue tree["m_Text"] = text.replace(text.strip(), translated, 1) font_data = tree.get("m_FontData", {}) if "m_BestFit" in font_data: font_data["m_BestFit"] = True font_data["m_MinSize"] = min(10, font_data["m_FontSize"]) font_data["m_MaxSize"] = font_data["m_FontSize"] obj.save_typetree(tree, nodes=text_node) allowed.add((obj.assets_file.name, obj.path_id)) button_count += 1 print("Собираю ресурсы и проверяю неизменённые объекты...", flush=True) verification = {} for original, environment, before, allowed in ((main_path, main, main_before, main_allowed), (bundle_path, bundle, bundle_before, bundle_allowed)): relative = original.relative_to(game) target = inside(package / "payload", relative) target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(next(iter(environment.files.values())).save(packer="lz4")) verification[relative.as_posix()] = verify_bundle(target, before, allowed) bundle_payload = inside(package / "payload", bundle_path.relative_to(game)) catalog = json.loads(sources[catalog_path].read_text(encoding="utf-8-sig")) old_hash = re.search(r"_([a-f0-9]{32})\.bundle$", bundle_path.name)[1] new_bundle_data = bundle_payload.read_bytes() updated_catalog, old_options, new_options = patch_catalog(catalog, old_hash, bundle_crc(new_bundle_data), len(new_bundle_data), hashlib.md5(new_bundle_data).hexdigest()) if bundle_crc(sources[bundle_path].read_bytes()) != old_options["m_Crc"]: raise ValueError("Original bundle CRC does not match the catalog") write_json(inside(package / "payload", catalog_path.relative_to(game)), updated_catalog) records = [] for original in inputs: if digest(sources[original]) != original_hashes[str(original)] or digest(original) != live_hashes[original]: raise ValueError("Original game changed while building the patch") relative = original.relative_to(game).as_posix() records.append({"path": relative, "before": original_hashes[str(original)], "after": digest(inside(package / "payload", relative))}) manifest = {"id": package.name, "game": str(game), "created_at": datetime.now(timezone.utc).isoformat(), "files": records, "managed_text_entries": len(entries) + len(extra_records), "literal_buttons": button_count, "quality_names": len(extra_records), "verification": verification, "addressables_before": old_options, "addressables_after": new_options, "scope": "DefaultUI and selected static UI labels; no scenario/dialogue translation; no in-game verification"} write_json(package / "manifest.json", manifest) write_json(output_root / "latest.json", {"package": str(package)}) print(f"Готово: {package}\nЗаписей словаря: {len(entries)}; статических надписей: {button_count}.\nЗакрой игру и запусти scripts/windows/tomie/install_tomie_translation.bat.") return package def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--config", type=Path, default=Path("tomie.json")) parser.add_argument("action", choices=("prepare", "install", "restore", "status")) parser.add_argument("--result", type=Path, help="Explicitly reuse a complete saved translations.json when rebuilding the same dataset") args = parser.parse_args() root = args.config.resolve().parent config = json.loads(args.config.read_text(encoding="utf-8-sig")) game = (root / config["game"]).resolve() if args.action == "prepare": prepare(config, root, args.result.resolve() if args.result else None) elif args.action == "restore": restore(game) elif args.action == "status": state = game / STATE print(state.read_text(encoding="utf-8") if state.exists() else "Патч не установлен.") else: latest = json.loads((root / config["output"] / "latest.json").read_text(encoding="utf-8")) install(Path(latest["package"]), game) if __name__ == "__main__": try: main() except (OSError, RuntimeError, ValueError, httpx.HTTPError, StopIteration, subprocess.SubprocessError) as exc: raise SystemExit(str(exc)) from exc