#!/usr/bin/env python3 """Build a CP936 Chinese locale on top of the 40250 English client resources. The Longju locale is an older client version. Only keyed UI text with compatible format arguments is overlaid; 40250 proto, scripts, and UI layouts stay intact. """ import argparse import json import re import shutil import subprocess from pathlib import Path REPO = Path(__file__).resolve().parents[1] DEFAULT_EN = REPO.parent / "40250/Server Client TMP4/Client/Eternexus/locale_en/locale/en" DEFAULT_ZH = REPO.parent / "server_extracted/client_locale/longju/locale/taiwan" DEFAULT_OUTPUT = REPO / "build/zh-locale/Client" DEFAULT_PROTO_TOOL = REPO / "build-release/extension/src/port/locale_proto_patch" DEFAULT_NAMES = REPO / "data/zh_40250" FORMAT = re.compile(r"%(?:\([^)]*\))?[-+#0 ]*\d*(?:\.\d+)?[diouxXeEfFgGcrs%]") KEYED_FILES = ("locale_game.txt", "locale_interface.txt") def keyed_lines(path: Path, encoding: str): lines = path.read_bytes().decode(encoding).splitlines() values = {} for line in lines: parts = line.split("\t") if len(parts) >= 2 and parts[0]: values[parts[0]] = parts[1] return lines, values def convert_names(source: Path, destination: Path): converted = 0 unrepresentable = [] rows = [] for line in source.read_text(encoding="utf-8").splitlines(): parts = line.split("\t", 1) if len(parts) != 2 or not parts[0].isdigit(): continue try: rows.append(f"{parts[0]}\t{parts[1]}\n".encode("gbk")) converted += 1 except UnicodeEncodeError: unrepresentable.append(parts[0]) destination.write_bytes(b"".join(rows)) return {"converted": converted, "unrepresentable_vnums": unrepresentable} def build(en: Path, zh: Path, output: Path, client_base: Path, proto_tool: Path = None, names_dir: Path = None): if not (en / "locale_game.txt").is_file() or not (zh / "locale_game.txt").is_file(): raise ValueError("English 40250 and Chinese Longju locale directories are required") locale_dir = output / "locale/zh" shutil.copytree(en, locale_dir, dirs_exist_ok=True) report = {"english_source": str(en), "chinese_source": str(zh), "files": {}} for filename in KEYED_FILES: english_lines, english = keyed_lines(en / filename, "cp1252") _, chinese = keyed_lines(zh / filename, "gbk") translated = [] incompatible = [] merged = [] for line in english_lines: parts = line.split("\t") if len(parts) >= 2 and parts[0] in chinese: key = parts[0] candidate = chinese[key] if FORMAT.findall(parts[1]) == FORMAT.findall(candidate): parts[1] = candidate translated.append(key) else: incompatible.append(key) line = "\t".join(parts) merged.append(line) (locale_dir / filename).write_bytes(("\n".join(merged) + "\n").encode("gbk")) report["files"][filename] = { "reference_keys": len(english), "translated_keys": len(translated), "english_fallback_keys": sorted(english.keys() - set(translated)), "incompatible_format_keys": sorted(incompatible), "chinese_only_keys": sorted(chinese.keys() - english.keys()), } # These files are prose, not versioned game data. Keep the 40250 UI layouts # and prototype binaries copied above; only replace their explanatory text. for filename in ( "jobdesc_warrior.txt", "jobdesc_assassin.txt", "jobdesc_sura.txt", "jobdesc_shaman.txt", "empiredesc_a.txt", "empiredesc_b.txt", "empiredesc_c.txt", ): if (zh / filename).is_file(): shutil.copyfile(zh / filename, locale_dir / filename) if proto_tool is not None: if not proto_tool.is_file() or names_dir is None: raise ValueError("build locale_proto_patch and provide the Chinese name tables") item_names = output.parent / "item_names_gbk.txt" mob_names = output.parent / "mob_names_gbk.txt" item_conversion = convert_names(names_dir / "item_names_zh.txt", item_names) mob_conversion = convert_names(names_dir / "mob_names_zh.txt", mob_names) result = subprocess.run( [str(proto_tool), str(en / "item_proto"), str(en / "mob_proto"), str(item_names), str(mob_names), str(locale_dir / "item_proto"), str(locale_dir / "mob_proto")], check=True, capture_output=True, text=True, ) report["proto"] = { "item_name_source": item_conversion, "mob_name_source": mob_conversion, "patch_result": result.stdout.strip().splitlines(), } (output / "locale.cfg").write_text("10002 936 zh\n", encoding="ascii") # A local test checkout can share the original 40250 packs without copying # gigabytes of assets. Distribution builds must package these separately. for name in ("pack", "sound", "BGM", "miles", "lib", "channel.inf"): source = client_base / name target = output / name if not source.exists(): continue if target.is_symlink(): if target.resolve() == source.resolve(): continue target.unlink() elif target.exists(): raise ValueError(f"refusing to replace existing client resource: {target}") target.symlink_to(source.resolve()) report_path = output.parent / "zh-locale-report.json" report_path.parent.mkdir(parents=True, exist_ok=True) report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return report_path def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--english", type=Path, default=DEFAULT_EN) parser.add_argument("--chinese", type=Path, default=DEFAULT_ZH) parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) parser.add_argument("--client-base", type=Path, default=REPO / "Client") parser.add_argument("--proto-tool", type=Path, default=DEFAULT_PROTO_TOOL) parser.add_argument("--name-tables", type=Path, default=DEFAULT_NAMES) args = parser.parse_args() report_path = build(args.english, args.chinese, args.output, args.client_base, args.proto_tool, args.name_tables) print(f"Built {args.output / 'locale/zh'}") print(f"Report: {report_path}") if __name__ == "__main__": main()