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