From e214a28d5db1e47dcf89ebeeadcfcb3e94aa20d1 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Thu, 9 Jul 2026 13:35:12 -0500 Subject: [PATCH] More changes and fixes --- README.md | 4 +- gui/wolf_workflow_tab.py | 160 +++++++++++++++++++++----------- modules/wolfdawn.py | 29 +++++- tests/test_wolf_inject_batch.py | 70 +++++++++++++- tests/test_wolfdawn.py | 50 ++++++++++ tests/test_wolfdawn_relayout.py | 40 ++++++++ util/wolfdawn/__init__.py | 43 +++++++++ util/wolfdawn/inject.py | 45 +++++++-- 8 files changed, 376 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index e29bc01..b786cac 100644 --- a/README.md +++ b/README.md @@ -331,9 +331,9 @@ Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine sel | **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 Precheck** | Dry-run selected JSON for safety-guard skips; fix those lines before writing binaries. | -| **7 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 Inject** | Always **Inject all** translated JSON from `translated/` into Data/ in one pass (keeps `names.json` and DB/map files in sync). Each file is reported individually; failures open a dialog with details. Uses pristine originals from `wolf_json/originals/`, then preserves name-only fields after names-inject. Optional **Layout-restore** re-applies source whitespace pads. | | **8 Package** | Run from loose `Data/` (backs up `Data.wolf` → `.bak`) or repack `Data.wolf` so you can playtest and see overflow in-game. Optionally rewrite existing `.sav` files so old Japanese saves load in the translated build. | -| **9 Fix wrap** | After packaging, 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`) and re-package to verify. 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. | +| **9 Fix wrap** | After packaging, 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 all** (Step 7) and re-package to verify. 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. | ### Recommended order by game layout diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index b26b468..fd14ab2 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -17,13 +17,14 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI Step 5 Maps/Events - .mps maps, CommonEvent, Game.dat, Evtext; speaker handling Step 6 Precheck - dry-run selected JSON for safety-guard skips; edit those lines before writing binaries - Step 7 Inject - inject translations into Data/ binaries and refresh - wolf_json/; quick-inject picks a few files or all + Step 7 Inject - always inject every translated JSON into Data/ and + refresh wolf_json/ (full pass avoids name/DB wipe bugs); + optional layout-restore re-applies source whitespace pads Step 8 Package - run from a loose Data/ folder or repack Data.wolf; optional save rewrite for existing .sav files Step 9 Fix wrap - search translated JSON by in-game text; Relayout (names-wrap / cell geometry) or Manual (width + font); - inject the edited file, then re-package to verify + then Inject all from Step 7 and re-package to verify 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 entries @@ -2523,9 +2524,10 @@ class WolfWorkflowTab(QWidget): def _build_step7_inject(self, layout: QVBoxLayout): layout.addWidget(_make_section_label("Step 7 · Inject Translations")) layout.addWidget(self._desc( - "Tick translated JSON files below, then inject them into the game's Data/ " - "binaries. Run Step 6 (Precheck) first if you want to catch safety-guard " - "skips before writing. Each file is reported individually." + "Inject every translated JSON into the game's Data/ binaries in one pass. " + "A full inject keeps names.json and DB/map files in sync (partial injects " + "can wipe name-only fields like rumor boards). Run Step 6 (Precheck) first " + "if you want to catch safety-guard skips before writing." )) self.en_punct_cb = QCheckBox("Convert Japanese punctuation to ASCII (--en-punct)") @@ -2562,10 +2564,10 @@ class WolfWorkflowTab(QWidget): layout.addLayout(check_row) layout.addWidget(_make_hr()) - layout.addWidget(self._subheading("Translated files")) + layout.addWidget(self._subheading("Files that will be injected")) self.inject_list = QListWidget() - self.inject_list.setMaximumHeight(280) + self.inject_list.setMaximumHeight(220) self.inject_list.setStyleSheet( "QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;" "color:#cccccc;font-size:12px;padding:2px;}" @@ -2574,44 +2576,51 @@ class WolfWorkflowTab(QWidget): ) layout.addWidget(self.inject_list) + self._inject_count_label = QLabel("") + self._inject_count_label.setStyleSheet("color:#9cdcfe;font-size:12px;") + layout.addWidget(self._inject_count_label) + list_row = QHBoxLayout() 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_checklist_checks(self.inject_list, True)) - sel_none_btn = self._register(_make_btn("Select none", "#3a3a3a")) - sel_none_btn.clicked.connect(lambda: self._set_checklist_checks(self.inject_list, False)) list_row.addWidget(refresh_btn) - list_row.addWidget(sel_all_btn) - list_row.addWidget(sel_none_btn) list_row.addStretch() layout.addLayout(list_row) 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.setToolTip( + "Always injects every file in translated/ that the manifest knows about. " + "names.json runs first; DB/map injects keep name-only fields." + ) inject_all_btn.clicked.connect(self._inject_all) - btn_row.addWidget(inject_sel_btn) + layout_restore_btn = self._register(_make_btn("Layout-restore", "#3a3a3a")) + layout_restore_btn.setToolTip( + "Re-run wolf layout-restore on every injectable translated JSON. " + "Copies unambiguous positional whitespace pads from source onto text " + "(e.g. status-card \\nNAME). Safe to re-run; already-fixed lines are no-ops." + ) + layout_restore_btn.clicked.connect(self._layout_restore_all) btn_row.addWidget(inject_all_btn) btn_row.addStretch() + btn_row.addWidget(layout_restore_btn) layout.addLayout(btn_row) layout.addWidget(_make_hr()) layout.addWidget(self._desc( "Next: Step 8 (Package) so you can playtest, then Step 9 (Fix wrapping) " - "to paste overflowing in-game text, wrap, and re-inject." + "to paste overflowing in-game text, wrap, and Inject all again." )) self._refresh_inject_list() def _build_step9_relayout(self, layout: QVBoxLayout): - """Search-first fix wrapping: find sheet by in-game text, wrap, re-inject.""" + """Search-first fix wrapping: find sheet by in-game text, wrap, Inject all.""" layout.addWidget(_make_section_label("Step 9 · Fix wrapping")) layout.addWidget(self._desc( "After packaging and playtesting, paste overflowing in-game text to find " "it in translated JSON. Relayout uses cell width + max lines " "(names.json → wolf names-wrap). Manual uses wrap width + body font; " - "emphasis \\f[N] scales with the body. Re-inject, then re-package to verify." + "emphasis \\f[N] scales with the body. Then Inject all (Step 7) and re-package." )) search_row = QHBoxLayout() @@ -2777,8 +2786,12 @@ class WolfWorkflowTab(QWidget): wrap_row_btn.clicked.connect(self._wrap_current_row) wrap_group_btn = _make_btn("Wrap all in group", "#00a86b") wrap_group_btn.clicked.connect(self._wrap_current_group) - inject_btn = _make_btn("Inject this file", "#007acc") - inject_btn.clicked.connect(self._inject_current_wrap_file) + inject_btn = _make_btn("Inject all", "#007acc") + inject_btn.setToolTip( + "Always injects every translated file (same as Step 7). " + "Partial injects can wipe name-only DB fields." + ) + inject_btn.clicked.connect(self._inject_all) btn_row.addWidget(wrap_row_btn) btn_row.addWidget(wrap_group_btn) btn_row.addWidget(inject_btn) @@ -3226,7 +3239,7 @@ class WolfWorkflowTab(QWidget): else "Line already fits (manual)." ) self._wrap_status_label.setText( - msg + f" Inject {self._current_wrap_path.name} from Step 7." + msg + " Inject all from Step 7 when ready." ) self._log(f"{'✅' if changed else 'ℹ'} {msg}") self._update_wrap_preview() @@ -3267,7 +3280,7 @@ class WolfWorkflowTab(QWidget): if line is not None: self._current_wrap_text = str(line.get("text") or "") msg = f"Wrapped line at width {width}." if changed else "Line already fits at this width." - self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name} from Step 7.") + self._wrap_status_label.setText(msg + " Inject all from Step 7 when ready.") self._log(f"{'✅' if changed else 'ℹ'} {msg}") self._update_wrap_preview() @@ -3301,7 +3314,7 @@ class WolfWorkflowTab(QWidget): f"Manual-wrapped {count} line(s) in {scope} " f"(width {width}, body \\f[{font}])." ) - self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name}.") + self._wrap_status_label.setText(msg + " Inject all from Step 7 when ready.") self._log(f"✅ {msg}") self._reload_wrap_hit_editor(hit, width) return @@ -3339,7 +3352,7 @@ class WolfWorkflowTab(QWidget): ) scope = wolf_ws.scope_label(hit) msg = f"Wrapped {count} line(s) in {scope} at width {width}." - self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name}.") + self._wrap_status_label.setText(msg + " Inject all from Step 7 when ready.") self._log(f"✅ {msg}") self._reload_wrap_hit_editor(hit, width) @@ -3434,20 +3447,11 @@ class WolfWorkflowTab(QWidget): self._reload_wrap_hit_editor(hit, self._active_wrap_width()) if not quiet and hasattr(self, "_wrap_status_label"): self._wrap_status_label.setText( - summary + " Re-inject names.json from Step 7." + summary + " Inject all from Step 7 when ready." ) self._run_task(task, on_done=_after) - def _inject_current_wrap_file(self): - hit = self._current_wrap_hit - if not hit: - QMessageBox.information(self, "Inject", "Select a search result first.") - return - if not self._require_manifest(): - return - self._inject({hit.json_file}) - def _tool_root(self) -> Path: """DazedMTLTool project root (``files/``, ``translated/``, etc.).""" return Path(__file__).resolve().parent.parent @@ -3626,7 +3630,7 @@ class WolfWorkflowTab(QWidget): self._refresh_inject_list() def _fill_injectable_checklist(self, list_widget: QListWidget): - """Populate a checkable file list from translated/ + manifest.""" + """Populate a checkable file list from translated/ + manifest (precheck).""" list_widget.clear() if not self._read_manifest(): item = QListWidgetItem("Run Step 0 (extract) first - no manifest found.") @@ -3652,9 +3656,31 @@ class WolfWorkflowTab(QWidget): self._fill_injectable_checklist(self.precheck_list) def _refresh_inject_list(self): + """Show every injectable file (read-only). Inject always uses the full set.""" if not hasattr(self, "inject_list"): return - self._fill_injectable_checklist(self.inject_list) + self.inject_list.clear() + if hasattr(self, "_inject_count_label"): + self._inject_count_label.setText("") + 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 + 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.setFlags(Qt.ItemIsEnabled) + self.inject_list.addItem(item) + if hasattr(self, "_inject_count_label"): + self._inject_count_label.setText( + f"{len(files)} file(s) will be injected together." + ) def _selected_checklist_files(self, list_widget: QListWidget) -> set[str]: selected: set[str] = set() @@ -3669,9 +3695,6 @@ class WolfWorkflowTab(QWidget): def _selected_precheck_files(self) -> set[str]: return self._selected_checklist_files(self.precheck_list) - def _selected_inject_files(self) -> set[str]: - return self._selected_checklist_files(self.inject_list) - def _set_checklist_checks(self, list_widget: QListWidget, checked: bool): state = Qt.Checked if checked else Qt.Unchecked for i in range(list_widget.count()): @@ -3821,18 +3844,6 @@ class WolfWorkflowTab(QWidget): "Saved. Re-run Precheck to confirm the safety skip is gone.", ) - def _inject_selected(self): - if not self._require_manifest(): - return - selected = self._selected_inject_files() - if not selected: - QMessageBox.information( - self, "Nothing selected", - "Tick one or more files in the list, then click Inject selected.", - ) - return - self._inject(selected) - def _inject_all(self): if not self._require_manifest(): return @@ -3845,6 +3856,49 @@ class WolfWorkflowTab(QWidget): return self._inject(set(files)) + def _layout_restore_all(self): + """Re-run wolf layout-restore on every injectable translated JSON.""" + files = self._injectable_filenames() + if not files: + QMessageBox.information( + self, + "Layout-restore", + "No injectable files found in translated/.", + ) + return + translated = self._tool_root() / "translated" + paths = [translated / name for name in files if (translated / name).is_file()] + if not paths: + QMessageBox.information( + self, + "Layout-restore", + "None of the injectable files exist under translated/.", + ) + return + + def task(log, progress=None): + from util import wolfdawn + + log(f"layout-restore: {len(paths)} file(s)…") + res = wolfdawn.layout_restore(paths, log_fn=log) + for line in (res.stdout or "").splitlines(): + if line.strip(): + log(line) + fixed = wolfdawn.parse_layout_restore_counts(res.stdout, res.stderr) + if not res.ok: + err = (res.stderr or res.stdout or "").strip() or f"exit {res.returncode}" + return False, f"layout-restore failed: {err}" + n = fixed if fixed is not None else 0 + return True, f"layout-restore fixed {n} line(s) across {len(paths)} file(s)." + + def _after(ok: bool, msg: str): + if ok: + QMessageBox.information(self, "Layout-restore", msg) + else: + QMessageBox.warning(self, "Layout-restore", msg or "layout-restore failed.") + + self._run_task(task, on_done=_after) + def _names_check(self): if not self._require_manifest(): return diff --git a/modules/wolfdawn.py b/modules/wolfdawn.py index 89b0642..2a0d29e 100644 --- a/modules/wolfdawn.py +++ b/modules/wolfdawn.py @@ -41,6 +41,10 @@ to WOLF's native ``Speaker\nline`` layout on write-back. See ``util.speakers``. Detection is WolfDawn's, so the reliable nameplate (``literal_line1``) is always reshaped; only the low-confidence guess (``literal_line1_lowconf``) is gated by a per-game, AI-recommended setting from the workflow. + +Layout restore: after each file is written to ``translated/``, ``wolf +layout-restore`` copies unambiguous positional whitespace pads from ``source`` +onto ``text`` (status-card ``\\nNAME`` fields the model often strips). """ import json @@ -61,6 +65,7 @@ from util.translation import ( ) from util import speakers as wolf_speakers from util import vocab as wolf_vocab +from util import wolfdawn from util.wolfdawn import codes as wolf_codes from util.wolfdawn import db_classify as wolf_db from util.wolfdawn import names as wolf_names @@ -149,12 +154,34 @@ def handleWolfDawn(filename, estimate): # translated/ here would overwrite prior work with source echoed back. # Real English is written on the consume pass (or a live non-batch run). if not estimate and _batch_phase() != "collect": + out_path = "translated/" + filename try: - with open("translated/" + filename, "w", encoding="utf-8", newline="\n") as outFile: + with open(out_path, "w", encoding="utf-8", newline="\n") as outFile: json.dump(translatedData[0], outFile, ensure_ascii=False, indent=4) except Exception: traceback.print_exc() return "Fail" + # Restore positional whitespace pads the model dropped (status-card + # ``\nNAME`` fields, etc.). WolfDawn edits the JSON in place. + try: + res = wolfdawn.layout_restore(out_path) + if not res.ok: + tqdm.write( + Fore.YELLOW + + f"{filename}: layout-restore exited {res.returncode}" + + (f" ({res.stderr.strip()})" if res.stderr.strip() else "") + + Fore.RESET + ) + else: + fixed = wolfdawn.parse_layout_restore_counts(res.stdout, res.stderr) + if fixed: + tqdm.write( + Fore.CYAN + + f"{filename}: layout-restore fixed {fixed} line(s)" + + Fore.RESET + ) + except Exception: + traceback.print_exc() end = time.time() tqdm.write(getResultString(translatedData, end - start, filename)) diff --git a/tests/test_wolf_inject_batch.py b/tests/test_wolf_inject_batch.py index 2cb30da..336ab5c 100644 --- a/tests/test_wolf_inject_batch.py +++ b/tests/test_wolf_inject_batch.py @@ -39,6 +39,7 @@ class ListInjectableTests(unittest.TestCase): class InjectOrderTests(unittest.TestCase): def test_names_runs_before_strings(self): calls: list[str] = [] + string_bases: list[str] = [] def fake_names(*_a, **_k): calls.append("names") @@ -49,8 +50,9 @@ class InjectOrderTests(unittest.TestCase): stderr="", ) - def fake_strings(*_a, **_k): + def fake_strings(edited_json, base, output, **_k): calls.append("strings") + string_bases.append(str(base)) return mock.Mock( ok=True, returncode=0, @@ -105,6 +107,72 @@ class InjectOrderTests(unittest.TestCase): self.assertEqual(calls, ["names", "strings"]) self.assertTrue(report.ok) self.assertEqual(len(report.succeeded), 2) + # After names-inject, strings must use live Data/ as --base so + # name-only fields are not rebuilt from pristine Japanese originals. + self.assertEqual(string_bases, [str(map_live)]) + + def test_strings_alone_use_pristine_original_base(self): + string_bases: list[str] = [] + + def fake_strings(edited_json, base, output, **_k): + string_bases.append(str(base)) + 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 / "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": "Map001.mps.json", "kind": "map", "base": str(map_live)}, + ] + with mock.patch.object(wi.wolfdawn, "strings_inject", side_effect=fake_strings): + report = wi.inject_selected( + ["Map001.mps.json"], + manifest_entries=entries, + data_dir=data, + originals_dir=originals, + translated_dir=translated, + game_root=data.parent, + ) + self.assertTrue(report.ok) + self.assertEqual(string_bases, [str(map_orig)]) + + def test_names_result_mentions_safety_skips(self): + res = mock.Mock( + ok=True, + returncode=0, + stdout=( + "would apply 10 name change(s) (0 drifted/unmatched)\n" + "WARNING: 46 line(s) left UNTRANSLATED by a safety guard - " + "46 control-code mismatch, 0 not encodable\n" + ), + stderr="", + ) + # parse_names_inject_counts needs "applied N name change" + res.stdout = ( + "applied 10 name change(s) (0 drifted/unmatched)\n" + "WARNING: 46 line(s) left UNTRANSLATED by a safety guard - " + "46 control-code mismatch, 0 not encodable\n" + ) + result = wi._interpret_names_result("names.json", res) + self.assertTrue(result.success) + self.assertIn("46 skipped by safety guard", result.summary) class ReportDialogTests(unittest.TestCase): diff --git a/tests/test_wolfdawn.py b/tests/test_wolfdawn.py index 29a26ce..3ab781a 100644 --- a/tests/test_wolfdawn.py +++ b/tests/test_wolfdawn.py @@ -298,6 +298,56 @@ class TestTranslationWriteback(unittest.TestCase): else: os.environ["BATCH_PHASE"] = orig_phase + def test_layout_restore_runs_after_write(self): + """After writing translated/, wolf layout-restore restores pad skeletons.""" + pad = " \n" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "files").mkdir() + (root / "translated").mkdir() + # Use map kind so DB sheet filters from .env cannot skip the line. + doc = { + "kind": "map", + "file": "Map001.mps", + "scenes": [ + { + "event": 1, + "name": "ev", + "lines": [ + { + "cmd": 0, + "str": 0, + "source": pad + "なし", + "text": pad + "なし", + } + ], + } + ], + } + (root / "files" / "Map001.mps.json").write_text( + json.dumps(doc, ensure_ascii=False), encoding="utf-8" + ) + + def translate(text, history, history_ctx=None): + # Model drops the leading pad / newline - the bug layout-restore fixes. + if isinstance(text, list): + return [["None" for _ in text], [1, 1]] + return ["None", [1, 1]] + + old_cwd = os.getcwd() + os.chdir(root) + orig_t = wd.translateAI + wd.translateAI = translate + try: + result = wd.handleWolfDawn("Map001.mps.json", estimate=False) + finally: + wd.translateAI = orig_t + os.chdir(old_cwd) + + self.assertNotEqual(result, "Fail") + out = json.loads((root / "translated" / "Map001.mps.json").read_text(encoding="utf-8")) + self.assertEqual(out["scenes"][0]["lines"][0]["text"], pad + "None") + def test_echoed_source_does_not_overwrite_text(self): """If the model/collect echoes JP source, leave text alone.""" doc = { diff --git a/tests/test_wolfdawn_relayout.py b/tests/test_wolfdawn_relayout.py index 6970adf..8455131 100644 --- a/tests/test_wolfdawn_relayout.py +++ b/tests/test_wolfdawn_relayout.py @@ -97,5 +97,45 @@ class TestRelayoutWrappers(unittest.TestCase): self.assertEqual(wolfdawn.parse_names_wrap_shrink_count(res.stdout), 2) +class TestLayoutRestoreWrapper(unittest.TestCase): + def test_layout_restore_flags(self): + captured = {} + + def fake_run(args, log_fn=None): + captured["args"] = list(args) + return wolfdawn.WolfResult( + 0, + "layout-restore: 1 translation(s) got back their source's whitespace skeleton\n", + "", + [str(a) for a in args], + ) + + with patch.object(wolfdawn, "_run", side_effect=fake_run): + res = wolfdawn.layout_restore("/tmp/db.json", dry_run=True) + self.assertEqual( + captured["args"], + ["layout-restore", "/tmp/db.json", "--dry-run"], + ) + self.assertEqual(wolfdawn.parse_layout_restore_counts(res.stdout), 1) + + def test_layout_restore_multiple_files(self): + captured = {} + + def fake_run(args, log_fn=None): + captured["args"] = list(args) + return wolfdawn.WolfResult(0, "", "", [str(a) for a in args]) + + with patch.object(wolfdawn, "_run", side_effect=fake_run): + wolfdawn.layout_restore(["/a.json", "/b.json"]) + self.assertEqual(captured["args"], ["layout-restore", "/a.json", "/b.json"]) + + def test_parse_layout_restore_would_count(self): + out = ( + "layout-restore: 3 translation(s) WOULD get back their " + "source's whitespace skeleton (dry run; no write)\n" + ) + self.assertEqual(wolfdawn.parse_layout_restore_counts(out), 3) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/util/wolfdawn/__init__.py b/util/wolfdawn/__init__.py index 7bede95..5469fff 100644 --- a/util/wolfdawn/__init__.py +++ b/util/wolfdawn/__init__.py @@ -54,6 +54,8 @@ __all__ = [ "names_wrap", "parse_names_wrap_counts", "parse_names_wrap_shrink_count", + "layout_restore", + "parse_layout_restore_counts", "relayout", "desc_relayout", ] @@ -752,6 +754,47 @@ def names_wrap( return _run(args, log_fn=log_fn) +_LAYOUT_RESTORE_APPLIED_RE = re.compile( + r"(\d+)\s+translation\(s\)\s+got back their source's whitespace skeleton", + re.IGNORECASE, +) +_LAYOUT_RESTORE_WOULD_RE = re.compile( + r"(\d+)\s+translation\(s\)\s+WOULD get back their source's whitespace skeleton", + re.IGNORECASE, +) + + +def parse_layout_restore_counts(stdout: str, stderr: str = "") -> int | None: + """Return how many translations layout-restore fixed (or would fix), or None.""" + blob = f"{stdout}\n{stderr}" + for pattern in (_LAYOUT_RESTORE_APPLIED_RE, _LAYOUT_RESTORE_WOULD_RE): + match = pattern.search(blob) + if match: + return int(match.group(1)) + return None + + +def layout_restore( + json_files: Union[PathLike, Iterable[PathLike]], + *, + dry_run: bool = False, + log_fn=None, +) -> WolfResult: + """``wolf layout-restore ... [--dry-run]``. + + Rebuilds translations that dropped their source's positional whitespace pad + skeleton (e.g. status-card ``\\nNAME`` fields). Edits JSON in place. + """ + if isinstance(json_files, (str, Path)): + paths = [json_files] + else: + paths = list(json_files) + args = ["layout-restore", *[_str(p) for p in paths]] + if dry_run: + args.append("--dry-run") + return _run(args, log_fn=log_fn) + + def _relayout_metric_arg(value: int | str | None, *, zero_is_auto: bool = False) -> str | None: """Format one relayout geometry flag (``55``, ``auto``, or omit).""" if value is None: diff --git a/util/wolfdawn/inject.py b/util/wolfdawn/inject.py index d15366b..038f628 100644 --- a/util/wolfdawn/inject.py +++ b/util/wolfdawn/inject.py @@ -2,7 +2,9 @@ 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). +per-file ``strings-inject`` when both are selected). After names-inject, each +``strings-inject`` uses the live post-names binary as ``--base`` so name-only +DB fields (rumor boards, etc.) are not rebuilt from pristine Japanese originals. """ from __future__ import annotations @@ -170,18 +172,21 @@ def _interpret_strings_result(json_name: str, res: wolfdawn.WolfResult) -> FileI 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) + safety = wolfdawn.parse_strings_inject_safety_count(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)" + if safety: + msg += f" ({safety} skipped by safety guard)" return FileInjectResult(json_name, True, msg, applied=applied, detail=detail) + if not res.ok: + reason = cli_err or f"wolf exited {res.returncode}" + return FileInjectResult(json_name, False, reason, applied=applied, detail=detail) + if res.ok and (drifted or 0) > 0: return FileInjectResult( json_name, @@ -320,9 +325,22 @@ def _inject_strings( *, allow_code_drift: bool, en_punct: bool, + base_path: Path | None = None, ) -> FileInjectResult: + """Inject one strings JSON. + + ``base_path`` defaults to the pristine original. Pass the live binary when + names-inject already ran in this batch so name-only fields are preserved. + """ orig = orig_base_for(entry, data_dir, originals_dir) out = entry["base"] + base = base_path if base_path is not None else orig + if not base.exists(): + return FileInjectResult( + json_name, + False, + f"no inject base at {base} (re-run Step 0 extract)", + ) if not orig.exists(): return FileInjectResult( json_name, @@ -330,16 +348,16 @@ def _inject_strings( f"no pristine original at {orig} (re-run Step 0 extract)", ) - if entry.get("kind") == "db" and not db_dat_sibling(orig).is_file(): + if entry.get("kind") == "db" and not db_dat_sibling(base).is_file(): return FileInjectResult( json_name, False, - "missing database .dat pair in originals (re-run Step 0 extract)", + "missing database .dat pair beside inject base (re-run Step 0 extract)", ) res = wolfdawn.strings_inject( str(inject_src), - str(orig), + str(base), out, allow_code_drift=allow_code_drift, en_punct=en_punct, @@ -410,6 +428,7 @@ def inject_selected( if not todo: return report + names_applied = False if NAMES_JSON in todo: emit("Preparing live Data/ for names.json…") names_src = translated_dir / NAMES_JSON @@ -452,15 +471,24 @@ def inject_selected( ) report.files.append(result) emit((" ✓ " if result.success else " ✗ ") + f"{NAMES_JSON}: {result.summary}") + names_applied = result.success strings_todo = [n for n in todo if n != NAMES_JSON] if strings_todo: ensure_db_dat_snapshots(manifest_entries, data_dir, originals_dir) + if names_applied: + emit( + " ℹ using live Data/ as strings-inject base " + "(preserve name-only fields from names-inject)" + ) 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}…") + # After names-inject, live binaries already hold EN name-only fields. + # Rebuilding from pristine JP originals would wipe those (rumor boards, etc.). + live_base = Path(entry["base"]) if names_applied else None result = _inject_strings( json_name, entry, @@ -469,6 +497,7 @@ def inject_selected( originals_dir, allow_code_drift=allow_code_drift, en_punct=en_punct, + base_path=live_base, ) report.files.append(result) emit((" ✓ " if result.success else " ✗ ") + f"{json_name}: {result.summary}")