"""Recover Naninovel data from Unity builds without executing their managed code.""" import hashlib import json from dataclasses import dataclass, field from pathlib import Path 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) environments: list = field(default_factory=list, repr=False) profile: str | None = None defaults: dict = field(default_factory=dict) function_data: dict = field(default_factory=dict) actor_resources: dict = field(default_factory=dict) def close(self): # UnityPy readers can retain OS file handles through cyclic references. # Explicitly close the owned readers before deleting a workspace on Windows. seen = set() def close(value): if id(value) in seen: return seen.add(id(value)) stream = getattr(value, "stream", None) if stream is not None: stream.close() reader = getattr(value, "reader", None) if reader is not None: close(reader) for field in ("files", "cabs"): for child in (getattr(value, field, None) or {}).values(): close(child) for environment in self.environments: close(environment) self.resources.clear() self.environments.clear() 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 = {}, {} 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" occurrence = keys.get(key.strip(), 0) keys[key.strip()] = occurrence + 1 for index, value in enumerate(values): if not value.strip(): continue address = f"{origin}:{key.strip()}:{index}" if occurrence: address += f":occurrence-{occurrence}" tid = "nani-resource-" + hashlib.sha256(address.encode()).hexdigest()[:24] records[tid] = {"text": value, "kind": kind, "collection": name, "origin": origin, "key": key.strip(), "part": index, "occurrence": occurrence, "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: package = Package() try: return _read_package(root, work, package, media=media) except BaseException: package.close() raise def _read_package(root: Path, work: Path, package: Package, *, media: bool) -> Package: import UnityPy from app.converter.tomie_profile import extract_tables, identify from app.converter.unity_scripts import ScriptCodec, script_objects package.profile = identify(root) if package.profile: package.function_data = extract_tables(root) 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] stream_names = {path.stem for path in (data / "StreamingAssets" / "text").glob("*.txt")} 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)) package.environments.append(environment) 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 and name == "Init" and package.profile: # This pinned Tomie build contains an editor/addressable # Init alongside the main corpus. The recovered port uses # the main ResourceManager copy; it is read first here. continue 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 == "ResourceManager" and package.profile and media: from UnityPy.classes import PPtr actor_keys = set() for key, pointer in obj.read_typetree()["m_Container"]: if not key.lower().startswith("characters/"): continue name = key.lower().removeprefix("characters/") actor_keys.add(name) actor = PPtr(assetsfile=obj.assets_file, **pointer).deref() # Unity exposes both the complete texture and its # Sprite under the same Resources key. Naninovel's # texture actor uses the complete texture, as does # the recovered Godot port. if actor.type.name == "Sprite": continue if actor.type.name != "Texture2D": package.issues.append(Issue("unsupported_actor", "Не поддерживается тип персонажа Tomie", key)) continue identity = f"{source.relative_to(root).as_posix()}:{actor.assets_file.name}:{actor.path_id}" previous = package.actor_resources.get(name) if previous and previous != identity: package.issues.append(Issue("conflicting_actor", "Различающиеся изображения персонажа Tomie", key)) package.actor_resources[name] = identity package.resources.append((source, actor)) for name in actor_keys - package.actor_resources.keys(): package.issues.append(Issue("missing_actor_texture", "Не найдена полная текстура персонажа Tomie", name)) elif obj.type.name == "MonoBehaviour" and package.profile and obj.peek_name() == "CustomVariablesConfiguration": if codec is None: codec = ScriptCodec(root, str(obj.assets_file.unity_version)) node = obj.serialized_type.node or codec.node("Elringus.Naninovel.Runtime", "Naninovel.CustomVariablesConfiguration") config = obj.read_typetree(nodes=node) defaults = {entry["name"]: json.loads(entry["value"]) for entry in config["PredefinedVariables"]} if package.defaults and package.defaults != defaults: package.issues.append(Issue("conflicting_defaults", "Различающиеся исходные переменные Naninovel")) else: package.defaults = defaults elif obj.type.name == "TextAsset": asset = obj.read() if asset.m_Name in {"DefaultUI", "Tips", "CharacterNames", "Inventory"} | stream_names: 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