服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
421 lines
14 KiB
Go
421 lines
14 KiB
Go
package protocol
|
||
|
||
import "encoding/binary"
|
||
|
||
// LatestGameStreamKind 标记 game 端口里混合出现的最新协议帧类型。
|
||
type LatestGameStreamKind uint8
|
||
|
||
const (
|
||
LatestGameStreamTransport LatestGameStreamKind = 1
|
||
LatestGameStreamUpper LatestGameStreamKind = 2
|
||
LatestGameStreamLegacy LatestGameStreamKind = 3
|
||
LatestGameStreamDproto LatestGameStreamKind = 4
|
||
|
||
currentDungeonMoveMapPlainBodySize = 100
|
||
currentTownSetUserAreaPlainBodySize = 16
|
||
currentAcceptQuestPlainBodySize = 4
|
||
currentSetQuestTriggerPlainBodySize = 6
|
||
currentDieCharacterPlainBodySize = 4
|
||
currentUseCoinPlainBodySize = 2
|
||
currentBossDieCheckPlainBodySize = 39
|
||
currentTutorialFlagPlainBodySize = 6
|
||
currentPlayResultBaseBodySize = 42
|
||
currentPlayResultOptionalBodySize = 46
|
||
currentPlayResultDynamicRowSize = 7
|
||
currentPlayResultMaximumDynamicRows = 8
|
||
currentChannelReconnectProbeSize = 31
|
||
observed90CNShortEndpointBodySize = 586
|
||
currentChannelDisplayRebindSize = 590
|
||
reference90CNChannelDisplayRebindBodySize = 598
|
||
observed90CNCrackEndpointBodySize = 610
|
||
observed90CNShortEndpointMaxBodySize = 630
|
||
current90CNEndpointShortVariantSize = 4
|
||
current90CNEndpointAdapterRecordSize = 32
|
||
current90CNEndpointMaxAdapterRecords = 32
|
||
)
|
||
|
||
// LatestGameStreamPacket 是从 game TCP 字节流切出的完整包。
|
||
type LatestGameStreamPacket struct {
|
||
Kind LatestGameStreamKind
|
||
Data []byte
|
||
}
|
||
|
||
// SplitLatestGameStream 从 game TCP 流中同时切出 transport frame 和 raw upper 包。
|
||
//
|
||
// 最新客户端在 game 连接上既会发送 12+8+7 的 game transport,也会发送
|
||
// 13 字节频道包头格式的 raw upper 包;拆流时必须先识别 raw upper,再按
|
||
// legacy game 和 frame+2 的 u16 totalLen 处理 transport。
|
||
func SplitLatestGameStream(buffer []byte, maxPacketBytes int) (packets []LatestGameStreamPacket, remaining []byte, skipped int, err error) {
|
||
if maxPacketBytes <= 0 {
|
||
maxPacketBytes = int(^uint16(0))
|
||
}
|
||
offset := 0
|
||
for offset <= len(buffer)-4 {
|
||
if packet, wait, ok := splitDprotoClientPacket(buffer, offset, maxPacketBytes); ok {
|
||
packets = append(packets, LatestGameStreamPacket{
|
||
Kind: LatestGameStreamDproto,
|
||
Data: packet,
|
||
})
|
||
offset += len(packet)
|
||
continue
|
||
} else if wait {
|
||
break
|
||
}
|
||
if packet, wait, ok := splitUpperPacket(buffer, offset, maxPacketBytes); ok {
|
||
packets = append(packets, LatestGameStreamPacket{
|
||
Kind: LatestGameStreamUpper,
|
||
Data: packet,
|
||
})
|
||
offset += len(packet)
|
||
continue
|
||
} else if wait {
|
||
break
|
||
}
|
||
if packet, wait, ok := splitLegacyGamePacket(buffer, offset, maxPacketBytes); ok {
|
||
packets = append(packets, LatestGameStreamPacket{
|
||
Kind: LatestGameStreamLegacy,
|
||
Data: packet,
|
||
})
|
||
offset += len(packet)
|
||
continue
|
||
} else if wait {
|
||
break
|
||
}
|
||
|
||
frameLength := int(binary.LittleEndian.Uint16(buffer[offset+2 : offset+4]))
|
||
if frameLength < MinTCPFrameSize {
|
||
offset++
|
||
skipped++
|
||
continue
|
||
}
|
||
if frameLength > maxPacketBytes {
|
||
return nil, nil, skipped, ErrPacketTooLarge
|
||
}
|
||
if len(buffer)-offset < frameLength {
|
||
break
|
||
}
|
||
packets = append(packets, LatestGameStreamPacket{
|
||
Kind: LatestGameStreamTransport,
|
||
Data: cloneBytes(buffer[offset : offset+frameLength]),
|
||
})
|
||
offset += frameLength
|
||
}
|
||
return packets, cloneBytes(buffer[offset:]), skipped, nil
|
||
}
|
||
|
||
func splitDprotoClientPacket(buffer []byte, offset int, maxPacketBytes int) ([]byte, bool, bool) {
|
||
available := len(buffer) - offset
|
||
if available < ChannelHeaderSize {
|
||
return nil, false, false
|
||
}
|
||
if buffer[offset] != DefaultChannelClassification ||
|
||
binary.LittleEndian.Uint16(buffer[offset+1:offset+3]) != DprotoClientEnvelopeOpcode {
|
||
return nil, false, false
|
||
}
|
||
length := int(binary.LittleEndian.Uint32(buffer[offset+3 : offset+7]))
|
||
if length < ChannelHeaderSize || length > maxPacketBytes {
|
||
return nil, false, false
|
||
}
|
||
if available < length {
|
||
return nil, true, false
|
||
}
|
||
return cloneBytes(buffer[offset : offset+length]), false, true
|
||
}
|
||
|
||
func splitLegacyGamePacket(buffer []byte, offset int, maxPacketBytes int) ([]byte, bool, bool) {
|
||
available := len(buffer) - offset
|
||
if available < LegacyGameHeaderSize {
|
||
return nil, false, false
|
||
}
|
||
cmd := buffer[offset]
|
||
if cmd != 0 && cmd != 1 {
|
||
return nil, false, false
|
||
}
|
||
length := int(binary.LittleEndian.Uint32(buffer[offset+3 : offset+7]))
|
||
if length < LegacyGameHeaderSize {
|
||
return nil, false, false
|
||
}
|
||
if length > maxPacketBytes {
|
||
return nil, false, false
|
||
}
|
||
if available < length {
|
||
return nil, true, false
|
||
}
|
||
packet := cloneBytes(buffer[offset : offset+length])
|
||
if _, err := ParseLegacyGamePacket(packet); err != nil {
|
||
return nil, false, false
|
||
}
|
||
return packet, false, true
|
||
}
|
||
|
||
func splitUpperPacket(buffer []byte, offset int, maxPacketBytes int) ([]byte, bool, bool) {
|
||
available := len(buffer) - offset
|
||
if available < ChannelHeaderSize {
|
||
return nil, false, false
|
||
}
|
||
classification := buffer[offset]
|
||
if classification != 0 && classification != DefaultChannelClassification {
|
||
return nil, false, false
|
||
}
|
||
length := int(binary.LittleEndian.Uint32(buffer[offset+3 : offset+7]))
|
||
if length < ChannelHeaderSize {
|
||
return nil, false, false
|
||
}
|
||
if length > maxPacketBytes {
|
||
return nil, false, false
|
||
}
|
||
if packet, ok := splitHookedShortCreateUpper(buffer, offset, available, length); ok {
|
||
return packet, false, true
|
||
}
|
||
if packet, ok := splitHookedCurrentSelectUpper(buffer, offset, available, length); ok {
|
||
return packet, false, true
|
||
}
|
||
if available < length {
|
||
return nil, true, false
|
||
}
|
||
packet := cloneBytes(buffer[offset : offset+length])
|
||
parsed, err := ParseChannelPacketUnchecked(packet)
|
||
if err != nil {
|
||
return nil, false, false
|
||
}
|
||
if !isKnownClientUpperMsg(parsed.Header.MsgID, parsed.Body) {
|
||
return nil, false, false
|
||
}
|
||
return packet, false, true
|
||
}
|
||
|
||
func splitHookedCurrentSelectUpper(buffer []byte, offset int, available int, headerLength int) ([]byte, bool) {
|
||
const (
|
||
selectMessageID = 16
|
||
staleSelectBodyLength = 32
|
||
currentSelectBodySize = 21
|
||
)
|
||
msgID := binary.LittleEndian.Uint16(buffer[offset+1 : offset+3])
|
||
if msgID != selectMessageID || headerLength != ChannelHeaderSize+staleSelectBodyLength {
|
||
return nil, false
|
||
}
|
||
actualLength := ChannelHeaderSize + currentSelectBodySize
|
||
if available < actualLength {
|
||
return nil, false
|
||
}
|
||
packet := cloneBytes(buffer[offset : offset+actualLength])
|
||
// The local hook copies the current 21-byte op16 body but leaves the old
|
||
// 32-byte body length in the upper header. Correct only that observed shape
|
||
// so the following packet header is not consumed as op16 data.
|
||
binary.LittleEndian.PutUint32(packet[3:7], uint32(actualLength))
|
||
return packet, true
|
||
}
|
||
|
||
func splitHookedShortCreateUpper(buffer []byte, offset int, available int, headerLength int) ([]byte, bool) {
|
||
msgID := binary.LittleEndian.Uint16(buffer[offset+1 : offset+3])
|
||
if msgID != 5 || headerLength <= ChannelHeaderSize {
|
||
return nil, false
|
||
}
|
||
bodyOffset := offset + ChannelHeaderSize
|
||
actualBodyLength, ok := hookedPlainCreateBodyLength(buffer[bodyOffset:])
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
actualLength := ChannelHeaderSize + actualBodyLength
|
||
if actualLength >= headerLength {
|
||
return nil, false
|
||
}
|
||
if available < actualLength {
|
||
return nil, false
|
||
}
|
||
packet := cloneBytes(buffer[offset : offset+actualLength])
|
||
// NoPack 本地 msg5 hook 只替换 body,没有同步修正 upper 头里的 length。
|
||
// 真实创建体长度由 nameLen+固定 8 字节外观选项决定,不能写死,否则会吞掉后续 1276 心跳头。
|
||
binary.LittleEndian.PutUint32(packet[3:7], uint32(actualLength))
|
||
// NoPack 本地 msg5 hook 只替换 body,未同步修正 upper header length;
|
||
// 服务端按实际 21 字节创建体切包,避免吞掉后续 1276 心跳头。
|
||
binary.LittleEndian.PutUint32(packet[3:7], uint32(actualLength))
|
||
return packet, true
|
||
}
|
||
|
||
func hookedPlainCreateBodyLength(body []byte) (int, bool) {
|
||
if len(body) < 5 {
|
||
return 0, false
|
||
}
|
||
if body[0] > 0x3f {
|
||
return 0, false
|
||
}
|
||
nameLen := int(binary.LittleEndian.Uint32(body[1:5]))
|
||
if nameLen < 2 || nameLen > 30 {
|
||
return 0, false
|
||
}
|
||
actualBodyLength := 5 + nameLen + 8
|
||
if len(body) < actualBodyLength {
|
||
return 0, false
|
||
}
|
||
return actualBodyLength, true
|
||
}
|
||
|
||
func isKnownClientUpperMsg(msgID uint16, body []byte) bool {
|
||
bodyLen := len(body)
|
||
switch msgID {
|
||
case 1:
|
||
return isCurrent90CNEndpointBody(body)
|
||
case 2:
|
||
// A channel transfer opens the target game port without requesting the
|
||
// ordinary roster and emits this exact 31-byte native upper probe.
|
||
// Recognize it before the overlapping legacy header parser.
|
||
return bodyLen == currentChannelReconnectProbeSize
|
||
case 4, 7, 0x000f, 0x0010, 0x003f, 0x0078, 0x02a7, 0x02b1, 0x02b2, 0x02b5:
|
||
return true
|
||
case 31:
|
||
// Current EXE sub_1C53100 writes class-1/op31 as the exact
|
||
// u16 opcode echo followed by a u16 quest id. Requiring both the
|
||
// four-byte size and echo prevents a same-shaped legacy packet from
|
||
// being cut as raw upper traffic.
|
||
return bodyLen == currentAcceptQuestPlainBodySize && binary.LittleEndian.Uint16(body[:2]) == msgID
|
||
case 33:
|
||
// Current SET_QUEST_TRIGGER is the u16 opcode echo, u16 quest id,
|
||
// u8 trigger channel, and u8 increment flag. The selective DPROTO
|
||
// clone form carries a four-byte trailer and therefore intentionally
|
||
// falls through to the legacy decoder for exact normalization.
|
||
return bodyLen == currentSetQuestTriggerPlainBodySize && binary.LittleEndian.Uint16(body[:2]) == msgID
|
||
case 39:
|
||
if bodyLen == 62 {
|
||
return true
|
||
}
|
||
if bodyLen < 66 {
|
||
return false
|
||
}
|
||
count := int(body[22])
|
||
return count <= 254 && bodyLen == 66+count*10
|
||
case 40:
|
||
// Current EXE normal-dungeon writers emit exactly two u16 actor
|
||
// coordinates. They are not an actor id and are not server authority.
|
||
return bodyLen == currentDieCharacterPlainBodySize
|
||
case 41:
|
||
// Current EXE normal revive requests emit one u16 target object key.
|
||
return bodyLen == currentUseCoinPlainBodySize
|
||
case 42:
|
||
return bodyLen == 0
|
||
case 45:
|
||
return bodyLen == currentDungeonMoveMapPlainBodySize
|
||
case 36:
|
||
return bodyLen == currentTownSetUserAreaPlainBodySize
|
||
case 46:
|
||
return isCurrentPlayResultPlainBodySize(bodyLen)
|
||
case 117:
|
||
return bodyLen == currentBossDieCheckPlainBodySize
|
||
case 123:
|
||
return bodyLen == 24
|
||
case 132:
|
||
return bodyLen == 0
|
||
case 143:
|
||
return bodyLen == currentTutorialFlagPlainBodySize
|
||
case 295:
|
||
// Current EXE character-slot exchange writes exactly two u32 slot
|
||
// indexes. The 13-byte upper header overlaps the legacy game header,
|
||
// so this exact boundary must be recognized before legacy parsing.
|
||
return bodyLen == 8
|
||
case 560:
|
||
// Current EXE sub_1DB82D0/sub_1DB88B0 starts class-1/op560 and
|
||
// immediately flushes it without writing a request body. Recognize the
|
||
// exact boundary so the 13-byte raw upper packet is not mistaken for a
|
||
// same-shaped legacy game packet.
|
||
return bodyLen == 0
|
||
// 2026-06-30 hook/packet_log 对照证明这些号是 msg1 后的 latest raw upper 上行。
|
||
// 其中 3/8/593 等号会和旧 cmd/type 重叠,必须用 body 长度避免旧 legacy 包误切。
|
||
case 3:
|
||
return bodyLen == 16
|
||
case 5:
|
||
if actualBodyLength, ok := hookedPlainCreateBodyLength(body); ok && actualBodyLength == bodyLen {
|
||
return true
|
||
}
|
||
return bodyLen == 24 || bodyLen == 32
|
||
case 8:
|
||
return bodyLen == 8
|
||
case 171:
|
||
return bodyLen == 48
|
||
case 194:
|
||
return bodyLen == 368
|
||
case 415:
|
||
return bodyLen == 0
|
||
case 250:
|
||
return bodyLen == 64
|
||
case 279:
|
||
return bodyLen == 16
|
||
case 441:
|
||
return bodyLen == 8
|
||
case 476:
|
||
return bodyLen == 16
|
||
case 477:
|
||
return bodyLen == 8
|
||
case 593:
|
||
return bodyLen == 8 || bodyLen == 24
|
||
case 645:
|
||
return bodyLen == 0
|
||
case 1262:
|
||
return bodyLen == 32
|
||
case 1276:
|
||
return bodyLen == 0
|
||
case 1286:
|
||
return bodyLen == 16
|
||
case 1287:
|
||
return bodyLen == 8
|
||
case 1320:
|
||
return bodyLen == 32
|
||
case 1516:
|
||
return bodyLen > 0
|
||
case 1518:
|
||
return bodyLen > 0
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isCurrent90CNEndpointBody(body []byte) bool {
|
||
bodyLen := len(body)
|
||
shortVariant := bodyLen >= observed90CNShortEndpointBodySize &&
|
||
bodyLen <= observed90CNShortEndpointMaxBodySize &&
|
||
(bodyLen-observed90CNShortEndpointBodySize)%current90CNEndpointShortVariantSize == 0
|
||
extendedVariant := bodyLen >= reference90CNChannelDisplayRebindBodySize+current90CNEndpointAdapterRecordSize &&
|
||
bodyLen <= reference90CNChannelDisplayRebindBodySize+current90CNEndpointAdapterRecordSize*current90CNEndpointMaxAdapterRecords &&
|
||
(bodyLen-reference90CNChannelDisplayRebindBodySize)%current90CNEndpointAdapterRecordSize == 0
|
||
if !shortVariant && !extendedVariant {
|
||
return false
|
||
}
|
||
|
||
// The current 90CN endpoint request has a fixed client-spec envelope,
|
||
// a lowercase 32-byte hexadecimal token, and a NUL terminator. These
|
||
// invariants distinguish the admitted length family from the overlapping
|
||
// legacy cmd=1/type=1 frame.
|
||
if binary.LittleEndian.Uint32(body[0:4]) != 1 ||
|
||
body[4] != 0x30 || body[5] != 0x20 ||
|
||
body[6] != 0 || body[7] != 0 || body[8] != 0 ||
|
||
body[41] != 0 || binary.LittleEndian.Uint32(body[42:46]) != 8 ||
|
||
body[54] != 0 || body[55] != 2 || body[56] != 0 || body[57] != 0 ||
|
||
body[58] != 1 || body[59] != 1 || body[60] != 0 ||
|
||
!isObserved90CNClientSpecTail(body[61]) {
|
||
return false
|
||
}
|
||
for _, value := range body[9:41] {
|
||
if !isLowerHexASCII(value) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func isObserved90CNClientSpecTail(value byte) bool {
|
||
return value == 0x16 || value == 0x1c || value == 0x2e
|
||
}
|
||
|
||
func isLowerHexASCII(value byte) bool {
|
||
return value >= '0' && value <= '9' || value >= 'a' && value <= 'f'
|
||
}
|
||
|
||
func isCurrentPlayResultPlainBodySize(bodyLen int) bool {
|
||
for rows := 0; rows <= currentPlayResultMaximumDynamicRows; rows++ {
|
||
if bodyLen == currentPlayResultBaseBodySize+rows*currentPlayResultDynamicRowSize ||
|
||
bodyLen == currentPlayResultOptionalBodySize+rows*currentPlayResultDynamicRowSize {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|