using Ananta.SDK.Logging; using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// 房产自带家具的「家具机关(furniture gadget)」—— 2026-10-01。 /// /// 用户报的症状:「沙发椅子已经生成了的没法交互」—— /// 家具模型都在(AskLoadHouseData 的 FloorBuildInfoDict 已上线), /// 但椅子坐不了、衣柜开不了、电视点不亮。 /// /// 根因(一句话):客户端把家具建出来之后会再问一次服务端要机关实例 id —— /// HouseGadgetManager.lua:CreateGadgetsForFurnitures → /// AskCreateHouseFurnitureGadgets(63581446),读应答里的 /// mapping.placedID2GadgetInstanceID,非 0 才写进 placedId2GadgetIdMap /// 并 gFurnitureUtils:TrySetGadgetInstanceId(furnitureGo, id)。 /// 而这条 RPC 在服务端一直是 DefaultReturnCatalog4229938.cs:624 的 /// body=[0xFF,0x01] shape="struct(631:dict7-empty)" —— 恒回空映射 /// ⇒ placedId2GadgetIdMap 恒空 ⇒ 所有机关失效。日志铁证(console-latest.log:319-321): /// /// -> rpc AskCreateHouseFurnitureGadgets [0x03CA2D06] 274b /// ! [RPC4229938] unimplemented AskCreateHouseFurnitureGadgets -> typed-default struct(631:dict7-empty) 2b /// <- ret ... e=0 2b /// /// /// ★ 为什么说「家具树本身是对的,只缺这一步」:客户端请求的 id 个数 /// 逐套精确等于该房模板树里「HouseFurnitureConfig.PathId != 0」的家具数 —— /// 绿屿 33/33、旧物仓 3/3、观澜 6/6、素光 1/1(unknown-methods.log 请求体逐字节解码后与模板树对齐)。 /// 而且请求里的 id 正好是 PlacedInstanceId = houseId*100000 + nodeId 的 32 位截断形态 /// (绿屿首件 0xC5388A93 - 0xC5381F20 = 14003 = 模板节点号)。 /// /// ★ 机关 prefab 从哪来:HouseFurnitureConfig.PathId /// 就是 GadgetCatalog.json 的 pathId(逐条核对过,见 /// HouseFurnitureGadgetCatalog4229938 的注释表)。位置也不用反推 —— /// HouseFurnitureTemplateConfig 节点的 Position 就是世界绝对坐标 /// (绿屿椅 (2290.629, 160.720, 1397.878)、素光衣柜 (1096.527, 13.040, 2375.721)), /// Rotation.Y 就是 yaw。 /// /// 本文件做的事: /// /// AskCreateHouseFurnitureGadgets:对请求里每个 placedId /// 反解 nodeId → FurnitureId → PathId,回 placedId → gadgetInstanceId 映射; /// spawnGadgetEntities=true(默认)时同时用 SyncGadgetAdd(68843189) /// 把机关实体摆到家具节点的世界坐标上(uniqueId = placedId,与返回值同源)。 /// AskDestoryHouseFurnitureGadgets:摘掉记录 + 推 /// SyncGadgetRemoveList(68199571),回空 body(客户端该 RPC 的应答 reader 是空函数)。 /// /// /// ⛔ 两个开关都要留(gameplay.houseFurniture): /// gadgets 是总开关;spawnGadgetEntities 决定要不要真的 SyncGadgetAdd。 /// 后者是实测分流用的 —— 有两种可能,客户端行为决定哪一种: /// /// H1(默认,spawn=true):机关实体得由服务端 SyncGadgetAdd 造出来, /// 客户端 OnGadgetSlotCompLoaded 的 gGadgetManager:GetEntitySearchByInstanceId(gadgetId) 才找得到。 /// H2(spawn=false):机关 GameObject 本来就在家具 prefab 里 /// (RegisterFurnitureLuaSlotReplace 用的就是 houseFurniture.gadgetGameObject), /// 服务端只需发一个唯一的非 0 id,客户端自己 TrySetGadgetInstanceId 就能把它点亮。 /// OnGadgetSlotCompLoaded 里那个 10 帧重试计时器更像 H2(id 要等 TrySetGadgetInstanceId 之后才可寻址)。 /// /// 上机判据:H1 若把椅子/灯渲染成两遍就是猜错了,把 spawnGadgetEntities 改 false 即可(热重载,不用重编译)。 /// /// 回滚:gameplay.houseFurniture.gadgets=false(回到恒空映射兜底), /// 或把两条 mid 从 Enabled4229938MethodIds 删掉。两者都与改动前逐字节一致。 /// internal sealed partial class GameRouter { /// HouseFurnitureTree4229938.PlacedIdStride 的同一常量(那里是 private)。 private const ulong HousePlacedIdStride = 100000UL; private const ulong Uint32Mask = 0xFFFFFFFFUL; /// /// 63581446 — HouseGadgetManager.CreateGadgetsForFurnitures 的请求: /// 「这些 placedId 的家具,给我它们的机关实例 id」。 /// /// 进房子时每层各发一次(素光小筑两层会发两次,同一节点出现两遍)。 /// 客户端离开房子时 DestroyGadgetsForFurnitures 会发 AskDestoryHouseFurnitureGadgets, /// 但场景切换也会清空客户端的表(OnBeforeSwitchScene → ClearAll), /// 所以这里每次请求都重推 SyncGadgetAdd(同一个 instanceId,幂等刷新), /// 否则「出去再进来」家具就再也点不动了。 /// [Handler(MethodId.AskCreateHouseFurnitureGadgets, HandlerPacketKind.Invoke)] private async Task OnCreateHouseFurnitureGadgets(Connection conn, UxRpcMessage msg) { var cfg = PrivateServerConfigStore.Current.Gameplay.HouseFurniture; var result = new GameMethods.AskCreateHouseFurnitureGadgetsResult4229938(); if (!msg.TryGetArgs(out var args) || args is null) { conn.Log.Warn($"[HOUSEGADGET] AskCreateHouseFurnitureGadgets 解码失败 bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body)}"); await conn.ReturnAsync(msg, result); return; } if (!cfg.Enabled || !cfg.Gadgets) { conn.Log.Info($"[HOUSEGADGET] 机关生成跳过 house={args.houseid} floor={args.floor} 件数={args.placedgadgets.Count} 原因=" + (cfg.Enabled ? "houseFurniture.gadgets=false" : "houseFurniture.enabled=false")); await conn.ReturnAsync(msg, result); return; } var state = GetStateIfExists(conn.Session); if (state is null) { conn.Log.Warn($"[HOUSEGADGET] 没有世界状态 ⇒ 只回映射不生成实体 house={args.houseid}"); await conn.ReturnAsync(msg, result); return; } if (!HouseCatalog4229938.TryGet(args.houseid, out var house) || house.BuildId == 0) { conn.Log.Warn($"[HOUSEGADGET] 未知房产 house={args.houseid} ⇒ 空映射(件数={args.placedgadgets.Count})"); await conn.ReturnAsync(msg, result); return; } var baseOffset = (args.houseid * HousePlacedIdStride) & Uint32Mask; var spawned = 0; var reused = 0; var noGadget = 0; var unknownNode = 0; var details = new List(); // 先把「要摆什么」全部解析出来,再决定走哪条通道发(见 cfg.Channel)。 // 2026-10-01 上机:add 通道服务端日志全对(44 条 [GADGET] add),但客户端 // GadgetInstanceRoot 下没有任何家具实体 ⇒ 换 addList 再试一次。 var specs = new List(); var wanted = new List<(ulong PlacedId, uint PathId)>(); foreach (var placedId in args.placedgadgets) { if (placedId == 0) continue; var nodeId = ResolveNodeIdFromPlacedId(placedId, args.houseid, baseOffset); if (nodeId == 0) { unknownNode++; continue; } if (!HouseFurnitureTemplateCatalog4229938.TryGetNode(house.BuildId, nodeId, out var node)) { unknownNode++; continue; } if (!HouseFurnitureGadgetCatalog4229938.TryGetGadgetPathId(node.FurnitureId, out var pathId)) { // 客户端理论上只为 PathId != 0 的家具发请求;真收到了也照实跳过。 noGadget++; continue; } var (x, y, z) = ApplyGadgetOffset(node, node.FurnitureId); if (!cfg.SpawnGadgetEntities) { reused++; result.placedID2GadgetInstanceID[placedId] = placedId; if (details.Count < 6) details.Add($"{placedId}(pathId {pathId}, 不生成实体)"); continue; } // uniqueId = placedId ⇒ 实体 id 与返回给客户端的 gadgetInstanceId 是同一个数, // 客户端「按 instanceId 找实体」才找得到。 specs.Add(new GadgetSpawnSpec((int)pathId, x, y, z, node.RotationY, placedId, "")); wanted.Add((placedId, pathId)); } var channel = (cfg.Channel ?? "add").Trim().ToLowerInvariant(); var sentAdd = false; var sentAddList = false; if (specs.Count > 0) { if (channel is "addlist" or "both") { var batch = await SpawnGadgetListAsync(conn.Session, state, specs, houseFurniture: true); sentAddList = batch.Ok; if (!batch.Ok) conn.Log.Warn($"[HOUSEGADGET] 批量生成失败 house={args.houseid} 件数={specs.Count}: {batch.Message}"); } if (channel is "add" or "both") { sentAdd = true; foreach (var spec in specs) { var (ok, message, _) = await SpawnGadgetAsync( conn.Session, state, spec.PathId, spec.X, spec.Y, spec.Z, spec.Yaw, channel: GadgetSendChannel.Add, uniqueId: spec.UniqueId); if (!ok) conn.Log.Warn($"[HOUSEGADGET] 生成失败 placedId={spec.UniqueId} pathId={spec.PathId}: {message}"); } } foreach (var (placedId, pathId) in wanted) { spawned++; result.placedID2GadgetInstanceID[placedId] = placedId; if (details.Count < 6) details.Add($"{placedId}→{placedId}(pathId {pathId})"); } } conn.Log.Info($"[HOUSEGADGET] house={args.houseid}({house.Name}) build={house.BuildId} floor={args.floor} " + $"请求={args.placedgadgets.Count} → 映射={result.placedID2GadgetInstanceID.Count} " + $"(生成实体={spawned} 仅回id={reused} 无机关={noGadget} 节点未知={unknownNode}) " + $"spawnEntities={cfg.SpawnGadgetEntities} channel={channel} add={sentAdd} addList={sentAddList} " + $"样例=[{string.Join(", ", details)}]"); await conn.ReturnAsync(msg, result); } /// /// 63759452 — HouseGadgetManager.DestroyGadgetsForFurnitures(官方拼写就是 Destory)。 /// 离开房子 / 切场景时客户端会发;应答是 void(客户端 reader 是空函数)。 /// [Handler(MethodId.AskDestoryHouseFurnitureGadgets, HandlerPacketKind.Invoke)] private async Task OnDestoryHouseFurnitureGadgets(Connection conn, UxRpcMessage msg) { if (!msg.TryGetArgs(out var args) || args is null) { conn.Log.Warn($"[HOUSEGADGET] AskDestoryHouseFurnitureGadgets 解码失败 bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body)}"); await conn.ReturnEmptyOkAsync(msg); return; } var state = GetStateIfExists(conn.Session); if (state is null || args.placedfurnitures.Count == 0) { await conn.ReturnEmptyOkAsync(msg); return; } var ids = new List(); lock (state.SyncRoot) { foreach (var placedId in args.placedfurnitures) { if (placedId != 0 && state.SpawnedGadgets.Remove(placedId)) ids.Add(placedId); } } if (ids.Count > 0) { await conn.NotifyAsync( MethodId.SyncGadgetRemoveList, new SceneMethods.SyncGadgetRemoveListArgs4229938 { ids = ids, reason = GadgetDisposeReasonGm, }); } conn.Log.Info($"[HOUSEGADGET] 销毁机关 house={args.houseid} floor={args.floor} " + $"请求={args.placedfurnitures.Count} 实销={ids.Count}"); await conn.ReturnEmptyOkAsync(msg); } /// /// ★★ 2026-10-01:把家具机关实体在每次网格 AOI 推送之后补回来。 /// /// 为什么必须补:SyncGadgetGridAOIDecrease 的 addGridInfo 在客户端是 /// 「当前 AOI 快照」语义 —— 本仓 GameRouter.GadgetGrid.cs 的注释早就记过这一点 /// (「an AOIMove that only carried the newly entered cells made every previously streamed prop /// disappear」)。世界摆件(摊位 / 派对家具 / 健身房器材)被撤掉后,客户端能从自己的 /// blob里再铺出来,所以它们没事;而家具机关是纯服务端造的、blob 里没有 /// ⇒ 一撤就永久消失。 /// /// 实测铁证(E:\anatacbt\log\Player.log,2026-10-01 10:23–10:24): /// /// 10:23:26.554 S2C NOTIFY SyncGadgetAddList (3731b) ← 33 件建出来 /// 10:23:34.103 [DRMK-HG] entityHits=33/33 ← 全在 /// 10:23:37.496 S2C NOTIFY SyncGadgetGridAOIDecrease (1363b) ← 第一次网格快照 /// 10:23:56.345 [DRMK-HG] entityHits=0/33 ← 全没了 /// /// 该窗口内唯一的机关类 S2C 就是 SyncGadgetGridAOIDecrease(每 5 秒一次, /// cells=81)。 /// /// 补发走同一条通道(SyncGadgetAddList)且 uniqueId = placedId 不变 —— /// 与 OnCreateHouseFurnitureGadgets 里那条「每次请求都重推」是同一个幂等刷新手法, /// 不会多出第二份家具。只补玩家附近(<300 m)那一套房的,跑到别的区就不做无用功。 /// /// 关掉:gameplay.houseFurniture.respawnAfterGridPush=false(热重载,不用重编译)。 /// internal static async Task TickHouseFurnitureGadgetsAsync( TcpSession session, WorldEntryState state, float px, float pz) { var cfg = PrivateServerConfigStore.Current.Gameplay.HouseFurniture; if (!cfg.Enabled || !cfg.Gadgets || !cfg.SpawnGadgetEntities || !cfg.RespawnAfterGridPush) return; List specs; lock (state.SyncRoot) { specs = state.SpawnedGadgets.Values .Where(r => r.IsHouseFurniture && r.PathId != 0 && IsNearPlayer(r.X, r.Z, px, pz)) .Select(r => new GadgetSpawnSpec(r.PathId, r.X, r.Y, r.Z, r.Yaw, r.InstanceId, r.Name)) .ToList(); } if (specs.Count == 0) return; var batch = await SpawnGadgetListAsync(session, state, specs, houseFurniture: true); if (!batch.Ok) session.Log.Warn($"[HOUSEGADGET] 网格推送后补发失败 {specs.Count} 件:{batch.Message}"); } /// 只补玩家附近那一套房的机关;玩家跑到别的区就不做无用功。 private static bool IsNearPlayer(float x, float z, float px, float pz) { var dx = x - px; var dz = z - pz; return dx * dx + dz * dz <= HouseFurnitureRespawnRadiusSq; } private const float HouseFurnitureRespawnRadiusSq = 300f * 300f; /// /// 把客户端发来的 placedId 反解成模板节点号。 /// /// ⛔ 线上是 32 位截断形态:客户端发的是 /// (houseId * 100000 + nodeId) & 0xFFFFFFFF,高 4 字节恒为 0 /// (实测绿屿首件 93 8A 38 C5 00 00 00 00 = 0xC5388A93, /// 而 0xC5388A93 - (11200003*100000 mod 2^32) = 14003)。 /// 因为 houseId ≥ 11200003 ⇒ houseId*100000 ≈ 1.12e12 > 2^32,截断必然发生。 /// /// 同时兼容「万一某条链路发的是完整 64 位值」:placedId > 0xFFFFFFFF /// 时直接减 houseId*100000。两条路都算一遍、只认落在 /// 1..99999(模板节点号上限实测 14150)的那个。 /// private static uint ResolveNodeIdFromPlacedId(ulong placedId, uint houseId, ulong baseOffset) { if (placedId > Uint32Mask) { var full = placedId - houseId * HousePlacedIdStride; if (full is > 0 and < HousePlacedIdStride) return (uint)full; } var truncated = (placedId - baseOffset) & Uint32Mask; return truncated < HousePlacedIdStride ? (uint)truncated : 0u; } /// /// 家具节点的机关落点。模板 Position 本来就是世界绝对坐标,直接搬; /// 少数家具带 GadgetOffset(实测 6 个 float:pos xyz + rot xyz,全表只有 2 条有值, /// 且都不在四套房的模板树里),有就叠加上去,避免以后换模板踩坑。 /// private static (float X, float Y, float Z) ApplyGadgetOffset( HouseFurnitureTemplateNode4229938 node, uint furnitureId) { if (!HouseFurnitureGadgetCatalog4229938.TryGet(furnitureId, out var definition) || definition.GadgetOffset.Count < 3) { return (node.X, node.Y, node.Z); } var offset = definition.GadgetOffset; return (node.X + offset[0], node.Y + offset[1], node.Z + offset[2]); } }