Files
shenleiandClaude Opus 5 1933a5ceda fix(combat): 战斗逐项对齐 40250,修复移动 / 技能释放与武器挂点
- 连击类型由 combo_changed 驱动(SetComboSkillFlag,技能 122 等级)
- 命中判定改为 .msa 刀尖 Z 圆柱 vs .msm 防御球(hit_collision.gd 逐行移植)
- _register_hit 改为 map::insert 语义,修正命中上限
- 攻速同时缩放 GetAttackingElapsedTime 与攻击动作速率;按武器动作模式目录绑定攻击段
- 去掉本地连击超时,motion_bound 清命中表 / 连击段号
- __ProcessDataAttackSuccess:InsertDelay 硬直、physics_push.gd 击退 + GetBlendingPosition 同步、
  命中特效、__HitGood / __HitGreate / __HitStone 受击动作链与抖动(ui/hit_reaction.gd)
- ClassicSession::send_use_skill 不再夹带 CG_FLY_TARGETING
- mac 前进 / 后退方向与技能释放修复;武器按职业骨骼挂到手上
- 新增 hit_collision / physics_push / hit_view / net_world_push / weapon_attach 测试;
  gpu_pose_bounds / race_motion_assembly 适配 damage 随机变体
- 文档:CLIENT-GAP.md、CLIENT-GAP-FIX.md §3.5 / §3.7 与 C.5 增量 130

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-12 13:15:28 +09:00

3248 lines
394 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 可用 loopbackW2/W3 可用固定实体和包 fixture,W4 可用最小地图 / 资源,
W5 可用 debug 构建先验证流程;替身通过后仍必须在集成门禁替换为真实链路。
4. **G1:接口集成。** 每个工作流先通过自己的单元 / 回环 / GDScript 回归,再由 W0 合并共享接口,
执行 C++ 扩展构建、原生网络测试和受影响的 Godot 测试。
5. **G2:真实验收。** 连接、DirectEnter、Warp 换图、双账号玩法、Mac App 资源包、架构 / 签名和
视觉 / 输入体验必须按对应验收项逐条记录;受限环境下的 `skip` 只能标为“未验证”,不能标为通过。
6. **合并顺序不等于开发顺序。** 只要依赖接口已经冻结,W1–W5 可以同时开发;只有触及共享状态或
集成门禁时才回到 W0 排队。
### 当前并行进度(2026-09-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<StateCmd>` 命令队列驱动(对齐 `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 __LoadGame<Class>Ex` 分四职业块解析为 `map<MAKE_COMBO_KEY(motionMode,comboType), vector<段号>>`,语义对齐 `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<Msa::HitWindow>``{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 修改 2POC 无 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/1C++ 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` 留 0poc 无公会徽记),文本宽按 `字符数 * 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 绑定当前场景音频;增量 53EffectLib 已读取 `.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 验收 |
> **最新进展(增量 1272026-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 最新进展(增量 1282026-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、双架构和正式签名验收。
> **W2 最新进展(增量 1302026-09-12**:战斗逐项对齐 40250。
> - 命中判定改为 `.msa` 刀尖 Z 圆柱 vs `.msm` 防御球(`hit_collision.gd`)。
> - `__ProcessDataAttackSuccess` 补齐:`InsertDelay` 硬直、`physics_push.gd` 击退 + `GetBlendingPosition`
> 同步、命中特效,以及 `__HitGood` / `__HitGreate` / `__HitStone` 受击动作链与抖动
> `ui/hit_reaction.gd`)。
> - 攻速同时缩放命中时间与动作速率。
> - 连击类型改由 `combo_changed` 驱动;连击段号和命中表随 `motion_bound` 清零。
> - `send_use_skill` 不再夹带 `CG_FLY_TARGETING`。
> - 测试:新增 `hit_collision_test` / `physics_push_test` / `hit_view_test` / `net_world_push_test`
> 扩充 `combat_fx_test`。
> - seam:击退没有世界 / 角色碰撞;防御球未跟骨骼;远端角色不跑 AttackProcessowner 时间取客户端观测。
>
> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.5 / §3.7 / C.5 增量 130。
> **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+GetEmpireIDNPC→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 断言)+ 全量离线回归通过。无 seam40250 源含 `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`:9381: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 个离线回归通过。无 seam40250 源含 `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/<type><digit>.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` 目标(增量 113forced 强显
> 头顶名字;现悬停到的角色(`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=1Bobvid 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-736poc 仍按单行 `"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 增量 99W3→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 增量 100W4 / §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 增量 73W2 / §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 增量 74W2 / §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 增量 75W2 / §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 增量 76W2 / §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 增量 77W2 / §3.2 远端同步命令队列 —— C++ 核心 part A**`EntityStore`
> 把远端 Actor 位置改为命令队列驱动(对齐 `InstanceBase.cpp:1278 CInstanceBase::StateProcess`),
> 不引入预测回滚 / 抖动缓冲。`Entity` 新增 `std::deque<StateCmd> 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` 回归全绿。
> 留给增量 78net_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 增量 78W2 / §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 增量 79W2 / §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 增量 80W2 / §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 __LoadGame<Class>Ex`
> 分四个职业块(男女同函数体 → 表按职业收),解析那批调用为
> `map<MAKE_COMBO_KEY(motionMode, comboType), vector<段号>>`(段号 = `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`= N1)、裸整数。
> 新增 `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 增量 81W2 / §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 增量 82W2 / §3.5 `.msa` 多命中窗导出 —— part C,修改 1**`formats/msa.{h,cpp}`
> 把 `Group AttackingData` 按 `GameLib GameType.cpp NRaceData::LoadMotionAttackData` 的两种
> 写法解析成 `std::vector<Msa::HitWindow>`:老式(组内直接 `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 增量 83W2 / §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 留桩)。~~
> 增量 130 已换成 Z 圆柱 vs `.msm` 防御球的逐行判定。
> `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 增量 84W2 / §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 增量 85W2 / §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 增量 86W2 / §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 增量 87W2 / §3.8 TSkillData 判定位解析,修改 2**POC 无 C++ skill proto
> `extension/src/proto` 只有 item/mob),技能数据全在 GDScript `project/ui/skill_table.gd`
> 读 `locale/locale/<lang>/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<<type)``:1143`/ `is_need_bow` / `skill_type_of` /
> `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 增量 88W2 / §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` 打桩恒 falsePOC 无 `.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 增量 89W2 / §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 增量 90W2 / §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 <idx>` 不变。③ `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 增量 91W2 / §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 47gwangyeokFAN + Range 2500 + `target_count(lv1)=2` / `(lv20)=5`/ skill 91
> bipabuFAN + 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 增量 92W2 / §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 增量 93W2 / §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 增量 94W3→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`:2390vnum 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 个 canonical25 组断言);`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 增量 95W3→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 增量 96W3→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 增量 97W3→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<v`→`need=v``LIMIT_STR/DEX/INT/CON`:对应属性 `>=0 且 <v` 才判)。
> `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 增量 98W3→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 增量 101W3→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 增量 102W3→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 增量 103W3→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 增量 104W3→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 增量 105W3→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 增量 106W3→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 增量 107W3→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 增量 46W0/W1 公共基础第一批实现)**Classic 角色槽已保存 `lAddr/wPort`
> 新增 `M2Client.enter_game(index)` 并接入 ClientVS22 DirectEnter 的第二次游戏连接入口;
> 选人 / 登录 / 进场发送结果已透传,未知 header 不再 Loading 启发式重同步,已知未处理包不再
> 静默成功;已建立游戏连接后的断线回到 LOGIN。`net_classic_stream_test`、
> `net_classic_session_test`、`netbridge_test`、`p10_test` 已通过(受限环境下本地 TCP bind 子测试自动
> skip)。真实双连接 E2E、
> `MT_CLASSIC_ENTER_DELAY` 移除和版本包 `0xf1` 仍未完成。
> - **2026-09-02 增量 47W0/W2 公共基础第一批实现)**Classic `GC_WARP` 已改为严格按
> `lAddr:wPort` 重连,不再用 `addr==0` 推断“同服原地移动”;连接会保留 login key / 已选角色槽,
> 新增 `world_reset` 信号,并在 Godot 侧清理网络实体、地面掉落、PVP / 决斗、目标、交互窗和
> 地图局部状态后重建地图、HUD 小地图、Atlas、环境和玩家位置。`net_entity_test`、
> `net_classic_session_test`、`gamescene_test`、`p9_test`、`p2b_test`、`netbridge_test`、
> `p8_test` 已通过;受限环境下 localhost TCP 目标测试仍自动 skip。真实 40250 换图 / 第二次
> 连接 E2E、地图 BGM 选择和 `MT_CLASSIC_ENTER_DELAY` 仍待完成。
> - **2026-09-02 增量 50Phase 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` 67Btimestamp 固定 `"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 codepageCP949 / 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()` setterM2Client 已按
> `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 步骤 45`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 增量 50Phase 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(1420114204) / 平面距主角 <
> 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 增量 50Phase 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` 被解析成 `<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 增量 50Phase 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 增量 52W0-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 增量 53W3/W4/W5 / §4.8 §6.3 §8.6 §10)**:W3 将快捷栏改为参考端的服务器
> 36 槽 / 客户端 4 页 × 8 格,类型改为 `INVENTORY=1/SKILL=2/EMOTION=3`,表情槽调用
> `send_emoticon()`;补齐 LSHIFT 翻页、F1F4、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 增量 54W3 / §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 增量 55W4 / §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 增量 56W3 / §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 增量 60W1/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 增量 65W4 / §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 增量 66W4 / §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 增量 67W4 / §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 增量 72W4 / §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 增量 70W4 / §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 增量 69W4 / §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 增量 68W4 / §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 增量 64W4 / §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 增量 63W4 / §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 增量 62W3 / §4.3**:按 `uitooltip.py` 的技能书、遗忘书、变身道具分支,把
> `item_tooltip.gd` 的 `sockets[0]` 接到本地技能名 / 怪物原型名,并对限制类型 7/8/9 对应的
> 结束时间或持续时长生成
> 剩余分钟 / 秒提示;新增技能书、变身和限时提示断言,`item_tooltip_test` 通过。CJK 文案 /
> 字体切换、未覆盖的其他特殊分支、装备对比、真实资源和真服 / 真机仍待验收。
> - **2026-09-02 增量 61W3 / §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 增量 59W3 / §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 增量 58W1/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 增量 57W3 / §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 增量 51W0 / §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 增量 49W0 接口冻结批次)**:为 Phase 1 的 4 个并行工作流一次性落地跨工作流
> 共享文件的骨架,冻结接口后再拆分。`EntityStore``Entity` 补 `empire / affect_flags /
> owner_vid / state_flags` 字段;新增 `mut_spawn_full()`(§2.3 两包合并的单次落地入口)、
> `mut_ownership()`(§2.5)、`mut_map_bgm()` + `take_bgm_dirty()/bgm_name()/bgm_volume()`
> (§9.1)、`drain_dirty()`(§2.1/§2.5 裸字段更新的可见性重镜像);`mut_char_info()` 不再
> 丢弃 `empire``reset_for_map_change()` 一并清 `m_dirty`。`M2Client`:新增 `bgm_changed`
> 信号,classic / m2dev 两条 pump 回路都消费 `drain_dirty()` → `entity_info` 与
> `take_bgm_dirty()` → `bgm_changed``entity_dict()` 补 `empire / affect_flags /
> owner_vid / state_flags` 键。`ClassicParser`BGM 名 / 音量从
> `GC_MAIN_CHARACTER3_BGM` / `GC_MAIN_CHARACTER4_BGM_VOL` 路由到 `mut_map_bgm()`(此前直接
> 丢弃),新增 `m_pending_actor` 暂存表成员(W1 在 §2.2 填充两包合并逻辑)。Godot 侧新增
> `project/bgm_director.gd``M2Client.bgm_changed` → `Audio.play_bgm`,把地图 BGM 消费点
> 从 `net_world.gd` 拆出,避免 W2/W4 抢改同一文件),`game_scene.gd` 装配并 `bind()`
> `game_scene.gd` 键位按 §8.6 / §4.8 收敛:数字键 1–4 → 快捷栏 0–3、F1–F4 → 快捷栏 4–7、
> Ctrl+1..9 → `_emote()` 统一入口(表情包与本地表情动作由 W3 在 §8.6 补全)。
> C++ `ctest` 16/16、`netbridge_test` / `gamescene_test` / `p9_test` / `netplay_test` /
> `system_menu_ui_test` / `skill_test` / `combat_fx_test` / `skill_fx_test` /
> `player_motion_test` 全绿。BGM→曲目解析与音量应用、§2.2 合并逻辑、§8.6 剩余键位为 Phase 1
> 各工作流范围。
> - **2026-09-02 增量 48(并行开发协议)**:本文件与 `CLIENT-GAP-FIX.md` 改为“公共基础 +
> W1–W5 并行工作流 + 集成门禁”的执行格式,不再把附录 C 的章节顺序当成唯一开发顺序;明确
> 共享文件主维护者、接口冻结、测试替身、真实验收和冲突处理规则。新增硬性要求:每个批次在开始、
> 完成、阻塞或重新验证时,都必须把状态、改动文件、测试证据、风险和下一步同步回两份文档。
> - **2026-09-01 增量 45P8 交互边界与龙魂运行时范围)**:补齐 NPC 点击路由、Cube NPC
> 前置 / 配方材料 / 金币校验与制作状态、商店购买 / 出售数量和错误反馈、交换重复 / 满槽 /
> 金币边界、仓库 / 商城密码与满包回滚、个人摊位标准 Godot 拖放路径;缺失可选 `.sub` 贴图
> 安全降级。龙魂入口运行时禁用,公会和真实服务器验收继续按当前范围暂缓。
> - **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 增量 30EterGrnLib 对齐复核)**:查代码确认以下已落地,表内相关行同步。
> ① **材质 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` uniform0.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 增量 38ESC 系统菜单 + 游戏设置窗)**:
> ① **`system_menu_ui.gd`**`systemdialog.py` 真 uiscriptthinboard 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/EREPointTypes 索引取自 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`(0x0B200x0B22) 的 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 增量 25Atlas 大地图窗口)**:新增 `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.253.0 限幅),`netplay_test.gd` 覆盖 150 → 1.5×。
> - **2026-08-31 增量 29(攻击速度校准)**`GC_CHARACTER_ADD[2]` / `GC_CHARACTER_UPDATE`
> 的 `attack_speed` 已进入 `EntityStore` / `M2Client.entity_dict()`;本地主角的
> `POINT_ATT_SPEED` 也通过 `get_points()` 暴露。`NetPlay` 按 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+19 表情已按参考接入,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<StateCmd>` 驱动(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 节流窗)/ OnMoving300 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<MAKE_COMBO_KEY, vector<段号>>`,非 `.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)` / `_register_hit` / `_flush_victim_list` 逐帧按动作时间与 `hit_window` [start,end] 相交对 `client.get_entities()` 可攻击 / 未死 / 非安全区实体做判定,增量 130 起为 `_normal_attack_process` 的刀尖 Z 圆柱 vs `.msm` 防御球逐行判定,`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 段断言);命中判定与命中效果已逐行对齐(增量 130:`hit_collision.gd` `detect_z_cylinder` + `.msm` 防御球;`_process_attack_success``InsertDelay` 硬直、`physics_push.gd` 击退 + `GetBlendingPosition` 同步、命中特效、`__HitGood` / `__HitGreate` / `__HitStone` 受击动作链与抖动;攻速同时缩放 `GetAttackingElapsedTime` 与动作速率;`combo_changed``SetComboSkillFlag``motion_bound``m_HitDataMap` / 连击段号;`send_use_skill` 不再夹带 `CG_FLY_TARGETING`);剩余:击退无地形 / 角色碰撞,防御球未跟骨骼矩阵,远端角色不跑 AttackProcess;坐骑移速规则、`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 取消窗口、`IsResistFallen` / 击退碰撞、角色碰撞、同步碰撞、武器拖尾、剩余 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 <vnum>` 技能升级、公会战宣战应答弹窗(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=9698` 观战者状态、频道、时间、NPC marker、天气粒子和 mount_vnum 字段;真服登录洪流已收到并解析 `GC_LAND_LIST`(436B),单会话发送 `CG_FISHING`(5B) 后收到“Please choose a Fishing Pole.”业务拒绝且连接保持 | 补完整钓鱼 UI / 鱼获背包落地、副本计时 / 入口 UI、坐骑真模型 / 骑乘动作 / 移速、地图 BGM / 天气配置、地形贴花、DungeonBlock;换图首版已落地,仍需真实 40250 端到端验收 |
| P1 | 🟡 | 小地图 / Atlas | 可画玩家、实体、NPC、任务标记、观战者紫色菱形和 `GC_LAND_LIST` 领地边框;支持 `set_scale()` / 鼠标滚轮缩放;增量 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、数字 14、F1F4、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/28`/ `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,目标框 115OnRender 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:1seam ⑩ 剩余: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` headerEscape 取消通过 `CG_SCRIPT_ANSWER=29` 发送 sentinel `255`
当前实现和测试均覆盖该路径;选择窗的背包魔石网格、grade 过滤和选择回包已由
`select_item_ui_test.gd` 覆盖;仍缺原版输入校验、CJK IME 和有脚本时的真实任务流程验收。
- `CG_PARTY_USE_SKILL`(76 / `0x4C`)wire、发送接口和 loopback 字节测试已覆盖;真服单会话已发送
9B 且保持在线 / 无 unknown,但无队伍技能业务回包;仍缺冷却 / 权限、UI 入口和有效队伍真服
验收。
- `GC_FLY_TARGETING`(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`(9698 / `0x600x62`)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。
### M3UI 与表现 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<StateCmd>`
命令队列驱动,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 可见性。**增量 78part 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 平滑)。
**增量 79part 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<MAKE_COMBO_KEY(motionMode, comboType), vector<段号>>`,语义 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<Msa::HitWindow>``HitWindow{start_time, end_time, bone_name, weapon_length,
vector<HitSample{time, last_pos[3], pos[3]}>}`),同时吃老式内联(无 `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 碰撞一起留桩)。~~ **增量 130** 已换成
`HitCollision.detect_z_cylinder` 刀尖 Z 圆柱 vs `.msm` 防御球,并补全
`__ProcessDataAttackSuccess`:硬直、击退、命中特效、受击动作链、抖动。`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<v<255` 谓词,`M2Client::use_skill(int skill_id,
int target_vid)`(形参增量 52 起即带符号)的 `skill_id` 护栏从 `skill_id <= 0 ||
!bounds::u32()` 收紧成 `!mtnet::bounds::skill_index()`,对齐 `ClickSkillSlot`
`slot >= 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 <skillIndex>` 发包;~~当前缺少参考端的可升级条件门控(技能组、等级上限、技能点、
死亡 / 变身 / 骑乘等状态)、加号按钮显示规则、失败提示和升级后的技能状态刷新。~~
—— **增量 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→
ARROWRING→RING1 空则 RING2UNIQUE→UNIQUE1 空则 UNIQUE2COSTUME 按 sub_typeBELT 固定,
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 <old> <new>``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 投放以及数字 / F1F4 / 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 <name>` 接受 / `/nowar <name>` 拒绝);
成员页资金行(存 / 取包保留 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 个 canonical23 组断言),`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/<type><digit>.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/28`
/ `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` 恒 0poc 无公会
徽记实体、参考的 `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/P2UI、输入和本地化
1. **uiscript 可解析不等于控件语义完成。** 当前约 80 个脚本可解析,但 `ani_image``mark`
真实 grid/list/scroll、tooltip、item cursor、focus、anchor、modal、layer 和完整 nine-patch
语义仍缺。
2. **大量窗口是手工 Panel。** 交易、仓库、商店、公会、任务、好友、聊天和系统窗口仍大量使用
固定坐标和固定尺寸,没有完整复现原 `Interface.MakeInterface()``.sub` 资源、字体描边、
rect/anchor/z-index 和分辨率缩放。
3. **帮助窗口已有首版,仍缺视觉等价验收。** `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` 已接数字 14 激活
本地槽 0–3、F1F4 激活本地槽 47、Ctrl/Command+19 表情、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` 恒 0poc 无公会
徽记实体);纵向(增量 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。