zh locale: pack locale_zh and add reviewed Chinese names

eterpack_pack writes locale packs in the original format (COMPRESS
entries, LZO+TEA index), and build_zh_locale.py packs locale/zh into
locale_zh.eix/.epk registered in pack/Index instead of leaving loose
files.

The reviewed translations for names and keys still in English are
kept apart from the server snapshot in data/zh_40250/*_zh_overlay.txt
(1,607 items, 231 mobs/NPCs, 45 locale_game keys) and override it at
build time. Only YMIR, the coordinate-named warp markers and the empty
TOOLTIP_UNSEAL_LEFT_TIME remain English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-28 18:45:58 +09:00
co-authored by Claude Opus 5.5
parent fd051cc3ed
commit a1ae69aa7a
9 changed files with 2180 additions and 45 deletions
+97 -21
View File
@@ -3,6 +3,12 @@
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.
The files are staged in build/zh-locale/locale-src/zh and packed like the original
locale packs (eterpack_pack: COMPRESS entries, encrypted index) into locale_zh.eix/.epk.
The output Client has a pack/ of its own: links to the original packs, the zh pack and
an Index registering locale/zh/ -> locale_zh. No loose locale/zh is left next to it,
since the client searches loose files first.
"""
import argparse
@@ -17,7 +23,8 @@ 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"
BUILD_DIRS = ("build-release", "build-native")
LOCALE_CFG = "10002 936 zh\n"
DEFAULT_NAMES = REPO / "data/zh_40250"
FORMAT = re.compile(r"%(?:\([^)]*\))?[-+#0 ]*\d*(?:\.\d+)?[diouxXeEfFgGcrs%]")
KEYED_FILES = ("locale_game.txt", "locale_interface.txt")
@@ -33,35 +40,99 @@ def keyed_lines(path: Path, encoding: str):
return lines, values
def convert_names(source: Path, destination: Path):
def name_rows(path: Path):
rows = {}
if path.is_file():
for line in path.read_text(encoding="utf-8").splitlines():
parts = line.split("\t", 1)
if len(parts) == 2 and parts[0].isdigit():
rows[parts[0]] = parts[1]
return rows
def convert_names(source: Path, destination: Path, overlay: Path = None):
"""The server name snapshot, with the reviewed translations of its English names laid over it."""
names = name_rows(source)
overlaid = {vnum: name for vnum, name in name_rows(overlay).items() if vnum in names} if overlay else {}
names.update(overlaid)
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
for vnum, name in names.items():
try:
rows.append(f"{parts[0]}\t{parts[1]}\n".encode("gbk"))
rows.append(f"{vnum}\t{name}\n".encode("gbk"))
converted += 1
except UnicodeEncodeError:
unrepresentable.append(parts[0])
unrepresentable.append(vnum)
destination.write_bytes(b"".join(rows))
return {"converted": converted, "unrepresentable_vnums": unrepresentable}
return {"converted": converted, "overlaid": len(overlaid), "unrepresentable_vnums": unrepresentable}
def tool(name: str) -> Path:
for build_dir in BUILD_DIRS:
path = REPO / build_dir / "extension/src/port" / name
if path.is_file():
return path
return REPO / BUILD_DIRS[0] / "extension/src/port" / name
def register(index: bytes) -> bytes:
"""pack/Index with locale/zh/ -> locale_zh added before the locale/en/ pair (CRLF, as the original)."""
lines = index.split(b"\r\n")
if b"locale/zh/" in lines:
return index
at = lines.index(b"locale/en/")
if at % 2 != 1:
raise ValueError("pack/Index: locale/en/ is not a folder line")
lines[at:at] = [b"locale/zh/", b"locale_zh"]
return b"\r\n".join(lines)
def make_pack_dir(client_base: Path, pack_dir: Path, locale_dir: Path, pack_tool: Path):
source = client_base / "pack"
if pack_dir.is_symlink():
pack_dir.unlink() # earlier builds linked the whole directory
pack_dir.mkdir(parents=True, exist_ok=True)
for entry in source.iterdir():
if entry.name == "Index" or entry.name.lower().startswith("locale_zh."):
continue
target = pack_dir / entry.name
if target.is_symlink() or target.exists():
target.unlink()
target.symlink_to(entry.resolve())
(pack_dir / "Index").write_bytes(register((source / "Index").read_bytes()))
result = subprocess.run([str(pack_tool), str(pack_dir / "locale_zh"), str(locale_dir), "locale/zh/"],
check=True, capture_output=True, text=True)
return result.stdout.strip()
def build(en: Path, zh: Path, output: Path, client_base: Path,
proto_tool: Path = None, names_dir: Path = None):
proto_tool: Path = None, names_dir: Path = None, pack_tool: Path = None):
"""names_dir holds the server name snapshot and the reviewed *_zh_overlay.txt translations."""
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")
if pack_tool is None or not pack_tool.is_file():
raise ValueError("build eterpack_pack (cmake --build <build dir> --target eterpack_pack)")
locale_dir = output / "locale/zh"
shutil.copytree(en, locale_dir, dirs_exist_ok=True)
locale_dir = output.parent / "locale-src/zh"
if locale_dir.exists():
shutil.rmtree(locale_dir)
shutil.copytree(en, locale_dir)
loose = output / "locale/zh"
if loose.exists():
shutil.rmtree(loose) # earlier builds ran the locale from loose files
if not any((output / "locale").iterdir()):
(output / "locale").rmdir()
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")
# Reviewed translations of the keys Longju lacks or formats differently; they win over Longju.
overlay = names_dir / (Path(filename).stem + "_zh_overlay.txt") if names_dir else None
if overlay and overlay.is_file():
_, reviewed = keyed_lines(overlay, "utf-8")
chinese.update(reviewed)
translated = []
incompatible = []
merged = []
@@ -100,8 +171,10 @@ def build(en: Path, zh: Path, output: Path, client_base: Path,
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)
item_conversion = convert_names(names_dir / "item_names_zh.txt", item_names,
names_dir / "item_names_zh_overlay.txt")
mob_conversion = convert_names(names_dir / "mob_names_zh.txt", mob_names,
names_dir / "mob_names_zh_overlay.txt")
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"),
@@ -114,10 +187,12 @@ def build(en: Path, zh: Path, output: Path, client_base: Path,
"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"):
report["pack"] = make_pack_dir(client_base, output / "pack", locale_dir, pack_tool)
(output / "locale.cfg").write_text(LOCALE_CFG, encoding="ascii")
(output / "locale_zh.cfg").write_text(LOCALE_CFG, encoding="ascii")
# A local test checkout shares the original 40250 assets through symbolic links
# (pack/ holds one per original pack); push-android-client.sh copies their targets.
for name in ("sound", "BGM", "miles", "lib", "channel.inf"):
source = client_base / name
target = output / name
if not source.exists():
@@ -141,12 +216,13 @@ def main():
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("--proto-tool", type=Path, default=tool("locale_proto_patch"))
parser.add_argument("--pack-tool", type=Path, default=tool("eterpack_pack"))
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'}")
args.proto_tool, args.name_tables, args.pack_tool)
print(f"Built {args.output / 'pack/locale_zh.eix'} (.epk), registered in pack/Index")
print(f"Report: {report_path}")
+41 -4
View File
@@ -27,18 +27,55 @@ class LocaleBuildTest(unittest.TestCase):
(en / "item_proto").write_bytes(b"40250-proto")
(zh / "item_proto").write_bytes(b"old-proto")
(client / "pack").mkdir()
(client / "pack/Index").write_bytes(b"PACK\r\nlocale/de/\r\nlocale_de\r\nlocale/en/\r\nlocale_en\r\n")
(client / "pack/locale_en.eix").write_bytes(b"en")
# Stands in for eterpack_pack: records its arguments as the .eix.
pack_tool = root / "eterpack_pack"
pack_tool.write_text("#!/bin/sh\necho \"$2 $3\" > \"$1.eix\"\n")
pack_tool.chmod(0o755)
report_path = MODULE.build(en, zh, output, client)
merged = (output / "locale/zh/locale_game.txt").read_bytes().decode("gbk")
report_path = MODULE.build(en, zh, output, client, pack_tool=pack_tool)
staged = output.parent / "locale-src/zh"
merged = (staged / "locale_game.txt").read_bytes().decode("gbk")
self.assertIn("GOOD\t中文 %d\tSA", merged)
self.assertIn("BAD\t%s at %d\tSA", merged)
self.assertIn("ONLY_EN\tFallback", merged)
self.assertEqual((output / "locale/zh/item_proto").read_bytes(), b"40250-proto")
self.assertEqual((staged / "item_proto").read_bytes(), b"40250-proto")
self.assertEqual((output / "locale.cfg").read_text(), "10002 936 zh\n")
self.assertTrue((output / "pack").is_symlink())
self.assertEqual((output / "locale_zh.cfg").read_text(), "10002 936 zh\n")
# A pack/ of its own: original packs linked, locale_zh packed from the staging dir and registered.
self.assertFalse((output / "pack").is_symlink())
self.assertFalse((output / "locale/zh").exists())
self.assertTrue((output / "pack/locale_en.eix").is_symlink())
self.assertEqual((output / "pack/locale_zh.eix").read_text(), f"{staged} locale/zh/\n")
self.assertEqual((output / "pack/Index").read_bytes(),
b"PACK\r\nlocale/de/\r\nlocale_de\r\nlocale/zh/\r\nlocale_zh\r\n"
b"locale/en/\r\nlocale_en\r\n")
self.assertEqual((client / "pack/Index").read_bytes()[:4], b"PACK")
self.assertFalse((client / "pack/locale_zh.eix").exists())
report = json.loads(report_path.read_text())
self.assertEqual(report["files"]["locale_game.txt"]["incompatible_format_keys"], ["BAD"])
# Reviewed overlay: fills an English-only key and replaces a format-incompatible Longju value.
names = root / "names"
names.mkdir()
(names / "locale_game_zh_overlay.txt").write_text("KEY\tVALUE\nBAD\t%s 于 %d\nONLY_EN\t备用\n")
report_path = MODULE.build(en, zh, output, client, names_dir=names, pack_tool=pack_tool)
merged = (staged / "locale_game.txt").read_bytes().decode("gbk")
self.assertIn("BAD\t%s 于 %d\tSA", merged)
self.assertIn("ONLY_EN\t备用", merged)
game = json.loads(report_path.read_text())["files"]["locale_game.txt"]
self.assertEqual((game["english_fallback_keys"], game["incompatible_format_keys"]), ([], []))
def test_name_overlay_replaces_snapshot_rows(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "names.txt").write_text("VNUM\tLOCALE_NAME\n1\tYang\n2\t剑\n")
(root / "overlay.txt").write_text("VNUM\tLOCALE_NAME\n1\t金币\n99\t不存在\n")
result = MODULE.convert_names(root / "names.txt", root / "out.txt", root / "overlay.txt")
self.assertEqual((root / "out.txt").read_bytes().decode("gbk"), "1\t金币\n2\t剑\n")
self.assertEqual(result["overlaid"], 1)
if __name__ == "__main__":
unittest.main()