- 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>
146 lines
6.7 KiB
Python
146 lines
6.7 KiB
Python
"""Naninovel importer: compiled Unity data -> validated PGVN package."""
|
|
import json
|
|
import tempfile
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
from app.blobs import put_bytes, put_file
|
|
from app.converter import assets, locales
|
|
from app.converter.compatibility import CompatibilityError, Issue
|
|
from app.converter.nani_compiler import compile_package
|
|
from app.converter.nani_package import read_package
|
|
|
|
|
|
def inspect_naninovel(root: Path, *, work: Path | None = None) -> dict:
|
|
if work is None:
|
|
with tempfile.TemporaryDirectory(prefix="pgames-naninovel-") as directory:
|
|
return inspect_naninovel(root, work=Path(directory))
|
|
package = read_package(root, work, media=True)
|
|
try:
|
|
data, entry, _ = compile_package(package)
|
|
resolve_media(package, data)
|
|
return {"engine": "naninovel", "compatible": True, "scripts": len(package.scripts),
|
|
"textResources": len(package.texts), "entry": entry,
|
|
"commands": sum(len(line["commands"]) for script in package.scripts.values() for line in script["lines"]),
|
|
"warnings": dict(data.warnings)}
|
|
finally:
|
|
package.close()
|
|
|
|
|
|
def resolve_media(package, data):
|
|
"""Resolve every resource before decoding or transcoding any of them."""
|
|
wanted = {image["file"] for image in data.images.values() if image.get("file")}
|
|
for modules in data.modules.values():
|
|
for label in modules:
|
|
for node in locales.walk(label["body"]):
|
|
if node["op"] == "play":
|
|
wanted.update(value["v"] for value in node["files"])
|
|
candidates = {}
|
|
for source, obj in package.resources:
|
|
try:
|
|
name = obj.peek_name()
|
|
except (ValueError, AttributeError):
|
|
continue
|
|
if name:
|
|
candidates.setdefault(name, []).append((source, obj))
|
|
resolved, issues = {}, []
|
|
for name in sorted(wanted):
|
|
matches = candidates.get(name, candidates.get(name.rsplit("/", 1)[-1], []))
|
|
if len(matches) != 1:
|
|
issues.append(Issue("ambiguous_media" if matches else "missing_media",
|
|
"Неоднозначный ресурс" if matches else "Не найден ресурс", name))
|
|
elif matches[0][1].type.name not in {"Texture2D", "AudioClip"}:
|
|
issues.append(Issue("unsupported_media", "Тип медиа пока не поддерживается", name))
|
|
else:
|
|
resolved[name] = matches[0]
|
|
if issues:
|
|
raise CompatibilityError("naninovel", issues)
|
|
return resolved
|
|
|
|
|
|
def export_media(package, data, root: Path, work: Path, progress):
|
|
resolved = resolve_media(package, data)
|
|
files, aliases, issues = {}, {}, []
|
|
out = work / "media"
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
for index, (name, (source, obj)) in enumerate(resolved.items()):
|
|
progress(f"Медиа Naninovel {index + 1}/{len(resolved)}")
|
|
try:
|
|
asset = obj.read()
|
|
stem = str(index)
|
|
if obj.type.name == "Texture2D":
|
|
path = out / (stem + ".png")
|
|
asset.image.save(path)
|
|
elif obj.type.name == "AudioClip":
|
|
samples = asset.samples
|
|
if len(samples) != 1:
|
|
raise ValueError("Аудиоресурс не содержит единственный клип")
|
|
filename, raw = next(iter(samples.items()))
|
|
path = out / (stem + (Path(filename).suffix or ".wav"))
|
|
path.write_bytes(raw)
|
|
else:
|
|
raise ValueError(f"Тип медиа {obj.type.name} пока не поддерживается")
|
|
converted, ext = assets.convert(path, out)
|
|
digest, size = put_file(converted, ext, move=True)
|
|
logical = "media/" + stem + ext
|
|
files[logical] = {"hash": digest, "size": size}
|
|
aliases[name] = logical
|
|
except Exception as exc:
|
|
issues.append(Issue("media_error", f"Не удалось извлечь медиа: {exc}", name))
|
|
if issues:
|
|
raise CompatibilityError("naninovel", issues)
|
|
for image in data.images.values():
|
|
if image.get("file"):
|
|
image["file"] = aliases[image["file"]]
|
|
for modules in data.modules.values():
|
|
for label in modules:
|
|
for node in locales.walk(label["body"]):
|
|
if node["op"] == "play":
|
|
for value in node["files"]:
|
|
value["v"] = aliases[value["v"]]
|
|
return files, aliases
|
|
|
|
|
|
def convert_naninovel(root: Path, work: Path, progress):
|
|
progress("Чтение сценариев Naninovel и проверка совместимости")
|
|
package = read_package(root, work / "unity", media=True)
|
|
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
|
|
|
|
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)
|
|
locales.assign_ids(data.modules)
|
|
scripts = []
|
|
|
|
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}
|
|
|
|
for path, labels in data.modules.items():
|
|
logical = "script/" + str(PurePosixPath(path).with_suffix(".json"))
|
|
store(logical, {"file": path, "labels": labels, "anchors": anchors(labels)})
|
|
scripts.append(logical)
|
|
source = locales.detect_language(data.modules) or "und"
|
|
resources = {}
|
|
for tid, record in package.texts.items():
|
|
resources.setdefault(record["collection"], {}).setdefault(record["key"], []).append({"tid": tid, **record})
|
|
game = {"format": "pgvn/1", "engine": "naninovel", "title": root.name,
|
|
"resolution": [1920, 1080], "entry": entry, "scripts": scripts,
|
|
"characters": data.characters, "images": data.images, "transforms": {},
|
|
"transitions": {}, "audio": {}, "files": aliases, "defaults": [], "init": [],
|
|
"ui": {}, "sourceLang": source, "locales": {}, "naniExpressions": expressions,
|
|
"naniResources": resources}
|
|
store("game.json", game)
|
|
total = sum(entry["size"] for entry in files.values())
|
|
report = {"engine": "naninovel", "compatible": True, "format": "pgvn/1",
|
|
"scripts": len(package.scripts), "textResources": len(package.texts),
|
|
"media": len(aliases), "warnings": []}
|
|
return Result({"format": "pgvn/1", "files": files}, report, root.name, None, total)
|