40250 classic: increment 50 (Phase 1 W1) — §1.5 client version, §1.10 text codec, §2.2 two-packet merge

§1.5 CG_CLIENT_VERSION: send_client_version() now branches on locale. EUROPE
(this project's locale) sends CGClientVersion2 (header 0xf1, 67B, timestamp
"1215955205"); non-EUROPE sends CGClientVersion (0xfd) + CMake-injected
MT_BUILD_TIMESTAMP in C __TIMESTAMP__ form. New ClassicSession::set_executable_name()
(default "metin2.bin"; TODO(W0): M2Client passes OS::get_executable_path().get_file()).
m_version_sent set only after send_fixed() succeeds. wire_classic.h gains
CGClientVersion2 + size-table entry.

§1.10 fixed-length text: new extension/src/net/text_codec.h — to_wire()/
from_wire_str() apply a hard byte cap without splitting a UTF-8 multibyte
sequence; godot::String overloads are __has_include-guarded so mtnet stays
godot-free. classic_session.cpp routes 8 fixed-length name/sign/comment fills
through to_wire(); parser reads GC_CHAR_ADDITIONAL_INFO name via from_wire_str().
New net_text_codec_test (ctest net.text_codec). Locale codepage (CP949/CP1252)
transcoding remains a follow-up; wire bytes are currently UTF-8 (ASCII round-trips).

§2.2 two-packet PC/NPC merge: ClassicParser GC_CHARACTER_ADD drops invisible
races (20025/20038/20039); PC/NPC only stash a bare Entity into m_pending_actor;
everything else spawns immediately with a name from root/npclist.txt (new
npc_names.h + set_npclist_path(); $MT_ASSETS/$M2_ASSETS fallback; TODO(W0):
M2Client passes the asset root). GC_CHAR_ADDITIONAL_INFO with no pending entry
records m_last_error + m_unhandled_headers and drops (never half-creates);
on a hit it merges the pending Entity with the additional-info's 9 fields into
one mut_spawn_full() then erases the pending entry. New pending_actor_count()
test hook; net_classic_session_test updated (version assertions -> 0xf1;
stash -> merge -> spawn, immediate monster spawn, invisible-race drop,
no-pending additional-info is a no-op). Depends on W2 §2.2 steps 4-5
(entity_store mut_char_info touch / mut_shop_sign cleanup) — the merge works
with current W2 code as-is.

Build + ctest 17/17 (16 + net.text_codec); netbridge_test, p10_test green.
Docs: CLIENT-GAP.md W1 row + increment-50 change-log entry; CLIENT-GAP-FIX.md
§1.5/§1.10/§2.2 status + facts and C.5 batch record.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
This commit is contained in:
shenlei
2026-09-02 15:51:48 +09:00
co-authored by Claude Sonnet 5
parent f32064c74a
commit b296457e3c
12 changed files with 742 additions and 74 deletions
+150 -38
View File
@@ -402,20 +402,39 @@ return SendSequence();
**修改**
1. `wire_classic.h` 补 `HDR_CG_CLIENT_VERSION2 = 0xf1` 与
1. `wire_classic.h` 补 `HDR_CG_CLIENT_VERSION2 = 0xF1` 与
`struct CGClientVersion2 { uint8_t header; char filename[33]; char timestamp[33]; };`
`static_assert(sizeof(CGClientVersion2) == 67)`(与 `CGClientVersion` 同布局)
2. `send_client_version()` 按 locale 分支:
- `Locale.is_europe() && !Locale.is_ymir()` → 发 `CGClientVersion2{0xf1, filename, "1215955205"}`
`static_assert(sizeof(CGClientVersion2) == 67)`(与 `CGClientVersion` 同布局)
尺寸表补 `case HDR_CG_CLIENT_VERSION2: return sizeof(CGClientVersion2);`。
2. ✅ `send_client_version()` 按 locale 分支:
- `m_locale_is_europe`(默认 true)→ 发 `CGClientVersion2{0xf1, filename, "1215955205"}`
header `HDR_CG_CLIENT_VERSION2`
- 否则 → 发 `CGClientVersion{0xfd, filename, MT_BUILD_TIMESTAMP}`。
本工程默认 locale 与参考一致(EUROPE 系),**默认走 0xf1**。
3. `filename` 改为运行时取:`OS::get_executable_path().get_file()`,由 `M2Client` 传入;
取不到时回落 `"metin2.bin"`。
4. `MT_BUILD_TIMESTAMP` 由 CMake 注入,格式对齐 C 的 `__TIMESTAMP__`
`"Www Mmm dd hh:mm:ss yyyy"`),只在非 EUROPE 分支使用。
5. 发送时机:跟随 `GC_MAIN_CHARACTER*`(4 个变体)解析成功后立刻发,并且
`return SendSequence()`(本工程即 `m_stream.send_fixed()` 的返回值透传),
删除现有的独立触发点与 `m_version_sent` 早置位。
3. `filename` 改为运行时取:新增 `ClassicSession::set_executable_name(std::string)` 由
`M2Client` 传入 `OS::get_executable_path().get_file()`;未设置时回落 `"metin2.bin"`。
填充经 §1.10 的 `mtnet::to_wire(name, sizeof(p.filename) - 1)`
**跨文件待接线**`M2Client` 需调用 `classic_sess->set_executable_name(...)`W0,已在
`classic_session.h` 留 `TODO(W0)`)。
4. ✅ `MT_BUILD_TIMESTAMP` 由 `extension/CMakeLists.txt` 的
`string(TIMESTAMP MT_BUILD_TS "%a %b %d %H:%M:%S %Y")` +
`target_compile_definitions(mtnet PRIVATE MT_BUILD_TIMESTAMP=...)` 注入,格式对齐 C 的
`__TIMESTAMP__``"Www Mmm dd hh:mm:ss yyyy"`),只在非 EUROPE 分支使用;
`.cpp` 内有 `#ifndef MT_BUILD_TIMESTAMP #define ... __TIMESTAMP__` 兜底。
非 1:1`%d` 是零补位,`__TIMESTAMP__` 的日是空格补位——按本节“近似即可”接受。
5. ✅ 发送时机:`on_packet()` 在四个 `GC_MAIN_CHARACTER*` 变体解析成功后立刻发,
`return send_fixed()` 的返回值;`m_version_sent` 只在发送成功后置位,删除独立触发点。
**W1 增量 50 已落地(🔎 待真实验收)**
- 改动:`wire_classic.h`enum + struct + 尺寸表)、`classic_session.{h,cpp}`
`set_executable_name` / `set_locale_is_europe` setter、`m_executable_name` /
`m_locale_is_europe` 成员、`send_client_version()` locale 分支重写)、
`extension/CMakeLists.txt``MT_BUILD_TIMESTAMP` 定义)。
- 测试:`net_classic_session_test` 的版本断言改为 `CGClientVersion2` / `0xf1` /
`filename=="metin2.bin"` / `timestamp=="1215955205"` / 尾字节 `SEQUENCE_TABLE[2]`
`ctest` `net.classic_session` 通过。
- 验收:未验证(无真实 40250 服务端确认 0xf1 被接受)。
### 1.6 Passpod / HackShield / XTrap / StateChecker 兼容路径
@@ -560,17 +579,38 @@ return true;
**修改**
1. 新建 `POC/extension/src/net/text_codec.h`
- `std::string to_wire(const godot::String&, size_t cap)`按当前 locale 代码页转换后**按字节**
截断到 `cap`,且不允许在多字节字符中间截断(截到最后一个完整字符)。
- `godot::String from_wire(const char*, size_t cap)`:反向,遇 `\0` 停
- 代码页由 `Locale.gd` 的 locale 决定,与 `REF/UserInterface/Locale_inc_*.h` 一一对应
2. `wire_classic.h` 的所有 `char name[...]` 字段统一经 `to_wire()` 填充,删掉散落的
`memcpy` / `strncpy(utf8)`
3. 角色名校验**统一为 `<= 24` 字节**`CHARACTER_NAME_MAX_LEN = 24`,缓冲 25 含结尾符)。
当前代码里 `< 24` 与 `<= 24` 两种规则各出现过,全部改为 `to_wire(name, 24).size() > 0 &&
<= 24`。涉及:`charselect.gd` 建号 / 改名、`guild_creation_ui.gd`(公会名上限 12)。
4. 回归:`text_codec_test.gd` 覆盖 CJK 名恰好 24 字节、25 字节(应拒)、边界多字节截断。
1. 新建 `extension/src/net/text_codec.h`mtnet 侧,不依赖 godot-cpp
- `std::string to_wire(std::string_view utf8, size_t cap)`**按字节**截断到 `cap`
且不在 UTF-8 多字节序列中间截断(截到最后一个完整码点)。
- `std::string from_wire_str(const char *bytes, size_t cap)`:反向,遇 `\0` 停
并裁掉末尾悬空的部分序列
- `godot::String` 重载用 `#if __has_include(<godot_cpp/variant/string.hpp>)`
`MT_TEXT_CODEC_HAS_GODOT`)守卫,保持 `net_classic_wire_test`(不链 mtnet)可编译
- **非 1:1follow-up**:目前 wire 字节即 UTF-8,未做 locale 代码页(CP949 / CP1252
转换;ASCII 可往返,非 ASCII 名对单字节编码服务端会呈现 mojibake。头注释已登记此缺口。
2. ✅ `wire_classic.h` `#include "../text_codec.h"``classic_session.cpp` 里所有定长名字 /
招牌 / 公会名 / 好友名 / 私密文本填充改为 `mtnet::to_wire(src, cap)` +
`memcpy(dst, w.data(), w.size())`,共 8 处:`create_character` / `change_name` /
`send_whisper` / `send_private_shop` / `send_guild_grade_name` / `send_guild_comment` /
`send_guild_answer_make` / `send_friend_add|remove`。`GC_CHAR_ADDITIONAL_INFO` 的名字
读取改用 `mtnet::from_wire_str(p.name, sizeof(p.name))`。
保留 `strncpy`:登录 id / 密码(`m_id` / `m_pw`,非 wire 定长名字段)、`send_chat` /
whisper 的动态文本 body。**其余 parser 名字读取(非 ADDITIONAL_INFO)仍走原 `strnlen`**
作为 follow-up。
3. ⬜ **不在本批次**`charselect.gd` / `guild_creation_ui.gd` 的名字长度校验统一为
`<= 24` 字节属 GDScriptW3 / Phase 2);本批次只交付 C++ 侧编码。已在下方登记。
4. ✅ 回归:新增 `extension/tests/net_text_codec_test.cpp``ctest` `net.text_codec`),
覆盖 CJK 名恰好 24 字节(接受)、25/27 字节(截断到 24,绝不 25/26)、
多字节边界截断(切进 3 字节序列中间丢整字)、ASCII 往返、`from_wire_str` 遇 NUL 停 /
悬空前导字节裁除 / 无终止符满字段整读。`extension/CMakeLists.txt` 注册。
**W1 增量 50 已落地(🔎 待真实验收)**
- 改动:`extension/src/net/text_codec.h`(新)、`extension/tests/net_text_codec_test.cpp`(新)、
`wire_classic.h`include)、`classic_session.cpp`8 处 to_wire 路由)、
`classic_parser.cpp`ADDITIONAL_INFO from_wire_str)、`extension/CMakeLists.txt`(测试注册)。
- 测试:`ctest` `net.text_codec` 通过(新增,套件 16 → 17);`net.classic_session` 通过。
- 验收:未验证(无真实服务端确认非 ASCII 名编码);locale 代码页转换为 follow-up。
---
@@ -672,21 +712,42 @@ else TraceError("TPacketGCCharacterAdditionalInfo name=%s vid=%d race=%d Error",
**修改**
1. `ClassicParser` 增加成员 `mtnet::PendingActor m_pending_actor;`(等价 `s_kNetActorData`)。
2. `GC_CHARACTER_ADD` 处理改为:
- `IsInvisibleRace(race)`20025 / 20038 / 20039)→ 直接返回;
- `bType == CHRTYPE_PC || bType == CHRTYPE_NPC` → **只**写 `m_pending_actor`,不调 `EntityStore`
- 否则 → 名字取 `npclist.txt` 的 `race → name`(等价 `CPythonNonPlayer::GetName`),
立即 `mut_spawn(...)`,且 `alignment/pk_mode/guild/empire/parts/mount/level` 全部置 0。
3. `GC_CHAR_ADDITIONAL_INFO` 处理改为:
- `m_pending_actor.vid != pkt.dwVID` → 记 error 并**丢弃**,不建实体;
- 相等 → 用 pending + additional 的 9 个字段合成一次 `mut_spawn_full(...)`
(新 API,一次性写全字段并推 `ChangeKind::Spawn`)。
4. **删除** `mut_char_info()` 里的 `touch()` 建实体路径;该函数只保留
「已存在实体的字段更新」语义,未知 VID 直接 return。
5. `mut_shop_sign()``entity_store.cpp:483`)不要再创建通用 Entity;改为独立的
`ShopSign` 表现状态,分别对应参考端 `BINARY_PrivateShop_Appear/Disappear` 的招牌显示 /
隐藏回调。这里不是把招牌包静默丢弃,而是移除“凭空建 Actor”的补丁。
1. `ClassicParser` 已有成员 `std::unordered_map<uint32_t, mtnet::Entity> m_pending_actor;`
(W0 增量 49 冻结,等价 `s_kNetActorData`,此处按多槽 map 而非单槽实现);
新增 `pending_actor_count()` 供测试断言。
2. ✅ `GC_CHARACTER_ADD` 处理改为:
- `is_invisible_race(race)`20025 / 20038 / 20039)→ `return true`(丢弃,不断连接);
- `type == CHRTYPE_PC || type == CHRTYPE_NPC` → **只**把裸 `Entity`vid / race / ch_type /
坐标 / 角度 / 速度 / state_flags / affect_flags)写入 `m_pending_actor[vid]`,不调 `m_world`
- 否则 → 名字取 `npclist.txt` 的 `race → code`(新 `npc_names.h`,等价
`CPythonNonPlayer::GetName`;取不到为空串),立即 `m_world.mut_spawn(...)`
`alignment / pk_mode / guild / empire / parts / mount / level` 全部为 0`mut_spawn` 默认)。
3. ✅ `GC_CHAR_ADDITIONAL_INFO` 处理改为:
- `m_pending_actor` 无 `pkt.dwVID` 条目 → 写 `m_last_error` + `m_unhandled_headers`
`return true`**丢弃**,不建实体;
- 命中 → 用 pending `Entity` + additional 的 9 个字段(name / guild / level / alignment /
pk_mode / empire / parts[armor/weapon/hair] / mount_vnum)合成一次
`m_world.mut_spawn_full(e)`(W0 新 API,一次写全字段并推 `Spawn`),随后 `erase` 该条目。
4. ⬜ **W2 依赖(本批次不改 `entity_store.*`**`mut_char_info()` 的 `touch()` 建实体路径、
`mut_shop_sign()` 的凭空建 Entity 由 W2 按 §2.2 步骤 4–5 处理;本批次的合并逻辑对当前 W2
代码即可工作(`mut_spawn_full` 已就位)。
5. ⬜ 同上,`mut_shop_sign()` → 独立 `ShopSign` 表现状态属 W2。
6. ✅ **跨文件待接线**`M2Client` 需调用
`parser().set_npclist_path(AssetRoot::path("root/npclist.txt"))`W0,已在
`classic_parser.h` 留 `TODO(W0)`);未设置时 `npc_names.h` 回落
`$MT_ASSETS` / `$M2_ASSETS` 环境变量,再回落空串。
**W1 增量 50 已落地(🔎 待真实验收)**
- 改动:`extension/src/net/classic/npc_names.h`(新,`load_npclist` / `default_npclist_path`
mtnet-safe)、`classic_parser.{h,cpp}``set_npclist_path` / `pending_actor_count` /
`npc_name_for` 惰性加载、`GC_CHARACTER_ADD` 与 `GC_CHAR_ADDITIONAL_INFO` 处理重写)。
`GC_CHARACTER_ADD2` 处理**未动**(超出本条范围,登记为 follow-up)。
- 测试:`net_classic_session_test` 的 NPC 块重写为“先 `GC_CHARACTER_ADD`(NPC) 只暂存
`world().size()` 不变、`pending_actor_count()==1`)→ 再 `GC_CHAR_ADDITIONAL_INFO` 合并落地
(暂存坐标 / 速度保留、9 字段合入、`pending_actor_count()==0`)→ 怪物立即 spawn →
隐身 race 丢弃 → 无 pending 的 additional-info 为 no-op”。`ctest` `net.classic_session` 通过。
- 验收:未验证(两包合并需真实 40250 服务端 `GC_CHARACTER_ADD` + `GC_CHAR_ADDITIONAL_INFO` 序列)。
---
@@ -2922,7 +2983,7 @@ var _pw := "123456789"
| 工作流 | 当前状态 | 已有证据 | 下一批次 |
|---|---|---|---|
| W0 公共基础 / 集成 | 🟡 开发中 | DirectEnter、Classic Warp endpoint、`world_reset`、场景重建首版;增量 49 接口冻结批次(`Entity` 字段 + `mut_spawn_full/mut_ownership/mut_map_bgm/drain_dirty` + `bgm_changed` 信号 + `bgm_director.gd` + §8.6 键位入口)已提交,C++ `ctest` 16/16 + 9 项 GDScript 回归通过 | 接收 Phase 1 W1/W2/W3/W4 批次并执行 G1 |
| W1 协议 / 账号会话 | 🟡 开发中 | 角色槽地址透传、发送结果、unknown / unhandled 错误链路、断线回 LOGIN`m_pending_actor` 暂存表成员就位 | §1.5 真实版本包、§1.10 定长文本编码、§2.2 两包合并逻辑 |
| W1 协议 / 账号会话 | 🟡 开发中(增量 50:§1.5 / §1.10 / §2.2 已落地,待真实验收) | 角色槽地址透传、发送结果、unknown / unhandled 错误链路、断线回 LOGIN§1.5 locale 分支 + `CGClientVersion2`(0xf1);§1.10 `text_codec.h` + `to_wire` 路由 + `net_text_codec_test`(套件 16→17);§2.2 `GC_CHARACTER_ADD` 隐身丢弃 / PC·NPC 暂存 / 其余按 `npclist.txt` 命名立即 spawn`GC_CHAR_ADDITIONAL_INFO` 无 pending 丢弃、命中则 `mut_spawn_full` 合并 | locale 代码页转码(CP949/CP1252)、真实双连接、进场延迟、GDScript 名字校验统一(Phase 2 / W3);`M2Client` 接线 `set_executable_name` / `set_npclist_path` |
| W2 实体 / 世界 / 战斗 | 🟡 开发中 | 地图实体 / 掉落清理、地图重建首版、实体测试和场景测试;`drain_dirty` / `mut_spawn_full` 骨架可用 | §2.1 两层模型 + 可见性、§2.3 `mut_spawn→mut_spawn_full`、§2.4 点数数组尾部、§2.5 应用顺序 + affect_flags |
| W3 玩法 UI / 输入 | 🟡 已有首版 | 多数窗口、背包 / 任务 / 社交、键位和输入已有回归;§8.6 数字键 / F1–F4 / Ctrl+数字入口已收敛 | §8.3 帮助窗;§8.6 剩余逐行键位、LSHIFT 翻页、表情包 |
| W4 渲染 / 资产 / 音频 | 🟡 已有首版 | GR2 / `.msa` / `.mse`、地图 / 水体 / 天气 / BGM 部分链路已有;`bgm_director.gd` 消费点就位 | §9.1 地图 BGM 曲目解析 + 音量、§9.2 后台恢复曲目、§9.3 生命周期单一属主 |
@@ -2971,3 +3032,54 @@ var _pw := "123456789"
下一批次:Phase 1 —— W1(§1.5/§1.10/§2.2)、W2(§2.1/§2.3/§2.4/§2.5)、W3(§8.3)、
W4(§9.1/§9.2/§9.3) 在各自 worktree 并行;Phase 2 由 W0 执行 G1 集成与文档合并
```
```text
批次:2026-09-02 / W1 / §1.5 §1.10 §2.2
状态:🔎 待真实验收(代码 + 本地回归齐全;无 40250 真实服务端)
参考:REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:4155SendClientVersionPacket);
REF/UserInterface/PythonNetworkStreamPhaseGameActor.cpp:140(两包合并 / IsInvisibleRace);
REF/UserInterface/StdAfx.h:43、Packet.h:380(定长文本常量)
改动:
extension/src/net/text_codec.h(新)—— to_wire(string_view,cap) / from_wire_str(char*,cap)
UTF-8 边界安全字节截断;godot::String 重载用 __has_include 守卫
extension/src/net/classic/npc_names.h(新)—— load_npclist() 解析 root/npclist.txt(vnum→code)
default_npclist_path() 走 $MT_ASSETS/$M2_ASSETSmtnet-safestd::ifstream
extension/src/net/classic/wire_classic.h —— +HDR_CG_CLIENT_VERSION2=0xF1、
struct CGClientVersion2(static_assert 67)、尺寸表分支;#include text_codec.h
CHRTYPE_* enum + is_invisible_race()W0 冻结项,已在增量 49 落地则保留)
extension/src/net/classic/classic_session.{h,cpp} —— set_executable_name/set_locale_is_europe
setter + m_executable_name/m_locale_is_europe 成员;send_client_version() locale 分支
(默认 0xf1 + "1215955205",非 EUROPE 走 0xfd + MT_BUILD_TIMESTAMP);8 处定长文本
填充路由到 to_wire()
extension/src/net/classic/classic_parser.{h,cpp} —— set_npclist_path/pending_actor_count/
npc_name_for 惰性加载;GC_CHARACTER_ADD(隐身丢弃 / PC·NPC 暂存 m_pending_actor /
其余 npclist 命名 + mut_spawn);GC_CHAR_ADDITIONAL_INFO(无 pending → m_last_error +
drop;命中 → 合并 9 字段 + mut_spawn_full + erase);ADDITIONAL_INFO 名字读 from_wire_str
extension/CMakeLists.txt —— string(TIMESTAMP)→MT_BUILD_TIMESTAMP 定义;net.text_codec 测试注册
extension/tests/net_text_codec_test.cpp(新)、net_classic_session_test.cpp(版本断言 +
两包合并断言重写)
接口/数据结构变化:ClassicSession +2 setter +2 成员;ClassicParser +2 public 方法
+1 private 方法 +3 私有成员(m_npclist_path / m_npc_names / m_npc_names_loaded);
wire +CGClientVersion2。未改 entity_store.* / m2_client.* / project/*。
测试:cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug && cmake --build build -j 通过;
cd build && ctest --output-on-failure → 100% tests passed, 17/17
(新增 net.text_codecnet.classic_session 及既有 16 项全绿);
godot --headless --path project --script res://netbridge_test.gd → PASSexit 0);
godot --headless --path project --script res://p10_test.gd → PASSexit 0)。
环境强制项:GDScript 回归前需先跑一次 godot --headless --path project --import
以填充 project/.godot/global_script_class_cache.cfgworktree 内 .godot 为空且 gitignore),
否则 class_name AssetRoot 无法解析;未改 project/*。无 skip。
验收:未验证——无真实 40250 服务端确认 0xf1 版本包被接受、两包合并序列、非 ASCII 名编码。
风险与依赖:
- to_wire() 仅做 UTF-8 边界安全字节截断,未做 locale 代码页(CP949/CP1252)转码;
ASCII 往返正确,非 ASCII 名对单字节编码服务端呈 mojibakefollow-up
- MT_BUILD_TIMESTAMP 的日为零补位(%d),__TIMESTAMP__ 为空格补位——按 §1.5“近似即可”
- 跨文件待接线(W0/M2Client):set_executable_name(OS::get_executable_path().get_file())、
parser().set_npclist_path(AssetRoot::path("root/npclist.txt"));均已留 TODO(W0)
- §2.2 步骤 45entity_store mut_char_info touch / mut_shop_sign)属 W2,本批次未动;
合并逻辑对当前 W2 代码可工作
- parser 中 ADDITIONAL_INFO 以外的名字读取仍走 strnlenGC_CHARACTER_ADD2 处理未动(follow-up
- GDScript 名字长度校验统一(charselect.gd / guild_creation_ui.gd)属 Phase 2 / W3
下一批次:Phase 2 —— W0 执行 G1 集成,接线 set_executable_name / set_npclist_path
合并 §2.2 步骤 4–5,统一 GDScript 名字校验;真实 40250 服务端到位后做 §1.5/§1.10/§2.2 真服验收
```