fix: correct CJK tooltip layout and center option titles
This commit is contained in:
@@ -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)
|
||||
|
||||
+1
-1
@@ -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在劫掠城市时,一些怪物获得了若干神圣水晶。它们通常把宝物藏在巢穴中。将这些水晶集齐,便会成为相当强大的力量源泉。若能收集齐一整套,这股力量便可用来对抗邪恶。",
|
||||
|
||||
@@ -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('<I', data, 0x3c)[0]
|
||||
opt = pe + 24
|
||||
base = struct.unpack_from('<I', data, opt + 28)[0]
|
||||
size = struct.unpack_from('<I', data, opt + 56)[0]
|
||||
sections = opt + struct.unpack_from('<H', data, pe + 20)[0]
|
||||
uc = Uc(UC_ARCH_X86, UC_MODE_32)
|
||||
uc.mem_map(base, (size + 4095) & ~4095)
|
||||
uc.mem_write(base, data[:4096])
|
||||
for i in range(struct.unpack_from('<H', data, pe + 6)[0]):
|
||||
_, va, raw_size, raw = struct.unpack_from('<IIII', data, sections + i*40 + 8)
|
||||
if raw_size:
|
||||
uc.mem_write(base + va, data[raw:raw + raw_size])
|
||||
uc.mem_map(0x2000000, 0x500000)
|
||||
font, stack, string, stop = 0x2100000, 0x2208000, 0x2300000, 0x2400000
|
||||
def put(addr, *values):
|
||||
uc.mem_write(addr, struct.pack('<' + 'I'*len(values), *values))
|
||||
def ints(addr, count):
|
||||
return struct.unpack('<' + 'I'*count, uc.mem_read(addr, 4*count))
|
||||
put(font + 0xc, 5, 5, 0)
|
||||
for ch in range(256):
|
||||
put(font + 0x101c + ch*8, 8, 12)
|
||||
return uc, put, ints, font, stack, string, stop
|
||||
|
||||
|
||||
def layout(text, width=48, alignment=2, measure_only=False):
|
||||
uc, put, ints, font, stack, string, stop = make_machine()
|
||||
raw = text.encode('gb2312') if isinstance(text, str) else text
|
||||
uc.mem_write(string, raw + b'\0')
|
||||
put(0x2000000, 0, 0)
|
||||
put(0x2000010, width, 1000)
|
||||
put(stack, stop, 0x2000000, 0x2000010, font, 0xffffffff,
|
||||
string, alignment, 0x2000020 if measure_only else 0)
|
||||
uc.reg_write(UC_X86_REG_ESP, stack)
|
||||
uc.reg_write(UC_X86_REG_ECX, 0x2001000)
|
||||
lines = []
|
||||
def draw(machine, address, length, _):
|
||||
if address != 0x4eab70:
|
||||
return
|
||||
sp = machine.reg_read(UC_X86_REG_ESP)
|
||||
ret, pos, _, _, ptr, _ = ints(sp, 6)
|
||||
buf = bytearray()
|
||||
while machine.mem_read(ptr + len(buf), 1)[0]:
|
||||
buf.extend(machine.mem_read(ptr + len(buf), 1))
|
||||
if len(buf) > 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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user