using System.Buffers.Binary; using System.Text; using Ananta.SDK.Rpc; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; namespace Ananta.Server.Handlers.Game; /// /// Scene devices — doors, vending machines, fridges, arcades, signs. /// /// Why "the fridge does nothing": an Ask* device INVOKE is only a REQUEST. The client's /// device-state manager waits for the server to echo the transition before it runs the /// presentation (door animation, fridge open, vending purchase). The donor handoff /// (HANDOFF-2026-09-18) walked exactly this path: round 13 added the echo, 13A removed it on a /// test that never happened, and 15D restored it from the one session whose log proved it worked /// (03:50 — the countdown door ran 9→6 cleanly with check=0 throughout; without the echo the same /// door re-fired 23× with the state DECREASING and the checkIndex escalating). Our logs show the /// very same signature today: AskSceneDeviceChangeState ×333 and AskSceneDeviceRecordValue ×364, /// all of them still on the generic neutral fallback. /// /// Wire (all CSharpOnly: raw fields, ZERO markers): /// /// C2S AskSceneDeviceChangeState 67226162 (22b) = u8 type | u64 id | i32 stateType | i32 state /// | i32 checkIndex | u8 trailing /// C2S AskSceneDeviceRecordValue 67703474 (21b) = u8 type | u64 id | i32 valueName /// | string value (BIASED len + UTF-8) /// | i32 checkIndex | trailing /// reply (both) = SceneDeviceCheckIndexCallback { int CheckIndex; uint Error; } = 8 bytes /// S2C SyncSceneDeviceStateChange 68165561 (21b) = u8 type | u64 id | i32 stateType | i32 state /// | i32 checkIndex /// S2C SyncSceneDeviceValueChange 68332775 = u8 type | u64 id | i32 valueName /// | string value | i32 checkIndex /// /// Two traps baked into the code below: /// 1. String bias is asymmetric. C2S device strings are BIASED (len+1 — a 9-char /// "StartMind" arrives as 0x0A); S2C strings are RAW (ReadStringOrNull @0x189AAB3D0). /// 2. Never push SyncSceneDeviceSignalSend(68477555). The client relays a server signal /// echo back as its own request — 13A measured 14×/s. That one echo is a real loop. /// internal sealed partial class GameRouter { /// AskSceneDeviceChangeState(67226162) / AskSceneDeviceRecordValue(67703474). [Handler(MethodId.AskSceneDeviceChangeState, HandlerPacketKind.Invoke)] private async Task OnAskSceneDeviceChangeState(Connection conn, UxRpcMessage msg) { var cfg = PrivateServerConfigStore.Current.World.Content.SceneDevices; var body = msg.Body; if (body.Length < 21) { conn.Log.Warn($"[SCENE-DEVICE] change-state short body bytes={body.Length} " + $"hex={Convert.ToHexString(body)}"); await conn.ReturnEmptyOkAsync(msg); return; } byte type = body[0]; ulong id = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(1)); int stateType = BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(9)); int state = BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(13)); int checkIndex = BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(17)); var echo = cfg.Enabled && cfg.EchoState && !EchoOff(cfg, id); conn.Log.Info($"[SCENE-DEVICE] change-state id={id} type={type} stateType={stateType} " + $"state={state} check={checkIndex}{(echo ? " + echo" : "")}"); if (echo) { await PushDeviceStateAsync(conn, type, id, stateType, state, checkIndex); } await msg.Context.ReturnAsync(SceneDeviceOkReply(checkIndex)); } /// AskSceneDeviceRecordValue(67703474) — value records (vending count, sign text). [Handler(MethodId.AskSceneDeviceRecordValue, HandlerPacketKind.Invoke)] private async Task OnAskSceneDeviceRecordValue(Connection conn, UxRpcMessage msg) { var cfg = PrivateServerConfigStore.Current.World.Content.SceneDevices; var body = msg.Body; if (body.Length < 14) { conn.Log.Warn($"[SCENE-DEVICE] record-value short body bytes={body.Length} " + $"hex={Convert.ToHexString(body)}"); await conn.ReturnEmptyOkAsync(msg); return; } byte type = body[0]; ulong id = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(1)); int valueName = BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(9)); // C2S device strings are BIASED: the byte at 13 is len+1. int len = body[13] - 1; if (len < 0) { len = 0; } if (14 + len + 4 > body.Length) { len = Math.Max(0, body.Length - 18); } var value = new byte[len]; Array.Copy(body, 14, value, 0, len); int checkIndex = 14 + len + 4 <= body.Length ? BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(14 + len)) : 0; var echo = cfg.Enabled && cfg.EchoValue && !EchoOff(cfg, id); conn.Log.Info($"[SCENE-DEVICE] record-value id={id} type={type} name={valueName} " + $"value=\"{Encoding.UTF8.GetString(value)}\" check={checkIndex}{(echo ? " + echo" : "")}"); if (echo) { await PushDeviceValueAsync(conn, type, id, valueName, value, checkIndex); } await msg.Context.ReturnAsync(SceneDeviceOkReply(checkIndex)); } /// CheckCanOccupySceneDevice(67063877) / ReleaseOccupySceneDevice(67244136): void. [Handler(MethodId.CheckCanOccupySceneDevice, HandlerPacketKind.Invoke)] [Handler(MethodId.ReleaseOccupySceneDevice, HandlerPacketKind.Invoke)] private Task OnSceneDeviceOccupy(Connection conn, UxRpcMessage msg) { if (msg.Body.Length >= 9) { conn.Log.Info($"[SCENE-DEVICE] {Ananta.SDK.Logging.RpcMethodNames.Display(msg.MethodId)} " + $"id={BinaryPrimitives.ReadUInt64LittleEndian(msg.Body.AsSpan(1))}"); } return conn.ReturnEmptyOkAsync(msg); } /// /// AskCheckSpoonConditionByType(67709054) — "may I run this device script?". /// /// ⛔⛔ **2026-09-28 实机订正(用户验证)—— 下面这段旧说法是错的,已改写,别再改回去:** /// 旧文:"The generated neutral default is 0x00 = false, which is precisely what makes an /// interaction appear dead: the client asks, we say no, and nothing plays. Donor round 13 /// answers 0x01." —— donor 那条从未实测,只是推断;对**衣柜这一族实测结果完全相反**。 /// /// 实测(塔菲公寓衣柜 uid 14013633775178039662): /// · 答 0x01(true):F 提示一闪即没、换装打不开,`[SPOON] action` 只发 2 次就停 /// (packets-20260928-204201.log 21:03:18.961 S2C hex=01)。 /// · 答 0x00(false):F 常驻,22:46:44 `[SPOON] action` 触发,payload 与能换装的对照组 /// v1.5 逐字节相同(console-20260928-224215.log)。 /// 对照组 v1.5 没有本 handler,走 DefaultReturnCatalog4229938 兜底 0x00 ⇒ 衣柜一直可用。 /// ⇒ **true 才是让交互挂死的那个值**:客户端拿到 true 就把交互交给服务端 spoon 脚本流程, /// 而 AskDoSpoonServerActionByType 我们只回空 OK、给不出任何脚本输出 ⇒ 卡死、提示被撤。 /// 答 false 时客户端走本地表现,脚本照跑。 /// 开关: /// (默认已改为 false;配置里 `world.content.sceneDevices.spoonConditionTrue` 显式 false)。 /// 另见 `PrivateServerConfig.cs` 该字段的完整实测记录。 /// [Handler(MethodId.AskCheckSpoonConditionByType, HandlerPacketKind.Invoke)] private Task OnAskCheckSpoonConditionByType(Connection conn, UxRpcMessage msg) { var cfg = PrivateServerConfigStore.Current.World.Content.SceneDevices; var ok = cfg.Enabled && cfg.SpoonConditionTrue; conn.Log.Info($"[SPOON] condition -> {ok}"); return msg.Context.ReturnAsync([ok ? (byte)0x01 : (byte)0x00]); } /// AskGetSpoonServerOutputPortIndexByType(67346473): raw u32, always 0. [Handler(MethodId.AskGetSpoonServerOutputPortIndexByType, HandlerPacketKind.Invoke)] private Task OnAskGetSpoonServerOutputPortIndexByType(Connection conn, UxRpcMessage msg) { conn.Log.Info($"[SPOON] output-port bytes={msg.Body.Length}"); return msg.Context.ReturnAsync([0x00, 0x00, 0x00, 0x00]); } /// AskDoSpoonServerActionByType(67863523): void. Logged — the payload carries the port. [Handler(MethodId.AskDoSpoonServerActionByType, HandlerPacketKind.Invoke)] private Task OnAskDoSpoonServerActionByType(Connection conn, UxRpcMessage msg) { conn.Log.Info($"[SPOON] action bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body)}"); return conn.ReturnEmptyOkAsync(msg); } // --- wire helpers ------------------------------------------------------------------------ /// SceneDeviceCheckIndexCallback { int CheckIndex; uint Error; } — 8 bytes, Error = 0. private static byte[] SceneDeviceOkReply(int checkIndex) { var reply = new byte[8]; BinaryPrimitives.WriteInt32LittleEndian(reply, checkIndex); return reply; } private static bool EchoOff(SceneDeviceSettings cfg, ulong id) { if (cfg.EchoOffIds is null || cfg.EchoOffIds.Length == 0) { return false; } var text = id.ToString(); return Array.IndexOf(cfg.EchoOffIds, text) >= 0; } /// SyncSceneDeviceStateChange(68165561) — 21 raw bytes, no markers. private static Task PushDeviceStateAsync( Connection conn, byte type, ulong id, int stateType, int state, int checkIndex) { var body = new byte[21]; body[0] = type; BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(1), id); BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(9), stateType); BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(13), state); BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(17), checkIndex); return conn.Session.NotifyAsync(MethodId.SyncSceneDeviceStateChange, body, CancellationToken.None); } /// SyncSceneDeviceValueChange(68332775) — S2C strings are RAW-length, not biased. private static Task PushDeviceValueAsync( Connection conn, byte type, ulong id, int valueName, byte[] value, int checkIndex) { var body = new List(18 + value.Length); body.Add(type); var head = new byte[12]; BinaryPrimitives.WriteUInt64LittleEndian(head, id); BinaryPrimitives.WriteInt32LittleEndian(head.AsSpan(8), valueName); body.AddRange(head); WriteRaw7(body, value.Length); body.AddRange(value); var tail = new byte[4]; BinaryPrimitives.WriteInt32LittleEndian(tail, checkIndex); body.AddRange(tail); return conn.Session.NotifyAsync( MethodId.SyncSceneDeviceValueChange, body.ToArray(), CancellationToken.None); } /// Raw 7-bit varint (no +1 bias) — the S2C string length encoding. private static void WriteRaw7(List sink, int value) { var v = (uint)value; while (v >= 0x80u) { sink.Add((byte)(v | 0x80u)); v >>= 7; } sink.Add((byte)v); } // -------------------------------------------------------------------------------------- // AskSceneDeviceSendSignal (67144566) — 2026-09-27,桌面清单 T1-44 P0。 // // 为什么排 P0:**它是 `unknown-methods.log` 里出现最多的 INVOKE**(全量 249 次,09-25 以后 74 次), // 而且解出来的 `signalName` 不是设备状态,是**玩法/任务/特效信号** —— 服务端此前一条都收不到: // // | signalName | 次数 | 含义(按名字推断) | // |---|---|---| // | `FinishMind` | 238 | 心智/意志玩法结束(集中在两个固定 gadget uid) | // | `StartMind` | 4 | 同上,开始 | // | `BowlingInviteTimelinePlay` | 2 | 保龄球邀请时间轴播放 | // | `TaskFinish60001155` | 2 | **任务 60001155 完成**(gadget 上触发) | // | `SmokeFX Off` | 2 | 烟雾特效关闭 | // | `carshop` | 1 | 车店 | // // ⛑ `TaskFinish60001155` 值得盯:服务端一直不知道任务什么时候完成(T2-9 任务面板那条线 // 缺的就是"真实达成"的事件钩子),这条是客户端唯一主动上报任务完成的通道之一。 // // 方向:**INVOKE**(`ClientToGameSceneDelegate.lua:899` 是 `self.Invoke(...)`)。 // 返回:`UXRPCTask`(void,`RPCDeserializeAuto.lua:14968` 的 reader 函数体为空)⇒ **空 OK 即正解**。 // // Wire(与 `AskSceneDeviceRecordValue` 同族,**raw,零 marker**): // `u8 type | u64 id | string signalName` // type = `SceneDeviceEntityType`(dump.cs:1749290 **只有两个值**:`Gadget = 0` / `Destructible = 1`; // 观测到的 249 条全部是 **0 = Gadget**) // signalName = **偏置** 7-bit varint 长度 + UTF-8(与本文件 RecordValue 的 C2S 字符串同规则, // ⛑ 不是 S2C 的 raw —— 见本文件头"陷阱 1:字符串偏置是不对称的") // // ⛑⛑ **绝对不要在这个 handler 里推 `SyncSceneDeviceSignalSend`(68477555)**:本文件头已记录, // 客户端会把服务端推的信号**当自己的请求再发回来**(donor 13A 实测 **14 次/秒**)⇒ 真回环。 // 这里只回空 OK + 打日志。将来真要驱动玩法,另开一条不会回环的路。 // // ⛑ 硬规则 94:id 必须进 `GameRouter.cs` 的 `Enabled4229938MethodIds`,否则编译过但不注册。 // -------------------------------------------------------------------------------------- [Handler(MethodId.AskSceneDeviceSendSignal, HandlerPacketKind.Invoke)] private Task OnAskSceneDeviceSendSignal(Connection conn, UxRpcMessage msg) { var body = msg.Body; if (body.Length < 10) { conn.Log.Warn($"[SCENE-DEVICE] send-signal short body bytes={body.Length} " + $"hex={Convert.ToHexString(body)}"); return conn.ReturnEmptyOkAsync(msg); } byte type = body[0]; ulong id = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(1)); var (len, consumed) = ReadBiasedLen7(body.AsSpan(9)); var at = 9 + consumed; if (at + len > body.Length) { len = Math.Max(0, body.Length - at); } var signal = Encoding.UTF8.GetString(body.AsSpan(at, len)); conn.Log.Info($"[SCENE-DEVICE] send-signal id={id} type={type}{TypeName(type)} " + $"signal=\"{signal}\" bytes={body.Length}"); return conn.ReturnEmptyOkAsync(msg); } private static string TypeName(byte type) => type switch { 0 => "(Gadget)", 1 => "(Destructible)", _ => "(?)", }; /// /// C2S 设备字符串的**有偏** 7-bit varint 长度:线上是 `len + 1`,返回时已减回。 /// 同时给出消耗的字节数(观测到的信号名都 < 127 字节,但保龄球那条 25 字符也走同一路径, /// 不做多字节解析将来迟早踩到)。 /// private static (int Length, int Consumed) ReadBiasedLen7(ReadOnlySpan span) { int shift = 0; uint value = 0; int i = 0; while (i < span.Length) { byte cur = span[i++]; value |= (uint)(cur & 0x7F) << shift; if ((cur & 0x80) == 0) { break; } shift += 7; if (shift > 28) { break; } } var decoded = (int)value - 1; return (decoded < 0 ? 0 : decoded, i); } }