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
+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()