feat(rpgmaker): add face-aware dialogue wrapping

This commit is contained in:
DazedAnon 2026-07-27 22:01:58 -05:00
parent c9bca5b66f
commit 3d81c750fc
10 changed files with 193 additions and 29 deletions

View file

@ -59,6 +59,9 @@ threads="1"
#The wordwrap of dialogue text
width="60"
#The narrower dialogue wrap used when code 101 displays a face graphic
faceWidth="50"
#The wordwrap of items and help text
listWidth="100"

View file

@ -166,7 +166,11 @@ geometry rather than generic RPG Maker defaults whenever project code is availab
6. For dialogue, verify the recommendation against the actual message-row limit, commonly four.
If a long message cannot fit without unreadable font reduction or horizontal overflow, flag it
for pagination/manual reflow instead of forcing an unsafe four-line wrap.
7. Recommend one conservative shared width and one readable font size for each category. If the
7. Detect standard message faces from non-empty code-101 parameter 0 values. Calculate both the
full dialogue width and the reduced `faceWidth`; DazedTL selects `faceWidth` automatically for
those message groups. For plugin portraits outside code 101, document the detection gap and use
a conservative global width or recommend custom handling.
8. Recommend one conservative shared width and one readable font size for each category. If the
current font should remain unchanged, say `keep current` and report its measured size. Cite the
files/functions/plugin parameters supporting every recommendation and give a confidence level.
@ -179,7 +183,7 @@ Label the fence language as `rpgmaker_config`. Inside:
```text
CODE408 : ENABLE|SKIP - <runtime evidence, affected count, and confidence>
Dialogue : width=<DazedTL width> ; font=<px or keep current (measured px)> ; rows=<count>
Dialogue : width=<full width> ; faceWidth=<code-101 face width> ; font=<px or keep current (measured px)> ; rows=<count>
List/Help: listWidth=<DazedTL width> ; font=<px or keep current (measured px)> ; rows=<count or varies>
Notes : noteWidth=<DazedTL width> ; font=<px or keep current (measured px)> ; rows=<count or varies>

View file

@ -1,32 +1,48 @@
You are an expert RPGMaker MV/MZ configuration analyst.
<task>
Calculate the correct text-wrap width settings for a Japanese-to-English RPGMaker MV/MZ translation tool. The tool wraps translated English using character-count limits (not pixels). I need three values: width, listWidth, and noteWidth.
Calculate the correct text-wrap and font recommendations for a Japanese-to-English RPGMaker
MV/MZ translation tool. The tool wraps translated English using character-count limits, not pixels.
I need `width`, `faceWidth`, `listWidth`, and `noteWidth`, plus a font recommendation for dialogue,
list/help text, and notes.
</task>
--- attach System.json and js/plugins.js here before continuing ---
--- open the game repository and inspect its data/ and js/ folders before continuing ---
<instructions>
1. Read screenWidth and fontSize from System.json.
Check js/plugins.js for any MessageCore or Window plugin that overrides these values.
2. For each window type, estimate its pixel width, subtract ~48px padding, then calculate:
chars = floor(content_px / (font_size × 0.58))
- width: main dialogue/message box (Show Text) — typically full screen width
- listWidth: item/skill/help description windows — typically full or half screen width
- noteWidth: database note fields — typically the narrowest pane (~4050% screen width)
3. If font size is above 26px and reducing it would meaningfully increase characters per line, note where to change it (System.json or the relevant plugin parameter).
1. Read the resolution and base fonts from `System.json` and engine code. Inspect enabled entries in
`js/plugins.js` and their plugin sources for message, help, list, note, portrait, font, padding,
line-height, column, and window-size overrides.
2. For dialogue, inspect code-101 commands. A non-empty parameter 0 is a standard face graphic and
reserves horizontal space. Calculate both the full `width` and reduced `faceWidth`; DazedTL
selects `faceWidth` automatically for those message groups. Identify plugin portraits that do
not use code 101 as exceptions requiring a conservative width or custom handling.
3. For Dialogue, List/Help, and Notes, calculate usable pixels from the actual window geometry:
subtract padding, text inset, faces/portraits, icons, columns, and plugin margins. Calculate row
capacity from usable height and line height.
4. Account for the real font face and base size plus `\\{`, `\\}`, `\\FS[n]`, custom font codes,
inline icons/images, and plugin scaling. Measure representative English glyphs in the real font
when possible; otherwise state the conservative average used. Never copy pixels directly into a
character-count setting.
5. Test representative short, long, icon-heavy, control-code-heavy, and font-changed values. Check
dialogue against its real row limit, commonly four. Recommend pagination/manual reflow when a
message cannot fit without unreadable font reduction or horizontal overflow.
6. Recommend a readable font for each category. Use `keep current (<N>px)` when no game-side font
change is needed; otherwise cite the exact plugin parameter or function that would change it.
</instructions>
<output_format>
Output only the final values — do not show calculations:
Output the recommendations followed by compact evidence and exceptions:
```
width=<N>
listWidth=<N>
noteWidth=<N>
fontSize=<N> # or: no change needed
```
Dialogue : width=<N> ; faceWidth=<N> ; font=<Npx or keep current (Npx)> ; rows=<N>
List/Help: listWidth=<N> ; font=<Npx or keep current (Npx)> ; rows=<N or varies>
Notes : noteWidth=<N> ; font=<Npx or keep current (Npx)> ; rows=<N or varies>
Followed by one sentence of assumptions if anything was estimated.
Evidence:
- <window/font/plugin locator and calculation>
Exceptions / playtests:
- <plugin portraits, variant windows, or messages requiring pagination>
```
</output_format>

View file

@ -806,6 +806,18 @@ class ConfigTab(QWidget):
self.width_spin.setSuffix(" chars")
add_row(trans_section, "Dialogue Width:", size_field(self.width_spin))
self.face_width_spin = QSpinBox()
self.face_width_spin.setButtonSymbols(QSpinBox.NoButtons)
self.face_width_spin.setRange(10, 200)
self.face_width_spin.setValue(50)
self.face_width_spin.setSuffix(" chars")
self.face_width_spin.setToolTip(
"Dialogue wrap width when a standard code-101 face graphic reserves window space."
)
self.face_width_spin.setMaximum(self.width_spin.value())
self.width_spin.valueChanged.connect(self.face_width_spin.setMaximum)
add_row(trans_section, "Dialogue + Face:", size_field(self.face_width_spin))
self.list_width_spin = QSpinBox()
self.list_width_spin.setButtonSymbols(QSpinBox.NoButtons)
self.list_width_spin.setRange(20, 200)
@ -1210,6 +1222,7 @@ class ConfigTab(QWidget):
# Formatting settings
self.width_spin.setValue(int(_get("width", "60")))
self.face_width_spin.setValue(int(_get("faceWidth", "50")))
self.list_width_spin.setValue(int(_get("listWidth", "100")))
self.note_width_spin.setValue(int(_get("noteWidth", "75")))
self.convert_quotes_cb.setChecked(
@ -1274,6 +1287,7 @@ class ConfigTab(QWidget):
self.batch_size_spin.editingFinished.connect(self.auto_save)
self.frequency_penalty_spin.editingFinished.connect(self.auto_save)
self.width_spin.editingFinished.connect(self.auto_save)
self.face_width_spin.editingFinished.connect(self.auto_save)
self.list_width_spin.editingFinished.connect(self.auto_save)
self.note_width_spin.editingFinished.connect(self.auto_save)
self.convert_quotes_cb.stateChanged.connect(self._on_convert_quotes_changed)
@ -1299,6 +1313,7 @@ class ConfigTab(QWidget):
self.batch_size_spin.editingFinished.disconnect(self.auto_save)
self.frequency_penalty_spin.editingFinished.disconnect(self.auto_save)
self.width_spin.editingFinished.disconnect(self.auto_save)
self.face_width_spin.editingFinished.disconnect(self.auto_save)
self.list_width_spin.editingFinished.disconnect(self.auto_save)
self.note_width_spin.editingFinished.disconnect(self.auto_save)
self.convert_quotes_cb.stateChanged.disconnect(self._on_convert_quotes_changed)
@ -1358,6 +1373,7 @@ class ConfigTab(QWidget):
"batchsize": str(self.batch_size_spin.value()),
"frequency_penalty": str(self.frequency_penalty_spin.value()),
"width": str(self.width_spin.value()),
"faceWidth": str(min(self.width_spin.value(), self.face_width_spin.value())),
"listWidth": str(self.list_width_spin.value()),
"noteWidth": str(self.note_width_spin.value()),
"convertQuotes": "true" if self.convert_quotes_cb.isChecked() else "false",
@ -1422,6 +1438,7 @@ class ConfigTab(QWidget):
# Formatting settings
self.width_spin.setValue(60)
self.face_width_spin.setValue(50)
self.list_width_spin.setValue(100)
self.note_width_spin.setValue(75)
self.convert_quotes_cb.setChecked(True)
@ -1468,6 +1485,7 @@ class ConfigTab(QWidget):
"batchsize": self.batch_size_spin.value(),
"frequency_penalty": self.frequency_penalty_spin.value(),
"width": self.width_spin.value(),
"faceWidth": min(self.width_spin.value(), self.face_width_spin.value()),
"listWidth": self.list_width_spin.value(),
"noteWidth": self.note_width_spin.value(),
"convertQuotes": self.convert_quotes_cb.isChecked(),

View file

@ -1744,13 +1744,14 @@ class WorkflowTab(QWidget):
wrap_hint = QLabel(
"Use Project Setup's measured rpgmaker_config recommendations, then apply the three "
"wrap widths to .env. Font recommendations describe the game UI and are not applied here."
"category widths plus the dialogue-face variant to .env. Font recommendations "
"describe the game UI and are not applied here."
)
wrap_hint.setWordWrap(True)
wrap_hint.setStyleSheet("color:#9d9d9d;font-size:13px;")
wrap_inner.addWidget(wrap_hint)
# All three spinboxes on one row
# Wrap settings on one row, including the narrower code-101 face variant.
spins_row = QHBoxLayout()
spins_row.setSpacing(16)
@ -1764,10 +1765,14 @@ class WorkflowTab(QWidget):
return lbl, sp
lbl_w, self.wrap_width_spin = _spin_pair("Dialogue", 60)
lbl_fw, self.wrap_face_spin = _spin_pair("Dialogue+Face", 50)
self.wrap_face_spin.setMaximum(self.wrap_width_spin.value())
self.wrap_width_spin.valueChanged.connect(self.wrap_face_spin.setMaximum)
lbl_lw, self.wrap_list_spin = _spin_pair("List/Help", 70)
lbl_nw, self.wrap_note_spin = _spin_pair("Notes", 60)
for lbl, sp in [(lbl_w, self.wrap_width_spin),
(lbl_fw, self.wrap_face_spin),
(lbl_lw, self.wrap_list_spin),
(lbl_nw, self.wrap_note_spin)]:
spins_row.addWidget(lbl)
@ -1776,7 +1781,9 @@ class WorkflowTab(QWidget):
apply_wrap_btn = _make_btn("✔ Apply to .env", "#3a7a3a")
apply_wrap_btn.setFixedWidth(140)
apply_wrap_btn.setToolTip("Write width / listWidth / noteWidth into .env")
apply_wrap_btn.setToolTip(
"Write width / faceWidth / listWidth / noteWidth into .env"
)
apply_wrap_btn.clicked.connect(self._apply_wrap_config)
spins_row.addWidget(apply_wrap_btn)
@ -3840,10 +3847,11 @@ class WorkflowTab(QWidget):
self._log(f"❌ Could not copy {filename}: {exc}")
def _apply_wrap_config(self):
"""Write width / listWidth / noteWidth back into .env."""
"""Write dialogue, face-dialogue, list, and note widths back into .env."""
import re as _re
updates = {
"width": str(self.wrap_width_spin.value()),
"faceWidth": str(min(self.wrap_width_spin.value(), self.wrap_face_spin.value())),
"listWidth": str(self.wrap_list_spin.value()),
"noteWidth": str(self.wrap_note_spin.value()),
}

View file

@ -28,6 +28,10 @@ VOCAB = VOCAB_PATH.read_text(encoding="utf-8")
LOCK = threading.Lock()
THREAD_CTX = threading.local()
WIDTH = int(os.getenv("width"))
FACEWIDTH = max(
1,
min(WIDTH, int(os.getenv("faceWidth", str(max(1, WIDTH - 10))))),
)
LISTWIDTH = int(os.getenv("listWidth"))
NOTEWIDTH = int(os.getenv("noteWidth"))
MAXHISTORY = 10
@ -728,6 +732,16 @@ def _facename101_speaker(cmd) -> str | None:
return None
def _101_has_face_graphic(cmd) -> bool:
"""Return whether a standard code-101 command reserves face-image space."""
params = cmd.get("parameters") or []
return (
len(params) >= 4
and isinstance(params[0], str)
and bool(params[0].strip())
)
def _entry_orig(entry) -> dict:
"""Return _original dict on a database entry, or empty dict if absent."""
orig = entry.get("_original") if isinstance(entry, dict) else None
@ -2332,7 +2346,7 @@ def searchCodes(page, pbar, jobList, filename):
syncIndex = 0
maxHistory = MAXHISTORY
VNameValue = None
reduceWidthFlag = False # Track if 101 code has non-empty first parameter
reduceWidthFlag = False # Track whether the active 101 reserves face space
global LOCK
global NAMESLIST
global MISMATCH
@ -2366,6 +2380,17 @@ def searchCodes(page, pbar, jobList, filename):
currentSourceGroup = []
nametag = ""
# Face layout affects the following message even when code-101 name
# translation is disabled or face-to-speaker resolution exits early.
if "code" in codeList[i] and codeList[i]["code"] == 101:
reduceWidthFlag = _101_has_face_graphic(codeList[i])
elif (
reduceWidthFlag
and "code" in codeList[i]
and codeList[i]["code"] not in (401, -1)
):
reduceWidthFlag = False
## Event Code: 401 Show Text
if "code" in codeList[i] and codeList[i]["code"] in [401, 405, -1] and ((codeList[i]["code"] in [401, -1] and CODE401) or (codeList[i]["code"] == 405 and CODE405)):
# Save Code and starting index (j)
@ -2756,7 +2781,7 @@ def searchCodes(page, pbar, jobList, filename):
finalJAString = finalJAString.replace("<br>", " ")
# Determine width based on reduceWidthFlag
currentWidth = WIDTH - 15 if reduceWidthFlag else WIDTH
currentWidth = FACEWIDTH if reduceWidthFlag else WIDTH
if FIXTEXTWRAP is True and "_ABL" in nametag:
translatedText = dazedwrap.wrapText(translatedText, width=100)
@ -3205,9 +3230,6 @@ def searchCodes(page, pbar, jobList, filename):
# Grab String
jaString = ""
if len(codeList[i]["parameters"]) > 4:
# Set flag if first parameter has a non-empty string
if isinstance(codeList[i]["parameters"][0], str) and codeList[i]["parameters"][0].strip():
reduceWidthFlag = True
jaString = codeList[i]["parameters"][4]
# Check for Var (only when parameters[0] is not a face file,
# i.e. fewer than 4 params — standard code 101 always has 4:

View file

@ -38,6 +38,7 @@ class ConfigTabRegressionTests(unittest.TestCase):
"batchsize",
"frequency_penalty",
"width",
"faceWidth",
"listWidth",
"noteWidth",
"convertQuotes",
@ -89,6 +90,7 @@ class ConfigTabRegressionTests(unittest.TestCase):
"batchsize=42",
"frequency_penalty=0.75",
"width=88",
"faceWidth=77",
"listWidth=99",
"noteWidth=111",
"convertQuotes=false",
@ -142,6 +144,7 @@ class ConfigTabRegressionTests(unittest.TestCase):
self.assertEqual(tab.batch_size_spin.value(), 42)
self.assertAlmostEqual(tab.frequency_penalty_spin.value(), 0.75)
self.assertEqual(tab.width_spin.value(), 88)
self.assertEqual(tab.face_width_spin.value(), 77)
self.assertEqual(tab.list_width_spin.value(), 99)
self.assertEqual(tab.note_width_spin.value(), 111)
self.assertFalse(tab.convert_quotes_cb.isChecked())
@ -206,6 +209,7 @@ class ConfigTabRegressionTests(unittest.TestCase):
tab.language_combo,
tab.timeout_spin,
tab.width_spin,
tab.face_width_spin,
tab.list_width_spin,
tab.note_width_spin,
),
@ -319,6 +323,7 @@ class ConfigTabRegressionTests(unittest.TestCase):
tab.batch_size_spin.setValue(64)
tab.frequency_penalty_spin.setValue(1.25)
tab.width_spin.setValue(72)
tab.face_width_spin.setValue(62)
tab.list_width_spin.setValue(84)
tab.note_width_spin.setValue(96)
tab.convert_quotes_cb.setChecked(True)
@ -350,6 +355,7 @@ class ConfigTabRegressionTests(unittest.TestCase):
"batchsize": "64",
"frequency_penalty": "1.25",
"width": "72",
"faceWidth": "62",
"listWidth": "84",
"noteWidth": "96",
"convertQuotes": "true",
@ -374,6 +380,7 @@ class ConfigTabRegressionTests(unittest.TestCase):
self.assertEqual(reloaded.batch_size_spin.value(), 64)
self.assertAlmostEqual(reloaded.frequency_penalty_spin.value(), 1.25)
self.assertEqual(reloaded.width_spin.value(), 72)
self.assertEqual(reloaded.face_width_spin.value(), 62)
self.assertEqual(reloaded.list_width_spin.value(), 84)
self.assertEqual(reloaded.note_width_spin.value(), 96)
self.assertTrue(reloaded.convert_quotes_cb.isChecked())
@ -409,6 +416,7 @@ class ConfigTabRegressionTests(unittest.TestCase):
self.assertEqual(tab.batch_size_spin.value(), 30)
self.assertAlmostEqual(tab.frequency_penalty_spin.value(), 0.05)
self.assertEqual(tab.width_spin.value(), 60)
self.assertEqual(tab.face_width_spin.value(), 50)
self.assertEqual(tab.list_width_spin.value(), 100)
self.assertEqual(tab.note_width_spin.value(), 75)
self.assertTrue(tab.convert_quotes_cb.isChecked())
@ -431,6 +439,7 @@ class ConfigTabRegressionTests(unittest.TestCase):
"batchsize": "30",
"frequency_penalty": "0.05",
"width": "60",
"faceWidth": "50",
"listWidth": "100",
"noteWidth": "75",
"convertQuotes": "true",

View file

@ -10,6 +10,7 @@ import re
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
os.chdir(ROOT)
@ -192,6 +193,80 @@ def _has_japanese(s: str) -> bool:
class TestMVMZSourceOriginal(unittest.TestCase):
def test_code101_face_detection_supports_mv_and_mz_shapes(self):
self.assertTrue(
mvmz._101_has_face_graphic(
{"parameters": ["Actor1", 0, 0, 2]}
)
)
self.assertTrue(
mvmz._101_has_face_graphic(
{"parameters": ["Actor1", 0, 0, 2, "Alice"]}
)
)
self.assertFalse(
mvmz._101_has_face_graphic(
{"parameters": ["", 0, 0, 2, "Alice"]}
)
)
def test_code101_face_uses_configured_face_width_after_speaker_resolution(self):
page = {
"list": [
{
"code": 101,
"indent": 0,
"parameters": ["___princess1", 0, 0, 2, ""],
},
{
"code": 401,
"indent": 0,
"parameters": ["これは顔付きの長い台詞です。"],
},
]
}
widths = []
def capture_wrap(text, width):
widths.append(width)
return text
with (
patch.object(mvmz, "WIDTH", 60),
patch.object(mvmz, "FACEWIDTH", 50),
patch.object(mvmz, "FACENAME101", True),
patch.object(mvmz.dazedwrap, "wrapText", side_effect=capture_wrap),
):
_run_search_codes(page)
self.assertEqual(widths, [50])
def test_code101_without_face_uses_full_dialogue_width(self):
page = {
"list": [
{"code": 101, "indent": 0, "parameters": ["", 0, 0, 2, ""]},
{
"code": 401,
"indent": 0,
"parameters": ["これは通常幅の台詞です。"],
},
]
}
widths = []
def capture_wrap(text, width):
widths.append(width)
return text
with (
patch.object(mvmz, "WIDTH", 60),
patch.object(mvmz, "FACEWIDTH", 50),
patch.object(mvmz.dazedwrap, "wrapText", side_effect=capture_wrap),
):
_run_search_codes(page)
self.assertEqual(widths, [60])
def test_choice_condition_prefix_parser_handles_nested_calls(self):
prefix, label = mvmz._split_choice_condition_prefix(
"if($gameSwitches.value(1) && v[31]>=4)迷宮四階"

View file

@ -35,6 +35,7 @@ class TranslationEngineDropdownTests(unittest.TestCase):
"language",
"timeout",
"width",
"faceWidth",
"listWidth",
"noteWidth",
)

View file

@ -59,6 +59,7 @@ class WorkflowTranslationPromptTests(unittest.TestCase):
self.assertIn("code408 : enable|skip", lowered)
self.assertIn("translate code 408 plugin/comment text", lowered)
self.assertIn("dialogue : width=", lowered)
self.assertIn("facewidth=<code-101 face width>", lowered)
self.assertIn("list/help: listwidth=", lowered)
self.assertIn("notes : notewidth=", lowered)
self.assertIn("face/portrait reservation", lowered)
@ -103,6 +104,13 @@ class WorkflowTranslationPromptTests(unittest.TestCase):
self.assertIn("QA Game Data Skill", workflow_source)
self.assertIn('load_clipboard_skill("rpgmaker_translation_qa.md")', workflow_source)
def test_wrap_prompt_accounts_for_code101_faces_and_font_changes(self):
prompt = load_clipboard_skill("wrap_config.md").casefold()
self.assertIn("facewidth", prompt)
self.assertIn("non-empty parameter 0", prompt)
self.assertIn("plugin portraits", prompt)
self.assertIn("pagination/manual reflow", prompt)
def test_image_translation_prompt_ends_skips_with_recovery_options(self):
prompt = load_clipboard_skill("image_translation.md")