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;
///
/// 场景物的「显式下发」通道 —— 把摊位 / 货箱 / 柴火 之类的烘焙场景物,从
/// 「客户端按 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 个的 SceneItemObj 非空(3.1%)⇒ 93.7% 只是占位壳;
/// · 交叉表 _useECSRender × SceneItemObj:
/// ecs=false | go=true 18
/// ecs=true | go=false 4975 ← 98%
/// ⇒ 走 ECS 渲染的 hold **不生成 GameObject**;
/// · 全部 5094 个的 _forceLODLevel 都 = Init,配合硬规则 8 的实测指纹
/// (Lod0 = 服务端摆的 / Init = 客户端烘焙物)⇒ **它们全部来自客户端自己的 blob**,
/// 没有任何一个是服务端显式下发的;
/// · 客户端日志里那句 场景不支持ecs的group数量过多,可能会影响性能, levelConfigType:10,
/// count:2725(从进入世界第一秒就有、不是本改动引入)说明本场景的 ECS 渲染被关卡守卫关掉 ⇒
/// 被判给 ECS 的 4975 件**没有任何东西去渲染它们** ⇒ 看不见。
///
/// ⇒ 结论:PushSceneItemGridAsync 只推「哪几格」,客户端就从 blob 铺壳;
/// 要真正物化,必须走 aoiInfo.addInfos 显式逐对象下发。
/// 这一点在仓库里早有明文(`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`)就是同一个模式:显式下发 + ForceLod0 = true
/// ——注释原话「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`)→
/// config/scene_item_placements.json(mtime 热重载,改完不用重启服务端);
/// 2) 每次要让东西出现:POST /api/scene-item/push-explicit 或直接调本类的
/// ;半径 / 条数 / 标记 / 通道全部是参数,
/// **不需要 rebuild、不需要重启**,可以现场 A/B。
///
/// ⛑️ 复用 Ananta.Server.RpcTypes.Client4229938 的保龄球 DTO(`BowlingWireContracts.cs`),
/// 不用 `SceneItemGridMethods4229938.cs` 里那份同名 DTO:两者的元素类型标记不同
/// (保龄球版 TypeMark = 1 = 客户端跳转表里的 BASE;SceneItem 版是默认标记),
/// 而保龄球版是**在线上验证过能建出 hold 的**那一份。标记写错的表现是
/// `parameter=DestructibleInfo` 解析失败——donor 第 23B 轮为这个赔过一整个会话。
///
internal sealed partial class GameRouter
{
// ---------------------------------------------------------------------------------------
// 摆位文件(热重载)
// ---------------------------------------------------------------------------------------
private static readonly object SceneItemPlacementLock = new();
private static string? _siPlacementFile;
private static long _siPlacementStamp = -1;
private static List _siPlacementCache = [];
/// 上次探测过的候选路径(诊断用)。未命中时不做长期缓存,故这里每次都会刷新。
private static List _siPlacementProbe = [];
/// 一条场景物摆位。字段名与客户端 hold 上的同名(cfgId / pathId / uniqueId / objPosition)。
internal readonly record struct SceneItemPlacement(
ulong UniqueId, uint CfgId, int PathId, float X, float Y, float Z, float Yaw, int Scale);
///
/// 找 config/scene_item_placements.json:从运行目录 / 当前目录各向上找 6 层
/// (与 BowlingWireNow 同款做法)。找不到返回 null。
///
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();
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;
}
///
/// 读摆位表(mtime 缓存)。JSON 键名做了宽松兼容,所以 dump 脚本换写法不用改这边:
///
/// { "items": [
/// { "id": 123456789, "cfg": 80001921, "path": 1154760923,
/// "x": -4713.9, "y": 176.2, "z": -2821.7, "yaw": 91.5, "scale": 100 },
/// ...
/// ] }
///
/// 键名兼容: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。
///
internal static List 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();
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);
}
// ---------------------------------------------------------------------------------------
// 下发
// ---------------------------------------------------------------------------------------
///
/// 把玩家周围 内的场景物显式下发,让客户端**真的**把 GameObject 装出来。
///
/// :
/// aoi = 只走 SyncDestructibleGridAOIDecrease(68969593) 的 aoiInfo.addInfos
/// (保龄球用的就是这条,hold 建得出来);
/// addlist = 只走 SyncDestructibleObjectAddList(68963429);
/// both = 两条都发(保龄球实际是这么干的,默认)。
///
/// 默认 true —— 这是**和健身房那条能用的通道唯一对齐的字段**
/// (`GameRouter.Gadget.cs:137`),关掉它只剩「显式下发」一半的效果,留作 A/B 对照组。
///
/// = true 时把下发的 uid 塞进 addGridInfo.ExceptIds,
/// 并按它们所在的格填 StandardIndexList,让客户端**别再**从 blob 铺同一批壳
/// (ExceptIds 这个字段就是为这件事存在的)。false = 只加不减,作对照组。
///
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(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(picked.Count);
var ids = new List(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();
try
{
if (doAoi)
{
var indexList = new List { new() { X = cx, Z = cz } };
var exceptList = new List();
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(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);
}
/// 诊断:摆位文件是否可用、总条数、解析失败的条数。
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 },
};
}
}