Compare commits
9 Commits
main
..
6c5f86f628
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c5f86f628 | |||
| b7ae04ec18 | |||
| 6cd5bcbfee | |||
| 997ae29355 | |||
| 8a061bc3a0 | |||
| 72a3d000dd | |||
| 2e214efeb8 | |||
| 8ee279864a | |||
| 253cc47bc5 |
@@ -14,6 +14,17 @@ docs/ DEVLOG, format specs, patch notes
|
||||
|
||||
Working data (not tracked): `original/`, `png/`, `report/`, `patch/`, `crack/`.
|
||||
|
||||
## Current localization status
|
||||
|
||||
The complete tutorial translation and Chinese font fixes were accepted in game
|
||||
by the user on 2026-09-20. All 57 tutorial display literals are translated using
|
||||
42 mappings. Tooltip wrapping, option-title centering, intro alignment, and the
|
||||
Chinese full-stop baseline are fixed. The latest targeted regression run passes
|
||||
39 tests; tutorial event logic remains byte-identical outside display strings.
|
||||
|
||||
See [教程汉化:构建、验证与恢复](docs/tutorial-localization.md) for reproducible
|
||||
commands, accepted behavior, and the remaining project queue.
|
||||
|
||||
## Tooling
|
||||
|
||||
| script | purpose |
|
||||
@@ -30,10 +41,17 @@ Working data (not tracked): `original/`, `png/`, `report/`, `patch/`, `crack/`.
|
||||
| `tools/cn_strings.py` | simplified-Chinese translation table (`Id` -> text) |
|
||||
| `tools/make_cn_strings.py` | patch the `English` column of `Strings.dat` to GB2312 |
|
||||
| `tools/install_cjk.py` | deploy/restore the whole patch into the game folder |
|
||||
| `tools/script_text.py` | translate tutorial display strings without changing event code |
|
||||
| `tools/tutorial_cn.json` | complete tutorial translation mapping |
|
||||
| `tools/verify_tutorial_step1.py` | verify the complete tutorial against the pinned original (historical filename) |
|
||||
| `tools/fix_full_stop.py` | lower U+3002 pixels in an existing atlas without changing other glyphs |
|
||||
| `tools/xrefs.py` | find call/jmp xrefs in the game executable |
|
||||
|
||||
See `docs/` for the reverse-engineered formats and addresses.
|
||||
|
||||
Implementation history and remaining work:
|
||||
[tutorial acceptance and subsequent small batches](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.
|
||||
|
||||
+116
@@ -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 20–50 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
|
||||
@@ -96,3 +153,62 @@ so centred labels (the "Start New Game" button) were flushed right. Fixed to
|
||||
- Do the localization ourselves (route B): patch the engine for double-byte text, build our own font
|
||||
atlas, translate only display text (never Ids / event names / unit types) to avoid trigger bugs.
|
||||
- Keep the repository text-only; regenerate binaries from scripts (see `.gitignore`).
|
||||
|
||||
## 2026-09-20 — Tutorial Chinese trial, batch 1
|
||||
|
||||
- Branch: `codex/tutorial-step1`. Translate only introduction, camera rotation, camera centering and zoom: 9 mappings replace 12 of 55 direct display literals. Flight and later stages remain unchanged.
|
||||
- Added `tools/script_text.py`, a byte-span scanner restricted to direct `DisplayMessage` / `SetMissionDescription` string arguments; preserves controls, printf parameters and all non-display bytes. JSON contains decoded strings; translated output uses GB2312.
|
||||
- Added 14 scanner tests and `tools/verify_tutorial_step1.py` for the pinned source, changed-span coverage and full-byte baseline/rollback checks. All 14 scanner + 16 existing font regression tests pass.
|
||||
- Original SHA256: `bd959758223b976ffac35b4ef7d94661f62060c1134316eddb559772d544e02c`. Translated SHA256: `c0e9d1d0fe2e8f3eb656907e5c2e1c44222614b8ecdb6e2ef148ccf897ed40e0`.
|
||||
- Original backup: `E:/DragonHD/backup_game/tutorial-step1-20260920/Tutorial.dsc`; build: `E:/DragonHD/build/tutorial-step1/Tutorial.dsc`. Deployed to the Steam English script directory after checking the game was stopped and the original hash still matched. Re-read deployed file and passed the same byte-span verification.
|
||||
- Git Bash rollback script was executed on a separate translated copy; restored bytes/hash match the original exactly. The deployed tutorial and deliverable remain translated.
|
||||
- In-game acceptance PENDING: start a fresh tutorial, read intro, rotate camera, press numpad 5, zoom with wheel, and check each next-step trigger. Then translate flight in the next small batch. No claim yet about native script loading or rendered layout.
|
||||
|
||||
## 2026-09-20 — Intro paragraph layout correction
|
||||
|
||||
- User confirmed the subsequent tutorial pages work; opening screenshot showed orphan comma and inconsistent alignment. Engine replay at width 400 reproduced the first-line 15px indent from `\f`, unindented wrapped continuation, and centered final paragraph from `\a`.
|
||||
- Replaced only intro message/mission paragraphs (literal indexes 1 and 2 relative to the previous trial) with concise text and `\b` left alignment. Added explicit `--allow-alignment-changes` build opt-in; default control validation remains strict, opt-in still preserves control order/count and newlines.
|
||||
- New regression failed on original mapping (x=15, expected 0), then passed at widths 360/400/480. All 32 tests pass. All other script bytes unchanged relative to previous Chinese trial.
|
||||
- Built and deployed after process and source-hash checks. SHA256: `15a7a24a236d01af4f5f3bcc89901dac5ab0211ed51357dfda5ef78d0026c423`. Backup of previous trial: `E:/DragonHD/backup_game/tutorial-intro-layout-20260920/Tutorial.dsc`. Rollback executed on separate copy and restored previous hash.
|
||||
- Rebuild: `python tools/script_text.py --source E:/DragonHD/backup_game/tutorial-step1-20260920/Tutorial.dsc --mapping tools/tutorial_cn.json --out E:/DragonHD/build/tutorial-intro-layout/Tutorial.dsc --allow-alignment-changes`.
|
||||
- Opening page visual recheck pending. Later pages retained exactly as user-tested.
|
||||
|
||||
## 2026-09-20 — Correct opening page to centered alignment
|
||||
|
||||
- User rejected left alignment. The prior fix interpreted the visual preference incorrectly. Changed all four intro paragraphs to centered alignment, preserving the concise text and all later pages.
|
||||
- Exactly eight escape bytes changed (`\b` to `\a`) across the intro message and repeated mission description.
|
||||
- Center-coordinate test first failed on prior left-aligned mapping (0 != 66). New mapping passes at widths 360/400/480 using engine font metrics including 5px spaces. All 32 tests pass; rollback restores the previous copy exactly.
|
||||
- User exited game; deployed centered file and reread/verified it. SHA256: `941fb4899d37304a304a985743c18cfd4b7dfed7613d831f8359c4d6b6972d6d`. Visual acceptance pending.
|
||||
|
||||
## 2026-09-20 — Chinese full-stop baseline correction
|
||||
|
||||
- Inspected live 12px SimSun atlas: U+3002 ink bounding box `(2,5,6,9)` in a 12x12 cell leaves three empty rows below, reproducing the user's raised punctuation.
|
||||
- Anchored only U+3002 to the cell bottom: bbox `(2,8,6,12)`, same eight pixels and shape. All other atlas pixels and bytes remain identical. Updated builder to retain this placement on future generation; `fix_full_stop.py` patches existing TGA without rebuilding other glyphs.
|
||||
- Prepared matching loose atlas and in-place same-size `Textures.res` entry; bytes before and after that entry remain identical. No executable, metrics, UV table, script, or other punctuation changes.
|
||||
- 34 tests pass. Checked original/fixed glyph bounds and exact shape preservation; rollback of both files tested against backups in an isolated directory. Comparison PNG uses actual atlas pixels enlarged 8x.
|
||||
- Backup: `E:/DragonHD/backup_game/full-stop-20260920`. User explicitly authorized terminating the running game for installation. Real in-game appearance pending.
|
||||
|
||||
## 2026-09-20 — Flight tutorial trial
|
||||
|
||||
- User accepted full-stop correction. Continued with flight only: title, message and mission description (literal indexes 12–14). Two concise centered paragraphs retain mouse destination, altitude globe and keyboard movement instructions.
|
||||
- All other text and non-display bytes preserved; the original movement threshold (>15 units along either horizontal axis within the timed loop) and next-button gating remain unchanged. Flight height/attack are next batches.
|
||||
- 35 tests pass, including engine layout at widths 360/400/480; original/modified byte verification and rollback passed. Backup: `E:/DragonHD/backup_game/tutorial-flight-20260920/Tutorial.dsc`.
|
||||
- Game stopped using user's prior termination authorization; deployed file reread and matched output. SHA256: `a41caad224f748222489f8d062526ae663bb04201c593f59d6af02d69a74f604`.
|
||||
- In-game acceptance pending: enter Flight after Zoom, use left click or arrow keys to move some distance, then check Continue Tutorial becomes available and Flight height starts.
|
||||
|
||||
## 2026-09-20 — Complete tutorial translation
|
||||
|
||||
- User accepted flight and requested all remaining tutorial stages. All 57 display literals are now translated through 42 unique mappings, including flight height, attack, charge/retry prompts, breath, eating, spell charging/casting, inactive spells, speed/pause and completion. Earlier 15 accepted literals remain byte-identical.
|
||||
- Stock casting instructions contain two unescaped `"F"` segments. Added tightly scoped recognition of these specific display arguments; generic expressions remain excluded. Numeric prose `0% is` no longer falsely matches printf `% i`; actual placeholders remain protected by regression tests.
|
||||
- Verified all translated glyphs exist in the GB2312 font metadata. New pages use centered paragraphs. Engine replay at widths 360/400/480 checks line positions, punctuation, height (<=140px). Fixed three punctuation wrap cases while wording.
|
||||
- All 39 tests pass. Byte-span verification confirms every non-display byte is unchanged from the original; controls and printf parameters validated. Original event names, key bindings, thresholds, paths and waits are preserved.
|
||||
- Backup: `E:/DragonHD/backup_game/tutorial-complete-20260920/Tutorial.dsc`. Rollback to the accepted five-group version tested on an isolated copy. Deployed after stopping the game under existing authorization; installed bytes reread and match output. SHA256: `720b7fce23b80405d6c8af2a1302ed95c085a072d8c8ecbae49b9db2cb32b8e3`.
|
||||
- Rebuild: `python tools/script_text.py --source E:/DragonHD/backup_game/tutorial-step1-20260920/Tutorial.dsc --mapping tools/tutorial_cn.json --out E:/DragonHD/build/tutorial-complete/Tutorial.dsc --allow-alignment-changes`.
|
||||
- Remaining acceptance: native playthrough from height through completion. Automated layout and byte checks do not prove the full gameplay chain; user has verified the earlier five groups and punctuation fix.
|
||||
|
||||
## 2026-09-20 — Final acceptance and Gitea handoff
|
||||
|
||||
- User confirmed the complete tutorial has no issues and requested repository organization/push. This supersedes pending acceptance notes in the historical trial entries above.
|
||||
- Re-ran all 39 targeted tests: pass. Re-verified the installed tutorial: all 57 display literals translated and all other bytes unchanged.
|
||||
- Added `docs/tutorial-localization.md` with final scope, hashes, build/test instructions, restoration procedure and font-update caveats. README now links the accepted deliverable and tools.
|
||||
- Complete tutorial, font punctuation and prior layout fixes are on `codex/tutorial-step1`; no generated game binaries/assets are included. Subsequent main-mission translation and HD batches have not started.
|
||||
|
||||
@@ -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,191 @@
|
||||
# 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/Unicorn;DDS 写回先确认编码工具及 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 3:20–50 张高清小批次。** 第 2 项确认至少一种素材类别收益稳定后再编写该批实施清单。优先玩家常见生物、建筑、道具;按清单逐张记录,不把全部 1195 张自动纳入。
|
||||
- **Task 4:主线首章汉化。** 第 1 项显示文本工具和事件回归验收后,以 `MainMission.dsc` 的实际显示调用清单确定章节边界,再单独编写实施计划。未证实的调用不加入自动替换白名单。
|
||||
|
||||
## 每项交付时报告
|
||||
|
||||
报告本项改了什么、实机看到的收益、验证结果、已知未完成点和恢复方式。
|
||||
每项单独提交;没有明确收益的试验及时止步,不因已经生成文件就继续批处理。
|
||||
|
||||
## 2026-09-20 分批执行进度
|
||||
|
||||
按用户“一步一步尝试汉化”将 Task 1 的首次实机批次缩小为开场、旋转镜头、镜头回正、缩放,飞行留待下一批。
|
||||
|
||||
- [x] 固定原始备份与 SHA256,建立独立工作区。
|
||||
- [x] 文本扫描器和 14 项测试;首批 9 条映射、12 处显示文字。
|
||||
- [x] 非显示字节逐段核对;原有 16 项字体回归通过。
|
||||
- [x] 在独立副本执行恢复并核对原始哈希;生成差异和验证记录。
|
||||
- [x] 游戏退出后部署,重读游戏文件并通过校验。
|
||||
- [ ] 用户实机验证首批中文显示和教程事件推进。
|
||||
- [ ] 首批确认后继续飞行及后续教程;Task 1 整体验收仍未完成。
|
||||
|
||||
### 飞行批次进度
|
||||
|
||||
- [x] 首批后续页面及句号位置经用户确认。
|
||||
- [x] 飞行标题、提示、任务说明已汉化并部署;35 项测试及回滚验证通过。
|
||||
- [ ] 飞行中文显示、移动后继续按钮、进入飞行高度步骤实机验收。
|
||||
- [ ] 验收后继续飞行高度与攻击提示。
|
||||
|
||||
### 教程全量汉化进度(按用户最新要求)
|
||||
|
||||
- [x] 飞行批次经用户确认。
|
||||
- [x] 剩余教程完整翻译,含两处原版引号异常的施法说明:57/57 显示字面量、42 条映射。
|
||||
- [x] 39 项回归测试、字库覆盖、非显示字节一致、三种宽度排版、独立副本回滚验证。
|
||||
- [x] 全量教程部署并重读校验。
|
||||
- [x] 用户确认完整教程没有问题(2026-09-20)。
|
||||
|
||||
Task 1 的翻译、部署与用户实机验收已完成。最终交付说明见 `docs/tutorial-localization.md`。前文未勾选的分批试作项保留为历史记录,以本条最终状态为准。
|
||||
@@ -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× 放大,都留到已有小批次收益得到确认后。
|
||||
这些工作仍有价值,但依赖和调试成本更高。
|
||||
@@ -0,0 +1,58 @@
|
||||
# 教程汉化交付说明
|
||||
|
||||
## 当前结果(2026-09-20)
|
||||
|
||||
用户已确认完整教程及字体修正“没有问题”。本次验收来自用户实机反馈;自动化检查覆盖字节、编码、排版与回滚,不替代实机操作。
|
||||
|
||||
- 教程全部 57 处标题、消息正文、任务说明使用 42 条中文映射。
|
||||
- 范围包括开场、镜头、飞行、高度、攻击、蓄力、吐息、进食、法术、速度控制和结束提示。
|
||||
- 开场及后续新增页面使用居中短句;此前已验收的镜头说明保留原有格式。
|
||||
- 句号在 12×12 字格中下移 3 像素,边界由 `(2,5,6,9)` 变为 `(2,8,6,12)`;字宽和其他字符不变。
|
||||
- 按钮中文与快捷键不再重叠,选项页标题按实际文字宽度居中。
|
||||
- 原事件、按键绑定、资源路径、等待与判定条件保持原字节。
|
||||
|
||||
## 教程构建
|
||||
|
||||
在仓库根目录使用 Python。将路径换为本机位置,`Tutorial.original.dsc` 必须是保留的原版英文脚本,而非已经汉化的文件。
|
||||
|
||||
```powershell
|
||||
python tools/script_text.py --source "C:/DragonWork/Tutorial.original.dsc" --mapping tools/tutorial_cn.json --out "C:/DragonWork/build/Tutorial.dsc" --allow-alignment-changes
|
||||
python tools/verify_tutorial_step1.py modified "C:/DragonWork/Tutorial.original.dsc" "C:/DragonWork/build/Tutorial.dsc"
|
||||
```
|
||||
|
||||
工具同时生成 `Tutorial.manifest.json`。验证器沿用早期文件名,目前检查的是完整教程。
|
||||
|
||||
| 文件 | SHA256 |
|
||||
|---|---|
|
||||
| 原版英文输入 | `bd959758223b976ffac35b4ef7d94661f62060c1134316eddb559772d544e02c` |
|
||||
| 完整中文输出 | `720b7fce23b80405d6c8af2a1302ed95c085a072d8c8ecbae49b9db2cb32b8e3` |
|
||||
|
||||
默认严格保留控制符;`--allow-alignment-changes` 仅允许替换同一位置的对齐/缩进控制符,换行顺序及数量仍受检查。原版施法正文两处未转义的 `"F"` 采用专门识别,不扩大到一般表达式。百分数文字 `0% is` 与实际格式占位符分别处理。
|
||||
|
||||
## 验证
|
||||
|
||||
字体排版测试需要 `unicorn`,位图测试需要 `Pillow`;排版测试默认读取已修补的本机游戏程序,路径可通过 `tools/test_cjk_layout.py` 的 `DEFAULT_EXE` 统一调整。
|
||||
|
||||
```powershell
|
||||
$tests = @('test_script_text.py', 'test_tutorial_intro.py', 'test_tutorial_flight.py', 'test_tutorial_complete.py', 'test_cjk_layout.py', 'test_cjk_titles.py', 'test_full_stop.py')
|
||||
foreach ($test in $tests) {
|
||||
python "tools/$test"
|
||||
if ($LASTEXITCODE -ne 0) { throw "Test failed: $test" }
|
||||
}
|
||||
```
|
||||
|
||||
最新运行:39 项通过。完整教程校验输出 `MODIFIED: all 57 display literals translated; all other bytes unchanged; PASS`。新页面在 360/400/480 像素宽度下检查行首标点、中心坐标和总高度。所有中文字符均核对存在于已生成的 GB2312 字库中。
|
||||
|
||||
## 安装与恢复
|
||||
|
||||
本次本机安装已经完成;以下步骤用于以后重建。游戏退出后,先备份当前 `Data/Scripts/English/Tutorial.dsc`,再用生成的同名文件替换。保持已有 CJK 程序补丁、字库及字体表。
|
||||
|
||||
恢复教程只需把该次备份复制回相同位置,核对 SHA256;不需要重装字体或重建程序。本次已在独立副本执行恢复,字节与备份一致。最后一批的备份是 `E:/DragonHD/backup_game/tutorial-complete-20260920/Tutorial.dsc`,内容为已验收的前五组汉化版本。
|
||||
|
||||
句号修正通过 `tools/fix_full_stop.py --source <原字库.TGA> --cells <cells.json> --out <新字库.TGA>` 生成。部署须同步更新松散字库和 `Textures.res` 的 `UI\Overhead\Shrift_gb_Germany.TGA` 条目;保留并恢复这两个文件的成对备份。`tools/build_cjk.py` 已包含相同修正,重新生成字库时不会丢失。
|
||||
|
||||
不要为单独更新教程重新执行 `install_cjk.py install`:它会重建并部署整套程序/字体/字符串文件。
|
||||
|
||||
## 下一阶段
|
||||
|
||||
教程已经验收。后续主线汉化或高清素材试作按独立批次推进,尚未在本次启动。历史试作记录见 `DEVLOG.md`,其中早期“待验收”状态以本说明和最终验收条目为准。
|
||||
@@ -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)
|
||||
|
||||
@@ -157,6 +157,16 @@ def pick_font(size):
|
||||
raise SystemExit("no CJK ttf found")
|
||||
|
||||
|
||||
def lower_full_stop(tile):
|
||||
"""Anchor the Chinese full stop at the bottom of its full-width cell."""
|
||||
bbox = tile.getchannel('A').getbbox()
|
||||
if bbox is None:
|
||||
return tile.copy()
|
||||
result = Image.new('RGBA', tile.size, (0, 0, 0, 0))
|
||||
result.paste(tile.crop(bbox), (bbox[0], tile.height - (bbox[3] - bbox[1])))
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--game", default=DEFAULT_GAME)
|
||||
@@ -211,6 +221,8 @@ def main():
|
||||
x = col * cell
|
||||
y = args.cjk_y0 + row * cell
|
||||
d.text((x, y + args.draw_dy), uni, font=font, fill=(255, 255, 255, 255))
|
||||
if uni == '\u3002':
|
||||
atlas.paste(lower_full_stop(atlas.crop((x, y, x + cell, y + cell))), (x, y))
|
||||
cells[code] = {"x": x, "y": y, "uni": uni}
|
||||
# sanity: ink must fit the cell
|
||||
bb = atlas.crop((x, y, x + cell, y + cell)).getbbox()
|
||||
|
||||
+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,45 @@
|
||||
"""Patch only U+3002 pixels in an existing CJK atlas; preserve its TGA bytes."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from build_cjk import lower_full_stop, tga_to_image, tga_geometry
|
||||
|
||||
|
||||
def patch_atlas(raw, metadata):
|
||||
image, desc = tga_to_image(raw)
|
||||
cell = metadata['cell']
|
||||
if list(image.size) != metadata['atlas']:
|
||||
raise ValueError('atlas geometry mismatch')
|
||||
entry = metadata['cells'][str(int.from_bytes('。'.encode('gb2312'), 'big'))]
|
||||
if entry['uni'] != '。':
|
||||
raise ValueError('full-stop cell mismatch')
|
||||
x, y = entry['x'], entry['y']
|
||||
tile = image.crop((x, y, x + cell, y + cell))
|
||||
fixed = lower_full_stop(tile)
|
||||
idlen, _, w, h, _, _ = tga_geometry(raw)
|
||||
result = bytearray(raw)
|
||||
for j in range(cell):
|
||||
row = y + j if desc & 0x20 else h - 1 - y - j
|
||||
for i in range(cell):
|
||||
offset = 18 + idlen + (row * w + x + i) * 4
|
||||
r, g, b, a = fixed.getpixel((i, j))
|
||||
result[offset:offset + 4] = bytes((b, g, r, a))
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--source', type=Path, required=True)
|
||||
p.add_argument('--cells', type=Path, required=True)
|
||||
p.add_argument('--out', type=Path, required=True)
|
||||
args = p.parse_args()
|
||||
if args.source.resolve() == args.out.resolve():
|
||||
p.error('output must differ from source')
|
||||
result = patch_atlas(args.source.read_bytes(), json.loads(args.cells.read_text(encoding='utf-8')))
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_bytes(result)
|
||||
print('Full stop anchored at cell bottom; other atlas bytes preserved.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Translate only direct display-call literals in Dragon .dsc scripts.
|
||||
|
||||
Source defaults to the original single-byte encoding; JSON mappings are UTF-8
|
||||
and use decoded string values (including actual control characters). The game
|
||||
output encodes translated literals as GB2312 with script escapes restored.
|
||||
"""
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
ALLOWED = {'DisplayMessage': 2, 'SetMissionDescription': 1}
|
||||
ESCAPES = {ord(k): ord(v) for k, v in {'a': '\a', 'b': '\b', 'f': '\f',
|
||||
'n': '\n', 'r': '\r', 't': '\t', 'v': '\v', '\\': '\\', '"': '"'}.items()}
|
||||
ENCODE_ESCAPES = {v: bytes((92, k)) for k, v in ESCAPES.items()}
|
||||
FORMAT = re.compile(r'%%|%[-+ #0]*[\d*]*(?:\.[\d*]+)?[hlL]?[diouxXeEfgGcs%]')
|
||||
CONTROLS = re.compile(r'[\a\b\f\n\r\t\v]')
|
||||
|
||||
|
||||
def format_tokens(text):
|
||||
# "speed 0% is ..." is prose, not the printf conversion "% i".
|
||||
# Only exclude a numeric percent whose apparent conversion cuts a word.
|
||||
return [m.group() for m in FORMAT.finditer(text)
|
||||
if not (m.start() > 0 and text[m.start() - 1].isdigit()
|
||||
and m.end() < len(text) and text[m.end()].isascii()
|
||||
and text[m.end()].isalpha() and ' ' in m.group())]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Literal:
|
||||
start: int
|
||||
end: int
|
||||
text: str
|
||||
call: str
|
||||
argument: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Token:
|
||||
kind: str
|
||||
start: int
|
||||
end: int
|
||||
value: str
|
||||
|
||||
|
||||
def decode_literal(raw: bytes, encoding: str) -> str:
|
||||
# A continuation includes the physical newline and indentation, not a
|
||||
# game newline. Original scripts also contain backslash-space-CRLF.
|
||||
raw = re.sub(rb'\\[ \t]*\r?\n[ \t]*', b'', raw)
|
||||
out = bytearray()
|
||||
i = 0
|
||||
while i < len(raw):
|
||||
if raw[i] == 92:
|
||||
if i + 1 == len(raw) or raw[i + 1] not in ESCAPES:
|
||||
raise ValueError('unsupported script escape in string literal')
|
||||
out.append(ESCAPES[raw[i + 1]])
|
||||
i += 2
|
||||
else:
|
||||
out.append(raw[i])
|
||||
i += 1
|
||||
return out.decode(encoding)
|
||||
|
||||
|
||||
def tokenize(source: bytes, encoding: str) -> list[Token]:
|
||||
tokens = []
|
||||
i = 0
|
||||
while i < len(source):
|
||||
b = source[i]
|
||||
if b in b' \t\r\n':
|
||||
i += 1
|
||||
elif b == 35 or source[i:i+2] == b'//':
|
||||
end = source.find(b'\n', i)
|
||||
i = len(source) if end < 0 else end + 1
|
||||
elif source[i:i+2] == b'/*':
|
||||
end = source.find(b'*/', i + 2)
|
||||
if end < 0:
|
||||
raise ValueError('unterminated block comment')
|
||||
i = end + 2
|
||||
elif b == 34:
|
||||
start = i
|
||||
i += 1
|
||||
while i < len(source) and source[i] != 34:
|
||||
i += 2 if source[i] == 92 else 1
|
||||
if i >= len(source):
|
||||
raise ValueError('unterminated string at byte %d' % start)
|
||||
i += 1
|
||||
tokens.append(Token('string', start, i, decode_literal(source[start+1:i-1], encoding)))
|
||||
elif b in b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_':
|
||||
start = i
|
||||
i += 1
|
||||
while i < len(source) and source[i] in b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_0123456789':
|
||||
i += 1
|
||||
tokens.append(Token('name', start, i, source[start:i].decode('ascii')))
|
||||
else:
|
||||
tokens.append(Token('symbol', i, i + 1, chr(b)))
|
||||
i += 1
|
||||
return tokens
|
||||
|
||||
|
||||
def extract_display_literals(source: bytes, encoding: str = 'latin1') -> list[Literal]:
|
||||
tokens = tokenize(source, encoding)
|
||||
found = []
|
||||
for index, token in enumerate(tokens[:-1]):
|
||||
if token.kind != 'name' or token.value not in ALLOWED or tokens[index+1].value != '(':
|
||||
continue
|
||||
arguments = [[]]
|
||||
nesting = ['(']
|
||||
for current in tokens[index+2:]:
|
||||
value = current.value if current.kind == 'symbol' else None
|
||||
if value in ('(', '[', '{'):
|
||||
nesting.append(value)
|
||||
elif value in (')', ']', '}'):
|
||||
if not nesting or nesting[-1] != {')': '(', ']': '[', '}': '{'}[value]:
|
||||
raise ValueError('unbalanced display call at byte %d' % token.start)
|
||||
nesting.pop()
|
||||
if not nesting:
|
||||
break
|
||||
if value == ',' and len(nesting) == 1:
|
||||
arguments.append([])
|
||||
else:
|
||||
arguments[-1].append(current)
|
||||
else:
|
||||
raise ValueError('unterminated display call at byte %d' % token.start)
|
||||
if len(arguments) != ALLOWED[token.value]:
|
||||
continue
|
||||
for number, arg in enumerate(arguments):
|
||||
if len(arg) == 1 and arg[0].kind == 'string':
|
||||
lit = arg[0]
|
||||
found.append(Literal(lit.start, lit.end, lit.value, token.value, number))
|
||||
elif (len(arg) == 3 and [t.kind for t in arg] == ['string', 'name', 'string']
|
||||
and arg[1].value == 'F'
|
||||
and arg[0].value.startswith('\fTo use a charged spell select it')
|
||||
and arg[0].value.endswith('pressing the ')
|
||||
and arg[2].value.startswith(' KEY marked under it')):
|
||||
# Stock English Tutorial.dsc has unescaped quotes around F in
|
||||
# this specific display argument. Keep the complete text span;
|
||||
# do not generalize to expressions or other malformed strings.
|
||||
found.append(Literal(arg[0].start, arg[2].end,
|
||||
arg[0].value + '"F"' + arg[2].value,
|
||||
token.value, number))
|
||||
return sorted(found, key=lambda literal: literal.start)
|
||||
|
||||
|
||||
def encode_literal(text: str) -> bytes:
|
||||
raw = text.encode('gb2312')
|
||||
return b'"' + b''.join(ENCODE_ESCAPES.get(b, bytes((b,))) for b in raw) + b'"'
|
||||
|
||||
|
||||
def patch_display_calls(source: bytes, translations: dict[str, str], *, allow_alignment_changes=False) -> bytes:
|
||||
literals = extract_display_literals(source)
|
||||
missing = set(translations) - {literal.text for literal in literals}
|
||||
if missing:
|
||||
raise ValueError('translation source not found: %r' % sorted(missing))
|
||||
replacements = []
|
||||
for literal in literals:
|
||||
if literal.text not in translations:
|
||||
continue
|
||||
translated = translations[literal.text]
|
||||
if not isinstance(translated, str):
|
||||
raise ValueError('translation must be a string')
|
||||
original_controls = CONTROLS.findall(literal.text)
|
||||
translated_controls = CONTROLS.findall(translated)
|
||||
if allow_alignment_changes:
|
||||
# Explicit opt-in: replace paragraph alignment/indent controls only.
|
||||
# Newline count/order and all other controls remain protected.
|
||||
original_controls = ['ALIGN' if c in '\a\b\f\v' else c for c in original_controls]
|
||||
translated_controls = ['ALIGN' if c in '\a\b\f\v' else c for c in translated_controls]
|
||||
if original_controls != translated_controls:
|
||||
raise ValueError('control sequence changed: %r' % literal.text)
|
||||
if format_tokens(literal.text) != format_tokens(translated):
|
||||
raise ValueError('format sequence changed: %r' % literal.text)
|
||||
replacements.append((literal.start, literal.end, encode_literal(translated)))
|
||||
result = source
|
||||
for start, end, value in reversed(replacements):
|
||||
result = result[:start] + value + result[end:]
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source', required=True, type=Path)
|
||||
parser.add_argument('--mapping', required=True, type=Path)
|
||||
parser.add_argument('--out', required=True, type=Path)
|
||||
parser.add_argument('--allow-alignment-changes', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.source.resolve() == args.out.resolve():
|
||||
parser.error('output must differ from source')
|
||||
source = args.source.read_bytes()
|
||||
mapping = json.loads(args.mapping.read_text(encoding='utf-8'))
|
||||
if not isinstance(mapping, dict):
|
||||
parser.error('mapping must be an object of original text to translated text')
|
||||
result = patch_display_calls(source, mapping, allow_alignment_changes=args.allow_alignment_changes)
|
||||
changed = [literal for literal in extract_display_literals(source) if literal.text in mapping]
|
||||
manifest = {'source': str(args.source.resolve()), 'output': str(args.out.resolve()),
|
||||
'source_sha256': hashlib.sha256(source).hexdigest(),
|
||||
'output_sha256': hashlib.sha256(result).hexdigest(),
|
||||
'mapping_count': len(mapping), 'changed_literals': len(changed),
|
||||
'allow_alignment_changes': args.allow_alignment_changes,
|
||||
'changes': [{'start': lit.start, 'end': lit.end, 'call': lit.call,
|
||||
'argument': lit.argument} for lit in changed]}
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_bytes(result)
|
||||
args.out.with_suffix('.manifest.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8')
|
||||
print('Translated %d literals using %d mappings' % (len(changed), len(mapping)))
|
||||
print('Source SHA256: ' + manifest['source_sha256'])
|
||||
print('Output SHA256: ' + manifest['output_sha256'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,35 @@
|
||||
import unittest
|
||||
from PIL import Image
|
||||
from build_cjk import lower_full_stop, image_to_tga, tga_to_image
|
||||
from fix_full_stop import patch_atlas
|
||||
|
||||
|
||||
class FullStopTests(unittest.TestCase):
|
||||
def test_ink_moves_to_bottom_without_shape_loss(self):
|
||||
tile = Image.new('RGBA', (12, 12))
|
||||
for x, y in [(3, 5), (4, 5), (2, 6), (5, 6), (2, 7), (5, 7), (3, 8), (4, 8)]:
|
||||
tile.putpixel((x, y), (255, 255, 255, 255))
|
||||
fixed = lower_full_stop(tile)
|
||||
self.assertEqual(fixed.getbbox(), (2, 8, 6, 12))
|
||||
self.assertEqual(tile.crop((2, 5, 6, 9)).tobytes(), fixed.crop((2, 8, 6, 12)).tobytes())
|
||||
self.assertEqual(lower_full_stop(fixed).tobytes(), fixed.tobytes())
|
||||
|
||||
def test_patch_preserves_neighbors_header_footer_and_orientation(self):
|
||||
for desc in (8, 40):
|
||||
image = Image.new('RGBA', (24, 24), (7, 8, 9, 255))
|
||||
image.paste((0, 0, 0, 0), (12, 12, 24, 24))
|
||||
image.putpixel((14, 17), (255, 255, 255, 255))
|
||||
raw = image_to_tga(image, desc, b'unchanged-footer')
|
||||
meta = {'cell': 12, 'atlas': [24, 24], 'cells': {'41379': {'uni': '。', 'x': 12, 'y': 12}}}
|
||||
fixed = patch_atlas(raw, meta)
|
||||
after, _ = tga_to_image(fixed)
|
||||
self.assertEqual(fixed[:18], raw[:18])
|
||||
self.assertTrue(fixed.endswith(b'unchanged-footer'))
|
||||
self.assertEqual(after.crop((0, 0, 24, 12)).tobytes(), image.crop((0, 0, 24, 12)).tobytes())
|
||||
self.assertEqual(after.crop((0, 12, 12, 24)).tobytes(), image.crop((0, 12, 12, 24)).tobytes())
|
||||
self.assertEqual(after.getpixel((14, 23)), (255, 255, 255, 255))
|
||||
self.assertEqual(patch_atlas(fixed, meta), fixed)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,93 @@
|
||||
import unittest
|
||||
from script_text import extract_display_literals, patch_display_calls
|
||||
|
||||
|
||||
class ScriptTextTests(unittest.TestCase):
|
||||
def test_numeric_percent_in_prose_is_not_a_printf_conversion(self):
|
||||
source = b'SetMissionDescription("speed 0% is pause; %s %d");'
|
||||
result = patch_display_calls(source, {'speed 0% is pause; %s %d': '速度 0% 等于暂停;%s %d'})
|
||||
self.assertIn('速度 0%'.encode('gb2312'), result)
|
||||
with self.assertRaises(ValueError):
|
||||
patch_display_calls(source, {'speed 0% is pause; %s %d': '速度 0% 等于暂停'})
|
||||
|
||||
def test_stock_tutorial_unescaped_function_key_is_a_display_span(self):
|
||||
text = '\fTo use a charged spell select it by pressing the "F" KEY marked under it'
|
||||
source = b'SetMissionDescription("\\fTo use a charged spell select it by pressing the "F" KEY marked under it");'
|
||||
self.assertEqual(extract_display_literals(source)[0].text, text)
|
||||
self.assertEqual(patch_display_calls(source, {text: '\f按 F 键'}),
|
||||
'SetMissionDescription("\\f按 F 键");'.encode('gb2312'))
|
||||
self.assertEqual(extract_display_literals(b'SetMissionDescription("other "F" text");'), [])
|
||||
|
||||
def test_only_direct_display_literals_change(self):
|
||||
source = b'DisplayMessage("Help", "Move");\r\nEnableEvent("Move");\r\n'
|
||||
result = patch_display_calls(source, {'Help': '帮助', 'Move': '移动'})
|
||||
self.assertEqual(result, 'DisplayMessage("帮助", "移动");\r\nEnableEvent("Move");\r\n'.encode('gb2312'))
|
||||
|
||||
def test_comments_and_non_display_strings_are_untouched(self):
|
||||
source = (b'# DisplayMessage("Help", "Move");\r\n'
|
||||
b'// SetMissionDescription("Move");\n'
|
||||
b'/* DisplayMessage("Help", "Move"); */\n'
|
||||
b'x="DisplayMessage(\\\"Help\\\", \\\"Move\\\")";\n'
|
||||
b'SetMissionDescription("Move");')
|
||||
result = patch_display_calls(source, {'Move': '移动'})
|
||||
self.assertEqual(result, source[:-8] + '"移动");'.encode('gb2312'))
|
||||
|
||||
def test_function_definition_and_expressions_are_not_direct_literals(self):
|
||||
source = (b'DisplayMessage(Caption, Text){MessageBox(Caption, Text);}\n'
|
||||
b'DisplayMessage("Help" + suffix, getText("Move"));\n'
|
||||
b'SetMissionDescription(text);')
|
||||
self.assertEqual(extract_display_literals(source), [])
|
||||
|
||||
def test_duplicate_display_text_is_replaced_in_both_calls(self):
|
||||
source = b'DisplayMessage("Title", "Move");SetMissionDescription("Move");'
|
||||
self.assertEqual(patch_display_calls(source, {'Move': '移动'}),
|
||||
'DisplayMessage("Title", "移动");SetMissionDescription("移动");'.encode('gb2312'))
|
||||
|
||||
def test_continuation_quotes_and_controls_round_trip(self):
|
||||
source = b'DisplayMessage("Title", "\\fPress \\\"H\\\" \\\r\n\tfor help.\\n\\aDone.");'
|
||||
entries = extract_display_literals(source)
|
||||
self.assertEqual(entries[1].text, '\fPress "H" for help.\n\aDone.')
|
||||
result = patch_display_calls(source, {entries[1].text: '\f按“H”键查看帮助。\n\a完成。'})
|
||||
self.assertEqual(result, 'DisplayMessage("Title", "\\f按“H”键查看帮助。\\n\\a完成。");'.encode('gb2312'))
|
||||
|
||||
def test_literal_backslash_and_quote_are_escaped_on_output(self):
|
||||
source = b'DisplayMessage("Title", "A\\\\B \\\"Q\\\"");'
|
||||
self.assertEqual(patch_display_calls(source, {'A\\B "Q"': '甲\\乙 "Q"'}),
|
||||
'DisplayMessage("Title", "甲\\\\乙 \\\"Q\\\"");'.encode('gb2312'))
|
||||
|
||||
def test_missing_mapping_key_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, 'not found'):
|
||||
patch_display_calls(b'SetMissionDescription("Move");', {'Wrong': '错误'})
|
||||
|
||||
def test_unencodable_translation_is_rejected(self):
|
||||
with self.assertRaises(UnicodeEncodeError):
|
||||
patch_display_calls(b'SetMissionDescription("Move");', {'Move': '移动🙂'})
|
||||
|
||||
def test_control_sequence_change_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, 'control'):
|
||||
patch_display_calls(b'SetMissionDescription("\\fMove\\nNow");', {'\fMove\nNow': '移动'})
|
||||
|
||||
def test_printf_sequence_change_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, 'format'):
|
||||
patch_display_calls(b'SetMissionDescription("Move %s %d");', {'Move %s %d': '移动 %d %s'})
|
||||
|
||||
def test_malformed_string_fails_before_output(self):
|
||||
with self.assertRaisesRegex(ValueError, 'unterminated'):
|
||||
patch_display_calls(b'SetMissionDescription("Move);', {'Move': '移动'})
|
||||
|
||||
def test_unknown_escape_is_not_silently_reinterpreted(self):
|
||||
with self.assertRaisesRegex(ValueError, 'escape'):
|
||||
extract_display_literals(b'SetMissionDescription("\\qMove");')
|
||||
|
||||
def test_empty_mapping_is_byte_identical(self):
|
||||
source = b'#\xff\xfe\r\nDisplayMessage("Title", "Move");'
|
||||
self.assertEqual(patch_display_calls(source, {}), source)
|
||||
|
||||
def test_allowed_call_nested_in_expression_is_found(self):
|
||||
source = b'if (DisplayMessage("Help", "Move")) { EnableEvent("Move"); }'
|
||||
self.assertEqual(patch_display_calls(source, {'Move': '移动'}),
|
||||
'if (DisplayMessage("Help", "移动")) { EnableEvent("Move"); }'.encode('gb2312'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Complete tutorial mapping coverage and engine layout regression."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from test_cjk_layout import layout
|
||||
|
||||
|
||||
class CompleteTutorialTests(unittest.TestCase):
|
||||
def test_remaining_pages_fit_without_orphan_punctuation(self):
|
||||
mapping = json.loads(Path(__file__).with_name('tutorial_cn.json').read_text(encoding='utf-8'))
|
||||
for original, translated in list(mapping.items())[12:]:
|
||||
for width in (360, 400, 480):
|
||||
with self.subTest(original=original[:40], width=width):
|
||||
lines, _ = layout(translated, width=width, alignment=2)
|
||||
self.assertTrue(lines)
|
||||
self.assertLessEqual(lines[-1][2] + 12, 140)
|
||||
for raw, x, y in lines:
|
||||
text = raw.decode('gb2312')
|
||||
self.assertNotIn(text[0], ',。;:、!?)”')
|
||||
pixels = sum(5 if c == ' ' else 8 if ord(c) < 128 else 12 for c in text)
|
||||
self.assertEqual(x, width // 2 - pixels // 2)
|
||||
|
||||
def test_translations_cover_core_controls_and_constraints(self):
|
||||
mapping = json.loads(Path(__file__).with_name('tutorial_cn.json').read_text(encoding='utf-8'))
|
||||
text = '\n'.join(mapping.values())
|
||||
for term in ['PgUp', 'PgDn', 'Tab', '空格', '50%', 'E 键', 'F12', 'Alt', '0%', 'P 键', 'Esc']:
|
||||
self.assertIn(term, text)
|
||||
self.assertEqual(sum(k.startswith('\fTo use a charged spell') for k in mapping), 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Flight tutorial text fits the existing dialog using actual engine layout."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from test_cjk_layout import layout
|
||||
|
||||
|
||||
class FlightTests(unittest.TestCase):
|
||||
def test_flight_instructions_fit_and_share_dialog_center(self):
|
||||
mapping = json.loads(Path(__file__).with_name('tutorial_cn.json').read_text(encoding='utf-8'))
|
||||
bodies = [v for k, v in mapping.items() if k.startswith('\fYou can fly')]
|
||||
self.assertEqual(len(bodies), 2)
|
||||
self.assertEqual(bodies[0], bodies[1])
|
||||
for body in bodies:
|
||||
for width in (360, 400, 480):
|
||||
lines, _ = layout(body, width=width, alignment=0)
|
||||
self.assertEqual(len(lines), 2)
|
||||
for raw, x, y in lines:
|
||||
text = raw.decode('gb2312')
|
||||
self.assertEqual(x, (width - len(text) * 12) // 2)
|
||||
self.assertNotIn(text[0], ',。;:、')
|
||||
self.assertEqual(lines[1][2] - lines[0][2], 17)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Replay tutorial introduction through the deployed engine's layout routine."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from script_text import patch_display_calls
|
||||
from test_cjk_layout import layout
|
||||
|
||||
|
||||
class IntroTests(unittest.TestCase):
|
||||
def test_intro_paragraphs_are_centered_without_orphan_punctuation(self):
|
||||
mapping = json.loads(Path(__file__).with_name('tutorial_cn.json').read_text(encoding='utf-8'))
|
||||
intros = [v for k, v in mapping.items() if k.startswith('\fNow')]
|
||||
self.assertEqual(len(intros), 2)
|
||||
for intro in intros:
|
||||
for width in (360, 400, 480):
|
||||
lines, _ = layout(intro, width=width, alignment=0)
|
||||
for raw, x, y in lines:
|
||||
text = raw.decode("gb2312")
|
||||
pixels = sum(5 if c == " " else 8 if ord(c) < 128 else 12 for c in text)
|
||||
self.assertEqual(x, (width - pixels) // 2)
|
||||
self.assertNotIn(raw.decode('gb2312')[0], ',。!?、;:”)')
|
||||
self.assertEqual(len(lines), 4)
|
||||
|
||||
def test_alignment_override_is_explicit_and_preserves_newlines(self):
|
||||
source = b'SetMissionDescription("\\fA\\n\\aB");'
|
||||
mapping = {'\fA\n\aB': '\bA\n\bB'}
|
||||
with self.assertRaises(ValueError):
|
||||
patch_display_calls(source, mapping)
|
||||
self.assertEqual(patch_display_calls(source, mapping, allow_alignment_changes=True),
|
||||
b'SetMissionDescription("\\bA\\n\\bB");')
|
||||
with self.assertRaises(ValueError):
|
||||
patch_display_calls(source, {'\fA\n\aB': '\bA\bB'}, allow_alignment_changes=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"Tutorial": "操作教程",
|
||||
"\fNow you have the chance to learn the game controls. Note the \"Next\" button at the top of the screen - click on it after completing each task to move to the next.\n\fYou can get help with the game interface by pressing the \"H\" button.\n\fYou don't have to finish this tutorial (although we recommend doing it once - there are some useful things to learn), you can exit it at any time by pressing the \"Esc\" button.\n\u0007(Note two buttons marked by red arrows: \"Next\" and a button that shows this text)": "\u0007每完成一项练习,点击上方“继续教程”。\n\u0007按 H 键查看界面帮助。\n\u0007建议首次游玩时完成教程;按 Esc 可退出。\n\u0007红箭头指向“继续教程”和“当前任务”按钮。",
|
||||
"\fNow you have the chance to learn the game controls. Note the \"Next\" button at the top of the screen - click on it after completing each task to move to the next.\n\fYou can get help on the game interface by pressing the \"H\" button.\n\fYou don't have to finish this tutorial (although we recommend doing it once - there are some useful things to learn), you can exit it at any time by pressing \"Esc\" button.\n\u0007(Note two buttons marked by red arrows: \"Next\" and a button that shows this text)": "\u0007每完成一项练习,点击上方“继续教程”。\n\u0007按 H 键查看界面帮助。\n\u0007建议首次游玩时完成教程;按 Esc 可退出。\n\u0007红箭头指向“继续教程”和“当前任务”按钮。",
|
||||
"Camera rotation": "旋转镜头",
|
||||
"\fMove the mouse cursor to the left or right edge of the screen to rotate the view. Alternatively, use the numeric keypad or hold the center mouse button (wheel) and the Shift key and move the mouse.": "\f将鼠标移到屏幕左、右边缘可旋转视角。也可以使用数字小键盘,或按住 Shift 和鼠标中键(滚轮)并移动鼠标。",
|
||||
"Center the camera": "镜头回正",
|
||||
"\fTo bring the camera into the default position behind the dragon press on the \"5\" key on the numeric keypad.": "\f按数字小键盘上的 5 键,将镜头恢复到巨龙身后的默认视角。",
|
||||
"Zoom": "缩放视角",
|
||||
"\fYou can zoom the camera by rotating the mouse wheel or by pressing the \"+\" and \"-\" keys on the numeric keypad.": "\f滚动鼠标滚轮,或按数字小键盘上的 +、- 键,可拉近或拉远视角。",
|
||||
"Flight": "飞行",
|
||||
"\fYou can fly using the mouse or the keyboard. Just left-click anywhere - and your dragon will fly there (note the indicator that appears there with a light globe that marks your dragon's altitude).\n\fYou can also use the keyboard to fly: up arrow moves forward, left and right arrow keys steer.": "\u0007左键点击目的地即可飞行,光球表示飞行高度。\n\u0007也可用方向键:上键前进,左、右键转向。",
|
||||
"\fYou can fly using the mouse or the keyboard. Just left-click anywhere - and your dragon will fly there (note the indicator that appears there with a light globe that marks your dragon's altitude).\n\fYou can also use the keyboard to fly: up arrow moves forward, left and right arrow keys steer.": "\u0007左键点击目的地即可飞行,光球表示飞行高度。\n\u0007也可用方向键:上键前进,左、右键转向。",
|
||||
"Flight height": "飞行高度",
|
||||
"\fIf you fly too low, enemies will hit you easily and if you fly too high - you'll have problems aiming accurately. Besides that, you can easily avoid enemy fire by changing altitude. Use \"Q\" and \"A\" keys or \"PgUp\" and \"PgDn\" or the buttons in the lower left hand corner of the screen.\n\u0007(Arrow marks the height change buttons)": "\u0007低飞容易受攻击,高飞难以瞄准;变换高度可躲避攻击。用 Q、A 或 PgUp、PgDn 键调节高度,也可点击左下角按钮。\n\u0007箭头指向高度调节按钮。",
|
||||
"Attack": "攻击",
|
||||
"\fThe easiest way to attack something is to right-click on it. If you use the keyboard to fly - just turn to the target and it will be highlighted. Then press \"Tab\" or \"Space\" to shoot.": "\u0007右键点击目标即可攻击。用方向键飞行时,转向目标使其高亮,再按 Tab 或空格键攻击。",
|
||||
"Charged attack": "蓄力攻击",
|
||||
"\fYou can make your next attack much more powerful by charging it before shooting. Press and hold the attack button until the charge gauge in the top left hand corner fills. Attacking power is directly proportional to the gauge value. To shoot - just release the attack button. Try to charge your attack now to at least 50%.\n\u0007(Arrow marks the attack charge gauge)": "\u0007按住攻击键蓄力,左上角的蓄力条越满,攻击越强。松开攻击键即可发射。现在请蓄力至少 50%。\n\u0007箭头指向攻击蓄力条。",
|
||||
"\fTry to charge your attack now to at least 50% by holding the attack button.": "\u0007按住攻击键,将蓄力条充至至少 50%。",
|
||||
"Different attacks": "切换攻击",
|
||||
"\fYou can select different natural attacks such as breath by using key \"2\" on the keyboard or clicking the screen button.\n\u0007(Arrow marks the breath attack button)": "\u0007按数字键 2 或点击对应按钮,可切换为吐息攻击。\n\u0007箭头指向吐息按钮。",
|
||||
"\fSelect the breath attack": "\u0007请选择吐息攻击。",
|
||||
"Breath usage details": "吐息用法",
|
||||
"\fNote that if you press the attack button without holding it while using breath attack, the dragon will gather a full charge first. You can hold the button as usual if you want to use a half-charge, for example.": "\u0007轻按攻击键后松开,吐息会自动蓄满力再发射。要用半蓄力吐息,就按住攻击键,蓄力一半时松开。",
|
||||
"Eating": "进食",
|
||||
"\fDragons need to eat sometimes. You can catch small animals and monsters, if your stamina indicator (green) is full - it fills by itself over time. Select the \"catch\" attack by pressing the \"E\" button or clicking the screen button and attacking as usual. To devour your prey, press the attack button or \"E\" once more. \n\fA hungry dragon (when the yellow indicator is empty) regenerates its health, breath and mana much slower.\n\u0007(Arrows mark the catch attack button and hunger/stamina indicator)": "\u0007绿色体力条会随时间恢复,满时可抓取小动物或怪物。按 E 键或点击抓取按钮,再像平常一样攻击猎物。抓住后,再按攻击键或 E 键吞食。\n\u0007黄色饥饿条耗尽时,生命、吐息和魔力的恢复速度会大幅降低。\n\u0007箭头指向抓取按钮及饥饿、体力条。",
|
||||
"Charging spells": "法术充能",
|
||||
"\fDragons are magical creatures. Sometimes you get a new spell. It appears along the right edge of the screen as a grayed out, round icon.\n\fTo use a spell, move it into one of the spell slots along the top edge of the screen either by dragging it, or just by clicking on an icon. The spell will start to charge. As soon as it's completely surrounded by a blue line you can use it.\n\u0007(Arrows mark spell slots)": "\u0007获得的新法术会以灰色圆形图标显示在屏幕右侧。\n\u0007点击图标,或将其拖到屏幕上方的法术槽中,即可开始充能。蓝色边框完整围住图标后,法术便可使用。\n\u0007箭头指向法术槽。",
|
||||
"Casting spells": "施放法术",
|
||||
"\fTo use a charged spell select it by left-clicking on the icon or pressing the \"F\" KEY marked under it (in this case it's \"F12\") and then attack in the usual way (right mouse button or \"Tab\" or \"Space\" key).\n\fIf you plan to attack with magic for a while, you can select a few spells by holding \"ALT\" and left-clicking on them and they will be used automatically as soon as they are charged (spells with lower numbers are selected first if more than one is currently charged).": "\u0007点击已充能的法术,或按图标下的功能键选择它(本次为 F12),再用鼠标右键、Tab 或空格键攻击。\n\u0007按住 Alt 并左键点击多个法术,可让它们充能后自动选用。同时充满时,优先选用编号较小的法术。",
|
||||
"\fShoot with a spell.": "\u0007请用法术攻击一次。",
|
||||
"Inactive spells": "备用法术",
|
||||
"\fIf you use a lot of spells, some day you'll have too many of them to fit into your spell slots. To remove them from the screen (temporarily, of course) you can left-click the screen button in the top right hand corner. Click it again to show the spells list again.\n\u0007(Arrow marks the button that toggles the inactive spells list)": "\u0007法术槽放不下所有法术时,可点击右上角按钮,暂时隐藏备用法术列表。再次点击即可显示列表。\n\u0007箭头指向备用法术列表的开关。",
|
||||
"\fPress this button again to show the spells list.": "\u0007请再点一次此按钮,显示法术列表。",
|
||||
"Speed control": "游戏速度",
|
||||
"\fYou can always make the game faster or slower to suit your taste (use keys \"+\" and \"-\" on the main keyboard.) To pause the game (press the \"P\" key). You can look around or give commands to your dragon at any time, while the game is paused.\n\u0007(Don't set the pause mode now and remember that speed 0% is, effectively, also pause.)\n\u0007(Arrow marks the speed-related controls)": "\u0007主键盘 +、- 键调整速度,P 键暂停。暂停时可自由观察并下达指令。\n\u0007这一步请勿暂停;速度调至 0% 也等同暂停。\n\u0007箭头指向游戏速度控件。",
|
||||
"\fChange the game speed (don't set the pause now, please).": "\u0007请改变游戏速度,这一步不要暂停。",
|
||||
"End of tutorial": "教程结束",
|
||||
"\u0007Congratulations! Now you are ready to play the game! \n\u0007To exit the tutorial press the \"Esc\" button.": "\u0007恭喜完成教程!现在可以开始游戏了!\n\u0007按 Esc 键退出教程。",
|
||||
"\fIf you fly too low, enemies will hit you easily and if you fly too high - you'll have problems aiming accurately. Besides that, you can easily avoid enemy fire by changing altitude. Use hand \"Q\" and \"A\" keys or \"PgUp\" and \"PgDn\" or the buttons in the lower left hand corner of the screen.\n\u0007(Arrow marks the height change buttons)": "\u0007低飞容易受攻击,高飞难以瞄准;变换高度可躲避攻击。用 Q、A 或 PgUp、PgDn 键调节高度,也可点击左下角按钮。\n\u0007箭头指向高度调节按钮。",
|
||||
"\fDragons need to eat sometimes. You can catch small animals and monsters if your stamina indicator (green) is full - it fills by itself over time. Select the \"catch\" attack by pressing the \"E\" button or clicking the screen button and attacking as usual. To devour your prey, press the attack button or \"E\" once more. \n\fA hungry dragon (when the yellow indicator is empty) regenerates its health, breath and mana much slower.\n\u0007(Arrows mark the catch attack button and hunger/stamina indicator)": "\u0007绿色体力条会随时间恢复,满时可抓取小动物或怪物。按 E 键或点击抓取按钮,再像平常一样攻击猎物。抓住后,再按攻击键或 E 键吞食。\n\u0007黄色饥饿条耗尽时,生命、吐息和魔力的恢复速度会大幅降低。\n\u0007箭头指向抓取按钮及饥饿、体力条。",
|
||||
"\fYou can always make the game faster or slower to suit your taste (use keys \"+\" and \"-\" on the main keyboard for that) or pause the game (press the \"P\" key). You can look around or give commands to your dragon at any time, while the game is paused.\n\u0007(Don't set the pause mode now and remember that speed 0% is, effectively, also pause.)\n\u0007(Arrow marks the speed-related controls)": "\u0007主键盘 +、- 键调整速度,P 键暂停。暂停时可自由观察并下达指令。\n\u0007这一步请勿暂停;速度调至 0% 也等同暂停。\n\u0007箭头指向游戏速度控件。"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Verify the bounded complete tutorial translation against an original backup."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from script_text import extract_display_literals, patch_display_calls
|
||||
|
||||
ORIGINAL_SHA256 = 'bd959758223b976ffac35b4ef7d94661f62060c1134316eddb559772d544e02c'
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('mode', choices=['baseline', 'modified', 'rollback'])
|
||||
p.add_argument('original', type=Path)
|
||||
p.add_argument('candidate', type=Path)
|
||||
args = p.parse_args()
|
||||
source, result = args.original.read_bytes(), args.candidate.read_bytes()
|
||||
assert hashlib.sha256(source).hexdigest() == ORIGINAL_SHA256, 'original hash mismatch'
|
||||
if args.mode in ('baseline', 'rollback'):
|
||||
assert result == source, 'original bytes not restored'
|
||||
assert extract_display_literals(result)[0].text == 'Tutorial'
|
||||
print(args.mode.upper() + ': original bytes and English tutorial restored; PASS')
|
||||
return
|
||||
mapping = json.loads(Path(__file__).with_name('tutorial_cn.json').read_text(encoding='utf-8'))
|
||||
assert result == patch_display_calls(source, mapping, allow_alignment_changes=True), 'build mismatch'
|
||||
before = extract_display_literals(source)
|
||||
after = extract_display_literals(result, encoding='gb2312')
|
||||
assert len(before) == len(after) == 57
|
||||
changed = []
|
||||
left = right = 0
|
||||
for i, (a, b) in enumerate(zip(before, after)):
|
||||
assert source[left:a.start] == result[right:b.start], 'non-display bytes changed'
|
||||
assert (a.call, a.argument) == (b.call, b.argument)
|
||||
if a.text != b.text:
|
||||
changed.append(i)
|
||||
assert b.text == mapping[a.text]
|
||||
else:
|
||||
assert source[a.start:a.end] == result[b.start:b.end]
|
||||
left, right = a.end, b.end
|
||||
assert source[left:] == result[right:], 'trailing bytes changed'
|
||||
assert changed == list(range(57)), changed
|
||||
print('MODIFIED: all 57 display literals translated; all other bytes unchanged; PASS')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user