按 docs/ 目录逐篇核对,只改与事实/路径不符的部分,不动尚未验证的计划条目: - `docs/steps/**` 在早前整理时已迁到 `docs/reference/steps/**`,全仓的旧路径 引用(PLAN.md / README.md / M0-gr2-reader.md 及 libgr2 的头注释)一并改正。 - CLIENT-GAP.md / CLIENT-GAP-FIX.md / BACKLOG.md / MIDREVIEW.md:把已经落地的 条目从「待办」改为已完成,删掉与代码现状矛盾的描述。 - ANDROID-TESTING.md / CLIENT-PORT.md / CLIENT-ROADMAP.md / GODOT-POC-PLAN.md / SHINSOO-WORLD-RENDERING.md / PARITY-GAP.md:同上,另补当前实际的构建/测试入口。 - THIRD-PARTY.md:补齐实际在用的第三方来源与许可说明。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJugvEJwz3FK4hw9ti3SRb
55 KiB
客户端移植 —— 从素材渲染器到能联机的客户端
适用范围(2026-09-08 复核):本文描述的是 m2dev fork 协议后端。该后端已不是默认——
project/client_main.gd:14-15在MT_PROTOCOL为空时无条件兜底成classic,当前默认走 40250 经典协议。迁移方案与 classic 后端的协议细节见CLIENT-40250-PORT.md。 本文仍然有效的部分:m2dev 后端自身的握手 / 封包 / 相位编排(MT_PROTOCOL=m2dev时仍可切回), 以及 §2 记录的移植进度。不要把本文的封包号或Packet.h依据用到 classic 后端上。
起点:本仓库(
metin2-client)的渲染纵切「差不多能用」(见PARITY-GAP.md)。 本文是把它变成能连服务端玩的客户端的分层计划。分水岭是网络层——别的玩家、 物品、战斗、聊天都以封包为数据源。玩法 / 客户端系统层面「原始客户端有、这里还没有」的完整差距清单见
CLIENT-GAP.md。
0. 目标服务端
- LAN:
192.168.21.203。握手端口 connection refused—— 已解决(2026-08-30):真机 端到端已打通到 PHASE_GAME,见本文 §3。 - 默认端口(
assets/root/serverinfo.py):auth11000,频道/游戏11011/11021/11031/11041。 - 测试账号:
admin/123456789。
1. 协议(本 m2dev fork,不是老 Metin2)
1.1 帧格式
每个包 = [uint16 header LE][uint16 length LE][payload...],length = 含 4 字节前缀的整包长度
(≥ 4)。包间的 0 字节是加密对齐 padding,跳过。header 是 CG::/GC:: 命名空间常量
(UserInterface/Packet.h + EterLib/ControlPackets.h)。
1.2 加密握手(libsodium)
EterBase/SecureCipher —— X25519 密钥交换(crypto_kx)+ 每方向固定 nonce 的 XChaCha20
流加密(带运行字节计数,顺序敏感)+ crypto_auth HMAC 挑战应答 + XChaCha20-Poly1305
AEAD 加密 session token。
握手序列(连上 auth server 后):
| 方向 | 包 | header | 动作 |
|---|---|---|---|
| S→C | GCPhase{phase=PHASE_HANDSHAKE} |
GC_PHASE 0x0008 |
进握手态 |
| S→C | GCKeyChallenge{server_pk[32], challenge[32], server_time} |
GC_KEY_CHALLENGE 0x000B |
Initialize();compute_client_keys(server_pk) |
| C→S | CGKeyResponse{client_pk[32], challenge_response[32]} |
CG_KEY_RESPONSE 0x000A |
get_public_key();compute_challenge_response(challenge) |
| S→C | GCKeyComplete{encrypted_token[48], nonce[24]} |
GC_KEY_COMPLETE 0x000C |
decrypt_token()→token;set_activated(true);解密已缓冲的剩余字节 |
| S→C | GCPhase{phase=PHASE_AUTH} |
GC_PHASE |
进认证态 |
| C→S | CGLogin3{name[31], pwd[17]} |
CG_LOGIN3 0x0102 |
明文账号密码(此时流已加密) |
| S→C | GCAuthSuccess{login_key, result} |
GC_AUTH_SUCCESS 0x0108 / 失败 GC_LOGIN_FAILURE 0x0106 |
result 真→拿 login_key |
拿到 login_key 后断开 auth,连游戏服(:11011),同样握手,然后 CGLogin2{name, login_key}
(CG_LOGIN2 0x0101)→ 选人 → 加载 → 进游戏相位。
S→C 流 nonce
[0]=0x01,C→S[0]=0x02。ping(GC_PING0x0007)→pong(CG_PONG0x0006) 全程要回。
2. 进度
| 模块 | 状态 | 位置 |
|---|---|---|
SecureCipher(KX + 流 + AEAD token) |
✅ 端口完成,net.cipher_roundtrip CTest(KX / 挑战应答 + 篡改拒绝 / token 往返 / 跨块流加解密 / 字节计数) |
extension/src/net/secure_cipher.{h,cpp} |
wire.h(帧 + 控制 + KX + auth 结构体) |
✅ #pragma pack(1) 尺寸断言 |
extension/src/net/wire.h |
ByteBuffer(收发暂存) |
✅ header-only | extension/src/net/byte_buffer.h |
| libsodium 接入 | ✅ 从源码 vendor(extension/third_party/libsodium-cmake + zstd + miniLZO),macOS/iOS 都编过,不再依赖 Homebrew。详见 docs/THIRD-PARTY.md |
extension/third_party/CMakeLists.txt |
NetStream(非阻塞 POSIX socket + process() 循环 + 帧解析 + 内建控制面 handler:GC_PHASE / GC_PING→CG_PONG / KX 握手 / 激活后解密已缓冲字节) |
✅ 编译通过 | extension/src/net/net_stream.{h,cpp} |
AuthClient(auth 相位:PHASE_AUTH→发 CGLogin3→收 GCAuthSuccess/GCLoginFailure) |
✅ 编译通过,等可达服务端端到端测 | extension/src/net/auth_client.h |
GameClient(游戏服:PHASE_LOGIN→发 CGLogin2{name, login_key}→收 GCLoginSuccess3 角色列表→select_character()→PHASE_SELECT/LOADING/GAME) |
✅ 编译通过。角色列表按 TSimplePlayerInformation(pack(1),103B)解析成 {index,id,name,job,level,x,y};游戏相位包暂只回 PING、记未知 header |
extension/src/net/game_client.h |
net_probe 诊断工具 |
✅ net_probe <host> <port> <id> <pw>,打印 state / 握手 / 登录结果 / login_key |
extension/tools/net_probe.cpp |
M2Client GDExtension 节点 |
✅ 已注册可用。connect_to_server(...) 编排 Auth→(auth_ok)→GameConnect→游戏相位;select_character(index)。信号 stage_changed / phase_changed / auth_ok / login_failed / char_list / entered_game / disconnected。生命周期(F5):suspend()/resume()/reconnect() + suspended/resumed 信号,NOTIFICATION_APPLICATION_PAUSED/RESUMED 自动触发。烟测:正确报 auth_connect → login_failed(Connection refused) → failed |
extension/src/net/m2_client.{h,cpp} |
M2Client 游戏内 API |
✅ 出站 move(func,arg,rot_deg,x,y)(rot /5、time 取帧钟)/ attack(motion,vid) / set_target(vid) / say(type,text),未入局返回 false。快照 get_entities() / get_entity(vid)(含 hp/sp/level/dead/stunned,坐标转 Godot 米)/ get_main_vid() / get_points()(HP/SP/level/exp/gold)/ get_target()(vid + hp%)。pump_game() 每帧 EntityStore::set_now/tick 并抽信号:entity_spawned/despawned/moved/main_set、chat、vitals_changed(vid)、entity_dead(vid)(首次去重)、damage(vid,amount,flag)、motion(vid,victim,motion)、points_changed(Dict)、target_info(vid,hp%)。net.loopback_flow 扩展:出站 CG_MOVE/ATTACK/TARGET/CHAT 经加密信道字节级校验 |
extension/src/net/m2_client.{h,cpp} |
| 游戏相位封包结构 | ✅ wire.h:GC_MAIN_CHARACTER / GC_CHARACTER_ADD[2] / GC_CHARACTER_DEL / GC_CHARACTER_UPDATE / GC_MOVE / GC_CHAT + CG_MOVE / CG_ATTACK / CG_TARGET / CG_CHAT。战斗/状态(对 Packet.h):GC_PLAYER_POINTS(0x0214, int32[255] 1024B) / GC_PLAYER_POINT_CHANGE(0x0215) / GC_STUN(0x0216) / GC_DEAD(0x0217) / GC_MOTION(0x0307) / GC_DAMAGE_INFO(0x0410) / GC_TARGET_INFO(0x0A10) + EPointTypes(HP/MAX_HP/SP/MAX_SP/LEVEL/EXP/GOLD…)+ EDamageFlag(NORMAL/POISON/DODGE/BLOCK/PENETRATE/CRITICAL)。全部 static_assert 尺寸 |
extension/src/net/wire.h |
EntityStore(封包→世界模型) |
✅ headless。apply() 消化上述包 → vid → Entity{race,name,x,y,z,angle,parts,func,moving, hp,max_hp,sp,max_sp,level,dead,stunned};GC_MOVE FUNC_MOVE 插值/其他 func 吸附;tick() 按 now_ms 插值。战斗:GC_PLAYER_POINTS→points() 全量属性 + 镜像到主角实体;GC_PLAYER_POINT_CHANGE→按 EPointTypes 更新单实体 vital;GC_DEAD/GC_STUN→标记;拉取口 drain_changes / drain_chat / drain_vitals / drain_damage / drain_motions / take_points_dirty / take_target_dirty。单测 net.entity_store 扩展覆盖全部战斗包 |
extension/src/net/entity_store.{h,cpp} |
CG_* 出站构造 |
✅ GameClient::send_move/send_attack/send_target/send_chat(CG_CHAT 变长) |
extension/src/net/game_client.h |
| 环回集成测试 | ✅ net.loopback_flow CTest:进程内 MockServer(真协议:帧 + libsodium KX + 脚本相位包)驱动 AuthClient→GameClient 走完 auth→角色列表→选人→PHASE_GAME→游戏包进 EntityStore→NPC 移动插值。服务端一通改个 IP 即可 |
extension/tests/net_loopback_test.cpp |
EntityStore 单测 |
✅ net.entity_store CTest:合成包 → spawn / 移动插值(半程/到达/func 复位)/ 吸附 / 变长 chat / despawn |
extension/tests/net_entity_test.cpp |
NetWorld 场景桥(GDScript) |
✅ net_world.gd:消费 M2Client 实体信号,每 vid 一个子节点,位置向 get_entity(vid).pos 平滑逼近(超 snap_dist 瞬移)、rotation.y 取自 angle_deg、动画状态取自 func。默认「占位胶囊 + 名字牌 + HP 条」;set_model_factory(cb) 换真模型。战斗:vitals_changed→缩放 HP 条 + emit vitals(vid,hp,max_hp,dead);damage→实体头顶飘字(crit 放大变金 / dodge=MISS,tween 上浮淡出)+ emit damage_number;entity_dead→倒地 + set_anim_state("dead")。main_entity_ready / chat_line 信号。headless 自检 netbridge_test.gd(spawn/main/远移吸附/近移插值/chat/伤害数字/HP 条/暴击/死亡/despawn)通过 |
project/net_world.gd |
| P0 闭环胶水 + 场景组装 | ✅ net_play.gd:player_controller ↔ M2Client —— 点地→move(FUNC_MOVE)(节流)/ 停→move(FUNC_WAIT) / 点实体→set_target + 进 2.5m 自动 attack / 收自己 GC_MOVE→位置校正 / points_changed+vitals_changed+target_info→HUD。hud.gd 加 set_vitals/set_exp/set_level/set_target/clear_target。game_scene.gd(简版)把 world+player+camera+pc+net_world+hud+net_play+audio+lifecycle 拼起来,login.gd 的 entered_game(--auto 自动选槽)实例化。netplay_test.gd + gamescene_test.gd headless 全过 |
project/{net_play,game_scene,hud,login}.gd |
| P1 UI 工具层 | ✅ ui/uiscript.gd 解析原始 uiscript/*.py(子集解释器,80/80 真实文件通过)+ ui/ui_build.gd dict→Control 树(~20 种 type)+ ui/ui_assets.gd(.sub/tga/png)+ ui/ui_manager.gd(窗口栈 / ESC / modal / 拖动)+ ui/dialogs.gd。ui_test.gd 全过 |
project/ui/*.gd |
| P2 物品数据层 | 🟡 wire.h:GC_ITEM_SET/DEL/UPDATE/USE/GROUND_ADD/GROUND_DEL/GET + CG_ITEM_MOVE/USE/DROP/PICKUP(全 static_assert)。EntityStore:inventory[90] / equipment[11] / ground map + drain_inv/ground/item_events。M2Client:get_inventory/equipment/item/ground_items + move_item/use_item/drop_item/pickup_item + 5 信号。新 Metin2Proto 节点(item(vnum)/mob(vnum),载 5748 真实 item)。⬜ 背包 / 装备窗 UI + 穿脱换模型 |
extension/src/{net/wire.h,net/entity_store.*,net/game_client.h,net/m2_client.*,proto/proto_node.*} |
| P3 聊天 | ✅ wire.h GC_WHISPER/CG_WHISPER + EntityStore whisper 解析(ChatMsg 加 from/sub)+ M2Client.whisper() + whisper_received 信号。ui/chat_ui.gd:4 标签 + 前缀 /w//g//p//s + 系统/战斗日志。net_world._bubble 头顶气泡 + main_bubble。chat_test.gd 全过 |
extension/src/net/{wire.h,entity_store.*,game_client.h,m2_client.*} · project/ui/chat_ui.gd |
| P4 战斗表现 | 🟡 wire.h GC_AFFECT_ADD/REMOVE + EntityStore affect map + M2Client.get_affects() / affect_added·affect_removed。受击硬直(net_play + pc.frozen)· combo(motion 递增)· 相机抖动(game_camera.shake)· 死亡窗(ui/death_ui.gd → /restart_here·/restart_town)· hud.set_affects 图标条。combat_fx_test.gd 全过。⬜ 武器拖尾 / 相机锁定 |
extension/src/net/* · project/{net_play,game_camera,hud}.gd · project/ui/death_ui.gd |
| P5 特效引擎(首版) | 🟡 fx/mse.gd(.mse 花括号树解析,真文件 6 particle)+ fx/effect_player.gd(Particle→GPUParticles3D:发射形状/速率/寿命/曲线/颜色渐变/billboard/加法混合)+ fx/effect_registry.gd(名字→.mse + 缓存 + spawn)。wire.h GC_SPECIAL/SPECIFIC_EFFECT + M2Client.effect_cue 信号 → game_scene 在实体上播。fx_test.gd 全过。⬜ .dds 纹理 / .mde mesh / 绑骨骼 |
extension/src/net/* · project/fx/*.gd |
| P6 技能系统(首版) | 🟢 wire.h GC_SKILL_LEVEL/COOLTIME_END/CREATE_FLY + EntityStore skill 表 + M2Client.get_skills/cast_skill/skill_up + skills_changed/skill_cooldown_end/fly_cue 信号。ui/skill_table.gd(skilldesc.txt)+ ui/skill_ui.gd(K 键,加点)+ ui/quickbar.gd(1-8 施放 + 冷却)+ net_world 飞行球。skill_test.gd 全过 |
extension/src/net/* · project/ui/{skill_table,skill_ui,quickbar}.gd |
| P7 任务 / NPC(首版) | 🟡 wire.h GC_SCRIPT/GC_QUEST_CONFIRM/GC_QUEST_INFO + CG_ON_CLICK/SCRIPT_ANSWER/SCRIPT_BUTTON/QUEST_INPUT_STRING/QUEST_CONFIRM(全 static_assert)。EntityStore:ScriptCue/ConfirmCue/QuestInfo map + drain_scripts/confirms/quest_changes + quest(idx)。Entity 加 ch_type。M2Client:click_npc/script_answer/script_button/quest_input/quest_confirm/get_quests + script_dialog/quest_confirm_ask/quest_info 信号。ui/quest_dialog.gd(EventManager 脚本渲染:[ENTER]/[QUESTION]/[DONE] → 文本+选项+确认)+ ui/quest_log.gd(J 键)。net_play._on_pick NPC 分流。quest 逻辑纯服务器驱动(选型②)。quest_test.gd 全过。⬜ [INPUT] 输入 UI / 选道具 / 立绘 / 世界任务箭头 |
extension/src/net/* · project/ui/{quest_dialog,quest_log}.gd · project/net_play.gd |
| charselect 串场 | ⬜ P10 | — |
| P8 社交 / 商店 / 仓库(首版) | 🟡 wire.h GC_PARTY_*(0x0710-16) / GC_MESSENGER(0x0741) / GC_SHOP(0x0810) / GC_EXCHANGE(0x051C) / GC_SAFEBOX_*(0x0830-34) + 对应 CG(全 static_assert;ShopItem/GCExchange/GCSubHeader 等)。EntityStore:PartyMember map / Friend map / ShopEntry 表 / ExchangeState / m_safebox[135] + dirty 标志 + drain_party_invites/drain_shop_errors。M2Client:party_invite/answer/leave · add/remove_friend · shop_buy/sell/close · exchange_start/add_item/add_gold/accept/cancel · safebox_checkin/checkout/move + get_party/friends/shop_items/exchange/safebox_items + 8 信号。ui/{party_ui,friend_ui,shop_ui,exchange_ui,safebox_ui}.gd(队员面板常驻 / O 键好友 / 商店·交易·仓库随信号自动开)。inventory_ui 右键分流(商店卖 / 交易放 / 仓库存)。p8_test.gd + net.entity_store / net.loopback_flow 扩测全过。⬜ 公会(PythonGuild 40 法 + Mark 下载)、精炼 / 强化 / 龙魂、私人商店 / 拍卖、交易道具图标网格 |
extension/src/net/* · project/ui/{party,friend,shop,exchange,safebox}_ui.gd · project/game_scene.gd |
| 公会 / 精炼 / 龙魂精炼 / 会徽下载(首版) | 🟡 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));GC_DRAGON_SOUL_REFINE(0x051F)/CG(0x050D grid[15]) + DS_SUB_* + EntityStore.DragonSoulCue/m_dragon_soul[180] + M2Client.ds_refine(mode,cells)/ds_window_open/ds_refine_result + ui/dragon_soul_ui.gd(L / 自动弹,3 模式 + 15 格背包右键填)。会徽:MARK_* 独立连接(GC_MARK_IDXLIST/BLOCK 0x0C1x、GC_MARK_UPDATE 0x0B15、CG_MARK_LOGIN/IDXLIST/CRCLIST 0x0C0x);NetStream 加 on_cipher_active()/on_raw()(u32 buf_size 分帧);mark_image.{h,cpp} MarkImageSet(LZO1X → 512×512 → mark_pixels)+ mark_client.h MarkClient;M2Client.download_guild_marks/get_guild_mark_image→Image;serverinfo mark_port 第 8 列;app_flow 进游戏连 + guild_mark_updated 冷却重连。公会战 + 技能页:GUILD_GC_SKILL_INFO(12) GuildSkillInfoBody(17)、WAR(15) GCGuildWar(10)、GUILD_WAR_LIST/END_LIST(17/18) GuildWarPair(8)、WAR_POINT(19) GuildWarPoint(12)、NAME(16) GuildNameEntry(16);EntityStore GuildSkillState/GuildWarStatus/m_guild_wars/m_guild_names + drain_guild_war_events/_scores;GameClient.send_guild_use_skill;M2Client get_guild_skill/get_guild_wars/get_guild_war/use_guild_skill/declare_guild_war(/war 命令) + guild_skill_changed/guild_war_changed/guild_war_event/guild_war_point 信号;ui/guild_ui.gd 改三页 Tab(成员/技能/公会战)。会徽上传:CG_MARK_UPLOAD(0x0C03) CGMarkUpload(776)、CG_GUILD_SYMBOL_UPLOAD(0x0722) CGSymbolUpload(8)+文件字节;NetStream.send_pending();MarkClient Mode(Download/UploadMark/UploadSymbol) + upload_done();M2Client.upload_guild_mark(转 RGBA8/缩放)/upload_guild_symbol/get_mark_server + guild_mark_uploaded;guild_ui 成员页「上传会徽」按钮。net.loopback_flow 加 MockServer::Mode::Mark 真 socket 端到端校验(KX → CG_MARK_LOGIN → CG_MARK_UPLOAD 768 字节校验和 → upload_done)。guild_refine_test.gd + dragon_soul_test.gd + guild_mark_test.gd + guild_war_skill_test.gd + net.entity_store + net.guild_mark + net.loopback_flow(CTest) 全过。真机验证(2026-08-31,net_e2e):GUILD_GC_SKILL_INFO(sub 12) 服务器进游戏自动推送,17B body 解析正确(skill_point=19 / guild_point 0/2300 / 12 等级);GUILD_GC_WAR(sub 15) 无战状态解析正确;trace 显示 GC_GUILD(0x0730) 多个子包 len=171/40/83/22/21/9/5 均无 unknown / parse 错误。会徽独立连接 + 会徽上传仍未接真机(联调服未开会徽服端口)。⬜ 龙魂属性表、公会等级/日志页 |
extension/src/net/* · project/ui/{guild,refine,dragon_soul}_ui.gd · project/{net/serverinfo,app_flow}.gd |
| 私人商店 / 道具商城 / Cube(首版) | 🟡 wire.h:CG_MYSHOP(0x0802) CGMyShopHead(38,char sign[33]+u8 count) + MyShopItem(13,u32 vnum,u8 count,ItemPos,u32 price,u8 display_pos)×N;CG_MALL_CHECKOUT(0x0840) CGMallCheckout(8);GC_MALL_OPEN(0x0841) GCMallOpen(5)、GC_MALL_SET(0x0842)/GC_MALL_DEL(0x0843) 复用 GCItemSet/GCItemDel,全 static_assert。GameClient:send_open_private_shop(sign, vector<MyShopItem>)(打包头+N项)/ send_close_private_shop(=SHOP_CG_END) / send_mall_checkout / `send_cube_make |
material_info |
| P9 世界系统(首版) | 🟡 wire.h GCWarp(18) / GCTime(12) / GCChannel(5) / GCNPCPosition(6+78×n) / GCTargetCreate/Update/Delete(46/16/8) / GCMount(21),全 static_assert。EntityStore:WarpCue + drain_warps、server_time/channel + dirty、m_npc_marks / m_markers map、Entity.mount_vnum + drain_mount_changes。M2Client:get_channel/get_server_time/get_npc_marks/get_world_markers + warp/time_changed/channel_changed/npc_marks_changed/world_markers_changed/mount_changed 信号。ui/minimap.gd(右上圆形,北朝上,实体/NPC/任务标记 + 玩家箭头 + CH 角标)+ world/world_time.gd(epoch → 太阳角度/光色/ambient 昼夜)+ fx/weather.gd(雪/雨 GPUParticles 跟相机)。game_scene._on_warp 同服挪人。p9_test.gd + net.entity_store 扩测全过 |
extension/src/net/* · project/ui/minimap.gd · project/world/world_time.gd · project/fx/weather.gd · project/game_scene.gd |
| P10 网络补全(首版) | 🟢 wire.h CGClientVersion(0x000D,70B)。GameClient 收 GC_MAIN_CHARACTER → 自动发 CG_CLIENT_VERSION = 打通 LOADING→PHASE_GAME(真机验证进游戏 + 121 实体)。net/serverinfo.gd(class_name ServerInfo,TSV 服务器表 + 频道端口偏移)+ ui/loading_screen.gd(相位驱动读取遮罩)+ ui/reconnect_ui.gd(断线倒计时 + 自动/手动重连)+ app_flow.gd(LOGIN→SELECT→GAME 串场状态机,单 M2Client 贯穿)。attack CRC 全 0 实测服务器接受。net_e2e 加 move+attack poke;net.loopback_flow 断言自动版本报文;p10_test.gd 全过 |
extension/src/net/{wire.h,game_client.h} · extension/tools/net_e2e.cpp · project/{app_flow,net/serverinfo}.gd · project/ui/{loading_screen,reconnect_ui}.gd |
| 大地图窗 / 地形贴花 / 钓鱼 / 副本 / 坐骑换模型 / 跨服换图 / 频道人数轮询 / 真机字节校准 | ⬜ 见 CLIENT-ROADMAP.md P9·P10 剩余 |
— |
物品 / 装备(item_proto + 背包封包 + 装备挂模型 → 解锁 §2.7 高光 / §2.3 时装) |
⬜ | — |
3. 连通性(2026-08-30:真机端到端打通到 PHASE_GAME)
服务端 = m2dev-fork 协议。net_e2e(extension/tools/net_e2e.cpp,build/extension/net_e2e)
驱动 auth→game→选人→进游戏全程;MT_NET_TRACE=1 打印每包 header/length/consumed。
真机结果 —— 全程打通:
- ✅ auth 握手(libsodium KX)+
CG_LOGIN3→GC_AUTH_SUCCESS - ✅ game
CG_LOGIN2→GC_LOGIN_SUCCESS4(0x0105,4 槽) 角色列表([SA]Adminlv105) - ✅
CG_CHARACTER_SELECT→GC_PHASE(LOADING)→ 整包洪流全部按正确字节解析 - ✅
GC_MAIN_CHARACTER后自动发CG_CLIENT_VERSION(0x000D) →GC_PHASE(GAME)/ IN GAME - ✅ 进游戏后:121 个实体、HP 14990/15178、level 105、20 格背包、好友列表 1 人
- ✅
CG_MOVE+CG_ATTACK(CRC 全 0)保持连接不被踢 → 不需要占位 CRC
修的 3 个真 bug(对任何真服务器都必需):
GC_LOGIN_SUCCESS4(0x0105) 没处理(只认 3 槽的)→ 加GCLoginSuccess4(492B) + handler。- 帧分发器 "did not consume" 误判:连续同 header 的包(一串
GC_PLAYER_POINT_CHANGE)会误杀连接 → 改成按实际消费字节数判定(net_stream.cppdispatch())。 - 缺
CG_CLIENT_VERSION:服务器收到GC_MAIN_CHARACTER后等客户端回版本报文,缺它就在 phase 4 断线 →GameClient收到GC_MAIN_CHARACTER时自动发(fork 固定 timestamp1215955205)。
真机字节校准(2026-08-30,已做) —— 用 MT_NET_DUMP=1 net_e2e 抓真实封包逐字节对:
- 实体名 / 等级 / 公会 / 骑乘 ✅ 走
GC_CHAR_ADD_INFO(0x0207,TPacketGCCharacterAdditionalInfo97B),之前根本没处理。加GCCharAddInfostruct + handler,NPC「Old Man」(race 20009) 名字正确出。 GC_CHARACTER_UPDATE(0x0209,38B) ✅ 之前只粗解 parts;现按TPacketGCCharacterUpdate全解 (parts / guild / alignment / pk_mode / mount,GCCharacterUpdatestruct)。ch_type:GC_CHARACTER_ADD.bType对 NPC/怪都是 0(服务器不填)。改在 GDScript 按race查mob_proto.type分类(net_play._entity_kind:0 怪 / 1 NPC / 2 石头 / 3 warp),_entity_name怪名回退mob_proto.locale_name。game_scene现在也载mob_proto。GC_PLAYER_POINTS(0x0214,1024B) —— 本来就对的。真机 gold=13.5 亿(GM 号)、exp=0、 next_exp=int32 溢出为负都是服务器真实值。M2Client.get_points把 exp/next_exp 按 unsigned 输出。
第二轮校准(2026-08-30,已做) —— 又抓了 in-game 洪流里没对过的包:
GC_SKILL_LEVEL_NEW(0x021B,1534B) ✅ 这才是本服发的技能包(没发老的 0x021A),之前get_skills()永远空、K 键技能窗没内容、快捷栏没法施放。加PlayerSkill{u8 master, u8 level, u32 next_read}(6B/条,fork 的time_t是 32 位)+GCSkillLevelNew+ handler。真机实测:33 个 技能全 lvl 40 master 3(GM 号)。get_skills()加master(0 普/1 M/2 G/3 P)。GC_QUICKSLOT_ADD/DEL/SWAP(0x0519/1A/1B) ✅ 之前没处理 → 快捷栏进游戏是空的。加QuickSlot{u8 type,u8 position}+ 3 个 handler +M2Client.get_quickslots()+quickslots_changed。ui/quickbar.gd.restore_from_server()按服务器内容填 1-8 号格(type 1 道具 / 2 技能)。真机 10 格。GC_NPC_POSITION(0x0A50) —— struct 本来就对(6 + 78×n,name[65])。真机 49 条 NPC,名字对 (服务器不 zero-pad name buffer,strnlen已兜住)。GC_ITEM_UPDATE(0x0514,41B) /GC_CHAT(0x0603,10B 头) /GC_AFFECT_ADD(0x0A20,25B) /GC_SPECIAL_EFFECT(0x0A30,9B) /GC_QUEST_INFO(0x0912,7B) —— 逐字节核对,全部已对。GC_GUILD(0x0730) sub-header 包(grade/info/list)已解析(见 M4 行);会徽走MARK_*独立连接。
校准后续(2026-08-30,已做):
-
3D 名字标签刷新 ✅
EntityStore加ChangeKind::Info;GC_CHAR_ADD_INFO到达时对已存在实体 推Info变更 →M2Client.entity_info(vid, dict)信号 →net_world._on_info刷 Label3D。net_world.name_resolver(game_scene设为net_play._entity_name)让怪名走mob_proto。 -
跨服换图
GC_WARPaddr≠0 ✅M2Client:WarpCue.addr是网络序 IPv4,转点分十进制后warp_to_game_server(host, port)—— 拆掉当前 game 连接、复用存下的login_key直连新 game server(跳过 auth)。同服 warp 仍只挪玩家。 -
断点快连 ✅
M2Client.reconnect()现在优先用last_login_key直连 game server(省一次 auth 往返),失败再走完整 auth→game。服务器重发 spawn = 断点续连。 -
频道负载查询 ✅
net/channel_status.gd(对齐CServerStateChecker):明文 TCP 连任一频道口 → 发CG_STATE_CHECKER(0x000F,len 4) → 跳过杂包到GC_RESPOND_CHANNELSTATUS(0x0010) → 读int32 count+ count×{i16 port, u8 status}。status:0 关 / 1 正常 / 2 拥挤 / 3 爆满 (Metin2 协议里就是 4 态,不是在线人数)。app_flow._probe_channels先查状态、没答上的频道回退_probe_tcp,下拉项标 ●/◐/✕。真机实测:192.168.21.203:11011回{11011:1, 11012:1, 11013:1, 11991:1}—— 也据此把serverinfo的频道端口步长从 +10 改成 +1(可在 serverlist.txt 第 7 列port_step按服覆盖)。channel_status_test(进程内 mock 服务器:请求字节 + 跳杂包 + 解析 + 不可达返回空)。 -
怪 / NPC 真 3D 模型 ✅
ui/mob_view.gd(对齐player_view.gd):race=mob_protovnum →root/npclist.txt(<vnum>\t<代号>\t…,如113→bear_brown、185→tiger_big、102→wolf; 翻译版mob_proto把szName也本地化成 "Brown Bear" 了,解不出目录,必须靠这张表,mob_proto名仅作兜底)→ 目录按代号逐段回退(bear_brown → bear),网格 / 贴图用完整代号 (换肤网格都在基础目录里:bear/bear_brown.gr2+bear/bear_brown.dds)→ 进Metin2Model(texture_dir = 目录)+ 解motlist.txt→Metin2AnimPlayer。扫<assets>/*/ymir work/{monster,monster2,npc,npc2}/<folder>/。npclist 解析一次进static缓存。set_anim_state("wait/run/attack/damage/dead/skill")按 motlist 动作名映射。game_scene._make_entity_model设为net_world的默认工厂(非主角 + 有 mob_proto 时);race→是否有模型 缓存,失败回退占位胶囊。net_world._attach_nameplate抽出来,真模型也挂头顶名字 + HP 条。mob_view_test:race 102 Wolf →wolf/目录、Metin2Model(40 骨 762 顶点)、motlist ≥3 动作、状态切换。 实测进游戏「Alpha Grey Wolf」「Hungry Black Bear」等出真模型 + 贴图 + 动作 + 名字牌 / 血条。 -
重连 / 已在局内时补拉实体 ✅
game_scene.setup()现在是协程(分帧避免阻塞漏 PONG),net_world.setup()接entity_spawned已晚于M2Client的初始 spawn 洪流 →_on_spawn0 次触发。 修:net_world.catch_up()遍历client.get_entities()补 spawn 未镜像的;game_scene已在局内分支里 先net_world.set_local_vid(get_main_vid())+net_play._on_main_set(get_main_vid())再catch_up(),否则本地玩家会被画成一个压在身上的大蓝胶囊。
剩的都不是代码问题:字面「在线人数」(数字)Metin2 协议里没有 —— bStatus 就是 4 态负载指示,
要真数字得改服务器;本地 assets/ 只有 ~85 个 monster 目录(不是全量),没目录的 race
仍是占位胶囊 —— 换全量资产包即可全部出真模型。
联调路上修的两个真 bug(对任何真实服务器都必需):
GC_LOGIN_SUCCESS4(0x0105) 没处理 —— 只认 3 槽的GC_LOGIN_SUCCESS3。已加GCLoginSuccess4结构(492B) + handler。- 帧分发器误杀连接 —— 连续同 header 的封包(如一串
GC_PLAYER_POINT_CHANGE)会触发 "on_packet did not consume" 误判并断线。改成按实际消费字节数判定 (net_stream.cppdispatch())。此 bug 之前会让 P0–P8 对任何真服务器的端到端全挂。
当前状态:P0–P8 的封包层仍以合成封包 CTest + FakeClient GDScript 测试离线验证(14 套 GDScript + 9 CTest 全绿)。真机端到端到 PHASE_GAME 的最后一跳留给 P10。
3b. B 轨(不依赖服务端的客户端系统)
| 模块 | 状态 | 位置 |
|---|---|---|
| eterpack 读写器(G1) | ✅ 本 fork = PackLib 单文件包(header + XChaCha20 加密索引 + zstd 数据 + 可选逐文件加密,硬编码 PACK_KEY)。EterPack(读,name-field 从 header 反推兼容真实包)+ write_pack + packtool CLI。pack.roundtrip CTest + 真实 textureset/ 45 文件明文/加密往返 diff 一致 |
extension/src/pack/eterpack.{h,cpp}、extension/tools/packtool.cpp |
| 登录 / 选人界面(首版) | ✅ charselect.gd:3 旋转台 × 真实职业模型(<class>_novice.gr2 + wait.msa)+ 真实 board 九宫格面板(槽位按钮 + 进入游戏)。数据来自 M2Client.char_list(联机)或 --mock(离线开发)。login.gd:服务器/账号/密码 + 连接,接全部 M2Client 信号(--auto 测试)。ui_kit.gd:从 hud.gd 抽出的九宫格助手。⬜ 创建/删除角色、旋转台微调、login→charselect→world 串场 |
project/{login,charselect,ui_kit}.gd |
| 正式选人页(Metin2 风) | ✅ ui/char_select_screen.gd:app_flow._build_char_list 用它替掉原来的裸按钮列表。全屏背景 d:/ymir work/ui/intro/select/select.sub(=select.jpg)+ 右侧 SubViewport(own_world_3d)里 PlayerView 真模型(正面朝相机 + wait + 缓摆)+ 左上职业名(locale name_<class>.sub → select.dds,DDS 走 Metin2World.load_dds;解不出退中文描边字 猛将/刺客/术士/巫女)+ 左侧 board 信息板(帮会徽 get_guild_mark_image / 国家名 get_empire()→EMPIRE_A/B/C locale + 国旗 empireflag_<a/b/c>.sub / 帮会名 / 名称 / 等级 / 游戏时间 / 体力·智力·力量·敏捷 条)+ 开始 / 创建 / 删除 / 退出 + 多角色 ◀ ▶ 槽位切换(_pad_slots 按 get_slot_count() 补空槽,◀▶ 能走到空槽建号)。建号 / 删号(1:1 对齐 PythonNetworkStreamPhaseSelect.cpp):CG_CHARACTER_CREATE(0x0201) CGCreateCharacter(77,job 是 u16)、CG_CHARACTER_DELETE(0x0202) CGDeleteCharacter(13,private_code[8])、GC_PLAYER_CREATE_SUCCESS(0x020C) GCPlayerCreateSuccess(108={u8 slot, SimplePlayerInfo})、GC_PLAYER_CREATE_FAILURE(0x020D) {u8 type}、GC_PLAYER_DELETE_SUCCESS(0x020E) {u8 slot}、GC_PLAYER_DELETE_WRONG_SOCIAL_ID(0x020F)。GameClient:create_character/delete_character + drain_char_events()(CharEvent{CreateOk/CreateFail/DeleteOk/DeleteFail})+ replace_slot/clear_slot(服务器只补丁槽位不重发列表)+ slot_count()。M2Client:create_character(slot,name,job,shape,con,int,str,dex)/delete_character(slot,code)/get_empire/get_slot_count + char_created/char_create_failed/char_deleted/char_delete_failed 信号(建 / 删成功后重发 char_list)。选人页建号弹窗(职业下拉 + 名称 + 各职业起始四维只读)、删号弹窗(secret 删除码);app_flow._on_char_list 在 SELECT 态只 set_chars() 不整屏重建。会徽:app_flow._goto_select 也发 download_guild_marks,guild_marks_ready(one-shot) → refresh_crest()。相机改按包围球 r/sin(fovV/2) 自适应 + 沿 -X 推 r*0.5(横向视锥更宽,不切远侧手),蒙皮定型后多 fit 4 帧。C++:CharSlot + char_list dict 补 play_minutes/st/ht/dx/iq/main_part/hair_part。ui_assets.gd 加 load_dds_image()。net.loopback_flow MockServer 加 CG_CHARACTER_CREATE/DELETE 真 socket 端到端(名称字节校验 + 成功补丁槽位 + 名称冲突 fail + 错误删除码拒绝);char_create_delete_test.gd(弹窗 → 假 client 收 create_character(slot1,"Newbie",job1,con3/int3/str4/dex6) + 短名拒绝 + char_created 关窗 + 删号码 + 失败清框 + set_chars 保持选中);p10_test 选人断言改点「开始」 |
project/ui/char_select_screen.gd · project/ui/ui_assets.gd · project/app_flow.gd · extension/src/net/{wire.h,game_client.h,m2_client.{h,cpp}} · extension/tests/net_loopback_test.cpp |
| 角色状态窗(1:1 窗口迁移样板) | ✅ project/ui/char_status_ui.gd:UiScript+UiBuild 直接装载 assets/uiscript/uiscript/characterwindow.py 真布局(4 页 tab:STATUS/SKILL/EMOTICON/QUEST,SetState 切页;技能 / 表情 / 任务的数据仍走各自专用窗)。STATUS 页数值逐字对照 assets/root/uicharacter.py.RefreshStatus:Level_Value=points[LEVEL]、Exp_Value=u32(exp)、RestExp_Value=u32(next_exp)-u32(exp)、HP/SP_Value=cur/max、STR/DEX/HTH/INT_Value=points[ST/DX/HT/IQ]、ATT_Value=`(MIN_ATK |
MAX_ATK)+ATT_GRADE_BONUS+PARTY_ATT_GRADE(min==max 取单值,全 0 退 ATT_POWER)、DEF_Value=DEF_GRADE(+DEF_GRADE_BONUS 若非 0)、MATT_Value=MAGIC_ATT_GRADE+(MIN |
组队成员信息板(uiparty.PartyMemberInfoBoard) |
✅ wire.h:CGPartySetState{u16 hdr,u16 len,u32 pid,u8 role,u8 on}(static_assert 10)+ EPartyRole 枚举(NORMAL 0 / LEADER 1 / ATTACKER 2 / TANKER 3 / BUFFER 4 / SKILL_MASTER 5 / BERSERKER 6 / DEFENDER 7,PythonPlayer.h)。game_client.send_party_set_state(pid,role,on)(CG_PARTY_SET_STATE 0x0704,注意结构字段名 dwVID 实际传 PID —— 对齐 uiparty.py OnSelectState 的 net.SendPartySetStatePacket(self.pid,...))。M2Client.party_set_state(pid,role,on) + bind;get_party() dict 补 state(完整角色字节,bit0=leader)+ affects[7](int×7)。project/ui/party_ui.gd 从「名字+一条 HP」重做成 partymemberinfoboard.py 布局(每员 106×36 条:StateButton 22px 角色图标 + NameSlot + Gauge + 附加图标行):_strip(m) → 角色状态按钮(_local_is_leader() 才可点 → _open_role_popup 弹 普通/攻击/坦克/狂战/辅助/宗师/防御 + 踢出;普通=party_set_state(pid, cur_role, false) 清角色;踢出=party_leave(pid)=SendPartyRemovePacket)+ 名字(★队长) + HP ProgressBar + affects[i]!=0 → chip(AFFECT_LABEL 暂定 经验/攻击/防御/辅助/宗师/时间/回复,tooltip 带值)。点名字 → set_target(vid)(OnMouseLeftButtonDown 选中)。header:EXP 分配开关 _toggle_distribute → party_set_distribute(EPartyExpDistributionType 0 NON_PARITY / 1 PARITY)+ 组队治疗 _party_heal → party_use_skill(PARTY_SKILL_HEAL=1, 0)。_local_is_leader() = get_party() 里 vid == get_main_vid() 那条的 leader。角色菜单按党技能等级门控(uiparty.__ShowStateButton Tanker≥10…Defender≥40)暂略。p8_test 组队段扩:affect chip 渲染 + 队长点 StateButton 弹菜单 + 「攻击」→ party_set_state(pid, 2, true) + 踢出 → party_leave + 分配切换 → party_set_distribute(1) + 治疗 → party_use_skill(1,0) + 点名字 → set_target + 非队长 StateButton disabled。顺带修 p8_test._init 未 await _run() 的隐患(第一个 await 之后所有断言从未生效,含增量 34 shop_ex)→ 改 await 后暴露 shop_ui.refresh() queue_free 延迟 bug(旧 row 当帧未消失),改 remove_child+queue_free |
extension/src/net/{wire.h,game_client.h,m2_client.{h,cpp}} · project/ui/party_ui.gd · project/ui/shop_ui.gd · project/p8_test.gd |
ESC 系统菜单 + 游戏设置窗(uisystem.SystemDialog + uigameoption.OptionDialog) |
✅ project/ui/system_menu_ui.gd(systemdialog.py 真 uiscript,thinboard 8 键)= uisystem.py __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() 在有 ping 时发 SendChatPacket("/phase_select")),logout_button→"/logout"(net.LogOutGame()),mall_button→"/in_game_mall",exit_button→get_tree().quit(),help_button→占位 toast,cancel_button/标题栏→关。modal 打开。game_scene:ESC 从直接 system_option_ui.toggle() 改成 system_menu_ui.toggle()。project/ui/game_option_ui.gd(gameoptiondialog.py 真 uiscript)= uigameoption.py:BLOCK_BITS = block_{exchange,party,guild,whisper,friend,party_request}_button(toggle_button) → _toggle_block(bit):_block_mode ^= bit + client.say(0, "/setblockmode %d" % _block_mode)(Packet.h EBlockAction 位 1<<0..1<<5,逐字对照 __OnClickBlock* 的 net.SendChatPacket("/setblockmode " + str(blockMode ^ player.BLOCK_*)))+ 持久化 [gameopt] block_mode(服务器回包同步待补)。PK_CMD = pvp_{peace,revenge,free,guild}(radio) → client.say(0,"/pkmode %d") 值 {0,1,2,4}(__OnClickPvPMode*;guild 是 /pkmode 4,PK_MODE_PROTECT=3 跳过)。DISPLAY_RADIOS:name_color/target_board/view_chat/always_show_name/show_damage/salestext 的 on/off/normal/empire radio → _display[key] + 持久化 user://system_option.cfg [gameopt](等价 systemSetting.Set*Flag;渲染侧钩子——头顶名字配色 / 伤害数字 / 聊天显隐——待补)。_bind_radio 处理 radio 배타성,_sync/_sync_block 开窗回填,_relabel 中文兜底(uiScriptLocale 未接全局 Locale)。system_menu_ui_test.gd:两窗装载 + /setblockmode 1→3→2(位 XOR)+ _block_mode 跟踪 + /pkmode 1/4 + radio 배타 + 显示开关落 cfg + 二次实例读 block_mode 回填按钮态 + menu 各键 /in_game_mall//phase_select//logout + system_option_button/game_option_button 开子窗关菜单 + cancel 关。C++ 无改动 |
project/ui/system_menu_ui.gd · project/ui/game_option_ui.gd · project/game_scene.gd · project/system_menu_ui_test.gd |
系统设置窗(uisystemoption.OptionDialog) |
✅ project/ui/system_option_ui.gd:UiScript+UiBuild 装 assets/uiscript/uiscript/systemoptiondialog.py(board + titlebar + music/sound_volume_controller sliderbar + camera_short/long + fog_level0/1/2 + tiling_cpu/gpu + tiling_apply + bgm_button)。控件绑定逐字对照 assets/root/uisystemoption.py:OnChangeMusicVolume→snd.SetMusicVolume+systemSetting.SetMusicVolume(接 Audio.master_bgm + 即时改在播 _bgm[_bgm_cur].volume_db),OnChangeSoundVolume→Audio.master_sfx(play_ui/play_at 每次读),camera_short/long→game_camera.max_dist(CAMERA_MAX=[11,20],并 dist=min(dist,max)),fog_level0/1/2(浓/중/淡)→__SetFogLevel = Environment.fog_enabled=true+fog_density=[0.055,0.018,0.004][i],tiling_*/tiling_apply(原 CPU/GPU 分块渲染 + net.ExitGame 重启)Godot 渲染器无对应 → 保留占位。_bind_radio 处理 radio 배타성。持久化 user://system_option.cfg(section audio/video,= systemSetting 配置文件);setup() 读回并 _apply_all() 即时生效;game_scene._on_main_set 在 .msenv 换 Environment 后再 _apply_all()。game_scene:ESC 呼出(ui_manager._unhandled_input 先 close_top() 吃事件,无窗时才落到 game_scene)。uiScriptLocale 未接全局 Locale → LABELS 中文兜底。system_option_ui_test.gd:装载 + music/sound slider value_changed 已连 + _on_*_volume 应用到 master_bgm/sfx + camera radio→max_dist+dist 夹 + fog radio→fog_density + cfg 落盘 + 二次实例读 cfg 即时应用。C++ 无改动 |
project/ui/system_option_ui.gd · project/game_scene.gd · project/system_option_ui_test.gd |
私人商店开设窗(PrivateShopBuilder 模型) |
✅ project/ui/private_shop_ui.gd 从「前 5 行 checkbox」重做成 1:1 复刻 assets/root/uiprivateshopbuilder.py(增量 36:布局改走真 uiscript privateshopbuilder.py —— board + TitleBar + NameLine(盖 LineEdit) + ItemSlot 5×8=40 grid + Ok/Close;背包候选面板挂窗右侧):_stock = itemStock({格号 -> {cell,vnum,count,price}})。左侧 _inv_list 背包候选(排除已上货的 cell),点一件 → _pick 设 _picked(= mouseModule attached item);点右侧 40 格(GRID_SLOTS=40 = shop.SHOP_SLOT_COUNT,5×8)空格 → _ask_price(uiCommon.MoneyInputDialog,SpinBox)→ _place(slot, price) 写 _stock(= AddPrivateShopItemStock + itemStock[targetSlotPos]=(src));点已占用格 → _stock.erase (= OnSelectItemSlot / DelPrivateShopItemStock)。_ok()(OkButton → BuildPrivateShop):_stock 按格号 sort()、display_pos = 格号、items.size() >= PRIVATE_SHOP_ITEM_MAX(39) 截断 → client.open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])(MyShopItem/TShopItemTable 字段 1:1:{u32 vnum, u8 count, ItemPos pos, u32 price, u8 display_pos})。_close_shop() = close_private_shop() = SHOP_CG_END。测试拆成独立 private_shop_ui_test.gd(真 uiscript,缺资产跳过):_cells 40 格 / NameLine 盖 LineEdit / 2 候选 / 空 stock 不发 / 拿起→价格弹窗→_place / 개설按格号排序(格 0 在格 3 前)+ display_pos / 点占用格撤下 / 철수;shop_cube_mall_test 私人商店段移除。C++ 无改动(open_private_shop / MyShopItem 早已就位) |
project/ui/private_shop_ui.gd · project/game_scene.gd · project/private_shop_ui_test.gd · project/shop_cube_mall_test.gd |
SHOP_GC_START_EX 多货架商店 |
✅ wire.h:ShopTabHead{char name[SHOP_TAB_NAME_MAX=32]; u8 coin_type}(static_assert 33)+ SHOP_TAB_COUNT_MAX=3。entity_store:SHOP_GC_START_EX(10) 解析 TPacketGCShopStartEx{u32 owner_vid; u8 tab_count} + tab_count × {ShopTabHead, ShopItem[SHOP_HOST_ITEM_MAX_NUM=40]}(对齐 PythonNetworkStreamPhaseGame.cpp RecvShopSub_StartEx)。ShopEntry 加 u8 pos(货架内槽位);ShopTab{name, coin_type, items} + m_shop_tabs;SHOP_GC_START 也改为按固定 40-槽数组填 pos(空槽计入,与参考 SetItemData(j,...) 一致);SHOP_GC_END 清 m_shop_tabs。shop_items() 保持是 m_shop_tabs[0].items 的镜像,旧 get_shop_items() 不动。M2Client.get_shop() → {vid, open, tabs:[{name, coin_type, items:[{pos,vnum,price,count}]}]}。shop_ui.gd:tabs.size()>1 时显示货架 Button 行,切货架重建列表;买位置 = _active_tab * SHOP_SLOT_COUNT(40) + it.pos(1:1 对齐 uishop.py GetIndexFromSlotPos = tabIdx * shop.SHOP_SLOT_COUNT + slotPos,shop_buy 的 u8 pos 容得下 3×40)。net_entity_test:双货架合成包 → 断言名称 / coin_type / 槽位保留 / tab0 镜像 / END 清空;p8_test:多货架 UI + 切页 + buy pos 5(tab0 slot5)/ buy pos 43(tab1 slot3) |
extension/src/net/{wire.h,entity_store.{h,cpp},m2_client.{h,cpp}} · project/ui/shop_ui.gd · extension/tests/net_entity_test.cpp · project/p8_test.gd |
| 选魔石窗(样板第 2 例) | ✅ project/ui/select_item_ui.gd:UiScript+UiBuild 装 assets/uiscript/uiscript/selectitemwindow.py(ItemSlot = 5×8 grid_table,ui_build._slot_cells 按 x_count/start_index/x_step/y_step 生成 slot_0..39)。填格逐字照 assets/root/uiselectitem.py.RefreshSlot:遍历背包 range(INVENTORY_PAGE_SIZE*2=90) 格,_is_metin(proto.item(vnum).type == 10 = ITEM_TYPE_METIN)+ _item_grade(vnum) <= 2(= proto.item(vnum).name 内部名最后一位数字,非数字按 0,复刻 PythonPlayerModule.GetItemGrade),slotPos > 54 截断,按背包格号升序映射到选择窗格 _slot_to_inv[slotPos]=invCell。点格 → client.script_select_item(invCell)(send_script_select_item → CGScriptSelectItem{CG_SCRIPT_SELECT_ITEM 0x0903, len, selection},= net.SendSelectItemPacket)+ 关窗;ExitButton / 标题栏关闭 → script_select_item(0)(uiselectitem.Close),_sent 标记避免选完再补发 0。触发链 1:1:quest_dialog.gd.parse_script 新识别 EventManager [SELECT_ITEM] token → has_select_item → _on_script 发新 select_item_requested 信号(对应 PythonEventManager EVENT_TYPE_SELECT_ITEM → interfacemodule.BINARY_OpenSelectItemWindow → wndItemSelect.Open());game_scene 在 proto/item_list 就绪后建 select_item_ui 并把该信号接到 .open。select_item_ui_test.gd:[SELECT_ITEM] token 解析 + 非 metin / grade>2 过滤 + 升序映射 + 点格发 script_select_item(7) + 关窗不补发 + 空手关窗发 script_select_item(0) |
project/ui/select_item_ui.gd · project/ui/quest_dialog.gd · project/game_scene.gd · project/select_item_ui_test.gd |
item_proto / mob_proto 读取器 |
✅ mtproto::load_proto —— 外层 MIPX(item:[fourcc][ver=1][stride][elements][datasize]) / MMPT(mob:[fourcc][elements][datasize]);内层 MCOZ CLZO 容器 = XChaCha20(本 fork 把 TEA 换成 libsodium:key=BLAKE2b(key16,"M2DevPackEncrypt"),nonce=BLAKE2b(key16,"M2DevNonce")[:24])解密 + LZO1X 解压。两把硬编码 4-DWORD key(ItemManager.cpp / PythonNonPlayer.cpp)。ItemRecord/MobRecord 解前导字段(bSpecular@234 → §2.7;alValues[6]@191 —— aLimits[2]×5 @166 + aApplies[3]×5 @176 后,bSpecular@234 锚定整链)。armor 的 alValues[3] = race .msm 的 body shape index(对齐客户端 __ArmorVnumToShape SHAPE_VALUE_SLOT_INDEX=3)—— 实测 "Monk Plate Armour" 11200-09 全 = shape 3,specular 随强化 0→100 递增。proto.item_mob CTest 断言 11209 values[3]==3 + specular==100。5748 items(stride 236)+ 1338 mobs(stride 335) |
extension/src/proto/proto.{h,cpp} |
AssetResolver 接包(首版) |
✅ mtpack::PackMount —— 挂载多个 .epk,按虚拟路径(盘符 + 反斜杠 + 大小写不敏感 + ymir work/ 后缀)resolve/read,metin2_patch_* 包后挂覆盖。mtpack::AssetSource —— 散文件 + 包统一:read() 取字节;to_path() 把包内文件解到缓存目录一次(路径型调用方不用改)。散文件优先于包(dev overlay,对齐客户端 pack/ 覆盖)。pack.roundtrip CTest 扩:2 包挂载 + 覆盖 + 虚拟路径 + 非 ymir 路径。⬜ 把 Metin2World/Metin2Model 等 ~6 处 resolver->resolve() 换成 AssetSource::to_path()(机械改动) |
extension/src/pack/{pack_mount,asset_source}.{h,cpp} |
| 输入 & 游戏相机(首版) | ✅ game_camera.gd = GameCamera:右键拖拽环绕(yaw/pitch clamp)+ 滚轮缩放(3.5–20m)+ 目标平滑跟随 + 地形防穿。player_controller.gd = PlayerController:点地移动(射线求地表交点 → 走过去,沿途 is_blocked(attr.atr bit0)判可行走,贴墙磨蹭按实际位移切回 wait —— 客户端预测,无服务端纠正)+ WASD 覆盖 + 选目标(射线取最近 pickable → target_selected 信号)+ anim_state/moved 信号。已换进 world_demo.gd(去掉 ~70 行临时相机/输入)。⬜ 相机撞建筑/树淡出(§8.2)、格子 A* 寻路、NPC 交互/拾取 |
project/{game_camera,player_controller}.gd |
| 本地化(首版) | ✅ locale.gd = Locale:加载 locale/locale/<lang>/{locale_interface,locale_game,itemdesc,skilldesc}.txt(KEY\tVALUE,UTF-8)。t(key, args) —— % 格式化 + 缺失回 <KEY>(开发期可见);set_lang();apply_font() 挂 CJK ttf。已接 charselect(JOB_* 职业名 / SELECT_LEVEL / SELECT_SELECT=Start / SELECT_CREATE)+ login(LOGIN_ID/LOGIN_PASSWORD/LOGIN_CONNECT)。18 语言(拉丁/西里尔/希腊/阿拉伯,Godot 原生,无需打包字体;CJK 需外挂 ttf) |
project/locale.gd |
| 声音(首版) | ✅ audio.gd = Audio 节点:BGM 交叉淡(2× AudioStreamPlayer,bgm/*.mp3)+ UI 音效池(8×,**/sound/ui/*.wav)+ 3D 定位音效(AudioStreamPlayer3D,**/sound/<rel>)。路径解析走 11 个声音根 + bgm/(.mss 不用,Godot 原生解 mp3/wav/ogg)。已接:charselect→characterselect BGM + <class>_select 选人音;login→loginok/loginfail;world_demo→Metin2AnimPlayer.motion_event 的 sound 字段 → play_at(model_pos)(脚步/挥击)。⬜ 按地图 BGM(服务端 GCMainCharacter.szBGMName)、.msenv 环境音循环、战斗/怪物/NPC 语音 |
project/audio.gd |
| 登录 / 选人界面 | 🟡 首版 | project/{login,charselect,ui_kit}.gd |
声音(AudioLib→Godot audio) |
🟡 首版 | project/audio.gd |
item_proto / mob_proto 读取器 |
✅ | extension/src/proto/proto.{h,cpp} |
本地化(EterLocale) |
✅ 首版 | project/locale.gd |
| 输入 & 游戏相机 | 🟡 首版 | project/{game_camera,player_controller}.gd |
AssetResolver 从包读取(接 eterpack) |
🟡 | extension/src/pack/{pack_mount,asset_source}.{h,cpp} |
3c. C 轨(渲染打磨,PARITY-GAP 不卡 §0 门禁的项)
| 项 | 状态 | 位置 |
|---|---|---|
| §8.2 相机遮挡淡出 + §8.3 相机撞建筑防穿 | ✅ 首版 | extension/src/metin2_world.cpp(层 2 盒碰撞)+ project/game_camera.gd |
| §2.2 GPU 路径发型 | ✅ | extension/src/metin2_model.{h,cpp} |
| §2.10 角色 LOD 距离切换 | ✅ 首版 | extension/src/metin2_model.{h,cpp} |
| §3.5 地形 patch 裁剪 | ✅ 首版(拆 4×4 patch + 逐 patch 视锥剔除 + 远景剔除) | extension/src/metin2_world.{h,cpp} |
| §2.6 顶点色 | ✅ 排查=不适用(全资产 gr2 无顶点色成员;逐顶点色是地形雾运行时 VB + .mse colorfactor) |
— |
§1.2 .mse EffectLib 端口 |
⬜(大工程) | — |
4. 下一步
本节 1–4 项已于 2026-08-30 全部完成(见 §3 与
CLIENT-ROADMAP.md的 P0–P10)。保留为当时的计划记录,不要再当作待办执行。
打通到✅。可达后立即能测:.203的连通性./build/extension/net_probe 192.168.21.203 11000 admin 123456789→ 完整握手 +CGLogin3→login_key- GDScript 里
M2Client.connect_to_server(...)→ 一路到char_list/entered_game
GCLoginSuccess3布局对真实字节校准:net_probe/M2Client收到后 dump 原始字节, 核对TSimplePlayerInformation(现假设 pack(1)、字段顺序照Packet.h:1161)。size 不符会走login_failed兜底。- 游戏相位包解码:
GC_CHARACTER_ADD/DEL/GC_MOVE/GC_CHARACTER_UPDATE→NetworkActorManager端口 → 每实体一个Metin2Model+Metin2AnimPlayer,用现有Metin2World当场景。里程碑「角色连上、站在地图里、别的实体在动」。 - 输入(点地移动 →
CG_MOVE)、聊天(CG_CHAT/GC_CHAT接 HUD)。
5. 风险
- 协议版本漂移:
Packet.h结构体要和服务端 build 一致。冻结一对 client/server commit。 端到端测试阻塞:目前握手端口 refused,走不到已解除(§3)。CGLogin3之前的验证。- 移动端 libsodium:Android/iOS 要静态 vendor(本身可移植,非阻塞)。
- 自用范围可跳过:商城 / GM 工具 / 部分社交系统。反作弊(
CG_HACK/ProcessCRC/ProcessScanner)已在CLIENT-GAP.md「不做清单」标注 ❌ 不做,除非服务端强制校验。
6. 移植面(客户端源码)
EterBase/SecureCipher · EterLib/{NetStream,ControlPackets,NetPacketHeaderMap} ·
UserInterface/{Packet.h, AccountConnector, PythonNetworkStream, PythonNetworkStreamPhase*} ·
UserInterface/NetworkActorManager(实体) · GameLib(玩法)。