Files
90CNServer/server/internal/services/dnfbridge/service.go
T

1275 lines
51 KiB
Go
Raw 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.
// service.go 负责 DNF bridge 生命周期、目录加载和监听器管理。
// 这里不写玩法状态,只装配频道服与 game 端口的 transport 边界。
package dnfbridge
import (
"context"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/netip"
runtimedebug "runtime/debug"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"longheng.io/server/internal/modules/dnf/adventuregroup"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/channelinfo"
dnfcharstat "longheng.io/server/internal/modules/dnf/charstat"
dnfcombatpower "longheng.io/server/internal/modules/dnf/combatpower"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/dproto"
dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission"
dnfepicbook "longheng.io/server/internal/modules/dnf/epicbook"
dnfexpertjob "longheng.io/server/internal/modules/dnf/expertjob"
dnfhellparty "longheng.io/server/internal/modules/dnf/hellparty"
dnfhonor "longheng.io/server/internal/modules/dnf/honor"
dnfjoust "longheng.io/server/internal/modules/dnf/joust"
dnfonlineevent "longheng.io/server/internal/modules/dnf/onlineevent"
"longheng.io/server/internal/modules/dnf/party"
dnfpet "longheng.io/server/internal/modules/dnf/pet"
dnfprofession "longheng.io/server/internal/modules/dnf/profession"
dnfprogression "longheng.io/server/internal/modules/dnf/progression"
dnfpvp "longheng.io/server/internal/modules/dnf/pvp"
dnfquest "longheng.io/server/internal/modules/dnf/quest"
dnfraid "longheng.io/server/internal/modules/dnf/raid"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
dnfstorydigest "longheng.io/server/internal/modules/dnf/storydigest"
dnftown "longheng.io/server/internal/modules/dnf/town"
"longheng.io/server/internal/modules/dnf/wedding"
"longheng.io/server/internal/modules/dnf/worldmap"
appkit "longheng.io/server/internal/platform/app"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// Service 是 DNF 最新客户端 bridge 的生命周期组件。
type Service struct {
logger *slog.Logger
options options
// launcherAuthHTTPClient performs the loopback verify-game-ticket call for
// external game connections when launcher auth is enabled; nil otherwise.
launcherAuthHTTPClient *http.Client
// launcherIdentity pins the deployment's launcher. It is never nil: a
// disabled verifier keeps every legacy account-binding path reachable.
launcherIdentity *launcherIdentityVerifier
// launcherIdentityErr is a launcher identity configuration error. It is
// surfaced by Start so a misconfigured server refuses to serve rather than
// quietly running unbound.
launcherIdentityErr error
// localDebugMode is supplied only by the local composition root. It is
// intentionally process-local and never persisted with character data.
localDebugMode bool
localDebugStrikeOnSkillUse bool
// petEggProbeOnce 装配进程级的"宠物蛋"判据(见 pet_egg_item.go);petEggCache
// 缓存模板级判定结果,避免每次对账都重新解析 PVF 文档。
petEggProbeOnce sync.Once
petEggCache sync.Map
// chatMu 保护下面两张 1:1 会话表(见 town_chat_conversation.go)。它是服务端
// 自己记的"这个客户端的聊天窗口里是谁":当前客户端的聊天请求里没有可用的对端
// 字段,对端只能由服务端维护。会话是纯内存状态,随 session 结束回收,不落盘。
chatMu sync.Mutex
chatConversations map[uint64]*currentChatConversation
chatConversationByMember map[uint16]*currentChatConversation
chatConversationSeq uint32
// companionConfigMu 串行化支援兵配置的读-改-写(见 dungeon_companion_control.go)。
// 配置按账号落库(repos.Settings),运行态按副本冻结;这把锁只保护那一段
// "读当前 revision → 校验 → 写回"的临界区,避免并发保存把 revision 撞掉。
companionConfigMu sync.Mutex
// companionPresenceMu guards the synthetic sessions that expose a volunteer
// in the native friend list / party frame. These sessions have no socket and
// are deliberately NOT stored in gameSessions: only the social and party
// projection helpers below may resolve them.
companionPresenceMu sync.Mutex
companionSessions map[uint16]*gameSession
mu sync.Mutex
catalog *channelcatalog.Catalog
channelScript []byte
listeners []net.Listener
// gameListeners 按游戏端口登记监听,供热重载时对账(gm_channel_reload.go)。
// 与 listeners 同步维护:listeners 仍是 Stop 的唯一清理来源,gameListeners 只提供
// "哪个端口对应哪个 listener"的反查能力。
gameListeners map[int]net.Listener
// runCtx 是 Start 建立的运行期上下文,供热重载时为新启用的频道开监听。
// 由 s.mu 保护,Stop 时置 nil。
runCtx context.Context
conns map[net.Conn]struct{}
gameSessions map[uint16]*gameSession
// onlineSeats tracks every authenticated game connection that currently
// occupies an online seat, whether or not it has bound a character yet
// (role-select / loading connections included). Guarded by s.mu. It is
// the authoritative source for online-capacity accounting: a server at
// its limit must reject a new launcher verify immediately instead of
// overselling a seat to a connection that is still connecting.
onlineSeats map[*gameSession]struct{}
onlineReportTrigger chan struct{}
pvpMu sync.Mutex
pvpRooms map[uint16]*currentPVPRoom
pvpMatching []*currentPVPMatchingRequest // protected by pvpMu
nextPVPMatchingToken uint32
pvpSubscribers map[*gameSession]int
pvpMapOrder []int64 // protected by pvpMu
pvpDailyCatalog *dnfpvp.Catalog // protected by initialEquipmentMu
pvpTotalCatalog *dnfpvp.Catalog // protected by initialEquipmentMu
pvpFatigueRules *currentPVPFatigueRules // protected by initialEquipmentMu
nextGameSessionGeneration uint64
nextDungeonLifecycleToken atomic.Uint64
nextPartyRoutingKey atomic.Uint32
// abisAdventureGroupMu 保护 abisAdventureGroupLevel:周常五图的账号额度上限
// 要按 [adventure group add enterable count chn] 加上冒险团分档加成,而
// currentDungeonAdmissionPolicies 是同步且只带 dungeonID 的入口,拿不到账号。
// 因此由带 session 的路径刷新这个缓存,策略生成时读它。只影响"多给几次",
// 读不到时按 0 处理(纯基础 5 次),属于安全方向。
abisAdventureGroupMu sync.Mutex
abisAdventureGroupLevel uint32
guildMu sync.Mutex
guildDungeonCatalog currentGuildDungeonCatalogCache
guildCompoundCatalog currentGuildCompoundCatalogCache
guildCompoundCandidates currentGuildCompoundCandidates
guildCreateMu sync.Mutex
friendMu sync.Mutex
// weddingMu 串行化婚姻手势(求婚/应答/戒指/心意点/婚房),并保护
// weddingInvitations。获取顺序必须是 weddingMu 先于 mu(见 game_sessions.go),
// 与上游一致,避免会话绑定/解绑路径与婚姻事务互相等待。
weddingMu sync.Mutex
weddingInvitations map[uint16]*currentWeddingInvitation
weddingCatalogMu sync.Mutex
weddingCatalog *wedding.Catalog
weddingCatalogLoadErr error
weddingCapsuleCatalog *wedding.CapsuleCatalog
weddingCapsuleCatalogErr error
tradeMu sync.Mutex
itemTrades map[uint16]*onlineItemTrade
privateStoreOpMu sync.Mutex
privateStoreMu sync.Mutex
privateStores map[uint16]*currentPrivateStore
privateStoreVisitors map[*gameSession]uint16
nextPrivateStoreGeneration atomic.Uint64
privateStoreItemEligibility func(uint32) bool
auctionMu sync.Mutex
nextAuctionListingID atomic.Uint64
auctionItemEligibility func(uint32) bool
auctionCategoryItem func(uint16, uint32) bool
expertJobStoreMu sync.Mutex
expertJobStoreOpMu sync.Mutex
skillSwitchInventoryMu sync.Mutex
expertJobStores map[uint16]*currentExpertJobStore
expertJobVisitors map[*gameSession]uint16
raidMu sync.Mutex
raidStartMu sync.Mutex
raids map[uint32]*runtimeRaidState
// raidPhaseCompletionRetries 是"阶段收尾重试"的预算,按 RaidKey 计:
// 门还没关完时 finishRuntimeRaidPhase 会把自己再排一次,直到所有人都回城
// 或预算用尽(见 scheduleRuntimeRaidPhaseCompletionRetry)。
raidPhaseCompletionMu sync.Mutex
raidPhaseCompletionRetries map[uint32]int
raidSituationValues map[runtimeRaidMonsterRuntimeKey][]uint32
raidBuffActivations map[runtimeRaidBuffActivationKey]runtimeRaidBuffActivation
raidReviveUses map[runtimeRaidReviveCoinKey]uint32
raidReviveGrants map[runtimeRaidReviveCoinKey]uint32
raidPeerAdmissions map[runtimeRaidPeerAdmissionKey]runtimeRaidPeerAdmission
raidDisconnects map[uint16]runtimeRaidDisconnectReservation
// raidPhaseDeaths 是本阶段整团的死亡次数(0x0259 的 deaths 字段),按
// (RaidKey, Phase) 计;与 raidReviveUses 共用 raidMu。见 raid_phase_death_count.go。
raidPhaseDeaths map[runtimeRaidPhaseDeathKey]uint32
nextRaidSeq uint32
raidAntonMu sync.Mutex
raidAnton *dnfraid.AntonConfig
raidAntonErr error
raidLukeMu sync.Mutex
raidLuke *dnfraid.LukeConfig
raidLukeErr error
// raidFiendWar 是 etc/raid/fiendwar/fiendwar.etc:招募标量 + 30 区域地图 +
// trigger/behavior 规则对 + 奖励。与 raidAnton/raidLuke 同形:一次读档、缓存,
// 失败也缓存(缺档只报一次)。见 raid_fiendwar_runtime.go。
raidFiendWarMu sync.Mutex
raidFiendWar *dnfraid.FiendWarConfig
raidFiendWarErr error
// raidLukeCardSet 是 etc/raid/luke/LukeCardSet.etc(塔罗牌面的图案表 + 22 张
// 大阿卡纳),只被 1181 的下发路径读,见 raid_luke_card_display.go。
raidLukeCardSetMu sync.Mutex
raidLukeCardSet *dnfraid.LukeCardSetConfig
raidLukeCardSetErr error
// raidLukeMonsterList 是 etc/raid/luke/luke.etc.buff 的 [RAID MONSTER LIST]
// (每个迷宫真会刷的 named/boss),1181 的牌面按它推,见 raid_luke_card_display.go。
raidLukeMonsterListMu sync.Mutex
raidLukeMonsterList *dnfraid.LukeRaidMonsterListConfig
raidLukeMonsterListErr error
raidAntonRewardMu sync.Mutex
raidAntonReward *antonRewardPVFCatalog
raidAntonRewardErr error
raidLukeRewardMu sync.Mutex
raidLukeReward *antonRewardPVFCatalog
raidLukeRewardErr error
raidRewardCommitMu sync.Mutex
raidRewardFlowMu sync.Mutex
raidRewardCompleted map[runtimeRaidRewardCompletionKey]runtimeRaidSettlementRewardCompletion
raidRewardPublished map[runtimeRaidRewardCompletionKey]*runtimeRaidRewardPublication
// 上面两张表的插入顺序。键含单调递增的 Token、RaidKey 永不复用,所以每条都是
// 新键;没有这个顺序就无法在超限时淘汰最旧的一条。见 raid_reward_bounds.go。
raidRewardCompletedOrder []runtimeRaidRewardCompletionKey
raidRewardPublishedOrder []runtimeRaidRewardCompletionKey
// finishedRaidLeavers 记录"攻坚已被释放后"已经点过退出的成员,按 raid key 分组:
// 这时 s.raids 里已经没有状态,剩下的名册只能由 completion 记录减去这些人生成
// (见 markFinishedRuntimeRaidLeaver)。
finishedRaidLeaverMu sync.Mutex
finishedRaidLeavers map[uint32]map[uint16]bool
raidRewardRandom func() uint64
packetLog atomic.Pointer[packetLogger]
// records 是三类审计记录(点券消费/对话/副本)的追加写入器,见 activity_record.go。
// 与 packetLog 同样用 atomic:玩法主路径会在任意协程调 recordActivity,
// 而 Start/Stop 会在生命周期线程创建/关闭它。
records atomic.Pointer[activityRecorder]
partyUDPRelay *currentPartyUDPRelay
partyUDPProbe *currentPartyUDPProbe
partyEndpointMu sync.Mutex
partyEndpoints currentPartyUDPEndpointRegistry
partyUDPOwnerPID func(netip.AddrPort) (uint32, bool)
runtimePartyManager *party.RuntimePartyManager
// partyPublishFormation maps a party to the token of its live formation-retry
// epoch; see party_publish_retry.go. It has its own mutex (never s.mu) because
// a retry callback runs from the gameplay timer queue and must not contend
// with, or invert against, the session/party locks taken by a publish.
partyPublishMu sync.Mutex
partyPublishSerial uint64
partyPublishFormation map[int]uint64
// raidCost 是每场攻坚的服务端成本计数(见 raid_cost_summary.go):慢事件、
// 安装分片、写阻塞、中继端口占用等聚合成一行团本记录,供"收不到玩家日志"
// 时判定这场到底卡没卡、掉没掉。它有自己的锁,绝不在 gameplay 锁内取用。
raidCost runtimeRaidCostRegistry
partyTeleportMu sync.Mutex
partyTeleportPending map[int]*currentPartyTeleportSession
partyTeleportLastRequest map[int]currentPartyTeleportRequest
partyTeleportToken uint64
cancel context.CancelFunc
wg sync.WaitGroup
packetID uint64
clientAccounts clientAccountRegistry
robotSessionSecret []byte
robotSessionReplayMu sync.Mutex
robotSessionReplays map[[32]byte]time.Time
// characterCreateMu serializes the name/slot/numeric-ID reservation shared
// by native character creation and loopback GM robot provisioning. The
// repository creation transaction makes one aggregate atomic, but the
// three reservations are account/global reads that must not race each
// other before that transaction begins.
characterCreateMu sync.Mutex
initialEquipmentMu sync.Mutex
initialEquipmentArchive *platformpvf.Archive
initialEquipmentByJob map[byte][]initialEquipmentEntry
initialEquipmentLoadErr error
pvfItemCatalogMu sync.Mutex
pvfItemCatalog *pvfDungeonDropCatalog
pvfItemCatalogLoadErr error
gmGameplaySettingsMu sync.Mutex
gmGameplaySettingsReady bool
gmHellEpicDropRatePct int
// gmEpicDropRatePotionTestMultiplierPct 是"城镇增益药剂临时测试倍率"的缓存
// (0 = 关闭,倍数按 PVF 表走)。见 gm_tool_dungeon.go 里那个常量的说明。
gmEpicDropRatePotionTestMultiplierPct int
// gmFatigueMode / gmBurningEnabled 是疲劳与燃烧疲劳的 GM 配置缓存。
// 见 gm_tool_dungeon.go 常量区与 gmFatigueModeLocked 的说明。
gmFatigueMode string
gmBurningEnabled bool
gmGameplaySettingsAt time.Time
automaticRobotAppearanceMu sync.Mutex
automaticRobotAvatarConfig *currentAvatarFittingRoomConfig
automaticRobotAvatarLoadErr error
chatEmoticonCatalogMu sync.Mutex
chatEmoticonCatalog []currentChatEmoticonEntry
chatEmoticonCatalogLoadErr error
vendingMachineCatalogMu sync.Mutex
vendingMachineCatalog *currentVendingMachineCatalog
vendingMachineCatalogLoadErr error
vendingMachineRoll func(uint64) (uint64, error)
vendingMachineCompletionKey func() (string, error)
joustCatalogMu sync.Mutex
joustCatalog *dnfjoust.Catalog
joustCatalogLoadErr error
joustHistoryMu sync.Mutex
joustOperationMu sync.Mutex
spendTimeCatalogMu sync.Mutex
spendTimeCatalogState *currentSpendTimeRuntimeCatalog
spendTimeCatalogLoadErr error
attendanceMarbleMu sync.Mutex
attendanceMarbleCatalog *attendanceMarbleCatalog
attendanceMarbleLoadErr error
laborDayMu sync.Mutex
laborDayCatalog *dnfonlineevent.LaborDayCatalog
monthlyAttendanceMu sync.Mutex
monthlyAttendanceCatalog *dnfonlineevent.MonthlyCatalog
weeklyAttendanceMu sync.Mutex
weeklyAttendanceCatalog *dnfonlineevent.WeeklyCatalog
returneeLuckyMu sync.Mutex
returneeCatalog *dnfonlineevent.ReturneeCatalog
luckyCharacterCatalog *currentLuckyCatalog
legacyLevelMu sync.Mutex
legacyLevelCatalog *dnfonlineevent.LegacyLevelCatalog
levelAimingMu sync.Mutex
levelAimingCatalog *dnfonlineevent.LevelAimingCatalog
expertJobMu sync.Mutex
expertJobCatalog *dnfexpertjob.Catalog
expertJobCatalogLoadErr error
petCatalogMu sync.Mutex
petCatalog *dnfpet.PVFCatalog
petCatalogLoadErr error
collectboxMu sync.Mutex
collectboxCatalog *currentCollectboxCatalog
collectboxCatalogLoadErr error
epicBookMu sync.Mutex
epicBookCatalog *dnfepicbook.Catalog
epicBookCatalogLoadErr error
hellPartyMu sync.Mutex
hellPartyCatalog *dnfhellparty.Catalog
hellPartyCatalogLoadErr error
hellPartyDropMu sync.Mutex
hellPartyDropCatalog *dnfhellparty.DropCatalog
hellPartyFragmentPools map[int][]currentDungeonHellPartyFragmentCandidate
hellPartyDropCatalogLoadErr error
premiumCatalogMu sync.Mutex
premiumCatalog *currentPremiumCatalog
premiumCatalogLoadErr error
weaknessCostMu sync.Mutex
weaknessCostTable *currentWeaknessCostTable
weaknessCostErr error
disjointConfigMu sync.Mutex
disjointPVFConfig *currentDisjointPVFConfig
disjointPVFConfigErr error
repairCostMu sync.Mutex
repairCostCatalog *currentRepairCostCatalog
repairCostLoadErr error
equipmentRegenerationMu sync.Mutex
equipmentRegenerationCatalog *currentEquipmentRegenerationCatalog
equipmentRegenerationLoadErr error
npcShopCatalogMu sync.Mutex
npcShopCatalog *currentNPCShopCatalog
npcShopCatalogLoadErr error
adventureGroupMu sync.Mutex
adventureGroupLoginMu sync.Mutex
adventureGroupRuntimeMu sync.Mutex
adventureRepresentativeMu sync.Mutex
adventureGroupTable *adventuregroup.Tables
adventureGroupLoadErr error
honorMu sync.Mutex
honorTable *dnfhonor.Tables
honorLoadErr error
initialSkillsMu sync.Mutex
initialSkillsByJob map[byte][]initialSkillEntry
skillCatalog *dnfskill.Table
initialSPTable map[int]int
initialTPTable map[int]int
experienceMu sync.Mutex
experienceTable *dnfprogression.ExperienceTable
experienceTableLoadErr error
professionMu sync.Mutex
professionProfiles *dnfprofession.Profiles
professionProfilesErr error
questCatalogMu sync.Mutex
questCatalog *dnfquest.Catalog
questCatalogLoadErr error
ceraShopMu sync.Mutex
ceraShopCatalog *pvfCeraShopCatalog
ceraShopCatalogLoadErr error
ceraShopPurchaseMu sync.Mutex
premiumWeb *currentPremiumWebPortal
deathTowerMu sync.Mutex
deathTowerStageMapIDs []int
deathTowerBasisLevel int
titleBookMappingMu sync.Mutex
titleBookMappingCache map[int32]titleBookMappingEntry
onlinePlayers *OnlinePlayerManager
characterStatsMu sync.Mutex
characterStats *dnfcharstat.Table
characterStatsLoadErr error
equipmentStatsMu sync.Mutex
equipmentStatPaths map[int64]string
equipmentStats map[int64]dnfcharstat.Vector
equipmentExpAdvantages map[int64]int64
equipmentStatsLoadErr error
combatPowerMu sync.Mutex
combatPowerCatalog *dnfcombatpower.Catalog
combatPowerCatalogErr error
townCatalogMu sync.RWMutex
townCatalog *dnftown.Table
// townMovieCatalog 是 etc/townmovielist.etc:城镇区域开启后要自动播放的过场。
townMovieCatalog *dnftown.TownMovieTable
// storyDigestCatalog 是 etc/storydigest/digest.etc:章节过场的播放边界。
// 城镇区域的过场影片属于哪一段,就把 last_level 下发到那段的等级下界,
// 客户端才会在该段结束处播放影片。
storyDigestCatalog *dnfstorydigest.Digest
worldMapMu sync.RWMutex
worldMapTable *worldmap.Table
worldMapResolver *worldmap.Resolver
dungeonAdmissionCatalog *dnfdungeonadmission.Catalog
dungeonAdmissionReceiptOnce sync.Once
dungeonAdmissionReceiptNonce string
dungeonAdmissionReceiptErr error
sequentialDungeonMu sync.Mutex
sequentialDungeonCatalog *currentSequentialDungeonCatalog
sequentialDungeonCatalogErr error
dungeonMonsterTable *pvfDungeonMonsterCatalog
dungeonAICharacterTable *pvfDungeonAICharacterCatalog
dungeonTutorialScripts *pvfDungeonTutorialScriptCatalog
dungeonChoice func(int) (int, error)
dungeonSeed func() (uint32, error)
gameplayTimers gameplayTimeQueue
repositoryProvider func() (dnfrepo.Group, bool)
initialTownEntryWait func(time.Duration)
initialSceneMemoryTrimOnce sync.Once
dprotoProvider dproto.Provider
// Personal-teleport (C2S op470) lock, kept in its own block so it does not
// re-align the field groups above. Keyed by CHARACTER id: the client keeps
// its countdown on the logged-in user object, so two characters of one
// account each own an entry. The server mirrors it so a request cannot
// bypass the client-side gate.
soloTeleportCooldownMu sync.Mutex
soloTeleportCooldownUntil map[uint16]time.Time
// soloTeleportClock is a test hook for the expiry above; nil means time.Now.
soloTeleportClock func() time.Time
// purchaseLimitClock 是 PVF 限购窗口取值用的时钟;nil 表示 time.Now。
purchaseLimitClock func() time.Time
}
const initialSceneMemoryTrimDelay = 5 * time.Second
func (s *Service) scheduleInitialSceneMemoryTrim() {
if s == nil {
return
}
s.initialEquipmentMu.Lock()
archiveLoaded := s.initialEquipmentArchive != nil
s.initialEquipmentMu.Unlock()
if !archiveLoaded {
return
}
s.scheduleProcessMemoryTrim(initialSceneMemoryTrimDelay, runtimedebug.FreeOSMemory)
}
func (s *Service) scheduleProcessMemoryTrim(delay time.Duration, trim func()) {
if s == nil || trim == nil {
return
}
s.initialSceneMemoryTrimOnce.Do(func() {
time.AfterFunc(delay, trim)
})
}
// ServiceOptions 描述 dnfbridge 对外注入的项目侧依赖。
// 这里只保存依赖入口,不直接创建 MySQL/Redis,也不把玩法状态塞进 bridge。
type ServiceOptions struct {
RepositoryProvider func() (dnfrepo.Group, bool)
DprotoProvider dproto.Provider
LocalDebugMode bool
LocalDebugStrikeOnSkillUse bool
// RobotSessionSecret authenticates the private-LAN dofrobot adapter. The
// value is process-only: it must never be logged or persisted by bridge.
RobotSessionSecret string
}
// New 使用框架环境创建 DNF bridge 服务。
func New(env *appkit.Env) *Service {
return NewWithOptions(env, ServiceOptions{})
}
// NewWithOptions 使用框架环境和项目侧依赖创建 DNF bridge 服务。
func NewWithOptions(env *appkit.Env, serviceOptions ServiceOptions) *Service {
if env == nil {
env = &appkit.Env{}
}
core := env.Core()
service := &Service{
logger: core.Logger,
options: optionsFromConfig(core.Config),
localDebugMode: serviceOptions.LocalDebugMode && strings.EqualFold(core.Config.Service.Environment, "local"),
conns: make(map[net.Conn]struct{}),
gameSessions: make(map[uint16]*gameSession),
onlineSeats: make(map[*gameSession]struct{}),
onlineReportTrigger: make(chan struct{}, 1),
itemTrades: make(map[uint16]*onlineItemTrade),
weddingInvitations: make(map[uint16]*currentWeddingInvitation),
privateStores: make(map[uint16]*currentPrivateStore),
privateStoreVisitors: make(map[*gameSession]uint16),
expertJobStores: make(map[uint16]*currentExpertJobStore),
expertJobVisitors: make(map[*gameSession]uint16),
raids: make(map[uint32]*runtimeRaidState),
initialEquipmentArchive: env.PVF,
repositoryProvider: serviceOptions.RepositoryProvider,
dprotoProvider: serviceOptions.DprotoProvider,
clientAccounts: newClientAccountRegistry(),
partyEndpoints: newCurrentPartyUDPEndpointRegistry(),
partyUDPOwnerPID: localUDPEndpointOwnerPID,
runtimePartyManager: party.NewRuntimePartyManager(),
robotSessionSecret: []byte(strings.TrimSpace(serviceOptions.RobotSessionSecret)),
robotSessionReplays: make(map[[32]byte]time.Time),
}
service.localDebugStrikeOnSkillUse = service.localDebugMode && serviceOptions.LocalDebugStrikeOnSkillUse
service.launcherAuthHTTPClient = newLauncherAuthVerifyClient(service.options)
identity, identityErr := newLauncherIdentityVerifier(
service.options.launcherIdentityMode,
service.options.launcherIdentityPublicKeys,
)
if identityErr != nil {
// Fail closed on a broken configuration: a server that looks bound but
// silently accepts anyone is worse than one that refuses to start, so
// the error is held and returned by Start.
service.launcherIdentityErr = identityErr
identity = &launcherIdentityVerifier{mode: LauncherIdentityModeOff}
}
service.launcherIdentity = identity
if service.launcherIdentity.enabled() {
service.logInfo("dnfbridge launcher identity configured",
"mode", service.launcherIdentity.modeName(),
"server_id", service.launcherIdentity.serverID,
"public_keys", len(service.launcherIdentity.keys))
}
service.nextAuctionListingID.Store(uint64(time.Now().Unix()))
service.gameplayTimers = newProcessGameplayTimeQueue(func(name string, recovered any) {
service.logWarn("dnfbridge gameplay timer callback panic", "timer_name", name, "recovered", recovered)
})
service.onlinePlayers = newOnlinePlayerManager()
_, _ = service.currentDungeonAdmissionReceiptNonce()
service.registerDungeonRuntimeDebugRoute(env)
return service
}
// Name 返回生命周期组件名。
func (s *Service) Name() string {
return "dnfbridge-service"
}
// Start 加载外部 channel_info.etc,并启动频道服和 game 端口监听。
func (s *Service) Start(ctx context.Context) error {
// 客户端影片指纹校验:文件被改动或删除则拒绝启动。
if err := verifyClientArtifacts(); err != nil {
return fmt.Errorf("client movie integrity check failed: %w", err)
}
if ctx == nil {
ctx = context.Background()
}
if err := s.validateDprotoConfiguration(); err != nil {
return err
}
if s.launcherIdentityErr != nil {
return fmt.Errorf("dnfbridge launcher identity: %w", s.launcherIdentityErr)
}
catalog, channelScript, err := s.loadChannelAssets()
if err != nil {
return err
}
// 表能起来但玩家侧会受影响的状态(缺默认城镇频道 / 没有特殊区域频道)在启动时也要可见,
// 手改过 channel_info.etc 的人不该只在热重载里才看到。
for _, warning := range currentChannelTableWarnings(catalog, true) {
s.logWarn("dnfbridge channel table warning",
"warning", warning,
"channel_info", s.options.channelInfoFile)
}
packetLog, err := s.openPacketLog()
if err != nil {
return err
}
// 审计记录器:目录不可写时 newActivityRecorder 返回 nil,后续 recordActivity 自动空转,
// 不影响服务启动——记录是旁路,不能因为磁盘/权限问题把服务端拦在门外。
records := newActivityRecorder(s.options.recordDir, s.logger)
runCtx, cancel := context.WithCancel(ctx)
s.mu.Lock()
s.catalog = catalog
s.channelScript = append([]byte(nil), channelScript...)
s.runCtx = runCtx
s.cancel = cancel
s.packetLog.Store(packetLog)
s.records.Store(records)
if s.initialTownEntryWait == nil {
// Initial-town visual delay is disabled. Production never blocks the
// server packet stream at the retained test boundary.
s.initialTownEntryWait = func(time.Duration) {}
}
if s.conns == nil {
s.conns = make(map[net.Conn]struct{})
}
s.mu.Unlock()
// 2026-09-12: both live sessions stopped answering right after login while the
// accept loop stayed healthy -- the signature of a handler blocked on a shared
// mutex (the read loop shares the session goroutine, so a blocked handler stops
// every later RECV from being logged at all). Keep a rolling goroutine dump on
// disk so the next occurrence names the exact frame instead of being inferred.
s.startGoroutineWatchdog(runCtx)
// 2026-09-25: the same class of question again, one level finer. The
// watchdog above dumps every 20s, which is far too coarse to land inside a
// 4-10s stall. This sampler is default-off (DNF90_STALL_SAMPLER) and
// appends an all-goroutine stack snapshot only when the snapshot changed,
// so it names the exact frame a slow path is sitting in -- including a
// frame nobody thought to instrument, which is what the five existing
// step probes in completeRuntimePartyDungeonLoading failed to do.
s.startStallStackSampler(runCtx)
// PVF preload stages run in a fixed order before any listener opens. Keep
// one row per stage: the order, each loader's own wrapped error text, and
// the shared cleanup path are observable startup behavior. The loop must
// not re-wrap loader errors or change what is reset on failure; name only
// labels the stage for audit, it is never merged into the returned error.
pvfPreloads := []struct {
name string
load func(context.Context) error
}{
{"initial equipment index", s.preloadInitialEquipmentIndex},
{"item catalog", s.preloadPVFItemCatalog},
{"pet catalog", s.preloadPetPVFCatalog},
{"expert job catalog", s.preloadExpertJobCatalog},
{"adventure group table", s.preloadAdventureGroupTable},
{"honor table", s.preloadHonorTable},
{"town catalog", s.preloadTownCatalog},
{"skill catalog", s.preloadSkillCatalog},
{"profession profiles", s.preloadProfessionProfiles},
{"quest catalog", s.preloadQuestCatalog},
{"dungeon world map", s.preloadDungeonWorldMap},
{"character stat table", s.preloadCharacterStatTable},
{"equipment stat index", s.preloadEquipmentStatIndex},
{"combat power affix catalog", s.preloadCombatPowerAffixCatalog},
{"epic book catalog", s.preloadEpicBookCatalog},
{"hell party catalog", s.preloadHellPartyCatalog},
{"hell party drop catalog", s.preloadHellPartyDropCatalog},
{"dungeon drop catalog", s.preloadDungeonDropCatalog},
// 把 itemRefs 索引里的全部道具定义解析好,消掉玩家进城时的逐道具冷解析。
// 依赖上一行的 catalog,必须排在它后面;代价与失败语义见
// dungeon_drop_item_preload.go。
{"dungeon drop item definitions", s.preloadDungeonDropItemDefinitions},
{"gold carry limit table", s.preloadCarryGoldLevelLimits},
}
for _, preload := range pvfPreloads {
if err := preload.load(runCtx); err != nil {
cancel()
closeErr := packetLog.Close()
s.mu.Lock()
s.catalog = nil
s.cancel = nil
s.packetLog.Store(nil)
s.mu.Unlock()
return errors.Join(err, closeErr)
}
}
// Build the PVF catalogs that would otherwise be created lazily inside the
// first player request that needs one. Measured 2026-09-26 on a cold server:
// the first town entry built three of them inside that player's own session
// event loop and blocked the session for 1589ms (op143
// ENUM_CMDPACKET_CHANGE_TUTORIAL_FLAG); the same entry 30 seconds later, with
// the catalogs warm, took 491ms. Deliberately best effort, and deliberately
// not rows in pvfPreloads -- see warmOptionalPVFCatalogs for both reasons.
s.warmOptionalPVFCatalogs(runCtx)
if err := s.restoreRuntimeCreatedRaidSeed(); err != nil {
cancel()
closeErr := packetLog.Close()
s.mu.Lock()
s.catalog = nil
s.cancel = nil
s.packetLog.Store(nil)
s.mu.Unlock()
return errors.Join(err, closeErr)
}
// Startup builds several short-lived PVF document indexes before retaining
// their compact domain catalogs. Force one collection/scavenge at this fixed
// boundary so those temporary token graphs and spans do not remain resident
// until normal gameplay eventually reaches the next GC target. This runs
// before listeners open, so it cannot add a pause to an active session.
runtimedebug.FreeOSMemory()
gamePorts := s.gameListenPorts(catalog)
if len(gamePorts) == 0 {
cancel()
closeErr := packetLog.Close()
s.mu.Lock()
s.catalog = nil
s.cancel = nil
s.packetLog.Store(nil)
s.mu.Unlock()
return errors.Join(fmt.Errorf("dnfbridge has no game listen ports"), closeErr)
}
partyRelay, err := newCurrentPartyUDPRelay(
s.options.partyUDPRelayEnabled,
s.options.serverIP,
s.options.partyUDPRelayPortStart,
s.options.partyUDPRelayPortCount,
func(message string, args ...any) { s.logWarn(message, args...) },
)
if err != nil {
cancel()
closeErr := packetLog.Close()
s.mu.Lock()
s.catalog = nil
s.cancel = nil
s.packetLog.Store(nil)
s.mu.Unlock()
return errors.Join(err, closeErr)
}
// The relay's own diagnostics: prose warnings for its lifecycle, and named
// packet-log events for the forwarding budget, so a throttled leg is
// greppable as game-party-udp-relay-throttled.
partyRelay.logEvent = func(kind string, args ...any) { s.logPacketEvent(kind, args...) }
if s.options.packetLogEnabled {
// The dump is a diagnostic, not a transcript. Party traffic between two
// clients in one town runs at thousands of datagrams per second, and
// writing every payload as hex put 228 MB into the packet log in five
// minutes, which starved the server's own packet path and made the client
// look unresponsive. Sample it the way the relay already samples its own
// trace: the head of the stream in full, then every thousandth datagram.
relayDumps := make(map[currentPartyUDPRelayLegKey]int)
var relayDumpsMu sync.Mutex
partyRelay.payloadDump = func(leg currentPartyUDPRelayLegKey, source *net.UDPAddr, payload []byte) {
relayDumpsMu.Lock()
relayDumps[leg]++
seen := relayDumps[leg]
relayDumpsMu.Unlock()
if !currentPartyUDPRelayDumpDatagram(seen) {
return
}
s.logPacketEvent("party-udp-relay-payload",
"party_id", leg.partyID,
"owner_id", leg.ownerID,
"peer_id", leg.peerID,
"source", source.String(),
"bytes", len(payload),
"packet_count", seen,
"sampled", seen > currentPartyUDPRelayDumpHeadPackets,
"payload_hex", hex.EncodeToString(payload))
}
}
partyProbeBindIP := s.options.partyUDPBindIP
if partyProbeBindIP == "" {
partyProbeBindIP = s.options.serverIP
}
partyProbe, err := newCurrentPartyUDPProbe(
runCtx,
partyProbeBindIP,
[]int{s.options.initialUDPPort1, s.options.initialUDPPort2},
s.observeCurrentPartyUDPEndpoint,
func(message string, args ...any) { s.logInfo(message, args...) },
)
if err != nil {
cancel()
_ = partyRelay.Close()
closeErr := packetLog.Close()
s.mu.Lock()
s.catalog = nil
s.cancel = nil
s.packetLog.Store(nil)
s.mu.Unlock()
return errors.Join(err, closeErr)
}
s.mu.Lock()
s.partyUDPRelay = partyRelay
s.partyUDPProbe = partyProbe
s.mu.Unlock()
if err := s.listen(runCtx, s.options.channelListen, s.handleChannelConn); err != nil {
cancel()
_ = partyRelay.Close()
_ = partyProbe.Close()
closeErr := packetLog.Close()
s.mu.Lock()
s.catalog = nil
s.cancel = nil
s.packetLog.Store(nil)
s.partyUDPRelay = nil
s.partyUDPProbe = nil
s.mu.Unlock()
return errors.Join(err, closeErr)
}
s.startGameplayTimeQueue(runCtx)
s.registerDailyQuestResetTimer()
// 骑士马战(joust)活动已取消:不再启动该活动的边界调度器,
// 服务端不会主动推送 op1240/op1241/op1242/op1243。
// 实现(joust_scheduler.go)保留为死代码,便于日后按需恢复。
s.startLauncherOnlineReport(runCtx)
for _, port := range gamePorts {
if err := s.listenGamePort(runCtx, port); err != nil {
cancel()
_ = s.Stop(context.Background())
return err
}
}
s.logInfo("dnfbridge started",
"channel_listen", s.options.channelListen,
"channel_info", s.options.channelInfoFile,
"channel_server_index", s.channelServerID(),
"advertise_server_index", s.channelAdvertiseID(),
"channel_info_body_mode", s.channelInfoBodyMode(),
"game_initial_mode", s.gameInitialMode(),
"game_pre_bootstrap", s.gamePreBootstrapMode(),
"game_post_bootstrap", s.gamePostBootstrapMode(),
"game_upper_header", s.gameUpperHeaderMode(),
"game_upper_client_body_codec", s.gameUpperClientBodyCodecMode(),
"game_dproto_mode", s.gameDprotoMode(),
"game_listen_host", s.options.gameListenHost,
"game_ports", len(gamePorts),
"server_ip", s.options.serverIP,
"party_udp_probe_ports", fmt.Sprintf("%d,%d", s.options.initialUDPPort1, s.options.initialUDPPort2),
"party_udp_relay", partyRelay.Enabled(),
"party_udp_relay_port_start", s.options.partyUDPRelayPortStart,
"party_udp_relay_port_count", s.options.partyUDPRelayPortCount,
// 支援兵自建 APC 的开关状态。
//
// 为什么要在启动日志里打这一行:这条通道由环境变量控制,而环境变量
// 是否真的传到了服务端进程,从外面看不出来(父进程可能是登录器、
// 也可能是 START.bat,链路不同)。把它写进启动日志,就能一眼确认
// "开关到没到位",不必靠行为反推。
"companion_server_start_map_apc", currentDungeonCompanionP0StartMapEnabled())
return nil
}
func gameListenAddress(host string, port int) string {
host = strings.TrimSpace(host)
if host == "" {
return ":" + strconv.Itoa(port)
}
return net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(port))
}
// Stop 关闭监听并等待连接处理退出。
func (s *Service) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
cancel := s.cancel
listeners := append([]net.Listener(nil), s.listeners...)
packetLog := s.packetLog.Load()
records := s.records.Load()
partyRelay := s.partyUDPRelay
partyProbe := s.partyUDPProbe
conns := make([]net.Conn, 0, len(s.conns))
for conn := range s.conns {
conns = append(conns, conn)
}
s.listeners = nil
s.gameListeners = nil
s.runCtx = nil
s.cancel = nil
s.packetLog.Store(nil)
s.records.Store(nil)
s.partyUDPRelay = nil
s.partyUDPProbe = nil
s.channelScript = nil
s.mu.Unlock()
if cancel != nil {
cancel()
}
var stopErr error
for _, listener := range listeners {
stopErr = errors.Join(stopErr, listener.Close())
}
for _, conn := range conns {
stopErr = errors.Join(stopErr, conn.Close())
}
if partyRelay != nil {
stopErr = errors.Join(stopErr, partyRelay.Close())
}
if partyProbe != nil {
stopErr = errors.Join(stopErr, partyProbe.Close())
}
stopErr = errors.Join(stopErr, packetLog.Close())
// 记录器先关:关闭后再来的 recordActivity 会因为 s.records 为空而空转,
// 不会往一个已经关掉的文件句柄里写。
records.Close()
done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()
select {
case <-ctx.Done():
return errors.Join(stopErr, ctx.Err())
case <-done:
return stopErr
}
}
func (s *Service) listen(ctx context.Context, addr string, handler func(context.Context, net.Conn)) error {
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
s.mu.Lock()
s.listeners = append(s.listeners, listener)
s.mu.Unlock()
s.wg.Add(1)
go s.acceptLoop(ctx, listener, handler)
return nil
}
// listenGamePort 打开一个游戏端口监听,并按端口登记,供热重载时按频道开关对账。
// 只用于游戏端口;频道口(channelListen)仍走 listen()。
func (s *Service) listenGamePort(ctx context.Context, port int) error {
addr := gameListenAddress(s.options.gameListenHost, port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
s.mu.Lock()
if s.gameListeners == nil {
s.gameListeners = make(map[int]net.Listener)
}
// 同一端口若已有监听(重复对账),保留先到的那个,并把这个多余的关掉。
// 注意要在登记进 s.listeners **之前**判断,否则会给 Stop 留一个已关闭的孤儿 listener。
if existing := s.gameListeners[port]; existing != nil {
s.mu.Unlock()
_ = listener.Close()
return nil
}
s.gameListeners[port] = listener
s.listeners = append(s.listeners, listener)
s.mu.Unlock()
s.wg.Add(1)
go s.acceptLoop(ctx, listener, s.handleGameConn)
return nil
}
// closeGamePort 关闭某个游戏端口的监听并解除登记。
//
// 注意语义:只停止接受**新**连接。net.Listener.Close 不会断开已经 Accept 的连接,
// 所以关闭频道不会把站在该频道里的玩家踢下线——这正是热重载想要的行为。
func (s *Service) closeGamePort(port int) error {
s.mu.Lock()
listener := s.gameListeners[port]
delete(s.gameListeners, port)
if listener != nil {
kept := make([]net.Listener, 0, len(s.listeners))
for _, item := range s.listeners {
if item != listener {
kept = append(kept, item)
}
}
s.listeners = kept
}
s.mu.Unlock()
if listener == nil {
return nil
}
return listener.Close()
}
// currentAcceptLoopFailureLimit 是 accept 连续失败多少次后放弃该监听。
//
// 存在的理由:Accept 的错误有两类——一类是监听被关闭(必须退出,见下),另一类
// 是资源类临时错误(EMFILE 之类,值得重试)。如果某天出现"永久性且非 ErrClosed"
// 的错误,无上限重试同样会变成空转 + 日志刷屏,所以给一个上限兜底。
const currentAcceptLoopFailureLimit = 32
func (s *Service) acceptLoop(ctx context.Context, listener net.Listener, handler func(context.Context, net.Conn)) {
defer s.wg.Done()
consecutiveFailures := 0
for {
conn, err := listener.Accept()
if err != nil {
// 监听被关闭时必须直接退出。
//
// 频道热重载会关掉"已停用频道"的游戏端口(gm_channel_reload.go → closeGamePort),
// 那条路径**不会**取消运行期 ctx(取消会把整个服务停掉)。此时 Accept 会立刻
// 返回 closed 错误,只判 ctx.Err() 就会 continue → 死循环:一个核 100% 空转,
// 而且每圈写一条 warn,日志会被刷爆。Stop 是先 cancel 再 Close listener,所以
// 老路径掩盖了这个坑,热重载才把它暴露出来。
if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
return
}
consecutiveFailures++
if consecutiveFailures >= currentAcceptLoopFailureLimit {
s.logWarn("dnfbridge accept loop stopped after repeated failures",
"addr", listener.Addr().String(), "failures", consecutiveFailures, "error", err)
return
}
s.logWarn("dnfbridge accept failed", "addr", listener.Addr().String(), "error", err)
continue
}
consecutiveFailures = 0
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.trackConn(conn)
defer s.untrackConn(conn)
handler(ctx, conn)
}()
}
}
func (s *Service) loadChannelAssets() (*channelcatalog.Catalog, []byte, error) {
raw, err := osReadFile(s.options.channelInfoFile)
if err != nil {
return nil, nil, fmt.Errorf("load dnf channel info: %w", err)
}
index, err := channelinfo.Parse(raw)
if err != nil {
return nil, nil, fmt.Errorf("parse dnf channel info: %w", err)
}
catalog, err := channelcatalog.New(index, channelcatalog.Options{ServerID: s.channelServerID()})
if err != nil {
return nil, nil, err
}
script, err := s.buildChannelScript(raw)
if err != nil {
return nil, nil, fmt.Errorf("build 90CN online channel script: %w", err)
}
return catalog, script, nil
}
func (s *Service) buildChannelScript(raw []byte) ([]byte, error) {
if s.channelTableStrict() {
return channelinfo.Build90CNOnlineScript(
raw,
s.channelServerID(),
s.channelAdvertiseID(),
dnfenum.BootstrapChannelID,
)
}
// 宽松模式:源表没有引导频道 19 时不再让 loadChannelAssets 失败(服务端起不来)。
// 只告警,让运维在"只开少数频道"的配置下仍能启动;要恢复旧硬校验把
// DNFBRIDGE_CHANNEL_TABLE_STRICT 设成 1。
script, bootstrapPresent, err := channelinfo.Build90CNOnlineScriptAllowingMissingBootstrap(
raw,
s.channelServerID(),
s.channelAdvertiseID(),
dnfenum.BootstrapChannelID,
)
if err != nil {
return nil, err
}
if !bootstrapPresent {
s.logWarn("dnfbridge channel table has no bootstrap channel; starting anyway",
"bootstrap_channel", dnfenum.BootstrapChannelID,
"channel_info", s.options.channelInfoFile,
"reason", "bootstrap_channel_check_is_legacy_for_the_official_pvf_flow",
"restore_strict", "DNFBRIDGE_CHANNEL_TABLE_STRICT=1")
}
return script, nil
}
// channelTableStrict 返回是否恢复"频道表必须含 11/19"的旧硬校验。
func (s *Service) channelTableStrict() bool {
if s == nil {
return false
}
return s.options.channelTableStrict
}
func (s *Service) channelServerID() int {
if s != nil {
if s.options.channelServerID > 0 {
return s.options.channelServerID
}
}
return 1
}
func (s *Service) channelAdvertiseID() int {
if s != nil {
if s.options.channelAdvertiseID >= 0 {
return s.options.channelAdvertiseID
}
}
return s.channelServerID()
}
func (s *Service) channelInfoBodyMode() string {
if s == nil {
return channelInfoBodyLatest
}
return normalizeChannelInfoBodyMode(s.options.channelInfoBodyMode)
}
func (s *Service) currentCatalog() *channelcatalog.Catalog {
s.mu.Lock()
defer s.mu.Unlock()
return s.catalog
}
func (s *Service) currentChannelScript() []byte {
s.mu.Lock()
defer s.mu.Unlock()
return append([]byte(nil), s.channelScript...)
}
func (s *Service) openPacketLog() (*packetLogger, error) {
if !s.options.packetLogEnabled {
return nil, nil
}
logger, err := openPacketLogger(s.options.packetLogPath)
if err != nil {
// 与 records 同一条策略(见下方 newActivityRecorder 的注释):包日志是旁路,
// 磁盘/密钥问题不能把服务端拦在门外。但这里**绝不退化成明文**——打不开就整条
// 通道关闭,并留下一条显式告警,让运维能立刻发现"日志没了"而不是"日志变明文了"。
if s.logger != nil {
s.logger.Warn(
"dnfbridge packet log disabled: encrypted log channel could not be opened",
"path", s.options.packetLogPath,
"error", err,
)
}
return nil, nil
}
return logger, nil
}
func (s *Service) logPacket(direction string, kind string, data []byte, fields ...any) {
if suppressPacketLogLine(direction, kind, fields...) {
return
}
logger := s.packetLog.Load()
logger.Log(direction, kind, data, fields...)
}
func (s *Service) logPacketEvent(kind string, args ...any) {
if suppressPacketLogEvent(kind, args...) {
return
}
logger := s.packetLog.Load()
logger.Event(kind, args...)
}
func suppressPacketLogLine(direction string, kind string, fields ...any) bool {
if kind == "game-raw" {
return true
}
msgID, hasMsgID := packetLogUintField(fields, "msg_id")
typ, hasType := packetLogUintField(fields, "type")
if hasMsgID {
switch msgID {
case 1276, 1516, 1518: // heartbeat / antibot / DPROTO callback noise.
return true
case 13:
// op13 full container refreshes already have concise EVENT evidence.
return direction == "SEND"
}
}
if hasType {
switch typ {
case 35: // SET_USER_POSITION, emitted continuously while walking.
return true
}
}
return false
}
func suppressPacketLogEvent(kind string, fields ...any) bool {
switch kind {
case "game-town-set-user-position-captured":
return true
case "game-upper-check-user-connection-selected-success":
return true
// 攻坚高频噪声:这两种 2.5 小时内接近一千条(实测 708 + 264),而它们的全部
// 信息量就是"同类型的门边界通知又跳过一次"——原因已经写在 kind 名里,且真正
// 要看的那次边界现在写在 records/raid-*.jsonl(带 wall_ms / 名单 / 就绪位图)。
// 掐掉它们不损失任何证据,只把 packet_log 里翻日志的时间还回来。
case "game-raid-dungeon-lifecycle-skipped-already-notified":
return true
case "game-raid-dungeon-lifecycle-skipped-joined":
return true
}
msgID, hasMsgID := packetLogUintField(fields, "msg_id")
typ, hasType := packetLogUintField(fields, "type")
if hasMsgID {
switch msgID {
case 1276, 1516, 1518:
return true
case 13:
return kind == "game-upper-send-meta"
}
}
if hasType && typ == 35 {
return kind == "game-legacy-meta"
}
return false
}
func packetLogUintField(fields []any, name string) (uint64, bool) {
for i := 0; i+1 < len(fields); i += 2 {
if fmt.Sprint(fields[i]) != name {
continue
}
switch v := fields[i+1].(type) {
case uint8:
return uint64(v), true
case uint16:
return uint64(v), true
case uint32:
return uint64(v), true
case uint64:
return v, true
case uint:
return uint64(v), true
case int8:
if v >= 0 {
return uint64(v), true
}
case int16:
if v >= 0 {
return uint64(v), true
}
case int32:
if v >= 0 {
return uint64(v), true
}
case int64:
if v >= 0 {
return uint64(v), true
}
case int:
if v >= 0 {
return uint64(v), true
}
}
}
return 0, false
}
func (s *Service) trackConn(conn net.Conn) {
s.mu.Lock()
defer s.mu.Unlock()
if s.conns == nil {
s.conns = make(map[net.Conn]struct{})
}
s.conns[conn] = struct{}{}
}
func (s *Service) untrackConn(conn net.Conn) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.conns, conn)
}
func (s *Service) nextPacketConnID(prefix string) string {
s.mu.Lock()
defer s.mu.Unlock()
s.packetID++
return fmt.Sprintf("%s-%06d", prefix, s.packetID)
}
func (s *Service) aesKey() string {
return time.Now().Format("20060102") + "000006"
}
func (s *Service) logInfo(msg string, args ...any) {
if s.logger != nil {
s.logger.Info(msg, args...)
}
}
func (s *Service) logWarn(msg string, args ...any) {
if s.logger != nil {
s.logger.Warn(msg, args...)
}
}