"""Recover Naninovel data from Unity builds without executing their managed code.""" from dataclasses import dataclass, field from pathlib import Path import hashlib import re from app.converter.compatibility import CompatibilityError, Issue from app.converter.unity_bundle import unpack @dataclass class Package: scripts: dict = field(default_factory=dict) texts: dict = field(default_factory=dict) resources: list = field(default_factory=list) issues: list = field(default_factory=list) def simplify(value): if isinstance(value, dict): if "hasValue" in value and "value" in value: dynamic = value.get("dynamicValue", {}) if dynamic.get("ValueText"): return {"template": dynamic["ValueText"]} return simplify(value["value"]) if value["hasValue"] else None return {k: simplify(v) for k, v in value.items()} if isinstance(value, list): return [simplify(v) for v in value] return value def decode_script(tree: dict) -> dict: references = tree["references"]["records"] def deref(ref): index = ref["id"] if not isinstance(index, int) or not 0 <= index < len(references): raise ValueError("Некорректная ссылка в сценарии Naninovel") return references[index] lines = [] for pointer in tree["lines"]: record = deref(pointer) kind, body = record["type"]["class"], record["data"] commands = [] if kind == "CommandScriptLine": commands = [deref(body["command"])] elif kind == "GenericTextScriptLine": commands = [deref(ref) for ref in body["inlinedCommands"]] elif kind not in {"LabelScriptLine", "CommentScriptLine", "EmptyScriptLine"}: raise ValueError(f"Не поддерживается тип строки Naninovel: {kind}") lines.append({"line": body["lineIndex"], "label": body.get("labelText"), "hash": body.get("lineHash", ""), "commands": [ {"op": command["type"]["class"], "args": { k: value for k, v in command["data"].items() if k != "playbackSpot" and (value := simplify(v)) is not None}} for command in commands]}) return {"name": tree["m_Name"], "lines": lines} def text_records(name: str, text: str, origin: str) -> dict: """Addressable entries; delimiters and keys remain outside translatable prose.""" records, keys = {}, set() for number, line in enumerate(text.splitlines(), 1): if not line.strip() or line.lstrip().startswith((";", "#", "//")): continue if name == "_synonyms": key, sep, value = line.partition("=") if not sep: raise ValueError(f"Некорректная таблица синонимов: {origin}:{number}") values, kind = value.split(","), "synonym" elif name in {"DefaultUI", "Tips", "CharacterNames", "Inventory"}: key, sep, value = line.partition(":") if not sep: # Naninovel managed-text resources can contain non-entry lines. # They are not dictionary values and must not become translations. continue values, kind = value.split("|") if name == "Tips" else [value], "dictionary" else: key, values, kind = str(number), [line], "template" if key.strip() in keys: raise ValueError(f"Повторяющийся ключ локализации: {origin}:{key.strip()}") keys.add(key.strip()) for index, value in enumerate(values): if not value.strip(): continue address = f"{origin}:{key.strip()}:{index}" tid = "nani-resource-" + hashlib.sha256(address.encode()).hexdigest()[:24] records[tid] = {"text": value, "kind": kind, "origin": origin, "key": key.strip(), "part": index, "line": number, "delimiters": ",=\r\n" if kind == "synonym" else "|\r\n" if name == "Tips" else "\r\n"} return records def read_package(root: Path, work: Path, *, media: bool = False) -> Package: import UnityPy from app.converter.unity_scripts import ScriptCodec, script_objects package = Package() data_roots = [p for p in root.iterdir() if p.is_dir() and p.name.lower().endswith("_data")] if len(data_roots) != 1: raise CompatibilityError("naninovel", [Issue("unity_layout", "Неоднозначный каталог Unity Data")]) data = data_roots[0] sources = sorted(data.glob("*.assets")) + sorted(data.glob("*.unity3d")) sources += sorted((data / "StreamingAssets").rglob("*.bundle")) codec = None for number, source in enumerate(sources): try: with source.open("rb") as handle: bundled = handle.read(8).startswith(b"UnityFS") paths = unpack(source, work / f"bundle-{number}") if bundled else [source] for path in paths: if path.name.endswith((".resource", ".resS")): continue environment = UnityPy.load(str(path)) for stream in data.glob("*.resource"): environment.load_file(str(stream)) for obj in script_objects(environment): if codec is None: codec = ScriptCodec(root, str(obj.assets_file.unity_version)) with codec.active(): decoded = decode_script(codec.read(obj)) name = decoded["name"] previous = package.scripts.get(name) if previous is not None and previous != decoded: package.issues.append(Issue("conflicting_script", "Различающиеся копии сценария", name)) else: package.scripts[name] = decoded for obj in environment.objects: if obj.type.name == "TextAsset": asset = obj.read() if asset.m_Name in {"DefaultUI", "Tips", "CharacterNames", "Inventory"}: origin = f"{source.relative_to(root).as_posix()}/{obj.path_id}/{asset.m_Name}" package.texts.update(text_records(asset.m_Name, asset.m_Script, origin)) elif media and obj.type.name in {"Texture2D", "AudioClip", "Font", "VideoClip"}: package.resources.append((source, obj)) except CompatibilityError: raise except Exception as exc: package.issues.append(Issue("unity_decode", f"Не удалось прочитать ресурсы: {exc}", source.relative_to(root).as_posix())) for source in sorted((data / "StreamingAssets" / "text").glob("*.txt")): package.texts.update(text_records(source.stem, source.read_text(encoding="utf-8-sig"), source.relative_to(root).as_posix())) if not package.scripts: package.issues.append(Issue("missing_scripts", "Не найдены читаемые сценарии Naninovel")) return package