DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,420 @@
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
}