Stabilize injects
This commit is contained in:
parent
6badfcb246
commit
3de5368817
7 changed files with 825 additions and 373 deletions
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
127
tests/test_wolf_inject_batch.py
Normal file
127
tests/test_wolf_inject_batch.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Unit tests for util/wolfdawn/inject.py orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
os.chdir(ROOT)
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from util.wolfdawn import inject as wi # noqa: E402
|
||||
|
||||
|
||||
class ListInjectableTests(unittest.TestCase):
|
||||
def test_lists_translated_files_with_manifest_entries(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
translated = root / "translated"
|
||||
translated.mkdir()
|
||||
(translated / "names.json").write_text("{}", encoding="utf-8")
|
||||
(translated / "Map001.mps.json").write_text("{}", encoding="utf-8")
|
||||
(translated / "orphan.json").write_text("{}", encoding="utf-8")
|
||||
entries = [
|
||||
{"json": "names.json", "kind": "names", "base": "/data"},
|
||||
{"json": "Map001.mps.json", "kind": "map", "base": "/data/Map001.mps"},
|
||||
]
|
||||
self.assertEqual(
|
||||
wi.list_injectable(translated, entries),
|
||||
["Map001.mps.json", "names.json"],
|
||||
)
|
||||
|
||||
|
||||
class InjectOrderTests(unittest.TestCase):
|
||||
def test_names_runs_before_strings(self):
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_names(*_a, **_k):
|
||||
calls.append("names")
|
||||
return mock.Mock(
|
||||
ok=True,
|
||||
returncode=0,
|
||||
stdout="applied 2 name change(s) (0 drifted/unmatched)",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
def fake_strings(*_a, **_k):
|
||||
calls.append("strings")
|
||||
return mock.Mock(
|
||||
ok=True,
|
||||
returncode=0,
|
||||
stdout="applied 1 translation(s) (0 drifted)",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
translated = root / "translated"
|
||||
originals = root / "originals"
|
||||
data = root / "data"
|
||||
translated.mkdir()
|
||||
originals.mkdir()
|
||||
data.mkdir()
|
||||
(translated / "names.json").write_text("{}", encoding="utf-8")
|
||||
(translated / "Map001.mps.json").write_text(
|
||||
'{"kind":"map","scenes":[]}', encoding="utf-8"
|
||||
)
|
||||
map_live = data / "Map001.mps"
|
||||
map_orig = originals / "Map001.mps"
|
||||
map_live.write_bytes(b"live")
|
||||
map_orig.write_bytes(b"orig")
|
||||
|
||||
entries = [
|
||||
{"json": "names.json", "kind": "names", "base": str(data)},
|
||||
{"json": "Map001.mps.json", "kind": "map", "base": str(map_live)},
|
||||
]
|
||||
|
||||
def would_apply_counts():
|
||||
yield 1 # prepare: after restore
|
||||
yield 1 # _inject_names: preflight
|
||||
yield 0 # _inject_names: post-check
|
||||
|
||||
counts = would_apply_counts()
|
||||
|
||||
def next_would_apply(*_a, **_k):
|
||||
return next(counts, 0)
|
||||
|
||||
with mock.patch.object(wi.wolf_originals, "names_inject_would_apply", side_effect=next_would_apply), \
|
||||
mock.patch.object(wi.wolfdawn, "names_inject", side_effect=fake_names), \
|
||||
mock.patch.object(wi.wolfdawn, "strings_inject", side_effect=fake_strings):
|
||||
report = wi.inject_selected(
|
||||
["Map001.mps.json", wi.NAMES_JSON],
|
||||
manifest_entries=entries,
|
||||
data_dir=data,
|
||||
originals_dir=originals,
|
||||
translated_dir=translated,
|
||||
game_root=data.parent,
|
||||
)
|
||||
|
||||
self.assertEqual(calls, ["names", "strings"])
|
||||
self.assertTrue(report.ok)
|
||||
self.assertEqual(len(report.succeeded), 2)
|
||||
|
||||
|
||||
class ReportDialogTests(unittest.TestCase):
|
||||
def test_failure_dialog_lists_every_problem(self):
|
||||
report = wi.InjectReport(
|
||||
files=[
|
||||
wi.FileInjectResult("a.json", True, "applied 3 line(s)"),
|
||||
wi.FileInjectResult("b.json", False, "0 applied, 2 drifted", detail="stderr"),
|
||||
],
|
||||
sync_failures=[("c.json", "permission denied")],
|
||||
)
|
||||
title, body = wi.format_report_dialog(report)
|
||||
self.assertIn("errors", title)
|
||||
self.assertIn("✗ b.json", body)
|
||||
self.assertIn("✗ sync c.json", body)
|
||||
self.assertIn("✓ a.json", body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -69,7 +69,8 @@ _INJECT_MISMATCH_EVENT_RE = re.compile(
|
|||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_NAMES_INJECT_COUNTS_RE = re.compile(
|
||||
r"applied\s+(\d+)\s+name change.*?(\d+)\s+drifted", re.IGNORECASE | re.DOTALL
|
||||
r"(?:would apply|applied)\s+(\d+)\s+name change.*?(\d+)\s+(?:drifted|unmatched)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
ProgressFn = Callable[[int, int, str], None]
|
||||
|
|
@ -632,6 +633,7 @@ def names_inject(
|
|||
out_dir: Optional[PathLike] = None,
|
||||
allow_code_drift: bool = False,
|
||||
en_punct: bool = False,
|
||||
dry_run: bool = False,
|
||||
log_fn=None,
|
||||
) -> WolfResult:
|
||||
"""``wolf names-inject <names.json> --data <data-dir> [-o <out-dir>] [flags]``."""
|
||||
|
|
@ -642,6 +644,8 @@ def names_inject(
|
|||
args.append("--allow-code-drift")
|
||||
if en_punct:
|
||||
args.append("--en-punct")
|
||||
if dry_run:
|
||||
args.append("--dry-run")
|
||||
return _run(args, log_fn=log_fn)
|
||||
|
||||
|
||||
|
|
|
|||
471
util/wolfdawn/inject.py
Normal file
471
util/wolfdawn/inject.py
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
"""WolfDawn inject orchestration for translated JSON files.
|
||||
|
||||
One list of files in ``translated/``, user picks some or all, each file gets a
|
||||
clear pass/fail result. ``names.json`` uses ``names-inject`` (must run before
|
||||
per-file ``strings-inject`` when both are selected).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from util.wolfdawn import codes as wolf_codes
|
||||
from util.wolfdawn import db_dat_sibling
|
||||
from util.wolfdawn import originals as wolf_originals
|
||||
from util import wolfdawn
|
||||
|
||||
NAMES_JSON = "names.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileInjectResult:
|
||||
json_name: str
|
||||
success: bool
|
||||
summary: str
|
||||
applied: int | None = None
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class InjectReport:
|
||||
files: list[FileInjectResult] = field(default_factory=list)
|
||||
sync_failures: list[tuple[str, str]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return all(r.success for r in self.files) and not self.sync_failures
|
||||
|
||||
@property
|
||||
def succeeded(self) -> list[FileInjectResult]:
|
||||
return [r for r in self.files if r.success]
|
||||
|
||||
@property
|
||||
def failed(self) -> list[FileInjectResult]:
|
||||
return [r for r in self.files if not r.success]
|
||||
|
||||
|
||||
def list_injectable(translated_dir: Path, manifest_entries: list[dict]) -> list[str]:
|
||||
"""Return sorted JSON filenames in *translated_dir* that the manifest can inject."""
|
||||
manifest_json = {e["json"] for e in manifest_entries if e.get("json")}
|
||||
if not translated_dir.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
p.name
|
||||
for p in translated_dir.glob("*.json")
|
||||
if p.is_file() and p.name in manifest_json
|
||||
)
|
||||
|
||||
|
||||
def orig_base_for(entry: dict, data_dir: Path, originals_dir: Path) -> Path:
|
||||
"""Pristine snapshot path for one manifest entry."""
|
||||
base = Path(entry["base"])
|
||||
try:
|
||||
rel = base.relative_to(data_dir)
|
||||
except ValueError:
|
||||
rel = Path(base.name)
|
||||
return originals_dir / rel
|
||||
|
||||
|
||||
def restore_live_from_originals(
|
||||
entries: list[dict],
|
||||
data_dir: Path,
|
||||
originals_dir: Path,
|
||||
) -> list[str]:
|
||||
"""Copy pristine originals onto live Data/. Return warning messages."""
|
||||
warnings: list[str] = []
|
||||
for entry in entries:
|
||||
if entry.get("kind") == "names":
|
||||
continue
|
||||
orig = orig_base_for(entry, data_dir, originals_dir)
|
||||
live = Path(entry["base"])
|
||||
if not orig.exists():
|
||||
warnings.append(f"{entry['json']}: no pristine original at {orig.name}")
|
||||
continue
|
||||
try:
|
||||
if orig.is_dir():
|
||||
if live.exists():
|
||||
shutil.rmtree(live)
|
||||
shutil.copytree(orig, live)
|
||||
else:
|
||||
live.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(orig, live)
|
||||
if entry.get("kind") == "db":
|
||||
dat_orig = db_dat_sibling(orig)
|
||||
dat_live = db_dat_sibling(live)
|
||||
if dat_orig.is_file():
|
||||
shutil.copy2(dat_orig, dat_live)
|
||||
except Exception as exc:
|
||||
warnings.append(f"{entry['json']}: could not restore live binary ({exc})")
|
||||
return warnings
|
||||
|
||||
|
||||
def repair_inject_json(src: Path) -> Path:
|
||||
"""Auto-repair WOLF inline codes in a translated JSON before inject."""
|
||||
data = json.loads(src.read_text(encoding="utf-8-sig"))
|
||||
data, repairs = wolf_codes.repair_document(data)
|
||||
if repairs:
|
||||
src.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=4) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return src
|
||||
|
||||
|
||||
def _wolf_output_snippet(stdout: str, stderr: str, *, limit: int = 400) -> str:
|
||||
text = (stdout or "").strip()
|
||||
if stderr and stderr.strip():
|
||||
text = f"{text}\n{stderr.strip()}".strip() if text else stderr.strip()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 3] + "..."
|
||||
|
||||
|
||||
def _interpret_strings_result(json_name: str, res: wolfdawn.WolfResult) -> FileInjectResult:
|
||||
cli_err = wolfdawn.parse_inject_cli_error(res.stdout, res.stderr)
|
||||
applied, drifted = wolfdawn.parse_strings_inject_counts(res.stdout, res.stderr)
|
||||
untranslated = wolfdawn.parse_strings_inject_untranslated(res.stdout, res.stderr)
|
||||
mismatches = wolfdawn.parse_strings_inject_mismatches(res.stdout, res.stderr)
|
||||
detail = _wolf_output_snippet(res.stdout, res.stderr)
|
||||
|
||||
if not res.ok:
|
||||
reason = cli_err or f"wolf exited {res.returncode}"
|
||||
return FileInjectResult(json_name, False, reason, applied=applied, detail=detail)
|
||||
|
||||
if wolfdawn.inject_had_applied(applied):
|
||||
msg = f"applied {applied} line(s)"
|
||||
if drifted:
|
||||
msg += f" ({drifted} drifted)"
|
||||
if untranslated:
|
||||
msg += f" ({untranslated} skipped by safety guard)"
|
||||
return FileInjectResult(json_name, True, msg, applied=applied, detail=detail)
|
||||
|
||||
if (drifted or 0) > 0:
|
||||
return FileInjectResult(
|
||||
json_name,
|
||||
False,
|
||||
f"0 applied, {drifted} drifted (live Data/ no longer matches Japanese sources)",
|
||||
applied=0,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
if untranslated or mismatches:
|
||||
parts = []
|
||||
if untranslated:
|
||||
parts.append(f"{untranslated} line(s) skipped by safety guard")
|
||||
if mismatches:
|
||||
parts.append(f"{len(mismatches)} control-code mismatch(es)")
|
||||
return FileInjectResult(
|
||||
json_name, False, "; ".join(parts), applied=0, detail=detail
|
||||
)
|
||||
|
||||
return FileInjectResult(json_name, True, "no changes needed", applied=0, detail=detail)
|
||||
|
||||
|
||||
def _interpret_names_result(json_name: str, res: wolfdawn.WolfResult) -> FileInjectResult:
|
||||
cli_err = wolfdawn.parse_inject_cli_error(res.stdout, res.stderr)
|
||||
applied, drifted = wolfdawn.parse_names_inject_counts(res.stdout, res.stderr)
|
||||
detail = _wolf_output_snippet(res.stdout, res.stderr)
|
||||
|
||||
if not res.ok:
|
||||
reason = cli_err or f"wolf exited {res.returncode}"
|
||||
return FileInjectResult(json_name, False, reason, applied=applied, detail=detail)
|
||||
|
||||
if wolfdawn.inject_had_applied(applied):
|
||||
msg = f"applied {applied} name change(s)"
|
||||
if drifted:
|
||||
msg += f" ({drifted} unmatched)"
|
||||
return FileInjectResult(json_name, True, msg, applied=applied, detail=detail)
|
||||
|
||||
if res.ok and (drifted or 0) > 0:
|
||||
return FileInjectResult(
|
||||
json_name,
|
||||
False,
|
||||
(
|
||||
f"0 applied, {drifted} unmatched "
|
||||
"(re-run Step 0 extract on the untranslated game)"
|
||||
),
|
||||
applied=0,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
return FileInjectResult(
|
||||
json_name,
|
||||
False,
|
||||
_STALE_NAMES_MSG,
|
||||
applied=0,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
_STALE_NAMES_MSG = (
|
||||
"0 name changes would apply. wolf_json/originals/ still holds old English "
|
||||
"instead of Japanese sources (usually after a previous inject). Rebuilt from "
|
||||
".wolf archives automatically when possible; if this persists, re-run Step 0 "
|
||||
"on the untranslated game."
|
||||
)
|
||||
|
||||
|
||||
def _prepare_for_names_inject(
|
||||
names_src: Path,
|
||||
manifest_entries: list[dict],
|
||||
data_dir: Path,
|
||||
originals_dir: Path,
|
||||
game_root: Path,
|
||||
log_fn: Callable[[str], None] | None = None,
|
||||
) -> str | None:
|
||||
"""Restore live Data/ and ensure names-inject can match Japanese sources."""
|
||||
emit = log_fn or (lambda _msg: None)
|
||||
|
||||
def _restore() -> None:
|
||||
for warning in restore_live_from_originals(
|
||||
manifest_entries, data_dir, originals_dir
|
||||
):
|
||||
emit(f" ⚠ {warning}")
|
||||
|
||||
_restore()
|
||||
would_apply = wolf_originals.names_inject_would_apply(names_src, data_dir)
|
||||
if wolfdawn.inject_had_applied(would_apply):
|
||||
return None
|
||||
|
||||
emit(" ⚠ names.json would apply 0 changes — rebuilding pristine originals…")
|
||||
if wolf_originals.rebuild_originals_from_archives(
|
||||
game_root, originals_dir, force=True, log_fn=log_fn
|
||||
):
|
||||
_restore()
|
||||
would_apply = wolf_originals.names_inject_would_apply(names_src, data_dir)
|
||||
if wolfdawn.inject_had_applied(would_apply):
|
||||
emit(f" ✓ ready — dry run would apply {would_apply} name change(s)")
|
||||
return None
|
||||
|
||||
return _STALE_NAMES_MSG
|
||||
|
||||
|
||||
def _inject_names(
|
||||
names_src: Path,
|
||||
data_dir: Path,
|
||||
*,
|
||||
allow_code_drift: bool,
|
||||
en_punct: bool,
|
||||
log_fn: Callable[[str], None] | None = None,
|
||||
) -> FileInjectResult:
|
||||
emit = log_fn or (lambda _msg: None)
|
||||
would_apply = wolf_originals.names_inject_would_apply(names_src, data_dir)
|
||||
if not wolfdawn.inject_had_applied(would_apply):
|
||||
return FileInjectResult(
|
||||
NAMES_JSON,
|
||||
False,
|
||||
(
|
||||
"inject refused — dry run would apply 0 name changes "
|
||||
"(live Data/ still lacks Japanese sources; rebuild originals first)"
|
||||
),
|
||||
)
|
||||
|
||||
emit(f" dry run: {would_apply} name change(s) pending")
|
||||
res = wolfdawn.names_inject(
|
||||
str(names_src),
|
||||
str(data_dir),
|
||||
allow_code_drift=allow_code_drift,
|
||||
en_punct=en_punct,
|
||||
log_fn=None,
|
||||
)
|
||||
result = _interpret_names_result(NAMES_JSON, res)
|
||||
if not result.success:
|
||||
return result
|
||||
|
||||
remaining = wolf_originals.names_inject_would_apply(names_src, data_dir)
|
||||
if wolfdawn.inject_had_applied(remaining):
|
||||
result = FileInjectResult(
|
||||
NAMES_JSON,
|
||||
False,
|
||||
(
|
||||
f"only partial apply — {result.summary}, but dry run still shows "
|
||||
f"{remaining} pending (restart Game.exe and report this)"
|
||||
),
|
||||
applied=result.applied,
|
||||
detail=result.detail,
|
||||
)
|
||||
elif result.success and wolfdawn.inject_had_applied(result.applied):
|
||||
result = FileInjectResult(
|
||||
NAMES_JSON,
|
||||
True,
|
||||
result.summary + " — restart Game.exe to see changes",
|
||||
applied=result.applied,
|
||||
detail=result.detail,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _inject_strings(
|
||||
json_name: str,
|
||||
entry: dict,
|
||||
inject_src: Path,
|
||||
data_dir: Path,
|
||||
originals_dir: Path,
|
||||
*,
|
||||
allow_code_drift: bool,
|
||||
en_punct: bool,
|
||||
) -> FileInjectResult:
|
||||
orig = orig_base_for(entry, data_dir, originals_dir)
|
||||
out = entry["base"]
|
||||
if not orig.exists():
|
||||
return FileInjectResult(
|
||||
json_name,
|
||||
False,
|
||||
f"no pristine original at {orig} (re-run Step 0 extract)",
|
||||
)
|
||||
|
||||
if entry.get("kind") == "db" and not db_dat_sibling(orig).is_file():
|
||||
return FileInjectResult(
|
||||
json_name,
|
||||
False,
|
||||
"missing database .dat pair in originals (re-run Step 0 extract)",
|
||||
)
|
||||
|
||||
res = wolfdawn.strings_inject(
|
||||
str(inject_src),
|
||||
str(orig),
|
||||
out,
|
||||
allow_code_drift=allow_code_drift,
|
||||
en_punct=en_punct,
|
||||
log_fn=None,
|
||||
)
|
||||
return _interpret_strings_result(json_name, res)
|
||||
|
||||
|
||||
def ensure_db_dat_snapshots(
|
||||
entries: list[dict],
|
||||
data_dir: Path,
|
||||
originals_dir: Path,
|
||||
) -> None:
|
||||
"""Backfill missing database ``.dat`` files in *originals_dir* from live Data/."""
|
||||
for entry in entries:
|
||||
if entry.get("kind") != "db":
|
||||
continue
|
||||
proj_orig = orig_base_for(entry, data_dir, originals_dir)
|
||||
dat_orig = db_dat_sibling(proj_orig)
|
||||
if dat_orig.exists():
|
||||
continue
|
||||
dat_live = db_dat_sibling(Path(entry["base"]))
|
||||
if not dat_live.is_file():
|
||||
continue
|
||||
dat_orig.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(dat_live, dat_orig)
|
||||
|
||||
|
||||
def inject_selected(
|
||||
selected: list[str],
|
||||
*,
|
||||
manifest_entries: list[dict],
|
||||
data_dir: Path,
|
||||
originals_dir: Path,
|
||||
translated_dir: Path,
|
||||
game_root: Path,
|
||||
allow_code_drift: bool = False,
|
||||
en_punct: bool = False,
|
||||
log_fn: Callable[[str], None] | None = None,
|
||||
) -> InjectReport:
|
||||
"""Inject *selected* translated JSON files. Returns a per-file report."""
|
||||
emit = log_fn or (lambda _msg: None)
|
||||
report = InjectReport()
|
||||
if not selected:
|
||||
return report
|
||||
|
||||
by_json = {e["json"]: e for e in manifest_entries if e.get("json")}
|
||||
ordered = sorted(set(selected), key=lambda n: (n != NAMES_JSON, n.lower()))
|
||||
|
||||
missing_manifest = [n for n in ordered if n not in by_json]
|
||||
missing_translated = [
|
||||
n for n in ordered if not (translated_dir / n).is_file()
|
||||
]
|
||||
for name in missing_manifest:
|
||||
report.files.append(
|
||||
FileInjectResult(name, False, "not listed in manifest (re-run Step 0 extract)")
|
||||
)
|
||||
for name in missing_translated:
|
||||
if name in by_json:
|
||||
report.files.append(
|
||||
FileInjectResult(name, False, f"not found in {translated_dir.name}/")
|
||||
)
|
||||
|
||||
todo = [
|
||||
n for n in ordered
|
||||
if n in by_json and (translated_dir / n).is_file()
|
||||
]
|
||||
if not todo:
|
||||
return report
|
||||
|
||||
if NAMES_JSON in todo:
|
||||
emit("Preparing live Data/ for names.json…")
|
||||
names_src = translated_dir / NAMES_JSON
|
||||
prep_error = _prepare_for_names_inject(
|
||||
names_src,
|
||||
manifest_entries,
|
||||
data_dir,
|
||||
originals_dir,
|
||||
game_root,
|
||||
log_fn=log_fn,
|
||||
)
|
||||
if prep_error:
|
||||
result = FileInjectResult(NAMES_JSON, False, prep_error)
|
||||
report.files.append(result)
|
||||
emit(f" ✗ {NAMES_JSON}: {result.summary}")
|
||||
todo = [n for n in todo if n != NAMES_JSON]
|
||||
else:
|
||||
emit(f"Injecting {NAMES_JSON}…")
|
||||
result = _inject_names(
|
||||
names_src,
|
||||
data_dir,
|
||||
allow_code_drift=allow_code_drift,
|
||||
en_punct=en_punct,
|
||||
log_fn=log_fn,
|
||||
)
|
||||
report.files.append(result)
|
||||
emit((" ✓ " if result.success else " ✗ ") + f"{NAMES_JSON}: {result.summary}")
|
||||
|
||||
strings_todo = [n for n in todo if n != NAMES_JSON]
|
||||
if strings_todo:
|
||||
ensure_db_dat_snapshots(manifest_entries, data_dir, originals_dir)
|
||||
|
||||
for json_name in strings_todo:
|
||||
entry = by_json[json_name]
|
||||
inject_src = repair_inject_json(translated_dir / json_name)
|
||||
emit(f"Injecting {json_name}…")
|
||||
result = _inject_strings(
|
||||
json_name,
|
||||
entry,
|
||||
inject_src,
|
||||
data_dir,
|
||||
originals_dir,
|
||||
allow_code_drift=allow_code_drift,
|
||||
en_punct=en_punct,
|
||||
)
|
||||
report.files.append(result)
|
||||
emit((" ✓ " if result.success else " ✗ ") + f"{json_name}: {result.summary}")
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def format_report_dialog(report: InjectReport) -> tuple[str, str]:
|
||||
"""Return (title, body) for a completion dialog."""
|
||||
if report.ok and report.succeeded:
|
||||
title = "Inject complete"
|
||||
lines = [f"✓ {r.json_name}: {r.summary}" for r in report.succeeded]
|
||||
elif report.failed or report.sync_failures:
|
||||
title = "Inject finished with errors"
|
||||
lines = []
|
||||
for r in report.succeeded:
|
||||
lines.append(f"✓ {r.json_name}: {r.summary}")
|
||||
for r in report.failed:
|
||||
lines.append(f"✗ {r.json_name}: {r.summary}")
|
||||
if r.detail:
|
||||
lines.append(f" {r.detail}")
|
||||
for name, err in report.sync_failures:
|
||||
lines.append(f"✗ sync {name}: {err}")
|
||||
else:
|
||||
title = "Inject"
|
||||
lines = ["Nothing was injected."]
|
||||
return title, "\n".join(lines)
|
||||
79
util/wolfdawn/originals.py
Normal file
79
util/wolfdawn/originals.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Pristine binary snapshots for idempotent WolfDawn inject."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from util import wolfdawn
|
||||
|
||||
ProgressFn = Callable[[int, int, str], None]
|
||||
|
||||
|
||||
def find_data_archives(game_root: Path, data_dir: Path) -> list[Path]:
|
||||
"""Return MapData / BasicData ``.wolf`` or ``.wolf.bak`` archives."""
|
||||
archives: list[Path] = []
|
||||
for base in (game_root, data_dir):
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for pat in ("*.wolf", "*.wolf.bak"):
|
||||
archives.extend(base.glob(pat))
|
||||
return [a for a in archives if a.name.lower().startswith(("mapdata", "basicdata"))]
|
||||
|
||||
|
||||
def rebuild_originals_from_archives(
|
||||
game_root: Path,
|
||||
originals_dir: Path,
|
||||
*,
|
||||
force: bool = False,
|
||||
log_fn: Callable[[str], None] | None = None,
|
||||
progress_fn: ProgressFn | None = None,
|
||||
) -> bool:
|
||||
"""Unpack ``BasicData`` / ``MapData`` archives into *originals_dir*.
|
||||
|
||||
When *force* is true, the entire *originals_dir* tree is removed first so
|
||||
stale flat snapshots from an old extract cannot survive beside rebuilt data.
|
||||
"""
|
||||
emit = log_fn or (lambda _msg: None)
|
||||
data_dir = game_root / "Data"
|
||||
archives = find_data_archives(game_root, data_dir)
|
||||
if not archives:
|
||||
emit(" ⚠ no BasicData/MapData .wolf archives found to rebuild originals")
|
||||
return False
|
||||
|
||||
if force and originals_dir.exists():
|
||||
shutil.rmtree(originals_dir)
|
||||
originals_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
emit("Rebuilding pristine originals from the game's .wolf archives…")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
inputs: list[str] = []
|
||||
for arc in archives:
|
||||
if arc.suffix == ".bak":
|
||||
staged = Path(tmp) / arc.with_suffix("").name
|
||||
shutil.copy2(arc, staged)
|
||||
inputs.append(str(staged))
|
||||
else:
|
||||
inputs.append(str(arc))
|
||||
res = wolfdawn.unpack_all(
|
||||
inputs,
|
||||
str(originals_dir),
|
||||
log_fn=log_fn,
|
||||
progress_fn=progress_fn,
|
||||
progress_total=len(inputs),
|
||||
)
|
||||
if not res.ok:
|
||||
emit(f" ⚠ could not rebuild originals (unpack exit {res.returncode})")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def names_inject_would_apply(names_json: Path, data_dir: Path) -> int | None:
|
||||
"""Return how many name changes wolf would apply (dry run), or None if unknown."""
|
||||
res = wolfdawn.names_inject(
|
||||
str(names_json), str(data_dir), dry_run=True, log_fn=None
|
||||
)
|
||||
applied, _drifted = wolfdawn.parse_names_inject_counts(res.stdout, res.stderr)
|
||||
return applied
|
||||
Loading…
Reference in a new issue