Files

1137 lines
52 KiB
C#
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.
using System.Globalization;
using Ananta.SDK.Network;
using Ananta.SDK.Serialization;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.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;
/// <summary>
/// 自动氛围行人(ambient pedestrians)—— 玩家一进世界,街上就有会走的人。
///
/// ★★★★★ 2026-09-20 终版配方(移植 donor1「PrivateServer修改」实机验证的路人网络):
/// 1. `SyncAetherAICrowdAdd`(crowd 通道)建人:ActionId=**400**(行为动作组,非 POI 动作)、
/// TargetLocationReason=1(Wander)、speed=1.6。
/// 2. `SyncManagedLogicAgent`(68771737)(LogicHandoff)—— **必发**。没有它 ⇒ T-pose;
/// 而且它还是**客户端来要路径(AskClientNpcFindPath)的前提**(donor1 第 81 轮三重证据:
/// 成功会话 12 条 vs 失败 2 条 / FindPath 310 次 vs 0 次)。
/// 3. `SyncLogicAgentEnter`(68719890) —— 把 id 注册进 logic-agent 管理器(任务从它派发)。
/// 4. `SyncAgentForceGo`(68698324) —— 启动行为树。
/// 5. **移动任务** `SyncLogicAgentCommand`(68050574) + `TaskWayPointMoveCommandData`
/// (wire tag 39):自带速度/步态(SpecificMethod=Walk)/动画组,客户端的
/// LogicBehaviorSchedulerModule 负责寻路+避障+步态。**这是唯一能产生走路步态的通道**。
/// 6. 沿真实人行道路网(PedLaneGraph,88361 条车道)每 30m 一段、预计走完+5s 后发下一段。
///
/// ⛑️ 已证伪的两条路(别回头):
/// · **位置流** `SyncLogicAgentSyncData`(68838628):不带任务 ⇒ 客户端产生不了步态。
/// 我们 10Hz 推 6280 条、字节布局逐字节对齐客户端上报,人纹丝不动;
/// donor1 第 16~23 轮的「服务端硬推位置」同一条死路。
/// · **`SyncMoveWandering`**(68745518):走 `WanderingActionModule`(BaseUnit 管线),
/// 而 crowd NPC 是 AgentEntity(ECS 管线)⇒ 静默失败。
///
/// 多态命令线格式(donor1 第 40 轮反汇编钉死,见 LogicAgentCommandMethods.cs):
/// tag 字节 = 类型名字母序(39=TaskWayPointMove);基类尾部 MoveId(1B)+DataSource(2B,
/// Other=255);Lua 表偏 1 不可信。
/// </summary>
internal sealed partial class GameRouter
{
private const string AmbientKey = "client4229938-ambient-peds";
/// <summary>批量退役的分块大小(T1-24)。一次连发上千个 Remove 会打爆客户端 socket。</summary>
private const int RemoveBatchSize = 48;
private const int RemoveBatchDelayMs = 12;
/// <summary>移动任务的相关 id(客户端会把它回执回来;donor1 同款语义)。</summary>
private static int s_ambientCommandSeq;
/// <summary>模板池过滤结果只打一次日志(见 <see cref="PedFormworkPool"/>)。</summary>
private static int s_formworkPoolLogged;
internal sealed class AmbientPed
{
internal ulong Id;
internal float X;
internal float Y;
internal float Z;
internal long WanderArmedTicks;
/// <summary>服务端驱动行走的路网状态(<see cref="PedLaneGraph"/>)。Walking=false 就是原地不动。</summary>
internal bool Walking;
internal PedLaneGraph.WalkState Walk;
internal float Speed;
/// <summary>上一个移动任务的下发时间(TickCount64)—— 任务步进的续接时钟。</summary>
internal long TargetSetTicks;
/// <summary>上一次实际下发的路程(米)。<=0 表示还没量过,按 stepMetres 兜底。
/// 用它算续接时钟才准:末段常常短于 stepMetres,按满步长等就会"多站一会儿"。</summary>
internal float LastStepMetres;
/// <summary>续接时钟的随机相位(毫秒)。同一批生成的人如果节拍完全一致,
/// 就会"一起起步、一起掉头" —— 各人偏移一点就错开了。</summary>
internal int StepJitterMs;
/// <summary>目标导向行程的剩余折线(服务端规划,客户端沿它走)。null = 退回随机游走。</summary>
internal (float X, float Y, float Z)[]? Route;
/// <summary>下一个要走的折线下标。</summary>
internal int RouteIdx;
/// <summary>
/// 连续派单失败次数(成功即清零)。达到 `AmbientPedsStepFailLimit` 就回收重生。
///
/// ⛑️ 这是**服务端自己就能发现**的一路卡死:路网状态坏了(车道没了 / 规划不出来 /
/// 采样推进失败)时,这个行人会永久停在"派不出任务"的状态 —— 客户端看不到任何异常,
/// 表现出来就是一个人杵着不动。donor round 84 的判据是"连续 5 次失败 ⇒ 判定客户端已不再走"。
/// </summary>
internal int StepFailCount;
/// <summary>
/// 位置回馈校正次数,计在 `AmbientPedsStallWindowMs` 这个滑动窗口里。
/// 达到 `AmbientPedsStallResyncLimit` 说明这个单位**反复**跟服务端账本脱节(贴墙 / 卡地形),
/// 校正已经救不回来 ⇒ 回收重生到别处,而不是让它一直杵在那儿。
/// </summary>
internal int ResyncCount;
/// <summary><see cref="ResyncCount"/> 的计数窗口起点(TickCount64)。</summary>
internal long ResyncWindowTicks;
/// <summary>
/// 客户端回执「命令已装好」的次数(<c>AskReportLogicAgentCommandSuccess2</c> 67057559)。
///
/// 2026-09-24 自捐赠端 DremkaPS 移植。他们那句注释是移植的理由:
/// <c>SyncLogicAgentCommand</c> 对服务端是 fire-and-forget —— 客户端装了这条命令、还是拿到
/// 就丢了,在出站日志里长得<b>一模一样</b>。有了这对计数,「行人还是不走」才能分成
/// 「收了 12 条一条没跑」和「每一条都被拒了」两种完全不同的故障。
/// </summary>
internal int CommandAcks;
/// <summary>客户端回执「命令失败」的次数(<c>AskReportLogicAgentCommandFail</c> 67193144)。</summary>
internal int CommandFails;
/// <summary>最近一条回执带回的 commandId(与 <c>s_ambientCommandSeq</c> 同源)。</summary>
internal int LastAckCommandId;
}
internal sealed class AmbientSessionState
{
internal object SyncRoot { get; } = new();
internal CancellationTokenSource? Cts;
internal CancellationTokenSource? MoveCts;
/// <summary>最近一次已知的玩家位置 —— 目标导向行程以它为中心取终点,
/// 保证行人不会一路走出 <c>ambientPedsRecycleMeters</c> 被回收。</summary>
internal float HomeX;
internal float HomeZ;
internal Task? Loop;
internal Task? MoveLoop;
internal List<AmbientPed> Peds { get; } = [];
}
private static AmbientSessionState AmbientState(TcpSession session)
{
if (session.Items.TryGetValue(AmbientKey, out var existing) && existing is AmbientSessionState typed)
return typed;
var created = new AmbientSessionState();
session.Items[AmbientKey] = created;
return created;
}
/// <summary>
/// 用客户端上报的真实位置校正行人的服务端账本。
///
/// ⛑️ 这是**唯一**的客户端位置回馈通道:散人是客户端自驱的(硬规则 19),服务端推完
/// `TaskWayPointMove` 就不知道人到底走到哪了,只在自己的账本里把 <c>ped.X/Z</c> 推到
/// 任务终点。一旦客户端实际被卡住(贴墙、被玩家推开、插值中断),两边就永久脱节 ⇒
/// 之后每一段任务都从幻想位置出发,行人看起来"站在原地不动"。
/// 2026-09-23 用户实测:"有 1 个卡在原地不动,打他几下让他位移之后又正常走了" ——
/// 正是因为击退让客户端位置重新贴合了服务端账本。
///
/// 触发源:<c>AskClientNpcFindPath</c>(67974714) 请求里的 <c>From</c>,就是客户端手里那个
/// 单位的真实坐标(同一 session 34/40 个行人 id 都能对上,确认是我们的单位)。
///
/// ⛑️ 双阈值是必须的:小于 min 是正常误差(车道采样点有间距),大于 max 说明这不是
/// 我们的单位或者数据异常 —— 两种情况都不能写回,否则行人会被拖着乱跳。
/// </summary>
internal static bool TryResyncAmbientPed(TcpSession session, ulong pedId, float realX, float realZ,
out float drift, out bool hardReset)
{
drift = 0f;
hardReset = false;
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
if (!session.Items.TryGetValue(AmbientKey, out var raw) || raw is not AmbientSessionState owner)
return false;
lock (owner.SyncRoot)
{
AmbientPed? ped = null;
foreach (var p in owner.Peds)
{
if (p.Id != pedId)
continue;
ped = p;
break;
}
if (ped is null)
return false;
var dx = realX - ped.X;
var dz = realZ - ped.Z;
drift = MathF.Sqrt(dx * dx + dz * dz);
if (!float.IsFinite(drift) || drift < MathF.Max(0.5f, cfg.AmbientPedsResyncMinMetres))
return false;
if (drift > MathF.Max(1f, cfg.AmbientPedsResyncMaxMetres))
return false;
ped.X = realX;
ped.Z = realZ;
// ⛑️⛑️ 2026-09-23 改:**问路不等于卡住**。
// 实测每人每 ~15 s 问一次路,drift 中位 38 m —— 因为账本在下发任务时就被推到了路线末端,
// 客户端多半只是"还没走完"。以前只要 drift 落在 [3,60] 就 Route=null + 立刻重派
// ⇒ 行人每 15 秒被掐断一次行程,表现就是走走停停 / 杵着不动。
// 现在只有"真实位置已经不在当前路线上"才算真脱节,才清路线重规划。
if (ped.Route is { Length: > 0 } route &&
RouteDistance(route, realX, realZ, out var nearestIdx) <=
MathF.Max(3f, cfg.AmbientPedsResyncRouteToleranceMetres))
{
// 软校正:账本归位 + 跳过已经走过的折线点,剩下的行程继续走完。
var next = Math.Min(nearestIdx + 1, route.Length - 1);
if (next > ped.RouteIdx)
ped.RouteIdx = next;
var walk0 = ped.Walk;
PedLaneGraph.SnapTo(realX, realZ, ref walk0);
ped.Walk = walk0;
return true;
}
// 硬校正:确实偏离了路线(被击退 / 自驱跑偏 / 卡地形)⇒ 从真实位置重新规划。
hardReset = true;
ped.Route = null;
ped.RouteIdx = 0;
var walk = ped.Walk;
PedLaneGraph.SnapTo(realX, realZ, ref walk);
ped.Walk = walk;
ped.TargetSetTicks = 0; // 立刻派下一段,不再干等余量
// 滑动窗口计数:窗口过期就重新开一局。反复脱节(而不是偶发一次)才算"顽固卡住"。
var nowTick = Environment.TickCount64;
if (nowTick - ped.ResyncWindowTicks > Math.Max(1000, cfg.AmbientPedsStallWindowMs))
{
ped.ResyncWindowTicks = nowTick;
ped.ResyncCount = 0;
}
ped.ResyncCount++;
return true;
}
}
/// <summary>点到折线的最短距离,并给出最近点的下标。</summary>
private static float RouteDistance((float X, float Y, float Z)[] route, float x, float z, out int nearestIdx)
{
nearestIdx = 0;
var best = float.MaxValue;
for (var i = 0; i < route.Length; i++)
{
var d = MathF.Sqrt((route[i].X - x) * (route[i].X - x) + (route[i].Z - z) * (route[i].Z - z));
if (d < best)
{
best = d;
nearestIdx = i;
}
}
// 折线可能稀疏(采样间距 6 m),点到点的距离不够,还要算点到线段的距离。
for (var i = 0; i + 1 < route.Length; i++)
{
var d = SegmentDistance(route[i].X, route[i].Z, route[i + 1].X, route[i + 1].Z, x, z);
if (d < best)
{
best = d;
nearestIdx = i;
}
}
return best;
}
private static float SegmentDistance(float ax, float az, float bx, float bz, float px, float pz)
{
var vx = bx - ax;
var vz = bz - az;
var wx = px - ax;
var wz = pz - az;
var len2 = vx * vx + vz * vz;
var t = len2 <= 0f ? 0f : Math.Clamp((wx * vx + wz * vz) / len2, 0f, 1f);
var cx = ax + vx * t;
var cz = az + vz * t;
return MathF.Sqrt((px - cx) * (px - cx) + (pz - cz) * (pz - cz));
}
/// <summary>世界就绪后调用一次;幂等。配置关掉就直接返回。</summary>
internal static void EnsureAmbientPeds(TcpSession session)
{
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
// ⛑️ crowdEnabled 是人群总开关:它关着时一个服务端行人都不发,
// 否则用户关了「启用人群」街上照样有 40 个行人(2026-09-24 实测)。
if (!cfg.AmbientPedsEnabled || !cfg.CrowdEnabled)
return;
var owner = AmbientState(session);
lock (owner.SyncRoot)
{
if (owner.Loop is { IsCompleted: false })
return;
owner.Cts?.Dispose();
owner.Cts = new CancellationTokenSource();
var token = owner.Cts.Token;
owner.Loop = Task.Run(() => RunAmbientLoopAsync(session, owner, token));
// 服务端驱动行走:独立的较高频循环(默认 100ms)只负责推进 + 下发位置。
if (cfg.AmbientPedsDriven && PedLaneGraph.Available)
{
owner.MoveCts?.Dispose();
owner.MoveCts = new CancellationTokenSource();
owner.MoveLoop = Task.Run(() => RunAmbientMoveLoopAsync(session, owner, owner.MoveCts.Token));
}
}
}
/// <summary>
/// 把场上所有服务端行人**真回收**:逐个发 SyncAetherAINpcRemove 再停循环。
///
/// ⛑️ 为什么不能直接 StopAmbientPeds:那个只停循环、本地链表一清了事,
/// 客户端上的人还站着 —— 面板关「启用人群」后用户看到的"还是有人"多半就是这批。
/// 分块(RemoveBatchSize + RemoveBatchDelayMs)沿用回收路径的老规矩,一次性连发会打爆 socket。
/// </summary>
internal static async Task RetireAmbientPedsAsync(TcpSession session)
{
if (!session.Items.TryGetValue(AmbientKey, out var existing) || existing is not AmbientSessionState owner)
return;
List<AmbientPed> all;
lock (owner.SyncRoot)
{
all = [.. owner.Peds];
owner.Peds.Clear();
owner.Cts?.Cancel();
owner.MoveCts?.Cancel();
}
session.Log.Info($"[AMBIENT] retire all peds count={all.Count}");
if (all.Count == 0)
return;
for (var i = 0; i < all.Count; i += RemoveBatchSize)
{
if (i > 0)
await Task.Delay(RemoveBatchDelayMs, CancellationToken.None);
var end = Math.Min(all.Count, i + RemoveBatchSize);
for (var j = i; j < end; j++)
{
try
{
await session.NotifyAsync(MethodId.SyncAetherAINpcRemove,
UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938 { pid = all[j].Id }),
CancellationToken.None);
}
catch (Exception ex)
{
session.Log.Warn($"[AMBIENT] retire remove failed pid={all[j].Id}: {ex.Message}");
}
}
}
}
/// <summary>断开/切场景时停掉循环(不主动移除行人 —— 场景卸载客户端自己清)。</summary>
internal static void StopAmbientPeds(TcpSession session)
{
if (!session.Items.TryGetValue(AmbientKey, out var existing) || existing is not AmbientSessionState owner)
return;
lock (owner.SyncRoot)
{
owner.Cts?.Cancel();
owner.Cts?.Dispose();
owner.Cts = null;
owner.MoveCts?.Cancel();
owner.MoveCts?.Dispose();
owner.MoveCts = null;
owner.Peds.Clear();
}
}
private static async Task RunAmbientLoopAsync(TcpSession session, AmbientSessionState owner, CancellationToken token)
{
var a = PrivateServerConfigStore.Current.Gameplay.Aether;
var laneStats = PedLaneGraph.Stats();
var channel = IsCrowdChannel(a) ? "crowd" : "static";
session.Log.Info($"[AMBIENT] pedestrian loop started channel={channel} " +
$"actionId={a.AmbientPedsCrowdActionId} " +
$"targetReason={a.AmbientPedsTargetLocationReason} " +
$"poiAction={a.AmbientPedsPoiActionId} speed={a.AmbientPedsDesiredSpeed} " +
$"driven={a.AmbientPedsDriven && PedLaneGraph.Available} " +
$"task=TaskWayPointMove(tag39) lanes={laneStats.Lanes}/banned={laneStats.Banned}/width={laneStats.WithWidth} " +
$"step={a.AmbientPedsStepMetres:F0}m tickMs={a.AmbientPedsSyncMs} " +
$"logicEnter={a.AmbientPedsLogicEnter} handoff={a.AmbientPedsCrowdManagedLogicAgent}");
var tick = Math.Max(200, PrivateServerConfigStore.Current.Gameplay.Aether.AmbientPedsTickMs);
while (!token.IsCancellationRequested)
{
// 运行中被面板关掉人群 ⇒ 当场回收已下发的行人并退出(不用等重进场景)。
if (!PrivateServerConfigStore.Current.Gameplay.Aether.CrowdEnabled)
{
session.Log.Info("[AMBIENT] loop stopping: crowd disabled from panel");
await RetireAmbientPedsAsync(session);
break;
}
try
{
// 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明。
if (!IsBackgroundPushPaused(session))
await TickAmbientAsync(session, owner, token);
}
catch (OperationCanceledException)
{
break;
}
catch (ObjectDisposedException)
{
session.Log.Info("[AMBIENT] loop stopped: session disposed");
break;
}
catch (Exception ex)
{
session.Log.Info($"[AMBIENT] tick failed: {ex.Message}");
}
try
{
await Task.Delay(tick, token);
}
catch (OperationCanceledException)
{
break;
}
}
}
/// <summary>
/// 服务端驱动行走循环(donor1 实机配方,2026-09-20 移植):**移动任务步进**,不是位置流。
///
/// 每个 Walking 的行人沿 PedLaneGraph 抽一段 30m 折线,发一条 `TaskWayPointMove` 命令
/// (`SyncLogicAgentCommand` 68050574 + wire tag 39,自带速度/步态/动画组);
/// 预计走完的时间(步长 ÷ 速度 + 5s 余量)过后再发下一段 —— 与 donor1 的
/// `CrowdLaneNeedsTarget` 同一条时钟。
///
/// ⛑️ 为什么不用位置流:`SyncLogicAgentSyncData`(68838628)不携带任何任务,
/// 客户端产生不了步态。donor1 第 39 轮实测 Servo 流 = 走两秒动画然后站住;
/// 我们 09-20 实测 10Hz 推 6280 条、字节布局与客户端上报逐字节对齐,人依然纹丝不动。
/// donor1 AI_HANDOFF 明确把「服务端硬推位置」标记为**已证伪路线**(路人第 16~23 轮的老路)。
/// </summary>
private static async Task RunAmbientMoveLoopAsync(
TcpSession session, AmbientSessionState owner, CancellationToken token)
{
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
var intervalMs = Math.Clamp(cfg.AmbientPedsSyncMs, 200, 2000);
var stepMetres = MathF.Max(6f, cfg.AmbientPedsStepMetres);
var stepsSent = 0;
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(intervalMs, token);
}
catch (OperationCanceledException)
{
break;
}
catch (ObjectDisposedException)
{
break;
}
// 客户端挂起期间不推进行走任务 —— 见 GameRouter.IsBackgroundPushPaused 的说明。
if (IsBackgroundPushPaused(session))
continue;
var now = Environment.TickCount64;
List<AmbientPed> walkers;
lock (owner.SyncRoot)
walkers = owner.Peds.Where(p => p.Walking).ToList();
if (walkers.Count == 0)
continue;
var sent = 0;
foreach (var ped in walkers)
{
// donor1 的续接时钟:expected + 5s 余量(Walk 期间客户端有到达判定,提前发会重启步态)。
// ⛑️ 这个余量就是"行人站着发呆"的那段时间:走完一段后要空等它过去才发下一段。
// 想让发呆变短就调 `ambientPedsStepSlackMs`,但调太小会在客户端还没走完时提前派活
// ⇒ 行人被拽回上一段终点(瞬移)。零瞬移的做法是加长 stepMetres、降低发呆频率。
// ⛑️ 按**实际下发**的路程算,不是按满步长:末段常常短于 stepMetres,
// 按 60m 去等就会多站一会儿(用户看到的"走走停停"有一部分来自这里)。
var metres = ped.LastStepMetres > 0f ? ped.LastStepMetres : stepMetres;
var expected = (long)(metres / MathF.Max(0.3f, ped.Speed) * 1000f)
+ Math.Max(0, cfg.AmbientPedsStepSlackMs)
+ ped.StepJitterMs;
if (ped.TargetSetTicks != 0 && now - ped.TargetSetTicks < expected)
continue;
if (await PushAmbientStepCommandAsync(session, owner, ped, stepMetres, token))
{
sent++;
ped.StepFailCount = 0;
}
else
{
// 派不出来(路网状态坏 / 规划不出来)不报错,只累加 —— 由 TickAmbientAsync
// 的看门狗在达到 `AmbientPedsStepFailLimit` 时回收重生。
// ⛑️ 不在这里直接回收:这个 Task 只负责推任务,remove/补点归 TickAmbientAsync。
ped.StepFailCount++;
}
}
if (sent > 0)
{
stepsSent += sent;
session.Log.Info($"[AMBIENT] step commands sent={sent} total={stepsSent} live={walkers.Count}");
}
}
}
/// <summary>
/// 给一个行人发下一段 `TaskWayPointMove` 移动任务:从当前位置沿路网推进
/// <paramref name="stepMetres"/> 米,沿途采样折线(起点 + 每 ~6m 一点 + 终点)。
/// 只有推进成功才写回路网状态 —— 失败时 <see cref="AmbientPed.Walk"/> 原样保留,下一拍重试。
/// </summary>
private static async Task<bool> PushAmbientStepCommandAsync(
TcpSession session, AmbientSessionState owner, AmbientPed ped, float stepMetres, CancellationToken token)
{
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
// ⛑️ 采样间距固定(`ambientPedsSampleMetres`,默认 6m),不再随路程放大 ——
// 车道中位数才 3.58m,采样太稀会让行人切弯穿绿化。
var chunk = MathF.Max(2f, cfg.AmbientPedsSampleMetres);
var points = new List<SceneMethods.UxVector3>(24) { new(ped.X, ped.Y, ped.Z) };
// ---- ① 目标导向行程(默认):一次规划几十米开外的整条路径,行人穿过多个路口 ----
if (cfg.AmbientPedsJourneyEnabled && PedLaneGraph.Available)
{
if (ped.Route is null || ped.RouteIdx >= ped.Route.Length - 1)
{
// 传**比例**而不是米数:实际偏移 = 该点车道的 HalfWalk × 这个值,窄路自动不偏
// (2026-09-23 修:"窄路上行人卡在街边墙壁里" —— 固定 0.6m 跟路宽无关,照样把人推进墙)。
var planned = PedLaneGraph.TryPlanJourney(ped.X, ped.Z, owner.HomeX, owner.HomeZ,
cfg.AmbientPedsJourneyMinMetres, cfg.AmbientPedsJourneyMaxMetres,
ped.Walk.Offset, Random.Shared);
ped.Route = planned is { Count: >= 2 } ? planned.ToArray() : null;
ped.RouteIdx = 0;
}
var route = ped.Route;
if (route is { Length: >= 2 } && ped.RouteIdx < route.Length - 1)
{
var need = stepMetres;
var acc = 0f;
var cx = ped.X;
var cz = ped.Z;
while (need > 0f && ped.RouteIdx < route.Length - 1)
{
var p = route[++ped.RouteIdx];
var dx = p.X - cx;
var dz = p.Z - cz;
var d = MathF.Sqrt(dx * dx + dz * dz);
cx = p.X;
cz = p.Z;
need -= d;
acc += d;
if (acc >= chunk)
{
points.Add(new SceneMethods.UxVector3(p.X, p.Y, p.Z));
acc = 0f;
}
}
// 末点必须是真实落点,否则客户端走到倒数第二个点就"没路了",又会站住。
var tail = route[ped.RouteIdx];
var ex = tail.X - points[^1].X;
var ez = tail.Z - points[^1].Z;
if (ex * ex + ez * ez > 0.09f)
points.Add(new SceneMethods.UxVector3(tail.X, tail.Y, tail.Z));
return await SendAmbientStepAsync(session, ped, points, token);
}
}
// ---- ② 退回随机游走 ----
// ⛑️ 从目标导向退回来必须先吸附:Walk.Lane 还停在生成时那条老车道上,
// 直接 Advance 会从几十米外出点 ⇒ 行人瞬移。
var walk = ped.Walk; // struct 副本:只有整段推进成功才写回
PedLaneGraph.SnapTo(ped.X, ped.Z, ref walk);
var remaining = stepMetres;
while (remaining > 0f)
{
var d = MathF.Min(chunk, remaining);
if (!PedLaneGraph.Advance(ref walk, d, Random.Shared, out var x, out var y, out var z, out _))
return false;
points.Add(new SceneMethods.UxVector3(x, y, z));
remaining -= d;
}
ped.Walk = walk;
ped.Route = null; // 下次再试目标导向
return await SendAmbientStepAsync(session, ped, points, token);
}
/// <summary>把折线封装成一条 `TaskWayPointMove` 发出,并回写行人位置与实际路程。</summary>
private static async Task<bool> SendAmbientStepAsync(
TcpSession session, AmbientPed ped, List<SceneMethods.UxVector3> points, CancellationToken token)
{
if (points.Count < 2)
return false;
var metres = 0f;
for (var i = 1; i < points.Count; i++)
{
var dx = points[i].X - points[i - 1].X;
var dz = points[i].Z - points[i - 1].Z;
metres += MathF.Sqrt(dx * dx + dz * dz);
}
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncLogicAgentCommand,
UxSerializer.Serialize(WorldCodec.LogicAgentCommandWayPoint(
ped.Id, Interlocked.Increment(ref s_ambientCommandSeq),
SceneMethods.AgentMovementMethods.Walk, ped.Speed,
moveActionId: 0u, moveActionGroup: 0u,
keepMovingAction: true, points, noRootMotion: true)), token);
ped.X = points[^1].X;
ped.Y = points[^1].Y;
ped.Z = points[^1].Z;
ped.LastStepMetres = metres;
ped.TargetSetTicks = Environment.TickCount64;
return true;
}
private static async Task TickAmbientAsync(TcpSession session, AmbientSessionState owner, CancellationToken token)
{
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
var state = GetStateIfExists(session);
if (state is null)
return;
Vec3 player;
lock (state.SyncRoot)
{
if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0)
return;
player = state.LastReportedPlayerPosition;
}
if (!float.IsFinite(player.X) || !float.IsFinite(player.Z))
return;
// 目标导向行程的中心锚点(驱动循环是另一个 Task,拿不到这里的 player)。
owner.HomeX = player.X;
owner.HomeZ = player.Z;
// ---- 1. 回收走远了的 ----
var recycle = MathF.Max(cfg.AmbientPedsRecycleMeters, cfg.AmbientPedsRadiusMeters + 50f);
List<AmbientPed> gone = [];
var stalled = 0;
var nowWatch = Environment.TickCount64;
var stallWindow = Math.Max(1000, cfg.AmbientPedsStallWindowMs);
var failLimit = Math.Max(2, cfg.AmbientPedsStepFailLimit);
var resyncLimit = Math.Max(2, cfg.AmbientPedsStallResyncLimit);
lock (owner.SyncRoot)
{
for (var i = owner.Peds.Count - 1; i >= 0; i--)
{
var ped = owner.Peds[i];
// ---- 看门狗:两路卡死判定,命中就回收重生(下面第 3 步会自动补点)----
// ① 服务端侧:连续派不出任务 —— 路网状态坏了,行人会永久杵着不动。
// ② 客户端侧:窗口内反复位置脱节 —— 贴墙/卡地形,校正也救不回来。
// ⛑️ 判据都用"连续/窗口内 N 次"而不是一次:偶发一次是正常抖动,
// 一次就回收会让行人在正常游走时凭空消失(比站着不动更难看)。
var stalledNow = ped.StepFailCount >= failLimit
|| (ped.ResyncCount >= resyncLimit
&& nowWatch - ped.ResyncWindowTicks <= stallWindow);
if (stalledNow)
{
stalled++;
gone.Add(ped);
owner.Peds.RemoveAt(i);
continue;
}
var dx = ped.X - player.X;
var dz = ped.Z - player.Z;
if (dx * dx + dz * dz <= recycle * recycle)
continue;
gone.Add(ped);
owner.Peds.RemoveAt(i);
}
}
// ⛑️ 一次性连发上千个 Remove 会把客户端 socket 直接打爆(donor 实测 1952~2204 个 ⇒
// "你的主机中的软件中止了一个已建立的连接")。分块 48 个/批 + 批间 12 ms 之后,
// 他们的进世界延迟 2500/4500 ms → 0。传送 / 切场景时 gone 会很大,这是我们最容易莫名掉线的地方之一。
for (var i = 0; i < gone.Count; i += RemoveBatchSize)
{
if (i > 0)
await Task.Delay(RemoveBatchDelayMs, token);
var end = Math.Min(gone.Count, i + RemoveBatchSize);
for (var j = i; j < end; j++)
await session.NotifyAsync(MethodId.SyncAetherAINpcRemove,
UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938 { pid = gone[j].Id }),
token);
}
// ---- 2. 游荡续期(MaxTime 是总预算,走完就永久站住)----
// crowd 通道是客户端自驱游荡,没有总时长上限,不用重发。
List<AmbientPed> due = [];
if (!IsCrowdChannel(cfg))
{
var rearmMs = (long)(MathF.Max(5f, cfg.AmbientPedsWanderRearmSeconds) * 1000f);
var now = Environment.TickCount64;
lock (owner.SyncRoot)
due = owner.Peds.Where(p => now - p.WanderArmedTicks >= rearmMs).ToList();
foreach (var ped in due)
await PushAmbientWanderAsync(session, cfg, ped, token);
}
if (gone.Count > 0 || due.Count > 0)
session.Log.Info($"[AMBIENT] recycle={gone.Count} (stalled={stalled}) reWander={due.Count} live={owner.Peds.Count}");
// ---- 3. 补点 ----
int live;
lock (owner.SyncRoot)
live = owner.Peds.Count;
var deficit = cfg.AmbientPedsTarget - live;
if (deficit <= 0)
return;
var batch = Math.Min(deficit, cfg.AmbientPedsTopUpPerTick);
var spawned = 0;
var driven = cfg.AmbientPedsDriven && PedLaneGraph.Available;
for (var i = 0; i < batch; i++)
{
float sx, sy, sz;
var walking = false;
var walk = default(PedLaneGraph.WalkState);
if (driven && PedLaneGraph.TryPickSpawn(player.X, player.Z, cfg.AmbientPedsRadiusMeters,
Random.Shared, out sx, out sy, out sz, out walk))
{
walking = true;
}
else
{
var spot = PedSpawnTable.PickNear(player.X, player.Z, cfg.AmbientPedsRadiusMeters,
cfg.AmbientPedsMinGapMeters, owner.Peds);
if (spot is null)
break;
sx = spot.Value.X;
sy = spot.Value.Y;
sz = spot.Value.Z;
}
var ped = await SpawnAmbientPedAsync(session, owner, cfg, sx, sy, sz, walking, walk, token);
if (ped is null)
break;
lock (owner.SyncRoot)
owner.Peds.Add(ped);
spawned++;
}
if (spawned > 0)
session.Log.Info($"[AMBIENT] spawned {spawned} pedestrians near " +
$"({player.X:F0},{player.Z:F0}) live={live + spawned}/{cfg.AmbientPedsTarget}");
}
private static bool IsCrowdChannel(AetherSettings cfg)
=> string.Equals(cfg.AmbientPedsChannel, "crowd", StringComparison.OrdinalIgnoreCase);
private static async Task<AmbientPed?> SpawnAmbientPedAsync(
TcpSession session, AmbientSessionState owner, AetherSettings cfg, float sx, float sy, float sz,
bool walking, PedLaneGraph.WalkState walk, CancellationToken token)
{
var formwork = PedFormworkPool.Pick();
if (formwork is null)
return null;
// T0-28:模板池过滤结果只打一次 —— 判"是不是滤空了 / 滤掉了多少"全靠这一行。
if (Interlocked.Exchange(ref s_formworkPoolLogged, 1) == 0)
session.Log.Info($"[AMBIENT] formwork pool {PedFormworkPool.Count} usable " +
$"(raw={PedFormworkPool.RawCount}, filtered={PedFormworkPool.Filtered})");
var id = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor));
// ⛑️ 2026-09-26:出生朝向必须跟着车道走向。原来是纯随机(-180~180),
// 而位移由 TaskWayPointMove 的路径点决定 ⇒ 身体朝 A、位移朝 B,看起来就是"倒退着走路"。
// 非路网出生(站位表)没有走向可言,才退回随机。
var facing = Random.Shared.NextSingle() * 360f - 180f;
if (walking && PedLaneGraph.TryFacing(walk, out var laneFacing))
facing = laneFacing;
var crowd = IsCrowdChannel(cfg);
var spot = new PedSpawnPoint(sx, sy, sz);
if (crowd)
await SendAmbientCrowdPedAsync(session, cfg, formwork.Value, id, spot, facing, token);
else
await SendAmbientStaticPedAsync(session, cfg, formwork.Value, id, spot, facing, token);
var ped = new AmbientPed
{
Id = id,
X = sx,
Y = sy,
Z = sz,
WanderArmedTicks = 0,
Walking = walking,
Walk = walk,
Speed = MathF.Max(0.1f, cfg.AmbientPedsDesiredSpeed) * (0.85f + Random.Shared.NextSingle() * 0.3f),
// 同一批生成的人如果节拍完全一致,就会"一起起步、一起掉头" —— 各人错开一点。
StepJitterMs = Random.Shared.Next(0, Math.Max(1, cfg.AmbientPedsStepSlackMs)),
};
// crowd 通道是客户端自驱游荡,服务端发游荡包反而多余;static 通道必须发,否则只播 idle。
if (!crowd)
await PushAmbientWanderAsync(session, cfg, ped, token);
// donor1 第 39 轮:移动任务在**创建时**就发第一条,不等驱动循环的首拍 ——
// 否则路人在第一拍之前是站着的。路网可用(walking)才发。
if (walking)
await PushAmbientStepCommandAsync(session, owner, ped,
MathF.Max(6f, cfg.AmbientPedsStepMetres), token);
return ped;
}
/// <summary>static 通道:静态 NPC + 行为树 + simulation owner(人一定出得来、有动画)。</summary>
private static async Task SendAmbientStaticPedAsync(
TcpSession session, AetherSettings cfg, uint formwork, ulong id,
PedSpawnPoint spot, float facing, CancellationToken token)
{
// ⛑️⛑️ POI 动作**不能留 0**。0 = 无动作 ⇒ 站着不动。
// 1 = UrbanDiversityPOIActionConfig.Walk(走路)。
await SendCivilianAsync(session, new CivilianSpawnItem(formwork, cfg.AmbientPedsPoiActionId), id,
spot.X, spot.Y, spot.Z, facing);
// 客户端是空 stub(无 override),默认关;留着给未来客户端版本/对照实验。
if (cfg.AmbientPedsSendDesiredSpeed)
{
await session.NotifyAsync(MethodId.SyncChangeNpcMoveDesiredSpeed,
UxSerializer.Serialize(new SceneMethods.SyncChangeNpcMoveDesiredSpeed4229938
{
EntityId = id,
DesiredSpeed = cfg.AmbientPedsDesiredSpeed,
}), token);
}
if (cfg.AmbientPedsForceGo)
{
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentForceGo,
UxSerializer.Serialize(new SceneMethods.SyncAgentForceGo4229938
{
id = id,
isForceGo = true,
}), token);
}
}
/// <summary>
/// crowd 通道:`SyncAetherAICrowdAdd`(68757757)(donor_lyco082 实机验证的市民通道)。
/// 客户端自驱游荡,服务端不用发移动包。⚠️ NpcFormworkId 必须是 AgentConfig id(4013xxxx)。
/// </summary>
private static async Task SendAmbientCrowdPedAsync(
TcpSession session, AetherSettings cfg, uint formwork, ulong id,
PedSpawnPoint spot, float facing, CancellationToken token)
{
await session.NotifyAsync(MethodId.SyncAetherAICrowdAdd,
UxSerializer.Serialize(new SceneMethods.SyncAetherAICrowdAdd4229938
{
crowd = new SceneMethods.ClientCrowdInitData4229938
{
NpcFormworkId = formwork,
AgentPersonaId = cfg.AmbientPedsCrowdPersonaId,
UrbanDiversityConfigId = 0,
DesiredSpeed = cfg.AmbientPedsDesiredSpeed,
// ★ 走起来靠 TargetLocationReason=1(Wander):之前填 0(None) = 告诉客户端
// "这个人没有目标位置意图" ⇒ 人出来了但不走。
// ⛑️ ActionId 是**动作组 400**(不是 POI 动作 id 1)—— 见下面 POI 处注释,别改回 1。
ActionId = cfg.AmbientPedsCrowdActionId,
TargetLocationReason = cfg.AmbientPedsTargetLocationReason,
FashionSuitId = 0,
Id = id,
Position = new SceneMethods.UxVector3(spot.X, spot.Y, spot.Z),
Facing = facing,
EulerAngles = new SceneMethods.UxVector3(0f, facing, 0f),
},
}), token);
// ⛑️⛑️ 没有 simulation owner ⇒ ECS→GO 交接后全模型状态机被关掉 ⇒ **T-pose**。
// ★ donor1 第 81 轮(三重证据):`SyncManagedLogicAgent` 也是**客户端来要路径
// (AskClientNpcFindPath)的前提** —— 状态机被关掉,客户端根本不来问,
// 服务端推多少命令都没人接。所以 driven 模式必须发。
if (cfg.AmbientPedsCrowdManagedLogicAgent)
{
await session.NotifyAsync(MethodId.SyncManagedLogicAgent,
UxSerializer.Serialize(WorldCodec.ManagedLogicAgent(id, Profile.PlayerPid, 0)), token);
}
// donor1 第 37 轮:Enter 把 id 注册进客户端的 logic-agent 同步管理器,
// 任务/同步都从它派发 —— 不发它,任务可能被静默丢弃。
if (cfg.AmbientPedsLogicEnter)
{
await session.NotifyAsync(MethodId.SyncLogicAgentEnter,
UxSerializer.Serialize(WorldCodec.LogicAgentEnter(id)), token);
}
// ⛑️⛑️ 2026-09-22 槽位归位(T0-9):
// `ClientCrowdInitData.ActionId` = **动作组**(400 = NPC 默认,donor 第 38 轮面板实测,上限 499)
// `SyncAgentPlayPOIAction.CPoiActionId` = **POI 动作 id**(POI 表:1=走路/9018、2=idle/1001、17=站姿闲置/1001)
// 这两个是**不同的表**。之前把动作组号 400 塞进 POI 槽位 ⇒ 每个行人都在播
// POI id 400「站姿_工人_记录(平板)」(还带 UsageId=81,需要平板道具,而 crowd 通道从不发 belongings)
// ⇒ 出场就是个拿平板记录的站姿,跟"走路"相反。位移靠 TaskWayPointMove,这里只负责动画状态 ⇒ 填 1。
if (cfg.AmbientPedsCrowdPlayPoiAction && cfg.AmbientPedsPoiActionId != 0)
{
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentPlayPOIAction,
UxSerializer.Serialize(new GameMethods.DonorClientNpcPoiActionData4229938
{
Id = id,
SPoiActionId = 0,
CPoiActionId = cfg.AmbientPedsPoiActionId,
IsActive = true,
PlayPoiSpeed = 1f,
}), token);
}
if (cfg.AmbientPedsForceGo)
{
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentForceGo,
UxSerializer.Serialize(new SceneMethods.SyncAgentForceGo4229938
{
id = id,
isForceGo = true,
}), token);
}
// T2-32 路人骇入:crowd 通道同样要标可骇(static 通道在 SendCivilianAsync 尾部标)。
// 行人一条 belongings 都没有 ⇒ 连手机都没有 ⇒ 按钮不会出,所以这里是 GivePhoneToCrowdPeds 的主战场。
await MarkNpcHackableOnSpawnAsync(session, id, HackNpcChannel.Crowd, token);
}
/// <summary>
/// 下发/续期一个行人的游荡意图。MoveId 不是序号,是 BattleMoveActionType,
/// 填 0(Unknown) 等于"没有 locomotion 状态",所以填 2(WalkFront)。
/// ⛑️ PathTags / 角度窗口是最容易踩的两个静默失败点,见文件头。
/// </summary>
private static async Task PushAmbientWanderAsync(
TcpSession session, AetherSettings cfg, AmbientPed ped, CancellationToken token)
{
var maxDis = MathF.Max(cfg.AmbientPedsWanderMinDis + 1f, cfg.AmbientPedsWanderMaxDis);
await session.NotifyAsync(MethodId.SyncMoveWandering,
UxSerializer.Serialize(new SceneMethods.SyncMoveWandering4229938
{
Data = new SceneMethods.MoveWanderingData4229938
{
PathTags = cfg.AmbientPedsWanderPathTags,
CloseObstacleAvoidance = true,
MinDis = cfg.AmbientPedsWanderMinDis,
MaxDis = maxDis,
InRangeAngle = cfg.AmbientPedsWanderLeftAngle,
OutRangeAngle = cfg.AmbientPedsWanderRightAngle,
MaxTime = cfg.AmbientPedsWanderSeconds + 5f,
MaxOnceWanderTime = cfg.AmbientPedsWanderSeconds,
UnitId = ped.Id,
MoveType = 0,
MoveId = WorldCodec.MoveAction.WalkFront,
CloseIK = false,
InstanceId = 0,
BlockCloseCapsuleCollision = false,
},
}), token);
ped.WanderArmedTicks = Environment.TickCount64;
}
}
/// <summary>一个候选站位(真实行人车道端点,带地面高度)。</summary>
internal readonly record struct PedSpawnPoint(float X, float Y, float Z);
/// <summary>
/// 行人布点表:`Ananta.Server/Ananta.App/data/ped_spawn_points.csv`(x,y,z)。
/// 100 米网格索引,按玩家位置查半径内候选。
/// </summary>
internal static class PedSpawnTable
{
private const float CellSize = 100f;
private sealed class Table
{
internal PedSpawnPoint[] Points = [];
internal Dictionary<(int, int), int[]> Cells = [];
}
private static readonly Lazy<Table> Cache = new(Load);
internal static int Count => Cache.Value.Points.Length;
private static Table Load()
{
var path = PrivateServerConfigStore.ResolveProjectPath(
"Ananta.Server/Ananta.App/data/ped_spawn_points.csv");
var points = new List<PedSpawnPoint>();
if (File.Exists(path))
{
foreach (var line in File.ReadLines(path))
{
if (line.Length == 0 || line.StartsWith("x,", StringComparison.OrdinalIgnoreCase))
continue;
var parts = line.Split(',');
if (parts.Length < 3)
continue;
if (!float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x)
|| !float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y)
|| !float.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var z))
continue;
points.Add(new PedSpawnPoint(x, y, z));
}
}
var cells = new Dictionary<(int, int), List<int>>();
for (var i = 0; i < points.Count; i++)
{
var key = (CellOf(points[i].X), CellOf(points[i].Z));
if (!cells.TryGetValue(key, out var bucket))
cells[key] = bucket = [];
bucket.Add(i);
}
return new Table
{
Points = [.. points],
Cells = cells.ToDictionary(kv => kv.Key, kv => kv.Value.ToArray()),
};
}
private static int CellOf(float v) => (int)MathF.Floor(v / CellSize);
/// <summary>
/// 在 (x,z) 半径内随机挑一个站位,且与已有行人保持 minGap 米。
/// 找不到就返回 null(比如玩家站在水域/荒地,路网上没点)。
/// </summary>
internal static PedSpawnPoint? PickNear(float x, float z, float radius, float minGap,
IReadOnlyList<GameRouter.AmbientPed> existing)
{
var table = Cache.Value;
if (table.Points.Length == 0)
return null;
var span = (int)MathF.Ceiling(radius / CellSize);
var cx = CellOf(x);
var cz = CellOf(z);
List<int> candidates = [];
for (var dx = -span; dx <= span; dx++)
for (var dz = -span; dz <= span; dz++)
{
if (!table.Cells.TryGetValue((cx + dx, cz + dz), out var bucket))
continue;
foreach (var idx in bucket)
{
var p = table.Points[idx];
var ox = p.X - x;
var oz = p.Z - z;
if (ox * ox + oz * oz <= radius * radius)
candidates.Add(idx);
}
}
if (candidates.Count == 0)
return null;
var gapSq = minGap * minGap;
for (var attempt = 0; attempt < 24; attempt++)
{
var pick = table.Points[candidates[Random.Shared.Next(candidates.Count)]];
var ok = true;
foreach (var ped in existing)
{
var ox = ped.X - pick.X;
var oz = ped.Z - pick.Z;
if (ox * ox + oz * oz < gapSq)
{
ok = false;
break;
}
}
if (ok)
return pick;
}
return null;
}
}
/// <summary>
/// 氛围行人的 NPC 模板池(= AgentConfig id)。直接复用已投影好的市民组目录,
/// 只取 NpcFormworkId —— POI 一律留 0,否则人会去播动作而不走路。
///
/// `CivilianGroups` 是为"给每个 POI 动作配一个体型匹配的人形"建的,
/// 它<b>不保证模板是路人</b> —— 池里原本混着店员、摊主、刷怪NPC、剧情角色,还有上百个
/// 共用同一具通用模特的"假人"。这里按 `GameRouter.IsCivilianFormwork` 的三道筛子收敛成纯市民。
///
/// ⛑️ **来源恒为 `CivilianGroups.json`(用户 2026-09-26 定)**:本池只做<b>减法</b>,
/// 绝不从 `NpcCatalog`(9345 行全表)往里补 id —— 那里有敌人、动物、机器人、淤泥怪。
/// 过滤掉一批之后剩下多少就是多少(现在 1985 个),不要为了凑数去别的表里捞。
/// </summary>
internal static class PedFormworkPool
{
private static readonly Lazy<uint[]> Cache = new(Load);
internal static int Count => Cache.Value.Length;
/// <summary>过滤前 / 过滤后的数量,给日志和面板看(判"是不是滤空了")。</summary>
internal static int RawCount { get; private set; }
internal static int Filtered { get; private set; }
private static uint[] Load()
{
var raw = new HashSet<uint>();
var kept = new List<uint>();
foreach (var group in GameRouter.CivilianGroups)
foreach (var item in group.Items)
{
var id = item.NpcFormworkId;
if (id == 0 || !raw.Add(id))
continue;
// 三道筛子(id 段白名单 / 通用临时模型黑名单 / 名字黑名单)全在 IsCivilianFormwork 里。
if (GameRouter.IsCivilianFormwork(id))
kept.Add(id);
}
RawCount = raw.Count;
Filtered = raw.Count - kept.Count;
// 两道筛子把池子滤空 ⇒ 退回全池(宁可混进店员,也不要一个路人都刷不出来)。
if (kept.Count == 0 && raw.Count > 0)
{
Filtered = 0;
return [.. raw];
}
return [.. kept];
}
internal static uint? Pick()
{
var pool = Cache.Value;
return pool.Length == 0 ? null : pool[Random.Shared.Next(pool.Length)];
}
}