235 lines
6.9 KiB
Python
235 lines
6.9 KiB
Python
"""
|
||
Project Scanner - Detect game engine and import/export data files.
|
||
|
||
RPGMaker layouts scanned (in priority order):
|
||
MV/MZ : <root>/www/data/ or <root>/data/
|
||
Ace : <root>/Data/
|
||
XP/VX : <root>/Data/ (rvdata/rxdata - not currently handled)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import shutil
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# File categories used to decide what to import by default
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Core name/database files – almost always needed
|
||
CORE_FILES = {
|
||
"Actors.json",
|
||
"Armors.json",
|
||
"Classes.json",
|
||
"Enemies.json",
|
||
"Items.json",
|
||
"MapInfos.json",
|
||
"Skills.json",
|
||
"States.json",
|
||
"System.json",
|
||
"Troops.json",
|
||
"Weapons.json",
|
||
"CommonEvents.json",
|
||
"Animations.json",
|
||
"Tilesets.json",
|
||
}
|
||
|
||
# Map files – large but contain the bulk of dialogue
|
||
MAP_PATTERN = "Map[0-9]*.json"
|
||
|
||
# Engine detection markers
|
||
_MVMZ_MARKERS = { # files / dirs that hint at MV or MZ
|
||
"www", # MV web build
|
||
"package.json", # MZ desktop
|
||
"Game.rpgproject", # MV
|
||
"game.rmmzproject", # MZ
|
||
}
|
||
_ACE_MARKERS = {
|
||
"Game.rgss3a",
|
||
"Game.exe", # not conclusive but common
|
||
}
|
||
_ACE_DATA_SCRIPTS = {".rvdata2", ".rvdata"}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Public API
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def find_data_folder(game_root: str | Path) -> tuple[Optional[Path], str]:
|
||
"""Return (data_path, engine_name) for *game_root*.
|
||
|
||
engine_name is one of: "MVMZ", "ACE", "UNKNOWN".
|
||
data_path is None when nothing is found.
|
||
"""
|
||
root = Path(game_root)
|
||
if not root.is_dir():
|
||
return None, "UNKNOWN"
|
||
|
||
# ---- RPGMaker MV/MZ: www/data or data ----
|
||
for candidate in (root / "www" / "data", root / "data", root / "Data"):
|
||
if candidate.is_dir() and _has_json_data(candidate):
|
||
# Confirm engine variant
|
||
engine = "MVMZ"
|
||
for m in _MVMZ_MARKERS:
|
||
if (root / m).exists():
|
||
engine = "MVMZ"
|
||
break
|
||
return candidate, engine
|
||
|
||
# ---- RPGMaker Ace: Data/ with .rvdata2 ----
|
||
ace_data = root / "Data"
|
||
if ace_data.is_dir():
|
||
rvdata = list(ace_data.glob("*.rvdata2")) + list(ace_data.glob("*.rvdata"))
|
||
if rvdata:
|
||
return ace_data, "ACE"
|
||
|
||
# ---- Fallback: any sub-directory that holds .json files ----
|
||
for child in root.iterdir():
|
||
if child.is_dir() and _has_json_data(child):
|
||
return child, "UNKNOWN"
|
||
|
||
return None, "UNKNOWN"
|
||
|
||
|
||
def list_data_files(data_path: str | Path, engine: str = "MVMZ") -> list[dict]:
|
||
"""Return a sorted list of importable file descriptors.
|
||
|
||
Each item: {"name": str, "path": Path, "size_kb": float,
|
||
"category": "core" | "map" | "other", "default": bool}
|
||
"""
|
||
data_path = Path(data_path)
|
||
results: list[dict] = []
|
||
|
||
if engine == "ACE":
|
||
# Ace uses binary rvdata2; not JSON-based, skip for now
|
||
return results
|
||
|
||
seen: set[str] = set()
|
||
for fp in sorted(data_path.iterdir()):
|
||
if not fp.is_file():
|
||
continue
|
||
if fp.suffix.lower() != ".json":
|
||
continue
|
||
name = fp.name
|
||
if name in seen:
|
||
continue
|
||
seen.add(name)
|
||
|
||
size_kb = fp.stat().st_size / 1024
|
||
if name in CORE_FILES:
|
||
cat = "core"
|
||
default = True
|
||
elif fp.match(MAP_PATTERN):
|
||
cat = "map"
|
||
default = True
|
||
else:
|
||
cat = "other"
|
||
default = False
|
||
|
||
results.append({
|
||
"name": name,
|
||
"path": fp,
|
||
"size_kb": round(size_kb, 1),
|
||
"category": cat,
|
||
"default": default,
|
||
})
|
||
|
||
# Sort: core first, then maps (numeric), then other
|
||
def _sort_key(item):
|
||
if item["category"] == "core":
|
||
return (0, item["name"])
|
||
if item["category"] == "map":
|
||
# extract numeric part for natural order
|
||
digits = "".join(c for c in item["name"] if c.isdigit()) or "0"
|
||
return (1, int(digits))
|
||
return (2, item["name"])
|
||
|
||
results.sort(key=_sort_key)
|
||
return results
|
||
|
||
|
||
def import_to_files(
|
||
file_items: list[dict],
|
||
dest_dir: str | Path = "files",
|
||
overwrite: bool = True,
|
||
) -> tuple[int, list[str]]:
|
||
"""Copy *file_items* (from list_data_files) into *dest_dir*.
|
||
|
||
Returns (count_copied, list_of_errors).
|
||
"""
|
||
dest = Path(dest_dir)
|
||
dest.mkdir(parents=True, exist_ok=True)
|
||
copied = 0
|
||
errors: list[str] = []
|
||
|
||
for item in file_items:
|
||
src: Path = item["path"]
|
||
dst = dest / item["name"]
|
||
try:
|
||
if not overwrite and dst.exists():
|
||
continue
|
||
shutil.copy2(src, dst)
|
||
copied += 1
|
||
except Exception as exc:
|
||
errors.append(f"{item['name']}: {exc}")
|
||
|
||
return copied, errors
|
||
|
||
|
||
def export_to_game(
|
||
translated_dir: str | Path,
|
||
game_data_path: str | Path,
|
||
filenames: Optional[list[str]] = None,
|
||
overwrite: bool = True,
|
||
) -> tuple[int, list[str]]:
|
||
"""Copy translated files back into the game's data folder.
|
||
|
||
If *filenames* is None, all .json files in *translated_dir* are copied.
|
||
Returns (count_copied, list_of_errors).
|
||
"""
|
||
src_dir = Path(translated_dir)
|
||
dst_dir = Path(game_data_path)
|
||
copied = 0
|
||
errors: list[str] = []
|
||
|
||
if not src_dir.is_dir():
|
||
return 0, [f"Translated folder not found: {src_dir}"]
|
||
if not dst_dir.is_dir():
|
||
return 0, [f"Game data folder not found: {dst_dir}"]
|
||
|
||
candidates = (
|
||
[src_dir / fn for fn in filenames]
|
||
if filenames
|
||
else list(src_dir.glob("*.json"))
|
||
)
|
||
|
||
for fp in candidates:
|
||
if not fp.is_file():
|
||
continue
|
||
dst = dst_dir / fp.name
|
||
try:
|
||
if not overwrite and dst.exists():
|
||
continue
|
||
shutil.copy2(fp, dst)
|
||
copied += 1
|
||
except Exception as exc:
|
||
errors.append(f"{fp.name}: {exc}")
|
||
|
||
return copied, errors
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _has_json_data(folder: Path) -> bool:
|
||
"""Return True if the folder contains at least one .json data file."""
|
||
try:
|
||
for p in folder.iterdir():
|
||
if p.suffix.lower() == ".json":
|
||
return True
|
||
except PermissionError:
|
||
pass
|
||
return False
|