Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

2218 lines
94 KiB
Go
Raw Permalink 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.
package dnfbridge
import (
"context"
"encoding/binary"
"fmt"
"sort"
"strings"
"sync"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfguild "longheng.io/server/internal/modules/dnf/guild"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// onlinePlayerInfo is one player's town presence state.
type onlinePlayerInfo struct {
CharacterID uint16
AccountID string
Name string
Job byte
GrowType byte
Level byte
// ChannelID separates the copies of one town. Without it two characters
// standing on the same map in different channels key into the same area
// and become peers: they see each other, receive each other's movement,
// and can interact across a channel boundary.
ChannelID uint16
TownID byte
AreaID byte
// InstanceKey partitions a visually identical town area into private
// scene instances. Ordinary towns and the public raid lobby use zero;
// the Anton waiting area uses the server-owned RaidKey.
InstanceKey uint32
PositionX uint16
PositionY uint16
Direction byte
AreaState byte
Session *gameSession
}
// areaKey identifies a unique town area inside one channel. A channel is a
// separate copy of the same town, so the channel id belongs in the key.
type areaKey struct {
ChannelID uint16
TownID byte
AreaID byte
InstanceKey uint32
}
// townPresenceChannelID is the channel component of every areaKey. It is the
// catalog id, not the u8 town-actor-owner byte: the byte only has to fit the
// client's scene table, while the key has to keep two channels apart even when
// both of their ids would truncate to the same byte.
func townPresenceChannelID(session *gameSession) uint16 {
if session == nil || session.channel.ID <= 0 {
return 0
}
return uint16(session.channel.ID)
}
// OnlinePlayerManager tracks online players by channel, town, area, and private
// scene instance and provides broadcast capabilities for town multiplayer
// visibility.
type OnlinePlayerManager struct {
mu sync.RWMutex
byArea map[areaKey]map[uint16]*onlinePlayerInfo // areaKey -> characterID -> info
byChar map[uint16]areaKey // characterID -> current area
}
func newOnlinePlayerManager() *OnlinePlayerManager {
return &OnlinePlayerManager{
byArea: make(map[areaKey]map[uint16]*onlinePlayerInfo),
byChar: make(map[uint16]areaKey),
}
}
// EnterArea registers a player in an area and returns the list of other
// players already in that area (for sending the full area list to the newcomer).
func (m *OnlinePlayerManager) EnterArea(info *onlinePlayerInfo) []onlinePlayerInfo {
if m == nil || info == nil || info.CharacterID == 0 {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
key := areaKey{
ChannelID: info.ChannelID,
TownID: info.TownID,
AreaID: info.AreaID,
InstanceKey: info.InstanceKey,
}
// Remove from previous area if any.
m.removeFromAreaLocked(info.CharacterID)
// Add to new area.
if m.byArea[key] == nil {
m.byArea[key] = make(map[uint16]*onlinePlayerInfo)
}
m.byArea[key][info.CharacterID] = info
m.byChar[info.CharacterID] = key
// Collect other players in the area.
others := make([]onlinePlayerInfo, 0, len(m.byArea[key])-1)
for id, other := range m.byArea[key] {
if id != info.CharacterID {
others = append(others, *other)
}
}
return others
}
// LeaveArea removes a player from their current area and returns the list of
// remaining players (for broadcasting the leave notification).
func (m *OnlinePlayerManager) LeaveArea(characterID uint16) (areaKey, []onlinePlayerInfo) {
if m == nil || characterID == 0 {
return areaKey{}, nil
}
m.mu.Lock()
defer m.mu.Unlock()
key, ok := m.byChar[characterID]
if !ok {
return areaKey{}, nil
}
m.removeFromAreaLocked(characterID)
// Collect remaining players.
others := make([]onlinePlayerInfo, 0, len(m.byArea[key]))
for _, other := range m.byArea[key] {
others = append(others, *other)
}
return key, others
}
// LeaveAreaSession removes presence only when the disconnecting transport is
// still the transport that registered the town actor. The current client
// opens short-lived same-character auxiliary game connections (for example
// the cross-channel/raid directory probe); those connections must not evict
// the resident town connection when they close.
func (m *OnlinePlayerManager) LeaveAreaSession(characterID uint16, session *gameSession) (areaKey, []onlinePlayerInfo, bool) {
if m == nil || characterID == 0 || session == nil {
return areaKey{}, nil, false
}
m.mu.Lock()
defer m.mu.Unlock()
key, ok := m.byChar[characterID]
if !ok {
return areaKey{}, nil, false
}
area := m.byArea[key]
current := area[characterID]
if current == nil || current.Session != session {
return areaKey{}, nil, false
}
m.removeFromAreaLocked(characterID)
others := make([]onlinePlayerInfo, 0, len(m.byArea[key]))
for _, other := range m.byArea[key] {
others = append(others, *other)
}
return key, others, true
}
func (m *OnlinePlayerManager) SessionForCharacter(characterID uint16) *gameSession {
if m == nil || characterID == 0 {
return nil
}
m.mu.RLock()
defer m.mu.RUnlock()
key, ok := m.byChar[characterID]
if !ok {
return nil
}
info := m.byArea[key][characterID]
if info == nil {
return nil
}
return info.Session
}
func (m *OnlinePlayerManager) PlayerForCharacter(characterID uint16) (onlinePlayerInfo, bool) {
if m == nil || characterID == 0 {
return onlinePlayerInfo{}, false
}
m.mu.RLock()
defer m.mu.RUnlock()
key, ok := m.byChar[characterID]
if !ok {
return onlinePlayerInfo{}, false
}
info := m.byArea[key][characterID]
if info == nil {
return onlinePlayerInfo{}, false
}
return *info, true
}
// UpdatePosition updates a player's position and returns the list of other
// players in the same area (for broadcasting the move notification).
func (m *OnlinePlayerManager) UpdatePosition(characterID uint16, posX, posY uint16) []onlinePlayerInfo {
if m == nil || characterID == 0 {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
key, ok := m.byChar[characterID]
if !ok {
return nil
}
info := m.byArea[key][characterID]
if info == nil {
return nil
}
info.PositionX = posX
info.PositionY = posY
others := make([]onlinePlayerInfo, 0, len(m.byArea[key])-1)
for id, other := range m.byArea[key] {
if id != characterID {
others = append(others, *other)
}
}
return others
}
// GetAreaPlayers returns players in the public (instance zero) form of an area
// of one channel. The channel id is required: the same town area exists once
// per channel, and a caller that omitted it would reach every copy of the town.
func (m *OnlinePlayerManager) GetAreaPlayers(channelID uint16, townID, areaID byte) []onlinePlayerInfo {
return m.GetAreaInstancePlayers(channelID, townID, areaID, 0)
}
// GetAreaInstancePlayers returns only the actors in one concrete scene
// instance. This is required by Anton's private waiting map: different raid
// groups use the same PVF town/area ids but must never see each other.
func (m *OnlinePlayerManager) GetAreaInstancePlayers(
channelID uint16,
townID, areaID byte,
instanceKey uint32,
) []onlinePlayerInfo {
if m == nil {
return nil
}
m.mu.RLock()
defer m.mu.RUnlock()
key := areaKey{
ChannelID: channelID,
TownID: townID,
AreaID: areaID,
InstanceKey: instanceKey,
}
players := make([]onlinePlayerInfo, 0, len(m.byArea[key]))
for _, info := range m.byArea[key] {
players = append(players, *info)
}
return players
}
// GetChannelTownInstancePlayers returns every player registered in the public
// (instance zero) town of one channel, regardless of which area of that town
// they stand in. The channel-wide friend/guild context-menu experiment needs
// these peers: the current client only resolves a member row's peer object from
// its own name-keyed user table, and that table is fed by actor creation, so a
// same-channel member standing in another area has to be sent as an actor even
// though the two players never share a scene.
func (m *OnlinePlayerManager) GetChannelTownInstancePlayers(channelID uint16, excludeID uint16) []onlinePlayerInfo {
if m == nil || channelID == 0 {
return nil
}
m.mu.RLock()
defer m.mu.RUnlock()
players := make([]onlinePlayerInfo, 0, 8)
for key, area := range m.byArea {
if key.ChannelID != channelID || key.InstanceKey != 0 {
continue
}
for id, info := range area {
if info == nil || id == 0 || id == excludeID {
continue
}
players = append(players, *info)
}
}
sort.Slice(players, func(i, j int) bool {
return players[i].CharacterID < players[j].CharacterID
})
return players
}
// GetChannelRaidInstancePlayers returns every player registered in one channel's
// raid instance area, i.e. the players whose areaKey carries the raid key as its
// instance component.
//
// GetChannelTownInstancePlayers deliberately keeps only instance zero, because it
// answers "who is in this channel's public town". A raid channel's waiting area
// (town 19 area 2, Town/BattleShip/BattleShip_Anton.map) is registered with
// InstanceKey == raidKey instead, so a member standing there is invisible to that
// pass. Measured: with the leader inside the raid instance and a member on the
// public deck, the member's client never received the leader's actor record, so
// the attack-party roster lost the leader's portrait and level; walking the leader
// onto the deck fixed it and walking back out broke it again.
func (m *OnlinePlayerManager) GetChannelRaidInstancePlayers(channelID uint16, instanceKey uint32, excludeID uint16) []onlinePlayerInfo {
if m == nil || channelID == 0 || instanceKey == 0 {
return nil
}
m.mu.RLock()
defer m.mu.RUnlock()
players := make([]onlinePlayerInfo, 0, 8)
for key, area := range m.byArea {
if key.ChannelID != channelID || key.InstanceKey != instanceKey {
continue
}
for id, info := range area {
if info == nil || id == 0 || id == excludeID {
continue
}
players = append(players, *info)
}
}
sort.Slice(players, func(i, j int) bool {
return players[i].CharacterID < players[j].CharacterID
})
return players
}
// peerActorInfoForCharacter resolves the record sendTownRemoteActorState needs for
// one character.
//
// PlayerForCharacter answers from the public presence map, and a character inside
// a private scene -- the Seria room every ordinary login lands in, a raid
// channel's Seria room, the PVP lobby -- is deliberately never registered there,
// so the lookup misses. The actor record is still required: the client resolves an
// op7 prompt's source through its own user table and drops the whole notification
// when the key is absent (sub_1D82CF0 -> sub_2699A30 -> jz). Falling back to the
// bound session gives a record with the only two fields the send path reads,
// CharacterID and Session.
func (s *Service) peerActorInfoForCharacter(characterID uint16) (onlinePlayerInfo, bool) {
if s == nil || characterID == 0 {
return onlinePlayerInfo{}, false
}
if info, ok := s.onlinePlayers.PlayerForCharacter(characterID); ok {
return info, true
}
session, ok := s.onlineGameSession(characterID)
if !ok || session == nil || session.conn == nil {
return onlinePlayerInfo{}, false
}
return onlinePlayerInfo{
CharacterID: characterID,
ChannelID: townPresenceChannelID(session),
Session: session,
}, true
}
// runtimeRaidMemberInsideOpenDungeonRun reports whether this raid already holds an
// open gate run for the member, i.e. whether that client is rendering a dungeon
// instance rather than a scene the channel owns.
//
// A run is open from the moment its gate is selected until it completes or is
// abandoned, which is exactly the window in which the member's client is inside the
// instance. It reads only the raid snapshot its caller owns: taking the target
// session's dungeon lock here would deadlock every caller that already holds it
// (notifyRuntimeRaidDungeonLifecycleLocked publishes these installs while the
// session's dungeon lock is held).
func runtimeRaidMemberInsideOpenDungeonRun(state runtimeRaidState, characterID uint16) bool {
if characterID == 0 {
return false
}
for _, progress := range state.Lifecycle.Dungeons {
if progress.CharacterID != characterID {
continue
}
if progress.Status == runtimeRaidDungeonProgressCompleted {
continue
}
return true
}
return false
}
// runtimeRaidTargetSceneChannelOwned reports whether the receiving client is
// currently rendering a scene the channel owns -- a town, the raid waiting area,
// a channel-owned room -- as opposed to a dungeon instance, its settlement
// screen, or a selector/transition scene.
//
// The basis is session.townActorOwnerChannel (character_protocol_constants.go:50
// currentTownActorOwnerContext), the byte the client's global scene table keys a
// remote actor by. Every scene the channel owns commits it through
// currentConnectionTownActorOwnerContext (game_bootstrap.go:54-55,
// initial_town_route_state.go:117 and :176, initial_town_reconnect.go:25/:107/:193,
// initial_town_route_progress.go:181, return_select_character.go:119,
// dungeon_return_town.go:975 and :1741) and every scene the channel does not own
// resets it to currentSceneObjectContext (0) in resetDungeonEntrySceneGates
// (dungeon_return_town.go:1952), which the dungeon selector, dungeon navigation,
// practice room and party-dungeon entries all call. A byte of 0 is therefore "this
// client holds no channel-owned scene actor" -- a real channel never has ID 0
// (currentPartySceneOwnerContext, character_protocol_constants.go:63-73).
//
// It is read without session.townMu on purpose: the callers below publish these
// installs from notifyRuntimeRaidDungeonLifecycleLocked, which already holds
// session.dungeon.mu for the acting session, and the acting session is one of the
// targets here -- taking its town or dungeon lock again would self-deadlock. The
// field is a single byte, it is only ever compared against a constant, and the
// install already reads it lock-free to fill the target_local_owner log column.
func runtimeRaidTargetSceneChannelOwned(target *gameSession) bool {
if target == nil || target.conn == nil {
return false
}
return currentTownActorOwnerContext(target) != currentSceneObjectContext
}
// runtimeRaidPeerActorInstallMode is the answer the peer-actor install uses. It
// has two values rather than a yes/no because the only thing that must never
// reach a client rendering an instance is the mode1 binding.
type runtimeRaidPeerActorInstallMode byte
const (
// runtimeRaidPeerActorInstallRecord sends mode0 only: the actor record the
// client's own user table (roster portrait, level, name) is resolved from.
runtimeRaidPeerActorInstallRecord runtimeRaidPeerActorInstallMode = iota
// runtimeRaidPeerActorInstallBound additionally sends mode1, which binds the
// peer's equipment and skill pages to the created object.
runtimeRaidPeerActorInstallBound
)
// runtimeRaidPeerActorInstallModeFor decides how much of a peer's actor record a
// target client may receive.
//
// It is deliberately no longer a deny gate. Blocking the install inside an
// instance traded one bug for another: the member rending the Anton dungeon then
// saw every squad mate's roster portrait as the default one, because nothing ever
// created those records in its user table.
//
// The owner context does not need to change for a dungeon scene either. The
// client's own trace shows `mode0-owner-compare scene_channel=23` with
// `dungeon=218 / 219 / 220 / 222 / 223` in scope and `branch=current-scene-create`
// -- the same branch a town actor takes -- so a channel-owner body is created in
// the client's global scene table inside a raid instance exactly as it is in
// town. What differs there is what the *mode1* half does: it carries the peer's
// skill pages, and the client applies those to whichever actor it controls, which
// is its own dungeon actor ("帝血弑天 moved from 9999 onto 1111" at the 210 boss
// clear). Mode0 alone creates the record without that binding.
//
// 2026-09-29: the mode1 half is no longer sent ANYWHERE, the waiting area
// included, and the evidence is the operator's own rule that "过多的 mode1 下发会
// 导致严重卡顿". Live raid 1507329, 8 members, own build -- same fan-out, same
// members, same code path, the ONLY difference being whether the installs carried
// mode1:
//
// phase-1 activation 00:41:08 (waiting area -> mode0+mode1):
// all EIGHT sessions 981ms 999ms 1.0129s 1.0129s 1.0219s 1.0226s 1.0244s 1.0254s
// phase-2 activation 00:46:07 (already inside -> mode0 only):
// the SAME eight loops 351ms 351ms 373ms 373ms 374ms 376ms 377ms 377ms
//
// 1.0s versus 0.37s: the mode1 half is ~63% of a full-second freeze for every
// client in the raid. Its volume that session: 302 of 861 installs carried it and
// mode1 was 39.9% of all op2 bytes. The town co-presence path stopped sending it
// on 2026-09-26 for the same reason ("mode1 现在没有任何消费者" -- the client's
// combat-power feature is off) and peers render fine there without it, so nothing
// a waiting-area client needs lives in that half.
func runtimeRaidPeerActorInstallModeFor(state runtimeRaidState, target *gameSession) runtimeRaidPeerActorInstallMode {
return runtimeRaidPeerActorInstallRecord
}
// sendRuntimeRaidPeerActor installs one character's actor record in another
// character's client, so a window or prompt that resolves the peer through the
// client's own user table has something to find. Failures are logged, never
// propagated: the caller is a gameplay step whose own result must not depend on a
// cosmetic actor record.
func (s *Service) sendRuntimeRaidPeerActor(state runtimeRaidState, target *gameSession, characterID uint16, source string) {
s.sendRuntimeRaidPeerActorWithContext(context.Background(), state, target, characterID, source)
}
// sendRuntimeRaidPeerActorWithContext is sendRuntimeRaidPeerActor with the
// caller's context, so a fan-out can hand the projection path its batch-scoped
// read cache. Without one it behaves exactly like sendRuntimeRaidPeerActor.
func (s *Service) sendRuntimeRaidPeerActorWithContext(ctx context.Context, state runtimeRaidState, target *gameSession, characterID uint16, source string) {
s.sendRuntimeRaidPeerActorWithMode(ctx, state, target, characterID, source, runtimeRaidPeerActorInstallModeFor(state, target))
}
// sendRuntimeRaidPeerActorRecord installs only the mode0 record (+ the op9 party frame)
// of one raid peer, without the mode1 binding half and whatever the receiving client's
// scene would otherwise select.
//
// It exists for prompts: the client resolves an op7 source through its own user table,
// which mode0 fills on its own (sendTownRemoteActorRecord documents the split), and it
// has no use for the peer's equipment/skill-page binding. The operator's report
// (2026-09-26) is why the cheaper form matters: the raid application built the mode1
// half of BOTH members in front of the leader's prompt, ~87 ms of equipment and PVF
// work for a record nobody asked to see.
func (s *Service) sendRuntimeRaidPeerActorRecord(state runtimeRaidState, target *gameSession, characterID uint16, source string) {
s.sendRuntimeRaidPeerActorWithMode(
context.Background(), state, target, characterID, source, runtimeRaidPeerActorInstallRecord)
}
// sendRuntimeRaidPeerActorWithMode is the shared body: it installs one character's actor
// record in another character's client with an explicit install mode.
func (s *Service) sendRuntimeRaidPeerActorWithMode(ctx context.Context, state runtimeRaidState, target *gameSession, characterID uint16, source string, mode runtimeRaidPeerActorInstallMode) {
if s == nil || target == nil || target.conn == nil || characterID == 0 {
return
}
if target.selectedCharacterID == characterID {
return
}
// The body installed below carries the receiving channel's scene context
// (currentPartySceneOwnerContext), which is also the context a raid instance
// resolves, but the mode1 binding half is withheld from a client that is
// rendering an instance or that has not committed a channel-owned scene: its
// skill pages land on the locally controlled actor there. See
// runtimeRaidPeerActorInstallModeFor.
//
// The completed-run window is the reason the mode is derived from both
// halves. A run status alone misses it: live 2026-09-17 07:14:02.843Z
// installed character 22 into character 1 with `raid_dungeon_completed_raid_mate_actors
// target_local_owner=0 remote_owner=23` 44ms after the boss-death commit
// (game-dungeon-ordinary-final-clear-committed, 07:14:02.798Z) and 85ms before
// the completed lifecycle that owns the install was even logged
// (game-raid-dungeon-lifecycle event=completed, 07:14:02.928Z, immediately
// followed by game-dungeon-settlement-entry-op31-send). At that instant the
// run was already Completed, so the run half alone would call it "town". The
// scene half alone misses the opposite boundary, the moment between selecting
// a gate and the client tearing its town scene down: the run is already
// Selected while townActorOwnerChannel is still the channel (live 2026-09-17
// 08:47:31.338Z, raid_dungeon_selected_raid_mate_actors target_local_owner=23).
// Either way the actor record itself is still installed.
actor, ok := s.peerActorInfoForCharacter(characterID)
if !ok {
s.logGameEvent(target, "game-raid-peer-actor-unavailable",
"peer_char_id", characterID, "source", source)
return
}
if err := s.sendTownRemoteActorProjectionWithContext(ctx, target, actor, source, mode == runtimeRaidPeerActorInstallBound); err != nil {
s.logGameEvent(target, "game-raid-peer-actor-failed",
"peer_char_id", characterID, "source", source, "error", err)
return
}
s.logGameEvent(target, "game-raid-peer-actor-sent",
"peer_char_id", characterID, "source", source,
"mode", describeRuntimeRaidPeerActorInstallMode(mode))
}
func describeRuntimeRaidPeerActorInstallMode(mode runtimeRaidPeerActorInstallMode) string {
if mode == runtimeRaidPeerActorInstallBound {
return "mode0_record_and_mode1_binding"
}
return "mode0_record_only"
}
// syncRuntimeRaidMateActors gives this character and every online raid-mate each
// other's actor record.
//
// A raid roster renders a member row's portrait and level from the client's own
// user table, and that table is only fed by actor creation. Peers standing in the
// same area get theirs from ordinary co-presence; every other combination -- a
// mate inside the raid instance area, a mate inside a private room, a mate in
// another area of the same channel -- had nothing, so the roster silently degraded
// until the two happened to walk into the same area.
func (s *Service) syncRuntimeRaidMateActors(session *gameSession, source string) {
s.syncRuntimeRaidMateActorsWith(session, source, s.sendRuntimeRaidPeerActor)
}
// syncRuntimeRaidMateActorRecords is syncRuntimeRaidMateActors with the record-only
// install form: the mode0 record and the op9 party frame, never the mode1 binding.
//
// It is the form the admission path uses, by the operator's rule (2026-09-27): a
// member that just got accepted needs the roster row to resolve its portrait and
// level from the client's own user table, and that is mode0's job alone. The mode1
// half installs the peer's equipment and skill pages -- measured on the wire at
// 679 B (leader) and 1373 B (member) plus a 14-slot equipment projection each --
// and nothing on the accepted-member path reads them. Same split the application
// prompt already uses (sendRuntimeRaidPeerActorRecord).
func (s *Service) syncRuntimeRaidMateActorRecords(session *gameSession, source string) {
s.syncRuntimeRaidMateActorsWith(session, source, s.sendRuntimeRaidPeerActorRecord)
}
// syncRuntimeRaidMateActorsWith installs this character and every online raid-mate
// into each other's client, once per direction, through the caller's install form.
//
// It is deliberately ONE call per direction: the loop below already covers both
// halves of every pair, so a caller that also ran it for the other side of the same
// pair sent the identical records twice (live 2026-09-26 17:18:06Z, raid 1507329:
// 472/1373 bytes to the applicant at .404 and again at .529, 459/679 bytes to the
// leader at .434 and again at .463 -- 2983 B of a 6674 B acceptance).
func (s *Service) syncRuntimeRaidMateActorsWith(
session *gameSession,
source string,
install func(runtimeRaidState, *gameSession, uint16, string),
) {
if s == nil || session == nil || session.conn == nil {
return
}
self := session.selectedCharacterID
if self == 0 {
return
}
state, ok := s.runtimeRaidForMember(self)
if !ok {
return
}
for _, member := range state.Members {
if member.CharID == self {
continue
}
mateSession, online := s.onlineGameSession(member.CharID)
if !online || mateSession == nil || mateSession.conn == nil {
continue
}
// Co-presence already installed both directions for the same area; the
// duplicate would only repaint the same actor.
if s.onlinePlayers.PeerInSameArea(self, member.CharID) {
continue
}
install(state, session, member.CharID, source+"_raid_mate_into_self")
install(state, mateSession, self, source+"_self_into_raid_mate")
}
}
// syncRuntimeRaidMateActorsAll gives every pair of frozen raid members each
// other's actor record, without the same-area shortcut.
//
// Everything the raid draws about another member resolves that member's key in
// the client's own user table, which only actor creation fills: the 战况表 grid
// cell looks the key up (sub_F14C40 -> sub_2699A30) and hides the whole cell on a
// miss, and the standby room renderer next to it does the same (sub_15C8560 ->
// sub_15C87FC). Co-presence installs those records only while both characters
// stand in the same scene, and a raid deliberately scatters them: one squad is
// inside the instance while another waits outside, and a member that just changed
// scene lost the records the old scene carried (see
// currentChannelWidePeerActorsEnabled). That is why this variant is called from
// the scene boundaries themselves and never asks PeerInSameArea -- at those
// boundaries "same area" is precisely the state that just stopped being true.
func (s *Service) syncRuntimeRaidMateActorsAll(state runtimeRaidState, source string) {
s.syncRuntimeRaidMateActorsAllAtSceneBoundary(state, source, false)
}
// syncRuntimeRaidMateActorsAllAtSceneBoundary is syncRuntimeRaidMateActorsAll
// with an optional "the receiver may be between scenes" guard.
//
// The `selected` boundary runs while the moving client is still tearing its old
// scene down: its channel-owned scene is already gone (the owner byte the client
// reports is 0) and the next one does not exist yet. A record installed into such
// a client lands in a scene that is about to be destroyed, and the client then
// walks that half-built entry from its own raid UI. Measured 2026-09-17 -- the
// only two hatchery entries after the 2026-09-17 18:20 (local) build:
//
// 11:05:52.017 selected(224) target_local_owner=0 -> client died 11:05:52.377
// (eip=0, ret0=0x0263A2ED, after
// eight 0x025BD2FF skips)
// 10:31:39.375 selected(221) target_local_owner=0 -> client died 10:31:39.721
// (eip=0, ret0=0x025BD2FF)
//
// while the same session's 218 entry ran the same hook with target_local_owner=23
// and was fine, and every pre-18:20 hatchery entry reached `loaded` and
// `completed` (03:48/08:51/09:49). The `loaded` boundary fires once the scene
// exists and installs exactly the same record, so deferring here costs nothing
// the roster needs.
func (s *Service) syncRuntimeRaidMateActorsAllAtSceneBoundary(
state runtimeRaidState, source string, deferWhileBetweenScenes bool) {
if s == nil || len(state.FrozenMembers) < 2 {
return
}
// 每个接收者都要一份同一个演员的完整投影,而投影只由演员自己的角色 ID 决定。
// 这次扇出期间挂一个只读缓存,同一个演员在这批里只解析一次;函数返回即失效。
ctx, _ := withProjectionReadCache(context.Background())
// 同一次扇出内再挂一层「按演员复用投影 body」的缓存。
// 56 个有序对的两份 body 只由演员与 remoteOwner 决定,而 remoteOwner 对
// 同一个接收者是常量(currentTownRemoteActorOwnerContext 忽略 charID),
// 所以 56 次构建会退化成约 M 次。见 actor_projection_bodies_cache.go。
ctx = withActorProjectionBodiesCache(ctx)
members := append([]runtimeRaidMemberState(nil), state.FrozenMembers...)
runtimeRaidSortMembers(members)
for _, self := range members {
selfSession, online := s.onlineGameSession(self.CharID)
if !online || selfSession == nil || selfSession.conn == nil {
continue
}
if deferWhileBetweenScenes && !runtimeRaidTargetSceneChannelOwned(selfSession) {
s.logGameEvent(selfSession, "game-raid-peer-actor-deferred",
"char_id", self.CharID,
"source", source,
"reason", "scene_between_scenes",
"local_owner", byte(currentTownActorOwnerContext(selfSession)))
continue
}
// 把「对这个接收者的整批安装」投进**它自己的**信箱,而不是在发起者的
// event loop 上同步写完 M 个接收者。
//
// 实测(2026-09-26 测试服,8 人攻坚):body 构建已经靠缓存从 56 次降到
// 8 次(cache hit 48 / miss 8 / distinct_keys 8),但 raid_loaded_fanout
// 仍是 4271ms。4271ms ÷ 8 接收者 ≈ 534ms/人 ≈ 2 × gameWriterSyncBudget
// (250ms) —— 剩下那一整段就是「同步写进每个接收者的 writer」,与构建无关。
//
// 这与战斗路径的既有约定一致:publishRuntimePartyDungeonCharacterState
// 的注释原文是 "a dungeon command must never synchronously wait for another
// member's mailbox"。进出图路径此前没有照做。
//
// 顺序不变:整批仍按 members 顺序执行,信箱本身是 FIFO;而且每个接收者现在
// 是在自己的 loop 上写自己的 wireMu(此前是跨 loop 抢锁)。
//
// 2026-09-29 00:41:08 实测(8 人攻坚,自家构建):整批作为**一个**任务投递时,
// 8 个成员各自的 event loop 全部被占满约 1.0 秒
// (raid_phase_activation_raid_mate_actors duration=981ms..1.025s,同一瞬间
// 8 个会话全部命中)—— 全团每个客户端整整卡 1 秒,就是"严重卡顿"。5 分钟后
// 二阶段同一次扇出只花 0.35 秒,唯一差别是那批安装不带 mode1 半边。也就是说
// 单次任务耗时 ≈ 安装数 × 每次写入。分批不减少总工作量,只是让客户端自己的包
// 在批次之间得到处理,而不是排在整批之后。
receiver, selfID := selfSession, self.CharID
mates := make([]uint16, 0, len(members))
for index := range members {
if members[index].CharID != selfID {
mates = append(mates, members[index].CharID)
}
}
for start := 0; start < len(mates); start += currentRaidMateActorInstallChunk {
stop := start + currentRaidMateActorInstallChunk
if stop > len(mates) {
stop = len(mates)
}
chunk := append([]uint16(nil), mates[start:stop]...)
if err := s.postGameSessionCharacterEvent(
receiver,
source,
selfID,
receiver.characterGeneration,
func() error {
started := time.Now()
for _, mateID := range chunk {
s.sendRuntimeRaidPeerActorWithContext(ctx, state, receiver, mateID, source)
}
s.logRuntimeRaidMateActorChunk(receiver, source, len(mates), len(chunk), time.Since(started))
return nil
},
); err != nil {
s.logGameEvent(receiver, "game-raid-peer-actor-fanout-deferred",
"char_id", selfID, "source", source, "error", err)
break
}
}
}
// 诊断:把这次扇出的缓存命中/未命中计数打出来。
// 目的是判定「同一个演员为什么没有在 7 个接收者之间复用」——
// 如果 miss_bind / miss_nobind 两边都很大,说明 withBinding 随接收者变化、
// 把同一个演员拆成了两个键;如果 distinct_keys 接近 M×2,键就是在按接收者分裂。
//
// 注意:这些计数取自 `postGameSessionCharacterEvent` **之后**,而投递是异步的
// (session.events.loop.SubmitBlocking,async=true),所以这里读到的 hit/miss/
// build_ms/send_ms 通常仍是 0 —— 见 actor_projection_bodies_cache.go 的 2026-09-27
// 实测:96 次 raid 边界扇出的 summary 全是 0。要拿真数必须把 summary 挪进投递的
// 那个闭包里(本轮未改,避免动诊断语义)。
if bodiesCache := actorProjectionBodiesCacheFromContext(ctx); bodiesCache != nil {
bodiesCache.logSummary(s, source)
}
}
// syncRuntimeRaidMateActorsMovedAtSceneBoundary installs only the pairs that one
// member's scene change actually invalidated.
//
// The four raid-dungeon lifecycle boundaries (selected / loaded / completed /
// abandoned) used to sweep every ordered pair of frozen members -- N*(N-1)
// installs per boundary -- but the event describes ONE member changing scene, and
// only two directions can be stale because of it:
//
// "the member that just moved lost the records for everyone who stayed behind,
// and the members that stayed never had a record for the one inside"
// -- the rationale notifyRuntimeRaidDungeonLifecycleLocked carries at its
// fan-out call site in raid_dungeon_lifecycle.go
//
// So the moved member needs every mate's record and every mate needs the moved
// member's record: 2*(N-1) installs instead of N*(N-1). The remaining pairs have
// neither side changing scene, and every path that does invalidate them still
// runs: ordinary co-presence (broadcastTownPlayerEnter), the channel-wide pass
// for another area of the same channel (sendChannelWidePeerActors), a move
// between areas (raid_mate_actor_after_area_enter), admission of a new member
// (raid_admitted_member_actor_*), phase activation, and every non-boundary caller
// (syncRuntimeRaidMateActorsAll, which is left on the full sweep).
//
// Measured on 203.0.113.11, 600 MB of packet_log covering ~1.7 h (2026-09-27):
// the four boundaries produced 11,298 of the 11,663 peer-actor installs (96.9%),
// every one of them carrying a mode1 half, 58.9% a full one (19..31 equipment
// create rows, each row set costing a PVF attribute recompute). mode1 was 15.7 MB
// of the 21.1 MB those installs carried. The raid snapshot in that window froze
// 12 members, so one boundary cost 132 installs where 22 are stale.
//
// The sweep was also re-sending verbatim: in the same window 88.2% of the
// `loaded` boundary's installs and 57.5% of the `selected` boundary's were an
// identical (target, actor, mode1 length) triple already sent to the same target
// for the same actor within 10 s -- which is what a per-member event driving an
// every-pair sweep produces.
//
// The moved member is still deferred at `selected` while it is between scenes,
// exactly as in the full sweep; covering that defer is what `loaded` is for.
func (s *Service) syncRuntimeRaidMateActorsMovedAtSceneBoundary(
state runtimeRaidState, movedCharID uint16, source string, deferWhileBetweenScenes bool) {
if s == nil || len(state.FrozenMembers) < 2 {
return
}
members := append([]runtimeRaidMemberState(nil), state.FrozenMembers...)
runtimeRaidSortMembers(members)
movedFrozen := false
if movedCharID != 0 {
for index := range members {
if members[index].CharID == movedCharID {
movedFrozen = true
break
}
}
}
if !movedFrozen {
// The event names a member this snapshot does not freeze, so the pair scope
// is unknown. Keep the full sweep rather than drop records for a member
// nobody would install.
s.syncRuntimeRaidMateActorsAllAtSceneBoundary(state, source, deferWhileBetweenScenes)
return
}
// Same two caches as the full sweep: one read cache for the actor records and
// one per-actor body cache for the two projections this boundary rebuilds.
ctx, _ := withProjectionReadCache(context.Background())
ctx = withActorProjectionBodiesCache(ctx)
// The scope the stayed-behind clients can be missing is the whole 战况表 ROW the
// moved member appears in, not the moved member alone. See
// runtimeRaidBoardRowInstallScope.
rowScope := runtimeRaidBoardRowInstallScope(state, movedCharID)
for _, self := range members {
selfSession, online := s.onlineGameSession(self.CharID)
if !online || selfSession == nil || selfSession.conn == nil {
continue
}
if deferWhileBetweenScenes && !runtimeRaidTargetSceneChannelOwned(selfSession) {
s.logGameEvent(selfSession, "game-raid-peer-actor-deferred",
"char_id", self.CharID,
"source", source,
"reason", "scene_between_scenes",
"local_owner", byte(currentTownActorOwnerContext(selfSession)))
continue
}
receiver, selfID := selfSession, self.CharID
if selfID != movedCharID {
// A mate that stayed kept its own scene, but what it can be missing is
// the record of ANY member of the row the moved member's squad draws --
// the board resolves that cell through the record of the row's own
// member, which is not necessarily the member that moved (see
// runtimeRaidBoardRowInstallScope). So install the row, minus whoever
// is receiving it. Install form stays record-only (no mode1 half), so
// this is a few small packets, not a second equipment projection.
installIDs := make([]uint16, 0, len(rowScope))
for _, scopeID := range rowScope {
if scopeID == 0 || scopeID == selfID {
continue
}
if _, online := s.onlineGameSession(scopeID); !online {
// The projection needs a live actor; an offline row member is
// skipped exactly as the moved-client batch skips offline mates.
continue
}
installIDs = append(installIDs, scopeID)
}
if len(installIDs) == 0 {
// No row member is online but the mover (already covered above), so
// keep the moved-only install rather than send nothing.
installIDs = append(installIDs, movedCharID)
}
// Chunked on the same bound as the moved client's own batch: a receiver
// loop is never handed more than currentRaidMateActorInstallChunk
// installs in one task, which is the budget the measured ~50ms-per-
// record-only-install stall was sized against.
for start := 0; start < len(installIDs); start += currentRaidMateActorInstallChunk {
stop := start + currentRaidMateActorInstallChunk
if stop > len(installIDs) {
stop = len(installIDs)
}
chunk := append([]uint16(nil), installIDs[start:stop]...)
if err := s.postGameSessionCharacterEvent(
receiver,
source,
selfID,
receiver.characterGeneration,
func() error {
started := time.Now()
for _, installID := range chunk {
s.sendRuntimeRaidPeerActorWithContext(ctx, state, receiver, installID, source)
}
s.logRuntimeRaidMateActorChunk(receiver, source, len(installIDs), len(chunk), time.Since(started))
return nil
},
); err != nil {
s.logGameEvent(receiver, "game-raid-peer-actor-fanout-deferred",
"char_id", selfID, "source", source, "error", err)
break
}
}
continue
}
// The moved client rebuilt its scene, so it lost every mate's record.
//
// This batch is the one place the moved-only scope still posts many
// installs to a single loop, and it is split into chunks for the reason
// measured live 2026-09-29 00:41:08 (8-member raid, phase activation,
// the full sweep as ONE task per receiver): every member's own event loop
// was blocked for ~1.0 s (duration=981ms..1.025s, all eight sessions at the
// same instant), while the same fan-out carrying no mode1 half cost 0.35 s
// on those same loops five minutes later. Installs x per-install write is
// the multiplier. Chunking does not reduce the total work; it lets the
// client's own packets run between the chunks instead of after all of them.
mates := make([]uint16, 0, len(members))
for index := range members {
if members[index].CharID != selfID {
mates = append(mates, members[index].CharID)
}
}
for start := 0; start < len(mates); start += currentRaidMateActorInstallChunk {
stop := start + currentRaidMateActorInstallChunk
if stop > len(mates) {
stop = len(mates)
}
chunk := append([]uint16(nil), mates[start:stop]...)
if err := s.postGameSessionCharacterEvent(
receiver,
source,
selfID,
receiver.characterGeneration,
func() error {
started := time.Now()
for _, mateID := range chunk {
s.sendRuntimeRaidPeerActorWithContext(ctx, state, receiver, mateID, source)
}
s.logRuntimeRaidMateActorChunk(receiver, source, len(mates), len(chunk), time.Since(started))
return nil
},
); err != nil {
s.logGameEvent(receiver, "game-raid-peer-actor-fanout-deferred",
"char_id", selfID, "source", source, "error", err)
break
}
}
}
if bodiesCache := actorProjectionBodiesCacheFromContext(ctx); bodiesCache != nil {
bodiesCache.logSummary(s, source)
}
}
// runtimeRaidBoardRowInstallScope lists the members whose actor records a client
// needs before its 战况表 can draw the row the moved member belongs to.
//
// The board does not draw one cell per member: it draws one cell per (dungeon,
// squad) row, and the client resolves that cell through the record of ONE member
// of the row -- the first member key the 584 row carries (sub_F14C40 ->
// sub_2699A30 looks that key up in the client's own user table and hides the whole
// cell on a miss; the same lookup _probe_634_report.md tracks for the row's
// dungeon). Installing only the member that MOVED therefore leaves the cell
// unresolvable whenever the mover is not the row's first member, which is the
// ordinary case: a squad leader opens the gate last, or a squadmate enters alone.
//
// Live 2026-09-29 12:25:33 (raid 1507329, our build, 4 squads x 4 members):
//
// 12:25:33 selected map=218 char=35 <- squad 1's slot-1 member enters
// 12:25:36 loaded map=218 char=35
// 12:31:01 the board rows published P1[1 35 36 37] P2[2 39 40 41] ...
// 12:31:22 the operator's 2队 client: its own row fine, squad 1's row drawn as
// 单人 with a black avatar -- that client had char 35's record (the
// member that moved) and never char 1's (the row's first member)
// 12:31:32 phase activation's full sweep installs every mate; the row draws
//
// So the install scope at a boundary is the ROW: the members of the squad the
// moved member's own roster byte names, plus the moved member when its live label
// no longer names a squad. The scope is capped by the squad size, which is what
// keeps this well below the every-pair sweep the move-only scope replaced.
func runtimeRaidBoardRowInstallScope(state runtimeRaidState, movedCharID uint16) []uint16 {
if movedCharID == 0 {
return nil
}
group := byte(0)
if index := runtimeRaidMemberIndex(state, movedCharID); index >= 0 {
group = runtimeRaidMemberSquad(state.Members[index].GroupIndex)
} else if member, ok := runtimeRaidFrozenMember(state, movedCharID); ok {
group = runtimeRaidMemberSquad(member.GroupIndex)
}
if group == 0 {
// An unassigned member is its own 单人 row, so its own record is the scope.
return []uint16{movedCharID}
}
members := runtimeRaidMembersInSquadLiveRows(state, group)
for _, member := range members {
if member == movedCharID {
return members
}
}
// The live roster no longer lists the moved member under this squad (its label
// was cleared, or that row is gone): keep it in the scope rather than install a
// row that omits the member this boundary is about.
members = append(members, movedCharID)
sort.Slice(members, func(i, j int) bool { return members[i] < members[j] })
return members
}
// currentRaidMateActorInstallChunk bounds how many peer actor installs one posted
// task may perform on a receiver's event loop. Two keeps a chunk near the write
// budget instead of the ~1 s a seven-install batch measured; see the call site.
const currentRaidMateActorInstallChunk = 2
// logRuntimeRaidMateActorChunk reports one chunk of the raid mate-actor fan-out so
// the next live raid can show whether the per-loop stall went down.
func (s *Service) logRuntimeRaidMateActorChunk(
receiver *gameSession,
source string,
installs int,
chunk int,
elapsed time.Duration,
) {
if s == nil || receiver == nil {
return
}
s.logPacketEvent("game-raid-mate-actor-install-chunk",
"conn_id", receiver.connID,
"char_id", receiver.selectedCharacterID,
"source", source,
"installs_in_batch", installs,
"installs_in_chunk", chunk,
"chunk_wall_ms", raidMillis(elapsed),
"chunk_size", currentRaidMateActorInstallChunk,
"reason", "bounded_so_one_client_loop_is_never_blocked_by_the_whole_sweep")
// Feed the per-raid cost summary: this is the number the operator reads back
// after a raid of more than eight players (see raid_cost_summary.go).
if raidKey := s.runtimeRaidKeyForMember(receiver.selectedCharacterID); raidKey != 0 {
s.raidCostMember(raidKey, receiver.selectedCharacterID)
s.noteRaidCostInstallChunk(raidKey, chunk, elapsed)
}
}
// currentChannelWidePeerActorsEnabled is unconditionally true.
//
// It used to be an opt-in environment switch. It is not one any more because the
// behaviour it guards is required, not experimental: the client resolves a remote
// character only through the record that actor creation puts in its own table
// (sub_1A089E0 -> sub_1C5B070 looks the key up in [this+0x278] and silently does
// nothing when the lookup misses). Without those records a player in another area
// of the same channel simply has no entry, and every window that shows remote
// characters degrades -- the attack-party roster loses its squad column, level and
// portrait, and the friend/guild context menu falls back to its offline variant.
// Measured: stepping the outsider into the member own area fixed the roster, and
// switching channels and back broke it again, because the records arrive with
// co-presence and are lost with the scene.
func currentChannelWidePeerActorsEnabled() bool {
return true
}
// sendChannelWidePeerActors installs an actor for every same-channel player who
// is standing in another area of the same town. The current client resolves a
// friend/guild member row's peer object only through its own name-keyed user
// table, and that table gains entries from actor creation: a member in another
// area of the same channel therefore has no record, the row's peer stays null,
// and the member context menu degrades to its offline variant even though the
// member is online in the player's own channel. Sending the peer actor gives the
// client that record, which is what the online menu items need.
//
// Peers already co-present in the same area are skipped: the ordinary
// co-presence path has already installed them.
func (s *Service) sendChannelWidePeerActors(session *gameSession, self *onlinePlayerInfo, source string) {
if s == nil || session == nil || self == nil || !currentChannelWidePeerActorsEnabled() {
return
}
channelID := townPresenceChannelID(session)
if channelID == 0 {
return
}
peers := s.onlinePlayers.GetChannelTownInstancePlayers(channelID, self.CharacterID)
// A raid channel's waiting area registers with InstanceKey == raidKey, which
// the public-town pass above filters out. Without this second pass a raid mate
// standing there had no actor record at all on this client.
if raidKey := s.runtimeRaidKeyForMember(self.CharacterID); raidKey != 0 {
peers = append(peers, s.onlinePlayers.GetChannelRaidInstancePlayers(channelID, raidKey, self.CharacterID)...)
}
// 这次扇出挂上两层缓存,与另两条兄弟路径(broadcastTownPlayerEnter:1332、
// syncRuntimeRaidMateActorsAllAtSceneBoundary:606)保持一致。
//
// 改前这里调的是 sendTownRemoteActorState(:995),它一路走到
// sendTownRemoteActorProjection(:1029)并自己造一个 context.Background(),
// 于是**两层缓存都没有**:仓储读不去重,投影 body 也不复用。
//
// 实测(2026-09-25/26 测试服):game-town-copresence-actor-state-send 在同一个
// 窗口里 1920 次,**比攻坚扇出自己的 1477 次还多** —— 城镇共现才是更大的
// 生产者,而它此前完全没走缓存。本函数由 area enter / town move /
// dungeon return 三条路径调用,每次都对全频道不同区域的每个 peer 重做一遍。
ctx, _ := withProjectionReadCache(context.Background())
ctx = withActorProjectionBodiesCache(ctx)
for _, peer := range peers {
if peer.TownID != self.TownID || peer.AreaID == self.AreaID {
continue
}
if err := s.sendTownRemoteActorRecordWithContext(ctx, session, peer, source); err != nil {
s.logGameEvent(session, "game-channel-wide-peer-actor-failed",
"peer_char_id", peer.CharacterID,
"peer_area_id", peer.AreaID,
"error", err)
continue
}
s.logGameEvent(session, "game-channel-wide-peer-actor-sent",
"peer_char_id", peer.CharacterID,
"peer_area_id", peer.AreaID,
"peer_name", peer.Name,
"source", source)
}
}
// PeerInSameArea verifies that both characters are registered in the same
// concrete town area. A visible remote actor is only interactive inside this
// scope; an online session in another area is not a valid peer target.
func (m *OnlinePlayerManager) PeerInSameArea(sourceID, targetID uint16) bool {
if m == nil || sourceID == 0 || targetID == 0 || sourceID == targetID {
return false
}
m.mu.RLock()
defer m.mu.RUnlock()
sourceKey, sourceOK := m.byChar[sourceID]
targetKey, targetOK := m.byChar[targetID]
if !sourceOK || !targetOK || sourceKey != targetKey {
return false
}
area := m.byArea[sourceKey]
return area != nil && area[sourceID] != nil && area[targetID] != nil
}
// PeerInSameChannel verifies that both characters are registered under the same
// channel component regardless of town, area, or private instance. Attack-party
// admission needs this wider scope: the applicant resolves the raid leader from
// the public directory while standing on the lobby deck, and the leader is
// already inside the raid's private waiting instance, so the two never share an
// areaKey even though the request is legitimate.
//
// Only the channel component is compared, exactly as PeerInSameArea compares the
// whole key: production sessions always carry a catalog channel id, so the
// comparison is meaningful there, and a registration that carries none must not
// be treated as a different channel or the admission path would fail closed.
func (m *OnlinePlayerManager) PeerInSameChannel(sourceID, targetID uint16) bool {
if m == nil || sourceID == 0 || targetID == 0 || sourceID == targetID {
return false
}
m.mu.RLock()
defer m.mu.RUnlock()
sourceKey, sourceOK := m.byChar[sourceID]
targetKey, targetOK := m.byChar[targetID]
return sourceOK && targetOK && sourceKey.ChannelID == targetKey.ChannelID
}
// PeersInSameArea returns a stable snapshot of every other player registered
// beside characterID. Callers use it for appearance-only projections that do
// not change movement state or area ownership.
func (m *OnlinePlayerManager) PeersInSameArea(characterID uint16) []onlinePlayerInfo {
if m == nil || characterID == 0 {
return nil
}
m.mu.RLock()
defer m.mu.RUnlock()
key, ok := m.byChar[characterID]
if !ok {
return nil
}
area := m.byArea[key]
peers := make([]onlinePlayerInfo, 0, len(area))
for id, info := range area {
if id == characterID || info == nil {
continue
}
peers = append(peers, *info)
}
sort.Slice(peers, func(i, j int) bool {
return peers[i].CharacterID < peers[j].CharacterID
})
return peers
}
func (m *OnlinePlayerManager) removeFromAreaLocked(characterID uint16) {
key, ok := m.byChar[characterID]
if !ok {
return
}
delete(m.byChar, characterID)
if area := m.byArea[key]; area != nil {
delete(area, characterID)
if len(area) == 0 {
delete(m.byArea, key)
}
}
}
// publishCurrentTownPresence registers a completed selected-town actor in the
// shared scene directory and performs the native peer-creation exchange. Cold
// character selection and channel reconnect both finish a complete mode0,
// mode1 and op24 route without a client op36 SET_USER_AREA request, so they
// must publish explicitly at that server-owned completion boundary.
func (s *Service) publishCurrentTownPresence(
session *gameSession,
character dnfrepo.CharacterRecord,
townID, areaID byte,
row currentSceneTransitionRow,
instanceKey uint32,
mapPath string,
source string,
) {
if s == nil || session == nil ||
session.selectedCharacterID == 0 || session.conn == nil {
return
}
characterID := session.selectedCharacterID
// Whatever this route decides, it owns the character's town registration
// from here on. A dungeon-selector retirement memo left behind would only
// let restoreTownPresenceAfterDungeonSelector resurrect a stale actor later.
clearTownSelectorRetiredPresence(session)
// 婚姻个人状态随"角色场景完成"一起下发:原生登录流程(冷选角 / 换频道重连)
// 不会走 op36,所以这个边界是唯一可靠的恢复点。weddingStateSent 让重复经过
// 同一路径时不再重发 1000(重发会清掉客户端的伴侣预览)。
// 三个下发都是 best-effort:失败只记账,不能让城镇发布整体失败。
if _, hasWedding := character.Stats["wedding_state"]; hasWedding || session.weddingStateSent.Load() {
if err := s.sendWeddingCharacterState(session, character); err != nil {
s.logGameEvent(session, "game-wedding-login-state-failed", "error", err)
}
if err := s.sendWeddingCapsuleState(session, character); err != nil {
s.logGameEvent(session, "game-wedding-capsule-restore-failed", "error", err)
}
// 仪式恢复要在本次发布返回后再做:它自己会走传送/时间队列,
// 在城镇注册尚未落定之前触发会读到过期的场景快照。
defer func() {
if err := s.restoreWeddingCeremony(session, character); err != nil {
s.logGameEvent(session, "game-wedding-ceremony-restore-failed", "error", err)
}
}()
}
// 婚姻场景按夫妻对分实例(见 wedding_presence.go)。这一步必须早于下面的
// privateRoomScene 分类:教堂/婚房是"按对分实例的公共场景",不是私密场景。
weddingKey, weddingScoped, weddingAdmitted := s.currentWeddingTownInstanceKey(
character, townID, areaID, uint16(townPresenceChannelID(session)))
if !weddingAdmitted {
s.logGameEvent(session, "game-town-presence-wedding-rejected",
"source", source, "char_id", characterID, "town_id", townID, "area_id", areaID,
"reason", "wedding_reservation_or_marriage_required")
return
}
if weddingScoped {
instanceKey = weddingKey
}
// 婚房状态(家具/情侣树)既是"进婚房别看到空房间"的数据,也是**结婚戒指上婚房属性的
// 唯一数据源**。原先只在 townID==婚房 时发一次,于是登录后不去婚房就永远没有这份数据
//(戒指不显示属性),进图再回城更是彻底丢失。已婚角色在任何区域都要有它。
if townID == currentWeddingRoomTownID {
if err := s.sendWeddingRoomState(session, character); err != nil {
s.logGameEvent(session, "game-wedding-room-restore-failed", "error", err)
return
}
} else {
s.restoreCurrentSceneWeddingRoomState(session, characterID, source)
}
// 决斗场认证面板(op48)+徽章钱包(op14)在每次城镇出现时下发(不限 PVP 频道);
// 每日任务清单(op323)只在 PVP 频道下发。总战绩频道另走认证面板。
// 两个都是 best-effort:失败只记账,不能让城镇发布整体失败。
if err := s.sendCurrentPVPDailyState(session); err != nil {
s.logGameEvent(session, "game-pvp-daily-initial-failed", "error", err)
}
if err := s.sendCurrentPVPTotalTeamSelection(session); err != nil {
s.logGameEvent(session, "game-pvp-total-team-initial-failed", "error", err)
}
// 上面的个人状态(婚姻 + 决斗场)属于"已完成演员",即使没有在线玩家管理器也要下发;
// 公开共处目录才开始需要 onlinePlayers。
if s.onlinePlayers == nil {
return
}
// Seria rooms, a raid channel's Seria room, and the PVP channel lobby (town 10
// / area 0) are all character-private scenes even though every visitor uses
// the same PVF town/area identifiers. Retire any public-town presence before
// entering and never place this actor in byArea, so visitors never see each
// other. Other PVP channel areas (battle-ready waiting areas etc.) stay public.
seriaMap := townMapPathLooksLikeSeria(mapPath)
raidGateRoom := currentRaidGateAreaIsSeriaRoom(session, townID, areaID)
pvpLobbyRoom := isPVPChannel(session.channel) &&
townID == byte(currentPVPTownID) && areaID == byte(currentPVPAreaID)
privateRoomScene := seriaMap || raidGateRoom || pvpLobbyRoom
privateRoomKind := "private_seria_room"
if privateRoomScene && !seriaMap && !raidGateRoom {
privateRoomKind = "private_pvp_lobby"
}
if privateRoomScene {
privateDestination := &onlinePlayerInfo{
CharacterID: characterID,
ChannelID: townPresenceChannelID(session),
TownID: townID,
AreaID: areaID,
InstanceKey: instanceKey,
PositionX: row.Value1,
PositionY: row.Value2,
Direction: row.Value3,
AreaState: row.Value4,
Session: session,
}
s.closeCurrentExpertJobStoreSession(session, true)
s.closeCurrentPrivateStoreSession(session, true)
_, oldOthers := s.onlinePlayers.LeaveArea(characterID)
if len(oldOthers) > 0 {
s.broadcastTownPlayerAreaChange(
privateDestination,
oldOthers,
runtimePartyStateSnapshot(session),
)
}
s.promoteResidentGameSession(session, characterID)
// A private Seria room is invisible to town co-presence, not to social
// state: login lands here, so skipping the social announcements left
// friends and guild members without an online notification until the
// character walked out into the public town.
s.publishCurrentSocialPresence(session, characterID, character.Name, source+"_"+privateRoomKind)
s.logGameEvent(session, "game-town-presence-suppressed",
"source", source,
"char_id", characterID,
"town_id", townID,
"area_id", areaID,
"map_path", mapPath,
"retired_peer_count", len(oldOthers),
"private_room_kind", privateRoomKind,
"reason", privateRoomKind)
// The PVP directory must survive an invisible lobby actor: it is the
// only way clients browse/create/join rooms. sendCurrentPVPDirectory
// is guarded internally by isPVPChannel + selectedCharacterID, so the
// ordinary private Seria room simply no-ops here.
if err := s.sendCurrentPVPDirectory(session); err != nil {
s.logGameEvent(session, "game-pvp-directory-private-scene-failed", "error", err)
}
return
}
channelID := townPresenceChannelID(session)
if current, ok := s.onlinePlayers.PlayerForCharacter(characterID); ok &&
current.Session == session && current.ChannelID == channelID &&
current.TownID == townID &&
current.AreaID == areaID && current.InstanceKey == instanceKey {
s.promoteResidentGameSession(session, characterID)
s.logGameEvent(session, "game-town-presence-publish-skipped",
"source", source,
"char_id", characterID,
"channel_id", channelID,
"town_id", townID,
"area_id", areaID,
"instance_key", instanceKey,
"reason", "same_session_scene_already_published")
return
}
name := character.Name
if name == "" {
name = csharpSelectCharacterName(characterID)
}
player := &onlinePlayerInfo{
CharacterID: characterID,
AccountID: character.AccountID,
Name: name,
Job: byte(numericCharacterStat(character.Job)),
GrowType: byte(numericCharacterStatValue(character, "grow_type")),
Level: byte(character.Level),
ChannelID: channelID,
TownID: townID,
AreaID: areaID,
InstanceKey: instanceKey,
PositionX: row.Value1,
PositionY: row.Value2,
Direction: row.Value3,
AreaState: row.Value4,
Session: session,
}
others := s.onlinePlayers.EnterArea(player)
s.promoteResidentGameSession(session, characterID)
s.broadcastTownPlayerEnter(player, others)
s.sendChannelWidePeerActors(session, player, "channel_wide_peer_actor_after_area_enter")
// Walking out of a private scene, or between two areas of the raid channel,
// makes the client drop the actors that are no longer co-present. Raid mates
// are not co-present by definition in those cases, so re-install them.
s.syncRuntimeRaidMateActors(session, "raid_mate_actor_after_area_enter")
s.publishCurrentSocialPresence(session, characterID, player.Name, source)
s.replayCurrentExpertJobStores(session, townID, areaID)
s.replayCurrentPrivateStores(session, townID, areaID)
s.logGameEvent(session, "game-town-presence-published",
"source", source,
"char_id", characterID,
"town_id", townID,
"area_id", areaID,
"instance_key", instanceKey,
"map_path", mapPath,
"position_x", row.Value1,
"position_y", row.Value2,
"direction", row.Value3,
"area_state", row.Value4,
"peer_count", len(others),
"sequence", "remote_mode0_then_mode1_then_op9_then_op23_both_directions")
if err := s.sendCurrentPVPDirectory(session); err != nil {
s.logGameEvent(session, "game-pvp-directory-initial-failed", "error", err)
}
}
// --- Broadcast packet builders ---
//
// IDA-verified wire formats (current 23.4.15.0 NoPack EXE):
//
// NOTI 0x0016 (sub_1D83990): u16 actorKey, u16 x, u16 y, u8 direction, u16 movementRate
// NOTI 0x0017 (sub_1D89590): u16 actorKey, u8 town, u8 area, u16 x, u16 y, u8 areaState, u8 direction
// NOTI 0x0006 (C# ref): u16 userId
const (
currentTownAreaUserListMsgID uint16 = 0x0018
currentTownUserLeaveMsgID uint16 = 0x0006
)
// buildTownAreaUserListBody builds the current EXE sub_1D901D0 layout:
// u8 town, u8 area, u16 count, repeat(u16 actorKey, u16 x, u16 y, u8 dir, u8 state).
//
// This is more than a co-presence roster. The final row for the selected
// character rebinds the local scene actor used by town camera tracking.
func buildTownAreaUserListBody(townID, areaID byte, players []onlinePlayerInfo) []byte {
count := len(players)
if count > 0xFFFF {
count = 0xFFFF
}
body := make([]byte, 4+count*8)
body[0] = townID
body[1] = areaID
binary.LittleEndian.PutUint16(body[2:4], uint16(count))
for i := 0; i < count; i++ {
offset := 4 + i*8
binary.LittleEndian.PutUint16(body[offset:offset+2], currentSceneActorObjectKey(players[i].CharacterID))
binary.LittleEndian.PutUint16(body[offset+2:offset+4], players[i].PositionX)
binary.LittleEndian.PutUint16(body[offset+4:offset+6], players[i].PositionY)
body[offset+6] = players[i].Direction
body[offset+7] = players[i].AreaState
}
return body
}
// buildTownUserLeaveBody builds NOTI 0x0006 body: u16 userId.
func buildTownUserLeaveBody(characterID uint16) []byte {
body := make([]byte, 2)
binary.LittleEndian.PutUint16(body, characterID)
return body
}
// currentTownRemoteActorOwnerContext returns the receiving session's committed
// town-channel owner. Current NoPack sub_2009160 treats an owner equal to the
// resident town channel as a global scene actor and creates it by its distinct
// uint16 object key through sub_20036C0. A character-ID-derived owner instead
// selects the auxiliary context-object branch; that object can be drawn, but it
// is absent from the native global table used by player interaction menus.
func currentTownRemoteActorOwnerContext(target *gameSession, _ uint16) byte {
// The selected local actor intentionally uses context 0 in mode0/mode1,
// but remote co-presence actors must remain in the channel-owned global
// scene table used by the party and interaction handlers.
return currentPartySceneOwnerContext(target)
}
// sendTownRemoteActorState installs one repository-backed peer actor in the
// target client's town scene. 86JP establishes co-presence with both appearance
// and full user-info records before the area-state notification. The current
// 90CN client uses its typed mode0/mode1 equivalents instead of 86JP's legacy
// USERINFO subtype0/subtype1 bodies.
func (s *Service) sendTownRemoteActorState(
target *gameSession,
actor onlinePlayerInfo,
source string,
) error {
return s.sendTownRemoteActorProjection(target, actor, source, true)
}
// sendTownRemoteActorStateWithContext is sendTownRemoteActorState with the
// caller's context, so a fan-out can hand the projection path its batch-scoped
// read cache. Without one it behaves exactly like sendTownRemoteActorState.
func (s *Service) sendTownRemoteActorStateWithContext(
ctx context.Context,
target *gameSession,
actor onlinePlayerInfo,
source string,
) error {
return s.sendTownRemoteActorProjectionWithContext(ctx, target, actor, source, true)
}
// sendTownRemoteActorRecord installs only the mode0 actor record plus the op9
// party frame, leaving out the mode1 binding. It is the form a target client
// that is rendering a dungeon instance receives: mode0 is what its own user
// table -- and therefore every roster portrait, level and name -- resolves the
// peer from, while mode1 is the half that binds the peer's equipment and skill
// pages and therefore the half that must not reach an instance.
func (s *Service) sendTownRemoteActorRecord(
target *gameSession,
actor onlinePlayerInfo,
source string,
) error {
return s.sendTownRemoteActorProjection(target, actor, source, false)
}
// sendTownRemoteActorRecordWithContext is sendTownRemoteActorRecord with the
// caller's context, so a fan-out can hand the projection path its batch-scoped
// read and body caches.
//
// 只用于**同频道跨区域**的那条共现扇出(sendChannelWidePeerActors)。
//
// 依据是客户端自己的实现,不是猜的。90CN.cpp 的 ProxyClass0Dispatch 在原生处理器
// 之后留着一段注释,原文是:The native handler has already accepted and applied
// this packet. Cache only the complete local mode1/mode3 stat block for the Lua
// calculator; remote town actors and incomplete bodies are deliberately ignored.
//
// 也就是说 mode1 描述的是**接收者自己**的 HP/MP/六维与装备行,原生处理器会把它应用
// 到本地受控角色并覆盖本地快照;90CN 之所以要加 !currentActorMatch -> return 这道
// 守卫,正是因为服务端会给 peer 也发一份。那道守卫每进程只报 8 次就不报了。
//
// 跨区域 peer 的 mode1 语义上必然用不上:他和接收者不在同一张图,那份属性快照只会
// 污染接收者自己的战斗力基准。参考端同样把这条写死 —— 区域共存广播只允许 USERINFO
// MINIMUM(subtype0/occ0),完整 subtype1 属于角色自身初始化。
//
// 刻意**不**动的:broadcastTownPlayerEnter 的两个方向。它们少了 mode1 会打乱
// town_move_test.go:616/690/708/783 与 online_player_manager_test.go:497 钉死的
// mode0 -> mode1 -> op9 -> 0x0017 线序契约;那是一套被多处字节级断言锁住的协议
// 契约,要不要跟着改是产品决定,不在这条改动里。
func (s *Service) sendTownRemoteActorRecordWithContext(
ctx context.Context,
target *gameSession,
actor onlinePlayerInfo,
source string,
) error {
return s.sendTownRemoteActorProjectionWithContext(ctx, target, actor, source, false)
}
func (s *Service) sendTownRemoteActorProjection(
target *gameSession,
actor onlinePlayerInfo,
source string,
withBinding bool,
) error {
return s.sendTownRemoteActorProjectionWithContext(context.Background(), target, actor, source, withBinding)
}
// sendTownRemoteActorProjectionWithContext is sendTownRemoteActorProjection
// derived from the caller's context instead of context.Background. A fan-out
// installs its batch-scoped read cache on that context; every other caller
// passes context.Background and gets the original behaviour byte for byte.
func (s *Service) sendTownRemoteActorProjectionWithContext(
parent context.Context,
target *gameSession,
actor onlinePlayerInfo,
source string,
withBinding bool,
) error {
if target == nil || target.conn == nil || actor.Session == nil || actor.CharacterID == 0 {
return nil
}
if parent == nil {
parent = context.Background()
}
ctx, cancel := context.WithTimeout(parent, createWriteTimeout)
defer cancel()
charID, charName, character, hasCharacter := s.characterForEnter(
ctx,
actor.Session,
actor.CharacterID,
)
if charID != actor.CharacterID || !hasCharacter {
return fmt.Errorf(
"remote town actor repository record unavailable: requested=%d loaded=%d found=%t",
actor.CharacterID,
charID,
hasCharacter,
)
}
remoteOwner := currentTownRemoteActorOwnerContext(target, charID)
if remoteOwner == currentSceneObjectContext {
return fmt.Errorf("remote town actor owner channel is not committed")
}
// 一次扇出内按**演员维度**复用建好的两份 body。
//
// 这两份 body 里与接收者相关的**只有一个字节**:偏移 4 的 ownerChannel
// (字符集见 actor_projection_bodies_cache.go)。而
// currentTownRemoteActorOwnerContext(target, _) 显式忽略 charID(:978-983),
// 只取决于接收者。所以缓存键**不含** owner:按演员存一份「字节 4 归零」的
// body,取出来再按接收者补那一个字节,最终字节与逐对重建**逐字相同**。
//
// ⚠️ 第一版把 ownerChannel 放进了键里,于是每个接收者各建一遍、命中率为零,
// 一次边界只从 6.0s 降到 4.5s。别再犯。
bodiesKey := actorProjectionBodiesKey{
charID: charID,
withBinding: withBinding,
}
bodiesCache := actorProjectionBodiesCacheFromContext(ctx)
mode0Body, mode1Body, bodiesCached := bodiesCache.lookup(bodiesKey)
buildStarted := time.Now()
if !bodiesCached {
var buildErr error
// 对端 actor 的 mode-0 行必须清掉副职业字段:观看者客户端是"从 0 新建"这条记录,
// 带非零副职业字节会被当成"刚获得"并播放获得特效(2026-10-08 现场:B 回城后
// 看到已回城的 A 身上冒副职业特效)。mode1 不承载该字段,保持原记录。
mode0Body, buildErr = s.buildCurrentSceneObjectListBodyForSessionInContextStrict(
ctx,
actor.Session,
charID,
charName,
character,
hasCharacter,
remoteOwner,
)
if buildErr != nil {
return fmt.Errorf("build remote town actor mode0: %w", buildErr)
}
adventureSummary := s.currentAccountAdventureGroupSummaryForPacket(
ctx,
actor.Session,
character,
hasCharacter,
)
// includeEquipment 必须跟随 withBinding。
//
// withBinding 只决定"发不发"(下面 `if withBinding` 才发 mode1),
// 但这里原本硬编码 true,于是每一对都把 ~31 行装备绑定整份建出来再丢掉。
// 实测一次 loaded 边界的 112 次发送里 withBinding 对**每一个**都是 false,
// 也就是说一半的构建量是纯废工。
//
// 身份校验只需要 mode1 的 raw16+objectKey 前缀,那段写在 includeEquipment
// 分支**之前**(userinfo_current_modes.go:161-164),关掉装备行不削弱校验。
mode1Body = s.buildCurrentActorBindingMode1BodyForSelectedWithEquipmentInContext(
ctx,
actor.Session,
character,
hasCharacter,
charID,
uint32(adventureSummary.ManageLevel),
remoteOwner,
withBinding,
)
bodiesCache.store(bodiesKey, mode0Body, mode1Body)
}
bodiesCache.noteBuild(time.Since(buildStarted))
// 缓存里按 0 存,这里补上本接收者的 owner 字节(偏移 4)。
// 未命中时刚构建出来的 body 本来就是该接收者的值,这一步是等值重写。
applyActorProjectionOwnerChannel(mode0Body, remoteOwner)
applyActorProjectionOwnerChannel(mode1Body, remoteOwner)
// The installed character's own key must be the one both bodies carry. mode0
// writes it after its fixed 0x47-byte actor state and mode1 after its 21-byte
// prefix; validateCurrentPartyActorProjectionRows is the same contract the
// selected actor's own cached projection is held to. Without this check a body
// built from the wrong record is indistinguishable from a correct one on the
// wire -- the fields this function logs are its arguments, not the encoded
// bytes -- and the client resolves the record purely by the key inside it.
if _, _, identityErr := validateCurrentPartyActorProjectionRows(charID, mode0Body, mode1Body); identityErr != nil {
s.logGameEvent(target, "game-town-copresence-actor-identity-rejected",
"source", source,
"target_char_id", target.selectedCharacterID,
"actor_char_id", charID,
"remote_owner", remoteOwner,
"mode0_body_len", len(mode0Body),
"mode1_body_len", len(mode1Body),
"error", identityErr)
return fmt.Errorf("remote town actor identity rejected: %w", identityErr)
}
s.logPacketEvent("game-town-copresence-actor-state-send",
"conn_id", target.connID,
"channel_id", target.channel.ID,
"source", source,
"target_char_id", target.selectedCharacterID,
"target_local_owner", currentTownActorOwnerContext(target),
"actor_char_id", charID,
"object_key", currentSceneActorObjectKey(charID),
"remote_owner", remoteOwner,
"actor_job", numericCharacterStat(character.Job),
"actor_grow_type", numericCharacterStatValue(character, "grow_type"),
"actor_level", character.Level,
"mode0_body_len", len(mode0Body),
"mode1_body_len", len(mode1Body),
"body_source", "current_exe_mode0_create_then_mode1_bind_then_same_owner_op9_party_state_before_area_state")
sendStarted := time.Now()
if err := s.sendGameUpperRawClass(
target,
uint16(dnfenum.CmdPacketSetUDPIPPort),
mode0Body,
0,
); err != nil {
return fmt.Errorf("send remote town actor mode0: %w", err)
}
if withBinding {
if err := s.sendGameUpperRawClass(
target,
uint16(dnfenum.CmdPacketSetUDPIPPort),
mode1Body,
0,
); err != nil {
return fmt.Errorf("send remote town actor mode1: %w", err)
}
}
// Current EXE sub_1D64CA0 kind0 does not create an actor. Resolve and send
// the actor's actual runtime party state only after mode0 globally created
// and mode1 bound the remote object. An empty state clears stale join-party
// menu state; an explicitly created solo party remains joinable.
actorParty := runtimePartyStateSnapshot(actor.Session)
if _, err := s.sendCurrentPartyActorFrameProjection(
target,
actor.Session,
actorParty,
"town_copresence_actor_initial_party_state",
); err != nil {
return fmt.Errorf("send remote town actor op9 display: %w", err)
}
// When the observer and the actor fight in the same ordinary party, that record IS
// the observer's own party model, and op153 is the only carrier of the per-member
// HP/MP rows (party.BuildSingleMemberRealtimeInfo). Ending the observer's frame on
// it leaves the party HUD health bars empty -- which is what the operator sees
// whenever a squadmate re-enters the area: entering a raid gate, returning to town,
// or a phase-end forced return all run this path. Live 2026-09-20T13:16:52Z, conn
// game-000169 (char 1 entering 224): three consecutive op9 records for chars
// 20/22/21 with party_model_key=4 and no op153 behind any of them. Send the
// observer its OWN rows, from the observer's own state -- never the actor's.
if observerParty := runtimePartyStateSnapshot(target); observerParty.PartyID > 0 &&
observerParty.PartyID == actorParty.PartyID {
if err := s.sendRuntimePartyRealtimeInfoLocal(target, observerParty); err != nil {
return fmt.Errorf("send remote town actor op153 realtime rows: %w", err)
}
}
// 累计「发包」耗时,与 noteBuild 一起回答那 4.3 秒究竟在构建还是在投递。
bodiesCache.noteSend(time.Since(sendStarted))
return nil
}
// sendTownRemoteActorAppearanceRefresh repaints one already-created peer actor in
// the target client's town scene. It is sendTownRemoteActorState without the party
// frame: mode0 is rebuilt from the repository in the same remote owner context the
// actor was created in, with the actor-state initialization event suppressed
// because the actor already exists, and mode1 re-binds it with its equipment.
//
// guild is the ACTOR's guild, already resolved by the caller. It is a parameter
// rather than a lookup here because this runs once per (receiver, actor) ordered
// pair: resolving it inside turned the projection quadratic in repository reads,
// which stress_raid_projection_test.go caught. Nil means the actor is in no guild
// and the aura block is skipped (a fresh nonmember already carries the native -1
// insignia indices).
func (s *Service) sendTownRemoteActorAppearanceRefresh(
target *gameSession,
actor onlinePlayerInfo,
guild *dnfguild.Guild,
source string,
) error {
if target == nil || target.conn == nil || actor.Session == nil || actor.CharacterID == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
charID, charName, character, hasCharacter := s.characterForEnter(
ctx,
actor.Session,
actor.CharacterID,
)
if charID != actor.CharacterID || !hasCharacter {
return fmt.Errorf(
"remote actor appearance record unavailable: requested=%d loaded=%d found=%t",
actor.CharacterID,
charID,
hasCharacter,
)
}
remoteOwner := currentTownRemoteActorOwnerContext(target, charID)
if remoteOwner == currentSceneObjectContext {
return fmt.Errorf("remote town actor owner channel is not committed")
}
// 对端 actor 的 mode-0 行必须清掉副职业字段(同 sendTownRemoteActorProjectionWithContext):
// 观看者客户端是"从 0 新建"这条记录,带非零副职业字节会被当成"刚获得"并播获得特效。
// 这条是**穿脱装备时的对端重绘**,因此现场表现为"穿脱勋章时他人视角出现一次特效"
//(2026-10-08)。
mode0Body, err := s.buildCurrentSceneObjectListBodyForSessionInContextStrict(
ctx,
actor.Session,
charID,
charName,
character,
hasCharacter,
remoteOwner,
)
if err != nil {
return fmt.Errorf("build remote actor appearance mode0: %w", err)
}
if !suppressCurrentSceneObjectActorStateEvent(mode0Body) {
return fmt.Errorf("remote actor appearance mode0 could not suppress actor-state event")
}
adventureSummary := s.currentAccountAdventureGroupSummaryForPacket(
ctx,
actor.Session,
character,
hasCharacter,
)
mode1Body := s.buildCurrentActorBindingMode1BodyForSelectedWithEquipmentInContext(
ctx,
actor.Session,
character,
hasCharacter,
charID,
uint32(adventureSummary.ManageLevel),
remoteOwner,
true,
)
s.logPacketEvent("game-town-actor-appearance-refresh-send",
"conn_id", target.connID,
"channel_id", target.channel.ID,
"source", source,
"target_char_id", target.selectedCharacterID,
"actor_char_id", charID,
"remote_owner", remoteOwner,
"mode0_body_len", len(mode0Body),
"mode1_body_len", len(mode1Body),
"body_source", "character_equipment_repository_existing_actor_mode0_event_suppressed_then_equipment_mode1")
if err := s.sendGameUpperRawClass(
target,
uint16(dnfenum.CmdPacketSetUDPIPPort),
mode0Body,
0,
); err != nil {
return fmt.Errorf("send remote actor appearance mode0: %w", err)
}
if err := s.sendGameUpperRawClass(
target,
uint16(dnfenum.CmdPacketSetUDPIPPort),
mode1Body,
0,
); err != nil {
return fmt.Errorf("send remote actor appearance mode1: %w", err)
}
// The peer now has this actor, so replay its guild insignia decoration. The
// aura rides the actor appearance block rather than the guild window, and the
// peer built this actor from scratch, so without it the newcomer renders with
// no insignia until something else rebuilds the actor. guild is a parameter
// because this runs once per (receiver, actor) ordered pair: resolving it here
// turned the projection quadratic in repository reads.
if guild != nil {
if err := s.writeCurrentGuildAuraState(target, charID, guild, remoteOwner); err != nil {
return fmt.Errorf("send remote town actor guild aura: %w", err)
}
}
return nil
}
// broadcastTownActorAppearanceRefresh repaints this player's actor on every client
// standing in the same town area after the player's own appearance changed.
//
// The wardrobes/equipment refresh only ever repainted the owner's own client
// (PostActionRefreshSelectedActorAppearance -> sendSelectedActorMode0AppearanceRefresh),
// so a peer kept drawing the old look until the owner re-entered the scene. The
// live 2026-09-12 20:04 run is exactly that: 1111 took the avatars off, B still
// saw them on, and only 1111's relogin (a fresh co-presence broadcast) made B
// repaint. Peers are re-sent the same appearance projection the co-presence path
// uses, which is what makes the change visible immediately.
func (s *Service) broadcastTownActorAppearanceRefresh(session *gameSession, source string) {
if s == nil || session == nil || s.onlinePlayers == nil {
return
}
charID := selectedCharacterID(session)
if charID == 0 {
return
}
self, found := s.onlinePlayers.PlayerForCharacter(charID)
if !found {
s.logGameEvent(session, "game-town-actor-appearance-refresh-skipped",
"char_id", charID,
"reason", "self_not_registered_in_town_area",
"source", source)
return
}
peers := s.onlinePlayers.PeersInSameArea(charID)
// The 公会徽章 decoration depends only on the ACTOR, so resolve it once for the
// whole fan-out instead of once per receiver.
auraCtx, auraCancel := s.currentGuildContext()
guild, guildErr := s.currentGuildForCharacterID(auraCtx, charID)
auraCancel()
if guildErr != nil {
s.logGameEvent(session, "game-town-actor-appearance-guild-lookup-failed",
"char_id", charID, "error", guildErr)
guild = nil
}
sent := 0
for _, peer := range peers {
if peer.Session == nil || peer.CharacterID == 0 || peer.CharacterID == charID {
continue
}
if err := s.sendTownRemoteActorAppearanceRefresh(peer.Session, self, guild, source); err != nil {
s.logGameEvent(session, "game-town-actor-appearance-refresh-failed",
"peer_char_id", peer.CharacterID,
"error", err)
continue
}
sent++
}
s.logGameEvent(session, "game-town-actor-appearance-refresh",
"char_id", charID,
"peer_count", len(peers),
"sent", sent,
"source", source)
}
// broadcastTownPlayerEnter sends each remote actor's current typed mode0,
// equipment-bearing mode1, same-owner op9 display registration, then
// incremental 0x0017 state. The town transition owner has already sent one
// complete authoritative op24 packet; replaying a second self-containing op24
// here rebinds the local actor and breaks camera ownership.
func (s *Service) broadcastTownPlayerEnter(newPlayer *onlinePlayerInfo, others []onlinePlayerInfo) {
if s == nil || s.onlinePlayers == nil || newPlayer == nil || newPlayer.Session == nil {
return
}
// 两个循环都是 (接收者, 演员) 扇出:第一个循环把同一个新人的投影发给 N-1 个
// 接收者,第二个循环把 N-1 个演员的投影发给同一个接收者。投影只由演员自己的
// 角色 ID 决定,所以这次扇出期间挂一个只读缓存,每个演员只解析一次。
ctx, _ := withProjectionReadCache(context.Background())
// ★ 补上 body 缓存。兄弟路径 sendChannelWidePeerActors 早就挂了两层
// (:726-727),这里此前只挂了读缓存 —— 这就是「B 进区域 0,A 也跟着卡一下」
// 那一条路径的漏洞。
//
// 关键在于下面【第一个循环】:它的演员恒为 *newPlayer,接收者却换 P−1 次,
// 于是同一个人的整份投影(mode0 外观表 + mode1 的 ~31 行装备 + 一次 PVF 属性
// 重算)被从头重算 P−1 遍。body 缓存的键是 (演员 charID, withBinding),
// 所以这里正好是 1 次构建 + P−2 次命中。
//
// 缓存生命周期仍然只有这一次调用,过期窗口是微秒级,不引入任何新的失效责任
// (服务级长生命周期方案已被否决:写侧没有卡点,且有 4 处墙上时钟会翻转投影)。
ctx = withActorProjectionBodiesCache(ctx)
peers := append([]onlinePlayerInfo(nil), others...)
sort.Slice(peers, func(i, j int) bool {
return peers[i].CharacterID < peers[j].CharacterID
})
actorKey := currentSceneActorObjectKey(newPlayer.CharacterID)
// To each existing player: newcomer's mode0/mode1/op9, then 0x0017.
//
// NOTE 2026-09-26: 试过把这里的 0x0017 换成 0x0016,被三个钉死契约的用例挡回
// (town_move_test.go:697/715 明确断言 peer 收到 currentTownUserAreaNotificationMsgID
// 且 body >= 10 字节、Body[3] != 0)。而且 town_move.go:341 / assault.go:208 /
// party_town_return.go:77 都在给 peer 发 0x0017 —— 它是成体系的既有设计,
// 不是疏忽。故保留原样。
enterBody := buildCurrentTownUserAreaNotificationBody(
actorKey, newPlayer.TownID, newPlayer.AreaID,
newPlayer.PositionX, newPlayer.PositionY,
newPlayer.Direction, newPlayer.AreaState,
)
for i := range peers {
if peers[i].Session == nil {
continue
}
if err := s.sendTownRemoteActorRecordWithContext(
ctx,
peers[i].Session,
*newPlayer,
"town_copresence_actor_state_newcomer_to_existing",
); err != nil {
s.logGameEvent(peers[i].Session, "game-town-copresence-actor-state-failed",
"source", "town_copresence_actor_state_newcomer_to_existing",
"actor_char_id", newPlayer.CharacterID,
"error", err)
continue
}
_ = s.sendCurrentSceneFixedClass0Packet(peers[i].Session,
currentTownUserAreaNotificationMsgID, enterBody,
"town_copresence_enter_0x0017_to_existing")
}
// To newcomer: each existing player's mode0/mode1/op9, then 0x0017.
for i := range peers {
if peers[i].Session == nil {
continue
}
if err := s.sendTownRemoteActorRecordWithContext(
ctx,
newPlayer.Session,
peers[i],
"town_copresence_actor_state_existing_to_newcomer",
); err != nil {
s.logGameEvent(newPlayer.Session, "game-town-copresence-actor-state-failed",
"source", "town_copresence_actor_state_existing_to_newcomer",
"actor_char_id", peers[i].CharacterID,
"error", err)
continue
}
otherKey := currentSceneActorObjectKey(peers[i].CharacterID)
otherBody := buildCurrentTownUserAreaNotificationBody(
otherKey, peers[i].TownID, peers[i].AreaID,
peers[i].PositionX, peers[i].PositionY,
peers[i].Direction, peers[i].AreaState,
)
_ = s.sendCurrentSceneFixedClass0Packet(newPlayer.Session,
currentTownUserAreaNotificationMsgID, otherBody,
"town_copresence_enter_0x0017_existing_to_newcomer")
}
// 探针:把这次城镇共现扇出的缓存命中/未命中与 build/send 耗时打出来。
// 第一循环理想值 = hit (P−2) / miss 1;若 miss 接近 P−1,说明键又被打散了。
if bodiesCache := actorProjectionBodiesCacheFromContext(ctx); bodiesCache != nil {
bodiesCache.logSummary(s, "town_copresence_player_enter")
}
}
// sendTownRemotePartyActors restores party members that are online in another
// town area after SET_USER_AREA rebuilt the receiving client's actor manager.
// Current sub_1D64CA0's same-owner party rows contain only user ids, so every
// referenced user must first exist through the normal mode0/mode1/op9 actor
// path. The following op23 carries the member's real town/area and position;
// it does not claim co-presence in the receiver's destination area.
func (s *Service) sendTownRemotePartyActors(receiver *gameSession, state alignedcmd.PartyState) error {
if s == nil || s.onlinePlayers == nil || receiver == nil || receiver.selectedCharacterID == 0 || state.PartyID <= 0 {
return nil
}
for _, member := range runtimePartyMembers(state) {
if member.UserID == 0 || member.UserID == receiver.selectedCharacterID ||
s.onlinePlayers.PeerInSameArea(receiver.selectedCharacterID, member.UserID) {
continue
}
actor, ok := s.onlinePlayers.PlayerForCharacter(member.UserID)
if !ok || actor.Session == nil {
continue
}
if err := s.sendTownRemoteActorState(receiver, actor, "town_remote_party_actor_after_set_user_area"); err != nil {
return err
}
areaBody := buildCurrentTownUserAreaNotificationBody(
currentSceneActorObjectKey(actor.CharacterID),
actor.TownID,
actor.AreaID,
actor.PositionX,
actor.PositionY,
actor.Direction,
actor.AreaState,
)
if err := s.sendCurrentSceneFixedClass0Packet(
receiver,
currentTownUserAreaNotificationMsgID,
areaBody,
"town_remote_party_actor_real_area_after_state",
); err != nil {
return err
}
}
return nil
}
// broadcastTownPlayerLeave sends NOTI 0x0006 (u16 userId) to remaining
// players so they remove the departed player's town actor.
func (s *Service) broadcastTownPlayerLeave(characterID uint16, others []onlinePlayerInfo) {
if s == nil || s.onlinePlayers == nil || len(others) == 0 {
return
}
leaveBody := buildTownUserLeaveBody(characterID)
for i := range others {
if others[i].Session != nil {
_ = s.sendCurrentSceneFixedClass0Packet(others[i].Session,
currentTownUserLeaveMsgID, leaveBody,
"town_copresence_leave_0x0006")
}
}
}
// broadcastTownPlayerAreaChange removes an ordinary old-area actor, but keeps
// an active party member bound to the stationary client's party manager. The
// current client treats op6 as both a town-actor destroy and a party-member
// removal. For party peers the native op23 area update is sufficient: it moves
// the existing actor out of the rendered area while retaining the roster and
// minimap identity. Recreating that actor after op6 is unsafe because the
// client answers the synthetic cross-area rebuild with LEAVE_PARTY.
func (s *Service) broadcastTownPlayerAreaChange(
mover *onlinePlayerInfo,
oldOthers []onlinePlayerInfo,
state alignedcmd.PartyState,
) {
if s == nil || mover == nil || len(oldOthers) == 0 {
return
}
partyMembers := make(map[uint16]struct{}, len(runtimePartyMembers(state)))
if state.PartyID > 0 {
for _, member := range runtimePartyMembers(state) {
if member.UserID != 0 && member.UserID != mover.CharacterID {
partyMembers[member.UserID] = struct{}{}
}
}
}
leaveBody := buildTownUserLeaveBody(mover.CharacterID)
areaBody := buildCurrentTownUserAreaNotificationBody(
currentSceneActorObjectKey(mover.CharacterID),
mover.TownID,
mover.AreaID,
mover.PositionX,
mover.PositionY,
mover.Direction,
mover.AreaState,
)
for i := range oldOthers {
peer := oldOthers[i].Session
if peer == nil {
continue
}
_, sameAuthoritativeParty := partyMembers[oldOthers[i].CharacterID]
peerState := runtimePartyStateSnapshot(peer)
if sameAuthoritativeParty && peerState.PartyID == state.PartyID {
_ = s.sendCurrentSceneFixedClass0Packet(
peer,
currentTownUserAreaNotificationMsgID,
areaBody,
"town_party_member_area_change_0x0017",
)
continue
}
_ = s.sendCurrentSceneFixedClass0Packet(
peer,
currentTownUserLeaveMsgID,
leaveBody,
"town_copresence_leave_0x0006",
)
}
}
// broadcastTownPlayerMove sends NOTI 0x0016 (sub_1D83990 position update) to
// all other players in the area.
//
// movementRate is the mover's own reported town movement rate (op35's final
// u16, the client's actor+0x748 scaled by 100). It must be forwarded verbatim:
// the receiving client applies it to the remote actor, so dropping it back to
// the 100 initialization value makes a boosted character look normal-speed on
// every other screen while it still moves correctly on its own.
func (s *Service) broadcastTownPlayerMove(mover *onlinePlayerInfo, movementRate uint16, others []onlinePlayerInfo) {
if s == nil || s.onlinePlayers == nil || mover == nil || len(others) == 0 {
return
}
actorKey := currentSceneActorObjectKey(mover.CharacterID)
moveBody := buildCurrentTownUserPositionNotificationBody(
actorKey, mover.PositionX, mover.PositionY, mover.Direction, movementRate,
)
// 建包与两次运行时命令查表都只取决于 (msgID, body),对同场景的每个收件人完全一样,
// 所以只做一次再逐个发送。改前是逐个收件人重复走一遍完整发送路径:在 P 个人的
// 场景里,每个 op35 都要白做 P-1 次分配与 2(P-1) 次查表(建包结果完全相同)。
// 构建失败时与改前等价:输入相同,改前也是每个收件人都失败、且错误同样被忽略。
reusable, err := buildReusableCurrentSceneFixedClass0Packet(
currentTownUserPositionNotificationMsgID,
moveBody,
"town_copresence_move_0x0016",
)
if err != nil {
return
}
for i := range others {
if others[i].Session != nil {
_ = s.sendReusableCurrentSceneFixedClass0Packet(others[i].Session, reusable)
}
}
}
// cleanupOnlinePlayer removes a player from the online player manager on
// disconnect and broadcasts the leave notification to remaining players.
func (s *Service) cleanupOnlinePlayer(session *gameSession) {
if s == nil || s.onlinePlayers == nil || session == nil || session.selectedCharacterID == 0 {
return
}
s.closeCurrentExpertJobStoreSession(session, false)
s.closeCurrentPrivateStoreSession(session, false)
departing, hadDeparting := s.onlinePlayers.PlayerForCharacter(session.selectedCharacterID)
_, others, removed := s.onlinePlayers.LeaveAreaSession(session.selectedCharacterID, session)
// "Left the game" does not depend on town co-presence. A character that
// published its social presence from the private Seria room route never
// enters the area registry at all, so both the lookup above and the removal
// come back empty for it — and gating the announcement on either is why a
// guild mate saw the login line but never the logout one.
departingName := ""
if hadDeparting {
departingName = departing.Name
}
s.announceCurrentSocialOffline(session, departingName, "game_session_disconnect")
s.publishCurrentGuildPresence(session.selectedCharacterID, "game_session_disconnect")
// A 1:1 window dies with the session that owned it: the 86 reference registers
// exactly this on its SessionEnding event. The peer is not told here -- the
// reference's LEAVE_USER_FROM_GROUP is its own notification type and the 90CN
// client's equivalent is still unidentified -- so the peer's window stays open
// and simply stops receiving lines.
for _, conversation := range s.closeCurrentChatConversationsForSession(session) {
s.logGameEvent(session, "game-peer-chat-conversation-closed",
"conversation_id", conversation.id,
"source_char_id", session.selectedCharacterID,
"reason", "game_session_disconnect",
"peer_notice", "unsupported_by_current_client")
}
if !removed {
return
}
if len(others) > 0 {
s.broadcastTownPlayerLeave(session.selectedCharacterID, others)
}
}
// announceCurrentSocialOffline tells the friend list and the guild the
// character is gone. The name falls back to the repository because the caller's
// area snapshot is empty for exactly the sessions this has to cover.
func (s *Service) announceCurrentSocialOffline(session *gameSession, name string, source string) {
characterID := session.selectedCharacterID
if characterID == 0 {
return
}
if strings.TrimSpace(name) == "" {
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
loadedID, loadedName, _, found := s.characterForEnter(ctx, session, characterID)
if !found || loadedID != characterID {
s.logGameEvent(session, "game-social-offline-name-unavailable", "char_id", characterID, "source", source)
return
}
name = loadedName
}
notified := s.notifyCurrentUnitedFriendPresence(name, currentFriendPresence{}, source+"_friend_offline")
s.notifyCurrentGuildMemberPresence(characterID, name, currentFriendPresence{}, source+"_guild_offline", notified)
}