render and server locale parity fixes

This commit is contained in:
shen
2026-09-28 00:18:37 -07:00
parent b776fd071f
commit d3cf586849
20 changed files with 7853 additions and 4 deletions
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Compare a read-only 40250 database name export with the older Chinese tables."""
import argparse
import json
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
DEFAULT_EXPORT = REPO / "build/zh-locale/server-audit"
DEFAULT_TABLES = REPO.parent / "server_extracted/40250_deploy"
def live_names(path: Path):
names = {}
for number, line in enumerate(path.read_text(encoding="ascii").splitlines(), 1):
try:
vnum, encoded = line.split("\t", 1)
name = bytes.fromhex(encoded).decode("gbk")
if int(vnum) in names:
raise ValueError("duplicate VNUM")
names[int(vnum)] = name
except (ValueError, UnicodeDecodeError) as error:
raise ValueError(f"{path}:{number}: {error}") from error
return names
def older_names(path: Path):
names = {}
for line in path.read_text(encoding="utf-8").splitlines()[1:]:
vnum, name = line.split("\t", 1)
names[int(vnum)] = name
return names
def audit(export_dir: Path, tables_dir: Path):
result = {}
for kind in ("item", "mob"):
live = live_names(export_dir / f"{kind}_names_hex.tsv")
older = older_names(tables_dir / f"{kind}_names_zh.txt")
shared = live.keys() & older.keys()
conflicts = [
{"vnum": vnum, "server": live[vnum], "older_table": older[vnum]}
for vnum in sorted(shared) if live[vnum] != older[vnum]
]
result[kind] = {
"server_rows": len(live),
"older_table_rows": len(older),
"shared_vnums": len(shared),
"server_only_vnums": len(live.keys() - older.keys()),
"older_only_vnums": len(older.keys() - live.keys()),
"server_names_without_hanzi": sum(
not any("\u4e00" <= char <= "\u9fff" for char in name)
for name in live.values()
),
"conflicts": conflicts,
}
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--export-dir", type=Path, default=DEFAULT_EXPORT)
parser.add_argument("--tables-dir", type=Path, default=DEFAULT_TABLES)
parser.add_argument("--output", type=Path, default=DEFAULT_EXPORT / "name-audit.json")
args = parser.parse_args()
report = audit(args.export_dir, args.tables_dir)
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
for kind, data in report.items():
print(f"{kind}: rows={data['server_rows']} no_hanzi={data['server_names_without_hanzi']} "
f"conflicts={len(data['conflicts'])}")
if __name__ == "__main__":
main()
+154
View File
@@ -0,0 +1,154 @@
#!/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()
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Build the 40250 newcibn locale_string.txt from its Korean keys and GBK text."""
import argparse
import hashlib
import json
import re
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
DEFAULT_ENGLISH = REPO.parent / "40250/Server Client TMP4/Server/metin2/server/share/locale/english/locale_string.txt"
DEFAULT_CHINESE = REPO.parent / "server_extracted/server_locale/locale_string.txt"
DEFAULT_OUTPUT = REPO / "build/zh-locale/server/newcibn/locale_string.txt"
QUOTED = re.compile(rb'^"(.*)"\s*;$')
FORMAT = re.compile(rb'%(?:\d+\$)?[-+#0 ]*\d*(?:\.\d+)?(?:hh|ll|[hlLzjt])?[diouxXeEfFgGaAcspD%]')
# These keys exist in 40250 but not in the older newcibn dictionary.
ADDED = {
"등급 개량에 성공했습니다.": "品级提升成功。",
"%s이 필요합니다.": "需要%s。",
"용심 추출에 성공하였습니다.": "龙心提取成功。",
"용심 추출에 실패하였습니다.": "龙心提取失败。",
"단계 개량에 필요한 재료가 아닙니다.": "这不是提升阶段所需的材料。",
"이미 장비를 착용하고 있습니다.": "已经装备了同类物品。",
"등급 개량할 수 없는 용혼석입니다.": "该龙魂石无法提升品级。",
"강화에 성공했습니다.": "强化成功。",
"<길드> 잠시후에 이용해주십시오.": "<公会>请稍后再试。",
"%d 분 %d 초 동안 채팅금지 상태입니다": "聊天禁言还剩%d分%d秒。",
"이미 같은 종류의 용혼석을 착용하고 있습니다.": "已经装备了同类龙魂石。",
"인벤이 활성화 되지 않음.": "龙魂石背包尚未开启,请先完成龙魂石任务。",
"단계 개량할 수 없는 용혼석입니다.": "该龙魂石无法提升阶段。",
"벨트 인벤토리에 아이템이 존재하면 해제할 수 없습니다.": "腰带背包中还有物品,无法卸下腰带。",
"강화창을 연 상태에서는 아이템을 옮길 수 없습니다.": "强化窗口打开时无法移动物品。",
"먼저 용혼석 퀘스트를 완료하셔야 합니다.": "请先完成龙魂石任务。",
"단계 개량에 실패했습니다.": "阶段提升失败。",
"강화에 실패했습니다.": "强化失败。",
"용혼석 추출에 실패하여 %s를 얻었습니다.": "龙魂石提取失败,但获得了%s。",
"다른 거래중(창고,교환,상점)에는 사용할 수 없습니다.": "使用仓库、交易或商店时无法进行此操作。",
"강화할 수 없는 용혼석입니다.": "该龙魂石无法强化。",
"용혼석 추출에 실패하였습니다.": "龙魂石提取失败。",
"%d 시간 %d 분 %d 초 동안 채팅금지 상태입니다": "聊天禁言还剩%d小时%d分%d秒。",
"이미 탈것을 이용중입니다.": "你已经骑乘坐骑,请先下坐骑。",
"<길드> %u의 경험치를 투자하였습니다.": "<公会>投入了%u点经验值。",
"이 아이템은 벨트 인벤토리로 옮길 수 없습니다.": "此物品无法放入腰带背包。",
"%d 초 동안 채팅금지 상태입니다": "聊天禁言还剩%d秒。",
"등급 개량에 필요한 재료가 아닙니다.": "这不是提升品级所需的材料。",
"용혼석 추출에 성공하였습니다.": "龙魂石提取成功。",
"%d 시간 %d 초 동안 채팅금지 상태입니다": "聊天禁言还剩%d小时%d秒。",
"Pregunta.": "问题。",
"착용 중인 용혼석은 추출할 수 없습니다.": "无法提取已装备的龙魂石。",
"등급 개량에 실패했습니다.": "品级提升失败。",
"외치기는 유료회원만 사용할수 있습니다": "当前无法使用世界喊话。",
"강화에 필요한 재료가 아닙니다.": "这不是强化所需的材料。",
"단계 개량에 성공했습니다.": "阶段提升成功。",
}
# These two live entries lose a printf argument. Keep their keys and restore both fields.
CORRECTED = {
"OX 상품을 지급하였습니다. ID: %d 수량: %d": "已发放OX奖品,编号:%d,数量:%d。",
"곡괭이의 수련도가 증가하였습니다! (%d/%d)": "矿镐熟练度提高了!(%d/%d)",
}
def read_pairs(path: Path):
quoted = []
for number, line in enumerate(path.read_bytes().splitlines(), 1):
line = line.strip()
if not line:
continue
match = QUOTED.fullmatch(line)
if match is None:
raise ValueError(f"{path}:{number}: malformed locale entry")
quoted.append(match.group(1))
if len(quoted) % 2:
raise ValueError(f"{path}: unmatched locale key")
pairs = dict(zip(quoted[::2], quoted[1::2]))
if len(pairs) != len(quoted) // 2:
raise ValueError(f"{path}: duplicate locale key")
return pairs
def format_args(value: bytes):
return [token for token in FORMAT.findall(value) if token != b"%%"]
def build(english: Path, chinese: Path, output: Path):
reference = read_pairs(english)
existing = read_pairs(chinese)
missing = reference.keys() - existing.keys()
added_keys = {key.encode("cp949") for key in ADDED}
if missing != added_keys:
raise ValueError(f"40250 key coverage changed: missing={len(missing)}, translations={len(added_keys)}")
result = []
for key in list(reference) + [key for key in existing if key not in reference]:
source_key = key.decode("cp949")
if source_key in CORRECTED:
value = CORRECTED[source_key].encode("gbk")
elif key in existing:
value = existing[key]
else:
value = ADDED[source_key].encode("gbk")
if format_args(key) != format_args(value):
raise ValueError(f"format arguments differ for {source_key!r}")
if b'"' in value or b'\n' in value or b'\r' in value:
raise ValueError(f"unsafe locale value for {source_key!r}")
result.extend((b'"' + key + b'";', b'"' + value + b'";', b''))
payload = b"\n".join(result) + b"\n"
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(payload)
report = {
"reference_keys": len(reference),
"existing_chinese_keys": len(existing),
"new_chinese_keys": len(missing),
"corrected_format_keys": len(CORRECTED),
"legacy_extra_keys": len(existing.keys() - reference.keys()),
"output_sha256": hashlib.sha256(payload).hexdigest(),
}
output.with_suffix(".report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
return report
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--english", type=Path, default=DEFAULT_ENGLISH)
parser.add_argument("--chinese", type=Path, default=DEFAULT_CHINESE)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
print(json.dumps(build(args.english, args.chinese, args.output), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Focused safeguards for the 40250/Longju locale merge."""
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
SPEC = importlib.util.spec_from_file_location("build_zh_locale", Path(__file__).with_name("build_zh_locale.py"))
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
class LocaleBuildTest(unittest.TestCase):
def test_preserves_40250_keys_formats_and_proto(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
en, zh, client, output = (root / name for name in ("en", "zh", "client", "out/Client"))
for path in (en, zh, client):
path.mkdir()
(en / "locale_game.txt").write_bytes(b"GOOD\tValue %d\tSA\nBAD\t%s at %d\tSA\nONLY_EN\tFallback\n")
(zh / "locale_game.txt").write_bytes("GOOD\t中文 %d\nBAD\t中文 %d 和 %s\n".encode("gbk"))
(en / "locale_interface.txt").write_bytes(b"ACCEPT\tAccept\n")
(zh / "locale_interface.txt").write_bytes("ACCEPT\t确定\n".encode("gbk"))
(en / "item_proto").write_bytes(b"40250-proto")
(zh / "item_proto").write_bytes(b"old-proto")
(client / "pack").mkdir()
report_path = MODULE.build(en, zh, output, client)
merged = (output / "locale/zh/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((output / "locale.cfg").read_text(), "10002 936 zh\n")
self.assertTrue((output / "pack").is_symlink())
report = json.loads(report_path.read_text())
self.assertEqual(report["files"]["locale_game.txt"]["incompatible_format_keys"], ["BAD"])
if __name__ == "__main__":
unittest.main()
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Checks for the 40250 server's GBK locale dictionary overlay."""
import tempfile
import unittest
from pathlib import Path
import build_zh_server_locale as locale
class ServerLocaleTest(unittest.TestCase):
def test_build_preserves_keys_and_existing_translations(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "locale_string.txt"
report = locale.build(locale.DEFAULT_ENGLISH, locale.DEFAULT_CHINESE, output)
reference = locale.read_pairs(locale.DEFAULT_ENGLISH)
existing = locale.read_pairs(locale.DEFAULT_CHINESE)
generated = locale.read_pairs(output)
self.assertEqual(report["new_chinese_keys"], 36)
self.assertEqual(report["corrected_format_keys"], 2)
self.assertEqual(set(generated), set(reference) | set(existing))
corrected = {key.encode("cp949") for key in locale.CORRECTED}
for key, value in existing.items():
if key not in corrected:
self.assertEqual(generated[key], value)
for key, value in generated.items():
value.decode("gbk")
self.assertEqual(locale.format_args(key), locale.format_args(value))
self.assertEqual(
generated["OX 상품을 지급하였습니다. ID: %d 수량: %d".encode("cp949")].decode("gbk"),
"已发放OX奖品,编号:%d,数量:%d。",
)
if __name__ == "__main__":
unittest.main()