Skip to content

fix: expire stale point-at state from replayed remote snapshots - #9780

Closed
alejandro-jimenez-dcl wants to merge 1 commit into
devfrom
bugsweep/pointing-emote-wrong-direction
Closed

fix: expire stale point-at state from replayed remote snapshots#9780
alejandro-jimenez-dcl wants to merge 1 commit into
devfrom
bugsweep/pointing-emote-wrong-direction

Conversation

@alejandro-jimenez-dcl

Copy link
Copy Markdown
Contributor

Server-cached movement snapshots delivered on join/teleport are applied by HandleFirstMessage with no freshness bound, and the remote path never expires IsPointing — one replayed pointing=true snapshot pins a remote avatar's arm at a stale absolute WorldHitPoint indefinitely. Bound the remote point-at lifetime the same way HandPointAtSystem bounds the local gesture.

Includes a regression test that fails without this fix.

Pull Request Description

What does this PR change?

Test Instructions

Steps (standard run):

metaforge explorer run XXXX  # ← replace with this PR number

Expected result:

Steps (fresh account):

metaforge account create --clear
metaforge explorer run XXXX  # ← replace with this PR number

Expected result:

Automation (if applicable):

metaforge explorer test XXXX

Prerequisites

  • List any required setup steps
  • Include environment/configuration requirements

Test Steps

  1. First step
  2. Second step
  3. Expected result after step 2
  4. ...

Additional Testing Notes

  • Note any edge cases to verify
  • Mention specific areas that need careful testing
  • List known limitations or potential issues

Quality Checklist

  • Changes have been tested locally
  • Documentation has been updated (if required)
  • Performance impact has been considered
  • For SDK features: Test scene is included

Code Review Reference

Please review our Branch & PR Standards before submitting. It explains the automated review flow, QA/DEV approval requirements, and what each label does — especially useful for first-time contributors.

Server-cached movement snapshots delivered on join/teleport are applied
by HandleFirstMessage with no freshness bound, and the remote path never
expires IsPointing — one replayed pointing=true snapshot pins a remote
avatar's arm at a stale absolute WorldHitPoint indefinitely. Bound the
remote point-at lifetime the same way HandPointAtSystem bounds the
local gesture.

Includes a regression test that fails without this fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Windows and Mac build successful in Unity Cloud! You can find a link to the downloadable artifact below.

Name Link
Commit c726e43
Logs https://github.com/decentraland/unity-explorer/actions/runs/32148352138
Download Windows https://github.com/decentraland/unity-explorer/suites/87147453072/artifacts/9330227890
Download Windows S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/bugsweep/pointing-emote-wrong-direction/pr-25261-c726e43/Decentraland_windows64.zip
Download Mac https://github.com/decentraland/unity-explorer/suites/87147453072/artifacts/9330069411
Download Mac S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/bugsweep/pointing-emote-wrong-direction/pr-25261-c726e43/Decentraland_macos.zip
Built on 2026-08-18T15:06:56Z

Lint

Lint did not finish (failure) — the warning ratchet could not be evaluated. See logs.

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped
EditMode ✅ Passed 25188 0 13
PlayMode ✅ Passed 236 0 37

@decentraland-bot
decentraland-bot self-requested a review August 18, 2026 14:26

@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.

Step 2 — Root-cause check: PASS

Problem: Server-cached movement snapshots delivered on join/teleport are applied by HandleFirstMessage with no freshness bound. The remote point-at path in RemotePlayersMovementSystem sets HandPointAtComponent.IsPointing and RemotePlayerMovementComponent.IsPointingAt from the replayed message but never ticks the duration timer — unlike HandPointAtSystem, which calls TickDuration every frame for the local player. One stale isPointingAt=true snapshot pins the arm at an absolute WorldHitPoint indefinitely.

Fix: The diff mirrors the local gesture's expiry mechanism: RefreshDuration on every pointing message, TickDuration per-frame, and sync-back to remotePlayerMovement.IsPointingAt on expiry. This addresses the root cause directly — not a workaround or symptom suppression.

Step 3 — Design & integration: PASS

Owner search: The lifecycle of remote point-at state is managed by RemotePlayersMovementSystem, which:

  • Receives NetworkMovementMessages from the player inbox
  • Updates RemotePlayerMovementComponent.IsPointingAt and PointAtWorldHitPoint (via UpdatePointAtIK)
  • Calls ApplyPointAtIK to propagate state into HandPointAtComponent

The downstream RemoteHandPointAtSystem (CharacterMotion/Systems/RemoteHandPointAtSystem.cs) runs [UpdateAfter(typeof(RemotePlayersMovementSystem))] and is purely a visual consumer — it reads HandPointAtComponent.IsPointing for animation weights and IK, never writes to RemotePlayerMovementComponent. The local player's HandPointAtSystem (CharacterMotion/Systems/HandPointAtSystem.cs) owns both input/duration management and IK application in a single system.

The fix correctly places the expiry logic in RemotePlayersMovementSystem — the existing owner of remote point-at state. Moving it to RemoteHandPointAtSystem was considered but rejected: that system uses RemotePlayerMovementComponent only as a query filter ([All(typeof(...))]) and never accesses it — adding a write-back there would introduce a reverse dependency that doesn't exist today.

Teardown trace: No new subscriptions, callbacks, event hookups, connections, or persistent collections are introduced. The only additions are per-frame arithmetic on existing struct fields (HandPointAtComponent.duration).

Step 4 — Member audit

UpdatePointAtIK (private method, new): 3 consumers — HandleFirstMessage (line 49), HandleNewMessage (line 137), and the inner loop of HandleNewMessage (line 154). Each call site previously had the bare remotePlayerMovement.UpdatePointAtIK(remote) call; the extraction adds the RefreshDuration side-effect consistently across all three. Not single-use; appropriate extraction.

No new public properties or accessors are introduced.

Step 5 — Line-level review

No blocking issues found across two passes:

A. Blocking-issue categories: No code quality violations, bugs, security vulnerabilities, performance issues, resource leaks, nullability violations, or false-intent conditions.

B. Design, encapsulation & resource smells: No construction issues, naming concerns, encapsulation violations, magic values, or resource lifecycle problems.

Execution order verified:

  1. ApplyPointAtIK sets handPointAt.IsPointing from remotePlayerMovement.IsPointingAt
  2. TickDuration decrements the timer; if expired, sets handPointAt.IsPointing = false
  3. Sync-back clears remotePlayerMovement.IsPointingAt when handPointAt expires
  4. HandleNewMessage may re-assert pointing via RefreshDuration

The set-then-clear of handPointAt.IsPointing on the expiry frame is harmless since RemoteHandPointAtSystem runs after and reads the final value.

Struct ref safety verified: Both handPointAt and remotePlayerMovement are received by ref in the query. No structural changes (Add/Remove) occur after the refs are obtained. TickDuration mutates the struct through the ref correctly.

Per-frame allocations: None introduced. UpdatePointAtIK operates on struct refs, TickDuration/RefreshDuration are struct methods. No LINQ, closures, or boxing.

Security review

No security concerns: no user input handling changes, no auth/permissions changes, no serialization of external data, no file operations, no sensitive data exposure, no new dependencies.

Test coverage

The new RemotePlayerPointAtExpiryShould.ExpirePointingAfterPointAtDurationWithNoReassertion test is well-structured:

  • Follows AAA pattern with clear Arrange/Act/Assert phases
  • Uses UnitySystemTestBase<RemotePlayersMovementSystem> correctly
  • Uses NUnit + NSubstitute per project conventions
  • Pre-clears the interpolation cooldown gate to isolate the expiry behavior
  • Verifies both HandPointAtComponent.IsPointing and RemotePlayerMovementComponent.IsPointingAt clear on expiry
  • Documents the regression scenario inline

CI status

  • semantic / title-matches-convention — PASS
  • Test (editmode), Test (playmode) — IN PROGRESS
  • rsp files match generator — FAIL (may need .rsp regeneration after adding the new test file; typically resolved by opening the project in Unity Editor)
  • enforce-approvals — FAIL (expected; awaiting QA and DEV approvals)
  • Prebuild — IN PROGRESS

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies multiplayer movement synchronization and point-at IK state management in RemotePlayersMovementSystem
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub

@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.

@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9780, run #32152545601

Builds: Windows change, Windows baseline, macOS change, macOS baseline

How to read this table
  • Each build is measured 3 times. The values are the median, and (min–max) is the lowest and highest of those runs — a wide range means the metric is noisy and small differences are not trustworthy.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a real difference: larger than both 3% and the run-to-run range.
  • ⚪ within noise — the difference is smaller than how much the build varies between its own runs, so it cannot be told apart from random variation. Treat it as no change.
  • Exceptions per run — the average number of exceptions in a run's log; more than the baseline is flagged 🔴 even when frame times look fine. The Exception breakdown under each table groups them by the explorer's report category and exception type (as totals across the runs).
  • A run that logged unusually many exceptions (at least 10 and 5× the median of its build's runs — e.g. a service was down during it) is excluded from all numbers and called out under the table.

Intel Core i5

Metric Baseline Change Δ Result
Samples 2349 (×3) 2534 (×3)
CPU average 38.2 ms (36.3–40.4) 35.4 ms (33.2–40.9) -2.8 ms ⚪ within noise
CPU 1% worst 378.7 ms (356.7–397.5) 256.7 ms (33.5–443.5) -122.0 ms ⚪ within noise
CPU 0.1% worst 407.6 ms (400.9–420.6) 455.7 ms (35.5–462.4) 48.0 ms ⚪ within noise
GPU average 9.4 ms (9.3–9.5) 8.2 ms (8.0–8.2) -1.2 ms 🟢 13% faster
GPU 1% worst 38.6 ms (37.9–43.3) 18.8 ms (18.7–18.9) -19.8 ms 🟢 51% faster
GPU 0.1% worst 49.8 ms (45.0–51.5) 19.1 ms (19.0–19.8) -30.7 ms 🟢 62% faster
Exceptions per run 66 66 0 ⚪ none new
Exception breakdown
Exception Baseline (3 runs) Change (3 runs)
[UI] DllNotFoundException 192 192
[ENGINE] NullReferenceException 3 3
[ENGINE] ObjectDisposedException 3 3

Apple M1

Metric Baseline Change Δ Result
Samples 4059 (×3) 4220 (×3)
CPU average 22.1 ms (21.9–23.3) 21.2 ms (20.9–22.6) -0.9 ms ⚪ within noise
CPU 1% worst 218.8 ms (172.9–221.7) 107.9 ms (43.6–223.5) -110.8 ms ⚪ within noise
CPU 0.1% worst 232.6 ms (227.6–235.3) 225.2 ms (127.5–231.5) -7.3 ms ⚪ within noise
GPU average 7.9 ms (2.1–8.4) 1.7 ms (0.2–8.0) -6.2 ms ⚪ within noise
GPU 1% worst 36.0 ms (34.9–37.2) 34.7 ms (21.6–35.6) -1.3 ms ⚪ within noise
GPU 0.1% worst 37.9 ms (36.4–38.1) 36.6 ms (36.3–36.7) -1.3 ms ⚪ within noise
Exceptions per run 0 0 0 ⚪ none new

@alejandro-jimenez-dcl

Copy link
Copy Markdown
Contributor Author

Not valid: see #9606

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants