diff --git a/Explorer/Assets/DCL/AvatarRendering/AvatarShape/Systems/AvatarShapeVisibilitySystem.cs b/Explorer/Assets/DCL/AvatarRendering/AvatarShape/Systems/AvatarShapeVisibilitySystem.cs index 1fbbba9e6e4..a8c436585f0 100644 --- a/Explorer/Assets/DCL/AvatarRendering/AvatarShape/Systems/AvatarShapeVisibilitySystem.cs +++ b/Explorer/Assets/DCL/AvatarRendering/AvatarShape/Systems/AvatarShapeVisibilitySystem.cs @@ -255,7 +255,7 @@ private void UpdateVisibilityState(ref AvatarShapeComponent avatarShape, IAvatar avatarCachedVisibility.IsVisible = shouldBeHidden; - avatarView.AvatarAnimator.enabled = shouldPlayFootstepFX; + avatarView.AvatarAnimator.enabled = shouldPlayFootstepFX && !avatarView.IsLegacyAnimationPlaying; avatarView.AvatarAnimator.fireEvents = shouldPlayFootstepFX; } diff --git a/Explorer/Assets/DCL/AvatarRendering/AvatarShape/Tests/EditMode/AvatarBaseLegacyAnimationShould.cs b/Explorer/Assets/DCL/AvatarRendering/AvatarShape/Tests/EditMode/AvatarBaseLegacyAnimationShould.cs new file mode 100644 index 00000000000..c5b4c400a7e --- /dev/null +++ b/Explorer/Assets/DCL/AvatarRendering/AvatarShape/Tests/EditMode/AvatarBaseLegacyAnimationShould.cs @@ -0,0 +1,164 @@ +using DCL.AvatarRendering.AvatarShape.UnityInterface; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using Utility.Animations; + +namespace DCL.AvatarRendering.AvatarShape.Tests +{ + public class AvatarBaseLegacyAnimationShould + { + private const string AVATAR_BASE_TEST_ASSET_PATH = "Assets/DCL/AvatarRendering/AvatarShape/Tests/Instantiate/TestAssets/AvatarBase_TestAsset.prefab"; + + private GameObject avatarGameObject = null!; + private AvatarBase avatarBase = null!; + + [SetUp] + public void SetUp() + { + var avatarBasePrefab = AssetDatabase.LoadAssetAtPath(AVATAR_BASE_TEST_ASSET_PATH); + Assert.IsNotNull(avatarBasePrefab, $"Could not load AvatarBase test prefab from {AVATAR_BASE_TEST_ASSET_PATH}"); + + avatarGameObject = Object.Instantiate(avatarBasePrefab); + avatarBase = avatarGameObject.GetComponentInChildren(); + Assert.IsNotNull(avatarBase, "AvatarBase component not found on test prefab"); + Assert.IsNotNull(avatarBase.AvatarAnimator, "AvatarAnimator not configured on test prefab"); + } + + [TearDown] + public void TearDown() + { + if (avatarGameObject != null) Object.DestroyImmediate(avatarGameObject); + } + + [Test] + public void AddOrGetLegacyAnimation_CreatesAnimationOnAvatarAnimatorGameObject() + { + Animation legacyAnimation = avatarBase.AddOrGetLegacyAnimation(); + + Assert.IsNotNull(legacyAnimation); + Assert.AreSame(legacyAnimation, avatarBase.LegacyAnimation); + Assert.AreSame(avatarBase.AvatarAnimator.gameObject, legacyAnimation.gameObject, + "PlayLegacyEmote expects the Animation component to live on the same GameObject as the Animator."); + } + + [Test] + public void AddOrGetLegacyAnimation_ReturnsSameInstance_OnRepeatedCalls() + { + Animation first = avatarBase.AddOrGetLegacyAnimation(); + Animation second = avatarBase.AddOrGetLegacyAnimation(); + + Assert.AreSame(first, second, + "Repeated calls must not create duplicate Animation components — AddClip relies on a single stable Animation instance."); + } + + [Test] + public void IsLegacyAnimationPlaying_ReturnsFalse_WhenLegacyAnimationExistsButIsNotPlaying() + { + avatarBase.AddOrGetLegacyAnimation(); + + Assert.IsFalse(avatarBase.IsLegacyAnimationPlaying, + "An idle Animation component must not gate the Mecanim animator or its setters."); + } + + [Test] + public void StopLegacyAnimation_DoesNotThrow_WhenLegacyAnimationIsIdle() + { + avatarBase.AddOrGetLegacyAnimation(); + + Assert.DoesNotThrow(() => avatarBase.StopLegacyAnimation()); + } + + [Test] + public void SetAnimatorTrigger_EnablesAnimator_WhenLegacyNotPlaying() + { + avatarBase.AvatarAnimator.enabled = false; + + avatarBase.SetAnimatorTrigger(AnimationHashes.EMOTE); + + Assert.IsTrue(avatarBase.AvatarAnimator.enabled, + "With no legacy animation playing, Mecanim setters must re-enable the Animator to process the new trigger."); + } + + [Test] + public void ReplaceEmoteAnimation_EnablesAnimator_WhenLegacyNotPlaying() + { + avatarBase.AvatarAnimator.enabled = false; + var clip = new AnimationClip { legacy = false }; + + avatarBase.ReplaceEmoteAnimation(clip); + + Assert.IsTrue(avatarBase.AvatarAnimator.enabled, + "ReplaceEmoteAnimation must re-enable the Mecanim animator when no legacy animation is blocking it."); + + Object.DestroyImmediate(clip); + } + + [Test] + public void StopLegacyAnimation_StopsAPlayingLegacyClip() + { + Animation legacyAnimation = avatarBase.AddOrGetLegacyAnimation(); + AnimationClip clip = CreateLegacyClip(); + + legacyAnimation.AddClip(clip, clip.name); + legacyAnimation.Play(clip.name); + + Assume.That(avatarBase.IsLegacyAnimationPlaying, Is.True, + "Sanity check: legacy clip should report as playing right after Play() in EditMode."); + + avatarBase.StopLegacyAnimation(); + + Assert.IsFalse(avatarBase.IsLegacyAnimationPlaying, + "StopLegacyAnimation must clear the playing flag so the IsLegacyAnimationPlaying gate releases for the next emote."); + + Object.DestroyImmediate(clip); + } + + [Test] + public void SetAnimatorTrigger_Ignored_WhileLegacyAnimationIsPlaying() + { + Animation legacyAnimation = avatarBase.AddOrGetLegacyAnimation(); + AnimationClip clip = CreateLegacyClip(); + legacyAnimation.AddClip(clip, clip.name); + legacyAnimation.Play(clip.name); + Assume.That(avatarBase.IsLegacyAnimationPlaying, Is.True); + + avatarBase.AvatarAnimator.enabled = false; + + avatarBase.SetAnimatorTrigger(AnimationHashes.EMOTE); + + Assert.IsFalse(avatarBase.AvatarAnimator.enabled, + "While a legacy Animation is playing the Mecanim animator must stay disabled — otherwise motion systems stomp the legacy pose every frame."); + + Object.DestroyImmediate(clip); + } + + [Test] + public void ReplaceEmoteAnimation_DoesNotEnableAnimator_WhileLegacyAnimationIsPlaying() + { + Animation legacyAnimation = avatarBase.AddOrGetLegacyAnimation(); + AnimationClip clip = CreateLegacyClip(); + legacyAnimation.AddClip(clip, clip.name); + legacyAnimation.Play(clip.name); + Assume.That(avatarBase.IsLegacyAnimationPlaying, Is.True); + + avatarBase.AvatarAnimator.enabled = false; + var mecanimClip = new AnimationClip { legacy = false }; + + avatarBase.ReplaceEmoteAnimation(mecanimClip); + + Assert.IsFalse(avatarBase.AvatarAnimator.enabled, + "Overriding the emote slot while legacy is playing must not re-enable the animator — the switch happens after StopLegacyAnimation releases the gate."); + + Object.DestroyImmediate(clip); + Object.DestroyImmediate(mecanimClip); + } + + private static AnimationClip CreateLegacyClip() + { + var clip = new AnimationClip { name = "TestLegacyClip", legacy = true }; + clip.SetCurve(string.Empty, typeof(Transform), "localPosition.x", AnimationCurve.Linear(0, 0, 1, 1)); + return clip; + } + } +} diff --git a/Explorer/Assets/DCL/AvatarRendering/AvatarShape/Tests/EditMode/AvatarBaseLegacyAnimationShould.cs.meta b/Explorer/Assets/DCL/AvatarRendering/AvatarShape/Tests/EditMode/AvatarBaseLegacyAnimationShould.cs.meta new file mode 100644 index 00000000000..f9d6d8b136e --- /dev/null +++ b/Explorer/Assets/DCL/AvatarRendering/AvatarShape/Tests/EditMode/AvatarBaseLegacyAnimationShould.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2c5f16a81a3ca454e9240927088c3e3c \ No newline at end of file diff --git a/Explorer/Assets/DCL/AvatarRendering/AvatarShape/Tests/EditMode/AvatarShapeVisibilitySystemShould.cs b/Explorer/Assets/DCL/AvatarRendering/AvatarShape/Tests/EditMode/AvatarShapeVisibilitySystemShould.cs index aecc2e5c317..f64a1f0b127 100644 --- a/Explorer/Assets/DCL/AvatarRendering/AvatarShape/Tests/EditMode/AvatarShapeVisibilitySystemShould.cs +++ b/Explorer/Assets/DCL/AvatarRendering/AvatarShape/Tests/EditMode/AvatarShapeVisibilitySystemShould.cs @@ -468,6 +468,41 @@ public void CombineMultipleHiddenReasons() Assert.IsTrue(updatedHiddenComponent.Reason.HasFlag(HiddenPlayerComponent.HiddenReason.BANNED)); } + [Test] + public void KeepAnimatorDisabledWhileLegacyAnimationIsPlaying() + { + // Arrange — a non-player avatar that starts hidden so the first update caches IsVisible=true. + var avatarShape = CreateAvatarShapeComponent(); + avatarShape.HiddenByModifierArea = true; + Entity avatarEntity = world.Create(avatarShape, avatarBase, new CharacterEmoteComponent()); + + // Prime the cached state — marks the avatar as hidden so the next update's visible-transition is NOT early-returned. + system.Update(0); + + // Start a legacy Animation on the avatar — LSD/Builder-preview FullBody emotes run through this component. + Animation legacyAnimation = avatarBase.AddOrGetLegacyAnimation(); + var clip = new AnimationClip { name = "TestLegacyClip", legacy = true }; + clip.SetCurve(string.Empty, typeof(Transform), "localPosition.x", AnimationCurve.Linear(0, 0, 1, 1)); + legacyAnimation.AddClip(clip, clip.name); + legacyAnimation.Play(clip.name); + + Assume.That(avatarBase.IsLegacyAnimationPlaying, Is.True, + "Sanity check: legacy clip should report as playing right after Play() in EditMode."); + + // Unhide so the system must transition to "visible" — this is the code path that sets AvatarAnimator.enabled. + ref var shapeRef = ref world.Get(avatarEntity); + shapeRef.HiddenByModifierArea = false; + + // Act + system.Update(0); + + // Assert — legacy playback must keep the Mecanim animator disabled so motion systems don't stomp the legacy pose every frame. + Assert.IsFalse(avatarBase.AvatarAnimator.enabled, + "AvatarShapeVisibilitySystem must not re-enable the Animator while a legacy Animation is playing."); + + Object.DestroyImmediate(clip); + } + [Test] public void HidePlayerAvatarWhenTransitioningToFirstPersonAndCloseToCameraStart() { diff --git a/Explorer/Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs b/Explorer/Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs index b430f9782a6..1f89e84f60e 100644 --- a/Explorer/Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs +++ b/Explorer/Assets/DCL/AvatarRendering/AvatarShape/UnityInterface/AvatarBase.cs @@ -28,6 +28,8 @@ public class AvatarBase : MonoBehaviour, IAvatarView [field: SerializeField] public Animator AvatarAnimator { get; private set; } [field: SerializeField] public AvatarAudioPlaybackController AudioPlaybackController { get; private set; } + public Animation? LegacyAnimation { get; private set; } + [field: SerializeField] public RigBuilder RigBuilder { get; private set; } [field: SerializeField] public SkinnedMeshRenderer AvatarSkinnedMeshRenderer { get; private set; } @@ -157,6 +159,20 @@ private void Awake() public Transform GetTransform() => transform; + public Animation AddOrGetLegacyAnimation() + { + if (LegacyAnimation != null) return LegacyAnimation; + LegacyAnimation = AvatarAnimator.gameObject.AddComponent(); + return LegacyAnimation; + } + + public bool IsLegacyAnimationPlaying => LegacyAnimation != null && LegacyAnimation.isPlaying; + + public void StopLegacyAnimation() + { + if (LegacyAnimation != null) LegacyAnimation.Stop(); + } + public void SetPointAtLayerWeight(float weight) { AvatarAnimator.SetLayerWeight(rightPointAtLayerIndex, weight); @@ -169,6 +185,7 @@ public void SetRotationLayerWeight(float weight) public void SetAnimatorFloat(int hash, float value) { + if (IsLegacyAnimationPlaying) return; if (AvatarAnimator.GetFloat(hash).Equals(value)) return; AvatarAnimator.enabled = true; @@ -177,6 +194,7 @@ public void SetAnimatorFloat(int hash, float value) public void SetAnimatorInt(int hash, int value) { + if (IsLegacyAnimationPlaying) return; if (AvatarAnimator.GetInteger(hash).Equals(value)) return; AvatarAnimator.enabled = true; @@ -185,6 +203,8 @@ public void SetAnimatorInt(int hash, int value) public void SetAnimatorTrigger(int hash) { + if (IsLegacyAnimationPlaying) return; + AvatarAnimator.enabled = true; AvatarAnimator.SetTrigger(hash); } @@ -217,6 +237,7 @@ public void ResetState() { ResetArmatureInclination(); transform.localPosition = Vector3.zero; + LegacyAnimation?.Stop(); AvatarAnimator.Rebind(); HipsConstraint.data.offset = Vector3.zero; HipsConstraint.weight = 0; @@ -234,6 +255,7 @@ public bool GetAnimatorBool(int hash) => public void SetAnimatorBool(int hash, bool value) { + if (IsLegacyAnimationPlaying) return; if (AvatarAnimator.GetBool(hash) == value) return; AvatarAnimator.enabled = true; @@ -251,6 +273,8 @@ public void ReplaceEmoteAnimation(AnimationClip animationClip) AvatarAnimator.runtimeAnimatorController = overrideController; lastEmote = animationClip; + + if (IsLegacyAnimationPlaying) return; AvatarAnimator.enabled = true; } @@ -262,6 +286,8 @@ public void ReplaceMaskedEmoteAnimation(AnimationClip animationClip) AvatarAnimator.runtimeAnimatorController = overrideController; lastMaskedEmote = animationClip; + + if (IsLegacyAnimationPlaying) return; AvatarAnimator.enabled = true; } @@ -341,6 +367,12 @@ public interface IAvatarView void ClearMaskedEmoteAnimationCache(); + Animation AddOrGetLegacyAnimation(); + + bool IsLegacyAnimationPlaying { get; } + + void StopLegacyAnimation(); + float GetAnimatorFloat(int hash); bool IsAnimatorInTag(int hashTag); diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/CharacterEmoteComponent.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/CharacterEmoteComponent.cs index bd05ddb682b..46d54d088a8 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/CharacterEmoteComponent.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/CharacterEmoteComponent.cs @@ -16,11 +16,13 @@ public struct CharacterEmoteComponent private int currentAnimationTag; public float PlayingEmoteDuration => CurrentEmoteReference?.avatarClip - ? CurrentEmoteReference.avatarClip.length * CurrentEmoteReference.animatorComp!.speed + ? CurrentEmoteReference.avatarClip.length * (CurrentEmoteReference.animatorComp != null ? CurrentEmoteReference.animatorComp.speed : 1f) : 0f; public readonly bool IsPlayingEmote => - currentAnimationTag == AnimationHashes.EMOTE || currentAnimationTag == AnimationHashes.EMOTE_LOOP; + (CurrentEmoteReference != null && CurrentEmoteReference.legacy) + || currentAnimationTag == AnimationHashes.EMOTE + || currentAnimationTag == AnimationHashes.EMOTE_LOOP; public readonly int CurrentAnimationTag => currentAnimationTag; diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/EmoteReferences.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/EmoteReferences.cs index 98270cdee82..21197c88884 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/EmoteReferences.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/EmoteReferences.cs @@ -8,15 +8,19 @@ public class EmoteReferences : MonoBehaviour public AnimationClip? avatarClip { get; private set; } public AnimationClip? propClip { get; private set; } public Animator? animatorComp { get; private set; } + public Animation? animationComp { get; private set; } + public bool legacy { get; private set; } public AudioSource? audioSource; - public void Initialize(AnimationClip? animationClip, AnimationClip? propClip, Animator? animatorComp, int propClipHash) + public void Initialize(AnimationClip? animationClip, AnimationClip? propClip, Animator? animatorComp, Animation? animationComp, int propClipHash, bool legacy) { this.avatarClip = animationClip; this.propClip = propClip; this.animatorComp = animatorComp; + this.animationComp = animationComp; this.propClipHash = propClipHash; + this.legacy = legacy; } } } diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromLocalSceneIntention.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromLocalSceneIntention.cs index ee768eaa0f2..0569ccdb0e4 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromLocalSceneIntention.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromLocalSceneIntention.cs @@ -1,6 +1,7 @@ using Arch.Core; using CommunicationData.URLHelpers; using DCL.AvatarRendering.Loading.Components; +using DCL.ECSComponents; using ECS.Prioritization.Components; using ECS.StreamableLoading; using ECS.StreamableLoading.GLTF; @@ -19,6 +20,7 @@ public struct GetSceneEmoteFromLocalSceneIntention : IEquatable - EmoteHash.Equals(other.EmoteHash) && Loop == other.Loop && BodyShape.Equals(other.BodyShape); + EmoteHash.Equals(other.EmoteHash) && Loop == other.Loop && BodyShape.Equals(other.BodyShape) && Mask == other.Mask; public readonly URN NewSceneEmoteURN() => $"{GetSceneEmoteFromRealmIntention.SCENE_EMOTE_PREFIX}:{SceneData.SceneShortInfo.Name}-{EmoteHash}-{Loop.ToString().ToLower()}"; public void CreateAndAddPromiseToWorld(World world, IPartitionComponent partitionComponent, URLSubdirectory? customStreamingSubdirectory, IEmote emote) { + // FullBody scene emotes load as Legacy animations so GLTFast doesn't hit `SetCurve` at runtime on non-legacy clips in builds. + // Masked scene emotes must be Mecanim — AnimatorOverrideController + layered masks are Mecanim-only. + bool useMecanim = Mask != AvatarEmoteMask.AemFullBody; + var promise = GltfPromise.Create(world, - GetGLTFIntention.Create(this.EmotePath, this.EmoteHash, mecanimAnimationClips: true), + GetGLTFIntention.Create(this.EmotePath, this.EmoteHash, mecanimAnimationClips: useMecanim), partitionComponent); world.Create(promise, emote, this.BodyShape); diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Helpers/BuilderEmoteAssetPromiseFactory.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Helpers/BuilderEmoteAssetPromiseFactory.cs new file mode 100644 index 00000000000..edea28cc726 --- /dev/null +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Helpers/BuilderEmoteAssetPromiseFactory.cs @@ -0,0 +1,90 @@ +using Arch.Core; +using CommunicationData.URLHelpers; +using DCL.AvatarRendering.Loading.Components; +using DCL.SDKComponents.AudioSources; +using ECS.Prioritization.Components; +using ECS.StreamableLoading.GLTF; +using GltfPromise = ECS.StreamableLoading.Common.AssetPromise; + +namespace DCL.AvatarRendering.Emotes +{ + public static class BuilderEmoteAssetPromiseFactory + { + private static readonly BodyShape[] ALL_BODYSHAPES = { BodyShape.MALE, BodyShape.FEMALE }; + + public static bool TryCreate(World world, IEmote emote, IPartitionComponent partition, IEmoteStorage emoteStorage, IURLBuilder urlBuilder) + { + if (string.IsNullOrEmpty(emote.DTO?.ContentDownloadUrl)) + return false; + + if (emote.IsLoading) + return true; + + if (emoteStorage.TryGetElement(emote.GetUrn(), out IEmote existingEmote)) + { + if (existingEmote.IsUnisex() && existingEmote.HasSameClipForAllGenders()) + { + if (existingEmote.AssetResults[BodyShape.MALE] != null || existingEmote.AssetResults[BodyShape.FEMALE] != null) + return false; + } + else + { + if (existingEmote.AssetResults[BodyShape.MALE] != null && existingEmote.AssetResults[BodyShape.FEMALE] != null) + return false; + } + } + + bool foundGlb = false; + bool stillProcessing = false; + + BodyShape? targetBodyShape = null; + if (!emote.IsUnisex()) + targetBodyShape = BodyShape.FromStringSafe(emote.DTO.Metadata.AbstractData.representations[0].bodyShapes[0]); + + foreach (var content in emote.DTO.content) + { + if (content.file.EndsWith(".glb")) + { + for (int i = 0; i < ALL_BODYSHAPES.Length; i++) + { + BodyShape bodyShape = ALL_BODYSHAPES[i]; + if (!emote.IsUnisex() && !bodyShape.Equals(targetBodyShape!)) + continue; + + if (emote.AssetResults[bodyShape] != null) + continue; + + var gltfPromise = GltfPromise.Create(world, GetGLTFIntention.Create(content.file, content.hash), partition); + world.Create(gltfPromise, emote, bodyShape); + emote.UpdateLoadingStatus(true); + foundGlb = true; + stillProcessing = true; + } + continue; + } + + if (content.file.EndsWith(".mp3") || content.file.EndsWith(".ogg")) + { + var audioType = content.file.ToAudioType(); + urlBuilder.Clear(); + urlBuilder.AppendDomain(URLDomain.FromString(emote.DTO.ContentDownloadUrl)).AppendPath(new URLPath(content.hash)); + URLAddress url = urlBuilder.Build(); + + for (int i = 0; i < ALL_BODYSHAPES.Length; i++) + { + BodyShape bodyShape = ALL_BODYSHAPES[i]; + if (!emote.IsUnisex() && !bodyShape.Equals(targetBodyShape)) + continue; + + var audioPromise = AudioUtils.CreateAudioClipPromise(world, url.Value, audioType, partition); + world.Create(audioPromise, emote, bodyShape); + } + + if (foundGlb) break; + } + } + + return stillProcessing; + } + } +} diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Helpers/BuilderEmoteAssetPromiseFactory.cs.meta b/Explorer/Assets/DCL/AvatarRendering/Emotes/Helpers/BuilderEmoteAssetPromiseFactory.cs.meta new file mode 100644 index 00000000000..e0c43e76ff0 --- /dev/null +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Helpers/BuilderEmoteAssetPromiseFactory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f85296f79678849cfba8729a3c21240e \ No newline at end of file diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs index 0e8a07921ea..71d601ee02e 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs @@ -138,7 +138,13 @@ IEnumerable emotes } if (emote.IsLoading) continue; - if (CreateAssetBundlePromiseIfRequired(emote, in intention, partitionComponent)) continue; + + // Builder-preview (and LSD) emotes are loaded via raw GLTF elsewhere + // (LoadTrimmedEmotesByParamSystem.AfterBuilderItemsLoaded / LoadSceneEmotesSystem). Their DTOs carry + // a sentinel manifest from CreateLSDAsset() with no version/build date, so + // creating an AssetBundle promise for them would NRE in PrepareCommonArguments. + bool skipAssetBundle = emote.DTO.assetBundleManifestVersion is { IsLSDAsset: true }; + if (!skipAssetBundle && CreateAssetBundlePromiseIfRequired(emote, in intention, partitionComponent)) continue; if (emote.AssetResults[intention.BodyShape] != null) diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadTrimmedEmotesByParamSystem.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadTrimmedEmotesByParamSystem.cs index 601197969a1..f6f6c5fdd7c 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadTrimmedEmotesByParamSystem.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadTrimmedEmotesByParamSystem.cs @@ -4,11 +4,13 @@ using CommunicationData.URLHelpers; using Cysharp.Threading.Tasks; using DCL.AvatarRendering.Loading; +using DCL.AvatarRendering.Loading.Components; using DCL.AvatarRendering.Loading.Systems.Abstract; using DCL.Diagnostics; using DCL.Multiplayer.Connections.DecentralandUrls; using DCL.WebRequests; using ECS; +using ECS.Prioritization.Components; using ECS.StreamableLoading.Cache; namespace DCL.AvatarRendering.Emotes.Load @@ -17,6 +19,8 @@ namespace DCL.AvatarRendering.Emotes.Load [LogCategory(ReportCategory.EMOTE)] public partial class LoadTrimmedEmotesByParamSystem : LoadTrimmedElementsByIntentionSystem { + private readonly IEmoteStorage emoteStorage; + public LoadTrimmedEmotesByParamSystem( World world, IRealmData realmData, @@ -30,6 +34,7 @@ public LoadTrimmedEmotesByParamSystem( ) : base(world, cache, trimmedEmoteStorage, emoteStorage, realmData, emotesSubdirectory, webRequestController,"emote", urlsSource, builderContentURL: builderContentURL) { + this.emoteStorage = emoteStorage; } protected override async UniTask>> ParseResponseAsync(GenericDownloadHandlerUtils.Adapter adapter) => @@ -39,7 +44,18 @@ protected override async UniTask(WRJsonParser.Newtonsoft); protected override TrimmedEmotesResponse AssetFromPreparedIntention(in GetTrimmedEmotesByParamIntention intention) => - // Promise creation for builder collections is handled at ResolveBuilderEmotePromisesSystem new (intention.Results, intention.TotalAmount); + + protected override void AfterBuilderItemsLoaded(ref GetTrimmedEmotesByParamIntention intention, IPartitionComponent partition) + { + if (intention.Results is not { Count: > 0 }) return; + + foreach (ITrimmedEmote trimmedEmote in intention.Results) + { + if (trimmedEmote is not IEmote emote) continue; + + BuilderEmoteAssetPromiseFactory.TryCreate(World!, emote, partition, emoteStorage, urlBuilder); + } + } } } diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/CharacterEmoteSystem.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/CharacterEmoteSystem.cs index 68e681f9bbb..6392ee875b0 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/CharacterEmoteSystem.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/CharacterEmoteSystem.cs @@ -168,6 +168,14 @@ private void CancelEmotes(ref CharacterEmoteComponent emoteComponent, in IAvatar return; } + // Tear down only when the legacy Animation component has stopped on its own (non-looping). + if (emoteReference.legacy) + { + if (!avatarView.IsLegacyAnimationPlaying) + StopEmote(ref emoteComponent, avatarView); + return; + } + if (!emoteComponent.IsPlayingEmote) { avatarView.ResetAnimatorTrigger(AnimationHashes.EMOTE_STOP); @@ -218,6 +226,9 @@ private void StopEmote(ref CharacterEmoteComponent emoteComponent, IAvatarView a emotePlayer.Stop(emoteComponent.CurrentEmoteReference); + // Legacy emotes keep the Mecanim animator disabled while playing + avatarView.StopLegacyAnimation(); + // Create a clean slate for the animator before setting the stop trigger avatarView.ResetAnimatorTrigger(AnimationHashes.EMOTE); avatarView.ResetAnimatorTrigger(AnimationHashes.EMOTE_RESET); @@ -351,7 +362,7 @@ private void ConsumeEmoteIntent([Data] float dt, Entity entity, } else // Request the emote when not it cache. It will eventually endup in the emoteStorage so it can be played by this query - CreateEmotePromise(emoteId, avatarShapeComponent.BodyShape); + CreateEmotePromise(emoteId, avatarShapeComponent.BodyShape, emoteIntent.Mask); } catch (Exception e) { ReportHub.LogException(e, GetReportData()); } } @@ -385,7 +396,7 @@ private void CleanUp(Profile profile, in DeleteEntityIntention deleteEntityInten messageBus.OnPlayerRemoved(profile.UserId); } - private void CreateEmotePromise(URN urn, BodyShape bodyShape) + private void CreateEmotePromise(URN urn, BodyShape bodyShape, AvatarEmoteMask mask) { loadEmoteBuffer[0] = urn; @@ -398,7 +409,7 @@ private void CreateEmotePromise(URN urn, BodyShape bodyShape) if (localSceneDevelopment && TryResolveLocalSceneEmotePath(scene, emoteHash, out string emotePath)) { SceneEmoteFromLocalPromise.Create(World, - new GetSceneEmoteFromLocalSceneIntention(scene.SceneData, emotePath, emoteHash, bodyShape, loop), + new GetSceneEmoteFromLocalSceneIntention(scene.SceneData, emotePath, emoteHash, bodyShape, loop, mask), PartitionComponent.TOP_PRIORITY); return; diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/EmotePlayer.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/EmotePlayer.cs index dbee1384cc1..1c47a3cc6c5 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/EmotePlayer.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/EmotePlayer.cs @@ -1,4 +1,5 @@ using DCL.AvatarRendering.AvatarShape.UnityInterface; +using DCL.Diagnostics; using DCL.ECSComponents; using DCL.Optimization.Pools; using System; @@ -21,9 +22,11 @@ public class EmotePlayer private readonly Dictionary> pools = new (); private readonly Dictionary> emotesInUse = new (); private readonly Transform poolRoot; + private readonly bool legacyAnimationsEnabled; - public EmotePlayer(AudioSource audioSourcePrefab) + public EmotePlayer(AudioSource audioSourcePrefab, bool legacyAnimationsEnabled = false) { + this.legacyAnimationsEnabled = legacyAnimationsEnabled; poolRoot = GameObject.Find("ROOT_POOL_CONTAINER")!.transform; audioSourcePool = new GameObjectPool(poolRoot, () => Object.Instantiate(audioSourcePrefab)); @@ -48,7 +51,18 @@ public bool Play(GameObject mainAsset, AudioClip? audioAsset, bool isLooping, bo EmoteReferences? emoteReferences = AcquireEmoteReferences(mainAsset, audioAsset, isSpatial, in view, emoteInUse); if (emoteReferences == null) return false; - PlayMecanimEmote(view, ref emoteComponent, emoteReferences, isLooping); + if (emoteReferences.legacy) + { + if (!legacyAnimationsEnabled) + { + Stop(emoteReferences); + return false; + } + + PlayLegacyEmote(view, ref emoteComponent, emoteReferences, emoteComponent.EmoteLoop || isLooping); + } + else + PlayMecanimEmote(view, ref emoteComponent, emoteReferences, isLooping); emotesInUse.Add(emoteReferences, pools[mainAsset]); emoteComponent.CurrentEmoteReference = emoteReferences; @@ -66,6 +80,13 @@ public bool PlayMasked(GameObject mainAsset, AudioClip? audioAsset, bool isLoopi EmoteReferences? emoteReferences = AcquireEmoteReferences(mainAsset, audioAsset, isSpatial, in view, emoteInUse); if (emoteReferences == null) return false; + if (emoteReferences.legacy) + { + ReportHub.LogWarning(ReportCategory.EMOTE, $"Masked scene emote '{mainAsset.name}' was loaded as Legacy; masks require Mecanim and cannot be played."); + Stop(emoteReferences); + return false; + } + PlayMaskedMecanimEmote(view, ref maskedEmote, emoteReferences, isLooping); emotesInUse.Add(emoteReferences, pools[mainAsset]); @@ -147,9 +168,11 @@ private bool IsSameLoopingEmote(EmoteReferences? emoteInUse, GameObject mainAsse if (emoteInUse != null) Stop(emoteInUse); + view.StopLegacyAnimation(); + if (!pools.ContainsKey(mainAsset)) { - if (mainAsset.GetComponentInChildren(true)) + if (IsValid(mainAsset)) pools.Add(mainAsset, new GameObjectPool(poolRoot, () => CreateNewEmoteReference(mainAsset), onRelease: releaseEmoteReferences)); else return null; @@ -187,18 +210,42 @@ private bool IsSameLoopingEmote(EmoteReferences? emoteInUse, GameObject mainAsse return emoteReferences; } + private bool IsValid(GameObject mainAsset) => + mainAsset.GetComponentInChildren(true) + || (legacyAnimationsEnabled && mainAsset.GetComponentInChildren(true)); + private static EmoteReferences CreateNewEmoteReference(GameObject mainAsset) { GameObject mainGameObject = Object.Instantiate(mainAsset); - Animator animatorComp = mainGameObject.GetComponentInChildren(true); - AnimationClip[] animationClips = animatorComp.runtimeAnimatorController.animationClips; + Animator? animatorComp = mainGameObject.GetComponentInChildren(true); + Animation? animationComp = null; + AnimationClip[] animationClips; + + if (animatorComp != null && animatorComp.runtimeAnimatorController != null) + animationClips = animatorComp.runtimeAnimatorController.animationClips; + else + { + // Legacy path: GLTFast attached an Animation component with legacy clips + animatorComp = null; + animationComp = mainGameObject.GetComponentInChildren(true); + + List legacyClipList = ListPool.Get()!; + + if (animationComp != null) + foreach (AnimationState state in animationComp) + if (state.clip != null) + legacyClipList.Add(state.clip); + + animationClips = legacyClipList.ToArray(); + ListPool.Release(legacyClipList); + } EmoteReferences references = mainGameObject.AddComponent(); IReadOnlyList renderers = mainGameObject.GetComponentsInChildren(); List uniqueClips = ListPool.Get()!; - ExtractClips(animationClips, uniqueClips, out AnimationClip? avatarClip, out AnimationClip? propClip, out int propClipHash); + ExtractClips(animationClips, uniqueClips, out AnimationClip? avatarClip, out AnimationClip? propClip, out int propClipHash, out bool legacy); if (uniqueClips.Count == 1) { @@ -225,15 +272,48 @@ private static EmoteReferences CreateNewEmoteReference(GameObject mainAsset) } } - references.Initialize(avatarClip, propClip, animatorComp, propClipHash); + references.Initialize(avatarClip, propClip, animatorComp, animationComp, propClipHash, legacy); ListPool.Release(uniqueClips); - animatorComp.fireEvents = false; + if (animatorComp != null) + animatorComp.fireEvents = false; return references; } + private void PlayLegacyEmote(IAvatarView avatarView, ref CharacterEmoteComponent emoteComponent, EmoteReferences emoteReferences, bool loop) + { + // Disable the Mecanim animator before the legacy Animation starts: on the very first legacy + // emote of a fresh AvatarBase the Animation component is added live, and if the Animator is + // still enabled during this same frame it drives the shared transforms and the legacy clip + // has no visible effect until the next Play. (DisableAnimatorWhenPlayingLegacyAnimations + // later in the frame is a defence-in-depth, not a substitute.) + avatarView.AvatarAnimator.enabled = false; + + Animation animationComp = avatarView.AddOrGetLegacyAnimation(); + + animationComp.playAutomatically = false; + animationComp.Stop(); + + if (emoteReferences.avatarClip != null) + { + emoteComponent.EmoteLoop = loop; + string avatarClipName = emoteReferences.avatarClip.name; + animationComp.AddClip(emoteReferences.avatarClip, avatarClipName); + animationComp[avatarClipName].wrapMode = loop ? WrapMode.Loop : WrapMode.Once; + animationComp.Play(avatarClipName); + } + + if (emoteReferences.propClip != null && emoteReferences.animationComp != null) + { + Animation propAnimationComp = emoteReferences.animationComp; + string propClipName = emoteReferences.propClip.name; + propAnimationComp[propClipName].wrapMode = loop ? WrapMode.Loop : WrapMode.Once; + propAnimationComp.Play(propClipName); + } + } + private void PlayMecanimEmote(in IAvatarView view, ref CharacterEmoteComponent emoteComponent, EmoteReferences emoteReferences, bool isLooping) { if (emoteReferences.avatarClip != null) @@ -310,7 +390,8 @@ private static void ExtractClips( List uniqueClips, out AnimationClip? avatarClip, out AnimationClip? propClip, - out int propClipHash) + out int propClipHash, + out bool legacy) { avatarClip = null; propClip = null; @@ -337,6 +418,7 @@ private static void ExtractClips( } } + legacy = avatarClip != null && avatarClip.legacy; return; bool IsValidUniqueClip(AnimationClip clip) => diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/ResolveBuilderEmotePromisesSystem.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/ResolveBuilderEmotePromisesSystem.cs deleted file mode 100644 index 4eeede15df6..00000000000 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/ResolveBuilderEmotePromisesSystem.cs +++ /dev/null @@ -1,164 +0,0 @@ -using Arch.Core; -using Arch.System; -using Arch.SystemGroups; -using Arch.SystemGroups.DefaultSystemGroups; -using CommunicationData.URLHelpers; -using DCL.AvatarRendering.Loading.Components; -using DCL.Diagnostics; -using DCL.SDKComponents.AudioSources; -using ECS.Abstract; -using ECS.Prioritization.Components; -using ECS.StreamableLoading.GLTF; -using System; -using System.Collections.Generic; -using UnityEngine.Pool; -using StreamableResult = ECS.StreamableLoading.Common.Components.StreamableLoadingResult; -using GltfPromise = ECS.StreamableLoading.Common.AssetPromise; - -namespace DCL.AvatarRendering.Emotes.Systems -{ - [UpdateInGroup(typeof(PresentationSystemGroup))] - [UpdateAfter(typeof(FinalizeEmoteLoadingSystem))] - [LogCategory(ReportCategory.EMOTE)] - public partial class ResolveBuilderEmotePromisesSystem : BaseUnityLoopSystem - { - private static readonly BodyShape[] ALL_BODYSHAPES = { BodyShape.MALE, BodyShape.FEMALE }; - - private readonly IEmoteStorage emoteStorage; - internal readonly IURLBuilder urlBuilder = new URLBuilder(); - - public ResolveBuilderEmotePromisesSystem( - World world, - IEmoteStorage emoteStorage - ) : base(world) - { - this.emoteStorage = emoteStorage; - } - - protected override void Update(float t) - { - ResolveBuilderEmotePromiseQuery(World); - } - - [Query] - [None(typeof(StreamableResult))] - private void ResolveBuilderEmotePromise(Entity entity, ref GetTrimmedEmotesByParamIntention intention, ref IPartitionComponent partitionComponent) - { - if (intention.CancellationTokenSource.IsCancellationRequested) - { - World!.Add(entity, new StreamableResult(GetReportCategory(), new OperationCanceledException("Emotes request cancelled"))); - return; - } - - // Only create promises for builder collections - if (intention is { NeedsBuilderAPISigning: false } || intention.Results is not { Count: > 0 }) - return; - - bool allEmotesProcessed = true; - List emoteList = ListPool.Get(); - - foreach (ITrimmedEmote trimmedEmote in intention.Results) - { - // All trimmed emotes from the builder endpoint should be processable to full emotes - if (trimmedEmote is not IEmote emote) - continue; - - emoteList.Add(emote); - - if (TryCreateBuilderEmoteAssetPromises(emote, partitionComponent)) - allEmotesProcessed = false; - } - - if (allEmotesProcessed) - { - RepoolableList resultList = RepoolableList.NewList(); - resultList.List.AddRange(emoteList); - World!.Add(entity, new StreamableResult(new EmotesResolution(resultList, intention.TotalAmount))); - } - - ListPool.Release(emoteList); - } - - private bool TryCreateBuilderEmoteAssetPromises(in IEmote emote, in IPartitionComponent partitionComponent) - { - if (string.IsNullOrEmpty(emote.DTO.ContentDownloadUrl)) - return false; - - // Check if emote already has assets loaded or is loading - if (emote.IsLoading) - return true; // Still processing, not done yet - - // Check if we already have this emote in storage with assets - if (emoteStorage.TryGetElement(emote.GetUrn(), out IEmote existingEmote)) - { - // For unisex emotes with same clip, check if either bodyshape has assets - if (existingEmote.IsUnisex() && existingEmote.HasSameClipForAllGenders()) - { - if (existingEmote.AssetResults[BodyShape.MALE] != null || existingEmote.AssetResults[BodyShape.FEMALE] != null) - return false; // Already processed - } - else - { - // For non-unisex emotes, check both bodyshapes - if (existingEmote.AssetResults[BodyShape.MALE] != null && existingEmote.AssetResults[BodyShape.FEMALE] != null) - return false; // Already processed - } - } - - bool foundGlb = false; - bool stillProcessing = false; - - BodyShape? targetBodyShape = null; - if (!emote.IsUnisex()) - targetBodyShape = BodyShape.FromStringSafe(emote.DTO.Metadata.AbstractData.representations[0].bodyShapes[0]); - - // The resolution of these promises will be finalized by FinalizeEmoteLoadingSystem - foreach (var content in emote.DTO.content) - { - if (content.file.EndsWith(".glb")) - { - for (int i = 0; i < ALL_BODYSHAPES.Length; i++) - { - BodyShape bodyShape = ALL_BODYSHAPES[i]; - if (!emote.IsUnisex() && !bodyShape.Equals(targetBodyShape!)) - continue; - - // Skip if this bodyshape already has an asset result - if (emote.AssetResults[bodyShape] != null) - continue; - - var gltfPromise = GltfPromise.Create(World!, GetGLTFIntention.Create(content.file, content.hash), partitionComponent); - World!.Create(gltfPromise, emote, bodyShape); - emote.UpdateLoadingStatus(true); - foundGlb = true; - stillProcessing = true; - } - continue; - } - - // Supported audio format in emotes: https://docs.decentraland.org/creator/emotes/props-and-sounds/#add-audio-to-the-emotes - if (content.file.EndsWith(".mp3") || content.file.EndsWith(".ogg")) - { - var audioType = content.file.ToAudioType(); - urlBuilder.Clear(); - urlBuilder.AppendDomain(URLDomain.FromString(emote.DTO.ContentDownloadUrl)).AppendPath(new URLPath(content.hash)); - URLAddress url = urlBuilder.Build(); - - for (int i = 0; i < ALL_BODYSHAPES.Length; i++) - { - BodyShape bodyShape = ALL_BODYSHAPES[i]; - if (!emote.IsUnisex() && !bodyShape.Equals(targetBodyShape)) - continue; - - var audioPromise = AudioUtils.CreateAudioClipPromise(World!, url.Value, audioType, partitionComponent); - World!.Create(audioPromise, emote, bodyShape); - } - - if (foundGlb) break; - } - } - - return stillProcessing; - } - } -} diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/ResolveBuilderEmotePromisesSystem.cs.meta b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/ResolveBuilderEmotePromisesSystem.cs.meta deleted file mode 100644 index eb77c72d229..00000000000 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/ResolveBuilderEmotePromisesSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: a048e45735f404d79bf7c088846f36a4 \ No newline at end of file diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/BuilderEmoteAssetPromiseFactoryShould.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/BuilderEmoteAssetPromiseFactoryShould.cs new file mode 100644 index 00000000000..ef182ff69c0 --- /dev/null +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/BuilderEmoteAssetPromiseFactoryShould.cs @@ -0,0 +1,212 @@ +using Arch.Core; +using CommunicationData.URLHelpers; +using DCL.AvatarRendering.Loading.Components; +using DCL.AvatarRendering.Loading.DTO; +using DCL.Diagnostics; +using DCL.Ipfs; +using ECS.Prioritization.Components; +using ECS.StreamableLoading.Common.Components; +using NUnit.Framework; +using System; +using AudioPromise = ECS.StreamableLoading.Common.AssetPromise; +using GltfPromise = ECS.StreamableLoading.Common.AssetPromise; +using MockEmote = DCL.AvatarRendering.Emotes.Tests.FinalizeEmoteLoadingSystemShould.MockEmote; +using MockEmoteStorage = DCL.AvatarRendering.Emotes.Tests.FinalizeEmoteLoadingSystemShould.MockEmoteStorage; + +namespace DCL.AvatarRendering.Emotes.Tests +{ + public class BuilderEmoteAssetPromiseFactoryShould + { + private const string CONTENT_URL = "https://peer.decentraland.org/content/contents/"; + private static readonly URN EMOTE_URN = new ("urn:test:builder-emote"); + + private World world = null!; + private IURLBuilder urlBuilder = null!; + private MockEmoteStorage storage = null!; + + [SetUp] + public void SetUp() + { + world = World.Create(); + urlBuilder = new URLBuilder(); + storage = new MockEmoteStorage(); + } + + [TearDown] + public void TearDown() + { + world.Dispose(); + } + + [Test] + public void ReturnFalse_WhenContentDownloadUrlIsEmpty() + { + MockEmote emote = CreateEmote(contentDownloadUrl: string.Empty, unisex: true, content: new[] { ("clip.glb", "hash1") }); + + bool result = BuilderEmoteAssetPromiseFactory.TryCreate(world, emote, PartitionComponent.TOP_PRIORITY, storage, urlBuilder); + + Assert.IsFalse(result, "Without a download URL the factory cannot resolve content paths, so it must bail out."); + Assert.AreEqual(0, CountComponents()); + } + + [Test] + public void ReturnTrue_WhenEmoteIsAlreadyLoading() + { + MockEmote emote = CreateEmote(contentDownloadUrl: CONTENT_URL, unisex: true, content: new[] { ("clip.glb", "hash1") }); + emote.IsLoading = true; + + bool result = BuilderEmoteAssetPromiseFactory.TryCreate(world, emote, PartitionComponent.TOP_PRIORITY, storage, urlBuilder); + + Assert.IsTrue(result, "IsLoading short-circuit must still signal that the emote is being processed."); + Assert.AreEqual(0, CountComponents(), + "An already-loading emote must not spawn duplicate promises — the in-flight flow owns completion."); + } + + [Test] + public void ReturnFalse_WhenUnisexSharesClipAndEitherSlotAlreadyLoaded() + { + MockEmote emote = CreateEmote(contentDownloadUrl: CONTENT_URL, unisex: true, content: new[] { ("clip.glb", "hash1") }); + emote.MockHasSameClipForAllGendersValue = true; + emote.AssetResults[BodyShape.MALE] = DummyAssetResult(); + storage.Set(emote.Urn, emote); + + bool result = BuilderEmoteAssetPromiseFactory.TryCreate(world, emote, PartitionComponent.TOP_PRIORITY, storage, urlBuilder); + + Assert.IsFalse(result); + Assert.AreEqual(0, CountComponents(), + "Unisex-shared-clip emotes are considered complete once either gender slot is populated."); + } + + [Test] + public void ReturnFalse_WhenNonUnisexAndBothSlotsAlreadyLoaded() + { + MockEmote emote = CreateEmote(contentDownloadUrl: CONTENT_URL, unisex: false, content: new[] { ("clip.glb", "hash1") }); + emote.AssetResults[BodyShape.MALE] = DummyAssetResult(); + emote.AssetResults[BodyShape.FEMALE] = DummyAssetResult(); + storage.Set(emote.Urn, emote); + + bool result = BuilderEmoteAssetPromiseFactory.TryCreate(world, emote, PartitionComponent.TOP_PRIORITY, storage, urlBuilder); + + Assert.IsFalse(result); + Assert.AreEqual(0, CountComponents()); + } + + [Test] + public void CreateGltfPromiseForBothBodyShapes_WhenEmoteIsUnisex() + { + MockEmote emote = CreateEmote(contentDownloadUrl: CONTENT_URL, unisex: true, content: new[] { ("clip.glb", "hash1") }); + + bool result = BuilderEmoteAssetPromiseFactory.TryCreate(world, emote, PartitionComponent.TOP_PRIORITY, storage, urlBuilder); + + Assert.IsTrue(result, "Creating new GLB promises must report still-processing so callers keep waiting."); + Assert.AreEqual(2, CountComponents(), + "Unisex .glb must create one GltfPromise per body shape so both avatar genders can animate."); + Assert.IsTrue(emote.IsLoading, + "UpdateLoadingStatus(true) must be called so follow-up TryCreate calls short-circuit while the load is in flight."); + } + + [Test] + public void CreateGltfPromiseOnlyForRepresentationBodyShape_WhenEmoteIsGendered() + { + MockEmote emote = CreateEmote( + contentDownloadUrl: CONTENT_URL, + unisex: false, + bodyShape: BodyShape.FEMALE.Value, + content: new[] { ("clip.glb", "hash1") }); + + bool result = BuilderEmoteAssetPromiseFactory.TryCreate(world, emote, PartitionComponent.TOP_PRIORITY, storage, urlBuilder); + + Assert.IsTrue(result); + Assert.AreEqual(1, CountComponents(), + "Gendered emotes must only spawn a promise for the body shape declared in their representation."); + Assert.IsNull(emote.AssetResults[BodyShape.MALE], + "The non-target body shape slot must remain untouched."); + } + + [Test] + public void SkipBodyShape_WhenAssetResultAlreadyPopulated() + { + MockEmote emote = CreateEmote(contentDownloadUrl: CONTENT_URL, unisex: true, content: new[] { ("clip.glb", "hash1") }); + emote.AssetResults[BodyShape.MALE] = DummyAssetResult(); + + bool result = BuilderEmoteAssetPromiseFactory.TryCreate(world, emote, PartitionComponent.TOP_PRIORITY, storage, urlBuilder); + + Assert.IsTrue(result); + Assert.AreEqual(1, CountComponents(), + "Already-populated slots must be skipped so the existing asset isn't clobbered by a duplicate load."); + } + + [Test] + public void CreateAudioPromiseForEachBodyShape_WhenContentHasAudioFile() + { + MockEmote emote = CreateEmote( + contentDownloadUrl: CONTENT_URL, + unisex: true, + content: new[] { ("sound.mp3", "audiohash") }); + + BuilderEmoteAssetPromiseFactory.TryCreate(world, emote, PartitionComponent.TOP_PRIORITY, storage, urlBuilder); + + Assert.AreEqual(2, CountComponents(), + "Unisex .mp3 content must produce one audio promise per body shape so both avatar genders play the emote SFX."); + } + + private int CountComponents() where T: struct + { + var query = new QueryDescription().WithAll(); + var count = 0; + world.Query(query, (ref T _) => count++); + return count; + } + + private static StreamableLoadingResult? DummyAssetResult() => + new StreamableLoadingResult(ReportData.UNSPECIFIED, new Exception("dummy")); + + private MockEmote CreateEmote(string contentDownloadUrl, bool unisex, (string file, string hash)[] content, string? bodyShape = null) + { + var dto = new TestEmoteDTO(); + dto.SetContentDownloadUrl(contentDownloadUrl); + dto.id = EMOTE_URN.ToString(); + + AvatarAttachmentDTO.Representation[] representations = unisex + ? new[] + { + new AvatarAttachmentDTO.Representation { bodyShapes = new[] { BodyShape.MALE.Value }, contents = Array.Empty() }, + new AvatarAttachmentDTO.Representation { bodyShapes = new[] { BodyShape.FEMALE.Value }, contents = Array.Empty() }, + } + : new[] + { + new AvatarAttachmentDTO.Representation { bodyShapes = new[] { bodyShape ?? BodyShape.MALE.Value }, contents = Array.Empty() }, + }; + + dto.metadata = new EmoteDTO.EmoteMetadataDto + { + id = EMOTE_URN.ToString(), + name = "Test", + emoteDataADR74 = new EmoteDTO.EmoteMetadataDto.Data + { + representations = representations, + }, + }; + + dto.content = BuildContent(content); + + var emote = new MockEmote(EMOTE_URN, storage); + emote.ApplyAndMarkAsLoaded(dto); + return emote; + } + + private static ContentDefinition[] BuildContent((string file, string hash)[] pairs) + { + var arr = new ContentDefinition[pairs.Length]; + for (var i = 0; i < pairs.Length; i++) + arr[i] = new ContentDefinition { file = pairs[i].file, hash = pairs[i].hash }; + return arr; + } + + private class TestEmoteDTO : EmoteDTO + { + public void SetContentDownloadUrl(string? url) => + ContentDownloadUrl = url; + } + } +} diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/BuilderEmoteAssetPromiseFactoryShould.cs.meta b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/BuilderEmoteAssetPromiseFactoryShould.cs.meta new file mode 100644 index 00000000000..2f03fcfd530 --- /dev/null +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/BuilderEmoteAssetPromiseFactoryShould.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a67d73c71b3814a50a1732f7a3fae942 \ No newline at end of file diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteComponentShould.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteComponentShould.cs new file mode 100644 index 00000000000..2af9cf24517 --- /dev/null +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteComponentShould.cs @@ -0,0 +1,73 @@ +using NUnit.Framework; +using UnityEngine; +using Utility.Animations; + +namespace DCL.AvatarRendering.Emotes.Tests +{ + public class CharacterEmoteComponentShould + { + private GameObject gameObject = null!; + private EmoteReferences emoteReferences = null!; + + [SetUp] + public void SetUp() + { + gameObject = new GameObject(nameof(CharacterEmoteComponentShould)); + emoteReferences = gameObject.AddComponent(); + } + + [TearDown] + public void TearDown() + { + if (gameObject != null) Object.DestroyImmediate(gameObject); + } + + [Test] + public void IsPlayingEmote_ReturnsFalse_WhenNoReferenceAndNoAnimatorTag() + { + var emoteComponent = new CharacterEmoteComponent(); + + Assert.IsFalse(emoteComponent.IsPlayingEmote); + } + + [Test] + public void IsPlayingEmote_ReturnsTrue_WhenLegacyReferenceIsSet() + { + emoteReferences.Initialize(null, null, null, null, 0, legacy: true); + + var emoteComponent = new CharacterEmoteComponent { CurrentEmoteReference = emoteReferences }; + + Assert.IsTrue(emoteComponent.IsPlayingEmote, + "Legacy emotes bypass the Mecanim animator so IsPlayingEmote must honour the reference's legacy flag."); + } + + [Test] + public void IsPlayingEmote_ReturnsFalse_WhenNonLegacyReferenceAndNoAnimatorTag() + { + emoteReferences.Initialize(null, null, null, null, 0, legacy: false); + + var emoteComponent = new CharacterEmoteComponent { CurrentEmoteReference = emoteReferences }; + + Assert.IsFalse(emoteComponent.IsPlayingEmote, + "Mecanim emotes only count as playing while the animator is actually in the EMOTE or EMOTE_LOOP tag."); + } + + [Test] + public void IsPlayingEmote_ReturnsTrue_WhenAnimatorInEmoteTag() + { + var emoteComponent = new CharacterEmoteComponent(); + emoteComponent.SetAnimationTag(AnimationHashes.EMOTE); + + Assert.IsTrue(emoteComponent.IsPlayingEmote); + } + + [Test] + public void IsPlayingEmote_ReturnsTrue_WhenAnimatorInEmoteLoopTag() + { + var emoteComponent = new CharacterEmoteComponent(); + emoteComponent.SetAnimationTag(AnimationHashes.EMOTE_LOOP); + + Assert.IsTrue(emoteComponent.IsPlayingEmote); + } + } +} diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteComponentShould.cs.meta b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteComponentShould.cs.meta new file mode 100644 index 00000000000..1420395452a --- /dev/null +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteComponentShould.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5ee4f663e06f347308e9dbbfb060175d \ No newline at end of file diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/EmoteShould.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/EmoteShould.cs index b23a2456645..313c6da345d 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/EmoteShould.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/EmoteShould.cs @@ -38,5 +38,22 @@ public void BeOffChain(string urn) Assert.IsFalse(emote.IsOnChain()); } + + [TestCase(true)] + [TestCase(false)] + public void ReflectLoopFlagFromDto(bool loop) + { + var model = new EmoteDTO + { + metadata = new EmoteDTO.EmoteMetadataDto + { + emoteDataADR74 = new EmoteDTO.EmoteMetadataDto.Data { loop = loop }, + }, + }; + + Emote emote = new Emote(new StreamableLoadingResult(model)); + + Assert.AreEqual(loop, emote.IsLooping()); + } } } diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/GetSceneEmoteFromLocalSceneIntentionShould.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/GetSceneEmoteFromLocalSceneIntentionShould.cs new file mode 100644 index 00000000000..83db5a313ed --- /dev/null +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/GetSceneEmoteFromLocalSceneIntentionShould.cs @@ -0,0 +1,79 @@ +using Arch.Core; +using DCL.AvatarRendering.Loading.Components; +using DCL.ECSComponents; +using ECS.Prioritization.Components; +using ECS.StreamableLoading.GLTF; +using NSubstitute; +using NUnit.Framework; +using SceneRunner.Scene; + +using GltfPromise = ECS.StreamableLoading.Common.AssetPromise; + +namespace DCL.AvatarRendering.Emotes.Tests +{ + public class GetSceneEmoteFromLocalSceneIntentionShould + { + private World world = null!; + + [SetUp] + public void SetUp() + { + world = World.Create(); + } + + [TearDown] + public void TearDown() + { + world.Dispose(); + } + + [Test] + public void MaskFullBody_RequestsLegacyClips() + { + GetGLTFIntention gltfIntention = CreatePromiseAndGetGLTFIntention(AvatarEmoteMask.AemFullBody); + + Assert.IsFalse(gltfIntention.MecanimAnimationClips, + "FullBody scene emotes must load as Legacy animations to avoid GLTFast SetCurve failures in builds."); + } + + [Test] + public void MaskUpperBody_RequestsMecanimClips() + { + GetGLTFIntention gltfIntention = CreatePromiseAndGetGLTFIntention(AvatarEmoteMask.AemUpperBody); + + Assert.IsTrue(gltfIntention.MecanimAnimationClips, + "Masked scene emotes must load as Mecanim clips because AnimatorOverrideController + layer masks are Mecanim-only."); + } + + [Test] + public void EqualityDistinguishesMask() + { + ISceneData sceneData = Substitute.For(); + + var fullBody = new GetSceneEmoteFromLocalSceneIntention(sceneData, "emote.glb", "hash", BodyShape.MALE, loop: false, AvatarEmoteMask.AemFullBody); + var upperBody = new GetSceneEmoteFromLocalSceneIntention(sceneData, "emote.glb", "hash", BodyShape.MALE, loop: false, AvatarEmoteMask.AemUpperBody); + + Assert.IsFalse(fullBody.Equals(upperBody), + "Intentions with identical hash/path/bodyShape but different masks must not be equal to prevent promise-cache cross-contamination."); + } + + private GetGLTFIntention CreatePromiseAndGetGLTFIntention(AvatarEmoteMask mask) + { + ISceneData sceneData = Substitute.For(); + IEmote emote = Substitute.For(); + + var intention = new GetSceneEmoteFromLocalSceneIntention(sceneData, "emote.glb", "hash", BodyShape.MALE, loop: false, mask); + + intention.CreateAndAddPromiseToWorld(world, PartitionComponent.TOP_PRIORITY, null, emote); + + var query = new QueryDescription().WithAll(); + GltfPromise? capturedPromise = null; + + world.Query(query, (ref GltfPromise p) => capturedPromise = p); + + Assert.IsNotNull(capturedPromise, "CreateAndAddPromiseToWorld should create an entity carrying a GltfPromise."); + + return capturedPromise!.Value.LoadingIntention; + } + } +} diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/GetSceneEmoteFromLocalSceneIntentionShould.cs.meta b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/GetSceneEmoteFromLocalSceneIntentionShould.cs.meta new file mode 100644 index 00000000000..10025c0fb98 --- /dev/null +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/GetSceneEmoteFromLocalSceneIntentionShould.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ecd5c2d187de34a56aeabc475e92bf44 \ No newline at end of file diff --git a/Explorer/Assets/DCL/AvatarRendering/Loading/Systems/LoadTrimmedElementsByIntentionSystem.cs b/Explorer/Assets/DCL/AvatarRendering/Loading/Systems/LoadTrimmedElementsByIntentionSystem.cs index 90fc3a4405a..50cb9af74d4 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Loading/Systems/LoadTrimmedElementsByIntentionSystem.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Loading/Systems/LoadTrimmedElementsByIntentionSystem.cs @@ -111,6 +111,11 @@ await ParseBuilderResponseAsync( await using (await ExecuteOnThreadPoolScope.NewScopeWithReturnOnMainThreadAsync()) LoadBuilderItem(ref intention, lambdaResponse); + + // Create dependent asset promises on the intention entity's partition BEFORE returning, + // so they exist before the promise result is added and the entity is consumed/destroyed + // by the async caller. + AfterBuilderItemsLoaded(ref intention, partition); } else { @@ -168,7 +173,8 @@ private async UniTask GetABVersionsAsync(IAttachmentLambda private void LoadBuilderItem(ref TIntention intention, IBuilderLambdaResponse> lambdaResponse) { - if (string.IsNullOrEmpty(builderContentURL)) return; + if (string.IsNullOrEmpty(builderContentURL)) + return; if (lambdaResponse.CollectionElements is { Count: > 0 }) { @@ -241,5 +247,7 @@ private async UniTask ProcessElementAsync(ILambdaResponseElement protected abstract UniTask>> ParseBuilderResponseAsync(GenericDownloadHandlerUtils.Adapter adapter); protected abstract TAsset AssetFromPreparedIntention(in TIntention intention); + + protected virtual void AfterBuilderItemsLoaded(ref TIntention intention, IPartitionComponent partition) { } } } diff --git a/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/RestrictedActions/GlobalWorldActions.cs b/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/RestrictedActions/GlobalWorldActions.cs index e24b633d865..67dd75b906c 100644 --- a/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/RestrictedActions/GlobalWorldActions.cs +++ b/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/RestrictedActions/GlobalWorldActions.cs @@ -122,7 +122,7 @@ public void TriggerEmote(URN urn, bool isLooping, AvatarEmoteMask mask) if (loadFromLocalScene) { if (src.ToLower().EndsWith(SCENE_EMOTE_NAMING)) - result = await ResolveSceneEmoteFromLocalSceneAsync(sceneData, src, hash, loop, ct); + result = await ResolveSceneEmoteFromLocalSceneAsync(sceneData, src, hash, loop, mask, ct); else ReportHub.LogError(ReportCategory.EMOTE, $"'{src}' scene emote cannot be played. It must follow the naming convention ending in '{SCENE_EMOTE_NAMING}'"); } @@ -175,14 +175,14 @@ public void StopEmote() return null; } - private async UniTask<(URN Urn, bool IsLooping)?> ResolveSceneEmoteFromLocalSceneAsync(ISceneData sceneData, string emotePath, string emoteHash, bool loop, CancellationToken ct) + private async UniTask<(URN Urn, bool IsLooping)?> ResolveSceneEmoteFromLocalSceneAsync(ISceneData sceneData, string emotePath, string emoteHash, bool loop, AvatarEmoteMask mask, CancellationToken ct) { if (!world.TryGet(playerEntity, out AvatarShapeComponent avatarShape)) return null; var promise = LocalSceneEmotePromise.Create(world, new GetSceneEmoteFromLocalSceneIntention(sceneData, emotePath, emoteHash, - avatarShape.BodyShape, loop), + avatarShape.BodyShape, loop, mask), PartitionComponent.TOP_PRIORITY); promise = await promise.ToUniTaskAsync(world, cancellationToken: ct); diff --git a/Explorer/Assets/DCL/PluginSystem/Global/EmotePlugin.cs b/Explorer/Assets/DCL/PluginSystem/Global/EmotePlugin.cs index 55af9f4dd84..dc563598574 100644 --- a/Explorer/Assets/DCL/PluginSystem/Global/EmotePlugin.cs +++ b/Explorer/Assets/DCL/PluginSystem/Global/EmotePlugin.cs @@ -5,7 +5,6 @@ using DCL.AssetsProvision; using DCL.AvatarRendering.Emotes; using DCL.AvatarRendering.Emotes.Load; -using DCL.AvatarRendering.Emotes.Systems; using DCL.AvatarRendering.Wearables; using DCL.Backpack; using DCL.DebugUtilities; @@ -155,10 +154,7 @@ public void InjectToWorld(ref ArchSystemsWorldBuilder builder, emoteStorage, trimmedEmoteStorage, EMOTES_COMPLEMENT_URL, decentralandUrlsSource, builderContentURL); - if(builderCollectionsPreview) - ResolveBuilderEmotePromisesSystem.InjectToWorld(ref builder, emoteStorage); - - var sharedEmotePlayer = new EmotePlayer(audioSourceReference!); + var sharedEmotePlayer = new EmotePlayer(audioSourceReference!, legacyAnimationsEnabled: localSceneDevelopment || builderCollectionsPreview); emotePlayerProxy.SetObject(sharedEmotePlayer); CharacterEmoteSystem.InjectToWorld(ref builder, emoteStorage, messageBus, sharedEmotePlayer, debugBuilder, localSceneDevelopment, scenesCache); diff --git a/Explorer/Assets/DCL/SDKComponents/AvatarShape/Systems/AvatarShapeHandlerSystem.cs b/Explorer/Assets/DCL/SDKComponents/AvatarShape/Systems/AvatarShapeHandlerSystem.cs index a7f6d14340c..7c17acb4eb0 100644 --- a/Explorer/Assets/DCL/SDKComponents/AvatarShape/Systems/AvatarShapeHandlerSystem.cs +++ b/Explorer/Assets/DCL/SDKComponents/AvatarShape/Systems/AvatarShapeHandlerSystem.cs @@ -165,7 +165,8 @@ private void AddCharacterEmoteIntent(Entity globalWorldEntity, ref SDKAvatarShap emoteId, hash, bodyShape, - loop: false), PartitionComponent.TOP_PRIORITY); + loop: false, + mask: AvatarEmoteMask.AemFullBody), PartitionComponent.TOP_PRIORITY); } else { diff --git a/docs/unreleased-wearables-emotes-preview.md b/docs/unreleased-wearables-emotes-preview.md index 8e88b97bbc9..6db52330f5c 100644 --- a/docs/unreleased-wearables-emotes-preview.md +++ b/docs/unreleased-wearables-emotes-preview.md @@ -103,14 +103,15 @@ LoadTrimmedEmotesByParamSystem - SignedFetchGetAsync() (same signing mechanism) - Parses BuilderEmoteDTO.BuilderLambdaResponse - Calls LoadBuilderItem(): BuildElementDTO(builderContentURL) - ↓ -ResolveBuilderEmotePromisesSystem [only injected when builderCollectionsPreview = true] - - For each emote in results with ContentDownloadUrl set: - .glb file → creates GetGLTFIntention - → World.Create(gltfPromise, emote, bodyShape) - .mp3/.ogg → creates AudioClipPromise via AudioUtils - URL = ContentDownloadUrl + content hash - - Marks as StreamableResult when all promises resolve + - AfterBuilderItemsLoaded() → BuilderEmoteAssetPromiseFactory.TryCreate(...) + For each emote in results with ContentDownloadUrl set: + .glb file → creates GetGLTFIntention + → World.Create(gltfPromise, emote, bodyShape) + .mp3/.ogg → creates AudioClipPromise via AudioUtils + URL = ContentDownloadUrl + content hash + - Promises are created on the intention's partition before the + StreamableLoadingResult is added, so they survive the + intention entity's destruction by the async caller. ``` ### Key files @@ -119,10 +120,10 @@ ResolveBuilderEmotePromisesSystem [only injected when builderCollectionsPreview |------|------| | `ApplicationParamsEmoteProvider.cs` | Intercepts emote requests, applies Builder collection UUIDs, sets signing flag | | `GetTrimmedEmotesByParamIntention.cs` | ECS intention component; carries `NeedsBuilderAPISigning` flag and resolved URL | -| `LoadTrimmedEmotesByParamSystem.cs` | Picks up intentions, calls signed fetch, parses `BuilderLambdaResponse` | +| `LoadTrimmedEmotesByParamSystem.cs` | Picks up intentions, calls signed fetch, parses `BuilderLambdaResponse`, and creates GLTF/audio promises via `AfterBuilderItemsLoaded` hook | | `EmoteDTO.cs` | Defines `BuilderEmoteMetadataDto` and `BuildElementDTO()` | -| `ResolveBuilderEmotePromisesSystem.cs` | Resolves GLTF and audio promises for Builder emotes | -| `EmotePlugin.cs` | Contains the conditional injection guard for Builder-only systems | +| `BuilderEmoteAssetPromiseFactory.cs` | Creates GLTF + audio promises for each builder emote | +| `EmotePlugin.cs` | Wires dependencies and gates Builder-only behavior | ---