From 69c469096ae6f36d0ace7c6cb62f96baeb03e455 Mon Sep 17 00:00:00 2001 From: davidejensen Date: Thu, 2 Jul 2026 16:08:42 +0200 Subject: [PATCH] fix: add mechanism to force stop stuck emotes --- .../Components/CharacterEmoteComponent.cs | 3 + .../Systems/Play/CharacterEmoteSystem.cs | 56 ++++++- .../Emotes/Systems/Play/EmotePlayer.cs | 2 + .../Tests/CharacterEmoteComponentShould.cs | 10 ++ .../Tests/CharacterEmoteSystemShould.cs | 153 ++++++++++++++++++ .../Tests/CharacterEmoteSystemShould.cs.meta | 11 ++ 6 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteSystemShould.cs create mode 100644 Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteSystemShould.cs.meta diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/CharacterEmoteComponent.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/CharacterEmoteComponent.cs index 46d54d088a8..0eabbb6654c 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/CharacterEmoteComponent.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/CharacterEmoteComponent.cs @@ -13,6 +13,8 @@ public struct CharacterEmoteComponent public bool StopEmote; public AvatarEmoteMask Mask; + public float PlayingTime; + private int currentAnimationTag; public float PlayingEmoteDuration => CurrentEmoteReference?.avatarClip @@ -35,6 +37,7 @@ public void Reset() CurrentEmoteReference = null; StopEmote = false; Mask = AvatarEmoteMask.AemFullBody; + PlayingTime = 0f; } } } diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/CharacterEmoteSystem.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/CharacterEmoteSystem.cs index fe315abb139..aaccbf322ac 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/CharacterEmoteSystem.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/CharacterEmoteSystem.cs @@ -45,6 +45,9 @@ namespace DCL.AvatarRendering.Emotes.Play [UpdateBefore(typeof(ChangeCharacterPositionGroup))] public partial class CharacterEmoteSystem : BaseUnityLoopSystem { + private const float STUCK_EMOTE_STOP_RETRY_PERIOD = 1f; + private const float EMOTE_OVERRUN_GRACE = 1f; + private static readonly string SCENE_EMOTE_PREFIX_WITH_COLON = GetSceneEmoteFromRealmIntention.SCENE_EMOTE_PREFIX + ":"; // todo: use this to add nice Debug UI to trigger any emote? @@ -90,6 +93,7 @@ protected override void Update(float t) CancelMaskedEmotesByDeletionQuery(World); UpdateEmoteTagsQuery(World); UpdateRemoteMaskedEmoteTagsQuery(World); + ForceStopStuckEmotesQuery(World, t); DisableCharacterControllerQuery(World); CleanUpQuery(World); } @@ -233,10 +237,7 @@ private void StopEmote(Entity entity, ref CharacterEmoteComponent emoteComponent // 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); - avatarView.SetAnimatorTrigger(AnimationHashes.EMOTE_STOP); + ResetAnimatorToStopEmote(avatarView); // See https://github.com/decentraland/unity-explorer/issues/4198 // Some emotes changes the armature rotation, we need to restore it @@ -249,6 +250,14 @@ private void StopEmote(Entity entity, ref CharacterEmoteComponent emoteComponent emoteComponent.Reset(); } + private static void ResetAnimatorToStopEmote(IAvatarView avatarView) + { + avatarView.ResetAnimatorTrigger(AnimationHashes.EMOTE); + avatarView.ResetAnimatorTrigger(AnimationHashes.EMOTE_RESET); + avatarView.SetAnimatorBool(AnimationHashes.EMOTE_LOOP, false); + avatarView.SetAnimatorTrigger(AnimationHashes.EMOTE_STOP); + } + /// /// Handles cancellation of masked emotes on remote player entities in the global world. /// Local player masked emotes are handled by SceneMaskedEmoteSystem in each scene world. @@ -263,6 +272,45 @@ private void CancelRemoteMaskedEmotes(ref CharacterMaskedEmoteComponent masked, private void UpdateRemoteMaskedEmoteTags(ref CharacterMaskedEmoteComponent masked, in IAvatarView avatarView) => EmotePlayer.UpdateMaskedEmoteTag(ref masked, avatarView); + // This query recovers avatars whose animator remains in an emote-tagged state after the emote should have ended + [Query] + [None(typeof(CharacterEmoteIntent), typeof(DeleteEntityIntention))] + private void ForceStopStuckEmotes([Data] float dt, Entity entity, ref CharacterEmoteComponent emoteComponent, in IAvatarView avatarView) + { + if (!emoteComponent.IsPlayingEmote) + { + emoteComponent.PlayingTime = 0f; + return; + } + + EmoteReferences? emoteReference = emoteComponent.CurrentEmoteReference; + + if (emoteReference == null) + { + emoteComponent.PlayingTime += dt; + + if (emoteComponent.PlayingTime < STUCK_EMOTE_STOP_RETRY_PERIOD) return; + + ReportHub.LogWarning(GetReportData(), "Animator stuck in an emote state without an emote reference, re-issuing the stop trigger"); + ResetAnimatorToStopEmote(avatarView); + emoteComponent.PlayingTime = 0f; + return; + } + + if (emoteComponent.EmoteLoop || emoteReference.legacy) + { + emoteComponent.PlayingTime = 0f; + return; + } + + emoteComponent.PlayingTime += dt; + + if (emoteComponent.PlayingTime < emoteComponent.PlayingEmoteDuration + EMOTE_OVERRUN_GRACE) return; + + ReportHub.LogWarning(GetReportData(), $"Emote {emoteComponent.EmoteUrn} exceeded its expected duration, force-stopping"); + StopEmote(entity, ref emoteComponent, avatarView); + } + // This query takes care of consuming the CharacterEmoteIntent to trigger an emote [Query] [None(typeof(DeleteEntityIntention), typeof(PlayerTeleportIntent.JustTeleported))] diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/EmotePlayer.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/EmotePlayer.cs index c41bab9032e..b01933ddb3b 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/EmotePlayer.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/EmotePlayer.cs @@ -72,6 +72,8 @@ public bool Play(GameObject mainAsset, AudioClip? audioAsset, bool isLooping, bo emotesInUse.Add(emoteReferences, pools[mainAsset]); emoteComponent.CurrentEmoteReference = emoteReferences; + + emoteComponent.PlayingTime = 0f; return true; } diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteComponentShould.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteComponentShould.cs index 2af9cf24517..764428db16f 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteComponentShould.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteComponentShould.cs @@ -69,5 +69,15 @@ public void IsPlayingEmote_ReturnsTrue_WhenAnimatorInEmoteLoopTag() Assert.IsTrue(emoteComponent.IsPlayingEmote); } + + [Test] + public void Reset_ZeroesPlayingTime() + { + var emoteComponent = new CharacterEmoteComponent { PlayingTime = 5f }; + + emoteComponent.Reset(); + + Assert.AreEqual(0f, emoteComponent.PlayingTime); + } } } diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteSystemShould.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteSystemShould.cs new file mode 100644 index 00000000000..e2e9c7f396b --- /dev/null +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteSystemShould.cs @@ -0,0 +1,153 @@ +using Arch.Core; +using DCL.AvatarRendering.AvatarShape.UnityInterface; +using DCL.AvatarRendering.Emotes.Play; +using DCL.DebugUtilities; +using DCL.Multiplayer.Emotes; +using ECS.SceneLifeCycle; +using ECS.TestSuite; +using NSubstitute; +using NUnit.Framework; +using UnityEngine; +using Utility.Animations; +using Object = UnityEngine.Object; + +namespace DCL.AvatarRendering.Emotes.Tests +{ + /// + /// Covers the stuck-emote watchdog (https://github.com/decentraland/unity-explorer/issues/9115): + /// the avatar must always recover when the animator never leaves an emote-tagged state. + /// + public class CharacterEmoteSystemShould : UnitySystemTestBase + { + private IAvatarView avatarView = null!; + private IEmotesMessageBus messageBus = null!; + private GameObject poolRoot = null!; + private GameObject audioSourcePrefab = null!; + private GameObject emoteReferencesGameObject = null!; + private EmoteMaskCatalog emoteMaskCatalog = null!; + + [SetUp] + public void SetUp() + { + poolRoot = new GameObject("ROOT_POOL_CONTAINER"); + audioSourcePrefab = new GameObject("AudioSourcePrefab"); + emoteReferencesGameObject = new GameObject(nameof(CharacterEmoteSystemShould)); + emoteMaskCatalog = ScriptableObject.CreateInstance(); + + var emotePlayer = new EmotePlayer(audioSourcePrefab.AddComponent(), emoteMaskCatalog); + + messageBus = Substitute.For(); + + system = new CharacterEmoteSystem( + world, + Substitute.For(), + messageBus, + emotePlayer, + Substitute.For(), + false, + Substitute.For()); + + avatarView = Substitute.For(); + } + + protected override void OnTearDown() + { + if (poolRoot != null) Object.DestroyImmediate(poolRoot); + if (audioSourcePrefab != null) Object.DestroyImmediate(audioSourcePrefab); + if (emoteReferencesGameObject != null) Object.DestroyImmediate(emoteReferencesGameObject); + if (emoteMaskCatalog != null) Object.DestroyImmediate(emoteMaskCatalog); + } + + [Test] + public void ReissueStopTrigger_WhenAnimatorStuckInEmoteTagWithoutReference() + { + // Arrange: the animator reports an emote tag but the emote was already torn down (no reference) + avatarView.GetAnimatorCurrentStateTag(AnimatorEmoteLayers.BASE_LAYER).Returns(AnimationHashes.EMOTE); + world.Create(new CharacterEmoteComponent(), avatarView); + + // Act: exceed the retry period + system!.Update(0.5f); + system.Update(0.5f); + + // Assert + avatarView.Received(1).SetAnimatorTrigger(AnimationHashes.EMOTE_STOP); + avatarView.Received(1).SetAnimatorBool(AnimationHashes.EMOTE_LOOP, false); + + // Act: a further period elapses while still stuck + system.Update(0.5f); + system.Update(0.5f); + + // Assert: the stop trigger is re-issued periodically, not every frame + avatarView.Received(2).SetAnimatorTrigger(AnimationHashes.EMOTE_STOP); + } + + [Test] + public void NotReissueStopTrigger_WithinTransitionGraceWindow() + { + // Arrange: same dead-end state, but less time than the retry period elapses + avatarView.GetAnimatorCurrentStateTag(AnimatorEmoteLayers.BASE_LAYER).Returns(AnimationHashes.EMOTE); + world.Create(new CharacterEmoteComponent(), avatarView); + + // Act + system!.Update(0.9f); + + // Assert: the normal post-stop transition window is left alone + avatarView.DidNotReceive().SetAnimatorTrigger(AnimationHashes.EMOTE_STOP); + } + + [Test] + public void ForceStopEmote_WhenNonLoopingEmoteExceedsDuration() + { + // Arrange: a non-looping emote whose animator never leaves the emote tag + avatarView.GetAnimatorCurrentStateTag(AnimatorEmoteLayers.BASE_LAYER).Returns(AnimationHashes.EMOTE); + EmoteReferences emoteReferences = emoteReferencesGameObject.AddComponent(); + emoteReferences.Initialize(null, null, null, null, 0, legacy: false); + Entity entity = world.Create(new CharacterEmoteComponent { CurrentEmoteReference = emoteReferences }, avatarView); + + // Act: exceed PlayingEmoteDuration (0 for a null clip) + grace + system!.Update(0.6f); + system.Update(0.6f); + + // Assert: full teardown ran, without broadcasting a stop for a non-player entity + Assert.IsNull(world.Get(entity).CurrentEmoteReference); + avatarView.Received(1).SetAnimatorTrigger(AnimationHashes.EMOTE_STOP); + messageBus.DidNotReceive().SendStop(); + } + + [Test] + public void NotStop_LoopingEmote() + { + // Arrange: an intentionally looping emote + avatarView.GetAnimatorCurrentStateTag(AnimatorEmoteLayers.BASE_LAYER).Returns(AnimationHashes.EMOTE_LOOP); + EmoteReferences emoteReferences = emoteReferencesGameObject.AddComponent(); + emoteReferences.Initialize(null, null, null, null, 0, legacy: false); + Entity entity = world.Create(new CharacterEmoteComponent { CurrentEmoteReference = emoteReferences, EmoteLoop = true }, avatarView); + + // Act: simulate a long time playing + for (var i = 0; i < 30; i++) + system!.Update(1f); + + // Assert + Assert.IsNotNull(world.Get(entity).CurrentEmoteReference); + avatarView.DidNotReceive().SetAnimatorTrigger(AnimationHashes.EMOTE_STOP); + } + + [Test] + public void NotStop_LegacyEmoteViaWatchdog() + { + // Arrange: legacy emotes are torn down by the IsLegacyAnimationPlaying poll, not by the watchdog + avatarView.IsLegacyAnimationPlaying.Returns(true); + EmoteReferences emoteReferences = emoteReferencesGameObject.AddComponent(); + emoteReferences.Initialize(null, null, null, null, 0, legacy: true); + Entity entity = world.Create(new CharacterEmoteComponent { CurrentEmoteReference = emoteReferences }, avatarView); + + // Act + for (var i = 0; i < 30; i++) + system!.Update(1f); + + // Assert + Assert.IsNotNull(world.Get(entity).CurrentEmoteReference); + avatarView.DidNotReceive().SetAnimatorTrigger(AnimationHashes.EMOTE_STOP); + } + } +} diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteSystemShould.cs.meta b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteSystemShould.cs.meta new file mode 100644 index 00000000000..974e7980a84 --- /dev/null +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterEmoteSystemShould.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 103edaf2b1304dd6acf934ce12e7ec50 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: