diff --git a/docs/CLIENT-GAP-FIX.md b/docs/CLIENT-GAP-FIX.md new file mode 100644 index 00000000..cb57021c --- /dev/null +++ b/docs/CLIENT-GAP-FIX.md @@ -0,0 +1,2973 @@ +# CLIENT-GAP 修改细则(1:1 复刻 40250/ClientVS22) + +> 本文是 [`CLIENT-GAP.md`](CLIENT-GAP.md) §「ClientVS22 实现细节审计(2026-09-02)」的**逐条修改说明**。 +> 编号与该节 1–10 完全对应。 +> +> **原则**:每条只写 `40250/ClientVS22` 里实际存在的行为、常量和结构,附源码 `文件:行`; +> 不设计新机制、不做“更好”的替代方案。`ClientVS22/source/` 本身没有完整的 Python UI +> 脚本;凡引用 `POC/assets/root/*.py` 的地方,表示迁移资产 / 历史脚本依据,不冒充 +> `ClientVS22/source/` 证据。凡本文没有从参考源码读出的细节,标注为 +> **[待读参考]** 并给出应读的参考文件,而不是臆造。 +> +> 路径约定:`REF/` = `/Users/shenlei/Work/mt/40250/ClientVS22/source/`, +> `POC/` = `/Users/shenlei/Work/mt/mtgodot-poc/`。 +> +> 创建 / 修订日期:2026-09-02 + +--- + +## 实施进度(2026-09-02) + +当前已完成 G0 公共接口的首版冻结,并已交付 W0/W1/W2 的“连接与阶段骨架”及 Warp / 换图清理 +第一批;正在进入并行波 A,不等于 §1、§2.7 或完整场景生命周期全部完成。 + +已完成并已编译验证: + +- [x] `GC_LOGIN_SUCCESS3/4` 的角色槽保存 `lAddr/wPort`;`M2Client.enter_game(index)` 已接入 + Classic DirectEnter:断开当前游戏连接,按角色槽 IP/端口重连,并在新连接的 SELECT 阶段自动发送 + `CG_CHARACTER_SELECT`。 +- [x] `select_char()`、登录包、`CG_ENTERGAME` 均透传 `send_fixed()` 结果;`CG_ENTERGAME` 成功后 + 执行即时 flush。`m_entergame_sent` 只在发送成功后置位。 +- [x] 未知 header 不再扫描 Loading 缓冲寻找伪边界;保留参考端唯一的 `header == 0` 填充跳过,错误会 + 记录最近两个 header 并断开。 +- [x] ClassicParser 对已知但未实现的集成包不再静默成功,而是记录阶段 / header 并触发失败链路。 +- [x] 已建立游戏连接后的断线现在发 `disconnected`;`AppFlow` 无条件回 LOGIN,匹配 + `OnRemoteDisconnect() -> SetLoginPhase()` 的最小语义。 +- [x] 回归覆盖:角色槽地址透传、未知包不重同步、未处理包显式失败、发送包顺序; + `net_classic_stream_test`、`net_classic_session_test`、`netbridge_test`、`p10_test` 已通过。 + `net_classic_session_test` 内的 localhost 双监听目标测试因当前受限环境禁止 bind 而自动 skip, + 未将 skip 计作真实 TCP / 真服验收。 +- [x] Classic `GC_WARP` 透传 `lX/lY/lAddr/wPort`,不再用 `addr==0` 判断同服;保留登录票据和已选 + 角色槽,通过同一个 `ClassicSession` 重连目标 `lAddr:wPort`,并在重连前清理 `EntityStore` 的 + 地图实体、掉落物、目标、PVP / 决斗、技能 / affect / 交互状态。 +- [x] 新增 `M2Client.world_reset`;`GameScene` / `NetWorld` / `GroundItems` 清理旧表现节点, + 重建地图、HUD 小地图、Atlas、环境和玩家坐标;地图切换时保留背包、任务、好友等持久数据。 +- [x] 回归覆盖 Classic Warp endpoint / selected slot 保留、EntityStore loading reset、场景重建 + 相关 GDScript;C++ 扩展和 `gamescene_test` / `p9_test` / `p2b_test` 已通过。 +- [x] **增量 49 —— W0 接口冻结批次**(Phase 1 前置)。为 4 个并行工作流一次性落地共享文件骨架: + - `entity_store.h/.cpp`:`Entity` 新增 `empire / affect_flags / owner_vid / state_flags`; + `mut_spawn_full(const Entity&)`(§2.3 两包合并的单次落地,保留在途插值状态)、 + `mut_ownership(vid, owner_vid)`(§2.5)、`mut_map_bgm(name, volume)` + + `take_bgm_dirty()/bgm_name()/bgm_volume()`(§9.1,`volume<0` = 服务器未指定)、 + `drain_dirty()`(§2.1/§2.5 裸字段更新去重队列);`mut_char_info()` 用 `e.empire = empire` + 取代 `(void)empire;`;`reset_for_map_change()` 一并清 `m_dirty`。 + - `m2_client.cpp`:新增 `bgm_changed(name, volume)` 信号;classic 与 m2dev 两条 pump 回路 + 都消费 `drain_dirty()` → `entity_info` 与 `take_bgm_dirty()` → `bgm_changed`; + `entity_dict()` 补 `empire / affect_flags / owner_vid / state_flags` 键。 + - `classic_parser.cpp/.h`:`GC_MAIN_CHARACTER3_BGM` / `GC_MAIN_CHARACTER4_BGM_VOL` 的 + `bgm_name` / `bgm_volume` 从「读出即丢弃」改为路由到 `mut_map_bgm()`;新增 + `std::unordered_map m_pending_actor` 成员(W1 在 §2.2 填充合并逻辑)。 + - `project/bgm_director.gd`(新):`M2Client.bgm_changed` → `Audio.play_bgm` / `stop_bgm`, + 把地图 BGM 消费点从 `net_world.gd` 拆出,避免 W2(可见性)与 W4(音频)抢改同一文件; + `game_scene.gd` 装配 `_bgm_director` 并 `bind(client, _audio)`。 + - `game_scene.gd` §8.6 / §4.8 键位收敛:数字键 1–4 → `quickbar.activate(0..3)`、 + F1–F4 → `quickbar.activate(4..7)`(原为翻页)、Ctrl/Cmd+1..9 → `_emote(n)` 统一入口 + (表情包发送与本地表情动作由 W3 在 §8.6 补全,当前调用 `client.send_emoticon` 若存在)。 + - 测试:`cmake --build build` 通过;`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` 全绿。无 skip。 + - 未做(留给 Phase 1):BGM 名 → 曲目 / 路径解析与音量真正应用到混音(W4 §9.1)、 + `m_pending_actor` 合并逻辑(W1 §2.2)、§8.6 剩余逐行键位与 LSHIFT 翻页(W3)。 + +仍未完成: + +- [ ] 真实 TCP E2E 证明两次游戏连接,以及第二次连接确实命中 `GC_LOGIN_SUCCESS4` 的槽地址; +- [ ] 移除 `MT_CLASSIC_ENTER_DELAY` 定时发送,改为 Loading/游戏窗口完成初始化后的明确发送点; +- [ ] 真实服务器验证第二次 Warp TCP 连接、目标地址、重连后的 `GC_MAIN_CHARACTER` 定位和旧场景 + 节点无残留;受限环境下 localhost bind 子测试自动 skip。 +- [ ] 地图 BGM / 天气配置、飞行物和 EffectLib 实例的完整按图清理与重建(当前已覆盖基础场景 / UI)。 +- [ ] 版本包 `0xf1` / 可执行文件名 / locale 分支(§1.5);当前仍是已知差距。 + +本节中的“已完成”只表示代码路径和离线门禁已落地;没有真实 40250 服务端证据的项目仍保持“未验证”。 + +## 并行开发与进度同步协议 + +本细则按工作流并行执行,不按 §0–§10 的编号逐条串行实现。章节号用于定位差距,工作流用于分配 +代码边界;依赖关系通过公共接口和集成门禁处理。 + +### 工作流边界 + +| 工作流 | 负责条目 | 主要修改范围 | 不得越界修改 | +|---|---|---|---| +| W0 公共基础 / 集成 | §0、共享会话、实体接口、场景生命周期、文档 | `extension/src/net/m2_client.*`、`entity_store.*`、`classic_session.*`、`project/game_scene.gd`、两份差距文档 | 其他工作流不得各自复制网络核心或场景 reset;接口改动必须回 W0 | +| W1 协议 / 账号 | §1、Classic wire / parser、登录、选人、DirectEnter、断线 | `extension/src/net/classic/*`、`extension/tests/net_classic_*`、登录 / 选人专属脚本 | 不直接重写 W0 的公共信号和 EntityStore 生命周期 | +| W2 实体 / 世界 / 战斗 | §2–§3、移动、动作、技能、目标和换图后的实体表现 | `project/net_world.gd`、`net_play.gd`、`player_controller.gd`、`project/world/*`、动画 / 战斗扩展和测试 | 不把 UI 状态写入协议层;共享 reset 只通过 W0 接口接入 | +| W3 玩法 UI / 输入 | §4–§5、§8、背包、任务、社交、IME、键位 | `project/ui/*`、`hud.gd`、`login.gd`、`charselect.gd`、UI 测试和本地化资源 | 不修改 Classic parser;缺少真服时使用 fixture,并注明未验证 | +| W4 渲染 / 资产 / 音频 | §6–§7、§9、模型、特效、地图、材质、天气、BGM、生命周期表现 | `extension/src/{gr2_bridge,metin2_world,terrain_splat,m2_material,environment_builder}.*`、`project/fx/*`、音频 / 渲染专属脚本和测试 | 不把渲染 fallback 塞进网络状态机;场景钩子由 W0 提供 | +| W5 打包 / 发布 / 验收 | §10、Mac App 自包含资源、架构、签名、发布配置和验收矩阵 | `project/export_presets.cfg`、打包 / 发布脚本、`extension/CMakeLists.txt`、发布验收文档 | 不以“能启动”代替资源、架构、签名和真实功能验收 | + +### 执行波次 + +1. **G0 公共基础冻结**:W0 维护 `enter_game`、`warp`、`world_reset`、实体快照、UI 信号和 + 测试 fixture 的契约,并在本文登记影响文件。 +2. **并行波 A**:W1–W5 按上表同时开发;每条工作流先使用 loopback、fixture、最小地图或 debug + 构建完成自己的回归,不等待其他工作流的全部功能。 +3. **G1 接口集成**:W0 合并共享接口,执行扩展构建、原生网络测试和受影响的 Godot 测试;冲突 + 只在这一层解决,不在各工作流中反复复制修复。 +4. **并行波 B**:未阻塞的协议细节、窗口行为、渲染资产、音频生命周期和打包检查继续并行;真实 + 服务端、双账号和 Mac App 验收按矩阵分别记录。 +5. **G2 发布门禁**:只有代码回归、真实链路(适用时)、资源包、架构 / 签名和文档状态都齐全, + 对应条目才可从“开发中 / 待验收”改为“完成”。受限环境中的 `skip` 只能记为未验证。 + +### 每批次必须同步文档 + +每个开发批次都要在**开始、完成、阻塞、重新验证**时更新本文和 `CLIENT-GAP.md`。代码提交而没有 +对应文档记录时,不得把条目标成完成。 + +批次记录至少包含: + +- `W0–W5` 工作流、§ 条目、状态(⬜ 未开始 / 🟡 开发中 / ✅ 完成 / ⚠️ 阻塞 / 🔎 待真实验收); +- 参考文件与行号、修改文件、接口 / 数据结构变化; +- 自动化测试命令和结果,包含被 `skip` 的测试及原因; +- 真服 / 双账号 / Mac App 验收证据,或明确列出尚缺证据; +- 风险、依赖、文件冲突和下一批次。 + +同步职责固定为:`CLIENT-GAP.md` 更新总览表与变更记录,本文更新对应条目的实现细节、测试证据和 +实施进度;W0 集成维护者统一合并文档,避免多个工作流覆盖彼此的状态。每次合并后应检查两份文档的 +状态、数字、当前路径和“已完成 / 未验证”表述一致。 + +--- + +## 0. 先决:参考端的整体骨架 + +改任何一条之前,必须先认这套骨架,否则单条修改会互相打架。 + +### 0.1 连接与阶段(唯一真相) + +参考客户端**只有两条 TCP**:`CAccountConnector`(认证)与 `CPythonNetworkStream`(游戏), +且后者**在一次登录里会连接两次**。 + +``` +1) CAccountConnector::Connect(gameAddr, gamePort, authAddr, authPort) REF/UserInterface/AccountConnector.cpp:37 + └ 记住 game 地址,连的是 auth 地址 +2) auth 上发 CG_LOGIN / CG_LOGIN3 → GC_AUTH_SUCCESS{bResult, dwLoginKey} + └ __AuthState_RecvAuthSuccess() REF/UserInterface/AccountConnector.cpp:451 + bResult==0 → Python OnLoginFailure("BESAMEKEY") + bResult!=0 → rkNet.SetLoginKey(dwLoginKey); rkNet.Connect(gameAddr, gamePort); + 随后 AccountConnector 立刻 Disconnect() +3) 游戏连接 #1:HandShake → Login → Select + Login 阶段:m_dwLoginKey != 0 → SendLoginPacketNew() = TPacketCGLogin2{header=109, + login_key, name[ID_MAX_NUM+1], adwClientKey[4]} REF/.../PythonNetworkStreamPhaseLogin.cpp:274 + Select 阶段:GC_LOGIN_SUCCESS3/4 填 m_akSimplePlayerInfo[4](含每槽 lAddr / wPort) +4) 点“开始”:introselect.py:754 `net.DirectEnter(chrSlot)` + → CPythonNetworkStream::ConnectGameServer(iChrSlot) REF/.../PythonNetworkStream.cpp:469 + __DirectEnterMode_Set(iChrSlot) + CNetworkStream::Connect((DWORD)slot.lAddr, slot.wPort) ← **用角色槽里的地址,不是配置地址** +5) 游戏连接 #2(DirectEnterMode): + HandShake:不通知 Python REF/.../PythonNetworkStreamPhaseHandShake.cpp:114 + Login :同样 SendLoginPacketNew,不通知 Python,不清角色数据 + Select :**直接** PyCall SetLoadingPhase() REF/.../PythonNetworkStreamPhaseSelect.cpp:28 + Loading :__DirectEnterMode_Initialize() REF/.../PythonNetworkStreamPhaseLoading.cpp:190 +``` + +阶段切换只由 `GC_PHASE`(0xfd) 驱动,`TPacketGCPhase.phase ∈ {PHASE_CLOSE, PHASE_HANDSHAKE, +PHASE_LOGIN, PHASE_SELECT, PHASE_LOADING, PHASE_GAME, PHASE_DEAD}` +(`REF/UserInterface/Packet.h:1124`,处理见 `PythonNetworkStream.cpp:591 RecvPhasePacket`)。 + +### 0.2 实体两层模型(唯一真相) + +参考端**数据层**与**表现层**是分开的,这是 §2 全部条目的根因: + +| 层 | 参考类 | 生命周期 | +|---|---|---| +| 数据 | `CNetworkActorManager::m_kNetActorDict`(`std::map`) | 只由 `GC_CHARACTER_ADD/DEL` 增删,**不随可见性变化** | +| 表现 | `CPythonCharacterManager` 的 `CInstanceBase` | 按距离 `CHAR_STAGE_VIEW_BOUND = 200*100` cm 懒创建 / 淡出删除 | + +- 每帧 `CNetworkActorManager::__OLD_Update()`(`REF/UserInterface/NetworkActorManager.cpp:157`) + 遍历 `m_kNetActorDict`,插值位置;发现「有数据但没实例」且 `__IsVisibleActor()` 为真时才建实例。 +- `CPythonCharacterManager::Update()`(`REF/.../PythonCharacterManager.cpp:182`)把 + `距离 > CHAR_STAGE_VIEW_BOUND + 10` 的实例 `__DeleteBlendOutInstance` 掉,**数据仍在**。 +- `__IsVisibleActor()`(`NetworkActorManager.cpp:295`):主角 VID / `AFFECT_SHOW_ALWAYS` / + 距离内 / `IsWall(race)` 四者任一为真。 + +### 0.3 PC/NPC 的两包合一(§2.2 的根因) + +`GC_CHARACTER_ADD` 对 `TYPE_PC` / `TYPE_NPC` **不立刻创建**,而是暂存到 +`static SNetworkActorData s_kNetActorData`;等 `GC_CHAR_ADDITIONAL_INFO` 同 VID 到达后合并再 +`AppendActor`(`REF/UserInterface/PythonNetworkStreamPhaseGameActor.cpp:83` 与 `:140`): + +- `RecvCharacterAppendPacket` 显式把 `alignment / pkMode / guild / empire / armor / weapon / + hair / mount / level` **全部置 0**,它们只能来自 additional info。 +- 非 PC/NPC(怪 / 石 / 门 / 器物)立即 `AppendActor`,名字取 + `CPythonNonPlayer::GetName(race)`。 +- `IsInvisibleRace(20025 / 20038 / 20039)` → 直接丢弃(`PhaseGameActor.cpp:66`)。 +- 若 additional info 的 VID 与暂存不符:`TraceError` 并丢弃,**不创建实体**。 + +--- + +## 1. P0:连接、协议和生命周期 + +### 1.1 Classic 直连分支(第一批已接入,仍待 E2E) + +**参考**:不存在“直连 vs Auth”的二选一。参考端固定是 §0.1 的四步,其中 +“进入游戏”这一步**必须**用角色槽自带的 `lAddr` / `wPort` 重新连一次 +(`REF/UserInterface/PythonNetworkStream.cpp:469`),并置 `__DirectEnterMode`。 + +**当前(第一批已修改)**:登录入口仍由 `M2Client::connect_to_server()`(`POC/extension/src/net/m2_client.cpp`) +走 Auth → Game;收到角色列表后,`M2Client.enter_game(index)` 会调用 +`ClassicSession::connect_direct_enter(index)`,断开第一条游戏连接并按角色槽 `lAddr/wPort` 建立第二条 +游戏连接。新连接的 LOGIN 使用登录 key,SELECT 阶段自动发送 `CG_CHARACTER_SELECT`。 + +**修改** + +1. `classic_session.h/.cpp`:把两个 `connect()` 重命名为语义化的两段,并新增第三段(**待完成的 + 清理项**;当前先保留原入口以减少无关改动): + - `connect_auth(auth_host, auth_port, id, pw)` —— 现有带 auth 的重载,**只做认证**; + 收到 `GC_AUTH_SUCCESS` 后保存 `m_auth_login_key`,随即**关闭 auth 流**。 + - `connect_game(game_host, game_port)` —— 用 `m_auth_login_key` 走 `CG_LOGIN2` + (已有 `connect_with_login_key`,改为公开主路径)。 + - `connect_direct_enter(slot_addr, slot_port, slot_index)` —— 新增。置 + `m_direct_enter = true; m_direct_enter_slot = slot_index;`,再 `m_stream.connect()`。 +2. `ClassicParser`:`GC_LOGIN_SUCCESS3/4` 解析时把每槽的 `lAddr`(LONG, 小端 4 字节 IP) 与 + `wPort` 存进 `SimplePlayer`(当前只存了展示字段)。IP 还原按参考 + `GetAccountCharacterSlotDataz`(`PythonNetworkStream.cpp:420`): + `ip[i] = (lAddr >> (8*i)) & 0xff`,格式化为 `"%d.%d.%d.%d"`。 +3. ✅ `M2Client::enter_game(index)` 已接入:调用 + `classic_sess->connect_direct_enter(index)`,即**断开当前连接、按角色槽地址重连**; + `app_flow.gd`、`charselect.gd`、`login.gd` 已优先使用该入口,旧 fake client 仍可回退到 + `select_character()`。 +4. `ClassicSession::pump()` 在 `m_direct_enter == true` 时: + - `Stage::Connecting`(HandShake)不发 `phase_changed` 给选人 UI; + - `Stage::CharSelect` 一进入就自动推进到 `Stage::Loading`(等价 + `PythonNetworkStreamPhaseSelect.cpp:28` 的 `SetLoadingPhase`), + 当前仍保留 `set_auto_entergame_delay(1500)` 的兼容性定时发送,**这一步尚未完成**; + 后续应改为 Loading/游戏窗口完成初始化后的明确发送点; + - `Stage::Loading` 入口清 `m_direct_enter`(等价 `PhaseLoading.cpp:190`)。 +5. `MT_CLASSIC_ENTER_DELAY` 环境变量连同 `m2_client.cpp` 的兼容性注入仍未删除——参考端 + `CG_ENTERGAME` 的时机是 Python `game.py:207 net.SendEnterGamePacket()`,即 + **游戏窗口 Open() 的最后一步**,不是定时器。 + +**已完成的离线验收**:角色槽 `lAddr/wPort` 能被解析并保存;`M2Client.enter_game` 已注册; +DirectEnter 的状态推进和第二次连接入口已接入。**待验收**:`net_e2e` 观察两次 TCP 连接,且第二次 +目标地址来自 `GC_LOGIN_SUCCESS4` 槽位数据。 + +--- + +### 1.2 游戏中断线回登录(第一批已接入,仍待 E2E) + +**参考**:`CNetworkStream` 收到远端关闭 → `OnRemoteDisconnect()` +(`REF/EterLib/NetStream.cpp:332/343/351`);`CPythonNetworkStream` 的覆盖实现 +(`REF/UserInterface/PythonNetworkStreamEvent.cpp:4`)**只有一行**: + +```cpp +void CPythonNetworkStream::OnRemoteDisconnect() +{ + PyCallClassMemberFunc(m_poHandler, "SetLoginPhase", Py_BuildValue("()")); +} +``` + +即:**任何阶段断线一律回登录阶段**。参考端没有“重连遮罩 + 倒计时 + 自动重连”。 +主动登出也走同一条:`AbsoluteExitGame()` = `OnRemoteDisconnect(); Disconnect();` +(`PythonNetworkStream.cpp:227`)。 + +**当前(第一批已修改)**:`pump_classic()` 在已经建立过游戏连接后发 +`disconnected(reason)`,认证 / 建立游戏连接前的失败仍发 `login_failed`; +`AppFlow._on_disconnected` 无条件回 LOGIN。`ReconnectUI` 已从 `app_flow.gd` 主流程移除,文件仍保留 +供旧测试 / 独立增强使用。 + +**修改** + +1. ✅ `m2_client.cpp` `pump_classic()` 已按“游戏连接是否曾进入 Online”区分——认证失败 / + 游戏连接尚未建立 → `login_failed`;已经建立过游戏连接的后续断开 → **`disconnected(reason)`**。 + `ClassicSession::was_online_lost()` 提供该判定。 +2. ✅ `app_flow.gd` 的 `_on_disconnected()` 已改为**无条件** `_goto_login()`,并附 + `_set_status(reason)` —— 与 `SetLoginPhase()` 1:1。 +3. **`reconnect_ui.gd` 的去留是产品选择,不是必然修复**。 + `AUTO_DELAY=5.0` / `MAX_TRIES=5` 是本工程自造,参考端不存在,所以 + **它不能计入 1:1 完成度**;但保留与否不影响本条的缺陷修复—— + 第 1、2 步修好信号链路后: + - 走严格 1:1 → 删除 `reconnect_ui.gd` 及其在 `app_flow.gd` 的挂载; + - 保留自动重连 → 另立需求,并在 `CLIENT-GAP.md` 标为「超出参考范围的增强」。 + 第 1、2 步已落地;剩余是产品是否删除独立的 `reconnect_ui.gd`,以及真实断线验收。 +4. `M2Client::logout()` / ESC 菜单的 `/logout`:按 `ExitGame()`(`PythonNetworkStream.cpp:204`) + 的两分支——`__IsNotPing()` 恒为 `false`(`PythonNetworkStream.cpp:243`), + 所以实际总是走 `SendChatPacket("/phase_select")` / `"/logout"`, + **不是**本地断开。现有 `system_menu_ui.gd` 已符合,保持。 + +--- + +### 1.3 跨服或换图的首版生命周期(已实现,仍待真服) + +**参考**:换图/跨服的清理分布在三处,必须全部照做。 + +- `SetLoadingPhase()`(`REF/.../PythonNetworkStreamPhaseLoading.cpp:180`): + ```cpp + CPythonPlayer::Instance().Clear(); + CFlyingManager::Instance().DeleteAllInstances(); + CEffectManager::Instance().DeleteAllInstances(); + __DirectEnterMode_Initialize(); + ``` +- `CNetworkActorManager::SetMainActorVID(vid)`(`NetworkActorManager.cpp:139`): + `m_dwMainVID=vid; m_lMainPosX=m_lMainPosY=0; m_kNetActorDict.clear();` + —— 由 `RecvMainCharacter*` 调用(`PhaseLoading.cpp:203` 等),即**每次进图清空整张实体表**。 +- 主角 `AppendActor` 时(`NetworkActorManager.cpp:409`)若不是上下马导致的重建: + `__RemoveDynamicActors(); __RemoveAllGroundItems();` + (`__RemoveDynamicActors` 只删 `IsPC() || IsNPC() || IsEnemy()`,保留门/器物)。 +- Python 侧 `game.py:221 GameWindow.Close()`:`snd.FadeOutMusic` → `chat.Close()` → + `snd.StopAllSound()` → `grp.InitScreenEffect()` → `chr.Destroy()` → `textTail.Clear()` → + `quest.Clear()` → `background.Destroy()`,顺序不可换。 +- BGM 重选:`game.py:194` + ```python + netFieldMusicFileName = net.GetFieldMusicFileName() + if netFieldMusicFileName: snd.FadeInMusic("BGM/" + netFieldMusicFileName) + elif musicInfo.fieldMusic: snd.FadeInMusic("BGM/" + musicInfo.fieldMusic) + ``` + +**当前(增量 47)**:Classic `GC_WARP` 已透传包内坐标和 `lAddr:wPort`,`M2Client.pump_classic()` +先调用 `EntityStore::reset_for_map_change()`,发出 `world_reset`,再通过同一个 +`ClassicSession::connect_warp()` 保留 login key / 已选角色槽并重连目标端点。Classic 的 `WarpCue` +不再以 `addr==0` 判定同服;m2dev 后端仍保留自己的旧协议语义,两者不混用。 + +Godot 侧 `GameScene` 已将 `world_reset` 映射为:清理 `NetWorld` / `GroundItems` 节点和 PVP / 决斗、 +目标、交互窗;收到 Warp 坐标后按服务器坐标重新解析地图、重建 `Metin2World`、HUD 小地图、 +Atlas、环境和玩家定位。背包、任务、好友等持久账号数据不在该清理中删除。 + +**修改** + +1. ✅ `EntityStore::reset_for_map_change()` 清空实体、地面物、PVP / 决斗、目标、技能 / affect、 + 观察者 / 地图 marker、交互状态和旧事件队列;保留背包、任务、好友、公会等持久数据。 +2. ✅ `net_world.gd` / `ui/ground_items.gd` 增加清理入口;`M2Client.world_reset` 由 `GameScene` + 关闭临时窗口、清除表现节点并停止当前 BGM。 +3. ✅ `ClassicSession::connect_warp(host, port)` 保留 login key 与选人槽,在同一 session 上 + 重置连接状态并进入 DirectEnter;`M2Client` 对 Classic 的每个 Warp 都按包内 `lAddr:wPort` + 连接,失败进入既有断线链路。 +4. ✅ `GameScene._reload_map_for_warp()` 根据 Warp 坐标重选地图,重建 `Metin2World`、HUD 小地图、 + Atlas 和环境引用,并重新贴地定位玩家。 +5. ⬜ 仍需按 `game.py:194` 的 `GetFieldMusicFileName()` / `musicInfo.fieldMusic` 实现地图 BGM + 自动选择;还需补飞行物 / EffectLib 的逐实例清理、真实服务换图矩阵和受控画面验收。 + +--- + +### 1.4 选人和进入游戏发送结果(第一批已修复) + +**参考**:每个发送函数都是 `if (!Send(...)) { Tracen(...); return false; } return SendSequence();` +(`REF/.../PythonNetworkStreamPhaseSelect.cpp:161 SendSelectCharacterPacket`、 +`:177 SendDestroyCharacterPacket`、`:194 SendCreateCharacterPacket`、 +`:217 SendChangeNamePacket`,`PhaseLoading.cpp:344 SendEnterGame`)。 +`SendEnterGame` 还额外要求 `SendSequence()` 成功后 `__SendInternalBuffer()`。 + +**当前(第一批已修改)**:`ClassicSession::select_char()` / `send_select_char()`、登录包和 +`enter_game()` 已透传 `send_fixed()` 结果;`enter_game()` 发送成功后再执行 +`flush_internal()`,并且只在发送成功后置 `m_entergame_sent`。`M2Client` 和选人相关 GDScript +也优先调用新的 `enter_game(index)`,旧测试 fake client 保留 `select_character()` 回退。 + +**修改** + +1. ✅ `classic_session.cpp` 的选人路径 → `return m_stream.send_fixed(&p, sizeof(p));` +2. ✅ `classic_session.cpp` 的进场路径 → + ```cpp + if (!m_stream.send_fixed(&p, sizeof(p))) return false; + m_entergame_sent = true; + return m_stream.flush_internal(); // = __SendInternalBuffer() + ``` + `m_entergame_sent` 只在**发送成功后**置位。 +3. ✅ 登录 / 认证发送路径也已检查 `send_fixed()` 返回值;其它玩法发送函数原本已透传。 +4. ✅ `M2Client.enter_game()` / `select_character()` 透传失败结果。GDScript 当前保留按钮状态, + 但失败提示 / 可重试文案仍是后续 UI 细化项。 + +--- + +### 1.5 客户端版本包是硬编码的 + +**参考**:`SendClientVersionPacket()`(`REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:4155`) + +```cpp +std::string filename; +GetExcutedFileName(filename); // 真实可执行文件全路径 +filename = CFileNameHelper::NoPath(filename); // 只留文件名 +CFileNameHelper::ChangeDosPath(filename); + +if (LocaleService_IsEUROPE() && !LocaleService_IsYMIR()) { + TPacketCGClientVersion2 { header = HEADER_CG_CLIENT_VERSION2 (0xf1), + filename[33], timestamp[33] = "1215955205" } +} else { + TPacketCGClientVersion { header = HEADER_CG_CLIENT_VERSION (0xfd), + filename[33], timestamp[33] = __TIMESTAMP__ } +} +return SendSequence(); +``` + +**这份 ClientVS22 实际走哪一支是确定的**:`REF/UserInterface/Locale_inc.h:1` 定义 +`LOCALE_SERVICE_SINGAPORE`;`Locale.cpp:186` 的 +`#if defined(LOCALE_SERVICE_EUROPE) || … || defined(LOCALE_SERVICE_SINGAPORE) || …` +把 `_LSS_SERVICE_NAME` 设为 `LSS_EUROPE`,于是 +`LocaleService_IsEUROPE() == true`、`LocaleService_IsYMIR() == false`。 +**参考客户端发的是 `HEADER_CG_CLIENT_VERSION2 = 0xf1`,timestamp = `"1215955205"`。** + +调用点是 4 个 `RecvMainCharacter*` 的**最后一行**(`PhaseLoading.cpp:214/240/267/294`)。 + +**当前**:`ClassicSession::send_client_version()`(`classic_session.cpp:335`)发 +`HDR_CG_CLIENT_VERSION`(0xfd) + `"metin2.bin"` + `"1215955205"` —— **header 走错分支** +(0xfd 对应的 timestamp 应该是 `__TIMESTAMP__`,而 `"1215955205"` 只属于 0xf1 分支)。 + +**修改** + +1. `wire_classic.h` 补 `HDR_CG_CLIENT_VERSION2 = 0xf1` 与 + `struct CGClientVersion2 { uint8_t header; char filename[33]; char timestamp[33]; };` + `static_assert(sizeof(CGClientVersion2) == 67)`(与 `CGClientVersion` 同布局)。 +2. `send_client_version()` 按 locale 分支: + - `Locale.is_europe() && !Locale.is_ymir()` → 发 `CGClientVersion2{0xf1, filename, "1215955205"}`; + - 否则 → 发 `CGClientVersion{0xfd, filename, MT_BUILD_TIMESTAMP}`。 + 本工程默认 locale 与参考一致(EUROPE 系),**默认走 0xf1**。 +3. `filename` 改为运行时取:`OS::get_executable_path().get_file()`,由 `M2Client` 传入; + 取不到时回落 `"metin2.bin"`。 +4. `MT_BUILD_TIMESTAMP` 由 CMake 注入,格式对齐 C 的 `__TIMESTAMP__` + (`"Www Mmm dd hh:mm:ss yyyy"`),只在非 EUROPE 分支使用。 +5. 发送时机:跟随 `GC_MAIN_CHARACTER*`(4 个变体)解析成功后立刻发,并且 + `return SendSequence()`(本工程即 `m_stream.send_fixed()` 的返回值透传), + 删除现有的独立触发点与 `m_version_sent` 早置位。 + +### 1.6 Passpod / HackShield / XTrap / StateChecker 兼容路径 + +**参考**:均存在于 `REF/UserInterface/`:`HackShield.cpp`、`WiseLogicXTrap.cpp`、 +`NProtectGameGuard.cpp`、`ServerStateChecker.cpp`,以及 +`CAccountConnector::SendNEWCIBNPasspodAnswerPacket`(`AccountConnector.cpp:66`, +`HEADER_CG_NEWCIBN_PASSPOD_ANSWER`)。 + +`CLIENT-GAP.md` 的「不做清单」已把反作弊标注为 ❌。本条**不改代码**,只补文档: + +**修改** + +1. 在 `CLIENT-GAP.md` 的不做清单里把 HackShield / XTrap / NProtect / Passpod 显式列入 + (目前只写了 `CG_HACK` 与桌面外壳)。 +2. `ServerStateChecker`(服务器列表状态查询,UDP)**不属于反作弊**,参考端用于登录页显示 + 频道负载。若目标服务端开着它,需要单独立项;本文不在此展开。 +3. `ClassicParser` 对这些 header 的处理按 §1.8 的规则(记日志 + 清接收缓冲), + 不静默吞。 + +--- + +### 1.7 未知包 Loading 重同步(第一批已修复) + +**参考**:`CPythonNetworkStream::CheckPacket()`(`REF/UserInterface/PythonNetworkStream.cpp:533`) + +```cpp +if (!s_packetHeaderMap.Get(header, &PacketType)) { + TraceError("Unknown packet header: %d, last: %d %d", header, g_iLastPacket[0], g_iLastPacket[1]); + ClearRecvBuffer(); + PostQuitMessage(0); // ← 直接结束进程 + return false; +} +``` + +另有一条**唯一合法的重同步**:`header == 0` 时连续 `Recv` 吞掉零字节(填充), +直到读到非零 header 为止(`PythonNetworkStream.cpp:505-524`,在 `CheckPacket` `:498` 内)。 +参考端**没有**“扫描后续字节找 GC_CHARACTER_ADD 边界”这种启发式。 + +**当前(第一批已修改)**:`classic_stream.cpp` 已删除 Loading 阶段的启发式边界扫描;未知 +header 记录最近两个已识别 header,清空接收缓冲并断开。`header == 0` 的填充跳过仍在尺寸表 +查询前执行。 + +**修改** + +1. ✅ 删除 `classic_stream.cpp` 的整段启发式恢复逻辑。 +2. ✅ 未知 header 的处理已改为移植后的最小安全语义: + ```cpp + log_error("Unknown packet header: %u, last: %u %u", header, m_last_pkt[0], m_last_pkt[1]); + m_recv.clear(); + m_last_error = "unknown GC header " + std::to_string(header); + disconnect(); // 等价 PostQuitMessage(0) 在本工程里的最小语义 + return; + ``` + 并新增 `uint8_t m_last_pkt[2]`,在每次成功识别 header 后按 + `m_last_pkt[0]=m_last_pkt[1]; m_last_pkt[1]=header;` 更新(`PythonNetworkStream.cpp:575`)。 + **落地限定**:`PostQuitMessage(0)` 是 Windows 的「结束进程」,移植时**不要字面照搬**—— + 映射为「明确的协议错误 + 断线」即可(走 §1.2 的 `disconnected` → 回登录页)。 + 要保住的语义只有两条:**不做启发式重同步**、**不静默继续**。 +3. ✅ `header == 0` 的填充跳过循环位于尺寸表查询之前,与参考同序。 +4. ✅ `disconnect()` 触发 §1.2 的 `disconnected` → 回登录页;这对应 Godot 移植中 + `PostQuitMessage(0)` 的安全映射。真实 TCP 断线场景仍待 E2E 验收。 + +--- + +### 1.8 已知但没有业务处理的包(第一批已修复静默失败) + +**参考**:`CPythonNetworkStream::RecvErrorPacket(header)`(`PythonNetworkStream.cpp:582`) + +```cpp +TraceError("Phase %s does not handle this header (header: %d, last: %d, %d)", + m_strPhase.c_str(), header, g_iLastPacket[0], g_iLastPacket[1]); +ClearRecvBuffer(); +return true; +``` + +六个阶段函数的 `switch` **落空即调它**(`PhaseOffline.cpp:21`、`PhaseHandShake.cpp:94`、 +`PhaseLogin.cpp:90`、`PhaseSelect.cpp:142`、`PhaseLoading.cpp:166`、`PhaseGame.cpp:673`)。 +注意语义是**丢弃整个接收缓冲**(不是丢一个包),并返回 true 让循环继续。 + +**当前(第一批已修改)**:`ClassicParser` 的 `default:` 分支会记录阶段名、header,并返回 +`false`;`ClassicSession` 将错误传给 `ClassicStream`,后者清空接收缓冲并断开,避免形成 +“连接不断但功能静默失效”。可选的反作弊 / Passpod / Matrix 等集成包也走同一条显式失败路径。 + +**修改** + +1. ✅ `ClassicParser` 增加成员 `m_phase_name`(`"Offline"/"HandShake"/"Login"/"Select"/"Loading"/"Game"`), + 由 `ClassicSession` 在阶段切换时写入。 +2. ✅ `default:` 分支改为: + ```cpp + default: + log_error("Phase %s does not handle this header (header: %u, last: %u, %u)", + m_phase_name, header, m_last_pkt[0], m_last_pkt[1]); + m_unhandled_headers.push_back(header); // 供测试断言 + return false; // 让 stream 执行 ClearRecvBuffer + ``` +3. ✅ `classic_stream.cpp` 在 parser 返回 false 时通过断开路径清空 `m_recv`,并触发显式错误 + 回调;与参考的 `ClearRecvBuffer()` 相比,本移植选择“清空 + 断开”以保持错误不可继续。 +4. **待完成**:按阶段分表,把现在“一个大 switch 处理所有 header”拆成 6 个阶段表,与参考的 + `OffLinePhase/HandShakePhase/LoginPhase/SelectPhase/LoadingPhase/GamePhase` 一一对应。 + 这样“Select 阶段收到 GC_MOVE”才会像参考一样报错,而不是被当成合法包消费。 +5. **待完成**:新增 `classic_phase_table_test.gd`,对每个阶段喂一个不属于该阶段的合法 header, + 断言 `unhandled_headers` 记录且缓冲被清;当前已有 C++ 回归覆盖未知包、未处理包和不重同步。 + +--- + +### 1.9 封包覆盖仍停留在结构和尺寸层 + +**参考**:`REF/EterLib/NetStream.cpp:520-620` 的 `CMainPacketHeaderMap` 是尺寸表; +真正的“完成”判据是每个 header 在**对应阶段函数**里有 case,且 case 里 +① 完整 `Recv` ② 写业务状态 ③ 通知 Python 窗口 ④ 需要时回包。 + +**修改**(流程性,不是单点改代码) + +1. 从 `REF/EterLib/NetStream.cpp:520` 起的 header 名表导出全量清单,生成 + `POC/docs/PACKET-MATRIX.md`,每行五列:`header / 尺寸 / 所属阶段 / 参考处理函数 / POC 实现位置`。 +2. 逐行核对上文 ①②③④ 四项,只有四项齐备才标 ✅。 +3. 动态长度包(`PacketType.isDynamicSizePacket`)另标一列,核对 + `TDynamicSizePacketHeader{header, size}` 的 `Peek(size)` 完整性校验(`PythonNetworkStream.cpp:540`)。 +4. 该矩阵替代 `CLIENT-GAP.md` 现在的“缺失 header 计数”。 + +--- + +### 1.10 文本编码和固定长度字段没有统一策略 + +**参考常量**(`REF/UserInterface/StdAfx.h:43`、`Packet.h:380`): + +| 常量 | 值 | 说明 | +|---|---|---| +| `CHARACTER_NAME_MAX_LEN` | 24 | 角色名字节数;结构里一律 `char[25]` | +| `ID_MAX_NUM` | 30 | 账号;`char[31]` | +| `PASS_MAX_NUM` | 16 | 密码;`char[17]` | +| `CHAT_MAX_NUM` | 128 | 聊天 | +| `SHOP_SIGN_MAX_LEN` | 32 | 私店招牌 | +| `GUILD_NAME_MAX_LEN` | 12 | 公会名 | +| `PRIVATE_CODE_LENGTH` | 8 | 删角色私密码 | +| `QUEST_INPUT_STRING_MAX_NUM` | 64 | 任务输入 | + +参考的写法一律是 `strncpy(dst, src, sizeof(dst)-1)`(见 +`SendCreateCharacterPacket`/`SendChangeNamePacket`/`SendDirectEnterPacket`), +且 `SendLoginPacket` 额外显式补 `name[ID_MAX_NUM]='\0'`。 +参考是**单字节本地编码**(`REF/UserInterface/Locale.cpp` + `EterLocale`),不是 UTF-8。 + +**修改** + +1. 新建 `POC/extension/src/net/text_codec.h`: + - `std::string to_wire(const godot::String&, size_t cap)`:按当前 locale 代码页转换后**按字节** + 截断到 `cap`,且不允许在多字节字符中间截断(截到最后一个完整字符)。 + - `godot::String from_wire(const char*, size_t cap)`:反向,遇 `\0` 停。 + - 代码页由 `Locale.gd` 的 locale 决定,与 `REF/UserInterface/Locale_inc_*.h` 一一对应。 +2. `wire_classic.h` 的所有 `char name[...]` 字段统一经 `to_wire()` 填充,删掉散落的 + `memcpy` / `strncpy(utf8)`。 +3. 角色名校验**统一为 `<= 24` 字节**(`CHARACTER_NAME_MAX_LEN = 24`,缓冲 25 含结尾符)。 + 当前代码里 `< 24` 与 `<= 24` 两种规则各出现过,全部改为 `to_wire(name, 24).size() > 0 && + <= 24`。涉及:`charselect.gd` 建号 / 改名、`guild_creation_ui.gd`(公会名上限 12)。 +4. 回归:`text_codec_test.gd` 覆盖 CJK 名恰好 24 字节、25 字节(应拒)、边界多字节截断。 + +--- + +### 1.11 登录错误、超时、会话失效和服务器列表状态没有完整映射 + +**参考** + +- `GC_LOGIN_FAILURE` → `TPacketGCLoginFailure.szStatus`(字符串), + 直接透给 Python `OnLoginFailure(szStatus)`(`REF/.../PythonNetworkStreamPhaseLogin.cpp:222`)。 + 文案映射在 `POC/assets/root/intrologin.py:390 OnLoginFailure()` 里(本仓已有该脚本)。 +- `GC_AUTH_SUCCESS.bResult == 0` → `OnLoginFailure("BESAMEKEY")` + (`REF/UserInterface/AccountConnector.cpp:460`)。 +- 连接失败 → `CPythonNetworkStream::OnConnectFailure()`(`PhaseLogin.cpp:211`): + `__DirectEnterMode_IsSet()` 为真则 `ClosePhase()`,否则 + `PyCall(PHASE_WINDOW_LOGIN, "OnConnectFailure")`。 +- 阶段专用:`RecvErrorPacket` 的文案带 `m_strPhase`(见 §1.8)。 + +**修改** + +1. `ClassicParser` 保留 `login_failure()` 的**原始 szStatus 字符串**(不要翻译、不要拼接前缀)。 +2. `M2Client` 的 `login_failed` 信号第二参数新增 `code: String`(= szStatus), + 现有第一参数保留为可读描述。 +3. `login.gd` 增加 `OnLoginFailure` 文案表,逐条抄 `POC/assets/root/intrologin.py:390` 起的 + `localeInfo` 键;未命中的 code 原样显示(与参考一致)。 +4. `GC_AUTH_SUCCESS.bResult == 0` 走同一条,code 固定 `"BESAMEKEY"`。 +5. 连接失败:`direct_enter` 期间 → 触发 `ClosePhase` 等价(回登录页,不弹错误框); + 否则 → `login.gd` 的 `OnConnectFailure()`。 + +--- + +## 2. P0/P1:实体、世界和状态一致性 + +> 本节全部条目的前提是 §0.2 的**两层模型**。先做 2.1 的重构,其余条目才有落点。 + +### 2.1 未知 VID 的更新包会直接丢失 + +**参考**:参考端**也**丢弃未知 VID 的更新,但丢的是「不在 `m_kNetActorDict` 里的 VID」, +而不是「没有场景节点的 VID」: + +```cpp +CNetworkActorManager::UpdateActor / MoveActor / SyncActor / SetActorOwner + auto f = m_kNetActorDict.find(vid); + if (f == end) { TraceError("... NOT EXIST VID"); return; } // 仅 _DEBUG 下报错 + SNetworkActorData& d = f->second; + CInstanceBase* inst = __FindActor(d); // 可见则**按需创建**实例 + if (inst) { ...改实例... } + ...无论实例在不在,都更新 d... +``` +(`REF/UserInterface/NetworkActorManager.cpp:456 / 508 / 536 / 553`) + +关键点有三: +1. **数据表更新与实例存在与否解耦**——`UpdateActor` 末尾无条件写回 `rkNetActorData` 的 + `affect / guild / movSpd / atkSpd / armor / weapon / hair / alignment / pkMode`。 +2. `__FindActor()`(`NetworkActorManager.cpp:189`)在实例缺失且**可见**时当场创建。 +3. `MoveActor` 用 `__FindActor(d, dstX, dstY)` 的重载,按**目标点**判可见(`:172`)。 + +**当前**:`EntityStore::mut_move/mut_set_position/mut_change_speed/mut_dead/mut_stun` 等在 +`m_ents.find(vid) == end` 时直接 `return`(`POC/extension/src/net/entity_store.cpp:123` 等), +而 `m_ents` 同时充当数据表和「场景应有节点」的清单——两层被合并了。 + +**修改** + +1. `EntityStore` 保持为**数据层**(= `m_kNetActorDict`),并明确:条目只由 + `GC_CHARACTER_ADD`(含 additional info 合流)创建、由 `GC_CHARACTER_DEL` 删除、 + 由 `mut_set_main_vid()`(§1.3)整表清空。**任何其它包都不创建、也不删除条目。** +2. 可见性下沉到 `net_world.gd`(= `CPythonCharacterManager`): + - 每帧对 `EntityStore` 的全部条目做 `__IsVisibleActor` 判定 + (主角 / `AFFECT_SHOW_ALWAYS` / 距离 < `CHAR_STAGE_VIEW_BOUND` / `IsWall(race)`); + - 可见但无节点 → 建节点;节点距离 > `CHAR_STAGE_VIEW_BOUND + 10` → 淡出删除节点。 + - 常量:`const CHAR_STAGE_VIEW_BOUND := 200 * 100 # cm`(`REF/.../PythonCharacterManager.cpp:13`)。 +3. 所有 `mut_*` 保持「未知 VID 直接 return + 记一条 debug 日志」,与参考一致—— + **不要**引入 pending / 重放机制,参考端没有。 +4. `EntityStore` 新增 `drain_dirty()`,`net_world.gd` 只对**已有节点**的脏实体做刷新, + 其余只更新数据。 + +**验收**:`net_entity_test` 补一条——先喂 200 m 外的 `GC_CHARACTER_ADD`,再喂 +`GC_CHARACTER_UPDATE`,断言 `EntityStore` 里字段已更新且没有场景节点;把主角挪近后 +断言节点出现且带更新后的装备。 + +--- + +### 2.2 `GC_CHAR_ADDITIONAL_INFO` 早于 Spawn 时状态不会被待合并缓存 + +**参考**:见 §0.3。参考端**根本不会**出现「additional info 早于 add」——它用 +`static SNetworkActorData s_kNetActorData` 做单槽暂存,`GC_CHARACTER_ADD` 只写暂存槽, +`GC_CHAR_ADDITIONAL_INFO` 才 `AppendActor`。VID 不匹配一律 `TraceError` 丢弃: + +```cpp +// REF/UserInterface/PythonNetworkStreamPhaseGameActor.cpp:140 +SNetworkActorData kNetActorData = s_kNetActorData; +if (IsInvisibleRace(kNetActorData.m_dwRace)) return true; +if (kNetActorData.m_dwVID == chrInfoPacket.dwVID) { ...合并 9 个字段...; __RecvCharacterAppendPacket(&kNetActorData); } +else TraceError("TPacketGCCharacterAdditionalInfo name=%s vid=%d race=%d Error", ...); +``` + +**当前**:`EntityStore::mut_char_info()`(`POC/extension/src/net/entity_store.cpp:458`) +用 `touch(vid, created)` 可以凭空建实体,且新建时不推 `Spawn` change;若后续 Spawn 正常到达, +`NetWorld` 仍会创建节点,但若 Spawn 丢失或顺序异常,实体会长期只有数据没有表现层节点。 + +**修改** + +1. `ClassicParser` 增加成员 `mtnet::PendingActor m_pending_actor;`(等价 `s_kNetActorData`)。 +2. `GC_CHARACTER_ADD` 处理改为: + - `IsInvisibleRace(race)`(20025 / 20038 / 20039)→ 直接返回; + - `bType == CHRTYPE_PC || bType == CHRTYPE_NPC` → **只**写 `m_pending_actor`,不调 `EntityStore`; + - 否则 → 名字取 `npclist.txt` 的 `race → name`(等价 `CPythonNonPlayer::GetName`), + 立即 `mut_spawn(...)`,且 `alignment/pk_mode/guild/empire/parts/mount/level` 全部置 0。 +3. `GC_CHAR_ADDITIONAL_INFO` 处理改为: + - `m_pending_actor.vid != pkt.dwVID` → 记 error 并**丢弃**,不建实体; + - 相等 → 用 pending + additional 的 9 个字段合成一次 `mut_spawn_full(...)` + (新 API,一次性写全字段并推 `ChangeKind::Spawn`)。 +4. **删除** `mut_char_info()` 里的 `touch()` 建实体路径;该函数只保留 + 「已存在实体的字段更新」语义,未知 VID 直接 return。 +5. `mut_shop_sign()`(`entity_store.cpp:483`)不要再创建通用 Entity;改为独立的 + `ShopSign` 表现状态,分别对应参考端 `BINARY_PrivateShop_Appear/Disappear` 的招牌显示 / + 隐藏回调。这里不是把招牌包静默丢弃,而是移除“凭空建 Actor”的补丁。 + +--- + +### 2.3 VID 重用时旧状态可能污染新实体 + +**参考**:`CNetworkActorManager::AppendActor`(`NetworkActorManager.cpp:409`) +```cpp +SNetworkActorData& rkNetActorData = m_kNetActorDict[c_rkNetActorData.m_dwVID]; +rkNetActorData = c_rkNetActorData; // operator= → __copy__ 覆盖**全部** 23 个字段 +``` +`SNetworkActorData::__copy__`(`NetworkActorManager.cpp:53`)逐个赋值 +`m_bType, m_dwVID, m_dwStateFlags, m_dwEmpireID, m_dwRace, m_dwMovSpd, m_dwAtkSpd, m_fRot, +m_lCurX/Y, m_lSrcX/Y, m_lDstX/Y, m_kAffectFlags, m_dwServerSrcTime, m_dwClientSrcTime, +m_dwDuration, m_dwArmor, m_dwWeapon, m_dwHair, m_dwOwnerVID, m_sAlignment, m_byPKMode, +m_dwMountVnum, m_dwGuildID, m_dwLevel, m_stName`。 +表现层同样重建:`__AppendCharacterManagerActor`(`:327`)在已有实例时先 +`rkChrMgr.DeleteInstance(dwVID)` 再 `CreateInstance(kCreateData)`——**整个实例重建**, +只有上/下马时才把旧坐标覆盖进 `kCreateData`(`:342`)。 + +**当前**:`EntityStore::mut_spawn()`(`entity_store.cpp:75`)用 `touch()` 后只覆盖 +`race/ch_type/name/x/y/z/angle/moving_speed/attack_speed/moving`。 + +**修改** + +1. `mut_spawn_full()`(§2.2 新增)实现为: + ```cpp + Entity e{}; // 值初始化 —— 等价 SNetworkActorData 默认构造 + operator= + e.vid = vid; ...逐字段赋值全部字段... + m_ents[vid] = e; // 整条替换,不做 touch/局部覆盖 + m_changes.push_back({ChangeKind::Spawn, vid}); + ``` + 即 `dead / stunned / mount_vnum / guild / pk_mode / alignment / parts / shop_sign / + affect / func / walk_mode / fly_target_*` 全部回默认值。 +2. `net_world.gd` 收到 `Spawn` 且节点已存在 → **销毁旧节点重建**(对齐 + `DeleteInstance` + `CreateInstance`);例外:上下马导致的重建保留旧世界坐标 + (`kCreateData.m_lPosX = oldPos.x; m_lPosY = -oldPos.y;`,注意 Y 取反)。 +3. `mut_spawn()` 旧签名保留给 `GC_MAIN_CHARACTER` 之外的老调用点,但内部改为委托 + `mut_spawn_full()`,避免两套覆盖规则。 + +--- + +### 2.4 点数组可能残留旧值 + +**参考**:`__RecvPlayerPoints()`(`REF/.../PythonNetworkStreamPhaseLoading.cpp:325`) +```cpp +for (DWORD i = 0; i < POINT_MAX_NUM; ++i) + CPythonPlayer::Instance().SetStatus(i, PointsPacket.points[i]); +``` +`TPacketGCPoints.points` 是**定长 `POINT_MAX_NUM` 数组**,全量覆盖,不存在“部分长度”。 + +**当前**:`EntityStore::mut_set_points(pts, n)`(`entity_store.cpp:133`)只 `memcpy` 前 `n` 个; +当前 Classic 固定长度包路径传入 `POINT_MAX_NUM`,所以该路径本身是全量覆盖,残留风险只存在于 +未来或其他调用者传入较短长度时。 + +**修改** + +1. `wire_classic.h` 确认 `GCPoints.points` 为 `int32_t[POINT_MAX_NUM]`,加 `static_assert`。 +2. 若 `mut_set_points()` 继续允许部分更新,先明确区分“全量快照”和“单点更新”:全量快照必须 + 传入并校验 `POINT_MAX_NUM`,部分更新不得被误称为参考端的 `GC_POINTS` 全量包;必要时在 + 部分更新前清零尾部,避免未来调用者读到旧值。 +3. `GC_POINT_CHANGE`(单点更新)保持只改一个下标——参考同样是单点 + (`PhaseSelect.cpp:129` 在 Select 阶段直接 `Recv` 丢弃,Game 阶段才处理)。 + +--- + +### 2.5 实体数据字段不完整 + +**参考**:`SNetworkActorData`(`REF/UserInterface/NetworkActorManager.h:5`)的完整字段表见 §2.3。 +`CInstanceBase::SCreateData` 又比它多出 `m_isMain`。参考端各字段的**唯一来源**: + +| 字段 | 来源包 | 参考行 | +|---|---|---| +| `m_bType`(CHRTYPE) / `m_dwRace` / `m_dwStateFlags` / `m_fRot` / `m_dwMovSpd` / `m_dwAtkSpd` / 位置 / `m_kAffectFlags`(64 bit, 两段 32) | `GC_CHARACTER_ADD` | `PhaseGameActor.cpp:83` | +| `m_stName` / `m_dwGuildID` / `m_dwLevel` / `m_sAlignment` / `m_byPKMode` / `m_dwEmpireID` / `armor,weapon,hair`(`awPart[CHR_EQUIPPART_*]`) / `m_dwMountVnum` | `GC_CHAR_ADDITIONAL_INFO` | `PhaseGameActor.cpp:140` | +| 同上 9 项的后续变更 | `GC_CHARACTER_UPDATE[2]` | `PhaseGameActor.cpp:211/236` | +| `m_dwOwnerVID` | `GC_OWNERSHIP` | `PhaseGameActor.cpp:375` | + +`UpdateActor` 应用到实例的调用序(`NetworkActorManager.cpp:470`,顺序即参考顺序): +`ChangeArmor → ChangeWeapon → ChangeHair → ChangeGuild → SetAffectFlagContainer → +SetMoveSpeed → SetAttackSpeed → SetAlignment → SetPKMode → SetStateFlags`。 +(`MountHorse/DismountHorse` 在参考里是**注释掉的**,坐骑变化只经 `AppendActor` 重建。) + +**当前**:`mut_char_info()`(`entity_store.cpp:477`)显式 `(void)empire;` 丢弃 empire; +名字颜色 / 称号 / 会徽 / target highlight / attackable / weapon mode / motion mode / +affect 图标与剩余时间没有统一状态。 + +**修改** + +1. `Entity` 结构补齐到与 `SNetworkActorData` 一一对应: + 新增 `uint8_t empire; uint64_t affect_flags; uint32_t owner_vid; uint32_t state_flags;` + (`state_flags` 已有 `func/position` 的话合并成一个字段,按 `bStateFlag` 原值保存)。 +2. `mut_char_info()` 写入 `e.empire = empire;`,删除 `(void)empire;`。 +3. `affect_flags` 按参考的两段拷贝:`CopyData(0, 4, &dwAffectFlag[0])` + + `CopyData(32, 4, &dwAffectFlag[1])` → `uint64_t = lo | (hi << 32)`。 + `AFFECT_SHOW_ALWAYS` 的位号取 `REF/UserInterface/InstanceBase.h` 的 `AFFECT_*` 枚举 + **[待读参考:`InstanceBase.h` 的 `enum EAffect`]**。 +4. `net_world.gd` 的实体刷新按上表的**参考调用序**执行,不要重排。 +5. 名字颜色 / 称号 / PK 颜色 / 目标框 属表现层,见 §8.8,数据源就是本节的 + `empire / guild / alignment / pk_mode / level`。 + +--- + +### 2.6 一次性事件没有可靠的生命周期缓存 + +**参考**:参考端**不缓存**。`CPythonNetworkStream` 收到包立刻 +`PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], ...)`;窗口指针由 Python 在 +`SetPhaseWindow(ePhaseWnd, poPhaseWnd)`(`PythonNetworkStream.h:133`)注册, +`PyCallClassMemberFunc` 对空指针是安全 no-op。真正的保证是**顺序**: +`GC_PHASE(PHASE_GAME)` 之前 Python 已经 `game.py:137 GameWindow.Open()` 完成装配, +而 `SendEnterGamePacket()` 是 `Open()` 的倒数第二步(`game.py:207`)—— +**服务端在收到 `CG_ENTERGAME` 之前不会推游戏事件**。 + +**当前**:`M2Client` 的 drain queue 在 `game_scene` 装配完成前可能被消费。 + +**修改** + +1. **不要**加事件缓存。改为对齐顺序: + `CG_ENTERGAME` 的发送点从 `ClassicSession` 的定时器(§1.1 已删)挪到 + `game_scene.gd` 完成全部窗口装配后显式调用 `client.enter_game()`, + 即 1:1 对齐 `game.py:207`。 +2. `game_scene.gd` 的装配顺序照抄 `game.py:137 Open()`: + 相机 → 玩家清理 → 各 UI Open → 声音音量 → BGM → 快捷栏页 → `SetFocus/Show/ShowCursor` + → `net.SendEnterGamePacket()` → `StartGame()`。 +3. `M2Client` 的 drain 接口保持「不缓存」,但在 `enter_game()` 之前**不启动** world 分派, + 避免登录洪流里的包被提前消费。 + +--- + +### 2.7 GC_WARP 永远重连,换图必须清理并重建世界 + +**参考**:`CPythonNetworkStream::RecvWarpPacket()`(`REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:923`) +整体只有三步:接收 `{bHeader, lX, lY, lAddr, wPort}`、设置 +`__DirectEnterMode_Set(m_dwSelectedCharacterIndex)`、调用 +`CNetworkStream::Connect((DWORD)lAddr, wPort)`。 + +结论: + +- `GC_WARP` 在参考端**永远是重连**,不存在用 `addr == 0` 判断“同服只移动玩家”的分支。 +- `lX / lY` 在 `RecvWarpPacket()` 这一步不直接消费,但重连后的 + `GC_MAIN_CHARACTER*` / `PhaseLoading.cpp:211 LoadData(lX, lY)` 仍会用它们定位角色,必须 + 透传,不能标成无用字段。 +- `CNetworkStream::Connect()`(`REF/EterLib/NetStream.cpp:401`)先 `Clear()`,会清理 socket、 + 收发缓冲和 sequence;新连接再按 DirectEnter 流程 HandShake → Login → Select → Loading。 + +**当前首版(已实现,仍待真实服务端)**: + +- `classic_parser.cpp` 将 Classic `GC_WARP` 标记为 `reconnect=true`;`WarpCue::same_server()` + 不再作为 Classic 的判据,m2dev 旧语义仍可保留在其独立协议路径。 +- `M2Client` 在收到 Warp 后发出 `world_reset`,保存登录 key / 已选角色槽,清空实体、掉落、 + 目标、PVP / 决斗、技能 / affect / 交互等地图局部状态,再由 `ClassicSession::connect_warp()` + 按 `lAddr:wPort` 建立第二次游戏连接。 +- Godot `GameScene` / `NetWorld` / `GroundItems` 清理旧节点并重建地图、HUD、Atlas、环境和 + 玩家位置;背包、任务、好友等跨地图数据保留。 + +**剩余修改与验收** + +1. 保持 `lX / lY / lAddr / wPort` 的 wire、信号和重连路径一致;注释统一写“本步不消费”, + 不写“坐标无用”。 +2. 完成真实 TCP E2E:验证第二次连接确实命中 Warp 的 `lAddr:wPort`、发送 DirectEnter / Select, + 收到目标 `GC_MAIN_CHARACTER*`,并确认旧场景节点、旧掉落和旧实体没有残留。 +3. 完成换图后的地图 BGM、天气、飞行物、EffectLib 实例和其他地图局部资源清理 / 重建;当前 + 只覆盖基础场景和 UI。 +4. 回归:`net_classic_session_test` 覆盖 endpoint / selected slot 保留,`net_entity_test` 覆盖 + loading reset,`gamescene_test` / `p9_test` / `p2b_test` 覆盖场景重建;localhost bind 被 + `skip` 时只能记为“未验证”,不能替代真实 E2E。 + +--- + +## 3. P1:移动、战斗和技能 + +### 3.1 本地移动是直线位置修改 + +**参考**:本地移动**不是**直接改坐标,而是「设 Src/Dst + 起走路动作 + 每帧推进 + 碰撞回退」。 + +- 点地移动 `CPythonPlayer::__OnPressGround`(`REF/UserInterface/PythonPlayerInput.cpp:419`): + ``` + __ClearReservedAction(); __ClearAutoAttackTargetActorID(); + if (NEW_CancelFishing()) return; + if (!__IsMovableGroundDistance(...)) return; // 距离 < MOVABLE_GROUND_DISTANCE 不动 + if (!rkInstMain.NEW_MoveToDestPixelPositionDirection(p)) __ReserveClickGround(p); + ``` + `__ReserveClickGround` 会设 `m_fReservedDelayTime = 0.1f`(`PythonPlayerInput.cpp:834`)。 +- `CInstanceBase::NEW_Goto()`(`REF/UserInterface/InstanceBaseMovement.cpp:251`)的**前置门**依序: + `__IsSyncing()` → false;`IsUsingMovingSkill()` → 只转向;`isLock()` → false; + `!NEW_CanMoveToDestPixelPosition(dst)` → 停走并返回 true。 + 通过后:`NEW_SetSrcPixelPosition(cur) / NEW_SetDstPixelPosition(dst) / + NEW_SetDstPixelPositionZ(src.z) / m_fDstRot / m_isGoing=TRUE / StartWalking()`。 +- 方向键移动 `NEW_MoveToDirection`(`InstanceBaseMovement.cpp:293`)同样有 + `__IsSyncing / IsUsingMovingSkill / isLock` 三门,且 `m_isGoing = FALSE`。 +- 玩家层 `CPythonPlayer::NEW_MoveToDirection`(`PythonPlayerInput.cpp:445`)额外: + `IsOpenPrivateShop()` → 直接返回;`isLock() && !IsUsingMovingSkill()` → 返回。 +- 碰撞:`CInstanceBase::CheckAdvancing()`(`InstanceBaseBattle.cpp:469`)—— + 地形 `ATTRIBUTE_BLOCK` 检测(`InstanceBaseBattle.cpp:560`)+ 与其它 `CInstanceBase` 的 + `TestActorCollision`,命中即 `BlockMovement()`。`CanSkipCollision()` 为真时整段跳过 + (网络状态包驱动的移动会 `__EnableSkipCollision()`,见 §3.2)。 + +**当前**:`player_controller.gd:216` 直接改 X/Z,只做地形阻挡。 + +**修改** + +1. `player_controller.gd` 重构为「Src/Dst + `is_going` + `walk_mode`」模型,一一对应上面的 + `NEW_Goto` / `NEW_MoveToDirection`,并保留三个前置门(`syncing / moving_skill / lock`)。 +2. 新增 `MOVABLE_GROUND_DISTANCE`(参考默认值由 `SetMovableGroundDistance` 从 Python 设置 + **[待读参考:`POC/assets/root/game.py` / `playersettingmodule.py` 中的 + `player.SetMovableGroundDistance` 调用]**)。 +3. `__ReserveClickGround` 的 0.1 s 延迟、`NEW_IsEmptyReservedDelayTime` 的递减逻辑 + (`PythonPlayerInput.cpp:626`)照搬。 +4. Actor 之间的碰撞:用 Godot 的 Area3D 近似 `TestActorCollision`;命中 → `BlockMovement()` + (= 停在上一帧位置,不做滑移)。 +5. 坐骑:`IsMountingHorse()` 时移动速度与动作模式按 §6.4 的 `MODE_HORSE*` 走。 + +--- + +### 3.2 同步是距离阈值加 lerp + +**参考**:远端 Actor 的位置**完全由一条命令队列**驱动,而不是 lerp。 + +- 入队:`CInstanceBase::PushTCPState(dwCmdTime, kPPosDst, fRot, eFunc, uArg)`, + 由 `CNetworkActorManager::MoveActor`(`NetworkActorManager.cpp:520`)与 + `__AppendCharacterManagerActor`(`:410`,用于补发正在移动中的 actor)调用。 +- 出队:`CInstanceBase::StateProcess()`(`REF/UserInterface/InstanceBase.cpp:1278`) + ``` + while (队非空): + if (ELTimer_GetServerFrameMSec() < front.m_dwChkTime) return; // 按服务器帧时间释放 + pop; + if (!__CanProcessNetworkStatePacket()) return; // Dead / KnockDown / 不可取消的技能中 + if (!__IsEnableTCPProcess(eFunc)) return; // 表情中 / m_bEnableTCPState==false + switch (eFunc) { ... } + ``` +- 各 `eFunc` 的阈值是**硬阈值**,不是 lerp: + | eFunc | 条件 | 行为 | + |---|---|---| + | `FUNC_WAIT` | `fDirLen > 1.0f` | 走过去(Src/Dst + StartWalking,`m_kMovAfterFunc = WAIT`) | + | `FUNC_WAIT` | 否则 | `SCRIPT_SetPixelPosition(dst)` + `SetAdvancingRotation` + `SetRotation`(瞬移对齐) | + | `FUNC_MOVE` | 无条件 | Src/Dst + StartWalking,`m_kMovAfterFunc = MOVE` | + | `FUNC_COMBO` | `fDirLen >= 50.0f` | 先走过去,到达后 `RunComboAttack(rot, uArg)` | + | `FUNC_COMBO` | 否则 | 瞬移对齐 + `RunComboAttack(rot, uArg)` | + | `FUNC_ATTACK` | `fDirLen >= 50.0f` | 先走过去,到达后普攻 | + | `FUNC_ATTACK` | 否则 | 瞬移对齐 + `BlendRotation` + `RunNormalAttack(rot)` | + | `FUNC_MOB_SKILL` | `fDirLen >= 50.0f` / 否则 | 同上,动作换 mob skill | + 所有「走过去」分支都会 `__EnableSkipCollision()`。 +- 数据层的插值另有一份,只用于可见性判定:`SNetworkActorData::UpdatePosition()` + (`NetworkActorManager.cpp:8`)按 `(now - m_dwClientSrcTime) / m_dwDuration` 线性插值, + 超时钳到 Dst。 +- 本地上行的节流(`REF/UserInterface/PythonPlayerEventHandler.cpp`): + | 事件 | 节流 | 发送 | + |---|---|---| + | `OnMove` (:76) | 重置两个计时器(wait +100 ms / moving +300 ms) | `FUNC_MOVE, 0` | + | `OnMoving`(:62) | 300 ms | `FUNC_MOVE, 0` | + | `OnWaiting`(:38)| 100 ms **且**位移 ≥ 1.0 | `FUNC_WAIT, 0` | + | `OnStop` (:88) | 无 | `FUNC_WAIT, 0` | + | `OnAttack`(:102)| 无 | `FUNC_COMBO, wMotionIndex` | + | `OnUseSkill`(:120)| 无 | `FUNC_SKILL\|uMotSkill, uArg` | +- 上行包 `SendCharacterStatePacket`(`PythonNetworkStreamPhaseGame.cpp:1111`): + `TPacketCGMove{ header=HEADER_CG_CHARACTER_MOVE, bFunc, bArg, bRot = rot/5.0f, + lX, lY, dwTime = ELTimer_GetServerMSec() }`;发送前 `__CanActMainInstance()` 为假直接返回 true; + 角度先归一到 `[0,360)`;坐标最后经 `__LocalPositionToGlobalPosition`。 + +**当前**:`net_world.gd` 按距离阈值 snap/lerp;`net_play.gd` 按固定时间/距离发送。 + +**修改** + +1. `EntityStore` 新增每实体的 `std::deque`(`{chk_time, dst_x, dst_y, rot, func, arg}`), + `mut_move()` 改为 `push_tcp_state()`,不再直接改坐标。 +2. 新增 `EntityStore::process_states(server_frame_ms)`,1:1 实现上表的 switch + (含 `1.0f` / `50.0f` 两个阈值与 `m_kMovAfterFunc` 的「到达后动作」)。 +3. `EntityStore::update(now)` 里保留 `SNetworkActorData::UpdatePosition()` 的线性插值, + 但**只**用于 §2.1 的可见性判定,不驱动节点位置。 +4. `net_play.gd` 的上行改为 6 个事件回调 + 上表节流常量(100 / 300 ms、位移 1.0), + 删除现有的「固定时间或距离」逻辑。 +5. `ELTimer_GetServerMSec()` 的等价:`GC_HANDSHAKE` 的 `lDelta` 已用于时钟对齐 + (`REF/.../PythonNetworkStreamPhaseHandShake.cpp:124`),确认 `M2Client` 的 + `server_time_ms()` 与之一致后统一使用,不要用本地 `ticks_msec`。 +6. **不要**引入预测回滚 / 抖动缓冲——参考端没有。 + +--- + +### 3.3 普攻节奏硬编码 + +**参考**:攻击节奏完全来自 `.msa` 动作数据,没有任何硬编码间隔。 + +- 攻击距离:`CInstanceBase::NEW_IsClickableDistanceDestInstance` + (`REF/UserInterface/InstanceBaseBattle.cpp:294`) + ``` + fDistance = 150.0f; + if (IsBowMode()) fDistance = __GetBowRange(); + if (dst.IsNPC()) fDistance = 500.0f; + if (dst.IsResource()) fDistance = 100.0f; + ``` + 对像素坐标的通用判定 `NEW_IsClickableDistanceDestPixelPosition` 固定 `150.0f`(`:283`)。 +- 攻击间隔:由动作时长决定。`CActorInstance::InputComboAttackCommand` + (`REF/GameLib/ActorInstanceBattle.cpp:157`)用 + `m_pkCurRaceMotionData->GetComboInputStartTime() / GetNextComboTime() / GetComboInputEndTime()` + (`CRaceMotionData::TComboInputData{fInputStartTime, fNextComboTime, fInputEndTime}`, + `REF/GameLib/RaceMotionData.h:177`);无 combo 输入数据时退化为 + `fElapsedTime > GetMotionDuration() * 0.9f`。 +- 攻速:`SetAttackSpeed(m_dwAtkSpd)` 影响动作播放速率 + **[待读参考:`REF/UserInterface/InstanceBase.cpp` 的 `CInstanceBase::SetAttackSpeed`]**。 + +**当前**:`POC/project/net_play.gd:25` 一组固定常量。 + +**修改** + +1. 距离常量搬到 `net_play.gd`:`CLICK_DIST_DEFAULT=150.0`、`CLICK_DIST_NPC=500.0`、 + `CLICK_DIST_RESOURCE=100.0`,弓箭走 `__GetBowRange()` 的等价 + **[待读参考:`InstanceBaseBattle.cpp` 的 `__GetBowRange`]**。 +2. 攻击间隔改为读 `.msa` 的 combo 输入时间(本工程已有 `.msa` 解析,见 + `metin2_anim.cpp`);`m2_anim` 需要额外导出 `combo_input_start / next_combo / combo_input_end / + motion_duration`。 +3. 删除 `net_play.gd` 里所有「按攻速缩放的固定间隔」。 + +--- + +### 3.4 自动攻击没有复现 ClientVS22 的目标预留逻辑 + +**参考**:两段。 + +**(a) `CPythonPlayer::__Update_AutoAttack()`**(`REF/UserInterface/PythonPlayer.cpp:207`),每帧: +``` +if (0 == m_dwAutoAttackTargetVID) return; +if (!NEW_GetMainActorPtr()) return; +if (__IsUsingChargeSkill()) return; // 弹射/冲锋中跳过 +victim = NEW_FindActorPtr(m_dwAutoAttackTargetVID); +if (!victim) __ClearAutoAttackTargetActorID(); +else if (victim->IsDead()) __ClearAutoAttackTargetActorID(); +else if (main->IsMountingHorse() && !main->CanAttackHorseLevel()) + __ClearAutoAttackTargetActorID(); +else if (main->IsAttackableInstance(*victim)) + { if (main->IsSleep()) { /* skip */ } else __ReserveClickActor(m_dwAutoAttackTargetVID); } +``` +`__IsUsingChargeSkill()`(`PythonPlayer.cpp:182`):`__CheckDashAffect` 为真,或 +`MODE_USE_SKILL` 且预约技能是 `IsChargeSkill()`。 + +**(b) `CPythonPlayer::__ReserveProcess_ClickActor()`**(`PythonPlayerInput.cpp:877`), +每帧在 `MODE_CLICK_ACTOR` 下跑,**顺序不可改**: +``` +1 main / reserved 任一为空 → __ClearReservedAction(); return +2 !NEW_IsClickableDistanceDestInstance → NEW_MoveToDestInstanceDirection(); return (继续靠近) +3 !IsAttackableInstance → NEW_Stop(); __SendClickActorPacket(); 清预约; return (NPC 对话) +4 reserved->IsDead() → 清预约; return +5 main->IsInSafe() → OnCannotAttack(..., "IN_SAFE"); NEW_Stop(); 清预约; return +6 reserved->IsInSafe() → OnCannotAttack(..., "DEST_IN_SAFE"); NEW_Stop(); 清预约; return +7 __CheckDashAffect(main) → return (保持预约) +8 main->IsBowMode() && !__HasEnoughArrow() → OnCannotShot(..., "EMPTY_ARROW"); NEW_Stop(); 清预约; return +9 reserved VID != GetTargetVID() → SetTarget(reserved VID) +10 NEW_AttackToDestInstanceDirection(reserved); __ClearReservedAction(); +``` + +**(c) 预约模式枚举**(`PythonPlayerInput.cpp:820-856`): +`MODE_NONE / MODE_CLICK_ITEM / MODE_CLICK_ACTOR / MODE_CLICK_POSITION / MODE_USE_SKILL`, +每帧在 `NEW_RefreshMouseWalkingDirection()`(`PythonPlayerInputMouse.cpp:287`)里分派; +末尾还依次处理 `m_isSmtMov / m_isDirMov / m_isDirKey / m_isAtkKey`。 + +**当前**:只有「测距后发攻击包」。 + +**修改** + +1. `player_controller.gd` 新增 `enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL}` + 与 `_vid_reserved / _iid_reserved / _ppos_reserved / _skill_slot_reserved / _skill_range_reserved / + _reserved_delay`,逐字对齐 `PythonPlayerInput.cpp:820-865`。 +2. 每帧执行 `_refresh_mouse_walking_direction()`,四个 case 分别照抄 + `PythonPlayerInputMouse.cpp:288-370`(含 `MODE_CLICK_ITEM` 的 `< 20.0f` 拾取阈值、 + `MODE_USE_SKILL` 的 `IS_HUGE_RACE` 距离 −200 修正)。 +3. `_reserve_process_click_actor()` 严格按 (b) 的 10 步实现。 +4. `_update_auto_attack()` 按 (a) 实现;`m_dwAutoAttackTargetVID` 只在 + `__OnPressActor(isAuto=true)` 且 `IsAttackableInstance` 时设置 + (`PythonPlayerInput.cpp:306` `__OnPressActor`)。 +5. `OnCannotAttack` / `OnCannotShot` / `OnCannotUseSkill` 的字符串码 + (`IN_SAFE / DEST_IN_SAFE / EMPTY_ARROW / NEED_TARGET / NOT_HORSE_SKILL / HAVE_TO_RIDE / + NEED_EMPTY_BOTTLE / NEED_POISON_BOTTLE / REMOVE_FISHING_ROD`)走 `game.py` 同名回调的 + `localeInfo` 文案表。 + +--- + +### 3.5 攻击动作、命中时机和网络包没有严格绑定 + +**参考**:三条链完全由动作数据驱动。 + +**(a) 连击索引**(`REF/GameLib/ActorInstanceBattle.cpp:157 / 213 / 253`): +``` +InputComboAttackCommand(rot): + m_fAtkDirRot = rot; + if (m_isPreInput) return false; + if (0 == m_dwcurComboIndex) { __RunNextCombo(); return true; } + if (motion->IsComboInputTimeData()): + t = GetAttackingElapsedTime() + if (t > GetComboInputEndTime()) { if (IsBowMode()) m_isNextPreInput = TRUE; return false; } + if (t > GetNextComboTime()) { __RunNextCombo(); return true; } + if (t > GetComboInputStartTime()) { m_isPreInput = TRUE; return false; } + else: + if (GetAttackingElapsedTime() > GetMotionDuration()*0.9f) { __RunNextCombo(); return true; } +``` +`__RunNextCombo()`(`:253`):`++m_dwcurComboIndex`,用 +`m_pkCurRaceData->GetComboDataPointer(m_wcurMotionMode, __GetCurrentComboType(), &pComboData)` +取 `ComboIndexVector[m_dwcurComboIndex-1]` 作为动作号,`ComboAttack(idx, m_fAtkDirRot, 0.1f)`。 +连击打满时 `__OnEndCombo()`:骑马下 `m_dwcurComboIndex = 1`,否则**不复位**(靠动作回 Wait 复位)。 +`__ClearCombo()`(`:305`)才把索引清零。 + +**(b) 命中帧**(`ActorInstanceBattle.cpp:355 / 386` + `RaceMotionData`): +`isValidAttacking()` 遍历 `TMotionAttackData::HitDataContainer` 的 +`THitData{fAttackStartTime, fAttackEndTime}`,落在窗口内才算命中窗口; +`CanCheckAttacking()` = `isAttacking()`(普攻 / 连击 / splash 三者之一)。 + +**(c) 发包**(`REF/UserInterface/PythonPlayerEventHandler.cpp:135 OnHit`): +``` +OnHit(uSkill, victimActor, isSendPacket): + CPythonPlayer::SetTarget(victimVID, FALSE); + if (isSendPacket) rkStream.SendAttackPacket(uSkill, victimVID); // CG_ATTACK{bType, dwVictimVID} + if (!victim.IsPushing()) return; + if (IS_HUGE_RACE(victim.GetRace())) return; + CPythonCharacterManager::AdjustCollisionWithOtherObjects(&victim); + victim.TEMP_Push(lastX, lastY); 记录到 m_kVctkVictim +``` +`FlushVictimList()`(`:194`)把本帧所有被击退者打成一个 `TPacketCGSyncPosition` +(上限 `SYNC_POSITION_COUNT_LIMIT = 16`)。 +`CInstanceBase::AttackProcess()`(`InstanceBaseBattle.cpp:413`)在 `CanCheckAttacking()` 为真时 +遍历全部实例做 `CheckAttacking()`,后者先查 `IsInSafe()` 双方,再 +`AttackingProcess(victimActor)` 做真正的几何判定。 + +**当前**:`net_play.gd` 的 combo 只在 0/1/2 循环,命中/发包用定时器。 + +**修改** + +1. `metin2_anim` 导出 `.msa` 的 `TMotionAttackData.HitDataContainer`(起止时间)与 + combo 输入三时间,`.msa` 已解析结构见 `POC/extension/src/metin2_anim.cpp`。 +2. `RaceData` 的 combo 表(`ComboIndexVector`,按 `MAKE_COMBO_KEY(motionMode, comboType)` 索引, + `REF/GameLib/RaceData.h:9`)需要从 `.msm` 解析出来;当前 `.msm` 解析只用于换肤。 +3. `player_controller.gd` 实现 (a) 的状态机(`_cur_combo_index / _is_pre_input / _is_next_pre_input`), + 删除 0/1/2 循环。 +4. 命中判定与发包挂到动作事件:进入 `THitData` 窗口 → 几何判定 → `OnHit` → `CG_ATTACK`; + `FlushVictimList` 每帧末尾发一次 `CG_SYNC_POSITION`(≤16 条), + `M2Client::sync_positions()` 已有上限 16 的实现,直接复用。 +5. `OnAttack` 事件(动作真正起手时)发 `CG_CHARACTER_MOVE{FUNC_COMBO, wMotionIndex}`, + 与 `CG_ATTACK` 是**两个不同时刻**的两个包,不能合并。 + +--- + +### 3.6 弓箭发射使用固定 1.5 秒定时器 + +**参考**:由 `.msa` 的 `MOTION_EVENT_TYPE_FLY` 事件驱动, +经 `IFlyEventHandler` 两个回调(`REF/UserInterface/PythonPlayerEventHandler.cpp:235`): +``` +CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTarget() + → SendFlyTargetingPacket(targetVID, target->OnGetFlyTargetPosition()) // CG_FLY_TARGETING +CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot(dwSkillIndex) + → SendShootPacket(dwSkillIndex) // CG_SHOOT +``` +handler 由 `__OnPressActor` 通过 +`rkPlayerEventHandler.GetNormalBowAttackFlyEventHandler(&main, &victim)` 取得并传给 +`NEW_AttackToDestInstanceDirection`(`PythonPlayerInput.cpp:358`)。 +`MOTION_EVENT_TYPE_FLY` 是 `CRaceMotionData::EMotionEventType` 的第 6 项 +(`REF/GameLib/RaceMotionData.h:168`)。 + +**当前**:`game_scene.gd:218` 固定 1.5 s 定时器,单个 pending skill 会被覆盖。 + +**修改** + +1. `.msa` 的 motion event 分派(本工程已实现,见 `game_scene._on_local_motion_event`)新增 + `FLY` 分支:触发 `OnSetFlyTarget` → `client.fly_targeting(vid, x, y, z)`; + 飞行物命中/到期 → `OnShoot(skill)` → `client.shoot(skill)`。 +2. 删除 `game_scene.gd:218` 的定时器与单槽 `pending skill`;改为**每次攻击创建一个 + handler 实例**(对齐 `_AutoClear` 语义:射完自动清理),支持并发多发。 +3. `SendFlyTargetingPacket` 的坐标取目标的 `OnGetFlyTargetPosition()` + **[待读参考:`REF/GameLib/ActorInstanceFly.cpp` 的 `OnGetFlyTargetPosition`]**。 + +--- + +### 3.7 受击状态分裂 + +**参考**:**没有**「客户端硬直」这种本地状态。可用于门控的只有三个,且全部来自 +`CActorInstance`: +- `IsDead()` / `IsKnockDown()` / `IsStun()`; +- `__CanProcessNetworkStatePacket()`(`REF/UserInterface/InstanceBase.cpp:1247`)只查 + `IsDead / IsKnockDown / (IsUsingSkill && !CanCancelSkill)`; +- `NEW_Attack()`(`InstanceBaseBattle.cpp:355`)与 `NEW_UseSkill()`(`:310`)的门也只有 + `IsDead / IsStun / IsKnockDown`(`NEW_Attack` 多一个 `IsUsingSkill`)。 +`Stun` 来自服务端 affect(`InstanceBaseEffect.cpp:932 case AFFECT_STUN`), +`KnockDown` 来自被击退动作,**都不是本地计时器**。 + +**当前**:`EntityStore.stunned` 与 `NetPlay._hitstun_until` 两套并存。 + +**修改** + +1. **删除 `net_play.gd` 的 `_hitstun_until` 及其全部引用**——参考端不存在这个概念。 +2. `Entity` 增加 `knock_down: bool`,由受击动作(`.msa` 的 knockdown 动作播放中)置位, + 动作结束清零;`dead` / `stunned` 保持由服务端包驱动。 +3. 所有「能否动作」的判定统一走三个函数: + `_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`, + 实现分别对齐 `InstanceBase.cpp:1247`、`InstanceBaseBattle.cpp:355`、`:310`。 + +--- + +### 3.8 技能缺少统一合法性检查 + +**参考**:三层,缺一不可。 + +**(a) `CPythonPlayer::ClickSkillSlot(dwSlotIndex)`**(`REF/UserInterface/PythonPlayerSkill.cpp:108`) +``` +slot >= SKILL_MAX_NUM → return +!GetSkillData(inst.dwIndex, &data) → return +SKILL_TYPE_GUILD == data->byType → UseGuildSkill(slot); return +!data->IsCanUseSkill() → return // 被动技能挡在这 +data->IsStandingSkill(): + data->IsToggleSkill() && IsSkillActive(slot): + main->IsUsingSkill() → return + SendUseSkillPacket(inst.dwIndex); return // 关闭 toggle + __UseSkill(slot); return +m_dwcurSkillSlotIndex == slot → __UseSkill(slot); return +!__IsRightButtonSkillMode() → __UseSkill(slot) +else → m_dwcurSkillSlotIndex = slot; ChangeCurrentSkill(slot) +``` + +**(b) `CPythonPlayer::__CanUseSkill()`**(`PythonPlayerSkill.cpp:439`) +``` +!NEW_GetMainActorPtr() → false +IsObserverMode() → false +IsMountingHorse() && GetSkillGrade(109) < 1 && GetSkillLevel(109) < 20 → false // 骑乘术硬编码 +return main->CanUseSkill(); +``` + +**(c) `CPythonPlayer::__CheckSkillUsable(slot)`**(`PythonPlayerSkill.cpp:166`) +``` +IsMountingHorse() && !data->IsHorseSkill() → OnCannotUseSkill("NOT_HORSE_SKILL"); false +data->IsHorseSkill() && !IsMountingHorse() → OnCannotUseSkill("HAVE_TO_RIDE"); false +data->IsAttackSkill() && main->IsInSafe() → OnCannotUseSkill("IN_SAFE"); false +!data->IsCanUseSkill() → false +data->IsNeedEmptyBottle() && !__HasItem(27995) → OnCannotUseSkill("NEED_EMPTY_BOTTLE"); false +data->IsNeedPoisonBottle() && !__HasItem(27996) → OnCannotUseSkill("NEED_POISON_BOTTLE"); false +main->IsFishingMode() → OnCannotUseSkill("REMOVE_FISHING_ROD"); false +``` + +**(d) `__UseSkill(slot)`**(`PythonPlayerSkill.cpp:460`)额外: +`IsOpenPrivateShop()` → true(静默);`__CheckSpecialSkill(idx)` → true; +`data->IsToggleSkill() && IsSkillActive(slot)` → 发 `SendUseSkillPacket(idx, slot)` 并返回 false; +`main->IsUsingSkill()` → false; +需要目标时(`IsNeedTarget() || CanChangeDirection() || IsAutoSearchTarget()`) +按 `IsNeedCorpse()` 取死/活目标,取不到就 `__ChangeTargetToPickedInstance()` 再取一次。 + +**当前**:只查 `skill id > 0`;`target_vid` 等参数被无符号化。 + +**修改** + +1. 在 GDScript 侧新建 `player_skill.gd`,把 (a)(b)(c)(d) 四段**逐行**移植; + `SKILL_MAX_NUM`、`SKILL_TYPE_GUILD`、骑乘术 `109` / 空瓶 `27995` / 毒瓶 `27996` + 均按参考写成命名常量。 +2. `CPythonSkill::TSkillData` 的判定位(`IsCanUseSkill / IsStandingSkill / IsToggleSkill / + IsHorseSkill / IsAttackSkill / IsNeedTarget / IsNeedCorpse / IsAutoSearchTarget / + CanChangeDirection / IsNeedEmptyBottle / IsNeedPoisonBottle / IsChargeSkill`) + 来自 `skilltable` / `skilldesc`,需要在 `POC/extension/src/proto` 侧补解析 + **[待读参考:`REF/UserInterface/PythonSkill.h` 的 `TSkillData` 字段与 + `PythonSkill.cpp` 的 `RegisterSkillDesc` / `LoadSkillTable`]**。 +3. `M2Client::use_skill()` 之类的公开 API 参数改为**带符号**并做范围校验(见 §3.9)。 + +--- + +### 3.9 公共网络 API 缺少参数边界检查 + +**参考**:参考端在**发送前**就有断言/裁剪,而不是靠服务端拒绝: +- `SendCharacterStatePacket`:`assert(lX >= 0 && lX < 204800)`(`PhaseGame.cpp:1132`); + 角度先归一到 `[0,360)`;`bRot = rot/5.0f`。 +- `OnAttack`:`assert(wMotionIndex < 255)`(`PythonPlayerEventHandler.cpp:104`)。 +- `FlushVictimList`:`SYNC_POSITION_COUNT_LIMIT = 16`(`:196`)。 +- `AddQuickSlot / DeleteQuickSlot / MoveQuickSlot`:`< 0 || >= QUICKSLOT_MAX_NUM(36)` 直接返回 + (`PythonPlayer.cpp:843/852/861`)。 +- `RequestUseLocalQuickSlot`:`>= QUICKSLOT_MAX_COUNT_PER_LINE(8)` 返回(`:815`)。 +- `RequestDeleteGlobalQuickSlot`:`>= QUICKSLOT_MAX_COUNT(32)` 返回(`:800`)。 +- `GetAccountCharacterSlotData*`:`>= PLAYER_PER_ACCOUNT4(4)` 返回 0(`PythonNetworkStream.cpp:378`)。 +- `__RecvPlayerCreateSuccessPacket`:槽位越界 `TraceError` 并**吞掉**(`PhaseSelect.cpp:239`)。 + +**修改** + +1. 在 `m2_client.cpp` 的每个公开 `send_*` 入口按上表补 `int` 范围校验,越界返回 `false` + 并记日志——**不要**先 `static_cast`。 +2. 新建 `POC/extension/src/net/limits.h` 收纳参考常量: + `PLAYER_PER_ACCOUNT4=4, QUICKSLOT_MAX_NUM=36, QUICKSLOT_MAX_COUNT=32, + QUICKSLOT_MAX_COUNT_PER_LINE=8, QUICKSLOT_MAX_LINE=4, SHOP_HOST_ITEM_MAX_NUM=40, + METIN_SOCKET_COUNT=6, PARTY_AFFECT_SLOT_MAX_NUM=7, EXCHANGE_ITEM_MAX_NUM=12, + SAFEBOX_PAGE_SIZE=45, REFINE_MATERIAL_MAX_NUM=5, SYNC_POSITION_COUNT_LIMIT=16, + MAP_COORD_MAX=204800`(来源:`REF/UserInterface/Packet.h:380`、`PythonSafeBox.h:8`、 + `PythonExchange.h:13`、`PythonPlayerEventHandler.cpp:200`、`PhaseGame.cpp:1132`)。 +3. 回归:`net_bounds_test.gd` 对每个 API 各喂一个越界值,断言返回 false 且未产生出站字节。 + +--- + +### 3.10 技能升级传输已对齐但状态门控不完整 + +**证据边界与订正**:本次 `ClientVS22/source/` 不含 Python UI 脚本;本仓与该客户端配套的 +迁移资产脚本本来就是聊天命令。因此不能据“使用命令”断定实现不符合参考端,也不能据此 +断定当前必须改传技能 vnum。`uicharacter.py:1022 OnPressedSlotButton()`(本仓 +`POC/assets/root/uicharacter.py`): +```python +srcSlotIndex = self.__RealSkillSlotToSourceSlot(slotNumber) +skillIndex = player.GetSkillIndex(srcSlotIndex) +curLevel = player.GetSkillLevel(srcSlotIndex) # 本版本算了但未使用 +maxLevel = skill.GetSkillMaxLevel(skillIndex) # 本版本算了但未使用 +net.SendChatPacket("/skillup " + str(skillIndex)) +``` +所以 `CLIENT-GAP.md` §3.10 的「应改成非命令路径」不成立,主文档已改为记录门控和状态流程差距。 + +**同时**:本文早前版本曾断言「当前实现应把参数从技能槽改为技能 vnum」—— +该结论**未经证明,撤回**。`POC/project/ui/skill_ui.gd:145` 传给 +`client.skill_up(sid)` 的 `sid` 来自 `table.for_category()`,本来就是技能表 ID/索引, +与参考端 `player.GetSkillIndex(srcSlotIndex)` 的产物同类。 + +**真正的差距是「加号按钮门控」完全缺失。** 迁移资产中的参考脚本把全部条件放在按钮 +**显示**阶段(`uicharacter.py`),点下去就无条件发包;C++ 侧可核实的技能上限检查见 +`PythonSkill.cpp`: + +```python +# RefreshSkillPlusButtonList() :984 +if self.isLoaded == 0: return +self.RefreshSkillPlusPointLabel() +if not self.__CanUseSkillNow(): return # 死亡/变身/骑乘等状态门(:1270) + curSelectedSkillGroup == PAGE_HORSE ? __RefreshSkillPlusButton("HORSE") + : __RefreshSkillPlusButton("ACTIVE") + __RefreshSkillPlusButton("SUPPORT") + +# __RefreshSkillPlusButton(name) :937 +if HIDE_SUPPORT_SKILL_POINT and name == "SUPPORT": return +slotWindow.HideAllSlotButton() +slotStatType = self.skillPageStatDict[name] # ACTIVE→SKILL_ACTIVE, SUPPORT→SKILL_SUPPORT, HORSE→SKILL_HORSE +if 0 == slotStatType: return +statPoint = player.GetStatus(slotStatType) +if statPoint > 0: # ← 技能点校验 + for 每个槽: + skillIndex = player.GetSkillIndex(slot); skillGrade = player.GetSkillGrade(slot) + skillLevel = player.GetSkillLevel(slot) + if skillIndex == 0: continue + if skillGrade != 0: continue # ← 已升阶的技能不给加号 + if name == "HORSE": + if player.GetStatus(player.LEVEL) >= skill.GetSkillLevelLimit(skillIndex): # ← 等级前置 + if skillLevel < 20: # ← 马术上限 20 + slotWindow.ShowSlotButton(__GetETCSkillRealSlotIndex(slot)) + else: + if name == "SUPPORT" and SHOW_LIMIT_SUPPORT_SKILL_LIST: + if skillIndex not in SHOW_LIMIT_SUPPORT_SKILL_LIST: continue # ← 辅助技能白名单 + if self.CanShowPlusButton(skillIndex, skillLevel, statPoint): + slotWindow.ShowSlotButton(slot) + +# CanShowPlusButton(skillIndex, skillLevel, curStatPoint) :925 +if 0 == skillIndex: return False +if not skill.CanLevelUpSkill(skillIndex, skillLevel): return False +return True +``` + +**当前**:`POC/project/ui/skill_ui.gd:141` 只有 +`if table.can_level_up(id) and not table.is_passive(id)`,缺技能点校验、skillGrade、 +等级前置、马术上限、辅助白名单和 `__CanUseSkillNow()` 状态门。 + +**修改** + +1. `skill_ui.gd` 按上面的三段(`RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` / + `CanShowPlusButton`)**逐行**实现加号按钮的显示门控,替换现有单行判断。 +2. 三个技能点来源分开取:`SKILL_ACTIVE` / `SKILL_SUPPORT` / `SKILL_HORSE` + (`uicharacter.py:1010-1019`),不是统一一个 `POINT_STAT`。 +3. `skill.CanLevelUpSkill(skillIndex, skillLevel)` 与 `skill.GetSkillLevelLimit(skillIndex)` + 需要 skill table 支持,见 §3.8 第 2 点的 `TSkillData` 解析 + **[待读参考:`REF/UserInterface/PythonSkill.cpp` 的 `CanLevelUpSkill` / `GetSkillLevelLimit`]**。 +4. `M2Client::skill_up(int skill_id)`(`m2_client.cpp:850`)的发包本身保持不变 + (`/skillup `),只补 §3.9 的入参范围校验。 +5. 升级结果由 `GC_SKILL_LEVEL` / `GC_CHANGE_SKILL_GROUP` 回包驱动刷新, + 失败提示走 `GC_CHAT` 的服务端文案——不本地伪造成功。 + +--- + +## 4. P1:物品、装备、商店和社交 + +### 4.1 24 个 wear 位置进入数据层,但没有完整视觉和规则 + +**参考**:`ClientVS22` 的编译开关(`REF/UserInterface/Locale_inc.h`,本份为 +`LOCALE_SERVICE_SINGAPORE`)打开了 `ENABLE_COSTUME_SYSTEM` / `ENABLE_ENERGY_SYSTEM` / +`ENABLE_DRAGON_SOUL_SYSTEM` / `ENABLE_NEW_EQUIPMENT_SYSTEM`。对应的**槽位版图** +(`REF/UserInterface/GameType.h:40-112`): + +``` +c_Inventory_Page_Size = 5*9 = 45 c_Inventory_Page_Count = 2 +c_ItemSlot_Count = 90 c_Equipment_Start = 90 c_Equipment_Count = 12 + +0 Body +1 Head +2 Shoes +3 Wrist +4 Weapon +5 Neck + +6 Ear +7 Unique1 +8 Unique2 +9 Arrow +10 Shield + +19 Costume_Body +20 Costume_Hair (ENABLE_COSTUME_SYSTEM) + +21 Ring1 +22 Ring2 +23 Belt (ENABLE_NEW_EQUIPMENT_SYSTEM) +c_Wear_Max = 32 +c_DragonSoul_Equip_Start = 90 + 32 = 122,6 槽 × DS_DECK_MAX_NUM(2) +c_Belt_Inventory_Width/Height = 4/4 → 16 格 +``` +本工程的「24 个 wear 位置」= `c_Equipment_Start + 0 .. +23`,**与参考一致**,无需改数据层。 + +**装备规则**来自 `CItemData`(`REF/GameLib/ItemData.h`): +- `EWearPositions`(`:227`):`WEAR_BODY..WEAR_SHIELD`,`WEAR_MAX_NUM = 11`(基础 11 位)。 +- `EItemWearableFlag`(`:241`):`WEARABLE_BODY(1<<0) … WEARABLE_ARROW(1<<9)`—— + 「这件装备能穿在哪些位置」的位掩码。 +- `EItemAntiFlags`(`:188`):`FEMALE/MALE/WARRIOR/ASSASSIN/SURA/SHAMAN`(职业性别限制)、 + `GET/DROP/SELL/SAVE/GIVE/PKDROP/STACK/MYSHOP`、`EMPIRE_A/B/R`。 +- `EItemFlag`(`:207`):`REFINEABLE/STACKABLE/COUNT_PER_1GOLD/RARE/UNIQUE/IRREMOVABLE/ + CONFIRM_WHEN_USE/QUEST_USE/APPLICABLE` 等。 +- `EItemTypes`(`:24`):0..34,其中 `ITEM_TYPE_COSTUME = 28`、`ITEM_TYPE_RING = 33`、 + `ITEM_TYPE_BELT = 34`、`ITEM_TYPE_ROD = 13`、`ITEM_TYPE_METIN = 10`。 +- 武器子类 `EWeaponSubTypes`(`:64`):`WEAPON_SWORD/DAGGER/BOW/TWO_HANDED/BELL/FAN/ARROW`。 + +**换模**:`CInstanceBase::ChangeArmor/ChangeWeapon/ChangeHair`,最终经 +`__GetRaceType` + `RefreshState`(§6.4)决定 motion mode。远端角色的换装同样走 +`CNetworkActorManager::UpdateActor`(§2.5)。 + +**修改** + +1. `proto` 层导出上述四组位标志(当前只用了 `specular`),落到 + `POC/extension/src/proto/item_proto.h` 的 `ItemProto`。 +2. 装备穿脱校验按参考顺序:`antiflag(性别/职业/帝国)` → `wearable_flag` 与目标槽位匹配 → + `limit`(等级/属性) → 发 `CG_ITEM_USE`。校验失败的文案走 `localeInfo` 的 + `CANNOT_EQUIP_*` 键。 +3. costume(19/20) / ring(21/22) / belt(23) 的槽位在 `inventory_ui.gd` / + `equipment_dialog` 里补齐,并按 `uiequipmentdialog.py` 的布局装载。 +4. 部位隐藏 / 遮挡(穿 costume 时隐藏本体 armor、穿头盔隐藏发型)按 + `REF/UserInterface/InstanceBase.cpp` 的 `__IsShapeAnimalWear` / `SetHair` 等实现 + **[待读参考:`InstanceBase.cpp` 的 `ChangeArmor` / `SetArmor` / `__AttachHorseSaddle`]**。 +5. 远端角色换装:`net_world.gd` 的实体刷新已在 §2.5 对齐调用序,此处只需把 + `armor/weapon/hair` 三个字段接到换模。 + +--- + +### 4.2 物品拖放不是原版鼠标物品态 + +**参考**:全局单例 `mouseModule.mouseController`(`POC/assets/root/mousemodule.py`, +`class CMouseController`),是所有窗口之间拖放的**唯一中介**: + +``` +AttachObject(Owner, Type, SlotNumber, ItemIndex, count=0) mousemodule.py:171 + Type ∈ ESlotType(GameType.h:140): + SLOT_TYPE_INVENTORY / SHOP / EXCHANGE_OWNER / EXCHANGE_TARGET / QUICK_SLOT / + SAFEBOX / PRIVATE_SHOP / MALL / DRAGON_SOUL_INVENTORY → 取物品图标 + SLOT_TYPE_SKILL / SLOT_TYPE_EMOTION → 取技能 / 表情图标 + SLOT_TYPE_QUICK_SLOT → 再取快捷栏里登记的真实类型递归解析 +AttachMoney(owner, type, count) :264 IsAttachedMoney() :251 +DeattachObject() :284 isAttached() :315 +GetAttachedOwner/Type/SlotNumber/ItemIndex/ItemCount :318-343 +``` +落点窗口的两个事件与参考里到处出现的模式一致(以 `uisafebox.py:411` 为样板): +``` +SelectEmptySlot(pos): if not isAttached(): return + 按 GetAttachedType() 分支 → 发对应移动/存取包 → DeattachObject() +SelectItemSlot(pos): 已有物品的格子;未持物时 AttachObject(self, <本窗类型>, pos, itemID) + 并 snd.PlaySound("sound/ui/pick.wav") +``` +`SlotTypeToInvenType(bSlotType)`(`REF/UserInterface/GameType.cpp:106` + 表 `:90`)把 +slot type 映射到 `EWindows`:只有 `SLOT_TYPE_INVENTORY→INVENTORY`、 +`SLOT_TYPE_DRAGON_SOUL_INVENTORY→DRAGON_SOUL_INVENTORY`,其余一律 `RESERVED_WINDOW`。 +落点代码里 `if RESERVED_WINDOW == attachedInvenType: return` 是标准防御 +(`uisafebox.py:437`)。 +音效固定:拿起 `sound/ui/pick.wav`、放下 `sound/ui/drop.wav`、金钱 `sound/ui/money.wav`、 +非法 `sound/ui/loginfail.wav`。 + +**当前**:各窗口各自实现点击/拖放,没有全局持有态。 + +**修改** + +1. 新建 `POC/project/ui/mouse_controller.gd`(autoload 单例),API 逐个对齐上表 + (名字保持 `attach_object / attach_money / deattach_object / is_attached / + get_attached_type / get_attached_slot_number / get_attached_item_index / + get_attached_item_count / is_attached_money`)。 +2. `ESlotType` / `EWindows` / `SlotTypeToInvenType` 三张表移植到 + `POC/project/ui/game_type.gd`,值与 `GameType.h` 完全一致。 +3. 所有物品窗口(inventory / safebox / mall / shop / exchange / private_shop / quickslot / + dragon_soul)统一改为 `select_empty_slot` / `select_item_slot` 两个入口, + 分支照抄各自的 `ui*.py`。 +4. 鼠标跟随图标:`CanvasLayer` 上画 `AttachedIconHandle` 等价物,位置跟随鼠标, + 数量文字用 `countNumberLine`(`mousemodule.py:87`)。 +5. 拖动取消:右键或落到无效目标 → `deattach_object()`,不发包(参考无回滚包)。 +6. 音效按上表四个固定路径。 + +--- + +### 4.3 物品 tooltip 只覆盖基础信息 + +**参考**:`POC/assets/root/uitooltip.py`(2718 行)是完整实现,本仓已有该脚本。 +入口按来源分:`SetInventoryItem / SetShopItem / SetExchangeOwnerItem / +SetExchangeTargetItem / SetSafeBoxItem / SetMallItem / SetPrivateShopBuilderItem / +SetSkill / SetQuickSlotItem`。 + +**修改** + +1. **不要**重写 tooltip 逻辑。按 §8 的窗口迁移样板(`char_status_ui.gd` 的做法) + 直接移植 `uitooltip.py` 的 `ItemToolTip` 类: + - 数值来源逐字对齐(`item.GetItemName / GetItemDescription / GetItemSummary / + GetValue(i) / GetLimit(i) / GetAffect(i) / GetItemSubType / GetItemSize` 等); + - 属性行(attribute)、魔石孔(socket,`METIN_SOCKET_COUNT = 6`)、 + `ITEM_FLAG_*` / `ITEM_ANTIFLAG_*` 行、`LIMIT_LEVEL/LIMIT_STR/...`、 + 精炼等级、使用时限(`GetItemDuration`)逐条对齐。 +2. `proto` 层补齐 tooltip 需要的全部字段(`ItemProto.value[6] / limit[2] / apply[3] / + flags / anti_flags / wear_flags / socket / refine` 等),当前只导出了少量字段。 +3. 装备对比(穿戴中 vs 鼠标所指)按 `uitooltip.py` 的 `__AppendEquipmentCompare` 等价实现 + **[待读参考:`uitooltip.py` 中比较相关的方法名以本仓文件为准]**。 + +--- + +### 4.4 Safebox 是列表和自动找空位 + +**参考** + +- 容量:`CPythonSafeBox`(`REF/UserInterface/PythonSafeBox.h:8`) + `SAFEBOX_SLOT_X_COUNT = 5`、`SAFEBOX_SLOT_Y_COUNT = 9`、`SAFEBOX_PAGE_SIZE = 45`。 +- 分页:`uisafebox.py:362 SetTableSize(size)` + ```python + pageCount = max(1, size // safebox.SAFEBOX_SLOT_Y_COUNT) + pageCount = min(3, pageCount) # 最多 3 页 + size = safebox.SAFEBOX_SLOT_Y_COUNT + self.wndItem.ArrangeSlot(0, SAFEBOX_SLOT_X_COUNT, size, 32, 32, 0, 0) + wnd_height = 130 + 32 * size + ``` + 全局槽号 `__LocalPosToGlobalPos(local) = curPageIndex * SAFEBOX_PAGE_SIZE + local`(`:357`)。 +- 协议(`REF/UserInterface/Packet.h:98`): + `CG_SAFEBOX_CHECKIN = 70`、`CG_SAFEBOX_CHECKOUT = 71`、`CG_SAFEBOX_ITEM_MOVE = 77`; + `GC_SAFEBOX_MONEY_CHANGE = 84`、`GC_SAFEBOX_SET = 85`、`GC_SAFEBOX_DEL = 86`、 + `GC_SAFEBOX_WRONG_PASSWORD = 87`、`GC_SAFEBOX_SIZE = 88`。 + 发送函数:`SendSafeBoxCheckinPacket(TItemPos inventoryPos, BYTE safeBoxPos)` + (`REF/.../PythonNetworkStreamPhaseGameItem.cpp:29`)——**目标格由调用方指定**, + `SendSafeBoxCheckoutPacket(BYTE safeBoxPos, TItemPos inventoryPos)`(`:43`)、 + `SendSafeBoxItemMovePacket(BYTE src, BYTE dst, BYTE count)`(`:57`)。 + `SendSafeBoxMoneyPacket` 在参考里是 `assert(!"…사용하지 않는 함수")`——**已废弃**, + 金钱走聊天命令(见下)。 +- 落点分支(`uisafebox.py:411 SelectEmptySlot`): + ``` + attachedType == SLOT_TYPE_SAFEBOX → SendSafeboxItemMovePacket(src, dst, 0) # 仓库内移动 + 否则 attachedInvenType = SlotTypeToInvenType(attachedType) + RESERVED_WINDOW → return + ITEM_MONEY → SendSafeboxSaveMoneyPacket(count) + sound/ui/money.wav + 其它 → SendSafeboxCheckinPacket(attachedInvenType, attachedSlotPos, selectedSlotPos) + 最后 DeattachObject() + ``` + `SelectItemSlot`(`:451`):持物时只处理金钱;未持物时先看光标—— + `app.SELL` → `SAFEBOX_SELL_DISABLE_SAFEITEM`;`app.BUY` → `SHOP_BUY_INFO`; + 否则 `AttachObject(self, SLOT_TYPE_SAFEBOX, pos, itemID)` + `sound/ui/pick.wav`。 +- 关闭:`Close()` = `net.SendChatPacket("/safebox_close")`(`uisafebox.py:400`)。 +- 密码 / 改密:`dlgChangePassword`(`uisafebox.py`),错误由 `GC_SAFEBOX_WRONG_PASSWORD` 驱动。 + +**当前**:`POC/project/ui/safebox_ui.gd:7` 是列表 + 自动找空位。 + +**修改** + +1. `safebox_ui.gd` 重写为 5×9 网格 + 最多 3 页的页签,`__LocalPosToGlobalPos` 照抄。 +2. 存取一律**指定目标格**:`M2Client::safebox_checkin(inven_window, inven_pos, safe_pos)`, + 删除「找下一个空位」的逻辑。 +3. 补 `CG_SAFEBOX_ITEM_MOVE(77)` 的发送(仓库内移动)。 +4. 金钱走 `/safebox_money` 类聊天命令**[待读参考:`netSendSafeboxSaveMoneyPacket` 在 + `PythonNetworkStreamModule.cpp` 中的实现,确认是包还是聊天命令]**; + 关闭固定用 `client.say(0, "/safebox_close")`。 +5. 接入 `mouse_controller`(§4.2),两个 slot 事件按上面的分支表实现,含光标态提示文案。 +6. 密码窗与 `GC_SAFEBOX_WRONG_PASSWORD` 的重试提示接上。 + +--- + +### 4.5 交易缺少事务状态 + +**参考** + +- 数据:`CPythonExchange`(`REF/UserInterface/PythonExchange.h:9`) + `EXCHANGE_ITEM_MAX_NUM = 12`;`TExchangeData{ name[25], item_vnum[12], item_count[12], + item_metin[12][ITEM_SOCKET_SLOT_MAX_NUM], item_attr[12][ITEM_ATTRIBUTE_SLOT_MAX_NUM], + accept, elk }`,self / target 各一份。 +- UI(`POC/assets/root/uiexchange.py`): + - `AcceptExchange()`:`net.SendExchangeAcceptPacket()` 然后 `self.AcceptButton.Disable()`。 + - `Refresh()`:`GetAcceptFromSelf()` 为真 → `OwnerAcceptLight.Down()`; + **为假 → `AcceptButton.Enable(); AcceptButton.SetUp(); OwnerAcceptLight.SetUp()`** + —— 即「对方/自己改动导致服务端清 accept」时按钮自动恢复可点,这就是 + “接受后修改自动取消接受”的 1:1 实现,客户端**不本地判定**,只跟随服务端的 accept 位。 + - `OpenPickMoneyDialog()`:`if exchange.GetElkFromSelf() > 0:` → + `EXCHANGE_CANT_EDIT_MONEY`,不弹窗(金钱一旦放入不可改)。`SetMax(7)` = 7 位数上限。 + - `SelectOwnerEmptySlot()`:持金钱 → `SendExchangeElkAddPacket(amount)`; + 持物品且 `attachedSlotType ∈ {SLOT_TYPE_INVENTORY, SLOT_TYPE_DRAGON_SOUL_INVENTORY}` → + `item.SelectItem(itemID)`;`item.IsAntiFlag(item.ANTIFLAG_GIVE)` → 提示 + `EXCHANGE_CANNOT_GIVE` 并 `DeattachObject()` 返回;否则 + `net.SendExchangeItemAddPacket(attachedInvenType, SrcSlotNumber, DstSlotNumber)`。 + - `OnUpdate()`:`USE_EXCHANGE_LIMIT_RANGE = 1000`——自开窗坐标起 + `abs(dx) > 1000 or abs(dy) > 1000` → `net.SendExchangeExitPacket()`。 + - 标题栏关闭按钮直接绑 `net.SendExchangeExitPacket`。 + +**当前**:两列文字列表,无 accept / 距离 / anti-flag 规则。 + +**修改** + +1. `exchange_ui.gd` 重写为 `uiexchange.py` 的 1:1:12 格 × 2 + 金钱 + 两个 accept 灯 + + `AcceptButton`,用 `exchangedialog.py` uiscript 装载真实布局。 +2. `EntityStore` 的交易状态补齐 `TExchangeData` 全字段(含 socket / attribute 二维数组)。 +3. accept 语义:**只跟随服务端**。发送 `CG_EXCHANGE(ACCEPT)` 后立刻禁用按钮; + 收到 `GC_EXCHANGE` 且 `accept == 0` 时重新启用——不写本地「修改则取消」逻辑。 +4. `OnUpdate` 的 1000 距离退出、`ANTIFLAG_GIVE` 拒绝、金钱 7 位上限与 + `EXCHANGE_CANT_EDIT_MONEY` 全部照抄。 +5. 交易中的背包锁定:参考端**没有**——不要加。 + +--- + +### 4.6 NPC 商店仍是基础列表 + +**参考**(`POC/assets/root/uishop.py`) + +- 槽位:`shop.SHOP_SLOT_COUNT`;多货架的全局槽号 + `GetIndexFromSlotPos(slotPos) = self.tabIdx * shop.SHOP_SLOT_COUNT + slotPos`(`:226`, + 本工程增量 34 已按此实现,保持)。 +- 买卖是**光标模式**,不是按钮直接买: + ```python + OnBuy(): chat.AppendChat(CHAT_TYPE_INFO, SHOP_BUY_INFO); app.SetCursor(app.BUY); btnSell.SetUp() + OnSell(): chat.AppendChat(CHAT_TYPE_INFO, SHOP_SELL_INFO); app.SetCursor(app.SELL); btnBuy.SetUp() + CancelShopping(): btnBuy.SetUp(); btnSell.SetUp(); app.SetCursor(app.NORMAL) + ``` +- 卖出 `SellAttachedItem()`(`:279`): + ``` + shop.IsPrivateShop() → DeattachObject(); return + attachedSlotType 必须是 SLOT_TYPE_INVENTORY,否则 sound/ui/loginfail.wav + item.IsAntiFlag(item.ANTIFLAG_SELL) → PopupDialog(SHOP_CANNOT_SELL_ITEM); return + player.IsValuableItem(pos): + itemPrice = item.GetISellItemPrice() + if item.Is1GoldItem(): itemPrice = attachedCount // itemPrice // 5 + else: itemPrice = itemPrice * max(1, attachedCount) // 5 + QuestionDialog(DO_YOU_SELL_ITEM(name, count, price)) → OnSellItem(pos, count, itemtype) + 否则直接 OnSellItem(...) + OnSellItem: net.SendShopSellPacketNew(slotPos, count, itemtype); sound/ui/money.wav + ``` + 注意**卖价 = 单价 × 数量 ÷ 5**(整数除),`Is1GoldItem` 走另一条公式。 +- 关闭:`OnPressEscapeKey`(`:250`)→ `self.Close()` 并 `return True`。 + +**当前**:基础列表 + 直接买。 + +**修改** + +1. `shop_ui.gd` 用 `shopdialog.py` uiscript 装载真实布局(格子 + 图标 + 买/卖两个 toggle)。 +2. 实现 BUY / SELL 光标模式:`app.SetCursor` 的等价见 §8.5 的光标管理器。 +3. 卖出流程按上面的 8 步照抄,含 `ANTIFLAG_SELL` 弹窗、`IsValuableItem` 的确认框、 + `÷5` 价格公式和 `Is1GoldItem` 分支。 +4. 买入:点格子(光标为 BUY 时)→ `net.SendShopBuyPacket(GetIndexFromSlotPos(pos))`。 +5. 货币类型(`ShopTab.coin_type`)与库存计数已在增量 34 实现,保持。 +6. 错误提示一律用服务端 `GC_CHAT` 文案 + `localeInfo` 的 `SHOP_*` 键。 + +--- + +### 4.7 私人商店是兼容实现 + +**参考**:`POC/assets/root/uiprivateshopbuilder.py`(`itemStock` 模型)+ +`PRIVATE_SHOP_ITEM_MAX_NUM`(= `TPacketCGMyShop::bCount` 上限)。 +本工程增量 35/36 已按该文件重写过一次,交互模型(拿起 / 落位 / 价格弹窗 / 排序 + +`display_pos` / 撤下 / 开设 / 撤收)与参考一致。 + +**剩余差距与修改** + +1. 拖放改走 §4.2 的全局 `mouse_controller`,而不是当前的「点选候选面板」。 +2. 开店前检查:参考在 `uiprivateshopbuilder.py` 里检查 + `item.IsAntiFlag(item.ANTIFLAG_MYSHOP)`(`ITEM_ANTIFLAG_MYSHOP = 1<<16`, + `REF/GameLib/ItemData.h:204`)——当前缺,补上。 +3. 其他玩家查看:`GC_SHOP` 的 `SHOP_SUBHEADER_GC_START_EX` 已实现;招牌走 + `GC_SHOP_SIGN`(已实现),但需按 §8.2 换成真 `.sub` 资源渲染。 +4. 撤下回滚:参考端**没有**本地回滚——撤下即发包,状态由 `GC_SHOP` 回包驱动。 + 现有实现若做了本地乐观更新,删掉。 + +--- + +### 4.8 快捷栏来源类型不完整 + +**参考** + +- 容量(`REF/UserInterface/Packet.h:392`): + `QUICKSLOT_MAX_LINE = 4`、`QUICKSLOT_MAX_COUNT_PER_LINE = 8`、 + `QUICKSLOT_MAX_COUNT = 32`、`QUICKSLOT_MAX_NUM = 36`(**服务器保存 36,客户端只显示 32**)。 +- 类型:`TQuickSlot{ BYTE Type; BYTE Position; }`(`REF/UserInterface/GameType.h:307`), + 其中 **`Type` 直接就是 `ESlotType`**(`GameType.h:140`)—— + `CPythonPlayer::RequestUseLocalQuickSlot`(`REF/UserInterface/PythonPlayer.cpp:813`) + 拿 `GetLocalQuickSlotData()` 的原值直接 switch: + ``` + SLOT_TYPE_INVENTORY (1) → SendItemUsePacket(TItemPos(INVENTORY, pos)) + SLOT_TYPE_SKILL (2) → ClickSkillSlot(pos) // 走 §3.8 的完整链 + SLOT_TYPE_EMOTION (3) → BINARY_ActEmotion(pos) + ``` + **参考端快捷栏没有「命令」类型**,`CLIENT-GAP.md` 里提到的“缺命令”不成立。 + +- **⚠ 当前实现的类型编号与参考端不兼容**: + `POC/extension/src/net/m2_client.cpp:878` 是 + ```cpp + d["type"] = (int)qs.type; // 1 item / 2 skill / 3 command / 4 emotion + ``` + 而参考端 `3 == SLOT_TYPE_EMOTION`、`4 == SLOT_TYPE_SHOP`(快捷栏不可执行)。 + 也就是说**当前把服务端发来的 EMOTION(3) 当成了 command,把不可执行的 SHOP(4) 当成了 + emotion**——服务端下发的表情快捷栏会被误判,`quickslot_add()` + (`m2_client.cpp:897` 的 `type < 1 || type > 4`)也会把错误类型发回服务端。 +- 边界(`PythonPlayer.cpp:800/815/843/852/861`): + `RequestDeleteGlobalQuickSlot >= QUICKSLOT_MAX_COUNT(32) → return`; + `RequestUseLocalQuickSlot >= QUICKSLOT_MAX_COUNT_PER_LINE(8) → return`; + `AddQuickSlot / DeleteQuickSlot / MoveQuickSlot` 越界 `QUICKSLOT_MAX_NUM(36)` → return。 + `MoveQuickSlot(src, dst)` 是 `std::swap`,不是覆盖。 +- 键位(`POC/assets/root/game.py:509`): + ```python + def __PressQuickSlot(self, localSlotIndex): + if app.IsRTL(): + if 0 <= localSlotIndex < 4: player.RequestUseLocalQuickSlot(3 - localSlotIndex) + else: player.RequestUseLocalQuickSlot(11 - localSlotIndex) + else: player.RequestUseLocalQuickSlot(localSlotIndex) + ``` + 数字键 1–4 走 `pressNumber`(默认 = `__PressQuickSlot`), + 按住 LSHIFT 时 `pressNumber` 切换成 `__SelectQuickPage`(`game.py:506`)。 + F1–F4 固定映射到 `__PressQuickSlot(4..7)`(`game.py:318`)。 + +**当前**:界面显示 36 格(4 页 × 9 格)+ F1–F4 + 数字键 1–9;参考端是服务器保存 36 格、 +客户端显示 32 格(4 页 × 8 格)。 + +**修改** + +1. 数字键改为 **1–4 → 快捷栏 0–3**(`__PressNumKey` 在 `game.py:431`: + 非 Ctrl 时 `if 1 <= num <= 4: self.pressNumber(num-1)`; + **Ctrl + 1..9 是表情**:`chrmgr.SetEmoticon(-1, num-1)` + `net.SendEmoticon(num-1)`)。 + 当前的「数字键 1–9 全给快捷栏」要改掉。 +2. LSHIFT 按下 → `pressNumber = __SelectQuickPage`;松开 → `pressNumber = __PressQuickSlot` + (`game.py:370` / `:408`)。 +3. **先修类型编号**:`EntityStore` / `M2Client` / `quickslot_ui` 里的快捷栏 type 一律改用 + §4.2 移植的 `ESlotType`(`SLOT_TYPE_INVENTORY=1 / SKILL=2 / EMOTION=3`), + 删掉 `m2_client.cpp:878` 的 `3 command / 4 emotion` 注释与对应分支; + `quickslot_add()` 的入参校验从 `1..4` 改为「只接受 1 / 2 / 3」。 + 这是**数据错误**,必须先于 UI 工作修复。 +4. 快捷栏类型只保留 `INVENTORY / SKILL / EMOTION` 三种,`SKILL` 必须走 §3.8 的 + `ClickSkillSlot` 完整链,不能直接发包;收到其它 `ESlotType` 一律不执行(与参考的 + switch 落空行为一致)。 +5. 边界常量按 §3.9 的 `limits.h`;`MoveQuickSlot` 改为 swap。 +6. 物品拖入快捷栏:走 §4.2 的 `mouse_controller`,`AttachObject` 的 + `SLOT_TYPE_QUICK_SLOT` 分支会递归解析真实类型(`mousemodule.py:218`)。 +7. 冷却 / 死亡 / 变身 / 换武器后的刷新由对应回包驱动,不本地计时。 + +--- + +### 4.9 组队状态是近似定义 + +**参考**:`PARTY_AFFECT_SLOT_MAX_NUM = 7`(`REF/UserInterface/Packet.h:404`)。 +角色枚举与 Leadership 门槛在本工程增量 43 已按 40250 服务端 `CParty::Update` 对齐 +(NORMAL 0 / LEADER 1 / ATTACKER 2 / TANKER 3 / BUFFER 4 / SKILL_MASTER 5 / HASTE 6 / +DEFENDER 7;门槛 10/20/20/25/35/40,治疗 18)。 + +**剩余差距与修改** + +1. `affects[7]` 的**槽位语义**目前是“暂定映射”。需要从 40250 服务端 + `game/src/party.cpp` 的 `CParty::ComputeRolePoint / Update` 读出每个下标的含义 + **[待读参考:服务端源码,不在 ClientVS22 内]**;在拿到之前, + UI 上的 chip 名称保留占位并在文档标注,不要臆造名称。 +2. 队伍技能冷却:参考客户端**不本地计时**,冷却由服务端拒绝 + `GC_CHAT` 提示体现。 + 若当前有本地冷却,删除。 +3. 队长权限动态变化:以 `get_party()` 里本地 VID 那条的 `leader` 位为唯一判据(已实现), + 收到 `GC_PARTY_*` 后立即重算按钮可用性。 +4. 离线 / 死亡 / 距离状态:来自 `GC_PARTY_UPDATE` 的字段,接到 `PartyMemberInfoBoard` 的 + 对应显示位。 + +--- + +### 4.10 好友、情侣、公会只有首版状态展示 + +**参考**:`POC/assets/root/uimessenger.py`(739 行,分组 / 上线离线 / 右键菜单)、 +`uiwhisper.py`(私聊窗生命周期)、`uiguild.py`。 +公会名长度 `GUILD_NAME_MAX_LEN = 12`、等级名 `GUILD_GRADE_NAME_MAX_LEN = 8`、 +等级数 `GUILD_GRADE_COUNT = 15`、公告 `GULID_COMMENT_MAX_LEN = 50` +(`REF/UserInterface/Packet.h:408`)。 + +**修改** + +1. 按 §8 的窗口迁移样板逐窗移植 `uimessenger.py` / `uiwhisper.py` / `uiguild.py`, + 不自行设计交互。 +2. 公会相关按 `CLIENT-GAP.md` 的当前范围说明**暂缓**,但常量先落到 `limits.h`。 +3. 情侣:已有 `GC_LOVER_INFO` / `GC_LOVE_POINT_UPDATE` 与 `love_ui`; + 完整交互按 `uiwhisper.py` / `uicharacter.py` 里的情侣分支移植。 + +--- + +## 5. P1:任务、NPC 和世界玩法 + +### 5.1 Quest EventManager 只支持少量标签 + +**参考**:标签表在 `CPythonEventManager::CPythonEventManager()` +(`REF/UserInterface/PythonEventManager.cpp:1148`)里注册,**共 38 个**: + +``` +LETTER COLOR COLOR256 DELAY ENTER WAIT CLEAR QUESTION NEXT DONE +LEFTIMAGE TOPIMAGE BGIMAGE IMAGE INSERT_IMAGE TITLE_IMAGE +ADDMAPSIGNAL CLEARMAPSIGNAL SETMSGPOS ADJMSGPOS SETCMAPPOS +QUESTBUTTON QUESTBUTTON_CLOSE +SLEEP SET_CAMERA BLEND_CAMERA RESTORE_CAMERA +FADE_OUT FADE_IN WHITE_OUT WHITE_IN +CLEAR_TEXT TEXT_HORIZONTAL_ALIGN_CENTER +RUN_CINEMA DUNGEON_RESULT ITEM MOB WINDOW_SIZE +INPUT CONFIRM_WAIT END_CONFIRM_WAIT SELECT_ITEM +``` + +分派在 `ProcessEventSet()`(`PythonEventManager.cpp:377`),每个 case 的语义 +(**逐条照抄**,行号为 `PythonEventManager.cpp`): + +| 标签 | 行 | 行为 | +|---|---|---| +| `LETTER` | 399 | 追加 `value` 到当前行;着色 `CurrentColor`;`iCurrentLetter += len`;超 `iRestrictedCharacterCount` 换行;`lLastDelayTime = lWaitingTime` | +| `DELAY` | 416 | start:`lWaitingTime = atoi(value)`;end:恢复 `c_lNormal_Waiting_Time` | +| `COLOR` | 425 | start:`r/g/b` 取 `atof`,a=1;end:全 1 | +| `COLOR256` | 444 | 同上但 `/255.0f` | +| `ENTER` | 463 | `__InsertLine` | +| `WAIT` | 469 | `iyLocal = 0; isLock = true` | +| `NEXT` | 477 | `MakeNextButton(BUTTON_TYPE_NEXT)`;`iAdjustLine += 2` | +| `DONE` | 484 | `MakeNextButton(BUTTON_TYPE_DONE)` + Python `DoneEvent()`;`iAdjustLine += 2` | +| `CLEAR` / `CLEAR_TEXT` | 492 / 667 | `ClearLine` | +| `QUESTION` | 498 | `MakeQuestion(argList)` | +| `LEFTIMAGE`/`TOPIMAGE`/`BGIMAGE` | 504/510/516 | Python `OnLeftImage/OnTopImage/OnBackgroundImage(src)` | +| `IMAGE` | 522 | Python `OnImage(x, y, src)` | +| `INSERT_IMAGE` | 532 | 有 `image_name` → `OnInsertImage(file, title, title, desc, index, total)`;否则 `OnInsertItemIcon(image_type, idx, title, desc, index, total)`;`iAdjustLine += 2` | +| `ADDMAPSIGNAL` | 554 | `MiniMap.AddSignalPoint(x,y)` + `OpenAtlasWindow()` | +| `CLEARMAPSIGNAL` | 564 | `MiniMap.ClearAllSignalPoint()` | +| `SETCMAPPOS` | 612 | `MiniMap.SetAtlasCenterPosition(x, y)` | +| `SETMSGPOS`/`ADJMSGPOS` | 604/608 | **空实现** | +| `QUESTBUTTON` | 579 | `icon_name` 为空 → `RecvQuest(idx, name)`;否则 `BINARY_RecvQuest(idx, name, icon_type, icon_name)` | +| `QUESTBUTTON_CLOSE` | 571 | `BINARY_ClearQuest(idx)` | +| `SLEEP` | 617 | `lLastDelayTime = atoi(value)` | +| `SET_CAMERA` | 620 | `rApp.SetEventCamera(设置)` | +| `BLEND_CAMERA` | 627 | `rApp.BlendEventCamera(设置, blendtime)` | +| `RESTORE_CAMERA` | 637 | `rApp.SetDefaultCamera()` | +| `FADE_OUT/FADE_IN/WHITE_OUT/WHITE_IN` | 642/649/655/661 | Python `FadeOut/FadeIn/WhiteOut/WhiteIn(speed)`,并 `isWaitFlag = true` | +| `TEXT_HORIZONTAL_ALIGN_CENTER` | 672 | `isTextCenterMode = true` + 当前行居中 | +| `TITLE_IMAGE` | 679 | Python `OnTitleImage(src)` | +| `DUNGEON_RESULT` | 684 | `ShowDungeonResult(killstone_count, killmob_count, find_hidden, hidden_total, use_potion, is_revived, killallmob, total_time, bonus_exp)` | +| `ITEM` | 709 | 用 `CItemManager` 取物品名追加,颜色固定 `(1.0, 0.2, 0.2)` | +| `MOB` | 728 | 用 `CPythonNonPlayer` 取怪物名追加(无着色) | +| `WINDOW_SIZE` | 748 | Python `OnSize(width, height)` | +| `INPUT` | 755 | `__InsertLine` + Python `OnInput()` | +| `CONFIRM_WAIT` | 761 | `isConfirmWait = TRUE; pConfirmTimeTextLine = 当前行; iConfirmEndTime = now/1000 + timeout;` `__InsertLine(TRUE)` + `MakeNextButton(BUTTON_TYPE_CANCEL)` | +| `END_CONFIRM_WAIT` | 771 | 遍历所有 event set,清 `isConfirmWait` | +| `SELECT_ITEM` | 790 | Python 侧打开选魔石窗(本工程已实现,保持) | +| `RUN_CINEMA` | 169 | **在注册阶段**特判:脚本首条是它时改为 `RegisterEventSet(value)` 加载另一个文件 | + +时间推进(`UpdateEventSet`,`:312`): +`lLastDelayTime` 按帧 elapsed 递减,`<=0` 就 `ProcessEventSet` 一条; +`isWaitFlag` 为真时整体暂停;`isConfirmWait` 时每帧刷新倒计时文本 +`m_strLeftTimeString`(默认 `"남은 시간 : %d초"`,可由 `SetLeftTimeString` 改)。 + +**当前**:`POC/project/ui/quest_dialog.gd:102` 只支持 8 个标签。 + +**修改** + +1. `quest_dialog.gd` 的 `parse_script` 换成完整的 `EventTypeMap`(38 项)+ + 上表的 `ProcessEventSet` 分派,逐条实现。 +2. 引入 `EventSet` 状态:`lWaitingTime / lLastDelayTime / iCurrentLetter / + iRestrictedCharacterCount / iAdjustLine / iyLocal / isLock / isWaitFlag / + isConfirmWait / iConfirmEndTime / isTextCenterMode / CurrentColor`,字段名保持一致。 +3. `SETMSGPOS` / `ADJMSGPOS` 实现为**空**(与参考一致),不要自行发挥。 +4. `MakeNextButton` 的三种类型 `NEXT / DONE / CANCEL`、`MakeQuestion` 的选项按钮 + 按 `PythonEventManager.cpp` 的对应函数移植 + (`MakeNextButton` `:878`、`MakeQuestion` `:884`、`ClearLine` `:953`、`__InsertLine` `:976`)。 +5. 相机类标签(`SET_CAMERA/BLEND_CAMERA/RESTORE_CAMERA`)接 §7.6 的相机模式。 + +--- + +### 5.2 任务日志不是完整 Quest 系统 + +**参考**:`REF/UserInterface/PythonQuest.h` + `POC/assets/root/uiquest.py`(926 行)。 +任务条目由 `EVENT_TYPE_QUEST_BUTTON` 的 `BINARY_RecvQuest(idx, name, icon_type, icon_name)` +(§5.1)与 `GC_QUEST_INFO` 共同驱动;关闭走 `BINARY_ClearQuest(idx)`。 + +**修改** + +1. 按 §8 的窗口迁移样板移植 `uiquest.py`(QuestButton / 图标 / 倒计时 / 定位)。 +2. `quest_log.gd:8` 的纯文字列表改为读 `icon_type` + `icon_name`, + 图标资源走 `UiAssets.load_dds_image`。 +3. 世界箭头 / 边缘指示对应 `EVENT_TYPE_ADD_MAP_SIGNAL` + `CPythonMiniMap::AddSignalPoint`, + 接到已有的 minimap 实现。 + +--- + +### 5.3 钓鱼、副本、坐骑和观战者只有部分协议入口 + +**参考要点** + +- 钓鱼:`CPythonPlayer::NEW_Fishing()`(`REF/UserInterface/PythonPlayerInput.cpp:526`) + ``` + if (pkInstMain->GetFishingRot(&irot)) SendFishingPacket(irot); + else PyCall(gameWindow, "OnFishingWrongPlace") + ``` + 攻击键在钓鱼模式下改为钓鱼:`SetAttackKeyState`(`PythonPlayerInputKeyboard.cpp:6`) + `if (isPress && main->IsFishingMode()) { NEW_Fishing(); return; }`。 + 方向键会 `NEW_CancelFishing()`(`PythonPlayerInputKeyboard.cpp:24`)。 +- 坐骑:`CInstanceBase::MountHorse` → `SetMotionMode(MODE_HORSE)`(`InstanceBase.cpp:953`); + 攻击限制 `CanAttackHorseLevel()`(`InstanceBaseBattle.cpp:623`)= + `IsMountingHorse() && m_kHorse.CanAttack()`; + 骑马手部模式下禁止徒手攻击:`NEW_Attack()`(`PythonPlayerInput.cpp:613`) + `if (main->IsMountingHorse() && main->IsHandMode()) return;` + 动作模式表见 §6.4。 +- 副本:`CDungeonBlock`(`REF/GameLib/DungeonBlock.h`)+ `PROPERTY_TYPE_DUNGEON_BLOCK` + (§7.1);结算走 `EVENT_TYPE_DUNGEON_RESULT`(§5.1)。 +- 观战:`CPythonPlayer::IsObserverMode()`——它在 `__CanUseSkill()`(`PythonPlayerSkill.cpp:445`) + 与 `IsAttackableInstance()`(`InstanceBase.cpp:2151`)里都是**第一道门**。 + +**修改** + +1. `player_controller.gd` 加 `is_fishing_mode / is_observer_mode / is_hand_mode` 三个状态位, + 并把上面四处门禁接上。 +2. 钓鱼:攻击键在钓鱼模式改发 `CG_FISHING(irot)`;方向键取消钓鱼; + `OnFishingWrongPlace` 走 `localeInfo` 文案。 +3. 坐骑:`GC_MOUNT` / `mount_vnum` 已入 `EntityStore`;补模型挂载与 + `MODE_HORSE*` 动作模式(§6.4)、`CanAttackHorseLevel` 限制。 +4. 副本:`DungeonBlock` 已接入静态地图(§7.1 保持);结算窗按 `EVENT_TYPE_DUNGEON_RESULT` + 的 9 个参数实现。 + +--- + +### 5.4 PVP、PK 和决斗没有完整规则 + +**参考**:`CInstanceBase::IsAttackableInstance(victim)` +(`REF/UserInterface/InstanceBase.cpp:2147`)是**唯一**判据,完整逻辑: + +``` +主角且 IsObserverMode() → false +自己打自己 → false +IsStone(): victim.IsPC() → true +IsPC(): + victim.IsStone() → true + victim.IsPC(): + GetDuelMode(): + DUEL_CANNOTATTACK → false + DUEL_START → __FindDUELKey(self, victim) ? true : false + PK_MODE_GUILD == GetPKMode() && 同公会 → false + victim.IsKiller() && !同队 → true + PK_MODE_PROTECT != GetPKMode(): + PK_MODE_FREE : victim 非 PROTECT && !同队 → true + PK_MODE_GUILD : victim 非 PROTECT && !同队 && 公会不同 → true + IsSameEmpire(victim): + IsPVPInstance(victim) → true + PK_MODE_REVENGE && !同队 && IsConflictAlignmentInstance → true + else (不同帝国) → true + victim.IsEnemy() → true + victim.IsWoodenDoor() → true +IsEnemy(): victim.IsPC() || victim.IsBuilding() → true +IsPoly(): victim.IsPC() || victim.IsEnemy() → true +否则 false +``` + +`EPKModes`(`REF/UserInterface/Packet.h:1273`): +`PK_MODE_PEACE=0, PK_MODE_REVENGE=1, PK_MODE_FREE=2, PK_MODE_PROTECT=3, PK_MODE_GUILD=4, +PK_MODE_MAX_NUM=5`。 + +**注意**:`uigameoption.py` 的 PK 单选按钮映射是 +`peace→/pkmode 0`、`revenge→/pkmode 1`、`guild→/pkmode 4`、`free→/pkmode 2` +(本工程增量 38 已实现,与上面的枚举一致,保持)。 + +名字颜色枚举 `ENameColor`(`REF/UserInterface/InstanceBase.h:227`): +`NAMECOLOR_MOB / NPC / PC(+EMPIRE_NUM) / NORMAL_* / EMPIRE_* / FUNC / PK / PVP / PARTY / +WARP / WAYPOINT / EXTRA(=FUNC+10) / NUM(=EXTRA+10)`。 + +**修改** + +1. 在 `player_controller.gd`(或 `entity_rules.gd`)**逐行**实现 + `is_attackable_instance(self_vid, victim_vid)`,不做简化。 +2. 依赖判据补齐:`IsKiller()`(alignment < 0 或 PK 标记)、`IsSameEmpire()`(empire 字段, + §2.5 已补)、`IsPVPInstance()`(`GC_PVP` 关系表)、`IsConflictAlignmentInstance()` + (alignment 符号相反)、`IsSamePartyMember()`(`get_party()`)、 + `GetDuelMode()` + `__FindDUELKey`(`GC_DUEL_START` 已实现)。 +3. 目标框 / 名字颜色 / 可攻击光标按 `ENameColor` 与 `is_attackable_instance` 的结果驱动(§8.8)。 +4. 保护区:`IsInSafe()` 已在 §3.4 的预约流程里用到,数据来自地形属性 + `ATTRIBUTE_BANPK`**[待读参考:`REF/GameLib/TerrainImpl` 的 `ATTRIBUTE_*` 枚举]**。 + +--- + +## 6. P1/P2:模型、动画和特效 + +### 6.1 本地角色先显示胶囊占位体 + +**参考**:参考端**没有占位体**。加载期由 Python 的加载幕布盖住: +`PHASE_WINDOW_LOAD` 的 `LoadData(lX, lY)`(`REF/.../PythonNetworkStreamPhaseLoading.cpp:211`) +在 `introloading.py` 里显示加载画面;真正的主角实例由 +`GC_CHARACTER_ADD` + `GC_CHAR_ADDITIONAL_INFO` 走 §0.3 的合流创建; +`GameWindow.Open()`(`game.py:137`)在 `PHASE_GAME` 才 `Show()`。 + +**当前**:`game_scene.gd:136` 先建 Capsule,`:811` 再换真模型。 + +**修改** + +1. 删除 `game_scene.gd:136` 与 `:811` 的 Capsule 占位路径。 +2. 主角节点只在收到 `Spawn(main_vid)`(合流后)时创建一次,位置/朝向直接取包内值。 +3. 在此之前保持 `introloading` 等价的加载幕布(`POC/project/ui` 下已有 `_loading` 层)覆盖, + 与 `GameWindow.Open()` 的 `Show()` 时机对齐(§2.6 第 2 步)。 +4. 相机目标高度、碰撞体、阴影都挂在真实模型上,不再迁移。 + +--- + +### 6.2 SpeedTree 仍是程序化代理 + +**参考**:`REF/SpeedTreeLib/`(`SpeedTreeWrapper.{h,cpp}` / `SpeedTreeForest*.cpp` / +`SpeedTreeMaterial.h` / `SpeedGrass*`),核心 API: +``` +LoadTree(pszSptFile, pbBlock, uiBlockSize, nSeed=1, fSize=-1, fSizeVariance=-1) // .spt +RenderBranches() / RenderFronds() / RenderLeaves() / RenderBillboards() // 四类几何 +GetLeafMaterial() / GetLeafLightingAdjustment() +SetWindStrength(float) +``` +地图侧:`CArea::__SetObjectInstance_SetTree` → `prt::PROPERTY_TYPE_TREE`, +属性 `TPropertyTree{strName, strFileName, fSize, fVariance}`(`REF/GameLib/MapType.h:47`); +风强度来自 `TEnvironmentData.fWindStrength / fWindRandom`(`MapType.h:154`)。 + +**当前**:`POC/extension/src/tree_placeholder.cpp:190` 只嗅探贴图 + proxy 几何。 + +**修改** + +1. 这是**独立的大工作量项**:需要实现 `.spt` 解析器(SpeedTree 5 二进制),产出 + branches / fronds / leaves / billboards 四组几何与各自材质。 +2. 在 `.spt` 解析落地之前,`tree_placeholder.cpp` 应至少: + - 读 `TPropertyTree` 的 `fSize` / `fVariance` 并按 `nSeed` 做确定性缩放(当前缺); + - 把风强度接到 `TEnvironmentData.fWindStrength / fWindRandom`(`.msenv`,§7.5)。 +3. 在 `PARITY-GAP.md:77` 标注为「参考模块未移植」,不要按截图反推参数。 + +--- + +### 6.3 EffectLib 只有粒子近似 + +**参考**:`CEffectData`(`REF/EffectLib/EffectData.h:9`)一个 `.mse` 由**三类元素 + 声音**组成: +``` +TParticleVector m_ParticleVector; // CParticleSystemData —— 粒子系统 +TMeshVector m_MeshVector; // CEffectMeshScript —— .mde 网格特效 +TLightVector m_LightVector; // CLightData —— SimpleLight +NSound::TSoundInstanceVector m_SoundInstanceVector; +float m_fBoundingSphereRadius; D3DXVECTOR3 m_v3BoundingSpherePosition; +``` +- `CLightData`(`REF/EffectLib/SimpleLightData.h`): + `m_fMaxRange / m_fDuration / m_cAmbient / m_cDiffuse / m_bLoopFlag / + m_fAttenuation0 / m_fAttenuation1 / m_fAttenuation2`,`GetRange(fTime, &range)` 是时间曲线。 +- `CParticleProperty`(`REF/EffectLib/ParticleProperty.h:12`): + `ERotationType{NONE, TIME_EVENT, CW, CCW, RANDOM_DIRECTION}`、 + `ETextureAnimationType{NONE, CW, CCW, RANDOM_FRAME, RANDOM_DIRECTION}`, + 多贴图(`InsertTexture`)+ `GetTextureAnimationFrameCount()`。 +- `CEmitterProperty`(`REF/EffectLib/EmitterProperty.h:9`): + `EmitterShape{POINT, ELLIPSE, SQUARE, SPHERE}`、 + `EmitterAdvancedType{FREE, OUTER, INNER}`、`isEmitFromEdge()`、 + `GetMaxEmissionCount / GetCycleLength / isCycleLoop / GetLoopCount`, + 以及一组时间曲线 `GetEmittingSize / AngularVelocity / DirectionX/Y/Z(fTime, &v)`。 + +**当前**:`POC/project/fx/effect_player.gd:8` 只映射 Particle → `GPUParticles3D`; +`:134` 用程序化渐变代替 `.dds`;`.mde` 用 box 代替。 + +**修改** + +1. `.dds` 贴图改为真加载——本工程已有 `Metin2World.load_dds`,把 + `effect_player.gd:134` 的程序化渐变换掉。 +2. `.mde` 网格特效:新增解析器输出 `ArrayMesh`,替换 box + **[待读参考:`REF/EffectLib/EffectMesh.{h,cpp}` 的 `CEffectMeshScript::LoadScript`]**。 +3. SimpleLight:按 `CLightData` 的 8 个字段生成 `OmniLight3D`, + `range` 走时间曲线,`attenuation` 三系数映射到 Godot 的衰减近似并在文档记录差异。 +4. 粒子属性补齐:`rotation_type` / `texture_animation_type` / 多贴图帧 / + emitter shape 与 advanced type / `emit_from_edge` / cycle & loop。 +5. 骨骼挂点:`.mse` 挂到角色骨骼由 `CActorInstance::AttachEffect` + **[待读参考:`REF/GameLib/ActorInstanceAttach.cpp`]** 驱动,接到本工程的 bone attach。 + +--- + +### 6.4 GR2 运行时不是完整 ActorInstance + +**参考** + +- 动作模式枚举 `CRaceMotionData::EMotionMode`(`REF/GameLib/RaceMotionData.h:33`): + ``` + MODE_RESERVED, MODE_GENERAL, MODE_ONEHAND_SWORD, MODE_TWOHAND_SWORD, + MODE_DUALHAND_SWORD, MODE_BOW, MODE_FAN, MODE_BELL, MODE_FISHING, + MODE_HORSE, MODE_HORSE_ONEHAND_SWORD, MODE_HORSE_TWOHAND_SWORD, + MODE_HORSE_DUALHAND_SWORD, MODE_HORSE_BOW, MODE_HORSE_FAN, MODE_HORSE_BELL, + MODE_WEDDING_DRESS, MODE_MAX_NUM + ``` +- 选择逻辑 `CInstanceBase::RefreshState`(`REF/UserInterface/InstanceBase.cpp:2851`), + **顺序即优先级**: + ``` + IsPoly() → MODE_GENERAL + IsWearingDress() → MODE_WEDDING_DRESS + IsHoldingPickAxe() → 骑马 ? MODE_HORSE : MODE_GENERAL + ITEM_TYPE_ROD → 骑马 ? MODE_HORSE : MODE_FISHING + 骑马: WEAPON_SWORD→HORSE_ONEHAND_SWORD, TWO_HANDED→HORSE_TWOHAND_SWORD, + DAGGER→HORSE_DUALHAND_SWORD, FAN→HORSE_FAN, BELL→HORSE_BELL, + BOW→HORSE_BOW, default→MODE_HORSE + 徒步: WEAPON_SWORD→ONEHAND_SWORD, TWO_HANDED→TWOHAND_SWORD, + DAGGER→DUALHAND_SWORD, BOW→MODE_BOW, FAN→MODE_FAN, BELL→MODE_BELL, + WEAPON_ARROW/default→MODE_GENERAL + 最后: isLoop ? InterceptLoopMotion(idx) : InterceptOnceMotion(idx); RefreshActorInstance(); + ``` + `SetMotionMode(m)`(`REF/GameLib/ActorInstanceMotion.cpp:247`):非法值回落 `MODE_GENERAL`。 +- 动作查表:`m_pkCurRaceData->GetMotionKey(m_wcurMotionMode, dwMotion, &dwMotionKey)` + (`ActorInstanceMotion.cpp:306/345/404`)——**动作号必须带 motion mode 一起查**。 +- 技能动作:`CInstanceBase::NEW_UseSkill(uSkill, uMot, uMotLoopCount, isMovingSkill)` + (`InstanceBaseBattle.cpp:310`) + ``` + IsDead/IsStun/IsKnockDown → false + isMovingSkill ? (未走则 StartWalking, m_isGoing=TRUE) : (在走则 EndWalking, m_isGoing=FALSE) + SetAdvancingRotation(GetTargetRotation()) + InterceptOnceMotion(CRaceMotionData::NAME_SKILL + uMot, 0.1f, uSkill, 1.0f) + __OnUseSkill(uMot, uMotLoopCount, isMovingSkill) + uMotLoopCount > 0 → SetMotionLoopCount(uMotLoopCount) + ``` +- 动作事件类型 `EMotionEventType`(`RaceMotionData.h:160`): + `NONE, EFFECT, SCREEN_WAVING, SCREEN_FLASHING, SPECIAL_ATTACKING, SOUND, FLY, + CHARACTER_SHOW, CHARACTER_HIDE, WARP, EFFECT_TO_TARGET`。 +- 动作数据接口(`RaceMotionData.h:216`): + `GetMotionDuration / IsAccumulationMotion / GetAccumulationPosition / + IsComboInputTimeData / GetComboInputStartTime / GetNextComboTime / GetComboInputEndTime / + isAttackingMotion / GetMotionAttackDataPointer / HasSplashMotionEvent / + IsCancelEnableSkill / IsLoopMotion / GetLoopStartTime / GetLoopEndTime / + GetMotionEventDataCount / GetEventType / GetEventStartTime / GetSoundInstanceVectorPointer`。 + +**当前**:无 motion mode 概念;loop / interrupt / accumulation / 动作保留未实现。 + +**修改** + +1. `metin2_model` / `metin2_anim` 引入 `motion_mode`,`.msm` 解析出 + 「(motionMode, motionIndex) → 动作文件 + 权重(byPercentage)」的两级表 + (`CRaceData::TMotionModeDataMap` / `TMotionVectorMap`,`REF/GameLib/RaceData.h:50`)。 +2. 实现 `refresh_state()`:**逐行**照抄上面的优先级链,输入是 + `is_poly / is_wearing_dress / is_holding_pickaxe / item_type / weapon_subtype / is_mounting`。 +3. 播放接口对齐三种:`InterceptLoopMotion` / `InterceptOnceMotion` / `SetLoopMotion`, + 带 `fBlendTime`(技能固定 `0.1f`)与 `fSpeedRatio`。 +4. `accumulation motion`(位移由动作数据驱动)与 `SetMotionLoopCount` 补上。 +5. 击退 / 倒地 / 起身 / 死亡动作:按 `NAME_*` 动作名枚举(`RaceMotionData.h:40-160`)接入。 +6. 武器轨迹 `CWeaponTrace`(`REF/GameLib/WeaponTrace.{h,cpp}`)单列一项。 + +--- + +### 6.5 武器和特效骨骼挂点不完整 + +**参考**:`CRaceData::EParts`(`REF/GameLib/RaceData.h:16`)只有 5 个: +``` +PART_MAIN, PART_WEAPON, PART_HEAD, PART_WEAPON_LEFT, PART_HAIR, PART_MAX_NUM +``` +挂点名表 `TAttachingBoneNameMap`(`RaceData.h:35`)与 +`TModelData{ NRaceData::TAttachingDataVector AttachingDataVector; }`(`:63`)来自 `.msm`。 +挂载实现在 `REF/GameLib/ActorInstanceAttach.cpp`。 + +**修改** + +1. `PART_WEAPON_LEFT` 是参考里明确存在的部件——补左手武器 / 双持挂点 + (本工程当前只有右手 + 部分盾牌)。 +2. 挂点骨骼名从 `.msm` 的 `TAttachingBoneNameMap` 读,不再硬编码 + (本工程当前硬编码 `equip_right_hand` 等)。 +3. 坐骑挂点、骨骼特效(`EFFECT_TO_TARGET`)按 `ActorInstanceAttach.cpp` 移植。 +4. 装备切换时清理旧挂点:参考在 `ChangeWeapon` 里 detach 后再 attach + **[待读参考:`InstanceBase.cpp` 的 `ChangeWeapon` / `__AttachWeapon`]**。 + +--- + +### 6.6 材质是固定功能近似 + +**参考**:材质开关来自 `.gr2` 的 `MaterialInfo`(本工程增量 30 已接 `alpha_blend / two_sided`); +多贴图 stage / color operation 由 `CGraphicThing` 的渲染路径决定 +**[待读参考:`REF/EterGrnLib/Thing.cpp` 与 `Material.cpp` 的 stage 设置]**。 +球面高光来自 `item_proto` 的 `bSpecular`(本工程已接 `specular/100`)。 + +**修改** + +1. `m2_material.h:39` 的 `spec_power` 默认 0 保持——参考端确实由装备数据驱动; + 缺的是**远端角色**也要按其 `armor/weapon` 的 proto 取 specular(当前只有本地主角)。 +2. alpha-test ref 值、贴图 stage 数、color op 三项在 `EterGrnLib` 的渲染端读出后再定, + 目前不要猜数值。 +3. 透明排序:参考按 `CArea` 的 `m_ThingCloneInstaceVector` 与角色分批渲染; + Godot 侧对应 `render_priority` + `transparency` 的固定分层,先在文档记录映射再实现。 + +--- + +## 7. P1/P2:地图、环境和相机 + +### 7.1 静态地图只完整接入 Building / DungeonBlock + +**参考**:`CArea`(`REF/GameLib/Area.{h,cpp}`)的对象只有 **5 种** +(`prt::PROPERTY_TYPE_*`,`Area.cpp:398-446`): + +| 类型 | 属性结构(`REF/GameLib/MapType.h`) | 运行时 | 参考行 | +|---|---|---|---| +| `TREE` | `TPropertyTree{strName, strFileName, fSize, fVariance}` (:47) | `CSpeedTreeWrapper` | `Area.cpp:590` | +| `BUILDING` | `TPropertyBuilding{strName, strFileName, strAttributeDataFileName, isShadowFlag}` (:56) | `CGraphicThingInstance` + 属性/碰撞/高度 | `Area.cpp:615` | +| `EFFECT` | `TPropertyEffect{strName, strFileName}` (:64) | `CEffectManager` 实例 | `Area.cpp:531` | +| `AMBIENCE` | `TPropertyAmbience{strName, strPlayType, fPlayInterval, fPlayIntervalVariation, fMaxVolumeAreaPercentage, AmbienceSoundVector}` (:85) | `TAmbienceInstance` | `Area.cpp:661` | +| `DUNGEON_BLOCK` | `TPropertyDungeonBlock{strName, strFileName, strAttributeDataFileName}` (:96) | `CDungeonBlock` | `Area.cpp:695` | + +对象静态数据 `CArea::TObjectData`(`Area.h:27`): +`Position(D3DXVECTOR3) / dwCRC / abyPortalID[PORTAL_ID_MAX_NUM] / +m_fYaw / m_fPitch / m_fRoll / m_fHeightBias / dwRange / fMaxVolumeAreaPercentage`。 +旋转步进常量(`Area.h:13`):`ROTATION_STEP_COUNT = 24`、`ROTATION_STEP_AMOUNT = 15`, +`YAW/PITCH` 同。 + +Ambience 的三种播放型(`Area.cpp:678`): +`"ONCE" → UpdateOnceSound`、`"STEP" → UpdateStepSound`、`"LOOP" → UpdateLoopSound`; +位置 `z = Position.z + m_fHeightBias`,作用半径 `dwRange`。 + +`CArea::Refresh()`(`Area.cpp:383`)把对象分桶到 +`m_TreeCloneInstaceVector / m_ThingCloneInstaceVector / m_AniThingCloneInstanceVector / +m_ShadowThingCloneInstaceVector / m_AmbienceCloneInstanceVector / +m_DungeonBlockCloneInstanceVector`;`IsMotionThing()` 的 building 会额外进动画桶并 +`SetMotion(0)`;`isShadowFlag` 决定是否进阴影桶。 + +**当前**:`POC/extension/src/metin2_world.cpp:367` 只处理 Building / DungeonBlock。 + +**修改** + +1. 补 `PROPERTY_TYPE_EFFECT`:按 `TPropertyEffect.strFileName` 注册 `.mse` 并在 + 对象位置创建常驻特效实例(旋转按 `Yaw/Pitch/Roll` 的 `D3DXMatrixRotationYawPitchRoll`)。 +2. 补 `PROPERTY_TYPE_AMBIENCE`:三种播放型 + `dwRange` + `fMaxVolumeAreaPercentage`, + 接 §9.4 的 ambience 声道。 +3. Building 补 `isShadowFlag` 与 `IsMotionThing() → SetMotion(0)` 两条分桶规则。 +4. `m_fHeightBias` 目前是否参与位置计算需核对;`abyPortalID` / portal / area 关系 + **[待读参考:`REF/GameLib/Area.cpp` 中 `abyPortalID` 的消费点]**。 +5. 对象 LOD 与透明排序按 `Refresh()` 的分桶来做,不要自定义分层。 + +--- + +### 7.2 Pack 资源读取没有统一 + +**参考**:`CEterPackManager`(`REF/EterPack/`)是唯一入口, +连 `CAccountConnector::__AuthState_RecvAuthSuccess`(`AccountConnector.cpp:465`)都要 +`CEterPackManager::instance().DecryptPackIV(dwPanamaKey)`——**pack 的解密 IV 来自登录密钥**。 + +**当前**:`metin2_world.cpp:380`、`terrain_splat.cpp:133` 等仍直接 +`AssetResolver::resolve()`,绕过 `AssetSource`/`PackMount`。 + +**修改** + +1. 全量替换:`grep -rn "AssetResolver::resolve" POC/extension/src` 逐个改为 + `AssetSource::open()`;`asset_io.cpp` 已经把 gr2/dds/map/proto 路由到 `FileAccess`, + 把 world / terrain / tree / water / static object 补齐。 +2. `AssetResolver` 的目录扫描(Phase 2b)改为 `AssetSource` 的统一枚举接口。 +3. 新增 CI 门禁:`MT_ASSETS_PACK_ONLY=1` 时禁用散文件回退,跑一遍现有全部 GDScript 测试。 +4. `DecryptPackIV(loginKey ^ clientKey[0..3])` 这条参考行为若目标服务端使用,需要在 + `ClassicSession` 收到 `GC_AUTH_SUCCESS` 后同样调用——当前未实现,先记录。 + +--- + +### 7.3 地形 splat 仍非完整固定功能复现 + +**参考**:`REF/GameLib/MapOutdoor*.cpp`(`MapOutdoorRenderSTP.cpp` = 单贴图通道、 +`MapOutdoorRenderHTP.cpp` = 高贴图通道、`MapOutdoorIndexBuffer.cpp` = patch 索引、 +`MapOutdoorQuadtree.cpp` = LOD/剔除、`TerrainPatch.{h,cpp}`、`TerrainQuadtree.{h,cpp}`)。 +Section/Cell 常量(`REF/GameLib/MapType.h:216`): +``` +c_Section_xAttributeCellSize = 50 c_Section_yAttributeCellSize = 50 +c_Section_xCellSize = 100 c_Section_yCellSize = 100 +``` + +**修改** + +1. 本条是**渲染端逐项对照**,应在 `PARITY-GAP.md` 建立子清单,每项列 + 「参考文件:函数 / 当前实现 / 差异」,包括: + alpha 语义、texture wrap/filter、stage 数、patch geometry LOD、skirt、 + patch 碰撞、baked shadow mask 叠加。 +2. 在读完 `MapOutdoorRenderSTP.cpp` / `MapOutdoorRenderHTP.cpp` 之前不要调数值—— + 本文档不给具体参数。 +3. 先落地的是**常量对齐**:把上面 4 个 Section/Cell 常量核对进 + `POC/extension/src/terrain_splat.cpp`。 + +--- + +### 7.4 水面是序列纹理加近似深度 + +**参考**:`REF/GameLib/MapOutdoorWater.cpp`。 + +**修改** + +1. 同 §7.3:先读 `MapOutdoorWater.cpp` 建立逐项清单(反射 / 折射 / 岸线泡沫 / 法线 / + blend·filter·wrap / 透明排序 / 水深标定),再改实现。 +2. `PARITY-GAP.md` 里「浅水透明度未校准」的标记保留,直到参考端的水深公式读出为止。 + +--- + +### 7.5 `.msenv` 只完成基础天空、雾和光照 + +**参考**:`TEnvironmentData`(`REF/GameLib/MapType.h:126`)的**完整字段表**: + +``` +// 光照 +BOOL bDirLightsEnable[2]; // ENV_DIRLIGHT_BACKGROUND, ENV_DIRLIGHT_CHARACTER +D3DLIGHT8 DirLights[2]; // 背景光 / 角色光 分开! +D3DMATERIAL8 Material; + +// 雾 +BOOL bFogEnable; BOOL bDensityFog; +float m_fFogNearDistance; float m_fFogFarDistance; +D3DXCOLOR FogColor; + +// 滤色 +BOOL bFilteringEnable; D3DXCOLOR FilteringColor; +BYTE byFilteringAlphaSrc; BYTE byFilteringAlphaDest; + +// 风 +float fWindStrength; float fWindRandom; + +// 天空盒 +D3DXVECTOR3 v3SkyBoxScale; BOOL bSkyBoxTextureRenderMode; +BYTE bySkyBoxGradientLevelUpper; BYTE bySkyBoxGradientLevelLower; +std::string strSkyBoxFaceFileName[6]; // front/back/left/right/top/bottom +std::vector SkyBoxGradientColorVector; + +// 云 +D3DXVECTOR2 v2CloudScale; float fCloudHeight; +D3DXVECTOR2 v2CloudTextureScale; D3DXVECTOR2 v2CloudSpeed; +std::string strCloudTextureFileName; TGradientColor CloudGradientColor; + +// 镜头光晕 +BOOL bLensFlareEnable; D3DXCOLOR LensFlareBrightnessColor; float fLensFlareMaxBrightness; +BOOL bMainFlareEnable; std::string strMainFlareTextureFileName; float fMainFlareSize; +``` + +**关键差异**:参考端**背景光和角色光是两盏独立方向光**(`ENV_DIRLIGHT_BACKGROUND` / +`ENV_DIRLIGHT_CHARACTER`),本工程当前只有一盏。 + +**修改** + +1. `environment_builder.cpp` 解析并落实上表**全部**字段;`TEnvironmentData` 的 + 字段名逐个对应,缺哪个补哪个。 +2. 两盏方向光分别作用于「地形/建筑」与「角色」层——Godot 侧用两个 + `DirectionalLight3D` + `cull_mask` 分层实现。 +3. 云层用 `strCloudTextureFileName` + `v2CloudScale/TextureScale/Speed` + `fCloudHeight` + + `CloudGradientColor` 真实渲染,不再省略。 +4. lens flare 按 `bLensFlareEnable / LensFlareBrightnessColor / fLensFlareMaxBrightness / + bMainFlareEnable / strMainFlareTextureFileName / fMainFlareSize` 实现。 +5. 滤色(`bFilteringEnable` + src/dest alpha blend 因子)是全屏后处理,补上。 +6. 昼夜插值:参考按 `TEnvironmentDataMap`(`MapType.h:187`,`DWORD → TEnvironmentData*`) + 在多个环境之间切换;**[待读参考:`REF/GameLib/MapOutdoor.cpp` 中该 map 的键与插值方式]**。 +7. 环境循环音属 §7.1 的 `PROPERTY_TYPE_AMBIENCE`,不在 `.msenv` 里。 + +--- + +### 7.6 相机不是固定基线机位 + +**参考** + +- 默认机位(`POC/assets/root/game.py:58`,`GameWindow.Open()` 时 + `app.SetCamera(cameraDistance, cameraPitch, cameraRotation, cameraHeight)`): + ```python + cameraDistance = 1550.0 cameraPitch = 27.0 + cameraRotation = 0.0 cameraHeight = 100.0 + ``` + 关窗时 `(cameraDistance, cameraPitch, cameraRotation, cameraHeight) = app.GetCamera()` + 存回全局(`game.py:225`)——即**跨进出图保持**。 +- 距离范围(`REF/UserInterface/CameraProcedure.cpp:10`): + ```cpp + float CCamera::CAMERA_MIN_DISTANCE = 200.0f; + float CCamera::CAMERA_MAX_DISTANCE = 2500.0f; + ``` +- 相机模式(`REF/UserInterface/PythonApplicationCamera.cpp:23/48/57`): + `CAMERA_MODE_NORMAL`(跟随主角 + `GetTargetHeight()`)、 + `CAMERA_MODE_STAND`(固定点)、`CAMERA_MODE_BLEND`(线性插值 zoom/pitch/rotation, + `BlendValueByLinear(elapsed, blendTime, from, to)`)。 + `SetDefaultCamera()`(`:295`)回 NORMAL 并恢复 `m_DefaultCameraSetting`。 +- 地形碰撞(`CameraProcedure.cpp:13 ProcessTerrainCollision`): + 两条射线 `m_kTargetToCameraBottomRay` 与 `m_kCameraBottomToTerrainRay`, + 命中即 `SetCameraState(CAMERA_STATE_CANTGODOWN)` 并把 eye 抬到 + `碰撞点 + 2.0f * m_fTerrainCollisionRadius * m_v3Up`(只在抬高时才 `SetEye`)。 + 未命中则 `SetCameraState(CAMERA_STATE_NORMAL)`。 +- 建筑碰撞(`:125 ProcessBuildingCollision`): + `fMoveAmountSmall = 2.0f` / `fMoveAmountLarge = 4.0f`,用 + `CameraCollisionChecker` + `CollisionDynamicSphere` 逐对象判定。 +- 键位(`POC/assets/root/game.py:340`): + `Q/E → RotateCamera(NEGATIVE/POSITIVE)`、`R → ZoomCamera(NEGATIVE)`、 + `F → ZoomCamera(POSITIVE)`(Ctrl+F 是喂马)、`T/G → PitchCamera(NEGATIVE/POSITIVE)`, + 松键一律 `CAMERA_STOP`;小键盘 4/6/8/2 + PGUP/PGDN 是 Movie 相机。 + +**当前**:`POC/project/game_camera.gd` 用脚本内 FOV/yaw/pitch/距离 + AABB 射线。 + +**修改** + +1. 默认值改为 `distance 1550 / pitch 27 / rotation 0 / height 100`, + 夹在 `[CAMERA_MIN_DISTANCE 200, CAMERA_MAX_DISTANCE 2500]`。 + (§8.4 的「远/近视角」两个 radio 也要落在这个范围内。) +2. 实现三种模式 `NORMAL / STAND / BLEND`,`BLEND` 用线性插值三参数; + `SetDefaultCamera()` 恢复默认设置——这三者是 §5.1 的 + `SET_CAMERA / BLEND_CAMERA / RESTORE_CAMERA` 标签的落点。 +3. 地形碰撞按上面两条射线 + `2 * m_fTerrainCollisionRadius` 的抬高规则重写, + 替换现有 AABB 射线。 +4. 建筑碰撞用 `2.0f / 4.0f` 两个位移量的球体检测。 +5. 相机参数跨进出图保持(`game.py:225` 的全局回写语义)。 +6. 键位按上表补齐 Q/E/R/F/T/G 与松键停止。 + +--- + +## 8. P1/P2:UI、输入和本地化 + +### 8.1 uiscript 可解析不等于控件语义完成 + +**参考**:控件实现在 `REF/EterPythonLib/`(`PythonWindow*.cpp`、`PythonGraphic*.cpp`) +与 `POC/assets/root/ui.py`(本仓已有)。`ui.py` 是权威的控件语义定义。 + +**修改** + +1. 逐个补齐控件,顺序按使用频度: + `ani_image` / `mark` / `grid_table` / `list` / `scroll` / `slotbar` / `thinboard` / + `expanded_image`(nine-patch) / `titlebar` / `radio_button` / `toggle_button`。 +2. 每个控件的属性名、默认值、事件名以 `ui.py` 的类定义为准,不自行命名。 +3. `SetTop / OnTop / focus / modal / layer` 的语义取 `ui.py` 的 + `Window.SetTop / wndMgr` 相关调用。 +4. nine-patch:`ui.py` 的 `ExpandedImageBox` + `.sub` 资源的 rect 定义。 + +--- + +### 8.2 大量窗口是手工 Panel + +**参考**:`Interface.MakeInterface()`(`POC/assets/root/interfacemodule.py`)逐个 +`LoadDialog()`,每个窗口用 `ui.PythonScriptLoader().LoadScriptFile(self, "UIScript/xxx.py")`。 + +**修改** + +1. 沿用本工程已验证的迁移样板(`char_status_ui.gd` / `select_item_ui.gd` / + `system_option_ui.gd` / `private_shop_ui.gd` / `party_ui.gd` / `system_menu_ui.gd` / + `game_option_ui.gd`):`UiScript` + `UiBuild` 装真 uiscript + 逐字绑定同名 `ui*.py`。 +2. 待迁清单(按 `interfacemodule.py` 的 `MakeInterface` 顺序): + `exchangedialog.py`(§4.5)、`safeboxwindow.py`(§4.4)、`shopdialog.py`(§4.6)、 + `guilddialog.py`、`questdialog.py`(§5.2)、`messengerwindow.py`、`chatwindow.py`、 + `helpwindow.py`(§8.3)、`inventorywindow.py`、`equipmentdialog.py`、 + `tooltip`(§4.3,非 uiscript)。 +3. 每迁一个窗口配一个 `*_ui_test.gd`,覆盖「装载 + 关键绑定 + 原聊天命令/封包」。 +4. 分辨率缩放与 `.sub` 资源、字体描边按 `ui.py` 的实现,不自定义。 + +--- + +### 8.3 帮助窗口未实现 + +**参考**:`POC/assets/root/uihelp.py`(136 行)+ uiscript `helpwindow.py`。 +入口:`game.py:459 __PressHKey()` —— 非 Ctrl 时 `self.interface.OpenHelpWindow()` +(Ctrl+H 是 `/user_horse_ride`)。 + +**当前**:`POC/project/ui/system_menu_ui.gd:91` 只显示占位提示。 + +**修改** + +1. 新建 `POC/project/ui/help_ui.gd`,按样板装 `helpwindow.py` uiscript, + 内容绑定逐字对齐 `uihelp.py`。 +2. 挂 `H` 键(非 Ctrl)与 ESC 菜单的 `help_button`,替换 `system_menu_ui.gd:91` 的占位。 +3. 配 `help_ui_test.gd`。 + +--- + +### 8.4 系统设置部分只是保存数值 + +**参考**:`POC/assets/root/uisystemoption.py`(本工程增量 36 已按其绑定)。 +其中 CPU/GPU tiling 对应 D3D 的分块渲染开关,Godot 无对应物。 + +**修改** + +1. **CPU/GPU tiling**:参考行为是切换渲染路径,Godot 渲染器没有等价物。 + 保留控件但明确标注为「参考功能无 Godot 对应」,并在 `CLIENT-GAP.md` 记为 + ❌(超出移植范围),不要伪造行为。 +2. **BGM 更换按钮**:参考对应 `uiselectmusic.py`(本仓已有)—— + 按 §8.2 迁移该窗口后启用按钮,接 `musicInfo.SaveLastPlayFieldMusic` / + `LoadLastPlayFieldMusic`(`POC/assets/root/musicinfo.py`)与 + `game.py:2217 __PlayMusic(flag, filename)`。 +3. **名字颜色 / 伤害数字 / 总是显示名字**:接到 §8.8 的 TextTail 渲染, + 开关键名沿用现有 `user://system_option.cfg [gameopt]`。 +4. **雾等级**:现有 `[0.055/0.018/0.004]` 是本工程标定值,参考端是 + `TEnvironmentData` 的 `m_fFogNearDistance/m_fFogFarDistance/bDensityFog`(§7.5)—— + 改为按环境数据的近远距离设置,浓/中/淡对应三档缩放 + **[待读参考:`uisystemoption.py` 的 `__SetFogLevel` 实参]**。 + +--- + +### 8.5 鼠标物品态和光标状态缺失 + +**参考**:光标枚举 `CPythonApplication::ECursorShapes` +(`REF/UserInterface/PythonApplication.h:63`): +``` +NORMAL, ATTACK, TARGET, TALK, CANT_GO, PICK, DOOR, CHAIR, MAGIC, BUY, SELL, +CAMERA_ROTATE, HSIZE, VSIZE, HVSIZE, COUNT +``` +资源(`REF/UserInterface/Cursors/`,Python 侧映射见 `mousemodule.py:100`): +``` +NORMAL→cursor.sub ATTACK/TARGET→cursor_attack.sub TALK→cursor_talk.sub +CANT_GO→cursor_no.sub PICK→cursor_pick.sub DOOR→cursor_door.sub +CHAIR/MAGIC→cursor_chair.sub BUY→cursor_buy.sub SELL→cursor_sell.sub +CAMERA_ROTATE→cursor_camera_rotate.sub +HSIZE→cursor_hsize.sub VSIZE→cursor_vsize.sub HVSIZE→cursor_hvsize.sub +``` +软件光标开关:`systemSetting.IsSoftwareCursor()`(`mousemodule.py:98`)。 +`SetCursorNum` / `SetCursor` 的调用点:商店 BUY/SELL(§4.6)、 +NPC 悬停 TALK、可攻击目标 ATTACK 等。 + +**修改** + +1. 新建 `POC/project/ui/cursor_manager.gd`:`set_cursor(shape)` / `get_cursor()`, + 枚举与资源映射按上表;资源用 `.sub`(`UiAssets`)。 +2. 鼠标跟随物品图标由 §4.2 的 `mouse_controller` 负责,与光标是两层。 +3. 悬停实体时的光标:可攻击 → `ATTACK`;NPC → `TALK`;地面物品 → `PICK`; + 不可达 → `CANT_GO`——判据用 §5.4 的 `is_attackable_instance` 与实体类型。 +4. 窗口焦点屏蔽:窗口下的鼠标事件不穿透到世界(`wndMgr` 的语义)。 + +--- + +### 8.6 键位配置不完整 + +**参考补充**:`ClientVS22/source/` 不含完整 Python 键位脚本;下表来自本仓迁移资产 +`POC/assets/root/game.py:309-408`,只能作为历史脚本行为依据,仍需用当前目标构建和实际 +输入验收确认。逐条: + +``` +数字 1..9 → __PressNumKey(n) + Ctrl+1..9 → chrmgr.SetEmoticon(-1, n-1) + net.SendEmoticon(n-1) + 否则 1..4 → pressNumber(n-1) (§4.8) +F1..F4 → __PressQuickSlot(4..7) +LALT 按/放 → ShowName() / HideName() +LCONTROL → ShowMouseImage() / HideMouseImage() +SYSRQ → SaveScreen() +SPACE 按/放 → StartAttack() / EndAttack() +方向键/WASD → MoveUp/Down/Left/Right,松键 StopUp/Down/Left/Right + D 键:Ctrl+D → 龙魂 deck 1;否则 MoveRight +Q / E → RotateCamera(NEGATIVE / POSITIVE),松键 CAMERA_STOP + Ctrl+Q → 切换所有 QuestButton 显示 +R → ZoomCamera(NEGATIVE),松键 STOP +F → ZoomCamera(POSITIVE);Ctrl+F → "/user_horse_feed" +T → PitchCamera(NEGATIVE),松键 STOP +G → 若 ShowNameFlag 则 ToggleGuildWindow(),否则 PitchCamera(POSITIVE) + Ctrl+G → "/ride";松键 CAMERA_STOP +NUMPAD 9/4/6/8/2, PGUP, PGDN → Movie 相机 +GRAVE / Z → PickUpItem() +C → Ctrl+C → 龙魂 deck 0;否则 ToggleCharacterWindow("STATUS") +V → ToggleCharacterWindow("SKILL") +N → ToggleCharacterWindow("QUEST") +B → Ctrl+B → "/user_horse_back";否则 ToggleCharacterWindow("EMOTICON") +H → Ctrl+H → "/user_horse_ride";否则 OpenHelpWindow() (§8.3) +J → Ctrl+J → 骑乘中 "/unmount",否则在背包里找 71114/71116/71118/71120 使用 +I → ToggleInventoryWindow() +O → ToggleDragonSoulWindowWithNoInfo() +M → interface.PressMKey() +ADD/SUBTRACT→ MiniMapScaleUp / MiniMapScaleDown +L → ToggleChatLogWindow() +COMMA → ShowConsole() +LSHIFT 按/放→ __SetQuickPageMode() / __SetQuickSlotMode() (§4.8) +``` + +**当前**:只有 WASD、点地、相机拖动、滚轮、触屏、F1–F4、数字键 1–9。macOS 上物理 F1–F12 +是否直接产生 `KEY_F1`–`KEY_F12` 还受系统“将 F1、F2 等键用作标准功能键”设置或 Fn 键影响, +这是平台验收条件,不应写成 ClientVS22 的额外键位功能。 + +**修改** + +1. `game_scene.gd` 的输入表按上面**逐行**补齐,包括每个 Ctrl 组合分支。 +2. `SaveScreen()`(SYSRQ)实现正式截图,替代现有的调试截图。 +3. `StartAttack/EndAttack`(空格)接 §3.4 的 `m_isAtkKey` 连续攻击。 +4. `ShowName / HideName`(LALT)接 §8.8 的 TextTail 显示开关。 +5. 键位重绑定 / 冲突检测:参考端**没有**——不要实现,若需要另立需求。 +6. 相机灵敏度持久化:参考端也没有独立设置(相机参数持久化见 §7.6 第 5 点)。 + +--- + +### 8.7 CJK 输入不等价 + +**参考**:`REF/UserInterface/PythonIME.{h,cpp}`,接口: +``` +Create(HWND) / MoveLeft / MoveRight / MoveHome / MoveEnd / SetCursorPosition / Delete +OnTab / OnReturn / OnEscape / OnWM_CHAR(wParam, lParam) / OnUpdate / OnChangeCodePage +OnOpenCandidateList / OnCloseCandidateList / OnOpenReadingWnd / OnCloseReadingWnd +``` +即:候选窗(candidate list)、读音窗(reading window)、代码页切换(`OnChangeCodePage`, +与 §1.10 的 `LocaleService_GetCodePage()` 联动)都由客户端自绘。 + +**修改** + +**定位**:macOS / Godot **可以用原生 IME 取代 Windows IMM32 实现**(不必自绘候选窗), +但**必须保持参考端的输入行为**——不能因为换了实现就默认等价。以下逐项验证后才算完成: + +1. **组合文本(composition)**:未确认的组合串要显示在输入位置,且**不计入**已提交文本; + 对应 `OnWM_CHAR` / `OnUpdate`。 +2. **候选词与候选窗**:候选列表可见、可翻页、可用数字键选中;候选窗位置跟随光标, + 不被游戏窗口遮挡;对应 `OnOpenCandidateList` / `OnCloseCandidateList` / + `OnOpenReadingWnd` / `OnCloseReadingWnd`。 +3. **焦点切换**:焦点离开输入框时组合串的处置(提交还是丢弃)要与参考一致; + 窗口失焦 / 切窗时不残留候选窗。 +4. **光标与编辑**:`MoveLeft / MoveRight / MoveHome / MoveEnd / SetCursorPosition / Delete` + 六个操作在含多字节字符时按**字符**移动,不能按字节。 +5. **长度限制**:见 §1.10——上限是**目标字段的字节数**(角色名 24、聊天 128、 + 公会名 12 …),且不得在多字节字符中间截断;组合中的未确认串也要参与上限计算。 +6. **代码页**:`OnChangeCodePage` 对应 `LocaleService_GetCodePage()`; + locale 切换后输入与显示都要跟着换(§1.10)。 +7. **`OnTab / OnReturn / OnEscape`**:聊天框 Tab 切换频道、Return 发送、Escape 关闭—— + 按 `uichat.py` 的绑定;这三个键在**候选窗打开时**必须优先给 IME,不能被窗口吃掉。 + +上述任一项不满足,本条不得标 ✅;`CLIENT-GAP.md` 里也不应写成「平台差异,等价即可」。 + +--- + +### 8.8 聊天和头顶信息不完整 + +**参考** + +- 聊天:`REF/UserInterface/PythonChat.{h,cpp}` + `POC/assets/root/uichat.py`; + `CHAT_MAX_NUM = 128`(`Packet.h:383`)。 + `InsultChecker`(`REF/UserInterface/InsultChecker.cpp`)做脏词过滤。 +- 头顶信息:`REF/UserInterface/PythonTextTail.{h,cpp}`——名字 / 称号 / 公会名 / + 伤害数字 / 聊天气泡都在这里。 +- 名字颜色:`ENameColor`(`REF/UserInterface/InstanceBase.h:227`,完整枚举见 §5.4)—— + 按 `MOB / NPC / PC+empire / NORMAL_* / EMPIRE_* / FUNC / PK / PVP / PARTY / WARP / + WAYPOINT` 分类,`EMPIRE_NUM` 决定每类里帝国的偏移。 +- 帝国常量:`EMPIRE_NUM` **[待读参考:`REF/UserInterface/GameType.h` 或 `Packet.h`]**。 + +**修改** + +1. 按 §8.2 迁移 `uichat.py`(notice / big notice / 频道过滤 / 历史滚动 / 私聊窗生命周期)。 +2. 新建 `text_tail.gd` 移植 `PythonTextTail`:名字 / 称号 / 公会名 / 伤害数字 / + 聊天气泡 / 屏幕空间血条,显示开关接 §8.6 的 LALT 与 §8.4 的三个 radio。 +3. 名字颜色按 `ENameColor` 的完整枚举实现,输入是 §2.5 补齐的 + `ch_type / empire / guild / alignment / pk_mode` 与 §5.4 的可攻击判定。 +4. `InsultChecker` 若目标服务端不需要,可标 ❌,但要在 `CLIENT-GAP.md` 明示。 + +--- + +### 8.9 任务日志图标仍是占位 + +见 §5.2,同一条修改。`POC/project/ui/quest_log.gd:8` 的文字列表在 +`EVENT_TYPE_QUEST_BUTTON` 补齐 `icon_type` / `icon_name` 后即可接真图标。 + +--- + +## 9. P1/P2:音频和应用生命周期 + +### 9.1 地图 BGM 字段被解析但没有消费 + +**参考** + +- 解析与存储(`REF/UserInterface/PythonNetworkStreamPhaseLoading.cpp`): + ``` + RecvMainCharacter3_BGM() :244 → __SetFieldMusicFileName(mainChrPacket.szBGMName) // :261 + RecvMainCharacter4_BGM_VOL() :271 → __SetFieldMusicFileInfo(szBGMName, fBGMVol) // :288 + ``` + ```cpp + // PythonNetworkStreamPhaseLoading.cpp:299 + static std::string gs_fieldMusic_fileName; + static float gs_fieldMusic_volume = 1.0f / 5.0f * 0.1f; // = 0.02f 默认值 + void __SetFieldMusicFileName(const char* n) { gs_fieldMusic_fileName = n; } + void __SetFieldMusicFileInfo(const char* n, float vol) { gs_fieldMusic_fileName = n; gs_fieldMusic_volume = vol; } + const char* GetFieldMusicFileName() { return gs_fieldMusic_fileName.c_str(); } + float GetFieldMusicVolume() { return gs_fieldMusic_volume; } + ``` +- 消费(`POC/assets/root/game.py:190`,`GameWindow.Open()` 内): + ```python + snd.SetMusicVolume(systemSetting.GetMusicVolume()) + snd.SetSoundVolume(systemSetting.GetSoundVolume()) + netFieldMusicFileName = net.GetFieldMusicFileName() + if netFieldMusicFileName: snd.FadeInMusic("BGM/" + netFieldMusicFileName) + elif musicInfo.fieldMusic != "": snd.FadeInMusic("BGM/" + musicInfo.fieldMusic) + ``` + 关窗(`game.py:225`):`if musicInfo.fieldMusic != "": snd.FadeOutMusic("BGM/" + musicInfo.fieldMusic)`。 +- 默认曲目(`POC/assets/root/musicinfo.py`): + ```python + METIN2THEMA = "M2BG.mp3" + loginMusic = "login_window.mp3" + createMusic = "characterselect.mp3" + selectMusic = "characterselect.mp3" + fieldMusic = METIN2THEMA + SaveLastPlayFieldMusic() / LoadLastPlayFieldMusic() → 文件 "BGM/lastplay.inf" + ``` + +**当前**:`POC/extension/src/net/classic/classic_parser.cpp:173` 丢弃 BGM 名与音量。 + +**修改** + +1. `ClassicParser` 保存 `field_music_name` / `field_music_volume`,默认音量 + `1.0f/5.0f*0.1f = 0.02f`;`M2Client` 暴露 `get_field_music_name()` / `get_field_music_volume()`。 +2. `game_scene.gd` 进图时按 `game.py:190` 的三行逻辑选 BGM(服务端名优先, + 否则 `musicInfo.fieldMusic`),并先 `SetMusicVolume/SetSoundVolume`。 +3. `musicinfo.gd` 落 `METIN2THEMA / loginMusic / createMusic / selectMusic / fieldMusic` + 五个常量与 `save_last_play_field_music / load_last_play_field_music` + (文件路径 `user://BGM/lastplay.inf`)。 +4. 换图(§1.3)走同一条选曲逻辑。 + +--- + +### 9.2 后台恢复后可能没有 BGM + +**参考**:参考端是 Windows 桌面客户端,没有 Android/iOS 的前后台切换语义—— +本条**没有 1:1 参考**。最接近的参考行为是 +`CSoundManager::SaveVolume() / RestoreVolume()`(`REF/MilesLib/SoundManager.h:31`), +它保存/恢复 `m_fBackupMusicVolume` / `m_fBackupSoundVolume`。 + +**修改**(按参考的 Save/Restore 语义,而不是 stop/replay) + +1. `POC/project/app_lifecycle.gd:67` 的 `_enter_background()` 把 + `_bound_audio.stop_bgm()` 改为 `_bound_audio.save_volume()` + 静音 + (等价 `SaveVolume()` 后 `SetMusicVolume(0)`)。 +2. `_exit_background()`(`app_lifecycle.gd:75`)加 `_bound_audio.restore_volume()`。 +3. `Audio` 增加 `save_volume() / restore_volume()`,字段名对齐 + `m_fBackupMusicVolume / m_fBackupSoundVolume`。 +4. 这样 `_bgm_name` 不再被清空,曲目继续播放,恢复后音量还原。 + +--- + +### 9.3 生命周期所有权重复 + +**参考**:唯一协调者是 `CPythonApplication` +(`REF/UserInterface/PythonApplication.cpp` + `PythonApplicationEvent.cpp`), +`CPythonNetworkStream` 只是被它驱动的一个子系统。 + +**修改** + +1. 确立 `AppFlow` 为唯一生命周期协调者(等价 `CPythonApplication`): + `AppLifecycle` 由 `AppFlow` 创建并持有,`GameScene` 不再自建。 +2. `M2Client` 删除自己的 `NOTIFICATION_*` 处理,只保留被调用的 + `suspend() / resume()`。 +3. 暂停 / 恢复 / 内存警告 / 关闭四条路径各只有一个入口,写进 + `POC/docs/CLIENT-PORT.md` 的生命周期小节。 + +--- + +### 9.4 MilesLib 事件模型没有迁移 + +**参考**:`CSoundManager`(`REF/MilesLib/SoundManager.h`)的完整接口: + +``` +// 3D 监听者 +SetPosition(x,y,z) SetDirection(xDir,yDir,zDir, xUp,yUp,zUp) Update() + +// 缩放与音量 +float m_fSoundScale = 200.0f; // SoundManager.cpp:20 +GetSoundScale / SetSoundScale / SetAmbienceSoundScale +SetSoundVolume / SetSoundVolumeRatio / SetSoundVolumeGrade(int) +SetMusicVolume / SetMusicVolumeRatio / SetMusicVolumeGrade(int) +SaveVolume / RestoreVolume / GetSoundVolume / GetMusicVolume + +// 音量换算(SoundManager.cpp:245/250) +__ConvertGradeVolumeToApplyVolume(grade) = __ConvertRatioVolumeToApplyVolume(grade / 5.0f) +__ConvertRatioVolumeToApplyVolume(r) = (r < 0.1f) ? r : pow(10.0f, -1.0f + r) + +// 播放 +PlaySound2D(file) +PlaySound3D(x,y,z,file, playCount=1) StopSound3D(index) +PlayAmbienceSound3D(x,y,z,file, playCount=1) +PlayCharacterSound3D(x,y,z,file, bCheckFrequency=FALSE) +SetSoundVolume3D(index, volume) StopAllSound3D() + +// 音乐(MUSIC_INSTANCE_MAX_NUM 个通道) +PlayMusic(file) +FadeInMusic(file, volumeSpeed = 0.016f) +FadeOutMusic(file, volumeSpeed = 0.016f) +FadeLimitOutMusic(file, limitVolume, volumeSpeed = 0.016f) +FadeOutAllMusic() FadeAll() +EMusicState { OFF, PLAY, FADE_IN, FADE_OUT, FADE_LIMIT_OUT } + +// 动作声音节点(.msa 的 sound 事件) +UpdateSoundData(curFrame, soundDataVector) +UpdateSoundData(x,y,z, curFrame, soundDataVector) +UpdateSoundInstance(x,y,z, curFrame, soundInstanceVector, bCheckFrequency=FALSE) +UpdateSoundInstance(curFrame, soundInstanceVector) +``` + +3D 定位(`SoundManager.cpp:349` / `:415`): +```cpp +pInstance->SetPosition((fx - m_fxPosition) / m_fSoundScale, + (fy - m_fyPosition) / m_fSoundScale, + (fz - m_fzPosition) / m_fSoundScale); +``` +即**世界坐标差 ÷ 200** 才是音频引擎坐标。 + +角色声音去重(`PlayCharacterSound3D`,`SoundManager.cpp:380`,仅当 `bCheckFrequency`): +``` +static float s_fLimitDistance = 5000*5000; // 距离平方上限 +dx² + dy² > s_fLimitDistance → 不播 +同一文件距上次播放 < 0.3f 秒 → 不播 +否则记录 m_PlaySoundHistoryMap[file] = now +``` + +**当前**:只有 BGM crossfade、UI 音效池和通用 3D 音效。 + +**修改** + +1. `POC/project/audio.gd` 按上表补齐 API 与常量: + `SOUND_SCALE = 200.0`、`FADE_SPEED = 0.016`、`CHAR_SOUND_MIN_INTERVAL = 0.3`、 + `CHAR_SOUND_LIMIT_DIST_SQ = 5000*5000`。 +2. 3D 音效位置一律除以 `SOUND_SCALE`。 +3. 音量换算函数按 `__ConvertRatioVolumeToApplyVolume` 的分段公式实现 + (`r < 0.1` 线性,否则 `pow(10, r - 1)`),`grade` 除以 5 转 ratio; + `system_option_ui.gd` 的音量滑条改走这个换算(当前是线性)。 +4. 三类声道分开:普通 3D(`PlaySound3D`)、环境(`PlayAmbienceSound3D`, + 有独立的 `m_fAmbienceSoundScale`)、角色(`PlayCharacterSound3D` + 去重)。 +5. `.msa` 的 sound 事件走 `UpdateSoundInstance(x,y,z,frame,vector,bCheckFrequency=TRUE)` + (角色动作声音一律开去重)。 +6. 音乐通道支持 `FADE_LIMIT_OUT`(降到某音量而不是 0)与 `FadeOutAllMusic`。 +7. `.mss` 声音脚本:`CEffectData::LoadSoundScriptData` 与 + `CRaceMotionData::GetSoundScriptFileName()`(`REF/GameLib/RaceMotionData.h:220`) + **[待读参考:`REF/MilesLib/SoundData.cpp` 的 `.mss` 解析]**。 + +--- + +## 10. 发布和验证问题 + +> 本节没有 `ClientVS22` 参考(它是 Windows / VS2022 工程),属于本工程的工程化要求。 +> 因此这里只写**可执行的动作**,不假装有 1:1 依据。 + +### 10.1 Mac App 不是自包含资源包 + +**修改** + +1. `build-macos-client.sh` 里把 `assets` / `bgm` 的**符号链接**改为 + `ditto` 复制进 `Contents/Resources/`(或打成 `.epk/.eix` 后复制)。 +2. `AssetRoot.path()` 在 macOS 上优先解析 `Contents/Resources/assets`, + `MT_ASSETS` 仍可覆盖(开发用)。 +3. 验收:把 `.app` 拷到另一台机器(或 `mv` 掉工作区)后能启动进图。 + +### 10.2 主程序和 GDExtension 架构不一致 + +**修改** + +1. `build.sh` / `CMakeLists.txt` 为 macOS 增加 `x86_64` 目标, + 用 `lipo -create` 合成 universal `.dylib`。 +2. `*.gdextension` 的 `macos.debug/release` 指向该 universal 库。 +3. 验收:`lipo -info` 显示 `x86_64 arm64`;Godot 主程序也是 universal。 + +### 10.3 当前是 Ad-hoc 签名 + +**修改** + +1. `codesign --force --options runtime --sign "Developer ID Application: …"` + 对 `.app` 内所有 `.dylib` 与主可执行文件逐个签名(先内后外)。 +2. `xcrun notarytool submit --wait` 提交公证,`xcrun stapler staple` 装订。 +3. 验收:`spctl -a -vvv -t install ` 通过。 + +### 10.4 发布构建仍包含 Demo 默认账号密码 + +**当前**:`POC/project/app_flow.gd:32-33` +```gdscript +var _id := "admin" +var _pw := "123456789" +``` + +**修改** + +1. 两行改为空串。 +2. 自动登录只在 `MT_AUTOLOGIN=1` 且 `MT_ACCOUNT` / `MT_PASSWORD` 环境变量存在时生效 + (`app_flow.gd:37` 的 `auto_login` 保留,凭据不再硬编码)。 +3. 加一条 CI 检查:release 预设的 PCK 里 `grep -c "123456789"` 必须为 0。 + +### 10.5 补丁器 / Logo / Movie / Web / Discord / 反作弊没有发布决策 + +**参考侧的事实**(用于做决策,不是要求实现): +- `REF/UserInterface/PythonApplicationLogo.cpp`、`MovieMan.cpp`、 + `PythonApplicationWebPage.cpp` + `REF/CWebBrowser/`、`REF/Discord/` + (`ENABLE_DISCORD_RPC` 在 `REF/UserInterface/Locale_inc.h:6` 是**注释掉的**)、 + `CheckLatestFiles.cpp`(补丁器)。 +- 反作弊四件套见 §1.6,已在不做清单。 + +**修改** + +1. 在 `CLIENT-GAP.md` 的不做清单里逐项定性: + Logo / Movie / Web / Discord(参考默认关闭)→ ❌; + 补丁器(`CheckLatestFiles`)→ 单独立项或 ❌。 +2. 决策写进 `CLIENT-ROADMAP.md`,避免每轮审计重复出现。 + +### 10.6 缺少真实验收门禁 + +**修改**:建立 `POC/docs/ACCEPTANCE.md`,每项写「前置条件 / 步骤 / 通过判据」: + +1. 40250 真服矩阵:登录 → 选人 → DirectEnter → 进图 → 换图(§2.7)→ 登出。 +2. 双账号:交易(§4.5)、组队(§4.9)、PVP(§5.4)。 +3. 断线重连:拔网线 → 回登录页(§1.2)。 +4. 纯 pack 运行:`MT_ASSETS_PACK_ONLY=1`(§7.2)。 +5. Intel Mac(§10.2)。 +6. 受控机位截图:地图 / 机位 / 角色 / 时间 / 天气 / 分辨率契约 + (见 `PARITY-GAP.md §0`,该门禁已存在,保持)。 +7. 移动端真机性能。 + +--- + +## 附录 A:本次审计对 `CLIENT-GAP.md` 的订正 + +对照参考源码后,`CLIENT-GAP.md`(2026-09-02 审计节)有以下条目需要订正或加限定。 +**订正分三档**:❶ 参考端行为与原文不符 · ❷ 原文方向对但表述需加限定 · +❸ 原文低估了差距(当前实现另有问题)。 + +| 原条目 | 档 | 订正后的说法 | 依据 | +|---|---|---|---| +| 协议覆盖审计 | ❶+❸ | `Packet.h` 是按 To Server / From Server 分段的全局 enum,不是 `namespace CG/GC`。主文档此前把 m2dev `wire.h` 与默认 Classic 混算,并把历史统计写成当前结论;应按 `MT_PROTOCOL=classic` 与 `MT_PROTOCOL=m2dev` 分开。当前默认 Classic 的参考编号是 `CG_CHARACTER_CREATE=4`、`CG_MYSHOP=55`、`CG_WARP=65`、`CG_FISHING=82`、`CG_DUNGEON=110`,而不是 `0x0201`、`0x0802`、`0x0B01` 等 m2dev 编号。 | `REF/UserInterface/Packet.h:25`、`POC/extension/src/net/classic/wire_classic.h:104`、`POC/project/client_main.gd:11` | +| `CG_TEXT` | ❶ | `CG_TEXT=64 (0x40)`,不是 `0x0011`。它存在于 40250 Server / Classic wire,但不在 ClientVS22 客户端普通 To Server 命名项中,不应计入 ClientVS22 客户端 header 总数。 | `40250/Server/.../packet.h:49`、`REF/UserInterface/Packet.h:88-93` | +| 拍卖行 | ❶ | 默认构建未见完整 UI / handler,但 `__AUCTION__` 条件下仍有 `CG_AUCTION_CMD=205` 和 GC 列表包 `=206`;当前 40250 默认服务配置关闭该宏,因此当前不做。不能写成全源码 / 协议 / 资产零命中。 | `REF/UserInterface/Packet.h:158-160,354-356`、`40250/Server/.../service.h:8` | +| `CG_HACK` | ❶ | 参考编号是 `105 (0x69)`,当前 Classic 也使用该值;不做决定可以保留,但编号必须更正。 | `REF/UserInterface/Packet.h:134-136`、`POC/extension/src/net/classic/wire_classic.h:162` | +| 物品 header 变体 | ❷ | `GC_ITEM_DEL/SET` 的 20/21 映射受 `GAIDEN` 条件编译影响;当前 40250 Server 使用 `DEL=20、SET=21`,文档应明确目标构建变体,不能概括为无条件的 ClientVS22 语义。 | `REF/UserInterface/Packet.h:191-197`、`40250/Server/.../packet.h:150-153` | +| `GC_ITEM_DROP` | ❷ | ClientVS22 保留 `GC_ITEM_DROP=23` 声明但没有注册 / 消费,实际掉落流程使用 `GC_ITEM_GROUND_ADD=26`。应标记为“保留声明 / 未消费”,不能宣称两个包逐字节等价。 | `REF/UserInterface/Packet.h:199-202`、`REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:332` | +| §1.2 | ❷ | 参考端确实**没有**重连遮罩 / 倒计时,`OnRemoteDisconnect()` 只调 `SetLoginPhase()`。但「删掉 `reconnect_ui.gd`」只是**严格 1:1 时的选择,不是必然修复**;原先的断线信号链路缺陷已在本轮第一批修复:已有游戏连接的 Classic 断开现在发 `disconnected`,`AppFlow` 无条件回 LOGIN;真实 TCP 断线仍待验收。 | `PythonNetworkStreamEvent.cpp:4` | +| §2.7 | ❷ | `RecvWarpPacket()` 确实**无条件**按 `lAddr / wPort` 重连,`same_server()` 不是参考端逻辑。但应表述为「**`RecvWarpPacket()` 内不直接使用 `lX/lY`**」——这两个坐标在重连后的 `GC_MAIN_CHARACTER*` 阶段仍参与角色加载定位,不是「完全没用」。 | `PythonNetworkStreamPhaseGame.cpp:923` | +| §3.10 | ❶+❸ | 本次 `ClientVS22/source/` 不含 Python UI 脚本;本仓迁移资产中的 `uicharacter.py` 仍显示参考脚本通过 `net.SendChatPacket("/skillup " + str(skillIndex))` 发包,因此“应改成非命令路径”不成立,也不能仅据此断定当前应传 skill vnum。当前明确差距是 C++ 技能合法性 / 等级上限逻辑和 UI 加号按钮门控、失败提示、升级后状态刷新不完整。 | `POC/assets/root/uicharacter.py:1022`、`REF/UserInterface/PythonSkill.cpp:1833` | +| §4.8 | ❶+❸ | 参考端可执行的快捷栏类型只有 `INVENTORY / SKILL / EMOTION`,没有「命令类型」——这一半正确。但原文**漏了当前实现的新问题**:`m2_client.cpp:878` 把 `3` 注释/使用为 `command`、`4` 为 `emotion`,而参考的 `TQuickSlot.Type` 直接就是 `ESlotType`(`SLOT_TYPE_EMOTION == 3`,`4` 是 `SLOT_TYPE_SHOP`)。**当前类型编号与参考端不兼容**;同时,键位表来源于本仓迁移资产,不能伪称为 ClientVS22/source 中的 Python 文件。 | `REF/UserInterface/PythonPlayer.cpp:813-841`、`REF/UserInterface/GameType.h:140,307`、`POC/assets/root/game.py:309` | +| §8.7 | ❷ | 参考端**确有** `CPythonIME` 的候选窗与读音窗,不是完全依赖系统输入框,所以不能一句「平台差异」了事。正确说法是:**macOS/Godot 可以用原生 IME 取代 Windows IMM32 实现,但必须保持参考端的输入行为**——候选词、组合文本(composition)、焦点切换、候选窗位置、长度限制都要逐项验证。字节编码与长度(§1.10)只是其中一部分。 | `PythonIME.h:6` | +| §8.6 | ❷ | 参考键位表来自本仓迁移资产而非 `ClientVS22/source/` 的 Python 文件;主文档已改为明确这一证据边界。macOS 物理 F1–F12 是否直接产生功能键事件受系统功能键设置 / Fn 键影响,应作为平台验收条件,不应虚构成参考端额外键位。 | `POC/assets/root/game.py:309-408`、Godot 当前 `project/game_scene.gd:558` | +| §1.5 | ✅ | **属实,保留**。参考这份 `ClientVS22`(`Locale_inc.h` = `LOCALE_SERVICE_SINGAPORE` → `LSS_EUROPE`)发 `HEADER_CG_CLIENT_VERSION2 = 0xf1` + `"1215955205"`;当前实现发 `0xfd` 却用了 0xf1 分支的 timestamp。另外当前 `filename` 固定 `"metin2.bin"`,参考端用**实际可执行文件名**(`GetExcutedFileName` + `NoPath`)。 | `PythonNetworkStreamPhaseGame.cpp:4155`、`Locale.cpp:186` | + +**§1.7 / §1.8 的订正属实并保留**,但补一条落地限定: +参考端对未知 header 是 `ClearRecvBuffer()` + `PostQuitMessage(0)`,对阶段内未处理 header 是 +`TraceError` + `ClearRecvBuffer()`。移植到 Godot 时**不必字面调用 `PostQuitMessage(0)`**—— +应映射为**明确的协议错误 + 断线处理**(走 §1.2 的 `disconnected` → 回登录页), +关键是保住「不做启发式重同步、不静默吞包」这两条语义。 + +## 附录 B:本文标注 `[待读参考]` 的条目 + +以下细节本次没有读到参考源码,实现前必须先读,**不得按经验补写**: + +| 条目 | 应读文件 | +|---|---| +| §2.5 affect 位号 | `REF/UserInterface/InstanceBase.h` 的 `enum EAffect` | +| §3.1 `MOVABLE_GROUND_DISTANCE` 默认值 | `POC/assets/root/game.py` / `playersettingmodule.py` | +| §3.3 攻速对动作速率的影响、`__GetBowRange` | `REF/UserInterface/InstanceBase.cpp` / `InstanceBaseBattle.cpp` | +| §3.6 `OnGetFlyTargetPosition` | `REF/GameLib/ActorInstanceFly.cpp` | +| §3.8 `TSkillData` 字段与加载 | `REF/UserInterface/PythonSkill.{h,cpp}` | +| §3.10 `CanLevelUpSkill` / `GetSkillLevelLimit` | `REF/UserInterface/PythonSkill.cpp` | +| §4.1 换装遮挡规则 | `REF/UserInterface/InstanceBase.cpp` 的 `ChangeArmor/SetArmor` | +| §4.3 装备对比 | `POC/assets/root/uitooltip.py` | +| §4.4 仓库金钱包/命令 | `REF/UserInterface/PythonNetworkStreamModule.cpp` | +| §4.9 `affects[7]` 槽位语义 | 40250 **服务端** `game/src/party.cpp` | +| §5.4 `ATTRIBUTE_BANPK` 等地形属性 | `REF/GameLib/TerrainImpl`(`MapType.h` / `TerrainPatch.h`) | +| §6.3 `.mde` 解析 | `REF/EffectLib/EffectMesh.{h,cpp}` | +| §6.3 骨骼挂特效 | `REF/GameLib/ActorInstanceAttach.cpp` | +| §6.5 `ChangeWeapon` 的 detach | `REF/UserInterface/InstanceBase.cpp` | +| §6.6 材质 stage / alpha-test ref | `REF/EterGrnLib/Thing.cpp`、`Material.cpp` | +| §7.1 `abyPortalID` 消费点、`m_fHeightBias` | `REF/GameLib/Area.cpp` | +| §7.3 地形渲染逐项 | `REF/GameLib/MapOutdoorRenderSTP.cpp` / `RenderHTP.cpp` / `TerrainPatch.cpp` | +| §7.4 水面逐项 | `REF/GameLib/MapOutdoorWater.cpp` | +| §7.5 昼夜插值 / `TEnvironmentDataMap` 键 | `REF/GameLib/MapOutdoor.cpp` | +| §8.4 雾等级实参 | `POC/assets/root/uisystemoption.py` | +| §8.8 `EMPIRE_NUM` | `REF/UserInterface/GameType.h` / `Packet.h` | +| §9.4 `.mss` 解析 | `REF/MilesLib/SoundData.cpp` | + +--- + +## 附录 C:并行实施计划、依赖门禁与进度看板 + +本附录取代旧版“按 1–12 步串行实施”的顺序。条目仍按依赖关系分组,但同一波次内的工作流可以 +并行;只有公共接口和集成门禁需要排队。具体的文件主维护者、状态定义和同步要求见本文 +《并行开发与进度同步协议》以及 `CLIENT-GAP.md` 顶部的同名章节。 + +### C.1 公共门禁 + +| 门禁 | 目标 | 通过条件 | 当前状态 | +|---|---|---|---| +| G0 公共接口冻结 | 统一连接、实体、Warp、场景 reset、UI 信号和 fixture | 接口有唯一实现者;影响文件已登记;每个工作流能独立编译 / 测试 | 🟡 增量 49 接口冻结批次已落地(`Entity` 字段、`mut_spawn_full/mut_ownership/mut_map_bgm/drain_dirty`、`bgm_changed`、`bgm_director.gd`、§8.6 键位入口);真实双连接和部分生命周期仍待验收 | +| G1 接口集成 | 汇合并行工作流的共享改动 | C++ 扩展构建、受影响原生测试、Godot 回归通过;无未登记共享文件冲突 | ⬜ 待 Phase 1(W1/W2/W3/W4)4 个并行工作流提交后执行 | +| G2 真实 / 发布验收 | 验证参考链路与可交付 Mac App | 40250 真实连接 / 换图、适用的双账号玩法、资源包、架构、签名和验收记录齐全 | ⬜ 未开始;受限环境 `skip` 不算通过 | + +### C.2 可并行工作流 + +| 工作流 | 对应条目 | 本批次可独立交付 | 依赖 | 集成时必须提供 | +|---|---|---|---|---| +| W1 协议与账号会话 | §1、Classic wire / parser、登录、选人、DirectEnter、断线 | wire 尺寸 / parser、错误处理、loopback、账号阶段测试 | G0 的会话接口;真实服务端不阻塞本地开发 | 包字节证据、阶段转换、unknown / unhandled 处理、真实验收缺口 | +| W2 实体、世界与战斗 | §2–§3、移动、动作、技能、目标、换图 | EntityStore / NetWorld / PlayerController、动作 fixture、战斗与实体测试 | G0 的实体快照和 `world_reset` | 节点生命周期、坐标 / 状态语义、受影响测试和性能风险 | +| W3 玩法 UI、输入与本地化 | §4–§5、§8、背包、任务、社交、IME、键位 | 独立 UI、输入映射、CJK / 键位测试、fixture 驱动回归 | M2Client 公开信号;无真服时可用 fixture | 对应窗口状态、发送包、平台差异和视觉未验收项 | +| W4 渲染、资产与音频 | §6–§7、§9、模型、特效、地图、材质、天气、BGM | 单项渲染 / 资产解析、效果和音频生命周期测试 | G0 的场景 / 地图钩子;可用最小资源 | 参考参数、资产路径、帧 / 内存风险、地图切换清理证据 | +| W5 打包、平台与验收 | §10、Mac App、架构、签名、发布矩阵 | debug / release 打包、资源自包含检查、签名和启动检查 | 可与 W1–W4 并行;G2 消费集成产物 | 包路径、架构、签名、运行日志和失败原因 | + +### C.3 当前进度初始化 + +| 工作流 | 当前状态 | 已有证据 | 下一批次 | +|---|---|---|---| +| W0 公共基础 / 集成 | 🟡 开发中 | DirectEnter、Classic Warp endpoint、`world_reset`、场景重建首版;增量 49 接口冻结批次(`Entity` 字段 + `mut_spawn_full/mut_ownership/mut_map_bgm/drain_dirty` + `bgm_changed` 信号 + `bgm_director.gd` + §8.6 键位入口)已提交,C++ `ctest` 16/16 + 9 项 GDScript 回归通过 | 接收 Phase 1 W1/W2/W3/W4 批次并执行 G1 | +| W1 协议 / 账号会话 | 🟡 开发中 | 角色槽地址透传、发送结果、unknown / unhandled 错误链路、断线回 LOGIN;`m_pending_actor` 暂存表成员就位 | §1.5 真实版本包、§1.10 定长文本编码、§2.2 两包合并逻辑 | +| W2 实体 / 世界 / 战斗 | 🟡 开发中 | 地图实体 / 掉落清理、地图重建首版、实体测试和场景测试;`drain_dirty` / `mut_spawn_full` 骨架可用 | §2.1 两层模型 + 可见性、§2.3 `mut_spawn→mut_spawn_full`、§2.4 点数数组尾部、§2.5 应用顺序 + affect_flags | +| W3 玩法 UI / 输入 | 🟡 已有首版 | 多数窗口、背包 / 任务 / 社交、键位和输入已有回归;§8.6 数字键 / F1–F4 / Ctrl+数字入口已收敛 | §8.3 帮助窗;§8.6 剩余逐行键位、LSHIFT 翻页、表情包 | +| W4 渲染 / 资产 / 音频 | 🟡 已有首版 | GR2 / `.msa` / `.mse`、地图 / 水体 / 天气 / BGM 部分链路已有;`bgm_director.gd` 消费点就位 | §9.1 地图 BGM 曲目解析 + 音量、§9.2 后台恢复曲目、§9.3 生命周期单一属主 | +| W5 打包 / 发布 / 验收 | ⬜ 未开始 | 已有 Godot 导出配置和构建入口 | 自包含资源包、Intel / Apple Silicon、签名和 Mac App 验收 | + +### C.4 每批次回写模板 + +后续开发不得只在提交说明中记录进度;每个批次完成后,将下面的信息同时回写到 +`CLIENT-GAP.md` 的变更记录 / 总览表和本文对应工作流 / 条目: + +```text +批次:YYYY-MM-DD / W? / §x.x +状态:🟡 开发中 | ✅ 完成 | ⚠️ 阻塞 | 🔎 待真实验收 +参考:ClientVS22/source/...:line +改动:文件列表;接口 / 数据结构变化 +测试:命令、结果;skip 项及原因 +验收:真服 / 双账号 / Mac App 证据,或明确“未验证” +风险与依赖:... +下一批次:... +``` + +同步责任固定为:工作流提交者提供事实和证据,W0 集成维护者统一合并两份文档;任何状态从 +“开发中 / 待验收”改为“完成”前,必须同时满足代码回归和文档回写。 + +### C.5 批次记录 + +```text +批次:2026-09-02 / W0 / §2.1 §2.3 §2.5 §8.6 §9.1(接口冻结,Phase 1 前置) +状态:🟡 开发中(骨架落地;语义补全在 Phase 1 各工作流) +参考:POC/assets/root/game.py:309-431(键位);ClientVS22 GC_MAIN_CHARACTER3/4(BGM) +改动: + extension/src/net/entity_store.h/.cpp —— Entity +empire/affect_flags/owner_vid/state_flags; + +mut_spawn_full()/mut_ownership()/mut_map_bgm()/drain_dirty()/take_bgm_dirty()/ + bgm_name()/bgm_volume();mut_char_info 落 empire;reset_for_map_change 清 m_dirty + extension/src/net/m2_client.cpp —— +bgm_changed 信号;两条 pump 回路消费 drain_dirty/ + take_bgm_dirty;entity_dict() 补 4 键 + extension/src/net/classic/classic_parser.h/.cpp —— BGM 名/音量路由到 mut_map_bgm; + +m_pending_actor 成员 + project/bgm_director.gd(新)、project/game_scene.gd —— 装配 BgmDirector;§8.6 键位入口 +测试:cmake --build build 通过;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 全绿;无 skip +验收:未验证(无真实 40250 服务端;BGM / 两包合并需服务端数据) +风险与依赖:mut_spawn_full 目前无调用方(W2 §2.3 接入);m_pending_actor 未使用 + (W1 §2.2 接入);bgm_director 仅透传曲目名,未解析地图→曲目(W4 §9.1) +下一批次:Phase 1 —— W1(§1.5/§1.10/§2.2)、W2(§2.1/§2.3/§2.4/§2.5)、W3(§8.3)、 + W4(§9.1/§9.2/§9.3) 在各自 worktree 并行;Phase 2 由 W0 执行 G1 集成与文档合并 +``` diff --git a/docs/CLIENT-GAP.md b/docs/CLIENT-GAP.md index 941cc73d..a5f98f15 100644 --- a/docs/CLIENT-GAP.md +++ b/docs/CLIENT-GAP.md @@ -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 可用 loopback,W2/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 增量 46(W0/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 增量 47(W0/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 增量 49(W0 接口冻结批次)**:为 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 增量 45(P8 交互边界与龙魂运行时范围)**:补齐 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/ER,EPointTypes 索引取自 m2dev `Packet.h`)。加点 + / - 按钮发 +> ASPD/MSPD/CSPD/ER,EPointTypes 索引取自 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.25–3.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=96–98` 观战者状态、频道、时间、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、F1–F4 快捷栏切页和数字键 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 的数字键 1–4、Ctrl+1–9 表情、F1–F4 本地槽位 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` header,Escape 取消通过 `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`(0x0B20–0x0B22):12B / 8B wire、持久观战者状态、 +- `GC_OBSERVER_ADD/REMOVE/MOVE`(96–98 / `0x60–0x62`):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 ` 发包;当前缺少参考端的可升级条件门控(技能组、等级上限、技能点、 + 死亡 / 变身 / 骑乘等状态)、加号按钮显示规则、失败提示和升级后的技能状态刷新。见 + [`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/P2:UI、输入和本地化 + +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。 diff --git a/extension/src/net/classic/classic_parser.cpp b/extension/src/net/classic/classic_parser.cpp index f697b800..7600d824 100644 --- a/extension/src/net/classic/classic_parser.cpp +++ b/extension/src/net/classic/classic_parser.cpp @@ -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; } } diff --git a/extension/src/net/classic/classic_parser.h b/extension/src/net/classic/classic_parser.h index 1399908a..ea7c1fe2 100644 --- a/extension/src/net/classic/classic_parser.h +++ b/extension/src/net/classic/classic_parser.h @@ -15,6 +15,7 @@ #include #include #include +#include #include 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 drain_unhandled_headers() { + auto v = std::move(m_unhandled_headers); + m_unhandled_headers.clear(); + return v; + } std::vector 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 m_pending_actor; std::vector 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 m_unhandled_headers; }; } // namespace mtnet::classic diff --git a/extension/src/net/classic/classic_session.cpp b/extension/src/net/classic/classic_session.cpp index 600aa304..7fb20193 100644 --- a/extension/src/net/classic/classic_session.cpp +++ b/extension/src/net/classic/classic_session.cpp @@ -6,6 +6,16 @@ namespace mtnet::classic { +namespace { +std::string ipv4_from_slot_addr(int32_t addr) { + const uint32_t value = static_cast(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(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: - set_stage(Stage::CharSelect); - if (m_parser.char_list_ready() && on_char_list) { + 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_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(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 --------------------------------------------------------- diff --git a/extension/src/net/classic/classic_session.h b/extension/src/net/classic/classic_session.h index e75a6b06..86e884f7 100644 --- a/extension/src/net/classic/classic_session.h +++ b/extension/src/net/classic/classic_session.h @@ -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 diff --git a/extension/src/net/classic/classic_stream.cpp b/extension/src/net/classic/classic_stream.cpp index ff3ee2bf..2df2e2f6 100644 --- a/extension/src/net/classic/classic_stream.cpp +++ b/extension/src/net/classic/classic_stream.cpp @@ -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(total - 1)); discard_recv(static_cast(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); } } diff --git a/extension/src/net/classic/classic_stream.h b/extension/src/net/classic/classic_stream.h index 3cce65e3..6f9aa447 100644 --- a/extension/src/net/classic/classic_stream.h +++ b/extension/src/net/classic/classic_stream.h @@ -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 diff --git a/extension/src/net/entity_store.cpp b/extension/src/net/entity_store.cpp index 39d926f6..de1773e7 100644 --- a/extension/src/net/entity_store.cpp +++ b/extension/src/net/entity_store.cpp @@ -1,5 +1,6 @@ #include "entity_store.h" +#include #include #include @@ -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) { diff --git a/extension/src/net/entity_store.h b/extension/src/net/entity_store.h index de736f5b..816fe2e1 100644 --- a/extension/src/net/entity_store.h +++ b/extension/src/net/entity_store.h @@ -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 &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 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 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 m_changes; + std::vector m_dirty; + std::string m_bgm_name; + float m_bgm_volume = -1.0f; + bool m_bgm_dirty = false; std::vector m_chat; std::vector m_vitals; std::vector m_damage; diff --git a/extension/src/net/m2_client.cpp b/extension/src/net/m2_client.cpp index e20899ca..4933bc93 100644 --- a/extension/src/net/m2_client.cpp +++ b/extension/src/net/m2_client.cpp @@ -20,6 +20,7 @@ #include #include +#include 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(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) { - emit_signal("login_failed", String(classic_sess->last_error().c_str())); + // 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; - 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); + // 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(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); + 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()), diff --git a/extension/src/net/m2_client.h b/extension/src/net/m2_client.h index d9761fd6..64895a65 100644 --- a/extension/src/net/m2_client.h +++ b/extension/src/net/m2_client.h @@ -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); diff --git a/extension/tests/net_classic_session_test.cpp b/extension/tests/net_classic_session_test.cpp index 9e75fc92..12c62cab 100644 --- a/extension/tests/net_classic_session_test.cpp +++ b/extension/tests/net_classic_session_test.cpp @@ -3,9 +3,14 @@ // session.stream().feed(), outgoing checked via take_outgoing(). #include "../src/net/classic/classic_session.h" +#include #include #include +#include +#include #include +#include +#include #include using namespace mtnet::classic; @@ -43,6 +48,42 @@ static std::vector 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(&addr), sizeof(addr)) < 0 || + ::listen(fd, 4) < 0) { + ::close(fd); + return -1; + } + socklen_t len = sizeof(addr); + if (::getsockname(fd, reinterpret_cast(&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(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(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 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(0x0100007f); // 127.0.0.1 + login.players[0].port = slot_port; + feed(direct, raw(login)); + CHECK(direct.char_slots()[0].addr == static_cast(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 --- diff --git a/extension/tests/net_classic_stream_test.cpp b/extension/tests/net_classic_stream_test.cpp index d09576f3..9b19a115 100644 --- a/extension/tests/net_classic_stream_test.cpp +++ b/extension/tests/net_classic_stream_test.cpp @@ -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) ---- diff --git a/extension/tests/net_entity_test.cpp b/extension/tests/net_entity_test.cpp index 37c58fd0..392ad7e0 100644 --- a/extension/tests/net_entity_test.cpp +++ b/extension/tests/net_entity_test.cpp @@ -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; diff --git a/project/app_flow.gd b/project/app_flow.gd index 11dce3f5..acb0f81d 100644 --- a/project/app_flow.gd +++ b/project/app_flow.gd @@ -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_scene,ReconnectUI 负责重连遮罩 - # LOGIN/SELECT 里:回登录 - if _state != GAME: - _goto_login() +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"): diff --git a/project/bgm_director.gd b/project/bgm_director.gd new file mode 100644 index 00000000..2eb30ca2 --- /dev/null +++ b/project/bgm_director.gd @@ -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) diff --git a/project/charselect.gd b/project/charselect.gd index 675cef63..76ed8225 100644 --- a/project/charselect.gd +++ b/project/charselect.gd @@ -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")) diff --git a/project/game_scene.gd b/project/game_scene.gd index 5e23a3ee..4242c914 100644 --- a/project/game_scene.gd +++ b/project/game_scene.gd @@ -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 地图 BGM:M2Client.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: + # 参考端 __PressNumKey(game.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: # ESC:ui_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: - 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) +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) + pos = entity.get("pos_cm", null) if not (pos is Vector3): - return requested + 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: diff --git a/project/hud.gd b/project/hud.gd index 7cef5149..7b94c956 100644 --- a/project/hud.gd +++ b/project/hud.gd @@ -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) diff --git a/project/login.gd b/project/login.gd index ffe68a1d..fd360658 100644 --- a/project/login.gd +++ b/project/login.gd @@ -134,7 +134,10 @@ 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) - _client.call("select_character", idx) + if _client.has_method("enter_game"): + _client.call("enter_game", idx) + else: + _client.call("select_character", idx) func _on_entered_game() -> void: _msg("已进入游戏 —— 加载场景") diff --git a/project/net_world.gd b/project/net_world.gd index 21d85add..4ffd2001 100644 --- a/project/net_world.gd +++ b/project/net_world.gd @@ -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) diff --git a/project/netbridge_test.gd b/project/netbridge_test.gd index b257ddf2..f7e87c93 100644 --- a/project/netbridge_test.gd +++ b/project/netbridge_test.gd @@ -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", diff --git a/project/p10_test.gd b/project/p10_test.gd index 987f961e..f25d7741 100644 --- a/project/p10_test.gd +++ b/project/p10_test.gd @@ -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 时断线 -> 回 LOGIN(ClientVS22 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") diff --git a/project/ui/atlas_ui.gd b/project/ui/atlas_ui.gd index 60b6f05f..9766d106 100644 --- a/project/ui/atlas_ui.gd +++ b/project/ui/atlas_ui.gd @@ -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" diff --git a/project/ui/cube_ui.gd b/project/ui/cube_ui.gd index b68e9236..53f69444 100644 --- a/project/ui/cube_ui.gd +++ b/project/ui/cube_ui.gd @@ -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 diff --git a/project/ui/exchange_ui.gd b/project/ui/exchange_ui.gd index 3a63e0bc..99893ec4 100644 --- a/project/ui/exchange_ui.gd +++ b/project/ui/exchange_ui.gd @@ -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 diff --git a/project/ui/friend_ui.gd b/project/ui/friend_ui.gd index 968e3d5d..e78e7c86 100644 --- a/project/ui/friend_ui.gd +++ b/project/ui/friend_ui.gd @@ -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: diff --git a/project/ui/ground_items.gd b/project/ui/ground_items.gd index 1ef953d4..6496bfe3 100644 --- a/project/ui/ground_items.gd +++ b/project/ui/ground_items.gd @@ -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: diff --git a/project/ui/mall_ui.gd b/project/ui/mall_ui.gd index 774d25f9..17a79ef4 100644 --- a/project/ui/mall_ui.gd +++ b/project/ui/mall_ui.gd @@ -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) diff --git a/project/ui/party_ui.gd b/project/ui/party_ui.gd index 104aff89..577c6058 100644 --- a/project/ui/party_ui.gd +++ b/project/ui/party_ui.gd @@ -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"): diff --git a/project/ui/quest_log.gd b/project/ui/quest_log.gd index c5897ce1..9c213a5c 100644 --- a/project/ui/quest_log.gd +++ b/project/ui/quest_log.gd @@ -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: diff --git a/project/ui/refine_ui.gd b/project/ui/refine_ui.gd index 961bd908..1f5f070c 100644 --- a/project/ui/refine_ui.gd +++ b/project/ui/refine_ui.gd @@ -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 "-" diff --git a/project/ui/safebox_ui.gd b/project/ui/safebox_ui.gd index 43a06ec6..d1ab193f 100644 --- a/project/ui/safebox_ui.gd +++ b/project/ui/safebox_ui.gd @@ -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 diff --git a/project/ui/shop_ui.gd b/project/ui/shop_ui.gd index 05533bfc..8ada634c 100644 --- a/project/ui/shop_ui.gd +++ b/project/ui/shop_ui.gd @@ -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 = "" diff --git a/project/world/world_time.gd b/project/world/world_time.gd index 9a94f03c..7fe3c971 100644 --- a/project/world/world_time.gd +++ b/project/world/world_time.gd @@ -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