render and server locale parity fixes
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# 40250 服务端名称快照
|
||||
|
||||
2026-09-28 从 `192.168.21.203` 的 `player.item_proto` 与 `player.mob_proto` 只读导出
|
||||
`vnum, HEX(gb2312name)`,按 GBK 解码为本目录的 UTF-8 TSV。导出时服务端配置为
|
||||
`LOCALE=newcibn`、`DB_NAME_COLUMN=gb2312name`。客户端原型只修改 `szLocaleName`,
|
||||
游戏属性和 VNUM 仍取自 40250 客户端原型。
|
||||
|
||||
| 文件 | 条数 | SHA-256 |
|
||||
| --- | ---: | --- |
|
||||
| `item_names_zh.txt` | 5,743 | `5604985d7f11039f3d3e38d4e2c07995e80ca3e4f6deb01fe57cd772e9cd0f70` |
|
||||
| `mob_names_zh.txt` | 1,334 | `fdbfe4b3f3367d5842c0e9bbe0b5e5ea2dbe99fb6e566ef5789f65d3794eab5c` |
|
||||
|
||||
线上字段里的 1,608 个物品名及 346 个怪物/NPC 名不含汉字,本快照原样保留。
|
||||
旧的龙驹中文表与线上表有 21 个相同 VNUM 不同名;不把旧表覆盖到服务端。
|
||||
服务端名称若再更新,需重新只读导出并核对差异后更新本快照。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
# 40250 简体中文适配(进行中)
|
||||
|
||||
## 当前可运行原型
|
||||
|
||||
先运行 `cmake --build build-release --target locale_proto_patch`,再运行
|
||||
`python3 script/build_zh_locale.py`,在 `build/zh-locale/Client` 生成独立的测试客户端目录。
|
||||
它复制 40250 `Eternexus/locale_en/locale/en` 的文件结构,配置 `locale.cfg` 为
|
||||
`10002 936 zh`,并将中文内容按键合入 `locale_game.txt`、`locale_interface.txt`。
|
||||
旧龙驹语言包中的占位符与 40250 不一致时保留英文原文;报告在
|
||||
`build/zh-locale/zh-locale-report.json`。职业与帝国说明采用龙驹中文文本。
|
||||
|
||||
生成目录通过符号链接共用仓库 `Client` 的 pack 等资源,仅用于本机验证;
|
||||
不会修改原始 40250 客户端,也不是可分发包。可用以下命令启动:
|
||||
|
||||
```sh
|
||||
build-release/native_render/mt_native_render \
|
||||
--live-client build/zh-locale/Client --login-screen --width 1024 --height 768
|
||||
```
|
||||
|
||||
物品与怪物原型从 40250 原版解压,按线上 40250 服务端 `gb2312name` 的 VNUM 快照
|
||||
只替换 `szLocaleName` 字段后按原格式
|
||||
重新压缩加密;生成器验证记录数、结构大小、除名称外所有字节不变,以及写出后的
|
||||
解压往返一致。`itemdesc.txt`、`skilldesc.txt`、`skilltable.txt`、地图坐标、UI 脚本和图像
|
||||
仍为 40250 原版。龙驹版本的原型不能整份覆盖。
|
||||
|
||||
## 已核对的差异
|
||||
|
||||
- 40250 `locale_game.txt` 有 817 个键;当前 771 个安全替换为中文,46 个保留英文。
|
||||
其中 12 个在龙驹文件中的格式占位符不一致,需逐条重译或验证。
|
||||
- 40250 `locale_interface.txt` 的 326 个键均找到中文对应项。
|
||||
- 40250 客户端物品原型 5,743 条、怪物/NPC 原型 1,334 条都已按 VNUM 写入
|
||||
`data/zh_40250/` 中的服务端名称快照;其中分别有 1,608 和 346 个名称仍不含汉字。
|
||||
- 40250 服务端(`192.168.21.203`,2026-09-27 只读 SSH 核对)当前数据库配置为
|
||||
`LOCALE=newcibn`、`DB_NAME_COLUMN=gb2312name`,有 `newcibn` 语言目录。
|
||||
因此客户端采用 CP936;服务端广播字典已在 2026-09-28 完成首轮补齐,名称与任务文本仍需核对。
|
||||
- 文本阶段的 `port_login_flow_test` 使用生成目录通过完整离线登录、进场、移动、战斗、
|
||||
商店及仓库流程;macOS 原生窗口的登录服务器选择文字已目视确认显示中文。
|
||||
名称原型加入后,测试运行确认原生客户端已加载中文的物品 19(武士刀+9);
|
||||
原英文测试中的名称断言及依赖英文布局的仓库点击断言不再适用,完整中文流程待专门验收。
|
||||
|
||||
## 后续验收
|
||||
|
||||
1. 根据线上 40250 VNUM 及用途逐条翻译仍为英文的 1,608 个物品、346 个怪物/NPC;
|
||||
同步更新服务端名称和客户端快照。`itemdesc`、`skilldesc` 也需按 40250 键和语义逐项合并。
|
||||
2. 补齐 46 个英文回退键,复核 12 个格式不兼容键。
|
||||
3. 在游戏窗口验证中文字符、字体、输入法、换行和 UI 宽度,再联机检查登录、背包、
|
||||
商店、任务、公告及聊天。
|
||||
4. 完成后制作注册到 `pack/Index` 的中文 pack,并在 macOS 和 Android 上分别测试。
|
||||
|
||||
## 服务端适配进度(2026-09-28)
|
||||
|
||||
服务端 `newcibn/locale_string.txt` 原有 733 个中文键,和 40250 英文参考字典的
|
||||
762 个键比对后缺少 36 个。`script/build_zh_server_locale.py` 保留原字典的 733 个键
|
||||
及其原始 GBK 译文,补齐 36 个 40250 键,并修复两条丢失 `%d` 参数的译文;旧字典
|
||||
特有的 7 个键继续保留。生成文件有 769 个键,全部译文按 GBK 编码,格式参数与
|
||||
韩文调用键逐条一致。生成产物在 `build/zh-locale/server/newcibn/locale_string.txt`;
|
||||
`python3 script/test_build_zh_server_locale.py` 验证键和旧译文保持不变。
|
||||
|
||||
已将该文件部署到 `/usr/metin2/server/share/locale/newcibn/locale_string.txt`,远端
|
||||
SHA-256 为 `7f16a8c7b9d08774ec90c4a86b80b17364b072c0af9e2b76a0ea9df027adaef8`。
|
||||
原文件保存在同目录的 `locale_string.txt.pre-zh-20260928`。在确认没有外部玩家连接后,
|
||||
仅重启了第 1 频道的三个 `game` 进程和 `game99`,认证及数据库进程未重启;四个
|
||||
游戏进程和对应监听端口均已恢复。服务端日志中还存在此前就有的
|
||||
`new_quest_lv52` 缺少 `is_destination_village` 函数错误,本轮没有修改任务脚本。
|
||||
|
||||
只读导出的线上数据库中,`item_proto` 有 5,743 条、`mob_proto` 有 1,334 条,
|
||||
`gb2312name` 全部能按 GBK 解码,但分别有 1,608 和 346 个名称不含汉字。
|
||||
`script/audit_zh_server_names.py` 将线上 VNUM 与旧中文名称表比对,发现物品 1 处、
|
||||
怪物/NPC 20 处同 VNUM 不同名(例如 VNUM 101 在线上是 `Wild Dog`,旧表是`幼狼`)。
|
||||
旧表并非完整的 40250 名称表,因此本轮没有批量修改数据库。客户端已改用本次
|
||||
只读导出的服务端名称快照,见 `data/zh_40250/README.md`,两侧 VNUM 名称由此一致。
|
||||
@@ -6,6 +6,28 @@
|
||||
#include <algorithm>
|
||||
|
||||
namespace {
|
||||
struct Point { float x, y; };
|
||||
std::vector<Point> clip_polygon(std::vector<Point> polygon, int edge, float bound) {
|
||||
std::vector<Point> result;
|
||||
if (polygon.empty()) return result;
|
||||
auto inside = [edge, bound](Point p) {
|
||||
return edge == 0 ? p.x >= bound : edge == 1 ? p.x <= bound
|
||||
: edge == 2 ? p.y >= bound : p.y <= bound;
|
||||
};
|
||||
auto intersect = [edge, bound](Point a, Point b) {
|
||||
const float t = edge < 2 ? (bound - a.x) / (b.x - a.x)
|
||||
: (bound - a.y) / (b.y - a.y);
|
||||
return Point{a.x + t * (b.x - a.x), a.y + t * (b.y - a.y)};
|
||||
};
|
||||
Point previous = polygon.back();
|
||||
for (Point current : polygon) {
|
||||
const bool was_inside = inside(previous), is_inside = inside(current);
|
||||
if (was_inside != is_inside) result.push_back(intersect(previous, current));
|
||||
if (is_inside) result.push_back(current);
|
||||
previous = current;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
std::vector<UIRenderCommand> commands;
|
||||
std::uint64_t frame_id = 0;
|
||||
int canvas_width = 0;
|
||||
@@ -31,6 +53,32 @@ void UIRenderAdd(UIRenderCommand command) {
|
||||
command.clip_x1 = clip_x1; command.clip_y1 = clip_y1;
|
||||
command.clip_x2 = clip_x2; command.clip_y2 = clip_y2;
|
||||
command.behind_3d = Render3DDraws().empty();
|
||||
if (command.kind == UIRenderCommand::Bar && command.quad) {
|
||||
// Cooldown fans are triangles. Clip their geometry before either renderer consumes it.
|
||||
std::vector<Point> polygon{{command.qx[0], command.qy[0]},
|
||||
{command.qx[1], command.qy[1]},
|
||||
{command.qx[2], command.qy[2]}};
|
||||
const float bounds[4] = {clip_x1, clip_x2, clip_y1, clip_y2};
|
||||
for (int edge = 0; edge < 4; ++edge)
|
||||
polygon = clip_polygon(std::move(polygon), edge, bounds[edge]);
|
||||
for (std::size_t i = 1; i + 1 < polygon.size(); ++i) {
|
||||
UIRenderCommand piece = command;
|
||||
const Point tri[3] = {polygon[0], polygon[i], polygon[i + 1]};
|
||||
const float area = (tri[1].x - tri[0].x) * (tri[2].y - tri[0].y) -
|
||||
(tri[1].y - tri[0].y) * (tri[2].x - tri[0].x);
|
||||
if (area == 0.0f) continue;
|
||||
for (int k = 0; k < 4; ++k) {
|
||||
piece.qx[k] = tri[k < 3 ? k : 2].x;
|
||||
piece.qy[k] = tri[k < 3 ? k : 2].y;
|
||||
}
|
||||
piece.x1 = std::min({tri[0].x, tri[1].x, tri[2].x});
|
||||
piece.y1 = std::min({tri[0].y, tri[1].y, tri[2].y});
|
||||
piece.x2 = std::max({tri[0].x, tri[1].x, tri[2].x});
|
||||
piece.y2 = std::max({tri[0].y, tri[1].y, tri[2].y});
|
||||
commands.push_back(std::move(piece));
|
||||
}
|
||||
return;
|
||||
}
|
||||
commands.push_back(command);
|
||||
}
|
||||
const std::vector<UIRenderCommand>& UIRenderCommands() { return commands; }
|
||||
|
||||
@@ -38,6 +38,9 @@ void CMapOutdoor::RenderWater()
|
||||
// PORT: see the file comment.
|
||||
if (!IsNativeTerrainRenderEnabled())
|
||||
return;
|
||||
CGraphicTexture* pWaterTexture = m_WaterInstances[((ELTimer_GetMSec() / 70) % 30)].GetTexturePointer();
|
||||
if (!pWaterTexture)
|
||||
return;
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// RenderState
|
||||
@@ -49,7 +52,7 @@ void CMapOutdoor::RenderWater()
|
||||
STATEMANAGER.SaveRenderState(D3DRS_DIFFUSEMATERIALSOURCE, D3DMCS_COLOR1);
|
||||
STATEMANAGER.SaveRenderState(D3DRS_COLORVERTEX, TRUE);
|
||||
|
||||
STATEMANAGER.SetTexture(0, m_WaterInstances[((ELTimer_GetMSec() / 70) % 30)].GetTexturePointer()->GetD3DTexture());
|
||||
STATEMANAGER.SetTexture(0, pWaterTexture->GetD3DTexture());
|
||||
|
||||
D3DXMatrixScaling(&matTexTransformWater, m_fWaterTexCoordBase, -m_fWaterTexCoordBase, 0.0f);
|
||||
D3DXMatrixMultiply(&matTexTransformWater, &m_matViewInverse, &matTexTransformWater);
|
||||
|
||||
@@ -88,6 +88,8 @@ void UIIMEKeyDown(int vkey);
|
||||
// While IsAppLooping(), UIUpdate is AppFrame() and UIRender keeps the commands that Process() drew.
|
||||
void UIUpdate();
|
||||
void UIRender();
|
||||
// Completed native frame duration, including Vulkan rendering and presentation, for auto sight range.
|
||||
void SetNativeRenderFrameTime(float milliseconds);
|
||||
bool IsSoftwareCursorVisible();
|
||||
|
||||
// 40250 Main() calls Clear() and lets the launcher leave scope.
|
||||
|
||||
@@ -588,6 +588,11 @@ bool CSpeedTreeRT::LoadTree(const unsigned char * pBlock, unsigned int nNumBytes
|
||||
return true;
|
||||
}
|
||||
|
||||
void CSpeedTreeRT::SetSourceName(const char * pFilename)
|
||||
{
|
||||
m_pImpl->pTree->strName = pFilename ? pFilename : "";
|
||||
}
|
||||
|
||||
bool CSpeedTreeRT::Compute(const float * /*pTransform*/, unsigned int nSeed, bool /*bCompositeStrips*/)
|
||||
{
|
||||
STreeData & tree = *m_pImpl->pTree;
|
||||
|
||||
@@ -72,6 +72,8 @@ float CPythonApplication::GetGlobalElapsedTime() { return CTimer::Instance().Get
|
||||
static bool s_isFrameSkipDisable = false;
|
||||
// m_iForceSightRange (the 40250 constructor sets -1: automatic sight range).
|
||||
static int s_iForceSightRange = -1;
|
||||
static float s_nativeRenderFrameTime = 0.0f;
|
||||
void PythonBoot::SetNativeRenderFrameTime(float milliseconds) { s_nativeRenderFrameTime = milliseconds; }
|
||||
void CPythonApplication::SetFrameSkip(bool isEnable) { s_isFrameSkipDisable = !isEnable; }
|
||||
// PORT: 40250 creates the Win32 window, the D3D8 device, cursors, sound, keyboard, the net device,
|
||||
// fonts and the IME here. The Godot window and its canvas already exist when the host starts the
|
||||
@@ -350,7 +352,11 @@ bool CPythonApplication::Process()
|
||||
static DWORD s_dwRenderRangeTime = 0;
|
||||
static DWORD s_dwRenderRangeFrame = 0;
|
||||
|
||||
m_dwCurRenderTime = dwRenderEndTime - dwRenderStartTime;
|
||||
// The native host completes Vulkan work after this command-recording pass.
|
||||
// Use its last completed frame, including presentation, for automatic sight range.
|
||||
m_dwCurRenderTime = s_nativeRenderFrameTime > 0.0f
|
||||
? static_cast<DWORD>(s_nativeRenderFrameTime + 0.5f)
|
||||
: dwRenderEndTime - dwRenderStartTime;
|
||||
s_dwRenderRangeTime += m_dwCurRenderTime;
|
||||
++s_dwRenderRangeFrame;
|
||||
|
||||
|
||||
@@ -222,6 +222,9 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
|
||||
add_test(NAME port.login_flow
|
||||
COMMAND $<TARGET_FILE:port_login_flow_test> ${MT_40250_CLIENT} ${MT_PYTHON_STDLIB_ZIP})
|
||||
set_tests_properties(port.login_flow PROPERTIES SKIP_RETURN_CODE 77)
|
||||
# Offline locale data builder: retains 40250 proto layout and gameplay fields.
|
||||
add_executable(locale_proto_patch ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/locale_proto_patch.cpp)
|
||||
target_link_libraries(locale_proto_patch PRIVATE port_platform)
|
||||
# 2V2-e: the same fake server as a process, for the Godot-driven render run
|
||||
# (script/python_game_render_test.sh).
|
||||
add_executable(port_fake_login_server ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_fake_login_server_main.cpp
|
||||
|
||||
@@ -371,6 +371,8 @@ bool CSpeedTreeWrapper::LoadTree(const char * pszSptFile, const BYTE * c_pbBlock
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// PORT: the in-memory SpeedTree replacement needs the pack path for species geometry and UVs.
|
||||
m_pSpeedTree->SetSourceName(pszSptFile);
|
||||
|
||||
// override the lighting method stored in the spt file
|
||||
#ifdef WRAPPER_USE_DYNAMIC_LIGHTING
|
||||
|
||||
@@ -297,6 +297,8 @@ static void operator delete[](void* pRawMemory);
|
||||
|
||||
bool LoadTree(const char* pFilename);
|
||||
bool LoadTree(const unsigned char* pBlock, unsigned int nNumBytes);
|
||||
// PORT: supply the pack path when LoadTree receives only a memory block.
|
||||
void SetSourceName(const char* pFilename);
|
||||
unsigned char* SaveTree(unsigned int& nNumBytes, bool bSaveLeaves = false) const;
|
||||
|
||||
void GetTreeSize(float& fSize, float& fVariance) const;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Offline CP936 name overlay for the 40250 item/mob proto format.
|
||||
// Runtime loading remains the original 40250 path and binary format.
|
||||
#include "GameLib/StdAfx.h"
|
||||
#include "GameLib/ItemData.h"
|
||||
#include "UserInterface/StdAfx.h"
|
||||
#include "UserInterface/PythonNonPlayer.h"
|
||||
#include "EterBase/lzo.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
using Item = CItemData::TItemTable;
|
||||
using Mob = CPythonNonPlayer::TMobTable;
|
||||
|
||||
constexpr std::array<DWORD, 4> item_key{{173217, 72619434, 408587239, 27973291}};
|
||||
constexpr std::array<DWORD, 4> mob_key{{4813894, 18955, 552631, 6822045}};
|
||||
|
||||
std::vector<BYTE> read_file(const char* path) {
|
||||
std::ifstream stream(path, std::ios::binary);
|
||||
if (!stream) throw std::runtime_error(std::string("cannot read ") + path);
|
||||
return {std::istreambuf_iterator<char>(stream), std::istreambuf_iterator<char>()};
|
||||
}
|
||||
|
||||
void write_file(const char* path, const std::vector<BYTE>& bytes) {
|
||||
std::ofstream stream(path, std::ios::binary | std::ios::trunc);
|
||||
if (!stream || !stream.write(reinterpret_cast<const char*>(bytes.data()), bytes.size()))
|
||||
throw std::runtime_error(std::string("cannot write ") + path);
|
||||
}
|
||||
|
||||
DWORD word(const std::vector<BYTE>& bytes, std::size_t offset) {
|
||||
if (offset + 4 > bytes.size()) throw std::runtime_error("truncated proto header");
|
||||
DWORD value;
|
||||
std::memcpy(&value, bytes.data() + offset, 4);
|
||||
return value;
|
||||
}
|
||||
|
||||
void put_word(std::vector<BYTE>& bytes, std::size_t offset, DWORD value) {
|
||||
std::memcpy(bytes.data() + offset, &value, 4);
|
||||
}
|
||||
|
||||
std::map<DWORD, std::string> read_names(const char* path) {
|
||||
std::ifstream stream(path, std::ios::binary);
|
||||
if (!stream) throw std::runtime_error(std::string("cannot read ") + path);
|
||||
std::map<DWORD, std::string> names;
|
||||
std::string line;
|
||||
while (std::getline(stream, line)) {
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
const std::size_t tab = line.find('\t');
|
||||
if (tab == std::string::npos || tab == 0) continue;
|
||||
try {
|
||||
std::size_t end = 0;
|
||||
const unsigned long value = std::stoul(line.substr(0, tab), &end);
|
||||
if (end != tab || value > UINT32_MAX) continue;
|
||||
const std::string name = line.substr(tab + 1);
|
||||
if (!name.empty() && name.find('\0') == std::string::npos)
|
||||
names[static_cast<DWORD>(value)] = name;
|
||||
} catch (const std::exception&) { /* header or malformed row */ }
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
template <class Record>
|
||||
std::vector<BYTE> patch(const char* source, const char* names_path,
|
||||
std::array<DWORD, 4> key, const char* magic, bool versioned) {
|
||||
const auto input = read_file(source);
|
||||
const std::size_t count_offset = versioned ? 12 : 4;
|
||||
const std::size_t payload_offset = versioned ? 20 : 12;
|
||||
if (input.size() < payload_offset || std::memcmp(input.data(), magic, 4) != 0)
|
||||
throw std::runtime_error("unexpected proto signature");
|
||||
if (versioned && (word(input, 4) != 1 || word(input, 8) != sizeof(Record)))
|
||||
throw std::runtime_error("unexpected item proto version or stride");
|
||||
const DWORD count = word(input, count_offset);
|
||||
const DWORD payload_size = word(input, count_offset + 4);
|
||||
if (payload_size > input.size() - payload_offset)
|
||||
throw std::runtime_error("truncated compressed proto");
|
||||
|
||||
CLZObject decoded;
|
||||
if (!CLZO::Instance().Decompress(decoded, input.data() + payload_offset, key.data()) ||
|
||||
decoded.GetSize() != std::size_t(count) * sizeof(Record))
|
||||
throw std::runtime_error("invalid proto payload size or key");
|
||||
std::vector<BYTE> plain(decoded.GetBuffer(), decoded.GetBuffer() + decoded.GetSize());
|
||||
const auto original = plain;
|
||||
const auto names = read_names(names_path);
|
||||
std::size_t translated = 0, missing = 0, too_long = 0;
|
||||
for (DWORD i = 0; i < count; ++i) {
|
||||
Record record;
|
||||
std::memcpy(&record, plain.data() + std::size_t(i) * sizeof(Record), sizeof(Record));
|
||||
const auto found = names.find(record.dwVnum);
|
||||
if (found == names.end()) { ++missing; continue; }
|
||||
if (found->second.size() >= sizeof(record.szLocaleName)) { ++too_long; continue; }
|
||||
std::memset(record.szLocaleName, 0, sizeof(record.szLocaleName));
|
||||
std::memcpy(record.szLocaleName, found->second.data(), found->second.size());
|
||||
std::memcpy(plain.data() + std::size_t(i) * sizeof(Record), &record, sizeof(Record));
|
||||
++translated;
|
||||
}
|
||||
// No item stats, VNUM, canonical name, or mob gameplay fields may change.
|
||||
for (DWORD i = 0; i < count; ++i) {
|
||||
const std::size_t start = std::size_t(i) * sizeof(Record);
|
||||
const std::size_t name_start = start + offsetof(Record, szLocaleName);
|
||||
const std::size_t name_end = name_start + sizeof(Record::szLocaleName);
|
||||
for (std::size_t byte = start; byte < start + sizeof(Record); ++byte) {
|
||||
if ((byte < name_start || byte >= name_end) && plain[byte] != original[byte])
|
||||
throw std::runtime_error("non-name proto data changed");
|
||||
}
|
||||
}
|
||||
|
||||
CLZObject encoded;
|
||||
if (!CLZO::Instance().CompressEncryptedMemory(encoded, plain.data(), plain.size(), key.data()))
|
||||
throw std::runtime_error("cannot encode proto");
|
||||
std::vector<BYTE> result(input.begin(), input.begin() + payload_offset);
|
||||
result.insert(result.end(), encoded.GetBuffer(), encoded.GetBuffer() + encoded.GetSize());
|
||||
put_word(result, count_offset + 4, encoded.GetSize());
|
||||
CLZObject verified;
|
||||
if (!CLZO::Instance().Decompress(verified, result.data() + payload_offset, key.data()) ||
|
||||
verified.GetSize() != plain.size() ||
|
||||
std::memcmp(verified.GetBuffer(), plain.data(), plain.size()) != 0)
|
||||
throw std::runtime_error("encoded proto failed round-trip verification");
|
||||
std::cout << magic << " count=" << count << " translated=" << translated
|
||||
<< " missing=" << missing << " too_long=" << too_long << '\n';
|
||||
return result;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 7) {
|
||||
std::cerr << "usage: locale_proto_patch item_proto mob_proto item_names_gbk mob_names_gbk out_item out_mob\n";
|
||||
return 2;
|
||||
}
|
||||
try {
|
||||
static CLZO lzo;
|
||||
const auto item = patch<Item>(argv[1], argv[3], item_key, "MIPX", true);
|
||||
const auto mob = patch<Mob>(argv[2], argv[4], mob_key, "MMPT", false);
|
||||
write_file(argv[5], item);
|
||||
write_file(argv[6], mob);
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "locale_proto_patch: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,26 @@ static void drawing_primitives()
|
||||
graphic.RenderCoolTimeBox(100, 100, 10, 1.0f);
|
||||
CHECK(commands.empty());
|
||||
|
||||
// A cooldown triangle crossing the viewport must have clipped vertices, not only a clipped bbox.
|
||||
UIRenderSetClip(95, 95, 10, 10);
|
||||
graphic.RenderCoolTimeBox(100, 100, 10, 0.25f);
|
||||
CHECK(!commands.empty());
|
||||
bool touched_clip_edge = false;
|
||||
for (const auto& command : commands)
|
||||
{
|
||||
CHECK(command.kind == UIRenderCommand::Bar && command.quad);
|
||||
for (int vertex = 0; vertex < 3; ++vertex)
|
||||
{
|
||||
CHECK(command.qx[vertex] >= 95 && command.qx[vertex] <= 105);
|
||||
CHECK(command.qy[vertex] >= 95 && command.qy[vertex] <= 105);
|
||||
touched_clip_edge |= near(command.qx[vertex], 95) || near(command.qx[vertex], 105) ||
|
||||
near(command.qy[vertex], 95) || near(command.qy[vertex], 105);
|
||||
}
|
||||
}
|
||||
CHECK(touched_clip_edge);
|
||||
UIRenderRestoreClip();
|
||||
UIRenderBeginFrame();
|
||||
|
||||
// CPythonGraphic::SetViewport also clips the UI commands to the viewport until RestoreViewport.
|
||||
graphic.SetViewport(10, 20, 30, 40);
|
||||
screen.RenderBar2d(0, 0, 5, 5);
|
||||
|
||||
@@ -3360,8 +3360,10 @@ int run_live_client(
|
||||
unsigned ui_w = renderer.width(), ui_h = renderer.height();
|
||||
UIRenderGetSize(&ui_w, &ui_h);
|
||||
renderer.render(Render3DDraws(), empty_textures, ui_w, ui_h, UIRenderCommands());
|
||||
frame_ms.push_back(std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - frame_start).count());
|
||||
const double elapsed_ms = std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - frame_start).count();
|
||||
PythonBoot::SetNativeRenderFrameTime(static_cast<float>(elapsed_ms));
|
||||
frame_ms.push_back(elapsed_ms);
|
||||
++completed;
|
||||
}
|
||||
renderer.finish_gpu_timings();
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user