Rework wolf workflow for stability

This commit is contained in:
DazedAnon 2026-07-03 17:46:15 -05:00
parent e0989112d7
commit de2ad8d511
8 changed files with 469 additions and 52 deletions

View file

@ -324,11 +324,11 @@ Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine sel
| Step | Action | | Step | Action |
|------|--------| |------|--------|
| **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, including `names.json` (translated later in Phase 0 only). 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 curated in Step 2 and harvested into `vocab.txt` automatically during Phase 0 — do not list them 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**, 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. | | **2 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`. You can also run **Translate safe names (Phase 0)** from here once categories are saved. |
| **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 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 in three ordered phases (RPG Maker's DB-first strategy): **Phase 0 · Names → vocab.txt** translates only the safe name categories from Step 2 and harvests them into `vocab.txt` (grouped by bilingual category headers such as `Weapon · 武器`); **Phase 1 · Database text** translates item/skill/state descriptions and system messages from `DataBase.project.json` / `CDataBase.project.json`; **Phase 2 · Dialogue & events** translates maps (`.mps`), `CommonEvent.dat.json`, `Game.dat`, and `Evtext`. Run them in order so later phases benefit from the glossary seeded in Phase 0. 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. |
| **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 Phase 0, 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. |
| **6 Saves** | *(Optional)* Update existing `.sav` files so old Japanese saves load cleanly in the translated build. Originals are backed up automatically. | | **6 Saves** | *(Optional)* Update existing `.sav` files so old Japanese saves load cleanly in the translated build. Originals are backed up automatically. |

View file

@ -11,11 +11,19 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
produce clean git diffs produce clean git diffs
Step 1 Glossary - build vocab.txt (characters / worldbuilding terms) before Step 1 Glossary - build vocab.txt (characters / worldbuilding terms) before
translating so the AI keeps names and voice consistent translating so the AI keeps names and voice consistent
Step 2 Translate - run the "Wolf RPG (WolfDawn)" module over files/ Step 2 Names - curate names.json (item/skill/enemy value names). Blindly
Step 3 Names - curate names.json (item/skill/enemy value names). Blindly
translating all of them breaks games that reference names by translating all of them breaks games that reference names by
value, so a repo-aware AI classifies which are safe to value, so a repo-aware AI classifies which categories are safe
translate and leaves the rest as source (done late, on purpose) to translate and leaves the rest as source. Done before
translating so Phase 0 can seed the glossary from them.
Step 3 Translate - run the "Wolf RPG (WolfDawn)" module over files/ in three
ordered phases (RPGMaker's DB-first strategy):
Phase 0 Names - the safe name categories from Step 2;
their translations are harvested into
vocab.txt so later phases stay consistent
Phase 1 Database - item/skill/state descriptions and system
messages (DataBase/CDataBase.project)
Phase 2 Dialogue - maps, CommonEvent, Game.dat, Evtext
Step 4 Inject - inject translations + curated names back into the Data/ binaries Step 4 Inject - inject translations + curated names back into the Data/ binaries
and refresh the git-tracked wolf_json/ with the translated and refresh the git-tracked wolf_json/ with the translated
JSON; quick-inject picks just a few translated files for JSON; quick-inject picks just a few translated files for
@ -23,10 +31,11 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
Step 5 Package - run from a loose Data/ folder, or repack Data.wolf Step 5 Package - run from a loose Data/ folder, or repack Data.wolf
Step 6 Saves - fix baked strings in existing .sav files (optional) Step 6 Saves - fix baked strings in existing .sav files (optional)
names.json is deliberately NOT staged into files/ and NOT auto-translated - many names.json is staged into files/ but is NOT translated in the bulk phases - many
WOLF "value names" double as logic keys (referenced by value in event code), so WOLF "value names" double as logic keys (referenced by value in event code), so
translating them all corrupts the game. It stays in wolf_json/ until Step 3, where translating them all corrupts the game. Step 2 marks the safe subset (by category),
an AI with repo context marks the safe subset; only those are injected in Step 4. Phase 0 translates only those and harvests them into vocab.txt, and Step 4 injects
the result.
The extracted JSON is staged in ``<game_root>/wolf_json/`` (a manifest maps each The extracted JSON is staged in ``<game_root>/wolf_json/`` (a manifest maps each
file back to its base binary), then imported into ``files/`` for the shared file back to its base binary), then imported into ``files/`` for the shared
@ -84,6 +93,13 @@ MANIFEST_NAME = "manifest.json"
NAMES_JSON = "names.json" NAMES_JSON = "names.json"
WORK_DIR_NAME = "wolf_json" WORK_DIR_NAME = "wolf_json"
# Translation phases, keyed by the manifest ``kind`` of each extracted file.
# Mirrors RPGMaker's DB-first strategy: translate names first (they seed the
# glossary), then database descriptions, then the bulk dialogue.
PHASE_NAMES_KINDS = {"names"}
PHASE_DB_KINDS = {"db"}
PHASE_DIALOGUE_KINDS = {"map", "common", "gamedat", "txt", "txt-dir"}
# WolfDawn's inject commands print e.g. "applied 91 translation(s) (0 untranslated, # WolfDawn's inject commands print e.g. "applied 91 translation(s) (0 untranslated,
# 0 drifted); wrote <path>". We parse the counts to distinguish a real inject from a # 0 drifted); wrote <path>". We parse the counts to distinguish a real inject from a
# silent no-op (exit 0 but 0 applied because the base was already translated). # silent no-op (exit 0 but 0 applied because the base was already translated).
@ -125,7 +141,7 @@ _WOLF_GLOSSARY_PROMPT = (
" - Game.dat.json — game/system strings (title, terms).\n" " - Game.dat.json — game/system strings (title, terms).\n"
" - <Map>.mps.json — per-map events: the main story dialogue (can be large).\n" " - <Map>.mps.json — per-map events: the main story dialogue (can be large).\n"
" - Evtext.json — external event text, when present.\n" " - Evtext.json — external event text, when present.\n"
" - names.json — item/skill/enemy value names (curated separately in Step 3; do NOT list them).\n" " - names.json — item/skill/enemy value names (curated separately in Step 2; do NOT list them).\n"
"</data_format>\n" "</data_format>\n"
"\n" "\n"
"<file_strategy>\n" "<file_strategy>\n"
@ -157,7 +173,7 @@ _WOLF_GLOSSARY_PROMPT = (
"- Include: faction/organisation names, locations mentioned in dialogue but not on maps, " "- Include: faction/organisation names, locations mentioned in dialogue but not on maps, "
"unique magic systems, lore titles, recurring in-universe concepts.\n" "unique magic systems, lore titles, recurring in-universe concepts.\n"
"- Exclude: skill names, item names, weapon/armour names (curated separately via " "- Exclude: skill names, item names, weapon/armour names (curated separately via "
"names.json in Step 3). Skip generic RPG words. Do not repeat character names here.\n" "names.json in Step 2). Skip generic RPG words. Do not repeat character names here.\n"
"</rules>\n" "</rules>\n"
"\n" "\n"
"<output_format>\n" "<output_format>\n"
@ -346,6 +362,18 @@ class WolfWorkflowTab(QWidget):
except Exception: except Exception:
return None return None
def _manifest_kinds(self) -> dict:
"""Return ``{json_filename: kind}`` from the manifest (used to pick which
files a translation phase should translate)."""
out: dict[str, str] = {}
manifest = self._read_manifest()
if manifest:
for entry in manifest.get("entries", []):
name, kind = entry.get("json"), entry.get("kind")
if name and kind:
out[name] = kind
return out
# ─────────────────────────── pristine originals ────────────────────────── # ─────────────────────────── pristine originals ──────────────────────────
# WolfDawn injects by locating each Japanese `source` string inside the base # WolfDawn injects by locating each Japanese `source` string inside the base
# binary and replacing it. That only works against the *original* (untranslated) # binary and replacing it. That only works against the *original* (untranslated)
@ -469,11 +497,12 @@ class WolfWorkflowTab(QWidget):
QTabBar::tab:hover:!selected { background-color: #2d2d30; color: #cccccc; } QTabBar::tab:hover:!selected { background-color: #2d2d30; color: #cccccc; }
""") """)
# Names curation comes before Translate so Phase 0 can seed vocab.txt.
_tab_defs = [ _tab_defs = [
("0 Project", self._build_step0), ("0 Project", self._build_step0),
("1 Glossary", self._build_step1_glossary), ("1 Glossary", self._build_step1_glossary),
("2 Translate", self._build_step2_translate), ("2 Names", self._build_step3_names),
("3 Names", self._build_step3_names), ("3 Translate", self._build_step2_translate),
("4 Inject", self._build_step4_inject), ("4 Inject", self._build_step4_inject),
("5 Package", self._build_step5_package), ("5 Package", self._build_step5_package),
("6 Saves", self._build_step6_saves), ("6 Saves", self._build_step6_saves),
@ -893,9 +922,9 @@ class WolfWorkflowTab(QWidget):
if removed: if removed:
log(f"Cleared {removed} existing file(s) from files/") log(f"Cleared {removed} existing file(s) from files/")
# Stage everything into files/. names.json is included so it can be # Stage everything into files/. names.json is included so Phase 0 can
# translated in Step 3, but the Step 2 bulk run skips it and the WolfDawn # translate it, but the WolfDawn module only translates the note
# module only translates the note categories marked safe in Step 3. # categories marked safe in Step 2 (Names).
copied = 0 copied = 0
for entry in manifest_entries: for entry in manifest_entries:
src = work_dir / entry["json"] src = work_dir / entry["json"]
@ -905,7 +934,7 @@ class WolfWorkflowTab(QWidget):
return True, ( return True, (
f"Extracted {len(manifest_entries)} document(s) and imported {copied} into files/. " f"Extracted {len(manifest_entries)} document(s) and imported {copied} into files/. "
"Go to Step 2 to translate (names.json is handled later, in Step 3)." "Curate names in Step 2, then translate in Step 3."
) )
self._run_task(task) self._run_task(task)
@ -950,7 +979,7 @@ class WolfWorkflowTab(QWidget):
layout.addWidget(self._desc( layout.addWidget(self._desc(
"vocab.txt is the project-wide glossary used by every translation batch to keep " "vocab.txt is the project-wide glossary used by every translation batch to keep "
"character names, speech register, honorifics, and lore terms consistent. Build it " "character names, speech register, honorifics, and lore terms consistent. Build it "
"before Step 2 so the AI already knows every character and term while translating. " "before translating (Step 3) so the AI already knows every character and term. "
"Copy the prompt below into Cursor or Copilot Chat with the extracted files/ JSON " "Copy the prompt below into Cursor or Copilot Chat with the extracted files/ JSON "
"open, let it analyse the game, then paste its output into the editor and save." "open, let it analyse the game, then paste its output into the editor and save."
)) ))
@ -991,8 +1020,9 @@ class WolfWorkflowTab(QWidget):
layout.addLayout(vrow) layout.addLayout(vrow)
note = self._desc( note = self._desc(
"Item / skill / enemy value names (names.json) are handled later in Step 3 - they " "You don't need to add item / skill / enemy value names here: they're curated in "
"are not part of vocab.txt and must not all be translated." "Step 2 and the safe ones are harvested into vocab.txt automatically during Phase 0. "
"Focus this glossary on characters and worldbuilding terms."
) )
layout.addWidget(note) layout.addWidget(note)
@ -1016,24 +1046,65 @@ class WolfWorkflowTab(QWidget):
except Exception as exc: except Exception as exc:
self._log(f"❌ Could not save vocab.txt: {exc}") self._log(f"❌ Could not save vocab.txt: {exc}")
# ── Step 2: Translate ────────────────────────────────────────────────────── # ── Step 3: Translate (phased) ─────────────────────────────────────────────
def _build_step2_translate(self, layout: QVBoxLayout): def _build_step2_translate(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 2 · Translate")) layout.addWidget(_make_section_label("Step 3 · Translate (phased)"))
layout.addWidget(self._desc( layout.addWidget(self._desc(
"Sends the extracted files in files/ to the Translation tab using the " "Sends the extracted files in files/ to the Translation tab using the Wolf RPG "
"Wolf RPG (WolfDawn) module and starts translating. Only the 'text' fields are " "(WolfDawn) module. Only the 'text' fields are filled in; 'source' is preserved so "
"filled in; 'source' is preserved so injection can verify each line." "injection can verify each line. Run the phases in order: Phase 0 translates the safe "
"names and writes them into vocab.txt, so Phases 1 and 2 translate descriptions and "
"dialogue with consistent item / skill / character names (RPGMaker's DB-first strategy)."
)) ))
self._add_tl_mode_selector(layout) self._add_tl_mode_selector(layout)
self._add_phase_buttons(layout)
self._add_speaker_options(layout) self._add_speaker_options(layout)
self._add_wrap_options(layout) self._add_wrap_options(layout)
btn = self._register(_make_btn("Translate all files now", "#00a86b")) def _add_phase_buttons(self, layout: QVBoxLayout):
btn.clicked.connect(lambda: self._navigate_to_translation(auto_start=True)) """Three ordered translation phases selected by manifest kind."""
layout.addWidget(btn) layout.addWidget(_make_hr())
layout.addWidget(self._subheading("Phase 0 · Names → vocab.txt"))
layout.addWidget(self._desc(
"Translates only the safe name categories you approved in Step 2 and harvests them into "
"vocab.txt (grouped by category, e.g. \"Weapon · 武器\"). Curate safe categories in Step 2 "
"first, or nothing is translated. Also runnable from Step 2."
))
p0 = self._register(_make_btn("▶ Phase 0 · Translate names", "#00a86b"))
p0.clicked.connect(
lambda: self._navigate_to_translation(kinds=PHASE_NAMES_KINDS, auto_start=True)
)
layout.addWidget(p0)
layout.addWidget(_make_hr())
layout.addWidget(self._subheading("Phase 1 · Database text"))
layout.addWidget(self._desc(
"Item / skill / state descriptions and system messages from DataBase.project and "
"CDataBase.project. Run after Phase 0 so names stay consistent."
))
p1 = self._register(_make_btn("▶ Phase 1 · Translate database text", "#00a86b"))
p1.clicked.connect(
lambda: self._navigate_to_translation(kinds=PHASE_DB_KINDS, auto_start=True)
)
layout.addWidget(p1)
layout.addWidget(_make_hr())
layout.addWidget(self._subheading("Phase 2 · Dialogue & events"))
layout.addWidget(self._desc(
"The bulk of the script: map events (.mps), CommonEvent, Game.dat and Evtext. Run last "
"so it benefits from the names and terms translated in the earlier phases."
))
p2 = self._register(_make_btn("▶ Phase 2 · Translate dialogue & events", "#00a86b"))
p2.clicked.connect(
lambda: self._navigate_to_translation(kinds=PHASE_DIALOGUE_KINDS, auto_start=True)
)
layout.addWidget(p2)
layout.addWidget(_make_hr())
open_btn = self._register(_make_btn("Open Translation tab (no auto-start)", "#3a3a3a")) open_btn = self._register(_make_btn("Open Translation tab (no auto-start)", "#3a3a3a"))
open_btn.clicked.connect(lambda: self._navigate_to_translation(auto_start=False)) open_btn.clicked.connect(lambda: self._navigate_to_translation(auto_start=False))
layout.addWidget(open_btn) layout.addWidget(open_btn)
@ -1217,8 +1288,18 @@ class WolfWorkflowTab(QWidget):
return BATCH_MODE_LABEL return BATCH_MODE_LABEL
return "Translate" return "Translate"
def _navigate_to_translation(self, only: str | None = None, auto_start: bool = False): def _navigate_to_translation(
"""Switch to the Translation tab, select the WolfDawn module, and check files.""" self,
only: str | None = None,
kinds: set[str] | None = None,
auto_start: bool = False,
):
"""Switch to the Translation tab, select the WolfDawn module, and check files.
Selection precedence: ``only`` (a single filename) > ``kinds`` (a phase's
manifest kinds, e.g. ``{"db"}``) > legacy bulk (everything except
names.json).
"""
pw = self.parent_window pw = self.parent_window
tt = getattr(pw, "translation_tab", None) if pw else None tt = getattr(pw, "translation_tab", None) if pw else None
if tt is None: if tt is None:
@ -1243,6 +1324,7 @@ class WolfWorkflowTab(QWidget):
except Exception: except Exception:
pass pass
kind_map = self._manifest_kinds() if kinds is not None else {}
try: try:
tt.refresh_file_lists() tt.refresh_file_lists()
fl = tt.file_list fl = tt.file_list
@ -1250,10 +1332,13 @@ class WolfWorkflowTab(QWidget):
item = fl.item(idx) item = fl.item(idx)
name = item.text() name = item.text()
if only is not None: if only is not None:
# Targeted run (e.g. Step 3 names): check exactly that file. # Targeted run: check exactly that file.
check = name == only check = name == only
elif kinds is not None:
# Phase run: check files whose manifest kind is in this phase.
check = kind_map.get(name) in kinds
else: else:
# Bulk run: everything except names.json (handled in Step 3). # Bulk run: everything except names.json.
check = name != NAMES_JSON check = name != NAMES_JSON
item.setCheckState(Qt.Checked if check else Qt.Unchecked) item.setCheckState(Qt.Checked if check else Qt.Unchecked)
except Exception: except Exception:
@ -1271,10 +1356,10 @@ class WolfWorkflowTab(QWidget):
lambda: tt.start_translation(skip_confirm=True) if tt is not None else None, lambda: tt.start_translation(skip_confirm=True) if tt is not None else None,
) )
# ── Step 3: Names ────────────────────────────────────────────────────────── # ── Step 2: Names ──────────────────────────────────────────────────────────
def _build_step3_names(self, layout: QVBoxLayout): def _build_step3_names(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 3 · Curate Name Values (names.json)")) layout.addWidget(_make_section_label("Step 2 · Curate Name Values (names.json)"))
layout.addWidget(self._desc( layout.addWidget(self._desc(
"names.json lists every item / skill / enemy / variable value name WolfDawn found. " "names.json lists every item / skill / enemy / variable value name WolfDawn found. "
"Translating all of them WILL break the game: many double as logic keys (compared by " "Translating all of them WILL break the game: many double as logic keys (compared by "
@ -1345,13 +1430,17 @@ class WolfWorkflowTab(QWidget):
self._reload_notes_list() self._reload_notes_list()
layout.addWidget(_make_hr()) layout.addWidget(_make_hr())
layout.addWidget(self._subheading("3 · Translate the safe names")) layout.addWidget(self._subheading("3 · Translate the safe names (Phase 0)"))
layout.addWidget(self._desc( layout.addWidget(self._desc(
"After saving, translate only the approved categories. The WolfDawn module skips every " "After saving, translate only the approved categories. The WolfDawn module skips every "
"other name (leaving it identical to the source), then Step 4 injects the result." "other name (leaving it identical to the source) and harvests the translated names into "
"vocab.txt (grouped by category) so the later Translate phases stay consistent. This is "
"the same as Phase 0 in Step 3; run it here first, or from the Translate step."
)) ))
tl_btn = self._register(_make_btn("Translate safe names now", "#00a86b")) tl_btn = self._register(_make_btn("Translate safe names now (Phase 0)", "#00a86b"))
tl_btn.clicked.connect(lambda: self._navigate_to_translation(only=NAMES_JSON, auto_start=True)) tl_btn.clicked.connect(
lambda: self._navigate_to_translation(kinds=PHASE_NAMES_KINDS, auto_start=True)
)
layout.addWidget(tl_btn) layout.addWidget(tl_btn)
def _copy_wolf_names_prompt(self): def _copy_wolf_names_prompt(self):
@ -1485,7 +1574,7 @@ class WolfWorkflowTab(QWidget):
layout.addWidget(_make_section_label("Step 4 · Inject Translations")) layout.addWidget(_make_section_label("Step 4 · Inject Translations"))
layout.addWidget(self._desc( layout.addWidget(self._desc(
"Writes the translated text back into the game's Data/ binaries with WolfDawn, " "Writes the translated text back into the game's Data/ binaries with WolfDawn, "
"byte-exact. The curated name values from Step 3 are applied across Data/ (only the " "byte-exact. The curated name values from Step 2 are applied across Data/ (only the "
"entries you translated change). Lines whose inline codes changed are skipped and " "entries you translated change). Lines whose inline codes changed are skipped and "
"reported (unless you allow drift)." "reported (unless you allow drift)."
)) ))
@ -1516,7 +1605,7 @@ class WolfWorkflowTab(QWidget):
"For iterating: after translating a few files, tick just those here and inject them " "For iterating: after translating a few files, tick just those here and inject them "
"straight into the game's Data/ so you can review the git diff in the game project and " "straight into the game's Data/ so you can review the git diff in the game project and "
"test in-game. Only files that already have a translation in translated/ are listed. " "test in-game. Only files that already have a translation in translated/ are listed. "
f"{NAMES_JSON} appears here once you've curated and saved it in Step 3; tick it to " f"{NAMES_JSON} appears here once you've curated and saved it in Step 2; tick it to "
"apply the safe name values across Data/." "apply the safe name values across Data/."
)) ))
@ -1550,7 +1639,7 @@ class WolfWorkflowTab(QWidget):
layout.addWidget(_make_hr()) layout.addWidget(_make_hr())
layout.addWidget(self._subheading("Full inject")) layout.addWidget(self._subheading("Full inject"))
layout.addWidget(self._desc( layout.addWidget(self._desc(
"Injects every extracted document and applies the curated name values (Step 3) " "Injects every extracted document and applies the curated name values (Step 2) "
"across Data/. Use this once translation is complete before packaging in Step 5." "across Data/. Use this once translation is complete before packaging in Step 5."
)) ))
inject_btn = self._register(_make_btn("Inject all translations", "#00a86b")) inject_btn = self._register(_make_btn("Inject all translations", "#00a86b"))
@ -1566,8 +1655,8 @@ class WolfWorkflowTab(QWidget):
return None return None
def _on_step_changed(self, idx: int): def _on_step_changed(self, idx: int):
# Index 3 == Names: refresh the note-category checklist from names.json. # Index 2 == Names: refresh the note-category checklist from names.json.
if idx == 3: if idx == 2:
self._reload_notes_list() self._reload_notes_list()
# Index 4 == Inject: keep the quick-inject picker in sync with translated/. # Index 4 == Inject: keep the quick-inject picker in sync with translated/.
elif idx == 4: elif idx == 4:
@ -1597,7 +1686,7 @@ class WolfWorkflowTab(QWidget):
self.inject_list.addItem(item) self.inject_list.addItem(item)
listed += 1 listed += 1
if listed == 0: if listed == 0:
item = QListWidgetItem("No translated files in translated/ yet — run Step 2.") item = QListWidgetItem("No translated files in translated/ yet — run Step 3.")
item.setFlags(Qt.ItemIsEnabled) item.setFlags(Qt.ItemIsEnabled)
self.inject_list.addItem(item) self.inject_list.addItem(item)
@ -1643,7 +1732,7 @@ class WolfWorkflowTab(QWidget):
if p: if p:
json_files.append(str(p)) json_files.append(str(p))
if not json_files: if not json_files:
return False, "No translated JSON found. Run Step 2 first." return False, "No translated JSON found. Run Step 3 first."
res = wolfdawn.names_check(json_files, log_fn=log) res = wolfdawn.names_check(json_files, log_fn=log)
if res.returncode == 0: if res.returncode == 0:
return True, "Name usage is consistent across files." return True, "Name usage is consistent across files."
@ -1660,7 +1749,7 @@ class WolfWorkflowTab(QWidget):
are applied across Data/ only when NAMES_JSON is included. are applied across Data/ only when NAMES_JSON is included.
Name values are applied only when translated/names.json exists (i.e. the Name values are applied only when translated/names.json exists (i.e. the
user curated it in Step 3); otherwise names are left untouched. user curated it in Step 2); otherwise names are left untouched.
""" """
if not self._require_manifest(): if not self._require_manifest():
return return
@ -1738,7 +1827,7 @@ class WolfWorkflowTab(QWidget):
log(f" ⚠ inject exit {res.returncode} for {entry['json']}") log(f" ⚠ inject exit {res.returncode} for {entry['json']}")
# Curated name values: apply across the whole data dir, but only when a # Curated name values: apply across the whole data dir, but only when a
# curated translated/names.json exists (Step 3). For a quick inject, only # curated translated/names.json exists (Step 2). For a quick inject, only
# do this if the user explicitly ticked the names file, since it rewrites # do this if the user explicitly ticked the names file, since it rewrites
# many binaries and would add noise to the git diff otherwise. # many binaries and would add noise to the git diff otherwise.
names_entry = next((e for e in entries if e["kind"] == "names"), None) names_entry = next((e for e in entries if e["kind"] == "names"), None)

View file

@ -23,7 +23,10 @@ names) that break the game if translated. So for ``kind == "names"`` documents,
only entries whose ``note`` is in the opt-in safe list (``SAFE_NOTES``, from only entries whose ``note`` is in the opt-in safe list (``SAFE_NOTES``, from
``data/wolf_safe_notes.json`` via :mod:`util.wolf_names`) are translated; the rest ``data/wolf_safe_notes.json`` via :mod:`util.wolf_names`) are translated; the rest
are left as source. The list is empty by default, so nothing is translated until are left as source. The list is empty by default, so nothing is translated until
the user opts categories in from the workflow. the user opts categories in from the workflow. After translating names.json, the
safe entries are harvested into ``vocab.txt`` (grouped by ``note``, with bilingual
headers) so later phases (DB descriptions, dialogue) keep item / skill / term
names consistent - the WOLF equivalent of RPGMaker's DB-first glossary seeding.
Text wrapping: translated dialogue is re-wrapped to a character width (like the 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. RPGMaker module, via :mod:`util.dazedwrap`) so English fits WOLF's message box.
@ -61,6 +64,7 @@ from util.translation import (
calculateCost, calculateCost,
) )
from util import speakers as wolf_speakers from util import speakers as wolf_speakers
from util import vocab as wolf_vocab
from util import wolf_names from util import wolf_names
# Globals (mirror the other engine modules; populated from .env at import time) # Globals (mirror the other engine modules; populated from .env at import time)
@ -131,13 +135,17 @@ 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, SPEAKER_CONFIG global ESTIMATE, TOKENS, FILENAME, SAFE_NOTES, SPEAKER_CONFIG, VOCAB
ESTIMATE = estimate ESTIMATE = estimate
FILENAME = filename FILENAME = filename
# Re-read workflow-configured settings so edits made this session take effect # Re-read workflow-configured settings so edits made this session take effect
# even when translation runs in-process (the 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() SPEAKER_CONFIG = wolf_speakers.load_config()
# Reload the glossary so a later phase (DB text / dialogue) picks up names
# that an earlier Phase 0 (names) harvested into vocab.txt.
VOCAB = VOCAB_PATH.read_text(encoding="utf-8")
TRANSLATION_CONFIG.vocab = VOCAB
start = time.time() start = time.time()
translatedData = openFiles(filename) translatedData = openFiles(filename)
@ -303,9 +311,40 @@ def parseDocument(data, filename):
else: else:
entry["text"] = _wrap_plain(text, is_firstline) entry["text"] = _wrap_plain(text, is_firstline)
# Phase 0 feeds the glossary: harvest the just-translated safe name values
# into vocab.txt (grouped by note) so the DB-text and dialogue phases keep
# item/skill/term names consistent. Mirrors the RPGMaker DB-first strategy.
if is_names and not ESTIMATE:
_harvest_names_to_vocab(data)
return [data, totalTokens, None] return [data, totalTokens, None]
def _harvest_names_to_vocab(data):
"""Write translated safe names.json entries into vocab.txt, grouped by note.
Each note category becomes a bilingual ``# English · 日本語`` section (English
sourced from the staged DB labels, else a static map, else JP-only). Only
safe notes with an actual translation are written; the base vocab section is
preserved by :func:`util.vocab.update_vocab_section`.
"""
try:
db_labels = wolf_names.derive_db_labels("files")
by_note: dict[str, list] = {}
for entry in data.get("names") or []:
note = entry.get("note", "")
if not wolf_names.is_note_safe(note, SAFE_NOTES):
continue
src, dst = entry.get("source"), entry.get("text")
if not isinstance(src, str) or not isinstance(dst, str):
continue
by_note.setdefault(note, []).append((src, dst))
for note, pairs in by_note.items():
wolf_vocab.update_vocab_section(wolf_names.note_header(note, db_labels), pairs)
except Exception:
traceback.print_exc()
def getResultString(translatedData, translationTime, filename): def getResultString(translatedData, translationTime, filename):
"""Format the per-file / total cost + status line for the console log.""" """Format the per-file / total cost + status line for the console log."""
cost = calculateCost(translatedData[1][0], translatedData[1][1], MODEL) cost = calculateCost(translatedData[1][0], translatedData[1][1], MODEL)

68
tests/test_vocab.py Normal file
View file

@ -0,0 +1,68 @@
"""Tests for util.vocab (game-specific glossary helpers)."""
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import util.vocab as vocab
class TestUpdateVocabSection(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.vocab_path = Path(self.tmp.name) / "vocab.txt"
self.base_path = Path(self.tmp.name) / "vocab_base.txt"
self.base_path.write_text("# Base\nhello (hello)\n", encoding="utf-8")
self.vocab_path.write_text(
"# Game Characters\nAlice (Alice)\n\n"
+ vocab.BASE_SEPARATOR
+ "# Base\nhello (hello)\n",
encoding="utf-8",
)
self._p_vocab = patch.object(vocab, "VOCAB_PATH", self.vocab_path)
self._p_base = patch.object(vocab, "VOCAB_BASE_PATH", self.base_path)
self._p_vocab.start()
self._p_base.start()
def tearDown(self):
self._p_vocab.stop()
self._p_base.stop()
self.tmp.cleanup()
def test_inserts_section_above_base_separator(self):
vocab.update_vocab_section("Weapon · 武器", [("", "Sword")])
text = self.vocab_path.read_text(encoding="utf-8")
self.assertIn("# Weapon · 武器\n剣 (Sword)", text)
self.assertIn(vocab.BASE_SEPARATOR, text)
self.assertIn("# Base\nhello (hello)", text)
# Game section must precede the base separator.
self.assertLess(text.index("# Weapon · 武器"), text.index(vocab.BASE_SEPARATOR))
def test_replaces_existing_section(self):
vocab.update_vocab_section("Game Characters", [("Bob (Bob)", "Robert")])
vocab.update_vocab_section("Game Characters", [("Alice", "Alicia")])
text = self.vocab_path.read_text(encoding="utf-8")
self.assertIn("Alice (Alicia)", text)
self.assertNotIn("Bob (Robert)", text)
self.assertEqual(text.count("# Game Characters"), 1)
def test_skips_noop_pairs(self):
before = self.vocab_path.read_text(encoding="utf-8")
vocab.update_vocab_section("Items", [("Potion", "Potion"), ("", "X")])
after = self.vocab_path.read_text(encoding="utf-8")
self.assertEqual(before, after)
def test_dedupes_by_source_last_wins(self):
vocab.update_vocab_section("Skill · 技能", [("ヒール", "Heal"), ("ヒール", "Cure")])
text = self.vocab_path.read_text(encoding="utf-8")
self.assertIn("ヒール (Cure)", text)
self.assertNotIn("ヒール (Heal)", text)
if __name__ == "__main__":
unittest.main()

View file

@ -53,5 +53,46 @@ class TestIsNoteSafe(unittest.TestCase):
self.assertFalse(wn.is_note_safe("", [])) self.assertFalse(wn.is_note_safe("", []))
class TestNoteHeader(unittest.TestCase):
def test_static_fallback_is_bilingual(self):
self.assertEqual(wn.note_header("武器"), "Weapon · 武器")
def test_live_db_label_wins_over_static(self):
labels = {"武器": "Blade"}
self.assertEqual(wn.note_header("武器", labels), "Blade · 武器")
def test_unknown_note_stays_japanese_only(self):
self.assertEqual(wn.note_header("■MOBセリフ"), "■MOBセリフ")
class TestDeriveDbLabels(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.dir = Path(self._tmp.name)
def tearDown(self):
self._tmp.cleanup()
def test_parses_bilingual_typenames_from_db_files(self):
import json
(self.dir / "DataBase.project.json").write_text(
json.dumps({
"kind": "db",
"groups": [
{"typeName": "Weapon · 武器", "lines": []},
{"typeName": "Skill · 技能", "lines": []},
{"typeName": "■MOBセリフ", "lines": []}, # no separator -> skipped
],
}),
encoding="utf-8",
)
labels = wn.derive_db_labels(self.dir)
self.assertEqual(labels, {"武器": "Weapon", "技能": "Skill"})
def test_missing_dir_returns_empty(self):
self.assertEqual(wn.derive_db_labels(self.dir / "nope"), {})
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -38,19 +38,30 @@ class _WolfTranslateHarness:
def __init__(self): def __init__(self):
self.captured = [] self.captured = []
# Captured (category, pairs) from the names -> vocab.txt harvest, so tests
# can assert on it without writing to the real data/vocab.txt.
self.vocab_writes = []
def run(self, data, filename="doc.json", estimate=False, safe_notes=None): def run(self, data, filename="doc.json", estimate=False, safe_notes=None):
def translate(text, history, history_ctx=None): def translate(text, history, history_ctx=None):
self.captured.append(copy.deepcopy(text)) self.captured.append(copy.deepcopy(text))
return _mock_translate(text, history, history_ctx) return _mock_translate(text, history, history_ctx)
def capture_vocab(category, pairs):
self.vocab_writes.append((category, list(pairs)))
orig_t = wd.translateAI orig_t = wd.translateAI
orig_estimate = wd.ESTIMATE orig_estimate = wd.ESTIMATE
orig_wrap = wd.WRAP orig_wrap = wd.WRAP
orig_notes = wd.SAFE_NOTES orig_notes = wd.SAFE_NOTES
orig_update = wd.wolf_vocab.update_vocab_section
orig_labels = wd.wolf_names.derive_db_labels
wd.translateAI = translate wd.translateAI = translate
wd.ESTIMATE = estimate wd.ESTIMATE = estimate
wd.WRAP = False # keep write-back byte-faithful; wrapping tested separately wd.WRAP = False # keep write-back byte-faithful; wrapping tested separately
# Never touch the real glossary / DB files during tests.
wd.wolf_vocab.update_vocab_section = capture_vocab
wd.wolf_names.derive_db_labels = lambda _p: {}
if safe_notes is not None: if safe_notes is not None:
wd.SAFE_NOTES = safe_notes wd.SAFE_NOTES = safe_notes
try: try:
@ -62,6 +73,8 @@ class _WolfTranslateHarness:
wd.ESTIMATE = orig_estimate wd.ESTIMATE = orig_estimate
wd.WRAP = orig_wrap wd.WRAP = orig_wrap
wd.SAFE_NOTES = orig_notes wd.SAFE_NOTES = orig_notes
wd.wolf_vocab.update_vocab_section = orig_update
wd.wolf_names.derive_db_labels = orig_labels
MAP_DOC = { MAP_DOC = {
@ -168,6 +181,20 @@ class TestTranslationWriteback(unittest.TestCase):
for entry in data["names"]: for entry in data["names"]:
self.assertEqual(entry["text"], entry["source"]) self.assertEqual(entry["text"], entry["source"])
def test_names_harvest_to_vocab(self):
# Phase 0 seeds vocab.txt: only translated safe-note pairs are harvested,
# under a bilingual header (English from the static NOTE_EN fallback).
harness = _WolfTranslateHarness()
(_data, _t, err), _c = harness.run(NAMES_DOC, "names.json", safe_notes=["武器"])
self.assertIsNone(err)
self.assertEqual(harness.vocab_writes, [("Weapon · 武器", [("", "EN_剣")])])
def test_names_harvest_skipped_in_estimate(self):
# Estimate mode must not seed the glossary.
harness = _WolfTranslateHarness()
harness.run(NAMES_DOC, "names.json", estimate=True, safe_notes=["武器"])
self.assertEqual(harness.vocab_writes, [])
def test_txtdir_translates(self): def test_txtdir_translates(self):
(data, _t, err), _c = _WolfTranslateHarness().run(TXTDIR_DOC, "Evtext.json") (data, _t, err), _c = _WolfTranslateHarness().run(TXTDIR_DOC, "Evtext.json")
self.assertIsNone(err) self.assertIsNone(err)

View file

@ -17,6 +17,10 @@ otherwise the base section would not be stripped on reload.
from __future__ import annotations from __future__ import annotations
import os
import re
import threading
from util.paths import VOCAB_BASE_PATH, VOCAB_PATH from util.paths import VOCAB_BASE_PATH, VOCAB_PATH
BASE_SEPARATOR = ( BASE_SEPARATOR = (
@ -25,6 +29,10 @@ BASE_SEPARATOR = (
_EMPTY_PLACEHOLDER = "# Add character glossary entries here\n" _EMPTY_PLACEHOLDER = "# Add character glossary entries here\n"
# Guards the read-modify-write in update_vocab_section against concurrent
# translation file-threads clobbering each other's sections.
_VOCAB_LOCK = threading.Lock()
def read_game_vocab() -> str: def read_game_vocab() -> str:
"""Return the game-specific portion of ``vocab.txt`` (base section stripped).""" """Return the game-specific portion of ``vocab.txt`` (base section stripped)."""
@ -45,3 +53,77 @@ def write_game_vocab(game_text: str) -> None:
) )
combined = game_text + "\n\n" + BASE_SEPARATOR + base_text combined = game_text + "\n\n" + BASE_SEPARATOR + base_text
VOCAB_PATH.write_text(combined, encoding="utf-8") VOCAB_PATH.write_text(combined, encoding="utf-8")
def _norm(s: str) -> str:
"""Normalise for no-op detection: collapse whitespace and case-fold."""
if s is None:
return ""
return re.sub(r"\s+", " ", str(s)).strip().casefold()
def update_vocab_section(category: str, pairs) -> None:
"""Insert or replace a ``# {category}`` section in the game-specific vocab.
Mirrors the RPGMaker auto-glossary behaviour (translated DB names feed
``vocab.txt`` so later phases stay consistent), but always writes *above*
the auto-appended base section (:data:`BASE_SEPARATOR`) so the base vocab is
preserved and not stripped on the next :func:`read_game_vocab`.
- ``category``: section header text, e.g. ``"Weapon · 武器"``.
- ``pairs``: iterable of ``(source, translated)``. Deduped by source (last
wins); no-ops (empty translation or unchanged after normalisation) are
dropped. When nothing survives filtering the file is left untouched.
"""
dedup: dict[str, str] = {}
for src, dst in pairs:
if not src:
continue
if dst is None or _norm(dst) == "" or _norm(dst) == _norm(src):
continue
dedup[str(src)] = str(dst)
if not dedup:
return
with _VOCAB_LOCK:
existing = VOCAB_PATH.read_text(encoding="utf-8") if VOCAB_PATH.is_file() else ""
# Keep the auto-appended base section (separator + base vocab) intact.
idx = existing.find(BASE_SEPARATOR)
if idx != -1:
game_part = existing[:idx]
base_part = existing[idx:]
else:
game_part = existing
base_part = ""
block_lines = [f"{src} ({dst})" for src, dst in dedup.items()]
new_block = f"# {category}\n" + "\n".join(block_lines) + "\n\n"
# Match this category's section up to the next '#' header or end of the
# game portion. Handles '#Cat', '# Cat', '## Cat', etc.
pattern = re.compile(
rf"^[\t ]*#+\s*{re.escape(category)}\s*$\r?\n.*?(?=^[\t ]*#|\Z)",
re.MULTILINE | re.DOTALL,
)
if pattern.search(game_part):
new_game = pattern.sub(lambda _m: new_block, game_part, count=1)
else:
new_game = game_part.rstrip("\n")
if new_game:
new_game += "\n\n"
new_game += new_block
if base_part:
combined = new_game.rstrip("\n") + "\n\n" + base_part
else:
combined = new_game
if combined == existing:
return
tmp_path = VOCAB_PATH.with_suffix(
VOCAB_PATH.suffix + f".{os.getpid()}.{threading.get_ident()}.tmp"
)
tmp_path.write_text(combined, encoding="utf-8")
os.replace(tmp_path, VOCAB_PATH)

View file

@ -28,6 +28,30 @@ from util.paths import DATA_DIR
SAFE_NOTES_PATH = DATA_DIR / "wolf_safe_notes.json" SAFE_NOTES_PATH = DATA_DIR / "wolf_safe_notes.json"
# WolfDawn labels standard database types bilingually in a document's group
# ``typeName`` (e.g. ``"Weapon · 武器"``). names.json ``note`` values are the JP
# half only, so this static map provides an English label for the common,
# safe-to-translate categories when the live DB labels are unavailable. Games
# with custom categories fall back to a JP-only header.
NOTE_EN: dict[str, str] = {
"武器": "Weapon",
"防具": "Armor",
"技能": "Skill",
"アイテム": "Item",
"状態設定": "State Setting",
"用語設定": "Term Setting",
"戦闘コマンド": "Battle Command",
"システム設定": "System Setting",
"属性名の設定": "Attribute",
"主人公ステータス": "Hero Status",
"敵グループ": "Enemy Group",
"敵キャラ個体データ": "Enemy Data",
}
# WolfDawn joins the English and Japanese halves of a database label with this
# separator, e.g. ``"Weapon · 武器"``.
_LABEL_SEP = " · "
def load_safe_notes() -> list[str]: def load_safe_notes() -> list[str]:
"""Return the saved list of ``note`` categories that are safe to translate.""" """Return the saved list of ``note`` categories that are safe to translate."""
@ -61,3 +85,50 @@ def is_note_safe(note: str, safe_notes) -> bool:
if not safe_notes: if not safe_notes:
return False return False
return note in safe_notes return note in safe_notes
def derive_db_labels(files_dir) -> dict[str, str]:
"""Build a ``{japanese_note: english}`` map from staged WolfDawn ``db`` files.
WolfDawn tags each database group with a bilingual ``typeName`` (e.g.
``"Weapon · 武器"``). Splitting on the separator recovers the game-accurate
English label for a names.json ``note`` (the JP half), so harvested vocab
sections can be headed bilingually without hardcoding categories per game.
"""
from pathlib import Path
labels: dict[str, str] = {}
try:
base = Path(files_dir)
if not base.is_dir():
return labels
for path in sorted(base.glob("*.project.json")):
try:
data = json.loads(path.read_text(encoding="utf-8-sig"))
except Exception:
continue
if data.get("kind") != "db":
continue
for group in data.get("groups") or []:
type_name = group.get("typeName") or ""
if _LABEL_SEP in type_name:
english, _, japanese = type_name.partition(_LABEL_SEP)
english, japanese = english.strip(), japanese.strip()
if english and japanese and japanese not in labels:
labels[japanese] = english
except Exception:
pass
return labels
def note_header(note: str, db_labels: dict[str, str] | None = None) -> str:
"""Return a bilingual vocab section header for a names.json ``note``.
Prefers the live DB label (:func:`derive_db_labels`), then the static
:data:`NOTE_EN` map, and falls back to the JP note alone. Formats matching
WolfDawn's own labels, e.g. ``"Weapon · 武器"``.
"""
english = (db_labels or {}).get(note) or NOTE_EN.get(note)
if english and english != note:
return f"{english}{_LABEL_SEP}{note}"
return note