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");
}
}