Files
mtgodot-poc/docs/CLIENT-40250-PORT.md

55 KiB
Raw Permalink Blame History

迁移到 40250 服务端 —— 协议差异与移植方案

目标:网络层从 m2dev fork 协议 换成 40250 经典 Metin2 协议,连 Work/mt/40250/Server/。现有 m2dev 那套保留为可切换后端(MT_PROTOCOL=m2dev|classic), Godot 场景 / GDScript / UI / 渲染保持既有接口与布局;为接住 classic 的状态/命令,只做必要的 bridge、状态显示和资源映射补充。

参考源:

  • 40250 客户端:/Users/shenlei/Work/mt/40250/ClientVS22/source/ UserInterface/EterLib/NetStream.{h,cpp}EterBase/cipher.{h,cpp}
  • 40250 服务端:/Users/shenlei/Work/mt/40250/Server/metin2/src/server/ game/src/packet.hgame/src/packet_info.cppgame/src/desc.cpp(握手/阶段/cipher)、 game/src/input.cppCInputHandshake + sequence 校验)、game/src/input_auth.cppcommon/)。 auth 服 = 同一个 game 二进制跑 auth 模式(无独立源码目录);运行时目录 Server/metin2/server/{auth, game99, channel1..4, db},各带 CONFIG
  • m2dev(现有移植的对照):/Users/shenlei/Work/mt/m2dev-client-src-main/src/UserInterface/

当前范围调整(2026-09-01:本阶段只收口客户端本地协议、状态桥接、UI 首版和离线回归。 真实 FreeBSD + MySQL 40250 服务端的完整登录 / 功能验收暂缓,不作为本阶段交付阻塞;公会相关 功能暂缓。当前目标 40250 服务端不提供龙魂玩法,因此龙魂不纳入本阶段支持范围;代码中的兼容 分支不代表该服务端可用。


0. 一句话结论

改动 量级
网络层 extension/src/net/ classic 栈从零写(cipher / 分帧 / 握手 / ~200 结构体 / dispatch),m2dev 栈挪进子目录冻结 ~24 周)
EntityStore 拆出 mutation API,两个 parser 共用世界模型 中(12 天)
M2Client INetSession seam,方法转发,按 MT_PROTOCOL 选后端 小(1 天)
渲染 / formats / libgr2 / Godot 场景 / app_flow / HUD / UI 窗口 保持既有接口;按 40250 复核并补状态、命令和动作资源映射(不重写渲染底座) 小—中
前置:跑起 40250 服务端 独立一摊(FreeBSD game/auth/db + MySQL + proto 表 + config;见 §7.1;仓库已提供可执行脚本)

1. 封包头

1.1 宽度与风格

m2dev fork 40250 classic
头字段 uint16_t(小端),十六进制段(CG_MOVE = 0x0301 BYTE1 字节),十进制(HEADER_CG_MOVE = 7
定义位置 wire.h(我们自己维护) 客户端 UserInterface/Packet.hL30 起匿名 enumCG/GC/GG 混一起)服务端 game/src/packet.h 手工保持一致

⚠️ 客户端 Packet.h 的 header enum 有 #if defined(GAIDEN) 分支,40250 客户端构建未定义 GAIDENUserInterface.vcxproj PreprocessorDefinitions 只有 WIN32;_DEBUG;_WINDOWS;USE_LOD; _CRT_SECURE_NO_WARNINGS)。非 GAIDEN 分支里同一 header 值的名字和服务端不同: 服务端 HEADER_GC_MAIN_CHARACTER=113 ↔ 客户端非 GAIDEN 是 HEADER_GC_MAIN_CHARACTER2_EMPIRE=113 HEADER_GC_MAIN_CHARACTER=15 在客户端指旧包);SKILL_LEVEL / REFINE_INFORMATION / TARGET_CREATE 等也有 _OLD / _NEW / 2_ 后缀错位。移植时以服务端 game/src/packet.h 的值 + 名为准(它就是线上实际值),结构体按「服务端实际 Packet() 发的那个」核(见 §4.4)。

1.2 40250 头表(服务端 packet.h 权威值)

控制段(保留头,两向都有)

0xff GC/CG_HANDSHAKE                 0xfe CG_PONG / GC_BINDUDP
0xfd CG_CLIENT_VERSION / GC_PHASE    0xfc CG_TIME_SYNC / GC_TIME_SYNC   (0xfc 的 GC 侧: 服务端
                                         packet.h 叫 HEADER_GC_TIME_SYNC,客户端 Packet.h 叫
                                         HEADER_GC_HANDSHAKE_OK —— 同一字节;CG 侧无 0xfa)
0xfb GC/CG_KEY_AGREEMENT             0xfa GC_KEY_AGREEMENT_COMPLETED
0xf1 CG_CLIENT_VERSION2

(客户端值:Packet.h L162-167 CG、L362-367 GC。GC 0xfc HEADER_GC_HANDSHAKE_OK 注释写「252」。)

CG(客户端→服务端)

 1 LOGIN            2 ATTACK          3 CHAT            4 CHARACTER_CREATE   5 CHARACTER_DELETE
 6 CHARACTER_SELECT 7 MOVE            8 SYNC_POSITION  10 ENTERGAME
11 ITEM_USE        12 ITEM_DROP      13 ITEM_MOVE      15 ITEM_PICKUP
16 QUICKSLOT_ADD   17 QUICKSLOT_DEL  18 QUICKSLOT_SWAP 19 WHISPER            20 ITEM_DROP2
26 ON_CLICK        27 EXCHANGE       28 CHARACTER_POSITION  29 SCRIPT_ANSWER
30 QUEST_INPUT_STRING  31 QUEST_CONFIRM
50 SHOP           51 FLY_TARGETING  52 USE_SKILL      53 ADD_FLY_TARGETING  54 SHOOT   55 MYSHOP
60 ITEM_USE_TO_ITEM  61 TARGET       64 TEXT          65 WARP               66 SCRIPT_BUTTON  67 MESSENGER
69 MALL_CHECKOUT   70 SAFEBOX_CHECKIN 71 SAFEBOX_CHECKOUT
72 PARTY_INVITE    73 PARTY_INVITE_ANSWER  74 PARTY_REMOVE  75 PARTY_SET_STATE  76 PARTY_USE_SKILL
77 SAFEBOX_ITEM_MOVE  78 PARTY_PARAMETER
80 GUILD          81 ANSWER_MAKE_GUILD  82 FISHING   83 ITEM_GIVE          90 EMPIRE     96 REFINE
100 MARK_LOGIN    101 MARK_CRCLIST  102 MARK_UPLOAD  104 MARK_IDXLIST      105 HACK    106 CHANGE_NAME
109 LOGIN2        110 DUNGEON       111 LOGIN3       112 GUILD_SYMBOL_UPLOAD 113 SYMBOL_CRC
114 SCRIPT_SELECT_ITEM  116 LOGIN5_OPENID  202 PASSPOD_ANSWER  203 HS_ACK  204 XTRAP_ACK
205 DRAGON_SOUL_REFINE  206 STATE_CHECKER

GC(服务端→客户端)

 1 CHARACTER_ADD    2 CHARACTER_DEL    3 MOVE            4 CHAT            5 SYNC_POSITION
 6 LOGIN_SUCCESS3(=3格,PLAYER_PER_ACCOUNT3)   7 LOGIN_FAILURE   32 LOGIN_SUCCESS4(=4格)
 8 CHARACTER_CREATE_SUCCESS  9 CREATE_FAILURE  10 DELETE_SUCCESS  11 DELETE_WRONG_SOCIAL_ID
12 ATTACK         13 STUN           14 DEAD           15 MAIN_CHARACTER_OLD
16 CHARACTER_POINTS  17 CHARACTER_POINT_CHANGE  18 CHANGE_SPEED
19 CHARACTER_UPDATE  24 CHARACTER_UPDATE_NEW
20 ITEM_DEL       21 ITEM_SET       22 ITEM_USE       23 ITEM_DROP       25 ITEM_UPDATE
26 ITEM_GROUND_ADD  27 ITEM_GROUND_DEL
28 QUICKSLOT_ADD  29 QUICKSLOT_DEL  30 QUICKSLOT_SWAP  31 ITEM_OWNERSHIP
34 WHISPER        36 MOTION         37 PARTS          38 SHOP           39 SHOP_SIGN
40 DUEL_START     41 PVP            42 EXCHANGE       43 CHARACTER_POSITION  44 PING
45 SCRIPT         46 QUEST_CONFIRM  61 MOUNT          62 OWNERSHIP      63 TARGET   65 WARP
69 ADD_FLY_TARGETING  70 CREATE_FLY  71 FLY_TARGETING  72 SKILL_LEVEL_OLD  76 SKILL_LEVEL
74 MESSENGER      75 GUILD         77 PARTY_INVITE   78 PARTY_ADD      79 PARTY_UPDATE  80 PARTY_REMOVE
81 QUEST_INFO     82 REQUEST_MAKE_GUILD  83 PARTY_PARAMETER
85 SAFEBOX_SET    86 SAFEBOX_DEL   87 SAFEBOX_WRONG_PASSWORD  88 SAFEBOX_SIZE
89 FISHING        90 EMPIRE        91 PARTY_LINK     92 PARTY_UNLINK   95 REFINE_INFORMATION_OLD
99 VIEW_EQUIP    100 MARK_BLOCK   102 MARK_IDXLIST  106 TIME          107 CHANGE_NAME  110 DUNGEON
111 WALK_MODE    112 SKILL_GROUP  113 MAIN_CHARACTER  114 SEPCIAL_EFFECT / (USE_POTION 注释掉)
115 NPC_POSITION 116 MATRIX_CARD  118 LOGIN_KEY    119 REFINE_INFORMATION  121 CHANNEL
122 MALL_OPEN    123 TARGET_UPDATE 124 TARGET_DELETE  125 TARGET_CREATE
126 AFFECT_ADD   127 AFFECT_REMOVE 128 MALL_SET     129 MALL_DEL       130 LAND_LIST
131 LOVER_INFO   132 LOVE_POINT_UPDATE  133 SYMBOL_DATA  134 DIG_MOTION  135 DAMAGE_INFO
136 CHAR_ADDITIONAL_INFO  137 MAIN_CHARACTER3_BGM  138 MAIN_CHARACTER4_BGM_VOL
150 AUTH_SUCCESS 151 PANAMA_PACK  152 HYBRIDCRYPT_KEYS  153 HYBRIDCRYPT_SDB  154 AUTH_SUCCESS_OPENID
200 ROULETTE     202 REQUEST_PASSPOD  203 REQUEST_PASSPOD_FAILED  204 HS_REQUEST  205 XTRAP_CS1_REQUEST
208 SPECIFIC_EFFECT  209 DRAGON_SOUL_REFINE  210 RESPOND_CHANNELSTATUS

逐结构体细节实现期直接看 game/src/packet.hEUC-KR 编码,注释乱码无碍)。


2. 分帧(framing

2.1 m2dev fork

每个包 [u16 header LE][u16 length LE][payload]。长度永远显式。无 sequence 字节。

2.2 40250 classic —— 静态尺寸表 + sequence 字节 + 动态包

  • :读 1 字节 header → 查尺寸表(CPacketInfo::Set(header, sizeof(TPacketXxx), name, bSeq),见 game/src/packet_info.cpp)→ 读定长 body。
  • CG 的 sequence 字节bSeq=true 的包尾部多 1 字节 BYTE sequence。服务端 Set()if (bSeq) element->iSize += sizeof(BYTE)(尺寸表已含 +1);客户端 NetStream.cpp 发包后调 SendSequence()m_bUseSequence 为真时追加 m_kVec_bSequenceTable[m_iSequence++ % SEQUENCE_TABLE_SIZE] (静态表 s_bSequenceTable[SEQUENCE_TABLE_SIZE]NetStream.cpp L881)。开关由 Python 调 net.SetPacketSequenceMode()PythonNetworkStreamModule.cpp:1601,对 rnsrkAccountConnector 都置 true)—— 具体时机在 root/*.py(本 dump 没带),实现期抓包定位。 服务端逐包校验、错了断线。
  • bSeq=true 覆盖几乎所有 CG 游戏内包Login/Move/Attack/Chat/ItemMove/Shop/Party/Quest/Pong/…; 连 CG_LOGIN/LOGIN2/LOGIN3CG_TIME_SYNC 都是 true,见 packet_info.cpp:148-150,137)。 bSeq=false 的全集(packet_info.cpp):TEXT HANDSHAKE MARK_LOGIN MARK_IDXLIST MARK_CRCLIST MARK_UPLOAD KEY_AGREEMENT GUILD_SYMBOL_UPLOAD SYMBOL_CRC DRAGON_SOUL_REFINE STATE_CHECKERHS_ACK / XTRAP_ACK 在 40250 服务端 packet_info.cpp:227-228注释掉的,根本没登记。) 客户端只在 m_bUseSequence 打开后才真正追加尾字节,未开时即便 bSeq=true 也不加。
  • GC 不带 sequence 字节
  • 动态尺寸包:客户端接收侧靠 CNetworkPacketHeaderMapCMainPacketHeaderMap 构造函数, PythonNetworkStream.cpp L29 起)里的 Set(header, TPacketType(sizeof(TPacketXxx), DYNAMIC_SIZE_PACKET)) 标记区分;CheckPacket() 命中动态项时先 Peek(TDynamicSizePacketHeader{BYTE header; WORD size;})size = 整包字节数,再 Peek(size)。发送侧则看结构体自带的长度字段 command_chatWORD lengthcommand_whisperWORD wSize、GC 侧 packet_*WORD size,语义相同)。 客户端 GC 动态包全集CMainPacketHeaderMap,其余 GC 全部 STATIC_SIZE_PACKET): GC_CHAT GC_SCRIPT GC_SHOP GC_GUILD GC_MESSENGER GC_QUEST_INFO GC_DUEL_START GC_SYNC_POSITION GC_DUNGEON GC_NPC_POSITION GC_LAND_LIST GC_HYBRIDCRYPT_KEYS GC_HYBRIDCRYPT_SDBCG 动态包:只有 CG_CHATcommand_chat)和 CG_WHISPERcommand_whisper)。 易错的静态包GC_MALL_OPEN/SET/DELGC_SAFEBOX_SETGC_PARTY_ADD/UPDATEGC_WHISPERGC_AFFECT_ADD 都是 STATIC_SIZE_PACKET(定长,sizeof 取满缓冲)。CG 侧 CG_MARK_UPLOAD {BYTE header; DWORD gid; BYTE image[16*12*4];})、CG_SHOP{BYTE header; BYTE subheader;})、 CG_GUILD{BYTE byHeader; BYTE bySubHeader;})也都是定长子头分发,不是动态包。

2.3 影响

net_stream.cpp 完全重写:新 classic_stream.cpp 维护 header→size 表(从 packet.h + packet_info.cpp 镜像一份到 wire_classic.hstatic_assert + 一个 packet_size(header) 函数),发 CG 时自动追加 sequence 字节并推进本地序列表,动态包读 size 前缀。


3. 加密(cipher

3.1 m2dev fork

libsodium crypto_kxX25519),一轮:GC_KEY_CHALLENGE{server_pk, challenge}CG_KEY_RESPONSE{client_pk, HMAC(challenge, tx_key)}GC_KEY_COMPLETE{AEAD 加密的 session token, nonce}crypto_kx_*_session_keys两把方向密钥 m_tx_key / m_rx_key不是两向同密钥);激活后每向 一条 crypto_stream_xchacha20(固定方向 nonce0x01 S→C、0x02 C→S)+ 递增字节计数原地 XOR。 KEY_COMPLETE 的 token 用一次性 crypto_aead_xchacha20poly1305_ietfsecure_cipher.{h,cpp} (近乎照搬客户端 EterBase/SecureCipher)。

3.2 40250 classic —— _IMPROVED_PACKET_ENCRYPTION_EterBase/cipher.{h,cpp}

  1. 密钥协商Crypto++ DH2Unified DH)——DH2KeyAgreementDH dh_; DH2 dh2_(dh_);, 同一组 DH 域参数下各生成「静态 + 临时」两对密钥,dh2_.Agree(shared_, spriv, epriv, otherPub…) 一次得共享密钥 SecByteBlock shared_cipher.cpp L123-133, 336, 428-441)。 握手时间同步收敛后,流程是服务端先手
    1. 服务端 DESC::SendKeyAgreement()desc.cpp:726):cipher_.Prepare(packet.data, &data_length) 生成本端 blob → 发 HEADER_GC_KEY_AGREEMENT TPacketKeyAgreement{BYTE bHeader; WORD wAgreedLength; WORD wDataLength; BYTE data[MAX_DATA_LEN=256];} (定长 261B,客户端 Packet.h L2722,客户端把它登记为 STATIC_SIZE_PACKET)。
    2. 客户端 RecvKeyAgreementPacket()PythonNetworkStreamPhaseHandshake.cpp:208):Prepare() 生成本端 blob → Activate(pkt.wAgreedLength, pkt.data, pkt.wDataLength) 用服务端的数据(内部 m_cipher.Activate(true, …)Agree()SecByteBlock shared_)→ 再发 HEADER_CG_KEY_AGREEMENT 带自己的 blob。
    3. 服务端收 HEADER_CG_KEY_AGREEMENTinput.cpp:647): SendKeyAgreementCompleted() (发 HEADER_GC_KEY_AGREEMENT_COMPLETED TPacketKeyAgreementCompleted{BYTE bHeader; BYTE data[3];}
      • ProcessOutput() 刷 socket IsCipherPrepared() 检查(否则「maybe a Hacker」 DelayedDisconnect(5))→ FinishHandshake(wAgreedLength, data, wDataLength) = cipher_.Activate(false, …) desc.cpp:755)成功则 g_bAuthServer ? SetPhase(PHASE_AUTH) : SetPhase(PHASE_LOGIN)input.cpp:665-668), 失败 PHASE_CLOSE
    4. 客户端 RecvKeyAgreementCompletedPacket()ActivateCipher() = m_cipher.set_activated(true)。 —— Agree() 完成到 set_activated(true) 之间有一小段窗口:密钥算出但还没开始真正加解密。 注意服务端是先发 COMPLETED 再自己 FinishHandshake/切阶段(顺序有意如此,帮客户端先进加密态)。
  2. 对称加密SetUp(polarity)cipher.cpp L205)里 hint_0 = shared[shared[0] % size]hint_1 = shared[shared[1] % size],各 BlockCipherAlgorithm::Pick(hint)selector = hint % kMaxAlgorithmskMaxAlgorithms = 14enum 里 kDefault=0 占一位)。selector 113 依次是 RC6/MARS/Twofish/Serpent/CAST256/IDEA/3DES(DES_EDE2)/Camellia/SEED/RC5/Blowfish/TEA/SHACAL2 selector == 0kDefault)或落到 default:TwofishkAES/Rijndael 那位是注释掉的)。 CTR 模式CTR_Mode<T>::Encryption/Decryption),key/iv 从 shared 切片。两个密码实例按 polarity 决定收/发各用哪个 —— 客户端 polarity = true EterLib/NetStream.cpp:3004 return m_cipher.Activate(true, agreed_length, buffer, length);), 服务端用 false。无 paddingCTR 流)。 Encrypt/Decrypt = SymmetricCipher::ProcessData 原地。
  3. _IMPROVED_PACKET_ENCRYPTION_ 是编译期 #define,不是运行时开关
    • 服务端 common/service.h:7 #define _IMPROVED_PACKET_ENCRYPTION_ —— shipped 40250 服务端默认开
    • 客户端 eterBase/ServiceDefs.h:4 同样 #define —— 也默认开。
    • 关掉走老路:4-DWORD key 的 TEA_Encrypt(服务端 desc.cpp non-improved 分支 + GetEncryptionKey() / 客户端 SetSecurityMode要「明文起步」必须重编服务端(把 service.h 那行注释掉), 没有 CONFIG token 能关(见 §7.2)。有源码,可行。
    • 另有一层 packet_encode/packet_decodedesc.cpp:479)在 cipher 之外,始终生效。

3.3 影响

最硬的一块。需要 Crypto++DH/DH2 + CTR_Mode<T> 13 个算法)。方案:

  • vendored Crypto++(子模块,CMake 跨 macOS/iOS/Android 能编)—— 体积大但省事;或
  • 只挪需要的算法(DH2 + Twofish/RC5/... CTR)—— 工作量大。
  • 版本 Crypto++ 8.4.0:服务端 src/extern/cryptopp_8_4_0.tar.gz+ 预编 src/extern/lib/libcryptopp.a), 客户端 ClientVS22/extern/include/cryptopp/config_ver.h CRYPTOPP_VERSION 840)。移植对齐这个版本。

secure_cipher.cpp 不动(m2dev 用),新 classic_cipher.{h,cpp} 端口 EterBase/cipher.cpp


4. 握手 / 阶段流

4.1 阶段枚举

服务端 enum EPhasepacket.h:850):PHASE_CLOSE=0, HANDSHAKE=1, LOGIN=2, SELECT=3, LOADING=4, GAME=5, DEAD=6, CLIENT_CONNECTING=7, DBCLIENT=8, P2P=9, AUTH=10, TEEN=11, PASSPOD=12。 客户端匿名 enumPacket.h:1124)前 7 个(06)一致,尾部是 DBCLIENT_CONNECTING / DBCLIENT—— 只有 06 会经 GC_PHASE 到达客户端PHASE_AUTH 等是服务端内部态。 正常流:CLOSE→HANDSHAKE→(AUTH,仅 g_bAuthServer)→LOGIN→SELECT→LOADING→GAME。 切阶段由 HEADER_GC_PHASE(0xfd) TPacketGCPhase{BYTE header; BYTE phase;} 驱动(客户端 RecvPhasePacket())。

4.2 HANDSHAKE

客户端 PythonNetworkStreamPhaseHandshake.cpp服务端 desc.cpp + input.cppCInputHandshake)明文都在

  • TPacketGCHandshake=TPacketCGHandshake,同布局):{BYTE bHeader; DWORD dwHandshake; DWORD dwTime; LONG lDelta;}(服务端 packet.h packet_header_handshake;客户端字段名 header)。bSeq=false packet_info.cpp:136)。
  • 连上 → 服务端 DESC::StartHandshakeSendHandshake(get_dword_time(), 0)pack.bHeader = HEADER_GC_HANDSHAKE(0xff)desc.cpp:637-658)。
  • 客户端 HandShake 阶段收到 HEADER_GC_HANDSHAKEPythonNetworkStreamPhaseHandshake.cpp L37-62 内联): ELTimer_SetServerMSec(dwTime + lDelta)dwTime += 2*lDeltalDelta = 0header 不变(仍 0xff)原样回发,不追加 sequence。
  • 服务端 CInputProcessor::Handshakeinput.cpp:195):先校验 p->dwHandshake == GetHandshake() (不符 PHASE_CLOSE)。在 PHASE_HANDSHAKE 里调 HandshakeProcess(dwTime, lDelta, false) desc.cpp:662):
    • bias = get_dword_time() - (dwTime + lDelta)0 ≤ bias ≤ 50 → 收敛m_bHandshaking=false 返回 true。
    • 否则 lNewDelta = (dwCurTime - dwTime) / 2<0 时用 (dwCurTime - m_dwHandshakeSentTime)/2), ++m_iHandshakeRetry > HANDSHAKE_RETRY_LIMITPHASE_CLOSE;否则 SendHandshake(dwCurTime, lNewDelta) 再来一轮。
  • 收敛后input.cpp:207-217):
    • #ifdef _IMPROVED_PACKET_ENCRYPTION_shipped 默认)→ d->SendKeyAgreement()这里先不切阶段 阶段推进发生在之后收到 HEADER_CG_KEY_AGREEMENT 时(input.cpp:665-668FinishHandshake 成功后 SetPhase(PHASE_AUTH|PHASE_LOGIN))。完整密钥协商流程见 §3.2。
    • #else(明文/老 TEA)→ 立即 g_bAuthServer ? SetPhase(PHASE_AUTH) : SetPhase(PHASE_LOGIN)
  • 握手期之后的时钟再同步SELECT/GAME 里服务端再发 HEADER_GC_HANDSHAKE,客户端不在 handshake phase → PythonNetworkStreamPhaseSelect.cpp L96-102 的 RecvHandshakePacket():回发时 header 改成 HEADER_CG_TIME_SYNC(0xfc) 且 SendSequence()bSeq=truepacket_info.cpp:137)。服务端此路 HandshakeProcess(…, true)bInfiniteRetry=trueinput.cpp:222),收敛时回发 1 字节 HEADER_GC_TIME_SYNC(0xfc)desc.cpp:676-679)→ 客户端 RecvHandshakeOKPacket()TPacketGCBlank{BYTE header;} 定终值。 —— 注意 0xfc 这个值:客户端叫 HEADER_GC_HANDSHAKE_OK,服务端叫 HEADER_GC_TIME_SYNC,同一字节。
  • 期间可能夹 HEADER_GC_KEY_AGREEMENT / _COMPLETED(见 §3)、HEADER_GC_BINDUDPHEADER_GC_PINGHEADER_GC_HYBRIDCRYPT_KEYS / _SDB(动态包)。CInputHandshake::Analyzeinput.cpp:276)还会 处理 HEADER_CG_TEXTwebadmin IS_SERVER_UP 等文本命令)和 header 10(旧探活忽略)。

4.3 LOGINPythonNetworkStreamPhaseLogin.cpp

  • 两条路
    • SendLoginPacketHEADER_CG_LOGIN TPacketCGLogin{BYTE header, char login[31], char passwd[17]} (明文账密,服务端直连模式)。
    • SendLoginPacketNewHEADER_CG_LOGIN2 command_login2 {BYTE header; char name[31]; DWORD login_key; DWORD adwClientKey[4];} —— login_key 来自独立 auth 服(同 game 二进制跑 auth 模式)先前发的 HEADER_GC_LOGIN_KEY(118)adwClientKey = 客户端 extern DWORD g_adwEncryptKey[4]SendLoginPacketNew 还会 SetSecurityMode(true, g_adwEncryptKey, g_adwDecryptKey) 起老 TEA 层。
    • HEADER_CG_LOGIN3 command_login3 {header; name[31]; pwd[17]; DWORD adwClientKey[4];} account server 直登 + 客户端 key)。
  • 回:40250 服务端实际只发 HEADER_GC_LOGIN_SUCCESS_NEWSLOT(=32) DESC::SendLoginSuccessPacket desc.cpp:925-959p.bHeader = HEADER_GC_LOGIN_SUCCESS_NEWSLOT Packet(&p, sizeof(TPacketGCLoginSuccess)))。结构体 packet_login_success / TPacketGCLoginSuccesspacket.h:892):{BYTE bHeader; TSimplePlayer players[PLAYER_PER_ACCOUNT=**4**]; DWORD guild_id[4]; char guild_name[4][GUILD_NAME_MAX_LEN+1=13]; DWORD handle; DWORD random_key;} —— 客户端非 GAIDEN 里 header 32 走 HEADER_GC_LOGIN_SUCCESS4TPacketGCLoginSuccess4[4],同布局)。 TSimplePlayercommon/tables.h:347pack(1)63 字节== 客户端 TSimplePlayerInformation。 header 6HEADER_GC_LOGIN_SUCCESS / 客户端 _SUCCESS3players[3])是旧格式,shipped 40250 不发。 失败 HEADER_GC_LOGIN_FAILURE(7) {BYTE header; char szStatus[LOGIN_STATUS_MAX_LEN+1=9];} LOGIN_FAILURE_* enum1 ALREADY / 2 ID_NOT_EXIST / 3 WRONG_PASS / 4 FALSE / 7 FULL)。 handle/random_keyCG_MARK_LOGIN 用(会徽下载)。
  • 频道状态:HEADER_GC_RESPOND_CHANNELSTATUS(210)。
  • GC_PHASE(PHASE_SELECT) → 选人阶段。

4.4 SELECT / LOADING / GAME

  • SELECTCG_CHARACTER_SELECT(6) command_player_select {BYTE header; BYTE player_index;}GC_PHASE(PHASE_LOADING) + header 113 的「主角包」。服务端 char.cpp:1607 发的是 TPacketGCMainCharacter(服务端 packet.h L1043= {BYTE header; DWORD dwVID; WORD wRaceNum; char szName[CHARACTER_NAME_MAX_LEN+1=25]; long lx,ly,lz; BYTE empire; BYTE skill_group;} —— 客户端非 GAIDEN 里这个 header 走 case HEADER_GC_MAIN_CHARACTER2_EMPIRETPacketGCMainCharacter2_EMPIRE(同布局)。注意 wRaceNum 是种族不是地图号,szName 在坐标 尾部是 empire + skill_group 两个 BYTE。开了 SUPPORT_BGM 时改发 _BGM(137) / _BGM_VOL(138) 变体 (多 BGM 名字段)。地图名另经加载流程 → CG_ENTERGAME(10) → GC_PHASE(PHASE_GAME)
  • 建/删号:CG_CHARACTER_CREATE(4) —— 客户端 command_player_create / TPacketCGCreateCharacter {BYTE header; BYTE index; char name[CHARACTER_NAME_MAX_LEN+1=25]; WORD job; BYTE shape; BYTE CON; BYTE INT; BYTE STR; BYTE DEX;}(服务端侧同一包叫 TPacketCGPlayerCreate)。 CG_CHARACTER_DELETE(5) command_player_delete / TPacketCGDestroyCharacter {BYTE header; BYTE index; char szPrivateCode[PRIVATE_CODE_LENGTH=8];}。 → GC_CHARACTER_CREATE_SUCCESS(8) command_player_create_success / TPacketGCPlayerCreateSuccess {BYTE header; BYTE bAccountCharacterSlot; TSimplePlayerInformation kSimplePlayerInfomation;} / _CREATE_FAILURE(9) / _DELETE_SUCCESS(10) {header, BYTE account_index} / _DELETE_WRONG_SOCIAL_ID(11)。

与 m2dev 对照:m2dev 是 CG_CHARACTER_CREATE=0x020140250 是 HEADER_CG_CHARACTER_CREATE=4。 create 包里 job 两边都是 WORD(u16);差异在头值、结构体名、字段序(40250 顺序是 name→job→shape→CON/INT/STR/DEX),按 40250 command_player_create 重推。只有摘要结构 TSimplePlayerInformation.byJob 是 BYTE(见 §5),别和 create 包的 WORD job 混。


5. 关键结构体差异(抽样,实现期逐个对 game/src/packet.h

语义 m2dev fork 40250 classic
包头 uint16_t header; uint16_t length; BYTE header;(动态包再加 WORD size;
TItemPos {u8 window_type; u16 cell;} {BYTE window_type; WORD cell;}(同布局,但不少老包里 cell 是 BYTE,按包看)
TPlayerItemAttribute {u8 bType; i16 sValue;} (3B)
TSimplePlayerInformation(客户端 Packet.h L1176 fork 版 {DWORD dwID; char szName[CHARACTER_NAME_MAX_LEN+1=25]; BYTE byJob; BYTE byLevel; DWORD dwPlayMinutes; BYTE byST,byHT,byDX,byIQ; WORD wMainPart; BYTE bChangeName; WORD wHairPart; BYTE bDummy[4]; long x,y; LONG lAddr; WORD wPort; BYTE bySkillGroup;} —— 摘要里 byJob 是 BYTE(但 command_player_create.jobWORD,别混),名字 25,无 wMapIndex/guildguild 在 TPacketGCLoginSuccess3/4 尾部并列数组)
移动 CG_MOVEcommand_move fork {BYTE bHeader; BYTE bFunc; BYTE bArg; BYTE bRot; long lX; long lY; DWORD dwTime;} —— 实测一致
聊天 fork [hdr][len][type][...] CG command_chat{BYTE header; WORD length; BYTE type;} + char szChat[]Packet.h L597);GC packet_chatting{BYTE header; WORD size; BYTE type; DWORD dwVID; BYTE bEmpire;} + 文本(L1425)。都是动态包,长度字段一个叫 length 一个叫 size,语义相同
商店 fork GC_SHOP(0x0810) sub-header + SHOP_GC_START_EX HEADER_GC_SHOP(38) TPacketGCShop{BYTE header; WORD size; BYTE subheader;}(动态)。EPacketShopSubHeadersPacket.h L1864):GC_START=0 … GC_START_EX=10、NOT_ENOUGH_MONEY_EX=11 —— 子头值与 fork 一致,商店层大体可搬
交易 fork GC_EXCHANGE sub-header TPacketExchange{BYTE header; BYTE subheader; …} —— 定长,无 WORD size(不是所有 sub-header 包都是动态包,逐个看)
帮会 / messenger / quest fork 各自 GC_GUILD / GC_MESSENGER / GC_QUEST_INFO 是动态包([BYTE header][WORD size][...],见 §2.2 权威清单),子头在 size 之后。GC_SCRIPT(任务对话)也是动态
龙魂精炼 不纳入本阶段 目标 40250 服务端不提供该玩法;客户端兼容代码保留但不宣称可用,也不纳入当前验收

wire.h ~2000 行结构体按 40250 重写成 wire_classic.h所有 wire 结构体是 #pragma pack(1) (客户端 Packet.h L4352813 的 pack(push)/pack(1)…pack(pop) 段;文件开头那段 pack(8) 是无关的 Hackshield 传输缓冲,不涉及协议)。


6. 双后端架构

extension/src/net/
├─ entity_store.{h,cpp}      # 共享世界模型。apply() 拆成:
│                            #   - 状态 + mutation 方法(spawn_entity / set_vitals /
│                            #     set_inv_item / party_set / shop_set / ...)—— 协议无关
│                            #   - m2dev_parser.cpp : 现有 apply() 挪过来,调 mutation
│                            #   - classic_parser.cpp: 新写,按 §1/§5 解 40250 包,调同一批 mutation
├─ i_net_session.h           # 新接口:
│     connect(host, port) / login(id, pw) / select_char(i) / enter_game()
│     send_move/attack/chat/item_move/use_skill/... (或 send(Intent, args)
│     pump()  → 产出 GC 包 → parser → entity_store
│     信号源: 让 M2Client 照旧 emitsession 只回调 "有 N 个事件待抽干"
├─ m2dev/                    # 现有文件整体挪入, namespace mtnet::m2dev, 不改逻辑
│     wire.h · secure_cipher.{h,cpp} · net_stream.{h,cpp} · game_client.h · auth_client.h ·
│     mark_client.h · mark_image.{h,cpp} · byte_buffer.h · m2dev_parser.cpp(从 entity_store 拆)
│     实现 INetSession
├─ classic/                  # 新, namespace mtnet::classic
│     wire_classic.h · classic_cipher.{h,cpp}Crypto++ DH2 + CTR)· classic_stream.{h,cpp}
│     1B 头 + size 表 + sequence 字节 + 动态包)· classic_session.cppHANDSHAKE→…→GAME 状态机)
│     · classic_parser.cpp · 实现 INetSession
└─ m2_client.{h,cpp}         # 持有 std::unique_ptr<INetSession>;
      构造时按 OS.get_environment("MT_PROTOCOL")(默认 "classic" 或先 "m2dev")选
      m2dev::Session / classic::Session; 所有 game->send_* / dispatch 改成 session->
  • CTestnet_entity_test / net_loopback_test 现有的绑 m2dev 后端,保持绿。 classic 侧新增 net_classic_test(合成包 + MockServer 走 classic 分帧/cipher)。
  • GDScript / 场景 / UI / 渲染:保留原有 M2Client 方法和既有信号兼容性;为 40250 特有的 classic 回包增加必要 signal/状态桥接和 UI 处理,不改渲染底座。

7. 待确认 / 暂缓项

  1. 【暂缓】跑起 40250 服务端 —— Server/metin2/src/server/(顶层 Makefilelibthecore libpoly libgame liblua libsql libserverkey game db)。这套是 FreeBSD-onlyCC = clang++-devel-m32 32 位、-lmd、静态链 /usr/local/lib/mysql/libmysqlclient.a + 预编 extern/lib/libcryptopp.a
    • libIL.aGAMEDIR = /usr/metin2/server/share/bingame/src/Makefile SVN_VERSION = 40250)。 Linux 上跑要么改 Makefile/补依赖,要么 FreeBSD VM/jail。还要 MySQLServer/metin2_mysql_dump / Server/mysql)、item_proto/mob_proto 表、运行时 Server/metin2/server/{game99,auth}CONFIG。 本仓库的 tools/40250/{build_server,setup_mysql,prepare_runtime,start_server,stop_server,run_classic_e2e}.sh 已把这一步固化为可执行 runbook;当前 macOS 机器仍没有 FreeBSD/MySQL server。该环境搭建和 live 结果回填暂不属于本阶段交付,后续恢复公会 / 真服验收时再执行。
  2. 明文起步要重编服务端_IMPROVED_PACKET_ENCRYPTION_common/service.h:7 的编译期 #define 没有 CONFIG token 可关config.cpp 里只有 test_server / distribution_test_server / g_bAuthServer 这类,与加密无关)。第一里程碑:注释掉 service.h 那行重编 game,客户端同理, shipped 40250 默认直接走 DH2/CTR;明文只作为需要重编 game 的诊断分支,不是运行配置项。
  3. auth 服务器是否独立端口g_bAuthServer 分支决定握手收敛后进 PHASE_AUTH 还是直接 PHASE_LOGIN input.cpp:207-217)。auth 服和 game 服可能不同端口(运行时 server/auth vs server/game99)。 决定用 CG_LOGIN 还是 CG_LOGIN2/3 + GC_LOGIN_KEY
  4. Crypto++ 引入方式vendored 子模块 vs 抽子集。对齐服务端的 cryptopp_8_4_0
  5. sequence 表:客户端静态表 s_bSequenceTable[SEQUENCE_TABLE_SIZE=32768]EterLib/NetStream.cpp L881SendSequence() L834 循环取值 m_iSequence % 32768SetPacketSequenceMode(true) L829 开启)。 服务端对应表 game/src/sequence.cpp gc_abSequence[SEQUENCE_MAX_NUM=32768];校验在 input.cpp L121-162m_pPacketInfo->IsSequence(bHeader) 时读包尾 1 字节与 lpDesc->GetSequence() 比,不符 → SetPhase(PHASE_CLOSE) 断线,符 → push_seq + SetNextSequence()两张表必须逐字节一致,端口时整表 + 开启时机照抄。
  6. HYBRIDCRYPTGC_HYBRIDCRYPT_KEYS/SDB, 152/153,动态包)—— 是加密资源包(EterPack)的密钥下发 客户端 RecvHybridCryptKeyPacket/SDBPacketCEterPackManager::RetrieveHybridCryptPackKeys/PackSDB SDB = Supplementary Data Blocks),让客户端能解密 hybrid 加密的 .eix/.epk与反外挂无关。 我们的移植走散文件 / 自研 mtpack,不吃官方加密包 → 直接忽略(收到丢弃即可)。
  7. root/*.py + uiscript:这个 dump 只有 client 源码,没带 root/ pack。UI 窗口对照 40250 版 uiscript 是独立线,不阻塞协议移植(现有窗口先照跑)。

8. 建议推进顺序

  1. 【暂缓】真服前置:在 FreeBSD VM/jail 按 tools/40250/README.md 起 40250 game+auth+db 并用官方客户端或 net_classic_e2e_host 登入验证;不作为当前交付门槛。
  2. INetSession 接口 + EntityStore mutation 拆分 + m2dev 挪入 net/m2dev/(现有测试保持绿)。
  3. classic/classic_stream1B 头 + size 表 + sequence + 动态包 + shipped DH2/CTR,打通 HANDSHAKE→LOGIN→SELECT→GAME 空跑。
  4. classic_parserGC_LOGIN_SUCCESS / GC_MAIN_CHARACTER / GC_CHARACTER_ADD / GC_MOVE / GC_CHARACTER_POINTS —— 能进游戏、看见自己和别人动。
  5. classic_cipherCrypto++ DH2 + CTR,接 KEY_AGREEMENT 流。
  6. 铺开其余 GC/CG(物品 / 战斗 / 商店 / 组队 / 任务 …),对照 m2dev 已做的功能清单逐个搬; 公会与龙魂不列入当前阶段。
  7. MT_PROTOCOL 切换、net_classic_test;真服 e2e 暂缓。
  8. UI 窗口对 40250 uiscript 复核(独立)。

进度按 docs/CLIENT-GAP.md 的功能清单同步,标注 classic/m2dev 各自完成度。

进度(branch feat/40250-classic-net

  • 前置(step 140250 FreeBSD 服务端 —— 暂缓(见 §7.1),不阻塞当前本地协议 / UI 交付。
  • step 3a — wire 层 extension/src/net/classic/wire_classic.hnamespace mtnet::classic, 1B 头 enum 全表,命名跟服务端 packet.h + #pragma pack(1) 结构体 + static_assert + packet_size_{cg,gc}(h)整包尺寸含 1B 头、不含 seq 字节 —— 对齐 CPacketInfo::Set(…, sizeof(…))+ is_sequence_cg / is_dynamic_{cg,gc})。覆盖 control / handshake / key-agreement / login(1/2/3) / login_success(32 主用, 6 legacy) / select / create / delete / main_character(113) / character_add(1,2) / move(CG,GC) / points / point_change / chat。TSimplePlayer = 63 字节。 40250 客户端/服务端实际会用到的结构体已随 parser 补齐;仅 vendor-only 或服务端未登记的包不伪造。
  • step 3b — sequence 表 extension/src/net/classic/sequence_table.h —— 32768 字节 SEQUENCE_TABLE,逐字节 = NetStream.cpp:881 s_bSequenceTable = 服务端 sequence.cpp gc_abSequence[](脚本校验过)。
  • step 3c — 分帧层 extension/src/net/classic/classic_stream.{h,cpp}(在 mtnet 库里)。 非阻塞 TCP + feed()/take_outgoing() 无 socket 测试路径。base 处理 HANDSHAKE 回显 time += 2*delta、header 不变、无 seq)、GC_TIME_SYNC blank、GC_PHASEGC_PING→CG_PONGBINDUDPset_sequence_mode(true) 后对 bSeq CG 包追加 SEQUENCE_TABLE[i++];动态包读 TDynamicSizePacketHeader.size。收到 GC_KEY_AGREEMENT 后由 classic_cipher 完成 DH2/CTR 协商,并支持完成包与后续密文同一 read 的 pipeline。 CTest net.classic_wire + net.classic_stream 绿。
  • step 2 — INetSession seam + EntityStore mutation API (部分): extension/src/net/i_net_session.hconnect / disconnect / select_char / enter_game / pump / stage / worldper-intent send_* 随后加)。EntityStore 加了协议无关的 mut_spawn / mut_spawn_main / mut_despawn / mut_move / mut_set_points / mut_set_point (包住原 touch() / start_move()m2dev apply() 一行没动,net.entity_store 仍绿)。 m2dev 文件的物理挪目录(net/m2dev/)延后(纯改名,无功能收益)。
  • step 4 — classic_parser + classic_session
    • classic/classic_parser.{h,cpp} —— 40250 GC 包体 → EntityStore::mut_*。已接 GC_LOGIN_SUCCESS(32/6) / GC_LOGIN_FAILURE / GC_LOGIN_KEY / GC_MAIN_CHARACTER(113) / GC_CHARACTER_ADD(1) / GC_CHARACTER_DEL(2) / GC_MOVE(3) / GC_CHARACTER_POINTS(16) / GC_CHARACTER_POINT_CHANGE(17,含 int32_t header 怪癖) / GC_CHAT(4dynamic)。 CharSlot 列表 + handle/random_key。
    • classic/classic_session.{h,cpp}(实现 INetSession)—— 驱动 HANDSHAKE→LOGIN(CG_LOGIN, shipped server 的 DH2/CTR)→SELECT→LOADING→GAME。GC_PHASE(LOGIN) 时开 sequence mode 并发登录;GC_PHASE(SELECT)on_char_listselect_char(slot)CG_CHARACTER_SELECT PHASE_LOADING 后 1.5s 自动 CG_ENTERGAME(可配);PHASE_GAMEon_entered_gamestart_offline() 测试钩子跳过 socket。
    • CTest net.classic_session —— 合成服务端字节走完整流程:握手回显、CG_LOGIN + seq[0]、 4 格角色列表、选人 + seq[1]、主角/NPC spawn、自动 ENTERGAME + seq[2]、进游戏、 GC_MOVE 插值、points/point_change/del。全量 ctest 13/13
  • step 4b — per-intent send_* INetSession + ClassicSession 加了 send_movebRot = deg/5/ send_attack / send_chatdynamic[hdr][WORD len][type]text\0 len = 4 + strlen + 1/ send_target / send_character_position / send_click_npc / send_script_answer / send_item_use / send_item_move / send_item_dropcount>0 走 DROP2/ send_item_pickup / send_use_skilltarget 非 0 时先发 CG_FLY_TARGETING)。全部 Stage::InGame 门控,走 classic_stream 的 seq 追加。wire_classic.h 补了对应 CG 结构体 + packet_size_cg 表项。EntityStore::mut_move 改收「已解码角度」(m2dev rot*360/256 vs classic rot*5 两种编码,放到 parser 里转)。net.classic_session 扩展验证 move/attack/target/chat/item_move 的字节布局 + seq[3..6] 连续性 + 非 InGame 拒绝。ctest 13/13。
  • step 7(前半)— M2ClientMT_PROTOCOL 选后端 connect_to_server()OS::get_environment("MT_PROTOCOL") == "classic" → 建 ClassicSession 直连 game host(无独立 auth 服),set_stage(GameConnect);否则原 AuthClient+GameClient 路径一行没动_process()pump_classic() 分支(stage→phase_changed/stage_changed/char_list/ entered_game + 复用世界镜像子集:entity_spawned/despawned/moved/main_set/info + chat + vitals_changed + points_changed——即 classic_parser 目前产出的那些)。热路径方法 move / attack / set_target / say / character_position / click_npc / script_answer / use_skill / move_item / use_item / drop_item{,_count} / pickup_item / select_characterif (classic_sess) return classic_sess->send_*(...) 前置分支。读方法 get_entity / get_entities / get_main_vid / get_points)经新 active_world()classic_sess ? &classic_sess->world() : &game->world()。guild/cube/marks 已接入 classic 公会/战争/技能走 CG_GUILD,会徽走带当前 handle/random_key 的独立 mark 连接,Cube 走 40250 的 /cube r_info/cube add/delete/cube make 命令;classic 下原来因 game == null 而 no-op 的 M2Client 分支已消除。仍不伪造 vendor-only HackShield/XTrap 响应和未在 40250 源码登记的可选包。 ctest 13/13Godot editor import 干净,抽样 GDScript 测试通过,iOS/Android 绿。
  • step 5 — classic_cipher_IMPROVED_PACKET_ENCRYPTION_ mac + iOS + Android 全绿):
    • Crypto++ 8.4.0 vendoredextension/third_party/cryptopp/(从 ClientVS22/extern/include/ cryptopp 拷 393 个 .h/.cpp/.S,非 submodule+ 自写 CMakeLists.txtglob .cpp,剔除 test/bench/validat/regtest/adhoc/fipsCRYPTOPP_DISABLE_ASM/SSSE3/AESNI 全平台纯 C++)。 mt3p::cryptopp,链进 mtnet。mac 编译干净。
    • classic/classic_cipher.{h,cpp} —— 近乎照搬 EterBase/cipher.cppDH2KeyAgreement RFC 5114 1024-bit MODP + DH2 静态+临时密钥对,prepare() 生成 spub||epub blobagree()SecByteBlock shared+ BlockCipherAlgorithm::Pick(hint % 14) 选块密码(默认 Twofish+ set_up(polarity)shared 切 key/iv 建两个 CTR_Mode<T> 实例(polarity 决定收发各用哪个)。
    • classic_stream 接进去:GC_KEY_AGREEMENT(0xFB) → prepare() + activate(polarity, …) + 回发 CG_KEY_AGREEMENT(明文);GC_KEY_AGREEMENT_COMPLETED(0xFA) → set_activated(true) + 立刻解密缓冲区里 server 可能 pipeline 的密文尾巴。激活后 emit_bytes() 对所有出站字节 (含 seq 字节)CTR 加密、decrypt_appended() 对每个 recv/feed chunk 解密。set_polarity(true) = 客户端。
    • CTest net.classic_cipher(DH2 双端协商 + 加解密对称往返 + CTR 连续性 + 会话隔离)、 net.classic_encstreamClassicStream 全流程:握手 → KEY_AGREEMENT 交换 → COMPLETED → 加密 GC_PHASE 解出 + 出站 CG_MOVE 服务端能解 + pipelined COMPLETED+密文一个 feed)。 mac ctest 15/15。
    • 交叉编译iOS arm64 、Android arm64 Android 需把 NDK 的 sources/android/cpufeatures/cpu-features.c 编进 cryptopp + 加到 include path —— cpu.cpp 无条件 #include "cpu-features.h";已在 cryptopp/CMakeLists.txtif(ANDROID) 里处理)。 .so 从 46 MB → 59 MB。Godot editor import 干净,ctest 15/15。
  • step 4c — classic_parser 铺开 GCitems :接了 GC_ITEM_SET(21) / GC_ITEM_DEL(20) / GC_ITEM_UPDATE(25) / GC_ITEM_GROUND_ADD(26) / GC_ITEM_GROUND_DEL(27)。40250 的 TPlayerItemAttribute 是 3 字节 pack(1){BYTE type; short value}),跟 EntityStore::Item.attrs 的 stride 不同 → parser 里逐元素解包。EntityStoremut_item_set / mut_item_del / mut_item_update / mut_ground_add / mut_ground_del(包 mut_slot() + m_inv_changes / m_ground)。M2Clientget_inventory / get_equipment / get_item / get_ground_items 改走 active_world()pump_classic()drain_invinventory_changeddrain_groundground_item_added/removednet.classic_session 扩展验证 item_set/update/del + ground add/del。 ctest 15/15iOS/Android 绿。
  • step 4d — classic_parser GC 实体状态簇 GC_CHARACTER_UPDATE(19,装备外观/公会/善恶/骑乘) / GC_CHANGE_SPEED(18) / GC_CHARACTER_POSITION(43) / GC_WALK_MODE(111) / GC_STUN(13) / GC_DEAD(14) / GC_MOTION(36) / GC_TARGET(63,目标血条 %)。EntityStoremut_char_update / mut_set_position / mut_change_speed / mut_walk_mode / mut_stun / mut_dead / mut_motion / mut_target (包 m_ents / m_motions / m_target_* / m_mount_changes)。pump_classic()drain_motionsmotiondrain_damagedamagedrain_mount_changesmount_changedtake_target_dirtytarget_infoget_target()active_world()net.classic_session 扩展验证。ctest 15/15iOS/Android 绿。
  • step 4e — classic_parser HUD 簇 GC_SKILL_LEVEL(76TPlayerSkill 6B/条,time_t 32 位) / GC_QUICKSLOT_ADD/DEL/SWAP(28/29/30) / GC_AFFECT_ADD/REMOVE(126/127TPacketAffectElement 21B) / GC_DAMAGE_INFO(135) / GC_CHAR_ADDITIONAL_INFO(136,补 spawn 的 name/parts/level/guild) / GC_WHISPER(34dynamic —— 客户端 map 登记 STATIC 但 wSize 恰在 TDynamicSizePacketHeader.size 偏移,走 dynamic 路径 ok) / GC_SYNC_POSITION(5dynamic)。EntityStoremut_skill_levels / mut_quickslot_{set,del,swap} / mut_affect_{add,remove} / mut_damage / mut_char_info / mut_snap_position / mut_chat / mut_whisperGC_CHAT/GC_WHISPER 改走 EntityStore::m_chat 队列(跟 m2dev 一致,pump_classicdrain_chat 统一处理),删掉 parser 的 on_chat 回调。 pump_classic() 补 skills/quickslots/affects 的 drainget_skills/get_quickslots/get_affects/ get_skill_groupactive_world()。ctest 15/15iOS/Android 绿。
  • step 4f — classic_parser party GC_PARTY_INVITE(77,leader_vid) / GC_PARTY_ADD(78) / GC_PARTY_UPDATE(79,role/hp%/affects[7]) / GC_PARTY_REMOVE(80) / GC_PARTY_LINK(91) / GC_PARTY_UNLINK(92) / GC_PARTY_PARAMETER(83,分配模式)。EntityStoremut_party_{invite,add, update,remove,link,unlink,parameter}pump_classic()drain_party_invitesparty_invite_askparty_dirtyparty_changedget_party / get_party_distribute_modeactive_world()。 ctest 15/15iOS/Android 绿。
  • step 4g — classic_parser NPC shop GC_SHOP(38dynamic 子头) —— START(0,单货架 packet_shop_start{u32 owner_vid; packet_shop_item[40]}packet_shop_item=43B) / START_EX(10,多货架 {u32 vid; u8 tab_count} + tab_count×{name[32]; coin_type; item[40]}) / END(1) / 错误子头(NOT_ENOUGH_MONEY/SOLDOUT/INVENTORY_FULL/INVALID_POS)。 EntityStoremut_shop_open(vid, tabs) / mut_shop_close() / mut_shop_error(code)pump_classic()shop_dirtyshop_opened/shop_closeddrain_shop_errorsshop_erroris_shop_open / get_shop_items / get_shopactive_world()。ctest 15/15iOS/Android 绿。
  • step 4h — classic_parser quest / NPC 对话 GC_SCRIPT(45dynamic{skin; u16 src_size; text}) / GC_QUEST_CONFIRM(46STATIC 74B) / GC_QUEST_INFO(81dynamic{u16 index; u8 flag} + flag 驱动 title/clock/counter/icon 串)。EntityStoremut_script / mut_quest_confirm / mut_quest_infopump_classic()drain_scriptsscript_dialogdrain_confirmsquest_confirm_askdrain_quest_changesquest_infoget_questsactive_world()。 ctest 15/15iOS/Android 绿。至此 click_npcGC_SCRIPT 对话闭环。
  • step 4i — classic P8 social / storage
    • GC_MESSENGER / CG_MESSENGER:好友快照、上线/下线与手机状态,以及按 40250 客户端格式发送加好友/删好友。
    • GC_EXCHANGE / CG_EXCHANGE:交易开始、双方物品/金币/接受状态、结束/错误提示,以及全部客户端操作。
    • GC_SAFEBOX_* / CG_SAFEBOX_*:仓库开启、资金、物品快照/删除、密码错误、存入/取出/移动。
    • GC_MALL_* / CG_MALL_CHECKOUT:商城开启、物品快照/删除、取出;均接入 EntityStoreM2Client 读接口、信号和 classic session intent。net.classic_wire / net.classic_session 覆盖 包尺寸、字段布局、序列字节和状态变更;macOS mtgodot 构建通过。
  • step 4j — classic P9 / remaining fixed packets :补齐 GC_WARP 跨频道重连、GC_TIME / GC_CHANNEL、NPC/土地/目标标记、恋人、观察者、坐骑/飞行、PVP、GC_DUEL_START 对手 VID 列表、装备查看、挖矿/钓鱼、炼化、 技能冷却和特殊效果;并消费登录安全包、BGM 主角包、CHARACTER_ADD2/UPDATE2。龙魂兼容代码不纳入目标服务端支持声明。 不具备 40250 vendor runtime 的 HackShield/XTrap 请求只做定长消费,不伪造响应。
  • step 4k — Cube + private shop semantics classic Cube 使用 40250 cmd_general.cpp/cube r_info/cube r_info <start> <count>/cube add <cube_index> <inventory_index>/cube delete <cube_index>/cube make;UI 提供背包→24 格材料槽的添加/删除。个人摊位收摊 使用空 CG_MYSHOP,与 40250 CHARACTER::OpenMyShop() 的关闭语义一致。
  • step 4l — UI/uiscript复核 :好友、交易、仓库、商城、NPC 商店、会徽、Cube、炼化和个人摊位 窗口均通过既有 M2Client 信号/intent 接入;公会窗口暂缓,龙魂窗口不纳入目标服务端支持范围。Cube UI 的旧“直接按配方 index 制作” 已改为先装载材料槽再制作,避免把 classic index 当成服务端参数;PVP/决斗状态也会在世界标签中 刷新,MobView.set_display_name() 对无内置名字节点的真模型提供同样的名字牌回退。
  • step 4m — 公会完整状态与会徽原生协议 ⏸️ 暂缓:公会在线/离线、公告列表、 邀请、战争积分等 GC 子包,并接入 EntityStoreM2Client 和公会窗口;公告发布/刷新/删除、 邀请接受/拒绝都有对应 CG 路径;已有代码保留但公会整体不纳入当前阶段。会徽独立连接也已改为 40250 的单字节头、bufSize 分片、CRC、 符号包和上传布局;并按原客户端在首个 GC_HANDSHAKE 后发送 CG_MARK_LOGIN 的时序处理, 兼容随后是否进入可选 DH2/CTR;net.classic_mark 固定了下载与上传的关键字节协议。
  • step 4n — 握手重试与时钟同步时序 PHASE_HANDSHAKE 中的重试仍回发 GC_HANDSHAKE(0xff),进入 LOGIN/SELECT/GAME 后收到的后续握手才改发带 sequence 的 CG_TIME_SYNC(0xfc);会徽独立连接保持首个握手后的 CG_MARK_LOGIN 路径,避免把两种连接的 控制包时序混用。net.classic_streamnet.classic_mark 已分别覆盖这两个边界。
  • step 4o — 好友邀请确认闭环 40250 的 GC_CHAT/COMMAND 命令 messenger_auth <name> 不再静默丢弃,现会产生 friend_invite_ask;好友窗口显示接受/拒绝 对话框,并通过 /messenger_auth y|n <name> 回发原生服务端命令。net.entity_storep8_test.gd 覆盖邀请入队、弹窗和两种回答;netbridge_test.gd 同时校验 P8/P9、会徽和精炼 API 都实际绑定到 Godot;公会与龙魂不作为当前交付验收项。
  • step 4p — 40250 命令总线补齐 PartyRequest/PartyRequestDeniedCloseSafebox/ CloseMallShowMeSafeboxPassword/ShowMeMallPasswordRefineSuceeded/RefineFailedOpenPrivateShopMyShopPriceList 和服务端回传的 setblockmode 已从静默 no-op 改为 EntityStore typed eventM2Client 暴露对应 signal,仓库/商城会弹密码框,错误密码会提示, 精炼结果会回显,个人摊位可由服务端命令打开并使用价格回填,游戏设置会按服务端屏蔽掩码同步。 net.entity_store 覆盖这些命令及仓库关闭状态。
  • step 4q — 其余有状态的 ServerCommand 40250 客户端原生处理的 ObserverMode/ObserverCountStoneDetectStartStaminaConsume/StopStaminaConsumesms/nosmsmobile_authcombo 已进入 EntityStore 状态及 typed event M2Client 提供观战/体力/手机/连击 getter 和对应 signal(体力按服务端下发速率递减,探石保留 转换后的角度),因此 classic 不会在这些回包上静默丢状态。ConsoleEnableBettingMoneyObserverTeamInfo 仍保持 40250 原客户端的无可见副作用语义;不伪造它们不存在的 Godot 功能。
  • step 4r — GC_MOTION 与表情命令 GC_MOTION 不再只由 M2Client 发出后被场景丢弃, NetWorld 会驱动本地/远端实体,并按 40250 CRaceMotionData 的职业变体选择真实 .msakissfrench_kissslapclapcheer1/2dance1..6dig_motion 及其余 congratulation/forgive/angry/.../joy ServerCommand 也映射到同一 motion 流;gift 会触发 可见通知。40250 源码只提供 Gift_Show() 通知入口,没有随命令携带奖励或领取 CG,因此不虚构 礼物奖励数据协议。
  • step 4s — 版本门禁与无阵营账号 :补齐账号未绑定阵营时的 CG_EMPIRE 选择入口,并在 收到 GC_MAIN_CHARACTERGC_MAIN_CHARACTER3_BGMGC_MAIN_CHARACTER4_BGM_VOL 后按 40250 原客户端时序发送一次 CG_CLIENT_VERSION1215955205)及 sequence。服务端 input_main.cpp 会在首次 CG_MOVE 前拒绝没有版本上报的连接;classic session 现在覆盖这个 门禁,离线测试同时锁定版本字段和序号推进。
  • step 4t — 强制改名 UI 闭环 SimplePlayer.change_name 已透传到 classic / m2dev 角色槽位;选人页在该标志存在时按 40250 原版行为拦截“开始”并打开改名窗,发送 CG_CHANGE_NAME,再由 GC_CHANGE_NAME 刷新角色名并清除强制改名标志。AppFlow 已接通请求、 成功和失败回传,char_create_delete_test.gd 覆盖短名拒绝、发送参数和成功状态。强制改名的 重名 / 非法名错误码与真实服务端验收暂缓。
  • step 4u — 创建公会 UI 闭环 ⏸️ 暂缓:GC_REQUEST_MAKE_GUILD 现在会触发公会窗的创建名称输入, 按 40250 原版流程校验空名、控制字符和 12 字上限,再调用 CG_ANSWER_MAKE_GUILD;发送失败会 保留窗口以便重试。已有 guild_creation_ui_test.gd 覆盖服务端请求、名称校验和合法名称应答。 公会完整页面、服务端敏感词 / 重名错误回包、实际建会状态和权限暂不纳入本阶段。
  • step 4v — 任务选魔石窗口复核 :确认 [SELECT_ITEM] 已从 QuestDialog 接到 SelectItemUI,按原 uiselectitem.RefreshSlot 过滤魔石和 grade、将选择窗格映射回背包格, 选择时发送 CG_SCRIPT_SELECT_ITEM,关闭时发送 0select_item_ui_test.gd 已覆盖过滤和选择。 原版 CJK IME、完整资源视觉及真实脚本业务回包验收暂缓。
  • step 4w — 队伍角色 / 能量 / 私店招牌复核 :按 40250 CParty::Update 修正 party_ui.gd 的角色 6 为 HASTE(迅捷),并补齐 affects[7] 槽位语义;攻击/坦克/迅捷/辅助/ 宗师/防御分别按 Leadership 10/20/20/25/35/40 门控,组队治疗按 Leadership 18 门控, p8_test.gd 覆盖低等级禁用路径。Cube 的 POINT_ENERGY 已由 HUD 快照显示;GC_SHOP_SIGN 已由 NetWorld 提供 ShopSign Label3D fallbackGC_SYMBOL_DATA 已接会徽下载链路;个人摊位 已支持标准 Godot 拖放与点击选取双路径,Cube 已校验 cube_open(npc) 前置、配方材料、金币和 制作状态。缺失共享 .sub 贴图时使用现有 fallback,不阻断窗口逻辑;live FreeBSD + MySQL E2E 暂缓。
  • step 4x — P8 交互边界与目标服务端范围 NPC/石头/传送点点选统一进入 click_npc,服务端 cube_open 才打开 Cube;商店购买/出售支持数量控件和错误回显,交易栏位按 服务端已占用槽位分配并拒绝重复/超限,仓库/商城拒绝满背包或满仓位,密码弹窗可正常确认/取消。 40250 目标服务端没有龙魂玩法,GameScene 不再创建龙魂入口、不消费其背包右键或快捷键; ds_refine 兼容 API 仍保留。
  • 本机回归结果C++/协议 CTest 16/16 通过(包含 net.loopback_flow);本次抽样的 project/*_test.gd 共 38 个脚本全部通过;系统设置测试已使用 headless 临时配置并对缺失可选 贴图安全降级。当前沙箱禁止 loopback bind,因此 channel_status_test 明确跳过本机 mock; 有网络权限时仍执行完整请求/响应断言,不是 40250 classic 协议失败。
  • 当前收尾项:协议 / 状态链路、classic UI 交互和离线回归测试已完成;真实 FreeBSD + MySQL binary E2E 暂缓,公会功能暂缓,龙魂因目标 40250 服务端不提供而排除。tools/40250/ 的 build/setup/start/stop/probe 入口保留,待后续恢复真服验收时使用。