feat: add mod editing, revision merges and secure game storage

Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
This commit is contained in:
mrleo1nid committed 2026-10-25 04:42:48 +03:00
1 parent 06ff19ac4a
commit c8d83b3fe7
65 files changed
+3825 -68

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
"""Recover Naninovel data from Unity builds without executing their managed code."""
from dataclasses import dataclass, field
from pathlib import Path
import hashlib
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)
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 = {}, set()
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"
if key.strip() in keys:
raise ValueError(f"Повторяющийся ключ локализации: {origin}:{key.strip()}")
keys.add(key.strip())
for index, value in enumerate(values):
if not value.strip():
continue
address = f"{origin}:{key.strip()}:{index}"
tid = "nani-resource-" + hashlib.sha256(address.encode()).hexdigest()[:24]
records[tid] = {"text": value, "kind": kind, "origin": origin, "key": key.strip(),
"part": index, "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:
import UnityPy
from app.converter.unity_scripts import ScriptCodec, script_objects
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]
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))
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:
package.issues.append(Issue("conflicting_script", "Различающиеся копии сценария", name))
else:
package.scripts[name] = decoded
for obj in environment.objects:
if obj.type.name == "TextAsset":
asset = obj.read()
if asset.m_Name in {"DefaultUI", "Tips", "CharacterNames", "Inventory"}:
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