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:
-128
View File
@@ -1,128 +0,0 @@
"""Recover the pinned Tomie build's media and ResourceManager actor bindings."""
import hashlib
from pathlib import Path
from app.blobs import put_file
from app.converter import assets
from app.converter.compatibility import CompatibilityError, Issue
def recover_object(obj, data: Path, out: Path, stem: str):
"""Decode a single owned temporary file; never rename or edit a source file."""
asset = obj.read()
kind, size = obj.type.name, None
if kind == "Texture2D":
if asset.m_Width == 0 or asset.m_Height == 0:
return None # empty runtime font atlas
path = out / (stem + ".png")
asset.image.save(path, compress_level=1)
size = [asset.m_Width, asset.m_Height]
elif kind == "AudioClip":
samples = asset.samples
if len(samples) != 1:
raise ValueError("Не найден единственный аудиоклип")
filename, raw = next(iter(samples.items()))
extension = Path(filename).suffix.lower() or ".wav"
if extension not in assets.AUDIO_EXTS:
raise ValueError("Не поддерживается контейнер аудиоклипа " + extension)
path = out / (stem + extension)
path.write_bytes(raw)
elif kind == "Font":
raw = bytes(asset.m_FontData or b"")
if not raw:
return None
path = out / (stem + (".otf" if raw[:4] == b"OTTO" else ".ttf"))
path.write_bytes(raw)
elif kind == "VideoClip":
tree = obj.read_typetree()
reference = tree["m_ExternalResources"]
source_name = reference["m_Source"].replace("\\", "/").rsplit("/", 1)[-1]
resource = data / source_name
offset, length = int(reference["m_Offset"]), int(reference["m_Size"])
if not resource.is_file() or offset < 0 or length <= 0 or offset + length > resource.stat().st_size:
raise ValueError("Некорректный диапазон исходного видео")
extension = Path(tree["m_OriginalPath"]).suffix.lower()
if extension not in assets.VIDEO_EXTS:
raise ValueError("Не поддерживается исходный видеоконтейнер " + extension)
path = out / (stem + extension)
with resource.open("rb") as incoming, path.open("wb") as outgoing:
incoming.seek(offset)
remaining = length
while remaining:
chunk = incoming.read(min(1 << 20, remaining))
if not chunk:
raise EOFError("Оборванный видеопоток")
outgoing.write(chunk)
remaining -= len(chunk)
else:
raise ValueError("Не поддерживается тип медиа " + kind)
return {"path": path, "name": asset.m_Name.lower(), "size": size,
"kind": {"Texture2D": "image", "AudioClip": "audio", "Font": "font", "VideoClip": "video"}[kind]}
def export_media(package, root: Path, work: Path, progress):
if package.issues:
raise CompatibilityError("naninovel", package.issues)
data = root / "TomieWGM_Data"
out = work / "media"
out.mkdir(parents=True, exist_ok=True)
files, counts = {}, {"image": 0, "audio": 0, "video": 0, "font": 0}
index = {"backgrounds": {}, "characters": {}, "audio": {}, "videos": {}, "textures": {}, "fonts": {}}
exported = {}
def store(path: Path, kind: str):
converted, extension = assets.convert(path, out)
digest, size = put_file(converted, extension, move=True)
logical = "media/" + digest
files[logical] = {"hash": digest, "size": size}
counts[kind] += 1
return logical
for number, source in enumerate(sorted((data / "Resources/Backgrounds/MainBackground").rglob("*"))):
if not source.is_file():
continue
if assets.kind_of(source) != "image":
raise CompatibilityError("naninovel", [Issue("unsupported_background", "Не поддерживается формат фона Tomie", source.name)])
if number % 100 == 0:
progress(f"Фоны Tomie: {number}")
name = source.stem.lower()
if name in index["backgrounds"]:
raise CompatibilityError("naninovel", [Issue("ambiguous_background", "Неоднозначный фон Tomie", name)])
index["backgrounds"][name] = store(source, "image")
objects = {}
for source, obj in package.resources:
identity = f"{source.relative_to(root).as_posix()}:{obj.assets_file.name}:{obj.path_id}"
objects.setdefault(identity, (source, obj))
for number, (identity, (source, obj)) in enumerate(objects.items()):
if number % 100 == 0:
progress(f"Ресурсы Tomie: {number}/{len(objects)}")
try:
stem = hashlib.sha256(identity.encode()).hexdigest()[:24]
recovered = recover_object(obj, data, out, stem)
if recovered is None:
continue
path, name = recovered["path"], recovered["name"]
try:
logical = store(path, recovered["kind"])
finally:
path.unlink(missing_ok=True)
if obj.type.name == "Texture2D":
exported[identity] = {"file": logical, "size": recovered["size"]}
# Main resources take precedence over the UI bundle, as in the port.
index["textures"].setdefault(name, logical)
elif obj.type.name == "AudioClip":
index["audio"].setdefault(name, logical)
elif obj.type.name == "Font":
index["fonts"].setdefault(name, logical)
elif obj.type.name == "VideoClip":
index["videos"][name] = logical
except Exception as exc:
raise CompatibilityError("naninovel", [Issue("tomie_media", f"Не удалось восстановить медиа: {exc}", identity)]) from exc
for name, identity in package.actor_resources.items():
if identity not in exported:
raise CompatibilityError("naninovel", [Issue("missing_actor", "Не найдено исходное изображение персонажа", name)])
index["characters"][name] = exported[identity]
if not index["backgrounds"] or not index["characters"] or not index["videos"]:
raise CompatibilityError("naninovel", [Issue("missing_media", "Не восстановлены все категории медиа Tomie")])
return files, index, counts
-118
View File
@@ -1,118 +0,0 @@
"""Editable PGVN envelope for the version-pinned native Naninovel interpreter."""
import copy
import hashlib
from app.converter.compatibility import CompatibilityError, Issue
from app.converter.tomie_profile import runtime_data
from app.converter.tomie_validation import validate_runtime
RUNTIME = "tomie/1"
TEXT_FIELDS = {"PrintText": "Text", "AppendText": "Text", "AddChoice": "ChoiceSummary",
"ShowToastUI": "Text", "InputCustomVariable": "Summary"}
def prepare(package):
if package.issues:
raise CompatibilityError("naninovel", package.issues)
try:
data = runtime_data(package)
except ValueError as exc:
raise CompatibilityError("naninovel", [Issue("tomie_metadata", str(exc))]) from exc
issues = validate_runtime(data)
if issues:
raise CompatibilityError("naninovel", issues)
routes = {"19": {"script": "DicePlaySub", "label": "DayEnd"},
"1000": {"script": "TRandbox", "label": "Start"}}
import re
for command in data["scripts"].get("DicePlaySub", {}).get("commands", []):
condition = command["args"].get("ConditionalExpression", "")
match = re.fullmatch(r"thisBlockNumber\s*==\s*(\d+)", condition) if isinstance(condition, str) else None
if command["op"] == "Goto" and match:
path = command["args"]["Path"]
if not isinstance(path, dict) or "template" in path:
raise CompatibilityError("naninovel", [Issue("tomie_board", "Динамический адрес клетки поля", str(command["line"]))])
routes[match[1]] = {"script": path.get("name") or "DicePlaySub", "label": path.get("value") or ""}
data["board_routes"] = routes
# Browser-native message dialog returns to the source's app screen, exactly
# as the two-command bridge in the recovered port does.
data["scripts"]["PGamesMessage"] = {"labels": {}, "label_depths": {}, "commands": [
{"op": "Gosub", "args": {"Path": "MessagePlay"}, "line": -1},
{"op": "Goto", "args": {"Path": "AppScreen"}, "line": -1},
]}
return data
def identity(value):
return "tomie-" + hashlib.sha256(value.encode()).hexdigest()[:24]
def modules(data, texts):
result = {}
for name, script in data["scripts"].items():
body = []
for index, command in enumerate(script["commands"]):
node = {"op": "nani_command", "id": identity(f"{name}:{index}"), "command": copy.deepcopy(command)}
field = TEXT_FIELDS.get(command["op"])
value = command["args"].get(field) if field else None
if value is not None:
node.update(what=value["template"] if isinstance(value, dict) else str(value),
tid=command.get("tid") or identity(f"text:{name}:{index}"), textField=field,
whoStr=str(command["args"].get("AuthorId", "")))
body.append(node)
path = f"script/nani/{identity(name)}.json"
result[path] = {"file": name + ".nani", "labels": [
{"name": "nani:" + name, "id": identity("script:" + name), "body": body}],
"nani": {"name": name, "labels": script["labels"], "label_depths": script.get("label_depths", {}),
"label_ids": {label: body[index]["id"] if index < len(body) else ""
for label, index in script["labels"].items()}}}
resources = []
for tid, record in texts.items():
resources.append({"op": "say", "id": identity(tid), "tid": tid, "what": record["text"],
"whoStr": record["collection"], "resource": copy.deepcopy(record)})
result["script/nani/resources.json"] = {"file": "_resources.nani", "labels": [
{"name": "nani:resources", "id": identity("resources"), "body": resources}]}
return result
def unpack_modules(scripts):
"""Rebuild the VM input from editable commands, including edited prose."""
result = {}
for module in scripts.values():
metadata = module.get("nani")
if metadata is None:
continue
if not isinstance(metadata, dict) or not isinstance(metadata.get("name"), str) or metadata["name"] in result:
raise ValueError("Некорректный модуль Naninovel")
commands, positions = [], {}
for label in module["labels"]:
for node in label["body"]:
if node.get("op") != "nani_command" or not isinstance(node.get("command"), dict):
raise ValueError("Модуль Tomie должен содержать исходные команды Naninovel")
command = copy.deepcopy(node["command"])
positions[node.get("id")] = len(commands)
if not isinstance(command.get("op"), str) or not isinstance(command.get("args"), dict):
raise ValueError("Некорректная команда Naninovel")
field = TEXT_FIELDS.get(command["op"])
if field and field in command["args"] and "what" not in node:
raise ValueError("Отсутствует редактируемый текст команды Naninovel")
if field and "what" in node:
if node.get("textField") != field or not isinstance(node["what"], str):
raise ValueError("Некорректное поле локализации Naninovel")
command["args"][field] = {"template": node["what"]}
command["tid"] = node.get("tid")
commands.append(command)
labels, depths = metadata.get("labels"), metadata.get("label_depths", {})
if not isinstance(labels, dict):
raise ValueError("Некорректные метки Naninovel")
identities = metadata.get("label_ids")
if identities is not None:
if not isinstance(identities, dict) or set(identities) != set(labels or {}) or any(
not isinstance(value, str) or (value and value not in positions) for value in identities.values()):
raise ValueError("Удалена команда, на которую ссылается метка Naninovel")
labels = {key: positions[value] if value else len(commands) for key, value in identities.items()}
if not isinstance(labels, dict) or any(not isinstance(key, str) or type(value) is not int or not 0 <= value <= len(commands) for key, value in labels.items()):
raise ValueError("Некорректный адрес метки Naninovel")
if not isinstance(depths, dict) or any(key not in labels or type(value) is not int or not 0 <= value <= 64 for key, value in depths.items()):
raise ValueError("Некорректная вложенность метки Naninovel")
result[metadata["name"]] = {"commands": commands, "labels": labels, "label_depths": depths}
return result
-158
View File
@@ -1,158 +0,0 @@
"""Version-pinned Tomie metadata. Reads IL constants; never executes managed code."""
import hashlib
import re
from pathlib import Path
PROFILE = "tomie/expansion-1.3801"
ASSEMBLY_HASH = "1c6aec12894362f1b9c62858a5a4c286eb93aee175ed1ef339218dc5a7b2729b"
FUNCTIONS = set("WillpowerDamage RoundValue MinMax Random NewRandList AddRandList MergeRandList HasValue StringContain StringReplace ItemExist itemExist ItemCount AddListString AddListString2 AddListStringWithRemoveDot RemoveListString StringContains StringReplaceSeed IntParse GetListCount GetArrayCount GetFromArray GetFromRandPick2 GetFromRandPick3 StringSplitComma RandPick RandPick2 RandSort RandPicks GetRandValue GetRandList GetRandListViaIndex ExtractRandList GetTargetList Pow Round random Rand HasLabel GetName GetRandName GetNormalComment GetLewdComment GetVODFileCount GetVODFileNameVidIndex GetMODFileNames GetModList OpenURL IsChristmas GetVODFileNames HasVideo ResetString SumString".split())
def identify(root: Path) -> str | None:
assembly = root / "TomieWGM_Data" / "Managed" / "Assembly-CSharp.dll"
if assembly.is_file() and hashlib.sha256(assembly.read_bytes()).hexdigest() == ASSEMBLY_HASH:
return PROFILE
return None
def constant_value(instructions, user_string):
"""Only a constant string / string-array initializer, not an IL interpreter."""
stack = []
for instruction in instructions:
op, value = instruction.mnemonic, instruction.operand
if op == "nop":
continue
if op in {"ldc.i4", "ldc.i4.s"}:
stack.append(int(value))
elif op.startswith("ldc.i4."):
stack.append(-1 if op.endswith("m1") else int(op.rsplit(".", 1)[-1]))
elif op == "ldstr":
stack.append(user_string(value.rid))
elif op == "newarr":
size = stack.pop()
if not isinstance(size, int) or not 0 <= size <= 100000:
raise ValueError("Некорректный размер таблицы Tomie")
stack.append([None] * size)
elif op == "dup":
stack.append(stack[-1])
elif op == "stelem.ref":
element, index, array = stack.pop(), stack.pop(), stack.pop()
if not isinstance(array, list) or not 0 <= index < len(array) or not isinstance(element, str):
raise ValueError("Некорректный элемент таблицы Tomie")
array[index] = element
else:
raise ValueError(f"Неизвестная инициализация таблицы Tomie: {op}")
if len(stack) != 1 or not isinstance(stack[0], (str, list)):
raise ValueError("Не удалось восстановить таблицу Tomie")
if isinstance(stack[0], list) and any(not isinstance(value, str) for value in stack[0]):
raise ValueError("Неполная таблица Tomie")
return stack[0]
def extract_tables(root: Path) -> dict[str, list[str]]:
if identify(root) != PROFILE:
raise ValueError("Версия Assembly-CSharp.dll не соответствует адаптеру Tomie")
import dnfile
from dncil.cil.body.reader import read_method_body_from_bytes
pe = dnfile.dnPE(str(root / "TomieWGM_Data/Managed/Assembly-CSharp.dll"))
try:
definition = next(row for row in pe.net.mdtables.TypeDef.rows
if str(row.TypeNamespace) == "Naninovel" and str(row.TypeName) == "KBCustom")
initializer = next(method.row for method in definition.MethodList if str(method.row.Name) == ".cctor")
body = read_method_body_from_bytes(pe.get_data(initializer.Rva))
wanted = {"names", "normalComment", "lewdComment", "VODstr"}
tables, segment = {}, []
for instruction in body.instructions:
if instruction.mnemonic == "stsfld":
field = str(pe.net.mdtables.Field.rows[instruction.operand.rid - 1].Name)
if field in wanted:
if field in tables:
raise ValueError(f"Повторная инициализация таблицы Tomie: {field}")
tables[field] = constant_value(segment, lambda rid: pe.net.user_strings.get(rid).value)
segment = []
else:
segment.append(instruction)
if set(tables) != wanted:
raise ValueError("Не найдены все исходные таблицы Tomie")
tables["videos"] = tables.pop("VODstr").split(",")
return tables
finally:
pe.close()
def runtime_data(package) -> dict:
"""Keep the original flat command machine and exact label nesting depths."""
if package.profile != PROFILE or not package.defaults or not package.function_data:
raise ValueError("Не загружены исходные настройки проверенной сборки Tomie")
scripts = {}
for name, source in package.scripts.items():
commands, labels, depths, depth = [], {}, {}, 0
for line in source["lines"]:
if line.get("label"):
labels.setdefault(line["label"], len(commands))
depths.setdefault(line["label"], depth)
for index, command in enumerate(line["commands"]):
identity = hashlib.sha256(f"{name}:{line['line']}:{index}".encode()).hexdigest()[:24]
commands.append({**command, "line": line["line"], "tid": "nani-" + identity})
if command["op"] == "BeginIf":
depth += 1
elif command["op"] == "EndIf":
depth = max(0, depth - 1)
scripts[name] = {"commands": commands, "labels": labels, "label_depths": depths}
if "StartGameSetting" not in scripts or "Start" not in scripts["StartGameSetting"]["labels"]:
raise ValueError("Не найдено исходное создание персонажа Tomie")
return {"scripts": scripts, "defaults": package.defaults, "function_data": package.function_data,
"start": {"script": "StartGameSetting", "label": "Start"},
"initial_inventory": {"item_Phone": 1, "item_StudentID": 1, "item_Wardrobe": 1,
"item_Book": 1, "item_SecretDiary": 1}}
def validate_expressions(scripts: dict) -> dict:
"""Check the full recovered expression corpus, including dynamic templates."""
from collections import Counter
from app.converter.nani_expr import assignment, parse
expressions, errors = {}, Counter()
examples = {}
def add(source, location):
try:
expressions[source] = parse(source, FUNCTIONS)
except ValueError as exc:
errors[str(exc)] += 1
examples.setdefault(str(exc), location)
def templates(value, location):
if isinstance(value, dict):
if "template" in value:
for match in re.finditer(r"\{([^{}]+)\}", value["template"]):
add(match[1], location)
else:
for child in value.values():
templates(child, location)
elif isinstance(value, list):
for child in value:
templates(child, location)
for name, script in scripts.items():
for command in script["commands"]:
args, op = command["args"], command["op"]
location = f"{name}:{command.get('line', -1)}"
templates(args, location)
value = args.get("Expression")
if isinstance(value, str):
if op == "SetCustomVariable":
try:
expression = assignment(value, FUNCTIONS)["v"]["ast"]
expressions[value.split("=", 1)[1].strip()] = expression
except ValueError as exc:
errors[str(exc)] += 1
examples.setdefault(str(exc), location)
else:
add(value, location)
if isinstance(args.get("ConditionalExpression"), str):
add(args["ConditionalExpression"], location)
return {"count": len(expressions), "asts": expressions,
"issues": [{"message": message, "count": count, "location": examples[message]}
for message, count in errors.items()]}
-153
View File
@@ -1,153 +0,0 @@
"""Fail-closed validation of the command surface implemented by the Tomie VM.
The assembly hash selects the adapter; it does not exempt mutable script assets
from validation. Unknown command arguments are rejected along with unknown ops.
"""
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
ARGUMENTS = {
"ModifyBackground": "AppearanceAndTransition Duration Scale TintColor",
"ModifyTextPrinter": "Duration HideOther IdAndAppearance MakeDefault ScenePosition",
"ModifyCharacter": "Duration IdAndAppearance LookDirection Scale ScenePosition TintColor Transition Visible",
"PrintText": "AuthorId LineBreaks ResetPrinter RevealSpeed Text WaitForInput",
"SkipInput": "", "AddChoice": "AutoPlay ButtonPath ButtonPosition ChoiceSummary Duration GotoPath HandlerId OnSelected ShowHandler",
"Stop": "", "SetCustomVariable": "Expression", "HideAllCharacters": "Duration",
"BeginIf": "Expression", "Else": "", "EndIf": "", "AppendLineBreak": "Count",
"Spawn": "Params Path", "Wait": "WaitMode", "ElseIf": "Expression",
"Gosub": "Path", "Goto": "Path", "Return": "", "DestroySpawned": "Path",
"HideActors": "ActorIds Duration", "PlaySfx": "Duration FadeInDuration Loop SfxPath Volume",
"WaitForInput": "", "HidePrinter": "", "AddItem": "Amount ItemId",
"PlayVoice": "VoicePath Volume", "ClearChoiceHandler": "HandlerId Hide",
"StopBgm": "FadeOutDuration", "PlayBgm": "BgmPath Duration FadeInDuration Loop Volume",
"ShowToastUI": "Appearance Duration Text", "ModifyCamera": "Duration Zoom", "Skip": "Enable",
"HideUI": "AllowToggle Duration UINames", "ProcessInput": "InputEnabled",
"AutoSave": "", "ExitToTitle": "", "StopSfx": "FadeOutDuration",
"RemoveItem": "Amount ItemId", "ShowUI": "UINames", "ShowPrinter": "",
"InputCustomVariable": "PlayOnSubmit Summary VariableName", "ClearBacklog": "", "AppendText": "Text",
}
EFFECTS = set("ShakeCamera ShakePrinter ShakeCharacter DigitalGlitch PinkFlash RedFlash WhiteFlash Hands1 Hands2 Hands3 Rain EffectSuccess EffectFailed EffectHeart Gaze SkillUp LewdInswitterComment PregMiniGame CamShowUI".split())
UI_NAMES = {"MStat", "Calendar", "AppUI", "CGGalleryUI", "Inventory"}
def inline_commands(value):
"""Same bounded comma/quote splitting as the browser's choice interpreter."""
if isinstance(value, list):
if not all(isinstance(part, str) for part in value):
raise ValueError("Неверный список команд выбора")
return [part.strip() for part in value if part.strip()]
if not isinstance(value, str) or len(value) > 30000:
raise ValueError("Неверная команда выбора")
result, start, depth, quote, escaped = [], 0, 0, "", False
for index, char in enumerate(value):
if escaped:
escaped = False
continue
if quote:
if char == "\\":
escaped = True
elif char == quote:
quote = ""
elif char in "\"'":
quote = char
elif char in "({":
depth += 1
elif char in ")}":
depth -= 1
if depth < 0:
raise ValueError("Некорректные скобки команды выбора")
elif char == "," and depth == 0:
result.append(value[start:index].strip())
start = index + 1
if depth or quote:
raise ValueError("Незакрытая команда выбора")
result.append(value[start:].strip())
return [item for item in result if item]
def validate_runtime(data):
scripts, issues = data["scripts"], []
def issue(code, message, location):
issues.append(Issue(code, message, location))
def target(value, script, location):
if isinstance(value, dict):
if "template" in value:
return # expressions checked separately; the VM checks the resolved target
name, label = value.get("name") or script, value.get("value") or ""
elif isinstance(value, str):
name, _, label = value.partition(".")
name = name or script
else:
issue("invalid_target", "Неверный переход Naninovel", location)
return
if name not in scripts or (label and label not in scripts[name]["labels"]):
issue("missing_target", f"Не найден переход {name}.{label}", location)
def inline(value, script, location):
try:
if isinstance(value, dict) and isinstance(value.get("template"), str):
# The template is parsed after interpolation by the VM. Its
# expressions are validated by validate_expressions below.
value = value["template"]
for command in inline_commands(value):
if not command.startswith("@"):
continue
name, _, body = command.partition(" ")
if name not in {"@set", "@goto", "@gosub", "@return", "@hideChars", "@addItem", "@additem", "@removeItem", "@voice", "@hide"}:
raise ValueError(f"Неизвестная команда выбора {name}")
if name == "@set" and "{" not in body:
assignment(body, FUNCTIONS)
elif name in {"@goto", "@gosub"} and "{" not in body:
target(body, script, location)
for match in re.finditer(r"\{([^{}]+)\}", body):
parse(match[1], FUNCTIONS)
except ValueError as exc:
issue("unsupported_choice", str(exc), location)
for name, script in scripts.items():
for index, command in enumerate(script["commands"]):
op, args = command["op"], command["args"]
location = f"{name}:{command.get('line', -1)}"
if op not in ARGUMENTS:
issue("unsupported_command", f"Не поддерживается команда {op}", location)
continue
extra = set(args) - set(ARGUMENTS[op].split()) - {"Wait", "ConditionalExpression"}
if extra:
issue("unsupported_argument", f"Не поддерживаются параметры {op}: {', '.join(sorted(extra))}", location)
if op in {"Spawn", "DestroySpawned"}:
effect = args.get("Path")
if not isinstance(effect, str) or effect not in EFFECTS:
issue("unsupported_effect", "Не поддерживается эффект Tomie: " + str(effect), location)
if op in {"ShowUI", "HideUI"}:
names = args.get("UINames", [])
if not isinstance(names, list) or any(not isinstance(value, str) or value not in UI_NAMES for value in names):
issue("unsupported_ui", "Не поддерживается интерфейс Tomie: " + str(names), location)
if op in {"BeginIf", "Else", "ElseIf"}:
# Native Naninovel seeks in the playlist instead of retaining a
# branch stack. An unmatched EndIf is a valid no-op; a branch
# without a forward closing EndIf cannot be traversed safely.
depth = 0
for following in script["commands"][index + 1:]:
if following["op"] == "BeginIf":
depth += 1
elif following["op"] == "EndIf":
if depth == 0:
break
depth -= 1
else:
issue("invalid_branch", f"{op} не имеет завершающего EndIf", location)
if op in {"Goto", "Gosub"}:
target(args.get("Path"), name, location)
if op == "AddChoice":
if "GotoPath" in args:
target(args["GotoPath"], name, location)
inline(args.get("OnSelected", []), name, location)
expressions = validate_expressions(scripts)
issues.extend(Issue("unsupported_expression", item["message"], item["location"], item["count"])
for item in expressions["issues"])
return issues