2 Commits

Author SHA1 Message Date
DragonHD 8ee279864a docs: prioritize tutorial translation and a small HD texture pilot 2026-09-20 11:28:55 +08:00
DragonHD 253cc47bc5 fix: correct CJK tooltip layout and center option titles 2026-09-20 11:25:04 +08:00
9 changed files with 656 additions and 1 deletions
+3
View File
@@ -34,6 +34,9 @@ Working data (not tracked): `original/`, `png/`, `report/`, `patch/`, `crack/`.
See `docs/` for the reverse-engineered formats and addresses.
Next steps, ordered by visible benefit and implementation effort:
[Chinese tutorial, then a small 2x texture pilot](docs/superpowers/plans/2026-09-20-high-impact-simple-first.md).
## Legal
No game data is distributed here. Patches are produced by the user against their own copy.
+57
View File
@@ -3,6 +3,63 @@
Working log for the *The I of the Dragon* localization / HD project.
Newest entries on top.
## 2026-09-20 — Accepted fixes and next-step plan
- User confirmed the tutorial prompt and option-title follow-up in game.
- Pushed fix commit `253cc47` to `codex/fix-cjk-tooltip-layout` on Gitea.
- Added a prioritized implementation plan: tutorial display-text translation,
an eight-texture 2x pilot, then validated 2050 texture batches and chapter-by-
chapter mission translation. The next-stage work is planned, not implemented.
- Plan: `docs/superpowers/plans/2026-09-20-high-impact-simple-first.md`.
## 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
+43
View File
@@ -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.
@@ -0,0 +1,162 @@
# DragonHD 高收益、低复杂度优先 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 先补齐可直接影响游玩体验的教程中文,再用少量贴图证明 2× 高清化收益,最后按验证结果扩大范围。
**Architecture:** 沿用已验收的字体补丁、GB2312 文本和资源解包成果。显示文本采用原文校验后的定点替换;高清素材采用独立清单、小批量构建及恢复。中文排版和贴图升级各自交付,避免同时更换多个变量。
**Tech Stack:** Python、Pillow、现有 res 工具;字体回归使用 Keystone/UnicornDDS 写回先确认编码工具及 mipmap 支持。
**Spec:** `docs/superpowers/specs/2026-09-20-next-steps.md`
## Global Constraints
- 游戏目录:`F:\steam\steamapps\common\The I of the Dragon`;工作目录:`E:\DragonHD`
- 保留目前 12px CJK 字库和通过验收的排版补丁。
- 脚本仅改显示调用中的文本字面量;逻辑标识、事件名、路径、单位类型保持字节不变。
- 每批单独记录原文件 SHA256、变更文件清单和可验证的恢复方式。
- 新文本须能 GB2312 编码,保留原有格式占位符和引擎控制符。
- 贴图第一轮只做 2×,保留比例;透明通道、压缩类型及 mipmap 策略逐项核验。
- Git 只提交自制工具、文本映射和文档,原始/生成游戏资源留在本地。
- 先看原比例游戏截图,再判断是否扩大处理范围;局部放大截图只辅助检查。
## 排序与完成条件
| 顺序 | 工作 | 可见收益 | 复杂度 | 首批完成条件 |
|---|---|---|---|---|
| 1 | 教程显示文本汉化 | 入门流程基本不再出现英文讲解 | 低—中 | 教程提示/任务说明中文;关键教程事件正常推进 |
| 2 | 8 张贴图 2× 试做 | 判断实际清晰度提升,避免白做整库 | 中,TGA 较低 | 至少 2 张通过实机原比例对比;逐文件可恢复 |
| 3 | 合格类型批量 20–50 张 | 扩大已确认有效的高清范围 | 低—中,依赖第 2 项 | 常见素材观感提升,未出现黑边、接缝或明显卡顿 |
| 4 | 主线按章翻译 | 扩大中文覆盖到正式任务 | 中,依赖第 1 项 | 首章显示文本完成,任务触发和读档正常 |
第 1、2 项是下一轮可执行任务;第 3、4 项是通过前置验收后再展开的队列。
不以“479/496”补到 100% 为目标:格式串、路径和空值保留其原有用途。
## 开始前复核
在工作目录运行:
```powershell
python tools/test_cjk_layout.py -q
python tools/test_cjk_titles.py -q
python tools/check_translations.py
git status --short
```
预期:14 项排版测试及 2 项标题测试通过,格式占位符不匹配为 0。
记录当前游戏文件哈希,不覆盖 `backup_game/font-layout-20260920` 中的既有快照。
## Task 1: 教程文本完整提取、翻译与回灌
**Files:**
- Create: `tools/script_text.py`(扫描允许的显示调用并精确定位字符串跨度)。
- Create: `tools/test_script_text.py`(编码、转义、续行、只修改显示文本的行为测试)。
- Create: `tools/tutorial_cn.json`(原文校验值及中文映射)。
- Modify: `docs/DEVLOG.md`(验收结果及未覆盖项)。
- Local input: `F:\steam\steamapps\common\The I of the Dragon\Data\Scripts\English\Tutorial.dsc`
- Local output: `build/tutorial/Tutorial.dsc``build/tutorial/manifest.json`
**Interfaces:**
- `patch_display_calls(source: bytes, translations: dict[str, str]) -> bytes`
- 映射键为解码后的原文字面量;同文同步替换,未命中的映射报错。
- 仅允许 `DisplayMessage` 的两个字符串实参与 `SetMissionDescription` 的字符串实参。
- CLI`python tools/script_text.py --source <path> --mapping tools/tutorial_cn.json --out build/tutorial/Tutorial.dsc`
- 工具只生成文件,不在构建时写游戏目录。
- [ ] **Step 1:固定输入。** 复制教程到本批次备份并计算 SHA256。当前文件为 21,166 字节;扫描可见 22 处 `DisplayMessage(`(含函数定义)、15 处 `SetMissionDescription(`,最终以解析出的字面量为准。
- [ ] **Step 2:建立失败测试。** 除下例外,补充注释内伪调用、变量实参、转义引号、反斜杠续行、重复原文、映射缺项与不可编码字符的样例。禁止用匹配全部引号字符串的正则替换脚本。
```python
import unittest
from script_text import patch_display_calls
class ScriptTextTests(unittest.TestCase):
def test_only_display_literals_change(self):
source = b'DisplayMessage("Help", "Move");\r\nEnableEvent("Move");\r\n'
actual = patch_display_calls(source, {'Help': '帮助', 'Move': '移动'})
expected = 'DisplayMessage("帮助", "移动");\r\nEnableEvent("Move");\r\n'.encode('gb2312')
self.assertEqual(actual, expected)
```
- [ ] **Step 3:运行测试并确认失败原因。** `python -m unittest discover -s tools -p test_script_text.py -v`
- [ ] **Step 4:实现字节扫描器。** 逐字节维护注释/字符串/转义/括号状态,只标记允许调用的字面量范围;按原范围倒序替换。输出前检查原文命中、GB2312、占位符与控制符;原文件其他字节全部保留。
- [ ] **Step 5:翻译第一组实机可达提示。** 先做 Tutorial、Camera rotation、Center the camera、Zoom、Flight 及其重复任务说明。表达简洁,键位说明准确,不擅自改变操作要求。
- [ ] **Step 6:执行测试与构建。** 使用上述测试命令和 CLI;核对所有改动仅在提取的字面量跨度中,备份哈希与输入一致。
- [ ] **Step 7:完成其余教程显示文本。** 用同一映射、同一规则补齐;控制符 `\a/\f/\n` 与格式参数单独检查。记录保留英文的专名及理由。
- [ ] **Step 8:退出游戏后部署教程副本。** 依次验收镜头旋转、镜头回正、缩放、飞行及后续教程步骤;检查提示显示和下一事件触发,保存实机截图与结果。中途恢复旧教程后验证哈希一致,再装回新版。
- [ ] **Step 9:提交独立变更。** `git add tools/script_text.py tools/test_script_text.py tools/tutorial_cn.json docs/DEVLOG.md``git commit -m "feat: translate tutorial display text without changing events"`
**完成门槛:** 显示文本中文、脚本逻辑字节不变、教程触发链实机通过,已有字体回归仍通过。仅文件成功生成不算教程已验收。
## Task 2: 8 张 2× 贴图试做,先评估再扩大
**Files:**
- Create: `tools/hd_pilot.json`(本轮选择及源文件哈希)。
- Create: `tools/hd_pilot.py`(选图、对比页、构建清单)。
- Create: `tools/test_hd_pilot.py`(尺寸、通道、清单与误选保护)。
- Create: `docs/hd-pilot-review.md`(每张在游戏中的结论)。
- Existing inputs: `report/upscale_plan.csv``report/textures_report.csv``png/``original/`
- Local outputs: `build/hd-pilot/`;原图不覆盖。
**首批候选(已在当前资源清单中核对存在):**
| 素材 | 原尺寸 | 用途 |
|---|---|---|
| `BigRock.tga` | 128×128 | 简单 TGA 回灌验证 |
| `Black_stone.tga` | 128×128 | 石材细节对比 |
| `creatures\amfibrahiy1.dds` | 256×128 | 不透明 DXT1 生物样本 |
| `creatures\Beatle.dds` | 256×256 | 不透明 DXT1 生物样本 |
| `buildings\arka.dds` | 256×128 | 建筑边缘细节 |
| `buildings\bochka01.dds` | 256×256 | 常见道具细节 |
| `buildings\brev-tor.dds` | 128×128 | 木材端面 |
| `buildings\brev.dds` | 128×256 | 木材表面 |
候选名称仅用于定位;先在游戏中确认可见场景。难以找到的素材换成同组、已确认可见的条目,并更新清单。自动分类可能遗漏平铺用途,实际出现接缝的样本移入后续专项。
**Interfaces:**
- `validate_size(original: tuple[int, int], result: tuple[int, int]) -> None`:只接受精确 2×,否则抛出 `ValueError`
- `hd_pilot.json` 每项:`name``source_sha256``scale: 2``alpha_policy: "preserve"`
- 每张比较原图、Lanczos 2× 基线、可用模型 2× 候选;尚无模型时先完成基线/回灌,不宣称 AI 高清化完成。
- [ ] **Step 1:记录源素材并建清单。** 核对原尺寸、通道、FourCC 和 mipmap;排除字体、UI 图集、烘焙文字、透明特效与已知平铺纹理。
- [ ] **Step 2:添加失败测试。**
```python
import unittest
from hd_pilot import validate_size
class PilotTests(unittest.TestCase):
def test_preserves_aspect_ratio_at_exact_two_times(self):
validate_size((256, 128), (512, 256))
with self.assertRaises(ValueError):
validate_size((256, 128), (512, 512))
```
- [ ] **Step 3:运行失败测试。** `python -m unittest discover -s tools -p test_hd_pilot.py -v`
- [ ] **Step 4:实现构建约束。**
```python
def validate_size(original, result):
if result != (original[0] * 2, original[1] * 2):
raise ValueError('pilot texture must be exactly 2x with unchanged aspect ratio')
```
- [ ] **Step 5:先完成两张 TGA 的原比例/放大对比。** Pillow 生成 Lanczos 基线;若已有可用超分模型,再生成模型版,保留三个版本。不要将普通插值称为新增细节。
- [ ] **Step 6:验证 DDS 写回。** 先做原尺寸 decode/encode 对照,核对 DXT1、通道与 mipmap;确认工具链后再做六张 DDS 的 2×。若编码或材质兼容未通过,本轮仅交付已通过的 TGA 结果,并在审查文档标记 DDS 阻塞原因。
- [ ] **Step 7:逐张回灌。** 优先使用已验证的松散文件覆盖方式;有打包加载差异再以现有 `replace_res_entries` 工具重建副本,逐条核对非目标资源哈希。单独保存每个被覆盖文件及是否原本存在。
- [ ] **Step 8:同存档同视角截图比较。** 记录正常距离的观感、边缘光晕、纹理游动、接缝、加载耗时和帧率;帧率在同一场景预热后测三次。若中位帧率下降超过 5%,调查原因并暂不扩大批次。
- [ ] **Step 9:恢复验证与小批次结论。** 恢复原文件并验哈希;只重新部署“正常视距可见改善、无明显副作用”的样本。逐张标记通过/维持原版/需专项处理。
- [ ] **Step 10:提交工具与结果文档。** `git add tools/hd_pilot.py tools/test_hd_pilot.py tools/hd_pilot.json docs/hd-pilot-review.md``git commit -m "feat: add validated two-times texture pilot"`
**完成门槛:** 至少 2 张实机效果优于原图且可完整恢复。即使模型版更锐,也允许选择观感更自然的基线或原版。
## 后续队列的启动条件
- **Task 32050 张高清小批次。** 第 2 项确认至少一种素材类别收益稳定后再编写该批实施清单。优先玩家常见生物、建筑、道具;按清单逐张记录,不把全部 1195 张自动纳入。
- **Task 4:主线首章汉化。** 第 1 项显示文本工具和事件回归验收后,以 `MainMission.dsc` 的实际显示调用清单确定章节边界,再单独编写实施计划。未证实的调用不加入自动替换白名单。
## 每项交付时报告
报告本项改了什么、实机看到的收益、验证结果、已知未完成点和恢复方式。
每项单独提交;没有明确收益的试验及时止步,不因已经生成文件就继续批处理。
@@ -0,0 +1,32 @@
# DragonHD 下一阶段范围与优先级
用户目标:先做效果明显、实现简单的工作;本轮只保存计划,不开始下一阶段改造。
当前基线:字体提示框与选项页对齐已获用户实测确认,修复提交为 `253cc47`
Strings.dat 已翻译 479/496 个 ID;其余含格式串、空值、路径,并非全部需要翻译。
已提取 1884 个纹理条目并生成 1882 张 PNG。现有分类为普通放大 1195、
平铺素材 499、需审查 182、跳过 8;分类是候选筛选,不等于已经通过视觉验收。
## 交付顺序
1. 教程的显示文本汉化:可直接消除进入游戏后的大段英文,复用现有 GB2312 字库。
2. 八张贴图的 2× 对比试做:先验证观感、回灌和性能,再选出适合批量处理的类型。
3. 已通过试做的贴图按 20–50 张小批次部署:优先实际画面中常见、占屏面积大的素材。
4. 主线任务按章节翻译:沿用教程的文本提取/替换工具,每章做任务触发回归。
## 全局约束
- 游戏目录:`F:\steam\steamapps\common\The I of the Dragon`;工作目录:`E:\DragonHD`
- 保留目前 12px CJK 字库和通过验收的排版补丁。
- 脚本仅改显示调用中的文本字面量;逻辑标识、事件名、路径、单位类型保持字节不变。
- 每批单独记录原文件 SHA256、变更文件清单和可验证的恢复方式。
- 新文本须能 GB2312 编码,保留原有格式占位符和引擎控制符。
- 贴图第一轮只做 2×,保留比例;透明通道、压缩类型及 mipmap 策略逐项核验。
- Git 只提交自制工具、文本映射和文档,原始/生成游戏资源留在本地。
- 先看原比例游戏截图,再判断是否扩大处理范围;局部放大截图只辅助检查。
## 暂后
平铺地形/水面/天空、透明树叶和粒子、带固定像素坐标的 UI 图集、烘焙文字图片、
全字库重做、模型/动画改造、一次性全资源 4× 放大,都留到已有小批次收益得到确认后。
这些工作仍有价值,但依赖和调试成本更高。
+147
View File
@@ -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
View File
@@ -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在劫掠城市时,一些怪物获得了若干神圣水晶。它们通常把宝物藏在巢穴中。将这些水晶集齐,便会成为相当强大的力量源泉。若能收集齐一整套,这股力量便可用来对抗邪恶。",
+152
View File
@@ -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)
+59
View File
@@ -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)