323 lines
10 KiB
Python
323 lines
10 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"}
|
||
|
||
# WOLF RPG Editor markers. Games ship data inside .wolf archives (one Data.wolf,
|
||
# or split BasicData/MapData/... .wolf files), or as a loose Data/ folder holding
|
||
# the unpacked binaries (CommonEvent.dat, *.mps maps, *.project databases).
|
||
_WOLF_LOOSE_MARKERS = ("BasicData/CommonEvent.dat", "CommonEvent.dat")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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", "WOLF", "UNKNOWN".
|
||
data_path is None when nothing is found.
|
||
|
||
For "WOLF" the data_path is the loose Data/ folder when already unpacked, or
|
||
the game root when only .wolf archives are present (still packed).
|
||
"""
|
||
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"
|
||
|
||
# ---- WOLF RPG Editor: loose Data/ or .wolf archives ----
|
||
wolf = detect_wolf_layout(root)
|
||
if wolf["engine"] == "WOLF":
|
||
return (wolf["data_dir"] or root), "WOLF"
|
||
|
||
# ---- 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 find_wolf_archives(game_root: str | Path) -> list[Path]:
|
||
"""Return .wolf archive files in the game root (and its Data/ subfolder)."""
|
||
root = Path(game_root)
|
||
archives: list[Path] = []
|
||
for base in (root, root / "Data"):
|
||
if base.is_dir():
|
||
archives.extend(sorted(base.glob("*.wolf")))
|
||
# De-dupe while preserving order.
|
||
seen: set[Path] = set()
|
||
unique: list[Path] = []
|
||
for a in archives:
|
||
rp = a.resolve()
|
||
if rp not in seen:
|
||
seen.add(rp)
|
||
unique.append(a)
|
||
return unique
|
||
|
||
|
||
def _wolf_loose_data_dir(root: Path) -> Optional[Path]:
|
||
"""Return the loose (unpacked) WOLF Data/ folder if it looks unpacked."""
|
||
for data_dir in (root / "Data", root):
|
||
if not data_dir.is_dir():
|
||
continue
|
||
for marker in _WOLF_LOOSE_MARKERS:
|
||
if (data_dir / marker).is_file():
|
||
return data_dir
|
||
# A folder with .mps maps (directly or under MapData/) is unpacked WOLF.
|
||
if list(data_dir.glob("*.mps")) or list((data_dir / "MapData").glob("*.mps")):
|
||
return data_dir
|
||
return None
|
||
|
||
|
||
def detect_wolf_layout(game_root: str | Path) -> dict:
|
||
"""Inspect *game_root* for a WOLF RPG Editor game.
|
||
|
||
Returns a dict:
|
||
engine : "WOLF" or "UNKNOWN"
|
||
root : Path to the game root
|
||
archives : list[Path] of .wolf archives found (may be empty)
|
||
data_dir : Path to the loose/unpacked Data/ folder, or None
|
||
unpacked : bool - True when a usable loose Data/ folder exists
|
||
basic_data : Path to Data/BasicData (or Data/ when flattened), or None
|
||
map_data : Path to Data/MapData (or Data/ when flattened), or None
|
||
"""
|
||
root = Path(game_root)
|
||
result = {
|
||
"engine": "UNKNOWN",
|
||
"root": root,
|
||
"archives": [],
|
||
"data_dir": None,
|
||
"unpacked": False,
|
||
"basic_data": None,
|
||
"map_data": None,
|
||
}
|
||
if not root.is_dir():
|
||
return result
|
||
|
||
archives = find_wolf_archives(root)
|
||
loose = _wolf_loose_data_dir(root)
|
||
|
||
if not archives and loose is None:
|
||
return result
|
||
|
||
result["engine"] = "WOLF"
|
||
result["archives"] = archives
|
||
if loose is not None:
|
||
result["data_dir"] = loose
|
||
result["unpacked"] = True
|
||
basic = loose / "BasicData"
|
||
result["basic_data"] = basic if basic.is_dir() else loose
|
||
maps = loose / "MapData"
|
||
result["map_data"] = maps if maps.is_dir() else loose
|
||
return result
|
||
|
||
|
||
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
|