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

+7
View File
@@ -15,6 +15,8 @@ from collections import Counter
from pathlib import Path
from app.converter import assets, locales
from app.converter.compatibility import Issue, audit_ir
from dataclasses import asdict
from app.converter.archive import ArchiveError, find_game_dir
from app.converter.pipeline import _auto_images, _is_engine_file, _resolve_image, anchors
from app.converter.rpa import RpaError, extract_rpa, read_index
@@ -81,6 +83,7 @@ def analyze(src: Path, dump: Path | None = None) -> dict:
data.warn("Слишком глубокая вложенность", rel)
by_line = locales.assign_ids(data.modules)
audit_ir(data)
translations = locales.import_tl(work, data.modules, by_line, data.warn)
says = len(by_line) or 1
language = data.config.get("language")
@@ -100,6 +103,10 @@ def analyze(src: Path, dump: Path | None = None) -> dict:
_collect_targets(data.modules, targets)
media = Counter(assets.kind_of(Path(v)) for v in files.values() if assets.kind_of(Path(v)))
return {
"issues": [asdict(Issue("unsupported_script", msg,
data.warning_examples.get(msg, ""), count))
for msg, count in data.blockers.items()] +
[asdict(Issue("archive_error", msg)) for msg in rpa_errors],
"game": src.name,
"renpy": renpy_version(src),
"seconds": round(time.monotonic() - started, 1),
+11 -5
View File
@@ -16,15 +16,16 @@ def _safe_target(root: Path, name: str) -> Path | None:
return target if target.is_relative_to(root.resolve()) else None
def extract(archive: Path, filename: str, dest: Path) -> None:
def extract(archive: Path, filename: str, dest: Path, *, only_scripts: bool = False) -> None:
dest.mkdir(parents=True, exist_ok=True)
if filename.lower().endswith(".zip") and zipfile.is_zipfile(archive):
_extract_zip(archive, dest)
_extract_zip(archive, dest, only_scripts=only_scripts)
return
seven = shutil.which("7zz") or shutil.which("7z")
if not seven:
raise ArchiveError("Для этого формата нужен 7-Zip (7zz), он не установлен")
proc = subprocess.run([seven, "x", "-y", "-snl-", f"-o{dest}", str(archive)],
filters = [f"-ir!*{suffix}" for suffix in SCRIPT_EXTS | {".tar"}] if only_scripts else []
proc = subprocess.run([seven, "x", "-y", "-snl-", *filters, f"-o{dest}", str(archive)],
capture_output=True, text=True)
if proc.returncode not in (0, 1): # 1 = warnings
raise ArchiveError(f"7-Zip: {proc.stderr.strip() or proc.stdout.strip()[-500:]}")
@@ -33,14 +34,19 @@ def extract(archive: Path, filename: str, dest: Path) -> None:
inner = list(dest.iterdir())
if len(inner) == 1 and inner[0].suffix == ".tar":
tar = inner[0]
extract(tar, tar.name, dest / "_tar")
extract(tar, tar.name, dest / "_tar", only_scripts=only_scripts)
tar.unlink()
def _extract_zip(archive: Path, dest: Path) -> None:
SCRIPT_EXTS = {".rpy", ".rpyc", ".rpa", ".rpym", ".rpymc"}
def _extract_zip(archive: Path, dest: Path, *, only_scripts: bool = False) -> None:
with zipfile.ZipFile(archive) as zf:
for info in zf.infolist():
name = info.filename
if only_scripts and Path(name).suffix.lower() not in SCRIPT_EXTS:
continue
if info.flag_bits & 0x800 == 0:
# legacy zips store names in cp437; many were really written as cp866/utf-8
try:
+137
View File
@@ -0,0 +1,137 @@
"""Engine detection and fail-closed import diagnostics. Never runs game executables."""
from dataclasses import asdict, dataclass
from pathlib import Path, PurePosixPath
import os
@dataclass(frozen=True)
class Issue:
code: str
message: str
location: str = ""
count: int = 1
class CompatibilityError(RuntimeError):
def __init__(self, engine: str, issues: list[Issue]):
self.engine, self.issues = engine, issues
super().__init__("Импорт остановлен: " + "; ".join(
i.message + (f" ({i.location})" if i.location else "") for i in issues[:5]))
def report(self) -> dict:
return {"error": str(self), "engine": self.engine, "compatible": False,
"issues": [asdict(i) for i in self.issues]}
@dataclass(frozen=True)
class Detection:
engine: str
root: Path
# Development copies and installed patches must not be mistaken for additional games.
IGNORED = {".git", ".godot", ".local-translator", "migration-staging", "migration-tools",
"node_modules", "__macosx"}
def detect(root: Path) -> Detection:
candidates: dict[tuple[str, Path], Detection] = {}
for directory, dirs, files in os.walk(root, followlinks=False):
dirs[:] = sorted(d for d in dirs if d.lower() not in IGNORED and
not Path(directory, d).is_symlink())
path = Path(directory)
names = {f.lower() for f in files}
lower = {d.lower() for d in dirs}
engine = None
if path.name.lower().endswith("_data") and ("managed" in lower or "il2cpp_data" in lower):
managed = path / next((d for d in dirs if d.lower() == "managed"), "Managed")
dlls = {p.name.lower() for p in managed.glob("*.dll")}
engine = "naninovel" if any("naninovel" in n for n in dlls) else "unity"
elif "data.win" in names:
engine = "gamemaker"
elif "project.godot" in names or any(n.endswith(".pck") for n in names):
engine = "godot"
elif "game.rgss3a" in names or "game.rgss2a" in names or "game.rgssad" in names:
engine = "rpgmaker"
elif "rpg_core.js" in names or "rmmz_core.js" in names:
engine = "rpgmaker"
elif path.name.lower() == "game" and any(
PurePosixPath(n).suffix in {".rpy", ".rpyc", ".rpa"} for n in names):
engine = "renpy"
if engine:
game_root = path.parent if engine in {"naninovel", "unity", "renpy"} else path
candidates[(engine, game_root)] = Detection(engine, game_root)
# Engine internals are not independent games.
dirs.clear()
if not candidates:
# Unpacked Ren'Py script-only archives do not always have a game/ wrapper.
from app.converter.archive import ArchiveError, find_game_dir
try:
game = find_game_dir(root)
return Detection("renpy", game)
except ArchiveError:
raise CompatibilityError("unknown", [Issue("unknown_engine",
"Движок не распознан; нужен полный дистрибутив игры, а не отдельный EXE или патч")]) from None
if len(candidates) != 1:
raise CompatibilityError("ambiguous", [Issue("multiple_games",
"В одном импорте обнаружено несколько игр; загрузите каждую отдельно",
", ".join(f"{c.engine}: {c.root.relative_to(root)}" for c in candidates.values()))])
result = next(iter(candidates.values()))
if result.engine not in {"renpy", "naninovel"}:
raise CompatibilityError(result.engine, [Issue("unsupported_engine",
f"Движок {result.engine} распознан, но его исполнение в PGames не поддерживается")])
return result
def require_supported_scripts(data, engine: str = "renpy") -> None:
issues = [Issue("unsupported_script", message, data.warning_examples.get(message, ""), count)
for message, count in data.blockers.items()]
if issues:
raise CompatibilityError(engine, issues)
CALLS = set("len str int float bool abs round min max sum any all range list tuple set dict sorted reversed enumerate zip isinstance _ __".split())
CALLS |= {prefix + method for prefix in ("random.", "renpy.random.")
for method in ("randint", "random", "choice", "shuffle", "uniform")}
CALLS |= {"math." + method for method in ("floor", "ceil", "sqrt", "pow")}
CALLS |= {"renpy." + method for method in ("play", "notify", "jump", "call", "input", "has_label",
"seen_label", "showing", "get_attributes", "full_restart", "quit", "version")}
CALLS |= {f"renpy.{channel}.{method}" for channel in ("music", "sound")
for method in ("play", "queue", "stop", "get_playing", "is_playing")}
METHODS = set("append extend insert pop remove index count reverse sort clear copy add discard get keys values items setdefault update lower upper capitalize title strip lstrip rstrip startswith endswith replace split join format find isdigit isalpha".split())
def audit_ir(data) -> None:
"""Reject calls which the browser would otherwise silently replace by None."""
def dotted(value):
if value.get("k") == "name":
return value["id"]
if value.get("k") == "attr":
return dotted(value["o"]) + "." + value["a"]
return ""
def visit(value, location):
if isinstance(value, list):
for item in value:
visit(item, location)
elif isinstance(value, dict):
if value.get("k") == "call":
function = value.get("f", {})
name = dotted(function)
if name not in CALLS and not (function.get("k") == "attr" and
function.get("a") in METHODS and not name.startswith(("renpy.", "math.", "random."))):
data.warn(f"Вызов не поддерживается плеером: {name or 'динамическая функция'}", location)
if value.get("op") in {"jump", "call"} and value.get("target"):
if value["target"] not in labels:
data.warn(f"Не найдена цель перехода: {value['target']}", location)
for child in value.values():
visit(child, location)
from app.converter.pipeline import anchors
labels = {label["name"] for module in data.modules.values() for label in module}
labels |= {name for module in data.modules.values() for name in anchors(module)}
for path, module in data.modules.items():
visit(module, path)
visit(data.defaults, "default")
visit(data.init_code, "init")
visit(data.images, "image")
+7 -5
View File
@@ -8,6 +8,7 @@
import hashlib
import re
from uuid import uuid4
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from pathlib import Path
@@ -90,20 +91,21 @@ def assign_ids(modules: dict[str, list[dict[str, Any]]]) -> dict[tuple[str, int]
by_line: dict[tuple[str, int], dict] = {}
used: Counter = Counter()
for file, labels in modules.items():
n = 0
for label in labels:
label.setdefault("id", str(uuid4()))
group = label["name"]
for node in walk(label["body"]):
n += 1
node["id"] = short_hash(f"{file}:{n}")
node.setdefault("id", str(uuid4()))
for branch in node.get("branches", []) + node.get("items", []):
branch.setdefault("id", str(uuid4()))
if node.get("op") == "label":
group = node["name"]
if node.get("op") != "say":
if node.get("op") not in {"say", "nani_add_choice"}:
continue
# Ren'Py style: <label>_<hash of the line>, de-duplicated
base = f"{group}_{short_hash((node.get('who') or '') + chr(0) + node['what'])}"
used[base] += 1
node["tid"] = base if used[base] == 1 else f"{base}_{used[base] - 1}"
node.setdefault("tid", base if used[base] == 1 else f"{base}_{used[base] - 1}")
node["_group"] = group
if "_ln" in node:
by_line[(file, node["_ln"])] = node
+214
View File
@@ -0,0 +1,214 @@
"""Compile the supported Naninovel command subset to the shared PGVN player."""
from collections import Counter
import hashlib
import re
from app.converter import nani_expr
from app.converter.compatibility import CompatibilityError, Issue
from app.converter.rpy_parser import ScriptData
def target(path, script: str) -> str:
if isinstance(path, dict):
return "nani:" + str(path.get("name") or script) + ("." + str(path["value"]) if path.get("value") else "")
if not isinstance(path, str) or "{" in path:
raise ValueError("Динамический адрес перехода Naninovel пока не поддерживается")
return "nani:" + (script + path if path.startswith(".") else path)
def compile_package(package) -> tuple[ScriptData, str, dict]:
data, expressions = ScriptData(), {}
counts, locations = Counter(), {}
def issue(message, location):
counts[message] += 1
locations.setdefault(message, location)
def text(value):
value = value.get("template", "") if isinstance(value, dict) else value
if not isinstance(value, str):
raise ValueError("Ожидался текст Naninovel")
for match in re.finditer(r"\{([^{}]+)\}", value):
expressions[match[1]] = nani_expr.parse(match[1])
# Rich text is parsed by the browser. Inline script commands must have
# been decoded as commands, rather than silently displayed as prose.
if re.search(r"\[(?:@|goto\b|set\b|wait\b|char\b|back\b)", value):
raise ValueError("Неподдерживаемая встроенная команда в тексте Naninovel")
for tag in re.findall(r"</?[A-Za-z][^<>]*>", value):
if not re.fullmatch(r"</?(?:b|i|u|s)>|<(?:color|size)=[^<>]+>|</(?:color|size)>|<br\s*/?>", tag, re.I):
raise ValueError(f"Не поддерживается текстовый тег Naninovel: {tag}")
return value
def static(value, name="параметр"):
if isinstance(value, dict) and "template" in value:
raise ValueError(f"Динамический {name} Naninovel пока не поддерживается")
return value
def expression(value):
return nani_expr.expr(static(value, "сценарное выражение"))
all_targets, all_names = [], set()
for script_name, script in package.scripts.items():
if (not re.fullmatch(r"[\w/.-]+", script_name) or script_name.startswith(("/", ".")) or
".." in script_name.split("/")):
issue("Некорректное имя сценария Naninovel", script_name)
continue
body, extras, stack = [], [], []
current = body
choice_count = 0
for line in script["lines"]:
location = f"{script_name}:{line['line']}"
if line.get("label"):
current.append({"op": "label", "name": f"nani:{script_name}.{line['label']}"})
for number, command in enumerate(line["commands"]):
op, args = command["op"], dict(command["args"])
identity = hashlib.sha256(f"{script_name}:{line['line']}:{number}".encode()).hexdigest()[:24]
try:
condition = args.pop("ConditionalExpression", None)
wait = args.pop("Wait", True)
if wait is not True and wait != 1:
raise ValueError("Асинхронные команды Naninovel (wait:false) пока не поддерживаются")
if op == "BeginIf":
node = {"op": "if", "branches": [{"c": expression(args.pop("Expression")), "body": []}]}
current.append(node)
stack.append((current, node))
current = node["branches"][0]["body"]
nodes = []
elif op in {"Else", "ElseIf"}:
if not stack or stack[-1][1]["branches"][-1]["c"] is None:
raise ValueError("Несогласованные ветки if/else Naninovel")
branch = {"c": expression(args.pop("Expression")) if op == "ElseIf" else None, "body": []}
stack[-1][1]["branches"].append(branch)
current = branch["body"]
nodes = []
elif op == "EndIf":
if not stack:
raise ValueError("EndIf без BeginIf")
current, _ = stack.pop()
nodes = []
elif op in {"PrintText", "AppendText"}:
author = text(args.pop("AuthorId", ""))
reset = args.pop("ResetPrinter", op != "AppendText")
interact = bool(args.pop("WaitForInput", True))
reveal = static(args.pop("RevealSpeed", 1), "RevealSpeed")
if reveal != 1:
raise ValueError("Изменение RevealSpeed Naninovel пока не поддерживается")
nodes = [{"op": "say", "what": text(args.pop("Text", "")), "tid": "nani-" + identity,
**({"whoStr": author} if reset else {"who": "extend"}), "interact": interact}]
elif op == "SetCustomVariable":
nodes = [{"op": "exec", "code": [nani_expr.assignment(static(args.pop("Expression"), "присваивание"))]}]
elif op in {"Goto", "Gosub"}:
destination = target(args.pop("Path"), script_name)
all_targets.append((destination, location))
nodes = [{"op": "jump" if op == "Goto" else "call", "target": destination}]
elif op == "Return":
nodes = [{"op": "return"}]
elif op == "AddChoice":
destination = args.pop("GotoPath", None)
actions = args.pop("OnSelected", [])
if actions:
raise ValueError("Обработчики OnSelected Naninovel пока не поддерживаются")
if not destination:
raise ValueError("Выбор Naninovel без GotoPath пока не поддерживается")
destination = target(destination, script_name)
all_targets.append((destination, location))
node = {"op": "nani_add_choice", "what": text(args.pop("ChoiceSummary", "")),
"tid": "nani-" + identity, "target": destination,
"handler": static(args.pop("HandlerId", ""))}
nodes = [node]
choice_count += 1
elif op == "Stop":
if not choice_count:
raise ValueError("Stop Naninovel требует внешнего интерфейса или механики игры")
nodes, choice_count = [{"op": "nani_menu"}], 0
elif op == "ClearChoiceHandler":
nodes = [{"op": "nani_clear_choice", "handler": static(args.pop("HandlerId", "*"))}]
elif op == "Wait":
mode = str(static(args.pop("WaitMode", "0"), "WaitMode"))
if mode.startswith("i"):
raise ValueError("Прерываемая пауза Naninovel пока не поддерживается")
nodes = [{"op": "pause", "d": {"k": "const", "v": float(mode)}}]
elif op == "WaitForInput":
nodes = [{"op": "pause", "d": None}]
elif op in {"HidePrinter", "ShowPrinter"}:
nodes = [{"op": "window", "mode": "hide" if op == "HidePrinter" else "show"}]
elif op in {"PlayBgm", "PlaySfx", "PlayVoice"}:
key = {"PlayBgm": "BgmPath", "PlaySfx": "SfxPath", "PlayVoice": "VoicePath"}[op]
path = static(args.pop(key), key)
nodes = [{"op": "play", "ch": {"PlayBgm": "music", "PlaySfx": "sound", "PlayVoice": "voice"}[op],
"files": [{"k": "const", "v": path}], "loop": args.pop("Loop", op == "PlayBgm")}]
elif op in {"StopBgm", "StopSfx"}:
nodes = [{"op": "stop", "ch": "music" if op == "StopBgm" else "sound"}]
elif op in {"ModifyBackground", "ModifyCharacter"}:
spec = static(args.pop("AppearanceAndTransition" if op == "ModifyBackground" else "IdAndAppearance"))
if not isinstance(spec, dict) or not spec.get("name"):
raise ValueError(f"Не поддерживается адрес изображения {op}")
actor = str(spec["name"])
appearance = spec.get("value")
if op == "ModifyBackground" and appearance:
raise ValueError("Специальный переход Naninovel пока не поддерживается")
image = actor if op == "ModifyBackground" else actor + "/" + str(appearance or "Default")
key = "nani-bg" if op == "ModifyBackground" else "nani-char-" + actor
name = key + " " + image
data.images[name] = {"file": image}
props = {"xalign": 0.5, "yalign": 1}
if "ScenePosition" in args:
position = static(args.pop("ScenePosition"), "ScenePosition")
if not isinstance(position, list) or len(position) != 2:
raise ValueError("Неподдерживаемая позиция Naninovel")
props = {"xalign": position[0] / 100, "yalign": 1 - position[1] / 100}
duration = float(static(args.pop("Duration", 0), "Duration"))
nodes = [{"op": "show", "as": key, "img": {"name": name.split(" ")},
"at": [{"props": props}], "zorder": {"k": "const", "v": 0 if op == "ModifyBackground" else 10},
"with": {"t": "dissolve", "d": duration} if duration else None}]
elif op == "HideAllCharacters":
duration = float(static(args.pop("Duration", 0), "Duration"))
# The player preserves backgrounds and other image layers.
nodes = [{"op": "nani_hide_characters", "duration": duration}]
elif op == "ExitToTitle":
nodes = [{"op": "nani_exit"}]
else:
raise ValueError(f"Не поддерживается команда Naninovel: {op}")
if args:
raise ValueError(f"Не поддерживаются параметры {op}: {', '.join(sorted(args))}")
if condition is not None:
if not nodes:
raise ValueError("Условие на структурной команде Naninovel")
nodes = [{"op": "if", "branches": [{"c": expression(condition), "body": nodes}]}]
current.extend(nodes)
except (ValueError, KeyError, TypeError) as exc:
issue(str(exc), location)
if stack:
issue("Не закрыт BeginIf Naninovel", script_name)
labels = [{"name": "nani:" + script_name, "body": body}, *extras]
for label in labels:
all_names.add(label["name"])
def names(nodes):
for node in nodes:
if node["op"] == "label":
all_names.add(node["name"])
for branch in node.get("branches", []):
names(branch["body"])
names(label["body"])
data.modules[script_name + ".nani"] = labels
for destination, location in all_targets:
if destination not in all_names:
issue(f"Не найдена цель Naninovel: {destination}", location)
entry = next(("nani:" + name for name in ("Opening", "Main", "Start") if name in package.scripts), None)
if entry is None:
issue("Не определена точка входа Naninovel (Opening/Main/Start)", "")
# Init may include external functions or engine UI setup. Do not discard it.
if "Init" in package.scripts:
data.init_code = []
issue("Инициализация Naninovel требует отдельного адаптера Init", "Init")
issues = package.issues + [Issue("naninovel_command", msg, locations[msg], count)
for msg, count in counts.items()]
if issues:
raise CompatibilityError("naninovel", issues)
resources = []
for tid, record in package.texts.items():
resources.append({"op": "say", "what": record["text"], "tid": tid,
"whoStr": record["origin"], "resource": record})
if resources:
data.modules["resources.nani"] = [{"name": "nani:resources", "body": resources}]
return data, entry, expressions
+93
View File
@@ -0,0 +1,93 @@
"""Parse Naninovel expressions into data, never Python/JavaScript source code."""
import re
TOKEN = re.compile(r'''\s*(?:(\d+(?:\.\d*)?(?:[eE][+-]?\d+)?|\.\d+)|([A-Za-z_]\w*)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(&&|\|\||==|!=|<>|<=|>=|<<|>>|[()+\-*/%<>=!~?:,&|^]))''')
PRECEDENCE = {"or": 2, "||": 2, "and": 3, "&&": 3, "|": 4, "^": 5,
"&": 6, "==": 7, "=": 7, "!=": 7, "<>": 7, "<": 8, ">": 8,
"<=": 8, ">=": 8, "<<": 9, ">>": 9, "+": 10, "-": 10,
"*": 11, "/": 11, "%": 11}
FUNCTIONS = {"Random", "random", "Rand", "GetRandValue", "Min", "Max", "Abs", "Round", "Pow"}
def parse(source: str, functions: set[str] | None = None) -> list:
source = source.strip().removesuffix(";").strip()
tokens, offset = [], 0
while offset < len(source):
match = TOKEN.match(source, offset)
if not match:
raise ValueError(f"Не поддерживается выражение Naninovel: {source[offset:offset + 60]}")
tokens.append((next(i for i, v in enumerate(match.groups()) if v is not None),
next(v for v in match.groups() if v is not None)))
offset = match.end()
position = 0
allowed = FUNCTIONS if functions is None else functions
def peek():
return tokens[position][1] if position < len(tokens) else ""
def take(expected=None):
nonlocal position
if position >= len(tokens) or (expected is not None and peek() != expected):
raise ValueError(f"Ожидалось {expected or 'выражение'}: {source}")
token = tokens[position]
position += 1
return token
def expression(minimum=1):
kind, token = take()
if token in {"-", "+", "!", "not", "~"}:
left = ["unary", token, expression(12)]
elif token == "(":
left = expression()
take(")")
elif kind == 0:
left = ["const", float(token) if any(c in token for c in ".eE") else int(token)]
elif kind == 2:
# Decode only conventional escapes; preserve all Unicode prose.
left = ["const", re.sub(r"\\([\\\"'nrt])", lambda m:
{"n": "\n", "r": "\r", "t": "\t"}.get(m[1], m[1]), token[1:-1])]
elif kind == 1:
if token.lower() in {"true", "false", "null"}:
left = ["const", {"true": True, "false": False, "null": None}[token.lower()]]
elif peek() == "(":
if token not in allowed:
raise ValueError(f"Не поддерживается функция Naninovel: {token}")
take("(")
args = []
if peek() != ")":
while True:
args.append(expression())
if peek() != ",":
break
take(",")
take(")")
left = ["call", token, args]
else:
left = ["name", token]
else:
raise ValueError(f"Неожиданный токен {token}: {source}")
while peek() in PRECEDENCE and PRECEDENCE[peek()] >= minimum:
op = take()[1]
left = ["bin", op, left, expression(PRECEDENCE[op] + 1)]
if minimum <= 1 and peek() == "?":
take("?")
yes = expression()
take(":")
left = ["if", left, yes, expression()]
return left
ast = expression()
if position != len(tokens):
raise ValueError(f"Лишние токены в выражении Naninovel: {source}")
return ast
def expr(source: str) -> dict:
return {"k": "nani", "ast": parse(source)}
def assignment(source: str) -> dict:
match = re.fullmatch(r"\s*([A-Za-z_]\w*)\s*=(?!=)(.*?)\s*;?\s*", source, re.S)
if not match:
raise ValueError(f"Не поддерживается присваивание Naninovel: {source}")
return {"op": "set", "t": {"k": "name", "id": match[1]}, "v": expr(match[2])}
+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
+132
View File
@@ -0,0 +1,132 @@
"""Naninovel importer: compiled Unity data -> validated PGVN package."""
from pathlib import Path, PurePosixPath
import json
import re
import tempfile
from app.blobs import put_bytes, put_file
from app.converter import assets, locales
from app.converter.compatibility import CompatibilityError, Issue
from app.converter.nani_compiler import compile_package
from app.converter.nani_package import read_package
def inspect_naninovel(root: Path, *, work: Path | None = None) -> dict:
if work is None:
with tempfile.TemporaryDirectory(prefix="pgames-naninovel-") as directory:
return inspect_naninovel(root, work=Path(directory))
package = read_package(root, work, media=True)
data, entry, _ = compile_package(package)
resolve_media(package, data)
return {"engine": "naninovel", "compatible": True, "scripts": len(package.scripts),
"textResources": len(package.texts), "entry": entry,
"commands": sum(len(line["commands"]) for script in package.scripts.values() for line in script["lines"]),
"warnings": dict(data.warnings)}
def resolve_media(package, data):
"""Resolve every resource before decoding or transcoding any of them."""
wanted = {image["file"] for image in data.images.values() if image.get("file")}
for modules in data.modules.values():
for label in modules:
for node in locales.walk(label["body"]):
if node["op"] == "play":
wanted.update(value["v"] for value in node["files"])
candidates = {}
for source, obj in package.resources:
try:
name = obj.peek_name()
except (ValueError, AttributeError):
continue
if name:
candidates.setdefault(name, []).append((source, obj))
resolved, issues = {}, []
for name in sorted(wanted):
matches = candidates.get(name, candidates.get(name.rsplit("/", 1)[-1], []))
if len(matches) != 1:
issues.append(Issue("ambiguous_media" if matches else "missing_media",
"Неоднозначный ресурс" if matches else "Не найден ресурс", name))
elif matches[0][1].type.name not in {"Texture2D", "AudioClip"}:
issues.append(Issue("unsupported_media", "Тип медиа пока не поддерживается", name))
else:
resolved[name] = matches[0]
if issues:
raise CompatibilityError("naninovel", issues)
return resolved
def export_media(package, data, root: Path, work: Path, progress):
resolved = resolve_media(package, data)
files, aliases, issues = {}, {}, []
out = work / "media"
out.mkdir(parents=True, exist_ok=True)
for index, (name, (source, obj)) in enumerate(resolved.items()):
progress(f"Медиа Naninovel {index + 1}/{len(resolved)}")
try:
asset = obj.read()
stem = str(index)
if obj.type.name == "Texture2D":
path = out / (stem + ".png")
asset.image.save(path)
elif obj.type.name == "AudioClip":
samples = asset.samples
if len(samples) != 1:
raise ValueError("Аудиоресурс не содержит единственный клип")
filename, raw = next(iter(samples.items()))
path = out / (stem + (Path(filename).suffix or ".wav"))
path.write_bytes(raw)
else:
raise ValueError(f"Тип медиа {obj.type.name} пока не поддерживается")
converted, ext = assets.convert(path, out)
digest, size = put_file(converted, ext, move=True)
logical = "media/" + stem + ext
files[logical] = {"hash": digest, "size": size}
aliases[name] = logical
except Exception as exc:
issues.append(Issue("media_error", f"Не удалось извлечь медиа: {exc}", name))
if issues:
raise CompatibilityError("naninovel", issues)
for image in data.images.values():
if image.get("file"):
image["file"] = aliases[image["file"]]
for modules in data.modules.values():
for label in modules:
for node in locales.walk(label["body"]):
if node["op"] == "play":
for value in node["files"]:
value["v"] = aliases[value["v"]]
return files, aliases
def convert_naninovel(root: Path, work: Path, progress):
from app.converter.pipeline import Result, anchors
progress("Чтение сценариев Naninovel и проверка совместимости")
package = read_package(root, work / "unity", media=True)
data, entry, expressions = compile_package(package)
# No media is decoded or written to the blob store before the script gate.
files, aliases = export_media(package, data, root, work, progress)
locales.assign_ids(data.modules)
scripts = []
def store(path, value):
raw = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
digest, size = put_bytes(raw, ".json")
files[path] = {"hash": digest, "size": size}
for path, labels in data.modules.items():
logical = "script/" + str(PurePosixPath(path).with_suffix(".json"))
store(logical, {"file": path, "labels": labels, "anchors": anchors(labels)})
scripts.append(logical)
source = locales.detect_language(data.modules) or "und"
game = {"format": "pgvn/1", "engine": "naninovel", "title": root.name,
"resolution": [1920, 1080], "entry": entry, "scripts": scripts,
"characters": data.characters, "images": data.images, "transforms": {},
"transitions": {}, "audio": {}, "files": aliases, "defaults": [], "init": [],
"ui": {}, "sourceLang": source, "locales": {}, "naniExpressions": expressions}
store("game.json", game)
total = sum(entry["size"] for entry in files.values())
report = {"engine": "naninovel", "compatible": True, "format": "pgvn/1",
"scripts": len(package.scripts), "textResources": len(package.texts),
"media": len(aliases), "warnings": []}
return Result({"format": "pgvn/1", "files": files}, report, root.name, None, total)
+26
View File
@@ -19,6 +19,7 @@ from app.converter.rpa import RpaError, extract_rpa
from app.converter.rpy_lexer import ParseError
from app.converter.rpy_parser import RpyParser, ScriptData
from app.converter.rpyc import decompile_missing
from app.converter.compatibility import CompatibilityError, Issue, audit_ir, detect, require_supported_scripts
log = logging.getLogger(__name__)
FORMAT = "pgvn/1"
@@ -46,12 +47,26 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
data = ScriptData()
if archive.is_dir():
detection = detect(archive)
if detection.engine == "naninovel":
from app.converter.naninovel import convert_naninovel
return convert_naninovel(detection.root, work, progress)
# Inspect scripts before copying/converting multi-gigabyte media collections.
from app.converter.preflight import inspect_directory
inspect_directory(detection.root)
progress("Копирование файлов игры")
game_dir = src_root / "game"
shutil.copytree(find_game_dir(archive), game_dir)
else:
from app.converter.preflight import inspect_archive
progress("Проверка движка и сценариев до распаковки медиа")
inspect_archive(archive, filename)
progress("Распаковка архива")
extract(archive, filename, src_root)
detection = detect(src_root)
if detection.engine == "naninovel":
from app.converter.naninovel import convert_naninovel
return convert_naninovel(detection.root, work, progress)
game_dir = find_game_dir(src_root)
rpas = sorted(game_dir.rglob("*.rpa"))
@@ -79,6 +94,8 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
data.warn("Слишком глубокая вложенность", rel)
if not any(lbl["name"] == "start" for labels in data.modules.values() for lbl in labels):
raise RuntimeError("В скриптах не найдена метка start — нечего запускать")
audit_ir(data)
require_supported_scripts(data)
files: dict[str, dict[str, Any]] = {}
aliases: dict[str, str] = {}
@@ -112,6 +129,10 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
aliases[rel.lower()] = logical
counts[assets.kind_of(p)] += 1
if failed:
raise CompatibilityError("renpy", [Issue("media_conversion", "Не удалось конвертировать обязательное медиа", name)
for name in failed])
progress("Сборка формата PGVN")
_auto_images(data, aliases)
missing: set[str] = set()
@@ -176,6 +197,11 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
total = sum(f["size"] for f in files.values())
report = {
"engine": "renpy",
"compatible": True,
"converterVersion": "pgvn-tools/2",
"unrpycRevision": (get_settings().unrpyc_path.parent / "REVISION").read_text().strip()
if (get_settings().unrpyc_path.parent / "REVISION").is_file() else "unavailable",
"format": FORMAT,
"durationSec": round(time.monotonic() - started, 1),
"scripts": len(rpy_files),
+77
View File
@@ -0,0 +1,77 @@
"""Read-only compatibility check, shared by uploads, CLI and the converter."""
from pathlib import Path
import tempfile
import zipfile
import shutil
import subprocess
from app.converter.archive import extract
from app.converter.compatibility import CompatibilityError, Issue, detect
def probe_archive(source: Path, filename: str) -> dict:
"""Cheap distribution check before queueing: list entries, never extract media."""
if zipfile.is_zipfile(source):
with zipfile.ZipFile(source) as archive:
entries = [entry.filename.replace("\\", "/") for entry in archive.infolist()]
else:
seven = shutil.which("7zz") or shutil.which("7z")
if not seven:
from app.converter.archive import ArchiveError
raise ArchiveError("Для проверки этого архива нужен 7-Zip")
process = subprocess.run([seven, "l", "-slt", "-ba", "-sccUTF-8", str(source)],
capture_output=True, encoding="utf-8", errors="replace", timeout=120)
if process.returncode:
from app.converter.archive import ArchiveError
raise ArchiveError("Не удалось прочитать архив: " + (process.stderr or process.stdout)[-500:])
entries = [line[7:].replace("\\", "/") for line in process.stdout.splitlines() if line.startswith("Path = ")]
from app.converter.compatibility import IGNORED
entries = [entry.lower() for entry in entries
if not any(part.lower() in IGNORED for part in Path(entry).parts)]
engines = set()
if any("naninovel" in entry.rsplit("/", 1)[-1] and entry.endswith(".dll") for entry in entries):
engines.add("naninovel")
elif any("_data/managed/" in entry or "_data/il2cpp_data/" in entry or entry.endswith("_data/data.unity3d") for entry in entries):
engines.add("unity")
if any(entry.rsplit("/", 1)[-1] == "data.win" for entry in entries):
engines.add("gamemaker")
if any(entry.endswith((".pck", "/project.godot")) or entry == "project.godot" for entry in entries):
engines.add("godot")
if any(entry.endswith((".rgss3a", ".rgss2a", ".rgssad", "/rpg_core.js", "/rmmz_core.js")) for entry in entries):
engines.add("rpgmaker")
if any(entry.endswith((".rpy", ".rpyc", ".rpa")) and "renpy/" not in entry for entry in entries):
engines.add("renpy")
if len(engines) != 1:
raise CompatibilityError("ambiguous" if engines else "unknown", [Issue(
"multiple_engines" if engines else "unknown_engine",
"В архиве несколько движков: " + ", ".join(sorted(engines)) if engines else
"Движок не распознан; нужен полный дистрибутив игры")])
engine = engines.pop()
if engine not in {"renpy", "naninovel"}:
raise CompatibilityError(engine, [Issue("unsupported_engine", f"Движок {engine} пока не поддерживается")])
return {"engine": engine, "scriptsChecked": False}
def inspect_directory(root: Path, *, work: Path | None = None) -> dict:
detection = detect(root)
if detection.engine == "renpy":
from app.converter.analyze import analyze
report = analyze(detection.root)
issues = [Issue(**issue) for issue in report.pop("issues", [])]
if not report["hasStart"]:
issues.append(Issue("missing_entry", "Не найдена точка входа start"))
if issues:
raise CompatibilityError("renpy", issues)
return {"engine": "renpy", "compatible": True, **report}
from app.converter.naninovel import inspect_naninovel
return inspect_naninovel(detection.root, work=work)
def inspect_archive(source: Path, filename: str) -> dict:
if source.is_dir():
return inspect_directory(source)
with tempfile.TemporaryDirectory(prefix="pgames-preflight-") as directory:
root = Path(directory)
probe = probe_archive(source, filename)
extract(source, filename, root, only_scripts=probe["engine"] == "renpy")
return inspect_directory(root, work=root / "_inspection")
+6 -2
View File
@@ -63,11 +63,14 @@ class ScriptData:
config: dict[str, Any] = field(default_factory=dict)
modules: dict[str, list[Node]] = field(default_factory=dict) # file -> labels
warnings: Counter = field(default_factory=Counter)
blockers: Counter = field(default_factory=Counter)
warning_examples: dict[str, str] = field(default_factory=dict)
stats: Counter = field(default_factory=Counter)
def warn(self, kind: str, where: str = "") -> None:
def warn(self, kind: str, where: str = "", *, blocking: bool = True) -> None:
self.warnings[kind] += 1
if blocking:
self.blockers[kind] += 1
if where and kind not in self.warning_examples:
self.warning_examples[kind] = where
@@ -234,7 +237,8 @@ class RpyParser:
return []
if word in SKIP_BLOCKS:
if word not in ("style", "translate"):
self.d.warn(f"Не поддерживается: {word}", self.where(line))
self.d.warn(f"Не поддерживается: {word}", self.where(line),
blocking=not (top_level and word == "screen"))
return []
say = self.say(line)
if say is not None:
+65
View File
@@ -0,0 +1,65 @@
"""Stream UnityFS nodes to a private workspace; do not load media bundles into RAM."""
from pathlib import Path
def unpack(source: Path, target: Path) -> list[Path]:
from UnityPy.streams import EndianBinaryReader
from UnityPy.helpers.CompressionHelper import DECOMPRESSION_MAP
target.mkdir(parents=True, exist_ok=True)
with source.open("rb") as stream:
reader = EndianBinaryReader(stream)
if reader.read_string_to_null() != "UnityFS":
raise ValueError(f"Неподдерживаемый Unity-контейнер: {source.name}")
version = reader.read_u_int()
reader.read_string_to_null()
reader.read_string_to_null()
size = reader.read_long()
packed, raw, flags = reader.read_u_int(), reader.read_u_int(), reader.read_u_int()
if version not in {6, 7, 8} or flags & ~0x3ff:
raise ValueError(f"Неподдерживаемый UnityFS: версия {version}, flags={flags}")
if version >= 7:
reader.align_stream(16)
start = reader.Position
if flags & 0x80:
reader.Position = size - packed
info = EndianBinaryReader(DECOMPRESSION_MAP[flags & 63](reader.read_bytes(packed), raw))
if flags & 0x80:
reader.Position = start
info.read_bytes(16)
count = info.read_int()
if not 0 <= count <= 1_000_000:
raise ValueError("Некорректное количество блоков UnityFS")
blocks = [(info.read_u_int(), info.read_u_int(), info.read_u_short()) for _ in range(count)]
count = info.read_int()
if not 0 <= count <= 100_000:
raise ValueError("Некорректное количество файлов UnityFS")
nodes = [(info.read_long(), info.read_long(), info.read_u_int(), info.read_string_to_null())
for _ in range(count)]
if flags & 0x200:
reader.align_stream(16)
handles, paths = [], []
total = sum(b[0] for b in blocks)
try:
for offset, length, _, name in nodes:
path = (target / name).resolve()
if (not path.is_relative_to(target.resolve()) or path in paths or
offset < 0 or length < 0 or offset + length > total):
raise ValueError(f"Некорректный файл UnityFS: {name}")
path.parent.mkdir(parents=True, exist_ok=True)
handles.append((offset, length, path.open("wb")))
paths.append(path)
position = 0
for raw_size, packed_size, block_flags in blocks:
chunk = DECOMPRESSION_MAP[block_flags & 63](reader.read_bytes(packed_size), raw_size)
if len(chunk) != raw_size:
raise ValueError("Повреждённый блок UnityFS")
for offset, length, handle in handles:
left, right = max(position, offset), min(position + raw_size, offset + length)
if left < right:
handle.write(chunk[left - position:right - position])
position += raw_size
finally:
for _, _, handle in handles:
handle.close()
return paths
+128
View File
@@ -0,0 +1,128 @@
"""Strict codec for Unity 2019 managed-reference scenario objects.
Schemas come from the game's own assemblies. The old version-1 registry uses
implicit integer IDs and a Terminus record, rather than the modern array format.
Use in an isolated asset-processing process; UnityPy's codec hooks are global.
"""
from contextlib import contextmanager
class ScriptCodec:
def __init__(self, game, version="2019.4.22f1"):
from UnityPy.helpers.TypeTreeGenerator import TypeTreeGenerator
self.generator = TypeTreeGenerator(version)
self.generator.load_local_game(str(game))
self.cache = {}
self.templates = {}
def node(self, assembly, fullname, reference=False):
from UnityPy.helpers import TypeTreeHelper as helper
from UnityPy.helpers.TypeTreeNode import TypeTreeNode
key = (assembly, fullname, reference)
if key in self.cache:
return self.cache[key]
source = self.generator.get_nodes_up(assembly, fullname)
# The generator calls List<string> "string"; the generic reader would
# mistake its count for a scalar string length. Normalize array nodes.
def clone(n):
values = n.to_dict()
values["m_Children"] = [clone(c) for c in n.m_Children]
return TypeTreeNode(**values)
node = clone(source)
for child in node.traverse():
if (child.m_Children and child.m_Children[0].m_Type == "Array"
and child.m_Type in helper.FUNCTION_READ_MAP
and child.m_Children[0].m_Children[1].m_Type != "char"):
child.m_Type = "vector"
if reference:
# Generator assumes MonoBehaviour even for plain serializable classes.
children = [c for c in node.m_Children if c.m_Name not in
{"m_GameObject", "m_Enabled", "m_Script", "m_Name", "references"}]
node = TypeTreeNode(0, node.m_Type, node.m_Name, -1, 0, m_Children=children)
self.cache[key] = node
return node
def ref_node(self, typ):
if not typ["class"]:
return None
if not typ["asm"] or not all(ord(c) >= 32 for c in typ["class"] + typ["ns"] + typ["asm"]):
raise ValueError("Invalid managed-reference type; refusing to guess its layout")
key = (typ["asm"].removesuffix(".dll"), typ["ns"], typ["class"])
if key in self.templates:
return self.templates[key]
return self.node(typ["asm"], ".".join(filter(None, [typ["ns"], typ["class"]])), True)
@contextmanager
def active(self):
from UnityPy.helpers import TypeTreeHelper as helper
old_read, old_write = helper.read_value, helper.write_value
old_boost, old_resolve = helper.read_typetree_boost, helper.get_ref_type_node
def read(node, reader, config):
if node.m_Type != "ManagedReferencesRegistry":
return old_read(node, reader, config)
version = reader.read_int()
if version != 1:
raise ValueError(f"Unsupported managed-reference registry: {version}")
type_node = node.m_Children[1].m_Children[0]
records = []
while len(records) < 1_000_000:
typ = old_read(type_node, reader, config)
if typ == {"class": "Terminus", "ns": "UnityEngine.DMAT", "asm": "FAKE_ASM"}:
return {"version": version, "records": records, "terminus": typ}
schema = self.ref_node(typ)
records.append({"type": typ, "data": read(schema, reader, config) if schema else {}})
raise ValueError("Unterminated managed-reference registry")
def write(value, node, writer, config):
if node.m_Type != "ManagedReferencesRegistry":
return old_write(value, node, writer, config)
if value["version"] != 1:
raise ValueError("Unsupported registry version")
writer.write_int(1)
type_node = node.m_Children[1].m_Children[0]
for ref in value["records"]:
old_write(ref["type"], type_node, writer, config)
schema = self.ref_node(ref["type"])
if schema:
write(ref["data"], schema, writer, config)
old_write(value["terminus"], type_node, writer, config)
helper.read_typetree_boost = None
helper.get_ref_type_node = lambda ref, _: self.ref_node(ref["type"])
helper.read_value, helper.write_value = read, write
try:
yield self
finally:
helper.read_value, helper.write_value = old_read, old_write
helper.read_typetree_boost, helper.get_ref_type_node = old_boost, old_resolve
def read(self, obj):
from UnityPy.streams import EndianBinaryWriter
from UnityPy.helpers import TypeTreeHelper as helper
# Bundles can retain exact schemas which differ from generated metadata
# (notably editor-compiled Init). Prefer those schemas when available.
self.templates = {(ref.m_AssemblyName.removesuffix(".dll"), ref.m_NameSpace, ref.m_ClassName): ref.node
for ref in obj.assets_file.ref_types if ref.node is not None}
node = obj.serialized_type.node
if node is None:
node = self.node("Elringus.Naninovel.Runtime", "Naninovel.Script")
tree = obj.read_typetree(nodes=node)
writer = EndianBinaryWriter(endian=obj.reader.endian)
helper.write_typetree(tree, node, writer, obj.assets_file)
if writer.bytes != obj.get_raw_data():
raise ValueError(f"Script {obj.path_id}: binary round-trip mismatch")
return tree
def script_objects(environment):
for obj in environment.objects:
if obj.type.name != "MonoBehaviour":
continue
try:
head = obj.parse_monobehaviour_head()
script = head.m_Script.read()
except (ValueError, AttributeError, FileNotFoundError):
continue
if script.m_Namespace == "Naninovel" and script.m_ClassName == "Script":
yield obj