234 lines
12 KiB
Python
234 lines
12 KiB
Python
"""Recover Naninovel data from Unity builds without executing their managed code."""
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
import hashlib
|
|
import json
|
|
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)
|
|
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.unity_scripts import ScriptCodec, script_objects
|
|
from app.converter.tomie_profile import identify, extract_tables
|
|
|
|
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
|