Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.SceneDevices.cs

346 lines
16 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Scene devices — doors, vending machines, fridges, arcades, signs.
///
/// Why "the fridge does nothing": an <c>Ask*</c> device INVOKE is only a REQUEST. The client's
/// device-state manager waits for the server to <b>echo the transition</b> 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):
/// <code>
/// 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
/// </code>
/// Two traps baked into the code below:
/// 1. <b>String bias is asymmetric.</b> C2S device strings are BIASED (len+1 — a 9-char
/// "StartMind" arrives as 0x0A); S2C strings are RAW (ReadStringOrNull @0x189AAB3D0).
/// 2. <b>Never push SyncSceneDeviceSignalSend(68477555).</b> The client relays a server signal
/// echo back as its own request — 13A measured 14×/s. That one echo is a real loop.
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>AskSceneDeviceChangeState(67226162) / AskSceneDeviceRecordValue(67703474).</summary>
[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));
}
/// <summary>AskSceneDeviceRecordValue(67703474) — value records (vending count, sign text).</summary>
[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));
}
/// <summary>CheckCanOccupySceneDevice(67063877) / ReleaseOccupySceneDevice(67244136): void.</summary>
[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);
}
/// <summary>
/// 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 时客户端走本地表现,脚本照跑。
/// 开关:<see cref="Ananta.Server.Configuration.SceneDeviceSettings.SpoonConditionTrue"/>
/// (默认已改为 false;配置里 `world.content.sceneDevices.spoonConditionTrue` 显式 false)。
/// 另见 `PrivateServerConfig.cs` 该字段的完整实测记录。
/// </summary>
[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]);
}
/// <summary>AskGetSpoonServerOutputPortIndexByType(67346473): raw u32, always 0.</summary>
[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]);
}
/// <summary>AskDoSpoonServerActionByType(67863523): void. Logged — the payload carries the port.</summary>
[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 ------------------------------------------------------------------------
/// <summary>SceneDeviceCheckIndexCallback { int CheckIndex; uint Error; } — 8 bytes, Error = 0.</summary>
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;
}
/// <summary>SyncSceneDeviceStateChange(68165561) — 21 raw bytes, no markers.</summary>
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);
}
/// <summary>SyncSceneDeviceValueChange(68332775) — S2C strings are RAW-length, not biased.</summary>
private static Task PushDeviceValueAsync(
Connection conn, byte type, ulong id, int valueName, byte[] value, int checkIndex)
{
var body = new List<byte>(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);
}
/// <summary>Raw 7-bit varint (no +1 bias) — the S2C string length encoding.</summary>
private static void WriteRaw7(List<byte> 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)",
_ => "(?)",
};
/// <summary>
/// C2S 设备字符串的**有偏** 7-bit varint 长度:线上是 `len + 1`,返回时已减回。
/// 同时给出消耗的字节数(观测到的信号名都 &lt; 127 字节,但保龄球那条 25 字符也走同一路径,
/// 不做多字节解析将来迟早踩到)。
/// </summary>
private static (int Length, int Consumed) ReadBiasedLen7(ReadOnlySpan<byte> 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);
}
}