新接手 996 引擎客户端的人,打开 network/networkUtil.lua 看到满屏的 i1、I4、C32、i8s,十个有八个当场想关编辑器。这套类型记号其实是整个引擎里最规矩的部分——它就是一份"字段宽度对照表",看懂一张表,网络层半边天就通了。今天从这张表讲起,讲到封包、发包、抓包调试,把二开最常碰的网络活一次说全。
先把核心表原样贴出来,这就是引擎作者定的"字典":
G_LEN_BYTE = {
i1 = 1, -- 有符号 1 字节,bool/小枚举
I1 = 1, -- 无符号 1 字节
i2 = 2, -- 有符号短整型
i4 = 4, -- 有符号整型,最常用
I4 = 4, -- 浮点(注意大写是浮点不是无符号!)
i8 = 8, -- 64位整型,金币/经验专用
I8 = 8, -- 64位浮点
C16 = 17, -- 定长字符串 16:2字节长度头 + 16 内容
C32 = 33, -- 定长字符串 32:2字节长度头 + 32 内容
C50 = 51 -- 定长字符串 50:2字节长度头 + 50 内容
}
大小写的门道要刻在脑子里:小写整型家族,大写浮点家族,C 开头是带 2 字节长度头的字符串。最容易踩的坑是 I4——按直觉该是"无符号 4 字节",实际是 ReadFloat。为什么这么定?因为协议文档年代久远,I 在这里代表的是 IBM 风格的浮点记法,一代代传下来了。你照直觉把血量字段写成 I4,读出来是 3.7E-38 这种天文数字,还以为服务端疯了,其实是你自己类型选错了。
字符串的 C16/C32/C50,数字是容量,总宽要加 2 字节长度头。所以 C32 = 33,C50 = 51。写协议文档时跟服务端对字段,一定按"总宽"对,别按"容量"对,不然你算的包大小永远差 2 的倍数。
类型定了,读写各有一张函数表,成对出现:
G_BUFFER_READ = {
i1 = function (b) return b:ReadBool() end,
i2 = function (b) return b:ReadShort() end,
i4 = function (b) return b:ReadInt() end,
i8 = function (b) return b:ReadInt64() end,
C32 = function (b) return SL:GBKToUTF8(b:ReadShortString(32)) end,
C50 = function (b) return SL:GBKToUTF8(b:ReadShortString(50)) end
}
注意 C32 那行尾巴上的 GBKToUTF8。服务端是 GBK 编码的老底子,客户端 UI 全线 UTF-8,转码就压在解包这一瞬间。你要是自己新写了读字符串的路径忘了转码,中文全是问号——不是乱码那种花哨的问号,就是朴素的"???",玩家截图发群里,一天之内全服皆知。
写包方向同理,WriteShortString(32, value) 超长截断。玩家改名功能上线前务必拿 16 个汉字的极限名字测一遍,我见过写 17 个汉字直接把后面字段全挤歪的惨案——定长字符串没有边界检查,挤歪了后面所有字段错位,解出来的金币数能变成负八百万。
汉字在这套协议里还有个隐藏宽度问题:GBK 一个汉字占 2 字节,所以 C32 名义上能装 32 个"字节",实际只能装 16 个汉字。策划写需求"昵称最长 16 字",客户端输入框限 16 汉字,服务端按 32 字节校验,看着都对,但玩家用半角符号混搭输入,字节宽度和字符宽度对不上,边界值翻车。改名功能的测试用例我固定放三条:16 汉字、16 英文、8 汉字加 8 英文混排,三条都过才敢上线。
TCP 层没有"包"的概念,只有字节流,所谓分包是客户端自己按长度头切的。RecvNetPackage 干的就是这个活:从缓冲区头读 2 字节长度,够长就切一条完整消息出来,不够就攒着等下一波。理解了这个,两个经典现象就都解释得通了。
现象一,"偶发解包错误后满屏报错"。一条消息的长度头被污染,切出来的"包"长度是错的,切完缓冲区错位,后面每条消息全歪——一个坏包毒死一锅。所以抓到解包异常,别盯着那条报错的消息看,往前找是谁把缓冲区写歪的,凶手永远在上游。
现象二,"合服当天消息乱序"。合服后服务端消息号重新编排过,老客户端按旧表解,把 A 消息的体按 B 消息的结构读,长度对得上、内容全错,还不报错——这比报错吓人,数据悄悄进内存。合服前把消息号对照表打出来双边核对,一行都不能差,这是我用一次凌晨四点的回档换来的纪律。
整个收发链路就三个函数,LuaRegisterMsgHandler 注册、LuaSendBytes 发送、SendTableToServer 发 JSON:
function LuaRegisterMsgHandler(msgID, netCB)
global.networkCtl:RegisterHandler(msgID, function(msg)
if _DEBUG and _LOG_RECV_ABLE then
local header = msg:GetHeader()
print("recv net msg", msgID, header.recog, header.param1,
header.param2, header.param3, msg:GetDataLength())
end
netCB(msg)
end)
end
消息头五个字段 recog / param1 / param2 / param3 / ident 是传奇协议的祖传骨架,recog 通常塞角色 ID 或数值主体,param 们塞辅助参数。二开加新协议时别嫌它土,照着用——服务端 C++ 层按这套头分发,你自定义头结构等于让网关瞎眼。
SendTableToServer 是给"结构化数据"准备的捷径,一张 Lua 表 cjson 编码后整包发过去:
function SendTableToServer(msgID, tableData)
local jsonStr = jencode(tableData)
if not jsonStr then
print(msgID, "json data encode error")
return nil
end
LuaSendMsg(msgID, 0, 0, 0, 0, jsonStr, slen(jsonStr))
end
什么时候用裸字段,什么时候用 JSON?经验值:高频战斗包(移动、攻击)用裸字段,一字节都要省;低频功能包(邮件、好友、设置)用 JSON,字段可读、前后端好对表。有个团队把移动包也改成 JSON,高峰期同屏五百人,带宽直接翻三倍——JSON 的花括号引号键名,在每秒三十次的移动包里全是纯税。
不过 JSON 也不是不能上战斗链路,得看方向。客户端发给服务端的上行包,人少频低,用 JSON 换开发效率,值;服务端发给客户端的下行包,人多频高,老老实实裸字段。同一个功能两条方向两套编码,听着别扭,账算下来是最优解。还有个折中货:数值字段用裸包,尾巴上挂一段 C50 的 JSON 备注,主数据走快车道,扩展信息走慢车道,两头都占。我们后来几个大型活动协议都是这么开的,服务端加字段不用动客户端主解包逻辑,只需要读备注里有没有新键,混服兼容期好过得很。
flowchart LR
A[业务层发消息] --> B{结构化数据?}
B -->|是| C[SendTableToServer cjson编码]
B -->|否| D[按 G_BUFFER_WRITE 逐字段写]
C --> E[LuaSendBytes]
D --> E
E --> F[networkCtl 底层发送]
F --> G[服务端处理]
G --> H[客户端 LuaRegisterMsgHandler 注册的回调]
H --> I[按 G_BUFFER_READ 逐字段解]
很多人装 Wireshark 抓传奇协议,抓了个寂寞——私有协议没有 dissector 全是十六进制粥。引擎自己留了后门,把 _DEBUG 和 _LOG_RECV_ABLE 两个全局设 true,收发包自动进统计表:
_LOG_RECV_NET_MSG[msgID] = _LOG_RECV_NET_MSG[msgID] or {}
_LOG_RECV_NET_MSG[msgID].hits = (_LOG_RECV_NET_MSG[msgID].hits or 0) + 1
_LOG_RECV_NET_MSG[msgID].msgId = msgID
_LOG_RECV_NET_MSG[msgID].dataLen = msg:GetDataLength()
hits 是这个消息号收到几次,dataLen 是平均包长。排查"流量异常"类问题特别好使:有个服反馈晚上八点卡,把统计表一拉,某个活动消息号 hits 每分钟四千次,正常应该六十次——前端埋了个刷新循环每秒拉一次活动列表,服务端还每条都认真回。这种问题抓包看包内容反而绕,看频次统计一眼就穿。
调试完记得把开关关回去。这两个开关开着,每条消息多一次 print,战斗高潮每秒几百条 print,帧率掉的锅最后又扣到引擎头上,冤。
再教一招进阶的:给统计表加个时间维度。引擎统计的是累计 hits,你每分钟把 hits 快照进自己的表,两分钟一对比,增量就是当下流量分布。有个服排查"半夜流量尖峰",就是这么抓到的:凌晨两点某个公告消息号每分钟固定八百发,一查是运营配的跑马灯定时器写错了周期。工具是现成的,缺的只是往前多想一步。
下面这个演示把"字段宽度"和"包的一生"拼在了一起:点按钮往包里加字段,看总宽怎么涨、字节流长什么样;拉延迟、勾丢包,点发送心跳看包从客户端飞到服务端再飞回来,丢了还会自动重发——真实引擎里判重靠的是心跳序号,不是玄学:
fx-net-msgpack-1003a
老案例但特别典型:背包从 40 格扩到 80 格,服务端改完,客户端一打开背包,后半背包全显示" Undefined "。排查一小时,根子是客户端解包还在按 40 格读——服务端发了 80 格的数据,客户端只认前 40,剩下 40 格的字节躺在缓冲区里没人认领,把下一条消息的头都污染了,连环报错。
修法不难,RecvNetPackage 里把循环上限改成 len / 16(每格 16 字节),按实际包长算。但教训值钱:协议是合同,改动必须双边同版本上线。后来我们给所有包加了版本字节,第一个字段固定 i1,客户端发现版本号对不上直接提示"请重启客户端更新",宁可挡住不让进,也不让脏数据进内存。
那次改造还顺手发现了第三个坑:背包格子结构里有个 C16 的"附加说明"字段,空格子服务端也老老实实发 16 字节的空串,80 格就是 1280 字节的废话。改成"说明为空就不发、用一个字节表示条目数"的可变结构,背包包从 1.4KB 瘦到 300B。高峰期千人多地图,就这一个包省出来的带宽,服务端带宽账单少了两成。协议优化不看不知道,一看全是肉。
第二课是字段顺序永不动。要加字段只能往尾部加,中间插一个字段,新老客户端混服期间解包全错位。有个团队嫌尾部字段乱"整理了一下顺序",混服当晚满屏飘血量负数,紧急回滚。协议字段表要像历史档案一样供着,注释写清哪年哪版加的,不动旧字段是底线。
二开加协议最忌讳随手挑个空闲数字。我们团队现在的规矩是消息号分段管理:1000 以下原版保留区一个不碰;1000 到 1999 是战斗区;2000 到 2999 是社交区;3000 往上是活动区;9000 以上留给调试。每段头一条消息号旁边注释写清申请人和日期。就一张文本表格的事,但省掉的是"两拨人用了同一个号"这种最难查的事故——两边解包都说得通,就是数据互相串,没有分段表你连怀疑方向都没有。
分段之外再立一条:新消息号必须先在 netMessageDef.lua 登记再使用。这个文件就是全引擎的号簿,所有消息号常量集中一处。曾经有项目组为了赶活动直接在业务代码里写魔法数字 3721,三个月后另一个活动也用了 3721,两个活动互相触发,玩家领奖励领出连环炸。号簿登记看着官僚,实则是给三个月后的自己留活路。
问:i8 的金币会不会溢出?
Int64 上限约 9.2E18,正常玩法到不了。但 Lua 5.1 的 number 是 double,整数精度只有 53 位,中间换算用 i8s(Int64 字符串)走,引擎专门留了 ReadInt64Str 就是为这个。直接把 i8 塞进 number 做加减,超过 9007199254740992 就悄悄丢精度,账对不上查三天。
问:服务端发包顺序客户端必须按序解吗?
必须。G_BUFFER_READ 是顺序消费缓冲区的游标,跳一个字段全盘错位。可选字段要么用个数前导,要么用固定长度占位,不能"服务端觉得没有就不发"。
问:心跳包发什么内容最合适?
越空越好,一个 i2 序号足够。有人往心跳里塞状态同步,把 20 字节的心跳调成 300 字节,等于自己给自己加了十倍心跳税。心跳只负责"我还活着",业务归业务包。
问:断线重连怎么把状态补齐?
重连后服务端会推一批"全量快照"消息(背包、血量、位置各一条),客户端收到快照前要冻结 UI 输入,不然玩家会看到自己"死而复生"血量来回跳。引擎的做法是重连期间进一张半透明遮罩,快照齐了才撤,体验上就是一瞬间的过场。
问:弱网环境下包太多怎么办?
先开统计表看 hits 排行,把前十名里的高频包逐个过堂:能不能合批(同屏飘字合成一条)、能不能降频(位置同步 10Hz 够用就别 30Hz)、能不能改增量(背包变化只发变了的格子)。一般三轮下来总包量砍六成,比任何"网络优化插件"都实在。
👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…
评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…
评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…
评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…
做排行榜、背包、邮件列表的兄弟,迟早会遇到同一张工单:"列表一打开掉帧,滑动像幻灯片"。99% 的原因是把一千行数据老老实实…
新接手 996 引擎客户端的人,打开 network/networkUtil.lua 看到满屏的 i1 、 I4 、 C32…