diff --git a/gameupdate/GameUpdate.bat b/gameupdate/GameUpdate.bat
new file mode 100644
index 0000000..ab6b767
--- /dev/null
+++ b/gameupdate/GameUpdate.bat
@@ -0,0 +1,14 @@
+@echo off
+setlocal
+
+REM Copy GAMEUPDATE.bat to a new file
+copy "gameupdate\patch.bat" "gameupdate\patch2.bat"
+
+REM Run the new file
+call "gameupdate\patch2.bat"
+
+REM Delete the new file
+del "gameupdate\patch2.bat"
+
+endlocal
+@echo on
\ No newline at end of file
diff --git a/gameupdate/GameUpdate_linux.sh b/gameupdate/GameUpdate_linux.sh
new file mode 100644
index 0000000..732096a
--- /dev/null
+++ b/gameupdate/GameUpdate_linux.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+# Enable error handling
+set -e
+
+# Copy patch.sh to a new file in gameupdate
+cp "gameupdate/patch.sh" "gameupdate/patch2.sh"
+
+# Run the new file
+bash "gameupdate/patch2.sh"
+
+# Delete the new file
+rm "gameupdate/patch2.sh"
diff --git a/gameupdate/README.md b/gameupdate/README.md
new file mode 100644
index 0000000..a924ae1
--- /dev/null
+++ b/gameupdate/README.md
@@ -0,0 +1,102 @@
+# Apply Patch
+1. Click Code
+2. Click Download ZIP
+3. Extract to game folder and Replace All.
+
+## Future Patching
+1. Run GAMEUPDATE.bat to auto patch.
+
+# Troubleshooting
+**GAMEUPDATE.bat doesn't update and closes immediately**
+1. Make sure your path doesn't contain any Japanese characters or lots of whitespace.
+2. Make sure you actually have permissions in the folder
+
+For WOLF RPG games, if you downloaded the game off of DLSite, you will need to do some extra steps to patch it. This is because there is a "master" file called Data.wolf that will take priority over the english patch files. You will need this file to be a folder before patching will work.
+
+# Wolf Games
+1) Download the latest UberWolf.exe release from the following link:
+https://github.com/Sinflower/UberWolf/releases
+2) Drag Data.wolf onto UberWolf.exe. This will create a new folder called data.wolf~
+3) Rename the new data.wolf~ folder to Data
+4) Delete the Data.wolf file
+5) Delete previous_patch_sha.txt (this will exist if you ran GameUpdate.bat previously)
+6) Run GameUpdate.bat
+
+# Edit/Contribute
+TLDR 3 steps.
+
+ Fork the repository.
+ Make the changes.
+ Submit a merge request.
+
+If everything looks good and doesn't break things I'll merge it in.
+
+Longer Version:
+
+# Required Software:
+* [VSCode](https://code.visualstudio.com/) Make sure you check all the boxes for context menus.
+* [Git](https://git-scm.com/downloads) (Use the default for everything. Just keep clicking Next)
+
+# Guide to contributing
+
+### 1. Fork the Repository
+- Go to the repository you want to fork.
+- Click the "Fork" button.
+
+### 2. Clone Your Fork
+- Clone your forked repository to your local machine.
+ ```sh
+ git clone https://gitgud.io/YOUR_USERNAME/REPO_NAME.git
+ ```
+
+### 3. Make Your Changes (In VSCode)
+- Edit the files locally on your new branch using VSCode.
+- Add and commit your changes.
+ ```sh
+ git add .
+ git commit -m "Description of your changes"
+ ```
+
+### 4. Push Your Changes
+- Push your changes to your fork on GitGud.io.
+ ```sh
+ git push origin your-feature-branch
+ ```
+
+### 5. Create a Merge Request
+- Go to your fork on GitGud.io.
+- Click on "Merge Requests" in the sidebar.
+- Click the "New merge request" button.
+- Select the branch you made changes to and the target project (the original repo).
+- Provide a title and description for your merge request and submit it.
+
+---
+
+## Example
+
+Assuming you want to fork a repository named `example-project`:
+
+### 1. Fork the Repo
+- Navigate to `https://gitgud.io/original_user/example-project` and click "Fork".
+
+### 2. Clone Your Fork
+ ```sh
+ git clone https://gitgud.io/YOUR_USERNAME/example-project.git
+ ```
+
+### 3. Make Changes and Commit
+ ```sh
+ # Make changes to the files
+ git add .
+ git commit -m "Add new feature to example project"
+ ```
+
+### 4. Push Changes
+ ```sh
+ git push origin add-new-feature
+ ```
+
+### 5. Create a Merge Request
+- Go to `https://gitgud.io/YOUR_USERNAME/example-project/merge_requests` and click on "New merge request"
+- Choose the source branch `add-new-feature` and target branch (default: `main` or `master`)
+- Fill in the details and submit the merge request
\ No newline at end of file
diff --git a/gameupdate/gameupdate/patch.bat b/gameupdate/gameupdate/patch.bat
new file mode 100644
index 0000000..22b661f
--- /dev/null
+++ b/gameupdate/gameupdate/patch.bat
@@ -0,0 +1,265 @@
+@echo off
+setlocal EnableExtensions EnableDelayedExpansion
+
+REM Check if being run from gameupdate folder (incorrect usage)
+for %%I in ("%CD%") do set "CURRENT_FOLDER=%%~nxI"
+if /I "%CURRENT_FOLDER%"=="gameupdate" (
+ echo.
+ echo ========================================
+ echo ERROR: Do not run patch.bat directly!
+ echo ========================================
+ echo.
+ echo You are running this from the gameupdate folder.
+ echo This will not work correctly!
+ echo.
+ echo Please go back to the game's root folder and
+ echo run GameUpdate.bat instead.
+ echo ========================================
+ echo.
+ pause
+ exit /b 1
+)
+
+REM Determine important paths
+set "SCRIPT_DIR=%~dp0"
+set "ROOT_DIR=%CD%"
+set "CONFIG_FILE=%SCRIPT_DIR%patch-config.txt"
+
+echo Using root directory: "%ROOT_DIR%"
+echo Using config: "%CONFIG_FILE%"
+
+echo Checking for pwsh...
+set _my_shell=pwsh
+
+REM Check if pwsh.exe exists
+where /q !_my_shell!
+if !errorlevel! neq 0 (
+ echo pwsh not found.
+ echo.
+ echo PowerShell 7 ^(pwsh^) is faster and recommended.
+ set /p "INSTALL_PWSH=Would you like to install it now via winget? (Y/N): "
+ if /I "!INSTALL_PWSH!"=="Y" (
+ echo Installing PowerShell 7 via winget...
+ winget install --id Microsoft.PowerShell --source winget --accept-package-agreements --accept-source-agreements
+ if !errorlevel! neq 0 (
+ echo Install failed or winget not available. Falling back to powershell...
+ set _my_shell=powershell
+ ) else (
+ echo PowerShell 7 installed successfully.
+ echo Please re-run GameUpdate.bat to use pwsh.
+ pause
+ exit /b 0
+ )
+ ) else (
+ echo Skipping install. Falling back to powershell...
+ set _my_shell=powershell
+ )
+
+ REM Check if powershell.exe exists
+ echo Checking for powershell...
+ where /q !_my_shell!
+ if !errorlevel! neq 0 (
+ echo.Error: Powershell not found!
+ pause
+ exit /B 1
+ ) else (
+ echo powershell found.
+ )
+) else (
+ echo pwsh found.
+)
+
+echo Using !_my_shell! for script execution.
+
+REM Check if patch-config.txt exists in gameupdate folder
+if not exist "%CONFIG_FILE%" (
+ echo "Config file (gameupdate\patch-config.txt) not found! Assuming no patching needed."
+ pause
+ exit /b
+)
+
+REM Read configuration from file
+for /f "usebackq tokens=1,2 delims==" %%a in ("%CONFIG_FILE%") do (
+ if "%%a"=="username" set "username=%%b"
+ if "%%a"=="repo" set "repo=%%b"
+ if "%%a"=="branch" set "branch=%%b"
+)
+
+REM --------------------------------------------------------
+REM PRE-SETUP: Ensure SRPG data and patch structure exists
+REM Run Steps 1 and 2 BEFORE pulling repo patch to avoid overwriting updates
+REM 1) Unpack once if data folder doesn't exist (and data.dts does)
+REM 2) Create Patch once if patch folder doesn't exist
+REM --------------------------------------------------------
+set "UNPACKER=%ROOT_DIR%\SRPG_Unpacker.exe"
+if exist "%ROOT_DIR%\data.dts" (
+ if exist "%UNPACKER%" (
+ echo [Pre-Setup] Running SRPG_Unpacker preparation steps...
+
+ REM Step 1: Unpack (once)
+ if not exist "%ROOT_DIR%\data\" (
+ set "SHOULD_UNPACK=1"
+ ) else if not exist "%ROOT_DIR%\data\project.dat" (
+ set "SHOULD_UNPACK=1"
+ ) else (
+ set "SHOULD_UNPACK=0"
+ )
+
+ if "!SHOULD_UNPACK!"=="1" (
+ if exist "%ROOT_DIR%\data.dts" (
+ echo [Pre-Setup] Step 1: Unpacking data.dts to data
+ pushd "%ROOT_DIR%"
+ "%UNPACKER%" -o "data" "data.dts"
+ if !errorlevel! neq 0 (
+ echo [Pre-Setup] ERROR: Unpack failed. Continuing.
+ )
+ popd
+ ) else (
+ echo [Pre-Setup] Step 1: Skipping unpack - no data folder and no data.dts found.
+ )
+ ) else (
+ echo [Pre-Setup] Step 1: data folder exists; skipping unpack.
+ )
+
+ REM Step 2: Create Patch (once)
+ if not exist "%ROOT_DIR%\patch\" (
+ if exist "%ROOT_DIR%\data\project.dat" (
+ echo [Pre-Setup] Step 2: Creating patch from data\project.dat
+ pushd "%ROOT_DIR%"
+ "%UNPACKER%" ".\data\project.dat" -c
+ if !errorlevel! neq 0 (
+ echo [Pre-Setup] ERROR: Create Patch failed. Continuing.
+ )
+ popd
+ ) else (
+ echo [Pre-Setup] Step 2: Skipping create patch - data\project.dat not found.
+ )
+ ) else (
+ echo [Pre-Setup] Step 2: patch folder exists; skipping create.
+ )
+ ) else (
+ echo [Pre-Setup] SRPG_Unpacker.exe not found in root; skipping pre-setup steps.
+ )
+) else (
+ echo [Pre-Setup] data.dts not found; skipping pre-setup SRPG steps.
+)
+
+REM Get the latest hash
+echo "Getting latest commit SHA hash"
+!_my_shell! -Command "(Invoke-RestMethod -Uri 'https://gitgud.io/api/v4/projects/%username%%%2F%repo%/repository/branches/%branch%').commit.id" > "%ROOT_DIR%\latest_patch_sha.txt"
+
+REM Read the latest SHA from the file
+set /p latest_patch_sha=<"%ROOT_DIR%\latest_patch_sha.txt"
+
+REM Check if previous_patch_sha.txt exists in gameupdate
+if not exist "%SCRIPT_DIR%previous_patch_sha.txt" (
+ echo "Previous SHA hash not found!"
+ echo "Assuming first time patching..."
+ goto download_extract
+)
+
+REM Read the stored SHA from previous check
+set /p previous_patch_sha=<"%SCRIPT_DIR%previous_patch_sha.txt"
+
+REM Trim whitespace from SHA strings
+set "previous_patch_sha=%previous_patch_sha: =%"
+set "latest_patch_sha=%latest_patch_sha: =%"
+
+REM Compare trimmed SHAs
+if "%latest_patch_sha%" neq "%previous_patch_sha%" (
+ echo "Update found! Patching..."
+ goto download_extract
+) else (
+ echo "Patch is up to date."
+)
+
+REM Delete latest_patch_sha.txt
+del "%ROOT_DIR%\latest_patch_sha.txt"
+
+endlocal
+pause
+exit /b
+
+:download_extract
+
+REM Escape single quotes in paths
+set "escaped_root=%ROOT_DIR:'=''%"
+
+REM Download zip file to root
+echo "Downloading latest patch..."
+!_my_shell! -Command "Set-Location -LiteralPath '%escaped_root%'; $ProgressPreference = 'SilentlyContinue'; Invoke-WebRequest -Uri 'https://gitgud.io/%username%/%repo%/-/archive/%branch%/%repo%-%branch%.zip' -OutFile 'repo.zip'"
+if !errorlevel! neq 0 (
+ pause
+ exit /b
+)
+
+REM Extract contents, overwriting conflicts into root
+echo "Extracting..."
+!_my_shell! -Command "Set-Location -LiteralPath '%escaped_root%'; $ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '.\repo.zip' -DestinationPath '.' -Force"
+if !errorlevel! neq 0 (
+ echo Extraction failed!
+ del "%ROOT_DIR%\repo.zip"
+ rmdir /s /q "%ROOT_DIR%\%repo%-%branch%"
+ pause
+ exit /b
+)
+echo "Applying patch..."
+xcopy /s /e /y "%ROOT_DIR%\%repo%-%branch%\*" "%ROOT_DIR%\"
+if !errorlevel! neq 0 (
+ echo Patch application failed!
+ del "%ROOT_DIR%\repo.zip"
+ rmdir /s /q "%ROOT_DIR%\%repo%-%branch%"
+ pause
+ exit /b
+)
+REM --------------------------------------------------------
+REM POST-APPLY: Run Steps 3 and 4 after patch files are merged
+REM 3) Apply Patch to data\project.dat
+REM 4) Pack data back into data.dts
+REM --------------------------------------------------------
+set "UNPACKER=%ROOT_DIR%\SRPG_Unpacker.exe"
+if exist "%ROOT_DIR%\data.dts" (
+ if exist "%UNPACKER%" (
+ echo Running SRPG_Unpacker apply/pack steps...
+
+ REM Step 3: Apply Patch
+ if exist "%ROOT_DIR%\data\project.dat" (
+ echo Step 3: Applying patch to data\project.dat
+ pushd "%ROOT_DIR%"
+ "%UNPACKER%" ".\data\project.dat" -a
+ if !errorlevel! neq 0 (
+ echo ERROR: Apply Patch failed.
+ )
+ popd
+ ) else (
+ echo ERROR: data\project.dat not found; cannot apply patch.
+ )
+
+ REM Step 4: Pack
+ if exist "%ROOT_DIR%\data\" (
+ echo Step 4: Packing data to data.dts
+ pushd "%ROOT_DIR%"
+ "%UNPACKER%" -o "data.dts" "data"
+ if !errorlevel! neq 0 (
+ echo WARNING: Pack failed.
+ )
+ popd
+ ) else (
+ echo Step 4: Skipping pack - data folder not found.
+ )
+ ) else (
+ echo SRPG_Unpacker.exe not found in root; skipping SRPG patch steps.
+ )
+) else (
+ echo data.dts not found; skipping SRPG patch steps.
+)
+REM Clean up
+echo "Cleaning up..."
+del "%ROOT_DIR%\repo.zip"
+rmdir /s /q "%ROOT_DIR%\%repo%-%branch%"
+del "%ROOT_DIR%\latest_patch_sha.txt"
+REM Store latest SHA for next check in gameupdate
+echo %latest_patch_sha% > "%SCRIPT_DIR%previous_patch_sha.txt"
+endlocal
+pause
+exit /b
diff --git a/gameupdate/gameupdate/patch.sh b/gameupdate/gameupdate/patch.sh
new file mode 100644
index 0000000..4986803
--- /dev/null
+++ b/gameupdate/gameupdate/patch.sh
@@ -0,0 +1,169 @@
+#!/bin/bash
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(pwd)"
+CONFIG_FILE="$SCRIPT_DIR/patch-config.txt"
+
+check_dependency() {
+ if ! command -v "$1" > /dev/null 2>&1; then
+ echo "Error: '$1' is not installed. Please install it using 'pkg install $1'."
+ exit 1
+ fi
+}
+
+# Check for jq, unzip, and curl
+check_dependency jq
+check_dependency unzip
+check_dependency curl
+
+# Check if CONFIG_FILE exists
+if [ ! -f "$CONFIG_FILE" ]; then
+ echo "Config file '$CONFIG_FILE' not found! Assuming no patching needed."
+ exit 0
+fi
+
+# Convert line endings to Unix format
+sed -i 's/\r$//' "$CONFIG_FILE"
+
+# Debug information
+echo "Root directory: $ROOT_DIR"
+echo "Config file path: $CONFIG_FILE"
+
+# Read configuration from file
+. "$CONFIG_FILE"
+
+# Get the latest hash
+echo "Getting latest commit SHA hash"
+latest_patch_sha=$(curl -s "https://gitgud.io/api/v4/projects/$username%2F$repo/repository/branches/$branch" | jq -r '.commit.id')
+
+# --------------------------------------------------------
+# PRE-SETUP: Ensure SRPG data and patch structure exists
+# Run Steps 1 and 2 BEFORE pulling repo patch to avoid overwriting updates
+# 1) Unpack once if data folder doesn't exist (and data.dts does)
+# 2) Create Patch once if patch folder doesn't exist
+# --------------------------------------------------------
+UNPACKER="$ROOT_DIR/SRPG_Unpacker.exe"
+if [ -f "$ROOT_DIR/data.dts" ]; then
+ if [ -f "$UNPACKER" ]; then
+ echo "[Pre-Setup] Running SRPG_Unpacker preparation steps..."
+
+ # Step 1: Unpack (once)
+ if [ ! -d "$ROOT_DIR/data" ]; then
+ if [ -f "$ROOT_DIR/data.dts" ]; then
+ echo "[Pre-Setup] Step 1: Unpacking data.dts -> data"
+ ( cd "$ROOT_DIR" && "$UNPACKER" -o data data.dts ) || echo "[Pre-Setup] ERROR: Unpack failed."
+ else
+ echo "[Pre-Setup] Step 1: Skipping unpack (no data folder and no data.dts found)."
+ fi
+ else
+ echo "[Pre-Setup] Step 1: data folder exists; skipping unpack."
+ fi
+
+ # Step 2: Create Patch (once)
+ if [ ! -d "$ROOT_DIR/patch" ]; then
+ if [ -f "$ROOT_DIR/data/project.dat" ]; then
+ echo "[Pre-Setup] Step 2: Creating patch from data/project.dat"
+ ( cd "$ROOT_DIR" && "$UNPACKER" ./data/project.dat -c ) || echo "[Pre-Setup] ERROR: Create Patch failed."
+ else
+ echo "[Pre-Setup] Step 2: Skipping create patch (data/project.dat not found)."
+ fi
+ else
+ echo "[Pre-Setup] Step 2: patch folder exists; skipping create."
+ fi
+ else
+ echo "[Pre-Setup] SRPG_Unpacker.exe not found in root; skipping pre-setup steps."
+ fi
+ else
+ echo "[Pre-Setup] data.dts not found; skipping pre-setup SRPG steps."
+ fi
+
+download_extract() {
+ # Download zip file into root
+ echo "Downloading latest patch..."
+ curl -sL "https://gitgud.io/$username/$repo/-/archive/$branch/$repo-$branch.zip" -o "$ROOT_DIR/repo.zip"
+ if [ $? -ne 0 ]; then
+ echo "Download failed!"
+ rm -f "$ROOT_DIR/repo.zip"
+ rm -rf "$ROOT_DIR/$repo-$branch"
+ return 1
+ fi
+
+ # Extract contents, overwriting conflicts into root
+ echo "Extracting..."
+ unzip -qo "$ROOT_DIR/repo.zip" -d "$ROOT_DIR"
+ if [ $? -ne 0 ]; then
+ echo "Extraction failed!"
+ rm -f "$ROOT_DIR/repo.zip"
+ rm -rf "$ROOT_DIR/$repo-$branch"
+ return 1
+ fi
+
+ echo "Applying patch..."
+ cp -r "$ROOT_DIR/$repo-$branch/"* "$ROOT_DIR/"
+ if [ $? -ne 0 ]; then
+ echo "Patch application failed!"
+ rm -f "$ROOT_DIR/repo.zip"
+ rm -rf "$ROOT_DIR/$repo-$branch"
+ return 1
+ fi
+
+ echo "Cleaning up..."
+ rm -f "$ROOT_DIR/repo.zip"
+ rm -rf "$ROOT_DIR/$repo-$branch"
+ rm -f "$ROOT_DIR/latest_patch_sha.txt"
+
+ # Store latest SHA for next check in gameupdate
+ echo "$latest_patch_sha" > "$SCRIPT_DIR/previous_patch_sha.txt"
+
+ # --------------------------------------------------------
+ # POST-APPLY: Run Steps 3 and 4 after patch files are merged
+ # 3) Apply Patch to data/project.dat
+ # 4) Pack data back into data.dts
+ # --------------------------------------------------------
+ UNPACKER="$ROOT_DIR/SRPG_Unpacker.exe"
+ if [ -f "$ROOT_DIR/data.dts" ]; then
+ if [ -f "$UNPACKER" ]; then
+ echo "Running SRPG_Unpacker apply/pack steps..."
+
+ # Step 3: Apply Patch
+ if [ -f "$ROOT_DIR/data/project.dat" ]; then
+ echo "Step 3: Applying patch to data/project.dat"
+ ( cd "$ROOT_DIR" && "$UNPACKER" ./data/project.dat -a ) || echo "ERROR: Apply Patch failed."
+ else
+ echo "ERROR: data/project.dat not found; cannot apply patch."
+ fi
+
+ # Step 4: Pack
+ if [ -d "$ROOT_DIR/data" ]; then
+ echo "Step 4: Packing data -> data.dts"
+ ( cd "$ROOT_DIR" && "$UNPACKER" -o data.dts data ) || echo "WARNING: Pack failed."
+ else
+ echo "Step 4: Skipping pack (data folder not found)."
+ fi
+ else
+ echo "SRPG_Unpacker.exe not found in root; skipping SRPG patch steps."
+ fi
+ else
+ echo "data.dts not found; skipping SRPG patch steps."
+ fi
+}
+
+# Check if previous_patch_sha.txt exists in gameupdate
+if [ ! -f "$SCRIPT_DIR/previous_patch_sha.txt" ]; then
+ echo "Previous SHA hash not found!"
+ echo "Assuming first time patching..."
+ download_extract
+else
+ # Read the stored SHA from previous check
+ previous_patch_sha=$(cat "$SCRIPT_DIR/previous_patch_sha.txt")
+
+ # Compare trimmed SHAs
+ if [ "$latest_patch_sha" != "$previous_patch_sha" ]; then
+ echo "Update found! Patching..."
+ download_extract
+ else
+ echo "Patch is up to date."
+ fi
+fi
diff --git a/gui/__init__.py b/gui/__init__.py
index 2c23ec7..a77871a 100644
--- a/gui/__init__.py
+++ b/gui/__init__.py
@@ -11,11 +11,13 @@ from .config_tab import ConfigTab
from .rpgmaker_tab import RPGMakerTab
from .log_viewer import LogViewer
from .file_manager import FileManager
+from .workflow_tab import WorkflowTab
__all__ = [
"DazedMTLGUI",
- "ConfigTab",
+ "ConfigTab",
"RPGMakerTab",
"LogViewer",
- "FileManager"
+ "FileManager",
+ "WorkflowTab",
]
diff --git a/gui/main.py b/gui/main.py
index d25e231..4fe4acd 100644
--- a/gui/main.py
+++ b/gui/main.py
@@ -19,6 +19,7 @@ from PyQt5.QtGui import QIcon, QFont, QPixmap, QScreen
# Import configuration widgets
from gui.config_tab import ConfigTab
from gui.translation_tab import TranslationTab
+from gui.workflow_tab import WorkflowTab
class DazedMTLGUI(QMainWindow):
"""Main GUI window for the DazedMTLTool."""
@@ -188,10 +189,17 @@ class DazedMTLGUI(QMainWindow):
btn_translation.clicked.connect(lambda: self.switch_page(0))
sidebar_layout.addWidget(btn_translation)
self.nav_buttons.append(btn_translation)
-
- # Configuration button (second)
+
+ # Workflow / Automation button (second)
+ btn_workflow = self.create_nav_button("⚡", "Workflow")
+ btn_workflow.setToolTip("Workflow — automated translation pipeline")
+ btn_workflow.clicked.connect(lambda: self.switch_page(1))
+ sidebar_layout.addWidget(btn_workflow)
+ self.nav_buttons.append(btn_workflow)
+
+ # Configuration button (third)
btn_config = self.create_nav_button("⚙️", "Configuration")
- btn_config.clicked.connect(lambda: self.switch_page(1))
+ btn_config.clicked.connect(lambda: self.switch_page(2))
sidebar_layout.addWidget(btn_config)
self.nav_buttons.append(btn_config)
@@ -252,11 +260,15 @@ class DazedMTLGUI(QMainWindow):
def setup_tabs(self):
"""Set up all the tabs in the interface."""
- # Translation Execution Tab (first)
+ # Translation Execution Tab (index 0)
self.translation_tab = TranslationTab(self)
self.content_stack.addWidget(self.translation_tab)
-
- # Configuration Tab (second)
+
+ # Workflow / Automation Tab (index 1)
+ self.workflow_tab = WorkflowTab(self)
+ self.content_stack.addWidget(self.workflow_tab)
+
+ # Configuration Tab (index 2)
self.config_tab = ConfigTab()
self.config_tab.config_changed.connect(self.on_config_changed)
self.content_stack.addWidget(self.config_tab)
diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py
new file mode 100644
index 0000000..ee5f754
--- /dev/null
+++ b/gui/workflow_tab.py
@@ -0,0 +1,1569 @@
+"""
+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 – Edit vocab.txt (glossary) inline
+ Step 2 – Actor variable substitution (\\n[X] ↔ names)
+ Step 3 – Auto-detect speaker format and apply to module settings
+ Step 4 – Phase 1 (safe dialogue codes) and Phase 2 (risky codes) translation
+ Step 5 – Export translated/ back to the game folder
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import threading
+from pathlib import Path
+
+from PyQt5.QtCore import Qt, QSettings, QThread, QTimer, pyqtSignal
+from PyQt5.QtGui import QFont
+from PyQt5.QtWidgets import (
+ QApplication,
+ QCheckBox,
+ QFileDialog,
+ QFrame,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QListWidget,
+ QListWidgetItem,
+ QMessageBox,
+ QPushButton,
+ QScrollArea,
+ QSizePolicy,
+ QSpinBox,
+ QSplitter,
+ QStyle,
+ QTextEdit,
+ QVBoxLayout,
+ QWidget,
+)
+
+# ---------------------------------------------------------------------------
+# Phase profiles applied to rpgmakermvmz.py before each translation run
+# ---------------------------------------------------------------------------
+
+PHASE1_CONFIG = {
+ # Safe dialogue / choices
+ "CODE101": False,
+ "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,
+}
+
+PHASE2_CONFIG = {
+ # Dialogue OFF (already handled by Phase 1)
+ "CODE101": False,
+ "CODE401": False,
+ "CODE405": False,
+ "CODE102": False,
+ "CODE408": False,
+ # Risky codes ON
+ "CODE122": True,
+ "CODE357": True,
+ "CODE111": True,
+ "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:
+ from util.project_scanner import import_to_files
+ 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 _ActorSubstituteWorker(QThread):
+ done = pyqtSignal(dict)
+ log = pyqtSignal(str)
+
+ def __init__(self, mode: str): # "substitute" or "restore"
+ super().__init__()
+ self.mode = mode
+
+ def run(self):
+ try:
+ from util.actor_substitutor import (
+ load_actor_map,
+ substitute_in_files,
+ restore_in_translated,
+ )
+ actor_map = load_actor_map()
+ if not actor_map:
+ self.done.emit({"error": "No actor map found. Make sure files/Actors.json exists."})
+ return
+ if self.mode == "substitute":
+ self.log.emit("Substituting \\n[X] → actor names in files/ …")
+ stats = substitute_in_files(actor_map=actor_map)
+ else:
+ self.log.emit("Restoring actor names → \\n[X] in translated/ …")
+ stats = restore_in_translated(actor_map=actor_map)
+ self.done.emit(stats)
+ except Exception as exc:
+ self.done.emit({"error": str(exc)})
+
+
+class _SpeakerDetectWorker(QThread):
+ done = pyqtSignal(dict)
+ log = pyqtSignal(str)
+
+ def run(self):
+ try:
+ from util.speaker_detector import detect_speaker_format
+ self.log.emit("Scanning files/ for speaker patterns …")
+ result = detect_speaker_format()
+ self.done.emit(result)
+ except Exception as exc:
+ self.done.emit({"error": str(exc)})
+
+
+class _ExportWorker(QThread):
+ done = pyqtSignal(int, list)
+ log = pyqtSignal(str)
+
+ def __init__(self, game_data_path: str):
+ super().__init__()
+ self.game_data_path = game_data_path
+
+ def run(self):
+ try:
+ from util.project_scanner import export_to_game
+ self.log.emit(f"Exporting translated/ → {self.game_data_path} …")
+ count, errors = export_to_game("translated", self.game_data_path)
+ 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 _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):
+ super().__init__()
+ self.src = src
+ self.dst = dst
+
+ 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
+ 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):
+ import subprocess as _sp
+ import sys
+ try:
+ import jsbeautifier
+ except ImportError:
+ self.log.emit("jsbeautifier not installed — installing via pip…")
+ r = _sp.run(
+ [sys.executable, "-m", "pip", "install", "jsbeautifier"],
+ capture_output=True, text=True,
+ )
+ if r.returncode != 0:
+ self.done.emit(False, f"pip install failed: {r.stderr.strip()}")
+ return
+ self.log.emit("jsbeautifier installed.")
+ try:
+ import jsbeautifier
+ except ImportError:
+ self.done.emit(False, "jsbeautifier could not be imported after install.")
+ return
+ 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:13px; font-weight:bold; color:#007acc; "
+ "padding:8px 0 4px 0; background:transparent;"
+ )
+ return lbl
+
+
+def _make_hr() -> QFrame:
+ hr = QFrame()
+ hr.setFrameShape(QFrame.HLine)
+ hr.setFrameShadow(QFrame.Sunken)
+ hr.setStyleSheet("QFrame{color:#444;margin:8px 0;}")
+ return hr
+
+
+def _make_btn(text: str, color: str = "#007acc") -> QPushButton:
+ btn = QPushButton(text)
+ # Derive a slightly lighter hover colour by blending toward white
+ try:
+ r = int(color[1:3], 16)
+ g = int(color[3:5], 16)
+ b = int(color[5:7], 16)
+ rh = min(255, r + 30)
+ gh = min(255, g + 30)
+ bh = min(255, b + 30)
+ hover_color = f"#{rh:02x}{gh:02x}{bh:02x}"
+ rp = max(0, r - 30)
+ gp = max(0, g - 30)
+ bp = max(0, b - 30)
+ press_color = f"#{rp:02x}{gp:02x}{bp:02x}"
+ except Exception:
+ hover_color = color
+ press_color = color
+ btn.setStyleSheet(
+ f"QPushButton{{background:{color};color:#fff;border:none;"
+ f"padding:6px 14px;border-radius:3px;font-size:11px;"
+ f"icon-size:16px;"
+ f"font-family:'Segoe UI Emoji','Segoe UI','Apple Color Emoji',sans-serif;}}"
+ f"QPushButton:hover{{background:{hover_color};}}"
+ f"QPushButton:pressed{{background:{press_color};padding:7px 13px 5px 15px;}}"
+ f"QPushButton:disabled{{background:#555;color:#999;}}"
+ )
+ return btn
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# 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._detect_result: dict | None = None
+ self._worker = None # active background QThread
+ # Pre-process paths (auto-populated after folder detection)
+ self._plugins_js_path: str = ""
+ self._gameupdate_path: str = ""
+
+ 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(4)
+ splitter.setStyleSheet("QSplitter::handle{background:#333;}")
+
+ # ---- Left: scrollable step panels ----
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setStyleSheet("QScrollArea{border:none;}")
+ container = QWidget()
+ container.setStyleSheet("background:#1e1e1e;")
+ vbox = QVBoxLayout(container)
+ vbox.setContentsMargins(18, 14, 18, 14)
+ vbox.setSpacing(4)
+
+ self._build_step0(vbox)
+ vbox.addWidget(_make_hr())
+ self._build_preprocess(vbox)
+ vbox.addWidget(_make_hr())
+ self._build_step1(vbox)
+ vbox.addWidget(_make_hr())
+ self._build_step2(vbox)
+ vbox.addWidget(_make_hr())
+ self._build_step3(vbox)
+ vbox.addWidget(_make_hr())
+ self._build_step4(vbox)
+ vbox.addWidget(_make_hr())
+ self._build_step6(vbox)
+ vbox.addStretch()
+
+ scroll.setWidget(container)
+ splitter.addWidget(scroll)
+
+ # ---- Right: log area ----
+ log_panel = QWidget()
+ log_panel.setStyleSheet("background:#1e1e1e;")
+ lp_layout = QVBoxLayout(log_panel)
+ lp_layout.setContentsMargins(0, 0, 0, 0)
+ lp_layout.setSpacing(0)
+
+ log_header = QLabel(" Log")
+ log_header.setStyleSheet(
+ "background:#252526;color:#ccc;font-size:11px;"
+ "padding:6px 8px;border-bottom:1px solid #333;"
+ )
+ 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:#1e1e1e;color:#d4d4d4;"
+ "border:none;padding:8px;}"
+ )
+ lp_layout.addWidget(self.log_area)
+
+ clear_btn = QPushButton("Clear Log")
+ clear_btn.setStyleSheet(
+ "QPushButton{background:#252526;color:#888;border:none;"
+ "font-size:10px;padding:4px;}QPushButton:hover{color:#ccc;}"
+ )
+ clear_btn.clicked.connect(self.log_area.clear)
+ lp_layout.addWidget(clear_btn)
+
+ splitter.addWidget(log_panel)
+ splitter.setSizes([640, 380])
+
+ root.addWidget(splitter)
+ self.setLayout(root)
+
+ # Auto-detect on first start if a folder was previously saved
+ if self._setting("last_game_folder", ""):
+ QTimer.singleShot(100, self._detect_folder)
+
+ # ── Step 0: Project Folder ──────────────────────────────────────────────
+
+ def _build_step0(self, layout: QVBoxLayout):
+ layout.addWidget(_make_section_label("Step 0 — Project Folder"))
+
+ # Folder picker row
+ row0 = QHBoxLayout()
+ self.folder_edit = QLineEdit()
+ self.folder_edit.setPlaceholderText("Path to game root folder…")
+ self.folder_edit.setStyleSheet(
+ "QLineEdit{background:#2d2d30;color:#ccc;border:1px solid #555;"
+ "padding:5px;font-size:11px;border-radius:2px;}"
+ )
+ saved = self._setting("last_game_folder", "")
+ if saved:
+ self.folder_edit.setText(saved)
+ row0.addWidget(self.folder_edit, 1)
+
+ browse_btn = _make_btn(" Browse…")
+ browse_btn.setIcon(browse_btn.style().standardIcon(QStyle.SP_DirOpenIcon))
+ browse_btn.clicked.connect(self._browse_folder)
+ row0.addWidget(browse_btn)
+
+ detect_btn = _make_btn("🔍 Detect", "#3a7a3a")
+ detect_btn.setToolTip("Scan the folder to find the data/ directory and list files")
+ detect_btn.clicked.connect(self._detect_folder)
+ row0.addWidget(detect_btn)
+ layout.addLayout(row0)
+
+ # Detected info
+ self.detected_label = QLabel("No folder detected yet.")
+ self.detected_label.setStyleSheet("color:#888;font-size:10px;padding:2px 0;")
+ layout.addWidget(self.detected_label)
+
+ # File list
+ self.file_list = QListWidget()
+ self.file_list.setMaximumHeight(180)
+ self.file_list.setStyleSheet(
+ "QListWidget{background:#252526;color:#ccc;border:1px solid #444;"
+ "font-size:10px;}"
+ "QListWidget::item:selected{background:#37373d;}"
+ "QListWidget::item:hover{background:#2a2d2e;}"
+ )
+ layout.addWidget(self.file_list)
+
+ # Action row
+ row1 = QHBoxLayout()
+ sel_all = _make_btn("Select All", "#555")
+ sel_all.clicked.connect(self._select_all_files)
+ row1.addWidget(sel_all)
+
+ sel_core = _make_btn("Core Only", "#555")
+ sel_core.setToolTip("Select only core database files; deselect map files")
+ sel_core.clicked.connect(self._select_core_only)
+ row1.addWidget(sel_core)
+
+ row1.addStretch()
+
+ self.import_btn = _make_btn("⬇ Import Selected → files/", "#007acc")
+ self.import_btn.setEnabled(False)
+ self.import_btn.clicked.connect(self._import_files)
+ row1.addWidget(self.import_btn)
+ layout.addLayout(row1)
+
+ # ── Step 1: Vocab / Glossary ────────────────────────────────────────────
+
+ # ── Copilot prompt templates ────────────────────────────────────────────
+
+ _PROMPT_GLOSSARY = (
+ "You are helping me build a complete translation glossary for a Japanese RPGMaker game.\n"
+ "\n"
+ "Please scan ALL of these game files (Actors.json, CommonEvents.json, Map*.json, "
+ "Troops.json, and any other available files) and produce TWO sections:\n"
+ "\n"
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
+ "SECTION 1 — NAMED CHARACTERS\n"
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
+ "Identify every NAMED CHARACTER that appears in dialogue or descriptions.\n"
+ "For each character provide:\n"
+ " - Japanese name (katakana/kanji as it appears in-game)\n"
+ " - English transliteration or translation\n"
+ " - Gender (Male / Female / Unknown) — infer from speech patterns or pronouns\n"
+ " - Brief role note (e.g. protagonist, antagonist, NPC, merchant)\n"
+ "\n"
+ "Format — one entry per line:\n"
+ "山田太郎 (Yamada Taro) - Male; protagonist; high-school student\n"
+ "浅井花子 (Asai Hanako) - Female; childhood friend; lives in 栄町\n"
+ "\n"
+ "Rules:\n"
+ " - Named characters only — no generic enemy types or unnamed NPCs.\n"
+ " - If a character has a player-chosen name (e.g. Actors.json id 1), note it explicitly.\n"
+ "\n"
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
+ "SECTION 2 — WORLDBUILDING TERMS\n"
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
+ "Identify lore-specific terms that appear in dialogue, descriptions, or narration "
+ "but do NOT have a dedicated database file — i.e. terms the translation tool will NOT "
+ "auto-populate from Skills.json, Items.json, Armors.json, Weapons.json, etc.\n"
+ "\n"
+ "Target specifically:\n"
+ " - Faction / organisation names (kingdoms, guilds, cults, nations)\n"
+ " - Location names that are mentioned in dialogue but are not map titles\n"
+ " - Unique magic systems, schools of magic, or power classifications\n"
+ " - Lore titles and honorifics unique to this setting\n"
+ " - Recurring in-universe concepts, events, or proper nouns with no English equivalent\n"
+ "\n"
+ "For each term provide:\n"
+ " - Japanese original (as it appears in-game)\n"
+ " - Recommended English translation\n"
+ " - Brief note explaining what it is\n"
+ "\n"
+ "Format — one entry per line:\n"
+ "魔世界 (Demon World) - The demonic realm referenced in NPC dialogue; not a named map\n"
+ "聖剣教団 (Holy Blade Order) - Antagonist faction controlling the eastern territories\n"
+ "\n"
+ "Rules:\n"
+ " - Do NOT list skill names, item names, weapon names, armour names — the tool handles those.\n"
+ " - Skip generic RPG words (ポーション, レベル, ステータス, etc.).\n"
+ " - Do NOT repeat character names here — those belong in Section 1."
+ )
+
+ # ── Step 1 (Optional): Pre-process ────────────────────────────────
+
+ def _build_preprocess(self, layout: QVBoxLayout):
+ header_row = QHBoxLayout()
+ header_row.addWidget(_make_section_label("Step 1 (Optional) — Pre-process"))
+ opt_badge = QLabel("personal / optional")
+ opt_badge.setStyleSheet(
+ "color:#888;font-size:9px;border:1px solid #555;"
+ "padding:1px 6px;border-radius:8px;margin-left:6px;"
+ )
+ header_row.addWidget(opt_badge)
+ header_row.addStretch()
+ layout.addLayout(header_row)
+
+ hint = QLabel(
+ "Three optional preparation tasks to run against the game folder before "
+ "importing files. Paths are auto-filled when a project folder is detected."
+ )
+ hint.setWordWrap(True)
+ hint.setStyleSheet("color:#888;font-size:10px;padding-bottom:6px;")
+ layout.addWidget(hint)
+
+ tasks_box = QGroupBox()
+ tasks_box.setStyleSheet(
+ "QGroupBox{border:1px solid #3a3a3a;border-radius:3px;margin-top:4px;"
+ "padding:6px;background:#1a1a1a;}"
+ )
+ tb = QVBoxLayout(tasks_box)
+ tb.setSpacing(10)
+
+ # ---- Task A: dazedformat -----------------------------------------
+ ta = QGroupBox("A — Format JSON files with dazedformat")
+ ta.setStyleSheet(self._task_box_style())
+ ta_inner = QVBoxLayout(ta)
+ ta_inner.setSpacing(4)
+ ta_desc = QLabel(
+ "Runs dazedformat . in the game\'s data folder to "
+ "normalise all JSON files before importing."
+ )
+ ta_desc.setTextFormat(Qt.RichText)
+ ta_desc.setWordWrap(True)
+ ta_desc.setStyleSheet("color:#aaa;font-size:10px;")
+ 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:#888;font-size:10px;")
+ 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.clicked.connect(self._run_dazedformat)
+ ta_btn_row.addWidget(run_dazed)
+ ta_btn_row.addStretch()
+ ta_inner.addLayout(ta_btn_row)
+ tb.addWidget(ta)
+
+ # ---- Task B: prettier on plugins.js -----------------------------
+ tb_box = QGroupBox("B — Format plugins.js with Prettier")
+ tb_box.setStyleSheet(self._task_box_style())
+ tb_inner = QVBoxLayout(tb_box)
+ 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:#aaa;font-size:10px;")
+ 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…")
+ self.pp_plugins_edit.setStyleSheet(
+ "QLineEdit{background:#2d2d30;color:#ccc;border:1px solid #555;"
+ "padding:3px;font-size:10px;border-radius:2px;}"
+ )
+ 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.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)
+
+ # ---- Task C: copy gameupdate/ -----------------------------------
+ tc = QGroupBox("C — Apply gameupdate/ patch files")
+ tc.setStyleSheet(self._task_box_style())
+ tc_inner = QVBoxLayout(tc)
+ tc_inner.setSpacing(4)
+ tc_desc = QLabel(
+ "Copies everything from the gameupdate/ folder in the game root "
+ "into the game\'s data folder, overwriting existing files."
+ )
+ tc_desc.setTextFormat(Qt.RichText)
+ tc_desc.setWordWrap(True)
+ tc_desc.setStyleSheet("color:#aaa;font-size:10px;")
+ 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…")
+ self.pp_gameupdate_edit.setStyleSheet(
+ "QLineEdit{background:#2d2d30;color:#ccc;border:1px solid #555;"
+ "padding:3px;font-size:10px;border-radius:2px;}"
+ )
+ 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("(data folder auto-filled from project)")
+ self.pp_gameupdate_dst_label.setStyleSheet("color:#888;font-size:10px;")
+ tc_dst_row.addWidget(self.pp_gameupdate_dst_label, 1)
+ tc_inner.addLayout(tc_dst_row)
+
+ tc_btn_row = QHBoxLayout()
+ run_gu = _make_btn("▶ Copy gameupdate/", "#555")
+ run_gu.clicked.connect(self._run_gameupdate)
+ tc_btn_row.addWidget(run_gu)
+ tc_btn_row.addStretch()
+ tc_inner.addLayout(tc_btn_row)
+ tb.addWidget(tc)
+
+ layout.addWidget(tasks_box)
+
+ # ---- Run All button ------------------------------------------
+ run_all_row = QHBoxLayout()
+ run_all_btn = _make_btn("▶▶ Run All 3 Tasks", "#007acc")
+ run_all_btn.setToolTip("Run dazedformat, prettier, and gameupdate copy in sequence")
+ run_all_btn.clicked.connect(self._run_all_preprocess)
+ run_all_row.addStretch()
+ run_all_row.addWidget(run_all_btn)
+ layout.addLayout(run_all_row)
+
+ @staticmethod
+ def _task_box_style() -> str:
+ return (
+ "QGroupBox{color:#bbb;border:1px solid #444;border-radius:2px;"
+ "margin-top:6px;font-size:10px;padding:4px;}"
+ "QGroupBox::title{padding:0 4px;}"
+ )
+
+ def _build_step1(self, layout: QVBoxLayout):
+ layout.addWidget(_make_section_label("Step 2 — Glossary (vocab.txt)"))
+ hint = QLabel(
+ "Edit your glossary below. Character names, genders, and worldbuilding terms "
+ "here are injected into every AI prompt for consistent terminology."
+ )
+ hint.setWordWrap(True)
+ hint.setStyleSheet("color:#888;font-size:10px;padding-bottom:4px;")
+ layout.addWidget(hint)
+
+ # ---- Copilot / Cursor prompt helpers --------------------------------
+ prompt_box = QGroupBox("AI Prompt Helpers (Copilot / Cursor)")
+ prompt_box.setStyleSheet(
+ "QGroupBox{color:#ccc;border:1px solid #444;border-radius:3px;"
+ "margin-top:8px;font-size:11px;}"
+ "QGroupBox::title{padding:0 6px;}"
+ )
+ pb_inner = QVBoxLayout(prompt_box)
+ pb_inner.setSpacing(6)
+
+ prompt_hint = QLabel(
+ "Copy a prompt below, paste it into GitHub Copilot Chat or Cursor with your "
+ "game files open, then paste the AI’s output into vocab.txt."
+ )
+ prompt_hint.setWordWrap(True)
+ prompt_hint.setStyleSheet("color:#888;font-size:10px;")
+ pb_inner.addWidget(prompt_hint)
+
+ # Combined glossary prompt
+ glossary_row = QHBoxLayout()
+ glossary_label = QLabel(
+ "👥🌍 Full Glossary — named characters (with gender & role) "
+ "and unique worldbuilding terms in one pass."
+ )
+ glossary_label.setTextFormat(Qt.RichText)
+ glossary_label.setWordWrap(True)
+ glossary_label.setStyleSheet("color:#ccc;font-size:10px;")
+ glossary_row.addWidget(glossary_label, 1)
+ copy_glossary_btn = _make_btn("📋 Copy Prompt", "#555")
+ copy_glossary_btn.setToolTip("Copy the full glossary discovery prompt to clipboard")
+ copy_glossary_btn.clicked.connect(lambda: self._copy_to_clipboard(self._PROMPT_GLOSSARY, "Glossary prompt copied."))
+ glossary_row.addWidget(copy_glossary_btn)
+ pb_inner.addLayout(glossary_row)
+
+ layout.addWidget(prompt_box)
+
+ # ---- vocab.txt editor -----------------------------------------------
+ layout.addWidget(_make_section_label("vocab.txt editor"))
+ format_hint = QLabel(
+ "Expected line format: Japanese (English) - Gender; role; notes\n"
+ "Example: 山田太郎 (Yamada Taro) - Male; protagonist; village blacksmith"
+ )
+ format_hint.setFont(QFont("Consolas", 9))
+ format_hint.setStyleSheet(
+ "color:#569cd6;background:#252526;border:1px solid #3a3a3a;"
+ "padding:5px 8px;border-radius:2px;font-size:10px;"
+ )
+ layout.addWidget(format_hint)
+
+ self.vocab_editor = QTextEdit()
+ self.vocab_editor.setMinimumHeight(160)
+ self.vocab_editor.setFont(QFont("Consolas", 9))
+ self.vocab_editor.setStyleSheet(
+ "QTextEdit{background:#252526;color:#d4d4d4;border:1px solid #444;"
+ "padding:6px;}"
+ )
+ self._reload_vocab()
+ layout.addWidget(self.vocab_editor)
+
+ row = QHBoxLayout()
+ save_vocab = _make_btn("💾 Save vocab.txt", "#3a7a3a")
+ save_vocab.clicked.connect(self._save_vocab)
+ row.addWidget(save_vocab)
+
+ reload_vocab = _make_btn("↺ Reload", "#555")
+ reload_vocab.clicked.connect(self._reload_vocab)
+ row.addWidget(reload_vocab)
+ row.addStretch()
+ layout.addLayout(row)
+
+ # ── Step 2: Actor Variables ─────────────────────────────────────────────
+
+ def _build_step2(self, layout: QVBoxLayout):
+ layout.addWidget(_make_section_label("Step 3 — Actor Variable Substitution"))
+ hint = QLabel(
+ "Replaces \\n[X] RPGMaker name variables with actual actor names "
+ "so the AI has proper context. After translation the variables are "
+ "restored — rename-able protagonists continue to work at runtime."
+ )
+ hint.setWordWrap(True)
+ hint.setStyleSheet("color:#888;font-size:10px;padding-bottom:4px;")
+ layout.addWidget(hint)
+
+ self.actor_status = QLabel("Click Scan to preview substitutions.")
+ self.actor_status.setWordWrap(True)
+ self.actor_status.setStyleSheet("color:#aaa;font-size:10px;padding:4px 0;")
+ layout.addWidget(self.actor_status)
+
+ row = QHBoxLayout()
+ scan_actors = _make_btn("🔍 Scan \\n[X] usage", "#555")
+ scan_actors.setToolTip("Scan files/ and show which actor IDs are referenced")
+ scan_actors.clicked.connect(self._scan_actor_vars)
+ row.addWidget(scan_actors)
+ row.addStretch()
+ layout.addLayout(row)
+
+ row2 = QHBoxLayout()
+ self.sub_btn = _make_btn("▶ Substitute \\n[X] → Names (pre-translation)", "#007acc")
+ self.sub_btn.setToolTip("Modifies files/ in-place. Run before translating.")
+ self.sub_btn.clicked.connect(self._substitute_actors)
+ row2.addWidget(self.sub_btn)
+
+ self.restore_btn = _make_btn("↩ Restore Names → \\n[X] (post-translation)", "#7a4a7a")
+ self.restore_btn.setToolTip("Modifies translated/ in-place. Run after translating.")
+ self.restore_btn.clicked.connect(self._restore_actors)
+ row2.addWidget(self.restore_btn)
+ layout.addLayout(row2)
+
+ # ── Step 3: Speaker Detection ───────────────────────────────────────────
+
+ def _build_step3(self, layout: QVBoxLayout):
+ layout.addWidget(_make_section_label("Step 4 — Speaker Format Detection"))
+ hint = QLabel(
+ "Scans map files to determine how speaker names are embedded in dialogue "
+ "and automatically sets the correct INLINE401SPEAKERS / FIRSTLINESPEAKERS "
+ "/ FACENAME101 flags."
+ )
+ hint.setWordWrap(True)
+ hint.setStyleSheet("color:#888;font-size:10px;padding-bottom:4px;")
+ layout.addWidget(hint)
+
+ self.speaker_result = QLabel("Run the scan to auto-detect speaker format.")
+ self.speaker_result.setWordWrap(True)
+ self.speaker_result.setStyleSheet(
+ "color:#aaa;font-size:10px;padding:4px 0;"
+ "background:#252526;border:1px solid #444;padding:6px;border-radius:2px;"
+ )
+ layout.addWidget(self.speaker_result)
+
+ row = QHBoxLayout()
+ scan_spk = _make_btn("🔍 Scan Speaker Format", "#555")
+ scan_spk.clicked.connect(self._detect_speakers)
+ row.addWidget(scan_spk)
+
+ self.apply_speaker_btn = _make_btn("✔ Apply to RPGMaker Settings", "#3a7a3a")
+ self.apply_speaker_btn.setEnabled(False)
+ self.apply_speaker_btn.clicked.connect(self._apply_speaker_settings)
+ row.addWidget(self.apply_speaker_btn)
+ row.addStretch()
+ layout.addLayout(row)
+
+ # ── Step 4: Translation ─────────────────────────────────────────────────
+
+ def _build_step4(self, layout: QVBoxLayout):
+ layout.addWidget(_make_section_label("Step 5 — Translation"))
+
+ p1_box = QGroupBox("Phase 1 – Safe Codes (dialogue + choices)")
+ p1_box.setStyleSheet(
+ "QGroupBox{color:#ccc;border:1px solid #444;border-radius:3px;"
+ "margin-top:8px;font-size:11px;}"
+ "QGroupBox::title{padding:0 6px;}"
+ )
+ p1_inner = QVBoxLayout(p1_box)
+ p1_desc = QLabel("Codes: 401 (Show Text), 405 (continued), 102 (Choices), 408 (extra lines)\n"
+ "These rarely break game logic — safe to translate first.")
+ p1_desc.setStyleSheet("color:#888;font-size:10px;")
+ p1_desc.setWordWrap(True)
+ p1_inner.addWidget(p1_desc)
+ p1_row = QHBoxLayout()
+ run_p1 = _make_btn("▶ Run Phase 1", "#007acc")
+ run_p1.clicked.connect(lambda: self._run_phase(1))
+ p1_row.addWidget(run_p1)
+ p1_row.addStretch()
+ p1_inner.addLayout(p1_row)
+ layout.addWidget(p1_box)
+
+ p2_box = QGroupBox("Phase 2 – Risky Codes (variables + conditionals)")
+ p2_box.setStyleSheet(
+ "QGroupBox{color:#ccc;border:1px solid #444;border-radius:3px;"
+ "margin-top:8px;font-size:11px;}"
+ "QGroupBox::title{padding:0 6px;}"
+ )
+ p2_inner = QVBoxLayout(p2_box)
+ p2_desc = QLabel(
+ "Codes: 122 (Control Variables), 357 (Plugin Commands), 111 (Conditional Branch)\n"
+ "Strings in these codes are often used in script comparisons — mistranslations "
+ "can break game logic. Scan first, then translate carefully."
+ )
+ p2_desc.setStyleSheet("color:#888;font-size:10px;")
+ p2_desc.setWordWrap(True)
+ p2_inner.addWidget(p2_desc)
+
+ p2_row = QHBoxLayout()
+ scan_p2 = _make_btn("🔍 Scan Risky Strings", "#555")
+ scan_p2.setToolTip(
+ "Preview all unique string values in codes 122 / 357 / 111 "
+ "before deciding what to translate."
+ )
+ scan_p2.clicked.connect(self._scan_risky_strings)
+ p2_row.addWidget(scan_p2)
+
+ run_p2 = _make_btn("▶ Run Phase 2", "#7a4a00")
+ run_p2.clicked.connect(lambda: self._run_phase(2))
+ p2_row.addWidget(run_p2)
+ p2_row.addStretch()
+ p2_inner.addLayout(p2_row)
+ layout.addWidget(p2_box)
+
+ # ── Step 6: Export ──────────────────────────────────────────────────────
+
+ def _build_step6(self, layout: QVBoxLayout):
+ layout.addWidget(_make_section_label("Step 6 — Export to Game"))
+ hint = QLabel(
+ "Copy all files from translated/ back into the game's data folder "
+ "to patch the game in-place."
+ )
+ hint.setWordWrap(True)
+ hint.setStyleSheet("color:#888;font-size:10px;padding-bottom:4px;")
+ layout.addWidget(hint)
+
+ row = QHBoxLayout()
+ export_btn = _make_btn("📤 Export translated/ → Game Folder", "#3a7a3a")
+ export_btn.clicked.connect(self._export_to_game)
+ row.addWidget(export_btn)
+ row.addStretch()
+ layout.addLayout(row)
+
+ # ─────────────────────────────────────────────────────────────────────────
+ # 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._detect_folder()
+
+ 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)
+ self.detected_label.setText("Scanning…")
+ self.file_list.clear()
+ self.import_btn.setEnabled(False)
+
+ 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}")
+ return
+
+ if data_path is None:
+ self.detected_label.setText(
+ "⚠ No recognised data folder found. "
+ "Make sure this is a valid RPGMaker game directory."
+ )
+ return
+
+ self._data_path = str(data_path)
+ self._engine = engine
+ self.detected_label.setText(
+ f"Engine: {engine} · Data folder: {data_path}"
+ )
+ self._log(f"Detected data folder: {data_path} (engine: {engine})")
+
+ 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()
+
+ for item in items:
+ cat = item["category"]
+ icon = "📄" if cat == "core" else ("🗺" if cat == "map" else "❓")
+ lw = QListWidgetItem(f"{icon} {item['name']} ({item['size_kb']:.1f} KB)")
+ lw.setData(Qt.UserRole, item)
+ 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.import_btn.setEnabled(len(items) > 0)
+ self._log(f"Found {len(items)} importable file(s).")
+ self._populate_preprocess_paths()
+
+ def _select_all_files(self):
+ count = self.file_list.count()
+ for i in range(count):
+ self.file_list.item(i).setCheckState(Qt.Checked)
+ if count:
+ self._log(f"✔ Selected all {count} file(s).")
+
+ def _select_core_only(self):
+ core = other = 0
+ 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
+ if self.file_list.count():
+ self._log(f"✔ Selected {core} core file(s); deselected {other} other(s).")
+
+ def _import_files(self):
+ 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))
+
+ if not selected:
+ self._log("⚠ No files selected.")
+ return
+
+ self.import_btn.setEnabled(False)
+ worker = _ImportWorker(selected, "files")
+ worker.log.connect(self._log)
+ worker.done.connect(self._on_import_done)
+ self._worker = worker
+ worker.start()
+
+ def _on_import_done(self, count: int, errors: list):
+ self.import_btn.setEnabled(True)
+ if errors:
+ self._log(f"⚠ {len(errors)} error(s) during import:")
+ for e in errors[:10]:
+ self._log(f" {e}")
+ self._log(f"✅ Imported {count} file(s) into files/")
+
+ # ─────────────────────────────────────────────────────────────────────────
+ # Step 1 – Vocab
+ # ─────────────────────────────────────────────────────────────────────────
+
+ def _reload_vocab(self):
+ vocab_path = Path("vocab.txt")
+ try:
+ if vocab_path.exists():
+ self.vocab_editor.setPlainText(vocab_path.read_text(encoding="utf-8"))
+ else:
+ self.vocab_editor.setPlainText("# Add character glossary entries here\n")
+ except Exception as exc:
+ self._log(f"❌ Could not load vocab.txt: {exc}")
+
+ def _save_vocab(self):
+ try:
+ Path("vocab.txt").write_text(
+ self.vocab_editor.toPlainText(), encoding="utf-8"
+ )
+ self._log("✅ vocab.txt saved.")
+ except Exception as exc:
+ self._log(f"❌ Could not save vocab.txt: {exc}")
+
+ # ─────────────────────────────────────────────────────────────────────────
+ # Step 2 – Actor substitution
+ # ─────────────────────────────────────────────────────────────────────────
+
+ def _scan_actor_vars(self):
+ try:
+ from util.actor_substitutor import scan_variables, load_actor_map
+ counts = scan_variables("files")
+ actor_map = load_actor_map()
+ if not counts:
+ self.actor_status.setText("No \\n[X] variables found in files/")
+ return
+ lines = []
+ for aid, cnt in sorted(counts.items()):
+ name = actor_map.get(aid, "???")
+ lines.append(f" \\n[{aid}] → {name} ({cnt} occurrence(s))")
+ self.actor_status.setText("\n".join(lines))
+ self._log("Actor variable scan complete.")
+ except Exception as exc:
+ self._log(f"❌ Scan error: {exc}")
+
+ def _substitute_actors(self):
+ reply = QMessageBox.question(
+ self,
+ "Substitute Actor Variables",
+ "This will modify files/ in-place, replacing \\n[X] codes with actor names.\n\n"
+ "Back up files/ first if needed. Continue?",
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.Yes,
+ )
+ if reply != QMessageBox.Yes:
+ return
+ self.sub_btn.setEnabled(False)
+ w = _ActorSubstituteWorker("substitute")
+ w.log.connect(self._log)
+ w.done.connect(self._on_actor_done)
+ self._worker = w
+ w.start()
+
+ def _restore_actors(self):
+ reply = QMessageBox.question(
+ self,
+ "Restore Actor Variables",
+ "This will modify translated/ in-place, replacing actor names back with \\n[X] codes.\n\n"
+ "Continue?",
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.Yes,
+ )
+ if reply != QMessageBox.Yes:
+ return
+ self.restore_btn.setEnabled(False)
+ w = _ActorSubstituteWorker("restore")
+ w.log.connect(self._log)
+ w.done.connect(self._on_actor_done)
+ self._worker = w
+ w.start()
+
+ def _on_actor_done(self, stats: dict):
+ self.sub_btn.setEnabled(True)
+ self.restore_btn.setEnabled(True)
+ if "error" in stats:
+ self._log(f"❌ {stats['error']}")
+ return
+ mods = stats.get("files_modified", 0)
+ total = stats.get("total_substitutions", 0)
+ found = stats.get("variables_found", {})
+ errors = stats.get("errors", [])
+ self._log(
+ f"✅ {total} substitution(s) across {mods} file(s). "
+ f"Actors: {', '.join(f'ID {k}={v}' for k, v in found.items()) or 'none'}"
+ )
+ for e in errors:
+ self._log(f" ⚠ {e}")
+
+ # ─────────────────────────────────────────────────────────────────────────
+ # Step 3 – Speaker detection
+ # ─────────────────────────────────────────────────────────────────────────
+
+ def _detect_speakers(self):
+ self.apply_speaker_btn.setEnabled(False)
+ self.speaker_result.setText("Scanning…")
+ w = _SpeakerDetectWorker()
+ w.log.connect(self._log)
+ w.done.connect(self._on_speaker_done)
+ self._worker = w
+ w.start()
+
+ def _on_speaker_done(self, result: dict):
+ if "error" in result:
+ self.speaker_result.setText(f"❌ Error: {result['error']}")
+ self._log(f"❌ Speaker detection error: {result['error']}")
+ return
+
+ self._detect_result = result
+ scores = result.get("scores", {})
+ best = result.get("best_mode", "NONE")
+ conf = result.get("confidence", "low")
+ note = result.get("note", "")
+
+ conf_color = {"high": "#4ec9b0", "medium": "#dcdcaa", "low": "#f48771"}.get(conf, "#ccc")
+ score_str = " | ".join(f"{k}: {v}" for k, v in scores.items())
+ self.speaker_result.setText(
+ f"Best mode: {best} ({conf} confidence)\n"
+ f"Scores — {score_str}\n"
+ f"{note}"
+ )
+ self._log(f"Speaker detection: {best} ({conf}) — {note}")
+
+ if best != "NONE":
+ self.apply_speaker_btn.setEnabled(True)
+
+ def _apply_speaker_settings(self):
+ if not self._detect_result:
+ return
+ cfg = self._detect_result.get("recommended_config", {})
+ try:
+ from gui.config_integration import ConfigIntegration
+ ci = ConfigIntegration()
+ ci.update_rpgmaker_config(cfg)
+ self._log(
+ f"✅ Applied speaker settings: "
+ + ", ".join(f"{k}={v}" for k, v in cfg.items())
+ )
+ # Refresh the RPGMaker tab if accessible
+ self._refresh_rpgmaker_tab(cfg)
+ except Exception as exc:
+ self._log(f"❌ Could not apply settings: {exc}")
+
+ def _refresh_rpgmaker_tab(self, cfg: dict):
+ """Push the new config to the RPGMaker tab widget so it refreshes live."""
+ 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
+
+ # ─────────────────────────────────────────────────────────────────────────
+ # Step 4 – Translation phases
+ # ─────────────────────────────────────────────────────────────────────────
+
+ def _run_phase(self, phase: int):
+ config = PHASE1_CONFIG if phase == 1 else PHASE2_CONFIG
+ label = "Phase 1 (safe codes)" if phase == 1 else "Phase 2 (risky codes)"
+
+ # Apply config profile
+ try:
+ from gui.config_integration import ConfigIntegration
+ ci = ConfigIntegration()
+ ci.update_rpgmaker_config(config)
+ self._log(f"Applied {label} config profile.")
+ except Exception as exc:
+ self._log(f"❌ Could not apply phase config: {exc}")
+ return
+
+ # Launch translation worker
+ try:
+ from gui.translation_tab import TranslationWorker
+ except Exception as exc:
+ self._log(f"❌ Could not import TranslationWorker: {exc}")
+ return
+
+ project_root = Path(__file__).parent.parent
+ module_info = ["RPG Maker MV/MZ", [".json"], None]
+
+ # Build file order: core DB files first (they populate the glossary),
+ # then map files. Within each group, sort alphabetically.
+ files_dir = project_root / "files"
+ all_json = sorted(
+ p.name for p in files_dir.glob("*.json") if p.name != ".gitkeep"
+ ) if files_dir.exists() else []
+ core_files = [f for f in all_json if not f.lower().startswith("map")]
+ map_files = [f for f in all_json if f.lower().startswith("map")]
+ ordered_files = core_files + map_files
+ if ordered_files:
+ self._log(
+ f"File order: {len(core_files)} core file(s) first, "
+ f"then {len(map_files)} map file(s)."
+ )
+
+ self._log(f"Starting {label} translation…")
+ worker = TranslationWorker(
+ project_root, module_info, estimate_only=False,
+ selected_files=ordered_files if ordered_files else None,
+ )
+ worker.log_signal.connect(self._log)
+ worker.progress_signal.connect(
+ lambda cur, tot, fn: self._log(f" [{cur}/{tot}] {fn}")
+ )
+ worker.finished_signal.connect(
+ lambda ok, msg: self._log(
+ f"{'✅' if ok else '❌'} {label} finished: {msg}"
+ )
+ )
+ self._worker = worker
+ worker.start()
+
+ # ─────────────────────────────────────────────────────────────────────────
+ # Risky string scanner (preview mode)
+ # ─────────────────────────────────────────────────────────────────────────
+
+ def _scan_risky_strings(self):
+ risky_codes = {122, 357, 111}
+ collected: dict[int, list[str]] = {c: [] for c in risky_codes}
+ files_dir = Path("files")
+
+ try:
+ for fp in sorted(files_dir.glob("*.json")):
+ data = json.loads(fp.read_text(encoding="utf-8-sig"))
+ self._collect_risky(data, risky_codes, collected)
+
+ self._log("=" * 50)
+ self._log("Risky Code String Scan (preview — nothing modified):")
+ total = 0
+ for code in sorted(risky_codes):
+ strings = sorted(set(collected[code]))
+ total += len(strings)
+ self._log(f"\n CODE {code} — {len(strings)} unique string(s):")
+ for s in strings[:50]:
+ self._log(f" {repr(s)}")
+ if len(strings) > 50:
+ self._log(f" … ({len(strings) - 50} more)")
+ self._log(f"\n Total unique strings: {total}")
+ self._log("=" * 50)
+ except Exception as exc:
+ self._log(f"❌ Scan error: {exc}")
+
+ def _collect_risky(self, obj, risky_codes: set, collected: dict):
+ if isinstance(obj, list):
+ for item in obj:
+ self._collect_risky(item, risky_codes, collected)
+ elif isinstance(obj, dict):
+ code = obj.get("code")
+ if code in risky_codes:
+ params = obj.get("parameters") or []
+ for p in params:
+ if isinstance(p, str) and p.strip():
+ collected[code].append(p)
+ for v in obj.values():
+ self._collect_risky(v, risky_codes, collected)
+
+ # ─────────────────────────────────────────────────────────────────────────
+ # Step 5 – Export to game
+ # ─────────────────────────────────────────────────────────────────────────
+
+ def _export_to_game(self):
+ game_data = self._data_path
+ if not game_data:
+ # Prompt the user
+ game_data = QFileDialog.getExistingDirectory(
+ self, "Select Game Data Folder to Export Into"
+ )
+ if not game_data:
+ return
+ self._data_path = game_data
+
+ reply = QMessageBox.question(
+ self,
+ "Export to Game",
+ f"This will overwrite 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 _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.")
+
+ # ─────────────────────────────────────────────────────────────────────────
+ # 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(data_path or "(no data 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 = _SubprocessWorker(["dazedformat", "."], cwd=data_path, label="dazedformat")
+ 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 _run_gameupdate(self):
+ src = self.pp_gameupdate_edit.text().strip()
+ dst = self._data_path
+ if not src:
+ self._log("⚠ No gameupdate folder path set.")
+ return
+ if not dst:
+ self._log("⚠ No data folder detected. Complete Step 0 first.")
+ return
+ if not Path(src).is_dir():
+ self._log(f"⚠ gameupdate folder not found: {src}")
+ return
+ w = _FileCopyWorker(src, dst)
+ 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}")
+
+ def _run_all_preprocess(self):
+ """Launch all three pre-process tasks, skipping any whose prerequisites are missing."""
+ import shutil as _shutil
+ data_path = self._data_path
+ plugins_js = self.pp_plugins_edit.text().strip()
+ gameupdate_src = self.pp_gameupdate_edit.text().strip()
+ skipped: list[str] = []
+
+ # A — dazedformat
+ if data_path and _shutil.which("dazedformat"):
+ self._log("▶ [A] dazedformat …")
+ self._run_dazedformat()
+ else:
+ reason = "data folder missing" if not data_path else "dazedformat not on PATH"
+ skipped.append(f"A (dazedformat): {reason}")
+
+ # B — jsbeautifier (pure Python, no Node required)
+ if plugins_js and Path(plugins_js).is_file():
+ self._log("▶ [B] formatting plugins.js …")
+ self._run_prettier()
+ else:
+ reason = f"plugins.js not found ({plugins_js or 'not set'})"
+ skipped.append(f"B (format plugins.js): {reason}")
+
+ # C — gameupdate copy
+ if gameupdate_src and Path(gameupdate_src).is_dir() and data_path:
+ self._log("▶ [C] gameupdate copy …")
+ self._run_gameupdate()
+ else:
+ if not gameupdate_src or not Path(gameupdate_src).is_dir():
+ reason = f"source not found ({gameupdate_src or 'not set'})"
+ else:
+ reason = "data folder missing"
+ skipped.append(f"C (gameupdate): {reason}")
+
+ for msg in skipped:
+ self._log(f" ⏭ Skipped: {msg}")
+
+ 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)
diff --git a/util/actor_substitutor.py b/util/actor_substitutor.py
new file mode 100644
index 0000000..b3663ed
--- /dev/null
+++ b/util/actor_substitutor.py
@@ -0,0 +1,248 @@
+"""
+Actor Variable Substitutor
+
+RPGMaker uses \\n[X] (stored as \\n[X] in JSON, single backslash after json.load)
+to pull actor names dynamically at runtime. This module:
+
+ 1. Builds a map of actor_id -> name from Actors.json
+ (prefers translated/Actors.json over files/Actors.json so the AI
+ receives the target-language name in context)
+
+ 2. substitute_in_files() – walks every .json file inside `files/`,
+ replaces \\n[X] with the actor's name, writes the file back.
+ Returns per-file statistics.
+
+ 3. restore_in_translated() – walks every .json file inside `translated/`,
+ replaces the substituted name back with \\n[X].
+ Returns per-file statistics.
+
+The rename-able protagonist case is handled automatically: substitute the
+default/translated name for context during translation, then restore the
+\\n[X] variable so the player's custom name still works at runtime.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from typing import Optional
+
+# Pattern that matches \n[X] after json.load() (single literal backslash)
+_VAR_RE = re.compile(r"\\n\[(\d+)\]", re.IGNORECASE)
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+def load_actor_map(
+ files_dir: str | Path = "files",
+ translated_dir: str | Path = "translated",
+) -> dict[int, str]:
+ """Return {actor_id: name}.
+
+ Prefers translated/Actors.json (English names) over files/Actors.json.
+ """
+ for candidates in (
+ Path(translated_dir) / "Actors.json",
+ Path(files_dir) / "Actors.json",
+ ):
+ if candidates.is_file():
+ try:
+ with open(candidates, "r", encoding="utf-8-sig") as fh:
+ data = json.load(fh)
+ actor_map: dict[int, str] = {}
+ for entry in data:
+ if not entry or not isinstance(entry, dict):
+ continue
+ actor_id = entry.get("id")
+ name = (entry.get("name") or "").strip()
+ if actor_id is not None and name:
+ actor_map[int(actor_id)] = name
+ if actor_map:
+ return actor_map
+ except Exception:
+ continue
+ return {}
+
+
+def substitute_in_files(
+ files_dir: str | Path = "files",
+ actor_map: Optional[dict[int, str]] = None,
+ translated_dir: str | Path = "translated",
+) -> dict:
+ """Replace \\n[X] with actor names in all .json files inside *files_dir*.
+
+ If *actor_map* is None it is built automatically via load_actor_map().
+ Returns a summary dict:
+ {
+ "files_modified": int,
+ "total_substitutions": int,
+ "variables_found": {actor_id: name, ...},
+ "errors": [str, ...]
+ }
+ """
+ files_dir = Path(files_dir)
+ if actor_map is None:
+ actor_map = load_actor_map(files_dir, translated_dir)
+
+ stats = {
+ "files_modified": 0,
+ "total_substitutions": 0,
+ "variables_found": {},
+ "errors": [],
+ }
+
+ for fp in sorted(files_dir.glob("*.json")):
+ try:
+ original = fp.read_text(encoding="utf-8-sig")
+ data = json.loads(original)
+ count = [0]
+ found: dict[int, str] = {}
+
+ new_data = _walk(data, actor_map, count, found, substitute=True)
+
+ if count[0] > 0:
+ stats["total_substitutions"] += count[0]
+ stats["files_modified"] += 1
+ stats["variables_found"].update(found)
+ out = json.dumps(new_data, ensure_ascii=False, indent=4)
+ fp.write_text(out + "\n", encoding="utf-8")
+ except Exception as exc:
+ stats["errors"].append(f"{fp.name}: {exc}")
+
+ return stats
+
+
+def restore_in_translated(
+ translated_dir: str | Path = "translated",
+ actor_map: Optional[dict[int, str]] = None,
+ files_dir: str | Path = "files",
+) -> dict:
+ """Replace actor names back with \\n[X] in all .json files inside *translated_dir*.
+
+ If *actor_map* is None it is built via load_actor_map() which prefers
+ translated/Actors.json (the already-translated names).
+
+ Returns a summary dict with the same shape as substitute_in_files().
+ """
+ translated_dir = Path(translated_dir)
+ if actor_map is None:
+ actor_map = load_actor_map(files_dir, translated_dir)
+
+ # Build reverse map: name (lowercased) → "\\n[X]"
+ # We match case-insensitively so the AI's casing doesn't break restore.
+ # We keep the original-case name for display in stats.
+ reverse: dict[str, tuple[str, int]] = {} # lower_name -> (var_string, actor_id)
+ for aid, name in actor_map.items():
+ if name:
+ reverse[name.lower()] = (f"\\n[{aid}]", aid)
+
+ stats = {
+ "files_modified": 0,
+ "total_substitutions": 0,
+ "variables_found": {},
+ "errors": [],
+ }
+
+ for fp in sorted(translated_dir.glob("*.json")):
+ # Never restore inside Actors.json itself — that file has the names
+ # as first-class translated data, not as variable stand-ins.
+ if fp.name == "Actors.json":
+ continue
+ try:
+ data = json.loads(fp.read_text(encoding="utf-8-sig"))
+ count = [0]
+ found: dict[int, str] = {}
+
+ new_data = _walk_restore(data, reverse, count, found)
+
+ if count[0] > 0:
+ stats["total_substitutions"] += count[0]
+ stats["files_modified"] += 1
+ stats["variables_found"].update(found)
+ out = json.dumps(new_data, ensure_ascii=False, indent=4)
+ fp.write_text(out + "\n", encoding="utf-8")
+ except Exception as exc:
+ stats["errors"].append(f"{fp.name}: {exc}")
+
+ return stats
+
+
+def scan_variables(
+ files_dir: str | Path = "files",
+) -> dict[int, int]:
+ """Scan *files_dir* and return {actor_id: occurrence_count} without modifying files."""
+ counts: dict[int, int] = {}
+ for fp in Path(files_dir).glob("*.json"):
+ try:
+ text = fp.read_text(encoding="utf-8-sig")
+ # Search in raw JSON text (\\n[ in file = single \n[ after parse)
+ # The raw JSON file stores it as \\n[X] so we search that directly:
+ for m in re.finditer(r"\\\\n\[(\d+)\]", text):
+ aid = int(m.group(1))
+ counts[aid] = counts.get(aid, 0) + 1
+ except Exception:
+ pass
+ return counts
+
+
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+def _sub_string(s: str, actor_map: dict[int, str], count: list, found: dict) -> str:
+ def replace(m: re.Match) -> str:
+ aid = int(m.group(1))
+ name = actor_map.get(aid)
+ if name:
+ count[0] += 1
+ found[aid] = name
+ return name
+ return m.group(0)
+ return _VAR_RE.sub(replace, s)
+
+
+def _restore_string(s: str, reverse: dict[str, tuple[str, int]], count: list, found: dict) -> str:
+ """Replace actor names with \\n[X] in a translated string."""
+ # We need to handle the fact that names can be substrings of other words.
+ # Build a single regex alternation sorted by length (longest first) to
+ # prevent short names like "Al" matching inside "Alicia".
+ if not reverse:
+ return s
+ pattern = re.compile(
+ "|".join(re.escape(name) for name in sorted(reverse.keys(), key=len, reverse=True)),
+ re.IGNORECASE,
+ )
+
+ def replace(m: re.Match) -> str:
+ lower_matched = m.group(0).lower()
+ entry = reverse.get(lower_matched)
+ if entry:
+ var_str, aid = entry
+ count[0] += 1
+ found[aid] = m.group(0)
+ return var_str
+ return m.group(0)
+
+ return pattern.sub(replace, s)
+
+
+def _walk(obj, actor_map, count, found, substitute: bool):
+ if isinstance(obj, str):
+ return _sub_string(obj, actor_map, count, found)
+ if isinstance(obj, list):
+ return [_walk(item, actor_map, count, found, substitute) for item in obj]
+ if isinstance(obj, dict):
+ return {k: _walk(v, actor_map, count, found, substitute) for k, v in obj.items()}
+ return obj
+
+
+def _walk_restore(obj, reverse, count, found):
+ if isinstance(obj, str):
+ return _restore_string(obj, reverse, count, found)
+ if isinstance(obj, list):
+ return [_walk_restore(item, reverse, count, found) for item in obj]
+ if isinstance(obj, dict):
+ return {k: _walk_restore(v, reverse, count, found) for k, v in obj.items()}
+ return obj
diff --git a/util/project_scanner.py b/util/project_scanner.py
new file mode 100644
index 0000000..77e59a8
--- /dev/null
+++ b/util/project_scanner.py
@@ -0,0 +1,235 @@
+"""
+Project Scanner - Detect game engine and import/export data files.
+
+RPGMaker layouts scanned (in priority order):
+ MV/MZ : /www/data/ or /data/
+ Ace : /Data/
+ XP/VX : /Data/ (rvdata/rxdata - not currently handled)
+"""
+
+from __future__ import annotations
+
+import json
+import shutil
+from pathlib import Path
+from typing import Optional
+
+# ---------------------------------------------------------------------------
+# File categories used to decide what to import by default
+# ---------------------------------------------------------------------------
+
+# Core name/database files – almost always needed
+CORE_FILES = {
+ "Actors.json",
+ "Armors.json",
+ "Classes.json",
+ "Enemies.json",
+ "Items.json",
+ "MapInfos.json",
+ "Skills.json",
+ "States.json",
+ "System.json",
+ "Troops.json",
+ "Weapons.json",
+ "CommonEvents.json",
+ "Animations.json",
+ "Tilesets.json",
+}
+
+# Map files – large but contain the bulk of dialogue
+MAP_PATTERN = "Map[0-9]*.json"
+
+# Engine detection markers
+_MVMZ_MARKERS = { # files / dirs that hint at MV or MZ
+ "www", # MV web build
+ "package.json", # MZ desktop
+ "Game.rpgproject", # MV
+ "game.rmmzproject", # MZ
+}
+_ACE_MARKERS = {
+ "Game.rgss3a",
+ "Game.exe", # not conclusive but common
+}
+_ACE_DATA_SCRIPTS = {".rvdata2", ".rvdata"}
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+def find_data_folder(game_root: str | Path) -> tuple[Optional[Path], str]:
+ """Return (data_path, engine_name) for *game_root*.
+
+ engine_name is one of: "MVMZ", "ACE", "UNKNOWN".
+ data_path is None when nothing is found.
+ """
+ root = Path(game_root)
+ if not root.is_dir():
+ return None, "UNKNOWN"
+
+ # ---- RPGMaker MV/MZ: www/data or data ----
+ for candidate in (root / "www" / "data", root / "data", root / "Data"):
+ if candidate.is_dir() and _has_json_data(candidate):
+ # Confirm engine variant
+ engine = "MVMZ"
+ for m in _MVMZ_MARKERS:
+ if (root / m).exists():
+ engine = "MVMZ"
+ break
+ return candidate, engine
+
+ # ---- RPGMaker Ace: Data/ with .rvdata2 ----
+ ace_data = root / "Data"
+ if ace_data.is_dir():
+ rvdata = list(ace_data.glob("*.rvdata2")) + list(ace_data.glob("*.rvdata"))
+ if rvdata:
+ return ace_data, "ACE"
+
+ # ---- Fallback: any sub-directory that holds .json files ----
+ for child in root.iterdir():
+ if child.is_dir() and _has_json_data(child):
+ return child, "UNKNOWN"
+
+ return None, "UNKNOWN"
+
+
+def list_data_files(data_path: str | Path, engine: str = "MVMZ") -> list[dict]:
+ """Return a sorted list of importable file descriptors.
+
+ Each item: {"name": str, "path": Path, "size_kb": float,
+ "category": "core" | "map" | "other", "default": bool}
+ """
+ data_path = Path(data_path)
+ results: list[dict] = []
+
+ if engine == "ACE":
+ # Ace uses binary rvdata2; not JSON-based, skip for now
+ return results
+
+ seen: set[str] = set()
+ for fp in sorted(data_path.iterdir()):
+ if not fp.is_file():
+ continue
+ if fp.suffix.lower() != ".json":
+ continue
+ name = fp.name
+ if name in seen:
+ continue
+ seen.add(name)
+
+ size_kb = fp.stat().st_size / 1024
+ if name in CORE_FILES:
+ cat = "core"
+ default = True
+ elif fp.match(MAP_PATTERN):
+ cat = "map"
+ default = True
+ else:
+ cat = "other"
+ default = False
+
+ results.append({
+ "name": name,
+ "path": fp,
+ "size_kb": round(size_kb, 1),
+ "category": cat,
+ "default": default,
+ })
+
+ # Sort: core first, then maps (numeric), then other
+ def _sort_key(item):
+ if item["category"] == "core":
+ return (0, item["name"])
+ if item["category"] == "map":
+ # extract numeric part for natural order
+ digits = "".join(c for c in item["name"] if c.isdigit()) or "0"
+ return (1, int(digits))
+ return (2, item["name"])
+
+ results.sort(key=_sort_key)
+ return results
+
+
+def import_to_files(
+ file_items: list[dict],
+ dest_dir: str | Path = "files",
+ overwrite: bool = True,
+) -> tuple[int, list[str]]:
+ """Copy *file_items* (from list_data_files) into *dest_dir*.
+
+ Returns (count_copied, list_of_errors).
+ """
+ dest = Path(dest_dir)
+ dest.mkdir(parents=True, exist_ok=True)
+ copied = 0
+ errors: list[str] = []
+
+ for item in file_items:
+ src: Path = item["path"]
+ dst = dest / item["name"]
+ try:
+ if not overwrite and dst.exists():
+ continue
+ shutil.copy2(src, dst)
+ copied += 1
+ except Exception as exc:
+ errors.append(f"{item['name']}: {exc}")
+
+ return copied, errors
+
+
+def export_to_game(
+ translated_dir: str | Path,
+ game_data_path: str | Path,
+ filenames: Optional[list[str]] = None,
+ overwrite: bool = True,
+) -> tuple[int, list[str]]:
+ """Copy translated files back into the game's data folder.
+
+ If *filenames* is None, all .json files in *translated_dir* are copied.
+ Returns (count_copied, list_of_errors).
+ """
+ src_dir = Path(translated_dir)
+ dst_dir = Path(game_data_path)
+ copied = 0
+ errors: list[str] = []
+
+ if not src_dir.is_dir():
+ return 0, [f"Translated folder not found: {src_dir}"]
+ if not dst_dir.is_dir():
+ return 0, [f"Game data folder not found: {dst_dir}"]
+
+ candidates = (
+ [src_dir / fn for fn in filenames]
+ if filenames
+ else list(src_dir.glob("*.json"))
+ )
+
+ for fp in candidates:
+ if not fp.is_file():
+ continue
+ dst = dst_dir / fp.name
+ try:
+ if not overwrite and dst.exists():
+ continue
+ shutil.copy2(fp, dst)
+ copied += 1
+ except Exception as exc:
+ errors.append(f"{fp.name}: {exc}")
+
+ return copied, errors
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _has_json_data(folder: Path) -> bool:
+ """Return True if the folder contains at least one .json data file."""
+ try:
+ for p in folder.iterdir():
+ if p.suffix.lower() == ".json":
+ return True
+ except PermissionError:
+ pass
+ return False
diff --git a/util/speaker_detector.py b/util/speaker_detector.py
new file mode 100644
index 0000000..286f1ac
--- /dev/null
+++ b/util/speaker_detector.py
@@ -0,0 +1,225 @@
+"""
+Speaker Format Detector for RPGMaker MV/MZ
+
+Scans map / event files and scores three detection modes:
+
+ INLINE401SPEAKERS – 401 lines contain Name「dialogue」
+ FIRSTLINESPEAKERS – a short (< 40 char) 401 followed by 401/405 starting
+ with 「 " ( ( * [
+ FACENAME101 – a 101 code (Show Text / face cmd) immediately precedes
+ a 401 block
+
+Returns the best mode and confidence scores for each.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+# Regex used to detect inline quote speaker: "Name「..." at start of 401 text
+_INLINE_RE = re.compile(r"^([^\s「」。、!?…\\\n]{1,20})「")
+
+# Characters that signal the next 401 is dialogue (first-line speaker heuristic)
+_DIALOGUE_STARTERS = ("「", '"', "(", "(", "*", "[")
+
+# Minimum number of qualifying events to consider a result reliable
+_MIN_HITS = 3
+
+
+def detect_speaker_format(
+ files_dir: str | Path = "files",
+ sample_size: int = 20,
+) -> dict:
+ """Scan up to *sample_size* map files and score speaker detection modes.
+
+ Returns:
+ {
+ "best_mode": "INLINE401SPEAKERS" | "FIRSTLINESPEAKERS" | "FACENAME101" | "NONE",
+ "scores": {
+ "INLINE401SPEAKERS": int, # raw hit count
+ "FIRSTLINESPEAKERS": int,
+ "FACENAME101": int,
+ },
+ "total_401_groups": int, # total dialogue groups examined
+ "files_scanned": int,
+ "recommended_config": { # ready to apply to rpgmakermvmz.py
+ "FIRSTLINESPEAKERS": bool,
+ "INLINE401SPEAKERS": bool,
+ "FACENAME101": bool,
+ },
+ "confidence": "high" | "medium" | "low",
+ "note": str, # human-readable explanation
+ }
+ """
+ files_dir = Path(files_dir)
+ scores = {
+ "INLINE401SPEAKERS": 0,
+ "FIRSTLINESPEAKERS": 0,
+ "FACENAME101": 0,
+ }
+ total_groups = 0
+ files_scanned = 0
+
+ # Collect map files (Maps only, not MapInfos)
+ map_files = sorted(
+ [p for p in files_dir.glob("Map[0-9]*.json") if p.is_file()],
+ key=lambda p: p.stat().st_size,
+ reverse=True, # scan larger maps first for better signal
+ )[:sample_size]
+
+ for fp in map_files:
+ try:
+ with open(fp, "r", encoding="utf-8-sig") as fh:
+ data = json.load(fh)
+ except Exception:
+ continue
+
+ files_scanned += 1
+ events = data.get("events") or []
+ for evt in events:
+ if not evt:
+ continue
+ for page in evt.get("pages") or []:
+ cmd_list = (page or {}).get("list") or []
+ _score_command_list(cmd_list, scores)
+ # Count 401 groups
+ i = 0
+ while i < len(cmd_list):
+ if cmd_list[i].get("code") in (401, 405):
+ total_groups += 1
+ while i < len(cmd_list) and cmd_list[i].get("code") in (401, 405, -1):
+ i += 1
+ continue
+ i += 1
+
+ # Also scan CommonEvents
+ ce_path = files_dir / "CommonEvents.json"
+ if ce_path.is_file():
+ try:
+ with open(ce_path, "r", encoding="utf-8-sig") as fh:
+ ce_data = json.load(fh)
+ files_scanned += 1
+ for entry in ce_data or []:
+ if not entry:
+ continue
+ cmd_list = entry.get("list") or []
+ _score_command_list(cmd_list, scores)
+ except Exception:
+ pass
+
+ # Determine best mode
+ best = max(scores, key=lambda k: scores[k])
+ best_score = scores[best]
+ second_best = sorted(scores.values(), reverse=True)[1]
+
+ if best_score < _MIN_HITS:
+ best_mode = "NONE"
+ confidence = "low"
+ note = (
+ f"No strong speaker pattern detected "
+ f"(scanned {files_scanned} file(s), {total_groups} dialogue group(s)). "
+ "Consider checking speaker settings manually."
+ )
+ elif best_score > second_best * 2:
+ best_mode = best
+ confidence = "high"
+ note = (
+ f"Strong signal for {best} "
+ f"({best_score} hits vs {second_best} for next best, "
+ f"{total_groups} dialogue group(s) in {files_scanned} file(s))."
+ )
+ else:
+ best_mode = best
+ confidence = "medium"
+ note = (
+ f"Moderate signal for {best} "
+ f"({best_score} hits, {second_best} for next-best mode). "
+ "Review a sample of dialogue manually to confirm."
+ )
+
+ recommended = {
+ "FIRSTLINESPEAKERS": best_mode == "FIRSTLINESPEAKERS",
+ "INLINE401SPEAKERS": best_mode == "INLINE401SPEAKERS",
+ "FACENAME101": best_mode == "FACENAME101",
+ }
+
+ return {
+ "best_mode": best_mode,
+ "scores": scores,
+ "total_401_groups": total_groups,
+ "files_scanned": files_scanned,
+ "recommended_config": recommended,
+ "confidence": confidence,
+ "note": note,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+def _score_command_list(cmd_list: list, scores: dict) -> None:
+ """Walk a command list and increment scores for detected patterns."""
+ if not cmd_list:
+ return
+
+ last_101_face: str | None = None # face name from most recent 101 cmd
+
+ i = 0
+ while i < len(cmd_list):
+ cmd = cmd_list[i]
+ code = cmd.get("code")
+ params = cmd.get("parameters") or []
+
+ # ---- 101: Show Text (face) ----
+ if code == 101:
+ # params[0] = face name, params[1] = face index
+ last_101_face = (params[0] if params else None) or ""
+ i += 1
+ continue
+
+ # ---- 401 / 405: dialogue line ----
+ if code in (401, 405):
+ text = (params[0] if params else "") or ""
+
+ # Score INLINE401SPEAKERS
+ if _INLINE_RE.match(text):
+ scores["INLINE401SPEAKERS"] += 1
+
+ # Score FACENAME101 — if face was set right before this block
+ if last_101_face is not None:
+ scores["FACENAME101"] += 1
+ last_101_face = None # only count once per block
+
+ # Score FIRSTLINESPEAKERS — short line with no dialogue starters,
+ # followed by a 401/405 that starts with a dialogue starter
+ if (
+ len(text) < 40
+ and not any(text.lstrip().startswith(s) for s in _DIALOGUE_STARTERS)
+ and _has_japanese(text)
+ ):
+ j = i + 1
+ while j < len(cmd_list) and cmd_list[j].get("code") == -1:
+ j += 1
+ if j < len(cmd_list) and cmd_list[j].get("code") in (401, 405):
+ next_text = ((cmd_list[j].get("parameters") or [""])[0]) or ""
+ if next_text.lstrip().startswith(_DIALOGUE_STARTERS):
+ scores["FIRSTLINESPEAKERS"] += 1
+
+ i += 1
+ continue
+
+ # Any non-dialogue code resets the face context
+ if code not in (-1,):
+ last_101_face = None
+
+ i += 1
+
+
+_JP_RE = re.compile(r"[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uF900-\uFAFF\uFF61-\uFF9F]")
+
+
+def _has_japanese(text: str) -> bool:
+ return bool(_JP_RE.search(text))