"""
RPGMaker Workflow Tab - Automation hub for the full translation pipeline.
Provides a guided, step-by-step interface:
Step 0 – Select game project folder and import data files into files/
Step 1 – (Optional) Pre-process game files
Step 2 – Setup: speaker flags, Project Setup skill, vocab / quirks / game skill
Step 3 – Translation: Phase 0 (DB), Phase 1 (dialogue), Phase 1b (111 cache)
Step 4 – Translation Phase 2 (risky codes)
Step 5 – Plugins.js prompt helpers + export translated/ to the game
Step 6 – Install TL Inspector and/or Forge playtest plugins
"""
from __future__ import annotations
import json
import os
import sys
import threading
from pathlib import Path
from util.paths import VOCAB_PATH, APP_NAME, ORG_NAME
from util.skills import load_clipboard_skill, load_project_setup
from util.vocab import BASE_SEPARATOR as _SHARED_BASE_SEPARATOR
import jsbeautifier
from PyQt5.QtCore import Qt, QEvent, QSettings, QSize, QThread, QTimer, pyqtSignal
from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QFileDialog,
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QSplitter,
QStyle,
QTabWidget,
QTextEdit,
QToolButton,
QVBoxLayout,
QWidget,
QAbstractItemView,
)
from gui.setup_skills_editors import SetupSkillsEditors
from gui.translation_tab import (
BATCH_MODE_LABEL,
BATCH_MODE_BENEFIT_NOTE,
BATCH_COLLECT_LIVE_CHARGE_NOTE,
)
WORKFLOW_TL_NORMAL_LABEL = "Normal Translate"
# ---------------------------------------------------------------------------
# Phase profiles applied to rpgmakermvmz.py before each translation run
# ---------------------------------------------------------------------------
# Core database files — translated first (names/descriptions)
_DB_FILES = {
"Actors.json", "Armors.json", "Classes.json", "Enemies.json",
"Items.json", "MapInfos.json", "Skills.json", "States.json",
"System.json", "Weapons.json",
}
# Event files — translated in phases 1 / 1b / 2
_EVENT_FILES_EXACT = {"CommonEvents.json", "Troops.json"}
# Any Map????.json is also an event file (matched by prefix below)
PHASE0_CONFIG = {
# All event codes OFF — DB files use top-level name/description fields
"CODE101": False, "CODE401": False, "CODE405": False,
"CODE102": False, "CODE408": False,
"CODE111": False, "CODE122": False, "CODE357": False,
"CODE355655": False, "CODE657": False, "CODE356": False,
"CODE320": False, "CODE324": False, "CODE325": False,
"CODE108": False,
}
PHASE1_CONFIG = {
# Safe dialogue / choices
"CODE101": True,
"CODE401": True,
"CODE405": True,
"CODE102": True,
"CODE408": True,
# Risky codes OFF
"CODE122": False,
"CODE355655": False,
"CODE357": False,
"CODE657": False,
"CODE356": False,
"CODE320": False,
"CODE324": False,
"CODE325": False,
"CODE111": False,
"CODE108": False,
}
PHASE1B_CONFIG = {
# Dialogue OFF (handled by Phase 1)
"CODE101": False,
"CODE401": False,
"CODE405": False,
"CODE102": False,
"CODE408": False,
# Only 111 ON — build the var-translation cache from string comparisons
"CODE111": True,
"CODE122": False,
"CODE357": False,
"CODE355655": False,
"CODE657": False,
"CODE356": False,
"CODE320": False,
"CODE324": False,
"CODE325": False,
"CODE108": False,
}
PHASE2_CONFIG = {
# Dialogue OFF (already handled by Phase 1)
"CODE101": False,
"CODE401": False,
"CODE405": False,
"CODE102": False,
"CODE408": False,
# Risky codes ON (111 OFF — cache already built by Phase 1b)
"CODE122": True,
"CODE357": True,
"CODE111": False,
"CODE356": False, # plugin cmd — user can enable manually if needed
"CODE108": False, # comment — rarely needed
}
# ─────────────────────────────────────────────────────────────────────────────
# Background workers
# ─────────────────────────────────────────────────────────────────────────────
class _ScanWorker(QThread):
"""Run project_scanner.list_data_files in a thread."""
done = pyqtSignal(object) # list[dict]
error = pyqtSignal(str)
def __init__(self, data_path: str, engine: str):
super().__init__()
self.data_path = data_path
self.engine = engine
def run(self):
try:
from util.project_scanner import list_data_files
result = list_data_files(self.data_path, self.engine)
self.done.emit(result)
except Exception as exc:
self.error.emit(str(exc))
class _ImportWorker(QThread):
"""Copy selected files into files/ directory."""
done = pyqtSignal(int, list) # count_copied, errors
log = pyqtSignal(str)
def __init__(self, file_items: list[dict], dest_dir: str):
super().__init__()
self.file_items = file_items
self.dest_dir = dest_dir
def run(self):
try:
import shutil
from util.project_scanner import import_to_files
# Clear existing files/ contents before importing so stale files
# from a previous game don't linger. translated/ is intentionally
# left untouched.
dest = Path(self.dest_dir)
if dest.exists():
removed = 0
for fp in dest.iterdir():
if fp.name == ".gitkeep":
continue
if fp.is_file():
try:
fp.unlink()
removed += 1
except Exception as e:
self.log.emit(f" ⚠ Could not remove {fp.name}: {e}")
elif fp.is_dir():
try:
shutil.rmtree(fp)
removed += 1
except Exception as e:
self.log.emit(f" ⚠ Could not remove {fp.name}: {e}")
if removed:
self.log.emit(f"Cleared {removed} existing file(s) from {dest.name}/")
self.log.emit(f"Importing {len(self.file_items)} file(s) into files/ …")
count, errors = import_to_files(self.file_items, self.dest_dir)
self.done.emit(count, errors)
except Exception as exc:
self.done.emit(0, [str(exc)])
class _ExportWorker(QThread):
done = pyqtSignal(int, list)
log = pyqtSignal(str)
def __init__(self, game_data_path: str, filter_names: list[str] | None = None):
super().__init__()
self.game_data_path = game_data_path
self.filter_names = filter_names # if set, only export these filenames
def run(self):
try:
from util.project_scanner import export_to_game
if self.filter_names:
self.log.emit(
f"Exporting {len(self.filter_names)} active file(s) → {self.game_data_path} …"
)
else:
self.log.emit(f"Exporting translated/ → {self.game_data_path} …")
count, errors = export_to_game(
"translated", self.game_data_path, filenames=self.filter_names
)
self.done.emit(count, errors)
except Exception as exc:
self.done.emit(0, [str(exc)])
class _SubprocessWorker(QThread):
"""Run an arbitrary shell command in a given working directory, streaming output."""
done = pyqtSignal(bool, str) # success, final message
log = pyqtSignal(str)
def __init__(self, cmd: list, cwd: str, label: str = ""):
super().__init__()
self.cmd = cmd
self.cwd = cwd
self.label = label or cmd[0]
def run(self):
import subprocess
import shutil as _shutil
try:
exe = _shutil.which(self.cmd[0])
if exe is None:
self.done.emit(
False,
f"'{self.cmd[0]}' not found on PATH. "
"Make sure it is installed and accessible from the terminal.",
)
return
self.log.emit(f"$ {' '.join(str(c) for c in self.cmd)} — cwd: {self.cwd}")
proc = subprocess.Popen(
self.cmd,
cwd=self.cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
)
for line in proc.stdout:
stripped = line.rstrip("\n")
if stripped:
self.log.emit(stripped)
proc.wait()
if proc.returncode == 0:
self.done.emit(True, f"{self.label}: finished successfully.")
else:
self.done.emit(False, f"{self.label}: exited with code {proc.returncode}.")
except Exception as exc:
self.done.emit(False, f"{self.label}: {exc}")
class _JsonFormatWorker(QThread):
"""Format all JSON files in a directory using the bundled dazedformat utility."""
done = pyqtSignal(bool, str)
log = pyqtSignal(str)
def __init__(self, data_path: str):
super().__init__()
self.data_path = data_path
def run(self):
try:
from util.dazedformat import format_json_files
self.log.emit(f"Formatting JSON files in {self.data_path} …")
count, errors = format_json_files(self.data_path, log=self.log.emit)
for e in errors:
self.log.emit(f" ⚠ {e}")
if errors:
self.done.emit(False, f"dazedformat: {count} formatted, {len(errors)} error(s).")
else:
self.done.emit(True, f"dazedformat: {count} file(s) formatted successfully.")
except Exception as exc:
self.done.emit(False, f"dazedformat error: {exc}")
# Never copy these into a game root when applying gameupdate/ (local updater state
# or stray translator assets that must not overwrite a live install).
_GAMEUPDATE_COPY_SKIP_NAMES = frozenset({
"previous_patch_sha.txt",
})
# UberWolf is only useful for WOLF RPG Editor archives. The generic workflow
# handles RPG Maker MV/MZ/Ace and must never install these files; the dedicated
# WOLF workflow intentionally continues using the smaller shared skip set.
_WOLF_ONLY_GAMEUPDATE_NAMES = frozenset({
"UberWolfCli.exe",
"UberWolfCli.LICENSE.txt",
})
_RPG_GAMEUPDATE_COPY_SKIP_NAMES = (
_GAMEUPDATE_COPY_SKIP_NAMES | _WOLF_ONLY_GAMEUPDATE_NAMES
)
class _FileCopyWorker(QThread):
"""Recursively copy a source folder into a destination folder."""
done = pyqtSignal(int, list) # count_copied, errors
log = pyqtSignal(str)
def __init__(self, src: str, dst: str, skip_names: frozenset[str] | None = None):
super().__init__()
self.src = src
self.dst = dst
self.skip_names = skip_names or frozenset()
def run(self):
import shutil
src = Path(self.src)
dst = Path(self.dst)
if not src.is_dir():
self.done.emit(0, [f"Source folder not found: {src}"])
return
dst.mkdir(parents=True, exist_ok=True)
copied = 0
errors: list[str] = []
self.log.emit(f"Copying {src} → {dst} …")
for fp in src.rglob("*"):
if not fp.is_file():
continue
if fp.name in self.skip_names:
self.log.emit(f" skipped {fp.relative_to(src)}")
continue
rel = fp.relative_to(src)
target = dst / rel
try:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(fp, target)
copied += 1
self.log.emit(f" copied {rel}")
except Exception as exc:
errors.append(f"{rel}: {exc}")
self.done.emit(copied, errors)
class _JsFormatWorker(QThread):
"""Format a JavaScript file using jsbeautifier (pure Python, no Node required)."""
done = pyqtSignal(bool, str)
log = pyqtSignal(str)
def __init__(self, js_path: str):
super().__init__()
self.js_path = js_path
def run(self):
try:
p = Path(self.js_path)
self.log.emit(f"Formatting {p.name} …")
original = p.read_text(encoding="utf-8")
opts = jsbeautifier.default_options()
opts.indent_size = 2
opts.indent_char = " "
opts.max_preserve_newlines = 2
opts.preserve_newlines = True
opts.end_with_newline = True
formatted = jsbeautifier.beautify(original, opts)
p.write_text(formatted, encoding="utf-8")
self.done.emit(True, f"plugins.js formatted successfully ({len(formatted):,} chars).")
except Exception as exc:
self.done.emit(False, f"Format error: {exc}")
def _make_section_label(text: str) -> QLabel:
lbl = QLabel(text)
lbl.setStyleSheet(
"font-size: 14px; font-weight: bold; color: #e0e0e0;"
"padding: 7px 0px 6px 10px; background-color: transparent;"
"border-left: 3px solid #007acc; margin-top: 6px;"
)
return lbl
def _make_help_button() -> QToolButton:
"""Small circular ? control for step help dialogs."""
btn = QToolButton()
btn.setText("?")
btn.setFixedSize(24, 24)
btn.setCursor(Qt.PointingHandCursor)
btn.setToolTip("How to use this step")
btn.setStyleSheet(
"QToolButton{"
"background-color:#2d2d30;color:#9d9d9d;border:1px solid #555;"
"border-radius:12px;font-size:12px;font-weight:bold;padding:0;}"
"QToolButton:hover{background-color:#3a3a3a;color:#ffffff;border-color:#007acc;}"
"QToolButton:pressed{background-color:#1e1e1e;}"
)
return btn
# Per-step help copy shown by the header ? button (keeps step UIs compact).
_STEP_HELP: dict[int, str] = {
0: (
"Step 0 - Project
"
"Select the game root folder (the folder that contains the game's data files).
"
"The tool detects MV/MZ/Ace layout, lists JSON files, and lets you import them into "
"files/ for translation.
"
"Tip: when switching games, clear files/ and translated/ "
"so old project data does not mix in."
),
1: (
"Step 1 - Pre-process (optional)
"
"Optional prep against the game folder before importing:
"
"• Format data JSON (dazedformat)
"
"• Format plugins.js (MV/MZ) for easier editing
"
"• Copy a gameupdate/ folder into the project
"
"Paths auto-fill when a project folder is detected. "
"Skip this step if you do not need those tasks."
),
2: (
"Step 2 - Setup (Speakers + Glossary)
"
"1. Speaker flags - enable the formats this game uses "
"(INLINE401 / FIRSTLINE / FACENAME). Tooltips explain each flag. "
"Project Setup's speakers block can recommend ENABLE/SKIP.
"
"2. Parse Speakers - collect speaker names from event files into "
"vocab.txt (# Speakers). Does not translate dialogue.
"
"3. Copy Project Setup - paste into Cursor/Copilot with the game repo open. "
"The AI returns labeled code blocks:
"
"• glossary → Vocab tab
"
"• speakers → apply flags above
"
"• translation_quirks → Quirks tab "
"(skills/quirks.md)
"
"• game_skill → Game skills → translation tab "
"(skills/game.md - Translation Frame, merged into the API prompt)
"
"Optional: + on Game skills adds custom skills/*.md "
"overlays (also merged into the API prompt - can hurt quality; use sparingly).
"
"Use one game folder per translation run so frame/quirks stay in the prompt cache."
),
3: (
"Step 3 - TL Phase 1
"
"Safe dialogue / database translation.
"
"• Set text-wrap widths if needed (or copy the wrap analysis prompt)
"
"• Phase 0 - database names/descriptions
"
"• Phase 1 - dialogue / choices
"
"• Phase 1b - build the code 111 variable-translation cache
"
"Choose Normal or Batch mode at the top. Run each phase from the Translation tab "
"after the workflow applies the phase profile."
),
4: (
"Step 4 - TL Phase 2
"
"Risky / plugin-related codes (variables, plugin commands, etc.).
"
"Copy the Plugin Prompt and audit the game first, then enable only codes that "
"contain player-visible text. Set code 122 variable ID ranges carefully - "
"translating IDs used as logic keys will break the game."
),
5: (
"Step 5 - Plugins / Scripts & Export
"
"MV/MZ - Plugins (optional before shipping): copy vocab.txt into "
"the game folder, then copy the plugins.js prompt and run it in your IDE with "
"plugins.js attached. Only translate player-visible UI strings - never "
"plugin parameter keys or internal identifiers.
"
"Ace - Ruby scripts (instead of plugins.js): RV2JSON unpacks "
"Data/Scripts.rvdata2 into ace_json/scripts/*.rb. "
"Copy vocab + the Ace scripts prompt, edit those .rb files in Cursor/VS Code "
"the same way you would edit plugins.js (player-visible strings only). "
"Then use Export - the tool runs RV2JSON -u and packs "
"ace_json/ (including scripts) back into Data/*.rvdata2.
"
"Export - copy finished translations from translated/ back "
"into the game's data folder:
"
"• Export Active Files - only names currently in files/
"
"• Export ALL - everything under translated/"
),
6: (
"Step 6 - Playtest
"
"Install playtest plugins into the MV/MZ game:
"
"• TL Inspector - inspect translated text in-game
"
"• Forge - additional playtest helpers
"
"Configure hotkeys/UI scale, then Install / Uninstall as needed. "
"Not available for Ace projects."
),
}
def _show_step_help(parent: QWidget | None, title: str, html: str) -> None:
box = QMessageBox(parent)
box.setWindowTitle(title)
box.setIcon(QMessageBox.Information)
box.setTextFormat(Qt.RichText)
box.setText(html)
box.setStandardButtons(QMessageBox.Ok)
box.setStyleSheet(
"QMessageBox{background-color:#252526;}"
"QLabel{color:#d4d4d4;font-size:13px;min-width:420px;}"
)
box.exec_()
def _make_hr() -> QFrame:
hr = QFrame()
hr.setFrameShape(QFrame.HLine)
hr.setFrameShadow(QFrame.Plain)
hr.setStyleSheet("QFrame { color: #333333; margin: 10px 0px 4px 0px; }")
return hr
def _make_btn(text: str, color: str = "#007acc") -> QPushButton:
"""Button styled to match the translation tab.
Dark utility colours (max channel < 115) use the flat sidebar style.
Action colours use flat dark bg + coloured outline + coloured text.
"""
btn = QPushButton()
try:
c = color.lstrip("#")
if len(c) == 3:
c = c[0] * 2 + c[1] * 2 + c[2] * 2
r, g, b = int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)
is_flat = max(r, g, b) < 115
except Exception:
r = g = b = 0
is_flat = False
_PAD = "padding:6px 14px;"
_icon_color = "#cccccc"
if is_flat:
btn.setStyleSheet(
f"QPushButton{{background-color:#2d2d30;color:#cccccc;"
f"border:1px solid #555555;{_PAD}"
f"border-radius:4px;font-size:12px;font-weight:bold;"
f"font-family:'Segoe UI','Segoe UI Emoji','Apple Color Emoji',sans-serif;}}"
f"QPushButton:hover{{background-color:#3e3e42;}}"
f"QPushButton:pressed{{background-color:#007acc;color:white;}}"
f"QPushButton:disabled{{background-color:#404040;color:#666666;border-color:#444444;}}"
)
else:
# Flat dark bg + coloured outline + lightened text
rt = min(255, r + 80)
gt = min(255, g + 80)
bt = min(255, b + 80)
text_color = f"#{rt:02x}{gt:02x}{bt:02x}"
_icon_color = text_color
base = 0x2d
rh = min(255, int(base + (r - base) * 0.18))
gh = min(255, int(base + (g - base) * 0.18))
bh = min(255, int(base + (b - base) * 0.18))
hover_bg = f"#{rh:02x}{gh:02x}{bh:02x}"
rb = min(255, r + 35)
gb = min(255, g + 35)
bb = min(255, b + 35)
hover_accent = f"#{rb:02x}{gb:02x}{bb:02x}"
btn.setStyleSheet(
f"QPushButton{{background-color:#2d2d30;color:{text_color};"
f"border:1px solid {color};{_PAD}"
f"border-radius:4px;font-size:12px;font-weight:bold;"
f"font-family:'Segoe UI','Segoe UI Emoji','Apple Color Emoji',sans-serif;}}"
f"QPushButton:hover{{background-color:{hover_bg};border-color:{hover_accent};color:{hover_accent};}}"
f"QPushButton:pressed{{background-color:#1a1a1a;}}"
f"QPushButton:disabled{{background-color:#2d2d30;color:#555555;border-color:#444444;}}"
)
from gui import qt_icons
qt_icons.apply_button_icon(btn, text, color=_icon_color)
if not btn.icon().isNull():
btn.setIconSize(QSize(16, 16))
return btn
_ACTION_BTN_STYLE = (
"QPushButton{background-color:#2d2d30;color:white;"
"font-weight:bold;font-size:12px;border:1px solid #555555;"
"border-radius:4px;padding:4px 10px;"
"min-height:36px;max-height:36px;}"
"QPushButton:hover{background-color:#3e3e42;border-color:#007acc;}"
"QPushButton:pressed{background-color:#007acc;}"
"QPushButton:disabled{background-color:#2d2d30;color:#555555;border-color:#444444;}"
)
def _make_text_btn(label: str, tooltip: str = "", *, min_width: int = 52) -> QPushButton:
"""Compact labeled button for file-list actions (All / None / Core / Import)."""
btn = QPushButton(label)
btn.setToolTip(tooltip)
btn.setFont(QFont("Segoe UI", 10))
btn.setMinimumWidth(min_width)
btn.setFixedHeight(36)
btn.setStyleSheet(_ACTION_BTN_STYLE)
return btn
def _make_icon_btn(icon_text: str, tooltip: str = "") -> QPushButton:
"""Compact icon-only button (e.g. folder browse)."""
from gui import qt_icons
btn = QPushButton()
qt_icons.apply_button_icon(btn, icon_text, color="#dddddd")
btn.setIconSize(QSize(18, 18))
btn.setToolTip(tooltip)
btn.setFont(QFont("Segoe UI", 12))
btn.setFixedSize(40, 36)
btn.setStyleSheet(
"QPushButton{background-color:#2d2d30;color:white;"
"font-weight:bold;font-size:16px;border:1px solid #555555;"
"border-radius:4px;min-width:40px;max-width:40px;"
"min-height:36px;max-height:36px;}"
"QPushButton:hover{background-color:#3e3e42;border-left-color:#007acc;}"
"QPushButton:pressed{background-color:#007acc;}"
"QPushButton:disabled{background-color:#2d2d30;color:#555555;border-color:#444444;}"
)
return btn
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# Main widget
# ─────────────────────────────────────────────────────────────────────────────
class WorkflowTab(QWidget):
"""Guided automation tab for the full RPGMaker translation workflow."""
def __init__(self, parent=None):
super().__init__(parent)
self.parent_window = parent
try:
self.settings = QSettings(ORG_NAME, APP_NAME)
except Exception:
self.settings = None
# State
self._data_path: str | None = None
self._engine: str = "MVMZ"
self._file_items: list[dict] = []
self._worker = None # active background QThread
# Pre-process paths (auto-populated after folder detection)
self._plugins_js_path: str = ""
self._gameupdate_path: str = ""
# RPGMaker Ace state
self._ace_encrypted: bool = False
self._ace_json_dir: str = "" # /JSON/ — used as _data_path for Ace
self._ace_rvdata_dir: str = "" # /Data/ with rvdata2 files
self._p2_loading_config: bool = False
self._p2_auto_apply_timer: QTimer | None = None
self._syncing_file_checks: bool = False
self._import_buttons: list[QPushButton] = []
self._current_step_index: int = 0
self._last_import_signature: tuple[str, ...] | None = None
self._pending_import_signature: tuple[str, ...] | None = None
self._init_ui()
# ───────────────────────────────── UI setup ──────────────────────────────
def _init_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
# Splitter: left=steps, right=log
splitter = QSplitter(Qt.Horizontal)
splitter.setHandleWidth(1)
splitter.setStyleSheet("QSplitter::handle{background:#3a3a3a;}")
# ---- Left: tabbed step panels ----
_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.setDocumentMode(True)
self._step_tabs.tabBar().setVisible(False)
self._step_tabs.setStyleSheet("""
QTabWidget::pane { border: none; background-color: #1e1e1e; top: 0; }
QTabBar { height: 0; max-height: 0; }
""")
# Compact always-visible strip - avoids the finicky overflow scroll arrows.
self._step_labels: list[str] = []
self._step_done: set[int] = set()
self._step_buttons: list[QToolButton] = []
self._step_strip = QWidget()
self._step_strip.setObjectName("mvStepStrip")
self._step_strip.setStyleSheet("""
QWidget#mvStepStrip {
background-color: #252526;
border-bottom: 1px solid #3a3a3a;
}
QToolButton {
background-color: transparent;
color: #8a8a8a;
border: none;
border-right: 1px solid #333333;
padding: 6px 2px;
font-size: 11px;
font-weight: 600;
}
QToolButton:hover {
background-color: #2d2d30;
color: #d0d0d0;
}
QToolButton:checked {
background-color: #1e1e1e;
color: #ffffff;
border-top: 2px solid #007acc;
padding-top: 4px;
}
QToolButton[done="true"] {
color: #6a9955;
}
QToolButton[done="true"]:checked {
color: #ffffff;
}
""")
strip_layout = QHBoxLayout(self._step_strip)
strip_layout.setContentsMargins(0, 0, 0, 0)
strip_layout.setSpacing(0)
_tab_defs = [
("0 Project", self._build_step0),
("1 Pre-process", self._build_step1_preprocess),
("2 Setup", self._build_step2_setup),
("3 TL Phase 1", self._build_step4_translation),
("4 TL Phase 2", self._build_step5_tl_phase2),
("5 Export", self._build_step5_finish),
("6 Playtest", self._build_step8_playtest),
]
self._step_labels = [label for label, _ in _tab_defs]
for tab_label, builder in _tab_defs:
# Each tab: outer page → scroll area → inner content widget
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) # current tab index (before addTab)
if tab_idx > 0:
back_btn = _make_btn("← Back", "#3a3a3a")
back_btn.setFixedWidth(100)
_idx = tab_idx # capture for lambda
back_btn.clicked.connect(
lambda _checked, i=_idx: self._goto_step(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)
_idx = tab_idx # capture for lambda
next_btn.clicked.connect(
lambda _checked, i=_idx: self._advance_step(i)
)
nav_layout.addWidget(next_btn)
page_layout.addWidget(nav)
self._step_tabs.addTab(page, tab_label)
btn = QToolButton()
btn.setText(self._step_strip_label(tab_idx, done=False))
btn.setCheckable(True)
btn.setAutoExclusive(True)
btn.setToolButtonStyle(Qt.ToolButtonTextOnly)
btn.setToolTip(tab_label)
btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
btn.setMinimumHeight(40)
btn.clicked.connect(lambda _c=False, i=tab_idx: self._goto_step(i))
strip_layout.addWidget(btn, 1)
self._step_buttons.append(btn)
self._step_tabs.currentChanged.connect(self._on_step_tab_changed)
if self._step_buttons:
self._step_buttons[0].setChecked(True)
steps_host = QWidget()
steps_host_layout = QVBoxLayout(steps_host)
steps_host_layout.setContentsMargins(0, 0, 0, 0)
steps_host_layout.setSpacing(0)
steps_host_layout.addWidget(self._step_strip)
steps_host_layout.addWidget(self._step_tabs, 1)
splitter.addWidget(steps_host)
# ---- Right: log area ----
log_panel = QWidget()
lp_layout = QVBoxLayout(log_panel)
lp_layout.setContentsMargins(0, 0, 0, 0)
lp_layout.setSpacing(0)
log_header = QLabel(" ▸ 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;"
"font-family:'Segoe UI','Arial',sans-serif;}"
"QPushButton:hover{background-color:#2d2d30;color:#9d9d9d;}"
"QPushButton:pressed{color:#cccccc;}"
)
clear_btn.clicked.connect(self.log_area.clear)
lp_layout.addWidget(clear_btn)
splitter.addWidget(log_panel)
splitter.setSizes([900, 200])
root.addWidget(splitter)
self.setLayout(root)
self._apply_theme()
self._detected_on_show: bool = False # guard: only auto-detect once per new folder
# ── Tab visibility ──────────────────────────────────────────────────────
def showEvent(self, event):
"""Trigger folder detection the first time this tab is shown (or after a new folder is set)."""
super().showEvent(event)
if not self._detected_on_show and self._setting("last_game_folder", ""):
self._detected_on_show = True
QTimer.singleShot(100, self._detect_folder)
def eventFilter(self, obj, event):
"""Toggle workflow file checks when clicking a row outside the checkbox."""
try:
if (
obj is self.file_list.viewport()
and event.type() == QEvent.MouseButtonRelease
and event.button() == Qt.LeftButton
):
item = self.file_list.itemAt(event.pos())
if item is None:
return False
# Let native checkbox clicks handle their own checked state.
item_rect = self.file_list.visualItemRect(item)
if event.pos().x() <= item_rect.left() + 26:
return False
item.setCheckState(
Qt.Unchecked if item.checkState() == Qt.Checked else Qt.Checked
)
return False
except Exception:
pass
return super().eventFilter(obj, event)
def _step_strip_label(self, idx: int, *, done: bool) -> str:
"""Compact strip text: number + short name, optional checkmark for done."""
short_names = (
"Project",
"Prep",
"Setup",
"Phase1",
"Phase2",
"Export",
"Playtest",
)
name = short_names[idx] if 0 <= idx < len(short_names) else str(idx)
mark = "✓" if done else ""
return f"{mark}{idx}\n{name}"
def _refresh_step_strip(self, current: int | None = None):
if current is None:
current = self._step_tabs.currentIndex() if hasattr(self, "_step_tabs") else 0
for i, btn in enumerate(getattr(self, "_step_buttons", [])):
if not btn.isVisible():
continue
done = i in self._step_done
btn.setText(self._step_strip_label(i, done=done))
btn.setProperty("done", "true" if done else "false")
btn.style().unpolish(btn)
btn.style().polish(btn)
if i == current and not btn.isChecked():
btn.blockSignals(True)
btn.setChecked(True)
btn.blockSignals(False)
elif i != current and btn.isChecked():
btn.blockSignals(True)
btn.setChecked(False)
btn.blockSignals(False)
def _goto_step(self, idx: int):
if not hasattr(self, "_step_tabs"):
return
idx = max(0, min(idx, self._step_tabs.count() - 1))
# Skip hidden steps (e.g. Playtest on Ace).
buttons = getattr(self, "_step_buttons", [])
if 0 <= idx < len(buttons) and not buttons[idx].isVisible():
# Prefer previous visible step.
for j in range(idx - 1, -1, -1):
if buttons[j].isVisible():
idx = j
break
if self._step_tabs.currentIndex() != idx:
self._step_tabs.setCurrentIndex(idx)
else:
self._refresh_step_strip(idx)
def _advance_step(self, from_idx: int):
"""Mark *from_idx* done and move to the next visible step."""
self._step_done.add(from_idx)
nxt = from_idx + 1
buttons = getattr(self, "_step_buttons", [])
while nxt < len(buttons) and not buttons[nxt].isVisible():
nxt += 1
self._goto_step(nxt)
def _on_step_tab_changed(self, index: int):
"""Refresh config-backed controls when their workflow page is shown."""
previous_index = self._current_step_index
self._current_step_index = index
self._refresh_step_strip(index)
if previous_index == 0 and index != 0:
self._auto_import_if_needed()
if index == 4:
self._populate_p2_checkboxes()
if index == 6:
self._refresh_playtest_status()
self._load_playtest_settings()
def _register_import_button(self, button: QPushButton) -> None:
self._import_buttons.append(button)
def _set_import_buttons_enabled(self, enabled: bool) -> None:
for button in self._import_buttons:
button.setEnabled(enabled)
def _apply_theme(self):
"""Apply a unified dark-theme stylesheet to all standard controls."""
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;
}
QLineEdit::placeholder {
color: #606060;
}
QSpinBox {
background-color: #3c3c3c;
border: 1px solid #555555;
border-radius: 4px;
padding: 3px 6px;
color: #cccccc;
font-size: 13px;
}
QSpinBox:focus {
border-color: #007acc;
}
QSpinBox::up-button, QSpinBox::down-button {
background-color: #2d2d30;
border: none;
width: 18px;
}
QSpinBox::up-button:hover, QSpinBox::down-button:hover {
background-color: #007acc;
}
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;
}
QCheckBox::indicator:disabled {
background-color: #2d2d30;
border-color: #444444;
}
""")
# ── Step 0: Project Folder ──────────────────────────────────────────────
def _add_step_header(
self,
layout: QVBoxLayout,
title: str,
step_idx: int,
*,
extra_widgets: list | None = None,
) -> QLabel:
"""Title row with optional trailing widgets and a ? help button on the right."""
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(8)
lbl = _make_section_label(title)
row.addWidget(lbl, 1)
for w in extra_widgets or []:
row.addWidget(w)
help_btn = _make_help_button()
help_html = _STEP_HELP.get(step_idx, "No help is available for this step.")
help_btn.clicked.connect(
lambda _=False, label=lbl, h=help_html: _show_step_help(
self, label.text(), h
)
)
row.addWidget(help_btn, 0, Qt.AlignTop)
layout.addLayout(row)
return lbl
def _build_step0(self, layout: QVBoxLayout):
self._add_step_header(layout, "Step 0 — Project Folder", 0)
# Folder picker row
row0 = QHBoxLayout()
self.folder_edit = QLineEdit()
self.folder_edit.setPlaceholderText("Path to game root folder…")
saved = self._setting("last_game_folder", "")
if saved:
self.folder_edit.setText(saved)
row0.addWidget(self.folder_edit, 1)
self.folder_edit.returnPressed.connect(self._detect_folder)
browse_btn = _make_icon_btn("📁", "Browse for a game project folder")
browse_btn.clicked.connect(self._browse_folder)
row0.addWidget(browse_btn)
layout.addLayout(row0)
# Detected info
self.detected_label = QLabel("No folder detected yet.")
self.detected_label.setStyleSheet(
"color:#6a9a6a;font-size:13px;padding:4px 8px;"
"background-color:#1f2b1f;border:1px solid #2a4a2a;"
"border-radius:4px;margin:4px 0;"
)
layout.addWidget(self.detected_label)
# File list
self.file_list = QListWidget()
self.file_list.setMinimumHeight(320)
self.file_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.file_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.file_list.itemChanged.connect(self._sync_selected_file_checks)
self.file_list.viewport().installEventFilter(self)
self.file_list.setStyleSheet(
"QListWidget{outline:none;border:1px solid #3c3c3c;"
"background-color:#252526;border-radius:4px;}"
"QListWidget::item{border:none;outline:none;padding:2px 6px;"
"color:#c8c8c8;}"
"QListWidget::item:selected{background-color:#252526;color:#c8c8c8;}"
"QListWidget::item:hover{background-color:#2d2d30;"
"border-left:2px solid #007acc;}"
)
layout.addWidget(self.file_list, 1)
# Action row
row1 = QHBoxLayout()
row1.setSpacing(6)
select_all_btn = _make_text_btn("All", "Select all importable files", min_width=44)
select_all_btn.clicked.connect(self._select_all_files)
row1.addWidget(select_all_btn)
deselect_all_btn = _make_text_btn("None", "Deselect all files", min_width=52)
deselect_all_btn.clicked.connect(self._deselect_all_files)
row1.addWidget(deselect_all_btn)
sel_core = _make_text_btn("Core", "Core only: select database files and deselect maps", min_width=52)
sel_core.setToolTip("Select only core database files; deselect map files")
sel_core.clicked.connect(self._select_core_only)
row1.addWidget(sel_core)
import_btn = _make_text_btn("Import", "Import selected files into files/", min_width=64)
import_btn.setEnabled(False)
import_btn.setToolTip("Replace files/ with exactly the checked files above")
import_btn.clicked.connect(lambda _checked=False: self._import_files())
self._register_import_button(import_btn)
row1.addWidget(import_btn)
row1.addStretch()
layout.addLayout(row1)
# ── Step 3: Vocab / Glossary ────────────────────────────────────────────
# Static clipboard prompts are loaded from editable data/skills/*.md files.
# ── Step 1 (Optional): Pre-process ────────────────────────────────
def _build_step1_preprocess(self, layout: QVBoxLayout):
opt_badge = QLabel("optional")
opt_badge.setStyleSheet(
"color:#7a7a7a;font-size:11px;border:1px solid #3c3c3c;"
"padding:1px 8px;border-radius:8px;"
"background-color:#252526;"
)
# Collapse/expand toggle
toggle_btn = QPushButton("▼")
toggle_btn.setCheckable(True)
toggle_btn.setChecked(True)
toggle_btn.setFixedSize(22, 22)
toggle_btn.setStyleSheet(
"QPushButton{background:transparent;color:#888;border:none;font-size:11px;}"
"QPushButton:hover{color:#ccc;}"
)
self._add_step_header(
layout,
"Step 1 (Optional) — Pre-process",
1,
extra_widgets=[opt_badge, toggle_btn],
)
# Collapsible container — wraps tasks_box + run-all row
collapse_widget = QWidget()
collapse_layout = QVBoxLayout(collapse_widget)
collapse_layout.setContentsMargins(0, 0, 0, 0)
collapse_layout.setSpacing(4)
tasks_box = QGroupBox()
tasks_box.setStyleSheet("QGroupBox{border:none;margin:0;padding:4px 0;}")
tb = QVBoxLayout(tasks_box)
tb.setSpacing(10)
collapse_layout.addWidget(tasks_box)
# ---- Task A: dazedformat -----------------------------------------
ta_title = QLabel("A — Format JSON files (dazedformat)")
ta_title.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;")
tb.addWidget(ta_title)
self._pp_dazedformat_title = ta_title
ta = QWidget()
ta.setObjectName("tbox")
ta.setStyleSheet(self._task_box_style())
ta_inner = QVBoxLayout(ta)
ta_inner.setContentsMargins(10, 8, 10, 8)
ta_inner.setSpacing(4)
ta_desc = QLabel(
"Normalises all JSON files in the game's data folder by round-tripping them "
"through json.load / json.dump. "
"Bundled — no external install required."
)
ta_desc.setTextFormat(Qt.RichText)
ta_desc.setWordWrap(True)
ta_desc.setStyleSheet("color:#9d9d9d;font-size:13px;")
ta_inner.addWidget(ta_desc)
ta_path_row = QHBoxLayout()
ta_path_row.addWidget(QLabel("Data folder:"))
self.pp_data_path_label = QLabel("(detect a project folder first)")
self.pp_data_path_label.setStyleSheet("color:#7a7a7a;font-size:13px;")
ta_path_row.addWidget(self.pp_data_path_label, 1)
ta_inner.addLayout(ta_path_row)
ta_btn_row = QHBoxLayout()
run_dazed = _make_btn("► Run dazedformat", "#555")
run_dazed.setFixedWidth(180)
run_dazed.clicked.connect(self._run_dazedformat)
ta_btn_row.addWidget(run_dazed)
ta_btn_row.addStretch()
ta_inner.addLayout(ta_btn_row)
tb.addWidget(ta)
self._pp_dazedformat_box = ta
# ---- Task B: prettier on plugins.js
tb_box_title = QLabel("B — Format plugins.js with Prettier")
tb_box_title.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;")
tb.addWidget(tb_box_title)
self._pp_plugins_js_title = tb_box_title
tb_box = QWidget()
tb_box.setObjectName("tbox")
tb_box.setStyleSheet(self._task_box_style())
tb_inner = QVBoxLayout(tb_box)
tb_inner.setContentsMargins(10, 8, 10, 8)
tb_inner.setSpacing(4)
tb_desc = QLabel(
"Formats plugins.js using jsbeautifier (pure Python — no Node.js required) "
"so it is human-readable before editing or inspection."
)
tb_desc.setTextFormat(Qt.RichText)
tb_desc.setWordWrap(True)
tb_desc.setStyleSheet("color:#9d9d9d;font-size:13px;")
tb_inner.addWidget(tb_desc)
tb_path_row = QHBoxLayout()
tb_path_lbl = QLabel("plugins.js:")
tb_path_row.addWidget(tb_path_lbl)
self.pp_plugins_edit = QLineEdit()
self.pp_plugins_edit.setPlaceholderText("Path to plugins.js…")
tb_path_row.addWidget(self.pp_plugins_edit, 1)
browse_plugins = _make_btn("", "#444")
browse_plugins.setIcon(browse_plugins.style().standardIcon(QStyle.SP_DirOpenIcon))
browse_plugins.setFixedWidth(34)
browse_plugins.clicked.connect(self._browse_plugins_js)
tb_path_row.addWidget(browse_plugins)
tb_inner.addLayout(tb_path_row)
tb_btn_row = QHBoxLayout()
run_prettier = _make_btn("► Format plugins.js", "#555")
run_prettier.setFixedWidth(180)
run_prettier.clicked.connect(self._run_prettier)
tb_btn_row.addWidget(run_prettier)
tb_btn_row.addStretch()
tb_inner.addLayout(tb_btn_row)
tb.addWidget(tb_box)
self._pp_plugins_js_box = tb_box
# ---- Task C: copy gameupdate/ -----------------------------------
tc_title = QLabel("C — Apply gameupdate/ patch files")
tc_title.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;")
tb.addWidget(tc_title)
tc = QWidget()
tc.setObjectName("tbox")
tc.setStyleSheet(self._task_box_style())
tc_inner = QVBoxLayout(tc)
tc_inner.setContentsMargins(10, 8, 10, 8)
tc_inner.setSpacing(4)
tc_desc = QLabel(
"Copies everything from the gameupdate/ folder "
"into the game\'s root folder, overwriting existing files. "
"WOLF-only UberWolf files are omitted for RPG Maker games. "
"Writes patch-config.txt from Config → Game Update defaults "
"(set your org once there; edit repo= per game)."
)
tc_desc.setTextFormat(Qt.RichText)
tc_desc.setWordWrap(True)
tc_desc.setStyleSheet("color:#9d9d9d;font-size:13px;")
tc_inner.addWidget(tc_desc)
tc_src_row = QHBoxLayout()
tc_src_row.addWidget(QLabel("Source:"))
self.pp_gameupdate_edit = QLineEdit()
self.pp_gameupdate_edit.setPlaceholderText("Path to gameupdate/ folder…")
tc_src_row.addWidget(self.pp_gameupdate_edit, 1)
browse_gu = _make_btn("", "#444")
browse_gu.setIcon(browse_gu.style().standardIcon(QStyle.SP_DirOpenIcon))
browse_gu.setFixedWidth(34)
browse_gu.clicked.connect(self._browse_gameupdate)
tc_src_row.addWidget(browse_gu)
tc_inner.addLayout(tc_src_row)
tc_dst_row = QHBoxLayout()
tc_dst_row.addWidget(QLabel("Destination:"))
self.pp_gameupdate_dst_label = QLabel("(game root folder auto-filled from project)")
self.pp_gameupdate_dst_label.setStyleSheet("color:#7a7a7a;font-size:13px;")
tc_dst_row.addWidget(self.pp_gameupdate_dst_label, 1)
tc_inner.addLayout(tc_dst_row)
tc_btn_row = QHBoxLayout()
tc_btn_row.setSpacing(8)
run_gu = _make_btn("► Copy gameupdate/", "#555")
run_gu.setFixedWidth(180)
run_gu.clicked.connect(self._run_gameupdate)
tc_btn_row.addWidget(run_gu)
run_all_btn = _make_btn("►► Run All 3 Tasks", "#007acc")
run_all_btn.setFixedWidth(180)
run_all_btn.setToolTip("Run dazedformat, prettier, and gameupdate copy in sequence")
run_all_btn.clicked.connect(self._run_all_preprocess)
tc_btn_row.addWidget(run_all_btn)
tc_btn_row.addStretch()
tc_inner.addLayout(tc_btn_row)
tb.addWidget(tc)
layout.addWidget(collapse_widget)
def _toggle_preprocess(expanded: bool):
toggle_btn.setText("▼" if expanded else "►")
collapse_widget.setVisible(expanded)
toggle_btn.toggled.connect(_toggle_preprocess)
@staticmethod
def _task_box_style() -> str:
return (
"QWidget#tbox{"
"background-color:#252526;"
"border:1px solid #3c3c3c;"
"border-radius:6px;}"
)
@staticmethod
def _checkbox_box_style() -> str:
"""Style for checkbox list container widgets."""
return (
"QWidget#cbbox{"
"background-color:#252526;"
"border:1px solid #3c3c3c;"
"border-radius:6px;}"
"QWidget{"
"background-color:#252526;"
"border:none;}"
"QCheckBox{border:none;background-color:transparent;}"
)
def _build_step2_setup(self, layout: QVBoxLayout):
"""Combined speaker flags + Project Setup + vocab/quirks/game-skill editors."""
self._add_step_header(layout, "Step 2 — Setup (Speakers + Glossary)", 2)
# ---- Compact action bar --------------------------------------------
top = QWidget()
top.setObjectName("tbox")
top.setStyleSheet(self._task_box_style())
top_l = QVBoxLayout(top)
top_l.setContentsMargins(10, 8, 10, 8)
top_l.setSpacing(6)
from gui import qt_icons
actions = QHBoxLayout()
actions.setSpacing(8)
import_btn = QPushButton()
qt_icons.apply_button_icon(import_btn, "↓ Import → files/", color="#4da8f0")
import_btn.setStyleSheet(
"QPushButton{background-color:#2d2d30;color:#4da8f0;border:1px solid #007acc;"
"padding:0px 10px;border-radius:4px;font-size:12px;font-weight:bold;"
"font-family:'Segoe UI',sans-serif;}"
"QPushButton:hover{background-color:#1a2d3a;border-color:#1a9aff;color:#7ac8ff;}"
"QPushButton:pressed{background-color:#0a1a2a;}"
"QPushButton:disabled{background-color:#2d2d30;color:#555555;border-color:#444444;}"
)
import_btn.setFixedHeight(32)
import_btn.setEnabled(False)
import_btn.clicked.connect(lambda _checked=False: self._import_files())
self._register_import_button(import_btn)
actions.addWidget(import_btn)
clear_translated_btn = QPushButton()
qt_icons.apply_button_icon(clear_translated_btn, "✕ Clear TL", color="#cc4444")
clear_translated_btn.setStyleSheet(
"QPushButton{background-color:#2d2d30;color:#cc4444;border:1px solid #8b0000;"
"padding:0px 10px;border-radius:4px;font-size:12px;font-weight:bold;"
"font-family:'Segoe UI',sans-serif;}"
"QPushButton:hover{background-color:#3a2020;border-color:#cc2222;color:#ff6666;}"
"QPushButton:pressed{background-color:#4a1010;}"
)
clear_translated_btn.setFixedHeight(32)
clear_translated_btn.setToolTip("Delete all files inside the translated/ folder")
clear_translated_btn.clicked.connect(self._clear_translated)
actions.addWidget(clear_translated_btn)
parse_btn = _make_btn("🔍 Parse Speakers", "#5a3a7a")
parse_btn.setFixedWidth(160)
parse_btn.setFixedHeight(32)
parse_btn.setToolTip(
"Collect speaker names from event files into vocab.txt # Speakers "
"(no dialogue translation)."
)
parse_btn.clicked.connect(self._run_parse_speakers)
actions.addWidget(parse_btn)
copy_btn = _make_btn("📋 Copy Project Setup", "#555")
copy_btn.setFixedWidth(190)
copy_btn.setFixedHeight(32)
copy_btn.setToolTip(
"Clipboard skill for the game repo IDE. Returns glossary, speakers, "
"translation_quirks, and game_skill code blocks."
)
copy_btn.clicked.connect(self._copy_project_setup_prompt)
actions.addWidget(copy_btn)
actions.addStretch()
top_l.addLayout(actions)
flags_row = QHBoxLayout()
flags_row.setSpacing(14)
flags_lbl = QLabel("Flags:")
flags_lbl.setStyleSheet("color:#4ec9b0;font-size:12px;font-weight:bold;")
flags_row.addWidget(flags_lbl)
self.spk_inline_cb = QCheckBox("INLINE401SPEAKERS")
self.spk_inline_cb.setToolTip("Speaker name inline before 「 in the 401 text")
self.spk_inline_cb.stateChanged.connect(self._apply_speaker_flags)
flags_row.addWidget(self.spk_inline_cb)
self.spk_firstline_cb = QCheckBox("FIRSTLINESPEAKERS")
self.spk_firstline_cb.setToolTip("First 401 line is a short speaker name")
self.spk_firstline_cb.stateChanged.connect(self._apply_speaker_flags)
flags_row.addWidget(self.spk_firstline_cb)
self.spk_face_cb = QCheckBox("FACENAME101")
self.spk_face_cb.setToolTip("Speaker inferred from 101 face-image filename")
self.spk_face_cb.stateChanged.connect(self._apply_speaker_flags)
flags_row.addWidget(self.spk_face_cb)
flags_row.addStretch()
top_l.addLayout(flags_row)
layout.addWidget(top)
self._populate_speaker_flags()
self.setup_editors = SetupSkillsEditors(
self,
game_root_fn=lambda: self.folder_edit.text().strip(),
log_fn=self._log,
)
layout.addWidget(self.setup_editors, 1)
self.setup_editors.reload_all()
def _run_parse_speakers(self):
"""Configure Translation tab for Parse Speakers mode and auto-start."""
try:
pw = self.parent_window
tt = getattr(pw, "translation_tab", None) if pw else None
if tt is None:
self._log("❌ Translation tab not found.")
return
# 1. Set engine to RPG Maker MV/MZ
try:
combo = tt.module_combo
for i in range(combo.count()):
if "RPG Maker MV/MZ" in combo.itemText(i):
combo.setCurrentIndex(i)
break
except Exception:
pass
# 2. Set mode to Parse Speakers
try:
mc = tt.mode_combo
idx = mc.findText("Parse Speakers")
if idx >= 0:
mc.setCurrentIndex(idx)
else:
self._log("❌ 'Parse Speakers' mode not available — make sure RPG Maker MV/MZ is selected.")
return
except Exception as exc:
self._log(f"❌ Could not set Parse Speakers mode: {exc}")
return
self._log("")
self._log("─" * 54)
self._log("🔍 Switching to Parse Speakers mode…")
self._log(" Event files selected. Speaker names will be")
self._log(" collected and written to vocab.txt # Speakers.")
self._log("─" * 54)
except Exception as exc:
self._log(f"❌ _run_parse_speakers error: {exc}")
return
# 3. Select event files and auto-start
self._navigate_to_translation("events", auto_start=True)
# ── Step 2: Speaker Detection ───────────────────────────────────────────
# ── Step 4: Translation ─────────────────────────────────────────────────
def _add_tl_mode_selector(self, layout: QVBoxLayout):
"""Dropdown for normal vs batch TL; applies to all workflow phase run buttons."""
mode_box = QWidget()
mode_box.setObjectName("tbox")
mode_box.setStyleSheet(self._task_box_style())
mode_inner = QVBoxLayout(mode_box)
mode_inner.setContentsMargins(10, 8, 10, 8)
mode_inner.setSpacing(6)
mode_row = QHBoxLayout()
mode_row.setSpacing(10)
mode_lbl = QLabel("Translation mode:")
mode_lbl.setStyleSheet("color:#cccccc;font-size:13px;font-weight:bold;")
self._tl_mode_combo = QComboBox()
self._tl_mode_combo.addItem(WORKFLOW_TL_NORMAL_LABEL)
self._tl_mode_combo.addItem(BATCH_MODE_LABEL)
self._tl_mode_combo.setFixedWidth(240)
self._tl_mode_combo.setToolTip(
"Applies to Phase 0, 1, 1b, and 2 run buttons. "
"Batch uses the Anthropic Batches API (50% off, Claude only)."
)
self._tl_mode_combo.currentTextChanged.connect(self._on_workflow_tl_mode_changed)
mode_row.addWidget(mode_lbl)
mode_row.addWidget(self._tl_mode_combo)
mode_row.addStretch()
mode_inner.addLayout(mode_row)
self._batch_mode_benefit = QLabel(BATCH_MODE_BENEFIT_NOTE)
self._batch_mode_benefit.setWordWrap(True)
self._batch_mode_benefit.setStyleSheet("color:#8fbc8f;font-size:12px;")
self._batch_mode_benefit.setVisible(False)
mode_inner.addWidget(self._batch_mode_benefit)
self._batch_mode_warning = QLabel(BATCH_COLLECT_LIVE_CHARGE_NOTE)
self._batch_mode_warning.setWordWrap(True)
self._batch_mode_warning.setStyleSheet("color:#f0ad4e;font-size:11px;")
self._batch_mode_warning.setVisible(False)
mode_inner.addWidget(self._batch_mode_warning)
layout.addWidget(mode_box)
batch_idx = self._tl_mode_combo.findText(BATCH_MODE_LABEL)
if batch_idx >= 0:
self._tl_mode_combo.setCurrentIndex(batch_idx)
self._on_workflow_tl_mode_changed(self._tl_mode_combo.currentText())
def _on_workflow_tl_mode_changed(self, mode_text: str):
is_batch = mode_text == BATCH_MODE_LABEL
if hasattr(self, "_batch_mode_benefit"):
self._batch_mode_benefit.setVisible(is_batch)
if hasattr(self, "_batch_mode_warning"):
self._batch_mode_warning.setVisible(is_batch)
if hasattr(self, "_step5_mode_hint"):
self._step5_mode_hint.setText(f"Translation mode: {mode_text}")
if is_batch:
self._step5_mode_hint.setStyleSheet("color:#8fbc8f;font-size:12px;margin-bottom:4px;")
else:
self._step5_mode_hint.setStyleSheet("color:#9d9d9d;font-size:12px;margin-bottom:4px;")
def _workflow_batch_mode(self) -> bool:
combo = getattr(self, "_tl_mode_combo", None)
return combo is not None and combo.currentText() == BATCH_MODE_LABEL
def _workflow_mode_text(self) -> str:
return BATCH_MODE_LABEL if self._workflow_batch_mode() else "Translate"
def _build_step4_translation(self, layout: QVBoxLayout):
self._add_step_header(layout, "Step 3 — TL Phase 1", 3)
self._add_tl_mode_selector(layout)
# ---- Pre-flight: text wrap configuration ----------------------------
wrap_box_title = QLabel("Pre-flight — Text Wrap Width")
wrap_box_title.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;")
layout.addWidget(wrap_box_title)
wrap_box = QWidget()
wrap_box.setObjectName("tbox")
wrap_box.setStyleSheet(self._task_box_style())
wrap_inner = QVBoxLayout(wrap_box)
wrap_inner.setContentsMargins(10, 8, 10, 8)
wrap_inner.setSpacing(4)
wrap_hint = QLabel(
"Adjust if lines overflow or wrap too early in-game, then click Apply to write to .env."
)
wrap_hint.setWordWrap(True)
wrap_hint.setStyleSheet("color:#9d9d9d;font-size:13px;")
wrap_inner.addWidget(wrap_hint)
# All three spinboxes on one row
spins_row = QHBoxLayout()
spins_row.setSpacing(16)
def _spin_pair(label_text: str, default: int):
lbl = QLabel(label_text)
lbl.setStyleSheet("color:#cccccc;font-size:13px;")
sp = QSpinBox()
sp.setRange(20, 300)
sp.setValue(default)
sp.setFixedWidth(60)
return lbl, sp
lbl_w, self.wrap_width_spin = _spin_pair("Dialogue", 60)
lbl_lw, self.wrap_list_spin = _spin_pair("List/Help", 70)
lbl_nw, self.wrap_note_spin = _spin_pair("Notes", 60)
for lbl, sp in [(lbl_w, self.wrap_width_spin),
(lbl_lw, self.wrap_list_spin),
(lbl_nw, self.wrap_note_spin)]:
spins_row.addWidget(lbl)
spins_row.addWidget(sp)
spins_row.addStretch()
apply_wrap_btn = _make_btn("✔ Apply to .env", "#3a7a3a")
apply_wrap_btn.setFixedWidth(140)
apply_wrap_btn.setToolTip("Write width / listWidth / noteWidth into .env")
apply_wrap_btn.clicked.connect(self._apply_wrap_config)
spins_row.addWidget(apply_wrap_btn)
wrap_inner.addLayout(spins_row)
layout.addWidget(wrap_box)
# ---- Phase 0 --------------------------------------------------------
p0_box_title = QLabel("Phase 0 – Core Database Files")
p0_box_title.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;")
layout.addWidget(p0_box_title)
p0_box = QWidget()
p0_box.setObjectName("tbox")
p0_box.setStyleSheet(self._task_box_style())
p0_inner = QVBoxLayout(p0_box)
p0_inner.setContentsMargins(10, 8, 10, 8)
p0_inner.setSpacing(4)
p0_desc = QLabel(
"Actors, Armors, Weapons, Items, Skills, States, Classes, Enemies, System, MapInfos — "
"run first before any event-code phases."
)
p0_desc.setStyleSheet("color:#9d9d9d;font-size:13px;")
p0_desc.setWordWrap(True)
p0_inner.addWidget(p0_desc)
p0_row = QHBoxLayout()
p0_row.setSpacing(10)
self._run_p0_btn = _make_btn("► Run Phase 0", "#4a7a4a")
self._run_p0_btn.setFixedWidth(200)
self._run_p0_btn.setToolTip(
"Sets engine to RPG Maker MV/MZ, selects only DB files, all event codes OFF."
)
self._run_p0_btn.clicked.connect(lambda: self._run_phase(0))
p0_row.addWidget(self._run_p0_btn)
self._p0_status_lbl = QLabel("")
self._p0_status_lbl.setStyleSheet("color:#6a9a6a;font-size:13px;padding-left:4px;")
p0_row.addWidget(self._p0_status_lbl)
p0_row.addStretch()
p0_inner.addLayout(p0_row)
layout.addWidget(p0_box)
# ---- Phase 1 --------------------------------------------------------
p1_box_title = QLabel("Phase 1 – Safe Codes (dialogue + choices)")
p1_box_title.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;")
layout.addWidget(p1_box_title)
p1_box = QWidget()
p1_box.setObjectName("tbox")
p1_box.setStyleSheet(self._task_box_style())
p1_inner = QVBoxLayout(p1_box)
p1_inner.setContentsMargins(10, 8, 10, 8)
p1_inner.setSpacing(4)
p1_desc = QLabel(
"Codes ON: 101 (Name), 401 (Show Text), 405 (continued), 102 (Choices), 408 (extra lines). "
"Speaker lines in the log should look like [Speaker]: Dialogue."
)
p1_desc.setStyleSheet("color:#9d9d9d;font-size:13px;")
p1_desc.setWordWrap(True)
p1_inner.addWidget(p1_desc)
p1_row = QHBoxLayout()
p1_row.setSpacing(10)
self._run_p1_btn = _make_btn("► Run Phase 1", "#007acc")
self._run_p1_btn.setFixedWidth(200)
self._run_p1_btn.setToolTip("Applies Phase 1 code settings and starts translation")
self._run_p1_btn.clicked.connect(lambda: self._run_phase(1))
p1_row.addWidget(self._run_p1_btn)
self._p1_status_lbl = QLabel("")
self._p1_status_lbl.setStyleSheet("color:#6ab4d4;font-size:13px;padding-left:4px;")
p1_row.addWidget(self._p1_status_lbl)
p1_row.addStretch()
p1_inner.addLayout(p1_row)
layout.addWidget(p1_box)
# ---- Phase 1b -------------------------------------------------------
p1b_box_title = QLabel("Phase 1b – Build Variable Cache (code 111)")
p1b_box_title.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;")
layout.addWidget(p1b_box_title)
p1b_box = QWidget()
p1b_box.setObjectName("tbox")
p1b_box.setStyleSheet(self._task_box_style())
p1b_inner = QVBoxLayout(p1b_box)
p1b_inner.setContentsMargins(10, 8, 10, 8)
p1b_inner.setSpacing(4)
p1b_desc = QLabel(
"Scans code 111 branches that compare \u2018$gameVariables\u2019 and builds the var_translation_map cache. "
"Run before Phase 2 so code 122 strings are automatically matched."
)
p1b_desc.setStyleSheet("color:#9d9d9d;font-size:13px;")
p1b_desc.setWordWrap(True)
p1b_inner.addWidget(p1b_desc)
p1b_row = QHBoxLayout()
p1b_row.setSpacing(10)
self._run_p1b_btn = _make_btn("► Run Phase 1b", "#2a5a7a")
self._run_p1b_btn.setFixedWidth(200)
self._run_p1b_btn.setToolTip("Applies Phase 1b code settings (111 only) and starts translation")
self._run_p1b_btn.clicked.connect(lambda: self._run_phase("1b"))
p1b_row.addWidget(self._run_p1b_btn)
self._p1b_status_lbl = QLabel("")
self._p1b_status_lbl.setStyleSheet("color:#6aaac4;font-size:13px;padding-left:4px;")
p1b_row.addWidget(self._p1b_status_lbl)
p1b_row.addStretch()
p1b_inner.addLayout(p1b_row)
layout.addWidget(p1b_box)
def _build_step5_tl_phase2(self, layout: QVBoxLayout):
self._add_step_header(layout, "Step 4 — TL Phase 2", 4)
self._step5_mode_hint = QLabel(f"Translation mode: {BATCH_MODE_LABEL}")
self._step5_mode_hint.setStyleSheet("color:#8fbc8f;font-size:12px;margin-bottom:4px;")
layout.addWidget(self._step5_mode_hint)
if hasattr(self, "_tl_mode_combo"):
self._on_workflow_tl_mode_changed(self._tl_mode_combo.currentText())
# ── Pre-flight card: description + prompt + var range ──────────────
pre_box_title = QLabel("Pre-flight \u2014 Audit & Configure")
pre_box_title.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;")
layout.addWidget(pre_box_title)
pre_box = QWidget()
pre_box.setObjectName("tbox")
pre_box.setStyleSheet(self._task_box_style())
pre_inner = QVBoxLayout(pre_box)
pre_inner.setContentsMargins(10, 8, 10, 8)
pre_inner.setSpacing(6)
pre_top = QHBoxLayout()
pre_top.setSpacing(12)
desc_lbl = QLabel(
"Targets script/variable strings and plugin text. "
"Use the Plugin Prompt to audit the game first, then enable only the codes with visible text."
)
desc_lbl.setWordWrap(True)
desc_lbl.setStyleSheet("color:#9d9d9d;font-size:13px;")
pre_top.addWidget(desc_lbl, 1)
copy_risky_btn = _make_btn("📋 Copy Plugin Prompt", "#555")
copy_risky_btn.setFixedWidth(200)
copy_risky_btn.setToolTip(
"Copy a Copilot prompt that audits code 122 variable ranges and all optional "
"plugin/script codes for visible text."
)
copy_risky_btn.clicked.connect(self._copy_plugin_prompt)
pre_top.addWidget(copy_risky_btn)
pre_inner.addLayout(pre_top)
# Divider line
div = QFrame()
div.setFrameShape(QFrame.HLine)
div.setStyleSheet("QFrame{background-color:#3a3a3a;border:none;max-height:1px;}")
pre_inner.addWidget(div)
# Code 122 var range row
var_row = QHBoxLayout()
var_row.setSpacing(6)
var_lbl = QLabel("Code 122 var range:")
var_lbl.setStyleSheet("color:#cccccc;font-size:13px;")
var_row.addWidget(var_lbl)
from PyQt5.QtGui import QIntValidator
self._p2_var_min = QLineEdit("0")
self._p2_var_min.setValidator(QIntValidator(0, 99999))
self._p2_var_min.setFixedWidth(55)
self._p2_var_min.setAlignment(Qt.AlignCenter)
self._p2_var_min.setToolTip("Minimum variable ID to translate (inclusive)")
var_row.addWidget(self._p2_var_min)
dash_lbl = QLabel("–")
dash_lbl.setStyleSheet("color:#9d9d9d;")
var_row.addWidget(dash_lbl)
self._p2_var_max = QLineEdit("2000")
self._p2_var_max.setValidator(QIntValidator(1, 99999))
self._p2_var_max.setFixedWidth(55)
self._p2_var_max.setAlignment(Qt.AlignCenter)
self._p2_var_max.setToolTip("Maximum variable ID to translate (exclusive)")
var_row.addWidget(self._p2_var_max)
apply_range_btn = _make_btn("Apply", "#3a3a3a")
apply_range_btn.setFixedWidth(80)
apply_range_btn.setToolTip("Write CODE122_VAR_MIN / CODE122_VAR_MAX to the module")
apply_range_btn.clicked.connect(self._apply_var_range)
var_row.addWidget(apply_range_btn)
self._p2_var_min.editingFinished.connect(self._schedule_p2_config_apply)
self._p2_var_max.editingFinished.connect(self._schedule_p2_config_apply)
var_row.addStretch()
pre_inner.addLayout(var_row)
layout.addWidget(pre_box)
# Pre-populate range
try:
from gui.config_integration import ConfigIntegration
cur = ConfigIntegration().read_current_config()
if "CODE122_VAR_MIN" in cur:
self._p2_var_min.setText(str(cur["CODE122_VAR_MIN"]))
if "CODE122_VAR_MAX" in cur:
self._p2_var_max.setText(str(cur["CODE122_VAR_MAX"]))
except Exception:
pass
# ── Code toggles ───────────────────────────────────────────────────
codes_hdr = QHBoxLayout()
codes_title_lbl = QLabel("Enable Codes")
codes_title_lbl.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;")
codes_hdr.addWidget(codes_title_lbl)
codes_hdr.addStretch()
codes_select_all_btn = QPushButton("☑")
codes_select_all_btn.setCheckable(True)
codes_select_all_btn.setFixedSize(28, 28)
codes_select_all_btn.setStyleSheet(
"QPushButton{background:transparent;color:#888888;border:none;"
"font-size:18px;padding:0 2px;}"
"QPushButton:hover{color:#cccccc;}"
"QPushButton:checked{color:#007acc;}"
)
codes_select_all_btn.setToolTip("Select / Deselect All")
codes_hdr.addWidget(codes_select_all_btn)
layout.addLayout(codes_hdr)
toggle_box = QWidget()
toggle_box.setObjectName("cbbox")
toggle_box.setStyleSheet(self._checkbox_box_style())
toggle_box_layout = QVBoxLayout(toggle_box)
toggle_box_layout.setContentsMargins(8, 8, 10, 6)
toggle_box_layout.setSpacing(3)
toggle_grid_container = QWidget()
toggle_grid = QGridLayout(toggle_grid_container)
toggle_grid.setContentsMargins(0, 0, 0, 0)
toggle_grid.setHorizontalSpacing(24)
toggle_grid.setVerticalSpacing(4)
_P2_CODE_DEFS = [
("CODE122", "122 – Variables", "Control Variables (code 122)"),
("CODE357", "357 – Plugins (MZ)", "MZ Plugin Command text (code 357)"),
("CODE355655", "355/655 – Scripts", "Inline script text (codes 355/655)"),
("CODE356", "356 – Plugins (MV)", "MV Plugin Command text (code 356)"),
("CODE657", "657 – Picture Text", "Extended picture text (code 657)"),
("CODE320", "320 – Actor Name", "Change Actor Name (code 320)"),
("CODE324", "324 – Nickname", "Change Nickname (code 324)"),
("CODE325", "325 – Profile", "Change Profile (code 325)"),
("CODE108", "108 – Comments", "Comment notetags (code 108)"),
]
self._p2_code_checks: dict = {}
for idx, (code_key, label, tip) in enumerate(_P2_CODE_DEFS):
cb = QCheckBox(label)
cb.setToolTip(tip)
cb.setStyleSheet("color:#cccccc;font-size:13px;")
toggle_grid.addWidget(cb, idx // 3, idx % 3)
cb.stateChanged.connect(self._schedule_p2_config_apply)
self._p2_code_checks[code_key] = cb
toggle_box_layout.addWidget(toggle_grid_container)
def _toggle_codes(checked):
codes_select_all_btn.setText("☑" if checked else "☐")
for cb in self._p2_code_checks.values():
cb.setChecked(checked)
codes_select_all_btn.toggled.connect(_toggle_codes)
layout.addWidget(toggle_box)
# ── 357 plugins + 355/655 patterns — side by side ──────────────────
lists_row = QHBoxLayout()
lists_row.setSpacing(8)
_icon_btn_style = (
"QPushButton{background:transparent;color:#888888;border:none;"
"font-size:18px;padding:0 2px;}"
"QPushButton:hover{color:#cccccc;}"
"QPushButton:checked{color:#007acc;}"
)
# Left column: header row + group box
left_col = QVBoxLayout()
left_col.setSpacing(3)
plugin357_hdr = QHBoxLayout()
plugin357_title_lbl = QLabel("Code 357 — Plugin Handlers (MZ)")
plugin357_title_lbl.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;")
plugin357_hdr.addWidget(plugin357_title_lbl)
plugin357_hdr.addStretch()
plugin357_select_all_btn = QPushButton("☑")
plugin357_select_all_btn.setCheckable(True)
plugin357_select_all_btn.setFixedSize(28, 28)
plugin357_select_all_btn.setStyleSheet(_icon_btn_style)
plugin357_select_all_btn.setToolTip("Select / Deselect All")
plugin357_hdr.addWidget(plugin357_select_all_btn)
left_col.addLayout(plugin357_hdr)
plugin357_box = QWidget()
plugin357_box.setObjectName("cbbox")
plugin357_box.setStyleSheet(self._checkbox_box_style())
plugin357_inner = QVBoxLayout(plugin357_box)
plugin357_inner.setContentsMargins(8, 8, 10, 6)
plugin357_inner.setSpacing(3)
plugin357_container = QWidget()
plugin357_vbox = QVBoxLayout(plugin357_container)
plugin357_vbox.setContentsMargins(4, 2, 4, 2)
plugin357_vbox.setSpacing(2)
self._p2_plugin_checks: dict = {}
try:
from modules.rpgmakermvmz import HEADER_MAPPINGS_357 as _HM357
for key in sorted(_HM357.keys(), key=str.casefold):
cb = QCheckBox(key)
cb.setStyleSheet("color:#cccccc;font-size:13px;")
cb.stateChanged.connect(self._schedule_p2_config_apply)
plugin357_vbox.addWidget(cb)
self._p2_plugin_checks[key] = cb
except Exception:
pass
plugin357_vbox.addStretch()
plugin357_scroll = QScrollArea()
plugin357_scroll.setWidgetResizable(True)
plugin357_scroll.setWidget(plugin357_container)
plugin357_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
plugin357_scroll.setStyleSheet("QScrollArea{border:none;}")
plugin357_inner.addWidget(plugin357_scroll, 1)
plugin357_box.setMaximumHeight(260)
def _toggle_plugins357(checked):
plugin357_select_all_btn.setText("☑" if checked else "☐")
for cb in self._p2_plugin_checks.values():
cb.setChecked(checked)
plugin357_select_all_btn.toggled.connect(_toggle_plugins357)
left_col.addWidget(plugin357_box, 1)
lists_row.addLayout(left_col, 1)
# Right column: header row + group box
right_col = QVBoxLayout()
right_col.setSpacing(3)
patterns_hdr = QHBoxLayout()
patterns_title_lbl = QLabel("Code 355/655 — Script Patterns")
patterns_title_lbl.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;")
patterns_hdr.addWidget(patterns_title_lbl)
patterns_hdr.addStretch()
patterns_select_all_btn = QPushButton("☑")
patterns_select_all_btn.setCheckable(True)
patterns_select_all_btn.setFixedSize(28, 28)
patterns_select_all_btn.setStyleSheet(_icon_btn_style)
patterns_select_all_btn.setToolTip("Select / Deselect All")
patterns_hdr.addWidget(patterns_select_all_btn)
right_col.addLayout(patterns_hdr)
patterns_box = QWidget()
patterns_box.setObjectName("cbbox")
patterns_box.setStyleSheet(self._checkbox_box_style())
patterns_inner_layout = QVBoxLayout(patterns_box)
patterns_inner_layout.setContentsMargins(8, 8, 10, 6)
patterns_inner_layout.setSpacing(3)
patterns_container = QWidget()
patterns_vbox = QVBoxLayout(patterns_container)
patterns_vbox.setContentsMargins(4, 2, 4, 2)
patterns_vbox.setSpacing(2)
self._p2_pattern_checks: dict = {}
try:
from modules.rpgmakermvmz import PATTERNS_355655 as _PAT
for key in sorted(_PAT.keys(), key=str.casefold):
cb = QCheckBox(key)
cb.setStyleSheet("color:#cccccc;font-size:13px;")
cb.stateChanged.connect(self._schedule_p2_config_apply)
patterns_vbox.addWidget(cb)
self._p2_pattern_checks[key] = cb
except Exception:
pass
patterns_vbox.addStretch()
patterns_scroll = QScrollArea()
patterns_scroll.setWidgetResizable(True)
patterns_scroll.setWidget(patterns_container)
patterns_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
patterns_scroll.setStyleSheet("QScrollArea{border:none;}")
patterns_inner_layout.addWidget(patterns_scroll, 1)
patterns_box.setMaximumHeight(260)
def _toggle_patterns(checked):
patterns_select_all_btn.setText("☑" if checked else "☐")
for cb in self._p2_pattern_checks.values():
cb.setChecked(checked)
patterns_select_all_btn.toggled.connect(_toggle_patterns)
right_col.addWidget(patterns_box, 1)
lists_row.addLayout(right_col, 1)
layout.addLayout(lists_row)
# ── Bottom row: Run ────────────────────────────────────────────────
bottom_row = QHBoxLayout()
bottom_row.setSpacing(10)
_P2_BTN_WIDTH = 200
_P2_BTN_HEIGHT = 36
self._run_p2_btn = _make_btn("► Run Phase 2", "#7a4a00")
self._run_p2_btn.setFixedSize(_P2_BTN_WIDTH, _P2_BTN_HEIGHT)
self._run_p2_btn.setToolTip(
"Applies Phase 2 code settings and starts translation with event files pre-selected."
)
self._run_p2_btn.clicked.connect(lambda: self._run_phase(2))
bottom_row.addWidget(self._run_p2_btn)
self._p2_status_lbl = QLabel("")
self._p2_status_lbl.setStyleSheet("color:#d4aa68;font-size:13px;padding-left:4px;")
bottom_row.addWidget(self._p2_status_lbl)
bottom_row.addStretch()
layout.addLayout(bottom_row)
self._p2_auto_apply_timer = QTimer(self)
self._p2_auto_apply_timer.setSingleShot(True)
self._p2_auto_apply_timer.timeout.connect(self._apply_p2_config)
# Pre-populate all Phase 2 checkboxes from current module state
self._populate_p2_checkboxes()
# ── Step 5: Plugins.js + Export ────────────────────────────────────────
def _build_step5_finish(self, layout: QVBoxLayout):
self._add_step_header(layout, "Step 5 — Plugins & Export", 5)
box = QWidget()
box.setObjectName("tbox")
box.setStyleSheet(self._task_box_style())
box.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
inner = QVBoxLayout(box)
inner.setContentsMargins(14, 12, 14, 12)
inner.setSpacing(10)
_LBL = "color:#4ec9b0;font-size:12px;font-weight:bold;"
_LBL_W = 70
_BTN_H = 32
_BTN_W = 230
def _labeled_row(label: QLabel, *buttons: QPushButton) -> QHBoxLayout:
row = QHBoxLayout()
row.setSpacing(10)
label.setStyleSheet(_LBL)
label.setFixedWidth(_LBL_W)
label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
row.addWidget(label)
for btn in buttons:
btn.setFixedSize(_BTN_W, _BTN_H)
row.addWidget(btn)
return row
self._step6_section_label = QLabel("Plugins")
self._step6_vocab_btn = _make_btn("📄 Copy vocab.txt → Game", "#555")
self._step6_vocab_btn.setToolTip(
"Copy vocab.txt to /vocab.txt so you can attach it "
"alongside plugins.js (or Ace .rb scripts) when running the AI prompt."
)
self._step6_vocab_btn.clicked.connect(self._copy_vocab_to_game)
self._step6_copy_btn = _make_btn("📋 Copy Prompt for Copilot", "#555")
self._step6_copy_btn.setToolTip(
"Copy a prompt that audits plugins.js and enabled plugin sources, asks what "
"needs translation, then edits approved player-visible strings in place."
)
self._step6_copy_btn.clicked.connect(self._copy_plugins_js_translate_prompt)
inner.addLayout(
_labeled_row(
self._step6_section_label,
self._step6_vocab_btn,
self._step6_copy_btn,
)
)
export_lbl = QLabel("Export")
export_active_btn = _make_btn("📤 Export Active Files", "#3a7a3a")
export_active_btn.setToolTip(
"Only export files whose names match those currently in files/\n"
"(i.e. the files you imported for this project)"
)
export_active_btn.clicked.connect(self._export_active_files)
export_all_btn = _make_btn("📤 Export ALL translated/", "#555")
export_all_btn.setToolTip(
"Export every file in translated/ regardless of what is in files/"
)
export_all_btn.clicked.connect(self._export_to_game)
inner.addLayout(_labeled_row(export_lbl, export_active_btn, export_all_btn))
layout.addWidget(box, 0, Qt.AlignLeft)
# ── Step 6: Playtest (TL Inspector) ─────────────────────────────────────
def _build_step8_playtest(self, layout: QVBoxLayout):
self._step8_section_label = self._add_step_header(
layout, "Step 6 — Playtest Tools", 6
)
settings_box = QWidget()
settings_box.setObjectName("tbox")
settings_box.setStyleSheet(self._task_box_style())
settings_inner = QVBoxLayout(settings_box)
settings_inner.setContentsMargins(12, 10, 12, 10)
settings_inner.setSpacing(8)
_PT_LABEL_W = 118
_PT_FIELD_W = 96
_PT_BTN_W = 96
_PT_LBL_STYLE = "color:#9d9d9d;font-size:12px;"
_PT_SECTION_STYLE = "color:#4ec9b0;font-size:12px;font-weight:bold;"
hotkey_title = QLabel("Hotkeys")
hotkey_title.setStyleSheet(_PT_SECTION_STYLE)
settings_inner.addWidget(hotkey_title)
hotkey_row = QHBoxLayout()
hotkey_row.setSpacing(8)
insp_lbl = QLabel("Inspector toggle:")
insp_lbl.setFixedWidth(_PT_LABEL_W)
insp_lbl.setStyleSheet(_PT_LBL_STYLE)
insp_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
hotkey_row.addWidget(insp_lbl)
self._pt_hotkey_edit = QLineEdit("F9")
self._pt_hotkey_edit.setFixedWidth(_PT_FIELD_W)
self._pt_hotkey_edit.setPlaceholderText("F9")
hotkey_row.addWidget(self._pt_hotkey_edit)
hotkey_row.addSpacing(16)
self._pt_forge_hotkey_lbl = QLabel("Forge toggle:")
self._pt_forge_hotkey_lbl.setFixedWidth(_PT_LABEL_W)
self._pt_forge_hotkey_lbl.setStyleSheet(_PT_LBL_STYLE)
self._pt_forge_hotkey_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
hotkey_row.addWidget(self._pt_forge_hotkey_lbl)
self._pt_forge_hotkey_edit = QLineEdit("F10")
self._pt_forge_hotkey_edit.setFixedWidth(_PT_FIELD_W)
self._pt_forge_hotkey_edit.setPlaceholderText("F8")
self._pt_forge_hotkey_edit.setToolTip(
"Key to open Forge (e.g. F8, F6, F10).\n"
"MV uses the legacy plugin; MZ uses the modern one.\n"
"Under Wine/Linux, F10 is often stolen by the window menu - prefer F8.\n"
"Click Apply to game after changing."
)
hotkey_row.addWidget(self._pt_forge_hotkey_edit)
hotkey_row.addStretch(1)
settings_inner.addLayout(hotkey_row)
scale_row = QHBoxLayout()
scale_row.setSpacing(8)
scale_lbl = QLabel("UI scale:")
scale_lbl.setFixedWidth(_PT_LABEL_W)
scale_lbl.setStyleSheet(_PT_LBL_STYLE)
scale_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
scale_row.addWidget(scale_lbl)
self._pt_ui_scale_combo = QComboBox()
self._pt_ui_scale_combo.setToolTip(
"In-game overlay size. Auto scales from window size and display DPI."
)
for label, value in (
("Auto (match game width)", "auto"),
("100%", "1"),
("125%", "1.25"),
("150%", "1.5"),
("175%", "1.75"),
("200%", "2"),
("225%", "2.25"),
("250%", "2.5"),
):
self._pt_ui_scale_combo.addItem(label, value)
scale_row.addWidget(self._pt_ui_scale_combo, 1)
settings_inner.addLayout(scale_row)
editor_title = QLabel("Editor settings")
editor_title.setStyleSheet(_PT_SECTION_STYLE + "padding-top:2px;")
settings_inner.addWidget(editor_title)
editor_grid = QGridLayout()
editor_grid.setHorizontalSpacing(8)
editor_grid.setVerticalSpacing(6)
editor_grid.setColumnMinimumWidth(0, _PT_LABEL_W)
editor_grid.setColumnStretch(1, 1)
editor_lbl = QLabel("Editor:")
editor_lbl.setStyleSheet(_PT_LBL_STYLE)
editor_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
editor_grid.addWidget(editor_lbl, 0, 0)
self._tli_editor_combo = QComboBox()
self._tli_editor_combo.currentIndexChanged.connect(self._on_tli_editor_combo_changed)
editor_grid.addWidget(self._tli_editor_combo, 0, 1)
detect_btn = _make_btn("Detect", "#4a4a4a")
detect_btn.setFixedWidth(_PT_BTN_W)
detect_btn.setToolTip("Scan this PC for VS Code, Insiders, or Cursor")
detect_btn.clicked.connect(self._detect_tli_editors)
editor_grid.addWidget(detect_btn, 0, 2)
custom_lbl = QLabel("Custom path:")
custom_lbl.setStyleSheet(_PT_LBL_STYLE)
custom_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
editor_grid.addWidget(custom_lbl, 1, 0)
self._tli_editor_custom = QLineEdit()
self._tli_editor_custom.setPlaceholderText("Editor executable when Custom is selected")
self._tli_editor_custom.setEnabled(False)
editor_grid.addWidget(self._tli_editor_custom, 1, 1)
browse_editor_btn = _make_btn("Browse…", "#4a4a4a")
browse_editor_btn.setFixedWidth(_PT_BTN_W)
browse_editor_btn.clicked.connect(self._browse_tli_editor)
editor_grid.addWidget(browse_editor_btn, 1, 2)
self._tli_detect_label = QLabel("")
self._tli_detect_label.setWordWrap(True)
self._tli_detect_label.setStyleSheet("color:#6a6a6a;font-size:11px;")
editor_grid.addWidget(self._tli_detect_label, 2, 1, 1, 2)
settings_inner.addLayout(editor_grid)
action_row = QHBoxLayout()
action_row.setSpacing(8)
save_pt_btn = _make_btn("✔ Save settings", "#3a5a7a")
save_pt_btn.setFixedHeight(30)
save_pt_btn.setToolTip("Write hotkeys and editor settings to .env (used on Install / Apply)")
save_pt_btn.clicked.connect(self._save_playtest_settings)
action_row.addWidget(save_pt_btn)
apply_pt_btn = _make_btn("↻ Apply to game", "#3a5a7a")
apply_pt_btn.setFixedHeight(30)
apply_pt_btn.setToolTip("Update the installed playtest plugin(s) in your game folder")
apply_pt_btn.clicked.connect(self._apply_playtest_settings)
action_row.addWidget(apply_pt_btn)
settings_inner.addLayout(action_row)
layout.addWidget(settings_box)
self._step8_settings_box = settings_box
# ── Plugins (TL Inspector + Forge) ────────────────────────────────────
plugins_box = QWidget()
plugins_box.setObjectName("tbox")
plugins_box.setStyleSheet(self._task_box_style())
plugins_inner = QVBoxLayout(plugins_box)
plugins_inner.setContentsMargins(12, 10, 12, 10)
plugins_inner.setSpacing(8)
_PT_SECTION_STYLE = "color:#4ec9b0;font-size:12px;font-weight:bold;"
tli_title = QLabel("TL Inspector")
tli_title.setStyleSheet(_PT_SECTION_STYLE)
plugins_inner.addWidget(tli_title)
self._tli_status_label = QLabel("Status: (detect a project folder first)")
self._tli_status_label.setWordWrap(True)
self._tli_status_label.setStyleSheet("color:#7a7a7a;font-size:13px;")
plugins_inner.addWidget(self._tli_status_label)
tli_btn_row = QHBoxLayout()
tli_btn_row.setSpacing(8)
self._tli_install_btn = _make_btn("⬇ Install TL Inspector", "#3a7a3a")
self._tli_install_btn.setMinimumHeight(30)
self._tli_install_btn.clicked.connect(self._install_tl_inspector)
tli_btn_row.addWidget(self._tli_install_btn, 1)
self._tli_uninstall_btn = _make_btn("⬆ Uninstall TL Inspector", "#7a3a3a")
self._tli_uninstall_btn.setMinimumHeight(30)
self._tli_uninstall_btn.clicked.connect(self._uninstall_tl_inspector)
tli_btn_row.addWidget(self._tli_uninstall_btn, 1)
plugins_inner.addLayout(tli_btn_row)
self._step8_forge_section = QWidget()
forge_section_layout = QVBoxLayout(self._step8_forge_section)
forge_section_layout.setContentsMargins(0, 4, 0, 0)
forge_section_layout.setSpacing(8)
forge_title = QLabel("Forge (MV / MZ)")
forge_title.setStyleSheet(_PT_SECTION_STYLE)
forge_section_layout.addWidget(forge_title)
self._forge_status_label = QLabel("Status: (MV or MZ project required)")
self._forge_status_label.setWordWrap(True)
self._forge_status_label.setStyleSheet("color:#7a7a7a;font-size:13px;")
forge_section_layout.addWidget(self._forge_status_label)
forge_btn_row = QHBoxLayout()
forge_btn_row.setSpacing(8)
self._forge_install_btn = _make_btn("⬇ Install Forge", "#3a7a3a")
self._forge_install_btn.setMinimumHeight(30)
self._forge_install_btn.clicked.connect(self._install_forge)
forge_btn_row.addWidget(self._forge_install_btn, 1)
self._forge_uninstall_btn = _make_btn("⬆ Uninstall Forge", "#7a3a3a")
self._forge_uninstall_btn.setMinimumHeight(30)
self._forge_uninstall_btn.clicked.connect(self._uninstall_forge)
forge_btn_row.addWidget(self._forge_uninstall_btn, 1)
forge_section_layout.addLayout(forge_btn_row)
plugins_inner.addWidget(self._step8_forge_section)
self._step8_tli_credits = QLabel("Idea by Sakura · Plugin by Kao_SSS")
self._step8_tli_credits.setStyleSheet("color:#6a6a6a;font-size:11px;font-style:italic;padding-top:2px;")
plugins_inner.addWidget(self._step8_tli_credits)
self._step8_forge_credits = QLabel(
'Forge by len'
)
self._step8_forge_credits.setStyleSheet("color:#6a6a6a;font-size:11px;font-style:italic;")
self._step8_forge_credits.setTextFormat(Qt.RichText)
self._step8_forge_credits.setOpenExternalLinks(True)
plugins_inner.addWidget(self._step8_forge_credits)
self._install_both_btn = _make_btn("⬇ Install Both", "#3a5a7a")
self._install_both_btn.setMinimumHeight(30)
self._install_both_btn.setToolTip(
"Install TL Inspector and Forge as separate plugins (MV/MZ)"
)
self._install_both_btn.clicked.connect(self._install_both_playtest)
plugins_inner.addWidget(self._install_both_btn)
layout.addWidget(plugins_box)
self._step8_playtest_box = plugins_box
self._step8_forge_box = self._step8_forge_section
self._populate_tli_editor_combo()
self._load_playtest_settings()
def _populate_tli_editor_combo(self, select: str | None = None):
"""Fill editor dropdown with auto-detect, found editors, and custom."""
from util.tl_inspector.config import detect_editors
combo = self._tli_editor_combo
combo.blockSignals(True)
combo.clear()
combo.addItem("Auto-detect (recommended)", "auto")
for label, path in detect_editors():
combo.addItem(f"{label} — {path}", str(path))
combo.addItem("Custom path…", "__custom__")
want = select
if want is None:
try:
from util.tl_inspector.config import load_config
want = load_config().get("editorCmd", "auto")
except Exception:
want = "auto"
idx = combo.findData(want) if want else 0
if idx >= 0:
combo.setCurrentIndex(idx)
elif want and want != "auto":
custom_idx = combo.findData("__custom__")
combo.setCurrentIndex(custom_idx if custom_idx >= 0 else 0)
self._tli_editor_custom.setText(want)
else:
combo.setCurrentIndex(0)
combo.blockSignals(False)
self._on_tli_editor_combo_changed()
self._update_tli_detect_label()
def _update_tli_detect_label(self):
from util.tl_inspector.config import detect_editors, detect_primary_editor
found = detect_editors()
primary = detect_primary_editor()
if primary:
extra = f" ({len(found)} found)" if len(found) > 1 else ""
self._tli_detect_label.setText(f"Detected on this PC: {primary}{extra}")
else:
self._tli_detect_label.setText(
"No VS Code / Cursor found — install one or choose Custom path."
)
def _load_playtest_settings(self):
"""Load playtest hotkeys and editor settings from .env into Step 8 controls."""
try:
from util.playtest.config import load_config
cfg = load_config()
except Exception:
cfg = {
"hotkey": "F9",
"forgeHotkey": "F10",
"uiScale": "auto",
"editorCmd": "auto",
}
self._pt_hotkey_edit.setText(cfg.get("hotkey", "F9"))
self._pt_forge_hotkey_edit.setText(cfg.get("forgeHotkey", "F10"))
want_scale = str(cfg.get("uiScale", "auto"))
scale_idx = self._pt_ui_scale_combo.findData(want_scale)
if scale_idx >= 0:
self._pt_ui_scale_combo.setCurrentIndex(scale_idx)
else:
custom_idx = self._pt_ui_scale_combo.findData("auto")
self._pt_ui_scale_combo.setCurrentIndex(custom_idx if custom_idx >= 0 else 0)
self._populate_tli_editor_combo(select=cfg.get("editorCmd", "auto"))
def _resolve_playtest_config(self) -> dict:
"""Build playtest config dict from Step 8 controls."""
mode = self._tli_editor_combo.currentData()
if mode == "__custom__":
editor = self._tli_editor_custom.text().strip() or "auto"
elif mode:
editor = str(mode)
else:
editor = "auto"
return {
"hotkey": self._pt_hotkey_edit.text().strip() or "F9",
"forgeHotkey": self._pt_forge_hotkey_edit.text().strip() or "F10",
"uiScale": str(self._pt_ui_scale_combo.currentData() or "auto"),
"editorCmd": editor,
"workspaceFolder": "auto",
}
def _save_playtest_settings(self):
cfg = self._resolve_playtest_config()
try:
from util.playtest.config import save_config
save_config(cfg)
self._log(
"✅ Playtest settings saved — "
f"inspector={cfg['hotkey']}, forge={cfg['forgeHotkey']}, "
f"scale={cfg['uiScale']}, editor={cfg['editorCmd']}"
)
except Exception as exc:
self._log(f"❌ Could not save playtest settings: {exc}")
def _apply_playtest_settings(self):
game_root = self.folder_edit.text().strip()
if not game_root:
self._log("⚠ No game folder set. Complete Step 0 first.")
return
cfg = self._resolve_playtest_config()
try:
from util.forge.installer import detect_engine
from util.playtest.config import save_config
save_config(cfg)
info = detect_engine(Path(game_root))
msgs: list[str] = []
if info is not None:
from util.forge.installer import apply_config as apply_forge
from util.forge.installer import status as forge_status
if forge_status(Path(game_root)).get("plugin_file"):
ok, msg = apply_forge(Path(game_root), cfg)
msgs.append(("✅ " if ok else "❌ ") + msg)
from util.tl_inspector.installer import apply_config as apply_tli
from util.tl_inspector.installer import status as tli_status
if tli_status(Path(game_root)).get("plugin_file"):
ok, msg = apply_tli(Path(game_root), cfg)
msgs.append(("✅ " if ok else "❌ ") + msg)
if not msgs:
self._log("⚠ No playtest plugins installed in this game folder.")
return
for line in msgs:
self._log(line)
except Exception as exc:
self._log(f"❌ Could not apply playtest settings: {exc}")
return
self._refresh_playtest_status()
def _refresh_playtest_status(self):
"""Update Step 8 status labels for the current engine."""
if getattr(self, "_step8_playtest_box", None) is not None:
self._refresh_tl_inspector_status()
self._refresh_forge_status()
def _on_tli_editor_combo_changed(self, _index: int | None = None):
custom = self._tli_editor_combo.currentData() == "__custom__"
self._tli_editor_custom.setEnabled(custom)
def _detect_tli_editors(self):
try:
from util.tl_inspector.config import load_config
current = load_config().get("editorCmd", "auto")
except Exception:
current = "auto"
self._populate_tli_editor_combo(select=current)
self._log("🔍 Scanned for VS Code / Cursor installations.")
def _browse_tli_editor(self):
start = self._tli_editor_custom.text() or self._setting("last_tli_editor", "")
path, _ = QFileDialog.getOpenFileName(
self,
"Select Editor Executable",
start,
"Executables (*.exe);;All Files (*)",
)
if not path:
return
self._save_setting("last_tli_editor", path)
custom_idx = self._tli_editor_combo.findData("__custom__")
if custom_idx >= 0:
self._tli_editor_combo.setCurrentIndex(custom_idx)
self._tli_editor_custom.setText(path)
def _save_tli_editor_settings(self):
self._save_playtest_settings()
def _apply_tli_editor_settings(self):
self._apply_playtest_settings()
def _refresh_tl_inspector_status(self):
"""Update Step 8 TL Inspector status label from the current game folder."""
label = getattr(self, "_tli_status_label", None)
if label is None:
return
game_root = self.folder_edit.text().strip()
if not game_root:
label.setText("Status: no game folder set — complete Step 0 first.")
label.setStyleSheet("color:#7a7a7a;font-size:13px;")
return
try:
from util.tl_inspector.installer import status
st = status(Path(game_root))
except Exception as exc:
label.setText(f"Status: error — {exc}")
label.setStyleSheet("color:#f48771;font-size:13px;")
return
if not st.get("ok"):
label.setText(f"Status: {st.get('message', 'unsupported')}")
label.setStyleSheet("color:#e9a12a;font-size:13px;")
return
engine = st.get("engine", "?")
msg = st.get("message", "")
parts = [f"RPG Maker {engine}", msg]
if st.get("declared") and st.get("plugin_file"):
detail = "plugin declared in plugins.js and file present"
elif st.get("declared"):
detail = "declared in plugins.js (plugin file missing)"
elif st.get("plugin_file"):
detail = "plugin file present (not declared in plugins.js)"
else:
detail = "not installed"
label.setText(f"Status: {' · '.join(parts)} — {detail}")
color = "#6a9a6a" if st.get("declared") and st.get("plugin_file") else "#9d9d9d"
label.setStyleSheet(f"color:{color};font-size:13px;")
def _install_tl_inspector(self):
game_root = self.folder_edit.text().strip()
if not game_root:
self._log("⚠ No game folder set. Complete Step 0 first.")
return
cfg = self._resolve_playtest_config()
try:
from util.playtest.config import save_config
from util.tl_inspector.installer import install
save_config(cfg)
ok, msg = install(Path(game_root), cfg=cfg)
except Exception as exc:
self._log(f"❌ TL Inspector install failed: {exc}")
return
self._log(("✅ " if ok else "❌ ") + msg)
self._refresh_playtest_status()
def _uninstall_tl_inspector(self):
game_root = self.folder_edit.text().strip()
if not game_root:
self._log("⚠ No game folder set. Complete Step 0 first.")
return
reply = QMessageBox.question(
self,
"Uninstall TL Inspector",
"Remove TLInspector from plugins.js and delete the plugin file?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
try:
from util.tl_inspector.installer import uninstall
ok, msg = uninstall(Path(game_root))
except Exception as exc:
self._log(f"❌ TL Inspector uninstall failed: {exc}")
return
self._log(("✅ " if ok else "❌ ") + msg)
self._refresh_playtest_status()
def _refresh_forge_status(self):
"""Update Step 8 Forge status label from the current game folder."""
label = getattr(self, "_forge_status_label", None)
if label is None:
return
game_root = self.folder_edit.text().strip()
if not game_root:
label.setText("Status: no game folder set — complete Step 0 first.")
label.setStyleSheet("color:#7a7a7a;font-size:13px;")
return
try:
from util.forge.installer import detect_engine, status
if detect_engine(Path(game_root)) is None:
label.setText("Status: not an MV/MZ project.")
label.setStyleSheet("color:#e9a12a;font-size:13px;")
return
st = status(Path(game_root))
except Exception as exc:
label.setText(f"Status: error — {exc}")
label.setStyleSheet("color:#f48771;font-size:13px;")
return
engine = st.get("engine", "?")
msg = st.get("message", "")
if st.get("declared") and st.get("plugin_file"):
detail = "plugin declared in plugins.js and file present"
elif st.get("declared"):
detail = "declared in plugins.js (plugin file missing)"
elif st.get("plugin_file"):
detail = "plugin file present (not declared in plugins.js)"
else:
detail = "not installed"
label.setText(f"Status: RPG Maker {engine} · {msg} — {detail}")
color = "#6a9a6a" if st.get("declared") and st.get("plugin_file") else "#9d9d9d"
label.setStyleSheet(f"color:{color};font-size:13px;")
def _install_forge(self):
game_root = self.folder_edit.text().strip()
if not game_root:
self._log("⚠ No game folder set. Complete Step 0 first.")
return
cfg = self._resolve_playtest_config()
try:
from util.playtest.config import save_config
from util.forge.installer import install
save_config(cfg)
ok, msg = install(Path(game_root), cfg=cfg)
except Exception as exc:
self._log(f"❌ Forge install failed: {exc}")
return
self._log(("✅ " if ok else "❌ ") + msg)
self._refresh_playtest_status()
def _install_both_playtest(self):
game_root = self.folder_edit.text().strip()
if not game_root:
self._log("⚠ No game folder set. Complete Step 0 first.")
return
try:
from util.forge.installer import detect_engine
if detect_engine(Path(game_root)) is None:
self._log("⚠ Install Both requires an MV or MZ project.")
return
except Exception as exc:
self._log(f"❌ Could not detect engine: {exc}")
return
cfg = self._resolve_playtest_config()
try:
from util.playtest.config import save_config
from util.tl_inspector.installer import install as install_tli
from util.forge.installer import install as install_forge
save_config(cfg)
ok_tli, msg_tli = install_tli(Path(game_root), cfg=cfg)
self._log(("✅ " if ok_tli else "❌ ") + msg_tli)
if not ok_tli:
return
ok_forge, msg_forge = install_forge(Path(game_root), cfg=cfg)
self._log(("✅ " if ok_forge else "❌ ") + msg_forge)
except Exception as exc:
self._log(f"❌ Install Both failed: {exc}")
return
self._refresh_playtest_status()
def _uninstall_forge(self):
game_root = self.folder_edit.text().strip()
if not game_root:
self._log("⚠ No game folder set. Complete Step 0 first.")
return
reply = QMessageBox.question(
self,
"Uninstall Forge",
"Remove Forge from plugins.js and delete the plugin file?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
try:
from util.forge.installer import uninstall
ok, msg = uninstall(Path(game_root))
except Exception as exc:
self._log(f"❌ Forge uninstall failed: {exc}")
return
self._log(("✅ " if ok else "❌ ") + msg)
self._refresh_playtest_status()
# ─────────────────────────────────────────────────────────────────────────
# Step 0 – Project Folder logic
# ─────────────────────────────────────────────────────────────────────────
def _browse_folder(self):
start = self.folder_edit.text() or self._setting("last_game_folder", "")
folder = QFileDialog.getExistingDirectory(self, "Select Game Root Folder", start)
if folder:
self.folder_edit.setText(folder)
self._save_setting("last_game_folder", folder)
self._detected_on_show = True # new folder chosen — treat as already-shown
self._ask_clear_old_files()
self._detect_folder()
def _ask_clear_old_files(self):
"""Prompt the user to clear /files and /translated to avoid stale data conflicts."""
import shutil
msg = QMessageBox(self)
msg.setWindowTitle("Clear Previous Translation Data?")
msg.setText(
"Do you want to clear the files/ and translated/ folders?\n\n"
"This is recommended when switching to a new game project to avoid "
"old translations conflicting with the new one."
)
msg.setIcon(QMessageBox.Question)
msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
msg.setDefaultButton(QMessageBox.Yes)
result = msg.exec_()
if result != QMessageBox.Yes:
return
base = Path(__file__).resolve().parent.parent
cleared = []
errors = []
for folder_name in ("files", "translated"):
target = base / folder_name
if target.is_dir():
for child in target.iterdir():
if child.name == ".gitkeep":
continue
try:
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink()
cleared.append(child.name)
except Exception as exc: # noqa: BLE001
errors.append(f"{child.name}: {exc}")
if cleared:
self._log(f"🗑 Cleared {len(cleared)} item(s) from files/ and translated/.")
else:
self._log("ℹ files/ and translated/ were already empty.")
for err in errors:
self._log(f"⚠ Could not remove {err}")
def _update_step6_for_engine(self, is_ace: bool) -> None:
"""Adapt plugins/scripts controls for MV/MZ vs Ace; hide Playtest on Ace."""
# Step 1 prettier / plugins.js format - only relevant for MV/MZ
for attr in ("_pp_dazedformat_title", "_pp_dazedformat_box",
"_pp_plugins_js_title", "_pp_plugins_js_box"):
w = getattr(self, attr, None)
if w is not None:
w.setVisible(not is_ace)
# Step 5 plugins subsection title + prompt tooltip
lbl = getattr(self, "_step6_section_label", None)
if lbl is not None:
lbl.setText("Scripts" if is_ace else "Plugins")
vocab_btn = getattr(self, "_step6_vocab_btn", None)
if vocab_btn is not None:
if is_ace:
vocab_btn.setToolTip(
"Copy vocab.txt to /vocab.txt so you can attach it "
"alongside ace_json/scripts/*.rb when running the AI prompt."
)
else:
vocab_btn.setToolTip(
"Copy vocab.txt to /vocab.txt so you can attach it "
"alongside plugins.js when running the AI prompt."
)
btn = getattr(self, "_step6_copy_btn", None)
if btn is not None:
if is_ace:
btn.setToolTip(
"Copy a prompt that instructs Copilot/Cursor to translate only "
"visible player-facing strings in the Ace .rb script files. "
"It audits first, asks what to translate, then edits approved files in place."
)
else:
btn.setToolTip(
"Copy a prompt that audits plugins.js and enabled plugin sources, asks what "
"needs translation, then edits approved player-visible strings in place."
)
# Step 6 - TL Inspector (MV/MZ only; hidden for Ace)
show_playtest = not is_ace
playtest_idx = 6
if hasattr(self, "_step_tabs") and self._step_tabs.count() > playtest_idx:
if hasattr(self._step_tabs, "setTabVisible"):
self._step_tabs.setTabVisible(playtest_idx, show_playtest)
else:
self._step_tabs.setTabEnabled(playtest_idx, show_playtest)
if is_ace and self._step_tabs.currentIndex() == playtest_idx:
self._goto_step(5)
if hasattr(self, "_step_buttons") and len(self._step_buttons) > playtest_idx:
self._step_buttons[playtest_idx].setVisible(show_playtest)
self._step_buttons[playtest_idx].setEnabled(show_playtest)
self._refresh_step_strip()
box = getattr(self, "_step8_playtest_box", None)
install_both_btn = getattr(self, "_install_both_btn", None)
if box is not None:
box.setVisible(not is_ace)
box.setEnabled(not is_ace)
if install_both_btn is not None:
install_both_btn.setVisible(not is_ace)
if not is_ace:
self._refresh_playtest_status()
def _detect_folder(self):
folder = self.folder_edit.text().strip()
if not folder:
self._log("⚠ No folder path entered.")
return
self._save_setting("last_game_folder", folder)
if hasattr(self, "setup_editors"):
self.setup_editors.reload_all()
self.detected_label.setText("Scanning…")
self.detected_label.setStyleSheet(
"color:#9d9d9d;font-size:13px;padding:4px 8px;"
"background-color:#252526;border:1px solid #3c3c3c;"
"border-radius:4px;margin:4px 0;"
)
self.file_list.clear()
self._set_import_buttons_enabled(False)
self._last_import_signature = None
self._pending_import_signature = None
# Reset ACE state from any previous detection
self._ace_encrypted = False
self._ace_json_dir = ""
self._ace_rvdata_dir = ""
self._update_step6_for_engine(False)
root_path = Path(folder)
# ── RPGMaker Ace encrypted: Game.rgss* present (no Data/ yet) ────────
# Must be checked BEFORE find_data_folder, which returns UNKNOWN for
# encrypted games (no rvdata2 files exist until the archive is extracted).
rgss_files = list(root_path.glob("Game.rgss*"))
if rgss_files:
self._ace_encrypted = True
rgss_name = rgss_files[0].name
self.detected_label.setText(
f"⚠ RPGMaker Ace — Encrypted ({rgss_name}). Decrypt before importing."
)
self.detected_label.setStyleSheet(
"color:#e9a12a;font-size:13px;padding:4px 8px;"
"background-color:#2b2010;border:1px solid #5a4010;"
"border-radius:4px;margin:4px 0;"
)
self._log(f"⚠ RPGMaker Ace (encrypted) detected — found: {rgss_name}")
self._update_step6_for_engine(True)
self._show_ace_decrypt_notice(folder, str(rgss_files[0]))
return
# ─────────────────────────────────────────────────────────────────────
try:
from util.project_scanner import find_data_folder
data_path, engine = find_data_folder(folder)
except Exception as exc:
self.detected_label.setText(f"Error: {exc}")
self.detected_label.setStyleSheet(
"color:#f48771;font-size:13px;padding:4px 8px;"
"background-color:#2b1a1a;border:1px solid #5a2a2a;"
"border-radius:4px;margin:4px 0;"
)
return
if data_path is None:
self.detected_label.setText(
"⚠ No recognised data folder found. "
"Make sure this is a valid RPGMaker game directory."
)
self.detected_label.setStyleSheet(
"color:#e9a12a;font-size:13px;padding:4px 8px;"
"background-color:#2b2010;border:1px solid #5a4010;"
"border-radius:4px;margin:4px 0;"
)
return
self._data_path = str(data_path)
self._engine = engine
# ── RPGMaker Ace decrypted: rvdata2 present, no rgss archive ─────────
if engine == "ACE":
self._ace_encrypted = False
self._ace_rvdata_dir = str(data_path)
self._engine = "MVMZ" # scan JSON files like MVMZ
self._update_step6_for_engine(True)
self._log("RPGMaker Ace (decrypted) detected.")
self._log(f" rvdata2 dir : {data_path}")
ace_json = root_path / "ace_json"
if ace_json.is_dir() and any(ace_json.glob("*.json")):
self._ace_json_dir = str(ace_json)
self._data_path = str(ace_json)
self._log(f" ace_json dir: {ace_json} (existing — skipping RV2JSON -c)")
self.detected_label.setText(
f"Engine: Ace (via RV2JSON) · ace_json: {ace_json}"
)
self.detected_label.setStyleSheet(
"color:#6a9a6a;font-size:13px;padding:4px 8px;"
"background-color:#1f2b1f;border:1px solid #2a4a2a;"
"border-radius:4px;margin:4px 0;"
)
worker = _ScanWorker(self._data_path, "MVMZ")
worker.done.connect(self._on_scan_done)
worker.error.connect(lambda e: self._log(f"❌ Scan error: {e}"))
self._worker = worker
worker.start()
else:
self._ace_json_dir = str(ace_json)
self._data_path = str(ace_json)
self.detected_label.setText(
"RPGMaker Ace (decrypted) · Creating JSON files with RV2JSON…"
)
self.detected_label.setStyleSheet(
"color:#9d9d9d;font-size:13px;padding:4px 8px;"
"background-color:#252526;border:1px solid #3c3c3c;"
"border-radius:4px;margin:4px 0;"
)
self._run_rv2json_create()
return # scan continues above or in _on_rv2json_create_done
# ─────────────────────────────────────────────────────────────────────
self.detected_label.setText(
f"Engine: {engine} · Data folder: {data_path}"
)
self.detected_label.setStyleSheet(
"color:#6a9a6a;font-size:13px;padding:4px 8px;"
"background-color:#1f2b1f;border:1px solid #2a4a2a;"
"border-radius:4px;margin:4px 0;"
)
self._log(f"Detected data folder: {data_path} (engine: {engine})")
self._update_step6_for_engine(False)
worker = _ScanWorker(self._data_path, self._engine)
worker.done.connect(self._on_scan_done)
worker.error.connect(lambda e: self._log(f"❌ Scan error: {e}"))
self._worker = worker
worker.start()
def _on_scan_done(self, items: list):
self._file_items = items
self.file_list.clear()
from gui.qt_icons import file_category_icon
for item in items:
cat = item["category"]
lw = QListWidgetItem(f"{item['name']} ({item['size_kb']:.1f} KB)")
lw.setIcon(file_category_icon(cat))
lw.setData(Qt.UserRole, item)
lw.setFlags(lw.flags() | Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsUserCheckable)
lw.setCheckState(Qt.Checked if item["default"] else Qt.Unchecked)
if cat == "core":
lw.setForeground(__import__("PyQt5.QtGui", fromlist=["QColor"]).QColor("#9cdcfe"))
elif cat == "map":
lw.setForeground(__import__("PyQt5.QtGui", fromlist=["QColor"]).QColor("#c5c5c0"))
self.file_list.addItem(lw)
self._set_import_buttons_enabled(len(items) > 0)
self._log(f"Found {len(items)} importable file(s).")
self._populate_preprocess_paths()
if items:
self._log("Choose files to import, then click 📥 to copy them into files/.")
def _select_all_files(self):
count = self.file_list.count()
if not count:
return
self._syncing_file_checks = True
try:
for i in range(count):
self.file_list.item(i).setCheckState(Qt.Checked)
finally:
self._syncing_file_checks = False
self._log(f"✔ Selected all {count} file(s).")
def _deselect_all_files(self):
count = self.file_list.count()
if not count:
return
self._syncing_file_checks = True
try:
for i in range(count):
self.file_list.item(i).setCheckState(Qt.Unchecked)
finally:
self._syncing_file_checks = False
self._log(f"✔ Deselected all {count} file(s).")
def _select_core_only(self):
core = other = 0
self._syncing_file_checks = True
try:
for i in range(self.file_list.count()):
item = self.file_list.item(i)
data = item.data(Qt.UserRole)
is_core = bool(data and data.get("category") == "core")
item.setCheckState(Qt.Checked if is_core else Qt.Unchecked)
if is_core:
core += 1
else:
other += 1
finally:
self._syncing_file_checks = False
if self.file_list.count():
self._log(f"✔ Selected {core} core file(s); deselected {other} other(s).")
def _sync_selected_file_checks(self, changed_item: QListWidgetItem):
"""Apply a checkbox change to the current Ctrl/Shift-selected file rows."""
if self._syncing_file_checks:
return
selected = self.file_list.selectedItems()
if len(selected) <= 1 or changed_item not in selected:
return
self._syncing_file_checks = True
try:
new_state = changed_item.checkState()
for item in selected:
if item is not changed_item:
item.setCheckState(new_state)
finally:
self._syncing_file_checks = False
def _selected_import_items(self) -> list[dict]:
selected = []
for i in range(self.file_list.count()):
lw = self.file_list.item(i)
if lw.checkState() == Qt.Checked:
selected.append(lw.data(Qt.UserRole))
return selected
def _import_signature(self, selected: list[dict] | None = None) -> tuple[str, ...]:
selected = selected if selected is not None else self._selected_import_items()
return tuple(sorted(str(item.get("name", "")) for item in selected if item))
def _auto_import_if_needed(self) -> None:
selected = self._selected_import_items()
signature = self._import_signature(selected)
if not signature:
return
if signature in (self._last_import_signature, self._pending_import_signature):
return
self._log("Auto-importing checked project files into files/ before leaving Project.")
self._import_files(confirm=False, selected=selected, signature=signature)
def _import_files(
self,
confirm: bool = True,
selected: list[dict] | None = None,
signature: tuple[str, ...] | None = None,
):
selected = selected if selected is not None else self._selected_import_items()
if not selected:
self._log("⚠ No files selected.")
return
signature = signature if signature is not None else self._import_signature(selected)
if self._pending_import_signature == signature:
self._log("ℹ Import for the current selection is already running.")
return
if confirm and not self._confirm_import_overwrite(selected):
self._log("ℹ Import cancelled; files/ was left unchanged.")
return
self._set_import_buttons_enabled(False)
self._pending_import_signature = signature
worker = _ImportWorker(selected, "files")
worker.log.connect(self._log)
worker.done.connect(self._on_import_done)
self._worker = worker
worker.start()
def _confirm_import_overwrite(self, selected: list[dict]) -> bool:
files_dir = Path("files")
existing = [
item for item in files_dir.iterdir()
if item.name != ".gitkeep"
] if files_dir.exists() else []
if not existing:
return True
reply = QMessageBox.warning(
self,
"Import game files",
"Importing selected game files will delete the existing contents of files/ "
"before copying the new files.\n\n"
f"Existing items: {len(existing)}\n"
f"Selected files to import: {len(selected)}\n\n"
"Continue and overwrite files/?",
QMessageBox.Yes | QMessageBox.Cancel,
QMessageBox.Cancel,
)
return reply == QMessageBox.Yes
def _clear_translated(self):
translated_dir = Path("translated")
items_to_delete = [
item for item in translated_dir.iterdir()
if item.name != ".gitkeep"
] if translated_dir.exists() else []
if not items_to_delete:
self._log("ℹ translated/ is already empty — nothing to clear.")
return
reply = QMessageBox.warning(
self,
"Clear translated/ folder",
"This will permanently delete all files inside the translated/ folder.\n\nAre you sure?",
QMessageBox.Yes | QMessageBox.Cancel,
QMessageBox.Cancel,
)
if reply != QMessageBox.Yes:
return
deleted = 0
errors = []
for item in items_to_delete:
try:
if item.is_file():
item.unlink()
deleted += 1
elif item.is_dir():
import shutil
shutil.rmtree(item)
deleted += 1
except Exception as exc:
errors.append(f"{item.name}: {exc}")
if errors:
self._log(f"⚠ {len(errors)} error(s) while clearing translated/:")
for e in errors[:10]:
self._log(f" {e}")
self._log(f"✅ Cleared {deleted} item(s) from translated/")
def _on_import_done(self, count: int, errors: list):
self._set_import_buttons_enabled(bool(self.file_list.count()))
if errors:
self._log(f"⚠ {len(errors)} error(s) during import:")
for e in errors[:10]:
self._log(f" {e}")
else:
self._last_import_signature = self._pending_import_signature
self._pending_import_signature = None
self._log(f"✅ Imported {count} file(s) into files/")
# ─────────────────────────────────────────────────────────────────────────
# Step 1 – Vocab
# ─────────────────────────────────────────────────────────────────────────
_BASE_SEPARATOR = _SHARED_BASE_SEPARATOR
def _copy_project_setup_prompt(self):
"""Copy the Project Setup skill, optionally prepending known speakers."""
try:
speakers = self._read_vocab_speakers()
prepend = ""
if speakers:
speaker_lines = "\n".join(f" {orig} ({tl})" for orig, tl in speakers)
prepend = (
"\n"
"These character names were extracted from the game files by the Parse Speakers tool.\n"
"For the glossary block '# Game Characters', prefer entries for these names, "
"then cross-check Actors.json for other major named actors.\n"
"\n"
+ speaker_lines
+ "\n\n"
)
prompt = load_project_setup("rpgmaker", prepend=prepend)
self._copy_to_clipboard(prompt, "Project Setup skill copied.")
except Exception as exc:
self._log(f"❌ Could not load Project Setup skill: {exc}")
def _read_vocab_speakers(self) -> list[tuple[str, str]]:
"""Parse the '# Speakers' section from vocab.txt and return (orig, tl) pairs."""
vocab_path = VOCAB_PATH
if not vocab_path.exists():
return []
try:
content = vocab_path.read_text(encoding="utf-8")
except Exception:
return []
import re as _re
# Find the # Speakers block (ends at next # header or EOF)
m = _re.search(
r"^[\t ]*#\s*Speakers\s*$\r?\n(.*?)(?=^[\t ]*#|\Z)",
content,
_re.MULTILINE | _re.DOTALL,
)
if not m:
return []
results = []
for line in m.group(1).splitlines():
line = line.strip()
if not line:
continue
# Expected format: "日本語 (English)"
pm = _re.match(r"^(.+?)\s+\((.+?)\)\s*$", line)
if pm:
results.append((pm.group(1), pm.group(2)))
return results
# ─────────────────────────────────────────────────────────────────────────
# Step 3 – Speaker detection
# ─────────────────────────────────────────────────────────────────────────
def _copy_speaker_prompt(self):
# Legacy alias — Project Setup covers speakers analysis.
self._copy_project_setup_prompt()
def _copy_wrap_prompt(self):
self._copy_clipboard_skill(
"wrap_config.md",
"Text-wrap analysis prompt copied to clipboard.",
)
def _apply_var_range(self):
"""Write CODE122_VAR_MIN / CODE122_VAR_MAX to the module file."""
try:
var_min = int(self._p2_var_min.text() or 0)
var_max = int(self._p2_var_max.text() or 2000)
except ValueError:
self._log("❌ Var range: invalid numbers")
return
cfg = {"CODE122_VAR_MIN": var_min, "CODE122_VAR_MAX": var_max}
try:
from gui.config_integration import ConfigIntegration
ConfigIntegration().update_rpgmaker_config(cfg)
self._log(f"✅ Code 122 var range set: {var_min}–{var_max}")
# Sync to the Settings tab if open
try:
if self.parent_window and hasattr(self.parent_window, "config_tab"):
ct = self.parent_window.config_tab
if hasattr(ct, "rpgmaker_tab") and ct.rpgmaker_tab:
rt = ct.rpgmaker_tab
if hasattr(rt, "code122_var_min_spin"):
rt.code122_var_min_spin.setText(str(var_min))
if hasattr(rt, "code122_var_max_spin"):
rt.code122_var_max_spin.setText(str(var_max))
except Exception:
pass
except Exception as exc:
self._log(f"❌ Could not apply var range: {exc}")
def _populate_p2_checkboxes(self):
"""Read current module config and pre-tick Phase 2 checkboxes."""
self._p2_loading_config = True
try:
from gui.config_integration import ConfigIntegration
ci = ConfigIntegration()
# Code toggle checkboxes
cur = ci.read_current_config()
if "CODE122_VAR_MIN" in cur:
self._p2_var_min.setText(str(cur["CODE122_VAR_MIN"]))
if "CODE122_VAR_MAX" in cur:
self._p2_var_max.setText(str(cur["CODE122_VAR_MAX"]))
for code_key, cb in getattr(self, "_p2_code_checks", {}).items():
if code_key in cur:
cb.setChecked(cur[code_key])
# Plugin / pattern checkboxes
plugin_cfg = ci.read_plugin_config()
enabled_357 = plugin_cfg.get("ENABLED_PLUGINS_357", set())
enabled_355655 = plugin_cfg.get("ENABLED_PATTERNS_355655", set())
for key, cb in getattr(self, "_p2_plugin_checks", {}).items():
cb.setChecked(key in enabled_357)
for key, cb in getattr(self, "_p2_pattern_checks", {}).items():
cb.setChecked(key in enabled_355655)
except Exception:
pass
finally:
self._p2_loading_config = False
def _schedule_p2_config_apply(self, *_args):
"""Debounce auto-saving Phase 2 settings while the user changes controls."""
if self._p2_loading_config:
return
timer = getattr(self, "_p2_auto_apply_timer", None)
if timer is not None:
timer.start(300)
else:
self._apply_p2_config()
def _apply_p2_config(self):
"""Write Phase 2 code and plugin settings when controls change."""
try:
var_min = int(self._p2_var_min.text() or 0)
var_max = int(self._p2_var_max.text() or 2000)
except ValueError:
self._p2_status_lbl.setText("Invalid Code 122 range")
self._log("❌ Phase 2 config not saved: invalid Code 122 range")
return
try:
from gui.config_integration import ConfigIntegration
ci = ConfigIntegration()
code_cfg = {
code_key: cb.isChecked()
for code_key, cb in getattr(self, "_p2_code_checks", {}).items()
}
code_cfg.update({
"CODE122_VAR_MIN": var_min,
"CODE122_VAR_MAX": var_max,
})
ci.update_rpgmaker_config(code_cfg)
enabled_357 = {
k for k, cb in getattr(self, "_p2_plugin_checks", {}).items()
if cb.isChecked()
}
enabled_355655 = {
k for k, cb in getattr(self, "_p2_pattern_checks", {}).items()
if cb.isChecked()
}
ci.update_plugin_config(enabled_357, enabled_355655)
self._p2_status_lbl.setText(
f"Auto-saved ({len(enabled_357)} handlers, {len(enabled_355655)} patterns)"
)
try:
if self.parent_window and hasattr(self.parent_window, "config_tab"):
ct = self.parent_window.config_tab
if hasattr(ct, "mvmz_tab") and ct.mvmz_tab:
ct.mvmz_tab.refresh_from_module()
except Exception:
pass
except Exception as exc:
self._p2_status_lbl.setText("Auto-save failed")
self._log(f"❌ Could not save Phase 2 settings: {exc}")
def _apply_plugin_settings(self):
"""Write the checked plugin handlers and script patterns back to rpgmakermvmz.py."""
try:
from gui.config_integration import ConfigIntegration
ci = ConfigIntegration()
enabled_357 = {
k for k, cb in getattr(self, "_p2_plugin_checks", {}).items()
if cb.isChecked()
}
enabled_355655 = {
k for k, cb in getattr(self, "_p2_pattern_checks", {}).items()
if cb.isChecked()
}
ci.update_plugin_config(enabled_357, enabled_355655)
self._log(
f"✅ Plugin settings saved — "
f"357: {len(enabled_357)} handler(s), "
f"355/655: {len(enabled_355655)} pattern(s) enabled"
)
if enabled_357:
self._log(" 357 : " + ", ".join(sorted(enabled_357)))
if enabled_355655:
self._log(" 355/655: " + ", ".join(sorted(enabled_355655)))
except Exception as exc:
self._log(f"❌ Could not save plugin settings: {exc}")
def _copy_vocab_to_game(self):
"""Copy vocab.txt into the game root folder so it can be attached to the AI prompt."""
game_root = self.folder_edit.text().strip()
if not game_root:
self._log("⚠ No game folder set. Complete Step 0 first.")
return
src = VOCAB_PATH
if not src.exists():
self._log("⚠ vocab.txt not found — save it in Step 3 first.")
return
import shutil
dst = Path(game_root) / "vocab.txt"
try:
shutil.copy2(src, dst)
self._log(f"✅ vocab.txt copied to {dst}")
except Exception as exc:
self._log(f"❌ Could not copy vocab.txt: {exc}")
def _copy_plugins_js_translate_prompt(self):
is_ace = bool(
getattr(self, "_ace_rvdata_dir", "") or getattr(self, "_ace_json_dir", "")
)
if is_ace:
self._copy_clipboard_skill(
"ace_script_translation.md",
"Ace scripts translation prompt copied to clipboard.",
)
else:
self._copy_clipboard_skill(
"plugin_translation.md",
"plugins.js translation prompt copied to clipboard.",
)
def _copy_plugin_prompt(self):
self._copy_clipboard_skill(
"risky_codes.md",
"Risky codes analysis prompt copied to clipboard.",
)
def _copy_clipboard_skill(self, filename: str, success_message: str):
try:
prompt = load_clipboard_skill(filename)
QApplication.clipboard().setText(prompt)
self._log(success_message)
except Exception as exc:
self._log(f"❌ Could not copy {filename}: {exc}")
def _apply_wrap_config(self):
"""Write width / listWidth / noteWidth back into .env."""
import re as _re
updates = {
"width": str(self.wrap_width_spin.value()),
"listWidth": str(self.wrap_list_spin.value()),
"noteWidth": str(self.wrap_note_spin.value()),
}
env_path = Path(".env")
try:
text = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
for key, val in updates.items():
text, n = _re.subn(
rf"^({_re.escape(key)}\s*=\s*')[^']*(')",
rf"\g<1>{val}\2",
text,
flags=_re.MULTILINE,
)
if n == 0:
text = text.rstrip("\n") + f"\n{key}='{val}'\n"
env_path.write_text(text, encoding="utf-8")
self._log(
"✅ .env updated — "
+ ", ".join(f"{k}={v}" for k, v in updates.items())
)
except Exception as exc:
self._log(f"❌ Could not update .env: {exc}")
def _populate_speaker_flags(self):
"""Read current module config and pre-tick speaker flag checkboxes."""
try:
from gui.config_integration import ConfigIntegration
cur = ConfigIntegration().read_current_config()
flag_map = {
"INLINE401SPEAKERS": self.spk_inline_cb,
"FIRSTLINESPEAKERS": self.spk_firstline_cb,
"FACENAME101": self.spk_face_cb,
}
for key, cb in flag_map.items():
if key in cur:
cb.blockSignals(True)
cb.setChecked(bool(cur[key]))
cb.blockSignals(False)
except Exception:
pass
def _apply_speaker_flags(self):
cfg = {
"INLINE401SPEAKERS": self.spk_inline_cb.isChecked(),
"FIRSTLINESPEAKERS": self.spk_firstline_cb.isChecked(),
"FACENAME101": self.spk_face_cb.isChecked(),
}
try:
from gui.config_integration import ConfigIntegration
ConfigIntegration().update_rpgmaker_config(cfg)
self._log(
"✅ Speaker flags applied: "
+ ", ".join(f"{k}={v}" for k, v in cfg.items())
)
try:
if self.parent_window and hasattr(self.parent_window, "config_tab"):
ct = self.parent_window.config_tab
if hasattr(ct, "rpgmaker_tab") and ct.rpgmaker_tab:
ct.rpgmaker_tab.set_config(cfg)
except Exception:
pass
except Exception as exc:
self._log(f"❌ Could not apply speaker flags: {exc}")
# ─────────────────────────────────────────────────────────────────────────
# Step 4 – Translation phases
# ─────────────────────────────────────────────────────────────────────────
def _run_phase(self, phase):
batch = self._workflow_batch_mode()
# Ask user if they want to sync translated/ → files/ before running this phase
from PyQt5.QtWidgets import QMessageBox
transl_dir = Path("translated")
files_dir = Path("files")
if transl_dir.exists() and any(transl_dir.glob("*.json")):
active = {fp.name for fp in files_dir.glob("*.json")} if files_dir.exists() else set()
overlap = [fp for fp in transl_dir.glob("*.json") if not active or fp.name in active]
if overlap:
reply = QMessageBox.question(
None,
"Sync before phase?",
f"translated/ contains {len(overlap)} file(s) that match files/.\n\n"
"Sync translated/ → files/ before running this phase?\n"
"Yes = overwrite files/ with translated versions\n"
"No = use existing files/ as-is",
QMessageBox.Yes | QMessageBox.No,
)
if reply == QMessageBox.Yes:
self._do_copy_translated_to_files()
if phase == 0:
config = PHASE0_CONFIG
label = "Phase 0 (core DB files)" + (" — batch" if batch else "")
file_preset = "db"
elif phase == 1:
config = PHASE1_CONFIG
label = "Phase 1 (safe codes)" + (" — batch" if batch else "")
file_preset = "events"
elif phase == "1b":
config = PHASE1B_CONFIG
label = "Phase 1b (code 111 cache)" + (" — batch" if batch else "")
file_preset = "events"
else:
# Build Phase 2 config: start from PHASE2_CONFIG defaults, then overlay checkbox states
config = dict(PHASE2_CONFIG)
for code_key, cb in getattr(self, "_p2_code_checks", {}).items():
config[code_key] = cb.isChecked()
label = "Phase 2 (risky codes)" + (" — batch" if batch else "")
file_preset = "events"
# Apply config profile so the Translation tab uses the right codes
try:
from gui.config_integration import ConfigIntegration
ci = ConfigIntegration()
ci.update_rpgmaker_config(config)
# Sync the live Settings tab if it is open
try:
if self.parent_window and hasattr(self.parent_window, "config_tab"):
ct = self.parent_window.config_tab
if hasattr(ct, "rpgmaker_tab"):
ct.rpgmaker_tab.set_config(
ct.rpgmaker_tab.get_config() | config
)
except Exception:
pass
self._log(f"✅ {label} config applied — codes set:")
on = [k for k, v in config.items() if v]
off = [k for k, v in config.items() if not v]
if on:
self._log(" ON : " + " ".join(on))
if off:
self._log(" OFF: " + " ".join(off))
except Exception as exc:
self._log(f"❌ Could not apply phase config: {exc}")
return
# Visual feedback on the phase run button
_btn_map = {0: "_run_p0_btn", 1: "_run_p1_btn", "1b": "_run_p1b_btn", 2: "_run_p2_btn"}
_lbl_map = {0: "_p0_status_lbl", 1: "_p1_status_lbl", "1b": "_p1b_status_lbl", 2: "_p2_status_lbl"}
_phlbl = getattr(self, _lbl_map.get(phase, ""), None)
_phbtn = getattr(self, _btn_map.get(phase, ""), None)
if _phlbl:
_phlbl.setText("✅ Applied")
if _phbtn:
_orig = _phbtn.text()
_phbtn.setText("⚙ Starting…")
_phbtn.setEnabled(False)
QTimer.singleShot(2500, lambda b=_phbtn, t=_orig: (b.setText(t), b.setEnabled(True)))
# Phase-specific guidance
if phase == 0:
self._log("")
self._log("─" * 54)
self._log("👉 Switch to the Translation tab and start the run.")
self._log(" Phase 0 translates core DB file fields (names,")
self._log(" descriptions, notes). Event codes are all OFF.")
self._log("─" * 54)
elif phase == 1:
self._log("")
self._log("─" * 54)
self._log("👉 Switch to the Translation tab and start the run.")
self._log("")
self._log("⚠ While translating, watch the log for speaker lines.")
self._log(" They should look like: [Speaker]: Dialogue text")
self._log(" If names are missing or garbled, stop the run and")
self._log(" revisit Step 2 (speaker flags) before continuing.")
self._log("─" * 54)
elif phase == "1b":
self._log("")
self._log("─" * 54)
self._log("👉 Switch to the Translation tab and start the run.")
self._log(" Phase 1b translates code 111 string comparisons and")
self._log(" writes a var_translation_map cache to log/.")
self._log(" Run Phase 2 afterwards — code 122 strings that match")
self._log(" a cached 111 comparison will reuse the same translation.")
self._log("─" * 54)
else:
self._log("")
self._log("─" * 54)
self._log("👉 Switch to the Translation tab and start the run.")
self._log(" Phase 2 targets script/variable strings — make sure")
self._log(" Phase 1b has been run first to build the 111 cache.")
self._log("─" * 54)
# Navigate to Translation tab, configure it, and auto-start
mode_text = self._workflow_mode_text()
self._navigate_to_translation(file_preset, auto_start=True, mode_text=mode_text)
def _navigate_to_translation(self, file_preset: str, auto_start: bool = False, mode_text: str | None = None):
"""Switch to Translation tab, set engine to MVMZ, and check/uncheck files.
file_preset:
'db' — check only core DB files, uncheck event files
'events' — check CommonEvents, Troops, and Map*.json; uncheck DB files
"""
try:
pw = self.parent_window
if not pw:
return
tt = getattr(pw, "translation_tab", None)
if tt is None:
return
# 1. Set engine to RPG Maker MV/MZ
try:
combo = tt.module_combo
for i in range(combo.count()):
if "RPG Maker MV/MZ" in combo.itemText(i):
combo.setCurrentIndex(i)
break
except Exception:
pass
# 2. Set requested mode after selecting the engine, since the engine
# change refreshes the mode list.
if mode_text:
try:
mode_combo = tt.mode_combo
mode_idx = mode_combo.findText(mode_text)
if mode_idx >= 0:
mode_combo.setCurrentIndex(mode_idx)
except Exception:
pass
# 3. Determine which files belong to each preset
files_dir = getattr(tt, "files_dir", None)
if files_dir is None:
files_dir = __import__("pathlib").Path("files")
def _is_event(name: str) -> bool:
return (
name in _EVENT_FILES_EXACT
or (name.startswith("Map") and name.endswith(".json") and name not in _DB_FILES)
)
def _is_db(name: str) -> bool:
return name in _DB_FILES
if file_preset == "db":
should_check = _is_db
else: # "events"
should_check = _is_event
# 4. Apply check states to the file list
try:
tt.refresh_file_lists()
fl = tt.file_list
from PyQt5.QtCore import Qt as _Qt
for idx in range(fl.count()):
item = fl.item(idx)
name = item.text()
item.setCheckState(
_Qt.Checked if should_check(name) else _Qt.Unchecked
)
except Exception:
pass
# 5. Navigate to Translation tab
if hasattr(pw, "switch_page"):
page = getattr(pw, "PAGE_TRANSLATION", 3)
pw.switch_page(page)
elif hasattr(pw, "content_stack"):
pw.content_stack.setCurrentIndex(3)
if hasattr(pw, "nav_buttons"):
for i, btn in enumerate(pw.nav_buttons):
btn.setChecked(i == 3)
# 6. Auto-start translation so the user doesn't need an extra click
if auto_start:
from PyQt5.QtCore import QTimer as _QTimer
_QTimer.singleShot(100, lambda: (
tt.start_translation(skip_confirm=True)
if tt is not None else None
))
except Exception:
pass
# ─────────────────────────────────────────────────────────────────────────
# Step 5 – Export to game
# ─────────────────────────────────────────────────────────────────────────
def _do_copy_translated_to_files(self):
"""Silently copy translated/ files back into files/ (only matching names). Returns count copied."""
import shutil
files_dir = Path("files")
transl_dir = Path("translated")
if not transl_dir.exists():
self._log("⚠ translated/ folder not found — nothing to sync.")
return 0
active = {fp.name for fp in files_dir.glob("*.json")} if files_dir.exists() else set()
to_copy = [fp for fp in transl_dir.glob("*.json") if not active or fp.name in active]
if not to_copy:
self._log("⚠ No matching files found in translated/ to sync.")
return 0
files_dir.mkdir(exist_ok=True)
copied = 0
for src in to_copy:
dst = files_dir / src.name
shutil.copy2(src, dst)
copied += 1
self._log(f"✅ Synced {copied} file(s) from translated/ → files/")
return copied
def _copy_translated_to_files(self):
"""Prompt user then copy translated/ files back into files/ (only matching names)."""
from PyQt5.QtWidgets import QMessageBox
files_dir = Path("files")
transl_dir = Path("translated")
if not transl_dir.exists():
self._log("⚠ translated/ folder not found — nothing to sync.")
return
active = {fp.name for fp in files_dir.glob("*.json")} if files_dir.exists() else set()
to_copy = [fp for fp in transl_dir.glob("*.json") if not active or fp.name in active]
if not to_copy:
self._log("⚠ No matching files found in translated/ to sync.")
return
reply = QMessageBox.question(
None,
"Sync translated/ → files/",
f"This will overwrite {len(to_copy)} file(s) in files/ with their translated versions.\n\n"
"Choose Yes to sync, or No to keep files/ as-is.",
QMessageBox.Yes | QMessageBox.No,
)
if reply != QMessageBox.Yes:
self._log("⏭ Sync skipped — using existing files/ as-is.")
return
self._do_copy_translated_to_files()
def _export_active_files(self):
"""Export only translated files whose names match what is in files/."""
files_dir = Path("files")
active = sorted(
fp.name for fp in files_dir.glob("*.json") if fp.name != ".gitkeep"
) if files_dir.exists() else []
if not active:
self._log("⚠ No files found in files/ — run Step 0 (Import) first.")
return
game_data = self._resolve_export_path()
if not game_data:
return
translated_dir = Path("translated")
active_set = set(active)
exportable = [
fp for fp in translated_dir.glob("*.json")
if fp.name in active_set and fp.name != ".gitkeep"
] if translated_dir.exists() else []
reply = QMessageBox.question(
self,
"Export Active Files to Game",
f"Export {len(exportable)} file(s) into:\n{game_data}\n\n"
"Make a backup first if needed. Continue?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
w = _ExportWorker(game_data, filter_names=active)
w.log.connect(self._log)
w.done.connect(self._on_export_done)
self._worker = w
w.start()
def _export_to_game(self):
game_data = self._resolve_export_path()
if not game_data:
return
reply = QMessageBox.question(
self,
"Export to Game",
f"This will overwrite ALL translated files in:\n{game_data}\n\n"
"Make a backup first if needed. Continue?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
w = _ExportWorker(game_data)
w.log.connect(self._log)
w.done.connect(self._on_export_done)
self._worker = w
w.start()
def _resolve_export_path(self) -> str | None:
"""Return the game data path, prompting if not yet set."""
game_data = self._data_path
if not game_data:
game_data = QFileDialog.getExistingDirectory(
self, "Select Game Data Folder to Export Into"
)
if not game_data:
return None
self._data_path = game_data
return game_data
# ─────────────────────────────────────────────────────────────────────────
# RPGMaker Ace helpers
# ─────────────────────────────────────────────────────────────────────────
@staticmethod
def _ace_tool_path(name: str) -> Path:
from util.ace.update_tools import ace_tool_path
return ace_tool_path(name)
def _ensure_ace_tools(self) -> bool:
from util.ace.update_tools import ensure_ace_tools
return ensure_ace_tools(log_fn=self._log)
def _show_ace_decrypt_notice(self, game_root: str, rgss_path: str):
"""Show a dialog explaining how to decrypt the encrypted Ace archive."""
rgss_name = Path(rgss_path).name
msg = QMessageBox(self)
msg.setWindowTitle("RPGMaker Ace — Encrypted Game")
msg.setIcon(QMessageBox.Warning)
msg.setTextFormat(Qt.RichText)
msg.setText(
f"This game is encrypted.
"
f"Found: {rgss_name}
"
"To use this game with the translation tool:
"
""
"- Run RPGMakerDecrypter.exe (button below) to extract the game files
"
"- Back up the
.rgss archive to a safe location "
f"- Delete
{rgss_name} from the game folder "
"- Re-scan the folder in this tool (press Enter in the path box)
"
"
"
)
run_btn = msg.addButton("Run RPGMakerDecrypter.exe", QMessageBox.ActionRole)
msg.addButton(QMessageBox.Ok)
msg.exec_()
if msg.clickedButton() == run_btn:
self._run_ace_decrypter(game_root)
def _run_ace_decrypter(self, game_root: str):
if not self._ensure_ace_tools():
return
try:
from util.ace.update_tools import build_decrypter_command
cmd = build_decrypter_command(Path(game_root))
except FileNotFoundError as exc:
self._log(f"❌ {exc}")
return
decrypter = Path(cmd[0])
self._log(f"Running {decrypter.name} in {game_root} …")
w = _SubprocessWorker(cmd, cwd=game_root, label=decrypter.stem)
w.log.connect(self._log)
w.done.connect(lambda ok, msg: self._log(("✅ " if ok else "❌ ") + msg))
self._worker = w
w.start()
def _run_rv2json_create(self):
"""Run RV2JSON.exe -c to convert rvdata2 → JSON files (run from game root)."""
if not self._ensure_ace_tools():
return
rv2json = self._ace_tool_path("RV2JSON.exe")
if not rv2json.is_file():
self._log(f"❌ RV2JSON.exe not found at {rv2json}")
return
game_root = self.folder_edit.text().strip()
# -c takes no path flags — must be run from the game root so it can
# find the Data/ folder automatically and creates JSON/ alongside it.
cmd = [str(rv2json), "-c"]
self._log(f"$ {' '.join(cmd)} (cwd: {game_root})")
w = _SubprocessWorker(cmd, cwd=game_root, label="RV2JSON -c")
w.log.connect(self._log)
w.done.connect(self._on_rv2json_create_done)
self._worker = w
w.start()
def _on_rv2json_create_done(self, ok: bool, msg: str):
self._log(("✅ " if ok else "❌ ") + msg)
if not ok:
self.detected_label.setText("❌ RV2JSON -c failed — check log for details.")
self.detected_label.setStyleSheet(
"color:#f48771;font-size:13px;padding:4px 8px;"
"background-color:#2b1a1a;border:1px solid #5a2a2a;"
"border-radius:4px;margin:4px 0;"
)
return
ace_json = Path(self._ace_json_dir)
if not ace_json.is_dir() or not any(ace_json.glob("*.json")):
self._log(f"⚠ RV2JSON ran but ace_json folder has no JSON files: {ace_json}")
self.detected_label.setText("⚠ ace_json not populated after RV2JSON -c. Check log.")
self.detected_label.setStyleSheet(
"color:#e9a12a;font-size:13px;padding:4px 8px;"
"background-color:#2b2010;border:1px solid #5a4010;"
"border-radius:4px;margin:4px 0;"
)
return
self._log(f"JSON files ready in: {ace_json}")
self.detected_label.setText(
f"Engine: Ace (via RV2JSON) · ace_json: {ace_json}"
)
self.detected_label.setStyleSheet(
"color:#6a9a6a;font-size:13px;padding:4px 8px;"
"background-color:#1f2b1f;border:1px solid #2a4a2a;"
"border-radius:4px;margin:4px 0;"
)
worker = _ScanWorker(self._data_path, "MVMZ")
worker.done.connect(self._on_scan_done)
worker.error.connect(lambda e: self._log(f"❌ Scan error: {e}"))
self._worker = worker
worker.start()
def _run_rv2json_update(self):
"""Run RV2JSON.exe -u to write translated JSON back to rvdata2 files."""
if not self._ensure_ace_tools():
return
rv2json = self._ace_tool_path("RV2JSON.exe")
if not rv2json.is_file():
self._log(f"❌ RV2JSON.exe not found at {rv2json}")
return
game_root = self.folder_edit.text().strip()
if not game_root:
self._log("❌ RV2JSON -u: game root folder not set.")
return
# Run without path flags (same as -c): tool finds Data/ and ace_json/
# relative to the game root automatically.
cmd = [str(rv2json), "-u"]
self._log("RV2JSON: updating rvdata2 files…")
self._log(f"$ {' '.join(cmd)} (cwd: {game_root})")
w = _SubprocessWorker(cmd, cwd=game_root, label="RV2JSON -u")
w.log.connect(self._log)
w.done.connect(lambda ok, msg: self._log(("✅ " if ok else "❌ ") + msg))
self._worker = w
w.start()
def _on_export_done(self, count: int, errors: list):
if errors:
self._log(f"⚠ {len(errors)} error(s) during export:")
for e in errors[:10]:
self._log(f" {e}")
self._log(f"✅ Exported {count} file(s) to game folder.")
# For RPGMaker Ace: convert the exported JSON files back to rvdata2
if self._ace_json_dir and self._ace_rvdata_dir:
self._run_rv2json_update()
# ─────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# Step 1 (Optional) – Pre-process handlers
# ─────────────────────────────────────────────────────────────────────────────
def _populate_preprocess_paths(self):
"""Auto-fill pre-process paths from the detected game root and data path."""
game_root = self.folder_edit.text().strip()
data_path = self._data_path or ""
# Update dazedformat label
try:
self.pp_data_path_label.setText(data_path or "(no data folder detected)")
except Exception:
pass
# Find plugins.js
if game_root:
for candidate in (
Path(game_root) / "js" / "plugins.js",
Path(game_root) / "www" / "js" / "plugins.js",
):
if candidate.is_file():
self._plugins_js_path = str(candidate)
break
else:
self._plugins_js_path = str(Path(game_root) / "js" / "plugins.js")
try:
self.pp_plugins_edit.setText(self._plugins_js_path)
except Exception:
pass
# Gameupdate path — default to the tool's own gameupdate/ folder
tool_gameupdate = Path(__file__).parent.parent / "gameupdate"
self._gameupdate_path = str(tool_gameupdate)
try:
self.pp_gameupdate_edit.setText(self._gameupdate_path)
except Exception:
pass
try:
self.pp_gameupdate_dst_label.setText(game_root or "(no game folder detected)")
except Exception:
pass
def _browse_plugins_js(self):
start = self.pp_plugins_edit.text() or self.folder_edit.text()
path, _ = QFileDialog.getOpenFileName(
self, "Select plugins.js", start, "JavaScript files (*.js);;All files (*)"
)
if path:
self.pp_plugins_edit.setText(path)
def _browse_gameupdate(self):
start = self.pp_gameupdate_edit.text() or self.folder_edit.text()
folder = QFileDialog.getExistingDirectory(self, "Select gameupdate folder", start)
if folder:
self.pp_gameupdate_edit.setText(folder)
def _run_dazedformat(self):
data_path = self._data_path
if not data_path:
self._log("⚠ No data folder detected. Complete Step 0 first.")
return
w = _JsonFormatWorker(data_path)
w.log.connect(self._log)
w.done.connect(lambda ok, msg: self._log(("✅ " if ok else "❌ ") + msg))
self._worker = w
w.start()
def _run_prettier(self):
plugins_js = self.pp_plugins_edit.text().strip()
if not plugins_js:
self._log("⚠ No plugins.js path set.")
return
p = Path(plugins_js)
if not p.is_file():
self._log(f"⚠ plugins.js not found: {p}")
return
w = _JsFormatWorker(str(p))
w.log.connect(self._log)
w.done.connect(lambda ok, msg: self._log(("✅ " if ok else "❌ ") + msg))
self._worker = w
w.start()
def _write_gameupdate_patch_config(self, game_root: str):
"""Write gameupdate/patch-config.txt from Config → Game Update defaults."""
from util.gameupdate_config import write_patch_config
ok, msg = write_patch_config(game_root)
if ok:
self._log(f"📝 Wrote patch-config.txt from Config defaults → {msg}")
else:
self._log(f"ℹ patch-config.txt: {msg}")
def _run_gameupdate(self):
src = self.pp_gameupdate_edit.text().strip()
dst = self.folder_edit.text().strip()
if not src:
self._log("⚠ No gameupdate folder path set.")
return
if not dst:
self._log("⚠ No game root folder set. Complete Step 0 first.")
return
if not Path(src).is_dir():
self._log(f"⚠ gameupdate folder not found: {src}")
return
w = _FileCopyWorker(src, dst, skip_names=_RPG_GAMEUPDATE_COPY_SKIP_NAMES)
w.log.connect(self._log)
w.done.connect(self._on_gameupdate_done)
self._worker = w
w.start()
def _on_gameupdate_done(self, count: int, errors: list):
self._log(f"✅ gameupdate: copied {count} file(s).")
for e in errors:
self._log(f" ⚠ {e}")
dst = self.folder_edit.text().strip()
if dst and not errors:
self._write_gameupdate_patch_config(dst)
def _run_all_preprocess(self):
"""Launch all three pre-process tasks in sequence, chaining via signals."""
data_path = self._data_path
plugins_js = self.pp_plugins_edit.text().strip()
gameupdate_src = self.pp_gameupdate_edit.text().strip()
game_root_dst = self.folder_edit.text().strip()
# Build the queue of (label, worker_or_None) pairs
queue: list[tuple[str, object]] = []
if data_path:
queue.append(("[A] dazedformat", _JsonFormatWorker(data_path)))
else:
self._log(" ⏭ Skipped: A (dazedformat): data folder missing")
if plugins_js and Path(plugins_js).is_file():
queue.append(("[B] format plugins.js", _JsFormatWorker(plugins_js)))
else:
self._log(f" ⏭ Skipped: B (format plugins.js): not found ({plugins_js or 'not set'})")
if gameupdate_src and Path(gameupdate_src).is_dir() and game_root_dst:
queue.append((
"[C] gameupdate copy",
_FileCopyWorker(
gameupdate_src,
game_root_dst,
skip_names=_RPG_GAMEUPDATE_COPY_SKIP_NAMES,
),
))
else:
reason = (f"source not found ({gameupdate_src or 'not set'})"
if not gameupdate_src or not Path(gameupdate_src).is_dir()
else "game root folder missing")
self._log(f" ⏭ Skipped: C (gameupdate): {reason}")
if not queue:
self._log("⚠ Nothing to run — check prerequisites.")
return
# Keep strong references to all workers so they aren't GC'd mid-run
self._preprocess_workers = [w for _, w in queue]
def run_next(remaining):
if not remaining:
self._log("✅ All pre-process tasks finished.")
return
label, worker = remaining[0]
self._log(f"► {label} …")
worker.log.connect(self._log)
def on_done(ok, msg, rest=remaining[1:]):
self._log(("✅ " if ok else "❌ ") + msg)
run_next(rest)
# _FileCopyWorker emits done(int, list) — wrap it
if isinstance(worker, _FileCopyWorker):
def on_copy_done(count, errors, rest=remaining[1:]):
self._log(f"✅ gameupdate: copied {count} file(s).")
for e in errors:
self._log(f" ⚠ {e}")
if not errors and game_root_dst:
self._write_gameupdate_patch_config(game_root_dst)
run_next(rest)
worker.done.connect(on_copy_done)
else:
worker.done.connect(on_done)
worker.start()
run_next(queue)
def _copy_to_clipboard(self, text: str, confirmation: str = "Copied."):
try:
QApplication.clipboard().setText(text)
self._log(f"📋 {confirmation}")
except Exception as exc:
self._log(f"❌ Could not copy to clipboard: {exc}")
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"workflow/{key}", default)
return default
def _save_setting(self, key: str, value):
if self.settings:
self.settings.setValue(f"workflow/{key}", value)