40250 classic: G1 integrate W1 (§1.5 client version, §1.10 text codec, §2.2 two-packet merge)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
This commit is contained in:
+150
-38
@@ -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]; };`
|
`struct CGClientVersion2 { uint8_t header; char filename[33]; char timestamp[33]; };`
|
||||||
`static_assert(sizeof(CGClientVersion2) == 67)`(与 `CGClientVersion` 同布局)。
|
`static_assert(sizeof(CGClientVersion2) == 67)`(与 `CGClientVersion` 同布局);
|
||||||
2. `send_client_version()` 按 locale 分支:
|
尺寸表补 `case HDR_CG_CLIENT_VERSION2: return sizeof(CGClientVersion2);`。
|
||||||
- `Locale.is_europe() && !Locale.is_ymir()` → 发 `CGClientVersion2{0xf1, filename, "1215955205"}`;
|
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}`。
|
- 否则 → 发 `CGClientVersion{0xfd, filename, MT_BUILD_TIMESTAMP}`。
|
||||||
本工程默认 locale 与参考一致(EUROPE 系),**默认走 0xf1**。
|
本工程默认 locale 与参考一致(EUROPE 系),**默认走 0xf1**。
|
||||||
3. `filename` 改为运行时取:`OS::get_executable_path().get_file()`,由 `M2Client` 传入;
|
3. ✅ `filename` 改为运行时取:新增 `ClassicSession::set_executable_name(std::string)` 由
|
||||||
取不到时回落 `"metin2.bin"`。
|
`M2Client` 传入 `OS::get_executable_path().get_file()`;未设置时回落 `"metin2.bin"`。
|
||||||
4. `MT_BUILD_TIMESTAMP` 由 CMake 注入,格式对齐 C 的 `__TIMESTAMP__`
|
填充经 §1.10 的 `mtnet::to_wire(name, sizeof(p.filename) - 1)`。
|
||||||
(`"Www Mmm dd hh:mm:ss yyyy"`),只在非 EUROPE 分支使用。
|
**跨文件待接线**:`M2Client` 需调用 `classic_sess->set_executable_name(...)`(W0,已在
|
||||||
5. 发送时机:跟随 `GC_MAIN_CHARACTER*`(4 个变体)解析成功后立刻发,并且
|
`classic_session.h` 留 `TODO(W0)`)。
|
||||||
`return SendSequence()`(本工程即 `m_stream.send_fixed()` 的返回值透传),
|
4. ✅ `MT_BUILD_TIMESTAMP` 由 `extension/CMakeLists.txt` 的
|
||||||
删除现有的独立触发点与 `m_version_sent` 早置位。
|
`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 兼容路径
|
### 1.6 Passpod / HackShield / XTrap / StateChecker 兼容路径
|
||||||
|
|
||||||
@@ -560,17 +579,38 @@ return true;
|
|||||||
|
|
||||||
**修改**
|
**修改**
|
||||||
|
|
||||||
1. 新建 `POC/extension/src/net/text_codec.h`:
|
1. ✅ 新建 `extension/src/net/text_codec.h`(mtnet 侧,不依赖 godot-cpp):
|
||||||
- `std::string to_wire(const godot::String&, size_t cap)`:按当前 locale 代码页转换后**按字节**
|
- `std::string to_wire(std::string_view utf8, size_t cap)`:**按字节**截断到 `cap`,
|
||||||
截断到 `cap`,且不允许在多字节字符中间截断(截到最后一个完整字符)。
|
且不在 UTF-8 多字节序列中间截断(截到最后一个完整码点)。
|
||||||
- `godot::String from_wire(const char*, size_t cap)`:反向,遇 `\0` 停。
|
- `std::string from_wire_str(const char *bytes, size_t cap)`:反向,遇 `\0` 停,
|
||||||
- 代码页由 `Locale.gd` 的 locale 决定,与 `REF/UserInterface/Locale_inc_*.h` 一一对应。
|
并裁掉末尾悬空的部分序列。
|
||||||
2. `wire_classic.h` 的所有 `char name[...]` 字段统一经 `to_wire()` 填充,删掉散落的
|
- `godot::String` 重载用 `#if __has_include(<godot_cpp/variant/string.hpp>)`
|
||||||
`memcpy` / `strncpy(utf8)`。
|
(`MT_TEXT_CODEC_HAS_GODOT`)守卫,保持 `net_classic_wire_test`(不链 mtnet)可编译。
|
||||||
3. 角色名校验**统一为 `<= 24` 字节**(`CHARACTER_NAME_MAX_LEN = 24`,缓冲 25 含结尾符)。
|
- **非 1:1(follow-up)**:目前 wire 字节即 UTF-8,未做 locale 代码页(CP949 / CP1252)
|
||||||
当前代码里 `< 24` 与 `<= 24` 两种规则各出现过,全部改为 `to_wire(name, 24).size() > 0 &&
|
转换;ASCII 可往返,非 ASCII 名对单字节编码服务端会呈现 mojibake。头注释已登记此缺口。
|
||||||
<= 24`。涉及:`charselect.gd` 建号 / 改名、`guild_creation_ui.gd`(公会名上限 12)。
|
2. ✅ `wire_classic.h` `#include "../text_codec.h"`;`classic_session.cpp` 里所有定长名字 /
|
||||||
4. 回归:`text_codec_test.gd` 覆盖 CJK 名恰好 24 字节、25 字节(应拒)、边界多字节截断。
|
招牌 / 公会名 / 好友名 / 私密文本填充改为 `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` 字节属 GDScript(W3 / 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`)。
|
1. ✅ `ClassicParser` 已有成员 `std::unordered_map<uint32_t, mtnet::Entity> m_pending_actor;`
|
||||||
2. `GC_CHARACTER_ADD` 处理改为:
|
(W0 增量 49 冻结,等价 `s_kNetActorData`,此处按多槽 map 而非单槽实现);
|
||||||
- `IsInvisibleRace(race)`(20025 / 20038 / 20039)→ 直接返回;
|
新增 `pending_actor_count()` 供测试断言。
|
||||||
- `bType == CHRTYPE_PC || bType == CHRTYPE_NPC` → **只**写 `m_pending_actor`,不调 `EntityStore`;
|
2. ✅ `GC_CHARACTER_ADD` 处理改为:
|
||||||
- 否则 → 名字取 `npclist.txt` 的 `race → name`(等价 `CPythonNonPlayer::GetName`),
|
- `is_invisible_race(race)`(20025 / 20038 / 20039)→ `return true`(丢弃,不断连接);
|
||||||
立即 `mut_spawn(...)`,且 `alignment/pk_mode/guild/empire/parts/mount/level` 全部置 0。
|
- `type == CHRTYPE_PC || type == CHRTYPE_NPC` → **只**把裸 `Entity`(vid / race / ch_type /
|
||||||
3. `GC_CHAR_ADDITIONAL_INFO` 处理改为:
|
坐标 / 角度 / 速度 / state_flags / affect_flags)写入 `m_pending_actor[vid]`,不调 `m_world`;
|
||||||
- `m_pending_actor.vid != pkt.dwVID` → 记 error 并**丢弃**,不建实体;
|
- 否则 → 名字取 `npclist.txt` 的 `race → code`(新 `npc_names.h`,等价
|
||||||
- 相等 → 用 pending + additional 的 9 个字段合成一次 `mut_spawn_full(...)`
|
`CPythonNonPlayer::GetName`;取不到为空串),立即 `m_world.mut_spawn(...)`,
|
||||||
(新 API,一次性写全字段并推 `ChangeKind::Spawn`)。
|
`alignment / pk_mode / guild / empire / parts / mount / level` 全部为 0(`mut_spawn` 默认)。
|
||||||
4. **删除** `mut_char_info()` 里的 `touch()` 建实体路径;该函数只保留
|
3. ✅ `GC_CHAR_ADDITIONAL_INFO` 处理改为:
|
||||||
「已存在实体的字段更新」语义,未知 VID 直接 return。
|
- `m_pending_actor` 无 `pkt.dwVID` 条目 → 写 `m_last_error` + `m_unhandled_headers`,
|
||||||
5. `mut_shop_sign()`(`entity_store.cpp:483`)不要再创建通用 Entity;改为独立的
|
`return true`**丢弃**,不建实体;
|
||||||
`ShopSign` 表现状态,分别对应参考端 `BINARY_PrivateShop_Appear/Disappear` 的招牌显示 /
|
- 命中 → 用 pending `Entity` + additional 的 9 个字段(name / guild / level / alignment /
|
||||||
隐藏回调。这里不是把招牌包静默丢弃,而是移除“凭空建 Actor”的补丁。
|
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 |
|
| 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 |
|
| 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 翻页、表情包 |
|
| 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 生命周期单一属主 |
|
| 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)、
|
下一批次: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 集成与文档合并
|
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:4155(SendClientVersionPacket);
|
||||||
|
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_ASSETS;mtnet-safe(std::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_codec;net.classic_session 及既有 16 项全绿);
|
||||||
|
godot --headless --path project --script res://netbridge_test.gd → PASS(exit 0);
|
||||||
|
godot --headless --path project --script res://p10_test.gd → PASS(exit 0)。
|
||||||
|
环境强制项:GDScript 回归前需先跑一次 godot --headless --path project --import
|
||||||
|
以填充 project/.godot/global_script_class_cache.cfg(worktree 内 .godot 为空且 gitignore),
|
||||||
|
否则 class_name AssetRoot 无法解析;未改 project/*。无 skip。
|
||||||
|
验收:未验证——无真实 40250 服务端确认 0xf1 版本包被接受、两包合并序列、非 ASCII 名编码。
|
||||||
|
风险与依赖:
|
||||||
|
- to_wire() 仅做 UTF-8 边界安全字节截断,未做 locale 代码页(CP949/CP1252)转码;
|
||||||
|
ASCII 往返正确,非 ASCII 名对单字节编码服务端呈 mojibake(follow-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 步骤 4–5(entity_store mut_char_info touch / mut_shop_sign)属 W2,本批次未动;
|
||||||
|
合并逻辑对当前 W2 代码可工作
|
||||||
|
- parser 中 ADDITIONAL_INFO 以外的名字读取仍走 strnlen;GC_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 真服验收
|
||||||
|
```
|
||||||
|
|||||||
+27
-1
@@ -56,7 +56,7 @@
|
|||||||
| 工作流 | 状态 | 当前已知进展 | 下一步 |
|
| 工作流 | 状态 | 当前已知进展 | 下一步 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 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 键位收敛)已提交 | Phase 1 4 工作流并行完成后执行 G1 集成 |
|
| 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 键位收敛)已提交 | Phase 1 4 工作流并行完成后执行 G1 集成 |
|
||||||
| W1 协议 / 账号会话 | 🟡 | 角色槽地址透传、发送结果、unknown / unhandled 错误链路、断线回 LOGIN 已覆盖;`m_pending_actor` 暂存表成员已就位 | §1.5 真实版本包、§1.10 定长文本编码、§2.2 两包合并逻辑;真实双连接、enter delay |
|
| W1 协议 / 账号会话 | 🟡 | 增量 50:§1.5 版本包按 locale 分支(EUROPE 发 `CG_CLIENT_VERSION2` 0xf1 + `"1215955205"`,其余发 0xfd + `MT_BUILD_TIMESTAMP`)、可执行名 setter;§1.10 `text_codec.h` UTF-8 安全定长文本裁剪 + `net_text_codec_test`,会话侧所有定长 name/sign/comment 填充改走 `to_wire()`;§2.2 `GC_CHARACTER_ADD` 按 invisible-race 丢弃 / PC·NPC 暂存 `m_pending_actor` / 其余即时 `mut_spawn`(名字取自 npclist.txt),`GC_CHAR_ADDITIONAL_INFO` 无暂存则记录并丢弃、命中则合并 9 字段 `mut_spawn_full` 后清表 | 真实 locale codepage 转码、真实双连接、enter delay;GDScript 名称长度校验统一(Phase 2/W3) |
|
||||||
| W2 实体 / 世界 / 战斗 | 🟡 | 地图实体 / 掉落清理、地图重建首版及离线回归已覆盖;`drain_dirty` / `mut_spawn_full` 骨架可用 | §2.1 两层模型 + 可见性、§2.3 `mut_spawn→mut_spawn_full`、§2.4 点数数组尾部、§2.5 应用顺序 + affect_flags |
|
| 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 剩余逐行键位与表情包 |
|
| W3 玩法 UI / 输入 | 🟡 | 多数窗口、背包 / 任务 / 社交、键位和输入已有首版;§8.6 数字键 / F1–F4 / Ctrl+数字入口已收敛 | §8.3 帮助窗;§8.6 剩余逐行键位与表情包 |
|
||||||
| W4 渲染 / 资产 / 音频 | 🟡 | GR2 / `.msa` / `.mse`、地图 / 水体 / 天气 / BGM 部分链路已有;`bgm_director.gd` 消费点已就位 | §9.1 地图 BGM 曲目解析 + 音量、§9.2 后台恢复曲目、§9.3 生命周期单一属主 |
|
| W4 渲染 / 资产 / 音频 | 🟡 | GR2 / `.msa` / `.mse`、地图 / 水体 / 天气 / BGM 部分链路已有;`bgm_director.gd` 消费点已就位 | §9.1 地图 BGM 曲目解析 + 音量、§9.2 后台恢复曲目、§9.3 生命周期单一属主 |
|
||||||
@@ -115,6 +115,32 @@
|
|||||||
> `net_classic_session_test`、`gamescene_test`、`p9_test`、`p2b_test`、`netbridge_test`、
|
> `net_classic_session_test`、`gamescene_test`、`p9_test`、`p2b_test`、`netbridge_test`、
|
||||||
> `p8_test` 已通过;受限环境下 localhost TCP 目标测试仍自动 skip。真实 40250 换图 / 第二次
|
> `p8_test` 已通过;受限环境下 localhost TCP 目标测试仍自动 skip。真实 40250 换图 / 第二次
|
||||||
> 连接 E2E、地图 BGM 选择和 `MT_CLASSIC_ENTER_DELAY` 仍待完成。
|
> 连接 E2E、地图 BGM 选择和 `MT_CLASSIC_ENTER_DELAY` 仍待完成。
|
||||||
|
> - **2026-09-02 增量 50(Phase 1 W1)**:落地 `CLIENT-GAP-FIX.md` §1.5 / §1.10 / §2.2。
|
||||||
|
> §1.5:`ClassicSession::send_client_version()` 改为 locale 分支——EUROPE(本项目 locale)发
|
||||||
|
> `CG_CLIENT_VERSION2`(header `0xf1`,`CGClientVersion2` 67B,timestamp 固定 `"1215955205"`),
|
||||||
|
> 非 EUROPE 发 `CG_CLIENT_VERSION`(`0xfd`)+ CMake 注入的 `MT_BUILD_TIMESTAMP`(C `__TIMESTAMP__`
|
||||||
|
> 形态);新增 `set_executable_name()`(默认 `"metin2.bin"`,`// TODO(W0)` 由 M2Client 传
|
||||||
|
> `OS::get_executable_path().get_file()`);版本包只在 `send_fixed()` 成功后置 `m_version_sent`。
|
||||||
|
> `wire_classic.h` 补 `CGClientVersion2` 结构 + 尺寸表项。§1.10:新增 `extension/src/net/text_codec.h`
|
||||||
|
> —— `to_wire()/from_wire_str()` 做 UTF-8 安全的按字节裁剪(不切多字节序列,硬字节上限),
|
||||||
|
> `godot::String` 重载用 `__has_include` 守卫使 mtnet 仍无 godot 依赖;`classic_session.cpp` 里
|
||||||
|
> create/change name、whisper、私店招牌、公会等级名 / 公告、`ANSWER_MAKE_GUILD`、好友增删共 8 处
|
||||||
|
> 定长填充改走 `to_wire()`,parser 侧 `GC_CHAR_ADDITIONAL_INFO` 名字读取走 `from_wire_str()`;
|
||||||
|
> 新增 `net_text_codec_test`(CJK 24B 收下 / 27B 截到 24B / 多字节边界 / ASCII 往返 / 读端悬挂字节)。
|
||||||
|
> 真实 locale codepage(CP949 / CP1252)转码仍为后续项,当前 wire 字节即 UTF-8(ASCII 往返一致)。
|
||||||
|
> §2.2:`ClassicParser` `GC_CHARACTER_ADD` —— `is_invisible_race`(20025/20038/20039)直接丢弃;
|
||||||
|
> `bType==PC||NPC` 仅把裸 `Entity`(vid/race/type/坐标/朝向/速度/state·affect flag)暂存进
|
||||||
|
> `m_pending_actor`,不碰世界模型;其余(怪 / 石 / 门 / 物件)即时 `mut_spawn()`,名字取自
|
||||||
|
> `root/npclist.txt`(race→code,新增 `npc_names.h` + `set_npclist_path()` setter,`$MT_ASSETS`/
|
||||||
|
> `$M2_ASSETS` 回退,`// TODO(W0)` 由 M2Client 传资源根,无表时名字为空)。`GC_CHAR_ADDITIONAL_INFO`
|
||||||
|
> —— 无对应暂存则写 `m_last_error` + `m_unhandled_headers` 并丢弃(绝不半创建);命中则把暂存
|
||||||
|
> `Entity` 与附加信息 9 字段(name/guild/level/alignment/pk_mode/empire/parts[armor/weapon/hair]/
|
||||||
|
> mount_vnum)合并为一条,`mut_spawn_full()` 落地后 `erase` 暂存项。新增 `pending_actor_count()`
|
||||||
|
> 测试钩子;`net_classic_session_test` 更新(版本包断言改 `0xf1`/`CGClientVersion2`;新增两包
|
||||||
|
> 暂存 → 合并 → 落地、怪即时生成、invisible-race 丢弃、无暂存附加信息为空操作的覆盖)。
|
||||||
|
> 依赖 W2 §2.2 步骤 4–5(`mut_char_info` touch-create / `mut_shop_sign` 裸 `Entity` 清理)——
|
||||||
|
> 本次合并逻辑与当前 W2 代码兼容。C++ `ctest` 17/17(原 16 + `net.text_codec`)、`netbridge_test`、
|
||||||
|
> `p10_test` 全绿。
|
||||||
> - **2026-09-02 增量 49(W0 接口冻结批次)**:为 Phase 1 的 4 个并行工作流一次性落地跨工作流
|
> - **2026-09-02 增量 49(W0 接口冻结批次)**:为 Phase 1 的 4 个并行工作流一次性落地跨工作流
|
||||||
> 共享文件的骨架,冻结接口后再拆分。`EntityStore`:`Entity` 补 `empire / affect_flags /
|
> 共享文件的骨架,冻结接口后再拆分。`EntityStore`:`Entity` 补 `empire / affect_flags /
|
||||||
> owner_vid / state_flags` 字段;新增 `mut_spawn_full()`(§2.3 两包合并的单次落地入口)、
|
> owner_vid / state_flags` 字段;新增 `mut_spawn_full()`(§2.3 两包合并的单次落地入口)、
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ add_library(mtnet STATIC
|
|||||||
target_include_directories(mtnet PUBLIC src/net)
|
target_include_directories(mtnet PUBLIC src/net)
|
||||||
target_link_libraries(mtnet PUBLIC mt3p::sodium mt3p::minilzo mt3p::cryptopp)
|
target_link_libraries(mtnet PUBLIC mt3p::sodium mt3p::minilzo mt3p::cryptopp)
|
||||||
target_compile_features(mtnet PUBLIC cxx_std_20)
|
target_compile_features(mtnet PUBLIC cxx_std_20)
|
||||||
|
# §1.5: non-EUROPE CG_CLIENT_VERSION timestamp, in C __TIMESTAMP__ form
|
||||||
|
# ("Www Mmm dd hh:mm:ss yyyy"); regenerated on each CMake configure.
|
||||||
|
string(TIMESTAMP MT_BUILD_TS "%a %b %d %H:%M:%S %Y")
|
||||||
|
target_compile_definitions(mtnet PRIVATE MT_BUILD_TIMESTAMP="${MT_BUILD_TS}")
|
||||||
|
|
||||||
# --- mtpack: m2dev-fork asset pack reader/writer (libsodium + zstd) ---
|
# --- mtpack: m2dev-fork asset pack reader/writer (libsodium + zstd) ---
|
||||||
add_library(mtpack STATIC
|
add_library(mtpack STATIC
|
||||||
@@ -109,6 +113,10 @@ if(BUILD_TESTING AND MT_HOST_BUILD)
|
|||||||
target_link_libraries(net_classic_mark_test PRIVATE mtnet)
|
target_link_libraries(net_classic_mark_test PRIVATE mtnet)
|
||||||
add_test(NAME net.classic_mark COMMAND $<TARGET_FILE:net_classic_mark_test>)
|
add_test(NAME net.classic_mark COMMAND $<TARGET_FILE:net_classic_mark_test>)
|
||||||
|
|
||||||
|
add_executable(net_text_codec_test tests/net_text_codec_test.cpp)
|
||||||
|
target_link_libraries(net_text_codec_test PRIVATE mtnet)
|
||||||
|
add_test(NAME net.text_codec COMMAND $<TARGET_FILE:net_text_codec_test>)
|
||||||
|
|
||||||
add_executable(net_mark_test tests/net_mark_test.cpp)
|
add_executable(net_mark_test tests/net_mark_test.cpp)
|
||||||
target_link_libraries(net_mark_test PRIVATE mtnet mt3p::minilzo)
|
target_link_libraries(net_mark_test PRIVATE mtnet mt3p::minilzo)
|
||||||
add_test(NAME net.guild_mark COMMAND $<TARGET_FILE:net_mark_test>)
|
add_test(NAME net.guild_mark COMMAND $<TARGET_FILE:net_mark_test>)
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
#include "classic_parser.h"
|
#include "classic_parser.h"
|
||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace mtnet::classic {
|
namespace mtnet::classic {
|
||||||
|
|
||||||
|
void ClassicParser::set_npclist_path(std::string path) {
|
||||||
|
if (path == m_npclist_path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_npclist_path = std::move(path);
|
||||||
|
m_npc_names.clear();
|
||||||
|
m_npc_names_loaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string &ClassicParser::npc_name_for(uint16_t race) {
|
||||||
|
static const std::string kEmpty;
|
||||||
|
if (!m_npc_names_loaded) {
|
||||||
|
m_npc_names = load_npclist(m_npclist_path.empty() ? default_npclist_path() : m_npclist_path);
|
||||||
|
m_npc_names_loaded = true;
|
||||||
|
}
|
||||||
|
const auto it = m_npc_names.find(race);
|
||||||
|
return it == m_npc_names.end() ? kEmpty : it->second;
|
||||||
|
}
|
||||||
|
|
||||||
void ClassicParser::parse_login_success(const SimplePlayer *players, const uint32_t *guild_id,
|
void ClassicParser::parse_login_success(const SimplePlayer *players, const uint32_t *guild_id,
|
||||||
const char (*guild_name)[GUILD_NAME_MAX_LEN + 1], int n, uint32_t handle, uint32_t rkey) {
|
const char (*guild_name)[GUILD_NAME_MAX_LEN + 1], int n, uint32_t handle, uint32_t rkey) {
|
||||||
m_slots.clear();
|
m_slots.clear();
|
||||||
@@ -207,7 +228,36 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
|||||||
if (!fill(p, header, body, len)) {
|
if (!fill(p, header, body, len)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
m_world.mut_spawn(p.vid, p.race, p.type, /*name*/ std::string(), (float)p.x, (float)p.y,
|
// §0.3 / §2.2: the reference client (IsInvisibleRace) never spawns
|
||||||
|
// these — drop before any stash / spawn.
|
||||||
|
if (is_invisible_race(p.race)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (p.type == CHRTYPE_PC || p.type == CHRTYPE_NPC) {
|
||||||
|
// Two-packet actor: GC_CHARACTER_ADD carries no name / guild /
|
||||||
|
// parts, so stash a bare record and wait for
|
||||||
|
// GC_CHAR_ADDITIONAL_INFO to merge + flush it. The world model
|
||||||
|
// is untouched until then (§0.2 data layer only).
|
||||||
|
Entity &e = m_pending_actor[p.vid];
|
||||||
|
e = Entity{};
|
||||||
|
e.vid = p.vid;
|
||||||
|
e.race = p.race;
|
||||||
|
e.ch_type = p.type;
|
||||||
|
e.x = (float)p.x;
|
||||||
|
e.y = (float)p.y;
|
||||||
|
e.z = (float)p.z;
|
||||||
|
e.angle = p.angle;
|
||||||
|
e.moving_speed = p.moving_speed;
|
||||||
|
e.attack_speed = p.attack_speed;
|
||||||
|
e.state_flags = p.state_flag;
|
||||||
|
e.affect_flags = (uint64_t)p.affect_flag[0] |
|
||||||
|
((uint64_t)p.affect_flag[1] << 32);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Monster / stone / door / object: appended immediately, name from
|
||||||
|
// npclist.txt (race -> code, like CPythonNonPlayer::GetName); every
|
||||||
|
// PC-specific field stays 0.
|
||||||
|
m_world.mut_spawn(p.vid, p.race, p.type, npc_name_for(p.race), (float)p.x, (float)p.y,
|
||||||
(float)p.z, p.angle, p.moving_speed, p.attack_speed);
|
(float)p.z, p.angle, p.moving_speed, p.attack_speed);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -396,9 +446,31 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
|||||||
if (!fill(p, header, body, len)) {
|
if (!fill(p, header, body, len)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
m_world.mut_char_info(p.vid, std::string(p.name, strnlen(p.name, sizeof(p.name))),
|
// §2.2: this only ever completes a pending GC_CHARACTER_ADD (the
|
||||||
p.parts, p.empire, (int32_t)p.guild_id, (int32_t)p.level, p.alignment,
|
// reference merges into s_kNetActorData, populated by that add). No
|
||||||
p.pk_mode, p.mount_vnum);
|
// pending record -> nothing to build a PC/NPC from: record + drop,
|
||||||
|
// never half-create.
|
||||||
|
const auto it = m_pending_actor.find(p.vid);
|
||||||
|
if (it == m_pending_actor.end()) {
|
||||||
|
m_last_error = "GC_CHAR_ADDITIONAL_INFO: no pending actor for vid " +
|
||||||
|
std::to_string(p.vid);
|
||||||
|
m_unhandled_headers.push_back(header);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Entity e = it->second;
|
||||||
|
e.vid = p.vid;
|
||||||
|
e.name = mtnet::from_wire_str(p.name, sizeof(p.name)); // §1.10
|
||||||
|
e.guild = (int32_t)p.guild_id;
|
||||||
|
e.level = (int32_t)p.level;
|
||||||
|
e.alignment = p.alignment;
|
||||||
|
e.pk_mode = p.pk_mode;
|
||||||
|
e.empire = p.empire;
|
||||||
|
for (int i = 0; i < CHR_EQUIPPART_NUM; ++i) {
|
||||||
|
e.parts[i] = p.parts[i];
|
||||||
|
}
|
||||||
|
e.mount_vnum = p.mount_vnum;
|
||||||
|
m_world.mut_spawn_full(e);
|
||||||
|
m_pending_actor.erase(it);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case HDR_GC_SKILL_LEVEL: { // 76
|
case HDR_GC_SKILL_LEVEL: { // 76
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
// [header][uint16 size] prefix); `len` its length — i.e. exactly what
|
// [header][uint16 size] prefix); `len` its length — i.e. exactly what
|
||||||
// ClassicStream::on_packet delivers.
|
// ClassicStream::on_packet delivers.
|
||||||
|
|
||||||
|
#include "npc_names.h"
|
||||||
#include "wire_classic.h"
|
#include "wire_classic.h"
|
||||||
|
|
||||||
#include "../entity_store.h"
|
#include "../entity_store.h"
|
||||||
@@ -67,6 +68,16 @@ public:
|
|||||||
uint8_t empire() const { return m_empire; }
|
uint8_t empire() const { return m_empire; }
|
||||||
void set_phase_name(const std::string &name) { m_phase_name = name; }
|
void set_phase_name(const std::string &name) { m_phase_name = name; }
|
||||||
const std::string &last_error() const { return m_last_error; }
|
const std::string &last_error() const { return m_last_error; }
|
||||||
|
// §2.2: path to root/npclist.txt (race -> model-code table). Used to name
|
||||||
|
// monster / stone / object spawns, mirroring CPythonNonPlayer::GetName().
|
||||||
|
// Empty path -> names resolve to "" (permitted). Falls back to
|
||||||
|
// $MT_ASSETS / $M2_ASSETS when never set.
|
||||||
|
// TODO(W0): M2Client should call
|
||||||
|
// parser().set_npclist_path(AssetRoot::path("root/npclist.txt"))
|
||||||
|
void set_npclist_path(std::string path);
|
||||||
|
// §2.2 test hook: number of GC_CHARACTER_ADD records awaiting their
|
||||||
|
// GC_CHAR_ADDITIONAL_INFO merge.
|
||||||
|
size_t pending_actor_count() const { return m_pending_actor.size(); }
|
||||||
std::vector<uint8_t> drain_unhandled_headers() {
|
std::vector<uint8_t> drain_unhandled_headers() {
|
||||||
auto v = std::move(m_unhandled_headers);
|
auto v = std::move(m_unhandled_headers);
|
||||||
m_unhandled_headers.clear();
|
m_unhandled_headers.clear();
|
||||||
@@ -101,12 +112,18 @@ private:
|
|||||||
void parse_login_success(const SimplePlayer *players, const uint32_t *guild_id,
|
void parse_login_success(const SimplePlayer *players, const uint32_t *guild_id,
|
||||||
const char (*guild_name)[GUILD_NAME_MAX_LEN + 1], int n, uint32_t handle,
|
const char (*guild_name)[GUILD_NAME_MAX_LEN + 1], int n, uint32_t handle,
|
||||||
uint32_t rkey);
|
uint32_t rkey);
|
||||||
|
// Lazily loads npclist.txt on first use; returns "" for an unknown race or
|
||||||
|
// when no list is available.
|
||||||
|
const std::string &npc_name_for(uint16_t race);
|
||||||
|
|
||||||
EntityStore &m_world;
|
EntityStore &m_world;
|
||||||
// §2.2 two-packet PC/NPC merge: GC_CHARACTER_ADD stages a bare Entity here
|
// §2.2 two-packet PC/NPC merge: GC_CHARACTER_ADD stages a bare Entity here
|
||||||
// keyed by vid; GC_CHAR_ADDITIONAL_INFO merges its fields and flushes the
|
// keyed by vid; GC_CHAR_ADDITIONAL_INFO merges its fields and flushes the
|
||||||
// combined record via m_world.mut_spawn_full(). W1 owns the merge logic.
|
// combined record via m_world.mut_spawn_full(). W1 owns the merge logic.
|
||||||
std::unordered_map<uint32_t, Entity> m_pending_actor;
|
std::unordered_map<uint32_t, Entity> m_pending_actor;
|
||||||
|
std::string m_npclist_path;
|
||||||
|
std::unordered_map<uint32_t, std::string> m_npc_names;
|
||||||
|
bool m_npc_names_loaded = false;
|
||||||
std::vector<CharSlot> m_slots;
|
std::vector<CharSlot> m_slots;
|
||||||
int m_slot_count = 0;
|
int m_slot_count = 0;
|
||||||
uint32_t m_handle = 0;
|
uint32_t m_handle = 0;
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#ifndef MT_BUILD_TIMESTAMP
|
||||||
|
// Injected by CMake (extension/CMakeLists.txt) in C __TIMESTAMP__ form
|
||||||
|
// ("Www Mmm dd hh:mm:ss yyyy"); §1.5 uses it as the non-EUROPE
|
||||||
|
// CG_CLIENT_VERSION timestamp. This fallback keeps a non-CMake build compiling.
|
||||||
|
#define MT_BUILD_TIMESTAMP __TIMESTAMP__
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace mtnet::classic {
|
namespace mtnet::classic {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -419,7 +426,8 @@ bool ClassicSession::create_character(int slot, const std::string &name, int job
|
|||||||
CGPlayerCreate p{};
|
CGPlayerCreate p{};
|
||||||
p.header = HDR_CG_CHARACTER_CREATE;
|
p.header = HDR_CG_CHARACTER_CREATE;
|
||||||
p.index = static_cast<uint8_t>(slot);
|
p.index = static_cast<uint8_t>(slot);
|
||||||
std::memcpy(p.name, name.data(), name.size());
|
const std::string wire_name = mtnet::to_wire(name, sizeof(p.name) - 1); // §1.10
|
||||||
|
std::memcpy(p.name, wire_name.data(), wire_name.size());
|
||||||
p.job = static_cast<uint16_t>(job);
|
p.job = static_cast<uint16_t>(job);
|
||||||
p.shape = static_cast<uint8_t>(shape);
|
p.shape = static_cast<uint8_t>(shape);
|
||||||
p.con = static_cast<uint8_t>(con);
|
p.con = static_cast<uint8_t>(con);
|
||||||
@@ -449,7 +457,8 @@ bool ClassicSession::change_name(int slot, const std::string &name) {
|
|||||||
CGChangeName p{};
|
CGChangeName p{};
|
||||||
p.header = HDR_CG_CHANGE_NAME;
|
p.header = HDR_CG_CHANGE_NAME;
|
||||||
p.index = static_cast<uint8_t>(slot);
|
p.index = static_cast<uint8_t>(slot);
|
||||||
std::memcpy(p.name, name.data(), name.size());
|
const std::string wire_name = mtnet::to_wire(name, sizeof(p.name) - 1); // §1.10
|
||||||
|
std::memcpy(p.name, wire_name.data(), wire_name.size());
|
||||||
return m_stream.send_fixed(&p, sizeof(p));
|
return m_stream.send_fixed(&p, sizeof(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,13 +474,31 @@ bool ClassicSession::send_client_version() {
|
|||||||
if (m_version_sent) {
|
if (m_version_sent) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
CGClientVersion p{};
|
|
||||||
// The stock 40250 game client sends the version report immediately after
|
// The stock 40250 game client sends the version report immediately after
|
||||||
// receiving the main-character packet and advances the normal CG sequence.
|
// receiving the main-character packet and advances the normal CG sequence.
|
||||||
p.header = HDR_CG_CLIENT_VERSION;
|
// §1.5: EUROPE-family locales send CG_CLIENT_VERSION2 (0xf1) with the fixed
|
||||||
std::strncpy(p.filename, "metin2.bin", sizeof(p.filename) - 1);
|
// ymir epoch string; every other locale sends CG_CLIENT_VERSION (0xfd) with
|
||||||
std::strncpy(p.timestamp, "1215955205", sizeof(p.timestamp) - 1);
|
// the build timestamp. All fixed-length text fills go through
|
||||||
if (!m_stream.send_fixed(&p, sizeof(p))) {
|
// mtnet::to_wire() (§1.10) — a hard byte cap on a code-point boundary.
|
||||||
|
bool ok = false;
|
||||||
|
if (m_locale_is_europe) {
|
||||||
|
CGClientVersion2 p{};
|
||||||
|
p.header = HDR_CG_CLIENT_VERSION2;
|
||||||
|
const std::string fn = mtnet::to_wire(m_executable_name, sizeof(p.filename) - 1);
|
||||||
|
std::memcpy(p.filename, fn.data(), fn.size());
|
||||||
|
const std::string ts = mtnet::to_wire(std::string_view("1215955205"), sizeof(p.timestamp) - 1);
|
||||||
|
std::memcpy(p.timestamp, ts.data(), ts.size());
|
||||||
|
ok = m_stream.send_fixed(&p, sizeof(p));
|
||||||
|
} else {
|
||||||
|
CGClientVersion p{};
|
||||||
|
p.header = HDR_CG_CLIENT_VERSION;
|
||||||
|
const std::string fn = mtnet::to_wire(m_executable_name, sizeof(p.filename) - 1);
|
||||||
|
std::memcpy(p.filename, fn.data(), fn.size());
|
||||||
|
const std::string ts = mtnet::to_wire(std::string_view(MT_BUILD_TIMESTAMP), sizeof(p.timestamp) - 1);
|
||||||
|
std::memcpy(p.timestamp, ts.data(), ts.size());
|
||||||
|
ok = m_stream.send_fixed(&p, sizeof(p));
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
m_version_sent = true;
|
m_version_sent = true;
|
||||||
@@ -808,7 +835,8 @@ bool ClassicSession::send_whisper(const std::string &to, const std::string &text
|
|||||||
CGWhisper p{};
|
CGWhisper p{};
|
||||||
p.header = HDR_CG_WHISPER;
|
p.header = HDR_CG_WHISPER;
|
||||||
p.size = static_cast<uint16_t>(total);
|
p.size = static_cast<uint16_t>(total);
|
||||||
std::memcpy(p.name_to, to.data(), to.size());
|
const std::string wire_to = mtnet::to_wire(to, sizeof(p.name_to) - 1); // §1.10
|
||||||
|
std::memcpy(p.name_to, wire_to.data(), wire_to.size());
|
||||||
std::memcpy(buf.data(), &p, sizeof(p));
|
std::memcpy(buf.data(), &p, sizeof(p));
|
||||||
std::memcpy(buf.data() + sizeof(p), text.data(), text.size());
|
std::memcpy(buf.data() + sizeof(p), text.data(), text.size());
|
||||||
return m_stream.send_dynamic(buf.data(), buf.size());
|
return m_stream.send_dynamic(buf.data(), buf.size());
|
||||||
@@ -974,7 +1002,8 @@ bool ClassicSession::send_private_shop(const std::string &sign,
|
|||||||
std::vector<uint8_t> buf(total, 0);
|
std::vector<uint8_t> buf(total, 0);
|
||||||
CGMyShopHead head{};
|
CGMyShopHead head{};
|
||||||
head.header = HDR_CG_MYSHOP;
|
head.header = HDR_CG_MYSHOP;
|
||||||
std::memcpy(head.sign, sign.data(), sign.size());
|
const std::string wire_sign = mtnet::to_wire(sign, sizeof(head.sign) - 1); // §1.10
|
||||||
|
std::memcpy(head.sign, wire_sign.data(), wire_sign.size());
|
||||||
head.count = static_cast<uint8_t>(n);
|
head.count = static_cast<uint8_t>(n);
|
||||||
std::memcpy(buf.data(), &head, sizeof(head));
|
std::memcpy(buf.data(), &head, sizeof(head));
|
||||||
if (n > 0) {
|
if (n > 0) {
|
||||||
@@ -1059,7 +1088,8 @@ bool ClassicSession::send_guild_grade_name(uint8_t grade, const std::string &nam
|
|||||||
CGGuild head{HDR_CG_GUILD, GUILD_CG_CHANGE_GRADE_NAME};
|
CGGuild head{HDR_CG_GUILD, GUILD_CG_CHANGE_GRADE_NAME};
|
||||||
std::memcpy(buf, &head, sizeof(head));
|
std::memcpy(buf, &head, sizeof(head));
|
||||||
buf[sizeof(head)] = grade;
|
buf[sizeof(head)] = grade;
|
||||||
std::memcpy(buf + sizeof(head) + 1, name.data(), name.size());
|
const std::string wire_name = mtnet::to_wire(name, GUILD_GRADE_NAME_MAX_LEN); // §1.10
|
||||||
|
std::memcpy(buf + sizeof(head) + 1, wire_name.data(), wire_name.size());
|
||||||
return m_stream.send_fixed(buf, sizeof(buf));
|
return m_stream.send_fixed(buf, sizeof(buf));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1096,12 +1126,13 @@ bool ClassicSession::send_guild_member_general(uint32_t pid, bool enabled) {
|
|||||||
bool ClassicSession::send_guild_comment(const std::string &text) {
|
bool ClassicSession::send_guild_comment(const std::string &text) {
|
||||||
if (m_stage != Stage::InGame || text.empty() ||
|
if (m_stage != Stage::InGame || text.empty() ||
|
||||||
text.size() + 1 > GUILD_COMMENT_MAX_LEN) return false;
|
text.size() + 1 > GUILD_COMMENT_MAX_LEN) return false;
|
||||||
std::vector<uint8_t> buf(sizeof(CGGuild) + 1 + text.size() + 1, 0);
|
const std::string wire_text = mtnet::to_wire(text, GUILD_COMMENT_MAX_LEN - 1); // §1.10
|
||||||
|
std::vector<uint8_t> buf(sizeof(CGGuild) + 1 + wire_text.size() + 1, 0);
|
||||||
CGGuild head{HDR_CG_GUILD, GUILD_CG_POST_COMMENT};
|
CGGuild head{HDR_CG_GUILD, GUILD_CG_POST_COMMENT};
|
||||||
std::memcpy(buf.data(), &head, sizeof(head));
|
std::memcpy(buf.data(), &head, sizeof(head));
|
||||||
buf[sizeof(head)] = static_cast<uint8_t>(text.size() + 1);
|
buf[sizeof(head)] = static_cast<uint8_t>(wire_text.size() + 1);
|
||||||
if (!text.empty()) {
|
if (!wire_text.empty()) {
|
||||||
std::memcpy(buf.data() + sizeof(head) + 1, text.data(), text.size());
|
std::memcpy(buf.data() + sizeof(head) + 1, wire_text.data(), wire_text.size());
|
||||||
}
|
}
|
||||||
return m_stream.send_fixed(buf.data(), buf.size());
|
return m_stream.send_fixed(buf.data(), buf.size());
|
||||||
}
|
}
|
||||||
@@ -1120,7 +1151,8 @@ bool ClassicSession::send_guild_answer_make(const std::string &name) {
|
|||||||
if (m_stage != Stage::InGame || name.empty() || name.size() > GUILD_NAME_MAX_LEN) return false;
|
if (m_stage != Stage::InGame || name.empty() || name.size() > GUILD_NAME_MAX_LEN) return false;
|
||||||
CGAnswerMakeGuild p{};
|
CGAnswerMakeGuild p{};
|
||||||
p.header = HDR_CG_ANSWER_MAKE_GUILD;
|
p.header = HDR_CG_ANSWER_MAKE_GUILD;
|
||||||
std::memcpy(p.guild_name, name.data(), name.size());
|
const std::string wire_name = mtnet::to_wire(name, sizeof(p.guild_name) - 1); // §1.10
|
||||||
|
std::memcpy(p.guild_name, wire_name.data(), wire_name.size());
|
||||||
return m_stream.send_fixed(&p, sizeof(p));
|
return m_stream.send_fixed(&p, sizeof(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1164,7 +1196,8 @@ bool ClassicSession::send_friend_add(const std::string &name) {
|
|||||||
std::vector<uint8_t> buf(sizeof(CGMessenger) + CHARACTER_NAME_MAX_LEN, 0);
|
std::vector<uint8_t> buf(sizeof(CGMessenger) + CHARACTER_NAME_MAX_LEN, 0);
|
||||||
CGMessenger p{HDR_CG_MESSENGER, MESSENGER_CG_ADD_BY_NAME};
|
CGMessenger p{HDR_CG_MESSENGER, MESSENGER_CG_ADD_BY_NAME};
|
||||||
std::memcpy(buf.data(), &p, sizeof(p));
|
std::memcpy(buf.data(), &p, sizeof(p));
|
||||||
std::memcpy(buf.data() + sizeof(p), name.data(), name.size());
|
const std::string wire_name = mtnet::to_wire(name, CHARACTER_NAME_MAX_LEN); // §1.10
|
||||||
|
std::memcpy(buf.data() + sizeof(p), wire_name.data(), wire_name.size());
|
||||||
return m_stream.send_fixed(buf.data(), buf.size());
|
return m_stream.send_fixed(buf.data(), buf.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1175,7 +1208,8 @@ bool ClassicSession::send_friend_remove(const std::string &name) {
|
|||||||
std::vector<uint8_t> buf(sizeof(CGMessenger) + CHARACTER_NAME_MAX_LEN, 0);
|
std::vector<uint8_t> buf(sizeof(CGMessenger) + CHARACTER_NAME_MAX_LEN, 0);
|
||||||
CGMessenger p{HDR_CG_MESSENGER, MESSENGER_CG_REMOVE};
|
CGMessenger p{HDR_CG_MESSENGER, MESSENGER_CG_REMOVE};
|
||||||
std::memcpy(buf.data(), &p, sizeof(p));
|
std::memcpy(buf.data(), &p, sizeof(p));
|
||||||
std::memcpy(buf.data() + sizeof(p), name.data(), name.size());
|
const std::string wire_name = mtnet::to_wire(name, CHARACTER_NAME_MAX_LEN); // §1.10
|
||||||
|
std::memcpy(buf.data() + sizeof(p), wire_name.data(), wire_name.size());
|
||||||
return m_stream.send_fixed(buf.data(), buf.size());
|
return m_stream.send_fixed(buf.data(), buf.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
namespace mtnet::classic {
|
namespace mtnet::classic {
|
||||||
|
|
||||||
@@ -159,6 +160,18 @@ public:
|
|||||||
// caller drives enter_game()). The 40250 test account sends a large map-load
|
// caller drives enter_game()). The 40250 test account sends a large map-load
|
||||||
// burst; waiting for that burst to drain avoids racing the server's spawn data.
|
// burst; waiting for that burst to drain avoids racing the server's spawn data.
|
||||||
void set_auto_entergame_delay(uint32_t ms) { m_entergame_delay = ms; }
|
void set_auto_entergame_delay(uint32_t ms) { m_entergame_delay = ms; }
|
||||||
|
// §1.5: the client-version report (CG_CLIENT_VERSION / _VERSION2) carries the
|
||||||
|
// running executable's file name — the stock client passes the launched
|
||||||
|
// .exe/.bin name (CPythonApplication). The net layer has no way to know it,
|
||||||
|
// so it stays "metin2.bin" until a host sets it.
|
||||||
|
// TODO(W0): M2Client should call
|
||||||
|
// classic_sess->set_executable_name(OS::get_executable_path().get_file())
|
||||||
|
void set_executable_name(std::string name) { m_executable_name = std::move(name); }
|
||||||
|
// §1.5: EUROPE-family locales send CG_CLIENT_VERSION2 (0xf1) + the fixed ymir
|
||||||
|
// epoch string "1215955205"; every other locale sends CG_CLIENT_VERSION
|
||||||
|
// (0xfd) + the build timestamp. This project's LOCALE_SERVICE_* resolves to
|
||||||
|
// EUROPE (see docs/CLIENT-GAP-FIX.md §1.5), hence the default.
|
||||||
|
void set_locale_is_europe(bool on) { m_locale_is_europe = on; }
|
||||||
void set_wire_trace(bool on) {
|
void set_wire_trace(bool on) {
|
||||||
m_stream.set_wire_trace(on);
|
m_stream.set_wire_trace(on);
|
||||||
m_auth_stream.set_wire_trace(on);
|
m_auth_stream.set_wire_trace(on);
|
||||||
@@ -200,6 +213,8 @@ private:
|
|||||||
bool m_authenticated_game = false;
|
bool m_authenticated_game = false;
|
||||||
bool m_auth_transition = false;
|
bool m_auth_transition = false;
|
||||||
bool m_version_sent = false;
|
bool m_version_sent = false;
|
||||||
|
bool m_locale_is_europe = true;
|
||||||
|
std::string m_executable_name = "metin2.bin";
|
||||||
bool m_game_connection_seen = false;
|
bool m_game_connection_seen = false;
|
||||||
bool m_direct_enter = false;
|
bool m_direct_enter = false;
|
||||||
int m_direct_enter_slot = -1;
|
int m_direct_enter_slot = -1;
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#pragma once
|
||||||
|
// npc_names — race -> display name for non-PC/NPC spawns (§2.2 / §0.3).
|
||||||
|
//
|
||||||
|
// Reference: a monster / stone / door / object spawn is appended immediately
|
||||||
|
// with its name taken from CPythonNonPlayer::GetName(race)
|
||||||
|
// (REF/UserInterface/PythonNetworkStreamPhaseGameActor.cpp). The localized
|
||||||
|
// mob_proto ships translated szName strings that no longer resolve to a model
|
||||||
|
// folder, so the client keeps root/npclist.txt as the race -> code table
|
||||||
|
// (see project/ui/mob_view.gd, which loads the same file). We reuse that code
|
||||||
|
// as the entity name; PC/NPC names come from GC_CHAR_ADDITIONAL_INFO instead
|
||||||
|
// and never hit this path.
|
||||||
|
//
|
||||||
|
// Line format (tab- or space-separated): "<vnum>\t<code>\t[<folder>]".
|
||||||
|
// Missing / unreadable file -> empty table -> empty name (allowed by §2.2).
|
||||||
|
//
|
||||||
|
// mtnet has no godot-cpp, so this reads with std::ifstream. The asset root is
|
||||||
|
// not plumbed into ClassicParser today: set_npclist_path() lets a future
|
||||||
|
// M2Client pass "<asset_root>/root/npclist.txt"; absent that we fall back to
|
||||||
|
// $MT_ASSETS / $M2_ASSETS. See the cross-file follow-up in the W1 report.
|
||||||
|
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace mtnet::classic {
|
||||||
|
|
||||||
|
inline std::unordered_map<uint32_t, std::string> load_npclist(const std::string &path) {
|
||||||
|
std::unordered_map<uint32_t, std::string> table;
|
||||||
|
if (path.empty()) {
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
std::ifstream in(path);
|
||||||
|
if (!in) {
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(in, line)) {
|
||||||
|
size_t i = 0;
|
||||||
|
const size_t n = line.size();
|
||||||
|
auto skip_ws = [&] {
|
||||||
|
while (i < n && (line[i] == ' ' || line[i] == '\t' || line[i] == '\r')) {
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
skip_ws();
|
||||||
|
size_t start = i;
|
||||||
|
while (i < n && std::isdigit(static_cast<unsigned char>(line[i]))) {
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
if (i == start) {
|
||||||
|
continue; // no leading vnum
|
||||||
|
}
|
||||||
|
const uint32_t vnum = static_cast<uint32_t>(std::strtoul(line.c_str() + start, nullptr, 10));
|
||||||
|
skip_ws();
|
||||||
|
start = i;
|
||||||
|
while (i < n && line[i] != ' ' && line[i] != '\t' && line[i] != '\r') {
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
const std::string code = line.substr(start, i - start);
|
||||||
|
if (vnum != 0 && !code.empty()) {
|
||||||
|
table.emplace(vnum, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the default npclist.txt location from the environment when no
|
||||||
|
// explicit path was supplied.
|
||||||
|
inline std::string default_npclist_path() {
|
||||||
|
for (const char *var : {"MT_ASSETS", "M2_ASSETS"}) {
|
||||||
|
if (const char *root = std::getenv(var); root && *root) {
|
||||||
|
return std::string(root) + "/root/npclist.txt";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mtnet::classic
|
||||||
@@ -23,6 +23,11 @@
|
|||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
||||||
|
// §1.10: every fixed-length `char name[...]` / sign / comment field below is
|
||||||
|
// filled at the call site via mtnet::to_wire(src, cap) (byte cap, no split
|
||||||
|
// multibyte char) and read back via mtnet::from_wire_str().
|
||||||
|
#include "../text_codec.h"
|
||||||
|
|
||||||
namespace mtnet::classic {
|
namespace mtnet::classic {
|
||||||
|
|
||||||
// ------------------------------------------------------------------ constants
|
// ------------------------------------------------------------------ constants
|
||||||
@@ -52,6 +57,28 @@ inline constexpr int GUILD_MARK_WIDTH = 16;
|
|||||||
inline constexpr int GUILD_MARK_HEIGHT = 12;
|
inline constexpr int GUILD_MARK_HEIGHT = 12;
|
||||||
inline constexpr int MARK_BLOCK_TOTAL_COUNT = 80;
|
inline constexpr int MARK_BLOCK_TOTAL_COUNT = 80;
|
||||||
|
|
||||||
|
// CHRTYPE_* — the domain of Entity::ch_type and packet_add_char.bType
|
||||||
|
// (client GameType.h / server char.h). Values line up with the Entity comment.
|
||||||
|
enum : uint8_t {
|
||||||
|
CHRTYPE_PC = 0,
|
||||||
|
CHRTYPE_NPC = 1,
|
||||||
|
CHRTYPE_MONSTER = 2,
|
||||||
|
CHRTYPE_STONE = 3,
|
||||||
|
CHRTYPE_WARP = 4,
|
||||||
|
CHRTYPE_DOOR = 5,
|
||||||
|
CHRTYPE_BUILDING = 6,
|
||||||
|
CHRTYPE_PET = 7,
|
||||||
|
CHRTYPE_HORSE = 8,
|
||||||
|
CHRTYPE_GOTO = 9,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Races the reference client never spawns — REF/UserInterface/
|
||||||
|
// PythonNetworkStreamPhaseGameActor.cpp:66 IsInvisibleRace(). GC_CHARACTER_ADD
|
||||||
|
// for one of these is dropped before any pending/stash step.
|
||||||
|
inline constexpr bool is_invisible_race(uint16_t race) {
|
||||||
|
return race == 20025 || race == 20038 || race == 20039;
|
||||||
|
}
|
||||||
|
|
||||||
// EPacketShopSubHeaders (client Packet.h L1864)
|
// EPacketShopSubHeaders (client Packet.h L1864)
|
||||||
enum : uint8_t {
|
enum : uint8_t {
|
||||||
SHOP_SUB_START = 0,
|
SHOP_SUB_START = 0,
|
||||||
@@ -466,6 +493,17 @@ struct CGClientVersion {
|
|||||||
};
|
};
|
||||||
static_assert(sizeof(CGClientVersion) == 67);
|
static_assert(sizeof(CGClientVersion) == 67);
|
||||||
|
|
||||||
|
// CG_CLIENT_VERSION2 (header 0xf1) — TPacketCGClientVersion2. Same layout as
|
||||||
|
// CGClientVersion; the EUROPE / non-YMIR locale sends THIS one with a fixed
|
||||||
|
// timestamp "1215955205" (REF/.../PythonNetworkStreamPhaseGame.cpp:4155,
|
||||||
|
// Locale.cpp:186). The 0xfd variant carries the C __TIMESTAMP__ instead.
|
||||||
|
struct CGClientVersion2 {
|
||||||
|
uint8_t header;
|
||||||
|
char filename[33];
|
||||||
|
char timestamp[33];
|
||||||
|
};
|
||||||
|
static_assert(sizeof(CGClientVersion2) == 67);
|
||||||
|
|
||||||
struct GCPlayerCreateSuccess { uint8_t header; uint8_t account_slot; SimplePlayer player; };
|
struct GCPlayerCreateSuccess { uint8_t header; uint8_t account_slot; SimplePlayer player; };
|
||||||
static_assert(sizeof(GCPlayerCreateSuccess) == 2 + 63);
|
static_assert(sizeof(GCPlayerCreateSuccess) == 2 + 63);
|
||||||
struct GCPlayerCreateFailure { uint8_t header; uint8_t type; };
|
struct GCPlayerCreateFailure { uint8_t header; uint8_t type; };
|
||||||
@@ -1221,8 +1259,8 @@ constexpr int packet_size_cg(uint8_t h) {
|
|||||||
case HDR_CG_CHARACTER_DELETE: return sizeof(CGPlayerDelete);
|
case HDR_CG_CHARACTER_DELETE: return sizeof(CGPlayerDelete);
|
||||||
case HDR_CG_CHANGE_NAME: return sizeof(CGChangeName);
|
case HDR_CG_CHANGE_NAME: return sizeof(CGChangeName);
|
||||||
case HDR_CG_EMPIRE: return sizeof(CGEmpire);
|
case HDR_CG_EMPIRE: return sizeof(CGEmpire);
|
||||||
case HDR_CG_CLIENT_VERSION:
|
case HDR_CG_CLIENT_VERSION: return sizeof(CGClientVersion);
|
||||||
case HDR_CG_CLIENT_VERSION2: return sizeof(CGClientVersion);
|
case HDR_CG_CLIENT_VERSION2: return sizeof(CGClientVersion2);
|
||||||
case HDR_CG_ENTERGAME: return sizeof(CGEnterGame);
|
case HDR_CG_ENTERGAME: return sizeof(CGEnterGame);
|
||||||
case HDR_CG_MOVE: return sizeof(CGMove);
|
case HDR_CG_MOVE: return sizeof(CGMove);
|
||||||
case HDR_CG_ATTACK: return sizeof(CGAttack);
|
case HDR_CG_ATTACK: return sizeof(CGAttack);
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#pragma once
|
||||||
|
// text_codec — one place to fill / read the 40250 wire's fixed-length text
|
||||||
|
// fields (CLIENT-GAP-FIX §1.10).
|
||||||
|
//
|
||||||
|
// Reference: the stock client stores names / signs / comments as **single-byte
|
||||||
|
// locale-codepage** strings (EterLocale + LocaleService_GetCodePage(), see
|
||||||
|
// REF/UserInterface/Locale_inc_*.h) and fills the C buffers with
|
||||||
|
// strncpy(dst, src, sizeof(dst) - 1);
|
||||||
|
// i.e. a hard **byte** cap, never a character count.
|
||||||
|
//
|
||||||
|
// This project carries text as UTF-8 end to end (GDScript String ->
|
||||||
|
// std::string). Until the per-locale codepage tables are ported, to_wire() /
|
||||||
|
// from_wire() do UTF-8-preserving byte truncation: cut on a code-point
|
||||||
|
// boundary so a multi-byte sequence is never split, and stop at `cap` bytes.
|
||||||
|
//
|
||||||
|
// FOLLOW-UP (W1/W3, tracked in §1.10): wire the real locale codepage
|
||||||
|
// (project/Locale.gd -> LocaleService_GetCodePage -> CP949 / CP1252 / …)
|
||||||
|
// and transcode here. Today the bytes on the wire are UTF-8; a server that
|
||||||
|
// expects a single-byte codepage sees mojibake for non-ASCII names. ASCII
|
||||||
|
// (the common case for account-created names) round-trips unchanged.
|
||||||
|
//
|
||||||
|
// mtnet (ClassicParser / ClassicSession) links **without** godot-cpp, so the
|
||||||
|
// std::string_view core below is the one those call sites use. The
|
||||||
|
// godot::String overloads are compiled only where <godot_cpp/...> is on the
|
||||||
|
// include path (the M2Client / GDScript boundary).
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#if defined(__has_include)
|
||||||
|
# if __has_include(<godot_cpp/variant/string.hpp>)
|
||||||
|
# include <godot_cpp/variant/char_string.hpp>
|
||||||
|
# include <godot_cpp/variant/string.hpp>
|
||||||
|
# define MT_TEXT_CODEC_HAS_GODOT 1
|
||||||
|
# endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace mtnet {
|
||||||
|
|
||||||
|
// Byte length of the longest prefix of [s, s+n) that (a) ends on a UTF-8
|
||||||
|
// code-point boundary and (b) is <= cap bytes. Invalid lead bytes advance one
|
||||||
|
// byte at a time (ASCII-safe) so malformed input still terminates.
|
||||||
|
inline size_t utf8_byte_truncate(const char *s, size_t n, size_t cap) {
|
||||||
|
size_t i = 0;
|
||||||
|
size_t last_boundary = 0;
|
||||||
|
while (i < n) {
|
||||||
|
const unsigned char c = static_cast<unsigned char>(s[i]);
|
||||||
|
size_t seq = 1;
|
||||||
|
if (c >= 0xF0 && c <= 0xF4) {
|
||||||
|
seq = 4;
|
||||||
|
} else if (c >= 0xE0 && c <= 0xEF) {
|
||||||
|
seq = 3;
|
||||||
|
} else if (c >= 0xC2 && c <= 0xDF) {
|
||||||
|
seq = 2;
|
||||||
|
}
|
||||||
|
if (i + seq > n) {
|
||||||
|
break; // truncated / invalid trailing sequence
|
||||||
|
}
|
||||||
|
if (i + seq > cap) {
|
||||||
|
break; // would overflow the byte cap
|
||||||
|
}
|
||||||
|
i += seq;
|
||||||
|
last_boundary = i;
|
||||||
|
}
|
||||||
|
return last_boundary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill helper for a fixed-length `char[cap+1]` wire field: returns the bytes to
|
||||||
|
// memcpy (<= cap, never a split multi-byte char). Caller value-initialises the
|
||||||
|
// struct so the tail stays NUL.
|
||||||
|
inline std::string to_wire(std::string_view utf8, size_t cap) {
|
||||||
|
return std::string(utf8.data(), utf8_byte_truncate(utf8.data(), utf8.size(), cap));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read helper: bytes up to the first NUL or `cap`, then guarded against a
|
||||||
|
// dangling partial sequence.
|
||||||
|
inline std::string from_wire_str(const char *bytes, size_t cap) {
|
||||||
|
size_t n = 0;
|
||||||
|
while (n < cap && bytes[n] != '\0') {
|
||||||
|
++n;
|
||||||
|
}
|
||||||
|
return std::string(bytes, utf8_byte_truncate(bytes, n, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef MT_TEXT_CODEC_HAS_GODOT
|
||||||
|
// §1.10 named API — the GDScript / M2Client boundary.
|
||||||
|
inline std::string to_wire(const godot::String &s, size_t cap) {
|
||||||
|
const godot::CharString u = s.utf8();
|
||||||
|
const char *data = u.get_data();
|
||||||
|
return to_wire(std::string_view(data ? data : "", data ? static_cast<size_t>(u.length()) : 0),
|
||||||
|
cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline godot::String from_wire(const char *bytes, size_t cap) {
|
||||||
|
const std::string s = from_wire_str(bytes, cap);
|
||||||
|
return godot::String::utf8(s.c_str(), static_cast<int64_t>(s.size()));
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
} // namespace mtnet
|
||||||
@@ -213,25 +213,74 @@ int main() {
|
|||||||
const auto *me = s.world().get(7777);
|
const auto *me = s.world().get(7777);
|
||||||
CHECK(me && me->is_main && me->x == 100000.f && me->name == "Warrior", "main entity");
|
CHECK(me && me->is_main && me->x == 100000.f && me->name == "Warrior", "main entity");
|
||||||
auto version = drain(s);
|
auto version = drain(s);
|
||||||
CHECK(version.size() == sizeof(CGClientVersion) + 1, "CG_CLIENT_VERSION + seq");
|
// §1.5: EUROPE-family locale sends CG_CLIENT_VERSION2 (0xf1) with the
|
||||||
CGClientVersion cv{};
|
// fixed ymir epoch string; same 67-byte layout as CG_CLIENT_VERSION.
|
||||||
|
CHECK(version.size() == sizeof(CGClientVersion2) + 1, "CG_CLIENT_VERSION2 + seq");
|
||||||
|
CGClientVersion2 cv{};
|
||||||
std::memcpy(&cv, version.data(), sizeof(cv));
|
std::memcpy(&cv, version.data(), sizeof(cv));
|
||||||
CHECK(cv.header == HDR_CG_CLIENT_VERSION && std::strcmp(cv.filename, "metin2.bin") == 0 &&
|
CHECK(cv.header == HDR_CG_CLIENT_VERSION2 && std::strcmp(cv.filename, "metin2.bin") == 0 &&
|
||||||
std::strcmp(cv.timestamp, "1215955205") == 0, "client version fields");
|
std::strcmp(cv.timestamp, "1215955205") == 0, "client version fields");
|
||||||
CHECK(version[sizeof(CGClientVersion)] == SEQUENCE_TABLE[2], "version seq == table[2]");
|
CHECK(version[sizeof(CGClientVersion2)] == SEQUENCE_TABLE[2], "version seq == table[2]");
|
||||||
|
|
||||||
|
// --- §2.2 two-packet PC/NPC merge: GC_CHARACTER_ADD only stashes; the
|
||||||
|
// actor is not spawned until GC_CHAR_ADDITIONAL_INFO merges it ---
|
||||||
GCCharacterAdd add{};
|
GCCharacterAdd add{};
|
||||||
add.header = HDR_GC_CHARACTER_ADD;
|
add.header = HDR_GC_CHARACTER_ADD;
|
||||||
add.vid = 8888;
|
add.vid = 8888;
|
||||||
add.race = 101;
|
add.race = 101;
|
||||||
add.type = 1; // NPC
|
add.type = CHRTYPE_NPC;
|
||||||
add.x = 100500;
|
add.x = 100500;
|
||||||
add.y = 200500;
|
add.y = 200500;
|
||||||
add.moving_speed = 150;
|
add.moving_speed = 150;
|
||||||
feed(s, raw(add));
|
feed(s, raw(add));
|
||||||
|
CHECK(s.world().get(8888) == nullptr, "PC/NPC add does not spawn yet");
|
||||||
|
CHECK(s.world().size() == 1, "still just the main character");
|
||||||
|
CHECK(s.parser().pending_actor_count() == 1, "actor stashed pending its info");
|
||||||
|
|
||||||
|
GCCharAddInfo info{};
|
||||||
|
info.header = HDR_GC_CHAR_ADDITIONAL_INFO;
|
||||||
|
info.vid = 8888;
|
||||||
|
std::strcpy(info.name, "Shopkeeper");
|
||||||
|
info.parts[0] = 7;
|
||||||
|
info.empire = 2;
|
||||||
|
info.level = 9;
|
||||||
|
feed(s, raw(info));
|
||||||
const auto *npc = s.world().get(8888);
|
const auto *npc = s.world().get(8888);
|
||||||
CHECK(npc && npc->ch_type == 1 && npc->race == 101, "npc spawned");
|
CHECK(npc && npc->ch_type == CHRTYPE_NPC && npc->race == 101, "merged actor spawned");
|
||||||
CHECK(s.world().size() == 2, "2 entities");
|
CHECK(npc && npc->name == "Shopkeeper" && npc->level == 9 && npc->parts[0] == 7 &&
|
||||||
|
npc->empire == 2, "additional-info fields merged in");
|
||||||
|
CHECK(npc && npc->x == 100500.f && npc->y == 200500.f && npc->moving_speed == 150,
|
||||||
|
"stashed spawn fields survive the merge");
|
||||||
|
CHECK(s.world().size() == 2 && s.parser().pending_actor_count() == 0,
|
||||||
|
"pending record flushed to the world");
|
||||||
|
|
||||||
|
// a monster spawns immediately, with no additional-info handshake
|
||||||
|
GCCharacterAdd mob{};
|
||||||
|
mob.header = HDR_GC_CHARACTER_ADD;
|
||||||
|
mob.vid = 8890;
|
||||||
|
mob.race = 101;
|
||||||
|
mob.type = CHRTYPE_MONSTER;
|
||||||
|
mob.x = 101000;
|
||||||
|
mob.y = 201000;
|
||||||
|
feed(s, raw(mob));
|
||||||
|
CHECK(s.world().get(8890) && s.world().get(8890)->ch_type == CHRTYPE_MONSTER,
|
||||||
|
"monster spawned on GC_CHARACTER_ADD alone");
|
||||||
|
CHECK(s.world().size() == 3, "3 entities incl. the monster");
|
||||||
|
|
||||||
|
// an invisible race is dropped outright (neither stashed nor spawned)
|
||||||
|
GCCharacterAdd ghost{};
|
||||||
|
ghost.header = HDR_GC_CHARACTER_ADD;
|
||||||
|
ghost.vid = 8891;
|
||||||
|
ghost.race = 20025;
|
||||||
|
ghost.type = CHRTYPE_MONSTER;
|
||||||
|
feed(s, raw(ghost));
|
||||||
|
CHECK(s.world().get(8891) == nullptr && s.parser().pending_actor_count() == 0 &&
|
||||||
|
s.world().size() == 3, "invisible race dropped");
|
||||||
|
|
||||||
|
// drop the monster so the rest of the flow sees just main + NPC 8888
|
||||||
|
GCCharacterDel mobdel{HDR_GC_CHARACTER_DEL, 8890};
|
||||||
|
feed(s, raw(mobdel));
|
||||||
|
CHECK(s.world().size() == 2, "monster removed");
|
||||||
|
|
||||||
GCShopSign sign{};
|
GCShopSign sign{};
|
||||||
sign.header = HDR_GC_SHOP_SIGN;
|
sign.header = HDR_GC_SHOP_SIGN;
|
||||||
@@ -399,7 +448,7 @@ int main() {
|
|||||||
// --- in-game intents: CG_MOVE / ATTACK / CHAT / TARGET, each + a seq byte ---
|
// --- in-game intents: CG_MOVE / ATTACK / CHAT / TARGET, each + a seq byte ---
|
||||||
{
|
{
|
||||||
// sequence index so far: [0]=CG_LOGIN, [1]=CG_CHARACTER_SELECT,
|
// sequence index so far: [0]=CG_LOGIN, [1]=CG_CHARACTER_SELECT,
|
||||||
// [2]=CG_CLIENT_VERSION, [3]=CG_ENTERGAME.
|
// [2]=CG_CLIENT_VERSION2, [3]=CG_ENTERGAME.
|
||||||
CHECK(s.stream().sequence_index() == 4, "seq index at 4 before intents");
|
CHECK(s.stream().sequence_index() == 4, "seq index at 4 before intents");
|
||||||
|
|
||||||
CHECK(s.send_move(mtnet::FUNC_MOVE, 0, 90.0f, 111111, 222222, 4242), "send_move");
|
CHECK(s.send_move(mtnet::FUNC_MOVE, 0, 90.0f, 111111, 222222, 4242), "send_move");
|
||||||
@@ -1130,19 +1179,27 @@ int main() {
|
|||||||
out.back() == SEQUENCE_TABLE[seq], "guild invite answer wire");
|
out.back() == SEQUENCE_TABLE[seq], "guild invite answer wire");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- GC_CHAR_ADDITIONAL_INFO(136): fills a spawned entity's name/parts/level ---
|
// --- GC_CHAR_ADDITIONAL_INFO(136) with no matching pending GC_CHARACTER_ADD
|
||||||
|
// is dropped and leaves the live entity untouched (§2.2) ---
|
||||||
{
|
{
|
||||||
|
const auto *before = s.world().get(8888);
|
||||||
|
CHECK(before != nullptr, "8888 present before the drop test");
|
||||||
|
const std::string name0 = before->name;
|
||||||
|
const int32_t level0 = before->level;
|
||||||
|
const uint16_t part0 = before->parts[0];
|
||||||
|
|
||||||
GCCharAddInfo ci{};
|
GCCharAddInfo ci{};
|
||||||
ci.header = HDR_GC_CHAR_ADDITIONAL_INFO;
|
ci.header = HDR_GC_CHAR_ADDITIONAL_INFO;
|
||||||
ci.vid = 8888;
|
ci.vid = 8888;
|
||||||
std::strcpy(ci.name, "Goblin");
|
std::strcpy(ci.name, "Goblin");
|
||||||
ci.parts[0] = 3;
|
ci.parts[0] = 3;
|
||||||
ci.empire = 1;
|
|
||||||
ci.guild_id = 0;
|
|
||||||
ci.level = 12;
|
ci.level = 12;
|
||||||
feed(s, raw(ci));
|
feed(s, raw(ci));
|
||||||
|
|
||||||
const auto *e = s.world().get(8888);
|
const auto *e = s.world().get(8888);
|
||||||
CHECK(e && e->name == "Goblin" && e->level == 12 && e->parts[0] == 3, "char_add_info");
|
CHECK(e && e->name == name0 && e->level == level0 && e->parts[0] == part0,
|
||||||
|
"additional-info without a pending add is a no-op");
|
||||||
|
CHECK(s.parser().pending_actor_count() == 0, "no stray pending record created");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- GC_SYNC_POSITION(5, dynamic): snap entities ---
|
// --- GC_SYNC_POSITION(5, dynamic): snap entities ---
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// net_text_codec_test — §1.10 fixed-length wire text: to_wire() / from_wire_str()
|
||||||
|
// must apply a hard BYTE cap (like the stock client's strncpy) while never
|
||||||
|
// splitting a UTF-8 multi-byte sequence.
|
||||||
|
#include "../src/net/text_codec.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(c, msg) \
|
||||||
|
do { \
|
||||||
|
if (!(c)) { \
|
||||||
|
std::fprintf(stderr, "FAIL: %s\n", msg); \
|
||||||
|
++g_fail; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
// A CJK code point is 3 bytes in UTF-8 (U+6C49 "汉" = e6 b1 89).
|
||||||
|
static std::string cjk(int n) {
|
||||||
|
std::string s;
|
||||||
|
for (int i = 0; i < n; ++i) {
|
||||||
|
s += "\xE6\xB1\x89";
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
using mtnet::to_wire;
|
||||||
|
using mtnet::from_wire_str;
|
||||||
|
|
||||||
|
// --- CHARACTER_NAME_MAX_LEN-equivalent cap = 24 bytes ---
|
||||||
|
// 8 CJK chars == exactly 24 bytes: accepted whole.
|
||||||
|
{
|
||||||
|
const std::string in = cjk(8);
|
||||||
|
CHECK(in.size() == 24, "8 CJK == 24 bytes");
|
||||||
|
const std::string w = to_wire(in, 24);
|
||||||
|
CHECK(w.size() == 24 && w == in, "exactly-24 accepted unchanged");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9 CJK chars == 27 bytes: truncated to 24 (8 chars), never 25/26.
|
||||||
|
{
|
||||||
|
const std::string in = cjk(9);
|
||||||
|
CHECK(in.size() == 27, "9 CJK == 27 bytes");
|
||||||
|
const std::string w = to_wire(in, 24);
|
||||||
|
CHECK(w.size() == 24, "27 -> 24 bytes");
|
||||||
|
CHECK(w == cjk(8), "truncated on the code-point boundary (8 chars)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 25-byte input (8 CJK + 1 stray lead byte): a 24-byte cap keeps 8 chars,
|
||||||
|
// and even a 25- or 26-byte cap cannot fit the 9th char -> still 24.
|
||||||
|
{
|
||||||
|
std::string in = cjk(8);
|
||||||
|
in += '\xE6'; // dangling lead byte of a 9th char
|
||||||
|
CHECK(in.size() == 25, "25-byte input");
|
||||||
|
CHECK(to_wire(in, 24).size() == 24, "cap 24 -> 24");
|
||||||
|
CHECK(to_wire(in, 25) == cjk(8), "cap 25 -> 24 (no split)");
|
||||||
|
CHECK(to_wire(in, 26) == cjk(8), "cap 26 -> 24 (9th char needs 3)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mid-sequence cap: cutting 2 bytes into a 3-byte char drops the whole char.
|
||||||
|
{
|
||||||
|
const std::string in = cjk(4); // 12 bytes
|
||||||
|
CHECK(to_wire(in, 11).size() == 9, "cap 11 -> 9 (3 whole chars)");
|
||||||
|
CHECK(to_wire(in, 10).size() == 9, "cap 10 -> 9");
|
||||||
|
CHECK(to_wire(in, 12) == in, "cap 12 -> all 12");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASCII round-trips against the same cap unchanged.
|
||||||
|
{
|
||||||
|
const std::string in = "Warrior";
|
||||||
|
CHECK(to_wire(in, 24) == in, "ascii under cap unchanged");
|
||||||
|
CHECK(to_wire(std::string(40, 'x'), 24).size() == 24, "ascii over cap -> 24");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- from_wire_str: stop at NUL, then guard a dangling partial sequence ---
|
||||||
|
{
|
||||||
|
char buf[25] = {};
|
||||||
|
const std::string src = cjk(3); // 9 bytes
|
||||||
|
std::memcpy(buf, src.data(), src.size());
|
||||||
|
CHECK(from_wire_str(buf, sizeof(buf) - 1) == src, "reads up to NUL");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
char buf[25];
|
||||||
|
std::memset(buf, 0, sizeof(buf));
|
||||||
|
const std::string src = cjk(8);
|
||||||
|
std::memcpy(buf, src.data(), src.size());
|
||||||
|
buf[24] = '\xE6'; // 25th byte: lead of a truncated char, no NUL room
|
||||||
|
const std::string got = from_wire_str(buf, 25);
|
||||||
|
CHECK(got == cjk(8), "dangling lead byte trimmed on read");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Full 24-byte field, no NUL terminator: read all 8 chars.
|
||||||
|
char buf[24];
|
||||||
|
const std::string src = cjk(8);
|
||||||
|
std::memcpy(buf, src.data(), src.size());
|
||||||
|
CHECK(from_wire_str(buf, 24) == src, "unterminated full field read whole");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g_fail) {
|
||||||
|
std::fprintf(stderr, "%d checks FAILED\n", g_fail);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
std::puts("net_text_codec_test: OK");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user