diff --git a/.env.example b/.env.example
index df1bb32..22b8c63 100644
--- a/.env.example
+++ b/.env.example
@@ -9,20 +9,21 @@
# - Leave this variable out to use the model's default setting.
GEMINI_THINKING_BUDGET=
-# Set to "gemini" to use the Gemini API or "openai" for OpenAI (If empty it will default to openai.)
+# Set to "gemini" to use the Gemini API or "openai" for OpenAI-compatible APIs (If empty it will default to openai.)
API_PROVIDER=openai
-#API link, leave blank to use OpenAI API
+# API URL, leave blank to use OpenAI API.
+# Nvidia example: "https://integrate.api.nvidia.com/v1/"
api=""
-#API key
+# API key
key=""
-#Oranization key, make something up for self hosted or other API
+#Oranization key, make something up for self hosted or other API. If using Nvidia API, leave it blank or it can get wonky
organization=""
-#LLM model name, use gpt-3.5-turbo-1106 or gpt-3.5-turbo or gpt-4-1106-preview for OpenAI API
-#For text generation webui use gpt-3.5-turbo, for other API's consult their documentation
+# LLM model name.
+# Default below works for OpenAI; for Gemini/Nvidia set your provider model name.
model="gpt-4.1"
#The language to translate TO, Don't forget to change the prompt
@@ -32,7 +33,10 @@ language="English"
timeout="120"
#The number of files to translate at the same time, 1 recommended for free or self hosted API or gpt-4
-fileThreads="1"
+fileThreads="5"
+
+#Concurrent API calls per file (threads per file), 1 recommended for free or self hosted API or gpt-4
+threads="1"
#The wordwrap of dialogue text
width="60"
@@ -56,4 +60,8 @@ output_cost= 0.002
batchsize="10"
# Frequency penalty - adjust according to your needs
-frequency_penalty= 0.2
\ No newline at end of file
+frequency_penalty= 0.2
+
+# TL Inspector (Step 8 playtest) — editor opened when clicking a source line in-game
+# tlEditorCmd: 'auto' scans for VS Code / Cursor, or set an absolute path to any editor exe
+tlEditorCmd='auto'
diff --git a/.gitignore b/.gitignore
index 5849736..91f71ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+
.env
*.tmp
*.json
@@ -19,3 +20,4 @@ __pycache__
!prompt.txt
!vocab_base.txt
!requirements.txt
+!util/tl_inspector/TLInspector.js
diff --git a/README.md b/README.md
index af86efb..6dcfd1b 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,7 @@ An AI-powered game translation tool with a GUI. Translate RPG Maker, Ren'Py, Tyr
## Credits
- **[Sinflower](https://github.com/Sinflower)** — [RV2JSON](https://github.com/Sinflower/RV2JSON) — enables RPGMaker Ace games to be translated the same way as MV/MZ by converting rvdata2 files to JSON and back.
+- **Sakura & Kao_SSS** — TL Inspector (`util/tl_inspector/`) — in-game translation source inspector and live-edit plugin for RPG Maker MV/MZ playtesting.
## Table of Contents
@@ -109,9 +110,11 @@ This means Python wasn't added to your PATH. You have two options:
1. Inside the tool folder, find `.env.example` and make a copy of it named `.env`.
2. Open `.env` in any text editor (Notepad works fine) and fill in your API details:
+ - `api` — Your API base URL (for Nvidia use `https://integrate.api.nvidia.com/v1/`).
- `key` — Your API key.
- `organization` — Your organization key (make something up if using a self-hosted or non-OpenAI API).
- - `API_PROVIDER` — Set to `openai` or `gemini` depending on your provider.
+ - `API_PROVIDER` — Use `openai` for OpenAI-compatible providers (including Nvidia), or `gemini` for Gemini.
+ - `model` — For Nvidia/custom OpenAI-compatible endpoints, enter the model name manually (example: `deepseek-ai/deepseek-v4-pro`).
3. The rest of the settings (wordwrap, batch size, etc.) can be left as defaults for now. You can tweak them later.
### 3. Launch the GUI
diff --git a/gui/config_tab.py b/gui/config_tab.py
index 8587b20..98b9f95 100644
--- a/gui/config_tab.py
+++ b/gui/config_tab.py
@@ -137,12 +137,27 @@ class ConfigTab(QWidget):
self.reset_to_defaults()
else:
self.load_from_env()
+ self._update_model_placeholder()
# Connect auto-save after initial load
self.connect_auto_save()
# Fetch latest models in the background once the UI is shown
QTimer.singleShot(0, lambda: self.fetch_models(silent=True))
+
+ def _is_nvidia_api_url(self, api_url: str) -> bool:
+ """Return True when the configured URL points to Nvidia's OpenAI-compatible API."""
+ return "integrate.api.nvidia.com" in (api_url or "").strip().lower()
+
+ def _update_model_placeholder(self):
+ """Show a manual model-entry hint only when Nvidia API is selected."""
+ line_edit = self.model_combo.lineEdit()
+ if not line_edit:
+ return
+ if self._is_nvidia_api_url(self.api_url_edit.text()):
+ line_edit.setPlaceholderText("Enter Nvidia model name (e.g., deepseek-ai/deepseek-v4-pro)")
+ else:
+ line_edit.setPlaceholderText("")
def init_ui(self):
"""Initialize the user interface with horizontal icon navigation at top."""
@@ -326,11 +341,13 @@ class ConfigTab(QWidget):
("Claude (Anthropic)", "https://api.anthropic.com/v1"),
("Gemini", "https://generativelanguage.googleapis.com/v1beta/openai/"),
("DeepSeek", "https://api.deepseek.com/v1/"),
+ ("Nvidia", "https://integrate.api.nvidia.com/v1/"),
]
for _name, _url in _url_presets:
_action = api_url_menu.addAction(_name)
_action.triggered.connect(lambda checked, u=_url: self.api_url_edit.setText(u))
api_url_preset_btn.setMenu(api_url_menu)
+ self.api_url_edit.textChanged.connect(self._update_model_placeholder)
api_url_layout.addWidget(self.api_url_edit)
api_url_layout.addWidget(api_url_preset_btn)
diff --git a/gui/rewrite_tab.py b/gui/rewrite_tab.py
index 06c847d..6b4ebc2 100644
--- a/gui/rewrite_tab.py
+++ b/gui/rewrite_tab.py
@@ -222,6 +222,45 @@ def _sanitise_output(text: str) -> str:
return text
+def _extract_claude_text(response) -> str:
+ """Collect text from all text blocks; skip thinking/tool blocks."""
+ parts = []
+ for block in getattr(response, "content", None) or []:
+ if getattr(block, "type", None) == "text":
+ t = getattr(block, "text", None)
+ if t:
+ parts.append(t)
+ elif hasattr(block, "text") and block.text:
+ # Older SDK blocks without explicit type
+ parts.append(block.text)
+ text = "".join(parts).strip()
+ if not text:
+ stop = getattr(response, "stop_reason", None)
+ raise ValueError(
+ f"Empty response from API (stop_reason={stop!r}). "
+ "Try again or use a different model."
+ )
+ return text
+
+
+def _extract_openai_text(response) -> str:
+ """Extract assistant message text from an OpenAI-compatible completion."""
+ choices = getattr(response, "choices", None) or []
+ if not choices:
+ raise ValueError("Empty response from API (no choices returned).")
+ message = choices[0].message
+ text = getattr(message, "content", None)
+ if text is None and hasattr(message, "refusal") and message.refusal:
+ raise ValueError(f"Model refused: {message.refusal}")
+ if not text or not str(text).strip():
+ finish = getattr(choices[0], "finish_reason", None)
+ raise ValueError(
+ f"Empty response from API (finish_reason={finish!r}). "
+ "Try again or use a different model."
+ )
+ return str(text).strip()
+
+
def _call_llm(messages: list, model: str, api_url: str, api_key: str,
is_claude: bool, timeout: int) -> tuple:
"""Call the LLM. Returns (text, input_tokens, output_tokens)."""
@@ -237,7 +276,7 @@ def _call_llm(messages: list, model: str, api_url: str, api_key: str,
messages=user_msgs,
timeout=timeout,
)
- text = response.content[0].text.strip()
+ text = _extract_claude_text(response)
in_tok = getattr(response.usage, "input_tokens", 0) or 0
out_tok = getattr(response.usage, "output_tokens", 0) or 0
return text, in_tok, out_tok
@@ -254,7 +293,7 @@ def _call_llm(messages: list, model: str, api_url: str, api_key: str,
temperature=0,
timeout=timeout,
)
- text = response.choices[0].message.content.strip()
+ text = _extract_openai_text(response)
in_tok = getattr(response.usage, "prompt_tokens", 0) or 0
out_tok = getattr(response.usage, "completion_tokens", 0) or 0
return text, in_tok, out_tok
diff --git a/gui/translation_tab.py b/gui/translation_tab.py
index f91761a..1235101 100644
--- a/gui/translation_tab.py
+++ b/gui/translation_tab.py
@@ -282,16 +282,15 @@ class TranslationWorker(QThread):
# Check for required environment variables
required_envs = ["api", "key", "model", "language", "timeout", "fileThreads", "threads", "width", "listWidth"]
- env_missing = False
-
- for env in required_envs:
- if os.getenv(env) is None or str(os.getenv(env))[:1] == "<":
- self.emit_log(f"❌ Environment variable {env} is not set!")
- env_missing = True
-
- if env_missing:
- self.emit_log("❌ Some required environment variables are not set. Check your .env file.")
- self.finished_signal.emit(False, "Environment variables missing")
+ missing_envs = [
+ env for env in required_envs
+ if os.getenv(env) is None or str(os.getenv(env))[:1] == "<"
+ ]
+ if missing_envs:
+ names = ", ".join(missing_envs)
+ self.emit_log(f"❌ Missing required environment variable(s): {names}")
+ self.emit_log(" Check your .env file (see .env.example).")
+ self.finished_signal.emit(False, f"Missing env: {names}")
return
# Get files to process
diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py
index e4500e5..7bc706c 100644
--- a/gui/workflow_tab.py
+++ b/gui/workflow_tab.py
@@ -7,9 +7,11 @@ Provides a guided, step-by-step interface:
Step 1 – (Optional) Pre-process game files
Step 2 – Auto-detect speaker format and apply to module settings
Step 3 – Build glossary: parse speakers, then enrich with AI prompt
- Step 4 – Translation: Phase 0 (DB), Phase 1 (dialogue), Phase 1b (111 cache), Phase 2 (risky)
- Step 5 – Translate visible strings in js/plugins.js
- Step 6 – Export translated/ back to the game folder
+ Step 4 – Translation: Phase 0 (DB), Phase 1 (dialogue), Phase 1b (111 cache)
+ Step 5 – Translation Phase 2 (risky codes)
+ Step 6 – Translate visible strings in js/plugins.js (or Ace scripts)
+ Step 7 – Export translated/ back to the game folder
+ Step 8 – Install TL Inspector for playtesting and live in-game edits (MV/MZ)
"""
from __future__ import annotations
@@ -27,6 +29,7 @@ from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import (
QApplication,
QCheckBox,
+ QComboBox,
QFileDialog,
QFrame,
QGridLayout,
@@ -560,6 +563,7 @@ class WorkflowTab(QWidget):
("5 TL Phase 2", self._build_step5_tl_phase2),
("6 Plugins.js", self._build_step6_plugins_js),
("7 Export", self._build_step7_export),
+ ("8 Playtest", self._build_step8_playtest),
]
for tab_label, builder in _tab_defs:
@@ -714,6 +718,9 @@ class WorkflowTab(QWidget):
if index == 5:
self._populate_p2_checkboxes()
+ if index == 8:
+ self._refresh_tl_inspector_status()
+ self._load_tli_editor_settings()
def _register_import_button(self, button: QPushButton) -> None:
self._import_buttons.append(button)
@@ -2398,6 +2405,335 @@ class WorkflowTab(QWidget):
row.addStretch()
layout.addLayout(row)
+ # ── Step 8: Playtest (TL Inspector) ─────────────────────────────────────
+
+ def _build_step8_playtest(self, layout: QVBoxLayout):
+ self._step8_section_label = _make_section_label("Step 8 — Playtest with TL Inspector")
+ layout.addWidget(self._step8_section_label)
+
+ hint = QLabel(
+ "Install TL Inspector into your RPG Maker MV/MZ game for playtesting. "
+ "Press F10 in-game to see which source file each line of text comes from, "
+ "open it in VSCode, or edit the text live and save directly to the JSON file."
+ )
+ hint.setWordWrap(True)
+ hint.setTextFormat(Qt.RichText)
+ hint.setStyleSheet("color:#9d9d9d;font-size:13px;padding-bottom:4px;")
+ layout.addWidget(hint)
+
+ credits = QLabel("Idea by Sakura · Plugin by Kao_SSS")
+ credits.setStyleSheet("color:#6a6a6a;font-size:11px;font-style:italic;padding-bottom:6px;")
+ layout.addWidget(credits)
+
+ box = QWidget()
+ box.setObjectName("tbox")
+ box.setStyleSheet(self._task_box_style())
+ inner = QVBoxLayout(box)
+ inner.setContentsMargins(10, 8, 10, 8)
+ inner.setSpacing(6)
+
+ self._tli_status_label = QLabel("Status: (detect a project folder first)")
+ self._tli_status_label.setWordWrap(True)
+ self._tli_status_label.setStyleSheet("color:#7a7a7a;font-size:13px;")
+ inner.addWidget(self._tli_status_label)
+
+ editor_title = QLabel("Editor settings")
+ editor_title.setStyleSheet("color:#4ec9b0;font-size:12px;font-weight:bold;padding-top:4px;")
+ inner.addWidget(editor_title)
+
+ _TLI_LABEL_W = 80
+ _TLI_BTN_W = 88
+ _tli_lbl_style = "color:#9d9d9d;font-size:12px;"
+
+ tli_grid = QGridLayout()
+ tli_grid.setHorizontalSpacing(6)
+ tli_grid.setVerticalSpacing(6)
+ tli_grid.setColumnStretch(1, 1)
+
+ editor_lbl = QLabel("Editor:")
+ editor_lbl.setFixedWidth(_TLI_LABEL_W)
+ editor_lbl.setStyleSheet(_tli_lbl_style)
+ editor_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
+ tli_grid.addWidget(editor_lbl, 0, 0)
+
+ self._tli_editor_combo = QComboBox()
+ self._tli_editor_combo.currentIndexChanged.connect(self._on_tli_editor_combo_changed)
+ tli_grid.addWidget(self._tli_editor_combo, 0, 1)
+
+ detect_btn = _make_btn("Detect", "#4a4a4a")
+ detect_btn.setFixedWidth(_TLI_BTN_W)
+ detect_btn.setToolTip("Scan this PC for VS Code, Insiders, or Cursor")
+ detect_btn.clicked.connect(self._detect_tli_editors)
+ tli_grid.addWidget(detect_btn, 0, 2)
+
+ self._tli_editor_custom = QLineEdit()
+ self._tli_editor_custom.setPlaceholderText("Path to editor executable (when Custom is selected)")
+ self._tli_editor_custom.setEnabled(False)
+ tli_grid.addWidget(self._tli_editor_custom, 1, 1)
+
+ browse_editor_btn = _make_btn("Browse…", "#4a4a4a")
+ browse_editor_btn.setFixedWidth(_TLI_BTN_W)
+ browse_editor_btn.clicked.connect(self._browse_tli_editor)
+ tli_grid.addWidget(browse_editor_btn, 1, 2)
+
+ self._tli_detect_label = QLabel("")
+ self._tli_detect_label.setWordWrap(True)
+ self._tli_detect_label.setStyleSheet("color:#6a6a6a;font-size:11px;")
+ tli_grid.addWidget(self._tli_detect_label, 2, 1, 1, 2)
+
+ cfg_btn_wrap = QWidget()
+ cfg_btn_row = QHBoxLayout(cfg_btn_wrap)
+ cfg_btn_row.setContentsMargins(0, 0, 0, 0)
+ cfg_btn_row.setSpacing(8)
+ save_tli_btn = _make_btn("✔ Save settings", "#3a5a7a")
+ save_tli_btn.setFixedWidth(140)
+ save_tli_btn.setToolTip("Write editor settings to .env (used on Install / Apply)")
+ save_tli_btn.clicked.connect(self._save_tli_editor_settings)
+ cfg_btn_row.addWidget(save_tli_btn)
+ apply_tli_btn = _make_btn("↻ Apply to game", "#3a5a7a")
+ apply_tli_btn.setFixedWidth(140)
+ apply_tli_btn.setToolTip("Update the installed TLInspector.js in your game folder")
+ apply_tli_btn.clicked.connect(self._apply_tli_editor_settings)
+ cfg_btn_row.addWidget(apply_tli_btn)
+ cfg_btn_row.addStretch()
+ tli_grid.addWidget(cfg_btn_wrap, 3, 1, 1, 2)
+
+ inner.addLayout(tli_grid)
+
+ tips = QLabel(
+ "
"
+ "
Run this after exporting translations so the game files are up to date.
"
+ "
Overlay save to file reloads instantly; VSCode saves reload once the file is stable on disk
"
+ "
F9 — force reload database + current map
"
+ "
F10 — toggle the inspector panel
"
+ "
Click a source location to open in VSCode; click edit to change text in-game
"
+ "
Remove the plugin before shipping a release build
"
+ "
"
+ )
+ tips.setWordWrap(True)
+ tips.setTextFormat(Qt.RichText)
+ inner.addWidget(tips)
+
+ btn_row = QHBoxLayout()
+ btn_row.setSpacing(8)
+ _BTN_W = 200
+ self._tli_install_btn = _make_btn("⬇ Install TL Inspector", "#3a7a3a")
+ self._tli_install_btn.setFixedWidth(_BTN_W)
+ self._tli_install_btn.clicked.connect(self._install_tl_inspector)
+ btn_row.addWidget(self._tli_install_btn)
+
+ self._tli_uninstall_btn = _make_btn("⬆ Uninstall TL Inspector", "#7a3a3a")
+ self._tli_uninstall_btn.setFixedWidth(_BTN_W)
+ self._tli_uninstall_btn.clicked.connect(self._uninstall_tl_inspector)
+ btn_row.addWidget(self._tli_uninstall_btn)
+ btn_row.addStretch()
+ inner.addLayout(btn_row)
+
+ layout.addWidget(box)
+ self._step8_playtest_box = box
+ self._populate_tli_editor_combo()
+ self._load_tli_editor_settings()
+
+ def _populate_tli_editor_combo(self, select: str | None = None):
+ """Fill editor dropdown with auto-detect, found editors, and custom."""
+ from util.tl_inspector.config import detect_editors
+
+ combo = self._tli_editor_combo
+ combo.blockSignals(True)
+ combo.clear()
+ combo.addItem("Auto-detect (recommended)", "auto")
+ for label, path in detect_editors():
+ combo.addItem(f"{label} — {path}", str(path))
+ combo.addItem("Custom path…", "__custom__")
+
+ want = select
+ if want is None:
+ try:
+ from util.tl_inspector.config import load_config
+ want = load_config().get("editorCmd", "auto")
+ except Exception:
+ want = "auto"
+
+ idx = combo.findData(want) if want else 0
+ if idx >= 0:
+ combo.setCurrentIndex(idx)
+ elif want and want != "auto":
+ custom_idx = combo.findData("__custom__")
+ combo.setCurrentIndex(custom_idx if custom_idx >= 0 else 0)
+ self._tli_editor_custom.setText(want)
+ else:
+ combo.setCurrentIndex(0)
+ combo.blockSignals(False)
+ self._on_tli_editor_combo_changed()
+ self._update_tli_detect_label()
+
+ def _update_tli_detect_label(self):
+ from util.tl_inspector.config import detect_editors, detect_primary_editor
+
+ found = detect_editors()
+ primary = detect_primary_editor()
+ if primary:
+ extra = f" ({len(found)} found)" if len(found) > 1 else ""
+ self._tli_detect_label.setText(f"Detected on this PC: {primary}{extra}")
+ else:
+ self._tli_detect_label.setText(
+ "No VS Code / Cursor found — install one or choose Custom path."
+ )
+
+ def _load_tli_editor_settings(self):
+ """Load TL Inspector editor settings from .env into Step 8 controls."""
+ try:
+ from util.tl_inspector.config import load_config
+ cfg = load_config()
+ except Exception:
+ cfg = {"editorCmd": "auto"}
+
+ self._populate_tli_editor_combo(select=cfg.get("editorCmd", "auto"))
+
+ def _resolve_tli_config(self) -> dict:
+ """Build config dict from Step 8 editor controls."""
+ mode = self._tli_editor_combo.currentData()
+ if mode == "__custom__":
+ editor = self._tli_editor_custom.text().strip() or "auto"
+ elif mode:
+ editor = str(mode)
+ else:
+ editor = "auto"
+
+ return {"editorCmd": editor, "workspaceFolder": "auto"}
+
+ def _on_tli_editor_combo_changed(self, _index: int | None = None):
+ custom = self._tli_editor_combo.currentData() == "__custom__"
+ self._tli_editor_custom.setEnabled(custom)
+
+ def _detect_tli_editors(self):
+ try:
+ from util.tl_inspector.config import load_config
+ current = load_config().get("editorCmd", "auto")
+ except Exception:
+ current = "auto"
+ self._populate_tli_editor_combo(select=current)
+ self._log("🔍 Scanned for VS Code / Cursor installations.")
+
+ def _browse_tli_editor(self):
+ start = self._tli_editor_custom.text() or self._setting("last_tli_editor", "")
+ path, _ = QFileDialog.getOpenFileName(
+ self,
+ "Select Editor Executable",
+ start,
+ "Executables (*.exe);;All Files (*)",
+ )
+ if not path:
+ return
+ self._save_setting("last_tli_editor", path)
+ custom_idx = self._tli_editor_combo.findData("__custom__")
+ if custom_idx >= 0:
+ self._tli_editor_combo.setCurrentIndex(custom_idx)
+ self._tli_editor_custom.setText(path)
+
+ def _save_tli_editor_settings(self):
+ cfg = self._resolve_tli_config()
+ try:
+ from util.tl_inspector.config import save_config
+ save_config(cfg)
+ self._log(f"✅ TL Inspector settings saved — editor={cfg['editorCmd']}")
+ except Exception as exc:
+ self._log(f"❌ Could not save TL Inspector settings: {exc}")
+
+ def _apply_tli_editor_settings(self):
+ game_root = self.folder_edit.text().strip()
+ if not game_root:
+ self._log("⚠ No game folder set. Complete Step 0 first.")
+ return
+ cfg = self._resolve_tli_config()
+ try:
+ from util.tl_inspector.config import save_config
+ from util.tl_inspector.installer import apply_config
+ save_config(cfg)
+ ok, msg = apply_config(Path(game_root), cfg)
+ except Exception as exc:
+ self._log(f"❌ Could not apply TL Inspector settings: {exc}")
+ return
+ self._log(("✅ " if ok else "❌ ") + msg)
+
+ def _refresh_tl_inspector_status(self):
+ """Update Step 8 status label from the current game folder."""
+ label = getattr(self, "_tli_status_label", None)
+ if label is None:
+ return
+ game_root = self.folder_edit.text().strip()
+ if not game_root:
+ label.setText("Status: no game folder set — complete Step 0 first.")
+ label.setStyleSheet("color:#7a7a7a;font-size:13px;")
+ return
+ try:
+ from util.tl_inspector.installer import status
+ st = status(Path(game_root))
+ except Exception as exc:
+ label.setText(f"Status: error — {exc}")
+ label.setStyleSheet("color:#f48771;font-size:13px;")
+ return
+
+ if not st.get("ok"):
+ label.setText(f"Status: {st.get('message', 'unsupported')}")
+ label.setStyleSheet("color:#e9a12a;font-size:13px;")
+ return
+
+ engine = st.get("engine", "?")
+ msg = st.get("message", "")
+ parts = [f"RPG Maker {engine}", msg]
+ if st.get("declared") and st.get("plugin_file"):
+ detail = "plugin declared in plugins.js and file present"
+ elif st.get("declared"):
+ detail = "declared in plugins.js (plugin file missing)"
+ elif st.get("plugin_file"):
+ detail = "plugin file present (not declared in plugins.js)"
+ else:
+ detail = "not installed"
+ label.setText(f"Status: {' · '.join(parts)} — {detail}")
+ color = "#6a9a6a" if st.get("declared") and st.get("plugin_file") else "#9d9d9d"
+ label.setStyleSheet(f"color:{color};font-size:13px;")
+
+ def _install_tl_inspector(self):
+ game_root = self.folder_edit.text().strip()
+ if not game_root:
+ self._log("⚠ No game folder set. Complete Step 0 first.")
+ return
+ cfg = self._resolve_tli_config()
+ try:
+ from util.tl_inspector.config import save_config
+ from util.tl_inspector.installer import install
+ save_config(cfg)
+ ok, msg = install(Path(game_root), cfg=cfg)
+ except Exception as exc:
+ self._log(f"❌ TL Inspector install failed: {exc}")
+ return
+ self._log(("✅ " if ok else "❌ ") + msg)
+ self._refresh_tl_inspector_status()
+
+ def _uninstall_tl_inspector(self):
+ game_root = self.folder_edit.text().strip()
+ if not game_root:
+ self._log("⚠ No game folder set. Complete Step 0 first.")
+ return
+ reply = QMessageBox.question(
+ self,
+ "Uninstall TL Inspector",
+ "Remove TLInspector from plugins.js and delete the plugin file?",
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.No,
+ )
+ if reply != QMessageBox.Yes:
+ return
+ try:
+ from util.tl_inspector.installer import uninstall
+ ok, msg = uninstall(Path(game_root))
+ except Exception as exc:
+ self._log(f"❌ TL Inspector uninstall failed: {exc}")
+ return
+ self._log(("✅ " if ok else "❌ ") + msg)
+ self._refresh_tl_inspector_status()
+
# ─────────────────────────────────────────────────────────────────────────
# Step 0 – Project Folder logic
# ─────────────────────────────────────────────────────────────────────────
@@ -2501,6 +2837,13 @@ class WorkflowTab(QWidget):
"Copy a prompt that instructs Copilot/Cursor to translate only "
"visible player-facing strings in plugins.js, using vocab.txt as a glossary."
)
+ # Step 8 — TL Inspector (MV/MZ only)
+ if hasattr(self, "_step_tabs") and self._step_tabs.count() > 8:
+ self._step_tabs.setTabEnabled(8, not is_ace)
+ box = getattr(self, "_step8_playtest_box", None)
+ if box is not None:
+ box.setEnabled(not is_ace)
+ self._refresh_tl_inspector_status()
def _detect_folder(self):
folder = self.folder_edit.text().strip()
diff --git a/modules/main.py b/modules/main.py
index 4072848..e2e07b4 100644
--- a/modules/main.py
+++ b/modules/main.py
@@ -10,27 +10,27 @@ from dotenv import load_dotenv
# This needs to be before the module imports as some of them currently try to read and use some of these values
# upon import, in which case if they are unset the script will crash before we can output these messages.
-envMissing = False
load_dotenv()
-for env in [
- "api",
- "key",
- "model",
- "language",
- "timeout",
- "fileThreads",
- "threads",
- "width",
- "listWidth",
-]:
- if os.getenv(env) is None or str(os.getenv(env))[:1] == "<":
- tqdm.write(Fore.RED + f"Environment variable {env} is not set!")
- envMissing = True
-if envMissing:
+_missing_envs = [
+ env for env in [
+ "api",
+ "key",
+ "model",
+ "language",
+ "timeout",
+ "fileThreads",
+ "threads",
+ "width",
+ "listWidth",
+ ]
+ if os.getenv(env) is None or str(os.getenv(env))[:1] == "<"
+]
+if _missing_envs:
+ names = ", ".join(_missing_envs)
tqdm.write(
Fore.RED
- + "Some of the required environment values may not be set correctly. You can set \
-these values using an .env file, for an example see .env.example"
+ + f"Missing required environment variable(s): {names}. "
+ + "Set them in a .env file (see .env.example)."
)
from modules.rpgmakermvmz import handleMVMZ, setSpeakerParseMode as setSpeakerParseMVMZ, finalizeSpeakerParse as finalizeSpeakerParseMVMZ
diff --git a/util/tl_inspector/TLInspector.bat b/util/tl_inspector/TLInspector.bat
new file mode 100644
index 0000000..1085807
--- /dev/null
+++ b/util/tl_inspector/TLInspector.bat
@@ -0,0 +1,112 @@
+@echo off
+rem ============================================================================
+rem TLInspector installer / uninstaller (RPG Maker MV & MZ)
+rem Idea by Sakura · Plugin by Kao_SSS
+rem Put this file and TLInspector.js in the GAME ROOT (next to the .exe /
+rem index.html), then double-click this file.
+rem - Not installed yet -> installs it (moves the plugin into the plugins
+rem folder and declares it at the end of plugins.js).
+rem - Already installed -> asks whether to remove it.
+rem ============================================================================
+chcp 65001 >nul
+setlocal
+set "TLI_ROOT=%~dp0"
+powershell -NoProfile -ExecutionPolicy Bypass -Command "$l=Get-Content -LiteralPath '%~f0'; $i=[Array]::IndexOf($l,'#:PSSTART'); Invoke-Expression (($l[($i+1)..($l.Count-1)]) -join [char]10)"
+endlocal
+exit /b
+#:PSSTART
+$ErrorActionPreference = 'Stop'
+try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {}
+function PauseExit { [void](Read-Host "`nPress Enter to exit"); exit }
+
+$root = $env:TLI_ROOT
+Write-Host "============================================"
+Write-Host " TLInspector installer"
+Write-Host " Idea by Sakura · Plugin by Kao_SSS"
+Write-Host "============================================"
+
+# --- Detect engine / locate plugins.js (MV uses www\, MZ does not) ----------
+$mvJs = Join-Path $root 'www\js\plugins.js'
+$mzJs = Join-Path $root 'js\plugins.js'
+if (Test-Path -LiteralPath $mvJs) {
+ $engine = 'MV'; $pluginsJs = $mvJs; $pluginsDir = Join-Path $root 'www\js\plugins'
+} elseif (Test-Path -LiteralPath $mzJs) {
+ $engine = 'MZ'; $pluginsJs = $mzJs; $pluginsDir = Join-Path $root 'js\plugins'
+} else {
+ Write-Host ""
+ Write-Host "ERROR: No RPG Maker MV/MZ game found here." -ForegroundColor Red
+ Write-Host "Could not find 'www\js\plugins.js' (MV) or 'js\plugins.js' (MZ)."
+ Write-Host "Place this installer and TLInspector.js in the game ROOT folder"
+ Write-Host "(the one containing the .exe / index.html) and run it again."
+ PauseExit
+}
+Write-Host ("Detected RPG Maker {0}" -f $engine)
+Write-Host ("plugins list: {0}" -f $pluginsJs)
+
+$target = Join-Path $pluginsDir 'TLInspector.js'
+$srcRoot = Join-Path $root 'TLInspector.js'
+$utf8 = New-Object System.Text.UTF8Encoding($false) # no BOM
+$content = [IO.File]::ReadAllText($pluginsJs)
+$nl = if ($content -match "`r`n") { "`r`n" } else { "`n" }
+$declared = [regex]::IsMatch($content, '"name"\s*:\s*"TLInspector"')
+$fileThere = Test-Path -LiteralPath $target
+
+# --- Already installed -> offer to remove -----------------------------------
+if ($declared -or $fileThere) {
+ Write-Host ""
+ Write-Host "TLInspector is currently INSTALLED." -ForegroundColor Yellow
+ $ans = Read-Host "Remove it? (Y/N)"
+ if ($ans -match '^(y|yes)$') {
+ if ($declared) {
+ $kept = ($content -split "`r?`n") | Where-Object { $_ -notmatch '"name"\s*:\s*"TLInspector"' }
+ $newText = ($kept -join $nl)
+ # drop a now-dangling comma before the closing ];
+ $newText = [regex]::Replace($newText, ',(\s*)\];', '$1];')
+ [IO.File]::WriteAllText($pluginsJs, $newText, $utf8)
+ Write-Host "Removed the TLInspector entry from plugins.js"
+ }
+ if ($fileThere) {
+ Remove-Item -LiteralPath $target -Force
+ Write-Host ("Deleted {0}" -f $target)
+ }
+ Write-Host ""
+ Write-Host "Uninstalled." -ForegroundColor Green
+ } else {
+ Write-Host "Cancelled - nothing changed."
+ }
+ PauseExit
+}
+
+# --- Not installed -> install -----------------------------------------------
+if (-not (Test-Path -LiteralPath $srcRoot)) {
+ Write-Host ""
+ Write-Host "ERROR: TLInspector.js was not found next to this installer." -ForegroundColor Red
+ Write-Host ("Expected: {0}" -f $srcRoot)
+ Write-Host "Copy TLInspector.js into the game root and run this again."
+ PauseExit
+}
+if (-not (Test-Path -LiteralPath $pluginsDir)) {
+ New-Item -ItemType Directory -Path $pluginsDir -Force | Out-Null
+}
+Move-Item -LiteralPath $srcRoot -Destination $target -Force
+Write-Host ("Moved plugin -> {0}" -f $target)
+
+# Insert the declaration as the last entry, just before the closing ];
+$entry = ' { "name": "TLInspector", "status": true, "description": "TL source inspector", "parameters": {} }'
+$idx = $content.LastIndexOf('];')
+if ($idx -lt 0) {
+ Write-Host ""
+ Write-Host "ERROR: could not find the end of the plugin list ( ]; ) in plugins.js." -ForegroundColor Red
+ Write-Host "The plugin file was moved; please add this line before the ']' yourself:"
+ Write-Host $entry
+ PauseExit
+}
+$before = $content.Substring(0, $idx).TrimEnd()
+$after = $content.Substring($idx) # starts at ];
+$sep = if ($before.EndsWith(',')) { $nl } else { ',' + $nl }
+$newText = $before + $sep + $entry + $nl + ' ' + $after
+[IO.File]::WriteAllText($pluginsJs, $newText, $utf8)
+Write-Host "Declared TLInspector at the end of plugins.js"
+Write-Host ""
+Write-Host "Installed! Start the game, press F10 to open the inspector, and use edit to save text changes in-game." -ForegroundColor Green
+PauseExit
diff --git a/util/tl_inspector/TLInspector.js b/util/tl_inspector/TLInspector.js
new file mode 100644
index 0000000..4ab93d3
--- /dev/null
+++ b/util/tl_inspector/TLInspector.js
@@ -0,0 +1,2279 @@
+//=============================================================================
+// TLInspector.js
+// Idea by Sakura · Plugin by Kao_SSS
+//=============================================================================
+/*:
+ * @plugindesc Translation Source Inspector - shows which source file/line every
+ * on-screen line of text and image comes from, and jumps to it in VSCode.
+ * @author Kao_SSS
+ *
+ * @help
+ * ----------------------------------------------------------------------------
+ * TLInspector (RPGMaker MV / MZ, NW.js)
+ * Credits: Idea by Sakura · Plugin by Kao_SSS
+ * ----------------------------------------------------------------------------
+ * A drop-in proofreading aid for translators. While the game runs, it tracks
+ * every message / choice / scrolling-text line and every on-screen image, and
+ * maps each one back to its EXACT source file and line number:
+ *
+ * - Dialogue / choices / scroll text -> www/data/MapXXX.json,
+ * CommonEvents.json, Troops.json (by identity-matching the running event
+ * list, so duplicate strings are never confused).
+ * - Pictures and other images -> img/... path + the event command
+ * (or JS call stack) that loaded them.
+ *
+ * Press the hotkey (default F10) to open the inspector overlay. Each row has an
+ * "Open in VSCode" button (runs `code -g file:line`) and an **edit** button to
+ * change the text and save it back to the source file without leaving the game.
+ *
+ * Live refresh (NW.js playtest only):
+ * In-game overlay "save to file" reloads that JSON into memory immediately.
+ * VSCode / external saves: we watch data/*.json; once the file mtime is stable
+ * (save finished), reload instantly. The game cannot hook Ctrl+S in VSCode itself.
+ * F9 — manual reload of all database JSON + the current map
+ *
+ * Limitations:
+ * - NW.js desktop playtest only (not browser)
+ * - Text already shown by a running event won't update until re-triggered
+ * (in-game overlay edit still updates the current message box)
+ * - Plugin .js / plugins.js changes still need F5
+ *
+ * ----------------------------------------------------------------------------
+ * Install: drop this file in www/js/plugins/ and add one line to plugins.js:
+ * { "name": "TLInspector", "status": true, "description": "TL source inspector", "parameters": {} }
+ * Place it LAST in the list. Remove that line to fully disable for a release.
+ *
+ * Config: edit the CFG object a few lines below.
+ * ----------------------------------------------------------------------------
+ */
+
+(function () {
+ 'use strict';
+
+ //=========================================================================
+ // Config
+ //=========================================================================
+ var CFG = {
+ enabled: true,
+ hotkey: 'F10', // key (event.key) that toggles the overlay
+ editorCmd: 'auto', // 'auto' = find VSCode automatically (no PATH setup
+ // needed). Or set an absolute path to any editor exe.
+ workspaceFolder: 'auto',// 'auto' = open files inside the game ROOT folder as the
+ // VSCode workspace (so they don't land in whatever window
+ // happened to be open last). Or set an absolute folder.
+ editorReuseWindow: true,// pass -r so VSCode reuses its current window
+ historySize: 80, // how many past text lines to keep
+ captureUiText: true, // capture plugin / menu / UI text drawn via Bitmap.drawText
+ // (so e.g. status-window strings from plugins.js are locatable)
+ liveEdit: true, // allow saving edited text back to the source file in-game
+ liveRefresh: true, // auto-reload data/*.json when files change on disk (NW.js)
+ liveRefreshStableMs: 120,// VSCode save: reload once mtime unchanged this long
+ liveRefreshPollMs: 400, // how often we check data/*.json for external saves
+ liveRefreshHotkey: 'F9',// manual reload: all DB JSON + current map
+ dataDirOverride: null // set an absolute path to force the data dir
+ };
+
+ if (!CFG.enabled) { return; }
+
+ //=========================================================================
+ // Node / environment bootstrap (NW.js exposes require)
+ //=========================================================================
+ var nodeOk = false, fs = null, path = null, cp = null;
+ try {
+ if (typeof require === 'function') {
+ fs = require('fs');
+ path = require('path');
+ cp = require('child_process');
+ nodeOk = true;
+ }
+ } catch (e) { nodeOk = false; }
+
+ var ENGINE = (typeof Utils !== 'undefined' && Utils.RPGMAKER_NAME) || 'MV';
+
+ // Resolve the www root (where index.html lives) -> data dir.
+ // Primary method = exactly how the engine itself resolves paths
+ // (StorageManager.localFileDirectoryPath uses process.mainModule.filename),
+ // which is reliable regardless of the page URL scheme NW.js uses.
+ function resolveWwwRoot() {
+ try {
+ if (nodeOk && process.mainModule && process.mainModule.filename) {
+ return path.dirname(process.mainModule.filename);
+ }
+ } catch (e) { /* fall through */ }
+ try {
+ var href = window.location.href; // file:///C:/.../www/index.html
+ var p = decodeURIComponent(new URL(href).pathname);
+ if (/^\/[A-Za-z]:\//.test(p)) { p = p.slice(1); } // strip leading slash on Windows
+ return path ? path.dirname(p) : p.replace(/\/[^\/]*$/, '');
+ } catch (e2) {
+ return nodeOk ? process.cwd() : '';
+ }
+ }
+ var WWW_ROOT = resolveWwwRoot();
+ var DATA_DIR = CFG.dataDirOverride || (path ? path.join(WWW_ROOT, 'data') : WWW_ROOT + '/data');
+
+ // The game root = folder holding the .exe. On MV that's the PARENT of www/; on MZ
+ // there is no www/, so it IS the resolved root. Used as the VSCode workspace folder
+ // so opened files land in the game's own project window, not the last-used one.
+ var GAME_ROOT = (function () {
+ try {
+ if (path && WWW_ROOT && path.basename(WWW_ROOT).toLowerCase() === 'www') {
+ return path.dirname(WWW_ROOT);
+ }
+ } catch (e) { /* ignore */ }
+ return WWW_ROOT;
+ })();
+
+ function mapFile(mapId) {
+ var n = ('000' + mapId).slice(-3);
+ return path ? path.join(DATA_DIR, 'Map' + n + '.json') : DATA_DIR + '/Map' + n + '.json';
+ }
+ function dataFile(name) {
+ return path ? path.join(DATA_DIR, name) : DATA_DIR + '/' + name;
+ }
+
+ function log() {
+ if (window.console) { console.log.apply(console, ['[TLInspector]'].concat([].slice.call(arguments))); }
+ }
+ log('init', { engine: ENGINE, node: nodeOk, dataDir: DATA_DIR, credits: 'Idea by Sakura · Plugin by Kao_SSS' });
+
+ //=========================================================================
+ // File cache + JSON value locator (path -> char offset -> line:col)
+ //=========================================================================
+ var fileCache = {}; // path -> { mtime, text, lineStarts }
+
+ function readFileCached(file) {
+ if (!nodeOk) { return null; }
+ try {
+ var st = fs.statSync(file);
+ var mt = st.mtimeMs;
+ var c = fileCache[file];
+ if (c && c.mtime === mt) { return c; }
+ var text = fs.readFileSync(file, 'utf8');
+ var lineStarts = [0];
+ for (var i = 0; i < text.length; i++) {
+ if (text.charCodeAt(i) === 10) { lineStarts.push(i + 1); }
+ }
+ c = { mtime: mt, text: text, lineStarts: lineStarts };
+ fileCache[file] = c;
+ return c;
+ } catch (e) { return null; }
+ }
+
+ function offsetToLineCol(c, off) {
+ var ls = c.lineStarts, lo = 0, hi = ls.length - 1, mid;
+ while (lo < hi) {
+ mid = (lo + hi + 1) >> 1;
+ if (ls[mid] <= off) { lo = mid; } else { hi = mid - 1; }
+ }
+ return { line: lo + 1, col: off - ls[lo] + 1 };
+ }
+
+ // --- minimal structural JSON scanner ------------------------------------
+ function skipWs(t, i) {
+ while (i < t.length) {
+ var ch = t.charCodeAt(i);
+ if (ch === 32 || ch === 9 || ch === 10 || ch === 13) { i++; } else { break; }
+ }
+ return i;
+ }
+ function scanString(t, i) { // i at opening " -> returns index after closing "
+ return scanQuotedString(t, i, '"');
+ }
+ function scanQuotedString(t, i, q) {
+ if (!q) { q = t[i]; }
+ if (q !== '"' && q !== "'") { return i; }
+ i++; // past opening quote
+ while (i < t.length) {
+ var ch = t[i];
+ if (ch === '\\') { i += 2; continue; }
+ if (ch === q) { return i + 1; }
+ i++;
+ }
+ return i;
+ }
+ function decodeStringLiteral(t, startOff) {
+ var q = t[startOff];
+ if (q !== '"' && q !== "'") { return null; }
+ var j = startOff + 1, out = '';
+ while (j < t.length) {
+ var ch = t[j];
+ if (ch === '\\') {
+ j++;
+ if (j >= t.length) { return null; }
+ var e = t[j];
+ if (e === 'n') { out += '\n'; }
+ else if (e === 'r') { out += '\r'; }
+ else if (e === 't') { out += '\t'; }
+ else if (e === 'b') { out += '\b'; }
+ else if (e === 'f') { out += '\f'; }
+ else if (e === 'u') {
+ var hex = t.substr(j + 1, 4);
+ if (!/^[0-9a-fA-F]{4}$/.test(hex)) { return null; }
+ out += String.fromCharCode(parseInt(hex, 16));
+ j += 4;
+ } else { out += e; }
+ j++;
+ continue;
+ }
+ if (ch === q) {
+ return { start: startOff, end: j, quote: q, value: out };
+ }
+ out += ch;
+ j++;
+ }
+ return null;
+ }
+ function encodeStringLiteral(text, quoteChar) {
+ if (quoteChar === "'") {
+ return "'" + String(text).replace(/\\/g, '\\\\').replace(/'/g, "\\'")
+ .replace(/\r/g, '\\r').replace(/\n/g, '\\n').replace(/\t/g, '\\t') + "'";
+ }
+ return JSON.stringify(text);
+ }
+ // Scan a line for quoted literals whose decoded value exactly equals expectedText.
+ function findExactStringLiteralOnLine(c, line, expectedText) {
+ if (!c || !line || expectedText == null) { return null; }
+ var lineStart = c.lineStarts[line - 1];
+ if (lineStart == null) { return null; }
+ var lineEnd = line < c.lineStarts.length ? c.lineStarts[line] : c.text.length;
+ var i = lineStart, best = null;
+ while (i < lineEnd) {
+ var ch = c.text[i];
+ if (ch === '"' || ch === "'") {
+ var lit = decodeStringLiteral(c.text, i);
+ if (lit) {
+ if (lit.value === expectedText) { best = lit; }
+ i = lit.end + 1;
+ continue;
+ }
+ }
+ i++;
+ }
+ return best;
+ }
+ // Resolve a safe edit target: the whole string literal whose value matches expectedText.
+ function resolveEditSite(file, line, col, expectedText) {
+ var c = readFileCached(file);
+ if (!c || !line || expectedText == null) { return null; }
+ var lit = findExactStringLiteralOnLine(c, line, expectedText);
+ if (lit) { return lit; }
+ // Fallback: literal containing the column, but only if the WHOLE value matches.
+ var pos = c.lineStarts[line - 1] + Math.max(0, (col || 1) - 1);
+ var lineStart = c.lineStarts[line - 1];
+ var lineEnd = line < c.lineStarts.length ? c.lineStarts[line] : c.text.length;
+ var i = lineStart;
+ while (i < lineEnd) {
+ var ch = c.text[i];
+ if (ch === '"' || ch === "'") {
+ var candidate = decodeStringLiteral(c.text, i);
+ if (candidate) {
+ if (pos > candidate.start && pos < candidate.end &&
+ candidate.value === expectedText) {
+ return candidate;
+ }
+ i = candidate.end + 1;
+ continue;
+ }
+ }
+ i++;
+ }
+ return null;
+ }
+ function readKey(t, i) { // i at opening quote -> { end, value }
+ var start = i + 1, j = i + 1, out = '';
+ while (j < t.length) {
+ var ch = t[j];
+ if (ch === '\\') { out += t[j + 1]; j += 2; continue; }
+ if (ch === '"') { return { end: j + 1, value: out }; }
+ out += ch; j++;
+ }
+ return { end: j, value: out };
+ }
+ function skipValue(t, i) {
+ i = skipWs(t, i);
+ var ch = t[i];
+ if (ch === '"') { return scanString(t, i); }
+ if (ch === '{' || ch === '[') {
+ var open = ch, close = ch === '{' ? '}' : ']', depth = 0;
+ for (; i < t.length; i++) {
+ var c = t[i];
+ if (c === '"') { i = scanString(t, i) - 1; continue; }
+ if (c === open) { depth++; }
+ else if (c === close) { depth--; if (depth === 0) { return i + 1; } }
+ }
+ return i;
+ }
+ // number / true / false / null
+ while (i < t.length && ',}] \t\r\n'.indexOf(t[i]) === -1) { i++; }
+ return i;
+ }
+
+ // Walk `path` (array of string keys / number indices) -> start offset of value, or -1.
+ function locateOffset(t, pathArr) {
+ var i = skipWs(t, 0);
+ for (var s = 0; s < pathArr.length; s++) {
+ var step = pathArr[s];
+ i = skipWs(t, i);
+ if (typeof step === 'number') {
+ if (t[i] !== '[') { return -1; }
+ i = skipWs(t, i + 1);
+ if (t[i] === ']') { return -1; }
+ for (var k = 0; k < step; k++) {
+ i = skipValue(t, i);
+ i = skipWs(t, i);
+ if (t[i] !== ',') { return -1; }
+ i = skipWs(t, i + 1);
+ }
+ // i now at target element value start
+ } else {
+ if (t[i] !== '{') { return -1; }
+ i = skipWs(t, i + 1);
+ var found = false;
+ while (i < t.length && t[i] !== '}') {
+ if (t[i] !== '"') { return -1; }
+ var key = readKey(t, i);
+ i = skipWs(t, key.end);
+ if (t[i] !== ':') { return -1; }
+ i = skipWs(t, i + 1);
+ if (key.value === step) { found = true; break; }
+ i = skipValue(t, i);
+ i = skipWs(t, i);
+ if (t[i] === ',') { i = skipWs(t, i + 1); }
+ }
+ if (!found) { return -1; }
+ }
+ }
+ return skipWs(t, i);
+ }
+
+ var lineCache = {}; // file::pathKey::mtime -> {line,col}
+ function locateLine(file, pathArr) {
+ if (!file || !pathArr || !nodeOk) { return null; }
+ var c = readFileCached(file);
+ if (!c) { return null; }
+ var key = file + '::' + pathArr.join('/') + '::' + c.mtime;
+ if (lineCache[key]) { return lineCache[key]; }
+ var off = locateOffset(c.text, pathArr);
+ var res = off >= 0 ? offsetToLineCol(c, off) : null;
+ lineCache[key] = res;
+ return res;
+ }
+
+ function invalidateFileCache(file) {
+ delete fileCache[file];
+ Object.keys(lineCache).forEach(function (k) {
+ if (k.indexOf(file + '::') === 0) { delete lineCache[k]; }
+ });
+ dupIndex = null;
+ }
+
+ function writeStringAtOffset(file, startOff, newText, expectedOld, quoteChar) {
+ var c = readFileCached(file);
+ if (!c) { return { ok: false, err: 'read failed' }; }
+ var t = c.text;
+ var lit = decodeStringLiteral(t, startOff);
+ if (!lit) { return { ok: false, err: 'not a complete string literal' }; }
+ if (expectedOld != null && lit.value !== expectedOld) {
+ return { ok: false, err: 'on-disk text differs from the captured line — use VSCode instead' };
+ }
+ var q = quoteChar || lit.quote;
+ var repl = encodeStringLiteral(newText, q);
+ var merged = t.slice(0, lit.start) + repl + t.slice(lit.end + 1);
+ try {
+ fs.writeFileSync(file, merged, 'utf8');
+ } catch (e) { return { ok: false, err: String(e.message || e) }; }
+ invalidateFileCache(file);
+ markSelfWrite(file);
+ return { ok: true };
+ }
+
+ function writeAtPath(file, pathArr, newText, expectedOld) {
+ var c = readFileCached(file);
+ if (!c) { return { ok: false, err: 'read failed' }; }
+ var off = locateOffset(c.text, pathArr);
+ if (off < 0) { return { ok: false, err: 'could not locate value' }; }
+ if (c.text[off] !== '"') {
+ return { ok: false, err: 'JSON value at path is not a string' };
+ }
+ return writeStringAtOffset(file, off, newText, expectedOld, '"');
+ }
+
+ function writeAtEditSite(file, site, newText, expectedOld) {
+ if (!site) { return { ok: false, err: 'no edit target' }; }
+ return writeStringAtOffset(file, site.start, newText, expectedOld, site.quote);
+ }
+
+ function memoryRootForFile(file) {
+ if (!path) { return null; }
+ var fname = path.basename(file);
+ if (/^Map\d+\.json$/i.test(fname)) {
+ if (typeof $dataMap === 'undefined' || !$dataMap) { return null; }
+ if (typeof $gameMap !== 'undefined' && $gameMap && $gameMap.mapId) {
+ var mapId = parseInt(fname.match(/\d+/)[0], 10);
+ if ($gameMap.mapId() !== mapId) { return null; }
+ }
+ return $dataMap;
+ }
+ if (fname === 'CommonEvents.json') {
+ return (typeof $dataCommonEvents !== 'undefined') ? $dataCommonEvents : null;
+ }
+ if (fname === 'Troops.json') {
+ return (typeof $dataTroops !== 'undefined') ? $dataTroops : null;
+ }
+ return null;
+ }
+
+ function setAtPath(root, pathArr, val) {
+ var o = root;
+ for (var i = 0; i < pathArr.length - 1; i++) {
+ o = o[pathArr[i]];
+ if (o == null) { return false; }
+ }
+ o[pathArr[pathArr.length - 1]] = val;
+ return true;
+ }
+
+ function applyLiveMessage(rec, newText) {
+ if (typeof $gameMessage === 'undefined' || !$gameMessage) { return; }
+ var busy = $gameMessage.isBusy && $gameMessage.isBusy();
+ if (!busy || rec.group !== currentGroup || currentGroup === -1) { return; }
+ try {
+ if ($gameMessage._texts) {
+ for (var i = 0; i < $gameMessage._texts.length; i++) {
+ if ($gameMessage._texts[i] === rec.text) { $gameMessage._texts[i] = newText; }
+ }
+ }
+ if ($gameMessage._choices) {
+ for (var j = 0; j < $gameMessage._choices.length; j++) {
+ if ($gameMessage._choices[j] === rec.text) { $gameMessage._choices[j] = newText; }
+ }
+ }
+ } catch (e) { /* ignore */ }
+ }
+
+ function canEditRecord(rec) {
+ if (!CFG.liveEdit || !nodeOk || rec._ambig) { return false; }
+ if (rec.file && rec.tail) { return true; }
+ if (rec.file && rec._editSite) { return true; }
+ return false;
+ }
+
+ function bindEditSite(rec, file, line, col) {
+ var site = resolveEditSite(file, line, col, rec.text);
+ if (!site) {
+ return false;
+ }
+ rec.file = file;
+ rec._editSite = site;
+ rec._editLine = line;
+ rec._editCol = col || 1;
+ delete rec._editQuoted;
+ return true;
+ }
+
+ function isDataJsonFile(file) {
+ if (!file || !path) { return false; }
+ try {
+ var ap = absPath(file);
+ var dir = absPath(DATA_DIR);
+ if (process.platform === 'win32') {
+ return ap.toLowerCase().indexOf(dir.toLowerCase()) === 0 && /\.json$/i.test(ap);
+ }
+ return ap.indexOf(dir) === 0 && /\.json$/i.test(ap);
+ } catch (e) { return false; }
+ }
+
+ function applyReloadAfterOverlaySave(file) {
+ if (!CFG.liveRefresh || !isDataJsonFile(file)) { return false; }
+ return reloadFile(file, { silent: true });
+ }
+
+ function saveRecordEdit(rec, newText) {
+ if (!nodeOk) { toast('Live edit requires the desktop NW.js build'); return false; }
+ if (newText === rec.text) { toast('No changes'); return false; }
+ var expected = rec.text;
+ var r;
+ if (rec.file && rec.tail) {
+ r = writeAtPath(rec.file, rec.tail, newText, expected);
+ } else if (rec.file && rec._editSite) {
+ r = writeAtEditSite(rec.file, rec._editSite, newText, expected);
+ } else if (rec.file && (rec._editLine || rec.uiLine)) {
+ var site = resolveEditSite(rec.file, rec._editLine || rec.uiLine,
+ rec._editCol || 1, expected);
+ if (!site) {
+ toast('Cannot safely edit — pick an exact quoted string match first');
+ return false;
+ }
+ rec._editSite = site;
+ r = writeAtEditSite(rec.file, site, newText, expected);
+ } else {
+ toast('Cannot edit — source location unresolved');
+ return false;
+ }
+ if (!r.ok) { toast('Save failed: ' + (r.err || '?')); log('save', r); return false; }
+ rec.text = newText;
+ rec._key = 't\u0000' + rec.kind + '\u0000' + newText + '\u0000' + (rec.file || '') +
+ '\u0000' + (rec.tail ? JSON.stringify(rec.tail) : '');
+ applyLiveMessage(rec, newText);
+ var reloaded = applyReloadAfterOverlaySave(rec.file);
+ if (!reloaded) { refreshSceneWindows(); }
+ var note = rec.kind === 'ui' && !reloaded ? ' — UI may need scene change' : '';
+ toast(reloaded ? 'Saved & reloaded' : ('Saved to file' + note));
+ scheduleRefresh();
+ return true;
+ }
+
+ function prepareUiEditTarget(rec, cb) {
+ if (rec.tail) { cb(true); return; }
+ var s = searchFilesSmart(rec.text);
+ if (s.partial) {
+ toast('Partial file match — live edit needs the full string; use VSCode');
+ cb(false);
+ return;
+ }
+ if (s.results.length === 1) {
+ if (bindEditSite(rec, s.results[0].file, s.results[0].line, s.results[0].col)) {
+ cb(true);
+ return;
+ }
+ toast('Match is not a complete string value — use VSCode');
+ cb(false);
+ return;
+ }
+ if (s.results.length > 1) {
+ toast(s.results.length + ' matches — use "locate in files" and pick an exact match');
+ cb(false);
+ return;
+ }
+ toast('No file match for this UI text');
+ cb(false);
+ }
+
+ //=========================================================================
+ // Provenance: map a running event command-list to its source file + path
+ //=========================================================================
+ function resolveList(list) {
+ try {
+ if (typeof $dataMap !== 'undefined' && $dataMap && $dataMap.events &&
+ typeof $gameMap !== 'undefined' && $gameMap) {
+ var evs = $dataMap.events;
+ for (var e = 0; e < evs.length; e++) {
+ var ev = evs[e];
+ if (!ev || !ev.pages) { continue; }
+ for (var p = 0; p < ev.pages.length; p++) {
+ if (ev.pages[p].list === list) {
+ return { file: mapFile($gameMap.mapId()),
+ prefix: ['events', e, 'pages', p, 'list'],
+ label: 'Map' + ('000' + $gameMap.mapId()).slice(-3) + ' ev' + e };
+ }
+ }
+ }
+ }
+ if (typeof $dataCommonEvents !== 'undefined' && $dataCommonEvents) {
+ for (var c = 0; c < $dataCommonEvents.length; c++) {
+ var ce = $dataCommonEvents[c];
+ if (ce && ce.list === list) {
+ return { file: dataFile('CommonEvents.json'),
+ prefix: [c, 'list'], label: 'CommonEvent ' + c };
+ }
+ }
+ }
+ if (typeof $gameTroop !== 'undefined' && $gameTroop && $gameTroop._troopId &&
+ typeof $dataTroops !== 'undefined' && $dataTroops) {
+ var tr = $dataTroops[$gameTroop._troopId];
+ if (tr && tr.pages) {
+ for (var tp = 0; tp < tr.pages.length; tp++) {
+ if (tr.pages[tp].list === list) {
+ return { file: dataFile('Troops.json'),
+ prefix: [$gameTroop._troopId, 'pages', tp, 'list'],
+ label: 'Troop ' + $gameTroop._troopId };
+ }
+ }
+ }
+ }
+ } catch (e) { log('resolveList error', e); }
+ return null;
+ }
+
+ function pathToString(arr) {
+ var s = '';
+ for (var i = 0; i < arr.length; i++) {
+ s += typeof arr[i] === 'number' ? '[' + arr[i] + ']' : (i ? '.' : '') + arr[i];
+ }
+ return s;
+ }
+
+ //=========================================================================
+ // Record store
+ //=========================================================================
+ var groupSeq = 0;
+ var currentGroup = -1;
+ var textRecords = []; // {kind,text,file,prefix,tail,group,ts,_key}
+ var imageTriggers = {}; // picture filename -> {file,prefix,tail,label}
+ var BUMP_GUARD_MS = 1000;
+
+ function findByKey(key) {
+ for (var i = 0; i < textRecords.length; i++) { if (textRecords[i]._key === key) { return i; } }
+ return -1;
+ }
+ // Text seen again -> move it to the top (newest) so it never feels "undetected"
+ // when it was buried in history. Guard against churn from per-frame UI redraws.
+ function seenExisting(i, group) {
+ var r = textRecords[i], now = Date.now();
+ if (now - r.ts < BUMP_GUARD_MS) {
+ r.ts = now; if (group != null && group !== -1) { r.group = group; }
+ return; // recently seen: keep its position
+ }
+ textRecords.splice(i, 1);
+ r.ts = now; if (group != null && group !== -1) { r.group = group; }
+ textRecords.push(r);
+ scheduleRefresh();
+ }
+
+ function pushText(kind, prov, tail, text, group, srcIndex, srcEventId) {
+ if (text == null || text === '') { return; }
+ var file = prov ? prov.file : null;
+ var key = 't\u0000' + kind + '\u0000' + text + '\u0000' + (file || '') + '\u0000' + (tail ? JSON.stringify(tail) : '');
+ var i = findByKey(key);
+ if (i >= 0) { seenExisting(i, group); return; }
+ textRecords.push({
+ kind: kind, text: text, file: file,
+ prefix: prov ? prov.prefix : null,
+ tail: tail, label: prov ? prov.label : '(unresolved)',
+ group: group, ts: Date.now(),
+ srcIndex: (srcIndex == null ? null : srcIndex),
+ srcEventId: (srcEventId == null ? 0 : srcEventId),
+ _key: key
+ });
+ while (textRecords.length > CFG.historySize) { textRecords.shift(); }
+ scheduleRefresh();
+ }
+
+ function captureFromList(list, startIdx, textCode, eventId) {
+ var prov = resolveList(list);
+ var group = ++groupSeq;
+ currentGroup = group;
+ var i = startIdx + 1;
+ while (i < list.length && list[i] && list[i].code === textCode) {
+ pushText('text', prov, prov ? prov.prefix.concat([i, 'parameters', 0]) : null,
+ list[i].parameters[0], group, i, eventId);
+ i++;
+ }
+ // inline "Show Choices" immediately following the text block
+ if (list[i] && list[i].code === 102) {
+ captureChoices(list, i, prov, group, eventId);
+ }
+ }
+
+ function captureChoices(list, idx, prov, group, eventId) {
+ if (prov === undefined) { prov = resolveList(list); }
+ if (group === undefined) { group = ++groupSeq; currentGroup = group; }
+ var choices = list[idx] && list[idx].parameters && list[idx].parameters[0];
+ if (!Array.isArray(choices)) { return; }
+ for (var n = 0; n < choices.length; n++) {
+ pushText('choice', prov,
+ prov ? prov.prefix.concat([idx, 'parameters', 0, n]) : null,
+ choices[n], group, idx, eventId);
+ }
+ }
+
+ //=========================================================================
+ // Hooks: text
+ //=========================================================================
+ if (typeof Game_Interpreter !== 'undefined') {
+ var GI = Game_Interpreter.prototype;
+
+ var _c101 = GI.command101;
+ GI.command101 = function () {
+ var list = this._list, start = this._index;
+ var r = _c101.apply(this, arguments);
+ try { if (this._index !== start) { captureFromList(list, start, 401, this._eventId); } }
+ catch (e) { log('c101', e); }
+ return r;
+ };
+
+ var _c105 = GI.command105;
+ GI.command105 = function () {
+ var list = this._list, start = this._index;
+ var r = _c105.apply(this, arguments);
+ try { if (this._index !== start) { captureFromList(list, start, 405, this._eventId); } }
+ catch (e) { log('c105', e); }
+ return r;
+ };
+
+ var _c102 = GI.command102;
+ GI.command102 = function () {
+ // A STANDALONE "Show Choices" (not following a message — those are consumed
+ // inside command101) doesn't advance this._index within the method: MV bumps
+ // it later in executeCommand, MZ takes params as an arg. So gating on an index
+ // change missed standalone choice menus entirely. Instead capture exactly when
+ // the choices get set up: this call, while the message box wasn't already busy.
+ var list = this._list, idx = this._index;
+ var wasBusy = (typeof $gameMessage !== 'undefined') && $gameMessage &&
+ $gameMessage.isBusy && $gameMessage.isBusy();
+ var r = _c102.apply(this, arguments);
+ try {
+ if (!wasBusy && list && list[idx] && list[idx].code === 102) {
+ captureChoices(list, idx, undefined, undefined, this._eventId);
+ }
+ } catch (e) { log('c102', e); }
+ return r;
+ };
+
+ // Show Picture -> remember which command triggered each picture file
+ var _c231 = GI.command231;
+ GI.command231 = function () {
+ try {
+ var list = this._list, idx = this._index;
+ var name = this._params && this._params[1];
+ if (name) {
+ var prov = resolveList(list);
+ imageTriggers['img/pictures/' + name + '.png'] = {
+ file: prov ? prov.file : null,
+ path: prov ? prov.prefix.concat([idx, 'parameters', 1]) : null,
+ label: prov ? prov.label : '(unresolved)'
+ };
+ }
+ } catch (e) { log('c231', e); }
+ return _c231.apply(this, arguments);
+ };
+ }
+
+ // Clear "live" marker when the message box closes
+ if (typeof Game_Message !== 'undefined') {
+ var _clear = Game_Message.prototype.clear;
+ Game_Message.prototype.clear = function () {
+ currentGroup = -1;
+ scheduleRefresh();
+ return _clear.apply(this, arguments);
+ };
+ }
+
+ // Capture plugin / menu / UI text. The true chokepoint is Bitmap.drawText:
+ // Window_Base.drawText/drawTextEx AND direct `this.contents.drawText(...)` calls
+ // inside plugins all funnel through it. We skip while a message box is showing
+ // (that text is captured precisely via the interpreter hooks) and de-duplicate by
+ // a digit-normalized key so a ticking value ("Money: 12"/"Money: 13") logs once.
+ if (CFG.captureUiText && typeof Bitmap !== 'undefined' && Bitmap.prototype.drawText) {
+ // Direct draws (Window_Base.drawText, plugin `this.contents.drawText(...)`).
+ // drawTextEx renders one character per call here, so single chars are dropped.
+ var _bmDrawText = Bitmap.prototype.drawText;
+ Bitmap.prototype.drawText = function (text) {
+ try { maybeCaptureUiText(text); } catch (e) { /* ignore */ }
+ return _bmDrawText.apply(this, arguments);
+ };
+ }
+ if (CFG.captureUiText && typeof Window_Base !== 'undefined' && Window_Base.prototype.drawTextEx) {
+ // Escape-processed text (item descriptions, help text, names) arrives here as a
+ // whole string before being drawn character-by-character.
+ var _drawTextEx = Window_Base.prototype.drawTextEx;
+ Window_Base.prototype.drawTextEx = function (text) {
+ try { maybeCaptureUiText(text); } catch (e) { /* ignore */ }
+ return _drawTextEx.apply(this, arguments);
+ };
+ }
+ function maybeCaptureUiText(text) {
+ if (typeof text !== 'string') { return; }
+ if (text.replace(/\s/g, '').length < 2) { return; } // drop single chars (per-char draws)
+ if (!/[^\s\d.,:%/+\-()]/.test(text)) { return; } // require a real letter
+ if (typeof $gameMessage !== 'undefined' && $gameMessage && $gameMessage.isBusy && $gameMessage.isBusy()) { return; }
+ var key = 'ui ' + text.replace(/\d+/g, '#'); // digit-normalized: "Money: 12/13" -> one entry
+ var i = findByKey(key);
+ if (i >= 0) { seenExisting(i, -1); return; }
+ var site = firstUserFrame(new Error().stack);
+ textRecords.push({ kind: 'ui', text: text, file: site.file, prefix: null,
+ tail: null, label: site.label, uiLine: site.line, group: -1, ts: Date.now(), _key: key });
+ while (textRecords.length > CFG.historySize) { textRecords.shift(); }
+ scheduleRefresh();
+ }
+
+ function firstUserFrame(stack) {
+ var lines = (stack || '').split('\n');
+ for (var i = 1; i < lines.length; i++) {
+ var m = lines[i].match(/(js\/plugins\/[^):]+|js\/[^):]+):(\d+):(\d+)/);
+ if (m && m[1].indexOf('TLInspector') === -1) {
+ var f = m[1];
+ return { file: path ? path.join(WWW_ROOT, f) : WWW_ROOT + '/' + f,
+ line: parseInt(m[2], 10), label: f + ':' + m[2] };
+ }
+ }
+ return { file: null, line: null, label: '(unknown)' };
+ }
+
+ //=========================================================================
+ // Hooks: images (load log with call-site)
+ //=========================================================================
+ var imageLog = []; // {url, label, ts, bitmap}
+ if (typeof ImageManager !== 'undefined') {
+ var _loadBitmap = ImageManager.loadBitmap;
+ ImageManager.loadBitmap = function (folder, filename) {
+ var bm = _loadBitmap.apply(this, arguments);
+ try {
+ if (filename) {
+ var url = folder + filename + '.png';
+ var last = imageLog[imageLog.length - 1];
+ if (!last || last.url !== url) {
+ var site = firstUserFrame(new Error().stack);
+ imageLog.push({ url: url, label: site.label, ts: Date.now(), bitmap: bm });
+ while (imageLog.length > CFG.historySize) { imageLog.shift(); }
+ } else if (last && !last.bitmap) { last.bitmap = bm; }
+ }
+ } catch (e) { /* ignore */ }
+ return bm;
+ };
+ }
+
+ //=========================================================================
+ // Live on-screen image collection (walk the sprite tree)
+ //=========================================================================
+ function collectOnScreenImages() {
+ var out = [], seen = {};
+ var scene = (typeof SceneManager !== 'undefined') && SceneManager._scene;
+ if (!scene) { return out; }
+ var canvas = getGameCanvas();
+ var rect = canvas ? canvas.getBoundingClientRect() : null;
+ var scale = rect && typeof Graphics !== 'undefined' && Graphics.width ?
+ rect.width / Graphics.width : 1;
+
+ (function walk(obj) {
+ if (!obj) { return; }
+ if (obj.visible === false) { return; }
+ var bmp = obj.bitmap;
+ if (bmp && bmp._url) {
+ var vis = ('worldVisible' in obj) ? obj.worldVisible : true;
+ if (vis) {
+ var b = null;
+ try { b = obj.getBounds && obj.getBounds(); } catch (e) { b = null; }
+ if (b && b.width > 0 && b.height > 0) {
+ if (!seen[bmp._url]) { seen[bmp._url] = true; }
+ out.push({
+ url: decodeUrl(bmp._url),
+ bitmap: bmp,
+ screen: rect ? {
+ x: rect.left + b.x * scale, y: rect.top + b.y * scale,
+ w: b.width * scale, h: b.height * scale
+ } : null
+ });
+ }
+ }
+ }
+ var kids = obj.children;
+ if (kids) { for (var i = 0; i < kids.length; i++) { walk(kids[i]); } }
+ })(scene);
+ return out;
+ }
+
+ function getGameCanvas() {
+ if (typeof Graphics !== 'undefined') {
+ if (Graphics._canvas) { return Graphics._canvas; }
+ if (Graphics._renderer && Graphics._renderer.view) { return Graphics._renderer.view; }
+ }
+ return document.querySelector('canvas');
+ }
+
+ // Bitmap URLs are percent-encoded (ImageManager uses encodeURIComponent on the
+ // filename), e.g. "img/pictures/%E3%83%90%E3%82%B9.png" for "バス". Decode so the
+ // path matches the real file on disk.
+ function decodeUrl(u) {
+ if (!u) { return u; }
+ try { return decodeURIComponent(u); } catch (e) { return u; }
+ }
+
+ function urlToAbsPath(url) {
+ if (!url) { return null; }
+ var rel = decodeUrl(url).replace(/^\.?\//, '');
+ return path ? path.join(WWW_ROOT, rel) : WWW_ROOT + '/' + rel;
+ }
+
+ //=========================================================================
+ // External actions (VSCode / file explorer)
+ //=========================================================================
+ // Auto-locate VSCode so the user never has to touch the system PATH.
+ var _editorPath = null;
+ function detectEditor() {
+ if (_editorPath !== null) { return _editorPath; }
+ if (CFG.editorCmd && CFG.editorCmd !== 'auto') { _editorPath = CFG.editorCmd; return _editorPath; }
+ if (nodeOk) {
+ var env = process.env, plat = process.platform, cands = [];
+ if (plat === 'win32') {
+ [env.LOCALAPPDATA, env.ProgramFiles, env['ProgramFiles(x86)']].forEach(function (b) {
+ if (!b) { return; }
+ cands.push(path.join(b, 'Programs', 'Microsoft VS Code', 'Code.exe'));
+ cands.push(path.join(b, 'Microsoft VS Code', 'Code.exe'));
+ cands.push(path.join(b, 'Programs', 'Microsoft VS Code Insiders', 'Code - Insiders.exe'));
+ cands.push(path.join(b, 'Programs', 'cursor', 'Cursor.exe'));
+ cands.push(path.join(b, 'cursor', 'Cursor.exe'));
+ });
+ } else if (plat === 'darwin') {
+ cands.push('/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code');
+ cands.push('/Applications/Cursor.app/Contents/MacOS/Cursor');
+ } else {
+ cands.push('/usr/bin/code', '/usr/share/code/bin/code', '/snap/bin/code', '/usr/bin/cursor');
+ }
+ for (var i = 0; i < cands.length; i++) {
+ try { if (fs.existsSync(cands[i])) { _editorPath = cands[i]; log('editor found', _editorPath); return _editorPath; } }
+ catch (e) { /* ignore */ }
+ }
+ }
+ _editorPath = 'code'; // fall back to PATH lookup
+ return _editorPath;
+ }
+
+ function openInEditor(file, line, col) {
+ if (!nodeOk || !file) { toast('No file resolved for this entry'); return; }
+ var loc = file + (line ? ':' + line + (col ? ':' + col : '') : '');
+ var cmd = detectEditor();
+ // Pass the game folder so VSCode opens (or reuses) THAT workspace and reveals the
+ // file in it, instead of dropping the file into whatever window was last focused.
+ // Passing a folder makes VSCode route to the matching window itself, so we drop the
+ // -r "reuse last window" flag (which is what caused the wrong-workspace behavior).
+ var ws = (CFG.workspaceFolder && CFG.workspaceFolder !== 'auto') ? CFG.workspaceFolder : GAME_ROOT;
+ var done = function (err) {
+ if (err) { toast('Editor launch failed; set CFG.editorCmd to your editor path.'); log('editor', err); }
+ };
+ try {
+ if (cmd === 'code') {
+ // PATH fallback (code.cmd) needs a shell. The command line does NOT
+ // start with a quote (program name has no spaces) and each path IS
+ // quoted, so spaces in the paths are preserved instead of being split
+ // into separate arguments.
+ var folder = ws ? '"' + ws + '" ' : '';
+ cp.exec(cmd + ' ' + folder + '-g "' + loc + '"', { cwd: WWW_ROOT }, done);
+ } else {
+ // Resolved absolute editor exe: run with no shell. Node quotes each
+ // argument, so a path with spaces stays a single argument.
+ var args = [];
+ if (ws) { args.push(ws); }
+ args.push('-g', loc);
+ cp.execFile(cmd, args, { cwd: WWW_ROOT }, done);
+ }
+ } catch (e) { toast('Editor launch failed'); log(e); }
+ }
+
+ function revealInExplorer(absPath) {
+ if (!nodeOk || !absPath) { return; }
+ try {
+ if (process.platform === 'win32') {
+ cp.execFile('explorer.exe', ['/select,', absPath]);
+ } else if (process.platform === 'darwin') {
+ cp.execFile('open', ['-R', absPath]);
+ } else {
+ cp.execFile('xdg-open', [path.dirname(absPath)]);
+ }
+ } catch (e) { log('reveal', e); }
+ }
+
+ //=========================================================================
+ // Image decryption (RPGMaker MV .rpgmvp / MZ .png_) + encryption-aware reveal
+ //=========================================================================
+ var ENC = {
+ on: function () {
+ try { return typeof $dataSystem !== 'undefined' && $dataSystem && !!$dataSystem.hasEncryptedImages; }
+ catch (e) { return false; }
+ },
+ keyBytes: function () {
+ try { return ($dataSystem.encryptionKey || '').match(/.{2}/g) || []; }
+ catch (e) { return []; }
+ },
+ // Candidate on-disk encrypted siblings for a plain .png path. MV renames the
+ // extension (image.png -> image.rpgmvp); MZ keeps the name and appends "_"
+ // (image.png -> image.png_). The XOR algorithm is identical for both.
+ encPaths: function (absPng) {
+ return [absPng.replace(/\.png$/i, '.rpgmvp'), // MV
+ absPng + '_']; // MZ (image.png -> image.png_)
+ }
+ };
+
+ // Returns {path, created} for a real .png on disk, decrypting the encrypted
+ // sibling (.rpgmvp on MV, .png_ on MZ) if the plain .png does not exist.
+ // Replicates the engine Decrypter (16-byte fake header + XOR of the next 16
+ // bytes with the encryption key).
+ function decryptToSibling(absPng) {
+ try {
+ if (fs.existsSync(absPng)) { return { path: absPng, created: false }; }
+ var cands = ENC.encPaths(absPng), enc = null;
+ for (var c = 0; c < cands.length; c++) {
+ if (fs.existsSync(cands[c])) { enc = cands[c]; break; }
+ }
+ if (!enc) { return null; }
+ var buf = fs.readFileSync(enc);
+ var body = buf.slice(16); // drop the fake PNG header
+ var key = ENC.keyBytes();
+ for (var i = 0; i < 16 && i < key.length; i++) {
+ body[i] = body[i] ^ parseInt(key[i], 16);
+ }
+ fs.writeFileSync(absPng, body);
+ return { path: absPng, created: true };
+ } catch (e) { log('decrypt', e); return null; }
+ }
+
+ function revealImage(absPng) {
+ if (!nodeOk || !absPng) { return; }
+ var r = decryptToSibling(absPng);
+ if (!r) { toast('Not found on disk (.png / .rpgmvp / .png_)'); return; }
+ toast(r.created ? 'Decrypted -> revealing .png' : 'Revealing');
+ revealInExplorer(r.path);
+ }
+
+ //=========================================================================
+ // Thumbnails (from the already-decoded in-memory Bitmap, so encrypted
+ // images preview fine without touching disk) + a shared image-row builder.
+ //=========================================================================
+ var thumbCache = {}; // url -> dataURL
+ function bitmapThumb(bmp, url) {
+ if (url && thumbCache[url]) { return thumbCache[url]; }
+ try {
+ if (!bmp || (bmp.isReady && !bmp.isReady())) { return null; }
+ var src = (bmp._image && bmp._image.width) ? bmp._image
+ : (bmp.canvas || bmp._canvas || bmp.__canvas);
+ var sw = bmp.width, sh = bmp.height;
+ if (!src || !sw || !sh) { return null; }
+ var max = 60, scale = Math.min(max / sw, max / sh, 1);
+ var w = Math.max(1, Math.round(sw * scale)), h = Math.max(1, Math.round(sh * scale));
+ var c = document.createElement('canvas');
+ c.width = w; c.height = h;
+ c.getContext('2d').drawImage(src, 0, 0, sw, sh, 0, 0, w, h);
+ var data = c.toDataURL('image/png');
+ if (url) { thumbCache[url] = data; }
+ return data;
+ } catch (e) { return null; }
+ }
+
+ function buildImgRow(url, bitmap, opts) {
+ opts = opts || {};
+ var abs = urlToAbsPath(url);
+ var row = document.createElement('div');
+ row.className = 'tl-row';
+ var thumb = bitmapThumb(bitmap, url);
+ row.innerHTML =
+ '