7 Commits

14 changed files with 746 additions and 2 deletions
+17 -2
View File
@@ -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,12 +41,16 @@ 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.
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).
Implementation history and remaining work:
[tutorial acceptance and subsequent small batches](docs/superpowers/plans/2026-09-20-high-impact-simple-first.md).
## Legal
+59
View File
@@ -153,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 1214). 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.
@@ -160,3 +160,32 @@ def validate_size(original, result):
报告本项改了什么、实机看到的收益、验证结果、已知未完成点和恢复方式。
每项单独提交;没有明确收益的试验及时止步,不因已经生成文件就继续批处理。
## 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`。前文未勾选的分批试作项保留为历史记录,以本条最终状态为准。
+58
View File
@@ -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`,其中早期“待验收”状态以本说明和最终验收条目为准。
+12
View File
@@ -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()
+45
View File
@@ -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()
+213
View File
@@ -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()
+35
View File
@@ -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()
+93
View File
@@ -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()
+33
View File
@@ -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()
+26
View File
@@ -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()
+36
View File
@@ -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()
+44
View File
@@ -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箭头指向游戏速度控件。"
}
+46
View File
@@ -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()