diff --git a/README.md b/README.md
index e36fe63..741ad73 100644
--- a/README.md
+++ b/README.md
@@ -326,7 +326,7 @@ Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine sel
|------|--------|
| **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. **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. Finally, **Format extracted JSON** (dazedformat) normalises `wolf_json/` and `files/` to the same layout the translator writes back (`json.dump`, indent 4) — run it once and commit it as your baseline so later injects produce clean, line-level git diffs instead of reformatting whole files. |
| **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** | Pick the **Translation mode** (Normal or Batch - Batch uses the Anthropic Batches API, ~50% cheaper, Claude only), then run the `Wolf RPG (WolfDawn)` module over `files/`. The chosen mode also applies to the Step 1b name glossary run. Under **Speaker handling**, toggle which speaker formats are reshaped: WolfDawn tags who speaks on each line, and for lines where the name is baked into the first line (a nameplate) the translator reshapes them into the `[Speaker]: line` convention the prompt understands (translating the speaker tag), then restores WOLF's native `Speaker⏎line` layout on inject. Turn a format off if a game's first lines are not really speaker names. Only the `text` fields are filled in; `source` is preserved so injection can verify each line. |
+| **2 Translate** | Pick the **Translation mode** (Normal or Batch - Batch uses the Anthropic Batches API, ~50% cheaper, Claude only), then run the `Wolf RPG (WolfDawn)` module over `files/`. The chosen mode also applies to the Step 1b name glossary run. Under **Speaker handling**, toggle which speaker formats are reshaped: WolfDawn tags who speaks on each line, and for lines where the name is baked into the first line (a nameplate) the translator reshapes them into the `[Speaker]: line` convention the prompt understands (translating the speaker tag), then restores WOLF's native `Speaker⏎line` layout on inject. Turn a format off if a game's first lines are not really speaker names. Under **Text wrap width**, set whether/where translated dialogue re-wraps to fit WOLF's message box (same `dazedwrap` engine the RPG Maker workflow uses, saved to `.env` as `wolfWrap` / `wolfWidth`); only the dialogue body is wrapped - a speaker's nameplate line is always kept separate so the `⏎` after a name is never folded into the text. Only the `text` fields are filled in; `source` is preserved so injection can verify each line. |
| **3 Inject** | Write the translations back into the game's `Data/` binaries **and** refresh the git-tracked `wolf_json/` with the translated JSON, so the game project's git diff shows both the JSON source and the injected binaries. Toggle `--en-punct` (convert Japanese punctuation to ASCII) and `--allow-code-drift` (relax the inline-code guard) as needed, and use **Check name consistency** to catch names translated differently across files. **Quick inject** lists the files you've translated so far (present in `translated/`) so you can tick just a few, inject them straight into `Data/`, and review the git diff / test in-game - ideal for iterating. **Inject all translations** writes every document and re-applies the name glossary across `Data/` for the final build. |
| **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. |
| **5 Saves** | *(Optional)* Update existing `.sav` files so old Japanese saves load cleanly in the translated build. Originals are backed up automatically. |
diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py
index 3cc8d11..3b13a0c 100644
--- a/gui/wolf_workflow_tab.py
+++ b/gui/wolf_workflow_tab.py
@@ -47,6 +47,7 @@ from PyQt5.QtWidgets import (
QMessageBox,
QPushButton,
QScrollArea,
+ QSpinBox,
QSplitter,
QTabWidget,
QTextEdit,
@@ -817,6 +818,7 @@ class WolfWorkflowTab(QWidget):
self._add_tl_mode_selector(layout)
self._add_speaker_options(layout)
+ self._add_wrap_options(layout)
btn = self._register(_make_btn("Translate all files now", "#00a86b"))
btn.clicked.connect(lambda: self._navigate_to_translation(auto_start=True))
@@ -867,6 +869,91 @@ class WolfWorkflowTab(QWidget):
except Exception as exc:
self._log(f"❌ Could not save speaker options: {exc}")
+ def _add_wrap_options(self, layout: QVBoxLayout):
+ """Dialogue text-wrap width (like the RPGMaker workflow, via dazedwrap)."""
+ layout.addWidget(_make_hr())
+ layout.addWidget(self._subheading("Text wrap width"))
+ layout.addWidget(self._desc(
+ "Re-wraps the translated English dialogue so it fits WOLF's message box. "
+ "Only the dialogue body is wrapped - a speaker's name line (the line break "
+ "right after a nameplate) is always kept separate. Lower the width if lines "
+ "overflow in-game; raise it if text wraps too early. Applies on the next run."
+ ))
+
+ wrap_env = self._read_env_values(["wolfWrap", "wolfWidth"])
+
+ self._wrap_enable_cb = QCheckBox("Wrap translated dialogue")
+ enabled = str(wrap_env.get("wolfWrap", "true")).strip().lower() != "false"
+ self._wrap_enable_cb.setChecked(enabled)
+ self._wrap_enable_cb.stateChanged.connect(self._apply_wrap_config)
+ layout.addWidget(self._wrap_enable_cb)
+
+ row = QHBoxLayout()
+ width_lbl = QLabel("Width (characters):")
+ width_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
+ self._wrap_width_spin = QSpinBox()
+ self._wrap_width_spin.setRange(20, 300)
+ try:
+ self._wrap_width_spin.setValue(int(wrap_env.get("wolfWidth") or 55))
+ except ValueError:
+ self._wrap_width_spin.setValue(55)
+ self._wrap_width_spin.setFixedWidth(70)
+ self._wrap_width_spin.valueChanged.connect(self._apply_wrap_config)
+ row.addWidget(width_lbl)
+ row.addWidget(self._wrap_width_spin)
+ row.addStretch()
+ layout.addLayout(row)
+
+ def _apply_wrap_config(self, *args):
+ """Persist wolfWrap / wolfWidth to .env so the next translation run uses them."""
+ updates = {
+ "wolfWrap": "true" if self._wrap_enable_cb.isChecked() else "false",
+ "wolfWidth": str(self._wrap_width_spin.value()),
+ }
+ if self._write_env_values(updates):
+ self._log(
+ "Text wrap updated: "
+ + ", ".join(f"{k}={v}" for k, v in updates.items())
+ )
+
+ def _read_env_values(self, keys) -> dict:
+ """Read a few `key='value'` pairs from .env (best effort)."""
+ import re as _re
+ out = {}
+ env_path = Path(".env")
+ if not env_path.exists():
+ return out
+ try:
+ text = env_path.read_text(encoding="utf-8")
+ except Exception:
+ return out
+ for key in keys:
+ m = _re.search(rf"^{_re.escape(key)}\s*=\s*'([^']*)'", text, _re.MULTILINE)
+ if m:
+ out[key] = m.group(1)
+ return out
+
+ def _write_env_values(self, updates: dict) -> bool:
+ """Update/insert `key='value'` pairs in .env. Returns True on success."""
+ import re as _re
+ env_path = Path(".env")
+ try:
+ text = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
+ for key, val in updates.items():
+ text, n = _re.subn(
+ rf"^({_re.escape(key)}\s*=\s*')[^']*(')",
+ rf"\g<1>{val}\g<2>",
+ text,
+ flags=_re.MULTILINE,
+ )
+ if n == 0:
+ text = text.rstrip("\n") + f"\n{key}='{val}'\n"
+ env_path.write_text(text, encoding="utf-8")
+ return True
+ except Exception as exc:
+ self._log(f"❌ Could not update .env: {exc}")
+ return False
+
def _add_tl_mode_selector(self, layout: QVBoxLayout):
"""Normal vs Batch selector; applies to the name glossary (1b) and full runs."""
row = QHBoxLayout()
diff --git a/modules/wolfdawn.py b/modules/wolfdawn.py
index 64382ab..61d93bf 100644
--- a/modules/wolfdawn.py
+++ b/modules/wolfdawn.py
@@ -15,8 +15,14 @@ Supported document ``kind``s (all share the ``{source, text}`` leaf pattern):
Only entries whose ``source`` contains target-language (Japanese by default)
text are sent to the model; everything else keeps ``text == source`` so inject
-is a no-op for it. Translated text is written back verbatim (no re-wrapping) to
-keep WolfDawn's inline-code and byte-exact guards happy.
+is a no-op for it.
+
+Text wrapping: translated dialogue is re-wrapped to a character width (like the
+RPGMaker module, via :mod:`util.dazedwrap`) so English fits WOLF's message box.
+Wrapping is applied to the dialogue *body* only - a speaker's nameplate line (the
+``\n`` right after a name) is kept on its own line and never folded into the body.
+Configurable from the workflow (``.env``: ``wolfWrap`` / ``wolfWidth``); a width of
+0 (or ``wolfWrap=false``) writes the model output back verbatim.
Speakers: WolfDawn tags each line with ``speaker`` / ``speaker_src``. For the
first-line formats (``literal_line1`` / ``literal_line1_lowconf``) the speaker
@@ -36,6 +42,7 @@ import traceback
from colorama import Fore
from tqdm import tqdm
+import util.dazedwrap as dazedwrap
from util.paths import PROMPT_PATH, VOCAB_PATH
from util.translation import (
TranslationConfig,
@@ -68,6 +75,17 @@ LANGREGEX = r"[一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]+"
# configurable from the workflow (data/wolf_speakers.json).
SPEAKER_CONFIG = wolf_speakers.load_config()
+# Text wrapping: rewrap translated dialogue to a character width the same way the
+# RPGMaker module does (util.dazedwrap), so English lines fit WOLF's message box.
+# Only the dialogue *body* is wrapped - the speaker's name line (the "\n" after a
+# nameplate) is kept on its own line and never merged into the body. Configurable
+# from the workflow (.env: wolfWrap / wolfWidth). Width <= 0 disables wrapping.
+WRAP = (os.getenv("wolfWrap", "true").strip().lower() == "true")
+try:
+ WRAPWIDTH = int(os.getenv("wolfWidth") or 0)
+except ValueError:
+ WRAPWIDTH = 0
+
# Pricing / batching from the configured model
PRICING_CONFIG = getPricingConfig(MODEL)
INPUTAPICOST = PRICING_CONFIG["inputAPICost"]
@@ -167,6 +185,30 @@ def collectEntries(data):
return entries
+def _wrap_body(body):
+ """Word-wrap a dialogue body to WRAPWIDTH (no-op when wrapping is disabled)."""
+ if not WRAP or WRAPWIDTH <= 0 or not isinstance(body, str) or not body:
+ return body
+ return dazedwrap.wrapText(body, WRAPWIDTH)
+
+
+def _wrap_plain(text, is_firstline):
+ """Wrap a non-reshaped entry, protecting a nameplate first line if present.
+
+ ``is_firstline`` is True for first-line-speaker formats that are turned off in
+ the config: their model output is still ``Speaker\\nbody``, so line 1 (the
+ name) is kept intact and only the body is wrapped. Any leading ``@