using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.Handlers; using Ananta.Server.Gameplay; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// World entry and client-driven scene loading barriers. internal sealed partial class GameRouter { WorldEntryState GetWorldState(RpcContext ctx) { if (ctx.Session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing) return existing; var created = new WorldEntryState(); // 改装件读盘(开关 repairShop.persistTuning,默认关)。只在 state 首次创建时读一次, // 之后由 AskBuyModifyParts 直接写内存并在每次购买后落盘。 VehicleTuningStore.Load(Profile.PlayerPid, created); ctx.Session.Items[WorldStateKey] = created; return created; } void ArmWorldEntry4229938(WorldEntryState state) { lock (state.SyncRoot) { var next = state.WorldEntryControlGeneration + 1; if (next <= 0) next = 1; state.WorldEntryControlGeneration = next; state.WorldEntryControlPending = true; state.WorldEntryControlFinalized = false; state.WorldEntryControlUnit = Profile.InitialUnitId; state.WorldEntryControlTemplate = Profile.InitialSpiritTemplateId; state.WorldEntryLoadingCompletedGeneration = 0; state.WorldEntryOpeningEndedGeneration = 0; state.WorldEntryControlFinalizingGeneration = 0; state.WorldEntryLogicProjectionPublishedGeneration = 0; state.WorldEntryCurrentMetadataPublishedGeneration = 0; state.WorldEntrySceneId4229938 = 0; state.WorldEntrySessionId4229938 = 0; // ★ R101:首次进世界不预设 scene(任何 ack 都接受,保持原行为)。 state.WorldEntryExpectedSceneId = 0; state.WorldEntryCreateHeroPosition = Profile.WorldSpawn; state.WorldEntryCreateHeroFacing = Profile.WorldFacing; state.LastReportedPlayerPosition = Profile.WorldSpawn; state.LastReportedPlayerRotation = new Vec3(0f, Profile.WorldFacing, 0f); state.HasLastReportedPlayerTransform = false; state.LastSwitchShowId = 0; state.AllBuildBuffsPublished = false; state.MapFogUnlockPublished = false; state.PlayerTasksPublished = false; state.InitialActorPresentationPublished = false; state.ActiveSpiritUnitId = Profile.InitialUnitId; state.ActiveSpiritTemplateId = Profile.InitialSpiritTemplateId; // ★ 登录时操控的那个角色也算「被玩家操控过」—— 第一次切走他时同样不去过日子, // 停在玩家离开他的地方(WorldEntryState.ControlledSpirits)。 state.MarkSpiritControlled(Profile.InitialSpiritTemplateId); state.WorldEntryIsAirportTravel = false; } } async Task SendInitialGameState(RpcContext ctx) { var state = GetWorldState(ctx); if (state.InitialStateSent) { ctx.Session.Log.Info("[WORLD-MIN] LoginGame repeat ignored"); return; } state.InitialStateSent = true; // Reload the persisted wallet/backpack/gacha counters before the login payload is built, // so money and items granted in a previous session are published with PlayerInfo. RestorePlayerProgress4229938(state, ctx.Session.Log); await ctx.NotifyAsync(MethodId.SendServerTimeGame, LoginCodec.ServerTime()); // ⛑ 搜打撤的背包必须在登录帧构造之前建好:PlayerExtractionShooterInfo 就在 // SyncPlayerInfo 那一帧里走,晚一步这一帧就是空的,而客户端不会为此再问一次。 // 只是往 session.Items 里塞几个空包 —— 零推送,唯一开销是那 11 张 json 的 Lazy 解析。 var extraction = EnsureExtractionSeeded(ctx.Session); await ctx.NotifyAsync(MethodId.SyncPlayerInfo, RuntimePayloadFactory.MinimalPlayerInfo4229938(state, extraction)); // OSS 图床配置不再等到玩家打开趴卜才推。眼界 (Tuite) 的配图走同一个 AliOssManager // (SocialNetWorkUtils.lua:2202 store.bucketName = GetOssPostImage(unpack(imageList[1].imageURL)) // / YanjieNewDetailPagePanel.lua:494 SetOssObjectWithCallback),但眼界**没有任何拉列表的 // RPC** —— 列表全在登录帧的 PlayerTuiteInfo.TuiteList 里,所以不会像趴卜那样有一个 // AskMomentsPostSimpleInfos 可以挂在后面推配置。先开眼界再开趴卜的玩家会一直拿不到 // bucket/endpoint,配图全部静默失败。登录帧之后推一次就两种 app 都覆盖了。 await PublishOssBucketInfoOnceAsync(new Connection(ctx.Session, ctx.CancellationToken), ctx.Session); await PublishVehicleOwnership(ctx); // ⛑️ grant-all 已从 LoginGame 关键路径移除(2026-09-23)。 // 它一次推 671 个 SyncArmoryAddWeapon(batchSize=25 + 10ms),会把 LoginGame handler // 占住几百毫秒;客户端重连时 RequestPlayerStopHangup 因此 5 秒收不到应答 // ⇒ Reconnect Module 12 秒硬超时(console-20260922-234914.log 里 19 分钟 LoginGame 9 次)。 // 现在只在场景加载完成后发 —— 见下方 OnLoadingFinished 里的 PublishAllAccountWeaponsIfEnabled。 // 语义不变:每次场景加载仍重放一次(OnLoadingFinished 里的 ResetAllWeaponsSentFlag 照常重置闸门), // 但一次进世界的包数从 1342 降到 671。代价:加载画面那几秒武器不在,无人可感。 await SendEnterSceneIfNeeded(ctx); } async Task SendEnterSceneIfNeeded(RpcContext ctx) { var state = GetWorldState(ctx); if (state.EnterSceneSent) return; state.EnterSceneSent = true; ArmWorldEntry4229938(state); state.WorldEntrySwitchShowId = 0; // Replay where the player was standing when the snapshot was written — including the scene // they were in. A coordinate captured in an unresolvable scene (or a hand-edited one) would // drop them out of the world, so those still keep the configured spawn. // // ⛑⛑ 2026-09-27 修:判据原来是「存档 raid 必须等于配置主城」,于是只要上一次是离开主城 // 退出的(GM 面板切到 23300999 凌云/重霄最典型),这里恒不成立 ⇒ 人掉回配置出生点。 // 存档现在把 raid/instance/universe 一起带出来(见 PlayerProgressStore), // 这里照原样进那张图 + 那个点。 var spawnPosition = Profile.WorldSpawn; var spawnFacing = Profile.WorldFacing; var spawnRaidId = Profile.RaidId; var spawnInstanceId = Profile.SceneInstanceId; var spawnUniverseId = Profile.UniverseId; var saved = state.Progress; if (saved.HasSavedPosition && saved.SavedRaidId != 0 && !GameRouter.IsExtractionRaid(saved.SavedRaidId) && TryResolveSavedScene(saved.SavedRaidId, saved.SavedInstanceId, saved.SavedUniverseId, out var savedInstanceId, out var savedUniverseId)) { spawnPosition = new Vec3(saved.SavedPositionX, saved.SavedPositionY, saved.SavedPositionZ); spawnFacing = saved.SavedFacing; spawnRaidId = saved.SavedRaidId; spawnInstanceId = savedInstanceId; spawnUniverseId = savedUniverseId; ctx.Session.Log.Info( $"[WORLD-MIN] restored position raid={spawnRaidId} instance={spawnInstanceId} " + $"universe={spawnUniverseId} " + $"pos=({spawnPosition.X:0.##},{spawnPosition.Y:0.##},{spawnPosition.Z:0.##}) facing={spawnFacing:0.##}"); } else if (saved.HasSavedPosition) { ctx.Session.Log.Warn( $"[WORLD-MIN] saved position dropped (raid={saved.SavedRaidId} instance={saved.SavedInstanceId}) " + $"— no scene to replay it in, falling back to the configured spawn"); } // Keep the session's notion of "where am I" in step with the scene we actually entered, // otherwise the next throttled position save would write the main-city raid next to a // coordinate that belongs to another map. state.ActiveRaidId = spawnRaidId; state.ActiveInstanceId = spawnInstanceId; state.ActiveUniverseId = spawnUniverseId; state.ActiveContentScene = spawnRaidId == Profile.RaidId ? "WorldMap_Release" : $"raid-{spawnRaidId}"; state.WorldEntryCreateHeroPosition = spawnPosition; state.WorldEntryCreateHeroFacing = spawnFacing; // Minimal mode intentionally does not ask the client to play an authored opening Timeline. // Scene assets and native quest/story systems remain entirely client-owned. await ctx.NotifyAsync(MethodId.SyncEnterScene, RuntimePayloadFactory.EnterScene( spawnRaidId, spawnInstanceId, spawnUniverseId, Profile.InitialUnitId, Profile.InitialSpiritTemplateId, spawnPosition, spawnFacing, switchShowId: 0, isSwitchSpiritShow: false)); ctx.Session.Log.Info($"[WORLD-MIN] enter generation={state.WorldEntryControlGeneration} raid={spawnRaidId} instance={spawnInstanceId} universe={spawnUniverseId} unit={Profile.InitialUnitId} template={Profile.InitialSpiritTemplateId} pos=({spawnPosition.X:0.##},{spawnPosition.Y:0.##},{spawnPosition.Z:0.##}) opening=false switchShow=0"); } /// /// 把存档里的 raid 补成一组能进的 (instance, universe)。 /// /// 2026-09-27:存档现在直接带 instance/universe;旧存档(06-27 之前写的)只有 raid, /// 这里按「主城用配置、其余查场景 preset」补齐。查不到 ⇒ false ⇒ 调用方放弃恢复, /// 走配置出生点 —— 宁可回出生点,也不要拿一个错误的 instance 进图。 /// private static bool TryResolveSavedScene( uint raidId, ulong instanceId, uint universeId, out ulong resolvedInstance, out uint resolvedUniverse) { if (instanceId != 0 && universeId != 0) { resolvedInstance = instanceId; resolvedUniverse = universeId; return true; } if (raidId == Profile.RaidId) { resolvedInstance = Profile.SceneInstanceId; resolvedUniverse = Profile.UniverseId; return true; } if (GameAdminBridge.TryGetScenePreset(raidId, out var preset) && preset is not null) { resolvedInstance = preset.InstanceId; resolvedUniverse = preset.UniverseId != 0 ? preset.UniverseId : Profile.UniverseId; return resolvedInstance != 0; } resolvedInstance = 0; resolvedUniverse = 0; return false; } async Task CommitWorldEntryCreateHeroData4229938(RpcContext ctx, int generation) { var state = GetWorldState(ctx); ulong unitId; uint templateId; ulong playerPid; Vec3 createHeroPosition; float createHeroFacing; string? reject = null; // Port the proven v7 preflight literally: validate the complete canonical identity and // CreateHero transform BEFORE claiming/sending the first authority packet. A failure after // LogicAgentEnter would leave an orphan authority entity, so this block is fail-closed. lock (state.SyncRoot) { if (!state.WorldEntryControlPending || state.WorldEntryControlFinalized) reject = "transaction-not-pending"; else if (generation <= 0 || generation != state.WorldEntryControlGeneration) reject = "generation-mismatch"; else if (Profile.PlayerPid == 0 || state.WorldEntryControlUnit == 0 || state.WorldEntryControlTemplate == 0) reject = "zero-identity"; else if (state.ActiveSpiritUnitId != state.WorldEntryControlUnit || state.ActiveSpiritTemplateId != state.WorldEntryControlTemplate) reject = "active-identity-mismatch"; // Login must land on the configured initial actor. Scene switches re-arm the // transaction on whichever character is currently active (flag set by rearm only). else if (!state.WorldEntryAllowNonInitialControl && (state.WorldEntryControlUnit != Profile.InitialUnitId || state.WorldEntryControlTemplate != Profile.InitialSpiritTemplateId)) reject = "profile-identity-mismatch"; else if (!ClientConfigRepository.Characters().Any(x => x.UnitId == state.WorldEntryControlUnit && x.TemplateId == state.WorldEntryControlTemplate)) reject = "canonical-roster-row-missing"; else if (!float.IsFinite(state.WorldEntryCreateHeroPosition.X) || !float.IsFinite(state.WorldEntryCreateHeroPosition.Y) || !float.IsFinite(state.WorldEntryCreateHeroPosition.Z) || !float.IsFinite(state.WorldEntryCreateHeroFacing)) reject = "non-finite-createhero-transform"; else if (state.WorldEntryLogicProjectionPublishedGeneration == generation && state.WorldEntryCurrentMetadataPublishedGeneration == generation) return true; else if (state.WorldEntryLogicProjectionPublishedGeneration != 0 || state.WorldEntryCurrentMetadataPublishedGeneration != 0) reject = "projection-already-claimed"; unitId = state.WorldEntryControlUnit; templateId = state.WorldEntryControlTemplate; playerPid = Profile.PlayerPid; createHeroPosition = state.WorldEntryCreateHeroPosition; createHeroFacing = state.WorldEntryCreateHeroFacing; if (reject is null) { // Both generation edges are claimed before any send. Partial failure remains // fail-closed; a duplicate callback must never replay half of the transaction. state.WorldEntryLogicProjectionPublishedGeneration = -generation; state.WorldEntryCurrentMetadataPublishedGeneration = -generation; } } if (reject is not null) { ctx.Session.Log.Warn($"[WORLD-V7] commit rejected generation={generation} reason={reject}"); return false; } // Exact proven order from the supplied v7 guide. No sleep, no presentation hydration, // no buffs and no weapon/fashion deltas may be interleaved into this quartet. await ctx.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(unitId)); await ctx.NotifyAsync(MethodId.SyncManagedLogicAgent, WorldCodec.ManagedLogicAgent(unitId, playerPid, 0)); await ctx.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit, RuntimePayloadFactory.ExistingUnitProjection(unitId, templateId, createHeroPosition, createHeroFacing, state)); lock (state.SyncRoot) { if (state.WorldEntryLogicProjectionPublishedGeneration == -generation) state.WorldEntryLogicProjectionPublishedGeneration = generation; } await ctx.NotifyAsync(MethodId.SyncPlayerCurrentSpirit, WorldCodec.CurrentSpirit(playerPid, templateId, unitId, isAgentSwitch: false)); lock (state.SyncRoot) { if (state.WorldEntryCurrentMetadataPublishedGeneration == -generation) state.WorldEntryCurrentMetadataPublishedGeneration = generation; state.CurrentSpiritStateSent = true; state.ControlPublished = true; } ctx.Session.Log.Info($"[HANDOFF-V7] generation={generation} exact=LogicAgentEnter->ManagedLogicAgent->sameID-AOI->CurrentSpirit unit={unitId} template={templateId} presentation=false buffs=false duplicateActor=false"); return true; } async Task OnLoadSceneCompleted(RpcContext ctx, ulong sceneId, ulong sessionId) { var state = GetWorldState(ctx); int generation = 0; string? reject = null; bool stale = false; lock (state.SyncRoot) { if (sceneId == 0 || sessionId == 0) reject = "invalid-scene-session"; else if (!state.WorldEntryControlPending || state.WorldEntryControlFinalized) reject = "transaction-not-pending"; else if (state.WorldEntrySceneId4229938 == 0 && state.WorldEntrySessionId4229938 == 0) { // ★ R101:切图后「上一张图迟到的 ack」往往比新图的先到。它一旦占了权威位, // 新图的 ack 就会被判 correlation-mismatch 永久拒绝 ⇒ world-entry 永不 // finalize ⇒ EnsureGadgetGrid 不跑 ⇒ 摊位/器材/补货/派对家具全都不摆。 // 有 expected 时只认它:不符的静默丢弃,既不占权威位也不报 mismatch。 if (state.WorldEntryExpectedSceneId != 0 && sceneId != state.WorldEntryExpectedSceneId) stale = true; else { state.WorldEntrySceneId4229938 = sceneId; state.WorldEntrySessionId4229938 = sessionId; } } else if (state.WorldEntrySceneId4229938 != sceneId || state.WorldEntrySessionId4229938 != sessionId) reject = "correlation-mismatch"; if (reject is null && !stale) { generation = state.WorldEntryControlGeneration; state.SceneLoadAckSeen = true; state.SceneId = sceneId; } } if (stale) { ctx.Session.Log.Info($"[WORLD] ignoring stale load-ack scene={sceneId} session={sessionId} (expecting {state.WorldEntryExpectedSceneId})"); return; } if (reject is not null) { ctx.Session.Log.Warn($"[WORLD] AskLoadSceneCompleted rejected scene={sceneId} session={sessionId} reason={reject}"); return; } if (!await CommitWorldEntryCreateHeroData4229938(ctx, generation)) return; // The confirmed handoff transaction ends with CurrentSpirit. Do not interleave roster/combat/state // hydration here; the client must reach AskLoadingFinished with the post-load quartet intact. ctx.Session.Log.Info($"[WORLD] load-edge committed generation={generation} scene={sceneId} session={sessionId} logicProjection={state.WorldEntryLogicProjectionPublishedGeneration} currentMetadata={state.WorldEntryCurrentMetadataPublishedGeneration} sceneComplete=false hydration=false"); } Task OnLoadGameResourcesCompleted(RpcContext ctx, ulong sceneId) { var state = GetWorldState(ctx); state.GameResourcesReadySeen = true; if (state.SceneId == 0 && sceneId != 0) state.SceneId = sceneId; return Task.CompletedTask; } async Task OnLoadingFinished(RpcContext ctx, ulong sceneId, ulong sessionId) { var state = GetWorldState(ctx); int generation = 0; string? reject = null; lock (state.SyncRoot) { if (sceneId == 0 || sessionId == 0) reject = "invalid-scene-session"; else if (!state.WorldEntryControlPending || state.WorldEntryControlFinalized) reject = state.WorldEntryControlFinalized ? "already-finalized" : "transaction-not-pending"; else if (sceneId != state.WorldEntrySceneId4229938 || sessionId != state.WorldEntrySessionId4229938) reject = "correlation-mismatch"; else { generation = state.WorldEntryControlGeneration; if (state.WorldEntryLogicProjectionPublishedGeneration != generation || state.WorldEntryCurrentMetadataPublishedGeneration != generation) reject = "v7-handoff-incomplete"; else if (state.WorldEntryLoadingCompletedGeneration == generation) return; else if (state.WorldEntryLoadingCompletedGeneration != 0) reject = "loading-edge-already-claimed"; else if (state.WorldEntryControlFinalizingGeneration != 0) reject = "finalizer-already-claimed"; else { state.WorldEntryLoadingCompletedGeneration = -generation; state.WorldEntryControlFinalizingGeneration = -generation; state.SceneId = sceneId; } } } if (reject is not null) { ctx.Session.Log.Warn($"[WORLD-V7] AskLoadingFinished rejected scene={sceneId} session={sessionId} generation={generation} reason={reject}"); return; } // Supplied guide invariant: SyncSceneLoadCompleted is the FIRST and ONLY S2C gameplay edge // of AskLoadingFinished. Do not put buffs/weapon/fashion/current before it. All optional // runtime hydration is deferred to the first real gameplay movement after Ready=true. await ctx.NotifyAsync(MethodId.SyncSceneLoadCompleted, WorldCodec.SceneLoadCompleted(sceneId)); lock (state.SyncRoot) { state.SceneCompletionSent = true; if (state.WorldEntryLoadingCompletedGeneration == -generation) state.WorldEntryLoadingCompletedGeneration = generation; state.LivePlayerProfilePublished = false; state.CombatProfilePublished = false; state.AccountArmoryPublished = true; // compact referenced subset already arrived in SyncPlayerInfo state.FreeRoamReleased = false; state.MovementCapabilityPublished = false; state.AllBuildBuffsPublished = false; state.InitialActorPresentationPublished = false; state.InitialCapabilityBuffsPublished = false; state.WorldEntryControlPending = false; state.WorldEntryControlFinalized = true; if (state.WorldEntryControlFinalizingGeneration == -generation) state.WorldEntryControlFinalizingGeneration = generation; state.Ready = true; state.WorldEntryIsAirportTravel = false; } // Normal post-load finalization, deliberately after the terminal scene-ready edge. // Reset S011 story + ownership one-shot flags so the client's freshly-built story // instance receives the root bootstrap and vehicle list after every scene load. var vehicleRuntime = GetVehicleRuntimeState(ctx.Session); lock (vehicleRuntime.SyncRoot) { vehicleRuntime.StoryRootSent = false; vehicleRuntime.StorySeenAnyRpc = false; vehicleRuntime.StoryHighestConfirmedRpcId = -1; vehicleRuntime.StoryPendingRpcIds.Clear(); vehicleRuntime.StoryCommittedRpcIds.Clear(); vehicleRuntime.OwnershipSent = false; vehicleRuntime.AetherInitSent = false; } ResetAllWeaponsSentFlag(ctx.Session); await ctx.NotifyAsync(MethodId.SyncGamePause, WorldCodec.GamePause(false)); await PublishVehicleOwnership(ctx); await PublishAllAccountWeaponsIfEnabled(ctx); await EnsureAetherVehicleInit(ctx); // 必须在 EnsureAetherVehicleInit 之后:没有 HasZoneGraph=true 客户端根本不建人群层。 await PublishAmbientCrowdAsync(ctx); // 潜水解锁:服务端没有能开 DiveManager 门的专用 S2C,只能走官方远程 GM 反射通道。 await PublishDivingUnlockAsync(ctx); // 自动氛围行人:起一个 per-session 维护循环,按真实行人路网在玩家周围铺人并发游荡意图。 // 行人必须先等玩家上报坐标(循环内部会等 HasLastReportedPlayerTransform),所以这里只负责启动。 EnsureAmbientPeds(ctx.Session); // 自动功能 NPC:跟随循环,玩家走近哪家店就把那家的店员放出来(配置 Aether.FunctionalNpcsEnabled)。 EnsureFunctionalNpcs(ctx.Session); // 可玩角色常驻:按各自日程站在城里(走近才放、走远撤掉、回来再放)—— 见 GameRouter.RosterNpc.cs。 EnsureRosterNpcs(ctx.Session); // Plate(盘)演出管线试点:配置 gameplay.plate.pushOnWorldReady=true 才推一次 // (默认关 —— 先手动 POST /api/plate/push 试线格式)。见 GameRouter.Plate.cs。 await EnsurePlateAsync(ctx.Session); // 自动地盘敌人:跟随循环,玩家走近哪个帮派地盘就把那帮人放出来 // (配置 Aether.EnemyTerritoryEnabled;坐标来自 FactionAreaPoint × FactionInfluenceArea)。 EnsureEnemyTerritory(ctx.Session); // T3-7 真实车流:同样要等玩家上报坐标,循环内部会等 HasLastReportedPlayerTransform。 // ★ await:起循环前要先清掉上一个会话残留的车(切场景/切角色后上一批车会冻在原地)。 await EnsureTrafficAsync(ctx.Session); // 世界互动物件(Gadget):按 40m 网格 + sector 流式下发,客户端从自己的 GadgetBlob 出货。 // 这就是 AnantaGadgetLoader 那个 exe 干的事,现在由服务端直接推 —— 不再需要额外注入器。 EnsureGadgetGrid(ctx.Session); // 场景物品(可破坏 / 可投掷物 / 路灯 / 街边道具):同一套 40m 网格流,走 // SyncDestructibleGridAOIDecrease(68969593)。这就是 AnantaWorldMod 注入器干的事。 EnsureSceneItemGrid(ctx.Session); // 搜打撤:只有当前 raid 在名单里才补(里面自带 IsExtractionRaid 守卫)。 // SyncPlayerEnterExtractionShooter 是空体包 —— 客户端靠它把 HUD 切成副本态。 await PushExtractionEnterAsync(ctx.Session, state); await EnsureVehicleStoryRoot(ctx); await PushSessionTimeAsync(ctx.Session); await PushSessionWeatherAsync(ctx.Session); await PublishMapFogUnlockAsync(ctx); // 大地图入口(传送点 / 地铁站图标 + 点击真传送)—— 见 GameRouter.MapEntrance.cs。 // 放在迷雾之后:两者都是「一次性世界级推送」,且入口帧量更大(93 × 3), // 内部自己走后台分批,不会堵住这里。 await PublishMapEntrancesAsync(ctx); await PublishPlayerTasksAsync(ctx); await PublishSwitchRoleTeamAsync(ctx); await PublishSceneLandingAsync(ctx); ctx.Session.Log.Info($"[WORLD-V7] ready generation={generation} scene={sceneId} session={sessionId} exactGuide=true sceneComplete=first actorPresentation=client-owned buffs=deferred-first-movement noStory=true noAOI=true"); } /// /// 潜水解锁(gameplay.divingMode == "on")—— 让所有角色能下潜,而不只是水面游泳。 /// /// 只负责门控那一半:通过 SyncRunGmUtils(66042045) 让客户端反射执行 /// LX6.Utils.GmUtils 的潜水开关(默认 EnableDive,可配 ForceDive / /// SetUnderWaterFilter)。客户端实现是 GameGMToClientImpl.SyncRunGmUtils /// (RVA 0x12752F10,真实现),按 methodName 反射解析,执行全程在客户端进程内。 /// 服务端没有专用 S2C 能代替它 —— DiveManager.CheckIsEnableDiving() 读的是 /// 客户端静态字段 enableInGamePlay。 /// /// 氧气那一半不在这里:由 CharacterCapabilities.GlobalCapabilityBuffIds 挂的 /// 52606233「潜水服buff」(不会掉氧气且更快恢复)随角色 buff 快照下发,切角色会自动重发。 /// async Task PublishDivingUnlockAsync(RpcContext ctx) { var gameplay = PrivateServerConfigStore.Current.Gameplay; if (!string.Equals(gameplay.DivingMode, "on", StringComparison.OrdinalIgnoreCase)) return; await PublishGmCommandsAsync(ctx, "DIVING", gameplay.DivingGmMethods, gameplay.DivingGmArgs); } /// /// 官方远程 GM 反射通道的公共推送口 —— SyncRunGmUtils(66042045)。 /// 客户端 GameGMToClientImpl(RVA 0x12752F10 真实现)按 methodName 反射调用 /// LX6.Utils.GmUtils 的静态方法,执行全程在客户端进程内,服务端只发一次 Notify。 /// async Task PublishGmCommandsAsync(RpcContext ctx, string tag, string[]? methods, string[]? args) { var list = (methods ?? []) .Where(m => !string.IsNullOrWhiteSpace(m)) .Select(m => m.Trim()) .ToArray(); if (list.Length == 0) return; // ⛑ 参数必须小写 "true":客户端 GmUtils.RunGmUtilsCommand 用 Newtonsoft.Json 逐个解析 // 参数字符串(实测 2026-09-22 21:52:09 传 "True" ⇒ JsonReaderException: Unexpected // character encountered while parsing value: T ⇒ 整条 GM 指令被丢弃,潜水没开)。 var a = (args is { Length: > 0 } x ? x : ["true"]).ToArray(); foreach (var method in list) { var dto = new SceneMethods.SyncRunGmUtils7 { methodName = method, args = a.ToList() }; await ctx.NotifyAsync(MethodId.SyncRunGmUtils, UxSerializer.Serialize(dto)); ctx.Session.Log.Info($"[{tag}] gm method={method} args=[{string.Join(",", a)}] mid=66042045"); } } /// /// Re-apply the landing pose for a scene switch into the raid/instance the client was already /// running. /// /// When both ends of a switch are the same raid+instance the client reloads the level but keeps /// the avatar at its current coordinates — SyncEnterScene's position only seeds a hero spawn, /// and no spawn happens because the level is already there. Sending SetPositionType.Teleport /// after the entry flow reached ready is what actually moves the player, and doing it here /// (rather than next to SyncEnterScene) guarantees the freshly respawned unit already exists. /// async Task PublishSceneLandingAsync(RpcContext ctx) { var state = GetWorldState(ctx); Vec3 position; float facing; ulong unitId; lock (state.SyncRoot) { if (state.SceneLandingPosition is not { } pending) return; position = pending; facing = state.SceneLandingFacing ?? 0f; state.SceneLandingPosition = null; state.SceneLandingFacing = null; unitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId; } // Give the client a beat to hand control back after SyncGamePause(false) before forcing // the transform, otherwise the free-roam restore can win the race. await Task.Delay(300); await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing, WorldCodec.PositionAndFacing(unitId, position, facing)); ctx.Session.Log.Info($"[SCENE] landing corrected unit={unitId} pos=({position.X:F2},{position.Y:F2},{position.Z:F2}) facing={facing:F2}"); } /// /// Reveal the whole world map by unlocking fog on every scene the config knows about. /// /// The login payload field PlayerClientInfoAchievement.SceneFogMaps is a dead end: no client /// code reads it for fog state (MapSystem_FogMap asks MapFogDataMgr, which is fed exclusively /// by the S2C below). Verified in-game — 212 fog entries at login changed nothing. /// /// The S2C's first argument is a **sceneId** (RaidConfig.SceneId, e.g. 20001222), not a raid /// id: MapFogDataMgr.IsInFog(sceneId, x, z) is the only lookup the fog view performs. /// async Task PublishMapFogUnlockAsync(RpcContext ctx) { var state = GetWorldState(ctx); lock (state.SyncRoot) { if (state.MapFogUnlockPublished) return; state.MapFogUnlockPublished = true; } var sceneIds = new List(SceneCatalog4229938.SceneIds); if (Profile.SceneInstanceId != 0 && Profile.SceneInstanceId <= uint.MaxValue) { var current = (uint)Profile.SceneInstanceId; if (!sceneIds.Contains(current)) sceneIds.Add(current); } if (sceneIds.Count == 0) { ctx.Session.Log.Info("[MAP-FOG] no scene ids in RaidConfig; fog left to the client"); return; } // ⛑️ 这曾经是 handler 里 **260~316 个同步 NotifyAsync**(packets 日志:进世界那一秒 // 489 个 S2C 里有 260 个就是它)。收包循环是串行的(TcpServer.HandleClientAsync: // 读完一帧 → await DispatchAsync → 才读下一帧),handler 不返回就一帧都不读, // 客户端此时发来的 RPC 全部排队 —— 与 2026-09-23 那次 671 把武器导致的断线风暴 // 是同一个死法,只是量级小一些。所以挪到后台摊平发,handler 立刻返回。 var session = ctx.Session; var ids = sceneIds; _ = Task.Run(async () => { const int batchSize = 25; const int batchDelayMs = 10; var sent = 0; for (var i = 0; i < ids.Count; i++) { try { await session.NotifyAsync(MethodId.SyncSceneFogMapAllUnlock, UxSerializer.Serialize(new GameMethods.SyncSceneFogMapAllUnlockData4229938 { SceneId = ids[i], Unlocked = true, }), CancellationToken.None); sent++; } catch (Exception ex) { session.Log.Warn($"[MAP-FOG] unlock failed scene={ids[i]}: {ex.Message}"); break; } if ((i + 1) % batchSize == 0 && i + 1 < ids.Count) await Task.Delay(batchDelayMs, CancellationToken.None); } session.Log.Info($"[MAP-FOG] SyncSceneFogMapAllUnlock scenes={sent}/{ids.Count} first={ids[0]} current={Profile.SceneInstanceId}"); }); ctx.Session.Log.Info($"[MAP-FOG] fog unlock scheduled in the background scenes={sceneIds.Count}"); } async Task OnLoadedInSameScene(RpcContext ctx) { var state = GetWorldState(ctx); bool firstPresentationEdge; lock (state.SyncRoot) { firstPresentationEdge = !state.SameSceneAckSeen; state.SameSceneAckSeen = true; if (firstPresentationEdge && Profile.WorldEntryOpeningEnabled && state.WorldEntryControlGeneration > 0) state.WorldEntryOpeningEndedGeneration = state.WorldEntryControlGeneration; } // 4229938 emits AskLoadedInSameScene after every authored same-scene character switch, not // only once after login. SyncPlayerLoadRate is therefore an acknowledgement for EACH edge. // Keep the opening-generation bookkeeping one-shot, but never suppress the per-switch ack. await ctx.NotifyAsync(MethodId.SyncPlayerLoadRate, WorldCodec.PlayerLoadRate()); ctx.Session.Log.Info($"[SAME-SCENE] load-rate ack=true first={firstPresentationEdge} generation={state.WorldEntryControlGeneration} pendingSwitch={state.PendingSwitchTemplateId}/{state.PendingSwitchUnitId} controlMutation=false"); } }