Files
sbox-public/engine/Sandbox.Hotload/ILHotload/ILHotload.cs
s&box team 71f266059a Open source release
This commit imports the C# engine code and game files, excluding C++ source code.

[Source-Commit: ceb3d758046e50faa6258bc3b658a30c97743268]
2025-11-24 09:05:18 +00:00

601 lines
15 KiB
C#

using Mono.Cecil;
using MonoMod.Core;
using MonoMod.Utils;
using Sandbox.Upgraders;
using Sentry;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using MethodImplAttributes = System.Reflection.MethodImplAttributes;
namespace Sandbox;
/// <summary>
/// A fast path hotload that patches the existing assemblies IL when applicable
/// </summary>
public partial class ILHotload : IDisposable
{
internal static bool IgnoreAttachedDebugger { get; set; }
private Logger log;
/// <summary>
/// All active detours within an Assembly
/// </summary>
Dictionary<Assembly, Dictionary<MethodBase, ICoreDetour>> ActiveDetours = new();
/// <summary>
/// Make sure we have an initialized detour factory, as it takes a small while to load
/// </summary>
private IDetourFactory DetourFactory;
/// <summary>
/// True if <see cref="ILHotload"/> is supported on this platform.
/// </summary>
public bool IsSupported => DetourFactory != null;
private Exception _notSupportedException;
private record AssemblyChange
{
public required Assembly OriginalAssembly { get; init; }
public required Assembly ReplacingAssembly { get; init; }
}
public ILHotload( string name )
{
log = new Logger( $"{nameof( ILHotload )}/{name}" );
try
{
DetourFactory = MonoMod.Core.DetourFactory.Current;
}
catch ( Exception e )
{
// Let's log the error when we first actually try to hotload
_notSupportedException = e;
}
}
public void Dispose()
{
ActiveDetours.Clear();
ActiveDetours = null;
DetourFactory = null;
}
public bool Replace( Assembly baseAssembly, Assembly oldIlHotloadAssembly, Assembly newIlHotloadAssembly )
{
if ( !IsSupported )
{
if ( _notSupportedException is null )
{
return false;
}
log.Warning( $"ILHotload not supported on this platform.{Environment.NewLine}{_notSupportedException}" );
_notSupportedException = null;
return false;
}
using var scope = SentrySdk.PushScope();
SentrySdk.ConfigureScope( x =>
{
x.SetTag( "group", "fast-hotload" );
} );
if ( !TryFindChangedMethods( baseAssembly, oldIlHotloadAssembly, newIlHotloadAssembly, out var changes, out var unexpected, out var hasAttribute ) )
{
if ( hasAttribute )
{
log.Warning( $"Fast hotload attempted, but members have changed.{Environment.NewLine}{string.Join( Environment.NewLine, unexpected.Select( x => $" {x.ToSimpleString()}" ) )}" );
}
return false;
}
//
// We shouldn't do IL replacement hotloading when the debugger is attached for two reasons:
// 1. If a user is debugging a method, the PDB will mismatch the actual execution
// 2. MonoMod is ridiculously slow with a debugger attached
//
// Obviously you can comment this out if you need to debug this stuff in particular
//
if ( Debugger.IsAttached && !IgnoreAttachedDebugger ) return false;
var anyFailed = false;
foreach ( var (oldMethod, newMethod) in changes )
{
using var innerScope = SentrySdk.PushScope();
SentrySdk.ConfigureScope( x =>
{
x.Contexts["Source Method"] = new
{
MethodName = $"{newMethod.ToSimpleString()}"
};
} );
if ( TryReplaceMethod( oldMethod, newMethod ) )
{
continue;
}
log.Warning( $"Failed to replace method {newMethod.ToSimpleString()}" );
anyFailed = true;
}
return !anyFailed;
}
private const BindingFlags AllDeclared = BindingFlags.Public | BindingFlags.NonPublic
| BindingFlags.Instance | BindingFlags.Static
| BindingFlags.DeclaredOnly;
public static bool TryFindChangedMethods( Assembly baseAsm, Assembly oldAsm, Assembly newAsm,
out (MethodBase Old, MethodBase New)[] changedMethods,
out MemberInfo[] unexpectedChanges,
out bool hasSupportedAttribute )
{
oldAsm ??= baseAsm;
hasSupportedAttribute = false;
changedMethods = Array.Empty<(MethodBase, MethodBase)>();
unexpectedChanges = Array.Empty<MemberInfo>();
if ( oldAsm == null || newAsm == null ) return false;
if ( oldAsm == newAsm ) return false;
// Check if IL hotload is supported for this pair
var supportedAttrib = newAsm.GetCustomAttribute<SupportsILHotloadAttribute>();
var oldAsmVersion = oldAsm.GetName().Version;
if ( supportedAttrib == null || oldAsmVersion?.ToString() != supportedAttrib.PreviousAssemblyVersion )
{
return false;
}
hasSupportedAttribute = true;
// Look for methods / properties tagged with change attributes
// Also make sure that no type definitions have changed
var newChangedMethods = new ConcurrentBag<MethodBase>();
var baseAsmTypes = baseAsm.GetTypes().ToDictionary( x => x.FullName, x => x );
var newAsmTypes = newAsm.GetTypes();
if ( baseAsmTypes.Count != newAsmTypes.Length )
{
return false;
}
var typeChanges = false;
var comparer = new MemberEqualityComparer();
var changedMembers = new ConcurrentBag<MemberInfo>();
Parallel.ForEach( newAsmTypes, type =>
{
if ( !baseAsmTypes.TryGetValue( type.FullName, out var baseType ) )
{
typeChanges = true;
return;
}
if ( !comparer.AllMembersEqual( type, baseType ) )
{
changedMembers.Add( type );
typeChanges = true;
return;
}
var baseMembers = baseType.GetMembers( AllDeclared );
var newMembers = type.GetMembers( AllDeclared );
if ( baseMembers.Length != newMembers.Length )
{
changedMembers.Add( type );
typeChanges = true;
return;
}
for ( var i = 0; i < newMembers.Length; i++ )
{
var baseMember = baseMembers[i];
var newMember = newMembers[i];
// This should always be true because of the AllMembersEqual test earlier
Assert.AreEqual( baseMember.Name, newMember.Name );
if ( newMember is MethodInfo methodInfo && methodInfo.GetCustomAttribute<MethodBodyChangeAttribute>() != null )
{
newChangedMethods.Add( methodInfo );
continue;
}
if ( newMember is not PropertyInfo propertyInfo )
{
continue;
}
var changedAttribs = propertyInfo.GetCustomAttributes<PropertyAccessorBodyChangeAttribute>();
foreach ( var attrib in changedAttribs )
{
switch ( attrib.Accessor )
{
case PropertyAccessor.Get when propertyInfo.GetMethod != null:
newChangedMethods.Add( propertyInfo.GetMethod );
break;
case PropertyAccessor.Set when propertyInfo.SetMethod != null:
newChangedMethods.Add( propertyInfo.SetMethod );
break;
}
}
}
} );
if ( typeChanges )
{
unexpectedChanges = changedMembers.ToArray();
return false;
}
if ( newChangedMethods.Count == 0 )
{
return true;
}
if ( newChangedMethods.Any( HasTypeParameters ) )
{
//
// Not supported yet, MonoMod will throw when trying to create a DynamicMethodDefinition
//
// Set this to false so we don't get a warning
hasSupportedAttribute = false;
return false;
}
changedMethods = newChangedMethods
.Distinct()
.Select( x => (FindMatchingMethod( baseAsm, x ), x) )
.ToArray();
return true;
}
private static MethodBase FindMatchingMethod( Assembly asm, MethodBase otherAsmMethod )
{
if ( asm == null || otherAsmMethod.DeclaringType?.FullName == null )
{
return null;
}
var declType = asm.GetType( otherAsmMethod.DeclaringType.FullName );
if ( declType == null )
{
return null;
}
var otherParams = otherAsmMethod.GetParameters();
foreach ( var methodInfo in declType.GetMethods( AllDeclared ) )
{
if ( methodInfo.Name != otherAsmMethod.Name )
{
continue;
}
var thisParams = methodInfo.GetParameters();
if ( thisParams.Length != otherParams.Length )
{
continue;
}
var matchingParams = true;
for ( var i = 0; i < thisParams.Length; ++i )
{
if ( !AreEquivalentParameters( thisParams[i], otherParams[i] ) )
{
matchingParams = false;
break;
}
}
if ( matchingParams )
{
return methodInfo;
}
}
return null;
}
private static bool AreEquivalentParameters( ParameterInfo a, ParameterInfo b )
{
if ( a.Name != b.Name )
{
return false;
}
if ( a.IsIn != b.IsIn || a.IsOut != b.IsOut || a.IsOptional != b.IsOptional || a.IsRetval != b.IsRetval )
{
return false;
}
return AreEquivalentParameterTypes( a.ParameterType, b.ParameterType );
}
private static bool AreEquivalentParameterTypes( Type a, Type b )
{
if ( a == b )
{
return true;
}
// TODO
return a.ToSimpleString() == b.ToSimpleString();
}
private static bool HasTypeParameters( MemberInfo member )
{
if ( member is MethodBase { ContainsGenericParameters: true } )
{
return true;
}
if ( member is Type { ContainsGenericParameters: true } )
{
return true;
}
return member.DeclaringType != null && HasTypeParameters( member.DeclaringType );
}
public bool TryReplaceMethod( MethodBase source, MethodBase replace )
{
//
// Strong doubts this would work on JITed stuff
//
if ( source.MethodImplementationFlags != MethodImplAttributes.IL )
{
return false;
}
var sourceAssembly = source.Module.Assembly;
if ( !ActiveDetours.TryGetValue( sourceAssembly, out var asmDetours ) )
{
asmDetours = new Dictionary<MethodBase, ICoreDetour>();
ActiveDetours[sourceAssembly] = asmDetours;
}
//
// If we already detoured this method before, undo it so we can compare the method body properly
//
if ( asmDetours.TryGetValue( source, out var oldDetour ) )
{
if ( oldDetour == null )
{
//
// We're currently working on this detour
//
return true;
}
if ( oldDetour.Target == replace )
{
//
// Exactly this detour is already in place
//
return true;
}
oldDetour.Undo();
asmDetours.Remove( source );
}
asmDetours[source] = null;
//
// MonoMod caches assemblies / member infos by name, which breaks what
// we're trying to do if you swap different versions of the same assembly more than once.
//
ReplaceMonoModReflectionCache( source.Module.Assembly );
//
// Create a dynamic method to remove it from the new assembly
//
try
{
var dynamicMethodDef = new DynamicMethodDefinition( source );
var replaceMethodDef = new DynamicMethodDefinition( replace );
dynamicMethodDef.Definition.Body = replaceMethodDef.Definition.Body.Clone( dynamicMethodDef.Definition );
//
// Replace references to members / types in replace.Module with the original
// members / types in source.Module
//
var referencedMethods = FixReferences( dynamicMethodDef, source.Module, replace.Module );
//
// Methods with lambdas / async will reference compiler generated methods that also need replacing
//
foreach ( var (refSource, refTarget) in referencedMethods )
{
log.Trace( $"Replacing compiler generated {refTarget}" );
if ( !TryReplaceMethod( refSource, refTarget ) )
{
return false;
}
}
var dynamicMethod = dynamicMethodDef.Generate( source );
//
// Trampoline and keep reference to the detour
//
var detour = DetourFactory.CreateDetour( source, dynamicMethod );
asmDetours[source] = detour;
}
catch ( NotSupportedException )
{
asmDetours.Remove( source );
return false;
}
catch ( Exception e )
{
log.Error( e );
asmDetours.Remove( source );
return false;
}
return true;
}
private static void ReplaceMonoModReflectionCache( Assembly asm )
{
const BindingFlags bFlags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
var assemblyCache = (Dictionary<string, WeakReference>)typeof( ReflectionHelper ).GetField( "AssemblyCache", bFlags )!.GetValue( null );
var assembliesCache = (Dictionary<string, WeakReference[]>)typeof( ReflectionHelper ).GetField( "AssembliesCache", bFlags )!.GetValue( null );
var resolveReflectionCache = (Dictionary<string, WeakReference>)typeof( ReflectionHelper ).GetField( "ResolveReflectionCache", bFlags )!.GetValue( null );
assemblyCache!.Clear();
assembliesCache!.Clear();
resolveReflectionCache!.Clear();
assemblyCache[asm.GetRuntimeHashedFullName()] = new WeakReference( asm );
}
private static MemberInfo ResolveMember( Module module, MemberReference memberRef )
{
switch ( memberRef )
{
case TypeReference typeRef:
return module.ResolveType( typeRef );
case FieldReference fieldRef:
return module.ResolveField( fieldRef );
case MethodReference methodRef:
return module.ResolveMethod( methodRef );
default:
throw new NotImplementedException();
}
}
private static void AddCompilerGeneratedMethods( HashSet<MethodBase> compilerGeneratedSourceMethods, HashSet<MemberInfo> seenMembers, MemberInfo memberInfo )
{
if ( !seenMembers.Add( memberInfo ) )
{
return;
}
if ( memberInfo is ConstructorInfo ctorInfo && DelegateUpgrader.IsCompilerGenerated( ctorInfo.DeclaringType ) )
{
memberInfo = ctorInfo.DeclaringType;
}
if ( DelegateUpgrader.IsCompilerGenerated( memberInfo.Name ) )
{
switch ( memberInfo )
{
case MethodBase sourceMethod:
compilerGeneratedSourceMethods.Add( sourceMethod );
break;
case Type sourceType:
foreach ( var innerSourceMethod in sourceType.GetMethods( AllDeclared ) )
{
compilerGeneratedSourceMethods.Add( innerSourceMethod );
}
break;
}
}
}
/// <summary>
/// Replace any references to types or members in <paramref name="replaceModule"/> with references in <paramref name="sourceModule"/>.
/// Returns an array of referenced compiler-generated methods that should also be detoured.
/// </summary>
private static (MethodBase Source, MethodBase Target)[] FixReferences( DynamicMethodDefinition dynamicMethodDef, Module sourceModule, Module replaceModule )
{
var compilerGeneratedSourceMethods = new HashSet<MethodBase>();
var seenMembers = new HashSet<MemberInfo>();
foreach ( var variable in dynamicMethodDef.Definition.Body.Variables )
{
var sourceInfo = sourceModule.ResolveType( variable.VariableType );
variable.VariableType = dynamicMethodDef.Module.ImportReference( sourceInfo );
AddCompilerGeneratedMethods( compilerGeneratedSourceMethods, seenMembers, sourceInfo );
}
foreach ( var inst in dynamicMethodDef.Definition.Body.Instructions )
{
switch ( inst.Operand )
{
case MemberReference memberRef:
var sourceInfo = ResolveMember( sourceModule, memberRef );
// Don't need to import a reference apparently!
inst.Operand = sourceInfo;
AddCompilerGeneratedMethods( compilerGeneratedSourceMethods, seenMembers, sourceInfo );
break;
}
}
if ( compilerGeneratedSourceMethods.Count == 0 )
{
return Array.Empty<(MethodBase, MethodBase)>();
}
var compilerGeneratedMethods = new (MethodBase, MethodBase)[compilerGeneratedSourceMethods.Count];
var index = 0;
foreach ( var source in compilerGeneratedSourceMethods )
{
var sourceRef = dynamicMethodDef.Module.ImportReference( source );
var target = ResolveMember( replaceModule, sourceRef ) as MethodBase;
Assert.NotNull( source );
Assert.NotNull( target );
compilerGeneratedMethods[index++] = (source, target);
}
return compilerGeneratedMethods;
}
}