Files
pgames-manager/backend/app/converter/nani_package.py
T
mrleo1nidandClaude Sonnet 5.5 12ec82c7e6 refactor: move game-specific import logic into profiles; unblock Tomie import
- Add app/games: GameProfile base, registry, ReaderHooks and renpy/naninovel/
  tomie profiles. pipeline, preflight, detect and mods dispatch via the registry
- Move tomie_* converter modules to app/games/tomie and split out reader hooks,
  import, mod validation and known defects
- Tolerate the 31 authoring defects of the pinned Tomie build as report
  warnings (exact list, per-script counts); any new defect still blocks import
- Frontend: runtime registry (runtimes.ts) with lazy Tomie launcher
- Add profile/registry tests and docs/GAME_PROFILES.md; update IMPORT.md

Full media export of the original Tomie build is not yet verified.

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

210 lines
9.4 KiB
Python

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