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

649 lines
55 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 迁移到 40250 服务端 —— 协议差异与移植方案
> 目标:网络层从 **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 栈挪进子目录冻结 | **大**~24 周)|
| `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 起匿名 enumCG/GC/GG 混一起)**与**服务端 `game/src/packet.h` 手工保持一致 |
> ⚠️ 客户端 `Packet.h` 的 header enum 有 `#if defined(GAIDEN)` 分支,**40250 客户端构建未定义
> `GAIDEN`**`UserInterface.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.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.cpp` L881)。开关由 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`):`TEXT` `HANDSHAKE` `MARK_LOGIN` `MARK_IDXLIST` `MARK_CRCLIST`
`MARK_UPLOAD` `KEY_AGREEMENT` `GUILD_SYMBOL_UPLOAD` `SYMBOL_CRC` `DRAGON_SOUL_REFINE` `STATE_CHECKER`
`HS_ACK` / `XTRAP_ACK` 在 40250 服务端 `packet_info.cpp:227-228` 是**注释掉的**,根本没登记。)
客户端只在 `m_bUseSequence` 打开后才真正追加尾字节,未开时即便 `bSeq=true` 也不加。
- **GC 不带 sequence 字节**。
- **动态尺寸包**:客户端接收侧靠 `CNetworkPacketHeaderMap``CMainPacketHeaderMap` 构造函数,
`PythonNetworkStream.cpp` L29 起)里的 `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_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_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}`
1. **密钥协商**Crypto++ `DH2`Unified DH)——`DH2KeyAgreement``DH 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_AGREEMENT``input.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 %
kMaxAlgorithms`**`kMaxAlgorithms = 14`**enum 里 `kDefault=0` 占一位)。`selector` 113 依次是
`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: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_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.h` `CRYPTOPP_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 个(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.cpp``CInputHandshake`)明文都在**。
- `TPacketGCHandshake`=`TPacketCGHandshake`,同布局):`{BYTE bHeader; DWORD dwHandshake; DWORD dwTime;
LONG lDelta;}`(服务端 `packet.h` `packet_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.cpp` L37-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.cpp` L96-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`webadmin `IS_SERVER_UP` 等文本命令)和 header `10`(旧探活忽略)。
### 4.3 LOGIN`PythonNetworkStreamPhaseLogin.cpp`
- **两条路**
- `SendLoginPacket` → `HEADER_CG_LOGIN` `TPacketCGLogin{BYTE header, char login[31], char passwd[17]}`
(明文账密,服务端直连模式)。
- `SendLoginPacketNew` → `HEADER_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-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.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_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),按 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.job` 是 `WORD`,别混),名字 25,无 wMapIndex/guildguild 在 `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` 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->
```
- **CTest**`net_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/`(顶层 `Makefile` → `libthecore libpoly libgame
liblua libsql libserverkey game db`)。**这套是 FreeBSD-only**`CC = clang++-devel`、`-m32`
32 位、`-lmd`、静态链 `/usr/local/lib/mysql/libmysqlclient.a` + 预编 `extern/lib/libcryptopp.a`
+ `libIL.a``GAMEDIR = /usr/metin2/server/share/bin``game/src/Makefile` `SVN_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 结果回填暂不属于本阶段交付,后续恢复公会 / 真服验收时再执行。
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`
L881`SendSequence()` L834 循环取值 `m_iSequence % 32768``SetPacketSequenceMode(true)` L829 开启)。
服务端对应表 `game/src/sequence.cpp` `gc_abSequence[SEQUENCE_MAX_NUM=32768]`;校验在 `input.cpp`
L121-162`m_pPacketInfo->IsSequence(bHeader)` 时读包尾 1 字节与 `lpDesc->GetSequence()` 比,不符
→ `SetPhase(PHASE_CLOSE)` 断线,符 → `push_seq` + `SetNextSequence()`。
**两张表必须逐字节一致**,端口时整表 + 开启时机照抄。
6. **HYBRIDCRYPT**`GC_HYBRIDCRYPT_KEYS/SDB`, 152/153,动态包)—— 是**加密资源包(EterPack)的密钥下发**:
客户端 `RecvHybridCryptKeyPacket`/`SDBPacket` → `CEterPackManager::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_stream`1B 头 + size 表 + sequence + 动态包 + shipped DH2/CTR,打通
HANDSHAKE→LOGIN→SELECT→GAME 空跑。
4. `classic_parser``GC_LOGIN_SUCCESS` / `GC_MAIN_CHARACTER` / `GC_CHARACTER_ADD` / `GC_MOVE` /
`GC_CHARACTER_POINTS` —— 能进游戏、看见自己和别人动。
5. `classic_cipher`Crypto++ 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 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: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_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。
CTest `net.classic_wire` + `net.classic_stream` 绿。
- **step 2 — `INetSession` seam + `EntityStore` mutation API** ✅(部分):
`extension/src/net/i_net_session.h``connect / disconnect / select_char / enter_game / pump /
stage / world`per-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_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` 改收「已解码角度」(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(前半)— `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/13Godot 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||epub` blob`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。
- **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 里逐元素解包。`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/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,目标血条 %)。`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/15iOS/Android 绿。
- **step 4e — `classic_parser` HUD 簇** ✅:`GC_SKILL_LEVEL`(76`TPlayerSkill` 6B/条,`time_t`
32 位) / `GC_QUICKSLOT_ADD/DEL/SWAP`(28/29/30) / `GC_AFFECT_ADD/REMOVE`(126/127`TPacketAffectElement`
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)。`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/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,分配模式)。`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/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`)。
`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/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 串)。`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/15iOS/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` 覆盖
包尺寸、字段布局、序列字节和状态变更;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 子包,并接入 `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 改为
`EntityStore` typed eventM2Client 暴露对应 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 会驱动本地/远端实体,并按 40250 `CRaceMotionData` 的职业变体选择真实 `.msa`。
`kiss`、`french_kiss`、`slap`、`clap`、`cheer1/2`、`dance1..6`、`dig_motion` 及其余
`congratulation/forgive/angry/.../joy` ServerCommand 也映射到同一 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` 提供 `ShopSign` Label3D 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 入口保留,待后续恢复真服验收时使用。