Skip to content

feat: Pulse transport (ENet avatar-state relay) + Multiplayer Debug panel#2541

Draft
kuruk-mm wants to merge 11 commits into
mainfrom
feat/pulse-transport
Draft

feat: Pulse transport (ENet avatar-state relay) + Multiplayer Debug panel#2541
kuruk-mm wants to merge 11 commits into
mainfrom
feat/pulse-transport

Conversation

@kuruk-mm

@kuruk-mm kuruk-mm commented Jul 14, 2026

Copy link
Copy Markdown
Member

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_enet comes from the DCL-org mirror dcl-regenesislabs/rusty_enet (tree-identical to robtfm's decentraland-pulse branch — the ENet-CSharp "modified protocol" the server speaks; stock ENet is wire-incompatible). The @dcl/protocol pin 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:

  • Multiplayer Debug panel (renamed from the LiveKit debug panel) showing the full comms state: adapter + connection phase, main room type/state, Archipelago state + island id, scene room state, Pulse state @ endpoint / failure count / dual-channel, and per-peer room membership.
  • Real LiveKit connection-state tracking (connecting / connected / reconnecting / disconnected — reconnect events were previously swallowed).
  • Avatar nametag debug labels now list rooms explicitly: PULSE + SCENE + ARCHIPELAGO (* on PULSE = Pulse is driving that avatar).
  • 5-strike session fallback: five failed connection attempts park Pulse for the session, LiveKit-only (Unity parity).

How to test

Everything is driven by deeplinks — whatever comes after the ? in https://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

https://mobile.dclexplorer.com/?pulse=true&multiplayer_debug=true

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 ON
  • Peers: N with rows like 0x1e93..452b PULSE* + SCENE + ARCHIPELAGO — the * means that avatar is being driven by Pulse (its LiveKit movement is being discarded).
  • Avatars near you show the same room labels above their nametags.

The panel can also be toggled in Settings → Advanced → Multiplayer Debug (the old livekit_debug=true deeplink still works as an alias).

2. Test against the .zone deployment

Everything on .zone (content, comms and Pulse):

https://mobile.dclexplorer.com/?dclenv=zone&pulse=true&multiplayer_debug=true

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:

https://mobile.dclexplorer.com/?dclenv=comms::zone,org&pulse=true&multiplayer_debug=true

Note: Pulse follows the comms env group — there is no separate pulse:: group. Pulse has .org/.zone deployments only (today resolves to .zone).

3. Join a specific Pulse server (shareable)

https://mobile.dclexplorer.com/?pulse-server=<host:port>&multiplayer_debug=true

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:

https://mobile.dclexplorer.com/?pulse=true&dual-channel=false&multiplayer_debug=true

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)

https://mobile.dclexplorer.com/?pulse=true&livekit=false&multiplayer_debug=true

Dev/testing switch (--no-livekit on 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 orange LIVEKIT DISABLED (pulse-only mode) banner, livekit=true re-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 cycles down → connecting → …, fails climbs, and after 5 never-established attempts shows disabled 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)

  • Their avatars move smoothly (~10 Hz + interpolation), teleports snap (no lerp streaks), emotes start and stop.
  • Each remote avatar is driven by exactly one source (no jitter from double-apply) — check the * in the peer rows.
  • Killing Pulse mid-session flips peers back to LiveKit within a frame — movement continues.
  • Rotation: a stationary Godot avatar faces the same way on Unity for every heading (the Pulse quantizer clamps [0, 360] without wrapping — signed yaw used to collapse half the compass to 0°; Unity masks this while moving because it derives facing from velocity).
  • Emotes: first-time emotes (not yet cached/loaded) propagate, and re-triggering after cancelling by movement works. EmoteStart is now sent when playback actually starts (not at wheel-press), so it can't be chased by a spurious EmoteStop during the async emote load, and it piggybacks an idle PlayerState (Unity parks remote emote intents while the networked movement blend is > 0.1).
  • No running-in-place: a stationary Unity peer entering your interest set appears standing (joins snap instead of lerping from the avatar's previous target), and a peer that stops moving settles to idle within ~300 ms even though Pulse goes silent (delta protocol) — Godot's locomotion flags used to latch on the last packet.

Follow-ups (not in this PR)

  • M5 mobile: lifecycle (background/foreground), NAT rebind, battery, device testing.
  • M6 polish: position_precision, warm socket, initial_state.
  • Bot-based inbound soak (DCLPulseTestClient) and the full cross-client matrix.

kuruk-mm added 8 commits July 14, 2026 14:52
- 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).
@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

📦 Build Report

🤖 Android

Artifact Status
APK 📱 Download APK
AAB main/release builds only
Debug Symbols main/release builds only

Build Status: ✅ Success

🍏 iOS

The signed APK is uploaded to R2 on every build (link above). Add the build label (alias: build-ios) to build & ship iOS to TestFlight and post a Slack "Android build ready" notification.


🔗 Workflow Run: View logs

🔄 Updated: 2026-07-15 18:57:50 UTC

kuruk-mm added 3 commits July 15, 2026 10:26
- 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.
@kuruk-mm kuruk-mm added the build trigger ci build label Jul 15, 2026
@github-actions github-actions Bot removed the build trigger ci build label Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📱 Mobile Distribution Triggered

🍏 iOS → TestFlight   |   🤖 Android → APK ready (R2) + Slack notification

🔗 Workflow: View run

📍 Branch: feat/pulse-transport

🔄 Triggered: 2026-07-15 18:32:51 UTC

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Android Build Ready

Status: APK available for download

📱 Download APK: direct link

🔗 Workflow: View run

📍 Branch: feat/pulse-transport   |   Commit: ee352a85b430afdb738a83a7e7955f0803bb48eb

🔄 Completed: 2026-07-15 18:58:38 UTC

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.

1 participant