Speaker Handling

This commit is contained in:
DazedAnon 2026-07-03 17:21:24 -05:00
parent 10eb68503a
commit e0989112d7
5 changed files with 115 additions and 37 deletions

View file

@ -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. | | **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. | | **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. | | **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/`. | | **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. | | **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. |

View file

@ -244,6 +244,47 @@ _WOLF_NAMES_PROMPT = (
"</output_format>\n" "</output_format>\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"
"<background>\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"
"</background>\n"
"\n"
"--- attach the extracted JSON in files/ here (maps / CommonEvent) before continuing ---\n"
"\n"
"<task>\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"
"</task>\n"
"\n"
"<output_format>\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"
"</output_format>\n"
)
class _WolfTaskWorker(QThread): class _WolfTaskWorker(QThread):
"""Run a blocking WolfDawn task callable off the UI thread. """Run a blocking WolfDawn task callable off the UI thread.
@ -998,41 +1039,53 @@ class WolfWorkflowTab(QWidget):
layout.addWidget(open_btn) layout.addWidget(open_btn)
def _add_speaker_options(self, layout: QVBoxLayout): 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 from util import speakers as wolf_speakers
layout.addWidget(_make_hr()) layout.addWidget(_make_hr())
layout.addWidget(self._subheading("Speaker handling")) layout.addWidget(self._subheading("Speaker handling"))
layout.addWidget(self._desc( layout.addWidget(self._desc(
"WolfDawn tags who is speaking on each line. For lines where the name is baked " "WolfDawn already detects who speaks on each line, so this is automatic: lines "
"into the first line (a nameplate), the translator reshapes them into the " "with a real nameplate (a face window precedes the name) are always reshaped into "
"\"[Speaker]: line\" format the prompt understands, translates the speaker tag, then " "the \"[Speaker]: line\" format the prompt understands, translated, and restored to "
"restores WOLF's native \"Speaker⏎line\" layout on inject. Turn the formats off if a " "WOLF's native \"Speaker⏎line\" layout on inject. The only judgement call is whether "
"game's first lines are not really speaker names." "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() 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( 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.setChecked(bool(cfg.get("literal_line1_lowconf", True)))
self._speaker_lo_cb.stateChanged.connect(self._save_speaker_options) self._speaker_lo_cb.stateChanged.connect(self._save_speaker_options)
layout.addWidget(self._speaker_lo_cb) 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): def _save_speaker_options(self):
from util import speakers as wolf_speakers from util import speakers as wolf_speakers
try: try:
wolf_speakers.save_config({ wolf_speakers.save_config({
"literal_line1": self._speaker_hi_cb.isChecked(),
"literal_line1_lowconf": self._speaker_lo_cb.isChecked(), "literal_line1_lowconf": self._speaker_lo_cb.isChecked(),
}) })
except Exception as exc: except Exception as exc:

View file

@ -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 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 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 ``[Speaker]: line`` convention (which the prompt already translates) and restored
to WOLF's native ``Speaker\nline`` layout on write-back. See ``util.speakers``; to WOLF's native ``Speaker\nline`` layout on write-back. See ``util.speakers``.
which formats are reshaped is configurable from the workflow. 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 import json
@ -129,12 +131,13 @@ TRANSLATION_CONFIG = TranslationConfig(
def handleWolfDawn(filename, estimate): def handleWolfDawn(filename, estimate):
"""Entry point used by the CLI/GUI dispatchers. Returns a summary string or 'Fail'.""" """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 ESTIMATE = estimate
FILENAME = filename FILENAME = filename
# Re-read the safe note categories so edits made in the workflow this session # Re-read workflow-configured settings so edits made this session take effect
# take effect even when translation runs in-process (module import is cached). # even when translation runs in-process (the module import is cached).
SAFE_NOTES = wolf_names.load_safe_notes() SAFE_NOTES = wolf_names.load_safe_notes()
SPEAKER_CONFIG = wolf_speakers.load_config()
start = time.time() start = time.time()
translatedData = openFiles(filename) translatedData = openFiles(filename)

View file

@ -14,8 +14,8 @@ sys.path.insert(0, str(ROOT))
from util import speakers as ws # noqa: E402 from util import speakers as ws # noqa: E402
ALL_ON = {"literal_line1": True, "literal_line1_lowconf": True} ALL_ON = {"literal_line1_lowconf": True}
ALL_OFF = {"literal_line1": False, "literal_line1_lowconf": False} ALL_OFF = {"literal_line1_lowconf": False}
class TestSplitSource(unittest.TestCase): class TestSplitSource(unittest.TestCase):
@ -30,6 +30,11 @@ class TestSplitSource(unittest.TestCase):
def test_disabled_format_returns_none(self): def test_disabled_format_returns_none(self):
self.assertIsNone(ws.split_source("市民\nおはよう", "literal_line1_lowconf", ALL_OFF)) 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): def test_non_firstline_src_returns_none(self):
for src in ("narration", "ui", "choice", "string_var", ""): for src in ("narration", "ui", "choice", "string_var", ""):
self.assertIsNone(ws.split_source("市民\nおはよう", src, ALL_ON)) self.assertIsNone(ws.split_source("市民\nおはよう", src, ALL_ON))
@ -94,10 +99,10 @@ class TestConfigIO(unittest.TestCase):
try: try:
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
ws.CONFIG_PATH = Path(td) / "wolf_speakers.json" ws.CONFIG_PATH = Path(td) / "wolf_speakers.json"
ws.save_config({"literal_line1": False, "literal_line1_lowconf": True}) ws.save_config({"literal_line1_lowconf": False})
loaded = ws.load_config() self.assertFalse(ws.load_config()["literal_line1_lowconf"])
self.assertFalse(loaded["literal_line1"]) ws.save_config({"literal_line1_lowconf": True})
self.assertTrue(loaded["literal_line1_lowconf"]) self.assertTrue(ws.load_config()["literal_line1_lowconf"])
finally: finally:
ws.CONFIG_PATH = orig ws.CONFIG_PATH = orig

View file

@ -348,19 +348,30 @@ def _has_japanese(text: str) -> bool:
# #
# When translating we reshape those into ``[Speaker]: body`` (the prompt already # When translating we reshape those into ``[Speaker]: body`` (the prompt already
# knows to translate the tag) and, on write-back, restore WOLF's native # knows to translate the tag) and, on write-back, restore WOLF's native
# ``Speaker\nbody`` layout so injection stays byte-faithful. Which formats are # ``Speaker\nbody`` layout so injection stays byte-faithful.
# reshaped is configurable (``data/wolf_speakers.json``) so the workflow can #
# toggle the high-confidence nameplate and the low-confidence guess separately. # 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. # speaker_src values whose name is baked into line 1 of the source text.
FIRSTLINE_SRCS = ("literal_line1", "literal_line1_lowconf") FIRSTLINE_SRCS = ("literal_line1", "literal_line1_lowconf")
# Default: reshape both first-line formats. WolfDawn already gates the # Reliable nameplate - always reshaped, not user-configurable.
# low-confidence one (short line 1, no control codes, no sentence punctuation), ALWAYS_ON_SRCS = ("literal_line1",)
# so it is safe enough to enable by default.
# 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 = { DEFAULT_CONFIG = {
"literal_line1": True,
"literal_line1_lowconf": 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: 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*.""" """True if *speaker_src* is a first-line format that should be reshaped.
if speaker_src not in FIRSTLINE_SRCS:
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 return False
cfg = config if config is not None else DEFAULT_CONFIG cfg = config if config is not None else DEFAULT_CONFIG
return bool(cfg.get(speaker_src, DEFAULT_CONFIG.get(speaker_src, False))) return bool(cfg.get(speaker_src, DEFAULT_CONFIG.get(speaker_src, False)))