feat: add native Tomie adapter and strict import compatibility checks

This commit is contained in:
mrleo1nid committed 2026-10-25 11:02:52 +03:00
1 parent c8d83b3fe7
commit 57a4edcbfd
45 files changed
+2985 -71

No files matched your search

+94 -9
View File
@@ -2,6 +2,7 @@
from dataclasses import dataclass, field
from pathlib import Path
import hashlib
import json
import re
from app.converter.compatibility import CompatibilityError, Issue
@@ -14,6 +15,33 @@ class Package:
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):
@@ -60,7 +88,7 @@ def decode_script(tree: dict) -> dict:
def text_records(name: str, text: str, origin: str) -> dict:
"""Addressable entries; delimiters and keys remain outside translatable prose."""
records, keys = {}, set()
records, keys = {}, {}
for number, line in enumerate(text.splitlines(), 1):
if not line.strip() or line.lstrip().startswith((";", "#", "//")):
continue
@@ -78,30 +106,45 @@ def text_records(name: str, text: str, origin: str) -> dict:
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())
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, "origin": origin, "key": key.strip(),
"part": index, "line": number,
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)
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]
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
@@ -114,6 +157,7 @@ def read_package(root: Path, work: Path, *, media: bool = False) -> Package:
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):
@@ -123,14 +167,55 @@ def read_package(root: Path, work: Path, *, media: bool = False) -> Package:
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 == "TextAsset":
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"}:
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"}: