More changes and fixes

This commit is contained in:
DazedAnon 2026-07-09 13:35:12 -05:00
parent 2c0bfddcd8
commit e214a28d5d
8 changed files with 376 additions and 65 deletions

View file

@ -331,9 +331,9 @@ Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine sel
| **4 Database** | Review the **discovery summary** to see where this game's text lives (standard RPG sheets vs custom dialogue tables). Database sheets are classified as foundation (items, skills, descriptions — translate first) or narrative (custom event/profile sheets — translate after foundation). Use **Translate foundation DB** then **Translate narrative DB**, or tick specific sheets and run **Translate checked sheets only**. Optional: copy the **DB structure prompt** for an AI audit and import the returned JSON into `wolf_json/db_profile.json`. |
| **5 Maps/Events** | Translate map scripts (`.mps`), common events (`CommonEvent.dat`), `Game.dat`, and Evtext. Run after Steps 34 so vocab and database terms are consistent. Configure speaker handling for low-confidence nameplate guesses. Batch mode is recommended for large `CommonEvent.dat` files. |
| **6 Precheck** | Dry-run selected JSON for safety-guard skips; fix those lines before writing binaries. |
| **7 Inject** | Tick translated JSON files from `translated/`, then **Inject selected** or **Inject all**. Each file is reported individually; failures open a dialog with details. Uses pristine originals from `wolf_json/originals/`. |
| **7 Inject** | Always **Inject all** translated JSON from `translated/` into Data/ in one pass (keeps `names.json` and DB/map files in sync). Each file is reported individually; failures open a dialog with details. Uses pristine originals from `wolf_json/originals/`, then preserves name-only fields after names-inject. Optional **Layout-restore** re-applies source whitespace pads. |
| **8 Package** | Run from loose `Data/` (backs up `Data.wolf``.bak`) or repack `Data.wolf` so you can playtest and see overflow in-game. Optionally rewrite existing `.sav` files so old Japanese saves load in the translated build. |
| **9 Fix wrap** | After packaging, paste overflowing in-game text to **search** `translated/` JSON and jump to the database sheet and row (sheet names match Step 4 and `names.json` notes, e.g. `├■街の噂MOB`). Edit the line, set wrap width, **Wrap this row** or **Wrap all overflowing rows in this sheet**, save, then **Inject this file** (usually `DataBase.project.json`) and re-package to verify. Per-sheet widths are remembered in `wolf_json/wrap_profile.json`. **Advanced:** **wolf relayout** for event message boxes (maps/CommonEvent only — not bulletin-style DB UI) and **wolf desc-relayout** for standard 説明 description fields. |
| **9 Fix wrap** | After packaging, paste overflowing in-game text to **search** `translated/` JSON and jump to the database sheet and row (sheet names match Step 4 and `names.json` notes, e.g. `├■街の噂MOB`). Edit the line, set wrap width, **Wrap this row** or **Wrap all overflowing rows in this sheet**, save, then **Inject all** (Step 7) and re-package to verify. Per-sheet widths are remembered in `wolf_json/wrap_profile.json`. **Advanced:** **wolf relayout** for event message boxes (maps/CommonEvent only — not bulletin-style DB UI) and **wolf desc-relayout** for standard 説明 description fields. |
### Recommended order by game layout

View file

@ -17,13 +17,14 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
Step 5 Maps/Events - .mps maps, CommonEvent, Game.dat, Evtext; speaker handling
Step 6 Precheck - dry-run selected JSON for safety-guard skips; edit those
lines before writing binaries
Step 7 Inject - inject translations into Data/ binaries and refresh
wolf_json/; quick-inject picks a few files or all
Step 7 Inject - always inject every translated JSON into Data/ and
refresh wolf_json/ (full pass avoids name/DB wipe bugs);
optional layout-restore re-applies source whitespace pads
Step 8 Package - run from a loose Data/ folder or repack Data.wolf;
optional save rewrite for existing .sav files
Step 9 Fix wrap - search translated JSON by in-game text; Relayout
(names-wrap / cell geometry) or Manual (width + font);
inject the edited file, then re-package to verify
then Inject all from Step 7 and re-package to verify
names.json is staged into files/ but is NOT translated in the bulk phases - WolfDawn
tags each name safe / refs / verify and Phase 0 translates only safe entries
@ -2523,9 +2524,10 @@ class WolfWorkflowTab(QWidget):
def _build_step7_inject(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 7 · Inject Translations"))
layout.addWidget(self._desc(
"Tick translated JSON files below, then inject them into the game's Data/ "
"binaries. Run Step 6 (Precheck) first if you want to catch safety-guard "
"skips before writing. Each file is reported individually."
"Inject every translated JSON into the game's Data/ binaries in one pass. "
"A full inject keeps names.json and DB/map files in sync (partial injects "
"can wipe name-only fields like rumor boards). Run Step 6 (Precheck) first "
"if you want to catch safety-guard skips before writing."
))
self.en_punct_cb = QCheckBox("Convert Japanese punctuation to ASCII (--en-punct)")
@ -2562,10 +2564,10 @@ class WolfWorkflowTab(QWidget):
layout.addLayout(check_row)
layout.addWidget(_make_hr())
layout.addWidget(self._subheading("Translated files"))
layout.addWidget(self._subheading("Files that will be injected"))
self.inject_list = QListWidget()
self.inject_list.setMaximumHeight(280)
self.inject_list.setMaximumHeight(220)
self.inject_list.setStyleSheet(
"QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;"
"color:#cccccc;font-size:12px;padding:2px;}"
@ -2574,44 +2576,51 @@ class WolfWorkflowTab(QWidget):
)
layout.addWidget(self.inject_list)
self._inject_count_label = QLabel("")
self._inject_count_label.setStyleSheet("color:#9cdcfe;font-size:12px;")
layout.addWidget(self._inject_count_label)
list_row = QHBoxLayout()
refresh_btn = self._register(_make_btn("↻ Refresh", "#3a3a3a"))
refresh_btn.clicked.connect(self._refresh_inject_list)
sel_all_btn = self._register(_make_btn("Select all", "#3a3a3a"))
sel_all_btn.clicked.connect(lambda: self._set_checklist_checks(self.inject_list, True))
sel_none_btn = self._register(_make_btn("Select none", "#3a3a3a"))
sel_none_btn.clicked.connect(lambda: self._set_checklist_checks(self.inject_list, False))
list_row.addWidget(refresh_btn)
list_row.addWidget(sel_all_btn)
list_row.addWidget(sel_none_btn)
list_row.addStretch()
layout.addLayout(list_row)
btn_row = QHBoxLayout()
inject_sel_btn = self._register(_make_btn("Inject selected", "#00a86b"))
inject_sel_btn.clicked.connect(self._inject_selected)
inject_all_btn = self._register(_make_btn("Inject all", "#00a86b"))
inject_all_btn.setToolTip(
"Always injects every file in translated/ that the manifest knows about. "
"names.json runs first; DB/map injects keep name-only fields."
)
inject_all_btn.clicked.connect(self._inject_all)
btn_row.addWidget(inject_sel_btn)
layout_restore_btn = self._register(_make_btn("Layout-restore", "#3a3a3a"))
layout_restore_btn.setToolTip(
"Re-run wolf layout-restore on every injectable translated JSON. "
"Copies unambiguous positional whitespace pads from source onto text "
"(e.g. status-card <pad>\\nNAME). Safe to re-run; already-fixed lines are no-ops."
)
layout_restore_btn.clicked.connect(self._layout_restore_all)
btn_row.addWidget(inject_all_btn)
btn_row.addStretch()
btn_row.addWidget(layout_restore_btn)
layout.addLayout(btn_row)
layout.addWidget(_make_hr())
layout.addWidget(self._desc(
"Next: Step 8 (Package) so you can playtest, then Step 9 (Fix wrapping) "
"to paste overflowing in-game text, wrap, and re-inject."
"to paste overflowing in-game text, wrap, and Inject all again."
))
self._refresh_inject_list()
def _build_step9_relayout(self, layout: QVBoxLayout):
"""Search-first fix wrapping: find sheet by in-game text, wrap, re-inject."""
"""Search-first fix wrapping: find sheet by in-game text, wrap, Inject all."""
layout.addWidget(_make_section_label("Step 9 · Fix wrapping"))
layout.addWidget(self._desc(
"After packaging and playtesting, paste overflowing in-game text to find "
"it in translated JSON. Relayout uses cell width + max lines "
"(names.json → wolf names-wrap). Manual uses wrap width + body font; "
"emphasis \\f[N] scales with the body. Re-inject, then re-package to verify."
"emphasis \\f[N] scales with the body. Then Inject all (Step 7) and re-package."
))
search_row = QHBoxLayout()
@ -2777,8 +2786,12 @@ class WolfWorkflowTab(QWidget):
wrap_row_btn.clicked.connect(self._wrap_current_row)
wrap_group_btn = _make_btn("Wrap all in group", "#00a86b")
wrap_group_btn.clicked.connect(self._wrap_current_group)
inject_btn = _make_btn("Inject this file", "#007acc")
inject_btn.clicked.connect(self._inject_current_wrap_file)
inject_btn = _make_btn("Inject all", "#007acc")
inject_btn.setToolTip(
"Always injects every translated file (same as Step 7). "
"Partial injects can wipe name-only DB fields."
)
inject_btn.clicked.connect(self._inject_all)
btn_row.addWidget(wrap_row_btn)
btn_row.addWidget(wrap_group_btn)
btn_row.addWidget(inject_btn)
@ -3226,7 +3239,7 @@ class WolfWorkflowTab(QWidget):
else "Line already fits (manual)."
)
self._wrap_status_label.setText(
msg + f" Inject {self._current_wrap_path.name} from Step 7."
msg + " Inject all from Step 7 when ready."
)
self._log(f"{'' if changed else ''} {msg}")
self._update_wrap_preview()
@ -3267,7 +3280,7 @@ class WolfWorkflowTab(QWidget):
if line is not None:
self._current_wrap_text = str(line.get("text") or "")
msg = f"Wrapped line at width {width}." if changed else "Line already fits at this width."
self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name} from Step 7.")
self._wrap_status_label.setText(msg + " Inject all from Step 7 when ready.")
self._log(f"{'' if changed else ''} {msg}")
self._update_wrap_preview()
@ -3301,7 +3314,7 @@ class WolfWorkflowTab(QWidget):
f"Manual-wrapped {count} line(s) in {scope} "
f"(width {width}, body \\f[{font}])."
)
self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name}.")
self._wrap_status_label.setText(msg + " Inject all from Step 7 when ready.")
self._log(f"{msg}")
self._reload_wrap_hit_editor(hit, width)
return
@ -3339,7 +3352,7 @@ class WolfWorkflowTab(QWidget):
)
scope = wolf_ws.scope_label(hit)
msg = f"Wrapped {count} line(s) in {scope} at width {width}."
self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name}.")
self._wrap_status_label.setText(msg + " Inject all from Step 7 when ready.")
self._log(f"{msg}")
self._reload_wrap_hit_editor(hit, width)
@ -3434,20 +3447,11 @@ class WolfWorkflowTab(QWidget):
self._reload_wrap_hit_editor(hit, self._active_wrap_width())
if not quiet and hasattr(self, "_wrap_status_label"):
self._wrap_status_label.setText(
summary + " Re-inject names.json from Step 7."
summary + " Inject all from Step 7 when ready."
)
self._run_task(task, on_done=_after)
def _inject_current_wrap_file(self):
hit = self._current_wrap_hit
if not hit:
QMessageBox.information(self, "Inject", "Select a search result first.")
return
if not self._require_manifest():
return
self._inject({hit.json_file})
def _tool_root(self) -> Path:
"""DazedMTLTool project root (``files/``, ``translated/``, etc.)."""
return Path(__file__).resolve().parent.parent
@ -3626,7 +3630,7 @@ class WolfWorkflowTab(QWidget):
self._refresh_inject_list()
def _fill_injectable_checklist(self, list_widget: QListWidget):
"""Populate a checkable file list from translated/ + manifest."""
"""Populate a checkable file list from translated/ + manifest (precheck)."""
list_widget.clear()
if not self._read_manifest():
item = QListWidgetItem("Run Step 0 (extract) first - no manifest found.")
@ -3652,9 +3656,31 @@ class WolfWorkflowTab(QWidget):
self._fill_injectable_checklist(self.precheck_list)
def _refresh_inject_list(self):
"""Show every injectable file (read-only). Inject always uses the full set."""
if not hasattr(self, "inject_list"):
return
self._fill_injectable_checklist(self.inject_list)
self.inject_list.clear()
if hasattr(self, "_inject_count_label"):
self._inject_count_label.setText("")
if not self._read_manifest():
item = QListWidgetItem("Run Step 0 (extract) first - no manifest found.")
item.setFlags(Qt.ItemIsEnabled)
self.inject_list.addItem(item)
return
files = self._injectable_filenames()
if not files:
item = QListWidgetItem("No injectable files in translated/ yet.")
item.setFlags(Qt.ItemIsEnabled)
self.inject_list.addItem(item)
return
for json_name in files:
item = QListWidgetItem(json_name)
item.setFlags(Qt.ItemIsEnabled)
self.inject_list.addItem(item)
if hasattr(self, "_inject_count_label"):
self._inject_count_label.setText(
f"{len(files)} file(s) will be injected together."
)
def _selected_checklist_files(self, list_widget: QListWidget) -> set[str]:
selected: set[str] = set()
@ -3669,9 +3695,6 @@ class WolfWorkflowTab(QWidget):
def _selected_precheck_files(self) -> set[str]:
return self._selected_checklist_files(self.precheck_list)
def _selected_inject_files(self) -> set[str]:
return self._selected_checklist_files(self.inject_list)
def _set_checklist_checks(self, list_widget: QListWidget, checked: bool):
state = Qt.Checked if checked else Qt.Unchecked
for i in range(list_widget.count()):
@ -3821,18 +3844,6 @@ class WolfWorkflowTab(QWidget):
"Saved. Re-run Precheck to confirm the safety skip is gone.",
)
def _inject_selected(self):
if not self._require_manifest():
return
selected = self._selected_inject_files()
if not selected:
QMessageBox.information(
self, "Nothing selected",
"Tick one or more files in the list, then click Inject selected.",
)
return
self._inject(selected)
def _inject_all(self):
if not self._require_manifest():
return
@ -3845,6 +3856,49 @@ class WolfWorkflowTab(QWidget):
return
self._inject(set(files))
def _layout_restore_all(self):
"""Re-run wolf layout-restore on every injectable translated JSON."""
files = self._injectable_filenames()
if not files:
QMessageBox.information(
self,
"Layout-restore",
"No injectable files found in translated/.",
)
return
translated = self._tool_root() / "translated"
paths = [translated / name for name in files if (translated / name).is_file()]
if not paths:
QMessageBox.information(
self,
"Layout-restore",
"None of the injectable files exist under translated/.",
)
return
def task(log, progress=None):
from util import wolfdawn
log(f"layout-restore: {len(paths)} file(s)…")
res = wolfdawn.layout_restore(paths, log_fn=log)
for line in (res.stdout or "").splitlines():
if line.strip():
log(line)
fixed = wolfdawn.parse_layout_restore_counts(res.stdout, res.stderr)
if not res.ok:
err = (res.stderr or res.stdout or "").strip() or f"exit {res.returncode}"
return False, f"layout-restore failed: {err}"
n = fixed if fixed is not None else 0
return True, f"layout-restore fixed {n} line(s) across {len(paths)} file(s)."
def _after(ok: bool, msg: str):
if ok:
QMessageBox.information(self, "Layout-restore", msg)
else:
QMessageBox.warning(self, "Layout-restore", msg or "layout-restore failed.")
self._run_task(task, on_done=_after)
def _names_check(self):
if not self._require_manifest():
return

View file

@ -41,6 +41,10 @@ to WOLF's native ``Speaker\nline`` layout on write-back. See ``util.speakers``.
Detection is WolfDawn's, so the reliable nameplate (``literal_line1``) is always
reshaped; only the low-confidence guess (``literal_line1_lowconf``) is gated by a
per-game, AI-recommended setting from the workflow.
Layout restore: after each file is written to ``translated/``, ``wolf
layout-restore`` copies unambiguous positional whitespace pads from ``source``
onto ``text`` (status-card ``<pad>\\nNAME`` fields the model often strips).
"""
import json
@ -61,6 +65,7 @@ from util.translation import (
)
from util import speakers as wolf_speakers
from util import vocab as wolf_vocab
from util import wolfdawn
from util.wolfdawn import codes as wolf_codes
from util.wolfdawn import db_classify as wolf_db
from util.wolfdawn import names as wolf_names
@ -149,12 +154,34 @@ def handleWolfDawn(filename, estimate):
# translated/ here would overwrite prior work with source echoed back.
# Real English is written on the consume pass (or a live non-batch run).
if not estimate and _batch_phase() != "collect":
out_path = "translated/" + filename
try:
with open("translated/" + filename, "w", encoding="utf-8", newline="\n") as outFile:
with open(out_path, "w", encoding="utf-8", newline="\n") as outFile:
json.dump(translatedData[0], outFile, ensure_ascii=False, indent=4)
except Exception:
traceback.print_exc()
return "Fail"
# Restore positional whitespace pads the model dropped (status-card
# ``<pad>\nNAME`` fields, etc.). WolfDawn edits the JSON in place.
try:
res = wolfdawn.layout_restore(out_path)
if not res.ok:
tqdm.write(
Fore.YELLOW
+ f"{filename}: layout-restore exited {res.returncode}"
+ (f" ({res.stderr.strip()})" if res.stderr.strip() else "")
+ Fore.RESET
)
else:
fixed = wolfdawn.parse_layout_restore_counts(res.stdout, res.stderr)
if fixed:
tqdm.write(
Fore.CYAN
+ f"{filename}: layout-restore fixed {fixed} line(s)"
+ Fore.RESET
)
except Exception:
traceback.print_exc()
end = time.time()
tqdm.write(getResultString(translatedData, end - start, filename))

View file

@ -39,6 +39,7 @@ class ListInjectableTests(unittest.TestCase):
class InjectOrderTests(unittest.TestCase):
def test_names_runs_before_strings(self):
calls: list[str] = []
string_bases: list[str] = []
def fake_names(*_a, **_k):
calls.append("names")
@ -49,8 +50,9 @@ class InjectOrderTests(unittest.TestCase):
stderr="",
)
def fake_strings(*_a, **_k):
def fake_strings(edited_json, base, output, **_k):
calls.append("strings")
string_bases.append(str(base))
return mock.Mock(
ok=True,
returncode=0,
@ -105,6 +107,72 @@ class InjectOrderTests(unittest.TestCase):
self.assertEqual(calls, ["names", "strings"])
self.assertTrue(report.ok)
self.assertEqual(len(report.succeeded), 2)
# After names-inject, strings must use live Data/ as --base so
# name-only fields are not rebuilt from pristine Japanese originals.
self.assertEqual(string_bases, [str(map_live)])
def test_strings_alone_use_pristine_original_base(self):
string_bases: list[str] = []
def fake_strings(edited_json, base, output, **_k):
string_bases.append(str(base))
return mock.Mock(
ok=True,
returncode=0,
stdout="applied 1 translation(s) (0 drifted)",
stderr="",
)
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
translated = root / "translated"
originals = root / "originals"
data = root / "data"
translated.mkdir()
originals.mkdir()
data.mkdir()
(translated / "Map001.mps.json").write_text(
'{"kind":"map","scenes":[]}', encoding="utf-8"
)
map_live = data / "Map001.mps"
map_orig = originals / "Map001.mps"
map_live.write_bytes(b"live")
map_orig.write_bytes(b"orig")
entries = [
{"json": "Map001.mps.json", "kind": "map", "base": str(map_live)},
]
with mock.patch.object(wi.wolfdawn, "strings_inject", side_effect=fake_strings):
report = wi.inject_selected(
["Map001.mps.json"],
manifest_entries=entries,
data_dir=data,
originals_dir=originals,
translated_dir=translated,
game_root=data.parent,
)
self.assertTrue(report.ok)
self.assertEqual(string_bases, [str(map_orig)])
def test_names_result_mentions_safety_skips(self):
res = mock.Mock(
ok=True,
returncode=0,
stdout=(
"would apply 10 name change(s) (0 drifted/unmatched)\n"
"WARNING: 46 line(s) left UNTRANSLATED by a safety guard - "
"46 control-code mismatch, 0 not encodable\n"
),
stderr="",
)
# parse_names_inject_counts needs "applied N name change"
res.stdout = (
"applied 10 name change(s) (0 drifted/unmatched)\n"
"WARNING: 46 line(s) left UNTRANSLATED by a safety guard - "
"46 control-code mismatch, 0 not encodable\n"
)
result = wi._interpret_names_result("names.json", res)
self.assertTrue(result.success)
self.assertIn("46 skipped by safety guard", result.summary)
class ReportDialogTests(unittest.TestCase):

View file

@ -298,6 +298,56 @@ class TestTranslationWriteback(unittest.TestCase):
else:
os.environ["BATCH_PHASE"] = orig_phase
def test_layout_restore_runs_after_write(self):
"""After writing translated/, wolf layout-restore restores pad skeletons."""
pad = " \n"
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "files").mkdir()
(root / "translated").mkdir()
# Use map kind so DB sheet filters from .env cannot skip the line.
doc = {
"kind": "map",
"file": "Map001.mps",
"scenes": [
{
"event": 1,
"name": "ev",
"lines": [
{
"cmd": 0,
"str": 0,
"source": pad + "なし",
"text": pad + "なし",
}
],
}
],
}
(root / "files" / "Map001.mps.json").write_text(
json.dumps(doc, ensure_ascii=False), encoding="utf-8"
)
def translate(text, history, history_ctx=None):
# Model drops the leading pad / newline - the bug layout-restore fixes.
if isinstance(text, list):
return [["None" for _ in text], [1, 1]]
return ["None", [1, 1]]
old_cwd = os.getcwd()
os.chdir(root)
orig_t = wd.translateAI
wd.translateAI = translate
try:
result = wd.handleWolfDawn("Map001.mps.json", estimate=False)
finally:
wd.translateAI = orig_t
os.chdir(old_cwd)
self.assertNotEqual(result, "Fail")
out = json.loads((root / "translated" / "Map001.mps.json").read_text(encoding="utf-8"))
self.assertEqual(out["scenes"][0]["lines"][0]["text"], pad + "None")
def test_echoed_source_does_not_overwrite_text(self):
"""If the model/collect echoes JP source, leave text alone."""
doc = {

View file

@ -97,5 +97,45 @@ class TestRelayoutWrappers(unittest.TestCase):
self.assertEqual(wolfdawn.parse_names_wrap_shrink_count(res.stdout), 2)
class TestLayoutRestoreWrapper(unittest.TestCase):
def test_layout_restore_flags(self):
captured = {}
def fake_run(args, log_fn=None):
captured["args"] = list(args)
return wolfdawn.WolfResult(
0,
"layout-restore: 1 translation(s) got back their source's whitespace skeleton\n",
"",
[str(a) for a in args],
)
with patch.object(wolfdawn, "_run", side_effect=fake_run):
res = wolfdawn.layout_restore("/tmp/db.json", dry_run=True)
self.assertEqual(
captured["args"],
["layout-restore", "/tmp/db.json", "--dry-run"],
)
self.assertEqual(wolfdawn.parse_layout_restore_counts(res.stdout), 1)
def test_layout_restore_multiple_files(self):
captured = {}
def fake_run(args, log_fn=None):
captured["args"] = list(args)
return wolfdawn.WolfResult(0, "", "", [str(a) for a in args])
with patch.object(wolfdawn, "_run", side_effect=fake_run):
wolfdawn.layout_restore(["/a.json", "/b.json"])
self.assertEqual(captured["args"], ["layout-restore", "/a.json", "/b.json"])
def test_parse_layout_restore_would_count(self):
out = (
"layout-restore: 3 translation(s) WOULD get back their "
"source's whitespace skeleton (dry run; no write)\n"
)
self.assertEqual(wolfdawn.parse_layout_restore_counts(out), 3)
if __name__ == "__main__":
unittest.main(verbosity=2)

View file

@ -54,6 +54,8 @@ __all__ = [
"names_wrap",
"parse_names_wrap_counts",
"parse_names_wrap_shrink_count",
"layout_restore",
"parse_layout_restore_counts",
"relayout",
"desc_relayout",
]
@ -752,6 +754,47 @@ def names_wrap(
return _run(args, log_fn=log_fn)
_LAYOUT_RESTORE_APPLIED_RE = re.compile(
r"(\d+)\s+translation\(s\)\s+got back their source's whitespace skeleton",
re.IGNORECASE,
)
_LAYOUT_RESTORE_WOULD_RE = re.compile(
r"(\d+)\s+translation\(s\)\s+WOULD get back their source's whitespace skeleton",
re.IGNORECASE,
)
def parse_layout_restore_counts(stdout: str, stderr: str = "") -> int | None:
"""Return how many translations layout-restore fixed (or would fix), or None."""
blob = f"{stdout}\n{stderr}"
for pattern in (_LAYOUT_RESTORE_APPLIED_RE, _LAYOUT_RESTORE_WOULD_RE):
match = pattern.search(blob)
if match:
return int(match.group(1))
return None
def layout_restore(
json_files: Union[PathLike, Iterable[PathLike]],
*,
dry_run: bool = False,
log_fn=None,
) -> WolfResult:
"""``wolf layout-restore <extract.json>... [--dry-run]``.
Rebuilds translations that dropped their source's positional whitespace pad
skeleton (e.g. status-card ``<pad>\\nNAME`` fields). Edits JSON in place.
"""
if isinstance(json_files, (str, Path)):
paths = [json_files]
else:
paths = list(json_files)
args = ["layout-restore", *[_str(p) for p in paths]]
if dry_run:
args.append("--dry-run")
return _run(args, log_fn=log_fn)
def _relayout_metric_arg(value: int | str | None, *, zero_is_auto: bool = False) -> str | None:
"""Format one relayout geometry flag (``55``, ``auto``, or omit)."""
if value is None:

View file

@ -2,7 +2,9 @@
One list of files in ``translated/``, user picks some or all, each file gets a
clear pass/fail result. ``names.json`` uses ``names-inject`` (must run before
per-file ``strings-inject`` when both are selected).
per-file ``strings-inject`` when both are selected). After names-inject, each
``strings-inject`` uses the live post-names binary as ``--base`` so name-only
DB fields (rumor boards, etc.) are not rebuilt from pristine Japanese originals.
"""
from __future__ import annotations
@ -170,18 +172,21 @@ def _interpret_strings_result(json_name: str, res: wolfdawn.WolfResult) -> FileI
def _interpret_names_result(json_name: str, res: wolfdawn.WolfResult) -> FileInjectResult:
cli_err = wolfdawn.parse_inject_cli_error(res.stdout, res.stderr)
applied, drifted = wolfdawn.parse_names_inject_counts(res.stdout, res.stderr)
safety = wolfdawn.parse_strings_inject_safety_count(res.stdout, res.stderr)
detail = _wolf_output_snippet(res.stdout, res.stderr)
if not res.ok:
reason = cli_err or f"wolf exited {res.returncode}"
return FileInjectResult(json_name, False, reason, applied=applied, detail=detail)
if wolfdawn.inject_had_applied(applied):
msg = f"applied {applied} name change(s)"
if drifted:
msg += f" ({drifted} unmatched)"
if safety:
msg += f" ({safety} skipped by safety guard)"
return FileInjectResult(json_name, True, msg, applied=applied, detail=detail)
if not res.ok:
reason = cli_err or f"wolf exited {res.returncode}"
return FileInjectResult(json_name, False, reason, applied=applied, detail=detail)
if res.ok and (drifted or 0) > 0:
return FileInjectResult(
json_name,
@ -320,9 +325,22 @@ def _inject_strings(
*,
allow_code_drift: bool,
en_punct: bool,
base_path: Path | None = None,
) -> FileInjectResult:
"""Inject one strings JSON.
``base_path`` defaults to the pristine original. Pass the live binary when
names-inject already ran in this batch so name-only fields are preserved.
"""
orig = orig_base_for(entry, data_dir, originals_dir)
out = entry["base"]
base = base_path if base_path is not None else orig
if not base.exists():
return FileInjectResult(
json_name,
False,
f"no inject base at {base} (re-run Step 0 extract)",
)
if not orig.exists():
return FileInjectResult(
json_name,
@ -330,16 +348,16 @@ def _inject_strings(
f"no pristine original at {orig} (re-run Step 0 extract)",
)
if entry.get("kind") == "db" and not db_dat_sibling(orig).is_file():
if entry.get("kind") == "db" and not db_dat_sibling(base).is_file():
return FileInjectResult(
json_name,
False,
"missing database .dat pair in originals (re-run Step 0 extract)",
"missing database .dat pair beside inject base (re-run Step 0 extract)",
)
res = wolfdawn.strings_inject(
str(inject_src),
str(orig),
str(base),
out,
allow_code_drift=allow_code_drift,
en_punct=en_punct,
@ -410,6 +428,7 @@ def inject_selected(
if not todo:
return report
names_applied = False
if NAMES_JSON in todo:
emit("Preparing live Data/ for names.json…")
names_src = translated_dir / NAMES_JSON
@ -452,15 +471,24 @@ def inject_selected(
)
report.files.append(result)
emit(("" if result.success else "") + f"{NAMES_JSON}: {result.summary}")
names_applied = result.success
strings_todo = [n for n in todo if n != NAMES_JSON]
if strings_todo:
ensure_db_dat_snapshots(manifest_entries, data_dir, originals_dir)
if names_applied:
emit(
" using live Data/ as strings-inject base "
"(preserve name-only fields from names-inject)"
)
for json_name in strings_todo:
entry = by_json[json_name]
inject_src = repair_inject_json(translated_dir / json_name)
emit(f"Injecting {json_name}")
# After names-inject, live binaries already hold EN name-only fields.
# Rebuilding from pristine JP originals would wipe those (rumor boards, etc.).
live_base = Path(entry["base"]) if names_applied else None
result = _inject_strings(
json_name,
entry,
@ -469,6 +497,7 @@ def inject_selected(
originals_dir,
allow_code_drift=allow_code_drift,
en_punct=en_punct,
base_path=live_base,
)
report.files.append(result)
emit(("" if result.success else "") + f"{json_name}: {result.summary}")