Skip to content

feat: read LiveKit room presence for the debug room indicator - #9980

Open
mikhail-dcl wants to merge 3 commits into
devfrom
feat/room-indicator-livekit-presence
Open

feat: read LiveKit room presence for the debug room indicator#9980
mikhail-dcl wants to merge 3 commits into
devfrom
feat/room-indicator-livekit-presence

Conversation

@mikhail-dcl

Copy link
Copy Markdown
Collaborator

Why

With Pulse active the client never announces its profile over LiveKit. LiveKitMessagesBroadcaster addresses messages only to wallets in announcedWallets, and the sole writer of that set is receiving a LiveKit AnnounceProfileVersion — so once every peer is on Pulse the set stays empty and nothing is ever sent.

The debug room indicator reads EntityParticipantTable.Entry.ConnectedTo, which those announcements populate. Under Pulse every remote avatar's tag therefore reads Pulse, never Island or Gatekeeper, and nothing in the client says whether an avatar is connected to the LiveKit rooms at all.

That blocks the QA plan for decentraland/Pulse#34, which verifies island assignment by reading these tags.

What changed

IRoomHub.RoomsOf(walletId) returns the local rooms whose participant roster lists a wallet — LiveKit's own membership, not its data channels. It reuses the identity maps RoomHub already refreshes once per frame, so it costs two dictionary lookups and no allocation.

The nametag tag now carries both facts, one glyph per room:

Tag Meaning
🟢Island on the roster and announced over that room
🔗Island on the roster, never announced — the steady state under Pulse
👻Island announced but not on the roster — stale, or a hand-off in flight
⚡Pulse announced over Pulse; Pulse exposes no roster, so it has this state only

Room: Info gains three counters reconciling the two rosters: Avatars on LiveKit, Avatars off LiveKit, LiveKit w/o Avatar.

Two lifecycle bugs this surfaced

Both predate the change in shape, but they defeat exactly the signal it exists to provide, so they are fixed here.

  • NametagHolder.ResetTransientVisualState reset every transient flag except the debug label, and nametag holders are pooled. A holder released while still carrying a tag could hand it to the next avatar — including a scene NPC, which is in no roster and no participant table, so nothing would ever overwrite it. Now cleared on release.
  • The indicator's toggle-off path only reached avatars that still had a nametag holder. Avatars out of nametag range kept their component, and the stale state on it then suppressed the first write after toggling back on, leaving a blank tag. The holder-less case is now cleaned up too.

What deliberately did not change

Presence is read-only and never enters EntityParticipantTable. Release destroys an entity only when its last RoomSource flag clears, so writing Island/Gatekeeper flags there would keep avatars alive after Pulse's area of interest drops them. Avatar lifetime is untouched.

Everything sits behind the existing Show Room Indicator toggle and the Room: Info widget, so nothing runs without the debug panel.

Testing

  • 10 EditMode tests for the label formatter (RoomIndicatorLabelShould). They were not run locally — the Editor held the project lock — so CI is the first real run; the formatter was instead executed directly from the built assembly over the same nine input combinations.
  • DCL.Multiplayer, DCL.Plugins and DCL.EditMode.Tests compile clean.
  • ReSharper InspectCode over the changed files: no new findings (the ones that remain are pre-existing; two dead usings in a file this touches were removed).
  • Reviewed; the three findings raised are the two bugs above plus RoomsOf originally going through AllLocalRoomsRemoteParticipantIdentities, which cleared and rebuilt the union cache every frame for a side effect it did not need. It now reads the two hubs directly — allocation-free and always fresh.

The glyphs render through the emoji sprite asset wired to the nametags panel — the same path that renders emoji in avatar chat bubbles. That path is verified statically (all four codepoints are in emojis32_uitk.asset), not yet confirmed on screen.

Not covered by tests: the indicator system itself. UpdateIndicator needs a NametagHolder, which is a MonoBehaviour over a live UI Toolkit document, so the two lifecycle bugs above are not cheap to pin in EditMode. They were found by review, not by a test, and a regression would be caught the same way.

Docs

docs/qa-archipelago-island-room.md is rewritten for the new tags and committed here — Pulse#34 already referenced the path, but the file had never landed. Pulse#34's own QA section is updated to match.

🤖 Generated with Claude Code

With Pulse active no client announces its profile over LiveKit, so the
participant table only ever records RoomSource.Pulse and the nametag room
indicator could not say whether an avatar was on the LiveKit rooms at all.

Add IRoomHub.RoomsOf, which reads the Island and Scene participant rosters
instead of the data channels, and render it next to the announcement state
as one glyph per room. Room: Info gains three counters reconciling the
avatar roster against LiveKit's.

Presence stays out of EntityParticipantTable on purpose: Release destroys an
entity only when its last flag clears, so an Island flag there would outlive
Pulse's area of interest and leave ghost avatars.

Fix two lifecycle bugs the indicator surfaced. ResetTransientVisualState
reset every transient nametag flag except the debug label, so a pooled
holder could hand a previous avatar's tag to an NPC. And the toggle-off
path only reached avatars that still had a nametag holder, leaving stale
components on the rest to suppress their first write on the way back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mikhail-dcl
mikhail-dcl requested review from a team as code owners September 4, 2026 12:46
@github-actions
github-actions Bot requested a review from anicalbano September 4, 2026 12:46
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Windows and Mac built successfully in Unity Cloud.

Name Links & timing
Build 4b8a5f7 · Logs · built 2026-09-04T14:59:39Z
Windows GitHub job · Unity Cloud #3 · Unity log · ⏱ 57m 51s build + 9m 7s queue · Download .zip · .zip via S3
Mac GitHub job · Unity Cloud #3 · Unity log · ⏱ 1h 28m build + 3m 1s queue · Download .zip · .zip via S3

Lint

Warnings count reduced: 12160 => 12158

Warnings/errors in files changed by this PR (17)
Assets/DCL/Multiplayer/Connections/Systems/DebugRoomsSystem.cs:60  CSharpWarnings::CS8602  Dereference of a possibly null reference
Assets/DCL/PluginSystem/Global/MultiplayerPlugin.cs:178  CSharpWarnings::CS8618  Non-nullable field 'RemoteAvatarColliderPrefab' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Multiplayer/Connections/Systems/DebugRoomsSystem.cs:34  CSharpWarnings::CS8618  Non-nullable field 'debugAvatarsRooms' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Multiplayer/Connections/RoomHubs/RoomHub.cs:31  CSharpWarnings::CS8618  Non-nullable fields 'islandIdentities', 'sceneIdentities' must contain non-null values when exiting constructor. Consider adding the 'required' modifiers or declaring the fields as nullable.
Assets/DCL/NameTags/NametagHolder.cs:9  CSharpWarnings::CS8618  Non-nullable property 'Nametag' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/Multiplayer/Connections/Systems/RoomIndicator/DebugRoomsSystem.Indicator.cs:60  CSharpWarnings::CS8625  Cannot convert null literal to non-nullable reference type
Assets/DCL/NameTags/NametagHolder.cs:24  CSharpWarnings::CS8625  Cannot convert null literal to non-nullable reference type
Assets/DCL/NameTags/NametagHolder.cs:18  ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract  Expression is always false according to nullable reference types' annotations
Assets/DCL/Multiplayer/Connections/RoomHubs/IRoomHub.cs:5  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/Multiplayer/Connections/RoomHubs/NullRoomHub.cs:5  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/Multiplayer/Connections/RoomHubs/NullRoomHub.cs:8  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/Multiplayer/Connections/RoomHubs/RoomHub.cs:8  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/Multiplayer/Connections/Systems/DebugRoomsSystem.cs:18  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/PluginSystem/Global/MultiplayerPlugin.cs:29  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/PluginSystem/Global/MultiplayerPlugin.cs:39  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/PluginSystem/Global/MultiplayerPlugin.cs:40  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/NameTags/NametagHolder.cs:11  UnusedMember.Local  Method 'OnEnable' is never used

Lint run · full InspectCode report · took 20m 39s

Tests

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

TESTS SUITE Result Passed Failed Skipped Tests time Job time
EditMode ⚠️ No results 32m 18s
PlayMode ✅ Passed 248 0 37 44s 10m 17s

Tests time sums the test cases; Job time is the job's wall clock including checkout, licensing and asset import.

Slowest tests
  • [playmode] 4.5s Global.Tests.PlayMode.CubeWaveSceneShould.EmitECSComponents
  • [playmode] 3.1s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.ContinuousTweensRunIndefinitelyWhenDurationIsZero
  • [playmode] 2.5s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TextureMoveSequenceUpdatesMaterial
  • [playmode] 2.3s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithoutLoopCompletesOnce
  • [playmode] 2.3s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.MoveContinuousMovesAndCompletesAfterDuration
  • [playmode] 2.3s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.RotateContinuousCompletesAfterDuration
  • [playmode] 2.2s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.TextureMoveContinuousOffsetCompletesAndUpdatesMaterial
  • [playmode] 1.7s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithMoveRotateScaleWithOmittedScale_ResolvesScaleFromCurrentTransform
  • [playmode] 1.6s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithMultipleTweens
  • [playmode] 1.6s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithRotateAndMove

Full report: run summary · results + editor logs: editmode · playmode

Performance

🏁 Bare-metal benchmark finished — run #33886974714.

Full report

PR #9980, run #33886974714

Overall: ✅ no significant changes

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

How to read this table
  • Each build is measured 3 times, interleaved with the other build (change, baseline, change, baseline, ...) in the same session, so both see the same world content and machine state. The values are the median, and (min–max) is the lowest and highest of those runs.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a difference that passed every check: the runs are fully separated (every run of one build faster than every run of the other), and the median difference is at least 3% and at least 0.5 ms.
  • ⚪ within noise — the builds' runs overlap, or the difference is tiny; it cannot be told apart from random variation. Treat it as no change.
  • — informational — the 0.1% worst metrics average only the few worst frames of a run, so a single OS hiccup swings them by a lot; they are shown for context and never earn a verdict.
  • ⚠️ no verdict — the two builds' sessions were not comparable (very different sample counts, or too few usable runs), so no conclusion is drawn from them.
  • Exceptions per run — the average number of exceptions in a run's log, not counting teardown ones logged while the app quits. Flagged only on a difference of at least 2 per run and 1.5× the other build; exception kinds the baseline never threw are called out under the table. The Exception breakdown groups all of 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.
  • The Overall line at the top only reacts to a metric that moved on two or more machines, or by 10% or more on one — a single modest 🟢/🔴 cell can still be a statistical fluke.

Apple M1

Metric Baseline Change Δ Result
Samples 3687 (×3) 3711 (×3)
CPU average 24.2 ms (24.0–24.4) 24.1 ms (24.0–24.2) -0.1 ms ⚪ within noise
CPU 1% worst 214.3 ms (214.2–228.2) 214.8 ms (203.1–220.4) 0.5 ms ⚪ within noise
CPU 0.1% worst 239.2 ms (238.0–239.5) 234.5 ms (232.5–242.4) -4.7 ms — informational
GPU average 45.7 ms (45.1–46.3) 45.5 ms (45.0–45.5) -0.2 ms ⚪ within noise
GPU 1% worst 56.0 ms (53.3–56.9) 55.9 ms (53.8–56.4) -0.1 ms ⚪ within noise
GPU 0.1% worst 58.5 ms (55.2–58.7) 57.4 ms (57.1–58.8) -1.1 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

Intel Core i5

Metric Baseline Change Δ Result
Samples 3676 (×3) 3860 (×3)
CPU average 24.2 ms (23.1–25.5) 23.3 ms (21.3–25.3) -1.0 ms ⚪ within noise
CPU 1% worst 748.3 ms (665.9–806.8) 694.3 ms (562.2–796.8) -54.0 ms ⚪ within noise
CPU 0.1% worst 793.8 ms (729.5–836.3) 771.2 ms (711.6–840.4) -22.6 ms — informational
GPU average 15.5 ms (14.9–15.9) 13.5 ms (13.3–15.9) -1.9 ms ⚪ within noise
GPU 1% worst 510.0 ms (455.8–554.7) 352.8 ms (327.8–535.9) -157.2 ms ⚪ within noise
GPU 0.1% worst 800.4 ms (735.8–837.9) 775.1 ms (714.4–826.7) -25.4 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

Automation

On demand — comment /visual-tests on this PR to run the visual regression suite against its build.

@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 — PR #9980: feat: read LiveKit room presence for the debug room indicator

STEP 2 — Root-cause check: PASS

The problem is precisely identified: under Pulse, no client announces its profile over LiveKit data channels, so EntityParticipantTable.Entry.ConnectedTo (the sole source the indicator read) carries only Pulse — telling you nothing about whether the avatar is backed by a live LiveKit session. The fix adds the second signal source (IRoomHub.RoomsOf reads LiveKit's own participant roster), and the indicator now reports both facts independently. This is a cause-level fix, not a workaround.

STEP 3 — Design & integration: PASS

No new lifecycle-managing units. RoomIndicatorLabel is a stateless static formatter with no persistent state beyond a thread-local StringBuilder. No new system, manager, or controller is introduced.

Owner search for RoomsOf: The method is a read-only query on IRoomHub, whose implementation (RoomHub) already holds islandParticipantsHub and sceneParticipantsHub. It performs two RemoteParticipantIdentities().ContainsKey() lookups — allocation-free, no cache rebuild. This is the natural home for this query; it reuses data the hub already refreshes once per frame.

Teardown/consumption trace:

  • RoomIndicatorLabel.BUILDER — static, reused, cleared at the start of each Build(). ✅
  • New ElementBinding<string> fields in AvatarsRoomDisplay — same lifecycle as the pre-existing activeCount. ✅
  • New roomHub field in DebugRoomsSystem and AvatarsRoomDisplay — injected dependency, no subscription/event hookup. ✅

Bug fix #1NametagHolder.ResetTransientVisualState: Nametag holders are pooled via ObjectPool<NametagHolder> in AvatarPlugin. actionOnRelease calls ResetTransientVisualState(). The old code reset every transient flag except DebugText, so a released holder could hand a stale debug label to the next avatar. Now cleared. ✅

Bug fix #2 — toggle-off coverage: RemoveAllIndicatorsQuery requires NametagHolder, so avatars out of nametag range kept their DebugRoomIndicatorComponent. RemoveIndicatorOnComponentRemovalQuery now also runs on toggle-off, catching holder-less entities. Both queries together cover all entities. ✅

STEP 4 — Member audit

New member Consumers Verdict
IRoomHub.RoomsOf(string) UpdateIndicator (Indicator.cs:27), AvatarsRoomDisplay.Update (AvatarsRoomDisplay.cs:48) 2 consumers — not single-use ✅
DebugRoomIndicatorComponent.Announced / .Present UpdateIndicator (read/write same method) ECS component fields, single-system consumer is the normal ECS pattern ✅
RoomIndicatorLabel.Build(RoomSource, RoomSource) UpdateIndicator (Indicator.cs:35) + 10 tests Extracted for testability ✅
RoomIndicatorLabel.* constants Build + test assertions Public for test pinning ✅

No absent-≠-false violations. RoomsOf returning RoomSource.None genuinely means the wallet is not on any roster.

STEP 5 — Line-level review: no blocking issues

Allocation profile: RoomsOf = two dictionary ContainsKey lookups, zero allocation. RoomIndicatorLabel.Build allocates a string via StringBuilder.ToString() only on state transition (guarded by the early-return in UpdateIndicator), behind the debug toggle. AvatarsRoomDisplay.Update() runs behind DebounceRoomDisplay (1 s interval) and OnlyVisibleRoomDisplay. All acceptable for debug-only code.

Structural-change safety: UpdateIndicator writes to ref DebugRoomIndicatorComponent with no subsequent World.Add/World.Remove — no ref invalidation risk. RemoveIndicatorInternal does World.Remove<DebugRoomIndicatorComponent> as its last operation. ✅

Query execution order (toggle ON): RemoveOnEntityRemovalRemoveOnComponentRemovalAddUpdate. Clean up before adding/updating. ✅

Query execution order (toggle OFF): RemoveAllIndicators (entities WITH holder) → RemoveOnComponentRemoval (entities WITHOUT holder). Full coverage. ✅

Naming: PascalCase for types/methods/properties, camelCase for locals, ALL_UPPER_SNAKE_CASE for consts. Matches docs/code-style-guidelines.md. ✅

Member ordering in RoomIndicatorLabel: consts → static readonly field → public static methods → private static methods. Correct per conventions. ✅

No LINQ used.

EnumUtils.HasFlag avoids the boxing allocation of Enum.HasFlag. ✅

Tests: 10 tests in RoomIndicatorLabelShould following AAA pattern, NUnit conventions, descriptive names. Good edge-case coverage (None, Pulse-only, present-only, announced-only, both, mixed states, ordering, Chat ignored, codepoint pinning, state isolation between builds). ✅

Dead imports removed: using UnityEngine.Pool; and using DCL.Multiplayer.Connections.RoomHubs; removed from the indicator partial; using DCL.Multiplayer.Profiles.Entities; removed from the component file. All were unused after the changes. ✅

STEP 5 (cont.) — Security review: PASS

No secrets, credentials, or sensitive data introduced. No user-controlled strings interpolated into the label (all inputs are enum flags and nameof() values). No network operations added. No auth/authz changes. RoomsOf reads the local LiveKit SDK's participant roster, already populated by the authenticated session. The static StringBuilder is main-thread-only (documented, verified by call-graph: DebugRoomsSystem.Update → ECS query → UpdateIndicatorBuild).

STEP 6 — Complexity: COMPLEX

Touches LiveKit room connections, multiplayer participant rosters, nametag UI lifecycle, ECS component queries, and debug system wiring across 15 files.

STEP 7 — QA: YES

Changes runtime code affecting the debug overlay users see (nametag labels, Room: Info counters). Touches multiplayer and nametag systems.

STEP 8 — Non-blocking warnings

None. Main scene is not modified.


REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Touches LiveKit room participant rosters, multiplayer debug systems, nametag UI lifecycle, and ECS component queries
QA_REQUIRED: YES


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

mikhail-dcl and others added 2 commits September 4, 2026 16:04
QA runs these with Pulse on only, so the --pulse false alternatives were
dead weight and made Test 4 read as if a disappearing avatar could be a
pass. The glyph legend keeps all four states: a green entry still shows up
for a peer whose Pulse connection fell back at start-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A peer on Pulse addresses its LiveKit announcement only to peers that
announced to it first, and a Pulse client never does. So the green and
ghost glyphs cannot appear on the same tag as the Pulse one, and the
example tag mixing them was unreachable. State the exclusion as a rule,
use reachable examples, and stop telling testers to expect a ghost during
a scene-border hand-off, which a Pulse peer cannot produce either.

Also drops a failure criterion in Test 2 that described a legitimate
non-Pulse peer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants