diff --git a/INSTANCE_HOOKING_EXAMPLE.md b/INSTANCE_HOOKING_EXAMPLE.md new file mode 100644 index 00000000..a58b9ad7 --- /dev/null +++ b/INSTANCE_HOOKING_EXAMPLE.md @@ -0,0 +1,164 @@ +# Instance-Specific Hooking Example + +This document demonstrates how to use the new instance-specific hooking feature in RemoteNET. + +## Overview + +Previously, when hooking a method, ALL invocations of that method across ALL instances would trigger the hook. Now you can hook a method on a SPECIFIC INSTANCE only. + +## Basic Usage + +### Hooking All Instances (Previous Behavior) + +```csharp +using RemoteNET; +using RemoteNET.Common; +using ScubaDiver.API.Hooking; + +// Connect to remote app +var app = RemoteAppFactory.Connect(...); + +// Get the type and method to hook +var targetType = app.GetRemoteType("MyNamespace.MyClass"); +var methodToHook = targetType.GetMethod("MyMethod"); + +// Hook ALL instances +app.HookingManager.HookMethod( + methodToHook, + HarmonyPatchPosition.Prefix, + (HookContext context, dynamic instance, dynamic[] args, ref dynamic retValue) => + { + Console.WriteLine($"Method called on instance: {instance}"); + } +); +``` + +### Hooking a Specific Instance (NEW) + +```csharp +using RemoteNET; +using RemoteNET.Common; +using ScubaDiver.API.Hooking; + +// Connect to remote app +var app = RemoteAppFactory.Connect(...); + +// Get a specific instance to hook +var instances = app.QueryInstances("MyNamespace.MyClass"); +var targetInstance = instances.First(); +var remoteObject = app.GetRemoteObject(targetInstance); + +// Get the method to hook +var targetType = remoteObject.GetRemoteType(); +var methodToHook = targetType.GetMethod("MyMethod"); + +// Option 1: Hook using HookingManager with instance parameter +app.HookingManager.HookMethod( + methodToHook, + HarmonyPatchPosition.Prefix, + (HookContext context, dynamic instance, dynamic[] args, ref dynamic retValue) => + { + Console.WriteLine($"Method called on the SPECIFIC instance!"); + }, + remoteObject // <-- Pass the specific instance here +); + +// Option 2: Hook using the convenience method on RemoteObject (RECOMMENDED) +remoteObject.Hook( + methodToHook, + HarmonyPatchPosition.Prefix, + (HookContext context, dynamic instance, dynamic[] args, ref dynamic retValue) => + { + Console.WriteLine($"Method called on the SPECIFIC instance!"); + } +); +``` + +### Using Patch Method for Multiple Hooks + +```csharp +// Get a specific instance +var remoteObject = app.GetRemoteObject(targetInstance); +var targetType = remoteObject.GetRemoteType(); +var methodToHook = targetType.GetMethod("MyMethod"); + +// Patch with prefix, postfix, and finalizer on SPECIFIC instance +remoteObject.Patch( + methodToHook, + prefix: (HookContext ctx, dynamic inst, dynamic[] args, ref dynamic ret) => + { + Console.WriteLine("PREFIX: Before method execution"); + }, + postfix: (HookContext ctx, dynamic inst, dynamic[] args, ref dynamic ret) => + { + Console.WriteLine($"POSTFIX: After method execution, return value: {ret}"); + }, + finalizer: (HookContext ctx, dynamic inst, dynamic[] args, ref dynamic ret) => + { + Console.WriteLine("FINALIZER: Always runs, even if exception occurred"); + } +); +``` + +## Multiple Hooks on Same Method + +You can hook the same method on different instances: + +```csharp +var instances = app.QueryInstances("MyNamespace.MyClass").Take(3); + +int hookCounter = 0; +foreach (var candidate in instances) +{ + var remoteObj = app.GetRemoteObject(candidate); + int instanceId = hookCounter++; + + remoteObj.Hook( + methodToHook, + HarmonyPatchPosition.Prefix, + (HookContext ctx, dynamic inst, dynamic[] args, ref dynamic ret) => + { + Console.WriteLine($"Hook triggered on instance #{instanceId}"); + } + ); +} + +// Now each instance will trigger only its own hook +``` + +## Important Notes + +1. **Instance Address Resolution**: The system uses the pinned object address to identify instances. For unpinned objects, it falls back to the object's identity hash code. + +2. **Static Methods**: Instance-specific hooking doesn't apply to static methods (since they have no instance). For static methods, use the standard hooking approach without specifying an instance. + +3. **Hook Cleanup**: When an instance-specific hook is removed, the underlying Harmony hook is only removed if it was the last hook for that method. + +4. **Performance**: Instance-specific hooks add a small overhead to check the instance address on each invocation, but this is minimal compared to the callback overhead. + +## Architecture + +The implementation uses a `HookingCenter` class that: +- Tracks multiple hooks per method (one for each instance) +- Filters invocations based on instance address +- Manages hook cleanup when hooks are removed + +When you hook a method on a specific instance: +1. The request includes the instance's address +2. ScubaDiver installs a single Harmony hook for that method (if not already hooked) +3. The hook callback checks if the current instance matches the registered instance address +4. Only matching invocations trigger the user callback + +## Migration Guide + +Existing code that hooks methods will continue to work unchanged. To add instance-specific hooking: + +```csharp +// Before (hooks all instances) +app.HookingManager.HookMethod(method, pos, callback); + +// After (hooks specific instance) +app.HookingManager.HookMethod(method, pos, callback, instanceObject); +// OR +instanceObject.Hook(method, pos, callback); +``` diff --git a/INSTANCE_HOOKING_IMPLEMENTATION.md b/INSTANCE_HOOKING_IMPLEMENTATION.md new file mode 100644 index 00000000..dff78f5d --- /dev/null +++ b/INSTANCE_HOOKING_IMPLEMENTATION.md @@ -0,0 +1,152 @@ +# Instance-Specific Hooking Implementation Details + +This document provides technical details about the implementation of instance-specific hooking in RemoteNET. + +## Problem Statement + +Previously, when hooking a method in RemoteNET, ALL invocations of that method would trigger the hook, regardless of which instance was calling it. This was fine for static methods, but for instance methods, users often wanted to hook only a SPECIFIC instance. + +## Solution Architecture + +### Backend Changes (ScubaDiver) + +#### 1. FunctionHookRequest Extension +- Added `InstanceAddress` field (ulong) to specify which instance to hook +- When `InstanceAddress` is 0, it means "hook all instances" (backward compatible) +- When `InstanceAddress` is non-zero, only hooks on that specific instance + +#### 2. HookingCenter Class +A centralized manager that handles instance-specific hook registrations: + +**Key Features:** +- Uses `ConcurrentDictionary>` for O(1) operations +- Each method+position combination gets a unique ID +- Multiple hooks can be registered per method (one per instance) +- Thread-safe registration and unregistration + +**How it Works:** +``` +Method A + Prefix → uniqueHookId + → Token 1 → (InstanceAddress: 0x1234, Callback: cb1) + → Token 2 → (InstanceAddress: 0x5678, Callback: cb2) + → Token 3 → (InstanceAddress: 0, Callback: cb3) // All instances +``` + +When a hooked method is called: +1. The unified callback from HookingCenter is invoked +2. It resolves the current instance's address +3. It checks all registered hooks for this method +4. It invokes callbacks where: + - `InstanceAddress == 0` (global hooks), OR + - `InstanceAddress == current instance address` (instance-specific hooks) + +#### 3. DiverBase Modifications +- Added `_hookingCenter` and `_harmonyHookLocks` fields +- Modified `HookFunctionWrapper` to: + - Use per-method locks to prevent race conditions + - Register callbacks with HookingCenter + - Install Harmony hook only on first registration + - Use HookingCenter's unified callback +- Modified `MakeUnhookMethodResponse` to: + - Unregister from HookingCenter + - Only remove Harmony hook when last callback is unregistered + +#### 4. Instance Address Resolution +Both DotNetDiver and MsvcDiver implement `ResolveInstanceAddress`: + +**DotNetDiver:** +- First tries to get pinned address from FrozenObjectsCollection +- Falls back to RuntimeHelpers.GetHashCode for unpinned objects + +**MsvcDiver:** +- For NativeObject instances, uses the Address property +- Falls back to FrozenObjectsCollection or GetHashCode + +### Frontend Changes (RemoteNET) + +#### 1. DiverCommunicator +- Added optional `instanceAddress` parameter to `HookMethod` +- Defaults to 0 for backward compatibility + +#### 2. RemoteHookingManager +- Updated `HookMethod` to accept optional `RemoteObject instance` parameter +- Added overload accepting `dynamic instance` to work with DynamicRemoteObject +- Tracks instance address in `PositionedLocalHook` +- Prevents duplicate hooks per instance+position combination +- Caches PropertyInfo for efficient dynamic→RemoteObject conversion + +#### 3. RemoteObject Extensions +Both ManagedRemoteObject and UnmanagedRemoteObject now have: +- `Hook(method, position, callback)` - Convenience method for hooking this instance +- `Patch(method, prefix, postfix, finalizer)` - Convenience method for patching this instance + +## Thread Safety + +The implementation is thread-safe through several mechanisms: + +1. **ConcurrentDictionary** usage in HookingCenter for all storage +2. **Per-method locks** in DiverBase for Harmony hook installation +3. **Atomic operations** for hook counting and removal +4. **Lock-free reads** for callback dispatching + +## Performance Considerations + +1. **Instance Resolution**: Pinned objects have O(1) lookup; unpinned objects use identity hash +2. **Hook Registration**: O(1) with ConcurrentDictionary +3. **Hook Unregistration**: O(1) removal +4. **Callback Dispatch**: O(n) where n = number of hooks on the method (typically small) +5. **Memory**: One HookRegistration per registered hook + +## Backward Compatibility + +All existing code continues to work: +- Hooks without instance parameter hook all instances (previous behavior) +- No API breaking changes +- New functionality is purely additive + +## Example Call Flow + +``` +User Code: + instance.Hook(method, Prefix, callback) + ↓ +RemoteHookingManager.HookMethod(method, Prefix, callback, instance) + ↓ +DiverCommunicator.HookMethod(method, Prefix, wrappedCallback, instanceAddress) + ↓ +ScubaDiver: DiverBase.HookFunctionWrapper() + → Registers in HookingCenter + → Installs Harmony hook (if first for method) + → Returns token + +When Method is Called: + Harmony intercepts call + ↓ + HookingCenter.UnifiedCallback(instance, args) + ↓ + ResolveInstanceAddress(instance) + ↓ + Check all registrations: + if (reg.InstanceAddress == 0 || reg.InstanceAddress == current) + → Invoke reg.Callback() +``` + +## Testing + +See `InstanceHookingTests.cs` for test examples and `INSTANCE_HOOKING_EXAMPLE.md` for usage examples. + +## Future Enhancements + +Possible future improvements: +1. Support for hooking by instance hashcode (for unpinned objects) +2. Bulk hook registration/unregistration APIs +3. Hook metrics (call counts per instance) +4. Hook filtering by argument values +5. Conditional hooks (only invoke if predicate matches) + +## Known Limitations + +1. **Unpinned Objects**: For unpinned .NET objects, instance resolution uses identity hashcode, which may change across GC if objects move +2. **MSVC Objects**: Instance resolution depends on NativeObject wrapper or pinning +3. **Static Methods**: Instance-specific hooking doesn't apply (no instance to filter by) +4. **Performance Overhead**: Small overhead on each hooked method call to check instance address diff --git a/src/RemoteNET.Tests/InstanceHookingTests.cs b/src/RemoteNET.Tests/InstanceHookingTests.cs new file mode 100644 index 00000000..c045fa9f --- /dev/null +++ b/src/RemoteNET.Tests/InstanceHookingTests.cs @@ -0,0 +1,200 @@ +using Xunit; +using RemoteNET; +using RemoteNET.Common; +using ScubaDiver.API.Hooking; +using System.Reflection; + +namespace RemoteNET.Tests; + +/// +/// Tests for instance-specific hooking functionality. +/// These tests demonstrate the new API for hooking methods on specific instances. +/// +public class InstanceHookingTests +{ + // NOTE: These are integration tests that require a running target process + // They serve as examples of the API usage and will be skipped if no target is available + + [Fact(Skip = "Integration test - requires target process")] + public void HookSpecificInstance_OnlyTriggersForThatInstance() + { + // Arrange + // var app = RemoteAppFactory.Connect(...); + // var instances = app.QueryInstances("MyClass").ToList(); + // var instance1 = app.GetRemoteObject(instances[0]); + // var instance2 = app.GetRemoteObject(instances[1]); + // var method = instance1.GetRemoteType().GetMethod("SomeMethod"); + + // int hook1Called = 0; + // int hook2Called = 0; + + // Act - Hook only instance1 + // instance1.Hook(method, HarmonyPatchPosition.Prefix, + // (HookContext ctx, dynamic inst, dynamic[] args, ref dynamic ret) => + // { + // hook1Called++; + // }); + + // Invoke method on both instances + // instance1.Dynamify().SomeMethod(); + // instance2.Dynamify().SomeMethod(); + + // Assert + // Assert.Equal(1, hook1Called); // Only instance1 hook should trigger + // Assert.Equal(0, hook2Called); // instance2 was not hooked + } + + [Fact(Skip = "Integration test - requires target process")] + public void HookMultipleInstances_EachTriggersItsOwnHook() + { + // Arrange + // var app = RemoteAppFactory.Connect(...); + // var instances = app.QueryInstances("MyClass").Take(3).ToList(); + // var remoteObjects = instances.Select(i => app.GetRemoteObject(i)).ToList(); + // var method = remoteObjects[0].GetRemoteType().GetMethod("SomeMethod"); + + // var callCounts = new Dictionary(); + + // Act - Hook each instance + // for (int i = 0; i < remoteObjects.Count; i++) + // { + // int instanceIndex = i; // Capture for closure + // callCounts[instanceIndex] = 0; + // + // remoteObjects[i].Hook(method, HarmonyPatchPosition.Prefix, + // (HookContext ctx, dynamic inst, dynamic[] args, ref dynamic ret) => + // { + // callCounts[instanceIndex]++; + // }); + // } + + // Invoke method on each instance + // foreach (var obj in remoteObjects) + // { + // obj.Dynamify().SomeMethod(); + // } + + // Assert - Each hook should have been called exactly once + // foreach (var kvp in callCounts) + // { + // Assert.Equal(1, kvp.Value); + // } + } + + [Fact(Skip = "Integration test - requires target process")] + public void HookWithoutInstance_TriggersForAllInstances() + { + // Arrange + // var app = RemoteAppFactory.Connect(...); + // var type = app.GetRemoteType("MyClass"); + // var method = type.GetMethod("SomeMethod"); + // var instances = app.QueryInstances("MyClass").Take(3).ToList(); + // var remoteObjects = instances.Select(i => app.GetRemoteObject(i)).ToList(); + + // int totalCalls = 0; + + // Act - Hook without specifying instance (global hook) + // app.HookingManager.HookMethod(method, HarmonyPatchPosition.Prefix, + // (HookContext ctx, dynamic inst, dynamic[] args, ref dynamic ret) => + // { + // totalCalls++; + // }); + + // Invoke method on all instances + // foreach (var obj in remoteObjects) + // { + // obj.Dynamify().SomeMethod(); + // } + + // Assert - Hook should trigger for all instances + // Assert.Equal(remoteObjects.Count, totalCalls); + } + + [Fact(Skip = "Integration test - requires target process")] + public void PatchMethod_WithInstanceSpecificHooks() + { + // Arrange + // var app = RemoteAppFactory.Connect(...); + // var instance = app.GetRemoteObject(app.QueryInstances("MyClass").First()); + // var method = instance.GetRemoteType().GetMethod("SomeMethod"); + + // bool prefixCalled = false; + // bool postfixCalled = false; + // bool finalizerCalled = false; + + // Act - Patch with multiple hooks on specific instance + // instance.Patch( + // method, + // prefix: (HookContext ctx, dynamic inst, dynamic[] args, ref dynamic ret) => + // { + // prefixCalled = true; + // }, + // postfix: (HookContext ctx, dynamic inst, dynamic[] args, ref dynamic ret) => + // { + // postfixCalled = true; + // }, + // finalizer: (HookContext ctx, dynamic inst, dynamic[] args, ref dynamic ret) => + // { + // finalizerCalled = true; + // }); + + // Invoke method + // instance.Dynamify().SomeMethod(); + + // Assert + // Assert.True(prefixCalled); + // Assert.True(postfixCalled); + // Assert.True(finalizerCalled); + } + + /// + /// Demonstrates the API for instance-specific hooking. + /// This is a documentation/example test. + /// + [Fact(Skip = "Example/Documentation test")] + public void ExampleUsage_InstanceSpecificHooking() + { + // This test demonstrates the complete API for instance-specific hooking + + // 1. Connect to remote app + // var app = RemoteAppFactory.Connect(endpoint); + + // 2. Get instances + // var instances = app.QueryInstances("TargetClass.FullName"); + // var instance1 = app.GetRemoteObject(instances.First()); + // var instance2 = app.GetRemoteObject(instances.Skip(1).First()); + + // 3. Get method to hook + // var targetType = instance1.GetRemoteType(); + // var methodToHook = targetType.GetMethod("MethodName"); + + // 4. Hook specific instance - Option A: Using RemoteObject.Hook() + // instance1.Hook( + // methodToHook, + // HarmonyPatchPosition.Prefix, + // (HookContext context, dynamic instance, dynamic[] args, ref dynamic retValue) => + // { + // Console.WriteLine($"Instance 1 method called with {args.Length} arguments"); + // // context.skipOriginal = true; // Optional: skip original method + // }); + + // 5. Hook specific instance - Option B: Using HookingManager + // app.HookingManager.HookMethod( + // methodToHook, + // HarmonyPatchPosition.Prefix, + // (HookContext context, dynamic instance, dynamic[] args, ref dynamic retValue) => + // { + // Console.WriteLine($"Instance 2 method called"); + // }, + // instance2); // Pass the instance as the last parameter + + // 6. Hook all instances (previous behavior, still supported) + // app.HookingManager.HookMethod( + // methodToHook, + // HarmonyPatchPosition.Postfix, + // (HookContext context, dynamic instance, dynamic[] args, ref dynamic retValue) => + // { + // Console.WriteLine($"Any instance method called"); + // }); // No instance parameter = hooks all instances + } +} diff --git a/src/RemoteNET/ManagedRemoteObject.cs b/src/RemoteNET/ManagedRemoteObject.cs index 757029c6..54ed98aa 100644 --- a/src/RemoteNET/ManagedRemoteObject.cs +++ b/src/RemoteNET/ManagedRemoteObject.cs @@ -132,5 +132,30 @@ public override RemoteObject Cast(Type t) { throw new NotImplementedException("Not implemented in Managed context"); } + + /// + /// Hooks a method on this specific instance. + /// This is a convenience method that calls app.HookingManager.HookMethod with this instance. + /// + /// The method to hook + /// Position of the hook (Prefix, Postfix, or Finalizer) + /// The callback to invoke when the method is called + /// True on success + public bool Hook(System.Reflection.MethodBase methodToHook, ScubaDiver.API.Hooking.HarmonyPatchPosition pos, RemoteNET.Common.DynamifiedHookCallback hookAction) + { + return _app.HookingManager.HookMethod(methodToHook, pos, hookAction, this); + } + + /// + /// Patches a method on this specific instance with prefix, postfix, and/or finalizer hooks. + /// This is a convenience method that calls app.HookingManager.Patch with this instance. + /// + public void Patch(System.Reflection.MethodBase original, + RemoteNET.Common.DynamifiedHookCallback prefix = null, + RemoteNET.Common.DynamifiedHookCallback postfix = null, + RemoteNET.Common.DynamifiedHookCallback finalizer = null) + { + _app.HookingManager.Patch(original, prefix, postfix, finalizer, this); + } } } diff --git a/src/RemoteNET/RemoteHookingManager.cs b/src/RemoteNET/RemoteHookingManager.cs index b425680e..7aabdff2 100644 --- a/src/RemoteNET/RemoteHookingManager.cs +++ b/src/RemoteNET/RemoteHookingManager.cs @@ -27,16 +27,22 @@ private class PositionedLocalHook public DynamifiedHookCallback HookAction { get; set; } public LocalHookCallback WrappedHookAction { get; private set; } public HarmonyPatchPosition Position { get; private set; } - public PositionedLocalHook(DynamifiedHookCallback action, LocalHookCallback callback, HarmonyPatchPosition pos) + public ulong InstanceAddress { get; private set; } + public PositionedLocalHook(DynamifiedHookCallback action, LocalHookCallback callback, HarmonyPatchPosition pos, ulong instanceAddress) { HookAction = action; WrappedHookAction = callback; Position = pos; + InstanceAddress = instanceAddress; } } private class MethodHooks : Dictionary { } + + // Cache for RemoteObject property reflection (thread-safe via lock) + private static System.Reflection.PropertyInfo _remoteObjectProperty = null; + private static readonly object _remoteObjectPropertyLock = new object(); public RemoteHookingManager(RemoteApp app) @@ -48,8 +54,15 @@ public RemoteHookingManager(RemoteApp app) /// True on success, false otherwise - public bool HookMethod(MethodBase methodToHook, HarmonyPatchPosition pos, DynamifiedHookCallback hookAction) + public bool HookMethod(MethodBase methodToHook, HarmonyPatchPosition pos, DynamifiedHookCallback hookAction, RemoteObject instance = null) { + // Extract instance address if provided + ulong instanceAddress = 0; + if (instance != null) + { + instanceAddress = instance.RemoteToken; + } + // Wrapping the callback which uses `dynamic`s in a callback that handles `ObjectOrRemoteAddresses` // and converts them to DROs LocalHookCallback wrappedHook = WrapCallback(hookAction); @@ -63,14 +76,15 @@ public bool HookMethod(MethodBase methodToHook, HarmonyPatchPosition pos, Dynami if (methodHooks.ContainsKey(hookAction)) { - throw new NotImplementedException("Shouldn't use same hook for 2 patches of the same method"); + throw new NotImplementedException("Shouldn't use same hook callback for 2 patches of the same method"); } - if (methodHooks.Any(existingHook => existingHook.Value.Position == pos)) + // Check for duplicate hooks on same instance and position + if (methodHooks.Any(existingHook => existingHook.Value.Position == pos && existingHook.Value.InstanceAddress == instanceAddress)) { - throw new NotImplementedException("Can not set 2 hooks in the same position on a single target"); + throw new NotImplementedException($"Can not set 2 hooks in the same position on the same {(instanceAddress == 0 ? "target (all instances)" : "instance")}"); } - methodHooks.Add(hookAction, new PositionedLocalHook(hookAction, wrappedHook, pos)); + methodHooks.Add(hookAction, new PositionedLocalHook(hookAction, wrappedHook, pos, instanceAddress)); List parametersTypeFullNames; if (methodToHook is IRttiMethodBase rttiMethod) @@ -86,7 +100,56 @@ public bool HookMethod(MethodBase methodToHook, HarmonyPatchPosition pos, Dynami methodToHook.GetParameters().Select(prm => prm.ParameterType.FullName).ToList(); } - return _app.Communicator.HookMethod(methodToHook, pos, wrappedHook, parametersTypeFullNames); + return _app.Communicator.HookMethod(methodToHook, pos, wrappedHook, parametersTypeFullNames, instanceAddress); + } + + /// + /// Hook a method on a specific instance using a dynamic object + /// + public bool HookMethod(MethodBase methodToHook, HarmonyPatchPosition pos, DynamifiedHookCallback hookAction, dynamic instance) + { + RemoteObject remoteObj = null; + + // Try to extract RemoteObject from dynamic + if (instance != null) + { + // If it's already a RemoteObject, use it directly + if (instance is RemoteObject ro) + { + remoteObj = ro; + } + // Otherwise, try to get the underlying RemoteObject from DynamicRemoteObject + else + { + try + { + // Cache the PropertyInfo for better performance (thread-safe) + if (_remoteObjectProperty == null) + { + lock (_remoteObjectPropertyLock) + { + if (_remoteObjectProperty == null) + { + _remoteObjectProperty = instance.GetType().GetProperty("RemoteObject", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + } + } + } + + if (_remoteObjectProperty != null) + { + remoteObj = _remoteObjectProperty.GetValue(instance) as RemoteObject; + } + } + catch + { + throw new ArgumentException("Unable to extract RemoteObject from the provided dynamic instance. " + + "Please provide a RemoteObject or DynamicRemoteObject."); + } + } + } + + return HookMethod(methodToHook, pos, hookAction, remoteObj); } private LocalHookCallback WrapCallback(DynamifiedHookCallback hookAction) @@ -148,7 +211,8 @@ dynamic DecodeOora(ObjectOrRemoteAddress oora) public void Patch(MethodBase original, DynamifiedHookCallback prefix = null, DynamifiedHookCallback postfix = null, - DynamifiedHookCallback finalizer = null) + DynamifiedHookCallback finalizer = null, + RemoteObject instance = null) { if (prefix == null && postfix == null && @@ -159,15 +223,15 @@ public void Patch(MethodBase original, if (prefix != null) { - HookMethod(original, HarmonyPatchPosition.Prefix, prefix); + HookMethod(original, HarmonyPatchPosition.Prefix, prefix, instance); } if (postfix != null) { - HookMethod(original, HarmonyPatchPosition.Postfix, postfix); + HookMethod(original, HarmonyPatchPosition.Postfix, postfix, instance); } if (finalizer != null) { - HookMethod(original, HarmonyPatchPosition.Finalizer, finalizer); + HookMethod(original, HarmonyPatchPosition.Finalizer, finalizer, instance); } } diff --git a/src/RemoteNET/UnmanagedRemoteObject.cs b/src/RemoteNET/UnmanagedRemoteObject.cs index d03028d2..e59e1a57 100644 --- a/src/RemoteNET/UnmanagedRemoteObject.cs +++ b/src/RemoteNET/UnmanagedRemoteObject.cs @@ -69,4 +69,29 @@ public override RemoteObject Cast(Type t) RemoteObjectRef ror = new RemoteObjectRef(_ref.RemoteObjectInfo, dumpType, _ref.CreatingCommunicator); return new UnmanagedRemoteObject(ror, _app); } + + /// + /// Hooks a method on this specific instance. + /// This is a convenience method that calls app.HookingManager.HookMethod with this instance. + /// + /// The method to hook + /// Position of the hook (Prefix, Postfix, or Finalizer) + /// The callback to invoke when the method is called + /// True on success + public bool Hook(System.Reflection.MethodBase methodToHook, ScubaDiver.API.Hooking.HarmonyPatchPosition pos, RemoteNET.Common.DynamifiedHookCallback hookAction) + { + return _app.HookingManager.HookMethod(methodToHook, pos, hookAction, this); + } + + /// + /// Patches a method on this specific instance with prefix, postfix, and/or finalizer hooks. + /// This is a convenience method that calls app.HookingManager.Patch with this instance. + /// + public void Patch(System.Reflection.MethodBase original, + RemoteNET.Common.DynamifiedHookCallback prefix = null, + RemoteNET.Common.DynamifiedHookCallback postfix = null, + RemoteNET.Common.DynamifiedHookCallback finalizer = null) + { + _app.HookingManager.Patch(original, prefix, postfix, finalizer, this); + } } \ No newline at end of file diff --git a/src/ScubaDiver.API/DiverCommunicator.cs b/src/ScubaDiver.API/DiverCommunicator.cs index df244c8c..08446345 100644 --- a/src/ScubaDiver.API/DiverCommunicator.cs +++ b/src/ScubaDiver.API/DiverCommunicator.cs @@ -485,7 +485,7 @@ public void EventUnsubscribe(LocalEventCallback callback) } } - public bool HookMethod(MethodBase methodBase, HarmonyPatchPosition pos, LocalHookCallback callback, List parametersTypeFullNames = null) + public bool HookMethod(MethodBase methodBase, HarmonyPatchPosition pos, LocalHookCallback callback, List parametersTypeFullNames = null, ulong instanceAddress = 0) { if (!_listener.IsOpen) { @@ -499,7 +499,8 @@ public bool HookMethod(MethodBase methodBase, HarmonyPatchPosition pos, LocalHoo TypeFullName = methodBase.DeclaringType.FullName, MethodName = methodBase.Name, HookPosition = pos.ToString(), - ParametersTypeFullNames = parametersTypeFullNames + ParametersTypeFullNames = parametersTypeFullNames, + InstanceAddress = instanceAddress }; var requestJsonBody = JsonConvert.SerializeObject(req); diff --git a/src/ScubaDiver.API/Interactions/Callbacks/FunctionHookRequest.cs b/src/ScubaDiver.API/Interactions/Callbacks/FunctionHookRequest.cs index 70843eb1..9254a768 100644 --- a/src/ScubaDiver.API/Interactions/Callbacks/FunctionHookRequest.cs +++ b/src/ScubaDiver.API/Interactions/Callbacks/FunctionHookRequest.cs @@ -13,6 +13,11 @@ public class FunctionHookRequest public string HookPosition { get; set; } // FFS: "Pre" or "Post" + /// + /// Optional: If specified, only hooks on this specific instance (address). 0 means hook all instances. + /// + public ulong InstanceAddress { get; set; } + } } \ No newline at end of file diff --git a/src/ScubaDiver/DiverBase.cs b/src/ScubaDiver/DiverBase.cs index 6bc0b1ed..4a87f0d5 100644 --- a/src/ScubaDiver/DiverBase.cs +++ b/src/ScubaDiver/DiverBase.cs @@ -30,10 +30,14 @@ public abstract class DiverBase : IDisposable protected bool _monitorEndpoints = true; private int _nextAvailableCallbackToken; protected readonly ConcurrentDictionary _remoteHooks; + protected readonly HookingCenter _hookingCenter; + private readonly ConcurrentDictionary _harmonyHookLocks; public DiverBase(IRequestsListener listener) { _listener = listener; + _hookingCenter = new HookingCenter(); + _harmonyHookLocks = new ConcurrentDictionary(); _responseBodyCreators = new Dictionary>() { // Divert maintenance @@ -202,7 +206,22 @@ protected string MakeUnhookMethodResponse(ScubaDiverMessage arg) if (_remoteHooks.TryRemove(token, out RegisteredManagedMethodHookInfo rmhi)) { - rmhi.UnhookAction(); + // Unregister from HookingCenter + if (!string.IsNullOrEmpty(rmhi.UniqueHookId)) + { + _hookingCenter.UnregisterHook(rmhi.UniqueHookId, token); + + // If this was the last hook for this method, unhook from Harmony + if (_hookingCenter.GetHookCount(rmhi.UniqueHookId) == 0) + { + rmhi.UnhookAction(); + } + } + else + { + // Old-style hook without instance filtering + rmhi.UnhookAction(); + } return "{\"status\":\"OK\"}"; } @@ -236,9 +255,13 @@ private string HookFunctionWrapper(FunctionHookRequest req, IPEndPoint endpoint) int token = AssignCallbackToken(); Logger.Debug($"[DiverBase] Hook Method - Assigned Token: {token}"); Logger.Debug($"[DiverBase] Hook Method - endpoint: {endpoint}"); + Logger.Debug($"[DiverBase] Hook Method - Instance Address: {req.InstanceAddress:X}"); + // Generate unique hook ID for this method+position combination + string uniqueHookId = GenerateHookId(req); // Preparing a proxy method that Harmony will invoke + // Note: Instance filtering is handled by HookingCenter, not here HarmonyWrapper.HookCallback patchCallback = (object obj, object[] args, ref object retValue) => { object[] parameters = new object[args.Length + 1]; @@ -262,14 +285,38 @@ private string HookFunctionWrapper(FunctionHookRequest req, IPEndPoint endpoint) Logger.Debug($"[DiverBase] Hooking function {req.MethodName}..."); Action unhookAction; + try { - unhookAction = HookFunction(req, patchCallback); + // Use a lock per unique hook ID to prevent race conditions + object hookLock = _harmonyHookLocks.GetOrAdd(uniqueHookId, _ => new object()); + + lock (hookLock) + { + // Register this callback with the hooking center first + _hookingCenter.RegisterHook(uniqueHookId, req.InstanceAddress, patchCallback, token); + + // Check if we need to install the Harmony hook + if (_hookingCenter.GetHookCount(uniqueHookId) == 1) + { + // First hook for this method - install the actual Harmony hook + // Use the unified callback from HookingCenter + HarmonyWrapper.HookCallback unifiedCallback = _hookingCenter.CreateUnifiedCallback(uniqueHookId, ResolveInstanceAddress); + unhookAction = HookFunction(req, unifiedCallback); + } + else + { + // Additional hook on same method - Harmony hook already installed + // The unified callback will dispatch to all registered callbacks + unhookAction = () => { }; // No-op unhook since we didn't install a new Harmony hook + } + } } catch (Exception ex) { // Hooking filed so we cleanup the Hook Info we inserted beforehand _remoteHooks.TryRemove(token, out _); + _hookingCenter.UnregisterHook(uniqueHookId, token); Logger.Debug($"[DiverBase] Failed to hook func {req.MethodName}. Exception: {ex}"); return QuickError($"Failed insert the hook for the function. HarmonyWrapper.AddHook failed. Exception: {ex}", ex.StackTrace); @@ -282,13 +329,22 @@ private string HookFunctionWrapper(FunctionHookRequest req, IPEndPoint endpoint) { Endpoint = endpoint, RegisteredProxy = patchCallback, - UnhookAction = unhookAction + UnhookAction = unhookAction, + UniqueHookId = uniqueHookId }; EventRegistrationResults erResults = new() { Token = token }; return JsonConvert.SerializeObject(erResults); } + private string GenerateHookId(FunctionHookRequest req) + { + string paramsList = string.Join(";", req.ParametersTypeFullNames ?? new List()); + return $"{req.TypeFullName}:{paramsList}:{req.MethodName}:{req.HookPosition}"; + } + + protected abstract ulong ResolveInstanceAddress(object instance); + public abstract object ResolveHookReturnValue(ObjectOrRemoteAddress oora); public int AssignCallbackToken() => Interlocked.Increment(ref _nextAvailableCallbackToken); diff --git a/src/ScubaDiver/DotNetDiver.cs b/src/ScubaDiver/DotNetDiver.cs index 3eeda4f8..9cc307ae 100644 --- a/src/ScubaDiver/DotNetDiver.cs +++ b/src/ScubaDiver/DotNetDiver.cs @@ -1587,5 +1587,26 @@ public override void Dispose() _remoteHooks.Clear(); Logger.Debug("[DotNetDiver] Removed all event subscriptions & hooks"); } + + protected override ulong ResolveInstanceAddress(object instance) + { + if (instance == null) + return 0; + + // Try to get the pinning address if the object is pinned + if (_freezer.TryGetPinningAddress(instance, out ulong pinnedAddress)) + { + return pinnedAddress; + } + + // For unpinned objects, we can't reliably get their address + // as it can change due to GC. In this case, we use object identity hash code. + // IMPORTANT: RuntimeHelpers.GetHashCode provides stable identity for the lifetime + // of an object, but different objects may have the same hash code (collisions). + // This means instance-specific hooks on unpinned objects may occasionally trigger + // for wrong instances if hash codes collide. For reliable instance-specific hooking, + // ensure objects are pinned before hooking. + return (ulong)System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(instance); + } } } \ No newline at end of file diff --git a/src/ScubaDiver/Hooking/HookingCenter.cs b/src/ScubaDiver/Hooking/HookingCenter.cs new file mode 100644 index 00000000..e4d7070f --- /dev/null +++ b/src/ScubaDiver/Hooking/HookingCenter.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Concurrent; +using System.Reflection; + +namespace ScubaDiver.Hooking +{ + /// + /// Centralized hooking manager that handles instance-specific hooks. + /// When a method is hooked with a specific instance, this center wraps callbacks + /// to filter invocations based on the instance address. + /// + public class HookingCenter + { + /// + /// Information about a registered hook + /// + public class HookRegistration + { + public ulong InstanceAddress { get; set; } + public HarmonyWrapper.HookCallback OriginalCallback { get; set; } + public int Token { get; set; } + } + + /// + /// Key: Unique hook identifier (method + position) + /// Value: Dictionary mapping token to hook registration + /// + private readonly ConcurrentDictionary> _instanceHooks; + + public HookingCenter() + { + _instanceHooks = new ConcurrentDictionary>(); + } + + /// + /// Registers a hook callback for a specific instance (or all instances if instanceAddress is 0) + /// + /// Unique identifier for the method hook (includes position) + /// Address of the instance to hook, or 0 for all instances + /// The callback to invoke + /// Token identifying this hook registration + public void RegisterHook(string uniqueHookId, ulong instanceAddress, HarmonyWrapper.HookCallback callback, int token) + { + var registrations = _instanceHooks.GetOrAdd(uniqueHookId, _ => new ConcurrentDictionary()); + registrations[token] = new HookRegistration + { + InstanceAddress = instanceAddress, + OriginalCallback = callback, + Token = token + }; + } + + /// + /// Unregisters a hook callback by token + /// + /// Unique identifier for the method hook + /// Token identifying the hook registration to remove + public bool UnregisterHook(string uniqueHookId, int token) + { + if (_instanceHooks.TryGetValue(uniqueHookId, out var registrations)) + { + bool removed = registrations.TryRemove(token, out _); + + if (removed && registrations.IsEmpty) + { + _instanceHooks.TryRemove(uniqueHookId, out _); + } + + return removed; + } + return false; + } + + /// + /// Creates a unified callback that dispatches to instance-specific callbacks. + /// This wraps the individual callbacks to filter by instance. + /// + /// Unique identifier for the method hook + /// Function to resolve an object to its address + /// A callback that handles instance filtering + public HarmonyWrapper.HookCallback CreateUnifiedCallback(string uniqueHookId, Func instanceResolver) + { + return (object instance, object[] args, ref object retValue) => + { + if (!_instanceHooks.TryGetValue(uniqueHookId, out var registrations) || registrations.IsEmpty) + { + // This should ideally not happen since we only create unified callbacks when hooks exist + // If it does, it means hooks were removed between callback creation and invocation + Logger.Debug($"[HookingCenter] Warning: Unified callback invoked for {uniqueHookId} but no registrations found"); + return true; + } + + // Resolve the instance address + ulong instanceAddress = 0; + if (instance != null && instanceResolver != null) + { + try + { + instanceAddress = instanceResolver(instance); + } + catch (Exception ex) + { + // Log the exception for debugging but continue with address 0 + Logger.Debug($"[HookingCenter] Failed to resolve instance address for {uniqueHookId}: {ex.Message}"); + instanceAddress = 0; + } + } + + // Invoke all matching callbacks + bool callOriginal = true; + + foreach (var kvp in registrations) + { + var registration = kvp.Value; + // Check if this callback matches + bool shouldInvoke = registration.InstanceAddress == 0 || // Global hook (all instances) + registration.InstanceAddress == instanceAddress; // Instance-specific match + + if (shouldInvoke) + { + bool thisCallOriginal = registration.OriginalCallback(instance, args, ref retValue); + // If any callback says skip original, we skip it + callOriginal = callOriginal && thisCallOriginal; + } + } + + return callOriginal; + }; + } + + /// + /// Checks if there are any hooks registered for a specific method + /// + public bool HasHooks(string uniqueHookId) + { + return _instanceHooks.TryGetValue(uniqueHookId, out var dict) && !dict.IsEmpty; + } + + /// + /// Gets the count of registered hooks for a method + /// + public int GetHookCount(string uniqueHookId) + { + if (_instanceHooks.TryGetValue(uniqueHookId, out var dict)) + { + return dict.Count; + } + return 0; + } + } +} diff --git a/src/ScubaDiver/MsvcDiver.cs b/src/ScubaDiver/MsvcDiver.cs index 4dd6621d..f5b4d505 100644 --- a/src/ScubaDiver/MsvcDiver.cs +++ b/src/ScubaDiver/MsvcDiver.cs @@ -977,5 +977,26 @@ public override void Dispose() { } + protected override ulong ResolveInstanceAddress(object instance) + { + if (instance == null) + return 0; + + // For MSVC/native objects, check if it's a NativeObject + if (instance is NativeObject nativeObj) + { + return nativeObj.Address; + } + + // Try to get the pinning address if the object is in the freezer + if (_freezer != null && _freezer.TryGetPinningAddress(instance, out ulong pinnedAddress)) + { + return pinnedAddress; + } + + // Fallback: use hashcode (not ideal but better than nothing) + return (ulong)instance.GetHashCode(); + } + } } diff --git a/src/ScubaDiver/RegisteredMethodHookInfo.cs b/src/ScubaDiver/RegisteredMethodHookInfo.cs index 2083e3ae..c78e0eeb 100644 --- a/src/ScubaDiver/RegisteredMethodHookInfo.cs +++ b/src/ScubaDiver/RegisteredMethodHookInfo.cs @@ -20,5 +20,10 @@ public class RegisteredManagedMethodHookInfo /// public Action UnhookAction{ get; set; } + /// + /// The unique identifier for this hook (method + position) + /// + public string UniqueHookId { get; set; } + } } \ No newline at end of file diff --git a/src/ScubaDiver/project_net5/ScubaDiver_Net5.csproj b/src/ScubaDiver/project_net5/ScubaDiver_Net5.csproj index de0e0b93..da158670 100644 --- a/src/ScubaDiver/project_net5/ScubaDiver_Net5.csproj +++ b/src/ScubaDiver/project_net5/ScubaDiver_Net5.csproj @@ -14,6 +14,7 @@ + diff --git a/src/ScubaDiver/project_net6_x64/ScubaDiver_Net6_x64.csproj b/src/ScubaDiver/project_net6_x64/ScubaDiver_Net6_x64.csproj index 877fc3b8..dc8cb23d 100644 --- a/src/ScubaDiver/project_net6_x64/ScubaDiver_Net6_x64.csproj +++ b/src/ScubaDiver/project_net6_x64/ScubaDiver_Net6_x64.csproj @@ -17,6 +17,7 @@ + diff --git a/src/ScubaDiver/project_net6_x86/ScubaDiver_Net6_x86.csproj b/src/ScubaDiver/project_net6_x86/ScubaDiver_Net6_x86.csproj index fc1a4248..0fa2ed69 100644 --- a/src/ScubaDiver/project_net6_x86/ScubaDiver_Net6_x86.csproj +++ b/src/ScubaDiver/project_net6_x86/ScubaDiver_Net6_x86.csproj @@ -17,6 +17,7 @@ + diff --git a/src/ScubaDiver/project_netcore/ScubaDiver_NetCore.csproj b/src/ScubaDiver/project_netcore/ScubaDiver_NetCore.csproj index 71f32018..bb6cf3d6 100644 --- a/src/ScubaDiver/project_netcore/ScubaDiver_NetCore.csproj +++ b/src/ScubaDiver/project_netcore/ScubaDiver_NetCore.csproj @@ -13,6 +13,7 @@ + diff --git a/src/ScubaDiver/project_netframework/ScubaDiver_NetFramework.csproj b/src/ScubaDiver/project_netframework/ScubaDiver_NetFramework.csproj index 0b30ef41..3384aa1b 100644 --- a/src/ScubaDiver/project_netframework/ScubaDiver_NetFramework.csproj +++ b/src/ScubaDiver/project_netframework/ScubaDiver_NetFramework.csproj @@ -43,6 +43,7 @@ +