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

+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)