feat: read LiveKit room presence for the debug room indicator - #9980
feat: read LiveKit room presence for the debug room indicator#9980mikhail-dcl wants to merge 3 commits into
Conversation
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>
🚦 CI StatusWindows and Mac built successfully in Unity Cloud.
Warnings count reduced: 12160 => 12158 Warnings/errors in files changed by this PR (17)Lint run · full InspectCode report · took 20m 39s
Tests time sums the test cases; Job time is the job's wall clock including checkout, licensing and asset import. Slowest tests
Full report: run summary · results + editor logs: editmode · playmode 🏁 Bare-metal benchmark finished — run #33886974714. Full reportPR #9980, run #33886974714 Overall: ✅ no significant changes Builds: Windows change, Windows baseline, macOS change, macOS baseline How to read this table
Apple M1
Intel Core i5
On demand — comment |
decentraland-bot
left a comment
There was a problem hiding this comment.
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 eachBuild(). ✅- New
ElementBinding<string>fields inAvatarsRoomDisplay— same lifecycle as the pre-existingactiveCount. ✅ - New
roomHubfield inDebugRoomsSystemandAvatarsRoomDisplay— injected dependency, no subscription/event hookup. ✅
Bug fix #1 — NametagHolder.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): RemoveOnEntityRemoval → RemoveOnComponentRemoval → Add → Update. 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 → UpdateIndicator → Build).
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
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>
Why
With Pulse active the client never announces its profile over LiveKit.
LiveKitMessagesBroadcasteraddresses messages only to wallets inannouncedWallets, and the sole writer of that set is receiving a LiveKitAnnounceProfileVersion— 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 readsPulse, neverIslandorGatekeeper, 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 mapsRoomHubalready refreshes once per frame, so it costs two dictionary lookups and no allocation.The nametag tag now carries both facts, one glyph per room:
🟢Island🔗Island👻Island⚡PulseRoom: Infogains 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.ResetTransientVisualStatereset 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.What deliberately did not change
Presence is read-only and never enters
EntityParticipantTable.Releasedestroys an entity only when its lastRoomSourceflag 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 Indicatortoggle and theRoom: Infowidget, so nothing runs without the debug panel.Testing
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.PluginsandDCL.EditMode.Testscompile clean.RoomsOforiginally going throughAllLocalRoomsRemoteParticipantIdentities, 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.
UpdateIndicatorneeds aNametagHolder, which is aMonoBehaviourover 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.mdis 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