346 lines
16 KiB
C#
346 lines
16 KiB
C#
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`,返回时已减回。
|
||
/// 同时给出消耗的字节数(观测到的信号名都 < 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);
|
||
}
|
||
}
|