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,587 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user