relayout for db and tls

This commit is contained in:
DazedAnon 2026-07-08 08:45:58 -05:00
parent 3fdaaad0d0
commit b9ab810c26
6 changed files with 457 additions and 72 deletions

View file

@ -70,8 +70,9 @@ noteWidth="75"
#wolfNameWrapWidth="60"
#wolfNameWrapNotes='["├■プロフィール","説明"]'
# WolfDawn dialogue (Step 4 Phase 2): re-wrap translated event text
#wolfWrap="true"
# WolfDawn dialogue wrap at translate time is optional / advanced; normal workflow
# leaves line breaks alone and runs `wolf relayout` after inject (Step 5).
#wolfWrap="false"
#wolfWidth="55"
#CSV delimiter character (comma, semicolon, or tab)

View file

@ -15,12 +15,13 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
Phase 1 Database - item/skill/state descriptions and system
messages (DataBase/CDataBase.project)
Phase 2 Maps / events - .mps maps, CommonEvent, Game.dat,
Evtext; speaker handling and text-wrap
settings live under this phase
Evtext; speaker handling lives under this phase
Step 5 Inject - inject translations + translated names back into the Data/ binaries
and refresh the git-tracked wolf_json/ with the translated
JSON; quick-inject picks just a few translated files for
fast in-game / git iteration, or inject everything at once
fast in-game / git iteration, or inject everything at once.
After a successful inject, optional WolfDawn relayout reflows
Message text (and optionally DB descriptions) to the message box.
Step 6 Package - run from a loose Data/ folder, or repack Data.wolf
Step 7 Saves - fix baked strings in existing .sav files (optional)
@ -1720,11 +1721,11 @@ class WolfWorkflowTab(QWidget):
layout.addWidget(self._desc(
"Map scripts (.mps), common events (CommonEvent.dat), Game.dat, and Evtext - story "
"dialogue, UI/objective strings, and other event text. Run after Phase 1 so it "
"benefits from the names and terms already in vocab.txt. Speaker handling and text "
"wrap below apply to the dialogue-like lines in this phase."
"benefits from the names and terms already in vocab.txt. Speaker handling below "
"applies to the dialogue-like lines in this phase. Message box layout is handled "
"after inject (Step 5 Relayout), not here."
))
self._add_speaker_options(layout)
self._add_wrap_options(layout)
p2 = self._register(_make_btn("▶ Phase 2 · Translate maps / events", "#00a86b"))
p2.clicked.connect(
lambda: self._navigate_to_translation(kinds=PHASE_MAPS_EVENTS_KINDS, auto_start=True)
@ -1789,53 +1790,6 @@ class WolfWorkflowTab(QWidget):
except Exception as exc:
self._log(f"❌ Could not save speaker options: {exc}")
def _add_wrap_options(self, layout: QVBoxLayout):
"""Dialogue text-wrap width (like the RPGMaker workflow, via dazedwrap)."""
layout.addWidget(_make_hr())
layout.addWidget(self._subheading("Text wrap width"))
layout.addWidget(self._desc(
"Re-wraps the translated English dialogue so it fits WOLF's message box. "
"Only the dialogue body is wrapped - a speaker's name line (the line break "
"right after a nameplate) is always kept separate. Lower the width if lines "
"overflow in-game; raise it if text wraps too early. Applies on the next run."
))
wrap_env = self._read_env_values(["wolfWrap", "wolfWidth"])
self._wrap_enable_cb = QCheckBox("Wrap translated dialogue")
enabled = str(wrap_env.get("wolfWrap", "true")).strip().lower() != "false"
self._wrap_enable_cb.setChecked(enabled)
self._wrap_enable_cb.stateChanged.connect(self._apply_wrap_config)
layout.addWidget(self._wrap_enable_cb)
row = QHBoxLayout()
width_lbl = QLabel("Width (characters):")
width_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._wrap_width_spin = QSpinBox()
self._wrap_width_spin.setRange(20, 300)
try:
self._wrap_width_spin.setValue(int(wrap_env.get("wolfWidth") or 55))
except ValueError:
self._wrap_width_spin.setValue(55)
self._wrap_width_spin.setFixedWidth(70)
self._wrap_width_spin.valueChanged.connect(self._apply_wrap_config)
row.addWidget(width_lbl)
row.addWidget(self._wrap_width_spin)
row.addStretch()
layout.addLayout(row)
def _apply_wrap_config(self, *args):
"""Persist wolfWrap / wolfWidth to .env so the next translation run uses them."""
updates = {
"wolfWrap": "true" if self._wrap_enable_cb.isChecked() else "false",
"wolfWidth": str(self._wrap_width_spin.value()),
}
if self._write_env_values(updates):
self._log(
"Text wrap updated: "
+ ", ".join(f"{k}={v}" for k, v in updates.items())
)
def _read_env_values(self, keys) -> dict:
"""Read a few `key='value'` pairs from .env (best effort)."""
import re as _re
@ -2283,6 +2237,115 @@ class WolfWorkflowTab(QWidget):
inject_btn.clicked.connect(lambda: self._inject())
layout.addWidget(inject_btn)
self._add_relayout_options(layout)
def _add_relayout_options(self, layout: QVBoxLayout):
"""Post-inject WolfDawn message-box / DB-description reflow."""
layout.addWidget(_make_hr())
layout.addWidget(self._subheading("Relayout (after inject)"))
layout.addWidget(self._desc(
"Runs WolfDawn on the live Data/ binaries after a successful inject: reflow Message "
"text to the message-box width (page-split overflow), and optionally refit DB "
"description fields. Layout is no longer done at translate time - leave dialogue "
"unwrapped in JSON and let this step fix the box."
))
self._relayout_after_cb = QCheckBox("Relayout after inject")
self._relayout_after_cb.setChecked(
str(self._setting("relayout_after_inject", "true")).lower() != "false"
)
self._relayout_after_cb.stateChanged.connect(
lambda: self._save_setting(
"relayout_after_inject",
"true" if self._relayout_after_cb.isChecked() else "false",
)
)
layout.addWidget(self._relayout_after_cb)
self._relayout_desc_cb = QCheckBox("Also refit DB descriptions (desc-relayout)")
self._relayout_desc_cb.setChecked(
str(self._setting("relayout_desc", "true")).lower() != "false"
)
self._relayout_desc_cb.stateChanged.connect(
lambda: self._save_setting(
"relayout_desc",
"true" if self._relayout_desc_cb.isChecked() else "false",
)
)
layout.addWidget(self._relayout_desc_cb)
msg_row = QHBoxLayout()
width_lbl = QLabel("Message width (cells):")
width_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._relayout_width_spin = QSpinBox()
self._relayout_width_spin.setRange(20, 300)
try:
self._relayout_width_spin.setValue(int(self._setting("relayout_width", 55) or 55))
except (TypeError, ValueError):
self._relayout_width_spin.setValue(55)
self._relayout_width_spin.setFixedWidth(70)
self._relayout_width_spin.valueChanged.connect(
lambda v: self._save_setting("relayout_width", v)
)
max_rows_lbl = QLabel("Max rows (0=default):")
max_rows_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._relayout_max_rows_spin = QSpinBox()
self._relayout_max_rows_spin.setRange(0, 20)
try:
self._relayout_max_rows_spin.setValue(int(self._setting("relayout_max_rows", 0) or 0))
except (TypeError, ValueError):
self._relayout_max_rows_spin.setValue(0)
self._relayout_max_rows_spin.setFixedWidth(70)
self._relayout_max_rows_spin.valueChanged.connect(
lambda v: self._save_setting("relayout_max_rows", v)
)
msg_row.addWidget(width_lbl)
msg_row.addWidget(self._relayout_width_spin)
msg_row.addWidget(max_rows_lbl)
msg_row.addWidget(self._relayout_max_rows_spin)
msg_row.addStretch()
layout.addLayout(msg_row)
desc_row = QHBoxLayout()
desc_width_lbl = QLabel("Desc width (cells):")
desc_width_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._relayout_desc_width_spin = QSpinBox()
self._relayout_desc_width_spin.setRange(20, 300)
try:
self._relayout_desc_width_spin.setValue(
int(self._setting("relayout_desc_width", 75) or 75)
)
except (TypeError, ValueError):
self._relayout_desc_width_spin.setValue(75)
self._relayout_desc_width_spin.setFixedWidth(70)
self._relayout_desc_width_spin.valueChanged.connect(
lambda v: self._save_setting("relayout_desc_width", v)
)
desc_lines_lbl = QLabel("Desc max lines (0=auto):")
desc_lines_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._relayout_desc_lines_spin = QSpinBox()
self._relayout_desc_lines_spin.setRange(0, 20)
try:
self._relayout_desc_lines_spin.setValue(
int(self._setting("relayout_desc_max_lines", 0) or 0)
)
except (TypeError, ValueError):
self._relayout_desc_lines_spin.setValue(0)
self._relayout_desc_lines_spin.setFixedWidth(70)
self._relayout_desc_lines_spin.valueChanged.connect(
lambda v: self._save_setting("relayout_desc_max_lines", v)
)
desc_row.addWidget(desc_width_lbl)
desc_row.addWidget(self._relayout_desc_width_spin)
desc_row.addWidget(desc_lines_lbl)
desc_row.addWidget(self._relayout_desc_lines_spin)
desc_row.addStretch()
layout.addLayout(desc_row)
now_btn = self._register(_make_btn("Relayout Data/ now", "#007acc"))
now_btn.clicked.connect(lambda: self._run_relayout(manual=True))
layout.addWidget(now_btn)
def _translated_or_source(self, json_name: str) -> Path | None:
"""Prefer translated/<name>; fall back to files/<name>."""
for base in ("translated", "files"):
@ -2564,8 +2627,150 @@ class WolfWorkflowTab(QWidget):
msg += ". Review the git diff in the game project and test in-game."
else:
msg += ". Continue to Step 6 to package."
inject_state["applied"] = applied
return failed == 0, msg
def _after_inject(ok: bool, msg: str):
if inject_state.get("applied", 0) > 0 and self._relayout_after_enabled():
# Defer until the inject worker has fully finished (busy flag clear).
QTimer.singleShot(0, lambda: self._run_relayout(manual=False))
inject_state: dict = {"applied": 0}
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 str(self._setting("relayout_after_inject", "true")).lower() != "false"
def _relayout_desc_enabled(self) -> bool:
cb = getattr(self, "_relayout_desc_cb", None)
if cb is not None:
return cb.isChecked()
return str(self._setting("relayout_desc", "true")).lower() != "false"
def _relayout_settings(self) -> dict:
def _spin(attr: str, key: str, default: int) -> int:
w = getattr(self, attr, None)
if w is not None:
return int(w.value())
try:
return int(self._setting(key, default) or default)
except (TypeError, ValueError):
return default
return {
"width": _spin("_relayout_width_spin", "relayout_width", 55),
"max_rows": _spin("_relayout_max_rows_spin", "relayout_max_rows", 0),
"desc_width": _spin("_relayout_desc_width_spin", "relayout_desc_width", 75),
"desc_max_lines": _spin(
"_relayout_desc_lines_spin", "relayout_desc_max_lines", 0
),
}
def _find_db_projects(self, data_dir: Path) -> list[Path]:
"""DataBase.project / CDataBase.project under BasicData or flat Data/."""
found: list[Path] = []
for base in (data_dir / "BasicData", data_dir):
if not base.is_dir():
continue
for name in ("DataBase.project", "CDataBase.project"):
p = base / name
if p.is_file() and p not in found:
found.append(p)
return found
def _sync_relayout_tree(self, src_root: Path, dest_root: Path, log) -> int:
"""Copy every file under *src_root* onto *dest_root*, preserving relative paths."""
copied = 0
for src in src_root.rglob("*"):
if not src.is_file():
continue
rel = src.relative_to(src_root)
dest = dest_root / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
copied += 1
log(f"{rel}")
return copied
def _run_relayout(self, *, manual: bool = False):
"""Reflow Message text (and optionally DB descriptions) on live Data/."""
if not self._require_manifest():
return
manifest = self._read_manifest()
data_dir = Path(manifest["data_dir"])
if not data_dir.is_dir():
QMessageBox.warning(self, "Relayout", f"Data/ folder not found:\n{data_dir}")
return
opts = self._relayout_settings()
do_desc = self._relayout_desc_enabled()
def task(log, progress=None):
from util import wolfdawn
log(
f"Relayout Message text in {data_dir} "
f"(width={opts['width']}"
+ (f", max-rows={opts['max_rows']}" if opts["max_rows"] else "")
+ ") …"
)
with tempfile.TemporaryDirectory(prefix="wolfdawn-relayout-") as tmp:
out_dir = Path(tmp) / "out"
out_dir.mkdir(parents=True, exist_ok=True)
res = wolfdawn.relayout(
data_dir,
out_dir=out_dir,
width=opts["width"],
max_rows=opts["max_rows"] or None,
log_fn=log,
)
if res.stdout:
for line in res.stdout.splitlines():
log(line)
if not res.ok and res.returncode not in (0, 2):
return False, f"relayout exited {res.returncode}"
n = self._sync_relayout_tree(out_dir, data_dir, log)
log(f"Applied {n} reflowed file(s) onto live Data/.")
if do_desc:
projects = self._find_db_projects(data_dir)
if not projects:
log("No DataBase.project / CDataBase.project found — skip desc-relayout.")
for proj in projects:
with tempfile.TemporaryDirectory(prefix="wolfdawn-desc-") as tmp:
tmp_path = Path(tmp)
out_proj = tmp_path / proj.name
log(
f"desc-relayout {proj.name} "
f"(width={opts['desc_width']}, max-lines={opts['desc_max_lines']}) …"
)
dres = wolfdawn.desc_relayout(
proj,
out_proj,
width=opts["desc_width"],
max_lines=opts["desc_max_lines"],
log_fn=log,
)
if dres.stdout:
for line in dres.stdout.splitlines():
log(line)
if not dres.ok and dres.returncode not in (0, 2):
log(f" ⚠ desc-relayout exit {dres.returncode} for {proj.name}")
continue
# WolfDawn may also write a sibling .dat next to the out .project
for produced in tmp_path.iterdir():
if not produced.is_file():
continue
dest = proj.parent / produced.name
shutil.copy2(produced, dest)
log(f"{dest.relative_to(data_dir)}")
return True, "Relayout complete." + (
" Continue to Step 6 to package." if not manual else ""
)
self._run_task(task)
# ── Step 5: Package ────────────────────────────────────────────────────────

View file

@ -25,12 +25,10 @@ names.json, translatable entries are harvested into ``vocab.txt`` (grouped by
``note``, with bilingual headers) so later phases keep item / skill / term names
consistent.
Text wrapping: translated dialogue is re-wrapped to a character width (like the
RPGMaker module, via :mod:`util.dazedwrap`) so English fits WOLF's message box.
Wrapping is applied to the dialogue *body* only - a speaker's nameplate line (the
``\n`` right after a name) is kept on its own line and never folded into the body.
Configurable from the workflow (``.env``: ``wolfWrap`` / ``wolfWidth``); a width of
0 (or ``wolfWrap=false``) writes the model output back verbatim.
Text wrapping: optional advanced ``wolfWrap`` / ``wolfWidth`` (via :mod:`util.dazedwrap`)
can still re-wrap dialogue bodies at translate time, but the normal Wolf workflow
leaves JSON line breaks alone and runs WolfDawn ``relayout`` after inject (Step 5)
to fit the message box. Defaults to off (``wolfWrap=false``).
names.json category wrap (Step 3): selected ``note`` categories can be re-wrapped
with dazedwrap at a dedicated width (``.env``: ``wolfNameWrap``,
@ -94,12 +92,9 @@ SPEAKER_CONFIG = wolf_speakers.load_config()
# names.json: translate per-entry safe/refs badges only (see util.wolf_names).
# Text wrapping: rewrap translated dialogue to a character width the same way the
# RPGMaker module does (util.dazedwrap), so English lines fit WOLF's message box.
# Only the dialogue *body* is wrapped - the speaker's name line (the "\n" after a
# nameplate) is kept on its own line and never merged into the body. Configurable
# from the workflow (.env: wolfWrap / wolfWidth). Width <= 0 disables wrapping.
WRAP = (os.getenv("wolfWrap", "true").strip().lower() == "true")
# Text wrapping: optional advanced rewrap via dazedwrap (wolfWrap/wolfWidth). Defaults
# off - the workflow uses WolfDawn relayout after inject instead.
WRAP = (os.getenv("wolfWrap", "false").strip().lower() == "true")
try:
WRAPWIDTH = int(os.getenv("wolfWidth") or 0)
except ValueError:

View file

@ -418,26 +418,47 @@ class TestNameCategoryWrap(unittest.TestCase):
self.assertEqual(wd._wrap_names_entry(long_text, entry), long_text)
def test_names_translation_wraps_selected_category(self):
# Narrow JP source (8 cells of CJK) so LANGREGEX matches; mock returns long EN.
profile_src = "あいうえ" # 4 CJK → matches LANGREGEX
doc = {
"kind": "names",
"names": [
{"source": "", "text": "", "note": "武器", "safety": "safe"},
{
"source": "alpha beta gamma delta epsilon zeta",
"text": "alpha beta gamma delta epsilon zeta",
"source": profile_src,
"text": profile_src,
"note": "├■プロフィール",
"safety": "safe",
},
],
}
self._set(True, 8, {"├■プロフィール"})
long_en = "alpha beta gamma delta epsilon zeta eta theta"
orig_t = wd.translateAI
orig_estimate = wd.ESTIMATE
orig_wrap = wd.WRAP
wd.WRAP = True
wd.WRAPWIDTH = 4
orig_update = wd.wolf_vocab.update_vocab_section
orig_labels = wd.wolf_names.derive_db_labels
def translate(text, history, history_ctx=None):
out = []
for t in text:
out.append(long_en if t == profile_src else f"EN_{t}")
return [out, [1, 1]]
wd.translateAI = translate
wd.ESTIMATE = False
wd.WRAP = False
wd.wolf_vocab.update_vocab_section = lambda *_a, **_k: None
wd.wolf_names.derive_db_labels = lambda _p: {}
try:
(data, _t, err), _c = _WolfTranslateHarness().run(doc, "names.json")
data, _t, err = wd.parseDocument(copy.deepcopy(doc), "names.json")
finally:
wd.translateAI = orig_t
wd.ESTIMATE = orig_estimate
wd.WRAP = orig_wrap
wd.wolf_vocab.update_vocab_section = orig_update
wd.wolf_names.derive_db_labels = orig_labels
self.assertIsNone(err)
names = {n["note"]: n["text"] for n in data["names"]}
self.assertEqual(names["武器"], "EN_剣")

View file

@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Unit tests for WolfDawn relayout / desc_relayout CLI wrappers."""
from __future__ import annotations
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
os.chdir(ROOT)
sys.path.insert(0, str(ROOT))
from util import wolfdawn # noqa: E402
class TestRelayoutWrappers(unittest.TestCase):
def test_relayout_dry_run_args(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.relayout("/game/Data", width=55, max_rows=0)
self.assertEqual(captured["args"][:2], ["relayout", "/game/Data"])
self.assertIn("--width", captured["args"])
self.assertIn("55", captured["args"])
self.assertNotIn("-o", captured["args"])
self.assertNotIn("--max-rows", captured["args"]) # 0 omitted
def test_relayout_write_args(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.relayout(
"/game/Data",
out_dir="/tmp/out",
width=60,
max_rows=4,
no_resolve=True,
)
args = captured["args"]
self.assertEqual(args[0], "relayout")
self.assertIn("-o", args)
self.assertIn("/tmp/out", args)
self.assertIn("--width", args)
self.assertIn("60", args)
self.assertIn("--max-rows", args)
self.assertIn("4", args)
self.assertIn("--no-resolve", args)
def test_desc_relayout_args(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.desc_relayout(
"/game/Data/BasicData/DataBase.project",
"/tmp/DataBase.project",
width=75,
max_lines=0,
keep_breaks=True,
)
args = captured["args"]
self.assertEqual(args[0], "desc-relayout")
self.assertIn("-o", args)
self.assertIn("/tmp/DataBase.project", args)
self.assertIn("--width", args)
self.assertIn("75", args)
self.assertIn("--max-lines", args)
self.assertIn("0", args)
self.assertIn("--keep-breaks", args)
if __name__ == "__main__":
unittest.main(verbosity=2)

View file

@ -47,6 +47,8 @@ __all__ = [
"pack",
"save_update",
"names_check",
"relayout",
"desc_relayout",
]
# strings-inject: "applied N translation(s) (M drifted)"; names-inject uses "name change(s)".
@ -553,3 +555,77 @@ def save_update(
args.append("--translations")
args += [_str(p) for p in translations]
return _run(args, log_fn=log_fn)
def relayout(
data_dir: PathLike,
out_dir: Optional[PathLike] = None,
*,
width: Optional[int] = None,
width_face: Optional[int] = None,
max_rows: Optional[int] = None,
sub_width: Optional[int] = None,
base_font: Optional[int] = None,
no_formup: bool = False,
no_resolve: bool = False,
log_fn=None,
) -> WolfResult:
"""``wolf relayout <data-dir> [-o <out-dir>] [flags]``.
Without ``-o`` this is a dry run. With ``-o``, only changed map / CommonEvent
files are written under ``out_dir`` (paths relative to ``data_dir``).
"""
args: list[str] = ["relayout", _str(data_dir)]
if out_dir is not None:
args += ["-o", _str(out_dir)]
if width is not None:
args += ["--width", str(int(width))]
if width_face is not None:
args += ["--width-face", str(int(width_face))]
if max_rows is not None and int(max_rows) > 0:
args += ["--max-rows", str(int(max_rows))]
if sub_width is not None:
args += ["--sub-width", str(int(sub_width))]
if base_font is not None:
args += ["--base-font", str(int(base_font))]
if no_formup:
args.append("--no-formup")
if no_resolve:
args.append("--no-resolve")
return _run(args, log_fn=log_fn)
def desc_relayout(
project: PathLike,
output: PathLike,
*,
width: Optional[int] = None,
max_lines: Optional[int] = None,
font: Optional[int] = None,
min_font: Optional[int] = None,
types: Optional[str] = None,
keep_breaks: bool = False,
no_formup: bool = False,
log_fn=None,
) -> WolfResult:
"""``wolf desc-relayout <X.project> -o <out.project> [flags]``.
``width`` may be omitted when ``wolfdawn-roles.json`` defines ``descBoxes``.
``max_lines=0`` means auto (read each field's ``[N行]`` hint).
"""
args: list[str] = ["desc-relayout", _str(project), "-o", _str(output)]
if width is not None:
args += ["--width", str(int(width))]
if max_lines is not None:
args += ["--max-lines", str(int(max_lines))]
if font is not None:
args += ["--font", str(int(font))]
if min_font is not None:
args += ["--min-font", str(int(min_font))]
if types:
args += ["--types", str(types)]
if keep_breaks:
args.append("--keep-breaks")
if no_formup:
args.append("--no-formup")
return _run(args, log_fn=log_fn)