This commit is contained in:
DazedAnon 2026-07-03 14:43:15 -05:00
parent b58ae84aa4
commit 014278b339
14 changed files with 1906 additions and 34 deletions

4
.gitignore vendored
View file

@ -30,3 +30,7 @@ util/forge/.forge_version.json
util/ace/*.exe
util/ace/.tools_version.json
!util/ace/offline/*.exe
# WolfDawn: ignore the Rust build tree, but keep the committed prebuilt binaries.
util/wolfdawn-master/target/
!util/wolfdawn/bin/**

View file

@ -22,6 +22,7 @@ An AI-powered game translation tool with a GUI. Translate RPG Maker, Ren'Py, Tyr
- [Folder Structure](#folder-structure)
- [Finding Untranslated Text (Snipping Tool OCR)](#finding-untranslated-text-snipping-tool-ocr)
- [RPG Maker Translation Workflow](#rpg-maker-translation-workflow)
- [Wolf RPG (WolfDawn) Translation Workflow](#wolf-rpg-wolfdawn-translation-workflow)
- [Using Copilot & VSCode](#using-copilot--vscode)
- [Version Control with Git](#version-control-with-git)
- [Troubleshooting](#troubleshooting)
@ -314,6 +315,28 @@ Here's the recommended step-by-step process for translating an RPG Maker MV/MZ g
---
## Wolf RPG (WolfDawn) Translation Workflow
WOLF RPG Editor games are handled by a dedicated, guided workflow built on the bundled [WolfDawn](https://gitgud.io/zero64801/wolfdawn) `wolf` CLI.
It unpacks the game's `.wolf` archives, extracts every translatable string to JSON, translates it with the same AI pipeline used elsewhere, then injects the results back into the game byte-exact.
Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine selector at the top.
| Step | Action |
|------|--------|
| **0 Project** | Select the game folder, **Unpack** the `.wolf` archives into a loose `Data/` folder, then **Extract text** (maps, common events, databases, `Game.dat`, external event text, and the project-wide name glossary). Everything is staged into `files/` for translation. |
| **1 Glossary** | Translate the name glossary (`names.json`) first. WOLF references item/skill/enemy names by value across many files, so doing names first keeps them consistent. |
| **2 Translate** | Run the `Wolf RPG (WolfDawn)` module over `files/`. Only the `text` fields are filled in; `source` is preserved so injection can verify each line. |
| **3 Inject** | Write the translations back into the `Data/` binaries. The name glossary is applied consistently across every file. Toggle `--en-punct` (convert Japanese punctuation to ASCII) and `--allow-code-drift` (relax the inline-code guard) as needed. Use **Check name consistency** to catch names translated differently across files. |
| **4 Package** | Either run from the loose `Data/` folder (backs up `Data.wolf``Data.wolf.bak`), or **Repack** a fresh `Data.wolf`, inheriting the original archive's encryption. |
| **5 Saves** | *(Optional)* Update existing `.sav` files so old Japanese saves load cleanly in the translated build. Originals are backed up automatically. |
> **`wolf` binary:** A prebuilt `wolf` CLI is bundled under `util/wolfdawn/bin/<platform>/`, so no build step is needed on supported platforms. If no bundled binary is present for your platform, the tool falls back to compiling it from the vendored source with `cargo build --release`, which requires a [Rust toolchain](https://rustup.rs/); the freshly built binary is then saved into the bundle for reuse. A clear error is shown if neither a bundled binary nor `cargo` is available.
> **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.
---
## Using Copilot & VSCode
[VSCode](https://code.visualstudio.com/) is a free code editor, and with [GitHub Copilot](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot) you get an AI assistant built right into it. This is incredibly useful for translation work — you can ask the AI to help modify game files or even tweak the tool's modules without needing to know how to code.

View file

@ -17,7 +17,7 @@ from PyQt5.QtWidgets import (
QApplication, QMainWindow, QTabWidget, QVBoxLayout, QHBoxLayout,
QWidget, QPushButton, QLabel, QFileDialog, QMessageBox, QProgressBar,
QTextEdit, QSplitter, QGroupBox, QStatusBar, QStackedWidget, QToolButton,
QDialog
QDialog, QComboBox
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer, QSettings, QCoreApplication
from PyQt5.QtGui import QIcon, QFont, QPixmap, QScreen, QGuiApplication
@ -385,6 +385,7 @@ class UpdateDialog(QDialog):
from gui.config_tab import ConfigTab
from gui.translation_tab import TranslationTab
from gui.workflow_tab import WorkflowTab
from gui.wolf_workflow_tab import WolfWorkflowTab
class DazedMTLGUI(QMainWindow):
"""Main GUI window for the DazedMTLTool."""
@ -426,6 +427,52 @@ class DazedMTLGUI(QMainWindow):
except Exception as e:
print(f"Warning: Could not save window state: {e}")
def stop_all_background_threads(self):
"""Gracefully stop every background ``QThread`` before the process exits.
A ``QThread`` that is destroyed while still running aborts the whole
process with SIGABRT ("QThread: Destroyed while thread is still
running"). Background workers here (startup update check, model-list
fetch, workflow/wolf tasks, translation worker) do blocking network or
subprocess I/O and can outlive the window, so we sweep every live
``QThread`` and quit/wait it, terminating only as a last resort.
"""
import gc
current = QThread.currentThread()
threads = []
for obj in gc.get_objects():
try:
if isinstance(obj, QThread) and obj is not current:
threads.append(obj)
except Exception:
continue
for t in threads:
try:
if not t.isRunning():
continue
# Nudge cooperative workers to stop before we block on them.
for stopper in ("stop", "requestInterruption"):
fn = getattr(t, stopper, None)
if callable(fn):
try:
fn()
except Exception:
pass
try:
t.quit()
except Exception:
pass
if not t.wait(3000):
try:
t.terminate()
t.wait(1000)
except Exception:
pass
except Exception:
continue
def closeEvent(self, event):
"""Handle application close event."""
# Save window geometry/state
@ -463,6 +510,13 @@ class DazedMTLGUI(QMainWindow):
except Exception:
pass
# Stop every remaining background thread (model fetch, update check,
# workflow/wolf workers) so the process can exit without aborting.
try:
self.stop_all_background_threads()
except Exception:
pass
event.accept()
def setup_font_scaling(self):
@ -643,15 +697,64 @@ class DazedMTLGUI(QMainWindow):
self.translation_tab = TranslationTab(self)
self.content_stack.addWidget(self.translation_tab)
# Workflow / Automation Tab (index 1)
self.workflow_tab = WorkflowTab(self)
self.content_stack.addWidget(self.workflow_tab)
# Workflow / Automation Tab (index 1) — engine selector swaps between the
# RPGMaker and Wolf guided panels while keeping a single sidebar button.
self.content_stack.addWidget(self._create_workflow_container())
# Configuration Tab (index 2)
self.config_tab = ConfigTab()
self.config_tab.config_changed.connect(self.on_config_changed)
self.content_stack.addWidget(self.config_tab)
def _create_workflow_container(self) -> QWidget:
"""Wrap the RPGMaker and Wolf guided workflows behind an engine selector."""
container = QWidget()
layout = QVBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# ── Engine selector bar ──
bar = QWidget()
bar.setStyleSheet(
"QWidget{background-color:#252526;border-bottom:1px solid #3a3a3a;}"
)
bar_layout = QHBoxLayout(bar)
bar_layout.setContentsMargins(16, 6, 16, 6)
bar_layout.setSpacing(8)
engine_label = QLabel("Engine:")
engine_label.setStyleSheet(
"color:#9d9d9d;font-size:12px;font-weight:bold;background:transparent;border:none;"
)
bar_layout.addWidget(engine_label)
self.workflow_engine_combo = QComboBox()
self.workflow_engine_combo.addItem("RPG Maker (MV/MZ/Ace)")
self.workflow_engine_combo.addItem("Wolf RPG (WolfDawn)")
self.workflow_engine_combo.setStyleSheet(
"QComboBox{background-color:#3c3c3c;color:#cccccc;border:1px solid #555555;"
"border-radius:4px;padding:3px 8px;font-size:12px;min-width:220px;}"
"QComboBox:hover{border-color:#007acc;}"
"QComboBox QAbstractItemView{background-color:#2d2d30;color:#cccccc;"
"selection-background-color:#007acc;}"
)
bar_layout.addWidget(self.workflow_engine_combo)
bar_layout.addStretch()
layout.addWidget(bar)
# ── Stacked guided panels ──
self.workflow_stack = QStackedWidget()
self.workflow_tab = WorkflowTab(self)
self.wolf_workflow_tab = WolfWorkflowTab(self)
self.workflow_stack.addWidget(self.workflow_tab)
self.workflow_stack.addWidget(self.wolf_workflow_tab)
layout.addWidget(self.workflow_stack, 1)
self.workflow_engine_combo.currentIndexChanged.connect(
self.workflow_stack.setCurrentIndex
)
return container
def start_background_update_check(self):
"""Check for tool, Ace, and Forge updates after the GUI is visible."""
if self._update_check_thread and self._update_check_thread.isRunning():
@ -1254,6 +1357,13 @@ def main():
except Exception:
pass
# Final safety net: stop any remaining background threads so a
# QThread is never destroyed while still running (SIGABRT).
try:
window.stop_all_background_threads()
except Exception:
pass
try:
app.aboutToQuit.connect(_on_about_to_quit)
except Exception:

View file

@ -1436,6 +1436,7 @@ class TranslationTab(QWidget):
from modules.nscript import handleOnscripter
from modules.wolf import handleWOLF
from modules.wolf2 import handleWOLF2
from modules.wolfdawn import handleWolfDawn
from modules.regex import handleRegex
from modules.text import handleText
from modules.renpy import handleRenpy
@ -1454,6 +1455,7 @@ class TranslationTab(QWidget):
["Lune", [".l"], handleLune],
["Yuris", [".json"], handleYuris],
["NScript", [".nscript"], handleOnscripter],
["Wolf RPG (WolfDawn)", [".json"], handleWolfDawn],
["Wolf RPG", [".json"], handleWOLF],
["Wolf RPG 2", [".txt"], handleWOLF2],
["Regex", [".txt", ".json", ".script", ".csv"], handleRegex],

View file

@ -69,16 +69,29 @@ class WolfTab(QWidget):
main_layout.setSpacing(10)
# Title and description
title = QLabel("Wolf RPG Editor Translation Settings")
title = QLabel("Wolf RPG Editor Translation Settings (Legacy)")
title.setStyleSheet("font-size: 15px; font-weight: bold; color: #007acc;")
main_layout.addWidget(title)
description = QLabel(
"Enable translation options for Wolf RPG Editor projects. Only enable what you need."
"These toggles configure the legacy 'Wolf RPG' / 'Wolf RPG 2' modules (event-code "
"based extraction). Only enable what you need."
)
description.setWordWrap(True)
description.setStyleSheet("color: #888888; font-size: 10px; margin-bottom: 5px;")
main_layout.addWidget(description)
recommend = QLabel(
"Recommended: use the Workflow tab and pick \"Wolf RPG (WolfDawn)\" for a guided, "
"end-to-end pipeline (unpack \u2192 extract \u2192 translate \u2192 inject \u2192 package). "
"It does not use the options below."
)
recommend.setWordWrap(True)
recommend.setStyleSheet(
"color:#c8c8c8;font-size:11px;background-color:#26333a;"
"border-left:3px solid #007acc;padding:6px 8px;margin-bottom:6px;"
)
main_layout.addWidget(recommend)
# Two-column layout for checkboxes
columns_layout = QHBoxLayout()

896
gui/wolf_workflow_tab.py Normal file
View file

@ -0,0 +1,896 @@
"""
Wolf RPG (WolfDawn) Workflow Tab - guided pipeline for WOLF RPG Editor games.
Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
(see util/wolfdawn):
Step 0 Project - select game folder, unpack .wolf archives, extract text
(strings-extract per file + names-extract) into files/
Step 1 Glossary - translate the project-wide name glossary first
Step 2 Translate - run the "Wolf RPG (WolfDawn)" module over files/
Step 3 Inject - inject translations + names back into the Data/ binaries
Step 4 Package - run from a loose Data/ folder, or repack Data.wolf
Step 5 Saves - fix baked strings in existing .sav files (optional)
The extracted JSON is staged in ``<game_root>/wolf_json/`` (a manifest maps each
file back to its base binary), then imported into ``files/`` for the shared
translation pipeline, mirroring how the Ace workflow uses ``ace_json/``.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from PyQt5.QtCore import Qt, QSettings, QThread, QTimer, pyqtSignal
from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import (
QCheckBox,
QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QScrollArea,
QSplitter,
QTabWidget,
QTextEdit,
QVBoxLayout,
QWidget,
)
from gui.workflow_tab import _make_btn, _make_hr, _make_section_label
from util.project_scanner import detect_wolf_layout
MANIFEST_NAME = "manifest.json"
NAMES_JSON = "names.json"
WORK_DIR_NAME = "wolf_json"
class _WolfTaskWorker(QThread):
"""Run a blocking WolfDawn task callable off the UI thread.
The task receives a ``log`` callable and returns ``(success, message)``.
"""
done = pyqtSignal(bool, str)
log = pyqtSignal(str)
def __init__(self, task):
super().__init__()
self._task = task
def run(self):
try:
ok, msg = self._task(self.log.emit)
self.done.emit(bool(ok), str(msg))
except Exception as exc: # pragma: no cover - defensive
import traceback
self.log.emit(traceback.format_exc())
self.done.emit(False, f"Error: {exc}")
class WolfWorkflowTab(QWidget):
"""Guided automation tab for the full WolfDawn translation pipeline."""
def __init__(self, parent=None):
super().__init__(parent)
self.parent_window = parent
try:
self.settings = QSettings("DazedTranslations", "DazedMTLTool")
except Exception:
self.settings = None
# State
self._game_root: str = ""
self._layout: dict = {}
self._worker: _WolfTaskWorker | None = None
self._buttons: list[QPushButton] = []
self._init_ui()
# ───────────────────────────────── paths ─────────────────────────────────
def _work_dir(self) -> Path:
return Path(self._game_root) / WORK_DIR_NAME
def _manifest_path(self) -> Path:
return self._work_dir() / MANIFEST_NAME
def _read_manifest(self) -> dict | None:
mp = self._manifest_path()
if not mp.is_file():
return None
try:
return json.loads(mp.read_text(encoding="utf-8"))
except Exception:
return None
# ───────────────────────────────── UI setup ──────────────────────────────
def _init_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
splitter = QSplitter(Qt.Horizontal)
splitter.setHandleWidth(1)
splitter.setStyleSheet("QSplitter::handle{background:#3a3a3a;}")
_SCROLL_STYLE = (
"QScrollArea{border:none;background-color:transparent;}"
"QScrollBar:vertical{background:#252526;width:10px;border:none;}"
"QScrollBar::handle:vertical{background:#555555;border-radius:5px;min-height:20px;}"
"QScrollBar::handle:vertical:hover{background:#007acc;}"
"QScrollBar::add-line:vertical,QScrollBar::sub-line:vertical{height:0;}"
)
self._step_tabs = QTabWidget()
self._step_tabs.setStyleSheet("""
QTabWidget::pane { border: none; background-color: #1e1e1e; }
QTabWidget::tab-bar { alignment: left; }
QTabBar { background-color: #252526; }
QTabBar::tab {
background-color: #252526; color: #7a7a7a;
padding: 9px 18px; border: none;
border-right: 1px solid #3a3a3a; font-size: 12px; min-width: 90px;
}
QTabBar::tab:selected {
background-color: #1e1e1e; color: #e0e0e0;
font-weight: bold; border-top: 2px solid #007acc;
}
QTabBar::tab:hover:!selected { background-color: #2d2d30; color: #cccccc; }
""")
_tab_defs = [
("0 Project", self._build_step0),
("1 Glossary", self._build_step1_glossary),
("2 Translate", self._build_step2_translate),
("3 Inject", self._build_step3_inject),
("4 Package", self._build_step4_package),
("5 Saves", self._build_step5_saves),
]
for tab_label, builder in _tab_defs:
page = QWidget()
page_layout = QVBoxLayout(page)
page_layout.setContentsMargins(0, 0, 0, 0)
page_layout.setSpacing(0)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setStyleSheet(_SCROLL_STYLE)
inner = QWidget()
vbox = QVBoxLayout(inner)
vbox.setContentsMargins(24, 18, 24, 12)
vbox.setSpacing(10)
builder(vbox)
vbox.addStretch()
scroll.setWidget(inner)
page_layout.addWidget(scroll, 1)
# Navigation footer
nav = QWidget()
nav.setStyleSheet("QWidget{background-color:#252526;border-top:1px solid #3a3a3a;}")
nav_layout = QHBoxLayout(nav)
nav_layout.setContentsMargins(16, 6, 16, 6)
nav_layout.setSpacing(8)
tab_idx = len(self._step_tabs)
if tab_idx > 0:
back_btn = _make_btn("← Back", "#3a3a3a")
back_btn.setFixedWidth(100)
back_btn.clicked.connect(
lambda _c, i=tab_idx: self._step_tabs.setCurrentIndex(i - 1)
)
nav_layout.addWidget(back_btn)
nav_layout.addStretch()
if tab_idx < len(_tab_defs) - 1:
next_btn = _make_btn("Next →", "#007acc")
next_btn.setFixedWidth(100)
next_btn.clicked.connect(
lambda _c, i=tab_idx, lbl=tab_label: (
self._step_tabs.setTabText(i, "" + lbl),
self._step_tabs.setCurrentIndex(i + 1),
)
)
nav_layout.addWidget(next_btn)
page_layout.addWidget(nav)
self._step_tabs.addTab(page, tab_label)
splitter.addWidget(self._step_tabs)
# Right: log panel
log_panel = QWidget()
lp_layout = QVBoxLayout(log_panel)
lp_layout.setContentsMargins(0, 0, 0, 0)
lp_layout.setSpacing(0)
log_header = QLabel(" ▸ Wolf Workflow Log")
log_header.setStyleSheet(
"background-color:#252526;color:#9d9d9d;font-size:11px;font-weight:bold;"
"padding:7px 10px;border-bottom:1px solid #3a3a3a;letter-spacing:0.5px;"
)
lp_layout.addWidget(log_header)
self.log_area = QTextEdit()
self.log_area.setReadOnly(True)
self.log_area.setFont(QFont("Consolas", 9))
self.log_area.setStyleSheet(
"QTextEdit{background-color:#1e1e1e;color:#c8c8c8;border:none;padding:10px;"
"selection-background-color:#264f78;}"
)
lp_layout.addWidget(self.log_area)
clear_btn = _make_btn("Clear Log", "#3a3a3a")
clear_btn.setStyleSheet(
"QPushButton{background-color:#252526;color:#6a6a6a;border:none;"
"border-top:1px solid #3a3a3a;padding:5px 12px;font-size:11px;font-weight:normal;}"
"QPushButton:hover{background-color:#2d2d30;color:#9d9d9d;}"
)
clear_btn.clicked.connect(self.log_area.clear)
lp_layout.addWidget(clear_btn)
splitter.addWidget(log_panel)
splitter.setSizes([900, 240])
root.addWidget(splitter)
self.setLayout(root)
self._apply_theme()
def _apply_theme(self):
self.setStyleSheet("""
QLineEdit {
background-color: #3c3c3c; border: 1px solid #555555;
border-radius: 4px; padding: 4px 8px; color: #cccccc; font-size: 13px;
}
QLineEdit:focus { border-color: #007acc; }
QLineEdit:disabled { background-color: #2d2d30; color: #666666; }
QCheckBox { color: #cccccc; font-size: 13px; spacing: 7px; }
QCheckBox::indicator {
width: 14px; height: 14px; border: 1px solid #555555;
border-radius: 3px; background-color: #3c3c3c;
}
QCheckBox::indicator:checked { background-color: #007acc; border-color: #007acc; }
QCheckBox::indicator:hover { border-color: #007acc; }
QLabel { color: #cccccc; }
""")
# ─────────────────────────────── helpers ─────────────────────────────────
def _desc(self, text: str) -> QLabel:
lbl = QLabel(text)
lbl.setWordWrap(True)
lbl.setStyleSheet("color:#9d9d9d;font-size:12px;background:transparent;")
return lbl
def _register(self, btn: QPushButton) -> QPushButton:
self._buttons.append(btn)
return btn
def _log(self, message: str):
self.log_area.append(message)
sb = self.log_area.verticalScrollBar()
sb.setValue(sb.maximum())
def _setting(self, key: str, default=None):
if self.settings:
return self.settings.value(f"wolf_workflow/{key}", default)
return default
def _save_setting(self, key: str, value):
if self.settings:
self.settings.setValue(f"wolf_workflow/{key}", value)
def _set_busy(self, busy: bool):
for btn in self._buttons:
btn.setEnabled(not busy)
def _run_task(self, task, on_done=None):
"""Run *task* in a worker thread; disables buttons until it finishes."""
if self._worker is not None and self._worker.isRunning():
QMessageBox.information(self, "Busy", "A task is already running. Please wait.")
return
self._set_busy(True)
worker = _WolfTaskWorker(task)
self._worker = worker
worker.log.connect(self._log)
def _finished(ok: bool, msg: str):
self._set_busy(False)
if msg:
self._log(("" if ok else "") + msg)
if on_done:
try:
on_done(ok, msg)
except Exception:
pass
worker.done.connect(_finished)
# Release the reference and delete the thread object only once the
# thread has fully stopped. The built-in ``finished`` signal fires
# after ``run()`` returns, so the QThread is never garbage-collected
# while still running - which aborts the process with
# "QThread: Destroyed while thread is still running".
worker.finished.connect(worker.deleteLater)
worker.finished.connect(self._clear_worker)
worker.start()
def _clear_worker(self):
if self.sender() is self._worker:
self._worker = None
# ── Step 0: Project ───────────────────────────────────────────────────────
def _build_step0(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 0 · Select Game & Extract Text"))
layout.addWidget(self._desc(
"Pick the WOLF game's root folder (the one containing Game.exe and Data.wolf, "
"or a loose Data/ folder). The tool unpacks the .wolf archives with WolfDawn, "
"extracts all translatable text, and stages it in files/ ready to translate.\n\n"
"A prebuilt WolfDawn 'wolf' CLI ships with the tool, so no setup is needed. "
"(If no prebuilt binary exists for your platform, it is compiled once from "
"source with cargo, which requires Rust.)"
))
row = QHBoxLayout()
self.folder_edit = QLineEdit()
self.folder_edit.setPlaceholderText("Path to the WOLF game folder…")
self.folder_edit.setText(self._setting("last_game_folder", "") or "")
row.addWidget(self.folder_edit, 1)
browse_btn = self._register(_make_btn("Browse…", "#3a3a3a"))
browse_btn.clicked.connect(self._browse_folder)
row.addWidget(browse_btn)
detect_btn = self._register(_make_btn("Detect", "#007acc"))
detect_btn.clicked.connect(self._detect_folder)
row.addWidget(detect_btn)
layout.addLayout(row)
self.status_label = QLabel("No game folder selected.")
self.status_label.setWordWrap(True)
self.status_label.setStyleSheet("color:#c8c8c8;font-size:12px;padding:6px 0px;")
layout.addWidget(self.status_label)
layout.addWidget(_make_hr())
unpack_btn = self._register(_make_btn("① Unpack .wolf archives", "#007acc"))
unpack_btn.clicked.connect(self._unpack)
layout.addWidget(unpack_btn)
layout.addWidget(self._desc(
"Unpacks every .wolf archive into a loose Data/ folder. Skip this if the game "
"already has an unpacked Data/ folder."
))
extract_btn = self._register(_make_btn("② Extract text & import into files/", "#00a86b"))
extract_btn.clicked.connect(self._extract_and_import)
layout.addWidget(extract_btn)
layout.addWidget(self._desc(
"Extracts maps, common events, databases, Game.dat, external event text, and the "
"project-wide name glossary, then imports them into files/ for translation."
))
def _browse_folder(self):
start = self.folder_edit.text() or str(Path.home())
folder = QFileDialog.getExistingDirectory(self, "Select WOLF Game Folder", start)
if folder:
self.folder_edit.setText(folder)
self._detect_folder()
def _detect_folder(self):
root = self.folder_edit.text().strip()
if not root or not Path(root).is_dir():
self.status_label.setText("❌ Folder not found. Pick a valid game directory.")
return
self._game_root = root
self._save_setting("last_game_folder", root)
info = detect_wolf_layout(root)
self._layout = info
if info["engine"] != "WOLF":
self.status_label.setText(
"⚠ This does not look like a WOLF game (no .wolf archives or loose Data/ "
"with maps/CommonEvent.dat were found)."
)
self._log(f"Detect: no WOLF layout found in {root}")
return
archives = info["archives"]
parts = [f"✅ WOLF game detected at {root}"]
if info["unpacked"]:
parts.append(f"Unpacked Data/ folder: {info['data_dir']}")
if archives:
parts.append(f"{len(archives)} .wolf archive(s): " + ", ".join(a.name for a in archives))
if not info["unpacked"] and archives:
parts.append("Run ① Unpack to extract the archives before extracting text.")
self.status_label.setText("\n".join(parts))
self._log("Detect: " + " | ".join(parts))
def _unpack(self):
if not self._require_root():
return
root = Path(self._game_root)
info = detect_wolf_layout(root)
archives = info["archives"]
if not archives:
self._log("Unpack: no .wolf archives found (already unpacked?).")
QMessageBox.information(self, "Unpack", "No .wolf archives found to unpack.")
return
out_dir = root / "Data"
def task(log):
from util import wolfdawn
log(f"Unpacking {len(archives)} archive(s) into {out_dir}")
res = wolfdawn.unpack_all([str(a) for a in archives], str(out_dir), log_fn=log)
if not res.ok:
return False, f"unpack-all exited {res.returncode}"
return True, "Unpacked archives. Now run ② Extract text."
self._run_task(task, on_done=lambda ok, _m: self._detect_folder())
def _extract_and_import(self):
if not self._require_root():
return
info = detect_wolf_layout(self._game_root)
self._layout = info
data_dir = info.get("data_dir")
if not data_dir:
QMessageBox.warning(
self, "Extract",
"No unpacked Data/ folder found. Run ① Unpack first.",
)
return
work_dir = self._work_dir()
game_root = self._game_root
def task(log):
from util import wolfdawn
basic = Path(info["basic_data"]) if info.get("basic_data") else Path(data_dir)
maps_dir = Path(info["map_data"]) if info.get("map_data") else Path(data_dir)
work_dir.mkdir(parents=True, exist_ok=True)
manifest_entries: list[dict] = []
def _extract_one(base: Path, out_name: str, kind: str):
out = work_dir / out_name
log(f"Extracting {base.name}")
res = wolfdawn.strings_extract(str(base), str(out), log_fn=log)
if res.ok and out.is_file():
manifest_entries.append({"json": out_name, "base": str(base), "kind": kind})
else:
log(f" ⚠ skipped {base.name} (exit {res.returncode})")
# Common events
ce = basic / "CommonEvent.dat"
if ce.is_file():
_extract_one(ce, "CommonEvent.dat.json", "common")
# Databases
for stem in ("DataBase", "CDataBase", "SysDatabase"):
proj = basic / f"{stem}.project"
if proj.is_file():
_extract_one(proj, f"{stem}.project.json", "db")
# Game.dat
gd = basic / "Game.dat"
if gd.is_file():
_extract_one(gd, "Game.dat.json", "gamedat")
# Maps
for mps in sorted(maps_dir.glob("*.mps")):
_extract_one(mps, f"{mps.name}.json", "map")
# External event text (optional)
evtext = Path(data_dir) / "Evtext"
if evtext.is_dir() and any(evtext.glob("*.txt")):
out = work_dir / "Evtext.json"
log("Extracting Evtext/ …")
res = wolfdawn.strings_extract(str(evtext), str(out), log_fn=log)
if res.ok and out.is_file():
manifest_entries.append({"json": "Evtext.json", "base": str(evtext), "kind": "txt-dir"})
# Project-wide name glossary
log("Extracting name glossary …")
names_out = work_dir / NAMES_JSON
res = wolfdawn.names_extract(str(data_dir), str(names_out), log_fn=log)
if res.ok and names_out.is_file():
manifest_entries.append({"json": NAMES_JSON, "base": str(data_dir), "kind": "names"})
if not manifest_entries:
return False, "Nothing was extracted. Check the Data/ folder layout."
manifest = {
"root": game_root,
"data_dir": str(data_dir),
"entries": manifest_entries,
}
(work_dir / MANIFEST_NAME).write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
)
# Import all extracted JSON (except the manifest) into files/.
files_dir = Path("files")
files_dir.mkdir(exist_ok=True)
removed = 0
for fp in files_dir.iterdir():
if fp.name == ".gitkeep":
continue
try:
if fp.is_file():
fp.unlink()
removed += 1
elif fp.is_dir():
shutil.rmtree(fp)
removed += 1
except Exception as e:
log(f" ⚠ could not clear {fp.name}: {e}")
if removed:
log(f"Cleared {removed} existing file(s) from files/")
copied = 0
for entry in manifest_entries:
src = work_dir / entry["json"]
if src.is_file():
shutil.copy2(src, files_dir / entry["json"])
copied += 1
return True, (
f"Extracted {len(manifest_entries)} document(s) and imported {copied} into files/. "
"Go to Step 2 to translate."
)
self._run_task(task)
# ── Step 1: Glossary / names ───────────────────────────────────────────────
def _build_step1_glossary(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 1 · Name Glossary (recommended first)"))
layout.addWidget(self._desc(
"WOLF databases reference item/skill/enemy names by value across many files, so "
"translating the glossary first keeps them consistent. This translates only "
f"{NAMES_JSON} using the Wolf RPG (WolfDawn) module, then you can inject it in Step 3."
))
btn = self._register(_make_btn("Translate name glossary now", "#007acc"))
btn.clicked.connect(lambda: self._navigate_to_translation(only=NAMES_JSON, auto_start=True))
layout.addWidget(btn)
layout.addWidget(_make_hr())
layout.addWidget(self._desc(
"Optional: you can also open the glossary in Step 2 together with everything else. "
"Doing names first is just the higher-quality path."
))
# ── Step 2: Translate ──────────────────────────────────────────────────────
def _build_step2_translate(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 2 · Translate"))
layout.addWidget(self._desc(
"Sends the extracted files in files/ to the Translation tab using the "
"Wolf RPG (WolfDawn) module and starts translating. Only the 'text' fields are "
"filled in; 'source' is preserved so injection can verify each line."
))
btn = self._register(_make_btn("Translate all files now", "#00a86b"))
btn.clicked.connect(lambda: self._navigate_to_translation(auto_start=True))
layout.addWidget(btn)
open_btn = self._register(_make_btn("Open Translation tab (no auto-start)", "#3a3a3a"))
open_btn.clicked.connect(lambda: self._navigate_to_translation(auto_start=False))
layout.addWidget(open_btn)
def _navigate_to_translation(self, only: str | None = None, auto_start: bool = False):
"""Switch to the Translation tab, select the WolfDawn module, and check files."""
pw = self.parent_window
tt = getattr(pw, "translation_tab", None) if pw else None
if tt is None:
QMessageBox.warning(self, "Translate", "Translation tab is not available.")
return
try:
combo = tt.module_combo
for i in range(combo.count()):
if "WolfDawn" in combo.itemText(i):
combo.setCurrentIndex(i)
break
except Exception:
pass
try:
tt.refresh_file_lists()
fl = tt.file_list
for idx in range(fl.count()):
item = fl.item(idx)
name = item.text()
check = (only is None) or (name == only)
item.setCheckState(Qt.Checked if check else Qt.Unchecked)
except Exception:
pass
if pw and hasattr(pw, "content_stack"):
pw.content_stack.setCurrentIndex(0)
if hasattr(pw, "nav_buttons"):
for i, btn in enumerate(pw.nav_buttons):
btn.setChecked(i == 0)
if auto_start:
QTimer.singleShot(
150,
lambda: tt.start_translation(skip_confirm=True) if tt is not None else None,
)
# ── Step 3: Inject ─────────────────────────────────────────────────────────
def _build_step3_inject(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 3 · Inject Translations"))
layout.addWidget(self._desc(
"Writes the translated text back into the game's Data/ binaries with WolfDawn, "
"byte-exact. The name glossary is applied consistently across every file. "
"Lines whose inline codes changed are skipped and reported (unless you allow drift)."
))
self.en_punct_cb = QCheckBox("Convert Japanese punctuation to ASCII (--en-punct)")
self.en_punct_cb.setChecked(self._setting("en_punct", "true") == "true")
self.en_punct_cb.stateChanged.connect(
lambda: self._save_setting("en_punct", "true" if self.en_punct_cb.isChecked() else "false")
)
layout.addWidget(self.en_punct_cb)
self.drift_cb = QCheckBox("Allow inline-code drift (--allow-code-drift, riskier)")
self.drift_cb.setChecked(self._setting("allow_code_drift", "false") == "true")
self.drift_cb.stateChanged.connect(
lambda: self._save_setting(
"allow_code_drift", "true" if self.drift_cb.isChecked() else "false"
)
)
layout.addWidget(self.drift_cb)
check_btn = self._register(_make_btn("Check name consistency", "#3a3a3a"))
check_btn.clicked.connect(self._names_check)
layout.addWidget(check_btn)
inject_btn = self._register(_make_btn("Inject all translations", "#00a86b"))
inject_btn.clicked.connect(self._inject)
layout.addWidget(inject_btn)
def _translated_or_source(self, json_name: str) -> Path | None:
"""Prefer translated/<name>; fall back to files/<name>."""
for base in ("translated", "files"):
p = Path(base) / json_name
if p.is_file():
return p
return None
def _names_check(self):
if not self._require_manifest():
return
manifest = self._read_manifest()
def task(log):
from util import wolfdawn
json_files = []
for entry in manifest["entries"]:
p = self._translated_or_source(entry["json"])
if p:
json_files.append(str(p))
if not json_files:
return False, "No translated JSON found. Run Step 2 first."
res = wolfdawn.names_check(json_files, log_fn=log)
if res.returncode == 0:
return True, "Name usage is consistent across files."
return True, "names-check reported inconsistencies (see log above)."
self._run_task(task)
def _inject(self):
if not self._require_manifest():
return
manifest = self._read_manifest()
en_punct = self.en_punct_cb.isChecked()
allow_drift = self.drift_cb.isChecked()
def task(log):
from util import wolfdawn
entries = manifest["entries"]
data_dir = manifest["data_dir"]
applied = 0
failed = 0
# Non-name documents: inject each back onto its base binary.
for entry in entries:
if entry["kind"] == "names":
continue
src = self._translated_or_source(entry["json"])
if src is None:
log(f" ⚠ no JSON for {entry['json']} — skipped")
continue
base = entry["base"]
out = base # in-place (txt-dir writes each file under this dir)
log(f"Injecting {entry['json']}{Path(base).name}")
res = wolfdawn.strings_inject(
str(src), base, out,
allow_code_drift=allow_drift, en_punct=en_punct, log_fn=log,
)
if res.ok:
applied += 1
else:
failed += 1
log(f" ⚠ inject exit {res.returncode} for {entry['json']}")
# Name glossary: apply across the whole data dir.
names_entry = next((e for e in entries if e["kind"] == "names"), None)
if names_entry:
src = self._translated_or_source(names_entry["json"])
if src is not None:
log("Applying name glossary across Data/ …")
res = wolfdawn.names_inject(
str(src), data_dir,
allow_code_drift=allow_drift, en_punct=en_punct, log_fn=log,
)
if res.ok:
applied += 1
else:
failed += 1
log(f" ⚠ names-inject exit {res.returncode}")
msg = f"Injected {applied} document(s)"
if failed:
msg += f", {failed} had guard/errors (see log)"
msg += ". Continue to Step 4 to package."
return failed == 0, msg
self._run_task(task)
# ── Step 4: Package ────────────────────────────────────────────────────────
def _build_step4_package(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 4 · Package the Translated Game"))
layout.addWidget(self._desc(
"Choose how the translated build runs. A loose Data/ folder is simplest for "
"playtesting; repacking rebuilds a single Data.wolf archive for distribution."
))
loose_btn = self._register(_make_btn("Use loose Data/ folder (back up archives)", "#007acc"))
loose_btn.clicked.connect(self._package_loose)
layout.addWidget(loose_btn)
layout.addWidget(self._desc(
"Renames Data.wolf (and any split .wolf archives) to .bak so the WOLF engine loads "
"the loose, translated Data/ folder next to Game.exe."
))
layout.addWidget(_make_hr())
repack_btn = self._register(_make_btn("Repack Data.wolf", "#00a86b"))
repack_btn.clicked.connect(self._package_repack)
layout.addWidget(repack_btn)
layout.addWidget(self._desc(
"Rebuilds Data.wolf from the translated Data/ folder, inheriting the original "
"archive's encryption where possible (--like the backed-up original)."
))
def _package_loose(self):
if not self._require_root():
return
from util.project_scanner import find_wolf_archives
archives = find_wolf_archives(self._game_root)
if not archives:
QMessageBox.information(self, "Package", "No .wolf archives to back up — already loose.")
return
def task(log):
renamed = 0
for arc in archives:
bak = arc.with_suffix(arc.suffix + ".bak")
if bak.exists():
log(f" {bak.name} already exists — leaving {arc.name} in place")
continue
arc.rename(bak)
log(f" {arc.name}{bak.name}")
renamed += 1
return True, f"Backed up {renamed} archive(s); the game now runs from the loose Data/ folder."
self._run_task(task)
def _package_repack(self):
if not self._require_root():
return
info = detect_wolf_layout(self._game_root)
data_dir = info.get("data_dir")
if not data_dir:
QMessageBox.warning(self, "Repack", "No loose Data/ folder found to repack.")
return
root = Path(self._game_root)
output = root / "Data.wolf"
like = None
for cand in (root / "Data.wolf.bak", root / "Data.wolf"):
if cand.is_file():
like = cand
break
def task(log):
from util import wolfdawn
log(f"Repacking {data_dir}{output}")
res = wolfdawn.pack(str(data_dir), str(output), like=str(like) if like else None, log_fn=log)
if not res.ok:
return False, f"pack exited {res.returncode}"
return True, f"Wrote {output.name}."
self._run_task(task)
# ── Step 5: Saves ──────────────────────────────────────────────────────────
def _build_step5_saves(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 5 · Update Existing Saves (optional)"))
layout.addWidget(self._desc(
"WOLF .sav files bake in the game title and some strings at save time. This rewrites "
"them so old Japanese saves load cleanly in the translated build. It is only needed "
"for players who already have saves, or to test against one. A timestamped backup is "
"made automatically."
))
row = QHBoxLayout()
self.save_edit = QLineEdit()
self.save_edit.setPlaceholderText("Save folder or .sav file…")
row.addWidget(self.save_edit, 1)
browse_btn = self._register(_make_btn("Browse…", "#3a3a3a"))
browse_btn.clicked.connect(self._browse_saves)
row.addWidget(browse_btn)
layout.addLayout(row)
run_btn = self._register(_make_btn("Update saves", "#007acc"))
run_btn.clicked.connect(self._update_saves)
layout.addWidget(run_btn)
def _browse_saves(self):
start = self.save_edit.text() or (str(Path(self._game_root) / "Save") if self._game_root else str(Path.home()))
folder = QFileDialog.getExistingDirectory(self, "Select Save Folder", start)
if folder:
self.save_edit.setText(folder)
def _update_saves(self):
save_path = self.save_edit.text().strip()
if not save_path or not Path(save_path).exists():
QMessageBox.warning(self, "Saves", "Pick a valid Save folder or .sav file.")
return
info = detect_wolf_layout(self._game_root) if self._game_root else {}
basic = info.get("basic_data")
game_dat = Path(basic) / "Game.dat" if basic else None
def task(log):
from util import wolfdawn
if not game_dat or not game_dat.is_file():
return False, "Game.dat not found — extract/unpack the game first."
translations = Path("translated")
tl_arg = [str(translations)] if translations.is_dir() else None
log(f"Updating saves in {save_path}")
res = wolfdawn.save_update(
save_path, game=str(game_dat), translations=tl_arg, log_fn=log,
)
if not res.ok:
return False, f"save-update exited {res.returncode}"
return True, "Saves updated (originals backed up)."
self._run_task(task)
# ── shared guards ──────────────────────────────────────────────────────────
def _require_root(self) -> bool:
if not self._game_root or not Path(self._game_root).is_dir():
QMessageBox.warning(self, "No game folder", "Select and detect a game folder in Step 0 first.")
return False
return True
def _require_manifest(self) -> bool:
if not self._require_root():
return False
if self._read_manifest() is None:
QMessageBox.warning(
self, "Not extracted",
"No extraction manifest found. Run Step 0 (Extract text) first.",
)
return False
return True

View file

@ -47,6 +47,7 @@ from modules.yuris import handleYuris
from modules.nscript import handleOnscripter
from modules.wolf import handleWOLF
from modules.wolf2 import handleWOLF2
from modules.wolfdawn import handleWolfDawn
from modules.regex import handleRegex
from modules.text import handleText
from modules.renpy import handleRenpy
@ -71,6 +72,7 @@ MODULES = [
["Lune", ["json"], handleLune],
["Yuris", ["json"], handleYuris],
["NScript", ["txt"], handleOnscripter],
["Wolf RPG (WolfDawn)", ["json"], handleWolfDawn],
["Wolf", ["json"], handleWOLF],
["Wolf", ["txt"], handleWOLF2],
["Regex", ["txt", "json", "script", "csv"], handleRegex],

229
modules/wolfdawn.py Normal file
View file

@ -0,0 +1,229 @@
"""Translate WolfDawn extraction JSON.
This module consumes the JSON produced by the vendored WolfDawn ``wolf`` CLI
(``strings-extract`` / ``names-extract``) and fills in the ``text`` fields with
translations, leaving ``source`` untouched so WolfDawn's inject drift-guard can
match each line back to its original.
Supported document ``kind``s (all share the ``{source, text}`` leaf pattern):
map / common -> scenes[].lines[]
db -> groups[].lines[]
gamedat -> lines[]
txt -> lines[]
txt-dir -> files[].lines[]
names -> names[]
Only entries whose ``source`` contains target-language (Japanese by default)
text are sent to the model; everything else keeps ``text == source`` so inject
is a no-op for it. Translated text is written back verbatim (no re-wrapping) to
keep WolfDawn's inline-code and byte-exact guards happy.
"""
import json
import os
import re
import threading
import time
import traceback
from colorama import Fore
from tqdm import tqdm
from util.paths import PROMPT_PATH, VOCAB_PATH
from util.translation import (
TranslationConfig,
translateAI as sharedtranslateAI,
getPricingConfig,
calculateCost,
)
# Globals (mirror the other engine modules; populated from .env at import time)
MODEL = os.getenv("model")
TIMEOUT = int(os.getenv("timeout"))
LANGUAGE = os.getenv("language").capitalize()
PROMPT = PROMPT_PATH.read_text(encoding="utf-8")
VOCAB = VOCAB_PATH.read_text(encoding="utf-8")
LOCK = threading.Lock()
MAXHISTORY = 10
ESTIMATE = ""
TOKENS = [0, 0]
MISMATCH = [] # Files that hit a length-mismatch during translation
FILENAME = None
# Regex - default matches Japanese (kanji, kana, full-width forms).
LANGREGEX = r"[一-龠ぁ-ゔァ-ヴーa---\uFF61-\uFF9F]+"
# Pricing / batching from the configured model
PRICING_CONFIG = getPricingConfig(MODEL)
INPUTAPICOST = PRICING_CONFIG["inputAPICost"]
OUTPUTAPICOST = PRICING_CONFIG["outputAPICost"]
BATCHSIZE = PRICING_CONFIG["batchSize"]
FREQUENCY_PENALTY = PRICING_CONFIG["frequencyPenalty"]
# tqdm / progress globals (PBAR is polled by util/subprocess_runner.py)
BAR_FORMAT = "{l_bar}{bar:10}{r_bar}{bar:-10b}"
POSITION = 0
LEAVE = False
PBAR = None
TRANSLATION_CONFIG = TranslationConfig(
model=MODEL,
language=LANGUAGE,
prompt=PROMPT,
vocab=VOCAB,
langRegex=LANGREGEX,
batchSize=BATCHSIZE,
maxHistory=MAXHISTORY,
estimateMode=False,
)
def handleWolfDawn(filename, estimate):
"""Entry point used by the CLI/GUI dispatchers. Returns a summary string or 'Fail'."""
global ESTIMATE, TOKENS, FILENAME
ESTIMATE = estimate
FILENAME = filename
start = time.time()
translatedData = openFiles(filename)
if not estimate:
try:
with open("translated/" + filename, "w", encoding="utf-8", newline="\n") as outFile:
json.dump(translatedData[0], outFile, ensure_ascii=False, indent=4)
except Exception:
traceback.print_exc()
return "Fail"
end = time.time()
tqdm.write(getResultString(translatedData, end - start, filename))
with LOCK:
TOKENS[0] += translatedData[1][0]
TOKENS[1] += translatedData[1][1]
totalString = getResultString(["", TOKENS, None], end - start, "TOTAL")
if len(MISMATCH) > 0:
return totalString + Fore.RED + f"\nMismatch Errors: {MISMATCH}" + Fore.RESET
return totalString
def openFiles(filename):
"""Load the extraction JSON, translate it in place, and return [data, tokens, error]."""
with open("files/" + filename, "r", encoding="utf-8-sig") as f:
data = json.load(f)
kind = data.get("kind")
if kind not in ("map", "common", "db", "gamedat", "txt", "txt-dir", "names"):
raise NameError(
f"{filename}: unrecognised WolfDawn document (kind={kind!r}). "
"Expected a strings-extract or names-extract JSON."
)
return parseDocument(data, filename)
def collectEntries(data):
"""Return the list of leaf {source, text} dicts for a WolfDawn document.
The returned dicts are live references into ``data`` so mutating ``text``
updates the document that gets written back out.
"""
kind = data.get("kind")
entries = []
if kind in ("map", "common"):
for scene in data.get("scenes") or []:
for line in scene.get("lines") or []:
entries.append(line)
elif kind == "db":
for group in data.get("groups") or []:
for line in group.get("lines") or []:
entries.append(line)
elif kind in ("gamedat", "txt"):
for line in data.get("lines") or []:
entries.append(line)
elif kind == "txt-dir":
for fileDoc in data.get("files") or []:
for line in fileDoc.get("lines") or []:
entries.append(line)
elif kind == "names":
for name in data.get("names") or []:
entries.append(name)
return entries
def parseDocument(data, filename):
"""Translate every translatable leaf entry and return [data, tokens, error]."""
global PBAR
totalTokens = [0, 0]
entries = collectEntries(data)
# Only translate entries that actually contain target-language text; the rest
# keep text == source so WolfDawn treats them as untouched on inject.
translatable = [
e for e in entries
if isinstance(e.get("source"), str) and re.search(LANGREGEX, e["source"])
]
with tqdm(bar_format=BAR_FORMAT, position=POSITION, total=len(translatable), leave=LEAVE) as pbar:
pbar.desc = filename
PBAR = pbar
if not translatable:
return [data, totalTokens, None]
sources = [e["source"] for e in translatable]
try:
response = translateAI(sources, [])
except Exception as e:
return [data, totalTokens, e]
translated, tokens = response[0], response[1]
totalTokens[0] += tokens[0]
totalTokens[1] += tokens[1]
# Write translations back (skip in estimate mode: translated == sources).
if not ESTIMATE and isinstance(translated, list) and len(translated) == len(translatable):
for entry, text in zip(translatable, translated):
if isinstance(text, str):
entry["text"] = text
return [data, totalTokens, None]
def getResultString(translatedData, translationTime, filename):
"""Format the per-file / total cost + status line for the console log."""
cost = calculateCost(translatedData[1][0], translatedData[1][1], MODEL)
totalTokenstring = (
Fore.YELLOW + "[Input: " + str(translatedData[1][0]) + "]"
"[Output: " + str(translatedData[1][1]) + "]"
"[Cost: ${:,.4f}".format(cost) + "]"
)
timeString = Fore.BLUE + "[" + str(round(translationTime, 1)) + "s]"
if translatedData[2] is None:
return filename + ": " + totalTokenstring + timeString + Fore.GREEN + " \u2713 " + Fore.RESET
try:
raise translatedData[2]
except Exception as e:
traceback.print_exc()
errorString = str(e) + Fore.RED
return (
filename + ": " + totalTokenstring + timeString
+ Fore.RED + " \u2717 " + errorString + Fore.RESET
)
def translateAI(text, history, history_ctx=None):
"""Thin wrapper around the shared translation entry point."""
global PBAR, MISMATCH, FILENAME
TRANSLATION_CONFIG.estimateMode = bool(ESTIMATE)
return sharedtranslateAI(
text=text,
history=history,
config=TRANSLATION_CONFIG,
filename=FILENAME,
pbar=PBAR,
lock=LOCK,
mismatchList=MISMATCH,
)

View file

@ -71,28 +71,17 @@ def _load_fixture_manifest():
def _load_map_excerpt():
"""Small real snippets from Map002 event 17 + Map001 102 + synthetic 101/122."""
map2 = json.loads((ROOT / "files" / "Map002.json").read_text(encoding="utf-8-sig"))
ev17 = next(e for e in map2["events"] if e and e.get("id") == 17)
real = copy.deepcopy(ev17["pages"][0]["list"][7:14]) # 101 + 401 dialogue block
"""Hand-authored page mirroring a 101+401 dialogue block, a 102 choice, and
synthetic 101/401/122 commands. Fully self-contained (no files/ dependency)."""
# 101 message box (4 params, no speaker name) + 401 dialogue lines.
real = [
{"code": 101, "indent": 0, "parameters": ["", 0, 0, 2]},
{"code": 401, "indent": 0, "parameters": ["これは本物のセリフです。"]},
{"code": 401, "indent": 0, "parameters": ["二行目のテキストです。"]},
]
map1 = json.loads((ROOT / "files" / "Map001.json").read_text(encoding="utf-8-sig"))
choice_cmd = None
for ev in map1["events"]:
if not ev:
continue
for pg in ev.get("pages") or []:
if not pg:
continue
for cmd in pg.get("list") or []:
if cmd and cmd.get("code") == 102:
choice_cmd = copy.deepcopy(cmd)
break
if choice_cmd:
break
if choice_cmd:
break
assert choice_cmd is not None, "Map001 should contain a 102 choice command"
# 102 choice command: parameters[0] is the list of Japanese choice strings.
choice_cmd = {"code": 102, "indent": 0, "parameters": [["買う", "売る", "やめる"], -1, 0, 2, 0]}
synthetic = [
{"code": 101, "indent": 0, "parameters": ["", 0, 0, 2, "\\C[2]アリス\\C[0]"]},
@ -233,11 +222,15 @@ class TestMVMZSourceOriginal(unittest.TestCase):
continue
self.fail(f"Re-run sent non-Japanese to translateAI: {item!r}")
def test_map002_micro_page_real_data(self):
"""Translate a tiny slice of Map002 event 17 only (7 commands)."""
map2 = json.loads((ROOT / "files" / "Map002.json").read_text(encoding="utf-8-sig"))
ev17 = next(e for e in map2["events"] if e and e.get("id") == 17)
micro = {"list": copy.deepcopy(ev17["pages"][0]["list"][8:11])} # 3x401 only
def test_micro_page_401_original(self):
"""Translate a tiny 3x401 dialogue slice and confirm _original capture."""
micro = {
"list": [
{"code": 401, "indent": 0, "parameters": ["一行目のセリフ。"]},
{"code": 401, "indent": 0, "parameters": ["二行目のセリフ。"]},
{"code": 401, "indent": 0, "parameters": ["三行目のセリフ。"]},
]
}
page, _ = _run_search_codes(micro)
c401 = _find_commands(page, 401)

174
tests/test_wolfdawn.py Normal file
View file

@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Unit tests for the WolfDawn translation module (modules/wolfdawn.py).
Covers each WolfDawn document ``kind``: translatable (Japanese) leaves get their
``text`` filled in while ``source`` is preserved, and non-translatable leaves are
left untouched so WolfDawn injection treats them as no-ops.
"""
from __future__ import annotations
import copy
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from dotenv import load_dotenv
ROOT = Path(__file__).resolve().parents[1]
os.chdir(ROOT)
sys.path.insert(0, str(ROOT))
load_dotenv(ROOT / ".env", override=True)
import modules.wolfdawn as wd # noqa: E402
def _mock_translate(text, history, history_ctx=None):
"""Return an EN_ prefixed translation for each item, preserving list length."""
if isinstance(text, list):
return [[f"EN_{t}" for t in text], [1, 1]]
return [f"EN_{text}", [1, 1]]
class _WolfTranslateHarness:
"""Run parseDocument with translateAI mocked and captured payloads recorded."""
def __init__(self):
self.captured = []
def run(self, data, filename="doc.json", estimate=False):
def translate(text, history, history_ctx=None):
self.captured.append(copy.deepcopy(text))
return _mock_translate(text, history, history_ctx)
orig_t = wd.translateAI
orig_estimate = wd.ESTIMATE
wd.translateAI = translate
wd.ESTIMATE = estimate
try:
data_copy = copy.deepcopy(data)
result = wd.parseDocument(data_copy, filename)
return result, self.captured
finally:
wd.translateAI = orig_t
wd.ESTIMATE = orig_estimate
MAP_DOC = {
"file": "Map001.mps",
"kind": "map",
"scenes": [
{
"event": 1,
"name": "ev",
"lines": [
{"cmd": 0, "str": 0, "speaker": "NPC", "speaker_src": "x", "source": "こんにちは", "text": "こんにちは"},
{"cmd": 1, "str": 0, "speaker": "", "speaker_src": "", "source": "OK", "text": "OK"},
],
}
],
}
DB_DOC = {
"file": "DataBase.project",
"kind": "db",
"groups": [
{"type": 0, "typeName": "Item", "lines": [
{"row": 0, "field": 2, "rowName": "ポーション", "fieldName": "説明", "source": "HPを回復", "text": "HPを回復"},
]}
],
}
GAMEDAT_DOC = {
"file": "Game.dat",
"kind": "gamedat",
"lines": [{"key": "Title", "source": "ゲームタイトル", "text": "ゲームタイトル"}],
}
NAMES_DOC = {
"kind": "names",
"count": 1,
"names": [{"source": "", "text": "", "occurrences": 2, "note": "Item"}],
}
TXTDIR_DOC = {
"kind": "txt-dir",
"files": [
{"file": "a.txt", "kind": "txt", "encoding": "sjis", "eol": "crlf",
"lines": [{"i": 0, "source": "せりふ", "text": "せりふ"}]},
],
}
class TestCollectEntries(unittest.TestCase):
def test_counts_per_kind(self):
self.assertEqual(len(wd.collectEntries(MAP_DOC)), 2)
self.assertEqual(len(wd.collectEntries(DB_DOC)), 1)
self.assertEqual(len(wd.collectEntries(GAMEDAT_DOC)), 1)
self.assertEqual(len(wd.collectEntries(NAMES_DOC)), 1)
self.assertEqual(len(wd.collectEntries(TXTDIR_DOC)), 1)
class TestTranslationWriteback(unittest.TestCase):
def test_map_translates_japanese_only(self):
(data, _tokens, err), captured = _WolfTranslateHarness().run(MAP_DOC, "Map001.mps.json")
self.assertIsNone(err)
lines = data["scenes"][0]["lines"]
# Japanese line got translated; source preserved.
self.assertEqual(lines[0]["text"], "EN_こんにちは")
self.assertEqual(lines[0]["source"], "こんにちは")
# Non-Japanese line left as-is (text == source), never sent to the model.
self.assertEqual(lines[1]["text"], "OK")
self.assertEqual(captured, [["こんにちは"]])
def test_db_translates(self):
(data, _t, err), _c = _WolfTranslateHarness().run(DB_DOC, "DataBase.project.json")
self.assertIsNone(err)
line = data["groups"][0]["lines"][0]
self.assertEqual(line["text"], "EN_HPを回復")
self.assertEqual(line["source"], "HPを回復")
def test_gamedat_translates(self):
(data, _t, err), _c = _WolfTranslateHarness().run(GAMEDAT_DOC, "Game.dat.json")
self.assertIsNone(err)
self.assertEqual(data["lines"][0]["text"], "EN_ゲームタイトル")
def test_names_translates(self):
(data, _t, err), _c = _WolfTranslateHarness().run(NAMES_DOC, "names.json")
self.assertIsNone(err)
self.assertEqual(data["names"][0]["text"], "EN_剣")
self.assertEqual(data["names"][0]["source"], "")
def test_txtdir_translates(self):
(data, _t, err), _c = _WolfTranslateHarness().run(TXTDIR_DOC, "Evtext.json")
self.assertIsNone(err)
self.assertEqual(data["files"][0]["lines"][0]["text"], "EN_せりふ")
def test_estimate_mode_does_not_write(self):
(data, _t, err), _c = _WolfTranslateHarness().run(MAP_DOC, "Map001.mps.json", estimate=True)
self.assertIsNone(err)
# In estimate mode text stays equal to source.
self.assertEqual(data["scenes"][0]["lines"][0]["text"], "こんにちは")
class TestOpenFiles(unittest.TestCase):
def test_rejects_unknown_kind(self):
with tempfile.TemporaryDirectory() as td:
files_dir = Path(td) / "files"
files_dir.mkdir()
bad = files_dir / "bad.json"
bad.write_text(json.dumps({"kind": "nope"}), encoding="utf-8")
cwd = os.getcwd()
os.chdir(td)
try:
with self.assertRaises(NameError):
wd.openFiles("bad.json")
finally:
os.chdir(cwd)
if __name__ == "__main__":
unittest.main(verbosity=2)

View file

@ -52,6 +52,11 @@ _ACE_MARKERS = {
}
_ACE_DATA_SCRIPTS = {".rvdata2", ".rvdata"}
# WOLF RPG Editor markers. Games ship data inside .wolf archives (one Data.wolf,
# or split BasicData/MapData/... .wolf files), or as a loose Data/ folder holding
# the unpacked binaries (CommonEvent.dat, *.mps maps, *.project databases).
_WOLF_LOOSE_MARKERS = ("BasicData/CommonEvent.dat", "CommonEvent.dat")
# ---------------------------------------------------------------------------
# Public API
@ -60,8 +65,11 @@ _ACE_DATA_SCRIPTS = {".rvdata2", ".rvdata"}
def find_data_folder(game_root: str | Path) -> tuple[Optional[Path], str]:
"""Return (data_path, engine_name) for *game_root*.
engine_name is one of: "MVMZ", "ACE", "UNKNOWN".
engine_name is one of: "MVMZ", "ACE", "WOLF", "UNKNOWN".
data_path is None when nothing is found.
For "WOLF" the data_path is the loose Data/ folder when already unpacked, or
the game root when only .wolf archives are present (still packed).
"""
root = Path(game_root)
if not root.is_dir():
@ -85,6 +93,11 @@ def find_data_folder(game_root: str | Path) -> tuple[Optional[Path], str]:
if rvdata:
return ace_data, "ACE"
# ---- WOLF RPG Editor: loose Data/ or .wolf archives ----
wolf = detect_wolf_layout(root)
if wolf["engine"] == "WOLF":
return (wolf["data_dir"] or root), "WOLF"
# ---- Fallback: any sub-directory that holds .json files ----
for child in root.iterdir():
if child.is_dir() and _has_json_data(child):
@ -93,6 +106,81 @@ def find_data_folder(game_root: str | Path) -> tuple[Optional[Path], str]:
return None, "UNKNOWN"
def find_wolf_archives(game_root: str | Path) -> list[Path]:
"""Return .wolf archive files in the game root (and its Data/ subfolder)."""
root = Path(game_root)
archives: list[Path] = []
for base in (root, root / "Data"):
if base.is_dir():
archives.extend(sorted(base.glob("*.wolf")))
# De-dupe while preserving order.
seen: set[Path] = set()
unique: list[Path] = []
for a in archives:
rp = a.resolve()
if rp not in seen:
seen.add(rp)
unique.append(a)
return unique
def _wolf_loose_data_dir(root: Path) -> Optional[Path]:
"""Return the loose (unpacked) WOLF Data/ folder if it looks unpacked."""
for data_dir in (root / "Data", root):
if not data_dir.is_dir():
continue
for marker in _WOLF_LOOSE_MARKERS:
if (data_dir / marker).is_file():
return data_dir
# A folder with .mps maps (directly or under MapData/) is unpacked WOLF.
if list(data_dir.glob("*.mps")) or list((data_dir / "MapData").glob("*.mps")):
return data_dir
return None
def detect_wolf_layout(game_root: str | Path) -> dict:
"""Inspect *game_root* for a WOLF RPG Editor game.
Returns a dict:
engine : "WOLF" or "UNKNOWN"
root : Path to the game root
archives : list[Path] of .wolf archives found (may be empty)
data_dir : Path to the loose/unpacked Data/ folder, or None
unpacked : bool - True when a usable loose Data/ folder exists
basic_data : Path to Data/BasicData (or Data/ when flattened), or None
map_data : Path to Data/MapData (or Data/ when flattened), or None
"""
root = Path(game_root)
result = {
"engine": "UNKNOWN",
"root": root,
"archives": [],
"data_dir": None,
"unpacked": False,
"basic_data": None,
"map_data": None,
}
if not root.is_dir():
return result
archives = find_wolf_archives(root)
loose = _wolf_loose_data_dir(root)
if not archives and loose is None:
return result
result["engine"] = "WOLF"
result["archives"] = archives
if loose is not None:
result["data_dir"] = loose
result["unpacked"] = True
basic = loose / "BasicData"
result["basic_data"] = basic if basic.is_dir() else loose
maps = loose / "MapData"
result["map_data"] = maps if maps.is_dir() else loose
return result
def list_data_files(data_path: str | Path, engine: str = "MVMZ") -> list[dict]:
"""Return a sorted list of importable file descriptors.

View file

@ -95,6 +95,9 @@ def run_handler(project_root, module_name, filename, estimate_only):
elif "NScript" in module_name:
from modules.nscript import handleOnscripter
handler = handleOnscripter
elif "WolfDawn" in module_name:
from modules.wolfdawn import handleWolfDawn
handler = handleWolfDawn
elif "Wolf RPG 2" in module_name:
from modules.wolf2 import handleWOLF2
handler = handleWOLF2

335
util/wolfdawn/__init__.py Normal file
View file

@ -0,0 +1,335 @@
"""WolfDawn CLI bootstrap and thin subprocess wrappers.
WolfDawn is the vendored Rust toolchain under ``util/wolfdawn-master`` that
unpacks, extracts, injects, and repacks WOLF RPG Editor game data. It ships as
source, so the ``wolf`` binary is built on demand with ``cargo build --release``
the first time it is needed (mirrors the on-demand tool bootstrap in
``util/ace``). Everything the DazedMTLTool 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 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 shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional, Sequence, Union
__all__ = [
"WolfDawnError",
"WolfResult",
"wolfdawn_source_dir",
"wolf_binary_path",
"ensure_wolf_binary",
"unpack_all",
"strings_extract",
"names_extract",
"strings_inject",
"names_inject",
"pack",
"save_update",
"names_check",
]
# ``util/wolfdawn`` -> repo root is two parents up; the vendored source sits at
# ``util/wolfdawn-master``.
_PACKAGE_DIR = Path(__file__).resolve().parent
_UTIL_DIR = _PACKAGE_DIR.parent
_SOURCE_DIR = _UTIL_DIR / "wolfdawn-master"
# Committed, prebuilt binaries live here (per-platform) so end users don't need a
# Rust toolchain. A build is only attempted when no bundled binary is present.
_BUNDLED_DIR = _PACKAGE_DIR / "bin"
PathLike = Union[str, Path]
class WolfDawnError(RuntimeError):
"""Raised when the WolfDawn binary can't be built/located 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 wolfdawn_source_dir() -> Path:
"""Absolute path to the vendored WolfDawn Rust workspace."""
return _SOURCE_DIR
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() -> Path:
"""Committed prebuilt binary path for the current platform (may not exist)."""
return _BUNDLED_DIR / _platform_dir() / _exe_name()
def _built_binary_path() -> Path:
"""Path of a locally cargo-built binary under the source tree's target/."""
return _SOURCE_DIR / "target" / "release" / _exe_name()
def wolf_binary_path() -> Path:
"""Preferred ``wolf`` binary path: bundled if present, else the built path."""
bundled = bundled_binary_path()
if bundled.is_file():
return bundled
return _built_binary_path()
def _persist_binary(src: Path, log_fn=None) -> Path:
"""Copy a freshly built binary into the committed per-platform bundle dir."""
dest = bundled_binary_path()
try:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
dest.chmod(0o755)
_log(f"Saved WolfDawn binary to {dest}", log_fn)
return dest
except Exception as exc: # pragma: no cover - best effort persistence
_log(f"Warning: could not persist WolfDawn binary to {dest}: {exc}", log_fn)
return src
def ensure_wolf_binary(force: bool = False, log_fn=print) -> Path:
"""Return the ``wolf`` binary path, building it with cargo only if needed.
Resolution order:
1. Committed, prebuilt binary under ``util/wolfdawn/bin/<platform>/`` used
directly so end users never need a Rust toolchain.
2. A locally cargo-built binary under the source ``target/release``.
3. Build from source with ``cargo build --release`` and persist the result
into the committed bundle dir for reuse.
Raises :class:`WolfDawnError` with actionable guidance when the source tree
is missing, ``cargo`` is not on PATH, or the build fails.
"""
if not force:
bundled = bundled_binary_path()
if bundled.is_file():
return bundled
built = _built_binary_path()
if built.is_file():
return _persist_binary(built, log_fn)
if not _SOURCE_DIR.is_dir():
raise WolfDawnError(
f"WolfDawn source not found at {_SOURCE_DIR}. The vendored "
"'wolfdawn-master' tree is required to build the 'wolf' CLI."
)
cargo = shutil.which("cargo")
if not cargo:
raise WolfDawnError(
"WolfDawn needs to be compiled but 'cargo' (the Rust toolchain) was "
"not found on PATH. Install Rust from https://rustup.rs/ and try "
"again, or place a prebuilt binary at "
f"{bundled_binary_path()}."
)
_log(f"Building WolfDawn 'wolf' binary (cargo build --release) in {_SOURCE_DIR} ...", log_fn)
try:
proc = subprocess.run(
[cargo, "build", "--release", "--bin", "wolf"],
cwd=str(_SOURCE_DIR),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
except Exception as exc: # pragma: no cover - subprocess spawn failure
raise WolfDawnError(f"Failed to run cargo build for WolfDawn: {exc}") from exc
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-20:]
raise WolfDawnError(
"cargo build --release failed for WolfDawn:\n" + "\n".join(tail)
)
built = _built_binary_path()
if not built.is_file():
raise WolfDawnError(
f"cargo build reported success but {built} is missing. "
"Check the WolfDawn workspace layout."
)
_log("WolfDawn 'wolf' binary ready", log_fn)
return _persist_binary(built, 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)

Binary file not shown.