287 lines
10 KiB
Python
287 lines
10 KiB
Python
"""Download / update RPG Maker Ace tools from upstream GitHub releases.
|
|
|
|
RV2JSON.exe — https://github.com/Sinflower/RV2JSON (bin/RV2JSON.exe)
|
|
Decrypter CLI — https://github.com/uuksu/RPGMakerDecrypter (release asset)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
ACE_DIR = Path(__file__).resolve().parent
|
|
OFFLINE_DIR = ACE_DIR / "offline"
|
|
VERSION_FILE = ACE_DIR / ".tools_version.json"
|
|
|
|
RV2JSON_REPO = "Sinflower/RV2JSON"
|
|
RV2JSON_REMOTE = "bin/RV2JSON.exe"
|
|
RV2JSON_LOCAL = ACE_DIR / "RV2JSON.exe"
|
|
|
|
DECRYPTER_REPO = "uuksu/RPGMakerDecrypter"
|
|
DECRYPTER_ASSET = "RPGMakerDecrypter-cli.exe"
|
|
DECRYPTER_LOCAL = ACE_DIR / "RPGMakerDecrypter-cli.exe"
|
|
# Legacy name shipped in older DazedMTLTool commits (local only, not in git).
|
|
DECRYPTER_LEGACY = ACE_DIR / "RPGMakerDecrypter.exe"
|
|
|
|
USER_AGENT = "DazedMTLTool"
|
|
|
|
|
|
def _load_versions() -> dict:
|
|
if not VERSION_FILE.is_file():
|
|
return {}
|
|
try:
|
|
return json.loads(VERSION_FILE.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _save_versions(data: dict) -> None:
|
|
ACE_DIR.mkdir(parents=True, exist_ok=True)
|
|
VERSION_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
|
|
|
|
def _github_json(url: str) -> dict | list:
|
|
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/vnd.github+json"})
|
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
return json.loads(resp.read().decode("utf-8"))
|
|
|
|
|
|
def _download(url: str, dest: Path) -> None:
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
with urllib.request.urlopen(req, timeout=600) as resp, open(tmp, "wb") as fh:
|
|
shutil.copyfileobj(resp, fh)
|
|
tmp.replace(dest)
|
|
|
|
|
|
def _log(msg: str, log_fn) -> None:
|
|
if log_fn:
|
|
log_fn(msg)
|
|
else:
|
|
print(msg, flush=True)
|
|
|
|
|
|
def _seed_from_offline(local: Path, offline_name: str, log_fn) -> bool:
|
|
"""Copy a bundled offline exe into the active path if missing."""
|
|
if local.is_file():
|
|
return True
|
|
src = OFFLINE_DIR / offline_name
|
|
if not src.is_file():
|
|
return False
|
|
local.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(src, local)
|
|
_log(f"Using offline bundled {offline_name}", log_fn)
|
|
return True
|
|
|
|
|
|
def _rv2json_upstream() -> tuple[str, str]:
|
|
"""Return (blob_sha, download_url) for RV2JSON.exe on main."""
|
|
url = f"https://api.github.com/repos/{RV2JSON_REPO}/contents/{RV2JSON_REMOTE}?ref=main"
|
|
info = _github_json(url)
|
|
sha = info.get("sha", "")
|
|
dl = info.get("download_url") or ""
|
|
if not sha or not dl:
|
|
raise RuntimeError(f"Could not resolve {RV2JSON_REPO}/{RV2JSON_REMOTE}")
|
|
return sha, dl
|
|
|
|
|
|
def _decrypter_upstream() -> tuple[str, str]:
|
|
"""Return (release_tag, browser_download_url) for the Windows CLI decrypter."""
|
|
url = f"https://api.github.com/repos/{DECRYPTER_REPO}/releases/latest"
|
|
release = _github_json(url)
|
|
tag = release.get("tag_name") or release.get("name") or "latest"
|
|
for asset in release.get("assets") or []:
|
|
if asset.get("name") == DECRYPTER_ASSET:
|
|
dl = asset.get("browser_download_url") or ""
|
|
if dl:
|
|
return tag, dl
|
|
raise RuntimeError(f"No {DECRYPTER_ASSET} asset in latest {DECRYPTER_REPO} release")
|
|
|
|
|
|
def ensure_rv2json(force: bool = False, log_fn=print) -> bool:
|
|
"""Download RV2JSON.exe if missing or upstream changed. Returns True if ready."""
|
|
_seed_from_offline(RV2JSON_LOCAL, "RV2JSON.exe", log_fn)
|
|
versions = _load_versions()
|
|
try:
|
|
remote_sha, download_url = _rv2json_upstream()
|
|
except Exception as exc:
|
|
if RV2JSON_LOCAL.is_file():
|
|
_log(f"Warning: Could not check RV2JSON update ({exc}); using local copy.", log_fn)
|
|
return True
|
|
if _seed_from_offline(RV2JSON_LOCAL, "RV2JSON.exe", log_fn):
|
|
return True
|
|
_log(f"ERROR: RV2JSON unavailable ({exc}).", log_fn)
|
|
return False
|
|
|
|
local_sha = versions.get("rv2json_sha", "")
|
|
if RV2JSON_LOCAL.is_file() and not force and local_sha == remote_sha:
|
|
return True
|
|
|
|
_log(f"Downloading RV2JSON.exe ({RV2JSON_REPO})...", log_fn)
|
|
try:
|
|
_download(download_url, RV2JSON_LOCAL)
|
|
except Exception as exc:
|
|
if RV2JSON_LOCAL.is_file():
|
|
_log(f"Warning: RV2JSON update failed ({exc}); keeping existing copy.", log_fn)
|
|
return True
|
|
if _seed_from_offline(RV2JSON_LOCAL, "RV2JSON.exe", log_fn):
|
|
return True
|
|
_log(f"ERROR: RV2JSON download failed: {exc}", log_fn)
|
|
return False
|
|
|
|
versions["rv2json_sha"] = remote_sha
|
|
_save_versions(versions)
|
|
_log("RV2JSON.exe ready", log_fn)
|
|
return True
|
|
|
|
|
|
def ensure_decrypter(force: bool = False, log_fn=print) -> bool:
|
|
"""Download RPGMakerDecrypter CLI if missing or upstream release changed."""
|
|
_seed_from_offline(DECRYPTER_LOCAL, DECRYPTER_ASSET, log_fn)
|
|
if DECRYPTER_LOCAL.is_file():
|
|
pass # prefer CLI (offline bundle or download)
|
|
elif DECRYPTER_LEGACY.is_file():
|
|
_log("Using legacy RPGMakerDecrypter.exe (local).", log_fn)
|
|
return True
|
|
|
|
versions = _load_versions()
|
|
try:
|
|
tag, download_url = _decrypter_upstream()
|
|
except Exception as exc:
|
|
if DECRYPTER_LOCAL.is_file() or DECRYPTER_LEGACY.is_file():
|
|
_log(f"Warning: Could not check decrypter update ({exc}); using local copy.", log_fn)
|
|
return True
|
|
if _seed_from_offline(DECRYPTER_LOCAL, DECRYPTER_ASSET, log_fn):
|
|
return True
|
|
_log(f"ERROR: Decrypter unavailable ({exc}).", log_fn)
|
|
return False
|
|
|
|
local_tag = versions.get("decrypter_tag", "")
|
|
if DECRYPTER_LOCAL.is_file() and not force and local_tag == tag:
|
|
return True
|
|
|
|
_log(f"Downloading {DECRYPTER_ASSET} ({DECRYPTER_REPO} {tag})...", log_fn)
|
|
try:
|
|
_download(download_url, DECRYPTER_LOCAL)
|
|
except Exception as exc:
|
|
if DECRYPTER_LOCAL.is_file() or DECRYPTER_LEGACY.is_file():
|
|
_log(f"Warning: Decrypter update failed ({exc}); keeping existing copy.", log_fn)
|
|
return True
|
|
if _seed_from_offline(DECRYPTER_LOCAL, DECRYPTER_ASSET, log_fn):
|
|
return True
|
|
_log(f"ERROR: Decrypter download failed: {exc}", log_fn)
|
|
return False
|
|
|
|
versions["decrypter_tag"] = tag
|
|
_save_versions(versions)
|
|
_log("RPG Maker decrypter CLI ready", log_fn)
|
|
return True
|
|
|
|
|
|
def seed_ace_tools(log_fn=None) -> None:
|
|
"""Copy offline-bundled Ace tools if missing (no network)."""
|
|
_seed_from_offline(RV2JSON_LOCAL, "RV2JSON.exe", log_fn)
|
|
_seed_from_offline(DECRYPTER_LOCAL, DECRYPTER_ASSET, log_fn)
|
|
|
|
|
|
def check_ace_tools_update() -> bool:
|
|
"""Return True if upstream Ace tools differ from the local copies."""
|
|
seed_ace_tools()
|
|
versions = _load_versions()
|
|
|
|
try:
|
|
remote_sha, _ = _rv2json_upstream()
|
|
if not RV2JSON_LOCAL.is_file() or versions.get("rv2json_sha", "") != remote_sha:
|
|
return True
|
|
except Exception:
|
|
if not RV2JSON_LOCAL.is_file():
|
|
return True
|
|
|
|
try:
|
|
tag, _ = _decrypter_upstream()
|
|
if not (DECRYPTER_LOCAL.is_file() or DECRYPTER_LEGACY.is_file()):
|
|
return True
|
|
if DECRYPTER_LOCAL.is_file() and versions.get("decrypter_tag", "") != tag:
|
|
return True
|
|
except Exception:
|
|
if not (DECRYPTER_LOCAL.is_file() or DECRYPTER_LEGACY.is_file()):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def ensure_ace_tools(force: bool = False, log_fn=print) -> bool:
|
|
"""Ensure both Ace tools are present (download / update if needed)."""
|
|
ok_rv = ensure_rv2json(force=force, log_fn=log_fn)
|
|
ok_dec = ensure_decrypter(force=force, log_fn=log_fn)
|
|
return ok_rv and ok_dec
|
|
|
|
|
|
def ace_tool_path(name: str) -> Path:
|
|
"""Resolve a tool path under util/ace/."""
|
|
if name == "RV2JSON.exe":
|
|
return RV2JSON_LOCAL
|
|
if name in ("RPGMakerDecrypter.exe", "RPGMakerDecrypter-cli.exe"):
|
|
if DECRYPTER_LOCAL.is_file():
|
|
return DECRYPTER_LOCAL
|
|
if DECRYPTER_LEGACY.is_file():
|
|
return DECRYPTER_LEGACY
|
|
return DECRYPTER_LOCAL
|
|
return ACE_DIR / name
|
|
|
|
|
|
def build_decrypter_command(game_root: Path) -> list[str]:
|
|
"""Build argv to decrypt Game.rgss* in game_root."""
|
|
exe = ace_tool_path("RPGMakerDecrypter-cli.exe")
|
|
if not exe.is_file():
|
|
raise FileNotFoundError(
|
|
f"No RPG Maker decrypter found in {ACE_DIR}. "
|
|
"Run: python -m util.ace.update_tools"
|
|
)
|
|
rgss = sorted(game_root.glob("Game.rgss*"))
|
|
if not rgss:
|
|
raise FileNotFoundError(f"No Game.rgss* archive in {game_root}")
|
|
# Legacy GUI exe ran with no args; CLI needs the archive path.
|
|
if exe.name == DECRYPTER_LEGACY.name:
|
|
return [str(exe)]
|
|
return [str(exe), str(rgss[0])]
|
|
|
|
|
|
def refresh_offline_bundle(log_fn=print) -> bool:
|
|
"""Download upstream tools into util/ace/offline/ (for maintainers to commit)."""
|
|
OFFLINE_DIR.mkdir(parents=True, exist_ok=True)
|
|
ok = True
|
|
try:
|
|
_, rv_url = _rv2json_upstream()
|
|
_log("Refreshing offline RV2JSON.exe...", log_fn)
|
|
_download(rv_url, OFFLINE_DIR / "RV2JSON.exe")
|
|
except Exception as exc:
|
|
_log(f"ERROR: offline RV2JSON refresh failed: {exc}", log_fn)
|
|
ok = False
|
|
try:
|
|
_, dec_url = _decrypter_upstream()
|
|
_log(f"Refreshing offline {DECRYPTER_ASSET}...", log_fn)
|
|
_download(dec_url, OFFLINE_DIR / DECRYPTER_ASSET)
|
|
except Exception as exc:
|
|
_log(f"ERROR: offline decrypter refresh failed: {exc}", log_fn)
|
|
ok = False
|
|
if ok:
|
|
_log("Offline bundle refreshed in util/ace/offline/", log_fn)
|
|
return ok
|
|
|
|
|
|
def main() -> int:
|
|
if "--refresh-offline" in sys.argv:
|
|
return 0 if refresh_offline_bundle() else 1
|
|
force = "--force" in sys.argv or "-f" in sys.argv
|
|
ok = ensure_ace_tools(force=force)
|
|
return 0 if ok else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|