Speaker Handling
This commit is contained in:
parent
10eb68503a
commit
e0989112d7
5 changed files with 115 additions and 37 deletions
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -244,6 +244,47 @@ _WOLF_NAMES_PROMPT = (
|
|||
"</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):
|
||||
"""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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
Loading…
Reference in a new issue