feat: translate first four tutorial stages for in-game trial

This commit is contained in:
DragonHD
2026-09-20 11:40:02 +08:00
parent 8ee279864a
commit 2e214efeb8
6 changed files with 341 additions and 0 deletions
+10
View File
@@ -153,3 +153,13 @@ 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 - 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. 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`). - 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.
@@ -160,3 +160,15 @@ def validate_size(original, result):
报告本项改了什么、实机看到的收益、验证结果、已知未完成点和恢复方式。 报告本项改了什么、实机看到的收益、验证结果、已知未完成点和恢复方式。
每项单独提交;没有明确收益的试验及时止步,不因已经生成文件就继续批处理。 每项单独提交;没有明确收益的试验及时止步,不因已经生成文件就继续批处理。
## 2026-09-20 分批执行进度
按用户“一步一步尝试汉化”将 Task 1 的首次实机批次缩小为开场、旋转镜头、镜头回正、缩放,飞行留待下一批。
- [x] 固定原始备份与 SHA256,建立独立工作区。
- [x] 文本扫描器和 14 项测试;首批 9 条映射、12 处显示文字。
- [x] 非显示字节逐段核对;原有 16 项字体回归通过。
- [x] 在独立副本执行恢复并核对原始哈希;生成差异和验证记录。
- [x] 游戏退出后部署,重读游戏文件并通过校验。
- [ ] 用户实机验证首批中文显示和教程事件推进。
- [ ] 首批确认后继续飞行及后续教程;Task 1 整体验收仍未完成。
+184
View File
@@ -0,0 +1,184 @@
"""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]')
@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))
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]) -> 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')
if CONTROLS.findall(literal.text) != CONTROLS.findall(translated):
raise ValueError('control sequence changed: %r' % literal.text)
if FORMAT.findall(literal.text) != FORMAT.findall(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)
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)
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),
'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()
+78
View File
@@ -0,0 +1,78 @@
import unittest
from script_text import extract_display_literals, patch_display_calls
class ScriptTextTests(unittest.TestCase):
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()
+11
View File
@@ -0,0 +1,11 @@
{
"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)": "\f欢迎进入操作教程。每完成一项练习,点击屏幕上方的“继续教程”按钮,进入下一项。\n\f按 H 键可查看界面帮助。\n\f建议首次游玩时完成教程。随时按 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)": "\f欢迎进入操作教程。每完成一项练习,点击屏幕上方的“继续教程”按钮,进入下一项。\n\f按 H 键可查看界面帮助。\n\f建议首次游玩时完成教程。随时按 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滚动鼠标滚轮,或按数字小键盘上的 +、- 键,可拉近或拉远视角。"
}
+46
View File
@@ -0,0 +1,46 @@
"""Verify the bounded first 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), 'build mismatch'
before = extract_display_literals(source)
after = extract_display_literals(result, encoding='gb2312')
assert len(before) == len(after) == 55
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(12)), changed
print('MODIFIED: first 12 of 55 literals translated; all other bytes unchanged; PASS')
if __name__ == '__main__':
main()