Skip to content

feat: per-realm scene-listener AoI, reassignable in place - #39

Merged
mikhail-dcl merged 8 commits into
mainfrom
feat/scene-listener-aoi-update
Aug 31, 2026
Merged

feat: per-realm scene-listener AoI, reassignable in place#39
mikhail-dcl merged 8 commits into
mainfrom
feat/scene-listener-aoi-update

Conversation

@robtfm

@robtfm robtfm commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Part of decentraland/sdk-multiplayer-server#132 — rollout plan in this comment. Protocol side: decentraland/protocol#469.

Two limits of the scene listener as shipped: it observed a single realm, and its parcel set was fixed for the connection's lifetime. An authoritative server needs neither — it cohosts scenes from several realms (every world numbers its parcels from 0,0, so two cohosted worlds share both grid cells and parcel indices), and it loads and unloads scenes while it runs. One connection per realm is ruled out by the wallet-unique session rule and the per-IP listener cap.

  • The announced AoI becomes repeated SceneListenerAoi, one entry per realm, on both the handshake and the new SceneListenerUpdate. SceneListenerState holds ParcelsByRealm plus one union of covering cell keys; Observes(realm, parcel) replaces the separate realm compare and parcel lookup. The Σ-area budget spans the whole announcement, and a realm may appear only once.
  • SceneListenerUpdateHandler swaps in a whole new descriptor rather than mutating one. The simulation re-reads it each tick, so a new AoI takes effect on the next one; dropped subjects age out through the ordinary stale-view sweep (now pinned by a test). It rides the shared discrete-event bucket; a malformed AoI disconnects with INVALID_SCENE_LISTENER_FIELD (19) without partially applying. A player sending it is counted under the listener's forbidden-message metric rather than logged per packet.

Consumer: bevy-explorer decentraland/bevy-explorer#1137 (one listener per engine, one AoI entry per hosted realm, updates coalesced to one per second).

Verified: dotnet test 637/637.

🤖 Generated with Claude Code

robtfm and others added 2 commits August 26, 2026 19:07
Two limits of the scene listener as shipped: it observed a single realm,
and its parcel set was fixed for the connection's lifetime. An
authoritative server needs neither — it cohosts scenes from several
realms, and loads and unloads them while it runs.

The announced AoI becomes a repeated SceneListenerAoi, one entry per
realm, on both the handshake and the new SceneListenerUpdate.
SceneListenerState holds ParcelsByRealm plus one union of covering cell
keys; Observes(realm, parcel) replaces the realm compare and parcel
lookup that the simulation did separately. Parcels only mean anything
inside a realm — every world numbers its own from 0,0 — so two cohosted
worlds share both grid cells and parcel indices, and neither filter is
redundant. The Σ-area budget spans the whole announcement, so extra
realms buy no extra area, and a realm may appear only once.

SceneListenerUpdateHandler swaps in a whole new descriptor rather than
mutating one, keeping SceneListenerState immutable for its readers. The
simulation re-reads it each tick, so a new AoI takes effect on the next
one: subjects inside it are joined like any newly visible peer, and
subjects dropped from it age out through the ordinary stale-view sweep,
exactly as for a player who walks out of range. It rides the shared
discrete-event bucket, and a malformed AoI disconnects with
INVALID_SCENE_LISTENER_FIELD without partially applying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L7qjELVNiLBaqmn75jGknw
…lms are replaceable

A player sending SceneListenerUpdate was dropped ahead of the rate limiter so
it could not spend the discrete-event budget — and then logged a warning per
packet, the one unthrottled thing left on the path. Count it under the
listener's forbidden-message metric instead, like every other dropped message.

The design spec and the choke-point doc still said the realm was fixed for the
connection's lifetime; the update replaces realms with the rest of the AoI and
a test already pins that. What is fixed is the role.

Also pin that a subject dropped by a reassignment is swept out with PlayerLeft
on the ordinary stale-view path, which the docs promise but nothing tested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AT5fSeGp16vwqAMymSMS8A
@mikhail-dcl
mikhail-dcl self-requested a review August 27, 2026 12:14
robtfm and others added 3 commits August 27, 2026 13:30
The protocol drops the reserved fields: no listener ever shipped on the
single-realm handshake, so `aoi` takes field 2 outright. Generated code
and the spec's excerpt follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The committed bindings put SceneListenerHandshakeRequest.aoi on field 2; the
protocol reserves 2 and 3 and puts it on 4, so a client generated from it
announced into a field this server never read and every listener handshake was
rejected as an empty AoI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The AoI's second pass now pays for itself — it sizes each parcel set from the area
the first measured, and takes the cell cover from each rect as a cell range instead
of four probes per parcel: same cover, 3x less allocation, ~10x faster at the cap.
MaxParcels charges per realm too, so 4096 single-parcel realms are no longer a legal
1.6 MB announcement. Also: FieldValidator returns the finished descriptor so neither
handler builds one, unbounded realm-name joins are out of log arguments, and
multi-realm filtering is tested through the simulation rather than only the validator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Agapov <mikhail.agapov@decentraland.org>
…nnect

Lower PeerTimeoutMs to 5s, split the sweep constant into a 1s cadence and 3s threshold, and clear snapshot/grid entries on the lifecycle event so the sweep starts at disconnect. Take the seqlock write side in ClearActive.

Co-Authored-By: Claude Opus 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: per-realm scene-listener AoI, reassignable in place

Verdict: APPROVE — no P0 or P1 issues found. This is a well-crafted, thoroughly tested extension to the scene-listener protocol.

What the PR does

Converts the scene listener's area of interest from a single-realm, immutable-at-connect parcel set to a per-realm (Dictionary<string, HashSet<int>>) descriptor that can be swapped in place via the new SceneListenerUpdate message. The change is motivated by an authoritative server cohosting scenes from several worlds — since every world numbers its parcels from 0,0, a flat parcel set cannot distinguish them, and the per-IP/wallet-unique session limits forbid one connection per world.

Other notable changes:

  • Two-phase disconnect: ClearActive + SpatialGrid.Remove now runs immediately on the Disconnected lifecycle event (phase 1), so the subject exits every AoI query on the next tick; identity/slot cleanup still waits for the DisconnectionCleanTimeoutMs grace window (phase 2).
  • Stale-view sweep rework: replaces SWEEP_INTERVAL = 100 with VIEW_STALE_TICKS = 60 + SWEEP_CHECK_INTERVAL = 20, giving a documented 3.05–4.0 s eviction bound.
  • PeerTimeoutMs reduced from 30 s to 5 s — see P2 note below.
  • Cell-cover optimization: SceneListenerCellMapper now operates per-rect at O(cells) instead of per-parcel at O(4×parcels).

Security audit

  • Rate-limiter ordering ✅ — SceneListenerUpdateHandler applies auth → role check → DiscreteEventRateLimiter → expensive FieldValidator validation/expansion. The O(Σ rect area) work runs only after a token is consumed.
  • Budget validation ✅ — cumulative budget (long) accumulates REALM_BUDGET_COST + Σ rect areas across all realms, checked incrementally. Two-pass validation (price before expand) prevents hostile payloads from buying expansion work on their way to rejection. No integer overflow possible.
  • Input validation completeness ✅ — empty AoI, empty realm, repeated realm, empty rect list, inverted rects, out-of-bounds coordinates, and over-budget announcements are all rejected with disconnect.
  • Player sending SceneListenerUpdate ✅ — two-layer defense: IsForbiddenForSceneListener passes it (not a listener message), but the handler drops at the SceneListener is not { } check before the rate limiter, so it cannot spend a player's discrete-event budget.
  • Thread safety ✅ — both the update handler (writer) and the simulation tick (reader) of PeerState.SceneListener run on the owning worker thread. The is { } listener capture in SimulateTick gives a consistent reference for the entire tick. Reference assignment is atomic on .NET regardless.
  • Seqlock in SnapshotBoard.ClearActive ✅ — protocol is correct (odd/even version, Thread.MemoryBarrier before closing even write). Single-writer invariant is maintained: ClearActive is called only from the owning worker thread.
  • Realm name injection ✅ — names are non-empty, length-capped, duplicate-rejected, and used only as dictionary keys and string.Equals comparisons. No injection surface.

Consumer impact

  • Protocol change: SceneListenerHandshakeRequest field 2 changed from string realm to repeated SceneListenerAoi aoi — wire-incompatible, but the PR description confirms nothing shipped the old shape. Protocol side tracked in decentraland/protocol#469.
  • bevy-explorer: consumer PR linked (decentraland/bevy-explorer#1137).
  • unity-explorer: only has the generated proto file (PulseClient.gen.cs), which would need regeneration; no runtime usage of the scene-listener handshake path found.

Test coverage

Excellent. Key new test files:

  • SceneListenerUpdateHandlerTests.cs (221 lines) — valid/invalid updates, role check, rate limiting, realm replacement.
  • PeerSimulationTests.TwoPhaseDisconnect.cs (226 lines) — phase-1 instant visibility drop, phase-2 deferred cleanup, sweep timing.
  • Extended FieldValidatorTests with multi-realm, budget-spanning, realm-overhead, repeated-realm, and cell-cover tests.
  • PeerSimulationTests.SceneListener extended with AoI reassignment, per-realm filtering, realm-set replacement, and sweep-bound tests.

P2 findings (non-blocking)

All minor — none block merge.

1. [P2] Misleading memory-model comment in SnapshotBoard.ClearActive
src/DCLPulse/Peers/Simulation/SnapshotBoard.cs

The comment says "Volatile.Write alone only prevents reordering of that store with later stores — on ARM, preceding stores could still be reordered past it." This is incorrect: .NET's Volatile.Write has release semantics on ARM64 (stlr), which already prevents preceding stores from being reordered past it. The Thread.MemoryBarrier() is therefore belt-and-suspenders — correct but redundant. The comment could mislead a future maintainer into removing the Volatile.Write instead of the truly redundant barrier. Consider correcting the comment.

2. [P2] PeerTimeoutMs reduced from 30 s to 5 s
src/DCLPulse/Transport/ENetTransportOptions.cs

The flat deadline means any network stall longer than 5 s drops the peer. The doc acknowledges the tradeoff and the development override to 300 s. Worth verifying this has been load-tested with mobile clients, which can experience >5 s stalls on cell networks. The reconnect flow absorbs it, but operators should be aware the default is now aggressive.

3. [P2] SceneListenerState exposes mutable collection types
src/DCLPulse/Peers/SceneListenerState.cs

ParcelsByRealm is Dictionary<string, HashSet<int>> and CellKeys is long[] — both mutable despite the class being documented as immutable. No current reader mutates them and the single-writer/single-reader threading model prevents corruption, but IReadOnlyDictionary<string, IReadOnlySet<int>> and ReadOnlyMemory<long> would enforce the contract at the type level. Low priority — consistent with the pre-existing pattern.

Git conventions ✅

  • PR title: feat: per-realm scene-listener AoI, reassignable in place — semantic commit format.
  • Branch: feat/scene-listener-aoi-update — matches.

CI ✅

All checks pass: build (linux/win/osx), tests (637/637).


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

Comment thread src/DCLPulse/Peers/Simulation/SnapshotBoard.cs
Comment thread src/DCLPulse/Transport/ENetTransportOptions.cs
@mikhail-dcl
mikhail-dcl merged commit a750b19 into main Aug 31, 2026
5 checks passed
@mikhail-dcl
mikhail-dcl deleted the feat/scene-listener-aoi-update branch August 31, 2026 11:59
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