"""Recover Naninovel data from Unity builds without executing their managed code.""" import hashlib 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 ReaderContext: root: Path source: Path media: bool codec: object # callable: Unity version -> ScriptCodec @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, hooks=None) -> Package: """Read a Naninovel Unity build. ``hooks`` (``app.games.hooks.ReaderHooks``) add game-specific data.""" from app.games.hooks import ReaderHooks package = Package() try: return _read_package(root, work, package, media=media, hooks=hooks or ReaderHooks()) except BaseException: package.close() raise def _read_package(root: Path, work: Path, package: Package, *, media: bool, hooks) -> Package: import UnityPy from app.converter.unity_scripts import ScriptCodec, script_objects hooks.begin(package, 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 def get_codec(version): nonlocal codec if codec is None: codec = ScriptCodec(root, version) return codec 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): codec = get_codec(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 hooks.accept_duplicate_script(name): 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 hooks.scan_object(package, obj, ReaderContext(root, source, media, get_codec)): continue if 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