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

588 lines
22 KiB
Go

package dnfbridge
import (
"context"
"encoding/binary"
"math/bits"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// sendGameInitial sends the endpoint success only after the current client
// answers CHANNELINFO with its native class1/op1 request.
func (s *Service) sendGameInitial(session *gameSession) (mode string, bodyLen int, sent bool, err error) {
body := s.buildLoginSuccessForSession(session)
if err = s.sendGameUpperSuccess(session, uint16(dnfenum.UpperMsgGameEndpoint), body); err != nil {
return gameInitialModeUpper, len(body), true, err
}
if err = s.sendCurrentGameOptionLoginOptions(session); err != nil {
return gameInitialModeUpper, len(body), true, err
}
return gameInitialModeUpper, len(body), true, nil
}
// sendGameConnectionBootstrap separates the launcher's all-port discovery
// probes from the one account-bound game connection. CHANNELINFO writes the
// current EXE's native scene-channel global, so it is valid only when the same
// connection can represent that channel in the u8 town actor owner field.
// Discovery probes and channels outside that range keep the proven proactive
// class1/op1 endpoint response, which does not mutate native scene ownership.
func (s *Service) sendGameConnectionBootstrap(session *gameSession, now time.Time) error {
if session == nil {
return errResidentChannelUnavailable
}
accountBound := strings.TrimSpace(session.accountID) != ""
pvpChannel := isPVPChannel(session.channel)
ownerChannel, ownerErr := currentCommittedChannelOwner(session)
// PVP channels used to be pinned to the neutral context 0 here, because
// their ids were 501+ and could not fit the u8 town-actor-owner field. That
// made every remote co-presence actor unbuildable: currentPartySceneOwnerContext
// returned 0 and sendTownRemoteActorState refuses an uncommitted owner, so
// two characters in the same arena never saw each other while their movement
// kept flowing. The [pvp] and [fiendwar] ids are inside the u8 range now, so
// they take the ordinary committed-owner path like every other channel.
if accountBound && ownerErr == nil {
if err := s.sendCurrentChannelResidentNotice(session, now); err != nil {
return err
}
session.connectionTownActorOwnerChannel = ownerChannel
session.townActorOwnerChannel = ownerChannel
return nil
}
session.endpointHandshakeMu.Lock()
defer session.endpointHandshakeMu.Unlock()
if session.gameEndpointSuccessSent {
return nil
}
mode, bodyLen, sent, err := s.sendGameInitial(session)
if err != nil || !sent {
return err
}
session.gameEndpointSuccessSent = true
reason := "unbound_channel_discovery_probe"
if pvpChannel {
reason = "pvp_channel_uses_dedicated_login_environment"
} else if accountBound {
reason = "resident_channel_cannot_fit_current_exe_town_owner"
}
s.logGameEvent(session, "game-endpoint-success-sent",
"mode", mode,
"body_len", bodyLen,
"request_body_len", 0,
"target_channel", session.channel.ID,
"resident_owner_error", ownerErr,
"reason", reason)
return nil
}
// handleCurrentGameEndpointRequest completes the current EXE's one-shot
// CHANNELINFO handshake. The class0/op1 notice makes the client send this
// class1/op1 request and arm its watchdog; the first class1/op1 success clears
// that watchdog. Later endpoint probes must remain write-silent.
func (s *Service) handleCurrentGameEndpointRequest(session *gameSession, classification byte, wireBodyLen int) error {
if session == nil {
return errResidentChannelUnavailable
}
session.endpointHandshakeMu.Lock()
defer session.endpointHandshakeMu.Unlock()
if classification != dnfproto.DefaultChannelClassification ||
!isCurrentChannelReconnectDisplayProbeBodyLen(wireBodyLen) {
s.logGameEvent(session, "game-endpoint-client-op1-ignored",
"classification", classification,
"body_len", wireBodyLen,
"target_channel", session.channel.ID,
"reason", "current_exe_endpoint_request_shape_mismatch")
return nil
}
if !session.currentChannelResidentNoticeSent {
s.logGameEvent(session, "game-endpoint-client-op1-ignored",
"classification", classification,
"body_len", wireBodyLen,
"target_channel", session.channel.ID,
"reason", "channelinfo_notice_not_committed")
return nil
}
if session.gameEndpointSuccessSent {
s.logGameEvent(session, "game-channel-stale-endpoint-probe-ignored",
"body_len", wireBodyLen,
"target_channel", session.channel.ID,
"reason", "single_class1_op1_already_sent_after_client_request")
return nil
}
// 顶号:同账号的第二个客户端在**进入频道这一刻**就处理,不等它选完角色进城。
//
// 这里正是"进入频道"的握手:每条连接只会成功一次(gameEndpointSuccessSent),
// 且要求会话已绑账号(currentChannelResidentNoticeSent),所以客户端启动时那上百条
// 探测连接不会走到这里;频道回退没有城镇 actor,同样到不了。
//
// 判据仍是进程/登录票据(见 currentDuplicateLoginWouldDisplace),所以同一客户端
// 换频道时新旧连接身份相同,不会被误踢。返回 true = 本会话已被顶号:直接收手,
// 不要再把进城数据发给一个马上就要收 157 的会话。
if s.enforceCurrentAccountSingleSession(session, session.accountID) {
return nil
}
mode, bodyLen, sent, err := s.sendGameInitial(session)
if err != nil {
return err
}
if !sent {
return nil
}
// Current NoPack keeps both auction entry buttons closed until its two
// class-0/op183 service gates have been projected. Send them only on the
// account-bound CHANNELINFO handshake; launcher probes and high-channel
// fallbacks have no usable town actor and must not receive scene services.
if err := s.sendCurrentAuctionServicesOpen(session); err != nil {
return err
}
session.gameEndpointSuccessSent = true
s.logGameEvent(session, "game-endpoint-success-sent",
"mode", mode,
"body_len", bodyLen,
"request_body_len", wireBodyLen,
"target_channel", session.channel.ID,
"reason", "current_exe_channelinfo_triggered_login_request")
return nil
}
func encNoticeBody(body []byte) []byte {
encoded := make([]byte, len(body))
for i, b := range body {
encoded[i] = bits.RotateLeft8(b, 2) ^ 0xd6
}
return encoded
}
func (s *Service) initialNoticeWireBody(body []byte) ([]byte, bool, string) {
if s.gameUpperBodyCodecMode() == gameUpperBodyCodecPlain {
return append([]byte(nil), body...), false, "csharp_initial_notice_plain"
}
return encNoticeBody(body), true, "csharp_initial_notice_rotl2_xor_d6"
}
// sendPreInitialBootstrap retains the historical option hook for tests and
// configuration compatibility. The live connection path sends CHANNELINFO
// directly and does not call this legacy write-silent hook.
func (s *Service) sendPreInitialBootstrap(_ *gameSession) error {
return nil
}
func (s *Service) gamePreBootstrapMode() string {
if s.options.gamePreBootstrap == "" {
return gamePreBootstrapNone
}
return s.options.gamePreBootstrap
}
// sendPostInitialBootstrap 保留旧探针入口,但当前 C# 对齐链路禁止首包后主动补包。
func (s *Service) sendPostInitialBootstrap(session *gameSession, initialMode string, initialSent bool) error {
// GET_USERINFO/SELECT_CHARACTER 后续初始化链是被动回包,不能在首包后主动推。
return nil
}
func (s *Service) gamePostBootstrapMode() string {
if s.options.gamePostBootstrap == "" {
return gamePostBootstrapNone
}
return s.options.gamePostBootstrap
}
func (s *Service) gameUpperHeaderMode() string {
if s.options.gameUpperHeader == "" {
return gameUpperHeaderChannel13
}
return s.options.gameUpperHeader
}
func (s *Service) gameInitialMode() string {
if s.options.gameInitialMode == "" {
return gameInitialModeNotice
}
return s.options.gameInitialMode
}
func (s *Service) sendUpperClientCheckList(session *gameSession) error {
var writer packetWriter
writer.writeByte(1)
writer.writeUint32(0)
return s.sendGameUpperSecondaryRaw(session, uint16(dnfenum.UpperMsgClientCheck), writer.bytes())
}
func (s *Service) sendUpperGuardClear(session *gameSession) error {
var writer packetWriter
writer.writeUint32(1)
writer.writeZero(8)
writer.writeByte(0)
writer.writeZero(12)
writer.writeUint32(0)
writer.writeUint32(0)
writer.writeUint32(0)
return s.sendGameUpperSecondaryRaw(session, uint16(dnfenum.UpperMsgGuardControl), writer.bytes())
}
func (s *Service) sendUpperEmptyRosterInit(session *gameSession) error {
return s.sendGameUpperCharacterRosterRaw(session, buildCSharpEmptyRosterBody())
}
func buildCSharpEmptyRosterBody() []byte {
return buildCSharpRosterBody(nil)
}
var emptyDprotoCallbackBody = []byte{0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
type csharpSelectInitPacket struct {
class byte
msgID uint16
marker uint32
file string
body []byte
occurrence int
kind string
bodyEncoded bool
bodyCodec string
}
var csharpUpperSelectInitTemplate = []csharpSelectInitPacket{
{class: dnfproto.DefaultChannelClassification, msgID: 0x0004, kind: "select_ack"},
}
func expandedCSharpUpperSelectInitTemplate() []csharpSelectInitPacket {
return append([]csharpSelectInitPacket(nil), csharpUpperSelectInitTemplate...)
}
func isLargeLongHengSceneRosterPacket(packet csharpSelectInitPacket, body []byte) bool {
return packet.kind == csharpLongHengSceneBootstrapKind &&
packet.msgID == uint16(dnfenum.UpperMsgCharacterRoster) &&
len(body) > 10000
}
func deferredSelectSceneTailPackets() []csharpSelectInitPacket {
out := make([]csharpSelectInitPacket, 0, len(longHengSceneBootstrapBeforeHudPackets))
afterLargeRoster := false
for _, packet := range longHengSceneBootstrapBeforeHudPackets {
// The current acceptable-quest notification is rebuilt from PVF/DB state
// at send time. It must not depend on the old large-roster fixture marker:
// the current bootstrap inserts dynamic userinfo rows instead, so that marker
// is absent and would otherwise suppress op0x15 forever. This function is
// only reached after either dungeon op29 or the completed-character town
// route has committed the selected actor, satisfying dword_52B5BB0.
if packet.kind == currentAcceptableQuestListKind {
out = append(out, packet)
continue
}
if afterLargeRoster {
out = append(out, packet)
continue
}
if isLargeLongHengSceneRosterPacket(packet, packet.body) {
afterLargeRoster = true
}
}
// The old 24-record HUD phase is intentionally not carried into the current
// deferred tail; its bodies do not match current op583 or op851.
return out
}
func (s *Service) sendUpperGetUserInfoBootstrap(session *gameSession) error {
// GET_USERINFO is the role-select phase and must remain passive. The current
// represent/adventure-name state opens a modal, so it is deliberately sent
// only after the selected town scene has completed its initialization.
// The roster must never be gated on first-time registration.
//
// The current client can deliver its three-byte op8 request through the
// legacy stream decoder. Record the semantic roster transition here, where
// both upper and legacy op8 converge, so the following upper op4 is not
// mistaken for a fresh channel-reconnect selection.
session.rosterRequested = true
session.representAccountNamePending = false
if err := s.prepareGetUserInfoPassiveAccountState(session); err != nil {
return err
}
return s.sendUpperGetUserInfoRosterBootstrap(session)
}
func (s *Service) sendLegacyGetUserInfoBootstrap(session *gameSession) error {
return s.sendUpperGetUserInfoBootstrap(session)
}
func (s *Service) deferUpperGetUserInfoBootstrapUntilHiddenProbe(session *gameSession) error {
if err := s.prepareGetUserInfoPassiveAccountState(session); err != nil {
return err
}
session.pendingCharacterRosterBootstrap = true
s.logGameEvent(session, "game-upper-getuserinfo-roster-deferred",
"source", "legacy_getuserinfo",
"reason", "wait_for_charac_view_hidden_info_probe")
return nil
}
func (s *Service) prepareGetUserInfoPassiveAccountState(session *gameSession) error {
session.selectorAdventureInfoPending = false
session.selectorAdventureInfoSlot = 0
repositories, ok := s.repositoryGroup()
if ok && repositories.Account != nil {
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
account, found, err := repositories.Account.Load(ctx, s.accountIDForSession(session))
if err != nil {
s.logGameEvent(session, "game-represent-account-name-state-load-failed",
"account_id", s.accountIDForSession(session),
"error", err)
return err
}
name := ""
if found {
name = strings.TrimSpace(account.RepresentAccountName)
}
if name == "" {
// The current client can surface this state packet as an account
// protection/password-style modal over the selector. GET_USERINFO
// must stay a passive role-list response; represent/adventure-name
// registration is handled only when the client explicitly submits one
// of the represent-name commands.
session.representAccountNamePending = false
s.logGameEvent(session, "game-represent-account-name-state-passive-deferred",
"account_id", s.accountIDForSession(session),
"account_found", found,
"source", "getuserinfo_before_roster",
"reason", "do_not_open_registration_or_security_modal_during_getuserinfo")
} else {
if slot, ok := currentSelectorAdventureInfoSlot(account.Metadata); ok {
session.selectorAdventureInfoPending = true
session.selectorAdventureInfoSlot = uint16(slot)
}
s.logGameEvent(session, "game-represent-account-name-state-passive-deferred",
"account_id", s.accountIDForSession(session),
"account_found", found,
"name_byte_len", len(name),
"source", "getuserinfo_before_roster",
"selector_adventure_info_pending", session.selectorAdventureInfoPending,
"selector_adventure_info_slot", session.selectorAdventureInfoSlot,
"reason", "registered_state_still_opens_current_client_registration_modal")
}
}
return nil
}
func (s *Service) sendUpperGetUserInfoRosterBootstrap(session *gameSession) error {
return s.sendUpperGetUserInfoRosterBootstrapEnvelope(session, "ordinary_upper")
}
func (s *Service) sendUpperGetUserInfoRosterBootstrapFixed16(session *gameSession) error {
return s.sendUpperGetUserInfoRosterBootstrapEnvelope(session, "legacy_hidden_probe_fixed16_zero_prefix")
}
func (s *Service) sendUpperGetUserInfoRosterBootstrapEnvelope(session *gameSession, envelope string) error {
body, characters := s.buildUpperGetUserInfoRosterBody(session)
s.logPacketEvent("game-upper-get-userinfo-bootstrap-send",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"source", "repository_roster_wstr",
"envelope", envelope,
"packet_count", 1,
"body_len", len(body))
if len(body) >= 15 {
s.logGameEvent(session, "game-character-roster-prefix",
"repository_count", body[1],
"display_count", body[1],
"selected_page", binary.LittleEndian.Uint16(body[7:9]),
"entry_count", binary.LittleEndian.Uint16(body[13:15]),
"prefix_len", 15)
}
switch envelope {
case "legacy_hidden_probe_fixed16_zero_prefix":
if err := s.sendGameUpperClass0Fixed16ZeroPrefix(session, uint16(dnfenum.UpperMsgCharacterRoster), body, "legacy_getuserinfo_hidden_probe_roster"); err != nil {
return err
}
case "fixed15_character_list":
if err := s.sendGameFixed15Route(
session,
byte(dnfenum.GameCmdNotice),
uint16(dnfenum.GameTypeCharacterList),
body,
latestCharacterStateRoute,
); err != nil {
return err
}
default:
if err := s.sendGameUpperSecondaryRaw(session, uint16(dnfenum.UpperMsgCharacterRoster), body); err != nil {
return err
}
}
noteEmptyRosterSlotProbe(session, body)
// The current NoPack selector owns a separate Dark Knight combo cache.
// Current-client roster regressions prove class-0/op809 consumes roster slots,
// so it must follow the exact
// roster snapshot that created those slots; it cannot be deferred until the
// selected actor's op447 initialization.
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
err := s.sendCurrentDarkKnightComboPreload(
session,
ctx,
characters,
"getuserinfo_"+envelope+"_after_roster",
)
cancel()
if err != nil {
return err
}
// Current NoPack keeps the role-selector adventure name/level outside the
// mode2 roster body. Do not append op1340 here: the client has not yet
// acknowledged that the selector objects are ready, and the former
// all-roster fan-out crashed the current executable. The verified op645
// hidden-info probe flushes one remembered-slot snapshot later.
return nil
}
func (s *Service) buildUpperGetUserInfoRosterBody(session *gameSession) ([]byte, []dnfrepo.CharacterRecord) {
repos, ok := s.repositoryGroup()
accountID := s.accountIDForSession(session)
if !ok || repos.Character == nil {
s.logGameEvent(session, "game-upper-getuserinfo-roster-repository-missing", "account_id", accountID)
return buildCSharpEmptyRosterBody(), nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
characters, err := s.listCharacters(ctx, repos, accountID)
if err != nil {
s.logGameEvent(session, "game-upper-getuserinfo-roster-list-failed", "account_id", accountID, "error", err)
return buildCSharpEmptyRosterBody(), nil
}
// GET_USERINFO 是客户端请求触发的被动角色表;这里只从仓库构造 roster,不触发栏位扩展包。
s.logGameEvent(session, "game-upper-getuserinfo-roster-built", "account_id", accountID, "count", len(characters))
slotLimit, err := s.accountCharacterSlotLimit(ctx, repos, accountID)
if err != nil {
s.logGameEvent(session, "game-upper-getuserinfo-roster-slot-limit-failed", "account_id", accountID, "error", err)
return buildCSharpEmptyRosterBody(), nil
}
visibleCount := rosterCountForLimit(characters, slotLimit)
visibleCharacters := append([]dnfrepo.CharacterRecord(nil), characters[:visibleCount]...)
return buildCSharpRosterBodyForLimit(characters, slotLimit), visibleCharacters
}
func (s *Service) buildInitialLoginNotice(channel channelcatalog.Channel) []byte {
var writer packetWriter
// C# BuildInitialLoginNotice 只写旧首包字段。DOVE 抓到的 335 字节前缀
// 是单次运行证据,不能作为所有频道/会话通用模板。
writer.writeByte(1)
writer.writeAsciiDstr(noticeChanName(channel))
writer.writeInt32(0)
writer.writeInt32(0)
writer.writeByte(byte(s.channelAdvertiseID()))
writer.writeByte(byte(channel.ID))
writer.writeByte(0)
writer.writeInt32(int(time.Now().Unix()))
writer.writeInt32(1)
writer.writeAsciiDstr(s.options.serverIP)
writer.writeInt32(s.options.initialUDPPort1)
writer.writeInt32(s.options.initialUDPPort2)
writer.writeByte(0)
writer.writeByte(0)
writer.writeInt32(s.options.commandCount)
writer.writeInt32(s.options.notificationCount)
return writer.bytes()
}
func noticeChanName(channel channelcatalog.Channel) string {
return dnfenum.ChannelNamePrefix + strconv.Itoa(channel.ID)
}
func (s *Service) buildLatestUpperInitBody(_ *gameSession, request []byte) []byte {
requestValue := uint16(0)
if len(request) >= 2 {
requestValue = binary.LittleEndian.Uint16(request[:2])
}
channelOrArea := requestValue
if channelOrArea == 0 {
channelOrArea = uint16(dnfenum.LoginChannelServerIndex)
}
var writer packetWriter
writer.writeUint32(1)
writer.writeUint32(0)
writer.writeUint16(channelOrArea)
writer.writeUint16(0)
writer.writeUint16(0)
writer.writeUint16(0)
writer.writeByte(0)
writer.writeUint32(0)
// 最新客户端固定读取 30 行表项;0xFFFF 是跳过该行的哨兵。
for range 30 {
writer.writeUint16(0xffff)
writer.writeUint32(0)
}
writer.writeUint32(0)
writer.writeByte(2)
writer.writeUint16(channelOrArea)
writer.writeUint16(0)
writer.writeByte(0)
writer.writeUint16(0)
writer.writeByte(0)
return writer.bytes()
}
func (s *Service) buildLoginSuccess(channel channelcatalog.Channel) []byte {
return s.buildLoginSuccessWithRoutingKey(channel, s.options.gameOuterToken)
}
func (s *Service) buildLoginSuccessForSession(session *gameSession) []byte {
if session == nil {
return s.buildLoginSuccess(channelcatalog.Channel{})
}
// NoPack stores this word as the local native P2P routing identity. It must
// be unique for every live game connection and later be repeated in that
// owner's remote op11 row. gameOuterToken remains confined to the outer TCP
// compatibility envelope in game_wire.go; it is not a party route key.
routingKey, _ := s.partyTransportIdentity(session)
return s.buildLoginSuccessWithRoutingKey(session.channel, routingKey)
}
func (s *Service) buildLoginSuccessWithRoutingKey(channel channelcatalog.Channel, routingKey uint32) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeByte(1)
// sub_1CFF660 -> sub_2263E20 stores this native channel type directly.
// Replacing integrated matching (8) with free practice (13) opens owner 9
// instead of the native matching panel (owner 15) in sub_34061A0.
writer.writeByte(channel.Type)
writer.writeByte(1)
writer.writeUint32(routingKey)
writer.writeAsciiDstr(s.options.serverIP)
writer.writeInt32(channel.Port)
writer.writeInt32(0)
// Current NoPack upper_handle_msg1_game_endpoint reads this 24-byte account
// state tail after the endpoint. Offsets 2 and 3 are useSecurityCard and
// alreadyAuthentificatedSecurityCard. Keeping both true suppresses the
// account-protection news modal without changing channel selection.
var accountState [24]byte
accountState[2] = 1
accountState[3] = 1
writer.writeBytes(accountState[:])
return writer.bytes()
}
func buildAuctionServiceNotice(serviceType byte, state byte) []byte {
return []byte{serviceType, state}
}
func zeroBody(count int) []byte {
if count <= 0 {
return nil
}
return make([]byte, count)
}