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
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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])}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user