From d14819700baf6fd430df2e19988e753d7abfbac7 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Fri, 3 Jul 2026 16:18:30 -0500 Subject: [PATCH] Proper wrapping and speaker catching --- README.md | 2 +- gui/wolf_workflow_tab.py | 87 ++++++++++++++++++++++++++++++++++++++++ modules/wolfdawn.py | 64 ++++++++++++++++++++++++----- tests/test_wolfdawn.py | 62 ++++++++++++++++++++++++++-- util/speakers.py | 22 +++++++--- 5 files changed, 218 insertions(+), 19 deletions(-) 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 ``@