374 lines
18 KiB
C#
374 lines
18 KiB
C#
using System.Text.Json;
|
||
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using Ananta.Server.RpcTypes.Client4229938;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// 场景物的「显式下发」通道 —— 把摊位 / 货箱 / 柴火 之类的烘焙场景物,从
|
||
/// 「客户端按 40m 格从 blob 铺出来的占位壳」升级成「真的挂了 GameObject 的物体」。
|
||
///
|
||
/// ════════════════════════════════════════════════════════════════════════════════════════
|
||
/// ★ 为什么必须有这个文件(第 79 轮,2026-09-26)
|
||
///
|
||
/// 症状:服务端 push 的 40m 格窗口从 49 格一路放到 289 格,在用 hold 数 2498 → 4531 → 5094
|
||
/// (数据层面全部成功、日志干净、帧率 73),**但玩家在集市里什么都看不见**。
|
||
///
|
||
/// frida 实测(只读,`gym_tools/_r79_render.py` / `_r79_ecs.py`):
|
||
/// · 5094 个在用 hold 里,只有 157 个的 <c>SceneItemObj</c> 非空(3.1%)⇒ 93.7% 只是占位壳;
|
||
/// · 交叉表 <c>_useECSRender × SceneItemObj</c>:
|
||
/// ecs=false | go=true 18
|
||
/// ecs=true | go=false 4975 ← 98%
|
||
/// ⇒ 走 ECS 渲染的 hold **不生成 GameObject**;
|
||
/// · 全部 5094 个的 <c>_forceLODLevel</c> 都 = <c>Init</c>,配合硬规则 8 的实测指纹
|
||
/// (<c>Lod0</c> = 服务端摆的 / <c>Init</c> = 客户端烘焙物)⇒ **它们全部来自客户端自己的 blob**,
|
||
/// 没有任何一个是服务端显式下发的;
|
||
/// · 客户端日志里那句 <c>场景不支持ecs的group数量过多,可能会影响性能, levelConfigType:10,
|
||
/// count:2725</c>(从进入世界第一秒就有、不是本改动引入)说明本场景的 ECS 渲染被关卡守卫关掉 ⇒
|
||
/// 被判给 ECS 的 4975 件**没有任何东西去渲染它们** ⇒ 看不见。
|
||
///
|
||
/// ⇒ 结论:<c>PushSceneItemGridAsync</c> 只推「哪几格」,客户端就从 blob 铺壳;
|
||
/// 要真正物化,必须走 <c>aoiInfo.addInfos</c> 显式逐对象下发。
|
||
/// 这一点在仓库里早有明文(`BowlingWireContracts.cs:591`):
|
||
/// 「the explicit addInfos inside aoiInfo are what actually materialise an object
|
||
/// when the blob has nothing for that cell」
|
||
/// 而**能用的健身房通道**(`GameRouter.Gadget.cs:137`)就是同一个模式:显式下发 + <c>ForceLod0 = true</c>
|
||
/// ——注释原话「ForceLod0 keeps a freshly spawned prop from being culled to a billboard」,
|
||
/// 并且用**客户端 blob 的真实 uid** 当 uniqueId,「so the entity the server places is the same
|
||
/// instance the client would have built itself」。
|
||
///
|
||
/// ════════════════════════════════════════════════════════════════════════════════════════
|
||
/// ★ 所以在整个链路里的位置
|
||
/// 1) 一次性:从客户端 dump 摆位(`gym_tools/_r79_dump_si_place.py`)→
|
||
/// <c>config/scene_item_placements.json</c>(mtime 热重载,改完不用重启服务端);
|
||
/// 2) 每次要让东西出现:<c>POST /api/scene-item/push-explicit</c> 或直接调本类的
|
||
/// <see cref="PushExplicitSceneItemsAsync"/>;半径 / 条数 / 标记 / 通道全部是参数,
|
||
/// **不需要 rebuild、不需要重启**,可以现场 A/B。
|
||
///
|
||
/// ⛑️ 复用 <c>Ananta.Server.RpcTypes.Client4229938</c> 的保龄球 DTO(`BowlingWireContracts.cs`),
|
||
/// 不用 `SceneItemGridMethods4229938.cs` 里那份同名 DTO:两者的元素类型标记不同
|
||
/// (保龄球版 <c>TypeMark = 1</c> = 客户端跳转表里的 BASE;SceneItem 版是默认标记),
|
||
/// 而保龄球版是**在线上验证过能建出 hold 的**那一份。标记写错的表现是
|
||
/// `parameter=DestructibleInfo` 解析失败——donor 第 23B 轮为这个赔过一整个会话。
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
// ---------------------------------------------------------------------------------------
|
||
// 摆位文件(热重载)
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
private static readonly object SceneItemPlacementLock = new();
|
||
private static string? _siPlacementFile;
|
||
private static long _siPlacementStamp = -1;
|
||
private static List<SceneItemPlacement> _siPlacementCache = [];
|
||
/// <summary>上次探测过的候选路径(诊断用)。未命中时不做长期缓存,故这里每次都会刷新。</summary>
|
||
private static List<string> _siPlacementProbe = [];
|
||
|
||
/// <summary>一条场景物摆位。字段名与客户端 hold 上的同名(cfgId / pathId / uniqueId / objPosition)。</summary>
|
||
internal readonly record struct SceneItemPlacement(
|
||
ulong UniqueId, uint CfgId, int PathId, float X, float Y, float Z, float Yaw, int Scale);
|
||
|
||
/// <summary>
|
||
/// 找 <c>config/scene_item_placements.json</c>:从运行目录 / 当前目录各向上找 6 层
|
||
/// (与 <c>BowlingWireNow</c> 同款做法)。找不到返回 null。
|
||
/// </summary>
|
||
private static string? FindSceneItemPlacementFile()
|
||
{
|
||
// ★ 只有「命中」才长期缓存。★ 「未命中」**绝不缓存**:
|
||
// 摆位文件是服务端起来之后才由 _r79_dump_si_place.py 导出的,
|
||
// 旧写法把「没找到」缓存成 "" ⇒ 只有重启服务端才能恢复(2026-09-27 踩到)。
|
||
if (_siPlacementFile is not null && _siPlacementFile.Length > 0)
|
||
return _siPlacementFile;
|
||
|
||
var candidates = new List<string>();
|
||
void AddDir(string? dir)
|
||
{
|
||
if (string.IsNullOrEmpty(dir)) return;
|
||
var d = new DirectoryInfo(dir);
|
||
for (var up = 0; up < 6 && d is not null; up++, d = d.Parent)
|
||
candidates.Add(Path.Combine(d.FullName, "config", "scene_item_placements.json"));
|
||
}
|
||
AddDir(AppContext.BaseDirectory);
|
||
try { AddDir(Directory.GetCurrentDirectory()); } catch { /* ignore */ }
|
||
|
||
_siPlacementProbe = candidates;
|
||
var hit = candidates.FirstOrDefault(File.Exists);
|
||
if (hit is not null)
|
||
_siPlacementFile = hit; // 命中才缓存
|
||
return hit;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 读摆位表(mtime 缓存)。JSON 键名做了宽松兼容,所以 dump 脚本换写法不用改这边:
|
||
/// <code>
|
||
/// { "items": [
|
||
/// { "id": 123456789, "cfg": 80001921, "path": 1154760923,
|
||
/// "x": -4713.9, "y": 176.2, "z": -2821.7, "yaw": 91.5, "scale": 100 },
|
||
/// ...
|
||
/// ] }
|
||
/// </code>
|
||
/// 键名兼容:id|uid|uniqueId|unique_id / cfg|cfgId|configId|config_id / path|pathId|path_id /
|
||
/// x,y,z / yaw|ry|facing|rotY / scale|iScale|i_scale。缺 yaw / scale 时取 0 / 100。
|
||
/// </summary>
|
||
internal static List<SceneItemPlacement> LoadSceneItemPlacements()
|
||
{
|
||
var file = FindSceneItemPlacementFile();
|
||
if (file is null)
|
||
return [];
|
||
|
||
long stamp;
|
||
try { stamp = new FileInfo(file).LastWriteTimeUtc.Ticks; }
|
||
catch { return []; }
|
||
|
||
lock (SceneItemPlacementLock)
|
||
{
|
||
if (stamp == _siPlacementStamp)
|
||
return _siPlacementCache;
|
||
|
||
var list = new List<SceneItemPlacement>();
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(File.ReadAllText(file));
|
||
var root = doc.RootElement;
|
||
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("items", out var items)
|
||
&& items.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var it in items.EnumerateArray())
|
||
{
|
||
if (it.ValueKind != JsonValueKind.Object) continue;
|
||
var p = ParsePlacement(it);
|
||
if (p is not null) list.Add(p.Value);
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.Error.WriteLine($"[SCENE-ITEM-EXPLICIT] placement file unreadable: {ex.Message}");
|
||
return _siPlacementCache;
|
||
}
|
||
|
||
_siPlacementStamp = stamp;
|
||
_siPlacementCache = list;
|
||
return list;
|
||
}
|
||
}
|
||
|
||
private static SceneItemPlacement? ParsePlacement(JsonElement it)
|
||
{
|
||
static bool TryNum(JsonElement o, out double v, params string[] keys)
|
||
{
|
||
foreach (var k in keys)
|
||
if (o.TryGetProperty(k, out var e))
|
||
{
|
||
if (e.ValueKind == JsonValueKind.Number) { v = e.GetDouble(); return true; }
|
||
if (e.ValueKind == JsonValueKind.String
|
||
&& double.TryParse(e.GetString(), System.Globalization.NumberStyles.Float,
|
||
System.Globalization.CultureInfo.InvariantCulture, out var s))
|
||
{ v = s; return true; }
|
||
}
|
||
v = 0;
|
||
return false;
|
||
}
|
||
|
||
if (!TryNum(it, out var uid, "id", "uid", "uniqueId", "unique_id")) return null;
|
||
if (!TryNum(it, out var cfg, "cfg", "cfgId", "configId", "config_id")) return null;
|
||
if (!TryNum(it, out var path, "path", "pathId", "path_id")) return null;
|
||
if (!TryNum(it, out var x, "x")) return null;
|
||
if (!TryNum(it, out var y, "y")) return null;
|
||
if (!TryNum(it, out var z, "z")) return null;
|
||
TryNum(it, out var yaw, "yaw", "ry", "facing", "rotY");
|
||
if (!TryNum(it, out var scale, "scale", "iScale", "i_scale")) scale = 100;
|
||
|
||
if (cfg <= 0 || path == 0) return null; // pathId 必须是客户端资源清单的键(硬规则 7)
|
||
return new SceneItemPlacement((ulong)uid, (uint)cfg, (int)path, (float)x, (float)y, (float)z,
|
||
(float)yaw, (int)scale);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// 下发
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// 把玩家周围 <paramref name="radiusMeters"/> 内的场景物显式下发,让客户端**真的**把 GameObject 装出来。
|
||
///
|
||
/// <paramref name="mode"/>:
|
||
/// <c>aoi</c> = 只走 <c>SyncDestructibleGridAOIDecrease(68969593)</c> 的 <c>aoiInfo.addInfos</c>
|
||
/// (保龄球用的就是这条,hold 建得出来);
|
||
/// <c>addlist</c> = 只走 <c>SyncDestructibleObjectAddList(68963429)</c>;
|
||
/// <c>both</c> = 两条都发(保龄球实际是这么干的,默认)。
|
||
///
|
||
/// <paramref name="forceLod0"/> 默认 true —— 这是**和健身房那条能用的通道唯一对齐的字段**
|
||
/// (`GameRouter.Gadget.cs:137`),关掉它只剩「显式下发」一半的效果,留作 A/B 对照组。
|
||
///
|
||
/// <paramref name="exceptIds"/> = true 时把下发的 uid 塞进 <c>addGridInfo.ExceptIds</c>,
|
||
/// 并按它们所在的格填 <c>StandardIndexList</c>,让客户端**别再**从 blob 铺同一批壳
|
||
/// (<c>ExceptIds</c> 这个字段就是为这件事存在的)。false = 只加不减,作对照组。
|
||
/// </summary>
|
||
internal static async Task<(bool Ok, string Message, int Pushed, int Available, double Nearest)>
|
||
PushExplicitSceneItemsAsync(TcpSession? session, double radiusMeters = 120.0, int maxItems = 48,
|
||
string mode = "both", bool forceLod0 = true, bool exceptIds = true,
|
||
byte addListReason = 14)
|
||
{
|
||
if (session is null)
|
||
return (false, "no session", 0, 0, -1);
|
||
|
||
var all = LoadSceneItemPlacements();
|
||
if (all.Count == 0)
|
||
return (false,
|
||
"config/scene_item_placements.json 没有内容(或文件不存在)——先跑 gym_tools/_r79_dump_si_place.py 导出摆位",
|
||
0, 0, -1);
|
||
|
||
var state = GetStateStatic(session);
|
||
float px, pz;
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (!state.HasLastReportedPlayerTransform)
|
||
return (false, "还没有玩家坐标(先进入世界、角色出现在游戏里)", 0, all.Count, -1);
|
||
px = state.LastReportedPlayerPosition.X;
|
||
pz = state.LastReportedPlayerPosition.Z;
|
||
}
|
||
|
||
var r = Math.Max(1.0, radiusMeters);
|
||
var r2 = r * r;
|
||
var near = new List<(SceneItemPlacement P, double D)>();
|
||
var nearest = double.MaxValue;
|
||
foreach (var p in all)
|
||
{
|
||
var dx = p.X - px;
|
||
var dz = p.Z - pz;
|
||
var d2 = (double)dx * dx + (double)dz * dz;
|
||
if (d2 < nearest) nearest = d2;
|
||
if (d2 <= r2) near.Add((p, Math.Sqrt(d2)));
|
||
}
|
||
nearest = nearest == double.MaxValue ? -1 : Math.Sqrt(nearest);
|
||
|
||
if (near.Count == 0)
|
||
return (false, $"半径 {r:F0} m 内一条摆位都没有(最近的一条在 {nearest:F1} m)", 0, all.Count, nearest);
|
||
|
||
near.Sort((a, b) => a.D.CompareTo(b.D));
|
||
var cap = maxItems <= 0 ? near.Count : Math.Min(maxItems, near.Count);
|
||
var picked = new List<SceneItemPlacement>(cap);
|
||
for (var i = 0; i < cap; i++) picked.Add(near[i].P);
|
||
|
||
var cx = (int)MathF.Floor(px / SceneItemCellSize);
|
||
var cz = (int)MathF.Floor(pz / SceneItemCellSize);
|
||
|
||
var infos = new List<DestructibleInfo>(picked.Count);
|
||
var ids = new List<ulong>(picked.Count);
|
||
var cells = new HashSet<(int, int)>();
|
||
foreach (var p in picked)
|
||
{
|
||
infos.Add(new DestructibleInfo
|
||
{
|
||
InstanceId = p.UniqueId,
|
||
UniqueId = p.UniqueId,
|
||
CfgId = p.CfgId,
|
||
PathId = p.PathId,
|
||
Position = new UXVector3 { X = p.X, Y = p.Y, Z = p.Z },
|
||
Facing = new UXVector3 { X = 0f, Y = p.Yaw, Z = 0f },
|
||
IScale = p.Scale,
|
||
NavId = 0,
|
||
// ★ 与健身房通道(GameRouter.Gadget.cs:137)对齐的**唯一一个渲染开关**。
|
||
ForceLod0 = forceLod0,
|
||
BindPid = 0,
|
||
Hp = 1f,
|
||
State = 0, // DestructibleState.Active
|
||
BreakStage = 0,
|
||
OccupantInfos = [],
|
||
DropWeaponId = 0,
|
||
StackCount = 1,
|
||
EffectIds = [],
|
||
NoSleep = true,
|
||
ReleaserId = 0,
|
||
SkillInfo = null,
|
||
});
|
||
ids.Add(p.UniqueId);
|
||
cells.Add(((int)MathF.Floor(p.X / SceneItemCellSize), (int)MathF.Floor(p.Z / SceneItemCellSize)));
|
||
}
|
||
|
||
var doAoi = mode is "aoi" or "both" or "";
|
||
var doList = mode is "addlist" or "both" or "";
|
||
var sent = new List<string>();
|
||
|
||
try
|
||
{
|
||
if (doAoi)
|
||
{
|
||
var indexList = new List<SceneMethods.GridIndex> { new() { X = cx, Z = cz } };
|
||
var exceptList = new List<ulong>();
|
||
if (exceptIds)
|
||
{
|
||
foreach (var c in cells) indexList.Add(new SceneMethods.GridIndex { X = c.Item1, Z = c.Item2 });
|
||
exceptList.AddRange(ids);
|
||
}
|
||
|
||
var args = new SyncDestructibleGridAOIDecreaseArgs4229938
|
||
{
|
||
aoiInfo = new DestructibleGridAOIIncrease
|
||
{
|
||
PlayerStandardIndex = new SceneMethods.GridIndex { X = cx, Z = cz },
|
||
AddInfos = infos,
|
||
IndexList = indexList,
|
||
AddUniqueIds = new List<ulong>(ids),
|
||
RemoveIds = [],
|
||
Reason = 1, // AoiAddAndRemoveReason.Loaded
|
||
},
|
||
addGridInfo = new SceneMethods.GridAOIDecrease
|
||
{
|
||
SectorIdList = [0],
|
||
BuildingId = 0,
|
||
StandardIndexList = indexList
|
||
.Select(g => new SceneMethods.GridIndex { X = g.X, Z = g.Z }).ToList(),
|
||
ExceptIds = exceptList,
|
||
},
|
||
removeGridInfo = new SceneMethods.GridAOIDecrease(),
|
||
};
|
||
var body = UxSerializer.Serialize(args);
|
||
await session.NotifyAsync(MethodId.SyncDestructibleGridAOIDecrease, body, CancellationToken.None);
|
||
sent.Add($"aoi(68969593) bytes={body.Length}");
|
||
}
|
||
|
||
if (doList)
|
||
{
|
||
var body = UxSerializer.Serialize(new DestructibleObjectAddList
|
||
{
|
||
AddInfos = infos,
|
||
Reason = addListReason,
|
||
});
|
||
await session.NotifyAsync(MethodId.SyncDestructibleObjectAddList, body, CancellationToken.None);
|
||
sent.Add($"addlist(68963429) bytes={body.Length} reason={addListReason}");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return (false, $"send failed: {ex.Message}", 0, all.Count, nearest);
|
||
}
|
||
|
||
var msg = $"已显式下发 {picked.Count}/{near.Count} 个(半径 {r:F0} m,共 {all.Count} 条摆位," +
|
||
$"最近 {nearest:F1} m,ForceLod0={forceLod0},exceptIds={exceptIds})via {string.Join(" + ", sent)}";
|
||
session.Log.Info($"[SCENE-ITEM-EXPLICIT] {msg}");
|
||
return (true, msg, picked.Count, all.Count, nearest);
|
||
}
|
||
|
||
/// <summary>诊断:摆位文件是否可用、总条数、解析失败的条数。</summary>
|
||
internal static object SceneItemPlacementStatus()
|
||
{
|
||
var file = FindSceneItemPlacementFile();
|
||
var list = LoadSceneItemPlacements();
|
||
return new
|
||
{
|
||
file = file ?? "(not found)",
|
||
exists = file is not null,
|
||
count = list.Count,
|
||
probe = _siPlacementProbe.Count == 0 ? null : _siPlacementProbe,
|
||
first = list.Count == 0
|
||
? null
|
||
: new { id = list[0].UniqueId, cfg = list[0].CfgId, path = list[0].PathId,
|
||
x = list[0].X, y = list[0].Y, z = list[0].Z, yaw = list[0].Yaw },
|
||
};
|
||
}
|
||
}
|