55 KiB
迁移到 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.h、game/src/packet_info.cpp、game/src/desc.cpp(握手/阶段/cipher)、game/src/input.cpp(CInputHandshake+ sequence 校验)、game/src/input_auth.cpp、common/)。 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 栈挪进子目录冻结 | 大(~2–4 周) |
EntityStore |
拆出 mutation API,两个 parser 共用世界模型 | 中(1–2 天) |
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) |
BYTE(1 字节),十进制(HEADER_CG_MOVE = 7) |
| 定义位置 | wire.h(我们自己维护) |
客户端 UserInterface/Packet.h(L30 起匿名 enum,CG/GC/GG 混一起)与服务端 game/src/packet.h 手工保持一致 |
⚠️ 客户端
Packet.h的 header enum 有#if defined(GAIDEN)分支,40250 客户端构建未定义GAIDEN(UserInterface.vcxprojPreprocessorDefinitions 只有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.h(EUC-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.cppL881)。开关由 Python 调net.SetPacketSequenceMode()(PythonNetworkStreamModule.cpp:1601,对rns和rkAccountConnector都置 true)—— 具体时机在root/*.py(本 dump 没带),实现期抓包定位。 服务端逐包校验、错了断线。 bSeq=true覆盖几乎所有 CG 游戏内包(Login/Move/Attack/Chat/ItemMove/Shop/Party/Quest/Pong/…; 连CG_LOGIN/LOGIN2/LOGIN3和CG_TIME_SYNC都是true,见packet_info.cpp:148-150,137)。bSeq=false的全集(packet_info.cpp):TEXTHANDSHAKEMARK_LOGINMARK_IDXLISTMARK_CRCLISTMARK_UPLOADKEY_AGREEMENTGUILD_SYMBOL_UPLOADSYMBOL_CRCDRAGON_SOUL_REFINESTATE_CHECKER。 (HS_ACK/XTRAP_ACK在 40250 服务端packet_info.cpp:227-228是注释掉的,根本没登记。) 客户端只在m_bUseSequence打开后才真正追加尾字节,未开时即便bSeq=true也不加。- GC 不带 sequence 字节。
- 动态尺寸包:客户端接收侧靠
CNetworkPacketHeaderMap(CMainPacketHeaderMap构造函数,PythonNetworkStream.cppL29 起)里的Set(header, TPacketType(sizeof(TPacketXxx), DYNAMIC_SIZE_PACKET))标记区分;CheckPacket()命中动态项时先Peek(TDynamicSizePacketHeader{BYTE header; WORD size;})拿size= 整包字节数,再Peek(size)。发送侧则看结构体自带的长度字段 (command_chat叫WORD length、command_whisper叫WORD wSize、GC 侧packet_*叫WORD size,语义相同)。 客户端 GC 动态包全集(CMainPacketHeaderMap,其余 GC 全部STATIC_SIZE_PACKET):GC_CHATGC_SCRIPTGC_SHOPGC_GUILDGC_MESSENGERGC_QUEST_INFOGC_DUEL_STARTGC_SYNC_POSITIONGC_DUNGEONGC_NPC_POSITIONGC_LAND_LISTGC_HYBRIDCRYPT_KEYSGC_HYBRIDCRYPT_SDB。 CG 动态包:只有CG_CHAT(command_chat)和CG_WHISPER(command_whisper)。 易错的静态包:GC_MALL_OPEN/SET/DEL、GC_SAFEBOX_SET、GC_PARTY_ADD/UPDATE、GC_WHISPER、GC_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.h 的 static_assert + 一个 packet_size(header) 函数),发 CG 时自动追加
sequence 字节并推进本地序列表,动态包读 size 前缀。
3. 加密(cipher)
3.1 m2dev fork
libsodium crypto_kx(X25519),一轮: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(固定方向 nonce:0x01 S→C、0x02 C→S)+ 递增字节计数原地 XOR。
KEY_COMPLETE 的 token 用一次性 crypto_aead_xchacha20poly1305_ietf。secure_cipher.{h,cpp}
(近乎照搬客户端 EterBase/SecureCipher)。
3.2 40250 classic —— _IMPROVED_PACKET_ENCRYPTION_(EterBase/cipher.{h,cpp})
- 密钥协商:Crypto++
DH2(Unified DH)——DH2KeyAgreement里DH dh_; DH2 dh2_(dh_);, 同一组 DH 域参数下各生成「静态 + 临时」两对密钥,dh2_.Agree(shared_, spriv, epriv, otherPub…)一次得共享密钥SecByteBlock shared_(cipher.cppL123-133, 336, 428-441)。 握手时间同步收敛后,流程是服务端先手:- 服务端
DESC::SendKeyAgreement()(desc.cpp:726):cipher_.Prepare(packet.data, &data_length)生成本端 blob → 发HEADER_GC_KEY_AGREEMENTTPacketKeyAgreement{BYTE bHeader; WORD wAgreedLength; WORD wDataLength; BYTE data[MAX_DATA_LEN=256];}(定长 261B,客户端Packet.hL2722,客户端把它登记为STATIC_SIZE_PACKET)。 - 客户端
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。 - 服务端收
HEADER_CG_KEY_AGREEMENT(input.cpp:647):先SendKeyAgreementCompleted()(发HEADER_GC_KEY_AGREEMENT_COMPLETEDTPacketKeyAgreementCompleted{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。
- 客户端
RecvKeyAgreementCompletedPacket()→ActivateCipher()=m_cipher.set_activated(true)。 ——Agree()完成到set_activated(true)之间有一小段窗口:密钥算出但还没开始真正加解密。 注意服务端是先发 COMPLETED 再自己 FinishHandshake/切阶段(顺序有意如此,帮客户端先进加密态)。
- 服务端
- 对称加密:
SetUp(polarity)(cipher.cppL205)里hint_0 = shared[shared[0] % size]、hint_1 = shared[shared[1] % size],各BlockCipherAlgorithm::Pick(hint)→selector = hint % kMaxAlgorithms(kMaxAlgorithms = 14,enum 里kDefault=0占一位)。selector1–13 依次是RC6/MARS/Twofish/Serpent/CAST256/IDEA/3DES(DES_EDE2)/Camellia/SEED/RC5/Blowfish/TEA/SHACAL2;selector == 0(kDefault)或落到default:→Twofish(kAES/Rijndael那位是注释掉的)。 CTR 模式(CTR_Mode<T>::Encryption/Decryption),key/iv 从shared切片。两个密码实例按polarity决定收/发各用哪个 —— 客户端 polarity =true(EterLib/NetStream.cpp:3004return m_cipher.Activate(true, agreed_length, buffer, length);), 服务端用false。无 padding(CTR 流)。Encrypt/Decrypt=SymmetricCipher::ProcessData原地。 _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.cppnon-improved 分支 +GetEncryptionKey()) / 客户端SetSecurityMode。要「明文起步」必须重编服务端(把service.h那行注释掉), 没有CONFIGtoken 能关(见 §7.2)。有源码,可行。 - 另有一层
packet_encode/packet_decode(desc.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.hCRYPTOPP_VERSION 840)。移植对齐这个版本。
secure_cipher.cpp 不动(m2dev 用),新 classic_cipher.{h,cpp} 端口 EterBase/cipher.cpp。
4. 握手 / 阶段流
4.1 阶段枚举
服务端 enum EPhase(packet.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。
客户端匿名 enum(Packet.h:1124)前 7 个(0–6)一致,尾部是 DBCLIENT_CONNECTING / DBCLIENT——
只有 0–6 会经 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.cpp(CInputHandshake)明文都在。
TPacketGCHandshake(=TPacketCGHandshake,同布局):{BYTE bHeader; DWORD dwHandshake; DWORD dwTime; LONG lDelta;}(服务端packet.hpacket_header_handshake;客户端字段名header)。bSeq=false(packet_info.cpp:136)。- 连上 → 服务端
DESC::StartHandshake→SendHandshake(get_dword_time(), 0),pack.bHeader = HEADER_GC_HANDSHAKE(0xff)(desc.cpp:637-658)。 - 客户端 HandShake 阶段收到
HEADER_GC_HANDSHAKE(PythonNetworkStreamPhaseHandshake.cppL37-62 内联):ELTimer_SetServerMSec(dwTime + lDelta)→dwTime += 2*lDelta、lDelta = 0→ header 不变(仍 0xff)原样回发,不追加 sequence。 - 服务端
CInputProcessor::Handshake(input.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_LIMIT→PHASE_CLOSE;否则SendHandshake(dwCurTime, lNewDelta)再来一轮。
- 收敛后(
input.cpp:207-217):#ifdef _IMPROVED_PACKET_ENCRYPTION_(shipped 默认)→d->SendKeyAgreement(),这里先不切阶段; 阶段推进发生在之后收到HEADER_CG_KEY_AGREEMENT时(input.cpp:665-668,FinishHandshake成功后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.cppL96-102 的RecvHandshakePacket():回发时 header 改成HEADER_CG_TIME_SYNC(0xfc) 且SendSequence()(bSeq=true,packet_info.cpp:137)。服务端此路HandshakeProcess(…, true)(bInfiniteRetry=true,input.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_BINDUDP、HEADER_GC_PING、HEADER_GC_HYBRIDCRYPT_KEYS/_SDB(动态包)。CInputHandshake::Analyze(input.cpp:276)还会 处理HEADER_CG_TEXT(webadminIS_SERVER_UP等文本命令)和 header10(旧探活忽略)。
4.3 LOGIN(PythonNetworkStreamPhaseLogin.cpp)
- 两条路:
SendLoginPacket→HEADER_CG_LOGINTPacketCGLogin{BYTE header, char login[31], char passwd[17]}(明文账密,服务端直连模式)。SendLoginPacketNew→HEADER_CG_LOGIN2command_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_LOGIN3command_login3{header; name[31]; pwd[17]; DWORD adwClientKey[4];}(account server 直登 + 客户端 key)。
- 回:40250 服务端实际只发
HEADER_GC_LOGIN_SUCCESS_NEWSLOT(=32) (DESC::SendLoginSuccessPacketdesc.cpp:925-959:p.bHeader = HEADER_GC_LOGIN_SUCCESS_NEWSLOT,Packet(&p, sizeof(TPacketGCLoginSuccess)))。结构体packet_login_success/TPacketGCLoginSuccess(packet.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_SUCCESS4→TPacketGCLoginSuccess4([4],同布局)。TSimplePlayer(common/tables.h:347,pack(1),63 字节)== 客户端TSimplePlayerInformation。 header 6(HEADER_GC_LOGIN_SUCCESS/ 客户端_SUCCESS3,players[3])是旧格式,shipped 40250 不发。 失败HEADER_GC_LOGIN_FAILURE(7){BYTE header; char szStatus[LOGIN_STATUS_MAX_LEN+1=9];}(LOGIN_FAILURE_* enum:1 ALREADY / 2 ID_NOT_EXIST / 3 WRONG_PASS / 4 FALSE / 7 FULL)。handle/random_key供CG_MARK_LOGIN用(会徽下载)。 - 频道状态:
HEADER_GC_RESPOND_CHANNELSTATUS(210)。 GC_PHASE(PHASE_SELECT)→ 选人阶段。
4.4 SELECT / LOADING / GAME
- SELECT:
CG_CHARACTER_SELECT(6)command_player_select{BYTE header; BYTE player_index;}→GC_PHASE(PHASE_LOADING)+ header 113 的「主角包」。服务端char.cpp:1607发的是TPacketGCMainCharacter(服务端packet.hL1043)={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_EMPIRE读TPacketGCMainCharacter2_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=0x0201;40250 是HEADER_CG_CHARACTER_CREATE=4。 create 包里job两边都是WORD(u16);差异在头值、结构体名、字段序(40250 顺序是 name→job→shape→CON/INT/STR/DEX),按 40250command_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.job 是 WORD,别混),名字 25,无 wMapIndex/guild(guild 在 TPacketGCLoginSuccess3/4 尾部并列数组) |
移动 CG_MOVE(command_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;}(动态)。EPacketShopSubHeaders(Packet.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 L435–2813 的 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 照旧 emit(session 只回调 "有 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.cpp(HANDSHAKE→…→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->
- CTest:
net_entity_test/net_loopback_test现有的绑 m2dev 后端,保持绿。 classic 侧新增net_classic_test(合成包 + MockServer 走 classic 分帧/cipher)。 - GDScript / 场景 / UI / 渲染:保留原有
M2Client方法和既有信号兼容性;为 40250 特有的 classic 回包增加必要 signal/状态桥接和 UI 处理,不改渲染底座。
7. 待确认 / 暂缓项
- 【暂缓】跑起 40250 服务端 ——
Server/metin2/src/server/(顶层Makefile→libthecore libpoly libgame liblua libsql libserverkey game db)。这套是 FreeBSD-only:CC = clang++-devel、-m3232 位、-lmd、静态链/usr/local/lib/mysql/libmysqlclient.a+ 预编extern/lib/libcryptopp.alibIL.a,GAMEDIR = /usr/metin2/server/share/bin(game/src/MakefileSVN_VERSION = 40250)。 Linux 上跑要么改 Makefile/补依赖,要么 FreeBSD VM/jail。还要 MySQL(Server/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 结果回填暂不属于本阶段交付,后续恢复公会 / 真服验收时再执行。
- 明文起步要重编服务端:
_IMPROVED_PACKET_ENCRYPTION_是common/service.h:7的编译期#define, 没有CONFIGtoken 可关(config.cpp里只有test_server/distribution_test_server/g_bAuthServer这类,与加密无关)。第一里程碑:注释掉service.h那行重编 game,客户端同理, shipped 40250 默认直接走 DH2/CTR;明文只作为需要重编 game 的诊断分支,不是运行配置项。 - auth 服务器是否独立端口:
g_bAuthServer分支决定握手收敛后进PHASE_AUTH还是直接PHASE_LOGIN(input.cpp:207-217)。auth 服和 game 服可能不同端口(运行时server/authvsserver/game99)。 决定用CG_LOGIN还是CG_LOGIN2/3+GC_LOGIN_KEY。 - Crypto++ 引入方式:vendored 子模块 vs 抽子集。对齐服务端的
cryptopp_8_4_0。 - sequence 表:客户端静态表
s_bSequenceTable[SEQUENCE_TABLE_SIZE=32768](EterLib/NetStream.cppL881;SendSequence()L834 循环取值m_iSequence % 32768,SetPacketSequenceMode(true)L829 开启)。 服务端对应表game/src/sequence.cppgc_abSequence[SEQUENCE_MAX_NUM=32768];校验在input.cppL121-162:m_pPacketInfo->IsSequence(bHeader)时读包尾 1 字节与lpDesc->GetSequence()比,不符 →SetPhase(PHASE_CLOSE)断线,符 →push_seq+SetNextSequence()。 两张表必须逐字节一致,端口时整表 + 开启时机照抄。 - HYBRIDCRYPT(
GC_HYBRIDCRYPT_KEYS/SDB, 152/153,动态包)—— 是加密资源包(EterPack)的密钥下发: 客户端RecvHybridCryptKeyPacket/SDBPacket→CEterPackManager::RetrieveHybridCryptPackKeys/PackSDB(SDB = Supplementary Data Blocks),让客户端能解密 hybrid 加密的.eix/.epk。与反外挂无关。 我们的移植走散文件 / 自研 mtpack,不吃官方加密包 → 直接忽略(收到丢弃即可)。 - root/*.py + uiscript:这个 dump 只有 client 源码,没带
root/pack。UI 窗口对照 40250 版 uiscript 是独立线,不阻塞协议移植(现有窗口先照跑)。
8. 建议推进顺序
- 【暂缓】真服前置:在 FreeBSD VM/jail 按
tools/40250/README.md起 40250 game+auth+db, 并用官方客户端或net_classic_e2e_host登入验证;不作为当前交付门槛。 INetSession接口 +EntityStoremutation 拆分 + m2dev 挪入net/m2dev/(现有测试保持绿)。classic/classic_stream:1B 头 + size 表 + sequence + 动态包 + shipped DH2/CTR,打通 HANDSHAKE→LOGIN→SELECT→GAME 空跑。classic_parser:GC_LOGIN_SUCCESS/GC_MAIN_CHARACTER/GC_CHARACTER_ADD/GC_MOVE/GC_CHARACTER_POINTS—— 能进游戏、看见自己和别人动。classic_cipher:Crypto++ DH2 + CTR,接KEY_AGREEMENT流。- 铺开其余 GC/CG(物品 / 战斗 / 商店 / 组队 / 任务 …),对照 m2dev 已做的功能清单逐个搬; 公会与龙魂不列入当前阶段。
MT_PROTOCOL切换、net_classic_test;真服 e2e 暂缓。- UI 窗口对 40250 uiscript 复核(独立)。
进度按
docs/CLIENT-GAP.md的功能清单同步,标注 classic/m2dev 各自完成度。
进度(branch feat/40250-classic-net)
- 前置(step 1):40250 FreeBSD 服务端 —— 暂缓(见 §7.1),不阻塞当前本地协议 / UI 交付。
- step 3a — wire 层 ✅:
extension/src/net/classic/wire_classic.h(namespace 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:881s_bSequenceTable= 服务端sequence.cppgc_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_SYNCblank、GC_PHASE、GC_PING→CG_PONG、BINDUDP;set_sequence_mode(true)后对 bSeq CG 包追加SEQUENCE_TABLE[i++];动态包读TDynamicSizePacketHeader.size。收到GC_KEY_AGREEMENT后由classic_cipher完成 DH2/CTR 协商,并支持完成包与后续密文同一 read 的 pipeline。 CTestnet.classic_wire+net.classic_stream绿。 - step 2 —
INetSessionseam +EntityStoremutation API ✅(部分):extension/src/net/i_net_session.h(connect / disconnect / select_char / enter_game / pump / stage / world,per-intentsend_*随后加)。EntityStore加了协议无关的mut_spawn / mut_spawn_main / mut_despawn / mut_move / mut_set_points / mut_set_point(包住原touch()/start_move(),m2devapply()一行没动,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(4,dynamic)。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_list;select_char(slot)→CG_CHARACTER_SELECT;PHASE_LOADING后 1.5s 自动CG_ENTERGAME(可配);PHASE_GAME→on_entered_game。start_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_move(bRot = deg/5)/send_attack/send_chat(dynamic:[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_drop(count>0 走 DROP2)/send_item_pickup/send_use_skill(target 非 0 时先发CG_FLY_TARGETING)。全部Stage::InGame门控,走classic_stream的 seq 追加。wire_classic.h补了对应 CG 结构体 +packet_size_cg表项。EntityStore::mut_move改收「已解码角度」(m2devrot*360/256vs classicrot*5两种编码,放到 parser 里转)。net.classic_session扩展验证 move/attack/target/chat/item_move 的字节布局 + seq[3..6] 连续性 + 非 InGame 拒绝。ctest 13/13。 - step 7(前半)—
M2Client按MT_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_character) 加if (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/13,Godot editor import 干净,抽样 GDScript 测试通过,iOS/Android 绿。 - step 5 —
classic_cipher(_IMPROVED_PACKET_ENCRYPTION_) ✅(mac + iOS + Android 全绿):- Crypto++ 8.4.0 vendored:
extension/third_party/cryptopp/(从ClientVS22/extern/include/ cryptopp拷 393 个.h/.cpp/.S,非 submodule)+ 自写CMakeLists.txt(glob.cpp,剔除 test/bench/validat/regtest/adhoc/fips,CRYPTOPP_DISABLE_ASM/SSSE3/AESNI全平台纯 C++)。mt3p::cryptopp,链进mtnet。mac 编译干净。 classic/classic_cipher.{h,cpp}—— 近乎照搬EterBase/cipher.cpp:DH2KeyAgreement(RFC 5114 1024-bit MODP +DH2静态+临时密钥对,prepare()生成spub||epubblob,agree()出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_encstream(ClassicStream全流程:握手 → 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.txt的if(ANDROID)里处理)。.so从 46 MB → 59 MB。Godot editor import 干净,ctest 15/15。
- Crypto++ 8.4.0 vendored:
- step 4c —
classic_parser铺开 GC(items) ✅:接了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 里逐元素解包。EntityStore加mut_item_set / mut_item_del / mut_item_update / mut_ground_add / mut_ground_del(包mut_slot()+m_inv_changes/m_ground)。M2Client:get_inventory / get_equipment / get_item / get_ground_items改走active_world();pump_classic()补drain_inv→inventory_changed、drain_ground→ground_item_added/removed。net.classic_session扩展验证 item_set/update/del + ground add/del。 ctest 15/15,iOS/Android 绿。 - step 4d —
classic_parserGC 实体状态簇 ✅: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,目标血条 %)。EntityStore加mut_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_motions→motion、drain_damage→damage、drain_mount_changes→mount_changed、take_target_dirty→target_info。get_target()走active_world()。net.classic_session扩展验证。ctest 15/15,iOS/Android 绿。 - step 4e —
classic_parserHUD 簇 ✅:GC_SKILL_LEVEL(76,TPlayerSkill6B/条,time_t32 位) /GC_QUICKSLOT_ADD/DEL/SWAP(28/29/30) /GC_AFFECT_ADD/REMOVE(126/127,TPacketAffectElement21B) /GC_DAMAGE_INFO(135) /GC_CHAR_ADDITIONAL_INFO(136,补 spawn 的 name/parts/level/guild) /GC_WHISPER(34,dynamic —— 客户端 map 登记 STATIC 但wSize恰在TDynamicSizePacketHeader.size偏移,走 dynamic 路径 ok) /GC_SYNC_POSITION(5,dynamic)。EntityStore加mut_skill_levels / mut_quickslot_{set,del,swap} / mut_affect_{add,remove} / mut_damage / mut_char_info / mut_snap_position / mut_chat / mut_whisper。GC_CHAT/GC_WHISPER改走EntityStore::m_chat队列(跟 m2dev 一致,pump_classic的drain_chat统一处理),删掉 parser 的on_chat回调。pump_classic()补 skills/quickslots/affects 的 drain;get_skills/get_quickslots/get_affects/ get_skill_group走active_world()。ctest 15/15,iOS/Android 绿。 - step 4f —
classic_parserparty ✅: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,分配模式)。EntityStore加mut_party_{invite,add, update,remove,link,unlink,parameter}。pump_classic()补drain_party_invites→party_invite_ask、party_dirty→party_changed。get_party/get_party_distribute_mode走active_world()。 ctest 15/15,iOS/Android 绿。 - step 4g —
classic_parserNPC shop ✅:GC_SHOP(38,dynamic 子头) ——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)。EntityStore加mut_shop_open(vid, tabs) / mut_shop_close() / mut_shop_error(code)。pump_classic()补shop_dirty→shop_opened/shop_closed、drain_shop_errors→shop_error。is_shop_open/get_shop_items/get_shop走active_world()。ctest 15/15,iOS/Android 绿。 - step 4h —
classic_parserquest / NPC 对话 ✅:GC_SCRIPT(45,dynamic,{skin; u16 src_size; text}) /GC_QUEST_CONFIRM(46,STATIC 74B) /GC_QUEST_INFO(81,dynamic,{u16 index; u8 flag}+ flag 驱动 title/clock/counter/icon 串)。EntityStore加mut_script / mut_quest_confirm / mut_quest_info。pump_classic()补drain_scripts→script_dialog、drain_confirms→quest_confirm_ask、drain_quest_changes→quest_info。get_quests走active_world()。 ctest 15/15,iOS/Android 绿。至此click_npc→GC_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:商城开启、物品快照/删除、取出;均接入EntityStore、M2Client读接口、信号和 classic session intent。net.classic_wire/net.classic_session覆盖 包尺寸、字段布局、序列字节和状态变更;macOSmtgodot构建通过。
- 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,与 40250CHARACTER::OpenMyShop()的关闭语义一致。 - step 4l — UI/uiscript复核 ✅:好友、交易、仓库、商城、NPC 商店、会徽、Cube、炼化和个人摊位
窗口均通过既有 M2Client 信号/intent 接入;公会窗口暂缓,龙魂窗口不纳入目标服务端支持范围。Cube UI 的旧“直接按配方 index 制作”
已改为先装载材料槽再制作,避免把 classic index 当成服务端参数;PVP/决斗状态也会在世界标签中
刷新,
MobView.set_display_name()对无内置名字节点的真模型提供同样的名字牌回退。 - step 4m — 公会完整状态与会徽原生协议 ⏸️ 暂缓:公会在线/离线、公告列表、
邀请、战争积分等 GC 子包,并接入
EntityStore、M2Client和公会窗口;公告发布/刷新/删除、 邀请接受/拒绝都有对应 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_stream和net.classic_mark已分别覆盖这两个边界。 - step 4o — 好友邀请确认闭环 ✅:40250 的
GC_CHAT/COMMAND命令messenger_auth <name>不再静默丢弃,现会产生friend_invite_ask;好友窗口显示接受/拒绝 对话框,并通过/messenger_auth y|n <name>回发原生服务端命令。net.entity_store与p8_test.gd覆盖邀请入队、弹窗和两种回答;netbridge_test.gd同时校验 P8/P9、会徽和精炼 API 都实际绑定到 Godot;公会与龙魂不作为当前交付验收项。 - step 4p — 40250 命令总线补齐 ✅:
PartyRequest/PartyRequestDenied、CloseSafebox/CloseMall、ShowMeSafeboxPassword/ShowMeMallPassword、RefineSuceeded/RefineFailed、OpenPrivateShop、MyShopPriceList和服务端回传的setblockmode已从静默 no-op 改为EntityStoretyped event;M2Client 暴露对应 signal,仓库/商城会弹密码框,错误密码会提示, 精炼结果会回显,个人摊位可由服务端命令打开并使用价格回填,游戏设置会按服务端屏蔽掩码同步。net.entity_store覆盖这些命令及仓库关闭状态。 - step 4q — 其余有状态的 ServerCommand ✅:40250 客户端原生处理的
ObserverMode/ObserverCount、StoneDetect、StartStaminaConsume/StopStaminaConsume、sms/nosms、mobile_auth和combo已进入EntityStore状态及 typed event; M2Client 提供观战/体力/手机/连击 getter 和对应 signal(体力按服务端下发速率递减,探石保留 转换后的角度),因此 classic 不会在这些回包上静默丢状态。ConsoleEnable、BettingMoney、ObserverTeamInfo仍保持 40250 原客户端的无可见副作用语义;不伪造它们不存在的 Godot 功能。 - step 4r — GC_MOTION 与表情命令 ✅:
GC_MOTION不再只由 M2Client 发出后被场景丢弃, NetWorld 会驱动本地/远端实体,并按 40250CRaceMotionData的职业变体选择真实.msa。kiss、french_kiss、slap、clap、cheer1/2、dance1..6、dig_motion及其余congratulation/forgive/angry/.../joyServerCommand 也映射到同一 motion 流;gift会触发 可见通知。40250 源码只提供Gift_Show()通知入口,没有随命令携带奖励或领取 CG,因此不虚构 礼物奖励数据协议。 - step 4s — 版本门禁与无阵营账号 ✅:补齐账号未绑定阵营时的
CG_EMPIRE选择入口,并在 收到GC_MAIN_CHARACTER、GC_MAIN_CHARACTER3_BGM或GC_MAIN_CHARACTER4_BGM_VOL后按 40250 原客户端时序发送一次CG_CLIENT_VERSION(1215955205)及 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,关闭时发送 0;select_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提供ShopSignLabel3D fallback,GC_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 入口保留,待后续恢复真服验收时使用。