- 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>
129 lines
6.4 KiB
Python
129 lines
6.4 KiB
Python
"""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
|