Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.IndoorSector.cs

426 lines
20 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.Buffers.Binary;
using Ananta.SDK.Network;
using Ananta.SDK.Serialization;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// 室内扇区(<c>sector_high_indoor_&lt;IndoorId&gt;</c>)—— 服务端主动驱动,不需要 frida 注入器。
/// </summary>
/// <remarks>
/// ★★★ 这是 2026-09-27 之前服务端唯一的「协议上完全可行、但完全够不着」的通道:
/// 6 个 indoor sector 的 MethodId 常量<b>早就存在</b>(<c>MethodId.cs</c> 3074 / 3813 / 3650 /
/// 2162 / 1977 / 3885),但全仓<b>零使用</b> —— 没有任何 Handler 推过它们。
/// (⛔ 2026-09-27 订正:之前记成「连常量都没有」是错的,常量在,缺的是 Handler。)
///
/// <b>通道归属</b>:独立于 gadget 网格(68956797)/ sceneitem 网格(68969593)/ 单件摆放
/// (68843189)/ PCG(无外部触发点)—— 是第五条通道。sector 只管「这块区域显不显示」,
/// 子树里是 <c>_lights</c> / <c>_decoration</c>(灯、镜子、LOD 组等静态装饰)。
/// <b>家具本体不归它管</b>(挂在 <c>GadgetInstanceRoot</c>,走 F/A 通道),
/// 所以室内完整还原 = 本通道开区域 + PropRestock 补家具,两条都要。
///
/// <b>IndoorId 从哪来</b>:不需要客户端上报,也不需要 frida 导出
/// <c>WorldStateIndoorSectorConfig</c>。服务端读 <c>Configs/IndoorConfig.json</c>,
/// 按「离玩家最近的室内条目」推出 IndoorId。
/// 实测对得上:客户端日志 <c>Enter GpsBound: RaidId 23300888, IndoorId 61</c>
/// ⇔ <c>IndoorConfig.Id=61</c> = "WowDonald's" @ (859,1847),玩家在 (866,1840),差 9.5 m。
///
/// ★★ <b>2026-09-27 补证:推下去的到底是哪个号</b>
/// 1. 先由位置推出 <c>IndoorId</c>(上面这条,来自 IndoorConfig);
/// 2. 再用 <c>Configs/WorldStateIndoorSectorConfig.json</c> 把 IndoorId 翻成
/// <b>扇区 Id(17600002…17600109)</b> —— 详见 <see cref="IndoorSectorCatalog4229938"/>。
/// 扇区 Id ≠ IndoorId,是该表的 <c>BelongingIndoorId</c> 字段指回 IndoorId。
/// 一个 IndoorId 可以有多个扇区(Spiko Hotel 7 个、ACD Nova Branch 9 个)。
/// 3. <b>参数语义 2026-09-27 20:1x 已用 A/B 定案:就是扇区 Id。</b>
/// 同一轮推 9 个扇区 Id + 1 个裸 IndoorId,客户端只报错裸 IndoorId:
/// <c>#NoCreateIssue Nexus SetIndoorSectorState …(17600101,True)…(17600109,True),(76,True)</c>
/// + <c>config not found for id 76</c> —— 9 个扇区 Id 全被接受。
/// ⇒ <c>alsoPushRawIndoorId</c> 已默认关闭,别再打开。
/// ⛑ <c>SyncLoadIndoorSector</c> 这条 <b>9 个里成功 7 个</b>(2026-09-27 20:4x 实机)。
/// 客户端每轮固定报 2 条
/// <c>LoadSectorByName: nameHash:-1386073846 / -1386073848 not found</c>,
/// 时间戳与服务端每次推送<b>逐秒对齐</b>(20:41:45/:50/:55 …),hash 恒定不变 ⇒
/// 不是随机错位,是「9 个扇区里有 2 个的场景对象不在当前已加载子场景里」。
/// 另外 9 个 id 在 <c>SetIndoorSectorState</c> 里 <b>0 条 config not found</b>(全被接受)。
/// ⇒ 通道是通的,剩下 2 个多半在别的楼层(ACD MapFloorList=[-1,1,2])。
/// (hash 不是 FNV1/FNV1a/CRC32/djb2/sdbm/BKDR/RS/JS/PJW/ELF/DEK/AP/Java31/
/// Murmur2/OAAT,已逐个反查过 44 个 SectorName,别再反查。)
/// ★ <b>2026-09-27 20:4x 抖动根因 + 修复</b>:ACD Nova Branch(<c>Id=76</c>) 与
/// ACD快乐房(<c>Id=129</c>) 的 <c>MiniMapPos</c> 矩形<b>互相重叠</b>,纯几何最近邻
/// 在重叠区每几秒 76↔129 跳一次 ⇒ 客户端反复建删 9 棵扇区子树。
/// 修法两条:① 优先采信客户端 <c>SyncChangeIndoor</c> 上报的权威 indoorConfigId
/// (<c>preferClientReport</c>);② 再叠 <c>switchDelayMs</c> 防抖(客户端自己也会
/// 瞬时抖到 0,光靠 ① 治不了)。实测把玩家传送到 76 的<b>非重叠</b>地段后立刻稳定。
/// ⛔ 覆盖上限:全表 44 扇区 / 约 25 场馆,全是剧情室内;普通店铺(麦当劳等)
/// 一条都没有,走 PCG,本通道管不着。
///
/// <b>线格式(C# 原生,无 lua 实现 —— 已从 lua_full 全文 grep 确认为 native)</b>:
/// <code>
/// S2C SyncLoadIndoorSector(68095220) = uint indoorSectorControlId
/// → 4 字节裸 UInt32(单字段 args,无 marker)
/// S2C SyncIndoorSectorControl(68971505) = Dictionary&lt;uint, bool&gt;
/// → FF(marker)+ <b>Int7</b> count(7bit,+1 偏置)
/// + 每条 (UInt32 LE key + u8 0/1)
/// </code>
/// 依据:dump.cs <c>UXRPCMethodArgs68095220.indoorSectorControlId</c> /
/// <c>UXRPCMethodArgs68971505.indoorSectorControl</c>;集合字段恒定带 marker(硬规则 182)。
///
/// ★★ <b>2026-09-27 19:5x 实机订正(必须记住)</b>:
/// count 一开始写成了 <c>I32</c>(照搬 <c>UxContract.WriteDictionary</c> 的框架<b>默认</b>
/// <c>UxCountEncoding.Int32</c>),结果客户端把 4 字节计数当 varint 读,整条 payload 错位:
/// Player.log 刷了 153 行
/// <c>SetIndoorSectorState: config not found for id 1694498816 / 1711341836 / …</c>。
/// 把收到的错位字节<b>反推求解</b>后,九个产物值与「FF + Int7(count) + U32 LE key」的模拟
/// 结果<b>逐个相等(差分全 0)</b>,而且「发 10 条 / 客户端只处理 9 条」正好对应
/// varint(0x0A)=10 再 −1 ⇒ <c>Int7</c>。
/// ⇒ 这条字段在客户端契约里是显式 <c>[UxCollection(Count=Int7)]</c>,
/// <b>不能用框架默认的 Int32</b>(见 <c>TOPIC_序列化UxContract.md</c> 第 10 行)。
/// ⇒ 以后再手写 any Dictionary payload,count 一律先试 <c>Int7</c>。
///
/// <b>回滚</b>:<c>world.content.indoorSectors.enabled = false</c>(不需要重新编译)。
/// </remarks>
internal sealed partial class GameRouter
{
private const string IndoorSectorKey = "client4229938-indoor-sector";
internal sealed class IndoorSectorSessionState
{
internal object SyncRoot { get; } = new();
/// <summary>上一次推下去的 indoor id;0 = 当前判定在室外。</summary>
internal uint CurrentId;
/// <summary>进过至少一个室内(用于区分「还没判定过」和「判定为室外」)。</summary>
internal bool HasJudged;
internal long LastPushMs;
internal bool WarnedEmpty;
internal bool WarnedNoRaid;
internal int Pushes;
/// <summary>上一次真正推下去的扇区 Id 列表(离开时要逐条关掉)。</summary>
internal List<uint> PushedSectors { get; } = new();
/// <summary>上一次是否额外推了「裸 IndoorId」那一路。</summary>
internal bool PushedRawId;
internal bool WarnedNoSectorTable;
/// <summary>
/// 防抖用:正在观察的候选 indoor id(0 = 没有候选)。
/// 见 <see cref="PrivateServerConfigStore"/> 的 <c>indoorSectors.switchDelayMs</c>。
/// </summary>
internal uint PendingId;
/// <summary>候选 id 第一次被观察到的时刻(TickCount64)。</summary>
internal long PendingSinceMs;
}
private static IndoorSectorSessionState IndoorSectorStateOf(TcpSession session)
{
if (session.Items.TryGetValue(IndoorSectorKey, out var existing)
&& existing is IndoorSectorSessionState typed)
return typed;
var created = new IndoorSectorSessionState();
session.Items[IndoorSectorKey] = created;
return created;
}
/// <summary>重连 / 切场景:忘掉当前室内,下次重新判定(客户端也把扇区状态丢了)。</summary>
internal static void ResetIndoorSector(TcpSession session)
{
var owner = IndoorSectorStateOf(session);
lock (owner.SyncRoot)
{
owner.CurrentId = 0;
owner.HasJudged = false;
owner.LastPushMs = 0;
owner.WarnedEmpty = false;
owner.WarnedNoRaid = false;
owner.PushedSectors.Clear();
owner.PushedRawId = false;
owner.PendingId = 0;
owner.PendingSinceMs = 0;
}
}
/// <summary>
/// 由 gadget 网格 tick 顺带驱动。玩家走进/离开某个 <c>IndoorConfig</c> 条目附近时,
/// 推 <c>SyncLoadIndoorSector</c> + <c>SyncIndoorSectorControl</c>。
/// </summary>
internal static async Task TickIndoorSectorAsync(
TcpSession session, WorldEntryState state, float px, float pz)
{
var cfg = PrivateServerConfigStore.Current.World.Content.IndoorSectors;
if (!cfg.Enabled)
return;
var owner = IndoorSectorStateOf(session);
if (IndoorCatalog4229938.Count == 0)
{
lock (owner.SyncRoot)
{
if (owner.WarnedEmpty)
return;
owner.WarnedEmpty = true;
}
session.Log.Info($"[INDOOR] 没有可用室内点位:{IndoorCatalog4229938.LoadError}");
return;
}
if (IndoorSectorCatalog4229938.Count == 0)
{
lock (owner.SyncRoot)
{
if (owner.WarnedNoSectorTable)
return;
owner.WarnedNoSectorTable = true;
}
session.Log.Warn("[INDOOR] 扇区表 WorldStateIndoorSectorConfig.json 没读到" +
$"({IndoorSectorCatalog4229938.LoadError})⇒ 退化成只推裸 indoorId," +
"剧情类室内(街机厅/银行/电视台…)不会被点亮。");
}
var raid = state.ActiveRaidId != 0
? state.ActiveRaidId
: PrivateServerConfigStore.Current.World.RaidId;
if (IndoorCatalog4229938.CountForRaid(raid) == 0)
{
lock (owner.SyncRoot)
{
if (owner.WarnedNoRaid)
return;
owner.WarnedNoRaid = true;
}
session.Log.Info($"[INDOOR] 点位表共 {IndoorCatalog4229938.Count} 条," +
$"但没有 raid={raid} 的数据(已有 raid:" +
$"{string.Join(",", IndoorCatalog4229938.Raids)})");
return;
}
// 迟滞:进入用 RadiusMeters,离开用 ExitRadiusMeters(默认 = 进入半径 + 15m)。
// 站在门口来回蹭会不停 Load/Unload,客户端跟着反复建删整棵扇区子树。
uint previous;
bool judged;
lock (owner.SyncRoot)
{
previous = owner.CurrentId;
judged = owner.HasJudged;
}
var enterRadius = Math.Max(1f, cfg.RadiusMeters);
var exitRadius = cfg.ExitRadiusMeters > 0 ? cfg.ExitRadiusMeters : enterRadius + 15f;
var near = IndoorCatalog4229938.Nearest(raid, px, pz, enterRadius);
if (near is null && previous != 0)
{
// 已经在一个室内里:放宽半径再判一次,只有真的走远了才算离开。
near = IndoorCatalog4229938.Nearest(raid, px, pz, exitRadius);
if (near is not null && near.Id != previous)
near = null; // 走到另一个室内的门口,按「离开旧的」处理
}
var geoId = near?.Id ?? 0u;
var now = Environment.TickCount64;
// ★ 权威来源:客户端 SyncChangeIndoor 上报的 indoorConfigId(进/出 GpsBound 时发)。
// 几何最近邻用的是 IndoorConfig.MiniMapPos 矩形(精度低、而且场馆之间互相重叠),
// 实测 ACD Nova Branch(76) / ACD快乐房(129) 在重叠区每几秒跳一次;客户端上报值不跳。
uint clientId = 0;
var clientFresh = false;
if (cfg.PreferClientReport)
{
lock (state.SyncRoot)
{
if (state.ClientIndoorReportMs != 0
&& now - state.ClientIndoorReportMs < Math.Max(0, cfg.ClientReportGraceMs))
{
clientId = state.ClientIndoorConfigId;
clientFresh = true;
}
}
}
var candId = clientFresh ? clientId : geoId;
// ★ 防抖:候选必须连续稳定 switchDelayMs 才真正切换。
// 两个来源都会瞬时抖(客户端自己也会 76↔0),光靠「优先采信」治不了,必须再叠一层。
// 切换期间保持旧 id ⇒ 客户端不会反复建删整棵扇区子树。
uint nowId;
lock (owner.SyncRoot)
{
if (candId == previous)
{
owner.PendingId = 0;
owner.PendingSinceMs = 0;
nowId = previous;
}
else if (owner.PendingId != candId || owner.PendingSinceMs == 0)
{
owner.PendingId = candId;
owner.PendingSinceMs = now;
nowId = previous; // 刚出现,还没确认
}
else if (now - owner.PendingSinceMs < Math.Max(0, cfg.SwitchDelayMs))
{
nowId = previous; // 稳定时间不够,继续等
}
else
{
owner.PendingId = 0;
owner.PendingSinceMs = 0;
nowId = candId; // 确认切换
}
}
var resolved = nowId == 0
? null
: (nowId == geoId ? near : IndoorCatalog4229938.ById(raid, nowId)) ?? near;
if (judged && nowId == previous)
{
// 状态没变。可选:按心跳重声明一次,防止客户端自己把扇区窗口丢掉
// (与 gadget / sceneitem 网格同症状:不持续声明 → 物件一闪就没)。
if (!(cfg.HeartbeatMs > 0) || nowId == 0 || owner.LastPushMs == 0)
return;
if (Environment.TickCount64 - owner.LastPushMs < cfg.HeartbeatMs)
return;
}
lock (owner.SyncRoot)
{
owner.CurrentId = nowId;
owner.HasJudged = true;
owner.LastPushMs = Environment.TickCount64;
}
var sectors = nowId == 0
? (IReadOnlyList<IndoorSector4229938>)Array.Empty<IndoorSector4229938>()
: IndoorSectorCatalog4229938.SectorsFor(nowId);
var src = clientFresh ? "client" : "geo";
if (cfg.LogOnly)
{
session.Log.Info($"[INDOOR] logOnly: indoorId={nowId}" +
(resolved is null ? "(室外)" : $" {resolved.Name} @({resolved.X:F0},{resolved.Z:F0})") +
$" 来源={src}" +
(nowId == 0 ? "" : sectors.Count == 0
? " ⛔该场馆无扇区配置(PCG 室内,本通道管不着)"
: $" ⇒ 扇区 {string.Join(",", sectors.Select(s => s.Id))}"));
return;
}
// 先关旧的再开新的:同时存在两个扇区子树会打架(同一个 IndoorId 两套灯/装饰)。
if (previous != 0 && previous != nowId)
await ClosePushedAsync(session, owner, previous);
if (nowId == 0)
{
if (previous != 0)
session.Log.Info($"[INDOOR] 离开 indoorId={previous}");
return;
}
foreach (var s in sectors)
await PushLoadIndoorSectorAsync(session, s.Id);
if (cfg.AlsoPushRawIndoorId)
await PushLoadIndoorSectorAsync(session, nowId);
if (cfg.PushControl)
{
var pairs = sectors.Select(s => (Id: s.Id, On: true)).ToList();
if (cfg.AlsoPushRawIndoorId)
pairs.Add((nowId, true));
if (pairs.Count > 0)
await PushIndoorSectorControlBatchAsync(session, pairs);
}
lock (owner.SyncRoot)
{
owner.PushedSectors.Clear();
foreach (var s in sectors)
owner.PushedSectors.Add(s.Id);
owner.PushedRawId = cfg.AlsoPushRawIndoorId;
}
session.Log.Info($"[INDOOR] 进入 indoorId={nowId} {resolved?.Name}" +
// ⛑ 别再写「只推了裸 indoorId」:alsoPushRawIndoorId 默认 false,
// 无扇区的场馆其实一条都没推(走 PCG,本通道管不着)。旧文案误导排查。
(sectors.Count == 0 ? "(无扇区配置 ⇒ 走 PCG,本通道管不着,未推任何扇区)"
: $" 扇区 {string.Join(",", sectors.Select(s => s.Id))}") +
$" 来源={src}" +
(resolved?.FromMiniMap == true ? "(坐标来自 MiniMapPos,精度较低)" : ""));
}
/// <summary>把上一次推下去的扇区全部关掉(离开室内 / 换室内时调用)。</summary>
private static async Task ClosePushedAsync(
TcpSession session, IndoorSectorSessionState owner, uint previousIndoorId)
{
if (!PrivateServerConfigStore.Current.World.Content.IndoorSectors.PushControl)
return;
List<uint> ids;
bool raw;
lock (owner.SyncRoot)
{
ids = new List<uint>(owner.PushedSectors);
raw = owner.PushedRawId;
owner.PushedSectors.Clear();
owner.PushedRawId = false;
}
var pairs = ids.Select(id => (Id: id, On: false)).ToList();
if (raw && previousIndoorId != 0)
pairs.Add((previousIndoorId, false));
if (pairs.Count == 0)
return;
await PushIndoorSectorControlBatchAsync(session, pairs);
}
/// <summary>SyncLoadIndoorSector(68095220) —— 单个 UInt32,单字段 args 不带 marker。</summary>
private static Task PushLoadIndoorSectorAsync(TcpSession session, uint id)
{
var body = new UxWriter().U32(id).ToArray();
return session.NotifyAsync(MethodId.SyncLoadIndoorSector, body, CancellationToken.None);
}
/// <summary>
/// SyncIndoorSectorControl(68971505) —— Dictionary&lt;uint,bool&gt;:
/// FF marker + Int32 count(原始,非 7bit)+ 每条 (UInt32 key + u8)。
/// </summary>
private static Task PushIndoorSectorControlAsync(TcpSession session, uint id, bool on)
=> PushIndoorSectorControlBatchAsync(session, new[] { (Id: id, On: on) });
/// <summary>
/// SyncIndoorSectorControl(68971505) —— Dictionary&lt;uint,bool&gt;,一次带多条:
/// FF marker + Int32 count(原始,非 7bit)+ 每条 (UInt32 key + u8)。
/// 一个 IndoorId 常常对应好几个扇区(Spiko Hotel 7 个、ACD Nova Branch 9 个),
/// 所以批量推,避免连发 9 个包。
/// </summary>
private static Task PushIndoorSectorControlBatchAsync(
TcpSession session, IReadOnlyList<(uint Id, bool On)> pairs)
{
var w = new UxWriter();
w.U8(0xFF); // 集合 marker
w.Int7(pairs.Count); // count —— ⛔ 必须是 Int7,不是 I32!见下方 2026-09-27 实机修正
foreach (var (id, on) in pairs)
{
w.U32(id);
w.Bool(on);
}
return session.NotifyAsync(MethodId.SyncIndoorSectorControl, w.ToArray(), CancellationToken.None);
}
}