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

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

397 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}