372 lines
13 KiB
Python
372 lines
13 KiB
Python
"""WolfDawn CLI bootstrap and thin subprocess wrappers.
|
|
|
|
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
|
|
goes through the helpers here so command syntax and exit-code handling live in
|
|
one place.
|
|
|
|
Exit codes emitted by ``wolf`` (see WolfDawn crates/wolf-cli/src/main.rs):
|
|
0 success
|
|
2 round-trip mismatch / inject guard / name-consistency failure
|
|
4 read / parse / write / crypto failure
|
|
64 usage error (bad arguments / unknown subcommand)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable, Optional, Sequence, Union
|
|
|
|
__all__ = [
|
|
"WolfDawnError",
|
|
"WolfResult",
|
|
"wolf_binary_path",
|
|
"bundled_binary_path",
|
|
"ensure_wolf_binary",
|
|
"download_wolf_binary",
|
|
"unpack_all",
|
|
"strings_extract",
|
|
"names_extract",
|
|
"strings_inject",
|
|
"names_inject",
|
|
"pack",
|
|
"save_update",
|
|
"names_check",
|
|
]
|
|
|
|
# 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.
|
|
_PACKAGE_DIR = Path(__file__).resolve().parent
|
|
_BUNDLED_DIR = _PACKAGE_DIR / "bin"
|
|
|
|
# WolfDawn upstream project on gitgud.io (a GitLab instance). The numeric project
|
|
# id is used for the uploads URL because the human-readable project path sits
|
|
# behind a Cloudflare challenge, while ``/-/project/<id>/uploads/...`` does not.
|
|
_GITGUD_HOST = "https://gitgud.io"
|
|
_WOLFDAWN_PROJECT_ID = 48753
|
|
_RELEASES_API = f"{_GITGUD_HOST}/api/v4/projects/{_WOLFDAWN_PROJECT_ID}/releases"
|
|
# Substring that identifies each platform's prebuilt zip in a release. Only the
|
|
# assets the upstream maintainer publishes can be pulled; unpublished platforms
|
|
# fall back to the committed offline binary.
|
|
_RELEASE_ASSET_MATCH = {
|
|
"windows": "win",
|
|
"linux": "linux",
|
|
"macos": "mac",
|
|
}
|
|
_DOWNLOAD_UA = (
|
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
"DazedMTLTool WolfDawn-fetch"
|
|
)
|
|
|
|
PathLike = Union[str, Path]
|
|
|
|
|
|
class WolfDawnError(RuntimeError):
|
|
"""Raised when the WolfDawn binary can't be located/downloaded or a command fails hard."""
|
|
|
|
|
|
@dataclass
|
|
class WolfResult:
|
|
"""Outcome of a single ``wolf`` invocation."""
|
|
|
|
returncode: int
|
|
stdout: str
|
|
stderr: str
|
|
argv: list[str]
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return self.returncode == 0
|
|
|
|
def raise_for_status(self) -> "WolfResult":
|
|
if not self.ok:
|
|
raise WolfDawnError(
|
|
f"wolf {' '.join(self.argv[1:])} exited {self.returncode}: "
|
|
f"{(self.stderr or self.stdout).strip()}"
|
|
)
|
|
return self
|
|
|
|
|
|
def _exe_name() -> str:
|
|
return "wolf.exe" if sys.platform.startswith("win") else "wolf"
|
|
|
|
|
|
def _platform_dir() -> str:
|
|
if sys.platform.startswith("win"):
|
|
return "windows"
|
|
if sys.platform == "darwin":
|
|
return "macos"
|
|
return "linux"
|
|
|
|
|
|
def bundled_binary_path(platform: Optional[str] = None) -> Path:
|
|
"""Committed/cached prebuilt binary path for a platform (may not exist)."""
|
|
platform = platform or _platform_dir()
|
|
exe = "wolf.exe" if platform == "windows" else "wolf"
|
|
return _BUNDLED_DIR / platform / exe
|
|
|
|
|
|
def wolf_binary_path() -> Path:
|
|
"""Preferred ``wolf`` binary path for the current platform (may not exist)."""
|
|
return bundled_binary_path()
|
|
|
|
|
|
def _find_release_upload_url(platform: str) -> Optional[str]:
|
|
"""Look up the newest release and return the download URL of ``platform``'s zip.
|
|
|
|
WolfDawn attaches prebuilt zips to a release as GitLab *uploads* referenced
|
|
from the release description markdown, e.g.
|
|
``[WolfDawn-win64.zip](/uploads/<hash>/WolfDawn-win64.zip)``. Returns the
|
|
numeric-project uploads URL (which bypasses the Cloudflare challenge) or
|
|
``None`` when the platform isn't published.
|
|
"""
|
|
match = _RELEASE_ASSET_MATCH.get(platform)
|
|
if not match:
|
|
return None
|
|
req = urllib.request.Request(_RELEASES_API, headers={"User-Agent": _DOWNLOAD_UA})
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
releases = json.loads(resp.read().decode("utf-8"))
|
|
link_re = re.compile(r"\[([^\]]+)\]\((/uploads/[0-9a-f]+/[^)]+)\)")
|
|
for release in releases:
|
|
description = release.get("description") or ""
|
|
for filename, upload_path in link_re.findall(description):
|
|
name = filename.lower()
|
|
if match in name and name.endswith(".zip"):
|
|
return f"{_GITGUD_HOST}/-/project/{_WOLFDAWN_PROJECT_ID}{upload_path}"
|
|
return None
|
|
|
|
|
|
def _extract_wolf_from_zip(zip_bytes: bytes, dest: Path, log_fn=None) -> Path:
|
|
"""Extract the ``wolf``/``wolf.exe`` member from a release zip into ``dest``."""
|
|
want = dest.name.lower()
|
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
member = None
|
|
for info in zf.infolist():
|
|
base = info.filename.rsplit("/", 1)[-1].lower()
|
|
if base == want:
|
|
member = info
|
|
break
|
|
if member is None:
|
|
names = ", ".join(i.filename for i in zf.infolist())
|
|
raise WolfDawnError(
|
|
f"WolfDawn release zip did not contain '{dest.name}' (found: {names})."
|
|
)
|
|
payload = zf.read(member)
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
dest.write_bytes(payload)
|
|
try:
|
|
dest.chmod(0o755)
|
|
except OSError: # pragma: no cover - non-POSIX filesystems
|
|
pass
|
|
_log(f"Saved WolfDawn binary to {dest}", log_fn)
|
|
return dest
|
|
|
|
|
|
def download_wolf_binary(platform: Optional[str] = None, log_fn=print) -> Path:
|
|
"""Pull the prebuilt ``wolf`` binary from the WolfDawn release and cache it.
|
|
|
|
Downloads the platform's release zip, extracts the ``wolf`` executable into
|
|
``util/wolfdawn/bin/<platform>/``, and returns its path. Raises
|
|
:class:`WolfDawnError` when the platform has no published binary or the
|
|
download fails.
|
|
"""
|
|
platform = platform or _platform_dir()
|
|
dest = bundled_binary_path(platform)
|
|
_log(f"Looking up WolfDawn '{platform}' binary on {_GITGUD_HOST} ...", log_fn)
|
|
try:
|
|
url = _find_release_upload_url(platform)
|
|
except (urllib.error.URLError, TimeoutError, ValueError) as exc:
|
|
raise WolfDawnError(
|
|
f"Could not reach the WolfDawn release page: {exc}. "
|
|
f"Place a prebuilt binary at {dest} to work fully offline."
|
|
) from exc
|
|
if not url:
|
|
raise WolfDawnError(
|
|
f"No prebuilt WolfDawn binary is published for '{platform}'. "
|
|
f"Place one at {dest}, or build it from source "
|
|
"(https://gitgud.io/zero64801/wolfdawn)."
|
|
)
|
|
_log(f"Downloading WolfDawn binary from {url} ...", log_fn)
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": _DOWNLOAD_UA})
|
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
zip_bytes = resp.read()
|
|
except (urllib.error.URLError, TimeoutError) as exc:
|
|
raise WolfDawnError(f"Failed to download WolfDawn binary: {exc}") from exc
|
|
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.
|
|
|
|
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.
|
|
"""
|
|
if not force:
|
|
bundled = bundled_binary_path()
|
|
if bundled.is_file():
|
|
return bundled
|
|
return download_wolf_binary(log_fn=log_fn)
|
|
|
|
|
|
def _log(msg: str, log_fn) -> None:
|
|
if log_fn:
|
|
log_fn(msg)
|
|
|
|
|
|
def _str(p: PathLike) -> str:
|
|
return str(p)
|
|
|
|
|
|
def _run(args: Sequence[str], log_fn=None) -> WolfResult:
|
|
"""Run ``wolf <args...>`` and capture output. Ensures the binary exists first."""
|
|
binary = ensure_wolf_binary(log_fn=log_fn)
|
|
argv = [str(binary), *[str(a) for a in args]]
|
|
proc = subprocess.run(
|
|
argv,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
result = WolfResult(
|
|
returncode=proc.returncode,
|
|
stdout=proc.stdout or "",
|
|
stderr=proc.stderr or "",
|
|
argv=argv,
|
|
)
|
|
if log_fn:
|
|
for line in result.stderr.splitlines():
|
|
log_fn(line)
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command wrappers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def unpack_all(inputs: Union[PathLike, Iterable[PathLike]], out_dir: PathLike, log_fn=None) -> WolfResult:
|
|
"""``wolf unpack-all <data-dir|archive.wolf>... -o <out-dir>``.
|
|
|
|
Each ``Name.wolf`` is unpacked to ``<out-dir>/Name/``.
|
|
"""
|
|
if isinstance(inputs, (str, Path)):
|
|
inputs = [inputs]
|
|
args = ["unpack-all", *[_str(p) for p in inputs], "-o", _str(out_dir)]
|
|
return _run(args, log_fn=log_fn)
|
|
|
|
|
|
def strings_extract(input_path: PathLike, out_json: PathLike, log_fn=None) -> WolfResult:
|
|
"""``wolf strings-extract <file|dir> -o <out.json>``.
|
|
|
|
Accepts a map ``.mps``, ``CommonEvent.dat``, a ``.project`` database, ``Game.dat``,
|
|
a single event-text ``.txt``, or a directory of ``.txt`` files.
|
|
"""
|
|
return _run(["strings-extract", _str(input_path), "-o", _str(out_json)], log_fn=log_fn)
|
|
|
|
|
|
def names_extract(data_dir: PathLike, out_json: PathLike, log_fn=None) -> WolfResult:
|
|
"""``wolf names-extract <data-dir> -o <names.json>`` (project-wide name glossary)."""
|
|
return _run(["names-extract", _str(data_dir), "-o", _str(out_json)], log_fn=log_fn)
|
|
|
|
|
|
def strings_inject(
|
|
edited_json: PathLike,
|
|
base: PathLike,
|
|
output: PathLike,
|
|
allow_code_drift: bool = False,
|
|
en_punct: bool = False,
|
|
log_fn=None,
|
|
) -> WolfResult:
|
|
"""``wolf strings-inject <edited.json> --base <orig> -o <out> [flags]``."""
|
|
args = ["strings-inject", _str(edited_json), "--base", _str(base), "-o", _str(output)]
|
|
if allow_code_drift:
|
|
args.append("--allow-code-drift")
|
|
if en_punct:
|
|
args.append("--en-punct")
|
|
return _run(args, log_fn=log_fn)
|
|
|
|
|
|
def names_inject(
|
|
names_json: PathLike,
|
|
data_dir: PathLike,
|
|
out_dir: Optional[PathLike] = None,
|
|
allow_code_drift: bool = False,
|
|
en_punct: bool = False,
|
|
log_fn=None,
|
|
) -> WolfResult:
|
|
"""``wolf names-inject <names.json> --data <data-dir> [-o <out-dir>] [flags]``."""
|
|
args = ["names-inject", _str(names_json), "--data", _str(data_dir)]
|
|
if out_dir is not None:
|
|
args += ["-o", _str(out_dir)]
|
|
if allow_code_drift:
|
|
args.append("--allow-code-drift")
|
|
if en_punct:
|
|
args.append("--en-punct")
|
|
return _run(args, log_fn=log_fn)
|
|
|
|
|
|
def names_check(json_files: Iterable[PathLike], log_fn=None) -> WolfResult:
|
|
"""``wolf names-check <file.json>...`` - report inconsistently translated names."""
|
|
args = ["names-check", *[_str(p) for p in json_files]]
|
|
return _run(args, log_fn=log_fn)
|
|
|
|
|
|
def pack(
|
|
directory: PathLike,
|
|
output: PathLike,
|
|
like: Optional[PathLike] = None,
|
|
encrypt: bool = False,
|
|
version: Optional[str] = None,
|
|
log_fn=None,
|
|
) -> WolfResult:
|
|
"""``wolf pack <dir> -o <out.wolf> [--like <orig> | --encrypt [--version <hex>]]``."""
|
|
args = ["pack", _str(directory), "-o", _str(output)]
|
|
if like is not None:
|
|
args += ["--like", _str(like)]
|
|
elif encrypt:
|
|
args.append("--encrypt")
|
|
if version:
|
|
args += ["--version", str(version)]
|
|
return _run(args, log_fn=log_fn)
|
|
|
|
|
|
def save_update(
|
|
input_path: PathLike,
|
|
output: Optional[PathLike] = None,
|
|
title: Optional[str] = None,
|
|
game: Optional[PathLike] = None,
|
|
translations: Optional[Iterable[PathLike]] = None,
|
|
log_fn=None,
|
|
) -> WolfResult:
|
|
"""``wolf save-update <save.sav|dir> [-o <out>] [--title <t> | --game <Game.dat>] [--translations ...]``."""
|
|
args = ["save-update", _str(input_path)]
|
|
if output is not None:
|
|
args += ["-o", _str(output)]
|
|
if title is not None:
|
|
args += ["--title", title]
|
|
elif game is not None:
|
|
args += ["--game", _str(game)]
|
|
if translations:
|
|
args.append("--translations")
|
|
args += [_str(p) for p in translations]
|
|
return _run(args, log_fn=log_fn)
|