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,396 @@
package dnfbridge
import (
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
func (s *Service) handleLegacyGamePacket(session *gameSession, raw []byte) error {
packet, err := dnfproto.ParseLegacyGamePacket(raw)
if err != nil {
return err
}
body := normalizeLegacyGameBody(packet.Header.Type, packet.Body)
runtimeName := runtimeCmdPacketName(packet.Header.Cmd, packet.Header.Type)
runtimeKnown := runtimeCmdPacketKnown(packet.Header.Cmd, packet.Header.Type)
s.logGamePacket(session, "RECV", "game-legacy", raw,
"cmd", packet.Header.Cmd,
"type", packet.Header.Type,
"runtime_cmd_name", runtimeName,
"runtime_cmd_known", runtimeKnown,
"header_seq", packet.Header.Sequence,
"body_len", len(packet.Body))
s.logPacketEvent("game-legacy-meta",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"cmd", packet.Header.Cmd,
"type", packet.Header.Type,
"runtime_cmd_name", runtimeName,
"runtime_cmd_known", runtimeKnown,
"sequence", packet.Header.Sequence,
"body_len", len(packet.Body),
"dispatch_body_len", len(body))
if len(body) != len(packet.Body) {
s.logPacketEvent("game-legacy-body-normalized",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"type", packet.Header.Type,
"runtime_cmd_name", runtimeName,
"runtime_cmd_known", runtimeKnown,
"raw_body_len", len(packet.Body),
"dispatch_body_len", len(body))
}
return s.handleGameCommand(session, packet.Header.Cmd, packet.Header.Type, body)
}
func normalizeLegacyGameBody(typ uint16, body []byte) []byte {
if normalized, ok := currentGameplayModules.NormalizeLegacy(typ, body); ok {
return normalized
}
if dnfenum.GameType(typ) == dnfenum.GameTypeSelectCharacter {
return stripLegacyCodecPrefix(body, 11)
}
return body
}
func stripLegacyBuySkillTransportTrailer(body []byte) []byte {
const (
headerLen = 2
entryLen = 4
tailLen = 1
trailerLen = 4
)
if len(body) < headerLen+tailLen+trailerLen {
return body
}
semanticLen := headerLen + int(body[1])*entryLen + tailLen
if len(body) != semanticLen+trailerLen {
return body
}
return append([]byte(nil), body[:semanticLen]...)
}
func stripLegacyEmblemCompoundTransportTrailer(body []byte) []byte {
if len(body) < 1+currentEmblemCompoundMinInputs*currentEmblemCompoundInputSize+4 {
return body
}
count := int(body[0])
semanticLength := 1 + count*currentEmblemCompoundInputSize
if count < currentEmblemCompoundMinInputs || count > currentEmblemCompoundMaxInputs || len(body) != semanticLength+4 {
return body
}
return append([]byte(nil), body[:semanticLength]...)
}
func stripLegacyCodecPrefix(body []byte, payloadLen int) []byte {
if payloadLen <= 0 || len(body) != payloadLen+5 {
return body
}
return append([]byte(nil), body[5:]...)
}
func stripLegacyTransportTrailer(body []byte, payloadLen int) []byte {
const trailerLen = 4
if payloadLen <= 0 || len(body) != payloadLen+trailerLen {
return body
}
return append([]byte(nil), body[:payloadLen]...)
}
func normalizeLegacySetQuestTriggerBody(body []byte) []byte {
const (
opcodeEchoLow = byte(dnfenum.CmdPacketSetQuestTrigger)
opcodeEchoHigh = byte(uint16(dnfenum.CmdPacketSetQuestTrigger) >> 8)
)
// A masked seven-byte op33 is native current-client data, not a zero
// trailer. Retain its 0x10/0x20/0x40 lane selector so the upper and legacy
// routes apply the same packed Hell Party objective transition. Only the
// old scalar six-byte clone with an appended zero can be safely shortened.
if len(body) == currentSetQuestTriggerMaskedRequestBodySize &&
body[0] == opcodeEchoLow &&
body[1] == opcodeEchoHigh &&
body[4] == 0 &&
(body[5] == 0 || body[5] == 1) &&
body[len(body)-1] == 0 {
return append([]byte(nil), body[:currentSetQuestTriggerRequestBodySize]...)
}
return stripLegacyTransportTrailer(body, currentSetQuestTriggerRequestBodySize)
}
func stripLegacyDieCharacterZeroTail(body []byte) []byte {
const legacyTailLen = 2
if len(body) != currentDungeonDieCharacterBodySize+legacyTailLen ||
body[currentDungeonDieCharacterBodySize] != 0 ||
body[currentDungeonDieCharacterBodySize+1] != 0 {
return body
}
return append([]byte(nil), body[:currentDungeonDieCharacterBodySize]...)
}
func runtimeCmdPacketName(cmd byte, typ uint16) string {
if dnfenum.GameCmd(cmd) != dnfenum.GameCmdCommand {
return ""
}
return dnfenum.CmdPacketName(typ)
}
func runtimeCmdPacketKnown(cmd byte, typ uint16) bool {
if dnfenum.GameCmd(cmd) != dnfenum.GameCmdCommand {
return false
}
return dnfenum.IsKnownCmdPacket(typ)
}
// slowLegacyRequestFields 构造 game-slow-legacy-request 的字段集。
//
// 与 slowUpperRequestFields 同理,独立成函数以便测试和线上共用同一份字段定义:
// 事件里刻意避开 "msg_id"/"type" 字段名,否则会被 suppressPacketLogEvent 的
// msg_id(1276/1516/1518) 与 type(35) 规则整条丢弃,诊断静默失效。
func slowLegacyRequestFields(
session *gameSession,
cmd byte,
typ uint16,
bodyLen int,
elapsed time.Duration,
) []any {
return []any{
"conn_id", session.connID,
"channel_id", session.channel.ID,
"request_type", typ,
"cmd", cmd,
"runtime_cmd_name", runtimeCmdPacketName(cmd, typ),
"runtime_cmd_known", runtimeCmdPacketKnown(cmd, typ),
"body_len", bodyLen,
"elapsed_ms", elapsed.Milliseconds(),
}
}
func (s *Service) handleGameCommand(session *gameSession, cmd byte, typ uint16, body []byte) error {
// 慢请求自诊断:放在 handleGameCommand 入口而不是它的调用方,因为现网进游戏
// 的主力流量是 kind=game-legacy,走 handleLegacyGamePacket -> handleGameCommand,
// 而 handleGameFrame 只覆盖 kind=game(transport)。放在这里一处即覆盖全部调用方。
// handleGameCommand 内有大量提前 return,故用 defer 统一收口;纯诊断,不改变
// 分发、返回值或任何发包内容。
slowRequestStarted := time.Now()
defer func() {
if elapsed := time.Since(slowRequestStarted); elapsed >= slowGameRequestThreshold {
s.logPacketEvent("game-slow-legacy-request",
slowLegacyRequestFields(session, cmd, typ, len(body), elapsed)...)
}
}()
if dnfenum.GameCmd(cmd) != dnfenum.GameCmdCommand {
return nil
}
if typ == currentRobotSessionBindOpcode {
return s.handleCurrentRobotSessionBind(session, body)
}
if handled, err := currentGameplayModules.DispatchLegacy(s, session, typ, body); handled || err != nil {
return err
}
switch dnfenum.GameType(typ) {
// class1/op689 在 legacy 形状下也要走同一条多路复用器:活动(升级预约 222、
// 周签到 2208)与建角后置状态共用这个 opcode,只按长度分不出来。
case dnfenum.GameType(dnfenum.UpperMsgCreatePostState):
return s.handleUpperCreatePostState(session, body)
// 活动的 legacy 形状(class 1)领取命令。同上:handler 已存在但没注册。
case dnfenum.GameType(812):
return s.handleCurrentReturneeNPCGift(session, body)
case dnfenum.GameType(currentMonthlyAttendanceClaimID):
return s.handleCurrentMonthlyAttendanceClaim(session, body)
case dnfenum.GameType(currentWeeklyAttendanceClaimID):
return s.handleCurrentWeeklyAttendanceClaim(session, body)
case dnfenum.GameTypeLogin:
// Current EXE evidence proves only the upper class1/op1 590/598-byte
// request emitted after CHANNELINFO. Keep legacy-shaped op1 silent.
s.logGameEvent(session, "game-legacy-endpoint-op1-ignored",
"body_len", len(body),
"reason", "legacy_endpoint_request_shape_not_proved")
return nil
case dnfenum.GameType(dnfenum.CmdPacketExit):
// Current op3/one-byte channel-exit is structurally ambiguous with the
// legacy frame and reaches this decoder in live traffic. It still owns
// the same class1/op3 ACK as the upper route.
return s.handleCurrentChannelExit(session, body, "legacy_plain_op3")
case dnfenum.GameTypeGetUserInfo:
// The name-change-record window reuses this opcode with mode=5. It must
// be answered with its own notification: the roster bootstrap below is
// only safe on the character-select screen and crashes the client when
// it is replayed into a live scene.
if handled, err := s.handleCurrentCharacterNameChangeLogRequest(session, body); handled || err != nil {
return err
}
if handled, err := s.handleCurrentPeerUserInfoRequest(session, body); handled || err != nil {
return err
}
// The hidden-probe-deferred and old WSTR bootstrap experiments both
// leave the current source-built upper-body-bypass client on a black
// selector. The user-verified visible role-select chain uses the
// fixed15 character-list route for legacy GET_USERINFO; upper op8 keeps
// the ordinary upper envelope.
return s.sendLegacyGetUserInfoBootstrap(session)
case dnfenum.GameTypeSelectCharacter:
s.logInfo("dnfbridge latest select character received",
"channel_id", session.channel.ID,
"body_len", len(body))
return s.sendSelectCharacterState(session, body)
case dnfenum.GameTypeEnterSelectDungeon:
s.logInfo("dnfbridge latest enter-select-dungeon received",
"channel_id", session.channel.ID,
"body_len", len(body))
return s.sendEnterSelectDungeonState(session, "game_cmd_15", true, true)
case dnfenum.GameType(dnfenum.CmdPacketDecreaseDurability):
return s.handleDungeonDecreaseDurability(session, body)
case dnfenum.GameType(dnfenum.CmdPacketPVPChannelInfo):
return s.handlePVPChannelInfo(session, body, "legacy")
case dnfenum.GameType(dnfenum.CmdPacketMakePVPRoom):
return s.handleMakePVPRoom(session, body, "legacy")
case dnfenum.GameType(dnfenum.CmdPacketFairPVPScore):
return s.handleCurrentPVPRecord(session, body)
case dnfenum.GameType(dnfenum.CmdPacketEnterPVPRoom):
return s.handleEnterPVPRoom(session, body)
case dnfenum.GameType(dnfenum.CmdPacketSetPVPSeatState):
return s.handleSetPVPSeatState(session, body, "legacy")
case dnfenum.GameType(dnfenum.CmdPacketSetPVPReadyState):
return s.handleSetPVPReadyState(session, body)
case dnfenum.GameType(dnfenum.CmdPacketRequestIntegratedMatching), dnfenum.GameType(dnfenum.CmdPacketCancelIntegratedMatching), dnfenum.GameType(dnfenum.CmdPacketIntegrateMatchingDirectComplete):
return s.handleCurrentPVPMatching(session, typ, body)
case dnfenum.GameType(dnfenum.CmdPacketDiePVPCharacter):
return s.handleCurrentPVPDeath(session, body)
case dnfenum.GameType(dnfenum.CmdPacketPVPMissionHpPercent), dnfenum.GameType(dnfenum.CmdPacketPVPMissionWinPose), dnfenum.GameType(dnfenum.CmdPacketPVPMissionFirstAerialAttack), dnfenum.GameType(dnfenum.CmdPacketPVPMissionComboDamageRate):
return s.handleCurrentPVPMissionReport(session, typ, body)
case dnfenum.GameType(dnfenum.CmdPacketPVPTimeOut):
return s.handleCurrentPVPTimeout(session, body)
case dnfenum.GameType(dnfenum.CmdPacketUsagePVPFatigue):
return s.handleCurrentPVPFatigueUsage(session, body)
case dnfenum.GameType(dnfenum.CmdPacketArcademodePVPRoundInfo):
return s.handleCurrentPVPArcadeRoundInfo(session, body)
case dnfenum.GameType(dnfenum.CmdPacketPVPFatigueReward):
return s.handleCurrentPVPArcadeResult(session, body)
case dnfenum.GameType(dnfenum.CmdPacketEndPVPResult):
return s.handleCurrentPVPResultEnd(session, body)
case dnfenum.GameType(dnfenum.CmdPacketCompleteLoadPVP), dnfenum.GameType(dnfenum.CmdPacketConnectP2pPVP):
return s.handleCurrentPVPMatchSignal(session, typ, body)
case dnfenum.GameType(dnfenum.CmdPacketSetPVPTeamMode):
return s.handleSetPVPTeamMode(session, body, "legacy")
case dnfenum.GameType(dnfenum.CmdPacketSetPVPMapIndex):
return s.handleSetPVPMapIndex(session, body, "legacy")
case dnfenum.GameTypeFinishLoading:
return s.sendFinishLoadingStatus(session, body)
case dnfenum.GameType(dnfenum.CmdPacketAgitWarInfo):
// Live current-client traffic sends legacy type1345 with u32(2)
// immediately after the first typed op24. It completes the deferred
// scene tail and HUD gauge only. Replaying actor/finish-loading state at
// this callback leaves the current EXE update loop with a null actor.
//
// The scene-ready check itself now lives INSIDE handleCurrentGuildWarInfo,
// which retains the already-owned scene tail first and only then reads the
// same packet as a 公会战 status query. That is upstream's order, and it is
// load-bearing in both directions: keeping the check here would swallow the
// war query, while dropping it would stop the type1345 ACK from completing
// the deferred tail at all and strand the player entering town.
if isCurrentLegacyTownSceneReadyAck(body) {
s.logGameEvent(session, "game-legacy-town-scene-ready-ack-accepted",
"type", typ,
"body_len", len(body),
"boundary", "after_typed_op24_deferred_tail_and_hud_gauge_only")
}
return s.handleCurrentGuildWarInfo(session, body)
case dnfenum.GameTypeContentsPlayInfo:
return s.sendContentsPlayInfoState(session, body)
case dnfenum.GameType(dnfenum.CmdPacketSendMessage):
return s.handleCurrentTownChat(session, body)
case dnfenum.GameType(dnfenum.CmdPacketItemHyperlinkMessage):
return s.handleCurrentTownChatHyperlink(session, body)
case dnfenum.GameType(dnfenum.CmdPacketSequentialDungeonInfo):
return s.handleCurrentSequentialDungeonInfo(session, body)
case dnfenum.GameType(dnfenum.CmdPacketRegisiterToBlacklist):
// legacy cmd=1 type=118, body = u32 nameLength + name (captured 2026-09-22).
return s.handleCurrentBlacklistRegister(session, body)
case dnfenum.GameType(dnfenum.CmdPacketDeleteToBlacklist):
// legacy cmd=1 type=119, same body shape as 118.
return s.handleCurrentBlacklistDelete(session, body)
case dnfenum.GameType(dnfenum.CmdPacketRequestBlacklist):
return s.sendRequestBlacklistState(session, "legacy_120_request", body)
case dnfenum.GameType(dnfenum.CmdPacketRequestUserChannel):
// 频道跟随: legacy cmd=1 type=804, body = u8 region + dstr 目标名。
// 响应必须是 class-1 同号 804(客户端 class-1 处理器 sub_3250DC0),
// 不能用 class-0 的同号包(class-0 的 804 是另一个功能)。
return s.handleCurrentUserChannelRequest(session, body)
case dnfenum.GameType(dnfenum.CmdPacketGuildAllMemberList):
return s.sendGuildAllMemberListState(session, "legacy_140_request", body)
case dnfenum.GameType(dnfenum.CmdPacketFpsDevideCollect):
return s.handleGameFpsDevideCollect(session, body)
case dnfenum.GameType(dnfenum.CmdPacketInformNotice2nd), dnfenum.GameType(dnfenum.CmdPacketOverflowInfo):
s.logPacketEvent("game-legacy-passive-report-deferred",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"type", typ,
"runtime_cmd_name", runtimeCmdPacketName(cmd, typ),
"body_len", len(body),
"reason", "client_runtime_report_no_reference_s2c_ack")
return nil
case dnfenum.GameTypeCreateCharacter:
return s.handleGameCreateCharacter(session, body)
case dnfenum.GameType(dnfenum.CmdPacketCheckPVPPrivateCharacter):
return s.handleCheckPVPPrivateCharacter(session, body)
case dnfenum.GameType(dnfenum.CmdPacketSetPVPTotalMatchCharacOrder):
return s.handleCurrentPVPTotalOrder(session, body)
case dnfenum.GameType(dnfenum.CmdPacketSetPVPTotalMatchTeam), dnfenum.GameType(dnfenum.CmdPacketCheckPVPTotalMatchTeamName), dnfenum.GameType(dnfenum.CmdPacketSetPVPTotalMatchTeamName):
return s.handlePVPTotalTeam(session, uint16(typ), body)
case dnfenum.GameTypeStaticsRuntimeTing:
s.logInfo("dnfbridge latest statics runtime report received; no response",
"type", typ,
"runtime_cmd_name", runtimeCmdPacketName(cmd, typ),
"body_len", len(body))
return nil
case dnfenum.GameTypeCheckName:
return s.handleGameCheckName(session, body)
case dnfenum.GameType(dnfenum.CmdPacketDeleteCharacter):
return s.handleDeleteCharacter(session, body)
case dnfenum.GameType(dnfenum.CmdPacketChangeCharacSlot):
return s.handleChangeCharacterSlot(session, body, false)
case dnfenum.GameType(currentCmdRepresentNameDuplicateCheck):
return s.handleRepresentAccountNameDuplicateCheck(session, body)
case dnfenum.GameType(currentCmdUpdateRepresentAccountName), dnfenum.GameType(currentCmdChangeRepresentAccountName):
return s.handleUpdateRepresentAccountName(session, body, typ)
case dnfenum.GameType(dnfenum.CmdPacketStoryDigestUpdate):
// The current client reaches bodyless op1445 through the legacy game
// decoder even when the semantic command is a class-1 upper command.
// Keep one owner for both transports so the accepted story digest is
// advanced monotonically instead of replaying on every login.
return s.handleCurrentStoryDigestAccepted(session, dnfproto.DefaultChannelClassification, body)
default:
if handled, err := s.handleAlignedGameCommand(session, cmd, typ, body); handled || err != nil {
return err
}
if rejected, err := s.rejectUnsupportedInteractiveCommand(
session,
typ,
len(body),
"legacy",
); rejected || err != nil {
return err
}
s.logInfo("dnfbridge unhandled latest game command",
"cmd", cmd,
"type", typ,
"runtime_cmd_name", runtimeCmdPacketName(cmd, typ),
"runtime_cmd_known", runtimeCmdPacketKnown(cmd, typ),
"body_len", len(body))
}
return nil
}
func isCurrentLegacyTownSceneReadyAck(body []byte) bool {
return len(body) == 4 &&
body[0] == 2 &&
body[1] == 0 &&
body[2] == 0 &&
body[3] == 0
}