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