I'm doing stuff

This commit is contained in:
dazedanon 2026-03-12 20:20:17 -05:00
parent c3ac172818
commit 5fd6e82b53
11 changed files with 2862 additions and 8 deletions

14
gameupdate/GameUpdate.bat Normal file
View file

@ -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

View file

@ -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"

102
gameupdate/README.md Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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",
]

View file

@ -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)

1569
gui/workflow_tab.py Normal file

File diff suppressed because it is too large Load diff

248
util/actor_substitutor.py Normal file
View file

@ -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

235
util/project_scanner.py Normal file
View file

@ -0,0 +1,235 @@
"""
Project Scanner - Detect game engine and import/export data files.
RPGMaker layouts scanned (in priority order):
MV/MZ : <root>/www/data/ or <root>/data/
Ace : <root>/Data/
XP/VX : <root>/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

225
util/speaker_detector.py Normal file
View file

@ -0,0 +1,225 @@
"""
Speaker Format Detector for RPGMaker MV/MZ
Scans map / event files and scores three detection modes:
INLINE401SPEAKERS 401 lines contain Namedialogue
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))