Files
90CNServer/server/internal/services/dnfbridge/game.go
T
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

948 lines
45 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.
// 本文件负责 DNF bridge 的 game 端口连接、混合帧分发和登录前 wire 回包。
// 这里只处理客户端字节协议兼容,不持有玩家持久化状态或玩法规则。
package dnfbridge
import (
"context"
"errors"
"io"
"net"
"net/netip"
"sync"
"sync/atomic"
"syscall"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/dproto"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
"longheng.io/server/internal/platform/wineenv"
)
type gameSession struct {
// completedRunTownReturnOrigin is the town origin frozen when the character
// entered the run that has just been retired. A completed settlement outlives
// its runtime: 选择其他地下城 retires it before the client's 返回城镇 arrives, and
// without this stash that return has no origin left and falls back to the
// persisted login town -- the Seria room (38/1). The party then lands in a
// private scene, cannot see each other there, and the client refuses the next
// dungeon entry with 队员不在附近. Guarded by townMu, like the selector origin.
completedRunTownReturnOrigin currentTownPositionSnapshot
// dungeonAutoNextGuard blocks the client's follow-up op16 after the native
// quest auto-next path sent op72 {1,3} while the previous settlement was not
// ready. The native client then enters a new dungeon without a selected
// quest and crashes in DNF.exe (EIP 017C4F80). The guard consumes only the
// first follow-up entry and answers it with a normal select-dungeon failure,
// so the player returns to the selector instead of receiving op28/op29.
dungeonAutoNextGuard bool
// completedAutoNextRuntime keeps the just-completed run when the client's
// selector request retires it. The following op72 {1,3} auto-next request
// arrives with no current runtime; this pointer lets the server resolve the
// direct PVF story successor and drive the next dungeon instead of sending
// the client into its null quest-manager crash.
completedAutoNextRuntime *runtimeDungeonState
conn net.Conn
// writer owns this session's blocking socket writes so the event loop never
// parks on a client that stopped reading. See game_session_writer.go for the
// live 48-second stall measurement that made it necessary. nil means "no
// writer": the write stays synchronous, which is what every test that builds
// a bare session expects.
writer *gameSessionWriter
connID string
accountID string
machineID string
accountSourceIP netip.Addr
remoteClientPID uint32
robotSessionBound bool
// auctionLastSearchOpcode/Body remember the last auction search this
// session ran so a successful 竞拍 can replay it. The client's own op185
// success path erases the row it acted on (sub_30071B0) - correct for a
// buyout, wrong for a bid, and not something the server can suppress - so
// the list has to be re-sent or it stays empty until the player searches
// again. Only ever touched under Service.auctionMu.
auctionLastSearchOpcode uint16
auctionLastSearchBody []byte
clientPID uint32
// loginIdentity is the per-login identity used to tell "another client on the
// same account" from "this client opened another connection". Local play uses
// clientPID; off this machine clientPID is always 0, so the in-band
// class-1/op1 credential gate stores its login-token digest here instead. An
// empty value means the identity is unknown, and duplicate-login enforcement
// deliberately does nothing rather than risk kicking the player's own channel
// switch. See currentDuplicateLoginWouldDisplace.
loginIdentity string
// duplicateLoginKicked marks a session a duplicate-login kick already owns, so
// the ~100 connections one client opens at startup cannot kick each other in a
// loop and a displaced session is not displaced twice. Guarded by Service.mu.
duplicateLoginKicked bool
raidAvailabilitySent bool
raidDirectoryReadySent bool
raidDirectory runtimeRaidDirectorySubscription
// raidBrowseTarget names the raid this session's attack-party window is
// currently browsing without having joined it. op1359 is the client's poll
// while that window is open, so a target that has not been refreshed for
// runtimeRaidBrowseTTL belongs to a window that was closed. Guarded by
// Service.mu, exactly like raidDirectory above.
//
// raidBrowseMembers is the fingerprint of the member id set last delivered as
// the identity payload (member actors + 0x0250 + 819 + 0x0256). It only decides
// whether that heavier part is replayed when the roster changes; it never
// suppresses the 0x024F mode=3 roster push itself.
raidBrowseTarget uint32
raidBrowseAt time.Time
raidBrowseMembers uint64
raidProjectionMu sync.Mutex
raidRejoinProjectionSent bool
raidRejoinProjectionRaidKey uint32
raidRejoinProjectionRevision uint64
raidEndedProjectionRaidKey uint32
// 超时空 1452..1460 战况面板的按会话增量基线。
//
// fiendWarBoard 是这个会话上一次收到的战场状态;下一次 tick 只发与它不同的行。
// fiendWarBoardMu 串行化这次比较 —— 面板既由攻坚 tick 发布,也由每个成员自己的
// 包触发,两个发布者同时跑会各自按一份已经被对方换掉的基线算增量。
fiendWarBoardMu sync.Mutex
fiendWarBoard runtimeRaidState
// fiendWarBoardFixtureSignature 是上一次为战况表算出的"每个设施区域画哪只怪"。
// 变了才记一行 game-fiendwar-board-fixtures,用来和进图那条
// game-fiendwar-maze-selected 对照(真机上报过表上画的与进去打的不一致)。
fiendWarBoardFixtureSignature string
// 超时空"走进区域即开战"的去重。城镇移动路径与战场 tick 都会调
// tryRuntimeFiendWarEncounter,没有这组记忆两边会为同一次抵达各开一个实例。
fiendWarEncounterRaidKey uint32
fiendWarEncounterArea byte
fiendWarEncounterAt time.Time
// fiendWarPlannedEncounterUntil 是"这一脚按进图的计划正在同一条信箱里跑"的
// 截止时刻(守卫见 runtimeFiendWarPlannedEncounterPending)。它挡的是战况表
// 1 Hz 心跳与计划进图之间的一场真实竞态:计划的这一趟在开工前就把本人的实时
// 城镇坐标推到了**目标区域**(raid_fiendwar_battle.go 的
// setCurrentTownPositionSceneFromTransitionLocked),而副本 runtime 要几百毫秒
// 后才落地。心跳若正好落在这一段里,plan==nil 那一路会把"我站在目标区域"当成
// 名单锚点,算出**空名单**、只带自己一个人进图,并且把随后到达的整队进图顶掉
//(那时 runtime 已存在,计划那一趟直接返回)。
//
// 真机 2026-10-01(本地 00:03:53)就是它:计划那一脚在 53.743 发包,战况表
// 心跳的 1 Hz 刻度在 53.73 落在 43.73+n 的节奏上、53.790 就把单人副本开出来
// 了,char 22 留在城镇原地;42 秒后 16:04:35 那一脚因为 op36(35.786) 落在心跳
// 刻度(35.73)**之后**,心跳先跑且当时人还在格 4(该格无怪,心跳自然放弃),
// 于是整队进图成功 —— 同一个操作时快时慢,差别只在按键落在刻度前还是后。
fiendWarPlannedEncounterUntil time.Time
// raidParticipationReplayMu guards raidParticipationReplay below. It is a leaf
// lock: the rebuilt-scene replay takes one session's lock at a time and takes
// nothing else while holding it, so it cannot invert against wireMu/dungeon.mu.
raidParticipationReplayMu sync.Mutex
// raidParticipationReplay is the 0x0248 (584) participation row set this client
// was last re-stated with by the rebuilt-scene self-heal
// (replayRuntimeRaidParticipationRows) -- i.e. the rows its 战况表 was last told
// about at a moment when its own scene was known to exist.
//
// The replay has to stay (it is what puts back a 584 row the client dropped
// while its scene was being rebuilt, see raid_current_compat.go), but the
// client's op=1 branch (sub_17C0F20) POPS the chat line 32048
// 「其中1支战队已进入[%s]地下城。」 for every row it inserts, so replaying on
// every finish-loading printed that line once per ROOM (服主实测 2026-09-29:
// "在卢克/安徒恩副本里每过一个房间,聊天框就弹一次"). A room change inside one
// gate does not change the row set, so it no longer replays; a real gate change
// does, and that is exactly the moment the client can lose the row.
raidParticipationReplay currentRaidParticipationReplayLedger
channel channelcatalog.Channel
residentChannel channelcatalog.Channel
packetSeq uint64
sequence uint32
upperSeq uint16
// stall 是**只读诊断**用的卡住计量器(见 dungeon_stall_diagnostics.go):
// 记录"同一个目标被连续拒绝了多少次",以及本会话最后发出去的 upper 包。
// 不参与任何判定、不发包;裸会话(测试构造的)为 nil,诊断自动关闭。
stall *dungeonStallTracker
// s2cCipher 是这条连接的 S2C 回包加解密器(见 protocol/s2ccipher.go)。
// nil = 明文。只在 LANA 握手协商成功(客户端答 version 3)时才有值,
// 因此"旧客户端/未开开关"的连接天然保持明文,不会收到乱码。
s2cCipher *dnfproto.S2CCipher
// lukeCardDisplayMu guards lukeCardDisplayPoppedDays below. It is a leaf
// lock, exactly like raidParticipationReplayMu above: nothing else is taken
// while it is held, and the claim is released before any packet is built.
lukeCardDisplayMu sync.Mutex
// lukeCardDisplayPoppedDays is the per-login-session ledger of the one tarot
// pop-up the official behaviour grants. 服主 2026-09-29 的官方口径:
//
// 「玩家在每次登陆后,第一次进入卢克 RAID 频道时,离开赛利亚旅馆会自动
// 展示一次塔罗牌。」
//
// ⇒ the key is the **tarot day**, not a raid: the card set is global PVF
// configuration that does not depend on any raid (raid_luke_card_display.go).
// The FIRST 1181 of a tarot day in one session goes out with
// openWindowFlag = 1 and records that day here; every later 1181 in the same
// session (landing in the town again, coming back from a cleared gate,
// walking into the waiting room) still refreshes the cards but carries
// openWindowFlag = 0, so the packet can never become a second pop-up.
// 重新登录 = 新的 gameSession = 空账本 ⇒ 再弹一次, which is exactly
// "每次登陆后…展示一次".
lukeCardDisplayPoppedDays map[int]bool
ctx context.Context
// wireMu owns the complete per-session wire boundary: sequence
// allocation, DPROTO state transitions, and socket writes. A gameplay
// timer can send concurrently with the connection reader, so callers
// must not allocate a sequence before acquiring this lock.
wireMu sync.Mutex
dproto dproto.Session
// upperWriteBatch is protected by wireMu. It is used only for explicit
// multi-result gestures whose independently encoded native frames should be
// delivered to the client in one socket write.
upperWriteBatchActive bool
upperWriteBatch []byte
endpointHandshakeMu sync.Mutex
currentChannelResidentNoticeSent bool
gameEndpointSuccessSent bool
auctionServiceOpenMask byte
connectionTownActorOwnerChannel byte
events *gameSessionEvents
characterGeneration uint64
// levelAimingCreatedID 是"升级预约"活动(onlineevent level_aiming,活动 222)里
// 本会话刚创建的那个角色 ID;0 表示本次创建的不是预约角色。建号时写入、
// 选角界面同步时读回,用来判断预约响应是否属于本会话创建的角色。
levelAimingCreatedID uint16
// weddingStateSent 记录本次会话是否已下发过 1000 号婚姻状态行。原生登录流程里
// 它是一次性事件;重复下发会重置伴侣预览(见 online_player_manager.go)。
weddingStateSent atomic.Bool
selectedCharacterID uint16
pvpRoom *currentPVPRoom // protected by Service.pvpMu
assault *currentAssault // protected by Service.pvpMu
selectCharacterInitMu sync.Mutex
selectCharacterInitActive bool
selectCharacterInitCharacterID uint16
privateStoreStateMu sync.Mutex
privateStorePending *currentPrivateStorePurchasePublication
blackDiamondMu sync.RWMutex
blackDiamondExpiresAt time.Time
dailyFatigueReset currentDailyFatigueResetClock
selectedCollectboxIndex byte
selectedCollectboxCharacterID uint16
collectboxInitializationSent bool
collectboxSceneProjectionSent bool
collectboxSelectionRefreshSent bool
selectedEpicBookBootstrapCharacterID uint16
rosterRequested bool
activityNameMu sync.Mutex
activityNameCharacterID uint16
activityName string
channelReconnect bool
// channelReconnectNewCharacter marks a reconnect transport that carried a
// different character than the registered client last selected. It still
// owns a fresh login: the select ACK and the town route must both fall back
// to the ordinary initial town instead of the persisted reconnect location.
channelReconnectNewCharacter bool
townActorOwnerChannel byte
representAccountNamePending bool
representAccountNameRegistrationSent bool
pendingCharacterRosterBootstrap bool
emptyRosterSlotProbePending bool
selectorAdventureInfoPending bool
selectorAdventureInfoSent bool
selectorAdventureInfoSlot uint16
enterSelectDungeonSent bool
enterSelectDungeonAckSent bool
enterSelectDungeonPassiveParty bool
enterSelectDungeonContextSent bool
backToVillageEnterSelectPending bool
backToVillageSelectorDuplicateGuard bool
// dungeonEntryItemRefusedAt records when the server last answered an entry
// attempt (or a 再次挑战) with the op16 "缺少入场材料" failure. The current
// client answers that failure with its own bodyless op132 BACK_2_VILLAGE
// reflex (measured 6~70ms later, live 2026-09-24 12:44:46 and 13:08:23), which
// would throw the player back to town even though they only wanted to see the
// prompt. handleDungeonBackToVillage swallows exactly one such reflex inside
// currentDungeonShortageEscapeWindow and re-projects the dungeon-select page
// instead; a later op132 (the player really clicking 返回城镇) returns to town
// as usual. Guarded by dungeon.mu.
dungeonEntryItemRefusedAt time.Time
sceneBootstrapTailMu sync.Mutex
sceneBootstrapTailDeferred bool
sceneBootstrapTailSent bool
// blacklistListAnswerPending records that the client asked for its blacklist
// (class-1 op120) while the select-character scene tail was still pending. The
// answer is held back until the tail commits, because a row the client builds in
// that window loses its 黑名单日期 (and its level): live 2026-09-22 23:46, the client
// asked six times, the last three landed inside a route transition that had
// re-deferred the tail, and the panel dropped the date the earlier answers had
// already rendered. See sendRequestBlacklistState.
blacklistListAnswerPending bool
// premiumPanelFlagConsumed mirrors the client's one-shot "show the contract
// panel" flag, which its own class1/op4 SELECT_CHARACTER handler arms and the
// first class1/op903 body with serviceType 1 clears. Only the login/entry
// scene tail sends serviceType 1; every later send stays silent so a session
// whose tail never ran cannot pop that panel inside a dungeon (live
// 2026-09-16T14:23:54Z, conn game-000209). atomic: the tail holds
// sceneBootstrapTailMu while sending and every other sender reads this.
premiumPanelFlagConsumed atomic.Bool
sceneBootstrapActorGeneration uint64
sceneBootstrapTailPacketIndex int
sceneBootstrapTailObjectMode1Pending bool
sceneBootstrapTailPostStage currentDeferredSelectSceneTailPostStage
sceneBootstrapFriendListPacketIndex int
runtimeAfterBlacklistSent bool
runtimeFinishLoadingGateSent bool
fpsFinishLoadingGateSent bool
selectedUserInfoRefreshSent bool
selectedUserInfoMode3Sent bool
currentSceneObjectListSent bool
selectedItemListRefreshSent bool
selectedItemListBootstrapCharacterID uint16
selectedEquipmentUpdateSent bool
selectedCreatureStateTableSent bool
selectedRentalWalletStateSent bool
expertJobInfoCharacterID uint16
// expertJobProjectionBody is the last class-0/op205 body this client
// accepted. Scene reconstruction compares against it so an unchanged
// auxiliary profession is never replayed as a fresh acquisition; see
// sendCurrentExpertJobInfoForSceneRebuild.
expertJobProjectionBody []byte
selectPreviewObjectStateSent bool
selectPreviewActorRemoved bool
preDungeonContextPlayerStateSent bool
postStartMapPlayerStateSent bool
dungeonSceneSkillInfoSent bool
trainingRoomBuffRegistrationPending bool
deferredDungeonUserStateObjectKey uint16
currentFinishLoadingStateSent bool
currentDungeonReviveReapplySent bool
currentFinishLoadingCompletionSent bool
postFinishLoadingPlayerStateSent bool
experienceConsumableProjectionSent bool
// itemCooltimeProjectionSent 保证每个选中角色只重放一次持久冷却列表
// (类 0 通知 172,见 item_cooltime_projection.go)。
itemCooltimeProjectionSent bool
// statChangePotionBuffProjectionSent 保证"城镇增益药剂"(史诗Buff药剂一族)的 buff
// 每个选中角色只补发一次(类 0 通知 487 CHARACTER_ADD_BUFF,见
// stat_change_potion_projection.go;对应参考服务端 EpicBuffPotionInitBodyBuilder 的
// 登录补发语义)。
statChangePotionBuffProjectionSent bool
// 城镇增益药剂的"到期摘图标"定时器状态(详见 stat_change_potion_expiry.go)。
// 会话级:药剂在城镇也生效,所以它不属于副本 runtime。
statChangePotionTimerMu sync.Mutex
statChangePotionTimerActive bool
statChangePotionTimerName string
statChangePotionTimerGeneration uint64
statChangePotionTimerCharacterID uint16
statChangePotionTimerDueAt time.Time
returnTownFinishLoadingAckOnly bool
confirmedDungeonReturnStatePending bool
confirmedDungeonReturnActionTableSent bool
confirmedDungeonReturnFinalGateResetSent bool
confirmedDungeonReturnRouteArmed bool
confirmedDungeonReturnTransition currentDungeonTownTransition
initialTownRouteCharacterID uint16
initialTownRouteStage currentInitialTownRouteStage
initialTownGuardianGemRefreshSent bool
initialTownGuardianShieldDeckSent bool
initialTownGuardianShieldAppearanceSent bool
chatEmoticonCatalogSent bool
initialTownActorSceneSnapshotSent bool
initialTownLocationNotificationsSent bool
initialTownQuestSnapshotsSent bool
initialTownQuestHUDRefreshSent bool
initialTownGuildStateProjectionSent bool
initialTownSkillInfoPrepared bool
initialTownSkillInfoSent bool
initialTownSkillInfo currentSceneSkillInfoProjection
initialTownMode3RefreshPending bool
completedTownReturnBuffRegistrationPending bool
skillSwitchInventoryAllDataCharacterID uint16
skillSwitchInventoryAllDataGeneration uint64
initialTownLegacySceneReadyAccepted bool
initialTownGameOptionRestoreCharacterID uint16
initialTownActorUserStateRestoreCharacterID uint16
currentGameOptionMu sync.Mutex
currentGameOptionMainRestoreCharacterID uint16
currentGameOptionNativeApplyBody []byte
currentGameOptionNativeApplyKeymapBody []byte
currentGameOptionNativeApplyMainAt time.Time
currentGameOptionNativeApplyKeymapAt time.Time
initialTownCharacterOptionRestoreCharacterID uint16
initialTownCharacterOptionRestoreBody []byte
initialTownCharacterOptionRestoreAt time.Time
initialTownCharacterOptionCorrectionSent bool
// characterOptionActorApplyCharacterID marks the character whose option
// table has already been replayed once the actor exists. The scene-init
// send lands before that and the client's own applier is a no-op then, so
// the replay is what makes the saved skill-cooldown list take effect.
characterOptionActorApplyCharacterID uint16
// dailyQuestReset* remember the character and service day whose daily
// quests were already reconciled on this session, so the login-read
// catch-up runs once per day instead of on every selected-character read.
dailyQuestResetCharacterID uint16
dailyQuestResetServiceDayID int64
initialTownAdventureOverheadRefreshSent bool
initialTownActorDisplaySent bool
initialTownActorPlacementSent bool
initialTownDungeonPermissionSent bool
initialTownCombatPowerAffixesSent bool
initialTownMoodProjectionSent bool
townPostTransition townPostTransitionState
crystalContractMu sync.Mutex
crystalContractTownUIReadyStateSent bool
auraSkinMu sync.Mutex
auraSkinTownUIReadyStateSent bool
townSceneReadyCharacterID uint16
townPositionSnapshot currentTownPositionSnapshot
townMeetingNearbyNPCs map[int64]struct{}
townPrevVillageSnapshot currentTownPositionSnapshot
townSelectorOriginSnapshot currentTownPositionSnapshot
townSelectorOriginBound bool
// townSelectorRetiredPresence remembers the town roster record that opening
// the dungeon-selection page retired, so closing the page can register the
// exact same actor again. It has its own leaf mutex rather than townMu:
// every route that gives the character a real registration back drops the
// memo, and several of them (SET_USER_AREA, prev-village, the initial town
// route) already hold townMu across their whole body.
townSelectorPresenceMu sync.Mutex
townSelectorRetiredPresence onlinePlayerInfo
townSelectorRetiredPresenceCharacterID uint16
townTransportEnterSelectPending bool
returnSelectTownReentryPending bool
returnSelectPreviousCharacterID uint16
returnSelectRetainedActorRemoved bool
// accountSharedInventoryRebindPending is armed by a return-to-selector
// (op7). The next selected character's HUD-ready boundary replays only the
// account-owned crystal/soul rows (354..365) through op14 so the surviving
// right-hand widgets rebind after the container reset.
accountSharedInventoryRebindPending bool
// A completed/interrupted dungeon return deliberately suppresses mode0's
// one-shot actor-state event because the dungeon actor generation is still
// installed. The current client can keep that event's native source vector
// empty after op7 tears the actor down; remember the condition across the
// selector reset so the first replacement actor does not dereference it.
returnSelectActorStateEventSuppressionPending bool
questReplay questReplayState
// questProgress serializes durable dungeon quest writes while keeping
// repository I/O off the packet/session event owner. Shutdown drains the
// wait group so an acknowledged kill or quest-item grant is not lost.
questProgressMu sync.Mutex
questProgressCond *sync.Cond
questProgressQueued uint64
questProgressPersisted uint64
questProgressWG sync.WaitGroup
guild guildSessionState
party partySessionState
// guildBuffProjection remembers the two 公会 BUFF rows (Guild_Status /
// Guild_Status1) this session's HUD currently shows, so a re-projection can
// send the exact op488 removals before the op487 adds. Guarded by its own
// mutex; see guild_buff_projection.go.
guildBuffProjection currentGuildBuffProjection
// partyRoutingKey is the server-assigned identity published in the login
// success packet and repeated by remote peers' class0/op11 rows. It is
// connection-scoped: sharing one global token makes NoPack bind both party
// actors to the same reliable-UDP route and leaves one HUD row at 0/4.
partyRoutingKey uint32
partyPathMTU uint32
partyPeerMu sync.Mutex
partyPeer currentPartyPeerEndpointRegistration
// partyActorProjection is the last complete, owner-produced mode0/mode1
// pair that this selected character successfully wrote to its own client.
// Party dungeon admission freezes these exact rows; rebuilding another
// member's row from the receiver's scene context changes native actor
// identity and breaks durable peer state such as riding/transformation and
// hit ownership.
partyActorProjectionMu sync.RWMutex
partyActorProjection currentPartyActorProjection
lottery lotteryState
vendingMachine currentVendingMachineSessionState
dungeon dungeonSessionState
// potionCast pairs a committed dungeon consumable delete (op18) with the
// cast (op38) the client sends right after it. It has its own leaf mutex:
// the delete is recorded from the aligned command path, which already holds
// other session locks.
potionCast currentPotionCastState
townMu sync.Mutex
petGrowth petGrowthClockState
spendTime currentSpendTimeClockState
// 活动(onlineevent)的会话级投影缓存。三者都按"内容变化才下发"的语义使用:
// monthlyAttendance / weeklyAttendance 钉住上一次下发的 op1206 窄记录,
// returneeProjection 钉住上一次下发的 176 字节回归状态体。
monthlyAttendance currentMonthlyAttendanceProjection
weeklyAttendance currentWeeklyAttendanceProjection
returneeProjection currentReturneeProjection
returneeTimeCharacter uint16
returneeTimeGeneration uint64
returneeTimeExpiresAt int64
returneePreparedExpiresAt int64
returneePreparedCharacter uint16
returneePreparedGeneration uint64
returneeActive bool
luckyPreparedCharacter uint16
luckyPreparedGeneration uint64
luckyPreparedActorGeneration uint64
returneeConnectionObserved bool
commandFallback currentCommandFallbackSessionState
darkKnightCombo darkKnightComboSessionState
customAbilityConverter currentCustomAbilityGroupConverterSessionState
}
type guildSessionState struct {
mu sync.Mutex
pendingName string
nameChecked bool
promotion string
createCommitted bool
createInvitation *currentGuildCreateInvitation // guarded by Service.guildCreateMu
memberListRequested bool
recommendationReplayPending bool
recommendationReplayGuildIDs []uint32
}
// partySessionState 持有组队投影与待处理邀请的会话状态。
// mu 守护 state 与 pending 邀请字段;state 的地址会传给 alignedcmd.Route,
// 迁移后仍为同一字段地址,锁序(party 锁内不嵌套 wireMu)保持不变。
// partySessionState is a per-client packet projection only. Membership,
// invitations, slots, leader replacement, and session generations are owned
// by party.RuntimePartyManager; this cache exists solely to shape the current
// EXE's existing party packets.
type partySessionState struct {
mu sync.Mutex
state alignedcmd.PartyState
// frameIdentities remembers, per projected actor, the identity of the last
// populated op9 kind-0 party record this receiver accepted. The client keys
// its party object by the (ownerChannel, objectKey) pair - current EXE
// sub_26A1200 builds an eight-byte lookup key from both arguments - and the
// populated record is addressed to the shared party model id, never to the
// actor's scene key. The record that empties that object therefore has to
// repeat the same pair, and the party id is already gone from every state
// this session holds by the time the clearing record is built.
frameIdentities map[uint16]runtimePartyFrameIdentity
}
// runtimePartyFrameIdentity is the (objectKey, ownerChannel) pair one populated
// op9 kind-0 party record was addressed to.
type runtimePartyFrameIdentity struct {
modelKey uint16
ownerChannel byte
}
// questReplayState 持有任务接受/触发/完成三条回放的幂等去重状态。
// 每把锁各自守护对应 map 的懒初始化与读写,三把锁互不嵌套,保持原有临界区。
type questReplayState struct {
acceptMu sync.Mutex
acceptAcknowledged map[currentAcceptQuestReplayKey]struct{}
giveUpMu sync.Mutex
giveUpAnswered map[currentGiveUpQuestReplayKey]struct{}
triggerMu sync.Mutex
triggerNoop map[currentSetQuestTriggerReplayKey]struct{}
finishMu sync.Mutex
finishAnswered map[currentFinishQuestReplayKey]struct{}
}
// lotteryState 持有魔盒抽奖待确认流程的会话状态。
// mu 守护待确认标记、栏位、双倍标记与发起时间,消费时清零的时机保持不变。
type lotteryState struct {
mu sync.Mutex
pending bool
pendingSlot int16
pendingDouble bool
pendingAt time.Time
}
// petGrowthClockState 持有宠物饱食度时钟的会话状态。
// mu 守护时钟模式、锚点、代数与定时器名的全部读写(含 timequeue 回调),
// 停止/切换时的结算与归零时机保持不变。
type petGrowthClockState struct {
mu sync.Mutex
mode currentPetGrowthClockMode
characterID uint16
anchor time.Time
generation uint64
timerName string
}
// townPostTransitionState 持有城镇入场后过渡流程的会话状态。
// mu 守护过渡代数、角色、属主频道、阶段与来源标记,arm/推进/归零时机保持不变。
type townPostTransitionState struct {
mu sync.Mutex
generation uint64
characterID uint16
ownerChannel byte
stage currentTownPostTransitionStage
source string
mode0Body []byte
mode1Body []byte
}
// dungeonSessionState 持有单个地下城运行周期的会话状态。
// mu 守护 runtime、runToken 与 deathTower 的全部读写(含定时器回调与
// handler 路径),原有 dungeonMu 的临界区与锁序惯例保持不变。
type dungeonSessionState struct {
mu sync.Mutex
runtime *runtimeDungeonState
runToken uint64
deathTower *deathTowerRuntime
debugLightningPending bool
}
func (s *Service) handleGameConn(ctx context.Context, conn net.Conn) {
defer conn.Close()
connID := s.nextPacketConnID("game")
channel, ok := s.channelForConn(conn)
if !ok {
s.logPacketEvent("game-connect-rejected",
"conn_id", connID,
"remote", conn.RemoteAddr().String(),
"local", conn.LocalAddr().String(),
"reason", "local_port_not_in_channel_catalog")
s.logWarn("dnfbridge rejected game connection for unknown channel port",
"remote", conn.RemoteAddr().String(),
"local", conn.LocalAddr().String())
return
}
session := &gameSession{
conn: conn,
connID: connID,
channel: channel,
residentChannel: channel,
// The numeric channel belongs to the transport/session. Until the
// current EXE commits a different scene context through its native
// protocol, the selected local actor is owned by context zero.
townActorOwnerChannel: currentSceneObjectContext,
ctx: ctx,
stall: newDungeonStallTracker(),
}
s.assignPartyTransportIdentity(session)
defer s.disconnectCurrentPremiumWeb(session)
// Free the online seat when this connection closes no matter how the
// handler returns (rejected auth, bootstrap failure, read error, ...).
defer s.releaseOnlineSeat(session)
accountBinding := "instance-fallback"
lidEnforcing := s.launcherIdentity.enforcing()
if pid, found := localTCPConnectionOwnerPID(conn); found {
session.clientPID = pid
// Under an enforced launcher identity the PID registry is read through
// its attested view: a registration that carries no signed lease is a
// local process that merely reached the loopback admin token, and it must
// not seat a session.
if lidEnforcing {
accountID, registered, direct, attested := s.waitAttestedClientAccountForPID(
ctx,
pid,
clientAccountRegistrationGrace,
)
if registered && attested {
session.accountID = accountID
if direct {
accountBinding = "lid-pid"
} else {
accountBinding = "lid-pid-ancestor"
}
} else if registered {
s.logPacketEvent("game-lid-rejected",
"conn_id", session.connID,
"remote", conn.RemoteAddr().String(),
"client_pid", pid,
"reason", "pid-registered-without-launcher-identity")
}
} else if accountID, registered, direct := s.waitRegisteredClientAccountForPID(
ctx,
pid,
clientAccountRegistrationGrace,
); registered {
session.accountID = accountID
if direct {
accountBinding = "pid"
} else {
accountBinding = "pid-ancestor"
}
} else if !s.launcherIdentity.enabled() {
if image, imageOK := localProcessImagePath(pid); imageOK && isCurrentGameClientImage(image) {
// The launcher registers PID->account once per client launch and the
// registry lives in memory. After a server restart the running
// client's registration is gone, so its next connection (a channel
// switch, a return from the selector) arrived unbound and the
// bootstrap treated it like a launcher discovery probe: no
// CHANNELINFO, town-actor owner 0, and the camera stopped following
// the character. The socket owner is provably the game client, and
// the profile is local single-account, so bind it to the instance
// account the way accountIDForSession already resolves it later.
//
// This fallback is exactly the hole the launcher identity closes:
// the image basename is not a credential, so any process named
// dnf.exe would inherit the instance account. It therefore stays
// reachable only while no launcher identity is configured.
session.accountID = s.accountID()
accountBinding = "pid-client-image-instance-account"
}
}
} else if remoteIP := currentPartySessionRemoteAddress(session); remoteIP.IsValid() &&
remoteIP.IsPrivate() && !remoteIP.IsLoopback() &&
(!lidEnforcing || s.options.launcherIdentityAllowWireGuard) {
if accountID, remotePID, registered := s.waitRegisteredRemoteClientAccount(
ctx,
remoteIP,
clientAccountRegistrationGrace,
); registered {
session.accountID = accountID
session.accountSourceIP = remoteIP
session.remoteClientPID = remotePID
accountBinding = "wireguard-source-ip"
}
}
if session.accountID == "" && session.clientPID == 0 && wineenv.Detected() && !lidEnforcing {
// Wine may return the TCP row without an owning PID, or omit the peer
// row entirely. The project profile is local-single-account: accept the
// launcher's authenticated account only when all live registrations
// agree, and otherwise retain the configured instance fallback. Never
// guess a PID because party/session process identity depends on it.
//
// An enforced launcher identity disqualifies this path outright: Wine
// exposes no owning PID, so the registration cannot be tied back to the
// connection that is asking for it.
if accountID, registered := s.waitUniqueRegisteredClientAccount(
ctx,
clientAccountRegistrationGrace,
); registered {
session.accountID = accountID
accountBinding = "wine-single-account"
}
}
// External launcher auth gate. Local PID / WireGuard / Wine bindings
// above already claimed this session, so a connection that still has no
// account here is an external peer: challenge it for a launcher ticket,
// verify through the loopback launcher API and bind the returned
// account_id. Any failure terminates the connection before the native
// DNF protocol flow starts.
//
// Both switches open this block. launcherAuthEnabled is the ticket path; the
// launcher identity is the signed-lease path, and it must be reachable on its
// own because a deployment can enforce the binding without running the ticket
// verifier. Pure audit mode is the one case that keeps serving the legacy
// path after a failed challenge, so an operator can watch real traffic before
// switching to enforce.
if session.accountID == "" &&
(s.options.launcherAuthEnabled || s.launcherIdentityWantsInBandChallenge(conn)) {
err := s.runLauncherAuthGate(ctx, session)
switch {
case err == nil:
accountBinding = "remote-auth"
case !s.options.launcherAuthEnabled && !s.launcherIdentity.enforcing():
// Audit only: the verdict was recorded by the gate and the legacy
// resolution below still applies, so nothing that worked before this
// change stops working.
accountBinding = "lid-audit-fallthrough"
s.logPacketEvent("game-lid-audit-fallthrough",
"conn_id", session.connID,
"remote", conn.RemoteAddr().String(),
"error", err)
default:
// The gate recorded the auth-start/ok/reject/timeout/frame/verify
// events; reuse the plain return so the deferred conn.Close() and
// session cleanup behave exactly like the rejected paths above.
return
}
}
// An enforced launcher identity makes an unbound session fatal. Without this
// the connection would fall through to the instance-account fallback in
// accountIDForSession, which is precisely the bypass the binding exists to
// remove.
if lidEnforcing && session.accountID == "" {
s.logPacketEvent("game-lid-rejected",
"conn_id", session.connID,
"remote", conn.RemoteAddr().String(),
"local", conn.LocalAddr().String(),
"channel_id", channel.ID,
"client_pid", session.clientPID,
"reason", "no-launcher-identity-lease")
s.logWarn("dnfbridge rejected game connection without a launcher identity lease",
"remote", conn.RemoteAddr().String(),
"client_pid", session.clientPID)
return
}
// Occupy the online seat as soon as an account is bound, before any
// character select/bootstrap. The capacity gate counts connecting and
// role-select connections too, so a full server rejects the next login
// instead of overselling the last seat into an in-flight session.
s.occupyOnlineSeat(session)
s.logInfo("dnfbridge game connection accepted",
"conn_id", session.connID,
"remote", conn.RemoteAddr().String(),
"local", conn.LocalAddr().String(),
"channel_id", channel.ID,
"channel_type", channel.Type,
"game_port", channel.Port,
"client_pid", session.clientPID,
"remote_client_pid", session.remoteClientPID,
"session_account_bound", session.accountID != "",
"account_binding", accountBinding)
s.logPacketEvent("game-connect",
"conn_id", session.connID,
"remote", conn.RemoteAddr().String(),
"local", conn.LocalAddr().String(),
"channel_id", channel.ID,
"channel_type", channel.Type,
"game_port", channel.Port)
if err := s.startGameSessionEvents(ctx, session); err != nil {
s.logPacketEvent("game-session-events-start-failed", "conn_id", session.connID, "error", err)
s.logWarn("dnfbridge start game session events failed", "remote", conn.RemoteAddr().String(), "error", err)
return
}
dprotoOpened := false
defer func() {
s.shutdownGameSessionEvents(session, dprotoOpened)
}()
// Inbound packets are submitted asynchronously below. Put a FIFO barrier
// ahead of session shutdown so a peer close cannot discard commands which
// were already fully read from the socket and accepted by the bounded
// session queue.
defer func() {
drainCtx, drainCancel := context.WithTimeout(context.Background(), gameSessionShutdownWait)
defer drainCancel()
if err := s.callGameSession(drainCtx, session, "game-inbound-drain", func() error { return nil }); err != nil &&
!isClosedGameSessionEventError(err) {
s.logGameEvent(session, "game-inbound-drain-failed", "error", err)
}
}()
if err := s.callGameSession(ctx, session, "game-dproto-open", func() error {
return s.openGameDprotoSession(ctx, session)
}); err != nil {
s.logPacketEvent("game-dproto-open-failed", "conn_id", session.connID, "error", err)
s.logWarn("dnfbridge open native dproto session failed", "remote", conn.RemoteAddr().String(), "error", err)
return
}
dprotoOpened = true
defer s.logPacketEvent("game-close",
"conn_id", session.connID,
"remote", conn.RemoteAddr().String(),
"local", conn.LocalAddr().String(),
"channel_id", channel.ID,
"game_port", channel.Port)
if err := s.callGameSession(ctx, session, "game-connection-bootstrap", func() error {
return s.sendGameConnectionBootstrap(session, time.Now())
}); err != nil {
s.logPacketEvent("game-connection-bootstrap-failed",
"conn_id", session.connID,
"remote", conn.RemoteAddr().String(),
"local", conn.LocalAddr().String(),
"channel_id", channel.ID,
"error", err)
s.logWarn("dnfbridge send game connection bootstrap failed", "remote", conn.RemoteAddr().String(), "error", err)
return
}
buffer := make([]byte, 0, 4096)
chunk := make([]byte, 4096)
for {
n, err := conn.Read(chunk)
if err != nil {
quiet := isQuietGameReadError(err)
s.logPacketEvent("game-read-close",
"conn_id", session.connID,
"remote", conn.RemoteAddr().String(),
"local", conn.LocalAddr().String(),
"channel_id", channel.ID,
"pending_bytes", len(buffer),
"quiet", quiet,
"error", err)
if !quiet && ctx.Err() == nil {
s.logWarn("dnfbridge read game packet failed", "remote", conn.RemoteAddr().String(), "error", err)
}
return
}
if n > 0 {
s.logGamePacket(session, "RECV", "game-raw", chunk[:n])
}
buffer = append(buffer, chunk[:n]...)
packets, remaining, skipped, err := dnfproto.SplitLatestGameStream(buffer, s.options.maxPacketBytes)
if err != nil {
s.logPacketEvent("game-split-error",
"conn_id", session.connID,
"remote", conn.RemoteAddr().String(),
"local", conn.LocalAddr().String(),
"channel_id", channel.ID,
"buffer_bytes", len(buffer),
"error", err)
s.logWarn("dnfbridge split game stream failed", "remote", conn.RemoteAddr().String(), "error", err)
return
}
if skipped > 0 {
s.logWarn("dnfbridge game frame resynced", "remote", conn.RemoteAddr().String(), "skipped", skipped)
}
buffer = remaining
for _, packet := range packets {
packet := packet
if err := s.postGameSessionEvent(ctx, session, "game-stream-packet", func() error {
return s.handleGameStreamPacket(session, packet)
}); err != nil {
// A handler error is a bug in one command, not a transport failure.
// Closing the socket over it turns any single packet mistake into
// "网络连接中断" for the player, and that happened three times on
// 2026-09-16 alone -- the raid scene rejoin, the party dungeon
// settlement whose peer had already dropped, and a raid roster
// projection. The session stays up, the offending command is logged
// and dropped, and the player keeps playing.
s.logPacketEvent("game-handler-error",
"conn_id", session.connID,
"remote", conn.RemoteAddr().String(),
"channel_id", channel.ID,
"stream_kind", uint8(packet.Kind),
"error", err)
s.logWarn("dnfbridge game handler failed",
"remote", conn.RemoteAddr().String(),
"kind", uint8(packet.Kind),
"error", err)
// The one error that still ends the read loop is the session's own
// event loop going away: that is the connection being torn down, not
// a command failing.
if session.events != nil && !session.events.active.Load() {
return
}
}
}
}
}
func (s *Service) channelForConn(conn net.Conn) (channelcatalog.Channel, bool) {
port := 0
if endpoint, ok := conn.LocalAddr().(*net.TCPAddr); ok {
port = endpoint.Port
}
return s.channelForPort(port)
}
func (s *Service) channelForPort(port int) (channelcatalog.Channel, bool) {
catalog := s.currentCatalog()
if catalog == nil {
return channelcatalog.Channel{}, false
}
return catalog.ForPort(port)
}
func (s *Service) gameListenPorts(catalog *channelcatalog.Catalog) []int {
if catalog == nil {
return nil
}
return catalog.GamePorts()
}
func (s *Service) handleGameStreamPacket(session *gameSession, packet dnfproto.LatestGameStreamPacket) error {
// 场景就绪触发:开区任务完成时记下待播影片,等客户端处理完场景重建、
// 发来下一个包时再写触发文件(此时影片窗口不会被重建拆掉);玩家一直
// 不操作时由 armCutsceneAfterSceneReady 的 2 秒兜底定时器触发。
s.fireReadyCutscene(session, "client_scene_ready")
switch packet.Kind {
case dnfproto.LatestGameStreamTransport:
return s.handleGameFrame(session, packet.Data)
case dnfproto.LatestGameStreamUpper:
return s.handleGameUpper(session, packet.Data)
case dnfproto.LatestGameStreamLegacy:
return s.handleLegacyGamePacket(session, packet.Data)
case dnfproto.LatestGameStreamDproto:
return s.handleGameDprotoPacket(session, packet.Data)
default:
return dnfproto.ErrPacketLength
}
}
func isQuietGameReadError(err error) bool {
return errors.Is(err, io.EOF) ||
errors.Is(err, net.ErrClosed) ||
errors.Is(err, syscall.ECONNRESET)
}