Files
pgames-manager/backend/app/converter/nani_package.py
T
mrleo1nidandClaude Sonnet 5.5 bbbcc6a5d8 chore: add CI, lint config, login rate limit and split large modules
- Rate-limit failed logins (8 per 15 min per IP or username, 429 + Retry-After)
- Add ruff/mypy config and GitHub Actions workflow (ruff, pytest, tsc, tests, build)
- Apply ruff autofixes (import order, pyupgrade)
- Extract rpy_helpers.py, engine-util.ts, TranslationParts.tsx and
  translation-types.ts from oversized modules
- Add dependency-free React UI tests
- Refresh README and ARCHITECTURE (Naninovel/Tomie, status and plans)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 11:23:22 +03:00

234 lines
12 KiB
Python

"""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