DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,797 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// slowGameRequestThreshold 是 game-upper / game-legacy 请求的慢请求告警门限。
|
||||
//
|
||||
// 2026-09-11 现网延迟排查:按命令聚合 packet_log 里「收到请求 -> 首个响应」的耗时后,
|
||||
// BLACKLIST/LOGIN/CERA/HIDDEN_CHARAC_INFO 等命令稳定在 0~3ms,说明 bridge 的通用
|
||||
// 分发路径本身不慢;但选角链路里存在只靠离线日志无法归因的数百毫秒空档(例如
|
||||
// select-inventory-bootstrap-committed 到 crystal-contract-state-send 之间 640ms
|
||||
// 内既无收包也无发包,且无任何事件)。玩家侧无法复现,因此让服务器自己记录:
|
||||
// 纯诊断,不改变任何握手顺序、发包内容或既有事件。
|
||||
const slowGameRequestThreshold = 200 * time.Millisecond
|
||||
|
||||
// slowUpperRequestFields 构造 game-slow-upper-request 的字段集。
|
||||
//
|
||||
// 刻意独立成函数而不是内联在 defer 里:这样测试可以和线上调用同一个函数来断言
|
||||
// 「该事件不会被 suppressPacketLogEvent 丢弃」。曾经踩过的坑:事件带了 "msg_id"
|
||||
// 字段,而 suppressPacketLogEvent 会按 msg_id 命中 1276/1516/1518 直接返回 true,
|
||||
// 那三个恰恰是最可能变慢的高频 opcode —— 诊断会在最需要的时候静默哑火。
|
||||
// 如果测试是照抄一份字段列表,改坏线上代码测试也不会失败,所以必须共用此函数。
|
||||
func slowUpperRequestFields(
|
||||
session *gameSession,
|
||||
packet dnfproto.ChannelPacket,
|
||||
runtimeName string,
|
||||
runtimeKnown bool,
|
||||
wireBodyLen int,
|
||||
elapsed time.Duration,
|
||||
) []any {
|
||||
return []any{
|
||||
"conn_id", session.connID,
|
||||
"channel_id", session.channel.ID,
|
||||
"request_msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"classification", packet.Header.Classification,
|
||||
"header_seq", packet.Header.Seq,
|
||||
"wire_body_len", wireBodyLen,
|
||||
"elapsed_ms", elapsed.Milliseconds(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) handleGameUpper(session *gameSession, raw []byte) error {
|
||||
packet, err := dnfproto.ParseChannelPacketUnchecked(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtimeName := upperRuntimeCmdPacketName(packet.Header.MsgID, packet.Header.Classification)
|
||||
runtimeKnown := upperRuntimeCmdPacketKnown(packet.Header.MsgID, packet.Header.Classification)
|
||||
wireBodyLen := len(packet.Body)
|
||||
channelReconnectLifecycleBody := isCurrentChannelReconnectLifecycleBody(
|
||||
packet.Header.MsgID,
|
||||
packet.Header.Classification,
|
||||
wireBodyLen,
|
||||
)
|
||||
clientBodyCodecMode := s.gameUpperClientBodyCodecMode()
|
||||
clientBodyCodec := gameUpperClientBodyCodecPlain
|
||||
clientBodyDecoded := false
|
||||
// Current live quest replays use the plaintext upper body mode. Once the
|
||||
// exact terminal request has been acknowledged in this TCP session, skip it
|
||||
// before packet logging as well as before the owner. Otherwise a client
|
||||
// retry storm turns into synchronous disk I/O even when the handler replies
|
||||
// silently.
|
||||
if clientBodyCodecMode == gameUpperClientBodyCodecPlain &&
|
||||
shouldSuppressKnownQuestReplayBeforeGameUpperLog(session, packet.Header.MsgID, packet.Header.Classification, packet.Body) {
|
||||
return nil
|
||||
}
|
||||
s.logGamePacket(session, "RECV", "game-upper", raw,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"classification", packet.Header.Classification,
|
||||
"header_seq", packet.Header.Seq,
|
||||
"wire_body_len", wireBodyLen,
|
||||
"client_body_codec_mode", clientBodyCodecMode)
|
||||
// 慢请求自诊断:handleGameUpper 有大量提前 return 的分支,因此用 defer 统一收口。
|
||||
// 只发事件,不做任何提前返回、重试或状态修改。
|
||||
slowRequestStarted := time.Now()
|
||||
defer func() {
|
||||
if elapsed := time.Since(slowRequestStarted); elapsed >= slowGameRequestThreshold {
|
||||
s.logPacketEvent("game-slow-upper-request",
|
||||
slowUpperRequestFields(session, packet, runtimeName, runtimeKnown, wireBodyLen, elapsed)...)
|
||||
}
|
||||
}()
|
||||
if packet.Header.MsgID == currentCommandCapabilityOpcode {
|
||||
if isCurrentDungeonBossMutationEnvelope(packet.Body) {
|
||||
return s.handleCurrentDungeonBossMutation(
|
||||
session,
|
||||
packet.Header.Classification,
|
||||
packet.Body,
|
||||
)
|
||||
}
|
||||
return s.handleCurrentCommandCapabilities(
|
||||
session,
|
||||
packet.Header.Classification,
|
||||
packet.Body,
|
||||
)
|
||||
}
|
||||
if !channelReconnectLifecycleBody &&
|
||||
clientBodyCodecMode != gameUpperClientBodyCodecPlain &&
|
||||
packet.Header.Classification == dnfproto.DefaultChannelClassification &&
|
||||
len(packet.Body) > 0 {
|
||||
decodedBody, codecName, supported, decodeErr := decodeCurrentUpperClientBody(packet.Header.MsgID, packet.Body)
|
||||
if supported && decodeErr == nil {
|
||||
s.logPacketEvent("game-upper-client-body-decode",
|
||||
"conn_id", session.connID,
|
||||
"channel_id", session.channel.ID,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"classification", packet.Header.Classification,
|
||||
"sequence", packet.Header.Seq,
|
||||
"mode", clientBodyCodecMode,
|
||||
"codec", codecName,
|
||||
"wire_body_len", wireBodyLen,
|
||||
"decoded_body_len", len(decodedBody))
|
||||
if clientBodyCodecMode == gameUpperClientBodyCodecNative {
|
||||
packet.Body = decodedBody
|
||||
clientBodyCodec = codecName
|
||||
clientBodyDecoded = true
|
||||
}
|
||||
} else {
|
||||
reason := "unsupported_codec_slot"
|
||||
codecForLog := codecName
|
||||
if supported && decodeErr != nil {
|
||||
reason = decodeErr.Error()
|
||||
}
|
||||
allowOpaqueControl := shouldAllowOpaqueUpperClientBodyControl(packet.Header.MsgID, packet.Header.Classification, supported, decodeErr)
|
||||
s.logPacketEvent("game-upper-client-body-decode-deferred",
|
||||
"conn_id", session.connID,
|
||||
"channel_id", session.channel.ID,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"classification", packet.Header.Classification,
|
||||
"sequence", packet.Header.Seq,
|
||||
"mode", clientBodyCodecMode,
|
||||
"codec", codecForLog,
|
||||
"wire_body_len", wireBodyLen,
|
||||
"reason", reason,
|
||||
"opaque_control_allowed", allowOpaqueControl)
|
||||
if clientBodyCodecMode == gameUpperClientBodyCodecNative {
|
||||
if allowOpaqueControl {
|
||||
clientBodyCodec = "idx6-kasumi-opaque-control"
|
||||
s.logPacketEvent("game-upper-client-body-opaque-control",
|
||||
"conn_id", session.connID,
|
||||
"channel_id", session.channel.ID,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"classification", packet.Header.Classification,
|
||||
"sequence", packet.Header.Seq,
|
||||
"mode", clientBodyCodecMode,
|
||||
"codec", clientBodyCodec,
|
||||
"wire_body_len", wireBodyLen,
|
||||
"reason", "ida_1518_body_not_consumed_current_live_idx6_key_is_instance_local")
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.logPacketEvent("game-upper-meta",
|
||||
"conn_id", session.connID,
|
||||
"channel_id", session.channel.ID,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"classification", packet.Header.Classification,
|
||||
"sequence", packet.Header.Seq,
|
||||
"wire_body_len", wireBodyLen,
|
||||
"body_len", len(packet.Body),
|
||||
"client_body_codec_mode", clientBodyCodecMode,
|
||||
"body_decoded", clientBodyDecoded,
|
||||
"body_codec", clientBodyCodec)
|
||||
if handled, err := currentGameplayModules.DispatchUpper(
|
||||
s,
|
||||
session,
|
||||
packet.Header.MsgID,
|
||||
packet.Header.Classification,
|
||||
packet.Body,
|
||||
); handled || err != nil {
|
||||
return err
|
||||
}
|
||||
switch dnfenum.UpperMsg(packet.Header.MsgID) {
|
||||
// 活动(onlineevent)的领取/引导命令。这三个 handler 早就写好了,但一直没
|
||||
// 注册到派发表上——玩家点"领取"时服务端什么都不做,回包长度为 0。
|
||||
case dnfenum.UpperMsg(currentReturneeGuideID), dnfenum.UpperMsg(currentReturneeClaimID):
|
||||
if packet.Header.Classification != dnfproto.DefaultChannelClassification {
|
||||
return nil
|
||||
}
|
||||
return s.handleCurrentReturneeCommand(session, packet.Header.MsgID, packet.Body)
|
||||
case dnfenum.UpperMsg(currentWeeklyAttendanceClaimID):
|
||||
if packet.Header.Classification != dnfproto.DefaultChannelClassification {
|
||||
return nil
|
||||
}
|
||||
return s.handleCurrentWeeklyAttendanceClaim(session, packet.Body)
|
||||
case dnfenum.UpperMsg(currentMonthlyAttendanceClaimID):
|
||||
if packet.Header.Classification != dnfproto.DefaultChannelClassification {
|
||||
return nil
|
||||
}
|
||||
return s.handleCurrentMonthlyAttendanceClaim(session, packet.Body)
|
||||
case dnfenum.UpperMsgGameEndpoint:
|
||||
return s.handleCurrentGameEndpointRequest(session, packet.Header.Classification, wireBodyLen)
|
||||
case dnfenum.UpperMsgCharacterRoster:
|
||||
if packet.Header.Classification == dnfproto.DefaultChannelClassification &&
|
||||
wireBodyLen == currentChannelReconnectProbeSize {
|
||||
// The same-version reference and live socket ownership prove this
|
||||
// 31-byte op2 is a channel-reconnect probe. Party endpoints are
|
||||
// learned from the native UDP probe sockets; the port carried here
|
||||
// is accepted only as a fallback that the probe has already
|
||||
// observed from this connection's address (see
|
||||
// captureCurrentPartyPeerEndpointFromOp2).
|
||||
if markCurrentChannelReconnect(session, wireBodyLen) {
|
||||
s.logGameEvent(session, "game-channel-reconnect-detected",
|
||||
"source", "op2_31_byte_probe",
|
||||
"body_len", wireBodyLen,
|
||||
"target_channel", session.channel.ID)
|
||||
}
|
||||
s.captureCurrentPartyPeerEndpointFromOp2(session, packet.Body)
|
||||
}
|
||||
return nil
|
||||
case dnfenum.UpperMsgSelectCharacter:
|
||||
if markCurrentChannelReconnectOnSelection(session) {
|
||||
s.logGameEvent(session, "game-channel-reconnect-detected",
|
||||
"source", "op4_without_roster",
|
||||
"body_len", wireBodyLen,
|
||||
"target_channel", session.channel.ID)
|
||||
}
|
||||
s.logInfo("dnfbridge latest upper select character received; sending csharp init stream",
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"body_len", len(packet.Body))
|
||||
s.logPacketEvent("game-upper-select-character-csharp-init",
|
||||
"conn_id", session.connID,
|
||||
"channel_id", session.channel.ID,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"body_len", len(packet.Body))
|
||||
if err := s.sendUpperCSharpSelectInit(session, packet.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
// The first selected-character projection finishes loading several
|
||||
// request-driven PVF views. Schedule one post-response scavenge outside
|
||||
// the packet critical path so their temporary parse graphs do not remain
|
||||
// resident until the next normal GC target.
|
||||
s.scheduleInitialSceneMemoryTrim()
|
||||
return nil
|
||||
case dnfenum.UpperMsgGetUserInfo:
|
||||
session.rosterRequested = true
|
||||
s.logInfo("dnfbridge latest upper get-userinfo received; sending roster",
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"body_len", len(packet.Body))
|
||||
s.logPacketEvent("game-upper-get-userinfo-bootstrap",
|
||||
"conn_id", session.connID,
|
||||
"channel_id", session.channel.ID,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"body_len", len(packet.Body))
|
||||
return s.sendUpperGetUserInfoBootstrap(session)
|
||||
case dnfenum.UpperMsgCharacViewHiddenInfo:
|
||||
s.logInfo("dnfbridge latest upper hidden character info probe received",
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"body_len", len(packet.Body))
|
||||
s.logPacketEvent("game-upper-hidden-charac-info-rebirth-hardcore-success",
|
||||
"conn_id", session.connID,
|
||||
"channel_id", session.channel.ID,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"body_len", len(packet.Body),
|
||||
"response_msg_id", uint16(dnfenum.UpperMsgRebirthHardcoreCharac),
|
||||
"reason", "snapshot_112000_646_success_body")
|
||||
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.UpperMsgRebirthHardcoreCharac), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if session.pendingCharacterRosterBootstrap {
|
||||
session.pendingCharacterRosterBootstrap = false
|
||||
s.logGameEvent(session, "game-upper-hidden-charac-info-flush-pending-roster",
|
||||
"source", "charac_view_hidden_info",
|
||||
"reason", "legacy_getuserinfo_roster_waited_for_hidden_probe")
|
||||
if err := s.sendUpperGetUserInfoRosterBootstrapFixed16(session); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.sendCurrentSelectorAdventureInfoAfterHiddenProbe(session)
|
||||
case dnfenum.UpperMsgCreateCharacter:
|
||||
return s.handleUpperCreateCharacter(session, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketCheckPVPPrivateCharacter):
|
||||
if packet.Header.Classification != dnfproto.DefaultChannelClassification {
|
||||
return nil
|
||||
}
|
||||
return s.handleCheckPVPPrivateCharacter(session, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketSetPVPTotalMatchCharacOrder):
|
||||
if packet.Header.Classification != dnfproto.DefaultChannelClassification {
|
||||
return nil
|
||||
}
|
||||
return s.handleCurrentPVPTotalOrder(session, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketSetPVPTotalMatchTeam), dnfenum.UpperMsg(dnfenum.CmdPacketCheckPVPTotalMatchTeamName), dnfenum.UpperMsg(dnfenum.CmdPacketSetPVPTotalMatchTeamName):
|
||||
if packet.Header.Classification != dnfproto.DefaultChannelClassification {
|
||||
return nil
|
||||
}
|
||||
return s.handlePVPTotalTeam(session, packet.Header.MsgID, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketDeleteCharacter):
|
||||
if packet.Header.Classification != dnfproto.DefaultChannelClassification {
|
||||
s.logGameEvent(session, "game-character-delete-blocked",
|
||||
"classification", packet.Header.Classification,
|
||||
"body_len", len(packet.Body),
|
||||
"reason", "current_exe_command_class_mismatch")
|
||||
return nil
|
||||
}
|
||||
return s.handleDeleteCharacter(session, packet.Body)
|
||||
case dnfenum.UpperMsgCheckDoubleCharName:
|
||||
return s.handleGameCheckName(session, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketChangeCharacSlot):
|
||||
if packet.Header.Classification != dnfproto.DefaultChannelClassification {
|
||||
s.logGameEvent(session, "game-character-slot-change-blocked",
|
||||
"classification", packet.Header.Classification,
|
||||
"body_len", len(packet.Body),
|
||||
"reason", "current_exe_command_class_mismatch")
|
||||
return nil
|
||||
}
|
||||
return s.handleChangeCharacterSlot(session, packet.Body, true)
|
||||
case dnfenum.UpperMsg(currentCmdRepresentNameDuplicateCheck):
|
||||
if packet.Header.Classification != dnfproto.DefaultChannelClassification {
|
||||
s.logGameEvent(session, "game-represent-account-name-duplicate-check-blocked",
|
||||
"classification", packet.Header.Classification,
|
||||
"body_len", len(packet.Body),
|
||||
"reason", "current_exe_command_class_mismatch")
|
||||
return nil
|
||||
}
|
||||
return s.handleRepresentAccountNameDuplicateCheck(session, packet.Body)
|
||||
case dnfenum.UpperMsg(currentCmdUpdateRepresentAccountName), dnfenum.UpperMsg(currentCmdChangeRepresentAccountName):
|
||||
if packet.Header.Classification != dnfproto.DefaultChannelClassification {
|
||||
s.logGameEvent(session, "game-represent-account-name-update-blocked",
|
||||
"classification", packet.Header.Classification,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"body_len", len(packet.Body),
|
||||
"reason", "current_exe_command_class_mismatch")
|
||||
return nil
|
||||
}
|
||||
return s.handleUpdateRepresentAccountName(session, packet.Body, packet.Header.MsgID)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketStoryDigestUpdate):
|
||||
return s.handleCurrentStoryDigestAccepted(session, packet.Header.Classification, packet.Body)
|
||||
case dnfenum.UpperMsgSelectAck:
|
||||
// UpperMsgSelectAck is the historical bridge name for current
|
||||
// ENUM_CMDPACKET_RETURN_SELECT_CHARACTER (op7). A bare ACK leaves the
|
||||
// client bound to the old selected actor and never rebuilds the roster.
|
||||
if packet.Header.Classification != dnfproto.DefaultChannelClassification {
|
||||
s.logGameEvent(session, "game-upper-return-select-character-blocked",
|
||||
"classification", packet.Header.Classification,
|
||||
"body_len", len(packet.Body),
|
||||
"reason", "current_exe_op7_command_class_mismatch")
|
||||
return nil
|
||||
}
|
||||
return s.handleUpperReturnSelectCharacter(session, packet.Body)
|
||||
case dnfenum.UpperMsgFollowUpStatus:
|
||||
s.logInfo("dnfbridge latest upper follow-up received", "msg_id", packet.Header.MsgID, "body_len", len(packet.Body))
|
||||
// 63 is a Cera query, not a generic scene gate. The current client emits
|
||||
// it after its op4 select-character handler has returned, so it is the
|
||||
// first deterministic boundary after that handler resets the raid
|
||||
// directory manager. Keep the HUD tail on its existing owner and publish
|
||||
// only the independent team-directory readiness after this ACK.
|
||||
if err := s.sendGameUpperSuccess(session, packet.Header.MsgID, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
// A reconnecting target socket can still be shadowed by the resident
|
||||
// source socket until its later op36 town-area report. op63 is already the
|
||||
// first boundary after the current EXE's select handler has returned, so
|
||||
// promote it now; otherwise the canonical-session guard suppresses raid
|
||||
// recovery while create remains blocked by the live unfinished raid.
|
||||
if session.selectedCharacterID != 0 {
|
||||
s.promoteResidentGameSession(session, session.selectedCharacterID)
|
||||
}
|
||||
if err := s.sendRuntimeRaidSceneRejoinOnce(session, "upper_cera_after_select_handler_returned"); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sendCurrentRaidDirectoryReadyOnce(session, "upper_cera_after_select_handler_returned")
|
||||
|
||||
case dnfenum.UpperMsgFollowUpReady:
|
||||
return s.sendRequestBlacklistState(session, "upper_120_request", packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketGuildAllMemberList):
|
||||
return s.sendGuildAllMemberListState(session, "upper_140_request", packet.Body)
|
||||
case dnfenum.UpperMsgLoadExtendCharacs:
|
||||
s.logInfo("dnfbridge latest upper load-extend-characs received",
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"body_len", len(packet.Body))
|
||||
if packet.Header.Classification == dnfproto.DefaultChannelClassification &&
|
||||
session.emptyRosterSlotProbePending &&
|
||||
len(packet.Body) == 2 &&
|
||||
binary.LittleEndian.Uint16(packet.Body) == upperMaxCharacter {
|
||||
session.emptyRosterSlotProbePending = false
|
||||
s.logPacketEvent("game-upper-load-extend-characs-empty-roster-complete",
|
||||
"conn_id", session.connID,
|
||||
"channel_id", session.channel.ID,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"body_len", len(packet.Body),
|
||||
"requested_slot", upperMaxCharacter,
|
||||
"reason", "current_exe_empty_roster_ready_handshake")
|
||||
// Current EXE mode-2 roster finalization uses UINT16_MAX as the
|
||||
// selected slot when there are no rows. It clears selector ready
|
||||
// while class1/op679 is outstanding; the class-1 failure result
|
||||
// completes that empty-page probe and restores the Create button.
|
||||
// Class0/op679 is a different {u32 count, repeated u32 pair}
|
||||
// parser and must not receive this common-result envelope.
|
||||
return s.sendGameUpperFailure(session, uint16(dnfenum.UpperMsgLoadExtendCharacs), 0)
|
||||
}
|
||||
return s.handleUpperLoadExtendCharacs(session, packet.Body)
|
||||
case dnfenum.UpperMsgCharacSlotExtendEffect:
|
||||
return s.handleUpperCharacSlotExtendEffect(session, packet.Body)
|
||||
case dnfenum.UpperMsgCreatePostState:
|
||||
return s.handleUpperCreatePostState(session, packet.Body)
|
||||
case dnfenum.UpperMsgStaticsRuntimeTing:
|
||||
s.logInfo("dnfbridge latest upper statics runtime report received",
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"body_len", len(packet.Body))
|
||||
s.logPacketEvent("game-upper-statics-runtime-ting-deferred",
|
||||
"conn_id", session.connID,
|
||||
"channel_id", session.channel.ID,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"body_len", len(packet.Body),
|
||||
"reason", "dove_login_endpoint_already_sent")
|
||||
return nil
|
||||
case dnfenum.UpperMsgDprotoCallback:
|
||||
if session.dproto != nil {
|
||||
s.logPacketEvent("game-upper-dproto-callback-native",
|
||||
"conn_id", session.connID,
|
||||
"channel_id", session.channel.ID,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"body_len", len(packet.Body))
|
||||
if err := s.handleGameDprotoControl(session, packet.Header.MsgID, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sendCurrentSelectSceneReadyBoundary(session, "upper_dproto_native_control")
|
||||
}
|
||||
s.logInfo("dnfbridge latest upper dproto callback received; sending empty success",
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"body_len", len(packet.Body))
|
||||
s.logPacketEvent("game-upper-dproto-callback-empty-success",
|
||||
"conn_id", session.connID,
|
||||
"channel_id", session.channel.ID,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"body_len", len(packet.Body),
|
||||
"reason", "ida_1518_success_empty_count")
|
||||
if err := s.sendUpperDprotoCallbackSuccess(session); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sendCurrentSelectSceneReadyBoundary(session, "upper_dproto_callback_after_ack")
|
||||
case dnfenum.UpperMsgAntibot:
|
||||
s.logInfo("dnfbridge latest upper antibot report received; deferring response",
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"body_len", len(packet.Body))
|
||||
s.logPacketEvent("game-upper-antibot-report-deferred",
|
||||
"conn_id", session.connID,
|
||||
"channel_id", session.channel.ID,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"body_len", len(packet.Body),
|
||||
"reason", "client_report_no_reference_s2c_ack")
|
||||
// 1516 在这里是客户端登录阶段的 antibot 上报;IDA sub_1D0EA20 只证明服务端主动发 1516 时客户端如何处理。
|
||||
// 最新实测中额外回 1516 会立刻触发 reset,因此按 DOVE 登录参考流静默接收。
|
||||
return nil
|
||||
case dnfenum.UpperMsgCheckCharacterGate:
|
||||
return s.handleUpperCheckCharacterGate(session, packet.Body)
|
||||
case dnfenum.UpperMsgCheckUserConnection:
|
||||
// DOVE 登录到选角参考流没有服务端 1276 ACK;旧探针成功包会让当前客户端在角色列表阶段断线。
|
||||
// 选角成功后 NoPack 会持续发送 1276 等待后续进场门闩。这时返回当前
|
||||
// EXE 成功处理器完整消费的三个中性 u32,不能再使用只有 success 的短包。
|
||||
if session.selectedCharacterID != 0 {
|
||||
s.logPacketEvent("game-upper-check-user-connection-selected-success",
|
||||
"conn_id", session.connID,
|
||||
"channel_id", session.channel.ID,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"body_len", len(packet.Body),
|
||||
"char_id", session.selectedCharacterID)
|
||||
if err := s.sendUpperCheckUserConnectionSuccess(session); err != nil {
|
||||
return err
|
||||
}
|
||||
if session.channelReconnect {
|
||||
s.logGameEvent(session, "game-channel-reconnect-check-user-connection-ack-only",
|
||||
"char_id", session.selectedCharacterID,
|
||||
"route_pending", session.channelReconnect)
|
||||
return nil
|
||||
}
|
||||
return s.sendCurrentSelectSceneReadyBoundary(session, "upper_check_user_connection_after_ack")
|
||||
}
|
||||
s.logPacketEvent("game-upper-check-user-connection-deferred",
|
||||
"conn_id", session.connID,
|
||||
"channel_id", session.channel.ID,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"body_len", len(packet.Body))
|
||||
return nil
|
||||
case dnfenum.UpperMsgSelectStart:
|
||||
// REVERTED 2026-09-20 21:0x: the direct-entry experiment
|
||||
// (dungeon_war_table_entry.go) committed the run server-side but left the
|
||||
// requesting client on the 战况表 with no page, and the operator reported
|
||||
// "点战况表进图没反应". The op15 body is read again only when the client-side
|
||||
// context question is answered; until then this is the proven page route.
|
||||
return s.sendEnterSelectDungeonState(session, "upper_15", false, true)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketPVPChannelInfo):
|
||||
return s.handlePVPChannelInfo(session, packet.Body, "upper")
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketMakePVPRoom):
|
||||
return s.handleMakePVPRoom(session, packet.Body, "upper")
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketFairPVPScore):
|
||||
return s.handleCurrentPVPRecord(session, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketEnterPVPRoom):
|
||||
return s.handleEnterPVPRoom(session, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketSetPVPSeatState):
|
||||
return s.handleSetPVPSeatState(session, packet.Body, "upper")
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketSetPVPReadyState):
|
||||
return s.handleSetPVPReadyState(session, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketRequestIntegratedMatching), dnfenum.UpperMsg(dnfenum.CmdPacketCancelIntegratedMatching), dnfenum.UpperMsg(dnfenum.CmdPacketIntegrateMatchingDirectComplete):
|
||||
return s.handleCurrentPVPMatching(session, packet.Header.MsgID, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketDiePVPCharacter):
|
||||
return s.handleCurrentPVPDeath(session, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketPVPMissionHpPercent), dnfenum.UpperMsg(dnfenum.CmdPacketPVPMissionWinPose), dnfenum.UpperMsg(dnfenum.CmdPacketPVPMissionFirstAerialAttack), dnfenum.UpperMsg(dnfenum.CmdPacketPVPMissionComboDamageRate):
|
||||
return s.handleCurrentPVPMissionReport(session, packet.Header.MsgID, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketPVPTimeOut):
|
||||
return s.handleCurrentPVPTimeout(session, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketUsagePVPFatigue):
|
||||
return s.handleCurrentPVPFatigueUsage(session, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketArcademodePVPRoundInfo):
|
||||
return s.handleCurrentPVPArcadeRoundInfo(session, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketPVPFatigueReward):
|
||||
return s.handleCurrentPVPArcadeResult(session, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketEndPVPResult):
|
||||
return s.handleCurrentPVPResultEnd(session, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketCompleteLoadPVP), dnfenum.UpperMsg(dnfenum.CmdPacketConnectP2pPVP):
|
||||
return s.handleCurrentPVPMatchSignal(session, packet.Header.MsgID, packet.Body)
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketSetPVPTeamMode):
|
||||
return s.handleSetPVPTeamMode(session, packet.Body, "upper")
|
||||
case dnfenum.UpperMsg(dnfenum.CmdPacketSetPVPMapIndex):
|
||||
return s.handleSetPVPMapIndex(session, packet.Body, "upper")
|
||||
|
||||
default:
|
||||
if packet.Header.Classification == dnfproto.DefaultChannelClassification {
|
||||
if handled, err := s.handleAlignedGameCommand(session, byte(dnfenum.GameCmdCommand), packet.Header.MsgID, packet.Body); handled || err != nil {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logPacketEvent("game-upper-aligned-fallback-handled",
|
||||
"conn_id", session.connID,
|
||||
"channel_id", session.channel.ID,
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"body_len", len(packet.Body),
|
||||
"reason", "unlisted_upper_command_routed_to_server_aligned_owner")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if rejected, err := s.rejectUnsupportedInteractiveCommand(
|
||||
session,
|
||||
packet.Header.MsgID,
|
||||
len(packet.Body),
|
||||
"upper",
|
||||
); rejected || err != nil {
|
||||
return err
|
||||
}
|
||||
s.logInfo("dnfbridge unhandled latest upper packet",
|
||||
"msg_id", packet.Header.MsgID,
|
||||
"runtime_cmd_name", runtimeName,
|
||||
"runtime_cmd_known", runtimeKnown,
|
||||
"body_len", len(packet.Body))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func shouldAllowOpaqueUpperClientBodyControl(msgID uint16, classification byte, supported bool, decodeErr error) bool {
|
||||
if supported || decodeErr != nil {
|
||||
return false
|
||||
}
|
||||
if classification != dnfproto.DefaultChannelClassification {
|
||||
return false
|
||||
}
|
||||
if dnfenum.UpperMsg(msgID) != dnfenum.UpperMsgDprotoCallback {
|
||||
return false
|
||||
}
|
||||
return msgID%14 == 6
|
||||
}
|
||||
|
||||
func (s *Service) sendUpperDprotoCallbackSuccess(session *gameSession) error {
|
||||
// IDA sub_1D2B020 显示 op1518 成功分支读取 success 后的 u8 count、u32、u32、u8。
|
||||
// 当前登录阶段没有 dproto 条目,返回 count=0 的最小成功体,避免客户端等待回包后主动断开。
|
||||
return s.sendGameUpperRawClassNoCodec(session, uint16(dnfenum.UpperMsgDprotoCallback), emptyDprotoCallbackBody, dnfproto.DefaultChannelClassification)
|
||||
}
|
||||
|
||||
const currentCheckUserConnectionSuccessPayloadSize = 12
|
||||
|
||||
func buildCurrentCheckUserConnectionSuccessPayload() []byte {
|
||||
// Current EXE sub_1D0C760 is registered as extended opcode 0x4FC. Its
|
||||
// success branch unconditionally consumes three u32 values and discards
|
||||
// them. There is no persisted owner, so the exact neutral state is 0/0/0.
|
||||
return make([]byte, currentCheckUserConnectionSuccessPayloadSize)
|
||||
}
|
||||
|
||||
func (s *Service) sendUpperCheckUserConnectionSuccess(session *gameSession) error {
|
||||
return s.sendGameUpperSuccess(
|
||||
session,
|
||||
uint16(dnfenum.UpperMsgCheckUserConnection),
|
||||
buildCurrentCheckUserConnectionSuccessPayload(),
|
||||
)
|
||||
}
|
||||
|
||||
func upperRuntimeCmdPacketName(msgID uint16, classification byte) string {
|
||||
if classification != dnfproto.DefaultChannelClassification {
|
||||
return ""
|
||||
}
|
||||
return dnfenum.CmdPacketName(msgID)
|
||||
}
|
||||
|
||||
func upperRuntimeCmdPacketKnown(msgID uint16, classification byte) bool {
|
||||
if classification != dnfproto.DefaultChannelClassification {
|
||||
return false
|
||||
}
|
||||
return dnfenum.IsKnownCmdPacket(msgID)
|
||||
}
|
||||
|
||||
// handleUpperCharacSlotExtendEffect 处理客户端自动探测的扩栏光效消费请求。
|
||||
// 默认 32 栏由 roster 开放;没有道具/DB 产生的待消费扩栏状态时不能回成功 ACK,否则客户端每次打开选角页都会播放解锁光效。
|
||||
func (s *Service) handleUpperCharacSlotExtendEffect(session *gameSession, body []byte) error {
|
||||
if len(body) != 0 {
|
||||
s.logGameEvent(session, "game-upper-charac-slot-extend-effect-rejected",
|
||||
"body_len", len(body),
|
||||
"reason", "current_exe_writer_requires_empty_body")
|
||||
return nil
|
||||
}
|
||||
repositories, ok := s.repositoryGroup()
|
||||
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
||||
if !ok || repositories.Account == nil || accountID == "" {
|
||||
s.logGameEvent(session, "game-upper-charac-slot-extend-effect-deferred",
|
||||
"body_len", len(body),
|
||||
"reason", "account_repository_unavailable")
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Serialize with checkout: the marker and the authoritative slot limit are
|
||||
// written together. Clear the marker before ACK so reconnect cannot replay
|
||||
// the unlock animation; the durable capacity remains even if this socket
|
||||
// closes before the cosmetic ACK reaches the client.
|
||||
s.ceraShopPurchaseMu.Lock()
|
||||
account, found, err := repositories.Account.Load(ctx, accountID)
|
||||
if err != nil || !found {
|
||||
s.ceraShopPurchaseMu.Unlock()
|
||||
s.logGameEvent(session, "game-upper-charac-slot-extend-effect-deferred",
|
||||
"body_len", len(body),
|
||||
"reason", "account_load_failed",
|
||||
"error", err)
|
||||
return nil
|
||||
}
|
||||
pendingLimit, parseErr := strconv.Atoi(strings.TrimSpace(account.Metadata[currentCharacterSlotExtendEffectPendingMetadataKey]))
|
||||
currentLimit := dnfrepo.AccountCharacterSlotLimit(account)
|
||||
if parseErr != nil || pendingLimit <= dnfrepo.DefaultCharacterSlotLimit || pendingLimit != currentLimit {
|
||||
s.ceraShopPurchaseMu.Unlock()
|
||||
s.logGameEvent(session, "game-upper-charac-slot-extend-effect-deferred",
|
||||
"body_len", len(body),
|
||||
"slot_limit", currentLimit,
|
||||
"pending_limit", pendingLimit,
|
||||
"reason", "no_pending_slot_unlock")
|
||||
return nil
|
||||
}
|
||||
err = dnfrepo.SaveAccountMetadataEntry(
|
||||
ctx,
|
||||
repositories.Account,
|
||||
account,
|
||||
currentCharacterSlotExtendEffectPendingMetadataKey,
|
||||
"0",
|
||||
s.gameplayNow().UTC(),
|
||||
)
|
||||
s.ceraShopPurchaseMu.Unlock()
|
||||
if err != nil {
|
||||
s.logGameEvent(session, "game-upper-charac-slot-extend-effect-deferred",
|
||||
"body_len", len(body),
|
||||
"slot_limit", currentLimit,
|
||||
"pending_limit", pendingLimit,
|
||||
"reason", "pending_marker_clear_failed",
|
||||
"error", err)
|
||||
return nil
|
||||
}
|
||||
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.UpperMsgCharacSlotExtendEffect), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logGameEvent(session, "game-upper-charac-slot-extend-effect-consumed",
|
||||
"slot_limit", currentLimit,
|
||||
"pending_limit", pendingLimit,
|
||||
"ack_body", "current_exe_class1_success_only")
|
||||
return nil
|
||||
}
|
||||
|
||||
var currentUpper689RequestLengths = map[uint32]int{
|
||||
222: 16, // create-character post state: discriminator, character ID, 0xff, 0
|
||||
349: 12,
|
||||
2099: 16,
|
||||
2208: 16,
|
||||
2384: 16,
|
||||
2427: 12,
|
||||
2428: 8,
|
||||
2443: 4,
|
||||
}
|
||||
|
||||
func (s *Service) handleUpperCreatePostState(session *gameSession, body []byte) error {
|
||||
// Current EXE uses class1/op689 as a multiplexed request. Every current
|
||||
// writer starts with a u32 discriminator, and sub_1D019A0 consumes that
|
||||
// same discriminator after the common success byte. Echoing a hard-coded
|
||||
// 0xde is correct only for the create-character branch and corrupts every
|
||||
// other current op689 request.
|
||||
if len(body) < 4 {
|
||||
s.logGameEvent(session, "game-upper-multiplexed-state-malformed",
|
||||
"msg_id", uint16(dnfenum.UpperMsgCreatePostState),
|
||||
"body_len", len(body),
|
||||
"reason", "missing_u32_discriminator")
|
||||
return nil
|
||||
}
|
||||
discriminator := binary.LittleEndian.Uint32(body[:4])
|
||||
// 活动(onlineevent)借这条多路复用命令带货:
|
||||
// 2208 = 周周签到的"领周奖励"分支(u32 事件号 + 两个 0)
|
||||
// 222 = 升级预约(活动 222)的预约/取消请求
|
||||
// 两者都在下面"已知长度表"之前分流——222 的长度与 create-post-state 相同,
|
||||
// 靠长度区分不出来。
|
||||
if discriminator == uint32(currentWeeklyAttendanceEventID) {
|
||||
return s.handleCurrentWeeklyAttendanceBonus(session, body)
|
||||
}
|
||||
if discriminator == 222 {
|
||||
return s.handleCurrentLevelAiming(session, body)
|
||||
}
|
||||
expectedLength, known := currentUpper689RequestLengths[discriminator]
|
||||
if !known || len(body) != expectedLength {
|
||||
s.logGameEvent(session, "game-upper-multiplexed-state-deferred",
|
||||
"msg_id", uint16(dnfenum.UpperMsgCreatePostState),
|
||||
"discriminator", discriminator,
|
||||
"body_len", len(body),
|
||||
"expected_body_len", expectedLength,
|
||||
"known_discriminator", known,
|
||||
"reason", "current_exe_request_shape_not_proved")
|
||||
return nil
|
||||
}
|
||||
var writer packetWriter
|
||||
writer.writeUint32(discriminator)
|
||||
return s.sendGameUpperSuccess(session, uint16(dnfenum.UpperMsgCreatePostState), writer.bytes())
|
||||
}
|
||||
|
||||
func (s *Service) handleUpperCheckCharacterGate(session *gameSession, body []byte) error {
|
||||
// sub_342D480 is the sole current writer: exactly two u32 values. The
|
||||
// class1/op693 success handler sub_1CFA0D0 consumes only the first value
|
||||
// and treats 0xffffffff as the inactive sentinel. Zero is not a sentinel,
|
||||
// so never substitute the second value or a fabricated default.
|
||||
if len(body) != 8 {
|
||||
s.logGameEvent(session, "game-upper-check-character-gate-malformed",
|
||||
"msg_id", uint16(dnfenum.UpperMsgCheckCharacterGate),
|
||||
"body_len", len(body),
|
||||
"reason", "current_exe_writer_requires_two_u32")
|
||||
return nil
|
||||
}
|
||||
candidate := binary.LittleEndian.Uint32(body[:4])
|
||||
pair := binary.LittleEndian.Uint32(body[4:8])
|
||||
if candidate == ^uint32(0) || pair == ^uint32(0) {
|
||||
s.logGameEvent(session, "game-upper-check-character-gate-deferred",
|
||||
"msg_id", uint16(dnfenum.UpperMsgCheckCharacterGate),
|
||||
"candidate", candidate,
|
||||
"pair", pair,
|
||||
"reason", "current_exe_lookup_inactive_sentinel")
|
||||
return nil
|
||||
}
|
||||
var writer packetWriter
|
||||
writer.writeUint32(candidate)
|
||||
return s.sendGameUpperSuccess(session, uint16(dnfenum.UpperMsgCheckCharacterGate), writer.bytes())
|
||||
}
|
||||
Reference in new issue
Block a user