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. 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 ## Legal
No game data is distributed here. Patches are produced by the user against their own copy. 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. Working log for the *The I of the Dragon* localization / HD project.
Newest entries on top. 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 ## 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
+43
View File
@@ -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.
@@ -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_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
View File
@@ -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在劫掠城市时,一些怪物获得了若干神圣水晶。它们通常把宝物藏在巢穴中。将这些水晶集齐,便会成为相当强大的力量源泉。若能收集齐一整套,这股力量便可用来对抗邪恶。",
+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)