Files
dragon-hd/tools/test_cjk_layout.py

153 lines
6.6 KiB
Python

"""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)