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

345 lines
16 KiB
C#
Raw Normal View History

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