diff --git a/README.md b/README.md
index 97f0af4..7e7da00 100644
--- a/README.md
+++ b/README.md
@@ -324,8 +324,8 @@ Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine sel
| Step | Action |
|------|--------|
-| **0 Project** | Select the game folder, **Unpack** the `.wolf` archives into a loose `Data/` folder, then **Extract text** (maps, common events, databases, `Game.dat`, external event text, and the project-wide name glossary). Everything is staged into `files/` for translation. |
-| **1 Glossary** | Translate the name glossary (`names.json`) first. WOLF references item/skill/enemy names by value across many files, so doing names first keeps them consistent. |
+| **0 Project** | Select the game folder, **Unpack** the `.wolf` archives into a loose `Data/` folder, then **Extract text** (maps, common events, databases, `Game.dat`, external event text, and the project-wide name glossary). Everything is staged into `files/` for translation. Finally, **Set up git tracking** by copying the `gameupdate/` folder (updater scripts, `patch.sh`/`patch.ps1`, and a `.gitignore` that excludes the original game files) into the game root, so the translation project can be tracked with git and later shipped as a git-based patch players apply themselves. |
+| **1 Glossary** | Build both glossaries before translating. **1a - Character & Worldbuilding Glossary (`vocab.txt`):** copy the WOLF-tailored prompt into Cursor/Copilot with the extracted `files/` JSON, let it discover character names, speech registers, and lore terms, then paste the result into the in-tab editor and save (the shared `vocab.txt` is used by every translation batch to keep names and voice consistent). **1b - Name Value Glossary (`names.json`):** translate the name glossary first, since WOLF references item/skill/enemy names by value across many files. |
| **2 Translate** | Run the `Wolf RPG (WolfDawn)` module over `files/`. Only the `text` fields are filled in; `source` is preserved so injection can verify each line. |
| **3 Inject** | Write the translations back into the `Data/` binaries. The name glossary is applied consistently across every file. Toggle `--en-punct` (convert Japanese punctuation to ASCII) and `--allow-code-drift` (relax the inline-code guard) as needed. Use **Check name consistency** to catch names translated differently across files. |
| **4 Package** | Either run from the loose `Data/` folder (backs up `Data.wolf` → `Data.wolf.bak`), or **Repack** a fresh `Data.wolf`, inheriting the original archive's encryption. |
diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py
index ec88d97..eff5c8a 100644
--- a/gui/wolf_workflow_tab.py
+++ b/gui/wolf_workflow_tab.py
@@ -5,8 +5,10 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
(see util/wolfdawn):
Step 0 Project - select game folder, unpack .wolf archives, extract text
- (strings-extract per file + names-extract) into files/
- Step 1 Glossary - translate the project-wide name glossary first
+ (strings-extract per file + names-extract) into files/, and
+ copy the gameupdate/ patch files (incl. .gitignore) for git tracking
+ Step 1 Glossary - build vocab.txt (characters/terms) and translate the
+ project-wide name-value glossary first
Step 2 Translate - run the "Wolf RPG (WolfDawn)" module over files/
Step 3 Inject - inject translations + names back into the Data/ binaries
Step 4 Package - run from a loose Data/ folder, or repack Data.wolf
@@ -26,7 +28,9 @@ from pathlib import Path
from PyQt5.QtCore import Qt, QSettings, QThread, QTimer, pyqtSignal
from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import (
+ QApplication,
QCheckBox,
+ QComboBox,
QFileDialog,
QHBoxLayout,
QLabel,
@@ -41,13 +45,109 @@ from PyQt5.QtWidgets import (
QWidget,
)
+from gui.translation_tab import (
+ BATCH_COLLECT_LIVE_CHARGE_NOTE,
+ BATCH_MODE_BENEFIT_NOTE,
+ BATCH_MODE_LABEL,
+)
from gui.workflow_tab import _make_btn, _make_hr, _make_section_label
from util.project_scanner import detect_wolf_layout
+from util.vocab import read_game_vocab, write_game_vocab
+
+# Workflow-level label for the non-batch (live) translation path. The Translation
+# tab's own mode is called "Translate"; _workflow_mode_text() maps to that.
+_TL_NORMAL_LABEL = "Normal Translate"
MANIFEST_NAME = "manifest.json"
NAMES_JSON = "names.json"
WORK_DIR_NAME = "wolf_json"
+# Glossary-discovery prompt for Copilot / Cursor, tailored to WolfDawn's extracted
+# JSON (source/text pairs staged in files/). Produces the same two-section format
+# the shared vocab.txt expects, so the AI output pastes straight into the editor.
+_WOLF_GLOSSARY_PROMPT = (
+ "You are an expert Japanese WOLF RPG Editor game analyst building a translation glossary.\n"
+ "\n"
+ "\n"
+ "Extract named characters and lore-specific worldbuilding terms from this game's extracted "
+ "text. Produce a structured glossary in the exact format specified below. It will be loaded "
+ "directly into a translation tool, so strict format compliance is required.\n"
+ "\n"
+ "\n"
+ "--- attach the extracted JSON in files/ here before continuing ---\n"
+ "\n"
+ "\n"
+ "The files/ folder holds WolfDawn extractions as JSON. Each file is a list of entries with a\n"
+ "'source' field (the original Japanese) and a 'text' field (initially empty/identical).\n"
+ "Analyse the 'source' fields only. The files are:\n"
+ " - DataBase.project.json, CDataBase.project.json, SysDatabase.project.json — databases:\n"
+ " richest, small source of character/actor names, classes, factions, and lore titles.\n"
+ " - CommonEvent.dat.json — common events: dialogue and system text.\n"
+ " - Game.dat.json — game/system strings (title, terms).\n"
+ " - \n"
+ "\n"
+ "\n"
+ "Map files can be extremely large. Do NOT read them sequentially — you will hit context "
+ "limits. Instead:\n"
+ "1. Read the DataBase.project.json files IN FULL first — small and the best source of names.\n"
+ "2. For large map files, SEARCH (grep) the 'source' fields instead of reading sequentially. "
+ "Prioritise dialogue because it is the best evidence for character voice:\n"
+ " - Speaker patterns such as 【Name】, [Name], Name「…」, Name:, and at the start of a line\n"
+ " - Katakana clusters or kanji compound proper nouns that recur across dialogue\n"
+ " Scan the lowest-numbered maps first — early maps have the most story content.\n"
+ "3. Stop once you stop finding new names or terms. Do not pad the output.\n"
+ "\n"
+ "\n"
+ "\n"
+ "Apply to both sections:\n"
+ "- Separator: use a plain hyphen-minus (-). Never use an em dash or en dash. "
+ "The translation tool only recognises the plain hyphen.\n"
+ "- Descriptions must be entirely in English. Refer to other characters by English name only.\n"
+ "- Never give two spelling options (e.g. 'Sylfia / Sylphia' is wrong). Commit to one translation.\n"
+ "\n"
+ "# Game Characters — rules:\n"
+ "- Discover named characters from the databases and dialogue. Skip unnamed NPCs, generic "
+ "enemies, and narration-only entries.\n"
+ "- For each character include: gender, role, speech register, and personality. Note if the "
+ "name is player-chosen (a hero/actor whose name comes from a variable or input prompt).\n"
+ "\n"
+ "# Worldbuilding Terms — rules:\n"
+ "- Include: faction/organisation names, locations mentioned in dialogue but not on maps, "
+ "unique magic systems, lore titles, recurring in-universe concepts.\n"
+ "- Exclude: skill names, item names, weapon/armour names (the tool handles those via "
+ "names.json). Skip generic RPG words. Do not repeat character names here.\n"
+ "\n"
+ "\n"
+ "\n"
+ "Return the ENTIRE glossary inside ONE fenced code block (a ``` block), with nothing before "
+ "or after it, so it can be copied in a single click and pasted straight into the tool.\n"
+ "Inside the code block, output EXACTLY two sections with these headers. Do not add any "
+ "preamble, explanation, or text outside the entries.\n"
+ "\n"
+ "# Game Characters\n"
+ "# Worldbuilding Terms\n"
+ "\n"
+ "Entry format: Japanese (English) - description\n"
+ "\n"
+ "\n"
+ "# Game Characters\n"
+ "シロ (Shiro) - Female; protagonist; player-named; speaks in a flustered, cute register "
+ "with feminine speech markers\n"
+ "ゼクス (Zex) - Male; antagonist; cold and commanding; uses an archaic formal register\n"
+ "カナエ (Kanae) - Female; NPC shopkeeper; warm and motherly; ends sentences with わね\n"
+ "\n"
+ "# Worldbuilding Terms\n"
+ "虚無の穴 (Void Rift) - Dimensional tear referenced repeatedly in late-game dialogue; "
+ "not a named map location\n"
+ "裁定者 (Arbiter) - Title held by the ruling council; lore-specific rank with no "
+ "real-world equivalent\n"
+ "\n"
+ "\n"
+)
+
class _WolfTaskWorker(QThread):
"""Run a blocking WolfDawn task callable off the UI thread.
@@ -270,6 +370,13 @@ class WolfWorkflowTab(QWidget):
lbl.setStyleSheet("color:#9d9d9d;font-size:12px;background:transparent;")
return lbl
+ def _subheading(self, text: str) -> QLabel:
+ lbl = QLabel(text)
+ lbl.setStyleSheet(
+ "color:#4ec9b0;font-size:13px;font-weight:bold;background:transparent;padding-top:4px;"
+ )
+ return lbl
+
def _register(self, btn: QPushButton) -> QPushButton:
self._buttons.append(btn)
return btn
@@ -375,6 +482,17 @@ class WolfWorkflowTab(QWidget):
"project-wide name glossary, then imports them into files/ for translation."
))
+ gu_btn = self._register(_make_btn("③ Set up git tracking (copy gameupdate/ files)", "#3a3a3a"))
+ gu_btn.clicked.connect(self._apply_gameupdate)
+ layout.addWidget(gu_btn)
+ layout.addWidget(self._desc(
+ "Copies the tool's gameupdate/ folder into the game's root folder: the updater "
+ "scripts (GameUpdate.bat / GameUpdate_linux.sh), the patch scripts (patch.sh / "
+ "patch.ps1), and a .gitignore that excludes the original game files. Do this early so "
+ "you can track the translation project with git and later ship it as a git-based patch "
+ "players apply themselves. Existing files are overwritten."
+ ))
+
def _browse_folder(self):
start = self.folder_edit.text() or str(Path.home())
folder = QFileDialog.getExistingDirectory(self, "Select WOLF Game Folder", start)
@@ -547,20 +665,91 @@ class WolfWorkflowTab(QWidget):
# ── Step 1: Glossary / names ───────────────────────────────────────────────
def _build_step1_glossary(self, layout: QVBoxLayout):
- layout.addWidget(_make_section_label("Step 1 · Name Glossary (recommended first)"))
+ layout.addWidget(_make_section_label("Step 1 · Glossary (build before translating)"))
+ layout.addWidget(self._desc(
+ "Two glossaries keep the translation consistent. Build them before Step 2 so the AI "
+ "already knows every character and term while translating."
+ ))
+
+ # ── 1a: Character & worldbuilding glossary (vocab.txt) ──
+ layout.addWidget(self._subheading("1a · Character & Worldbuilding Glossary (vocab.txt)"))
+ layout.addWidget(self._desc(
+ "vocab.txt is the project-wide glossary used by every translation batch to keep "
+ "character names, speech register, honorifics, and lore terms consistent. Copy the "
+ "prompt below into Cursor or Copilot Chat with the extracted files/ JSON open, let it "
+ "analyse the game, then paste its output into the editor and save."
+ ))
+ prompt_btn = _make_btn("📋 Copy glossary prompt for Copilot / Cursor", "#5a3a7a")
+ prompt_btn.clicked.connect(self._copy_wolf_glossary_prompt)
+ layout.addWidget(prompt_btn)
+
+ fmt = QLabel(
+ "Format: Japanese (English) - description\n"
+ "Example: シロ (Shiro) - Female; protagonist; flustered, cute register"
+ )
+ fmt.setFont(QFont("Consolas", 9))
+ fmt.setWordWrap(True)
+ fmt.setStyleSheet(
+ "color:#569cd6;background-color:#1a1e2a;border:1px solid #2a3a5a;"
+ "padding:5px 10px;border-radius:4px;font-size:12px;border-left:3px solid #2a6a9a;"
+ )
+ layout.addWidget(fmt)
+
+ self.vocab_editor = QTextEdit()
+ self.vocab_editor.setMinimumHeight(120)
+ self.vocab_editor.setFont(QFont("Consolas", 9))
+ self.vocab_editor.setStyleSheet(
+ "QTextEdit{background-color:#252526;color:#d4d4d4;border:1px solid #3c3c3c;"
+ "border-radius:4px;padding:8px;selection-background-color:#264f78;}"
+ )
+ self._reload_vocab()
+ layout.addWidget(self.vocab_editor, 1)
+
+ vrow = QHBoxLayout()
+ save_btn = _make_btn("💾 Save vocab.txt", "#3a7a3a")
+ save_btn.clicked.connect(self._save_vocab)
+ vrow.addWidget(save_btn)
+ reload_btn = _make_btn("↺ Reload", "#555")
+ reload_btn.clicked.connect(self._reload_vocab)
+ vrow.addWidget(reload_btn)
+ vrow.addStretch()
+ layout.addLayout(vrow)
+
+ layout.addWidget(_make_hr())
+
+ # ── 1b: Name-value glossary (names.json) ──
+ layout.addWidget(self._subheading("1b · Name Value Glossary (item / skill / enemy names)"))
layout.addWidget(self._desc(
"WOLF databases reference item/skill/enemy names by value across many files, so "
- "translating the glossary first keeps them consistent. This translates only "
- f"{NAMES_JSON} using the Wolf RPG (WolfDawn) module, then you can inject it in Step 3."
+ f"translating {NAMES_JSON} first keeps those consistent. This translates only "
+ f"{NAMES_JSON} using the Wolf RPG (WolfDawn) module; it is injected in Step 3. "
+ "You can also translate it together with everything else in Step 2, but doing it "
+ "first is the higher-quality path. It uses the translation mode (Normal / Batch) "
+ "selected in Step 2."
))
btn = self._register(_make_btn("Translate name glossary now", "#007acc"))
btn.clicked.connect(lambda: self._navigate_to_translation(only=NAMES_JSON, auto_start=True))
layout.addWidget(btn)
- layout.addWidget(_make_hr())
- layout.addWidget(self._desc(
- "Optional: you can also open the glossary in Step 2 together with everything else. "
- "Doing names first is just the higher-quality path."
- ))
+
+ def _copy_wolf_glossary_prompt(self):
+ try:
+ QApplication.clipboard().setText(_WOLF_GLOSSARY_PROMPT)
+ self._log("📋 Glossary prompt copied. Paste it into Cursor/Copilot with files/ open.")
+ except Exception as exc:
+ self._log(f"❌ Could not copy glossary prompt: {exc}")
+
+ def _reload_vocab(self):
+ try:
+ self.vocab_editor.setPlainText(read_game_vocab())
+ except Exception as exc:
+ self._log(f"❌ Could not load vocab.txt: {exc}")
+
+ def _save_vocab(self):
+ try:
+ write_game_vocab(self.vocab_editor.toPlainText())
+ self._log("✅ vocab.txt saved (base terms from vocab_base.txt appended).")
+ except Exception as exc:
+ self._log(f"❌ Could not save vocab.txt: {exc}")
# ── Step 2: Translate ──────────────────────────────────────────────────────
@@ -571,6 +760,9 @@ class WolfWorkflowTab(QWidget):
"Wolf RPG (WolfDawn) module and starts translating. Only the 'text' fields are "
"filled in; 'source' is preserved so injection can verify each line."
))
+
+ self._add_tl_mode_selector(layout)
+
btn = self._register(_make_btn("Translate all files now", "#00a86b"))
btn.clicked.connect(lambda: self._navigate_to_translation(auto_start=True))
layout.addWidget(btn)
@@ -579,6 +771,48 @@ class WolfWorkflowTab(QWidget):
open_btn.clicked.connect(lambda: self._navigate_to_translation(auto_start=False))
layout.addWidget(open_btn)
+ def _add_tl_mode_selector(self, layout: QVBoxLayout):
+ """Normal vs Batch selector; applies to the name glossary (1b) and full runs."""
+ row = QHBoxLayout()
+ lbl = QLabel("Translation mode:")
+ lbl.setStyleSheet("color:#cccccc;font-size:12px;font-weight:bold;background:transparent;")
+ self._tl_mode_combo = QComboBox()
+ self._tl_mode_combo.addItem(_TL_NORMAL_LABEL)
+ self._tl_mode_combo.addItem(BATCH_MODE_LABEL)
+ self._tl_mode_combo.setFixedWidth(220)
+ self._tl_mode_combo.setToolTip(
+ "Applies to both the Step 1 name glossary and the full run here.\n"
+ "Normal translates live; Batch uses the Anthropic Batches API (~50% cheaper, Claude only)."
+ )
+ saved = self._setting("tl_mode", BATCH_MODE_LABEL) or BATCH_MODE_LABEL
+ idx = self._tl_mode_combo.findText(str(saved))
+ if idx >= 0:
+ self._tl_mode_combo.setCurrentIndex(idx)
+ self._tl_mode_combo.currentTextChanged.connect(self._on_tl_mode_changed)
+ row.addWidget(lbl)
+ row.addWidget(self._tl_mode_combo)
+ row.addStretch()
+ layout.addLayout(row)
+
+ self._batch_note = QLabel(BATCH_MODE_BENEFIT_NOTE + "\n" + BATCH_COLLECT_LIVE_CHARGE_NOTE)
+ self._batch_note.setWordWrap(True)
+ self._batch_note.setStyleSheet("color:#8fbc8f;font-size:11px;background:transparent;")
+ layout.addWidget(self._batch_note)
+ self._on_tl_mode_changed(self._tl_mode_combo.currentText())
+
+ def _on_tl_mode_changed(self, mode_text: str):
+ is_batch = mode_text == BATCH_MODE_LABEL
+ if hasattr(self, "_batch_note"):
+ self._batch_note.setVisible(is_batch)
+ self._save_setting("tl_mode", mode_text)
+
+ def _workflow_mode_text(self) -> str:
+ """Map the selector to the Translation tab's own mode label."""
+ combo = getattr(self, "_tl_mode_combo", None)
+ if combo is not None and combo.currentText() == BATCH_MODE_LABEL:
+ return BATCH_MODE_LABEL
+ return "Translate"
+
def _navigate_to_translation(self, only: str | None = None, auto_start: bool = False):
"""Switch to the Translation tab, select the WolfDawn module, and check files."""
pw = self.parent_window
@@ -595,6 +829,16 @@ class WolfWorkflowTab(QWidget):
except Exception:
pass
+ # Apply the chosen translation mode (after the module, since changing the
+ # module can refresh the mode list).
+ try:
+ mode_combo = tt.mode_combo
+ mode_idx = mode_combo.findText(self._workflow_mode_text())
+ if mode_idx >= 0:
+ mode_combo.setCurrentIndex(mode_idx)
+ except Exception:
+ pass
+
try:
tt.refresh_file_lists()
fl = tt.file_list
@@ -769,6 +1013,46 @@ class WolfWorkflowTab(QWidget):
"archive's encryption where possible (--like the backed-up original)."
))
+ def _apply_gameupdate(self):
+ if not self._require_root():
+ return
+ from util.paths import PROJECT_ROOT
+
+ src = PROJECT_ROOT / "gameupdate"
+ dst = Path(self._game_root)
+ if not src.is_dir():
+ QMessageBox.warning(
+ self, "gameupdate",
+ f"The tool's gameupdate/ folder was not found at {src}.",
+ )
+ return
+
+ def task(log):
+ import shutil
+
+ copied = 0
+ errors: list[str] = []
+ log(f"Copying gameupdate patch files: {src} → {dst} …")
+ for fp in sorted(src.rglob("*")):
+ if not fp.is_file():
+ continue
+ rel = fp.relative_to(src)
+ target = dst / rel
+ try:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(fp, target)
+ copied += 1
+ log(f" copied {rel}")
+ except Exception as exc:
+ errors.append(f"{rel}: {exc}")
+ for e in errors:
+ log(f" ⚠ {e}")
+ if errors:
+ return False, f"Copied {copied} file(s); {len(errors)} failed (see log)."
+ return True, f"Copied {copied} gameupdate file(s) into {dst.name}."
+
+ self._run_task(task)
+
def _package_loose(self):
if not self._require_root():
return
diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py
index 6148b2f..34f9e5f 100644
--- a/gui/workflow_tab.py
+++ b/gui/workflow_tab.py
@@ -22,7 +22,9 @@ import sys
import threading
from pathlib import Path
-from util.paths import VOCAB_BASE_PATH, VOCAB_PATH
+from util.paths import VOCAB_PATH
+from util.vocab import BASE_SEPARATOR as _SHARED_BASE_SEPARATOR
+from util.vocab import read_game_vocab, write_game_vocab
import jsbeautifier
@@ -1075,8 +1077,10 @@ class WorkflowTab(QWidget):
"\n"
"\n"
"\n"
- "Output EXACTLY two sections with these headers. Do not add any preamble, explanation, "
- "or text outside the entries.\n"
+ "Return the ENTIRE glossary inside ONE fenced code block (a ``` block), with nothing before "
+ "or after it, so it can be copied in a single click and pasted straight into the tool.\n"
+ "Inside the code block, output EXACTLY two sections with these headers. Do not add any "
+ "preamble, explanation, or text outside the entries.\n"
"\n"
"# Game Characters\n"
"# Worldbuilding Terms\n"
@@ -1194,10 +1198,11 @@ class WorkflowTab(QWidget):
"\n"
"\n"
"\n"
- "Provide the translated file as a complete replacement of js/plugins.js. Only change the "
+ "Provide the translated file as a complete replacement of js/plugins.js, inside a single "
+ "fenced code block (```js ... ```) so it can be copied in one click. Only change the "
"string values identified above. Preserve all original formatting, indentation, comments, and structure.\n"
"\n"
- "After the file, output a summary:\n"
+ "After the code block, output a summary:\n"
"\n"
"### Translations Made\n"
" Plugin: \n"
@@ -1268,8 +1273,9 @@ class WorkflowTab(QWidget):
"\n"
"\n"
"\n"
- "For each .rb file that needed changes, provide the full translated file content. "
- "Only change string values identified as safe. "
+ "For each .rb file that needed changes, provide the full translated file content inside its "
+ "own fenced code block (```ruby ... ```), preceded by the filename, so each file can be "
+ "copied in one click. Only change string values identified as safe. "
"Preserve all Ruby syntax, indentation, comments, and structure exactly.\n"
"\n"
"After all files, output a summary:\n"
@@ -3555,7 +3561,7 @@ class WorkflowTab(QWidget):
# Step 1 – Vocab
# ─────────────────────────────────────────────────────────────────────────
- _BASE_SEPARATOR = "# ── Base Vocabulary (auto-appended from vocab_base.txt — do not edit below) ──\n"
+ _BASE_SEPARATOR = _SHARED_BASE_SEPARATOR
def _copy_glossary_prompt(self):
"""Copy the glossary prompt to clipboard, injecting known speakers from vocab.txt."""
@@ -3608,27 +3614,14 @@ class WorkflowTab(QWidget):
return results
def _reload_vocab(self):
- vocab_path = VOCAB_PATH
try:
- if vocab_path.exists():
- text = vocab_path.read_text(encoding="utf-8")
- # Strip the auto-appended base section so editor shows only game-specific content
- sep_idx = text.find(self._BASE_SEPARATOR)
- if sep_idx != -1:
- text = text[:sep_idx].rstrip("\n")
- self.vocab_editor.setPlainText(text)
- else:
- self.vocab_editor.setPlainText("# Add character glossary entries here\n")
+ self.vocab_editor.setPlainText(read_game_vocab())
except Exception as exc:
self._log(f"❌ Could not load vocab.txt: {exc}")
def _save_vocab(self):
try:
- game_text = self.vocab_editor.toPlainText().rstrip("\n")
- base_path = VOCAB_BASE_PATH
- base_text = base_path.read_text(encoding="utf-8") if base_path.exists() else ""
- combined = game_text + "\n\n" + self._BASE_SEPARATOR + base_text
- VOCAB_PATH.write_text(combined, encoding="utf-8")
+ write_game_vocab(self.vocab_editor.toPlainText())
self._log("✅ vocab.txt saved (base terms from vocab_base.txt appended).")
except Exception as exc:
self._log(f"❌ Could not save vocab.txt: {exc}")
diff --git a/util/vocab.py b/util/vocab.py
new file mode 100644
index 0000000..640232c
--- /dev/null
+++ b/util/vocab.py
@@ -0,0 +1,47 @@
+"""Shared helpers for the game-specific translation glossary (``data/vocab.txt``).
+
+``vocab.txt`` is loaded by the shared translation layer (:mod:`util.translation`)
+and applied to every engine, so a good glossary keeps character names, honorifics,
+and worldbuilding terms consistent across the whole translation.
+
+The file has two parts:
+
+* the game-specific entries (characters, worldbuilding terms) edited per project,
+* a base vocabulary that is auto-appended from ``data/vocab_base.txt``.
+
+``BASE_SEPARATOR`` marks where the auto-appended base section begins so the
+workflow editors can show and save only the game-specific portion. It must stay
+byte-identical to what has already been written into users' ``vocab.txt`` files,
+otherwise the base section would not be stripped on reload.
+"""
+
+from __future__ import annotations
+
+from util.paths import VOCAB_BASE_PATH, VOCAB_PATH
+
+BASE_SEPARATOR = (
+ "# ── Base Vocabulary (auto-appended from vocab_base.txt — do not edit below) ──\n"
+)
+
+_EMPTY_PLACEHOLDER = "# Add character glossary entries here\n"
+
+
+def read_game_vocab() -> str:
+ """Return the game-specific portion of ``vocab.txt`` (base section stripped)."""
+ if VOCAB_PATH.is_file():
+ text = VOCAB_PATH.read_text(encoding="utf-8")
+ idx = text.find(BASE_SEPARATOR)
+ if idx != -1:
+ text = text[:idx].rstrip("\n")
+ return text
+ return _EMPTY_PLACEHOLDER
+
+
+def write_game_vocab(game_text: str) -> None:
+ """Write the game-specific vocab and re-append the base vocabulary."""
+ game_text = (game_text or "").rstrip("\n")
+ base_text = (
+ VOCAB_BASE_PATH.read_text(encoding="utf-8") if VOCAB_BASE_PATH.is_file() else ""
+ )
+ combined = game_text + "\n\n" + BASE_SEPARATOR + base_text
+ VOCAB_PATH.write_text(combined, encoding="utf-8")