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