From 253cc47bc5cfbd2bc143fb61db911e65fd4ca144 Mon Sep 17 00:00:00 2001 From: DragonHD Date: Sun, 20 Sep 2026 11:25:04 +0800 Subject: [PATCH] fix: correct CJK tooltip layout and center option titles --- docs/DEVLOG.md | 48 +++++++++++++ docs/patch-notes.md | 43 +++++++++++ tools/apply_cjk.py | 147 +++++++++++++++++++++++++++++++++++++ tools/cn_strings.py | 2 +- tools/test_cjk_layout.py | 152 +++++++++++++++++++++++++++++++++++++++ tools/test_cjk_titles.py | 59 +++++++++++++++ 6 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 tools/test_cjk_layout.py create mode 100644 tools/test_cjk_titles.py diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index f4e23d4..c16cdb1 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -3,6 +3,54 @@ Working log for the *The I of the Dragon* localization / HD project. Newest entries on top. +## 2026-09-20 — Center option titles and shorten tutorial exit prompt + +- User confirmed the HUD tooltip fix in game. +- Original tutorial text was `\aOK - quit, Cancel - continue tutorial.`; + replaced the verbose literal Chinese translation with `\a退出教程?`. +- Audio, game, video and controls titles all used fixed left x=620 before + resolution scaling. Their panel/button center is reference x=640. At + 1280px, the 24px Chinese controls title was consequently 20px left of the + Bind button center. Title-only draw wrappers now measure actual text width + and subtract half from the transformed x=640 anchor, after screen scaling. +- `test_cjk_titles.py` executes all four original title call sites with three + text widths plus 1280/1920 screen-width cases. Both title tests and all 14 + tooltip/layout tests pass. Game source patcher and translated Strings.dat + are deployed locally; the user confirmed both fixes in game. + +## 2026-09-20 — Fix Chinese HUD tooltip overlap + +- Reproduced the reported world-map/minimap tooltip bug in the game's actual + x86 wrapped-text routine (`0x4ED380`) using Unicorn. It builds a one-byte + temporary string before measuring each character, so the existing DBCS + metrics hook measured isolated GB2312 bytes as width/height zero. This + miscentered Chinese lines and advanced the next line by only the 5px line + distance, overlapping the 12px Chinese artwork. +- Added pair-aware read, append, and fit caves to `tools/apply_cjk.py`. + GB2312 pairs now reach the measurer together, are appended/consumed together, + and become complete-character wrap checkpoints. Initialized empty-line + checkpoints correctly so mixed ASCII/CJK wraps retain the prior line height. + A glyph wider than the available box is emitted intact rather than losing + its lead byte; existing caller clipping remains responsible for box bounds. +- Added `tools/test_cjk_layout.py`: executes the real layout/measurement + instructions and captures only the Direct3D draw boundary. Fourteen tests + cover screenshot tooltip strings, shortcuts, centering, control codes, + measure-only height, mixed text, whitespace, narrow boxes, and oversize glyphs. + The original overlap case changes from `(Chinese x=24,y=0; S x=20,y=5)` to + `(Chinese x=12,y=0; S x=20,y=17)` in a 48px box. +- No atlas, translation, font-size, or mission-script changes are needed. + Original exe and patcher snapshots: `backup_game/font-layout-20260920/`. + Local deployed program: `F:\steam\steamapps\common\The I of the Dragon\TheIOfTheDragon.exe`. + +Validation (requires `unicorn`): +`python tools/test_cjk_layout.py --exe build/cjk-layout-fixed.exe -v` + +Progress audit: local HEAD matched Gitea `ca57488` before this fix; 1884 texture +entries / 1882 preprocessed PNGs, 1397 geometry entries, and 479/496 translated +Strings.dat IDs. Mission scripts, description tables, baked text, and HD +upscaling remain separate unfinished work. Other-font CJK coverage is also +still limited as documented in patch-notes.md. + ## 2026-09-20 — Full `Strings.dat` translation - Translated the `English` column for **479 / 496** entries (the rest are pure-format diff --git a/docs/patch-notes.md b/docs/patch-notes.md index 83546ae..6c625fb 100644 --- a/docs/patch-notes.md +++ b/docs/patch-notes.md @@ -100,6 +100,49 @@ Shrift_gb_Germany.TGA 2048 x 1024, 32bpp TGA (type 2, bottom-up, desc 0x08, 26 ## Tools +### Option-title centering + +Four original title sites push reference x=620 and directly draw text. The +panel/Bind button center is reference x=640 (`0x4D8646`). Patch the title +anchors to x=640 and redirect their draw calls to `.cjk+0x180500`: + +| Page | Anchor instruction | Draw call | +|---|---|---| +| Audio | `0x4D748C` | `0x4D74A6` | +| Game | `0x4D9A1C` | `0x4D9A36` | +| Video | `0x4DDAB4` | `0x4DDACE` | +| Controls | `0x4DE8C8` | `0x4DE8E2` | + +The wrapper preserves registers, measures the supplied font/text using +`0x4E7FD0`, subtracts half the measured width from the already transformed +screen point, and tail-jumps to `0x4EAB70`. Ordinary option rows and unrelated +draw calls retain their existing placement. Input opcodes are checked before +these title edits. `tools/test_cjk_titles.py` tests the actual call-site code, +coordinate transformation, measurement, and resulting draw positions. + +### Wrapped-text layout correction + +`0x4ED380` wraps/aligns tooltip paragraphs separately from the plain draw and +measure loops. Its one-byte temporary string must be extended to a full GB2312 +character, otherwise a Chinese-only line has zero measured glyph height. + +New hooks (all addresses apply to the documented Steam executable): + +| Hook | Cave offset in .cjk | Purpose | +|---|---|---| +| `0x4ED430` | `0x180200` | Prepare a NUL-terminated one/two-byte character; preserve control-code flags | +| `0x4ED50E` | `0x180300` | Append complete character, advance byte index, retain width/height at CJK break | +| `0x4ED4E9` | `0x180400` | Preserve an overwide first CJK glyph instead of consuming only its lead byte | + +Layout locals: `esp+0x18..0x1A` character plus NUL, `+0x1B` byte length, +`+0x14` source byte index, `+0x24` output byte count, `+0x1C` last wrap +checkpoint. Empty output initializes the checkpoint to `source_index-1`. +The caves use spare space after the metric table; UVs and atlas pixels are +unchanged. `tools/test_cjk_layout.py` runs the original layout and measurement +machine code in Unicorn and captures line bytes and coordinates at the draw +call boundary. This checks layout behavior without replacing it with a Python +reimplementation. + - `tools/add_section.py` — append an executable PE section. - `tools/apply_cjk.py` — assemble the caves (keystone), add `.cjk`, write hooks and tables. - `tools/build_cjk.py` — build the atlas TGA + `cjk_glyph`/`cjk_metric` from a charset. diff --git a/tools/apply_cjk.py b/tools/apply_cjk.py index 851e1ac..dd9afc4 100644 --- a/tools/apply_cjk.py +++ b/tools/apply_cjk.py @@ -15,6 +15,21 @@ OFF_DRAW = 0x0000 OFF_MEAS = 0x0100 OFF_GLYPH = 0x0200 OFF_METRIC = 0x100200 +OFF_LAYOUT_READ = 0x180200 +OFF_LAYOUT_APPEND = 0x180300 +OFF_LAYOUT_FIT = 0x180400 +OFF_TITLE_CENTER = 0x180500 + +# Fixed left edge at x=620 was tuned for English titles. The panel and Bind +# button are centered at reference x=640 (scaled by the engine from 800px). +TITLE_SITES = ((0x4D748C, 0x4D74A6), (0x4D9A1C, 0x4D9A36), + (0x4DDAB4, 0x4DDACE), (0x4DE8C8, 0x4DE8E2)) + +# The wrapped-text path has its own one-byte temporary string and output +# buffer. It must measure and append complete GB2312 characters too. +HOOK_LAYOUT_READ_VA = 0x4ED430 +HOOK_LAYOUT_APPEND_VA = 0x4ED50E +HOOK_LAYOUT_FIT_VA = 0x4ED4E9 def align(x, a): @@ -130,6 +145,124 @@ def main(orig=ORIG, out=OUT, tbl=TBL): meas_code = bytes(meas_code) print("draw_cave %d bytes @0x%x, metrics_cave %d bytes @0x%x" % (len(draw_code), draw_va, len(meas_code), meas_va)) + # Locals in 0x4ED380: +18..1A = character + NUL, +1B = byte count. + # +14 is the source byte index, +24 the output byte count. The two + # formerly unused bytes at +1A/+1B precede the +1C wrap checkpoint. + # Preserve flags from cmp al,7: the original continuation uses jne. + layout_read_asm = """ + mov byte ptr [esp + 0x18], al + mov word ptr [esp + 0x19], 0 + mov byte ptr [esp + 0x1B], 1 + pushfd + push eax + cmp dword ptr [esp + 0x2C], 0 + jne checkpoint_ready + mov eax, ecx + dec eax + mov [esp + 0x24], eax + mov al, byte ptr [esp + 0x20] + checkpoint_ready: + cmp al, 0xA1 + jb done + cmp al, 0xF7 + ja done + cmp ecx, edx + jae done + mov eax, [esp + 0x450] + mov al, byte ptr [eax + ecx + 1] + cmp al, 0xA1 + jb done + cmp al, 0xFE + ja done + mov byte ptr [esp + 0x21], al + mov byte ptr [esp + 0x23], 2 + done: + pop eax + popfd + jmp 0x4ED439 + """ + # Consume the second source byte only AFTER the character fits. On + # overflow the stock wrap code must retry the lead byte on the next line. + layout_append_asm = """ + mov eax, [esp + 0x24] + mov cl, byte ptr [esp + 0x12] + mov byte ptr [esp + eax + 0x4C], cl + inc eax + cmp byte ptr [esp + 0x1B], 2 + jne done + mov cl, byte ptr [esp + 0x19] + mov byte ptr [esp + eax + 0x4C], cl + inc eax + inc dword ptr [esp + 0x14] + done: + mov [esp + 0x24], eax + mov byte ptr [esp + eax + 0x4C], 0 + mov ecx, [esp + 0x20] + mov eax, [esp + 0x40] + cmp eax, ecx + jle height_done + mov [esp + 0x20], eax + height_done: + mov esi, edx + cmp byte ptr [esp + 0x1B], 2 + jne appended + mov eax, [esp + 0x14] + mov [esp + 0x1C], eax + mov ebp, esi + mov eax, [esp + 0x20] + mov [esp + 0x2C], eax + appended: + xor bl, bl + jmp 0x4ED67C + """ + # A box narrower than a glyph must still consume the whole character. + # Emit it on its own line instead of dropping its lead byte. The caller + # retains its normal clipping behavior for the overwide line. + layout_fit_asm = """ + lea edx, [eax + esi] + cmp edx, ecx + jle fits + cmp dword ptr [esp + 0x24], 0 + jne overflow + cmp byte ptr [esp + 0x1B], 2 + jne overflow + fits: + jmp 0x4ED4F0 + overflow: + jmp 0x4ED53D + """ + layout_read_code = bytes(ks.asm(layout_read_asm, sec_abs + OFF_LAYOUT_READ)[0]) + layout_append_code = bytes(ks.asm(layout_append_asm, sec_abs + OFF_LAYOUT_APPEND)[0]) + layout_fit_code = bytes(ks.asm(layout_fit_asm, sec_abs + OFF_LAYOUT_FIT)[0]) + # Same ABI as DrawText: point/font/color/text/justification. The incoming + # point is already transformed to screen pixels; subtract the measured + # width in pixels AFTER transformation (glyphs do not scale with it). + title_center_asm = """ + pushad + sub esp, 8 + mov ecx, [esp + 0x30] + mov eax, [esp + 0x38] + mov edx, esp + push 0 + push eax + push edx + call 0x4E7FD0 + mov eax, [esp] + shr eax, 1 + mov edx, [esp + 0x2C] + sub [edx], eax + add esp, 8 + popad + jmp 0x4EAB70 + """ + title_center_code = bytes(ks.asm(title_center_asm, sec_abs + OFF_TITLE_CENTER)[0]) + assert len(draw_code) <= OFF_MEAS - OFF_DRAW + assert len(meas_code) <= OFF_GLYPH - OFF_MEAS + assert len(layout_read_code) <= OFF_LAYOUT_APPEND - OFF_LAYOUT_READ + assert len(layout_append_code) <= OFF_LAYOUT_FIT - OFF_LAYOUT_APPEND + assert len(layout_fit_code) <= OFF_TITLE_CENTER - OFF_LAYOUT_FIT + assert OFF_TITLE_CENTER + len(title_center_code) <= SEC_SIZE + if so + (nsec + 1) * 40 > secs[0][2]: print("no room for section header", file=sys.stderr) sys.exit(3) @@ -151,6 +284,10 @@ def main(orig=ORIG, out=OUT, tbl=TBL): glyph, metric = load_tables(tbl) data[sec_off + OFF_GLYPH: sec_off + OFF_GLYPH + len(glyph)] = glyph data[sec_off + OFF_METRIC: sec_off + OFF_METRIC + len(metric)] = metric + data[sec_off + OFF_LAYOUT_READ: sec_off + OFF_LAYOUT_READ + len(layout_read_code)] = layout_read_code + data[sec_off + OFF_LAYOUT_APPEND: sec_off + OFF_LAYOUT_APPEND + len(layout_append_code)] = layout_append_code + data[sec_off + OFF_LAYOUT_FIT: sec_off + OFF_LAYOUT_FIT + len(layout_fit_code)] = layout_fit_code + data[sec_off + OFF_TITLE_CENTER: sec_off + OFF_TITLE_CENTER + len(title_center_code)] = title_center_code def hook(va, target): off = va - imagebase @@ -164,6 +301,16 @@ def main(orig=ORIG, out=OUT, tbl=TBL): hook(HOOK_DRAW_VA, draw_va) hook(HOOK_MEAS_VA, meas_va) + hook(HOOK_LAYOUT_READ_VA, sec_abs + OFF_LAYOUT_READ) + hook(HOOK_LAYOUT_APPEND_VA, sec_abs + OFF_LAYOUT_APPEND) + hook(HOOK_LAYOUT_FIT_VA, sec_abs + OFF_LAYOUT_FIT) + for anchor, call_site in TITLE_SITES: + anchor_off, call_off = anchor - imagebase, call_site - imagebase + expected_call = bytes(ks.asm('call 0x4EAB70', call_site)[0]) + if data[anchor_off:anchor_off + 5] != b'\x68\x6c\x02\x00\x00' or data[call_off:call_off + 5] != expected_call: + raise ValueError('unexpected option-title instructions at 0x%x' % anchor) + data[anchor_off:anchor_off + 5] = bytes(ks.asm('push 0x280', anchor)[0]) + data[call_off:call_off + 5] = bytes(ks.asm('call %s' % hex(sec_abs + OFF_TITLE_CENTER), call_site)[0]) os.makedirs(os.path.dirname(out), exist_ok=True) open(out, "wb").write(data) diff --git a/tools/cn_strings.py b/tools/cn_strings.py index b35c8ef..92c73ac 100644 --- a/tools/cn_strings.py +++ b/tools/cn_strings.py @@ -406,7 +406,7 @@ CN_STRINGS = { "Overhead_DeadDialog_Multiplayer_Text": "\\a已重生", "Overhead_Multiplayer_GameOver": "\\a游戏结束", "Overhead_Multiplayer_MainMenu": "\\a确定 - 退出,取消 - 继续游戏。", - "Overhead_Tutorial_MainMenu": "\\a确定 - 退出,取消 - 继续教程。", + "Overhead_Tutorial_MainMenu": "\\a退出教程?", "Overhead_StoneType0": "\\a红色魔法石\\r\\n\\f在劫掠城市时,一些怪物获得了若干神圣水晶。它们通常把宝物藏在巢穴中。将这些水晶集齐,便会成为相当强大的力量源泉。若能收集齐一整套,这股力量便可用来对抗邪恶。", "Overhead_StoneType1": "\\a绿色魔法石\\r\\n\\f在劫掠城市时,一些怪物获得了若干神圣水晶。它们通常把宝物藏在巢穴中。将这些水晶集齐,便会成为相当强大的力量源泉。若能收集齐一整套,这股力量便可用来对抗邪恶。", "Overhead_StoneType2": "\\a蓝色魔法石\\r\\n\\f在劫掠城市时,一些怪物获得了若干神圣水晶。它们通常把宝物藏在巢穴中。将这些水晶集齐,便会成为相当强大的力量源泉。若能收集齐一整套,这股力量便可用来对抗邪恶。", diff --git a/tools/test_cjk_layout.py b/tools/test_cjk_layout.py new file mode 100644 index 0000000..3b06456 --- /dev/null +++ b/tools/test_cjk_layout.py @@ -0,0 +1,152 @@ +"""Run the game's actual x86 layout/measurement code without Direct3D. + +Requires unicorn. --exe is a locally generated executable, never redistributed. +The draw boundary is intercepted to inspect the exact strings/positions that +the engine submits. All wrapping and measuring instructions execute unchanged. +""" +import argparse +import struct +import unittest +from pathlib import Path + +from unicorn import Uc, UC_ARCH_X86, UC_MODE_32, UC_HOOK_CODE +from unicorn.x86_const import UC_X86_REG_ESP, UC_X86_REG_ECX, UC_X86_REG_EIP + +DEFAULT_EXE = r"F:\steam\steamapps\common\The I of the Dragon\TheIOfTheDragon.exe" +EXE_PATH = DEFAULT_EXE + + +def make_machine(): + data = Path(EXE_PATH).read_bytes() + pe = struct.unpack_from(' 1024: + raise AssertionError('unterminated output line') + lines.append((bytes(buf), *ints(pos, 2))) + machine.reg_write(UC_X86_REG_ESP, sp + 24) + machine.reg_write(UC_X86_REG_EIP, ret) + uc.hook_add(UC_HOOK_CODE, draw, begin=0x4eab70, end=0x4eab70) + uc.emu_start(0x4ed380, stop, count=1000000) + if uc.reg_read(UC_X86_REG_EIP) != stop: + raise AssertionError('layout did not terminate') + return lines, ints(0x2000020, 1)[0] + + +class LayoutTests(unittest.TestCase): + def test_chinese_label_does_not_overlap_shortcut(self): + lines, _ = layout('停止\nS') + self.assertEqual(lines, [('停止'.encode('gb2312'), 12, 0), (b'S', 20, 17)]) + + def test_two_chinese_lines_and_shortcut(self): + lines, _ = layout('建造城镇\nCtrl+B', width=48) + self.assertEqual(lines, [('建造城镇'.encode('gb2312'), 0, 0), (b'Ctrl+B', 0, 17)]) + + def test_wrap_never_splits_a_chinese_character(self): + lines, _ = layout('开始新游戏', width=24) + self.assertEqual(lines, [('开始'.encode('gb2312'), 0, 0), + ('新游'.encode('gb2312'), 0, 17), + ('戏'.encode('gb2312'), 6, 34)]) + + def test_mixed_ascii_chinese_width(self): + lines, _ = layout('A龙B\nS', width=48) + self.assertEqual(lines, [('A龙B'.encode('gb2312'), 10, 0), (b'S', 20, 17)]) + + def test_ascii_layout_unchanged(self): + lines, _ = layout('Stop\nS') + self.assertEqual(lines, [(b'Stop', 8, 0), (b'S', 20, 17)]) + + def test_measure_only_height_matches_rendered_lines(self): + _, height = layout('停止\nS', measure_only=True) + self.assertEqual(height, 34) + + def test_alignment_control_codes_preserve_flags(self): + for prefix, initial, x, shortcut_x in [('\a', 0, 12, 20), + ('\b', 2, 0, 0), + ('\v', 0, 24, 40)]: + with self.subTest(prefix=repr(prefix)): + lines, _ = layout(prefix + '停止\nS', alignment=initial) + self.assertEqual(lines, [('停止'.encode('gb2312'), x, 0), + (b'S', shortcut_x, 17)]) + + def test_space_wrap_preserves_chinese_and_shortcut(self): + lines, _ = layout('停止 停止\nS', width=24) + self.assertEqual(lines, [('停止'.encode('gb2312'), 0, 0), + ('停止'.encode('gb2312'), 0, 17), (b'S', 8, 34)]) + + def test_mixed_word_wrap_retries_complete_character(self): + lines, _ = layout('A龙B龙C', width=24) + self.assertEqual(lines, [('A龙'.encode('gb2312'), 2, 0), + ('B龙'.encode('gb2312'), 2, 17), (b'C', 8, 34)]) + + def test_one_character_wide_box(self): + lines, _ = layout('龙龙龙龙', width=12) + self.assertEqual(lines, [(b'\xc1\xfa', 0, 0), (b'\xc1\xfa', 0, 17), + (b'\xc1\xfa', 0, 34), (b'\xc1\xfa', 0, 51)]) + + def test_oversize_character_remains_intact(self): + lines, _ = layout('龙A', width=8, alignment=0) + self.assertEqual(lines, [(b'\xc1\xfa', 0, 0), (b'A', 0, 17)]) + + def test_first_mixed_wrap_has_real_height(self): + lines, _ = layout('AB龙', width=24) + self.assertEqual(lines, [(b'AB', 4, 0), (b'\xc1\xfa', 6, 17)]) + + def test_world_map_tooltip_from_user_screenshot(self): + lines, _ = layout('\a世界地图', width=96) + self.assertEqual(lines, [('世界地图'.encode('gb2312'), 24, 0)]) + + def test_minimap_tooltip_from_user_screenshot(self): + text = '\a小地图\n切换小地图显示开/关。\n\v(Enter)' + lines, _ = layout(text, width=160) + self.assertEqual(lines, [('小地图'.encode('gb2312'), 62, 0), + ('切换小地图显示开/关。'.encode('gb2312'), 16, 17), + (b'(Enter)', 104, 34)]) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--exe', default=DEFAULT_EXE) + args, remaining = parser.parse_known_args() + EXE_PATH = args.exe + unittest.main(argv=[__file__] + remaining) diff --git a/tools/test_cjk_titles.py b/tools/test_cjk_titles.py new file mode 100644 index 0000000..568560f --- /dev/null +++ b/tools/test_cjk_titles.py @@ -0,0 +1,59 @@ +"""Execute real option-title call sites, coordinate transform and font metrics.""" +import argparse +import unittest +from unicorn import UC_HOOK_CODE +from unicorn.x86_const import UC_X86_REG_EAX, UC_X86_REG_ECX, UC_X86_REG_EDI, UC_X86_REG_ESP, UC_X86_REG_EIP +import test_cjk_layout as engine + +# From the source string's lookup return to the original draw return. +TITLE_SITES = [(0x4de8b7, 0x4de8e7), (0x4d747b, 0x4d74ab), + (0x4ddaa3, 0x4ddad3), (0x4d9a0b, 0x4d9a3b)] + + +def title_position(site, text, resolution): + uc, put, ints, font, stack, string, stop = engine.make_machine() + manager = font - 0x3a8 + put(0x65af70, 0x2001000) + put(0x2001008, 0x2002000) + put(0x200105c, manager) + put(0x2002514, *resolution) + uc.mem_write(string, text.encode('gb2312') + b'\0') + uc.reg_write(UC_X86_REG_ESP, stack) + uc.reg_write(UC_X86_REG_EAX, string) + uc.reg_write(UC_X86_REG_EDI, manager) + uc.reg_write(UC_X86_REG_ECX, manager) + # The caller pushed justification=0 before looking up its title. + put(stack, 0) + positions = [] + def draw(machine, address, size, data): + sp = machine.reg_read(UC_X86_REG_ESP) + ret, point = ints(sp, 2) + positions.append(ints(point, 2)) + machine.reg_write(UC_X86_REG_ESP, sp + 24) + machine.reg_write(UC_X86_REG_EIP, ret) + uc.hook_add(UC_HOOK_CODE, draw, begin=0x4eab70, end=0x4eab70) + uc.emu_start(site[0], site[1], count=10000) + assert uc.reg_read(UC_X86_REG_EIP) == site[1] + return positions + + +class TitleTests(unittest.TestCase): + def test_all_option_titles_share_panel_center(self): + for site in TITLE_SITES: + for label, x in [('操作', 628), ('游戏选项', 616), ('Controls', 608)]: + with self.subTest(site=hex(site[0]), label=label): + self.assertEqual(title_position(site, label, (800, 600)), [(x, 60)]) + + def test_title_center_tracks_resolution_without_scaling_font_width(self): + for resolution, expected in [((1280, 720), (1012, 72)), + ((1920, 1080), (1524, 108))]: + with self.subTest(resolution=resolution): + self.assertEqual(title_position(TITLE_SITES[0], '操作', resolution), [expected]) + + +if __name__ == '__main__': + p = argparse.ArgumentParser() + p.add_argument('--exe', default=engine.DEFAULT_EXE) + args, rest = p.parse_known_args() + engine.EXE_PATH = args.exe + unittest.main(argv=[__file__] + rest)