538 lines
18 KiB
Python
538 lines
18 KiB
Python
"""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). After names-inject, each
|
||
``strings-inject`` uses the live post-names binary as ``--base`` so name-only
|
||
DB fields (rumor boards, etc.) are not rebuilt from pristine Japanese originals.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
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) -> tuple[Path, bool]:
|
||
"""Auto-repair WOLF inline codes in a translated JSON before inject.
|
||
|
||
Returns ``(path, has_font_size_drift)``. Font-size drift is detected after
|
||
repair so intentional ``\\f[N]`` shrinks from Fix-wrap survive and inject
|
||
can auto-pass ``--allow-code-drift``.
|
||
"""
|
||
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, wolf_codes.document_has_font_size_drift(data)
|
||
|
||
|
||
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)
|
||
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)
|
||
|
||
# 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 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,
|
||
False,
|
||
f"0 applied, {drifted} drifted (live Data/ no longer matches Japanese sources)",
|
||
applied=0,
|
||
detail=detail,
|
||
)
|
||
|
||
if safety or mismatches:
|
||
parts = []
|
||
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
|
||
)
|
||
|
||
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)
|
||
safety = wolfdawn.parse_strings_inject_safety_count(res.stdout, res.stderr)
|
||
detail = _wolf_output_snippet(res.stdout, res.stderr)
|
||
|
||
if wolfdawn.inject_had_applied(applied):
|
||
msg = f"applied {applied} name change(s)"
|
||
if drifted:
|
||
msg += f" ({drifted} unmatched)"
|
||
if safety:
|
||
msg += f" ({safety} skipped by safety guard)"
|
||
return FileInjectResult(json_name, True, msg, applied=applied, detail=detail)
|
||
|
||
if not res.ok:
|
||
reason = cli_err or f"wolf exited {res.returncode}"
|
||
return FileInjectResult(json_name, False, reason, applied=applied, detail=detail)
|
||
|
||
if res.ok and (drifted or 0) > 0:
|
||
return FileInjectResult(
|
||
json_name,
|
||
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,
|
||
*,
|
||
allow_code_drift: bool = False,
|
||
) -> 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, allow_code_drift=allow_code_drift
|
||
)
|
||
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, allow_code_drift=allow_code_drift
|
||
)
|
||
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, allow_code_drift=allow_code_drift
|
||
)
|
||
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, allow_code_drift=allow_code_drift
|
||
)
|
||
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,
|
||
base_path: Path | None = None,
|
||
) -> FileInjectResult:
|
||
"""Inject one strings JSON.
|
||
|
||
``base_path`` defaults to the pristine original. Pass the live binary when
|
||
names-inject already ran in this batch so name-only fields are preserved.
|
||
"""
|
||
orig = orig_base_for(entry, data_dir, originals_dir)
|
||
out = entry["base"]
|
||
base = base_path if base_path is not None else orig
|
||
if not base.exists():
|
||
return FileInjectResult(
|
||
json_name,
|
||
False,
|
||
f"no inject base at {base} (re-run Step 0 extract)",
|
||
)
|
||
if not orig.exists():
|
||
return FileInjectResult(
|
||
json_name,
|
||
False,
|
||
f"no pristine original at {orig} (re-run Step 0 extract)",
|
||
)
|
||
|
||
if entry.get("kind") == "db" and not db_dat_sibling(base).is_file():
|
||
return FileInjectResult(
|
||
json_name,
|
||
False,
|
||
"missing database .dat pair beside inject base (re-run Step 0 extract)",
|
||
)
|
||
|
||
res = wolfdawn.strings_inject(
|
||
str(inject_src),
|
||
str(base),
|
||
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
|
||
|
||
names_applied = False
|
||
if NAMES_JSON in todo:
|
||
emit("Preparing live Data/ for names.json…")
|
||
names_src = translated_dir / NAMES_JSON
|
||
names_drift = allow_code_drift
|
||
if not names_drift:
|
||
try:
|
||
names_doc = json.loads(names_src.read_text(encoding="utf-8-sig"))
|
||
except Exception:
|
||
names_doc = None
|
||
if isinstance(names_doc, dict) and wolf_codes.names_doc_has_font_size_drift(
|
||
names_doc
|
||
):
|
||
names_drift = True
|
||
emit(
|
||
" ℹ names-wrap changed \\f[N] sizes — "
|
||
"passing --allow-code-drift for names-inject"
|
||
)
|
||
prep_error = _prepare_for_names_inject(
|
||
names_src,
|
||
manifest_entries,
|
||
data_dir,
|
||
originals_dir,
|
||
game_root,
|
||
log_fn=log_fn,
|
||
allow_code_drift=names_drift,
|
||
)
|
||
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=names_drift,
|
||
en_punct=en_punct,
|
||
log_fn=log_fn,
|
||
)
|
||
report.files.append(result)
|
||
emit((" ✓ " if result.success else " ✗ ") + f"{NAMES_JSON}: {result.summary}")
|
||
names_applied = result.success
|
||
|
||
strings_todo = [n for n in todo if n != NAMES_JSON]
|
||
if strings_todo:
|
||
ensure_db_dat_snapshots(manifest_entries, data_dir, originals_dir)
|
||
if names_applied:
|
||
emit(
|
||
" ℹ using live Data/ as strings-inject base "
|
||
"(preserve name-only fields from names-inject)"
|
||
)
|
||
|
||
for json_name in strings_todo:
|
||
entry = by_json[json_name]
|
||
inject_src, font_drift = repair_inject_json(translated_dir / json_name)
|
||
strings_drift = allow_code_drift or font_drift
|
||
if font_drift and not allow_code_drift:
|
||
emit(
|
||
f" ℹ {json_name}: Fix-wrap / \\f[N] size changes — "
|
||
"passing --allow-code-drift for strings-inject"
|
||
)
|
||
emit(f"Injecting {json_name}…")
|
||
# After names-inject, live binaries already hold EN name-only fields.
|
||
# Rebuilding from pristine JP originals would wipe those (rumor boards, etc.).
|
||
live_base = Path(entry["base"]) if names_applied else None
|
||
result = _inject_strings(
|
||
json_name,
|
||
entry,
|
||
inject_src,
|
||
data_dir,
|
||
originals_dir,
|
||
allow_code_drift=strings_drift,
|
||
en_punct=en_punct,
|
||
base_path=live_base,
|
||
)
|
||
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)
|