From 519b09fad233263aa6bb285e3a83c9241da18623 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Thu, 9 Jul 2026 07:55:19 -0500 Subject: [PATCH] Precheck --- gui/wolf_workflow_tab.py | 340 +++++++++++++++++++++---- tests/test_wolf_inject_counts.py | 3 + tests/test_wolf_inject_precheck.py | 136 ++++++++++ util/wolfdawn/__init__.py | 77 ++++-- util/wolfdawn/inject.py | 24 +- util/wolfdawn/inject_precheck.py | 384 +++++++++++++++++++++++++++++ 6 files changed, 889 insertions(+), 75 deletions(-) create mode 100644 tests/test_wolf_inject_precheck.py create mode 100644 util/wolfdawn/inject_precheck.py diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index 301a9a5..5126ced 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -14,20 +14,20 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI Step 4 Database - discover content layout; translate foundation DB sheets (items, skills, descriptions) before narrative custom sheets Step 5 Maps/Events - .mps maps, CommonEvent, Game.dat, Evtext; speaker handling - Step 6 Inject - inject translations + translated names back into the Data/ binaries - and refresh the git-tracked wolf_json/ with the translated - JSON; quick-inject picks just a few translated files for - fast in-game / git iteration, or inject everything at once. - Step 7 Fix wrap - search translated JSON by in-game text; Relayout + 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 8 Fix wrap - search translated JSON by in-game text; Relayout (names-wrap / cell geometry) or Manual (width + font); inject the edited file - Step 8 Package - run from a loose Data/ folder, or repack Data.wolf - Step 9 Saves - fix baked strings in existing .sav files (optional) + Step 9 Package - run from a loose Data/ folder, or repack Data.wolf + Step 10 Saves - fix baked strings in existing .sav files (optional) 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 -(per-name, not per-category), harvests them into vocab.txt, and Step 6 injects the -result. All text wrapping lives in Step 7. +(per-name, not per-category), harvests them into vocab.txt, and Step 7 injects the +result. All text wrapping lives in Step 8. The extracted JSON is staged in ``/wolf_json/`` (a manifest maps each file back to its base binary), then imported into ``files/`` for the shared @@ -584,10 +584,11 @@ class WolfWorkflowTab(QWidget): ("3 Names", self._build_step3_names), ("4 Database", self._build_step4_database), ("5 Maps/Events", self._build_step5_maps_events), - ("6 Inject", self._build_step4_inject), - ("7 Fix wrap", self._build_step7_relayout), - ("8 Package", self._build_step5_package), - ("9 Saves", self._build_step6_saves), + ("6 Precheck", self._build_step6_precheck), + ("7 Inject", self._build_step4_inject), + ("8 Fix wrap", self._build_step7_relayout), + ("9 Package", self._build_step5_package), + ("10 Saves", self._build_step6_saves), ] for tab_label, builder in _tab_defs: @@ -2375,14 +2376,103 @@ class WolfWorkflowTab(QWidget): return p return None - # ── Step 4: Inject ───────────────────────────────────────────────────────── + # ── Step 6: Precheck ─────────────────────────────────────────────────────── + + def _build_step6_precheck(self, layout: QVBoxLayout): + layout.addWidget(_make_section_label("Step 6 · Inject Precheck")) + layout.addWidget(self._desc( + "Dry-run selected translated JSON before writing binaries. Lists only real " + "safety skips: control-code mismatch, or text not Shift-JIS encodable. " + "Fix those lines here, then continue to Step 7 (Inject)." + )) + + layout.addWidget(self._subheading("Translated files")) + self.precheck_list = QListWidget() + self.precheck_list.setMaximumHeight(200) + self.precheck_list.setStyleSheet( + "QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;" + "color:#cccccc;font-size:12px;padding:2px;}" + "QListWidget::item{padding:3px 4px;}" + "QListWidget::item:selected{background:#264f78;color:#ffffff;}" + ) + layout.addWidget(self.precheck_list) + + list_row = QHBoxLayout() + refresh_btn = self._register(_make_btn("↻ Refresh", "#3a3a3a")) + refresh_btn.clicked.connect(self._refresh_precheck_list) + sel_all_btn = self._register(_make_btn("Select all", "#3a3a3a")) + sel_all_btn.clicked.connect(lambda: self._set_checklist_checks(self.precheck_list, True)) + sel_none_btn = self._register(_make_btn("Select none", "#3a3a3a")) + sel_none_btn.clicked.connect(lambda: self._set_checklist_checks(self.precheck_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) + + precheck_row = QHBoxLayout() + precheck_btn = self._register(_make_btn("Precheck selected", "#007acc")) + precheck_btn.clicked.connect(self._precheck_inject_selected) + precheck_all_btn = self._register(_make_btn("Precheck all", "#007acc")) + precheck_all_btn.clicked.connect(self._precheck_inject_all) + precheck_row.addWidget(precheck_btn) + precheck_row.addWidget(precheck_all_btn) + precheck_row.addStretch() + layout.addLayout(precheck_row) + + self._inject_precheck_label = QLabel("Run precheck to list safety-guard skips.") + self._inject_precheck_label.setWordWrap(True) + self._inject_precheck_label.setStyleSheet("color:#9cdcfe;font-size:12px;") + layout.addWidget(self._inject_precheck_label) + + self._inject_issue_list = QListWidget() + self._inject_issue_list.setMaximumHeight(200) + self._inject_issue_list.setWordWrap(True) + self._inject_issue_list.setTextElideMode(Qt.ElideNone) + self._inject_issue_list.setUniformItemSizes(False) + self._inject_issue_list.setStyleSheet( + "QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;" + "color:#cccccc;font-size:12px;padding:2px;}" + "QListWidget::item{padding:4px;}" + "QListWidget::item:selected{background:#264f78;color:#ffffff;}" + ) + self._inject_issue_list.currentItemChanged.connect(self._on_inject_issue_selected) + layout.addWidget(self._inject_issue_list) + + self._inject_issue_meta = QLabel("") + self._inject_issue_meta.setWordWrap(True) + self._inject_issue_meta.setStyleSheet("color:#808080;font-size:11px;") + layout.addWidget(self._inject_issue_meta) + + self._inject_issue_edit = QTextEdit() + self._inject_issue_edit.setPlaceholderText( + "Select a precheck row to edit its translated text, then Save line." + ) + self._inject_issue_edit.setMaximumHeight(120) + self._inject_issue_edit.setStyleSheet( + "QTextEdit{background-color:#1e1e1e;color:#d4d4d4;border:1px solid #3c3c3c;" + "border-radius:4px;padding:6px;font-size:12px;}" + ) + layout.addWidget(self._inject_issue_edit) + + edit_row = QHBoxLayout() + save_line_btn = self._register(_make_btn("Save line", "#3a3a3a")) + save_line_btn.clicked.connect(self._save_inject_issue_line) + edit_row.addWidget(save_line_btn) + edit_row.addStretch() + layout.addLayout(edit_row) + + self._inject_precheck_issues: list = [] + self._refresh_precheck_list() + + # ── Step 7: Inject ───────────────────────────────────────────────────────── def _build_step4_inject(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 6 · Inject Translations")) + 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. Each file is reported individually — failures are shown in a " - "dialog and in the log." + "binaries. Run Step 6 (Precheck) first if you want to catch safety-guard " + "skips before writing. Each file is reported individually." )) self.en_punct_cb = QCheckBox("Convert Japanese punctuation to ASCII (--en-punct)") @@ -2424,9 +2514,9 @@ class WolfWorkflowTab(QWidget): 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)) + 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_inject_checks(False)) + 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) @@ -2445,14 +2535,14 @@ class WolfWorkflowTab(QWidget): layout.addWidget(_make_hr()) layout.addWidget(self._desc( - "English text is often longer than Japanese. Step 7 (Fix wrapping): paste " + "English text is often longer than Japanese. Step 8 (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.""" - layout.addWidget(_make_section_label("Step 7 · Fix wrapping")) + layout.addWidget(_make_section_label("Step 8 · Fix wrapping")) layout.addWidget(self._desc( "Paste overflowing in-game text to find it in translated JSON. " "Relayout uses cell width + max lines (names.json → wolf names-wrap). " @@ -3071,7 +3161,7 @@ class WolfWorkflowTab(QWidget): else "Line already fits (manual)." ) self._wrap_status_label.setText( - msg + f" Inject {self._current_wrap_path.name} from Step 6." + msg + f" Inject {self._current_wrap_path.name} from Step 7." ) self._log(f"{'✅' if changed else 'ℹ'} {msg}") self._update_wrap_preview() @@ -3112,7 +3202,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 6.") + self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name} from Step 7.") self._log(f"{'✅' if changed else 'ℹ'} {msg}") self._update_wrap_preview() @@ -3279,7 +3369,7 @@ 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 6." + summary + " Re-inject names.json from Step 7." ) self._run_task(task, on_done=_after) @@ -3348,8 +3438,8 @@ class WolfWorkflowTab(QWidget): if not self._require_manifest(): return manifest = self._read_manifest() - en_punct = self.en_punct_cb.isChecked() - allow_drift = self.drift_cb.isChecked() + en_punct = self._inject_flag_en_punct() + allow_drift = self._inject_flag_allow_drift() game_json_dir = self._work_dir() def task(log, progress=None): @@ -3412,51 +3502,209 @@ class WolfWorkflowTab(QWidget): # Index 4 == Database: refresh discovery report and sheet list. elif idx == 4: self._refresh_db_discovery() - # Index 6 == Inject: keep the file list in sync with translated/. + # Index 6 == Precheck: keep the file list in sync with translated/. elif idx == 6: + self._refresh_precheck_list() + # Index 7 == Inject: keep the file list in sync with translated/. + elif idx == 7: self._refresh_inject_list() - def _refresh_inject_list(self): - if not hasattr(self, "inject_list"): - return - self.inject_list.clear() + def _fill_injectable_checklist(self, list_widget: QListWidget): + """Populate a checkable file list from translated/ + manifest.""" + list_widget.clear() if not self._read_manifest(): - item = QListWidgetItem("Run Step 0 (extract) first — no manifest found.") + item = QListWidgetItem("Run Step 0 (extract) first - no manifest found.") item.setFlags(Qt.ItemIsEnabled) - self.inject_list.addItem(item) + list_widget.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) + list_widget.addItem(item) return for json_name in files: item = QListWidgetItem(json_name) item.setData(Qt.UserRole, json_name) item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) item.setCheckState(Qt.Unchecked) - self.inject_list.addItem(item) + list_widget.addItem(item) - def _selected_inject_files(self) -> set[str]: + def _refresh_precheck_list(self): + if not hasattr(self, "precheck_list"): + return + self._fill_injectable_checklist(self.precheck_list) + + def _refresh_inject_list(self): + if not hasattr(self, "inject_list"): + return + self._fill_injectable_checklist(self.inject_list) + + def _selected_checklist_files(self, list_widget: QListWidget) -> set[str]: selected: set[str] = set() - for i in range(self.inject_list.count()): - it = self.inject_list.item(i) + for i in range(list_widget.count()): + it = list_widget.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 + 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(self.inject_list.count()): - it = self.inject_list.item(i) + for i in range(list_widget.count()): + it = list_widget.item(i) if it.flags() & Qt.ItemIsUserCheckable: it.setCheckState(state) + def _precheck_inject_selected(self): + if not self._require_manifest(): + return + selected = self._selected_precheck_files() + if not selected: + QMessageBox.information( + self, + "Nothing selected", + "Tick one or more files, then click Precheck selected.", + ) + return + self._run_inject_precheck(selected) + + def _precheck_inject_all(self): + if not self._require_manifest(): + return + files = self._injectable_filenames() + if not files: + QMessageBox.information( + self, + "Nothing to precheck", + "No injectable files found in translated/.", + ) + return + self._run_inject_precheck(set(files)) + + def _inject_flag_en_punct(self) -> bool: + if hasattr(self, "en_punct_cb"): + return self.en_punct_cb.isChecked() + return self._setting("en_punct", "true") == "true" + + def _inject_flag_allow_drift(self) -> bool: + if hasattr(self, "drift_cb"): + return self.drift_cb.isChecked() + return self._setting("allow_code_drift", "false") == "true" + + def _run_inject_precheck(self, selected: set[str]): + manifest = self._read_manifest() + en_punct = self._inject_flag_en_punct() + allow_drift = self._inject_flag_allow_drift() + state: dict = {} + + def task(log, progress=None): + from util.wolfdawn import inject_precheck as wolf_pre + + 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" + self._ensure_originals(manifest, log, progress, quiet=True) + report = wolf_pre.precheck_selected( + sorted(selected), + manifest_entries=entries, + data_dir=data_dir_path, + originals_dir=originals_dir, + translated_dir=self._tool_root() / "translated", + allow_code_drift=allow_drift, + en_punct=en_punct, + log_fn=log, + ) + state["report"] = report + return True, wolf_pre.format_precheck_summary(report) + + def _after(ok: bool, msg: str): + from util.wolfdawn import inject_precheck as wolf_pre + + report = state.get("report") + if not report: + self._inject_precheck_label.setText(msg or "Precheck failed.") + return + self._populate_inject_precheck(report) + self._inject_precheck_label.setText(wolf_pre.format_precheck_summary(report)) + if report.safety_issues: + QMessageBox.warning( + self, + "Inject precheck", + wolf_pre.format_precheck_summary(report) + + "\n\nSelect a safety row below, fix the text, Save line, then re-run precheck.", + ) + else: + QMessageBox.information(self, "Inject precheck", msg) + + self._run_task(task, on_done=_after) + + def _populate_inject_precheck(self, report): + from util.wolfdawn import inject_precheck as wolf_pre + + self._inject_issue_list.clear() + self._inject_issue_edit.clear() + self._inject_issue_meta.setText("") + issues = wolf_pre.issues_for_ui(report) + self._inject_precheck_issues = issues + for issue in issues: + item = QListWidgetItem(issue.summary()) + item.setData(Qt.UserRole, issue) + item.setForeground(QColor("#f48771")) + self._inject_issue_list.addItem(item) + fm = self._inject_issue_list.fontMetrics() + item.setSizeHint( + QSize( + self._inject_issue_list.viewport().width(), + max(fm.height() * 3 + 10, 48), + ) + ) + + def _on_inject_issue_selected(self, current, _previous): + if current is None: + self._inject_issue_edit.clear() + self._inject_issue_meta.setText("") + return + issue = current.data(Qt.UserRole) + if issue is None: + return + meta_parts = [issue.json_file, issue.locator, issue.message] + self._inject_issue_meta.setText(" · ".join(p for p in meta_parts if p)) + self._inject_issue_edit.setPlainText(issue.text or issue.source or "") + + def _save_inject_issue_line(self): + item = self._inject_issue_list.currentItem() + if item is None: + QMessageBox.information(self, "Save line", "Select a precheck row first.") + return + issue = item.data(Qt.UserRole) + if issue is None: + return + from util.wolfdawn import inject_precheck as wolf_pre + + new_text = self._inject_issue_edit.toPlainText() + ok, err = wolf_pre.apply_issue_text( + self._tool_root() / "translated", issue, new_text + ) + if not ok: + QMessageBox.warning(self, "Save line", err or "Could not save.") + return + item.setText(issue.summary()) + self._log(f"Saved precheck edit: {issue.json_file} · {issue.locator}") + QMessageBox.information( + self, + "Save line", + "Saved. Re-run Precheck to confirm the safety skip is gone.", + ) + def _inject_selected(self): if not self._require_manifest(): return @@ -3506,7 +3754,7 @@ class WolfWorkflowTab(QWidget): # ── Step 5: Package ──────────────────────────────────────────────────────── def _build_step5_package(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 8 · Package the Translated Game")) + layout.addWidget(_make_section_label("Step 9 · Package the Translated Game")) layout.addWidget(self._desc( "Choose how the translated build runs. A loose Data/ folder is simplest for " "playtesting; repacking rebuilds a single Data.wolf archive for distribution." @@ -3581,10 +3829,10 @@ class WolfWorkflowTab(QWidget): self._run_task(task) - # ── Step 6: Saves ────────────────────────────────────────────────────────── + # ── Step 10: Saves ───────────────────────────────────────────────────────── def _build_step6_saves(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 9 · Update Existing Saves (optional)")) + layout.addWidget(_make_section_label("Step 10 · Update Existing Saves (optional)")) layout.addWidget(self._desc( "WOLF .sav files bake in the game title and some strings at save time. This rewrites " "them so old Japanese saves load cleanly in the translated build. It is only needed " diff --git a/tests/test_wolf_inject_counts.py b/tests/test_wolf_inject_counts.py index 4a0bd7c..83dcd3e 100644 --- a/tests/test_wolf_inject_counts.py +++ b/tests/test_wolf_inject_counts.py @@ -45,11 +45,14 @@ class WolfInjectCountsTests(unittest.TestCase): "event 374 cmd 233 str 0: control-code mismatch - source has [\"\\\\^\"], " "translation has [\"\\\\ \"]\n" "applied 24547 translation(s) (37 untranslated, 0 drifted); wrote CommonEvent.dat\n" + "WARNING: 1 line(s) left UNTRANSLATED by a safety guard - " + "1 control-code mismatch, 0 not encodable\n" ) applied, drifted = wolfdawn.parse_strings_inject_counts("", stderr) self.assertEqual(applied, 24547) self.assertEqual(drifted, 0) self.assertEqual(wolfdawn.parse_strings_inject_untranslated("", stderr), 37) + self.assertEqual(wolfdawn.parse_strings_inject_safety_count("", stderr), 1) mismatches = wolfdawn.parse_strings_inject_mismatches("", stderr) self.assertEqual(len(mismatches), 1) self.assertIn("control-code mismatch", mismatches[0]) diff --git a/tests/test_wolf_inject_precheck.py b/tests/test_wolf_inject_precheck.py new file mode 100644 index 0000000..2b6c2a5 --- /dev/null +++ b/tests/test_wolf_inject_precheck.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Unit tests for inject precheck and safety-guard parsers.""" + +from __future__ import annotations + +import json +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 import wolfdawn # noqa: E402 +from util.wolfdawn import inject as wi # noqa: E402 +from util.wolfdawn import inject_precheck as pre # noqa: E402 + + +class InjectSafetyParserTests(unittest.TestCase): + def test_identity_count_is_not_safety(self): + stderr = ( + "db type 49 row 18 field 21: control-code mismatch - source has " + '["\\\\c[19]"], translation has []; edit the words\n' + "would apply 2178 translation(s) (4 untranslated, 0 drifted); " + "dry run - did NOT write out.project\n" + "WARNING: 1 line(s) left UNTRANSLATED by a safety guard - " + "1 control-code mismatch, 0 not encodable\n" + ) + self.assertEqual(wolfdawn.parse_strings_inject_counts("", stderr), (2178, 0)) + self.assertEqual(wolfdawn.parse_strings_inject_untranslated("", stderr), 4) + self.assertEqual(wolfdawn.parse_strings_inject_safety_count("", stderr), 1) + lines = wolfdawn.parse_strings_inject_safety_lines("", stderr) + self.assertEqual(len(lines), 1) + self.assertEqual(lines[0][1], "code_mismatch") + + def test_interpret_ignores_identity_in_summary(self): + res = mock.Mock( + ok=True, + returncode=0, + stdout="", + stderr=( + "applied 2178 translation(s) (4 untranslated, 0 drifted); " + "wrote DataBase.project\n" + ), + ) + result = wi._interpret_strings_result("DataBase.project.json", res) + self.assertTrue(result.success) + self.assertEqual(result.summary, "applied 2178 line(s)") + self.assertNotIn("unchanged", result.summary) + self.assertNotIn("safety", result.summary) + + def test_interpret_labels_real_safety_warning(self): + res = mock.Mock( + ok=True, + returncode=0, + stdout="", + stderr=( + "db type 1 row 0 field 0: control-code mismatch - source has " + '["\\\\^"], translation has []\n' + "applied 10 translation(s) (0 untranslated, 0 drifted); wrote x\n" + "WARNING: 1 line(s) left UNTRANSLATED by a safety guard - " + "1 control-code mismatch, 0 not encodable\n" + ), + ) + result = wi._interpret_strings_result("x.json", res) + self.assertTrue(result.success) + self.assertIn("skipped by safety guard", result.summary) + + +class InjectPrecheckLocalTests(unittest.TestCase): + def test_resolve_db_locator(self): + doc = { + "kind": "db", + "groups": [ + { + "type": 23, + "typeName": "Sheet", + "lines": [ + { + "row": 2, + "field": 65, + "fieldName": "comment", + "source": "txtblank", + "text": "txtblank", + }, + ], + } + ], + } + line, hit = pre.resolve_locator_line(doc, "db type 23 row 2 field 65") + self.assertIsNotNone(line) + self.assertEqual(line["text"], "txtblank") + self.assertEqual(hit["sheet_name"], "Sheet") + + def test_apply_issue_text_updates_json(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "DataBase.project.json" + doc = { + "kind": "db", + "groups": [ + { + "type": 1, + "typeName": "T", + "lines": [ + { + "row": 0, + "field": 2, + "fieldName": "f", + "source": "あ\\^", + "text": "A", + } + ], + } + ], + } + path.write_text(json.dumps(doc), encoding="utf-8") + issue = pre.InjectIssue( + json_file="DataBase.project.json", + kind="code_mismatch", + locator="db type 1 row 0 field 2", + message="mismatch", + source="あ\\^", + text="A", + ) + ok, err = pre.apply_issue_text(Path(tmp), issue, "A\\^") + self.assertTrue(ok, err) + saved = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual(saved["groups"][0]["lines"][0]["text"], "A\\^") + + +if __name__ == "__main__": + unittest.main() diff --git a/util/wolfdawn/__init__.py b/util/wolfdawn/__init__.py index 150041f..7bede95 100644 --- a/util/wolfdawn/__init__.py +++ b/util/wolfdawn/__init__.py @@ -44,6 +44,8 @@ __all__ = [ "parse_strings_inject_counts", "parse_strings_inject_untranslated", "parse_strings_inject_mismatches", + "parse_strings_inject_safety_count", + "parse_strings_inject_safety_lines", "parse_names_inject_counts", "inject_had_applied", "pack", @@ -56,20 +58,25 @@ __all__ = [ "desc_relayout", ] -# strings-inject: "applied N translation(s) (M drifted)"; names-inject uses "name change(s)". +# strings-inject: "applied N" / "would apply N"; names-inject uses "name change(s)". +# WolfDawn counts identity lines (text == source) as "untranslated" - not safety skips. _INJECT_COUNTS_RE = re.compile( - r"applied\s+(\d+)\s+translation.*?(\d+)\s+drifted", re.IGNORECASE | re.DOTALL + r"(?:would apply|applied)\s+(\d+)\s+translation.*?(\d+)\s+drifted", + re.IGNORECASE | re.DOTALL, ) _INJECT_UNTRANSLATED_RE = re.compile( - r"applied\s+\d+\s+translation.*?\((\d+)\s+untranslated", re.IGNORECASE | re.DOTALL + r"(?:would apply|applied)\s+\d+\s+translation.*?\((\d+)\s+untranslated", + re.IGNORECASE | re.DOTALL, ) -_INJECT_MISMATCH_RE = re.compile( - r"^(?:event\s+\d+\s+)?cmd\s+(\d+)\s+str\s+(\d+):\s+control-code mismatch", - re.IGNORECASE | re.MULTILINE, +_INJECT_SAFETY_COUNT_RE = re.compile( + r"WARNING:\s*(\d+)\s+line\(s\)\s+left\s+UNTRANSLATED\s+by\s+a\s+safety\s+guard", + re.IGNORECASE, ) -_INJECT_MISMATCH_EVENT_RE = re.compile( - r"^event\s+(\d+)\s+cmd\s+(\d+)\s+str\s+(\d+):\s+control-code mismatch", - re.IGNORECASE | re.MULTILINE, +_INJECT_SAFETY_LINE_RE = re.compile( + r"^(?P.+?):\s+" + r"(?Pcontrol-code mismatch|text not representable in Shift-JIS)\b" + r"(?P.*)$", + re.IGNORECASE, ) _NAMES_INJECT_COUNTS_RE = re.compile( r"(?:would apply|applied)\s+(\d+)\s+name change.*?(\d+)\s+(?:drifted|unmatched)", @@ -95,7 +102,7 @@ def _inject_output_text(stdout: str, stderr: str) -> str: def parse_strings_inject_counts(stdout: str, stderr: str = "") -> tuple[int | None, int | None]: - """Return (applied, drifted) from wolf strings-inject output, or (None, None).""" + """Return (applied / would-apply, drifted) from wolf strings-inject output.""" text = _inject_output_text(stdout, stderr) if not text: return None, None @@ -106,23 +113,54 @@ def parse_strings_inject_counts(stdout: str, stderr: str = "") -> tuple[int | No def parse_strings_inject_untranslated(stdout: str, stderr: str = "") -> int | None: - """Return the untranslated line count from strings-inject summary, if present.""" + """Return identity-line count (``text == source``), not safety-guard skips.""" text = _inject_output_text(stdout, stderr) m = _INJECT_UNTRANSLATED_RE.search(text) return int(m.group(1)) if m else None -def parse_strings_inject_mismatches(stdout: str, stderr: str = "") -> list[str]: - """Return human-readable control-code mismatch locations from inject output.""" +def parse_strings_inject_safety_count(stdout: str, stderr: str = "") -> int | None: + """Return WARNING safety-guard skip count, if WolfDawn printed one.""" text = _inject_output_text(stdout, stderr) - out: list[str] = [] - for line in text.splitlines(): - line = line.strip() - if "control-code mismatch" in line: - out.append(line) + m = _INJECT_SAFETY_COUNT_RE.search(text) + return int(m.group(1)) if m else None + + +def parse_strings_inject_safety_lines( + stdout: str, stderr: str = "" +) -> list[tuple[str, str, str]]: + """Return ``(locator, kind, full_line)`` for each per-line safety diagnostic. + + *kind* is ``code_mismatch`` or ``unrepresentable``. + """ + text = _inject_output_text(stdout, stderr) + out: list[tuple[str, str, str]] = [] + for raw in text.splitlines(): + line = raw.strip() + if not line: + continue + m = _INJECT_SAFETY_LINE_RE.match(line) + if not m: + continue + kind_raw = m.group("kind").lower() + kind = ( + "unrepresentable" + if "representable" in kind_raw + else "code_mismatch" + ) + out.append((m.group("locator").strip(), kind, line)) return out +def parse_strings_inject_mismatches(stdout: str, stderr: str = "") -> list[str]: + """Return human-readable control-code mismatch lines from inject output.""" + return [ + full + for _loc, kind, full in parse_strings_inject_safety_lines(stdout, stderr) + if kind == "code_mismatch" + ] + + def parse_names_inject_counts(stdout: str, stderr: str = "") -> tuple[int | None, int | None]: """Return (applied, drifted) from wolf names-inject output, or (None, None).""" text = _inject_output_text(stdout, stderr) @@ -619,6 +657,7 @@ def strings_inject( output: PathLike, allow_code_drift: bool = False, en_punct: bool = False, + dry_run: bool = False, log_fn=None, ) -> WolfResult: """``wolf strings-inject --base -o [flags]``.""" @@ -627,6 +666,8 @@ def strings_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 index b976ade..d15366b 100644 --- a/util/wolfdawn/inject.py +++ b/util/wolfdawn/inject.py @@ -127,22 +127,24 @@ def _wolf_output_snippet(stdout: str, stderr: str, *, limit: int = 400) -> str: 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) + safety = wolfdawn.parse_strings_inject_safety_count(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) - + # Exit 2 with a positive applied count still wrote the good lines; treat as success + # and surface safety skips in the summary (see inject_had_applied). 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)" + 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 (drifted or 0) > 0: return FileInjectResult( json_name, @@ -152,11 +154,11 @@ def _interpret_strings_result(json_name: str, res: wolfdawn.WolfResult) -> FileI detail=detail, ) - if untranslated or mismatches: + if safety or mismatches: parts = [] - if untranslated: - parts.append(f"{untranslated} line(s) skipped by safety guard") - if mismatches: + if safety: + parts.append(f"{safety} line(s) skipped by safety guard") + elif mismatches: parts.append(f"{len(mismatches)} control-code mismatch(es)") return FileInjectResult( json_name, False, "; ".join(parts), applied=0, detail=detail diff --git a/util/wolfdawn/inject_precheck.py b/util/wolfdawn/inject_precheck.py new file mode 100644 index 0000000..d0abefd --- /dev/null +++ b/util/wolfdawn/inject_precheck.py @@ -0,0 +1,384 @@ +"""Pre-inject review: list WolfDawn safety-guard skips before writing binaries. + +WolfDawn ``strings-inject`` outcomes: + +* ``applied`` - translation written +* ``untranslated`` - ``text == source`` (left alone; not a problem, not listed here) +* ``drifted`` - base no longer holds ``source`` (stale Data/; file-level, not listed here) +* ``control-code mismatch`` / ``text not representable`` - safety skips (listed) + +This module dry-runs selected files and resolves safety locators back into editable +JSON lines so Step 6 can show a review list before inject. +""" + +from __future__ import annotations + +import json +import re +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable + +from util.wolfdawn import codes as wolf_codes +from util.wolfdawn import db_dat_sibling +from util import wolfdawn +from util.wolfdawn.inject import NAMES_JSON, orig_base_for, repair_inject_json + +_DB_LOC_RE = re.compile( + r"^db\s+type\s+(?P\d+)\s+row\s+(?P\d+)\s+field\s+(?P\d+)\s*$", + re.IGNORECASE, +) +_EVENT_LOC_RE = re.compile( + r"^event\s+(?P\d+)" + r"(?:\s+page\s+(?P\d+))?" + r"\s+cmd\s+(?P\d+)\s+str\s+(?P\d+)\s*$", + re.IGNORECASE, +) +_GAMEDAT_LOC_RE = re.compile(r"^gamedat\s+(?P.+)\s*$", re.IGNORECASE) + + +@dataclass +class InjectIssue: + """One line skipped by a WolfDawn safety guard.""" + + json_file: str + kind: str # code_mismatch | unrepresentable + locator: str + message: str + source: str = "" + text: str = "" + hit_id: dict[str, Any] = field(default_factory=dict) + + def summary(self) -> str: + label = { + "code_mismatch": "control-code mismatch", + "unrepresentable": "not Shift-JIS encodable", + }.get(self.kind, self.kind) + preview = (self.text or self.source).replace("\n", " ").strip() + if len(preview) > 90: + preview = preview[:90] + "…" + loc = self.locator or self.json_file + if preview: + return f"{label}\n{self.json_file} · {loc}\n{preview}" + return f"{label}\n{self.json_file} · {loc}" + + +@dataclass +class FilePrecheck: + json_name: str + would_apply: int | None = None + drifted: int | None = None + safety_count: int | None = None + issues: list[InjectIssue] = field(default_factory=list) + error: str = "" + detail: str = "" + + +@dataclass +class PrecheckReport: + files: list[FilePrecheck] = field(default_factory=list) + + @property + def issues(self) -> list[InjectIssue]: + out: list[InjectIssue] = [] + for f in self.files: + out.extend(f.issues) + return out + + @property + def safety_issues(self) -> list[InjectIssue]: + return list(self.issues) + + @property + def ok(self) -> bool: + return all(not f.error for f in self.files) + + +def _load_doc(path: Path) -> dict[str, Any] | None: + try: + data = json.loads(path.read_text(encoding="utf-8-sig")) + except Exception: + return None + return data if isinstance(data, dict) else None + + +def resolve_locator_line( + doc: dict[str, Any], locator: str +) -> tuple[dict[str, Any] | None, dict[str, Any]]: + """Map a WolfDawn inject locator to the JSON line dict and a hit_id.""" + kind = str(doc.get("kind") or "") + m = _DB_LOC_RE.match(locator.strip()) + if m and kind == "db": + type_id, row, field = int(m.group("type")), int(m.group("row")), int(m.group("field")) + for g in doc.get("groups") or []: + if not isinstance(g, dict) or g.get("type") != type_id: + continue + type_name = str(g.get("typeName") or type_id) + for ln in g.get("lines") or []: + if not isinstance(ln, dict): + continue + if ln.get("row") == row and ln.get("field") == field: + return ln, { + "json_file": "", + "kind": "db", + "sheet_name": type_name, + "row": row, + "field_name": str(ln.get("fieldName") or ""), + "type": type_id, + "field": field, + } + return None, { + "kind": "db", + "type": type_id, + "row": row, + "field": field, + "sheet_name": str(type_id), + } + + m = _EVENT_LOC_RE.match(locator.strip()) + if m and kind in ("map", "common"): + event = int(m.group("event")) + page = m.group("page") + page_i = int(page) if page is not None else None + cmd, si_arg = int(m.group("cmd")), int(m.group("str")) + for si, sc in enumerate(doc.get("scenes") or []): + if not isinstance(sc, dict) or sc.get("event") != event: + continue + if page_i is not None and sc.get("page") != page_i: + continue + for li, ln in enumerate(sc.get("lines") or []): + if not isinstance(ln, dict): + continue + if ln.get("cmd") == cmd and ln.get("str") == si_arg: + return ln, { + "json_file": "", + "kind": kind, + "sheet_name": "", + "scene_index": si, + "line_index": li, + "event": event, + "page": page_i, + "cmd": cmd, + "str": si_arg, + } + return None, { + "kind": kind, + "event": event, + "page": page_i, + "cmd": cmd, + "str": si_arg, + } + + m = _GAMEDAT_LOC_RE.match(locator.strip()) + if m and kind == "gamedat": + key = m.group("key").strip() + for ln in doc.get("lines") or []: + if isinstance(ln, dict) and str(ln.get("key") or "") == key: + return ln, { + "json_file": "", + "kind": "gamedat", + "sheet_name": "Game.dat", + "field_name": key, + "key": key, + } + return None, {"kind": "gamedat", "key": key, "sheet_name": "Game.dat"} + + return None, {} + + +def apply_issue_text( + translated_dir: Path, + issue: InjectIssue, + new_text: str, +) -> tuple[bool, str]: + """Write *new_text* onto the JSON line for *issue*. Returns (ok, error).""" + path = Path(translated_dir) / issue.json_file + if not path.is_file(): + return False, f"{issue.json_file} not found" + doc = _load_doc(path) + if not doc: + return False, f"could not read {issue.json_file}" + + line: dict[str, Any] | None = None + if issue.locator: + line, _ = resolve_locator_line(doc, issue.locator) + if line is None and issue.hit_id.get("kind") == "db": + line, _ = resolve_locator_line( + doc, + f"db type {issue.hit_id.get('type')} row {issue.hit_id.get('row')} " + f"field {issue.hit_id.get('field')}", + ) + + if line is None: + return False, f"could not locate {issue.locator or issue.json_file}" + + line["text"] = new_text + src = line.get("source") + if isinstance(src, str) and isinstance(new_text, str): + repaired = wolf_codes.rebuild_text_preserving_source_codes(src, new_text) + if repaired != new_text: + line["text"] = repaired + path.write_text( + json.dumps(doc, ensure_ascii=False, indent=4) + "\n", + encoding="utf-8", + ) + issue.text = str(line.get("text") or "") + return True, "" + + +def _precheck_strings_file( + json_name: str, + entry: dict, + inject_src: Path, + data_dir: Path, + originals_dir: Path, + *, + allow_code_drift: bool, + en_punct: bool, +) -> FilePrecheck: + result = FilePrecheck(json_name=json_name) + orig = orig_base_for(entry, data_dir, originals_dir) + if not orig.exists(): + result.error = f"no pristine original at {orig} (re-run Step 0 extract)" + return result + if entry.get("kind") == "db" and not db_dat_sibling(orig).is_file(): + result.error = "missing database .dat pair in originals (re-run Step 0 extract)" + return result + + doc = _load_doc(inject_src) + + with tempfile.TemporaryDirectory(prefix="wolf-precheck-") as tmp: + out = Path(tmp) / Path(entry["base"]).name + res = wolfdawn.strings_inject( + str(inject_src), + str(orig), + str(out), + allow_code_drift=allow_code_drift, + en_punct=en_punct, + dry_run=True, + log_fn=None, + ) + result.detail = ((res.stdout or "") + "\n" + (res.stderr or "")).strip() + applied, drifted = wolfdawn.parse_strings_inject_counts(res.stdout, res.stderr) + result.would_apply = applied + result.drifted = drifted + result.safety_count = wolfdawn.parse_strings_inject_safety_count(res.stdout, res.stderr) + + cli_err = wolfdawn.parse_inject_cli_error(res.stdout, res.stderr) + if cli_err and applied is None and not result.issues: + result.error = cli_err + return result + + if doc: + for locator, kind, full in wolfdawn.parse_strings_inject_safety_lines( + res.stdout, res.stderr + ): + line, hit_id = resolve_locator_line(doc, locator) + hit_id = dict(hit_id) + hit_id["json_file"] = json_name + if not hit_id.get("sheet_name"): + hit_id["sheet_name"] = json_name + src = str(line.get("source") or "") if isinstance(line, dict) else "" + txt = str(line.get("text") or "") if isinstance(line, dict) else "" + result.issues.append( + InjectIssue( + json_file=json_name, + kind=kind, + locator=locator, + message=full, + source=src, + text=txt, + hit_id=hit_id, + ) + ) + return result + + +def precheck_selected( + selected: list[str], + *, + manifest_entries: list[dict], + data_dir: Path, + originals_dir: Path, + translated_dir: Path, + allow_code_drift: bool = False, + en_punct: bool = False, + log_fn: Callable[[str], None] | None = None, +) -> PrecheckReport: + """Dry-run inject for *selected* files and collect safety-guard issues.""" + emit = log_fn or (lambda _msg: None) + report = PrecheckReport() + 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())) + + for json_name in ordered: + if json_name == NAMES_JSON: + # names-inject does not emit the same per-line safety locators. + report.files.append(FilePrecheck(json_name=NAMES_JSON)) + emit(f"Precheck {NAMES_JSON}: skipped (use names-inject dry-run in log if needed)") + continue + + entry = by_json.get(json_name) + if not entry: + report.files.append( + FilePrecheck( + json_name, + error="not listed in manifest (re-run Step 0 extract)", + ) + ) + continue + src = translated_dir / json_name + if not src.is_file(): + report.files.append( + FilePrecheck(json_name, error=f"not found in {translated_dir.name}/") + ) + continue + + emit(f"Precheck {json_name}…") + inject_src = repair_inject_json(src) + fp = _precheck_strings_file( + json_name, + entry, + inject_src, + data_dir, + originals_dir, + allow_code_drift=allow_code_drift, + en_punct=en_punct, + ) + if fp.error: + emit(f" ✗ {json_name}: {fp.error}") + else: + parts = [] + if fp.would_apply is not None: + parts.append(f"would apply {fp.would_apply}") + if fp.issues: + parts.append(f"{len(fp.issues)} safety skip(s)") + elif fp.safety_count: + parts.append(f"{fp.safety_count} safety skip(s)") + else: + parts.append("no safety skips") + if fp.drifted: + parts.append(f"{fp.drifted} drifted") + emit(f" · {json_name}: " + ", ".join(parts)) + report.files.append(fp) + return report + + +def format_precheck_summary(report: PrecheckReport) -> str: + """One-line status for the Step 6 precheck label.""" + safety = len(report.safety_issues) + errors = sum(1 for f in report.files if f.error) + parts = [] + if safety: + parts.append(f"{safety} safety skip(s)") + if errors: + parts.append(f"{errors} file error(s)") + if not parts: + return "Precheck clean - no safety-guard skips." + return "Precheck: " + ", ".join(parts) + + +def issues_for_ui(report: PrecheckReport) -> list[InjectIssue]: + """Safety-guard issues only.""" + return list(report.safety_issues)