"""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 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())