Stabilize injects

This commit is contained in:
DazedAnon 2026-07-08 18:11:04 -05:00
parent 6badfcb246
commit 3de5368817
7 changed files with 825 additions and 373 deletions

View file

@ -330,7 +330,7 @@ Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine sel
| **3 Names** | Translate `names.json` (item/skill/enemy/map value names). WolfDawn tags each name with a per-entry **safety** badge (`safe`, `refs`, or `verify`). Phase 0 translates only `safe` entries; `refs` and `verify` names stay Japanese so inject skips them. Review the category breakdown for this game, pick **Translation mode** (Normal or Batch), and run **Translate safe names (Phase 0)**. |
| **4 Database** | Review the **discovery summary** to see where this game's text lives (standard RPG sheets vs custom dialogue tables). Database sheets are classified as foundation (items, skills, descriptions — translate first) or narrative (custom event/profile sheets — translate after foundation). Use **Translate foundation DB** then **Translate narrative DB**, or tick specific sheets and run **Translate checked sheets only**. Optional: copy the **DB structure prompt** for an AI audit and import the returned JSON into `wolf_json/db_profile.json`. |
| **5 Maps/Events** | Translate map scripts (`.mps`), common events (`CommonEvent.dat`), `Game.dat`, and Evtext. Run after Steps 34 so vocab and database terms are consistent. Configure speaker handling for low-confidence nameplate guesses. Batch mode is recommended for large `CommonEvent.dat` files. |
| **6 Inject** | Write translations back into the game's `Data/` binaries and refresh git-tracked `wolf_json/`. Uses pristine originals from `wolf_json/originals/`. Quick inject or inject all. |
| **6 Inject** | Tick translated JSON files from `translated/`, then **Inject selected** or **Inject all**. Each file is reported individually; failures open a dialog with details. Uses pristine originals from `wolf_json/originals/`. |
| **7 Fix wrap** | Paste overflowing in-game text to **search** `translated/` JSON and jump to the database sheet and row (sheet names match Step 4 and `names.json` notes, e.g. `├■街の噂MOB`). Edit the line, set wrap width, **Wrap this row** or **Wrap all overflowing rows in this sheet**, save, then **Inject this file** (usually `DataBase.project.json`). Per-sheet widths are remembered in `wolf_json/wrap_profile.json`. **Advanced:** **wolf relayout** for event message boxes (maps/CommonEvent only — not bulletin-style DB UI) and **wolf desc-relayout** for standard 説明 description fields. |
| **8 Package** | Run from loose `Data/` (backs up `Data.wolf``.bak`) or repack `Data.wolf`. |
| **9 Saves** | *(Optional)* Update existing `.sav` files for the translated build. |

View file

@ -2530,13 +2530,9 @@ class WolfWorkflowTab(QWidget):
def _build_step4_inject(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 6 · Inject Translations"))
layout.addWidget(self._desc(
"Writes the translated text back into the game's Data/ binaries with WolfDawn, "
"byte-exact. Translated safe 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. "
f"Before each inject, inline \\\\codes are auto-repaired from source; on a full inject "
f"every translated/ JSON is copied into {WORK_DIR_NAME}/ for the game-repo diff."
"Tick translated JSON files below, then inject them into the game's Data/ "
"binaries. Each file is reported individually — failures are shown in a "
"dialog and in the log."
))
self.en_punct_cb = QCheckBox("Convert Japanese punctuation to ASCII (--en-punct)")
@ -2560,17 +2556,10 @@ class WolfWorkflowTab(QWidget):
layout.addWidget(check_btn)
layout.addWidget(_make_hr())
layout.addWidget(self._subheading("Quick inject — write only specific files into the game"))
layout.addWidget(self._desc(
"For iterating: tick the files you want, then inject. Each selected JSON is "
f"copied into {WORK_DIR_NAME}/ and written into its Data/ binary with WolfDawn. "
f"{NAMES_JSON} is special: it applies translated name values across every binary "
f"(and resets live Data/ from {WORK_DIR_NAME}/originals/ first). "
"Only tick the files you actually want to inject."
))
layout.addWidget(self._subheading("Translated files"))
self.inject_list = QListWidget()
self.inject_list.setMaximumHeight(220)
self.inject_list.setMaximumHeight(280)
self.inject_list.setStyleSheet(
"QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;"
"color:#cccccc;font-size:12px;padding:2px;}"
@ -2580,7 +2569,7 @@ class WolfWorkflowTab(QWidget):
layout.addWidget(self.inject_list)
list_row = QHBoxLayout()
refresh_btn = self._register(_make_btn("↻ Refresh list", "#3a3a3a"))
refresh_btn = self._register(_make_btn("↻ Refresh", "#3a3a3a"))
refresh_btn.clicked.connect(self._refresh_inject_list)
sel_all_btn = self._register(_make_btn("Select all", "#3a3a3a"))
sel_all_btn.clicked.connect(lambda: self._set_inject_checks(True))
@ -2592,25 +2581,22 @@ class WolfWorkflowTab(QWidget):
list_row.addStretch()
layout.addLayout(list_row)
quick_btn = self._register(_make_btn("Inject selected files", "#00a86b"))
quick_btn.clicked.connect(self._inject_selected)
layout.addWidget(quick_btn)
layout.addWidget(_make_hr())
layout.addWidget(self._subheading("Full inject"))
layout.addWidget(self._desc(
"Injects every extracted document and applies translated name values from names.json "
"across Data/. Use this once translation is complete before packaging in Step 8."
))
inject_btn = self._register(_make_btn("Inject all translations", "#00a86b"))
inject_btn.clicked.connect(lambda: self._inject())
layout.addWidget(inject_btn)
btn_row = QHBoxLayout()
inject_sel_btn = self._register(_make_btn("Inject selected", "#00a86b"))
inject_sel_btn.clicked.connect(self._inject_selected)
inject_all_btn = self._register(_make_btn("Inject all", "#00a86b"))
inject_all_btn.clicked.connect(self._inject_all)
btn_row.addWidget(inject_sel_btn)
btn_row.addWidget(inject_all_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
layout.addWidget(_make_hr())
layout.addWidget(self._desc(
"English text is often longer than Japanese. Step 7 (Fix wrapping): paste "
"overflowing in-game text to find the sheet, wrap, and re-inject."
))
self._refresh_inject_list()
def _build_step7_relayout(self, layout: QVBoxLayout):
"""Search-first fix wrapping: find sheet by in-game text, wrap, re-inject."""
@ -3187,7 +3173,7 @@ class WolfWorkflowTab(QWidget):
return
if not self._require_manifest():
return
self._inject(only_json={hit.json_file})
self._inject({hit.json_file})
def _refresh_wrap_sheets_list(self):
if not hasattr(self, "_wrap_sheets_list"):
@ -3273,18 +3259,17 @@ class WolfWorkflowTab(QWidget):
return p
return None
def _repair_inject_json(self, src: Path, log=None) -> Path:
"""Auto-repair WOLF inline codes in a translated JSON before inject."""
from util.wolfdawn import codes as wolf_codes
def _injectable_filenames(self) -> list[str]:
"""JSON files in translated/ that the manifest knows how to inject."""
from util.wolfdawn import inject as wolf_inject
data = json.loads(src.read_text(encoding="utf-8-sig"))
data, repairs = wolf_codes.repair_document(data)
if repairs:
src.write_text(
json.dumps(data, ensure_ascii=False, indent=4) + "\n",
encoding="utf-8",
)
return src
manifest = self._read_manifest()
if not manifest:
return []
return wolf_inject.list_injectable(
self._tool_root() / "translated",
manifest.get("entries", []),
)
def _sync_translated_json_to_wolf_json(
self, json_name: str, game_json_dir: Path
@ -3306,105 +3291,83 @@ class WolfWorkflowTab(QWidget):
except Exception as exc:
return False, str(exc)
def _inject_sync_targets(
self,
entries: list[dict],
only_json: set[str] | None,
) -> list[str]:
"""JSON files to copy from translated/ into the game's wolf_json/ after inject."""
if only_json is None:
return sorted(
e["json"]
for e in entries
if e.get("json") and self._translated_or_source(e["json"]) is not None
)
return sorted(
j for j in only_json if self._translated_or_source(j) is not None
)
def _inject(self, selected: set[str]):
"""Inject the selected translated JSON files into live Data/."""
if not self._require_manifest():
return
manifest = self._read_manifest()
en_punct = self.en_punct_cb.isChecked()
allow_drift = self.drift_cb.isChecked()
game_json_dir = self._work_dir()
def _emit_inject_summary(
self,
log,
*,
injected: list[str],
failed: list[tuple[str, str]],
skipped_files: int = 0,
skipped_lines: int = 0,
) -> None:
"""Concise inject report: successes, optional guard summary, then failures."""
if injected:
if len(injected) <= 12:
log("Injected: " + ", ".join(injected))
else:
log(f"Injected: {len(injected)} file(s)")
if skipped_files:
log(
f"Warnings: {skipped_files} file(s) had {skipped_lines} line(s) "
"skipped by WolfDawn safety guards"
)
for name, reason in failed:
log(f"Failed: {name}{reason}")
if not injected and not failed:
log("Injected: (nothing)")
def task(log, progress=None):
from util.wolfdawn import inject as wolf_inject
def _inject_failure_reason(
self,
*,
drift: int | None = None,
exit_code: int | None = None,
untranslated: int | None = None,
mismatches: int | None = None,
no_source: bool = False,
no_json: bool = False,
no_original: bool = False,
names_drift: bool = False,
names_stale: bool = False,
) -> str:
if no_json:
return "no translated JSON"
if no_source:
return "no translated copy to sync"
if no_original:
return "no pristine original"
if names_stale:
return (
"sources no longer match live Data/ "
"(re-run Step 0 extract on the untranslated game)"
)
if names_drift:
return (
"0 applied — live Data/ no longer has Japanese name sources "
f"(reset from {WORK_DIR_NAME}/originals/ and inject again)"
)
if drift:
return f"0 applied, {drift} skipped as drift"
if untranslated or mismatches:
parts = []
if untranslated:
parts.append(f"{untranslated} untranslated line(s)")
if mismatches:
parts.append(f"{mismatches} control-code mismatch(es)")
return "; ".join(parts)
if exit_code is not None:
return f"wolf exited {exit_code}"
return "inject failed"
entries = manifest["entries"]
data_dir_path = Path(manifest["data_dir"])
game_root = Path(manifest.get("root") or self._game_root)
originals_dir = game_root / WORK_DIR_NAME / "originals"
def _strings_inject_failure_reason(self, res) -> str:
from util import wolfdawn
self._ensure_originals(manifest, log, progress, quiet=True)
cli_err = wolfdawn.parse_inject_cli_error(res.stdout, res.stderr)
if cli_err:
return cli_err
a, d = wolfdawn.parse_strings_inject_counts(res.stdout, res.stderr)
untranslated = wolfdawn.parse_strings_inject_untranslated(res.stdout, res.stderr)
mismatches = wolfdawn.parse_strings_inject_mismatches(res.stdout, res.stderr)
if res.ok:
return self._inject_failure_reason(drift=d or 0)
return self._inject_failure_reason(
exit_code=res.returncode,
untranslated=untranslated,
mismatches=len(mismatches),
)
report = wolf_inject.inject_selected(
sorted(selected),
manifest_entries=entries,
data_dir=data_dir_path,
originals_dir=originals_dir,
translated_dir=self._tool_root() / "translated",
game_root=game_root,
allow_code_drift=allow_drift,
en_punct=en_punct,
log_fn=log,
)
for json_name in sorted(selected):
ok, err = self._sync_translated_json_to_wolf_json(
json_name, game_json_dir
)
if not ok:
report.sync_failures.append((json_name, err or "unknown error"))
log(f" ✗ sync {json_name}: {err}")
title, body = wolf_inject.format_report_dialog(report)
inject_state["dialog"] = (title, body)
inject_state["report"] = report
inject_state["selected"] = set(selected)
return report.ok, title
def _after_inject(ok: bool, msg: str):
title, body = inject_state.get("dialog", ("Inject", msg))
report = inject_state.get("report")
if report and (report.failed or report.sync_failures):
QMessageBox.warning(self, title, body)
elif report and report.succeeded:
QMessageBox.information(self, title, body)
elif body:
QMessageBox.information(self, title, body)
selection = inject_state.get("selected") or set()
names_only = selection and selection.issubset({NAMES_JSON})
if names_only or not self._relayout_after_enabled():
self._log("Relayout: not run")
return
if not report or not any(
r.success and r.json_name != NAMES_JSON for r in report.files
):
self._log("Relayout: not run")
return
QTimer.singleShot(
0, lambda: self._run_relayout(manual=False, quiet=True)
)
inject_state: dict = {}
self._run_task(task, on_done=_after_inject)
def _relayout_after_enabled(self) -> bool:
cb = getattr(self, "_relayout_after_cb", None)
if cb is not None:
return cb.isChecked()
return self._setting("relayout_after_inject", "false") == "true"
def _on_step_changed(self, idx: int):
previous = self._current_step_index
@ -3418,7 +3381,7 @@ class WolfWorkflowTab(QWidget):
# Index 4 == Database: refresh discovery report and sheet list.
elif idx == 4:
self._refresh_db_discovery()
# Index 6 == Inject: keep the quick-inject picker in sync with translated/.
# Index 6 == Inject: keep the file list in sync with translated/.
elif idx == 6:
self._refresh_inject_list()
# Index 7 == Fix wrap: refresh sheet overflow list.
@ -3430,31 +3393,34 @@ class WolfWorkflowTab(QWidget):
if not hasattr(self, "inject_list"):
return
self.inject_list.clear()
manifest = self._read_manifest()
if not manifest:
if not self._read_manifest():
item = QListWidgetItem("Run Step 0 (extract) first — no manifest found.")
item.setFlags(Qt.ItemIsEnabled)
self.inject_list.addItem(item)
return
listed = 0
for entry in manifest.get("entries", []):
json_name = entry.get("json")
if not json_name:
continue
if not self._translated_path(json_name):
continue
files = self._injectable_filenames()
if not files:
item = QListWidgetItem("No injectable files in translated/ yet.")
item.setFlags(Qt.ItemIsEnabled)
self.inject_list.addItem(item)
return
for json_name in files:
item = QListWidgetItem(json_name)
item.setData(Qt.UserRole, json_name)
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
default_checked = False
item.setCheckState(Qt.Checked if default_checked else Qt.Unchecked)
self.inject_list.addItem(item)
listed += 1
if listed == 0:
item = QListWidgetItem("No translated files in translated/ yet — run Steps 4-5.")
item.setFlags(Qt.ItemIsEnabled)
item.setCheckState(Qt.Unchecked)
self.inject_list.addItem(item)
def _selected_inject_files(self) -> set[str]:
selected: set[str] = set()
for i in range(self.inject_list.count()):
it = self.inject_list.item(i)
if (it.flags() & Qt.ItemIsUserCheckable) and it.checkState() == Qt.Checked:
name = it.data(Qt.UserRole)
if name:
selected.add(name)
return selected
def _set_inject_checks(self, checked: bool):
if not hasattr(self, "inject_list"):
return
@ -3467,21 +3433,26 @@ class WolfWorkflowTab(QWidget):
def _inject_selected(self):
if not self._require_manifest():
return
selected = set()
for i in range(self.inject_list.count()):
it = self.inject_list.item(i)
if (it.flags() & Qt.ItemIsUserCheckable) and it.checkState() == Qt.Checked:
name = it.data(Qt.UserRole)
if name:
selected.add(name)
selected = self._selected_inject_files()
if not selected:
QMessageBox.information(
self, "Nothing selected",
"Tick the files you want to inject, then try again. "
"Use “↻ Refresh list” if you just translated something.",
"Tick one or more files in the list, then click Inject selected.",
)
return
self._inject(only_json=selected)
self._inject(selected)
def _inject_all(self):
if not self._require_manifest():
return
files = self._injectable_filenames()
if not files:
QMessageBox.information(
self, "Nothing to inject",
"No injectable files found in translated/. Translate something first.",
)
return
self._inject(set(files))
def _names_check(self):
if not self._require_manifest():
@ -3505,213 +3476,6 @@ class WolfWorkflowTab(QWidget):
self._run_task(task)
def _inject(self, only_json: set[str] | None = None):
"""Inject translations into the game's Data/ binaries.
only_json:
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. Phase 0
has run); otherwise names are left untouched.
"""
if not self._require_manifest():
return
manifest = self._read_manifest()
en_punct = self.en_punct_cb.isChecked()
allow_drift = self.drift_cb.isChecked()
game_json_dir = self._work_dir()
def task(log, progress=None):
from util import wolfdawn
injected: list[str] = []
failures: list[tuple[str, str]] = []
skipped_files = 0
skipped_lines = 0
self._ensure_originals(manifest, log, progress, quiet=True)
entries = manifest["entries"]
data_dir = manifest["data_dir"]
data_dir_path = Path(data_dir)
self._ensure_db_dat_snapshots(entries, data_dir_path)
names_entry = next((e for e in entries if e["kind"] == "names"), None)
names_json = names_entry["json"] if names_entry else NAMES_JSON
names_src = (
self._translated_or_source(names_entry["json"])
if names_entry
else None
)
will_names = bool(
names_entry
and names_src is not None
and (only_json is None or names_entry["json"] in only_json)
)
if only_json is None or will_names:
self._restore_live_from_originals(
entries, data_dir_path, log, quiet=True
)
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"
}
else:
# Quick inject: only the JSON files the user ticked (names uses
# names-inject separately; do not pull in every translated file).
strings_targets = {j for j in only_json if j != NAMES_JSON}
for entry in entries:
if entry["kind"] == "names":
continue
if entry["json"] not in strings_targets:
continue
json_name = entry["json"]
inject_src = self._translated_path(json_name)
if inject_src is None:
inject_src = self._translated_or_source(json_name)
if inject_src is None:
failures.append(
(json_name, self._inject_failure_reason(no_json=True))
)
continue
inject_src = self._repair_inject_json(inject_src)
out = entry["base"]
orig = self._orig_base_for(entry, data_dir_path)
base = str(orig) if orig.exists() else out
if not orig.exists():
failures.append(
(json_name, self._inject_failure_reason(no_original=True))
)
continue
if entry.get("kind") == "db":
from util.wolfdawn import db_dat_sibling
if not db_dat_sibling(orig).is_file():
failures.append((
json_name,
"missing database .dat pair in originals "
f"(re-run Step 0 extract on the untranslated game)",
))
continue
res = wolfdawn.strings_inject(
str(inject_src), base, out,
allow_code_drift=allow_drift, en_punct=en_punct, log_fn=None,
)
a, d = wolfdawn.parse_strings_inject_counts(res.stdout, res.stderr)
untranslated = wolfdawn.parse_strings_inject_untranslated(
res.stdout, res.stderr
)
mismatches = wolfdawn.parse_strings_inject_mismatches(
res.stdout, res.stderr
)
strings_ok = wolfdawn.inject_had_applied(a) or (
res.ok and not (a == 0 and (d or 0) > 0)
)
if strings_ok:
injected.append(json_name)
guard_skips = (untranslated or 0) + len(mismatches)
if guard_skips:
skipped_files += 1
skipped_lines += untranslated or 0
elif res.ok:
failures.append((
json_name,
self._strings_inject_failure_reason(res),
))
else:
failures.append((
json_name,
self._strings_inject_failure_reason(res),
))
if will_names and names_entry and names_src is not None:
if not names_restored:
self._restore_live_from_originals(
entries, data_dir_path, log, quiet=True
)
res = wolfdawn.names_inject(
str(names_src), data_dir,
allow_code_drift=allow_drift, en_punct=en_punct, log_fn=None,
)
a, d = wolfdawn.parse_names_inject_counts(res.stdout, res.stderr)
if wolfdawn.inject_had_applied(a):
injected.append(names_json)
elif res.ok and a == 0:
failures.append((
names_json,
self._inject_failure_reason(names_drift=True),
))
elif res.ok and (d or 0) > 0:
failures.append((
names_json,
self._inject_failure_reason(names_stale=True),
))
else:
failures.append((
names_json,
self._inject_failure_reason(exit_code=res.returncode),
))
sync_targets = self._inject_sync_targets(entries, only_json)
for json_name in sync_targets:
ok, err = self._sync_translated_json_to_wolf_json(
json_name, game_json_dir
)
if not ok:
failures.append((
json_name,
self._inject_failure_reason(
no_source=err == "no translated copy",
)
if err == "no translated copy"
else f"could not sync to {WORK_DIR_NAME}/ ({err})",
))
self._emit_inject_summary(
log,
injected=injected,
failed=failures,
skipped_files=skipped_files,
skipped_lines=skipped_lines,
)
inject_state["applied"] = len(injected)
inject_state["had_failures"] = bool(failures)
inject_state["only_json"] = only_json
return not failures, ""
def _after_inject(ok: bool, msg: str):
selection = inject_state.get("only_json")
names_only = (
selection is not None
and selection
and selection.issubset({NAMES_JSON})
)
if names_only or not self._relayout_after_enabled():
self._log("Relayout: not run")
return
if inject_state.get("applied", 0) <= 0:
self._log("Relayout: not run")
return
QTimer.singleShot(
0, lambda: self._run_relayout(manual=False, quiet=True)
)
inject_state: dict = {"applied": 0, "had_failures": False}
self._run_task(task, on_done=_after_inject)
def _relayout_after_enabled(self) -> bool:
cb = getattr(self, "_relayout_after_cb", None)
if cb is not None:
return cb.isChecked()
return self._setting("relayout_after_inject", "false") == "true"
def _relayout_desc_enabled(self) -> bool:
cb = getattr(self, "_relayout_desc_cb", None)
if cb is not None:

View file

@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Unit tests for util/wolfdawn/inject.py orchestration."""
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
os.chdir(ROOT)
sys.path.insert(0, str(ROOT))
from util.wolfdawn import inject as wi # noqa: E402
class ListInjectableTests(unittest.TestCase):
def test_lists_translated_files_with_manifest_entries(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
translated = root / "translated"
translated.mkdir()
(translated / "names.json").write_text("{}", encoding="utf-8")
(translated / "Map001.mps.json").write_text("{}", encoding="utf-8")
(translated / "orphan.json").write_text("{}", encoding="utf-8")
entries = [
{"json": "names.json", "kind": "names", "base": "/data"},
{"json": "Map001.mps.json", "kind": "map", "base": "/data/Map001.mps"},
]
self.assertEqual(
wi.list_injectable(translated, entries),
["Map001.mps.json", "names.json"],
)
class InjectOrderTests(unittest.TestCase):
def test_names_runs_before_strings(self):
calls: list[str] = []
def fake_names(*_a, **_k):
calls.append("names")
return mock.Mock(
ok=True,
returncode=0,
stdout="applied 2 name change(s) (0 drifted/unmatched)",
stderr="",
)
def fake_strings(*_a, **_k):
calls.append("strings")
return mock.Mock(
ok=True,
returncode=0,
stdout="applied 1 translation(s) (0 drifted)",
stderr="",
)
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
translated = root / "translated"
originals = root / "originals"
data = root / "data"
translated.mkdir()
originals.mkdir()
data.mkdir()
(translated / "names.json").write_text("{}", encoding="utf-8")
(translated / "Map001.mps.json").write_text(
'{"kind":"map","scenes":[]}', encoding="utf-8"
)
map_live = data / "Map001.mps"
map_orig = originals / "Map001.mps"
map_live.write_bytes(b"live")
map_orig.write_bytes(b"orig")
entries = [
{"json": "names.json", "kind": "names", "base": str(data)},
{"json": "Map001.mps.json", "kind": "map", "base": str(map_live)},
]
def would_apply_counts():
yield 1 # prepare: after restore
yield 1 # _inject_names: preflight
yield 0 # _inject_names: post-check
counts = would_apply_counts()
def next_would_apply(*_a, **_k):
return next(counts, 0)
with mock.patch.object(wi.wolf_originals, "names_inject_would_apply", side_effect=next_would_apply), \
mock.patch.object(wi.wolfdawn, "names_inject", side_effect=fake_names), \
mock.patch.object(wi.wolfdawn, "strings_inject", side_effect=fake_strings):
report = wi.inject_selected(
["Map001.mps.json", wi.NAMES_JSON],
manifest_entries=entries,
data_dir=data,
originals_dir=originals,
translated_dir=translated,
game_root=data.parent,
)
self.assertEqual(calls, ["names", "strings"])
self.assertTrue(report.ok)
self.assertEqual(len(report.succeeded), 2)
class ReportDialogTests(unittest.TestCase):
def test_failure_dialog_lists_every_problem(self):
report = wi.InjectReport(
files=[
wi.FileInjectResult("a.json", True, "applied 3 line(s)"),
wi.FileInjectResult("b.json", False, "0 applied, 2 drifted", detail="stderr"),
],
sync_failures=[("c.json", "permission denied")],
)
title, body = wi.format_report_dialog(report)
self.assertIn("errors", title)
self.assertIn("✗ b.json", body)
self.assertIn("✗ sync c.json", body)
self.assertIn("✓ a.json", body)
if __name__ == "__main__":
unittest.main()

View file

@ -27,6 +27,13 @@ class WolfInjectCountsTests(unittest.TestCase):
self.assertEqual(drifted, 0)
self.assertTrue(wolfdawn.inject_had_applied(applied))
def test_parse_names_inject_dry_run_counts(self):
out = "would apply 0 name change(s) (0 drifted/unmatched); dry run — did NOT write"
applied, drifted = wolfdawn.parse_names_inject_counts(out)
self.assertEqual(applied, 0)
self.assertEqual(drifted, 0)
self.assertFalse(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)

View file

@ -69,7 +69,8 @@ _INJECT_MISMATCH_EVENT_RE = re.compile(
re.IGNORECASE | re.MULTILINE,
)
_NAMES_INJECT_COUNTS_RE = re.compile(
r"applied\s+(\d+)\s+name change.*?(\d+)\s+drifted", re.IGNORECASE | re.DOTALL
r"(?:would apply|applied)\s+(\d+)\s+name change.*?(\d+)\s+(?:drifted|unmatched)",
re.IGNORECASE | re.DOTALL,
)
ProgressFn = Callable[[int, int, str], None]
@ -632,6 +633,7 @@ def names_inject(
out_dir: Optional[PathLike] = None,
allow_code_drift: bool = False,
en_punct: bool = False,
dry_run: bool = False,
log_fn=None,
) -> WolfResult:
"""``wolf names-inject <names.json> --data <data-dir> [-o <out-dir>] [flags]``."""
@ -642,6 +644,8 @@ def names_inject(
args.append("--allow-code-drift")
if en_punct:
args.append("--en-punct")
if dry_run:
args.append("--dry-run")
return _run(args, log_fn=log_fn)

471
util/wolfdawn/inject.py Normal file
View file

@ -0,0 +1,471 @@
"""WolfDawn inject orchestration for translated JSON files.
One list of files in ``translated/``, user picks some or all, each file gets a
clear pass/fail result. ``names.json`` uses ``names-inject`` (must run before
per-file ``strings-inject`` when both are selected).
"""
from __future__ import annotations
import json
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from util.wolfdawn import codes as wolf_codes
from util.wolfdawn import db_dat_sibling
from util.wolfdawn import originals as wolf_originals
from util import wolfdawn
NAMES_JSON = "names.json"
@dataclass
class FileInjectResult:
json_name: str
success: bool
summary: str
applied: int | None = None
detail: str = ""
@dataclass
class InjectReport:
files: list[FileInjectResult] = field(default_factory=list)
sync_failures: list[tuple[str, str]] = field(default_factory=list)
@property
def ok(self) -> bool:
return all(r.success for r in self.files) and not self.sync_failures
@property
def succeeded(self) -> list[FileInjectResult]:
return [r for r in self.files if r.success]
@property
def failed(self) -> list[FileInjectResult]:
return [r for r in self.files if not r.success]
def list_injectable(translated_dir: Path, manifest_entries: list[dict]) -> list[str]:
"""Return sorted JSON filenames in *translated_dir* that the manifest can inject."""
manifest_json = {e["json"] for e in manifest_entries if e.get("json")}
if not translated_dir.is_dir():
return []
return sorted(
p.name
for p in translated_dir.glob("*.json")
if p.is_file() and p.name in manifest_json
)
def orig_base_for(entry: dict, data_dir: Path, originals_dir: Path) -> Path:
"""Pristine snapshot path for one manifest entry."""
base = Path(entry["base"])
try:
rel = base.relative_to(data_dir)
except ValueError:
rel = Path(base.name)
return originals_dir / rel
def restore_live_from_originals(
entries: list[dict],
data_dir: Path,
originals_dir: Path,
) -> list[str]:
"""Copy pristine originals onto live Data/. Return warning messages."""
warnings: list[str] = []
for entry in entries:
if entry.get("kind") == "names":
continue
orig = orig_base_for(entry, data_dir, originals_dir)
live = Path(entry["base"])
if not orig.exists():
warnings.append(f"{entry['json']}: no pristine original at {orig.name}")
continue
try:
if orig.is_dir():
if live.exists():
shutil.rmtree(live)
shutil.copytree(orig, live)
else:
live.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(orig, live)
if entry.get("kind") == "db":
dat_orig = db_dat_sibling(orig)
dat_live = db_dat_sibling(live)
if dat_orig.is_file():
shutil.copy2(dat_orig, dat_live)
except Exception as exc:
warnings.append(f"{entry['json']}: could not restore live binary ({exc})")
return warnings
def repair_inject_json(src: Path) -> Path:
"""Auto-repair WOLF inline codes in a translated JSON before inject."""
data = json.loads(src.read_text(encoding="utf-8-sig"))
data, repairs = wolf_codes.repair_document(data)
if repairs:
src.write_text(
json.dumps(data, ensure_ascii=False, indent=4) + "\n",
encoding="utf-8",
)
return src
def _wolf_output_snippet(stdout: str, stderr: str, *, limit: int = 400) -> str:
text = (stdout or "").strip()
if stderr and stderr.strip():
text = f"{text}\n{stderr.strip()}".strip() if text else stderr.strip()
if len(text) <= limit:
return text
return text[: limit - 3] + "..."
def _interpret_strings_result(json_name: str, res: wolfdawn.WolfResult) -> FileInjectResult:
cli_err = wolfdawn.parse_inject_cli_error(res.stdout, res.stderr)
applied, drifted = wolfdawn.parse_strings_inject_counts(res.stdout, res.stderr)
untranslated = wolfdawn.parse_strings_inject_untranslated(res.stdout, res.stderr)
mismatches = wolfdawn.parse_strings_inject_mismatches(res.stdout, res.stderr)
detail = _wolf_output_snippet(res.stdout, res.stderr)
if not res.ok:
reason = cli_err or f"wolf exited {res.returncode}"
return FileInjectResult(json_name, False, reason, applied=applied, detail=detail)
if wolfdawn.inject_had_applied(applied):
msg = f"applied {applied} line(s)"
if drifted:
msg += f" ({drifted} drifted)"
if untranslated:
msg += f" ({untranslated} skipped by safety guard)"
return FileInjectResult(json_name, True, msg, applied=applied, detail=detail)
if (drifted or 0) > 0:
return FileInjectResult(
json_name,
False,
f"0 applied, {drifted} drifted (live Data/ no longer matches Japanese sources)",
applied=0,
detail=detail,
)
if untranslated or mismatches:
parts = []
if untranslated:
parts.append(f"{untranslated} line(s) skipped by safety guard")
if mismatches:
parts.append(f"{len(mismatches)} control-code mismatch(es)")
return FileInjectResult(
json_name, False, "; ".join(parts), applied=0, detail=detail
)
return FileInjectResult(json_name, True, "no changes needed", applied=0, detail=detail)
def _interpret_names_result(json_name: str, res: wolfdawn.WolfResult) -> FileInjectResult:
cli_err = wolfdawn.parse_inject_cli_error(res.stdout, res.stderr)
applied, drifted = wolfdawn.parse_names_inject_counts(res.stdout, res.stderr)
detail = _wolf_output_snippet(res.stdout, res.stderr)
if not res.ok:
reason = cli_err or f"wolf exited {res.returncode}"
return FileInjectResult(json_name, False, reason, applied=applied, detail=detail)
if wolfdawn.inject_had_applied(applied):
msg = f"applied {applied} name change(s)"
if drifted:
msg += f" ({drifted} unmatched)"
return FileInjectResult(json_name, True, msg, applied=applied, detail=detail)
if res.ok and (drifted or 0) > 0:
return FileInjectResult(
json_name,
False,
(
f"0 applied, {drifted} unmatched "
"(re-run Step 0 extract on the untranslated game)"
),
applied=0,
detail=detail,
)
return FileInjectResult(
json_name,
False,
_STALE_NAMES_MSG,
applied=0,
detail=detail,
)
_STALE_NAMES_MSG = (
"0 name changes would apply. wolf_json/originals/ still holds old English "
"instead of Japanese sources (usually after a previous inject). Rebuilt from "
".wolf archives automatically when possible; if this persists, re-run Step 0 "
"on the untranslated game."
)
def _prepare_for_names_inject(
names_src: Path,
manifest_entries: list[dict],
data_dir: Path,
originals_dir: Path,
game_root: Path,
log_fn: Callable[[str], None] | None = None,
) -> str | None:
"""Restore live Data/ and ensure names-inject can match Japanese sources."""
emit = log_fn or (lambda _msg: None)
def _restore() -> None:
for warning in restore_live_from_originals(
manifest_entries, data_dir, originals_dir
):
emit(f"{warning}")
_restore()
would_apply = wolf_originals.names_inject_would_apply(names_src, data_dir)
if wolfdawn.inject_had_applied(would_apply):
return None
emit(" ⚠ names.json would apply 0 changes — rebuilding pristine originals…")
if wolf_originals.rebuild_originals_from_archives(
game_root, originals_dir, force=True, log_fn=log_fn
):
_restore()
would_apply = wolf_originals.names_inject_would_apply(names_src, data_dir)
if wolfdawn.inject_had_applied(would_apply):
emit(f" ✓ ready — dry run would apply {would_apply} name change(s)")
return None
return _STALE_NAMES_MSG
def _inject_names(
names_src: Path,
data_dir: Path,
*,
allow_code_drift: bool,
en_punct: bool,
log_fn: Callable[[str], None] | None = None,
) -> FileInjectResult:
emit = log_fn or (lambda _msg: None)
would_apply = wolf_originals.names_inject_would_apply(names_src, data_dir)
if not wolfdawn.inject_had_applied(would_apply):
return FileInjectResult(
NAMES_JSON,
False,
(
"inject refused — dry run would apply 0 name changes "
"(live Data/ still lacks Japanese sources; rebuild originals first)"
),
)
emit(f" dry run: {would_apply} name change(s) pending")
res = wolfdawn.names_inject(
str(names_src),
str(data_dir),
allow_code_drift=allow_code_drift,
en_punct=en_punct,
log_fn=None,
)
result = _interpret_names_result(NAMES_JSON, res)
if not result.success:
return result
remaining = wolf_originals.names_inject_would_apply(names_src, data_dir)
if wolfdawn.inject_had_applied(remaining):
result = FileInjectResult(
NAMES_JSON,
False,
(
f"only partial apply — {result.summary}, but dry run still shows "
f"{remaining} pending (restart Game.exe and report this)"
),
applied=result.applied,
detail=result.detail,
)
elif result.success and wolfdawn.inject_had_applied(result.applied):
result = FileInjectResult(
NAMES_JSON,
True,
result.summary + " — restart Game.exe to see changes",
applied=result.applied,
detail=result.detail,
)
return result
def _inject_strings(
json_name: str,
entry: dict,
inject_src: Path,
data_dir: Path,
originals_dir: Path,
*,
allow_code_drift: bool,
en_punct: bool,
) -> FileInjectResult:
orig = orig_base_for(entry, data_dir, originals_dir)
out = entry["base"]
if not orig.exists():
return FileInjectResult(
json_name,
False,
f"no pristine original at {orig} (re-run Step 0 extract)",
)
if entry.get("kind") == "db" and not db_dat_sibling(orig).is_file():
return FileInjectResult(
json_name,
False,
"missing database .dat pair in originals (re-run Step 0 extract)",
)
res = wolfdawn.strings_inject(
str(inject_src),
str(orig),
out,
allow_code_drift=allow_code_drift,
en_punct=en_punct,
log_fn=None,
)
return _interpret_strings_result(json_name, res)
def ensure_db_dat_snapshots(
entries: list[dict],
data_dir: Path,
originals_dir: Path,
) -> None:
"""Backfill missing database ``.dat`` files in *originals_dir* from live Data/."""
for entry in entries:
if entry.get("kind") != "db":
continue
proj_orig = orig_base_for(entry, data_dir, originals_dir)
dat_orig = db_dat_sibling(proj_orig)
if dat_orig.exists():
continue
dat_live = db_dat_sibling(Path(entry["base"]))
if not dat_live.is_file():
continue
dat_orig.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(dat_live, dat_orig)
def inject_selected(
selected: list[str],
*,
manifest_entries: list[dict],
data_dir: Path,
originals_dir: Path,
translated_dir: Path,
game_root: Path,
allow_code_drift: bool = False,
en_punct: bool = False,
log_fn: Callable[[str], None] | None = None,
) -> InjectReport:
"""Inject *selected* translated JSON files. Returns a per-file report."""
emit = log_fn or (lambda _msg: None)
report = InjectReport()
if not selected:
return report
by_json = {e["json"]: e for e in manifest_entries if e.get("json")}
ordered = sorted(set(selected), key=lambda n: (n != NAMES_JSON, n.lower()))
missing_manifest = [n for n in ordered if n not in by_json]
missing_translated = [
n for n in ordered if not (translated_dir / n).is_file()
]
for name in missing_manifest:
report.files.append(
FileInjectResult(name, False, "not listed in manifest (re-run Step 0 extract)")
)
for name in missing_translated:
if name in by_json:
report.files.append(
FileInjectResult(name, False, f"not found in {translated_dir.name}/")
)
todo = [
n for n in ordered
if n in by_json and (translated_dir / n).is_file()
]
if not todo:
return report
if NAMES_JSON in todo:
emit("Preparing live Data/ for names.json…")
names_src = translated_dir / NAMES_JSON
prep_error = _prepare_for_names_inject(
names_src,
manifest_entries,
data_dir,
originals_dir,
game_root,
log_fn=log_fn,
)
if prep_error:
result = FileInjectResult(NAMES_JSON, False, prep_error)
report.files.append(result)
emit(f"{NAMES_JSON}: {result.summary}")
todo = [n for n in todo if n != NAMES_JSON]
else:
emit(f"Injecting {NAMES_JSON}")
result = _inject_names(
names_src,
data_dir,
allow_code_drift=allow_code_drift,
en_punct=en_punct,
log_fn=log_fn,
)
report.files.append(result)
emit(("" if result.success else "") + f"{NAMES_JSON}: {result.summary}")
strings_todo = [n for n in todo if n != NAMES_JSON]
if strings_todo:
ensure_db_dat_snapshots(manifest_entries, data_dir, originals_dir)
for json_name in strings_todo:
entry = by_json[json_name]
inject_src = repair_inject_json(translated_dir / json_name)
emit(f"Injecting {json_name}")
result = _inject_strings(
json_name,
entry,
inject_src,
data_dir,
originals_dir,
allow_code_drift=allow_code_drift,
en_punct=en_punct,
)
report.files.append(result)
emit(("" if result.success else "") + f"{json_name}: {result.summary}")
return report
def format_report_dialog(report: InjectReport) -> tuple[str, str]:
"""Return (title, body) for a completion dialog."""
if report.ok and report.succeeded:
title = "Inject complete"
lines = [f"{r.json_name}: {r.summary}" for r in report.succeeded]
elif report.failed or report.sync_failures:
title = "Inject finished with errors"
lines = []
for r in report.succeeded:
lines.append(f"{r.json_name}: {r.summary}")
for r in report.failed:
lines.append(f"{r.json_name}: {r.summary}")
if r.detail:
lines.append(f" {r.detail}")
for name, err in report.sync_failures:
lines.append(f"✗ sync {name}: {err}")
else:
title = "Inject"
lines = ["Nothing was injected."]
return title, "\n".join(lines)

View file

@ -0,0 +1,79 @@
"""Pristine binary snapshots for idempotent WolfDawn inject."""
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
from typing import Callable
from util import wolfdawn
ProgressFn = Callable[[int, int, str], None]
def find_data_archives(game_root: Path, data_dir: Path) -> list[Path]:
"""Return MapData / BasicData ``.wolf`` or ``.wolf.bak`` archives."""
archives: list[Path] = []
for base in (game_root, data_dir):
if not base.is_dir():
continue
for pat in ("*.wolf", "*.wolf.bak"):
archives.extend(base.glob(pat))
return [a for a in archives if a.name.lower().startswith(("mapdata", "basicdata"))]
def rebuild_originals_from_archives(
game_root: Path,
originals_dir: Path,
*,
force: bool = False,
log_fn: Callable[[str], None] | None = None,
progress_fn: ProgressFn | None = None,
) -> bool:
"""Unpack ``BasicData`` / ``MapData`` archives into *originals_dir*.
When *force* is true, the entire *originals_dir* tree is removed first so
stale flat snapshots from an old extract cannot survive beside rebuilt data.
"""
emit = log_fn or (lambda _msg: None)
data_dir = game_root / "Data"
archives = find_data_archives(game_root, data_dir)
if not archives:
emit(" ⚠ no BasicData/MapData .wolf archives found to rebuild originals")
return False
if force and originals_dir.exists():
shutil.rmtree(originals_dir)
originals_dir.mkdir(parents=True, exist_ok=True)
emit("Rebuilding pristine originals from the game's .wolf archives…")
with tempfile.TemporaryDirectory() as tmp:
inputs: list[str] = []
for arc in archives:
if arc.suffix == ".bak":
staged = Path(tmp) / arc.with_suffix("").name
shutil.copy2(arc, staged)
inputs.append(str(staged))
else:
inputs.append(str(arc))
res = wolfdawn.unpack_all(
inputs,
str(originals_dir),
log_fn=log_fn,
progress_fn=progress_fn,
progress_total=len(inputs),
)
if not res.ok:
emit(f" ⚠ could not rebuild originals (unpack exit {res.returncode})")
return False
return True
def names_inject_would_apply(names_json: Path, data_dir: Path) -> int | None:
"""Return how many name changes wolf would apply (dry run), or None if unknown."""
res = wolfdawn.names_inject(
str(names_json), str(data_dir), dry_run=True, log_fn=None
)
applied, _drifted = wolfdawn.parse_names_inject_counts(res.stdout, res.stderr)
return applied