From 91f35f825a11e937e1fcbabc4b4a32873e0a8eaa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 11:31:18 +0000 Subject: [PATCH 01/14] Initial plan From 0a4b0f597e3bfd100d76bdaf9c96d67e14b33306 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 11:44:51 +0000 Subject: [PATCH 02/14] Add instance-specific hooking support to ScubaDiver and RemoteNET Co-authored-by: theXappy <10898152+theXappy@users.noreply.github.com> --- src/RemoteNET/ManagedRemoteObject.cs | 25 +++ src/RemoteNET/RemoteHookingManager.cs | 62 ++++++- src/RemoteNET/UnmanagedRemoteObject.cs | 25 +++ src/ScubaDiver.API/DiverCommunicator.cs | 5 +- .../Callbacks/FunctionHookRequest.cs | 5 + src/ScubaDiver/DiverBase.cs | 63 ++++++- src/ScubaDiver/DotNetDiver.cs | 18 ++ src/ScubaDiver/Hooking/HookingCenter.cs | 167 ++++++++++++++++++ src/ScubaDiver/MsvcDiver.cs | 21 +++ src/ScubaDiver/RegisteredMethodHookInfo.cs | 5 + .../project_net5/ScubaDiver_Net5.csproj | 1 + .../ScubaDiver_Net6_x64.csproj | 1 + .../ScubaDiver_Net6_x86.csproj | 1 + .../project_netcore/ScubaDiver_NetCore.csproj | 1 + .../ScubaDiver_NetFramework.csproj | 1 + 15 files changed, 389 insertions(+), 12 deletions(-) create mode 100644 src/ScubaDiver/Hooking/HookingCenter.cs 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..02cb4651 100644 --- a/src/RemoteNET/RemoteHookingManager.cs +++ b/src/RemoteNET/RemoteHookingManager.cs @@ -48,8 +48,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); @@ -65,8 +72,9 @@ public bool HookMethod(MethodBase methodToHook, HarmonyPatchPosition pos, Dynami { throw new NotImplementedException("Shouldn't use same hook for 2 patches of the same method"); } - if (methodHooks.Any(existingHook => existingHook.Value.Position == pos)) + if (instanceAddress == 0 && methodHooks.Any(existingHook => existingHook.Value.Position == pos)) { + // Only prevent duplicate hooks if hooking all instances throw new NotImplementedException("Can not set 2 hooks in the same position on a single target"); } @@ -86,7 +94,46 @@ 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 + { + // DynamicRemoteObject has a GetRemoteObject method or RemoteObject property + var remoteObjProp = instance.GetType().GetProperty("RemoteObject", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (remoteObjProp != null) + { + remoteObj = remoteObjProp.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 +195,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 +207,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..15baa262 100644 --- a/src/ScubaDiver/DiverBase.cs +++ b/src/ScubaDiver/DiverBase.cs @@ -30,10 +30,12 @@ public abstract class DiverBase : IDisposable protected bool _monitorEndpoints = true; private int _nextAvailableCallbackToken; protected readonly ConcurrentDictionary _remoteHooks; + protected readonly HookingCenter _hookingCenter; public DiverBase(IRequestsListener listener) { _listener = listener; + _hookingCenter = new HookingCenter(); _responseBodyCreators = new Dictionary>() { // Divert maintenance @@ -202,7 +204,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,11 +253,25 @@ 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 HarmonyWrapper.HookCallback patchCallback = (object obj, object[] args, ref object retValue) => { + // Check if this invocation matches the instance filter (if any) + if (req.InstanceAddress != 0) + { + ulong instanceAddress = ResolveInstanceAddress(obj); + if (instanceAddress != req.InstanceAddress) + { + // Wrong instance, skip this callback + return true; // Call original + } + } + object[] parameters = new object[args.Length + 1]; parameters[0] = obj; Array.Copy(args, 0, parameters, 1, args.Length); @@ -262,9 +293,26 @@ private string HookFunctionWrapper(FunctionHookRequest req, IPEndPoint endpoint) Logger.Debug($"[DiverBase] Hooking function {req.MethodName}..."); Action unhookAction; + + // Check if this is the first hook for this method + bool isFirstHook = !_hookingCenter.HasHooks(uniqueHookId); + try { - unhookAction = HookFunction(req, patchCallback); + if (isFirstHook) + { + // First hook for this method - install the actual Harmony hook + unhookAction = HookFunction(req, patchCallback); + } + else + { + // Additional hook on same method - no need to install Harmony hook again + // The existing hook will dispatch to all registered callbacks + unhookAction = () => { }; // No-op unhook since we didn't install a new Harmony hook + } + + // Register this callback with the hooking center + _hookingCenter.RegisterHook(uniqueHookId, req.InstanceAddress, patchCallback, token); } catch (Exception ex) { @@ -282,13 +330,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..dd9e4908 100644 --- a/src/ScubaDiver/DotNetDiver.cs +++ b/src/ScubaDiver/DotNetDiver.cs @@ -1587,5 +1587,23 @@ 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 reference equality + // which is handled by comparing object identity in the callback. + // Return a pseudo-address based on the object's identity hash code + 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..9f617365 --- /dev/null +++ b/src/ScubaDiver/Hooking/HookingCenter.cs @@ -0,0 +1,167 @@ +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: List of hook registrations for that method + /// + 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 ConcurrentBag()); + registrations.Add(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)) + { + // We can't efficiently remove from ConcurrentBag, so we'll mark it for filtering + // or recreate the bag without the item + var newBag = new ConcurrentBag(); + bool found = false; + foreach (var reg in registrations) + { + if (reg.Token != token) + { + newBag.Add(reg); + } + else + { + found = true; + } + } + + if (found) + { + if (newBag.IsEmpty) + { + _instanceHooks.TryRemove(uniqueHookId, out _); + } + else + { + _instanceHooks[uniqueHookId] = newBag; + } + return true; + } + } + 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) + { + // No callbacks registered, call original + return true; + } + + // Resolve the instance address + ulong instanceAddress = 0; + if (instance != null && instanceResolver != null) + { + try + { + instanceAddress = instanceResolver(instance); + } + catch + { + // If resolution fails, treat as 0 (unknown) + instanceAddress = 0; + } + } + + // Invoke all matching callbacks + bool callOriginal = true; + + foreach (var registration in registrations) + { + // 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 bag) && !bag.IsEmpty; + } + + /// + /// Gets the count of registered hooks for a method + /// + public int GetHookCount(string uniqueHookId) + { + if (_instanceHooks.TryGetValue(uniqueHookId, out var bag)) + { + return bag.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 @@ + From 24eee19d728d08d86660db7d5e9e6ebd32e3a229 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 11:46:14 +0000 Subject: [PATCH 03/14] Add documentation and tests for instance-specific hooking Co-authored-by: theXappy <10898152+theXappy@users.noreply.github.com> --- INSTANCE_HOOKING_EXAMPLE.md | 164 ++++++++++++++++ src/RemoteNET.Tests/InstanceHookingTests.cs | 200 ++++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 INSTANCE_HOOKING_EXAMPLE.md create mode 100644 src/RemoteNET.Tests/InstanceHookingTests.cs 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/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 + } +} From 2c5dd523a18d67404b0cfafbd90a9b9f2816723f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 11:49:17 +0000 Subject: [PATCH 04/14] Address code review feedback: fix race conditions, improve performance, and prevent duplicate hooks Co-authored-by: theXappy <10898152+theXappy@users.noreply.github.com> --- src/RemoteNET/RemoteHookingManager.cs | 31 +++++++++------ src/ScubaDiver/DiverBase.cs | 39 +++++++++++-------- src/ScubaDiver/Hooking/HookingCenter.cs | 51 ++++++++----------------- 3 files changed, 60 insertions(+), 61 deletions(-) diff --git a/src/RemoteNET/RemoteHookingManager.cs b/src/RemoteNET/RemoteHookingManager.cs index 02cb4651..3bb0baaf 100644 --- a/src/RemoteNET/RemoteHookingManager.cs +++ b/src/RemoteNET/RemoteHookingManager.cs @@ -27,16 +27,21 @@ 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 + private static System.Reflection.PropertyInfo _remoteObjectProperty = null; public RemoteHookingManager(RemoteApp app) @@ -70,15 +75,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 (instanceAddress == 0 && 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)) { - // Only prevent duplicate hooks if hooking all instances - 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) @@ -117,12 +122,16 @@ public bool HookMethod(MethodBase methodToHook, HarmonyPatchPosition pos, Dynami { try { - // DynamicRemoteObject has a GetRemoteObject method or RemoteObject property - var remoteObjProp = instance.GetType().GetProperty("RemoteObject", - System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (remoteObjProp != null) + // Cache the PropertyInfo for better performance + if (_remoteObjectProperty == null) { - remoteObj = remoteObjProp.GetValue(instance) as RemoteObject; + _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 diff --git a/src/ScubaDiver/DiverBase.cs b/src/ScubaDiver/DiverBase.cs index 15baa262..98446fbf 100644 --- a/src/ScubaDiver/DiverBase.cs +++ b/src/ScubaDiver/DiverBase.cs @@ -31,11 +31,13 @@ public abstract class DiverBase : IDisposable 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 @@ -294,30 +296,37 @@ private string HookFunctionWrapper(FunctionHookRequest req, IPEndPoint endpoint) Logger.Debug($"[DiverBase] Hooking function {req.MethodName}..."); Action unhookAction; - // Check if this is the first hook for this method - bool isFirstHook = !_hookingCenter.HasHooks(uniqueHookId); - try { - if (isFirstHook) - { - // First hook for this method - install the actual Harmony hook - unhookAction = HookFunction(req, patchCallback); - } - else + // Use a lock per unique hook ID to prevent race conditions + object hookLock = _harmonyHookLocks.GetOrAdd(uniqueHookId, _ => new object()); + + lock (hookLock) { - // Additional hook on same method - no need to install Harmony hook again - // The existing hook will dispatch to all registered callbacks - unhookAction = () => { }; // No-op unhook since we didn't install a new Harmony hook + // 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 + } } - - // Register this callback with the hooking center - _hookingCenter.RegisterHook(uniqueHookId, req.InstanceAddress, patchCallback, token); } 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); diff --git a/src/ScubaDiver/Hooking/HookingCenter.cs b/src/ScubaDiver/Hooking/HookingCenter.cs index 9f617365..21fb408f 100644 --- a/src/ScubaDiver/Hooking/HookingCenter.cs +++ b/src/ScubaDiver/Hooking/HookingCenter.cs @@ -23,13 +23,13 @@ public class HookRegistration /// /// Key: Unique hook identifier (method + position) - /// Value: List of hook registrations for that method + /// Value: Dictionary mapping token to hook registration /// - private readonly ConcurrentDictionary> _instanceHooks; + private readonly ConcurrentDictionary> _instanceHooks; public HookingCenter() { - _instanceHooks = new ConcurrentDictionary>(); + _instanceHooks = new ConcurrentDictionary>(); } /// @@ -41,13 +41,13 @@ public HookingCenter() /// Token identifying this hook registration public void RegisterHook(string uniqueHookId, ulong instanceAddress, HarmonyWrapper.HookCallback callback, int token) { - var registrations = _instanceHooks.GetOrAdd(uniqueHookId, _ => new ConcurrentBag()); - registrations.Add(new HookRegistration + var registrations = _instanceHooks.GetOrAdd(uniqueHookId, _ => new ConcurrentDictionary()); + registrations[token] = new HookRegistration { InstanceAddress = instanceAddress, OriginalCallback = callback, Token = token - }); + }; } /// @@ -59,34 +59,14 @@ public bool UnregisterHook(string uniqueHookId, int token) { if (_instanceHooks.TryGetValue(uniqueHookId, out var registrations)) { - // We can't efficiently remove from ConcurrentBag, so we'll mark it for filtering - // or recreate the bag without the item - var newBag = new ConcurrentBag(); - bool found = false; - foreach (var reg in registrations) - { - if (reg.Token != token) - { - newBag.Add(reg); - } - else - { - found = true; - } - } + bool removed = registrations.TryRemove(token, out _); - if (found) + if (removed && registrations.IsEmpty) { - if (newBag.IsEmpty) - { - _instanceHooks.TryRemove(uniqueHookId, out _); - } - else - { - _instanceHooks[uniqueHookId] = newBag; - } - return true; + _instanceHooks.TryRemove(uniqueHookId, out _); } + + return removed; } return false; } @@ -126,8 +106,9 @@ public HarmonyWrapper.HookCallback CreateUnifiedCallback(string uniqueHookId, Fu // Invoke all matching callbacks bool callOriginal = true; - foreach (var registration in registrations) + 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 @@ -149,7 +130,7 @@ public HarmonyWrapper.HookCallback CreateUnifiedCallback(string uniqueHookId, Fu /// public bool HasHooks(string uniqueHookId) { - return _instanceHooks.TryGetValue(uniqueHookId, out var bag) && !bag.IsEmpty; + return _instanceHooks.TryGetValue(uniqueHookId, out var dict) && !dict.IsEmpty; } /// @@ -157,9 +138,9 @@ public bool HasHooks(string uniqueHookId) /// public int GetHookCount(string uniqueHookId) { - if (_instanceHooks.TryGetValue(uniqueHookId, out var bag)) + if (_instanceHooks.TryGetValue(uniqueHookId, out var dict)) { - return bag.Count; + return dict.Count; } return 0; } From 9357b71d18e6e25426f62c8051377765e6971ec9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 11:50:02 +0000 Subject: [PATCH 05/14] Add technical implementation documentation Co-authored-by: theXappy <10898152+theXappy@users.noreply.github.com> --- INSTANCE_HOOKING_IMPLEMENTATION.md | 152 +++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 INSTANCE_HOOKING_IMPLEMENTATION.md 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 From 9da844ffa9c5dde8ccbc79cea61103bd8641f6f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 11:52:07 +0000 Subject: [PATCH 06/14] Final improvements: remove duplicate filtering, add logging, improve thread safety Co-authored-by: theXappy <10898152+theXappy@users.noreply.github.com> --- src/RemoteNET/RemoteHookingManager.cs | 15 +++++++++++---- src/ScubaDiver/DiverBase.cs | 12 +----------- src/ScubaDiver/DotNetDiver.cs | 9 ++++++--- src/ScubaDiver/Hooking/HookingCenter.cs | 9 ++++++--- 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/RemoteNET/RemoteHookingManager.cs b/src/RemoteNET/RemoteHookingManager.cs index 3bb0baaf..7aabdff2 100644 --- a/src/RemoteNET/RemoteHookingManager.cs +++ b/src/RemoteNET/RemoteHookingManager.cs @@ -40,8 +40,9 @@ private class MethodHooks : Dictionary { - // Check if this invocation matches the instance filter (if any) - if (req.InstanceAddress != 0) - { - ulong instanceAddress = ResolveInstanceAddress(obj); - if (instanceAddress != req.InstanceAddress) - { - // Wrong instance, skip this callback - return true; // Call original - } - } - object[] parameters = new object[args.Length + 1]; parameters[0] = obj; Array.Copy(args, 0, parameters, 1, args.Length); diff --git a/src/ScubaDiver/DotNetDiver.cs b/src/ScubaDiver/DotNetDiver.cs index dd9e4908..9cc307ae 100644 --- a/src/ScubaDiver/DotNetDiver.cs +++ b/src/ScubaDiver/DotNetDiver.cs @@ -1600,9 +1600,12 @@ protected override ulong ResolveInstanceAddress(object instance) } // For unpinned objects, we can't reliably get their address - // as it can change due to GC. In this case, we use object reference equality - // which is handled by comparing object identity in the callback. - // Return a pseudo-address based on the object's identity hash code + // 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); } } diff --git a/src/ScubaDiver/Hooking/HookingCenter.cs b/src/ScubaDiver/Hooking/HookingCenter.cs index 21fb408f..e4d7070f 100644 --- a/src/ScubaDiver/Hooking/HookingCenter.cs +++ b/src/ScubaDiver/Hooking/HookingCenter.cs @@ -84,7 +84,9 @@ public HarmonyWrapper.HookCallback CreateUnifiedCallback(string uniqueHookId, Fu { if (!_instanceHooks.TryGetValue(uniqueHookId, out var registrations) || registrations.IsEmpty) { - // No callbacks registered, call original + // 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; } @@ -96,9 +98,10 @@ public HarmonyWrapper.HookCallback CreateUnifiedCallback(string uniqueHookId, Fu { instanceAddress = instanceResolver(instance); } - catch + catch (Exception ex) { - // If resolution fails, treat as 0 (unknown) + // Log the exception for debugging but continue with address 0 + Logger.Debug($"[HookingCenter] Failed to resolve instance address for {uniqueHookId}: {ex.Message}"); instanceAddress = 0; } } From 0a51255633c3f897c577ed0acb4d4d9078a2213d Mon Sep 17 00:00:00 2001 From: RemoteNet Date: Sat, 6 Dec 2025 15:53:25 +0200 Subject: [PATCH 07/14] refactor code, remove debug prints --- src/RemoteNET.Common/RemoteObject.cs | 4 + src/RemoteNET.Tests/InstanceHookingTests.cs | 386 +++++++++--------- src/RemoteNET/ManagedRemoteObject.cs | 17 +- src/RemoteNET/RemoteCharStar.cs | 8 + src/RemoteNET/UnmanagedRemoteObject.cs | 17 +- src/ScubaDiver/DiverBase.cs | 63 +-- src/ScubaDiver/DotNetDiver.cs | 7 +- src/ScubaDiver/Hooking/HookingCenter.cs | 98 +++-- src/ScubaDiver/MsvcDiver.cs | 10 +- .../MsvcPrimitives/MsvcTypesManager.cs | 93 +---- .../MsvcPrimitives/VftableParser.cs | 78 +--- src/ScubaDiver/RegisteredMethodHookInfo.cs | 19 +- 12 files changed, 331 insertions(+), 469 deletions(-) diff --git a/src/RemoteNET.Common/RemoteObject.cs b/src/RemoteNET.Common/RemoteObject.cs index ea8dfaa7..8816f892 100644 --- a/src/RemoteNET.Common/RemoteObject.cs +++ b/src/RemoteNET.Common/RemoteObject.cs @@ -1,5 +1,8 @@ +using RemoteNET.Common; using ScubaDiver.API; +using ScubaDiver.API.Hooking; using System; +using System.Reflection; namespace RemoteNET; @@ -11,6 +14,7 @@ public abstract class RemoteObject public abstract ObjectOrRemoteAddress GetItem(ObjectOrRemoteAddress key); public abstract RemoteObject Cast(Type t); + public abstract bool Hook(MethodBase methodToHook, HarmonyPatchPosition pos, DynamifiedHookCallback hookAction); public abstract Type GetRemoteType(); public new Type GetType() => GetRemoteType(); diff --git a/src/RemoteNET.Tests/InstanceHookingTests.cs b/src/RemoteNET.Tests/InstanceHookingTests.cs index c045fa9f..e4139339 100644 --- a/src/RemoteNET.Tests/InstanceHookingTests.cs +++ b/src/RemoteNET.Tests/InstanceHookingTests.cs @@ -1,200 +1,200 @@ -using Xunit; -using RemoteNET; -using RemoteNET.Common; -using ScubaDiver.API.Hooking; -using System.Reflection; +//using Xunit; +//using RemoteNET; +//using RemoteNET.Common; +//using ScubaDiver.API.Hooking; +//using System.Reflection; -namespace RemoteNET.Tests; +//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 +///// +///// 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 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 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 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); - } +// [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 - } -} +// /// +// /// 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 54ed98aa..241b1b50 100644 --- a/src/RemoteNET/ManagedRemoteObject.cs +++ b/src/RemoteNET/ManagedRemoteObject.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; +using System.Reflection; +using RemoteNET.Common; using RemoteNET.Internal; using ScubaDiver.API; +using ScubaDiver.API.Hooking; using ScubaDiver.API.Interactions; using ScubaDiver.API.Interactions.Dumps; @@ -141,21 +144,9 @@ public override RemoteObject Cast(Type t) /// 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) + public override bool Hook(MethodBase methodToHook, HarmonyPatchPosition pos, 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/RemoteCharStar.cs b/src/RemoteNET/RemoteCharStar.cs index 28fc9eb0..c4e85466 100644 --- a/src/RemoteNET/RemoteCharStar.cs +++ b/src/RemoteNET/RemoteCharStar.cs @@ -1,5 +1,8 @@ using System; +using System.Reflection; +using RemoteNET.Common; using ScubaDiver.API; +using ScubaDiver.API.Hooking; namespace RemoteNET; @@ -35,4 +38,9 @@ public override RemoteObject Cast(Type t) { throw new NotImplementedException("Not implemented for char* remote objects"); } + + public override bool Hook(MethodBase methodToHook, HarmonyPatchPosition pos, DynamifiedHookCallback hookAction) + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/src/RemoteNET/UnmanagedRemoteObject.cs b/src/RemoteNET/UnmanagedRemoteObject.cs index e59e1a57..45ea73d6 100644 --- a/src/RemoteNET/UnmanagedRemoteObject.cs +++ b/src/RemoteNET/UnmanagedRemoteObject.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; +using System.Reflection; +using RemoteNET.Common; using RemoteNET.Internal; using ScubaDiver.API; +using ScubaDiver.API.Hooking; using ScubaDiver.API.Interactions; using ScubaDiver.API.Interactions.Dumps; @@ -78,20 +81,8 @@ public override RemoteObject Cast(Type t) /// 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) + public override bool Hook(MethodBase methodToHook, HarmonyPatchPosition pos, 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/DiverBase.cs b/src/ScubaDiver/DiverBase.cs index 4a87f0d5..2e769ebb 100644 --- a/src/ScubaDiver/DiverBase.cs +++ b/src/ScubaDiver/DiverBase.cs @@ -31,13 +31,11 @@ public abstract class DiverBase : IDisposable 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 @@ -145,6 +143,11 @@ private void HandleDispatchedRequest(object obj, ScubaDiverMessage request) string body; if (_responseBodyCreators.TryGetValue(request.UrlAbsolutePath, out var respBodyGenerator)) { + Logger.Debug($"[DiverBase] Handling request for {request.UrlAbsolutePath} (delaying)"); + // Delaying execution by 5000 ms + Thread.Sleep(5000); + Logger.Debug($"[DiverBase] Handling request for {request.UrlAbsolutePath} ... GO!"); + try { body = respBodyGenerator(request); @@ -206,22 +209,8 @@ protected string MakeUnhookMethodResponse(ScubaDiverMessage arg) if (_remoteHooks.TryRemove(token, out RegisteredManagedMethodHookInfo rmhi)) { - // 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(); - } + // Unregister from HookingCenter (it will handle Harmony unhooking if needed) + _hookingCenter.UnregisterHookAndUninstall(rmhi.UniqueHookId, token); return "{\"status\":\"OK\"}"; } @@ -284,39 +273,24 @@ private string HookFunctionWrapper(FunctionHookRequest req, IPEndPoint endpoint) }; Logger.Debug($"[DiverBase] Hooking function {req.MethodName}..."); - Action unhookAction; try { - // 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 - } - } + // Register this callback with the hooking center + // It will handle installing the Harmony hook if this is the first registration + _hookingCenter.RegisterHookAndInstall( + uniqueHookId, + req.InstanceAddress, + patchCallback, + token, + unifiedCallback => HookFunction(req, unifiedCallback), + ResolveInstanceAddress); } catch (Exception ex) { - // Hooking filed so we cleanup the Hook Info we inserted beforehand + // Hooking failed so we cleanup the Hook Info we inserted beforehand _remoteHooks.TryRemove(token, out _); - _hookingCenter.UnregisterHook(uniqueHookId, token); + _hookingCenter.UnregisterHookAndUninstall(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); @@ -329,7 +303,6 @@ private string HookFunctionWrapper(FunctionHookRequest req, IPEndPoint endpoint) { Endpoint = endpoint, RegisteredProxy = patchCallback, - UnhookAction = unhookAction, UniqueHookId = uniqueHookId }; diff --git a/src/ScubaDiver/DotNetDiver.cs b/src/ScubaDiver/DotNetDiver.cs index 9cc307ae..4cd0c9dd 100644 --- a/src/ScubaDiver/DotNetDiver.cs +++ b/src/ScubaDiver/DotNetDiver.cs @@ -1579,9 +1579,12 @@ public override void Dispose() { rehi.EventInfo.RemoveEventHandler(rehi.Target, rehi.RegisteredProxy); } - foreach (RegisteredManagedMethodHookInfo rmhi in _remoteHooks.Values) + foreach (var hookKvp in _remoteHooks) { - rmhi.UnhookAction(); + int token = hookKvp.Key; + RegisteredManagedMethodHookInfo rmhi = hookKvp.Value; + // Unregister from HookingCenter (it will handle Harmony unhooking if needed) + _hookingCenter.UnregisterHookAndUninstall(rmhi.UniqueHookId, token); } _remoteEventHandler.Clear(); _remoteHooks.Clear(); diff --git a/src/ScubaDiver/Hooking/HookingCenter.cs b/src/ScubaDiver/Hooking/HookingCenter.cs index e4d7070f..77f63152 100644 --- a/src/ScubaDiver/Hooking/HookingCenter.cs +++ b/src/ScubaDiver/Hooking/HookingCenter.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Reflection; namespace ScubaDiver.Hooking @@ -21,54 +22,105 @@ public class HookRegistration public int Token { get; set; } } + /// + /// Information about a Harmony hook installation + /// + private class HarmonyHookInfo + { + public Action UnhookAction { get; set; } + public ConcurrentDictionary Registrations { get; set; } + } + /// /// Key: Unique hook identifier (method + position) - /// Value: Dictionary mapping token to hook registration + /// Value: Harmony hook info containing unhook action and registrations /// - private readonly ConcurrentDictionary> _instanceHooks; + private readonly ConcurrentDictionary _harmonyHooks; + + /// + /// Locks for synchronizing hook installation/uninstallation per unique hook ID + /// + private readonly ConcurrentDictionary _hookLocks; public HookingCenter() { - _instanceHooks = new ConcurrentDictionary>(); + _harmonyHooks = new ConcurrentDictionary(); + _hookLocks = new ConcurrentDictionary(); } /// /// Registers a hook callback for a specific instance (or all instances if instanceAddress is 0) + /// and installs the Harmony hook if this is the first registration for this method. /// /// 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) + /// Function that installs the Harmony hook and returns an unhook action + /// Function to resolve an object to its address + /// True if this was the first hook and Harmony was installed, false otherwise + public bool RegisterHookAndInstall(string uniqueHookId, ulong instanceAddress, HarmonyWrapper.HookCallback callback, int token, + Func hookInstaller, Func instanceResolver) { - var registrations = _instanceHooks.GetOrAdd(uniqueHookId, _ => new ConcurrentDictionary()); - registrations[token] = new HookRegistration + object hookLock = _hookLocks.GetOrAdd(uniqueHookId, _ => new object()); + + lock (hookLock) { - InstanceAddress = instanceAddress, - OriginalCallback = callback, - Token = token - }; + // Get or create the harmony hook info + var hookInfo = _harmonyHooks.GetOrAdd(uniqueHookId, _ => new HarmonyHookInfo + { + Registrations = new ConcurrentDictionary() + }); + + // Add this registration + hookInfo.Registrations[token] = new HookRegistration + { + InstanceAddress = instanceAddress, + OriginalCallback = callback, + Token = token + }; + + // Check if we need to install the Harmony hook + bool isFirstHook = hookInfo.Registrations.Count == 1; + if (isFirstHook) + { + // First hook for this method - install the actual Harmony hook + HarmonyWrapper.HookCallback unifiedCallback = CreateUnifiedCallback(uniqueHookId, instanceResolver); + hookInfo.UnhookAction = hookInstaller(unifiedCallback); + return true; + } + + return false; + } } /// - /// Unregisters a hook callback by token + /// Unregisters a hook callback by token and uninstalls the Harmony hook if this was the last registration. /// /// Unique identifier for the method hook /// Token identifying the hook registration to remove - public bool UnregisterHook(string uniqueHookId, int token) + /// True if the hook was removed, false if not found + public bool UnregisterHookAndUninstall(string uniqueHookId, int token) { - if (_instanceHooks.TryGetValue(uniqueHookId, out var registrations)) + if (!_harmonyHooks.TryGetValue(uniqueHookId, out var hookInfo)) + return false; + + object hookLock = _hookLocks.GetOrAdd(uniqueHookId, _ => new object()); + + lock (hookLock) { - bool removed = registrations.TryRemove(token, out _); + bool removed = hookInfo.Registrations.TryRemove(token, out _); - if (removed && registrations.IsEmpty) + if (removed && hookInfo.Registrations.IsEmpty) { - _instanceHooks.TryRemove(uniqueHookId, out _); + // Last hook for this method - uninstall the Harmony hook + hookInfo.UnhookAction?.Invoke(); + _harmonyHooks.TryRemove(uniqueHookId, out _); + _hookLocks.TryRemove(uniqueHookId, out _); } return removed; } - return false; } /// @@ -78,11 +130,11 @@ public bool UnregisterHook(string uniqueHookId, int token) /// 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) + private HarmonyWrapper.HookCallback CreateUnifiedCallback(string uniqueHookId, Func instanceResolver) { return (object instance, object[] args, ref object retValue) => { - if (!_instanceHooks.TryGetValue(uniqueHookId, out var registrations) || registrations.IsEmpty) + if (!_harmonyHooks.TryGetValue(uniqueHookId, out HarmonyHookInfo hookInfo) || hookInfo.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 @@ -109,7 +161,7 @@ public HarmonyWrapper.HookCallback CreateUnifiedCallback(string uniqueHookId, Fu // Invoke all matching callbacks bool callOriginal = true; - foreach (var kvp in registrations) + foreach (KeyValuePair kvp in hookInfo.Registrations) { var registration = kvp.Value; // Check if this callback matches @@ -133,7 +185,7 @@ public HarmonyWrapper.HookCallback CreateUnifiedCallback(string uniqueHookId, Fu /// public bool HasHooks(string uniqueHookId) { - return _instanceHooks.TryGetValue(uniqueHookId, out var dict) && !dict.IsEmpty; + return _harmonyHooks.TryGetValue(uniqueHookId, out var hookInfo) && !hookInfo.Registrations.IsEmpty; } /// @@ -141,9 +193,9 @@ public bool HasHooks(string uniqueHookId) /// public int GetHookCount(string uniqueHookId) { - if (_instanceHooks.TryGetValue(uniqueHookId, out var dict)) + if (_harmonyHooks.TryGetValue(uniqueHookId, out var hookInfo)) { - return dict.Count; + return hookInfo.Registrations.Count; } return 0; } diff --git a/src/ScubaDiver/MsvcDiver.cs b/src/ScubaDiver/MsvcDiver.cs index f5b4d505..420c906f 100644 --- a/src/ScubaDiver/MsvcDiver.cs +++ b/src/ScubaDiver/MsvcDiver.cs @@ -987,15 +987,7 @@ protected override ulong ResolveInstanceAddress(object instance) { 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(); + throw new ArgumentException("Object is not a NativeObject: " + instance.GetType().FullName); } } diff --git a/src/ScubaDiver/MsvcPrimitives/MsvcTypesManager.cs b/src/ScubaDiver/MsvcPrimitives/MsvcTypesManager.cs index 6bb6237c..22b8f785 100644 --- a/src/ScubaDiver/MsvcPrimitives/MsvcTypesManager.cs +++ b/src/ScubaDiver/MsvcPrimitives/MsvcTypesManager.cs @@ -504,138 +504,81 @@ private MsvcTypeStub CreateTypeStub(RichModuleInfo module, Rtti.TypeInfo type) } private MsvcType CreateType(RichModuleInfo module, Rtti.TypeInfo type) - { - Logger.Debug($"[MsvcTypesManager][CreateType] ===== BEGIN CreateType for {type.FullTypeName} ====="); - Logger.Debug($"[MsvcTypesManager][CreateType] Module: {module.ModuleInfo.Name}"); - + { if (!_modulesCache.TryGetValue(module.ModuleInfo.Name, out MsvcModule msvcModule)) { - Logger.Debug($"[MsvcTypesManager][CreateType] Module not in cache, creating new MsvcModule"); msvcModule = new MsvcModule(module.ModuleInfo); _modulesCache[module.ModuleInfo.Name] = msvcModule; - Logger.Debug($"[MsvcTypesManager][CreateType] New MsvcModule created and cached"); - } - else - { - Logger.Debug($"[MsvcTypesManager][CreateType] Module found in cache"); } // Create hollow type - Logger.Debug($"[MsvcTypesManager][CreateType] Creating hollow MsvcType"); MsvcType finalType = new MsvcType(msvcModule, type); - Logger.Debug($"[MsvcTypesManager][CreateType] Hollow MsvcType created"); // Get all exported members of the requested type - Logger.Debug($"[MsvcTypesManager][CreateType] Getting exported type members from _exportsMaster"); List rawMembers = _exportsMaster.GetExportedTypeMembers(module.ModuleInfo, type.NamespaceAndName).ToList(); - Logger.Debug($"[MsvcTypesManager][CreateType] Got {rawMembers.Count} raw members"); List exportedFuncs = rawMembers.OfType().ToList(); - Logger.Debug($"[MsvcTypesManager][CreateType] Found {exportedFuncs.Count} exported functions"); // Collect all vftable addresses and create VftableInfo objects from two sources: // 1. Exported vftables (with UndecoratedExportedField wrappers) - PRIORITIZED // 2. TypeInfo's vftable (if it's a FirstClassTypeInfo) - Only if not exported - Logger.Debug($"[MsvcTypesManager][CreateType] ===== VFTABLE COLLECTION PHASE ====="); List allVftableAddresses = new List(); List allVftableInfos = new List(); // Source 1: Find exported vftables (PRIORITIZED - added first) - Logger.Debug($"[MsvcTypesManager][CreateType] SOURCE 1: Searching for exported vftables"); UndecoratedExportedField[] exportedVftables = rawMembers.OfType() .Where(member => member.UndecoratedName.EndsWith("`vftable'")) .ToArray(); - Logger.Debug($"[MsvcTypesManager][CreateType] Found {exportedVftables.Length} exported vftables"); - foreach (var exportedVftable in exportedVftables) + foreach (UndecoratedExportedField exportedVftable in exportedVftables) { - Logger.Debug($"[MsvcTypesManager][CreateType] Exported vftable: {exportedVftable.UndecoratedName} at 0x{exportedVftable.Address:x}"); allVftableAddresses.Add(exportedVftable.Address); allVftableInfos.Add(new VftableInfo(finalType, exportedVftable)); } // Source 2: Add RTTI vftable addresses (primary + secondary) ONLY if not already exported - Logger.Debug($"[MsvcTypesManager][CreateType] SOURCE 2: Checking for RTTI vftables"); if (type is FirstClassTypeInfo firstClass) { - Logger.Debug($"[MsvcTypesManager][CreateType] Type is FirstClassTypeInfo"); - Logger.Debug($"[MsvcTypesManager][CreateType] Primary vftable address: 0x{firstClass.VftableAddress:x}"); - // Add primary vftable ONLY if not already in the list (i.e., not exported) if (!allVftableAddresses.Contains(firstClass.VftableAddress)) { - Logger.Debug($"[MsvcTypesManager][CreateType] Primary vftable NOT in exported list, adding as non-exported"); allVftableAddresses.Add(firstClass.VftableAddress); allVftableInfos.Add(new VftableInfo(finalType, firstClass.VftableAddress, $"`vftable' (primary, non-exported)")); } - else - { - Logger.Debug($"[MsvcTypesManager][CreateType] Primary vftable already in exported list, skipping"); - } // Add secondary vftables ONLY if not already in the list (i.e., not exported) if (firstClass.SecondaryVftableAddresses != null) { - Logger.Debug($"[MsvcTypesManager][CreateType] Type has {firstClass.SecondaryVftableAddresses.Count()} secondary vftables"); int secondaryIndex = 0; foreach (nuint secondaryVftable in firstClass.SecondaryVftableAddresses) { - Logger.Debug($"[MsvcTypesManager][CreateType] Secondary vftable #{secondaryIndex}: 0x{secondaryVftable:x}"); if (!allVftableAddresses.Contains(secondaryVftable)) { - Logger.Debug($"[MsvcTypesManager][CreateType] NOT in exported list, adding as non-exported"); allVftableAddresses.Add(secondaryVftable); allVftableInfos.Add(new VftableInfo(finalType, secondaryVftable, $"`vftable' (secondary #{secondaryIndex}, non-exported)")); secondaryIndex++; } else { - Logger.Debug($"[MsvcTypesManager][CreateType] Already in exported list, skipping (incrementing counter)"); // Secondary vftable is exported, increment counter anyway for consistent numbering secondaryIndex++; } } } - else - { - Logger.Debug($"[MsvcTypesManager][CreateType] Type has NO secondary vftables"); - } - } - else - { - Logger.Debug($"[MsvcTypesManager][CreateType] Type is NOT FirstClassTypeInfo, skipping RTTI vftables"); } - - Logger.Debug($"[MsvcTypesManager][CreateType] Total vftables collected: {allVftableAddresses.Count}"); - Logger.Debug($"[MsvcTypesManager][CreateType] Setting vftables on finalType"); + // Set all vftables (exported ones first, then non-exported RTTI ones) finalType.SetVftables(allVftableInfos.ToArray()); - Logger.Debug($"[MsvcTypesManager][CreateType] Vftables set on finalType"); // Find all virtual methods (from all vftables) - Logger.Debug($"[MsvcTypesManager][CreateType] ===== VIRTUAL METHODS PARSING PHASE ====="); List virtualFuncs = new List(); - Logger.Debug($"[MsvcTypesManager][CreateType] Getting module exports"); MsvcModuleExports moduleExports = GetOrCreateModuleExports(module.ModuleInfo); - Logger.Debug($"[MsvcTypesManager][CreateType] Module exports retrieved"); - - Logger.Debug($"[MsvcTypesManager][CreateType] About to parse {allVftableAddresses.Count} vftable(s)"); for (int i = 0; i < allVftableAddresses.Count; i++) { - nuint vftableAddress = allVftableAddresses[i]; - Logger.Debug($"[MsvcTypesManager][CreateType] ----- Parsing vftable {i + 1}/{allVftableAddresses.Count}: 0x{vftableAddress:x} -----"); - + nuint vftableAddress = allVftableAddresses[i]; try - { - Logger.Debug($"[MsvcTypesManager][CreateType] Calling VftableParser.AnalyzeVftable"); - Logger.Debug($"[MsvcTypesManager][CreateType] ProcessHandle: 0x{_tricksterWrapper.GetProcessHandle().Value:x}"); - Logger.Debug($"[MsvcTypesManager][CreateType] Module: {module.ModuleInfo.Name}"); - Logger.Debug($"[MsvcTypesManager][CreateType] Type: {type.FullTypeName}"); - Logger.Debug($"[MsvcTypesManager][CreateType] VftableAddress: 0x{vftableAddress:x}"); - Logger.Debug($"[MsvcTypesManager][CreateType] TypesManager: this (non-null)"); - Logger.Debug($"[MsvcTypesManager][CreateType] Verbose: true"); - + { // ✅ Pass 'this' to VftableParser to enable RTTI-based vftable detection List methodsFromThisVftable = VftableParser.AnalyzeVftable( _tricksterWrapper.GetProcessHandle(), @@ -643,27 +586,17 @@ private MsvcType CreateType(RichModuleInfo module, Rtti.TypeInfo type) moduleExports, type, vftableAddress, - typesManager: this, - verbose: true); - - Logger.Debug($"[MsvcTypesManager][CreateType] VftableParser.AnalyzeVftable returned {methodsFromThisVftable.Count} methods"); + typesManager: this); if (methodsFromThisVftable.Count > 0) { - Logger.Debug($"[MsvcTypesManager][CreateType] Methods from vftable 0x{vftableAddress:x}:"); for (int j = 0; j < methodsFromThisVftable.Count; j++) { var method = methodsFromThisVftable[j]; - Logger.Debug($"[MsvcTypesManager][CreateType] [{j}] {method.UndecoratedFullName} at 0x{method.Address:x}"); } } - else - { - Logger.Debug($"[MsvcTypesManager][CreateType] WARNING: No methods found for vftable 0x{vftableAddress:x}"); - } virtualFuncs.AddRange(methodsFromThisVftable); - Logger.Debug($"[MsvcTypesManager][CreateType] Methods added to virtualFuncs. Total so far: {virtualFuncs.Count}"); } catch (Exception ex) { @@ -679,32 +612,18 @@ private MsvcType CreateType(RichModuleInfo module, Rtti.TypeInfo type) Logger.Debug($"[MsvcTypesManager][CreateType] Continuing to next vftable..."); } } - - Logger.Debug($"[MsvcTypesManager][CreateType] All vftables parsed. Total virtual functions found: {virtualFuncs.Count}"); // Remove duplicates - the methods which are both virtual and exported - Logger.Debug($"[MsvcTypesManager][CreateType] Removing duplicates from virtualFuncs"); int beforeDistinct = virtualFuncs.Count; virtualFuncs = virtualFuncs.Distinct().ToList(); - Logger.Debug($"[MsvcTypesManager][CreateType] After Distinct(): {virtualFuncs.Count} (removed {beforeDistinct - virtualFuncs.Count} duplicates)"); int beforeExportedFilter = virtualFuncs.Count; virtualFuncs = virtualFuncs.Where(method => !exportedFuncs.Contains(method)).ToList(); - Logger.Debug($"[MsvcTypesManager][CreateType] After removing exported funcs: {virtualFuncs.Count} (removed {beforeExportedFilter - virtualFuncs.Count} that were also exported)"); - - // Finalize methods - Logger.Debug($"[MsvcTypesManager][CreateType] ===== FINALIZING METHODS ====="); - Logger.Debug($"[MsvcTypesManager][CreateType] Exported functions: {exportedFuncs.Count}"); - Logger.Debug($"[MsvcTypesManager][CreateType] Virtual functions (non-exported): {virtualFuncs.Count}"); IEnumerable allFuncs = exportedFuncs.Concat(virtualFuncs); MsvcMethod[] msvcMethods = allFuncs.Select(func => new MsvcMethod(finalType, func)).ToArray(); - Logger.Debug($"[MsvcTypesManager][CreateType] Total methods created: {msvcMethods.Length}"); finalType.SetMethods(msvcMethods); - Logger.Debug($"[MsvcTypesManager][CreateType] Methods set on finalType"); - - Logger.Debug($"[MsvcTypesManager][CreateType] ===== END CreateType for {type.FullTypeName} ====="); return finalType; } diff --git a/src/ScubaDiver/MsvcPrimitives/VftableParser.cs b/src/ScubaDiver/MsvcPrimitives/VftableParser.cs index bb38fabb..c4862ebe 100644 --- a/src/ScubaDiver/MsvcPrimitives/VftableParser.cs +++ b/src/ScubaDiver/MsvcPrimitives/VftableParser.cs @@ -25,37 +25,20 @@ public static List AnalyzeVftable( MsvcModuleExports moduleExports, TypeInfo type, nuint vftableAddress, - MsvcTypesManager typesManager = null, - bool verbose = false) + MsvcTypesManager typesManager = null) { - if (verbose) - Logger.Debug("[VftableParser][AnalyzeVftable] Called"); - - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Parameters: process=0x{process.Value:x}, module={module.ModuleInfo.Name}, type={type.FullTypeName}, vftableAddress=0x{vftableAddress:x}, typesManager={(typesManager != null ? "provided" : "null")}"); - - if (verbose) - Logger.Debug("[VftableParser][AnalyzeVftable] Getting .TEXT sections from module"); + Logger.Debug($"[VftableParser][AnalyzeVftable] Analyzing vftable for type {type.FullTypeName} in module {module.ModuleInfo.Name}"); IReadOnlyList textSections = module.GetSections(".TEXT").ToList(); - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Found {textSections.Count} .TEXT sections"); - List virtualMethods = new List(); - if (verbose) - Logger.Debug("[VftableParser][AnalyzeVftable] Creating RttiScanner"); using var scanner = new RttiScanner( process, module.ModuleInfo.BaseAddress, module.ModuleInfo.Size, module.Sections ); - if (verbose) - Logger.Debug("[VftableParser][AnalyzeVftable] RttiScanner created successfully"); bool nextVftableFound = false; - if (verbose) - Logger.Debug("[VftableParser][AnalyzeVftable] Starting vftable iteration (max 100 entries)"); // Assuming at most 99 functions in the vftable. for (int i = 0; i < 100; i++) @@ -64,20 +47,12 @@ public static List AnalyzeVftable( // (Not checking the first one, since it's OUR vftable) nuint nextEntryAddress = (nuint)(vftableAddress + (nuint)(i * IntPtr.Size)); - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Iteration {i}: nextEntryAddress = 0x{nextEntryAddress:x}"); - if (i != 0) { // Hybrid detection: Check both exports AND RTTI cache bool isVftableByExports = moduleExports.TryGetVftable(nextEntryAddress, out _); bool isVftableByCache = typesManager?.IsKnownVftableAddress(nextEntryAddress) ?? false; - if (verbose) - { - Logger.Debug($"[VftableParser][AnalyzeVftable] Iteration {i}: isVftableByExports={isVftableByExports}, isVftableByCache={isVftableByCache}"); - } - // ✅ NEW: Check both exports AND cache if (isVftableByExports || isVftableByCache) { @@ -85,50 +60,27 @@ public static List AnalyzeVftable( if (isVftableByExports && isVftableByCache) detectionMethod = "both exports and cache"; - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Iteration {i}: Found another vftable at this address (detected via {detectionMethod}), stopping iteration"); nextVftableFound = true; break; } } - // Read next vftable entry - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Iteration {i}: Reading vftable entry at 0x{nextEntryAddress:x}"); - + // Read next vftable entry bool readNext = scanner.TryRead(nextEntryAddress, out nuint entryContent); if (!readNext) { - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Iteration {i}: Failed to read vftable entry, stopping iteration"); break; } - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Iteration {i}: Read entry content: 0x{entryContent:x}"); - - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Iteration {i}: Attempting to resolve function from module exports"); - if (!moduleExports.TryGetFunc(entryContent, out UndecoratedFunction undecFunc)) { - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Iteration {i}: Function not found in exports, checking if it points to .TEXT section"); - // Check for anon-exported method of our type. We should still add it to the list. if (PointsToTextSection(textSections, entryContent)) - { - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Iteration {i}: Entry points to .TEXT section, creating anonymous function"); - + { nuint subRelativeOffset = (nuint)(entryContent - module.ModuleInfo.BaseAddress); string trimmedHex = subRelativeOffset.ToString("x16").TrimStart('0'); - string subName = $"sub_{trimmedHex}"; - - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Iteration {i}: Creating UndecoratedInternalFunction: {subName} at offset 0x{subRelativeOffset:x}"); - + string subName = $"sub_{trimmedHex}"; undecFunc = new UndecoratedInternalFunction( moduleInfo: module.ModuleInfo, decoratedName: subName, @@ -138,41 +90,23 @@ public static List AnalyzeVftable( numArgs: 1, // TODO: This is 99% wrong retType: "void*" // TODO: Also XX% wrong ); - - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Iteration {i}: Anonymous function created successfully"); } else { - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Iteration {i}: Entry does not point to .TEXT section, skipping"); continue; } } - else - { - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Iteration {i}: Function resolved from exports: {undecFunc.UndecoratedFullName} at 0x{undecFunc.Address:x}"); - } // Found a new virtual method for our type! virtualMethods.Add(undecFunc); - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Iteration {i}: Added function to virtual methods list. Total count: {virtualMethods.Count}"); } - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Iteration completed. nextVftableFound={nextVftableFound}, virtualMethods.Count={virtualMethods.Count}"); - if (nextVftableFound) { - if (verbose) - Logger.Debug($"[VftableParser][AnalyzeVftable] Returning {virtualMethods.Count} virtual methods (stopped at next vftable)"); return virtualMethods; } - if (verbose) - Logger.Debug("[VftableParser][AnalyzeVftable] Returning empty list (no next vftable found)"); + Logger.Debug("[VftableParser][AnalyzeVftable] Returning empty list (no next vftable found)"); return new(); } diff --git a/src/ScubaDiver/RegisteredMethodHookInfo.cs b/src/ScubaDiver/RegisteredMethodHookInfo.cs index c78e0eeb..a5fb57bb 100644 --- a/src/ScubaDiver/RegisteredMethodHookInfo.cs +++ b/src/ScubaDiver/RegisteredMethodHookInfo.cs @@ -1,29 +1,24 @@ -using System; -using System.Net; +using System.Net; +using ScubaDiver.Hooking; namespace ScubaDiver { public class RegisteredManagedMethodHookInfo { /// - /// The patch callback that was registered on the method + /// Hook callback that was registered with HookingCenter /// - public Delegate RegisteredProxy { get; set; } + public HarmonyWrapper.HookCallback RegisteredProxy { get; set; } /// - /// The IP Endpoint listening for invocations + /// Endpoint listening for invocations of the hook /// public IPEndPoint Endpoint { get; set; } /// - /// The method that was hooked - /// - public Action UnhookAction{ get; set; } - - /// - /// The unique identifier for this hook (method + position) + /// Unique identifier for this method hook (method + position) + /// Used to coordinate with HookingCenter for unhooking /// public string UniqueHookId { get; set; } - } } \ No newline at end of file From 1c45f26a765ea791b4734fdc24ebf844bcbf4079 Mon Sep 17 00:00:00 2001 From: RemoteNet Date: Sat, 6 Dec 2025 20:05:14 +0200 Subject: [PATCH 08/14] [MsvcDiver] Reduce vftable leak (not gone) --- src/ScubaDiver/DiverBase.cs | 5 -- src/ScubaDiver/MsvcDiver.cs | 7 +- .../MsvcPrimitives/MsvcTypesManager.cs | 83 ++++++++----------- src/ScubaDiver/MsvcPrimitives/Trickster.cs | 10 +++ .../MsvcPrimitives/TypeDumpFactory.cs | 2 +- .../MsvcPrimitives/VftableParser.cs | 12 +-- 6 files changed, 59 insertions(+), 60 deletions(-) diff --git a/src/ScubaDiver/DiverBase.cs b/src/ScubaDiver/DiverBase.cs index 2e769ebb..bf8da2a0 100644 --- a/src/ScubaDiver/DiverBase.cs +++ b/src/ScubaDiver/DiverBase.cs @@ -143,11 +143,6 @@ private void HandleDispatchedRequest(object obj, ScubaDiverMessage request) string body; if (_responseBodyCreators.TryGetValue(request.UrlAbsolutePath, out var respBodyGenerator)) { - Logger.Debug($"[DiverBase] Handling request for {request.UrlAbsolutePath} (delaying)"); - // Delaying execution by 5000 ms - Thread.Sleep(5000); - Logger.Debug($"[DiverBase] Handling request for {request.UrlAbsolutePath} ... GO!"); - try { body = respBodyGenerator(request); diff --git a/src/ScubaDiver/MsvcDiver.cs b/src/ScubaDiver/MsvcDiver.cs index 420c906f..c69ddc8d 100644 --- a/src/ScubaDiver/MsvcDiver.cs +++ b/src/ScubaDiver/MsvcDiver.cs @@ -306,7 +306,12 @@ protected override string MakeTypesResponse(ScubaDiverMessage req) ImportingModule = importerModule }; - IEnumerable matchingTypes = _typesManager.GetTypes(msvcModuleFilter, typeFilterPredicate); + IReadOnlyList matchingTypes = _typesManager.GetTypes(msvcModuleFilter, typeFilterPredicate); + + Logger.Debug($"[MsvcDiver] Found GetTypes finished with {matchingTypes.Count} results, waiting 10 seconds"); + Thread.Sleep(TimeSpan.FromSeconds(10)); + Logger.Debug($"[MsvcDiver] Found GetTypes finished with {matchingTypes.Count} results ... GO!"); + List types = new(); foreach (MsvcTypeStub typeStub in matchingTypes) diff --git a/src/ScubaDiver/MsvcPrimitives/MsvcTypesManager.cs b/src/ScubaDiver/MsvcPrimitives/MsvcTypesManager.cs index 22b8f785..886d3fdc 100644 --- a/src/ScubaDiver/MsvcPrimitives/MsvcTypesManager.cs +++ b/src/ScubaDiver/MsvcPrimitives/MsvcTypesManager.cs @@ -7,6 +7,7 @@ using System.Globalization; using System.Linq; using System.Reflection; +using System.Threading; namespace ScubaDiver { @@ -36,7 +37,7 @@ public interface ISymbolBackedMember public class VftableInfo : FieldInfo, ISymbolBackedMember { private MsvcType _type; - private nuint _address; + private nuint _xoredAddress; private string _name; public UndecoratedExportedField ExportedField { get; set; } @@ -47,21 +48,21 @@ public VftableInfo(MsvcType msvcType, UndecoratedExportedField symbol) { _type = msvcType; ExportedField = symbol; - _address = symbol.Address; + _xoredAddress = symbol.Address; _name = symbol.UndecoratedName; } // Constructor for RTTI vftables (not exported) - public VftableInfo(MsvcType msvcType, nuint vftableAddress, string name = null) + public VftableInfo(MsvcType msvcType, nuint xoredVftableAddress, string name = null) { _type = msvcType; ExportedField = null; - _address = vftableAddress; - _name = name ?? $"`vftable' (at 0x{vftableAddress:x})"; + _xoredAddress = xoredVftableAddress; + _name = name ?? $"`vftable' (at 0x{xoredVftableAddress ^ FirstClassTypeInfo.XorMask:x})"; } public override string Name => _name; - public ulong Address => (ulong)_address; + public ulong Address => _xoredAddress ^ FirstClassTypeInfo.XorMask; public override Type DeclaringType => _type; public override object GetValue(object obj) => Address; @@ -258,10 +259,10 @@ public class MsvcTypesManager // Vftables cache. // Dictionary _vftablesCache = new(); - + // All known vftable addresses from RTTI-discovered types // Populated during GetTypes() to enable boundary detection in VftableParser - private HashSet _allKnownVftableAddresses = new HashSet(); + private HashSet _allKnownXoredVftableAddresses = new HashSet(); private Dictionary _exportsCache; @@ -311,7 +312,7 @@ internal void RefreshIfNeeded() // Clear vftable cache on refresh since runtime may have changed lock (_getTypesLock) { - _allKnownVftableAddresses.Clear(); + _allKnownXoredVftableAddresses.Clear(); } } } @@ -326,37 +327,25 @@ private void EnsureVftableCachePopulated() lock (_getTypesLock) { // If cache is already populated, no work needed - if (_allKnownVftableAddresses.Count > 0) + if (_allKnownXoredVftableAddresses.Count > 0) return; // Get all modules (with refresh if needed) List modules = GetUndecoratedModules(); - - int totalVftablesAdded = 0; - // Populate cache from all FirstClassTypeInfo instances across all modules foreach (UndecoratedModule undecoratedModule in modules) - { - int moduleVftableCount = 0; - + { foreach (Rtti.TypeInfo type in undecoratedModule.Types) { - if (type is FirstClassTypeInfo firstClass) + if (type is not FirstClassTypeInfo firstClass) + continue; + + _allKnownXoredVftableAddresses.Add(firstClass.XoredVftableAddress); + + // Also add secondary vftables (multiple inheritance) + foreach (nuint xoredSecondaryVftable in firstClass.XoredSecondaryVftableAddresses ?? Enumerable.Empty()) { - _allKnownVftableAddresses.Add(firstClass.VftableAddress); - moduleVftableCount++; - totalVftablesAdded++; - - // Also add secondary vftables (multiple inheritance) - if (firstClass.SecondaryVftableAddresses != null) - { - foreach (nuint secondaryVftable in firstClass.SecondaryVftableAddresses) - { - _allKnownVftableAddresses.Add(secondaryVftable); - moduleVftableCount++; - totalVftablesAdded++; - } - } + _allKnownXoredVftableAddresses.Add(xoredSecondaryVftable); } } } @@ -367,17 +356,17 @@ private void EnsureVftableCachePopulated() /// Checks if the given address is a known vftable address from RTTI-discovered types. /// Automatically populates the cache on first call if needed. /// - /// The address to check + /// The address to check /// Enable verbose logging for debugging /// True if this address is a known vftable - public bool IsKnownVftableAddress(nuint address) + public bool IsKnownVftableAddress(nuint xoredVftableAddress) { // Ensure cache is populated before querying EnsureVftableCachePopulated(); lock (_getTypesLock) { - return _allKnownVftableAddresses.Contains(address); + return _allKnownXoredVftableAddresses.Contains(xoredVftableAddress); } } @@ -523,7 +512,7 @@ private MsvcType CreateType(RichModuleInfo module, Rtti.TypeInfo type) // 1. Exported vftables (with UndecoratedExportedField wrappers) - PRIORITIZED // 2. TypeInfo's vftable (if it's a FirstClassTypeInfo) - Only if not exported - List allVftableAddresses = new List(); + List allXoredVftableAddresses = new List(); List allVftableInfos = new List(); // Source 1: Find exported vftables (PRIORITIZED - added first) @@ -533,7 +522,7 @@ private MsvcType CreateType(RichModuleInfo module, Rtti.TypeInfo type) foreach (UndecoratedExportedField exportedVftable in exportedVftables) { - allVftableAddresses.Add(exportedVftable.Address); + allXoredVftableAddresses.Add(exportedVftable.XoredAddress); allVftableInfos.Add(new VftableInfo(finalType, exportedVftable)); } @@ -541,22 +530,22 @@ private MsvcType CreateType(RichModuleInfo module, Rtti.TypeInfo type) if (type is FirstClassTypeInfo firstClass) { // Add primary vftable ONLY if not already in the list (i.e., not exported) - if (!allVftableAddresses.Contains(firstClass.VftableAddress)) + if (!allXoredVftableAddresses.Contains(firstClass.XoredVftableAddress)) { - allVftableAddresses.Add(firstClass.VftableAddress); - allVftableInfos.Add(new VftableInfo(finalType, firstClass.VftableAddress, $"`vftable' (primary, non-exported)")); + allXoredVftableAddresses.Add(firstClass.XoredVftableAddress); + allVftableInfos.Add(new VftableInfo(finalType, firstClass.XoredVftableAddress, $"`vftable' (primary, non-exported)")); } // Add secondary vftables ONLY if not already in the list (i.e., not exported) if (firstClass.SecondaryVftableAddresses != null) { int secondaryIndex = 0; - foreach (nuint secondaryVftable in firstClass.SecondaryVftableAddresses) + foreach (nuint xoredSecondaryVftable in firstClass.XoredSecondaryVftableAddresses) { - if (!allVftableAddresses.Contains(secondaryVftable)) + if (!allXoredVftableAddresses.Contains(xoredSecondaryVftable)) { - allVftableAddresses.Add(secondaryVftable); - allVftableInfos.Add(new VftableInfo(finalType, secondaryVftable, $"`vftable' (secondary #{secondaryIndex}, non-exported)")); + allXoredVftableAddresses.Add(xoredSecondaryVftable); + allVftableInfos.Add(new VftableInfo(finalType, xoredSecondaryVftable, $"`vftable' (secondary #{secondaryIndex}, non-exported)")); secondaryIndex++; } else @@ -574,9 +563,9 @@ private MsvcType CreateType(RichModuleInfo module, Rtti.TypeInfo type) // Find all virtual methods (from all vftables) List virtualFuncs = new List(); MsvcModuleExports moduleExports = GetOrCreateModuleExports(module.ModuleInfo); - for (int i = 0; i < allVftableAddresses.Count; i++) + for (int i = 0; i < allXoredVftableAddresses.Count; i++) { - nuint vftableAddress = allVftableAddresses[i]; + nuint xoredVftableAddress = allXoredVftableAddresses[i]; try { // ✅ Pass 'this' to VftableParser to enable RTTI-based vftable detection @@ -585,7 +574,7 @@ private MsvcType CreateType(RichModuleInfo module, Rtti.TypeInfo type) module, moduleExports, type, - vftableAddress, + xoredVftableAddress, typesManager: this); if (methodsFromThisVftable.Count > 0) @@ -600,7 +589,7 @@ private MsvcType CreateType(RichModuleInfo module, Rtti.TypeInfo type) } catch (Exception ex) { - Logger.Debug($"[MsvcTypesManager][CreateType] EXCEPTION while parsing vftable 0x{vftableAddress:x}"); + Logger.Debug($"[MsvcTypesManager][CreateType] EXCEPTION while parsing vftable 0x{xoredVftableAddress:x}"); Logger.Debug($"[MsvcTypesManager][CreateType] Exception type: {ex.GetType().Name}"); Logger.Debug($"[MsvcTypesManager][CreateType] Exception message: {ex.Message}"); Logger.Debug($"[MsvcTypesManager][CreateType] Exception stack trace: {ex.StackTrace}"); diff --git a/src/ScubaDiver/MsvcPrimitives/Trickster.cs b/src/ScubaDiver/MsvcPrimitives/Trickster.cs index 83a7a70f..61181a62 100644 --- a/src/ScubaDiver/MsvcPrimitives/Trickster.cs +++ b/src/ScubaDiver/MsvcPrimitives/Trickster.cs @@ -65,6 +65,9 @@ public Trickster(Process process) ModuleInfo module = richModule.ModuleInfo; IReadOnlyList sections = richModule.Sections; + + // Used to FORCE the change of the vftable var value in the loop + nuint dummySum = 0; using (RttiScanner processMemory = new(_processHandle, module.BaseAddress, module.Size, sections)) { nuint inc = (nuint)(_is32Bit ? 4 : 8); @@ -92,9 +95,16 @@ public Trickster(Process process) list.Add(new FirstClassTypeInfo(module.Name, namespaceName, typeName, possibleVftableAddress, offset)); } + + // Destroy false positives by moving to the next possible vftable address + possibleVftableAddress ^= 0xa5a5a5a5; + dummySum += possibleVftableAddress; // So the compiler doesn't optimize the above line out } } + // Use the dummySum to avoid compiler optimizations + dummySum.ToString(); + return (typeInfoSeen, list); } diff --git a/src/ScubaDiver/MsvcPrimitives/TypeDumpFactory.cs b/src/ScubaDiver/MsvcPrimitives/TypeDumpFactory.cs index be1e8acf..ff523f76 100644 --- a/src/ScubaDiver/MsvcPrimitives/TypeDumpFactory.cs +++ b/src/ScubaDiver/MsvcPrimitives/TypeDumpFactory.cs @@ -65,7 +65,7 @@ private static void DeconstructRttiType(MsvcType type, { DecoratedName = vftable.Name, UndecoratedFullName = vftable.Name, - XoredAddress = (long)(vftable.Address ^ FirstClassTypeInfo.XorMask), + XoredAddress = (long)(vftable.Address), }); } continue; diff --git a/src/ScubaDiver/MsvcPrimitives/VftableParser.cs b/src/ScubaDiver/MsvcPrimitives/VftableParser.cs index c4862ebe..e3fc363b 100644 --- a/src/ScubaDiver/MsvcPrimitives/VftableParser.cs +++ b/src/ScubaDiver/MsvcPrimitives/VftableParser.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using ScubaDiver.API.Interactions.Dumps; using ScubaDiver; @@ -24,7 +24,7 @@ public static List AnalyzeVftable( RichModuleInfo module, MsvcModuleExports moduleExports, TypeInfo type, - nuint vftableAddress, + nuint xoredVftableAddress, MsvcTypesManager typesManager = null) { Logger.Debug($"[VftableParser][AnalyzeVftable] Analyzing vftable for type {type.FullTypeName} in module {module.ModuleInfo.Name}"); @@ -39,19 +39,19 @@ public static List AnalyzeVftable( ); bool nextVftableFound = false; - + bool nullTerminatorFound = false; // Assuming at most 99 functions in the vftable. for (int i = 0; i < 100; i++) { // Check if this address is some other type's vftable address. // (Not checking the first one, since it's OUR vftable) - nuint nextEntryAddress = (nuint)(vftableAddress + (nuint)(i * IntPtr.Size)); + nuint nextEntryAddress = (nuint)((xoredVftableAddress ^ FirstClassTypeInfo.XorMask) + (nuint)(i * IntPtr.Size)); if (i != 0) { // Hybrid detection: Check both exports AND RTTI cache bool isVftableByExports = moduleExports.TryGetVftable(nextEntryAddress, out _); - bool isVftableByCache = typesManager?.IsKnownVftableAddress(nextEntryAddress) ?? false; + bool isVftableByCache = typesManager?.IsKnownVftableAddress(nextEntryAddress ^ FirstClassTypeInfo.XorMask) ?? false; // ✅ NEW: Check both exports AND cache if (isVftableByExports || isVftableByCache) @@ -101,7 +101,7 @@ public static List AnalyzeVftable( virtualMethods.Add(undecFunc); } - if (nextVftableFound) + if (nextVftableFound || nullTerminatorFound) { return virtualMethods; } From 36744e4867f43a19fb0b35dc3e7a3fa7bea4bd7e Mon Sep 17 00:00:00 2001 From: RemoteNet Date: Sat, 6 Dec 2025 20:05:37 +0200 Subject: [PATCH 09/14] [VFTableParser] Break also on null terminators --- src/ScubaDiver/MsvcPrimitives/VftableParser.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ScubaDiver/MsvcPrimitives/VftableParser.cs b/src/ScubaDiver/MsvcPrimitives/VftableParser.cs index e3fc363b..d94ae773 100644 --- a/src/ScubaDiver/MsvcPrimitives/VftableParser.cs +++ b/src/ScubaDiver/MsvcPrimitives/VftableParser.cs @@ -72,6 +72,12 @@ public static List AnalyzeVftable( break; } + if (entryContent == 0) + { + nullTerminatorFound = true; + break; + } + if (!moduleExports.TryGetFunc(entryContent, out UndecoratedFunction undecFunc)) { // Check for anon-exported method of our type. We should still add it to the list. From afb512651b56b692259b3dffdb53078ea8bb62da Mon Sep 17 00:00:00 2001 From: RemoteNet Date: Sat, 6 Dec 2025 20:06:19 +0200 Subject: [PATCH 10/14] [VFTableParser] Create fake subroutines with 10 params Should magically "auto-forward" all (most?) arguments. Using just 1 was a bad idea and caused corruptions. --- src/ScubaDiver/MsvcPrimitives/VftableParser.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ScubaDiver/MsvcPrimitives/VftableParser.cs b/src/ScubaDiver/MsvcPrimitives/VftableParser.cs index d94ae773..529d8767 100644 --- a/src/ScubaDiver/MsvcPrimitives/VftableParser.cs +++ b/src/ScubaDiver/MsvcPrimitives/VftableParser.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using ScubaDiver.API.Interactions.Dumps; using ScubaDiver; @@ -93,7 +93,7 @@ public static List AnalyzeVftable( undecoratedFullName: $"{type.NamespaceAndName}::{subName}", undecoratedName: subName, address: entryContent, - numArgs: 1, // TODO: This is 99% wrong + numArgs: 10, // TODO: This is 99% wrong, but I think it's ok in Microsoft's "x64 calling convention" to have more args than needed. retType: "void*" // TODO: Also XX% wrong ); } From f3761ddac56647ed12c1f6dcfa8259f10205875d Mon Sep 17 00:00:00 2001 From: RemoteNet Date: Sat, 27 Dec 2025 09:27:52 +0200 Subject: [PATCH 11/14] Remove logs --- src/ScubaDiver/MsvcDiver.cs | 5 ----- src/ScubaDiver/MsvcPrimitives/MsvcTypesManager.cs | 1 - src/ScubaDiver/MsvcPrimitives/VftableParser.cs | 2 -- 3 files changed, 8 deletions(-) diff --git a/src/ScubaDiver/MsvcDiver.cs b/src/ScubaDiver/MsvcDiver.cs index c69ddc8d..752b9cf6 100644 --- a/src/ScubaDiver/MsvcDiver.cs +++ b/src/ScubaDiver/MsvcDiver.cs @@ -308,11 +308,6 @@ protected override string MakeTypesResponse(ScubaDiverMessage req) IReadOnlyList matchingTypes = _typesManager.GetTypes(msvcModuleFilter, typeFilterPredicate); - Logger.Debug($"[MsvcDiver] Found GetTypes finished with {matchingTypes.Count} results, waiting 10 seconds"); - Thread.Sleep(TimeSpan.FromSeconds(10)); - Logger.Debug($"[MsvcDiver] Found GetTypes finished with {matchingTypes.Count} results ... GO!"); - - List types = new(); foreach (MsvcTypeStub typeStub in matchingTypes) { diff --git a/src/ScubaDiver/MsvcPrimitives/MsvcTypesManager.cs b/src/ScubaDiver/MsvcPrimitives/MsvcTypesManager.cs index 886d3fdc..1f9533cf 100644 --- a/src/ScubaDiver/MsvcPrimitives/MsvcTypesManager.cs +++ b/src/ScubaDiver/MsvcPrimitives/MsvcTypesManager.cs @@ -485,7 +485,6 @@ private MsvcTypeStub CreateTypeStub(RichModuleInfo module, Rtti.TypeInfo type) { Func upgrader = () => { - Logger.Debug($"[MsvcTypesManager] Upgrading type {type.FullTypeName}"); return CreateType(module, type); }; MsvcTypeStub newType = new MsvcTypeStub(type, new Lazy(upgrader)); diff --git a/src/ScubaDiver/MsvcPrimitives/VftableParser.cs b/src/ScubaDiver/MsvcPrimitives/VftableParser.cs index 529d8767..d37bc93e 100644 --- a/src/ScubaDiver/MsvcPrimitives/VftableParser.cs +++ b/src/ScubaDiver/MsvcPrimitives/VftableParser.cs @@ -27,7 +27,6 @@ public static List AnalyzeVftable( nuint xoredVftableAddress, MsvcTypesManager typesManager = null) { - Logger.Debug($"[VftableParser][AnalyzeVftable] Analyzing vftable for type {type.FullTypeName} in module {module.ModuleInfo.Name}"); IReadOnlyList textSections = module.GetSections(".TEXT").ToList(); List virtualMethods = new List(); @@ -112,7 +111,6 @@ public static List AnalyzeVftable( return virtualMethods; } - Logger.Debug("[VftableParser][AnalyzeVftable] Returning empty list (no next vftable found)"); return new(); } From 1957f01aecc993951f113cabf264df79c0898aba Mon Sep 17 00:00:00 2001 From: RemoteNet Date: Sat, 27 Dec 2025 09:29:58 +0200 Subject: [PATCH 12/14] [DRO] Enhance ToString --- src/RemoteNET/DynamicRemoteObject.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RemoteNET/DynamicRemoteObject.cs b/src/RemoteNET/DynamicRemoteObject.cs index b91c0a05..7fa36217 100644 --- a/src/RemoteNET/DynamicRemoteObject.cs +++ b/src/RemoteNET/DynamicRemoteObject.cs @@ -596,7 +596,7 @@ public override string ToString() } // No "ToString" method, target is not a .NET object - return __type.ToString(); + return __type.ToString() + $" (0x{this.__ro.RemoteToken:x16})"; } public override int GetHashCode() From 1387e8be3a7bac6d66636245e83c7ba25365a102 Mon Sep 17 00:00:00 2001 From: RemoteNet Date: Sat, 27 Dec 2025 09:31:03 +0200 Subject: [PATCH 13/14] [RTTI] Store IsStatic for proxy Ctros --- .../Internal/Reflection/Rtti/RemoteRttiConstructorInfo.cs | 6 ++++-- src/RemoteNET/Internal/Reflection/Rtti/RttiTypesFactory.cs | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/RemoteNET/Internal/Reflection/Rtti/RemoteRttiConstructorInfo.cs b/src/RemoteNET/Internal/Reflection/Rtti/RemoteRttiConstructorInfo.cs index 303e9164..21b85e75 100644 --- a/src/RemoteNET/Internal/Reflection/Rtti/RemoteRttiConstructorInfo.cs +++ b/src/RemoteNET/Internal/Reflection/Rtti/RemoteRttiConstructorInfo.cs @@ -15,7 +15,8 @@ public class RemoteRttiConstructorInfo : ConstructorInfo, IRttiMethodBase protected LazyRemoteParameterResolver[] _lazyParamInfosImpl; public LazyRemoteParameterResolver[] LazyParamInfos => _lazyParamInfosImpl; - public override MethodAttributes Attributes => throw new NotImplementedException(); + private MethodAttributes _attributes; + public override MethodAttributes Attributes => _attributes; public override RuntimeMethodHandle MethodHandle => throw new NotImplementedException(); @@ -29,10 +30,11 @@ public class RemoteRttiConstructorInfo : ConstructorInfo, IRttiMethodBase private RemoteApp App => (DeclaringType as RemoteRttiType)?.App; - public RemoteRttiConstructorInfo(LazyRemoteTypeResolver declaringType, LazyRemoteParameterResolver[] paramInfos) + public RemoteRttiConstructorInfo(LazyRemoteTypeResolver declaringType, LazyRemoteParameterResolver[] paramInfos, MethodAttributes attributes) { _lazyDeclaringType = declaringType; _lazyParamInfosImpl = paramInfos; + _attributes = attributes; } public override object[] GetCustomAttributes(bool inherit) diff --git a/src/RemoteNET/Internal/Reflection/Rtti/RttiTypesFactory.cs b/src/RemoteNET/Internal/Reflection/Rtti/RttiTypesFactory.cs index d383e5c6..dff19a2b 100644 --- a/src/RemoteNET/Internal/Reflection/Rtti/RttiTypesFactory.cs +++ b/src/RemoteNET/Internal/Reflection/Rtti/RttiTypesFactory.cs @@ -202,12 +202,13 @@ public static MethodBase AddFunctionImpl(RemoteApp app, string moduleName, TypeD func.ReturnTypeFullName, func.ReturnTypeName); + MethodAttributes attributes = (MethodAttributes)func.Attributes; if (areConstructors) { // TODO: RTTI ConstructorsType LazyRemoteTypeResolver declaringTypeResolver = new LazyRemoteTypeResolver(declaringType); RemoteRttiConstructorInfo ctorInfo = - new RemoteRttiConstructorInfo(declaringTypeResolver, parameters.ToArray()); + new RemoteRttiConstructorInfo(declaringTypeResolver, parameters.ToArray(), attributes); declaringType.AddConstructor(ctorInfo); return ctorInfo; } @@ -229,7 +230,7 @@ public static MethodBase AddFunctionImpl(RemoteApp app, string moduleName, TypeD RemoteRttiMethodInfo methodInfo = new RemoteRttiMethodInfo(declaringTypeResolver, returnTypeResolver, func.Name, mangledName, - parameters.ToArray(), (MethodAttributes)func.Attributes); + parameters.ToArray(), attributes); declaringType.AddMethod(methodInfo); return methodInfo; } From 9bf074b5b4c9324f15351a335184bafaf105dc99 Mon Sep 17 00:00:00 2001 From: RemoteNet Date: Sat, 27 Dec 2025 09:31:26 +0200 Subject: [PATCH 14/14] [Hooking] Fix first arg recognition (static or instance) --- src/RemoteNET/RemoteHookingManager.cs | 13 +++ src/ScubaDiver/DynamicMethodGenerator.cs | 112 ++++++++++++++--------- 2 files changed, 83 insertions(+), 42 deletions(-) diff --git a/src/RemoteNET/RemoteHookingManager.cs b/src/RemoteNET/RemoteHookingManager.cs index 7aabdff2..8f0b38e1 100644 --- a/src/RemoteNET/RemoteHookingManager.cs +++ b/src/RemoteNET/RemoteHookingManager.cs @@ -167,6 +167,19 @@ dynamic DecodeOora(ObjectOrRemoteAddress oora) { try { + if (_app is UnmanagedRemoteApp ura) + { + // {[ObjectOrRemoteAddress] RemoteAddress: 0x000000d1272fb618, Type: libSpen_base.dll!SPen::File} + string module = oora.Assembly; + if (module == null) + { + int separatorPos = oora.Type.IndexOf("!"); + if (separatorPos != -1) + module = oora.Type.Substring(0, separatorPos); + } + if (module != null) + ura.Communicator.StartOffensiveGC(module); + } RemoteObject roInstance = this._app.GetRemoteObject(oora); o = roInstance.Dynamify(); } diff --git a/src/ScubaDiver/DynamicMethodGenerator.cs b/src/ScubaDiver/DynamicMethodGenerator.cs index 22f9f33f..a0ab1d7e 100644 --- a/src/ScubaDiver/DynamicMethodGenerator.cs +++ b/src/ScubaDiver/DynamicMethodGenerator.cs @@ -243,63 +243,50 @@ public static nuint Unified(string generatedMethodName, params object[] args) /// Boolean indicating 'skipOriginal' static bool RunPatchInPosition(HarmonyPatchPosition position, DetouredFuncInfo hookedFunc, object[] args, ref object retValue) { - if (args.Length == 0) throw new Exception("Bad arguments to unmanaged HookCallback. Expecting at least 1 (for 'this')."); + object self; + int firstNonSelfArgIndex; + if (hookedFunc.Target is UndecoratedExportedFunc uef && uef.IsStatic) + { + // Static method + self = null; + firstNonSelfArgIndex = 0; + } + else + { + // Instance method (probably) + if (args.Length == 0) + throw new Exception("Bad arguments to unmanaged HookCallback. Expecting at least 1 (for 'this')."); - object self = new NativeObject((nuint)args.FirstOrDefault(), hookedFunc.DeclaringClass); + self = new NativeObject((nuint)args.FirstOrDefault(), hookedFunc.DeclaringClass); + firstNonSelfArgIndex = 1; + } // Args without self - object[] argsToForward = new object[args.Length - 1]; + object[] argsToForward = new object[args.Length - firstNonSelfArgIndex]; for (int i = 0; i < argsToForward.Length; i++) { - if (args[i + 1] is nuint arg) + int argIndex = i + firstNonSelfArgIndex; + object currentArg = args[argIndex]; + + object valueToAssign; + if (currentArg is nuint arg) { - string argType = hookedFunc.Target.ArgTypes[i + 1]; - if (argType == "char*" || argType == "char *") - { - if (arg != 0) - { - string cString = Marshal.PtrToStringAnsi(new IntPtr((long)arg)); - argsToForward[i] = new CharStar(arg, cString); - } - else - { - argsToForward[i] = arg; - } - } - else if (argType.EndsWith('*')) - { - // If the argument is a pointer, indicate it with a NativeObject - // TODO: SecondClassTypeInfo is abused here - string fixedArgType = argType[..^1].Trim(); - - // split fixedArgType to namespace and name - // Look for last index of "::" and split around it - int lastIndexOfColonColon = fixedArgType.LastIndexOf("::"); - // take into consideration that "::" might no be present at all, and the namespace is empty - string namespaceName = lastIndexOfColonColon == -1 ? "" : fixedArgType.Substring(0, lastIndexOfColonColon); - string typeName = lastIndexOfColonColon == -1 ? fixedArgType : fixedArgType.Substring(lastIndexOfColonColon + 2); - - SecondClassTypeInfo typeInfo = new SecondClassTypeInfo(hookedFunc.DeclaringClass.ModuleName, namespaceName, typeName); - argsToForward[i] = new NativeObject(arg, typeInfo); - } - else - { - // Primitive or struct or something else crazy - argsToForward[i] = arg; - } + valueToAssign = ConvertNuintArg(hookedFunc, argIndex, arg); } - else if (args[i + 1] is double doubleArg) + else if (currentArg is double doubleArg) { - argsToForward[i] = doubleArg; + valueToAssign = doubleArg; } - else if (args[i + 1] is float floatArg) + else if (currentArg is float floatArg) { - argsToForward[i] = floatArg; + valueToAssign = floatArg; } else { throw new Exception($"Unexpected argument type from generated detour hook. Expected nuint or double, got: {args[i + 1].GetType().FullName}, Arg Num: {i}"); } + + argsToForward[i] = valueToAssign; } @@ -350,6 +337,47 @@ static bool RunPatchInPosition(HarmonyPatchPosition position, DetouredFuncInfo h } return skipOriginal; + + static object ConvertNuintArg(DetouredFuncInfo hookedFunc, int argIndex, nuint arg) + { + object valueToAssign; + string argType = hookedFunc.Target.ArgTypes[argIndex]; + if (argType == "char*" || argType == "char *") + { + if (arg != 0) + { + string cString = Marshal.PtrToStringAnsi(new IntPtr((long)arg)); + valueToAssign = new CharStar(arg, cString); + } + else + { + valueToAssign = arg; + } + } + else if (argType.EndsWith('*')) + { + // If the argument is a pointer, indicate it with a NativeObject + // TODO: SecondClassTypeInfo is abused here + string fixedArgType = argType[..^1].Trim(); + + // split fixedArgType to namespace and name + // Look for last index of "::" and split around it + int lastIndexOfColonColon = fixedArgType.LastIndexOf("::"); + // take into consideration that "::" might no be present at all, and the namespace is empty + string namespaceName = lastIndexOfColonColon == -1 ? "" : fixedArgType.Substring(0, lastIndexOfColonColon); + string typeName = lastIndexOfColonColon == -1 ? fixedArgType : fixedArgType.Substring(lastIndexOfColonColon + 2); + + SecondClassTypeInfo typeInfo = new SecondClassTypeInfo(hookedFunc.DeclaringClass.ModuleName, namespaceName, typeName); + valueToAssign = new NativeObject(arg, typeInfo); + } + else + { + // Primitive or struct or something else crazy + valueToAssign = arg; + } + + return valueToAssign; + } } } \ No newline at end of file