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;
///
/// 室内扇区(sector_high_indoor_<IndoorId>)—— 服务端主动驱动,不需要 frida 注入器。
///
///
/// ★★★ 这是 2026-09-27 之前服务端唯一的「协议上完全可行、但完全够不着」的通道:
/// 6 个 indoor sector 的 MethodId 常量早就存在(MethodId.cs 3074 / 3813 / 3650 /
/// 2162 / 1977 / 3885),但全仓零使用 —— 没有任何 Handler 推过它们。
/// (⛔ 2026-09-27 订正:之前记成「连常量都没有」是错的,常量在,缺的是 Handler。)
///
/// 通道归属:独立于 gadget 网格(68956797)/ sceneitem 网格(68969593)/ 单件摆放
/// (68843189)/ PCG(无外部触发点)—— 是第五条通道。sector 只管「这块区域显不显示」,
/// 子树里是 _lights / _decoration(灯、镜子、LOD 组等静态装饰)。
/// 家具本体不归它管(挂在 GadgetInstanceRoot,走 F/A 通道),
/// 所以室内完整还原 = 本通道开区域 + PropRestock 补家具,两条都要。
///
/// IndoorId 从哪来:不需要客户端上报,也不需要 frida 导出
/// WorldStateIndoorSectorConfig。服务端读 Configs/IndoorConfig.json,
/// 按「离玩家最近的室内条目」推出 IndoorId。
/// 实测对得上:客户端日志 Enter GpsBound: RaidId 23300888, IndoorId 61
/// ⇔ IndoorConfig.Id=61 = "WowDonald's" @ (859,1847),玩家在 (866,1840),差 9.5 m。
///
/// ★★ 2026-09-27 补证:推下去的到底是哪个号
/// 1. 先由位置推出 IndoorId(上面这条,来自 IndoorConfig);
/// 2. 再用 Configs/WorldStateIndoorSectorConfig.json 把 IndoorId 翻成
/// 扇区 Id(17600002…17600109) —— 详见 。
/// 扇区 Id ≠ IndoorId,是该表的 BelongingIndoorId 字段指回 IndoorId。
/// 一个 IndoorId 可以有多个扇区(Spiko Hotel 7 个、ACD Nova Branch 9 个)。
/// 3. 参数语义 2026-09-27 20:1x 已用 A/B 定案:就是扇区 Id。
/// 同一轮推 9 个扇区 Id + 1 个裸 IndoorId,客户端只报错裸 IndoorId:
/// #NoCreateIssue Nexus SetIndoorSectorState …(17600101,True)…(17600109,True),(76,True)
/// + config not found for id 76 —— 9 个扇区 Id 全被接受。
/// ⇒ alsoPushRawIndoorId 已默认关闭,别再打开。
/// ⛑ SyncLoadIndoorSector 这条 9 个里成功 7 个(2026-09-27 20:4x 实机)。
/// 客户端每轮固定报 2 条
/// LoadSectorByName: nameHash:-1386073846 / -1386073848 not found,
/// 时间戳与服务端每次推送逐秒对齐(20:41:45/:50/:55 …),hash 恒定不变 ⇒
/// 不是随机错位,是「9 个扇区里有 2 个的场景对象不在当前已加载子场景里」。
/// 另外 9 个 id 在 SetIndoorSectorState 里 0 条 config not found(全被接受)。
/// ⇒ 通道是通的,剩下 2 个多半在别的楼层(ACD MapFloorList=[-1,1,2])。
/// (hash 不是 FNV1/FNV1a/CRC32/djb2/sdbm/BKDR/RS/JS/PJW/ELF/DEK/AP/Java31/
/// Murmur2/OAAT,已逐个反查过 44 个 SectorName,别再反查。)
/// ★ 2026-09-27 20:4x 抖动根因 + 修复:ACD Nova Branch(Id=76) 与
/// ACD快乐房(Id=129) 的 MiniMapPos 矩形互相重叠,纯几何最近邻
/// 在重叠区每几秒 76↔129 跳一次 ⇒ 客户端反复建删 9 棵扇区子树。
/// 修法两条:① 优先采信客户端 SyncChangeIndoor 上报的权威 indoorConfigId
/// (preferClientReport);② 再叠 switchDelayMs 防抖(客户端自己也会
/// 瞬时抖到 0,光靠 ① 治不了)。实测把玩家传送到 76 的非重叠地段后立刻稳定。
/// ⛔ 覆盖上限:全表 44 扇区 / 约 25 场馆,全是剧情室内;普通店铺(麦当劳等)
/// 一条都没有,走 PCG,本通道管不着。
///
/// 线格式(C# 原生,无 lua 实现 —— 已从 lua_full 全文 grep 确认为 native):
///
/// S2C SyncLoadIndoorSector(68095220) = uint indoorSectorControlId
/// → 4 字节裸 UInt32(单字段 args,无 marker)
/// S2C SyncIndoorSectorControl(68971505) = Dictionary<uint, bool>
/// → FF(marker)+ Int7 count(7bit,+1 偏置)
/// + 每条 (UInt32 LE key + u8 0/1)
///
/// 依据:dump.cs UXRPCMethodArgs68095220.indoorSectorControlId /
/// UXRPCMethodArgs68971505.indoorSectorControl;集合字段恒定带 marker(硬规则 182)。
///
/// ★★ 2026-09-27 19:5x 实机订正(必须记住):
/// count 一开始写成了 I32(照搬 UxContract.WriteDictionary 的框架默认
/// UxCountEncoding.Int32),结果客户端把 4 字节计数当 varint 读,整条 payload 错位:
/// Player.log 刷了 153 行
/// SetIndoorSectorState: config not found for id 1694498816 / 1711341836 / …。
/// 把收到的错位字节反推求解后,九个产物值与「FF + Int7(count) + U32 LE key」的模拟
/// 结果逐个相等(差分全 0),而且「发 10 条 / 客户端只处理 9 条」正好对应
/// varint(0x0A)=10 再 −1 ⇒ Int7。
/// ⇒ 这条字段在客户端契约里是显式 [UxCollection(Count=Int7)],
/// 不能用框架默认的 Int32(见 TOPIC_序列化UxContract.md 第 10 行)。
/// ⇒ 以后再手写 any Dictionary payload,count 一律先试 Int7。
///
/// 回滚:world.content.indoorSectors.enabled = false(不需要重新编译)。
///
internal sealed partial class GameRouter
{
private const string IndoorSectorKey = "client4229938-indoor-sector";
internal sealed class IndoorSectorSessionState
{
internal object SyncRoot { get; } = new();
/// 上一次推下去的 indoor id;0 = 当前判定在室外。
internal uint CurrentId;
/// 进过至少一个室内(用于区分「还没判定过」和「判定为室外」)。
internal bool HasJudged;
internal long LastPushMs;
internal bool WarnedEmpty;
internal bool WarnedNoRaid;
internal int Pushes;
/// 上一次真正推下去的扇区 Id 列表(离开时要逐条关掉)。
internal List PushedSectors { get; } = new();
/// 上一次是否额外推了「裸 IndoorId」那一路。
internal bool PushedRawId;
internal bool WarnedNoSectorTable;
///
/// 防抖用:正在观察的候选 indoor id(0 = 没有候选)。
/// 见 的 indoorSectors.switchDelayMs。
///
internal uint PendingId;
/// 候选 id 第一次被观察到的时刻(TickCount64)。
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;
}
/// 重连 / 切场景:忘掉当前室内,下次重新判定(客户端也把扇区状态丢了)。
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;
}
}
///
/// 由 gadget 网格 tick 顺带驱动。玩家走进/离开某个 IndoorConfig 条目附近时,
/// 推 SyncLoadIndoorSector + SyncIndoorSectorControl。
///
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)Array.Empty()
: 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,精度较低)" : ""));
}
/// 把上一次推下去的扇区全部关掉(离开室内 / 换室内时调用)。
private static async Task ClosePushedAsync(
TcpSession session, IndoorSectorSessionState owner, uint previousIndoorId)
{
if (!PrivateServerConfigStore.Current.World.Content.IndoorSectors.PushControl)
return;
List ids;
bool raw;
lock (owner.SyncRoot)
{
ids = new List(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);
}
/// SyncLoadIndoorSector(68095220) —— 单个 UInt32,单字段 args 不带 marker。
private static Task PushLoadIndoorSectorAsync(TcpSession session, uint id)
{
var body = new UxWriter().U32(id).ToArray();
return session.NotifyAsync(MethodId.SyncLoadIndoorSector, body, CancellationToken.None);
}
///
/// SyncIndoorSectorControl(68971505) —— Dictionary<uint,bool>:
/// FF marker + Int32 count(原始,非 7bit)+ 每条 (UInt32 key + u8)。
///
private static Task PushIndoorSectorControlAsync(TcpSession session, uint id, bool on)
=> PushIndoorSectorControlBatchAsync(session, new[] { (Id: id, On: on) });
///
/// SyncIndoorSectorControl(68971505) —— Dictionary<uint,bool>,一次带多条:
/// FF marker + Int32 count(原始,非 7bit)+ 每条 (UInt32 key + u8)。
/// 一个 IndoorId 常常对应好几个扇区(Spiko Hotel 7 个、ACD Nova Branch 9 个),
/// 所以批量推,避免连发 9 个包。
///
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);
}
}