mirror of
https://github.com/Facepunch/sbox-public.git
synced 2026-08-01 16:28:36 -04:00
* Add regression test for prefab RenderOptions persistence * Skip clone fast path for members without a setter
288 lines
10 KiB
C#
288 lines
10 KiB
C#
using Facepunch.ActionGraphs;
|
|
using System.Linq.Expressions;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace Sandbox;
|
|
|
|
public abstract partial class Component
|
|
{
|
|
// Set only during the cloning process
|
|
// We store this on the component to avoid the need reverse lookup table during the clone process
|
|
private Component _cloneOriginal = null;
|
|
|
|
/// <summary>
|
|
/// Runs after this clone has been created by a cloned GameObject.
|
|
/// </summary>
|
|
/// <param name="original">The original component that was cloned.</param>
|
|
/// <param name="originalToClonedObject">During the cloning process, we build a mapping from original objects to their clone, so we will need to add ourselves to it.</param>
|
|
internal void InitClone( Component original, Dictionary<object, object> originalToClonedObject )
|
|
{
|
|
originalToClonedObject[original] = this;
|
|
_cloneOriginal = original;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs after all objects of the original hierarchy have been cloned/created.
|
|
/// Here we initialize the clones properties and fields with the values from the original object.
|
|
/// <param name="originalToClonedObject">A mapping of original objects to their clones, used for all reference types.</param>
|
|
/// <param name="originalIdToCloneId">A mapping of original GUIDs to cloned GUIDs, used for GameObject and Component references in JSON.</param>
|
|
/// </summary>
|
|
internal void PostClone( Dictionary<object, object> originalToClonedObject, Dictionary<Guid, Guid> originalIdToCloneId )
|
|
{
|
|
if ( !_cloneOriginal.IsValid() )
|
|
{
|
|
// Nothing todo this component is not a proper clone. It was created through side effects while cloning properties.
|
|
return;
|
|
}
|
|
|
|
using var targetScope = ActionGraph.PushTarget( InputDefinition.Target( typeof( GameObject ), GameObject ) );
|
|
|
|
ClonePropertiesAndFields( _cloneOriginal, originalToClonedObject, originalIdToCloneId );
|
|
|
|
CheckRequireComponent();
|
|
|
|
_cloneOriginal = null;
|
|
}
|
|
|
|
private void ClonePropertiesAndFields( object original, Dictionary<object, object> originalToClonedObject, Dictionary<Guid, Guid> originalIdToCloneId )
|
|
{
|
|
foreach ( var member in ReflectionQueryCache.OrderedSerializableMembers( GetType() ) )
|
|
{
|
|
CloneHelpers.CloneMember(
|
|
this,
|
|
original,
|
|
member,
|
|
originalToClonedObject,
|
|
originalIdToCloneId );
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Provides helper methods for cloning objects and their members.
|
|
/// We use a heuristic <see cref="ReflectionQueryCache.IsTypeCloneableByCopy"/> to determine if a type can be cloned by copy to speed up cloning.
|
|
/// If we cannot copy something and we have to "clone" we do so by serializing to and deserializing from JSON.
|
|
/// However, our goal is to copy as much as possible to avoid the serialization overhead.
|
|
/// </summary>
|
|
internal static class CloneHelpers
|
|
{
|
|
public static void CloneMember(
|
|
object target,
|
|
object original,
|
|
MemberDescription member,
|
|
Dictionary<object, object> originalToClonedObject,
|
|
Dictionary<Guid, Guid> originalIdToCloneId )
|
|
{
|
|
object originalValue = null;
|
|
Type valueType = null;
|
|
|
|
if ( member is PropertyDescription prop )
|
|
{
|
|
valueType = prop.PropertyType;
|
|
|
|
// Fast path: value types that are safe to copy by value are transferred via a pre-compiled
|
|
// delegate, avoiding the boxing allocation that PropertyInfo.GetValue would cause.
|
|
if ( valueType.IsValueType && ReflectionQueryCache.IsTypeCloneableByCopy( valueType ) )
|
|
{
|
|
MemberCopyCache.CopyTo( prop, original, target );
|
|
return;
|
|
}
|
|
|
|
originalValue = prop.GetValue( original );
|
|
}
|
|
else if ( member is FieldDescription field )
|
|
{
|
|
valueType = field.FieldType;
|
|
|
|
// Fast path: same as above for fields.
|
|
if ( valueType.IsValueType && ReflectionQueryCache.IsTypeCloneableByCopy( valueType ) )
|
|
{
|
|
MemberCopyCache.CopyTo( field, original, target );
|
|
return;
|
|
}
|
|
|
|
originalValue = field.GetValue( original );
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException( "Member is neither a property nor a field" );
|
|
}
|
|
|
|
if ( originalValue is null || ReflectionQueryCache.IsTypeCloneableByCopy( valueType ) )
|
|
{
|
|
// Embedded resources are deep-copied to carry any inline generator data over, only when in the editor.
|
|
if ( !Application.IsEditor || !ReflectionQueryCache.IsInlineEmbeddedResource( originalValue, valueType ) )
|
|
{
|
|
SetMemberValue( member, target, originalValue );
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If the original object has already been cloned simply point to it.
|
|
// For now only do this for Component and GameObjects ( matches original clone via JSON behaviour )
|
|
var isGameObjectorComponent = (originalValue is GameObject || originalValue is Component);
|
|
// There is an ambiguity when we reference the root of the prefab, it could either mean we want to reference the cloned root or the original prefab.
|
|
// To maintain old clone behaviour we reference the cloned root gameobject except when the cloned property is of type PrefabScene, in that case we reference the original prefab.
|
|
var isPrefabReference = originalValue is PrefabScene && valueType == typeof( PrefabScene );
|
|
if ( isGameObjectorComponent && !isPrefabReference && originalToClonedObject.TryGetValue( originalValue, out var existingClone ) )
|
|
{
|
|
SetMemberValue( member, target, existingClone );
|
|
return;
|
|
}
|
|
|
|
// Fast path: if the type implements ICloneable and is not a BCL/system type (whose Clone()
|
|
// is only a shallow copy that would silently skip GUID rewiring), call Clone() directly
|
|
// to avoid the JSON roundtrip overhead.
|
|
// Only when the member is assignable: SetMemberValue no-ops on get-only properties (e.g.
|
|
// Renderer.RenderOptions) and would drop the clone, so those fall through to the JSON path.
|
|
if ( originalValue is ICloneable cloneable && ReflectionQueryCache.IsICloneableSafe( valueType ) && IsMemberAssignable( member ) )
|
|
{
|
|
SetMemberValue( member, target, cloneable.Clone() );
|
|
return;
|
|
}
|
|
|
|
// Fallback to JSON
|
|
var clonedJson = Json.ToNode( originalValue, valueType );
|
|
UpdateClonedIdsInJson( clonedJson, originalIdToCloneId );
|
|
|
|
var targetValue = member is PropertyDescription ? ((PropertyDescription)member).GetValue( target ) : ((FieldDescription)member).GetValue( target );
|
|
|
|
if ( targetValue is IJsonPopulator jsonPopulator )
|
|
{
|
|
if ( targetValue == null )
|
|
targetValue = Activator.CreateInstance( valueType );
|
|
|
|
jsonPopulator.Deserialize( clonedJson );
|
|
|
|
SetMemberValue( member, target, targetValue );
|
|
}
|
|
else
|
|
{
|
|
var clonedValue = Json.FromNode( clonedJson, valueType );
|
|
|
|
SetMemberValue( member, target, clonedValue );
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether <see cref="SetMemberValue"/> can write this member. Mirrors the setter guard in <see cref="PropertyDescription.SetValue"/>.
|
|
/// </summary>
|
|
private static bool IsMemberAssignable( MemberDescription member )
|
|
{
|
|
if ( member is not PropertyDescription prop )
|
|
return true;
|
|
|
|
if ( prop.PropertyInfo.SetMethod is null )
|
|
return false;
|
|
|
|
if ( !prop.TypeDescription.IsDynamicAssembly && (!prop.IsSetMethodPublic || prop.IsSetMethodInitOnly) )
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
private static void SetMemberValue(
|
|
MemberDescription member,
|
|
object target,
|
|
object value )
|
|
{
|
|
if ( member is PropertyDescription prop )
|
|
{
|
|
prop.SetValue( target, value );
|
|
}
|
|
else if ( member is FieldDescription field )
|
|
{
|
|
field.SetValue( target, value );
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// We want GUIDS that reference something within the original hierarchy to reference the corresponding clone in the new hierarchy.
|
|
/// </summary>
|
|
public static void UpdateClonedIdsInJson( in JsonNode json, Dictionary<Guid, Guid> originalIdToCloneId )
|
|
{
|
|
Sandbox.Json.WalkJsonTree( json, ( k, v ) =>
|
|
{
|
|
if ( !v.TryGetValue<Guid>( out var guid ) ) return v;
|
|
if ( !originalIdToCloneId.TryGetValue( guid, out var updatedGuid ) ) return v;
|
|
|
|
return updatedGuid;
|
|
} );
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Caches pre-compiled expression-tree delegates that copy a single member's value directly
|
|
/// from source to target without boxing. Only used for value types that are safe to clone by copy.
|
|
/// Cleared via <see cref="ReflectionQueryCache.ClearTypeCache"/> after hotload and game close.
|
|
/// </summary>
|
|
internal static class MemberCopyCache
|
|
{
|
|
// Compiled expression delegates come from cant be upgraded via hot upload -> skip.
|
|
// The cache is still cleared explicitly via ReflectionQueryCache.ClearTypeCache() during hotload.
|
|
[SkipHotload]
|
|
private static readonly Dictionary<MemberDescription, Action<object, object>> _cache = new();
|
|
|
|
internal static bool IsEmpty => _cache.Count == 0;
|
|
|
|
internal static void Clear() => _cache.Clear();
|
|
|
|
internal static void CopyTo( PropertyDescription prop, object source, object target )
|
|
{
|
|
if ( !_cache.TryGetValue( prop, out var del ) )
|
|
{
|
|
del = BuildPropertyDelegate( prop );
|
|
_cache[prop] = del;
|
|
}
|
|
|
|
del( source, target );
|
|
}
|
|
|
|
internal static void CopyTo( FieldDescription field, object source, object target )
|
|
{
|
|
if ( !_cache.TryGetValue( field, out var del ) )
|
|
{
|
|
del = BuildFieldDelegate( field );
|
|
_cache[field] = del;
|
|
}
|
|
|
|
del( source, target );
|
|
}
|
|
|
|
private static Action<object, object> BuildPropertyDelegate( PropertyDescription prop )
|
|
{
|
|
var propInfo = prop.PropertyInfo;
|
|
var declaringType = propInfo.DeclaringType;
|
|
|
|
if ( propInfo.SetMethod is null )
|
|
return static ( _, _ ) => { };
|
|
|
|
// Mirror the same access guard as PropertyDescription.SetValue:
|
|
// engine types must not write to non-public or init-only setters.
|
|
if ( !prop.TypeDescription.IsDynamicAssembly && (!prop.IsSetMethodPublic || prop.IsSetMethodInitOnly) )
|
|
return static ( _, _ ) => { };
|
|
|
|
var sourceParam = Expression.Parameter( typeof( object ), "source" );
|
|
var targetParam = Expression.Parameter( typeof( object ), "target" );
|
|
|
|
var getExpr = Expression.Property( Expression.Convert( sourceParam, declaringType ), propInfo );
|
|
var setExpr = Expression.Call( Expression.Convert( targetParam, declaringType ), propInfo.SetMethod, getExpr );
|
|
|
|
return Expression.Lambda<Action<object, object>>( setExpr, sourceParam, targetParam ).Compile();
|
|
}
|
|
|
|
private static Action<object, object> BuildFieldDelegate( FieldDescription field )
|
|
{
|
|
var fieldInfo = field.FieldInfo;
|
|
var declaringType = fieldInfo.DeclaringType;
|
|
|
|
var sourceParam = Expression.Parameter( typeof( object ), "source" );
|
|
var targetParam = Expression.Parameter( typeof( object ), "target" );
|
|
|
|
var getExpr = Expression.Field( Expression.Convert( sourceParam, declaringType ), fieldInfo );
|
|
var setExpr = Expression.Assign( Expression.Field( Expression.Convert( targetParam, declaringType ), fieldInfo ), getExpr );
|
|
|
|
return Expression.Lambda<Action<object, object>>( setExpr, sourceParam, targetParam ).Compile();
|
|
}
|
|
}
|