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

+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}"