Use offline bundles for 3rd party tools for security

This commit is contained in:
DazedAnon 2026-07-05 15:54:25 -05:00
parent 1fb7a21bdd
commit 9ad65488da
8 changed files with 442 additions and 327 deletions

View file

@ -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. A copy is bundled offline in `util/ace/offline/`; newer builds are downloaded when online.
- **[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 curated copy is bundled offline in `util/ace/offline/` and updates with DazedMTLTool.
- **Sakura & Kao_SSS** — TL Inspector (`util/tl_inspector/`) — in-game translation source inspector and live-edit plugin for RPG Maker MV/MZ playtesting.
- **Len** — [Forge](https://gitgud.io/zero64801/forge-mvmz) MV/MZ playtest plugin (`util/forge/`), Mistral API support (provider integration and adaptive rate limiting), and batch translation mode (Anthropic Message Batches API).
@ -333,7 +333,7 @@ Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine sel
| **6 Package** | Run from loose `Data/` (backs up `Data.wolf``.bak`) or repack `Data.wolf`. |
| **7 Saves** | *(Optional)* Update existing `.sav` files for the translated build. |
> **`wolf` binary:** Prebuilt `wolf` CLIs for Windows and Linux are bundled offline under `util/wolfdawn/bin/<platform>/`, so no toolchain or build step is needed. If your platform's binary is missing, the tool downloads a prebuilt one from the [WolfDawn release page](https://gitgud.io/zero64801/wolfdawn) and caches it into that same folder for offline reuse. A clear error is shown if no bundled binary is present and the download can't be completed (e.g. no published binary for your platform).
> **`wolf` binary:** Prebuilt `wolf` CLIs for Windows and Linux are bundled offline under `util/wolfdawn/bin/<platform>/`, so no toolchain or build step is needed. They update when you update DazedMTLTool. If your platform's binary is missing, update the tool or ask the maintainer to refresh the bundled copy.
> **Legacy modules:** The older `Wolf RPG` / `Wolf RPG 2` modules (configured in the Engine Config tab) still exist for edge cases, but the WolfDawn workflow above is the recommended path.

View file

@ -55,87 +55,13 @@ def check_tool_update() -> str | None:
return None
def check_all_updates() -> dict:
"""Check tool, Ace, Forge, and WolfDawn for available updates."""
results = {"tool": None, "ace": False, "forge": False, "wolf": False}
results["tool"] = check_tool_update()
try:
from util.ace.update_tools import check_ace_tools_update
results["ace"] = check_ace_tools_update()
except Exception:
pass
try:
from util.forge.update_tools import check_forge_plugins_update
results["forge"] = check_forge_plugins_update()
except Exception:
pass
try:
from util.wolfdawn.update_tools import check_wolfdawn_update
results["wolf"] = check_wolfdawn_update()
except Exception:
pass
return results
def any_updates_available(results: dict) -> bool:
return (
bool(results.get("tool"))
or results.get("ace")
or results.get("forge")
or results.get("wolf")
)
class BackgroundUpdateCheckThread(QThread):
"""Checks for tool, Ace, Forge, and WolfDawn updates without blocking the UI."""
"""Checks for DazedMTLTool updates without blocking the UI."""
finished = pyqtSignal(dict)
finished = pyqtSignal(object) # str | None — pending tool SHA when available
def run(self):
self.finished.emit(check_all_updates())
class AuxToolsUpdateThread(QThread):
"""Download Ace, Forge, and/or WolfDawn updates without updating the main tool."""
progress = pyqtSignal(str)
finished = pyqtSignal(bool, str)
def __init__(
self,
update_ace: bool = False,
update_forge: bool = False,
update_wolf: bool = False,
parent=None,
):
super().__init__(parent)
self.update_ace = update_ace
self.update_forge = update_forge
self.update_wolf = update_wolf
def run(self):
try:
if self.update_ace:
from util.ace.update_tools import ensure_ace_tools
self.progress.emit("Updating RPG Maker Ace tools…")
if not ensure_ace_tools(log_fn=lambda m: self.progress.emit(m)):
self.finished.emit(False, "Ace tool update failed.")
return
if self.update_forge:
from util.forge.update_tools import ensure_forge_plugins
self.progress.emit("Updating Forge plugins…")
if not ensure_forge_plugins(force=True, log_fn=lambda m: self.progress.emit(m)):
self.finished.emit(False, "Forge plugin update failed.")
return
if self.update_wolf:
from util.wolfdawn.update_tools import ensure_wolfdawn_binary
self.progress.emit("Updating WolfDawn…")
if not ensure_wolfdawn_binary(force=True, log_fn=lambda m: self.progress.emit(m)):
self.finished.emit(False, "WolfDawn update failed.")
return
self.finished.emit(True, "components_updated")
except Exception as exc:
self.finished.emit(False, str(exc))
self.finished.emit(check_tool_update())
class UpdateThread(QThread):
@ -157,9 +83,8 @@ class UpdateThread(QThread):
progress = pyqtSignal(str) # status message
finished = pyqtSignal(bool, str) # (success, message)
def __init__(self, check_only=False, parent=None):
def __init__(self, parent=None):
super().__init__(parent)
self.check_only = check_only
# ------------------------------------------------------------------ #
@ -172,10 +97,6 @@ class UpdateThread(QThread):
self.finished.emit(True, "already_up_to_date")
return
if self.check_only:
self.finished.emit(True, f"update_available:{latest_sha}")
return
self._download_and_apply(latest_sha)
except Exception as exc:
@ -249,31 +170,13 @@ class UpdateThread(QThread):
dst.chmod(mode)
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}")
try:
from util.forge.update_tools import ensure_forge_plugins
self.progress.emit("Updating Forge plugins…")
ensure_forge_plugins(log_fn=lambda m: self.progress.emit(m))
except Exception as exc:
self.progress.emit(f"Forge update skipped: {exc}")
try:
from util.wolfdawn.update_tools import ensure_wolfdawn_binary
self.progress.emit("Updating WolfDawn…")
ensure_wolfdawn_binary(log_fn=lambda m: self.progress.emit(m))
except Exception as exc:
self.progress.emit(f"WolfDawn update skipped: {exc}")
self.finished.emit(True, f"updated:{latest_sha[:8]}")
class UpdateDialog(QDialog):
"""Modal dialog that shows update progress and result."""
def __init__(self, parent=None, pending_updates: dict | None = None):
def __init__(self, parent=None, pending_tool_sha: str | None = None):
super().__init__(parent)
self.setWindowTitle("Tool Update")
self.setMinimumWidth(420)
@ -295,10 +198,10 @@ class UpdateDialog(QDialog):
layout.addWidget(self.close_btn)
self._thread = None
self._pending = pending_updates or {}
self._pending_tool_sha = pending_tool_sha
def start(self, check_only=False):
if self._pending:
def start(self):
if self._pending_tool_sha:
self._show_pending_updates()
return
@ -306,44 +209,28 @@ class UpdateDialog(QDialog):
self._thread.finished.connect(self._on_check_finished)
self._thread.start()
def _update_labels(self) -> list[str]:
labels = []
if self._pending.get("tool"):
labels.append("DazedMTLTool")
if self._pending.get("ace"):
labels.append("RPG Maker Ace tools")
if self._pending.get("forge"):
labels.append("Forge plugins")
if self._pending.get("wolf"):
labels.append("WolfDawn")
return labels
def _show_pending_updates(self):
self.progress_bar.setRange(0, 1)
self.progress_bar.setValue(1)
self.close_btn.setText("Close")
labels = self._update_labels()
if not labels:
tool_sha = self._pending_tool_sha
if not tool_sha:
self.status_label.setText("✅ Already up to date.")
return
detail = "\n".join(f"{label}" for label in labels)
tool_sha = self._pending.get("tool")
if tool_sha:
detail = f" • DazedMTLTool ({tool_sha[:8]})\n" + "\n".join(
f"{label}" for label in labels if label != "DazedMTLTool"
)
self.status_label.setText(
f"🆕 Updates available:\n{detail}\n\nClick 'Update Now' to install."
f"🆕 Update available:\n"
f" • DazedMTLTool ({tool_sha[:8]})\n\n"
f"Click 'Update Now' to install."
)
self.close_btn.setText("Cancel")
update_btn = QPushButton("Update Now")
update_btn.clicked.connect(self._do_update)
self.layout().insertWidget(self.layout().count() - 1, update_btn)
def _on_check_finished(self, results: dict):
self._pending = results
def _on_check_finished(self, tool_sha: str | None):
self._pending_tool_sha = tool_sha
self._show_pending_updates()
def _on_finished(self, success, message):
@ -357,23 +244,9 @@ class UpdateDialog(QDialog):
if message == "already_up_to_date":
self.status_label.setText("✅ Already up to date.")
elif message == "components_updated":
self._pending = {"tool": None, "ace": False, "forge": False, "wolf": False}
self.status_label.setText("✅ Component updates installed.")
if isinstance(self.parent(), DazedMTLGUI):
self.parent().clear_update_indicator()
elif message.startswith("update_available:"):
sha = message.split(":", 1)[1]
self.status_label.setText(
f"🆕 Update available ({sha[:8]}).\nClick 'Update Now' to install."
)
self.close_btn.setText("Cancel")
update_btn = QPushButton("Update Now")
update_btn.clicked.connect(self._do_update)
self.layout().insertWidget(self.layout().count() - 1, update_btn)
elif message.startswith("updated:"):
sha = message.split(":", 1)[1]
self._pending = {"tool": None, "ace": False, "forge": False, "wolf": False}
self._pending_tool_sha = None
self.status_label.setText(
f"✅ Updated to {sha}.\n\nPlease restart the tool for changes to take effect."
)
@ -395,25 +268,9 @@ class UpdateDialog(QDialog):
self.progress_bar.setRange(0, 0)
self.close_btn.setText("Cancel")
if self._pending.get("tool"):
if self._pending_tool_sha:
self.status_label.setText("Downloading update…")
self._thread = UpdateThread(check_only=False, parent=self)
self._thread.progress.connect(self.status_label.setText)
self._thread.finished.connect(self._on_finished)
self._thread.start()
return
update_ace = bool(self._pending.get("ace"))
update_forge = bool(self._pending.get("forge"))
update_wolf = bool(self._pending.get("wolf"))
if update_ace or update_forge or update_wolf:
self.status_label.setText("Downloading component updates…")
self._thread = AuxToolsUpdateThread(
update_ace=update_ace,
update_forge=update_forge,
update_wolf=update_wolf,
parent=self,
)
self._thread = UpdateThread(parent=self)
self._thread.progress.connect(self.status_label.setText)
self._thread.finished.connect(self._on_finished)
self._thread.start()
@ -435,7 +292,7 @@ class DazedMTLGUI(QMainWindow):
def __init__(self):
super().__init__()
self.settings = QSettings("DazedTranslations", "DazedMTLTool")
self._pending_updates: dict = {}
self._pending_tool_sha: str | None = None
self._update_check_thread = None
self._shutdown_started = False
self._update_icon = "🔄"
@ -780,16 +637,16 @@ class DazedMTLGUI(QMainWindow):
return container
def start_background_update_check(self):
"""Check for tool, Ace, and Forge updates after the GUI is visible."""
"""Check for DazedMTLTool updates after the GUI is visible."""
if self._update_check_thread and self._update_check_thread.isRunning():
return
self._update_check_thread = BackgroundUpdateCheckThread(self)
self._update_check_thread.finished.connect(self._on_background_update_check)
self._update_check_thread.start()
def _on_background_update_check(self, results: dict):
self._pending_updates = results
if any_updates_available(results):
def _on_background_update_check(self, tool_sha: str | None):
self._pending_tool_sha = tool_sha
if tool_sha:
self.set_update_indicator()
else:
self.clear_update_indicator()
@ -806,7 +663,7 @@ class DazedMTLGUI(QMainWindow):
def clear_update_indicator(self):
"""Restore the default update button appearance."""
self._pending_updates = {}
self._pending_tool_sha = None
if not self.btn_update:
return
self.btn_update.setToolTip("Check for Updates")
@ -817,11 +674,11 @@ class DazedMTLGUI(QMainWindow):
def show_update_dialog(self):
"""Open the update dialog and check for a newer version."""
dlg = UpdateDialog(self, pending_updates=self._pending_updates.copy())
dlg.start(check_only=True)
dlg = UpdateDialog(self, pending_tool_sha=self._pending_tool_sha)
dlg.start()
dlg.exec_()
self._pending_updates = dlg._pending
if any_updates_available(dlg._pending):
self._pending_tool_sha = dlg._pending_tool_sha
if self._pending_tool_sha:
self.set_update_indicator()
else:
self.clear_update_indicator()

View file

@ -0,0 +1,312 @@
"""Integration tests for bundled-only utility updates and tool-only auto-update checks.
These tests use temporary directories and mocks no live network or GUI window.
They guard the security model: third-party utilities must not fetch upstream unless
a maintainer explicitly uses --force / --refresh-offline / --refresh-all.
"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from util.wolfdawn import WolfDawnError, bundled_binary_path, ensure_wolf_binary
class CheckToolUpdateTests(unittest.TestCase):
"""Tool update check compares remote SHA to data/last_update_sha.txt only."""
def test_returns_sha_when_remote_differs(self):
with tempfile.TemporaryDirectory() as raw:
sha_file = Path(raw) / "last_update_sha.txt"
sha_file.write_text("aaaaaaaa", encoding="utf-8")
from gui.main import UpdateThread, check_tool_update
with patch.object(UpdateThread, "SHA_FILE", str(sha_file)):
with patch.object(
UpdateThread, "fetch_latest_sha", return_value="bbbbbbbb"
):
self.assertEqual(check_tool_update(), "bbbbbbbb")
def test_returns_none_when_up_to_date(self):
with tempfile.TemporaryDirectory() as raw:
sha_file = Path(raw) / "last_update_sha.txt"
sha_file.write_text("same1234", encoding="utf-8")
from gui.main import UpdateThread, check_tool_update
with patch.object(UpdateThread, "SHA_FILE", str(sha_file)):
with patch.object(
UpdateThread, "fetch_latest_sha", return_value="same1234"
):
self.assertIsNone(check_tool_update())
def test_returns_none_on_fetch_failure(self):
from gui.main import UpdateThread, check_tool_update
with patch.object(
UpdateThread, "fetch_latest_sha", side_effect=OSError("offline")
):
self.assertIsNone(check_tool_update())
class AceBundledOnlyTests(unittest.TestCase):
def _ace_paths(self, base: Path) -> dict:
offline = base / "offline"
offline.mkdir(parents=True, exist_ok=True)
return {
"OFFLINE_DIR": offline,
"RV2JSON_LOCAL": base / "RV2JSON.exe",
"DECRYPTER_LOCAL": base / "RPGMakerDecrypter-cli.exe",
"DECRYPTER_LEGACY": base / "RPGMakerDecrypter.exe",
"VERSION_FILE": base / ".tools_version.json",
}
def test_seed_overwrites_stale_runtime_copy(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
paths = self._ace_paths(base)
(paths["OFFLINE_DIR"] / "RV2JSON.exe").write_bytes(b"OFFLINE")
paths["RV2JSON_LOCAL"].write_bytes(b"STALE")
import util.ace.update_tools as ace
with patch.multiple("util.ace.update_tools", **paths):
ace._seed_from_offline(paths["RV2JSON_LOCAL"], "RV2JSON.exe", None)
self.assertEqual(paths["RV2JSON_LOCAL"].read_bytes(), b"OFFLINE")
def test_ensure_rv2json_does_not_contact_upstream(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
paths = self._ace_paths(base)
(paths["OFFLINE_DIR"] / "RV2JSON.exe").write_bytes(b"OK")
import util.ace.update_tools as ace
with patch.multiple("util.ace.update_tools", **paths):
with patch.object(
ace,
"_rv2json_upstream",
side_effect=AssertionError("must not fetch upstream"),
):
self.assertTrue(ace.ensure_rv2json(force=False))
self.assertEqual(paths["RV2JSON_LOCAL"].read_bytes(), b"OK")
def test_ensure_rv2json_fails_when_nothing_bundled(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
paths = self._ace_paths(base)
import util.ace.update_tools as ace
with patch.multiple("util.ace.update_tools", **paths):
with patch.object(
ace,
"_rv2json_upstream",
side_effect=AssertionError("must not fetch upstream"),
):
self.assertFalse(ace.ensure_rv2json(force=False))
def test_force_delegates_to_upstream_fetch(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
paths = self._ace_paths(base)
import util.ace.update_tools as ace
with patch.multiple("util.ace.update_tools", **paths):
with patch.object(ace, "_fetch_rv2json_upstream", return_value=True) as fetch:
self.assertTrue(ace.ensure_rv2json(force=True))
fetch.assert_called_once()
class ForgeBundledOnlyTests(unittest.TestCase):
def _forge_paths(self, base: Path, *, with_plugins: bool) -> dict:
upstream = base / "upstream"
upstream.mkdir(parents=True, exist_ok=True)
plugin_body = b"// forge plugin\n"
paths = {
"_PKG_ROOT": base,
"UPSTREAM_DIR": upstream,
"VERSION_FILE": base / ".forge_version.json",
}
if with_plugins:
for name in ("Forge_MV.js", "Forge_MZ.js"):
(base / name).write_bytes(plugin_body)
(upstream / name).write_bytes(plugin_body)
return paths
def test_ensure_forge_plugins_does_not_contact_upstream(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
paths = self._forge_paths(base, with_plugins=True)
import util.forge.update_tools as forge
with patch.multiple("util.forge.update_tools", **paths):
with patch.object(
forge,
"_upstream_commit",
side_effect=AssertionError("must not fetch upstream"),
):
with patch.object(
forge,
"refresh_forge_plugins",
side_effect=AssertionError("must not refresh upstream"),
):
self.assertTrue(forge.ensure_forge_plugins(force=False))
def test_ensure_forge_plugins_seeds_from_upstream_folder(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
paths = self._forge_paths(base, with_plugins=False)
(paths["UPSTREAM_DIR"] / "Forge_MV.js").write_bytes(b"// mv")
(paths["UPSTREAM_DIR"] / "Forge_MZ.js").write_bytes(b"// mz")
import util.forge.update_tools as forge
with patch.multiple("util.forge.update_tools", **paths):
self.assertTrue(forge.ensure_forge_plugins(force=False))
self.assertTrue((base / "Forge_MV.js").is_file())
self.assertTrue((base / "Forge_MZ.js").is_file())
def test_ensure_forge_plugins_fails_when_missing_everywhere(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
paths = self._forge_paths(base, with_plugins=False)
import util.forge.update_tools as forge
with patch.multiple("util.forge.update_tools", **paths):
self.assertFalse(forge.ensure_forge_plugins(force=False))
def test_force_calls_refresh_forge_plugins(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
paths = self._forge_paths(base, with_plugins=True)
import util.forge.update_tools as forge
with patch.multiple("util.forge.update_tools", **paths):
with patch.object(forge, "refresh_forge_plugins", return_value=True) as refresh:
self.assertTrue(forge.ensure_forge_plugins(force=True))
refresh.assert_called_once()
class WolfBundledOnlyTests(unittest.TestCase):
def test_ensure_wolf_binary_uses_bundled_path(self):
with tempfile.TemporaryDirectory() as raw:
wolf = Path(raw) / "wolf"
wolf.write_bytes(b"\x7fELF")
with patch("util.wolfdawn.bundled_binary_path", return_value=wolf):
self.assertEqual(ensure_wolf_binary(), wolf)
def test_ensure_wolf_binary_raises_when_missing(self):
missing = Path("/nonexistent/wolf/missing")
with patch("util.wolfdawn.bundled_binary_path", return_value=missing):
with self.assertRaises(WolfDawnError) as ctx:
ensure_wolf_binary()
self.assertIn("Update DazedMTLTool", str(ctx.exception))
def test_ensure_wolf_binary_never_downloads(self):
missing = Path("/nonexistent/wolf/missing")
with patch("util.wolfdawn.bundled_binary_path", return_value=missing):
with patch(
"util.wolfdawn.download_wolf_binary",
side_effect=AssertionError("must not download at runtime"),
):
with self.assertRaises(WolfDawnError):
ensure_wolf_binary()
def test_ensure_wolfdawn_binary_does_not_refresh_by_default(self):
import util.wolfdawn.update_tools as wd_update
wolf = bundled_binary_path()
if not wolf.is_file():
self.skipTest(f"no bundled wolf binary at {wolf}")
with patch.object(
wd_update,
"refresh_wolfdawn_binary",
side_effect=AssertionError("must not refresh upstream"),
):
self.assertTrue(wd_update.ensure_wolfdawn_binary(force=False))
def test_ensure_wolfdawn_binary_force_calls_refresh(self):
import util.wolfdawn.update_tools as wd_update
with patch.object(wd_update, "refresh_wolfdawn_binary", return_value=True) as refresh:
with patch.object(wd_update, "bundled_binary_path", return_value=Path("/wolf")):
with patch("pathlib.Path.is_file", return_value=True):
self.assertTrue(wd_update.ensure_wolfdawn_binary(force=True))
refresh.assert_called_once()
class MaintainerRefreshTests(unittest.TestCase):
"""Maintainer CLI paths should still wire upstream fetch when explicitly invoked."""
def test_refresh_offline_bundle_downloads_into_offline_dir(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
offline = base / "offline"
offline.mkdir()
import util.ace.update_tools as ace
paths = {
"OFFLINE_DIR": offline,
"RV2JSON_LOCAL": base / "RV2JSON.exe",
"DECRYPTER_LOCAL": base / "RPGMakerDecrypter-cli.exe",
"DECRYPTER_LEGACY": base / "RPGMakerDecrypter.exe",
"VERSION_FILE": base / ".tools_version.json",
}
def fake_download(url: str, dest: Path) -> None:
dest.write_bytes(b"downloaded")
with patch.multiple("util.ace.update_tools", **paths):
with patch.object(
ace,
"_rv2json_upstream",
return_value=("sha1", "https://example/rv2json"),
):
with patch.object(
ace,
"_decrypter_upstream",
return_value=("v1", "https://example/dec"),
):
with patch.object(ace, "_download", side_effect=fake_download):
self.assertTrue(ace.refresh_offline_bundle(log_fn=None))
self.assertEqual((offline / "RV2JSON.exe").read_bytes(), b"downloaded")
self.assertEqual(
(offline / ace.DECRYPTER_ASSET).read_bytes(), b"downloaded"
)
def test_refresh_forge_plugins_installs_downloaded_bytes(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
upstream = base / "upstream"
upstream.mkdir()
import util.forge.update_tools as forge
paths = {
"_PKG_ROOT": base,
"UPSTREAM_DIR": upstream,
"VERSION_FILE": base / ".forge_version.json",
}
plugin = b"// downloaded forge\n"
with patch.multiple("util.forge.update_tools", **paths):
with patch.object(forge, "_upstream_commit", return_value="abc123"):
with patch.object(forge, "_fetch_plugins", return_value=plugin):
self.assertTrue(forge.refresh_forge_plugins(log_fn=None))
self.assertEqual((base / "Forge_MV.js").read_bytes(), plugin)
self.assertEqual((upstream / "Forge_MZ.js").read_bytes(), plugin)
if __name__ == "__main__":
unittest.main(verbosity=2)

View file

@ -1,3 +1,3 @@
"""RPG Maker Ace helper binaries (downloaded on demand, not committed to git)."""
"""RPG Maker Ace helper binaries (seeded from offline bundle, not committed to git)."""
__all__ = ["ace_tool_path", "build_decrypter_command", "ensure_ace_tools"]

View file

@ -2,6 +2,10 @@
RV2JSON.exe https://github.com/Sinflower/RV2JSON (bin/RV2JSON.exe)
Decrypter CLI https://github.com/uuksu/RPGMakerDecrypter (release asset)
End users receive curated copies via the offline bundle (``util/ace/offline/``)
shipped with DazedMTLTool updates. Upstream fetches are maintainer-only
(``--refresh-offline`` or ``--force``).
"""
from __future__ import annotations
@ -67,15 +71,13 @@ def _log(msg: str, log_fn) -> None:
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
"""Copy a bundled offline exe into the active path when the bundle exists."""
src = OFFLINE_DIR / offline_name
if not src.is_file():
return False
return local.is_file()
local.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, local)
_log(f"Using offline bundled {offline_name}", log_fn)
_log(f"Synced {offline_name} from offline bundle", log_fn)
return True
@ -103,9 +105,8 @@ def _decrypter_upstream() -> tuple[str, str]:
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)
def _fetch_rv2json_upstream(force: bool, log_fn=print) -> bool:
"""Download RV2JSON.exe from upstream (maintainer-only)."""
versions = _load_versions()
try:
remote_sha, download_url = _rv2json_upstream()
@ -113,8 +114,6 @@ def ensure_rv2json(force: bool = False, log_fn=print) -> bool:
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
@ -129,8 +128,6 @@ def ensure_rv2json(force: bool = False, log_fn=print) -> bool:
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
@ -140,9 +137,8 @@ def ensure_rv2json(force: bool = False, log_fn=print) -> bool:
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)
def _fetch_decrypter_upstream(force: bool, log_fn=print) -> bool:
"""Download RPGMakerDecrypter CLI from upstream (maintainer-only)."""
if DECRYPTER_LOCAL.is_file():
pass # prefer CLI (offline bundle or download)
elif DECRYPTER_LEGACY.is_file():
@ -156,8 +152,6 @@ def ensure_decrypter(force: bool = False, log_fn=print) -> bool:
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
@ -172,8 +166,6 @@ def ensure_decrypter(force: bool = False, log_fn=print) -> bool:
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
@ -183,40 +175,52 @@ def ensure_decrypter(force: bool = False, log_fn=print) -> bool:
return True
def ensure_rv2json(force: bool = False, log_fn=print) -> bool:
"""Ensure RV2JSON.exe is present from the offline bundle (no upstream fetch)."""
_seed_from_offline(RV2JSON_LOCAL, "RV2JSON.exe", log_fn)
if force:
return _fetch_rv2json_upstream(force=force, log_fn=log_fn)
if RV2JSON_LOCAL.is_file():
return True
_log(
f"ERROR: RV2JSON.exe not found. Update DazedMTLTool or ask the maintainer "
f"to refresh util/ace/offline/RV2JSON.exe.",
log_fn,
)
return False
def ensure_decrypter(force: bool = False, log_fn=print) -> bool:
"""Ensure the RPG Maker decrypter CLI is present from the offline bundle."""
_seed_from_offline(DECRYPTER_LOCAL, DECRYPTER_ASSET, log_fn)
if DECRYPTER_LOCAL.is_file():
if not force:
return True
elif DECRYPTER_LEGACY.is_file() and not force:
_log("Using legacy RPGMakerDecrypter.exe (local).", log_fn)
return True
if force:
return _fetch_decrypter_upstream(force=force, log_fn=log_fn)
if DECRYPTER_LOCAL.is_file() or DECRYPTER_LEGACY.is_file():
return True
_log(
f"ERROR: {DECRYPTER_ASSET} not found. Update DazedMTLTool or ask the maintainer "
f"to refresh util/ace/offline/{DECRYPTER_ASSET}.",
log_fn,
)
return False
def seed_ace_tools(log_fn=None) -> None:
"""Copy offline-bundled Ace tools if missing (no network)."""
"""Sync runtime Ace tools from the offline bundle (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)."""
"""Ensure both Ace tools are present (offline bundle; upstream only with force=True)."""
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

View file

@ -4,6 +4,9 @@ Upstream: https://gitgud.io/zero64801/forge-mvmz
CI builds a unified forge.js plugin (master branch artifacts).
Offline copies: util/forge/upstream/
Active plugins: util/forge/Forge_MZ.js, util/forge/Forge_MV.js
End users receive curated copies shipped with DazedMTLTool updates.
Upstream fetches are maintainer-only (``--refresh-offline`` or ``--force``).
"""
from __future__ import annotations
@ -164,49 +167,31 @@ def seed_forge_plugins(log_fn=None) -> None:
_seed_from_offline(engine, log_fn)
def check_forge_plugins_update() -> bool:
"""Return True if upstream Forge plugins differ from the local copies."""
seed_forge_plugins()
missing = [e for e in PLUGIN_BY_ENGINE if not bundled_plugin_path(e).is_file()]
if missing:
return True
versions = _load_versions()
local_commit = versions.get("commit", "")
try:
return _upstream_commit() != local_commit
except Exception:
return False
def ensure_forge_plugins(force: bool = False, log_fn=print) -> bool:
"""Ensure Forge plugins are present; fetch upstream when missing or stale."""
"""Ensure Forge plugins are present from the offline bundle (no upstream fetch)."""
for engine in PLUGIN_BY_ENGINE:
_seed_from_offline(engine, log_fn)
missing = [e for e in PLUGIN_BY_ENGINE if not bundled_plugin_path(e).is_file()]
versions = _load_versions()
local_commit = versions.get("commit", "")
if missing and not force:
names = ", ".join(PLUGIN_BY_ENGINE[e] + ".js" for e in missing)
_log(
f"ERROR: Forge plugin(s) missing ({names}). "
"Update DazedMTLTool or ask the maintainer to refresh util/forge/upstream/.",
log_fn,
)
return False
need_fetch = bool(missing) or force
if not need_fetch:
try:
need_fetch = _upstream_commit() != local_commit
except Exception as exc:
if missing:
_log(f"ERROR: Forge upstream unavailable ({exc})", log_fn)
return False
_log(f"Warning: could not check Forge update ({exc}); using local copy.", log_fn)
if need_fetch:
if force:
if refresh_forge_plugins(log_fn=log_fn):
return True
have_local = all(bundled_plugin_path(e).is_file() for e in PLUGIN_BY_ENGINE)
if force or not have_local:
if not have_local:
_log("ERROR: Forge plugin update failed.", log_fn)
return False
_log("Warning: could not update Forge plugins; using local copy.", log_fn)
return True
return all(bundled_plugin_path(e).is_file() for e in PLUGIN_BY_ENGINE)
def main() -> int:

View file

@ -3,9 +3,8 @@
WolfDawn is the Rust toolchain (https://gitgud.io/zero64801/wolfdawn) that
unpacks, extracts, injects, and repacks WOLF RPG Editor game data. DazedMTLTool
ships prebuilt ``wolf`` binaries offline under ``util/wolfdawn/bin/<platform>/``
so end users never need a Rust toolchain. When no offline binary is bundled for
the running platform, the tool pulls a prebuilt one from the WolfDawn release
page and caches it into that same folder. Everything the Wolf workflow needs
so end users never need a Rust toolchain or a live upstream fetch. Binaries are
updated when DazedMTLTool itself is updated. Everything the Wolf workflow needs
goes through the helpers here so command syntax and exit-code handling live in
one place.
@ -97,7 +96,7 @@ def inject_had_applied(applied: int | None) -> bool:
return applied is not None and applied > 0
# Committed, prebuilt binaries live here (per-platform) so end users don't need a
# Rust toolchain. When one is missing we download it from the WolfDawn release.
# Rust toolchain or a live upstream fetch.
_PACKAGE_DIR = Path(__file__).resolve().parent
_BUNDLED_DIR = _PACKAGE_DIR / "bin"
@ -266,23 +265,20 @@ def download_wolf_binary(platform: Optional[str] = None, log_fn=print) -> Path:
return _extract_wolf_from_zip(zip_bytes, dest, log_fn)
def ensure_wolf_binary(force: bool = False, log_fn=print) -> Path:
"""Return the ``wolf`` binary path, downloading it only if needed.
def ensure_wolf_binary(log_fn=print) -> Path:
"""Return the bundled ``wolf`` binary path (no upstream fetch at runtime).
Resolution order:
1. Committed/cached prebuilt binary under ``util/wolfdawn/bin/<platform>/``
used directly so the tool works fully offline.
2. Otherwise (or when ``force`` is set), pull the prebuilt binary from the
WolfDawn release page and cache it into that same folder.
Raises :class:`WolfDawnError` with actionable guidance when no offline binary
is present and the download can't be completed.
Raises :class:`WolfDawnError` when no offline binary is present for this
platform. Maintainers refresh bundled binaries with
``python -m util.wolfdawn.update_tools --refresh-all``.
"""
if not force:
bundled = bundled_binary_path()
if bundled.is_file():
return bundled
return download_wolf_binary(log_fn=log_fn)
bundled = bundled_binary_path()
if bundled.is_file():
return bundled
raise WolfDawnError(
f"No bundled WolfDawn binary for '{_platform_dir()}' at {bundled}. "
"Update DazedMTLTool to receive a prebuilt wolf binary."
)
def _log(msg: str, log_fn) -> None:

View file

@ -1,9 +1,7 @@
"""Check for and apply WolfDawn ``wolf`` binary updates.
"""Check for and apply WolfDawn ``wolf`` binary updates (maintainer-only upstream fetch).
Resolution order for each platform:
1. Temporary shallow clone + ``cargo`` source build (when possible on this machine)
2. Prebuilt zip from the latest GitLab release
3. Keep the existing offline bundled binary
End users receive prebuilt binaries under ``util/wolfdawn/bin/<platform>/`` via
DazedMTLTool updates. Maintainers refresh them with ``--refresh-all`` or ``--force``.
"""
from __future__ import annotations
@ -22,7 +20,6 @@ from util.wolfdawn import (
)
from util.wolfdawn.build_tools import (
build_and_install_platforms,
buildable_from_source,
upstream_commit,
)
@ -69,39 +66,6 @@ def _local_version(platform: str) -> str:
return _platform_versions().get(platform, "")
def _record_platform_version(platform: str, version: str) -> None:
versions = _load_versions()
platforms = versions.setdefault("platforms", {})
if not isinstance(platforms, dict):
platforms = {}
versions["platforms"] = platforms
platforms[platform] = version
if version and not versions.get("commit"):
versions["commit"] = version
_save_versions(versions)
def _desired_version(platform: str) -> str:
"""Best upstream version we can install for ``platform`` on this machine."""
if buildable_from_source(platform):
return upstream_commit()
asset = _latest_release_asset(platform)
if asset:
return asset[0]
return upstream_commit()
def check_wolfdawn_update(platform: str | None = None) -> bool:
"""Return True when upstream WolfDawn differs from the bundled binary."""
platform = platform or _platform_dir()
if not bundled_binary_path(platform).is_file():
return True
try:
return _local_version(platform) != _desired_version(platform)
except Exception:
return False
def _refresh_from_release(platform: str, log_fn=print) -> str | None:
asset = _latest_release_asset(platform)
if not asset:
@ -169,30 +133,27 @@ def refresh_wolfdawn_binary(
def ensure_wolfdawn_binary(force: bool = False, log_fn=print) -> bool:
"""Ensure the ``wolf`` binary is present; refresh when missing or stale."""
"""Ensure the bundled ``wolf`` binary is present (no upstream fetch by default)."""
platform = _platform_dir()
bundled = bundled_binary_path(platform)
missing = not bundled.is_file()
need_fetch = missing or force
if not need_fetch:
try:
need_fetch = check_wolfdawn_update(platform)
except Exception as exc:
if missing:
_log(f"ERROR: WolfDawn upstream unavailable ({exc})", log_fn)
return False
_log(f"Warning: could not check WolfDawn update ({exc}); using local copy.", log_fn)
if need_fetch:
if force:
if refresh_wolfdawn_binary(platforms=BUNDLED_PLATFORMS, log_fn=log_fn):
return bundled_binary_path(platform).is_file()
if force or missing:
_log("ERROR: WolfDawn update failed.", log_fn)
return False
_log("Warning: could not update WolfDawn; using local copy.", log_fn)
if bundled.is_file():
_log("Warning: WolfDawn update failed; using bundled copy.", log_fn)
return True
_log("ERROR: WolfDawn update failed.", log_fn)
return False
return bundled.is_file()
if bundled.is_file():
return True
_log(
f"ERROR: no bundled WolfDawn binary for '{platform}' at {bundled}. "
"Update DazedMTLTool to receive a prebuilt wolf binary.",
log_fn,
)
return False
def main() -> int: