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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user