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
@@ -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)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""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
|
||||
@@ -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
|
||||
@@ -0,0 +1,121 @@
|
||||
"""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.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",
|
||||
"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, 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
|
||||
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
|
||||
@@ -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))
|
||||
@@ -0,0 +1,158 @@
|
||||
"""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()]}
|
||||
@@ -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
|
||||
@@ -0,0 +1,153 @@
|
||||
"""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.games.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
|
||||
Reference in new issue
Block a user