packet.CLConnect 详解
这篇文档是写给第一次接触这个项目的人看的。
目标不是只告诉你 "packet.CLConnect 里有哪几个字段",而是把下面这些问题一次讲清楚:
CLConnect 是什么,处在登录流程的哪个位置。
- 它的包体到底长什么样,按字节怎么排。
packet.lua 里的 new / ctor / bis / bos 分别在干什么。
- 为什么
net_provider 现在被标成了“未使用”。
- 为什么
account / version / ip 虽然都被读出来了,但真正参与当前 Lua 登录服逻辑的只有一部分。
- 旧 Login C++ 参考代码和当前项目是什么关系,哪些能直接借,哪些不能照抄。
1. 先记住它在什么位置
CLConnect 是客户端发给登录服的一个“连接阶段包”。
在当前项目里,它对应的包号是:
packet.XYID_CLCONNECT = 355
定义位置:
services/login/packet.lua
处理位置:
services/login/server.lua
services/login/logind.lua
你可以把它理解成:
- 客户端 TCP 连接建立。
- 客户端先发一个
CLConnect。
- 登录服收到后,先记录一些连接阶段信息。
- 登录服回一个
LCRetConnect。
- 客户端再继续发
CLAskLogin,进入真正的登录认证阶段。
也就是说:
CLConnect 不是“真正校验账号密码”的包。
- 它更像“登录前握手 / 连接信息上报 / 服务端返回登录入口信息”的第一步。
2. 相关文件分别负责什么
为了不一上来就陷进细节里,先把几个关键文件记住:
services/login/packet.lua
这里定义了所有登录相关包的 Lua 结构。
对 CLConnect 来说,这里负责两件事:
- 定义字段长什么样。
- 负责把原始二进制包解析成 Lua 对象。
services/login/server.lua
这里是通用收发包层。
它负责:
- 按
xy_id 找到对应的包定义。
- 调用
p.new() 创建包对象。
- 调用
p:bis(message) 把二进制包体解析成字段。
services/login/logind.lua
这里是真正的登录业务逻辑。
它会根据 packet.xy_id 判断“收到的是哪一种包”,然后进入不同分支。
CLConnect 就是在这里被消费的。
lualib/iostream.lua
这是底层二进制读写工具。
它负责:
readuint() 这种“按数字读”
read(0x20) 这种“按固定长度字节串读”
writeuint() / write() 这种反向写包
如果你第一次看 packet.lua,一定要知道:
packet.lua 本身并不直接处理字节,它只是调用 iostream.lua 提供的二进制读写接口。
3. CLConnect 在 Lua 里的原始定义
当前代码如下:
packet.CLConnect = {
xy_id = packet.XYID_CLCONNECT,
new = function()
local o = {}
setmetatable(o, {__index = packet.CLConnect})
o:ctor()
return o
end,
ctor = function(self)
self.net_provider = 0 -- 网络线路/运营商线路标识(未使用)。
self.account = ""
self.version = ""
self.ip = ""
end,
bis = function(self, buffer)
local stream = bistream.new()
stream:attach(buffer)
self.net_provider = stream:readuint()
self.account = stream:read(0x32)
self.version = stream:read(0x20)
self.ip = stream:read(0x18)
end,
bos = function(self)
local stream = bostream.new()
stream:writeuint(self.net_provider)
stream:write(self.account, 0x32)
stream:write(self.version, 0x20)
stream:write(self.ip, 0x18)
end
}
这段代码虽然不长,但里面每一行都有意义。后面我们逐个拆。
4. 先看最重要的结论:包体布局
CLConnect 的包体在当前项目里可以按下面的方式理解:
| 偏移 |
长度 |
字段 |
类型 |
说明 |
0x00 |
0x04 |
net_provider |
uint32 |
网络线路/运营商线路标识 |
0x04 |
0x32 |
account |
固定长度字节串 |
账号字段,当前 Lua 登录服不使用它做认证 |
0x36 |
0x20 |
version |
固定长度字节串 |
连接阶段版本串,当前 Lua 登录服会记录它 |
0x56 |
0x18 |
ip |
固定长度字节串 |
客户端自报 IP,当前 Lua 登录服不信任它 |
包体总长度:
0x04 + 0x32 + 0x20 + 0x18 = 0x6E
也就是十进制 110 字节。
这个布局不是只凭 packet.lua 猜出来的。
我们前面的逆向对照里,已经确认当前客户端版本的 Game.exe 在 CLConnect 收发时,包体顺序也是:
4 + 0x32 + 0x20 + 0x18
所以这里可以把它当成“当前 Lua 定义与当前客户端版本一致”的结果来看。
这里一定要注意一个新手最容易混淆的点:
xy_id = 355 不是这个 110 字节包体的一部分。
xy_id 是外层包号。
bis(buffer) 里收到的 buffer,已经是“剥掉包号之后的包体”了。
所以你在 bis() 里看到的 4 + 0x32 + 0x20 + 0x18,就是纯包体布局,不包含包头。
5. xy_id 到底是怎么起作用的
很多人第一次看 packet.lua,会误以为 xy_id 也是 bis() 从字节流里读出来的。
其实不是。
真正流程在 services/login/server.lua:
local function unpack_message(id, message)
local xy_id = id
local p = packets[xy_id]
if p then
p = p.new()
p:bis(message)
return p
end
end
这段逻辑说明:
- 外层网络层先把
id 和 message 分开了。
id 用来决定应该实例化哪种包对象。
- 真正传给
bis() 的,只是包体 message。
所以 packet.CLConnect.xy_id = 355 的作用是:
- 告诉分发器:收到
355 时,应该创建 packet.CLConnect 对象。
它不是 CLConnect 包体内部的一个字段。
6. new() 和 ctor() 到底在做什么
new()
new = function()
local o = {}
setmetatable(o, {__index = packet.CLConnect})
o:ctor()
return o
end,
这里做了三件事:
- 先创建一个空表
o。
- 给这个表挂上元表,让
o.xxx 找不到时去 packet.CLConnect.xxx 里找。
- 调用
ctor() 给字段赋默认值。
也就是说,packet.CLConnect 本质上不是 class 关键字创建的类,而是“用 Lua table + metatable 模拟对象”。
ctor()
ctor = function(self)
self.net_provider = 0 -- 网络线路/运营商线路标识(未使用)。
self.account = ""
self.version = ""
self.ip = ""
end,
ctor() 的意义主要有两个:
- 给对象一个稳定、可预期的初始状态。
- 让调用方即使在
bis() 之前访问字段,也不会遇到 nil。
这里的默认值也暗示了字段类型:
net_provider 是数字
account / version / ip 是字符串
7. bis() 是真正的“收包解析器”
bis 可以理解成 binary input stream。
代码:
bis = function(self, buffer)
local stream = bistream.new()
stream:attach(buffer)
self.net_provider = stream:readuint()
self.account = stream:read(0x32)
self.version = stream:read(0x20)
self.ip = stream:read(0x18)
end,
这段逻辑按顺序做了四件事:
- 创建一个
bistream
- 把原始包体
buffer 挂上去
- 从当前位置读取一个
uint32
- 再顺序读取三个固定长度字节串
最关键的点是:
- 这是“顺序读取”
- 没有字段名,没有 tag,没有 JSON key
- 读的时候必须和客户端写的时候顺序完全一致
一旦顺序错一位,后面所有字段都会整体错位。
这就是二进制协议最典型的特征。
8. readuint() 和 read(0x32) 的区别
这个细节对第一次看项目的人非常重要。
readuint()
在 lualib/iostream.lua 里:
function bistream:readuint()
local start = self.pos
local s = string.sub(self.buffer, start)
local n = string.unpack("I4", s)
self.pos = self.pos + 4
return n
end
这意味着:
- 从当前偏移读取 4 个字节
- 按
uint32 解释
- 读取后游标向后移动 4 个字节
在当前项目实际运行环境里,可以把它理解成“按小端 4 字节无符号整数读”。
所以:
net_provider 是数值字段
- 它不是原始字符串
- 也不是 4 字节原样 hex 文本
read(0x32)
在 lualib/iostream.lua 里:
function bistream:read(len)
local start = self.pos
local endd = self.pos + len - 1
local s = string.sub(self.buffer, start, endd)
self.pos = self.pos + len
return s
end
这意味着:
- 它只是把这
len 个字节原样切出来
- 不做数值解释
- 不自动去掉末尾
\0
所以 account / version / ip 在 Lua 里其实都是“固定长度原始字节串”。
你后续如果要把它们转成更自然的字符串,经常还要自己处理:
- 结尾补零
- 截断
string.match
- 编码问题
9. 每个字段分别应该怎么理解
下面按字段逐个讲。
9.1 net_provider
当前字段名:
self.net_provider = 0 -- 网络线路/运营商线路标识(未使用)。
这个字段之前叫 unknow,后来改成了 net_provider。
改名原因不是拍脑袋,而是有逆向依据:
- 旧 Login C++ 参考里,
CLConnect 有一个明确命名的字段 mNetProvider
- 老的
CLConnectHandler::Execute 会把这个字段存起来
- 后面再走按线路选择代理入口的逻辑
所以这个字段的语义不是“随机值”,也不是“验证码”,而是:
- 客户端上报自己想走哪条线
- 或者客户端声明自己当前归属哪类网络线路
可以把它理解成:
这里要特别强调:
当前 Lua 登录服虽然已经能把它读出来,但没有真正使用它。
也就是说,现阶段它只是“解析出来了”,并没有进入业务分支。
这也是为什么注释里写了 (未使用)。
9.2 account
self.account = stream:read(0x32)
这个字段长度固定是 0x32,也就是 50 字节。
新手很容易下意识以为:
但当前项目并不是这样走的。
当前 Lua 登录服真正做账号登录,是在后续 CLAskLogin 分支里。
也就是说:
CLConnect.account 会被解析
- 但当前
logind.lua 处理 CLConnect 时没有使用它
如果你问“那它为什么还在包里”:
- 从当前代码能确定的是“它存在且被客户端发来了”
- 但从当前 Lua 逻辑看,它并不是认证主路径需要的字段
更稳妥的说法是:
- 它是协议里的连接阶段冗余/预备字段
- 当前服务端没有消费它
这里我特意不用更强的结论,是因为“它为什么存在”属于推测问题;我们目前能确定的是“当前 Lua 登录服没有用它”。
9.3 version
self.version = stream:read(0x20)
这是 CLConnect 里当前 Lua 登录服唯一明确保存下来的字段。
在 logind.lua 里:
if packet.xy_id == packet_def.XYID_CLCONNECT then
connect_versions[fd] = packet.version
...
end
这说明:
- 它是连接阶段版本串
- 服务端把它按连接
fd 记录起来
- 到后面真正处理
CLAskLogin 时,再拿出来一起写日志
注意它和后面 CLAskLogin.uVersion 不是同一种东西:
CLConnect.version 是固定 0x20 字节字符串
CLAskLogin.uVersion 是一个数值版本号
两者不要混为一谈。
9.4 ip
self.ip = stream:read(0x18)
它是客户端自己上报的一个固定长度 IP 字段,长度 24 字节。
但当前 Lua 登录服并不信这个字段。
实际业务里用的是:
local ip, port = string.match(handshake[fd], "(.+):(.+)")
也就是服务端从 socket 层记录的真实对端地址。
这点非常重要。
如果你第一次接项目,很容易误以为:
packet.ip 就是服务端最终拿来判定来源 IP 的值
其实不是。
当前代码里真正被信任的是:
handshake[fd]
- 也就是连接建立时服务端看到的对端地址
而不是客户端自己在包里写的 ip。
这是比较合理的,因为客户端自报 IP 本来就不可信。
10. CLConnect 在当前 Lua 登录服里的真实处理链
这一段是最值得新手反复看一遍的。
第一步:收到外层包号和包体
server.lua 里,网络层把消息拆成:
然后:
local p = packets[xy_id]
p = p.new()
p:bis(message)
到这里为止,packet.CLConnect 只是一个被正确解析出来的 Lua 对象。
第二步:进入 logind.lua 的 CLConnect 分支
逻辑大致如下:
if packet.xy_id == packet_def.XYID_CLCONNECT then
connect_versions[fd] = packet.version
ret = packet_def.LCRetConnect.new()
...
ret.login_challenge = 3474034634
loginservice.send(fd, idx, ret)
end
这一段里,你能看到当前服务端实际上只做了这些事:
- 记录
packet.version
- 构造一个
LCRetConnect
- 回给客户端登录 IP / 端口 / challenge
没有做的事包括:
- 没有使用
packet.net_provider
- 没有使用
packet.account
- 没有使用
packet.ip
也就是说,CLConnect 在当前 Lua 登录服里的作用更接近:
- 作为连接阶段握手包存在
- 提供一个版本串给后面日志使用
- 然后触发服务端回
LCRetConnect
第三步:等客户端发 CLAskLogin
真正开始账号密码处理,是后面的 CLAskLogin 分支。
这也是为什么你会看到:
CLConnect 里虽然也有 account
- 但当前登录服实际认证并不是用它完成的
11. 为什么 net_provider 明明有语义,却还标“未使用”
这是一个很典型、也很值得新手习惯的项目现象:
- 协议字段有明确语义
- 但当前 Lua 服务端并没有把它接入业务逻辑
这两件事并不矛盾。
对 CLConnect.net_provider 来说,现在的状态就是:
已经明确的部分
- 它是一个 4 字节数值字段
- 它不是随机数
- 它对应线路/运营商线路选择语义
当前没接上的部分
- Lua 登录服没有根据它切换
login_ip
- Lua 登录服没有根据它切换
login_port
- Lua 登录服没有把它存到
fd 关联状态里
- Lua 登录服没有做基于线路的策略判断
所以注释写“未使用”是为了提醒后来的人:
- 字段不是没意义
- 只是当前这套 Lua 实现里还没有消费它
12. 旧 Login C++ 参考代码应该怎么用
这一块非常容易踩坑,所以单独讲。
旧 Login 参考里,CLConnect::Read 大致是:
Read(&this->mMiBao, 1);
Read(&this->mNetProvider, 4);
如果你第一次看到这个,很容易得出一个错误结论:
那当前 Lua 里的 CLConnect 也应该只有 5 个字节。
这是不对的。
正确理解是:
旧 Login 参考能提供什么
- 它能提供字段语义锚点
- 尤其是
mNetProvider 这个名字非常有价值
旧 Login 参考不能直接照抄什么
- 不能把它的整包长度原样套到当前 Lua
- 不能假设当前客户端的
CLConnect 还和旧 Login 一模一样
我们现在的判断是:
- 当前 Lua 里的
CLConnect 包体更长
- 现客户端确实会发送
4 + 0x32 + 0x20 + 0x18 这样的布局
- 旧 Login 参考主要用来帮助确认“前面那个 4 字节字段的语义”
所以你在做项目接手时,最好形成这个习惯:
- 用旧代码确认“这个字段大概是干什么的”
- 用当前客户端 / 当前 Lua 代码确认“现在这个版本具体长什么样”
两边都要看,不能只看一边。
13. 为什么 CLConnect.bos() 里没有 return,现在却没出问题
这是一个非常值得新手注意的小细节。
当前 CLConnect.bos() 是这样写的:
bos = function(self)
local stream = bostream.new()
stream:writeuint(self.net_provider)
stream:write(self.account, 0x32)
stream:write(self.version, 0x20)
stream:write(self.ip, 0x18)
end
注意:
- 它没有像很多别的包一样
return stream:get()
如果你去看 loginserver.send():
local message, size = packet:bos()
那你会发现:
- 如果真拿
CLConnect 去调用 loginservice.send()
- 那这里理论上会拿不到
message
为什么当前项目没炸?
因为当前服务端并不会主动给客户端发 CLConnect。
当前实际收发方向是:
CLConnect:客户端 -> 服务端
LCRetConnect:服务端 -> 客户端
所以 CLConnect.bos() 现在更像一个“没被用到的反向构包函数”。
这也是你以后维护时要记住的一点:
- 当前没出问题,不等于这段代码天然没问题
- 只是因为当前运行路径没走到它
如果以后你真要让服务端主动构造 CLConnect,那这里应该补上:
return stream:get()
14. 为什么 version 被保存,而 account / ip / net_provider 没有
从当前实现看,登录服对 CLConnect 的取舍标准很明显:
- 能直接服务当前流程的,就留
- 当前流程用不到的,就先不接
version
被保存到:
connect_versions[fd]
用途是后面记录登录日志时带上连接阶段版本信息。
account
当前没有保存,因为真正登录认证使用的是后续 CLAskLogin 里的账号相关字段。
ip
当前没有保存,因为服务端更信任 socket 层看到的真实地址,而不是客户端自报。
net_provider
当前没有保存,因为这套 Lua 登录服还没有做“按线路回不同登录入口”的逻辑。
15. 如果以后要启用 net_provider,应该从哪里开始改
如果未来你要把这个字段真正用起来,建议从下面这个方向接:
第一步:在 CLConnect 分支里先保存下来
例如:
connect_net_provider[fd] = packet.net_provider
第二步:回 LCRetConnect 时按线路决定返回哪个地址
就是在构造:
ret.login_ip
ret.login_port
之前,根据 packet.net_provider 选线路。
第三步:断开或登录完成后清理状态
和 connect_versions[fd] 一样,在合适时机清掉。
第四步:增加日志
建议至少打出:
fd
net_provider
- 最终下发的
login_ip
- 最终下发的
login_port
这样后面排线问题时会轻松很多。
16. 新手最容易犯的几个误区
误区 1:xy_id 在 bis() 里读
不是。
xy_id 是外层包号,先由网络层拆出来,再决定用哪个包对象去解析 message。
误区 2:account 一定是当前登录服真正拿来查库的账号
不是当前这套逻辑。
真正认证是后面的 CLAskLogin。
误区 3:ip 一定可信
不可信。
当前服务端使用的是 socket 记录的真实对端地址。
误区 4:net_provider 标了“未使用”就等于“没有语义”
不是。
它有语义,只是当前 Lua 登录服没有消费。
误区 5:旧 Login 参考代码里的结构可以直接照搬
不能直接照搬。
旧代码主要拿来确认字段语义,不是拿来替代当前版本包布局的。
17. 如果你想自己验证这篇文档,可以从哪里下手
最适合新手的验证顺序如下:
验证 1:确认 CLConnect 的 Lua 包定义
看:
services/login/packet.lua
确认:
xy_id = 355
net_provider / account / version / ip
验证 2:确认收包分发流程
看:
services/login/server.lua
确认:
xy_id 用来选包类型
bis(message) 只吃包体
验证 3:确认 CLConnect 的业务分支
看:
services/login/logind.lua
确认:
- 只保存了
packet.version
- 没用
packet.net_provider
- 没用
packet.account
- 没用
packet.ip
验证 4:确认底层读写函数
看:
确认:
readuint() 是数值读取
read(len) 是原样截取固定长度字节串
18. 当前可以给出的最稳结论
如果你只想记住最核心的结论,可以记下面这几条:
packet.CLConnect 是登录流程里的连接阶段包,包号 355。
- 当前 Lua 定义的包体布局是:
net_provider + account + version + ip。
net_provider 的语义是“网络线路/运营商线路标识”,但当前 Lua 登录服未使用。
- 当前 Lua 登录服在
CLConnect 分支里真正用到的只有 version。
account 和 ip 虽然会被解析,但当前不参与核心登录逻辑。
- 旧 Login C++ 参考代码可以帮助确认
net_provider 的语义,但不能原样照抄整包结构。
CLConnect.bos() 目前没有 return stream:get(),只是因为当前服务端不主动发送 CLConnect,所以暂时没暴露问题。
19. 一句话总结
CLConnect 在当前项目里最准确的理解是:
一个登录前的连接阶段包。
当前 Lua 登录服主要拿它做“记录连接版本并进入下一步握手”,其中 net_provider 字段已经确认是线路标识,但还没有真正接入业务逻辑。