服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
948 lines
45 KiB
Go
948 lines
45 KiB
Go
// 本文件负责 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)
|
||
}
|