feat: add native Tomie adapter and strict import compatibility checks
This commit is contained in:
1 parent
c8d83b3fe7
commit
57a4edcbfd
45 files changed
+2985
-71
No files matched your search
@@ -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"}:
|
||||
|
||||
Reference in new issue
Block a user