diff --git a/.gitignore b/.gitignore index 91f71ce..844c89b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ __pycache__ !vocab_base.txt !requirements.txt !util/tl_inspector/TLInspector.js +util/ace/*.exe +util/ace/.tools_version.json +!util/ace/offline/*.exe diff --git a/README.md b/README.md index 6dcfd1b..5ace3e1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ An AI-powered game translation tool with a GUI. Translate RPG Maker, Ren'Py, Tyr ## Credits -- **[Sinflower](https://github.com/Sinflower)** — [RV2JSON](https://github.com/Sinflower/RV2JSON) — enables RPGMaker Ace games to be translated the same way as MV/MZ by converting rvdata2 files to JSON and back. +- **[Sinflower](https://github.com/Sinflower)** — [RV2JSON](https://github.com/Sinflower/RV2JSON) — enables RPGMaker Ace games to be translated the same way as MV/MZ by converting rvdata2 files to JSON and back. A copy is bundled offline in `util/ace/offline/`; newer builds are downloaded when online. - **Sakura & Kao_SSS** — TL Inspector (`util/tl_inspector/`) — in-game translation source inspector and live-edit plugin for RPG Maker MV/MZ playtesting. ## Table of Contents diff --git a/START.bat b/START.bat index e2e3f3f..9919beb 100644 --- a/START.bat +++ b/START.bat @@ -178,6 +178,14 @@ if errorlevel 1 ( ) echo. +:: RPG Maker Ace tools (RV2JSON + decrypter) — downloaded from GitHub, not stored in git +echo Checking RPG Maker Ace tools... +python -m util.ace.update_tools +if errorlevel 1 ( + echo WARNING: Ace tool download failed. Ace translation features may not work until fixed. +) +echo. + :: Launch the GUI echo ========================================== echo Launching DazedMTLTool GUI... diff --git a/gui/main.py b/gui/main.py index 211752d..62bb8dd 100644 --- a/gui/main.py +++ b/gui/main.py @@ -132,6 +132,12 @@ class UpdateThread(QThread): shutil.copy2(src, dst) Path(self.SHA_FILE).write_text(latest_sha) + try: + from util.ace.update_tools import ensure_ace_tools + self.progress.emit("Updating RPG Maker Ace tools…") + ensure_ace_tools(log_fn=lambda m: self.progress.emit(m)) + except Exception as exc: + self.progress.emit(f"Ace tool update skipped: {exc}") self.finished.emit(True, f"updated:{latest_sha[:8]}") diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py index 7bc706c..bbdb8ba 100644 --- a/gui/workflow_tab.py +++ b/gui/workflow_tab.py @@ -3868,7 +3868,12 @@ class WorkflowTab(QWidget): @staticmethod def _ace_tool_path(name: str) -> Path: - return Path(__file__).resolve().parent.parent / "util" / "ace" / name + from util.ace.update_tools import ace_tool_path + return ace_tool_path(name) + + def _ensure_ace_tools(self) -> bool: + from util.ace.update_tools import ensure_ace_tools + return ensure_ace_tools(log_fn=self._log) def _show_ace_decrypt_notice(self, game_root: str, rgss_path: str): """Show a dialog explaining how to decrypt the encrypted Ace archive.""" @@ -3895,12 +3900,17 @@ class WorkflowTab(QWidget): self._run_ace_decrypter(game_root) def _run_ace_decrypter(self, game_root: str): - decrypter = self._ace_tool_path("RPGMakerDecrypter.exe") - if not decrypter.is_file(): - self._log(f"❌ RPGMakerDecrypter.exe not found at {decrypter}") + if not self._ensure_ace_tools(): return - self._log(f"Running RPGMakerDecrypter.exe in {game_root} …") - w = _SubprocessWorker([str(decrypter)], cwd=game_root, label="RPGMakerDecrypter") + try: + from util.ace.update_tools import build_decrypter_command + cmd = build_decrypter_command(Path(game_root)) + except FileNotFoundError as exc: + self._log(f"❌ {exc}") + return + decrypter = Path(cmd[0]) + self._log(f"Running {decrypter.name} in {game_root} …") + w = _SubprocessWorker(cmd, cwd=game_root, label=decrypter.stem) w.log.connect(self._log) w.done.connect(lambda ok, msg: self._log(("✅ " if ok else "❌ ") + msg)) self._worker = w @@ -3908,6 +3918,8 @@ class WorkflowTab(QWidget): def _run_rv2json_create(self): """Run RV2JSON.exe -c to convert rvdata2 → JSON files (run from game root).""" + if not self._ensure_ace_tools(): + return rv2json = self._ace_tool_path("RV2JSON.exe") if not rv2json.is_file(): self._log(f"❌ RV2JSON.exe not found at {rv2json}") @@ -3962,6 +3974,8 @@ class WorkflowTab(QWidget): def _run_rv2json_update(self): """Run RV2JSON.exe -u to write translated JSON back to rvdata2 files.""" + if not self._ensure_ace_tools(): + return rv2json = self._ace_tool_path("RV2JSON.exe") if not rv2json.is_file(): self._log(f"❌ RV2JSON.exe not found at {rv2json}") diff --git a/start_gui.py b/start_gui.py index 19860d9..1a7a3d4 100644 --- a/start_gui.py +++ b/start_gui.py @@ -43,6 +43,12 @@ def main(): # Check dependencies if not check_dependencies(): sys.exit(1) + + try: + from util.ace.update_tools import ensure_ace_tools + ensure_ace_tools() + except Exception as exc: + print(f"Warning: Ace tool setup failed ({exc}). Ace features may be unavailable.") # Import and run GUI try: diff --git a/util/ace/__init__.py b/util/ace/__init__.py new file mode 100644 index 0000000..eee6e89 --- /dev/null +++ b/util/ace/__init__.py @@ -0,0 +1,3 @@ +"""RPG Maker Ace helper binaries (downloaded on demand, not committed to git).""" + +__all__ = ["ace_tool_path", "build_decrypter_command", "ensure_ace_tools"] diff --git a/util/ace/offline/RPGMakerDecrypter-cli.exe b/util/ace/offline/RPGMakerDecrypter-cli.exe new file mode 100644 index 0000000..ada7c59 Binary files /dev/null and b/util/ace/offline/RPGMakerDecrypter-cli.exe differ diff --git a/util/ace/RV2JSON.exe b/util/ace/offline/RV2JSON.exe similarity index 100% rename from util/ace/RV2JSON.exe rename to util/ace/offline/RV2JSON.exe diff --git a/util/ace/update_tools.py b/util/ace/update_tools.py new file mode 100644 index 0000000..e9b4709 --- /dev/null +++ b/util/ace/update_tools.py @@ -0,0 +1,255 @@ +"""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())