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)))