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