diff --git a/.gitignore b/.gitignore index ba96724..4173a2f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/** diff --git a/README.md b/README.md index ae2c356..97f0af4 100644 --- a/README.md +++ b/README.md @@ -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//`, 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. diff --git a/gui/main.py b/gui/main.py index 7d6c3b6..4c81196 100644 --- a/gui/main.py +++ b/gui/main.py @@ -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: diff --git a/gui/translation_tab.py b/gui/translation_tab.py index ccc55fb..1972e09 100644 --- a/gui/translation_tab.py +++ b/gui/translation_tab.py @@ -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], diff --git a/gui/wolf_tab.py b/gui/wolf_tab.py index c37b9e5..e3c2554 100644 --- a/gui/wolf_tab.py +++ b/gui/wolf_tab.py @@ -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() diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py new file mode 100644 index 0000000..ec88d97 --- /dev/null +++ b/gui/wolf_workflow_tab.py @@ -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 ``/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/; fall back to files/.""" + 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 diff --git a/modules/main.py b/modules/main.py index c4dbc4b..8033c61 100644 --- a/modules/main.py +++ b/modules/main.py @@ -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], diff --git a/modules/wolfdawn.py b/modules/wolfdawn.py new file mode 100644 index 0000000..e4fb09e --- /dev/null +++ b/modules/wolfdawn.py @@ -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-zA-Z0-9\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, + ) diff --git a/tests/test_mvmz_source_original.py b/tests/test_mvmz_source_original.py index b3bc091..dee4bca 100644 --- a/tests/test_mvmz_source_original.py +++ b/tests/test_mvmz_source_original.py @@ -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) diff --git a/tests/test_wolfdawn.py b/tests/test_wolfdawn.py new file mode 100644 index 0000000..5afe285 --- /dev/null +++ b/tests/test_wolfdawn.py @@ -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) diff --git a/util/project_scanner.py b/util/project_scanner.py index 77e59a8..ab1d127 100644 --- a/util/project_scanner.py +++ b/util/project_scanner.py @@ -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. diff --git a/util/subprocess_runner.py b/util/subprocess_runner.py index 7f637d6..2c8ba74 100644 --- a/util/subprocess_runner.py +++ b/util/subprocess_runner.py @@ -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 diff --git a/util/wolfdawn/__init__.py b/util/wolfdawn/__init__.py new file mode 100644 index 0000000..4f0dfe2 --- /dev/null +++ b/util/wolfdawn/__init__.py @@ -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//`` — 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 `` 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 ... -o ``. + + Each ``Name.wolf`` is unpacked to ``/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 -o ``. + + 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 -o `` (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 --base -o [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 --data [-o ] [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 ...`` - 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 -o [--like | --encrypt [--version ]]``.""" + 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 [-o ] [--title | --game ] [--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) diff --git a/util/wolfdawn/bin/linux/wolf b/util/wolfdawn/bin/linux/wolf new file mode 100644 index 0000000..a55f656 Binary files /dev/null and b/util/wolfdawn/bin/linux/wolf differ