426 lines
20 KiB
C#
426 lines
20 KiB
C#
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_<IndoorId></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<uint, bool>
|
||
/// → 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<uint,bool>:
|
||
/// 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<uint,bool>,一次带多条:
|
||
/// 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);
|
||
}
|
||
}
|