# 客户端玩法开发计划 > 把 [`CLIENT-GAP.md`](./CLIENT-GAP.md) 的缺口排成可执行的阶段计划。 > 网络栈进度见 [`CLIENT-PORT.md`](./CLIENT-PORT.md),渲染见 [`PARITY-GAP.md`](./PARITY-GAP.md) / [`BACKLOG.md`](./BACKLOG.md)。 > > 规模:S ≤ 3 天 · M ≈ 1 周 · L ≈ 2–3 周 · XL ≈ 1 月+ > 标记:🌐 需服务器端到端 · 💻 离线可做(mock server / 合成封包 / 假 client) > 更新日期:2026-08-30 · 自用,无发布门禁 > **P0–P10 首版全部完成**;`192.168.21.203` 已联通,`net_e2e` 端到端到 PHASE_GAME。里程碑见文末。 ## 已就绪的地基(这些不重复做) - net 栈:握手 / auth / login / select / game 相位,`M2Client` 编排 + 生命周期 - `EntityStore`:实体模型 + 插值 + 战斗 / 状态封包(points / vitals / damage / motion / stun / dead / target) - `NetWorld`(`net_world.gd`):实体→场景桥,占位胶囊 + HP 条 + 伤害飘字 + 死亡倒地 - `M2Client` GDScript 面:`move/attack/set_target/say` + `get_entities/get_entity/get_points/get_target` + 12 个信号 - 渲染节点:`Metin2Model`(骨骼 + crossfade + 武器挂点 + LOD + 换肤)、`Metin2World`、`Metin2AnimPlayer` - 资产:`mtproto`(item/mob_proto)、`mtpack`(eterpack)、`locale.gd`、`audio.gd` ## 依赖关系 ```mermaid graph TD P0["P0 闭环骨架 💻"] --> P1["P1 UI 工具层 💻"] P0 --> P4["P4 战斗表现 🌐"] P1 --> P2["P2 物品/背包/装备 🌐"] P1 --> P3["P3 聊天 + TextTail 💻"] P1 --> P8["P8 社交/商店/仓库 🌐"] P5["P5 EffectLib 特效引擎 💻→🌐"] --> P4 P5 --> P6["P6 技能系统 🌐"] P2 --> P6 P1 --> P6 P1 --> P7["P7 任务/NPC 🌐"] P2 --> P9["P9 世界系统 🌐"] P0 --> P10["P10 网络补全 🌐"] ``` P1(UI 工具层)是 P2/P3/P6/P7/P8 的共同前置。P5(特效)可**任何时候并行起** (`.mse` 解析器纯离线)。 --- ## P0 — 闭环骨架(M,💻)—— ✅ 离线部分完成,待服务器端到端 **目标**:现有骨架变成「点怪→走过去→挥→掉血→切目标」的可玩手感,无新系统。 | 任务 | 规模 | 状态 | 落点 | |---|---|---|---| | 输入接线 | S | ✅ | `net_play.gd`:`pc.moved`→`M2Client.move(FUNC_MOVE)`(节流 >1m / >0.2s),`anim_state("wait")`→`move(FUNC_WAIT)`(一次),`pc.target_selected`→`set_target(vid)`(vid 取节点 `vid` meta) | | 本地预测 + 服务器校正 | S | ✅ | `net_play._on_net_moved`:收到自己 vid 的 `entity_moved` → 差 >6m 瞬移、>0.3m lerp 0.25,高度本地贴地不信服务器 z | | HUD 数据绑定 | S | ✅ | `hud.gd` 加 `set_vitals/set_exp/set_level/set_target/clear_target`;`net_play` 绑 `points_changed`/`vitals_changed`。血条 / 蓝条 / 经验细条 / 等级 label | | 目标血条 | S | ✅ | `hud._build_target_panel`(顶部居中,名字 + 180px 血条);`net_play` 绑 `target_info(vid,hp%)` + 目标 `vitals_changed` 刷新;目标死亡 / despawn → `clear_target` | | 攻击动作驱动 | S | 🟡 | `net_play`:进 2.5m 距离按 `attack_period`(0.6s) 自动 `attack(0, vid)` + 面向目标。真模型动作切换靠 `net_world` 的 `set_model_factory` + `set_anim_state("attack")`(P4 细化 motion index / 攻速) | | 坐标 / 朝向换算 | S | ✅ | Godot 米 ↔ 服务器 cm(`position_to_godot` 逆)、Godot yaw ↔ Metin2 罗盘度(`net_world` 逆)| | net_world 去重主角节点 | S | ✅ | `net_world.set_local_vid(vid)`:本地玩家由 `player_controller` 的节点代表,`net_world` 不再画一个 | | 场景组装 | S | ✅ | `game_scene.gd`(简版):`setup(client, assets_root, map_path?)` 装 `Metin2World`(load 失败则平地兜底)+ 占位玩家 + `GameCamera` + `PlayerController` + `NetWorld` + `HUD` + `NetPlay` + `Audio` + `AppLifecycle`;`entity_main_set` → 主角定位出生点。`login.gd` 的 `entered_game` → 隐藏登录层 + 实例化(`--auto` 自动选槽 0)。`set_player_model()` / `set_entity_model_factory()` 留给 P2 | **已验证**: - `netplay_test.gd` —— move 带正确 server cm + 节流、FUNC_WAIT 单发、set_target、攻击距离内自动 attack + 节流、points→HUD、远程校正瞬移、target_info→HUD、死亡清目标。 - `gamescene_test.gd` —— 装配 + 子系统接线 + 实体镜像(主角不重复画)+ 出生点定位(无地图兜底)。 - 项目导入无错,`netbridge_test` 仍过。 **未完成(仅剩服务器相关)**: - **端到端**:走真 `GC_MOVE`/`GC_PLAYER_POINTS`/`GC_TARGET_INFO` 需 `192.168.21.203` 可达。net 栈就绪,改 IP 即测。 - 攻击动作用真 `Metin2Model`(`set_player_model` / `model_factory` 把 race→gr2,P2 装备系统会带出来)。 - `login → charselect → game` 正式串场(现在 `--auto` 直接选槽 0;`charselect.gd` 已存在,接一下即可,属 P10)。 ## P1 — UI 工具层(L,💻)—— 🟡 核心完成(装载器打通全量语料) **目标**:一层 Godot `Control` 封装对齐 `EterPythonLib`,让 P2+ 的窗口可批量搬。 | 任务 | 规模 | 状态 | 落点 | |---|---|---|---| | **uiscript 装载器** | M | ✅ | `ui/uiscript.gd`:解析原始 `assets/uiscript/*.py` 的 `window={...}` dict(子集解释器:str/int/float、NAME、`uiScriptLocale.X`、算术 `+ - * /`、`%` 格式化、相邻字符串拼接、`SCREEN_WIDTH/HEIGHT` + 文件顶层常量、`#`/`##` 注释)。防跑飞 guard。**实测 80/80 个真实 uiscript 文件解析 + 构建全绿** | | 控件封装 | M | 🟡 | `ui/ui_build.gd`:dict → Control 树,覆盖 ~20 种 type(window/board/thinboard/board_with_titlebar/titlebar/text/button/toggle_button/radio_button/image/expanded_image/editline/line/listbox/gauge/grid_table/slotbar/sliderbar/scrollbar)+ `horizontal_align`/`vertical_align`/`text_color`(0xAARRGGBB)/按钮 default/over/down 贴图。`ui/ui_assets.gd` 解 `.sub`/`.tga`/`.png`/`.jpg`(`.dds` 暂不支持→降级主题)。九宫格复用 `ui_kit.board`。⬜ 少见 type(ani_image 动画、mark、grid_table 内容填充、真 scrollbar 行为)按窗口逐个细化 | | 窗口管理器 | S | ✅ | `ui/ui_manager.gd`(`CanvasLayer` layer=10):窗口栈、`open/close/close_top/top`、ESC 关顶层、modal 变暗层、标题栏(`titlebar` / `board_with_titlebar` / meta `is_titlebar`)拖动、点击置顶。`open_script(path, assets)` 一步解析+构建+开 | | 通用弹窗 | S | ✅ | `ui/dialogs.gd`:`confirm(text,on_ok,on_cancel)` / `alert(text)` / `input(prompt,on_submit,default)`,走 `UiManager.open(modal=true)` | | 拖拽层(物品图标跟光标 + 落点命中 slot) | S | ⬜ | 归 P2(背包才用到)| **已验证**:`ui_test.gd` —— 解析器(算术 710、hex 色、locale 解析、常量、坏输入→{})、 构建器(节点类型 / 尺寸 / 位置 / center 对齐数学 / board 标题)、窗口管理器(栈 / 置顶 / close_top / modal dim)、弹窗(confirm 打开 + modal + on_ok + 关闭)、真实文件 (`popupdialog.py` name/children/.sub 按钮 + 构建)。全过。全量语料 sweep 80/80。 ## P2 — 物品 / 背包 / 装备(L,🌐 解析+UI 💻)—— 🟢 主体完成(离线部分),端到端待服务器 **目标**:捡东西、开背包、穿脱装备(穿装备换模型 → 解锁 `PARITY §2.7` 高光 / `§2.3` 时装)。 | 任务 | 规模 | 状态 | 落点 | |---|---|---|---| | `GC_ITEM_*` 封包 + 模型 | S | ✅ | `wire.h`:`ItemPos`(3) / `ItemAttr`(3) + `GC_ITEM_SET`(54) / `DEL`(7) / `UPDATE`(41) / `USE`(19) / `GROUND_ADD`(24) / `GROUND_DEL`(8) / `GET`(75) + `CG_ITEM_MOVE`(11) / `USE`(7) / `DROP`(11) / `PICKUP`(8),全 `static_assert`。`EWindows` 常量。`EntityStore`:`m_inventory[90]` / `m_equipment[11]` / `m_ground` map + `apply()` 全部 7 包 + `drain_inv/drain_ground/drain_item_events`。`game_client.h` 路由 + `send_item_move/use/drop/pickup` | | `Metin2Proto` 节点 | S | ✅ | 新 GDExtension 类:`load_item_proto/load_mob_proto` → vnum→index map;`item(vnum)` / `mob(vnum)` → Dict(name / locale_name / type / sub_type / wear_flags / prices / specular …)。实测载入 5748 真实 item,`item(1)`=Yang、`item(19)`=Sword+9 | | `M2Client` 物品面 | S | ✅ | `get_inventory()` / `get_equipment()` / `get_item(win,cell)` / `get_ground_items()`(坐标转 Godot 米)+ `move_item/use_item/drop_item/pickup_item`;信号 `inventory_changed(win,cell)` / `ground_item_added(Dict)` / `ground_item_removed(vid)` / `item_picked_up(vnum,count,from)` / `item_used(vnum)` | | 背包 / 装备窗 | M | ✅ | `ui/inventory_ui.gd`:`ui_manager.open_script("inventorywindow.py")`(P1 装载器)→ 索引格子 → `M2Client.get_inventory/get_equipment` 填(icon `icon/item/.tga` 或名字文本 + 数量 + tooltip 用 `Metin2Proto` 名字)→ `inventory_changed` 刷新 → 右键 `use_item` / 拖到别格 `move_item`。`ui/ui_build.gd` 加 slot-cell 构建(`grid_table`/`slot`/`slotbar` 的 `start_index`/`x_count`/`y_count`/`x_step`/`y_step` 网格 + 显式 `"slot"` 元组)。接进 `game_scene.gd`(I 键开关)。UI 格子编号:0..89 背包 / 90+ 装备(wear=idx-90) | | item_list.txt 读取 | S | ✅ | `ui/item_list.gd`:解析 `locale/locale/common/item_list.txt`(TSV:vnum→type/icon/model)。用于背包图标(替代 vnum 猜路径)+ 武器模型解析 | | 本地玩家真模型 | M | ✅ | `ui/player_view.gd`(PlayerView,Node3D):`build(assets, race)` —— race 0..7 → class + 性别 → `pc(2)//_novice.gr2` 身体 + `hair/` + `Metin2AnimPlayer`。`set_anim_state("wait/walk/run/attack/dead")` → `general/.msa`(crossfade)。`_set` 转发 `weapon_gr2`/`gr2_path`/`hair_*` 给内部 `Metin2Model`。`game_scene._on_main_set` 首次拿到 race 时 `set_player_model(pv)` + 接 `pc.anim_state` | | 穿脱 → 换模型 | M | ✅ | `ui/equip_model.gd`:`inventory_changed(EQUIPMENT)` → `get_equipment()`:**WEAR_WEAPON(4)** → `item_list.model(vnum)` → `player.set("weapon_gr2", …)`;**WEAR_SHIELD(10)** → `shield_gr2`(`Metin2Model` 新增第二个刚体挂点,默认骨 `Bip01 L Hand` —— PC 骨架没 `equip_left_hand`,共用 `_load_attach` 抽出的加载器);**WEAR_BODY(0)** → `ui/race_spec.gd` 解 `.msm`(`ShapeData`/`HairData` 组 → model + SourceSkin/TargetSkin)→ `armor_shape_of(vnum)`(默认 =vnum,可覆写)→ `gr2_path` 换整模 + 换肤;**WEAR_HEAD(1)** 有模型的头盔 → 覆盖 `hair_gr2` 槽(Metin2 约定:头防替发型),拆下回 `parts[CHR_EQUIPPART_HAIR]` → `race_spec.hair(idx)`。头发也随 `entity_info`(GC_CHARACTER_UPDATE 改 parts)刷新。`equip_model_test`:武器 / 盾装拆、`armor_model_map` 注入、头盔覆盖 hair 槽 / 拆回 parts 发型;`race_spec_test`:真 `warrior.msm` 26 shape、shape 9 → `warrior_cheongrin.gr2` + 换肤 | | 地面掉落物 + 拾取 | S | ✅ | `ui/ground_items.gd`:`ground_item_added(Dict)` → 旋转小方块 + billboard 名条(`Metin2Proto` locale 名);`_process` 近距离名条变绿;`try_pickup()`(Z 键)→ 范围内最近的 → `pickup_item(vid)`;`ground_item_removed` → free。接进 `game_scene.gd` | | belt inventory / 快捷栏 | S | ⬜ | `beltinventorywindow.py` `taskbar.py` | **已验证**(8 个 GDScript 测试套件全绿 + `ctest 9/9` + uiscript sweep 80/80 + iOS 编过): `netbridge_test`(物品面 + `Metin2Proto` 5748 item)· `inventory_ui_test`(真 `inventorywindow.py` 开窗 / 填充 / tooltip / 刷新 / use / move)· `equip_model_test`(`item_list.txt` + 武器 vnum→真实 gr2)· `p2b_test`(地面掉落 spawn/名条/拾取 + PlayerView 真 warrior 模型 + `set_anim_state`)· `race_spec_test`(`warrior.msm` 解析 26 shape + 装 shape 9→真实 cheongrin.gr2 + 换肤)。 **未完成**:belt / 快捷栏、`.epk` 内 icon(需 `AssetSource` → GDScript)、 拖动图标跟手视觉、`armor_shape_of` 用真 `item_proto` value[3](现 =vnum)、 **端到端**(真服务器 `192.168.21.203`)。 ## P3 — 聊天 + TextTail(M,💻)—— 🟢 主体完成(离线部分) **目标**:完整聊天窗 + 头顶信息。 | 任务 | 规模 | 状态 | 落点 | |---|---|---|---| | `GC_WHISPER` / `CG_WHISPER` | S | ✅ | `wire.h`:`GCWhisper`(70) / `CGWhisper`(69) + `EChatType` / `WHISPER_TYPE_*` 常量,`static_assert`。`EntityStore::apply(GC_WHISPER)` → `ChatMsg{type=WHISPER, from, sub, text}`(`ChatMsg` 加 `from`/`sub` 字段)。`game_client.h` 路由 + `send_whisper(name,text)`。`M2Client`:`whisper(to,text)` 方法 + `whisper_received(sub,from,text)` 信号(与方法不同名,避 GDScript 冲突)| | 聊天窗 | M | ✅ | `ui/chat_ui.gd`:底部面板,4 标签(全部 / 私聊 / 系统 / 战斗)+ `RichTextLabel` 滚动 + `LineEdit` 输入。前缀 `/w 名 话`→`whisper` / `/g`→GUILD / `/p`→PARTY / `/s`→SHOUT / 其它→TALKING。消费 `chat`(按 type 分标签,名字取 `get_entity(vid).name`)/ `whisper_received` / `item_picked_up`。每类型配色(bbcode)。接进 `game_scene.gd`(Enter 聚焦输入) | | 聊天气泡 | S | ✅ | `net_world.gd._bubble`:普通 / 队伍 / 公会 / 喊话 → 实体头顶 `Label3D`(3s + 1s 淡出);本地玩家 vid → `main_bubble` 信号 → `game_scene._player_bubble` 挂 `player` 头顶 | | 头顶信息完善 | S | 🟡 | 名字 + HP 条 + 伤害飘字已有(P2);⬜ 称号 / 公会名 / 目标高亮框 | | 系统消息 / 战斗日志 | S | 🟡 | 拾取 → 系统 + 战斗标签;⬜ `GC_DAMAGE_INFO` / 经验 / 金钱变化的文本行(signal 已到位,接一下即可) | **已验证**:`net.entity_store` 扩展(whisper 包解析);`chat_test.gd`(4 标签路由:talking→全部带名 / info→系统+全部 / whisper→私聊+全部 / 系统 whisper→`[系统]` / 拾取→系统+战斗;前缀解析 `/w` `/g` `/s` + 本地回显;气泡:实体头顶节点 + 本地 vid→`main_bubble`)。9 个 GDScript 测试套件全绿,ctest 9/9,iOS 编过。 ## P4 — 战斗表现完善(L,🌐 表现 💻)—— 🟡 大部分完成(离线部分) **目标**:战斗「有肉感」——拖尾、硬直、连段、状态、死亡复活、镜头。 | 任务 | 规模 | 状态 | 落点 | |---|---|---|---| | affect 封包 + 图标条 | M | ✅ | `wire.h`:`AffectElement`(21) / `GCAffectAdd`(25) / `GCAffectRemove`(9),`static_assert`。`EntityStore`:`m_affects` map + `drain_affects()` + `affects()`。`M2Client`:`get_affects()` + `affect_added(Dict)` / `affect_removed(type)` 信号。`hud.set_affects(array)` —— 顶部图标条(色块 by type + tooltip 显 type/point/value/duration)。接进 `game_scene` | | 受击硬直 / 锁输入 | S | ✅ | `net_play._on_damage`:自己是 victim 且非 `DAMAGE_DODGE` → `_hitstun_until = now + 0.32s`;`is_stunned()` 期间 `_process` 不发攻击 + `pc.frozen = true`(`player_controller._process` 见 frozen 即 `anim_state("wait")` 早退)+ `player_view.set_anim_state("damage")` | | combo 连段 | S | ✅ | `net_play._process` 攻击:`COMBO_WINDOW`(0.9s) 内连续攻击 → `_combo` 0→1→2 循环,`attack(attack_motion + _combo, vid)` + `set_anim_state("combo"/"attack")` | | 相机抖动 | S | ✅ | `game_camera.shake(strength, decay)` —— `_process` 里加随机偏移,指数衰减。`net_play._on_damage` 命中 0.04m / 暴击 0.08m | | 死亡 / 复活流程 | S | ✅ | `ui/death_ui.gd`:`entity_dead(main)` 或 `phase_changed("dead")` → 灰屏 + 窗(在此复活 / 回城复活 → `say(0, "/restart_here"/"/restart_town")`,同客户端走 quest 命令);`vitals_changed` hp>0 → 自动关。接进 `game_scene` | | 相机锁定目标视角 | S | ⬜ | `game_camera` 锁定 target 时 yaw 跟随;低优先 | | 武器挥砍拖尾 | S | ⬜ | 需 `Metin2Model` 暴露每帧武器骨骼世界变换 → `ImmediateMesh` 条带。纯视觉,延后 | **已验证**:`net.entity_store` 扩展(affect add/remove);`combat_fx_test.gd`(受击→硬直+抖屏+damage 动作,闪避不硬直,他人受击不影响;combo 0→1→2 循环;`hud.set_affects`;死亡窗弹出 + 在此复活→`say("/restart_here")` + 复活自动关)。10 个 GDScript 测试套件全绿,ctest 9/9,iOS 编过。 ## P5 — EffectLib 特效引擎(XL,💻 起 → 🌐 用)⏭ 独立立项 —— 🟡 首版打通 **目标**:`.mse` 特效脚本 → Godot。见 `PARITY-GAP.md §1.2`。 | 任务 | 规模 | 状态 | 落点 | |---|---|---|---| | `.mse` 解析器 | M | ✅ | `fx/mse.gd`:CTextFileLoader 花括号树 + `List` 行表解析。`parse_file` → `{bsphere_r, bsphere_pos, particles:[{start_time, position, emitter, particle}], meshes:[...]}`。**实测真 `geompung_3_sword.mse` → 6 particle**。`.mse` 全资产集只有 3 种 group:Particle(514)/Mesh(22)/MeshElement,无 SimpleLight | | Particle → GPUParticles3D | M | ✅ | `fx/effect_player.gd`:每 Particle 组 → `GPUParticles3D` + `ParticleProcessMaterial`。映射:`EmitterShape`(0点/3球+`EmittingRadius`)、`MaxEmissionCount`→amount、`TimeEventLifeTime`→lifetime、`TimeEventEmittingVelocity`→速度、`TimeEventGravity`→重力、`TimeEventSizeX`→scale、`TimeEventScaleX`→`scale_curve`(Curve)、`TimeEventColorRGB`+`TimeEventAlpha`→`color`+`color_ramp`(Gradient)、`RotationSpeed`→angular_velocity。绘制:`QuadMesh` + `StandardMaterial3D`(`BillboardType`→billboard_mode、`Src/DestBlendType`→blend_mode(dst=2→ADD)、程序化径向渐变代 `.dds`)| | EffectMesh / Light / 时间轴 | M | 🟡 | 时间轴:每组按 `StartTime` 延迟 `create_timer` 起 emitting;`one_shot` 到寿命自动 `queue_free`。Mesh 组占位小方块(`.mde` 解码器待做)。Light:本资产集未用 | | 挂点系统 | S | 🟡 | `spawn(name, parent_node)` 挂到任意节点下(跟随变换);`spawn_at(name, world, pos)` 世界定点。绑骨骼需 `Metin2Model` 暴露骨骼节点,待做 | | 特效索引 + 服务器触发 | S | ✅ | `fx/effect_registry.gd`:名字 / 相对路径 / 盘符路径 → `.mse`(散包扫 + 裸名递归找 depth 7)+ 解析缓存。`wire.h` `GC_SPECIAL_EFFECT`(9) / `GC_SPECIFIC_EFFECT`(136) + `EntityStore::drain_effect_cues()` + `M2Client.effect_cue(vid, name, special)` 信号 → `game_scene` 在实体上 `fx.spawn(name)` | **已验证**:`net.entity_store` 扩展(specific/special effect cue 解析);`fx_test.gd`(`.mse` 解析:bsphere / 2 particle / List 行表 / 引号枚举 / 嵌套 group;EffectPlayer:2 GPUParticles3D + amount + 球形发射 + 加法混合 + start_time meta + play()→emitting;registry:解析真 `.mse` 6 particle + spawn 挂载)。11 个 GDScript 测试套件全绿,ctest 9/9,iOS 编过。 **未完成**:`.dds` 粒子纹理(GDScript 不解 DDS,需暴露 `Metin2Image.load_dds` 静态方法)、`.mde` mesh 解码、SimpleLight(本资产集未用)、绑骨骼挂点、`GC_SPECIAL_EFFECT` 的 id→路径表、时间轴的 `MovingType` 位置动画、TexAni 帧动画。 ## P6 — 技能系统(L,🌐)—— 🟢 主体完成(离线部分) 依赖 P1(技能窗)+ P2(技能书物品)+ P5(技能特效)。 | 任务 | 规模 | 状态 | 落点 | |---|---|---|---| | 技能表 | S | ✅ | `ui/skill_table.gd`:解析 `skilldesc.txt`(**列偏移修正** —— 0-idx:id0 job1 name2-4 desc5-8 (9空) **attrs10** weapon11 **motion_name12** **motion_idx13** grades14;旧代码整体差 1 列,`motion_idx` 一直是 0)。`entry`/`name_of`/`motion_idx_of`/`is_attack`/`is_passive`/`is_toggle`/`can_level_up`/`category_of`/`for_category`(`for_job` = 别名)。JOB 列即分类:WARRIOR/ASSASSIN/SURA/SHAMAN(主动) · SUPPORT(辅助/被动) · HORSE · GUILD | | 技能封包 | S | ✅ | `wire.h`:`GCSkillLevel`(259,flat u8[255]) / `GCSkillCooltimeEnd`(5) / `GCCreateFly`(13),`static_assert`。`EntityStore`:`m_skills[255]` + `skills_dirty()` + `drain_cooldown_ends()` + `drain_fly_cues()`。`M2Client`:`get_skills()`([{id,level}])+ `skills_changed` / `skill_cooldown_end(skill)` / `fly_cue(type,start,end)` 信号 | | 技能窗 + 加点 | M | ✅ | `ui/skill_ui.gd`(K 键):**三个分类页** `[主动][辅助][坐骑]`(→ `for_category(job/SUPPORT/HORSE)`)。每行 = 名 + `Lv X`(+ `M`/`G`/`P` 按 `master_type`) + `[被动]`/`[切换]` 标签 + `[+]`(`is_passive` 或 `CANNOT_LEVEL_UP` 时无 +)。`+` → `M2Client.skill_up(id)`。点名 → `drag_skill_id`。`skills_changed` 刷新等级 + master | | 快捷栏施放 + 冷却 | S | ✅ | `ui/quickbar.gd`(数字键 1-8):`assign(slot, "skill"/"item", id)`。技能 → `M2Client.cast_skill(motion_idx, heading, x, y)` + 本地 2s 冷却占位;`GC_SKILL_COOLTIME_END` 提前解锁。冷却遮罩自上而下扫。`restore_from_server()` 从 `get_quickslots()` 恢复。施放时 emit `skill_activated(id)` → `game_scene` 播技能特效 | | 技能特效表 | S | ✅ | `fx/skill_fx.gd`:`skill_effect_name(id, master)` = `_`(`PC/ymir work/pc//effect/*.mse`,`_blow`/`_head`/… 子特效存在就一起播);`GC_SPECIAL_EFFECT` 内建 id → `SPECIAL_FX` 表(`drugup_red/blue/green`、`firecracker_1`、`fail` …)。`game_scene` 接 `effect_cue` 的 `special` 分支 + `quickbar.skill_activated` | | 飞行道具 | S | 🟡 | `net_world._on_fly`:`GC_CREATE_FLY` → 从 start 实体飞向 end 实体的小发光球(tween,按距离算时长)。⬜ 真弹道 mesh / 命中特效 / `GC_FLY_TARGETING` 追踪 | **已验证**:`net.entity_store` 扩展(skill level / cooltime end / fly cue);`skill_test.gd`(真 `skilldesc.txt` >30 skill + WARRIOR 列 + skill 1 job/name/motion_idx;skill 窗行数 = 职业技能数 + +→`skill_up(1)` + Lv 5 显示;quickbar assign→activate→`cast_skill` 用正确 motion_idx + 冷却中不重发 + `cooldown_end` 后可再发)。12 个 GDScript 测试套件全绿,ctest 9/9,iOS 编过。 **未完成**:真弹道、加点等级公式 / 消耗校验(服务器管)。 被动 / 支援技能树 ✅(skilldesc 列偏移修 + skill_ui 三分类页 + `is_passive`/`master_type` 显示)。 技能特效表 ✅:`fx/skill_fx.gd` —— skill id + master → `_<2+master夹2..4>`(`skilldesc` col13 的 motion_name 就是特效基名,上一轮列偏移修好后自然接上);子特效 `_blow`/`_head`/`_foot`/`_yong` 存在就一起播。 `GC_SPECIAL_EFFECT` 内建 id → `SPECIAL_FX` 小表(`SE_HPUP_RED`=1→`drugup_red`、`SE_AUTO_HPUP`=19、 `SE_FAIL`=12→`fail` …)。`quickbar.skill_activated` 信号 → `game_scene` 播;`effect_cue` 的 `special` 分支 接上。实测真资产:skill 1/5/17/31/92/93 + SE 1/2/3/9/12/19/20 全部 resolve 到真 `.mse`。 ## P7 — 任务 / NPC(L,🌐) | 任务 | 规模 | 状态 | 落点 | |---|---|---|---| | NPC 交互 | S | ✅ | `wire.h`:`CGOnClick`(8)、`CG_ON_CLICK`(0x0A02)。`M2Client.click_npc(vid)` → `send_on_click`。`net_play._on_pick`:`ch_type ∈ {1,3,4}`(NPC/石头/warp)→ `click_npc(vid)` 而非攻击 | | 对话框 | 🟡 | 🟡 | `wire.h`:`GCScript`(7,`skin`+`src_size`)、`GCQuestConfirm`(77)、`CGScriptAnswer`(5) / `CGScriptButton`(8) / `CGQuestInputString`(69) / `CGQuestConfirm`(9),`static_assert`。`EntityStore`:`ScriptCue{skin,text}` / `ConfirmCue{msg,timeout,pid}` + `drain_scripts()` / `drain_confirms()`。`ui/quest_dialog.gd`:`parse_script` 解 `[ENTER]`/`[CLEAR]`/`[NEXT]`/`[DONE]`/`[QUESTION arg(..)]`/`[LETTER]` → 文本 + 选项 + 继续钮;选项 → `script_answer(idx)`,无选项 → `script_answer(255)`;`GC_QUEST_CONFIRM` → 接受/拒绝 → `quest_confirm(yes,pid)`。⬜ `[INPUT]` 输入框、`SCRIPT_SELECT_ITEM` 选道具、左右立绘 | | 任务日志 + 指引 | 🟡 | 🟡 | `wire.h`:`GCQuestInfo`(7 + flag 驱动变长尾)、`QUEST_SEND_*` flag 枚举。`EntityStore`:`QuestInfo{index,flag,begin,title,clock_*,counter_*,icon}` + `m_quests` map + `drain_quest_changes()` / `quest(idx)` / `quest_indices()`。`ui/quest_log.gd`(J 键):列 `◆ title` + `counter_name: value` + `clock_name: value`,`quest_info` 信号刷新。⬜ 地图 / 世界箭头(`TARGET_CREATE` 位置目标)、`[QUESTBUTTON]` → `script_button` | | quest 脚本执行 | — | ✅(选型②) | 采纳推荐方案:**客户端纯渲染 `GC_SCRIPT`,quest 逻辑全在服务器**。不内嵌 Python 解释器 | **已验证**:`net.entity_store` 扩展(`GC_SCRIPT` 带 `[QUESTION]` → `ScriptCue`;`GC_QUEST_CONFIRM` → `ConfirmCue`;`GC_QUEST_INFO` 带 TITLE+COUNTER flag → `QuestInfo` 变长尾解析,`length` 字段回填);`quest_test.gd`(`parse_script`:纯文本保留 / `[ENTER]`→换行 / `[QUESTION]`→2 选项 / `[DONE]`→继续钮;`script_dialog` 信号 → 弹窗 + 3 选项钮 + 选 B → `script_answer(1)` + 关窗;无选项 → 继续 → `script_answer(255)`;`quest_confirm_ask` → 接受 → `quest_confirm(true,999)`;quest_log 只显示有标题的 quest + `quest_info` 刷新不炸)。13 个 GDScript 测试套件全绿,ctest 9/9,iOS 编过。 **未完成**:`[INPUT]` 文本输入 UI(`CGQuestInputString` 封包已备)、`SCRIPT_SELECT_ITEM` 选道具、NPC 对话左右立绘、`[QUESTBUTTON]` → `script_button`、地图 / 世界任务箭头(`TARGET_CREATE_TYPE_LOCATION`)、端到端(见 `CLIENT-PORT.md` §3:握手 + 认证已联通,进游戏串场归 P10)。 ## P8 — 社交 / 商店 / 仓库(XL,🌐) 依赖 P1。窗口多,首版覆盖组队 / 好友 / 商店 / 交易 / 仓库五件;公会 / 精炼 / 私人商店留后续(拍卖行 m2dev-client 无)。 | 子系统 | 规模 | 状态 | 落点 | |---|---|---|---| | 组队 | M | ✅ | `wire.h`:`GCPartyInvite/Add/Update/Remove/Link/Unlink/Parameter`(0x0710-16) + `CGPartyInvite/InviteAnswer/Remove/Parameter`(0x0701-06),`static_assert`。`EntityStore`:`m_party`(pid→`PartyMember{vid,name,state,hp_pct,affects[7]}`) + `party_dirty()` + `drain_party_invites()` + `party_distribute_mode()`。`M2Client`:`party_invite/party_answer/party_leave/party_set_distribute` + `get_party()` + `party_changed`/`party_invite_ask(leader_pid)` 信号。`ui/party_ui.gd`:左侧常驻队员面板(名字 + ★队长 + HP 条),收邀请 → `Dialogs.confirm` → `party_answer` | | 私聊 / 好友 | M | ✅ | `wire.h`:`GC_MESSENGER`(0x0741) / `CG_MESSENGER`(0x0740) sub-header 包(`GCSubHeader`/`CGSubHeader`)+ `MESSENGER_GC/CG_*` 子头枚举。`EntityStore`:`m_friends`(name→`Friend{online}`) + `friends_dirty()`,解 `LIST`(`{u8 connected,u8 len,name}` 循环)/ `LOGIN`/`LOGOUT`/`REMOVE_FRIEND`。`M2Client`:`add_friend/remove_friend`(`ADD_BY_NAME`/`REMOVE` 变长包)+ `get_friends()` + `friends_changed`。`ui/friend_ui.gd`(O 键):在线点 + 名字 + [×],点名 → `whisper_to` → `chat.start_whisper("/w 名 ")` | | NPC 商店 | M | 🟡 | `wire.h`:`GC_SHOP`(0x0810) / `CG_SHOP`(0x0801) sub-header + `SHOP_GC/CG_*` 子头 + `ShopItem`(43,`TShopItemData`)。`EntityStore`:`SHOP_GC_START`(`u32 owner_vid` + `ShopItem[]`)→ `m_shop_items` + `m_shop_open`/`m_shop_vid`;`END` 清;错误 → `drain_shop_errors()`。`M2Client`:`shop_buy(pos,cnt)`(`BUY` = `[head][u8 cnt][u8 pos]`)/ `shop_sell(cell,cnt)`(`SELL2`)/ `shop_close` + `get_shop_items()` + `shop_opened(vid)`/`shop_closed`/`shop_error(kind)`。`ui/shop_ui.gd`:名字 + 价格 + [买],背包右键 → `sell(cell)`,错误红字。⬜ `START_EX` 多标签商店、图标、批量 | | 交易 | M | 🟡 | `wire.h`:`GC_EXCHANGE`/`CG_EXCHANGE`(0x051C/0x0508) + `EXCHANGE_SUB_*`(START/ITEM_ADD/ITEM_DEL/ELK_ADD/ACCEPT/CANCEL;GC 侧 END/ALREADY/LESS_ELK)+ `GCExchange`(50,`is_me`+`arg1/2/3`+sockets+attrs)/`CGExchange`(13)。`EntityStore`:`ExchangeState{active,partner_vid,self/peer_items[12],self/peer_gold,self/peer_accept}` + `exchange_dirty()`。`M2Client`:`exchange_start/add_item/add_gold/accept/cancel` + `get_exchange()` + `exchange_changed`。`ui/exchange_ui.gd`:我方 / 对方两栏 + 金币 + [接受]/[取消],背包右键 → `offer(win,cell)`(display pos 0..11 轮转)。⬜ 道具图标格、拒绝时回滚提示 | | 仓库 | M | 🟡 | `wire.h`:`GC_SAFEBOX_SET/DEL`(0x0830-31,body 复用 `GCItemSet`/`GCItemDel`) / `SIZE`(0x0833) / `MONEY_CHANGE`(0x0834) / `WRONG_PASSWORD`(0x0832);`CG_SAFEBOX_CHECKIN/CHECKOUT`(0x0820-21,`{u8 safe_pos, ItemPos inv}`) / `ITEM_MOVE`(0x0822,复用 `CGItemMove`)。`EntityStore`:`m_safebox[135]` + `m_safebox_size/gold/open` + `safebox_dirty()`。`M2Client`:`safebox_checkin/checkout/move` + `get_safebox_items()`/`get_safebox_size()`/`get_safebox_gold()` + `safebox_changed`。`ui/safebox_ui.gd`:金币 + 道具行 + [取出],背包右键 → `deposit(win,cell)`(自动挑空位)。⬜ 密码框、商城页(`MALL_*`)、拖拽网格 | | 公会 | L | 🟡 | `wire.h`:`GC_GUILD`(0x0730) sub-header + `GUILD_GC_*`/`GUILD_CG_*` 枚举 + `GuildInfoBody`(35)/`GuildSubMember`(13,`name_flag` 后跟 name[65])/`GuildSubGrade`(10),`static_assert`。`EntityStore`:`GuildState` + `m_guild_members` map + `m_guild_grades[16]` + `guild_dirty()`,解 INFO/LIST/GRADE/GRADE_NAME/CHANGE_EXP/MONEY_CHANGE。`M2Client`:`get_guild`/`get_guild_members`/`get_guild_grades` + `guild_add_member`/`guild_remove_member`/`guild_offer`/`guild_answer_make` + `guild_changed` 信号。`ui/guild_ui.gd`(G 键):信息行 + 成员列表(按 grade 排序,grade 名解析,★官员)+ 16×12 会徽图(放大 3×,`NEAREST`)。**真机实测**:`[GM-TEAM]` lvl 20 members 1/70。**会徽下载(独立连接)**:`wire.h` `GC_MARK_IDXLIST`(0x0C11)/`GC_MARK_BLOCK`(0x0C10)/`GC_MARK_UPDATE`(0x0B15) + `CG_MARK_LOGIN`(0x0C01)/`CG_MARK_IDXLIST`(0x0C04)/`CG_MARK_CRCLIST`(0x0C02),`GC_MARK_*` body 用 u32 `buf_size` 而非 u16 帧长。`NetStream` 加 `on_cipher_active()` + `on_raw()`(子类自定义分帧)钩子。`mark_image.{h,cpp}`:`MarkImageSet`(guild_id→mark_id、LZO1X 解压 64×48 块拼进 512×512、`rect_of`/`mark_pixels`)+ `parse_mark_idxlist`/`parse_mark_block`。`mark_client.h`:`MarkClient : NetStream`(KX→`CG_MARK_LOGIN{handle,random_key}`→`CG_MARK_IDXLIST`→逐图 `CG_MARK_CRCLIST`(全 0 CRC = 整图)→`GC_MARK_BLOCK`)。`GameClient` 存 `mark_handle`/`mark_random_key`(来自 `GC_LOGIN_SUCCESS3/4`)+ per-slot `guild_id`/`guild_name` + `drain_mark_updates()`。`M2Client`:`download_guild_marks(host,port)` / `get_guild_mark(gid)` / `get_guild_mark_image(gid)`→`Image` + `guild_marks_ready` / `guild_mark_updated` 信号。`serverinfo.gd` 第 8 列 `mark_port`(0=跳过);`app_flow` 进游戏时按配置连、`guild_mark_updated` 1s 冷却重连。**公会战 + 公会技能页**:`wire.h` `GUILD_GC_SKILL_INFO`(12) `GuildSkillInfoBody`(17=`{u8 point, u8 lvl[12], u16 gp, u16 max_gp}`)、`GUILD_GC_WAR`(15) `GCGuildWar`(10)、`GUILD_GC_GUILD_WAR_LIST/END_LIST`(17/18) `GuildWarPair`(8)、`GUILD_GC_WAR_POINT`(19) `GuildWarPoint`(12)、`GUILD_GC_NAME`(16) `GuildNameEntry`(16,无 NUL);`EGuildWarState` 枚举;`GUILD_CG_USE_SKILL`(9)。`EntityStore`:`GuildSkillState` / `GuildWarStatus` / `m_guild_wars` / `m_guild_names` map + `guild_skill_dirty()`/`guild_war_dirty()` + `drain_guild_war_events()`/`drain_guild_war_scores()`。`GameClient.send_guild_use_skill(vnum, target_vid)`。`M2Client`:`get_guild_skill`/`get_guild_wars`/`get_guild_war`/`get_guild_name` + `use_guild_skill(vnum, target)` + `declare_guild_war(name)`(发 `/war ` 聊天命令)+ `guild_skill_changed`/`guild_war_changed`/`guild_war_event`/`guild_war_point` 信号。`ui/guild_ui.gd` 改三页 Tab(成员 / 技能 / 公会战):技能页显示技能点 + 公会点 + 12 技能等级(名字取 `skill_table.for_category("GUILD")`)+ 已学技能「施放」按钮;公会战页显示当前战状态 + 进行中 GvG 列表(我方标记)+ 宣战输入框。**会徽上传**:`wire.h` `CG_MARK_UPLOAD`(0x0C03) `CGMarkUpload`(776=`{gid, u8 image[16*12*4]}`)、`CG_GUILD_SYMBOL_UPLOAD`(0x0722) `CGSymbolUpload`(8) + 追加原始文件字节;`NetStream.send_pending()`;`MarkClient` 加 `Mode`(Download / UploadMark / UploadSymbol)+ `set_upload_mark/set_upload_symbol` + `upload_done()`(发缓冲排空即完成)。`M2Client`:`upload_guild_mark(host, port, gid, Image)`(自动转 RGBA8 / 缩放到 16×12)/ `upload_guild_symbol(host, port, gid, PackedByteArray)` / `get_mark_server()` + `guild_mark_uploaded(ok)` 信号。`guild_ui` 成员页「上传会徽」按钮(配了 `mark_port` + 图源才出),`game_scene._guild_mark_upload_image()` 优先 `res://ui/default_guild_mark.png` 否则现造占位。⬜ 等级/日志页、公会战应答(`/war` 系聊天命令) | | 精炼 / 强化 | M | 🟡 | `wire.h`:`GC_REFINE_INFO`(0x051D)/`_NEW`(0x051E) `GCRefineInfo`(63) = type+pos+`RefineTable`(57,src/result vnum + cost + prob + `RefineMaterial[5]`);`CG_REFINE`(0x050C) `CGRefine`(6)=`{pos,type}`。`EntityStore`:`RefineCue` + `drain_refine_cues()`。`M2Client`:`refine(pos,type)` + `refine_ask(dict)` 信号。`ui/refine_ui.gd`:`refine_ask` → 弹框(src→result / 成功率 / 费用 / 材料)→ [精炼]→`refine(pos,type)` / [取消]。⬜ 精炼结果动画 | | 龙魂精炼 | M | 🟡 | `wire.h`:`GC_DRAGON_SOUL_REFINE`(0x051F) `GCDragonSoulRefine`(8)=`{sub_type, ItemPos}`;`CG_DRAGON_SOUL_REFINE`(0x050D) `CGDragonSoulRefine`(50)=`{sub_type, ItemPos grid[15]}`;`DS_SUB_*` 枚举(OPEN / DO_UPGRADE·IMPROVEMENT·REFINE / REFINE_SUCCEED / REFINE_FAIL_*);`DRAGON_SOUL_MAX_NUM`(180) + `WINDOW_DRAGON_SOUL`(5) 走 `mut_slot`。`EntityStore`:`DragonSoulCue` + `drain_ds_cues()` + `m_dragon_soul[180]` + `dragon_soul_slot()`。`M2Client`:`ds_refine(mode, cells)`(mode 0/1/2 → DO_UPGRADE/IMPROVEMENT/REFINE,cells 为背包格,grid[0]=龙魂)+ `get_dragon_souls()` + `ds_window_open` / `ds_refine_result(ok, sub_type, cell)` 信号。`ui/dragon_soul_ui.gd`(L 键 / `ds_window_open` 自动弹):3 模式按钮 + 15 格(背包右键填/取)+ [执行]→`ds_refine` + 成功/失败提示。⬜ 龙魂属性表 / 结果动画 | | 私人商店 / 道具商城 / Cube | M | 🟡 | **首版**:`CG_MYSHOP`(0x0802) `CGMyShopHead`(38)+`MyShopItem`(13)×N;`GC_MALL_OPEN`(0x0841)/`SET`(0x0842)/`DEL`(0x0843) 路由进 `EntityStore.m_mall[135]`,`CG_MALL_CHECKOUT`(0x0840)。Cube 走 `GC_CHAT`/`CHAT_TYPE_COMMAND` 文本总线(`EntityStore.apply_server_command` 解 `cube open/close/info/success/fail/r_list/m_info`)→ `CubeState` + `drain_cube_events()`;`send_cube_make/material_info/result_list` 走 `/cube …`。`M2Client`:`open/close_private_shop`、`get_mall_items`/`mall_checkout`、`get_cube`/`cube_make`/`cube_request_*` + `mall_opened`/`mall_changed`/`cube_opened`/`cube_closed`/`cube_changed`/`cube_result`。`ui/{private_shop,mall,cube}_ui.gd`。`net.entity_store`+`net.loopback_flow`+`shop_cube_mall_test.gd` 覆盖。⬜ 能量条、头顶招牌 3D、`START_EX` 多标签、39 格拖放、send 命令字对真服校准 | **已验证**:`net.entity_store` 扩测(party ADD/UPDATE/LINK/PARAMETER/REMOVE + INVITE 入队;messenger LIST 2 好友 + 在线位 + LOGIN 翻转;shop START owner+item / 错误 / END;exchange START/ITEM_ADD/ELK_ADD/ACCEPT/END;safebox SIZE/SET/MONEY/DEL);`net.loopback_flow` MockServer 断言 `CG_PARTY_INVITE{vid}` / `CG_SHOP BUY{cnt,pos}` / `CG_EXCHANGE START{vid}` / `CG_SAFEBOX_CHECKIN{safe,inv}` 字节到位;`p8_test.gd`(5 个 UI 节点 + FakeClient:队员面板 2 行 + 自动接受邀请、好友行 + 点名 whisper + 添加、商店 2 行 + 买 + 卖 + 错误本地化 + 关、交易两栏 + offer + 放金币 + 非 active 自动关、仓库开 + 金币 + 取出 + 存入挑空位)。13 套 GDScript 测试全绿,ctest 9/9,项目导入干净,iOS 编过。 **已验证(公会 + 精炼)**:`net.entity_store` 扩测(GC_GUILD INFO 名/等级/资金/容量 + LIST `name_flag` 变长成员 + GRADE `[count][idx][GuildSubGrade]` 名/权限;GC_REFINE_INFO src/result/cost/prob + materials[5]);`guild_refine_test.gd`(公会窗无公会提示 / 信息行 / 成员按 grade 排序 + grade 名 / ★官员;精炼框 `refine_ask` 弹出 + 成功率 + 费用 + 材料行 + [精炼]→`refine(7,1)` + [取消] 不发包)。真机 `net_e2e`:`[GM-TEAM]` 公会数据解析正确。 **已验证(龙魂精炼)**:`net.entity_store` 扩测(GC_DRAGON_SOUL_REFINE OPEN cue / REFINE_SUCCEED 带 cell+window / REFINE_FAIL_* / drain 清空);`dragon_soul_test.gd`(`ds_window_open` 弹出 + 默认精炼模式 + 切模式禁用当前按钮 + 背包右键按序填 15 格 + 非背包窗忽略 + 再次右键取出/末尾重放 + [执行]→`ds_refine(mode, [cells])` + 成功清空/失败留原因码 + 清空/关闭按钮)。 **已验证(会徽下载)**:新 CTest `net.guild_mark`(`parse_mark_idxlist` 2 条 → guild→mark_id + `needed_images` 升序去重;`rect_of` 按 `mark_id/1280` 选图、`%1280` 定位;合成 LZO1X 压缩块 → `parse_mark_block` → `apply_block` 拼图 → `mark_pixels` 逐像素回读;坏压缩数据 / 越界索引 / 截断 body 全部安全拒绝);`guild_mark_test.gd`(guild_ui 无图时 TextureRect 隐藏 → `guild_marks_ready` 出图 16×12 → 离开公会收起;`app_flow` `mark_port=0` 不下载 / 配置后 `download_guild_marks(host,11002)` / `guild_mark_updated` 冷却外重下、1s 内不重下)。**独立连接 KX + `MarkClient` 分帧逻辑离线覆盖,未接真机**(联调服未开会徽服端口)。附带修复:`game_scene.gd` 里 `_skill_master()` 之前被误插进 `setup()` 中段,截断了后半段(导致 `assets_root` 等未声明)——已把它移到 `setup()` 之后,多个依赖 `game_scene` 的 GDScript 套件恢复真绿。 **已验证(会徽上传)**:`net.loopback_flow` 加 `MockServer::Mode::Mark`:真 loopback socket 跑完整 KX → `CG_MARK_LOGIN{handle,random_key}` 字节到位 → `CG_MARK_UPLOAD{gid=77, image[768]}` 服务端逐字节校验和一致 → `upload_done()`;同法验 `CG_GUILD_SYMBOL_UPLOAD{gid=88}` + 2000 字节原始附加。`guild_mark_test.gd`:未配 mark server 无「上传会徽」按钮 → 配好后按钮出现 → `upload_guild_mark("1.2.3.4",11002,88,img)` + `guild_mark_uploaded(true)` → 状态更新。23 套 GDScript 全绿,ctest 10/10,导入干净,iOS 编过。 **已验证(公会战 + 公会技能)**:`net.entity_store` 扩测(GUILD_GC_NAME id→名注册、SKILL_INFO 点数/12 等级/公会点、WAR ON_WAR 状态 + 事件 drain、WAR_LIST 2 对 → WAR_END_LIST 删 1、WAR_POINT drain);`guild_war_skill_test.gd`(技能页无数据提示 → `guild_skill_changed` 出点数行 + GUILD 技能名 + 等级 + 第 12 技能配 `levels[11]`;已学技能有「施放」→ `use_guild_skill(4051,0)`;公会战页无战提示 + 宣战 → `declare_guild_war("Tigers")` + `guild_war_changed` → 「交战中」+ GvG 行「◀我方」)。23 套 GDScript 全绿,ctest 10/10,导入干净,iOS 编过。**真机(2026-08-31,`net_e2e`)**:`[GM-TEAM]` 账号进游戏后服务器自动推 `GUILD_GC_SKILL_INFO`,17B body 解析正确(skill_point=19 / guild_point 0/2300 / 12 等级);`GUILD_GC_WAR` 无战状态解析正确;trace `GC_GUILD` 子包 len=171/40/83/22/21/9/5 无 unknown。 **未完成**:私人商店打磨;商店 `START_EX`;交易 / 仓库道具图标网格;仓库密码框 + 商城页;公会战应答聊天命令;公会等级/日志页;龙魂属性表 + 精炼结果动画。 ## P9 — 世界系统(L,🌐,分散) | 任务 | 规模 | 状态 | 落点 | |---|---|---|---| | 换图 / 传送 | S | 🟡 | `wire.h` `GCWarp`(18,x/y/addr/port)。`EntityStore` `WarpCue` + `drain_warps()`(`same_server()` = addr==0)。`M2Client` `warp(pos, same_server)` 信号(pos 已转 Godot 米)。`game_scene._on_warp`:同服 → 直接挪玩家 + 相机 snap;跨服(addr≠0)→ warning,归 P10 重连串场 | | 频道 | S | ✅ | `wire.h` `GCChannel`(5)。`M2Client` `get_channel()` + `channel_changed(ch)` 信号。小地图角标显示 `CH n`(`hud.set_channel` 若有也调) | | 游戏内时间 / 昼夜 | S | ✅ | `wire.h` `GCTime`(12,unix 秒 i64)。`M2Client` `get_server_time()` + `time_changed(epoch)`。`world/world_time.gd`:epoch → 日内秒 → 太阳 pitch/azimuth + 光强/光色(夜黑冷蓝 → 黄昏橙 → 正午暖白)+ `Environment` ambient/背景色。无 GC_TIME 时退回系统本地时间 | | 小地图 | M | 🟡 | `ui/minimap.gd`:右上角圆形,北朝上,`_draw` 画底圆 + 外环 + 玩家朝向箭头 + 实体点(怪红 / 其它蓝)+ NPC 点(传送青 / 其它黄)+ 任务标记橙方。世界→图 = `(d.x, d.z)*SCALE` 夹到半径。⬜ 缩放 / 拖动 / 大地图窗 / atlas 底图 | | NPC 位置 / 世界任务标记 | M | 🟡 | `wire.h` `GCNPCPosition`(6 + `count`×`NPCPositionEntry`78) / `GCTargetCreate`(46) / `GCTargetUpdate`(16) / `GCTargetDelete`(8) + `CREATE_TARGET_TYPE_*`。`EntityStore` `m_npc_marks` + `m_markers` map + `npc_marks()`/`markers()` + dirty。`M2Client` `get_npc_marks()`/`get_world_markers()` + `npc_marks_changed`/`world_markers_changed`。小地图消费。⬜ 世界空间 3D 箭头 / 屏幕边缘指示 | | 天气 / 环境粒子 | S | 🟡 | `fx/weather.gd`:跟随相机上方的 `GPUParticles3D` 盒发射,`set_weather("snow"/"rain"/"none")` 切重力 / 速度 / 尺寸 / 颜色 / 湍流。⬜ 由地图 `.msenv` 属性驱动、闪电 / 雾 | | 坐骑 | S | 🟡 | `wire.h` `GCMount`(21)。`Entity.mount_vnum` + `drain_mount_changes()`。`M2Client` `mount_changed(vid)` 信号 + `entity_dict` 带 `mount_vnum`。⬜ 换骑乘模型 / 坐骑动作集 / 移速 | | 地形贴花 | S | ⬜ | `GameLib/TerrainDecal.cpp` —— 血迹 / 施法阵 / 脚印(Godot `Decal` 节点) | | 钓鱼 / 副本 | M | ⬜ | `GC_FISHING`(0x0B10) `DUNGEON`(0x0B11) | **已验证**:`net.entity_store` 扩测(warp 入队 + same_server;time set + dirty once;channel set;`GC_NPC_POSITION` 2 条 mark;marker create→update→delete;mount → `Entity.mount_vnum` + change);`p9_test.gd`(小地图:view + `CH` 角标 + `_to_map` 北朝上/东朝右/夹半径;world_time:正午 `day_fraction≈0.5` + 太阳亮,午夜 ≈0 + 太阳暗;weather:off→snow→rain(雨落更快)→off;warp 同服信号)。14 套 GDScript 测试全绿,ctest 9/9,项目导入干净。 **未完成**:大地图窗 + atlas 底图、地形贴花、钓鱼 / 副本、坐骑换模型、世界空间任务箭头、天气按地图驱动、跨服换图(P10)。 ## P10 — 网络补全(M,🌐) | 任务 | 规模 | 状态 | 落点 | |---|---|---|---| | **LOADING → PHASE_GAME**(真机端到端的最后一跳)| S | ✅ | `wire.h` `CGClientVersion`(0x000D,70B,filename+timestamp)。`GameClient`:收到 `GC_MAIN_CHARACTER` 后**自动发 `CG_CLIENT_VERSION`**(fork 用固定 timestamp `1215955205`)—— 这是服务器放行到 PHASE_GAME 的门槛,之前缺它就在 phase 4 断线。`net_e2e` 真机验证:`phase = 5 / IN GAME`,收到 121 个实体 + HP/SP/level + 20 格背包 + 好友列表 | | Loading 相位 | S | ✅ | `ui/loading_screen.gd`(`CanvasLayer` layer 60):`phase_changed` → "login/select→连接中/载入角色"、"loading→载入地图" 显示转轮 + 阶段文案 + 可选进度条;"game" 隐藏。断线也收起 | | 选服 / 选频道 | S | 🟡 | `net/serverinfo.gd`(`class_name ServerInfo`):解析 TSV 服务器表(`name / auth_host / auth_port / game_host / game_port / channels`),内置当前联调服默认项。`address(server, channel)` → 频道 N 的 game 端口 = base + (N-1)×10(经典 Metin2 约定)。`app_flow.gd` 登录表单里做服务器 / 频道下拉。⬜ 频道在线人数轮询(`ServerStateChecker` UDP 探针) | | Offline / 断线重连 | S | ✅ | `ui/reconnect_ui.gd`(layer 70):`disconnected(reason)` → 遮罩 + "N 秒后自动重连(次数/上限)" 倒计时 → `M2Client.reconnect()`(已有);[立即重连] / [停止] 按钮;`entered_game` 或非 offline 的 `phase_changed` 自动收起。最多重试 5 次 | | charselect 串场 | M | ✅ | `app_flow.gd`(`Node` 场景根):一个 `M2Client` 贯穿全程,状态机 `LOGIN → SELECT → GAME`。`char_list` → 选人列表;点角色 → `select_character`;`entered_game` → 实例化 `game_scene` 并 `setup`;LOGIN/SELECT 期间断线 → 回登录,GAME 期间断线 → 保留场景交给 `ReconnectUI`。`LoadingScreen` + `ReconnectUI` 常驻。`build_game_scene` 开关便于 headless 测状态机 | | Metin2 风选人页 | M | ✅ | `ui/char_select_screen.gd` 取代裸按钮列表:全屏 `select.jpg` 背景 + 右侧 `SubViewport` 里 `PlayerView` 真模型(正面 + `wait` + 缓摆,**包围球** `r/sin(fovV/2)` 自适应机位,不再切远侧手)+ 左上职业名(locale `name_.sub`→`select.dds`,DDS 走 `Metin2World.load_dds`;退中文描边字)+ 左侧 `board` 板(帮会徽 / **国家名+国旗** `get_empire()` / 帮会名 / 名称 / 等级 / 游戏时间 / 体力·智力·力量·敏捷)+ 开始/创建/删除/退出 + ◀▶ 槽位切换(`_pad_slots` 补空槽)。**建号/删号 1:1**:`CG_CHARACTER_CREATE`(0x0201)/`CG_CHARACTER_DELETE`(0x0202) + `GC_PLAYER_CREATE_SUCCESS/FAILURE`(0x020C/D) + `GC_PLAYER_DELETE_SUCCESS`(0x020E)/`WRONG_SOCIAL_ID`(0x020F);`GameClient.create/delete_character` + `drain_char_events` + `replace_slot/clear_slot`(服务器补丁槽位不重发);`M2Client` 同名方法 + `char_created/char_create_failed/char_deleted/char_delete_failed` 信号 + `get_empire/get_slot_count`;选人页建号弹窗(职业+名称+各职业起始四维)、删号弹窗(secret 删除码);`app_flow._on_char_list` 在 SELECT 只 `set_chars` 不整屏重建。`net.loopback_flow` MockServer + `char_create_delete_test.gd` 覆盖。C++ `CharSlot`+dict 补 `play_minutes/st/ht/dx/iq/main_part/hair_part`;`ui_assets` 加运行时 DDS 解码 | | attack CRC | S | ✅ | `net_e2e` 真机实测:进游戏后发 `CG_MOVE` + `CG_ATTACK`(`crc_proc=0 crc_file=0`),保持连接 10s 不被踢。**结论:0 CRC 服务器接受,不需要占位 CRC** | | ESC 系统菜单 + 游戏设置窗(`uisystem.SystemDialog` + `uigameoption.OptionDialog`)| M | ✅ | `system_menu_ui.gd`(`systemdialog.py`)8 键:system_option / game_option 开子窗、`change_button`→`/phase_select`、`logout_button`→`/logout`、`mall_button`→`/in_game_mall`、`exit_button`→quit。ESC 改开此菜单。`game_option_ui.gd`(`gameoptiondialog.py`):`/setblockmode `(EBlockAction 位 1<<0..1<<5,本地 `_block_mode` + cfg 持久化)+ `/pkmode {0,1,2,4}` PK radio + 6 组显示开关持久化 `user://system_option.cfg [gameopt]`(渲染钩子待补)。`system_menu_ui_test.gd` 覆盖。C++ 无改动 | | 组队成员信息板(`uiparty.PartyMemberInfoBoard`)| M | ✅ | `wire.h` `CGPartySetState`(10) + `EPartyRole`;`send_party_set_state` / `M2Client.party_set_state`;`get_party()` 补 `state`+`affects[7]`。`party_ui.gd` 每员 strip = 角色状态按钮(队长弹菜单 `CG_PARTY_SET_STATE` 攻/坦/狂/辅/宗/防 + 踢人)+ 名字(★) + HP gauge + affect chip;顶部 EXP 分配开关 + 组队治疗。点名字 → `set_target`。`p8_test` 组队段扩覆盖;顺带修 `p8_test._init` 未 await 的隐患 + `shop_ui.refresh` queue_free 延迟 bug | | 系统设置窗(`uisystemoption.OptionDialog`)| S | ✅ | `ui/system_option_ui.gd`:装 `systemoptiondialog.py` 真 uiscript,绑定照 `uisystemoption.py`:音乐/音效音量滑条→`Audio.master_bgm/sfx`(含在播 BGM 即时)、`camera_short/long`→`game_camera.max_dist`(近 11/远 20)、`fog_level0/1/2`(浓/中/淡)→`Environment.fog_density`、`tiling_*` Godot 无对应保留占位。持久化 `user://system_option.cfg`(= `systemSetting` 配置),setup 读回即时生效,`.msenv` 换 env 后重贴。`game_scene` ESC 呼出。`system_option_ui_test.gd` 覆盖 | | 私人商店开설창(`PrivateShopBuilder`)| S | ✅ | 增量 36:布局改走真 uiscript `privateshopbuilder.py`(40 格 grid + NameLine 盖 LineEdit + Ok/Close,候选面板挂窗右侧)。`itemStock` 交互(拿起/落位/价格弹窗/排序+display_pos/撤下/개설/철수)不变。测试拆出 `private_shop_ui_test.gd`。以下为原 | `private_shop_ui.gd` 重做成 `uiprivateshopbuilder.py` 的 `itemStock` 模型:左侧背包候选「拿起」+ 右侧 40 格(`shop.SHOP_SLOT_COUNT`);拿件点空格 → 价格弹窗(`MoneyInputDialog`)→ 落位(`AddPrivateShopItemStock`),点占用格 → 撤下(`DelPrivateShopItemStock`)。개설 把 stock 按格号排序、`display_pos=格号`、上限 39(`PRIVATE_SHOP_ITEM_MAX_NUM`)→ `open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])`(`TPacketCGMyShop`+`TShopItemTable`×N)。`shop_cube_mall_test` 重写覆盖。C++ 无改动 | | 商店 `SHOP_GC_START_EX`(多货架)| S | ✅ | `entity_store` 解析 `SHOP_GC_START_EX`(10) = `{u32 owner_vid, u8 tab_count}` + `tab_count`×`{ShopTabHead(name[32]+coin_type), ShopItem[40]}`;`ShopEntry.pos` 保槽位,`m_shop_tabs`(`ShopTab{name,coin_type,items}`),`SHOP_GC_START` 也按 40-槽填 pos,END 清货架。`M2Client.get_shop()` → `{vid,open,tabs:[…]}`。`shop_ui.gd` 多货架按钮 + 买位置 `tabIdx*40+slot`(1:1 `uishop.py GetIndexFromSlotPos`)。`net_entity_test` 双货架合成包 + `p8_test` 切页 / `buy pos 5` / `buy pos 43` | | 选魔石窗(样板第 2 例)| S | ✅ | `ui/select_item_ui.gd`:装 `selectitemwindow.py`(5×8 grid),填格照 `uiselectitem.py.RefreshSlot`(只 `ITEM_TYPE_METIN`=10 + `GetItemGrade`(内部名末位数字)≤2,≤54 个,升序映射背包格)。点格 → `client.script_select_item(invCell)`(`CG_SCRIPT_SELECT_ITEM` 0x0903 = `net.SendSelectItemPacket`)+ 关窗;关闭 → `script_select_item(0)`。触发链:`quest_dialog.parse_script` 识别 `[SELECT_ITEM]` token → `select_item_requested` 信号(= `PythonEventManager EVENT_TYPE_SELECT_ITEM` → `BINARY_OpenSelectItemWindow`)→ `game_scene` 接 `select_item_ui.open`。`select_item_ui_test.gd` 覆盖 | | 角色状态窗(1:1 窗口样板)| M | ✅ | `ui/char_status_ui.gd`:`UiScript`+`UiBuild` 装载 `characterwindow.py` 真布局,数值逐字对照 `uicharacter.py.RefreshStatus`(Level/Exp/RestExp、HP/SP、STR/DEX/HTH/INT、ATT=`(min|max)+ATT_BONUS+ATTACKER_BONUS`、DEF、MATT=`MAG_ATT+(min|max)MAGIC_WEP`、MDEF、ASPD/MSPD/CSPD/ER;EPointTypes 索引取 m2dev `Packet.h`)。加点 +/- → `client.say(0,"/stat ht"|"/stat- ht")`(同原 `statusPlusCommandDict`);`POINT_STAT>0` 才显加点按钮。4 页 tab `SetState` 切换。名称/帮会/头像取 `get_entity(get_main_vid())`。`game_scene` V/C 热键。`char_status_ui_test.gd` 覆盖。**后续窗口(选道具网格 / 商店 START_EX / 私人商店 39 格 / 系统设置 …)照抄此样板:uiscript 装载 + 逐字数值绑定 + 原聊天命令 / 封包** | **已验证**:`net.loopback_flow` MockServer 断言「`GC_MAIN_CHARACTER` 后客户端自动发 `CG_CLIENT_VERSION`」;`p10_test.gd`(serverinfo 内置默认 + 文件解析 + 频道端口偏移;loading 遮罩 phase=loading 显示 / progress 条 / phase=game 隐藏;reconnect 断线 → 倒计时耗尽调 `reconnect()` + 手动重连 + `entered_game` 收起;app_flow 状态机 LOGIN→连接用 serverinfo 地址→`char_list`→SELECT→点角色→`select_character`→`entered_game`→GAME,SELECT 断线→LOGIN)。15 套 GDScript 测试全绿,ctest 9/9。**`net_e2e` 真机:auth → game → 选人 → LOADING → PHASE_GAME 全程打通。** **真机字节校准 + 收尾(2026-08-30,已做)**: - `GC_CHAR_ADD_INFO`(0x0207,97B) 补上 → 实体名/等级/公会/骑乘出(NPC「Old Man」实测正确); `GC_CHARACTER_UPDATE`(0x0209) 按 `TPacketGCCharacterUpdate` 全解(parts/guild/alignment/pk/mount)。 - `ch_type` 改在 GDScript 按 race 查 `mob_proto.type` 分类(`net_play._entity_kind`/`_entity_name`), `game_scene` 载 `mob_proto`。`GC_PLAYER_POINTS` 本来就对(gold 13.5 亿 = GM 号真实值, exp/next_exp 按 unsigned 输出)。 - **3D 名字标签刷新**:`ChangeKind::Info` + `entity_info` 信号 → `net_world._on_info` 刷 Label3D, `name_resolver` 让怪名走 `mob_proto`。 - **跨服换图**(`GC_WARP` addr≠0):`M2Client.warp_to_game_server` 拆连接、复用 `login_key` 直连新 game server。**断点快连**:`reconnect()` 优先直连 game server 省一次 auth 往返。 - **频道负载查询**:`net/channel_status.gd` = `CServerStateChecker` 端口(明文 TCP,`CG_STATE_CHECKER` 0x000F → `GC_RESPOND_CHANNELSTATUS` 0x0010 → `{port: 0关/1正常/2拥挤/3爆满}`)。真机 `11011` 回 `{11011:1,11012:1,11013:1,11991:1}` → `serverinfo` 频道口步长改 +1(`port_step` 可按服覆盖)。 - **怪 / NPC 真 3D 模型**:`ui/mob_view.gd`(race → `root/npclist.txt` 代号 → 目录逐段回退 (`bear_brown→bear`)→ `Metin2Model`(网格 / 贴图用完整代号)+ `motlist.txt` 动作), `game_scene._make_entity_model` 作 `net_world` 默认工厂,失败回退占位胶囊。 `net_world._attach_nameplate` 抽出,真模型也挂名字 + HP 条。翻译版 `mob_proto` `szName` 被本地化 ("Brown Bear")解不出目录,故必须走 `npclist.txt`。 - **已在局内 / 重连补拉实体**:`net_world.catch_up()` 遍历 `client.get_entities()` 补 spawn (协程化的 `setup()` 让 `entity_spawned` 接得晚,会漏初始 spawn 洪流);`game_scene` 先 `set_local_vid` + `net_play._on_main_set` 再 `catch_up`,避免本地玩家被画成压身的蓝胶囊。 详见 `CLIENT-PORT.md` §3。**23 套 GDScript + 10 CTest + 项目导入 + macOS/iOS 扩展全绿。** **剩的不是代码问题**:字面在线人数(Metin2 协议只有 4 态负载,要数字得改服务器)、全量 monster 资产。 --- ## 里程碑 **P0–P10 首版全部完成**(2026-08-30)。真机端到端(`net_e2e`)从 auth 握手一路打通到 PHASE_GAME —— 收到 121 个实体、HP/SP/等级、20 格背包、好友列表。18 套 GDScript + 9 CTest + 项目导入 + macOS/iOS 扩展全绿。 | 里程碑 | 含 | 首版状态 | 距「打磨完成」还差 | |---|---|---|---| | **M1 可打** | P0 闭环 + P4 硬直 / 连击 / 相机抖 / 死亡窗 | ✅ 首版 | 攻速 / motion index 按 `POINT_ATT_SPEED` 细化、武器拖尾、相机锁定 | | **M2 角色养成** | P1 UI 层 + P2 物品 / 装备(武器 / 盾 / 身体 / 头盔 / 头发 / armor shape / 时装)+ P6 技能(分类页 + 技能特效表 + 内建特效)+ P3 聊天 | ✅ | 加点等级公式(服务器管)、真弹道 | | **M3 世界** | P7 任务 / NPC + P9 世界系统 + P10 网络补全 | ✅ 首版 | `[INPUT]` / 选道具 / 立绘、大地图窗 + atlas、地形贴花、跨服换图、断点续连 | | **M4 社交** | P8 组队 / 好友 / 商店 / 交易 / 仓库 / **公会**(技能页 + 公会战 + 会徽上/下载)/ **精炼** / **龙魂精炼** | 🟡 首版(12 件) | 龙魂属性表 + 结果动画、私人商店打磨、公会等级/日志页 | | **特效** | P5 EffectLib(`.mse` → GPUParticles) | 🟡 首版 | `.dds` 纹理、`.mde` mesh、绑骨骼、skill / 命中特效表 | ### 下一步优先级(都是「首版 → 打磨」) 1. ~~真机字节校准第二轮~~ ✅ 已做(2026-08-30):`GC_SKILL_LEVEL_NEW`(0x021B,本服才发这个, 之前技能窗全空) + `GC_QUICKSLOT_*`(0x0519/1A/1B) 补上;`GC_NPC_POSITION` / `GC_ITEM_UPDATE` / `GC_CHAT` / `GC_AFFECT_ADD` / `GC_SPECIAL_EFFECT` / `GC_QUEST_INFO` 逐字节确认全对。详见 `CLIENT-PORT.md` §3。 2. ~~换装完整~~ ✅ 已做(2026-08-30):`Metin2Model` 加第二刚体挂点(盾,`Bip01 L Hand`,`_load_attach` 抽公共加载器);`equip_model` 补 WEAR_SHIELD / WEAR_HEAD(头盔覆盖 hair 槽)/ 头发随 `parts[HAIR]`。 **armor→shape 真表**:proto 读器加 `alValues[6]`@191,`equip_model._armor_shape_default` = `item_proto values[3]`(非 0)否则 =vnum(对齐 `__ArmorVnumToShape`)+ armor `specular` → `model.specular_power` (§2.7)。**时装**:Metin2 不发独立 costume 槽包,服务器把 costume vnum 写进主角 `parts[ARMOR/HAIR]`; `equip_model` 每槽改成 `_eff()`(`parts[]` 非 0 优先,否则回退 `get_equipment()`),大编号 `parts[HAIR]` 走 `item_list.model`(假发)。`GC_CHARACTER_UPDATE` parts 变 → 推 `Info` 变更触发全刷。也是渲染 远端玩家外观的同一条路。 3. ~~被动 / 支援技能树~~ ✅ 已做(2026-08-30):`skilldesc.txt` 列偏移修正(旧代码差 1 列,`motion_idx` 一直读成 0);`skill_ui` 三分类页 `[主动][辅助][坐骑]`(`for_category`),被动技能无 `[+]`,`master_type` 显示为 `Lv X M/G/P`。 4. ~~技能特效表~~ ✅ 已做(2026-08-30):`fx/skill_fx.gd`,skill id + master → `_`(`skilldesc` col13),`GC_SPECIAL_EFFECT` id → `SPECIAL_FX` 小表;`quickbar.skill_activated` + `effect_cue.special` 接上。 5. **全量资产** —— 换完整 monster / npc / effect 包,`mob_view` / `effect_registry` / `skill_fx` 立刻全量。 6. ~~公会 + 精炼~~ ✅ 已做(2026-08-30):`GC_GUILD`(0x0730) INFO/LIST/GRADE 解析 + `ui/guild_ui.gd`(G 键, 真机 `[GM-TEAM]` 验证);`GC_REFINE_INFO`(0x051D) + `ui/refine_ui.gd`(`refine_ask` 弹框 → `refine(pos,type)`)。 ~~公会战 / 技能页~~ ✅ 已做(2026-08-30):`GUILD_GC_SKILL_INFO`(12)/`WAR`(15)/`GUILD_WAR_LIST`·`END_LIST`(17/18)/ `WAR_POINT`(19)/`NAME`(16) 解析 + `GUILD_CG_USE_SKILL`(9);`M2Client` `get_guild_skill`/`get_guild_wars`/ `use_guild_skill`/`declare_guild_war`(`/war` 命令) + 4 信号;`guild_ui` 改三页 Tab(成员/技能/公会战)。 9. ~~会徽上传~~ ✅ 已做(2026-08-30):`CG_MARK_UPLOAD`(0x0C03,`{gid, u8 image[768]}`) / `CG_GUILD_SYMBOL_UPLOAD` (0x0722) + 追加文件字节。`MarkClient` 加 `Mode`(Download/UploadMark/UploadSymbol)+ `set_upload_mark/_symbol` + `upload_done()`(`NetStream.send_pending()` 归零即完成)。`M2Client.upload_guild_mark(host,port,gid,Image)`(自动转 RGBA8 + 缩放 16×12)/ `upload_guild_symbol` / `get_mark_server` + `guild_mark_uploaded(ok)`。`guild_ui` 成员页 「上传会徽」按钮。`net.loopback_flow` 加 `MockServer::Mode::Mark` 真 socket 校验字节。 7. ~~龙魂精炼~~ ✅ 已做(2026-08-30):`GC_DRAGON_SOUL_REFINE`(0x051F) / `CG_DRAGON_SOUL_REFINE`(0x050D, `grid[15]`) + `DS_SUB_*` 枚举;`EntityStore.DragonSoulCue` + `m_dragon_soul[180]`(`WINDOW_DRAGON_SOUL` 走 `mut_slot`);`M2Client.ds_refine(mode, cells)` + `ds_window_open` / `ds_refine_result`;`ui/dragon_soul_ui.gd` (L 键 / 自动弹,3 模式 + 15 格背包右键填 + [执行])。剩龙魂属性表、精炼结果动画。 8. ~~会徽下载~~ ✅ 已做(2026-08-30):`MARK_*`(0x0C0x/0x0B15)独立连接。`NetStream` 加 `on_cipher_active()` + `on_raw()` 钩子让子类自定义分帧(`GC_MARK_*` 用 u32 `buf_size`)。`mark_image.{h,cpp}` `MarkImageSet` (LZO1X 解 64×48 块 → 512×512 → `mark_pixels`)+ `mark_client.h` `MarkClient`(KX → `CG_MARK_LOGIN{handle, random_key}` → `CG_MARK_IDXLIST` → 逐图 `CG_MARK_CRCLIST`(全 0) → `GC_MARK_BLOCK`)。`M2Client.download_guild_marks` / `get_guild_mark_image` → `Image`;`guild_ui` 显示 16×12 会徽;`serverinfo` `mark_port` 第 8 列(0=跳过); `app_flow` 进游戏连、`guild_mark_updated` 冷却重连。CTest `net.guild_mark` + `guild_mark_test.gd`。**未接真机** (联调服未开会徽服端口)。 9. **私人商店打磨**、商店 `START_EX`、道具图标网格 —— 按需。(拍卖行不在 m2dev-client 内) ## 通用做法 1. **每个 P 先做 💻 部分**:`wire.h` 加封包 + `EntityStore` 解析 + 合成封包单测(`net_entity_test` 风格)+ 假 client 驱动 UI(`netbridge_test` 风格)。 2. 🌐 端到端用 `build/extension/net_e2e`(真机跑 auth→game→选人→进游戏);`MT_NET_TRACE=1` 逐包 header/len/consumed、`MT_NET_DUMP=1` 逐包 hex,对字节用。 3. UI 一律走 P1 的 uiscript 装载器,不手搓布局。 4. 封包号 / 结构以 `../m2dev-client-src-main/src/UserInterface/Packet.h` 为准,`static_assert` 尺寸;**真机字节和参考头文件不符时以真机为准**(已踩:`GC_LOGIN_SUCCESS4`、`bType`、`CG_CLIENT_VERSION`)。 5. 玩法逻辑尽量**服务器驱动、客户端只渲染**(尤其 quest / 商店 / 公会),少搬业务规则。