"""
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
from util.skills import 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",
})
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("DazedTranslations", "DazedMTLTool")
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 ────────────────────────────────────────────
# ── Copilot prompt templates ────────────────────────────────────────────
_WRAP_PROMPT = (
"You are an expert RPGMaker MV/MZ configuration analyst.\n"
"\n"
"\n"
"Calculate the correct text-wrap width settings for a Japanese-to-English RPGMaker MV/MZ "
"translation tool. The tool wraps translated English using character-count limits (not pixels). "
"I need three values: width, listWidth, and noteWidth.\n"
"\n"
"\n"
"--- attach System.json and js/plugins.js here before continuing ---\n"
"\n"
"\n"
"1. Read screenWidth and fontSize from System.json.\n"
" Check js/plugins.js for any MessageCore or Window plugin that overrides these values.\n"
"2. For each window type, estimate its pixel width, subtract ~48px padding, then calculate:\n"
" chars = floor(content_px / (font_size × 0.58))\n"
" - width: main dialogue/message box (Show Text) — typically full screen width\n"
" - listWidth: item/skill/help description windows — typically full or half screen width\n"
" - noteWidth: database note fields — typically the narrowest pane (~40–50% screen width)\n"
"3. If font size is above 26px and reducing it would meaningfully increase characters per line, "
"note where to change it (System.json or the relevant plugin parameter).\n"
"\n"
"\n"
"\n"
"Output only the final values — do not show calculations:\n"
"\n"
"```\n"
"width=\n"
"listWidth=\n"
"noteWidth=\n"
"fontSize= # or: no change needed\n"
"```\n"
"\n"
"Followed by one sentence of assumptions if anything was estimated.\n"
"\n"
)
_PLUGINS_JS_TRANSLATE_PROMPT = (
"You are an expert RPGMaker MV/MZ localisation engineer.\n"
"\n"
"\n"
"Translate visible Japanese strings inside js/plugins.js without breaking any game logic "
"or plugin functionality. A vocab.txt glossary is attached — use it as your primary reference. "
"Any name or term that appears in the glossary must be translated exactly as shown there.\n"
"\n"
"\n"
"--- attach js/plugins.js and vocab.txt here before continuing ---\n"
"\n"
"\n"
"\n"
"Only translate string values directly shown to the player at runtime:\n"
"- Menu/button labels in-game UI (e.g. 決定, キャンセル)\n"
"- Scene/window title text (e.g. アイテム, スキル, 装備)\n"
"- In-game popup, tooltip, or notification messages\n"
"- Default NPC names or pronouns used in UI display\n"
"- Help or description text shown in help windows\n"
"- Battle log messages or status effect messages\n"
"\n"
"\n"
"\n"
"Translating the following WILL BREAK THE GAME:\n"
"\n"
"1. Plugin parameter keys (object property names).\n"
" { \"CommandName\": \"セーブ\" } → translate セーブ but NOT CommandName\n"
"\n"
"2. Strings used as internal identifiers or lookup keys:\n"
" - Switch/variable names matching System.json entries\n"
" - Actor, skill, item, weapon, armour names used as keys inside other plugin parameters\n"
" - Strings passed as plugin command arguments that the engine looks up\n"
"\n"
"3. File paths, filenames, URLs, colour codes, font names, icon indices.\n"
" (e.g. img/faces/Actor1 or #ffffff)\n"
"\n"
"4. Plugin names and script identifiers:\n"
" - The \"name\" property at the top of each plugin block (e.g. { \"name\": \"YEP_CoreEngine\" })\n"
" - Function identifiers, class names, JS event names\n"
"\n"
"5. Any string in the game data files (Actors.json, Skills.json, Items.json, etc.) that is "
"also used as a lookup key in the plugin — changing it here would desync it from the data file.\n"
"\n"
"6. Boolean strings, numeric strings, regex patterns.\n"
"\n"
"\n"
"\n"
"Before translating any string, confirm all three are true:\n"
"- It is displayed directly to the player as visible text\n"
"- It is purely a UI label, not used as a key anywhere else\n"
"- Nothing in the codebase compares this exact string to another value\n"
"When in doubt, skip it — untranslated Japanese is better than a broken game.\n"
"\n"
"\n"
"\n"
"Provide the translated file as a complete replacement of js/plugins.js, inside a single "
"fenced code block (```js ... ```) so it can be copied in one click. Only change the "
"string values identified above. Preserve all original formatting, indentation, comments, and structure.\n"
"\n"
"After the code block, output a summary:\n"
"\n"
"### Translations Made\n"
" Plugin: \n"
" Parameter: \n"
" Before: \n"
" After: \n"
"\n"
"### Skipped (Ambiguous or Internal)\n"
"List any Japanese strings you detected but did not translate, with a one-line reason.\n"
"\n"
)
_ACE_SCRIPTS_TRANSLATE_PROMPT = (
"You are an expert RPGMaker VX Ace (Ruby) localisation engineer.\n"
"\n"
"\n"
"Translate visible Japanese strings inside the game's Ruby scripts (ace_json/scripts/*.rb) "
"without breaking any game logic or script functionality. A vocab.txt glossary is attached - "
"use it as your primary reference. Any name or term in the glossary must be translated exactly as shown.\n"
"\n"
"\n"
"--- attach the .rb script files and vocab.txt here before continuing ---\n"
"\n"
"\n"
"========================================\n"
"## WHAT TO TRANSLATE\n"
"========================================\n"
"\n"
"Only translate string literals that are directly shown to the player at runtime.\n"
"These typically appear as:\n"
" - Strings passed to msgbox, msgbox_p, print, p\n"
" - Labels and text in Window or Scene classes rendered to screen\n"
" - draw_text / draw_item calls with a Japanese string literal\n"
" - Default UI label text (menu names, button labels, status window text)\n"
" - Battle log messages, notifications, popup strings\n"
" - Help or description text shown in help windows\n"
"\n"
"========================================\n"
"## WHAT MUST NOT BE TRANSLATED\n"
"========================================\n"
"\n"
"CRITICAL - translating the following will break the game:\n"
"\n"
" 1. Strings used as hash keys, method names, or symbol equivalents.\n"
" Example: vocab[\"HP\"] = \"体力\" -> translate \"体力\" but NOT the key \"HP\"\n"
"\n"
" 2. Strings used as internal identifiers compared with == or used in case/when:\n"
" - Actor/class/skill/item names used as lookup strings\n"
" - Script-internal state names or flag strings\n"
" Example: if type == \"スキル\" -> do NOT translate \"スキル\"\n"
"\n"
" 3. File paths, filenames, font names, colour strings, URLs.\n"
"\n"
" 4. Regular expressions, format strings used with sprintf or % operator\n"
" where the placeholders must stay in the same position.\n"
" (You may translate the human-readable parts but keep %s / %d / %1 etc intact.)\n"
"\n"
" 5. Script class names, module names, method names, constants.\n"
"\n"
" 6. Any string that is read back elsewhere in the scripts with an exact match.\n"
"\n"
"\n"
"Before translating any string, confirm all three are true:\n"
"- It is displayed directly to the player as visible text\n"
"- It is purely a display string, not compared or looked up anywhere\n"
"- Changing it would break no conditional logic or data lookup\n"
"When in doubt, skip it - untranslated Japanese is better than a broken game.\n"
"\n"
"\n"
"\n"
"For each .rb file that needed changes, provide the full translated file content inside its "
"own fenced code block (```ruby ... ```), preceded by the filename, so each file can be "
"copied in one click. Only change string values identified as safe. "
"Preserve all Ruby syntax, indentation, comments, and structure exactly.\n"
"\n"
"After all files, output a summary:\n"
"\n"
"### Translations Made\n"
" File: