feat: translate first four tutorial stages for in-game trial
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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滚动鼠标滚轮,或按数字小键盘上的 +、- 键,可拉近或拉远视角。"
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user