diff --git a/README.md b/README.md
index 98e8161..7a7d382 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; `names.json` is translated later (Step 3) and the Step 2 bulk run skips it. Extraction also snapshots the pristine (untranslated) binaries into `wolf_json/originals/` so Step 4 can inject idempotently. **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 `vocab.txt` before translating: 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). Item/skill/enemy value names (`names.json`) are handled later in Step 3, not here. |
-| **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/`. 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. |
+| **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/`. Under **Speaker handling**, speaker attribution is automatic - WolfDawn already tags who speaks on each line, so lines with a real nameplate (a face window precedes the name) are always reshaped into the `[Speaker]: line` convention the prompt understands (translating the speaker tag), then restored to WOLF's native `Speaker⏎line` layout on inject. The only judgement call is WolfDawn's low-confidence guesses (a short first line with no face window that may or may not be a name); copy the **speaker-format prompt** to have the repo-aware AI inspect `files/` and recommend ENABLE/DISABLE, then set the single low-confidence checkbox to match (there is no toggle for the reliable nameplates - they need no configuration). 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 Names** | Curate `names.json` (item/skill/enemy/variable value names). **Translating all of them breaks the game** - many WOLF value names double as logic keys (compared by value, used as variable/file/event names), so by default none are translated. WolfDawn tags each name with its WOLF database **category** (the `note` field), so instead of judging thousands of names you pick which *categories* are safe. Copy the **names-safety prompt** into a repo-aware AI (Cursor/Copilot with `files/` open); it groups names by category, checks how each is used, and returns a JSON list of the safe categories in a code block. Paste that list and click **Apply** to tick the matching rows (or tick them by hand - each row shows the category, its name count, and an example), then **Save** to `data/wolf_safe_notes.json`. Finally **Translate safe names** - the WolfDawn module only translates entries whose category you approved and leaves every other name identical to the source; `names.json`'s structure is unchanged. Conservative by design: nothing changes until you opt categories in. |
| **4 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. Injection always reads from a **pristine snapshot** of the original binaries (`wolf_json/originals/`, captured during Step 0 extraction and rebuilt from the `.wolf`/`.wolf.bak` archives if missing) and writes the result into the live `Data/` file. This keeps injection **idempotent** - WolfDawn locates each Japanese source string in the original, so re-injecting after fixing a few lines actually updates the binary instead of silently no-op'ing (the old in-place inject broke once a file had already been translated). 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, including the safe name values from Step 3, across `Data/`. |
| **5 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 98951a6..24b6c7c 100644
--- a/gui/wolf_workflow_tab.py
+++ b/gui/wolf_workflow_tab.py
@@ -244,6 +244,47 @@ _WOLF_NAMES_PROMPT = (
"\n"
)
+# Speaker-format prompt for a repo-aware AI. WolfDawn already detects and tags who
+# speaks on each line, so the only decision left is whether its LOW-confidence
+# first-line guesses are really speaker names for this game. The AI inspects the
+# extracted text and returns a single ENABLE/DISABLE recommendation.
+_WOLF_SPEAKER_PROMPT = (
+ "You are an expert Japanese WOLF RPG Editor translator helping configure a translation tool.\n"
+ "\n"
+ "\n"
+ "WolfDawn extracts each line with a 'speaker' and a 'speaker_src' tag describing how it "
+ "detected the speaker. Two tags carry the speaker name baked into the FIRST line of the "
+ "'source' text (the rest is the dialogue body):\n"
+ " - literal_line1 : HIGH confidence - a face/name window precedes the line, so the "
+ "first line really is a nameplate. The tool always reshapes these; nothing to decide.\n"
+ " - literal_line1_lowconf : LOW confidence - a short first line with NO preceding face "
+ "window. WolfDawn guesses it is a speaker name, but it might actually be the start of the "
+ "dialogue or narration.\n"
+ "When a first-line format is trusted, the tool reshapes 'Name\\nbody' into '[Name]: body' for "
+ "translation, then restores 'Name\\nbody' on inject (byte-safe either way).\n"
+ "\n"
+ "\n"
+ "--- attach the extracted JSON in files/ here (maps / CommonEvent) before continuing ---\n"
+ "\n"
+ "\n"
+ "Decide whether the LOW-confidence first-line guesses (speaker_src = literal_line1_lowconf) "
+ "should be treated as speaker names for THIS game. Inspect a good sample of those entries in "
+ "files/: look at the first line of each such 'source' and judge whether it is genuinely a "
+ "character/speaker label as opposed to real dialogue or narration.\n"
+ " - ENABLE if the low-confidence first lines are overwhelmingly real speaker names.\n"
+ " - DISABLE if many are actually dialogue/narration (reshaping them would mislabel lines).\n"
+ "(The high-confidence nameplates are always handled; you are only ruling on the guesses.)\n"
+ "\n"
+ "\n"
+ "\n"
+ "Return ONLY a fenced code block containing the recommendation and a one-line reason, e.g.\n"
+ "```\n"
+ "LOWCONF_FIRSTLINE: ENABLE - low-confidence first lines are short character names (市民, 兵士...).\n"
+ "```\n"
+ "Use exactly ENABLE or DISABLE after the colon.\n"
+ "\n"
+)
+
class _WolfTaskWorker(QThread):
"""Run a blocking WolfDawn task callable off the UI thread.
@@ -998,41 +1039,53 @@ class WolfWorkflowTab(QWidget):
layout.addWidget(open_btn)
def _add_speaker_options(self, layout: QVBoxLayout):
- """Toggles for which speaker formats are reshaped into '[Speaker]: line'."""
+ """Speaker handling: nameplates are automatic; the low-confidence guess is
+ an AI-recommended per-game setting."""
from util import speakers as wolf_speakers
layout.addWidget(_make_hr())
layout.addWidget(self._subheading("Speaker handling"))
layout.addWidget(self._desc(
- "WolfDawn tags who is speaking on each line. For lines where the name is baked "
- "into the first line (a nameplate), the translator reshapes them into the "
- "\"[Speaker]: line\" format the prompt understands, translates the speaker tag, then "
- "restores WOLF's native \"Speaker⏎line\" layout on inject. Turn the formats off if a "
- "game's first lines are not really speaker names."
+ "WolfDawn already detects who speaks on each line, so this is automatic: lines "
+ "with a real nameplate (a face window precedes the name) are always reshaped into "
+ "the \"[Speaker]: line\" format the prompt understands, translated, and restored to "
+ "WOLF's native \"Speaker⏎line\" layout on inject. The only judgement call is whether "
+ "to trust WolfDawn's low-confidence guesses (a short first line with no face window "
+ "that might be a name - or might just be the start of dialogue)."
+ ))
+ layout.addWidget(self._desc(
+ "Ask the AI managing the game repo to decide: the prompt has it inspect the "
+ "extracted files/ and recommend whether low-confidence first-line names should be "
+ "reshaped for this game. Then set the box below to match."
))
+ prompt_btn = _make_btn("📋 Copy speaker-format prompt for Copilot / Cursor", "#5a3a7a")
+ prompt_btn.clicked.connect(self._copy_wolf_speaker_prompt)
+ layout.addWidget(prompt_btn)
+
cfg = wolf_speakers.load_config()
-
- self._speaker_hi_cb = QCheckBox(
- "High-confidence nameplates (a face window precedes the line)"
- )
- self._speaker_hi_cb.setChecked(bool(cfg.get("literal_line1", True)))
- self._speaker_hi_cb.stateChanged.connect(self._save_speaker_options)
- layout.addWidget(self._speaker_hi_cb)
-
self._speaker_lo_cb = QCheckBox(
- "Low-confidence first-line guesses (short first line, no preceding face window)"
+ "Reshape low-confidence first-line names (enable if the AI says this game uses them)"
)
self._speaker_lo_cb.setChecked(bool(cfg.get("literal_line1_lowconf", True)))
self._speaker_lo_cb.stateChanged.connect(self._save_speaker_options)
layout.addWidget(self._speaker_lo_cb)
+ def _copy_wolf_speaker_prompt(self):
+ try:
+ QApplication.clipboard().setText(_WOLF_SPEAKER_PROMPT)
+ self._log(
+ "📋 Speaker-format prompt copied. Paste it into Cursor/Copilot with files/ open; "
+ "set the low-confidence box to match its recommendation."
+ )
+ except Exception as exc:
+ self._log(f"❌ Could not copy speaker prompt: {exc}")
+
def _save_speaker_options(self):
from util import speakers as wolf_speakers
try:
wolf_speakers.save_config({
- "literal_line1": self._speaker_hi_cb.isChecked(),
"literal_line1_lowconf": self._speaker_lo_cb.isChecked(),
})
except Exception as exc:
diff --git a/modules/wolfdawn.py b/modules/wolfdawn.py
index cd16b05..3012b26 100644
--- a/modules/wolfdawn.py
+++ b/modules/wolfdawn.py
@@ -36,8 +36,10 @@ Speakers: WolfDawn tags each line with ``speaker`` / ``speaker_src``. For the
first-line formats (``literal_line1`` / ``literal_line1_lowconf``) the speaker
name is baked into line 1 of ``source``. Those lines are reshaped into the shared
``[Speaker]: line`` convention (which the prompt already translates) and restored
-to WOLF's native ``Speaker\nline`` layout on write-back. See ``util.speakers``;
-which formats are reshaped is configurable from the workflow.
+to WOLF's native ``Speaker\nline`` layout on write-back. See ``util.speakers``.
+Detection is WolfDawn's, so the reliable nameplate (``literal_line1``) is always
+reshaped; only the low-confidence guess (``literal_line1_lowconf``) is gated by a
+per-game, AI-recommended setting from the workflow.
"""
import json
@@ -129,12 +131,13 @@ TRANSLATION_CONFIG = TranslationConfig(
def handleWolfDawn(filename, estimate):
"""Entry point used by the CLI/GUI dispatchers. Returns a summary string or 'Fail'."""
- global ESTIMATE, TOKENS, FILENAME, SAFE_NOTES
+ global ESTIMATE, TOKENS, FILENAME, SAFE_NOTES, SPEAKER_CONFIG
ESTIMATE = estimate
FILENAME = filename
- # Re-read the safe note categories so edits made in the workflow this session
- # take effect even when translation runs in-process (module import is cached).
+ # Re-read workflow-configured settings so edits made this session take effect
+ # even when translation runs in-process (the module import is cached).
SAFE_NOTES = wolf_names.load_safe_notes()
+ SPEAKER_CONFIG = wolf_speakers.load_config()
start = time.time()
translatedData = openFiles(filename)
diff --git a/tests/test_wolf_speakers.py b/tests/test_wolf_speakers.py
index 2da18ce..872d1f4 100644
--- a/tests/test_wolf_speakers.py
+++ b/tests/test_wolf_speakers.py
@@ -14,8 +14,8 @@ sys.path.insert(0, str(ROOT))
from util import speakers as ws # noqa: E402
-ALL_ON = {"literal_line1": True, "literal_line1_lowconf": True}
-ALL_OFF = {"literal_line1": False, "literal_line1_lowconf": False}
+ALL_ON = {"literal_line1_lowconf": True}
+ALL_OFF = {"literal_line1_lowconf": False}
class TestSplitSource(unittest.TestCase):
@@ -30,6 +30,11 @@ class TestSplitSource(unittest.TestCase):
def test_disabled_format_returns_none(self):
self.assertIsNone(ws.split_source("市民\nおはよう", "literal_line1_lowconf", ALL_OFF))
+ def test_highconf_always_on_ignores_config(self):
+ # The reliable nameplate is reshaped regardless of config (no toggle).
+ out = ws.split_source("セルリア\nふふふ", "literal_line1", ALL_OFF)
+ self.assertEqual(out, ("", "セルリア", "ふふふ"))
+
def test_non_firstline_src_returns_none(self):
for src in ("narration", "ui", "choice", "string_var", ""):
self.assertIsNone(ws.split_source("市民\nおはよう", src, ALL_ON))
@@ -94,10 +99,10 @@ class TestConfigIO(unittest.TestCase):
try:
with tempfile.TemporaryDirectory() as td:
ws.CONFIG_PATH = Path(td) / "wolf_speakers.json"
- ws.save_config({"literal_line1": False, "literal_line1_lowconf": True})
- loaded = ws.load_config()
- self.assertFalse(loaded["literal_line1"])
- self.assertTrue(loaded["literal_line1_lowconf"])
+ ws.save_config({"literal_line1_lowconf": False})
+ self.assertFalse(ws.load_config()["literal_line1_lowconf"])
+ ws.save_config({"literal_line1_lowconf": True})
+ self.assertTrue(ws.load_config()["literal_line1_lowconf"])
finally:
ws.CONFIG_PATH = orig
diff --git a/util/speakers.py b/util/speakers.py
index baaf84a..26439de 100644
--- a/util/speakers.py
+++ b/util/speakers.py
@@ -348,19 +348,30 @@ def _has_japanese(text: str) -> bool:
#
# When translating we reshape those into ``[Speaker]: body`` (the prompt already
# knows to translate the tag) and, on write-back, restore WOLF's native
-# ``Speaker\nbody`` layout so injection stays byte-faithful. Which formats are
-# reshaped is configurable (``data/wolf_speakers.json``) so the workflow can
-# toggle the high-confidence nameplate and the low-confidence guess separately.
+# ``Speaker\nbody`` layout so injection stays byte-faithful.
+#
+# WolfDawn does the detection, so there is nothing to configure for the reliable
+# format: ``literal_line1`` is a real nameplate (a face window precedes the line),
+# always reshaped, no toggle. Only ``literal_line1_lowconf`` is a heuristic guess
+# (short line 1, no face window) that can occasionally be wrong; whether to trust
+# it is a per-game call, so it is the single configurable format
+# (``data/wolf_speakers.json``), recommended by the workflow's speaker-format AI
+# prompt rather than guessed by hand.
# ============================================================================
# speaker_src values whose name is baked into line 1 of the source text.
FIRSTLINE_SRCS = ("literal_line1", "literal_line1_lowconf")
-# Default: reshape both first-line formats. WolfDawn already gates the
-# low-confidence one (short line 1, no control codes, no sentence punctuation),
-# so it is safe enough to enable by default.
+# Reliable nameplate - always reshaped, not user-configurable.
+ALWAYS_ON_SRCS = ("literal_line1",)
+
+# Heuristic guess - reshaping is gated by config (per-game, AI-recommended).
+CONFIGURABLE_SRCS = ("literal_line1_lowconf",)
+
+# Default: also reshape the low-confidence guess. WolfDawn already gates it (short
+# line 1, no control codes, no sentence punctuation), so it is safe enough on by
+# default; the speaker-format prompt can advise turning it off for a given game.
DEFAULT_CONFIG = {
- "literal_line1": True,
"literal_line1_lowconf": True,
}
@@ -394,8 +405,14 @@ def save_config(config: dict) -> None:
def is_firstline_enabled(speaker_src: str, config: dict | None = None) -> bool:
- """True if *speaker_src* is a first-line format that is enabled in *config*."""
- if speaker_src not in FIRSTLINE_SRCS:
+ """True if *speaker_src* is a first-line format that should be reshaped.
+
+ The reliable nameplate (``literal_line1``) is always on; the low-confidence
+ guess is gated by *config* (per-game, AI-recommended).
+ """
+ if speaker_src in ALWAYS_ON_SRCS:
+ return True
+ if speaker_src not in CONFIGURABLE_SRCS:
return False
cfg = config if config is not None else DEFAULT_CONFIG
return bool(cfg.get(speaker_src, DEFAULT_CONFIG.get(speaker_src, False)))