diff --git a/StArray.ModManager.Android/Native/AndroidInput.cs b/StArray.ModManager.Android/Native/AndroidInput.cs index 3699bca..d67c91d 100644 --- a/StArray.ModManager.Android/Native/AndroidInput.cs +++ b/StArray.ModManager.Android/Native/AndroidInput.cs @@ -33,7 +33,7 @@ public enum KeyAction Multiple = 2, } - /// 触摸动作(主动作需用 ActionMask 提取) + /// 触摸动作(主动作需先去除指针索引位) public enum MotionAction { Down = 0, @@ -43,11 +43,12 @@ public enum MotionAction Outside = 4, PointerDown = 5, PointerUp = 6, - HoverEnter = 7, - HoverMove = 8, - HoverExit = 9, - ButtonPress = 10, - ButtonRelease = 11, + HoverMove = 7, + Scroll = 8, + HoverEnter = 9, + HoverExit = 10, + ButtonPress = 11, + ButtonRelease = 12, } /// Meta 键状态 @@ -71,6 +72,13 @@ public static class MotionMask public const int PointerIndexShift = 8; } + // Keep integer helpers available to the timestamp broadcast path. The raw + // action is read only once per native event, avoiding repeated P/Invoke + // calls on the Android input thread. + public const int ActionMask = MotionMask.Action; + public const int ActionPointerIndexMask = MotionMask.PointerIndex; + public const int ActionPointerIndexShift = MotionMask.PointerIndexShift; + // ======================== 不透明句柄结构体 ======================== /// 不透明输入事件句柄 @@ -156,21 +164,39 @@ int handled [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] public static extern int AMotionEvent_getPointerId(IntPtr ev, int pointerIndex); + /// + /// 事件发生时刻,单位纳秒,时钟源为 CLOCK_MONOTONIC。 + /// 该时间戳独立于渲染帧,可用于还原触摸真实发生时刻。 + /// + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern long AMotionEvent_getEventTime(IntPtr ev); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern long AMotionEvent_getDownTime(IntPtr ev); + // ======================== 辅助方法(扩展风格) ======================== + /// 从原始动作值取出主动作。 + public static MotionAction GetMainAction(int rawAction) + => (MotionAction)(rawAction & ActionMask); + + /// 从原始动作值取出指针索引。 + public static int GetPointerIndex(int rawAction) + => (rawAction & ActionPointerIndexMask) >> ActionPointerIndexShift; + /// 获取触摸事件的主动作(去除指针索引) public static MotionAction GetMainAction(this IntPtr ev) { if (AInputEvent_getType(ev) != EventType.Motion) throw new InvalidOperationException("事件不是 Motion 类型"); int raw = AMotionEvent_getAction(ev); - return (MotionAction)(raw & MotionMask.Action); + return GetMainAction(raw); } /// 获取触摸事件的指针索引(用于多点触控) public static int GetPointerIndex(this IntPtr ev) { int raw = AMotionEvent_getAction(ev); - return (raw & MotionMask.PointerIndex) >> MotionMask.PointerIndexShift; + return GetPointerIndex(raw); } -} \ No newline at end of file +} diff --git a/StArray.ModManager.Android/Native/InputEvents.cs b/StArray.ModManager.Android/Native/InputEvents.cs new file mode 100644 index 0000000..4dca2df --- /dev/null +++ b/StArray.ModManager.Android/Native/InputEvents.cs @@ -0,0 +1,310 @@ +using System.Diagnostics; +using StArray.ModManager.Manager; + +namespace StArray.ModManager.Android.Native; + +/// 一次完整触摸事件的快照。所有字段在广播时即已从原生事件读出。 +/// 主动作,已去除指针索引 +/// 指针索引 +/// 稳定的指针 ID,比索引更适合跨事件跟踪多指输入 +/// 事件发生时刻,纳秒,时钟源为 CLOCK_MONOTONIC +/// 触点 X 坐标(像素) +/// 触点 Y 坐标(像素) +public readonly record struct TouchEventInfo( + AndroidInput.MotionAction Action, + int PointerIndex, + int PointerId, + long EventTimeNanos, + float X, + float Y) +{ + // Source compatibility for mods built against the first broadcast API. + public TouchEventInfo( + AndroidInput.MotionAction action, + int pointerIndex, + long eventTimeNanos, + float x, + float y) + : this(action, pointerIndex, -1, eventTimeNanos, x, y) + { + } +} + +/// +/// 只包含异步输入所需字段的原始触摸快照。 +/// +/// +/// 该事件不读取坐标,且只广播 Down/Up/Cancel。高 KPS 时订阅方不需要为 Move +/// 事件读取坐标或创建完整手势快照。 +/// +public readonly record struct TouchTimestampInfo( + AndroidInput.MotionAction Action, + int PointerId, + long EventTimeNanos); + +/// +/// 输入事件广播点。原生输入 Hook 只负责解析一次事件,订阅方在自己的队列中异步处理。 +/// +/// +/// 回调运行在 Android 输入分发线程,不是 Unity 主线程。订阅方只能做廉价的值类型快照 +/// 和入队操作,不能访问 Unity 对象或执行 IL2CPP 游戏逻辑。 +/// +public static class InputEvents +{ + private const string LogTag = nameof(InputEvents); + private const long DuplicateWindowMilliseconds = 8L; + + private static Action? s_onTouch; + private static Action? s_onTouchTimestamp; + private static int s_touchSubscriberCount; + private static int s_touchTimestampSubscriberCount; + private static int s_faultLogged; + + private static readonly object DedupLock = new(); + private static int s_lastRawAction; + private static int s_lastPointerIndex; + private static int s_lastPointerCount; + private static int s_lastPointerId; + private static long s_lastEventTimeNanos; + private static long s_lastDispatchTicks; + + /// 是否已有任一类订阅者。 + public static bool HasSubscribers => + Volatile.Read(ref s_touchSubscriberCount) > 0 + || Volatile.Read(ref s_touchTimestampSubscriberCount) > 0; + + /// 完整触摸事件广播,保留坐标和 Move 事件。 + public static event Action? OnTouch + { + add + { + if (value == null) return; + lock (DedupLock) + { + s_onTouch += value; + Interlocked.Increment(ref s_touchSubscriberCount); + } + } + remove + { + if (value == null) return; + lock (DedupLock) + { + s_onTouch -= value; + Interlocked.Decrement(ref s_touchSubscriberCount); + } + } + } + + /// + /// 异步输入时间戳快速广播,只处理 Down、PointerDown、Up、PointerUp 和 Cancel。 + /// + public static event Action? OnTouchTimestamp + { + add + { + if (value == null) return; + lock (DedupLock) + { + s_onTouchTimestamp += value; + Interlocked.Increment(ref s_touchTimestampSubscriberCount); + } + } + remove + { + if (value == null) return; + lock (DedupLock) + { + s_onTouchTimestamp -= value; + Interlocked.Decrement(ref s_touchTimestampSubscriberCount); + } + } + } + + /// + /// 从原生 AInputEvent 解析并广播。输入事件只在这里读取一次,避免每个 Mod 重复访问 + /// 原生对象;重复的同一事件只在广播层过滤一次。 + /// + internal static void RaiseFrom(nint inputEvent) + { + Action? handlers; + Action? timestampHandlers; + lock (DedupLock) + { + handlers = s_onTouch; + timestampHandlers = s_onTouchTimestamp; + } + + if ((handlers == null && timestampHandlers == null) || inputEvent == 0) + return; + + try + { + if (AndroidInput.AInputEvent_getType(inputEvent) != AndroidInput.EventType.Motion) + return; + + int rawAction = AndroidInput.AMotionEvent_getAction(inputEvent); + AndroidInput.MotionAction action = AndroidInput.GetMainAction(rawAction); + int pointerIndex = AndroidInput.GetPointerIndex(rawAction); + bool timestampAction = action is AndroidInput.MotionAction.Down + or AndroidInput.MotionAction.PointerDown + or AndroidInput.MotionAction.Up + or AndroidInput.MotionAction.PointerUp + or AndroidInput.MotionAction.Cancel; + + // AsyncInput subscribes only to the timestamp channel. Do not + // inspect Move coordinates, pointer IDs, or pointer counts on the + // hot path when no full-gesture subscriber exists. + if (!timestampAction && handlers == null) + return; + + int pointerCount = AndroidInput.AMotionEvent_getPointerCount(inputEvent); + long eventTimeNanos = AndroidInput.AMotionEvent_getEventTime(inputEvent); + int pointerId = action == AndroidInput.MotionAction.Cancel + ? -1 + : AndroidInput.AMotionEvent_getPointerId(inputEvent, pointerIndex); + + if (IsDuplicate( + rawAction, + pointerIndex, + pointerCount, + pointerId, + eventTimeNanos)) + return; + + if (timestampHandlers != null && timestampAction) + { + DispatchTimestampHandlers( + timestampHandlers, + new TouchTimestampInfo(action, pointerId, eventTimeNanos)); + } + + if (handlers == null) + return; + + TouchEventInfo info = new( + action, + pointerIndex, + pointerId, + eventTimeNanos, + AndroidInput.AMotionEvent_getX(inputEvent, pointerIndex), + AndroidInput.AMotionEvent_getY(inputEvent, pointerIndex)); + DispatchTouchHandlers(handlers, info); + } + catch (Exception exception) + { + LogOnce($"Failed to read native input event: {exception}"); + } + } + + private static bool IsDuplicate( + int rawAction, + int pointerIndex, + int pointerCount, + int pointerId, + long eventTimeNanos) + { + long now = Stopwatch.GetTimestamp(); + long windowTicks = Math.Max( + 1L, + Stopwatch.Frequency * DuplicateWindowMilliseconds / 1000L); + + lock (DedupLock) + { + long elapsed = now - s_lastDispatchTicks; + bool sameEventPayload = s_lastRawAction == rawAction + && s_lastPointerIndex == pointerIndex + && s_lastPointerCount == pointerCount + && s_lastPointerId == pointerId + && s_lastEventTimeNanos == eventTimeNanos; + bool duplicate = sameEventPayload + && elapsed >= 0L + && elapsed <= windowTicks; + + if (duplicate) + return true; + + s_lastRawAction = rawAction; + s_lastPointerIndex = pointerIndex; + s_lastPointerCount = pointerCount; + s_lastPointerId = pointerId; + s_lastEventTimeNanos = eventTimeNanos; + s_lastDispatchTicks = now; + return false; + } + } + + private static void DispatchTimestampHandlers( + Action handlers, + TouchTimestampInfo info) + { + if (Volatile.Read(ref s_touchTimestampSubscriberCount) == 1) + { + try + { + handlers(info); + } + catch (Exception exception) + { + LogOnce($"Touch timestamp subscriber threw: {exception}"); + } + return; + } + + foreach (Delegate handler in handlers.GetInvocationList()) + { + try + { + ((Action)handler)(info); + } + catch (Exception exception) + { + LogOnce($"Touch timestamp subscriber threw: {exception}"); + } + } + } + + private static void DispatchTouchHandlers( + Action handlers, + TouchEventInfo info) + { + if (Volatile.Read(ref s_touchSubscriberCount) == 1) + { + try + { + handlers(info); + } + catch (Exception exception) + { + LogOnce($"Touch event subscriber threw: {exception}"); + } + return; + } + + foreach (Delegate handler in handlers.GetInvocationList()) + { + try + { + ((Action)handler)(info); + } + catch (Exception exception) + { + LogOnce($"Touch event subscriber threw: {exception}"); + } + } + } + + private static void LogOnce(string message) + { + if (Interlocked.Exchange(ref s_faultLogged, 1) != 0) + return; + try + { + Logger.Error(LogTag, $"{message} (further occurrences suppressed)"); + } + catch + { + // Never allow an exception to escape back through the native input stack. + } + } +} diff --git a/StArray.ModManager.Android/UI/ImGuiInputHandler.cs b/StArray.ModManager.Android/UI/ImGuiInputHandler.cs index 5cd20ae..2eb0cd4 100644 --- a/StArray.ModManager.Android/UI/ImGuiInputHandler.cs +++ b/StArray.ModManager.Android/UI/ImGuiInputHandler.cs @@ -93,6 +93,8 @@ public unsafe static bool OnInitializeMotionEvent(void* @event, void* message) var result = OnInitializeMotionEventOriginal(@event, message); var x = AndroidInput.AMotionEvent_getX(new(@event), 0); var y = AndroidInput.AMotionEvent_getY(new(@event), 0); + if (InputEvents.HasSubscribers) + InputEvents.RaiseFrom(new IntPtr(@event)); ImGuiImplAndroid.HandleInputEvent(new IntPtr(@event)); return result; } diff --git a/StArray.ModManager/RuntimeAbstractions/RuntimeManager.cs b/StArray.ModManager/RuntimeAbstractions/RuntimeManager.cs index 9e72119..eb65f63 100644 --- a/StArray.ModManager/RuntimeAbstractions/RuntimeManager.cs +++ b/StArray.ModManager/RuntimeAbstractions/RuntimeManager.cs @@ -72,6 +72,33 @@ public static RuntimeBackend Detect() return null; } + /// + /// 从运行时对象取得其真实类型。 + /// + /// + /// 泛型容器只有在对象实例创建后才能拿到具体方法表。调用方可缓存返回类型的 + /// Add/Clear 等方法,避免每次事件通过字符串重新解析。 + /// + public static IRuntimeClass? GetObjectClass(nint objectPtr) + { + if (objectPtr == 0) + return null; + + if (IsIl2Cpp) + { + nint klass = Il2CppFunctions.il2cpp_object_get_class(objectPtr); + return klass == 0 ? null : new Il2CppClass(klass); + } + + if (IsMono) + { + nint klass = MonoFunctions.MonoObjectGetClass(objectPtr); + return klass == 0 ? null : new MonoClass(klass); + } + + return null; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr GetModuleHandle(string lpModuleName);