fix: scene emotes masked loop - #9532
Conversation
decentraland-bot
left a comment
There was a problem hiding this comment.
Review Summary
STEP 2 — Root-cause check: PASS
The PR correctly identifies and fixes three distinct root causes:
-
Infinite replay loop —
CancelMaskedEmotesusedpermanent: false(default) for natural completion, keeping theEmoteUrnalive.UpdateMaskedEmoteVisibilitythen calledReplayMaskedEmoteevery frame, which never checkedIsLooping(). Fix: natural-completion branches now usepermanent: !masked.EmoteLoop, andReplayMaskedEmoteguards withemote.IsLooping(). -
Stale URN on scene re-entry — a finished non-looping emote's URN survived across scene exits, replaying on re-entry. Fix:
ReplayMaskedEmotecallsmasked.Reset()for non-looping emotes, clearing the stale URN. -
Always-zero
durationMs— masked emotes readdurationMsfromCharacterEmoteComponent.PlayingEmoteDuration, whose reference had been stopped, so it was structurally0. Fix: newCharacterMaskedEmoteComponent.PlayingEmoteDurationreads from the masked emote's own clip.
This is a root-cause fix, not symptom suppression.
STEP 3 — Design & integration: PASS
No new long-lived units are introduced. All changes stay within SceneMaskedEmoteSystem (the existing lifecycle owner for scene-triggered masked emotes) and CharacterMaskedEmoteComponent (the component that owns masked emote state). No new systems, managers, or persistent-state holders.
Defense-in-depth structure: The dual guard — permanent stop in CancelMaskedEmotes for natural completion + IsLooping() check in ReplayMaskedEmote — is sound. The ReplayMaskedEmote guard catches the suspension path (UpdateMaskedEmoteVisibility still uses default permanent: false), so a suspended non-looping emote that survives with its URN intact is correctly discarded instead of restarted.
EmoteLoop = false in non-permanent stop (line 323): Safe. Both PlayMaskedLegacyEmote and PlayMaskedMecanimEmote unconditionally set maskedEmote.EmoteLoop = isLooping when PlayMasked runs. Since ReplayMaskedEmote passes isLooping: true, the flag is correctly restored. Non-looping emotes never reach PlayMasked because the guard resets them first.
Teardown/consumption trace: No new subscriptions, event handlers, or resources opened. masked.Reset() properly clears the component. The broadcast at line 256 omits DurationMs (struct default 0), which is correct for looping emotes.
STEP 4 — Member audit: PASS
CharacterMaskedEmoteComponent.PlayingEmoteDuration (new, line 18):
- Used by 3 production callers:
SceneMaskedEmoteSystem.ConsumeMaskedEmoteIntent(line 188),CharacterEmoteSystem.ConsumeEmoteIntent(line 395), andCharacterPreviewControllerBase(line 322, pre-existing onCharacterEmoteComponent). - Mirrors the identical formula from
CharacterEmoteComponent.PlayingEmoteDuration— warranted since both structs hold their ownCurrentEmoteReference. - Multi-use, correctly placed in the component.
STEP 5 — Line-level review
See inline comments. Two P2 findings in the test file, no P0/P1 issues.
Security review: No security issues. No auth/authz changes, no user input handling, no injection vectors, no secrets. The fix actually closes a minor broadcast spam vector by preventing non-looping emotes from infinite replay cycles.
STEP 6 — Complexity: COMPLEX
Modifies ECS system logic (SceneMaskedEmoteSystem), component state management (CharacterMaskedEmoteComponent), and cross-system broadcast flow (EmotePendingToBroadcast). Changes affect the masked emote cancel/replay lifecycle which involves animator state machines and cross-world entity access.
STEP 7 — QA: YES
Runtime emote animation code — affects what the user sees (masked emote playback behavior). PR includes detailed test scene instructions for manual verification.
STEP 8 — Non-blocking warnings
None.
STEP 9 — Verdict
REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies ECS masked-emote cancel/replay lifecycle, cross-world broadcast flow, and component state management in SceneMaskedEmoteSystem
QA_REQUIRED: YES
Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub
|
🔍 Jarvis reviewed this PR and found no blocking issues, but assessed it as complex — human DEV review is still required before merging. |
|
|
DafGreco
left a comment
There was a problem hiding this comment.
✔️ PR reviewed and approved by QA on both platforms following instructions playing both happy and un-happy path
Regressions for this ticket had been performed in order to verify that the normal flow is working as expected:
- [✔️ ] Backpack and wearables in world
- [ ✔️] Emotes in world and in backpack
- [✔️ ] Teleport with map/coordinates/Jump In
- [✔️ ] Chat and multiplayer
- [✔️ ] Profile card
- [ ✔️] Skybox
- [ ✔️] Settings
Evidence:
20260731-0859-18.0208003.mp4
|
/visual-tests |
Visual regression tests (macos)✅ All visual tests passed.
Triggered via |
What does this PR change?
A masked (upper-body) scene emote triggered with
loop: falserestarted forever instead of playing once.Root cause —
SceneMaskedEmoteSystemtreated a retainedEmoteUrnas a resume token regardless of the loop flag:CancelMaskedEmotestore it down withpermanent: false, which keepsEmoteUrnalive for resuming.UpdateMaskedEmoteVisibilitysaw a set urn with no emote reference and calledReplayMaskedEmote, which never checkedIsLooping().So every completion immediately restarted the emote, and the stale urn also replayed it on every scene re-entry. Looping emotes never leave the
MASKED_EMOTE_LOOPanimator tag, so they never hit that path — which is why only some emotes showed the bug. Full-body emotes are unaffected (no replay logic).Changes
SceneMaskedEmoteSystem.cs— replay/resume now applies to looping emotes only:ReplayMaskedEmotereturns early and resets the component when the stored emote is non-looping, so a spent urn can never drive a restart.CancelMaskedEmotesstop withpermanent: !masked.EmoteLoop: a clip that ran to its end has nothing to resume, while a looping emote that lost its animator state keeps its urn as before.CharacterMaskedEmoteComponent.cs/CharacterEmoteSystem.cs— the broadcastdurationMsfor masked emotes was read from the full-bodyCharacterEmoteComponent, whose reference had just been stopped, so it was structurally always0.DurationMs = 0is dropped on the wire, meaning the server treated every masked emote as open-ended for late joiners. AddedCharacterMaskedEmoteComponent.PlayingEmoteDurationand used it in all three broadcast sites.Tests
SceneMaskedEmoteSystemShould(2) andCharacterMaskedEmoteComponentShould(7). Full emote EditMode suite: 64/64 pass.ReplayMaskedEmoteguard removed,DiscardNonLoopingEmoteThatAlreadyPlayedfails whileKeepLoopingEmoteResumablestill passes.Test scenes
sdk7-test-scenes(separate PR) adds a one-shot / looping pair of the same clip to both masked-emote scenes:80,-1-scene-emotes— cubes at(8,1,12)loop: falseand(8,1,14)loop: true.88,-13-avatar-masks— blue box at(10,1,4)loop: falseand purple at(12,1,4)loop: true.QA TEST INSTRUCTIONS
The 2 sdk7-test-scenes used are these ones (you can skip downloading them and just test them in the
sdk7testscenes.dcl.ethSEPOLIA realm):Download the build from this PR and open it connected to the SEPOLIA (Switch MetaMask to the SEPOLIA network, not mainnet)
sdk7testscenes.dcl.ethworld and to position88,-13:Windows
"C:\Users\[YOUR-USER]\Downloads\Decentraland_windows64\Decentraland.exe" --realm sdk7testscenes.dcl.eth --dclenv zone --position 88,-13 --skip-version-check true --multi-instancemacOS
open -n Decentraland.app --args --realm sdk7testscenes.dcl.eth --dclenv zone --position 88,-13 --skip-version-check true --multi-instance(Generic instructions to connect a custom build with app params at: https://github.com/decentraland/unity-explorer/blob/dev/docs/how-to-connect-to-a-local-scene.md)
Confirm the following
Then test the same things but connecting to the test scene at
80,-1