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
+700 -196

No files matched your search

+3
View File
@@ -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).
+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()))])
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
+27 -51
View File
@@ -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}"
-45
View File
@@ -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()))
+15 -6
View File
@@ -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"))
+9 -12
View File
@@ -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:
+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."""
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
+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.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
View File
@@ -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)
+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.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()
+4 -4
View File
@@ -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"]}},
+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 пока не проходит строгую проверку целиком.** После
учёта исходной семантики ветвлений остаётся 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`;
+5 -32
View File
@@ -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,
+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; }
}