Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.SceneItemExplicit.cs
T

374 lines
18 KiB
C#
Raw 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.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 },
};
}
}