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