# 40250/ClientVS22 → metin2-client 1:1 功能差距 > 对照范围:`../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`。 > > **本文末「ClientVS22 实现细节审计(2026-09-02)」每一条的具体修改细节见 > [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md)**(逐条给出 40250/ClientVS22 的参考 > 源码位置、常量与算法,以及本工程要改的文件/函数;其附录 A 列出本节中 > 需要订正的条目,分「参考行为不符 / 表述需加限定 / 低估了当前差距」三档)。 > > 更新日期:2026-09-04(对照 40250/ClientVS22 复核) > > **状态列图例**:✅ 达到本文末“1:1 完成判据” · 🟡 有首版 / 部分语义 · ⬜ 未实现 · > ❌ 明确不做(超出 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-04) | 工作流 | 状态 | 当前已知进展 | 下一步 | |---|---|---|---| | W0 公共基础 / 集成 | 🟡 | DirectEnter、Classic Warp endpoint、`world_reset`、场景重建首版已落地;增量 49 接口冻结 + 增量 51 AppFlow 单一 `AppLifecycle` + 增量 52 `net_bounds` 已接入,GameScene 不再重复创建,当前 C++ CTest 15/15 与生命周期 / 场景回归通过 | Phase 1 4 工作流并行完成后执行 G1 集成;真机生命周期 / 业务边界验收 | | W1 协议 / 账号会话 | 🟡 | 增量 50:§1.5 版本包按 locale 分支(EUROPE 发 `CG_CLIENT_VERSION2` 0xf1 + `"1215955205"`,其余发 0xfd + `MT_BUILD_TIMESTAMP`)、可执行名 setter;§1.10 `text_codec.h` UTF-8 安全定长文本裁剪 + `net_text_codec_test`,会话侧所有定长 name/sign/comment 填充改走 `to_wire()`;§2.2 `GC_CHARACTER_ADD` 按 invisible-race 丢弃 / PC·NPC 暂存 `m_pending_actor` / 其余即时 `mut_spawn`(名字取自 npclist.txt),`GC_CHAR_ADDITIONAL_INFO` 无暂存则记录并丢弃、命中则合并 9 字段 `mut_spawn_full` 后清表 | 真实 locale codepage 转码、真实双连接、enter delay;GDScript 名称长度校验统一(G1 已收) | | W2 实体 / 世界 / 战斗 | 🟡 | §2.1 两层模型(`EntityStore` 纯数据层 + `net_world.gd` 每帧 `_update_visibility()` 按 `CHAR_STAGE_VIEW_BOUND` / `AFFECT_SHOW_ALWAYS` / `IsWall` 建 / 淡出节点)、§2.3(`mut_spawn`→`mut_spawn_full` 整行替换 + 节点销毁重建 + 上下坐骑保坐标)、§2.4(点数快照截断 / 尾部清零 + 单索引边界)、§2.5(`mut_affect_flags` 两段拼装 + 参考调用序节点刷新)已落地并回归(增量 50);增量 52 补齐 M2Client 公共动作 / 物品 / 交互入口的 wire 参数边界;增量 73(§3.7)删除 `net_play.gd` 的本地硬直计时器 `_hitstun_until`,「能否行动」统一走 `_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`(对齐 `InstanceBase.cpp:1247` / `InstanceBaseBattle.cpp:355` / `:310`),`Entity` 增加 `knock_down`(受击 `.msa` 播放生命周期驱动、动作播完清零),`dead` / `stunned` 保持服务端包驱动;增量 74(§3.3)`formats/msa` 新增 `ComboInputData` / `AttackingData` 解析、`metin2_anim.get_motion_data()`,`net_play.gd` 删除 `ATTACK_RANGE` 改 `CLICK_DIST_DEFAULT/NPC/RESOURCE_CM`(对齐 `InstanceBaseBattle.cpp:294`),普攻节奏改为 `.msa` `DirectInputTime`(缺组退化 `duration*0.9`)除以 `bAttackSpeed/100`;增量 75(§3.4)`net_play.gd` 新增 `enum ReservedMode` + `_update_auto_attack()` + `_reserve_process_click_actor()` 严格 10 步(对齐 `PythonPlayer.cpp:207` / `PythonPlayerInput.cpp:877`),出距离 `pc.walk_to` 靠近、NPC 走 `click_npc`、`IN_SAFE` / `EMPTY_ARROW` 经 `signal cannot_act`;增量 76(§3.1)`player_controller.gd` 去掉直线改坐标,改 `_src_pos` / `_dst_pos` / `_is_going` / `_dst_rot`,`walk_to()` → `_goto()` 对齐 `NEW_Goto`(`InstanceBaseMovement.cpp:251`:太近不动、三门 `_can_translate()` 未过 → `__ReserveClickGround` 0.1 s 重试、`moving_skill` 只转向),新增 `player_move_test.gd`;增量 77(§3.2 命令队列 part A)`EntityStore` 把远端 Actor 位置改为 `std::deque` 命令队列驱动(对齐 `InstanceBase.cpp:1278 StateProcess`),`mut_move()` 入队(≈ `PushTCPState`)+ `drain_state_queue()` 释放到期项、删旧 `start_move()` 直改坐标、两套后端统一走 `mut_move(...,arg,time)`,新增 `process_states()` / `apply_state_cmd()` 1:1 复刻 switch(`FUNC_WAIT` 1.0、`FUNC_COMBO/ATTACK/MOB_SKILL` 50.0 硬阈值 + `m_kMovAfterFunc` 到达后动作 + `skip_collision`),释放门按 `set_server_frame_ms()`(为 0 = 帧钟未对齐即时释放)+ `__CanProcessNetworkStatePacket`(`dead` / `knock_down` 挂队),新增 `net_state_queue_test`(5 组)、C++ 20/20;增量 78(§3.2 part B)`net_play.gd` 上行按 `PythonPlayerEventHandler.cpp` 拆成 6 回调(统一出口 `_send_state`):OnMove 立即 `FUNC_MOVE` + 重置 moving/waiting 节流窗、OnMoving 300 ms 节流、OnWaiting(新 `_tick_on_waiting`)100 ms + 位移 ≥ 1 cm、OnStop `FUNC_WAIT`、OnAttack 额外 `FUNC_COMBO`、新公共入口 `on_use_skill()` → `FUNC_SKILL|motion`;删旧 `SEND_INTERVAL/SEND_DIST` 单一节流;`net_world.gd` 去掉自有 `follow_lerp`,`_process` 直接 `n.position = e.pos`(插值全交 C++ `tick()`);`netplay_test` 加第 11 段、`netbridge_test` near-move 断言改写,7 个 GDScript 回归 + C++ 20/20 全绿;增量 79(§3.2 part C 收官)`ClassicStream::server_frame_ms()`(≈ `ELTimer_GetServerFrameMSec()`,由 `HDR_HANDSHAKE` `m_server_time_base = hs.time + lDelta` / `m_client_time_base` 算出)经 `ClassicSession::process()` 每帧 `set_server_frame_ms()` 喂给命令队列(m2dev 恒 0 = 即时释放,无回归),`drain_state_queue` 加防呆阀 `STATE_QUEUE_MAX_WAIT_MS=1000`(异帧钟 `dwTime` 立即放行,有意偏离参考端),`net_classic_stream_test` + `net_state_queue_test` 补覆盖,C++ 20/20 + 7 GDScript 全绿;增量 80(§3.5 part A)查明 PC 连击段表**不在** `.msm` 里(`.msm` 只有 `BaseModelFileName` + `HairData`),实为 `assets/root/playersettingmodule.py` 的 `chrmgr.ReserveComboAttackNew` / `RegisterComboAttackNew` 调用建在 `CRaceData` 上,新增 `formats/combo_table.{h,cpp}`(`fmt::PlayerComboTables`)按 `def __LoadGameEx` 分四职业块解析为 `map>`,语义对齐 `CRaceData::Reserve/RegisterComboAttack`(insert 不覆盖 / 越界跳过),新增 `formats_combo_table_test`(合成 + 真实 `playersettingmodule.py` 双覆盖),C++ 21/21 + 7 GDScript 全绿;增量 81(§3.5 part B)`net_play.gd` 用此表 + `.msa` `ComboInputData` 三时间实现连击状态机(`_do_attack_swing` / `_run_next_combo` / `_combo_process` / `_on_end_combo` / `_clear_combo` 逐行对齐 `ActorInstanceBattle.cpp:157/213/256/297/308`),替掉 `(_combo + 1) % 3`,段号经新静态绑定 `Metin2AnimPlayer.parse_combo_tables()` 按 `race & 3` × `combo_motion_mode<<16\|type` 取,缺段表退化单段普攻,`game_scene` 装配时 `set_asset_root()`,`combat_fx_test` 加 `FakeAnim` 全路径断言,C++ 21/21 + 7 GDScript 全绿;增量 82(§3.5 part C,修改 1)导出 `.msa` `HitDataContainer` 多命中窗:`textscript.cpp` 补 `List Name { ... }` 解析(此前 `List HitPosition` 破坏后续 `Group`),`formats/msa.{h,cpp}` `AttackingData` → `std::vector`(`{start_time,end_time,bone_name,weapon_length,samples}`,吃老式内联 + 新式 `HitDataCount N` / `Group HitData0i`),新增 `motion_type` / `hit_limit_count`,`get_motion_data()` 增 `hit_windows` / `motion_type` / `hit_limit_count`,`formats_msa_test` 补合成 + `M2_ASSETS` 真机断言(真实双命中窗 `assassin/dualhand_sword/combo_01.msa`),C++ 21/21 + 7 GDScript 全绿;增量 83(§3.5 修改 4,收官)`net_play.gd` 新增 `_begin_hit_windows` / `_attack_process(dt)` / `_hit_deduped` / `_register_hit` / `_hit_geometry` / `_flush_victim_list`:逐帧按动作时间窗与 `hit_window [start,end]` 相交对 `client.get_entities()` 可攻击 / 未死 / 非安全区实体做几何判定(`m_HitDataMap` 查重 + 每窗命中上限 NORMAL/COMBO 16 / SKILL `hit_limit_count`),命中 → `client.attack(_swing_skill, vid)`(`OnHit`),被击退目标帧末 `client.sync_positions()`(≤16);`_emit_swing` 改为只有缺命中窗数据才在挥击瞬间发 `CG_ATTACK`;几何判定 = `reach`(max WeaponLength / 采样水平偏移)+ 正面 ±60° 弧近似(真实动态圆柱扫掠随 §3.1 留桩),`combat_fx_test` 加 `FakeNetWorld` + 6 段断言,C++ 21/21 + 7 GDScript 全绿;增量 84(§3.5 修改 6,§3.5 修改 1-6 全落地)`net_play.gd` 补全 `MOTION_MODE_*`(`EMode` 17 项)+ `ITEM_TYPE_*` / `WEAPON_SUB_*` 常量,新增静态 `motion_mode_for(item_type, weapon_sub, mounted, poly, dress)` 逐分支复刻 `CInstanceBase::RefreshState` 的 `SetMotionMode`(`InstanceBase.cpp:2851`:Poly→GENERAL / 婚纱→WEDDING_DRESS / 镐·钓竿→骑乘 HORSE 否则 GENERAL·FISHING / 骑乘 switch → HORSE_* / else switch → ONEHAND·TWOHAND·DUALHAND_SWORD·BOW·FAN·BELL·默认 GENERAL),`_equipped_weapon_vnum()`(`parts[WEAPON]` 优先,否则 `get_equipment()[WEAR_WEAPON].vnum`)+ `_refresh_motion_mode()`(读 `mount_vnum`/`polymorph`/`wedding_dress` + `proto.item(vnum).{type,sub_type}` → `set_combo_motion_mode()`,换模式内部 `_clear_combo()`),由 `_on_entity_info()` 末尾 / `inventory_changed`(EQUIPMENT) / `mount_changed`(主角) 三处触发,`combat_fx_test` 加 `FakeProto` + 15 段 `motion_mode_for` + 2 段端到端断言,C++ 21/21 + 7 GDScript(含 `equip_model_test`)全绿;增量 85(§3.5 修改 7,§3.5 修改 1-7 全落地)`net_play.gd` `_clickable_distance()` 逐分支对齐 `NEW_IsClickableDistanceDestInstance`(`InstanceBaseBattle.cpp:294`:默认 150 → `_is_bow_mode()`(`MODE_BOW`/`MODE_HORSE_BOW`)改用 `_bow_range_cm()` → NPC 500 → 采集物 100,后写 `if` 覆盖前面),`_bow_range_cm()` = `BOW_RANGE_BASE_CM`(2500-100) + `_bow_distance_bonus`(1:1 复刻 `CInstanceBase::__GetBowRange` `InstanceBase.cpp:694`),`_bow_distance_bonus` 由 `_on_points()` 读 `points["bow_distance"]`,`wire.h` 补 `POINT_BOW_DISTANCE=34` + `get_points()` 透出,`combat_fx_test` §3.5 段加 8 段断言,C++ 21/21 + 7 GDScript 全绿;增量 86(§3.6,修改 1-3 全落地)`game_scene.gd` 单槽 `_pending_shoot_skill` + `create_timer(1.5)` 换成 `_pending_shots: Array[int]` FIFO(上限 8):弓挥击 / 弓技能起手 `_queue_shot(uSkill)` 压一个,`.msa` `MOTION_EVENT_TYPE_FLY` 帧 `pop_front()` → `client.shoot()`(AutoClear + 连射并发,对齐 `ActorInstanceMotionEvent.cpp:290`);`OnSetFlyTarget` 移到 `net_play._emit_swing` 的 `_send_fly_target()`(目标节点 → `MapCoord.to_server_cm()` → `client.add_fly_targeting()`,对齐 `ActorInstanceMotion.cpp:372` + `ActorInstanceFly.cpp:4`),新增信号 `bow_shot_fired`,`combat_fx_test` +6 段 / `eterngrn_polish_test` FLY 段改 FIFO 连射断言,C++ 21/21 + 9 GDScript 全绿;增量 87(§3.8 修改 2)POC 无 C++ skill proto,故在 `ui/skill_table.gd` 按 `RegisterSkillDesc`(`PythonSkill.cpp:280`)把 skilldesc 第 11 列 attributes / 第 12 列 weapon 的 `|` 分隔记号折成 `attr_bits` / `need_weapon_bits` 位掩码 + `skill_type`(`_fold_bits()` 静态方法,记号名 1:1 抄 `PythonSkill.h:92-128`;`DOUBLE_SWORD` 复用 `DAGGER` 位),`CPythonSkill` 谓词逐条落地(`is_can_use_skill` / `is_standing` / `is_need_target` / `is_need_corpse` / `is_auto_search_target` / `can_change_direction` / `is_horse_skill` / `is_moving_skill` / `is_melee` / `is_charge_skill` / `is_use_hp` / `is_time_increase` / `is_need_empty_bottle` / `is_need_poison_bottle` / `can_use_for_me` / `is_only_for_alliance` / `can_use_if_not_enough` / `is_only_for_guild_war` / `is_fan_range` / `is_circle_range` / `has_weapon_limitation` / `can_use_weapon_type` / `is_need_bow` / `skill_type_of` / `is_guild_skill`;旧 `is_attack` / `is_passive` / `is_toggle` / `is_ranged` / `can_level_up` 改查位),`skill_test.gd` +34 断言,C++ 21/21 + 9 GDScript 全绿;增量 88(§3.8 修改 1)新建 `project/player_skill.gd`(`extends RefCounted` 纯校验器),逐行移植 `PythonPlayerSkill.cpp` 的 (a)`click_skill_slot`(`:106`)/ (b)`can_use_skill`(`:439`,硬编码骑乘术 `109`)/ (c)`check_skill_usable`(`:166` 完整前置顺序)/ (d)`use_skill`(`:460` + `_resolve_target` 目标矩阵)四层,返回 `{ok, code, target_vid}`,命名常量 `SKILL_MAX_NUM=255` / `SKILL_TYPE_GUILD=3` / `RIDING_SKILL_INDEX=109` / 空瓶 `27995` / 毒瓶 `27996` / 连击 `122` / 钓鱼 `123`,`SILENT_CODES[]` + `static is_silent_code()`;`net_play.skill_context()` 每次注入运行期状态,`ui/quickbar.gd activate()` 施放前跑三层、非静默码 → 新信号 `skill_rejected` → `game_scene` 转 `net_play.cannot_act`(同一 `OnCannotUseSkill` 通道)、gate 的 `target_vid` 覆盖点击目标;`__CheckShortMana` / `__CheckShortLife` 打桩恒 false(无 `.msk` NeedSP/NeedHP 解析);新增 `player_skill_test.gd`,C++ 21/21 + 12 GDScript 全绿;增量 89(§3.8 修改 3,三层全落地)`net_bounds.h` `SKILL_MAX_NUM=255` + `skill_index()` 谓词,`M2Client::use_skill()` 的 `skill_id` 护栏收紧成 `!bounds::skill_index()`(对齐 `ClickSkillSlot` 的 `slot >= SKILL_MAX_NUM` 门),`skill_table.gd` 解析 `.msk` `NeedSPFormula` + `_SKILL_POWER[]` → `need_sp` / `need_hp` / `_skill_point`,`player_skill.gd` `_check_short_mana` / `_check_short_life` 从桩改为移植 `__CheckShortMana`(`:320`)/ `__CheckShortLife`(`:354`),HP/SP 经 `skill_context()` 读 `get_points()` 供入、`-1` 不判;`net_bounds_test.cpp` +4、`player_skill_test.gd` +“修改 3”段,C++ 21/21 + 13 GDScript 全绿;增量 90(§3.10 技能升级门)`wire.h` +`POINT_SUB_SKILL=27` / `POINT_SKILL=28` / `POINT_HORSE_SKILL=113`、`M2Client::get_points()` 透出 `skill_active` / `skill_support` / `skill_horse`,`M2Client::skill_up()` 入参收紧为 `!bounds::skill_index()`(与 `use_skill` 同一谓词),`skill_table.gd` +`max_level_of` / `skill_level_limit`(本 40250 数据无 `LEVEL_LIMIT` 列 / `.msk` 记号 → 恒 0)/ `can_level_up_skill(id, level)`(= `skillCanLevelUpSkill`,本 build 前置技能 / 前置属性字段无人填充 → 化简为 `level < GetMaxLevel() && !CANNOT_LEVEL_UP`),`skill_ui.gd` `refresh()` 逐行移植 `uicharacter.py` 的 `RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` / `CanShowPlusButton`(技能组门 `get_skill_group()!=0` + 三技能点来源分开取 + `skillGrade==0` + 马术页 `level<20` + 主动页 `can_level_up_skill` + 辅助页 `HIDE_SUPPORT_SKILL_POINT` 整页隐),按钮 `_rebuild()` 建 / `refresh()` 切 `visible`,升级结果由 `GC_SKILL_LEVEL` 经 `skills_changed` 驱动、不本地伪造,`skill_test.gd` +`§3.10` 段,C++ 21/21 + 13 GDScript 全绿;增量 91(§3.4 其余三种预约模式)`net_play.gd` `_refresh_reserved()` 由「只 CLICK_ACTOR」改四路 `match` 逐行对齐 `NEW_RefreshMouseWalkingDirection`(`PythonPlayerInputMouse.cpp:287`):`_reserve_process_click_item()`(IID 查掉落物世界坐标,`< 20cm` `CLICK_ITEM_PICKUP_CM` → `FUNC_WAIT`+`pickup_item`+解冻+清,否则 `walk_to`;物品消失→清)、`_reserve_process_click_position()`(`_can_translate()` 未锁 && `_reserved_delay<=0` → `walk_to(_ppos_reserved)`+清)、`_reserve_process_use_skill()`(`dist_cm` 含 `_is_huge_race` 的 `-200cm` `HUGE_RACE_DIST_FIX_CM` 修正,`< _skill_range_reserved` → `set_target`+`use_skill_hook(slot)` 成功才清,否则 `walk_to`;目标丢失→清);预约入口 `reserve_click_item` / `reserve_click_ground`(`_reserved_delay=0.1`)/ `reserve_use_skill`(= `__ReserveUseSkill` `PythonPlayerInput.cpp:854`,`range>100→-10` `RESERVE_SKILL_RANGE_TRIM_CM`)/ `pick_ground_item`(= `__OnPressItem`);`_on_pick()` 补 `__OnPressActor` 特例(`_reserved_mode==USE_SKILL && _dash_active` 只改 `_vid_reserved`),战斗目标改走 `_change_target_to_picked_instance(vid)`(= `__ChangeTargetToPickedInstance` `PythonPlayerInput.cpp:186` → `SetTarget` :130,`IsTargetableInstance` 限 PC/怪否则 `set_target(0)`+`clear_target`);`_is_huge_race(race)` 因 `IS_HUGE_RACE` vnum 白名单未随源码发布 → `proto.mob(race)` `size>=MOBSIZE_HUGE(3)` 或 `rank>=BOSS(4)` 近似(seam);扇形/圆形补 fly-targeting(`PythonPlayerSkill.cpp:680-746` `if (dwTargetMaxCount>0 …)` 块)`net_play.send_fly_targeting(target_vid, range_cm, max_count, shape)`——主目标 `CG_FLY_TARGETING`+`CG_USE_SKILL` 仍由 `M2Client::use_skill` 发,这里只补附加目标——`_instances_in_fan_range()`(= `NEW_GetInstanceVectorInFanRange` `InstanceBaseBattle.cpp:192`:随距离收窄半角 `(MAX-MIN)-RPM·min(dist,1000)+MIN`,`FAN_HALF_ROT_MIN=20`/`MAX=40`/`MIN_DISTANCE=1000`)/ `_instances_in_circle_range()`(:243,无方位过滤)内其它可攻击敌人经 `client.add_fly_targeting(vid, sx, sy)` 补齐,名额不足时 `_random_pos_in_fan_range()`(= `NEW_GetRandomPositionInFanRange` :124:目标方位 ±10° `FAN_RANDOM_JITTER_DEG` 抖动、沿该方向 `FAN_RANDOM_REACH_CM=8000cm` 取点)→ `add_fly_targeting(0, sx, sy)`;`ui/skill_table.gd` `_load_msk` 补 `.msk` `TargetCountFormula` / `Range` 解析,新增 `target_count(id, level)`(= `GetTargetCount` `PythonSkill.cpp:1361`)/ `target_range(id)`(= `__GetSkillTargetRange` 基础项 `PythonPlayerSkill.cpp:391`)/ `fly_shape(id)`(`FAN_RANGE`→1 / `CIRCLE_RANGE`→2 / 否则 0);`ui/quickbar.gd` `activate()` 里 `use_skill` 成功后 `target_vid!=0` && 范围技 → 调 `send_fly_targeting(...)`,新增 `_skill_level(sid)` / `activate_reserved(global_slot)`;`game_scene.gd` 接 `net_play.use_skill_hook = func(slot): return quickbar.activate_reserved(slot)`;`netplay_test` +「5c」段、`skill_test` 补 skill 47/91/1,C++ 21/21(无 C++ 改动)+ 13 GDScript 全绿;增量 92(§3.6 飞行物实体)新增 `project/fly_object.gd`(`FlyData` / `FlyInstance` / `FlyManager` 对齐 `GameLib/FlyingObjectManager.{h,cpp}` + `FlyingInstance.{h,cpp}` + `FlyingData.cpp __Initialize()`:`Create`→`__SetDataPointer`→`__SetTargetDirection`,`Update()` 逐帧 `vel+=accel*dt`→`remain_range-=|movement|`→线段-点 `< bomb_range²` 命中→`OnShootDamage`/pierce/`__Explode`/`__Bomb`,超程→`__Explode(false)` 不 `__Bomb`,`m_bHitOnBackground` 触地;`_seg_point_sq_dist` 1:1 移植 `EterLib/GrpMath.h:80`),`net_world._on_fly` 把原 tween「发光球」占位换成 `_fly_mgr.spawn(...)` 真实弹道 + 新 `signal fly_exploded`,`net_play._can_shot()` 防御式重建 `__CanShot`(`grep` 全树无定义——非弓 `NOT_BOW_MODE` / 无箭 `EMPTY_ARROW` / 无目标 `NO_TARGET` / 死目标 / 出弓程 `OUT_OF_RANGE`),`_emit_swing` 弓分支据此走 `OnNoArrow` / `OnNoTarget` 文案并跳过 `CG_FLY_TARGETING` / `OnShoot`;新增 `project/fly_test.gd`(第 14 个 GDScript 回归),C++ 21/21(无 C++ 改动)+ 14 GDScript 全绿;seam:`GC_CREATE_FLY` 在本 40250 客户端无包处理器(仅 `NetStream.cpp` 头串索引 70)→ 实际驱动走 POC 自有 `fly_cue`、重力参考端作用于 `.z`=上本 POC 映射 `-Y`、怪物球体碰撞 / `CullingMgr` / 建筑体素未移植(客户端只做命中时机)、`.fly` 加载器缺失 → 合成箭参数;增量 93(§3.1 收尾)`player_controller._actor_blocked()` 近似 `TestActorCollision`(8 m 距离门 `ACTOR_COLLIDE_MAX_DIST_M` ≈ `LengthSq > 800²` + body 球重叠 `ACTOR_BODY_RADIUS_M=0.55` 相加 + 趋近判定 `d_next <= d_now` 才挡)+ `_process` 平移逐轴门控(单 Actor 逐轴放行=滑移 ≈ `AdjustDynamicCollisionMovement`,两轴都挡=停在上一帧位 ≈ `uCollisionCount==2` → `BlockMovement()`/`__InitializeMovement`),候选 = `pickables`、`dead` meta 跳过(≈ `rVictim.IsDead()`),`skip_actor_collision`(≈ `CActorInstance::CanSkipCollision()` / `__EnableSkipCollision`)由 `net_play.setup()` / `_on_observer_mode` 按 `_observer_mode` 置位;坐骑转向速度 `player_controller.rotation_speed_deg` + `_turn_toward(target_yaw, dt)` 逐帧限幅收敛替换原两处 `rotation.y = atan2()` 瞬转,`net_play._refresh_motion_mode()` `mounted` 切 `ROT_SPEED_HORSE_DEG=300`(`c_fDefaultHorseRotationSpeed`,对齐 `CInstanceBase::MountHorse` → `SetRotationSpeed` `InstanceBase.cpp:954`)/ 下马 `ROT_SPEED_DEFAULT_DEG=1200`(`MODE_HORSE*` 动作模式已由增量 84 覆盖),`player_move_test.gd` 5→8 组,C++ 21/21(无 C++ 改动)+ 14 GDScript 全绿;seam:真实 body sphere 半径需 race 碰撞数据(`.col` / `CollisionData.h`),`AdjustDynamicCollisionMovement` 精确滑移向量 + 门 / Actor 复合的 `uCollisionCount==2` 用逐轴近似代替,`EnableSkipCollision` 尚未接到 §3.2 网络状态包驱动的本地预测玩家,`MOVABLE_GROUND_DISTANCE` 参考默认值仍是估计(1.0 m);增量 94(§5.4,W3→M2 起点)新建 `entity_rules.gd`,`is_attackable_instance(self_e, victim_e, ctx)` 逐行照抄 `CInstanceBase::IsAttackableInstance`(`InstanceBase.cpp:2147`)+ `alignment_grade`/`alignment_type`(:502/:524)/ `is_conflict_alignment_instance`(:2116)/ `_is_same_empire`(:470)/ `_is_pvp_instance`(`InstanceBaseEffect.cpp:505`)/ `_find_duel_key`(:495)/ `_is_same_party`(`PythonPlayer.cpp:1445`)/ `_is_wooden_door`(:2390),`net_play._is_attackable` 从 `_entity_kind in [0,2]` 桩改为拼 `self_e`/`victim_e`/`_attack_ctx()`(`get_duel`/`get_pvp_relations`/`get_guild_wars`/`get_party`)委托 `EntityRules`,`entity_rules_test.gd`(第 15 个 canonical,25 断言)+ 3 个既有测试的怪物夹具补 `ch_type`;seam:`__FindPVPKey`(`GC_PVP`)/ `__FindGVGKey`(公会战对局)/ `IsGameMaster` / `IsWall` race 白名单 / m2dev `IsKiller` 位的 packet setter,名字颜色 §8.8、`IsInSafe` 的 `ATTRIBUTE_BANPK` 未接;留给增量 95+:§5.1 quest 38 tag、§5.3 fishing/dungeon/mount/observer UI、§4.1 costume/belt | 远服 40250 端到端可见性 / VID 复用 / affect 表现验收;远端实体 `knock_down` 接线、真实受击 / 击退 / `AFFECT_STUN` 清除时序;§3.5 命中帧绑定 / `ReservedMode` 的 CLICK_ITEM / CLICK_POSITION / USE_SKILL 分派;真实服务器 `GC_MOVE` `dwTime` 时基联机核验、`__IsEnableTCPProcess` 表情锁、`GC_SYNC_POSITION` 是否清队列(随对应 §3.x);§5.4 的 `GC_PVP` / 公会战对局 key setter、名字颜色、保护区地形属性;技能 / 目标语义与服务端权限仍需补齐 | | W3 玩法 UI / 输入 | 🟡 | 增量 53–62 已覆盖快捷栏、输入入口、物品窗口 / 拖放、tooltip 基础字段、任务条带、地图信号和帮助窗;增量 95 完成 Quest EventManager 常用标签分派;增量 96–98 完成 wear 槽位 / 装备门 / 部位遮挡;增量 99 完成远端 PC `RemotePlayerView` + `awPart[4]` 四字段换装;增量 101 完成 Quest 日志 `QUEST_MAX_NUM=5` 五行窗口、滚动起始索引、默认 icon、倒计时和任务行事件;增量 108 完成小地图任务信标越界夹圆周 + 方向箭头(世界箭头 / 屏幕边缘指示,对照基线 m2dev `PythonMiniMap.cpp:441-462`);增量 109 完成 quest 信件按钮条 `__ArrangeQuestButton` 多列定位 + 最新在前 + 组队窗 `xPos` 移位;增量 110 完成头顶名字颜色 `CInstanceBase::GetNameColorIndex` / `SetEmpireNameMode` 1:1(`name_color.gd` 纯静态 + `net_world.gd` 事件驱动 / ~2Hz 兜底重染,帝国名色默认关);增量 111 完成头顶公会名 / 称号色 `GetTitleColor` / 等级子标签 1:1(`text_tail.gd` 纯静态 `RefreshTextTail` / `UpdateTextTailLevel` / `EnablePKTitle` + `net_world.gd` `GuildTag` / `TitleTag` / `LevelTag` 三 `Label3D` 子节点);增量 112 完成头顶聊天 / 信息尾标 `RegisterChatTail` / `RegisterInfoTail` 1:1(`chat_tail.gd` 纯静态 `LivingTime` 5s 硬删 / `"名字: "` 前缀剥离 / SHOUT·COMMAND 排除 + `net_world.gd` 每 vid 一条 `"bubble"` `Label3D`、白 / 淡红按 chat/info、新增 `register_info_tail()`);增量 113 完成头顶名字「是否显示」1:1(`name_show.gd` 纯静态 `IsAlwaysShowName` / `__IsShowName`=`总是显示名字选项 or LALT` / `ShowAllTextTail` 的 `fDistanceFromPlayer < 3500` 平面距裁剪 / `ShowCharacterTextTail` 的 `isShow · !IsGuildWall · CanPickInstance` 资格判定 + `net_world._name_shown()` 四路组装:uigameoption `always_show_name` radio(经 `game_option_ui` 新信号)/ LALT / `net_play.target_changed` 目标强显 / chat 尾标 `bNameFlag` 强显);增量 114 完成头顶伤害飘字 `AddDamageEffect` / `ProcessDamage` 1:1(`damage_effect.gd` 纯静态:`DODGE\|BLOCK`→`MISS`/`TARGETMISS`、`CRITICAL`/`POISON` 分支参考端注释掉走普通数字、`bSelf`→`"damage_"`+`SELFDAMAGE`⇄`SELFDAMAGE2` 交替、`!bTarget`→`"nontarget_"` 不渲染、否则 `"target_"`、`while(damage>0)` 逐位 `%10`/`/10` index 0..7、`IsShowDamage` / `SetShowDamageFlag` 门 + `net_world._on_damage`/`_process_damage_queue` 每帧 pop 一条 + `game_option_ui`「伤害数字」radio 经 `display_option_changed` 接 `net_world.set_show_damage`;seam ⑪:单 `Label3D` 写整串 vs `damagevalue` dds 逐位特效、self 退化水平抖动、暴击不再放大 / 变色、全局单队列);增量 115 完成头顶目标框 `RecvTargetPacket` 收包分派 1:1(`target_board.gd` 纯静态:`RecvTargetPacket` 判定树(实例缺失→`CloseTargetBoard`、目标已死→不动框、`IsPC()\|IsBuilding()`→`CloseTargetBoardIfDifferent`、`CanViewTargetHP`(`IsStone()\|IsWoodenDoor()\|IsEnemy()`)→`SetHPTargetBoard`、否则→`CloseTargetBoard`)+ `game.py` `SetHPTargetBoard` 换 VID 先 `ResetTargetBoard` + `net_play._on_target_info` 由「任意有名字 VID 都显框」改为按判定树分派(PC / building 不显血条框、NPC / warp 关框);seam ⑫:poc 无 building / wooden-door 实体类型、`LCONTROL` 悄悄话捷径未接、`target_info` 由 C++ EntityStore 侧抽出不改 C++ 收包);增量 116 完成 `game.py OnRender` Picking 块分派 1:1 + 悬停角色强显名字(`pick_show.gd` 纯静态第 27 个 canonical:`IsPickedWindow` 门内 `chr.Pick()` 命中角色 → `ShowCharacterTextTail`、`targetBoard.GetTargetVID()` → `ShowCharacterTextTail`、`__IsShowName()` 分叉 `item.Pick()` ↔ `ShowAllTextTail` + `textTail.Pick()`、末尾 `SelectItemName`;`forced_name_vids` 收 `[hover, target]` 去重)+ `net_world._hover_vid` / `set_hover_vid` / `_name_shown()` forced 第 5 路 + `player_controller.hover_entity_changed` 信号(射线悬停拾取,压 UI / 悬停地面物品 → 0)+ `game_scene` 连线;seam ⑨:名字尾标随 chat 气泡下移 17px 未接;seam ⑩:`ArrangeTextTail` 屏幕空间去重叠仍缺(本增量只收口「悬停强显名字」一小块);增量 117 完成头顶名字尾标随聊天气泡上移 17px 1:1(`name_tail_layout.gd` 纯静态第 28 个 canonical:`UpdateShowingTextTail` :110-114 —— VID 有 chat 尾标且 `bNameFlag` TRUE 时名字屏幕 y = 气泡 y − 17px;poc 3D 适配为名字 `Label3D` 局部 y 顶到「气泡 y + 17*pixel_size」,气泡消失或「信息」尾标时回默认 2.1;`net_world._reposition_name_tag` 由 `_apply_chat_tail` / `_expire_chat_tails` 调用,seam ⑨ 收口);增量 118 完成目标框「看他国玩家」前置过滤 + §8.4 名字色 radio 接线 1:1(`target_board.hidden_as_other_empire(view_other_empire, same_empire)` 照抄 `uitarget.py TargetBoard.Open :189-193` —— game-option 关「看他国目标框」且目标异阵营 → `net_play._on_target_info` 在 `classify` 前 `_clear_target()` return;`net_play.set_view_other_empire_target()` + `net_world.set_empire_name_mode()` 经 `game_scene._on_display_option` 接 `game_option_ui` 的 `target_board` / `name_color` radio,开局各自 `display_value(...) == 1` 回填;修 `game_option_ui.gd` `target_board` 默认 0→1 对齐 `VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1`;§8.4 三显示 radio + LALT 至此全部接线);增量 119 完成 seam ⑦ 收口 —— 头顶「公会名 / 称号 / 等级」子标签随名字尾标纵向位移 1:1(`name_tail_layout.gd` 增 `name_tail_lift_delta` / `subtag_world_y` 照抄 `CPythonTextTail::ArrangeTextTail` :211/:228/:258/:283 —— 三子标签屏幕 y 全锚在已被 `UpdateShowingTextTail :114` 顶过的 `pTextTail->y` 上;`net_world` 把子标签默认局部 y 提为 `SUBTAG_DEFAULT_Y` 常量、`_reposition_name_tag` 顶完名字尾标后按同一 `lift` 平移 `GuildTag` / `TitleTag` / `LevelTag`,气泡过期 / 「信息」尾标时回默认;剩余 seam ⑦:子标签竖排 vs 参考屏幕空间逐像素水平排版仍近似);增量 120 完成 seam ⑩ 收口 —— `ArrangeTextTail` item 尾标去重叠 + `isIn` 1:1(`text_tail_arrange.gd` 纯静态第 29 个 canonical:`boxes_overlap`=`isIn` AABB 含边界 + `arrange_item_tails` 外层逐条 / 内层比对 / 命中顶到 `pCompareTextTail->y + yEnd + 5` / 从头重跑 / `MAX_ITERS` 20 封顶 + `box_*` 四边 `-w/2-2` `w/2+2` `-2` `h`;`ground_items._arrange_tags` 每帧把所有掉落名条锚点 `cam.unproject_position` 到屏幕、按字符数估框、跑去重叠、按 `Label3D.pixel_size` 折回局部 y,可见名条 < 2 回默认;`game_scene` 传相机 getter;剩余 seam ⑩:名条框按「字符数 * 7px」估宽 vs 参考 `GetTextSize` 精确像素、只对掉落 item 名条做);增量 121 完成 seam ⑦ 水平深化 —— 头顶「公会名 / 称号 / 等级」子标签逐像素水平排版 1:1(`name_tail_layout.gd` 增 `name_x_shift`(`fxAdd` 8/4/0)/ `title_x_offset`(`−nameW/2 − 8`)/ `level_x_offset`(有称号再 `− titleW`)/ `guild_name_x_offset` / `mark_x_offset` / `subtag_world_x` 照抄 `ArrangeTextTail` :211-320 非 EUROPE 分支;`net_world._layout_subtags_x` 在 `_apply_text_tail_to` 尾部设名字 / `TitleTag` / `LevelTag` 的 `.position.x`,`GuildTag` 留 0(poc 无公会徽记),文本宽按 `字符数 * 7px` 估;纵向位移(增量 117/119)与横向排布只分别写 `.position.y` / `.position.x` 互不干扰;剩余 seam ⑦:无公会徽记 mark 实体、字符数估宽 vs `GetTextSize`、三子标签竖排 + x 偏移的折衷 vs 参考同一屏幕行);增量 122 完成 seam ⑫ 收口 —— 目标框点击 `LCONTROL` 悄悄话捷径接 whisper 1:1(`net_play` 加 `whisper_requested` 信号 + `_lcontrol_down` + `set_lcontrol_down()`,`_on_pick` 在 `KIND_PC` 分支轮询 `target_board.whisper_on_ctrl_click`(`lcontrol_down and same_empire and not is_main and not is_building`,谓词增量 118 已备)→ 发 `whisper_requested`;`game_scene._unhandled_input` `KEY_CTRL` 按下 / 松开喂 `set_lcontrol_down(e.pressed)`、`net_play.whisper_requested` → `chat.start_whisper`(poc 版 `OpenWhisperDialog`=聊天输入预填 `"/w 名 "`);C++ picking 只对 PC 派发 `SetPCTargetBoard` 故 poc 也只在 PC 分支查;`is_building` 实参恒 false(poc 无门实体、:814 分支永不触发)、无独立 whisper dialog 窗口,seam ⑫ 末尾);增量 123 完成 seam ⑦ / ⑩ 收口 —— 头顶子标签 / 掉落名条排版的文本宽度从「字符数 * 约 7px」估算换成 `CGraphicTextInstance::GetTextSize` 的真实字体度量 1:1(新增 `text_metrics.gd` 第 30 个 canonical 纯静态 `text_size` / `width_of` / `label_width`,`net_world._layout_subtags_x` 的 `name_w` / `title_w` 与 `ground_items._arrange_tags` 的名条框 `w` 改走 `TextMetrics.label_width`,`SUBTAG_CHAR_W_PX` / `TAG_CHAR_W_PX` 保留仅作无字体兜底;`name_tail_layout.gd` / `text_tail_arrange.gd` 排布公式不动;剩余:单行文本高仍 15px 估、无公会徽记 mark 实体、三子标签竖排 vs 参考同一屏幕行);增量 124 完成 seam ⑦ / ⑩ 末尾 —— 掉落名条屏幕空间去重叠框的「单行文本高」从硬编码 15px 换成 `CGraphicTextInstance::GetTextSize` 的 h 真实字体度量 1:1(`text_metrics.gd` 增 `const FALLBACK_LINE_H_PX := 15.0` + `height_of` / `label_height`,`ground_items._arrange_tags` 的框高 `h` 改走 `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`,`TAG_LINE_H_PX` 保留仅作无字体兜底;`text_tail_arrange.gd`(`box_end_y(h) = h`)/ `name_tail_layout.gd` 排布公式不动;剩余:掉落名条 owner 未拆成独立 text instance、无公会徽记 mark 实体、三子标签竖排 vs 参考同一屏幕行);增量 125 完成 seam ⑩ 末尾 —— 带 owner 的掉落名条屏幕空间去重叠框按 `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)的「owner 独立度量 + `yEnd += yOwnerSize + 4`」1:1(`text_tail_arrange.gd` 扩第 29 个 canonical 加纯静态 `item_box_with_owner`——无 owner -> 纯名字框、有 owner -> `{maxf(name_w, owner_w-2.0), name_h+owner_h+4.0}` 使 `box_end_x(w)` 恰等于参考 :737 `fMAX`;`text_metrics.gd` 扩第 30 个 canonical 加 `string_width_like` / `string_height_like` 量某 `Label3D` 字体下的任意子串;`ground_items._on_added` 存 `item_name` / `item_owner` meta、`_arrange_tags` 把名字子串与 owner + `'s` 子串分别度量后走 `item_box_with_owner`;渲染仍单个合并 `"name (owner)"` `Label3D`;剩余:owner 未独立渲染成 CENTER 黄字、`strOwnership` 未接本地化语素、只对掉落 item 名条);相关离线回归通过 | CJK IME、窗口视觉 / `.sub` 资源、完整交易事务、复杂 mousemodule 规则、EventManager 逐帧 / `.msc` / 相机消费端、任务日志窗体自身多列 / 分页、tooltip 特殊分支 / 装备对比、`text_tail.gd` 屏幕空间血条整合、三子标签并到名字同一屏幕行 / 接公会徽记 mark(seam ⑦ 末尾;文本宽 / 高已分别由增量 123 / 124 换真实字体度量 `GetTextSize` 的 w / h 1:1)/ 名字与气泡合成单尾标链表(seam ⑨ 遗留)、掉落名条 owner 独立渲染成 CENTER 黄字 + `strOwnership` 接本地化语素(seam ⑩ 末尾;去重叠框度量已由增量 125 换 1:1)、伤害飘字 `damagevalue` dds 逐位特效(seam ⑪)、目标框 building / wooden-door 实体类型分类(`is_building` 恒 false;`LCONTROL` 悄悄话捷径已由增量 122 接 whisper)(seam ⑫ 末尾)、真服和真机仍未验收 | | W4 渲染 / 资产 / 音频 | 🟡 | 增量 50:§9.1 地图 BGM 选曲、§9.2 后台音量存恢复已落地;增量 51:AppFlow 绑定当前场景音频;增量 53:EffectLib 已读取 `.mse` 首个 `TextureFiles` 的实际 `.dds`,并映射位置、发射形状、方向 / 速度;增量 55:`audio.gd` 已接入 MilesLib 的监听器、200/1000 缩放、音量曲线、普通 / 环境 / 角色 3D 声道、角色 0.3 秒与 5000cm 限频、3D stop 和限幅淡出;增量 63:按 `MilesLib/Type.cpp` 解析 `.mss` 的 `SoundDataCount/SoundData%02d`,转换为 60 FPS 声音实例并接到 PlayerView / MobView 动作帧;增量 64:按 `EffectMesh.cpp` 解析 `.mde` EffectData/MDEData002,展开几何帧、UV、`.ifl` 贴图和 MeshElement 材质;增量 65:解析 `.mse` Light 组并生成范围 / 生命周期曲线,粒子多贴图支持帧延迟与顺逆序,旋转类型区分 NONE / TIME_EVENT / CW / CCW;增量 66:按 `EffectElementBase::GetPosition` 接入粒子 / 网格 / Light 的 MovingType 直接段与二次 Bezier 段,并映射球面边缘发射;增量 67:按 `Area.cpp` / `MapType.cpp` 接入 `AreaAmbienceData` + `.pra`,独立于对象渲染加载环境声源,Audio 实现 LOOP / ONCE / STEP、距离音量和卸载停止;增量 68:按 `MapUtil.cpp` 补齐 `.msenv` 参考缺省值、`IsDensity`、`Filter`、`BTextureRenderMode`、六面天空文件名和 `Reserved`,通过 `AssetResolver` 接入真实云层贴图到 `ProceduralSkyMaterial.sky_cover`,并修复 macOS 大小写敏感环境路径;增量 69:背景 / 角色方向光按参考端分成 `Sun` layer 1 与 `CharacterLight` layer 2,静态场景和 `Metin2Model` 分层,换图时清理两盏光;增量 70:真实 CapeDragonHead 五面天空贴图按 ClientVS22 面顺序接入 Godot `sky` shader,空 bottom 面回退渐变;增量 71:树 proxy 使用参考 `fWindStrength=0.2 / fWindRandom=0` 缺省;增量 72:真实 DawnMistWood 验证 texture-mode 五面天空与有限云层平面、CloudScale / Height / TextureScale / Speed 和参考颜色合成;`bgm_test`、`audio_miles_test`、`ambience_test`、`environment_test`、`skybox_test`、`world_probe`、`player_motion_test`、`mob_view_test`、`fx_test` + 生命周期 / 场景回归全绿 | TexAni 的随机起始帧 / 每粒子语义、完整 emitter advanced / cycle、完整时间轴 / Actor motion、Direct3D 材质 stage、SpeedTree、云层渐变过渡、滤色实际合成、风参数完整语义、lens flare、昼夜天气联动、声音资源逐项和真机验收 | | W5 打包 / 发布 / 验收 | 🟡 | 增量 53 已把 `assets` / `bgm` 复制进 `Contents/Resources`,支持 `MT_MAC_ARCHES=universal` 和 `lipo` 架构门禁,嵌套扩展先签名、App 后签名;默认凭据改为环境变量;脚本语法 / diff 检查通过 | 尚未执行完整 2.2G 资源导出;当前已检查的 dylib 仍为 arm64;正式 Developer ID / notarization、跨机启动和 Mac App 验收 | > **最新进展(增量 127,2026-09-04)**:本地可验证的四项尾部缺口已完成。`net_world.gd` 现用 > `CanvasLayer + ScreenHpBar` 做屏幕空间 HP 条,按相机投影更新并在死亡、镜头后方、屏幕外和换图时 > 隐藏 / 清理;`entity_rules.gd` / `net_play.gd` 支持建筑与木门分类(显式 `kind` / `building`、 > `bType=4`、race 13000 / 30111..30119),目标框不会再将它们误作 PC;`ground_items.gd` 的 > owner 行继承掉落物显式颜色或品阶颜色;`chat_ui.gd` 提供独立 `WhisperDialog`,LCONTROL 点同阵营 > 其他 PC 后可发送 / 取消 / ESC 关闭。`screen_hp_bar_test.gd`、`entity_rules_test.gd`、 > `target_board_test.gd`、`netplay_test.gd`、`chat_test.gd`、`p2b_test.gd` 已覆盖这些行为;全量 > C++/CTest 21/21、Godot 脚本 71/71 通过。剩余的是公会徽记 / 单尾标等表现 seam、完整世界玩法和 > 真实服务器 / 真机 / 发布包验收,不能把离线通过当作端到端验收。 > **W3→M2 最新进展(增量 128,2026-09-04)**:`net_world.gd` 已消费客户端公会会徽缓存的 > `guild_marks_ready` / `guild_mark_updated` 事件,为已存在角色动态创建 / 刷新 / 删除头顶 > `GuildMark` `Sprite3D`,并按参考 `mark_x_offset` / `guild_name_x_offset` 排布,聊天气泡 > 上移时会徽同步跟随;`guild_mark_tail_test.gd` 覆盖延迟下载、纹理更新、排布和公会移除。 > `quest_dialog.gd` 的 `[DONE]` 解析已无副作用,`done_event` 只在用户点击关闭按钮时触发, > `quest_test.gd` 覆盖解析与点击语义。当前离线回归为 CTest 22/22、Godot 74/74;仍未完成的 > 是单尾标链表、完整 `.msc` 电影播放器、Quest/fishing/dungeon/mount/observer 完整玩法、 > 真实服务器、真机、纯 pack、双架构和正式签名验收。 > **W4 最新进展(增量 100)**:EffectLib 已完成缺省 cycle、有限 LoopCount 发射停止、重播时钟复位和 > 粒子寿命隔离;OUTER / INNER 当前仍是显式标记的宽方向 fallback,不计作精确径向速度完成。完整 > 状态以本文变更记录和 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §6.3 / C.5 为准。 > **W3→M2 最新进展(增量 99)**:远端 `ch_type==0` PC 已由 `game_scene.gd` 创建 > `RemotePlayerView`,复用真实 `PlayerView` race / GR2 / MSA 模型;远端换装改为消费完整 > `awPart[ARMOR,WEAPON,HEAD,HAIR]`,`net_world.gd` 的四字段调用序已覆盖。所有职业 / race、 > 变身字段、远端真实服务器洪流和精确视觉仍待验收。详见 `CLIENT-GAP-FIX.md` §4.1 / C.5。 > **W3→M2 最新进展(增量 101)**:`quest_log.gd` 已按 ClientVS22 的 `QUEST_MAX_NUM=5` > 固定显示 5 条任务;超过 5 条显示滚动条并映射 `questShowingStartIndex`,任务行点击发送 > `-2147483648 + questIndex`,空 icon 路径使用 `season1/icon/scroll_open.tga` 默认图标。 > `quest_test.gd` 已覆盖 7 条任务、首尾滚动和事件参数。多列定位、世界箭头、屏幕边缘指示、 > 完整 `.sub` 图标视觉和真实任务生命周期仍待验收。详见 `CLIENT-GAP-FIX.md` §5.2 / C.5。 > **W3→M2 最新进展(增量 102)**:§5.3 钓鱼输入状态机已接入:钓鱼模式攻击键发送 > `CG_FISHING` 方向、普通攻击不再误发;方向键边沿和地面点击按参考顺序取消钓鱼,并保留 > `NEW_CancelFishing` 的 500ms 节流;特殊技能 123 已从快捷栏特殊技能门进入钓鱼入口。 > `GC_FISHING` START/REACT/STOP/SUCCESS/FAIL/FISH 会更新客户端意图状态。水面角度扫描、 > wrong-place 文案、鱼获物品落袋,以及副本 / 坐骑 / 观战者完整实现仍待验收。详见 > `CLIENT-GAP-FIX.md` §5.3 / C.5。 > **W3→M2 最新进展(增量 103)**:`Metin2World` 已按 ClientVS22 的 > `InstanceBaseMotion::GetFishingRot` 通过 `attr.atr` 水面属性,在角色前方 600cm 对左右 > `0..180°`、每 10° 扫描,并由 `NetWorld` 桥接给 `NetPlay`;选中的 heading 会发送给 > `CG_FISHING`,没有水面时发出 wrong-place 状态。`fishing_water_test.gd` 加载 A1 全 20 区块 > 找真实水面并通过。鱼获物品落袋、副本 / 坐骑 / 观战者完整玩法仍待验收。详见 > `CLIENT-GAP-FIX.md` §5.3 / C.5。 > **W3→M2 最新进展(增量 104)**:`ChatUI` 已按 ClientVS22 的 `RecvFishing` 分支接入 > `FISHING_WRONG_PLACE`、`FISHING_FAILURE`、`FISHING_UNKNOWN`,以及 item proto 类型驱动的 > `FISHING_NOTIFY1/2`、`FISHING_SUCCESS1/2` 文案;`GC_FISHING(SUCCESS)` 只切换动画状态, > 不再错误打印“鱼钩命中”。`chat_test.gd`、`gamescene_test.gd` 通过。参考端的真正背包增加 > 仍由独立 `GC_ITEM_*` 包驱动,本批次没有伪造本地落袋。详见 `CLIENT-GAP-FIX.md` §5.3 / C.5。 > **W3→M2 最新进展(增量 105)**:坐骑攻击门按 ClientVS22 的 `SHORSE::GetLevel/CanAttack` 与 > `CPythonPlayer::__CanAttack` 接入:普通坐骑 level 1 禁止攻击,新坐骑要求骑乘术 level 11 或 master; > 自动攻击路径同时改用实体 `mount_vnum` 判断,不再只检查不存在的 `mounting` 标志。`netplay_test.gd` > 已覆盖普通 / 新坐骑门禁。坐骑模型 / `MODE_HORSE*` 完整动作、移速和真实服务器验收仍待完成。详见 > `CLIENT-GAP-FIX.md` §5.3 / C.5。 > **W3→M2 最新进展(增量 106)**:小地图已按 ClientVS22 `CPythonMiniMap::AddObserver / > MoveObserver / RemoveObserver` 接入观战者紫色标记的一秒位置插值,初始快照可播种轨迹, > `world_reset` 清理观战者轨迹。`p9_test.gd` 已覆盖 ADD / MOVE / REMOVE / reset;副本入口、 > 观战自由视角及真实服务器完整观战生命周期仍待完成。详见 `CLIENT-GAP-FIX.md` §5.3 / C.5。 > **W3→M2 最新进展(增量 107)**:副本 `GC_DUNGEON DESTINATION_POSITION` 已按 > `PythonNetworkStreamPhaseGame.cpp:3875-3901`、`PythonPlayer.cpp:147-167,1255-1285` > 接入 `dungeon_state.gd`:保存全局厘米坐标、立即发出目标提示、按参考端 Manhattan `<10000cm` > 清除目标,并在 `world_reset` 时清理。HUD 增加目标距离 / 朝向提示;`TIME_ATTACK_START` 保持 > 参考端空分支,不虚构本地计时器。`dungeon_state_test.gd`、`gamescene_test.gd`、`p9_test.gd`、 > `netplay_test.gd` 均通过。DungeonBlock 的 `.mdatr` 碰撞、真实副本入口 / 结算窗口及真服验收仍待完成。 > 详见 `CLIENT-GAP-FIX.md` §5.3 / C.5。 > **总览表同步说明(增量 107)**:P1“世界玩法”中“副本入口 / 计时”仍是旧快照的概括;当前已完成的 > 是 `GC_DUNGEON` 目的地消费、参考端到达判据和 HUD 罗盘提示,`TIME_ATTACK_START` 在参考端本身为空分支, > 因此不实现本地计时器。仍待的是 DungeonBlock `.mdatr` 碰撞、真实副本入口 / 结算窗和真服验收。 > **W3→M2 最新进展(增量 108)**:§5.2 小地图任务信标已补齐世界箭头 / 屏幕边缘指示。信标越出 > 小地图半径时,`minimap.gd` 按 `m2dev PythonMiniMap.cpp:441-462` 的 `atan2 + radius*cos/sin` > 夹到圆周(留边常量 `SIGNAL_RIM_INSET=9.0` 对齐 `c_fMiniMapWindowRadius` 的 `- 9.0f`),并改画 > 指向真实目标的方向箭头(`signal_marker_geometry` + `_draw_signal_arrow`)。`p9_test.gd` 加 > 8 断言覆盖夹边朝向 / 半径 / 增删计数。夹边逻辑的对照基线落在 m2dev 完整版(40250/ClientVS22 > 的 PythonMiniMap.cpp 为裁剪版,缺该实现);多列定位、真实 `.sub` 图标、Atlas 同一信标夹边 > 仍待。详见 `CLIENT-GAP-FIX.md` §5.2 / §8.9 / C.5。 > **W3→M2 最新进展(增量 109)**:§5.2 quest 信件按钮条已按 `assets/root/interfacemodule.py:1357-1386` > 的 `__ArrangeQuestButton` 1:1 多列定位:`yPos = 170*H//600`、`yCount = (H-330)//63`、第 i 个 > `= (xPos + (i//yCount)*100, yPos + (i%yCount)*63)`,`xPos` 组队窗显示时由 20 移到 130。 > `quest_log.gd` 新增有序表 `_quest_button_order` 复刻 `questButtonList.insert(0, btn)`(最新在前、 > 同 index 提前),`_button_list` 从 VBox 单列改绝对定位 Control,组队窗可见性经 `set_party_probe` > 在 `_process` 轮询。`quest_test.gd` 加 16 断言覆盖换列坐标 / 顺序 / 组队窗移位 / 清空隐藏。 > 剩余:任务日志窗体 `_list` 自身多列 / 分页、真实 `.sub` 信件图标 / Flash 视觉。详见 > `CLIENT-GAP-FIX.md` §5.2 / §8.9 / C.5 增量 109。 > **W3→M2 最新进展(增量 110)**:§8.8 头顶名字颜色按 `CInstanceBase::GetNameColorIndex` > (`InstanceBaseEffect.cpp:525`)+ `GetIndexedNameColor`(:75)+ `SetEmpireNameMode`(:52) > 1:1 落地。新建 `project/name_color.gd`(纯静态 `RefCounted`):`NAMECOLOR_*` 枚举 > (`InstanceBase.h:216`,`EMPIRE_NUM = 4`)、`introloading.py:264` 注册的 RGB 槽表、 > `chrmgr.NAMECOLOR_MOB/NPC/PC` → `NORMAL_*` 的 `PythonCharacterManagerModule.cpp:767` 重映射, > `name_color_index(self_e, main_e, ctx)` 逐行照抄(IsPC → killer→PK → 存在主角实例且非本人时 > 决斗 `DUEL_CANNOTATTACK`→本色 / `DUEL_START` 命中对局→PVP、同阵营查 `__FindPVPKey` / > `__FindGVGKey`(guild≠0) → PVP、异阵营→PVP → 组队→PARTY → PC+GetEmpireID;NPC→NPC、 > 敌对 / 变身→MOB、其余 fallthrough→白)。`ctx` 与 `entity_rules.gd` / > `net_play._attack_ctx()` 同款(`main_vid` / `duel_mode` / `duel_opponents` / `pvp_pairs` / > `gvg_pairs` / `party_vids`)。`net_world.gd` 把 `Label3D.modulate` 从非 1:1 的 > `_state_tint` 占位(已删)改为专用名字色:spawn / info / pvp / duel / main_set 事件即时 > `_apply_name_color(vid)` + `_process` 每 0.5 s `_retint_all_names()` 兜底(覆盖组队增减 / > `set_empire_name_mode` 切换),`kind_resolver = net_play._entity_kind` 由 `game_scene.gd` > 注入以补 m2dev fork「NPC / 怪 bType 均为 0」的分类缺口(未注入则回退裸 `ch_type`=seam ⑥)。 > 帝国名色默认关(`constinfo.CHRNAME_COLOR_INDEX == 0`),运行期开关 > `net_world.set_empire_name_mode(bool)` 尚未接 game_option UI。`name_color_test.gd`(第 21 > 个 canonical,~55 断言)+ 全量离线回归通过。无 seam(40250 源含 `GetNameColorIndex`, > ISO-8859 编码,与 m2dev 逐字节一致;更正增量 109「对照基线 m2dev 完整版」的措辞)。 > 剩余:`text_tail.gd` 称号色 `GetTitleColor` / 公会名 / 屏幕空间血条 / 聊天气泡整合、 > 目标框 / 可攻击光标按 `ENameColor` + `is_attackable_instance` 驱动。详见 > `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 110。 > **W3→M2 最新进展(增量 111)**:§8.8 头顶「公会名 / 称号 / 等级」子标签按 > `CInstanceBase::GetTitleColor`(`InstanceBaseEffect.cpp:609`)+ `RefreshTextTail`(:664)+ > `AttachTextTail`(:621)+ `UpdateTextTailLevel`(:654)+ `CPythonTextTail::RegisterCharacterTextTail` > 的公会名分支(`PythonTextTail.cpp:509`)+ `EnablePKTitle`(:938)1:1 落地。新建 > `project/text_tail.gd`(纯静态 `RefCounted`):`TITLE_NUM=9` / `TITLE_NONE=4`、`g_akD3DXClrTitle` > 的 9 色槽(`introloading.py:287` `TITLE_COLOR_DICT` + `colorInfo.py:29`)、`s_kLevelColor` > = `Color(152/255, 255/255, 51/255)`、公会名色 `0xFFEFD3FF`、`"Noname"` 兜底; > `title_color(grade)`(grade≥`TITLE_NUM` → 白)、`refresh_title(alignment)`(复用 > `entity_rules.alignment_grade`,`grade==TITLE_NONE` 或 `EnablePKTitle` 关时不显示)、 > `refresh_level(level)`(`"Lv %d"`,`level>0` 且 title 开)、`refresh_guild(id, name)` > (`id==0` 不显示 / 空名→`"Noname"`)。`net_world.gd` 在 `_attach_nameplate` 加 > `GuildTag` / `TitleTag` / `LevelTag` 三个 `Label3D` 子节点,`_apply_text_tail(vid)`(spawn / > info 事件)+ `_process` 每 0.5 s `_retint_all_text_tails()` 兜底,`title_name_resolver` 由 > `game_scene.gd` 注入 `Locale.t("PVP_LEVEL%d")`(称号名 `localeInfo.TITLE_NAME_LIST`)。 > `text_tail_test.gd`(第 22 个 canonical,~50 断言)+ 全 62 个离线回归通过。无 seam > (40250 源含 `GetTitleColor` / `RefreshTextTail` / `UpdateTextTailLevel` / `RegisterTitleColor`); > seam ⑦:billboard 3D 竖排 vs 参考屏幕空间像素排版;seam ⑧:`title_name_resolver` 未注入 > 回退裸 `PVP_LEVEL%d` key。剩余:伤害数字 / 聊天气泡 / 屏幕空间血条整合、头顶信息显示开关 > (LALT + §8.4 三 radio)。详见 `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 111。 > **W3→M2 最新进展(增量 112)**:§8.8 头顶「聊天 / 信息」尾标按 > `CPythonTextTail::RegisterChatTail`(`PythonTextTail.cpp:581`)+ `RegisterInfoTail`(:624)+ > `TextTail_GetLivingTime` / `SetLivingTime`(:26/:28/:34,`gs_TextTail_LivingTime = 5000`, > `assert > 1000`)+ `UpdateAllTextTail` 的 `LivingTime < now` 硬删(:328)+ `IsChatTextTail`(:684)+ > `RecvChatPacket` 的派发规则(`PythonNetworkStreamPhaseGame.cpp:1301/:1321/:1372`)1:1 落地。新建 > `project/chat_tail.gd`(纯静态 `RefCounted`):`CHAT_COLOR`=白 / `INFO_COLOR`=`Color(1,.785,.785)`、 > `LIVING_TIME_DEFAULT=5000`、`EChatType` 0..8;`strip_name_prefix`(`strchr(buf,':') ? p+2 : buf`)、 > `line_for_tail`(TALKING/PARTY/GUILD/SHOUT/WHISPER 去 `"名字: "` 前缀,INFO/NOTICE 原样)、 > `wants_chat_tail`(除 SHOUT / COMMAND 全进)、`register_chat` / `register_info`(共用 `_chat_tails`, > 同 vid 互相覆盖,`bNameFlag` TRUE/FALSE)、`is_chat_text_tail`、`expire`。`net_world.gd` 的 > `_bubble` 改走 `chat_tail`,每 vid 一条 `"bubble"` `Label3D`(白 / 淡红按 chat/info), > `LivingTime` 到点由 `_expire_chat_tails`(`_process` 每 0.5 s)硬删;新增 `register_info_tail(vid, text)` > 公开方法对齐 `RegisterInfoTail`;`chat` 派发从 `t in [0,3,4,6]` 换成 `ChatTail.wants_chat_tail(t)` > —— 行为变化:SHOUT 不再弹头顶气泡(对齐 :1372),WHISPER / INFO / NOTICE 带 VID 时现在会弹。 > `game_scene._player_bubble` 寿命对齐 5s。`chat_tail_test.gd`(第 23 个 canonical,~45 断言)+ > 全 63 个离线回归通过。无 seam(40250 源含 `RegisterChatTail` / `RegisterInfoTail` / `RecvChatPacket`); > seam ⑨:气泡与名字 `Label3D` 各自 billboard 并存,名字不强制顶出 / 不随气泡下移 17px, > `bNameFlag` 只存不用。剩余:伤害数字(`GC_DAMAGE_INFO` / `AddDamageEffect`)/ 屏幕空间血条整合、 > `TextTail_SetLivingTime` 运行期钩子。详见 `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 112。 > **W3→M2 最新进展(增量 113)**:§8.4 / §8.6 头顶名字「是否显示」按 > `40250/ClientVS22/source` 补 1:1。新建 `project/name_show.gd`(第 24 个 canonical 起点,纯静态 > `extends RefCounted`,与 `name_color.gd` / `text_tail.gd` / `chat_tail.gd` 同套路)照抄 > `CPythonSystem::IsAlwaysShowName` / `SetAlwaysShowNameFlag`(`iFlag == 1`)、配置行 > `ALWAYS_VIEW_NAME`(`DEFAULT_VALUE_ALWAYS_SHOW_NAME = true`)、`game.py::__IsShowName()` > =`IsAlwaysShowName() or ShowNameFlag`、`CPythonTextTail::ShowAllTextTail` 的 > `fDistanceFromPlayer < 3500.0f` 平面距裁剪、`ShowCharacterTextTail` 的 > `pOwner->isShow()` / `!IsGuildWall()` / `CanPickInstance()` 资格判定、`UpdateAllTextTail:74` > 的 chat 尾标 `bNameFlag` 强显(不受 3500 限制)。`net_world.gd`:`_names_visible`(默认 true) > 拆成 `_always_show_name`(默认 true,对齐参考默认值与 `game_option_ui` 默认 1)+ `_alt_held` > + `_target_vid`;新增 `_name_shown()` 四路组装(`_always_show_name` / `_alt_held` / `_target_vid` > / chat `bNameFlag`)+ `set_always_show_name()` / `set_target_vid()`,`_process` ~2Hz 节流兜底 > 距离变化,LALT / 选项 / 选中 / 尾标增删即时刷新。`game_option_ui.gd` 加 `display_option_changed(key, value)` > 信号 + `display_value()`(对齐 `uigameoption RefreshAlwaysShowName`);`game_scene.gd` 接 > `net_play.target_changed` → `set_target_vid`、`display_option_changed` → `set_always_show_name`。 > `name_show_test.gd`(第 24 个 canonical,~35 断言)+ 全 64 个离线回归通过。 > 行为变化:3500–20000cm 的实体有节点但无名字(原先全显);主角位置未知时 fail-open(同 > `_is_visible_entity`);配置总写 `ALWAYS_VIEW_NAME` 键(参考仅在非默认时落盘)。 > seam ⑩:悬停 `chr.Pick()` 强显、屏幕空间 `ArrangeTextTail` 防重叠、`CanPickInstance` 仅按 > 死透近似、参考 `UpdateDistance` 的 `-ownerY` 是渲染帧 / 像素帧混用产物(本移植统一 `pos_cm` 帧)。 > `ShowName()` 的 `playerGauge.EnableShowAlways()` / `SetQuickPage` 两步未移植(blob / quickbar UI)。 > 详见 `CLIENT-GAP-FIX.md` §8.4 / §8.6 / §8.8 / §5.4 / C.5 增量 113。 > **W3→M2 最新进展(增量 114)**:§8.8 / §5.4 头顶伤害飘字按 > `40250/ClientVS22/source` 补 1:1。新建 `project/damage_effect.gd`(第 25 个 canonical,纯静态 > `extends RefCounted`,与 `name_color.gd` / `text_tail.gd` / `chat_tail.gd` / `name_show.gd` 同套路) > 照抄 `enum DamageFlag`(`InstanceBase.h:300`:`NORMAL 1<<0` … `CRITICAL 1<<5`)、 > `TPacketGCDamageInfo`(`Packet.h:1943`:`header / dwVID / flag / int damage`)、 > `RecvDamageInfoPacket`(`PythonNetworkStreamPhaseGame.cpp:2401`:`bSelf = 目标是主实例`、 > `bTarget = 目标是 m_pInstTarget`、`damage >= 0` 才 `AddDamageEffect`(`< 0` 记 `TraceError`))、 > `CInstanceBase::AddDamageEffect`(`InstanceBaseEffect.cpp:87`:仅 `CPythonSystem::IsShowDamage()` > 才 `m_DamageQueue.push_back`)、`ProcessDamage`(:100,`InstanceBase.cpp:1910` 每帧调一次, > `m_DamageQueue` 每帧 `pop_front` 一条:`(flag & DODGE) || (flag & BLOCK)` → > `bSelf ? EFFECT_DAMAGE_MISS : EFFECT_DAMAGE_TARGETMISS` + `return`;`CRITICAL` / `POISON` 的 > `CreateEffect` 参考端均注释掉 → 落普通数字(无独立暴击视觉);`bSelf` → `"damage_"` + > `EFFECT_DAMAGE_SELFDAMAGE` / `SELFDAMAGE2` 按 `m_bDamageEffectType` 交替;`bTarget == false` > → `"nontarget_"` + `return`(**不渲染**);否则 `"target_"`;`while (damage > 0)` 逐位 > `damage % 10` / `damage /= 10`,`index 0..7`(`> 7` `TraceError` break),每位一张 > `d:/ymir work/effect/affect/damagevalue/.dds` 按 `FONT_WIDTH(30) * index` 相机右向铺开)、 > `CPythonSystem::IsShowDamage` / `SetShowDamageFlag`(`PythonSystem.cpp:337` / `:342`, > `bShowDamage = iFlag == 1`,默认 `true`,配置行 `SHOW_DAMAGE` = `atoi == 1`,仅非默认落盘)。 > `net_world.gd`:`_on_damage` 照 `AddDamageEffect`(`damage_number` 信号仍无条件发=本移植扩展 > 供战斗日志 / 测试;飘字本体 `_show_damage` 门后入 `_damage_queue`,元素 `{vid, amount, flag, > self, target}`)、新增 `_process_damage_queue()` 由 `_process` 每帧调一次照 `ProcessDamage` > 分类,`show == false`(非 self 非 target 数字)直接 return,其余一块 billboard `Label3D` 写 > `str(amount)` / `"MISS"`;`set_show_damage(on)`、`clear_for_map_change` 清 `_damage_queue`。 > `game_scene.gd` 接 `game_option_ui.display_option_changed("show_damage")` → `net_world.set_show_damage`, > 初值 `display_value("show_damage") == 1` 回填。`damage_effect_test.gd`(第 25 个 canonical, > ~50 断言)+ 全 65 个离线回归通过。 > 行为变化:`BLOCK` 也算 MISS(原占位只判 DODGE);非 self 非 target 的数字伤害不再显示; > 暴击不再金色 / 放大(对齐该 40250 build);受 `IsShowDamage()` 门控;每帧只 pop 一条。 > seam ⑪:单 `Label3D` 写整串 vs `damagevalue` dds 逐位特效 / 相机右向像素铺排;self > `SELFDAMAGE`⇄`SELFDAMAGE2` 退化为落字水平抖动符号交替(±0.3);`_damage_queue` 全局单条 > vs 参考每实例一条(同帧多 VID 受击会分帧显示);`_show_damage` 配置总写 `SHOW_DAMAGE` 键。 > 详见 `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 114。 > **W3→M2 最新进展(增量 115)**:§5.4 / §8.8 头顶目标框(TargetBoard)收包分派按 > `40250/ClientVS22/source` 补 1:1。新建 `project/target_board.gd`(第 26 个 canonical,纯静态 > `extends RefCounted`,与 `name_color.gd` / `text_tail.gd` / `chat_tail.gd` / `name_show.gd` / > `damage_effect.gd` 同套路)照抄 `TPacketGCTarget`(`Packet.h:1937`:`header / dwVID / bHPPercent`)、 > `CPythonNetworkStream::RecvTargetPacket`(`PythonNetworkStreamPhaseGame.cpp:2424`)的判定树: > `pInstPlayer && pInstTarget` 都在且 `!IsDead()` 时 —— `IsPC() || IsBuilding()` → > `CloseTargetBoardIfDifferent(dwVID)`(:2440)、否则 `pInstPlayer->CanViewTargetHP(*pInstTarget)` > → `SetHPTargetBoard(dwVID, bHPPercent)`(:2442)、否则 `CloseTargetBoard()`(:2444); > `m_pInstTarget = pInstTarget` 仅在该 `!IsDead()` 块内(:2447);实例缺失 → `CloseTargetBoard()` > (:2453);目标已死透 → 整块跳过(不动框)。`CInstanceBase::CanViewTargetHP` > (`InstanceBase.cpp:2293`)= `victim.IsStone() || victim.IsWoodenDoor() || victim.IsEnemy()`。 > `game.py`:`SetHPTargetBoard`(:829,`vid != GetTargetVID()` → `ResetTargetBoard()` + > `SetEnemyVID(vid)`,再 `SetHP` + `Show`)、`CloseTargetBoardIfDifferent`(:837,`vid != GetTargetVID()` > 才 `Close()`)、`CloseTargetBoard`(:841)、`SetPCTargetBoard`(:804,按住 `LCONTROL` 且 > `IsSameEmpire` 且非本人且非 building → `OpenWhisperDialog`)。`target_board.gd` API: > `enum Action { CLOSE, CLOSE_IF_DIFFERENT, SET_HP, NONE }`、`can_view_target_hp` / `classify` / > `updates_current_target` / `needs_reset` / `should_close_if_different` / `whisper_on_ctrl_click` / > `clamp_hp_pct`。`net_play.gd`:`_on_target_info(vid, hp%)` 由「任意 `vid != 0` 且有名字都 > `hud.set_target`」改为按 `classify` 分派 —— `is_pc = _entity_kind == KIND_PC`、 > `is_building = false`(seam ⑫)、`can_view = KIND_STONE 或 KIND_MONSTER`;`CLOSE` / > `CLOSE_IF_DIFFERENT`(`vid != _target_vid` 才) → `_clear_target()`;`SET_HP` → 换 VID 先 > `_target_vid = vid` + `target_changed.emit` 再 `hud.set_target(名字, clamp_hp_pct(hp%))`; > `NONE` → 不动。`target_board_test.gd`(第 26 个 canonical,~35 断言)+ 全 66 个离线回归通过。 > 行为变化:此前对任意有名字 VID(含 NPC)都显目标框;现按判定树 —— PC / building 目标不再显 > 血条框(只在框上是别的 VID 时清旧框)、NPC / warp 等不可看血目标一律关框、目标已死透不动框、 > `hp%` 钳到 0..100。`_change_target_to_picked_instance`(点击选目标,PythonPlayerInput 路径) > 不在本批次范围,维持原逻辑。 > seam ⑫:poc 无 building / wooden-door / stone-door 实体类型(`is_building` 恒 false、木门分类 > 缺);`target_info(vid, hp%)` 由 C++ `extension/src/net/m2_client.cpp:3129/3633` 从 EntityStore > 抽出,本移植不改 C++ 收包(对齐「纯 GDScript」约束);`LCONTROL` 悄悄话捷径谓词 > `whisper_on_ctrl_click` 已备,`net_play` 暂不接(无 whisper dialog 钩子)。 > 详见 `CLIENT-GAP-FIX.md` §5.4 / §8.8 / C.5 增量 115。 > **W3→M2 最新进展(增量 116)**:§8.8 / §5.4 `game.py GameWindow.OnRender()` 的「Picking」块 > (:1519)分派按 `40250/ClientVS22/source` 补 1:1,并把「悬停角色强显名字」接进 poc。新建 > `project/pick_show.gd`(第 27 个 canonical,纯静态 `extends RefCounted`,与 `name_color.gd` / > `text_tail.gd` / `chat_tail.gd` / `name_show.gd` / `damage_effect.gd` / `target_board.gd` 同套路) > 照抄:`textTail.UpdateAllTextTail()`(:1531)→ `wndMgr.IsPickedWindow(self.hWnd)` 门(:1533)内 > `chr.Pick()`(:1535)命中角色(!= -1)→ `textTail.ShowCharacterTextTail(idx)`(:1537)、 > `targetBoard.GetTargetVID()` != 0 → `ShowCharacterTextTail(targetVID)`(:1539)、`not __IsShowName()` > → `item.Pick()` → `ShowItemTextTail`(:1543/:1546);门外 `__IsShowName()` → `ShowAllTextTail()` > + `PickingItemIndex = textTail.Pick(x, y)`(:1552/:1554);末尾 `UpdateShowingTextTail` + > `ArrangeTextTail`(:1557/:1558)、`PickingItemIndex != -1` → `SelectItemName`(:1560)。 > `pick_show.gd` API:`NO_PICK := -1`、`char_pick_active(is_picked_window)`、 > `forced_name_vids(is_picked_window, pick_char_vid, target_vid)`(收 `chr.Pick()` 命中 + 目标框 > VID,去重、顺序 `[hover, target]`、非窗口 → `[]`)、`should_pick_item(is_picked_window, > is_show_name)`= `and not`、`should_show_all_names` / `should_pick_text_tail`= `is_show_name`、 > `should_select_item_name`= `!= NO_PICK`。poc 侧仿增量 113 的 `_target_vid` forced 路数给 > `net_world._name_shown()` 加第 5 路 forced `_hover_vid`:`net_world.set_hover_vid(vid)`(值变才刷 > prev / vid 两节点名字可见性,仿 `set_target_vid`)由 `player_controller._update_hover_cursor()` > 三出口喂 —— 鼠标压 UI → 0、悬停地面物品 → 0、射线命中 `pickable` → `best.get_meta("vid")`、 > 无命中 → 0;新增 `hover_entity_changed(vid)` 信号(值变去抖),`game_scene` 紧挨 > `net_play.target_changed → net_world.set_target_vid` 那行连 `pc.hover_entity_changed → > net_world.set_hover_vid`。`pick_show_test.gd`(第 27 个 canonical,~24 断言)+ 全 67 个离线回归 > 通过(两次运行 PASS=67 FAIL=0)。行为变化:此前只有 `targetBoard` 目标(增量 113)forced 强显 > 头顶名字;现悬停到的角色(`player_controller` 射线拾取,`pickables` 带 `vid` meta)也 forced > 强显 —— 即便未开「总显名字」且超 3500 平面距。悬停命中沿用既有 `_ray_pick_t`(cursor 用的同 > 一套),无新增射线开销。`net_play._on_target_info`(增量 115)不受影响。 > seam ⑨:名字尾标 y = chat 气泡 y − 17px(`PythonTextTail.cpp:112-114`)未接。seam ⑩: > `ArrangeTextTail` 的屏幕空间尾标去重叠排布仍缺 —— poc 名字牌是 3D billboard,靠深度天然分层, > 未做 2D 投影后的挤开;本增量只收口其中「悬停角色强显名字」一小块。近似:参考里 `chr.Pick()` > 强显与 `targetBoard` 强显都在 :1533 的 `IsPickedWindow` 门内,poc 把 `_target_vid` / `_hover_vid` > 都当作无条件 forced,`_hover_vid` 靠 `player_controller` 压 UI / 悬停物品时置 0 部分等效该门。 > 详见 `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 116。 > **W3→M2 最新进展(增量 117)**:§8.8 seam ⑨ 收口 —— 头顶名字尾标「有聊天气泡时上移贴气泡」 > 按 `40250/ClientVS22/source` 补 1:1。`CPythonTextTail::UpdateShowingTextTail` > (`PythonTextTail.cpp:88`)在角色名字尾标投影完屏幕坐标后(:104),`m_ChatTailMap.find(dwVirtualID)` > 命中(:106/:110)且那条 `bNameFlag` 为 TRUE(:112,= `RegisterChatTail` 的「聊天」气泡,非 > `RegisterInfoTail` 的「信息」气泡)→ `pTextTail->y = pChatTail->y - 17.0f`(:114,屏幕 y 向下 > 减 17px = 名字上移一行贴气泡上沿)。名字与气泡默认同世界高(`GetHeight()+10`,:519/:581),全靠 > 这条 −17px 分开。新建 `project/name_tail_layout.gd`(第 28 个 canonical,纯静态 `extends > RefCounted`,与 `name_color.gd` / `text_tail.gd` / `chat_tail.gd` / `name_show.gd` / > `damage_effect.gd` / `target_board.gd` / `pick_show.gd` 同套路):`NAME_OFFSET_ABOVE_BUBBLE_PX > := 17.0`、`name_follows_bubble(has_chat_tail, chat_name_flag)`= `and`(:110 命中 && :112 > `bNameFlag`)、`name_tail_screen_y(default_y, bubble_y, follows)`= 参考端字面 `bubble_y − 17`、 > `name_tail_world_y(default_world_y, bubble_world_y, follows, px_size)`= poc 3D 适配(屏幕 y > 向下 −17px → 世界 y 向上 `bubble_world_y + 17*px_size`)。poc 侧 `net_world._reposition_name_tag(n)` > 由 `_apply_chat_tail`(冒气泡)/ `_expire_chat_tails`(气泡过期)调用,读 `_chat_tails[vid].name_flag` > (`RegisterChatTail` TRUE / `RegisterInfoTail` FALSE)决定名字 `Label3D` 局部 y 在 > `NAME_TAG_DEFAULT_Y`(2.1)与「`BUBBLE_TAG_Y` 2.35 + 17*pixel_size」之间切换;`_attach_nameplate` > 用 `NAME_TAG_DEFAULT_Y`、`_apply_chat_tail` 用 `BUBBLE_TAG_Y`。`name_tail_layout_test.gd`(第 28 个 > canonical)+ `chat_test.gd` 扩断言(冒气泡后名字 y == 气泡 y + 17*pixel_size 且 > 气泡 y, > `register_info_tail` 覆盖 → 名字回 2.1)+ 全 68 个离线回归通过(PASS=68 FAIL=0)。剩余 seam ⑨: > 气泡与名字仍是两个并存 `Label3D` 节点(非参考单尾标链表)、子标签 GuildTag / TitleTag / > LevelTag 不随名字上移 = seam ⑦ 遗留;换算按名字 `Label3D` 的 `pixel_size`(0.006),与参考的 > 字体无关常量 17 对齐。seam ⑩:`ArrangeTextTail` 屏幕空间去重叠仍缺。 > 详见 `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 117。 > **W3→M2 最新进展(增量 118)**:§5.4 / §8.8 / §8.4 目标框「看他国玩家」前置过滤 + 名字色 > radio 接线按 `40250/ClientVS22/source` + `assets/root` 补 1:1。`root/uitarget.py` > `TargetBoard.Open(vid, name)`(:189-193)在整个 `RecvTargetPacket` 判定之前先过滤: > `if not constInfo.GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(): if not player.IsSameEmpire(vid): > self.Hide(); return`(`constinfo.VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1` 默认, > `uigameoption.py` 的 `target_board_view` / `target_board_no_view` radio 经 > `constInfo.SET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(index)`)。`project/target_board.gd` > 加纯静态谓词 `hidden_as_other_empire(view_other_empire, same_empire)`= > `not view_other_empire and not same_empire`(不按 `is_pc` 收窄 —— 忠于参考;empire 0 的 > 怪 / NPC / 石头在 `EntityRules._is_same_empire` 里恒「同阵营」故永不被挡)。`net_play` > 加 `_view_other_empire_target`(默认 true)+ `set_view_other_empire_target(on)`, > `_on_target_info` 在 `TargetBoard.classify(...)` 之前 `if exists and > TargetBoard.hidden_as_other_empire(_view_other_empire_target, > EntityRules._is_same_empire(_main_entity(), e)): _clear_target(); return`。同批把 §8.4 > 「名字色」radio(`name_color_normal` / `name_color_empire`)接进 > `game_scene._on_display_option` → `net_world.set_empire_name_mode(v == 1)`(帝国名色调色板 > 算法增量 110 已 1:1),并修 `game_option_ui.gd` 的 `target_board` 默认值 0 → 1(对齐 > `VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1`)。`game_scene` 开局两处 `display_value(...)` > 回填。`target_board_test.gd`(+4 断言)/ `netplay_test.gd`(+8b 段:看他国框开→异阵营 PC > 目标框保留、关→前置过滤清框、关不影响怪)/ `system_menu_ui_test.gd`(+`name_color_empire` / > `target_board_no_view` radio → `_display` + cfg 持久化 + `display_option_changed` 信号)+ > 全 68 个离线回归通过(PASS=68 FAIL=0,无新增测试文件)。§8.4 三个显示 radio + §8.6 LALT > 至此全部接线完毕。seam ⑫(延续):poc 无 building / wooden-door 实体类型、目标框点击 > `LCONTROL` 悄悄话捷径未接。 > 详见 `CLIENT-GAP-FIX.md` §5.4 / §8.8 / §8.4 / C.5 增量 118。 > **W3→M2 最新进展(增量 119)**:§8.8 seam ⑦ 收口 —— 头顶「公会名 / 称号 / 等级」子标签 > 随名字尾标纵向位移按 `40250/ClientVS22/source` 补 1:1。`CPythonTextTail::ArrangeTextTail` > (`PythonTextTail.cpp:151`)逐条角色名字尾标(:211)把公会名(:228,`pTextTail->y - > c_fyGuildNamePosition`,`c_fyGuildNamePosition = 15.0f` :21)/ 称号(:258,同 `pTextTail->y`)/ > 等级(:283,同 y)三个子标签的屏幕 y 全部锚在 `pTextTail->y` 上 —— 而这个 y 已被 > `UpdateShowingTextTail :114` 顶过 −17(增量 117)。所以名字尾标因「聊天」气泡上移时,三个 > 子标签整体跟随同一段纵向位移;增量 117 只让名字尾标本身跟随,子标签当时留作 seam ⑦ 遗留。 > `project/name_tail_layout.gd` 增 `GUILD_NAME_ABOVE_NAME_PX := 15.0`、 > `name_tail_lift_delta(default_world_y, bubble_world_y, follows, px_size)`(= > `name_tail_world_y(...) - default_world_y`,follows 时为净上移量、否则 0)、 > `subtag_world_y(default_subtag_y, name_tail_lift_delta)`(=二者相加)。`net_world` 把 > `GuildTag` / `TitleTag` / `LevelTag` 默认局部 y 提为 `SUBTAG_DEFAULT_Y` > (2.34 / 1.86 / 1.74)常量,`_reposition_name_tag` 顶完名字尾标后按同一 `lift` 平移三个 > 子标签,气泡过期 / 「信息」尾标(`bNameFlag` FALSE)时回默认。`name_tail_layout_test.gd` > (+「4. seam ⑦ 收口」段:`name_tail_lift_delta` / `subtag_world_y` 数值、平移后相对间距不变、 > lift 0 回默认)/ `chat_test.gd`(+真 `NetWorld` 断言:冒聊天气泡后三子标签 `position.y` == > 默认 + lift、覆盖成信息尾标后回默认)+ 全 68 个离线回归通过(PASS=68 FAIL=0,无新增测试文件)。 > 剩余 seam ⑦:子标签竖排 vs 参考屏幕空间逐像素水平排版(公会名上方 15px、称号 / 等级同 > 水平行左偏)仍近似。剩余 seam ⑨:气泡与名字仍是两个并存 `Label3D`(非参考单尾标链表)。 > 详见 `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 119。 > **W3→M2 最新进展(增量 120)**:§8.8 seam ⑩ 收口 —— `CPythonTextTail::ArrangeTextTail` 的 > item 尾标去重叠 + `isIn` 按 `40250/ClientVS22/source` 补 1:1。`PythonTextTail.cpp:153` 的 > `m_ItemTextTailList` 块(:160-190):逐条掉落名条与其它每条比 `isIn`(:489-507, > `[x+xStart,x+xEnd]×[y+yStart,y+yEnd]` 的 AABB,`<=`/`>=` 含边界),命中就把「自己」顶到 > `pCompareTextTail->y + pCompareTextTail->yEnd + yTemp`(`yTemp = 5`),内层从头重跑、 > `LimitCount >= 20` 封顶。新增第 29 个 canonical `project/text_tail_arrange.gd` > (`extends RefCounted` 全静态):`PUSH_GAP := 5.0` / `MAX_ITERS := 20`、`box_start_x/end_x/ > start_y/end_y`(`-w/2-2` / `w/2+2` / `-2` / `h`,`PythonTextTail.cpp:810-813`)、 > `boxes_overlap(a, b)`(=`isIn`)、`arrange_item_tails(tails: Array) -> Array`(外层逐条 i、 > 内层逐条 j 跳过自身、命中 `ys[i] = ys[j] + box_end_y(h_j) + PUSH_GAP` 后 j 归零 limit++、 > `limit >= MAX_ITERS` break;`pCompare` 用已排布后的 `ys[j]`)。poc 侧 > `project/ui/ground_items.gd` 加 `_camera_getter` + 常量 `TAG_BASE_Y 0.75` / `TAG_CHAR_W_PX 7` / > `TAG_LINE_H_PX 15`,`_process` 尾部 `_arrange_tags()` 把所有掉落名条世界锚点 > `cam.unproject_position` 到屏幕、按字符数估框、跑 `arrange_item_tails`、 > `position.y = TAG_BASE_Y - (arranged_y - y0) * pixel_size`,可见名条 < 2 直接回默认; > `project/game_scene.gd` 把 `func() -> Camera3D: return cam` 传进 `ground_items.setup` 第 6 参。 > `text_tail_arrange_test.gd`(新:常量、`box_*` 四边、`boxes_overlap` x/y 都叠 / 边界相接 / > x·y 分离、`arrange_item_tails` 单条 / 不叠 / 两条同位 → [121, 100] / 三条同位 → [121, 142, 100] > 且两两不叠 / 40 条全同位不死循环)/ `p2b_test.gd`(+真 `Camera3D` + 真 `GroundItems`:同位两 > 掉落名条投影后靠前一条按 `(15+5)*0.005 = 0.1` 下推、靠后留默认、拉开距离回默认)+ 全 69 个 > 离线回归通过(PASS=69 FAIL=0)。剩余 seam ⑩:名条框按「字符数 * 7px」估宽 vs 参考 > `GetTextSize` 精确像素;只对掉落 item 名条做(与参考 `m_ItemTextTailList` / > `m_CharacterTextTailList` 分表一致,角色名字尾标不参与)。 > 详见 `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 120。 > **W3→M2 最新进展(增量 121)**:§8.8 seam ⑦ 水平深化 —— `CPythonTextTail::ArrangeTextTail` > 角色块(`PythonTextTail.cpp:211-320` 非 EUROPE 分支)的「公会名 / 称号 / 等级」子标签逐像素 > 水平排版按 `40250/ClientVS22/source` 补 1:1。参考端:`fxAdd` = 0(无称号无等级,:236)/ 8 > (有称号,:242)/ 4(仅等级无称号,:288);名字文本 CENTER 对齐画在 `x + fxAdd`(:320); > 称号 CENTER 对齐画在 `x − iNameWidth/2 − fxAdd`(:258);等级 RIGHT 对齐,有称号时 > `x − iNameWidth/2 − fxAdd − iTitleWidth`(:282)、仅等级时 `x − iNameWidth/2 − fxAdd`(:312); > 公会名 / 徽记仅当 `pMarkInstance && pGuildNameInstance` 时才由 ArrangeTextTail 重定位 > (:220),`iImageHalfSize = markW/2 + c_fxMarkPosition(1.5)`(:224),公会名 `x + iImageHalfSize` > (:228)、徽记 `x − guildW/2 − iImageHalfSize`(:227)。`project/name_tail_layout.gd` 增 4 常量 > (`NAME_X_ADD_TITLE 8.0` / `NAME_X_ADD_LEVEL_ONLY 4.0` / `MARK_X_PAD 1.5` / > `MARK_ABOVE_NAME_PX 26.0`)+ 6 静态方法(`name_x_shift(has_title, has_level)`=fxAdd 8/4/0、 > `title_x_offset(name_w)`=`−name_w/2 − 8`、`level_x_offset(name_w, title_w, has_title)`=有称号 > 再减 `title_w`、`guild_name_x_offset(mark_w)`=`mark_w/2 + 1.5`、`mark_x_offset(guild_w, mark_w)`、 > `subtag_world_x(px_offset, px_size)`=`px_offset * px_size` 线性折回、符号保留左负右正)。 > poc 侧 `net_world.gd` 加 `const SUBTAG_CHAR_W_PX := 7.0`(无字体度量 -> 按「字符数 * 约 7px」 > 估文本宽,与 `ground_items` 的 `TAG_CHAR_W_PX` 同源)、`_apply_text_tail_to` 尾部调 > `_layout_subtags_x(n, ttag, ltag)`:由 `.text != ""` 判 `has_title` / `has_level`,设名字 > `Label3D.position.x = subtag_world_x(name_x_shift(...), pixel_size)`、`TitleTag.position.x` / > `LevelTag.position.x` 同法(无则回 0)、`GuildTag.position.x = 0`(poc 无公会徽记实体,参考的 > `pMarkInstance && pGuildNameInstance` 门恒不满足)。纵向位移(增量 117/119 写 `.position.y`) > 与横向排布(增量 121 写 `.position.x`)互不干扰。`name_tail_layout_test.gd`(+「5. seam ⑦ > 水平排版」段:4 常量、`name_x_shift` 四组、`title_x_offset(40)==−28` 且 `< 0`、 > `level_x_offset(40,30,true)==−58` / `(40,30,false)==−24` 且有称号时比称号更左、 > `guild_name_x_offset(20)==11.5` / `(0)==1.5`、`mark_x_offset(60,20)==−41.5`、`subtag_world_x` > 线性 / 符号 / 0、组合 `lx(−58) < tx(−28) < nx(+8)`)/ `chat_test.gd`(+真 `NetWorld`:Charlie > vid 3000 有公会 7 / alignment 15000 / level 42 -> `GuildTag.text=="Camelot"` 且 > `position.x≈0`、名字 `position.x≈8*pixel_size`、`TitleTag.position.x<0` 且精确 > `≈(−(7*7/2)−8)*pixel_size`、`LevelTag.position.x < TitleTag.position.x`;改 alignment=0 后 > `TitleTag.text=="" 且 position.x≈0`、名字 `position.x≈4*pixel_size`、`LevelTag.position.x` > 精确 `≈(−(7*7/2)−4)*pixel_size`)+ 全 69 个离线回归通过(PASS=69 FAIL=0,无新增测试文件)。 > 剩余 seam ⑦:poc 无公会徽记 / 徽记 mark 实体(参考 `pMarkInstance && pGuildNameInstance` 门 > 恒不满足,`GuildTag` x 恒 0);文本宽按「字符数 * 7px」估 vs 参考 `GetTextSize` 精确像素; > 三子标签仍竖排叠 x 偏移 vs 参考同一屏幕行(poc 可读性折衷)。 > 详见 `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 121。 > **W3→M2 最新进展(增量 122)**:§5.4 / §8.8 seam ⑫ 收口 —— 目标框点击的 `LCONTROL` > 悄悄话捷径按 `40250/ClientVS22/source` + `assets/root/game.py` 补 1:1。参考端 > `CGameWindow::SetPCTargetBoard`(`game.py:804-817`):`self.targetBoard.Open(vid, name)` 之后, > `if app.IsPressed(app.DIK_LCONTROL)`(:807)→ `if not player.IsSameEmpire(vid): return`(:809) > → `if player.IsMainCharacterIndex(vid): return`(:812)→ `elif > chr.INSTANCE_TYPE_BUILDING == chr.GetInstanceType(vid): return`(:814)→ > `self.interface.OpenWhisperDialog(name)`(:817);C++ picking 只对 PC 命中派发 > `SetPCTargetBoard`(怪 / NPC 走别的分派)。谓词 `target_board.whisper_on_ctrl_click( > lcontrol_down, same_empire, is_main, is_building)`=`lcontrol_down and same_empire and > not is_main and not is_building` 增量 118 已备好没接,本增量纯接线:`project/net_play.gd` 加 > `signal whisper_requested(name)` + `var _lcontrol_down` + `func set_lcontrol_down(down)`, > `_on_pick` 在 `_change_target_to_picked_instance(vid)` 之后、`_entity_kind(e) == KIND_PC` > 分支内调 `TargetBoard.whisper_on_ctrl_click(_lcontrol_down, > EntityRules._is_same_empire(_main_entity(), e), vid == _main_vid, false)`,真且 > `_entity_name(e) != ""` → `whisper_requested.emit(name)`。`project/game_scene.gd` > `_unhandled_input` 在 `KEY_SHIFT` 块后加 `KEY_CTRL` 按下 / 松开 → > `net_play.set_lcontrol_down(e.pressed)`(一处轮询态,对齐参考端 `IsPressed` 语义),`chat` > 装配处(`fishing_feedback` 接线之后)连 `net_play.whisper_requested` → `chat.start_whisper` > (poc 版 `OpenWhisperDialog`=聊天输入预填 `"/w 名 "` 并抢焦点)。`netplay_test.gd` 加 > 「8c) seam ⑫」段(无新增测试文件):主角 empire=1,Bob(vid 6100 / `ch_type` 0 / empire 1) > 没按 Ctrl 点 → `whispers` 空但 `set_target` 6100、按 Ctrl 点 → `whispers == ["Bob"]`、改 > empire=2 再点 → 空(:809)、Wolf2(`ch_type` 2)按 Ctrl 点 → 空(仅 PC 分派)+ 全 69 个 > 离线回归通过(PASS=69 FAIL=0)。剩余 seam ⑫:poc 无 building / wooden-door / stone-door > 实体类型(`_entity_kind` 只到 0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP),`is_building` > 实参恒 false —— 参考端 :814 `INSTANCE_TYPE_BUILDING` 分支在 poc 永不触发;poc 悄悄话=聊天 > 输入预填,非独立 whisper dialog 窗口。 > 详见 `CLIENT-GAP-FIX.md` §5.4 / §8.8 / C.5 增量 122。 > **W3→M2 最新进展(增量 123)**:§8.8 seam ⑦ / ⑩ 收口 —— 头顶子标签 / 掉落名条排版的 > 文本宽度从「字符数 * 约 7px」估算换成 `CGraphicTextInstance::GetTextSize` 的真实字体度量 1:1。 > 参考端 `CPythonTextTail::ArrangeTextTail`(`PythonTextTail.cpp`)里 `iNameWidth` / `iTitleWidth` / > `iLevelWidth` / `guildW`(:225 / :233 / :240 / :267 / :297)与 item 尾标框 `xSize` / `ySize` > (:733 / :748 / :809)全部取自 `pTextInstance->GetTextSize(&w, &h)`——即字体的实际逐字形排版 > 度量(含字距、非等宽),不是「字符数 * 定宽」。新增纯静态模块 `project/text_metrics.gd`(第 30 个 > canonical,同 `name_color.gd` / `text_tail.gd` / `name_tail_layout.gd` / `text_tail_arrange.gd` > 一套 `extends RefCounted` 全 static + `*_test.gd` 头less 回归的镜像套路):`text_size(font, text, > font_size)`(空 / null / `font_size<=0` -> `Vector2.ZERO`,否则 `Font.get_string_size(text, > HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)`)、`width_of(font, text, font_size, fallback_char_px > := 7.0)`(有字体量得正宽 -> 精确像素;否则 -> `text.length() * fallback_char_px`)、 > `label_width(label: Label3D, fallback_char_px := 7.0)`(从节点自身取 `font ?? ThemeDB.fallback_font` > 和 `font_size`)。调用方换源不换排布:`net_world._layout_subtags_x` 的 `name_w` / `title_w` 改走 > `TextMetrics.label_width(label, SUBTAG_CHAR_W_PX)`(原 `字符数 * SUBTAG_CHAR_W_PX`), > `ground_items._arrange_tags` 的名条框 `w` 改走 `TextMetrics.label_width(tag, TAG_CHAR_W_PX)` > (原 `text.length() * TAG_CHAR_W_PX`);`name_tail_layout.gd` / `text_tail_arrange.gd` 收 `name_w` / > `title_w` / 框 `w` 作入参,排布公式(增量 117 / 119 / 120 / 121)不动。`SUBTAG_CHAR_W_PX` / > `TAG_CHAR_W_PX`(均 7.0)保留仅作无字体兜底。新增 `text_metrics_test.gd`(头less):常量、退化输入 -> > ZERO、真实度量随串长 / 字号单调、`width_of` 有字体 == `text_size.x`、无字体回退 `字符数 * px`、 > `label_width` 回落 `fallback_font` / null -> 0、`Label3D` 实测。离线回归 PASS=70 FAIL=0(新增 > `text_metrics_test`)。剩余 seam ⑦ / ⑩:单行文本高仍按 15px 估(未走 GetTextSize 的 h)、poc 无公会 > 徽记 mark 实体、三子标签竖排 vs 参考同一屏幕行、掉落名条为唯一 item 尾标来源。 > 详见 `CLIENT-GAP-FIX.md` §5.4 / §8.8 / C.5 增量 123。 > **W3→M2 最新进展(增量 124)**:§8.8 seam ⑦ / ⑩ 末尾 —— 掉落名条屏幕空间去重叠所用文本框的 > 「单行文本高」从硬编码 15px 换成 `CGraphicTextInstance::GetTextSize` 的 h 真实字体度量 1:1。 > 参考端 `CPythonTextTail::ArrangeTextTail` 的 item 尾标去重叠把「自己」顶到 > `pCompareTextTail->y + pCompareTextTail->yEnd + yTemp`(`PythonTextTail.cpp:182`),而 > `yEnd = ySize`(:751 / :812),`ySize` 与增量 123 换过的 `xSize` 同出 > `pTextInstance->GetTextSize(&xSize, &ySize)`(:749 / :809)——单行文本的 `ySize` ≈ 字体行高 > (ascent + descent),与字形无关,`Font.get_string_size(...).y` 是 1:1 对应物。`text_metrics.gd` > (仍第 30 个 canonical,扩非新增)加 `const FALLBACK_LINE_H_PX := 15.0`(=旧 > `ground_items.TAG_LINE_H_PX`)、`height_of(font, text, font_size, fallback_line_px := 15.0)` > (空串 -> 0;量得正高 -> `text_size(...).y`;否则 -> 回退)、`label_height(label: Label3D, > fallback_line_px := 15.0)`(`label.font ?? ThemeDB.fallback_font` + `label.font_size`,null -> 0)。 > `ground_items._arrange_tags` 的名条框 `h` 由硬编码 `TAG_LINE_H_PX` 改走 > `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`;`TAG_LINE_H_PX`(15.0)保留仅作 > 无字体兜底。`text_tail_arrange.gd`(`box_end_y(h) = h`,对齐参考 `yEnd = ySize`)/ > `name_tail_layout.gd` 收框高作入参、排布公式不动。`text_metrics_test.gd` 扩「5. height_of / > label_height」段(`FALLBACK_LINE_H_PX = 15`、空串 -> 0、无字体 / `font_size<=0` -> 回退、 > 有字体高为正且与串内容无关、随字号单调、`height_of == text_size(...).y`、`label_height(null) -> 0` > / 空 `Label3D` -> 0),离线回归 PASS=70 FAIL=0(无新增测试文件)。剩余 seam ⑦:poc 无公会徽记 > mark 实体、三子标签竖排 vs 参考同一屏幕行;剩余 seam ⑩:掉落名条 owner 未拆成独立 text > instance(参考端 `yEnd += yOwnerSize + 4`,:733-736,poc 仍按单行 `"name (owner)"` 处理)、 > 只对掉落 item 名条做。详见 `CLIENT-GAP-FIX.md` §5.4 / §8.8 / C.5 增量 124。 > **W3→M2 最新进展(增量 125)**:§8.8 seam ⑩ 末尾 —— 带 owner 的掉落名条,其屏幕空间去重叠 > 框按参考 `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)的「owner 独立 > 度量 + `yEnd += yOwnerSize + 4`」1:1。参考端 owner 后缀是与名字分开的 `CGraphicTextInstance` > (文本 = `c_szName + strOwnership`,`IDS_POSSESSIVE_MORPHENE` 为空取字面 `"'s"`;黄 1,1,0 / > CENTER),单独 `GetTextSize(&xOwnerSize, &yOwnerSize)`,据此 `yEnd += float(yOwnerSize + 4)` > (:735)、`xStart = fMIN(xStart, -xOwnerSize/2 - 1)`(:736)、`xEnd = fMAX(xEnd, xOwnerSize/2 > + 1)`(:737);owner 为空则删 owner 实例、框复位纯名字(:739-751)。`text_tail_arrange.gd` > (扩第 29 个 canonical)加纯静态 `item_box_with_owner(name_w, name_h, owner_w := 0.0, > owner_h := 0.0)`:`owner_w<=0 或 owner_h<=0` -> `{name_w, name_h}`(对齐参考 else 分支); > 否则 -> `{maxf(name_w, owner_w - 2.0), name_h + owner_h + 4.0}`——令 `box_end_x(w) = w/2+2` > 恰好等于 :737 的 `fMAX(nameW/2+2, ownerW/2+1)`,框 x 两边对称故 `box_start_x` 自动 = :736 的 > `fMIN`。`text_metrics.gd`(扩第 30 个 canonical)加 `string_width_like(label, text, > fallback_char_px := 7.0)` / `string_height_like(label, text, fallback_line_px := 15.0)`——用某 > `Label3D` 的字体 / 字号量任意子串(`null` -> 字符数 * 兜底 / 空串 -> 0),对应参考端两个独立 > text instance 各自 `GetTextSize`。`ground_items._on_added` 存 `item_name` / `item_owner` meta, > `_arrange_tags` 把「名字子串」「owner + `'s` 子串」分别度量后走 `item_box_with_owner` 合成 > 去重叠框;渲染仍是单个合并 `"name (owner)"` billboard `Label3D`(seam ⑩ 记录在案的折中)。 > `text_tail_arrange_test.gd` 扩「5. item_box_with_owner」段、`text_metrics_test.gd` 扩 > 「6. string_*_like」段、`p2b_test.gd` 的 `FakeClient.add_ground` 加可选 owner 入参 + 带 owner > 掉落被顶 `(15+15+4+5)*0.005` 段,离线回归 PASS=70 FAIL=0(无新增测试文件)。剩余 seam ⑩: > owner 未真正独立渲染成 CENTER 黄字、`strOwnership` 未接 `ApplicationStringTable` 的本地化语素、 > 只对掉落 item 名条做。详见 `CLIENT-GAP-FIX.md` §5.4 / §8.8 / C.5 增量 125。 > > **W3→M2 最新进展(增量 126)**:§8.8 seam ⑩ 末尾 —— 带 owner 的掉落名条把 owner 后缀拆成 > **一个独立的 CENTER 黄字(`1,1,0`)text instance**,并按参考 `ArrangeTextTail` 把名字实例 > 下移 15px。参考 `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:712-745`):owner > 非空时新建 `pOwnerTextInstance`(:715)、文本 = `c_szName + strOwnership`(:723,`strOwnership > = ApplicationStringTable_GetString(IDS_POSSESSIVE_MORPHENE) == "" ? "'s" : ...`,:722)、 > `SetHorizonalAlign(HORIZONTAL_ALIGN_CENTER)`(:727)、`SetColor(1.0f, 1.0f, 0.0f)` 黄(:729); > 排布 `pOwnerTextInstance->SetPosition(x, y, z)`(:195)、`pTextInstance->SetPosition(x, y + > 15.0f, z)`(:200);owner 为空 `CGraphicTextInstance::Delete(pOwnerTextInstance)`(:743)。 > `ground_items`:drop 节点下加第二个 `Label3D "owner_tag"`(`horizontal_alignment = > HORIZONTAL_ALIGNMENT_CENTER` / `modulate = Color(1,1,0)` / billboard / `no_depth_test` / > `pixel_size 0.005`,文本 = `owner + _possessive`),`_apply_owner_tag(node, owner)` 建 / > `remove_child + queue_free` 删;名字 `tag` 只放 `_name_for(vnum)`(`_tag_text` 删,不再拼 > `"name (owner)"`)。新增 `const OWNER_NAME_GAP_PX := 15.0` + `_place_tail(n, tag, d_px)`:有 > owner -> `owner_tag.y = TAG_BASE_Y - d_px*px`、`tag.y = TAG_BASE_Y - (d_px + 15)*px`;无 -> > `tag.y = TAG_BASE_Y - d_px*px`(`_arrange_tags` behind-camera / 单条 / 多条三处落点都改走它)。 > `setup()` 加可选 `possessive := ""`(`_possessive = possessive if possessive != "" else > OWNER_POSSESSIVE`,1:1 对齐 :722 的 `== "" ? "'s" :` 语义),`game_scene` 从 `EterLocale` 的 > `POSSESSIVE_MORPHENE` 键解析后注入(poc 的 `locale/*.txt` 无此键 -> 回落 `"'s"`,与 40250 > 西文 `UserInterface.rc` 的 `IDS_POSSESSIVE_MORPHENE "'s"` 一致)。去重叠框度量仍是增量 125 的 > `item_box_with_owner`(`yEnd += ownerH + 4`)不变。`p2b_test.gd` seam ⑩ 末尾段重写 > (名字实例文本 = `"掉落28"`、`owner_tag` 文本 `"Alice's"` / CENTER / 黄 / 落基点,名字实例 > 下移 `15 * pixel_size`;无 owner 的 drop 不建 `owner_tag`;`setup(possessive="@")` -> > `owner_tag` 文本 `"Bob@"`),离线回归 PASS=70 FAIL=0(无新增测试文件)。剩余 seam ⑩:owner > 行颜色语义固定黄(参考名字实例取 `pInsertTextTail->Color` 的道具品阶色、poc 名字走 `_process` > 远近着色)、去重叠只对掉落 item 名条做。详见 `CLIENT-GAP-FIX.md` §5.4 / §8.8 / C.5 增量 126。 > **表格旧快照更正**:W3 行中若仍写“远端 PC 仍是占位胶囊”或“任务日志缺滚动 / 定位”,以本节 > 增量 99 / 101 记录及 `CLIENT-GAP-FIX.md` 对应条目为准;后续编辑总览表时须同步替换旧描述。 ### 进度同步是交付条件 开发过程中的每个批次都必须同步本文和 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md),包括“开始、完成、 阻塞、重新验证”四类状态变化。代码已经提交但文档没有同步时,该批次仍视为**未完成**。 每次同步至少记录: - 工作流 / 条目编号、当前状态(⬜ 未开始、🟡 开发中、✅ 已完成、⚠️ 阻塞、🔎 待真实验收); - 参考源码依据、实际修改文件和公开接口变化; - 自动化测试命令及结果;无法运行或被 `skip` 的测试和原因; - 真实服务端 / Mac App 验收证据,仍缺什么; - 已知风险、与其他工作流的冲突 / 依赖、下一批次和负责人。 同步位置固定为:本文对应差距表 / 变更记录负责“全局状态”,`CLIENT-GAP-FIX.md` 对应条目和实施进度 负责“实现细节”。文档由 W0 集成维护者统一合并,工作流提交者只更新自己负责的条目,避免并行编辑造成状态 互相覆盖。 > **当前 40250 范围调整(增量 45)**:真实 FreeBSD + MySQL 服务端的完整登录 / 功能验收暂缓, > 不作为本阶段交付门槛;公会相关功能暂缓。当前目标 40250 服务端不提供龙魂玩法,龙魂不纳入本阶段 > 支持范围,已有兼容代码不代表服务端可用;运行时龙魂入口已禁用,但保留 `ds_refine` 等协议兼容代码。 > > ### 不做清单(❌) > > - **拍卖行 / 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 目标服务端)**:目标服务端不提供该玩法,本阶段不实现 / 不验收; > 客户端保留的兼容代码不计入当前支持范围。 > > ### 变更记录 > > 以下记录保留当时的增量快照;其中旧协议缺口数量、旧路径名称以及 `0x` 协议编号可能来自当时的 > m2dev / 旧实现,不代表当前 Classic 审计口径。当前结论以“协议覆盖审计”和 `MT_PROTOCOL` 变体 > 说明为准;历史记录中的 `CG_QUEST_CANCEL` 也不代表 Classic 存在同名独立 header。 > > - **2026-09-03 增量 99(W3→M2 / §4.1 修改 5 远端 PC 真模型工厂)**:`game_scene.gd` 对 > `ch_type==0` 创建 `RemotePlayerView`,复用 `PlayerView` 的 race / GR2 / MSA;新增远端专用 > `EquipModel.setup_remote()`,只消费参考端 `Packet.h:1282-1289` 定义的 > `awPart[ARMOR=0,WEAPON=1,HEAD=2,HAIR=3]`,不读取本地 `get_equipment()`。`net_world.gd` 按 > `set_armor(parts[0])→set_weapon(parts[1])→set_head(parts[2])→set_hair(parts[3])` 对齐 > `NetworkActorManager.cpp:473`;`remote_player_test.gd` 以真实 warrior 资源覆盖建模、换装和字段序, > `gamescene_test` / `equip_model_test` / `net_world_vis_test` / `netplay_test` / > `player_motion_test` 全部通过。其他职业 / race、变身、真服多角色换装和精确视觉仍待验收。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §4.1 / C.5。 > - **2026-09-03 增量 100(W4 / §6.3 EffectLib emitter cycle 生命周期首版)**:按 > `40250/ClientVS22/source/EffectLib/ParticleSystemData.cpp:38-60` 对齐缺省 `CycleLength=0.05`, > 按 `ParticleSystemInstance.cpp:280-300` 接入有限 cycle / LoopCount 的发射停止;重播时复位 CPU > 时间门控,并移除把 cycle 长度错误写入粒子寿命的逻辑。`fx_test` 已覆盖 cycle、LoopCount、 > 独立粒子寿命和 OUTER fallback。OUTER / INNER 精确径向速度、每周期 emission residue / 时间曲线 > 重置、随机起始帧和每粒子随机语义仍待实现。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §6.3 / C.5。 > > - **2026-09-02 增量 73(W2 / §3.7 受击状态统一)**:删除 `net_play.gd` 里参考端不存在的 > `_hitstun_until` / `HITSTUN` 本地硬直计时器及全部引用。「能否行动」的判定统一走 > `_can_process_network_state()` / `_can_attack()` / `_can_use_skill()` 三个函数:对齐 > `InstanceBase.cpp:1247`(≈ `ActorInstanceSync.cpp` 的 `__IsSyncing` = `IsDead || IsStun || > IsPushing`)、`InstanceBaseBattle.cpp:355`(`NEW_Attack` 多一个 `IsUsingSkill`)、`:310`。 > C++ `Entity` 增加 `knock_down`(`entity_dict` 暴露、`mut_knockdown(vid,on)` 落位并标脏); > 本地玩家的击退态由受击 `.msa` 播放生命周期驱动——`_on_damage` 只置位 + 播 `damage` 动作 + > 抖屏,`_process` 轮询 `player_view.is_in_hit_reaction()` 在动作播完时清零(`player_view` > 的 `_on_playback_finished` 增加 `damage` 一次性 clip 分支)。`dead` / `stunned` 保持由服务端 > 包驱动。`combat_fx_test` 断言改写并通过;C++ 19/19、`netplay_test` / `netbridge_test` / > `gamescene_test` / `player_motion_test` 回归全绿。远端实体 `knock_down` 的接线、真实 40250 > 受击 / 击退 / `AFFECT_STUN` 清除时序仍待后续增量 / 真服验收。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.7 / C.5。 > - **2026-09-02 增量 74(W2 / §3.3 普攻节奏硬编码 → 动作数据驱动)**:`formats/msa.{h,cpp}` > 新增 `Group ComboInputData`(`PreInputTime` / `DirectInputTime` / `InputLimitTime` / > `LinkTime`)与 `Group AttackingData`(`AttackingType` / `HittingType` / > `AttackingStartTime` / `AttackingEndTime` / `StiffenTime` / `InvisibleTime` / > `ExternalForce`)解析,对齐 `EterGrnLib CGrannyMotion::LoadMotionData` → > `CRaceMotionData::TComboInputData / TAttackingData`;`metin2_anim` 新增并绑定 > `get_motion_data()` 导出 `duration` / `next_combo`(`DirectInputTime` 或 > `duration*0.9`)等。`net_play.gd` 删除 `ATTACK_RANGE` 常量,改为 > `CLICK_DIST_DEFAULT_CM=150 / NPC_CM=500 / RESOURCE_CM=100` + `_clickable_distance(te)` > (对齐 `InstanceBaseBattle.cpp:294 NEW_IsClickableDistanceDestInstance`);普攻节奏改为 > `_current_attack_period()` = `(.msa next_combo 或 DEFAULT_ATTACK_PERIOD) / > (bAttackSpeed/100)`,收 info 包时 `_apply_attack_speed()` 只写 `_atk_speed_factor` > 不再写死间隔。弓箭 `__GetBowRange()`、`AttackingData` / `HitPosition` 驱动的命中帧 > 留待 §3.5。C++ 19/19(`formats.msa_loop_data` 扩展断言)、`combat_fx_test` / > `netplay_test` / `netbridge_test` / `gamescene_test` / `player_motion_test` / > `p2b_test` 回归全绿。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.3 / C.5。 > - **2026-09-02 增量 75(W2 / §3.4 自动攻击目标预留状态机)**:`net_play.gd` 新增 > `enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL}` 与预留字段 > (对齐 `PythonPlayerInput.cpp:820-865`)。`_process` 改为每帧 > `_update_auto_attack()`(照抄 `PythonPlayer.cpp:207 __Update_AutoAttack` 的目标存在 / > 死亡 / 骑乘 / charge-skill / 睡眠分支)→ `_refresh_reserved()` 模式分派。 > `_reserve_process_click_actor()` 严格 10 步对齐 `PythonPlayerInput.cpp:877`:出可点击距离 > `pc.walk_to` 继续靠近、不可攻击走 `client.click_npc`、`IN_SAFE` / `DEST_IN_SAFE` / > `EMPTY_ARROW` 经新 `signal cannot_act(code)`、第 10 步按 §3.3 节奏 gate `_do_attack_swing` > 后清预约并于下帧重新预约。安全区 / 弓箭模式 / 箭矢数 / 骑乘等级 / dash affect 等服务端 > 未下发的判定位先用实体字典键 + 本地占位变量接线(做法同 §3.7 `knock_down`)。~~CLICK_ITEM / > CLICK_POSITION / USE_SKILL 分派留桩。~~ —— **增量 91 已补齐**(见该增量条目)。`netplay_test` 新增 5b 段(出距离→`walk_to` 且不发包、 > 进距离→攻击且清预约、`IN_SAFE`→`cannot_act` 且不发包、目标死→auto-attack 清零); > C++ 19/19、`combat_fx_test` / `netbridge_test` / `gamescene_test` / `player_motion_test` / > `p2b_test` / `input_key_test` 回归全绿。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.4 / C.5。 > - **2026-09-02 增量 76(W2 / §3.1 本地移动 Src/Dst 模型)**:`player_controller.gd` 去掉 > `_move_target` / `_has_target` 的直线改坐标,改为 `_src_pos` / `_dst_pos` / `_is_going` / > `_dst_rot` 模型。`walk_to()` → `_goto(dst_flat)` 对齐 `CInstanceBase::NEW_Goto()` > (`InstanceBaseMovement.cpp:251`):`flat.length() < MOVABLE_GROUND_DISTANCE_M`(1.0 m) → > 返回 true 不动(≈ `__IsMovableGroundDistance`);三门 `_can_translate()` = `not frozen and > not locked and not moving_skill` 未过 → 存 `_reserved_ground` + `RESERVED_GROUND_DELAY` > 0.1 s,`_process` 里递减归 0 后自动重跑 `_goto`(≈ `__ReserveClickGround` / > `NEW_IsEmptyReservedDelayTime`);`moving_skill` 只转向不平移。`_process` 每帧朝 `_dst_pos` > 推进、抵达清 `_is_going`,WASD 分支置 `_is_going = false`(≈ `NEW_MoveToDirection` 的 > `m_isGoing = FALSE`)。新增 `project/player_move_test.gd`(5 组);C++ 19/19、`netplay_test` / > `combat_fx_test` / `gamescene_test` / `eterngrn_polish_test`(既有无关 `motion_event: sound` > FAIL)/ `player_motion_test` / `p2b_test` / `netbridge_test` / `mouse_controller_test` 回归全绿。 > 未做:Actor 间碰撞(`TestActorCollision` / `BlockMovement` 的 Area3D 近似)、坐骑 > `MODE_HORSE*`(依赖 §6.4 + §3.2)。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.1 / C.5。 > - **2026-09-02 增量 77(W2 / §3.2 远端同步命令队列 —— C++ 核心 part A)**:`EntityStore` > 把远端 Actor 位置改为命令队列驱动(对齐 `InstanceBase.cpp:1278 CInstanceBase::StateProcess`), > 不引入预测回滚 / 抖动缓冲。`Entity` 新增 `std::deque state_queue` + `mov_after_func` > / `mov_after_arg` / `mov_after_rot` / `bool skip_collision`;`mut_move()` 改为把 > `{chk_time,dst,rot,func,arg,dur_ms}` 入队(≈ `PushTCPState`)后 `drain_state_queue()` 释放 > 到期项;删掉旧 `start_move()` 直改坐标路径,m2dev `apply()` 的 GC_MOVE 与 classic parser 的 > HDR_GC_MOVE 都统一走 `mut_move(vid, angle, func, tx, ty, dur, arg, time)`。新增 > `process_states()` + `apply_state_cmd()` 1:1 实现 `StateProcess` 的 switch:`FUNC_WAIT` > dir_len>1.0 → 走 + `m_kMovAfterFunc=WAIT`,else `SCRIPT_SetPixelPosition` 贴齐;`FUNC_MOVE` > 恒走;`FUNC_COMBO/ATTACK/MOB_SKILL` dir_len>=50.0 → 走到位再动作,else 贴齐 + 动作;所有 > 「走过去」分支置 `skip_collision`(≈ `__EnableSkipCollision`),`tick()` 到达帧据 > `mov_after_func` 落地 COMBO/ATTACK/MOB_SKILL 动作并清标志。释放门按新 `set_server_frame_ms()` > (`m_server_frame_ms == 0` = 帧钟未对齐 → 立即释放 = 退回增量 76 时序,无回归)+ > `__CanProcessNetworkStatePacket`(`dead` / `knock_down` 时挂队不应用,恢复即释放)。 > `SNetworkActorData::UpdatePosition` 式线性插值只保留给 §2.1 可见性、不再直接写节点位置的 > 工作留给增量 78。新增 `extension/tests/net_state_queue_test.cpp`(5 组:帧钟释放门 / FUNC_WAIT > 1.0 阈值 / FUNC_COMBO 50.0 阈值 + 到达后动作 / knock_down·dead 门 / 多条到期按序 drain); > `net_entity_test` 的 FUNC_ATTACK 断言按新语义拆成近(贴齐)/ 远(先走后打)两例。 > `ctest --test-dir build` → 20/20(新增 `net.state_queue`);`netbridge_test` / > `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` / `combat_fx_test` 回归全绿。 > 留给增量 78:net_play.gd 上行 6 事件回调 + 节流常量(100/300 ms + disp≥1.0)、net_world.gd > 去掉自有 lerp、从 `GC_HANDSHAKE` `lDelta` 接真实服务器帧钟;`__IsEnableTCPProcess` 表情锁与 > Actor 碰撞近似另计。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.2 / C.5。 > - **2026-09-02 增量 78(W2 / §3.2 上行 6 事件回调 + net_world 去 lerp —— part B)**: > `net_play.gd` 的上行按 `PythonPlayerEventHandler.cpp` 拆成 6 个回调,统一走新出口 > `_send_state(func,arg,pos)`(≈ `SendCharacterStatePacket` / `TPacketCGMove`):`OnMove` > (`pc.moved` 首帧)立即发 `FUNC_MOVE` 并重置 moving/waiting 两个节流窗,`OnMoving` 按 > `SEND_MOVING_INTERVAL=0.3s` 节流发 `FUNC_MOVE`,`OnWaiting`(新 `_tick_on_waiting()`, > `_process` 每帧)按 `SEND_WAITING_INTERVAL=0.1s` 节流且相对上次上行位移 ≥ 1 cm 才发 > `FUNC_WAIT`,`OnStop`(`anim_state("wait")` 且刚在移动)不节流收尾 `FUNC_WAIT`,`OnAttack` > 在 `client.attack()`(`CG_ATTACK`) 外额外 `_send_state(FUNC_COMBO, attack_motion+_combo, > pos)`,新增公共入口 `on_use_skill(motion,arg)` → `_send_state(FUNC_SKILL|(motion&0x7F), > arg, pos)`(真实调用点属 §3.8 `player_skill.gd`,此处仅留 1:1 出口)。删掉旧的 > `SEND_INTERVAL` / `SEND_DIST` / `_last_sent_t` 单一节流。`net_world.gd` 的 `_process` > 改为 `n.position = _grounded(e.pos)` 直接跟随,去掉自有 `follow_lerp` 二次平滑——位置 > 插值现在完全在 C++ `EntityStore::tick()`(增量 77 命令队列 + `SNetworkActorData:: > UpdatePosition` 式线性插值)里做,只朝向仍在 GDScript 侧 `lerp_angle` 平滑;`follow_lerp` > / `snap_dist` 字段保留仅为兼容。`netplay_test` 新增第 11 段(OnMove / OnMoving 节流 / > OnStop / `on_use_skill`)、观战段基线改「相对计数不变」,`netbridge_test` 的 near-move > 断言改「node follows e.pos directly」。C++ 20/20 不变;`netbridge_test` / `gamescene_test` > / `netplay_test` / `player_move_test` / `p2b_test` / `combat_fx_test` / `player_motion_test` > 回归全绿。留给增量 79:从 `GC_HANDSHAKE` `lDelta` 接真实服务器毫秒帧钟 > (`ClassicStream` 已存 `m_server_time_base` / `m_client_time_base`,需暴露访问器并穿到 > `EntityStore::set_server_frame_ms()`);`__IsEnableTCPProcess` 表情锁、`GC_SYNC_POSITION` > 是否清队列另计。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.2 / C.5。 > - **2026-09-03 增量 79(W2 / §3.2 真实服务器毫秒帧钟 —— part C,§3.2 收官)**: > `ClassicStream` 暴露 `server_frame_ms()`(`classic_stream.h:107` / `.cpp:48`,≈ > `ELTimer_GetServerFrameMSec()`)——`HDR_HANDSHAKE` 处理里早已存的 > `m_server_time_base = hs.time + lDelta` / `m_client_time_base = now_ms()` > (`classic_stream.cpp:266-267`),帧钟 = `now_ms() - m_client_time_base + > m_server_time_base`,首个握手前返回 0;另加 `has_server_clock()`。 > `ClassicSession::process()` 在 `m_world.tick()` 前 > `m_world.set_server_frame_ms(m_stream.server_frame_ms())`(`classic_session.cpp:307`), > classic 后端 `drain_state_queue()` 遂按服务器时间放行命令队列而非即时释放。m2dev > 后端无握手 `lDelta` 对应物,`m_server_frame_ms` 恒 0 = 即时释放 = 增量 76 时序,无回归。 > `EntityStore::drain_state_queue` 加防呆阀 `STATE_QUEUE_MAX_WAIT_MS = 1000` > (`entity_store.h`)——`chk_time` 领先帧钟超过 1 s 视为异帧钟 `dwTime`,立即放行以免 > 远端 Actor 永久冻结;参考端 `StateProcess` 无此上限,属有意偏离,已在 `CLIENT-GAP-FIX.md` > §3.2 记录。`net_classic_stream_test` 加帧钟段(握手前 0 + `!has_server_clock()`、握手后 > ≈ `hs.time + lDelta`、单调不减),`net_state_queue_test` 加第 6 段(防呆阀边界)。 > C++ 20/20、7 个 GDScript 回归全绿。**§3.2 至此完成**,真实服务器 `GC_MOVE` `dwTime` > 时基是否与握手帧钟一致仍待联机核验。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.2 / C.5。 > - **2026-09-03 增量 80(W2 / §3.5 PC 连击段表解析 —— part A)**:查明参考端 PC 的连击 > 段表 **不在** `.msm` 里(`.msm` 只有 `BaseModelFileName` + `HairData`),而是由 > `assets/root/playersettingmodule.py` 的 `chrmgr.ReserveComboAttackNew` / > `RegisterComboAttackNew` 调用在 `CRaceData` 上建起来(`PythonCharacterManagerModule.cpp` > → `CRaceData::ReserveComboAttack` / `RegisterComboAttack`,`RaceData.cpp:405`)。新增 > `formats/combo_table.{h,cpp}`(`fmt::PlayerComboTables`):按 `def __LoadGameEx` > 分四个职业块(男女同函数体 → 表按职业收),解析那批调用为 > `map>`(段号 = `NAME_COMBO_ATTACK_*` > = 14..21),语义对齐 `CRaceData`:`Reserve` 建长 `count` 的 0 向量(`map::insert`, > 重复 key 不覆盖)、`Register` 按下标写(key 未 Reserve 或下标越界即跳过)。认得 > `chr.MOTION_MODE_*`(= `CRaceMotionData::EMode`)、`chr.MOTION_COMBO_ATTACK_N`(= 13+N)、 > `chr.MOTION_NORMAL_ATTACK`(= 13)、`COMBO_TYPE_N` / `COMBO_INDEX_N`(= N−1)、裸整数。 > 新增 `formats_combo_table_test`(合成片段 + `M2_ASSETS` 时对真实 `playersettingmodule.py` > 断言 warrior/sura 1H、shaman FAN),C++ **21/21** + 7 个 GDScript 回归全绿。留给增量 81: > `player_controller.gd` 连击状态机(`_cur_combo_index` / `_is_pre_input` / > `_is_next_pre_input`)改用此表 + `.msa` `ComboInputData` 三时间,替掉 `net_play.gd` > `_do_attack_swing` 的 `(_combo + 1) % 3`;之后 §3.5 命中窗(`.msa` `HitDataContainer`) > + 命中判定 / `OnHit`→`CG_ATTACK` / `FlushVictimList` 帧末 `CG_SYNC_POSITION`。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.5 / C.5。 > - **2026-09-03 增量 81(W2 / §3.5 PC 连击状态机 —— part B)**:`net_play.gd` 用增量 80 的 > 连击段表 + `.msa` `ComboInputData` 三时间实现连击状态机,替掉 `_do_attack_swing` 的 > `(_combo + 1) % 3`。新增 `_do_attack_swing` / `_run_next_combo` / `_combo_process` / > `_on_end_combo` / `_clear_combo`,逐行对齐 `CActorInstance::InputComboAttackCommand` / > `__RunNextCombo` / `ComboProcess` / `__OnEndCombo` / `__ClearCombo` > (`ActorInstanceBattle.cpp:157/213/256/297/308`):`_combo_index==0` 直接推进;否则按 > `pre_input_time`(InputStartTime)/ `direct_input_time`(NextComboTime)/ > `input_limit_time`(InputEndTime)判 `_is_pre_input` 挂起 / 直接推进 / 弓模式 > `_is_next_pre_input`;无 combo 输入组用 `duration*0.9` 兜底;`_combo_process()` 每帧触发 > 挂起输入、动作回 Wait 时 `_clear_combo()`;段号越界不推进;末段仅骑乘复位到 1。段号取自 > 新静态绑定 `Metin2AnimPlayer.parse_combo_tables(py_path)`(`extension/src/metin2_anim.{h,cpp}` > 转发 `fmt::parse_player_combo_tables_file`),按 `race & 3` 选职业块、 > `combo_motion_mode<<16|type` 取段序列(`combo_motion_mode` 默认 GENERAL,武器→mode 映射 > 留给增量 82+);缺段表退化为单段普攻。`_emit_swing` 保持增量 78 的两包 > (`CG_ATTACK{bType=0}` + `CG_CHARACTER_MOVE{FUNC_COMBO, 段号}`)。`game_scene.gd` 装配时 > `net_play.set_asset_root(_assets)`。`combat_fx_test.gd` 加 `FakeAnim` + 连击状态机全路径断言。 > C++ **21/21** + 7 个 GDScript 回归全绿。留给增量 82+:`.msa` `HitDataContainer` 多命中窗 > 导出、`THitData` 窗口命中判定 + `OnHit`/`CG_ATTACK` + `FlushVictimList` 帧末 > `CG_SYNC_POSITION`、武器种类 → `combo_motion_mode` 映射。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.5 / C.5。 > - **2026-09-03 增量 82(W2 / §3.5 `.msa` 多命中窗导出 —— part C,修改 1)**:`formats/msa.{h,cpp}` > 把 `Group AttackingData` 按 `GameLib GameType.cpp NRaceData::LoadMotionAttackData` 的两种 > 写法解析成 `std::vector`:老式(组内直接 `AttackingStartTime`/…/`List HitPosition`, > 无 `HitDataCount`)= 1 窗;新式(`AttackType` + `MotionType` + `HitDataCount N` + > `Group HitData00..0N`)= N 窗(多段挥击 / 双持左右手 = 2 窗)。每窗带 > `start_time`/`end_time`/`bone`/`weapon_length` + `List HitPosition` 采样(`{time, last_pos, pos}`, > 7 浮点一组);新增 `motion_type` / `hit_limit_count`,`attacking_type` 兼容 > `AttackType`/`AttackingType`,`attack_start_time`/`attack_end_time` 保留为首窗起止(旧调用兼容)。 > `formats/textscript.cpp` 补 `List Name { ... }` 块解析(原来 `List` 会把 `{` 吞进普通行、 > 还串掉其后的 `Group`——`palbang.msa` 的 `MotionEventData` 就丢了)。 > `extension/src/metin2_anim.cpp get_motion_data()` 新增 `hit_windows` 数组 > (`{start_time,end_time,bone,weapon_length,samples:[{time,last_pos:Vector3,pos:Vector3}]}`) > + `motion_type` / `hit_limit_count`。`formats/tests/msa_test.cpp` 加内联单窗 / 新式双窗合成 > 断言 + `M2_ASSETS` 时对真实 `warrior/general/attack`、`twohand_sword/combo_05`、 > `assassin/dualhand_sword/combo_01`(R Hand + L Hand 双窗)、`warrior/skill/palbang` 断言。 > C++ **21/21** + 7 个 GDScript 回归全绿。留给增量 83+:修改 4(`hit_window` [start,end] 内 > 几何判定 + `OnHit`→`CG_ATTACK` + `FlushVictimList` 帧末 `CG_SYNC_POSITION`)、 > 武器种类 → `combo_motion_mode` 映射。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.5 / C.5。 > - **2026-09-03 增量 83(W2 / §3.5 命中窗几何判定 → OnHit,修改 4 / §3.5 收官)**: > `net_play.gd` 新增 `_begin_hit_windows()` / `_attack_process(dt)` / `_hit_deduped()` / > `_register_hit()` / `_hit_geometry()` / `_flush_victim_list()`,逐帧按动作时间落在某个 > `hit_window [start,end]` 内对 `client.get_entities()` 里可攻击 / 未死 / 双方非安全区的实体 > 做几何判定(对齐 `InstanceBaseBattle.cpp:413 AttackProcess` + > `ActorInstanceCollisionDetection.cpp:333 __NormalAttackProcess`):`_hit_deduped` 复刻 > `m_HitDataMap`(`MOTION_TYPE_COMBO` 同窗同目标一次、其余按 `invisible_time` 冷却), > `_register_hit` 复刻每窗命中上限(NORMAL/COMBO 16、SKILL `hit_limit_count`),命中 → > `client.attack(_swing_skill, vid)`(`OnHit`→`SendAttackPacket`,普攻 `_swing_skill=0`), > 被击退目标帧末 `client.sync_positions()`(≤16,对齐 `PythonPlayerEventHandler.cpp:194 > FlushVictimList`)。`_emit_swing()` 因此改为**只有拿不到命中窗数据**(headless / 缺资源) > 才在挥击瞬间立即发 `CG_ATTACK`。几何判定用 `reach = max(WeaponLength, 窗内采样最大水平偏移)` > + 正面 `±60°` 弧近似(真实动态圆柱扫掠需骨骼矩阵 + defending sphere,随 §3.1 留桩)。 > `combat_fx_test.gd` 加 `FakeNetWorld` + 6 段断言。C++ **21/21** + 7 个 GDScript 回归全绿。 > 留给增量 84:武器种类 → `combo_motion_mode` 映射。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.5 / C.5。 > - **2026-09-03 增量 84(W2 / §3.5 武器种类 → `combo_motion_mode`,修改 6 / §3.5 修改 1-6 全落地)**: > `net_play.gd` 补全 `MOTION_MODE_*`(`CRaceMotionData::EMode` 全 17 项)+ > `ITEM_TYPE_WEAPON/ROD/PICK` + `WEAPON_SUB_*` 常量,新增静态 `motion_mode_for(item_type, > weapon_sub, mounted, poly, dress)` 逐分支复刻 `CInstanceBase::RefreshState` 的 `SetMotionMode` > (`InstanceBase.cpp:2851`,顺序:Poly→`GENERAL` / 婚纱→`WEDDING_DRESS` / 镐→骑乘 `HORSE` 否则 > `GENERAL` / 钓竿→骑乘 `HORSE` 否则 `FISHING` / 骑乘 `switch(subType)` → `HORSE_*` / else > `switch(subType)` → `ONEHAND/TWOHAND/DUALHAND_SWORD`·`BOW`·`FAN`·`BELL`·默认 `GENERAL`), > `_equipped_weapon_vnum()`(主角 `parts[WEAPON]` 非 0 优先,否则 `get_equipment()[WEAR_WEAPON].vnum`) > + `_refresh_motion_mode()`(读 `mount_vnum` / `polymorph` / `wedding_dress` + `proto.item(vnum)` > 的 `type`/`sub_type` → `set_combo_motion_mode(...)`,换模式内部 `_clear_combo()`),由 > `_on_entity_info()` 末尾、`inventory_changed`(`window==2` EQUIPMENT)、`mount_changed`(主角)三处触发。 > `combat_fx_test.gd` 加 `FakeProto` + 15 段 `motion_mode_for` 断言 + 2 段端到端(换匕首 → > `DUALHAND_SWORD` 且 `_combo_index` 归零;再上马 → `HORSE_DUALHAND_SWORD`)。C++ **21/21** + > 7 个 GDScript 回归全绿(`equip_model_test` 亦 PASS)。留给增量 85+:弓箭 `__GetBowRange()` > (§3.5 命中 / 点击距离)、§3.6 箭矢 `MOTION_EVENT_TYPE_FLY` 事件。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.5 / C.5。 > - **2026-09-03 增量 85(W2 / §3.5 弓箭 `__GetBowRange()` 可点击 / 攻击距离,§3.5 修改 1-7 全落地)**: > `net_play.gd` `_clickable_distance()` 逐分支对齐 > `CInstanceBase::NEW_IsClickableDistanceDestInstance`(`InstanceBaseBattle.cpp:294`): > 默认 150 cm → `_is_bow_mode()`(`combo_motion_mode` ∈ {`MODE_BOW`, `MODE_HORSE_BOW`}, > 对齐 `CActorInstance::IsBowMode` `ActorInstance.cpp:258`)时改用 `_bow_range_cm()` → > 目标 `IsNPC()` 覆盖为 500 → `IsResource()` 覆盖为 100(后写的 `if` 覆盖前面)。 > `_bow_range_cm()` = `BOW_RANGE_BASE_CM`(`2500-100`)+ `_bow_distance_bonus`,1:1 复刻 > `CInstanceBase::__GetBowRange`(`InstanceBase.cpp:694`:`2500.0f-100.0f` + 主角 > `GetStatus(POINT_BOW_DISTANCE)`,参考端按 cm 直加不 ×100)。`_bow_distance_bonus` 由 > `_on_points()` 读 `points["bow_distance"]` 填充;`wire.h` 补 `POINT_BOW_DISTANCE = 34`、 > `M2Client::get_points()` 增 `d["bow_distance"]`。`combat_fx_test.gd` §3.5 段加 8 段断言。 > C++ **21/21** + 7 个 GDScript 回归全绿(`equip_model_test` 亦 PASS)。留给增量 86:§3.6 箭矢 > `.msa` `MOTION_EVENT_TYPE_FLY` → `CG_FLY_TARGETING` / `CG_SHOOT`,替掉约 1.5 s 定时器兜底。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.5 / §3.3 / C.5。 > - **2026-09-03 增量 86(W2 / §3.6 弓箭 FLY 事件 `OnSetFlyTarget` / `OnShoot`,§3.6 修改 1-3 全落地)**: > 拆掉 `game_scene.gd` 单槽 `_pending_shoot_skill` + `get_tree().create_timer(1.5)` 兜底,换成 > `_pending_shots: Array[int]`(FIFO,上限 `MAX_PENDING_SHOTS = 8`,满丢最旧),1:1 对齐 > `CNormalBowAttack_FlyEventHandler_AutoClear`:每次弓挥击 / 弓技能起手 `_queue_shot(uSkill)` > 压一个,`.msa` `MOTION_EVENT_TYPE_FLY` 帧(`_on_local_motion_event` 的 `type 6`)`pop_front()` > 弹一个 `client.shoot()`(pop 即自清 = AutoClear,连射多箭在途各自一发;对齐 > `CActorInstance::ProcessMotionEventFly` `ActorInstanceMotionEvent.cpp:290`)。`OnSetFlyTarget` > (`ActorInstanceMotion.cpp:372`:`__SetMotion` 里 `__IsNeedFlyTargetMotion()` 为真即触发)放在 > `net_play.gd._emit_swing`:`_is_bow_mode()` 时先 `_send_fly_target()`(`_target_vid` 回退 > `_vid_reserved` → `net_world` 节点 → `MapCoord.to_server_cm()` → `client.add_fly_targeting(vid, x, y)`; > 对齐 `SendFlyTargetingPacket(targetVID, target->OnGetFlyTargetPosition())`,`OnGetFlyTargetPosition` > = 目标包围球中心世界坐标,平面分量与脚点一致,`CG_FLY_TARGETING` 只带平面坐标, > `ActorInstanceFly.cpp:4`),再 `bow_shot_fired.emit(_swing_skill)` 让 `game_scene` 入队。 > 新增 `net_play` 信号 `bow_shot_fired(skill)` + `_send_fly_target()`、`game_scene._queue_shot()`。 > `combat_fx_test.gd` 加 `FakeClient.add_fly_targeting/shoot` + 6 段 §3.6 断言(非弓无 FLY / > 弓挥击发 `CG_FLY_TARGETING(vid)` / 坐标 = 目标 server-XY / `bow_shot_fired(0)` / 无目标不发), > `eterngrn_polish_test.gd` FLY 段改 FIFO 连射断言。C++ **21/21** + 9 个 GDScript 回归全绿。 > 留给后续:飞行物实体本身(`CFlyingManager` / `FlyingInstance` 生成 / 轨迹 / 命中)、`__CanShot` > 弓箭合法性、真服 `CG_FLY_TARGETING` z 分量。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.6 / C.5。 > - **2026-09-03 增量 87(W2 / §3.8 TSkillData 判定位解析,修改 2)**:POC 无 C++ skill proto > (`extension/src/proto` 只有 item/mob),技能数据全在 GDScript `project/ui/skill_table.gd` > 读 `locale/locale//skilldesc.txt` TSV。按 `RegisterSkillDesc`(`PythonSkill.cpp:280`)把 > 第 11 列 attributes / 第 12 列 weapon 的 `|` 分隔记号折成 `attr_bits` / `need_weapon_bits` > 位掩码(`_fold_bits()` 静态方法;记号名 1:1 抄 `PythonSkill.h:92-128` 的 `SKILL_ATTRIBUTE_*` / > `SKILL_NEED_WEAPON_*`,未知记号忽略 = 参考端“map 查不到就跳过”;`DOUBLE_SWORD` 复用 `DAGGER` > 位;weapon 位 = `1 << CItemData::WEAPON_*`,`GameLib/ItemData.h:63-75`),`load_file` 时连同 > `skill_type`(JOB 列经 `m_SkillTypeIndexMap` → `SKILL_TYPE_{NONE,ACTIVE,SUPPORT,GUILD,HORSE}`) > 一起预存进 entry。谓词逐条对齐 `CPythonSkill`:`is_can_use_skill`(=非 PASSIVE,`:1407`)、 > `is_standing` / `is_need_target` / `is_need_corpse` / `is_auto_search_target` / > `can_change_direction` / `is_horse_skill` / `is_moving_skill` / `is_melee` / `is_charge_skill` / > `is_use_hp` / `is_time_increase` / `is_need_empty_bottle` / `is_need_poison_bottle` / > `can_use_for_me` / `is_only_for_alliance` / `can_use_if_not_enough` / `is_only_for_guild_war` / > `is_fan_range` / `is_circle_range` / `has_weapon_limitation` / `can_use_weapon_type(id,type)` > (=无限制恒真否则 `dwNeedWeapon & (1< `is_guild_skill`;旧 `is_attack` / `is_passive` / `is_toggle` / `is_ranged` / `can_level_up` > 改为查位(`is_ranged` 现含 ARROW 位)。`skill_test.gd` +34 条 §3.8 断言。C++ **21/21** + > 9 个 GDScript 回归全绿。留给增量 88+:`player_skill.gd` 移植 (a)(b)(c)(d) 三层校验、 > `M2Client::use_skill()` 带符号 + 范围校验(§3.9)。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.8 / C.5。 > - **2026-09-03 增量 88(W2 / §3.8 `player_skill.gd` 三层合法性校验,修改 1)**:新建 > `project/player_skill.gd`(`extends RefCounted`,纯校验器,不发包 / 不驱动动画,返回 > `{"ok", "code", "target_vid"}`),逐行移植 `PythonPlayerSkill.cpp` 的四层:(a)`click_skill_slot()` > 分派(`:106`)、(b)`can_use_skill()` 全局可用性(`:439`,硬编码骑乘术 vnum `109`:骑乘中且 > `grade<1 && level<20` → 禁)、(c)`check_skill_usable()` 逐项前置(`:166` 完整顺序:骑乘⇄马术 > `NOT_HORSE_SKILL` / `HAVE_TO_RIDE` → 安全区攻击技 `IN_SAFE` → 被动 → 空瓶 `27995` → 毒瓶 > `27996` → 钓鱼 `REMOVE_FISHING_ROD` → 未学 `NOT_YET_LEARN` → 武器不匹配 `NOT_MATCHABLE_WEAPON` > → 无箭 `EMPTY_ARROW` → dash / 蓄力 / 冷却 `WAIT_COOLTIME` → HP / SP)、(d)`use_skill()` 施放 > (私人商店静默、`__CheckSpecialSkill` 钓鱼 `123` / 连击 `122`、切换技 `TOGGLE_OFF`、施法中 > `ALREADY_CASTING`、目标矩阵 `_resolve_target()` 同盟 / 安全区 / 可攻击 / 尸体 → > `CANNOT_USE_SELF` / `ONLY_FOR_ALLIANCE` / `CANNOT_ATTACK_ENEMY_IN_SAFE_AREA` / `CANNOT_ATTACK` / > `ONLY_FOR_CORPSE` / `NEED_TARGET`)。命名常量 `SKILL_MAX_NUM=255`(`Packet.h:2041`)/ > `SKILL_TYPE_GUILD=3` / `RIDING_SKILL_INDEX=109` / `ITEM_EMPTY_BOTTLE=27995` / > `ITEM_POISON_BOTTLE=27996` / `SKILL_INDEX_COMBO=122` / `SKILL_INDEX_FISHING=123` > (`PythonPlayerModule.cpp:6-7`);`SILENT_CODES[]` 13 个无文案码 + `static is_silent_code()`。 > 运行期状态尽量走 `client`(`get_main_vid` / `get_entity` / `get_skills` / `get_target` / > `get_inventory`),其余经公有 context 字段由 `net_play.skill_context()` 每次注入(观察 / 骑乘 / > 安全区 / 钓鱼 / 施法中 / 可行动 / dash / `weapon_sub_type` / `arrow_count`)。`ui/quickbar.gd` > `activate()` 技能分支施放前跑三层,`TOGGLE_OFF` → 关闭切换技 + `skill_activated`,非静默码 → > 新信号 `skill_rejected` → `game_scene` 转 `net_play.cannot_act.emit(code)`(HUD 走同一条 > `OnCannotUseSkill` 文案通道),gate 解析出的 `target_vid` 覆盖点击目标。`__CheckShortMana` / > `__CheckShortLife` 打桩恒 false(POC 无 `.msk` NeedSP/NeedHP 公式解析,留给增量 89)。 > 新增 `project/player_skill_test.gd`(读真 `skilldesc.txt`,覆盖 (a)(b)(c)(d) 每条分支 + > 目标矩阵 + `is_silent_code`)。C++ **21/21** + **12** 个 GDScript 回归全绿。留给增量 89+: > 修改 3(`M2Client::use_skill()` 带符号 + 范围校验,§3.9)+ HP/SP 校验落地。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.8 / C.5。 > - **2026-09-03 增量 89(W2 / §3.8 修改 3 —— `use_skill` 边界收紧 + 短蓝短血校验落地,§3.8 三层全落地)**: > ① `extension/src/net/net_bounds.h` 新增 `SKILL_MAX_NUM=255`(`Packet.h:2041`)+ > `skill_index(v) = 0 < v < 255` 谓词;`extension/src/net/m2_client.cpp::use_skill(int skill_id, > int target_vid)`(形参增量 52 起即带符号)的 `skill_id` 护栏从 `skill_id <= 0 || > !bounds::u32()` 收紧成 `!mtnet::bounds::skill_index(skill_id)`,对齐 `ClickSkillSlot` 的 > `slot >= SKILL_MAX_NUM` 门;`target_vid` 保留 `>= 0 && bounds::u32`;`cast_skill()` 的 > `motion_idx ∈ [0,0x7f]` 不动(7 位 wire 位段)。`net_bounds_test.cpp` +4 断言。② `ui/skill_table.gd` > `_load_cooltime` 更名 `_load_msk`,加解析 `.msk` `NeedSPFormula`;新增 `_SKILL_POWER[]` > (`LocaleService_GetSkillPower` INTERNATIONAL 表,`Locale.cpp:126`,`level >= 50 → 0`)+ > `_skill_point(level)` + `need_sp(id, level)`(≈ `GetNeedSP`,`PythonSkill.cpp:1426`;POC 约定: > 无 `NeedSPFormula` 返回 `-1`,参考返回 0,避免误挡缺字段技能)+ `need_hp()`(照抄 > `__CheckShortLife:359` 复用 `GetNeedSP` 的怪癖);`cooldown_of` 的 `SkillPoint` 也改走 > `_skill_point()`(与参考同一份 `fcurEfficientPercentage`)。③ `project/player_skill.gd` 新增 > `SKILL_INDEX_SUMMON=131`(`PythonPlayerModule.cpp:15`)+ 上下文字段 `cur_sp` / `cur_hp` > (`-1` = 未知不判),`_check_short_mana` 逐条移植 `__CheckShortMana`(`:320`:summon `131` 豁免、 > 非开关技 `need_sp == 0` → 坏数据拦、`can_use_if_not_enough` → `cur_sp <= 0` 拦、否则 > `need_sp != -1 && need_sp > cur_sp` 拦),`_check_short_life` 移植 `__CheckShortLife`(`:354`: > `!is_use_hp() → false` 放行、`need_hp > cur_hp` 拦;shipped `skilldesc.txt` 无 USE_HP 技能故 > 对现有技能等价放行),二者转 `NOT_ENOUGH_SP` / `NOT_ENOUGH_HP`。④ `net_play.gd::skill_context()` > 读 `client.get_points()` 的 `hp` / `sp` 填 `cur_hp` / `cur_sp`。⑤ `player_skill_test.gd` > +“(c) §3.8 修改 3”段(`_skill_point(20)==0.5` / `need_sp(1,5)==52` / `need_hp==need_sp` / > SP 不足→`NOT_ENOUGH_SP` / SP 未知→ok / 无公式技 + SP 0→不挡 / 非 USE_HP 技 + HP 1→不报), > `_reset` 补 `cur_hp=-1` / `cur_sp=-1`。C++ **21/21** + **13** 个 GDScript 回归全绿。§3.8 > 三层校验(修改 1/2/3)至此全部落地。留给增量 90+:§3.10 技能升级门(audit 10)、 > `__ChangeTargetToPickedInstance` 拾取回退、扇形 / 圆形多目标 fly-targeting。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.8 / §3.9 / C.5。 > - **2026-09-03 增量 90(W2 / §3.10 技能升级门 —— 加号按钮门控 + `skill_up` 边界收紧)**: > 审计条目 10 的门控空缺补齐。① `extension/src/net/wire.h` `EPointTypes` 加 > `POINT_SUB_SKILL=27` / `POINT_SKILL=28` / `POINT_HORSE_SKILL=113`(`UserInterface/Packet.h`; > 对应 `uicharacter.py` `skillPageStatDict`);`m2_client.cpp::get_points()` 新增 > `skill_active` / `skill_support` / `skill_horse` 三键(镜像增量 85 的 `bow_distance`)。 > ② `m2_client.cpp::skill_up(int skill_id)` 护栏 `skill_id >= POINT_COUNT` 收紧成 > `!mtnet::bounds::skill_index(skill_id)`(= `0 < v < 255`,与 `use_skill` §3.9 同一谓词); > 发包 `/skillup ` 不变。③ `project/ui/skill_table.gd`:`load_file` 补 `RegisterSkillDesc` > 的 `LEVEL_LIMIT`(列 26)/ `MAX_LEVEL`(列 27)解析(`.msk` `maxlevel` 优先;shipped 22 列 > 数据两列都缺,逻辑为兼容第三方数据);新增 `max_level_of(id)`(= `SSkillData::GetMaxLevel()`, > `PythonSkill.cpp:1392`,缺值默认 20 = 构造默认 `byMaxLevel`,:1462)、`skill_level_limit(id)` > (= `skillGetSkillLevelLimit`,`PythonSkill.cpp:1741` = `byLevelLimit`;**本 40250 数据里 > skilldesc 无 `LEVEL_LIMIT` 列、`.msk` 无 `levellimit` 记号 → 恒 0**,马术页等级前置门恒真)、 > `can_level_up_skill(id, level)`(= `skillCanLevelUpSkill`,`PythonSkill.cpp:1833`;**本 build 里 > `isRequirement` / `strRequireSkillName` / `RequireStatDataVector` 从不被填充(构造默认,:1471) > → 1:1 化简为 `level < GetMaxLevel() && !(attr & CANNOT_LEVEL_UP)`**)。④ `project/ui/skill_ui.gd`: > `refresh()` 里逐行移植 `uicharacter.py` 的 `RefreshSkillPlusButtonList`(:984)/ > `__RefreshSkillPlusButton`(:937)/ `CanShowPlusButton`(:925)——`_can_use_skill_now()` > (= `client.get_skill_group() != 0`,对应 `__CanUseSkillNow` :1270)、`_stat_point_for(page)` > (`SUPPORT`→`skill_support` / `HORSE`→`skill_horse` / 其余→`skill_active`,三来源分开取)、 > `_can_show_plus(id, level, grade, page, …)`(技能组门 → 辅助页 `HIDE_SUPPORT_SKILL_POINT` > 整页隐 → `stat_point<=0` 隐 → `skillGrade!=0` 隐 → 马术页 `plv >= skill_level_limit(id)` 时 > `level < 20` 才显 → 主动页 `table.can_level_up_skill(id, level)`);按钮仍在 `_rebuild()` 为 > 非被动 / 非 `CANNOT_LEVEL_UP` 技能创建,`refresh()` 只切 `visible`(= `HideAllSlotButton()` + > `ShowSlotButton()`);常量 `HIDE_SUPPORT_SKILL_POINT`(`uicharacter.py:21`,本资源为 True → > 辅助页整页不出加号)/ `RIDING_SKILL_MAX_LEVEL=20`。升级结果仍由 `GC_SKILL_LEVEL` 经 > `skills_changed` 驱动 `refresh()`,不本地伪造。⑤ `skill_test.gd` FakeClient 补 > `get_skill_group()` / `get_points()`;扩 `§3.10` 段(`max_level_of` / `skill_level_limit` / > `can_level_up_skill` 未满级 / 满级 / 超级 / `CANNOT_LEVEL_UP` / 未知 id + 加号显隐:主动页 > 可见 / `skillGrade!=0` 隐 / `skill_group==0` 隐 / 该页点数 0 隐 / 辅助页整页隐 / 马术页 > `skill_horse>0 && level<20` 可见 / 点数 0 隐)。C++ **21/21** + **13** 个 GDScript 回归全绿。 > 留给增量 91+:`__ChangeTargetToPickedInstance` 拾取回退、扇形 / 圆形多目标 fly-targeting、 > §3.6 飞行物实体(`CFlyingManager` / `FlyingInstance`)。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.10 / C.5。 > - **2026-09-03 增量 91(W2 / §3.4 其余三种预约模式 + `__ChangeTargetToPickedInstance` + 扇形 / 圆形补 fly-targeting)**: > § 3.4 自增量 75 起留桩的 `MODE_CLICK_ITEM` / `MODE_CLICK_POSITION` / `MODE_USE_SKILL` 三个 > 分派补齐。① `project/net_play.gd` `_refresh_reserved()` 由「只跑 CLICK_ACTOR」改为四路 > `match`,逐行对齐 `NEW_RefreshMouseWalkingDirection`(`PythonPlayerInputMouse.cpp:287`): > `_reserve_process_click_item()`(按 IID 查 `client.get_ground_items()` 世界坐标, > `< 20cm`(`CLICK_ITEM_PICKUP_CM`)到手 → `_send_state(FUNC_WAIT)` + `client.pickup_item()` + > 解冻 + `_clear_reserved()`,否则 `pc.walk_to()`;物品消失 → `_clear_reserved()`)、 > `_reserve_process_click_position()`(`pc._can_translate()` 未锁且 `_reserved_delay <= 0` > (`_process` 逐帧递减 ≈ `NEW_IsEmptyReservedDelayTime`)→ `pc.walk_to(_ppos_reserved)` + > `_clear_reserved()`)、`_reserve_process_use_skill()`(`dist_cm` 含 `_is_huge_race` 的 > `-200cm`(`HUGE_RACE_DIST_FIX_CM`)修正,`< _skill_range_reserved` → `set_target` + > `use_skill_hook(slot)` 成功才 `_clear_reserved()`,否则 `pc.walk_to(tnode)`;目标丢失 → > `_clear_reserved()`)。② 预约入口 `reserve_click_item(iid)` / `reserve_click_ground(pos)` > (`_reserved_delay = 0.1`)/ `reserve_use_skill(vid, slot, range_cm)`(= `__ReserveUseSkill` > `PythonPlayerInput.cpp:854`,`range > 100` 先减 `RESERVE_SKILL_RANGE_TRIM_CM`(10))/ > `pick_ground_item(iid)`(= `__OnPressItem`——清预约 + auto-attack 归零,`CLICK_DIST_RESOURCE_CM` > 内直接 `pickup_item` + 停,否则预约)。`_clear_reserved()` 追加复位 `_ppos_reserved` / > `_skill_range_reserved`。③ `_on_pick()` 补 `__OnPressActor` 特例(`PythonPlayerInput.cpp:324`): > `_reserved_mode == USE_SKILL && _dash_active` 时只改 `_vid_reserved` 不选中;战斗目标改走 > `_change_target_to_picked_instance(vid)`(= `__ChangeTargetToPickedInstance` :186 → > `SetTarget` :130——`IsTargetableInstance` 限定 PC / 怪,否则 `set_target(0)` + `clear_target`; > NPC / 石头 / warp 仍由 `_on_pick` 先分流到 `click_npc`)。`_is_huge_race(race)`——`IS_HUGE_RACE` > 的 vnum 白名单未随源码发布,改用 `proto.mob(race)` 的 `size >= MOBSIZE_HUGE(3)` 或 > `rank >= BOSS(4)` 近似,查不到 → false(文档化 seam)。④ 扇形 / 圆形补 fly-targeting > (`PythonPlayerSkill.cpp:680-746` `if (dwTargetMaxCount>0 …)` 块):`net_play.send_fly_targeting( > target_vid, range_cm, max_count, shape)`——主目标 `CG_FLY_TARGETING` + `CG_USE_SKILL` 仍由 > `M2Client::use_skill` 发,这里只补附加目标——`_instances_in_fan_range()`(= > `NEW_GetInstanceVectorInFanRange` `InstanceBaseBattle.cpp:192`:随距离收窄半角 > `(MAX-MIN) - RPM·min(dist,1000) + MIN`,`FAN_HALF_ROT_MIN=20` / `MAX=40` / `MIN_DISTANCE=1000`)/ > `_instances_in_circle_range()`(:243,无方位过滤)内其它可攻击敌人经 > `client.add_fly_targeting(vid, sx, sy)`(= `SendAddFlyTargetingPacket`)补齐;名额不足时 > `_random_pos_in_fan_range()`(= `NEW_GetRandomPositionInFanRange` :124:目标方位 ±10° > (`FAN_RANDOM_JITTER_DEG`)抖动、沿该方向 `FAN_RANDOM_REACH_CM`(8000cm) 取点)→ > `add_fly_targeting(0, sx, sy)`。方位角在 Godot world 帧统一用 `_bearing_deg`(角差与坐标系无关)。 > ⑤ `project/ui/skill_table.gd`:`_load_msk` 补 `.msk` `TargetCountFormula` / `Range` 解析 > (`CMotionSkillData` 的 `strTargetCountFormula` / `dwTargetRange`);新增 > `target_count(id, level)`(= `GetTargetCount(fSkillPoint)` `PythonSkill.cpp:1361`——空公式 → 0, > 否则 `SkillPoint` 代入后 `floor`)、`target_range(id)`(= `__GetSkillTargetRange` 基础项 > `PythonPlayerSkill.cpp:391`,弓距加成留 `net_play` 叠加)、`fly_shape(id)`(`FAN_RANGE`→1 / > `CIRCLE_RANGE`→2 / 否则 0,对齐 `NetPlay.FlyShape`)。⑥ `project/ui/quickbar.gd`:`activate()` > 里 `client.use_skill` 成功后,若 `target_vid != 0` 且技能为 `FAN_RANGE` / `CIRCLE_RANGE` → 调 > `net_play.send_fly_targeting(...)`;新增 `_skill_level(sid)`(扫 `client.get_skills()`)、 > `activate_reserved(global_slot)`(`use_skill_hook` 回调,`MODE_USE_SKILL` 进射程后执行槽位施法)。 > ⑦ `project/game_scene.gd`:`net_play.use_skill_hook = func(slot): return > quickbar.activate_reserved(slot)`。⑧ 测试:`netplay_test.gd` FakeClient 补 `get_entities` / > `pickup_item` / `add_fly_targeting` / `get_ground_items` / `get_skills`,新增「5c」段 > (CLICK_ITEM 远→`walk_to` / 到手→`pickup`+`FUNC_WAIT`+清、物品消失→清;CLICK_POSITION 延时门 + > `walk_to`;USE_SKILL `range>100→-10`、射程外`walk_to`、射程内`hook`+`set_target`+清、目标丢失→清; > `_change_target_to_picked_instance` NPC→清目标 / 怪→选中;`send_fly_targeting` FAN 2 侧翼入扇 + > 背后排除、CIRCLE 背后计入、随机落点补齐、`max_count<=1` / `SINGLE` → 0);`skill_test.gd` 补 > skill 47(gwangyeok,FAN + Range 2500 + `target_count(lv1)=2` / `(lv20)=5`)/ skill 91 > (bipabu,FAN + Range 1800 + 无公式→0)/ skill 1(`fly_shape=SINGLE`)。C++ **21/21**(无 C++ > 改动)+ **13** 个 GDScript 回归全绿。留给增量 92+:§3.6 飞行物实体(`CFlyingManager` / > `FlyingInstance`)生成 / 命中、`__CanShot` 弓箭合法性。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.4 / C.5。 > - **2026-09-03 增量 92(W2 / §3.6 飞行物实体 `CFlyingManager` / `FlyingInstance` 生成 / 命中 + `__CanShot` 弓箭合法性)**: > ① 新增 `project/fly_object.gd`(`FlyData` / `FlyInstance` / `FlyManager`),对齐 > `GameLib/FlyingObjectManager.{h,cpp}`(`CreateFlyingInstanceFlyTarget`、`Update()` 的 `while` > 迭代 + `erase`、`CreateIndexedFly` 的 `NORMAL` / `FIRE_CRACKER` / `AUTO_FIRE` 分支常量)+ > `FlyingInstance.{h,cpp}`(`Create` → `__SetDataPointer` → `__SetTargetDirection`;`Update()` > 逐帧 `vel += accel*dt` → `vel.z += gravity*dt` → `remain_range -= |movement|` → 线段-点 > `square_distance_between_linesegment_and_point < m_fBombRange²` 命中 → `OnShootDamage`(本 POC > 转 `shoot_damage(vid)` 信号)/ pierce `--` + `__Bomb` 续飞 / `__Explode` + `return false`; > `m_fRemainRange<0` → `OnExplodingOutOfRange` + `__Explode(false)`(不 `__Bomb`);位置目标同 > 线段判定;`m_bHitOnBackground` 的 `GetTerrainHeight(x,-y) > z`)+ `FlyingData.cpp __Initialize()` > 默认值(米制换算)。线段判定 `_seg_point_sq_dist` 1:1 移植 `EterLib/GrpMath.h:80`。 > ② `project/net_world.gd`:`setup()` 挂 `FlyManager` 子节点并 `setup(parent, world)`,新增 > `signal fly_exploded(world_pos, cause)`;重写 `_on_fly(type, start_vid, end_vid)`——原 tween > 「发光球」占位换成 `_fly_mgr.spawn(start_w, target_node|goal_w, false, FlyData, type)` 真实 > 弹道 + 线段命中(合成箭参数 `init_vel=45` / `flat_range=max(dist*1.5, 30)` / `bomb_range=0.6`—— > 无 `.fly` 资源加载器)。③ `project/net_play.gd`:新增 `_can_shot(target_vid := -1) -> String` > (`__CanShot` 在本发布源码树中 `grep` 无命中——据 `IFlyEventHandler::OnNoTarget` / `OnNoArrow` > `FlyHandler.h`、`NEW_Attack` `InstanceBaseBattle.cpp:355` 的 `IsDead` 门、`__GetBowRange` > `InstanceBase.cpp:694` 防御式重建):非弓 → `"NOT_BOW_MODE"`;`_arrow_count == 0` → > `"EMPTY_ARROW"`(`-1` = 未跟踪 / 无限,放行);`_target_vid` / `_vid_reserved` 均 ≤ 0 → > `"NO_TARGET"`;目标实体已知且不可攻击 / 已死 → `"NO_TARGET"`(`te.is_empty()` 不拒——headless > 容差,与 `_send_fly_target`「有节点即可」一致);目标世界距离 `* CM > _bow_range_cm()` → > `"OUT_OF_RANGE"`;否则 `""`。`_emit_swing` 弓分支据此加门:`_can_shot()` 非空 → `_emit_cannot(why)` > (走 `OnNoArrow` / `OnNoTarget` 文案),不发 `CG_FLY_TARGETING`、不排 `OnShoot`;其余起手动作 > 照常(seam:参考端整挥击中止)。④ 新增 `project/fly_test.gd`(第 14 个 GDScript 回归):直线 > 命中对象目标 / 超程不 `__Bomb` / `can_attack` 触 `shoot_damage(vid)` / 穿透后存活续飞再超程 / > 位置目标 / 触地 / `_can_shot` 六分支 / `_seg_point_sq_dist` 三段。C++ **21/21**(无 C++ 改动) > + **14** 个 GDScript 回归全绿。已文档化 seam:`GC_CREATE_FLY` 在本 40250 客户端无包处理器 > (仅 `NetStream.cpp` 头串索引 70),实际驱动走 POC 自有 `fly_cue`;`__CanShot` 防御式重建; > 重力参考端作用于 `.z`=上、本 POC 映射到 `-Y`;怪物球体碰撞 / `CullingMgr` / 建筑体素判定未 > 移植(客户端飞行物只做命中时机,伤害服务端裁决);`.fly` 数据文件加载器缺失 → 合成箭参数。 > 留给增量 93+:§3.1 Actor-Actor 碰撞(`TestActorCollision` / `BlockMovement`)+ 坐骑 `MODE_HORSE*`。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.6 / C.5。 > - **2026-09-03 增量 93(W2 / §3.1 收尾:Actor-Actor 碰撞 + 坐骑转向速度)**: > ① `project/player_controller.gd` 新增 `_actor_blocked(next_pos)`——`CActorInstance::TestActorCollision` > (`ActorInstanceCollisionDetection.cpp`)+ `CInstanceBase::CheckAdvancing`(`InstanceBaseBattle.cpp:469`) > 近似:距离门 `ACTOR_COLLIDE_MAX_DIST_M = 8.0 m`(≈ `LengthSq(victim - self) > 800²` 直接不检)→ > body 球重叠(`ACTOR_BODY_RADIUS_M = 0.55`,自身 + 对方相加 = 判定阈值;固定半径近似真实 race > 碰撞球)→「趋近」判定 `d_next < combined && d_next <= d_now`(对齐 `TestActorCollision` 末尾 > `dist(cur) <= dist(last)` 才 `return TRUE`,远离时放行 → 能从重叠里走出来)。候选 = `pickables` > (每个已生成的远端 NPC / 怪 / 他人节点),`dead` meta 跳过(≈ `rVictim.IsDead()`)。 > ② `_process` 平移分支改逐轴门控:`block_x = _actor_blocked(仅动 X) or _blocked(...)`、`block_z` > 同理——单个 Actor 逐轴放行 = 滑移(≈ `AdjustDynamicCollisionMovement`,该函数不在发布源码树), > 两轴都挡 = 停在上一帧位(≈ `uCollisionCount==2` / 调整后仍撞 → `BlockMovement()` = `__InitializeMovement`); > 随后既有 `disp` 判定把顶人磨蹭切回 `_is_going = false` + `wait`。③ `skip_actor_collision` > (≈ `CActorInstance::CanSkipCollision()` / `__EnableSkipCollision`,§3.2 网络状态包驱动的移动整段 > 跳过)——`net_play` 在 `setup()` / `_on_observer_mode` 按 `_observer_mode` 置位。④ 坐骑转向速度: > `player_controller` 新增 `rotation_speed_deg`(默认 `ROT_SPEED_DEFAULT_DEG = 1200` = `c_fDefaultRotationSpeed`) > + `_turn_toward(target_yaw, dt)`(`SetAdvancingRotation` 的逐帧限幅收敛,移动方向不受限,仅朝向变慢), > 替换原来两处 `player.rotation.y = atan2(...)` 瞬转;`net_play._refresh_motion_mode()` 在 `mounted` > 时把 `rotation_speed_deg` 切到 `ROT_SPEED_HORSE_DEG = 300`(= `c_fDefaultHorseRotationSpeed`,对齐 > `CInstanceBase::MountHorse` → `SetRotationSpeed`,`InstanceBase.cpp:954`),下马恢复 1200。坐骑动作 > 模式(`MODE_HORSE*`)已由增量 84 的 `motion_mode_for` + `mount_changed` → `_refresh_motion_mode` > 覆盖,本增量不重复。⑤ `project/player_move_test.gd` 5 组 → 8 组(正前方 Actor 挡下且 `is_going` > 归零、`dead` Actor 不挡、`skip_actor_collision` 直穿、坐骑单帧转向 ≤ 300 deg/s 且仍朝 Dst 平移)。 > C++ **21/21**(无 C++ 改动)+ **14** 个 GDScript 回归全绿。seam:真实 body sphere 半径需 race > 碰撞数据(`.col` / `CollisionData.h`);`AdjustDynamicCollisionMovement` 精确滑移向量 + 门 / Actor > 复合的 `uCollisionCount==2` 用逐轴近似代替;`EnableSkipCollision` 尚未接到 §3.2 网络状态包驱动的 > 本地预测玩家;`MOVABLE_GROUND_DISTANCE` 参考默认值仍是估计(1.0 m)。留给增量 94+:§3.2 / §3.5 > 收尾(多为待真实服务器);转 M2(W3 玩法)。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.1 / C.5。 > - **2026-09-03 增量 94(W3→M2 / §5.4 PVP·PK·决斗可攻击判据 `IsAttackableInstance` 1:1)**: > ① 新建 `project/entity_rules.gd`(纯静态 `extends RefCounted`)——`is_attackable_instance(self_e, > victim_e, ctx)` 逐行照抄 `CInstanceBase::IsAttackableInstance`(`REF/UserInterface/InstanceBase.cpp:2147`): > 主角 + 观战 → false;自打自 → false;`IsStone` / `IsPC` / `IsEnemy` / `IsPoly` 四分支。PC 分支内 > victim.IsPC 时按序:决斗三态(`DUEL_CANNOTATTACK` → false / `DUEL_START` → `__FindDUELKey` ? true : false)、 > `PK_MODE_GUILD` && 同公会 → false、`victim.IsKiller()` && !同队 → true、`PK_MODE_PROTECT != GetPKMode()` > 下 `PK_MODE_FREE`(victim 非 PROTECT && !同队 → true)/ `PK_MODE_GUILD`(再加公会不同 → true)、 > `IsSameEmpire` 时 `IsPVPInstance` → true / `PK_MODE_REVENGE` && !同队 && `IsConflictAlignmentInstance` > → true,异帝国直接 → true;随后 `victim.IsEnemy()` / `victim.IsWoodenDoor()` → true。判据 helper 全 > 1:1:`alignment_grade`(:502 九档)、`alignment_type`(:524)、`is_conflict_alignment_instance`(:2116)、 > `_is_same_empire`(:470)、`_is_pvp_instance`(`InstanceBaseEffect.cpp:505`)、`_find_duel_key`(:495)、 > `_is_same_party`(`PythonPlayer.cpp:1445`)、`_is_wooden_door`(:2390,vnum 13000 / 30111..30119); > `_is_pc` / `_is_enemy` / `_is_poly` 依 `CActorInstance`(`ActorInstance.cpp:270/282/298`)的 `m_eActorType` > 单值语义——变身玩家仍是 `TYPE_PC`(`else if (IsPC())` 先命中,`IsPoly()` 分支只服务真正的 `TYPE_POLY`)。 > ② `project/net_play.gd`:`_is_attackable(e)` 从「`_entity_kind(e) in [0,2]`」桩改为拼 `self_e` > (`_main_entity()` 快照 + `kind`)/ `victim_e`(+ `kind`)/ 新 `_attack_ctx()`(从 `client.get_duel()` / > `get_pvp_relations()` / `get_guild_wars()` / `get_party()` 装配 observer / pk_mode / duel_mode / > duel_opponents / pvp_pairs / gvg_pairs / party_vids),委托 `EntityRules.is_attackable_instance`; > `_main_entity()` 为空(headless)退化旧近似。5 处调用点签名不变。③ 新增 `project/entity_rules_test.gd` > (第 15 个 canonical,25 组断言);`combat_fx_test` / `netplay_test` / `fly_test` 的怪物夹具补 > `"ch_type": 2`(此前无 `ch_type` → `_entity_kind` 归 PC,旧桩按 `[0,2]` 也「可打」;1:1 规则下和平同 > 帝国 PC 不可攻击 → 夹具须明确怪类别,与真实服务器一致)。C++ **21/21**(无 C++ 改动)+ **15** 个 > GDScript 回归全绿。seam:`__FindPVPKey`(`GC_PVP` 关系表 setter)/ `__FindGVGKey`(公会战对局 setter)/ > `IsGameMaster` 标记 / `IsBuilding`·`IsWall` 的 race 白名单(源码未随发布)/ m2dev 后端 `IsKiller` 状态位 / > `_entity_kind` 无法分类时怪被当和平 PC(生产 mob_proto 恒加载);名字颜色 §8.8、`IsInSafe` 的 > `ATTRIBUTE_BANPK` 地形属性仍未接。留给增量 95+:§5.1 quest 38 tag、§5.3 fishing/dungeon/mount/observer > UI、§4.1 costume/belt。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §5.4 / C.5。 > - **2026-09-03 增量 95(W3→M2 / §5.1 Quest EventManager `CPythonEventManager` 1:1)**: > `project/ui/quest_dialog.gd` 由旧的单遍手写 `parse_script` 重写为 `EventTypeMap` + 状态机 + > 分派:① `const EVENT_TYPE_MAP`(42 键,tag 字符串 → `EVT_*`)+ `enum { EVT_LETTER .. EVT_SELECT_ITEM }` > (顺序 = `REF/UserInterface/PythonEventManager.h:35` 的 `EEventType`)+ `_get_script_event_index()` > (= `GetScriptEventIndex` `PythonEventManager.cpp:1093`:前导 `/` → `EVENT_POSITION_END` + 剥离, > 未知名字 → `ok=false` / 跳过、不进 `match`)。② `class EventSet extends RefCounted`(= `SEventSet` > `:98`,字段名 1:1:`ix/iy/iWidth/iyLocal/isLock/lLastDelayTime/iCurrentLetter/CurrentColor/ > strCurrentLine/isConfirmWait/iConfirmEndTime/DiffuseColor/lWaitingTime/iRestrictedCharacterCount=30/ > iVisibleStartLine/iVisibleLineCount/iAdjustLine/isTextCenterMode/isWaitFlag/nAnswer`)+ > `init_event_set()`(`__InitEventSet` `:72`,常量 `c_lNormal_Waiting_Time=10` / `c_fLine_Temp=16` / > `BOX_VISIBLE_LINE_COUNT=5`)、`insert_line()`(`__InsertLine` `:976`)、`clear_line()`(`ClearLine` `:953`)。 > ③ `_process_event_set(es, cmd)` = `ProcessEventSet()` `:377` 的 `switch` 逐 `case` 对拷:`DELAY` / > `COLOR` / `COLOR256` 的 START vs `/END`、`LETTER` / `ITEM` / `MOB` 的 > `iCurrentLetter >= iRestrictedCharacterCount → insert_line` 断行、`NEXT` / `DONE` / `INSERT_IMAGE` 的 > `iAdjustLine += 2`、`WAIT`(`iyLocal=0; isLock=true`)、`QUESTION`(= `MakeQuestion` `:884`)、 > `CLEAR` / `CLEAR_TEXT` 均走 `clear_line`、image family + `TITLE_IMAGE`、`ADDMAPSIGNAL` / > `CLEARMAPSIGNAL` / `SETCMAPPOS`、`SETMSGPOS` / `ADJMSGPOS` → `pass`(引 `:562/:566` 空实现)、 > `SLEEP`、`SET_CAMERA`(`_camera_setting` 九参 = `GetCameraSettingFromArgList` `:48`)/ `BLEND_CAMERA` > (+`blendtime`)/ `RESTORE_CAMERA`、`FADE_*` / `WHITE_*` → `isWaitFlag=true`、`DUNGEON_RESULT` 9 参、 > `QUESTBUTTON` / `QUESTBUTTON_CLOSE`、`WINDOW_SIZE`、`INPUT`、`CONFIRM_WAIT` / `END_CONFIRM_WAIT`、 > `SELECT_ITEM`、`RUN_CINEMA` → `pass`(`ProcessEventSet` 无此 case)。④ `_tokenize()` 拆混排文本 / > `[tag ...]`;裸网络文本折成合成 `LETTER`(`is_raw=true` → 跳过 30 字断行,reference 脚本只有显式 > `[LETTER]`)。arg 提取 `_tag_kv()`(`key(v)` / `key("v")` / `key;v`)+ `_arg_s/_arg_i/_arg_f` > (keyword 优先、位置回退,兼容旧测试 `value;255` / `value(1)` 写法)。⑤ `parse_script()` 返回字典 > =旧契约全键不变,新增 `insert_images / dungeon_result / adjust_line / waiting_time / is_locked / > wait_flag / answer_count / cinema_file / end_confirm`;首命令 `RUN_CINEMA`(`RegisterEventSetFromString` > `:142` 特例,仅当 `cmds[0]` 即 `RUN_CINEMA`、无前导文本)→ `es.cinema_file` + 新 `signal > cinema_requested`(`_emit_side_effects` 透出)。新增 `project/quest_event_test.gd`(第 16 个 canonical, > 23 组断言);既有 `quest_test` / `select_item_ui_test` 仍绿。C++ **21/21**(无 C++ 改动)+ **18** 个 > GDScript 回归全绿。seam:逐帧 `UpdateEventSet` `:312` 的时间推进(`lLastDelayTime` 递减 / `isWaitFlag` > 暂停 / `isConfirmWait` 倒计时刷新 `m_strLeftTimeString`)、`Skip` / `EndEventProcess`、`iConfirmEndTime` > 的 `timeGetTime`、`.msc` 事件文件加载(`RUN_CINEMA` 换文件)、`MakeNextButton` NEXT vs DONE 文案区分 + > `DoneEvent()` 回调、`camera_event_requested` / `fade_event_requested` / `cinema_requested` 消费端与 > §7.6 相机模式、game_scene 接线、物品名颜色 `(1.0,0.2,0.2)` 均未接(一次性跑完 token,`isLock` / > `isWaitFlag` 仅作状态保留并透出)。留给增量 96+:§5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer > UI、§4.1 costume/belt 窗口、§4.2 拖放鼠标物品态。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §5.1 / C.5。 > - **2026-09-03 增量 96(W3→M2 / §4.1 wear 槽位版图 + costume/ring/belt 发包坐标 `GameType.h` 1:1)**: > `project/ui/inventory_ui.gd._to_wire()` 按 `REF/UserInterface/GameType.h:40-112` 把装备格拆两路—— > `c_Equipment_Start+0..10`(`EWearPositions` BODY..SHIELD,`WEAR_MAX_NUM=11`)走 `WINDOW_EQUIPMENT(2)` + > 零基 wear cell;`ENABLE_COSTUME_SYSTEM` / `ENABLE_NEW_EQUIPMENT_SYSTEM` 的 > `c_Costume_Slot_Start(90+19)` / `c_Costume_Slot_Hair(90+20)` / `c_Equipment_Ring1(90+21)` / > `c_Equipment_Ring2(90+22)` / `c_Equipment_Belt(90+23)` 在客户端没有独立窗,继续用 > `WINDOW_INVENTORY(1)` 的全局 cell 109..113——修掉了 `inventory_test.gd` 里 `_to_wire(113) == [1, 113]` > 一直红的断言(旧实现返回 `[2, 23]`)。新增常量 `WEAR_MAX_NUM=11` / `WEAR_COSTUME_BODY=19` / > `WEAR_COSTUME_HAIR=20` / `WEAR_RING1=21` / `WEAR_RING2=22`(`WEAR_BELT=23` 值不变)/ > `NEW_EQUIP_WEAR_START=19` / `WINDOW_EQUIPMENT=2` 全部 1:1 自 `GameType.h` 与 `ItemData.h` > `EWearPositions`。`_add_extended_cells` 由「wear 11..34 连续 24 格」收窄为「19..23 这 5 个真实新 > 装备位 + 16 格腰带背包」(`EWearPositions` 11..18 无位置,`refresh()` 对空槽自动跳过)。 > `inventory_test.gd` 增 7 条 wire 坐标断言(`_to_wire(90)→[2,0]` / `(100)→[2,10]` / `(109..112)→ > [1,109..112]`);`inventory_ui_test.gd`(`move_to(5,91) → [1,5,2,1,1]`,基础 wear 仍走 window=2) > 保持绿。C++ **21/21**(`net.loopback_flow` 首跑 localhost bind 抖动,`--rerun-failed` 即过;无 C++ > 改动)+ **55** 个 GDScript 回归全绿。seam:§4.1 修改 2(`antiflag→wearable_flag→limit` 装备校验链 + > `CANNOT_EQUIP_*` 文案)——40250 客户端不做 `CanEquipNow` 前置门(`PythonItemModule` 只暴露 > `IsWearableFlag` / `GetLimit`,仲裁在服务器 `char_item.cpp`),需按 §3.9 `__CanShot` 方式防御性重建; > 修改 4(穿 costume 隐藏本体 armor / 穿头盔隐藏发型,`InstanceBase.cpp` `ChangeArmor` / `SetHair`)、 > 修改 5(远端角色 `armor/weapon/hair` 三字段接换模)render 侧未接。留给增量 97+:§4.1 修改 2/4/5、 > §5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态。详见 > [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §4.1 / C.5。 > - **2026-09-03 增量 97(W3→M2 / §4.1 修改 2 —— `CanEquipNow` 前置门 `ItemData.h` 1:1)**: > 新增 `project/equip_rules.gd`(`extends RefCounted` 纯校验器),枚举 1:1 自 `REF/GameLib/ItemData.h` > ——`EItemAntiFlag`(classic 排列:`FEMALE=1<<0`…`SHAMAN=1<<5`,帝国位 `EMPIRE_A/B/R=1<<9..1<<11`)、 > `EItemWearableFlag`(`BODY=1<<0`…`ARROW=1<<9`)、`EWearPositions`(0..10)+ `GameType.h` 新装备槽 > 19..23、`ELimitTypes`(`LIMIT_LEVEL=1`…`LIMIT_CON=5`、`PCBANG=6`、`REAL_TIME=7`)、`EItemType` > (`WEAPON=1`/`ARMOR=2`/`COSTUME=28`/`RING=33`/`BELT=34`)+ `EArmorSubTypes`/`ECostumeSubTypes`。 > `can_equip(item, wear, ctx) -> {ok, code, need}`,语义顺序对齐服务器 `char_item.cpp::CanEquipNow`: > 目标槽解析(`wear<0`→`default_wear_for()`)→ anti-flag 性别(`ANTI_SEX`)→ 职业(`ANTI_JOB`, > `JOB_ANTIFLAG[race%4]`)→ 帝国(`ANTI_EMPIRE`,`GC_EMPIRE 1/2/3`)→ `slot_accepts()`(新槽按 item > type、旧 11 槽按 `wear_flags & wear_to_wearable(wear)`,`NOT_WEARABLE_HERE`)→ limit 逐条 > (`LIMIT_LEVEL`:`ctx.level=0 且 `SEX_BY_RACE` / `job_of_race`=`race%4` / `sex_of_race` 表 1:1 自 `MAIN_RACE_*`。 > `inventory_ui.gd.use()` 对「常规背包格 + 可穿戴物品」跑此门,拦下则 `_set_hint()` + 新 > `signal equip_rejected(code, need)` + 不发 `use_item`;新增公开 `can_equip_item()`、`_equip_ctx()` > (`get_points().level` + `get_entities()` 主角 `race/empire/level`;`st/dx/ht/iq` 未由 M2Client 按名 > 暴露 → 传 `-1` = seam)、`_equip_reject_hint()` 中文文案。新增 canonical 测试 #17 > `project/equip_rules_test.gd`(33 断言);`inventory_ui_test.gd` 的 `FakeProto.item()` 补 > `sub_type/anti_flags/wear_flags/limits` 让 `iv.use(5)` 仍过门。C++ **21/21**(无 C++ 改动)+ **56** > 个 GDScript 回归全绿。seam:精确 tie-break 顺序 / 已穿同槽替换 / `LIMIT_PCBANG`·`REAL_TIME*` / > 属性 4 项按名读取留待接服务器比对;§4.1 修改 4(部位隐藏遮挡)+ 修改 5(远端换装接字段)为 render > 侧,留给增量 98+。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §4.1 / C.5。 > - **2026-09-03 增量 98(W3→M2 / §4.1 修改 4 —— 变身 / 时装部位遮挡 `InstanceBase.cpp` 1:1 + 修改 5 字段序确认)**: > 新增 `project/part_hiding.gd`(`extends RefCounted` 纯函数),常量 1:1 自 > `REF/UserInterface/InstanceBase*.cpp`:`POLY_SHAPE=0`(`IsPoly()`→`SetShape(0)`,`SetWeapon`/`SetParts` > 提前 return)、`ANIMAL_WEAR_SHAPES=[100,101,102,103]`(`__IsShapeAnimalWear`:2834 → `SetWeapon`:2747 / > `SetParts` 返回 false)、`WEDDING_DRESS_SHAPE=201`(`IsWearingDress`)、`BOUQUET_VNUMS=[50201..50204]` > (`c_iBouquets`,`__IsChangableWeapon`:901 除捧花外一律 false)。函数 `is_animal_wear` / `is_wearing_dress` > / `is_bouquet` / `is_changable_weapon`(婚纱→仅捧花)/ `weapon_hidden`(变身 ∨ 动物时装 ∨ 婚纱非捧花)/ > `hair_hidden`(变身 ∨ 动物时装;婚纱不影响头发)/ `body_shape`(变身→0,动物时装 100-103 保留)/ > `effective_weapon`。`equip_model.gd.refresh()` 刷新序改为对齐 `ChangeArmor`(`SetArmor→SetHair→SetWeapon`) > = 身体→头发→武器→盾;解出身体 shape(`armor_shape_of`=`__ArmorVnumToShape` 的 `values[3]`/`=vnum`) > 后过 `PartHiding`:动物 / 怪物时装隐藏 `weapon_gr2`+`shield_gr2`+`hair_gr2`,婚纱非捧花隐藏武器+盾、 > 头发保留;新增 `_is_poly()` **seam**(40250 实体表无变身字段,恒 `false`)。`net_world._apply_field_updates()` > 确认 `set_armor(parts[0])→set_weapon(parts[1])→set_head(parts[2])→set_hair(parts[3])` 字段序已 1:1 于 > `NetworkActorManager.cpp:473`(增量 46 落地),补注遮挡走模型侧 `part_hiding.gd`。新增 canonical 测试 > #18 `project/part_hiding_test.gd`(45 断言)+ `equip_model_test.gd` 补 7 段遮挡断言。C++ **21/21** > (无 C++ 改动)+ **57** 个 GDScript 回归全绿。seam:变身态(等 `GC_CHARACTER_UPDATE` 带变身 race); > 远端 PC 真模型工厂已在增量 99 首版落地(`RemotePlayerView` + 只消费 `awPart[4]` 的远端 > `EquipModel`),所有职业 / race、远端真服换装和精确视觉仍待验收。详见 > [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §4.1 / C.5。 > - **2026-09-03 增量 101(W3→M2 / §5.2 Quest 日志窗口)**:按参考端 `QUEST_MAX_NUM=5` 固定渲染 > 5 行,超过 5 条显示滚动条并映射 `questShowingStartIndex`;空 icon 使用 > `season1/icon/scroll_open.tga`,任务行点击发送 `-2147483648 + questIndex`;`quest_test.gd` > 已覆盖 7 条任务、首尾滚动和事件参数。多列定位、世界箭头、屏幕边缘指示、完整 `.sub` 视觉 > 和真实任务生命周期仍待验收。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §5.2 / C.5。 > - **2026-09-03 增量 102(W3→M2 / §5.3 钓鱼输入状态机)**:钓鱼模式攻击键改发 > `CG_FISHING` 方向并阻断普通攻击;方向键边沿、地面点击接入 `NEW_CancelFishing`,地面点击 > 先清预约 / 自动攻击再取消,取消保留 500ms 节流;快捷栏特殊技能 123 接入钓鱼入口; > `GC_FISHING` 五类事件维护钓鱼意图状态。`netplay_test`、`player_move_test`、`skill_test`、 > `gamescene_test`、`combat_fx_test` 全部 PASS。尚缺 `GetFishingRot` 水面像素扫描、 > wrong-place 文案、鱼获物品落袋及副本 / 坐骑 / 观战者完整玩法。详见 > [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §5.3 / C.5。 > - **2026-09-03 增量 103(W3→M2 / §5.3 GetFishingRot 水面扫描)**:新增 > `Metin2World.get_fishing_rotation()` / `can_fishing_position()`,读取已加载区块的 > `ATTRIBUTE_WATER`,按参考端前方 600cm、左右 0..180° 每 10° 扫描并返回选中 heading; > `NetWorld` 将地图层查询桥接给 `NetPlay`,无地图 provider 时保留协议 fallback。扩展重新编译通过, > `fishing_water_test.gd` 真实 A1 20 区块回归 PASS。仍缺 wrong-place locale 文案、鱼获 item > proto / inventory 落袋和副本 / 坐骑 / 观战者完整玩法。详见 > [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §5.3 / C.5。 > - **2026-09-03 增量 104(W3→M2 / §5.3 钓鱼消息分支)**:`ChatUI` 加载 locale_game.txt, > 按 `RecvFishing` 的状态与 item proto 类型分派 `FISHING_NOTIFY1/2`、`FISHING_SUCCESS1/2`、 > `FISHING_FAILURE`、`FISHING_UNKNOWN` 和 `FISHING_WRONG_PLACE`;`SUCCESS` 只作为动画事件, > 不再误报“鱼钩命中”。`chat_test.gd`、`gamescene_test.gd` PASS。真正物品数量仍等待 > 独立 `GC_ITEM_*` 包验证,副本 / 坐骑 / 观战者完整玩法仍未完成。详见 > [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §5.3 / C.5。 > - **2026-09-03 增量 105(W3→M2 / §5.3 坐骑攻击等级门)**:按 > `40250/ClientVS22/source/UserInterface/InstanceBase.cpp:124-216` 与 > `InstanceBaseBattle.cpp:623-628` 接入坐骑 vnum→level 映射和 `SHORSE::CanAttack`(level 1 禁攻); > 按 `PythonPlayerInput.cpp:737-764` 接入新坐骑 `GetSkillGrade(109) < 1 && GetSkillLevel(109) < 11` > 门禁。`net_play.gd` 的自动攻击改用 `mount_vnum`,`netplay_test.gd` PASS。坐骑真实模型 / > `MODE_HORSE*` 动作 / 移速及真服坐骑数据仍待验收。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) > §5.3 / C.5。 > - **2026-09-03 增量 106(W3→M2 / §5.3 观战者小地图插值)**:按 > `40250/ClientVS22/source/UserInterface/PythonMiniMap.cpp:16-64,168-180` 接入 > `GC_OBSERVER_ADD/REMOVE/MOVE` 的独立轨迹和 1 秒线性插值;新增初始快照播种和 `world_reset` > 清理。`p9_test.gd` 覆盖 ADD / MOVE / REMOVE / reset 并 PASS。观战自由视角、真服完整生命周期 > 和副本 / 坐骑剩余项仍待验收。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §5.3 / C.5。 > - **2026-09-03 增量 107(W3→M2 / §5.3 副本目的地状态)**:按 > `40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp:3875-3901` 与 > `PythonPlayer.cpp:147-167,1255-1285` 接入 `GC_DUNGEON` 目的地状态:保存全局厘米坐标、 > 收包立即提示、按 Manhattan `<10000cm` 清除目标、每 20 秒重新提示,并在 `world_reset` 清理。 > `hud.gd` 增加目标距离 / 朝向提示;`TIME_ATTACK_START` 保持参考端空分支,不虚构本地计时器。 > `dungeon_state_test.gd`、`gamescene_test.gd`、`p9_test.gd`、`netplay_test.gd` PASS。DungeonBlock > `.mdatr` 碰撞、真实副本入口 / 结算窗和真服验收仍待完成。详见 > [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §5.3 / C.5。 > - **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 增量 50(Phase 1 W1)**:落地 `CLIENT-GAP-FIX.md` §1.5 / §1.10 / §2.2。 > §1.5:`ClassicSession::send_client_version()` 改为 locale 分支——EUROPE(本项目 locale)发 > `CG_CLIENT_VERSION2`(header `0xf1`,`CGClientVersion2` 67B,timestamp 固定 `"1215955205"`), > 非 EUROPE 发 `CG_CLIENT_VERSION`(`0xfd`)+ CMake 注入的 `MT_BUILD_TIMESTAMP`(C `__TIMESTAMP__` > 形态);新增 `set_executable_name()`(默认 `"metin2.bin"`,已由 M2Client 传入 > `OS::get_executable_path().get_file()`);版本包只在 `send_fixed()` 成功后置 `m_version_sent`。 > `wire_classic.h` 补 `CGClientVersion2` 结构 + 尺寸表项。§1.10:新增 `extension/src/net/text_codec.h` > —— `to_wire()/from_wire_str()` 做 UTF-8 安全的按字节裁剪(不切多字节序列,硬字节上限), > `godot::String` 重载用 `__has_include` 守卫使 mtnet 仍无 godot 依赖;`classic_session.cpp` 里 > create/change name、whisper、私店招牌、公会等级名 / 公告、`ANSWER_MAKE_GUILD`、好友增删共 8 处 > 定长填充改走 `to_wire()`,parser 侧 `GC_CHAR_ADDITIONAL_INFO` 名字读取走 `from_wire_str()`; > 新增 `net_text_codec_test`(CJK 24B 收下 / 27B 截到 24B / 多字节边界 / ASCII 往返 / 读端悬挂字节)。 > 真实 locale codepage(CP949 / CP1252)转码仍为后续项,当前 wire 字节即 UTF-8(ASCII 往返一致)。 > §2.2:`ClassicParser` `GC_CHARACTER_ADD` —— `is_invisible_race`(20025/20038/20039)直接丢弃; > `bType==PC||NPC` 仅把裸 `Entity`(vid/race/type/坐标/朝向/速度/state·affect flag)暂存进 > `m_pending_actor`,不碰世界模型;其余(怪 / 石 / 门 / 物件)即时 `mut_spawn()`,名字取自 > `root/npclist.txt`(race→code,新增 `npc_names.h` + `set_npclist_path()` setter,M2Client 已按 > `MT_ASSETS` 传入资源根,未配置时回退 `$M2_ASSETS`,无表时名字为空)。`GC_CHAR_ADDITIONAL_INFO` > —— 无对应暂存则写 `m_last_error` + `m_unhandled_headers` 并丢弃(绝不半创建);命中则把暂存 > `Entity` 与附加信息 9 字段(name/guild/level/alignment/pk_mode/empire/parts[armor/weapon/hair]/ > mount_vnum)合并为一条,`mut_spawn_full()` 落地后 `erase` 暂存项。新增 `pending_actor_count()` > 测试钩子;`net_classic_session_test` 更新(版本包断言改 `0xf1`/`CGClientVersion2`;新增两包 > 暂存 → 合并 → 落地、怪即时生成、invisible-race 丢弃、无暂存附加信息为空操作的覆盖)。 > 依赖 W2 §2.2 步骤 4–5(`mut_char_info` touch-create / `mut_shop_sign` 裸 `Entity` 清理)—— > 本次合并逻辑与当前 W2 代码兼容。C++ `ctest` 17/17(原 16 + `net.text_codec`)、`netbridge_test`、 > `p10_test` 全绿。 > - **2026-09-02 增量 50(Phase 1 W2:实体两层模型 + 可见性 / 单次覆盖 / 点数边界 / 应用顺序)**: > 把可见性从 `EntityStore` 彻底移到 `net_world.gd`。**§2.1**:`EntityStore` 只做数据层—— > 仅 `mut_spawn / mut_spawn_full / mut_spawn_main` 建行,`mut_mount / mut_char_info / > mut_shop_sign` 遇未知 VID 只 `dbg_ignored()`(`MT_NET_TRACE` 门控)后返回,不再 `touch()` > 出半行、不做 replay 缓冲;`net_world.gd` 每帧 `_update_visibility()` 按 > `is_main / AFFECT_SHOW_ALWAYS(bit7) / IsWall(14201–14204) / 平面距主角 < > CHAR_STAGE_VIEW_BOUND(200*100 cm)` 决定建节点,超 `+10` 淡出并保留数据行(`_fading` 集合 > 防淡出中重建),只在主角坐标已知时启用、否则沿用 `entity_spawned` 建节点。**§2.3**: > `mut_spawn()` 改为委托 `mut_spawn_full()`,后者整行值替换(`m_ents[vid] = e`,不再保留旧 > move 状态,对齐 `SNetworkActorData::__copy__`);`net_world._on_spawn` 对已有节点的 vid > 先销毁再重建,上/下坐骑触发的重建复用旧世界坐标。**§2.4**:`mut_set_points()` 全量快照 > `> POINT_MAX_NUM(255)` 截断、`< 255` 清零尾部;`mut_set_point()` 索引 `>= 255` 拒绝。 > **§2.5**:新增 `EntityStore::mut_affect_flags(vid, lo, hi)`(`v = lo | (hi<<32)` 两段拼装), > `mut_char_update()` 的 guild/alignment/pk 变化也触发 Info;`net_world._apply_field_updates()` > 按参考调用序(`NetworkActorManager.cpp:470`:Armor→Weapon→Hair→Guild→AffectFlag→MoveSpeed→ > AttackSpeed→Alignment→PKMode→StateFlags)刷新节点。C++ `ctest` 16/16(`net.entity_store` > 加 §2.1 数据层断言)、新增 `net_world_vis_test.gd`、`netbridge_test` / `gamescene_test` / > `netplay_test` / `p9_test` / `combat_fx_test` / `player_motion_test` 全绿。待 W1 跟进: > `wire_classic.h` 的 `GCPoints` 已有 `static_assert(sizeof == 1 + 255*4)`;`ClassicParser` > 需在 `GC_CHARACTER_ADD[2]` / `GC_CHARACTER_UPDATE` 后调用 `mut_affect_flags()` 把 > `affect_flag[2]` 送进实体(`mut_char_update` 无 affect 形参,未接线)。G1 集成已接线(见下)。 > - **2026-09-02 增量 50(Phase 1 W3 / §8.3 帮助窗)**:新增 `project/ui/help_ui.gd` —— 1:1 > 迁移 `assets/root/uihelp.py` 的 `HelpWindow`(单页版 `ENABLE_HELP_MULTIPAGE = 0` → > `LoadDialogSinglePage`),布局走真 `assets/uiscript/uiscript/helpwindow.py`(UiScript → > UiBuild),19 条浮动帮助行 + 10 个任务栏贴纸标签 + `close_button` 的文本逐字对齐 > `uiScriptLocale.HELP_*`(`locale/locale/en/locale_interface.txt`);`UiManager` 未接 locale > 回调时 `helpwindow.py` 里的 `uiScriptLocale.X` 被解析成 `` 占位,`_relabel()` 用本工程 > `Locale` 表 + 英文兜底补回(含原脚本重名的两个 `help_02`)。入口:`game_scene.gd` > `_unhandled_input` 加单条 `elif`(`H` 且非 Ctrl / 非 Meta → `system_menu_ui.toggle_help()`; > `Ctrl+H` 仍留给 `/user_horse_ride`);`system_menu_ui.gd` 的 `help_button` 由占位提示改为 > `close()` + `open_help()`,新增 `_ensure_help()` 懒建 / `open_help()` / `toggle_help()` 与 > 可注入的 `help_ui` 成员(`setup()` 追加可选第 6 参,旧 5 参调用不受影响)。新增 > `project/help_ui_test.gd`(helpwindow.py 装载 + 19 行逐字绑定 + 任务栏标签 + close_button + > open/close/toggle + ESC 栈顶 + `SystemMenuUI.open_help()` 懒建)。`help_ui_test` / > `system_menu_ui_test` / `gamescene_test` / `ui_test`(附加 `system_option_ui_test` / > `char_status_ui_test`)全绿;本 worktree 无 `build/`,未跑 C++ `ctest`。视觉 / `.sub` 贴图 > 与真服未验证。 > - **2026-09-02 增量 50(Phase 1 W4:地图 BGM / 音频)**:落地 > [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §9.1 §9.2。§9.1:`bgm_director.gd` > 补齐「地图 → 曲目」解析——服务端 `bgm_changed(name, volume)` 名非空即播;名为空回落 > `musicInfo.fieldMusic`(`METIN2THEMA = "M2BG.mp3"`);`volume < 0`(服务端未下发, > `GC_MAIN_CHARACTER3_BGM`)用默认 `1/5*0.1 = 0.02`,否则用服务端值;应用音量在 > `play_bgm()` 淡入之前(对标 `game.py:190` 先 `SetMusicVolume/SetSoundVolume` 再 > `FadeInMusic`);连 `M2Client.world_reset` 在换图时重跑选曲。新增 `musicinfo.gd` > (`METIN2THEMA / loginMusic / createMusic / selectMusic / fieldMusic` 五常量 + > `save/load_last_play_field_music` → `user://BGM/lastplay.inf`)。§9.2:`Audio` 新增 > `save_volume() / restore_volume()`(字段名对齐参考 `m_fBackupMusicVolume / > m_fBackupSoundVolume`)与 `set_music_volume()`;`app_lifecycle.gd` 进后台改为 > `save_volume()` + 静音(不再 `stop_bgm()`),回前台 `restore_volume()`,`_bgm_name` > 全程不清空,同一首继续播。新增 `bgm_test.gd`;`bgm_test`、`gamescene_test`、`netplay_test`、 > `p9_test` 全绿;真服 BGM 验收待 40250 服务端。§9.3 后续由增量 51 单独落地。 > - **2026-09-02 增量 52(W0-W2 / §3.9 公共网络 API 参数边界)**:新增 `extension/src/net/net_bounds.h` > 统一封装 `u8/u16/u32/i32`、36 快捷栏槽、12 交易槽、24 Cube 槽、39 私人商店项、15 龙魂精炼格等 > 已核实边界;`M2Client` 在窄类型转换前校验连接端口、角色创建、坐标 / VID、动态文本包长度、聊天 / > 技能、社交、公会、商店 / 交易 / 仓库 / 私人商店 / Cube、精炼、物品与会徽参数,超限直接返回 `false`, > 不再静默截断或把负数转为无符号值。新增 `net_bounds_test.cpp`,C++ `ctest` 18/18,通过相关 Godot > 离线回归;真实 40250 业务合法性仍待验收。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.9 / C.5。 > - **2026-09-02 增量 53(W3/W4/W5 / §4.8 §6.3 §8.6 §10)**:W3 将快捷栏改为参考端的服务器 > 36 槽 / 客户端 4 页 × 8 格,类型改为 `INVENTORY=1/SKILL=2/EMOTION=3`,表情槽调用 > `send_emoticon()`;补齐 LSHIFT 翻页、F1–F4、Ctrl/Command+数字、空格按住攻击、Alt 显示名字、 > 相机按住 / 释放、截图、帮助 / 坐骑 / 角色窗口 / 小地图 / 聊天等键位入口。W4 的 `.mse` 粒子现在 > 按参考 `EmitterShape` 读取位置、形状、方向 / 速度,并通过 `UiAssets` + 原生 DDS 解码加载首个 > `TextureFiles`;`.mde` 等仍明确保留为未完成项。W5 将 `assets` / `bgm` 自包含复制进 App,支持 > `MT_MAC_ARCHES=universal` 与 `lipo` 门禁、嵌套扩展签名顺序,移除发布代码中的硬编码凭据。离线 > `fx_test`、`input_key_test`、`skill_test`、`netplay_test`、`netbridge_test`、`gamescene_test` 全绿; > 2.2G 完整导出、当前机器双架构产物、正式证书 / notarization 和真服 / 真机仍未验证。详见 > [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §4.8 / §6.3 / §8.6 / §10 / C.5。 > - **2026-09-02 增量 54(W3 / §4.2 §4.9)**:新增 `cursor_manager.gd` 和 `mouse_controller.gd`, > 对齐 `mousemodule.py` 的全局物品拖放边界:背包物品可拖到其他背包格或快捷栏,释放到无效区域、 > 右键、ESC、失焦均取消;快捷栏物品槽类型和装备窗口 wire 坐标保持原语义。`UiManager` 增加窗口 > 键盘焦点屏蔽,`PlayerController` / `GroundItems` / `NetWorld` 接入 Talk / Attack / Pick 等光标 > 状态。新增 `mouse_controller_test.gd`,并重跑 `inventory_ui_test`、`input_key_test`、 > `gamescene_test` 全绿。CJK IME 候选窗、复杂窗口逐项 cursor 资源和真服 / 真机仍未验证。 > - **2026-09-02 增量 55(W4 / §9.4 MilesLib 音频子集)**:`project/audio.gd` 对齐 > `CSoundManager` 的 `SOUND_SCALE=200`、环境音 `1000`、监听器位置 / 方向、世界坐标相对监听器 > 缩放、`PlaySound3D` / `PlayAmbienceSound3D` / `PlayCharacterSound3D`、`SetSoundVolume3D` / > `StopSound3D` / `StopAllSound3D`、`r<0.1 ? r : pow(10,r-1)` 音量曲线、角色声音 5000cm 距离和 > 0.3 秒同文件限频;`.msa` 本地动作声音已走角色声音入口,GameScene 每帧更新监听器。新增 > `audio_miles_test.gd`,回归通过。`.mss` 解析、真实声音节点数据和真机 / 真服仍未完成。 > - **2026-09-02 增量 56(W3 / §4.2 §4.4 §4.5 §4.6 §4.7)**:仓库接入 5×9 网格分页、指定格存取和 > 仓库内移动;商城接入分页网格与商城→背包;交易接入 12 个指定投放格;NPC 商店接入 BUY/SELL > toggle、出售投放区和 BUY/SELL 光标联动;私人商店 40 格接入全局物品态。新增 > `complex_item_drop_test.gd`,覆盖仓库 / 商城 / 交易 / 商店落点,并确认购买模式拒绝出售投放; > 回归通过。金钱附着、stack split、anti-flag / 售价确认、完整交易事务、真实 `.sub` 资源和真服 / 真机 > 仍未完成。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §4.2 / §4.4 / §4.6 / C.5。 > - **2026-09-02 增量 60(W1/W3 / §4.5 §5.1 §5.2)**:交易协议状态补齐 `GC_EXCHANGE` 的 > `sockets[3]` / `attrs[7]`,m2dev / Classic 两条解析路径、`EntityStore::ExchangeSlot` 和 > `M2Client.get_exchange()` 保持同一实例数据;`quest_log.gd` 接入任务 icon(资源存在时显示)和 > 按参考格式递减的分钟 / 秒倒计时;`quest_dialog.gd` 识别 `COLOR/COLOR256`、`ITEM/MOB`、 > `WINDOW_SIZE`、居中、`CONFIRM_WAIT`、图片元数据和 `DELAY/WAIT`。新增交易字段断言与任务日志 > 回归;C++ 构建、受影响 Godot 测试通过。完整交易事务、38 项逐事件 EventManager、真实资源和 > 真服 / 真机仍待验收。 > - **2026-09-02 增量 65(W4 / §6.3)**:按 `40250/ClientVS22/source/EffectLib/SimpleLightData.cpp` / > `SimpleLightInstance.cpp` 接入 `.mse` Light 组:解析 `MaxRange`、ambient/diffuse、三项衰减、 > duration、loop / loop count 和 `TimeEventRange`,在 `effect_player.gd` 中生成 `OmniLight3D`, > 按参考范围曲线和生命周期更新位置、可见性和范围。粒子多贴图接入 `TexAniDelay` 及顺 / 逆序, > `RotationType` 区分 `NONE / TIME_EVENT / CW / CCW`。`fx_test` 增加 Light 组范围 / 生命周期 > 断言并通过;随机起始帧、每粒子随机语义、完整 MovingType / 时间轴、SpeedTree、`.msenv` 和 > 真机视觉仍未完成。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §6.3 / C.5。 > - **2026-09-02 增量 66(W4 / §6.3)**:按 `40250/ClientVS22/source/EffectLib/EffectElementBase.cpp:5-60` > 接入效果元素位置时间曲线:直接段按前后关键帧线性插值,`MOVING_TYPE_BEZIER_CURVE` 按 > `p0 + controlPoint` 做二次 Bezier,边界时间按参考端钳制;粒子、`.mde` 网格和 SimpleLight > 都消费同一位置曲线。`EmitterEmitFromEdgeFlag` 的球体映射到 Godot 球面发射,并保存 > `EmitterAdvancedType` / edge 元数据;新增直接段 / Bezier / sphere-edge 断言,`fx_test` 通过。 > 随机起始帧、每粒子随机方向 / 帧语义、完整 emitter advanced / cycle、完整时间轴和真机视觉仍未完成。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §6.3 / C.5。 > - **2026-09-02 增量 67(W4 / §9.4)**:按 `40250/ClientVS22/source/GameLib/Area.cpp` 与 > `MapType.cpp` 接入地图 `AreaAmbienceData` 和 `.pra`:位置 / 范围 / 播放类型 / 间隔 / 声音向量 > 由 C++ 世界层提供,且与对象渲染、splat 开关解耦;`audio.gd` 对齐 LOOP / ONCE / STEP 的严格 > 范围判定、距离音量、进入边沿播放和离开区域停止;修正 Miles 3D 坐标的厘米换算。新增真实 > A3 地图 `ambience_test`,并通过 C++ 构建、`audio_miles_test`、`fx_test` 回归。`.msenv` 的 > 云层 / 滤色 / 双方向光 / 风参数 / 昼夜天气联动、声音资源逐项听感和真机验收仍未完成。详见 > [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §9.4 / C.5。 > - **2026-09-02 增量 72(W4 / §7.5)**:按 `40250/ClientVS22/source/EterLib/SkyBox.cpp:552-557,869-927` > 补上 texture-mode 天空与云层的组合:有限水平云面按 `CloudScale` / `CloudHeight` 投影, > `CloudTextureScale` / `CloudSpeed` 映射到 Godot sky shader 的 UV 缩放 / 秒级滚动,并保留 > ClientVS22 的 `MODULATEINVALPHA_ADDCOLOR` + `ONE/INVSRCCOLOR` 颜色合成;真实 DawnMistWood > 环境验证通过。云层渐变过渡、滤色、风、lens flare、昼夜联动和真机视觉仍待完成。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §7.5 / C.5。 > - **2026-09-02 增量 70(W4 / §7.5)**:按 `40250/ClientVS22/source/EterLib/SkyBox.cpp:483-542` > 对齐 texture mode 的六面顺序和 UV,`environment_builder.cpp` 用 Godot `sky` shader 接入 > `Front/Back/Left/Right/Top/BottomFaceFileName`;真实 CapeDragonHead 的五个非空面可解析并渲染, > 空 bottom 面回退 `.msenv` 渐变。新增 `skybox_test.gd`,验证五面资源、空面回退和 shader 安装。 > 纹理天空与云层同时存在时的叠加、云层 UV / 高度动画、滤色、风、lens flare 和昼夜联动仍待实现; > 后续增量 72 已补上前两项的首版 shader 映射。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §7.5 / C.5。 > - **2026-09-02 增量 69(W4 / §7.5)**:按 `40250/ClientVS22/source/GameLib/MapOutdoor.cpp:246-264` > 对齐背景 / 角色两套方向光:`Sun` 只照 layer 1 静态场景,`CharacterLight` 只照 layer 2 的 > `Metin2Model`、装备和 LOD;换图清理两盏光。`environment_test`、`player_motion_test` 和 C++ 构建通过。 > SpeedTree 风参数、滤色、lens flare、昼夜联动和真机视觉仍未完成。详见 > [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §7.5 / C.5。 > - **2026-09-02 增量 68(W4 / §7.5)**:按 `40250/ClientVS22/source/GameLib/MapUtil.cpp` 与 > `MapType.cpp` 补齐 `.msenv` 的参考缺省值、`IsDensity`、`Filter`、`BTextureRenderMode`、六面 > 天空文件名和 `Reserved` 字段;`environment_builder.cpp` 通过 `AssetResolver` 解析真实资源, > 将云层贴图接入 `ProceduralSkyMaterial.sky_cover`,并把 Godot 暂无等价渲染载体的原始参数保存 > 到 `WorldEnvironment` 元数据。同步修复已解析环境文件路径被强制转小写导致的 macOS 加载风险。 > `formats_map_test`(内联 + 真实 A1)、`environment_test`、`world_probe` 和 C++ / Godot 回归通过。 > 六面天空、云层 UV / 高度动画、滤色实际合成、双方向光分层、风参数、lens flare 和昼夜天气联动 > 在该增量记录时仍未宣称完成;后续增量 69/70/71/72 已分别补上双光层、纹理天空首版、参考风缺省和 > 纹理天空云层组合首版。 > 详见 > [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §7.5 / C.5。 > - **2026-09-02 增量 64(W4 / §6.3)**:按 `40250/ClientVS22/source/EffectLib/EffectMesh.cpp` > 实现 `.mde` 的 `EffectData` v1 / `MDEData002` v2 读取:校验几何体 / 帧 / 数量边界,读取每帧 > visibility 与顶点、索引、UV,按参考展开 `TPTVertex`、翻转 UV,并将厘米坐标换为 Godot 米坐标。 > `effect_player.gd` 改为构建真实 `ArrayMesh`,播放几何帧,接入 MeshElement 的 billboard、混合、 > ColorFactor / TimeEventAlpha 和 `.ifl` 贴图帧;新增 `project/fx/mde.gd` 与真实 `geompung.mde` > 解析 / 构建断言。`fx_test` 通过,并修复了真实 `.mse` 触发的 Gradient 单点移除运行时错误。 > SimpleLight、TexAni 完整帧语义、MovingType / 骨骼挂点和资源逐项视觉 / 真机验收仍未完成。详见 > [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §6.3 / C.5。 > - **2026-09-02 增量 63(W4 / §9.4)**:按 `40250/ClientVS22/source/MilesLib/Type.cpp:17-74,118-136` > 实现 `.mss` 的 `SoundDataCount` / `SoundData%02d` 解析和 `DWORD(time / (1/60))` 帧换算; > 修正 `sound/` 虚拟前缀到各 patch `sound` 根的查找,`audio.gd` 提供 `load_mss_for_motion()` / > `update_sound_instances_3d()`,PlayerView / MobView 在动作帧进入时播放角色声音。新增 `.mss` > 解析、虚拟路径、60 FPS 换算和玩家 / 怪物动作配对断言;`audio_miles_test`、`player_motion_test`、 > `mob_view_test` 通过。SimpleLight、TexAni、SpeedTree、`.msenv`、完整音频资源和真机 / > 真服听感仍待验收。 > - **2026-09-02 增量 62(W3 / §4.3)**:按 `uitooltip.py` 的技能书、遗忘书、变身道具分支,把 > `item_tooltip.gd` 的 `sockets[0]` 接到本地技能名 / 怪物原型名,并对限制类型 7/8/9 对应的 > 结束时间或持续时长生成 > 剩余分钟 / 秒提示;新增技能书、变身和限时提示断言,`item_tooltip_test` 通过。CJK 文案 / > 字体切换、未覆盖的其他特殊分支、装备对比、真实资源和真服 / 真机仍待验收。 > - **2026-09-02 增量 61(W3 / §5.1 §5.2 §9.5)**:按 `PythonEventManager.cpp:554-614` / `579-602` > 接入任务脚本的 `ADDMAPSIGNAL`、`CLEARMAPSIGNAL`、`SETCMAPPOS`、`QUESTBUTTON`、 > `QUESTBUTTON_CLOSE` side effect;任务日志增加独立的任务信件条带,点击后发送已有的 > `script_button(index)`,对话打开期间按参考隐藏条带,关闭后恢复。`Minimap` 保存并绘制任务信号点, > `AtlasUI` 接入原版 screen-space center adjust;`IMAGE` / `LEFTIMAGE` / `TOPIMAGE` / `BGIMAGE` / > `TITLE_IMAGE` 在资源存在时实际创建图片节点,缺资源安全跳过。新增 quest 事件 / 条带断言并重跑 > `quest_test`、`p9_test`、`gamescene_test`,均通过。完整 EventSet 逐事件等待 / 淡入淡出 / 相机 / > 电影、真实 `.sub` 资源和真服 / 真机仍待验收。 > - **2026-09-02 增量 59(W3 / §4.6)**:`shop_ui.gd` 按参考 `shopdialog.py` 改为 5×8、40 格 > 货位,空位保持不可用,已放货物显示名称 / 数量并接入统一 tooltip;多货架仍按 > `tabIdx * SHOP_SLOT_COUNT + slot` 计算全局槽号。BUY 模式点击非空货位进入确认,确认后固定 > 发送数量 1,SELL 模式继续只接受出售投放。`p8_test` 已更新并通过;真实 `.sub` 槽位 / 按钮 > 贴图、图标和非金币货币显示仍待完成。 > - **2026-09-02 增量 58(W1/W3 / §4.2 §4.3)**:按 40250 `GameLib/ItemData.h` 的 236 字节 > `TItemTable` 布局补齐 `limits[2]`、`applies[3]`、`values[6]`、`sockets[3]`、精炼关联、 > 魔法属性概率、球面高光和开孔概率字段;`proto_item_layout_test` 锁定字段偏移。新增统一 > `project/ui/item_tooltip.gd`,解析 `itemdesc.txt`,并接入背包、装备查看、NPC 商店、仓库、 > 商城、交易、私人商店,生成基础数值 / 限制 / applies / 实例 attributes / sockets / refine 提示。 > `item_tooltip_test`、库存回归和 C++ 构建通过。鱼竿 / 镐经验、龙魂计时、技能书 / 变身等特殊 > 分支,CJK 文案 / 字体、装备对比、真实资源和真服 / 真机仍未完成。 > - **2026-09-02 增量 57(W3 / §4.2 §4.5 §4.6 §4.7 + W1 item 实例字段)**:全局物品态现在保留 > `anti_flags`、`flags`、`sockets` 等实例元数据;普通背包拖放携带整组数量,Shift+左键堆叠物品可先 > 选择附着数量,金币使用 `ITEM_MONEY=-1` 独立拖放态。交易按参考端补齐服务端 accept 镜像、1000 > 距离退出、`ANTIFLAG_GIVE`、7 位金币上限和已放金币不可编辑;NPC 商店补齐实例 `ANTIFLAG_SELL`、 > `IsValuableItem` / `Is1GoldItem` 售价公式和首次确认框;私人商店补齐 `ANTIFLAG_GIVE/MYSHOP`。 > 新增 `proto_item_layout_test`、`shop_rules_test`,并重跑库存 / 鼠标 / 交易 / 摆摊 / 复杂窗口回归。 > 代码与离线回归完成;参考端 safebox money 发送函数确认主动 `return false`,未伪造 `/safebox_money`。 > 完整交易事务、真实 `.sub` 资源、40250 真服和真机仍待验收。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) > §4.2 / §4.4 / §4.5 / §4.6 / §4.7 / C.5。 > - **2026-09-02 增量 51(W0 / §9.3 生命周期单一属主)**:生产入口 `client_main.gd → AppFlow` > 创建并持有唯一 `AppLifecycle`,登录 / 选人 / 游戏切屏不重复创建;`GameScene` 删除本地 > `AppLifecycle`,只通过 `lifecycle_audio()` 交给 AppFlow 绑定当前 `Audio`;`M2Client` 删除 > `NOTIFICATION_APPLICATION_PAUSED/RESUMED` 监听,只保留 `suspend()/resume()`。旧兼容启动器 > `login.gd` 也不再在进游戏时销毁自己的生命周期节点。新增 `app_flow_lifecycle_test.gd`, > 扩展 `gamescene_test.gd` 验证单一属主;两项 Godot 回归使用独立 `--log-file` 串行通过。 > 真机前后台 / 内存告警 / 后台 TCP 回收后的重连仍未验证。 > - **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` 贴图 > 安全降级。龙魂入口运行时禁用,公会和真实服务器验收继续按当前范围暂缓。 > - **2026-09-01 增量 44(交付范围调整)**:真实 FreeBSD + MySQL 完整验收、公会相关功能均 > 暂缓;当前目标 40250 服务端不提供龙魂玩法,龙魂从本阶段支持范围排除。保留本地协议 / 状态 / UI > 首版与离线回归结果,后续恢复服务端条件后再单独推进 deferred 项。 > - **2026-09-01 增量 43(队伍角色 / 能量 / 私店招牌复核)**:对照 40250 服务端 > `CParty::Update` 修正队伍角色 6 为 `HASTE`(迅捷),补齐 `affects[7]` 的服务端槽位语义, > 并在 `party_ui.gd` 按 Leadership 10/20/25/35/40 门控攻击/坦克/迅捷/辅助/宗师/防御, > Leadership 18 门控组队治疗;`p8_test.gd` 覆盖低 Leadership 的禁用路径。Cube HUD 已有 > `POINT_ENERGY` 快照显示;`GC_SHOP_SIGN` 已解析并在 `NetWorld` 以 `ShopSign` Label3D > 作为无 `.sub` 资源 fallback,`GC_SYMBOL_DATA` 也已有会徽下载链路。剩余仍是原版资源、真 > `.sub` 拖放 / 视觉和 Cube NPC 交互;live FreeBSD + MySQL E2E 暂缓。 > - **2026-09-01 增量 42(选魔石窗口闭环复核)**:确认 `GC_SCRIPT` 的 `[SELECT_ITEM]` 已由 > `QuestDialog` 转发到 `SelectItemUI`;选择窗按原 `uiselectitem.RefreshSlot` 过滤魔石 / grade、 > 映射背包格并发送 `CG_SCRIPT_SELECT_ITEM`,关闭发送 0,`select_item_ui_test.gd` 已覆盖。仍需 > 完整原版资源、图标和 CJK 输入环境下的视觉验收。 > - **2026-09-01 增量 41(创建公会 UI 闭环)**:`GC_REQUEST_MAKE_GUILD` 触发公会创建窗口, > 对应 `CG_ANSWER_MAKE_GUILD` 已接入名称输入、12 字上限、空名 / 控制字符校验和发送失败提示; > 新增 `guild_creation_ui_test.gd` 覆盖请求、校验和合法名称应答。服务端敏感词 / 重名错误及实际建会 > 状态仍需真服验收。 > - **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 行不再把 > 改名 UI 列为未实现;仍需真服验证强制改名、重名 / 非法名错误码和服务端状态。 > - **2026-08-31 增量 30(EterGrnLib 对齐复核)**:查代码确认以下已落地,表内相关行同步。 > ① **材质 blend / two-sided 改读 GR2 材质信息**(`metin2_model::decide_blend/decide_two_sided` > 优先 `gr2::MaterialInfo.alpha_blend/two_sided` = `dump_materials` 端口 `Material.cpp:233`, > body + weapon 两处;`m2_material` 加 `cull_disabled` shader 变体)。 > ② **LOD 淡入**(`LodGhost` 冻结旧 mesh + `lod_fade` uniform,0.18s,距离阈值 15% 迟滞)。 > ③ **crossfade 权重改 ease-in `2w²−w³`**,对齐 `GrannySetControlEaseInCurve(0,0,1,1)`。 > ④ **`.msa` motion event 类型分派**(`game_scene._on_local_motion_event`:sound / effect / > SCREEN_WAVING 震屏 / SCREEN_FLASHING 屏闪 / FLY·WARP 钩子)。 > ⑤ **角色接触阴影**(`project/ui/char_shadow.gd`:强制 `cast_shadow=ON` + 脚下渐变 Decal, > `player_view` / `mob_view` 调用)。 > ⑥ **点选精度**(`player_controller._ray_pick_t`:射线 vs 子 mesh 合并世界 AABB slab 相交, > 无 mesh 回退胶囊)。 > ⑦ **武器挂点复核**:隔离探针实测 weapon MI 原点 == `equip_right_hand` 骨骼原点 > Δ≤0.06 m(旧「偏 10 m」为 cpu_skin / MapCoord 修复前的失效结论);新增 grip > pre-transform `weapon_pre = mul4x3(weaponInvWorld[0], weaponLocal[0])`(对齐 > `ModelInstanceUpdate.cpp:171` 的 `weaponComposite[0]`),30 cm 位移夹断退回原始手部位姿。 > `03150` 类网格在 .gr2 里就离骨骼原点 ~3 m(资产缺陷,非变换)。 > 新增测试 `eterngrn_polish_test.gd`;`ctest 10/10` + 29 套 GDScript + iOS 全绿。 > - **2026-08-31 增量 38(ESC 系统菜单 + 游戏设置窗)**: > ① **`system_menu_ui.gd`**(`systemdialog.py` 真 uiscript,thinboard 8 键)= `uisystem.py` > `SystemDialog.__LoadSystemMenu_Default` 的 1:1:`system_option_button` → `system_option_ui.open()`, > `game_option_button` → `game_option_ui.open()`,`change_button` → `client.say(0,"/phase_select")` > (= `net.ExitGame()` 里 `SendChatPacket("/phase_select")`),`logout_button` → `"/logout"` > (= `net.LogOutGame()`),`mall_button` → `"/in_game_mall"`,`exit_button` → `get_tree().quit()`, > `help_button` → 占位提示,`cancel_button` / 标题栏 → 关。ESC 现在开这个菜单(`game_scene` > 把 ESC 从直接开 system_option 改成开 system_menu)。 > ② **`game_option_ui.gd`**(`gameoptiondialog.py` 真 uiscript)= `uigameoption.py`: > `block_{exchange,party,guild,whisper,friend,party_request}_button`(toggle) → > `client.say(0,"/setblockmode " + str(mask ^ bit))`(`EBlockAction` 位 1<<0..1<<5,本地 > `_block_mode` 跟踪 + 持久化,服务器回包同步待补);`pvp_{peace,revenge,guild,free}`(radio) → > `"/pkmode {0,1,4,2}"`(`__OnClickPvPMode*`);`name_color`/`target_board`/`view_chat`/ > `always_show_name`/`show_damage`/`salestext` radio → 持久化 `user://system_option.cfg [gameopt]` > (`systemSetting.Set*Flag` 等价;渲染侧钩子待补)。 > 新增 `system_menu_ui_test.gd`(两窗装载 + `/setblockmode` 位运算 + `/pkmode` + `/phase_select`/ > `/logout`/`/in_game_mall` + 显示开关持久化 + 二次实例读 cfg)。C++ 无改动;`ctest 10/10` + > 34 套 GDScript + 导入 + iOS 全绿。 > - **2026-08-31 增量 37(组队成员信息板 + `CG_PARTY_SET_STATE`)**: > ① **C++**:`wire.h` 加 `CGPartySetState{u16 hdr,u16 len,u32 pid,u8 role,u8 on}`(断言 10) > + `EPartyRole` 枚举(NORMAL 0/LEADER 1/ATTACKER 2/TANKER 3/BUFFER 4/SKILL_MASTER 5/ > HASTE 6/DEFENDER 7);`game_client.send_party_set_state(pid,role,on)`; > `M2Client.party_set_state(pid,role,on)` + bind;`get_party()` dict 补 `state`(完整角色字节) > 和 `affects[7]`。 > ② **`party_ui.gd`** 从「名字 + 一条 HP」重做成 1:1 迁移 `assets/root/uiparty.py` > `PartyMemberInfoBoard` + `partymemberinfoboard.py` 布局:每员 strip = 角色状态按钮 > (队长可点 → 弹角色菜单 普通/攻击/坦克/迅捷/辅助/宗师/防御 → `party_set_state`;普通 = > 清当前角色 `on=false`;+ 踢出 → `party_leave` = `SendPartyRemovePacket`)+ 名字(+★队长) > + HP gauge + `affects[7]` 非零 → 附加效果 chip(tooltip 带值,槽位名暂定)。点名字 → > `set_target(vid)`。顶部:EXP 分配开关(`party_set_distribute` 0 不均分 / 1 均分)+ > 组队治疗(`party_use_skill(PARTY_SKILL_HEAL=1, 0)`)。队长判定 = `get_party()` 里本地 > `get_main_vid()` 那条的 `leader`。角色菜单按 40250 服务端 Leadership 门控:攻击≥10、坦克/迅捷≥20、 > 辅助≥25、宗师≥35、防御≥40;治疗要求 Leadership≥18。 > ③ **修 `p8_test` 隐患**:`_init` 原来 `_run()` 未 `await`,`_run` 第一个 `await process_frame` > 之后的所有断言(含增量 34 的 `shop_ex` 多货架)从未真正生效 —— 改成 `await _run()` 后 > 暴露 `shop_ui.refresh()` 的 `queue_free` 延迟 bug(旧 row 当帧未消失 → `get_child_count` > 多计),已改 `remove_child` + `queue_free`。 > `ctest 10/10` + 33 套 GDScript + 导入 + iOS 全绿。 > - **2026-08-31 增量 36(系统设置窗 + 私人商店/选魔石补真 uiscript)**: > ① **系统设置窗** `project/ui/system_option_ui.gd` —— `UiScript`+`UiBuild` 装 > `assets/uiscript/uiscript/systemoptiondialog.py`;控件绑定逐字对照 > `assets/root/uisystemoption.py`:`music/sound_volume_controller`(sliderbar) → > `snd.SetMusicVolume/SetSoundVolume`(接 `Audio.master_bgm/master_sfx` + 即时改在播 BGM 声道); > `camera_short/long`(radio) → `game_camera.max_dist`(近 11 / 远 20,并夹当前 dist); > `fog_level0/1/2`(浓/中/淡 radio) → `Environment.fog_enabled` + `fog_density` > `[0.055/0.018/0.004]`;`tiling_cpu/gpu`+`apply`(原 CPU/GPU 分块渲染)Godot 渲染器无对应, > 保留占位。设置持久化到 `user://system_option.cfg`(= 原 `systemSetting` 配置文件), > `setup()` 时读回即时生效,`.msenv` 换 `Environment` 后 `_on_main_set` 重新 `_apply_all`。 > `game_scene`:ESC 呼出(`ui_manager` 先关最顶层窗并吃掉事件 → 到 `game_scene` 说明无窗打开)。 > 新增 `system_option_ui_test.gd`(装载 + 音量/镜头/雾绑定 + cfg 持久化 + 二次实例读 cfg)。 > ② **私人商店窗改走真 uiscript** `privateshopbuilder.py`(board + TitleBar + NameLine + > `ItemSlot` 5×8=40 grid + Ok/Close):40 格从 `_win.nodes` 索引,`NameLine`(text) 上盖 > `LineEdit`,背包候选面板挂窗右侧(原客户端靠主背包拖拽,此处点选)。`itemStock` 交互模型 > (拿起 / 落位 / 价格弹窗 / 排序 + display_pos / 撤下 / 개설 / 철수)不变。测试拆出独立 > `private_shop_ui_test.gd`(uiscript,缺资产跳过),`shop_cube_mall_test` 私人商店段移除。 > ③ **选魔石窗** `select_item_ui.gd` 早已走真 uiscript `selectitemwindow.py`(增量 33),无需改。 > C++ 无改动;`ctest 10/10` + 33 套 GDScript + 导入 + iOS 全绿。 > - **2026-08-31 增量 35(私人商店开设窗 = `PrivateShopBuilder` 交互模型)**:`private_shop_ui.gd` > 从「前 5 行 checkbox」重做成 1:1 复刻 `assets/root/uiprivateshopbuilder.py` 的 > `itemStock` 模型:左侧背包候选(点一件“拿起”)+ 右侧 40 格(`shop.SHOP_SLOT_COUNT`,5×8); > 拿着件点空格 → 价格输入(`uiCommon.MoneyInputDialog`)→ 落位(`AddPrivateShopItemStock` + > `itemStock[targetSlot]=(src)`);点已占用格 → 撤下(`OnSelectItemSlot` / > `DelPrivateShopItemStock`)。개설(OkButton) 把 stock 按格号排序、`display_pos = 格号`、 > 上限 39(`PRIVATE_SHOP_ITEM_MAX_NUM` = `TPacketCGMyShop::bCount` cap)打包成 > `M2Client.open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])` > (= `TPacketCGMyShop` + `TShopItemTable`×N,字段 1:1)。`shop_cube_mall_test` 私人商店段 > 重写(拿起 / 落位 / 价格弹窗 / 排序 + display_pos / 撤下 / 철수)。C++ 无改动; > `ctest 10/10` + 31 套 GDScript + 导入 + iOS 全绿。 > NOTE: 面板仍手绘(与 mall_ui / cube_ui 一致),真 `.sub` 版 `privateshopbuilder.py` uiscript 待后续。 > - **2026-08-31 增量 34(`SHOP_GC_START_EX` 多货架商店)**:`entity_store` 新增 > `SHOP_GC_START_EX`(10) 解析 —— `TPacketGCShopStartEx{u32 owner_vid, u8 tab_count}` > + `tab_count` × `{ShopTabHead(char name[32]+u8 coin_type=33B), ShopItem[40]}`(`wire.h` > 加 `ShopTabHead` + 断言 33,`SHOP_TAB_NAME_MAX=32`/`SHOP_TAB_COUNT_MAX=3`)。 > `ShopEntry` 加 `pos`(货架内槽位,买位置用),`ShopTab{name,coin_type,items}` + > `m_shop_tabs`;`SHOP_GC_START` 也改成按固定 40 槽数组填 `pos`;`SHOP_GC_END` 清货架。 > `shop_items()` 仍是 tab 0 的镜像(旧调用不动)。`M2Client.get_shop()` → `{vid, open, > tabs:[{name, coin_type, items:[{pos,vnum,price,count}]}]}`。`shop_ui.gd`:`tabs>1` 显示 > 货架按钮,买位置 = `tabIdx*SHOP_SLOT_COUNT(40)+slotPos`(1:1 对齐 `uishop.py` > `GetIndexFromSlotPos`)。`net_entity_test` 加双货架合成包断言(名称 / coin_type / 槽位保留 / > tab0 镜像 / END 清空),`p8_test` 加多货架 UI + `buy pos 5` / `buy pos 43` 断言。 > `ctest 10/10` + 31 套 GDScript + 导入 + iOS 全绿。 > - **2026-08-31 增量 33(选魔石窗,样板第 2 例)**:新增 `project/ui/select_item_ui.gd` > ——`UiScript`+`UiBuild` 装载 `assets/uiscript/uiscript/selectitemwindow.py`(5×8 > `grid_table`);填格逻辑逐字照 `assets/root/uiselectitem.py.RefreshSlot`:遍历背包前 > `INVENTORY_PAGE_SIZE*2` 格,只留 `item.IsMetin`(`proto.type == ITEM_TYPE_METIN` = 10) > 且 `GetItemGrade <= 2`(= 物品**内部名**最后一位数字,非数字按 0)的物品,最多 54 个。 > 点格 → `client.script_select_item(inventoryCell)`(= `net.SendSelectItemPacket`, > `CG_SCRIPT_SELECT_ITEM` 0x0903)+ 关窗;ExitButton / 关闭 → `script_select_item(0)` > (`uiselectitem.Close`)。触发链 1:1:`quest_dialog.parse_script` 识别 EventManager > `[SELECT_ITEM]` token → 新增 `select_item_requested` 信号(对应 `PythonEventManager` > `EVENT_TYPE_SELECT_ITEM` → `interfacemodule.BINARY_OpenSelectItemWindow`)→ > `game_scene` 接到 `select_item_ui.open()`。新增 `select_item_ui_test.gd`(token 解析 + > 过滤 + 选包 + 关窗发 0);`ctest 10/10` + 31 套 GDScript + 导入 + iOS 全绿。 > - **2026-08-31 增量 32(角色状态窗 = 1:1 窗口迁移样板)**:新增 > `project/ui/char_status_ui.gd`——直接用 `UiScript`+`UiBuild` 装载 > `assets/uiscript/uiscript/characterwindow.py` 真布局;数值绑定逐字对照 > `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 索引取自 40250/ClientVS22 `Packet.h`)。加点 + / - 按钮发 > `client.say(0,"/stat ht"|"/stat- ht" …)`,与原 `statusPlusCommandDict` / > `statusMinusCommandDict` 完全一致;`POINT_STAT>0` 才显示加点按钮。STATUS/SKILL/ > EMOTICON/QUEST 四页做 `SetState` 页签切换(技能 / 表情 / 任务的数据仍在各自专用窗)。 > 名称 / 帮会 / 职业头像取 `get_entity(get_main_vid())`。`game_scene` 挂 V / C 热键。 > 新增 `char_status_ui_test.gd`;`ctest 10/10` + 30 套 GDScript + 导入 + iOS 全绿。 > 这是「原 `interfacemodule.py` 逐窗口迁移」的样板,后续选道具网格 / 商店 START_EX / > 私人商店 39 格照抄这套(uiscript 装载 + 逐字数值绑定 + 原聊天命令 / 封包)。 > - **2026-08-31 增量 31(真服 E2E 二轮扫,单会话)**:以 > `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)、脚本取消(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)。脚本取消触发 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` 世界分派,现已补路由并由 > `net_entity_test` 复核。`CG_DUNGEON`、`CG_WARP`、改名 / 符号 / 物品变更等破坏性或独立 > 连接协议本轮未发送,仍保持“待真服验”。 > - **2026-08-31 增量 5(实现复核)**:修正文档中已经落后的功能状态。 > **情侣状态**已不再是“整体未做”:`GC_LOVER_INFO`(70B) / `GC_LOVE_POINT_UPDATE`(5B) > 已接入 `EntityStore`,并通过 `M2Client.get_lover()` / `lover_changed` 驱动常驻 > `love_ui`,C++ 状态测试和 `love_ui_test.gd` 均有覆盖;仍缺完整情侣交互与真服验收。 > **角色 / 装备渲染**已补本地主角的身体盔甲 / 时装 parts、头盔 / 假发 / 发型、右手武器、 > 左手盾牌、`item_proto.specular/100` 球面高光、角色 LOD 迟滞 + 0.18s 淡入、动作切换 > ease-in、`.msa` 音效 / 特效 / 震屏 / 屏闪事件和角色接触阴影首版。**地图 / 材质**行同步 > 记录 16 层 splat、原 UV / 源分辨率、水纹序列 / 水深 alpha、terrain patch 剔除和物体 > Y-up 旋转修复。协议审计总缺口仍为 **24 个 header**,但 `GC_LOVER_*` 已从“声明未消费” > 清单移除;输入行修正为已支持 F1–F4 与数字键 1–9。 > 本次同时补充“参考模块覆盖索引”,把协议表之外的聊天、TextTail、HUD、UI 运行时、输入 / 相机、 > 登录串场和桌面外壳差距单独列出。 > - **2026-08-31 增量 6(位置同步上行)**:补齐 `CG_CHARACTER_POSITION`(0x0A60) / > `CG_SYNC_POSITION`(0x0303) 的 wire 结构、尺寸断言、`GameClient` 发送接口和 > `M2Client.character_position()` / `sync_positions()` API;回环测试覆盖姿态包、最多 16 个 > 坐标元素、字段值和空批次 / 超限拒绝。两项已从“缺失 header”移入“已声明但待核”——仍需 > 接入原版的自动碰撞纠偏时机、姿态动作和真服务器端到端验收。 > - **2026-08-31 增量 7(任务应答协议)**:补齐 `CG_SCRIPT_SELECT_ITEM`(0x0903) / > `CG_QUEST_CANCEL`(0x0906) 的 wire 结构、尺寸断言、`GameClient` 发送接口和 > `M2Client.script_select_item()` / `quest_cancel()` API;回环测试验证选择值与取消包。 > 同时让 `QuestDialog` 支持基础 `[INPUT]` 文本框和 Escape 取消;缺失 header 清单由 22 项 > 降为 20 项。任务窗口仍需接入选道具网格、原版 IME / 输入校验和完整 `EventManager` 标签语义。 > - **2026-08-31 增量 8(组队技能上行)**:补齐 `CG_PARTY_USE_SKILL`(0x0705) 的 wire > 结构、尺寸断言、`GameClient.send_party_use_skill()` 和 `M2Client.party_use_skill()`; > 回环测试验证技能索引 / 目标 VID。缺失 header 清单由 20 项降为 19 项;队伍技能的 > 冷却、权限和队员世界标记仍待补齐。 > - **2026-08-31 增量 9(飞行目标广播)**:补齐 `GC_FLY_TARGETING`(0x0411) / > `GC_ADD_FLY_TARGETING`(0x0412) 的 20B wire 结构;`EntityStore` 持久化 shooter 的目标 > VID / 坐标并产生追加标记,`M2Client.fly_targeting` 信号向 Godot 暴露;C++ 实体测试覆盖 > 设置目标、追加目标和坐标回退。缺失 header 清单由 19 项降为 17 项。 > - **2026-08-31 增量 10(技能组切换)**:补齐 `GC_CHANGE_SKILL_GROUP`(0x021D) 的 5B > wire 结构、`EntityStore` 技能组状态 / dirty 标记、`M2Client.get_skill_group()` 和 > `skill_group_changed` 信号;切换会同时使技能快照失效,C++ 实体测试覆盖状态更新。 > 缺失 header 清单由 17 项降为 16 项。 > - **2026-08-31 增量 11(角色改名)**:补齐 `CG_CHANGE_NAME`(0x010B) / > `GC_CHANGE_NAME`(0x010C) 的固定长度 wire 结构、`GameClient` 改名发送与角色槽位更新、 > `M2Client.change_name()` 及 `char_name_changed` 信号;回环测试覆盖名称字节和 pid 广播。 > 缺失 header 清单由 16 项降为 14 项。 > - **2026-08-31 增量 12(公会创建请求)**:补齐 `GC_REQUEST_MAKE_GUILD`(0x0731) 的 > 无负载包解析,`GameClient` 产生请求事件,`M2Client.guild_make_requested` 向 Godot 暴露, > 回环测试覆盖事件消费。缺失 header 清单由 14 项降为 13 项。 > - **2026-08-31 增量 13(主动传送请求)**:补齐 `CG_WARP`(0x0305) 的 4B wire 结构、 > `GameClient.send_warp()` 和 `M2Client.request_warp()`;回环测试验证上行请求及现有 > `GC_WARP` 目标回传。缺失 header 清单由 13 项降为 12 项。 > - **2026-08-31 增量 14(挖矿动作广播)**:补齐 `GC_DIG_MOTION`(0x0308) 的 13B wire > 结构、`EntityStore` 挖矿动作事件、`M2Client.dig_motion` 信号和 `NetWorld` 朝向 / 动画 > 元数据处理;实体、回环和 Godot 桥接测试覆盖。缺失 header 清单由 12 项降为 11 项。 > - **2026-08-31 增量 15(公会符号下载)**:补齐 `CG_SYMBOL_CRC`(0x0723) / `GC_SYMBOL_DATA`(0x0732) > 的 16B / 8B 结构和原始数据流;`MarkClient` 支持 CRC 请求与变长符号文件接收, > `M2Client.download_guild_symbol()` / `get_guild_symbol()` / `guild_symbol_ready` 已接入, > 回环测试覆盖 CRC 字段、guild id 和数据长度。缺失 header 清单由 11 项降为 9 项。 > - **2026-08-31 增量 16(钓鱼协议)**:补齐 `CG_FISHING`(0x0B01) / `GC_FISHING`(0x0B10) > 的 5B / 10B wire 结构、旋转方向换算、`EntityStore` 事件和 `M2Client.fishing()` / > `fishing_event`;回环与 Godot 桥接测试覆盖。缺失 header 清单由 9 项降为 7 项。 > - **2026-08-31 增量 17(副本协议)**:补齐 `CG_DUNGEON`(0x0B02) / `GC_DUNGEON`(0x0B11) > 的 4B / 5B wire 结构,支持目的地坐标变长 body,接入 `M2Client.request_dungeon()` / > `dungeon_event`;实体、回环和 Godot 桥接测试覆盖。缺失 header 清单由 7 项降为 5 项。 > - **2026-08-31 增量 18(领地与观战者)**:补齐 `GC_LAND_LIST`(0x0B12) 的 4B 头 + 24B > 元素变长解析,以及 `GC_OBSERVER_ADD/REMOVE/MOVE`(0x0B20–0x0B22) 的 12B / 8B wire > 结构;`EntityStore` 保存领地和观战者状态,`M2Client.get_land_areas()` / > `get_observers()` 与 `land_areas_changed` / `observer_event` 已接入;小地图现在消费观战者 > 状态并绘制紫色菱形标记。实体、回环、Godot 桥接和 `p9_test.gd` 覆盖。缺失 header 清单由 > 5 项降为 **1 项**(仅剩 `CG_TEXT`,参考端未发现实际发送方)。 > - **2026-09-01 增量 39(`CG_TEXT` wire 尺寸覆盖)**:按 40250 `TPacketCGText` / > `CInputHandshake::Analyze` 补齐 `CG_TEXT` 的 1 字节固定前缀和 classic `packet_size_cg()` > 表项,并加入 wire 回归断言。该包后续文本由服务端按换行特殊解析,仅作为握手阶段管理通道; > Cube 继续使用已验证的 `CG_CHAT`/`CHAT_TYPE_COMMAND`,不把管理命令暴露为普通游戏 API。 > 主网络层缺失 header 清单由 1 项降为 0 项。 > - **2026-08-31 增量 19(钓鱼动作消费)**:`NetWorld` 和 `NetPlay` 现在消费 > `fishing_event` 的 START / STOP / REACT / SUCCESS / FAIL 子头,记录方向并尝试驱动远端及 > 本地主角的钓鱼动作;FISH 子头按参考客户端语义保留为鱼获物品事件,不误当作角色 VID。 > `combat_fx_test.gd`、`netbridge_test.gd` 覆盖动作分发。 > - **2026-08-31 增量 20(钓鱼结果日志)**:`ChatUI` 消费 `GC_FISHING` 的 SUCCESS / FAIL / > FISH 事件,将命中、失败和鱼获物品写入系统 / 战斗日志;`chat_test.gd` 覆盖结果路由。 > - **2026-08-31 增量 21(小地图领地标记)**:小地图继续消费 `GC_LAND_LIST`,把全局厘米坐标 > 的领地矩形绘制为公会金色 / 中性青色边框;`p9_test.gd` 补充领地数据供 headless 重绘路径 > 自检。观战者紫色菱形与领地边框均已接入,仍缺原版 minimap 贴图和 Atlas 交互。 > - **2026-08-31 增量 22(小地图缩放)**:`minimap.gd` 新增 `set_scale()` / `get_scale()`, > 对齐原 `CPythonMiniMap::SetScale` 的像素 / 米缩放语义;鼠标滚轮在小地图区域内调整缩放, > `p9_test.gd` 验证缩放会改变世界坐标投影。仍缺原版贴图、Atlas 拖动和坐标查询窗口。 > - **2026-08-31 增量 23(飞行目标消费)**:`NetWorld` 为每个 shooter 保存 > `FLY_TARGETING` / `ADD_FLY_TARGETING` 目标队列;`GC_CREATE_FLY` 在终点 VID 缺失时回退到 > 队列中的目标 VID 或全局厘米坐标,补上原版 `SetFlyTarget` / `AddFlyTarget` 的关键语义。 > `netbridge_test.gd` 覆盖无终点 VID 的投射物创建;真实弹道模型、命中时机和特效仍待补齐。 > - **2026-08-31 增量 24(弓技能发射时机)**:`SkillTable.is_ranged()` 根据 > `weapon_limit=BOW` 识别远程技能;`GameScene` 为弓技能暂存 `CG_SHOOT`,在 `.msa` 的 FLY > motion event 到达时发送,超时自动清理,避免近战技能误发。`skill_test.gd` 与 > `eterngrn_polish_test.gd` 覆盖远近程识别及一次性发射。 > - **2026-08-31 增量 25(Atlas 大地图窗口)**:新增 `AtlasUI`,复用地图区块 > `minimap.dds` 构建 Atlas 底图;支持 M 键打开 / 关闭、地图名称、玩家位置(全局厘米)显示、 > 左键拖动和右键复位。无底图资源时仍保留可交互坐标查询;`atlas_test.gd` 与 > `gamescene_test.gd` 覆盖窗口装配和输入路径。 > - **2026-08-31 增量 26(队伍地图标记)**:圆形小地图和 Atlas 从 `get_party()` / 实体快照 > 区分队员,绘制绿色队员点、队长环和 Atlas 队员标记;`p9_test.gd` / `atlas_test.gd` 覆盖 > 队伍标记绘制路径。队伍世界标记已不再是纯 UI 列表,仍需按原版颜色和离线状态逐项校准。 > - **2026-08-31 增量 27(输入差距复核)**:根据 `game_camera.gd` 当前实现修正文档:桌面右键 > 拖拽环绕、滚轮缩放和触屏单指 / 双指手势已经存在,不再列为缺失功能;剩余差距改为相机 > 模式、灵敏度 / 键位持久化及窗口冲突规则。 > - **2026-08-31 增量 28(移动速度校准)**:`GC_CHANGE_SPEED` 已暴露实体的 > `moving_speed`;`NetPlay` 在主角 `entity_info` 到达时调用 `PlayerController.set_server_speed()`, > 以原版 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` 按 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、武器 / 坐骑 > 规则和服务端攻击合法性逐帧校准。 > - **2026-08-31 增量 4(真机验证)**:`net_e2e` 加 post-2026-08-30 协议探针(env 门禁 > `MT_E2E_CHARCREATE` / `MT_E2E_MYSHOP` / `MT_E2E_CUBE`)。真服 `192.168.21.203` > 确认 1:1:`CG_CHARACTER_CREATE`(77B/u16 job) + `GC_PLAYER_CREATE_FAILURE`(5B) + > `GC_EMPIRE`(5B) + `GUILD_GC_SKILL_INFO`(17B body) + `GUILD_GC_WAR` + `CG_MYSHOP` > (38B+13B×N) 全部被真服接受 / 解析正确,无 unknown header。Cube/MALL 送出路径通但 > 需 NPC 交互;`CG_CHARACTER_DELETE` / 龙魂精炼 / 会徽上传(破坏性)仍未真机验。 > - **2026-08-31 增量 3**:两个 ⬜ 项落地首版 —— **私人商店 / 道具商城** > (`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 细节。 > **拍卖行**默认构建未见完整 UI / handler,但协议受 `__AUCTION__` 条件编译控制;当前目标服务端 > 默认关闭 → 标注 ❌ 不做(见上「不做清单」)。 > **反作弊**(`CG_HACK` + `ProcessCRC`/`ProcessScanner`)→ 标注 ❌ 不做。 > **「协议覆盖审计」段**改为逐条清单:当时按 `wire.h` vs `Packet.h` 核对,主网络层 > 仍缺 **24 个 header**(位置同步上行 / 战斗广播 / 任务 / 社交 / 改名 / 世界玩法整组; > `CG_HACK` 已移入「不做」),另有 3 个「已声明未消费 / 待核」。 > - **2026-08-31 增量 2**: > - 角色创建 / 删除流程完成首版并接近 1:1(原为“暂不纳入”)——见新表行 > 「选人 / 建号 / 删号」。 > - 怪物 / NPC 真模型渲染修复:`root/npclist.txt`(vnum→代号→目录)解析 + > `net_world.catch_up()` 补拉协程化 `setup()` 漏掉的初始 spawn 洪流。世界玩法行 > 的“NPC / 怪物首版”由“能出占位胶囊”提升为“能出真模型 + 贴图 + 动作 + 名字牌 / > 血条”。 > - `GC_EMPIRE`(0x0109) 已暴露为 `M2Client.get_empire()`;选人页显示国家名 + 国旗。 > - 关键 1:1 差距表新增“状态”列。 ## 结论 `metin2-client` 已经不是早期“只能登录、移动和攻击”的骨架:登录到 PHASE_GAME、基础战斗、 背包 / 装备、技能 UI、任务、聊天、组队 / 好友、商店 / 交易 / 仓库、精炼, 地图和部分特效都已有可运行首版。 当前的主要差距已从“有没有功能”转为: 1. 部分关键协议只实现了表现包或收包,没有完成原客户端的完整上行语义; 2. 数据模型已扩到 24 个 wear 位置和独立 16 格腰带,并接入部分装备 / 时装换模,槽位版图与 发包坐标已按 `GameType.h` 对齐(增量 96),装备校验链(修改 2:`antiflag→wearable_flag→limit`) 已由 `equip_rules.gd` 在客户端防御性重建(增量 97),变身 / 动物时装 / 婚纱的部位遮挡(修改 4) 已由 `part_hiding.gd` + `equip_model.gd` 落地(增量 98),但原版 costume / belt 窗口、拖放、tooltip、 远端 PC 换模(修改 5,远端仍是占位胶囊)、变身态字段和校验链的精确 tie-break(接服务器)仍不完整; 3. 多数玩法窗口是简化实现,未复现原 Python UI 的完整行为; 4. EffectLib、SpeedTree、材质和世界表现仍有明确占位或近似; 5. 打包资产路径、桌面配置和输入体验尚未形成原客户端等价闭环。 因此当前应称为“核心功能纵切完成”,不能称为 40250/ClientVS22 的 1:1 客户端。 ## 已具备的首版能力 - 登录、服务器 / 频道选择、频道负载状态、断线回登录、选人、**建号 / 删号**、进入游戏; - Metin2 风选人页(背景 / 3D 角色 / 职业书法字 / 国家 + 国旗 / 四维条 / ◀▶ 槽位 / 建删弹窗); - 基础移动、攻击、选目标、生命 / 法力 / 经验、伤害飘字、affect、死亡与复活窗口; - 背包、基础装备换模、地面物品、技能窗口和快捷栏读取; - 聊天、私聊、基础任务对话和任务日志; - 组队、好友、情侣名 / 爱意值状态、NPC 商店、交易、仓库; - 公会、公会技能、公会战、会徽上 / 下载、精炼(公会相关验收暂缓); - 地形、建筑、水体、**NPC / 怪物真模型(vnum→npclist.txt→目录)**、小地图、频道、昼夜和天气首版; - GR2 模型 / 动画、换装首版、武器 / 盾挂点、距离 LOD / 淡入、角色接触阴影、 `item_proto` 驱动的球面高光和 `.mse` 粒子首版。 这些项目只说明已有入口和基础闭环;是否 1:1 仍以下表为准。 ## 关键 1:1 差距 | 优先级 | 状态 | 模块 | 当前实现 | 需要迁移的原客户端语义 | |---|---|---|---|---| | 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`(增量 86:单槽 + 1.5 s 定时器改成 FIFO `_pending_shots` 队列,支持连射多箭在途各自一发;弓普攻也走同一路径,并在挥击起手由 `net_play._send_fly_target()` 发 `CG_FLY_TARGETING`)。真服单会话已发送 `CG_USE_SKILL` / `CG_FLY_TARGETING` / `CG_ADD_FLY_TARGETING` / `CG_SHOOT`,均无断连 / unknown | 仍需实现范围 / 多目标选择器来自动驱动 `ADD_FLY_TARGETING`;冷却、目标合法性、真实弹道和客户端表现也须按技能类型细分;尚未在真服观察到对应 GC 飞行目标推送 | | P0 | 🟡 | 快捷栏持久化 | 服务器保存 36 槽,客户端本地显示 4 页 × 8 格;类型统一为 `INVENTORY(1)` / `SKILL(2)` / `EMOTION(3)`,清除 / 交换 / 回包恢复和技能冷却已有;数字 1–4、F1–F4、LSHIFT 翻页、Ctrl/Command+1–9 表情已按参考接入,inventory → quickslot 有全局拖放首版 | 仍需完整刷新死亡 / 变身 / 换武器后的服务端状态、跨窗口快捷栏拖放和真实服务端验收;macOS F1–F12 受系统 Fn / 功能键设置影响 | | P0 | 🟡 | 物品操作 | 已有移动、使用、丢弃、拾取;Shift 选“来源→目标”发 `ITEM_USE_TO_ITEM`,Ctrl+右键向当前目标 `ITEM_GIVE`,Alt+右键以数量框发 `ITEM_DROP2`;增量 54 增加全局 item payload、鼠标跟随图标、inventory / equipment / belt → inventory / quickbar 投放、无效区域 / 右键 / ESC / 失焦取消和 PICK / TALK / ATTACK 光标;增量 56 接入 safebox 45 格分页 / 仓库内移动、mall 分页、exchange 12 格、shop 出售目标和 private_shop 40 格;增量 57 使 payload 携带 `anti_flags` / `flags` / `sockets` 等实例字段,普通拖放保留整组数量,Shift+左键支持 stack split,金币使用 `ITEM_MONEY=-1` 独立态;交易、NPC 商店和私人商店的关键 anti-flag / 确认边界已接入;增量 58 统一基础 tooltip;增量 60 让 m2dev / Classic 交易包保留 `sockets[3]` 与 `attrs[7]`;`GC_VIEW_EQUIP` 已保存 11 格检查快照并弹出装备查看窗 | 仍需完整交易事务、宝石专用规则,以及装备查看窗的原版槽位图标、3D 纸娃娃与交互;CJK IME、真实 `.sub` 资源、tooltip 特殊分支、真服 / 真机输入仍待验收 | | P0 | 🟡 | 装备 / 背包数据模型 | 常规背包 90 格、运行时 24 wear 位、腰带 16 格、基础 tooltip 已有;增量 96 按 `GameType.h` 对齐基础 wear / costume / ring / belt 发包坐标;增量 97 按 `ItemData.h` 防御性重建 `CanEquipNow` 的 antiflag→wearable→limit 校验;增量 98 完成变身 / 动物时装 / 婚纱部位遮挡;增量 99 让远端 PC 创建 `RemotePlayerView` 并消费 `awPart[ARMOR,WEAPON,HEAD,HAIR]` 四字段换装 | 变身字段、所有职业 / race 的资源、真服远端换装、精确 tie-break / 同槽替换 / `LIMIT_PCBANG`·`REAL_TIME*` / 属性读取、原版 costume / belt 视觉、装备对比和真机仍待验收 | | 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;**本地移动改为 Src/Dst 模型(增量 76,§3.1)**:`player_controller.gd` 的 `walk_to()` → `_goto()` 对齐 `CInstanceBase::NEW_Goto()`(`InstanceBaseMovement.cpp:251`),太近(`< MOVABLE_GROUND_DISTANCE_M` 1.0 m)不动、三门 `_can_translate()`(`frozen`/`locked`/`moving_skill` ≈ `__IsSyncing`/`isLock`/`IsUsingMovingSkill`)未过时按 `__ReserveClickGround` 存点 + 0.1 s 延迟重试,`moving_skill` 只转向,WASD 分支 `_is_going = false`;**远端同步改为命令队列(增量 77,§3.2 part A)**:`EntityStore` 远端 Actor 位置由 `std::deque` 驱动(1:1 复刻 `StateProcess`,`InstanceBase.cpp:1278`),`mut_move()` 入队 + `drain_state_queue()` 按 `set_server_frame_ms()`(为 0 即时释放)+ `__CanProcessNetworkStatePacket` 逐条释放,`apply_state_cmd()` 走 `FUNC_WAIT` 1.0 / `FUNC_COMBO/ATTACK/MOB_SKILL` 50.0 硬阈值 + `m_kMovAfterFunc` 到达后动作 + `skip_collision`,线性插值只保留给 §2.1 可见性;**上行 6 事件回调(增量 78,§3.2 part B)**:`net_play.gd` 按 `PythonPlayerEventHandler.cpp` 拆成 OnMove(立即 `FUNC_MOVE` + 重置 moving/waiting 节流窗)/ OnMoving(300 ms 节流)/ OnWaiting(`_tick_on_waiting`,100 ms + 位移 ≥ 1 cm)/ OnStop(`FUNC_WAIT`)/ OnAttack(`CG_ATTACK` 外额外 `FUNC_COMBO`)/ `on_use_skill()`(`FUNC_SKILL|motion`),统一走 `_send_state`;`net_world.gd` 去掉自有 `follow_lerp`,节点位置直接跟随 `e.pos`(插值全交 C++ `tick()`);**真实服务器毫秒帧钟(增量 79,§3.2 收官)**:`ClassicStream::server_frame_ms()`(≈ `ELTimer_GetServerFrameMSec()`)由 `HDR_HANDSHAKE` 存的 `m_server_time_base = hs.time + lDelta` / `m_client_time_base` 算出,`ClassicSession::process()` 每帧 `set_server_frame_ms()` 后 classic 后端命令队列按服务器时间放行(m2dev 恒 0 = 即时释放,无回归);`drain_state_queue` 加防呆阀 `STATE_QUEUE_MAX_WAIT_MS=1000`(`chk_time` 领先帧钟超 1 s 立即放行,参考端无此上限,有意偏离);**Actor-Actor 碰撞 + 坐骑转向速度(增量 93,§3.1 收尾)**:`player_controller._actor_blocked()` 近似 `TestActorCollision`(8 m 距离门 + body 球重叠 `ACTOR_BODY_RADIUS_M=0.55` + 趋近判定)+ `_process` 逐轴门控(单 Actor 滑移 / 两轴挡=`BlockMovement`),`skip_actor_collision`(≈ `CanSkipCollision()`)观战置位;`rotation_speed_deg` + `_turn_toward()` 逐帧限幅收敛,`net_play._refresh_motion_mode()` 坐骑切 `ROT_SPEED_HORSE_DEG=300`(`c_fDefaultHorseRotationSpeed`)/ 常态 1200,替换两处 `rotation.y = atan2()` 瞬转;`player_move_test.gd` 5→8 组 | 仍需按原客户端同步窗口、碰撞纠偏触发时机与保留动作处理;**抖动缓冲 / 预测回滚不做(§3.2/§6 排除)**;姿态动作还没有完整 UI / 动画语义;~~§3.1 的 Actor 间碰撞(`TestActorCollision`/`BlockMovement`)、坐骑 `MODE_HORSE*`(依赖 §6.4 + §3.2)~~(增量 93 已近似落地)、真实 body sphere 半径(`.col` race 碰撞数据)、`AdjustDynamicCollisionMovement` 精确滑移 + `uCollisionCount==2` 复合门、`MOVABLE_GROUND_DISTANCE` 默认值校准、网络驱动本地预测玩家的 `__EnableSkipCollision()`;真实服务器 `GC_MOVE` `dwTime` 时基联机核验、`__IsEnableTCPProcess` 表情锁、`GC_SYNC_POSITION` 是否清队列(随对应 §3.x) | | P0 | 🟡 | PVP / 决斗 / 阵营 | `GC_PVP` 和 `DUEL_START` 已进入状态层,并以 `pvp_changed` / `duel_started` 信号提供给 Godot;挑战 / 战斗 / 复仇关系会在世界实体上显示标签;实体已有部分 empire / alignment / pk 字段;**可攻击判定 `IsAttackableInstance` 已 1:1(增量 94,`entity_rules.gd` + `net_play._is_attackable` / `_attack_ctx`,含 alignment grade/type、`IsSameEmpire`、`IsConflictAlignmentInstance`、`IsPVPInstance`、`__FindDUELKey`、`IsSamePartyMember`、`IsWoodenDoor`)** | ~~可攻击判定~~(增量 94 落地);~~原版名字颜色(`ENameColor` §8.8)~~(增量 110 落地:`name_color.gd` 1:1 `GetNameColorIndex` / `SetEmpireNameMode`,`net_world` 事件 + ~2Hz 兜底重染 `Label3D.modulate`,帝国名色默认关、开关 `net_world.set_empire_name_mode()` 已由增量 118 接 §8.4「名字色」radio);~~头顶称号色 / 公会名 / 等级~~(增量 111 落地:`text_tail.gd` 1:1 `GetTitleColor` / `RefreshTextTail` / `UpdateTextTailLevel` / `EnablePKTitle`,`net_world` 驱动 `GuildTag` / `TitleTag` / `LevelTag` 子标签、开关 `net_world.set_pk_title_enabled()` 未接 UI);~~头顶聊天 / 信息气泡~~(增量 112 落地:`chat_tail.gd` 1:1 `RegisterChatTail` / `RegisterInfoTail` / `TextTail_GetLivingTime` / `IsChatTextTail`,`net_world` 每 vid 一条 `"bubble"` `Label3D` + 5s 硬删 + `"名字: "` 前缀剥离 + SHOUT·COMMAND 排除、`register_info_tail()` 公开方法);~~头顶名字「是否显示」/「总是显示名字」/ LALT 名字显示 / 目标强显~~(增量 113 落地:`name_show.gd` 1:1 `IsAlwaysShowName` / `SetAlwaysShowNameFlag` / `__IsShowName` / `ShowAllTextTail` 的 `fDistanceFromPlayer < 3500` 平面距裁剪 / `ShowCharacterTextTail` 的 `isShow · !IsGuildWall · CanPickInstance` 资格 / `UpdateAllTextTail` 的 chat `bNameFlag` 强显,`net_world._name_shown()` 四路:`_always_show_name`(`game_option_ui` radio)/ `_alt_held`(LALT)/ `_target_vid`(`net_play.target_changed`)/ chat `bNameFlag`);~~头顶伤害飘字 `AddDamageEffect` / `ProcessDamage`~~(增量 114 落地:`damage_effect.gd` 1:1 `enum DamageFlag` / `RecvDamageInfoPacket` 的 `bSelf` / `bTarget` / `damage >= 0` 门 / `AddDamageEffect` 的 `IsShowDamage()` 门 / `ProcessDamage` 分类(`DODGE\|BLOCK`→MISS、`CRITICAL`·`POISON` 参考端注释掉走普通数字、`bSelf`→`"damage_"`、`!bTarget`→`"nontarget_"` 不渲染、否则 `"target_"`、`while(damage>0)` 逐位)/ `SetShowDamageFlag`,`net_world._on_damage` / `_process_damage_queue` 每帧 pop 一条、`game_option_ui`「伤害数字」radio 接 `net_world.set_show_damage`);~~头顶目标框 `RecvTargetPacket` 收包分派 / `CanViewTargetHP`~~(增量 115 落地:`target_board.gd` 1:1 `RecvTargetPacket` 判定树(实例缺失→`CloseTargetBoard`、目标已死→不动框、`IsPC()\|IsBuilding()`→`CloseTargetBoardIfDifferent`、`CanViewTargetHP`(`IsStone()\|IsWoodenDoor()\|IsEnemy()`)→`SetHPTargetBoard`、否则→`CloseTargetBoard`)+ `game.py` `SetHPTargetBoard` 换 VID 先 `ResetTargetBoard`,`net_play._on_target_info` 由「任意有名字 VID 都显框」改为按判定树分派:PC / building 不显血条框、NPC / warp 关框);~~目标框「看他国玩家」前置过滤(`uitarget.py TargetBoard.Open :189-193`)~~(增量 118 落地:`target_board.hidden_as_other_empire` + `net_play._on_target_info` 在 `classify` 前判 —— game-option 关「看他国目标框」且目标异阵营 → `_clear_target()` return;`net_play.set_view_other_empire_target()` 经 `game_scene._on_display_option` 接 §8.4 `target_board` radio、开局 `display_value` 回填、`game_option_ui.gd` `target_board` 默认修 0→1);仍需 PK 模式上行、保护区 `ATTRIBUTE_BANPK`、原版目标板视觉 / 决斗 UI、可攻击光标按 `ENameColor` 驱动;seam:`__FindPVPKey` / `__FindGVGKey` / `IsGameMaster` / `IsWall` race 白名单 / m2dev `IsKiller` 位的 packet setter、`name_color` 的 `kind_resolver` 未注入时回退裸 `ch_type`(seam ⑥)、`text_tail` 子标签 billboard 竖排 vs 屏幕空间排版(seam ⑦;逐像素水平排版增量 121 已 1:1、纵向位移 119、文本宽换真实字体度量 `GetTextSize` 123 已 1:1、单行文本高换 `GetTextSize` 的 h 124 已 1:1、剩余:无公会徽记 mark 实体 / 三子标签竖排叠 x 偏移 vs 同一屏幕行)、`title_name_resolver` 未注入回退裸 key(seam ⑧)、`chat_tail` 气泡与名字 `Label3D` 并存、名字与气泡未合成单尾标链表(seam ⑨ 遗留;~~名字随气泡下移 17px~~ 增量 117 落地:`name_tail_layout.gd` 纯静态第 28 个 canonical `UpdateShowingTextTail` :110-114 —— chat 尾标 `bNameFlag` TRUE 时名字 `Label3D` 局部 y 顶到「气泡 y + 17*pixel_size」,`net_world._reposition_name_tag` 由 `_apply_chat_tail` / `_expire_chat_tails` 调用)、~~子标签竖排 vs 参考屏幕空间逐像素水平排版(seam ⑦ 遗留)~~(增量 119 落地纵向:`name_tail_layout.gd` 增 `name_tail_lift_delta` / `subtag_world_y` 照抄 `ArrangeTextTail` :211/:228/:258/:283 —— 三子标签屏幕 y 全锚在已被 `UpdateShowingTextTail :114` 顶过的 `pTextTail->y` 上,`net_world` `SUBTAG_DEFAULT_Y` 常量 + `_reposition_name_tag` 顶完名字尾标后按同一 `lift` 平移 `GuildTag` / `TitleTag` / `LevelTag`;增量 121 落地横向:`name_x_shift`(fxAdd 8/4/0)/ `title_x_offset` / `level_x_offset` / `subtag_world_x` 照抄 `ArrangeTextTail :211-320` 非 EUROPE 分支,`net_world._layout_subtags_x` 在 `_apply_text_tail_to` 尾部设名字 / `TitleTag` / `LevelTag` 的 `.position.x`、`GuildTag` 恒 0;增量 123 落地文本宽:`text_metrics.gd` 纯静态第 30 个 canonical `text_size` / `width_of` / `label_width`(`Font.get_string_size` = `CGraphicTextInstance::GetTextSize` 1:1),`_layout_subtags_x` 的 `name_w` / `title_w` 与 `ground_items._arrange_tags` 的名条框 `w` 改走 `TextMetrics.label_width`、`SUBTAG_CHAR_W_PX` / `TAG_CHAR_W_PX` 保留仅作无字体兜底;增量 124 落地单行文本高:`text_metrics.gd` 增 `FALLBACK_LINE_H_PX := 15.0` + `height_of` / `label_height`,`ground_items._arrange_tags` 的框高 `h` 改走 `TextMetrics.label_height`(= `GetTextSize` 的 h)、`TAG_LINE_H_PX` 保留仅作无字体兜底;seam ⑦ 剩余:无公会徽记 mark 实体 / 三子标签竖排叠 x 偏移 vs 同一屏幕行)、~~悬停 `chr.Pick()` 强显~~(增量 116 落地:`pick_show.gd` + `net_world._hover_vid` forced 第 5 路 + `player_controller.hover_entity_changed`)、~~屏幕空间 `ArrangeTextTail` 防重叠~~ 增量 120 落地:`text_tail_arrange.gd` 纯静态第 29 个 canonical `boxes_overlap`(`isIn`)+ `arrange_item_tails`(外层逐条 / 内层比对 / 命中顶到 `pCompareTextTail->y + yEnd + 5` / 从头重跑 / `MAX_ITERS` 20 封顶),`ground_items._arrange_tags` 每帧投影所有掉落名条、跑去重叠、按 `pixel_size` 折回局部 y,`game_scene` 传相机 getter;`CanPickInstance` 仅按死透近似(seam ⑩:名条框宽 / 单行文本高已分别由增量 123 / 124 换真实字体度量 `GetTextSize` 的 w / h 1:1、带 owner 的去重叠框已由增量 125 按 `SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)的「owner 独立度量 + `yEnd += yOwnerSize + 4`」换 1:1(`text_tail_arrange.gd` 加纯静态 `item_box_with_owner`、`text_metrics.gd` 加 `string_width_like` / `string_height_like`、`ground_items._on_added` 存 `item_name` / `item_owner` meta 后 `_arrange_tags` 把名字子串与 owner + `'s` 子串分别度量),剩余:owner 未独立渲染成 CENTER 黄字 / `strOwnership` 未接本地化语素、只对掉落 item 名条)、伤害飘字单 `Label3D` 写整串 vs `damagevalue` dds 逐位特效 / self 双资源交替 / 全局单队列(seam ⑪)、目标框 poc 无 building / wooden-door 实体类型(`is_building` 恒 false、木门分类缺)/ ~~`LCONTROL` 悄悄话捷径未接~~(增量 122 已接:`net_play._on_pick` KIND_PC 分支轮询 `whisper_on_ctrl_click` → `whisper_requested` → `game_scene` → `chat.start_whisper`;`game_scene` `KEY_CTRL` 喂 `_lcontrol_down`)/ `target_info` 由 C++ EntityStore 侧抽出不改 C++ 收包(seam ⑫ 末尾:门实体分类、独立 whisper dialog 窗口) | | P1 | 🟡 | Actor 战斗运行时 | 有骨骼动画、基础 combo / 相机抖动;受击不再用固定硬直秒数(增量 73 删除 `_hitstun_until`),「能否行动」统一走 `_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`(对齐 `InstanceBase.cpp:1247` / `InstanceBaseBattle.cpp:355` / `:310`),击退态由受击 `.msa` 播放生命周期驱动、动作播完清零;动作切换按 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(增量 86:`type 6` FIFO `_pending_shots` → `client.shoot()` = `OnShoot`,`OnSetFlyTarget` 在 `net_play._emit_swing` 发 `client.add_fly_targeting()`;WARP 留钩子);玩家 / 怪物强制实时投影并附脚底接触阴影 Decal;点击 / 攻击距离按目标类型分 `150 / 500 / 100` cm(增量 74,对齐 `InstanceBaseBattle.cpp:294`);普攻节奏改由 `.msa` `ComboInputData.DirectInputTime`(`get_motion_data().next_combo`,缺组退化 `duration*0.9`)除以 `bAttackSpeed/100` 得出,`attack_speed` 只缩放动作播放速率不再写死间隔;自动攻击走 `ReservedMode` 状态机(增量 75):`_update_auto_attack()` + `_reserve_process_click_actor()` 严格 10 步(照 `PythonPlayer.cpp:207` / `PythonPlayerInput.cpp:877`:出距离 `pc.walk_to` 靠近、NPC 走 `click_npc`、`IN_SAFE` / `DEST_IN_SAFE` / `EMPTY_ARROW` 经 `signal cannot_act`、第 10 步按节奏 `_do_attack_swing`) | PC 连击段表已解析(增量 80,`formats/combo_table.{h,cpp}` 从 `playersettingmodule.py` 的 `ReserveComboAttackNew` / `RegisterComboAttackNew` 建 `map>`,非 `.msm`),并已接进 `net_play.gd` 连击状态机替掉 `(_combo + 1) % 3`(增量 81:`_do_attack_swing` / `_run_next_combo` / `_combo_process` / `_on_end_combo` / `_clear_combo` 逐行对齐 `ActorInstanceBattle.cpp:157/213/256/297/308`,按 `.msa` `ComboInputData` 三时间驱动 `_combo_index` / `_is_pre_input` / `_is_next_pre_input`,段号经新静态绑定 `Metin2AnimPlayer.parse_combo_tables()` 按 `race & 3` 取);`.msa` `HitDataContainer` 多命中窗已导出(增量 82:`formats/msa.{h,cpp}` `Msa::HitWindow` + `get_motion_data().hit_windows` / `motion_type` / `hit_limit_count`,`textscript.cpp` 补 `List` 解析,含真实双命中窗 `assassin/dualhand_sword/combo_01.msa`);命中帧已落地(增量 83:`net_play.gd` `_begin_hit_windows` / `_attack_process(dt)` / `_hit_deduped` / `_register_hit` / `_hit_geometry` / `_flush_victim_list` 逐帧按动作时间与 `hit_window` [start,end] 相交对 `client.get_entities()` 可攻击 / 未死 / 非安全区实体做几何判定,`m_HitDataMap` 每窗查重 + 命中上限 NORMAL/COMBO 16 / SKILL `hit_limit_count`,命中 → `client.attack(_swing_skill, vid)`(`OnHit`),被击退目标帧末 `client.sync_positions()` ≤16;`_emit_swing` 改为只有缺命中窗数据才在挥击瞬间发 `CG_ATTACK`);武器种类 → `combo_motion_mode` 映射已落地(增量 84:`net_play.gd` `motion_mode_for()` 逐分支复刻 `CInstanceBase::RefreshState` 的 `SetMotionMode`(`InstanceBase.cpp:2851`),`_refresh_motion_mode()` 读 `parts[WEAPON]` / `get_equipment()[WEAR_WEAPON].vnum` + `proto.item().{type,sub_type}` + `mount_vnum` / `polymorph` / `wedding_dress`,由 `_on_entity_info` / `inventory_changed` / `mount_changed` 三处触发,换模式内部 `_clear_combo()`;`combat_fx_test` 加 `FakeProto` + 15 段映射 + 2 段端到端断言);弓箭 `__GetBowRange()` 已落地(增量 85:`_clickable_distance()` 里 `_is_bow_mode()` 改用 `_bow_range_cm()` = `BOW_RANGE_BASE_CM`(2500-100) + `_bow_distance_bonus`(`POINT_BOW_DISTANCE`=34,`_on_points()` 读 `points["bow_distance"]`),NPC/采集物分支覆盖之,逐分支对齐 `NEW_IsClickableDistanceDestInstance` + `__GetBowRange`;`combat_fx_test` §3.5 段加 8 段断言);剩余:几何判定当前是 `reach`(max WeaponLength / 采样水平偏移)+ 正面 ±60° 弧近似,真实动态 Z 圆柱 vs 防御球扫掠随 §3.1 Actor 碰撞一起补;坐骑移速规则、`POINT_BOW_DISTANCE` 真服下发路径和服务端合法性校准;技能合法性判定位数据层已落地(增量 87:`ui/skill_table.gd` 按 `RegisterSkillDesc` 把 skilldesc 第 11/12 列折成 `attr_bits` / `need_weapon_bits` + `skill_type`,`IsCanUseSkill` / `IsStandingSkill` / `IsHorseSkill` / `IsNeedTarget` / `IsNeedCorpse` / `CanChangeDirection` / `IsChargeSkill` / `CanUseWeaponType` 等谓词逐条对齐 `CPythonSkill`);三层校验已落地(增量 88:`project/player_skill.gd` `extends RefCounted` 纯校验器逐行移植 `PythonPlayerSkill.cpp` 的 (a)`click_skill_slot`(`:106`)/ (b)`can_use_skill`(`:439`,硬编码骑乘术 `109`)/ (c)`check_skill_usable`(`:166` 完整前置顺序)/ (d)`use_skill`(`:460` + `_resolve_target` 目标矩阵),返回 `{ok, code, target_vid}`,`net_play.skill_context()` 注入运行期状态,`ui/quickbar.gd activate()` 施放前跑三层、非静默码 → 信号 `skill_rejected` → `net_play.cannot_act`(同一 `OnCannotUseSkill` 通道),新增 `player_skill_test.gd`);边界收紧 + 短蓝短血校验已落地(增量 89,§3.8 修改 3 → 三层全落地:`net_bounds.h` `SKILL_MAX_NUM=255` + `skill_index()` 谓词,`M2Client::use_skill()` 的 `skill_id` 护栏收紧成 `!bounds::skill_index()` 对齐 `ClickSkillSlot` 的 `slot >= SKILL_MAX_NUM` 门;`skill_table.gd` 解析 `.msk` `NeedSPFormula` + `_SKILL_POWER[]` 功率表 → `need_sp` / `need_hp` / `_skill_point`;`player_skill.gd` `_check_short_mana` / `_check_short_life` 从桩改为移植 `__CheckShortMana`(`:320`)/ `__CheckShortLife`(`:354`),HP/SP 经 `net_play.skill_context()` 读 `get_points()` 供入、`-1` 不判);技能升级门已落地(增量 90,§3.10:`wire.h` +`POINT_SKILL=28` / `POINT_SUB_SKILL=27` / `POINT_HORSE_SKILL=113`,`M2Client::get_points()` 透出 `skill_active` / `skill_support` / `skill_horse`,`skill_up()` 入参收紧为 `!bounds::skill_index()`;`skill_table.gd` +`max_level_of` / `skill_level_limit`(本 40250 数据无 `LEVEL_LIMIT` 列 / `.msk` 记号 → 恒 0)/ `can_level_up_skill(id, level)`(= `skillCanLevelUpSkill`,本 build 前置技能 / 前置属性字段无人填充 → 化简为 `level < GetMaxLevel() && !CANNOT_LEVEL_UP`);`skill_ui.gd` `refresh()` 逐行移植 `uicharacter.py` 的 `RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` / `CanShowPlusButton`——技能组门 `get_skill_group()!=0` + 三技能点来源分开取 + `skillGrade==0` + 马术页 `level<20` + 主动页 `can_level_up_skill` + 辅助页 `HIDE_SUPPORT_SKILL_POINT` 整页隐;按钮 `_rebuild()` 建 / `refresh()` 切 `visible`,升级结果由 `GC_SKILL_LEVEL` 经 `skills_changed` 驱动、不本地伪造;`skill_test.gd` +`§3.10` 段);`ReservedMode` 的 CLICK_ITEM / CLICK_POSITION / USE_SKILL 分派已落地(增量 91:`_refresh_reserved()` 四路 `match` 逐行对齐 `NEW_RefreshMouseWalkingDirection`,含 CLICK_ITEM 的 20cm 拾取阈值、CLICK_POSITION 的 0.1s ReservedDelay 门、USE_SKILL 的 `IS_HUGE_RACE` −200cm 修正 + `use_skill_hook` 进射程施法;`__ChangeTargetToPickedInstance` 拾取回退 = `IsTargetableInstance` 限 PC/怪、否则清目标;扇形 / 圆形补 fly-targeting = `send_fly_targeting()` 经 `_instances_in_fan_range` / `_instances_in_circle_range` + `_random_pos_in_fan_range` 补 `CG_ADD_FLY_TARGETING`,几何逐行对齐 `NEW_GetInstanceVectorInFanRange` / `InCircleRange`);安全区 / 弓箭 / 骑乘 / dash 判定位的服务端来源、`IS_HUGE_RACE` vnum 白名单(现用 `proto.mob` `size>=3` / `rank>=4` 近似)仍留桩;另补 combo 取消窗口、精确受击 / 击退、角色碰撞、同步碰撞、武器拖尾、剩余 motion event / 骨骼挂点语义、GPU skin LOD、双持 / 左手武器和目标锁定相机;`03150` 类偏移网格需 per-weapon 偏移数据或修 .gr2 | | P1 | 🟡 | 飞行物与命中特效 | `GC_FLY_TARGETING` / `GC_ADD_FLY_TARGETING` 保存 shooter 的目标队列和 VID / 坐标;~~`GC_CREATE_FLY` 可消费队列并生成 tween 发光球~~ → **增量 92:`project/fly_object.gd` 的 `FlyManager` / `FlyInstance` / `FlyData` 对齐 `GameLib/FlyingObjectManager.{h,cpp}` + `FlyingInstance.{h,cpp}`——`Update()` 逐帧真实弹道(`vel += accel*dt` + `gravity`)+ 线段-点命中判定(`_seg_point_sq_dist` 1:1 移植 `EterLib/GrpMath.h:80` `square_distance_between_linesegment_and_point` < `m_fBombRange²`)+ `OnShootDamage` / pierce `--` 续飞 / 超程 `__Explode(false)` / `m_bHitOnBackground` 触地;`FlyData` 默认值取 `FlyingData.cpp __Initialize()`;`net_world._on_fly` 换成 `_fly_mgr.spawn(...)` + `signal fly_exploded`。`net_play._can_shot()` 防御式重建 `__CanShot`(`grep` 全树无定义)——非弓 / 无箭 `EMPTY_ARROW` / 无目标 / 死目标 / 出弓程 `OUT_OF_RANGE`,`_emit_swing` 弓分支据此跳过 `CG_FLY_TARGETING` / `OnShoot`** | seam:`GC_CREATE_FLY` 在本 40250 客户端无包处理器(仅 `NetStream.cpp` 头串索引 70)→ 实际驱动走 POC 自有 `fly_cue`;重力参考端作用于 `.z`=上、本 POC 映射到 `-Y`;怪物球体碰撞 / `CullingMgr.ForInRange` / 建筑体素 r=1 判定未移植(客户端飞行物只做命中"时机",伤害服务端裁决);无 `.fly` 数据文件加载器 → 合成箭参数(`init_vel=45` / `flat_range=max(dist*1.5,30)` / `bomb_range=0.6`);`FIRE_CRACKER` / `AUTO_FIRE` 的 `CreateIndexedFly` 分支已在常量层预留但本 build 无驱动来源;bomb effect / 声音 / EffectLib 特效仍是占位闪光 | | P1 | 🟡 | 任务 / NPC | 增量 95 已完成 `CPythonEventManager` 常用标签解析 / 分派;增量 101 完成 Quest 日志 `QUEST_MAX_NUM=5` 五行窗口、离散滚动、默认 / 服务端 icon、clock 和任务行事件;增量 108 完成小地图任务信标越界夹圆周 + 指向目标的方向箭头(世界箭头 / 屏幕边缘指示首版,`minimap.signal_marker_geometry` + `_draw_signal_arrow`);增量 109 完成 quest 信件按钮条 `interfacemodule.__ArrangeQuestButton` 多列定位(`yCount=(H-330)//63`、换列 +100px、最新在前、组队窗 `xPos`→130);`SelectItemUI`、QuestButton、地图信号和对话图片已有首版 | 逐帧 `UpdateEventSet`、Skip / EndEventProcess、`.msc`、NEXT / DONE 文案、相机 / fade / cinema 消费端、CJK IME、任务日志窗体 `_list` 自身分页 / 多列、Atlas 同一信标夹边、完整 `.sub` 图标、立绘 / 电影、真实资源和真服仍待验收 | | P1 | 🟡 | 商店 / 交易 / 仓库 | 普通 NPC 商店、交易状态和仓库列表已有首版;**`SHOP_GC_START_EX` 多货架已解析(`m_shop_tabs`,`get_shop()`),`shop_ui` 带 5×8/40 格货位、货架按钮 + `tabIdx*40+slot` 买位置、空位禁用和 BUY 确认(固定 count=1)**;增量 56 增加仓库 5×9 分页 / 指定格 / 仓库内移动、交易 12 格指定投放和商店 BUY/SELL toggle / 出售投放区;增量 57 补齐出售 anti-flag、首次贵重物品确认、普通 / `COUNT_PER_1GOLD` 价格公式和失败音效;增量 58 接入统一物品 tooltip;增量 60 交易物品状态补齐 sockets[3] / attrs[7] | 补交易事务回滚提示、仓库金钱 / 改密码、完整音效和 `coin_type` 非金币货币显示;真实 `.sub` / 图标和真服回包仍待验收 | | 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 有尺寸断言;增量 56 增加商城 45 格分页 / 商城→背包以及私人商店 40 格全局物品态投放;增量 57 补齐 `ANTIFLAG_GIVE/MYSHOP`;增量 58 接入统一物品 tooltip;商城状态路由进 `EntityStore`,`private_shop_ui` / `mall_ui` 和真服探针均已接入。 | 仍缺真 `.sub` 版 `privateshopbuilder.py` / `shopdialog.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=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` 覆盖 | 增量 129 补 messenger 三固定组(好友 / 公会 / 情侣,对齐 `uimessenger.py:274-295`)与 `lover_login/logout/near/far/divorce` 命令事件 → 情侣在线 / 在身边状态(`love_ui` 后缀 + `friend_ui` 分组行);仍需情侣 affect 表现、断线 / 切图生命周期、原版文案和真服务器端到端验收 | | P1 | 🟡 | 公会 | 信息、成员、技能、公会战、会徽上 / 下载和部分操作已有首版;`GC_REQUEST_MAKE_GUILD=82` 已解析并以 `M2Client.guild_make_requested` 通知;公会窗已弹出创建名称输入并发送 `CG_ANSWER_MAKE_GUILD`;`CG_GUILD_SYMBOL_CRC=113` / `GC_GUILD_SYMBOL_DATA=133` 已支持原始符号文件下载。**增量 129 恢复公会范围**:权限门(`GUILD_AUTH_*` 位控踢人 / 公告 / 技能升级 / 施放,grade 0 = 会长,数据未同步回退全开交服务器权威)、成员页资金行与踢人按钮、`!` 前缀公告发布、`/gskillup ` 技能升级、公会战宣战应答弹窗(state 3 → `/war`·`/nowar`) | 仍缺公会日志页、积分板、领地和公会建筑;完整真服验收待进行 | | — | ❌ | 龙魂精炼 | 当前目标 40250 服务端不提供该玩法;本阶段不纳入支持或验收,已有兼容代码仅保留作协议参考 | 若未来服务端增加该玩法,再另立协议、UI 和验收需求 | | 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()` / 鼠标滚轮缩放;增量 61 接入 EventManager 任务信号点、清除和 Atlas center adjust;增量 108 任务信号点越出小地图半径时按 `m2dev PythonMiniMap.cpp:441-462` 夹到圆周并改画指向目标的方向箭头(`SIGNAL_RIM_INSET=9.0`);Atlas 支持底图、地图名称、玩家坐标、拖动和坐标查询 | 补原 minimap / Atlas 贴图细节、提示、队员 / NPC 分类、Atlas 侧同一信标夹边、完整 `.sub` 边缘指示图标、地图驱动天气配置和原版窗口视觉 | | P1 | 🟡 | EffectLib | `.mse` Particle 已映射到 `GPUParticles3D`;增量 53 已读取首个 `TextureFiles` 的实际 `.dds`,并接入位置、发射形状、方向 / 速度;增量 64 已解析 `.mde` v1/v2、展开三角形帧、翻转 UV、接入 `.ifl` 贴图和 MeshElement 材质 / 透明度;增量 65 已接入 Light 组、范围曲线、多贴图 TexAni 的帧延迟 / 顺逆序和旋转类型 | TexAni 随机起始帧 / 每粒子语义、MovingType / 时间曲线、Direct3D 材质 stage、骨骼挂点和资源逐项视觉验收 | | 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`;角色有实时投影 + 接触阴影;`.msenv` 双光层和纹理天空首版已接入 | 补其余固定功能材质与多贴图分支、精确 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` 加点命令作为迁移样板** | **选魔石窗** `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、数字 1–4、F1–F4、LSHIFT 翻页、Ctrl/Command+数字表情、空格连续攻击、Alt 显示名字、截图、帮助 / 坐骑 / 角色窗口和实体 PICK / TALK / ATTACK 光标已接;窗口键盘不会穿透到世界;增量 56 已接商店 BUY / SELL 光标 | 仍需 CJK IME、完整复杂窗口 mousemodule、不可达 / DOOR / CHAIR / MAGIC / camera resize 等光标状态、真实 `.sub` 光标资源、macOS Fn / 功能键实机验收;键位重绑定 / 相机灵敏度持久化不是参考端已有能力 | | P2 | 🟡 | CJK 输入与本地化 | 支持 KEY→VALUE 查表和基础字体注入 | 补 `PythonIME` 中 / 日 / 韩候选窗、复数 / 性别规则、字体自动切换、完整 item / mob 名称与 tooltip 本地化 | | P2 | 🟡 | 音频 | BGM crossfade、UI 音效池和 MilesLib 子集已接入:监听器、200/1000 缩放、音量曲线、普通 / 环境 / 角色 3D 播放、角色限频、停止与限幅淡出;本地 `.msa` 动作声音和 `.mss` 60 FPS 动作声音表走角色入口;地图 `AreaAmbienceData` + `.pra` 已接入 LOOP / ONCE / STEP、距离音量和卸载停止 | 补 EffectInstance 完整声音节点生命周期、完整武器 / 技能 / 怪物 / NPC 资源逐项验收和真机空间听感校准;`.msenv` 剩余项归入地图环境渲染与天气联动 | | P2 | 🟡 | 客户端外壳 | Godot 工程已有 macOS / iOS / Android 构建脚本 | 补 `config.exe` 对应的分辨率、显示、音量、阴影、语言和鼠标配置;Movie / Web / Logo、补丁器约束、Discord RPC 按发布目标取舍 | | P0 发布 | 🟡 | Pack 资产统一读取 | `PackMount` / `AssetSource` 已实现并有 roundtrip 测试 | `Metin2World` / `Metin2Model` 等仍有直接 `AssetResolver::resolve()` 的散文件路径;必须统一切到 `AssetSource::read/to_path()`,否则 `.epk/.eix` 打包客户端不等价 | ## 参考模块覆盖索引 下表以 `40250/ClientVS22/source/UserInterface/` 的模块职责为索引,补充上表未展开的基础能力。 “已有”只表示当前有可运行首版;仍需满足本文末的 1:1 完成判据。 | 参考模块 / 能力域 | 当前实现 | 与 40250/ClientVS22 的差距 | 优先级 | |---|---|---|---| | `AccountConnector` / `PythonNetworkStreamPhase*` 登录与相位 | auth → game → 选人 → LOADING → PHASE_GAME 已打通;`LoadingScreen`、频道状态探针、Classic DirectEnter 和 Classic `GC_WARP` 重连 / 换图清理均有首版 | 登录 / 选服 / 选频道的原版错误码、表单校验、超时与会话失效文案,真实双连接 / 换图验收、服务器列表刷新和所有相位边界清理仍未逐项对齐 | P1 | | `PythonChat` / `PythonTextTail` / `PythonPlayer` | 聊天四标签、私聊 / 组队 / 公会 / 喊话路由,实体气泡、名字、HP 条、伤害飘字和本地气泡已有;**头顶名字颜色已 1:1(增量 110,`name_color.gd` `GetNameColorIndex` / `SetEmpireNameMode`,`net_world` 驱动 `Label3D.modulate`);头顶公会名 / 称号色 `GetTitleColor` / 等级子标签已 1:1(增量 111,`text_tail.gd` `RefreshTextTail` / `UpdateTextTailLevel` / `EnablePKTitle`,`net_world` 驱动 `GuildTag` / `TitleTag` / `LevelTag`);头顶聊天 / 信息尾标已 1:1(增量 112,`chat_tail.gd` `RegisterChatTail` / `RegisterInfoTail` / `TextTail_GetLivingTime` / `IsChatTextTail`,`net_world` 每 vid 一条 `"bubble"` + 5s 硬删 + 前缀剥离 + SHOUT·COMMAND 排除);头顶名字「是否显示」`IsAlwaysShowName` / `__IsShowName` / `ShowAllTextTail` 的 3500 平面距裁剪 / `ShowCharacterTextTail` 资格已 1:1(增量 113,`name_show.gd` 纯静态,`net_world._name_shown()` 四路 + `game_option_ui` `display_option_changed` 信号 + `net_play.target_changed` 强显);头顶伤害飘字 `GC_DAMAGE_INFO` / `AddDamageEffect` / `ProcessDamage` 分类 + `IsShowDamage` 门已 1:1(增量 114,`damage_effect.gd` 纯静态,`net_world._on_damage` / `_process_damage_queue` 每帧 pop 一条 + `game_option_ui`「伤害数字」radio 接 `net_world.set_show_damage`);头顶目标框 `GC_TARGET` / `RecvTargetPacket` 收包分派 + `CanViewTargetHP` 已 1:1(增量 115,`target_board.gd` 纯静态 `Action { CLOSE, CLOSE_IF_DIFFERENT, SET_HP, NONE }` 判定树,`net_play._on_target_info` 按判定树分派:PC / building 不显血条框、NPC / warp 关框、石头 / 敌对怪 `SetHPTargetBoard`、换 VID 先 Reset);`game.py OnRender` Picking 块分派 + 悬停角色强显名字已 1:1(增量 116,`pick_show.gd` 纯静态第 27 个 canonical:`IsPickedWindow` 门 + `chr.Pick()` / `targetBoard.GetTargetVID()` → `ShowCharacterTextTail` + `__IsShowName()` 分叉 `item.Pick()` ↔ `ShowAllTextTail` + `textTail.Pick()` + `SelectItemName`,`net_world._hover_vid` forced 第 5 路 + `player_controller.hover_entity_changed`);头顶名字尾标随聊天气泡上移 17px 已 1:1(增量 117,`name_tail_layout.gd` 纯静态第 28 个 canonical `UpdateShowingTextTail` :110-114 —— chat 尾标 `bNameFlag` TRUE 时名字 `Label3D` 局部 y 顶到「气泡 y + 17*pixel_size」,`net_world._reposition_name_tag` 由 `_apply_chat_tail` / `_expire_chat_tails` 调用;seam ⑨ 收口);目标框「看他国玩家」前置过滤 + §8.4 名字色 radio 接线已 1:1(增量 118,`target_board.hidden_as_other_empire` + `net_play._on_target_info` 前置过滤 + `net_play.set_view_other_empire_target` / `net_world.set_empire_name_mode` 经 `game_scene._on_display_option` 接 `game_option_ui` 的 `target_board` / `name_color` radio;`game_option_ui.gd` `target_board` 默认修 0→1;§8.4 三显示 radio + LALT 全部接线);头顶「公会名 / 称号 / 等级」子标签随名字尾标纵向位移已 1:1(增量 119,`name_tail_layout.gd` `name_tail_lift_delta` / `subtag_world_y` 照抄 `ArrangeTextTail` :211/:228/:258/:283,`net_world` `SUBTAG_DEFAULT_Y` 常量 + `_reposition_name_tag` 顶完名字尾标后按同一 `lift` 平移 `GuildTag` / `TitleTag` / `LevelTag`,气泡过期 / 「信息」尾标时回默认);掉落物名条屏幕空间去重叠 `ArrangeTextTail` item 块 + `isIn` 已 1:1(增量 120,`text_tail_arrange.gd` 纯静态第 29 个 canonical:`boxes_overlap`=`isIn` AABB 含边界 + `arrange_item_tails` 外层逐条 / 内层比对 / 命中顶到 `pCompareTextTail->y + yEnd + 5` / 从头重跑 / `MAX_ITERS` 20 封顶 + `box_*` 四边 `-w/2-2` `w/2+2` `-2` `h`,`ground_items._arrange_tags` 每帧把所有掉落名条锚点 `cam.unproject_position` 到屏幕、按字符数估框、跑去重叠、按 `Label3D.pixel_size` 折回局部 y、可见名条 < 2 回默认,`game_scene` 传相机 getter);头顶「公会名 / 称号 / 等级」子标签逐像素水平排版(`ArrangeTextTail` 角色块 `PythonTextTail.cpp:211-320` 非 EUROPE 分支)已 1:1(增量 121,`name_tail_layout.gd` `name_x_shift`(fxAdd 8/4/0)/ `title_x_offset`(`−nameW/2−8`)/ `level_x_offset`(有称号再 `−titleW`)/ `guild_name_x_offset` / `mark_x_offset` / `subtag_world_x`,`net_world` `const SUBTAG_CHAR_W_PX := 7.0` + `_layout_subtags_x` 在 `_apply_text_tail_to` 尾部设名字 / `TitleTag` / `LevelTag` 的 `.position.x`,`GuildTag` 恒 0(poc 无公会徽记实体);纵向 `.position.y`(117/119)与横向 `.position.x`(121)互不干扰);目标框点击 `LCONTROL` 悄悄话捷径已 1:1(增量 122,`net_play` `signal whisper_requested(name)` + `var _lcontrol_down` + `set_lcontrol_down()`,`_on_pick` 在 `KIND_PC` 分支(C++ picking 只对 PC 派发 `SetPCTargetBoard`)轮询 `target_board.whisper_on_ctrl_click`(`lcontrol_down and same_empire and not is_main and not is_building`,谓词增量 118 已备)→ 发 `whisper_requested`,`game_scene._unhandled_input` `KEY_CTRL` 按下 / 松开喂 `set_lcontrol_down(e.pressed)` + `whisper_requested` → `chat.start_whisper`(poc 版 `OpenWhisperDialog`=聊天输入预填 `"/w 名 "`);`is_building` 实参恒 false(poc 无门实体、:814 分支永不触发)、无独立 whisper dialog 窗口 = seam ⑫ 末尾);头顶子标签 / 掉落名条排版的文本宽换真实字体度量 `CGraphicTextInstance::GetTextSize` 已 1:1(增量 123,`text_metrics.gd` 纯静态第 30 个 canonical `text_size` / `width_of` / `label_width` = `Font.get_string_size`,`net_world._layout_subtags_x` 的 `name_w` / `title_w` 与 `ground_items._arrange_tags` 的名条框 `w` 改走 `TextMetrics.label_width`,`SUBTAG_CHAR_W_PX` / `TAG_CHAR_W_PX` 保留仅作无字体兜底、`name_tail_layout.gd` / `text_tail_arrange.gd` 排布公式不动);头顶掉落名条屏幕空间去重叠框的「单行文本高」换真实字体度量 `CGraphicTextInstance::GetTextSize` 的 h 已 1:1(增量 124,`text_metrics.gd` 增 `const FALLBACK_LINE_H_PX := 15.0` + `height_of` / `label_height` = `Font.get_string_size(...).y`,`ground_items._arrange_tags` 的名条框 `h` 由硬编码 `TAG_LINE_H_PX` 改走 `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`,`TAG_LINE_H_PX`(15.0)保留仅作无字体兜底、`text_tail_arrange.gd`(`box_end_y(h) = h`)/ `name_tail_layout.gd` 排布公式不动);带 owner 的头顶掉落名条屏幕空间去重叠框按 `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)的「owner 独立度量 + `yEnd += yOwnerSize + 4`」已 1:1(增量 125,`text_tail_arrange.gd` 扩第 29 个 canonical 加纯静态 `item_box_with_owner`——无 owner -> 纯名字框、有 owner -> `{maxf(name_w, owner_w-2.0), name_h+owner_h+4.0}` 使 `box_end_x(w)` 恰等于参考 :737 `fMAX(nameW/2+2, ownerW/2+1)`;`text_metrics.gd` 扩第 30 个 canonical 加 `string_width_like` / `string_height_like` 量某 `Label3D` 字体下的任意子串;`ground_items._on_added` 存 `item_name` / `item_owner` meta,`_arrange_tags` 把名字子串与 owner + `'s` 子串分别度量后走 `item_box_with_owner`;渲染仍单个合并 `"name (owner)"` `Label3D`)** | 缺屏幕空间血条(名字颜色 / 称号色 / 公会名 / 等级 / 聊天气泡增量 110–112 已 1:1,名字显示开关 113,伤害数字 114,目标框 115,OnRender Picking + 悬停强显 116,名字尾标随气泡上移 17px 117,看他国目标框 + 名字色 radio 接线 118,子标签随名字尾标纵向位移 119,掉落物名条屏幕空间去重叠 120,子标签逐像素水平排版 121,目标框 `LCONTROL` 悄悄话捷径 122,头顶子标签 / 掉落名条文本宽换真实字体度量 `GetTextSize` 123,掉落名条去重叠框单行文本高换 `GetTextSize` 的 h 124,带 owner 的掉落名条去重叠框换 `SetItemTextTailOwner` 1:1 125);名字与气泡合成单尾标链表未做(seam ⑨ 遗留,名字随 chat 气泡下移 17px 已由 117 收口、子标签随名字纵向位移已由 119 收口、`ArrangeTextTail` item 尾标去重叠已由 120 收口、子标签逐像素水平排版已由 121 收口、文本宽换真实字体度量 `GetTextSize` 已由 123 收口、单行文本高换 `GetTextSize` 的 h 已由 124 收口 —— seam ⑦ 剩余:poc 无公会徽记 mark 实体、三子标签竖排叠 x 偏移 vs 参考同一屏幕行);掉落名条框宽 / 单行文本高已分别由增量 123 / 124 换真实字体度量 `GetTextSize` 的 w / h 1:1、带 owner 的去重叠框已由增量 125 按 `SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)的「owner 独立度量 + `yEnd += yOwnerSize + 4`」换 1:1(seam ⑩ 剩余:owner 未真正独立渲染成 CENTER 黄字、`strOwnership` 未接 `ApplicationStringTable` 本地化语素、只对掉落 item 名条);伤害飘字 `damagevalue` dds 逐位特效 / self 双资源交替未做(seam ⑪);目标框 poc 无 building / wooden-door 实体类型(`is_building` 恒 false)/ `LCONTROL` 悄悄话捷径已由增量 122 接 whisper(`net_play.whisper_requested` → `chat.start_whisper`)/ 无独立 whisper dialog 窗口(seam ⑫ 末尾);经验 / 金钱变化的原版日志行、notice / big-notice 排版、历史滚动和 `InsultChecker` 过滤未完整迁移 | P1 | | `PythonPlayer` HUD / affect / target | HP、SP、经验、等级、目标血条、死亡复活窗和 affect 色块条已绑定 | 原版状态栏布局、图标贴图、持续时间倒计时、目标板按钮 / 交互、所有 point 类型和战斗日志格式仍是简化版 | P1 | | `PythonPlayerInput*` / `CameraProcedure` / `PythonApplicationCursor` | WASD、点地、触屏点选 / 单指相机拖动 / 双指捏合、桌面右键拖拽与滚轮缩放、网格 AABB 点选、相机遮挡淡出和碰撞、首版 TALK / ATTACK / PICK 光标和窗口键盘焦点已有;`GC_CHANGE_SPEED` 会校准本地移动速度 | 缺 BUY / SELL / CANT_GO 等完整光标状态、真实 `.sub` 光标资源、相机模式与灵敏度配置持久化;macOS F1–F12 仍需实机验收 | P1 | | `EterPythonLib` / `PythonApplicationModule` UI 运行时 | 80 个真实 `uiscript` 可解析;约 20 类控件、窗口栈、modal、拖动标题栏、全局物品 attach / drop / cancel 和通用弹窗已有 | `ani_image`、`mark`、真实 `grid_table` / scrollbar / list 行为、`.dds` UI 贴图、复杂窗口的落点分支、焦点 / 锚点 / 层级和原版九宫格视觉仍未完全对齐 | P1 | | `PythonEffect` / `PythonFly` / `EffectLib` | `.mse` Particle → `GPUParticles3D`、首个 `.dds`、位置 / 形状 / 方向 / 速度、技能 / 服务器特效触发、基础飞行球和动作事件已有;`.mde` v1/v2 几何帧、UV、`.ifl` 贴图和基础 MeshElement 材质已接入;Light 组、范围曲线、多贴图 TexAni 基础帧控件、旋转类型和 MovingType 直接 / 二次 Bezier 位置曲线已接入 | TexAni 随机 / 每粒子语义、完整 emitter advanced / cycle、完整时间轴、Direct3D 材质 stage、骨骼挂点、真实弹道 / 命中事件和声音仍缺 | P1 | | `PythonMiniMap` / `PythonBackground` | 圆形小地图、实体 / NPC / 任务点、观战者、领地边框、队员 / 队长标记、鼠标滚轮缩放;Atlas 已有区块底图、拖动、坐标查询、地图名称和玩家 / 队员标记;地形 / 建筑 / 水体 / 天气和昼夜首版已有 | 缺原 minimap / Atlas 贴图细节、世界箭头、地图驱动天气配置和原版窗口视觉 | P1 | | `PythonSystem` / `MovieMan` / `PythonApplicationWebPage` / `Discord` | Godot macOS / iOS / Android 构建脚本、基础窗口和音频设置入口已有 | 缺 `config.exe` 的分辨率 / 阴影 / 语言 / 鼠标配置持久化、Movie / Logo / Web 页面、补丁器约束及按发布目标取舍的 Discord RPC | P2 | | `ProcessCRC` / `ProcessScanner` / `PythonProfiler` | 反作弊明确列为不做 | 若服务端未来强制校验,只补最小协议上报;性能采样、崩溃上报和诊断工具不属于当前 1:1 交付范围 | — | ## 协议覆盖审计 `40250/ClientVS22/source/UserInterface/Packet.h` 是一个按 “To Server / From Server” 分段的全局 enum,不存在 `namespace CG` / `namespace GC`。本文此前的“68 + 109 = 177”以及“当前 0 个 header” 是把旧版 `wire.h`(m2dev)与 Classic 实现混在一起后的历史统计,不能继续作为当前覆盖结论。 当前协议审计必须按运行时变体分开: - **当前默认 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` | 64 (`0x40`) | 40250 Server 的管理文本包,当前 Classic 已有 1 字节固定前缀尺寸表;后续文本由服务端按换行特殊解析。它不在 ClientVS22 客户端 `Packet.h` 的普通 To Server 命名项中,因此不计入 ClientVS22 客户端 header 覆盖数。Cube 继续使用 `CG_CHAT` / `CHAT_TYPE_COMMAND`,不伪造管理命令入口。 | ### 已接线但仍需验收 - `CG_CHARACTER_POSITION`(28 / `0x1C`) / `CG_SYNC_POSITION`(8 / `0x08`):wire、发送接口和 loopback 字节测试已覆盖;真服单会话已分别发送 5B / 16B,均保持在线且无 unknown;仍缺原版 自动触发语义、姿态动作链和服务端位置纠偏回包验收。 - `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`(71 / `0x47`) / `GC_ADD_FLY_TARGETING`(69 / `0x45`):wire、EntityStore 状态、 `M2Client.fly_targeting` 信号和 `NetWorld` 目标队列消费已覆盖;本轮发现并修复 `GameClient` 未分派这两个 GC header 的 bug;真服本轮未主动推送,仍缺真实弹道模型、命中时机 和服务端广播验收。 - `GC_CHANGE_SKILL_GROUP`(112 / `0x70`):wire、EntityStore 状态、dirty 失效和 `M2Client.skill_group_changed` 已覆盖;本轮发现并修复 `GameClient` 未分派该 header 的 bug; 仍缺技能组选择 UI、服务端权限和真服推送验收。 - `CG_CHANGE_NAME`(106 / `0x6A`) / `GC_CHANGE_NAME`(107 / `0x6B`):wire、角色槽位更新、 `M2Client.change_name()` 和 `char_name_changed` 已覆盖;选人页按 `change_name` 标志接入 原版“点击开始先改名”流程,并对重名 / 非法名错误码给出状态文案;仍缺真服验收。 - `GC_REQUEST_MAKE_GUILD`(82 / `0x52`):无负载 wire 解析和 `guild_make_requested` 信号已覆盖;公会窗已 完成创建窗口、12 字上限、空名 / 控制字符校验和 `CG_ANSWER_MAKE_GUILD` 应答;仍缺服务端敏感词 / 重名错误回包、实际建会状态和真服验收。 - `CG_GUILD_SYMBOL_CRC`(113 / `0x71`) / `GC_GUILD_SYMBOL_DATA`(133 / `0x85`):16B / 8B wire、`MarkClient` CRC 请求、 变长数据接收和 `M2Client.guild_symbol_ready` 已覆盖;仍缺本地符号缓存失效策略、UI 预览和 真服验收。 - `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`(110 / `0x6E`) / `GC_DUNGEON`(110 / `0x6E`):4B / 5B wire、目的地坐标变长 body、 `EntityStore` 事件和 `M2Client.dungeon_event` 已覆盖;仍缺副本计时 / 入口 UI、地图生命周期 清理和真服验收。 - `GC_LAND_LIST`(130 / `0x82`):4B 固定头 + 24B `LandPacketElement` 变长解析、领地状态、 `M2Client.get_land_areas()` / `land_areas_changed` 和小地图领地矩形边框已覆盖;真服登录洪流 已收到并解析 436B;仍缺领地贴图 / 建筑和有实际领地变更时的推送验收。 - `GC_OBSERVER_ADD/REMOVE/MOVE`(96–98 / `0x60–0x62`):12B / 8B wire、持久观战者状态、 `M2Client.get_observers()` / `observer_event` 和小地图紫色菱形标记已覆盖;仍缺真服验收。 - `CG_WARP`(65 / `0x41`):4B wire、`GameClient.send_warp()`、`M2Client.request_warp()` 和回环 请求 / `GC_WARP` 回传测试已覆盖;仍缺传送确认 UI、冷却 / 失败提示和真服验收。 - `GC_DIG_MOTION`(134 / `0x86`):13B wire、`EntityStore` 事件、`M2Client.dig_motion` 和 `NetWorld` 朝向 / 动画元数据处理已覆盖;仍缺真实挖矿动作资源、音效 / 特效和真服验收。 - `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 消费和真服务器端到端行为。 ## 建议迁移顺序 ### M1:服务器语义正确 1. 把远程 `SHOOT` 时机、范围目标与技能类型冷却接入现有技能协议; 2. 快捷栏的 `INVENTORY / SKILL / EMOTION` 类型映射、数字键 / Ctrl 表情 / F1–F4 槽位语义、 物品拖放与特殊冷却规则; 3. 细化 24 wear / 腰带 / 装备查看的原版窗口表现,并完成完整物品操作; 4. 移动同步、PVP / 决斗和 Actor 战斗状态机; 5. 用 loopback + 真服抓包为每类协议建立双向验收。 ### M2:主要玩法完整 1. 任务完整标签 / 输入 / 选道具 / 指引; 2. 商店 START_EX、仓库密码、商城、私人商店、Cube(拍卖行 40250/ClientVS22 无,除非另起协议); 3. 组队角色、情侣完整交互、公会日志 / 积分板、领地与公会建筑;公会完整页面已随增量 129 恢复; 4. 钓鱼、副本、坐骑、观战者和 Atlas。 ### M3:UI 与表现 1:1 1. 以原 `interfacemodule.py` 为窗口清单,逐窗口迁移行为; 2. 完成 tooltip、拖放、光标、IME、快捷键和系统设置; 3. 完成 EffectLib、SpeedTree、材质、地图表现和声音事件; 4. 固定原客户端参考机位、角色、地图、时间和 UI 状态,建立截图 / 录屏签核。 ### M4:发布闭环 1. 所有资产调用方统一使用 `AssetSource`,验证纯 pack 运行; 2. 覆盖目标平台构建、前后台、输入和性能; 3. 按服务器要求决定 Discord / Web / Movie 等桌面能力是否迁移(**反作弊 `ProcessCRC` / `ProcessScanner` 已标注 ❌ 不做**,除非服务端强制校验)。 ## 1:1 完成判据 一个功能只有同时满足以下条件才标记为完成。当前 40250 阶段对已明确暂缓的真服、公会和龙魂项不作完成承诺: 1. 原客户端涉及的 CG / GC 包和 sub-header 全部覆盖,并有尺寸断言; 2. 数据状态能正确恢复、重连、切图和清理; 3. UI 操作、错误提示、拖放、快捷键和窗口互斥与原客户端一致; 4. 模型、动画、特效、声音和名字 / tooltip 表现一致; 5. loopback、真服务器端到端、受控截图或交互录屏至少有一种自动门禁和一种人工签核。 在达到以上判据前,应继续使用“首版”“部分完成”描述,避免把“能解析布局”“有一个按钮”或 “能播放动作”写成 1:1 完成。 --- ## ClientVS22 实现细节审计(2026-09-02) ### 审计口径 本节以当前 `metin2-client` 源码和 `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/metin2-client/extension/src/net/classic/classic_session.cpp)、 [`m2_client.cpp`](/Users/shenlei/Work/mt/metin2-client/extension/src/net/m2_client.cpp)。 2. **游戏中断线的回登录链路已接入,真实 TCP 断线仍待验收。** 已建立游戏连接后的 Classic 失败发 `disconnected`,`AppFlow` 无条件回 LOGIN;认证 / 游戏连接建立前的失败仍发 `login_failed`。`ReconnectUI` 已从 AppFlow 主流程移除,但独立文件仍保留作旧测试 / 可选增强。 见 [`m2_client.cpp`](/Users/shenlei/Work/mt/metin2-client/extension/src/net/m2_client.cpp)、 [`app_flow.gd`](/Users/shenlei/Work/mt/metin2-client/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/metin2-client/extension/src/net/classic/classic_session.cpp)。 5. **客户端版本包是硬编码的。** 当前固定发送 `metin2.bin` 和旧 timestamp,未读取实际 构建版本、文件时间或资源包版本。服务端开启版本校验时,更新客户端后仍可能被当成旧客户端。 见 [`classic_session.cpp:335`](/Users/shenlei/Work/mt/metin2-client/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/metin2-client/extension/src/net/classic/classic_stream.cpp)。 8. **已知但没有业务处理的包静默丢弃问题已修复第一批。** `ClassicParser` 现在记录阶段 / header 并返回失败,连接层清空缓冲并断开;可选集成包不会再静默成功。按阶段拆分处理表和 全量业务覆盖仍待完成。见 [`classic_parser.cpp`](/Users/shenlei/Work/mt/metin2-client/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/metin2-client/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/metin2-client/extension/src/net/entity_store.cpp:458)、 [`net_world.gd:128`](/Users/shenlei/Work/mt/metin2-client/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/metin2-client/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/metin2-client/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. **本地移动改为 Src/Dst 模型(增量 76)。** ~~当前控制器直接修改 X/Z,只做地形阻挡检查~~ —— `player_controller.gd` 去掉 `_move_target` / `_has_target`,改 `_src_pos` / `_dst_pos` / `_is_going` / `_dst_rot`,`walk_to()` → `_goto()` 对齐 `CInstanceBase::NEW_Goto()` (`InstanceBaseMovement.cpp:251`):太近(`< MOVABLE_GROUND_DISTANCE_M` 1.0 m)不动、三门 `_can_translate()`(`not frozen and not locked and not moving_skill` ≈ `__IsSyncing` / `isLock` / `IsUsingMovingSkill`)未过时存 `_reserved_ground` + `RESERVED_GROUND_DELAY` 0.1 s 延迟重试(≈ `__ReserveClickGround` / `NEW_IsEmptyReservedDelayTime`),`moving_skill` 只转向。 WASD 分支置 `_is_going = false`(≈ `NEW_MoveToDirection`)。见 [`player_controller.gd`](/Users/shenlei/Work/mt/metin2-client/project/player_controller.gd) + `project/player_move_test.gd`。**增量 93 补完 Actor-Actor 碰撞 + 坐骑转向速度**: `player_controller._actor_blocked(next_pos)` 近似 `CActorInstance::TestActorCollision` (`ActorInstanceCollisionDetection.cpp`)+ `CInstanceBase::CheckAdvancing` (`InstanceBaseBattle.cpp:469`)—— 8 m 距离门(≈ `LengthSq > 800²` 直接不检)+ body 球 重叠(`ACTOR_BODY_RADIUS_M = 0.55` 相加)+「趋近」判定(`d_next < combined && d_next <= d_now`, 远离放行,能从重叠里走出来),候选 = `pickables`,`dead` meta 跳过(≈ `rVictim.IsDead()`); `_process` 平移逐轴门控(单 Actor 逐轴放行 = 滑移 ≈ `AdjustDynamicCollisionMovement`,两轴都 挡 = 停在上一帧位 ≈ `uCollisionCount==2` → `BlockMovement()` / `__InitializeMovement`); `skip_actor_collision`(≈ `CActorInstance::CanSkipCollision()` / `__EnableSkipCollision`)由 `net_play` 在 `setup()` / `_on_observer_mode` 按 `_observer_mode` 置位。坐骑转向速度:新增 `rotation_speed_deg`(默认 `ROT_SPEED_DEFAULT_DEG = 1200` = `c_fDefaultRotationSpeed`)+ `_turn_toward(target_yaw, dt)` 逐帧限幅收敛,替换原两处 `player.rotation.y = atan2(...)` 瞬转; `net_play._refresh_motion_mode()` 在 `mounted` 时切到 `ROT_SPEED_HORSE_DEG = 300` (= `c_fDefaultHorseRotationSpeed`,对齐 `CInstanceBase::MountHorse` → `SetRotationSpeed`, `InstanceBase.cpp:954`),下马恢复 1200;`MODE_HORSE*` 动作模式已由增量 84 覆盖。 `player_move_test.gd` 5 组 → 8 组。**仍未接**:~~Actor 之间碰撞(`TestActorCollision` / `BlockMovement` 的 Area3D 近似,需远端 Actor 碰撞体)、坐骑 `MODE_HORSE*` 移速与动作模式 (依赖 §6.4 + §3.2)~~、真实 body sphere 半径需 race 碰撞数据(`.col` / `CollisionData.h`)、 `AdjustDynamicCollisionMovement` 精确滑移向量 + 门 / Actor 复合的 `uCollisionCount==2` (现用逐轴近似)、`MOVABLE_GROUND_DISTANCE` 参考默认值校准、路径寻路 / 坡度处理、网络驱动 移动的 `__EnableSkipCollision()`(§3.2 网络状态包驱动的本地预测玩家尚未接)。 2. **远端同步改为命令队列 + 硬阈值(增量 77 / 78 / 79,§3.2 完成)。** ~~当前按固定时间或距离发送移动, 回包后按距离阈值 snap/lerp~~ —— `EntityStore` 的远端 Actor 位置改为 `std::deque` 命令队列驱动,1:1 复刻 `CInstanceBase::StateProcess`(`InstanceBase.cpp:1278`):`mut_move()` 入队(≈ `PushTCPState`)后 `drain_state_queue()` 按 `m_server_frame_ms`(`set_server_frame_ms()`, 为 0 = 帧钟未对齐 → 立即释放,退回增量 76 时序)+ `__CanProcessNetworkStatePacket` (`dead` / `knock_down` 挂队)逐条释放;`apply_state_cmd()` 走 `FUNC_WAIT` 1.0 / `FUNC_COMBO/ATTACK/MOB_SKILL` 50.0 硬阈值 + `m_kMovAfterFunc` 到达后动作 + `skip_collision` (≈ `__EnableSkipCollision`)。`SNetworkActorData::UpdatePosition` 式线性插值保留但只喂 §2.1 可见性。**增量 78(part B)**:`net_play.gd` 上行按 `PythonPlayerEventHandler.cpp` 拆成 6 个回调(统一出口 `_send_state`)—— OnMove 立即发 `FUNC_MOVE` 并重置 moving/waiting 两个节流窗、OnMoving 300 ms 节流、OnWaiting(`_tick_on_waiting`)100 ms 节流且位移 ≥ 1 cm、 OnStop 收尾 `FUNC_WAIT`、OnAttack 额外 `FUNC_COMBO`、新公共入口 `on_use_skill()` → `FUNC_SKILL|motion`;`net_world.gd` 去掉自有 `follow_lerp`,`_process` 直接 `n.position = e.pos`(位置插值全交 C++ `tick()`,只朝向仍 GDScript 平滑)。 **增量 79(part C,§3.2 收官)**:`ClassicStream::server_frame_ms()`(≈ `ELTimer_GetServerFrameMSec()`)由 `HDR_HANDSHAKE` 存的 `m_server_time_base = hs.time + lDelta` / `m_client_time_base = now_ms()` 算出(握手前返回 0);`ClassicSession::process()` 在 `m_world.tick()` 前 `set_server_frame_ms(m_stream.server_frame_ms())`,classic 后端 命令队列遂按服务器时间放行(m2dev 无 `lDelta` 对应物,恒 0 = 即时释放,无回归)。 `drain_state_queue` 加防呆阀 `STATE_QUEUE_MAX_WAIT_MS = 1000`——`chk_time` 领先帧钟超过 1 s 立即放行以免远端 Actor 冻结(参考端无此上限,有意偏离)。 仍未接:完整 sequence / authoritative tick,**抖动缓冲不做、预测回滚不做**(§3.2 / §6 明确排除);真实服务器 `GC_MOVE` `dwTime` 时基核验、`__IsEnableTCPProcess` 表情锁、 `GC_SYNC_POSITION` 是否清队列随对应 §3.x 处理。见 §3.1 / §3.2 修复批次。 3. **普攻节奏部分对齐动作数据(增量 74)。** ~~范围、攻击间隔、硬直和连击窗口均为固定值或简单按攻速缩放~~ —— 点击 / 攻击距离改为 `CLICK_DIST_DEFAULT_CM=150 / NPC_CM=500 / RESOURCE_CM=100` (对齐 `InstanceBaseBattle.cpp:294`);普攻节奏改为读 `.msa` `ComboInputData.DirectInputTime` (`metin2_anim.get_motion_data().next_combo`,缺组退化 `duration*0.9` / headless 兜底 `DEFAULT_ATTACK_PERIOD`)再除以 `bAttackSpeed/100`。弓箭 `__GetBowRange()` 已在增量 85 接入 (`_clickable_distance()` 里 `_is_bow_mode()` 改用 `_bow_range_cm()` = `2500-100` + `_bow_distance_bonus`,NPC/采集物分支覆盖之,逐分支对齐 `NEW_IsClickableDistanceDestInstance` + `CInstanceBase::__GetBowRange` `InstanceBase.cpp:694`)。仍未接: `AttackingData` / `HitPosition` 驱动的命中帧 / 硬直 / 无敌帧的真实动态圆柱扫掠(§3.5, 当前 reach + 正面弧近似)、mount 与武器规则、服务端合法性校准。见 [`net_play.gd`](/Users/shenlei/Work/mt/metin2-client/project/net_play.gd:200)。 4. **自动攻击目标预留逻辑已移植(增量 75)。** ~~当前主要是检测距离后发攻击包~~ —— `net_play.gd` 新增 `enum ReservedMode` + `_update_auto_attack()`(照 `PythonPlayer.cpp:207`,检查目标存在 / 死亡 / 骑乘 / charge skill / 睡眠后 `_reserve_click_actor()`)+ `_reserve_process_click_actor()` 严格 10 步(照 `PythonPlayerInput.cpp:877`,出距离 `pc.walk_to` 靠近 / NPC 走 `click_npc` / `IN_SAFE` / `DEST_IN_SAFE` / `EMPTY_ARROW` 经 `signal cannot_act` / 第 10 步按 §3.3 节奏 `_do_attack_swing` 后清预约)。仍未接:CLICK_ITEM / CLICK_POSITION / USE_SKILL 分派、安全区 / 弓箭 / 骑乘等级 / dash 等判定位的服务端来源、`OnCannot*` 文案接 HUD。 参考:[`ClientVS22/PythonPlayer.cpp:207`](/Users/shenlei/Work/mt/40250/ClientVS22/source/UserInterface/PythonPlayer.cpp:207)。 5. **~~攻击动作、命中时机和网络包没有严格绑定~~ —— §3.5 修改 1-5 已全部落地(增量 80/81/82/83)。** ~~当前 combo 仍只在 0/1/2 之间循环(`net_play.gd _do_attack_swing` 的 `(_combo + 1) % 3`)~~ —— 增量 81 已替换:`_do_attack_swing` / `_run_next_combo` / `_combo_process` / `_on_end_combo` / `_clear_combo` 逐行对齐 `CActorInstance::InputComboAttackCommand` / `__RunNextCombo` / `ComboProcess` / `__OnEndCombo` / `__ClearCombo` (`ActorInstanceBattle.cpp:157/213/256/297/308`),按 `.msa` `ComboInputData` 三时间 (`pre_input_time` / `direct_input_time` / `input_limit_time`)驱动 `_combo_index` / `_is_pre_input` / `_is_next_pre_input`,段号取自增量 80 的段表 (新静态绑定 `Metin2AnimPlayer.parse_combo_tables()`,按 `race & 3` × `combo_motion_mode<<16|type`)。 ~~hit frame、范围、角度、武器轨迹、击退和受击动作仍未由真实 motion data 驱动。~~ —— 增量 83 已接命中帧(见下);武器轨迹拖尾仍留桩。 ~~`RaceData` 的 combo 段表需从 `.msm` 解析~~ —— **更正**:PC 连击段表**不在** `.msm` 里 (`.msm` 只有 `BaseModelFileName` + `HairData`),而是 `assets/root/playersettingmodule.py` 的 `chrmgr.ReserveComboAttackNew` / `RegisterComboAttackNew` 调用建在 `CRaceData` 上 (`PythonCharacterManagerModule.cpp` → `CRaceData::ReserveComboAttack` / `RegisterComboAttack`)。增量 80 新增 `formats/combo_table.{h,cpp}`(`fmt::PlayerComboTables`) 把那批调用解析为按职业分块的 `map>`,语义 1:1 对齐 `CRaceData` (`Reserve` insert 不覆盖、`Register` 越界 / 未登记跳过),`formats_combo_table_test` 合成 + 真实资源双覆盖。增量 82(part C,修改 1)导出 `.msa` `HitDataContainer` 多命中窗: `formats/textscript.cpp` 补 `List Name { ... }` 解析(此前 `List HitPosition` 会破坏后续 `Group`,`palbang.msa` 的 `MotionEventData` 因此丢失),`formats/msa.{h,cpp}` 把 `AttackingData` 重写为 `std::vector`(`HitWindow{start_time, end_time, bone_name, weapon_length, vector}`),同时吃老式内联(无 `HitDataCount`)与新式 `AttackType` + `MotionType` + `HitDataCount N` + `Group HitData0i` 两种写法,新增 `motion_type` / `hit_limit_count`,`attack_start_time` / `attack_end_time` 保留为窗 0 别名;`get_motion_data()` 增加 `hit_windows` / `motion_type` / `hit_limit_count`;`formats_msa_test` 补合成 + `M2_ASSETS` 真机断言(含真实双命中窗 `assassin/dualhand_sword/combo_01.msa`:右手窗 `Bip01 R Hand` + 左手窗 `Bip01 L Hand`)。增量 83(修改 4,§3.5 收官)在 `net_play.gd` 落地命中帧: `_begin_hit_windows()` 挥击起手缓存 `get_motion_data().hit_windows` / `motion_type` / `hit_limit_count` / `invisible_time`;`_attack_process(dt)` 每帧按动作时间窗 `[t-dt, t]` 与某个 `hit_window [start,end]` 相交时,遍历 `client.get_entities()` 里可攻击 / 未死 / 双方 非安全区的实体,`net_world.node_for(vid)` 取世界位做几何判定(对齐 `InstanceBaseBattle.cpp:413 AttackProcess` + `ActorInstanceCollisionDetection.cpp:333 __NormalAttackProcess`):`_hit_deduped()` 复刻 `m_HitDataMap`(`MOTION_TYPE_COMBO` 同窗同目标 一次、其余按 `invisible_time` 冷却),`_register_hit()` 复刻每窗命中上限(NORMAL/COMBO 16、 SKILL `hit_limit_count`),命中 → `client.attack(_swing_skill, vid)`(`OnHit`→`SendAttackPacket`, 普攻 `_swing_skill=0`),被击退目标帧末 `_flush_victim_list()` → `client.sync_positions()`(≤16, 对齐 `PythonPlayerEventHandler.cpp:194 FlushVictimList`)。`_emit_swing()` 因此改为只有拿不到 命中窗数据(headless / 缺资源)才在挥击瞬间立即发 `CG_ATTACK`。几何判定为近似: `reach = max(WeaponLength, 窗内采样最大水平偏移)` + 正面 `±60°` 弧(真实动态圆柱扫掠需骨骼矩阵 + defending sphere,随 §3.1 actor 碰撞一起留桩)。`combat_fx_test.gd` 加 `FakeNetWorld` + 6 段断言。 ~~**剩余(增量 84)**:武器种类 → `combo_motion_mode` 映射(当前恒 `MOTION_MODE_GENERAL`)。~~ —— **增量 84 已落地**:`net_play.gd` 补全 `MOTION_MODE_*`(`CRaceMotionData::EMode` 全 17 项) + `ITEM_TYPE_WEAPON/ROD/PICK` + `WEAPON_SUB_*` 常量,新增静态 `motion_mode_for(item_type, weapon_sub, mounted, poly, dress)` 逐分支复刻 `CInstanceBase::RefreshState` 的 `SetMotionMode`(`InstanceBase.cpp:2851`,顺序 Poly→`GENERAL` / 婚纱→`WEDDING_DRESS` / 镐→骑乘 `HORSE` 否则 `GENERAL` / 钓竿→骑乘 `HORSE` 否则 `FISHING` / 骑乘 `switch(subType)` → `HORSE_{ONEHAND,TWOHAND,DUALHAND}_SWORD`·`HORSE_{FAN,BELL,BOW}`·默认 `HORSE` / else `switch(subType)` → `{ONEHAND,TWOHAND,DUALHAND}_SWORD`·`BOW`·`FAN`·`BELL`· `ARROW`/默认 `GENERAL`),`_equipped_weapon_vnum()`(主角 `parts[WEAPON]` 非 0 优先,否则 `get_equipment()[WEAR_WEAPON].vnum`)+ `_refresh_motion_mode()`(读 `mount_vnum` / `polymorph` / `wedding_dress` + `proto.item(vnum).{type,sub_type}` → `set_combo_motion_mode(...)`,换模式 内部 `_clear_combo()`),由 `_on_entity_info()` 末尾、`inventory_changed`(`window==2` EQUIPMENT)、 `mount_changed`(主角)三处触发。`combat_fx_test.gd` 加 `FakeProto` + 15 段 `motion_mode_for` 断言 + 2 段端到端(换匕首 → `DUALHAND_SWORD` 且 `_combo_index` 归零;再上马 → `HORSE_DUALHAND_SWORD`)。 ~~仍留桩:弓箭 `__GetBowRange()`(§3.5 命中 / 点击距离,增量 85+)~~ —— **增量 85 已落地**: `_clickable_distance()` 里 `_is_bow_mode()`(`combo_motion_mode` ∈ {`MODE_BOW`,`MODE_HORSE_BOW`}, 对齐 `CActorInstance::IsBowMode` `ActorInstance.cpp:258`)改用 `_bow_range_cm()` = `BOW_RANGE_BASE_CM`(`2500-100`)+ `_bow_distance_bonus`(`_on_points()` 读 `points["bow_distance"]` = `POINT_BOW_DISTANCE` 34),NPC 500 / 采集物 100 分支覆盖之, 逐分支对齐 `NEW_IsClickableDistanceDestInstance`(`InstanceBaseBattle.cpp:294`)+ `CInstanceBase::__GetBowRange`(`InstanceBase.cpp:694`);`wire.h` 补 `POINT_BOW_DISTANCE=34` + `M2Client::get_points()` 透出,`combat_fx_test.gd` §3.5 段加 8 段断言。 `wedding_dress` 实体标志本工程暂无(读 `e.wedding_dress`,缺则 false)。 `OnAttack` 的 `CG_CHARACTER_MOVE{FUNC_COMBO}` 已在增量 78 落地。 6. ~~**弓箭发射仍有固定超时兜底。** 当前优先消费 `.msa` 的 FLY 事件,但 pending skill 仍由约 1.5 秒定时器兜底;不同技能、攻速、动作长度和网络延迟下可能偏离真实事件,连续释放还可能 覆盖 pending skill。~~ —— **增量 86 已落地**:删掉 `game_scene.gd` 的单槽 `_pending_shoot_skill` + `get_tree().create_timer(1.5)`,换成 `_pending_shots: Array[int]` FIFO(上限 8,满丢最旧)。每次弓挥击 / 弓技能起手 `_queue_shot(uSkill)` 压一个,`.msa` `MOTION_EVENT_TYPE_FLY` 帧 `pop_front()` → `client.shoot()`(pop 即自清 = `_AutoClear`, 连射多箭在途各自一发;对齐 `CActorInstance::ProcessMotionEventFly` `ActorInstanceMotionEvent.cpp:290`)。`OnSetFlyTarget`(`ActorInstanceMotion.cpp:372`)改在 `net_play._emit_swing`:`_is_bow_mode()` 时 `_send_fly_target()`(目标节点 → `MapCoord.to_server_cm()` → `client.add_fly_targeting(vid, x, y)`;`OnGetFlyTargetPosition` = 目标包围球中心,平面分量与脚点一致,`ActorInstanceFly.cpp:4`)+ `bow_shot_fired.emit`。 ~~仍待:飞行物实体(`CFlyingManager` / `FlyingInstance`)、`__CanShot` 弓箭合法性~~ —— **增量 92 已落地**:新增 `project/fly_object.gd`(`FlyData` / `FlyInstance` / `FlyManager`), 对齐 `GameLib/FlyingObjectManager.{h,cpp}`(`CreateFlyingInstanceFlyTarget`、`Update()` 的 `while` 迭代 + `erase`、`CreateIndexedFly` 的 `NORMAL` / `FIRE_CRACKER` / `AUTO_FIRE` 分支 常量)+ `FlyingInstance.{h,cpp}`(`Create` → `__SetDataPointer` → `__SetTargetDirection`; `Update()` 逐帧 `vel += accel*dt` → `vel.z += gravity*dt` → `remain_range -= |movement|` → 线段-点 `square_distance_between_linesegment_and_point < m_fBombRange²` 命中 → `if (m_canAttack) OnShootDamage()`(本 POC 转 `shoot_damage(vid)` 信号)→ `m_iPierceCount` 则 `--` + `__Bomb()` 续飞、否则 `__Explode()` + `return false`;`m_fRemainRange<0` → `OnExplodingOutOfRange` + `__Explode(false)`(不 `__Bomb`);位置目标同线段判定; `m_bHitOnBackground` 的 `GetTerrainHeight(x,-y) > z`)+ `FlyingData.cpp __Initialize()` 默认值(米制换算)。`_seg_point_sq_dist` 1:1 移植 `EterLib/GrpMath.h:80`。`net_world.setup()` 挂 `FlyManager` 子节点,`_on_fly()` 把原 tween「发光球」占位换成 `_fly_mgr.spawn(start_w, target_node|goal_w, false, FlyData, type)` 真实弹道,新增 `signal fly_exploded(world_pos, cause)`。`net_play._can_shot(target_vid := -1) -> String` 防御式重建 `__CanShot`(`grep -rn "CanShot|__CanShot|CanShoot"` 全树无命中——据 `IFlyEventHandler::OnNoTarget` / `OnNoArrow` `FlyHandler.h`、`NEW_Attack` `InstanceBaseBattle.cpp:355` 的 `IsDead` 门、 `__GetBowRange` `InstanceBase.cpp:694` 重建):非弓 → `"NOT_BOW_MODE"`;`_arrow_count == 0` → `"EMPTY_ARROW"`(`-1` = 未跟踪 / 无限,放行);`_target_vid` / `_vid_reserved` 均 ≤ 0 → `"NO_TARGET"`;目标实体已知且不可攻击 / 已死 → `"NO_TARGET"`(`te.is_empty()` 不拒—— headless 容差,与 `_send_fly_target`「有节点即可」一致);目标世界距离 `* CM > _bow_range_cm()` → `"OUT_OF_RANGE"`;否则 `""`。`_emit_swing` 弓分支据此加门:`_can_shot()` 非空 → `_emit_cannot(why)`(走 `OnNoArrow` / `OnNoTarget` 文案),不发 `CG_FLY_TARGETING`、不排 `OnShoot`;其余起手动作照常(seam:参考端整挥击中止)。新增 `project/fly_test.gd`(第 14 个 GDScript 回归)。C++ 21/21(无 C++ 改动)+ 14 GDScript 全绿。已文档化 seam:`GC_CREATE_FLY` 在本 40250 客户端无包处理器(仅 `NetStream.cpp` 头串索引 70)→ 实际驱动走 POC 自有 `fly_cue`;`__CanShot` 防御式重建;重力参考端作用于 `m_v3Velocity.z`=上、本 POC 映射到 `vel.y`= `-Y`;怪物球体碰撞 / `CullingMgr.ForInRange` / 建筑体素 r=1 判定未移植(客户端 飞行物只做命中"时机",伤害服务端裁决);无 `.fly` 数据文件加载器 → 合成箭参数。仍待:真服 `CG_FLY_TARGETING` z 分量、`.fly` 数据文件解析。见 [`fly_object.gd`](/Users/shenlei/Work/mt/metin2-client/project/fly_object.gd) / [`game_scene.gd`](/Users/shenlei/Work/mt/metin2-client/project/game_scene.gd) `_queue_shot` / `_on_local_motion_event`。 7. **受击状态分裂。** ~~`EntityStore.stunned` 和 `NetPlay._hitstun_until` 是两套互不统一的状态~~ —— 增量 73 已删除 `_hitstun_until` 本地硬直计时器,「能否行动」统一走 `_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`(对齐 `InstanceBase.cpp:1247` / `InstanceBaseBattle.cpp:355` / `:310` ≈ `IsDead || IsStun || IsPushing`)。`Entity.knock_down` 由本地受击 `.msa` 播放生命周期驱动,`dead` / `stunned` 保持服务端包驱动。**剩余**:远端实体 `knock_down` 填充、真实 40250 受击 / 击退 / `AFFECT_STUN` 清除时序、`mut_stun` 目前无独立清除包。 8. **~~技能缺少统一合法性检查。~~** ~~当前主要检查 skill id 是否大于零,没有统一验证技能归属、等级、 master、cooldown、消耗、目标类型、目标数量、距离、角度、当前动作、PK 和坐骑限制。 `target_vid` 等参数还可能先被转换为无符号整数。~~ —— **§3.8 修改 1/2/3 于增量 87/88/89 全部落地。** —— **增量 87 落地判定位数据层(§3.8 修改 2)**:POC 无 C++ skill proto,技能数据只在 GDScript `project/ui/skill_table.gd`,现按 `RegisterSkillDesc`(`PythonSkill.cpp:280`)把 skilldesc 第 11/12 列折成 `attr_bits` / `need_weapon_bits` 位掩码 + `skill_type`,`CPythonSkill` 的 `IsCanUseSkill / IsStandingSkill / IsHorseSkill / IsAttackSkill / IsNeedTarget / IsNeedCorpse / IsAutoSearchTarget / CanChangeDirection / IsNeedEmptyBottle / IsNeedPoisonBottle / IsChargeSkill / CanUseWeaponType / IsOnlyForGuildWar` 等谓词逐条落地,`skill_test.gd` +34 断言。 —— **增量 88 落地三层校验(§3.8 修改 1)**:新建 `project/player_skill.gd`(`extends RefCounted`, 纯校验器,不发包 / 不驱动动画,返回 `{ok, code, target_vid}`),逐行移植 `PythonPlayerSkill.cpp`: (a)`click_skill_slot()`(`:106` 入口分派:越界 / 无数据 / 公会技路由 / 被动 / 切换技)、 (b)`can_use_skill()`(`:439` 全局:观察态 / 骑乘中且骑乘术 `109` `grade<1 && level<20` / 不可行动)、 (c)`check_skill_usable()`(`:166` 完整前置顺序:骑乘⇄马术 `NOT_HORSE_SKILL` / `HAVE_TO_RIDE` → 安全区攻击技 `IN_SAFE` → 被动 → 空瓶 `27995` → 毒瓶 `27996` → 钓鱼 `REMOVE_FISHING_ROD` → 未学 `NOT_YET_LEARN` → 武器不匹配 `NOT_MATCHABLE_WEAPON` → 无箭 `EMPTY_ARROW` → dash / 蓄力 / 冷却 `WAIT_COOLTIME` → HP / SP)、(d)`use_skill()`(`:460`:私人商店静默、 `__CheckSpecialSkill` 钓鱼 `123` / 连击 `122`、切换技、施法中 `ALREADY_CASTING`、 `_resolve_target()` 目标矩阵:同盟 / 安全区 / 可攻击 / 尸体 → `CANNOT_USE_SELF` / `ONLY_FOR_ALLIANCE` / `CANNOT_ATTACK_ENEMY_IN_SAFE_AREA` / `CANNOT_ATTACK` / `ONLY_FOR_CORPSE` / `NEED_TARGET`)。命名常量 `SKILL_MAX_NUM=255`(`Packet.h:2041`)/ `SKILL_TYPE_GUILD=3` / `RIDING_SKILL_INDEX=109` / `ITEM_EMPTY_BOTTLE=27995` / `ITEM_POISON_BOTTLE=27996` / `SKILL_INDEX_COMBO=122` / `SKILL_INDEX_FISHING=123`(`PythonPlayerModule.cpp:6-7`); `SILENT_CODES[]` 13 个无文案码 + `static is_silent_code()`。运行期状态尽量走 `client` (`get_main_vid` / `get_entity` / `get_skills` / `get_target` / `get_inventory`),其余经 `net_play.skill_context()` 每次注入(观察 / 骑乘 / 安全区 / 钓鱼 / 施法中 / 可行动 / dash / `weapon_sub_type` / `arrow_count`)。`ui/quickbar.gd activate()` 技能分支施放前跑三层, `TOGGLE_OFF` → 关闭切换技 + `skill_activated`,非静默码 → 新信号 `skill_rejected` → `game_scene` 转 `net_play.cannot_act.emit(code)`(同一条 `OnCannotUseSkill` 文案通道), gate 解析出的 `target_vid` 覆盖点击目标。新增 `project/player_skill_test.gd`(读真 `skilldesc.txt`,覆盖 (a)(b)(c)(d) 每条分支 + 目标矩阵 + `is_silent_code`)。 —— **增量 89 落地边界收紧 + 短蓝短血校验(§3.8 修改 3,三层全落地)**:`net_bounds.h` 新增 `SKILL_MAX_NUM=255` + `skill_index(v)=0= SKILL_MAX_NUM` 门(`net_bounds_test.cpp` +4 断言)。`skill_table.gd` `_load_cooltime` → `_load_msk`,加解析 `.msk` `NeedSPFormula` + `_SKILL_POWER[]`(`LocaleService_GetSkillPower` INTERNATIONAL 表,`Locale.cpp:126`)+ `_skill_point()` / `need_sp()`(≈ `GetNeedSP`, `PythonSkill.cpp:1426`;无公式返回 `-1`,POC 约定与参考的 0 相异以免误挡)/ `need_hp()` (照抄 `__CheckShortLife` 复用 `GetNeedSP` 的怪癖)。`player_skill.gd` +`SKILL_INDEX_SUMMON=131` + 上下文 `cur_sp` / `cur_hp`(`-1`=未知不判),`_check_short_mana` / `_check_short_life` 从桩 改为逐条移植 `__CheckShortMana`(`:320`)/ `__CheckShortLife`(`:354`),转 `NOT_ENOUGH_SP` / `NOT_ENOUGH_HP`;HP/SP 由 `net_play.skill_context()` 读 `get_points()` 供入。`player_skill_test.gd` +“(c) §3.8 修改 3”段。C++ **21/21** + **13** 个 GDScript 回归全绿。见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.8 / §3.9 / C.5。 9. **公共网络 API 的 wire 参数边界已补齐,但业务合法性仍未完成。** 增量 52 已在 `M2Client` 所有相关窄类型转换前拒绝负数、溢出、错误 Variant、超限槽位 / 数量 / 文本,覆盖 `attack`、交易、仓库、物品、技能、社交、公会、Cube 和私人商店;剩余是技能目标 / 冷却、背包 规则、权限和地图坐标等业务语义,不能由通用整数边界代替。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.9。 10. **~~技能升级的传输方式已与参考端一致,但门控和状态流程不完整。~~** ClientVS22 本身也通过 `/skillup ` 发包;~~当前缺少参考端的可升级条件门控(技能组、等级上限、技能点、 死亡 / 变身 / 骑乘等状态)、加号按钮显示规则、失败提示和升级后的技能状态刷新。~~ —— **增量 90 落地加号按钮门控(§3.10)**:`skill_ui.gd` `refresh()` 逐行移植 `uicharacter.py` 的 `RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` / `CanShowPlusButton` —— 技能组门(`get_skill_group() != 0`,对应 `__CanUseSkillNow`)、 三个技能点来源分开取(`skill_active` / `skill_support` / `skill_horse`,`wire.h` 加 `POINT_SKILL=28` / `POINT_SUB_SKILL=27` / `POINT_HORSE_SKILL=113`、`get_points()` 透出)、 `skillGrade==0`、马术页 `skillLevel < 20`、主动页 `skill_table.can_level_up_skill(id, level)` (= `skillCanLevelUpSkill`,本 40250 build 里前置技能 / 前置属性字段无人填充 → 化简为 `level < GetMaxLevel() && !CANNOT_LEVEL_UP`;`GetSkillLevelLimit` 无源数据列 / 记号 → 恒 0)、 辅助页 `HIDE_SUPPORT_SKILL_POINT` 整页隐藏。`skill_table` 加 `max_level_of` / `skill_level_limit` / `can_level_up_skill`。`M2Client::skill_up()` 入参收紧为 `mtnet::bounds::skill_index`(与 `use_skill` 同一谓词)。升级结果由 `GC_SKILL_LEVEL` 经 `skills_changed` 驱动刷新,不本地伪造。`skill_test.gd` +`§3.10` 段。 C++ **21/21** + **13** GDScript 全绿。见 [`m2_client.cpp:961`](/Users/shenlei/Work/mt/metin2-client/extension/src/net/m2_client.cpp:961)、 参考 [`PythonSkill.cpp:1833`](/Users/shenlei/Work/mt/40250/ClientVS22/source/UserInterface/PythonSkill.cpp:1833)、 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.10 / C.5。 ### 4. P1:物品、装备、商店和社交 1. **24 个 wear 位置进入数据层,但没有完整视觉和规则。** ~~costume、翅膀、饰品、双持、左手、anti-flag、遮挡、职业/性别变体、部位隐藏、卸装复位、 远程角色换装和逐部位高光仍不完整。~~ **增量 96(§4.1 修改 1 + 修改 3)**:`proto` 层此前 (§3.5)已导出 `type` / `sub_type` / `anti_flags` / `wear_flags` / `limits`(校验数据层齐备); `ui/inventory_ui.gd._to_wire()` 按 `REF/UserInterface/GameType.h:40-112` 分流 wear 槽—— 基础 11 位(`EWearPositions` BODY..SHIELD,`WEAR_MAX_NUM=11`)走 `WINDOW_EQUIPMENT(2)` + 零基 wear cell;`ENABLE_COSTUME_SYSTEM` 的 `c_Costume_Slot_Start(90+19)` / `Hair(90+20)`、 `ENABLE_NEW_EQUIPMENT_SYSTEM` 的 `Ring1(90+21)` / `Ring2(90+22)` / `Belt(90+23)` 在客户端 无独立窗,继续用 `WINDOW_INVENTORY(1)` 全局 cell 109..113;新增常量 `WEAR_COSTUME_BODY/HAIR`、 `WEAR_RING1/RING2`、`NEW_EQUIP_WEAR_START=19`、`WINDOW_EQUIPMENT=2` 全部 1:1 自 `GameType.h`; `_add_extended_cells` 由「wear 11..34 连续 24 格」收窄为「19..23 五个真实新装备位 + 16 腰带格」; `inventory_test.gd` 增 7 条 wire 断言(原 `_to_wire(113)==[1,113]` 转绿)。 **增量 97(§4.1 修改 2)**:40250 客户端不做 `CanEquipNow` 前置门(`PythonItemModule` 只暴露 `IsWearableFlag` / `GetLimit`,仲裁在服务器 `char_item.cpp`),故按 §3.9 `__CanShot` 方式在 GDScript 侧防御性重建——新增 `project/equip_rules.gd`(`extends RefCounted`),枚举 1:1 自 `REF/GameLib/ItemData.h`(`EItemAntiFlag` classic 排列 / `EItemWearableFlag` / `EWearPositions` + `GameType.h` 19..23 / `ELimitTypes` / `EItemType` + `EArmorSubTypes`·`ECostumeSubTypes`)。 `can_equip(item, wear, ctx) -> {ok, code, need}`:目标槽解析 → anti-flag 性别(`ANTI_SEX`)→ 职业 (`ANTI_JOB`,`race%4`)→ 帝国(`ANTI_EMPIRE`,`GC_EMPIRE 1/2/3`)→ `slot_accepts()` (`NOT_WEARABLE_HERE`)→ limit 逐条(`LIMIT_LEVEL` / `LIMIT_STR/DEX/INT/CON`,属性 `<0` 跳过)。 `inventory_ui.gd.use()` 对常规背包格的可穿戴物品跑此门,拦下 → `_set_hint()` + 新 `signal equip_rejected(code, need)` + 不发 `use_item`;加公开 `can_equip_item()` / `_equip_ctx()` (`get_points().level` + `get_entities()` 主角 `race/empire/level`;`st/dx/ht/iq` = `-1` seam)/ `_equip_reject_hint()` 中文文案。新增 canonical 测试 #17 `equip_rules_test.gd`(33 断言)。 **增量 98(§4.1 修改 4 + 修改 5 字段序)**:穿头盔覆盖发型槽、costume(parts[ARMOR]) 压过本体 armor 在增量 96/97 已由 `equip_model.gd` 落地;本次补齐**变身 / 动物时装 / 婚纱的整件遮挡**—— 新增 `project/part_hiding.gd`(`extends RefCounted` 纯函数),常量 1:1 自 `REF/UserInterface/InstanceBase*.cpp`:`POLY_SHAPE=0`(`IsPoly()`→`SetShape(0)`)、 `ANIMAL_WEAR_SHAPES=[100..103]`(`__IsShapeAnimalWear`:2834 → `SetWeapon`:2747 / `SetParts` 返回 false)、`WEDDING_DRESS_SHAPE=201`(`IsWearingDress`)、`BOUQUET_VNUMS=[50201..50204]` (`c_iBouquets`,`__IsChangableWeapon`:901 除捧花外 false)。`equip_model.gd.refresh()` 刷新序改为 对齐 `ChangeArmor`(`SetArmor→SetHair→SetWeapon`)= 身体→头发→武器→盾;解出身体 shape 后过 `PartHiding`:动物 / 怪物时装隐藏 `weapon_gr2`+`shield_gr2`+`hair_gr2`,婚纱非捧花隐藏武器+盾、 头发保留。`net_world._apply_field_updates()` 确认 `set_armor→set_weapon→set_hair` 字段序已 1:1 于 `NetworkActorManager.cpp:473`。新增 canonical 测试 #18 `part_hiding_test.gd`(45 断言)+ `equip_model_test.gd` 补 7 段。 ~~仍为 seam:精确 tie-break 顺序 / 已穿同槽替换 / `LIMIT_PCBANG`·`REAL_TIME*` / 属性 4 项按名读取 留待接服务器 `char_item.cpp` 比对;变身态(40250 实体表无 `poly_race` 字段,`_is_poly()` 恒 `false`,等 `GC_CHARACTER_UPDATE` 带变身 race)。~~ **增量 129(§4.1 装备校验链精确化)**: 已对照 40250 服务器源码逐行核实链路并重排 `equip_rules.gd.can_equip()`—— `char_item.cpp:7462 CanEquipNow`(JOB antiflag → LIMIT_LEVEL/STR/INT/DEX/CON → WEARABLE_UNIQUE 同组去重,`IsSameSpecialGroup` 客户端退化为同 vnum 判定)→ `item.cpp:472 FindEquipCell`(新增 1:1 复刻 `find_equip_cell()`:else-if 优先级 BODY→HEAD→FOOTS→WRIST→WEAPON→SHIELD→NECK→EAR→ ARROW,RING→RING1 空则 RING2,UNIQUE→UNIQUE1 空则 UNIQUE2,COSTUME 按 sub_type,BELT 固定, DS/SPECIAL_DS 走龙魂专用路径返回 -1)→ `char_item.cpp:6100 EquipItem` 后置检查(礼服 vnum 11901..11904 + 骑乘禁止 → 变身中除 `WEAR_ARROW` 一律禁止 → `FN_check_item_sex` 性别 → `IsRideItem` 已骑乘禁止)。**帝国 antiflag 从装备门降级为 tooltip 提示**(`empire_warning()`, 服务器装备路径从不检查、仅显示层语义);`LIMIT_PCBANG(6)` 不是门禁(仅标记), `LIMIT_REAL_TIME(7)` / `LIMIT_REAL_TIME_START_FIRST_USE(8)` 由 socket 驱动到期,客户端职责只有 tooltip 倒计时(`item_tooltip.gd` 补「PC房专用道具」「首次使用后计时」与到期文案);变身门读 实体 `polymorph`/`poly_vnum` + affect 220,`_equip_ctx()` 同步采集 st/dx/ht/iq 四属性与穿戴表 (0..10 + 新装备槽 90+19..90+23)。`equip_rules_test.gd` 增 `find_equip_cell` 与后置检查断言。 **远端 PC 换模链首版已在增量 99 落地**—— `game_scene` 对 `ch_type==0` 创建 `RemotePlayerView`,复用真实 `PlayerView` race / GR2 / MSA; 远端 `EquipModel` 只消费 `awPart[ARMOR,WEAPON,HEAD,HAIR]`,`net_world` 按四字段调用 setter, 不读取本地 `get_equipment()`。所有职业 / race、变身字段、远端真服换装和精确视觉仍待验收。见 [`inventory_ui.gd`](/Users/shenlei/Work/mt/metin2-client/project/ui/inventory_ui.gd) / [`equip_rules.gd`](/Users/shenlei/Work/mt/metin2-client/project/equip_rules.gd) / [`part_hiding.gd`](/Users/shenlei/Work/mt/metin2-client/project/part_hiding.gd) / [`equip_model.gd`](/Users/shenlei/Work/mt/metin2-client/project/ui/equip_model.gd),详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §4.1 / C.5。 2. **物品拖放已有全局首版,但还不是完整原版鼠标物品态。** 已有全局持有物品、鼠标跟随图标、 背包 / 装备 / 腰带到背包 / 快捷栏投放、safebox / mall / shop / exchange / private_shop 的 首版落点、无效区域取消、stack split、实例元数据传递和统一失败恢复;仍缺多格占用、装备预览、 全部窗口的原版光标 / 音效和真服回滚语义。 3. **物品 tooltip 已覆盖固定字段与部分特殊分支,但仍未完整迁移。** 增量 58 已接入 `limits[2]`、`applies[3]`、`values[6]`、实例 `attrs[7]` / `sockets[3]`、精炼关联和英文 `itemdesc.txt`;增量 60 已补鱼竿 / 镐、鱼长度、彩票、混合物品和龙魂基础提示;增量 62 已补 技能书 / 遗忘书 / 变身道具标题和基于限制字段的实时剩余时间。仍缺 CJK 文案 / 字体、其他 特殊分支、装备对比(参考源码未发现对应方法)和完整计时来源语义。 4. **Safebox 金币 / 改密已按 40250 真实能力定界(增量 129),网格仍为首版。** 金币存取经核实 **40250 无 wire 协议**:`HEADER_CG_SAFEBOX_MONEY` 服务端不存在、`GC_SAFEBOX_MONEY_CHANGE(84)` 从不发送、ClientVS22 `SendSafeBoxMoneyPacket` 为 assert-stub、原版 `RefreshSafeboxMoney` 为 `pass`——故 UI 金币只读展示(不伪造协议包,避免状态失步)。`close()` 补发聊天命令 `/safebox_close`(`cmd.cpp:350`,对齐原版关窗即服务器侧关闭);改密走 `/safebox_change_password `(`cmd.cpp:354`,各 1..6 位)三段密码 ConfirmationDialog; `mall_ui.close()` 同步补 `/mall_close`。仍缺 45 格网格、分页、指定目标格、仓库内移动、重试 限制和原版互斥规则。见 [`safebox_ui.gd:7`](/Users/shenlei/Work/mt/metin2-client/project/ui/safebox_ui.gd:7)。 5. **交易结束 / 失败提示已补(增量 129),事务状态仍缺。** 已核实 `GC_END` 对成功与取消发同码 (`exchange.cpp` `EXCHANGE_END`→`Cancel()`),包级不可区分——`EntityStore.ExchangeState` 新增 `last_notice` 捕获 `EXCHANGE_GC_ALREADY(6)` / `EXCHANGE_GC_LESS_GOLD(7)`(新协议 wire 名 `LESS_ELK`),`exchange_ui` 结束时 5s 浮层提示(「对方已经在交易中」/「金币不足」)+ 交易中 状态行内联提示;最终结果以 `CHAT_TYPE_INFO` 为准。仍缺锁定、接受后修改自动取消接受、物品 占用、交易失败恢复和交易中的背包锁定。 6. **NPC 商店已有货位网格与购买确认,但仍非完整原版商店。** 增量 59 已接入 5×8/40 格货位、 空位禁用、货架全局槽号、tooltip 和 BUY 确认(固定数量 1);仍缺真实图标 / `.sub`、非金币货币、 busy 状态、完整库存限制、精确回包和对应音效。 7. **私人商店是兼容实现。** 虽然 `CG_MYSHOP` 已能发送,但原版库存模型、拖放、价格编辑、 开店前检查、库存锁定、撤下回滚和其他玩家查看流程尚未完整迁移。 8. **快捷栏死亡 / 变身 / 失配刷新已补(增量 129),特殊技能冷却仍缺。** 参考端可执行类型只有 `INVENTORY(1)`、`SKILL(2)`、`EMOTION(3)`,当前已统一该编号、接通表情执行、4×8 显示、 inventory → quickslot 投放以及数字 / F1–F4 / Shift 入口;增量 129 接 `entity_dead`(死亡全槽 置灰 + 激活守卫)、`affect_added`/`affect_removed`(affect 220 = 变身,技能槽置灰)、 `inventory_changed`(物品槽 `get_item` 为空 / vnum==0 时置灰并显示 `×vnum`,覆盖换武器 / 服务器回收)。仍缺特殊技能冷却。见 [`quickbar.gd`](/Users/shenlei/Work/mt/metin2-client/project/ui/quickbar.gd)。 9. **组队状态是近似定义。** affect 标签仍有暂定映射,队伍技能冷却、队长权限动态变化、离线、 死亡、距离状态和多账号行为还未完成真实验收。 10. **好友 / 公会 / 情侣 messenger 与公会权限已有完整首版(增量 129)。** `friend_ui.gd` 按 原版三固定组(好友 / 公会 / 情侣,`uimessenger.py:274-295`)分列:好友行、公会行(在线点 / 会长标记 / 点击私聊)、情侣行(♥ 在线点 + 「在身边」标记);情侣在线 / 在身边 / 离婚由 `CHAT_TYPE_COMMAND` 事件 `lover_login/logout/near/far/divorce`(`marriage.cpp`)驱动 `LoverInfo.online/near` → `love_ui` 状态后缀与自动隐藏。公会:`_my_auth()` 按自身 grade 查 auth 位(`guild.h:94-97` ADD/REMOVE/NOTICE/USE_SKILL)门控踢人按钮、`!` 前缀公告、技能施放 / `/gskillup` 升级,成员数据未同步时回退全开(服务器权威,对齐原版无前端门);公会战宣战 应答弹窗(state 3 = `GUILD_WAR_RECV_DECLARE` → `/war ` 接受 / `/nowar ` 拒绝); 成员页资金行(存 / 取包保留 1:1,40250 服务端已禁用 `CG 13/14` 处理器)。仍缺:私聊生命 周期细节、情侣 affect 表现、公会日志页、积分板、领地和公会建筑、真服端到端验收。 ### 5. P1:任务、NPC 和世界玩法 1. **Quest EventManager ~~已从少量标签扩展为常用标签的解析和部分界面 side effect,但不是完整事件机~~—— 标签表 / 索引解析 / 命令分派 / 状态字段已 1:1(增量 95)。** ~~当前支持 `ENTER`、`CLEAR`、`NEXT`、 `DONE`、`QUESTION`、`INPUT`、`SELECT_ITEM`、`LETTER`,并识别 `COLOR/COLOR256`、`ITEM/MOB`、 `WINDOW_SIZE`、居中、`CONFIRM_WAIT`、图片资源加载、`DELAY/WAIT`、`QUESTBUTTON/QUESTBUTTON_CLOSE`、 `ADDMAPSIGNAL/CLEARMAPSIGNAL/SETCMAPPOS`;仍未按参考端逐事件推进等待、分页……~~ 增量 95 把 `project/ui/quest_dialog.gd` 由单遍手写 `parse_script` 重写为 `CPythonEventManager` 1:1: `const EVENT_TYPE_MAP`(42 tag,键 = tag 字符串)+ `enum { EVT_LETTER .. EVT_SELECT_ITEM }` (顺序 = `REF/UserInterface/PythonEventManager.h:35` 的 `EEventType`)+ `_get_script_event_index()` (= `GetScriptEventIndex` `PythonEventManager.cpp:1093`:前导 `/` → `EVENT_POSITION_END` + 剥离, 未知名字 → 跳过不进 `match`)+ `class EventSet extends RefCounted`(= `SEventSet` `:98`,字段名 1:1:`iyLocal/isLock/lLastDelayTime/iCurrentLetter/CurrentColor/strCurrentLine/isConfirmWait/ iConfirmEndTime/lWaitingTime/iRestrictedCharacterCount=30/iAdjustLine/isTextCenterMode/isWaitFlag/ nAnswer` 等)+ `init_event_set()`(`__InitEventSet` `:72`)/ `insert_line()`(`__InsertLine` `:976`)/ `clear_line()`(`ClearLine` `:953`)+ `_process_event_set()`(= `ProcessEventSet` `:377` 的 `switch` 逐 `case` 对拷:`DELAY`/`COLOR`/`COLOR256` 的 START vs `/END`、`LETTER`/`ITEM`/`MOB` 的 `iCurrentLetter >= iRestrictedCharacterCount` 断行、`NEXT`/`DONE`/`INSERT_IMAGE` 的 `iAdjustLine += 2`、 `WAIT`、`QUESTION`(`MakeQuestion`)、image family、`SETMSGPOS`/`ADJMSGPOS` 空实现(`:562/:566`)、 `SET_CAMERA`(`GetCameraSettingFromArgList` `:48`)/`BLEND_CAMERA`/`RESTORE_CAMERA`、`FADE_*`/`WHITE_*`、 `DUNGEON_RESULT`、`CONFIRM_WAIT`/`END_CONFIRM_WAIT`、`RUN_CINEMA` → `pass`)+ `_tokenize()` / `_tag_kv()` / `_arg_s/_arg_i/_arg_f`(keyword 优先、位置回退)。`parse_script()` 返回字典保旧契约 全键不变,新增 `insert_images/dungeon_result/adjust_line/waiting_time/is_locked/wait_flag/ answer_count/cinema_file/end_confirm`;首命令 `RUN_CINEMA`(`RegisterEventSetFromString` `:142` 特例)→ `es.cinema_file` + 新 `signal cinema_requested`。裸网络文本折成合成 `LETTER` (`is_raw=true` → 跳过 30 字断行,参考脚本只有显式 `[LETTER]`)。自检 `project/quest_event_test.gd`(第 16 个 canonical,23 组断言),`quest_test` / `select_item_ui_test` 仍绿。**仍为 seam**:逐帧 `UpdateEventSet` `:312` 时间推进(`lLastDelayTime` 递减 / `isWaitFlag` 暂停 / `isConfirmWait` 倒计时刷新 `m_strLeftTimeString`)、`Skip` / `EndEventProcess`、 `iConfirmEndTime` 的 `timeGetTime`、`.msc` 事件文件加载(`RUN_CINEMA` 换文件)、`MakeNextButton` NEXT vs DONE 文案区分 + `DoneEvent()` 回调、`camera_event_requested` / `fade_event_requested` / `cinema_requested` 消费端与 §7.6 相机模式对接、物品名颜色 `(1.0,0.2,0.2)`。见 [`quest_dialog.gd`](/Users/shenlei/Work/mt/metin2-client/project/ui/quest_dialog.gd) / [`quest_event_test.gd`](/Users/shenlei/Work/mt/metin2-client/project/quest_event_test.gd)。 2. **任务日志已补 5 行窗口、滚动、默认图标、倒计时和独立 QuestButton 条带,但不是完整 Quest 系统。** 增量 101 按参考端 `QUEST_MAX_NUM=5` 固定显示 5 行;超过 5 条滚动到离散的 `questShowingStartIndex`,任务行点击发送 `-2147483648 + questIndex`,空 icon 使用 `season1/icon/scroll_open.tga`。QuestButton 的开始 / 删除 / 点击发送、小地图任务信号已接入; 仍缺多列定位、世界箭头、屏幕边缘指示、完整任务资源视觉和真实任务生命周期。 3. **钓鱼输入、水面位置判定和反馈消息已完成首版,但钓鱼、副本、坐骑和观战者仍不是完整玩法。** 增量 102 已接入 钓鱼模式攻击键 → `CG_FISHING`、方向 / 地面点击取消和 500ms 节流、特殊技能 123 快捷栏入口, 以及 `GC_FISHING` 五类事件的客户端状态维护;增量 103 已接 `GetFishingRot` 等价的 `ATTRIBUTE_WATER` 扫描和选中 heading;增量 104 已接 wrong-place / failure / unknown / notify / success locale 消息和 item proto 类型判定;增量 105 已接坐骑 vnum→level 攻击门和新坐骑技能门。 仍缺真实 `GC_ITEM_*` 鱼获事务 / 背包校验、副本入口 / 计时 / DungeonBlock、坐骑模型 / `MODE_HORSE*` 动作 / 移速、观战自由视角 / 完整 ADD/REMOVE/MOVE 生命周期和换图后的完整清理。 4. **PVP、PK 和决斗~~没有完整规则~~——可攻击判据已 1:1(增量 94)。** ~~当前有部分关系和字段, 但缺……可攻击判断……~~ 增量 94 新建 `project/entity_rules.gd`,`is_attackable_instance(self_e, victim_e, ctx)` 逐行照抄 `CInstanceBase::IsAttackableInstance`(`REF/UserInterface/InstanceBase.cpp:2147`)—— `IsStone` / `IsPC` / `IsEnemy` / `IsPoly` 四分支、决斗三态、`PK_MODE_GUILD` 同公会提前放行、 `victim.IsKiller()` && !同队、`PK_MODE_FREE/GUILD` 的 `!= PROTECT` && !同队分支、`IsSameEmpire` → `IsPVPInstance` / `PK_MODE_REVENGE` && 冲突善恶、异帝国直接可打、`victim.IsEnemy()` / `IsWoodenDoor()`; 附 `alignment_grade`(:502)/ `alignment_type`(:524)/ `is_conflict_alignment_instance`(:2116)/ `_is_same_empire`(:470)/ `_is_pvp_instance`(`InstanceBaseEffect.cpp:505`)/ `_find_duel_key`(:495)/ `_is_same_party`(`PythonPlayer.cpp:1445`)/ `_is_wooden_door`(:2390)全部 1:1。`net_play.gd` 的 `_is_attackable(e)` 从「`_entity_kind(e) in [0,2]`」桩改为拼 `self_e` / `victim_e` / `_attack_ctx()` (`get_duel` / `get_pvp_relations` / `get_guild_wars` / `get_party`)委托 `EntityRules`;自检 `project/entity_rules_test.gd`(第 15 个 canonical,25 组断言)。名字颜色(`ENameColor`,§8.8) 已由增量 110 的 `name_color.gd`(`GetNameColorIndex` / `SetEmpireNameMode` 1:1)+ `net_world` 驱动补齐;称号色(`GetTitleColor`)/ 头顶公会名 / 等级已由增量 111 的 `text_tail.gd` (`RefreshTextTail` / `UpdateTextTailLevel` / `EnablePKTitle` 1:1)+ `net_world` 三子标签补齐; 头顶聊天 / 信息尾标已由增量 112 的 `chat_tail.gd`(`RegisterChatTail` / `RegisterInfoTail` / `TextTail_GetLivingTime` / `IsChatTextTail` / `RecvChatPacket` 派发 1:1)+ `net_world` 每 vid 一条 `"bubble"` `Label3D`(5s 硬删、`"名字: "` 前缀剥离、SHOUT·COMMAND 排除)补齐。 头顶名字「是否显示」已由增量 113 的 `name_show.gd` + `net_world._name_shown()` 补齐 (`__IsShowName()` = `_always_show_name`(`IsAlwaysShowName`,`game_option_ui` `always_show_name` radio 经 `display_option_changed` 信号)`or` `_alt_held`(LALT);`ShowAllTextTail` 的 `fDistanceFromPlayer < 3500` 平面距裁剪;`net_play.target_changed` 目标 VID / chat 尾标 `bNameFlag` 强显绕过 3500 但仍过 `isShow · !IsGuildWall · CanPickInstance` 资格)。 头顶伤害飘字已由增量 114 的 `damage_effect.gd`(纯静态)+ `net_world._on_damage` / `_process_damage_queue()` 补齐:`RecvDamageInfoPacket`(`PythonNetworkStreamPhaseGame.cpp:2401`) 的 `bSelf`(目标是主实例)/ `bTarget`(目标是 `m_pInstTarget`)/ `damage >= 0` 门、 `CInstanceBase::AddDamageEffect`(`InstanceBaseEffect.cpp:87`)的 `CPythonSystem::IsShowDamage()` 门、`ProcessDamage`(:100,`InstanceBase.cpp:1910` 每帧调一次,`m_DamageQueue` 每帧 `pop_front` 一条)的分类(`(flag & DODGE) || (flag & BLOCK)` → `bSelf ? MISS : TARGETMISS` 并 return; `CRITICAL` / `POISON` 的 `CreateEffect` 参考端均注释掉 → 落普通数字;`bSelf` → `"damage_"` + `SELFDAMAGE` / `SELFDAMAGE2` 交替;`bTarget == false` → `"nontarget_"` + return(不渲染); 否则 `"target_"`;`while (damage > 0)` 逐位 `% 10` / `/= 10`,`index 0..7`,每位一张 `damagevalue/.dds` 按 `FONT_WIDTH(30) * index` 相机右向铺开)、 `CPythonSystem::SetShowDamageFlag`(`PythonSystem.cpp:342`,`bShowDamage = iFlag == 1`, 默认 `true`)全部 1:1;`game_option_ui`「伤害数字」radio 经 `display_option_changed` 接 `net_world.set_show_damage`,`net_world.clear_for_map_change` 清 `_damage_queue`。 自检 `project/damage_effect_test.gd`(第 25 个 canonical,~50 组断言)。 **seam ⑪**:本移植用一块 billboard `Label3D` 写 `str(amount)` / `"MISS"`,无 `damagevalue` dds 逐位特效 / 相机右向像素铺排;self `SELFDAMAGE`⇄`SELFDAMAGE2` 退化为落字水平抖动符号 交替(±0.3);暴击不再放大 / 变色(对齐该 40250 build 本就无独立暴击视觉);`_damage_queue` 全局单条 vs 参考每实例一条(同帧多 VID 受击分帧显示);`damage_number` 信号仍无条件发 (本移植扩展供战斗日志 / 测试)而参考端整个 `AddDamageEffect` 受 `IsShowDamage()` 门控。 **seam ⑩**:~~悬停 `chr.Pick()` 强显~~(增量 116 收口,见下)、~~屏幕空间 `ArrangeTextTail` 防重叠~~ (增量 120 收口,见下)、`CanPickInstance` 仅按死透近似;`ShowName()` 的 `playerGauge.EnableShowAlways()` / `SetQuickPage` 未移植。 头顶目标框(TargetBoard)收包分派已由增量 115 的 `target_board.gd`(纯静态)+ `net_play._on_target_info` 补齐:`CPythonNetworkStream::RecvTargetPacket` (`PythonNetworkStreamPhaseGame.cpp:2424`)的判定树 —— 实例缺失 → `CloseTargetBoard`(:2453); 目标 `!IsDead()` 时:`IsPC() || IsBuilding()` → `CloseTargetBoardIfDifferent`(:2440)、 否则 `pInstPlayer->CanViewTargetHP(*pInstTarget)`(`InstanceBase.cpp:2293` = `IsStone() || IsWoodenDoor() || IsEnemy()`)→ `SetHPTargetBoard`(:2442)、否则 `CloseTargetBoard`(:2444);`m_pInstTarget = pInstTarget` 仅在 `!IsDead()` 块内(:2447); 目标已死透 → 不动框。`game.py` 的 `SetHPTargetBoard`(:829,换 VID 先 `ResetTargetBoard` + `SetEnemyVID`)/ `CloseTargetBoardIfDifferent`(:837)/ `CloseTargetBoard`(:841)/ `SetPCTargetBoard` 的 `LCONTROL` 悄悄话捷径(:804)全部 1:1。`net_play._on_target_info(vid, hp%)` 由「任意 `vid != 0` 且有名字都 `hud.set_target`」改为按 `classify` 分派(`is_pc = _entity_kind == KIND_PC`、`can_view = KIND_STONE 或 KIND_MONSTER`):`CLOSE` / `CLOSE_IF_DIFFERENT` (`vid != _target_vid` 才)→ `_clear_target()`;`SET_HP` → 换 VID 先 `_target_vid = vid` + `target_changed.emit` 再 `hud.set_target(名字, clamp_hp_pct(hp%))`;`NONE` → 不动。 自检 `project/target_board_test.gd`(第 26 个 canonical,~35 组断言)。 **seam ⑫**:poc 无 building / wooden-door / stone-door 实体类型(`is_building` 恒 false、 木门分类缺,做出来后再补 `IsWoodenDoor` / `IsBuilding`);`target_info(vid, hp%)` 由 C++ `extension/src/net/m2_client.cpp:3129/3633` 从 EntityStore 抽出,本移植不改 C++ 收包 (对齐「纯 GDScript」约束);~~`LCONTROL` 悄悄话捷径谓词 `whisper_on_ctrl_click` 已备, `net_play` 暂不接(无 whisper dialog 钩子)~~ 增量 122 已接线:`net_play._on_pick` 在 `_entity_kind(e) == KIND_PC` 分支(C++ picking 只对 PC 派发 `SetPCTargetBoard`)调 `TargetBoard.whisper_on_ctrl_click(_lcontrol_down, EntityRules._is_same_empire(_main_entity(), e), vid == _main_vid, false)`,真且 `_entity_name(e) != ""` → `whisper_requested.emit(name)`; `net_play` 加 `signal whisper_requested(name)` + `var _lcontrol_down` + `set_lcontrol_down(down)`, `game_scene._unhandled_input` `KEY_CTRL` 按下 / 松开喂 `set_lcontrol_down(e.pressed)`, `net_play.whisper_requested` → `chat.start_whisper`(poc 版 `OpenWhisperDialog`=聊天输入 预填 `"/w 名 "`);`is_building` 实参恒 false(:814 分支 poc 永不触发)、无独立 whisper dialog 窗口 = seam ⑫ 末尾;`netplay_test.gd` 加「8c) seam ⑫」段(无新增测试文件,PASS=69)。 行为变化:此前对任意有名字 VID(含 NPC)都显 目标框;现 PC / building 目标不显血条框(只在框上是别的 VID 时清旧框)、NPC / warp 关框、 目标已死透不动框、`hp%` 钳到 0..100。 `game.py GameWindow.OnRender()` 的「Picking」块(:1519)分派已由增量 116 的 `pick_show.gd` (纯静态第 27 个 canonical)补齐:`textTail.UpdateAllTextTail()`(:1531)→ `wndMgr.IsPickedWindow(self.hWnd)` 门(:1533)内 `chr.Pick()`(:1535)命中角色(!= -1)→ `textTail.ShowCharacterTextTail(idx)`(:1537)、`targetBoard.GetTargetVID()` != 0 → `ShowCharacterTextTail(targetVID)`(:1539)、`not __IsShowName()` → `item.Pick()` → `ShowItemTextTail`(:1543/:1546);门外 `__IsShowName()` → `ShowAllTextTail()` + `PickingItemIndex = textTail.Pick(x, y)`(:1552/:1554);末尾 `UpdateShowingTextTail` + `ArrangeTextTail`(:1557/:1558)、`PickingItemIndex != -1` → `SelectItemName`(:1560)全部 1:1。 `pick_show.gd` API:`NO_PICK := -1`、`char_pick_active(is_picked_window)`、`forced_name_vids( is_picked_window, pick_char_vid, target_vid)`(收 `chr.Pick()` 命中 + 目标框 VID,去重、顺序 `[hover, target]`、非窗口 → `[]`)、`should_pick_item(is_picked_window, is_show_name)`= `and not`、 `should_show_all_names` / `should_pick_text_tail`= `is_show_name`、`should_select_item_name`= `!= NO_PICK`。poc 侧仿增量 113 的 `_target_vid` forced 路数给 `net_world._name_shown()` 加第 5 路 forced `_hover_vid`:`net_world.set_hover_vid(vid)`(值变才刷 prev / vid 两节点名字可见性,仿 `set_target_vid`)由 `player_controller._update_hover_cursor()` 三出口喂 —— 鼠标压 UI → 0、 悬停地面物品 → 0、射线命中 `pickable` → `best.get_meta("vid")`、无命中 → 0;新增 `hover_entity_changed(vid)` 信号(值变去抖),`game_scene` 紧挨 `net_play.target_changed → net_world.set_target_vid` 那行连 `pc.hover_entity_changed → net_world.set_hover_vid`。 自检 `project/pick_show_test.gd`(第 27 个 canonical,~24 组断言)+ 全 67 个离线回归通过。 **seam ⑩**:`ArrangeTextTail` 屏幕空间尾标去重叠排布仍缺(poc 名字牌是 3D billboard,靠深度 天然分层,未做 2D 投影后的挤开)—— 本增量只收口「悬停角色强显名字」一小块。近似:参考里 `chr.Pick()` 强显与 `targetBoard` 强显都在 :1533 的 `IsPickedWindow` 门内,poc 把 `_target_vid` / `_hover_vid` 都当无条件 forced,`_hover_vid` 靠 `player_controller` 压 UI / 悬停物品时置 0 部分等效该门。行为变化:此前只有 `targetBoard` 目标(增量 113)forced 强显头顶名字;现悬停到 的角色(`pickables` 带 `vid` meta)也 forced 强显 —— 即便未开「总显名字」且超 3500 平面距。 悬停命中沿用既有 `_ray_pick_t`,无新增射线开销。 `CPythonTextTail::UpdateShowingTextTail`(`PythonTextTail.cpp:88`)里「名字尾标随聊天气泡上移 17px」已由增量 117 的 `name_tail_layout.gd`(纯静态第 28 个 canonical)收口:角色名字尾标投影完 屏幕坐标后(:104),`m_ChatTailMap.find(dwVirtualID)` 命中(:106/:110)且那条 `bNameFlag` 为 TRUE(:112,= `RegisterChatTail` 的「聊天」气泡,非 `RegisterInfoTail` 的「信息」气泡)→ `pTextTail->y = pChatTail->y - 17.0f`(:114)。`name_follows_bubble(has_chat_tail, chat_name_flag)` = `and`、`name_tail_screen_y(default_y, bubble_y, follows)`= 参考端字面 `bubble_y − 17`、 `name_tail_world_y(default_world_y, bubble_world_y, follows, px_size)`= poc 3D 适配(屏幕 y 向下 −17px → 世界 y 向上 `bubble_world_y + 17*px_size`)。poc 侧 `net_world._reposition_name_tag(n)` 由 `_apply_chat_tail` / `_expire_chat_tails` 调用,读 `_chat_tails[vid].name_flag` 决定名字 `Label3D` 局部 y 在 `NAME_TAG_DEFAULT_Y`(2.1)与「`BUBBLE_TAG_Y` 2.35 + 17*pixel_size」之间 切换。自检 `project/name_tail_layout_test.gd`(第 28 个 canonical)+ `chat_test.gd` 扩断言 + 全 68 个离线回归通过。 **增量 119 收口 seam ⑦(子标签随名字尾标纵向位移)**:`CPythonTextTail::ArrangeTextTail` (`PythonTextTail.cpp:151`)逐条角色名字尾标(:211)把公会名(:228,`pTextTail->y - c_fyGuildNamePosition`,`c_fyGuildNamePosition = 15.0f` :21)/ 称号(:258,同 `pTextTail->y`)/ 等级(:283,同 y)三个子标签屏幕 y 全部锚在 `pTextTail->y` 上 —— 而这个 y 已被 `UpdateShowingTextTail :114` 顶过 −17。`name_tail_layout.gd` 增 `GUILD_NAME_ABOVE_NAME_PX := 15.0`、`name_tail_lift_delta(default_world_y, bubble_world_y, follows, px_size)`(= `name_tail_world_y(...) - default_world_y`)、`subtag_world_y(default_subtag_y, name_tail_lift_delta)`(=相加)。`net_world` 把 `GuildTag` / `TitleTag` / `LevelTag` 默认 局部 y 提为 `SUBTAG_DEFAULT_Y`(2.34 / 1.86 / 1.74)常量,`_reposition_name_tag` 顶完名字 尾标后按同一 `lift` 平移三个子标签,气泡过期 / 「信息」尾标时回默认。自检 `name_tail_layout_test.gd`(+「4. seam ⑦ 收口」段)+ `chat_test.gd`(+真 `NetWorld` 断言: 冒气泡后三子标签 `position.y` == 默认 + lift、覆盖成信息尾标后回默认)+ 全 68 个离线回归通过。 **seam ⑦ 剩余**:~~子标签竖排 vs 参考屏幕空间逐像素水平排版(公会名上方 15px、称号 / 等级 同水平行左偏)~~ 逐像素水平排版已由增量 121 收口(见下)、~~文本宽按字符数估 vs `GetTextSize`~~ 已由增量 123 收口(见下)、~~单行文本高仍 15px 估~~ 已由增量 124 换 `GetTextSize` 的 h 收口(见下),剩余:poc 无公会徽记 mark 实体、三子标签仍竖排叠 x 偏移 vs 参考同一屏幕行。 **seam ⑨ 剩余**:气泡与名字仍是两个并存 `Label3D` 节点(非参考单尾标链表)。 **增量 120 收口 seam ⑩(`ArrangeTextTail` item 尾标去重叠 + `isIn`)**:`PythonTextTail.cpp:153` 的 `m_ItemTextTailList` 块(:160-190)—— 逐条掉落名条 `pInsertTextTail` 与其它每条 `pCompareTextTail` 比 `isIn`(:489-507,`[x+xStart,x+xEnd]×[y+yStart,y+yEnd]` 的 AABB, `<=`/`>=` 含边界),命中就把「自己」顶到 `pCompareTextTail->y + pCompareTextTail->yEnd + yTemp` (`yTemp = 5`),`itorCompare` 回 `begin()` 从头重跑、`LimitCount >= 20` 封顶。新增第 29 个 canonical `project/text_tail_arrange.gd`(`extends RefCounted` 全静态):`PUSH_GAP := 5.0` / `MAX_ITERS := 20`、`box_start_x(w)`=`-w/2-2` / `box_end_x(w)`=`w/2+2` / `box_start_y()`=`-2` / `box_end_y(h)`=`h`(`PythonTextTail.cpp:810-813`)、`boxes_overlap(a, b)`(=`isIn`, `a`/`b`=`{x,y,w,h}`)、`arrange_item_tails(tails: Array) -> Array`(外层逐条 i、内层逐条 j 跳过自身、`boxes_overlap` 命中则 `ys[i] = ys[j] + box_end_y(h_j) + PUSH_GAP`、j 归零 limit++、 `limit >= MAX_ITERS` break;`pCompare` 用「已排布后」的 `ys[j]`,与参考端共用一张链表就地改写 一致)。poc 侧 `project/ui/ground_items.gd` 加 `_camera_getter` + 常量 `TAG_BASE_Y 0.75` / `TAG_CHAR_W_PX 7` / `TAG_LINE_H_PX 15`,`setup()` 尾部加可选 camera-getter 参数,`_process` 尾部 `_arrange_tags()`:按 vid 排序遍历掉落 → `cam.unproject_position` 各名条世界锚点 (背面 / 无相机跳过并回默认)→ 组 `{x, y, w = max(1, len*7), h = 15}` → `arrange_item_tails` → `position.y = TAG_BASE_Y - (arranged_y - y0) * pixel_size`,可见名条 < 2 直接回默认; `project/game_scene.gd` 把 `func() -> Camera3D: return cam` 传进 `ground_items.setup` 第 6 参。 自检 `project/text_tail_arrange_test.gd`(第 29 个 canonical:`box_*` 四边、`boxes_overlap` x/y 都叠 / 边界相接含边界 / x·y 分离、`arrange_item_tails` 单条 / 不叠 / 两条同位 → [121, 100] / 三条同位 → [121, 142, 100] 且两两不叠 / 40 条全同位不死循环)+ `p2b_test.gd`(+真 `Camera3D` + 真 `GroundItems`:同位两掉落名条投影后靠前一条按 `(15+5)*0.005 = 0.1` 下推、靠后留默认、 拉开距离回默认)+ 全 69 个离线回归通过(PASS=69 FAIL=0)。**seam ⑩ 剩余**:~~名条框按 「字符数 * 7px」估宽 vs 参考 `GetTextSize` 精确像素~~ 已由增量 123 换真实字体度量 `GetTextSize` 1:1(见下);~~单行文本高仍 15px 估~~ 已由增量 124 换 `GetTextSize` 的 h 1:1(见下);只对掉落 item 名条做(与参考 `m_ItemTextTailList` / `m_CharacterTextTailList` 分表一致,角色名字尾标不参与)。 **增量 121 深化 seam ⑦(子标签逐像素水平排版)**:`CPythonTextTail::ArrangeTextTail` 角色块 (`PythonTextTail.cpp:211-320` 非 EUROPE 分支)—— `fxAdd` = 0(无称号无等级,:236)/ 8 (有称号,:242)/ 4(仅等级无称号,:288);名字文本 CENTER 对齐画在 `x + fxAdd`(:320)、 称号 CENTER 对齐画在 `x − iNameWidth/2 − fxAdd`(:258)、等级 RIGHT 对齐画在有称号 `x − iNameWidth/2 − fxAdd − iTitleWidth`(:282)/ 仅等级 `x − iNameWidth/2 − fxAdd`(:312); 公会名 / 徽记仅当 `pMarkInstance && pGuildNameInstance` 才由 ArrangeTextTail 重定位(:220), `iImageHalfSize = markW/2 + c_fxMarkPosition(1.5)`(:224),公会名 `x + iImageHalfSize`(:228)、 徽记 `x − guildW/2 − iImageHalfSize`(:227)。`name_tail_layout.gd` 增 `NAME_X_ADD_TITLE := 8.0` / `NAME_X_ADD_LEVEL_ONLY := 4.0` / `MARK_X_PAD := 1.5` / `MARK_ABOVE_NAME_PX := 26.0` + 6 静态 方法 `name_x_shift(has_title, has_level)`(fxAdd 8/4/0)/ `title_x_offset(name_w)`(`−name_w/2−8`) / `level_x_offset(name_w, title_w, has_title)`(有称号再减 `title_w`)/ `guild_name_x_offset(mark_w)`(`mark_w/2+1.5`)/ `mark_x_offset(guild_w, mark_w)` / `subtag_world_x(px_offset, px_size)`(`px_offset * px_size` 线性、符号保留左负右正)。 `net_world` 加 `const SUBTAG_CHAR_W_PX := 7.0`(无字体度量 → 文本宽按「字符数 * 约 7px」估, 与 `ground_items` 的 `TAG_CHAR_W_PX` 同源)、`_apply_text_tail_to` 尾部调 `_layout_subtags_x(n, ttag, ltag)`:由 `.text != ""` 判 `has_title` / `has_level`,设名字 `Label3D` / `TitleTag` / `LevelTag` 的 `.position.x`(无则回 0),`GuildTag.position.x` 恒 0(poc 无公会 徽记实体、参考的 `pMarkInstance && pGuildNameInstance` 门恒不满足)。纵向位移(增量 117/119 写 `.position.y`)与横向排布(增量 121 写 `.position.x`)互不干扰。自检 `name_tail_layout_test.gd`(+「5. seam ⑦ 水平排版」段)+ `chat_test.gd`(+真 `NetWorld`: Charlie vid 3000 有公会 / 称号 / 等级 → 三子标签 x 偏移方向与精确值断言、去称号后名字回移 4)+ 全 69 个离线回归通过(PASS=69 FAIL=0,无新增测试文件)。**seam ⑦ 剩余**:poc 无公会徽记 mark 实体(`GuildTag` x 恒 0)、~~文本宽按字符数估 vs 参考 `GetTextSize` 精确像素~~ 已由增量 123 换真实字体度量 `GetTextSize` 1:1(见下)、三子标签仍竖排叠 x 偏移 vs 参考同一屏幕行 (poc 可读性折衷)、~~单行文本高仍 15px 估~~ 已由增量 124 换 `GetTextSize` 的 h 收口(见下)。 **增量 123 收口 seam ⑦ / ⑩(文本宽换真实字体度量 `GetTextSize`)**:`CPythonTextTail::ArrangeTextTail` 里 `iNameWidth` / `iTitleWidth` / `iLevelWidth` / `guildW`(`PythonTextTail.cpp:225` / `:233` / `:240` / `:267` / `:297`)与 item 尾标框 `xSize` / `ySize`(`:733` / `:748` / `:809`)全部取自 `pTextInstance->GetTextSize(&w, &h)` —— 字体的实际逐字形排版度量(含字距、非等宽),不是 「字符数 * 定宽」。新增第 30 个 canonical `project/text_metrics.gd`(`extends RefCounted` 全静态): `text_size(font, text, font_size)`(空 / null / `font_size<=0` → `Vector2.ZERO`,否则 `Font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)` = `GetTextSize` 1:1)、 `width_of(font, text, font_size, fallback_char_px := 7.0)`(有字体量得正宽 → 精确像素,否则 → `text.length() * fallback_char_px`)、`label_width(label: Label3D, fallback_char_px := 7.0)` (从节点自身取 `font ?? ThemeDB.fallback_font` 和 `font_size`)。`net_world._layout_subtags_x` 的 `name_w` / `title_w` 改走 `TextMetrics.label_width(label, SUBTAG_CHAR_W_PX)`(原 `字符数 * SUBTAG_CHAR_W_PX`)、`ground_items._arrange_tags` 的名条框 `w` 改走 `TextMetrics.label_width(tag, TAG_CHAR_W_PX)`(原 `text.length() * TAG_CHAR_W_PX`); `SUBTAG_CHAR_W_PX` / `TAG_CHAR_W_PX`(均 7.0)保留仅作无字体(headless / 未配主题)兜底; `name_tail_layout.gd` / `text_tail_arrange.gd` 收 `name_w` / `title_w` / 框 `w` 作入参,排布公式 (增量 117 / 119 / 120 / 121)不动。自检 `project/text_metrics_test.gd`(第 30 个 canonical:常量、 退化输入 → ZERO、真实度量随串长 / 字号单调、`width_of` 有字体 == `text_size.x`、无字体回退 `字符数 * px`、`label_width` 回落 `ThemeDB.fallback_font` / null → 0、`Label3D` 实测)+ 全 70 个 离线回归通过(PASS=70 FAIL=0,新增 `text_metrics_test`)。**seam ⑦ / ⑩ 剩余**:~~单行文本高仍按 15px 估(未走 `GetTextSize` 的 h)~~ 已由增量 124 收口(见下)、poc 无公会徽记 mark 实体、 三子标签竖排叠 x 偏移 vs 参考同一屏幕行、掉落名条为唯一 item 尾标来源。 **增量 124 末尾 seam ⑦ / ⑩(单行文本高换真实字体度量 `GetTextSize` 的 h)**:`ArrangeTextTail` 的 item 尾标去重叠把「自己」顶到 `pCompareTextTail->y + pCompareTextTail->yEnd + yTemp` (`PythonTextTail.cpp:182`),而 `yEnd = ySize`(`:751` / `:812`),`ySize` 与增量 123 换过的 `xSize` 同出 `pTextInstance->GetTextSize(&xSize, &ySize)`(`:749` / `:809`)—— 单行文本的 `ySize` ≈ 字体行高(ascent + descent),与字形无关,`Font.get_string_size(...).y` 是 1:1 对应物。 `text_metrics.gd`(仍第 30 个 canonical,扩非新增)加 `const FALLBACK_LINE_H_PX := 15.0` (=旧 `ground_items.TAG_LINE_H_PX`)、`height_of(font, text, font_size, fallback_line_px := 15.0)`(空串 → 0;量得正高 → `text_size(...).y`;否则 → 回退)、`label_height(label: Label3D, fallback_line_px := 15.0)`(`label.font ?? ThemeDB.fallback_font` + `label.font_size`,null → 0)。 `ground_items._arrange_tags` 的名条框 `h` 由硬编码 `TAG_LINE_H_PX` 改走 `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`;`TAG_LINE_H_PX`(15.0)保留仅作无字体 兜底。`text_tail_arrange.gd`(`box_end_y(h)` = `h`,对齐参考 `yEnd = ySize`)/ `name_tail_layout.gd` 收框高作入参、排布公式不动。自检 `text_metrics_test.gd`(+「5. height_of / label_height」段: `FALLBACK_LINE_H_PX = 15`、空串 → 0、无字体 / `font_size<=0` → 回退、有字体高为正且与串内容无关、 随字号单调、`height_of == text_size(...).y`、`label_height(null) → 0` / 空 `Label3D` → 0)+ 全 70 个 离线回归通过(PASS=70 FAIL=0,无新增测试文件)。**seam ⑦ 剩余**:poc 无公会徽记 mark 实体、 三子标签竖排叠 x 偏移 vs 参考同一屏幕行。**seam ⑩ 剩余**:~~掉落名条 owner 未拆成独立 text instance(参考端 `yEnd += yOwnerSize + 4`,`:733-736`,poc 仍按单行 `"name (owner)"` 处理)~~ 增量 125 已把去重叠框度量按 `SetItemTextTailOwner` 换 1:1(见下)、掉落名条为唯一 item 尾标来源。 **增量 125 末尾 seam ⑩(owner 后缀框度量:owner 独立度量 + `yEnd += yOwnerSize + 4`)**:参考 `CPythonTextTail::SetItemTextTailOwner(dwVID, c_szName)`(`PythonTextTail.cpp:706-737`)—— 带 owner 的掉落名条,owner 后缀挂成一个与名字分开的 `CGraphicTextInstance`(`SetTextPointer(ms_pFont)`, 文本 = `c_szName + strOwnership`,`strOwnership` 在 `IDS_POSSESSIVE_MORPHENE == ""` 时取字面 `"'s"`(`:722`);`SetColor(1.0f, 1.0f, 0.0f)` 黄、`HORIZONTAL_ALIGN_CENTER`),单独 `GetTextSize(&xOwnerSize, &yOwnerSize)`(`:733`),据此把去重叠框扩张:`yStart = -2.0f`(`:734`)、 `yEnd += float(yOwnerSize + 4)`(`:735`)、`xStart = fMIN(xStart, float(-xOwnerSize/2 - 1))` (`:736`)、`xEnd = fMAX(xEnd, float(xOwnerSize/2 + 1))`(`:737`);owner 为空则 `Delete(pOwnerTextInstance)` 并把框复位纯名字 `xStart=-xSize/2-2` / `xEnd=xSize/2+2` / `yEnd=ySize` (`:739-751`)。代入 poc 的 `{w, h}` 框(`box_start_x(w) = -w/2-2` / `box_end_x(w) = w/2+2` / `box_end_y(h) = h`):`h = name_h + owner_h + 4`;`box_end_x(w)` 要等于参考 `:737` 的 `fMAX(nameW/2+2, ownerW/2+1)` → `w = maxf(name_w, owner_w - 2.0)`(框 x 两边对称,故 `box_start_x` 自动 = 参考 `:736` 的 `fMIN`);`owner_w<=0` 或 `owner_h<=0`(无 owner)→ `{name_w, name_h}`, 与参考 else 分支一致。`text_tail_arrange.gd`(扩第 29 个 canonical)加纯静态 `item_box_with_owner(name_w, name_h, owner_w := 0.0, owner_h := 0.0) -> Dictionary`。 `text_metrics.gd`(扩第 30 个 canonical)加 `string_width_like(label: Label3D, text, fallback_char_px := 7.0)` / `string_height_like(label: Label3D, text, fallback_line_px := 15.0)` —— 用某 `Label3D` 的 `font ?? ThemeDB.fallback_font` + `font_size` 量任意子串(`label` 为 null → 宽 `text.length() * fallback_char_px` / 高 空串 0 否则 `fallback_line_px`;空串宽恒 0),对应 参考端「名字 instance」「owner instance」各自 `GetTextSize`。`ground_items._on_added` 新建 / 复用 drop 节点时都 `set_meta("item_name", _name_for(vnum))` + `set_meta("item_owner", owner)`; `_arrange_tags` 的去重叠框改为 `iname` = meta `item_name`、`owner_txt` = `item_owner + "'s"`(空则 `""`,`const OWNER_POSSESSIVE := "'s"`),`TextTailArrange.item_box_with_owner(string_width_like(tag, iname), string_height_like(tag, iname), string_width_like(tag, owner_txt), string_height_like(tag, owner_txt))` 合成 `{w, h}`;渲染仍是单个合并 `"name (owner)"` billboard `Label3D`(seam ⑩ 记录 在案的折中)。自检 `text_tail_arrange_test.gd`(+「5. item_box_with_owner」段:无 owner / `owner_w<=0` / `owner_h<=0` → 退化、有 owner → `h += ownerH + 4`、owner 更宽 → `w = ownerW-2` 使 `box_end_x == ownerW/2+1`、顶到带 owner 框底下 → push = `(name_h+owner_h+4) + PUSH_GAP`)+ `text_metrics_test.gd`(+「6. `string_*_like`」段:`label` null → 回退 / 空串 → 0 / 有字体量的是 给定子串而非 `label.text`)+ `p2b_test.gd`(`FakeClient.add_ground` 加可选 owner 入参 + 新增 `mount3/fc3/gi3` 段:301 无 owner 靠前、302 带 owner `"Alice"` 靠后同位 → 无字体度量下 302 框 `yEnd = 15+15+4 = 34`、靠前 301 被顶 `(34+5)*0.005 = 0.195`)+ 全 70 个离线回归通过(PASS=70 FAIL=0,无新增测试文件)。**seam ⑩ 剩余**:owner 未真正独立渲染成 CENTER 黄字(poc 仍单条 `"name (owner)"`)、`strOwnership` 只取了字面 `"'s"`(未接 `ApplicationStringTable` `IDS_POSSESSIVE_MORPHENE` 的本地化语素)、掉落名条为唯一 item 尾标来源。 `root/uitarget.py` `TargetBoard.Open(vid, name)`(:189-193)在整个 `RecvTargetPacket` 判定之前的 异阵营前置过滤(`if not constInfo.GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(): if not player.IsSameEmpire(vid): self.Hide(); return`,`constinfo.VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1` 默认)已由增量 118 补齐:`target_board.gd` 加纯静态 `hidden_as_other_empire(view_other_empire, same_empire)`= `not view_other_empire and not same_empire`(不按 `is_pc` 收窄 —— empire 0 的 怪 / NPC / 石头在 `EntityRules._is_same_empire` 里恒「同阵营」故永不被挡);`net_play` 加 `_view_other_empire_target`(默认 true)+ `set_view_other_empire_target(on)`,`_on_target_info` 在 `TargetBoard.classify(...)` 之前 `if exists and TargetBoard.hidden_as_other_empire( _view_other_empire_target, EntityRules._is_same_empire(_main_entity(), e)): _clear_target(); return`。同批把 §8.4「名字色」radio(`name_color_normal` / `name_color_empire`)接进 `game_scene._on_display_option` → `net_world.set_empire_name_mode(v == 1)`(算法增量 110 已 1:1)、 「看他国玩家目标框」radio → `net_play.set_view_other_empire_target(v == 1)`,`game_scene` 开局 两处 `game_option_ui.display_value(...) == 1` 回填;修 `game_option_ui.gd` 的 `target_board` 默认值 0 → 1(对齐 `VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1`)。§8.4 三个显示 radio + §8.6 LALT 至此全部接线完毕。自检 `target_board_test.gd`(+4 断言)/ `netplay_test.gd`(+8b 段)/ `system_menu_ui_test.gd`(+ name_color / target_board radio → `_display` + cfg + 信号)+ 全 68 个 离线回归通过。**seam ⑫(延续)**:poc 无 building / wooden-door 实体类型(`is_building` 恒 false)、~~`LCONTROL` 悄悄话捷径未接~~(增量 122 已接 whisper)。 **仍缺**:PK 模式上行(`/pkmode` 增量 38 已有)、保护区 `IsInSafe` 的 `ATTRIBUTE_BANPK` 地形属性、原版目标板视觉 / 决斗 UI;seam:`__FindPVPKey`(`GC_PVP`)/ `__FindGVGKey`(公会战对局)/ `IsGameMaster` / `IsBuilding`·`IsWall` race 白名单 / m2dev 后端 `IsKiller` 状态位的 packet setter 均未接。 ### 6. P1/P2:模型、动画和特效 1. **本地角色先显示胶囊占位体。** GameScene 初始化阶段先创建 Capsule,真实角色稍后加载, 会造成模型闪烁、相机目标高度变化、碰撞体和阴影重新挂接。见 [`game_scene.gd:136`](/Users/shenlei/Work/mt/metin2-client/project/game_scene.gd:136)、 [`game_scene.gd:811`](/Users/shenlei/Work/mt/metin2-client/project/game_scene.gd:811)。 2. **SpeedTree 仍是程序化代理。** 当前只嗅探 bark/composite 贴图,叶簇 UV 和枝干使用 proxy, 没有真实 `.spt` 几何、leaf table、LOD、impostor、风参数和原始阴影。见 [`tree_placeholder.cpp:190`](/Users/shenlei/Work/mt/metin2-client/extension/src/tree_placeholder.cpp:190)、 [`PARITY-GAP.md:77`](/Users/shenlei/Work/mt/metin2-client/docs/PARITY-GAP.md:77)。 3. **EffectLib 已从粒子近似推进到 `.mde` 网格子集。** `.mse` Particle 已映射到 `GPUParticles3D`,现在会读取首个 `TextureFiles` 的实际 `.dds`、位置、发射形状、方向 / 速度; 增量 64 又按参考 `EffectMesh.cpp` 读取 `.mde` 的 `EffectData` / `MDEData002`,展开索引三角形、 翻转 UV、播放几何帧,并接入 `.ifl` 贴图帧和 MeshElement 的混合 / billboard / alpha;增量 65 接入 SimpleLight 的范围 / 生命周期曲线和 TexAni 基础帧延迟 / 顺逆序;增量 66 接入统一的 MovingType 直接段 / 二次 Bezier 位置曲线,并让粒子、网格、Light 共用。仍缺 TexAni 随机起始帧 / 每粒子语义、完整 emitter advanced / cycle、完整时间轴、骨骼挂点和真实资源逐项验收。见 [`effect_player.gd`](/Users/shenlei/Work/mt/metin2-client/project/fx/effect_player.gd)、 [`mde.gd`](/Users/shenlei/Work/mt/metin2-client/project/fx/mde.gd)。 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/metin2-client/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/metin2-client/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/metin2-client/extension/src/metin2_world.cpp:380)、 [`terrain_splat.cpp:133`](/Users/shenlei/Work/mt/metin2-client/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` 已完成参考字段解析、静态云层、双方向光分层和 texture-mode 六面天空 / 云层组合首版, 但完整环境表现仍缺。** 当前已覆盖参考缺省值、`IsDensity`、`Filter`、`BTextureRenderMode`、 六面天空文件名、`Reserved`,并将真实云层贴图接入渐变天空的 `sky_cover` 或 texture-mode 的 有限云面 shader;云层渐变过渡、滤色实际合成、SpeedTree 精确风常量 / 随机风语义、lens flare、 日夜 / 天气插值、颜色曲线和原始 ambient/diffuse/specular 关系仍没有完整实现;树 proxy 只采用 参考的 `0.2/0.0` 缺省。 6. **相机不是固定基线机位。** 当前依赖脚本中的 FOV、yaw、pitch、距离和 AABB 射线,缺 `CameraProcedure` 等价模式、灵敏度持久化、平滑碰撞、树木遮挡淡出、目标 Actor 状态和可回放 的受控机位。见 [`PARITY-GAP.md:195`](/Users/shenlei/Work/mt/metin2-client/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. **帮助窗口已有首版,仍缺视觉等价验收。** `help_ui.gd` 已装载 `helpwindow.py` 的布局和帮助 文案,H 键及 ESC 菜单入口已接通;仍需 `.sub` 贴图、字体描边和真分辨率视觉验收。见 [`help_ui.gd`](/Users/shenlei/Work/mt/metin2-client/project/ui/help_ui.gd)。 4. **系统设置部分只是保存数值。** CPU/GPU tiling 不会改变实际渲染,BGM 更换按钮 disabled, 部分画质设置没有完整接入渲染侧;名字颜色本身已 1:1(增量 110)、头顶称号色 / 公会名 / 等级已 1:1(增量 111)、头顶聊天 / 信息气泡已 1:1(增量 112)、头顶名字「是否显示」/ 「总是显示名字」/ LALT 名字显示已 1:1(增量 113,`name_show.gd` + `net_world._name_shown()`)、 「伤害数字」radio 已接到渲染侧(增量 114,`damage_effect.gd` + `net_world.set_show_damage` ← `game_option_ui.display_option_changed("show_damage")`,`GC_DAMAGE_INFO` / `AddDamageEffect` / `ProcessDamage` 整合),但 game_option 的 「empire 名字颜色」开关尚未接到 `net_world.set_empire_name_mode(bool)`(默认关)、 `EnablePKTitle` 开关尚未接到 `net_world.set_pk_title_enabled(bool)`(默认开)、 `TextTail_SetLivingTime`(气泡停留秒数)尚无运行期钩子(默认 5000)。见 [`system_option_ui.gd:143`](/Users/shenlei/Work/mt/metin2-client/project/ui/system_option_ui.gd:143)。 5. **鼠标物品态和光标已有首版,复杂窗口语义仍不完整。** `mouse_controller.gd` 已提供鼠标跟随 物品、投放到背包 / 快捷栏、无效区域 / 右键 / ESC / 失焦取消;`cursor_manager.gd` 已提供 NORMAL / TALK / ATTACK / PICK / BUY / SELL / ITEM 状态,窗口键盘事件不会穿透到世界。仍缺 CJK / `.sub` 光标资源、商店 / 交易等复杂窗口的逐项 mousemodule 行为和真机体验验收。 6. **键位主入口已按参考端收敛,平台 / 上下文仍需验收。** `game_scene.gd` 已接数字 1–4 激活 本地槽 0–3、F1–F4 激活本地槽 4–7、Ctrl/Command+1–9 表情、LSHIFT 翻页、空格按住攻击、 Alt 显示名字、截图、帮助 / 坐骑 / 角色窗口 / 小地图 / 聊天 / 相机入口;剩余差距是窗口焦点 屏蔽、物品态和 macOS F1–F12 的系统 Fn / 功能键设置验收。键位重绑定、冲突检测及独立相机 灵敏度设置不是 ClientVS22 已有能力,不应伪装成参考端缺失项。 7. **CJK 输入不等价。** 当前使用 Godot `LineEdit`,没有 `PythonIME` 候选窗、composition、 候选确认、输入法切换、特殊文本框行为、复数/性别规则和自动字体切换。 8. **聊天和头顶信息不完整。** 缺 notice/big notice、经验和金币日志、私聊窗口生命周期、历史 滚动、频道过滤、InsultChecker、屏幕空间血条。 ~~empire 名字颜色 / PK 颜色~~ 增量 110 已 1:1(`name_color.gd` `GetNameColorIndex` / `SetEmpireNameMode`,`net_world` 事件 + ~2Hz 兜底重染 `Label3D.modulate`,帝国名色默认关); ~~称号色 `GetTitleColor` / 公会名 / 头顶等级~~ 增量 111 已 1:1(`text_tail.gd` `RefreshTextTail` / `UpdateTextTailLevel` / `EnablePKTitle`,`net_world` 驱动 `GuildTag` / `TitleTag` / `LevelTag`); ~~头顶聊天 / 信息气泡~~ 增量 112 已 1:1(`chat_tail.gd` `RegisterChatTail` / `RegisterInfoTail` / `TextTail_GetLivingTime` / `IsChatTextTail` + `RecvChatPacket` 派发,`net_world` 每 vid 一条 `"bubble"` `Label3D` + 5s 硬删 + `"名字: "` 前缀剥离 + SHOUT·COMMAND 排除 + `register_info_tail()`; seam ⑨:气泡与名字 `Label3D` 并存;~~名字随气泡下移 17px~~ 增量 117 收口、~~子标签随名字 尾标纵向位移~~ 增量 119 收口(`ArrangeTextTail` :211/:228/:258/:283 —— 三子标签屏幕 y 锚在 `pTextTail->y`;`name_tail_lift_delta` / `subtag_world_y` + `net_world` `SUBTAG_DEFAULT_Y` 常量 + `_reposition_name_tag` 按同一 `lift` 平移 `GuildTag` / `TitleTag` / `LevelTag`),见下; ~~剩余 seam ⑦:子标签竖排 vs 参考屏幕空间逐像素水平排版~~ 逐像素水平排版增量 121 收口,见下)。 ~~总是显示名字 / LALT 名字显示 / 目标强显~~ 增量 113 已 1:1(`name_show.gd` `IsAlwaysShowName` / `SetAlwaysShowNameFlag` / `__IsShowName` / `ShowAllTextTail` 的 3500 平面距裁剪 / `ShowCharacterTextTail` 资格 / `UpdateAllTextTail` 的 chat `bNameFlag` 强显;`net_world._name_shown()` 四路 + `game_option_ui` `display_option_changed` 信号 + `net_play.target_changed`;seam ⑩:~~悬停 `chr.Pick()` 强显~~ (增量 116 收口)/ ~~屏幕空间 `ArrangeTextTail` 防重叠~~(增量 120 收口)/ `CanPickInstance` 仅按死透近似未做)。 ~~`GC_DAMAGE_INFO` 伤害数字整合~~ 增量 114 已 1:1(`damage_effect.gd` `enum DamageFlag` / `RecvDamageInfoPacket` 的 `bSelf` / `bTarget` / `damage >= 0` 门 / `AddDamageEffect` 的 `IsShowDamage()` 门 / `ProcessDamage` 分类(`DODGE|BLOCK`→MISS、`CRITICAL`·`POISON` 参考端 注释掉走普通数字、`bSelf`→`"damage_"`、`!bTarget`→`"nontarget_"` 不渲染、否则 `"target_"`、 `while(damage>0)` 逐位 `%10` / `/10` index 0..7)/ `SetShowDamageFlag`;`net_world._on_damage` 照 `AddDamageEffect` 入 `_damage_queue`、`_process_damage_queue()` 每帧 pop 一条照 `ProcessDamage`、 `game_option_ui`「伤害数字」radio 经 `display_option_changed` 接 `net_world.set_show_damage`; seam ⑪:单 `Label3D` 写整串 vs `damagevalue` dds 逐位特效 / 相机右向像素铺排、self `SELFDAMAGE`⇄`SELFDAMAGE2` 退化为落字水平抖动符号交替、暴击不再放大 / 变色(对齐该 build)、 `_damage_queue` 全局单条 vs 参考每实例一条)。 ~~目标框(TargetBoard)收包分派~~ 增量 115 已 1:1(`target_board.gd` `Action { CLOSE, CLOSE_IF_DIFFERENT, SET_HP, NONE }`;`RecvTargetPacket`(`PythonNetworkStreamPhaseGame.cpp:2424`) 判定树 —— 实例缺失→`CloseTargetBoard`、目标已死→不动框、`IsPC()|IsBuilding()`→ `CloseTargetBoardIfDifferent`、`CanViewTargetHP`(`InstanceBase.cpp:2293` = `IsStone()|IsWoodenDoor()|IsEnemy()`)→`SetHPTargetBoard`、否则→`CloseTargetBoard`; `game.py` `SetHPTargetBoard` 换 VID 先 `ResetTargetBoard` + `SetEnemyVID`;`net_play._on_target_info` 由「任意有名字 VID 都显框」改为按判定树分派:PC / building 不显血条框(只在框上是别的 VID 时清旧框)、NPC / warp 关框、石头 / 敌对怪 `SetHPTargetBoard`、`hp%` 钳 0..100; seam ⑫:poc 无 building / wooden-door / stone-door 实体类型(`is_building` 恒 false、木门分类缺)、 `target_info` 由 C++ EntityStore 侧抽出不改 C++ 收包、~~`LCONTROL` 悄悄话捷径未接~~ 增量 122 已接(`net_play._on_pick` KIND_PC 分支轮询 `whisper_on_ctrl_click` → `whisper_requested` → `game_scene` `KEY_CTRL` 喂 `_lcontrol_down` + `whisper_requested` → `chat.start_whisper`; `is_building` 实参恒 false、无独立 whisper dialog 窗口 = seam ⑫ 末尾))。 ~~`game.py OnRender` Picking 块分派 + 悬停角色强显名字~~ 增量 116 已 1:1(`pick_show.gd` 纯静态第 27 个 canonical:`textTail.UpdateAllTextTail()`(:1531)→ `wndMgr.IsPickedWindow` 门 (:1533)内 `chr.Pick()` 命中角色 → `ShowCharacterTextTail`(:1537)、`targetBoard.GetTargetVID()` → `ShowCharacterTextTail`(:1539)、`not __IsShowName()` → `item.Pick()`(:1543);门外 `__IsShowName()` → `ShowAllTextTail()` + `textTail.Pick(x, y)`(:1552/:1554);末尾 `UpdateShowingTextTail` + `ArrangeTextTail`(:1557/:1558)、`PickingItemIndex != -1` → `SelectItemName`(:1560);`forced_name_vids` 收 `[hover, target]` 去重。poc 侧仿增量 113 的 `_target_vid` forced 路数给 `net_world._name_shown()` 加第 5 路 forced `_hover_vid`: `net_world.set_hover_vid` 由 `player_controller._update_hover_cursor()` 三出口喂 (压 UI / 悬停地面物品 → 0、射线命中 `pickable` → `vid` meta、无命中 → 0)+ `hover_entity_changed` 信号(值变去抖)+ `game_scene` 连线;seam ⑩:`ArrangeTextTail` 屏幕空间 去重叠本增量未做(只收口「悬停强显名字」一小块),已由增量 120 收口,见下)。 ~~名字尾标随聊天气泡上移 17px~~ 增量 117 已 1:1(`name_tail_layout.gd` 纯静态第 28 个 canonical: `CPythonTextTail::UpdateShowingTextTail`(`PythonTextTail.cpp:88`)里角色名字尾标投影完屏幕坐标后 (:104),`m_ChatTailMap.find(dwVirtualID)` 命中(:106/:110)且那条 `bNameFlag` 为 TRUE(:112, = `RegisterChatTail` 的「聊天」气泡,非 `RegisterInfoTail` 的「信息」气泡)→ `pTextTail->y = pChatTail->y - 17.0f`(:114)。`name_follows_bubble` / `name_tail_screen_y` (参考端字面 `bubble_y − 17`)/ `name_tail_world_y`(poc 3D:屏幕 y 向下 −17px → 世界 y 向上 `bubble_world_y + 17*px_size`);`net_world._reposition_name_tag(n)` 由 `_apply_chat_tail` / `_expire_chat_tails` 调用,名字 `Label3D` 局部 y 在 `NAME_TAG_DEFAULT_Y`(2.1)与「`BUBBLE_TAG_Y` 2.35 + 17*pixel_size」之间切换;seam ⑨ 收口 —— 剩余:气泡与名字仍是两个并存 `Label3D`)。 ~~公会名 / 称号 / 等级子标签随名字尾标纵向位移~~ 增量 119 已 1:1(`name_tail_layout.gd`: `CPythonTextTail::ArrangeTextTail`(`PythonTextTail.cpp:151`)逐条角色名字尾标(:211)把公会名 (:228,`pTextTail->y - c_fyGuildNamePosition` 15px)/ 称号(:258,同 `pTextTail->y`)/ 等级 (:283,同 y)三子标签屏幕 y 全锚在已被 `UpdateShowingTextTail :114` 顶过的 `pTextTail->y` 上; 新增 `GUILD_NAME_ABOVE_NAME_PX := 15.0`、`name_tail_lift_delta`(= `name_tail_world_y(...) - default_world_y`)、`subtag_world_y`(= `default_subtag_y + lift_delta`);`net_world` 把三 子标签默认局部 y 提为 `SUBTAG_DEFAULT_Y`(2.34 / 1.86 / 1.74)常量,`_reposition_name_tag` 顶完名字尾标后按同一 `lift` 平移 `GuildTag` / `TitleTag` / `LevelTag`,气泡过期 / 「信息」 尾标时回默认;`name_tail_layout_test.gd` + `chat_test.gd` 扩断言 + 全 68 个离线回归通过; ~~seam ⑦ 收口 —— 剩余:子标签竖排 vs 参考屏幕空间逐像素水平排版仍近似~~ 逐像素水平排版增量 121 收口,见下)。 ~~掉落物名条屏幕空间去重叠 `ArrangeTextTail` item 块 + `isIn`~~ 增量 120 已 1:1 (`text_tail_arrange.gd` 纯静态第 29 个 canonical:`CPythonTextTail::ArrangeTextTail` 的 `m_ItemTextTailList` 块(`PythonTextTail.cpp:153-190`)逐条 `pInsertTextTail`:`yTemp = 5` / `LimitCount` 上限 20,内层遍历全表跳过自己,`isIn(pInsert, pCompare)`(:489-507 标准 AABB, 框 `xStart=-xSize/2-2` / `yStart=-2` / `xEnd=xSize/2+2` / `yEnd=ySize`,含边界 `<=`/`>=`)命中则 `pInsert->y = pCompare->y + pCompare->yEnd + yTemp` 并把内层游标重置回表头、`++LimitCount`; `arrange_item_tails(tails)` 在共享 y 表上原地推、`boxes_overlap` = `isIn`;`ground_items.gd` `_arrange_tags()` 把每条掉落名条世界锚点经 `unproject_position` 投到屏幕、按字符数 `*7px` 估框、 跑 `arrange_item_tails` 得下推屏幕 Δy 再按 `Label3D.pixel_size` 折回世界局部 y,无相机 / 少于 2 条回默认;`game_scene` 给 `ground_items.setup` 传 `camera_getter`;`text_tail_arrange_test.gd` + `p2b_test.gd` 扩断言 + 全 69 个离线回归通过;seam ⑩ 收口 —— ~~剩余:名条框按字符数估宽 vs 参考 `GetTextSize` 精确像素~~ 名条框宽 / 单行文本高已分别由增量 123 / 124 换真实字体度量 `GetTextSize` 的 w / h 1:1(见下),~~剩余:掉落名条 owner 未拆成独立 text instance (`yEnd += yOwnerSize + 4`)~~ 带 owner 的掉落名条去重叠框已由增量 125 按 `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`:owner 独立 `GetTextSize` → `yEnd += float(yOwnerSize + 4)` / `xStart = fMIN(xStart, -xOwnerSize/2-1)` / `xEnd = fMAX(xEnd, xOwnerSize/2+1)`)换 1:1(`text_tail_arrange.item_box_with_owner` + `text_metrics.string_width_like` / `string_height_like` 把「名字」「owner + 所有格」两子串 分开度量再合成框),剩余:owner 未独立渲染成 CENTER 黄字 text instance、`strOwnership` 未接 `ApplicationStringTable` 的 `IDS_POSSESSIVE_MORPHENE`、只对掉落 item 名条不含角色名条)。 ~~公会名 / 称号 / 等级子标签逐像素水平排版 `ArrangeTextTail` 角色块~~ 增量 121 已 1:1 (`name_tail_layout.gd`:`CPythonTextTail::ArrangeTextTail`(`PythonTextTail.cpp:211-320` 非 EUROPE 分支)—— `fxAdd` = 0(无称号无等级 :236)/ 8(有称号 :242)/ 4(仅等级 :288),名字 CENTER `x + fxAdd`(:320)、称号 CENTER `x − iNameWidth/2 − fxAdd`(:258)、等级 RIGHT 有称号 `x − iNameWidth/2 − fxAdd − iTitleWidth`(:282)/ 仅等级 `x − iNameWidth/2 − fxAdd`(:312), 公会名 / 徽记仅 `pMarkInstance && pGuildNameInstance` 才重定位(:220,`iImageHalfSize = markW/2 + 1.5` :224,公会名 `x + iImageHalfSize` :228 / 徽记 `x − guildW/2 − iImageHalfSize` :227); 新增 `NAME_X_ADD_TITLE 8.0` / `NAME_X_ADD_LEVEL_ONLY 4.0` / `MARK_X_PAD 1.5` / `MARK_ABOVE_NAME_PX 26.0` + `name_x_shift` / `title_x_offset` / `level_x_offset` / `guild_name_x_offset` / `mark_x_offset` / `subtag_world_x`(`px_offset * px_size` 线性、 符号保留左负右正);`net_world` 加 `const SUBTAG_CHAR_W_PX := 7.0`(无字体度量 → 文本宽按 「字符数 * 7px」估)、`_apply_text_tail_to` 尾部 `_layout_subtags_x(n, ttag, ltag)` 设名字 / `TitleTag` / `LevelTag` 的 `.position.x`(无则回 0)、`GuildTag.position.x` 恒 0(poc 无公会 徽记实体);纵向(增量 117/119 写 `.position.y`)与横向(增量 121 写 `.position.x`)互不干扰; `name_tail_layout_test.gd` +「5. seam ⑦ 水平排版」段 + `chat_test.gd` 扩真 `NetWorld` 断言 + 全 69 个离线回归通过(无新增测试文件);seam ⑦ 收口 —— 剩余:poc 无公会徽记 mark 实体 (`GuildTag` x 恒 0)、~~文本宽字符数估 vs `GetTextSize`~~ 已由增量 123 换真实字体度量 `GetTextSize` 1:1(见下)、三子标签仍竖排叠 x 偏移 vs 参考同一屏幕行、~~单行文本高仍 15px 估~~ 已由增量 124 换 `GetTextSize` 的 h 1:1(见下))。 ~~名条框 / 子标签文本宽按「字符数 * 约 7px」估 vs 参考 `GetTextSize` 精确像素~~ 增量 123 已 1:1 (`text_metrics.gd` 纯静态第 30 个 canonical:`text_size(font, text, font_size)` = `GetTextSize` 1:1(`Font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)`,空 / null / `font_size<=0` → `Vector2.ZERO`)/ `width_of(...)`(有字体 → 精确像素、否则 → `字符数 * fallback_char_px`)/ `label_width(label: Label3D, ...)`(`font ?? ThemeDB.fallback_font`); `net_world._layout_subtags_x` 的 `name_w` / `title_w` 与 `ground_items._arrange_tags` 的名条框 `w` 改走 `TextMetrics.label_width`,`SUBTAG_CHAR_W_PX` / `TAG_CHAR_W_PX`(均 7.0)保留仅作无字体 兜底,`name_tail_layout.gd` / `text_tail_arrange.gd` 排布公式不动;`text_metrics_test.gd` 第 30 个 canonical + 全 70 个离线回归通过(PASS=70 FAIL=0);剩余:~~单行文本高仍 15px 估(未走 `GetTextSize` 的 h)~~ 已由增量 124 收口(见下)、poc 无公会徽记 mark 实体、掉落名条为唯一 item 尾标来源)。 ~~名条框 / 子标签单行文本高按硬编码 15px 估 vs 参考 `GetTextSize` 的 h~~ 增量 124 已 1:1 (`text_metrics.gd` 第 30 个 canonical 加 `const FALLBACK_LINE_H_PX := 15.0` + `height_of(font, text, font_size, fallback_line_px := 15.0)`(空串 → 0;量得正高 → `text_size(...).y`;否则 → 回退)+ `label_height(label: Label3D, fallback_line_px := 15.0)` (`label.font ?? ThemeDB.fallback_font` + `label.font_size`,null → 0);参考端 item 尾标框 `yEnd = ySize`(`PythonTextTail.cpp:751` / `:812`),`ySize` 与增量 123 换过的 `xSize` 同出 `pTextInstance->GetTextSize(&xSize, &ySize)`(`:749` / `:809`),单行文本的 `ySize` ≈ 字体行高 (ascent + descent),与字形无关,`Font.get_string_size(...).y` 是 1:1 对应物; `ground_items._arrange_tags` 的名条框 `h` 由硬编码 `TAG_LINE_H_PX` 改走 `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`,`TAG_LINE_H_PX`(15.0)保留仅作 无字体兜底;`text_tail_arrange.gd`(`box_end_y(h)` = `h`,对齐参考 `yEnd = ySize`)/ `name_tail_layout.gd` 收框高作入参、排布公式不动;`text_metrics_test.gd` 扩「5. height_of / label_height」段 + 全 70 个离线回归通过(PASS=70 FAIL=0,无新增测试文件);剩余:掉落名条 owner 未拆成独立 text instance(参考端 `yEnd += yOwnerSize + 4`,`:733-736`)、poc 无公会徽记 mark 实体、掉落名条为唯一 item 尾标来源)。 ~~目标框「看他国玩家」前置过滤 + §8.4 名字色 radio 接线~~ 增量 118 已 1:1(`root/uitarget.py` `TargetBoard.Open(vid, name)`(:189-193)在 `RecvTargetPacket` 判定之前:`if not constInfo.GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(): if not player.IsSameEmpire(vid): self.Hide(); return`,`constinfo.VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1` 默认;`target_board.gd` 加纯静态 `hidden_as_other_empire(view_other_empire, same_empire)`= `not view_other_empire and not same_empire`(不按 `is_pc` 收窄 —— empire 0 的怪 / NPC / 石头恒同阵营故永不被挡);`net_play` 加 `_view_other_empire_target`(默认 true)+ `set_view_other_empire_target(on)`,`_on_target_info` 在 `classify` 前 `if exists and hidden_as_other_empire(...): _clear_target(); return`。同批把 §8.4 「名字色」radio(`name_color_normal` / `name_color_empire`)接进 `game_scene._on_display_option` → `net_world.set_empire_name_mode(v == 1)`(算法增量 110 已 1:1)、「看他国玩家目标框」radio → `net_play.set_view_other_empire_target(v == 1)`,`game_scene` 开局两处 `display_value(...) == 1` 回填;修 `game_option_ui.gd` `target_board` 默认 0 → 1(对齐 `VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1`)。§8.4 三个显示 radio + §8.6 LALT 至此全部接线完毕;seam ⑫(延续):poc 无 building / wooden-door 实体类型(`is_building` 恒 false)、~~`LCONTROL` 悄悄话捷径未接~~(增量 122 已接: `net_play._on_pick` KIND_PC 分支 → `whisper_requested` → `game_scene` → `chat.start_whisper`))。 9. **任务导航和完整资源视觉仍未完成。** 增量 101 已接入默认 / 服务端 icon、5 行滚动、倒计时和 QuestButton 事件参数;仍缺世界箭头、屏幕边缘指示、多列定位、真实 `.sub` 状态图和服务端 `iStartTime` 生命周期。见 [`quest_log.gd`](/Users/shenlei/Work/mt/metin2-client/project/ui/quest_log.gd)。 ### 9. P1/P2:音频和应用生命周期 1. **地图 BGM 已有首版消费,仍缺真服 / 完整 MilesLib 验收。** `GC_MAIN_CHARACTER3_BGM` 和 `GC_MAIN_CHARACTER4_BGM_VOL` 已路由到 `bgm_changed`,`bgm_director.gd` 按服务端名称、 `musicInfo.fieldMusic` 回落和默认音量选曲;仍缺完整声音事件模型及真实 40250 地图验证。见 [`bgm_director.gd`](/Users/shenlei/Work/mt/metin2-client/project/bgm_director.gd)。 2. **后台 BGM 恢复已有首版,仍缺真机验收。** `AppLifecycle` 后台保存并静音音量,回前台恢复 音量和当前曲目状态,不再清空 `_bgm_name`;Android/iOS 前后台切换和系统回收 TCP 后的恢复 仍未在真机验证。见 [`app_lifecycle.gd`](/Users/shenlei/Work/mt/metin2-client/project/app_lifecycle.gd)。 3. **生命周期单一属主代码已完成,仍缺平台验收。** 生产入口由 `AppFlow` 持有唯一 `AppLifecycle`,`GameScene` 不再创建,`M2Client` 不再直接处理应用暂停 / 恢复通知;旧 `login.gd` 兼容启动器也保持自己的单一节点贯穿全流程。仍需真机验证暂停、恢复、内存告警和 断线重连。见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §9.3。 4. **MilesLib 事件模型已迁移可复用子集,完整事件数据仍未迁移。** `audio.gd` 已实现 ambience / character / 普通 3D 三类入口、监听器位置 / 方向、200/1000 缩放、参考音量曲线、 角色声音的 5000cm + 0.3 秒限频、实例停止和音乐限幅淡出;增量 63 已按 `MilesLib/Type.cpp` 解析 `.mss`,以 60 FPS 帧实例接入 PlayerView / MobView 的动作声音。增量 67 又按 `Area.cpp` / `MapType.cpp` 接入 `AreaAmbienceData` + `.pra`,并实现 LOOP / ONCE / STEP 和区域卸载停止; 增量 68/72 又按 `MapUtil.cpp` / `SkyBox.cpp` 补齐 `.msenv` 解析字段与参考缺省值,并将真实云层贴图接入 `ProceduralSkyMaterial.sky_cover` 或 texture-mode sky shader,未等价承载的参数保存在 `WorldEnvironment` 元数据。EffectLib 的 `.mde` 基础网格、SimpleLight 范围 / 生命周期和 MovingType 基础曲线已接入。仍缺 TexAni 随机 / 每粒子语义、SpeedTree、完整 `.msenv` 环境渲染 / 天气联动、完整 `UpdateSoundData/UpdateSoundInstance` 节点数据、全部武器 / 技能 / 怪物 / NPC 资源逐项验收和真实设备听感。参考:[`ClientVS22/MilesLib/SoundManager.h`](/Users/shenlei/Work/mt/40250/ClientVS22/source/MilesLib/SoundManager.h:1)。 ### 10. 发布和验证问题 1. **Mac App 自包含脚本已修复,但完整产物尚未验收。** `build-macos-client.sh` 现在把 `assets` 和 `bgm` 复制到 `Contents/Resources`,`MT_ASSETS` 只作为开发覆盖;仍需用完整导出产物跨机启动, 不能把脚本通过等同于 App 验收。 2. **双架构构建入口已补齐,当前产物仍未证明 Universal。** `build.sh` / `build-macos-client.sh` 支持 `MT_MAC_ARCHES=universal` 并在打包时用 `lipo` 拒绝单架构扩展;当前已检查的扩展仍为 arm64, Intel Mac 兼容性待实际构建与运行验收。 3. **签名入口已补齐,默认仍是 Ad-hoc。** 脚本支持 `CODESIGN_IDENTITY`,按嵌套扩展先于 App 的 顺序签名并启用 hardened runtime 选项;当前没有 Developer ID、notarization 或 stapled ticket 证据,不能当作正式分发包。 4. **Demo 默认账号密码已移除。** `AppFlow`、登录和选人页只从 `MT_ACCOUNT` / `MT_PASSWORD` 读取,自动登录还要求 `MT_AUTOLOGIN=1`;正式 PCK / 导出包仍应加入字符串扫描门禁。 5. **补丁器、Logo、Movie、Web、Discord 和反作弊策略没有形成发布决策。** 当前只是“能启动 并进游戏”的工程入口,不是完整的 ClientVS22 发布外壳。 6. **缺少真实验收门禁。** 当前已有单元测试、loopback 和部分单会话验证,但仍缺:完整 40250 真服矩阵、双账号交易/组队/PVP、断线重连、跨服换图、纯 pack 运行、Intel Mac、受控机位 截图和移动端真机性能验证。 ### 当前结论 当前版本应继续定义为:**可登录、可进入地图、具备部分玩法和表现的核心功能纵切**; 连接骨架和换图清理已经开始实现,但尚未完成真实双连接、换图和完整生命周期验收。 它还不能定义为:**ClientVS22 的跨平台等价客户端**。 当前已完成 G0 公共接口的首版冻结,正在按附录 C 的并行波 A 持续交付;增量 49–128 的完成项、 自动化证据和剩余项以本文顶部看板及 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md)“实施进度 / 进度看板 / C.5 批次记录”为准。W3 已完成远端 PC 真模型工厂、任务日志五行 / 滚动 / 事件和 钓鱼输入 / 水面扫描 / 反馈消息首版、坐骑攻击等级门、观战者小地图插值和副本目的地罗盘首版;W4 已完成环境格式解析、静态云层和 texture-mode 云层组合及 EffectLib emitter cycle 生命周期首版接入,但均不代表完整玩法 / 环境渲染完成。 后续仍需要完成以下闭环: 1. 完成 Classic 直连/Auth/Warp 的真实双连接验收,并补齐阶段清理和回登录后的完整生命周期; 2. 未知 VID、VID 重用、事件缓存和完整实体生命周期; 3. 真实 Actor 战斗状态机、动作命中时序和技能合法性; 4. 物品拖放、装备规则、Safebox、交易和快捷栏事务状态; 5. 所有资源调用统一切换到 `AssetSource`,并验证纯 pack 运行; 6. 以 `ClientVS22` 的 UI、EffectLib、SpeedTree、MilesLib 模块建立逐项验收; 7. 完成受控地图/机位/角色/时间/天气/分辨率契约,再进行截图签核; 8. 完成 Mac 自包含资源、双架构扩展、正式签名和 notarization。