Skip to content

feat: scene listener — receive-only parcel-scoped observers - #29

Open
mikhail-dcl wants to merge 31 commits into
mainfrom
feat/scene-listener
Open

feat: scene listener — receive-only parcel-scoped observers#29
mikhail-dcl wants to merge 31 commits into
mainfrom
feat/scene-listener

Conversation

@mikhail-dcl

Copy link
Copy Markdown
Collaborator

What

A new scene-listener client type: authenticates via a dedicated SCENE_LISTENER_HANDSHAKE (same ECDSA auth chain as players), announces an immutable parcel set as its area of interest, never sends state updates, and receives only the positional stream (PlayerJoined/PlayerLeft/PlayerStateDelta/PlayerStateFull/Teleported) for players inside those parcels. Intended for scene/world services that need presence awareness.

Design

  • Never a subject — no SnapshotBoard/SpatialGrid registration, so players can't see listeners; a PENDING_AUTH gate prevents an authenticated player from converting itself in place
  • Parcel-exact AoI — parcels map to covering grid cells once at handshake; per-subject parcel filter at tick time; fixed TIER_0 (no distance tiers)
  • Post-auth choke point — everything except RESYNC_REQUEST is silently dropped and counted; the parcel set is immutable by construction
  • Config SceneListener:MaxParcels (default 256, reject over-cap), pulse.scene_listener.* metrics
  • Includes refactors: shared HandshakeHandlerBase pipeline for both handshakes; SimulateTick split into player/listener paths
  • Also fixes IdentityBoard losing the live wallet binding when an evicted peer's cleanup runs

Requires

Companion @dcl/protocol commit b875338 (SceneListenerHandshakeRequest, ClientMessage.scene_listener_handshake = 8).

Verification

  • Unit suite 404/404
  • Live E2E: listener + 2 wandering bots, ~35 min — positional stream flows parcel-exact (10.9k deltas, joins/leaves on parcel entry/exit); 222 emote broadcasts reached players, 0 leaked to the listener; listener never appears to players
  • Spec: docs/superpowers/specs/2026-07-07-scene-listener-design.md

🤖 Generated with Claude Code

mikhail-dcl and others added 22 commits July 7, 2026 17:33
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Regenerate ClientMessage with the new scene_listener_handshake oneof
case (= 8) and a round-trip envelope test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add SimulateSceneListenerObserver + CollectSceneListenerSubjects and thread
positionalOnly through the shared pipeline so listeners get parcel-exact,
TIER_0, positional-only fan-out with no snapshot of their own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add --scene-listener-parcels flag that connects receive-only, logs the
positional stream, and never sends after handshake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The listener now also subscribes to EmoteStarted/EmoteStopped/
PlayerProfileVersionAnnounced and logs any receipt as a LEAK line, so
the "0 received" tally is evidence rather than true by construction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prevents an already-AUTHENTICATED player from converting itself into a
listener in place, which would strand its snapshot/grid state as a ghost.
Adds coverage for the gate and mid-emote-join emote suppression; aligns
the spec's failure-path wording with the no-response implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Value-checked TryRemove so the duplicate-session eviction's delayed
cleanup can't delete the wallet's rebound mapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the shared handshake pipeline (admission gate, attempt throttle, auth,
ban, replay, promotion, response/rejection) into a template-method base class;
handlers now override only what differs.
Now that the base is the only caller, fold the auth-chain parsing/validation
into a private method taking AuthChainValidator directly; drop the wrapper
class and its DI registration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SimulateTick now only walks lifecycle gates and dispatches to
SimulatePlayerObserver / SimulateSceneListenerObserver; the tail both paths
duplicated (view map, per-subject processing, resync drop, sweep) moves into
a shared ProcessCollectedSubjects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	src/DCLPulse/Peers/Simulation/PeerSimulation.cs
#	src/Protocol/Generated/PulseClient.cs
@mikhail-dcl

Copy link
Copy Markdown
Collaborator Author

Wire change follow-up: the scene-listener handshake now announces its area of interest as repeated ParcelRect parcel_rects (inclusive parcel-coordinate rects, sint32 corners, a single parcel is min == max) instead of repeated int32 parcel_indices. Clients no longer compute ParcelEncoder-packed indices — the server validates the rects and expands them to the internal parcel set on receipt.

Motivation: an explicit index list ballooned the handshake packet for large scenes; rects keep it compact. The cap is now the Σ of nominal rect areas ≤ SceneListener:MaxParcels (default raised 256 → 4096), rejected — never clamped. Overlapping rects are budgeted by area sum, not union, so clients should announce disjoint rects.

Spec (docs/superpowers/specs/2026-07-07-scene-listener-design.md) and the client integration guide (#30) are updated to match.

mikhail-dcl and others added 4 commits July 17, 2026 13:38
Replace parcel_indices with inclusive parcel-coordinate rects (sint32) so
big scenes fit in small handshake packets. The server validates rect bounds
and a sum-of-areas budget (MaxParcels, raised to 4096) before expanding to
the internal parcel set; everything downstream of the handshake is
unchanged. Test client announces x:z / x1:z1..x2:z2 specs. Includes a
311-parcel real-scene fixture proving the cover -> expansion round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	src/DCLPulse/Metrics/MetricsSnapshot.cs
#	src/DCLPulseTestClient/ClientOptions.cs
#	src/DCLPulseTestClient/Program.cs
…tests

Main's WebTransport test fixtures construct 8-bucket counters; the envelope
on this branch has SceneListenerHandshake = 8, so the Prometheus formatter
read out of bounds. Bump all seven test constructions to 9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Parameterless ctor sizes the array to max defined value + 1, so the
count can no longer go stale when the message envelopes grow. Explicit
count ctor kept for special cases; Program.cs and all test call sites
migrated off the hardcoded literals.

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

@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

Thanks for the detailed design notes and test coverage. I found two P1 issues that should be addressed before merge.

Findings

  1. P1 — Breaking protocol change is not coordinated with the existing client package

    The PR changed SceneListenerHandshakeRequest to require parcel_rects (src/Protocol/Generated/PulseClient.cs, src/DCLPulse/Messaging/Hardening/FieldValidator.cs:132 rejects when ParcelRects.Count == 0). However, the current decentraland/dcl-pulse-client consumer still builds scene-listener handshakes with parcelIndices (src/client/handshake.ts, src/protocol/messages.ts). With this server, an existing client will send no parcel_rects and be rejected as INVALID_HANDSHAKE_FIELD.

    Please either keep a backwards-compatible migration path on the server for the old parcel_indices shape, or coordinate/update/release the client package before this server change lands and document the required version.

  2. P1 — Player handshakes are still accepted after authentication and can re-key a live peer

    HandshakeHandlerBase.CanBeginHandshake() defaults to true (src/DCLPulse/Messaging/HandshakeHandlerBase.cs:130), and the normal HandshakeHandler does not override it. An already AUTHENTICATED peer can send another player HANDSHAKE; the shared pipeline replaces peers[from] and calls identityBoard.Set(from, wallet) without removing the previous wallet’s reverse mapping. If the second handshake uses a different wallet, IdentityBoard can retain a stale oldWallet -> same PeerIndex entry while walletsByPeerIds[from] now points to the new wallet.

    Please gate the normal player handshake to PENDING_AUTH as well (or explicitly remove the old identity mapping before any supported re-auth flow), and add regression tests for an authenticated peer sending a second handshake.

  3. P2 — Initial-state emote mask loses optional presence

    HandshakeHandler.SeedInitialState() maps an omitted EmoteMask to 0 and passes it as present (src/DCLPulse/Messaging/HandshakeHandler.cs:90-92). The live EmoteStartHandler preserves absence with int? mask = emoteStart.HasMask ? emoteStart.Mask : null. Reconnect/initial-state emotes can therefore serialize mask = 0 where live emotes omit the field. Please preserve presence with int? emoteMask = initialState.HasEmoteMask ? initialState.EmoteMask : null.

Security review

No hardcoded secrets or obvious injection issues found. The authenticated re-handshake issue above is the main auth/lifecycle security concern.

Consumer impact

The scene-listener protocol surface is public. I found an existing downstream consumer (decentraland/dcl-pulse-client) still using the old parcel-index handshake shape, so this needs a compatibility or rollout plan before merge.

CI

All current checks are passing.


Reviewed by Jarvis 🤖 · Requested by Mikhail Agapov (<@U04A7TYN13L>) via Slack

@mikhail-dcl

Copy link
Copy Markdown
Collaborator Author

Re Finding 1 (P1 — "Breaking protocol change is not coordinated with the existing client package"): not applicable.

The Pulse protocol is pre-GA and has no released external consumers whose wire compatibility must be preserved. dcl-pulse-client is being adapted in lockstep with this branch — its scene-listener handshake moves from parcel_indices to parcel_rects as part of the same coordinated change, so the client and server evolve together and stay in sync. There is no scenario where an already-released client is stranded against this server.

A server-side back-compat shim for the old parcel_indices shape is therefore intentionally not planned; dropping the field outright (reusing tag 3) is the deliberate choice for the pre-GA phase. No action needed on this finding.

🤖 Generated with Claude Code

…sk presence

Finding 2 (P1): HandshakeHandler now overrides CanBeginHandshake to require
PENDING_AUTH, matching SceneListenerHandshakeHandler. An already-AUTHENTICATED
peer can no longer re-key a live session in place (which left a stale
oldWallet -> PeerIndex reverse mapping in IdentityBoard). Genuine reconnects
arrive on a fresh PeerIndex, so they are unaffected. Regression test added.

Finding 3 (P2): SeedInitialState preserves emote-mask absence (int? = null)
instead of collapsing an omitted mask to a present 0, matching EmoteStartHandler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@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

Thoroughly reviewed the full 5800-line diff across 55 changed files. This is a well-designed, well-tested feature addition. The previous review's P1 findings (re-handshake on authenticated peer, emote mask presence) have been properly fixed, and the IdentityBoard race condition fix is correct. No P0 or P1 issues remain — approving.

Previous findings — resolved

# Previous finding Resolution
1 P1 — Breaking protocol change (parcel_indicesparcel_rects) Author confirmed pre-GA, coordinated lockstep with dcl-pulse-client. No released external consumers. Accepted.
2 P1 — Re-handshake on authenticated peer ✅ Fixed. Both HandshakeHandler and SceneListenerHandshakeHandler now override CanBeginHandshake() to require PENDING_AUTH. New test Handle_SecondHandshakeOnAuthenticatedPeer_IsDroppedAndDoesNotRekey covers the regression.
3 P2 — EmoteMask presence ✅ Fixed. SeedInitialState now uses int? emoteMask = initialState.HasEmoteMask ? initialState.EmoteMask : null, matching the live EmoteStartHandler semantics. New tests Handle_EmoteInitialStateWithoutMask_LeavesMaskNull and Handle_EmoteInitialStateWithMask_PreservesMask cover both paths.

New findings

[P2] CanBeginHandshake base default is now dead code with a stale doc comment

HandshakeHandlerBase.CanBeginHandshake() defaults to true with a comment saying "the player flow re-authenticates regardless." But the player handler now overrides to require PENDING_AUTH, so the default is never exercised. The comment is misleading — a future maintainer reading the base class would believe re-auth is intended.

Consider either:

  • Making CanBeginHandshake abstract (forces future handlers to decide explicitly), or
  • Changing the default to PENDING_AUTH (safe-by-default) and updating the comment.

[P2] Design spec proto snippet is stale

docs/superpowers/specs/2026-07-07-scene-listener-design.md ("Protocol changes" section) still shows:

repeated int32 parcel_indices = 3;

but the actual wire protocol now uses repeated ParcelRect parcel_rects = 3 (as correctly reflected in CLAUDE.md and the generated C#). The spec should be updated to match.

Security review

No security issues found. Specifically verified:

  • Auth pipeline: Scene-listener handshakes flow through the same ECDSA auth-chain validation, ban-list check (before replay cache), and anti-replay guard as player handshakes. No bypass path.
  • PENDING_AUTH gate: Both handlers require PENDING_AUTH, preventing an authenticated peer from converting itself in place (which would leave ghost SnapshotBoard/SpatialGrid entries).
  • IdentityBoard race fix: TryRemove(KeyValuePair<string, PeerIndex>) is the correct ConcurrentDictionary overload for value-checked atomic removal — the evicted peer's delayed cleanup can no longer delete the live wallet→peer binding.
  • Post-auth choke point: IsForbiddenForSceneListener drops all non-Resync messages from listeners, counted via metrics. No disconnect on violation (degrades to noise, not churn). Parcel set immutable by construction.
  • Input validation: ValidateSceneListenerHandshake validates rect well-formedness (min ≤ max), coordinate bounds (IsValidCoordinate for corners — sufficient since the valid region is rectangular), and nominal area budget with long arithmetic (no int overflow). Budget enforced before expansion to prevent CPU/memory DoS.
  • No hardcoded secrets, no new network exposure.

Consumer impact

The ParcelRect-based scene-listener handshake is a new protocol surface with no GA consumers. The dcl-pulse-client adaptation is coordinated in lockstep. No backward-compatibility concern.

Architecture assessment

  • Template method pattern (HandshakeHandlerBase): Well-motivated — the shared pipeline (attempt throttle → auth-chain parse → ban check → replay guard → promote → evict duplicate → register identity → respond) is security-critical and shouldn't be duplicated. The hooks (GetAuthChain, TryAuthorize, OnAuthenticated, LogAccepted) are clean extension points.
  • Peer variant, not separate subsystem: Correct choice — reusing the observer pipeline (views, diffs, resync, sweeps) avoids duplicating ~500 lines of battle-tested machinery. The SceneListener descriptor on PeerState cleanly signals the variant.
  • positionalOnly flag: Threading a boolean through ProcessVisibleSubjects is pragmatic for exactly two paths (player/listener). The gates are placed precisely at the three emission points (emote start on new subject, emote start on existing subject, profile announcement). Emote stop suppression is implicit and correct — since EmoteStarted is never sent to listeners, view.LastSentEmote stays null, so the stop path is a no-op.
  • Parcel-to-cell mapping: Computed once at handshake, O(1) per cell per tick — efficient. The covering-cell over-approximation (1–4 cells per 16m parcel on a 100-unit grid) is harmless because the simulation filters parcel-exact.
  • EnumCounters parameterless ctor: DeriveBucketCount from max defined value + 1 prevents the bucket count from going stale when new ClientMessage variants are added. Good defensive change.

Test coverage

404/404 tests passing. New test suites provide excellent coverage:

  • SceneListenerHandshakeHandlerTests — accept, reject (over-cap, empty, bad auth), PENDING_AUTH gate, duplicate eviction, no subject registration
  • PeerSimulationTests.SceneListener — parcel-exact filtering, cross-realm invisibility, emote/profile suppression, teleport delivery, sweep-based PlayerLeft, resync served
  • SceneListenerMessagePolicyTests — every forbidden message type dropped, Resync allowed, metric counting
  • FieldValidatorTests — rect validation (valid, inverted, out-of-bounds, over-cap, overlapping dedup, origin-crossing)
  • SceneListenerRealSceneTests — 311-parcel real scene with greedy rect cover, server-side expansion reproduces exact footprint
  • IdentityBoardTests.Remove_OldPeerAfterWalletRebound_KeepsLiveForwardMapping — regression test for the race fix
  • HandshakeHandlerTests — re-handshake drop, eviction cleanup chain, emote mask presence

CI status

Tests ✅ | Linux build ✅ | macOS build ✅ | Win build pending


Reviewed by Jarvis 🤖 · Requested by mikhail-dcl via GitHub

/// (the player flow re-authenticates regardless). Scene listeners override to require
/// PENDING_AUTH so an already-authenticated peer can't convert itself in place.
/// </summary>
protected virtual bool CanBeginHandshake(PeerState existingState) => true;

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.

[P2] Stale default + doc comment. Both concrete handlers now override this to PENDING_AUTH, making the true default dead code. The comment says "the player flow re-authenticates regardless" — no longer accurate after the fix. Consider making this abstract (forces future handlers to decide) or changing the default to PENDING_AUTH (safe-by-default).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in e08b5d8: CanBeginHandshake is now abstract, removing the dead true default — both handlers require PENDING_AUTH and future handlers must decide explicitly. (The doc-comment half was fixed earlier in ee66461.)

```proto
message SceneListenerHandshakeRequest {
bytes auth_chain = 1; // same signed-fetch headers JSON as HandshakeRequest
string realm = 2; // required; same rules as TeleportRequest.realm

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.

[P2] Stale proto snippet. This section still shows repeated int32 parcel_indices = 3 but the actual wire format is now repeated ParcelRect parcel_rects = 3 (as documented correctly in CLAUDE.md). Worth updating to match.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in e08b5d8: the spec now shows the ParcelRect/parcel_rects wire format (sint32), the Σ-area budget, cap 4096, and rect validation/expansion — matching CLAUDE.md and the implementation.


// Value-checked removal: after a duplicate-session eviction the wallet is already
// rebound to the replacement peer, and the evicted peer's delayed cleanup must not
// delete that live mapping.

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.

👍 Excellent fix. The TryRemove(KeyValuePair<string, PeerIndex>) overload is exactly the right tool — atomically removes only if the value still matches, so a late cleanup of an evicted peer can never delete the live wallet binding. The regression test (Remove_OldPeerAfterWalletRebound_KeepsLiveForwardMapping) pins it well.

mikhail-dcl and others added 2 commits July 22, 2026 12:55
The player HandshakeHandler now overrides CanBeginHandshake to require
PENDING_AUTH (470c403), so the base default's comment claiming the player
flow re-authenticates regardless was stale. Comment-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- HandshakeHandlerBase.CanBeginHandshake is now abstract, removing the dead
  `true` default (both concrete handlers override it) and forcing future
  handlers to make the admission decision explicitly.
- Update the scene-listener design spec to the parcel-rect wire format:
  ParcelRect/parcel_rects proto, sint32 coords, Σ-area budget, cap 4096,
  rect validation + expansion — the spec had lagged behind CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <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