Skip to content

fix: scene emotes masked loop - #9532

Open
pravusjif wants to merge 3 commits into
devfrom
fix/scene-emote-masked-loop
Open

fix: scene emotes masked loop#9532
pravusjif wants to merge 3 commits into
devfrom
fix/scene-emote-masked-loop

Conversation

@pravusjif

@pravusjif pravusjif commented Jul 31, 2026

Copy link
Copy Markdown
Member

What does this PR change?

A masked (upper-body) scene emote triggered with loop: false restarted forever instead of playing once.

Root causeSceneMaskedEmoteSystem treated a retained EmoteUrn as a resume token regardless of the loop flag:

  1. When the clip ended, CancelMaskedEmotes tore it down with permanent: false, which keeps EmoteUrn alive for resuming.
  2. Two queries later in the same frame, UpdateMaskedEmoteVisibility saw a set urn with no emote reference and called ReplayMaskedEmote, which never checked IsLooping().

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_LOOP animator 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:

  • ReplayMaskedEmote returns early and resets the component when the stored emote is non-looping, so a spent urn can never drive a restart.
  • The two natural-completion branches in CancelMaskedEmotes stop with permanent: !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 broadcast durationMs for masked emotes was read from the full-body CharacterEmoteComponent, whose reference had just been stopped, so it was structurally always 0. DurationMs = 0 is dropped on the wire, meaning the server treated every masked emote as open-ended for late joiners. Added CharacterMaskedEmoteComponent.PlayingEmoteDuration and used it in all three broadcast sites.

Tests

  • New SceneMaskedEmoteSystemShould (2) and CharacterMaskedEmoteComponentShould (7). Full emote EditMode suite: 64/64 pass.
  • Verified the regression test is not vacuous: with the ReplayMaskedEmote guard removed, DiscardNonLoopingEmoteThatAlreadyPlayed fails while KeepLoopingEmoteResumable still 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: false and (8,1,14) loop: true.
  • 88,-13-avatar-masks — blue box at (10,1,4) loop: false and 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.eth SEPOLIA realm):

Download the build from this PR and open it connected to the SEPOLIA (Switch MetaMask to the SEPOLIA network, not mainnet) sdk7testscenes.dcl.eth world and to position 88,-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-instance

macOS
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

  1. Find the cube that says "fishing cast emote (loop false) or similar and click it
  2. Confirm that the masked emote only player in the upper part of the body and that IT IS NOT LOOPED
  3. Find the other cube that says "fishing cast emote (loop true) or similar and click it
  4. Confirm that the masked emote only player in the upper part of the body and that IT LOOPS

Then test the same things but connecting to the test scene at 80,-1

@pravusjif pravusjif self-assigned this Jul 31, 2026
@pravusjif pravusjif added the bug Something isn't working label Jul 31, 2026
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

badge

New build in progress, come back later!

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

badge

Lint in progress, come back later!

@pravusjif pravusjif moved this to QA in Creators Tools Jul 31, 2026
@pravusjif
pravusjif marked this pull request as ready for review July 31, 2026 00:09
@pravusjif
pravusjif requested review from a team as code owners July 31, 2026 00:09
@decentraland-bot
decentraland-bot self-requested a review July 31, 2026 00:10

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

STEP 2 — Root-cause check: PASS

The PR correctly identifies and fixes three distinct root causes:

  1. Infinite replay loopCancelMaskedEmotes used permanent: false (default) for natural completion, keeping the EmoteUrn alive. UpdateMaskedEmoteVisibility then called ReplayMaskedEmote every frame, which never checked IsLooping(). Fix: natural-completion branches now use permanent: !masked.EmoteLoop, and ReplayMaskedEmote guards with emote.IsLooping().

  2. Stale URN on scene re-entry — a finished non-looping emote's URN survived across scene exits, replaying on re-entry. Fix: ReplayMaskedEmote calls masked.Reset() for non-looping emotes, clearing the stale URN.

  3. Always-zero durationMs — masked emotes read durationMs from CharacterEmoteComponent.PlayingEmoteDuration, whose reference had been stopped, so it was structurally 0. Fix: new CharacterMaskedEmoteComponent.PlayingEmoteDuration reads 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), and CharacterPreviewControllerBase (line 322, pre-existing on CharacterEmoteComponent).
  • Mirrors the identical formula from CharacterEmoteComponent.PlayingEmoteDuration — warranted since both structs hold their own CurrentEmoteReference.
  • 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

Comment thread Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/SceneMaskedEmoteSystemShould.cs Outdated
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Jarvis reviewed this PR and found no blocking issues, but assessed it as complex — human DEV review is still required before merging.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

badge

⚠️ EditMode produced no results — the run likely crashed or timed out before finishing. Check the Unity Test / Test (editmode) job.

⚠️ PlayMode produced no results — the run likely crashed or timed out before finishing. Check the Unity Test / Test (playmode) job.

TESTS SUITE Result Passed Failed Skipped
EditMode ⚠️ No results
PlayMode ⚠️ No results

@DafGreco DafGreco left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✔️ 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

evidence on macos

@pravusjif

Copy link
Copy Markdown
Member Author

/visual-tests

@github-actions

Copy link
Copy Markdown
Contributor

Visual regression tests (macos)

All visual tests passed.

Platform macos
Mode test
Commit 28f0cfb
Branch fix/scene-emote-masked-loop
Tests ref main
Filter Category=Visual
Allure report Open
Workflow run #30625552836

Triggered via /visual-tests · this comment is updated on every run for this platform.

@pravusjif pravusjif added the no-warning-ratchet Prevent CI linting checks label Aug 3, 2026
@pravusjif
pravusjif enabled auto-merge (squash) August 3, 2026 23:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working no-warning-ratchet Prevent CI linting checks

Projects

Status: QA

Development

Successfully merging this pull request may close these issues.

4 participants