fix: correct CJK tooltip layout and center option titles
This commit is contained in:
@@ -3,6 +3,54 @@
|
|||||||
Working log for the *The I of the Dragon* localization / HD project.
|
Working log for the *The I of the Dragon* localization / HD project.
|
||||||
Newest entries on top.
|
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
|
## 2026-09-20 — Full `Strings.dat` translation
|
||||||
|
|
||||||
- Translated the `English` column for **479 / 496** entries (the rest are pure-format
|
- Translated the `English` column for **479 / 496** entries (the rest are pure-format
|
||||||
|
|||||||
@@ -100,6 +100,49 @@ Shrift_gb_Germany.TGA 2048 x 1024, 32bpp TGA (type 2, bottom-up, desc 0x08, 26
|
|||||||
|
|
||||||
## Tools
|
## 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/add_section.py` — append an executable PE section.
|
||||||
- `tools/apply_cjk.py` — assemble the caves (keystone), add `.cjk`, write hooks and tables.
|
- `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.
|
- `tools/build_cjk.py` — build the atlas TGA + `cjk_glyph`/`cjk_metric` from a charset.
|
||||||
|
|||||||
@@ -15,6 +15,21 @@ OFF_DRAW = 0x0000
|
|||||||
OFF_MEAS = 0x0100
|
OFF_MEAS = 0x0100
|
||||||
OFF_GLYPH = 0x0200
|
OFF_GLYPH = 0x0200
|
||||||
OFF_METRIC = 0x100200
|
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):
|
def align(x, a):
|
||||||
@@ -130,6 +145,124 @@ def main(orig=ORIG, out=OUT, tbl=TBL):
|
|||||||
meas_code = bytes(meas_code)
|
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))
|
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]:
|
if so + (nsec + 1) * 40 > secs[0][2]:
|
||||||
print("no room for section header", file=sys.stderr)
|
print("no room for section header", file=sys.stderr)
|
||||||
sys.exit(3)
|
sys.exit(3)
|
||||||
@@ -151,6 +284,10 @@ def main(orig=ORIG, out=OUT, tbl=TBL):
|
|||||||
glyph, metric = load_tables(tbl)
|
glyph, metric = load_tables(tbl)
|
||||||
data[sec_off + OFF_GLYPH: sec_off + OFF_GLYPH + len(glyph)] = glyph
|
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_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):
|
def hook(va, target):
|
||||||
off = va - imagebase
|
off = va - imagebase
|
||||||
@@ -164,6 +301,16 @@ def main(orig=ORIG, out=OUT, tbl=TBL):
|
|||||||
|
|
||||||
hook(HOOK_DRAW_VA, draw_va)
|
hook(HOOK_DRAW_VA, draw_va)
|
||||||
hook(HOOK_MEAS_VA, meas_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)
|
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||||
open(out, "wb").write(data)
|
open(out, "wb").write(data)
|
||||||
|
|||||||
+1
-1
@@ -406,7 +406,7 @@ CN_STRINGS = {
|
|||||||
"Overhead_DeadDialog_Multiplayer_Text": "\\a已重生",
|
"Overhead_DeadDialog_Multiplayer_Text": "\\a已重生",
|
||||||
"Overhead_Multiplayer_GameOver": "\\a游戏结束",
|
"Overhead_Multiplayer_GameOver": "\\a游戏结束",
|
||||||
"Overhead_Multiplayer_MainMenu": "\\a确定 - 退出,取消 - 继续游戏。",
|
"Overhead_Multiplayer_MainMenu": "\\a确定 - 退出,取消 - 继续游戏。",
|
||||||
"Overhead_Tutorial_MainMenu": "\\a确定 - 退出,取消 - 继续教程。",
|
"Overhead_Tutorial_MainMenu": "\\a退出教程?",
|
||||||
"Overhead_StoneType0": "\\a红色魔法石\\r\\n\\f在劫掠城市时,一些怪物获得了若干神圣水晶。它们通常把宝物藏在巢穴中。将这些水晶集齐,便会成为相当强大的力量源泉。若能收集齐一整套,这股力量便可用来对抗邪恶。",
|
"Overhead_StoneType0": "\\a红色魔法石\\r\\n\\f在劫掠城市时,一些怪物获得了若干神圣水晶。它们通常把宝物藏在巢穴中。将这些水晶集齐,便会成为相当强大的力量源泉。若能收集齐一整套,这股力量便可用来对抗邪恶。",
|
||||||
"Overhead_StoneType1": "\\a绿色魔法石\\r\\n\\f在劫掠城市时,一些怪物获得了若干神圣水晶。它们通常把宝物藏在巢穴中。将这些水晶集齐,便会成为相当强大的力量源泉。若能收集齐一整套,这股力量便可用来对抗邪恶。",
|
"Overhead_StoneType1": "\\a绿色魔法石\\r\\n\\f在劫掠城市时,一些怪物获得了若干神圣水晶。它们通常把宝物藏在巢穴中。将这些水晶集齐,便会成为相当强大的力量源泉。若能收集齐一整套,这股力量便可用来对抗邪恶。",
|
||||||
"Overhead_StoneType2": "\\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