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