Skip to content

feat: carry local scene development player state over pulse - #9857

Open
mikhail-dcl wants to merge 3 commits into
devfrom
chore/pulse-lsd
Open

feat: carry local scene development player state over pulse#9857
mikhail-dcl wants to merge 3 commits into
devfrom
chore/pulse-lsd

Conversation

@mikhail-dcl

@mikhail-dcl mikhail-dcl commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

What does this PR change?

Local Scene Development (LSD) now carries player state over Pulse, while the local gatekeeper
LiveKit room keeps carrying Scene Messages. This is a dual-transport change, not a replacement —
comms-gatekeeper-local.decentraland.org and LocalSceneDevelopmentSceneRoomMetaDataSource are
untouched, and nothing is decommissioned.

Traffic Transport
Movement, emotes, teleports, profile-version announcements Pulse
SDK MessageBus, client↔client CRDT sync LiveKit, local gatekeeper scene room

The realm-key contract (cross-repo — please read)

Pulse has no rooms; it partitions visibility by exact realm-string match. Every concurrent dev
process would otherwise share one realm, so each derives a key from the preview entity id its dev
server already serves:

previewSceneId = "b64-" + base64(absoluteProjectRoot + "-" + machineId)   // minted by sdk-commands
realmKey       = "lsd:" + previewSceneId

and past Pulse's MaxRealmLength of 255 it collapses deterministically:

realmKey = "lsd:sha256:" + SHA256Hex(previewSceneId)

Hex casing is lowercase, and the hash is taken over previewSceneId including its b64-
prefix. The overflow form is always 75 characters. Hashed rather than truncated on purpose.

This matches js-sdk-toolchain#1554
(logic/lsd-realm.ts) exactly — that PR was already open, so the casing is confirmed against it
rather than merely declared
. Node's .digest('hex') is lowercase; PulseRealm
spells the digits out ("0123456789abcdef") instead of using culture-dependent formatting.
PulseRealmShould pins both worked examples published in that PR's
docs/lsd-identity-and-pulse-realm.md, so a future drift on either side fails a test here:

  • lsd:b64-L2hvbWUvZGV2L215LXNjZW5lLWRldi1ib3g=
  • lsd:sha256:783635fb50eadaed0300d80104920bfc55894d5ad2ab69ab6b48c6ff1ddb9da5

Nothing is exchanged at runtime — every party derives the key independently. That is what makes
isolation work with no paired endpoint, and it is also the failure mode: implementations that drift
do not error, their peers just never see each other (the LiveKit preview-${sceneId} vs
LocalPreview:{sceneId} bug class). bevy-explorer must match these strings byte-for-byte.

The key derives from the entity id only, never a content hash — js-sdk-toolchain#1529 keeps the
project directory's own entity id path-only — so it survives content edits, hot reloads and
dev-server restarts.

Technical changes

  • FeaturesRegistry — dropped && !localSceneDevelopment from the FeatureId.Pulse gate. LSD
    resolves no remote feature flags (the flag host is the local dev server), so simply removing the
    clause would have left Pulse driven by a flag that can never be on there. The fallback is now
    localSceneDevelopment || featureFlags.IsEnabled(...), which is what "defaults ON in LSD" requires.
    --pulse false remains the way back to LiveKit-only.
  • PulseRealm — one concrete class every realm read/write in PulseMultiplayerBus now goes
    through (11 sites across 4 files — more than the 6 originally listed; the extra ones are the
    PlayerJoined filter and the RemoveWhereNotInRealm purge). No interface and no second
    implementation: the optional ILocalSceneEntityIdSource constructor argument is the mode, so it
    reads as "follow the realm, unless a local dev server tells us otherwise". With it null — every
    session that is not local scene development — Value is IRealmData.RealmName read live, so
    this is a pure passthrough with no behaviour change. PulseMultiplayerBus no longer takes
    IRealmData at all; it had no other use for it.
  • LocalSceneEntityIdSource — the dev server's two-step fetch (GET scene.json → base parcel →
    POST content/entities/activeresult[0].id), extracted verbatim out of
    LocalSceneDevelopmentSceneRoomMetaDataSource, which now delegates to it. One definition of "the
    local scene's entity id" instead of two that can drift. It also returns the base parcel, which the
    Pulse realm uses for the bounds warning below.
  • StartPulseMultiplayerStartupOperation — awaits EnsureResolvedAsync before ConnectAsync
    (the realm ships in the handshake's PlayerInitialState.Realm, the very first message). If the
    realm is still empty it deactivates Pulse and returns success. Confirmed against the server:
    FieldValidator.ValidateHandshake rejects an empty realm with INVALID_HANDSHAKE_FIELD, so
    connecting anyway would join a session nothing can be filtered into. Resolution never throws — a
    failure leaves the realm empty rather than failing log-in.
  • Genesis bounds warning — Pulse's FieldValidator disconnects peers reporting parcel indices
    outside Genesis City, so PulseRealm logs a warning naming the parcel and the
    bounds. (sdk-commands refuses to start such a scene, so this only fires for dev servers it did
    not launch.)
  • No --pulse-url. The endpoint is pulse-server.{BaseDomain}, so it follows the session's
    environment — org by default, pulse-server.decentraland.zone:7777 under --dclenv zone.
    LocalGateKeeperSceneAdapter stays pinned to the org domain regardless of environment, so Scene
    Messages keep working either way. That is what makes the zone end-to-end run below possible with
    no new argument.

Why there is no double delivery (dual-transport check)

Verified by reading the wiring rather than assuming:

  • Player state goes through LiveKitMessagesBroadcaster, which reads PulseActivation live. With
    Pulse active it sends only to wallets in announcedWallets — populated exclusively by
    AnnounceProfileVersion arriving over a LiveKit pipe. Since that announcement is itself sent
    through the same broadcaster, the set starts empty and stays empty, and BuildMessageAndSend is
    skipped on the Count > 0 guard. The LiveKit player-state path self-suppresses. A peer running
    --pulse false still broadcasts to all, lands in the other's announcedWallets, and from then on
    receives over LiveKit — mixed fleets interoperate without duplication. AUTH_SERVER_IDENTITY is
    always appended, so authoritative-server bots keep getting everything over LiveKit.
  • Scene Messages go through SceneCommunicationPipe, which uses messagePipesHub.ScenePipe()
    directly and never consults PulseActivation. They are structurally unaffected by this change.

Both mechanisms are transport- and realm-agnostic, so they hold in LSD exactly as in Play mode.

Docs

docs/pulse.md (new "Realm — PulseRealm" section, plus the feature-flag and start-up-fallback
sections it quotes), docs/how-to-connect-to-a-local-scene.md (new "Multiplayer in a local scene"),
and touch-ups to docs/multiplayer.md and docs/livekit-networking.md.


Test Instructions

Use robtfm/lsd-zone-scene — an authoritative-multiplayer
scene built for exactly this feature. It spawns a Pulse-capable bevy auth server and publishes its
player roster (address + position, 1 Hz) into a synced component, so the server itself tells you
whether client state arrived over Pulse.

Do not use the client log as the pass criterion.
Local scene development Pulse realm resolved to 'lsd:b64-…' is a ReportHub.Log (info) call, and
info is disabled in player builds by the severity matrix — you will not see it in a build, and its
absence means nothing. It is an Editor/debug convenience only.

The decisive signal is the server log, which is a separate process and unaffected by the
client's log matrix.

Prerequisites

  • Node (the scene declares >= 24.16; 24.15.0 worked in practice)
  • Outbound UDP 7777 to pulse-server.decentraland.zone
  • The client must run against zone — pass --dclenv zone. The Pulse endpoint is
    pulse-server.{BaseDomain} and this scene's server is on zone, so a client left on the default
    org lands in a different Pulse instance and is silently invisible — matching realm key, no
    peers, no error. There is no --pulse-url and none is needed.

Step 1 — start the scene + auth server

git clone https://github.com/robtfm/lsd-zone-scene && cd lsd-zone-scene
npm install
RUST_LOG='warn,scene_runner::renderer_context=info,comms=info' \
  npx sdk-commands start --no-client --port 8000

--no-client stops it launching its own explorer. The port is arbitrary as long as --realm below
matches it. Wait for:

pulse: configured for pulse-server.decentraland.zone:7777 (PULSE_SERVER)
pulse: local scene development realm resolved to lsd:b64-…
pulse: handshake accepted
LSD|server|roster|empty                     ← heartbeat, every 5 s

If you see repeated disconnected (None) and never handshake accepted, zone Pulse is unreachable —
that is not a realm-key mismatch and not this PR. Confirm handshake accepted before testing.

Step 2 — launch the build

Get the build with metaforge explorer run 9857, then launch the executable directly so you can pass
args:

Windows

"C:\Users\[YOUR-USER]\Downloads\Decentraland_windows64\Decentraland.exe" --realm http://127.0.0.1:8000 --position 0,0 --local-scene true --dclenv zone --debug --skip-version-check true

macOS

open Decentraland.app --args --realm http://127.0.0.1:8000 --position 0,0 --local-scene true --dclenv zone --debug --skip-version-check true
Arg Why
--realm http://127.0.0.1:8000 the local preview server (match Step 1's port)
--position 0,0 the scene's base parcel
--local-scene true local scene development mode
--dclenv zone required — puts Pulse on pulse-server.decentraland.zone:7777
--debug --skip-version-check true debug panel, skip the launcher version gate

Deliberately not passed: --pulse. Its absence is the point — Pulse defaulting on in LSD is
what this PR adds. Add --pulse false only for scenario E.

(In-Editor instead: Main.unityinitialRealm: 6 (Localhost, hardcoded to 127.0.0.1:8000),
targetScene: {x: 0, y: 0}, decentralandEnvironment: 1 (Zone), appParameters empty. The Editor
reads debugSettings.appParameters, not the command line.)

Test Steps

A — client state reaches the auth server over Pulse (the core check)

  1. With the server running, launch one build as above.
  2. Walk the avatar around inside the scene.
  3. Expected: the server's roster heartbeat stops saying empty and names your address with a
    position that changes as you walk
    :
    LSD|server|enter|0x…
    LSD|server|roster|0x…@0.0,0.1,0.0
    LSD|server|roster|0x…@1.3,0.1,3.7     ← moved
    LSD|server|roster|0x…@8.7,0.1,4.7     ← moved again
    
    The position is the proof. Avatar state rides Pulse exclusively, so a position in the
    server's roster proves client → Pulse → scene-listener → server. The address alone can arrive
    over the LiveKit scene room and proves nothing about Pulse. A position that appears once and never
    changes while you walk is a failure, not a pass.
  4. Expected: an orange marker sphere tracks each server-reported position in-world; drift between
    a sphere and the rendered avatar is a presence lag.

B — two clients see each other, exactly once

  1. Keep A running; launch a second build the same way.
  2. Expected: both avatars visible and moving smoothly, no duplicated or double-moving avatars
    that would mean LiveKit and Pulse are both applying the same movement.
  3. Expected: the server roster lists both addresses with independent, changing positions.

C — cross-process isolation

  1. Clone the scene into a second folder and start it on another port (--port 8001).
  2. Launch a third build with --realm http://127.0.0.1:8001.
  3. Expected: the third client sees neither of the first two, and they do not see it. Confirm on
    both servers: each roster lists only its own client. The two servers' realm resolved to lines
    must differ.

D — Scene Messages still work (LiveKit, untouched)

  1. Click the cube in scenario A or B.
  2. Expected: the colour toggles and both clients agree. That is the SDK MessageBus/CRDT path
    over the local gatekeeper room — this PR must not affect it.

E — opt-out parity

  1. Relaunch with --pulse false added.
  2. Expected: behaviour identical to before this PR — everything over LiveKit. The server roster
    will show the address but no useful position, since nothing is publishing to Pulse.

F — Pulse or dev server unreachable

  1. Launch with zone Pulse blocked, or with no dev server on the port.
  2. Expected: log-in still completes and LSD works as before — the client falls back to
    LiveKit-only rather than hanging or erroring.

Additional Testing Notes

  • The realm key is path-based, not content-based: it must survive /reload, scene edits and
    dev-server restarts. Avatars should stay visible across all three.
  • A scene outside Genesis City bounds gets no player state over Pulse (the server rejects the parcel
    index and disconnects the peer). Scene Messages still work. sdk-commands refuses to start such a
    scene, so reproducing needs a hand-rolled dev server.
  • Two quirks of the test scene, not of this PR: the auth server cannot resolve guest profiles,
    so onEnterScene may not fire server-side for guests (the roster heartbeat still shows them); and
    the roster is a 1 Hz heartbeat, not a change feed, so a stationary avatar repeats the same position.

Quality Checklist

  • Changes have been tested locally (see below)
  • Documentation has been updated
  • Performance impact has been considered — outside LSD this is one property read behind one class,
    replacing a property read; no allocation in BroadcastTeleport or the message handlers
  • For SDK features: test scene — robtfm/lsd-zone-scene

What was verified locally, and what was not

Executed — end to end against a real second implementation:

Ran lsd-zone-scene (auth server pinned to the
js-sdk-toolchain#1565 CDN build, which
presets PULSE_SERVER=pulse-server.decentraland.zone:7777 and runs the Pulse-capable bevy @next
engine) with one Unity client from this branch, against zone.

  1. The cross-repo realm key matched byte-for-byte. bevy's Rust derivation and this branch's C#
    PulseRealm.RealmKeyFor independently produced
    lsd:b64-RTpcRGVjZW50cmFsYW5kXGxzZC16b25lLXNjZW5lLVJldm9sdXRpb24=, decoding to
    <projectRoot>-<hostname>. This is the contract's one silent failure mode, now checked against an
    actual second implementation rather than only against published vectors.
  2. Client state reached the auth server over Pulse, and tracked movement. The server roster went
    from empty to the client's address with a position that followed the avatar:
    0.0,0.1,0.01.3,0.1,3.78.7,0.1,4.7, plus matching enter/leave. Since avatar state
    rides Pulse exclusively, that is the full client → Pulse → scene-listener → server loop.
  3. The client reached zone, not org, transitively: the server is on zone and would not see an
    org-connected peer.

Also executed:

  • Unity 6000.4.0f1 batch compile — 0 error CS. EditMode suite — 41/41 passed
    (PulseRealmShould, StartPulseMultiplayerStartupOperationShould,
    PulseMultiplayerBusRealmFilteringShould, FeaturesRegistryPulseShould, PulseActivationShould,
    PulseMultiplayerServiceShould, ENetTransportShould), including both published cross-repo vectors
    and both sides of the 255-character boundary.
  • Two dev servers in different folders → distinct realm keys, each decoding to its own
    <projectRoot>-<hostname>.
  • Key stability: edited scene source (server rebuilt) → key unchanged; killed and restarted the
    dev server on a fresh PID → key unchanged.
  • Server-side compatibility read from the Pulse repo: MaxRealmLength = 255, and realm validation
    is length-only with no charset restriction, so base64 +, / and = are safe.

Not executed — please cover in QA:

  • Scenario B: two clients at once. Single-client-to-auth-server is proven; mutual client
    visibility, and the absence of duplicate/double-moving avatars, is not.
  • Scenario C: the cross-process isolation run. Key derivation is proven distinct per folder, but
    not that two live sessions actually fail to see each other.
  • Scenario D: the cube / Scene Messages visual check.
  • Scenarios E and F: --pulse false parity and the unreachable fallback are covered by unit tests,
    not by a running client.
  • All of the above in a player build — the run above was in-Editor.

One correction to an earlier claim in this PR: authoritative-server previews are no longer outside
the Pulse story. bevy-headless gained a Pulse transport in server mode
(bevy-explorer #1137, the @next engine —
@latest predates it), superseding the "not covered" note that referenced
decentraland/sdk-multiplayer-server#132. The evidence above is that scenario working.

🤖 Generated with Claude Code

Pulse now defaults on in local scene development, alongside the local gatekeeper
LiveKit room rather than replacing it: player state (movement, emotes, teleports,
profile-version announcements) goes over Pulse, Scene Messages (SDK MessageBus and
client-to-client CRDT sync) keep going over LiveKit. Nothing is decommissioned.

Pulse has no rooms and partitions visibility by exact realm-string match, so every
concurrent dev process would otherwise land in the same realm. Each one now derives
a realm from the preview entity id its dev server already serves:

    realmKey = "lsd:" + previewSceneId

collapsing to "lsd:sha256:" + SHA256Hex(previewSceneId) (lowercase hex) past Pulse's
MaxRealmLength of 255. Nothing is exchanged; every party derives the identical string,
which is what avoids the LiveKit preview-room-name-mismatch class of bug. The contract
matches js-sdk-toolchain's logic/lsd-realm.ts, and the tests pin its published vectors.

- FeaturesRegistry: Pulse defaults on in local scene development (which resolves no
  remote flags), with --pulse false as the way back to LiveKit-only.
- IPulseRealm: the seam every realm read in PulseMultiplayerBus now goes through.
  RealmDataPulseRealm passes IRealmData.RealmName through live, so nothing changes
  outside local scene development; LocalSceneDevelopmentPulseRealm resolves the key.
- LocalSceneEntityIdSource: the dev server's two-step entity-id fetch, extracted from
  LocalSceneDevelopmentSceneRoomMetaDataSource so both transports share one definition.
- StartPulseMultiplayerStartupOperation resolves the realm before connecting, and
  deactivates Pulse when it cannot be resolved — an empty realm is rejected server-side,
  so this falls back to LiveKit-only rather than joining a broken session.
- Warn when the scene's base parcel is outside Genesis City bounds, which Pulse's
  FieldValidator rejects by disconnecting the peer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mikhail-dcl
mikhail-dcl requested review from a team as code owners August 25, 2026 15:26
@github-actions

github-actions Bot commented Aug 25, 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 be33f9b
Logs https://github.com/decentraland/unity-explorer/actions/runs/33387251615
Download Windows https://github.com/decentraland/unity-explorer/suites/90464308412/artifacts/9757095227
Download Windows S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/chore/pulse-lsd/pr-25708-be33f9b/Decentraland_windows64.zip
Download Mac https://github.com/decentraland/unity-explorer/suites/90464308412/artifacts/9757150542
Download Mac S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/chore/pulse-lsd/pr-25708-be33f9b/Decentraland_macos.zip
Built on 2026-08-31T12:10:21Z

Lint

Warnings count reduced: 12215 => 12212

Warnings/errors in files changed by this PR (23)
Assets/DCL/Multiplayer/Connections/Pulse/Tests/PulseRealmShould.cs:53  AccessToModifiedClosure  Captured variable is modified in the outer scope
Assets/DCL/Multiplayer/Movement/Tests/PulseMultiplayerBusRealmFilteringShould.cs:61  CSharpWarnings::CS8618  Non-nullable field 'beforeMessage' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Multiplayer/Movement/Tests/PulseMultiplayerBusRealmFilteringShould.cs:59  CSharpWarnings::CS8618  Non-nullable field 'bus' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Multiplayer/Movement/Tests/PulseMultiplayerBusRealmFilteringShould.cs:62  CSharpWarnings::CS8618  Non-nullable field 'currentRealm' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Multiplayer/Movement/Tests/PulseMultiplayerBusRealmFilteringShould.cs:60  CSharpWarnings::CS8618  Non-nullable field 'handlers' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Multiplayer/Movement/Tests/PulseMultiplayerBusRealmFilteringShould.cs:56  CSharpWarnings::CS8618  Non-nullable field 'incomingProfiles' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Multiplayer/Movement/Tests/PulseMultiplayerBusRealmFilteringShould.cs:36  CSharpWarnings::CS8618  Non-nullable field 'landscapeData' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Multiplayer/Movement/Tests/PulseMultiplayerBusRealmFilteringShould.cs:55  CSharpWarnings::CS8618  Non-nullable field 'movementInbox' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Multiplayer/Movement/Tests/PulseMultiplayerBusRealmFilteringShould.cs:54  CSharpWarnings::CS8618  Non-nullable field 'participantTable' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Multiplayer/Movement/Tests/PulseMultiplayerBusRealmFilteringShould.cs:53  CSharpWarnings::CS8618  Non-nullable field 'peerIdCache' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Multiplayer/Movement/Tests/PulseMultiplayerBusRealmFilteringShould.cs:52  CSharpWarnings::CS8618  Non-nullable field 'pulseService' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Multiplayer/Movement/Tests/PulseMultiplayerBusRealmFilteringShould.cs:58  CSharpWarnings::CS8618  Non-nullable field 'realmData' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Multiplayer/Movement/Tests/PulseMultiplayerBusRealmFilteringShould.cs:57  CSharpWarnings::CS8618  Non-nullable field 'removeIntentions' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Multiplayer/Movement/Tests/PulseMultiplayerBusRealmFilteringShould.cs:63  CSharpWarnings::CS8618  Non-nullable field 'world' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Multiplayer/Movement/Systems/PulseContainer.cs:100  CSharpWarnings::CS8618  Non-nullable property 'ENetTransportOptions' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/Multiplayer/Movement/Systems/PulseContainer.cs:103  CSharpWarnings::CS8618  Non-nullable property 'ReconnectionSettings' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/Multiplayer/Movement/Systems/PulseMultiplayerBus.cs:40  InconsistentNaming  Name 'EmoteStateMismatchCount' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'emoteStateMismatchCount'.
Assets/DCL/Multiplayer/Movement/Tests/PulseMultiplayerBusRealmFilteringShould.cs:38  InconsistentNaming  Name 'LandscapeData' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'landscapeData'.
Assets/DCL/Multiplayer/Movement/Systems/PulseMultiplayerBus.cs:38  InconsistentNaming  Name 'ResyncCount' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'resyncCount'.
Assets/DCL/Multiplayer/Movement/Systems/PulseContainer.cs:73  ParameterHidesMember  Parameter 'settings' hides property 'DCL.PluginSystem.DCLContainer<TSettings>.settings'
Assets/DCL/Multiplayer/Connections/Demo/LocalSceneDevelopmentPlayground.cs:20  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/Multiplayer/Movement/Systems/MultiplayerContainer.cs:25  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/Multiplayer/Connections/Demo/LocalSceneDevelopmentPlayground.cs:30  UnusedMember.Local  Method 'Start' is never used

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped
EditMode ✅ Passed 25528 0 13
PlayMode ✅ Passed 248 0 37

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

PR Review — #9857 feat: carry local scene development player state over pulse

STEP 2 — Root-cause check: PASS

This PR adds a new capability — carrying LSD player state over Pulse alongside the existing LiveKit scene-room transport. It is a feature addition, not a workaround. The dual-transport design is explicit: Pulse handles player state (movement, emotes, teleports, profile announcements); LiveKit keeps carrying Scene Messages (SDK MessageBus, CRDT sync). The de-duplication in LiveKitMessagesBroadcaster self-suppresses the LiveKit player-state path when Pulse is active, verified by reading the wiring.

STEP 3 — Design & integration: PASS

Owner search — IPulseRealm (new interface, 2 implementations):
The realm string is a configuration value, not a lifecycle entity. Before this PR, PulseMultiplayerBus read IRealmData.RealmName directly at 11 sites. The new IPulseRealm seam wraps that with:

  • RealmDataPulseRealm — pure passthrough to IRealmData.RealmName (reads live). Behavior-identical to the old direct read outside LSD.
  • LocalSceneDevelopmentPulseRealm — resolves once from the dev server, caches the derived key.

Both are constructed in DynamicWorldContainer (lines 253–255), passed through MultiplayerContainer.CreateAsyncPulseContainerPulseMultiplayerBus. The bus is the consumer, not the lifecycle owner. The realm is resolved in StartPulseMultiplayerStartupOperation before the bus sends its first message. No existing owner is bypassed — IRealmData remains the source of truth (via RealmDataPulseRealm), and LSD had no prior realm concept to duplicate.

Owner search — LocalSceneEntityIdSource (new class, 1 implementation + test mock):
Extracts the dev-server two-step fetch (GET scene.jsonPOST content/entities/active) that was inline in LocalSceneDevelopmentSceneRoomMetaDataSource. Two production consumers: CommsContainer.Create (line 123, for LiveKit gatekeeper room) and DynamicWorldContainer (line 254, for Pulse realm). Each creates its own instance — correct, since they're used at different times in different containers, and the entity id is stable (path-based, not content-based).

Teardown trace:

  • SHA256.Create() in RealmKeyFor()using var sha256 (line 87). Properly disposed.
  • No subscriptions, events, connections, or IDisposable resources opened by any new type.
  • LocalSceneEntityIdSource uses IWebRequestController for HTTP — no persistent handles to track.

STEP 4 — Member audit

Member Consumers Verdict
IPulseRealm.Value PulseMultiplayerBus (7 sites), StartPulseMultiplayerStartupOperation (1) Multiple consumers, justified
IPulseRealm.EnsureResolvedAsync StartPulseMultiplayerStartupOperation (1) Single caller, but 2 implementations with distinct behavior (resolve vs no-op) — strategy pattern, not single-use
ILocalSceneEntityIdSource.EntityAsync LocalSceneDevelopmentSceneRoomMetaDataSource (1), LocalSceneDevelopmentPulseRealm (1) 2 consumers, justified extraction
LocalSceneEntity.Id / .BaseParcel 2 / 1 consumers Data carrier struct fields — BaseParcel has 1 consumer but is a natural property of the entity, not a derived predicate
MultiplayerContainer.PulseRealm DynamicWorldContainerInitializationFlowContainer (1) DI forwarding field, correct

No single-use-merge, absent≠false, re-derive, or redundant-guard issues.

STEP 5 — Line-level findings

One P2 finding — see inline comment below.

STEP 6 — Complexity: COMPLEX

Touches Pulse multiplayer transport wiring, async startup realm resolution, cross-repo realm-key contract, feature flag gating, and 32 files with non-trivial logic changes.

STEP 7 — QA: YES

Runtime multiplayer behavior changes (Pulse enabled in LSD, dual-transport, realm isolation). Affects player-visible behavior (avatar movement sync). Requires multi-client manual testing per the PR's test plan (scenarios A–D).

STEP 8 — Non-blocking warnings

None. Main.unity not in changed files.

Security review

  • Secrets/credentials: No hardcoded secrets. The realm key is derived from a path + machine id (already sent to the gatekeeper today). SHA256 hashing is correctly implemented with culture-independent lowercase hex.
  • Input validation: The realm parameter originates from --realm program argument. URLs constructed from it are used for local HTTP calls to the dev server. The same pattern exists in the pre-existing code that was refactored. No new attack surface.
  • SSRF: The local dev server URLs (scene.json, content/entities/active) are the same endpoints the gatekeeper already fetches. No new exposure.
  • Auth/authz: No auth changes. Pulse uses the same org endpoint as non-LSD sessions.
  • Hash correctness: SHA256.Create()ComputeHash(Encoding.UTF8.GetBytes(...)) → lowercase hex. The hex encoding spells digits out ("0123456789abcdef") rather than using culture-dependent ToString("x2"). Correct and byte-identical to the js-sdk-toolchain implementation.

No security issues found.

Summary

This is a well-structured dual-transport change. The IPulseRealm abstraction is a clean seam — outside LSD it's a pure passthrough with no behavior change; inside LSD it resolves the realm from the dev server once and caches it. The cross-repo realm-key contract is pinned by test vectors matching the js-sdk-toolchain documentation. The startup gating (resolve → check empty → fallback to LiveKit) correctly prevents connecting with an empty realm, which would violate the server contract. Test coverage is thorough: 39 EditMode tests covering both boundary conditions, both sides of the 255-character limit, resolve-once semantics, error paths, and the feature flag matrix.

The feature flag change (localSceneDevelopment || featureFlags.IsEnabled(...)) correctly defaults Pulse on in LSD while preserving --pulse false as the opt-out, and does not affect non-LSD behavior.

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Pulse multiplayer transport wiring, async startup realm resolution, cross-repo realm-key contract, feature flag gating changes
QA_REQUIRED: YES


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


string? id = result[0].id;

if (string.IsNullOrWhiteSpace(id!))

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] Null-forgiving operator defeats flow narrowing (CLAUDE.md: "Flag any null-forgiving operator (!) that lacks a justifying comment")

string.IsNullOrWhiteSpace accepts string? and carries [NotNullWhen(false)], so the compiler can narrow id to non-null on the happy path — but id! suppresses that analysis. Remove the ! to let flow narrowing work correctly.

(Pre-existing code moved verbatim from the old LocalSceneDevelopmentSceneRoomMetaDataSource — good opportunity to clean it up in the new file.)

Suggested change
if (string.IsNullOrWhiteSpace(id!))
if (string.IsNullOrWhiteSpace(id))

await webRequestController.GetAsync(
new CommonArguments(sceneDefinitionEndpoint),
ct,
ReportCategory.LIVEKIT,

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] ReportCategory.LIVEKIT is now misleading for a shared source. This class was extracted so both LiveKit (gatekeeper room) and Pulse (realm resolution) share one fetch. When the fetch fails while resolving the Pulse realm, the exception still gets tagged LIVEKIT, which could send someone triaging a Pulse connectivity issue down the wrong path.

Consider switching to ReportCategory.MULTIPLAYER (the category LocalSceneDevelopmentPulseRealm already uses), or accepting a ReportCategory parameter from the caller so each consumer tags with its own category.

Suggested change
ReportCategory.LIVEKIT,
ReportCategory.MULTIPLAYER,

@decentraland-bot

This comment has been minimized.

IPulseRealm had exactly two implementations that differed only in where the string
came from, and neither was polymorphic anywhere else. Merged them into a single
concrete PulseRealm: the optional ILocalSceneEntityIdSource constructor argument is
the mode, so the class reads as "follow the realm, unless a local dev server tells
us otherwise" instead of spreading that decision across an interface and two files.

Removes IPulseRealm.cs, RealmDataPulseRealm.cs and LocalSceneDevelopmentPulseRealm.cs;
adds PulseRealm.cs. Behaviour is unchanged — the derivation, the resolve-once caching,
the Genesis-bounds warning and the empty-realm contract are carried over verbatim.

ILocalSceneEntityIdSource stays: it has two production consumers (the gatekeeper
metadata source and this class), and it is the seam that lets the whole local
scene development path — resolve, dev-server-unreachable, fetch-throws — be tested
without standing up HTTP.

Tests merged into PulseRealmShould, which now also covers the passthrough mode that
RealmDataPulseRealm had only implicitly: 41 pass, up from 39.

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.

PR Review: feat: carry local scene development player state over pulse


STEP 2 — Root-cause check: PASS

This PR implements a new feature — carrying local scene development (LSD) player state over Pulse while keeping Scene Messages on LiveKit. It is not patching a symptom; it is adding a dual-transport capability with a well-defined realm-key contract.


STEP 3 — Design & integration: PASS

PulseRealm — New class centralizing every realm read/write in PulseMultiplayerBus (11 sites across 4 partial files). The optional ILocalSceneEntityIdSource constructor arg is the mode selector: null → live passthrough to IRealmData.RealmName, supplied → derived LSD key resolved once. This avoids littering PulseMultiplayerBus with conditional LSD logic.

Owner search: The lifecycle owner for realm data is IRealmData, which PulseMultiplayerBus previously accessed directly. PulseRealm wraps that access to add the LSD derivation, a valid indirection since IRealmData has no concept of LSD and shouldn't. The wrapping class does not duplicate any creation/destruction lifecycle — it sits alongside PulseActivation at the same construction level (DynamicWorldContainerMultiplayerContainerPulseContainer).

LocalSceneEntityIdSource — Extracted verbatim from LocalSceneDevelopmentSceneRoomMetaDataSource, which now delegates to it. Two consumers (LocalSceneDevelopmentSceneRoomMetaDataSource for LiveKit gatekeeper, PulseRealm for Pulse) independently instantiate it. Both hit the same localhost dev server for the same stable entity ID — the duplicate fetch is negligible for a local endpoint, and sharing the instance would require threading it through container hierarchies that are otherwise independent. This is a pragmatic split that eliminates code duplication at the definition level.

ILocalSceneEntityIdSource interface with one implementation — Justified by test mocking: PulseRealmShould and StartPulseMultiplayerStartupOperationShould both substitute it via NSubstitute.

Teardown/consumption trace:

  • PulseRealm holds no subscriptions, event handlers, connections, or disposable resources. The SHA256.Create() in RealmKeyFor is wrapped in using.
  • LocalSceneEntityIdSource is stateless with no subscriptions.
  • StartPulseMultiplayerStartupOperation adds no new subscriptions — it only awaits EnsureResolvedAsync and ConnectAsync.
  • No leaks.

STEP 4 — Member audit: PASS

Member Consumers Verdict
PulseRealm.Value 11 sites in PulseMultiplayerBus (handshake, PlayerJoined filter, teleport broadcast/receive, wallet lookup, peer purge), plus StartPulseMultiplayerStartupOperation empty-check Multi-consumer — justified
PulseRealm.EnsureResolvedAsync StartPulseMultiplayerStartupOperation Single consumer, but a lifecycle entry point (resolve-before-connect), not a derived predicate — legitimate
PulseRealm.RealmKeyFor EnsureResolvedAsync (production), PulseRealmShould (tests, 4 vectors) internal static utility, test-pinned — justified
LocalSceneEntity.Id LocalSceneDevelopmentSceneRoomMetaDataSource, PulseRealm Two consumers
LocalSceneEntity.BaseParcel PulseRealm.WarnIfOutsideGenesisBounds Single consumer, but the struct represents domain data, not a single-use predicate
MultiplayerContainer.PulseRealm DynamicWorldContainer (passes to InitializationFlowContainer) Forwarding field — legitimate container exposure

STEP 5 — Line-level findings

One P2 finding (see inline comment).

Security review: Clean. No secrets committed, no injection vectors, no auth/authz changes. The base64-encoded path in the realm key is the same information already sent to the gatekeeper — not a new exposure vector. SHA256 derivation is deterministic and platform-independent.


STEP 6 — Complexity: COMPLEX

Touches networking/multiplayer sync (Pulse transport), modifies dependency injection wiring (PulseContainer, MultiplayerContainer, DynamicWorldContainer, InitializationFlowContainer), introduces new shared interfaces, and changes the feature-flag gate. 28 files changed.


STEP 7 — QA assessment: YES

Runtime multiplayer behavior changes — player-state traffic now routes through Pulse in LSD mode. The dual-transport coexistence, realm isolation, and fallback paths need manual verification with running Explorers and authenticated wallets.


STEP 8 — Non-blocking warnings

None. Main scene not modified.


STEP 9 — Verdict

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies Pulse multiplayer transport, feature-flag gating, DI wiring, and cross-repo realm-key contract for local scene development.
QA_REQUIRED: YES


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


string? id = result[0].id;

if (string.IsNullOrWhiteSpace(id!))

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] Null-forgiving ! without justifying comment (CLAUDE.md § Nullable Reference Types). id is declared string? and string.IsNullOrWhiteSpace accepts nullable — the ! should either be removed or annotated with why it is needed in this BCL version.

Suggested change
if (string.IsNullOrWhiteSpace(id!))
if (string.IsNullOrWhiteSpace(id))

If this triggers CS8604 in Unity's BCL version, add a comment instead:
// ! suppresses CS8604 — IsNullOrWhiteSpace handles null despite the BCL annotation.

@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9857, run #33390466695

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.

Intel Core i5

Metric Baseline Change Δ Result
Samples 2392 (×3) 2365 (×3)
CPU average 37.3 ms (36.2–38.0) 37.8 ms (37.4–38.3) 0.4 ms ⚪ within noise
CPU 1% worst 330.1 ms (311.7–333.4) 320.2 ms (296.2–340.5) -9.9 ms ⚪ within noise
CPU 0.1% worst 340.6 ms (326.0–369.4) 345.4 ms (310.7–400.6) 4.9 ms — informational
GPU average 23.1 ms (22.9–23.6) 23.4 ms (23.1–24.1) 0.2 ms ⚪ within noise
GPU 1% worst 316.3 ms (310.7–337.6) 325.6 ms (302.4–346.1) 9.2 ms ⚪ within noise
GPU 0.1% worst 350.1 ms (328.8–374.8) 354.0 ms (320.2–408.6) 3.9 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

Apple M1

Metric Baseline Change Δ Result
Samples 3361 (×3) 3335 (×3)
CPU average 26.7 ms (26.4–27.7) 26.8 ms (26.4–26.9) 0.2 ms ⚪ within noise
CPU 1% worst 228.8 ms (223.9–231.8) 224.6 ms (224.2–224.9) -4.2 ms ⚪ within noise
CPU 0.1% worst 234.6 ms (234.1–237.3) 234.3 ms (233.4–236.6) -0.3 ms — informational
GPU average 20.6 ms (18.5–21.1) 19.2 ms (17.8–20.7) -1.4 ms ⚪ within noise
GPU 1% worst 52.1 ms (49.3–54.5) 55.5 ms (51.8–56.2) 3.4 ms ⚪ within noise
GPU 0.1% worst 57.9 ms (56.5–58.1) 59.5 ms (56.4–64.8) 1.6 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

@mikhail-dcl

Copy link
Copy Markdown
Collaborator Author

Tested end to end against bevy on zone Pulse ✅

The description's Test Instructions and evidence sections have been rewritten — flagging it here since
editing the description doesn't notify anyone.

This now has a real cross-implementation run, using
robtfm/lsd-zone-scene (auth server pinned to the
js-sdk-toolchain#1565 build, which
presets PULSE_SERVER=pulse-server.decentraland.zone:7777) with one Unity client from this branch.

1. The realm key matched byte-for-byte. bevy's Rust derivation and this branch's C#
PulseRealm.RealmKeyFor independently produced the same string:

pulse: local scene development realm resolved to lsd:b64-RTpcRGVjZW50cmFsYW5kXGxzZC16b25lLXNjZW5lLVJldm9sdXRpb24=   ← bevy server
[MULTIPLAYER]: Local scene development Pulse realm resolved to 'lsd:b64-RTpcRGVjZW50cmFsYW5kXGxzZC16b25lLXNjZW5lLVJldm9sdXRpb24='   ← unity client

That's the contract's one silent failure mode, now checked against an actual second implementation
instead of only against published vectors.

2. Client state reached the auth server over Pulse, and tracked movement. The server roster went
from empty to the client's address with a position that followed the avatar:

LSD|server|enter|0x…
LSD|server|roster|0x…@0.0,0.1,0.0
LSD|server|roster|0x…@1.3,0.1,3.7     ← walked
LSD|server|roster|0x…@8.7,0.1,4.7     ← walked again

Avatar state rides Pulse exclusively, so a position in the server's roster proves the full
client → Pulse → scene-listener → server loop. It also transitively proves the client reached
zone, not org — the server is on zone and wouldn't see an org-connected peer.


One stale claim removed

Authoritative-server previews are no longer outside the Pulse story. bevy-headless gained a Pulse
transport in server mode (bevy-explorer #1137,
the @next engine — @latest predates it), superseding the old "not covered, see
decentraland/sdk-multiplayer-server#132" note. The evidence above is that scenario working.

Two things QA must not miss

  • --dclenv zone is mandatory for this scene, and omitting it fails silently: the client
    derives the correct realm key, logs nothing unusual, and simply never sees anyone — because the Pulse
    endpoint is pulse-server.{BaseDomain}, so it would sit on org while the scene's server is on zone.
  • Don't use the client log as the pass criterion. Local scene development Pulse realm resolved to … is a ReportHub.Log (info) call, and info is disabled in player builds by the severity matrix —
    it will not appear in a build and its absence means nothing. Validate the server log, which is a
    separate process and unaffected by the client's matrix.

Still open

Two-client mutual visibility (and the absence of duplicate/double-moving avatars), the cross-process
isolation run, the cube / Scene Messages check, and all of it in a player build rather than the Editor.
Scenarios B–F in the description cover these.

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

PR Review: feat: carry local scene development player state over pulse

STEP 1 — Context

Loaded: CLAUDE.md, docs/README.md, subsystem docs (docs/pulse.md, docs/multiplayer.md, docs/livekit-networking.md). Changed files span: FeatureFlags, Infrastructure/Dynamic containers (5 files), Multiplayer/Connections (Pulse + GateKeeper), Multiplayer/Movement (PulseMultiplayerBus across 4 partial files), UserInAppInitializationFlow, and docs (4 files). Full surrounding files read from the PR head commit.

STEP 2 — Root-cause check: PASS

This is a feature addition — Pulse transport for LSD — not a bug fix. The design addresses the core problem: LSD needs per-process realm isolation for Pulse, because Pulse has no rooms and partitions visibility by exact realm-string match. The solution derives a key from the preview entity id (lsd: prefix, SHA256 overflow form), which is stable across content edits and restarts. The feature flag inversion (localSceneDevelopment || featureFlags.IsEnabled(...)) correctly defaults Pulse ON in LSD where remote flags can never be resolved. No symptom-patching detected.

STEP 3 — Design & integration: PASS

New units introduced:

  • PulseRealm — not a lifecycle manager; a data source combining pass-through of IRealmData.RealmName (normal mode) with a once-derived key (LSD mode). Constructed once in DynamicWorldContainer, injected into PulseContainerPulseMultiplayerBus and InitializationFlowContainerStartPulseMultiplayerStartupOperation. No frame-by-frame reconciliation; the realm is read live via a property.
  • LocalSceneEntityIdSource — extracts the two-step dev-server fetch (GET scene.jsonPOST content/entities/active) verbatim from LocalSceneDevelopmentSceneRoomMetaDataSource, which now delegates to it. One class definition of "the local scene's entity id" instead of two.
  • LocalSceneEntity struct — a simple immutable value type carrying Id + BaseParcel.
  • ILocalSceneEntityIdSource interface — has one production implementation but is mocked via NSubstitute in PulseRealmShould (8 tests) and StartPulseMultiplayerStartupOperationShould (2 new tests). Justified per CLAUDE.md anti-pattern #4 exception.

Owner search: The realm was previously read via IRealmData.RealmName directly by PulseMultiplayerBus (7 sites). The new PulseRealm wraps that same live read and adds the LSD-specific derivation. Outside LSD, Value is a pure passthrough with no behaviour change. No lifecycle duplication, no per-frame scanning, no polling.

Teardown trace: PulseRealm holds no subscriptions, event hookups, connections, or disposable state. LocalSceneEntityIdSource holds no disposable state. No leak risk.

STEP 4 — Member audit

Member Consumers Status
PulseRealm.Value 11 in PulseMultiplayerBus (4 partial files) + 1 in StartPulseMultiplayerStartupOperation + tests Not single-use
PulseRealm.EnsureResolvedAsync 1 call site (StartPulseMultiplayerStartupOperation) Resolve-once by design; guard localSceneRealm.Length > 0 makes subsequent calls no-ops
LocalSceneEntity.Id, .BaseParcel Consumed at their creation sites Clean value type
LocalSceneEntityIdSource.EntityAsync 2 consumers: LocalSceneDevelopmentSceneRoomMetaDataSource.MetaDataAsync + PulseRealm.EnsureResolvedAsync Not single-use

No single-use-merge, absent≠false, or redundant guard issues.

STEP 5 — Line-level review

See inline comments. Two P2 findings:

  1. [P2] Stale doc claimdocs/how-to-connect-to-a-local-scene.md:108 says auth-server previews are NOT on Pulse, contradicted by this PR's own test evidence and body correction.
  2. [P2] Redundant LocalSceneEntityIdSource constructionDynamicWorldContainer.cs:255 and CommsContainer.cs:123 each create an independent instance with identical arguments; both will make separate HTTP calls to the same dev server at startup.

Thread safety note (not a finding): PulseRealm.localSceneRealm is written once in EnsureResolvedAsync (before ConnectAsync) and read from Value on multiple threads. String reference assignment is atomic in .NET, and the resolve-before-connect ordering establishes a happens-before relationship. Functionally safe.

Security review: No secrets exposed, no injection risks, no auth/authz issues. The non-overflow realm key transmits base64-encoded filesystem paths and hostnames to the remote Pulse server — acknowledged in docs/pulse.md § Caveats (Privacy). The SHA256 fallback covers longer paths; shorter paths mirror information already sent to the gatekeeper. No action required.

STEP 6 — Complexity: COMPLEX

Touches Pulse transport realm filtering (PulseMultiplayerBus across 4 partial files), feature flag gating, 5 container wiring files, startup operation flow, and a cross-repo realm-key contract.

STEP 7 — QA: YES

Runtime multiplayer changes affecting player-visible behavior (avatar movement sync over Pulse in LSD, dual-transport wiring, feature flag default change). Scenarios B–F in the PR description require manual testing.

STEP 8 — Non-blocking warnings

None. Main.unity not modified.

STEP 9 — Verdict

Clean, well-tested feature addition. 41 tests covering realm key derivation (including cross-repo pinned vectors), boundary conditions (255-char limit), startup fallback, and feature flag semantics. The refactoring eliminates a code duplication risk between the gatekeeper and Pulse transports. Documentation is thorough. Two minor P2 findings — no blockers.

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Touches Pulse transport realm filtering (4 partial files), feature flag gating, 5 container wiring files, startup operation flow, and a cross-repo realm-key contract.
QA_REQUIRED: YES


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

Comment on lines +108 to +110
- **Authoritative-server previews are not on Pulse yet.** A bevy-headless server has no Pulse
transport in server mode (decentraland/sdk-multiplayer-server#132); that scenario still runs over
LiveKit.

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 doc claim. The PR body explicitly corrects this — bevy-headless gained a Pulse transport in server mode (bevy-explorer #1137, the @next engine), and the end-to-end test above proves it. This bullet contradicts the PR's own evidence.

Suggested change
- **Authoritative-server previews are not on Pulse yet.** A bevy-headless server has no Pulse
transport in server mode (decentraland/sdk-multiplayer-server#132); that scenario still runs over
LiveKit.
- **Authoritative-server previews.** bevy-headless gained a Pulse transport in server mode
([bevy-explorer #1137](https://github.com/decentraland/bevy-explorer/pull/1137), the `@next`
engine; `@latest` predates it), so authoritative-server scenes connect to Pulse alongside LiveKit.

Comment on lines +251 to +256
// Pulse partitions visibility by exact realm string. Local scene development has no realm of its own,
// so each dev process derives one from the entity id its dev server serves, keeping concurrent previews apart.
var pulseRealm = new PulseRealm(staticContainer.RealmData,
localSceneDevelopment
? new LocalSceneEntityIdSource(staticContainer.WebRequestsContainer.WebRequestController, dynamicWorldParams.LocalSceneDevelopmentRealm)
: null);

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] Redundant LocalSceneEntityIdSource construction. A second instance with identical arguments is created in CommsContainer.Create (line 123) for the gatekeeper metadata source. Both will independently HTTP-fetch the same dev-server endpoints (scene.json + content/entities/active) at startup. Consider creating the source once here and passing it into CommsContainer.Create (replacing the string localSceneDevelopmentRealm parameter with the pre-built ILocalSceneEntityIdSource?), so both transports share one instance. If caching the result in EntityAsync is added later, this also avoids a redundant network round-trip.

Suggested change
// Pulse partitions visibility by exact realm string. Local scene development has no realm of its own,
// so each dev process derives one from the entity id its dev server serves, keeping concurrent previews apart.
var pulseRealm = new PulseRealm(staticContainer.RealmData,
localSceneDevelopment
? new LocalSceneEntityIdSource(staticContainer.WebRequestsContainer.WebRequestController, dynamicWorldParams.LocalSceneDevelopmentRealm)
: null);
// Pulse partitions visibility by exact realm string. Local scene development has no realm of its own,
// so each dev process derives one from the entity id its dev server serves, keeping concurrent previews apart.
// NOTE: CommsContainer.Create (line 123) also constructs a LocalSceneEntityIdSource with the same arguments
// for the gatekeeper metadata source. Consider sharing a single instance to avoid redundant HTTP fetches.
var pulseRealm = new PulseRealm(staticContainer.RealmData,
localSceneDevelopment
? new LocalSceneEntityIdSource(staticContainer.WebRequestsContainer.WebRequestController, dynamicWorldParams.LocalSceneDevelopmentRealm)
: null);

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