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 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 11:35:23 +03:00
1 parent bbbcc6a5d8
commit 12ec82c7e6
30 files changed
+699 -195

No files matched your search

+3
View File
@@ -12,6 +12,9 @@
Пакетный импорт коллекции, строгая проверка совместимости и текущие ограничения Пакетный импорт коллекции, строгая проверка совместимости и текущие ограничения
Naninovel описаны в [docs/IMPORT.md](docs/IMPORT.md). Naninovel описаны в [docs/IMPORT.md](docs/IMPORT.md).
Профили игр (отдельный конвейер для игр с особенностями) —
в [docs/GAME_PROFILES.md](docs/GAME_PROFILES.md).
Редактор модов, слияние, перенос на новую версию, ревизии и сборщик мусора — Редактор модов, слияние, перенос на новую версию, ревизии и сборщик мусора —
в [docs/MODDING.md](docs/MODDING.md). в [docs/MODDING.md](docs/MODDING.md).
+2 -1
View File
@@ -77,7 +77,8 @@ def detect(root: Path) -> Detection:
"В одном импорте обнаружено несколько игр; загрузите каждую отдельно", "В одном импорте обнаружено несколько игр; загрузите каждую отдельно",
", ".join(f"{c.engine}: {c.root.relative_to(root)}" for c in candidates.values()))]) ", ".join(f"{c.engine}: {c.root.relative_to(root)}" for c in candidates.values()))])
result = next(iter(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", raise CompatibilityError(result.engine, [Issue("unsupported_engine",
f"Движок {result.engine} распознан, но его исполнение в PGames не поддерживается")]) f"Движок {result.engine} распознан, но его исполнение в PGames не поддерживается")])
return result return result
+26 -50
View File
@@ -1,6 +1,5 @@
"""Recover Naninovel data from Unity builds without executing their managed code.""" """Recover Naninovel data from Unity builds without executing their managed code."""
import hashlib import hashlib
import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -8,6 +7,14 @@ from app.converter.compatibility import CompatibilityError, Issue
from app.converter.unity_bundle import unpack from app.converter.unity_bundle import unpack
@dataclass
class ReaderContext:
root: Path
source: Path
media: bool
codec: object # callable: Unity version -> ScriptCodec
@dataclass @dataclass
class Package: class Package:
scripts: dict = field(default_factory=dict) scripts: dict = field(default_factory=dict)
@@ -121,24 +128,24 @@ def text_records(name: str, text: str, origin: str) -> dict:
return records 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() package = Package()
try: try:
return _read_package(root, work, package, media=media) return _read_package(root, work, package, media=media, hooks=hooks or ReaderHooks())
except BaseException: except BaseException:
package.close() package.close()
raise 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 import UnityPy
from app.converter.tomie_profile import extract_tables, identify
from app.converter.unity_scripts import ScriptCodec, script_objects from app.converter.unity_scripts import ScriptCodec, script_objects
package.profile = identify(root) hooks.begin(package, root)
if package.profile:
package.function_data = extract_tables(root)
data_roots = [p for p in root.iterdir() if p.is_dir() and p.name.lower().endswith("_data")] data_roots = [p for p in root.iterdir() if p.is_dir() and p.name.lower().endswith("_data")]
if len(data_roots) != 1: 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.glob("*.assets")) + sorted(data.glob("*.unity3d"))
sources += sorted((data / "StreamingAssets").rglob("*.bundle")) sources += sorted((data / "StreamingAssets").rglob("*.bundle"))
codec = None codec = None
def get_codec(version):
nonlocal codec
if codec is None:
codec = ScriptCodec(root, version)
return codec
for number, source in enumerate(sources): for number, source in enumerate(sources):
try: try:
with source.open("rb") as handle: 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"): for stream in data.glob("*.resource"):
environment.load_file(str(stream)) environment.load_file(str(stream))
for obj in script_objects(environment): for obj in script_objects(environment):
if codec is None: codec = get_codec(str(obj.assets_file.unity_version))
codec = ScriptCodec(root, str(obj.assets_file.unity_version))
with codec.active(): with codec.active():
decoded = decode_script(codec.read(obj)) decoded = decode_script(codec.read(obj))
name = decoded["name"] name = decoded["name"]
previous = package.scripts.get(name) previous = package.scripts.get(name)
if previous is not None and previous != decoded and name == "Init" and package.profile: if previous is not None and previous != decoded and hooks.accept_duplicate_script(name):
# 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.
continue continue
if previous is not None and previous != decoded: if previous is not None and previous != decoded:
package.issues.append(Issue("conflicting_script", "Различающиеся копии сценария", name)) package.issues.append(Issue("conflicting_script", "Различающиеся копии сценария", name))
else: else:
package.scripts[name] = decoded package.scripts[name] = decoded
for obj in environment.objects: for obj in environment.objects:
if obj.type.name == "ResourceManager" and package.profile and media: if hooks.scan_object(package, obj, ReaderContext(root, source, media, get_codec)):
from UnityPy.classes import PPtr
actor_keys = set()
for key, pointer in obj.read_typetree()["m_Container"]:
if not key.lower().startswith("characters/"):
continue continue
name = key.lower().removeprefix("characters/") if obj.type.name == "TextAsset":
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":
asset = obj.read() asset = obj.read()
if asset.m_Name in {"DefaultUI", "Tips", "CharacterNames", "Inventory"} | stream_names: 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}" origin = f"{source.relative_to(root).as_posix()}/{obj.path_id}/{asset.m_Name}"
-45
View File
@@ -16,13 +16,6 @@ def inspect_naninovel(root: Path, *, work: Path | None = None) -> dict:
return inspect_naninovel(root, work=Path(directory)) return inspect_naninovel(root, work=Path(directory))
package = read_package(root, work, media=True) package = read_package(root, work, media=True)
try: 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) data, entry, _ = compile_package(package)
resolve_media(package, data) resolve_media(package, data)
return {"engine": "naninovel", "compatible": True, "scripts": len(package.scripts), 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): def _convert_package(root: Path, work: Path, progress, package):
from app.converter.pipeline import Result, anchors from app.converter.pipeline import Result, anchors
if package.profile:
return _convert_tomie(root, work, progress, package)
data, entry, expressions = compile_package(package) data, entry, expressions = compile_package(package)
# No media is decoded or written to the blob store before the script gate. # No media is decoded or written to the blob store before the script gate.
files, aliases = export_media(package, data, root, work, progress) 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), "scripts": len(package.scripts), "textResources": len(package.texts),
"media": len(aliases), "warnings": []} "media": len(aliases), "warnings": []}
return Result({"format": "pgvn/1", "files": files}, report, root.name, None, total) 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()))
+15 -6
View File
@@ -43,6 +43,17 @@ class Result:
size: int 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: def convert_archive(archive: Path, filename: str, work: Path, progress: Progress) -> Result:
"""Convert an uploaded archive, or an already unpacked game directory (left untouched).""" """Convert an uploaded archive, or an already unpacked game directory (left untouched)."""
started = time.monotonic() started = time.monotonic()
@@ -55,9 +66,8 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
if archive.is_dir(): if archive.is_dir():
detection = detect(archive) detection = detect(archive)
if detection.engine == "naninovel": if (profile := _profile_converter(detection)):
from app.converter.naninovel import convert_naninovel return profile.convert(detection.root, work, progress)
return convert_naninovel(detection.root, work, progress)
# Inspect scripts before copying/converting multi-gigabyte media collections. # Inspect scripts before copying/converting multi-gigabyte media collections.
from app.converter.preflight import inspect_directory from app.converter.preflight import inspect_directory
inspect_directory(detection.root) inspect_directory(detection.root)
@@ -71,9 +81,8 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
progress("Распаковка архива") progress("Распаковка архива")
extract(archive, filename, src_root) extract(archive, filename, src_root)
detection = detect(src_root) detection = detect(src_root)
if detection.engine == "naninovel": if (profile := _profile_converter(detection)):
from app.converter.naninovel import convert_naninovel return profile.convert(detection.root, work, progress)
return convert_naninovel(detection.root, work, progress)
game_dir = find_game_dir(src_root) game_dir = find_game_dir(src_root)
rpas = sorted(game_dir.rglob("*.rpa")) rpas = sorted(game_dir.rglob("*.rpa"))
+9 -12
View File
@@ -51,24 +51,21 @@ def probe_archive(source: Path, filename: str) -> dict:
"В архиве несколько движков: " + ", ".join(sorted(engines)) if engines else "В архиве несколько движков: " + ", ".join(sorted(engines)) if engines else
"Движок не распознан; нужен полный дистрибутив игры")]) "Движок не распознан; нужен полный дистрибутив игры")])
engine = engines.pop() 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} пока не поддерживается")]) raise CompatibilityError(engine, [Issue("unsupported_engine", f"Движок {engine} пока не поддерживается")])
return {"engine": engine, "scriptsChecked": False} return {"engine": engine, "scriptsChecked": False}
def inspect_directory(root: Path, *, work: Path | None = None) -> dict: def inspect_directory(root: Path, *, work: Path | None = None) -> dict:
from app.games.registry import select
detection = detect(root) detection = detect(root)
if detection.engine == "renpy": profile = select(detection.engine, detection.root)
from app.converter.analyze import analyze if profile is None:
report = analyze(detection.root) raise CompatibilityError(detection.engine, [Issue(
issues = [Issue(**issue) for issue in report.pop("issues", [])] "unsupported_engine", f"Для движка {detection.engine} нет профиля импорта")])
if not report["hasStart"]: return profile.inspect(detection.root, work)
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: def inspect_archive(source: Path, filename: str) -> dict:
+8
View File
@@ -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)
+50
View File
@@ -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
+22
View File
@@ -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
+19
View File
@@ -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)
+45
View File
@@ -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
+23
View File
@@ -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}
+30
View File
@@ -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)
File renamed without changes.
+80
View File
@@ -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()))
+63
View File
@@ -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
@@ -1,10 +1,12 @@
"""Editable PGVN envelope for the version-pinned native Naninovel interpreter.""" """Editable PGVN envelope for the version-pinned native Naninovel interpreter."""
import copy import copy
import hashlib import hashlib
from dataclasses import asdict
from app.converter.compatibility import CompatibilityError, Issue from app.converter.compatibility import CompatibilityError, Issue
from app.converter.tomie_profile import runtime_data from app.games.tomie.defects import split_known
from app.converter.tomie_validation import validate_runtime from app.games.tomie.profile import runtime_data
from app.games.tomie.validation import validate_runtime
RUNTIME = "tomie/1" RUNTIME = "tomie/1"
TEXT_FIELDS = {"PrintText": "Text", "AppendText": "Text", "AddChoice": "ChoiceSummary", TEXT_FIELDS = {"PrintText": "Text", "AppendText": "Text", "AddChoice": "ChoiceSummary",
@@ -18,9 +20,10 @@ def prepare(package):
data = runtime_data(package) data = runtime_data(package)
except ValueError as exc: except ValueError as exc:
raise CompatibilityError("naninovel", [Issue("tomie_metadata", str(exc))]) from exc raise CompatibilityError("naninovel", [Issue("tomie_metadata", str(exc))]) from exc
issues = validate_runtime(data) issues, known = split_known(validate_runtime(data))
if issues: if issues:
raise CompatibilityError("naninovel", issues) raise CompatibilityError("naninovel", issues)
data["known_defects"] = [asdict(issue) for issue in known]
routes = {"19": {"script": "DicePlaySub", "label": "DayEnd"}, routes = {"19": {"script": "DicePlaySub", "label": "DayEnd"},
"1000": {"script": "TRandbox", "label": "Start"}} "1000": {"script": "TRandbox", "label": "Start"}}
import re import re
+28
View File
@@ -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))
File renamed without changes.
+66
View File
@@ -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
@@ -7,7 +7,7 @@ import re
from app.converter.compatibility import Issue from app.converter.compatibility import Issue
from app.converter.nani_expr import assignment, parse 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 = { ARGUMENTS = {
"ModifyBackground": "AppearanceAndTransition Duration Scale TintColor", "ModifyBackground": "AppearanceAndTransition Duration Scale TintColor",
+5 -26
View File
@@ -7,6 +7,7 @@ from uuid import NAMESPACE_URL, uuid4, uuid5
from app.blobs import BLOB_ID_RE from app.blobs import BLOB_ID_RE
from app.converter.locales import walk from app.converter.locales import walk
from app.converter.pipeline import anchors from app.converter.pipeline import anchors
from app.games.registry import for_runtime
from app.pgvn import expression, statements from app.pgvn import expression, statements
from app.translation import file_json, put_json from app.translation import file_json, put_json
@@ -111,8 +112,8 @@ def validate(doc, manifest):
body(n.get("body"), depth + 1) body(n.get("body"), depth + 1)
if op == "exec": if op == "exec":
statements(n.get("code")) statements(n.get("code"))
if op == "nani_command" and game.get("runtime") != "tomie/1": if op == "nani_command" and not for_runtime(game.get("runtime")):
raise ValueError("Исходные команды Naninovel требуют плеер Tomie") raise ValueError("Исходные команды Naninovel требуют отдельный плеер игры")
for path, module in scripts.items(): for path, module in scripts.items():
check_path(path) check_path(path)
@@ -134,30 +135,8 @@ def validate(doc, manifest):
raise ValueError("Точка входа не найдена") raise ValueError("Точка входа не найдена")
if any(t not in names for t in targets): if any(t not in names for t in targets):
raise ValueError("Переход на отсутствующую метку") raise ValueError("Переход на отсутствующую метку")
if game.get("runtime") == "tomie/1": if profile := for_runtime(game.get("runtime")):
from app.converter.tomie_format import unpack_modules profile.validate_mod(game, scripts, manifest)
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))
files = manifest["files"] files = manifest["files"]
for path, entry in files.items(): for path, entry in files.items():
check_path(path) check_path(path)
+54
View File
@@ -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]
+6 -6
View File
@@ -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 import mods
from app.config import get_settings from app.config import get_settings
from app.converter.nani_package import Package, text_records from app.converter.nani_package import Package, text_records
from app.converter.naninovel import _convert_package from app.games.tomie.convert import convert_package
from app.converter.tomie_format import unpack_modules from app.games.tomie.format import unpack_modules
from app.converter.tomie_profile import PROFILE from app.games.tomie.profile import PROFILE
from app.translation import apply_changes, locale_tables, text_index from app.translation import apply_changes, locale_tables, text_index
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data")) monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
get_settings.cache_clear() 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") package.texts = text_records("Tips", "tip:Title|Description {points}", "Tips")
media = {key: {} for key in ("backgrounds", "characters", "textures", "videos", "audio", "fonts")} 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: 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) index = text_index(result.manifest)
assert {line.source for line in index.lines} == {"Points: {points}", "Title", "Description {points}"} 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:")) 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): 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" data = tmp_path / "data"
out = tmp_path / "out" out = tmp_path / "out"
data.mkdir(); out.mkdir() data.mkdir(); out.mkdir()
+4 -4
View File
@@ -6,7 +6,7 @@ from types import SimpleNamespace
import pytest import pytest
from app.converter.nani_expr import assignment, parse from app.converter.nani_expr import assignment, parse
from app.converter.tomie_profile import ( from app.games.tomie.profile import (
ASSEMBLY_HASH, ASSEMBLY_HASH,
FUNCTIONS, FUNCTIONS,
constant_value, constant_value,
@@ -49,7 +49,7 @@ def test_profile_expression_functions_are_opt_in():
def test_full_vm_validation_rejects_unimplemented_and_broken_commands(): 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": [ data = {"scripts": {"Main": {"labels": {"Start": 0}, "commands": [
{"op": "AddChoice", "args": {"ChoiceSummary": "OK", "OnSelected": ['@set result=If(true, 1, 2)']}, "line": 1}, {"op": "AddChoice", "args": {"ChoiceSummary": "OK", "OnSelected": ['@set result=If(true, 1, 2)']}, "line": 1},
{"op": "Goto", "args": {"Path": ".Missing"}, "line": 2}, {"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(): 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'] assert inline_commands('@set x=If(true, "a,b", "c"), @return') == ['@set x=If(true, "a,b", "c")', '@return']
data = {"scripts": {"Main": {"labels": {}, "commands": [ data = {"scripts": {"Main": {"labels": {}, "commands": [
{"op": "AddChoice", "args": {"OnSelected": {"template": '@set result="{answer}"'}}}, {"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(): 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": [ data = {"scripts": {"Main": {"labels": {}, "commands": [
{"op": "Spawn", "args": {"Path": "NativePlugin"}}, {"op": "Spawn", "args": {"Path": "NativePlugin"}},
{"op": "ShowUI", "args": {"UINames": ["CustomScreen"]}}, {"op": "ShowUI", "args": {"UINames": ["CustomScreen"]}},
+59
View File
@@ -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`), которые оригинал тоже не обрабатывает. Они не блокируют
импорт, а попадают в отчёт как предупреждения; плеер на таком месте останавливается
с явной ошибкой. Список точный: ключ — (код, сообщение, сценарий), значение —
максимальное число. Новые или лишние случаи, в том числе в моде, по-прежнему
отклоняются. Добавлять записи ради прохождения импорта нельзя.
+10 -9
View File
@@ -106,15 +106,16 @@ SHA-256 оригинальной `Assembly-CSharp.dll`; переименован
Предпросмотр Tomie с отдельной строки пока явно отклоняется; запуск с начала Предпросмотр Tomie с отдельной строки пока явно отклоняется; запуск с начала
поддерживается. поддерживается.
**Оригинальная папка Tomie пока не проходит строгую проверку целиком.** После Оригинальная папка Tomie проходит строгую проверку. В скриптах остаются 31 дефект
учёта исходной семантики ветвлений остаётся 31 диагностируемый случай: самой игры: 22 перехода на отсутствующие метки (например, `B_Rest.Start` и
22 отсутствующих адреса переходов, 6 условных блоков без завершающего `EndIf` `AB_Studio.SkillUp`), 6 условных блоков без `EndIf` и 3 некорректных присваивания
и 3 выражения выбора без поддерживаемого присваивания. Например, сценарии в вариантах выбора. Они перечислены в `app/games/tomie/defects.py`, показываются
обращаются к отсутствующему `B_Rest.Start` и к `AB_Studio.SkillUp`, хотя в отчёте версии как предупреждения, а плеер останавливается с явной ошибкой, если
`SkillUp` присутствует в `Common`. Автоматическая подмена сюжета не выполняется. игра доходит до такого места. Любой новый дефект по-прежнему останавливает импорт.
Импорт останавливается перед экспортом медиа, статус `ready` не выставляется. Автоматическая подмена сюжета не выполняется. Полное прохождение игры и экспорт
Полное прохождение оригинальной игры и экспорт всего её медиакаталога ещё всего медиакаталога через конвертер ещё не подтверждены.
не подтверждены; наличие адаптера не считается доказательством совместимости.
Устройство профилей и добавление новых игр описаны в [GAME_PROFILES.md](GAME_PROFILES.md).
Проверка браузерного интерфейса находится в `frontend/tests/tomie-browser.test.mjs`. Проверка браузерного интерфейса находится в `frontend/tests/tomie-browser.test.mjs`.
Она использует уже установленный Playwright через `PGAMES_PLAYWRIGHT_PATH`; Она использует уже установленный Playwright через `PGAMES_PLAYWRIGHT_PATH`;
+5 -32
View File
@@ -1,6 +1,7 @@
import "./player.css"; import "./player.css";
import { Engine, type SaveApi } from "./engine"; import { Engine, type SaveApi } from "./engine";
import { loadTranslator, preferredLanguage } from "./i18n"; import { loadTranslator, preferredLanguage } from "./i18n";
import { RUNTIMES } from "./runtimes";
import type { GameData, Manifest, SaveData, ScriptModule } from "./types"; import type { GameData, Manifest, SaveData, ScriptModule } from "./types";
async function getJSON<T>(url: string): Promise<T> { async function getJSON<T>(url: string): Promise<T> {
@@ -55,38 +56,10 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))), Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))),
loadTranslator(game, manifest, lang), loadTranslator(game, manifest, lang),
]); ]);
if (game.runtime === "tomie/1") { if (game.runtime) {
if (!game.tomie) throw new Error("Не найдены исходные настройки Tomie"); const load = RUNTIMES[game.runtime];
const { TomiePlayer } = await import("./tomie-player"); if (!load) throw new Error(`Неизвестная среда исполнения игры: ${game.runtime}`);
const scripts: import("./nani-runtime").NaniPackage["scripts"] = Object.create(null); return (await load())({ root, game, manifest, modules, url, tr, onExit, versionId, saveApi });
const resources: NonNullable<GameData["naniResources"]> = 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<import("./tomie-player").TomieSave>(versionId), tr, onExit);
try { player.start(); return player; } catch (error) { player.destroy(); throw error; }
} }
const engine = new Engine(root, game, manifest, modules, saveApi(versionId), const engine = new Engine(root, game, manifest, modules, saveApi(versionId),
`pgvn:persistent:${versionId}`, onExit, tr, `pgvn:persistent:${versionId}`, onExit, tr,
+23
View File
@@ -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: <T>(versionId: number) => SaveApi<T>;
}
export type RuntimeLauncher = (context: RuntimeContext) => Promise<PlayerHandle>;
export const RUNTIMES: Record<string, () => Promise<RuntimeLauncher>> = {
"tomie/1": async () => (await import("./tomie-launch")).launchTomie,
};
+37
View File
@@ -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<GameData["naniResources"]> = 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<import("./tomie-player").TomieSave>(versionId), tr, onExit);
try { player.start(); return player; } catch (error) { player.destroy(); throw error; }
}