package dnfbridge import ( "context" "fmt" "strconv" "strings" dnfenum "longheng.io/server/internal/modules/dnf/dnfenum" ) // 本文件是「支援兵能否用现有客户端渲染」的可行性实验。 // // 背景 // 支援兵的官方实现(Server-90 的 client-patch)把渲染逻辑**补丁进 DB4 客户端**, // 而本部署的客户端是另一个 EXE 版本(8A7AF614…),那些硬编码地址全都对不上。 // // 但现有服务端里已经有一条「服务端创建 actor → 客户端渲染另一个玩家角色」的通道: // sendTownRemoteActorRecord / sendTownRemoteActorProjection // 它的注释明确写着这是「**正在渲染副本实例的客户端**收到的形态」, // mode0 是客户端 user table 解析同伴名字/等级/头像的依据。 // // 唯一挡路的是 sendTownRemoteActorProjectionWithContext 开头那道门: // if ... || actor.Session == nil || actor.CharacterID == 0 { return nil } // 「actor 必须是一个真实在线玩家」。支援兵是离线角色,所以走不通。 // // 本实验绕过那道门,直接复用同一批构造器与同一套发包顺序: // mode0(创造 actor)→ mode1(绑定装备/技能)→ op9(组队框) // 用**接收者自己的会话**当账号上下文,把另一个角色当成远端 actor 下发。 // // 目的只有一个:观察客户端会不会把它画出来。 // 这不是支援兵的最终实现 —— 只是决定路线(纯服务端 vs 必须换客户端)的判据。 // GMCompanionRenderExperimentForCharacter 按在线角色 id 触发渲染实验。 // // 供 admin 端点使用:只有该角色**当前在线**才能下发 actor 包 // (包必须投递到一个活的连接上)。 func (s *Service) GMCompanionRenderExperimentForCharacter( ctx context.Context, characterID string, companionCharacterID uint16, withBinding bool, ownerChannelOverride int, ) error { targetID, err := gmNumericCharacterID(characterID) if err != nil { return err } target, online := s.onlineGameSession(targetID) if !online || target == nil { return fmt.Errorf("%w: character %d is not online; the actor packet needs a live connection", ErrGMRequestInvalid, targetID) } return s.GMCompanionRenderExperiment(ctx, target, companionCharacterID, withBinding, ownerChannelOverride) } // // GMCompanionRenderExperiment 把一个离线角色作为远端 actor 下发到目标会话的场景。 // // ownerChannelOverride < 0 时用 currentTownRemoteActorOwnerContext(= 频道 ID)。 // 现场 2026-10-17 00:25 实测:用频道 ID(11) 时客户端日志里 // mode0-owner-compare scene_channel=11 local_owner_channel=11 // branch=current-scene-create // 也就是说 owner 与本地 actor 相同,客户端把它当成"重建本地角色"而不是 // "另一个角色"。所以这个参数用来试不同的 owner 值。 func (s *Service) GMCompanionRenderExperiment( ctx context.Context, target *gameSession, companionCharacterID uint16, withBinding bool, ownerChannelOverride int, ) error { if s == nil || target == nil || target.conn == nil { return fmt.Errorf("%w: companion experiment target session is missing", ErrGMUnavailable) } if companionCharacterID == 0 { return fmt.Errorf("%w: companion character id is required", ErrGMRequestInvalid) } if target.selectedCharacterID == companionCharacterID { return fmt.Errorf("%w: companion must differ from the local character", ErrGMRequestInvalid) } if ctx == nil { ctx = context.Background() } ctx, cancel := context.WithTimeout(ctx, createWriteTimeout) defer cancel() repositories, ok := s.repositoryGroup() if !ok || repositories.Character == nil { return ErrGMUnavailable } characterID := strconv.FormatUint(uint64(companionCharacterID), 10) companion, found, err := repositories.Character.Load(ctx, characterID) if err != nil { return fmt.Errorf("load companion character %d: %w", companionCharacterID, err) } if !found { return fmt.Errorf("%w: companion character %d is missing", ErrGMRequestInvalid, companionCharacterID) } name := strings.TrimSpace(companion.Name) if name == "" { name = csharpSelectCharacterName(companionCharacterID) } // ownerChannel:同伴必须与本地受控角色处在同一张场景表里, // 但又不能与本地 actor 用同一个 owner —— 否则客户端会把它当成 // "重建本地角色"(现场 00:25 日志就是这样)。 remoteOwner := currentTownRemoteActorOwnerContext(target, companionCharacterID) if ownerChannelOverride >= 0 && ownerChannelOverride <= 0xff { remoteOwner = byte(ownerChannelOverride) } if remoteOwner == currentSceneObjectContext && ownerChannelOverride < 0 { return fmt.Errorf("%w: scene owner channel is not committed", ErrGMUnavailable) } mode0Body, err := s.buildCurrentSceneObjectListBodyForSessionInContextStrict( ctx, target, companionCharacterID, name, companion, true, remoteOwner, ) if err != nil { return fmt.Errorf("build companion mode0 body: %w", err) } if len(mode0Body) == 0 { return fmt.Errorf("%w: companion mode0 body is empty", ErrGMUnavailable) } var mode1Body []byte if withBinding { adventureSummary := s.currentAccountAdventureGroupSummaryForPacket( ctx, target, companion, true) mode1Body = s.buildCurrentActorBindingMode1BodyForSelectedWithEquipmentInContext( ctx, target, companion, true, companionCharacterID, uint32(adventureSummary.ManageLevel), remoteOwner, true, ) if len(mode1Body) == 0 { return fmt.Errorf("%w: companion mode1 body is empty", ErrGMUnavailable) } } // 身份校验:客户端只按 body 里的 object key 解析记录, // 传错角色的 body 与正确的在线上字节无法区分 —— 所以必须在这里挡住。 if _, _, identityErr := validateCurrentPartyActorProjectionRows(companionCharacterID, mode0Body, mode1Body); identityErr != nil { s.logGameEvent(target, "companion-render-experiment-identity-rejected", "companion_character_id", companionCharacterID, "error", identityErr) return fmt.Errorf("companion actor identity rejected: %w", identityErr) } // 与城镇共现完全相同的发包顺序。 if err := s.sendGameUpperRawClass(target, uint16(dnfenum.CmdPacketSetUDPIPPort), mode0Body, 0); err != nil { return fmt.Errorf("send companion mode0: %w", err) } if len(mode1Body) > 0 { if err := s.sendGameUpperRawClass(target, uint16(dnfenum.CmdPacketSetUDPIPPort), mode1Body, 0); err != nil { return fmt.Errorf("send companion mode1: %w", err) } } // op9:让同伴在组队框里有行,客户端才会给它建可见 actor。 if _, err := s.sendCurrentPartyActorFrameProjection( target, target, runtimePartyStateSnapshot(target), "companion_render_experiment_party_frame", ); err != nil { return fmt.Errorf("send companion op9 display: %w", err) } s.logGameEvent(target, "companion-render-experiment-sent", "companion_character_id", companionCharacterID, "companion_name", name, "local_character_id", target.selectedCharacterID, "owner_channel", remoteOwner, "mode0_bytes", len(mode0Body), "mode1_bytes", len(mode1Body), "with_binding", withBinding, ) return nil }