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:
1 parent
06ff19ac4a
commit
c8d83b3fe7
65 files changed
+3825
-68
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user