Initial
This commit is contained in:
parent
b58ae84aa4
commit
014278b339
14 changed files with 1906 additions and 34 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -30,3 +30,7 @@ util/forge/.forge_version.json
|
||||||
util/ace/*.exe
|
util/ace/*.exe
|
||||||
util/ace/.tools_version.json
|
util/ace/.tools_version.json
|
||||||
!util/ace/offline/*.exe
|
!util/ace/offline/*.exe
|
||||||
|
|
||||||
|
# WolfDawn: ignore the Rust build tree, but keep the committed prebuilt binaries.
|
||||||
|
util/wolfdawn-master/target/
|
||||||
|
!util/wolfdawn/bin/**
|
||||||
|
|
|
||||||
23
README.md
23
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)
|
- [Folder Structure](#folder-structure)
|
||||||
- [Finding Untranslated Text (Snipping Tool OCR)](#finding-untranslated-text-snipping-tool-ocr)
|
- [Finding Untranslated Text (Snipping Tool OCR)](#finding-untranslated-text-snipping-tool-ocr)
|
||||||
- [RPG Maker Translation Workflow](#rpg-maker-translation-workflow)
|
- [RPG Maker Translation Workflow](#rpg-maker-translation-workflow)
|
||||||
|
- [Wolf RPG (WolfDawn) Translation Workflow](#wolf-rpg-wolfdawn-translation-workflow)
|
||||||
- [Using Copilot & VSCode](#using-copilot--vscode)
|
- [Using Copilot & VSCode](#using-copilot--vscode)
|
||||||
- [Version Control with Git](#version-control-with-git)
|
- [Version Control with Git](#version-control-with-git)
|
||||||
- [Troubleshooting](#troubleshooting)
|
- [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
|
## 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.
|
[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.
|
||||||
|
|
|
||||||
118
gui/main.py
118
gui/main.py
|
|
@ -17,7 +17,7 @@ from PyQt5.QtWidgets import (
|
||||||
QApplication, QMainWindow, QTabWidget, QVBoxLayout, QHBoxLayout,
|
QApplication, QMainWindow, QTabWidget, QVBoxLayout, QHBoxLayout,
|
||||||
QWidget, QPushButton, QLabel, QFileDialog, QMessageBox, QProgressBar,
|
QWidget, QPushButton, QLabel, QFileDialog, QMessageBox, QProgressBar,
|
||||||
QTextEdit, QSplitter, QGroupBox, QStatusBar, QStackedWidget, QToolButton,
|
QTextEdit, QSplitter, QGroupBox, QStatusBar, QStackedWidget, QToolButton,
|
||||||
QDialog
|
QDialog, QComboBox
|
||||||
)
|
)
|
||||||
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer, QSettings, QCoreApplication
|
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer, QSettings, QCoreApplication
|
||||||
from PyQt5.QtGui import QIcon, QFont, QPixmap, QScreen, QGuiApplication
|
from PyQt5.QtGui import QIcon, QFont, QPixmap, QScreen, QGuiApplication
|
||||||
|
|
@ -385,6 +385,7 @@ class UpdateDialog(QDialog):
|
||||||
from gui.config_tab import ConfigTab
|
from gui.config_tab import ConfigTab
|
||||||
from gui.translation_tab import TranslationTab
|
from gui.translation_tab import TranslationTab
|
||||||
from gui.workflow_tab import WorkflowTab
|
from gui.workflow_tab import WorkflowTab
|
||||||
|
from gui.wolf_workflow_tab import WolfWorkflowTab
|
||||||
|
|
||||||
class DazedMTLGUI(QMainWindow):
|
class DazedMTLGUI(QMainWindow):
|
||||||
"""Main GUI window for the DazedMTLTool."""
|
"""Main GUI window for the DazedMTLTool."""
|
||||||
|
|
@ -426,6 +427,52 @@ class DazedMTLGUI(QMainWindow):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: Could not save window state: {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):
|
def closeEvent(self, event):
|
||||||
"""Handle application close event."""
|
"""Handle application close event."""
|
||||||
# Save window geometry/state
|
# Save window geometry/state
|
||||||
|
|
@ -463,6 +510,13 @@ class DazedMTLGUI(QMainWindow):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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()
|
event.accept()
|
||||||
|
|
||||||
def setup_font_scaling(self):
|
def setup_font_scaling(self):
|
||||||
|
|
@ -643,15 +697,64 @@ class DazedMTLGUI(QMainWindow):
|
||||||
self.translation_tab = TranslationTab(self)
|
self.translation_tab = TranslationTab(self)
|
||||||
self.content_stack.addWidget(self.translation_tab)
|
self.content_stack.addWidget(self.translation_tab)
|
||||||
|
|
||||||
# Workflow / Automation Tab (index 1)
|
# Workflow / Automation Tab (index 1) — engine selector swaps between the
|
||||||
self.workflow_tab = WorkflowTab(self)
|
# RPGMaker and Wolf guided panels while keeping a single sidebar button.
|
||||||
self.content_stack.addWidget(self.workflow_tab)
|
self.content_stack.addWidget(self._create_workflow_container())
|
||||||
|
|
||||||
# Configuration Tab (index 2)
|
# Configuration Tab (index 2)
|
||||||
self.config_tab = ConfigTab()
|
self.config_tab = ConfigTab()
|
||||||
self.config_tab.config_changed.connect(self.on_config_changed)
|
self.config_tab.config_changed.connect(self.on_config_changed)
|
||||||
self.content_stack.addWidget(self.config_tab)
|
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):
|
def start_background_update_check(self):
|
||||||
"""Check for tool, Ace, and Forge updates after the GUI is visible."""
|
"""Check for tool, Ace, and Forge updates after the GUI is visible."""
|
||||||
if self._update_check_thread and self._update_check_thread.isRunning():
|
if self._update_check_thread and self._update_check_thread.isRunning():
|
||||||
|
|
@ -1254,6 +1357,13 @@ def main():
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
try:
|
||||||
app.aboutToQuit.connect(_on_about_to_quit)
|
app.aboutToQuit.connect(_on_about_to_quit)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -1436,6 +1436,7 @@ class TranslationTab(QWidget):
|
||||||
from modules.nscript import handleOnscripter
|
from modules.nscript import handleOnscripter
|
||||||
from modules.wolf import handleWOLF
|
from modules.wolf import handleWOLF
|
||||||
from modules.wolf2 import handleWOLF2
|
from modules.wolf2 import handleWOLF2
|
||||||
|
from modules.wolfdawn import handleWolfDawn
|
||||||
from modules.regex import handleRegex
|
from modules.regex import handleRegex
|
||||||
from modules.text import handleText
|
from modules.text import handleText
|
||||||
from modules.renpy import handleRenpy
|
from modules.renpy import handleRenpy
|
||||||
|
|
@ -1454,6 +1455,7 @@ class TranslationTab(QWidget):
|
||||||
["Lune", [".l"], handleLune],
|
["Lune", [".l"], handleLune],
|
||||||
["Yuris", [".json"], handleYuris],
|
["Yuris", [".json"], handleYuris],
|
||||||
["NScript", [".nscript"], handleOnscripter],
|
["NScript", [".nscript"], handleOnscripter],
|
||||||
|
["Wolf RPG (WolfDawn)", [".json"], handleWolfDawn],
|
||||||
["Wolf RPG", [".json"], handleWOLF],
|
["Wolf RPG", [".json"], handleWOLF],
|
||||||
["Wolf RPG 2", [".txt"], handleWOLF2],
|
["Wolf RPG 2", [".txt"], handleWOLF2],
|
||||||
["Regex", [".txt", ".json", ".script", ".csv"], handleRegex],
|
["Regex", [".txt", ".json", ".script", ".csv"], handleRegex],
|
||||||
|
|
|
||||||
|
|
@ -69,17 +69,30 @@ class WolfTab(QWidget):
|
||||||
main_layout.setSpacing(10)
|
main_layout.setSpacing(10)
|
||||||
|
|
||||||
# Title and description
|
# 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;")
|
title.setStyleSheet("font-size: 15px; font-weight: bold; color: #007acc;")
|
||||||
main_layout.addWidget(title)
|
main_layout.addWidget(title)
|
||||||
|
|
||||||
description = QLabel(
|
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.setWordWrap(True)
|
||||||
description.setStyleSheet("color: #888888; font-size: 10px; margin-bottom: 5px;")
|
description.setStyleSheet("color: #888888; font-size: 10px; margin-bottom: 5px;")
|
||||||
main_layout.addWidget(description)
|
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
|
# Two-column layout for checkboxes
|
||||||
columns_layout = QHBoxLayout()
|
columns_layout = QHBoxLayout()
|
||||||
columns_layout.setSpacing(40)
|
columns_layout.setSpacing(40)
|
||||||
|
|
|
||||||
896
gui/wolf_workflow_tab.py
Normal file
896
gui/wolf_workflow_tab.py
Normal 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
|
||||||
|
|
@ -47,6 +47,7 @@ from modules.yuris import handleYuris
|
||||||
from modules.nscript import handleOnscripter
|
from modules.nscript import handleOnscripter
|
||||||
from modules.wolf import handleWOLF
|
from modules.wolf import handleWOLF
|
||||||
from modules.wolf2 import handleWOLF2
|
from modules.wolf2 import handleWOLF2
|
||||||
|
from modules.wolfdawn import handleWolfDawn
|
||||||
from modules.regex import handleRegex
|
from modules.regex import handleRegex
|
||||||
from modules.text import handleText
|
from modules.text import handleText
|
||||||
from modules.renpy import handleRenpy
|
from modules.renpy import handleRenpy
|
||||||
|
|
@ -71,6 +72,7 @@ MODULES = [
|
||||||
["Lune", ["json"], handleLune],
|
["Lune", ["json"], handleLune],
|
||||||
["Yuris", ["json"], handleYuris],
|
["Yuris", ["json"], handleYuris],
|
||||||
["NScript", ["txt"], handleOnscripter],
|
["NScript", ["txt"], handleOnscripter],
|
||||||
|
["Wolf RPG (WolfDawn)", ["json"], handleWolfDawn],
|
||||||
["Wolf", ["json"], handleWOLF],
|
["Wolf", ["json"], handleWOLF],
|
||||||
["Wolf", ["txt"], handleWOLF2],
|
["Wolf", ["txt"], handleWOLF2],
|
||||||
["Regex", ["txt", "json", "script", "csv"], handleRegex],
|
["Regex", ["txt", "json", "script", "csv"], handleRegex],
|
||||||
|
|
|
||||||
229
modules/wolfdawn.py
Normal file
229
modules/wolfdawn.py
Normal 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-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,
|
||||||
|
)
|
||||||
|
|
@ -71,28 +71,17 @@ def _load_fixture_manifest():
|
||||||
|
|
||||||
|
|
||||||
def _load_map_excerpt():
|
def _load_map_excerpt():
|
||||||
"""Small real snippets from Map002 event 17 + Map001 102 + synthetic 101/122."""
|
"""Hand-authored page mirroring a 101+401 dialogue block, a 102 choice, and
|
||||||
map2 = json.loads((ROOT / "files" / "Map002.json").read_text(encoding="utf-8-sig"))
|
synthetic 101/401/122 commands. Fully self-contained (no files/ dependency)."""
|
||||||
ev17 = next(e for e in map2["events"] if e and e.get("id") == 17)
|
# 101 message box (4 params, no speaker name) + 401 dialogue lines.
|
||||||
real = copy.deepcopy(ev17["pages"][0]["list"][7:14]) # 101 + 401 dialogue block
|
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"))
|
# 102 choice command: parameters[0] is the list of Japanese choice strings.
|
||||||
choice_cmd = None
|
choice_cmd = {"code": 102, "indent": 0, "parameters": [["買う", "売る", "やめる"], -1, 0, 2, 0]}
|
||||||
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"
|
|
||||||
|
|
||||||
synthetic = [
|
synthetic = [
|
||||||
{"code": 101, "indent": 0, "parameters": ["", 0, 0, 2, "\\C[2]アリス\\C[0]"]},
|
{"code": 101, "indent": 0, "parameters": ["", 0, 0, 2, "\\C[2]アリス\\C[0]"]},
|
||||||
|
|
@ -233,11 +222,15 @@ class TestMVMZSourceOriginal(unittest.TestCase):
|
||||||
continue
|
continue
|
||||||
self.fail(f"Re-run sent non-Japanese to translateAI: {item!r}")
|
self.fail(f"Re-run sent non-Japanese to translateAI: {item!r}")
|
||||||
|
|
||||||
def test_map002_micro_page_real_data(self):
|
def test_micro_page_401_original(self):
|
||||||
"""Translate a tiny slice of Map002 event 17 only (7 commands)."""
|
"""Translate a tiny 3x401 dialogue slice and confirm _original capture."""
|
||||||
map2 = json.loads((ROOT / "files" / "Map002.json").read_text(encoding="utf-8-sig"))
|
micro = {
|
||||||
ev17 = next(e for e in map2["events"] if e and e.get("id") == 17)
|
"list": [
|
||||||
micro = {"list": copy.deepcopy(ev17["pages"][0]["list"][8:11])} # 3x401 only
|
{"code": 401, "indent": 0, "parameters": ["一行目のセリフ。"]},
|
||||||
|
{"code": 401, "indent": 0, "parameters": ["二行目のセリフ。"]},
|
||||||
|
{"code": 401, "indent": 0, "parameters": ["三行目のセリフ。"]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
page, _ = _run_search_codes(micro)
|
page, _ = _run_search_codes(micro)
|
||||||
c401 = _find_commands(page, 401)
|
c401 = _find_commands(page, 401)
|
||||||
|
|
|
||||||
174
tests/test_wolfdawn.py
Normal file
174
tests/test_wolfdawn.py
Normal 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)
|
||||||
|
|
@ -52,6 +52,11 @@ _ACE_MARKERS = {
|
||||||
}
|
}
|
||||||
_ACE_DATA_SCRIPTS = {".rvdata2", ".rvdata"}
|
_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
|
# Public API
|
||||||
|
|
@ -60,8 +65,11 @@ _ACE_DATA_SCRIPTS = {".rvdata2", ".rvdata"}
|
||||||
def find_data_folder(game_root: str | Path) -> tuple[Optional[Path], str]:
|
def find_data_folder(game_root: str | Path) -> tuple[Optional[Path], str]:
|
||||||
"""Return (data_path, engine_name) for *game_root*.
|
"""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.
|
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)
|
root = Path(game_root)
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
|
|
@ -85,6 +93,11 @@ def find_data_folder(game_root: str | Path) -> tuple[Optional[Path], str]:
|
||||||
if rvdata:
|
if rvdata:
|
||||||
return ace_data, "ACE"
|
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 ----
|
# ---- Fallback: any sub-directory that holds .json files ----
|
||||||
for child in root.iterdir():
|
for child in root.iterdir():
|
||||||
if child.is_dir() and _has_json_data(child):
|
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"
|
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]:
|
def list_data_files(data_path: str | Path, engine: str = "MVMZ") -> list[dict]:
|
||||||
"""Return a sorted list of importable file descriptors.
|
"""Return a sorted list of importable file descriptors.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ def run_handler(project_root, module_name, filename, estimate_only):
|
||||||
elif "NScript" in module_name:
|
elif "NScript" in module_name:
|
||||||
from modules.nscript import handleOnscripter
|
from modules.nscript import handleOnscripter
|
||||||
handler = handleOnscripter
|
handler = handleOnscripter
|
||||||
|
elif "WolfDawn" in module_name:
|
||||||
|
from modules.wolfdawn import handleWolfDawn
|
||||||
|
handler = handleWolfDawn
|
||||||
elif "Wolf RPG 2" in module_name:
|
elif "Wolf RPG 2" in module_name:
|
||||||
from modules.wolf2 import handleWOLF2
|
from modules.wolf2 import handleWOLF2
|
||||||
handler = handleWOLF2
|
handler = handleWOLF2
|
||||||
|
|
|
||||||
335
util/wolfdawn/__init__.py
Normal file
335
util/wolfdawn/__init__.py
Normal 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)
|
||||||
BIN
util/wolfdawn/bin/linux/wolf
Normal file
BIN
util/wolfdawn/bin/linux/wolf
Normal file
Binary file not shown.
Loading…
Reference in a new issue