using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.Server.ClientData; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// AskMetroGadgetIds(67809129) — the missing half of the metro carriages. /// /// What the client does: it builds the LightRail prefab on its own (see the captured hierarchy /// LightRail_4_405/carriage1|2/Seats/Gadgets_888_LightRailChair03_*), then asks the server /// which gadget instance ids belong to that train. Every SpoonSlotComponent on a seat needs a /// gadget hold before it yields a "sit down" button, and the hold comes from this reply. /// /// Before 2026-09-23 this method had no handler at all, so the answer was the typed-default empty /// list (see the log line "unimplemented AskMetroGadgetIds -> typed-default list7-empty 2b"), which /// is why the seats inside our carriages stay inert while AnantaTrainSeatLoader.exe can wake them /// up by calling SpoonSlotComponent.Init() directly through frida. /// /// Option A (implemented here): hand back invented but stable ids from the server's own 9.5e12 /// gadget band. The client only needs an id to bind the local seat slot to. If seats still stay /// dead, the fallback is option B — really spawn the chairs through SyncGadgetAdd(68843189) with /// the LightRailChair pathIds (843740076 / 1058671153 / 267309514 / 1181678445, iScale must be 0) /// and return those instance ids instead. /// /// Rollback: world.content.metroGadgets.enabled = false. /// internal sealed partial class GameRouter { [Handler(MethodId.AskMetroGadgetIds)] private async Task OnAskMetroGadgetIds(Connection conn, UxRpcMessage msg) { var cfg = PrivateServerConfigStore.Current.World.Content.MetroGadgets; if (!msg.TryGetArgs(out var args) || args is null) { conn.Log.Warn($"[METROG] AskMetroGadgetIds undecodable bytes={msg.Body.Length} " + $"hex={(msg.Body.Length > 0 ? System.Convert.ToHexString(msg.Body, 0, System.Math.Min(msg.Body.Length, 16)) : "-")}"); await conn.ReturnAsync(msg, new List()); return; } var metroId = args.metroId; // One answer entry per carriage, each holding exactly that carriage's seat count: // the client walks its own carriages in order and hands entry[i] to carriage[i], and a // count mismatch silently drops the whole carriage (verified 2026-09-23 — our first version // answered 6 ids for every carriage and only carriage1, which really has 6 seats, woke up). var perCarriage = new List(); var composition = ""; if (cfg.UseTrainConfig && global::Ananta.Server.ClientData.Client4229938.MetroCarriageCatalog4229938.TryGetCarriageTemplates(metroId, out var templates) && templates.Length > 0) { var unknown = new System.Collections.Generic.List(); var borrowed = new System.Collections.Generic.List(); foreach (var template in templates) { var known = cfg.SeatsByCarriageTemplate.TryGetValue(template, out var configured); int seats; if (known) { seats = configured; } else { unknown.Add(template); // 同一车型(Vehicle01/Head、Vehicle03/End …)的座位数应当一致 —— 没采过的模板 // 先借用同车型已配好的那个值,比无脑退化成 defaultCarriageSeats 更接近真值。 var cls = RailCarriageCatalog4229938.LayoutClassOf((uint)template); var sibling = cls is null ? null : FindSiblingSeats(cfg, cls, conn); if (sibling is not null) borrowed.Add(template); seats = sibling ?? cfg.DefaultCarriageSeats; } perCarriage.Add(System.Math.Clamp(seats, 0, 64)); } composition = "tpl[" + string.Join(",", templates) + "] seats[" + string.Join(",", perCarriage) + "]"; // ⛔ 座位数只对得上「编组模板 id」在 seatsByCarriageTemplate 里有条目的车厢。 // 查不到就退化成 defaultCarriageSeats(默认 6)。 // 实测 2026-09-27 15:13:1/2/3 号线的车(tpl 10,2,2,2,3 与 11,7,7,7,8) // 全部命中这条退化路径,只有 4 号线的 tpl 4/5/9 是真实值。 // // ★★ 2026-09-28 受控对照 + 症状订正(原来这里写"整节车厢被客户端静默丢弃、座位按不出坐下", // 与客户端实际报出来的东西不符,会把人带偏): // · 客户端唯一喊出来的错是 **MGAGadgets 槽**(`Metro/Gadget.json data mismatch with // MGAGadgets slot, the slot count is 1 and the gadget count is 0`), // 调用栈是 `LoadMoveGroundArea() → OnMoveGroundAreaLoaded → ActivateMGAGadgets()` // —— 车厢自己的 MoveGroundArea(MGA)gadget 没绑上,不是座位。 // · `mismatch with **Seats** slot` 那条串在 3 个会话里命中 **0 次** ⇒ 座位检查从未触发。 // · 一局内对照:metroId=400..405(4 号线,模板已配)被问 8 次,MGA 报错 **0**; // 1/2/3 号线(模板未配)报 50 条。三局日志里报错点名的列车**一辆 4 号线都没有**。 // · 线上本身干净:38 次 INVOKE / 38 次 RETURN,全 error=0,延迟 5–10ms, // body 267B(5 车厢×6)或 145B(3 车厢 4+6+6)⇒ 不是协议问题。 // ⇒ 模板未配 ⇒ 退化的 6 与真值不符 ⇒ 该车厢的 gadget infos 被丢 ⇒ MGA 拿到 0 个。 // 所以"必须把真座位数补上"这个结论不变,但**症状描述以 MGA 为准**。 // 真值只在 prefab 里(任何 JSON 都没有),只能进游戏数,或用 defaultCarriageSeats // 做零编译 A/B 反推。见硬规则 212 备注。 // ⇒ 这里必须喊出来,否则日志看起来"一切正常"。 if (unknown.Count > 0) { var names = unknown.Select(t => RailCarriageCatalog4229938.Describe((uint)t)); conn.Log.Warn($"[METROG] metroId={metroId} 编组模板 {string.Join(",", unknown)} " + $"(车型 {string.Join(", ", names)})在 metroGadgets.seatsByCarriageTemplate " + $"里没有条目 ⇒ " + (borrowed.Count > 0 ? $"{string.Join(",", borrowed)} 借用了同车型已配值," + $"其余退化成 defaultCarriageSeats={cfg.DefaultCarriageSeats}" : $"退化成 defaultCarriageSeats={cfg.DefaultCarriageSeats}") + $"。客户端实际报的是 MGAGadgets 槽对不上" + $"(Metro/Gadget.json data mismatch with MGAGadgets slot, slot count is 1 / " + $"gadget count is 0,经 LoadMoveGroundArea → ActivateMGAGadgets 触发;" + $"Seats 槽那条报错 3 个会话里一次都没出现过)。" + $"补法:进游戏站到该线路的车上,跑 TrainSeatLoader 的 trainhier," + $"数每节车厢 Seats 下的 LightRailChair 个数,按模板 id 填进配置" + $"(13 个模板按车型归并只有 8 种布局,采一趟 1/2 号线就能覆盖 Vehicle01 的头/中/尾)。"); } } else { var carriages = System.Math.Clamp(cfg.Carriages, 0, 32); var flat = System.Math.Clamp(cfg.SeatsPerCarriage, 0, 64); for (var i = 0; i < carriages; i++) perCarriage.Add(flat); composition = $"flat {carriages}x{flat}"; } if (!cfg.Enabled || cfg.LogOnly || cfg.IdBase == 0UL || perCarriage.Count == 0 || perCarriage.All(n => n == 0)) { conn.Log.Info($"[METROG] AskMetroGadgetIds metroId={metroId} -> empty " + $"(enabled={cfg.Enabled} logOnly={cfg.LogOnly} {composition})"); await conn.ReturnAsync(msg, new List()); return; } // Deterministic: the same train always gets the same ids, so a repeated ask (the client // re-asks every few seconds while a train is near) never reshuffles the seats. var result = new List(perCarriage.Count); ulong total = 0UL; for (var c = 0; c < perCarriage.Count; c++) { var seats = perCarriage[c]; var inner = new List(seats); for (var s = 0; s < seats; s++) inner.Add(cfg.IdBase + (ulong)((long)metroId * 10000L) + (ulong)(c * 100) + (ulong)s); total += (ulong)seats; result.Add(new SceneMethods.MetroCarriageGadgetInfos { InnerGadgetIds = inner, OuterGadgetIds = cfg.IncludeOuter ? new List(inner) : new List(), }); } var firstIds = result[0].InnerGadgetIds; var firstId = firstIds is { Count: > 0 } ? firstIds[0].ToString() : "-"; conn.Log.Info($"[METROG] AskMetroGadgetIds metroId={metroId} -> {result.Count} carriage(s), {total} seat id(s), " + $"{composition}, first={firstId} outer={cfg.IncludeOuter}"); await conn.ReturnAsync(msg, result); } private static readonly HashSet WarnedSeatConflicts = new(); private static readonly object SeatConflictLock = new(); /// /// 同车型(同一 RailCarriageConfig 布局类)里有没有已经配好座位数的兄弟模板? /// 有就借用它的值 —— 比退化成 defaultCarriageSeats 更接近真值。 /// ⛑ 必须按 key 升序取第一个:Dictionary 的遍历顺序没有保证,同车型存在 /// 多个已配值时会取到随机的那个(见 )。 /// private static int? FindSiblingSeats(MetroGadgetSettings cfg, string layoutClass, Connection conn) { int? first = null; var distinct = new SortedSet(); foreach (var kv in cfg.SeatsByCarriageTemplate.OrderBy(k => k.Key)) { if (RailCarriageCatalog4229938.LayoutClassOf((uint)kv.Key) != layoutClass) continue; distinct.Add(kv.Value); first ??= kv.Value; } if (distinct.Count > 1) WarnSeatConflict(layoutClass, distinct, conn); return first; } /// /// 同一布局类里配了不一样的座位数 —— 一定是某次实测数错了,喊出来(每条只喊一次)。 /// 2026-09-27 现况:Vehicle03/End 同时有 tpl4=4 与 tpl9=6,而两者 /// TemplateName(Vehicle03/CarriageOnEnd) 与 ViewModelName(View_Blue_End) /// 完全相同,只差 IsRevert(镜像)⇒ 座位数本该一样,现在必有一个是错的。 /// private static void WarnSeatConflict(string layoutClass, SortedSet values, Connection conn) { lock (SeatConflictLock) { if (!WarnedSeatConflicts.Add(layoutClass)) return; } conn.Log.Warn("[METROG] ⛔ 座位表自相矛盾:布局类 " + layoutClass + " 在 metroGadgets.seatsByCarriageTemplate 里配了 " + string.Join(" / ", values) + " 多个不同值 —— 同一车型的车厢座位数本该一致,必有一次实测数错," + "当前只按模板 id 升序取第一个用。请进游戏跑 TrainSeatLoader 的 trainhier 重新数一遍。"); } }