Skip to content

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

Merged
kuruk-mm merged 33 commits into
mainfrom
feat/pulse-transport
Aug 4, 2026
Merged

feat: Pulse transport (ENet avatar-state relay) + Multiplayer Debug panel#2541
kuruk-mm merged 33 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). Pulse is ON by default (as are dual-channel movement and LiveKit itself); disable it per run with --no-pulse or at runtime with a pulse=false deeplink. The 5-strike session fallback still parks Pulse automatically when the server never answers, so a blocked UDP path degrades silently to LiveKit-only.

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 #449 branch tarball (commit-fdfb857), which tracks protocol main (incl. PR #426 TouchscreenInputControls/UiInputBinding that godot main needs) and ships the pulse protos — repoint at the released npm tarball once that PR merges. The experimental-only rfc4 fields (PlayerEmote.is_stopping & co., Chat.forwarded_from) are not in that build; lib/build.rs re-adds them via a build-time patch that no-ops once upstream ships them.

Also included:

  • Multiplayer Debug panel (renamed from the LiveKit debug panel) showing the full comms state: adapter + connection phase (access tokens elided to 16 chars), main room type/state, Archipelago state + island id, scene room state, Pulse state @ endpoint / failure count / dual-channel, and per-peer room membership with profile names. Collapsible via the ▾ button in its header (the button is the panel's only clickable element — the rest stays click-through).
  • 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).
  • Fix: remote avatars stuck ~5 min on the loading ghost next to Unity peers. Unity publishes lambdasEndpoint without a trailing slash; bare concatenation produced …/lambdasprofiles/0x… (404) for the whole profile retry chain until the registry fallback rescued it. URL building and endpoint comparison now tolerate both slash styles. The peer-advertised endpoint (untrusted LiveKit metadata) is also validated at intake, and a hard failure on it now falls back to the realm endpoint immediately instead of burning the ~2-min retry chain first.

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. See it working (debug panel)

Pulse is on by default — you only need the panel:

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

(pulse=false turns Pulse off at runtime; --no-pulse on desktop.) 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 SomePlayer 0x1e93..452b PULSE* + SCENE + ARCHIPELAGO — the * means that avatar is being driven by Pulse (its LiveKit movement is being discarded). The name appears once the peer's profile resolves.
  • 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&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&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/?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/?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. A peer already mid-emote when you join shows it as soon as their avatar finishes loading — Pulse replays the stored emote announcement at join, and the intent is now latched until the avatar is ready instead of being consumed by the loading ghost. 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-08-04 13:30:29 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

github-actions Bot commented Jul 15, 2026

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-31 18:45:05 UTC

@github-actions

github-actions Bot commented Jul 15, 2026

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: 13960d7fa279951e860922db7a13d2a5731b6dfa

🔄 Completed: 2026-07-31 19:14:53 UTC

kuruk-mm added 4 commits July 21, 2026 15:35
- Panel header gains a collapse/expand button (the only clickable element;
  the rest stays click-through). Body rebuilds pause while collapsed.
- Adapter line elides access_token values to 16 chars + '...'.
- Peer rows show the profile display name next to the short address:
  get_peer_room_info now returns (address, rooms, name) and
  get_debug_peer_rooms exposes it as a 'name' key. Names are bbcode-escaped.
Unity publishes lambdasEndpoint as ".../lambdas" (no trailing slash) in
its LiveKit metadata while Godot publishes ".../lambdas/". The profile
fetch URL was built by bare concatenation, producing
".../lambdasprofiles/0x..." (404) for every attempt against a Unity
peer's endpoint — the full retry chain (6 retries, backoff capped at
60s) burned ~5 minutes before the asset-bundle-registry fallback
rescued the profile, leaving remote avatars on the loading ghost the
whole time.

- build the profiles URL with the base trimmed of trailing slashes
  (request_lambda_profile and the default-profile path)
- compare peer endpoint vs realm endpoint modulo trailing slash so the
  realm's own catalyst isn't mistaken for a different fetch target
The peer-advertised lambdasEndpoint is untrusted LiveKit metadata; when
it hard-fails (bad URL, unreachable catalyst) the retry chain used to
burn all 6 backoff attempts (~2 min) on it before any fallback ran.

- validate the metadata at intake: anything that isn't a plausible
  http(s) URL is discarded, so the fetch sticks to the realm endpoint
- on the first hard error from a peer endpoint, switch the remaining
  retry chain to the realm endpoint and retry immediately; Ok-but-stale
  responses still retry the same endpoint (catalyst propagation lag)
Pulse replays a peer's last emote announcement at join, and LiveKit
emotes can race the profile fetch — either way the emote reached
async_play_emote while the avatar was still the loading ghost, resolved
against the default body shape and got wiped by the profile rebuild, so
a peer already mid-emote appeared idle until they re-triggered it.

Latch the urn when avatar_ready is false and replay it right after the
avatar finishes loading (same pattern as the AvatarShape
pending_expression_trigger); a network stop clears the latch so a
stopped emote isn't ghost-played later.
@kuruk-mm
kuruk-mm force-pushed the feat/pulse-transport branch from 5c3eb73 to d4effc0 Compare July 22, 2026 13:59
@kuruk-mm kuruk-mm added the build trigger ci build label Jul 22, 2026
Conflict resolution notes:
- @dcl/protocol pin: neither side's pin worked alone (ours had pulse protos
  but predated protocol PR #426 that main's UiInputBinding/TouchscreenInput
  components need; main's had #426 but no pulse). Repointed at the protocol
  PR #449 branch build (commit fdfb857) which ships both. That build tracks
  protocol `main`, so the `experimental`-only rfc4 fields our comms code uses
  (PlayerEmote.is_stopping & co., Chat.forwarded_from) are re-added by a
  build-time patch in lib/build.rs (patched_rfc4_comms_proto) that no-ops
  once upstream ships them.
- billboard.rs: comment-only conflict, kept main's wording.
- explorer.gd hit gdlint's 1900-line cap post-merge: panel load() folded into
  a const preload and an else/if fused to elif.
@kuruk-mm kuruk-mm added build trigger ci build and removed build trigger ci build labels Jul 22, 2026
@github-actions github-actions Bot removed the build trigger ci build label Jul 22, 2026
Flips the CLI default (deeplinks inherit it): Pulse now activates without
pulse=true / --pulse. Disable per run with the new --no-pulse flag or at
runtime with a pulse=false deeplink. --pulse stays accepted as a no-op
for older scripts; --pulse-server keeps overriding the endpoint (and the
deeplink form still force-enables + clears the session park).

Dual-channel movement and LiveKit were already on by default — unchanged.
The 5-strike session fallback still parks Pulse when the server never
answers, so blocked-UDP environments degrade silently to LiveKit-only.
@kuruk-mm kuruk-mm added the build trigger ci build label Jul 22, 2026
@github-actions github-actions Bot removed the build trigger ci build label Jul 22, 2026
@Ludmilafantaniella
Ludmilafantaniella self-requested a review July 30, 2026 13:16
@kuruk-mm kuruk-mm added the build trigger ci build label Jul 30, 2026
@github-actions github-actions Bot removed the build trigger ci build label Jul 30, 2026
@Ludmilafantaniella

Copy link
Copy Markdown
Contributor

QA update. Mobile testing (iOS + Android)

Validated:

  • Cross-platform multiplayer sync (Android/iOS/Windows) at Genesis Plaza - Fishing Pod, Flag Tag, Space Runner, Tower of Madness all synced correctly
  • Wearables/emotes/scene emotes - working fine
  • Debug panel on Android shows Pulse connected properly

🔴 Blocking issues found:

  • Android crash on scene teleport - happens ~3/4 attempts, with or without the pulse-server deeplink, sometimes shows "app closed unexpectedly". This is blocking most of the remaining test cases (fallback, dual-channel, etc.)
  • iOS: Multiplayer Debug panel doesn't open - neither via ?multiplayer_debug=true nor a Settings toggle (couldn't find one)
  • livekit=false deeplink shows no visible effect (no orange banner) on my device - not confirmed if this is a bug or device-specific

Not yet tested (need backend coordination or a stable build first): pulse-server unreachable fallback confirmation, dual-channel, kill-the-server, Unity cross-client rotation/emote cases.

Screen_Recording_20260730_163924_Decentraland.mp4

…ecoder edges

Consolidated fixes from two deep reviews of the Pulse transport:

- Pulse resurrection: create_fallback_connection() and release_comms() now
  call ensure_pulse_room() — previously the archipelago kill switch (no
  fixedAdapter) or a pre-comms hold left Pulse permanently uncreated.
- Remote kill switch: new `pulse` feature flag (disable-only, so a server
  flag can never override a local --no-pulse run).
- FeatureFlags fetch failure downgraded printerr→push_warning: fail-open is
  the design, offline cold starts must not burn Sentry error quota.
- Idle decay reshaped to Unity parity (RemotePlayerAnimationSystem): only
  ground locomotion decays on stream silence; rise/fall latch, so a mid-air
  peer no longer pops to a grounded pose on a >300ms packet gap. Also fixes
  the dead lerp clamp so the final step lands exactly on target.
- Decoder: subject-slot rebind to a different wallet now emits a synthetic
  Left for the replaced wallet (its pulse_live gate leaked forever);
  ResyncRequests are deduped per subject while one is in flight.
- Pulse movements skip the f32 timestamp dedup: server_tick/1000 loses ms
  precision past ~12 days of server uptime, silently dropping 10Hz updates
  (the decoder already sequence-orders them).
- Driver prefers IPv4 resolution and binds a matching-family socket
  (NAT64/DNS64 carriers resolve AAAA first → every send failed → Pulse
  parked after 5 strikes).
- Sentry quota: decode-failure and send-failure warns dedupe to once per
  connection; emote-before-movement warn → debug.
- pending_pulse_emote_urn cleared on emote end and room teardown — a stale
  urn from a while-moving press could be announced by the next emote.
- Emote stop-during-load: generation counter so an EmoteStop arriving while
  the GLB downloads cancels the deferred playback (looping emotes played
  forever).
- notify_app_resumed gated to real mobile: desktop alt-tab must not arm the
  30s Duplicate*-eviction grace window.
- Nits: shared _apply_comms_deeplink_params helper (was duplicated in
  global.gd/deep_link_router.gd), BBCode-escape server-derived panel
  strings, drop stale plan_pulse/ doc references.
@kuruk-mm kuruk-mm added the build trigger ci build label Jul 31, 2026
@github-actions github-actions Bot removed the build trigger ci build label Jul 31, 2026
…ump-in for config-only links

A target-less deeplink (e.g. ?multiplayer_debug=true with no realm/position)
broke two ways:

- route() matched "/open" with no target and emitted deep_link_jump, popping
  the jump-in side panel over Discover with no data set — an empty "scene
  information" card. Now the panel only opens for links with no params at
  all (pure navigation); config-only params were already applied in
  process_deep_link and route to nothing.
- The panel never appeared after entering a scene from Discover: the flag's
  only carrier was deep_link_obj, read once in explorer._ready — fragile
  across the lobby → menu → explorer scene changes. The flag is now latched
  on the comms manager at parse time (_apply_comms_deeplink_params,
  enable-only) and explorer re-reads it on boot, plus re-checks on
  deep_link_received for links arriving while already in-world.

The working flow (realm + multiplayer_debug) is unchanged.
@kuruk-mm kuruk-mm added the build trigger ci build label Jul 31, 2026
@github-actions github-actions Bot removed the build trigger ci build label Jul 31, 2026
kuruk-mm added 2 commits July 31, 2026 13:41
… UAF panics on teleport/re-entry

QA's Android "crash on scene teleport" (~3/4 repro on a Galaxy A35): killing
the Explorer (sign-out / return-to-discover / realm change) frees base_ui,
the current scene's UI root and the Player while the SceneManager autoload
still holds Gd handles; cache-hot scene spawns racing the next
explorer._ready() then clone the freed nodes. The panics are caught on fast
devices but escalate to native crash fallout on slow/low-RAM ones.

Deterministic repro: return_to_discover() -> jump back in within 1-3s.
3 UAF panic events per cycle before, 0 after (verified on device, 5 cycles).

- start_scene(): recreate base_ui eagerly when the Explorer freed it
- scene_fetcher: skip+requeue spawns while there is no Explorer
- parcel-change handler: validity-guard UI re-parenting, only re-attach
  Alive scenes' UI roots
- update_scene_ui(): skip scenes whose UI root is already freed
- sign_out()/return_to_discover(): detach base_ui before
  change_scene_to_file so freeing the Explorer can't free Rust-held UI
- player_avatar_node/player_body_node: Option<Gd> with validity-filtered
  property getters — the plain #[var] getter panicked before any GDScript
  is_instance_valid() could run; freed now reads as null
- player_collider_filter: null-parent guard on tree_changed during teardown

Not pulse-specific: the same crash family ships in prod 1.11.1
(Sentry GODOT-EXPLORER-1DY, 3k+ events / 186 users, regressed).

Fixes GODOT-EXPLORER-1DY
Fixes GODOT-EXPLORER-1E0
…shrinks to header

- The deep_link_jump handlers (menu + explorer) opened the Discover jump-in
  side panel unconditionally; with no place loaded it rendered the default
  "Scene Title" card at 0,0. Guard on item_data being set — a deeplink with
  no navigation target now lands on Discover itself. Defense in depth on
  top of 9d03422's router guard (mobile.dclexplorer.com links convert to
  decentraland://open?<params>, so config-only links hit this path).
- Multiplayer debug panel: a Control never shrinks below its grown size, so
  collapsing kept the full-height background. reset_size after the collapse
  toggle and after each content refresh.
@kuruk-mm kuruk-mm added the build trigger ci build label Jul 31, 2026
@github-actions github-actions Bot removed the build trigger ci build label Jul 31, 2026
reset_size() collapsed the authored 440px width to the collapse button's
width because both RichTextLabels autowrap (minimum width ~0). Snap only
the height to the content minimum instead.
@kuruk-mm kuruk-mm added the build trigger ci build label Jul 31, 2026
@github-actions github-actions Bot removed the build trigger ci build label Jul 31, 2026
36x22 @ 12pt was too small a touch target on phones — now 72x44 @ 24pt.
@kuruk-mm kuruk-mm added the build trigger ci build label Jul 31, 2026
@github-actions github-actions Bot removed the build trigger ci build label Jul 31, 2026
…6038)

Protocol PR #449 merged — replace the branch-build CDN tarball with the
npm-published release built from protocol main (what the next dist-tag
resolves to today), kept as an explicit pin so upstream publishes can't
change builds without a repo change.

Main still lacks the experimental-only rfc4 fields, so the
patched_rfc4_comms_proto build-time patch stays. Upstream delta is
additive except pulse_client.proto dropping the unused
SceneListenerHandshakeRequest/ParcelRect.

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

✅ Approve

Tested in a group session (6 people — QA + mobile team), cross-client against Unity on .zone.

Debug panel

  • Panel shows correct Pulse state, fails count, dual-ch status
  • Peer rows show PULSE*/SCENE/ARCHIPELAGO, * correctly flags who's driven by Pulse
  • Nametag labels match panel data

Cross-client (Godot vs Unity)

  • Smooth movement (~10Hz + interpolation), no jitter, no double-apply
  • Clean teleports, no lerp streaks
  • Rotation/yaw correct across headings, no collapse-to-0° bug
  • Emotes: first-time, re-trigger after cancel, and join-mid-emote all work
  • No running-in-place; idle settles within ~300ms

Fallback / resilience

  • Unreachable pulse-server → 5-strike fallback to LiveKit-only works, no interruption
  • Killing Pulse mid-session → peers flip back to LiveKit within a frame

Alternate modes

  • dual-channel=false behaves correctly (player never invisible)
  • livekit=false (Pulse-only) works as expected
  • Regression: streaming/voice/chat unaffected in normal mode

No blockers. Solid fallback UX even under forced failures.

Pulse now stays OFF until feature_flags.gd confirms the mobile-bff
'pulse' flag is explicitly true — a fetch failure or an absent flag
keeps the transport disabled and LiveKit avatar sync covers everything.
Every other feature flag keeps its fail-open enabled default.

The flag verdict lands in a dedicated slot (set_pulse_flag_enabled),
separate from the deeplink override, and a single pulse_enabled()
helper resolves precedence: deeplink pulse=/pulse-server= > CLI >
server flag. Explicit local opt-ins force Pulse on regardless of the
flag: --pulse (previously an accepted no-op), --pulse-server /
PULSE_SERVER (naming an endpoint is explicit intent, matching the
pulse-server= deeplink which already self-enables), and pulse=true.
--no-pulse / pulse=false always win — a server flag can never
force-enable Pulse over a local opt-out, and a flag verdict can no
longer clobber a local test run.
@kuruk-mm
kuruk-mm enabled auto-merge (squash) August 4, 2026 13:13
@kuruk-mm
kuruk-mm merged commit 13ebf8b into main Aug 4, 2026
7 checks passed
@kuruk-mm
kuruk-mm deleted the feat/pulse-transport branch August 4, 2026 13:30
This was referenced Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants