feat: Pulse transport (ENet avatar-state relay) + Multiplayer Debug panel#2541
Draft
kuruk-mm wants to merge 11 commits into
Draft
feat: Pulse transport (ENet avatar-state relay) + Multiplayer Debug panel#2541kuruk-mm wants to merge 11 commits into
kuruk-mm wants to merge 11 commits into
Conversation
- Vendor decentraland.pulse protos + common/options.proto in the git-tracked lib/proto_overlay/ (a second protoc include root) — the npm-managed proto tree is gitignored and wiped by 'cargo run -- install', and the pinned @dcl/protocol (commit-a598406) doesn't ship them yet (protocol PR #429). Source: protocol@45edead4 (feat/pulse-prd), pinned in file headers. - Patch rfc4 PlayerEmote.is_stopping at build time into an OUT_DIR copy (upstream field the npm pin predates; Unity peers already send it) and compile that copy; no-op once the pin catches up. - Port bevy-explorer build_quant.rs: generate {field}_dequantized/_quantized/ _step accessors from the quantized/quantized_power field options via the FileDescriptorSet (prost-reflect 0.11, same pairing bevy uses). - Fix pre-existing --no-default-features build breakage: scene-access/ gatekeeper code used livekit-gated imports (Arc, TokioRuntime, wallet, gatekeeper_url*) — un-gate them. - Unit tests: linear + power-law grids (sign/exact-zero), optional delta accessors, envelope prost round-trip.
Port of bevy-explorer's pulse transport (PR #919 @ 3f65c164), ENet only: - transport.rs: byte-boundary seam (frames, status, server disconnect codes) - native.rs: 'pulse-enet' driver thread — rusty_enet (robtfm fork, ENet-CSharp modified protocol; stock/Godot ENet is wire-incompatible), pinned by rev - decoder.rs: quantized state -> rfc4::Movement reconstruction, parcel grid, seq-gap resync; full bevy test-vector port - pulse_room.rs: connection state machine (Down->Connecting->Idle->Signing-> AwaitingResponse->Established/Dead, 2s cooldown, 5s handshake timeout, pipe-close-authoritative), MessageProcessor bridging with room_id 'pulse', synthetic PeerLeft flood on teardown, outbound API (movement/emote/teleport/ profile-version) with grid-bounds guard - sign_pulse_connect: signed-fetch-shaped connect payload, x-identity dict as protobuf bytes (verbatim, no lowercasing) - CommunicationManager: pulse_room parallel to scene_room, lifecycle via change_adapter/clean, 5-strike session fallback (Unity parity), deferred teleport until valid realm name + cached position - Activation (opt-in): --pulse / --pulse-server / PULSE_SERVER env; urls::pulse_server() org/zone endpoint; use_pulse feature (default on, runtime off) - --no-livekit-movement CLI + dual-channel flag plumbing (wired in M3) 24 unit tests (quant grids, decoder, state machine).
- broadcast_movement: build the uncompressed rfc4 Movement unconditionally (it feeds the Pulse PlayerStateInput), tee to Pulse when established (10Hz/1Hz cadence inherited from broadcast_position.gd, under the 20Hz cap) - livekit_movement_dual_channel (default ON): OFF skips only the island movement sends (main room + archipelago) and only while Pulse is established — LiveKit auto-resumes on Pulse drop, scene-room movement and the archipelago heartbeat are never gated - send_emote/set_emoting -> Pulse EmoteStart/EmoteStop (reliable) - broadcast_profile_version -> Pulse announce (on-change only, deduped in PulseRoom; the 10s LiveKit rebroadcast loop is not teed) - notify_player_teleported #[func] latched via pulse_teleport_pending (coalesces to <=1 teleport/frame vs the server's 20/s discrete-event budget) + explorer.gd move_to hook (scene movePlayerTo funnels through it per handle_restricted_actions.rs:210)
…op + teleport snap (M2/M4) - MessageProcessor: per-peer pulse_live flag; while set, LiveKit Position/Movement/MovementCompressed/PlayerEmote for that peer are discarded (LiveKit sender-clock vs Pulse server-tick timestamps are incomparable — merging starves one source permanently). Both dedup layers reset on every flip; pulse PeerLeft (incl. PulseRoom's teardown flood) hands the peer back to LiveKit within a frame - 'pulse' room exempt from the 5s inactivity sweep: the server stops sending deltas for static distant peers by design; membership (reliable Joined/Left) is the liveness signal - rfc4 PlayerEmote.is_stopping handled before the incremental-id dedup -> AvatarScene::stop_emote -> avatar.gd stop_emote_from_network (also fixes the latent gap for Unity LiveKit peers, which already send it) - Movement.is_instant -> DclAvatar::snap_to_position (teleports snap instead of lerping across the jump) - get_peer_room_info: Pulse/…+Pulse room labels, '*' = pulse-driving - unit test pins the gate's message classification
- Deeplink params (router + desktop fake-deeplink path): decentraland://open?pulse-server=<host:port> joins a specific Pulse server — shareable, implies enabling, clears the 5-strike session fallback, rebuilds the room immediately; pulse=true|false toggles with the configured endpoint. - CommunicationManager::set_pulse_server/set_pulse_enabled runtime #[func]s; room creation defers until an identity exists (a deeplink can land before login — was a 'wallet not initialized' panic). - ServiceGroup::Pulse: dclenv=pulse::zone,org points ONLY Pulse at the zone deployment (dclenv=zone flips it along with everything else); urls::pulse_server() resolves through it, Today -> .zone. Live-verified: fake-deeplink run with no --pulse flag connected, authenticated and teleported on the local server (peers_connected +1, teleport +1 in server metrics).
Rename the LiveKit debug panel to "Multiplayer Debug" everywhere and refactor it into a full comms-state overlay: - Deeplink param is now multiplayer_debug=true (livekit_debug kept as a working legacy alias); settings toggle renamed to "Multiplayer Debug" and now syncs with the actual panel state on menu open. - LiveKit rooms track a real connection state (connecting/connected/ reconnecting/disconnected) via a shared atomic written by the room event loop; Reconnecting/Reconnected events were previously swallowed. - New state getters: WebSocket room handshake state, Archipelago state + island id + island room state, Pulse state machine name + endpoint; Adapter trait gains a connection_state_str() default. - get_debug_room_info() extended (key-stable across feature combos): connection_state, main_room_type/state, archipelago_state, island_id, island_room_state, scene_room_state, pulse_available/enabled/state/ endpoint/failures/disabled_for_session, dual_channel. - Panel shows realm, adapter, main room, archipelago/island, scene room, Pulse line and a per-peer room list (get_debug_peer_rooms, capped at 10 rows); panel widened for the longer rows. - validate_all_scripts now skips embedded Godot projects (the Android build template's instrumented assets broke the sweep after a class cache regeneration). Verified live on Genesis Plaza with the local Pulse server: panel shows island id + connected states and Pulse established @ 127.0.0.1:7777.
- New shareable deeplink param dual-channel=true/false (default true): false makes movement Pulse-only while the Pulse link is established; LiveKit movement auto-resumes if Pulse drops. Wired in both the deeplink router and the desktop fake-deeplink path. - Peer room labels (avatar nametag debug + panel peer rows) now list every room explicitly, e.g. "PULSE + SCENE + ARCHIPELAGO" instead of the ambiguous "Both"/"+Pulse" scheme. The trailing '*' on PULSE still marks the source driving the avatar.
Remove the separate ServiceGroup::Pulse — Pulse is part of the comms stack, so dclenv=comms::zone,org (or plain dclenv=zone) now points the Pulse endpoint at the zone deployment together with the rest of comms. Today still resolves to zone (no .today Pulse deployment).
Contributor
📦 Build Report🤖 Android
Build Status: ✅ Success 🍏 iOS
🔗 Workflow Run: View logs 🔄 Updated: 2026-07-15 18:57:50 UTC |
- rusty_enet now comes from dcl-regenesislabs/rusty_enet @ 8023f9b — the DCL-org mirror of robtfm/rusty_enet branch decentraland-pulse @ 6fa651d (verified tree-identical). - @dcl/protocol pin moved from commit-a598406 to the protocol PR #429 branch build (sdk-team-cdn, commit-45edead), which ships the decentraland/pulse protos, options.proto and rfc4 is_stopping upstream. Repoint at the released npm tarball once the PR merges. - Deleted the interim lib/proto_overlay/ vendoring and the build.rs rfc4 is_stopping patch: all protos now compile from the npm-extracted tree with a single include root. - The newer proto vintage adds Chat.forwarded_from and seven PlayerEmote fields (is_repeating, interaction_id, social emote fields, mask) — initializers updated with ..Default::default() / struct-update spreads so future additive fields don't break the build.
…eplink) New dev/testing switch that disables LiveKit entirely and leaves avatar sync to Pulse alone: - CLI --no-livekit, shareable deeplink livekit=false (livekit=true re-enables live), and Global.comms.set_livekit_enabled(bool). - While disabled, change_adapter skips the main livekit room and the archipelago manager (still creating the shared MessageProcessor for Pulse/avatars), scene rooms are skipped, and a racing async gatekeeper connect result is dropped. Chat, voice and scene messages go with the rooms; ws-room realms are unaffected (not LiveKit). - Runtime disable tears the rooms down; re-enable rebuilds the current adapter and reconnects the scene room. Disabling while Pulse is not established logs a warning (no avatar sync source). - Debug panel shows an orange "LIVEKIT DISABLED (pulse-only mode)" banner (new livekit_enabled key in get_debug_room_info). Live-verified on the local Pulse server: archipelago/scene rooms skipped, zero LiveKit rooms created, Pulse handshake accepted and teleport sent, server counted the peer.
…nim latch) Three cross-client bugs seen testing Godot peers from the Unity client: - Rotation: rfc4 rotation_y was signed degrees, but the Pulse quantizer clamps to [0, 360] (Unity Quantize.Encode parity) — every negative yaw collapsed to wire 0, so half the compass faced one fixed direction on Unity (masked while moving: Unity derives facing from velocity). Wrap with rem_euclid(360) at the rfc4 source and in from_movement, mirroring the existing head-angle handling. - Emotes: Pulse EmoteStart fired at wheel-press time while EmoteStop is derived from the per-frame animation poll — any gap between press and playback (first-use async emote load, idle gate, cooldown) flapped is_emoting true->false and the stop cancelled the start server-side before it played. EmoteStart is now deferred (pending urn) and flushed by set_emoting when playback actually starts, which also attaches an idle PlayerState (Unity parks remote emote intents while the networked movement blend is > 0.1). The explicit EmoteStop is load-bearing for one-shots too: we never send duration_ms, so the server ledger only clears on an explicit stop. - "Running in place": remote locomotion flags (walk/jog/run) derive from the inter-update target distance and latch until the next packet. LiveKit streams ~10 Hz and self-corrects; Pulse goes silent for stationary peers, so a PlayerJoined bridged as a normal movement made the avatar sprint from its previous/default target and keep the run flag forever. Joins now bridge as teleport (snap), and DclAvatar decays the flags to idle after 300 ms without a movement update.
Contributor
📱 Mobile Distribution Triggered🍏 iOS → TestFlight | 🤖 Android → APK ready (R2) + Slack notification 🔗 Workflow: View run 📍 Branch: 🔄 Triggered: 2026-07-15 18:32:51 UTC |
Contributor
🤖 Android Build Ready✅ Status: APK available for download 📱 Download APK: direct link 🔗 Workflow: View run 📍 Branch: 🔄 Completed: 2026-07-15 18:58:38 UTC |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pulse transport (ENet avatar-state relay) + Multiplayer Debug panel
Adds the Pulse transport coexisting with LiveKit. Pulse replaces only the high-frequency avatar-sync slice — movement (quantized/delta-compressed, server-side AoI LOD), emotes, teleports and profile-version announcements — over ENet/UDP (port 7777). Chat, voice, scene messages, scene rooms and Archipelago discovery stay on LiveKit.
Interop contract (matches Unity in prod): outbound is dual-sent to both Pulse and LiveKit; inbound is gated per peer — while a peer is live on Pulse, their LiveKit avatar-sync is discarded (the two clock domains are incomparable, merging starves one source). Everything is opt-in at runtime: without
pulse=true(or--pulse) nothing changes.Dependency provenance:
rusty_enetcomes from the DCL-org mirrordcl-regenesislabs/rusty_enet(tree-identical to robtfm'sdecentraland-pulsebranch — the ENet-CSharp "modified protocol" the server speaks; stock ENet is wire-incompatible). The@dcl/protocolpin points at the protocol PR #429 branch tarball (commit-45edead), which ships the pulse protos upstream — repoint at the released npm tarball once that PR merges.Also included:
PULSE + SCENE + ARCHIPELAGO(*on PULSE = Pulse is driving that avatar).How to test
Everything is driven by deeplinks — whatever comes after the
?inhttps://mobile.dclexplorer.com/is the deeplink (e.g.https://mobile.dclexplorer.com/?position=0,0&scene-stats=true). On desktop the same query string can be passed with--fake-deeplink "decentraland://open?<params>".1. Enable Pulse + the debug panel
The panel opens automatically (top-center overlay). Expect within a few minutes of entering the world:
Pulse: established @ pulse-server.decentraland.org:7777 | fails 0 | dual-ch ONPeers: Nwith rows like0x1e93..452b PULSE* + SCENE + ARCHIPELAGO— the*means that avatar is being driven by Pulse (its LiveKit movement is being discarded).The panel can also be toggled in Settings → Advanced → Multiplayer Debug (the old
livekit_debug=truedeeplink still works as an alias).2. Test against the .zone deployment
Everything on
.zone(content, comms and Pulse):Only the comms stack (gatekeeper + Pulse at
pulse-server.decentraland.zone) on.zone, everything else on prod — useful to interop-test against Unity clients pointed at the zone Pulse:Note: Pulse follows the
commsenv group — there is no separatepulse::group. Pulse has.org/.zonedeployments only (todayresolves to.zone).3. Join a specific Pulse server (shareable)
pulse-server=implies enabling Pulse and overrides the env endpoint — everyone opening the same link lands on the same instance. Visibility is partitioned by realm string (exact match), so testers should also be on the same realm.4. Dual-channel movement flag
Default is ON (movement dual-sends to LiveKit and Pulse). To make movement Pulse-only while the link is established:
The panel shows
dual-ch OFF. A LiveKit-only observer stops seeing your movement while your Pulse link is up, and starts seeing it again automatically if Pulse drops — the flag can never make a player invisible. Scene-room movement and the Archipelago heartbeat are never gated.5. Pulse-only mode (LiveKit fully disabled)
Dev/testing switch (
--no-livekiton desktop): no LiveKit rooms are created at all — chat, voice and scene messages are gone with them; avatar sync runs purely over Pulse. The panel shows an orangeLIVEKIT DISABLED (pulse-only mode)banner,livekit=truere-enables live (rooms rebuild, scene room reconnects). Useful to prove a behavior comes from Pulse and not from the LiveKit dual-send.6. Failure/fallback behavior
Point
pulse-server=at an unreachable host: the panel cyclesdown → connecting → …,failsclimbs, and after 5 never-established attempts showsdisabled for session(red) — avatars keep working via LiveKit throughout. A working setup that later loses the server retries every ~2s indefinitely (the path proved itself once).What to look for cross-client (vs Unity on .zone)
*in the peer rows.EmoteStartis now sent when playback actually starts (not at wheel-press), so it can't be chased by a spuriousEmoteStopduring the async emote load, and it piggybacks an idlePlayerState(Unity parks remote emote intents while the networked movement blend is > 0.1).Follow-ups (not in this PR)
position_precision, warm socket,initial_state.