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:
1 parent
bbbcc6a5d8
commit
12ec82c7e6
30 files changed
+700
-196
No files matched your search
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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()))
|
||||
@@ -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"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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.
@@ -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()))
|
||||
@@ -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."""
|
||||
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
|
||||
@@ -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.
@@ -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.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",
|
||||
+5
-26
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]}},
|
||||
|
||||
@@ -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
@@ -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`;
|
||||
|
||||
@@ -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<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)))),
|
||||
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<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; }
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user