40250 classic: parallel-dev checkpoint through increment 49 (W0 interface freeze)

Captures the uncommitted parallel-development work (increments 46-49) on the
40250 classic client port, folded into docs/CLIENT-GAP.md + CLIENT-GAP-FIX.md.

Increment 49 (W0 interface-freeze batch, Phase 1 prerequisite) lands the
cross-workflow shared-file scaffolding so the 4 Phase 1 workflows can run in
isolated worktrees without contention:

- entity_store.{h,cpp}: Entity gains empire/affect_flags/owner_vid/state_flags;
  new mut_spawn_full() (single-shot two-packet merge, §2.3), mut_ownership()
  (§2.5), mut_map_bgm() + take_bgm_dirty()/bgm_name()/bgm_volume() (§9.1),
  drain_dirty() (§2.1/§2.5 bare-field-update queue); mut_char_info() now stores
  empire; reset_for_map_change() clears m_dirty.
- m2_client.cpp: new bgm_changed(name, volume) signal; classic + m2dev pump
  loops drain drain_dirty() -> entity_info and take_bgm_dirty() -> bgm_changed;
  entity_dict() exposes the 4 new keys.
- classic/classic_parser.{h,cpp}: GC_MAIN_CHARACTER3_BGM /
  GC_MAIN_CHARACTER4_BGM_VOL route bgm_name/bgm_volume to mut_map_bgm() (was
  discarded); new m_pending_actor staging map (W1 fills §2.2 merge logic).
- project/bgm_director.gd (new) + game_scene.gd: BGM consumption split out of
  net_world.gd so W2/W4 don't collide; §8.6/§4.8 keybind convergence (digits
  1-4 -> quickslots 0-3, F1-F4 -> quickslots 4-7, Ctrl+1..9 -> _emote()).

Tests: cmake --build build clean; ctest 16/16; 15 GDScript regressions green
(netbridge, gamescene, netplay, p9, p10, p2b, p8, system_menu_ui, skill,
combat_fx, skill_fx, player_motion, char_status_ui, chat, inventory), no skips.

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:13:01 +09:00
co-authored by Claude Sonnet 5
parent 70f200e885
commit f32064c74a
36 changed files with 4473 additions and 202 deletions
File diff suppressed because it is too large Load Diff
+540 -76
View File
@@ -1,17 +1,83 @@
# m2dev-client → mtgodot-poc 1:1 功能差距
# 40250/ClientVS22 → mtgodot-poc 1:1 功能差距
> 对照范围:`../m2dev-client-src-main/`(客户端源码)、`../m2dev-client-main/`
>(可执行客户端与配置)以及本工程当前实现。
> 对照范围:`../40250/ClientVS22/`40250 客户端源码及其运行时模块)以及本工程当前实现。
> 当前工程默认运行 `MT_PROTOCOL=classic``extension/src/net/wire.h` 仅属于显式选择的 m2dev
> 后端,当前 40250/ClientVS22 对照应优先使用 `extension/src/net/classic/wire_classic.h`。
>
> 本文按“协议、数据、交互、表现均与原客户端等价”的严格 1:1 标准审计;
> `CLIENT-ROADMAP.md` 中的“P0–P10 首版完成”只表示主要纵向流程已有首版,不等于 1:1 完成。
> 渲染细节另见 `PARITY-GAP.md`,动画、材质和资产管线另见 `BACKLOG.md`,网络实现另见
> `CLIENT-PORT.md`。
>
> 更新日期:2026-09-01(增量 45
> **本文末「ClientVS22 实现细节审计(2026-09-02)」每一条的具体修改细节见
> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md)**(逐条给出 40250/ClientVS22 的参考
> 源码位置、常量与算法,以及本工程要改的文件/函数;其附录 A 列出本节中
> 需要订正的条目,分「参考行为不符 / 表述需加限定 / 低估了当前差距」三档)。
>
> 更新日期:2026-09-02(对照 40250/ClientVS22 复核)
>
> **状态列图例**:✅ 达到本文末“1:1 完成判据” · 🟡 有首版 / 部分语义 · ⬜ 未实现 ·
> ❌ 明确不做(超出 m2dev-client 参考范围,除非另立需求)。
> ❌ 明确不做(超出 40250/ClientVS22 参考范围,除非另立需求)。
## 开发执行约定:并行工作流与进度同步
本文按“可并行开发”维护,不要求按章节号从 1 到 10 串行实现。章节号只表示差距
归属;实际开发按工作流拆分,使用稳定接口、测试替身和集成门禁降低互相等待。
所有工作流都以 `40250/ClientVS22/` 的源码和当前 Classic 协议为唯一对照基线。
### 并行拆分
| 工作流 | 负责范围 | 主文件边界 | 依赖 / 集成点 |
|---|---|---|---|
| W0 公共基础与集成 | 连接阶段、共享网络状态、实体公共接口、场景生命周期、文档 | `extension/src/net/m2_client.*``entity_store.*``classic_session.*``project/game_scene.gd`、本目录两份差距文档 | 先冻结信号、状态结构、reset / enter / warp 接口;其他工作流不得同时重写这些共享文件 |
| W1 协议与账号会话 | §1、Classic wire / parser、登录 / 选人 / DirectEnter / 断线、协议错误 | `extension/src/net/classic/*`、对应 `extension/tests/net_classic_*`、登录和选人专属脚本 | 依赖 W0 的会话接口;用 loopback / mock 验证,真实服务端留在集成门禁 |
| W2 实体、世界与战斗 | §2–§3、实体两层模型、移动、动作、技能、目标和换图后的世界状态 | `project/net_world.gd``net_play.gd``player_controller.gd``project/world/*`、动画 / 战斗相关扩展及测试 | 依赖 W0 的 EntityStore / `world_reset`;UI 只消费公开信号,不直接修改网络核心 |
| W3 玩法 UI、输入与本地化 | §4–§5、§8、背包 / 快捷栏 / 任务 / 社交 / 输入 / IME / 键位 | `project/ui/*``hud.gd``login.gd``charselect.gd`、UI 测试和本地化资源 | 依赖稳定的 M2Client 信号和数据快照;缺少服务端时使用 fixture,不阻塞 W1/W2 |
| W4 渲染、资产与音频 | §6–§7、§9、GR2 / EffectLib / SpeedTree、地图、材质、天气、BGM、生命周期表现 | `extension/src/{gr2_bridge,metin2_world,terrain_splat,m2_material,environment_builder}.*``project/fx/*`、音频和渲染专属脚本 / 测试 | 依赖 W0 的场景 reset / 地图切换钩子;不得把渲染占位逻辑写入协议层 |
| W5 打包、平台与验收 | §10、Mac App 自包含资源、架构 / 签名、发布配置、验收脚本 | `project/export_presets.cfg`、打包 / 发布脚本、`extension/CMakeLists.txt`、发布验收文档 | 可与 W1–W4 并行;集成阶段消费最终构建产物,不能用“能启动”代替资源和签名验收 |
### 并行规则与集成门禁
1. **G0:接口冻结后再分工。** W0 先定义并记录 `enter_game``warp``world_reset`、实体快照、
UI 信号和测试 fixture;接口变更必须在两份差距文档中先登记影响范围,再通知相关工作流。
2. **按文件边界并行。** 同一文件只能有一个工作流作为主维护者。需要改共享文件时,提交“接口变更”
给 W0 集成,不在多个分支中各自复制一份核心实现。
3. **允许用替身解耦。** W1 可用 loopbackW2/W3 可用固定实体和包 fixture,W4 可用最小地图 / 资源,
W5 可用 debug 构建先验证流程;替身通过后仍必须在集成门禁替换为真实链路。
4. **G1:接口集成。** 每个工作流先通过自己的单元 / 回环 / GDScript 回归,再由 W0 合并共享接口,
执行 C++ 扩展构建、原生网络测试和受影响的 Godot 测试。
5. **G2:真实验收。** 连接、DirectEnter、Warp 换图、双账号玩法、Mac App 资源包、架构 / 签名和
视觉 / 输入体验必须按对应验收项逐条记录;受限环境下的 `skip` 只能标为“未验证”,不能标为通过。
6. **合并顺序不等于开发顺序。** 只要依赖接口已经冻结,W1–W5 可以同时开发;只有触及共享状态或
集成门禁时才回到 W0 排队。
### 当前并行进度(2026-09-02
| 工作流 | 状态 | 当前已知进展 | 下一步 |
|---|---|---|---|
| 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 |
| 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 剩余逐行键位与表情包 |
| W4 渲染 / 资产 / 音频 | 🟡 | GR2 / `.msa` / `.mse`、地图 / 水体 / 天气 / BGM 部分链路已有;`bgm_director.gd` 消费点已就位 | §9.1 地图 BGM 曲目解析 + 音量、§9.2 后台恢复曲目、§9.3 生命周期单一属主 |
| W5 打包 / 发布 / 验收 | ⬜ | 已有 Godot 导出配置和构建入口 | 自包含资源包、Intel / Apple Silicon、签名和 Mac App 验收 |
### 进度同步是交付条件
开发过程中的每个批次都必须同步本文和 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md),包括“开始、完成、
阻塞、重新验证”四类状态变化。代码已经提交但文档没有同步时,该批次仍视为**未完成**。
每次同步至少记录:
- 工作流 / 条目编号、当前状态(⬜ 未开始、🟡 开发中、✅ 已完成、⚠️ 阻塞、🔎 待真实验收);
- 参考源码依据、实际修改文件和公开接口变化;
- 自动化测试命令及结果;无法运行或被 `skip` 的测试和原因;
- 真实服务端 / Mac App 验收证据,仍缺什么;
- 已知风险、与其他工作流的冲突 / 依赖、下一批次和负责人。
同步位置固定为:本文对应差距表 / 变更记录负责“全局状态”,`CLIENT-GAP-FIX.md` 对应条目和实施进度
负责“实现细节”。文档由 W0 集成维护者统一合并,工作流提交者只更新自己负责的条目,避免并行编辑造成状态
互相覆盖。
> **当前 40250 范围调整(增量 45**:真实 FreeBSD + MySQL 服务端的完整登录 / 功能验收暂缓,
> 不作为本阶段交付门槛;公会相关功能暂缓。当前目标 40250 服务端不提供龙魂玩法,龙魂不纳入本阶段
@@ -19,10 +85,11 @@
>
> ### 不做清单(❌)
>
> - **拍卖行 / auction house**m2dev-client 无此系统 —— `0x08xx` 段只有
> `SHOP`/`MYSHOP`/`SAFEBOX_*`/`MALL_*`,全源码 / 协议头 / 资产零 `auction` 命中。
> 要做即“新造协议 + 配套服务端”,不属 1:1 移植。**标注为不做**,除非后续明确提需求。
> - **反作弊(anti-cheat**`CG_HACK`(0x0B03) 客户端异常上报,以及桌面外壳的
> - **拍卖行 / auction house**当前 40250 默认构建未发现完整的 ClientVS22 拍卖行 UI / handler
> 但 `Packet.h` 和服务端仍保留 `__AUCTION__` 条件编译协议(客户端 `CG_AUCTION_CMD=205`、
> 服务端列表包 `=206`)。当前服务端默认关闭该宏,因此本阶段标注为不做;不能表述为“全源码、
> 协议和资产均不存在”。参考:[Packet.h:158-160](/Users/shenlei/Work/mt/40250/ClientVS22/source/UserInterface/Packet.h:158)。
> - **反作弊(anti-cheat**`CG_HACK`(105 / 0x69) 客户端异常上报,以及桌面外壳的
> `ProcessCRC` / `ProcessScanner` / 内存扫描等。自用客户端不需要,**标注为不做**,
> 除非服务端强制校验才回头做最小上报。
> - **龙魂精炼(当前 40250 目标服务端)**:目标服务端不提供该玩法,本阶段不实现 / 不验收;
@@ -30,6 +97,47 @@
>
> ### 变更记录
>
> 以下记录保留当时的增量快照;其中旧协议缺口数量、旧路径名称以及 `0x` 协议编号可能来自当时的
> m2dev / 旧实现,不代表当前 Classic 审计口径。当前结论以“协议覆盖审计”和 `MT_PROTOCOL` 变体
> 说明为准;历史记录中的 `CG_QUEST_CANCEL` 也不代表 Classic 存在同名独立 header。
>
> - **2026-09-02 增量 46W0/W1 公共基础第一批实现)**Classic 角色槽已保存 `lAddr/wPort`
> 新增 `M2Client.enter_game(index)` 并接入 ClientVS22 DirectEnter 的第二次游戏连接入口;
> 选人 / 登录 / 进场发送结果已透传,未知 header 不再 Loading 启发式重同步,已知未处理包不再
> 静默成功;已建立游戏连接后的断线回到 LOGIN。`net_classic_stream_test`、
> `net_classic_session_test`、`netbridge_test`、`p10_test` 已通过(受限环境下本地 TCP bind 子测试自动
> skip)。真实双连接 E2E、
> `MT_CLASSIC_ENTER_DELAY` 移除和版本包 `0xf1` 仍未完成。
> - **2026-09-02 增量 47W0/W2 公共基础第一批实现)**Classic `GC_WARP` 已改为严格按
> `lAddr:wPort` 重连,不再用 `addr==0` 推断“同服原地移动”;连接会保留 login key / 已选角色槽,
> 新增 `world_reset` 信号,并在 Godot 侧清理网络实体、地面掉落、PVP / 决斗、目标、交互窗和
> 地图局部状态后重建地图、HUD 小地图、Atlas、环境和玩家位置。`net_entity_test`、
> `net_classic_session_test`、`gamescene_test`、`p9_test`、`p2b_test`、`netbridge_test`、
> `p8_test` 已通过;受限环境下 localhost TCP 目标测试仍自动 skip。真实 40250 换图 / 第二次
> 连接 E2E、地图 BGM 选择和 `MT_CLASSIC_ENTER_DELAY` 仍待完成。
> - **2026-09-02 增量 49W0 接口冻结批次)**:为 Phase 1 的 4 个并行工作流一次性落地跨工作流
> 共享文件的骨架,冻结接口后再拆分。`EntityStore``Entity` 补 `empire / affect_flags /
> owner_vid / state_flags` 字段;新增 `mut_spawn_full()`(§2.3 两包合并的单次落地入口)、
> `mut_ownership()`(§2.5)、`mut_map_bgm()` + `take_bgm_dirty()/bgm_name()/bgm_volume()`
> (§9.1)、`drain_dirty()`(§2.1/§2.5 裸字段更新的可见性重镜像);`mut_char_info()` 不再
> 丢弃 `empire``reset_for_map_change()` 一并清 `m_dirty`。`M2Client`:新增 `bgm_changed`
> 信号,classic / m2dev 两条 pump 回路都消费 `drain_dirty()` → `entity_info` 与
> `take_bgm_dirty()` → `bgm_changed``entity_dict()` 补 `empire / affect_flags /
> owner_vid / state_flags` 键。`ClassicParser`BGM 名 / 音量从
> `GC_MAIN_CHARACTER3_BGM` / `GC_MAIN_CHARACTER4_BGM_VOL` 路由到 `mut_map_bgm()`(此前直接
> 丢弃),新增 `m_pending_actor` 暂存表成员(W1 在 §2.2 填充两包合并逻辑)。Godot 侧新增
> `project/bgm_director.gd``M2Client.bgm_changed` → `Audio.play_bgm`,把地图 BGM 消费点
> 从 `net_world.gd` 拆出,避免 W2/W4 抢改同一文件),`game_scene.gd` 装配并 `bind()`
> `game_scene.gd` 键位按 §8.6 / §4.8 收敛:数字键 1–4 → 快捷栏 0–3、F1–F4 → 快捷栏 4–7、
> Ctrl+1..9 → `_emote()` 统一入口(表情包与本地表情动作由 W3 在 §8.6 补全)。
> C++ `ctest` 16/16、`netbridge_test` / `gamescene_test` / `p9_test` / `netplay_test` /
> `system_menu_ui_test` / `skill_test` / `combat_fx_test` / `skill_fx_test` /
> `player_motion_test` 全绿。BGM→曲目解析与音量应用、§2.2 合并逻辑、§8.6 剩余键位为 Phase 1
> 各工作流范围。
> - **2026-09-02 增量 48(并行开发协议)**:本文件与 `CLIENT-GAP-FIX.md` 改为“公共基础 +
> W1–W5 并行工作流 + 集成门禁”的执行格式,不再把附录 C 的章节顺序当成唯一开发顺序;明确
> 共享文件主维护者、接口冻结、测试替身、真实验收和冲突处理规则。新增硬性要求:每个批次在开始、
> 完成、阻塞或重新验证时,都必须把状态、改动文件、测试证据、风险和下一步同步回两份文档。
> - **2026-09-01 增量 45P8 交互边界与龙魂运行时范围)**:补齐 NPC 点击路由、Cube NPC
> 前置 / 配方材料 / 金币校验与制作状态、商店购买 / 出售数量和错误反馈、交换重复 / 满槽 /
> 金币边界、仓库 / 商城密码与满包回滚、个人摊位标准 Godot 拖放路径;缺失可选 `.sub` 贴图
@@ -52,7 +160,7 @@
> 对应 `CG_ANSWER_MAKE_GUILD` 已接入名称输入、12 字上限、空名 / 控制字符校验和发送失败提示;
> 新增 `guild_creation_ui_test.gd` 覆盖请求、校验和合法名称应答。服务端敏感词 / 重名错误及实际建会
> 状态仍需真服验收。
> - **2026-09-01 增量 40(强制改名 UI 闭环)**`SimplePlayer.change_name` 已从 classic / m2dev
> - **2026-09-01 增量 40(强制改名 UI 闭环)**`SimplePlayer.change_name` 已从 classic / ClientVS22
> 角色列表透传到 `M2Client.char_list`;选人页按 40250 原版语义,在角色带该标志时点击“开始”
> 弹出改名窗,校验名称后发送 `CG_CHANGE_NAME`,成功的 `GC_CHANGE_NAME` 刷新槽位并清除标志,
> `AppFlow` 接通请求、成功和失败回传,新增 `char_create_delete_test.gd` 回归覆盖。P1 行不再把
@@ -174,7 +282,7 @@
> `assets/root/uicharacter.py.RefreshStatus`Level/Exp/RestExp、HP/SP、
> STR/DEX/HTH/INT、ATT=`(min|max)+ATT_BONUS+ATTACKER_BONUS`、
> DEF=`DEF_GRADE(+DEF_BONUS)`、MATT=`MAG_ATT+(min|max)MAGIC_WEP`、MDEF、
> ASPD/MSPD/CSPD/EREPointTypes 索引取自 m2dev `Packet.h`)。加点 / 按钮发
> ASPD/MSPD/CSPD/EREPointTypes 索引取自 40250/ClientVS22 `Packet.h`)。加点 / 按钮发
> `client.say(0,"/stat ht"|"/stat- ht" …)`,与原 `statusPlusCommandDict` /
> `statusMinusCommandDict` 完全一致;`POINT_STAT>0` 才显示加点按钮。STATUS/SKILL/
> EMOTICON/QUEST 四页做 `SetState` 页签切换(技能 / 表情 / 任务的数据仍在各自专用窗)。
@@ -186,10 +294,11 @@
> `MT_NET_TRACE=1 MT_E2E_SWEEP=1 ./build/extension/net_e2e` 完成一次
> auth→选人→PHASE_GAME 会话;出站明文 / 加密字节均已落盘。10 个安全探针全部发送成功、
> 450ms 后仍在线、`unknown_header=0``CG_CHARACTER_POSITION`(5B)、
> `CG_SYNC_POSITION`(16B)、`CG_SCRIPT_SELECT_ITEM`(8B)、`CG_QUEST_CANCEL`(4B)、
> `CG_SYNC_POSITION`(16B)、`CG_SCRIPT_SELECT_ITEM`(8B)、脚本取消(Classic 为
> `CG_SCRIPT_ANSWER=29` + sentinel `255`)、
> `CG_PARTY_USE_SKILL`(9B)、`CG_FLY_TARGETING`/`CG_ADD_FLY_TARGETING`(16B)、
> `CG_FISHING`(5B)、`CG_SHOOT`(5B)、`CG_USE_SKILL`(12B)。`CG_QUEST_CANCEL` 触发 1 条
> quest-change钓鱼收到真服 `Please choose a Fishing Pole.`(无鱼竿的预期业务拒绝),其余
> `CG_FISHING`(5B)、`CG_SHOOT`(5B)、`CG_USE_SKILL`(12B)。脚本取消触发 1 条 quest-change
> 钓鱼收到真服 `Please choose a Fishing Pole.`(无鱼竿的预期业务拒绝),其余
> 无业务事件但均无断连 / 无解析错误。被动 `GC_LAND_LIST` 436B 在登录洪流中真服收到并解析。
> 扫描暴露一个代码缺口:`GC_FLY_TARGETING`/`GC_ADD_FLY_TARGETING` 与
> `GC_CHANGE_SKILL_GROUP` 已有 `EntityStore` 实现却未列入 `GameClient` 世界分派,现已补路由并由
@@ -298,7 +407,7 @@
> 以原版 100 为基准缩放本地步行 / 奔跑预测(0.253.0 限幅),`netplay_test.gd` 覆盖 150 → 1.5×。
> - **2026-08-31 增量 29(攻击速度校准)**`GC_CHARACTER_ADD[2]` / `GC_CHARACTER_UPDATE`
> 的 `attack_speed` 已进入 `EntityStore` / `M2Client.entity_dict()`;本地主角的
> `POINT_ATT_SPEED` 也通过 `get_points()` 暴露。`NetPlay` 按 m2dev 的 `attack_speed / 100.0`
> `POINT_ATT_SPEED` 也通过 `get_points()` 暴露。`NetPlay` 按 40250/ClientVS22 的 `attack_speed / 100.0`
> 动作速率换算自动攻击间隔(0.25–3.0 倍限幅,100 → 0.6 秒),并由 `netplay_test.gd` /
> `net_entity_test` 覆盖实体字段和 150 → 0.4 秒。仍需用真实 `.msa` motion duration、武器 / 坐骑
> 规则和服务端攻击合法性逐帧校准。
@@ -312,9 +421,10 @@
> `CG_MYSHOP` / `GC_MALL_OPEN|SET|DEL` / `CG_MALL_CHECKOUT` + `private_shop_ui` /
> `mall_ui`)、**Cube 제작**`GC_CHAT/COMMAND` 的 `cube …` 总线 → `CubeState` +
> `cube_ui`)。能量值已由 `POINT_ENERGY` 快照接入 HUD;仍缺原版拖放 UI 细节。
> **拍卖行**经核查 m2dev-client 无此系统 → 标注 ❌ 不做(见上「不做清单」)。
> **拍卖行**默认构建未见完整 UI / handler,但协议受 `__AUCTION__` 条件编译控制;当前目标服务端
> 默认关闭 → 标注 ❌ 不做(见上「不做清单」)。
> **反作弊**`CG_HACK` + `ProcessCRC`/`ProcessScanner`)→ 标注 ❌ 不做。
> **「协议覆盖审计」段**改为逐条清单:脚本核对 `wire.h` vs `Packet.h`,主网络层
> **「协议覆盖审计」段**改为逐条清单:当时按 `wire.h` vs `Packet.h` 核对,主网络层
> 仍缺 **24 个 header**(位置同步上行 / 战斗广播 / 任务 / 社交 / 改名 / 世界玩法整组;
> `CG_HACK` 已移入「不做」),另有 3 个「已声明未消费 / 待核」。
> - **2026-08-31 增量 2**
@@ -342,11 +452,11 @@
4. EffectLib、SpeedTree、材质和世界表现仍有明确占位或近似;
5. 打包资产路径、桌面配置和输入体验尚未形成原客户端等价闭环。
因此当前应称为“核心功能纵切完成”,不能称为 `m2dev-client` 的 1:1 客户端。
因此当前应称为“核心功能纵切完成”,不能称为 40250/ClientVS22 的 1:1 客户端。
## 已具备的首版能力
- 登录、服务器 / 频道选择、频道负载状态、断线重连、选人、**建号 / 删号**、进入游戏;
- 登录、服务器 / 频道选择、频道负载状态、断线回登录、选人、**建号 / 删号**、进入游戏;
- Metin2 风选人页(背景 / 3D 角色 / 职业书法字 / 国家 + 国旗 / 四维条 / ◀▶ 槽位 / 建删弹窗);
- 基础移动、攻击、选目标、生命 / 法力 / 经验、伤害飘字、affect、死亡与复活窗口;
- 背包、基础装备换模、地面物品、技能窗口和快捷栏读取;
@@ -363,32 +473,32 @@
| 优先级 | 状态 | 模块 | 当前实现 | 需要迁移的原客户端语义 |
|---|---|---|---|---|
| P1 | 🟡 | 选人 / 建号 / 删号 / 改名 | `CG_CHARACTER_CREATE`(0x0201) / `CG_CHARACTER_DELETE`(0x0202) / `CG_CHANGE_NAME`(0x010B) / `GC_PLAYER_CREATE_SUCCESS`(0x020C) / `GC_PLAYER_CREATE_FAILURE`(0x020D) / `GC_PLAYER_DELETE_SUCCESS`(0x020E) / `GC_PLAYER_DELETE_WRONG_SOCIAL_ID`(0x020F) / `GC_CHANGE_NAME`(0x010C) 全部有尺寸断言;`GameClient` 发送接口、角色槽位补丁和 `drain_*_events()``M2Client` 同名建删改 API、4 个建删结果信号及 `char_name_changed``net.loopback_flow` 覆盖建删和改名名称字节 / pid 广播。**真机验证(2026-08-31`net_e2e MT_E2E_CHARCREATE=1`**:建号、`GC_PLAYER_CREATE_FAILURE``GC_EMPIRE` 已验。选人页有建号 / 删号弹窗、强制改名弹窗与国家国旗 | `CG_CHARACTER_DELETE` 破坏性,真机未验;强制改名、重名 / 非法名错误码和服务端状态未真服验收;建号缺外形 / 发型选择与属性再分配 UI(现发 `shape=0` + 职业基础四维);原版选人台座、镜头动画、`OnCreateFailure` 完整错误码文案 |
| P1 | 🟡 | 选人 / 建号 / 删号 / 改名 | Classic 当前使用 `CG_CHARACTER_CREATE=4` / `CG_CHARACTER_DELETE=5` / `CG_CHANGE_NAME=106` / `GC_PLAYER_CREATE_SUCCESS=8` / `GC_PLAYER_CREATE_FAILURE=9` / `GC_PLAYER_DELETE_SUCCESS=10` / `GC_PLAYER_DELETE_WRONG_SOCIAL_ID=11` / `GC_CHANGE_NAME=107`全部有尺寸断言;`GameClient` 发送接口、角色槽位补丁和 `drain_*_events()``M2Client` 同名建删改 API、4 个建删结果信号及 `char_name_changed``net.loopback_flow` 覆盖建删和改名名称字节 / pid 广播。**真机验证(2026-08-31`net_e2e MT_E2E_CHARCREATE=1`**:建号、`GC_PLAYER_CREATE_FAILURE``GC_EMPIRE` 已验。选人页有建号 / 删号弹窗、强制改名弹窗与国家国旗 | `CG_CHARACTER_DELETE` 破坏性,真机未验;强制改名、重名 / 非法名错误码和服务端状态未真服验收;建号缺外形 / 发型选择与属性再分配 UI(现发 `shape=0` + 职业基础四维);原版选人台座、镜头动画、`OnCreateFailure` 完整错误码文案 |
| P0 | 🟡 | 技能施放协议 | `CG_USE_SKILL``CG_FLY_TARGETING``CG_ADD_FLY_TARGETING``CG_SHOOT` 已有尺寸断言、原生发送接口和 loopback 字节验证;服务端 `GC_FLY_TARGETING` / `GC_ADD_FLY_TARGETING` 已进入 `EntityStore`,通过 `M2Client.fly_targeting` 暴露,且已补入 `GameClient` 世界分派;`GC_CHANGE_SKILL_GROUP` 会更新 `get_skill_group()` 并使技能快照失效;快捷栏先发 `use_skill(skill_id,target_vid)`,再同步 `CG_MOVE` 技能动作;弓技能会在 `.msa` FLY 事件发送 `CG_SHOOT`。真服单会话已发送 `CG_USE_SKILL` / `CG_FLY_TARGETING` / `CG_ADD_FLY_TARGETING` / `CG_SHOOT`,均无断连 / unknown | 仍需实现范围 / 多目标选择器来自动驱动 `ADD_FLY_TARGETING`;冷却、目标合法性、真实弹道和客户端表现也须按技能类型细分;尚未在真服观察到对应 GC 飞行目标推送 |
| P0 | 🟡 | 快捷栏持久化 | 已覆盖 36 槽(4 页 × 9 格)、F1–F4 切页和 1–9 激活;拖放 / 清除 / 交换会分别发送 `CG_QUICKSLOT_ADD/DEL/SWAP`,服务器 `GC_*`完整恢复;技能冷却读取对应 `.msk``CoolTimeFormula` | 仍需补命令 / 表情快捷槽、物品拖放来源、鼠标物品态及原 UI 的完整快捷栏交互;冷却还未叠加原客户端的施法速度与所有特殊技能规则 |
| P0 | 🟡 | 快捷栏持久化 | 当前有 36 槽(4 页 × 9 格)、F1–F4 切页和 1–9 激活;拖放 / 清除 / 交换会分别发送 `CG_QUICKSLOT_ADD/DEL/SWAP`,服务器 `GC_*` 可恢复;技能冷却读取对应 `.msk``CoolTimeFormula` | 参考端服务端保存 36 槽,但客户端本地显示为 4 页 × 8 格(32 槽);可执行类型只有 `INVENTORY(1)` / `SKILL(2)` / `EMOTION(3)`,没有“命令类型”,当前 type=3/4 映射不兼容。参考端数字键默认只激活本地槽 0–3,Ctrl+1–9 发放表情,F1–F4 激活本地槽 4–7;当前 4×9 切页 / 1–9 全激活是自定义行为,仍需补物品拖放来源、鼠标物品态及完整刷新 |
| P0 | 🟡 | 物品操作 | 已有移动、使用、丢弃、拾取;Shift 选“来源→目标”发 `ITEM_USE_TO_ITEM`Ctrl+右键向当前目标 `ITEM_GIVE`Alt+右键以数量框发 `ITEM_DROP2``ITEM_OWNERSHIP` 会实时更新地面名条;`GC_VIEW_EQUIP` 已保存 11 格检查快照并弹出装备查看窗 | 仍需复刻原鼠标物品态、操作确认 / 错误提示、宝石专用规则,以及装备查看窗的原版槽位图标、3D 纸娃娃与交互 |
| P0 | 🟡 | 装备 / 背包数据模型 | 常规背包维持 90 格;运行时模型已扩展到 24 wear 位置,并将腰带改为独立 16 格;背包右侧动态呈现能力 / 时装 / 戒指 / 腰带和 4×4 腰带格,发包使用原客户端 `INVENTORY` 全局 cell`90 + wear` / `152 + belt`),按 `values[0]` 的原腰带等级规则锁格。`EquipModel` 已把本地主角 `parts[]`(时装优先)和装备槽接到身体 shape / TargetSkin、头盔 / 假发 / 发型、右手武器、左手盾牌,并用 `item_proto.specular/100` 驱动护甲球面高光 | 仍需复刻原 costume / belt 窗口的槽位贴图、分页、tooltip 和鼠标物品态;补齐饰品 / 翅膀等多部件遮挡规则、远端角色同等换装以及逐部位高光 / 卸装复位,不以当前兼容性扩展面板代替原 UI |
| P0 | 🟡 | 移动与战斗同步 | 有 `GC_MOVE`、本地预测、攻击;`M2Client.character_position()` 可发送姿态回报,`M2Client.sync_positions()` 可按原包批量发送最多 16 个坐标,`M2Client.request_warp()` 可发送 `CG_WARP` 主动传送请求;`GC_SYNC_POSITION` 批量坐标纠偏、`GC_CHANGE_SPEED``GC_WALK_MODE``GC_CHARACTER_POSITION``GC_DIG_MOTION` 已恢复到实体状态,`NetPlay``moving_speed/100` 校准主角步行 / 奔跑预测,挖矿广播可驱动目标朝向 / dig 动画,`WALK_MODE` 会驱动远端 walk/run 动画选择;真服单会话已发送 `CG_CHARACTER_POSITION`(5B) 与 `CG_SYNC_POSITION`(16B),均无断连 / unknown | 仍需按原客户端同步窗口、碰撞纠偏触发时机与保留动作处理,而不是只靠距离阈值和 lerp;姿态动作还没有完整 UI / 动画语义 |
| P0 | 🟡 | PVP / 决斗 / 阵营 | `GC_PVP``DUEL_START` 已进入状态层,并以 `pvp_changed` / `duel_started` 信号提供给 Godot;挑战 / 战斗 / 复仇关系会在世界实体上显示标签;实体已有部分 empire / alignment / pk 字段 | 仍需 PK 模式上行、原版名字颜色、可攻击判定、阵营规则和原版目标板 / 决斗 UI |
| P1 | 🟡 | Actor 战斗运行时 | 有骨骼动画、基础 combo / 硬直 / 相机抖动;动作切换按 Granny `EaseInCurve(0,0,1,1)``2w²−w³` 逐骨 crossfade;角色 mesh 支持距离 LOD、15% 迟滞和约 0.18s 新旧 mesh 淡入(`LodGhost` + `lod_fade` uniform);右手武器 / 左手盾按 `weaponComposite[0] = weaponInvWorld[0]·weaponLocal[0]` grip 复合挂 `equip_right_hand`(探针实测 Δ≤0.06 m,非标准米级偏移文件夹断回退);`.msa` motion event 已分派声音、EffectPosition 特效、震屏和屏闪(FLY / WARP 留钩子);玩家 / 怪物强制实时投影并附脚底接触阴影 Decal;实体 `attack_speed` 与本地 `POINT_ATT_SPEED` 已按原版 100 基准影响攻击请求间隔 | 仍需用真实 `.msa` motion duration、武器 / 坐骑规则和服务端合法性校准攻击节奏;另补 combo 取消窗口、精确受击 / 击退、角色碰撞、同步碰撞、武器拖尾、剩余 motion event / 骨骼挂点语义、GPU skin LOD、双持 / 左手武器和目标锁定相机;`03150` 类偏移网格需 per-weapon 偏移数据或修 .gr2 |
| P1 | 🟡 | 飞行物与命中特效 | `GC_FLY_TARGETING` / `GC_ADD_FLY_TARGETING` 保存 shooter 的目标队列和 VID / 坐标;`GC_CREATE_FLY` 可消费队列并生成 tween 发光球 | 对齐 `FlyingObjectManager`:真实技能弹道模型、命中时机 / 命中特效、消失条件、声音与 EffectLib 特效 |
| P1 | 🟡 | 任务 / NPC | 支持 `[ENTER]``[CLEAR]``[NEXT]``[DONE]`、基础 `[QUESTION]``[INPUT]``[SELECT_ITEM]` 文本流程和任务日志;`SelectItemUI` 按原 `uiselectitem.RefreshSlot` 过滤魔石 / grade、映射背包格并发送 `CG_SCRIPT_SELECT_ITEM``quest_cancel()` 发送取消包;真服单会话已发送 `CG_SCRIPT_SELECT_ITEM`(8B) 与 `CG_QUEST_CANCEL`(4B),取消触发 1 条 quest-change 且无断连 / unknown | 补原版 CJK IME / 输入校验、QuestButton、立绘、完整 EventManager 标签、世界箭头和屏幕边缘指示;选道具业务回包与完整资源视觉仍需真服 / 资源环境验收 |
| P1 | 🟡 | 任务 / NPC | 支持 `[ENTER]``[CLEAR]``[NEXT]``[DONE]`、基础 `[QUESTION]``[INPUT]``[SELECT_ITEM]` 文本流程和任务日志;`SelectItemUI` 按原 `uiselectitem.RefreshSlot` 过滤魔石 / grade、映射背包格并发送 `CG_SCRIPT_SELECT_ITEM=114`Classic 没有独立的 `CG_QUEST_CANCEL``quest_cancel()` 通过 `CG_SCRIPT_ANSWER=29` 发送 sentinel `255`;真服单会话已验证选择包 8B,取消触发 1 条 quest-change 且无断连 / unknown | 补原版 CJK IME / 输入校验、QuestButton、立绘、完整 EventManager 标签、世界箭头和屏幕边缘指示;选道具业务回包与完整资源视觉仍需真服 / 资源环境验收 |
| P1 | 🟡 | 商店 / 交易 / 仓库 | 普通 NPC 商店、交易状态和仓库列表已有首版;**`SHOP_GC_START_EX` 多货架已解析(`m_shop_tabs``get_shop()`),`shop_ui` 带货架按钮 + `tabIdx*40+slot` 买位置** | 补图标格网格、数量输入、交易回滚提示、仓库密码 / 改密码、完整拖放规则、`coin_type` 非金币货币显示 |
| P1 | 🟡 | 私人商店 / 道具商城 | **首版**`CG_MYSHOP`(0x0802) `CGMyShopHead`(38) + `MyShopItem`(13)×N 有尺寸断言 + `GameClient::send_open_private_shop` / `send_close_private_shop`(=`SHOP_CG_END`) + loopback 字节级;`GC_MALL_OPEN`(0x0841)/`GC_MALL_SET`(0x0842)/`GC_MALL_DEL`(0x0843) 路由进 `EntityStore``m_mall[135]` + `mall_open/size/slot/dirty`),`CG_MALL_CHECKOUT`(0x0840) `CGMallCheckout`(8) 发送;`M2Client` `open/close_private_shop``get_mall_items`/`mall_checkout` + `mall_opened`/`mall_changed` 信号;`ui/private_shop_ui.gd`**已重做成 `PrivateShopBuilder.itemStock` 模型:40 格 grid + 拿起 / 落位 / 逐件价格弹窗 / 点占用格撤下 / 개설按格号排序 + display_pos + 上限 39**)、`ui/mall_ui.gd`(列表 + 取出)。**真机验证(2026-08-31`net_e2e MT_E2E_MYSHOP=1`**`CG_MYSHOP` 38B head + 13B item×1 被真服接受,无断连 / 无 shop error`SHOP_CG_END` 关店正常。`CG_MALL_CHECKOUT` 送出不断连,但 mall 需点 NPC 才 open。他人开店 `SHOP_GC_START_EX` 多货架已解析(见增量 34)。`GC_SHOP_SIGN` 已在 `NetWorld` 提供 `ShopSign` Label3D fallback`GC_SYMBOL_DATA` 已接会徽下载;仍缺真 `.sub``privateshopbuilder.py` uiscript、资源视觉和鼠标真拖放物品态 |
| — | ❌ | 拍卖行 / auction house | 不做 | m2dev-client 无此系统(`0x08xx` 段只有 shop/myshop/safebox/mall,全源码 / 协议 / 资产零 `auction` 命中)。需自定义协议 + 服务端支持才能实现,**标注为不做**,除非后续明确提需求 |
| P1 | 🟡 | 私人商店 / 道具商城 | **首版**`CG_MYSHOP=55``CG_MALL_CHECKOUT=69``GC_MALL_OPEN=122` / `GC_MALL_SET=128` / `GC_MALL_DEL=129``CGMyShopHead`(38) + `MyShopItem`(13)×N 有尺寸断言;商城状态路由进 `EntityStore``private_shop_ui` / `mall_ui` 和真服探针均已接入。 | 仍缺真 `.sub``privateshopbuilder.py` uiscript、资源视觉和鼠标真拖放物品态 |
| — | ❌ | 拍卖行 / auction house | 不做 | 当前 40250 默认构建未发现完整 ClientVS22 拍卖行 UI / handler;但客户端 `Packet.h` 保留 `__AUCTION__` 下的 `CG_AUCTION_CMD=205`,服务端也保留列表包 `=206`。当前默认服务端关闭该宏,因此本阶段不做;若启用需另行验收,不能称为“协议不存在” |
| P1 | 🟡 | Cube (제작) / 能量系统 | **首版**Cube 走 `GC_CHAT`/`CHAT_TYPE_COMMAND` 文本总线(对齐 `ServerCommand()`),`EntityStore::apply_server_command` 解析 `cube open/close/info/success/fail/r_list/m_info`(配方 `v,c` 列表 + `@`/`&`/`|`/`/` 分层材料 + 金币)→ `CubeState` + `drain_cube_events()``GameClient::send_cube_make/material_info/result_list``/cube make|mInfo|rList`);`M2Client` `get_cube`/`cube_make`/`cube_request_*` + `cube_opened`/`cube_closed`/`cube_changed`/`cube_result` 信号;`ui/cube_ui.gd`(配方列表 + 材料/金币 + 제작)。`net.entity_store` + `net.loopback_flow` 覆盖。真机(2026-08-31`/cube rList` 送出不断连,但服务器只在玩家站到 cube NPC 前才回应 → `cube open=0`,需 NPC 交互完整验。`POINT_ENERGY` 已由 HUD 快照显示;仍缺原 `uiCube.py` 材料格 3D 图标 / 拖放 / 결과 애니、NPC 前完整验 send 命令字 |
| P1 | 🟡 | 组队 | 邀请、接受、离队;**成员信息板(`party_ui.gd`1:1 迁移 `uiparty.PartyMemberInfoBoard`):每员角色状态按钮(队长弹菜单 → `CG_PARTY_SET_STATE` 0x0704 分配 攻/坦/迅捷/辅/宗/防 或踢人)+ 名字(★队长) + HP gauge + `affects[7]` 附加效果 chip;顶部 EXP 分配开关(`party_set_distribute`+ 组队治疗(`party_use_skill(1,0)`)**;角色 / 治疗控件已按 40250 服务端 Leadership 规则本地门控(攻击≥10、坦克/迅捷≥20、辅助≥25、宗师≥35、防御≥40,治疗≥18);真服单会话 `CG_PARTY_USE_SKILL`(9B) 后仍在线;小地图 / Atlas 绿色队员点 + 队长环 | 仍需真版 `.sub` 图标 / 颜色、队伍技能冷却 / 权限、离线颜色语义和真实两账号组队验收 |
| P1 | 🟡 | 组队 | 邀请、接受、离队;**成员信息板(`party_ui.gd`1:1 迁移 `uiparty.PartyMemberInfoBoard`):每员角色状态按钮(队长弹菜单 → `CG_PARTY_SET_STATE=75` 分配 攻/坦/迅捷/辅/宗/防 或踢人)+ 名字(★队长) + HP gauge + `affects[7]` 附加效果 chip;顶部 EXP 分配开关(`party_set_distribute`+ 组队治疗(`party_use_skill(1,0)`)**;角色 / 治疗控件已按 40250 服务端 Leadership 规则本地门控(攻击≥10、坦克/迅捷≥20、辅助≥25、宗师≥35、防御≥40,治疗≥18);真服单会话 `CG_PARTY_USE_SKILL`(9B) 后仍在线;小地图 / Atlas 绿色队员点 + 队长环 | 仍需真版 `.sub` 图标 / 颜色、队伍技能冷却 / 权限、离线颜色语义和真实两账号组队验收 |
| P1 | 🟡 | 好友 / 情侣 | 好友列表、上线状态和私聊入口已有;`GC_LOVER_INFO`(70B) / `GC_LOVE_POINT_UPDATE`(5B) 已有尺寸断言、`EntityStore::LoverInfo` 状态、`M2Client.get_lover()` / `lover_changed``love_ui` 常驻显示伴侣名和 0–100 爱意值;`net.entity_store``love_ui_test.gd` 覆盖 | 补好友分组和完整 messenger 行为;情侣仍需完整交互 / 状态图标与 affect 表现、断线 / 切图生命周期、原版文案和真服务器端到端验收 |
| P1 | 🟡 | 公会(暂缓) | 信息、成员、技能、公会战、会徽上 / 下载和部分操作已有首版;`GC_REQUEST_MAKE_GUILD`(0x0731) 已解析并以 `M2Client.guild_make_requested` 通知;公会窗已弹出创建名称输入并发送 `CG_ANSWER_MAKE_GUILD``CG_SYMBOL_CRC` / `GC_SYMBOL_DATA` 已支持原始符号文件下载 | 本阶段暂缓完整权限 / 等级页、公告与日志、成员管理、公会战应答、积分板、领地和公会建筑;恢复公会范围后再做完整真服验收 |
| P1 | 🟡 | 公会(暂缓) | 信息、成员、技能、公会战、会徽上 / 下载和部分操作已有首版;`GC_REQUEST_MAKE_GUILD=82` 已解析并以 `M2Client.guild_make_requested` 通知;公会窗已弹出创建名称输入并发送 `CG_ANSWER_MAKE_GUILD``CG_GUILD_SYMBOL_CRC=113` / `GC_GUILD_SYMBOL_DATA=133` 已支持原始符号文件下载 | 本阶段暂缓完整权限 / 等级页、公告与日志、成员管理、公会战应答、积分板、领地和公会建筑;恢复公会范围后再做完整真服验收 |
| — | ❌ | 龙魂精炼 | 当前目标 40250 服务端不提供该玩法;本阶段不纳入支持或验收,已有兼容代码仅保留作协议参考 | 若未来服务端增加该玩法,再另立协议、UI 和验收需求 |
| P1 | 🟡 | 世界玩法 | 有 `CG_WARP` 主动请求、`GC_WARP` 同服 / 跨服处理`CG_FISHING` / `GC_FISHING` 钓鱼方向与状态事件、`NetWorld` / `NetPlay` 钓鱼动作分发、`ChatUI` 鱼获 / 失败日志、`CG_DUNGEON` / `GC_DUNGEON` 副本请求与状态事件、`GC_DIG_MOTION` 挖矿动作广播、`GC_LAND_LIST` 领地状态、`GC_OBSERVER_ADD/REMOVE/MOVE` 观战者状态、频道、时间、NPC marker、天气粒子和 mount_vnum 字段;真服登录洪流已收到并解析 `GC_LAND_LIST`(436B),单会话发送 `CG_FISHING`(5B) 后收到“Please choose a Fishing Pole.”业务拒绝且连接保持;同服 `GC_WARP` 直接挪玩家,跨服 `addr≠0``M2Client` 复用 login key 直连目标 game server,断点重连也走该快路径 | 补完整钓鱼 UI / 鱼获背包落地、副本计时 / 入口 UI、坐骑真模型 / 骑乘动作 / 移速、地形贴花、DungeonBlock 和地图天气配置;`CG_DUNGEON` / `CG_WARP` 本轮为避免副作用未发送,跨服换图仍需真服端到端验收与场景生命周期清理 |
| P1 | 🟡 | 世界玩法 | 有 `CG_WARP=65` 主动请求、`GC_WARP=65` 处理、`NetWorld` / `GameScene` 的无条件 `lAddr:wPort` 重连与换图清理首版`CG_FISHING=82` / `GC_FISHING=89` 钓鱼方向与状态事件、`NetPlay` 钓鱼动作分发、`ChatUI` 鱼获 / 失败日志、`CG_DUNGEON=110` / `GC_DUNGEON=110` 副本请求与状态事件、`GC_DIG_MOTION=134` 挖矿动作广播、`GC_LAND_LIST=130` 领地状态、`GC_OBSERVER_ADD/REMOVE/MOVE=9698` 观战者状态、频道、时间、NPC marker、天气粒子和 mount_vnum 字段;真服登录洪流已收到并解析 `GC_LAND_LIST`(436B),单会话发送 `CG_FISHING`(5B) 后收到“Please choose a Fishing Pole.”业务拒绝且连接保持 | 补完整钓鱼 UI / 鱼获背包落地、副本计时 / 入口 UI、坐骑真模型 / 骑乘动作 / 移速、地图 BGM / 天气配置、地形贴花、DungeonBlock;换图首版已落地,仍需真实 40250 端到端验收 |
| P1 | 🟡 | 小地图 / Atlas | 可画玩家、实体、NPC、任务标记、观战者紫色菱形和 `GC_LAND_LIST` 领地边框;支持 `set_scale()` / 鼠标滚轮缩放;Atlas 支持底图、地图名称、玩家坐标、拖动和坐标查询 | 补原 minimap / Atlas 贴图细节、提示、队员 / NPC 分类、世界箭头、地图驱动天气配置和原版窗口视觉 |
| P1 | 🟡 | EffectLib | `.mse` Particle 已映射到 `GPUParticles3D` | 粒子 `.dds` 目前被程序化径向渐变替代,`.mde` 用 box 占位;需补 EffectMesh、SimpleLight、TexAni、MovingType、骨骼挂点和完整时间轴 |
| P1 | 🟡 | SpeedTree | `.spt` 只嗅探真实 bark / composite 贴图,几何为程序化 proxy | 迁移或离线转换真实树干、叶片、frond、LOD、billboard、风和阴影;当前不能作为 1:1 树木 |
| P1 | 🟡 | 地图 / 材质渲染 | A1 地形、静态物、水和 `.msenv` 环境已有首版;splat 已支持每区块最多 16 个活动层、原客户端 UV / offset 与最大源分辨率,地形拆 patch 做视锥 / 距离剔除;水使用原 30 帧 DDS 序列、水深 alpha 和轻微高度动画;静态物 Y-up 旋转共轭已修正。GR2 blend / two-sided 优先读材质信息,球面高光管线已接 `item_proto.specular`;角色有实时投影 + 接触阴影 | 补其余固定功能材质与多贴图分支、精确 alpha-test / blend / cull、透明排序、逐 patch 几何 LOD、baked / realtime 阴影 mask、血迹 / 施法阵等 Decal、云层和 lens flare;按受控参考帧校准水深、LOD、阴影、光照和材质 |
| P1 | 🟡 | UI 控件语义 | 80 个 `uiscript` 可解析和构建,覆盖约 20 类基础节点 | 解析成功不等于窗口完成;需补动画图片、slot 拖放、scrollbar/listbox 行为、tooltip、radio group、候选窗、原层级 / 锚点 / 焦点 / modal 规则 |
| P1 | 🟡 | UI 窗口行为 | 背包、技能、任务、聊天、社交和商店等有简化窗口;腰带背包、扩展装备和装备查看已有兼容窗口;**选人页已按 Metin2 风重做**;**角色状态窗(`char_status_ui.gd`)已按 `characterwindow.py` 布局 + `uicharacter.py.RefreshStatus` 逐字绑定 + `/stat` 加点命令做成,作为 1:1 窗口迁移样板** | **选魔石窗** `select_item_ui.gd`、**系统设置窗** `system_option_ui.gd`、**游戏设置窗** `game_option_ui.gd``/setblockmode` + `/pkmode` 真命令 + 显示开关持久化)、**ESC 系统菜单** `system_menu_ui.gd``systemdialog.py` + `/phase_select`/`/logout`/`/in_game_mall`)、**私人商店窗** `private_shop_ui.gd`、**组队成员板** `party_ui.gd` 均已按样板做成 | 按样板逐个复现原 `Interface.MakeInterface()` 装配与视觉 / 交互细节:帮助窗、Atlas、商城 / Cube uiscript、Guild Building、game_option 的名字颜色 / 伤害数字等渲染侧钩子(拍卖行参考端无此窗口)|
| P2 | 🟡 | 输入 / 快捷键 | WASD、点地、触屏、右键拖拽环绕、滚轮缩放、双指捏合、I/K/J/O/G/L/Z、F1F4 快捷栏切页和数字键 1–9 激活已接;实体点选从固定半径提升为优先使用子 mesh 合并世界 AABB,无码型实体才回退胶囊近似 | 对齐空格连续攻击、显示名字、鼠标物品态、PK 模式、正式客户端截图、相机模式与灵敏度配置持久化、键位配置和窗口冲突规则 |
| P1 | 🟡 | UI 窗口行为 | 背包、技能、任务、聊天、社交和商店等有简化窗口;腰带背包、扩展装备和装备查看已有兼容窗口;**选人页已按 Metin2 风重做**;**角色状态窗(`char_status_ui.gd`)已按迁移资产中的 `characterwindow.py` 布局 `uicharacter.py.RefreshStatus` 逻辑实现,并使用`/stat` 加点命令作为迁移样板** | **选魔石窗** `select_item_ui.gd`、**系统设置窗** `system_option_ui.gd`、**游戏设置窗** `game_option_ui.gd``/setblockmode` + `/pkmode` 真命令 + 显示开关持久化)、**ESC 系统菜单** `system_menu_ui.gd``systemdialog.py` + `/phase_select`/`/logout`/`/in_game_mall`)、**私人商店窗** `private_shop_ui.gd`、**组队成员板** `party_ui.gd` 均已按迁移样板做成;这些 Python 文件不在本次 `ClientVS22/source/` 树中,仍需用对应 C++ 常量 / 行为和实际资源验收 | 按 `ClientVS22/source/UserInterface` 的窗口装配、消息、控件和资源行为逐个复核视觉 / 交互细节:帮助窗、Atlas、商城 / Cube uiscript、Guild Building、game_option 的名字颜色 / 伤害数字等渲染侧钩子 |
| P2 | 🟡 | 输入 / 快捷键 | WASD、点地、触屏、右键拖拽环绕、滚轮缩放、双指捏合、I/K/J/O/G/L/Z、当前自定义的 F1–F4 切页和数字键 1–9 激活已接;实体点选从固定半径提升为优先使用子 mesh 合并世界 AABB,无码型实体才回退胶囊近似 | 对齐 ClientVS22 的数字键 14、Ctrl+19 表情、F1F4 本地槽位 4–7、空格连续攻击、显示名字、鼠标物品态、PK 模式、正式客户端截图和窗口上下文规则;键位重绑定 / 相机灵敏度持久化不是参考端已有能力 |
| P2 | 🟡 | CJK 输入与本地化 | 支持 KEY→VALUE 查表和基础字体注入 | 补 `PythonIME` 中 / 日 / 韩候选窗、复数 / 性别规则、字体自动切换、完整 item / mob 名称与 tooltip 本地化 |
| P2 | 🟡 | 音频 | 有 BGM crossfade、UI 音效池和 3D 音效 | 补地图 BGM 自动选择、`.msenv` 环境循环音、武器 / 技能 / 怪物 / NPC 声音事件和原距离衰减规则 |
| P2 | 🟡 | 客户端外壳 | Godot 工程已有 macOS / iOS / Android 构建脚本 | 补 `config.exe` 对应的分辨率、显示、音量、阴影、语言和鼠标配置;Movie / Web / Logo、补丁器约束、Discord RPC 按发布目标取舍 |
@@ -396,12 +506,12 @@
## 参考模块覆盖索引
下表以 `m2dev-client-src-main/src/UserInterface/` 的模块职责为索引,补充上表未展开的基础能力。
下表以 `40250/ClientVS22/source/UserInterface/` 的模块职责为索引,补充上表未展开的基础能力。
“已有”只表示当前有可运行首版;仍需满足本文末的 1:1 完成判据。
| 参考模块 / 能力域 | 当前实现 | 与 m2dev-client 的差距 | 优先级 |
| 参考模块 / 能力域 | 当前实现 | 与 40250/ClientVS22 的差距 | 优先级 |
|---|---|---|---|
| `AccountConnector` / `PythonNetworkStreamPhase*` 登录与相位 | auth → game → 选人 → LOADING → PHASE_GAME 已打通;`LoadingScreen``ReconnectUI`、跨服 `GC_WARP` 重连和频道状态探针均有首版 | 登录 / 选服 / 选频道的原版错误码、表单校验、超时与会话失效文案,服务器列表刷新和所有相位边界清理仍未逐项对齐 | P1 |
| `AccountConnector` / `PythonNetworkStreamPhase*` 登录与相位 | auth → game → 选人 → LOADING → PHASE_GAME 已打通;`LoadingScreen`频道状态探针、Classic DirectEnter 和 Classic `GC_WARP` 重连 / 换图清理均有首版 | 登录 / 选服 / 选频道的原版错误码、表单校验、超时与会话失效文案,真实双连接 / 换图验收、服务器列表刷新和所有相位边界清理仍未逐项对齐 | P1 |
| `PythonChat` / `PythonTextTail` / `PythonPlayer` | 聊天四标签、私聊 / 组队 / 公会 / 喊话路由,实体气泡、名字、HP 条、伤害飘字和本地气泡已有 | 缺称号、帮会名、名字颜色和目标高亮框;`GC_DAMAGE_INFO`、经验 / 金钱变化的原版日志行、notice / big-notice 排版、历史滚动和 `InsultChecker` 过滤未完整迁移 | P1 |
| `PythonPlayer` HUD / affect / target | HP、SP、经验、等级、目标血条、死亡复活窗和 affect 色块条已绑定 | 原版状态栏布局、图标贴图、持续时间倒计时、目标板按钮 / 交互、所有 point 类型和战斗日志格式仍是简化版 | P1 |
| `PythonPlayerInput*` / `CameraProcedure` / `PythonApplicationCursor` | WASD、点地、触屏点选 / 单指相机拖动 / 双指捏合、桌面右键拖拽与滚轮缩放、网格 AABB 点选、相机遮挡淡出和碰撞已有;`GC_CHANGE_SPEED` 会校准本地移动速度 | 缺原版光标状态(talk / attack / buy / sell / pick 等)、相机模式与灵敏度配置持久化、键位配置、窗口冲突和空格连续攻击语义 | P1 |
@@ -413,81 +523,81 @@
## 协议覆盖审计
`m2dev-client-src-main/src/UserInterface/Packet.h` 为基准(`namespace CG` 68 个 +
`namespace GC` 109 个 = **177** 个协议键)。增量 2/3 已补 `CG_CHARACTER_CREATE` /
`CG_CHARACTER_DELETE` / `GC_PLAYER_CREATE_SUCCESS` / `GC_PLAYER_CREATE_FAILURE` /
`GC_PLAYER_DELETE_SUCCESS` / `GC_PLAYER_DELETE_WRONG_SOCIAL_ID` / `CG_MYSHOP` /
`CG_MALL_CHECKOUT` / `GC_MALL_OPEN` / `GC_MALL_SET` / `GC_MALL_DEL` 共 11 个,并把已声明的
`GC_EMPIRE` 接到 `M2Client.get_empire()`。增量 5 又把此前仅声明的 `GC_LOVER_INFO` /
`GC_LOVE_POINT_UPDATE` 接入状态、信号和 UI;增量 6 又补了
`CG_CHARACTER_POSITION` / `CG_SYNC_POSITION` 的上行构造和 API。这两项不再计入缺失清单,
故当前总数从 24 降为 22 个 header;增量 7 再补任务选择 / 取消上行,增量 8 补队伍技能上行,
增量 9 补飞行目标广播,增量 10 补技能组切换,增量 11 补角色改名,增量 12 补公会创建请求,
增量 13 补主动传送,增量 14 补挖矿动作,增量 15 补会徽符号下载,增量 16 补钓鱼协议,
增量 17 补副本协议,增量 18 补领地与观战者,增量 39 补 `CG_TEXT`,当前为 **0 个 header**
`40250/ClientVS22/source/UserInterface/Packet.h` 是一个按 “To Server / From Server” 分段的全局
enum,不存在 `namespace CG` / `namespace GC`。本文此前的“68 + 109 = 177”以及“当前 0 个 header”
是把旧版 `wire.h`m2dev)与 Classic 实现混在一起后的历史统计,不能继续作为当前覆盖结论。
排除仅命名不同、功能已覆盖的别名(`GC_CHAR_ADDITIONAL_INFO`=`GC_CHAR_ADD_INFO`
`GC_TARGET`=`GC_TARGET_INFO``GC_TARGET_CREATE_NEW`=`GC_TARGET_CREATE`
`GC_CHARACTER_UPDATE2`=`GC_CHARACTER_UPDATE``GC_OWNERSHIP`=`GC_ITEM_OWNERSHIP`
`GC_SEPCIAL_EFFECT`=`GC_SPECIAL_EFFECT``GC_REFINE_INFORMATION_NEW`=`GC_REFINE_INFO_NEW`
以及 `CG_STATE_CHECKER` / `GC_RESPOND_CHANNELSTATUS``project/net/channel_status.gd` 单独实现)
`CG_HACK`(反作弊,见「不做清单」),
**当前主网络层仍缺 0 个 header**(脚本核对 `wire.h` vs `Packet.h`):
当前协议审计必须按运行时变体分开:
- **当前默认 Classic**:以 `extension/src/net/classic/wire_classic.h``classic_parser.cpp`
`GameClient` 分派和 `40250/Server/.../packet.h` 逐包核对。Classic 的核心编号与参考端一致
例如 `CG_CHARACTER_CREATE=4``CG_MYSHOP=55``CG_WARP=65``CG_FISHING=82`
`CG_DUNGEON=110`GC 侧 `GC_PLAYER_CREATE_SUCCESS=8``GC_FISHING=89`
`GC_LAND_LIST=130`
- **m2dev**:只有显式使用 `MT_PROTOCOL=m2dev` 时才核对 `extension/src/net/wire.h`;其中的
`0x0201``0x0802``0x0B01` 等编号不是当前 Classic/40250 对照值。
- `CG_HACK``CG_STATE_CHECKER``GC_RESPOND_CHANNELSTATUS` 等条件能力应单独列为“已排除 / 待
服务器变体验证”,不能从一个总数中静默扣除。
- “header 已声明”只代表 wire / size 层存在,不代表 parser、实体状态、UI 消费或真实服务器流程
已完成;本文以这些层级分别记录状态,不再用单一缺失数量表示全部协议覆盖。
### 账号 / 命令通道
| header | 值 | 说明 |
|---|---|---|
| `CG_TEXT` | 0x0011 | 已补 classic wire 尺寸表;这是握手阶段的管理命令,固定前缀为 1 字节,后续文本直到换行由服务端特殊解析。Cube 仍借 `CG_CHAT`/`CHAT_TYPE_COMMAND` 实现,不伪造管理命令入口 |
| `CG_TEXT` | 64 (`0x40`) | 40250 Server 的管理文本包,当前 Classic 已有 1 字节固定前缀尺寸表;后续文本由服务端按换行特殊解析。它不在 ClientVS22 客户端 `Packet.h` 的普通 To Server 命名项中,因此不计入 ClientVS22 客户端 header 覆盖数。Cube 继续使用 `CG_CHAT` / `CHAT_TYPE_COMMAND`,不伪造管理命令入口 |
### 已声明但待核(不计入上面 1 个)
### 已接线但仍需验收
- `CG_CHARACTER_POSITION`(0x0A60) / `CG_SYNC_POSITION`(0x0303)wire、发送接口和
- `CG_CHARACTER_POSITION`(28 / `0x1C`) / `CG_SYNC_POSITION`(8 / `0x08`)wire、发送接口和
loopback 字节测试已覆盖;真服单会话已分别发送 5B / 16B,均保持在线且无 unknown;仍缺原版
自动触发语义、姿态动作链和服务端位置纠偏回包验收。
- `CG_SCRIPT_SELECT_ITEM`(0x0903) / `CG_QUEST_CANCEL`(0x0906)wire、发送接口和
loopback 字节测试已覆盖;真服单会话已分别发送 8B / 4B,取消触发 1 条 quest-change 且无
unknown;基础 `[INPUT]` / `[SELECT_ITEM]` / Escape 取消已接入,选择窗的背包魔石网格、
grade 过滤和选择回包已由 `select_item_ui_test.gd` 覆盖;仍缺原版输入校验、CJK IME 和有脚本时
的真实任务流程验收。
- `CG_PARTY_USE_SKILL`(0x0705)wire、发送接口和 loopback 字节测试已覆盖;真服单会话已发送
- `CG_SCRIPT_SELECT_ITEM`(114 / `0x72`)wire、发送接口和 loopback 字节测试已覆盖;真服单会话
已发送 8B 且无 unknown;基础 `[INPUT]` / `[SELECT_ITEM]` 已接入。Classic 没有独立的
`CG_QUEST_CANCEL` headerEscape 取消通过 `CG_SCRIPT_ANSWER=29` 发送 sentinel `255`
当前实现和测试均覆盖该路径;选择窗的背包魔石网格、grade 过滤和选择回包已由
`select_item_ui_test.gd` 覆盖;仍缺原版输入校验、CJK IME 和有脚本时的真实任务流程验收。
- `CG_PARTY_USE_SKILL`(76 / `0x4C`)wire、发送接口和 loopback 字节测试已覆盖;真服单会话已发送
9B 且保持在线 / 无 unknown,但无队伍技能业务回包;仍缺冷却 / 权限、UI 入口和有效队伍真服
验收。
- `GC_FLY_TARGETING`(0x0411) / `GC_ADD_FLY_TARGETING`(0x0412)wire、EntityStore 状态、
- `GC_FLY_TARGETING`(71 / `0x47`) / `GC_ADD_FLY_TARGETING`(69 / `0x45`)wire、EntityStore 状态、
`M2Client.fly_targeting` 信号和 `NetWorld` 目标队列消费已覆盖;本轮发现并修复
`GameClient` 未分派这两个 GC header 的 bug;真服本轮未主动推送,仍缺真实弹道模型、命中时机
和服务端广播验收。
- `GC_CHANGE_SKILL_GROUP`(0x021D)wire、EntityStore 状态、dirty 失效和
- `GC_CHANGE_SKILL_GROUP`(112 / `0x70`)wire、EntityStore 状态、dirty 失效和
`M2Client.skill_group_changed` 已覆盖;本轮发现并修复 `GameClient` 未分派该 header 的 bug
仍缺技能组选择 UI、服务端权限和真服推送验收。
- `CG_CHANGE_NAME`(0x010B) / `GC_CHANGE_NAME`(0x010C)wire、角色槽位更新、
- `CG_CHANGE_NAME`(106 / `0x6A`) / `GC_CHANGE_NAME`(107 / `0x6B`)wire、角色槽位更新、
`M2Client.change_name()``char_name_changed` 已覆盖;选人页按 `change_name` 标志接入
原版“点击开始先改名”流程,并对重名 / 非法名错误码给出状态文案;仍缺真服验收。
- `GC_REQUEST_MAKE_GUILD`(0x0731):无负载 wire 解析和 `guild_make_requested` 信号已覆盖;公会窗已
- `GC_REQUEST_MAKE_GUILD`(82 / `0x52`):无负载 wire 解析和 `guild_make_requested` 信号已覆盖;公会窗已
完成创建窗口、12 字上限、空名 / 控制字符校验和 `CG_ANSWER_MAKE_GUILD` 应答;仍缺服务端敏感词 /
重名错误回包、实际建会状态和真服验收。
- `CG_SYMBOL_CRC`(0x0723) / `GC_SYMBOL_DATA`(0x0732)16B / 8B wire、`MarkClient` CRC 请求、
- `CG_GUILD_SYMBOL_CRC`(113 / `0x71`) / `GC_GUILD_SYMBOL_DATA`(133 / `0x85`)16B / 8B wire、`MarkClient` CRC 请求、
变长数据接收和 `M2Client.guild_symbol_ready` 已覆盖;仍缺本地符号缓存失效策略、UI 预览和
真服验收。
- `CG_FISHING`(0x0B01) / `GC_FISHING`(0x0B10)5B / 10B wire、旋转方向换算、
- `CG_FISHING`(82 / `0x52`) / `GC_FISHING`(89 / `0x59`)5B / 10B wire、旋转方向换算、
`EntityStore` 事件、`M2Client.fishing_event`、远端 / 本地主角动作分发和 ChatUI 结果日志已
覆盖;真服单会话已发送 5B,收到“Please choose a Fishing Pole.”业务拒绝且无断连 / unknown,
证明包布局和错误路径可达;仍缺持竿后的完整钓鱼 UI、鱼获背包落地和成功流程验收。
- `CG_DUNGEON`(0x0B02) / `GC_DUNGEON`(0x0B11)4B / 5B wire、目的地坐标变长 body、
- `CG_DUNGEON`(110 / `0x6E`) / `GC_DUNGEON`(110 / `0x6E`)4B / 5B wire、目的地坐标变长 body、
`EntityStore` 事件和 `M2Client.dungeon_event` 已覆盖;仍缺副本计时 / 入口 UI、地图生命周期
清理和真服验收。
- `GC_LAND_LIST`(0x0B12)4B 固定头 + 24B `LandPacketElement` 变长解析、领地状态、
- `GC_LAND_LIST`(130 / `0x82`)4B 固定头 + 24B `LandPacketElement` 变长解析、领地状态、
`M2Client.get_land_areas()` / `land_areas_changed` 和小地图领地矩形边框已覆盖;真服登录洪流
已收到并解析 436B;仍缺领地贴图 / 建筑和有实际领地变更时的推送验收。
- `GC_OBSERVER_ADD/REMOVE/MOVE`(0x0B200x0B22)12B / 8B wire、持久观战者状态、
- `GC_OBSERVER_ADD/REMOVE/MOVE`(9698 / `0x600x62`)12B / 8B wire、持久观战者状态、
`M2Client.get_observers()` / `observer_event` 和小地图紫色菱形标记已覆盖;仍缺真服验收。
- `CG_WARP`(0x0305)4B wire、`GameClient.send_warp()``M2Client.request_warp()` 和回环
- `CG_WARP`(65 / `0x41`)4B wire、`GameClient.send_warp()``M2Client.request_warp()` 和回环
请求 / `GC_WARP` 回传测试已覆盖;仍缺传送确认 UI、冷却 / 失败提示和真服验收。
- `GC_DIG_MOTION`(0x0308)13B wire、`EntityStore` 事件、`M2Client.dig_motion`
- `GC_DIG_MOTION`(134 / `0x86`)13B wire、`EntityStore` 事件、`M2Client.dig_motion`
`NetWorld` 朝向 / 动画元数据处理已覆盖;仍缺真实挖矿动作资源、音效 / 特效和真服验收。
- `GC_ITEM_DROP`(0x0513):疑似被 `GC_ITEM_GROUND_ADD`(0x0515) 取代,暂按等价处理,未逐字节核。
- `GC_LOGIN_KEY`(0x0107)auth 握手用;`net_e2e` 真机 auth→PHASE_GAME 全程已打通,功能等价
(可能走 `GC_AUTH_SUCCESS` 0x0108),未单独立项
- `GC_ITEM_DROP`(23 / `0x17`)ClientVS22 仍保留声明,但客户端没有注册 / 消费;实际掉落流程使用
`GC_ITEM_GROUND_ADD`(26 / `0x1A)`40250 Server 的掉落发送路径也使用后者。当前可以不实现
`GC_ITEM_DROP`,但应标记为“保留声明 / 未消费”,不能宣称两个包已经逐字节等价
- `GC_LOGIN_KEY`(118 / `0x76`):游戏阶段登录 key 包;`net_e2e` 真机 auth→PHASE_GAME 全程已打通,
但仍需将 auth 成功、游戏连接和登录 key 的实际变体在真服矩阵中单独记录,不能用旧的
`0x0107` / `GC_AUTH_SUCCESS=0x0108` 表述替代。
“header 已声明”也不能直接视为完成:还需检查封包尺寸、变长 body、所有 sub-header、
`EntityStore` 状态、GDScript 信号、UI 消费和真服务器端到端行为。
@@ -497,7 +607,8 @@
### M1:服务器语义正确
1. 把远程 `SHOOT` 时机、范围目标与技能类型冷却接入现有技能协议;
2. 快捷栏的命令 / 表情 / 物品拖放语义与特殊冷却规则;
2. 快捷栏的 `INVENTORY / SKILL / EMOTION` 类型映射、数字键 / Ctrl 表情 / F1–F4 槽位语义、
物品拖放与特殊冷却规则;
3. 细化 24 wear / 腰带 / 装备查看的原版窗口表现,并完成完整物品操作;
4. 移动同步、PVP / 决斗和 Actor 战斗状态机;
5. 用 loopback + 真服抓包为每类协议建立双向验收。
@@ -505,7 +616,7 @@
### M2:主要玩法完整
1. 任务完整标签 / 输入 / 选道具 / 指引;
2. 商店 START_EX、仓库密码、商城、私人商店、Cube(拍卖行 m2dev-client 无,除非另起协议);
2. 商店 START_EX、仓库密码、商城、私人商店、Cube(拍卖行 40250/ClientVS22 无,除非另起协议);
3. 组队角色、情侣完整交互、领地与公会建筑;公会完整页面暂缓;
4. 钓鱼、副本、坐骑、观战者和 Atlas。
@@ -535,3 +646,356 @@
在达到以上判据前,应继续使用“首版”“部分完成”描述,避免把“能解析布局”“有一个按钮”或
“能播放动作”写成 1:1 完成。
---
## ClientVS22 实现细节审计(2026-09-02
### 审计口径
本节以当前 `mtgodot-poc` 源码和 `40250/ClientVS22` 源码做静态对照,重点检查
`UserInterface``GameLib``EffectLib``MilesLib``EterLib``EterGrnLib`
`SpeedTreeLib``PRTerrainLib` 中承担实际运行时语义的模块。
它不是简单的功能勾选表,而是把问题分成四类:
- **明确缺陷**:当前代码存在可定位的错误或错误状态转移;
- **未实现**:当前只有接口、占位或协议接线,没有原客户端对应行为;
- **近似实现**:能够运行,但和 `ClientVS22` 的数据、时序或表现语义不同;
- **未验证**:代码路径存在,但还没有真实 40250 服务器、多账号或受控画面证据。
本次指定的 `40250/ClientVS22/source/` 树中没有 `game.py``uicharacter.py``uiparty.py`
Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移资产 / 历史脚本依据,不应单独
当作 ClientVS22 源码证据。凡只能由这些脚本确认、而在 `ClientVS22/source/` 没有对应 C++
实现或常量的内容,统一视为“迁移资产依据 / 待验证”,不宣称已完成 1:1 源码对照。
参考客户端的核心差异在于:它不是只有网络收发,而是由
`CPythonNetworkStream``NetworkActorManager``CPythonPlayer``ActorInstance`
`CPythonEventManager``PythonIME``MapOutdoor``EffectLib``MilesLib` 共同维护
阶段、Actor、动作、地图、脚本、输入、特效和声音状态。
### 1. P0:连接、协议和生命周期
1. **Classic DirectEnter 已接入,但仍未完成真实双连接验收。** 登录入口仍是 Auth → Game
角色列表保存每槽 `lAddr/wPort` 后,`M2Client.enter_game(index)` 会断开第一条游戏连接,
按角色槽地址重连,并在 DirectEnter 的 SELECT 阶段发送 `CG_CHARACTER_SELECT`
`MT_CLASSIC_ENTER_DELAY` 和真实两次 TCP 连接验收仍待完成。见
[`classic_session.cpp`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/classic/classic_session.cpp)、
[`m2_client.cpp`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/m2_client.cpp)。
2. **游戏中断线的回登录链路已接入,真实 TCP 断线仍待验收。** 已建立游戏连接后的
Classic 失败发 `disconnected``AppFlow` 无条件回 LOGIN;认证 / 游戏连接建立前的失败仍发
`login_failed``ReconnectUI` 已从 AppFlow 主流程移除,但独立文件仍保留作旧测试 / 可选增强。
见 [`m2_client.cpp`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/m2_client.cpp)、
[`app_flow.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/app_flow.gd)。
3. **跨服或换图的场景重建已完成首版,但还没有真实服务器验收。** Classic `GC_WARP` 已按
`lAddr:wPort` 重连;`world_reset` 会清理旧实体、掉落物、PVP / 决斗、目标和交互窗,
`GameScene` 会重建地图、HUD 小地图、Atlas、环境和玩家位置。仍缺真实换图服务器矩阵、
地图 BGM 自动选择、飞行物 / 特效的完整清理和加载阶段逐项截图验收。
4. **选人和进入游戏吞掉底层发送失败(已修复第一批)。** `select_char()` / DirectEnter
选人 / `enter_game()` 现在透传 `send_fixed()` 结果,`CG_ENTERGAME` 成功后执行 flush
GDScript 入口优先调用 `enter_game(index)`。失败提示和真实发送失败注入仍待补充。
见 [`classic_session.cpp`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/classic/classic_session.cpp)。
5. **客户端版本包是硬编码的。** 当前固定发送 `metin2.bin` 和旧 timestamp,未读取实际
构建版本、文件时间或资源包版本。服务端开启版本校验时,更新客户端后仍可能被当成旧客户端。
见 [`classic_session.cpp:335`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/classic/classic_session.cpp:335)。
6. **Passpod、HackShield、XTrap、StateChecker 等兼容路径没有实现。** 当前选择了不伪造
这些回应;如果目标 40250 服务端启用了其中任意一项,登录或进场可能被断开。应把“不支持”
变成明确的服务器兼容矩阵,而不是让连接表现为随机失败。
7. **未知包的 Loading 重同步问题已修复第一批。** 当前不再扫描后续字节寻找
`GC_CHARACTER_ADD` 边界;未知 header 记录最近包、清空缓冲并断开,保留 `header == 0`
填充跳过。真实加密流验收仍待完成。见
[`classic_stream.cpp`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/classic/classic_stream.cpp)。
8. **已知但没有业务处理的包静默丢弃问题已修复第一批。** `ClassicParser` 现在记录阶段 /
header 并返回失败,连接层清空缓冲并断开;可选集成包不会再静默成功。按阶段拆分处理表和
全量业务覆盖仍待完成。见
[`classic_parser.cpp`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/classic/classic_parser.cpp)。
9. **封包覆盖仍停留在结构和尺寸层。** 还需要逐包核对 sub-header、动态 body、实体状态、
UI 消费、发送后的锁定状态、失败回滚和重连恢复,不能仅以 `Packet.h` 中存在 header 作为完成。
10. **文本编码和固定长度字段没有统一策略。** 当前大量使用 UTF-8 `String``std::string`
`memcpy`,没有完整复现 ClientVS22 的本地编码转换。CJK 名称、聊天、私聊、公会名可能
按 UTF-8 字节截断或超过服务端字段边界;角色名校验还存在 `< 24``<= 24` 两种规则。
11. **登录错误、超时、会话失效和服务器列表状态没有完整映射。** 当前更多是把底层字符串
直接显示出来,没有复现 ClientVS22 的错误码、阶段专用文案、服务器刷新和超时处理。
### 2. P0/P1:实体、世界和状态一致性
1. **未知 VID 的普通 Actor 更新包会直接丢失。** `mut_move``mut_set_position``mut_change_speed`
`mut_dead``mut_stun`、物品更新等路径遇到未知实体直接返回,没有 pending state 或重放机制。
角色添加顺序变化时,位置、HP、死亡、动作和装备信息会丢失;`GC_SHOP_SIGN` 当前有独立的
招牌 fallback,不能与普通 Actor 更新混为一谈。见
[`entity_store.cpp:123`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/entity_store.cpp:123)。
2. **`GC_CHAR_ADDITIONAL_INFO` 早于 Spawn 时状态不会被待合并缓存。** `mut_char_info()` 可以
创建 / 更新 Entity,但新建时不发送 Spawn change;如果后续 Spawn 正常到达,NetWorld 仍会
创建节点,若 Spawn 丢失或顺序异常则实体会长期只有数据没有表现层节点。见
[`entity_store.cpp:458`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/entity_store.cpp:458)、
[`net_world.gd:128`](/Users/shenlei/Work/mt/mtgodot-poc/project/net_world.gd:128)。
3. **VID 重用时旧状态可能污染新实体。** `mut_spawn()` 没有完整重置 dead、stunned、mount、
guild、pk、alignment、parts、shop sign、affect 和上一动作状态,只覆盖了基础出生字段。
见 [`entity_store.cpp:75`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/entity_store.cpp:75)。
4. **点数组 API 对部分更新可能残留旧值。** `mut_set_points()` 只复制本次传入数量,不清理
上一次更长数组的尾部;不过当前固定长度 Classic 包路径传入 `POINT_MAX_NUM`,该路径不会
产生部分更新。后续 UI 或其他部分更新调用者仍可能读到旧状态。
5. **实体数据字段不完整。** empire、称号、公会名、公会徽章、名字颜色、alignment 颜色、
target highlight、attackable、weapon mode、sex/job/race motion mode、affect 图标和
剩余时间没有形成完整统一的实体状态。`mut_char_info()` 当前还明确忽略 `empire`
见 [`entity_store.cpp:477`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/entity_store.cpp:477)。
6. **一次性事件没有可靠的生命周期缓存。** 聊天、脚本、特效、飞行物、任务提示等事件主要
通过 drain queue 即时消费;如果 UI 或 GameScene 尚未完成装配,事件可能被消费后无法补发。
7. **Classic Warp 的无条件重连和换图清理已落地首版,仍缺真实 E2E。** ClientVS22 的
`RecvWarpPacket()` 对所有 `GC_WARP` 都按 `lAddr:wPort` 重连;当前 Classic parser 透传
`lX/lY``M2Client` 在重连前发 `world_reset`,并保留 login key / 已选角色槽。`GameScene`
已重建地图和局部 UI;仍需真实服务确认第二次连接、重连后的 `GC_MAIN_CHARACTER` 定位、
地图 BGM / 任务 / 飞行物 / EffectLib 清理。m2dev 后端仍保留自己的 `addr==0` 原地移动语义,
不应与 Classic 对照混用。
### 3. P1:移动、战斗和技能
1. **本地移动是直线位置修改。** 当前控制器直接修改 X/Z,只做地形阻挡检查,没有 Actor 碰撞、
路径寻路、坡度处理、斜坡碰撞、行动预留、移动打断和坐骑状态。见
[`player_controller.gd:221`](/Users/shenlei/Work/mt/mtgodot-poc/project/player_controller.gd:221)。
2. **同步是距离阈值加 lerp。** 当前按固定时间或距离发送移动,回包后按距离阈值 snap/lerp,
没有完整 sequence、authoritative tick、抖动缓冲、预测回滚、动作保留和服务器纠偏时序。
3. **普攻节奏仍是简化实现。** 范围、攻击间隔、硬直和连击窗口均为固定值或简单按攻速缩放,
未使用 race、武器、动作 duration、命中帧、mount 和 Actor battle state。见
[`net_play.gd:34`](/Users/shenlei/Work/mt/mtgodot-poc/project/net_play.gd:34)。
4. **自动攻击没有复现 ClientVS22 的目标预留逻辑。** 参考客户端会持续检查目标存在、死亡、
可攻击、坐骑限制、charge skill、睡眠状态,并预留点击动作;当前主要是检测距离后发攻击包。
参考:[`ClientVS22/PythonPlayer.cpp:207`](/Users/shenlei/Work/mt/40250/ClientVS22/source/UserInterface/PythonPlayer.cpp:207)。
5. **攻击动作、命中时机和网络包没有严格绑定。** 当前 combo 只在 0/1/2 之间循环,动作播放、
hit frame、范围、角度、武器轨迹、击退和受击动作没有由真实 motion data 驱动。
6. **弓箭发射仍有固定超时兜底。** 当前优先消费 `.msa` 的 FLY 事件,但 pending skill 仍由约
1.5 秒定时器兜底;不同技能、攻速、动作长度和网络延迟下可能偏离真实事件,连续释放还可能
覆盖 pending skill。见 [`game_scene.gd:220`](/Users/shenlei/Work/mt/mtgodot-poc/project/game_scene.gd:220)。
7. **受击状态分裂。** `EntityStore.stunned``NetPlay._hitstun_until` 是两套互不统一的状态,
服务端 stun、客户端硬直、远程受击动作和恢复时机可能不一致。
8. **技能缺少统一合法性检查。** 当前主要检查 skill id 是否大于零,没有统一验证技能归属、等级、
master、cooldown、消耗、目标类型、目标数量、距离、角度、当前动作、PK 和坐骑限制。
`target_vid` 等参数还可能先被转换为无符号整数。
9. **公共网络 API 缺少参数边界检查。** `attack`、交易、仓库、物品和技能接口存在负数、窗口、
cell、display position、count 等参数直接 cast 的情况,服务端拒绝不能替代客户端自身的协议校验。
10. **技能升级的传输方式已与参考端一致,但门控和状态流程不完整。** ClientVS22 本身也通过
`/skillup <skillIndex>` 发包;当前缺少参考端的可升级条件门控(技能组、等级上限、技能点、
死亡 / 变身 / 骑乘等状态)、加号按钮显示规则、失败提示和升级后的技能状态刷新。见
[`m2_client.cpp:850`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/m2_client.cpp:850)、
参考 [`PythonSkill.cpp:1833`](/Users/shenlei/Work/mt/40250/ClientVS22/source/UserInterface/PythonSkill.cpp:1833)。
### 4. P1:物品、装备、商店和社交
1. **24 个 wear 位置进入数据层,但没有完整视觉和规则。** costume、翅膀、饰品、双持、左手、
anti-flag、遮挡、职业/性别变体、部位隐藏、卸装复位、远程角色换装和逐部位高光仍不完整。
2. **物品拖放不是原版鼠标物品态。** 缺少全局持有物品、鼠标跟随图标、跨窗口拖放、拖动取消、
多格占用、stack split、装备预览和统一失败恢复。
3. **物品 tooltip 只覆盖基础信息。** sockets、attributes、anti flags、wear flags、requirements、
duration、refine、value、绑定状态、装备对比和本地化描述没有完整迁移。
4. **Safebox 是列表和自动找空位。** 当前 deposit 使用下一个空位,缺少 45 格网格、分页、指定
目标格、仓库内移动、金币存取、密码修改、重试限制、完整错误状态和原版互斥规则。
见 [`safebox_ui.gd:7`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/safebox_ui.gd:7)。
5. **交易缺少事务状态。** 当前是两列文字列表,没有锁定、接受后修改自动取消接受、物品占用、
服务器回滚、交易失败恢复、对方物品变化提示和交易中的背包锁定。
6. **NPC 商店仍是基础列表。** 缺少物品格、图标、购买确认、货币类型、库存限制、busy 状态、
精确错误提示和对应音效。
7. **私人商店是兼容实现。** 虽然 `CG_MYSHOP` 已能发送,但原版库存模型、拖放、价格编辑、
开店前检查、库存锁定、撤下回滚和其他玩家查看流程尚未完整迁移。
8. **快捷栏类型映射和键位语义不完整。** 参考端可执行类型只有 `INVENTORY(1)``SKILL(2)`
`EMOTION(3)`,没有“命令类型”;当前 `m2_client.cpp` 仍把 3/4 映射为 command/emotion
与参考端不兼容。当前还缺表情执行、物品拖入、鼠标物品态、特殊技能冷却、死亡 / 变身 /
换武器后的完整刷新。见 [`m2_client.cpp:878`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/m2_client.cpp:878)。
9. **组队状态是近似定义。** affect 标签仍有暂定映射,队伍技能冷却、队长权限动态变化、离线、
死亡、距离状态和多账号行为还未完成真实验收。
10. **好友、情侣、公会只有首版状态展示。** Messenger 分组、上线/离线、私聊生命周期、情侣
交互、公会权限、公告、日志、成员管理、公会战应答、领地和公会建筑均不完整或暂缓。
### 5. P1:任务、NPC 和世界玩法
1. **Quest EventManager 只支持少量标签。** 当前支持 `ENTER``CLEAR``NEXT``DONE`
`QUESTION``INPUT``SELECT_ITEM``LETTER`,但 `COLOR``DELAY``WAIT``IMAGE`
立绘、样式、滚动、多阶段按钮和 `CONFIRM_WAIT` 等被忽略。见
[`quest_dialog.gd:102`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/quest_dialog.gd:102)。
2. **任务日志不是完整 Quest 系统。** 当前主要是标题、counter 和 clock 文字列表,缺少真实
图标、开始/更新/删除状态、倒计时、QuestButton、世界箭头、边缘指示和点击定位。
3. **钓鱼、副本、坐骑和观战者只有部分协议入口。** 仍缺鱼获落袋、副本入口和计时、DungeonBlock、
坐骑模型与动作、坐骑攻击限制和速度、观战视角以及换图后的完整清理。
4. **PVP、PK 和决斗没有完整规则。** 当前有部分关系和字段,但缺 PK 模式上行、名字颜色、
可攻击判断、阵营限制、保护区、目标板、挑战/复仇和禁止攻击逻辑。
### 6. P1/P2:模型、动画和特效
1. **本地角色先显示胶囊占位体。** GameScene 初始化阶段先创建 Capsule,真实角色稍后加载,
会造成模型闪烁、相机目标高度变化、碰撞体和阴影重新挂接。见
[`game_scene.gd:136`](/Users/shenlei/Work/mt/mtgodot-poc/project/game_scene.gd:136)、
[`game_scene.gd:811`](/Users/shenlei/Work/mt/mtgodot-poc/project/game_scene.gd:811)。
2. **SpeedTree 仍是程序化代理。** 当前只嗅探 bark/composite 贴图,叶簇 UV 和枝干使用 proxy,
没有真实 `.spt` 几何、leaf table、LOD、impostor、风参数和原始阴影。见
[`tree_placeholder.cpp:190`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/tree_placeholder.cpp:190)、
[`PARITY-GAP.md:77`](/Users/shenlei/Work/mt/mtgodot-poc/docs/PARITY-GAP.md:77)。
3. **EffectLib 只有粒子近似。** `.mse` Particle 已映射到 `GPUParticles3D`,但 `.dds` 纹理被
程序化渐变代替,`.mde` 被 box 代替,SimpleLight、MovingType、TexAni、骨骼挂点和完整时间轴
尚未实现。见 [`effect_player.gd:8`](/Users/shenlei/Work/mt/mtgodot-poc/project/fx/effect_player.gd:8)、
[`effect_player.gd:134`](/Users/shenlei/Work/mt/mtgodot-poc/project/fx/effect_player.gd:134)。
4. **GR2 运行时不是完整 ActorInstance。** 缺 race/weapon/sex motion mode、loop/interrupt/
accumulation、action reservation、mount motion、武器轨迹、击退、倒地、起身和完整死亡动作。
参考:[`ClientVS22/ActorInstanceMotion.cpp`](/Users/shenlei/Work/mt/40250/ClientVS22/source/GameLib/ActorInstanceMotion.cpp:1)。
5. **武器和特效骨骼挂点不完整。** 当前主要覆盖右手武器和部分盾牌,缺双持、左手武器、职业挂点、
武器拖尾、骨骼特效、坐骑挂点、轨迹碰撞和装备切换清理。
6. **材质是固定功能近似。** 缺精确 alpha-test ref、多贴图 stage、color operation、透明排序、
角色专属光照和逐 surface 状态;sphere-map 默认 `spec_power = 0`,没有完整由装备数据驱动。
见 [`m2_material.h:39`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/m2_material.h:39)。
### 7. P1/P2:地图、环境和相机
1. **静态地图只完整接入 Building / DungeonBlock。** Effect、Ambience、地图机关、透明物排序、
portal/area、对象脚本状态和对象 LOD 没有完整迁移。见
[`metin2_world.cpp:367`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/metin2_world.cpp:367)。
2. **Pack 资源读取没有统一。** 虽然 `AssetSource`/`PackMount` 已存在,World、terrain、tree、
water 和 static object 仍有直接 `AssetResolver::resolve()` 的路径,散文件和 `.epk/.eix` 运行
结果可能不同。见 [`metin2_world.cpp:380`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/metin2_world.cpp:380)、
[`terrain_splat.cpp:133`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/terrain_splat.cpp:133)。
3. **地形 splat 虽已支持 16 层,仍非完整固定功能复现。** alpha 含义、texture wrap/filter、
多 stage、patch geometry LOD、skirt、patch 碰撞和 baked shadow mask 叠加规则仍未完成。
4. **水面是序列纹理加近似深度。** 反射、折射、岸线泡沫、法线、精确 blend/filter/wrap、透明
排序和水深标定仍缺。当前浅水透明度已被项目文档标记为未校准。
5. **`.msenv` 只完成基础天空、雾和光照。** 云层实际渲染、环境循环音、lens flare、日夜插值、
天气切换、颜色曲线和原始 ambient/diffuse/specular 关系没有完整实现。
6. **相机不是固定基线机位。** 当前依赖脚本中的 FOV、yaw、pitch、距离和 AABB 射线,缺
`CameraProcedure` 等价模式、灵敏度持久化、平滑碰撞、树木遮挡淡出、目标 Actor 状态和可回放
的受控机位。见 [`PARITY-GAP.md:195`](/Users/shenlei/Work/mt/mtgodot-poc/docs/PARITY-GAP.md:195)。
### 8. P1/P2UI、输入和本地化
1. **uiscript 可解析不等于控件语义完成。** 当前约 80 个脚本可解析,但 `ani_image``mark`
真实 grid/list/scroll、tooltip、item cursor、focus、anchor、modal、layer 和完整 nine-patch
语义仍缺。
2. **大量窗口是手工 Panel。** 交易、仓库、商店、公会、任务、好友、聊天和系统窗口仍大量使用
固定坐标和固定尺寸,没有完整复现原 `Interface.MakeInterface()``.sub` 资源、字体描边、
rect/anchor/z-index 和分辨率缩放。
3. **帮助窗口未实现。** ESC 菜单中的帮助按钮仍只显示“帮助窗暂未实现”。见
[`system_menu_ui.gd:91`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/system_menu_ui.gd:91)。
4. **系统设置部分只是保存数值。** CPU/GPU tiling 不会改变实际渲染,BGM 更换按钮 disabled
名字颜色、伤害数字和部分画质设置没有完整接入渲染侧。见
[`system_option_ui.gd:143`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/system_option_ui.gd:143)。
5. **鼠标物品态和光标状态缺失。** Talk、Attack、Buy、Sell、Pick、NPC 交互光标、鼠标跟随物品、
拖放取消和窗口焦点屏蔽规则没有完成。
6. **键位实现与参考端不一致。** 当前已接 WASD、点地、相机拖动、滚轮、触屏、F1–F4 和数字键,
但当前把 F1–F4 用作切页、数字 1–9 全部激活快捷栏;参考端是数字 1–4 激活本地槽 0–3、
Ctrl+1–9 发放表情、F1–F4 激活本地槽 4–7。另缺空格连续攻击、显示名字、鼠标物品态、窗口
上下文屏蔽和正式截图。键位重绑定、冲突检测及独立相机灵敏度设置不是 ClientVS22 已有能力,
不应伪装成参考端缺失项。
7. **CJK 输入不等价。** 当前使用 Godot `LineEdit`,没有 `PythonIME` 候选窗、composition、
候选确认、输入法切换、特殊文本框行为、复数/性别规则和自动字体切换。
8. **聊天和头顶信息不完整。** 缺 notice/big notice、经验和金币日志、私聊窗口生命周期、历史
滚动、频道过滤、InsultChecker、empire 名字颜色、称号、公会名、PK 颜色、目标框和屏幕空间血条。
9. **任务日志图标仍是占位。** 当前任务列表主要显示文字,真实 icon、状态变化、倒计时和任务导航
尚未接入。见 [`quest_log.gd:8`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/quest_log.gd:8)。
### 9. P1/P2:音频和应用生命周期
1. **地图 BGM 字段被解析但没有消费。** `GC_MAIN_CHARACTER3_BGM`
`GC_MAIN_CHARACTER4_BGM_VOL` 中的 BGM 名称、音量被丢弃,当前主要靠登录页和选人页固定调用
`play_bgm()`,没有按地图和服务端状态自动切换。见
[`classic_parser.cpp:173`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/classic/classic_parser.cpp:173)。
2. **后台恢复后可能没有 BGM。** 后台时 `AppLifecycle` 调用 `stop_bgm()`,回前台只恢复客户端和
帧率,没有恢复上一次曲目;`stop_bgm()` 还清空了 `_bgm_name`。见
[`app_lifecycle.gd:60`](/Users/shenlei/Work/mt/mtgodot-poc/project/app_lifecycle.gd:60)、
[`app_lifecycle.gd:75`](/Users/shenlei/Work/mt/mtgodot-poc/project/app_lifecycle.gd:75)。
3. **生命周期所有权重复。** `M2Client` 自己处理通知,同时 GameScene 又创建 `AppLifecycle` 绑定
同一个客户端,暂停、恢复、内存警告和关闭的责任边界不清晰;目前即使部分操作幂等,也没有
明确的单一生命周期协调者。
4. **MilesLib 事件模型没有迁移。** 当前只有 BGM crossfade、UI 音效池和通用 3D 音效,缺少
ambience、character sound、sound scale、精确距离衰减、fade/stop 策略、武器/技能/怪物/NPC
动作事件和 `.mss` 支持。参考:[`ClientVS22/MilesLib/SoundManager.h`](/Users/shenlei/Work/mt/40250/ClientVS22/source/MilesLib/SoundManager.h:1)。
### 10. 发布和验证问题
1. **Mac App 不是自包含资源包。** 最近构建产物中的 `assets``bgm` 仍是指向工作区的绝对
符号链接,只复制 `.app` 到另一台 Mac 不能保证资源可用。
2. **主程序和 GDExtension 架构不一致。** App 主程序是 Universal,但 GDExtension 只有 arm64
Intel Mac 可能无法加载核心扩展。
3. **当前是 Ad-hoc 签名,不是正式分发签名。** 没有 Developer ID、完整 hardened runtime、
notarization 和 stapled ticket,不能直接当作面向用户的正式安装包。
4. **发布构建仍包含 Demo 默认账号密码。** `AppFlow` 中默认值是 `admin / 123456789`
不能保留在正式客户端。见 [`app_flow.gd:32`](/Users/shenlei/Work/mt/mtgodot-poc/project/app_flow.gd:32)。
5. **补丁器、Logo、Movie、Web、Discord 和反作弊策略没有形成发布决策。** 当前只是“能启动
并进游戏”的工程入口,不是完整的 ClientVS22 发布外壳。
6. **缺少真实验收门禁。** 当前已有单元测试、loopback 和部分单会话验证,但仍缺:完整 40250
真服矩阵、双账号交易/组队/PVP、断线重连、跨服换图、纯 pack 运行、Intel Mac、受控机位
截图和移动端真机性能验证。
### 当前结论
当前版本应继续定义为:**可登录、可进入地图、具备部分玩法和表现的核心功能纵切**;
连接骨架和换图清理已经开始实现,但尚未完成真实双连接、换图和完整生命周期验收。
它还不能定义为:**ClientVS22 的跨平台等价客户端**。
当前已完成 G0 公共接口的首版冻结,正在进入附录 C 的并行波 A;已完成项与剩余项以文档顶部的
增量 47/48 和 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md)“实施进度 / 进度看板”为准。后续仍需要完成以下闭环:
1. 完成 Classic 直连/Auth/Warp 的真实双连接验收,并补齐阶段清理和回登录后的完整生命周期;
2. 未知 VID、VID 重用、事件缓存和完整实体生命周期;
3. 真实 Actor 战斗状态机、动作命中时序和技能合法性;
4. 物品拖放、装备规则、Safebox、交易和快捷栏事务状态;
5. 所有资源调用统一切换到 `AssetSource`,并验证纯 pack 运行;
6.`ClientVS22` 的 UI、EffectLib、SpeedTree、MilesLib 模块建立逐项验收;
7. 完成受控地图/机位/角色/时间/天气/分辨率契约,再进行截图签核;
8. 完成 Mac 自包含资源、双架构扩展、正式签名和 notarization。
+21 -8
View File
@@ -23,6 +23,8 @@ void ClassicParser::parse_login_success(const SimplePlayer *players, const uint3
s.play_minutes = sp.play_minutes;
s.x = sp.x;
s.y = sp.y;
s.addr = sp.addr;
s.port = sp.port;
s.main_part = sp.main_part;
s.hair_part = sp.hair_part;
s.skill_group = sp.skill_group;
@@ -37,6 +39,7 @@ void ClassicParser::parse_login_success(const SimplePlayer *players, const uint3
}
bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
m_last_error.clear();
switch (header) {
case HDR_GC_LOGIN_SUCCESS_NEWSLOT: { // 32 — what 40250 actually sends
GCLoginSuccess p;
@@ -74,10 +77,12 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
case HDR_GC_XTRAP_CS1_REQUEST:
case HDR_GC_PANAMA_PACK:
// These packets belong to optional account/anti-cheat integrations.
// Their static sizes are still registered in wire_classic.h so they
// cannot desynchronise the game stream; this client has no matching
// Godot UI or vendor runtime to act on them.
return true;
// Their static sizes are still registered in wire_classic.h, but this
// client has no matching Godot UI or vendor runtime to act on them.
m_last_error = "phase " + m_phase_name + " does not handle GC header " +
std::to_string(header);
m_unhandled_headers.push_back(header);
return false;
case HDR_GC_CHARACTER_CREATE_SUCCESS: {
GCPlayerCreateSuccess p;
if (!fill(p, header, body, len)) {
@@ -178,6 +183,9 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
m_world.mut_spawn_main(p.vid, p.race,
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
(float)p.z);
// §9.1: route the map's background-music track; volume unspecified.
m_world.mut_map_bgm(
std::string(p.bgm_name, strnlen(p.bgm_name, sizeof(p.bgm_name))), -1.0f);
return true;
}
case HDR_GC_MAIN_CHARACTER4_BGM_VOL: {
@@ -188,6 +196,10 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
m_world.mut_spawn_main(p.vid, p.race,
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
(float)p.z);
// §9.1: track + server-authored volume (0..1).
m_world.mut_map_bgm(
std::string(p.bgm_name, strnlen(p.bgm_name, sizeof(p.bgm_name))),
p.bgm_volume);
return true;
}
case HDR_GC_CHARACTER_ADD: { // 1
@@ -1244,7 +1256,7 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (!fill(packet, header, body, len)) {
return false;
}
m_world.mut_warp(packet.x, packet.y, packet.addr, packet.port);
m_world.mut_warp(packet.x, packet.y, packet.addr, packet.port, true);
return true;
}
case HDR_GC_TIME: {
@@ -1400,9 +1412,10 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
}
default:
// not handled yet — harmless, keep going. classic_stream already
// rejected unknown *static* headers via the size table.
return true;
m_last_error = "phase " + m_phase_name + " does not handle GC header " +
std::to_string(header);
m_unhandled_headers.push_back(header);
return false;
}
}
@@ -15,6 +15,7 @@
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
namespace mtnet::classic {
@@ -27,6 +28,8 @@ struct CharSlot {
uint8_t st = 0, ht = 0, dx = 0, iq = 0;
uint32_t play_minutes = 0;
int32_t x = 0, y = 0;
int32_t addr = 0;
uint16_t port = 0;
uint16_t main_part = 0, hair_part = 0;
uint8_t skill_group = 0;
// Server requires this character to be renamed before entering the game.
@@ -62,6 +65,13 @@ public:
const std::string &login_failure() const { return m_login_failure; }
uint32_t login_key() const { return m_login_key; } // GC_LOGIN_KEY (auth path)
uint8_t empire() const { return m_empire; }
void set_phase_name(const std::string &name) { m_phase_name = name; }
const std::string &last_error() const { return m_last_error; }
std::vector<uint8_t> drain_unhandled_headers() {
auto v = std::move(m_unhandled_headers);
m_unhandled_headers.clear();
return v;
}
std::vector<CharEvent> drain_char_events() {
auto v = std::move(m_char_events);
m_char_events.clear();
@@ -93,6 +103,10 @@ private:
uint32_t rkey);
EntityStore &m_world;
// §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
// combined record via m_world.mut_spawn_full(). W1 owns the merge logic.
std::unordered_map<uint32_t, Entity> m_pending_actor;
std::vector<CharSlot> m_slots;
int m_slot_count = 0;
uint32_t m_handle = 0;
@@ -104,6 +118,9 @@ private:
int m_guild_make_requests = 0;
bool m_char_list_ready = false;
std::string m_login_failure;
std::string m_phase_name = "Offline";
std::string m_last_error;
std::vector<uint8_t> m_unhandled_headers;
};
} // namespace mtnet::classic
+176 -21
View File
@@ -6,6 +6,16 @@
namespace mtnet::classic {
namespace {
std::string ipv4_from_slot_addr(int32_t addr) {
const uint32_t value = static_cast<uint32_t>(addr);
return std::to_string(value & 0xffu) + "." +
std::to_string((value >> 8) & 0xffu) + "." +
std::to_string((value >> 16) & 0xffu) + "." +
std::to_string((value >> 24) & 0xffu);
}
} // namespace
ClassicSession::ClassicSession() : m_parser(m_world) {
m_stream.on_phase = [this](uint8_t p) { on_phase(p); };
m_stream.on_packet = [this](uint8_t h, const uint8_t *b, uint32_t l) {
@@ -22,6 +32,9 @@ ClassicSession::ClassicSession() : m_parser(m_world) {
}
};
m_stream.on_state_change = [this](ClassicStream::State s) {
if (s == ClassicStream::State::Online && m_game_started) {
m_game_connection_seen = true;
}
if (s == ClassicStream::State::Offline && !m_auth_transition &&
m_stage != Stage::Failed &&
m_stage != Stage::Offline) {
@@ -61,6 +74,17 @@ void ClassicSession::set_stage(Stage s) {
return;
}
m_stage = s;
const char *phase = "Offline";
switch (s) {
case Stage::Connecting: phase = "HandShake"; break;
case Stage::LoggingIn: phase = "Login"; break;
case Stage::CharSelect: phase = "Select"; break;
case Stage::Loading: phase = "Loading"; break;
case Stage::InGame: phase = "Game"; break;
case Stage::Failed: phase = "Failed"; break;
case Stage::Offline: break;
}
m_parser.set_phase_name(phase);
if (on_stage_change) {
on_stage_change(s);
}
@@ -78,6 +102,10 @@ void ClassicSession::start_offline(const std::string &id, const std::string &pw)
m_login_sent = false;
m_entergame_sent = false;
m_version_sent = false;
m_game_connection_seen = false;
m_direct_enter = false;
m_direct_enter_slot = -1;
m_selected_slot = -1;
m_last_error.clear();
// During PHASE_HANDSHAKE even retry handshakes must be answered with
// 0xFF. The normal game client switches to CG_TIME_SYNC only after the
@@ -145,6 +173,82 @@ bool ClassicSession::connect_with_login_key(const std::string &game_host, uint16
return true;
}
bool ClassicSession::connect_direct_enter(int slot) {
if (slot < 0 || slot >= static_cast<int>(m_parser.char_slots().size())) {
m_last_error = "direct enter slot out of range";
return false;
}
const CharSlot &character = m_parser.char_slots()[slot];
if (character.id == 0 || character.addr == 0 || character.port == 0 ||
m_auth_login_key == 0) {
m_last_error = "direct enter slot has no game address or login key";
return false;
}
m_auth_transition = true;
m_stream.disconnect();
m_auth_transition = false;
m_use_auth = false;
m_auth_succeeded = true;
m_game_started = true;
m_authenticated_game = true;
m_direct_enter = true;
m_direct_enter_slot = slot;
m_selected_slot = slot;
m_game_host = ipv4_from_slot_addr(character.addr);
m_game_port = character.port;
m_login_sent = false;
m_entergame_sent = false;
m_version_sent = false;
m_stream.set_time_sync_mode(false);
m_auth_transition = true;
const bool connected = m_stream.connect(m_game_host, m_game_port);
m_auth_transition = false;
if (!connected) {
m_last_error = m_stream.last_error();
set_stage(Stage::Failed);
return false;
}
set_stage(Stage::Connecting);
return true;
}
bool ClassicSession::connect_warp(const std::string &game_host, uint16_t game_port) {
if (m_selected_slot < 0 || m_auth_login_key == 0 || game_host.empty() || game_port == 0) {
m_last_error = "warp has no selected character, login key, or game address";
return false;
}
// Keep the same ClassicSession so the login ticket and the selected slot
// survive the reconnect. This is the important distinction from the old
// warp_to_game_server() path, which rebuilt the parser and lost DirectEnter.
m_auth_transition = true;
m_stream.disconnect();
m_auth_transition = false;
m_use_auth = false;
m_auth_succeeded = true;
m_game_started = true;
m_authenticated_game = true;
m_direct_enter = true;
m_direct_enter_slot = m_selected_slot;
m_game_host = game_host;
m_game_port = game_port;
m_login_sent = false;
m_entergame_sent = false;
m_version_sent = false;
m_stream.set_time_sync_mode(false);
m_auth_transition = true;
const bool connected = m_stream.connect(m_game_host, m_game_port);
m_auth_transition = false;
if (!connected) {
m_last_error = m_stream.last_error();
set_stage(Stage::Failed);
return false;
}
set_stage(Stage::Connecting);
return true;
}
void ClassicSession::disconnect() {
m_auth_transition = true;
m_auth_stream.disconnect();
@@ -184,18 +288,21 @@ void ClassicSession::pump() {
// sending it too early makes the server drop us mid spawn-burst).
if (m_stage == Stage::Loading && !m_entergame_sent && m_entergame_delay > 0 &&
m_now - m_loading_since >= m_entergame_delay) {
enter_game();
if (!enter_game()) {
m_last_error = m_stream.last_error().empty() ? "failed to send CG_ENTERGAME" :
m_stream.last_error();
set_stage(Stage::Failed);
}
}
m_world.tick();
}
void ClassicSession::send_login() {
bool ClassicSession::send_login() {
if (m_login_sent) {
return;
return true;
}
if (m_authenticated_game) {
send_login_by_key();
return;
return send_login_by_key();
}
// docs §2.2: the client turns sequence mode on around here; CG_LOGIN is a
// bSeq packet and the server expects the trailing byte from packet #1.
@@ -205,13 +312,17 @@ void ClassicSession::send_login() {
p.header = HDR_CG_LOGIN;
std::strncpy(p.login, m_id.c_str(), sizeof(p.login) - 1);
std::strncpy(p.passwd, m_pw.c_str(), sizeof(p.passwd) - 1);
m_stream.send_fixed(&p, sizeof(p));
if (!m_stream.send_fixed(&p, sizeof(p))) {
m_last_error = "failed to send CG_LOGIN";
return false;
}
m_login_sent = true;
return true;
}
void ClassicSession::send_auth_login() {
bool ClassicSession::send_auth_login() {
if (m_auth_login_sent) {
return;
return true;
}
m_auth_stream.set_sequence_mode(true);
CGLogin3 p{};
@@ -220,11 +331,15 @@ void ClassicSession::send_auth_login() {
std::strncpy(p.passwd, m_pw.c_str(), sizeof(p.passwd) - 1);
// The stock connector sends four client keys here. Zero is valid for this
// client, provided the same values are used in the later CG_LOGIN2 packet.
m_auth_stream.send_fixed(&p, sizeof(p));
if (!m_auth_stream.send_fixed(&p, sizeof(p))) {
m_last_error = "failed to send CG_LOGIN3";
return false;
}
m_auth_login_sent = true;
return true;
}
void ClassicSession::send_login_by_key() {
bool ClassicSession::send_login_by_key() {
// The game side must use the ticket returned by the auth server; sending
// CG_LOGIN here would hit the server's locale/version gate.
m_stream.set_sequence_mode(true);
@@ -232,14 +347,20 @@ void ClassicSession::send_login_by_key() {
p.header = HDR_CG_LOGIN2;
std::strncpy(p.login, m_id.c_str(), sizeof(p.login) - 1);
p.login_key = m_auth_login_key;
m_stream.send_fixed(&p, sizeof(p));
if (!m_stream.send_fixed(&p, sizeof(p))) {
m_last_error = "failed to send CG_LOGIN2";
return false;
}
m_login_sent = true;
return true;
}
void ClassicSession::on_auth_phase(uint8_t phase) {
if (phase == PHASE_AUTH) {
set_stage(Stage::LoggingIn);
send_auth_login();
if (!send_auth_login()) {
set_stage(Stage::Failed);
}
} else if (phase == PHASE_CLOSE) {
m_last_error = "auth server closed phase";
set_stage(Stage::Failed);
@@ -276,8 +397,16 @@ bool ClassicSession::on_auth_packet(uint8_t header, const uint8_t *body, uint32_
set_stage(Stage::Failed);
return false;
}
// Optional Panama/hybrid-crypt packets are already framed and can be
// ignored by this asset-loader client.
if (header == HDR_GC_AUTH_SUCCESS_OPENID || header == HDR_GC_MATRIX_CARD ||
header == HDR_GC_RUNUP_MATRIX_QUIZ || header == HDR_GC_REQUEST_PASSPOD ||
header == HDR_GC_REQUEST_PASSPOD_FAILED || header == HDR_GC_HS_REQUEST ||
header == HDR_GC_XTRAP_CS1_REQUEST || header == HDR_GC_PANAMA_PACK ||
header == HDR_GC_HYBRIDCRYPT_KEYS || header == HDR_GC_HYBRIDCRYPT_SDB) {
m_last_error = "auth phase does not handle GC header " + std::to_string(header);
return false;
}
// Optional hybrid-crypt packets are deliberately unsupported as well; the
// stream still frames them, then surfaces the unsupported integration.
return true;
}
@@ -357,16 +486,27 @@ void ClassicSession::on_phase(uint8_t phase) {
switch (phase) {
case PHASE_LOGIN:
set_stage(Stage::LoggingIn);
send_login();
if (!send_login()) {
set_stage(Stage::Failed);
}
break;
case PHASE_SELECT:
if (m_direct_enter) {
set_stage(Stage::Loading);
if (!send_select_char(m_direct_enter_slot)) {
set_stage(Stage::Failed);
}
} else {
set_stage(Stage::CharSelect);
if (m_parser.char_list_ready() && on_char_list) {
}
if (!m_direct_enter && m_parser.char_list_ready() && on_char_list) {
on_char_list(m_parser.char_slots());
}
break;
case PHASE_LOADING:
set_stage(Stage::Loading);
m_direct_enter = false;
m_direct_enter_slot = -1;
m_loading_since = m_now;
m_entergame_sent = false;
break;
@@ -387,12 +527,15 @@ void ClassicSession::on_phase(uint8_t phase) {
bool ClassicSession::on_packet(uint8_t header, const uint8_t *body, uint32_t len) {
if (!m_parser.on_gc(header, body, len)) {
m_last_error = "parse error on GC header " + std::to_string(header);
m_last_error = m_parser.last_error().empty() ?
"parse error on GC header " + std::to_string(header) : m_parser.last_error();
m_stream.set_last_error(m_last_error);
return false;
}
if ((header == HDR_GC_MAIN_CHARACTER || header == HDR_GC_MAIN_CHARACTER3_BGM ||
header == HDR_GC_MAIN_CHARACTER4_BGM_VOL) && !send_client_version()) {
m_last_error = "failed to send client version";
m_stream.set_last_error(m_last_error);
return false;
}
if (!m_parser.login_failure().empty() && m_stage == Stage::LoggingIn) {
@@ -409,14 +552,24 @@ bool ClassicSession::select_char(int slot) {
if (m_stage != Stage::CharSelect) {
return false;
}
if (slot < 0 || slot >= m_parser.slot_count()) {
return false;
}
if (!send_select_char(slot)) {
return false;
}
m_selected_slot = slot;
return true;
}
bool ClassicSession::send_select_char(int slot) {
if (slot < 0 || slot >= m_parser.slot_count()) {
return false;
}
CGPlayerSelect p{};
p.header = HDR_CG_CHARACTER_SELECT;
p.player_index = static_cast<uint8_t>(slot);
m_stream.send_fixed(&p, sizeof(p));
return true;
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::enter_game() {
@@ -425,9 +578,11 @@ bool ClassicSession::enter_game() {
}
CGEnterGame p{};
p.header = HDR_CG_ENTERGAME;
m_stream.send_fixed(&p, sizeof(p));
if (!m_stream.send_fixed(&p, sizeof(p))) {
return false;
}
m_entergame_sent = true;
return true;
return m_stream.flush_internal();
}
// --- in-game intents ---------------------------------------------------------
+15 -3
View File
@@ -37,6 +37,12 @@ public:
void disconnect() override;
bool select_char(int slot) override;
bool enter_game() override;
// ClientVS22 DirectEnter: reconnect to the slot's lAddr:wPort, then the
// loading phase sends CG_CHARACTER_SELECT on the new game connection.
bool connect_direct_enter(int slot);
// ClientVS22 GC_WARP: every warp is a new game connection to the address
// carried by the packet. The selected slot and login ticket are retained.
bool connect_warp(const std::string &game_host, uint16_t game_port);
bool create_character(int slot, const std::string &name, int job, int shape,
int con, int intel, int str, int dex);
bool delete_character(int slot, const std::string &private_code);
@@ -143,6 +149,7 @@ public:
ClassicStream &auth_stream() { return m_auth_stream; } // auth-flow tests / trace
ClassicParser &parser() { return m_parser; }
uint32_t login_key() const { return m_auth_login_key; }
bool was_online_lost() const { return m_game_connection_seen; }
uint32_t now_ms() const { return m_now; }
void set_now(uint32_t ms) {
m_now = ms;
@@ -163,10 +170,11 @@ private:
void on_auth_phase(uint8_t phase);
bool on_packet(uint8_t header, const uint8_t *body, uint32_t len);
bool on_auth_packet(uint8_t header, const uint8_t *body, uint32_t len);
void send_login();
void send_auth_login();
void send_login_by_key();
bool send_login();
bool send_auth_login();
bool send_login_by_key();
bool send_client_version();
bool send_select_char(int slot);
EntityStore m_world;
ClassicStream m_stream;
@@ -192,6 +200,10 @@ private:
bool m_authenticated_game = false;
bool m_auth_transition = false;
bool m_version_sent = false;
bool m_game_connection_seen = false;
bool m_direct_enter = false;
int m_direct_enter_slot = -1;
int m_selected_slot = -1;
};
} // namespace mtnet::classic
+27 -36
View File
@@ -29,16 +29,6 @@ void set_nonblocking(int fd) {
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
bool plausible_character_add(const uint8_t *p, size_t available) {
if (available < sizeof(GCCharacterAdd) || p[0] != HDR_GC_CHARACTER_ADD) {
return false;
}
GCCharacterAdd packet{};
std::memcpy(&packet, p, sizeof(packet));
return packet.vid != 0 && packet.race != 0 && packet.type <= 8 &&
std::isfinite(packet.angle) && std::fabs(packet.angle) <= 360.0f &&
packet.moving_speed <= 255 && packet.attack_speed <= 255;
}
} // namespace
ClassicStream::~ClassicStream() {
@@ -114,6 +104,11 @@ void ClassicStream::discard_recv(size_t n) {
m_recv.discard(n);
}
void ClassicStream::remember_packet(uint8_t header) {
m_last_pkt[0] = m_last_pkt[1];
m_last_pkt[1] = header;
}
void ClassicStream::decrypt_appended(size_t n) {
if (n == 0 || !m_cipher.activated()) {
return;
@@ -209,6 +204,10 @@ size_t ClassicStream::take_outgoing(void *dst, size_t cap) {
return n;
}
bool ClassicStream::flush_internal() {
return m_sock < 0 || flush_send();
}
void ClassicStream::append_sequence_if_needed(uint8_t header) {
if (!m_seq_on || !is_sequence_cg(header)) {
return;
@@ -422,6 +421,7 @@ void ClassicStream::dispatch() {
return;
}
if (consumed) {
remember_packet(header);
continue;
}
// control handler said "need more bytes" for a control header it owns?
@@ -488,41 +488,25 @@ void ClassicStream::dispatch() {
bool ok = !on_packet || on_packet(header, body, body_len);
discard_recv(body_len);
if (!ok) {
if (m_last_error.empty()) {
m_last_error = "packet handler rejected GC header " + std::to_string(header);
}
if (on_error) {
on_error(m_last_error);
}
disconnect();
return;
}
remember_packet(header);
continue;
}
// --- static-size GC packet --- (packet_size_gc = FULL size incl. header)
int total = packet_size_gc(header);
if (total <= 0) {
m_last_error = "unknown/unsupported GC header " + std::to_string(header);
// Some 40250 deployments append one legacy entity packet variant that
// is not present in the checked-in header map. It has no length field,
// but the following normal GC_CHARACTER_ADD stream is self-describing.
// During PHASE_LOADING, recover only at a strongly validated character
// boundary; this keeps a deployment-specific spawn packet from aborting
// the whole login while leaving malformed encrypted data fatal elsewhere.
if (m_phase == PHASE_LOADING) {
const uint8_t *bytes = m_recv.read_ptr();
bool recovered = false;
for (size_t i = 1; i + sizeof(GCCharacterAdd) <= m_recv.readable(); ++i) {
if (!plausible_character_add(bytes + i, m_recv.readable() - i)) {
continue;
}
if (m_trace) {
std::fprintf(stderr, "[classic] recovering loading stream at +%zu\n", i);
}
discard_recv(i);
m_last_error.clear();
recovered = true;
break;
}
if (recovered) {
continue;
}
}
m_last_error = "unknown GC header " + std::to_string(header) +
" (last: " + std::to_string(m_last_pkt[0]) + "," +
std::to_string(m_last_pkt[1]) + ")";
if (on_error) {
on_error(m_last_error);
}
@@ -539,9 +523,16 @@ void ClassicStream::dispatch() {
bool ok = !on_packet || on_packet(header, p + 1, static_cast<uint32_t>(total - 1));
discard_recv(static_cast<size_t>(total));
if (!ok) {
if (m_last_error.empty()) {
m_last_error = "packet handler rejected GC header " + std::to_string(header);
}
if (on_error) {
on_error(m_last_error);
}
disconnect();
return;
}
remember_packet(header);
}
}
@@ -66,6 +66,7 @@ public:
State state() const { return m_state; }
bool is_online() const { return m_state == State::Online; }
const std::string &last_error() const { return m_last_error; }
void set_last_error(const std::string &error) { m_last_error = error; }
// --- socket-free framing (tests / alternative transports) ---
void feed(const void *data, size_t n); // append inbound bytes, run dispatch
@@ -78,6 +79,8 @@ public:
bool send_fixed(const void *struct_bytes, size_t n);
// Caller supplies the full dynamic packet incl. [header][uint16 size][...].
bool send_dynamic(const void *bytes, size_t n);
// Flush immediately for a real socket. Socket-free tests retain queued bytes.
bool flush_internal();
// docs §2.2: turn on once the client would call net.SetPacketSequenceMode()
// (LOGIN phase onward). Off during pure HANDSHAKE.
@@ -125,10 +128,12 @@ private:
bool m_time_sync_mode = false;
bool m_trace = false;
uint8_t m_phase = PHASE_HANDSHAKE;
uint8_t m_last_pkt[2] = {0, 0};
// server clock offset from the last handshake exchange (informational).
uint32_t m_server_time_base = 0;
uint32_t m_client_time_base = 0;
void remember_packet(uint8_t header);
};
} // namespace mtnet::classic
+153 -3
View File
@@ -1,5 +1,6 @@
#include "entity_store.h"
#include <algorithm>
#include <cstring>
#include <utility>
@@ -111,6 +112,53 @@ void EntityStore::mut_spawn_main(uint32_t vid, uint16_t race, const std::string
}
}
void EntityStore::mut_spawn_full(const Entity &src) {
bool created;
Entity &e = touch(src.vid, created);
// Preserve the live interpolation state if the entity is already moving and
// the caller did not carry an explicit move (spawn packets never do).
const bool was_moving = e.moving;
const float keep_x = e.x, keep_y = e.y, keep_z = e.z;
e = src;
e.vid = src.vid;
if (was_moving && !src.moving) {
e.moving = true;
e.x = keep_x;
e.y = keep_y;
e.z = keep_z;
}
if (e.is_main) {
m_main_vid = e.vid;
}
m_changes.push_back({ChangeKind::Spawn, src.vid});
}
void EntityStore::mut_ownership(uint32_t vid, uint32_t owner_vid) {
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
return;
}
if (it->second.owner_vid != owner_vid) {
it->second.owner_vid = owner_vid;
m_dirty.push_back(vid);
}
}
void EntityStore::mut_map_bgm(const std::string &name, float volume) {
bool changed = false;
if (name != m_bgm_name) {
m_bgm_name = name;
changed = true;
}
if (volume >= 0.0f && volume != m_bgm_volume) {
m_bgm_volume = volume;
changed = true;
}
if (changed) {
m_bgm_dirty = true;
}
}
void EntityStore::mut_despawn(uint32_t vid) {
if (m_ents.erase(vid)) {
m_changes.push_back({ChangeKind::Despawn, vid});
@@ -474,7 +522,9 @@ void EntityStore::mut_char_info(uint32_t vid, const std::string &name, const uin
e.mount_vnum = mount_vnum;
m_mount_changes.push_back(vid);
}
(void)empire;
if (empire) {
e.empire = empire;
}
if (!created) {
m_changes.push_back({ChangeKind::Info, vid});
}
@@ -685,8 +735,108 @@ void EntityStore::mut_quest_info(uint16_t index, uint8_t flag, bool begin, const
m_quest_changes.push_back(index);
}
void EntityStore::mut_warp(int32_t x, int32_t y, int32_t addr, uint16_t port) {
m_warps.push_back({x, y, addr, port});
void EntityStore::mut_warp(int32_t x, int32_t y, int32_t addr, uint16_t port, bool reconnect) {
m_warps.push_back({x, y, addr, port, reconnect});
}
void EntityStore::reset_for_map_change() {
// CPythonNetworkStream::SetLoadingPhase() calls CPythonPlayer::Clear() and
// destroys the network actor manager before the next connection starts.
// Clear queued deltas as well: they describe the old connection and must not
// be replayed after the new GC_MAIN_CHARACTER arrives.
m_ents.clear();
m_main_vid = 0;
m_changes.clear();
m_dirty.clear();
m_vitals.clear();
m_damage.clear();
m_motions.clear();
m_dig_motions.clear();
m_fishing_events.clear();
m_dungeon_events.clear();
m_view_equipment.clear();
m_mount_changes.clear();
m_ground.clear();
m_ground_changes.clear();
m_pvp.clear();
m_pvp_changes.clear();
m_duel_opponents.clear();
m_duel_started = false;
m_duel_cannot_attack = false;
m_observers.clear();
m_observer_events.clear();
m_npc_marks.clear();
m_npc_marks_dirty = true;
m_land_areas.clear();
m_land_dirty = true;
m_markers.clear();
m_markers_dirty = true;
// CPythonPlayer::Clear() resets local combat/UI state. Inventory is kept,
// like the reference client: a warp does not make the server-side items
// disappear while the new loading packets are arriving.
m_points = PlayerPoints{};
m_points_dirty = true;
m_target_vid = 0;
m_target_hp_pct = 0;
m_target_dirty = true;
std::memset(m_skills, 0, sizeof(m_skills));
std::memset(m_skill_master, 0, sizeof(m_skill_master));
m_skill_group = 0;
m_skill_group_dirty = true;
m_skills_dirty = true;
std::fill(std::begin(m_quickslots), std::end(m_quickslots), QuickSlot{});
m_quickslots_dirty = true;
m_cooldown_ends.clear();
m_affects.clear();
m_affect_changes.clear();
m_stamina_consuming = false;
m_stamina_per_sec = 0;
m_current_stamina = 0;
m_stamina_current_f = 0.0f;
m_stamina_last_ms = 0;
m_observer_mode = false;
m_observer_count = 0;
m_mobile_flag = false;
m_combo_skill_flag = false;
// Loading must not leave an old modal transaction or one-shot event alive.
m_shop_open = false;
m_shop_vid = 0;
m_shop_items.clear();
m_shop_tabs.clear();
m_shop_selling_price = 0;
m_shop_errors.clear();
m_shop_dirty = true;
m_exchange = ExchangeState{};
m_exchange_dirty = true;
for (Item &item : m_safebox) {
item = Item{};
}
m_safebox_open = false;
m_safebox_size = 0;
m_safebox_gold = 0;
m_safebox_dirty = true;
for (Item &item : m_mall) {
item = Item{};
}
m_mall_open = false;
m_mall_size = 0;
m_mall_dirty = true;
m_cube = CubeState{};
m_cube_events.clear();
m_cube_last_success = CubeResultEntry{};
m_server_commands.clear();
m_scripts.clear();
m_confirms.clear();
m_item_events.clear();
m_warps.clear();
m_refine_cues.clear();
m_ds_cues.clear();
for (Item &item : m_dragon_soul) {
item = Item{};
}
}
void EntityStore::mut_server_time(int64_t time) {
+47 -4
View File
@@ -62,16 +62,24 @@ struct Entity {
int32_t guild = 0;
int16_t alignment = 0;
uint8_t pk_mode = 0;
// --- W0 interface freeze (CLIENT-GAP §2.3/§2.5) ---
uint8_t empire = 0; // 1 Shinsoo, 2 Chunjo, 3 Jinno (GC_CHAR_ADDITIONAL_INFO)
uint64_t affect_flags = 0; // AFF_* bitset carried on the entity (buff icons / aura)
uint32_t owner_vid = 0; // summon / horse owner; 0 = none
uint32_t state_flags = 0; // presentation-layer latch bits (W2 defines the enum)
};
// --- P9 world systems ----------------------------------------------------
// GC_WARP — teleport target. same_server() -> just move the player; otherwise
// the caller must reconnect to addr:port (P10).
// GC_WARP — teleport target. The m2dev protocol uses addr==0 as an in-place
// warp; the 40250 classic protocol always reconnects, including when lAddr is
// zero. `reconnect` records that protocol-specific distinction at parse time.
struct WarpCue {
int32_t x = 0, y = 0;
int32_t addr = 0;
uint16_t port = 0;
bool same_server() const { return addr == 0; }
bool reconnect = false;
bool same_server() const { return !reconnect && addr == 0; }
};
// One atlas/minimap NPC entry (GC_NPC_POSITION).
struct NPCMark {
@@ -466,6 +474,16 @@ public:
uint8_t attack_speed = 0);
void mut_spawn_main(uint32_t vid, uint16_t race, const std::string &name, float x, float y,
float z);
// Full spawn (CLIENT-GAP §2.3): merge every field of `e` into the world in one
// shot, keyed by e.vid, so a two-packet PC/NPC add (GC_CHARACTER_ADD +
// GC_CHAR_ADDITIONAL_INFO) lands as a single Spawn change with name/parts/
// empire/affect_flags/owner already populated. Creates the entity if new.
void mut_spawn_full(const Entity &e);
// summon / horse ownership (CLIENT-GAP §2.5); no-op if vid unknown.
void mut_ownership(uint32_t vid, uint32_t owner_vid);
// Map background music (CLIENT-GAP §9.1). name is the resolved track id or
// path; volume < 0 means "server did not specify, keep current".
void mut_map_bgm(const std::string &name, float volume);
void mut_despawn(uint32_t vid);
// angle_deg is the already-decoded compass heading (m2dev: rot*360/256;
// classic: rot*5 — the two wire encodings differ, so decode at the parser).
@@ -554,7 +572,12 @@ public:
int32_t counter_value, const std::string &icon);
// --- P9 world systems ---
void mut_warp(int32_t x, int32_t y, int32_t addr, uint16_t port);
void mut_warp(int32_t x, int32_t y, int32_t addr, uint16_t port,
bool reconnect = false);
// Clear the loading-phase state that ClientVS22 resets before a new map
// connection. Persistent account data (friends, guild, quests, inventory)
// is retained; entities and map-local/transient state are discarded.
void reset_for_map_change();
void mut_server_time(int64_t time);
void mut_channel(uint8_t channel);
void mut_npc_marks(const std::vector<NPCMark> &marks);
@@ -968,6 +991,22 @@ public:
m_changes.clear();
return v;
}
// vids whose entity fields changed without a Spawn/Move/Info change since the
// last drain (deduped). W2 uses this for the §2.1 visibility pass so a bare
// ownership/affect update still re-mirrors the node.
std::vector<uint32_t> drain_dirty() {
auto v = std::move(m_dirty);
m_dirty.clear();
return v;
}
// Map BGM (§9.1). Returns true once after mut_map_bgm and consumes the flag.
bool take_bgm_dirty() {
bool d = m_bgm_dirty;
m_bgm_dirty = false;
return d;
}
const std::string &bgm_name() const { return m_bgm_name; }
float bgm_volume() const { return m_bgm_volume; }
std::vector<ChatMsg> drain_chat() {
auto v = std::move(m_chat);
m_chat.clear();
@@ -1063,6 +1102,10 @@ private:
uint32_t m_main_vid = 0;
uint32_t m_now = 0;
std::vector<Change> m_changes;
std::vector<uint32_t> m_dirty;
std::string m_bgm_name;
float m_bgm_volume = -1.0f;
bool m_bgm_dirty = false;
std::vector<ChatMsg> m_chat;
std::vector<uint32_t> m_vitals;
std::vector<DamageEvent> m_damage;
+69 -4
View File
@@ -20,6 +20,7 @@
#include <m2_coord.h>
#include <cmath>
#include <cstdio>
using namespace godot;
@@ -36,6 +37,7 @@ void M2Client::_bind_methods() {
&M2Client::connect_to_server);
ClassDB::bind_method(D_METHOD("disconnect_from_server"), &M2Client::disconnect_from_server);
ClassDB::bind_method(D_METHOD("select_character", "index"), &M2Client::select_character);
ClassDB::bind_method(D_METHOD("enter_game", "index"), &M2Client::enter_game);
ClassDB::bind_method(
D_METHOD("create_character", "slot", "name", "job", "shape", "con", "int", "str", "dex"),
&M2Client::create_character);
@@ -363,6 +365,7 @@ void M2Client::_bind_methods() {
ClassDB::bind_method(D_METHOD("get_stamina_state"), &M2Client::get_stamina_state);
ADD_SIGNAL(MethodInfo("warp", PropertyInfo(Variant::VECTOR3, "pos"),
PropertyInfo(Variant::BOOL, "same_server")));
ADD_SIGNAL(MethodInfo("world_reset"));
ADD_SIGNAL(MethodInfo("time_changed", PropertyInfo(Variant::INT, "server_epoch")));
ADD_SIGNAL(MethodInfo("channel_changed", PropertyInfo(Variant::INT, "channel")));
ADD_SIGNAL(MethodInfo("npc_marks_changed"));
@@ -376,6 +379,10 @@ void M2Client::_bind_methods() {
PropertyInfo(Variant::BOOL, "has_destination")));
ADD_SIGNAL(MethodInfo("world_markers_changed"));
ADD_SIGNAL(MethodInfo("mount_changed", PropertyInfo(Variant::INT, "vid")));
// §9.1 map background music: name is the resolved track id/path, volume is
// 0..1 or -1 when the server left it unspecified.
ADD_SIGNAL(MethodInfo("bgm_changed", PropertyInfo(Variant::STRING, "name"),
PropertyInfo(Variant::FLOAT, "volume")));
}
static const char *phase_name(int p) {
@@ -550,6 +557,17 @@ bool M2Client::select_character(int index) {
return ok;
}
bool M2Client::enter_game(int index) {
if (classic_sess) {
const bool ok = classic_sess->connect_direct_enter(index);
if (ok && index >= 0 && index < static_cast<int>(classic_sess->char_slots().size())) {
selected_pid = classic_sess->char_slots()[index].id;
}
return ok;
}
return select_character(index);
}
bool M2Client::create_character(int slot, const godot::String &name, int job, int shape,
int con, int intel, int str, int dex) {
if (classic_sess) {
@@ -2232,6 +2250,10 @@ static Dictionary entity_dict(const mtnet::Entity &e) {
d["guild"] = e.guild;
d["alignment"] = (int)e.alignment;
d["pk_mode"] = (int)e.pk_mode;
d["empire"] = (int)e.empire;
d["affect_flags"] = (int64_t)e.affect_flags;
d["owner_vid"] = (int)e.owner_vid;
d["state_flags"] = (int)e.state_flags;
Array parts;
for (int i = 0; i < 4; ++i) {
parts.push_back((int)e.parts[i]);
@@ -2734,7 +2756,15 @@ void M2Client::pump_classic() {
}
emit_signal("phase_changed", String(pn[idx]));
if (classic_sess->stage() == St::Failed && stage != Stage::Failed) {
// ClientVS22 sends every already-established game connection loss back
// through SetLoginPhase(), regardless of whether SELECT was reached.
// Authentication failures happen before this flag is set and remain
// login_failed events.
if (classic_sess->was_online_lost()) {
emit_signal("disconnected", String(classic_sess->last_error().c_str()));
} else {
emit_signal("login_failed", String(classic_sess->last_error().c_str()));
}
set_stage(Stage::Failed);
} else if (classic_sess->stage() == St::LoggingIn && stage == Stage::GameConnect) {
set_stage(Stage::GameLogin);
@@ -2806,6 +2836,17 @@ void M2Client::pump_classic() {
break;
}
}
// §2.5: bare field updates (ownership / affect flags) with no Spawn/Move/
// Info change — re-mirror the node so presentation stays in sync.
for (uint32_t vid : w.drain_dirty()) {
if (const mtnet::Entity *e = w.get(vid)) {
emit_signal("entity_info", (int)vid, entity_dict(*e));
}
}
// §9.1: map background music.
if (w.take_bgm_dirty()) {
emit_signal("bgm_changed", String::utf8(w.bgm_name().c_str()), w.bgm_volume());
}
for (const auto &m : w.drain_chat()) {
if (m.type == mtnet::CHAT_TYPE_WHISPER) {
emit_signal("whisper_received", (int)m.sub, String::utf8(m.from.c_str()),
@@ -3108,15 +3149,28 @@ void M2Client::pump_classic() {
}
for (const auto &warp : w.drain_warps()) {
fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(warp.x, warp.y, 0);
emit_signal("warp", Vector3(g.x, g.y, g.z), warp.same_server());
if (!warp.same_server()) {
uint32_t addr = (uint32_t)warp.addr;
// ClientVS22's RecvWarpPacket() always calls Connect(lAddr, wPort),
// even for an address that points back to the current channel. Clear
// the old loading/world state before the socket is replaced.
w.reset_for_map_change();
emit_signal("world_reset");
emit_signal("warp", Vector3(g.x, g.y, g.z), false);
uint32_t addr = static_cast<uint32_t>(warp.addr);
char host[16];
std::snprintf(host, sizeof(host), "%u.%u.%u.%u", addr & 0xFF,
(addr >> 8) & 0xFF, (addr >> 16) & 0xFF, (addr >> 24) & 0xFF);
warp_to_game_server(String(host), warp.port);
if (!classic_sess->connect_warp(std::string(host), warp.port)) {
// connect_warp() marks the session Failed. Let the normal stage
// bridge surface it as a post-login disconnect on the next pump.
return;
}
classic_last_stage = -1;
shop_open_seen = false;
mall_open_seen = false;
dead_seen.clear();
set_stage(Stage::GameConnect);
return;
}
if (w.take_time_dirty()) {
emit_signal("time_changed", (int64_t)w.server_time());
@@ -3280,6 +3334,17 @@ void M2Client::pump_game() {
break;
}
}
// §2.5: bare field updates (ownership / affect flags) with no Spawn/Move/
// Info change — re-mirror the node so presentation stays in sync.
for (uint32_t vid : w.drain_dirty()) {
if (const mtnet::Entity *e = w.get(vid)) {
emit_signal("entity_info", (int)vid, entity_dict(*e));
}
}
// §9.1: map background music.
if (w.take_bgm_dirty()) {
emit_signal("bgm_changed", String::utf8(w.bgm_name().c_str()), w.bgm_volume());
}
for (const auto &m : w.drain_chat()) {
if (m.type == mtnet::CHAT_TYPE_WHISPER) {
emit_signal("whisper_received", (int)m.sub, String::utf8(m.from.c_str()),
+2
View File
@@ -58,6 +58,8 @@ public:
// pick a slot from the char_list payload (its "index" field).
bool select_character(int index);
// Enter a character through the stock two-connection direct-enter flow.
bool enter_game(int index);
// CG_CHARACTER_CREATE / CG_CHARACTER_DELETE (mirror SendCreate/DestroyCharacterPacket).
bool create_character(int slot, const godot::String &name, int job, int shape,
int con, int intel, int str, int dex);
+120 -1
View File
@@ -3,9 +3,14 @@
// session.stream().feed(), outgoing checked via take_outgoing().
#include "../src/net/classic/classic_session.h"
#include <arpa/inet.h>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <poll.h>
#include <string>
#include <sys/socket.h>
#include <unistd.h>
#include <vector>
using namespace mtnet::classic;
@@ -43,6 +48,42 @@ static std::vector<uint8_t> drain(ClassicSession &s) {
return out;
}
static int listen_loopback(uint16_t &port) {
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
return -1;
}
int one = 1;
::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = 0;
if (::bind(fd, reinterpret_cast<const sockaddr *>(&addr), sizeof(addr)) < 0 ||
::listen(fd, 4) < 0) {
::close(fd);
return -1;
}
socklen_t len = sizeof(addr);
if (::getsockname(fd, reinterpret_cast<sockaddr *>(&addr), &len) < 0) {
::close(fd);
return -1;
}
int flags = ::fcntl(fd, F_GETFL, 0);
::fcntl(fd, F_SETFL, flags | O_NONBLOCK);
port = ntohs(addr.sin_port);
return fd;
}
static bool accept_loopback(int listener, int &accepted) {
pollfd pfd{listener, POLLIN, 0};
if (::poll(&pfd, 1, 500) <= 0) {
return false;
}
accepted = ::accept(listener, nullptr, nullptr);
return accepted >= 0;
}
int main() {
ClassicSession s;
s.set_now(0);
@@ -53,6 +94,7 @@ int main() {
s.on_entered_game = [&]() { entered = true; };
s.start_offline("admin", "123456789");
s.set_auto_entergame_delay(1500);
CHECK(s.stage() == mtnet::INetSession::Stage::Connecting, "start_offline -> Connecting");
// --- handshake: server HANDSHAKE -> client echoes it back ---
@@ -98,6 +140,8 @@ int main() {
ls.players[0].level = 42;
ls.players[0].ht = 20;
ls.players[0].change_name = 1;
ls.players[0].addr = static_cast<int32_t>(0x0100007f); // 127.0.0.1, little-endian lAddr
ls.players[0].port = 13002;
ls.players[1].id = 1002;
std::strcpy(ls.players[1].name, "Sura");
ls.players[1].job = 4;
@@ -110,6 +154,8 @@ int main() {
const auto &sl = s.char_slots();
CHECK(sl.size() == 4 && sl[0].name == "Warrior" && sl[0].level == 42 && sl[0].change_name,
"slot 0 + forced rename flag");
CHECK(sl[0].addr == static_cast<int32_t>(0x0100007f) && sl[0].port == 13002,
"slot 0 game address carried");
CHECK(sl[1].name == "Sura" && sl[1].job == 4, "slot 1");
CHECK(sl[2].empty() && sl[3].empty(), "slots 2,3 empty");
}
@@ -218,6 +264,79 @@ int main() {
CHECK(s.in_game() && entered, "in_game() + on_entered_game");
}
// --- known but unsupported integration packet is not silently accepted ---
{
ClassicSession unsupported;
unsupported.start_offline("admin", "123456789");
std::vector<uint8_t> packet(sizeof(GCMatrixCard), 0);
packet[0] = HDR_GC_MATRIX_CARD;
feed(unsupported, packet);
CHECK(unsupported.stage() == mtnet::INetSession::Stage::Failed,
"unsupported integration packet -> Failed");
CHECK(unsupported.last_error().find("does not handle") != std::string::npos,
"unsupported integration packet -> explicit error");
auto unsupported_headers = unsupported.parser().drain_unhandled_headers();
CHECK(unsupported_headers.size() == 1 && unsupported_headers[0] == HDR_GC_MATRIX_CARD,
"unsupported integration packet -> header event");
}
// --- DirectEnter opens the character slot's TCP endpoint ---
{
uint16_t initial_port = 0;
uint16_t slot_port = 0;
uint16_t warp_port = 0;
const int initial_listener = listen_loopback(initial_port);
const int slot_listener = listen_loopback(slot_port);
const int warp_listener = listen_loopback(warp_port);
if (initial_listener >= 0 && slot_listener >= 0 && warp_listener >= 0) {
ClassicSession direct;
CHECK(direct.connect_with_login_key("127.0.0.1", initial_port, "admin", 0x12345678),
"direct enter initial game connection");
int initial_connection = -1;
CHECK(accept_loopback(initial_listener, initial_connection),
"direct enter first TCP connection accepted");
GCLoginSuccess login{};
login.header = HDR_GC_LOGIN_SUCCESS_NEWSLOT;
login.players[0].id = 2001;
std::strcpy(login.players[0].name, "Direct");
login.players[0].addr = static_cast<int32_t>(0x0100007f); // 127.0.0.1
login.players[0].port = slot_port;
feed(direct, raw(login));
CHECK(direct.char_slots()[0].addr == static_cast<int32_t>(0x0100007f) &&
direct.char_slots()[0].port == slot_port, "direct enter slot endpoint parsed");
CHECK(direct.connect_direct_enter(0), "direct enter reconnect accepted");
int slot_connection = -1;
CHECK(accept_loopback(slot_listener, slot_connection),
"direct enter second TCP connection targets slot endpoint");
CHECK(direct.connect_warp("127.0.0.1", warp_port),
"GC_WARP reconnect keeps selected slot and login key");
int warp_connection = -1;
CHECK(accept_loopback(warp_listener, warp_connection),
"GC_WARP reconnect targets packet endpoint");
if (initial_connection >= 0) {
::close(initial_connection);
}
if (slot_connection >= 0) {
::close(slot_connection);
}
if (warp_connection >= 0) {
::close(warp_connection);
}
} else {
std::puts("SKIP: DirectEnter TCP target test (localhost bind unavailable)");
}
if (initial_listener >= 0) {
::close(initial_listener);
}
if (slot_listener >= 0) {
::close(slot_listener);
}
if (warp_listener >= 0) {
::close(warp_listener);
}
}
// --- GC_MOVE(3): NPC starts moving, interpolation advances on tick ---
{
GCMove mv{};
@@ -248,7 +367,7 @@ int main() {
feed(s, raw(warp));
auto warps = s.world().drain_warps();
CHECK(warps.size() == 1 && warps[0].x == 432100 && warps[0].y == 876500 &&
warps[0].same_server(), "classic GC_WARP parsed and surfaced");
!warps[0].same_server(), "classic GC_WARP always requests reconnect");
}
// --- GC_CHARACTER_POINTS(16): stat block ---
@@ -218,6 +218,24 @@ int main() {
s.feed(&bad, 1);
CHECK(!err.empty(), "unknown header -> on_error");
CHECK(s.state() == ClassicStream::State::Offline, "unknown header -> disconnect");
GCCharacterDel del{HDR_GC_CHARACTER_DEL, 99};
auto trailing = raw(del);
s.feed(trailing.data(), trailing.size());
CHECK(s.outgoing_pending() == 0, "unknown header does not resync into trailing bytes");
}
// ---------------------------------------------------------- rejected handler
{
ClassicStream s;
std::string err;
s.on_error = [&](const std::string &e) { err = e; };
s.on_packet = [&](uint8_t, const uint8_t *, uint32_t) { return false; };
GCCharacterDel del{HDR_GC_CHARACTER_DEL, 99};
auto b = raw(del);
s.feed(b.data(), b.size());
CHECK(err.find("packet handler rejected") != std::string::npos,
"rejected packet -> explicit on_error");
CHECK(s.state() == ClassicStream::State::Offline, "rejected packet -> disconnect");
}
// ---- GC_KEY_AGREEMENT: a partial packet just waits (no error, no crash) ----
+13
View File
@@ -1590,6 +1590,19 @@ int main() {
CHECK(despawn, "despawn change emitted");
}
// --- ClientVS22 loading reset: discard map-local state, retain no stale deltas ---
es.mut_spawn(99, 101, 1, "reset-me", 10, 20, 0, 0);
es.mut_ground_add(700, 27100, 12, 24, 0);
es.mut_pvp(99, 1, 2);
es.mut_target(99, 50);
es.reset_for_map_change();
CHECK(es.size() == 0 && es.main_vid() == 0, "map reset clears network entities");
CHECK(es.ground_vids().empty() && es.pvp_relations().empty(),
"map reset clears ground items and pvp state");
CHECK(es.target_vid() == 0 && es.take_target_dirty(), "map reset clears target state");
CHECK(es.drain_changes().empty() && es.drain_ground().empty(),
"map reset drops stale entity and ground deltas");
if (g_fail) {
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
return 1;
+13 -14
View File
@@ -1,19 +1,18 @@
# AppFlow (P10) —— 登录 → 选人 → 进游戏 的串场控制器。
#
# 把它做场景根:一个 M2Client 贯穿全程,LoadingScreen / ReconnectUI 常驻。
# 把它做场景根:一个 M2Client 贯穿全程,LoadingScreen 常驻。
# var af := preload("res://app_flow.gd").new()
# add_child(af)
# af.start(assets_root) # assets_root 传给 game_scene
#
# 状态机:LOGIN → SELECT → GAME。M2Client 信号驱动切屏。
# 断线:在 GAME 里保留场景交给 ReconnectUI 自动重连;在 LOGIN/SELECT 回登录
# 断线:任何阶段都回到登录阶段,和 ClientVS22 的 SetLoginPhase 一致
extends Node
const ServerInfoRes = preload("res://net/serverinfo.gd")
const ChannelStatus = preload("res://net/channel_status.gd")
const LoadingScreen = preload("res://ui/loading_screen.gd")
const CharSelectScreen = preload("res://ui/char_select_screen.gd")
const ReconnectUI = preload("res://ui/reconnect_ui.gd")
const GameScene = preload("res://game_scene.gd")
enum { LOGIN, SELECT, GAME }
@@ -24,7 +23,6 @@ var _state := LOGIN
var _assets := ""
var _ui: CanvasLayer # 登录 / 选人的临时界面层
var _loading: CanvasLayer
var _reconnect: CanvasLayer
var _game: Node
var build_game_scene := true # 测试里置 false,只跑状态机不建重场景
var _sel_server := 0
@@ -53,9 +51,6 @@ func start(assets_root: String = "", injected_client: Node = null) -> void:
_loading = LoadingScreen.new()
add_child(_loading)
_loading.setup(client)
_reconnect = ReconnectUI.new()
add_child(_reconnect)
_reconnect.setup(client)
_goto_login()
if auto_login:
call_deferred("_do_connect")
@@ -83,8 +78,12 @@ func _on_char_list(list: Array) -> void:
sc.set_chars(list)
return
_goto_select()
if auto_login and not _chars.is_empty() and client.has_method("select_character"):
client.select_character(int(_chars[0].get("index", 0)))
if auto_login and not _chars.is_empty():
var idx := int(_chars[0].get("index", 0))
if client.has_method("enter_game"):
client.enter_game(idx)
elif client.has_method("select_character"):
client.select_character(idx)
func _on_char_name_changed(pid: int, name: String) -> void:
var sc: Node = _ui.get_node_or_null("CharSelect") if _ui and _state == SELECT else null
@@ -121,11 +120,9 @@ func _on_login_failed(reason: String) -> void:
_goto_login()
_set_status("登录失败:%s" % reason)
func _on_disconnected(_reason: String) -> void:
# GAME 里:保留 game_sceneReconnectUI 负责重连遮罩
# LOGIN/SELECT 里:回登录
if _state != GAME:
func _on_disconnected(reason: String) -> void:
_goto_login()
_set_status("连接断开:%s" % reason)
# --- 切屏 ------------------------------------------------------------------
@@ -323,7 +320,9 @@ func _build_char_list() -> void:
_ui.add_child(screen)
screen.setup(client, _assets, _chars)
screen.select_requested.connect(func(idx: int):
if client.has_method("select_character"):
if client.has_method("enter_game"):
client.enter_game(idx)
elif client.has_method("select_character"):
client.select_character(idx))
screen.back_requested.connect(func():
if client.has_method("disconnect_from_server"):
+39
View File
@@ -0,0 +1,39 @@
# BgmDirector —— 把 M2Client.bgm_changed 信号翻译成 Audio 的 BGM 播放。
#
# 存在的理由:§9.1 的地图 BGM 消费点,从 net_world.gd 里拆出来,避免 W2(可见性)
# 和 W4(音频)同时改 net_world.gd。真正的「地图 → 曲目」解析和音量应用由 W4 在
# 这里补全(见 docs/CLIENT-GAP-FIX.md §9.1)。
#
# 用法:
# var bd := preload("res://bgm_director.gd").new()
# add_child(bd)
# bd.bind(client, audio)
extends Node
var _client: Node
var _audio: Node
# 服务器最近一次下发的 BGM(曲目 id 或路径)和音量(<0 表示未指定)。
var current_track := ""
var current_volume := -1.0
func bind(client: Node, audio: Node) -> void:
_client = client
_audio = audio
if _client and _client.has_signal("bgm_changed"):
_client.bgm_changed.connect(_on_bgm_changed)
func _on_bgm_changed(name: String, volume: float) -> void:
current_track = name
if volume >= 0.0:
current_volume = volume
if _audio and "master_bgm" in _audio:
_audio.master_bgm = clampf(volume, 0.0, 1.0)
if _audio == null:
return
if name == "":
if _audio.has_method("stop_bgm"):
_audio.stop_bgm()
return
if _audio.has_method("play_bgm"):
_audio.play_bgm(name)
+8 -3
View File
@@ -1,6 +1,6 @@
# 选人界面 —— 3 个旋转台角色 + 九宫格信息面板 + 选择/创建按钮。
# 数据来自 M2Client.char_list(联机)或 --mock 合成列表(离线开发)。
# 选中 -> M2Client.select_character(index) -> entered_game。
# 选中 -> M2Client.enter_game(index) / DirectEnter -> entered_game。
# 用法(离线预览):godot --path project --script charselect.gd --mock --quit-after 120
extends SceneTree
@@ -209,8 +209,13 @@ func _enter() -> void:
_set_status("空槽不能进入(创建角色未实现)")
return
if _client:
_client.call("select_character", int(s.get("index", _sel)))
_set_status("select_character(%d) sent" % int(s.get("index", _sel)))
var idx := int(s.get("index", _sel))
if _client.has_method("enter_game"):
_client.call("enter_game", idx)
_set_status("enter_game(%d) sent" % idx)
else:
_client.call("select_character", idx)
_set_status("select_character(%d) sent" % idx)
else:
_set_status("[mock] 进入游戏:%s" % s.get("name"))
+95 -13
View File
@@ -19,6 +19,7 @@ const NetPlay = preload("res://net_play.gd")
const Hud = preload("res://hud.gd")
const AppLifecycle = preload("res://app_lifecycle.gd")
const Audio = preload("res://audio.gd")
const BgmDirector = preload("res://bgm_director.gd")
const UiManager = preload("res://ui/ui_manager.gd")
const InventoryUI = preload("res://ui/inventory_ui.gd")
const ItemListDB = preload("res://ui/item_list.gd")
@@ -107,8 +108,10 @@ var atlas_ui: Node # AtlasUI
var world_time: Node # WorldTime
var weather: Node3D # Weather
var _assets := ""
var map_path := ""
var _model_built := false
var _audio: Node
var _bgm_director: Node
var _mount: Node3D
var _sun: DirectionalLight3D
var _env: Environment
@@ -118,14 +121,14 @@ var _guild_invite_dialog: ConfirmationDialog
var _gift_dialog: AcceptDialog
func setup(m2client: Node, assets_root: String,
map_path: String = "OutdoorA1/metin2_map_a1") -> void:
initial_map_path: String = "OutdoorA1/metin2_map_a1") -> void:
client = m2client
_assets = assets_root
# GC_MAIN_CHARACTER only carries the server-space coordinates, not the map
# folder. The old fixed A1 fallback puts characters from A2/A3 outside the
# rendered terrain, which looks like an empty map. Resolve the map from the
# character position before constructing Metin2World.
map_path = _resolve_map_path(map_path)
map_path = _resolve_map_path(initial_map_path)
_build_lighting()
# 关键:setup 是协程。每个重活之间让出一帧,好让 M2Client._process 抽 socket
# (否则整段 ~5s 同步阻塞会漏 PONG → 服务器 10s 后 "peer closed")。
@@ -179,6 +182,11 @@ func setup(m2client: Node, assets_root: String,
add_child(life)
life.bind(client, _audio)
# §9.1 地图 BGMM2Client.bgm_changed -> Audio。放在独立节点里,W4 补全曲目解析。
_bgm_director = BgmDirector.new()
add_child(_bgm_director)
_bgm_director.bind(client, _audio)
# UI 层 + 物品 proto + 背包窗(I 键开关)
ui = UiManager.new()
add_child(ui)
@@ -194,7 +202,7 @@ func setup(m2client: Node, assets_root: String,
fx = EffectRegistry.new()
fx.setup(_assets)
# 技能(P6)—— K 键技能窗,数字键 1-9 快捷栏F1-F4 切页+ 技能特效表
# 技能(P6)—— K 键技能窗,数字键 1-4 → 快捷栏 0-3 / F1-F4 → 4-7 / Ctrl+数字 = 表情+ 技能特效表
if assets_root != "":
skill_table = SkillTable.new()
for lang in ["en", "common"]:
@@ -378,6 +386,8 @@ func setup(m2client: Node, assets_root: String,
system_menu_ui.setup(ui, client, assets_root, system_option_ui, game_option_ui)
if client.has_signal("warp"):
client.warp.connect(_on_warp)
if client.has_signal("world_reset"):
client.world_reset.connect(_on_world_reset)
if client.has_signal("channel_changed") and hud and hud.has_method("set_channel"):
client.channel_changed.connect(func(c): hud.set_channel(c))
@@ -495,12 +505,62 @@ func _skill_master(sid: int) -> int:
return int(s.get("master", 0))
return 0
# GC_WARP —— 同服传送:直接把玩家挪到新坐标(Y 交给地形贴地)。
# 跨服(same_server=false)留给 P10 的重连串场
# GC_WARP —— 40250 的参考端无论是否“同服”都会重连到 lAddr:wPort
# world_reset 先清旧实体,随后这里重载地图并把玩家放到新坐标
func _on_warp(pos: Vector3, same_server: bool) -> void:
if not same_server:
push_warning("GC_WARP 到别的服务器 %s —— 跨服重连归 P10" % pos)
_reload_map_for_warp(pos)
return
_place_player_at_net_pos(pos)
func _on_world_reset() -> void:
if net_world and net_world.has_method("clear_for_map_change"):
net_world.clear_for_map_change()
if ground_items and ground_items.has_method("clear_for_map_change"):
ground_items.clear_for_map_change()
# The reference loading phase closes transient interaction state. Keep
# persistent account data (inventory, quests, friends) intact.
for widget in [shop_ui, exchange_ui, safebox_ui, mall_ui, cube_ui,
private_shop_ui, select_item_ui, quest_dialog, quest_log, party_ui,
friend_ui, refine_ui, dragon_soul_ui]:
if widget == null or not is_instance_valid(widget):
continue
if widget.has_method("close"):
widget.call("close")
elif widget.has_method("_close"):
widget.call("_close")
if atlas_ui and atlas_ui.has_method("close"):
atlas_ui.close()
if ui and ui.has_method("close_top"):
while ui.close_top():
pass
if _audio and _audio.has_method("stop_bgm"):
_audio.stop_bgm(0.15)
func _reload_map_for_warp(pos: Vector3) -> void:
var server_pos := Vector2(pos.x * 100.0, -pos.z * 100.0)
var next_map := _resolve_map_path(map_path, server_pos)
var old_world := world
map_path = next_map
_build_world(_assets, map_path)
if old_world and is_instance_valid(old_world) and old_world != world:
old_world.queue_free()
if cam:
cam.world = world
if pc:
pc.world = world
if net_world:
net_world.world = world
if hud and hud.has_method("reload_world") and _map_loaded():
hud.reload_world(world)
if atlas_ui and atlas_ui.has_method("reload_map"):
atlas_ui.reload_map(world, map_path.get_file())
if world_time and world_time.has_method("set_lighting"):
world_time.set_lighting(_sun if not _env_from_msenv else null,
_env if not _env_from_msenv else null)
_place_player_at_net_pos(pos)
func _place_player_at_net_pos(pos: Vector3) -> void:
if player:
var wp := MapCoord.to_world(pos)
if world and world.has_method("set_focus_position"):
@@ -556,13 +616,26 @@ func _unhandled_input(e: InputEvent) -> void:
elif e.keycode == KEY_Z and ground_items:
ground_items.try_pickup()
elif e.keycode >= KEY_F1 and e.keycode <= KEY_F4 and quickbar:
quickbar.set_page(e.keycode - KEY_F1)
elif e.keycode >= KEY_1 and e.keycode <= KEY_9 and quickbar:
quickbar.activate(e.keycode - KEY_1)
# 参考端 game.py:318 —— F1..F4 固定 __PressQuickSlot(4..7)。
quickbar.activate(4 + (e.keycode - KEY_F1))
elif e.keycode >= KEY_1 and e.keycode <= KEY_9:
# 参考端 __PressNumKeygame.py:431):
# Ctrl+1..9 → 表情;否则 1..4 → 快捷栏 0..3,5..9 无操作。
var n: int = e.keycode - KEY_1
if e.ctrl_pressed or e.meta_pressed:
_emote(n)
elif n < 4 and quickbar:
quickbar.activate(n)
elif e.keycode == KEY_ESCAPE and system_menu_ui:
# ESCui_manager 先关最顶层窗口并吃掉事件;到这里说明没有窗口打开。
system_menu_ui.toggle()
# 表情快捷键(§8.6 / §4.8)—— 参考端 Ctrl+1..9 = chrmgr.SetEmoticon(-1, n) + net.SendEmoticon(n)。
# W3 在 §8.6 里补全:本地立即播表情动作 + 发 classic 表情包。这里先留统一入口。
func _emote(index: int) -> void:
if client and client.has_method("send_emoticon"):
client.send_emoticon(index)
func _job_name() -> String:
var r := int(client.get_entity(client.get_main_vid()).get("race", 0)) & 3
return ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"][r]
@@ -642,6 +715,7 @@ func _build_world(assets_root: String, map_path: String) -> void:
if not ClassDB.class_exists("Metin2World"):
push_warning("GameScene: Metin2World 未注册(扩展没编?)")
return
_env_from_msenv = false
world = ClassDB.instantiate("Metin2World")
world.set("auto_load", false) # 别让 _ready() 自动加载一遍,下面显式调一次拿返回值
world.set("assets_root", assets_root)
@@ -667,6 +741,10 @@ func _build_world(assets_root: String, map_path: String) -> void:
_sun = msenv_sun
_env = msenv_we.environment
_env_from_msenv = true
elif _sun == null or not is_instance_valid(_sun) or _sun.get_parent() != self:
# A map without .msenv still needs the fallback lighting. This branch is
# mainly for a cross-map reload from an outdoor .msenv map.
_build_lighting()
# .msenv 换过 Environment 后,把持久化的雾等设置重新贴上去
if system_option_ui and system_option_ui.has_method("_apply_all"):
system_option_ui._apply_all()
@@ -677,16 +755,20 @@ func _map_loaded() -> bool:
var rep: Dictionary = world.call("get_load_report")
return int(rep.get("map_size_x", 0)) > 0
func _resolve_map_path(requested: String) -> String:
func _resolve_map_path(requested: String, forced_pos: Variant = null) -> String:
var pos: Variant = forced_pos
if pos == null:
if client == null or _assets == "" or not client.has_method("get_main_vid"):
return requested
var vid := int(client.get_main_vid())
if vid == 0 or not client.has_method("get_entity"):
return requested
var entity: Dictionary = client.get_entity(vid)
var pos: Variant = entity.get("pos_cm", null)
pos = entity.get("pos_cm", null)
if not (pos is Vector3):
if not (pos is Vector2):
return requested
var map_pos := Vector2(pos.x, pos.y)
var settings: Array[String] = []
_collect_map_settings(_assets, settings)
@@ -700,7 +782,7 @@ func _resolve_map_path(requested: String) -> String:
var size: Vector2i = bounds["size"]
var max_x := base.x + float(size.x) * 25600.0
var max_y := base.y + float(size.y) * 25600.0
if pos.x < base.x or pos.x >= max_x or pos.y < base.y or pos.y >= max_y:
if map_pos.x < base.x or map_pos.x >= max_x or map_pos.y < base.y or map_pos.y >= max_y:
continue
var root := _assets.trim_suffix("/")
var rel_setting := setting_path.substr(root.length() + 1)
@@ -720,7 +802,7 @@ func _resolve_map_path(requested: String) -> String:
best = candidate
if best != requested:
print("[GameScene] map resolved by server position ", pos, " -> ", best)
print("[GameScene] map resolved by server position ", map_pos, " -> ", best)
return best
func _collect_map_settings(dir_path: String, out: Array[String]) -> void:
+17
View File
@@ -7,6 +7,7 @@ extends CanvasLayer
var _world: Node
var _player: Node3D
var _minimap_rect: TextureRect
var _minimap_frame: Control
var _dot: ColorRect
var _map_w_m := 1024.0
var _map_h_m := 1280.0
@@ -43,6 +44,21 @@ func setup(world: Node, player: Node3D) -> void:
_build_hotbar()
_build_inventory()
func reload_world(new_world: Node) -> void:
_world = new_world
if _world == null:
return
var rep: Dictionary = _world.call("get_load_report")
_map_w_m = float(rep.get("map_size_x", 1)) * 256.0
_map_h_m = float(rep.get("map_size_y", 1)) * 256.0
if _minimap_frame and is_instance_valid(_minimap_frame):
_minimap_frame.queue_free()
_minimap_frame = null
_minimap_rect = null
_dot = null
if int(rep.get("map_size_x", 0)) > 0 and int(rep.get("map_size_y", 0)) > 0:
_build_minimap(int(rep["map_size_x"]), int(rep["map_size_y"]))
# --- real UI texture loading (§6.1) -------------------------------------------
func _ui_tex(name: String) -> Texture2D:
@@ -149,6 +165,7 @@ func _build_minimap(nx: int, ny: int) -> void:
big.blit_rect(img, Rect2i(0, 0, cell, cell), Vector2i(tx * cell, ty * cell))
var mm_h := 192 * ny / nx if nx > 0 else 192
var frame := _nine("board", 32, 128)
_minimap_frame = frame
frame.set_anchors_preset(Control.PRESET_TOP_RIGHT)
frame.position = Vector2(-192 - 24 - 12, 12)
frame.size = Vector2(192 + 24, mm_h + 24)
+3
View File
@@ -134,6 +134,9 @@ func _on_chars(list: Array) -> void:
if list.size() > 0 and (_auto_select or "--auto" in OS.get_cmdline_args()):
var idx: int = int(list[0].get("index", 0))
_msg("自动选择槽位 %d ..." % idx)
if _client.has_method("enter_game"):
_client.call("enter_game", idx)
else:
_client.call("select_character", idx)
func _on_entered_game() -> void:
+15
View File
@@ -117,6 +117,21 @@ func set_local_vid(vid: int) -> void:
func set_local_node(node: Node3D) -> void:
_local_node = node
# ClientVS22 destroys the network actor manager when Loading starts. This is
# called from M2Client.world_reset before the replacement connection sends its
# first GC_MAIN_CHARACTER/GC_CHARACTER_ADD burst.
func clear_for_map_change() -> void:
for vid in _by_vid.keys():
var node: Node3D = _by_vid[vid]
if is_instance_valid(node):
node.queue_free()
entity_removed.emit(int(vid))
_by_vid.clear()
_main_vid = 0
_pvp_relations.clear()
_duel_opponents.clear()
_duel_cannot_attack = false
func node_for(vid: int) -> Node3D:
return _by_vid.get(vid, null)
+1 -1
View File
@@ -74,7 +74,7 @@ func _test_m2client_surface() -> void:
var c: Object = ClassDB.instantiate("M2Client")
get_root().add_child(c)
for m in ["move", "character_position", "sync_positions", "request_warp", "fishing", "request_dungeon", "attack", "set_target", "say", "shoot", "add_fly_targeting", "change_name", "select_empire", "download_guild_symbol", "get_guild_symbol", "script_select_item", "quest_cancel", "party_use_skill", "get_skill_group", "get_entity", "get_entities",
"get_main_vid", "get_main_pid", "get_points", "get_target", "select_character", "reconnect",
"get_main_vid", "get_main_pid", "get_points", "get_target", "select_character", "enter_game", "reconnect",
"suspend", "resume", "move_item", "use_item", "drop_item", "drop_item_count", "use_item_to_item", "give_item", "pickup_item",
"get_inventory", "get_equipment", "get_belt_inventory", "get_view_equipment", "get_item", "get_ground_items", "get_pvp_relations", "get_duel", "get_lover", "get_land_areas", "get_observers", "is_observer_mode", "get_observer_count", "has_mobile_flag", "combo_skill_enabled", "get_stamina_state", "whisper",
"party_invite", "party_answer", "party_leave", "party_set_distribute", "party_set_state", "get_party", "get_party_distribute_mode",
+4 -1
View File
@@ -119,7 +119,10 @@ func _run() -> void:
_ck(picked and fc2.selects == [0], "flow: pick char -> select_character(0)")
fc2.entered_game.emit()
_ck(af.state() == AppFlow.GAME, "flow: entered_game -> GAME")
# 在 SELECT 时断线 -> 回 LOGIN
# 在 GAME 时断线 -> 回 LOGINClientVS22 SetLoginPhase 语义)
fc2.disconnected.emit("game dropped")
_ck(af.state() == AppFlow.LOGIN, "flow: disconnect in GAME -> LOGIN")
# 再次收到角色列表时允许回到 SELECT
fc2.char_list.emit([{"index": 0, "name": "Hero", "level": 42}])
_ck(af.state() == AppFlow.SELECT, "flow: back to SELECT")
fc2.disconnected.emit("dropped")
+8
View File
@@ -64,6 +64,14 @@ func close() -> void:
ui.close(_win)
_win = null
func reload_map(metin_world: Node, name := "") -> void:
world = metin_world
if name != "":
map_name = name
if is_open():
close()
open()
func _build_window() -> void:
_win = Control.new()
_win.name = "AtlasWindow"
+7
View File
@@ -45,6 +45,13 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
_sel = -1
_cube_slots.clear()
_craft_in_flight = false
func _on_open(npc_vnum: int) -> void:
if npc_vnum <= 0:
return
+8
View File
@@ -32,6 +32,14 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
_next_display = 0
_offered_cells.clear()
if _gold_input:
_gold_input.text = ""
func offer(inv_window: int, inv_cell: int) -> void:
if not is_open() or inv_window < 0 or inv_cell < 0:
return
+4
View File
@@ -29,6 +29,10 @@ func setup(m2client: Node, parent: Node) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
func toggle() -> void:
_root.visible = not _root.visible
if _root.visible:
+7
View File
@@ -77,6 +77,13 @@ func _on_removed(vid: int) -> void:
_by_vid.erase(vid)
n.queue_free()
func clear_for_map_change() -> void:
for vid in _by_vid.keys():
var node: Node3D = _by_vid[vid]
if is_instance_valid(node):
node.queue_free()
_by_vid.clear()
func _process(dt: float) -> void:
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
if p == null:
+7
View File
@@ -29,6 +29,13 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
if is_instance_valid(_password_dialog):
_password_dialog.queue_free()
_password_dialog = null
func _name_of(vnum: int) -> String:
if proto and proto.has_method("item"):
var d: Dictionary = proto.item(vnum)
+4
View File
@@ -62,6 +62,10 @@ func setup(m2client: Node, parent: Node, dlg: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
# --- 로컬 플레이어가 파티장인가 ---
func _local_is_leader() -> bool:
if client == null or not client.has_method("get_main_vid"):
+4
View File
@@ -22,6 +22,10 @@ func setup(m2client: Node, parent: Node) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
func toggle() -> void:
_root.visible = not _root.visible
if _root.visible:
+5
View File
@@ -26,6 +26,11 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
_cur = {}
func _name_of(vnum: int) -> String:
if vnum == 0:
return "-"
+7
View File
@@ -34,6 +34,13 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
if is_instance_valid(_password_dialog):
_password_dialog.queue_free()
_password_dialog = null
func deposit(inv_window: int, inv_cell: int) -> void:
if not is_open() or inv_window < 0 or inv_cell < 0:
return
+3
View File
@@ -36,6 +36,9 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null, ilist: RefCoun
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
_close()
func open() -> void:
_root.visible = true
_err.text = ""
+4
View File
@@ -29,6 +29,10 @@ func setup(m2client: Node, sun_light: DirectionalLight3D, environment: Environme
if t > 0:
_on_time(t)
func set_lighting(sun_light: DirectionalLight3D, environment: Environment = null) -> void:
sun = sun_light
env = environment
func _on_time(server_epoch: int) -> void:
_epoch = float(server_epoch)
_since = 0.0