diff --git a/README.md b/README.md index 6ad321d..edbffac 100644 --- a/README.md +++ b/README.md @@ -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 3–4 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. | diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index 3a0b905..78d7a24 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -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: diff --git a/tests/test_wolf_inject_batch.py b/tests/test_wolf_inject_batch.py new file mode 100644 index 0000000..2cb30da --- /dev/null +++ b/tests/test_wolf_inject_batch.py @@ -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() diff --git a/tests/test_wolf_inject_counts.py b/tests/test_wolf_inject_counts.py index ea228bc..4a0bd7c 100644 --- a/tests/test_wolf_inject_counts.py +++ b/tests/test_wolf_inject_counts.py @@ -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) diff --git a/util/wolfdawn/__init__.py b/util/wolfdawn/__init__.py index 56a0e79..59266a4 100644 --- a/util/wolfdawn/__init__.py +++ b/util/wolfdawn/__init__.py @@ -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 --data [-o ] [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) diff --git a/util/wolfdawn/inject.py b/util/wolfdawn/inject.py new file mode 100644 index 0000000..fdce137 --- /dev/null +++ b/util/wolfdawn/inject.py @@ -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) diff --git a/util/wolfdawn/originals.py b/util/wolfdawn/originals.py new file mode 100644 index 0000000..652b2bc --- /dev/null +++ b/util/wolfdawn/originals.py @@ -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