Initial import of IC.Rebuild
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
public static class ActiveSendScope
|
||||
{
|
||||
[ThreadStatic] private static int depth;
|
||||
public static bool Active => depth > 0;
|
||||
public static IDisposable Enter()
|
||||
{
|
||||
depth++;
|
||||
return new Scope();
|
||||
}
|
||||
private sealed class Scope : IDisposable
|
||||
{
|
||||
private int disposed;
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref disposed, 1) == 0 && depth > 0) depth--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum SendTransactionState
|
||||
{
|
||||
Queued,
|
||||
SentNative,
|
||||
RejectedNative,
|
||||
Confirmed,
|
||||
TimedOut,
|
||||
Canceled
|
||||
}
|
||||
|
||||
public sealed record SendSession(bool Ready, uint Territory, nint LocalAddress, uint LocalEntityId)
|
||||
{
|
||||
public static readonly SendSession Empty = new(false, 0, 0, 0);
|
||||
public bool IsValid => Ready && Territory != 0 && LocalAddress != 0 && LocalEntityId is not (0 or 0xE0000000);
|
||||
public bool Matches(SendSession other) =>
|
||||
IsValid && other.IsValid && Territory == other.Territory &&
|
||||
LocalAddress == other.LocalAddress && LocalEntityId == other.LocalEntityId;
|
||||
}
|
||||
|
||||
public sealed record EncodedGamePacket(
|
||||
string Name,
|
||||
int Opcode,
|
||||
byte[] Payload,
|
||||
byte[] NativeBuffer,
|
||||
int? ExpectedResponseOpcode = null,
|
||||
Func<ReadOnlySpan<byte>, bool>? ResponseMatcher = null);
|
||||
|
||||
public sealed record SendTransactionSnapshot(
|
||||
long Id,
|
||||
string PacketName,
|
||||
int Opcode,
|
||||
SendTransactionState State,
|
||||
long CreatedMs,
|
||||
long? SentMs,
|
||||
long? CompletedMs,
|
||||
byte? NativeResult,
|
||||
long? ResponseObservationId,
|
||||
long? RoundTripMs,
|
||||
string Detail);
|
||||
|
||||
public static class NativeSendPacket
|
||||
{
|
||||
public const string AuditedClientVersion = PacketParser.AuditedClientVersion;
|
||||
public const string BaselineClientVersion = PacketParser.BaselineClientVersion;
|
||||
public static bool IsAuditedVersion(string? version) => PacketParser.IsAuditedVersion(version);
|
||||
public const int NativePrefixSize = 0x20;
|
||||
public const int IpcHeaderSize = NativePacketCapture.IpcHeaderSize;
|
||||
|
||||
// Native ZoneUp (0x1D718C0) reads: +0x00 uint Opcode, +0x08 ulong (payloadLength + 0x10), +0x20 byte[] Payload.
|
||||
public static byte[] BuildBuffer(int opcode, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (opcode is <= 0 or > ushort.MaxValue) throw new ArgumentOutOfRangeException(nameof(opcode), "Opcode 超出有效范围");
|
||||
if (payload.Length > NativePacketCapture.MaxPayload) throw new ArgumentOutOfRangeException(nameof(payload), "载荷超出 64 KiB 上限");
|
||||
var buffer = new byte[NativePrefixSize + payload.Length];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0, 4), (uint)opcode);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(8, 8), (ulong)(payload.Length + IpcHeaderSize));
|
||||
payload.CopyTo(buffer.AsSpan(NativePrefixSize));
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
public static class AuditedPacketEncoder
|
||||
{
|
||||
public static EncodedGamePacket EncodeEventStart(OpcodeMap map, string version, ulong targetObjectId, uint eventId, uint param = 0)
|
||||
{
|
||||
RequireAuditedVersion(version);
|
||||
if (targetObjectId == 0 || targetObjectId == 0xE0000000) throw new ArgumentException("目标对象 ID 无效", nameof(targetObjectId));
|
||||
if (eventId == 0) throw new ArgumentException("事件 ID 不能为 0", nameof(eventId));
|
||||
var opcode = RequireOpcode(map, version, PacketDirection.Up, "UP_EventStart");
|
||||
var responseOpcode = map.ResolveOpcode(version, PacketDirection.Down, "DOWN_EventStart");
|
||||
var payload = new byte[0x10];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(payload.AsSpan(0, 8), targetObjectId);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8, 4), eventId);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12, 4), param);
|
||||
return new("UP_EventStart", opcode, payload, NativeSendPacket.BuildBuffer(opcode, payload), responseOpcode,
|
||||
responseOpcode.HasValue ? p => p.Length >= 12 && BinaryPrimitives.ReadUInt32LittleEndian(p.Slice(8, 4)) == eventId : null);
|
||||
}
|
||||
|
||||
public static EncodedGamePacket EncodeEventFinish(OpcodeMap map, string version, uint eventId, ushort sceneId, byte state, ReadOnlySpan<uint> args)
|
||||
{
|
||||
RequireAuditedVersion(version);
|
||||
if (eventId == 0) throw new ArgumentException("事件 ID 不能为 0", nameof(eventId));
|
||||
var (name, capacity, length) = SelectEventTier("UP_EventFinish", args.Length);
|
||||
var opcode = RequireOpcode(map, version, PacketDirection.Up, name);
|
||||
var responseOpcode = map.ResolveOpcode(version, PacketDirection.Down, "DOWN_EventFinish");
|
||||
var payload = WriteEventPayload(eventId, sceneId, state, args, capacity, length);
|
||||
return new(name, opcode, payload, NativeSendPacket.BuildBuffer(opcode, payload), responseOpcode,
|
||||
responseOpcode.HasValue ? p => p.Length >= 4 && BinaryPrimitives.ReadUInt32LittleEndian(p[..4]) == eventId : null);
|
||||
}
|
||||
|
||||
public static EncodedGamePacket EncodeEventAction(OpcodeMap map, string version, uint eventId, ushort sceneId, byte state, ReadOnlySpan<uint> args)
|
||||
{
|
||||
RequireAuditedVersion(version);
|
||||
if (eventId == 0) throw new ArgumentException("事件 ID 不能为 0", nameof(eventId));
|
||||
var (name, capacity, length) = SelectEventTier("UP_EventAction", args.Length);
|
||||
var opcode = RequireOpcode(map, version, PacketDirection.Up, name);
|
||||
var payload = WriteEventPayload(eventId, sceneId, state, args, capacity, length);
|
||||
return new(name, opcode, payload, NativeSendPacket.BuildBuffer(opcode, payload));
|
||||
}
|
||||
|
||||
public static EncodedGamePacket EncodeClientTrigger(OpcodeMap map, string version, uint commandId,
|
||||
uint arg0 = 0, uint arg1 = 0, uint arg2 = 0, uint arg3 = 0, ulong targetId = 0)
|
||||
{
|
||||
RequireAuditedVersion(version);
|
||||
if (commandId == 0) throw new ArgumentException("触发指令 ID 不能为 0", nameof(commandId));
|
||||
var opcode = RequireOpcode(map, version, PacketDirection.Up, "UP_ClientTrigger");
|
||||
var payload = new byte[0x20];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0, 4), commandId);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4, 4), arg0);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8, 4), arg1);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12, 4), arg2);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16, 4), arg3);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(payload.AsSpan(24, 8), targetId);
|
||||
return new("UP_ClientTrigger", opcode, payload, NativeSendPacket.BuildBuffer(opcode, payload));
|
||||
}
|
||||
|
||||
public static EncodedGamePacket EncodeActionSend(OpcodeMap map, string version, uint actionId, byte actionKind,
|
||||
ushort requestId, ulong targetId, ushort facing = 0, ushort targetFacing = 0, uint arg = 0)
|
||||
{
|
||||
RequireAuditedVersion(version);
|
||||
if (actionId == 0) throw new ArgumentException("技能 ID 不能为 0", nameof(actionId));
|
||||
if (requestId == 0) throw new ArgumentException("请求序号 RequestId 必须大于 0", nameof(requestId));
|
||||
if (targetId == 0 || targetId == 0xE0000000) throw new ArgumentException("目标对象 ID 无效", nameof(targetId));
|
||||
var opcode = RequireOpcode(map, version, PacketDirection.Up, "UP_ActionSend");
|
||||
var responseOpcode = map.ResolveOpcode(version, PacketDirection.Down, "DOWN_Effect");
|
||||
var payload = new byte[0x20];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0, 4), actionId);
|
||||
payload[5] = actionKind;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(6, 2), requestId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(8, 2), facing);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(10, 2), targetFacing);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(payload.AsSpan(16, 8), targetId);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(24, 4), arg);
|
||||
return new("UP_ActionSend", opcode, payload, NativeSendPacket.BuildBuffer(opcode, payload), responseOpcode,
|
||||
responseOpcode.HasValue
|
||||
? p => p.Length == 0x7C && p[0x21] <= 1 && BinaryPrimitives.ReadUInt16LittleEndian(p.Slice(0x18, 2)) == requestId
|
||||
: null);
|
||||
}
|
||||
|
||||
private static (string Name, int Capacity, int Length) SelectEventTier(string prefix, int argCount) => argCount switch
|
||||
{
|
||||
< 0 or > 255 => throw new ArgumentOutOfRangeException(nameof(argCount), "事件参数数量超出 0..255 范围"),
|
||||
<= 2 => (prefix, 2, 0x10),
|
||||
<= 4 => ($"{prefix}4", 4, 0x18),
|
||||
<= 8 => ($"{prefix}8", 8, 0x28),
|
||||
<= 16 => ($"{prefix}16", 16, 0x48),
|
||||
<= 32 => ($"{prefix}32", 32, 0x88),
|
||||
<= 64 => ($"{prefix}64", 64, 0x108),
|
||||
<= 128 => ($"{prefix}128", 128, 0x208),
|
||||
_ => ($"{prefix}255", 255, 0x408)
|
||||
};
|
||||
|
||||
private static byte[] WriteEventPayload(uint eventId, ushort sceneId, byte state, ReadOnlySpan<uint> args, int capacity, int length)
|
||||
{
|
||||
if (args.Length > capacity) throw new ArgumentOutOfRangeException(nameof(args));
|
||||
var payload = new byte[length];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0, 4), eventId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(4, 2), sceneId);
|
||||
payload[6] = state;
|
||||
payload[7] = (byte)args.Length;
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8 + i * 4, 4), args[i]);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static void RequireAuditedVersion(string version)
|
||||
{
|
||||
if (!NativeSendPacket.IsAuditedVersion(version))
|
||||
throw new InvalidOperationException($"主动发包编码器仅适配已核对版本 {NativeSendPacket.AuditedClientVersion},当前为 {version}");
|
||||
}
|
||||
|
||||
private static int RequireOpcode(OpcodeMap map, string version, PacketDirection direction, string name) =>
|
||||
map.ResolveOpcode(version, direction, name)
|
||||
?? throw new InvalidOperationException($"未找到 {version} 的 {name} Opcode 映射,禁止发送未映射消息");
|
||||
}
|
||||
|
||||
public sealed class ActivePacketDispatcher(
|
||||
string clientVersion,
|
||||
Func<long>? clockMs = null,
|
||||
int maxQueue = 32,
|
||||
long minIntervalMs = 100,
|
||||
long queueTimeoutMs = 2000,
|
||||
long responseTimeoutMs = 5000)
|
||||
{
|
||||
private sealed class Transaction(long id, EncodedGamePacket packet, SendSession session, long createdMs)
|
||||
{
|
||||
public readonly long Id = id;
|
||||
public readonly EncodedGamePacket Packet = packet;
|
||||
public readonly SendSession Session = session;
|
||||
public readonly long CreatedMs = createdMs;
|
||||
public SendTransactionState State = SendTransactionState.Queued;
|
||||
public long? SentMs;
|
||||
public long? CompletedMs;
|
||||
public byte? NativeResult;
|
||||
public long? ResponseObservationId;
|
||||
public long? RoundTripMs;
|
||||
public string Detail = "排队等待主线程调度";
|
||||
public SendTransactionSnapshot Snapshot() => new(
|
||||
Id, Packet.Name, Packet.Opcode, State, CreatedMs, SentMs, CompletedMs,
|
||||
NativeResult, ResponseObservationId, RoundTripMs, Detail);
|
||||
}
|
||||
|
||||
[ThreadStatic] private static bool inReceiveCallback;
|
||||
private readonly object sync = new();
|
||||
private readonly Func<long> nowMs = clockMs ?? (() => Environment.TickCount64);
|
||||
private readonly List<Transaction> history = [];
|
||||
private SendSession lastSession = SendSession.Empty;
|
||||
private long nextId;
|
||||
private long lastSendMs = long.MinValue / 2;
|
||||
|
||||
public int PendingQueueCount { get { lock (sync) return history.Count(t => t.State == SendTransactionState.Queued); } }
|
||||
public int InFlightCount { get { lock (sync) return history.Count(t => t.State == SendTransactionState.SentNative && !t.CompletedMs.HasValue); } }
|
||||
|
||||
public IDisposable Attach(NetworkPipeline pipeline) =>
|
||||
pipeline.Subscribe(NetworkPhase.Received, OnReceived);
|
||||
|
||||
public SendTransactionSnapshot Enqueue(EncodedGamePacket packet, SendSession session)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(packet);
|
||||
if (!NativeSendPacket.IsAuditedVersion(clientVersion))
|
||||
throw new InvalidOperationException($"主动发送仅适配已核对版本 {NativeSendPacket.AuditedClientVersion}");
|
||||
if (!session.IsValid)
|
||||
throw new InvalidOperationException("当前角色或区域会话未就绪,禁止入队发送");
|
||||
if (packet.Payload == null || packet.NativeBuffer == null ||
|
||||
packet.NativeBuffer.Length != NativeSendPacket.NativePrefixSize + packet.Payload.Length)
|
||||
throw new InvalidDataException("发送包原生缓冲区长度与载荷不匹配");
|
||||
if (!PacketParser.Parse(packet.Name, packet.Payload, clientVersion).Parsed)
|
||||
throw new InvalidDataException($"{packet.Name} 载荷未通过本机协议边界校验");
|
||||
|
||||
lock (sync)
|
||||
{
|
||||
var queuedCount = history.Count(t => t.State == SendTransactionState.Queued);
|
||||
if (queuedCount >= maxQueue)
|
||||
throw new InvalidOperationException($"发送队列已满(上限 {maxQueue} 条)");
|
||||
if (lastSession.IsValid && !lastSession.Matches(session))
|
||||
CancelSessionLocked("切区或角色会话变更,已取消旧会话请求");
|
||||
lastSession = session;
|
||||
var tx = new Transaction(++nextId, packet, session, nowMs());
|
||||
history.Add(tx);
|
||||
TrimHistoryLocked();
|
||||
return tx.Snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
// Canceling a queued item prevents native dispatch; it never claims to undo an already-sent packet.
|
||||
public bool Cancel(long transactionId, string reason = "已取消排队请求")
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
var tx = history.FirstOrDefault(t => t.Id == transactionId);
|
||||
if (tx == null || tx.State != SendTransactionState.Queued) return false;
|
||||
tx.State = SendTransactionState.Canceled;
|
||||
tx.CompletedMs = nowMs();
|
||||
tx.Detail = reason;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public int CancelAllQueued(string reason = "已清空待发送队列")
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
var count = 0;
|
||||
var now = nowMs();
|
||||
foreach (var tx in history.Where(t => t.State == SendTransactionState.Queued))
|
||||
{
|
||||
tx.State = SendTransactionState.Canceled;
|
||||
tx.CompletedMs = now;
|
||||
tx.Detail = reason;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
// Must be called from Framework.Update on the main thread, never from a network receive callback.
|
||||
public SendTransactionSnapshot? Pump(SendSession currentSession, Func<ReadOnlySpan<byte>, byte> nativeSender)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nativeSender);
|
||||
if (inReceiveCallback || NetworkPipeline.InCallback)
|
||||
throw new InvalidOperationException("禁止在网络接收回调中直接触发主动发送");
|
||||
|
||||
Transaction? toSend = null;
|
||||
var now = nowMs();
|
||||
lock (sync)
|
||||
{
|
||||
if (!currentSession.IsValid || (lastSession.IsValid && !lastSession.Matches(currentSession)))
|
||||
{
|
||||
CancelSessionLocked("切区或会话不可用,已取消未完成请求");
|
||||
lastSession = currentSession.IsValid ? currentSession : SendSession.Empty;
|
||||
if (!currentSession.IsValid) return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastSession = currentSession;
|
||||
}
|
||||
|
||||
foreach (var tx in history)
|
||||
{
|
||||
if (tx.State == SendTransactionState.Queued && !tx.Session.Matches(currentSession))
|
||||
{
|
||||
tx.State = SendTransactionState.Canceled;
|
||||
tx.CompletedMs = now;
|
||||
tx.Detail = "会话已变更,过期请求未发送";
|
||||
}
|
||||
else if (tx.State == SendTransactionState.Queued && now - tx.CreatedMs > queueTimeoutMs)
|
||||
{
|
||||
tx.State = SendTransactionState.TimedOut;
|
||||
tx.CompletedMs = now;
|
||||
tx.Detail = $"排队超过 {queueTimeoutMs} ms 未发送,已过期丢弃";
|
||||
}
|
||||
else if (tx.State == SendTransactionState.SentNative && tx.Packet.ExpectedResponseOpcode.HasValue &&
|
||||
tx.SentMs.HasValue && now - tx.SentMs.Value > responseTimeoutMs)
|
||||
{
|
||||
tx.State = SendTransactionState.TimedOut;
|
||||
tx.CompletedMs = now;
|
||||
tx.Detail = $"已发送(原生返回 {tx.NativeResult}),但等待响应 0x{tx.Packet.ExpectedResponseOpcode.Value:X4} 超过 {responseTimeoutMs} ms";
|
||||
}
|
||||
}
|
||||
|
||||
if (now - lastSendMs < minIntervalMs) return null;
|
||||
toSend = history.FirstOrDefault(t => t.State == SendTransactionState.Queued);
|
||||
if (toSend == null) return null;
|
||||
lastSendMs = now;
|
||||
}
|
||||
|
||||
// Invoke native sender outside lock and outside observer catch blocks: never retry a native send.
|
||||
byte nativeResult;
|
||||
using (ActiveSendScope.Enter())
|
||||
{
|
||||
nativeResult = nativeSender(toSend.Packet.NativeBuffer);
|
||||
}
|
||||
|
||||
var sentAt = nowMs();
|
||||
lock (sync)
|
||||
{
|
||||
toSend.SentMs = sentAt;
|
||||
toSend.NativeResult = nativeResult;
|
||||
if (nativeResult == 0)
|
||||
{
|
||||
toSend.State = SendTransactionState.RejectedNative;
|
||||
toSend.CompletedMs = sentAt;
|
||||
toSend.Detail = "原生发送返回 0(连接未就绪或被底层拒绝)";
|
||||
}
|
||||
else if (toSend.Packet.ExpectedResponseOpcode.HasValue)
|
||||
{
|
||||
toSend.State = SendTransactionState.SentNative;
|
||||
toSend.Detail = $"已调原生发送(返回 {nativeResult}),等待响应 0x{toSend.Packet.ExpectedResponseOpcode.Value:X4}";
|
||||
}
|
||||
else
|
||||
{
|
||||
toSend.State = SendTransactionState.SentNative;
|
||||
toSend.CompletedMs = sentAt;
|
||||
toSend.Detail = $"已调原生发送(返回 {nativeResult},无回包契约,不代表服务端已执行)";
|
||||
}
|
||||
return toSend.Snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public SendTransactionSnapshot[] Snapshot()
|
||||
{
|
||||
lock (sync) return history.Select(t => t.Snapshot()).ToArray();
|
||||
}
|
||||
|
||||
private void OnReceived(NetworkObservation observation)
|
||||
{
|
||||
var msg = observation.Message;
|
||||
if (!msg.Complete || msg.Direction != PacketDirection.Down) return;
|
||||
var prev = inReceiveCallback;
|
||||
inReceiveCallback = true;
|
||||
try
|
||||
{
|
||||
var now = nowMs();
|
||||
lock (sync)
|
||||
{
|
||||
foreach (var tx in history)
|
||||
{
|
||||
if (tx.State != SendTransactionState.SentNative || !tx.Packet.ExpectedResponseOpcode.HasValue) continue;
|
||||
if (tx.Packet.ExpectedResponseOpcode.Value != msg.Opcode) continue;
|
||||
if (tx.SentMs.HasValue && now - tx.SentMs.Value > responseTimeoutMs)
|
||||
{
|
||||
tx.State = SendTransactionState.TimedOut;
|
||||
tx.CompletedMs = now;
|
||||
tx.Detail = $"等待响应超时(>{responseTimeoutMs} ms)";
|
||||
continue;
|
||||
}
|
||||
if (tx.Packet.ResponseMatcher != null && !tx.Packet.ResponseMatcher(msg.Payload)) continue;
|
||||
tx.State = SendTransactionState.Confirmed;
|
||||
tx.CompletedMs = now;
|
||||
tx.ResponseObservationId = msg.Id;
|
||||
tx.RoundTripMs = tx.SentMs.HasValue ? Math.Max(0, now - tx.SentMs.Value) : 0;
|
||||
tx.Detail = $"收到响应确认(观察 #{msg.Id},耗时 {tx.RoundTripMs} ms)";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
inReceiveCallback = prev;
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelSessionLocked(string reason)
|
||||
{
|
||||
var now = nowMs();
|
||||
foreach (var tx in history)
|
||||
{
|
||||
if (tx.State == SendTransactionState.Queued)
|
||||
{
|
||||
tx.State = SendTransactionState.Canceled;
|
||||
tx.CompletedMs = now;
|
||||
tx.Detail = reason;
|
||||
}
|
||||
else if (tx.State == SendTransactionState.SentNative && !tx.CompletedMs.HasValue)
|
||||
{
|
||||
tx.State = SendTransactionState.Canceled;
|
||||
tx.CompletedMs = now;
|
||||
tx.Detail = "切区或会话变更,停止等待旧会话响应";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void TrimHistoryLocked()
|
||||
{
|
||||
while (history.Count > 128)
|
||||
{
|
||||
var index = history.FindIndex(t => t.State != SendTransactionState.Queued);
|
||||
if (index < 0) break;
|
||||
history.RemoveAt(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
public enum AdaptationIssue
|
||||
{
|
||||
None,
|
||||
MissingImplementation,
|
||||
VersionMismatch,
|
||||
OffsetMismatch,
|
||||
SignatureMissing,
|
||||
MultipleMatches,
|
||||
RuntimeFault
|
||||
}
|
||||
|
||||
public sealed record SignatureRequirement(string Name, string Signature, string AuditedRva, bool RequireUnique = true);
|
||||
|
||||
public sealed record ModuleAdaptationSpec(
|
||||
int FeatureId,
|
||||
string FeatureName,
|
||||
bool VersionLocked,
|
||||
bool RequiresSdkOffsets,
|
||||
string Evidence,
|
||||
SignatureRequirement[] Signatures);
|
||||
|
||||
public sealed record ModuleAdaptationEntry(
|
||||
int FeatureId,
|
||||
string FeatureName,
|
||||
bool VersionLocked,
|
||||
AdaptationIssue Issue,
|
||||
Adaptation AdaptationState,
|
||||
string Summary,
|
||||
string Evidence,
|
||||
string[] FailedSignatures);
|
||||
|
||||
public sealed record AdaptationReport(
|
||||
string ClientVersion,
|
||||
string AuditedVersion,
|
||||
bool VersionMatches,
|
||||
bool ActiveSendReady,
|
||||
string ActiveSendSummary,
|
||||
ModuleAdaptationEntry[] Entries)
|
||||
{
|
||||
public int ReadyCount => Entries.Count(e => e.Issue == AdaptationIssue.None);
|
||||
public int BlockedCount => Entries.Count(e => e.Issue != AdaptationIssue.None);
|
||||
}
|
||||
|
||||
public static class AdaptationManifest
|
||||
{
|
||||
public const string AuditedVersion = PacketParser.AuditedClientVersion;
|
||||
public const string BaselineVersion = PacketParser.BaselineClientVersion;
|
||||
public static bool IsAuditedVersion(string? version) => PacketParser.IsAuditedVersion(version);
|
||||
|
||||
public const string ActiveSendReceiverSignature =
|
||||
"48 83 EC 38 48 8B 89 70 0A 00 00 48 85 C9 74 0F C6 44 24 20 00 E8 ?? ?? ?? ?? 48 83 C4 38 C3 32 C0 48 83 C4 38 C3 CC CC CC CC CC CC CC CC CC CC 48 83 EC 38 48 8B 89 70 0A 00 00";
|
||||
public const string ActiveSendZoneSignature =
|
||||
"48 83 EC 28 48 8B 89 98 00 00 00 48 85 C9 74 1F 44 89 44 24 30 4C 8D 44 24 30";
|
||||
|
||||
public static readonly SignatureRequirement[] ActiveSendSignatures =
|
||||
[
|
||||
new("ReceiverSendPacket", ActiveSendReceiverSignature, "0x1CFD140"),
|
||||
new("ZoneClientSendPacket", ActiveSendZoneSignature, "0x1D03040")
|
||||
];
|
||||
|
||||
public static readonly ModuleAdaptationSpec[] Specs =
|
||||
[
|
||||
new(1, "移速调整", false, false, "RVA 0x8D6E40 + 40 项减速状态保护",
|
||||
[new("SpeedHook", "40 57 48 83 EC ?? 48 8B F9 48 8B 49 ?? 48 8B 01 FF 90 ?? ?? ?? ?? 48 85 C0 75", "0x8D6E40")]),
|
||||
new(2, "最大加速度", true, false, "RVA 0x182BB20,偏移 +0x20/+0x3C/+0x44 与 owner +0x3E0 已核对",
|
||||
[new("AccelerationHook", "40 ?? 48 ?? ?? ?? 80 79 ?? ?? 48 ?? ?? 0f 84 ?? ?? ?? ?? 48 89 7c 24 ?? 48 ?? ?? ??", "0x182BB20")]),
|
||||
new(3, "强制移动", false, false, "RVA 0xE16130,Permission ID 14/96/97 已核对",
|
||||
[new("MovePermissionHook", "48 89 5C 24 ?? 48 89 6C 24 ?? 48 89 74 24 ?? 48 89 7C 24 ?? 41 54 41 56 41 57 48 83 EC ?? 33 DB 41 8B E9 48 39 1D", "0xE16130")]),
|
||||
new(4, "击退距离调整", false, false, "RVA 0x90BC90,按本地角色地址过滤",
|
||||
[new("AntiKnockHook", "48 ?? ?? 57 48 ?? ?? ?? ?? ?? ?? 0f 29 70 ?? 0f ?? ??", "0x90BC90")]),
|
||||
new(5, "掉落无伤", false, false, "RVA 0x62AF60,移动标志位掩码处理",
|
||||
[new("OnSendFlagHook", "48 89 5c 24 ?? 57 48 ?? ?? ?? 8b ?? 48 ?? ?? 33 ?? e8 ?? ?? ?? ?? 84 ??", "0x62AF60")]),
|
||||
new(6, "无掉落", false, false, "调用点 RVA 0x182F9E4",
|
||||
[new("FallCheckHook", "e8 ?? ?? ?? ?? 8b ?? 85 ?? 0f 88 ?? ?? ?? ?? 48 ?? ?? ?? f3 ?? ?? ?? ?? ?? ?? ??", "0x182F9E4")]),
|
||||
new(7, "魅惑与恐惧移动处理", true, false, "调用点 RVA 0x194AEC3,第 5 参数核实为 float",
|
||||
[new("ForcedWalkHook", "e8 ?? ?? ?? ?? b0 ?? c7 43 ?? ?? ?? ?? ?? eb ??", "0x194AEC3")]),
|
||||
new(8, "状态屏蔽", true, true, "SetStatus RVA 0x8A6BF0 + 本地角色 RVA 0xB37D7A + 偏移 0x23B0/0x78",
|
||||
[
|
||||
new("StatusSetter", "40 53 56 41 56 48 83 EC 70 45 32 F6 0F 29 74 24 50 41 0F B7 F0 0F 28 F3 48 8B D9 83 FA 3C", "0x8A6BF0"),
|
||||
new("LocalPlayerPtr", "48 8B 1D ?? ?? ?? ?? 48 8B FA 48 85 DB 74 28 48 8B 03 48 8B CB FF 90 78 02 00 00", "0xB37D7A")
|
||||
]),
|
||||
new(9, "飞天遁地", false, false, "普通移动 RVA 0x62B680 + 战斗移动 RVA 0x62B730",
|
||||
[
|
||||
new("OnSendNormalMove", "48 89 5C 24 ?? 48 89 74 24 ?? 57 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 48 8B F9 41 8B D8", "0x62B680"),
|
||||
new("OnSendCombatMove", "48 89 5C 24 ?? 48 89 74 24 ?? 57 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 48 8B F9 41 8B F0", "0x62B730")
|
||||
]),
|
||||
new(10, "深层迷宫专用模式", false, false, "依赖模块 9 与 EventFramework 深层迷宫实例", []),
|
||||
new(11, "迷宫状态显示", false, false, "EventFramework.GetInstanceContentDeepDungeon", []),
|
||||
new(13, "技能距离调整", false, false, "ActionManager.GetActionRange SDK 入口", []),
|
||||
new(14, "目标圈半径调整", true, false, "ActorRadius RVA 0x175728F + 原生圈 RVA 0x7B5430 + ActionManager 距离入口",
|
||||
[
|
||||
new("ActorRadius", "e8 ?? ?? ?? ?? f3 ?? ?? ?? 48 ?? ?? ?? ?? f3 ?? ?? ?? ?? f3 ?? ?? ?? f3 ?? ?? ?? ?? f3 0f 11 74 24 ?? ", "0x175728F"),
|
||||
new("NativeTargetRingUpdate", "4C 8B DC 55 57 41 57 49 8D AB ?? ?? ?? ?? 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 85 ?? ?? ?? ?? 80 B9 ?? ?? ?? ?? ?? 4C 8B FA 48 8B F9 0F 85 ?? ?? ?? ?? 48 8B 41 30", "0x7B5430")
|
||||
]),
|
||||
new(15, "后摇期间可移动", false, false, "RVA 0x8D6F30(首匹配包装入口)",
|
||||
[new("NoBackswing", "48 ?? ?? ?? 48 ?? ?? ?? 45 ?? ?? 33 ?? e8 ?? ?? ?? ?? 84 ?? 74 ?? ", "0x8D6F30", RequireUnique: false)]),
|
||||
new(16, "突进无位移", true, false, "公共位移初始化 RVA 0x1822510,owner +0x3E0 与 ushort Timeline",
|
||||
[new("ActionMovementStart", "48 89 5C 24 ?? 48 89 6C 24 ?? 48 89 74 24 ?? 57 41 56 41 57 48 83 EC ?? 48 8B D9 0F B6 FA 48 81 C1 ?? ?? ?? ?? 4D 8B F1 4D 8B F8 E8 ?? ?? ?? ?? 48 8B B3 ?? ?? ?? ??", "0x1822510")]),
|
||||
new(17, "复唱缩减秒数", false, false, "GetAdjustedRecastTime + 同步 RVA 0x8EF260(GCD 组 57)",
|
||||
[new("SyncRecast", "40 ?? 48 ?? ?? ?? 0f 29 74 24 ?? 41 ?? ?? 0f ?? ?? e8 ?? ?? ?? ??", "0x8EF260")]),
|
||||
new(18, "快速结印", false, false, "依赖模块 17,结印 ActionId 2259/2261/2263/18805..18807", []),
|
||||
new(19, "咏唱缩减秒数", false, false, "GetAdjustedCastTime + 静态地址 RVA 0x104F17 + 更新 RVA 0x8E00A0",
|
||||
[
|
||||
new("CastStaticAddress", "F3 44 0F 2C C0 BA ?? ?? ?? ?? 48 8B CB E8 ?? ?? ?? ?? F3 44 0F 10 1D", "0x104F17"),
|
||||
new("CastUpdate", "48 89 5C 24 ?? 57 48 83 EC ?? 48 8B F9 0F 29 74 24 ?? 0F B6 49", "0x8E00A0")
|
||||
]),
|
||||
new(20, "PVP 动画锁上限", false, false, "ActionManager.AnimationLock 帧更新", []),
|
||||
new(27, "坐标移动", false, false, "GameObject.SetPosition", []),
|
||||
new(28, "面向调整", false, false, "GameObject.SetRotation", []),
|
||||
new(29, "目标缩放", false, false, "RVA 0x899720,含原缩放恢复表",
|
||||
[new("SetScale", "F3 0F 11 89 ?? ?? ?? ?? E9 ?? ?? ?? ?? CC CC CC CC CC CC CC CC CC CC CC CC", "0x899720")]),
|
||||
new(36, "网络记录", true, false, "ZoneUp RVA 0x1D718C0 + ZoneDown RVA 0x1D71440 + 共享观察管线",
|
||||
[
|
||||
new("ZoneUp", "48 89 5C 24 ?? 48 89 74 24 ?? 4C 89 64 24 ?? 55 41 56 41 57 48 8B EC 48 83 EC 70", "0x1D718C0"),
|
||||
new("ZoneDown", "48 89 5C 24 18 57 48 83 EC 20 48 83 B9 88 00 00 00 00 48 8B FA 48 8B D9", "0x1D71440")
|
||||
]),
|
||||
new(37, "包解析与 Opcode 管理", false, false, "离线/实时解析与请求响应关联,不挂接原生 Hook", [])
|
||||
];
|
||||
|
||||
public static AdaptationReport Evaluate(
|
||||
ModuleManager modules,
|
||||
string clientVersion,
|
||||
Func<string, int> countMatches,
|
||||
bool sdkOffsetsValid = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(modules);
|
||||
ArgumentNullException.ThrowIfNull(countMatches);
|
||||
var versionMatches = IsAuditedVersion(clientVersion);
|
||||
var entries = new List<ModuleAdaptationEntry>(Specs.Length);
|
||||
|
||||
foreach (var spec in Specs)
|
||||
{
|
||||
var status = modules.Status[spec.FeatureId];
|
||||
if (!status.HasImplementation)
|
||||
{
|
||||
entries.Add(new(spec.FeatureId, spec.FeatureName, spec.VersionLocked,
|
||||
AdaptationIssue.MissingImplementation, status.Adaptation, "缺实现,保持禁用", spec.Evidence, []));
|
||||
continue;
|
||||
}
|
||||
if (spec.VersionLocked && !versionMatches)
|
||||
{
|
||||
entries.Add(new(spec.FeatureId, spec.FeatureName, spec.VersionLocked,
|
||||
AdaptationIssue.VersionMismatch, Adaptation.Unadapted,
|
||||
$"版本不符:仅核对 {AuditedVersion}(兼容 {BaselineVersion}),当前为 {clientVersion}", spec.Evidence, []));
|
||||
continue;
|
||||
}
|
||||
if (spec.RequiresSdkOffsets && !sdkOffsetsValid)
|
||||
{
|
||||
entries.Add(new(spec.FeatureId, spec.FeatureName, spec.VersionLocked,
|
||||
AdaptationIssue.OffsetMismatch, Adaptation.Unadapted,
|
||||
"SDK 角色/状态偏移与已核对入口不一致", spec.Evidence, []));
|
||||
continue;
|
||||
}
|
||||
|
||||
List<string> missing = [];
|
||||
List<string> multiple = [];
|
||||
foreach (var sig in spec.Signatures)
|
||||
{
|
||||
var matches = countMatches(sig.Signature);
|
||||
if (matches <= 0) missing.Add($"{sig.Name} ({sig.AuditedRva})");
|
||||
else if (sig.RequireUnique && matches > 1) multiple.Add($"{sig.Name} ({matches} 处匹配)");
|
||||
}
|
||||
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
entries.Add(new(spec.FeatureId, spec.FeatureName, spec.VersionLocked,
|
||||
AdaptationIssue.SignatureMissing, Adaptation.Unadapted,
|
||||
$"签名未匹配:{string.Join(", ", missing)}", spec.Evidence, [.. missing]));
|
||||
continue;
|
||||
}
|
||||
if (multiple.Count > 0)
|
||||
{
|
||||
entries.Add(new(spec.FeatureId, spec.FeatureName, spec.VersionLocked,
|
||||
AdaptationIssue.MultipleMatches, Adaptation.Unadapted,
|
||||
$"签名多重匹配,拒绝盲挂:{string.Join(", ", multiple)}", spec.Evidence, [.. multiple]));
|
||||
continue;
|
||||
}
|
||||
if (status.State == ModuleState.Failed)
|
||||
{
|
||||
entries.Add(new(spec.FeatureId, spec.FeatureName, spec.VersionLocked,
|
||||
AdaptationIssue.RuntimeFault, status.Adaptation,
|
||||
$"运行异常:{status.Detail}", spec.Evidence, []));
|
||||
continue;
|
||||
}
|
||||
|
||||
var adapt = versionMatches && ValidationCatalog.UserConfirmed.Contains(spec.FeatureId)
|
||||
? Adaptation.Verified
|
||||
: Adaptation.Testable;
|
||||
var label = adapt == Adaptation.Verified ? "已核对且用户实测有效" : "签名与版本核对通过,待实测";
|
||||
entries.Add(new(spec.FeatureId, spec.FeatureName, spec.VersionLocked,
|
||||
AdaptationIssue.None, adapt, label, spec.Evidence, []));
|
||||
}
|
||||
|
||||
var (activeSendReady, activeSendSummary) = EvaluateActiveSend(clientVersion, countMatches);
|
||||
return new(clientVersion, AuditedVersion, versionMatches, activeSendReady, activeSendSummary, [.. entries]);
|
||||
}
|
||||
|
||||
public static (bool Ready, string Summary) EvaluateActiveSend(string clientVersion, Func<string, int> countMatches)
|
||||
{
|
||||
if (!IsAuditedVersion(clientVersion))
|
||||
return (false, $"主动发送(M4)已禁用:仅核对已审计版本({AuditedVersion} / {BaselineVersion}),当前为 {clientVersion}");
|
||||
foreach (var sig in ActiveSendSignatures)
|
||||
{
|
||||
var matches = countMatches(sig.Signature);
|
||||
if (matches <= 0) return (false, $"主动发送(M4)已禁用:签名未匹配 {sig.Name} ({sig.AuditedRva})");
|
||||
if (sig.RequireUnique && matches > 1) return (false, $"主动发送(M4)已禁用:签名多重匹配 {sig.Name} ({matches})");
|
||||
}
|
||||
return (true, "主动发送(M4)入口核对通过:ReceiverSendPacket → ZoneClientSendPacket");
|
||||
}
|
||||
|
||||
// Applies diagnostic gating without breaking unaffected modules or offline analysis.
|
||||
public static void ApplyGate(ModuleManager modules, AdaptationReport report)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(modules);
|
||||
ArgumentNullException.ThrowIfNull(report);
|
||||
foreach (var entry in report.Entries)
|
||||
{
|
||||
if (!modules.Status.TryGetValue(entry.FeatureId, out var status)) continue;
|
||||
status.Adaptation = entry.AdaptationState;
|
||||
if (entry.Issue is AdaptationIssue.VersionMismatch or AdaptationIssue.OffsetMismatch
|
||||
or AdaptationIssue.SignatureMissing or AdaptationIssue.MultipleMatches)
|
||||
{
|
||||
if (modules.IsEnabled(entry.FeatureId)) modules.Disable(entry.FeatureId);
|
||||
modules.Unavailable(entry.FeatureId, entry.Summary);
|
||||
}
|
||||
else if (entry.Issue == AdaptationIssue.None)
|
||||
{
|
||||
modules.ClearUnavailable(entry.FeatureId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
public enum DashFilterMode { AllLocal, BlockListed, AllowListed }
|
||||
|
||||
public static class DashPolicy
|
||||
{
|
||||
// Audited native consumer reads word [data], then flags at [data+2] and [data+3].
|
||||
public static ushort Timeline(ReadOnlySpan<byte> data) => data.Length>=4 ? BinaryPrimitives.ReadUInt16LittleEndian(data) : (ushort)0;
|
||||
public static bool Block(bool ready,nint owner,nint local,ushort timeline,DashFilterMode mode,IReadOnlySet<uint> list)
|
||||
{
|
||||
if(!ready || owner==0 || owner!=local || timeline==0) return false;
|
||||
return mode switch
|
||||
{
|
||||
DashFilterMode.AllLocal=>true,
|
||||
DashFilterMode.BlockListed=>list.Contains(timeline),
|
||||
DashFilterMode.AllowListed=>list.Count>0 && !list.Contains(timeline),
|
||||
_=>false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DashObservation(ushort Timeline,byte Flags2,byte Flags3,byte MoveType,bool Blocked);
|
||||
|
||||
public sealed record MovementSkill(uint ActionId,string Name,uint Timeline,bool PlayerAction,bool AffectsPosition,bool PvP);
|
||||
public static class MovementSkillCatalog
|
||||
{
|
||||
public static MovementSkill[] Select(IEnumerable<MovementSkill> skills) => skills
|
||||
.Where(s=>s.PlayerAction && s.AffectsPosition).DistinctBy(s=>s.ActionId).OrderBy(s=>s.ActionId).ToArray();
|
||||
public static bool Mappable(MovementSkill skill) => skill.Timeline is >0 and <=ushort.MaxValue;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
public static class CurrentProtocolEvidence
|
||||
{
|
||||
// Only fill missing names/slots; do not rewrite explicit mappings or the cache file.
|
||||
private static readonly (int Opcode,string Name)[] Downstream =
|
||||
[
|
||||
(338,"DOWN_PingRsp"),
|
||||
(671,"DOWN_UpdateHate"),
|
||||
(558,"DOWN_UpdateHater"),
|
||||
(697,"DOWN_EffectResultBasic"),
|
||||
// ActorGauge already exists at a different explicit slot in the cache; use an evidence label.
|
||||
(614,"DOWN_JobGauge"),
|
||||
(788,"DOWN_UpdateClassInfo"),
|
||||
(214,"DOWN_FirstAttack")
|
||||
];
|
||||
public static int AddMissing(string version,Dictionary<int,string> down)
|
||||
{
|
||||
if(version!=PacketParser.AuditedClientVersion) return 0;
|
||||
var added=0;
|
||||
foreach(var (opcode,name) in Downstream)
|
||||
if(!down.ContainsKey(opcode) && !down.ContainsValue(name)) { down[opcode]=name; added++; }
|
||||
return added;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
public sealed record Feature(int Id, string Name, string Group, float Min = 0, float Max = 0, float Default = 0);
|
||||
|
||||
public static class Features
|
||||
{
|
||||
public static readonly Feature[] All =
|
||||
[
|
||||
new(1,"移速调整","移动",0,5), new(2,"最大加速度","移动"),
|
||||
new(3,"强制移动","移动"), new(4,"击退距离调整","移动",0,30),
|
||||
new(5,"掉落无伤","移动"), new(6,"无掉落","移动"),
|
||||
new(7,"魅惑与恐惧移动处理","移动"), new(8,"状态屏蔽","移动"),
|
||||
new(9,"飞天遁地","深层迷宫",-10,10,-7), new(10,"深层迷宫专用模式","深层迷宫"),
|
||||
new(11,"迷宫状态显示","深层迷宫"), new(13,"技能距离调整","战斗",0,40),
|
||||
new(14,"目标圈半径调整","战斗",0,5), new(15,"后摇期间可移动","战斗"),
|
||||
new(16,"突进无位移","战斗"), new(17,"复唱缩减秒数","战斗",0,1),
|
||||
new(18,"快速结印","战斗"), new(19,"咏唱缩减秒数","战斗",0,1),
|
||||
new(20,"PVP 动画锁上限","战斗",0,1,0.6f),
|
||||
new(27,"坐标移动","坐标"), new(28,"面向调整","坐标"),
|
||||
new(29,"目标缩放","坐标",0.1f,5,1),
|
||||
new(36,"网络记录","网络"), new(37,"包解析与 Opcode 管理","网络")
|
||||
];
|
||||
public static Feature Get(int id) => All.First(x => x.Id == id);
|
||||
}
|
||||
|
||||
public enum Adaptation { Unadapted, Testable, Verified, KnownBroken }
|
||||
public enum ModuleState { Disabled, Enabled, Failed }
|
||||
|
||||
public sealed class ModuleStatus(Feature feature)
|
||||
{
|
||||
public Feature Feature { get; } = feature;
|
||||
public Adaptation Adaptation { get; set; } = Adaptation.Unadapted;
|
||||
public ModuleState State { get; set; }
|
||||
public bool Requested { get; set; }
|
||||
public bool HasImplementation { get; set; }
|
||||
public string Detail { get; set; } = "未接入";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
// Session state only. Editing the saved preset never changes the applied height.
|
||||
public sealed class HeightActivation
|
||||
{
|
||||
private float applied;
|
||||
public float Applied => Volatile.Read(ref applied);
|
||||
public bool IsApplied => Applied!=0;
|
||||
public float Toggle(bool moduleEnabled,float preset)
|
||||
{
|
||||
if(!moduleEnabled || IsApplied) { Reset(); return 0; }
|
||||
var next=Policies.FiniteClamp(preset,-10,10);
|
||||
Volatile.Write(ref applied,next);
|
||||
return next;
|
||||
}
|
||||
public void Reset() => Volatile.Write(ref applied,0);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
// Every resource is registered before activation, including during partial startup.
|
||||
public sealed class ResourceScope : IDisposable
|
||||
{
|
||||
private readonly Stack<IDisposable> resources = new();
|
||||
private bool disposed;
|
||||
public T Own<T>(T resource) where T : IDisposable
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
resources.Push(resource);
|
||||
return resource;
|
||||
}
|
||||
public void Defer(Action action) => Own(new ActionDisposable(action));
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
List<Exception> errors = [];
|
||||
while (resources.TryPop(out var resource))
|
||||
try { resource.Dispose(); } catch (Exception ex) { errors.Add(ex); }
|
||||
if (errors.Count > 0) throw new AggregateException("资源释放失败", errors);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ActionDisposable(Action action) : IDisposable
|
||||
{
|
||||
private Action? release = action;
|
||||
public void Dispose() => Interlocked.Exchange(ref release, null)?.Invoke();
|
||||
}
|
||||
|
||||
public sealed class ModuleManager : IDisposable
|
||||
{
|
||||
private readonly Dictionary<int, Action<ResourceScope>> factories = [];
|
||||
private readonly ConcurrentDictionary<int, ResourceScope> active = [];
|
||||
private readonly Dictionary<int, int[]> dependencies = [];
|
||||
private readonly Dictionary<int, string> unavailable = [];
|
||||
private readonly HashSet<int> cleanupFailed = [];
|
||||
private bool disposed;
|
||||
public Dictionary<int, ModuleStatus> Status { get; } = Features.All.ToDictionary(f => f.Id, f => new ModuleStatus(f));
|
||||
public bool IsEnabled(int id) => active.ContainsKey(id);
|
||||
|
||||
public void Register(int id, Action<ResourceScope> start, params int[] required)
|
||||
{
|
||||
factories.Add(id, start); dependencies[id] = required;
|
||||
Status[id].HasImplementation=true;
|
||||
Status[id].Detail = "已关闭";
|
||||
}
|
||||
public void Unavailable(int id, string reason)
|
||||
{
|
||||
unavailable[id] = reason;
|
||||
if (Status.TryGetValue(id, out var state)) state.Detail = reason;
|
||||
}
|
||||
public void ClearUnavailable(int id) => unavailable.Remove(id);
|
||||
|
||||
public bool Enable(int id)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
if (!Status.TryGetValue(id, out var state)) return false;
|
||||
if (cleanupFailed.Contains(id)) { state.Detail="上次资源释放失败,重载插件前禁止重复挂接"; return false; }
|
||||
if (unavailable.TryGetValue(id, out var blockedReason)) { state.State = ModuleState.Failed; state.Detail = blockedReason; return false; }
|
||||
state.Requested = true;
|
||||
if (active.ContainsKey(id)) return true;
|
||||
if (!factories.TryGetValue(id, out var start)) return false;
|
||||
var missing = dependencies[id].Where(x => !IsEnabled(x)).ToArray();
|
||||
if (missing.Length > 0)
|
||||
{ state.State = ModuleState.Failed; state.Detail = $"先启用依赖:{string.Join(", ", missing)}"; return false; }
|
||||
var scope = new ResourceScope();
|
||||
try
|
||||
{
|
||||
start(scope);
|
||||
if (!active.TryAdd(id, scope)) throw new InvalidOperationException("模块已经启用");
|
||||
state.State = ModuleState.Enabled;
|
||||
if(state.Adaptation!=Adaptation.Verified) state.Adaptation = Adaptation.Testable;
|
||||
state.Detail = "已开启";
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { scope.Dispose(); } catch (Exception cleanup) { cleanupFailed.Add(id); ex = new AggregateException(ex, cleanup); }
|
||||
state.State = ModuleState.Failed; state.Detail = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public void Disable(int id)
|
||||
{
|
||||
if (!Status.TryGetValue(id, out var state)) return;
|
||||
foreach (var dependent in dependencies.Where(x => x.Value.Contains(id) && IsEnabled(x.Key)).Select(x => x.Key).ToArray())
|
||||
Disable(dependent);
|
||||
state.Requested = false;
|
||||
if (!active.TryRemove(id, out var scope)) return;
|
||||
try { scope.Dispose(); state.State = ModuleState.Disabled; state.Detail = "已关闭"; }
|
||||
catch (Exception ex) { cleanupFailed.Add(id); state.State = ModuleState.Failed; state.Detail = $"卸载失败:{ex.Message}"; }
|
||||
}
|
||||
public void DisableAll() { foreach (var id in active.Keys.Reverse().ToArray()) Disable(id); }
|
||||
public void Dispose() { if (disposed) return; DisableAll(); disposed = true; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
public static class NativeIds
|
||||
{
|
||||
// The native check resolves Permission sheet 0x14A. 1001+ are native aliases, not Status IDs.
|
||||
public static readonly FrozenSet<uint> MovementPermissionChecks = new uint[] {96,97,98,99,0x3E9,0x3EE,0x3EF,0x3F0}.ToFrozenSet();
|
||||
// Action rows: Ten, Chi and Jin, including retained variants in the current action sheet.
|
||||
public static readonly FrozenSet<uint> MudraActions = new uint[] {2259,2261,2263,18805,18806,18807}.ToFrozenSet();
|
||||
// Zero-based cooldown group index passed to ActionManager.GetRecastGroupDetail.
|
||||
public const int GlobalCooldownGroup = 57;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
// The reader must copy exactly count bytes from the current process or fail.
|
||||
public delegate bool PacketMemoryReader(nint address,int count,out byte[] bytes);
|
||||
public sealed record CapturedPacket(int Opcode,uint? Target,byte[] Payload,bool Complete,string? Issue=null);
|
||||
public sealed record CaptureRead(CapturedPacket? Packet,string? Error=null);
|
||||
|
||||
public static class NativePacketCapture
|
||||
{
|
||||
public const string ClientVersion="2026.10.06.0000.0000";
|
||||
public const string BaselineClientVersion="2026.09.15.0000.0000";
|
||||
public static bool IsSupportedVersion(string? version) => version is ClientVersion or BaselineClientVersion;
|
||||
public const int IpcHeaderSize=0x10;
|
||||
public const int MaxPayload=65536;
|
||||
|
||||
public static CaptureRead Up(PacketMemoryReader read,nint data,Func<int,bool> accepts)
|
||||
{
|
||||
if(!Read(read,data,0,16,out var header)) return new(null,"上行对象头读取失败");
|
||||
var opcode=BinaryPrimitives.ReadUInt16LittleEndian(header);
|
||||
if(!accepts(opcode)) return new(null);
|
||||
return Payload(read,data,0x20,BinaryPrimitives.ReadUInt64LittleEndian(header.AsSpan(8)),opcode,null);
|
||||
}
|
||||
// Called only AFTER ZoneReceive returned success; holder owns the message until its caller releases it.
|
||||
public static CaptureRead Down(PacketMemoryReader read,nint holder,Func<int,bool> accepts)
|
||||
{
|
||||
if(!Read(read,holder,0x10,8,out var reference)) return new(null,"下行消息引用读取失败");
|
||||
var message=Pointer(reference);
|
||||
if(!Read(read,message,0x20,0x20,out var descriptor)) return new(null,"下行消息描述读取失败");
|
||||
var length=BinaryPrimitives.ReadUInt64LittleEndian(descriptor.AsSpan(0x10));
|
||||
var data=Pointer(descriptor.AsSpan(0x18));
|
||||
// Even a fallback must not read an opcode beyond the declared message.
|
||||
if(length<4 || !Read(read,data,0,4,out var ipc)) return new(null,"下行 IPC 头读取失败或长度不足");
|
||||
var opcode=BinaryPrimitives.ReadUInt16LittleEndian(ipc.AsSpan(2));
|
||||
if(!accepts(opcode)) return new(null);
|
||||
var target=NormalizeTarget(BinaryPrimitives.ReadUInt32LittleEndian(descriptor));
|
||||
return Payload(read,data,IpcHeaderSize,length,opcode,target);
|
||||
}
|
||||
private static CaptureRead Payload(PacketMemoryReader read,nint data,int offset,ulong messageLength,int opcode,uint? target)
|
||||
{
|
||||
if(!TryPayloadLength(messageLength,out var count))
|
||||
return new(new(opcode,target,[],false,$"消息长度无效或超出采集上限:{messageLength}"));
|
||||
if(count==0) return new(new(opcode,target,[],true));
|
||||
return Read(read,data,offset,count,out var bytes)
|
||||
? new(new(opcode,target,bytes,true))
|
||||
: new(new(opcode,target,[],false,"载荷读取失败,未保留部分数据"));
|
||||
}
|
||||
public static bool TryPayloadLength(ulong messageLength,out int count)
|
||||
{
|
||||
count=0;
|
||||
if(messageLength<IpcHeaderSize || messageLength>MaxPayload+IpcHeaderSize) return false;
|
||||
count=(int)messageLength-IpcHeaderSize;
|
||||
return true;
|
||||
}
|
||||
// Same reserved-target normalization as the native NetworkModule dispatcher caller.
|
||||
public static uint NormalizeTarget(uint target) => (target&0x5FF80000u)==0x5FF80000u ? 0x5FF80002u-(target&1u) : target;
|
||||
private static nint Pointer(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var value=BinaryPrimitives.ReadUInt64LittleEndian(bytes);
|
||||
return value>(ulong)nint.MaxValue ? 0 : (nint)value;
|
||||
}
|
||||
private static bool Read(PacketMemoryReader read,nint data,int offset,int count,out byte[] bytes)
|
||||
{
|
||||
bytes=[];
|
||||
if(data<=0 || offset<0 || count<0 || count>MaxPayload || (long)data>long.MaxValue-offset-count) return false;
|
||||
if(!read(data+offset,count,out var result) || result==null || result.Length!=count) return false;
|
||||
bytes=result;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
public enum NetworkPhase { Received, BeforeSend, AfterSend }
|
||||
public enum PacketOrigin { NativeZone, PluginActiveSend }
|
||||
public enum PacketStage { RawIpcPayload }
|
||||
public sealed record PacketContext(long ObservationId, PacketOrigin Origin, PacketStage Stage,
|
||||
NetworkPhase Phase, byte? NativeResult = null);
|
||||
|
||||
// Owned immutable copy. No native pointer or mutable array is exposed to observers.
|
||||
public sealed class NetworkMessage
|
||||
{
|
||||
private readonly byte[] payload;
|
||||
public long Id { get; }
|
||||
public DateTimeOffset Time { get; } = DateTimeOffset.UtcNow;
|
||||
public long MonotonicTicks { get; } = Stopwatch.GetTimestamp();
|
||||
public PacketDirection Direction { get; }
|
||||
public string GameVersion { get; }
|
||||
public int Opcode { get; }
|
||||
public uint? Target { get; }
|
||||
public bool Complete { get; }
|
||||
public string? Issue { get; }
|
||||
public PacketOrigin Origin { get; }
|
||||
public ReadOnlySpan<byte> Payload => payload;
|
||||
public NetworkMessage(long id, PacketDirection direction, string version, CapturedPacket packet,
|
||||
PacketOrigin origin = PacketOrigin.NativeZone)
|
||||
{
|
||||
Id=id; Direction=direction; GameVersion=version; Opcode=packet.Opcode;
|
||||
Target=packet.Target; Complete=packet.Complete; Issue=packet.Issue; Origin=origin;
|
||||
payload=packet.Payload.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record NetworkObservation(NetworkMessage Message, NetworkPhase Phase, byte? NativeResult = null)
|
||||
{
|
||||
public PacketContext Context => new(Message.Id,Message.Origin,PacketStage.RawIpcPayload,Phase,NativeResult);
|
||||
}
|
||||
|
||||
// Inline on the native calling thread, in registration order per observation. Concurrent
|
||||
// calls may interleave. Callbacks must be short; no game/UI service access, waits for
|
||||
// Framework, native sending, or changes to native Hook lifetimes from a callback.
|
||||
public sealed class NetworkPipeline(string version) : IDisposable
|
||||
{
|
||||
private sealed class Subscription(NetworkPhase phase, Action<NetworkObservation> callback, int? opcode)
|
||||
{
|
||||
public readonly NetworkPhase Phase=phase;
|
||||
public readonly Action<NetworkObservation> Callback=callback;
|
||||
public readonly int? Opcode=opcode;
|
||||
public int Active=1;
|
||||
public bool Matches(NetworkPhase phase, int? opcode=null) => Volatile.Read(ref Active)!=0 &&
|
||||
Phase==phase && (!opcode.HasValue || !Opcode.HasValue || Opcode==opcode);
|
||||
}
|
||||
private readonly object sync=new();
|
||||
private Subscription[] subscriptions=[];
|
||||
private bool disposed;
|
||||
private long sequence,readErrors,observerErrors,received,beforeSend,afterSend;
|
||||
private int lastSendResult=-1;
|
||||
private string lastIssue="无";
|
||||
public long ReadErrors => Interlocked.Read(ref readErrors);
|
||||
public long ObserverErrors => Interlocked.Read(ref observerErrors);
|
||||
public long Received => Interlocked.Read(ref received);
|
||||
public long BeforeSend => Interlocked.Read(ref beforeSend);
|
||||
public long AfterSend => Interlocked.Read(ref afterSend);
|
||||
public int LastSendResult => Volatile.Read(ref lastSendResult);
|
||||
public string LastIssue => Volatile.Read(ref lastIssue);
|
||||
public int SubscriberCount => Volatile.Read(ref subscriptions).Length;
|
||||
|
||||
public IDisposable Subscribe(NetworkPhase phase, Action<NetworkObservation> callback, int? opcode=null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
if(!Enum.IsDefined(phase) || opcode is <0 or >65535) throw new ArgumentOutOfRangeException(nameof(opcode));
|
||||
var item=new Subscription(phase,callback,opcode);
|
||||
lock(sync)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed,this);
|
||||
Volatile.Write(ref subscriptions,[..subscriptions,item]);
|
||||
}
|
||||
return new ActionDisposable(()=>
|
||||
{
|
||||
// Already-entered callbacks may finish; cancellation cannot recall them.
|
||||
Interlocked.Exchange(ref item.Active,0);
|
||||
lock(sync) Volatile.Write(ref subscriptions,subscriptions.Where(x=>x!=item).ToArray());
|
||||
});
|
||||
}
|
||||
public byte Send(Func<byte> original, Func<Func<int,bool>,CaptureRead> capture)
|
||||
{
|
||||
var listeners=Volatile.Read(ref subscriptions);
|
||||
NetworkMessage? message=null;
|
||||
if(listeners.Any(s=>s.Matches(NetworkPhase.BeforeSend) || s.Matches(NetworkPhase.AfterSend)))
|
||||
message=Read(PacketDirection.Up,capture,opcode=>listeners.Any(s=>
|
||||
s.Matches(NetworkPhase.BeforeSend,opcode) || s.Matches(NetworkPhase.AfterSend,opcode)));
|
||||
if(message!=null)
|
||||
{
|
||||
Interlocked.Increment(ref beforeSend);
|
||||
Publish(listeners,new(message,NetworkPhase.BeforeSend));
|
||||
}
|
||||
// Outside observer/read exception handlers: never retry the native call.
|
||||
var result=original();
|
||||
Volatile.Write(ref lastSendResult,result);
|
||||
if(message!=null)
|
||||
{
|
||||
Interlocked.Increment(ref afterSend);
|
||||
Publish(listeners,new(message,NetworkPhase.AfterSend,result));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
public byte Receive(Func<byte> original, Func<Func<int,bool>,CaptureRead> capture)
|
||||
{
|
||||
var result=original();
|
||||
if(result==0) return result; // Holder is unreadable on unsuccessful receive.
|
||||
var listeners=Volatile.Read(ref subscriptions);
|
||||
if(listeners.Any(s=>s.Matches(NetworkPhase.Received)))
|
||||
{
|
||||
var message=Read(PacketDirection.Down,capture,opcode=>listeners.Any(s=>s.Matches(NetworkPhase.Received,opcode)));
|
||||
if(message!=null)
|
||||
{
|
||||
Interlocked.Increment(ref received);
|
||||
Publish(listeners,new(message,NetworkPhase.Received,result));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private NetworkMessage? Read(PacketDirection direction,Func<Func<int,bool>,CaptureRead> capture,Func<int,bool> accepts)
|
||||
{
|
||||
try
|
||||
{
|
||||
var read=capture(accepts);
|
||||
if(read.Error!=null) { ReadFailed(read.Error); return null; }
|
||||
if(read.Packet is not {} packet) return null;
|
||||
if(!packet.Complete) ReadFailed(packet.Issue ?? "载荷读取不完整");
|
||||
var origin=direction==PacketDirection.Up && ActiveSendScope.Active ? PacketOrigin.PluginActiveSend : PacketOrigin.NativeZone;
|
||||
return new(Interlocked.Increment(ref sequence),direction,version,packet,origin);
|
||||
}
|
||||
catch(Exception ex) { ReadFailed(ex.Message); return null; }
|
||||
}
|
||||
private void ReadFailed(string issue)
|
||||
{
|
||||
Interlocked.Increment(ref readErrors); Volatile.Write(ref lastIssue,issue);
|
||||
}
|
||||
[ThreadStatic] private static int callbackDepth;
|
||||
public static bool InCallback => callbackDepth > 0;
|
||||
private void Publish(Subscription[] listeners,NetworkObservation observation)
|
||||
{
|
||||
callbackDepth++;
|
||||
try
|
||||
{
|
||||
foreach(var listener in listeners)
|
||||
{
|
||||
if(!listener.Matches(observation.Phase,observation.Message.Opcode)) continue;
|
||||
try { listener.Callback(observation); }
|
||||
catch(Exception ex)
|
||||
{
|
||||
Interlocked.Increment(ref observerErrors);
|
||||
Volatile.Write(ref lastIssue,$"{observation.Phase} 订阅异常:{ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(callbackDepth>0) callbackDepth--;
|
||||
}
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
lock(sync)
|
||||
{
|
||||
disposed=true;
|
||||
foreach(var listener in subscriptions) Interlocked.Exchange(ref listener.Active,0);
|
||||
Volatile.Write(ref subscriptions,[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
// One consumer of the manager. Pausing and filtering affect only this log.
|
||||
public sealed class PacketRecorder(PacketBuffer buffer)
|
||||
{
|
||||
private long completeUp,completeDown,metadataOnly;
|
||||
public long CompleteUp => Interlocked.Read(ref completeUp);
|
||||
public long CompleteDown => Interlocked.Read(ref completeDown);
|
||||
public long MetadataOnly => Interlocked.Read(ref metadataOnly);
|
||||
public void Record(NetworkObservation observation)
|
||||
{
|
||||
if(observation.Phase==NetworkPhase.AfterSend) return; // One record per message.
|
||||
var packet=observation.Message;
|
||||
if(!buffer.Add(packet.Direction,packet.Opcode,packet.GameVersion,null,packet.Target,packet.Payload,
|
||||
packet.Complete,packet.Issue,observation.Context,packet.MonotonicTicks,packet.Time)) return;
|
||||
if(!packet.Complete) Interlocked.Increment(ref metadataOnly);
|
||||
else if(packet.Direction==PacketDirection.Up) Interlocked.Increment(ref completeUp);
|
||||
else Interlocked.Increment(ref completeDown);
|
||||
}
|
||||
}
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text.Json;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
public enum PacketDirection { Up, Down }
|
||||
public sealed record PacketRecord(long Sequence, long MonotonicTicks, DateTimeOffset Time, PacketDirection Direction,
|
||||
int Opcode, string GameVersion, uint? Source, uint? Target, byte[] Payload, bool PayloadComplete = true,string? CaptureIssue = null,
|
||||
PacketContext? Context = null);
|
||||
public sealed record OpcodeSnapshot(string GameVersion, string Source, IReadOnlyDictionary<int,string> Up, IReadOnlyDictionary<int,string> Down);
|
||||
|
||||
public sealed class OpcodeMap
|
||||
{
|
||||
private OpcodeSnapshot snapshot = new("unknown","none",new ReadOnlyDictionary<int,string>(new Dictionary<int,string>()),new ReadOnlyDictionary<int,string>(new Dictionary<int,string>()));
|
||||
public OpcodeSnapshot Snapshot => Volatile.Read(ref snapshot);
|
||||
public string? Resolve(string version, PacketDirection direction, int opcode)
|
||||
{
|
||||
var state = Snapshot;
|
||||
return version != "unknown" && state.GameVersion == version ? (direction == PacketDirection.Up ? state.Up : state.Down).GetValueOrDefault(opcode) : null;
|
||||
}
|
||||
public int? ResolveOpcode(string version, PacketDirection direction, string name)
|
||||
{
|
||||
var state = Snapshot;
|
||||
if (version == "unknown" || state.GameVersion != version || string.IsNullOrWhiteSpace(name)) return null;
|
||||
var dict = direction == PacketDirection.Up ? state.Up : state.Down;
|
||||
int? found = null;
|
||||
foreach (var kv in dict)
|
||||
{
|
||||
if (kv.Value != name) continue;
|
||||
if (found.HasValue) return null; // Refuse ambiguous multi-opcode mappings.
|
||||
found = kv.Key;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
public void Load(string json, string version, string source)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(version) || version == "unknown") throw new InvalidDataException("客户端版本未知,不能应用映射");
|
||||
using var document = JsonDocument.Parse(json,new JsonDocumentOptions { MaxDepth = 12 });
|
||||
if (!document.RootElement.TryGetProperty(version,out var entries) || entries.ValueKind != JsonValueKind.Object)
|
||||
throw new InvalidDataException($"映射不包含版本 {version}");
|
||||
Dictionary<int,string> up = [], down = [];
|
||||
foreach (var property in entries.EnumerateObject())
|
||||
{
|
||||
Dictionary<int,string> target;
|
||||
if (property.Name.StartsWith("UP_",StringComparison.Ordinal)) target = up;
|
||||
else if (property.Name.StartsWith("DOWN_",StringComparison.Ordinal)) target = down;
|
||||
else continue;
|
||||
if (property.Value.ValueKind != JsonValueKind.Array) throw new InvalidDataException("Opcode 值应为数组");
|
||||
foreach (var item in property.Value.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetInt32(out var code) || code is < 0 or > 65535) throw new InvalidDataException("Opcode 超出 ushort 范围");
|
||||
if (target.TryGetValue(code,out var old) && old != property.Name) throw new InvalidDataException($"Opcode {code} 存在冲突");
|
||||
target[code] = property.Name;
|
||||
}
|
||||
}
|
||||
if (up.Count + down.Count == 0) throw new InvalidDataException("映射为空");
|
||||
var additions=CurrentProtocolEvidence.AddMissing(version,down);
|
||||
if(additions>0) source+=$" + 本机核实 {additions} 项";
|
||||
Volatile.Write(ref snapshot,new(version,source,new ReadOnlyDictionary<int,string>(up),new ReadOnlyDictionary<int,string>(down)));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ParseResult(bool Parsed, string Summary);
|
||||
public static class PacketParser
|
||||
{
|
||||
public const string AuditedClientVersion="2026.10.06.0000.0000";
|
||||
public const string BaselineClientVersion="2026.09.15.0000.0000";
|
||||
public static bool IsAuditedVersion(string? version) => version is AuditedClientVersion or BaselineClientVersion;
|
||||
public static ParseResult Parse(string? name, ReadOnlySpan<byte> payload,string? gameVersion=null)
|
||||
{
|
||||
if (name == null) return new(false,"未知 Opcode;保留原始数据");
|
||||
var current=IsAuditedVersion(gameVersion);
|
||||
if (TryEventTier(name,out var eventLabel,out var eventCapacity,out var eventLength))
|
||||
return EventPacket(name,eventLabel,payload,current,eventCapacity,eventLength);
|
||||
switch (name)
|
||||
{
|
||||
case "UP_PingReq":
|
||||
var pingLength=current ? 0x20 : 0x1C;
|
||||
if (payload.Length != pingLength) return Invalid(name,payload.Length,pingLength);
|
||||
return new(true,$"Ping 请求 time={U32(payload,0)} ms reported={U32(payload,4)} ms");
|
||||
case "DOWN_PingRsp":
|
||||
if (current ? payload.Length!=0x20 : payload.Length<0x10) return Invalid(name,payload.Length,current ? 0x20 : 0x10);
|
||||
return new(true,$"Ping 响应 echo={U32(payload,0)} ms field04={U32(payload,4)}(未解释);其余字节不解释");
|
||||
case "UP_UpdatePositionHandler":
|
||||
var positionLength=current ? 0x18 : 0x28;
|
||||
if (payload.Length != positionLength) return Invalid(name,payload.Length,positionLength);
|
||||
return Position(payload,8);
|
||||
case "UP_UpdatePositionInstance":
|
||||
if (payload.Length != 0x30) return Invalid(name,payload.Length,0x30);
|
||||
var first = Position(payload,12); var second = Position(payload,24);
|
||||
return new(first.Parsed && second.Parsed,$"{first.Summary}; new {second.Summary}");
|
||||
case "UP_EventStart":
|
||||
if (payload.Length != 0x10) return Invalid(name,payload.Length,0x10);
|
||||
return new(true,$"对象={U64(payload,0)} event={U32(payload,8)}");
|
||||
case "UP_ActionSend":
|
||||
if(!current) return Unverified(name);
|
||||
if(payload.Length!=0x20) return Invalid(name,payload.Length,0x20);
|
||||
return new(true,$"动作请求 · 技能={U32(payload,0)} 类型={payload[5]} 请求序号={U16(payload,6)} 目标=0x{U64(payload,0x10):X16} 附加={U32(payload,0x18)}");
|
||||
case "UP_ActionSendPos":
|
||||
if(!current) return Unverified(name);
|
||||
if(payload.Length!=0x18) return Invalid(name,payload.Length,0x18);
|
||||
var actionPos=Position(payload,0x0C);
|
||||
return actionPos.Parsed
|
||||
? new(true,$"地面动作请求 · 技能={U32(payload,0)} 类型={payload[5]} 请求序号={U16(payload,6)} {actionPos.Summary}")
|
||||
: actionPos;
|
||||
case "UP_ClientTrigger":
|
||||
if(!current) return Unverified(name);
|
||||
if(payload.Length!=0x20) return Invalid(name,payload.Length,0x20);
|
||||
return new(true,$"客户端触发器 · 指令={U32(payload,0)} 参数=({U32(payload,4)},{U32(payload,8)},{U32(payload,12)},{U32(payload,16)}) 目标=0x{U64(payload,0x18):X16}");
|
||||
case "DOWN_UpdateHate": case "DOWN_UpdateHater":
|
||||
if(!current) return new(false,$"{name}:此版本的仇恨列表布局未核实");
|
||||
return Enmity(name,payload,name=="DOWN_UpdateHate" ? 8 : 32);
|
||||
case "DOWN_EffectResultBasic":
|
||||
if(!current) return new(false,"此版本的精简效果结果布局未核实");
|
||||
if(payload.Length!=24) return Invalid(name,payload.Length,24);
|
||||
if(payload[0]>1) return new(false,"精简效果结果计数超出此包容量");
|
||||
if(payload[0]==0) return new(true,"精简效果结果条目=0");
|
||||
return new(true,$"精简效果结果条目=1 · 序列={U32(payload,4)} · 当前 HP={U32(payload,12)} · 目标序号={payload[16]};其他字段未解释");
|
||||
case "DOWN_JobGauge":
|
||||
if(!current) return new(false,"此版本的职业量表布局未核实");
|
||||
if(payload.Length!=16) return Invalid(name,payload.Length,16);
|
||||
return new(true,$"职业量表更新 · 职业 ID={payload[0]};各职业量表字段保留原始数据");
|
||||
case "DOWN_UpdateClassInfo":
|
||||
if(!current) return new(false,"此版本的职业信息布局未核实");
|
||||
if(payload.Length!=16) return Invalid(name,payload.Length,16);
|
||||
return new(true,$"职业信息 · 职业 ID={payload[0]} · 当前等级={U16(payload,2)} · 经验={U32(payload,8)};其他字段未解释");
|
||||
case "DOWN_FirstAttack":
|
||||
if(!current) return new(false,"此版本的战斗归属布局未核实");
|
||||
if(payload.Length!=16) return Invalid(name,payload.Length,16);
|
||||
return new(true,$"战斗归属 · 类型={payload[0]} · 标记者=0x{U64(payload,8):X16};不是技能伤害包");
|
||||
case "DOWN_ActorControl":
|
||||
if(!current) return Unverified(name);
|
||||
if(payload.Length!=0x18) return Invalid(name,payload.Length,0x18);
|
||||
var category=U16(payload,0);
|
||||
var note=category==0x22 ? "(Arg0 为分派前原始值)" : "";
|
||||
return new(true,$"角色控制 · 类型=0x{category:X4} 参数=({U32(payload,4)},{U32(payload,8)},{U32(payload,12)},{U32(payload,16)}){note}");
|
||||
case "DOWN_ActorControlSelf":
|
||||
if(!current) return Unverified(name);
|
||||
if(payload.Length!=0x28) return Invalid(name,payload.Length,0x28);
|
||||
return new(true,$"自身控制 · 类型=0x{U16(payload,0):X4} 参数=({U32(payload,4)},{U32(payload,8)},{U32(payload,12)},{U32(payload,16)},{U32(payload,20)},{U32(payload,24)},{U32(payload,28)},{U32(payload,32)})");
|
||||
case "DOWN_ActorControlTarget":
|
||||
if(!current) return Unverified(name);
|
||||
if(payload.Length!=0x20) return Invalid(name,payload.Length,0x20);
|
||||
return new(true,$"目标控制 · 类型=0x{U16(payload,0):X4} 参数=({U32(payload,4)},{U32(payload,8)},{U32(payload,12)},{U32(payload,16)}) 目标=0x{U64(payload,0x18):X16}");
|
||||
case "DOWN_Effect":
|
||||
if(!current) return Unverified(name);
|
||||
if(payload.Length!=0x7C) return Invalid(name,payload.Length,0x7C);
|
||||
var lockTime=F32(payload,0x10);
|
||||
if(!float.IsFinite(lockTime) || lockTime<0) return new(false,"技能效果动画锁时间无效");
|
||||
var targetCount=payload[0x21];
|
||||
if(targetCount>1) return new(false,$"单目标技能效果目标计数 {targetCount} 超出容量 1");
|
||||
return new(true,$"技能效果 · 序列={U32(payload,0x0C)} 请求序号={U16(payload,0x18)} 动画锁={lockTime:F2}s 类型={payload[0x1F]} 目标数={targetCount} 主目标=0x{U64(payload,0x70):X16}(动作与效果字为分派前原始值)");
|
||||
case "DOWN_StatusEffectList":
|
||||
if(!current) return Unverified(name);
|
||||
if(payload.Length!=0x180) return Invalid(name,payload.Length,0x180);
|
||||
var activeSlots=0;
|
||||
for(var i=0;i<30;i++)
|
||||
{
|
||||
var slotOffset=0x14+i*12;
|
||||
if(!float.IsFinite(F32(payload,slotOffset+4))) return new(false,"状态列表包含非有限持续时间");
|
||||
if(U16(payload,slotOffset)!=0) activeSlots++;
|
||||
}
|
||||
return new(true,$"状态列表 · 职业={payload[0]} 等级={payload[1]} HP={U32(payload,4)}/{U32(payload,8)} 资源={U16(payload,12)}/{U16(payload,14)} 护盾={payload[0x10]}% 非空槽位={activeSlots}/30(状态 ID 为分派转换前原始值)");
|
||||
case "DOWN_EffectResult":
|
||||
if(current)
|
||||
{
|
||||
if(payload.Length!=0x60) return Invalid(name,payload.Length,0x60);
|
||||
int entryCount=payload[0];
|
||||
if(entryCount>1) return new(false,"效果结果计数超出单条目包容量");
|
||||
if(entryCount==0) return new(true,"效果结果条目=0");
|
||||
int statusCount=payload[0x19];
|
||||
if(statusCount>4) return new(false,"效果状态计数超限");
|
||||
for(var i=0;i<statusCount;i++)
|
||||
if(!float.IsFinite(F32(payload,0x1C+i*16+8))) return new(false,"效果状态持续时间包含非有限数");
|
||||
return new(true,$"效果结果条目=1 · 序列={U32(payload,4)} HP={U32(payload,0x0C)}/{U32(payload,0x10)} MP={U16(payload,0x14)} 目标序号={payload[0x16]} 职业={payload[0x17]} 护盾={payload[0x18]}% 状态数={statusCount}");
|
||||
}
|
||||
if (payload.Length < 4) return Invalid(name,payload.Length,4);
|
||||
int count = payload[0];
|
||||
if (count > 16 || payload.Length < 4 + count * 0x58) return new(false,"效果结果计数超限或载荷截断");
|
||||
for (var i=0;i<count;i++)
|
||||
if (payload[4+i*0x58+0x15] > 4) return new(false,"效果状态计数超限");
|
||||
return new(true,$"效果结果条目={count};历史布局解析,须样本核验");
|
||||
case "DOWN_ActorDelete":
|
||||
if(!current) return Unverified(name);
|
||||
if(payload.Length!=8) return Invalid(name,payload.Length,8);
|
||||
return new(true,$"对象移除 · 槽位索引={payload[0]};其余字节未由消费函数读取");
|
||||
case "DOWN_ObjectSpawn":
|
||||
if(!current) return Unverified(name);
|
||||
if(payload.Length!=0x40) return Invalid(name,payload.Length,0x40);
|
||||
var objPos=Position(payload,0x34);
|
||||
return objPos.Parsed
|
||||
? new(true,$"对象生成 · 槽位={payload[0]} 类型={payload[1]} BaseId={U32(payload,4)} EntityId=0x{U32(payload,8):X8} {objPos.Summary}")
|
||||
: objPos;
|
||||
case "DOWN_NpcSpawn":
|
||||
if(!current) return Unverified(name);
|
||||
if(payload.Length!=0x290) return Invalid(name,payload.Length,0x290);
|
||||
var npcPos=Position(payload,0x200);
|
||||
return npcPos.Parsed
|
||||
? new(true,$"NPC 生成 · 槽位={payload[0x7E]} BaseId={U32(payload,0x40)} Owner=0x{U32(payload,0x54):X8} 等级={payload[0x86]} 职业={payload[0x87]} HP={U32(payload,0x60)}/{U32(payload,0x5C)} {npcPos.Summary}")
|
||||
: npcPos;
|
||||
default: return new(false,$"{name}:尚无已接入解析器,保留原始数据");
|
||||
}
|
||||
}
|
||||
private static bool TryEventTier(string name,out string label,out int capacity,out int expectedLength)
|
||||
{
|
||||
label=""; capacity=0; expectedLength=0;
|
||||
ReadOnlySpan<char> suffix;
|
||||
if(name.StartsWith("UP_EventFinish",StringComparison.Ordinal)) { label="事件完成"; suffix=name.AsSpan("UP_EventFinish".Length); }
|
||||
else if(name.StartsWith("UP_EventAction",StringComparison.Ordinal)) { label="事件动作"; suffix=name.AsSpan("UP_EventAction".Length); }
|
||||
else return false;
|
||||
if(suffix.IsEmpty) { capacity=2; expectedLength=0x10; return true; }
|
||||
if(!int.TryParse(suffix,out capacity)) return false;
|
||||
expectedLength=capacity switch { 4=>0x18, 8=>0x28, 16=>0x48, 32=>0x88, 64=>0x108, 128=>0x208, 255=>0x408, _=>0 };
|
||||
return expectedLength!=0;
|
||||
}
|
||||
private static ParseResult EventPacket(string name,string label,ReadOnlySpan<byte> payload,bool current,int capacity,int expectedLength)
|
||||
{
|
||||
if(!current) return new(false,$"{name}:变长事件分档仅核实 {AuditedClientVersion},保留 {payload.Length} 字节原始数据");
|
||||
if(payload.Length!=expectedLength) return Invalid(name,payload.Length,expectedLength);
|
||||
int argCount=payload[7];
|
||||
if(argCount>capacity) return new(false,$"{name} 参数计数 {argCount} 超出分档容量 {capacity}");
|
||||
var previewCount=Math.Min(argCount,4);
|
||||
var args=new List<string>(previewCount);
|
||||
for(var i=0;i<previewCount;i++) args.Add(U32(payload,8+i*4).ToString());
|
||||
var suffix=argCount>previewCount ? ",..." : "";
|
||||
return new(true,$"{label} · event={U32(payload,0)} scene={U16(payload,4)} 状态={payload[6]} 参数数={argCount}/{capacity} [{string.Join(",",args)}{suffix}]");
|
||||
}
|
||||
private static ushort U16(ReadOnlySpan<byte> p,int at) => BinaryPrimitives.ReadUInt16LittleEndian(p[at..]);
|
||||
private static uint U32(ReadOnlySpan<byte> p,int at) => BinaryPrimitives.ReadUInt32LittleEndian(p[at..]);
|
||||
private static ulong U64(ReadOnlySpan<byte> p,int at) => BinaryPrimitives.ReadUInt64LittleEndian(p[at..]);
|
||||
private static float F32(ReadOnlySpan<byte> p,int at) => BinaryPrimitives.ReadSingleLittleEndian(p[at..]);
|
||||
private static ParseResult Unverified(string name) => new(false,$"{name}:此版本的字段布局未核实");
|
||||
private static ParseResult Enmity(string name,ReadOnlySpan<byte> payload,int capacity)
|
||||
{
|
||||
var expected=8+capacity*8; // 4-byte prefix, fixed entries, 4 trailing bytes kept uninterpreted.
|
||||
if(payload.Length!=expected) return Invalid(name,payload.Length,expected);
|
||||
var count=payload[0];
|
||||
if(count>capacity) return new(false,$"仇恨列表计数 {count} 超过容量 {capacity}");
|
||||
var entries=new List<string>(count);
|
||||
for(var i=0;i<count;i++) entries.Add($"0x{U32(payload,4+i*8):X8}:{payload[8+i*8]}");
|
||||
var label=name=="DOWN_UpdateHate" ? "目标仇恨列表" : "自身敌对列表";
|
||||
return new(true,$"{label} {count} 项(对象:仇恨值){string.Join(", ",entries)}");
|
||||
}
|
||||
private static ParseResult Position(ReadOnlySpan<byte> p,int at)
|
||||
{
|
||||
float x=BinaryPrimitives.ReadSingleLittleEndian(p[at..]), y=BinaryPrimitives.ReadSingleLittleEndian(p[(at+4)..]), z=BinaryPrimitives.ReadSingleLittleEndian(p[(at+8)..]);
|
||||
return float.IsFinite(x)&&float.IsFinite(y)&&float.IsFinite(z) ? new(true,$"X={x:F3} Y={y:F3} Z={z:F3}") : new(false,"坐标包含非有限数");
|
||||
}
|
||||
private static ParseResult Invalid(string name,int actual,int expected) => new(false,$"{name} 长度不匹配:{actual},预期 {expected}");
|
||||
}
|
||||
|
||||
public sealed class PacketBuffer(int capacity = 2048, int maxPayload = 65536,int maxBytes = 8*1024*1024)
|
||||
{
|
||||
private readonly object sync = new();
|
||||
private readonly Queue<PacketRecord> records = new();
|
||||
private long sequence;
|
||||
private bool paused = true;
|
||||
private PacketDirection? directionFilter;
|
||||
private int? opcodeFilter;
|
||||
private long dropped;
|
||||
private long payloadBytes;
|
||||
public long PayloadBytes { get { lock(sync) return payloadBytes; } }
|
||||
public int PayloadByteLimit => maxBytes;
|
||||
public bool Paused { get { lock(sync) return paused; } set { lock(sync) paused=value; } }
|
||||
public PacketDirection? DirectionFilter { get { lock(sync) return directionFilter; } set { lock(sync) directionFilter=value; } }
|
||||
public int? OpcodeFilter { get { lock(sync) return opcodeFilter; } set { lock(sync) opcodeFilter=value; } }
|
||||
public long Dropped { get { lock(sync) return dropped; } }
|
||||
public bool Accepts(PacketDirection direction,int? opcode=null)
|
||||
{
|
||||
lock(sync) return !paused && (!directionFilter.HasValue || directionFilter==direction) &&
|
||||
(!opcode.HasValue || !opcodeFilter.HasValue || opcodeFilter==opcode);
|
||||
}
|
||||
public bool Add(PacketDirection direction,int opcode,string version,uint? source,uint? target,ReadOnlySpan<byte> payload,bool payloadComplete = true,string? captureIssue = null,
|
||||
PacketContext? context=null,long? monotonicTicks=null,DateTimeOffset? time=null)
|
||||
{
|
||||
lock(sync)
|
||||
{
|
||||
if (paused || (directionFilter.HasValue && directionFilter!=direction) || (opcodeFilter.HasValue && opcodeFilter!=opcode)) return false;
|
||||
if (payload.Length>maxPayload || payload.Length>maxBytes || opcode is <0 or >65535 || capacity <= 0 || !Enum.IsDefined(direction)) { dropped++; return false; }
|
||||
while (records.Count>0 && (records.Count>=capacity || payloadBytes+payload.Length>maxBytes))
|
||||
{ payloadBytes-=records.Dequeue().Payload.Length; dropped++; }
|
||||
records.Enqueue(new(++sequence,monotonicTicks ?? System.Diagnostics.Stopwatch.GetTimestamp(),time ?? DateTimeOffset.UtcNow,direction,opcode,version,source,target,payload.ToArray(),payloadComplete,captureIssue,context));
|
||||
payloadBytes+=payload.Length;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
public PacketRecord[] Snapshot() { lock(sync) return records.ToArray(); }
|
||||
public void Clear() { lock(sync) { records.Clear(); dropped=0; payloadBytes=0; } }
|
||||
public void Replace(IEnumerable<PacketRecord> imported)
|
||||
{
|
||||
var input=imported.ToArray();
|
||||
if(capacity<=0 || maxBytes<0) throw new InvalidDataException("无效缓冲区容量");
|
||||
foreach(var p in input)
|
||||
if(p.Payload == null || p.Payload.Length>maxPayload || p.Opcode is <0 or >65535 || !Enum.IsDefined(p.Direction) || string.IsNullOrWhiteSpace(p.GameVersion))
|
||||
throw new InvalidDataException("无效记录");
|
||||
lock(sync)
|
||||
{
|
||||
records.Clear(); payloadBytes=0; dropped=Math.Max(0,input.Length-capacity);
|
||||
foreach(var p in input.TakeLast(capacity))
|
||||
{
|
||||
if(p.Payload.Length>maxBytes) { dropped++; continue; }
|
||||
while(records.Count>0 && payloadBytes+p.Payload.Length>maxBytes)
|
||||
{ payloadBytes-=records.Dequeue().Payload.Length; dropped++; }
|
||||
records.Enqueue(p with { Payload=p.Payload.ToArray() }); payloadBytes+=p.Payload.Length;
|
||||
}
|
||||
sequence=input.Length==0 ? 0 : input.Max(p=>p.Sequence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PacketCorrelation(long Request,long Response,TimeSpan Elapsed);
|
||||
public static class PacketCorrelator
|
||||
{
|
||||
public static PacketCorrelation[] Ping(IEnumerable<PacketRecord> packets,OpcodeMap map)
|
||||
{
|
||||
Dictionary<(string,uint),Queue<PacketRecord>> requests=[];
|
||||
List<PacketCorrelation> results=[];
|
||||
foreach(var packet in packets.OrderBy(p=>p.Time).ThenBy(p=>p.Sequence))
|
||||
{
|
||||
if(!packet.PayloadComplete) continue;
|
||||
var name=map.Resolve(packet.GameVersion,packet.Direction,packet.Opcode);
|
||||
var current=packet.GameVersion==PacketParser.AuditedClientVersion;
|
||||
if(name=="UP_PingReq" && packet.Payload.Length==(current ? 0x20 : 0x1C))
|
||||
{
|
||||
var key=(packet.GameVersion,BinaryPrimitives.ReadUInt32LittleEndian(packet.Payload));
|
||||
if(!requests.TryGetValue(key,out var queue)) requests[key]=queue=new();
|
||||
queue.Enqueue(packet);
|
||||
}
|
||||
else if(name=="DOWN_PingRsp" && (current ? packet.Payload.Length==0x20 : packet.Payload.Length>=0x10))
|
||||
{
|
||||
var key=(packet.GameVersion,BinaryPrimitives.ReadUInt32LittleEndian(packet.Payload));
|
||||
if(requests.TryGetValue(key,out var queue) && queue.TryDequeue(out var request))
|
||||
{
|
||||
var elapsed=packet.Time-request.Time;
|
||||
if(elapsed>=TimeSpan.Zero && elapsed<=TimeSpan.FromMinutes(1)) results.Add(new(request.Sequence,packet.Sequence,elapsed));
|
||||
}
|
||||
}
|
||||
}
|
||||
return results.ToArray();
|
||||
}
|
||||
|
||||
public static PacketCorrelation[] Actions(IEnumerable<PacketRecord> packets,OpcodeMap map)
|
||||
{
|
||||
Dictionary<(string,ushort),Queue<PacketRecord>> requests=[];
|
||||
List<PacketCorrelation> results=[];
|
||||
foreach(var packet in packets.OrderBy(p=>p.Time).ThenBy(p=>p.Sequence))
|
||||
{
|
||||
if(!packet.PayloadComplete || packet.GameVersion!=PacketParser.AuditedClientVersion) continue;
|
||||
var name=map.Resolve(packet.GameVersion,packet.Direction,packet.Opcode);
|
||||
if((name=="UP_ActionSend" && packet.Payload.Length==0x20) || (name=="UP_ActionSendPos" && packet.Payload.Length==0x18))
|
||||
{
|
||||
var reqId=BinaryPrimitives.ReadUInt16LittleEndian(packet.Payload.AsSpan(6));
|
||||
if(reqId==0) continue;
|
||||
if(!requests.TryGetValue((packet.GameVersion,reqId),out var queue)) requests[(packet.GameVersion,reqId)]=queue=new();
|
||||
queue.Enqueue(packet);
|
||||
}
|
||||
else if(name=="DOWN_Effect" && packet.Payload.Length==0x7C && packet.Payload[0x21]<=1)
|
||||
{
|
||||
var reqId=BinaryPrimitives.ReadUInt16LittleEndian(packet.Payload.AsSpan(0x18));
|
||||
if(reqId>0 && requests.TryGetValue((packet.GameVersion,reqId),out var queue) && queue.TryDequeue(out var request))
|
||||
{
|
||||
var elapsed=packet.Time-request.Time;
|
||||
if(elapsed>=TimeSpan.Zero && elapsed<=TimeSpan.FromMinutes(1)) results.Add(new(request.Sequence,packet.Sequence,elapsed));
|
||||
}
|
||||
}
|
||||
}
|
||||
return results.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public static class PacketFiles
|
||||
{
|
||||
public static void ExportAtomic(string path,IEnumerable<PacketRecord> packets)
|
||||
{
|
||||
var temporary=path+"."+Guid.NewGuid().ToString("N")+".tmp";
|
||||
try { Export(temporary,packets); File.Move(temporary,path,true); }
|
||||
finally { if(File.Exists(temporary)) File.Delete(temporary); }
|
||||
}
|
||||
public static void Export(string path,IEnumerable<PacketRecord> packets)
|
||||
{
|
||||
using var writer = new StreamWriter(path,false,new System.Text.UTF8Encoding(false));
|
||||
foreach (var packet in packets) writer.WriteLine(JsonSerializer.Serialize(packet));
|
||||
}
|
||||
public static PacketRecord[] Import(string path)
|
||||
{
|
||||
if (new FileInfo(path).Length > 32*1024*1024) throw new InvalidDataException("记录文件超过 32 MiB");
|
||||
var records = new List<PacketRecord>();
|
||||
foreach(var line in File.ReadLines(path))
|
||||
{
|
||||
if (line.Length>100000) throw new InvalidDataException("记录行过长");
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
var packet=JsonSerializer.Deserialize<PacketRecord>(line) ?? throw new InvalidDataException("空记录");
|
||||
if (packet.Payload == null || packet.Payload.Length>65536 || packet.Opcode is <0 or >65535 || !Enum.IsDefined(packet.Direction) || string.IsNullOrWhiteSpace(packet.GameVersion))
|
||||
throw new InvalidDataException("无效记录");
|
||||
records.Add(packet);
|
||||
if (records.Count>10000) throw new InvalidDataException("记录超过 10000 条");
|
||||
}
|
||||
return records.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
public sealed record DungeonSnapshot(bool Valid, uint Territory, byte DungeonId, int Floor)
|
||||
{
|
||||
public static readonly DungeonSnapshot Empty = new(false, 0, 0, 0);
|
||||
public bool SpecialFloor => Valid && (Floor % 10 == 0 || (Floor == 99 && Territory is 1108 or 1290));
|
||||
public string Description => !Valid ? "不在有效深层迷宫实例中" : $"区域 {Territory} · 迷宫 {DungeonId} · {Floor} 层 · {(SpecialFloor ? "特殊层" : "普通层")}";
|
||||
}
|
||||
|
||||
public static class Policies
|
||||
{
|
||||
public static float FiniteClamp(float value, float min, float max, float fallback = 0) =>
|
||||
float.IsFinite(value) ? Math.Clamp(value, min, max) : Math.Clamp(fallback, min, max);
|
||||
public static float HeightDelta(bool ready, bool dungeonOnly, DungeonSnapshot dungeon, float amount) =>
|
||||
!ready || (dungeonOnly && (!dungeon.Valid || dungeon.Floor <= 0 || dungeon.SpecialFloor)) ? 0 : FiniteClamp(amount, -10, 10);
|
||||
public static bool BlockTimeline(uint timeline, IReadOnlySet<uint> list, bool blockListed) => list.Count > 0 && (blockListed ? list.Contains(timeline) : !list.Contains(timeline));
|
||||
public static bool IsMudra(int id) => id>=0 && NativeIds.MudraActions.Contains((uint)id);
|
||||
public static uint FallFlags(uint flags) => (flags & 0b11100000000) == 0 ? flags : (flags & ~0b11100000000u) | 2u;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
public readonly struct ActionRangeCheckScope : IDisposable
|
||||
{
|
||||
[ThreadStatic] private static bool active;
|
||||
private readonly bool previous;
|
||||
private ActionRangeCheckScope(bool allowed) { previous=active; active=allowed; }
|
||||
public static ActionRangeCheckScope Enter(bool allowed) => new(allowed);
|
||||
public static bool Active => active;
|
||||
public void Dispose() => active=previous;
|
||||
}
|
||||
|
||||
public static class TargetRadiusPolicy
|
||||
{
|
||||
// One target radius is used consistently by native drawing, attack checks and gap-closer placement.
|
||||
// Never add the bonus to the player's own radius (which would double the landing displacement).
|
||||
public static float Adjust(GameFrame frame,nint actor,uint entityId,float original,float extra) =>
|
||||
frame.Ready && actor!=0 && actor!=frame.LocalAddress && actor==frame.TargetAddress && entityId==frame.TargetEntityId && float.IsFinite(original) && original>=0
|
||||
? original+Policies.FiniteClamp(extra,0,5) : original;
|
||||
}
|
||||
|
||||
public sealed record TargetRadiusObservation(uint EntityId,float Original,float Effective);
|
||||
|
||||
public readonly struct NativeTargetRingScope : IDisposable
|
||||
{
|
||||
[ThreadStatic] private static bool active;
|
||||
private readonly bool previous;
|
||||
private NativeTargetRingScope(bool allowed) { previous=active; active=allowed; }
|
||||
public static NativeTargetRingScope Enter(bool allowed) => new(allowed);
|
||||
public static bool Active => active;
|
||||
public void Dispose() => active=previous;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
public sealed record GameFrame(bool Ready, nint LocalAddress, bool IsPvP, bool IsSlowed, DungeonSnapshot Dungeon,nint ActionManagerAddress = 0,nint TargetAddress = 0,uint TargetEntityId = 0,
|
||||
nint LocalStatusManagerAddress = 0, uint LocalEntityId = 0, bool IsReplay = false)
|
||||
{
|
||||
public static readonly GameFrame Empty = new(false,0,false,false,DungeonSnapshot.Empty);
|
||||
}
|
||||
|
||||
// Producers sample thread-affine services only on Framework.Update. Native workers read values only.
|
||||
public sealed class FrameStore(Func<long>? clock = null, long? maximumAge = null)
|
||||
{
|
||||
private sealed record Published(GameFrame Frame,long Time);
|
||||
private readonly Func<long> now = clock ?? Stopwatch.GetTimestamp;
|
||||
private readonly long ttl = maximumAge ?? Stopwatch.Frequency / 2;
|
||||
private Published? current;
|
||||
public void Publish(GameFrame frame) => Volatile.Write(ref current,new(frame,now()));
|
||||
public void Clear() => Volatile.Write(ref current,null);
|
||||
public GameFrame Read()
|
||||
{
|
||||
var value=Volatile.Read(ref current);
|
||||
if(value==null) return GameFrame.Empty;
|
||||
var age=now()-value.Time;
|
||||
return age<0 || age>ttl ? GameFrame.Empty : value.Frame;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SettingsSnapshot
|
||||
{
|
||||
private readonly float[] values=new float[44];
|
||||
public FrozenSet<uint> Timelines { get; }
|
||||
public bool BlockListed { get; }
|
||||
public DashFilterMode DashMode { get; }
|
||||
public float KnockSeconds { get; }
|
||||
public FrozenSet<uint> BlockedStatuses { get; }
|
||||
public SettingsSnapshot(IReadOnlyDictionary<int,float> source,IEnumerable<uint> timelines,bool blockListed,float knockSeconds,DashFilterMode dashMode = DashFilterMode.BlockListed,
|
||||
IEnumerable<uint>? blockedStatuses = null)
|
||||
{
|
||||
foreach(var f in Features.All) values[f.Id]=Policies.FiniteClamp(source.GetValueOrDefault(f.Id,f.Default),f.Min,f.Max,f.Default);
|
||||
Timelines=timelines.Where(x=>x>0).ToFrozenSet();
|
||||
BlockListed=blockListed; KnockSeconds=Policies.FiniteClamp(knockSeconds,0,5);
|
||||
DashMode=dashMode;
|
||||
BlockedStatuses=(blockedStatuses ?? []).Where(StatusCatalog.AuditedBlockCandidates.Contains).ToFrozenSet();
|
||||
}
|
||||
public float Value(int id) => (uint)id<(uint)values.Length ? values[id] : 0;
|
||||
}
|
||||
|
||||
public static class AccelerationPolicy
|
||||
{
|
||||
// Audited in 2026.09.15 and 2026.10.06 executables: native routine consumes and updates +0x44.
|
||||
// The returned state belongs to the game and MUST NOT be restored to the value before the call.
|
||||
public const string AuditedVersion="2026.10.06.0000.0000";
|
||||
public const string BaselineVersion="2026.09.15.0000.0000";
|
||||
public static bool IsAuditedVersion(string? version) => version is AuditedVersion or BaselineVersion;
|
||||
public static bool ShouldPrime(GameFrame frame,nint owner,bool moving,float current) =>
|
||||
frame.Ready && !frame.IsSlowed && frame.LocalAddress!=0 && owner==frame.LocalAddress && moving && float.IsFinite(current);
|
||||
public static void Run(ref float state,bool prime,Action original)
|
||||
{
|
||||
if(prime) state=100;
|
||||
original();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
public sealed record ServiceBarStatus(bool Shown,string Text,string Tooltip);
|
||||
|
||||
public static class ServiceBarPresentation
|
||||
{
|
||||
public static ServiceBarStatus Build(GameFrame frame,ModuleState height,ModuleState mode,ModuleState display,float preset,float applied)
|
||||
{
|
||||
if(!frame.Ready) return new(false,"IC","Not in game or changing areas. Height offset paused.");
|
||||
var enabled=height==ModuleState.Enabled;
|
||||
var effective=enabled ? Policies.HeightDelta(true,mode==ModuleState.Enabled,frame.Dungeon,applied) : 0;
|
||||
var heightText=height==ModuleState.Failed ? "ERR" : !enabled ? "OFF" : applied==0 ? "SET" : effective==0 ? "HOLD" : Number(effective);
|
||||
var modeText=mode==ModuleState.Failed ? "ERR" : mode==ModuleState.Enabled ? "1" : "0";
|
||||
var floorText=display==ModuleState.Failed ? "ERR" : display!=ModuleState.Enabled ? "OFF" : !frame.Dungeon.Valid ? "--" : $"{frame.Dungeon.Floor}{(frame.Dungeon.SpecialFloor ? "*" : "")}";
|
||||
var reason=!enabled ? "Height disabled." : applied==0 ? "SET: preset ready; press Apply to activate." : effective!=0 ? "Height offset active." : "HOLD: outside a valid deep dungeon or on a special floor.";
|
||||
var dungeonText=mode!=ModuleState.Enabled && display!=ModuleState.Enabled ? "Dungeon sampling disabled." : !frame.Dungeon.Valid ? "No valid deep dungeon instance." : $"Territory {frame.Dungeon.Territory}, dungeon {frame.Dungeon.DungeonId}, floor {frame.Dungeon.Floor}{(frame.Dungeon.SpecialFloor ? " (special)" : "")}.";
|
||||
return new(true,$"IC Y:{heightText} D:{modeText} F:{floorText}",
|
||||
$"Y = height offset (9)\nPreset {Number(preset)}, applied {Number(applied)}, effective {Number(effective)}\n{reason}\nD = dungeon-only mode (10): 1 on, 0 off\nF = floor display (11): -- outside, * special floor\nOFF = disabled; ERR = failed\n{dungeonText}\nClick to open IC settings.");
|
||||
}
|
||||
private static string Number(float value) => value.ToString("0.###",CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
// Called by the framework-thread module manager. One native hook can serve multiple modules.
|
||||
public sealed class SharedModuleResource(Action<ResourceScope> start)
|
||||
{
|
||||
private ResourceScope? resource;
|
||||
private int owners;
|
||||
private bool cleanupFailed;
|
||||
public void Acquire(ResourceScope owner)
|
||||
{
|
||||
if(cleanupFailed) throw new InvalidOperationException("共享 Hook 清理失败,禁止重复挂接");
|
||||
if(resource==null)
|
||||
{
|
||||
var created=new ResourceScope();
|
||||
try { start(created); resource=created; }
|
||||
catch(Exception startup)
|
||||
{
|
||||
try { created.Dispose(); }
|
||||
catch(Exception cleanup) { cleanupFailed=true; throw new AggregateException(startup,cleanup); }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
owners++;
|
||||
try { owner.Defer(Release); } catch { Release(); throw; }
|
||||
}
|
||||
private void Release()
|
||||
{
|
||||
if(--owners!=0) return;
|
||||
var old=resource; resource=null;
|
||||
try { old?.Dispose(); } catch { cleanupFailed=true; throw; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
// The audited SetStatus ABI uses a byte result/flag and a full 64-bit GameObjectId.
|
||||
public delegate byte StatusSetter(nint manager, int index, ushort statusId, float remaining,
|
||||
ushort param, ulong source, byte refreshFlags);
|
||||
|
||||
public static class StatusBlockPolicy
|
||||
{
|
||||
public const string ClientVersion = "2026.10.06.0000.0000";
|
||||
public const string BaselineClientVersion = "2026.09.15.0000.0000";
|
||||
public static bool IsAuditedVersion(string? version) => version is ClientVersion or BaselineClientVersion;
|
||||
public const int ManagerOffset = 0x23B0;
|
||||
public const int EntityIdOffset = 0x78;
|
||||
public const uint DefaultStatusId = 1257;
|
||||
|
||||
public static bool IsLocal(GameFrame frame, nint manager, nint currentPlayer, uint currentEntityId) =>
|
||||
frame.Ready && !frame.IsReplay && frame.LocalAddress != 0 &&
|
||||
frame.LocalStatusManagerAddress == frame.LocalAddress + ManagerOffset &&
|
||||
manager == frame.LocalStatusManagerAddress && currentPlayer == frame.LocalAddress &&
|
||||
frame.LocalEntityId is not (0 or 0xE0000000) && currentEntityId == frame.LocalEntityId;
|
||||
|
||||
public static bool IsSelected(int index, ushort statusId, IReadOnlySet<uint> selected) =>
|
||||
(uint)index < 60 && statusId != 0 && selected.Contains(statusId) &&
|
||||
StatusCatalog.AuditedBlockCandidates.Contains(statusId);
|
||||
|
||||
// Never skip the setter: an empty slot must still replace its previous status and
|
||||
// report native changes to the batch caller, which then refreshes the status flags.
|
||||
public static byte Forward(StatusSetter original, bool block, nint manager, int index,
|
||||
ushort statusId, float remaining, ushort param, ulong source, byte refreshFlags) =>
|
||||
block ? original(manager, index, 0, 0, 0, 0, refreshFlags)
|
||||
: original(manager, index, statusId, remaining, param, source, refreshFlags);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
// These are Status row IDs from the supplied IC sources, not action/timeline/opcode IDs.
|
||||
// Kept named by purpose to prevent accidental reuse across different ID spaces.
|
||||
public static class StatusCatalog
|
||||
{
|
||||
public static readonly FrozenSet<uint> SlowCandidates = new uint[]
|
||||
{14,67,181,240,436,484,502,623,674,709,1073,1107,1114,1141,1147,1259,1344,1394,1595,1790,1796,1935,2099,2158,2391,2551,2662,2731,3167,3284,3472,3473,3548,3943,3948,4334,4341}.ToFrozenSet();
|
||||
public static readonly FrozenSet<uint> LegacySlipperyCandidates = new uint[] {142,149,604,905,911,1579,1580}.ToFrozenSet();
|
||||
public static readonly FrozenSet<uint> ForcedMarch = new uint[] {1257,1681,3629,3719,3737}.ToFrozenSet();
|
||||
public static readonly FrozenSet<uint> LegacyDirectionCandidates = new uint[]
|
||||
{1293,1294,1295,1296,1958,1959,1960,1961,2161,2162,2163,2164,2381,2382,2383,2384,2538,2539,2540,2541,3698,3699,3700,3701,3715,3716,3717,3718}.ToFrozenSet();
|
||||
public static readonly FrozenSet<uint> Confusion = new uint[] {1422,2936,3694,3909}.ToFrozenSet();
|
||||
public static readonly FrozenSet<uint> RejectedLegacyBlockIds = new uint[] {142,149,2538,2539,2540,2541}.ToFrozenSet();
|
||||
// Status sheet names and descriptions checked against the user's 2026.09.15 data.
|
||||
public static readonly FrozenSet<uint> AuditedSlow = SlowCandidates.Concat(new uint[] {5044,5175,5356}).ToFrozenSet();
|
||||
public static readonly FrozenSet<uint> AuditedBlockCandidates = LegacySlipperyCandidates.Concat(ForcedMarch)
|
||||
.Concat(LegacyDirectionCandidates).Concat(Confusion).Except(RejectedLegacyBlockIds).ToFrozenSet();
|
||||
public static IEnumerable<(string Group,uint Id)> Entries()
|
||||
{
|
||||
foreach(var (name,set) in new[] {("Slow",SlowCandidates),("Slippery",LegacySlipperyCandidates),("ForcedMarch",ForcedMarch),("DirectionCommands",LegacyDirectionCandidates),("Confusion",Confusion)})
|
||||
foreach(var id in set.Order()) yield return (name,id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace ICRebuild.Core;
|
||||
|
||||
public static class ValidationCatalog
|
||||
{
|
||||
public const string ClientVersion="2026.10.06.0000.0000";
|
||||
public const string BaselineClientVersion="2026.09.15.0000.0000";
|
||||
public static bool IsAuditedVersion(string? version) => version is ClientVersion or BaselineClientVersion;
|
||||
// Explicit user confirmation, 2026-10-11. This is not inferred from compilation or hook startup.
|
||||
public static readonly FrozenSet<int> UserConfirmed = new[] {1,2,3,9,10,11,13,14,15,16,17,19,27,28,29}.ToFrozenSet();
|
||||
public static void Apply(ModuleManager modules,string clientVersion)
|
||||
{
|
||||
if(!IsAuditedVersion(clientVersion)) return;
|
||||
foreach(var id in UserConfirmed)
|
||||
if(modules.Status.TryGetValue(id,out var state) && state.HasImplementation) state.Adaptation=Adaptation.Verified;
|
||||
}
|
||||
}
|
||||
|
||||
public static class StatusPresentation
|
||||
{
|
||||
public static string Summary(ModuleStatus state)
|
||||
{
|
||||
var validation=state.Adaptation switch
|
||||
{
|
||||
Adaptation.Verified=>"已实测有效",
|
||||
Adaptation.KnownBroken=>"已知问题",
|
||||
_ when !state.HasImplementation=>"暂不可用",
|
||||
_ when state.Feature.Id==14=>"待复测",
|
||||
_=>"待实测"
|
||||
};
|
||||
var running=state.State switch {ModuleState.Enabled=>"已开启",ModuleState.Failed=>"运行异常",_=>"已关闭"};
|
||||
return $"{validation} · {running}";
|
||||
}
|
||||
public static bool ShowDetail(ModuleStatus state) => !state.HasImplementation || state.State==ModuleState.Failed || state.Adaptation==Adaptation.KnownBroken;
|
||||
}
|
||||
Reference in New Issue
Block a user