From 12ec82c7e668413f5b805dc6e3596360dc2d6e75 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:35:23 +0300 Subject: [PATCH] refactor: move game-specific import logic into profiles; unblock Tomie import - Add app/games: GameProfile base, registry, ReaderHooks and renpy/naninovel/ tomie profiles. pipeline, preflight, detect and mods dispatch via the registry - Move tomie_* converter modules to app/games/tomie and split out reader hooks, import, mod validation and known defects - Tolerate the 31 authoring defects of the pinned Tomie build as report warnings (exact list, per-script counts); any new defect still blocks import - Frontend: runtime registry (runtimes.ts) with lazy Tomie launcher - Add profile/registry tests and docs/GAME_PROFILES.md; update IMPORT.md Full media export of the original Tomie build is not yet verified. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 3 + backend/app/converter/compatibility.py | 3 +- backend/app/converter/nani_package.py | 78 +++++++----------- backend/app/converter/naninovel.py | 45 ----------- backend/app/converter/pipeline.py | 21 +++-- backend/app/converter/preflight.py | 21 +++-- backend/app/games/__init__.py | 8 ++ backend/app/games/base.py | 50 ++++++++++++ backend/app/games/hooks.py | 22 +++++ backend/app/games/naninovel.py | 19 +++++ backend/app/games/registry.py | 45 +++++++++++ backend/app/games/renpy.py | 23 ++++++ backend/app/games/tomie/__init__.py | 30 +++++++ .../tomie_assets.py => games/tomie/assets.py} | 0 backend/app/games/tomie/convert.py | 80 +++++++++++++++++++ backend/app/games/tomie/defects.py | 63 +++++++++++++++ .../tomie_format.py => games/tomie/format.py} | 9 ++- backend/app/games/tomie/mod.py | 28 +++++++ .../tomie/profile.py} | 0 backend/app/games/tomie/reader.py | 66 +++++++++++++++ .../tomie/validation.py} | 2 +- backend/app/mods.py | 31 ++----- backend/tests/test_game_profiles.py | 54 +++++++++++++ backend/tests/test_tomie_format.py | 12 +-- backend/tests/test_tomie_profile.py | 8 +- docs/GAME_PROFILES.md | 59 ++++++++++++++ docs/IMPORT.md | 19 ++--- frontend/src/player/index.ts | 37 ++------- frontend/src/player/runtimes.ts | 23 ++++++ frontend/src/player/tomie-launch.ts | 37 +++++++++ 30 files changed, 700 insertions(+), 196 deletions(-) create mode 100644 backend/app/games/__init__.py create mode 100644 backend/app/games/base.py create mode 100644 backend/app/games/hooks.py create mode 100644 backend/app/games/naninovel.py create mode 100644 backend/app/games/registry.py create mode 100644 backend/app/games/renpy.py create mode 100644 backend/app/games/tomie/__init__.py rename backend/app/{converter/tomie_assets.py => games/tomie/assets.py} (100%) create mode 100644 backend/app/games/tomie/convert.py create mode 100644 backend/app/games/tomie/defects.py rename backend/app/{converter/tomie_format.py => games/tomie/format.py} (95%) create mode 100644 backend/app/games/tomie/mod.py rename backend/app/{converter/tomie_profile.py => games/tomie/profile.py} (100%) create mode 100644 backend/app/games/tomie/reader.py rename backend/app/{converter/tomie_validation.py => games/tomie/validation.py} (99%) create mode 100644 backend/tests/test_game_profiles.py create mode 100644 docs/GAME_PROFILES.md create mode 100644 frontend/src/player/runtimes.ts create mode 100644 frontend/src/player/tomie-launch.ts diff --git a/README.md b/README.md index f4ef705..076ad02 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ Пакетный импорт коллекции, строгая проверка совместимости и текущие ограничения Naninovel описаны в [docs/IMPORT.md](docs/IMPORT.md). +Профили игр (отдельный конвейер для игр с особенностями) — +в [docs/GAME_PROFILES.md](docs/GAME_PROFILES.md). + Редактор модов, слияние, перенос на новую версию, ревизии и сборщик мусора — в [docs/MODDING.md](docs/MODDING.md). diff --git a/backend/app/converter/compatibility.py b/backend/app/converter/compatibility.py index 797986f..1c0ad67 100644 --- a/backend/app/converter/compatibility.py +++ b/backend/app/converter/compatibility.py @@ -77,7 +77,8 @@ def detect(root: Path) -> Detection: "В одном импорте обнаружено несколько игр; загрузите каждую отдельно", ", ".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"}: + from app.games.registry import all_profiles + if result.engine not in {profile.engine for profile in all_profiles()}: raise CompatibilityError(result.engine, [Issue("unsupported_engine", f"Движок {result.engine} распознан, но его исполнение в PGames не поддерживается")]) return result diff --git a/backend/app/converter/nani_package.py b/backend/app/converter/nani_package.py index 3a25bed..4833819 100644 --- a/backend/app/converter/nani_package.py +++ b/backend/app/converter/nani_package.py @@ -1,6 +1,5 @@ """Recover Naninovel data from Unity builds without executing their managed code.""" import hashlib -import json from dataclasses import dataclass, field from pathlib import Path @@ -8,6 +7,14 @@ from app.converter.compatibility import CompatibilityError, Issue from app.converter.unity_bundle import unpack +@dataclass +class ReaderContext: + root: Path + source: Path + media: bool + codec: object # callable: Unity version -> ScriptCodec + + @dataclass class Package: scripts: dict = field(default_factory=dict) @@ -121,24 +128,24 @@ def text_records(name: str, text: str, origin: str) -> dict: return records -def read_package(root: Path, work: Path, *, media: bool = False) -> Package: +def read_package(root: Path, work: Path, *, media: bool = False, hooks=None) -> Package: + """Read a Naninovel Unity build. ``hooks`` (``app.games.hooks.ReaderHooks``) add game-specific data.""" + from app.games.hooks import ReaderHooks + package = Package() try: - return _read_package(root, work, package, media=media) + return _read_package(root, work, package, media=media, hooks=hooks or ReaderHooks()) except BaseException: package.close() raise -def _read_package(root: Path, work: Path, package: Package, *, media: bool) -> Package: +def _read_package(root: Path, work: Path, package: Package, *, media: bool, hooks) -> Package: import UnityPy - from app.converter.tomie_profile import extract_tables, identify from app.converter.unity_scripts import ScriptCodec, script_objects - package.profile = identify(root) - if package.profile: - package.function_data = extract_tables(root) + hooks.begin(package, root) data_roots = [p for p in root.iterdir() if p.is_dir() and p.name.lower().endswith("_data")] if len(data_roots) != 1: @@ -148,6 +155,13 @@ def _read_package(root: Path, work: Path, package: Package, *, media: bool) -> P sources = sorted(data.glob("*.assets")) + sorted(data.glob("*.unity3d")) sources += sorted((data / "StreamingAssets").rglob("*.bundle")) codec = None + + def get_codec(version): + nonlocal codec + if codec is None: + codec = ScriptCodec(root, version) + return codec + for number, source in enumerate(sources): try: with source.open("rb") as handle: @@ -161,59 +175,21 @@ def _read_package(root: Path, work: Path, package: Package, *, media: bool) -> P 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)) + codec = get_codec(str(obj.assets_file.unity_version)) with codec.active(): decoded = decode_script(codec.read(obj)) name = decoded["name"] previous = package.scripts.get(name) - if previous is not None and previous != decoded and name == "Init" and package.profile: - # This pinned Tomie build contains an editor/addressable - # Init alongside the main corpus. The recovered port uses - # the main ResourceManager copy; it is read first here. + if previous is not None and previous != decoded and hooks.accept_duplicate_script(name): continue if previous is not None and previous != decoded: package.issues.append(Issue("conflicting_script", "Различающиеся копии сценария", name)) else: package.scripts[name] = decoded for obj in environment.objects: - if obj.type.name == "ResourceManager" and package.profile and media: - from UnityPy.classes import PPtr - actor_keys = set() - for key, pointer in obj.read_typetree()["m_Container"]: - if not key.lower().startswith("characters/"): - continue - name = key.lower().removeprefix("characters/") - actor_keys.add(name) - actor = PPtr(assetsfile=obj.assets_file, **pointer).deref() - # Unity exposes both the complete texture and its - # Sprite under the same Resources key. Naninovel's - # texture actor uses the complete texture, as does - # the recovered Godot port. - if actor.type.name == "Sprite": - continue - if actor.type.name != "Texture2D": - package.issues.append(Issue("unsupported_actor", "Не поддерживается тип персонажа Tomie", key)) - continue - identity = f"{source.relative_to(root).as_posix()}:{actor.assets_file.name}:{actor.path_id}" - previous = package.actor_resources.get(name) - if previous and previous != identity: - package.issues.append(Issue("conflicting_actor", "Различающиеся изображения персонажа Tomie", key)) - package.actor_resources[name] = identity - package.resources.append((source, actor)) - for name in actor_keys - package.actor_resources.keys(): - package.issues.append(Issue("missing_actor_texture", "Не найдена полная текстура персонажа Tomie", name)) - elif obj.type.name == "MonoBehaviour" and package.profile and obj.peek_name() == "CustomVariablesConfiguration": - if codec is None: - codec = ScriptCodec(root, str(obj.assets_file.unity_version)) - node = obj.serialized_type.node or codec.node("Elringus.Naninovel.Runtime", "Naninovel.CustomVariablesConfiguration") - config = obj.read_typetree(nodes=node) - defaults = {entry["name"]: json.loads(entry["value"]) for entry in config["PredefinedVariables"]} - if package.defaults and package.defaults != defaults: - package.issues.append(Issue("conflicting_defaults", "Различающиеся исходные переменные Naninovel")) - else: - package.defaults = defaults - elif obj.type.name == "TextAsset": + if hooks.scan_object(package, obj, ReaderContext(root, source, media, get_codec)): + continue + if obj.type.name == "TextAsset": asset = obj.read() if asset.m_Name in {"DefaultUI", "Tips", "CharacterNames", "Inventory"} | stream_names: origin = f"{source.relative_to(root).as_posix()}/{obj.path_id}/{asset.m_Name}" diff --git a/backend/app/converter/naninovel.py b/backend/app/converter/naninovel.py index f63fdf0..b0d61f9 100644 --- a/backend/app/converter/naninovel.py +++ b/backend/app/converter/naninovel.py @@ -16,13 +16,6 @@ def inspect_naninovel(root: Path, *, work: Path | None = None) -> dict: return inspect_naninovel(root, work=Path(directory)) package = read_package(root, work, media=True) try: - if package.profile: - from app.converter.tomie_format import prepare - native = prepare(package) - return {"engine": "naninovel", "profile": package.profile, "compatible": True, - "scripts": len(native["scripts"]), "textResources": len(package.texts), - "commands": sum(len(script["commands"]) for script in native["scripts"].values()), - "warnings": {}} data, entry, _ = compile_package(package) resolve_media(package, data) return {"engine": "naninovel", "compatible": True, "scripts": len(package.scripts), @@ -119,9 +112,6 @@ def convert_naninovel(root: Path, work: Path, progress): def _convert_package(root: Path, work: Path, progress, package): from app.converter.pipeline import Result, anchors - if package.profile: - return _convert_tomie(root, work, progress, package) - 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) @@ -153,38 +143,3 @@ def _convert_package(root: Path, work: Path, progress, package): "scripts": len(package.scripts), "textResources": len(package.texts), "media": len(aliases), "warnings": []} return Result({"format": "pgvn/1", "files": files}, report, root.name, None, total) - - -def _convert_tomie(root, work, progress, package): - from app.converter.pipeline import Result, anchors - from app.converter.tomie_assets import export_media as export_tomie_media - from app.converter.tomie_format import RUNTIME, modules, prepare - - native = prepare(package) # all script checks precede media decoding/blob writes - scripts = modules(native, package.texts) - files, media, counts = export_tomie_media(package, root, work, progress) - - 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} - - resources = {} - for tid, record in package.texts.items(): - resources.setdefault(record["collection"], {}).setdefault(record["key"], []).append({"tid": tid, **record}) - for path, module in scripts.items(): - module["anchors"] = anchors(module["labels"]) - store(path, module) - game = {"format": "pgvn/1", "engine": "naninovel", "runtime": RUNTIME, "title": root.name, - "resolution": [1920, 1080], "entry": "nani:" + native["start"]["script"], "scripts": list(scripts), - "characters": {}, "images": {}, "transforms": {}, "transitions": {}, "audio": {}, "files": {}, - "defaults": [], "init": [], "ui": {}, "sourceLang": locales.detect_language( - {path: module["labels"] for path, module in scripts.items()}) or "und", "locales": {}, - "naniResources": resources, - "tomie": {**{key: value for key, value in native.items() if key != "scripts"}, "media": media}} - store("game.json", game) - report = {"engine": "naninovel", "profile": package.profile, "compatible": True, "format": "pgvn/1", - "runtime": RUNTIME, "scripts": len(package.scripts), "textResources": len(package.texts), - "media": counts, "warnings": []} - return Result({"format": "pgvn/1", "files": files}, report, root.name, None, - sum(entry["size"] for entry in files.values())) diff --git a/backend/app/converter/pipeline.py b/backend/app/converter/pipeline.py index 9f72617..d23270d 100644 --- a/backend/app/converter/pipeline.py +++ b/backend/app/converter/pipeline.py @@ -43,6 +43,17 @@ class Result: size: int +def _profile_converter(detection): + """The game profile that converts this game itself, or None for the built-in Ren'Py path.""" + from app.games.registry import select + + profile = select(detection.engine, detection.root) + if profile is None: + raise CompatibilityError(detection.engine, [Issue( + "unsupported_engine", f"Для движка {detection.engine} нет профиля импорта")]) + return None if profile.converts_in_pipeline else profile + + def convert_archive(archive: Path, filename: str, work: Path, progress: Progress) -> Result: """Convert an uploaded archive, or an already unpacked game directory (left untouched).""" started = time.monotonic() @@ -55,9 +66,8 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress 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) + if (profile := _profile_converter(detection)): + return profile.convert(detection.root, work, progress) # Inspect scripts before copying/converting multi-gigabyte media collections. from app.converter.preflight import inspect_directory inspect_directory(detection.root) @@ -71,9 +81,8 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress 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) + if (profile := _profile_converter(detection)): + return profile.convert(detection.root, work, progress) game_dir = find_game_dir(src_root) rpas = sorted(game_dir.rglob("*.rpa")) diff --git a/backend/app/converter/preflight.py b/backend/app/converter/preflight.py index 0da3a86..31aafd6 100644 --- a/backend/app/converter/preflight.py +++ b/backend/app/converter/preflight.py @@ -51,24 +51,21 @@ def probe_archive(source: Path, filename: str) -> dict: "В архиве несколько движков: " + ", ".join(sorted(engines)) if engines else "Движок не распознан; нужен полный дистрибутив игры")]) engine = engines.pop() - if engine not in {"renpy", "naninovel"}: + from app.games.registry import all_profiles + if engine not in {profile.engine for profile in all_profiles()}: raise CompatibilityError(engine, [Issue("unsupported_engine", f"Движок {engine} пока не поддерживается")]) return {"engine": engine, "scriptsChecked": False} def inspect_directory(root: Path, *, work: Path | None = None) -> dict: + from app.games.registry import select + 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) + profile = select(detection.engine, detection.root) + if profile is None: + raise CompatibilityError(detection.engine, [Issue( + "unsupported_engine", f"Для движка {detection.engine} нет профиля импорта")]) + return profile.inspect(detection.root, work) def inspect_archive(source: Path, filename: str) -> dict: diff --git a/backend/app/games/__init__.py b/backend/app/games/__init__.py new file mode 100644 index 0000000..8ddf2d9 --- /dev/null +++ b/backend/app/games/__init__.py @@ -0,0 +1,8 @@ +"""Game profiles. Add a module here and register it to support a new engine or game build.""" +from app.games.naninovel import NaninovelProfile +from app.games.registry import register +from app.games.renpy import RenPyProfile +from app.games.tomie import TomieProfile + +for _profile in (RenPyProfile(), NaninovelProfile(), TomieProfile()): + register(_profile) diff --git a/backend/app/games/base.py b/backend/app/games/base.py new file mode 100644 index 0000000..43783ca --- /dev/null +++ b/backend/app/games/base.py @@ -0,0 +1,50 @@ +"""Game profiles: everything specific to one engine or one concrete game build. + +The shared pipeline (upload, queue, storage, mods, player shell) only talks to +this interface. A game that needs its own importer, validator or runtime adds a +profile in ``app/games`` and registers it; nothing else has to change. +""" +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from app.converter.pipeline import Result + +Progress = Callable[[str], None] + + +class GameProfile: + """Base class. Subclasses override what differs from a plain engine import.""" + + #: Stable identifier stored in reports, e.g. ``renpy`` or ``tomie/expansion-1.3801``. + id: str = "" + #: Engine family from ``compatibility.detect`` this profile applies to. + engine: str = "" + #: ``game.json`` ``runtime`` value for games played by a dedicated player (None = shared player). + runtime: str | None = None + #: True for profiles pinned to one concrete build; they win over the engine's generic profile. + specific: bool = False + #: True when ``pipeline.convert_archive`` itself performs the conversion (Ren'Py). + converts_in_pipeline: bool = False + + def claims(self, root: Path) -> bool: + """Whether this profile recognises the game at ``root`` (cheap, no media decoding).""" + return True + + def inspect(self, root: Path, work: Path | None = None) -> dict[str, Any]: + """Read-only compatibility check; raise ``CompatibilityError`` to refuse the game.""" + raise NotImplementedError + + def convert(self, root: Path, work: Path, progress: Progress) -> Result: + """Full import into a PGVN package.""" + raise NotImplementedError + + def validate_mod(self, game: dict[str, Any], scripts: dict[str, Any], manifest: dict[str, Any]) -> None: + """Extra checks for edited mods of games using this profile; raise ``ValueError``.""" + + def unity_hooks(self) -> Any | None: + """Hooks for the shared Unity/Naninovel reader (see ``app.games.hooks``).""" + return None diff --git a/backend/app/games/hooks.py b/backend/app/games/hooks.py new file mode 100644 index 0000000..b677f7d --- /dev/null +++ b/backend/app/games/hooks.py @@ -0,0 +1,22 @@ +"""Extension points of the shared Unity/Naninovel reader for game-specific profiles.""" +from __future__ import annotations + +from typing import Any + + +class ReaderHooks: + """Default hooks: a plain Naninovel game. Profiles subclass and override.""" + + def begin(self, package: Any, root: Any) -> None: + """Called once before the Unity data is scanned.""" + + def accept_duplicate_script(self, name: str) -> bool: + """Return True to keep the first copy of a script that appears twice with different content.""" + return False + + def scan_object(self, package: Any, obj: Any, ctx: Any) -> bool: + """Offer a Unity object to the profile. Return True when it was handled. + + ``ctx`` has ``root``, ``source``, ``media`` and ``codec()`` (lazily created script codec). + """ + return False diff --git a/backend/app/games/naninovel.py b/backend/app/games/naninovel.py new file mode 100644 index 0000000..e2e5602 --- /dev/null +++ b/backend/app/games/naninovel.py @@ -0,0 +1,19 @@ +"""Generic Naninovel games: compiled scripts become ordinary PGVN modules.""" +from pathlib import Path + +from app.games.base import GameProfile, Progress + + +class NaninovelProfile(GameProfile): + id = "naninovel" + engine = "naninovel" + + def inspect(self, root: Path, work: Path | None = None) -> dict: + from app.converter.naninovel import inspect_naninovel + + return inspect_naninovel(root, work=work) + + def convert(self, root: Path, work: Path, progress: Progress): + from app.converter.naninovel import convert_naninovel + + return convert_naninovel(root, work, progress) diff --git a/backend/app/games/registry.py b/backend/app/games/registry.py new file mode 100644 index 0000000..073ca09 --- /dev/null +++ b/backend/app/games/registry.py @@ -0,0 +1,45 @@ +"""Profile lookup. Specific (build-pinned) profiles are tried before engine-wide ones.""" +from __future__ import annotations + +from pathlib import Path + +from app.games.base import GameProfile + +_PROFILES: list[GameProfile] = [] + + +def register(profile: GameProfile) -> GameProfile: + if any(p.id == profile.id for p in _PROFILES): + raise ValueError(f"Профиль уже зарегистрирован: {profile.id}") + _PROFILES.append(profile) + return profile + + +def all_profiles() -> list[GameProfile]: + _load() + return list(_PROFILES) + + +def select(engine: str, root: Path) -> GameProfile | None: + """The profile that imports the game at ``root`` for a detected ``engine``.""" + _load() + candidates = [p for p in _PROFILES if p.engine == engine] + for profile in sorted(candidates, key=lambda p: not p.specific): + if profile.claims(root): + return profile + return None + + +def for_runtime(runtime: str | None) -> GameProfile | None: + _load() + return next((p for p in _PROFILES if runtime and p.runtime == runtime), None) + + +def for_id(profile_id: str | None) -> GameProfile | None: + _load() + return next((p for p in _PROFILES if p.id == profile_id), None) + + +def _load() -> None: + # Importing the package registers the built-in profiles. + import app.games # noqa: F401 diff --git a/backend/app/games/renpy.py b/backend/app/games/renpy.py new file mode 100644 index 0000000..4d51e45 --- /dev/null +++ b/backend/app/games/renpy.py @@ -0,0 +1,23 @@ +"""Ren'Py: the conversion runs in ``app.converter.pipeline`` because it also handles archives.""" +from pathlib import Path + +from app.converter.compatibility import CompatibilityError, Issue +from app.games.base import GameProfile + + +class RenPyProfile(GameProfile): + id = "renpy" + engine = "renpy" + #: Converted by ``pipeline.convert_archive`` (archive unpacking, RPA, media in one pass). + converts_in_pipeline = True + + def inspect(self, root: Path, work: Path | None = None) -> dict: + from app.converter.analyze import analyze + + report = analyze(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} diff --git a/backend/app/games/tomie/__init__.py b/backend/app/games/tomie/__init__.py new file mode 100644 index 0000000..f535fdd --- /dev/null +++ b/backend/app/games/tomie/__init__.py @@ -0,0 +1,30 @@ +"""Tomie Wants to Get Married, Expansion 1.3801 (Unity + Naninovel), pinned by assembly hash.""" +from pathlib import Path + +from app.games.base import GameProfile, Progress +from app.games.tomie.profile import PROFILE, identify + + +class TomieProfile(GameProfile): + id = PROFILE + engine = "naninovel" + runtime = "tomie/1" + specific = True + + def claims(self, root: Path) -> bool: + return identify(root) == PROFILE + + def inspect(self, root: Path, work: Path | None = None) -> dict: + from app.games.tomie.convert import inspect_tomie + + return inspect_tomie(root, work) + + def convert(self, root: Path, work: Path, progress: Progress): + from app.games.tomie.convert import convert_tomie + + return convert_tomie(root, work, progress) + + def validate_mod(self, game, scripts, manifest) -> None: + from app.games.tomie.mod import validate_mod + + validate_mod(game, scripts, manifest) diff --git a/backend/app/converter/tomie_assets.py b/backend/app/games/tomie/assets.py similarity index 100% rename from backend/app/converter/tomie_assets.py rename to backend/app/games/tomie/assets.py diff --git a/backend/app/games/tomie/convert.py b/backend/app/games/tomie/convert.py new file mode 100644 index 0000000..ba002ee --- /dev/null +++ b/backend/app/games/tomie/convert.py @@ -0,0 +1,80 @@ +"""Tomie import: validated native command lists in editable PGVN modules.""" +import json +import tempfile +from pathlib import Path + +from app.blobs import put_bytes +from app.converter import locales +from app.converter.nani_package import read_package +from app.games.tomie.assets import export_media +from app.games.tomie.format import RUNTIME, modules, prepare +from app.games.tomie.reader import TomieHooks + + +def inspect_tomie(root: Path, work: Path | None = None) -> dict: + if work is None: + with tempfile.TemporaryDirectory(prefix="pgames-tomie-") as directory: + return inspect_tomie(root, Path(directory)) + package = read_package(root, work, media=True, hooks=TomieHooks()) + try: + native = prepare(package) + return {"engine": "naninovel", "profile": package.profile, "compatible": True, + "scripts": len(native["scripts"]), "textResources": len(package.texts), + "commands": sum(len(script["commands"]) for script in native["scripts"].values()), + "warnings": _warnings(native)} + finally: + package.close() + + +def _warnings(native: dict) -> dict: + """Known authoring defects of the original build, grouped by message.""" + result: dict[str, int] = {} + for defect in native.get("known_defects", []): + text = f"{defect['message']} ({defect['location']})" + result[text] = result.get(text, 0) + 1 + return result + + +def convert_tomie(root: Path, work: Path, progress): + progress("Чтение сценариев Tomie и проверка совместимости") + package = read_package(root, work / "unity", media=True, hooks=TomieHooks()) + try: + return convert_package(root, work, progress, package) + finally: + package.close() + + +def convert_package(root: Path, work: Path, progress, package): + from app.converter.pipeline import Result, anchors + + native = prepare(package) # all script checks precede media decoding/blob writes + known = _warnings(native) + native.pop("known_defects", None) + scripts = modules(native, package.texts) + files, media, counts = export_media(package, root, work, progress) + + 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} + + resources = {} + for tid, record in package.texts.items(): + resources.setdefault(record["collection"], {}).setdefault(record["key"], []).append({"tid": tid, **record}) + for path, module in scripts.items(): + module["anchors"] = anchors(module["labels"]) + store(path, module) + game = {"format": "pgvn/1", "engine": "naninovel", "runtime": RUNTIME, "title": root.name, + "resolution": [1920, 1080], "entry": "nani:" + native["start"]["script"], "scripts": list(scripts), + "characters": {}, "images": {}, "transforms": {}, "transitions": {}, "audio": {}, "files": {}, + "defaults": [], "init": [], "ui": {}, "sourceLang": locales.detect_language( + {path: module["labels"] for path, module in scripts.items()}) or "und", "locales": {}, + "naniResources": resources, + "tomie": {**{key: value for key, value in native.items() if key != "scripts"}, "media": media}} + store("game.json", game) + report = {"engine": "naninovel", "profile": package.profile, "compatible": True, "format": "pgvn/1", + "runtime": RUNTIME, "scripts": len(package.scripts), "textResources": len(package.texts), + "media": counts, "warnings": [{"message": text, "count": count} for text, count in known.items()], + "knownDefects": sum(known.values())} + return Result({"format": "pgvn/1", "files": files}, report, root.name, None, + sum(entry["size"] for entry in files.values())) diff --git a/backend/app/games/tomie/defects.py b/backend/app/games/tomie/defects.py new file mode 100644 index 0000000..e8cca3c --- /dev/null +++ b/backend/app/games/tomie/defects.py @@ -0,0 +1,63 @@ +"""Authoring defects of the pinned Tomie build that the importer tolerates. + +These are mistakes in the original scripts (jumps to labels that do not exist, +branches without EndIf, assignments without a valid operator). The original +engine fails at the same place; so does the PGames player, which throws an +explicit error when execution reaches one (see nani-runtime.ts). They are listed +as warnings in the import report instead of blocking the whole game. + +The list is exact: a key is (issue code, message, script) and the number is the +maximum count. Anything new or more frequent - for example from an edited mod - +is still refused. Do not add entries to make an import pass; add them only after +confirming that the defect exists in the original build. +""" +from collections import Counter + +from app.converter.compatibility import Issue + +KNOWN_DEFECTS: dict[tuple[str, str, str], int] = { + ('invalid_branch', 'BeginIf не имеет завершающего EndIf', 'EP_Slum'): 1, + ('invalid_branch', 'BeginIf не имеет завершающего EndIf', 'QuestHub'): 1, + ('invalid_branch', 'BeginIf не имеет завершающего EndIf', 'System'): 1, + ('invalid_branch', 'Else не имеет завершающего EndIf', 'EP_Slum'): 1, + ('invalid_branch', 'Else не имеет завершающего EndIf', 'QuestHub'): 1, + ('invalid_branch', 'ElseIf не имеет завершающего EndIf', 'EP_Slum'): 1, + ('missing_target', 'Не найден переход AB_Studio.SkillUp', 'AB_Studio'): 1, + ('missing_target', 'Не найден переход B_Beggar.Start', 'DicePlaySub'): 1, + ('missing_target', 'Не найден переход B_Exercise.Start', 'DicePlaySub'): 1, + ('missing_target', 'Не найден переход B_Harassment.endStreet', 'B_Harassment'): 1, + ('missing_target', 'Не найден переход B_Job.Start', 'DicePlaySub'): 1, + ('missing_target', 'Не найден переход B_Model.Start', 'DicePlaySub'): 1, + ('missing_target', 'Не найден переход B_Police.Start', 'DicePlaySub'): 1, + ('missing_target', 'Не найден переход B_R_Roaming_Normal.DemandSexByGroup', 'B_R_Roaming_Normal'): 1, + ('missing_target', 'Не найден переход B_Rest.Start', 'DicePlaySub'): 1, + ('missing_target', 'Не найден переход B_Study.Start', 'DicePlaySub'): 1, + ('missing_target', 'Не найден переход Common.Failed', 'B_Approached'): 1, + ('missing_target', 'Не найден переход Common.StatP', 'Common_Date'): 1, + ('missing_target', 'Не найден переход E_VacationWithMari.DayPassVacation', 'DicePlaySub'): 1, + ('missing_target', 'Не найден переход E_VacationWithMari.DayPassVacation', 'E_VacationWithMari'): 2, + ('missing_target', 'Не найден переход Laptop.Cam_END', 'Laptop'): 1, + ('missing_target', 'Не найден переход Laptop.SexPain_Defloration', 'Laptop'): 1, + ('missing_target', 'Не найден переход LoanShark.End', 'LoanShark'): 1, + ('missing_target', 'Не найден переход MessagePlay.End', 'MessagePlay'): 1, + ('missing_target', 'Не найден переход ReturnToHome.return', 'ReturnToHome'): 1, + ('missing_target', 'Не найден переход XEnding.Koman', 'QuestHub'): 1, + ('missing_target', 'Не найден переход XEnding.Suicide_Continue', 'XEnding'): 1, + ('unsupported_choice', 'Не поддерживается присваивание Naninovel: iCorruption+1.5', 'AppInswitter'): 1, + ('unsupported_choice', 'Не поддерживается присваивание Naninovel: iWillpower-5', 'EP_Slum'): 1, + ('unsupported_choice', 'Не поддерживается присваивание Naninovel: temp==2', 'B_HighStreet'): 1, +} + + +def split_known(issues: list[Issue]) -> tuple[list[Issue], list[Issue]]: + """Return (blocking, known) issues. Each key is waived up to its recorded count.""" + left = Counter(KNOWN_DEFECTS) + blocking, known = [], [] + for issue in issues: + key = (issue.code, issue.message, issue.location.split(":")[0]) + if left[key] > 0: + left[key] -= 1 + known.append(issue) + else: + blocking.append(issue) + return blocking, known diff --git a/backend/app/converter/tomie_format.py b/backend/app/games/tomie/format.py similarity index 95% rename from backend/app/converter/tomie_format.py rename to backend/app/games/tomie/format.py index b73e226..28c12ec 100644 --- a/backend/app/converter/tomie_format.py +++ b/backend/app/games/tomie/format.py @@ -1,10 +1,12 @@ """Editable PGVN envelope for the version-pinned native Naninovel interpreter.""" import copy import hashlib +from dataclasses import asdict from app.converter.compatibility import CompatibilityError, Issue -from app.converter.tomie_profile import runtime_data -from app.converter.tomie_validation import validate_runtime +from app.games.tomie.defects import split_known +from app.games.tomie.profile import runtime_data +from app.games.tomie.validation import validate_runtime RUNTIME = "tomie/1" TEXT_FIELDS = {"PrintText": "Text", "AppendText": "Text", "AddChoice": "ChoiceSummary", @@ -18,9 +20,10 @@ def prepare(package): data = runtime_data(package) except ValueError as exc: raise CompatibilityError("naninovel", [Issue("tomie_metadata", str(exc))]) from exc - issues = validate_runtime(data) + issues, known = split_known(validate_runtime(data)) if issues: raise CompatibilityError("naninovel", issues) + data["known_defects"] = [asdict(issue) for issue in known] routes = {"19": {"script": "DicePlaySub", "label": "DayEnd"}, "1000": {"script": "TRandbox", "label": "Start"}} import re diff --git a/backend/app/games/tomie/mod.py b/backend/app/games/tomie/mod.py new file mode 100644 index 0000000..d4705e7 --- /dev/null +++ b/backend/app/games/tomie/mod.py @@ -0,0 +1,28 @@ +"""Extra validation of edited Tomie mods (called from ``app.mods``).""" +from app.games.tomie.defects import split_known +from app.games.tomie.format import unpack_modules +from app.games.tomie.validation import validate_runtime + + +def validate_mod(game, scripts, manifest) -> None: + native = unpack_modules(scripts) + issues, _ = split_known(validate_runtime({"scripts": native})) + if issues: + raise ValueError(issues[0].message + " (" + issues[0].location + ")") + config = game.get("tomie") + if not isinstance(config, dict) or not isinstance(config.get("defaults"), dict) or not isinstance(config.get("function_data"), dict): + raise ValueError("Отсутствуют исходные настройки Tomie") + start = config.get("start", {}) + script = native.get(start.get("script")) + if script is None or start.get("label", "") not in script["labels"]: + raise ValueError("Точка входа Tomie не найдена") + media = config.get("media") + if not isinstance(media, dict): + raise ValueError("Не найден индекс медиа Tomie") + for collection in ("backgrounds", "characters", "textures", "videos", "audio", "fonts"): + if not isinstance(media.get(collection), dict): + raise ValueError("Некорректный индекс медиа Tomie: " + collection) + for entry in media[collection].values(): + path = entry.get("file") if isinstance(entry, dict) else entry + if not isinstance(path, str) or path not in manifest["files"]: + raise ValueError("Отсутствует медиа Tomie: " + str(path)) diff --git a/backend/app/converter/tomie_profile.py b/backend/app/games/tomie/profile.py similarity index 100% rename from backend/app/converter/tomie_profile.py rename to backend/app/games/tomie/profile.py diff --git a/backend/app/games/tomie/reader.py b/backend/app/games/tomie/reader.py new file mode 100644 index 0000000..5b46a03 --- /dev/null +++ b/backend/app/games/tomie/reader.py @@ -0,0 +1,66 @@ +"""Tomie-specific parts of the Unity reader: ResourceManager actors and original variables.""" +import json + +from app.converter.compatibility import Issue +from app.games.hooks import ReaderHooks +from app.games.tomie.profile import PROFILE, extract_tables + + +class TomieHooks(ReaderHooks): + def begin(self, package, root): + package.profile = PROFILE + package.function_data = extract_tables(root) + + def accept_duplicate_script(self, name): + # This pinned build contains an editor/addressable Init alongside the main + # corpus. The recovered port uses the main ResourceManager copy; it is read first. + return name == "Init" + + def scan_object(self, package, obj, ctx): + kind = obj.type.name + if kind == "ResourceManager" and ctx.media: + self._actors(package, obj, ctx) + return True + if kind == "MonoBehaviour" and obj.peek_name() == "CustomVariablesConfiguration": + self._defaults(package, obj, ctx) + return True + return False + + @staticmethod + def _actors(package, obj, ctx): + from UnityPy.classes import PPtr + + actor_keys = set() + for key, pointer in obj.read_typetree()["m_Container"]: + if not key.lower().startswith("characters/"): + continue + name = key.lower().removeprefix("characters/") + actor_keys.add(name) + actor = PPtr(assetsfile=obj.assets_file, **pointer).deref() + # Unity exposes both the complete texture and its Sprite under the same + # Resources key. Naninovel's texture actor uses the complete texture, + # as does the recovered Godot port. + if actor.type.name == "Sprite": + continue + if actor.type.name != "Texture2D": + package.issues.append(Issue("unsupported_actor", "Не поддерживается тип персонажа Tomie", key)) + continue + identity = f"{ctx.source.relative_to(ctx.root).as_posix()}:{actor.assets_file.name}:{actor.path_id}" + previous = package.actor_resources.get(name) + if previous and previous != identity: + package.issues.append(Issue("conflicting_actor", "Различающиеся изображения персонажа Tomie", key)) + package.actor_resources[name] = identity + package.resources.append((ctx.source, actor)) + for name in actor_keys - package.actor_resources.keys(): + package.issues.append(Issue("missing_actor_texture", "Не найдена полная текстура персонажа Tomie", name)) + + @staticmethod + def _defaults(package, obj, ctx): + codec = ctx.codec(str(obj.assets_file.unity_version)) + node = obj.serialized_type.node or codec.node("Elringus.Naninovel.Runtime", "Naninovel.CustomVariablesConfiguration") + config = obj.read_typetree(nodes=node) + defaults = {entry["name"]: json.loads(entry["value"]) for entry in config["PredefinedVariables"]} + if package.defaults and package.defaults != defaults: + package.issues.append(Issue("conflicting_defaults", "Различающиеся исходные переменные Naninovel")) + else: + package.defaults = defaults diff --git a/backend/app/converter/tomie_validation.py b/backend/app/games/tomie/validation.py similarity index 99% rename from backend/app/converter/tomie_validation.py rename to backend/app/games/tomie/validation.py index f1ebdea..a552a3f 100644 --- a/backend/app/converter/tomie_validation.py +++ b/backend/app/games/tomie/validation.py @@ -7,7 +7,7 @@ import re from app.converter.compatibility import Issue from app.converter.nani_expr import assignment, parse -from app.converter.tomie_profile import FUNCTIONS, validate_expressions +from app.games.tomie.profile import FUNCTIONS, validate_expressions ARGUMENTS = { "ModifyBackground": "AppearanceAndTransition Duration Scale TintColor", diff --git a/backend/app/mods.py b/backend/app/mods.py index e068d97..6601796 100644 --- a/backend/app/mods.py +++ b/backend/app/mods.py @@ -7,6 +7,7 @@ from uuid import NAMESPACE_URL, uuid4, uuid5 from app.blobs import BLOB_ID_RE from app.converter.locales import walk from app.converter.pipeline import anchors +from app.games.registry import for_runtime from app.pgvn import expression, statements from app.translation import file_json, put_json @@ -111,8 +112,8 @@ def validate(doc, manifest): body(n.get("body"), depth + 1) if op == "exec": statements(n.get("code")) - if op == "nani_command" and game.get("runtime") != "tomie/1": - raise ValueError("Исходные команды Naninovel требуют плеер Tomie") + if op == "nani_command" and not for_runtime(game.get("runtime")): + raise ValueError("Исходные команды Naninovel требуют отдельный плеер игры") for path, module in scripts.items(): check_path(path) @@ -134,30 +135,8 @@ def validate(doc, manifest): raise ValueError("Точка входа не найдена") if any(t not in names for t in targets): raise ValueError("Переход на отсутствующую метку") - if game.get("runtime") == "tomie/1": - from app.converter.tomie_format import unpack_modules - from app.converter.tomie_validation import validate_runtime - native = unpack_modules(scripts) - issues = validate_runtime({"scripts": native}) - if issues: - raise ValueError(issues[0].message + " (" + issues[0].location + ")") - config = game.get("tomie") - if not isinstance(config, dict) or not isinstance(config.get("defaults"), dict) or not isinstance(config.get("function_data"), dict): - raise ValueError("Отсутствуют исходные настройки Tomie") - start = config.get("start", {}) - script = native.get(start.get("script")) - if script is None or start.get("label", "") not in script["labels"]: - raise ValueError("Точка входа Tomie не найдена") - media = config.get("media") - if not isinstance(media, dict): - raise ValueError("Не найден индекс медиа Tomie") - for collection in ("backgrounds", "characters", "textures", "videos", "audio", "fonts"): - if not isinstance(media.get(collection), dict): - raise ValueError("Некорректный индекс медиа Tomie: " + collection) - for entry in media[collection].values(): - path = entry.get("file") if isinstance(entry, dict) else entry - if not isinstance(path, str) or path not in manifest["files"]: - raise ValueError("Отсутствует медиа Tomie: " + str(path)) + if profile := for_runtime(game.get("runtime")): + profile.validate_mod(game, scripts, manifest) files = manifest["files"] for path, entry in files.items(): check_path(path) diff --git a/backend/tests/test_game_profiles.py b/backend/tests/test_game_profiles.py new file mode 100644 index 0000000..692133b --- /dev/null +++ b/backend/tests/test_game_profiles.py @@ -0,0 +1,54 @@ +from pathlib import Path + +import pytest + +from app.converter.compatibility import Issue +from app.games import registry +from app.games.base import GameProfile +from app.games.tomie.defects import KNOWN_DEFECTS, split_known + + +def test_builtin_profiles_are_registered(): + ids = {profile.id for profile in registry.all_profiles()} + assert {"renpy", "naninovel", "tomie/expansion-1.3801"} <= ids + assert registry.for_runtime("tomie/1").id == "tomie/expansion-1.3801" + assert registry.for_runtime(None) is None + assert registry.for_runtime("unknown/9") is None + + +def test_specific_profile_wins_over_engine_profile(tmp_path, monkeypatch): + assert registry.select("naninovel", tmp_path).id == "naninovel" # hash does not match + monkeypatch.setattr("app.games.tomie.identify", lambda root: "tomie/expansion-1.3801") + assert registry.select("naninovel", tmp_path).id == "tomie/expansion-1.3801" + assert registry.select("renpy", tmp_path).id == "renpy" + assert registry.select("godot", tmp_path) is None + + +def test_new_profile_can_be_registered_once(monkeypatch): + monkeypatch.setattr(registry, "_PROFILES", list(registry.all_profiles())) + + class Custom(GameProfile): + id = "custom/game-1" + engine = "naninovel" + specific = True + + def claims(self, root: Path) -> bool: + return root.name == "Custom" + + registry.register(Custom()) + assert registry.select("naninovel", Path("x/Custom")).id == "custom/game-1" + assert registry.select("naninovel", Path("x/Other")).id == "naninovel" + with pytest.raises(ValueError, match="уже зарегистрирован"): + registry.register(Custom()) + + +def test_known_defects_are_waived_only_up_to_the_recorded_count(): + (code, message, script), allowed = next(iter(KNOWN_DEFECTS.items())) + known_issue = Issue(code, message, f"{script}:10") + blocking, known = split_known([known_issue] * (allowed + 1)) + assert len(known) == allowed and len(blocking) == 1 + new = Issue("missing_target", "Не найден переход Nowhere.Start", "Common:1") + blocking, known = split_known([new, known_issue]) + assert blocking == [new] and known == [known_issue] + same_message_other_script = Issue(code, message, "SomewhereElse:1") + assert split_known([same_message_other_script])[0] == [same_message_other_script] diff --git a/backend/tests/test_tomie_format.py b/backend/tests/test_tomie_format.py index b74076a..44721e7 100644 --- a/backend/tests/test_tomie_format.py +++ b/backend/tests/test_tomie_format.py @@ -7,9 +7,9 @@ def test_native_translation_and_mod_edits_rebuild_the_same_vm_input(tmp_path, mo from app import mods from app.config import get_settings from app.converter.nani_package import Package, text_records - from app.converter.naninovel import _convert_package - from app.converter.tomie_format import unpack_modules - from app.converter.tomie_profile import PROFILE + from app.games.tomie.convert import convert_package + from app.games.tomie.format import unpack_modules + from app.games.tomie.profile import PROFILE from app.translation import apply_changes, locale_tables, text_index monkeypatch.setenv("DATA_DIR", str(tmp_path / "data")) get_settings.cache_clear() @@ -27,9 +27,9 @@ def test_native_translation_and_mod_edits_rebuild_the_same_vm_input(tmp_path, mo } package.texts = text_records("Tips", "tip:Title|Description {points}", "Tips") media = {key: {} for key in ("backgrounds", "characters", "textures", "videos", "audio", "fonts")} - monkeypatch.setattr("app.converter.tomie_assets.export_media", lambda *_: ({}, media, {})) + monkeypatch.setattr("app.games.tomie.convert.export_media", lambda *_: ({}, media, {})) try: - result = _convert_package(tmp_path, tmp_path / "work", lambda _: None, package) + result = convert_package(tmp_path, tmp_path / "work", lambda _: None, package) index = text_index(result.manifest) assert {line.source for line in index.lines} == {"Points: {points}", "Title", "Description {points}"} line = next(line for line in index.lines if line.source.startswith("Points:")) @@ -56,7 +56,7 @@ def test_native_translation_and_mod_edits_rebuild_the_same_vm_input(tmp_path, mo def test_video_recovery_checks_ranges_and_copies_only_declared_bytes(tmp_path): - from app.converter.tomie_assets import recover_object + from app.games.tomie.assets import recover_object data = tmp_path / "data" out = tmp_path / "out" data.mkdir(); out.mkdir() diff --git a/backend/tests/test_tomie_profile.py b/backend/tests/test_tomie_profile.py index ef167b2..9ca25a4 100644 --- a/backend/tests/test_tomie_profile.py +++ b/backend/tests/test_tomie_profile.py @@ -6,7 +6,7 @@ from types import SimpleNamespace import pytest from app.converter.nani_expr import assignment, parse -from app.converter.tomie_profile import ( +from app.games.tomie.profile import ( ASSEMBLY_HASH, FUNCTIONS, constant_value, @@ -49,7 +49,7 @@ def test_profile_expression_functions_are_opt_in(): def test_full_vm_validation_rejects_unimplemented_and_broken_commands(): - from app.converter.tomie_validation import validate_runtime + from app.games.tomie.validation import validate_runtime data = {"scripts": {"Main": {"labels": {"Start": 0}, "commands": [ {"op": "AddChoice", "args": {"ChoiceSummary": "OK", "OnSelected": ['@set result=If(true, 1, 2)']}, "line": 1}, {"op": "Goto", "args": {"Path": ".Missing"}, "line": 2}, @@ -64,7 +64,7 @@ def test_full_vm_validation_rejects_unimplemented_and_broken_commands(): def test_choice_templates_and_quoted_commas_are_validated_without_evaluating_them(): - from app.converter.tomie_validation import inline_commands, validate_runtime + from app.games.tomie.validation import inline_commands, validate_runtime assert inline_commands('@set x=If(true, "a,b", "c"), @return') == ['@set x=If(true, "a,b", "c")', '@return'] data = {"scripts": {"Main": {"labels": {}, "commands": [ {"op": "AddChoice", "args": {"OnSelected": {"template": '@set result="{answer}"'}}}, @@ -76,7 +76,7 @@ def test_choice_templates_and_quoted_commas_are_validated_without_evaluating_the def test_native_effect_and_ui_extensions_are_rejected_before_media_export(): - from app.converter.tomie_validation import validate_runtime + from app.games.tomie.validation import validate_runtime data = {"scripts": {"Main": {"labels": {}, "commands": [ {"op": "Spawn", "args": {"Path": "NativePlugin"}}, {"op": "ShowUI", "args": {"UINames": ["CustomScreen"]}}, diff --git a/docs/GAME_PROFILES.md b/docs/GAME_PROFILES.md new file mode 100644 index 0000000..3bf47b5 --- /dev/null +++ b/docs/GAME_PROFILES.md @@ -0,0 +1,59 @@ +# Профили игр + +Общий конвейер (загрузка, очередь, хранилище блобов, моды, оболочка плеера) не знает +подробностей конкретных игр. Всё, что относится к движку или к одной сборке игры, +живёт в профиле — модуле в `backend/app/games/`. + +## Что такое профиль + +Класс `GameProfile` (`app/games/base.py`): + +| Член | Назначение | +| --- | --- | +| `id` | Стабильный идентификатор в отчётах, например `tomie/expansion-1.3801` | +| `engine` | Семейство движка из `compatibility.detect` (`renpy`, `naninovel`, …) | +| `specific` | `True` для профиля, привязанного к одной сборке: он выбирается раньше общего профиля движка | +| `runtime` | Значение `game.runtime`, если игре нужен отдельный плеер | +| `claims(root)` | Дешёвая проверка, что профиль подходит (например, SHA-256 сборки) | +| `inspect(root, work)` | Проверка совместимости без записи блобов; при отказе — `CompatibilityError` | +| `convert(root, work, progress)` | Полный импорт в PGVN | +| `validate_mod(game, scripts, manifest)` | Дополнительные проверки изменённого мода | +| `unity_hooks()` | Расширение общего читателя Unity/Naninovel (`app/games/hooks.py`) | + +`app/games/registry.py` выбирает профиль: `select(engine, root)` — сначала +`specific`-профили, затем общий профиль движка; `for_runtime(runtime)` — для +валидации модов. Остальной код обращается только к реестру: `pipeline`, +`preflight`, `compatibility.detect`, `mods`. + +## Как добавить игру со своим конвейером + +1. Создайте `backend/app/games/<имя>/` с классом профиля. Задайте `engine`, + `specific = True`, `claims()` (хэш сборки, а не имя папки) и `inspect`/`convert`. +2. Особенности чтения Unity-данных вынесите в наследника `ReaderHooks` + (`begin`, `accept_duplicate_script`, `scan_object`) и передайте его в + `read_package(..., hooks=...)`. +3. Зарегистрируйте профиль в `app/games/__init__.py`. +4. Если игре нужен свой плеер, добавьте `runtime` и запись в + `frontend/src/player/runtimes.ts` (загрузчик подгружается лениво). +5. Проверки: профиль закрывается по принципу «закрыто по умолчанию» — неизвестное + отклоняется. Добавьте тесты по образцу `tests/test_tomie_*.py` и + `tests/test_game_profiles.py`. + +## Профили сейчас + +* `renpy` — `app/games/renpy.py`; конвертация остаётся в `converter/pipeline.py`, + потому что объединяет распаковку архива, RPA и медиа. +* `naninovel` — общий импорт Naninovel (`converter/naninovel.py`). +* `tomie/expansion-1.3801` — `app/games/tomie/`: хэш сборки (`profile.py`), + формат и нативный интерпретатор команд (`format.py`, `validation.py`), медиа + (`assets.py`), хуки читателя (`reader.py`), известные дефекты оригинала + (`defects.py`), проверка модов (`mod.py`), импорт (`convert.py`). + +## Известные дефекты оригинала + +Профиль может перечислить ошибки самой игры (переход на несуществующую метку, +ветка без `EndIf`), которые оригинал тоже не обрабатывает. Они не блокируют +импорт, а попадают в отчёт как предупреждения; плеер на таком месте останавливается +с явной ошибкой. Список точный: ключ — (код, сообщение, сценарий), значение — +максимальное число. Новые или лишние случаи, в том числе в моде, по-прежнему +отклоняются. Добавлять записи ради прохождения импорта нельзя. diff --git a/docs/IMPORT.md b/docs/IMPORT.md index e930a98..1eeaf66 100644 --- a/docs/IMPORT.md +++ b/docs/IMPORT.md @@ -106,15 +106,16 @@ SHA-256 оригинальной `Assembly-CSharp.dll`; переименован Предпросмотр Tomie с отдельной строки пока явно отклоняется; запуск с начала поддерживается. -**Оригинальная папка Tomie пока не проходит строгую проверку целиком.** После -учёта исходной семантики ветвлений остаётся 31 диагностируемый случай: -22 отсутствующих адреса переходов, 6 условных блоков без завершающего `EndIf` -и 3 выражения выбора без поддерживаемого присваивания. Например, сценарии -обращаются к отсутствующему `B_Rest.Start` и к `AB_Studio.SkillUp`, хотя -`SkillUp` присутствует в `Common`. Автоматическая подмена сюжета не выполняется. -Импорт останавливается перед экспортом медиа, статус `ready` не выставляется. -Полное прохождение оригинальной игры и экспорт всего её медиакаталога ещё -не подтверждены; наличие адаптера не считается доказательством совместимости. +Оригинальная папка Tomie проходит строгую проверку. В скриптах остаются 31 дефект +самой игры: 22 перехода на отсутствующие метки (например, `B_Rest.Start` и +`AB_Studio.SkillUp`), 6 условных блоков без `EndIf` и 3 некорректных присваивания +в вариантах выбора. Они перечислены в `app/games/tomie/defects.py`, показываются +в отчёте версии как предупреждения, а плеер останавливается с явной ошибкой, если +игра доходит до такого места. Любой новый дефект по-прежнему останавливает импорт. +Автоматическая подмена сюжета не выполняется. Полное прохождение игры и экспорт +всего медиакаталога через конвертер ещё не подтверждены. + +Устройство профилей и добавление новых игр описаны в [GAME_PROFILES.md](GAME_PROFILES.md). Проверка браузерного интерфейса находится в `frontend/tests/tomie-browser.test.mjs`. Она использует уже установленный Playwright через `PGAMES_PLAYWRIGHT_PATH`; diff --git a/frontend/src/player/index.ts b/frontend/src/player/index.ts index d003d91..991d3da 100644 --- a/frontend/src/player/index.ts +++ b/frontend/src/player/index.ts @@ -1,6 +1,7 @@ import "./player.css"; import { Engine, type SaveApi } from "./engine"; import { loadTranslator, preferredLanguage } from "./i18n"; +import { RUNTIMES } from "./runtimes"; import type { GameData, Manifest, SaveData, ScriptModule } from "./types"; async function getJSON(url: string): Promise { @@ -55,38 +56,10 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () => Promise.all(game.scripts.map((s) => getJSON(blob(manifest, s)))), loadTranslator(game, manifest, lang), ]); - if (game.runtime === "tomie/1") { - if (!game.tomie) throw new Error("Не найдены исходные настройки Tomie"); - const { TomiePlayer } = await import("./tomie-player"); - const scripts: import("./nani-runtime").NaniPackage["scripts"] = Object.create(null); - const resources: NonNullable = Object.create(null); - for (const module of modules) { - if (!module.nani) { - for (const node of module.labels.flatMap((label) => label.body)) { - const record = node.resource; - if (!record || typeof node.what !== "string" || typeof record.collection !== "string" || typeof record.key !== "string") continue; - const collection = resources[record.collection] ??= Object.create(null); - (collection[record.key] ??= []).push({ ...record, tid: node.tid, text: node.what }); - } - continue; - } - const nodes = module.labels.flatMap((label) => label.body); - const commands = nodes.map((node) => { - const command = structuredClone(node.command); - if (node.textField && typeof node.what === "string") command.args[node.textField] = { template: node.what }; - command.tid = node.tid ?? command.tid; return command; - }); - const labels = module.nani.label_ids ? Object.fromEntries(Object.entries(module.nani.label_ids).map(([label, id]) => { - const index = id ? nodes.findIndex((node) => node.id === id) : nodes.length; - if (index < 0) throw new Error("Не найдена команда метки Naninovel: " + label); - return [label, index]; - })) : module.nani.labels; - scripts[module.nani.name] = { commands, labels, label_depths: module.nani.label_depths }; - } - if (url.get("at")) throw new Error("Предпросмотр Tomie с отдельной строки ещё не поддерживается. Откройте игру с начала."); - const player = new TomiePlayer(root, { ...game.tomie, scripts, resources }, manifest, - saveApi(versionId), tr, onExit); - try { player.start(); return player; } catch (error) { player.destroy(); throw error; } + if (game.runtime) { + const load = RUNTIMES[game.runtime]; + if (!load) throw new Error(`Неизвестная среда исполнения игры: ${game.runtime}`); + return (await load())({ root, game, manifest, modules, url, tr, onExit, versionId, saveApi }); } const engine = new Engine(root, game, manifest, modules, saveApi(versionId), `pgvn:persistent:${versionId}`, onExit, tr, diff --git a/frontend/src/player/runtimes.ts b/frontend/src/player/runtimes.ts new file mode 100644 index 0000000..ef1e40f --- /dev/null +++ b/frontend/src/player/runtimes.ts @@ -0,0 +1,23 @@ +// Games that need their own player register a launcher here, keyed by game.runtime. +import type { SaveApi } from "./engine"; +import type { Translator } from "./i18n"; +import type { GameData, Manifest, ScriptModule } from "./types"; +import type { PlayerHandle } from "./index"; + +export interface RuntimeContext { + root: HTMLElement; + game: GameData; + manifest: Manifest; + modules: ScriptModule[]; + url: URLSearchParams; + tr: Translator; + onExit: () => void; + versionId: number; + saveApi: (versionId: number) => SaveApi; +} + +export type RuntimeLauncher = (context: RuntimeContext) => Promise; + +export const RUNTIMES: Record Promise> = { + "tomie/1": async () => (await import("./tomie-launch")).launchTomie, +}; diff --git a/frontend/src/player/tomie-launch.ts b/frontend/src/player/tomie-launch.ts new file mode 100644 index 0000000..ac2b515 --- /dev/null +++ b/frontend/src/player/tomie-launch.ts @@ -0,0 +1,37 @@ +// Launcher for the dedicated Tomie runtime (game.runtime === "tomie/1"). +import type { GameData } from "./types"; +import type { RuntimeContext } from "./runtimes"; + +export async function launchTomie({ root, game, manifest, modules, url, tr, onExit, saveApi, versionId }: RuntimeContext) { + if (!game.tomie) throw new Error("Не найдены исходные настройки Tomie"); + const { TomiePlayer } = await import("./tomie-player"); + const scripts: import("./nani-runtime").NaniPackage["scripts"] = Object.create(null); + const resources: NonNullable = Object.create(null); + for (const module of modules) { + if (!module.nani) { + for (const node of module.labels.flatMap((label) => label.body)) { + const record = node.resource; + if (!record || typeof node.what !== "string" || typeof record.collection !== "string" || typeof record.key !== "string") continue; + const collection = resources[record.collection] ??= Object.create(null); + (collection[record.key] ??= []).push({ ...record, tid: node.tid, text: node.what }); + } + continue; + } + const nodes = module.labels.flatMap((label) => label.body); + const commands = nodes.map((node) => { + const command = structuredClone(node.command); + if (node.textField && typeof node.what === "string") command.args[node.textField] = { template: node.what }; + command.tid = node.tid ?? command.tid; return command; + }); + const labels = module.nani.label_ids ? Object.fromEntries(Object.entries(module.nani.label_ids).map(([label, id]) => { + const index = id ? nodes.findIndex((node) => node.id === id) : nodes.length; + if (index < 0) throw new Error("Не найдена команда метки Naninovel: " + label); + return [label, index]; + })) : module.nani.labels; + scripts[module.nani.name] = { commands, labels, label_depths: module.nani.label_depths }; + } + if (url.get("at")) throw new Error("Предпросмотр Tomie с отдельной строки ещё не поддерживается. Откройте игру с начала."); + const player = new TomiePlayer(root, { ...game.tomie, scripts, resources }, manifest, + saveApi(versionId), tr, onExit); + try { player.start(); return player; } catch (error) { player.destroy(); throw error; } +}