TL By Entry not Category for Wolf Dawn

This commit is contained in:
DazedAnon 2026-07-05 14:48:00 -05:00
parent 50c7e56778
commit f85ea1af35
8 changed files with 511 additions and 485 deletions

View file

@ -8,32 +8,26 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
Step 1 Pre-process - optional dazedformat + gameupdate/ copy before translating
Step 2 Glossary - build vocab.txt (characters / worldbuilding terms) before
translating so the AI keeps names and voice consistent
Step 3 Names - curate names.json (item/skill/enemy value names). Blindly
translating all of them breaks games that reference names by
value, so a repo-aware AI classifies which categories are safe
to translate and leaves the rest as source. Pick Normal/Batch
translation mode here and run Phase 0 (names -> vocab.txt).
Step 4 Translate - run the "Wolf RPG (WolfDawn)" module over files/ in three
ordered phases (RPGMaker's DB-first strategy; mode set in Step 3):
Phase 0 Names - also runnable from Step 3; harvests safe
name translations into vocab.txt
Step 3 Names - translate names.json (Phase 0). WolfDawn safe/refs entries
are translated per-name; verify names are skipped. Harvests
short name terms into vocab.txt.
Step 4 Translate - WolfDawn module over files/ in two phases (after Step 3):
Phase 1 Database - item/skill/state descriptions and system
messages (DataBase/CDataBase.project)
Phase 2 Maps / events - .mps maps, CommonEvent, Game.dat,
Evtext; speaker handling and text-wrap
settings live under this phase
Step 5 Inject - inject translations + curated names back into the Data/ binaries
Step 5 Inject - inject translations + translated names back into the Data/ binaries
and refresh the git-tracked wolf_json/ with the translated
JSON; quick-inject picks just a few translated files for
fast in-game / git iteration, or inject everything at once
Step 6 Package - run from a loose Data/ folder, or repack Data.wolf
Step 7 Saves - fix baked strings in existing .sav files (optional)
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
translating them all corrupts the game. Step 3 marks the safe subset (by category),
Phase 0 translates only those and harvests them into vocab.txt, and Step 5 injects
the result.
names.json is staged into files/ but is NOT translated in the bulk phases - WolfDawn
tags each name safe / refs / verify and Phase 0 translates only safe+refs entries
(per-name, not per-category), harvests them into vocab.txt, and Step 5 injects the
result.
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
@ -110,23 +104,6 @@ PHASE_NAMES_KINDS = {"names"}
PHASE_DB_KINDS = {"db"}
PHASE_MAPS_EVENTS_KINDS = {"map", "common", "gamedat", "txt", "txt-dir"}
# 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
# silent no-op (exit 0 but 0 applied because the base was already translated).
_INJECT_COUNTS_RE = re.compile(
r"applied\s+(\d+)\s+translation.*?(\d+)\s+drifted", re.IGNORECASE | re.DOTALL
)
def _parse_inject_counts(stdout: str):
"""Return (applied, drifted) ints from wolf inject output, or (None, None)."""
if not stdout:
return None, None
m = _INJECT_COUNTS_RE.search(stdout)
if not m:
return None, None
return int(m.group(1)), int(m.group(2))
# Glossary-discovery prompt for Copilot / Cursor, tailored to WolfDawn's extracted
# JSON (source/text pairs staged in files/). Produces the same two-section format
# the shared vocab.txt expects, so the AI output pastes straight into the editor.
@ -151,7 +128,7 @@ _WOLF_GLOSSARY_PROMPT = (
" - Game.dat.json — game/system strings (title, terms).\n"
" - <Map>.mps.json — per-map events: the main story dialogue (can be large).\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 (translated separately in Step 3; do NOT list them).\n"
"</data_format>\n"
"\n"
"<file_strategy>\n"
@ -182,8 +159,8 @@ _WOLF_GLOSSARY_PROMPT = (
"# Worldbuilding Terms — rules:\n"
"- Include: faction/organisation names, locations mentioned in dialogue but not on maps, "
"unique magic systems, lore titles, recurring in-universe concepts.\n"
"- 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"
"- Exclude: skill names, item names, weapon/armour names (translated via names.json in "
"Step 3). Skip generic RPG words. Do not repeat character names here.\n"
"</rules>\n"
"\n"
"<output_format>\n"
@ -214,62 +191,6 @@ _WOLF_GLOSSARY_PROMPT = (
)
# Names-safety prompt for a repo-aware AI (Cursor / Copilot with the extracted
# files/ JSON open). WolfDawn groups every value name in names.json under a WOLF
# database category (the 'note' field). Some categories are pure on-screen text,
# others are logic keys (variable / file / event names) that break the game if
# translated. The AI classifies by CATEGORY and returns the list of safe note
# values as a JSON array; the tool then translates only those categories.
_WOLF_NAMES_PROMPT = (
"You are an expert Japanese WOLF RPG Editor translator and reverse-engineer. Accuracy "
"matters more than coverage: translating the wrong names breaks the game, so be conservative.\n"
"\n"
"<background>\n"
"names.json (a 'kind':'names' object with a 'names' list) is a project-wide list of every "
"\"value name\" WolfDawn found referenced across the WOLF game. Each entry has a Japanese "
"'source', a 'text', an 'occurrences' count, and a 'note' - the WOLF database CATEGORY the "
"name came from (e.g. 武器 = weapons, 技能 = skills, システム変数名 = system variable names, "
"SEリスト = sound-effect list, マップ設定 = map settings).\n"
"Translating everything WILL break the game: many categories are logic keys - the engine "
"compares them by value, looks entries up by exact name, builds file/CG/BGM/SE paths from "
"them, or uses them as variable / switch / event / map names. Only categories that are "
"purely on-screen display text are safe to translate.\n"
"</background>\n"
"\n"
"--- attach the extracted JSON in files/ here (especially names.json) before continuing ---\n"
"\n"
"<task>\n"
"Group the names.json entries by their 'note' value and classify each DISTINCT note "
"category as SAFE or UNSAFE. Return the list of SAFE categories only. Do NOT translate "
"anything yourself and do NOT edit names.json - the tool does the translation, filtered to "
"the categories you approve.\n"
"</task>\n"
"\n"
"<how_to_decide>\n"
"For each note category, look at its example 'source' values and how they are used in the "
"other files/ JSON (grep the strings across maps / CommonEvent / databases):\n"
"- SAFE -> the category is text shown to the player and nothing depends on its exact value:\n"
" item / weapon / armour / skill names, enemy names, state names, class/job names,\n"
" in-game terms (用語), battle commands, attribute names, actor/party display names.\n"
"- UNSAFE -> the category is (or might be) used as an identifier / key:\n"
" variable / switch / string-variable names, SE / BGM / BGS lists, picture numbers,\n"
" character / face / parallax / window image names, map settings, event names,\n"
" anything looked up or compared by value, or built into a filename or path.\n"
"When a category is ambiguous, or its values look like identifiers (ASCII, digits, "
"underscores, paths, bracket tags like [SE]), classify it UNSAFE. Prefer leaving a category "
"out over risking a break.\n"
"</how_to_decide>\n"
"\n"
"<output_format>\n"
"Return ONLY a JSON array of the SAFE note strings, copied EXACTLY as they appear in the "
"'note' fields (same characters, including any brackets or symbols), inside ONE fenced code "
"block and nothing else. Example:\n"
"```json\n"
"[\"武器\", \"防具\", \"技能\", \"アイテム\", \"用語設定\"]\n"
"```\n"
"</output_format>\n"
)
# Speaker-format prompt for a repo-aware AI. WolfDawn already detects and tags who
# speaks on each line, so the only decision left is whether its LOW-confidence
# first-line guesses are really speaker names for this game. The AI inspects the
@ -1539,8 +1460,8 @@ class WolfWorkflowTab(QWidget):
layout.addLayout(vrow)
note = self._desc(
"You don't need to add item / skill / enemy value names here: they're curated in "
"Step 3 and the safe ones are harvested into vocab.txt automatically during Phase 0. "
"You don't need to add item / skill / enemy value names here: Phase 0 translates "
"WolfDawn safe/refs entries from names.json and harvests them into vocab.txt "
"Focus this glossary on characters and worldbuilding terms."
)
layout.addWidget(note)
@ -1572,33 +1493,19 @@ class WolfWorkflowTab(QWidget):
layout.addWidget(self._desc(
"Sends the extracted files in files/ to the Translation tab using the Wolf RPG "
"(WolfDawn) module. Only the 'text' fields are filled in; 'source' is preserved so "
"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 "
"in-engine text with consistent item / skill / character names (RPGMaker's DB-first strategy)."
"injection can verify each line. Run Step 3 (names) first so vocab.txt is seeded, "
"then Phase 1 (database text) and Phase 2 (maps / events) in order."
))
self._add_phase_buttons(layout)
def _add_phase_buttons(self, layout: QVBoxLayout):
"""Three ordered translation phases selected by manifest kind."""
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 3 and harvests them into "
"vocab.txt (grouped by category, e.g. \"Weapon · 武器\"). Curate safe categories and pick "
"the translation mode in Step 3 first, or nothing is translated. Also runnable from Step 3."
))
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)
"""Two translation phases (names are handled in Step 3)."""
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."
"CDataBase.project. Run after Step 3 (names) so vocab.txt stays consistent."
))
p1 = self._register(_make_btn("▶ Phase 1 · Translate database text", "#00a86b"))
p1.clicked.connect(
@ -1610,9 +1517,9 @@ class WolfWorkflowTab(QWidget):
layout.addWidget(self._subheading("Phase 2 · Maps / events"))
layout.addWidget(self._desc(
"Map scripts (.mps), common events (CommonEvent.dat), Game.dat, and Evtext - story "
"dialogue, UI/objective strings, and other event text. Run last so it benefits from "
"the names and terms translated in the earlier phases. Speaker handling and text wrap "
"below apply to the dialogue-like lines in this phase."
"dialogue, UI/objective strings, and other event text. Run after Phase 1 so it "
"benefits from the names and terms already in vocab.txt. Speaker handling and text "
"wrap below apply to the dialogue-like lines in this phase."
))
self._add_speaker_options(layout)
self._add_wrap_options(layout)
@ -1877,84 +1784,31 @@ class WolfWorkflowTab(QWidget):
# ── Step 2: Names ──────────────────────────────────────────────────────────
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 3 · Translate Name Values (names.json)"))
layout.addWidget(self._desc(
"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 "
"value, used as variable / file / event names). WolfDawn tags each name with its WOLF "
"database category (a 'note'), so instead of judging thousands of names you choose "
"which categories are safe on-screen text. The translator only touches those; by "
"default none are selected, so nothing changes until you opt categories in."
"names.json is WolfDawn's project-wide name glossary. Each entry has a safety badge: "
"safe (display-only), refs (referenced by name but rewritten on inject), or verify "
"(also in indirect literals - skipped). Phase 0 translates every safe and refs entry "
"individually, regardless of category, and harvests them into vocab.txt."
))
layout.addWidget(self._subheading("1 · Ask a repo-aware AI which categories are safe"))
layout.addWidget(self._desc(
"Copy the prompt into Cursor or Copilot Chat with the extracted files/ JSON open. It "
"groups names by category, checks how each is used, and returns a JSON list of the "
"safe categories in a code block. Conservative by design - ambiguous categories are "
"left out."
))
prompt_btn = _make_btn("📋 Copy names-safety prompt for Copilot / Cursor", "#5a3a7a")
prompt_btn.clicked.connect(self._copy_wolf_names_prompt)
layout.addWidget(prompt_btn)
self.names_summary_label = QLabel("Open this step after extraction to see name counts.")
self.names_summary_label.setWordWrap(True)
self.names_summary_label.setStyleSheet(
"color:#9cdcfe;font-size:13px;padding:8px 10px;"
"background-color:#1a2430;border:1px solid #2a4a6a;border-radius:4px;"
)
layout.addWidget(self.names_summary_label)
refresh_btn = _make_btn("↻ Refresh name counts", "#555")
refresh_btn.clicked.connect(self._refresh_names_summary)
layout.addWidget(refresh_btn)
layout.addWidget(_make_hr())
layout.addWidget(self._subheading("2 · Choose safe categories"))
layout.addWidget(self._subheading("Translate names (Phase 0)"))
layout.addWidget(self._desc(
"Paste the AI's JSON list below and click Apply to tick the matching categories, or "
"tick them by hand. Each row shows the category, how many names it has, and an "
"example. Save writes your choice to data/wolf_safe_notes.json."
))
self.names_paste = QTextEdit()
self.names_paste.setMaximumHeight(70)
self.names_paste.setFont(QFont("Consolas", 9))
self.names_paste.setPlaceholderText('Paste the AI list here, e.g. ["武器", "技能", "アイテム"]')
self.names_paste.setStyleSheet(
"QTextEdit{background-color:#252526;color:#d4d4d4;border:1px solid #3c3c3c;"
"border-radius:4px;padding:6px;selection-background-color:#264f78;}"
)
layout.addWidget(self.names_paste)
prow = QHBoxLayout()
apply_btn = _make_btn("Apply pasted list", "#007acc")
apply_btn.clicked.connect(self._apply_pasted_notes)
prow.addWidget(apply_btn)
prow.addStretch()
layout.addLayout(prow)
self.notes_list = QListWidget()
self.notes_list.setMinimumHeight(200)
self.notes_list.setStyleSheet(
"QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;"
"color:#cccccc;font-size:12px;padding:2px;}"
"QListWidget::item{padding:3px 4px;}"
"QListWidget::item:selected{background:#264f78;color:#ffffff;}"
)
layout.addWidget(self.notes_list, 1)
nrow = QHBoxLayout()
save_btn = _make_btn("💾 Save safe categories", "#3a7a3a")
save_btn.clicked.connect(self._save_safe_notes)
nrow.addWidget(save_btn)
reload_btn = _make_btn("↻ Refresh", "#555")
reload_btn.clicked.connect(self._reload_notes_list)
nrow.addWidget(reload_btn)
none_btn = _make_btn("Select none", "#3a3a3a")
none_btn.clicked.connect(lambda: self._set_note_checks(False))
nrow.addWidget(none_btn)
nrow.addStretch()
layout.addLayout(nrow)
self._reload_notes_list()
layout.addWidget(_make_hr())
layout.addWidget(self._subheading("3 · Translate the safe names (Phase 0)"))
layout.addWidget(self._desc(
"After saving, translate only the approved categories. The WolfDawn module skips every "
"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 first translation step - pick Normal or Batch below, then run Phase 0 here or from "
"Step 4 (the same mode applies to all phases)."
"Pick Normal or Batch below, then run Phase 0. Only safe and refs entries are sent to "
"the model; verify names stay identical to source so inject skips them."
))
self._add_tl_mode_selector(layout)
tl_btn = self._register(_make_btn("Translate safe names now (Phase 0)", "#00a86b"))
@ -1962,13 +1816,32 @@ class WolfWorkflowTab(QWidget):
lambda: self._navigate_to_translation(kinds=PHASE_NAMES_KINDS, auto_start=True)
)
layout.addWidget(tl_btn)
self._refresh_names_summary()
def _copy_wolf_names_prompt(self):
def _load_names_document(self) -> tuple[dict | None, Path | None]:
"""Read names.json from files/ or wolf_json/."""
src = self._names_json_path()
if src is None:
return None, None
try:
QApplication.clipboard().setText(_WOLF_NAMES_PROMPT)
self._log("📋 Names-safety prompt copied. Paste it into Cursor/Copilot with files/ open.")
with open(src, "r", encoding="utf-8-sig") as f:
return json.load(f), src
except Exception as exc:
self._log(f"❌ Could not copy names prompt: {exc}")
self._log(f"❌ Could not read {NAMES_JSON}: {exc}")
return None, src
def _refresh_names_summary(self):
if not hasattr(self, "names_summary_label"):
return
data, src = self._load_names_document()
if not isinstance(data, dict):
self.names_summary_label.setText(
"No names.json found - run Step 0 (Extract text) first."
)
return
summary = wolf_names.format_name_safety_summary(data)
where = src.parent.name if src else WORK_DIR_NAME
self.names_summary_label.setText(f"{summary}\nSource: {where}/{NAMES_JSON}")
def _names_json_path(self) -> Path | None:
"""Locate names.json for reading its note categories (files/ then wolf_json/)."""
@ -1978,124 +1851,14 @@ class WolfWorkflowTab(QWidget):
return p
return None
def _distinct_notes(self):
"""Return [(note, count, sample_source), ...] from names.json, or None."""
src = self._names_json_path()
if src is None:
return None
try:
with open(src, "r", encoding="utf-8-sig") as f:
data = json.load(f)
except Exception as exc:
self._log(f"❌ Could not read {NAMES_JSON}: {exc}")
return None
counts: dict[str, int] = {}
samples: dict[str, str] = {}
for entry in data.get("names", []):
if not isinstance(entry, dict):
continue
note = str(entry.get("note", ""))
counts[note] = counts.get(note, 0) + 1
if note not in samples:
samples[note] = str(entry.get("source", ""))
# Most populous categories first - the safe display ones tend to be larger.
ordered = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
return [(note, cnt, samples.get(note, "")) for note, cnt in ordered]
def _reload_notes_list(self):
if not hasattr(self, "notes_list"):
return
self.notes_list.clear()
notes = self._distinct_notes()
if notes is None:
item = QListWidgetItem("No names.json found — run Step 0 (Extract text) first.")
item.setFlags(Qt.ItemIsEnabled)
self.notes_list.addItem(item)
return
safe = set(wolf_names.load_safe_notes())
for note, cnt, sample in notes:
sample = sample.replace("\n", " ").replace("\r", " ")
if len(sample) > 40:
sample = sample[:40] + ""
label = f"{note or '(no category)'} · {cnt} name(s) · e.g. {sample}"
item = QListWidgetItem(label)
item.setData(Qt.UserRole, note)
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
item.setCheckState(Qt.Checked if note in safe else Qt.Unchecked)
self.notes_list.addItem(item)
def _set_note_checks(self, checked: bool):
if not hasattr(self, "notes_list"):
return
state = Qt.Checked if checked else Qt.Unchecked
for i in range(self.notes_list.count()):
it = self.notes_list.item(i)
if it.flags() & Qt.ItemIsUserCheckable:
it.setCheckState(state)
def _apply_pasted_notes(self):
text = self.names_paste.toPlainText().strip()
if text.startswith("```"):
lines = text.splitlines()
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip().startswith("```"):
lines = lines[:-1]
text = "\n".join(lines).strip()
try:
parsed = json.loads(text)
if not isinstance(parsed, list):
raise ValueError("expected a JSON array of note strings")
wanted = {str(n) for n in parsed}
except Exception as exc:
QMessageBox.warning(
self, "Invalid list",
f"Paste the AI's JSON array of category names.\n{exc}",
)
return
matched = 0
unmatched = set(wanted)
for i in range(self.notes_list.count()):
it = self.notes_list.item(i)
if not (it.flags() & Qt.ItemIsUserCheckable):
continue
note = it.data(Qt.UserRole)
if note in wanted:
it.setCheckState(Qt.Checked)
matched += 1
unmatched.discard(note)
else:
it.setCheckState(Qt.Unchecked)
msg = f"Ticked {matched} categor(y/ies) from the pasted list."
if unmatched:
msg += f" {len(unmatched)} not found in this game: {', '.join(sorted(unmatched))}"
self._log(msg)
def _save_safe_notes(self):
if not hasattr(self, "notes_list"):
return
safe = []
for i in range(self.notes_list.count()):
it = self.notes_list.item(i)
if (it.flags() & Qt.ItemIsUserCheckable) and it.checkState() == Qt.Checked:
safe.append(it.data(Qt.UserRole))
try:
wolf_names.save_safe_notes(safe)
self._log(
f"✅ Saved {len(safe)} safe categor(y/ies) to data/wolf_safe_notes.json. "
"Translate the safe names below, then inject in Step 5."
)
except Exception as exc:
self._log(f"❌ Could not save safe categories: {exc}")
# ── Step 4: Inject ─────────────────────────────────────────────────────────
def _build_step4_inject(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 5 · Inject Translations"))
layout.addWidget(self._desc(
"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 "
"entries you translated change). Lines whose inline codes changed are skipped and "
"byte-exact. Translated safe/refs name values from names.json are applied across Data/ "
"(only the entries you translated change). Lines whose inline codes changed are skipped and "
"reported (unless you allow drift). Full inject and any inject that includes "
f"{NAMES_JSON} reset live Data/ from {WORK_DIR_NAME}/originals/ first."
))
@ -2126,8 +1889,9 @@ class WolfWorkflowTab(QWidget):
"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 "
"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 "
"apply the safe name values across Data/."
f"{NAMES_JSON} appears here once Phase 0 has translated it (checked by default). "
"Name injection resets live Data/ from wolf_json/originals/ first, then applies "
"every safe/refs name across all binaries."
))
self.inject_list = QListWidget()
@ -2160,7 +1924,7 @@ class WolfWorkflowTab(QWidget):
layout.addWidget(_make_hr())
layout.addWidget(self._subheading("Full inject"))
layout.addWidget(self._desc(
"Injects every extracted document and applies the curated name values (Step 3) "
"Injects every extracted document and applies translated name values from names.json "
"across Data/. Use this once translation is complete before packaging in Step 6."
))
inject_btn = self._register(_make_btn("Inject all translations", "#00a86b"))
@ -2180,9 +1944,9 @@ class WolfWorkflowTab(QWidget):
self._current_step_index = idx
if previous == 0 and idx != 0:
self._auto_import_if_needed()
# Index 3 == Names: refresh the note-category checklist from names.json.
# Index 3 == Names: refresh the per-name safety summary.
if idx == 3:
self._reload_notes_list()
self._refresh_names_summary()
# Index 5 == Inject: keep the quick-inject picker in sync with translated/.
elif idx == 5:
self._refresh_inject_list()
@ -2207,7 +1971,8 @@ class WolfWorkflowTab(QWidget):
item = QListWidgetItem(json_name)
item.setData(Qt.UserRole, json_name)
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
item.setCheckState(Qt.Unchecked)
default_checked = json_name == NAMES_JSON
item.setCheckState(Qt.Checked if default_checked else Qt.Unchecked)
self.inject_list.addItem(item)
listed += 1
if listed == 0:
@ -2269,12 +2034,12 @@ class WolfWorkflowTab(QWidget):
"""Inject translations into the game's Data/ binaries.
only_json:
None full inject: every document + curated name values across Data/.
set(names) quick inject: only those JSON files; the curated name values
None full inject: every document + translated name values across Data/.
set(names) quick inject: only those JSON files; name values from names.json
are applied across Data/ only when NAMES_JSON is included.
Name values are applied only when translated/names.json exists (i.e. the
user curated it in Step 3); otherwise names are left untouched.
Name values are applied only when translated/names.json exists (i.e. Phase 0
has run); otherwise names are left untouched.
"""
if not self._require_manifest():
return
@ -2328,6 +2093,8 @@ class WolfWorkflowTab(QWidget):
log(f"Resetting live Data/ from {WORK_DIR_NAME}/originals/ …")
self._restore_live_from_originals(entries, data_dir_path, log)
names_restored = only_json is None or will_names
if only_json is None:
strings_targets = {
e["json"] for e in entries if e.get("kind") != "names"
@ -2366,10 +2133,18 @@ class WolfWorkflowTab(QWidget):
str(src), base, out,
allow_code_drift=allow_drift, en_punct=en_punct, log_fn=log,
)
a, d = _parse_inject_counts(res.stdout)
if res.ok and not (a == 0 and (d or 0) > 0):
a, d = wolfdawn.parse_strings_inject_counts(res.stdout)
strings_ok = wolfdawn.inject_had_applied(a) or (
res.ok and not (a == 0 and (d or 0) > 0)
)
if strings_ok:
applied += 1
_sync_json(entry["json"], src, log)
if not res.ok and wolfdawn.inject_had_applied(a):
log(
f"{entry['json']}: wolf exited {res.returncode} "
f"after applying {a} change(s) — see warnings above"
)
elif res.ok:
# Exit 0 but nothing applied and lines drifted = stale/injected base.
failed += 1
@ -2382,21 +2157,43 @@ class WolfWorkflowTab(QWidget):
failed += 1
log(f" ⚠ inject exit {res.returncode} for {entry['json']}")
# 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
# do this if the user explicitly ticked the names file, since it rewrites
# many binaries and would add noise to the git diff otherwise.
# Name values: apply across the whole data dir when translated/names.json
# exists. For a quick inject, only include NAMES_JSON when you want names
# applied (it rewrites many binaries).
if will_names and names_entry and names_src is not None:
log("Applying curated name values across Data/ …")
if not names_restored:
log(
f"Resetting live Data/ from {WORK_DIR_NAME}/originals/ "
"before name injection …"
)
self._restore_live_from_originals(entries, data_dir_path, log)
log("Applying translated name values across Data/ …")
res = wolfdawn.names_inject(
str(names_src), data_dir,
allow_code_drift=allow_drift, en_punct=en_punct, log_fn=log,
)
a, d = _parse_inject_counts(res.stdout)
if res.ok and not (a == 0 and (d or 0) > 0):
out = (res.stdout or "") + (res.stderr or "")
a, d = wolfdawn.parse_names_inject_counts(out)
# Keep wolf_json/names.json aligned with translated/ even when the
# binary pass is a no-op (stale base) or exits 2 after partial guards.
_sync_json(names_entry["json"], names_src, log)
if wolfdawn.inject_had_applied(a):
applied += 1
_sync_json(names_entry["json"], names_src, log)
elif res.ok:
if not res.ok:
log(
f" ⚠ names: wolf exited {res.returncode} after applying "
f"{a} name change(s) — see warnings above"
)
elif res.ok and a == 0:
failed += 1
log(
f" ⚠ names: 0 applied — the live Data/ binaries no longer contain "
"the Japanese 'source' strings in names.json (often because a prior "
"inject already translated them without resetting from originals). "
"Use “Inject all translations”, or tick names.json in quick inject "
f"(which resets from {WORK_DIR_NAME}/originals/ first)."
)
elif res.ok and (d or 0) > 0:
failed += 1
log(
f" ⚠ names: 0 applied, {d} skipped as drift — sources in "

View file

@ -17,16 +17,13 @@ Only entries whose ``source`` contains target-language (Japanese by default)
text are sent to the model; everything else keeps ``text == source`` so inject
is a no-op for it.
names.json safety: WolfDawn's ``names-extract`` groups every value name under a
``note`` category, and many categories are logic keys (variable / file / event
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
``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
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.
names.json safety: WolfDawn tags every value name with a static ``safety`` badge
(``safe``, ``refs``, or ``verify``). For ``kind == "names"`` documents, only
entries whose badge is ``safe`` or ``refs`` are translated; ``verify`` names and
legacy entries without a badge keep ``text == source``. After translating
names.json, translatable entries are harvested into ``vocab.txt`` (grouped by
``note``, with bilingual headers) so later phases keep item / skill / term names
consistent.
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.
@ -90,12 +87,7 @@ LANGREGEX = r"[一-龠ぁ-ゔァ-ヴーa---\uFF61-\uFF9F]+"
# configurable from the workflow (data/wolf_speakers.json).
SPEAKER_CONFIG = wolf_speakers.load_config()
# names.json safety: WolfDawn tags every value name with a ``note`` category. Many
# categories are logic keys (variable/file/event names) that break the game if
# translated, so only entries whose ``note`` is in this opt-in list are sent to
# the model; the rest keep text == source. Empty by default = translate nothing.
# Configured from the workflow (data/wolf_safe_notes.json).
SAFE_NOTES = wolf_names.load_safe_notes()
# names.json: translate per-entry safe/refs badges only (see util.wolf_names).
# Text wrapping: rewrap translated dialogue to a character width the same way the
# RPGMaker module does (util.dazedwrap), so English lines fit WOLF's message box.
@ -135,12 +127,11 @@ 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, SPEAKER_CONFIG, VOCAB
global ESTIMATE, TOKENS, FILENAME, SPEAKER_CONFIG, VOCAB
ESTIMATE = estimate
FILENAME = filename
# 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()
# Reload the glossary so a later phase (DB text / dialogue) picks up names
# that an earlier Phase 0 (names) harvested into vocab.txt.
@ -251,13 +242,13 @@ def parseDocument(data, filename):
src = e.get("source")
if not (isinstance(src, str) and re.search(LANGREGEX, src)):
return False
# names.json: only translate categories the user marked safe (by note).
if is_names and not wolf_names.is_note_safe(e.get("note", ""), SAFE_NOTES):
# names.json: only translate WolfDawn safe/refs entries (per-name badge).
if is_names and not wolf_names.is_name_translatable(e):
return False
return True
# Only translate entries that actually contain target-language text (and, for
# names.json, sit in a safe note category); the rest keep text == source so
# names.json, carry a translatable safety badge); the rest keep text == source so
# WolfDawn treats them as untouched on inject.
translatable = [e for e in entries if _translatable(e)]
@ -321,26 +312,33 @@ def parseDocument(data, filename):
def _harvest_names_to_vocab(data):
"""Write translated safe names.json entries into vocab.txt, grouped by note.
"""Write short translated name labels 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`.
Profile blurbs and other content-shaped names are translated in names.json but
skipped here. Categories with no harvestable terms remove any stale section.
"""
try:
db_labels = wolf_names.derive_db_labels("files")
by_note: dict[str, list] = {}
touched_notes: set[str] = set()
for entry in data.get("names") or []:
note = entry.get("note", "")
if not wolf_names.is_note_safe(note, SAFE_NOTES):
if not wolf_names.is_name_translatable(entry):
continue
note = str(entry.get("note", ""))
touched_notes.add(note)
if not wolf_names.is_vocab_harvest_candidate(entry):
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)
for note in touched_notes:
header = wolf_names.note_header(note, db_labels)
pairs = by_note.get(note, [])
if pairs:
wolf_vocab.update_vocab_section(header, pairs)
else:
wolf_vocab.remove_vocab_section(header)
except Exception:
traceback.print_exc()

View file

@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Unit tests for WolfDawn inject stdout parsers."""
from __future__ import annotations
import os
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
os.chdir(ROOT)
sys.path.insert(0, str(ROOT))
from util import wolfdawn # noqa: E402
class WolfInjectCountsTests(unittest.TestCase):
def test_parse_names_inject_counts(self):
out = (
"applied 2336 name change(s) (0 drifted/unmatched); "
"wrote 78 file(s) in place\n"
"WARNING: 2 line(s) left UNTRANSLATED by a safety guard\n"
)
applied, drifted = wolfdawn.parse_names_inject_counts(out)
self.assertEqual(applied, 2336)
self.assertEqual(drifted, 0)
self.assertTrue(wolfdawn.inject_had_applied(applied))
def test_parse_strings_inject_counts(self):
out = "applied 91 translation(s) (3 drifted); wrote Map001.mps\n"
applied, drifted = wolfdawn.parse_strings_inject_counts(out)
self.assertEqual(applied, 91)
self.assertEqual(drifted, 3)
def test_inject_had_applied_rejects_zero(self):
self.assertFalse(wolfdawn.inject_had_applied(0))
self.assertFalse(wolfdawn.inject_had_applied(None))
if __name__ == "__main__":
unittest.main()

View file

@ -1,8 +1,9 @@
#!/usr/bin/env python3
"""Unit tests for the WOLF names safe-note config in util/wolf_names.py."""
"""Unit tests for WolfDawn names.json helpers in util/wolf_names.py."""
from __future__ import annotations
import json
import os
import sys
import tempfile
@ -16,41 +17,62 @@ sys.path.insert(0, str(ROOT))
from util import wolf_names as wn # noqa: E402
class TestSafeNotesIO(unittest.TestCase):
def setUp(self):
self._orig = wn.SAFE_NOTES_PATH
self._tmp = tempfile.TemporaryDirectory()
wn.SAFE_NOTES_PATH = Path(self._tmp.name) / "wolf_safe_notes.json"
class TestNameTranslatable(unittest.TestCase):
def test_safe_and_refs_are_translatable(self):
self.assertTrue(wn.is_name_translatable({"safety": "safe"}))
self.assertTrue(wn.is_name_translatable({"safety": "refs"}))
def tearDown(self):
wn.SAFE_NOTES_PATH = self._orig
self._tmp.cleanup()
def test_default_is_empty(self):
self.assertEqual(wn.load_safe_notes(), [])
def test_round_trip_preserves_order_and_unicode(self):
wn.save_safe_notes(["武器", "技能", "アイテム"])
self.assertEqual(wn.load_safe_notes(), ["武器", "技能", "アイテム"])
def test_save_dedupes(self):
wn.save_safe_notes(["武器", "武器", "技能"])
self.assertEqual(wn.load_safe_notes(), ["武器", "技能"])
def test_ignores_non_list_payload(self):
wn.SAFE_NOTES_PATH.write_text('{"武器": true}', encoding="utf-8")
self.assertEqual(wn.load_safe_notes(), [])
def test_verify_and_missing_are_not_translatable(self):
self.assertFalse(wn.is_name_translatable({"safety": "verify"}))
self.assertFalse(wn.is_name_translatable({}))
self.assertFalse(wn.is_name_translatable({"safety": ""}))
class TestIsNoteSafe(unittest.TestCase):
def test_membership(self):
safe = ["武器", "技能"]
self.assertTrue(wn.is_note_safe("武器", safe))
self.assertFalse(wn.is_note_safe("通常変数名", safe))
class TestVocabHarvestCandidate(unittest.TestCase):
def test_short_weapon_name_harvests(self):
entry = {"source": "ダガー", "note": "武器", "safety": "safe"}
self.assertTrue(wn.is_vocab_harvest_candidate(entry))
def test_empty_list_is_never_safe(self):
self.assertFalse(wn.is_note_safe("武器", []))
self.assertFalse(wn.is_note_safe("", []))
def test_multiline_profile_skipped(self):
entry = {
"source": "セルリアと申します。\nよろしくお願いいたします。",
"note": "├■プロフィール",
"safety": "safe",
}
self.assertFalse(wn.is_vocab_harvest_candidate(entry))
def test_profile_note_skipped_even_when_single_line(self):
entry = {"source": "ローザだ。", "note": "├■プロフィール", "safety": "safe"}
self.assertFalse(wn.is_vocab_harvest_candidate(entry))
def test_resistance_label_still_harvests(self):
entry = {"source": "物理50軽減", "note": "┣ 属性耐性", "safety": "safe"}
self.assertTrue(wn.is_vocab_harvest_candidate(entry))
class TestCountNameSafety(unittest.TestCase):
DOC = {
"kind": "names",
"names": [
{"source": "a", "safety": "safe"},
{"source": "b", "safety": "refs"},
{"source": "c", "safety": "verify"},
{"source": "d"},
],
}
def test_counts_badges(self):
counts = wn.count_name_safety(self.DOC)
self.assertEqual(counts["safe"], 1)
self.assertEqual(counts["refs"], 1)
self.assertEqual(counts["verify"], 1)
self.assertEqual(counts["unknown"], 1)
self.assertEqual(counts["translatable"], 2)
def test_summary_mentions_translatable_count(self):
summary = wn.format_name_safety_summary(self.DOC)
self.assertIn("2 of 4", summary)
self.assertIn("verify", summary)
class TestNoteHeader(unittest.TestCase):
@ -74,15 +96,12 @@ class TestDeriveDbLabels(unittest.TestCase):
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",

View file

@ -38,11 +38,10 @@ class _WolfTranslateHarness:
def __init__(self):
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 = []
self.vocab_remove_writes = []
def run(self, data, filename="doc.json", estimate=False, safe_notes=None):
def run(self, data, filename="doc.json", estimate=False):
def translate(text, history, history_ctx=None):
self.captured.append(copy.deepcopy(text))
return _mock_translate(text, history, history_ctx)
@ -53,7 +52,6 @@ class _WolfTranslateHarness:
orig_t = wd.translateAI
orig_estimate = wd.ESTIMATE
orig_wrap = wd.WRAP
orig_notes = wd.SAFE_NOTES
orig_update = wd.wolf_vocab.update_vocab_section
orig_labels = wd.wolf_names.derive_db_labels
wd.translateAI = translate
@ -62,8 +60,6 @@ class _WolfTranslateHarness:
# 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:
wd.SAFE_NOTES = safe_notes
try:
data_copy = copy.deepcopy(data)
result = wd.parseDocument(data_copy, filename)
@ -72,7 +68,6 @@ class _WolfTranslateHarness:
wd.translateAI = orig_t
wd.ESTIMATE = orig_estimate
wd.WRAP = orig_wrap
wd.SAFE_NOTES = orig_notes
wd.wolf_vocab.update_vocab_section = orig_update
wd.wolf_names.derive_db_labels = orig_labels
@ -110,10 +105,25 @@ GAMEDAT_DOC = {
NAMES_DOC = {
"kind": "names",
"count": 2,
"count": 3,
"names": [
{"source": "", "text": "", "occurrences": 2, "note": "武器"},
{"source": "スイッチ状態", "text": "スイッチ状態", "occurrences": 1, "note": "通常変数名"},
{"source": "", "text": "", "occurrences": 2, "note": "武器", "safety": "safe"},
{"source": "", "text": "", "occurrences": 1, "note": "武器", "safety": "refs"},
{
"source": "スイッチ状態",
"text": "スイッチ状態",
"occurrences": 1,
"note": "通常変数名",
"safety": "verify",
},
],
}
LEGACY_NAMES_DOC = {
"kind": "names",
"count": 1,
"names": [
{"source": "", "text": "", "occurrences": 1, "note": "武器"},
],
}
@ -159,42 +169,69 @@ class TestTranslationWriteback(unittest.TestCase):
self.assertIsNone(err)
self.assertEqual(data["lines"][0]["text"], "EN_ゲームタイトル")
def test_names_translate_only_safe_notes(self):
# Only the entry whose note is opted-in gets translated.
(data, _t, err), _c = _WolfTranslateHarness().run(
NAMES_DOC, "names.json", safe_notes=["武器"]
def test_names_translate_only_safe_and_refs(self):
(data, _t, err), captured = _WolfTranslateHarness().run(
NAMES_DOC, "names.json"
)
self.assertIsNone(err)
self.assertEqual(data["names"][0]["text"], "EN_剣")
self.assertEqual(data["names"][0]["source"], "")
# The unsafe category (variable name) is left identical to the source.
self.assertEqual(data["names"][1]["text"], "スイッチ状態")
self.assertEqual(data["names"][1]["source"], "スイッチ状態")
self.assertEqual(data["names"][1]["text"], "EN_槍")
self.assertEqual(data["names"][2]["text"], "スイッチ状態")
self.assertEqual(captured, [["", ""]])
def test_names_default_translates_nothing(self):
# Empty safe list = safe default: no name is translated.
def test_names_without_safety_badges_translate_nothing(self):
(data, _t, err), captured = _WolfTranslateHarness().run(
NAMES_DOC, "names.json", safe_notes=[]
LEGACY_NAMES_DOC, "names.json"
)
self.assertIsNone(err)
self.assertEqual(captured, [])
for entry in data["names"]:
self.assertEqual(entry["text"], entry["source"])
self.assertEqual(data["names"][0]["text"], "")
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=["武器"])
(_data, _t, err), _c = harness.run(NAMES_DOC, "names.json")
self.assertIsNone(err)
self.assertEqual(harness.vocab_writes, [("Weapon · 武器", [("", "EN_剣")])])
self.assertEqual(
harness.vocab_writes,
[
("Weapon · 武器", [("", "EN_剣"), ("", "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=["武器"])
harness.run(NAMES_DOC, "names.json", estimate=True)
self.assertEqual(harness.vocab_writes, [])
def test_names_harvest_skips_profile_blurbs(self):
doc = {
"kind": "names",
"names": [
{"source": "ダガー", "text": "EN_ダガー", "note": "武器", "safety": "safe"},
{
"source": "セルリアと申します。\nよろしくお願いいたします。",
"text": "EN_profile",
"note": "├■プロフィール",
"safety": "safe",
},
],
}
harness = _WolfTranslateHarness()
harness.vocab_remove_writes = []
orig_remove = wd.wolf_vocab.remove_vocab_section
def capture_remove(category):
harness.vocab_remove_writes.append(category)
wd.wolf_vocab.remove_vocab_section = capture_remove
try:
(_data, _t, err), _c = harness.run(doc, "names.json")
finally:
wd.wolf_vocab.remove_vocab_section = orig_remove
self.assertIsNone(err)
self.assertEqual(harness.vocab_writes, [("Weapon · 武器", [("ダガー", "EN_ダガー")])])
self.assertEqual(harness.vocab_remove_writes, ["├■プロフィール"])
def test_txtdir_translates(self):
(data, _t, err), _c = _WolfTranslateHarness().run(TXTDIR_DOC, "Evtext.json")
self.assertIsNone(err)

View file

@ -127,3 +127,39 @@ def update_vocab_section(category: str, pairs) -> None:
)
tmp_path.write_text(combined, encoding="utf-8")
os.replace(tmp_path, VOCAB_PATH)
def remove_vocab_section(category: str) -> None:
"""Remove a ``# {category}`` section from the game-specific vocab, if present."""
with _VOCAB_LOCK:
if not VOCAB_PATH.is_file():
return
existing = VOCAB_PATH.read_text(encoding="utf-8")
idx = existing.find(BASE_SEPARATOR)
if idx != -1:
game_part = existing[:idx]
base_part = existing[idx:]
else:
game_part = existing
base_part = ""
pattern = re.compile(
rf"^[\t ]*#+\s*{re.escape(category)}\s*$\r?\n.*?(?=^[\t ]*#|\Z)",
re.MULTILINE | re.DOTALL,
)
new_game = pattern.sub("", game_part, count=1)
if new_game == game_part:
return
new_game = re.sub(r"\n{3,}", "\n\n", new_game).rstrip("\n")
if base_part:
combined = new_game + "\n\n" + base_part if new_game else base_part
else:
combined = new_game + "\n" if new_game else ""
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

@ -1,38 +1,46 @@
"""Safe-to-translate ``note`` categories for the WOLF (WolfDawn) name glossary.
"""WolfDawn ``names.json`` helpers for the WOLF translation workflow.
WolfDawn's ``names-extract`` produces ``names.json``: a project-wide list of every
"value name" the game references (item / skill / enemy names, but also variable
names, file/SE/BGM keys, event names, map settings, ...). Each entry carries a
``note`` field naming the WOLF database category it came from, e.g. ``武器``
(weapons), ``技能`` (skills), ``システム変数名`` (system variable names).
WolfDawn's ``names-extract`` produces ``names.json``: a project-wide glossary of
every value name the game references. Each entry has a ``source`` / ``text`` pair,
a ``note`` field (the WOLF database category it came from), and a static
``safety`` badge from WolfDawn's command-stream analysis:
Translating *all* of them corrupts the game, because many categories are logic
keys (compared by value, used to build filenames, stored in variables). Only the
categories that are pure on-screen text are safe to translate.
* ``safe`` - display-only; no command references the string by name.
* ``refs`` - referenced by name, but WolfDawn rewrites every literal on inject.
* ``verify`` - also appears in indirect literals; left untranslated by default.
Rather than hand-editing 2000+ entries, the workflow classifies by ``note``: a
repo-aware AI decides which *categories* are display-only, and that short list is
saved here. The WolfDawn translation module (:mod:`modules.wolfdawn`) then only
translates ``names.json`` entries whose ``note`` is in the safe list, leaving
everything else identical to the source (so injection is a no-op for it).
:mod:`modules.wolfdawn` translates only entries whose badge is ``safe`` or
``refs``. ``verify`` and legacy entries without a badge keep ``text == source``
so injection is a no-op for them.
The default is an empty list: nothing is translated until the user opts a
category in, which keeps the safe-by-default behaviour the workflow relies on.
Phase 0 still harvests translated **short name-like** entries into ``vocab.txt``,
grouped by ``note``. Multi-line profile blurbs, dialogue, and other content-shaped
names are translated in ``names.json`` but omitted from the glossary.
"""
from __future__ import annotations
import json
import re
from typing import Any
from util.paths import DATA_DIR
SAFETY_SAFE = "safe"
SAFETY_REFS = "refs"
SAFETY_VERIFY = "verify"
_TRANSLATABLE_SAFETY = frozenset({SAFETY_SAFE, SAFETY_REFS})
SAFE_NOTES_PATH = DATA_DIR / "wolf_safe_notes.json"
# names.json ``note`` values that are content fields, not short display names.
_VOCAB_SKIP_NOTE_RE = re.compile(
r"プロフィール|説明|セリフ|台詞|会話|自己紹介|挨拶|モノローグ|独白",
)
# Short row labels and item/skill names fit; multi-line blurbs do not.
_VOCAB_HARVEST_MAX_LEN = 72
# 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.
# half only, so this static map provides an English label for the common categories
# when the live DB labels are unavailable.
NOTE_EN: dict[str, str] = {
"武器": "Weapon",
"防具": "Armor",
@ -48,53 +56,107 @@ NOTE_EN: dict[str, str] = {
"敵キャラ個体データ": "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]:
"""Return the saved list of ``note`` categories that are safe to translate."""
try:
if SAFE_NOTES_PATH.is_file():
data = json.loads(SAFE_NOTES_PATH.read_text(encoding="utf-8"))
if isinstance(data, list):
return [str(n) for n in data]
except Exception:
pass
return []
def parse_names_entries(data: dict[str, Any]) -> list[dict[str, Any]]:
names = data.get("names", [])
if not isinstance(names, list):
return []
return [entry for entry in names if isinstance(entry, dict)]
def save_safe_notes(notes: list[str]) -> None:
"""Persist the safe ``note`` categories (de-duplicated, order preserved)."""
seen: set[str] = set()
ordered: list[str] = []
for note in notes:
note = str(note)
if note not in seen:
seen.add(note)
ordered.append(note)
DATA_DIR.mkdir(parents=True, exist_ok=True)
SAFE_NOTES_PATH.write_text(
json.dumps(ordered, ensure_ascii=False, indent=4), encoding="utf-8"
def has_safety_metadata(data: dict[str, Any]) -> bool:
"""True when *data* includes WolfDawn per-name ``safety`` badges."""
return any("safety" in entry for entry in parse_names_entries(data))
def entry_safety(entry: dict[str, Any]) -> str:
"""Return the normalised ``safety`` badge for one names.json entry."""
return str(entry.get("safety", "")).strip().lower()
def is_name_translatable(entry: dict[str, Any]) -> bool:
"""True when WolfDawn marks *entry* as safe or refs (per-name, not per-category)."""
return entry_safety(entry) in _TRANSLATABLE_SAFETY
def is_vocab_harvest_candidate(entry: dict[str, Any]) -> bool:
"""True when a translated names.json entry should seed ``vocab.txt``.
Translation uses :func:`is_name_translatable`; this is stricter. Profile text,
multi-line blurbs, and dialogue-shaped strings are still translated in
``names.json`` but are not glossary terms.
"""
if not is_name_translatable(entry):
return False
src = str(entry.get("source", ""))
if not src.strip():
return False
if "\n" in src or "\r" in src:
return False
if len(src) > _VOCAB_HARVEST_MAX_LEN:
return False
note = str(entry.get("note", ""))
if _VOCAB_SKIP_NOTE_RE.search(note):
return False
# Two or more sentence endings usually means dialogue, not a row label.
if len(re.findall(r"[。!?]", src)) >= 2:
return False
return True
def count_name_safety(data: dict[str, Any]) -> dict[str, int]:
"""Return per-badge counts for a names.json document."""
counts = {
"total": 0,
"safe": 0,
"refs": 0,
"verify": 0,
"unknown": 0,
"translatable": 0,
}
for entry in parse_names_entries(data):
counts["total"] += 1
safety = entry_safety(entry)
if safety == SAFETY_SAFE:
counts["safe"] += 1
elif safety == SAFETY_REFS:
counts["refs"] += 1
elif safety == SAFETY_VERIFY:
counts["verify"] += 1
else:
counts["unknown"] += 1
counts["translatable"] = counts["safe"] + counts["refs"]
return counts
def format_name_safety_summary(data: dict[str, Any]) -> str:
"""One-line summary for the workflow UI."""
counts = count_name_safety(data)
if not counts["total"]:
return "No names found in names.json."
if not has_safety_metadata(data):
return (
f"{counts['total']} name(s), but no WolfDawn safety badges - re-run names-extract "
"with a current WolfDawn build before Phase 0."
)
parts = []
if counts["safe"]:
parts.append(f"{counts['safe']} safe")
if counts["refs"]:
parts.append(f"{counts['refs']} refs")
if counts["verify"]:
parts.append(f"{counts['verify']} verify")
badge_text = ", ".join(parts) if parts else "no badges"
return (
f"{counts['translatable']} of {counts['total']} name(s) will translate "
f"({badge_text}). Verify names are skipped."
)
def is_note_safe(note: str, safe_notes) -> bool:
"""True if *note* is in the *safe_notes* collection."""
if not safe_notes:
return False
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.
"""
"""Build a ``{japanese_note: english}`` map from staged WolfDawn ``db`` files."""
from pathlib import Path
labels: dict[str, str] = {}
@ -104,12 +166,12 @@ def derive_db_labels(files_dir) -> dict[str, str]:
return labels
for path in sorted(base.glob("*.project.json")):
try:
data = json.loads(path.read_text(encoding="utf-8-sig"))
doc = json.loads(path.read_text(encoding="utf-8-sig"))
except Exception:
continue
if data.get("kind") != "db":
if doc.get("kind") != "db":
continue
for group in data.get("groups") or []:
for group in doc.get("groups") or []:
type_name = group.get("typeName") or ""
if _LABEL_SEP in type_name:
english, _, japanese = type_name.partition(_LABEL_SEP)
@ -122,12 +184,7 @@ def derive_db_labels(files_dir) -> dict[str, str]:
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 · 武器"``.
"""
"""Return a bilingual vocab section header for a names.json ``note``."""
english = (db_labels or {}).get(note) or NOTE_EN.get(note)
if english and english != note:
return f"{english}{_LABEL_SEP}{note}"

View file

@ -42,11 +42,51 @@ __all__ = [
"names_extract",
"strings_inject",
"names_inject",
"parse_strings_inject_counts",
"parse_names_inject_counts",
"inject_had_applied",
"pack",
"save_update",
"names_check",
]
# strings-inject: "applied N translation(s) (M drifted)"; names-inject uses "name change(s)".
_INJECT_COUNTS_RE = re.compile(
r"applied\s+(\d+)\s+translation.*?(\d+)\s+drifted", re.IGNORECASE | re.DOTALL
)
_NAMES_INJECT_COUNTS_RE = re.compile(
r"applied\s+(\d+)\s+name change.*?(\d+)\s+drifted", re.IGNORECASE | re.DOTALL
)
def parse_strings_inject_counts(stdout: str) -> tuple[int | None, int | None]:
"""Return (applied, drifted) from wolf strings-inject output, or (None, None)."""
if not stdout:
return None, None
m = _INJECT_COUNTS_RE.search(stdout)
if not m:
return None, None
return int(m.group(1)), int(m.group(2))
def parse_names_inject_counts(stdout: str) -> tuple[int | None, int | None]:
"""Return (applied, drifted) from wolf names-inject output, or (None, None)."""
if not stdout:
return None, None
m = _NAMES_INJECT_COUNTS_RE.search(stdout)
if not m:
return None, None
return int(m.group(1)), int(m.group(2))
def inject_had_applied(applied: int | None) -> bool:
"""True when WolfDawn reported at least one applied change.
WolfDawn may exit 2 when a few lines fail safety guards but still writes the
rest; treat a positive applied count as success for inject bookkeeping.
"""
return applied is not None and applied > 0
# Committed, prebuilt binaries live here (per-platform) so end users don't need a
# Rust toolchain. When one is missing we download it from the WolfDawn release.
_PACKAGE_DIR = Path(__file__).resolve().parent