diff --git a/godot/src/decentraland_components/avatar/avatar.gd b/godot/src/decentraland_components/avatar/avatar.gd index 03704936b..1f6e5cce4 100644 --- a/godot/src/decentraland_components/avatar/avatar.gd +++ b/godot/src/decentraland_components/avatar/avatar.gd @@ -1807,6 +1807,13 @@ func async_play_emote(emote_urn: String): await emote_controller.async_play_emote(emote_urn) +## Stop a looping emote on network request (rfc4 PlayerEmote.is_stopping / +## Pulse EmoteStopped). Called from Rust (AvatarScene::stop_emote). +func stop_emote_from_network(): + if emote_controller: + emote_controller.stop_emote() + + ## Register scene emote content info for later retrieval. ## Called from Rust before async_play_emote for scene emotes. func register_scene_emote_content( diff --git a/godot/src/deep_link_router.gd b/godot/src/deep_link_router.gd index f66c3881c..bb53fc655 100644 --- a/godot/src/deep_link_router.gd +++ b/godot/src/deep_link_router.gd @@ -34,6 +34,33 @@ func process_deep_link(url: String) -> void: print("[DEEPLINK] Found rust-log param: ", rust_log_value) DclGlobal.set_rust_log_filter(rust_log_value) + # Pulse transport params. `pulse-server=` joins a specific Pulse + # server (shareable — everyone opening the link lands on the same instance, + # also implies enabling); `pulse=true/false` toggles it with the configured + # endpoint. No-ops on builds without the use_pulse feature. + var pulse_server_value = Global.deep_link_obj.params.get("pulse-server", "") + if not pulse_server_value.is_empty(): + print("[DEEPLINK] pulse-server=", pulse_server_value) + Global.comms.set_pulse_server(pulse_server_value) + var pulse_value = Global.deep_link_obj.params.get("pulse", "") + if not pulse_value.is_empty(): + print("[DEEPLINK] pulse=", pulse_value) + Global.comms.set_pulse_enabled(pulse_value.to_lower() in ["true", "1", "yes"]) + # `dual-channel=true/false` (default true): whether movement keeps going over + # LiveKit while Pulse is established. false = Pulse-only movement while up. + var dual_channel_value = Global.deep_link_obj.params.get("dual-channel", "") + if not dual_channel_value.is_empty(): + print("[DEEPLINK] dual-channel=", dual_channel_value) + Global.comms.set_livekit_movement_dual_channel( + dual_channel_value.to_lower() in ["true", "1", "yes"] + ) + # `livekit=false`: pulse-only mode — no LiveKit rooms at all (no chat/voice/ + # scene messages). Dev/testing switch; `livekit=true` re-enables. + var livekit_value = Global.deep_link_obj.params.get("livekit", "") + if not livekit_value.is_empty(): + print("[DEEPLINK] livekit=", livekit_value) + Global.comms.set_livekit_enabled(livekit_value.to_lower() in ["true", "1", "yes"]) + Global._apply_optimized_content_base_url(Global.deep_link_obj) # `skip-gltf` toggle has to be set BEFORE any scene's GLTF_CONTAINER diff --git a/godot/src/global.gd b/godot/src/global.gd index 7f4a960db..3b357f16c 100644 --- a/godot/src/global.gd +++ b/godot/src/global.gd @@ -470,6 +470,27 @@ func _ready(): _apply_optimized_content_base_url(deep_link_obj) + # Pulse transport params (mirrors deep_link_router.process_deep_link — this + # fake-deeplink path doesn't route through it). pulse-server implies enabling. + var pulse_server_value = deep_link_obj.params.get("pulse-server", "") + if not pulse_server_value.is_empty(): + print("[DEEPLINK] pulse-server=", pulse_server_value) + comms.set_pulse_server(pulse_server_value) + var pulse_value = deep_link_obj.params.get("pulse", "") + if not pulse_value.is_empty(): + print("[DEEPLINK] pulse=", pulse_value) + comms.set_pulse_enabled(pulse_value.to_lower() in ["true", "1", "yes"]) + var dual_channel_value = deep_link_obj.params.get("dual-channel", "") + if not dual_channel_value.is_empty(): + print("[DEEPLINK] dual-channel=", dual_channel_value) + comms.set_livekit_movement_dual_channel( + dual_channel_value.to_lower() in ["true", "1", "yes"] + ) + var livekit_value = deep_link_obj.params.get("livekit", "") + if not livekit_value.is_empty(): + print("[DEEPLINK] livekit=", livekit_value) + comms.set_livekit_enabled(livekit_value.to_lower() in ["true", "1", "yes"]) + print("[DEEPLINK] safemargindebug=", deep_link_obj.safe_margin_debug) if deep_link_obj.safe_margin_debug: set_safe_margin_debug_enable(true) diff --git a/godot/src/test/validate_all_scripts.gd b/godot/src/test/validate_all_scripts.gd index 587fcee77..d9c335a9b 100644 --- a/godot/src/test/validate_all_scripts.gd +++ b/godot/src/test/validate_all_scripts.gd @@ -53,8 +53,13 @@ func find_all_scripts(path: String) -> Array: while file_name != "": if dir.current_is_dir(): - if not file_name.begins_with("."): - scripts.append_array(find_all_scripts(path + "/" + file_name)) + var sub_path = path + "/" + file_name + # Skip hidden dirs and embedded Godot projects (e.g. the Android build + # template's instrumented assets): their scripts resolve against their + # own project, not ours. + var is_nested_project = FileAccess.file_exists(sub_path + "/project.godot") + if not file_name.begins_with(".") and not is_nested_project: + scripts.append_array(find_all_scripts(sub_path)) elif file_name.ends_with(".gd"): scripts.append(path + "/" + file_name) diff --git a/godot/src/ui/COMPONENT_AUDIT.md b/godot/src/ui/COMPONENT_AUDIT.md index b4d6d50b0..e188f0925 100644 --- a/godot/src/ui/COMPONENT_AUDIT.md +++ b/godot/src/ui/COMPONENT_AUDIT.md @@ -203,7 +203,7 @@ Layouts ship as scripts only (no `.tscn`); each is a reusable container/wrapper | `debug_panel.tscn` | `components/organisms/debug_panel/` | `components/debug_panel/` | Dev debug panel | | `network_inspector_ui.tscn` | `components/organisms/debug_panel/network_inspector/` | `components/debug_panel/network_inspector/` | Network-inspector UI | | `request_entry.tscn` | `components/organisms/debug_panel/network_inspector/` | `components/debug_panel/network_inspector/` | Network-inspector row | -| `livekit_debug_panel.tscn` | `components/organisms/livekit_debug/` | `components/livekit_debug/` | LiveKit voice debug panel | +| `multiplayer_debug_panel.tscn` | `components/organisms/multiplayer_debug/` | `components/multiplayer_debug/` | Multiplayer comms debug panel | ## Duplication / unification candidates diff --git a/godot/src/ui/components/organisms/livekit_debug/livekit_debug_panel.gd b/godot/src/ui/components/organisms/livekit_debug/livekit_debug_panel.gd deleted file mode 100644 index 23858687f..000000000 --- a/godot/src/ui/components/organisms/livekit_debug/livekit_debug_panel.gd +++ /dev/null @@ -1,49 +0,0 @@ -extends PanelContainer - -@onready var rich_text_label: RichTextLabel = %RichTextLabel -@onready var timer: Timer = %Timer - - -func _ready(): - timer.timeout.connect(_on_timer_timeout) - timer.start() - _on_timer_timeout() - - -func _status_color(connected: bool) -> String: - return "green" if connected else "red" - - -func _on_timer_timeout(): - if not is_instance_valid(Global.comms): - return - - var info: Dictionary = Global.comms.get_debug_room_info() - var adapter: String = info.get("adapter", "") - var main_connected: bool = info.get("main_connected", false) - var scene_room: String = info.get("scene_room", "") - var scene_connected: bool = info.get("scene_connected", false) - var on_hold: bool = info.get("comms_on_hold", false) - - var text := "[b]LiveKit Debug[/b]\n" - text += "Adapter: " + adapter + "\n" - - var main_label := "" - if on_hold: - main_label = "[color=yellow]PAUSED[/color]" - elif main_connected: - main_label = "[color=green]CONNECTED[/color]" - else: - main_label = "[color=red]DISCONNECTED[/color]" - text += "Archipelago: " + main_label + "\n" - - var scene_label := "" - if on_hold: - scene_label = "[color=yellow]PAUSED[/color]" - elif scene_connected: - scene_label = "[color=green]CONNECTED[/color]" - else: - scene_label = "[color=red]DISCONNECTED[/color]" - text += "Scene Room: " + scene_room + " " + scene_label - - rich_text_label.text = text diff --git a/godot/src/ui/components/organisms/livekit_debug/livekit_debug_panel.tscn b/godot/src/ui/components/organisms/livekit_debug/livekit_debug_panel.tscn deleted file mode 100644 index 3c70d16f2..000000000 --- a/godot/src/ui/components/organisms/livekit_debug/livekit_debug_panel.tscn +++ /dev/null @@ -1,40 +0,0 @@ -[gd_scene format=3 uid="uid://hif3grdgg3c4"] - -[ext_resource type="Script" uid="uid://be8ew7ydyt5fw" path="res://src/ui/components/organisms/livekit_debug/livekit_debug_panel.gd" id="1_script"] - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bg"] -content_margin_left = 12.0 -content_margin_top = 8.0 -content_margin_right = 12.0 -content_margin_bottom = 8.0 -bg_color = Color(0, 0, 0, 0.7) -corner_radius_top_left = 8 -corner_radius_top_right = 8 -corner_radius_bottom_right = 8 -corner_radius_bottom_left = 8 - -[node name="LiveKitDebugPanel" type="PanelContainer" unique_id=273984283] -anchors_preset = 5 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = -150.0 -offset_right = 150.0 -offset_bottom = 120.0 -grow_horizontal = 2 -mouse_filter = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_bg") -script = ExtResource("1_script") - -[node name="RichTextLabel" type="RichTextLabel" parent="." unique_id=252622328] -unique_name_in_owner = true -layout_mode = 2 -mouse_filter = 2 -theme_override_colors/default_color = Color(0.3, 0.9, 0.3, 1) -theme_override_font_sizes/normal_font_size = 12 -bbcode_enabled = true -text = "[b]LiveKit Debug[/b]" -fit_content = true - -[node name="Timer" type="Timer" parent="." unique_id=233221733] -unique_name_in_owner = true -autostart = true diff --git a/godot/src/ui/components/organisms/menu/menu.gd b/godot/src/ui/components/organisms/menu/menu.gd index 99c5c398c..0e6259ee0 100644 --- a/godot/src/ui/components/organisms/menu/menu.gd +++ b/godot/src/ui/components/organisms/menu/menu.gd @@ -8,7 +8,7 @@ signal toggle_fps signal toggle_ram signal request_pause_scenes(enabled: bool) signal request_debug_panel(enabled: bool) -signal request_livekit_debug(enabled: bool) +signal request_multiplayer_debug(enabled: bool) #signals from advanced settings var is_in_game: bool = false # when it is playing in the 3D Game or not @@ -199,8 +199,8 @@ func async_show_settings(): control_settings.instance.request_debug_panel.connect( func(enabled): request_debug_panel.emit(enabled) ) - control_settings.instance.request_livekit_debug.connect( - func(enabled): request_livekit_debug.emit(enabled) + control_settings.instance.request_multiplayer_debug.connect( + func(enabled): request_multiplayer_debug.emit(enabled) ) select_settings_screen() diff --git a/godot/src/ui/components/organisms/multiplayer_debug/multiplayer_debug_panel.gd b/godot/src/ui/components/organisms/multiplayer_debug/multiplayer_debug_panel.gd new file mode 100644 index 000000000..dd53787f0 --- /dev/null +++ b/godot/src/ui/components/organisms/multiplayer_debug/multiplayer_debug_panel.gd @@ -0,0 +1,166 @@ +extends PanelContainer + +const MAX_PEER_ROWS := 10 +const TOKEN_SUMMARY_CHARS := 16 + +var collapsed := false + +@onready var rich_text_label: RichTextLabel = %RichTextLabel +@onready var collapse_button: Button = %CollapseButton +@onready var timer: Timer = %Timer + + +func _ready(): + timer.timeout.connect(_on_timer_timeout) + collapse_button.pressed.connect(_on_collapse_button_pressed) + timer.start() + _on_timer_timeout() + + +func _on_collapse_button_pressed(): + collapsed = not collapsed + rich_text_label.visible = not collapsed + collapse_button.text = "▸" if collapsed else "▾" + if not collapsed: + _on_timer_timeout() + + +func _state_color(state: String) -> String: + match state: + "connected", "established": + return "green" + "disconnected", "dead", "none": + return "red" + "off", "unavailable", "disabled_for_session", "unknown": + return "gray" + _: + # connecting / reconnecting / identifying / signing / idle / ... + return "yellow" + + +func _colored_state(state: String) -> String: + return "[color=%s]%s[/color]" % [_state_color(state), state] + + +func _short_address(address: String) -> String: + if address.length() <= 12: + return address + return address.substr(0, 6) + ".." + address.right(4) + + +# Long credentials (LiveKit JWTs) blow up the adapter line — keep the first +# TOKEN_SUMMARY_CHARS characters of the token value and elide the rest. +func _summarize_access_token(adapter: String) -> String: + var key := "access_token=" + var idx := adapter.find(key) + if idx == -1: + return adapter + var start := idx + key.length() + var end := adapter.find("&", start) + if end == -1: + end = adapter.length() + if end - start <= TOKEN_SUMMARY_CHARS: + return adapter + return adapter.substr(0, start + TOKEN_SUMMARY_CHARS) + "..." + adapter.substr(end) + + +# Peer names come from the wire — escape the bbcode delimiter so a bracket in a +# name can't inject markup into the RichTextLabel. +func _escape_bbcode(text_value: String) -> String: + return text_value.replace("[", "[lb]") + + +func _on_timer_timeout(): + if collapsed: + return + if not is_instance_valid(Global.comms): + return + + var info: Dictionary = Global.comms.get_debug_room_info() + var lines := PackedStringArray() + + var realm_name := "" + if is_instance_valid(Global.realm): + realm_name = String(Global.realm.realm_name) + if not realm_name.is_empty(): + lines.append("Realm: %s" % realm_name) + + var adapter: String = _summarize_access_token(info.get("adapter", "")) + var connection_state: String = info.get("connection_state", "unknown") + lines.append("Adapter: %s [%s]" % [adapter, connection_state]) + + if info.get("comms_on_hold", false): + lines.append("[color=yellow]COMMS ON HOLD[/color]") + + if not info.get("livekit_enabled", true): + lines.append("[color=orange]LIVEKIT DISABLED (pulse-only mode)[/color]") + + var main_room_type: String = info.get("main_room_type", "none") + var main_room_state: String = info.get("main_room_state", "none") + # In archipelago mode the island room (below) is the effective main room + if main_room_type != "none" or connection_state != "archipelago": + lines.append("Main room (%s): %s" % [main_room_type, _colored_state(main_room_state)]) + + if connection_state == "archipelago": + var archipelago_state: String = info.get("archipelago_state", "none") + var island_id: String = info.get("island_id", "") + var island_room_state: String = info.get("island_room_state", "none") + var island_label := island_id if not island_id.is_empty() else "-" + lines.append( + ( + "Archipelago: %s | island %s (%s)" + % [ + _colored_state(archipelago_state), + island_label, + _colored_state(island_room_state) + ] + ) + ) + + var scene_room: String = info.get("scene_room", "") + var scene_room_state: String = info.get("scene_room_state", "none") + var scene_label := scene_room if not scene_room.is_empty() else "-" + lines.append("Scene room: %s %s" % [scene_label, _colored_state(scene_room_state)]) + + lines.append(_build_pulse_line(info)) + if info.get("pulse_disabled_for_session", false): + lines.append("[color=red]Pulse disabled for session[/color]") + + lines.append_array(_peer_lines()) + + rich_text_label.text = "\n".join(lines) + + +func _build_pulse_line(info: Dictionary) -> String: + if not info.get("pulse_available", false): + return "Pulse: [color=gray]not in build[/color]" + if not info.get("pulse_enabled", false): + return "Pulse: [color=gray]OFF[/color]" + + var pulse_state: String = info.get("pulse_state", "unavailable") + var pulse_endpoint: String = info.get("pulse_endpoint", "") + var pulse_failures: int = info.get("pulse_failures", 0) + var dual_channel_label: String = "ON" if info.get("dual_channel", true) else "OFF" + return ( + "Pulse: %s @ %s | fails %d | dual-ch %s" + % [_colored_state(pulse_state), pulse_endpoint, pulse_failures, dual_channel_label] + ) + + +func _peer_lines() -> PackedStringArray: + var lines := PackedStringArray() + var peers: Array = Global.comms.get_debug_peer_rooms() + lines.append("Peers: %d" % peers.size()) + var shown: int = mini(peers.size(), MAX_PEER_ROWS) + for i in range(shown): + var peer: Dictionary = peers[i] + var address: String = peer.get("address", "") + var rooms: String = peer.get("rooms", "") + var peer_name: String = peer.get("name", "") + var who := _short_address(address) + if not peer_name.is_empty(): + who = "%s %s" % [_escape_bbcode(peer_name), who] + lines.append(" %s %s" % [who, rooms]) + if peers.size() > MAX_PEER_ROWS: + lines.append(" … +%d more" % (peers.size() - MAX_PEER_ROWS)) + return lines diff --git a/godot/src/ui/components/organisms/livekit_debug/livekit_debug_panel.gd.uid b/godot/src/ui/components/organisms/multiplayer_debug/multiplayer_debug_panel.gd.uid similarity index 100% rename from godot/src/ui/components/organisms/livekit_debug/livekit_debug_panel.gd.uid rename to godot/src/ui/components/organisms/multiplayer_debug/multiplayer_debug_panel.gd.uid diff --git a/godot/src/ui/components/organisms/multiplayer_debug/multiplayer_debug_panel.tscn b/godot/src/ui/components/organisms/multiplayer_debug/multiplayer_debug_panel.tscn new file mode 100644 index 000000000..3423b1ca4 --- /dev/null +++ b/godot/src/ui/components/organisms/multiplayer_debug/multiplayer_debug_panel.tscn @@ -0,0 +1,69 @@ +[gd_scene format=3 uid="uid://hif3grdgg3c4"] + +[ext_resource type="Script" uid="uid://be8ew7ydyt5fw" path="res://src/ui/components/organisms/multiplayer_debug/multiplayer_debug_panel.gd" id="1_script"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bg"] +content_margin_left = 12.0 +content_margin_top = 8.0 +content_margin_right = 12.0 +content_margin_bottom = 8.0 +bg_color = Color(0, 0, 0, 0.7) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[node name="MultiplayerDebugPanel" type="PanelContainer" unique_id=273984283] +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -220.0 +offset_right = 220.0 +offset_bottom = 120.0 +grow_horizontal = 2 +mouse_filter = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_bg") +script = ExtResource("1_script") + +[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=273984911] +layout_mode = 2 +mouse_filter = 2 + +[node name="Header" type="HBoxContainer" parent="VBoxContainer" unique_id=273984912] +layout_mode = 2 +mouse_filter = 2 + +[node name="TitleLabel" type="RichTextLabel" parent="VBoxContainer/Header" unique_id=273984913] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 4 +mouse_filter = 2 +theme_override_colors/default_color = Color(0.85, 0.85, 0.85, 1) +theme_override_font_sizes/normal_font_size = 12 +bbcode_enabled = true +text = "[b]Multiplayer Debug[/b]" +fit_content = true + +[node name="CollapseButton" type="Button" parent="VBoxContainer/Header" unique_id=273984914] +unique_name_in_owner = true +custom_minimum_size = Vector2(36, 22) +layout_mode = 2 +size_flags_horizontal = 8 +focus_mode = 0 +theme_override_font_sizes/font_size = 12 +text = "▾" +flat = true + +[node name="RichTextLabel" type="RichTextLabel" parent="VBoxContainer" unique_id=252622328] +unique_name_in_owner = true +layout_mode = 2 +mouse_filter = 2 +theme_override_colors/default_color = Color(0.85, 0.85, 0.85, 1) +theme_override_font_sizes/normal_font_size = 12 +bbcode_enabled = true +text = "" +fit_content = true + +[node name="Timer" type="Timer" parent="." unique_id=233221733] +unique_name_in_owner = true +autostart = true diff --git a/godot/src/ui/explorer.gd b/godot/src/ui/explorer.gd index d115188cf..6e54367a2 100644 --- a/godot/src/ui/explorer.gd +++ b/godot/src/ui/explorer.gd @@ -16,7 +16,7 @@ var panel_bottom_left_height: int = 0 var dirty_save_position: bool = false var debug_panel = null -var livekit_debug_panel = null +var multiplayer_debug_panel = null var scene_stats_panel = null var disable_move_to = false @@ -220,9 +220,9 @@ func _ready(): # Preview-only scene-stats / limits overlay (never created in production) _update_scene_stats_ui() - # livekit_debug deep link parameter auto-enables the LiveKit debug panel - if Global.deep_link_obj.livekit_debug: - _on_control_menu_request_livekit_debug(true) + # multiplayer_debug deep link parameter auto-enables the multiplayer debug panel + if Global.deep_link_obj.multiplayer_debug: + _on_control_menu_request_multiplayer_debug(true) # Scene Inspector: the bridge is now dialed from app startup (Global._ready), # not here — so the channel is live from second 0, before login / world entry. @@ -916,20 +916,22 @@ func _emit_pos_command_message() -> void: Global.on_chat_message.emit("system", msg, Time.get_unix_time_from_system()) -func _on_control_menu_request_livekit_debug(enabled): - Global.comms.set_livekit_debug(enabled) +func _on_control_menu_request_multiplayer_debug(enabled): + Global.comms.set_multiplayer_debug(enabled) if enabled: - if not is_instance_valid(livekit_debug_panel): - livekit_debug_panel = ( - load("res://src/ui/components/organisms/livekit_debug/livekit_debug_panel.tscn") + if not is_instance_valid(multiplayer_debug_panel): + multiplayer_debug_panel = ( + load( + "res://src/ui/components/organisms/multiplayer_debug/multiplayer_debug_panel.tscn" + ) . instantiate() ) - ui_root.add_child(livekit_debug_panel) + ui_root.add_child(multiplayer_debug_panel) else: - if is_instance_valid(livekit_debug_panel): - ui_root.remove_child(livekit_debug_panel) - livekit_debug_panel.queue_free() - livekit_debug_panel = null + if is_instance_valid(multiplayer_debug_panel): + ui_root.remove_child(multiplayer_debug_panel) + multiplayer_debug_panel.queue_free() + multiplayer_debug_panel = null func _on_control_menu_request_pause_scenes(enabled): @@ -951,6 +953,9 @@ func move_to(position: Vector3, skip_loading: bool, check_stuck: bool = true): player.avatar.emote_controller.set_teleport_grace() player.move_to(position, check_stuck) + # Announce the instant reposition to the Pulse transport so remote peers + # snap to it instead of interpolating across the jump (no-op when inactive). + Global.comms.notify_player_teleported(position) var cur_parcel_position = Vector2i( floor(player.position.x * 0.0625), -floor(player.position.z * 0.0625) ) diff --git a/godot/src/ui/explorer.tscn b/godot/src/ui/explorer.tscn index ea8cd0990..ac87cb701 100644 --- a/godot/src/ui/explorer.tscn +++ b/godot/src/ui/explorer.tscn @@ -468,7 +468,7 @@ script = ExtResource("20_064cw") [connection signal="jump_to" from="UI/Control_Menu" to="." method="_on_control_menu_jump_to"] [connection signal="open_profile" from="UI/Control_Menu" to="." method="_on_control_menu_open_profile"] [connection signal="request_debug_panel" from="UI/Control_Menu" to="." method="_on_control_menu_request_debug_panel"] -[connection signal="request_livekit_debug" from="UI/Control_Menu" to="." method="_on_control_menu_request_livekit_debug"] +[connection signal="request_multiplayer_debug" from="UI/Control_Menu" to="." method="_on_control_menu_request_multiplayer_debug"] [connection signal="request_pause_scenes" from="UI/Control_Menu" to="." method="_on_control_menu_request_pause_scenes"] [connection signal="toggle_fps" from="UI/Control_Menu" to="." method="_on_control_menu_toggle_fps"] [connection signal="toggle_minimap" from="UI/Control_Menu" to="." method="_on_control_menu_toggle_minimap"] diff --git a/godot/src/ui/pages/settings/settings.gd b/godot/src/ui/pages/settings/settings.gd index 0ad392d86..9ad7605b6 100644 --- a/godot/src/ui/pages/settings/settings.gd +++ b/godot/src/ui/pages/settings/settings.gd @@ -2,7 +2,7 @@ extends Control signal request_debug_panel(enabled: bool) signal request_pause_scenes(enabled: bool) -signal request_livekit_debug(enabled: bool) +signal request_multiplayer_debug(enabled: bool) signal panel_closed enum SceneLogLevel { @@ -77,6 +77,7 @@ var check_button_submit_message_closes_chat: CheckButton = %CheckButton_SubmitMe @onready var line_edit_custom_preview_url: LineEditCustom = %LineEditCustom_WebSocket @onready var process_tick_quota: SettingsSlider = %ProcessTickQuota @onready var check_button_raycast_debugger: CheckButton = %CheckButton_RaycastDebugger +@onready var check_button_multiplayer_debug: CheckButton = %CheckButton_MultiplayerDebug @onready var dropdown_list_realm: DropdownList = %DropdownList_Realm @onready var button_graphics: Button = %Button_Graphics @@ -352,6 +353,8 @@ func refresh_values(): process_tick_quota.value = Global.get_config().process_tick_quota_ms if is_instance_valid(Global.raycast_debugger): check_button_raycast_debugger.set_pressed_no_signal(true) + if is_instance_valid(Global.comms): + check_button_multiplayer_debug.set_pressed_no_signal(Global.comms.get_multiplayer_debug()) func _on_button_connect_preview_pressed(): @@ -849,8 +852,8 @@ func _on_check_button_scene_processing_paused_toggled(toggled_on: bool) -> void: emit_signal("request_pause_scenes", toggled_on) -func _on_check_button_livekit_debug_toggled(toggled_on: bool) -> void: - request_livekit_debug.emit(toggled_on) +func _on_check_button_multiplayer_debug_toggled(toggled_on: bool) -> void: + request_multiplayer_debug.emit(toggled_on) func _on_check_button_raycast_debugger_toggled(toggled_on: bool) -> void: diff --git a/godot/src/ui/pages/settings/settings.tscn b/godot/src/ui/pages/settings/settings.tscn index 217894dbf..b68a874cb 100644 --- a/godot/src/ui/pages/settings/settings.tscn +++ b/godot/src/ui/pages/settings/settings.tscn @@ -1068,20 +1068,21 @@ layout_mode = 2 size_flags_vertical = 0 theme = ExtResource("12_sa1v7") -[node name="LiveKitDebug" type="HBoxContainer" parent="VBoxContainer/MarginContainer_Content/ContentScrollContainer/VBoxContainer/MarginContainer_Upgrade/VBoxContainer_Sections/VBoxContainer_Advanced/HBoxContainer5" unique_id=520005061] +[node name="MultiplayerDebug" type="HBoxContainer" parent="VBoxContainer/MarginContainer_Content/ContentScrollContainer/VBoxContainer/MarginContainer_Upgrade/VBoxContainer_Sections/VBoxContainer_Advanced/HBoxContainer5" unique_id=520005061] layout_mode = 2 theme_override_constants/separation = 32 -[node name="Label_Title" type="Label" parent="VBoxContainer/MarginContainer_Content/ContentScrollContainer/VBoxContainer/MarginContainer_Upgrade/VBoxContainer_Sections/VBoxContainer_Advanced/HBoxContainer5/LiveKitDebug" unique_id=1801219375] +[node name="Label_Title" type="Label" parent="VBoxContainer/MarginContainer_Content/ContentScrollContainer/VBoxContainer/MarginContainer_Upgrade/VBoxContainer_Sections/VBoxContainer_Advanced/HBoxContainer5/MultiplayerDebug" unique_id=1801219375] layout_mode = 2 size_flags_horizontal = 3 theme_override_colors/font_color = Color(1, 1, 1, 1) theme_override_fonts/font = ExtResource("16_blskf") theme_override_font_sizes/font_size = 14 -text = "LiveKit Debug" +text = "Multiplayer Debug" label_settings = ExtResource("17_67hd5") -[node name="CheckButton_LiveKitDebug" type="CheckButton" parent="VBoxContainer/MarginContainer_Content/ContentScrollContainer/VBoxContainer/MarginContainer_Upgrade/VBoxContainer_Sections/VBoxContainer_Advanced/HBoxContainer5/LiveKitDebug" unique_id=2142717050] +[node name="CheckButton_MultiplayerDebug" type="CheckButton" parent="VBoxContainer/MarginContainer_Content/ContentScrollContainer/VBoxContainer/MarginContainer_Upgrade/VBoxContainer_Sections/VBoxContainer_Advanced/HBoxContainer5/MultiplayerDebug" unique_id=2142717050] +unique_name_in_owner = true layout_mode = 2 size_flags_vertical = 0 theme = ExtResource("12_sa1v7") @@ -1421,7 +1422,7 @@ font = ExtResource("25_jf4mt") [connection signal="toggled" from="VBoxContainer/MarginContainer_Content/ContentScrollContainer/VBoxContainer/MarginContainer_Upgrade/VBoxContainer_Sections/VBoxContainer_Advanced/HBoxContainer5/SceneLogsEnabled/CheckButton_SceneLogsEnabled" to="." method="_on_check_button_scene_logs_enabled_toggled"] [connection signal="toggled" from="VBoxContainer/MarginContainer_Content/ContentScrollContainer/VBoxContainer/MarginContainer_Upgrade/VBoxContainer_Sections/VBoxContainer_Advanced/HBoxContainer5/RaycastDebugger/CheckButton_RaycastDebugger" to="." method="_on_check_button_raycast_debugger_toggled"] [connection signal="toggled" from="VBoxContainer/MarginContainer_Content/ContentScrollContainer/VBoxContainer/MarginContainer_Upgrade/VBoxContainer_Sections/VBoxContainer_Advanced/HBoxContainer5/SceneProcessingPaused/CheckButton_SceneProcessingPaused" to="." method="_on_check_button_scene_processing_paused_toggled"] -[connection signal="toggled" from="VBoxContainer/MarginContainer_Content/ContentScrollContainer/VBoxContainer/MarginContainer_Upgrade/VBoxContainer_Sections/VBoxContainer_Advanced/HBoxContainer5/LiveKitDebug/CheckButton_LiveKitDebug" to="." method="_on_check_button_livekit_debug_toggled"] +[connection signal="toggled" from="VBoxContainer/MarginContainer_Content/ContentScrollContainer/VBoxContainer/MarginContainer_Upgrade/VBoxContainer_Sections/VBoxContainer_Advanced/HBoxContainer5/MultiplayerDebug/CheckButton_MultiplayerDebug" to="." method="_on_check_button_multiplayer_debug_toggled"] [connection signal="item_selected" from="VBoxContainer/MarginContainer_Content/ContentScrollContainer/VBoxContainer/MarginContainer_Upgrade/VBoxContainer_Sections/VBoxContainer_Advanced/DropdownList_Realm" to="." method="_on_dropdown_list_realm_item_selected"] [connection signal="pressed" from="VBoxContainer/MarginContainer_Content/ContentScrollContainer/VBoxContainer/MarginContainer_Upgrade/VBoxContainer_Sections/VBoxContainer_Advanced/Button_TestNotification" to="." method="_on_button_test_notification_pressed"] [connection signal="pressed" from="VBoxContainer/MarginContainer_Content/ContentScrollContainer/VBoxContainer/MarginContainer_Upgrade/VBoxContainer_Sections/VBoxContainer_Advanced/Button_OpenUserData" to="." method="_on_button_open_user_data_pressed"] diff --git a/lib/Cargo.lock b/lib/Cargo.lock index a8e384b63..1569f7a26 100644 --- a/lib/Cargo.lock +++ b/lib/Cargo.lock @@ -905,10 +905,12 @@ dependencies = [ "poll-promise", "prost 0.11.9", "prost-build 0.11.9", + "prost-reflect", "protobuf", "rand", "regex", "reqwest", + "rusty_enet", "serde", "serde_json", "simple-easing", @@ -3546,6 +3548,17 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "prost-reflect" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b823de344848e011658ac981009100818b322421676740546f8b52ed5249428" +dependencies = [ + "once_cell", + "prost 0.11.9", + "prost-types 0.11.9", +] + [[package]] name = "prost-types" version = "0.11.9" @@ -3953,6 +3966,14 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" +[[package]] +name = "rusty_enet" +version = "0.4.0" +source = "git+https://github.com/dcl-regenesislabs/rusty_enet?rev=8023f9b39e12d4b11a2a054ade49fa8365a5e250#8023f9b39e12d4b11a2a054ade49fa8365a5e250" +dependencies = [ + "js-sys", +] + [[package]] name = "ryu" version = "1.0.18" diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 1e6782574..343b03a34 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -65,6 +65,12 @@ futures-util = "0.3.30" livekit = { git = "https://github.com/robtfm/client-sdk-rust", rev="c59f7e4693579dc137dba72153a0e995079305c6", features=["rustls-tls-webpki-roots"], optional = true } +# Pulse (ENet/UDP avatar-state relay). MUST be this fork: it's a pure-Rust ENet retargeted to +# the nxrighthere ENet-CSharp "modified protocol" the Pulse server runs — stock lsalzman ENet +# (including Godot's built-in one) is wire-incompatible. DCL-org mirror of +# robtfm/rusty_enet branch decentraland-pulse (rev 6fa651d, tree-identical); pinned by rev. +rusty_enet = { git = "https://github.com/dcl-regenesislabs/rusty_enet", rev = "8023f9b39e12d4b11a2a054ade49fa8365a5e250", optional = true } + taffy = "0.5.2" tracing-test = "0.2.4" @@ -103,8 +109,11 @@ oslog = "0.2" libc = "0.2" [features] -default = ["use_livekit", "use_deno"] +default = ["use_livekit", "use_deno", "use_pulse"] use_livekit = ["dep:livekit", "dep:webrtc-sys-build"] +# Compiled in by default (pure-Rust dep, keeps CI/mobile builds from bitrotting); +# runtime activation is opt-in via --pulse / --pulse-server / PULSE_SERVER. +use_pulse = ["dep:rusty_enet"] use_deno = ["dep:deno_core", "dep:v8"] use_resource_tracking = [] use_voice_chat = [] @@ -121,6 +130,9 @@ enable_inspector = ["use_deno", "dep:fastwebsockets", "dep:hyper1"] [build-dependencies] webrtc-sys-build = { git = "https://github.com/robtfm/client-sdk-rust", rev="c59f7e4693579dc137dba72153a0e995079305c6", optional = true } prost-build = "0.11.8" +# Reads the Pulse quantization field options out of the FileDescriptorSet (build_quant.rs). +# 0.11.x pairs with prost 0.11 (same pairing bevy-explorer uses). +prost-reflect = "0.11" chrono = "0.4.31" # Pinned to a commit (not a branch) so CI builds are immutable — a moved branch must not # change what runs alongside the signing/ASC secrets. Bump deliberately when needed. diff --git a/lib/build.rs b/lib/build.rs index 859074400..040e356f8 100644 --- a/lib/build.rs +++ b/lib/build.rs @@ -7,6 +7,8 @@ use std::{ path::Path, }; +mod build_quant; + struct Component { id: u32, pascal_name: String, @@ -395,6 +397,16 @@ fn main() -> io::Result<()> { format!("{PROTO_FILES_BASE_DIR}decentraland/kernel/comms/v3/archipelago.proto").into(), ); + // Pulse comms protos, shipped by the pinned @dcl/protocol tarball (protocol PR #429 branch + // build — see PROTOCOL_FIXED_VERSION_URL in src/install_dependency.rs). Compiled + // unconditionally — runtime code is gated by the `use_pulse` feature instead, keeping the + // build script feature-free. options.proto must be in the compile set so the + // FileDescriptorSet carries the quantization extension values for build_quant. + proto_files.push(format!("{PROTO_FILES_BASE_DIR}decentraland/common/options.proto").into()); + proto_files.push(format!("{PROTO_FILES_BASE_DIR}decentraland/pulse/pulse_shared.proto").into()); + proto_files.push(format!("{PROTO_FILES_BASE_DIR}decentraland/pulse/pulse_client.proto").into()); + proto_files.push(format!("{PROTO_FILES_BASE_DIR}decentraland/pulse/pulse_server.proto").into()); + // Social service protos (with RPC services) proto_files .push(format!("{PROTO_FILES_BASE_DIR}decentraland/social_service/errors.proto").into()); @@ -432,7 +444,19 @@ fn main() -> io::Result<()> { let mut prost_config = prost_build::Config::new(); prost_config.type_attribute(".", "#[derive(serde::Serialize)]"); prost_config.service_generator(Box::new(dcl_rpc::codegen::RPCServiceGenerator::new())); - prost_config.compile_protos(&proto_files, &["src/dcl/components/proto/"])?; + // Emit the descriptor set so build_quant can read the Pulse quantization + // field options (the .proto stays the single source of truth for the wire ABI). + let descriptor_path = Path::new(&env::var("OUT_DIR").unwrap()).join("proto_descriptor_set.bin"); + prost_config.file_descriptor_set_path(&descriptor_path); + prost_config.compile_protos(&proto_files, &[PROTO_FILES_BASE_DIR])?; + + let descriptor_bytes = fs::read(&descriptor_path).expect("read proto descriptor set"); + let quant_path = Path::new(&env::var("OUT_DIR").unwrap()).join("pulse_quant.rs"); + build_quant::generate(&descriptor_bytes, &quant_path); + println!("cargo:rerun-if-changed=build_quant.rs"); + println!( + "cargo:rerun-if-changed={PROTO_FILES_BASE_DIR}decentraland/kernel/comms/rfc4/comms.proto" + ); #[cfg(feature = "use_livekit")] if env::var("CARGO_CFG_TARGET_OS").unwrap() == "android" { diff --git a/lib/build_quant.rs b/lib/build_quant.rs new file mode 100644 index 000000000..5652a792c --- /dev/null +++ b/lib/build_quant.rs @@ -0,0 +1,187 @@ +//! Host-only (build-time) quantization codegen for the Pulse protocol. +//! Ported from bevy-explorer `crates/dcl_component/build_quant.rs` @ 3f65c164. +//! +//! Reads the `(decentraland.common.quantized[_power])` field options (`{min, max, bits}` / +//! `{max, pow, bits}`) straight from the FileDescriptorSet emitted by `build.rs` and generates +//! `{field}_dequantized()` / `{field}_quantized(v)` / `{field}_step()` accessors for every +//! `decentraland.pulse` message field that carries one. This keeps the `.proto` the single +//! source of truth for the quantization ABI — the exact constants the C# Pulse server bakes via +//! `protoc-gen-bitwise` — instead of hand-transcribing them on the client side, where a silent +//! drift would decode to wrong positions with no compile error. +//! +//! Not a runtime dependency: the emitted file is plain const arithmetic, `include!`d into the +//! `proto_components::pulse` module. + +use std::fmt::Write as _; + +use prost_reflect::{DescriptorPool, DynamicMessage, Value}; + +pub fn generate(descriptor_bytes: &[u8], out_path: &std::path::Path) { + let pool = DescriptorPool::decode(descriptor_bytes).expect("decode descriptor"); + let quant = pool + .get_extension_by_name("decentraland.common.quantized") + .expect("quantized extension missing from descriptor"); + let quant_power = pool + .get_extension_by_name("decentraland.common.quantized_power") + .expect("quantized_power extension missing from descriptor"); + + let mut out = String::new(); + out.push_str( + "// @generated by build_quant.rs from the (decentraland.common.quantized[_power]) proto options. DO NOT EDIT.\n\n", + ); + // Inverse of the server's Quantize.Encode: an encoded integer in [0, 2^bits - 1] maps back + // onto the float range [min, max]. + out.push_str( + "#[inline]\n\ + fn dequantize(encoded: u32, min: f32, max: f32, bits: u32) -> f32 {\n \ + let levels = ((1u32 << bits) - 1) as f32;\n \ + min + (encoded as f32 / levels) * (max - min)\n\ + }\n\n", + ); + // Inverse of the server's Quantize.EncodePower: an (bits-1)-bit linear unorm magnitude `u` in + // the high bits plus a sign in the LSB, reconstructed as `sign * max * u^pow`. + // Zero (code 0) decodes to exactly 0. + out.push_str( + "#[inline]\n\ + fn dequantize_power(encoded: u32, max: f32, pow: f32, bits: u32) -> f32 {\n \ + let mag_steps = ((1u32 << (bits - 1)) - 1) as f32;\n \ + let u = (encoded >> 1) as f32 / mag_steps;\n \ + let magnitude = max * u.powf(pow);\n \ + if encoded & 1 != 0 { -magnitude } else { magnitude }\n\ + }\n\n", + ); + // The encode side, needed for the outbound PlayerState. Mirror of the server's Quantize.Encode: + // clamp to [min, max], scale onto [0, 2^bits - 1], round to the nearest integer. + out.push_str( + "#[inline]\n\ + fn quantize(value: f32, min: f32, max: f32, bits: u32) -> u32 {\n \ + let levels = ((1u32 << bits) - 1) as f32;\n \ + let t = ((value - min) / (max - min)).clamp(0.0, 1.0);\n \ + (t * levels).round() as u32\n\ + }\n\n", + ); + // Mirror of the server's Quantize.EncodePower: `(magnitude << 1) | sign`, magnitude from the + // inverse power curve `(|value|/max)^(1/pow)`. A zero magnitude never sets the sign bit, so a + // stopped field canonicalizes to 0 and proto3 omits it. + out.push_str( + "#[inline]\n\ + fn quantize_power(value: f32, max: f32, pow: f32, bits: u32) -> u32 {\n \ + let mag_steps = ((1u32 << (bits - 1)) - 1) as f32;\n \ + let t = (value.abs() / max).clamp(0.0, 1.0);\n \ + let magnitude = (t.powf(1.0 / pow) * mag_steps).round() as u32;\n \ + let sign = if value < 0.0 && magnitude != 0 { 1 } else { 0 };\n \ + (magnitude << 1) | sign\n\ + }\n\n", + ); + + for msg in pool.all_messages() { + if msg.package_name() != "decentraland.pulse" { + continue; + } + + let mut accessors = String::new(); + for field in msg.fields() { + let opts = field.options(); + let name = field.name(); + // `optional uint32` (delta fields) decodes to `Option`; a plain `uint32` (the + // non-optional PlayerState fields) decodes straight to `f32`. + let optional = field.supports_presence(); + + if opts.has_extension(&quant) { + let value = opts.get_extension(&quant); + let q = value + .as_message() + .expect("quantized option must be a message"); + let min = get_f32(q, "min"); + let max = get_f32(q, "max"); + let bits = get_u32(q, "bits"); + + let decode = if optional { + format!( + " /// Dequantized `{name}` ({min}..={max}, {bits} bits); `None` when absent.\n \ + pub fn {name}_dequantized(&self) -> Option {{\n \ + self.{name}.map(|v| dequantize(v, {min}f32, {max}f32, {bits}))\n \ + }}\n" + ) + } else { + format!( + " /// Dequantized `{name}` ({min}..={max}, {bits} bits).\n \ + pub fn {name}_dequantized(&self) -> f32 {{\n \ + dequantize(self.{name}, {min}f32, {max}f32, {bits})\n \ + }}\n" + ) + }; + + writeln!( + accessors, + "{decode} \ + /// Quantize a float into `{name}` ({min}..={max}, {bits} bits).\n \ + pub fn {name}_quantized(value: f32) -> u32 {{\n \ + quantize(value, {min}f32, {max}f32, {bits})\n \ + }}\n \ + /// Quantization step (granularity) of `{name}`: `(max - min) / (2^bits - 1)`.\n \ + pub fn {name}_step() -> f32 {{\n \ + ({max}f32 - {min}f32) / ((1u32 << {bits}) - 1) as f32\n \ + }}", + ) + .unwrap(); + } else if opts.has_extension(&quant_power) { + let value = opts.get_extension(&quant_power); + let q = value + .as_message() + .expect("quantized_power option must be a message"); + let max = get_f32(q, "max"); + let pow = get_f32(q, "pow"); + let bits = get_u32(q, "bits"); + + let decode = if optional { + format!( + " /// Dequantized `{name}` (\u{00b1}{max}, power-law pow {pow}, {bits} bits); `None` when absent.\n \ + pub fn {name}_dequantized(&self) -> Option {{\n \ + self.{name}.map(|v| dequantize_power(v, {max}f32, {pow}f32, {bits}))\n \ + }}\n" + ) + } else { + format!( + " /// Dequantized `{name}` (\u{00b1}{max}, power-law pow {pow}, {bits} bits).\n \ + pub fn {name}_dequantized(&self) -> f32 {{\n \ + dequantize_power(self.{name}, {max}f32, {pow}f32, {bits})\n \ + }}\n" + ) + }; + + writeln!( + accessors, + "{decode} \ + /// Quantize a float into `{name}` (\u{00b1}{max}, power-law pow {pow}, {bits} bits).\n \ + pub fn {name}_quantized(value: f32) -> u32 {{\n \ + quantize_power(value, {max}f32, {pow}f32, {bits})\n \ + }}", + ) + .unwrap(); + } + } + + if accessors.is_empty() { + continue; + } + + writeln!(out, "impl {} {{\n{accessors}\n}}\n", msg.name()).unwrap(); + } + + std::fs::write(out_path, out).expect("write pulse_quant.rs"); +} + +fn get_f32(message: &DynamicMessage, field: &str) -> f32 { + match message.get_field_by_name(field).as_deref() { + Some(Value::F32(v)) => *v, + other => panic!("quantized.{field} expected f32, got {other:?}"), + } +} + +fn get_u32(message: &DynamicMessage, field: &str) -> u32 { + match message.get_field_by_name(field).as_deref() { + Some(Value::U32(v)) => *v, + other => panic!("quantized.{field} expected u32, got {other:?}"), + } +} diff --git a/lib/src/auth/wallet.rs b/lib/src/auth/wallet.rs index 19b680c16..a9c62678e 100644 --- a/lib/src/auth/wallet.rs +++ b/lib/src/auth/wallet.rs @@ -230,6 +230,46 @@ impl AsH160 for String { } } +/// Sign the Pulse ENet handshake payload and pack the auth chain as the JSON `x-identity-*` +/// header dictionary the server expects inside `HandshakeRequest.auth_chain` — identical in shape +/// to the HTTP signed-fetch headers (`sign_request`), just carried as protobuf bytes. +/// +/// The payload is the signed-fetch string for `connect` on path `/`: +/// `connect:/:{timestamp_ms}:{}` — deliberately NOT lowercased: the server +/// (`HandshakeHandler`/`SignedFetch.BuildSignedFetchPayload`) verifies the signature over this +/// exact string, and Unity/bevy sign it verbatim. (It happens to be all-lowercase today; don't +/// couple to that.) Re-sign per attempt: the server enforces a ±60s replay window on the +/// timestamp. +pub async fn sign_pulse_connect(wallet: &EphemeralAuthChain) -> Result, String> { + let unix_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_millis(); + + let payload = format!("connect:/:{unix_time}:{{}}"); + let signature = wallet + .ephemeral_wallet() + .sign_message(&payload) + .await + .map_err(|e| format!("ephemeral sign failed: {e}"))?; + let mut auth_chain = wallet.auth_chain().clone(); + auth_chain.add_signed_entity(payload, signature); + + let mut dict = serde_json::Map::new(); + for (key, value) in auth_chain.headers() { + dict.insert(key, serde_json::Value::String(value)); + } + dict.insert( + "x-identity-timestamp".to_owned(), + serde_json::Value::String(unix_time.to_string()), + ); + dict.insert( + "x-identity-metadata".to_owned(), + serde_json::Value::String("{}".to_owned()), + ); + serde_json::to_vec(&dict).map_err(|e| e.to_string()) +} + pub async fn sign_request( method: &str, uri: &Uri, diff --git a/lib/src/avatars/avatar_scene.rs b/lib/src/avatars/avatar_scene.rs index 9aa152941..f574b1405 100644 --- a/lib/src/avatars/avatar_scene.rs +++ b/lib/src/avatars/avatar_scene.rs @@ -744,7 +744,7 @@ impl AvatarScene { }; let dcl_transform = DclTransformAndParent::from_godot(&transform, Vector3::ZERO); - self._update_avatar_transform(&entity_id, dcl_transform); + self._update_avatar_transform(&entity_id, dcl_transform, false); } #[func] @@ -1411,14 +1411,22 @@ impl AvatarScene { &mut self, avatar_entity_id: &SceneEntityId, dcl_transform: DclTransformAndParent, + instant: bool, ) { let avatar_scene = self .avatar_godot_scene .get_mut(avatar_entity_id) .expect("avatar not found"); - avatar_scene - .bind_mut() - .set_target_position(dcl_transform.to_godot_transform_3d()); + if instant { + // Teleport: snap, don't interpolate across the jump. + avatar_scene + .bind_mut() + .snap_to_position(dcl_transform.to_godot_transform_3d()); + } else { + avatar_scene + .bind_mut() + .set_target_position(dcl_transform.to_godot_transform_3d()); + } let mut scene_runner = DclGlobal::singleton().bind().scene_runner.clone(); let mut scene_runner = scene_runner.bind_mut(); @@ -1496,7 +1504,7 @@ impl AvatarScene { parent: SceneEntityId::ROOT, }; - self._update_avatar_transform(&entity_id, dcl_transform); + self._update_avatar_transform(&entity_id, dcl_transform, false); self.last_position_index.insert(alias, transform.index); true } @@ -1550,7 +1558,7 @@ impl AvatarScene { parent: SceneEntityId::ROOT, }; - self._update_avatar_transform(&entity_id, dcl_transform); + self._update_avatar_transform(&entity_id, dcl_transform, movement.is_instant); // Wire-authoritative animation state for remote double-jump / glide. if let Some(avatar) = self.avatar_godot_scene.get_mut(&entity_id) { avatar.bind_mut().apply_wire_movement_state( @@ -1607,7 +1615,7 @@ impl AvatarScene { parent: SceneEntityId::ROOT, }; - self._update_avatar_transform(&entity_id, dcl_transform); + self._update_avatar_transform(&entity_id, dcl_transform, false); self.last_movement_timestamp.insert(alias, timestamp); true } @@ -1679,6 +1687,27 @@ impl AvatarScene { } } + /// Stop a remote avatar's looping emote (rfc4 `PlayerEmote.is_stopping` — sent by Unity + /// peers over LiveKit and synthesized from Pulse `EmoteStopped`). Deliberately does not + /// touch the incremental-id dedup: a stop must neither depend on nor affect id ordering. + pub fn stop_emote(&mut self, alias: u32) { + let Some(entity_id) = self.avatar_entity.get(&alias) else { + return; + }; + if let Some(avatar_scene) = self.avatar_godot_scene.get_mut(entity_id) { + avatar_scene.call("stop_emote_from_network", &[]); + } + } + + /// Clear the per-alias movement/emote dedup state. Called by MessageProcessor when a peer's + /// preferred transport flips (Pulse ⇄ LiveKit): the two sources use incomparable clocks + /// (sender clock vs Pulse server tick), so carrying dedup state across the switch would + /// permanently starve the newly-preferred source. + pub fn reset_movement_dedup(&mut self, alias: u32) { + self.last_movement_timestamp.remove(&alias); + self.last_emote_incremental_id.remove(&alias); + } + pub fn update_avatar(&mut self, entity_id: SceneEntityId, profile: &UserProfile) { // Avoid updating avatar with the same data if let Some(val) = self.last_updated_profile.get(&entity_id) { diff --git a/lib/src/comms/adapter/adapter_trait.rs b/lib/src/comms/adapter/adapter_trait.rs index a9e1f6bb6..5efb78bfe 100644 --- a/lib/src/comms/adapter/adapter_trait.rs +++ b/lib/src/comms/adapter/adapter_trait.rs @@ -14,4 +14,8 @@ pub trait Adapter { fn broadcast_voice(&mut self, frame: Vec); fn support_voice_chat(&self) -> bool; + + fn connection_state_str(&self) -> &'static str { + "unknown" + } } diff --git a/lib/src/comms/adapter/archipelago.rs b/lib/src/comms/adapter/archipelago.rs index 223b4e356..db3b1f93b 100644 --- a/lib/src/comms/adapter/archipelago.rs +++ b/lib/src/comms/adapter/archipelago.rs @@ -106,6 +106,27 @@ impl ArchipelagoManager { self.adapter.as_ref() } + pub fn state_name(&self) -> &'static str { + match self.state { + ArchipelagoState::Connecting => "connecting", + ArchipelagoState::Connected => "ws_open", + ArchipelagoState::IdentMessageSent => "identifying", + ArchipelagoState::ChallengeMessageSent => "challenge_sent", + ArchipelagoState::WelcomeMessageReceived => "connected", + } + } + + pub fn island_id(&self) -> Option<&str> { + self.last_island_id.as_deref() + } + + pub fn island_room_state(&self) -> &'static str { + self.adapter + .as_ref() + .map(|adapter| adapter.connection_state_str()) + .unwrap_or("none") + } + fn ws_internal_send(&mut self, packet: T, only_when_active: bool) -> bool where T: Message, diff --git a/lib/src/comms/adapter/livekit.rs b/lib/src/comms/adapter/livekit.rs index 243c12e76..eb310b8c8 100644 --- a/lib/src/comms/adapter/livekit.rs +++ b/lib/src/comms/adapter/livekit.rs @@ -1,4 +1,10 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicU8, Ordering}, + Arc, + }, +}; use ethers_core::types::H160; use futures_util::StreamExt; @@ -34,6 +40,12 @@ use super::{ // Constants const CHANNEL_SIZE: usize = 1000; +// Connection state values shared with the background livekit thread +const LK_STATE_CONNECTING: u8 = 0; +const LK_STATE_CONNECTED: u8 = 1; +const LK_STATE_RECONNECTING: u8 = 2; +const LK_STATE_DISCONNECTED: u8 = 3; + pub struct NetworkMessage { pub data: Vec, pub unreliable: bool, @@ -51,6 +63,7 @@ pub struct LivekitRoom { message_processor_sender: Option< tokio::sync::mpsc::Sender, >, + connection_state: Arc, } impl LivekitRoom { @@ -97,6 +110,8 @@ impl LivekitRoom { let url = realm.bind().get_lambda_server_base_url().to_string(); url }; + let connection_state = Arc::new(AtomicU8::new(LK_STATE_CONNECTING)); + let connection_state_for_thread = connection_state.clone(); let _ = std::thread::Builder::new() .name("livekit dcl thread".into()) .spawn(move || { @@ -108,6 +123,7 @@ impl LivekitRoom { room_id_clone, auto_subscribe, lambdas_endpoint, + connection_state_for_thread, ); }) .unwrap(); @@ -119,6 +135,16 @@ impl LivekitRoom { receiver_from_thread, room_id, message_processor_sender: None, + connection_state, + } + } + + pub fn connection_state_str(&self) -> &'static str { + match self.connection_state.load(Ordering::Relaxed) { + LK_STATE_CONNECTED => "connected", + LK_STATE_RECONNECTING => "reconnecting", + LK_STATE_DISCONNECTED => "disconnected", + _ => "connecting", } } @@ -243,8 +269,13 @@ impl Adapter for LivekitRoom { // Scene messages are now handled by MessageProcessor Vec::new() } + + fn connection_state_str(&self) -> &'static str { + self.connection_state_str() + } } +#[allow(clippy::too_many_arguments)] fn spawn_livekit_task( remote_address: String, mut receiver: tokio::sync::mpsc::Receiver, @@ -253,6 +284,7 @@ fn spawn_livekit_task( room_id: String, auto_subscribe: bool, lambdas_endpoint: String, + connection_state: Arc, ) { let url = Uri::try_from(remote_address).unwrap(); let address = format!( @@ -278,17 +310,21 @@ fn spawn_livekit_task( let rt2 = rt.clone(); + let connection_state_in_task = connection_state.clone(); let task = rt.spawn(async move { + let connection_state = connection_state_in_task; tracing::debug!("🔌 LiveKit connecting - room: '{}', auto_subscribe: {}", room_id, auto_subscribe); let connect_result = livekit::prelude::Room::connect(&address, &token, RoomOptions{ auto_subscribe, adaptive_stream: false, dynacast: false, ..Default::default() }).await; let (room, mut network_rx) = match connect_result { Ok(result) => { tracing::debug!("🔌 LiveKit connection successful - room: '{}'", room_id); + connection_state.store(LK_STATE_CONNECTED, Ordering::Relaxed); result } Err(e) => { tracing::warn!("🔌 LiveKit connection failed - room: '{}', error: {:?}", room_id, e); + connection_state.store(LK_STATE_DISCONNECTED, Ordering::Relaxed); return; } }; @@ -725,7 +761,16 @@ fn spawn_livekit_task( tracing::warn!("Failed to send PeerLeft: {}", e); } } + livekit::RoomEvent::Reconnecting => { + tracing::debug!("🔌 LiveKit reconnecting - room: '{}'", room_id); + connection_state.store(LK_STATE_RECONNECTING, Ordering::Relaxed); + } + livekit::RoomEvent::Reconnected => { + tracing::debug!("🔌 LiveKit reconnected - room: '{}'", room_id); + connection_state.store(LK_STATE_CONNECTED, Ordering::Relaxed); + } livekit::RoomEvent::Disconnected { reason } => { + connection_state.store(LK_STATE_DISCONNECTED, Ordering::Relaxed); // Log LiveKit session end with reason tracing::debug!( "🔌 LiveKit session ended - room: '{}', reason: {:?}", @@ -820,12 +865,15 @@ fn spawn_livekit_task( } tracing::debug!("🔌 LiveKit room closing - room: '{}'", room_id); + connection_state.store(LK_STATE_DISCONNECTED, Ordering::Relaxed); if let Err(e) = room.close().await { tracing::warn!("🔌 LiveKit room.close() failed - room: '{}', error: {}", room_id, e); } }); let _ = rt.block_on(task); + // Covers a panicked/aborted task too — anyone still holding the handle sees disconnected + connection_state.store(LK_STATE_DISCONNECTED, Ordering::Relaxed); } #[cfg(target_os = "android")] diff --git a/lib/src/comms/adapter/message_processor.rs b/lib/src/comms/adapter/message_processor.rs index 2b1efd068..814371967 100644 --- a/lib/src/comms/adapter/message_processor.rs +++ b/lib/src/comms/adapter/message_processor.rs @@ -15,7 +15,7 @@ use crate::{ truncate_utf8_safe, DEFAULT_PROTOCOL_VERSION, INACTIVE_PEER_THRESHOLD_SECS, MAX_CHAT_MESSAGES, MAX_CHAT_MESSAGE_SIZE, MAX_SCENE_IDS, MAX_SCENE_MESSAGES_PER_SCENE, MESSAGE_CHANNEL_SIZE, OUTGOING_CHANNEL_SIZE, PROFILE_REQUEST_INTERVAL_SECS, - PROFILE_UPDATE_CHANNEL_SIZE, + PROFILE_UPDATE_CHANNEL_SIZE, PULSE_ROOM_ID, }, profile::{SerializedProfile, UserProfile}, }, @@ -133,6 +133,12 @@ struct Peer { lambdas_endpoint: Option, // Peer's lambda URL from LiveKit metadata (lambdasEndpoint) last_movement_timestamp: f32, // Dedup: last movement timestamp received last_emote_incremental_id: u32, // Dedup: last emote incremental ID received + /// Transport-preference gate: true while this peer is a live member of the "pulse" room + /// (set on any pulse-bridged message, cleared by a pulse PeerLeft). While set, this peer's + /// movement/emotes from LiveKit rooms are DISCARDED — never merged: LiveKit timestamps are + /// the sender's clock and Pulse timestamps are the server tick, so comparing them starves + /// one source permanently. On either flip both dedup layers are reset for the same reason. + pulse_live: bool, } struct ProfileUpdate { @@ -284,6 +290,20 @@ impl MessageProcessor { } } + /// Which rfc4 messages the transport-preference gate applies to: exactly the avatar-sync + /// slice that rides Pulse (movement in its three encodings, and emotes). Everything else + /// either never rides Pulse (chat, scene, profile request/response) or is idempotent + /// (profile-version announcements) and flows from both transports untouched. + fn is_gated_by_pulse_preference(message: &rfc4::packet::Message) -> bool { + matches!( + message, + rfc4::packet::Message::Position(_) + | rfc4::packet::Message::Movement(_) + | rfc4::packet::Message::MovementCompressed(_) + | rfc4::packet::Message::PlayerEmote(_) + ) + } + /// Returns true if the address looks like a real player (non-synthetic Ethereum address). /// Synthetic addresses (like H160::from_low_u64_be(1) for the auth server) are non-player. fn is_player_address(address: H160) -> bool { @@ -500,9 +520,16 @@ impl MessageProcessor { for (address, peer) in self.peer_identities.iter_mut() { let mut inactive_rooms = Vec::new(); - // Check each room the peer has been seen in + // Check each room the peer has been seen in. + // "pulse" is exempt: its server stops sending deltas for static distant peers by + // design (interest-management tiers), so activity is not a liveness signal there. + // Membership is authoritative instead — reliable PlayerJoined/PlayerLeft, plus the + // synthetic PeerLeft flood PulseRoom emits on any teardown. let rooms_to_check: Vec = peer.room_activity.keys().cloned().collect(); for room_id in rooms_to_check { + if room_id == PULSE_ROOM_ID { + continue; + } if let Some(&last_seen) = peer.room_activity.get(&room_id) { if last_seen.elapsed() > inactive_threshold { inactive_rooms.push(room_id); @@ -808,6 +835,7 @@ impl MessageProcessor { lambdas_endpoint: None, last_movement_timestamp: f32::NEG_INFINITY, last_emote_incremental_id: 0, + pulse_live: false, }, ); @@ -857,6 +885,12 @@ impl MessageProcessor { new_alias }; + // Any pulse-bridged message (except the departure itself) marks the peer as live on + // Pulse, engaging the transport-preference gate; the flip resets the dedup layers. + if room_id == PULSE_ROOM_ID && !matches!(message.message, MessageType::PeerLeft) { + self.set_peer_pulse_live(message.address, true); + } + // Handle non-RFC4 messages that need avatar_scene match &message.message { MessageType::InitVoice(voice_init) => { @@ -903,7 +937,12 @@ impl MessageProcessor { } MessageType::Rfc4(rfc4_msg) => { // Handle RFC4 messages - self.handle_rfc4_message(rfc4_msg.message.clone(), peer_alias, message.address); + self.handle_rfc4_message( + rfc4_msg.message.clone(), + peer_alias, + message.address, + &room_id, + ); } MessageType::PeerJoined => { // Peer joined event - ensure peer exists and update room activity @@ -998,7 +1037,37 @@ impl MessageProcessor { } } + /// Flip a peer's transport preference. On EITHER flip direction, both dedup layers (the + /// per-peer fields here and the per-alias state in AvatarScene) reset: LiveKit and Pulse + /// timestamps come from incomparable clocks, so stale dedup state from the previous source + /// would permanently starve the new one (see `Peer::pulse_live`). + fn set_peer_pulse_live(&mut self, address: H160, live: bool) { + let Some(peer) = self.peer_identities.get_mut(&address) else { + return; + }; + if peer.pulse_live == live { + return; + } + peer.pulse_live = live; + peer.last_movement_timestamp = f32::NEG_INFINITY; + peer.last_emote_incremental_id = 0; + let alias = peer.alias; + tracing::debug!( + "🔀 Peer {:#x} (alias: {}) transport preference → {}", + address, + alias, + if live { "pulse" } else { "livekit" } + ); + let mut avatar_scene_ref = self.avatars.clone(); + avatar_scene_ref.bind_mut().reset_movement_dedup(alias); + } + fn handle_peer_left(&mut self, address: H160, room_id: String) { + // Leaving the pulse room (a real PlayerLeft or PulseRoom's teardown flood) hands the + // peer back to LiveKit-driven rendering within this same frame. + if room_id == PULSE_ROOM_ID { + self.set_peer_pulse_live(address, false); + } if let Some(peer) = self.peer_identities.get_mut(&address) { peer.room_activity.remove(&room_id); tracing::debug!( @@ -1037,7 +1106,27 @@ impl MessageProcessor { message: rfc4::packet::Message, peer_alias: u32, address: H160, + room_id: &str, ) { + // Transport-preference gate: while a peer is live on Pulse, its avatar-sync messages + // from LiveKit rooms are discarded outright (see `Peer::pulse_live` for why merging is + // impossible). Chat/Scene/ProfileRequest/Response never ride Pulse and ProfileVersion + // is idempotent — none of those are gated. + if room_id != PULSE_ROOM_ID + && Self::is_gated_by_pulse_preference(&message) + && self + .peer_identities + .get(&address) + .is_some_and(|peer| peer.pulse_live) + { + tracing::trace!( + "🔀 Discarding LiveKit avatar-sync message from pulse-live peer {:#x} (room '{}')", + address, + room_id + ); + return; + } + match message { rfc4::packet::Message::Position(position) => { tracing::debug!( @@ -1174,7 +1263,7 @@ impl MessageProcessor { "{}...", truncate_utf8_safe(&chat.message, MAX_CHAT_MESSAGE_SIZE) ), - timestamp: chat.timestamp, + ..chat } } else { chat @@ -1612,6 +1701,16 @@ impl MessageProcessor { } rfc4::packet::Message::Voice(_voice) => {} rfc4::packet::Message::PlayerEmote(player_emote) => { + // A stop signal ends the looping emote. Handled BEFORE the incremental-id + // dedup: a stop must neither depend on nor affect id ordering (Pulse stops + // carry no meaningful id; Unity's LiveKit stops reuse the start's id). + if player_emote.is_stopping == Some(true) { + tracing::debug!("Received PlayerEmote stop from {:#x}", address); + let mut avatar_scene_ref = self.avatars.clone(); + avatar_scene_ref.bind_mut().stop_emote(peer_alias); + return; + } + // Deduplicate: skip if incremental_id is not newer (dual-room broadcasting) if let Some(peer) = self.peer_identities.get_mut(&address) { if player_emote.incremental_id <= peer.last_emote_incremental_id { @@ -1687,28 +1786,94 @@ impl MessageProcessor { } /// Returns room connectivity info for each peer. - /// Each entry is (address, room_description) where room_description is - /// "Scene", "Archipelago", or "Both". - pub fn get_peer_room_info(&self) -> Vec<(H160, String)> { + /// Each entry is (address, room_description, name) where room_description lists + /// every room the peer is seen in, joined with " + " — e.g. "PULSE + SCENE + + /// ARCHIPELAGO", "SCENE + ARCHIPELAGO", "PULSE", or "NONE". A trailing '*' on + /// PULSE marks that Pulse is the source currently driving the avatar + /// (transport-preference gate). `name` is the profile display name, empty while + /// the profile hasn't been resolved yet. + pub fn get_peer_room_info(&self) -> Vec<(H160, String, String)> { let mut result = Vec::new(); for (address, peer) in &self.peer_identities { let mut has_scene = false; let mut has_archipelago = false; + let mut has_pulse = false; for room_id in peer.room_activity.keys() { if room_id.starts_with("scene-") { has_scene = true; + } else if room_id == PULSE_ROOM_ID { + has_pulse = true; } else { has_archipelago = true; } } - let room_desc = match (has_scene, has_archipelago) { - (true, true) => "Both".to_string(), - (true, false) => "Scene".to_string(), - (false, true) => "Archipelago".to_string(), - (false, false) => "None".to_string(), + let mut parts: Vec<&str> = Vec::new(); + if has_pulse { + // Mark which source is actually driving the avatar right now. + parts.push(if peer.pulse_live { "PULSE*" } else { "PULSE" }); + } + if has_scene { + parts.push("SCENE"); + } + if has_archipelago { + parts.push("ARCHIPELAGO"); + } + let room_desc = if parts.is_empty() { + "NONE".to_string() + } else { + parts.join(" + ") }; - result.push((*address, room_desc)); + let name = peer + .profile + .as_ref() + .map(|profile| profile.content.name.clone()) + .unwrap_or_default(); + result.push((*address, room_desc, name)); } result } } + +// MessageProcessor itself needs a live Godot engine (Gd), so the full +// interleaved pulse/livekit sequences are covered by the M4 cross-client matrix (see +// plan_pulse/05); what IS engine-free — the gate's message classification — is pinned here. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pulse_preference_gates_exactly_the_avatar_sync_slice() { + use rfc4::packet::Message; + + // Gated: the avatar-sync slice that rides Pulse. + assert!(MessageProcessor::is_gated_by_pulse_preference( + &Message::Movement(rfc4::Movement::default()) + )); + assert!(MessageProcessor::is_gated_by_pulse_preference( + &Message::MovementCompressed(rfc4::MovementCompressed::default()) + )); + assert!(MessageProcessor::is_gated_by_pulse_preference( + &Message::Position(rfc4::Position::default()) + )); + assert!(MessageProcessor::is_gated_by_pulse_preference( + &Message::PlayerEmote(rfc4::PlayerEmote::default()) + )); + + // Never gated: doesn't ride Pulse, or idempotent. + assert!(!MessageProcessor::is_gated_by_pulse_preference( + &Message::Chat(rfc4::Chat::default()) + )); + assert!(!MessageProcessor::is_gated_by_pulse_preference( + &Message::Scene(rfc4::Scene::default()) + )); + assert!(!MessageProcessor::is_gated_by_pulse_preference( + &Message::ProfileVersion(rfc4::AnnounceProfileVersion::default()) + )); + assert!(!MessageProcessor::is_gated_by_pulse_preference( + &Message::ProfileRequest(rfc4::ProfileRequest::default()) + )); + assert!(!MessageProcessor::is_gated_by_pulse_preference( + &Message::ProfileResponse(rfc4::ProfileResponse::default()) + )); + } +} diff --git a/lib/src/comms/adapter/ws_room.rs b/lib/src/comms/adapter/ws_room.rs index f7d8457c0..4a6a1afb6 100644 --- a/lib/src/comms/adapter/ws_room.rs +++ b/lib/src/comms/adapter/ws_room.rs @@ -512,6 +512,16 @@ impl WebSocketRoom { fn _change_profile(&mut self, new_profile: UserProfile) { self.player_profile = Some(new_profile); } + + pub fn state_name(&self) -> &'static str { + match self.state { + WsRoomState::Connecting => "connecting", + WsRoomState::Connected => "ws_open", + WsRoomState::IdentMessageSent => "identifying", + WsRoomState::ChallengeMessageSent => "challenge_sent", + WsRoomState::WelcomeMessageReceived => "connected", + } + } } fn get_next_packet(mut peer: Gd) -> Option<(usize, ws_packet::Message)> { @@ -560,4 +570,8 @@ impl Adapter for WebSocketRoom { // Scene messages are now handled by MessageProcessor Vec::new() } + + fn connection_state_str(&self) -> &'static str { + self.state_name() + } } diff --git a/lib/src/comms/communication_manager.rs b/lib/src/comms/communication_manager.rs index e0690fb8f..de74f170c 100644 --- a/lib/src/comms/communication_manager.rs +++ b/lib/src/comms/communication_manager.rs @@ -1,14 +1,12 @@ use ethers_core::types::H160; use godot::prelude::*; use http::Uri; -#[cfg(feature = "use_livekit")] use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; #[cfg(feature = "use_livekit")] -use crate::{ - auth::wallet, comms::consts::DISABLE_ARCHIPELAGO, scene_runner::tokio_runtime::TokioRuntime, -}; +use crate::comms::consts::DISABLE_ARCHIPELAGO; +use crate::{auth::wallet, scene_runner::tokio_runtime::TokioRuntime}; use crate::{ comms::{ adapter::{ @@ -95,13 +93,37 @@ impl MainRoom { MainRoom::LiveKit(livekit_room) => livekit_room.support_voice_chat(), } } + + fn type_str(&self) -> &'static str { + match self { + MainRoom::WebSocket(_) => "websocket", + #[cfg(feature = "use_livekit")] + MainRoom::LiveKit(_) => "livekit", + } + } + + fn connection_state_str(&self) -> &'static str { + match self { + MainRoom::WebSocket(ws_room) => ws_room.state_name(), + #[cfg(feature = "use_livekit")] + MainRoom::LiveKit(livekit_room) => livekit_room.connection_state_str(), + } + } } #[cfg(feature = "use_livekit")] -use crate::{ - comms::adapter::{archipelago::ArchipelagoManager, livekit::LivekitRoom}, - http_request::http_queue_requester::HttpQueueRequester, +use crate::comms::adapter::{archipelago::ArchipelagoManager, livekit::LivekitRoom}; +#[cfg(feature = "use_pulse")] +use crate::comms::pulse::{ + pulse_room::{PulseRoom, PulseRoomEvent}, + transport::PulseTransportConfig, }; +use crate::http_request::http_queue_requester::HttpQueueRequester; + +/// Consecutive never-established Pulse connection attempts before giving up for the whole +/// session (Unity parity: 5 attempts, then permanent LiveKit-only fallback until restart). +#[cfg(feature = "use_pulse")] +const PULSE_SESSION_FAILURE_LIMIT: u32 = 5; #[allow(clippy::large_enum_variant)] enum CommsConnection { @@ -150,9 +172,9 @@ pub struct CommunicationManager { realm_min_bounds: Vector2i, realm_max_bounds: Vector2i, - // LiveKit debug mode - livekit_debug: bool, - livekit_debug_last_update: Instant, + // Multiplayer debug mode (in-world panel + avatar room labels) + multiplayer_debug: bool, + multiplayer_debug_last_update: Instant, // Shared message processor for all adapters message_processor: Option, @@ -163,6 +185,50 @@ pub struct CommunicationManager { scene_room: Option, current_scene_id: Option, + // Pulse (ENet avatar-state relay) — an always-on parallel room, not a MainRoom variant: + // its death must never surface as a session disconnect (LiveKit keeps the session alive). + #[cfg(feature = "use_pulse")] + pulse_room: Option, + /// Consecutive attempts that never reached Established; survives clean() so realm changes + /// don't reset the strike count. At PULSE_SESSION_FAILURE_LIMIT → one-way session disable. + #[cfg(feature = "use_pulse")] + pulse_session_failures: u32, + #[cfg(feature = "use_pulse")] + pulse_disabled_for_session: bool, + /// The initial TeleportRequest is deferred until both a valid realm name and a broadcast + /// position exist (sending a bogus realm would silo us into a phantom partition). + #[cfg(feature = "use_pulse")] + pulse_teleport_pending: bool, + /// Local player position in DCL world convention, cached from broadcast_movement; feeds + /// the Pulse TeleportRequest. + #[cfg(feature = "use_pulse")] + last_dcl_position: Option, + /// Dual-channel movement: while true (default), movement keeps going over LiveKit exactly + /// as today even when Pulse is established. Turned off via --no-livekit-movement or the + /// setter, movement is Pulse-only *while established* — LiveKit sending auto-resumes the + /// moment Pulse drops, so this can never make the player invisible. + #[cfg(feature = "use_pulse")] + livekit_movement_dual_channel: bool, + /// Runtime activation override (deeplink `pulse=true/false`): `None` follows the CLI/env. + #[cfg(feature = "use_pulse")] + pulse_runtime_enabled: Option, + /// Runtime endpoint override (deeplink `pulse-server=host:port`, shareable so a group can + /// join the same server). Wins over --pulse-server / PULSE_SERVER / the default endpoint. + #[cfg(feature = "use_pulse")] + pulse_endpoint_override: Option, + /// Pulse EmoteStart deferred from `send_emote` (press time) to `set_emoting` (playback + /// start). Pressing the wheel precedes the async emote load and the idle gate — an + /// EmoteStart at press time gets chased by the per-frame animation poll's EmoteStop, + /// cancelling it remotely before it ever plays. Overwritten by every new trigger. + #[cfg(feature = "use_pulse")] + pending_pulse_emote_urn: Option, + /// Runtime "pulse-only" switch (deeplink `livekit=false`, CLI `--no-livekit`): `None` + /// follows the CLI. While disabled, no LiveKit-backed rooms are created or kept (main + /// livekit room, archipelago island room, scene rooms) — chat/voice/scene messages are + /// gone with them. Pulse and ws-room realms are unaffected. Dev/testing switch. + #[cfg(feature = "use_livekit")] + livekit_runtime_enabled: Option, + // Reconnection timer for scene rooms #[cfg(feature = "use_livekit")] scene_room_reconnect_at: Option, @@ -200,13 +266,33 @@ impl INode for CommunicationManager { block_auto_reconnect: false, comms_on_hold: false, saved_adapter_for_resume: GString::default(), - livekit_debug: false, - livekit_debug_last_update: Instant::now(), + multiplayer_debug: false, + multiplayer_debug_last_update: Instant::now(), message_processor: None, main_room: None, #[cfg(feature = "use_livekit")] scene_room: None, current_scene_id: None, + #[cfg(feature = "use_pulse")] + pulse_room: None, + #[cfg(feature = "use_pulse")] + pulse_session_failures: 0, + #[cfg(feature = "use_pulse")] + pulse_disabled_for_session: false, + #[cfg(feature = "use_pulse")] + pulse_teleport_pending: false, + #[cfg(feature = "use_pulse")] + last_dcl_position: None, + #[cfg(feature = "use_pulse")] + livekit_movement_dual_channel: true, + #[cfg(feature = "use_pulse")] + pulse_runtime_enabled: None, + #[cfg(feature = "use_pulse")] + pulse_endpoint_override: None, + #[cfg(feature = "use_pulse")] + pending_pulse_emote_urn: None, + #[cfg(feature = "use_livekit")] + livekit_runtime_enabled: None, #[cfg(feature = "use_livekit")] scene_room_reconnect_at: None, #[cfg(feature = "use_livekit")] @@ -482,18 +568,22 @@ impl INode for CommunicationManager { self.scene_room_reconnect_at = Some(Instant::now() + Duration::from_secs(5)); } + // Poll the Pulse room (state machine + inbound bridging) + #[cfg(feature = "use_pulse")] + self.poll_pulse_room(); + // Periodic ProfileVersion broadcasting (every 10 seconds) if self.last_profile_version_broadcast.elapsed().as_secs() >= 10 { self.broadcast_profile_version(); self.last_profile_version_broadcast = Instant::now(); } - // LiveKit debug: update avatar room labels (~1/sec) - if self.livekit_debug && self.livekit_debug_last_update.elapsed().as_secs() >= 1 { - self.livekit_debug_last_update = Instant::now(); + // Multiplayer debug: update avatar room labels (~1/sec) + if self.multiplayer_debug && self.multiplayer_debug_last_update.elapsed().as_secs() >= 1 { + self.multiplayer_debug_last_update = Instant::now(); if let Some(processor) = &self.message_processor { let avatar_scene = DclGlobal::singleton().bind().get_avatars(); - for (address, rooms) in processor.get_peer_room_info() { + for (address, rooms, _) in processor.get_peer_room_info() { let address_str = format!("{:#x}", address); let mut avatar_scene_ref = avatar_scene.clone(); avatar_scene_ref @@ -553,6 +643,159 @@ impl CommunicationManager { } } + /// Create the Pulse room if activation is on and it doesn't exist yet. + /// Activation: deeplink override (`pulse=`/`pulse-server=`) > CLI `--pulse` / env. + /// Endpoint: deeplink `pulse-server=` > `--pulse-server` / `PULSE_SERVER` > default + /// `urls::pulse_server():7777` (env-resolved via the `comms` group; `dclenv=zone` + /// or `dclenv=comms::zone,org` both target the zone deployment). + #[cfg(feature = "use_pulse")] + fn ensure_pulse_room(&mut self) { + if self.pulse_room.is_some() || self.pulse_disabled_for_session || self.comms_on_hold { + return; + } + + let global = DclGlobal::singleton(); + + // The shared MessageProcessor (and the Pulse handshake itself) need an identity. + // A deeplink can land before login (Global._ready) — keep the runtime overrides and + // defer; change_adapter re-runs this once the identity exists. + if global + .bind() + .get_player_identity() + .bind() + .try_get_address() + .is_none() + { + tracing::debug!("pulse: no identity yet; deferring room creation"); + return; + } + + let cli = global.bind().cli.clone(); + let cli = cli.bind(); + if !self.pulse_runtime_enabled.unwrap_or(cli.pulse) { + return; + } + if cli.no_livekit_movement { + self.livekit_movement_dual_channel = false; + } + + let (host, port) = self.resolve_pulse_endpoint(&cli); + + let processor_sender = self.ensure_message_processor(); + tracing::info!("pulse: room created for {host}:{port}"); + self.pulse_room = Some(PulseRoom::new( + PulseTransportConfig { host, port }, + processor_sender, + )); + } + + /// Whether LiveKit-backed rooms may be created (see `livekit_runtime_enabled`). + #[cfg(feature = "use_livekit")] + fn livekit_enabled(&self) -> bool { + self.livekit_runtime_enabled.unwrap_or_else(|| { + let global = DclGlobal::singleton(); + let cli = global.bind().cli.clone(); + let no_livekit = cli.bind().no_livekit; + !no_livekit + }) + } + + /// Endpoint precedence: deeplink `pulse-server=` override > CLI `--pulse-server` / + /// `PULSE_SERVER` env > env-resolved default host at the standard port. + #[cfg(feature = "use_pulse")] + fn resolve_pulse_endpoint(&self, cli: &crate::godot_classes::dcl_cli::DclCli) -> (String, u16) { + let endpoint = self + .pulse_endpoint_override + .clone() + .unwrap_or_else(|| cli.pulse_server.to_string()); + if endpoint.is_empty() { + ( + crate::urls::pulse_server(), + crate::comms::consts::PULSE_SERVER_PORT, + ) + } else { + match endpoint + .rsplit_once(':') + .and_then(|(h, p)| p.parse::().ok().map(|p| (h.to_owned(), p))) + { + Some(parsed) => parsed, + None => (endpoint, crate::comms::consts::PULSE_SERVER_PORT), + } + } + } + + #[cfg(feature = "use_pulse")] + fn poll_pulse_room(&mut self) { + let events = { + let Some(pulse_room) = self.pulse_room.as_mut() else { + return; + }; + pulse_room.poll(Instant::now(), || { + let player_identity = DclGlobal::singleton().bind().get_player_identity(); + let player_identity = player_identity.bind(); + let ephemeral = player_identity.try_get_ephemeral_auth_chain()?; + let profile_version = player_identity + .clone_profile() + .map(|p| p.version as i32) + .unwrap_or(0); + Some((ephemeral, profile_version)) + }) + }; + + for event in events { + match event { + PulseRoomEvent::Established => { + self.pulse_session_failures = 0; + // Mandatory first gameplay message; deferred until realm + position exist. + self.pulse_teleport_pending = true; + } + PulseRoomEvent::AttemptFailed => { + self.pulse_session_failures += 1; + if self.pulse_session_failures >= PULSE_SESSION_FAILURE_LIMIT { + tracing::warn!( + "pulse: {} consecutive failed attempts — falling back to LiveKit-only for this session", + self.pulse_session_failures + ); + self.pulse_disabled_for_session = true; + if let Some(mut pulse_room) = self.pulse_room.take() { + pulse_room.clean(); + } + return; + } + } + PulseRoomEvent::Dead => { + // Terminal code — parked until the next change_adapter rebuilds the room. + tracing::warn!("pulse: room dead until next adapter change"); + } + } + } + + if self.pulse_teleport_pending { + self.try_send_pulse_teleport(); + } + } + + /// Send the initial/announce TeleportRequest once a valid realm name and a cached position + /// are both available. Sending the `realm.gd` fallback name would silo the player into a + /// phantom realm — invisible to everyone, with zero errors — so wait instead. + #[cfg(feature = "use_pulse")] + fn try_send_pulse_teleport(&mut self) { + let Some(position) = self.last_dcl_position else { + return; + }; + let realm = DclGlobal::singleton().bind().get_realm(); + let realm_name = realm.bind().get_realm_name().to_string(); + if realm_name.is_empty() || realm_name == "no_realm_name" { + return; + } + if let Some(pulse_room) = self.pulse_room.as_mut() { + if pulse_room.is_established() { + pulse_room.send_teleport(position, &realm_name); + self.pulse_teleport_pending = false; + } + } + } + #[cfg(feature = "use_livekit")] fn reconnect_scene_room(&self) { let Some(scene_entity_id) = &self.current_scene_id else { @@ -700,6 +943,13 @@ impl CommunicationManager { ); } } + + // Dual-send to Pulse. PulseRoom dedups on version internally — the 10s rebroadcast + // loop is a LiveKit liveness idiom, pure noise on a reliable server-stored channel. + #[cfg(feature = "use_pulse")] + if let Some(pulse_room) = &mut self.pulse_room { + pulse_room.send_profile_version(player_profile.version); + } } } @@ -834,6 +1084,162 @@ impl CommunicationManager { self.voice_chat_enabled } + /// Dual-channel movement toggle (default ON): whether movement keeps going over LiveKit + /// while Pulse is established. Turning it off makes movement Pulse-only *while established*; + /// LiveKit sending auto-resumes if Pulse drops. No-op without the use_pulse feature. + #[func] + fn set_livekit_movement_dual_channel(&mut self, enabled: bool) { + #[cfg(feature = "use_pulse")] + { + self.livekit_movement_dual_channel = enabled; + } + #[cfg(not(feature = "use_pulse"))] + let _ = enabled; + } + + #[func] + fn is_livekit_movement_dual_channel(&self) -> bool { + #[cfg(feature = "use_pulse")] + { + self.livekit_movement_dual_channel + } + #[cfg(not(feature = "use_pulse"))] + true + } + + /// Runtime (deeplink `pulse-server=host:port`): join a specific Pulse server. Shareable — + /// everyone opening the same deeplink lands on the same instance. Overrides the CLI/env + /// endpoint, clears any session fallback (an explicit user action earns a fresh chance), + /// and rebuilds the room immediately. + #[func] + pub fn set_pulse_server(&mut self, host_port: GString) { + #[cfg(feature = "use_pulse")] + { + let endpoint = host_port.to_string(); + if endpoint.is_empty() { + return; + } + tracing::info!("pulse: runtime endpoint set to {endpoint}"); + self.pulse_endpoint_override = Some(endpoint); + self.pulse_runtime_enabled = Some(true); + self.pulse_disabled_for_session = false; + self.pulse_session_failures = 0; + if let Some(mut pulse_room) = self.pulse_room.take() { + pulse_room.clean(); + self.pulse_teleport_pending = false; + } + self.ensure_pulse_room(); + } + #[cfg(not(feature = "use_pulse"))] + let _ = host_port; + } + + /// Runtime (deeplink `pulse=true/false`): toggle the Pulse transport with the configured + /// endpoint. Enabling clears any session fallback; disabling tears the room down. + #[func] + pub fn set_pulse_enabled(&mut self, enabled: bool) { + #[cfg(feature = "use_pulse")] + { + self.pulse_runtime_enabled = Some(enabled); + if enabled { + self.pulse_disabled_for_session = false; + self.pulse_session_failures = 0; + self.ensure_pulse_room(); + } else if let Some(mut pulse_room) = self.pulse_room.take() { + pulse_room.clean(); + self.pulse_teleport_pending = false; + } + tracing::info!("pulse: runtime enabled = {enabled}"); + } + #[cfg(not(feature = "use_pulse"))] + let _ = enabled; + } + + /// Runtime (deeplink `livekit=false`, CLI `--no-livekit`): pulse-only mode. Disabling + /// tears down every LiveKit-backed room (main livekit room, archipelago island room, + /// scene room) — chat, voice and scene messages go with them; avatar sync continues over + /// Pulse only. Re-enabling rebuilds the current adapter and reconnects the scene room. + /// ws-room realms are not LiveKit and are unaffected. Dev/testing switch. + #[func] + pub fn set_livekit_enabled(&mut self, enabled: bool) { + #[cfg(feature = "use_livekit")] + { + self.livekit_runtime_enabled = Some(enabled); + tracing::info!("livekit: runtime enabled = {enabled}"); + if enabled { + let adapter = self.current_connection_str.clone(); + if !adapter.is_empty() { + self.change_adapter(adapter); + } + if self.current_scene_id.is_some() { + self.reconnect_scene_room(); + } + } else { + #[cfg(feature = "use_pulse")] + if !self.pulse_room.as_ref().is_some_and(|p| p.is_established()) { + tracing::warn!( + "livekit disabled while Pulse is not established — no avatar sync source" + ); + } + if matches!(self.main_room, Some(MainRoom::LiveKit(_))) { + if let Some(mut main_room) = self.main_room.take() { + main_room.clean(); + } + } + if matches!(self.current_connection, CommsConnection::Archipelago(_)) { + if let CommsConnection::Archipelago(archipelago) = &mut self.current_connection + { + archipelago.clean(); + } + self.current_connection = CommsConnection::None; + } + if let Some(scene_room) = &mut self.scene_room { + scene_room.clean(); + } + self.scene_room = None; + self.scene_room_reconnect_at = None; + self.voice_chat_enabled = false; + } + } + #[cfg(not(feature = "use_livekit"))] + let _ = enabled; + } + + #[func] + pub fn is_livekit_enabled(&self) -> bool { + #[cfg(feature = "use_livekit")] + { + self.livekit_enabled() + } + #[cfg(not(feature = "use_livekit"))] + false + } + + /// The local player was instantly repositioned (explorer.gd move_to: UI teleports and + /// scene movePlayerTo both funnel through it). Announce it to the Pulse server as a + /// same-realm TeleportRequest so remote peers snap instead of interpolating across the + /// jump. `position` is in Godot world convention (same as broadcast_movement's). + /// + /// Latches pulse_teleport_pending rather than sending directly: process() flushes at most + /// one teleport per frame (teleports share the server's 20/s discrete-event budget with + /// emotes — a scene spamming movePlayerTo must not burn it) and re-checks the realm guard. + #[func] + pub fn notify_player_teleported(&mut self, position: Vector3) { + #[cfg(feature = "use_pulse")] + { + self.last_dcl_position = Some(Vector3::new(position.x, position.y, -position.z)); + if self + .pulse_room + .as_ref() + .is_some_and(|pulse| pulse.is_established()) + { + self.pulse_teleport_pending = true; + } + } + #[cfg(not(feature = "use_pulse"))] + let _ = position; + } + #[func] #[allow(clippy::too_many_arguments)] fn broadcast_movement( @@ -858,6 +1264,13 @@ impl CommunicationManager { archipelago.update_position(position); } + // Cache the DCL-convention position (z negated, matching the rfc4 packet below) for the + // Pulse TeleportRequest. + #[cfg(feature = "use_pulse")] + { + self.last_dcl_position = Some(Vector3::new(position.x, position.y, -position.z)); + } + let velocity = Vector3::new(velocity.x, velocity.y, -velocity.z); // The Temporal bitfield doesn't carry jump_count / glide_state yet, so @@ -870,6 +1283,58 @@ impl CommunicationManager { let needs_uncompressed = jump_count >= 2 || glide_state != 0; let use_compressed = compressed && !needs_uncompressed; + // Always built (not just for the uncompressed wire path): it is also the Pulse + // PlayerStateInput source, quantized via decoder::from_movement. + let uncompressed_movement = { + // Get elapsed time since start + let timestamp = self.start_time.elapsed().as_secs_f32(); + + // Calculate movement blend value based on velocity and movement type + let movement_blend_value = if run { + 3.0 + } else if jog { + 2.0 + } else if walk { + 1.0 + } else { + 0.0 + }; + + rfc4::Movement { + timestamp, + position_x: position.x, + position_y: position.y, + position_z: -position.z, + velocity_x: velocity.x, + velocity_y: velocity.y, + velocity_z: velocity.z, + // Negate + rad→deg to match Unity Foundation Client, then wrap so the value + // actually lands in the documented [0, 360) range — rfc4 receivers tolerate + // signed degrees, but the Pulse quantizer clamps them to 0. + rotation_y: (-rotation_y).to_degrees().rem_euclid(360.0), + movement_blend_value, + slide_blend_value: 0.0, + is_grounded, + is_jumping: rise, + jump_count, + is_long_jump: false, + is_long_fall: false, + is_falling: fall, + is_stunned: false, + glide_state, + is_instant: false, + is_emoting: self.is_emoting, + head_ik_yaw_enabled: false, // TODO: implement head sync + head_ik_pitch_enabled: false, + head_yaw: 0.0, + head_pitch: 0.0, + point_at_x: 0.0, // TODO: implement point-at + point_at_y: 0.0, + point_at_z: 0.0, + is_pointing_at: false, + } + }; + let get_packet = || { if use_compressed { // Get elapsed time since start @@ -919,74 +1384,46 @@ impl CommunicationManager { protocol_version: DEFAULT_PROTOCOL_VERSION, } } else { - // Create regular Movement packet with all required fields - - // Get elapsed time since start - let timestamp = self.start_time.elapsed().as_secs_f32(); - - // Calculate movement blend value based on velocity and movement type - let movement_blend_value = if run { - 3.0 - } else if jog { - 2.0 - } else if walk { - 1.0 - } else { - 0.0 - }; - - let movement_packet = rfc4::Movement { - timestamp, - position_x: position.x, - position_y: position.y, - position_z: -position.z, - velocity_x: velocity.x, - velocity_y: velocity.y, - velocity_z: velocity.z, - // Negate + rad→deg to match Unity Foundation Client - // (rfc4.Movement.rotation_y is degrees in [0, 360)). - rotation_y: (-rotation_y).to_degrees(), - movement_blend_value, - slide_blend_value: 0.0, - is_grounded, - is_jumping: rise, - jump_count, - is_long_jump: false, - is_long_fall: false, - is_falling: fall, - is_stunned: false, - glide_state, - is_instant: false, - is_emoting: self.is_emoting, - head_ik_yaw_enabled: false, // TODO: implement head sync - head_ik_pitch_enabled: false, - head_yaw: 0.0, - head_pitch: 0.0, - point_at_x: 0.0, // TODO: implement point-at - point_at_y: 0.0, - point_at_z: 0.0, - is_pointing_at: false, - }; - rfc4::Packet { - message: Some(rfc4::packet::Message::Movement(movement_packet)), + message: Some(rfc4::packet::Message::Movement( + uncompressed_movement.clone(), + )), protocol_version: DEFAULT_PROTOCOL_VERSION, } } }; + // Dual-channel movement (see plan_pulse/06): while the flag is on (default), movement + // rides LiveKit exactly as today even when Pulse is established (Unity prod contract — + // LiveKit-only peers must still see us). With the flag off, the island movement sends + // (main room + archipelago) are skipped ONLY while Pulse is established, so a Pulse drop + // auto-resumes LiveKit movement — the flag can never make the player invisible. + #[cfg(feature = "use_pulse")] + let pulse_established = self + .pulse_room + .as_ref() + .is_some_and(|pulse| pulse.is_established()); + #[cfg(not(feature = "use_pulse"))] + let pulse_established = false; + #[cfg(feature = "use_pulse")] + let island_movement_over_livekit = self.livekit_movement_dual_channel || !pulse_established; + #[cfg(not(feature = "use_pulse"))] + let island_movement_over_livekit = true; + // Send to main room if available - let mut message_sent = if let Some(main_room) = &mut self.main_room { - let sent = main_room.send_rfc4(get_packet(), true); - if sent { - tracing::debug!("📡 Movement sent to main room"); + let mut message_sent = match &mut self.main_room { + Some(main_room) if island_movement_over_livekit => { + let sent = main_room.send_rfc4(get_packet(), true); + if sent { + tracing::debug!("📡 Movement sent to main room"); + } + sent } - sent - } else { - false + _ => false, }; - // Also send to scene room if available (dual broadcasting) + // Also send to scene room if available (dual broadcasting). Never gated by the + // dual-channel flag: scene auth-servers don't speak Pulse. #[cfg(feature = "use_livekit")] if let Some(scene_room) = &mut self.scene_room { let scene_sent = scene_room.send_rfc4(get_packet(), true); @@ -998,16 +1435,28 @@ impl CommunicationManager { // Also send through archipelago's adapter if available #[cfg(feature = "use_livekit")] - if let CommsConnection::Archipelago(archipelago) = &mut self.current_connection { - if let Some(adapter) = archipelago.adapter_as_mut() { - let sent = adapter.send_rfc4(get_packet(), true); - if sent { - tracing::debug!("📡 Movement also sent through archipelago"); - message_sent = true; + if island_movement_over_livekit { + if let CommsConnection::Archipelago(archipelago) = &mut self.current_connection { + if let Some(adapter) = archipelago.adapter_as_mut() { + let sent = adapter.send_rfc4(get_packet(), true); + if sent { + tracing::debug!("📡 Movement also sent through archipelago"); + message_sent = true; + } } } } + // Tee to Pulse (quantized PlayerStateInput, unreliable-sequenced). The 10Hz/1Hz cadence + // is inherited from broadcast_position.gd — under the server's 20Hz disconnect cap. + #[cfg(feature = "use_pulse")] + if pulse_established { + if let Some(pulse_room) = &mut self.pulse_room { + pulse_room.send_movement(&uncompressed_movement); + message_sent = true; + } + } + if message_sent { self.last_position_broadcast_index += 1; } @@ -1094,6 +1543,7 @@ impl CommunicationManager { message: Some(rfc4::packet::Message::Chat(rfc4::Chat { message: text.to_string(), timestamp: ole_timestamp_now(), + forwarded_from: None, })), protocol_version: DEFAULT_PROTOCOL_VERSION, }; @@ -1122,13 +1572,14 @@ impl CommunicationManager { )); self.last_emote_incremental_id += 1; - self.is_emoting = true; let packet = rfc4::Packet { message: Some(rfc4::packet::Message::PlayerEmote(rfc4::PlayerEmote { urn: emote_urn.to_string(), incremental_id: self.last_emote_incremental_id, timestamp: self.start_time.elapsed().as_secs_f32(), + is_stopping: None, + ..Default::default() })), protocol_version: DEFAULT_PROTOCOL_VERSION, }; @@ -1146,11 +1597,45 @@ impl CommunicationManager { sent = scene_room.send_rfc4(packet, false) || sent; } + // Pulse EmoteStart is deferred to set_emoting (actual playback start), NOT sent here: + // the trigger fires before the emote is async-loaded and idle-gated, and sending now + // means the per-frame animation poll reports "not emoting" next frame, chasing this + // with an EmoteStop that cancels it remotely before it ever plays (first-use emotes + // never propagated). Deferring also makes the PlayerState attached to the EmoteStart + // an idle one — Unity parks remote emote intents while the networked movement blend + // is > 0.1, so a press-time state (still decelerating) delays/loses the emote there. + #[cfg(feature = "use_pulse")] + { + self.pending_pulse_emote_urn = Some(emote_urn.to_string()); + } + sent } #[func] pub fn set_emoting(&mut self, emoting: bool) { + // Called every frame by the local avatar with its actual animation state. + #[cfg(feature = "use_pulse")] + { + if emoting { + // Playback is live — flush the deferred EmoteStart (see send_emote). Checked on + // every `true`, not just the false→true edge, so re-triggering while an emote is + // already playing (no edge) still announces the new emote. + if let Some(urn) = self.pending_pulse_emote_urn.take() { + if let Some(pulse_room) = &mut self.pulse_room { + pulse_room.send_emote_start(&urn); + } + } + } else if self.is_emoting { + // true→false: the local emote ended or was cancelled — reliable EmoteStop. Also + // load-bearing for one-shots: our EmoteStart carries no duration_ms, so the + // server never auto-completes them — without this stop the server ledger keeps + // us emoting forever and every later movement snapshot says is_emoting=true. + if let Some(pulse_room) = &mut self.pulse_room { + pulse_room.send_emote_stop(); + } + } + } self.is_emoting = emoting; } @@ -1372,22 +1857,28 @@ impl CommunicationManager { #[cfg(feature = "use_livekit")] "livekit" => { - // Ensure shared message processor is created - let processor_sender = self.ensure_message_processor(); + if !self.livekit_enabled() { + tracing::warn!("🔇 LiveKit disabled (pulse-only mode) — skipping main room"); + // Pulse and avatars still need the shared processor + let _ = self.ensure_message_processor(); + } else { + // Ensure shared message processor is created + let processor_sender = self.ensure_message_processor(); - // Create LiveKit room with shared message processor - // Main rooms use auto_subscribe: true (default) to automatically receive all peers - let mut livekit_room = LivekitRoom::new( - comms_address.to_string(), - format!("livekit-{}", comms_address), - ); - livekit_room.set_message_processor_sender(processor_sender); + // Create LiveKit room with shared message processor + // Main rooms use auto_subscribe: true (default) to automatically receive all peers + let mut livekit_room = LivekitRoom::new( + comms_address.to_string(), + format!("livekit-{}", comms_address), + ); + livekit_room.set_message_processor_sender(processor_sender); - // Store the room - no need to change connection type - self.main_room = Some(MainRoom::LiveKit(livekit_room)); + // Store the room - no need to change connection type + self.main_room = Some(MainRoom::LiveKit(livekit_room)); - // Announce initial profile to the room - self.announce_initial_profile(); + // Announce initial profile to the room + self.announce_initial_profile(); + } } #[cfg(not(feature = "use_livekit"))] @@ -1404,6 +1895,10 @@ impl CommunicationManager { tracing::debug!( "⚠️ Archipelago connections are disabled (DISABLE_ARCHIPELAGO = true)" ); + } else if !self.livekit_enabled() { + tracing::warn!("🔇 LiveKit disabled (pulse-only mode) — skipping archipelago"); + // Pulse and avatars still need the shared processor + let _ = self.ensure_message_processor(); } else { // Ensure we have a message processor let processor_sender = self.ensure_message_processor(); @@ -1449,6 +1944,14 @@ impl CommunicationManager { } }; + // Pulse rides alongside every real adapter (it has no adapter string of its own — + // endpoint is fixed/CLI-provided). Recreating it here gives realm changes a fresh + // handshake + TeleportRequest with the new realm name for free. + #[cfg(feature = "use_pulse")] + if protocol != "offline" { + self.ensure_pulse_room(); + } + let voice_chat_enabled = self.voice_chat_enabled.to_variant(); self.base_mut().emit_signal( "on_adapter_changed", @@ -1457,6 +1960,12 @@ impl CommunicationManager { } fn clean(&mut self) { + #[cfg(feature = "use_pulse")] + if let Some(mut pulse_room) = self.pulse_room.take() { + pulse_room.clean(); + self.pulse_teleport_pending = false; + } + match &mut self.current_connection { CommsConnection::None | CommsConnection::SignedLogin(_) @@ -1620,6 +2129,14 @@ impl CommunicationManager { scene_id, livekit_url, } => { + // An async gatekeeper result may land after a runtime disable — drop it + if !self.livekit_enabled() { + tracing::debug!( + "🔇 LiveKit disabled — dropping scene room connect for '{}'", + scene_id + ); + return; + } tracing::debug!( "🔌 Processing scene room connection request for scene '{}' with URL: {}", scene_id, @@ -1708,6 +2225,12 @@ impl CommunicationManager { return; } + // current_scene_id stays set above so re-enabling can reconnect_scene_room() + if !self.livekit_enabled() { + tracing::debug!("🔇 LiveKit disabled (pulse-only mode) — skipping scene room"); + return; + } + // Get player identity for signing let player_identity = DclGlobal::singleton().bind().get_player_identity(); let player_identity_bind = player_identity.bind(); @@ -1825,13 +2348,13 @@ impl CommunicationManager { } #[func] - pub fn set_livekit_debug(&mut self, enabled: bool) { - self.livekit_debug = enabled; + pub fn set_multiplayer_debug(&mut self, enabled: bool) { + self.multiplayer_debug = enabled; if !enabled { // Clear all avatar room debug labels let avatar_scene = DclGlobal::singleton().bind().get_avatars(); if let Some(processor) = &self.message_processor { - for (address, _) in processor.get_peer_room_info() { + for (address, _, _) in processor.get_peer_room_info() { let address_str = format!("{:#x}", address); let mut avatar_scene_ref = avatar_scene.clone(); avatar_scene_ref @@ -1846,8 +2369,8 @@ impl CommunicationManager { } #[func] - pub fn get_livekit_debug(&self) -> bool { - self.livekit_debug + pub fn get_multiplayer_debug(&self) -> bool { + self.multiplayer_debug } #[func] @@ -1858,7 +2381,25 @@ impl CommunicationManager { self.current_connection_str.to_variant(), ); - // Main room / archipelago status + let connection_state = match &self.current_connection { + CommsConnection::None => "none", + CommsConnection::WaitingForIdentity(_) => "waiting_identity", + CommsConnection::SignedLogin(_) => "signed_login", + #[cfg(feature = "use_livekit")] + CommsConnection::Archipelago(_) => "archipelago", + CommsConnection::Connected(_) => "connected", + }; + dict.set( + "connection_state".to_variant(), + connection_state.to_variant(), + ); + + dict.set( + "livekit_enabled".to_variant(), + self.is_livekit_enabled().to_variant(), + ); + + // Legacy coarse flag: a room struct exists (says nothing about the actual link) let main_connected = self.main_room.is_some() || matches!(&self.current_connection, CommsConnection::Connected(_)) || { @@ -1873,6 +2414,32 @@ impl CommunicationManager { }; dict.set("main_connected".to_variant(), main_connected.to_variant()); + let (main_room_type, main_room_state) = match &self.main_room { + Some(room) => (room.type_str(), room.connection_state_str()), + None => ("none", "none"), + }; + dict.set("main_room_type".to_variant(), main_room_type.to_variant()); + dict.set("main_room_state".to_variant(), main_room_state.to_variant()); + + let mut archipelago_state = "none"; + let mut island_id = String::new(); + let mut island_room_state = "none"; + #[cfg(feature = "use_livekit")] + if let CommsConnection::Archipelago(archipelago) = &self.current_connection { + archipelago_state = archipelago.state_name(); + island_id = archipelago.island_id().unwrap_or("").to_owned(); + island_room_state = archipelago.island_room_state(); + } + dict.set( + "archipelago_state".to_variant(), + archipelago_state.to_variant(), + ); + dict.set("island_id".to_variant(), island_id.to_variant()); + dict.set( + "island_room_state".to_variant(), + island_room_state.to_variant(), + ); + // Scene room status let scene_room_id = self.current_scene_id.clone().unwrap_or_default(); dict.set("scene_room".to_variant(), scene_room_id.to_variant()); @@ -1884,11 +2451,74 @@ impl CommunicationManager { { let scene_connected = self.scene_room.is_some(); dict.set("scene_connected".to_variant(), scene_connected.to_variant()); + let scene_room_state = self + .scene_room + .as_ref() + .map(|room| room.connection_state_str()) + .unwrap_or("none"); + dict.set( + "scene_room_state".to_variant(), + scene_room_state.to_variant(), + ); } #[cfg(not(feature = "use_livekit"))] { dict.set("scene_connected".to_variant(), false.to_variant()); + dict.set("scene_room_state".to_variant(), "unavailable".to_variant()); } + + #[cfg(feature = "use_pulse")] + { + let global = DclGlobal::singleton(); + let cli = global.bind().cli.clone(); + let cli = cli.bind(); + let pulse_enabled = self.pulse_runtime_enabled.unwrap_or(cli.pulse); + let pulse_state = if self.pulse_disabled_for_session { + "disabled_for_session" + } else if let Some(pulse_room) = &self.pulse_room { + pulse_room.state_name() + } else { + "off" + }; + // The room's actual endpoint when it exists; what a rebuild would resolve otherwise + let pulse_endpoint = match &self.pulse_room { + Some(pulse_room) => pulse_room.endpoint(), + None => { + let (host, port) = self.resolve_pulse_endpoint(&cli); + format!("{host}:{port}") + } + }; + dict.set("pulse_available".to_variant(), true.to_variant()); + dict.set("pulse_enabled".to_variant(), pulse_enabled.to_variant()); + dict.set("pulse_state".to_variant(), pulse_state.to_variant()); + dict.set("pulse_endpoint".to_variant(), pulse_endpoint.to_variant()); + dict.set( + "pulse_failures".to_variant(), + (self.pulse_session_failures as i64).to_variant(), + ); + dict.set( + "pulse_disabled_for_session".to_variant(), + self.pulse_disabled_for_session.to_variant(), + ); + dict.set( + "dual_channel".to_variant(), + self.livekit_movement_dual_channel.to_variant(), + ); + } + #[cfg(not(feature = "use_pulse"))] + { + dict.set("pulse_available".to_variant(), false.to_variant()); + dict.set("pulse_enabled".to_variant(), false.to_variant()); + dict.set("pulse_state".to_variant(), "unavailable".to_variant()); + dict.set("pulse_endpoint".to_variant(), "".to_variant()); + dict.set("pulse_failures".to_variant(), 0i64.to_variant()); + dict.set( + "pulse_disabled_for_session".to_variant(), + false.to_variant(), + ); + dict.set("dual_channel".to_variant(), true.to_variant()); + } + dict } @@ -1896,11 +2526,12 @@ impl CommunicationManager { pub fn get_debug_peer_rooms(&self) -> VarArray { let mut arr = VarArray::new(); if let Some(processor) = &self.message_processor { - for (address, rooms) in processor.get_peer_room_info() { + for (address, rooms, name) in processor.get_peer_room_info() { let mut dict = VarDictionary::new(); let address_str = format!("{:#x}", address); dict.set("address".to_variant(), address_str.to_variant()); dict.set("rooms".to_variant(), rooms.to_variant()); + dict.set("name".to_variant(), name.to_variant()); arr.push(&dict.to_variant()); } } diff --git a/lib/src/comms/consts.rs b/lib/src/comms/consts.rs index 743863aba..8703da45e 100644 --- a/lib/src/comms/consts.rs +++ b/lib/src/comms/consts.rs @@ -1,11 +1,9 @@ /// Get the gatekeeper URL (transformed based on environment) -#[cfg(feature = "use_livekit")] pub fn gatekeeper_url() -> String { crate::urls::comms_gatekeeper() } /// Get the local/preview gatekeeper URL (transformed based on environment) -#[cfg(feature = "use_livekit")] pub fn gatekeeper_url_local() -> String { crate::urls::comms_gatekeeper_local() } @@ -34,6 +32,17 @@ pub const PROFILE_REQUEST_INTERVAL_SECS: f32 = 10.0; // Protocol version pub const DEFAULT_PROTOCOL_VERSION: u32 = 100; +// Pulse (ENet avatar-state relay) game port. The endpoint host comes from +// crate::urls::pulse_server(), overridable via --pulse-server / PULSE_SERVER. +#[cfg(feature = "use_pulse")] +pub const PULSE_SERVER_PORT: u16 = 7777; + +/// The `IncomingMessage::room_id` for everything bridged from Pulse. A load-bearing room id +/// (like the `"scene-"`/`"livekit-"` prefixes): MessageProcessor keys the per-peer transport +/// preference and the inactivity-sweep exemption on it. Unconditional (not `use_pulse`-gated) +/// so MessageProcessor's gate logic compiles in every feature combination. +pub const PULSE_ROOM_ID: &str = "pulse"; + /// Truncates a string to at most `max_bytes` while respecting UTF-8 character boundaries. /// Returns the original string if it's already within the limit. pub fn truncate_utf8_safe(s: &str, max_bytes: usize) -> &str { diff --git a/lib/src/comms/mod.rs b/lib/src/comms/mod.rs index 12a104616..456988f49 100644 --- a/lib/src/comms/mod.rs +++ b/lib/src/comms/mod.rs @@ -3,6 +3,8 @@ pub mod communication_manager; mod consts; pub use consts::truncate_utf8_safe; pub mod profile; +#[cfg(feature = "use_pulse")] +pub mod pulse; pub mod randomize_profile; pub mod signed_login; pub mod voice_chat; diff --git a/lib/src/comms/pulse/decoder.rs b/lib/src/comms/pulse/decoder.rs new file mode 100644 index 000000000..7ac71869c --- /dev/null +++ b/lib/src/comms/pulse/decoder.rs @@ -0,0 +1,1011 @@ +//! Pulse decode + per-subject sliding-window state. +//! Ported from bevy-explorer `crates/comms/src/pulse/mod.rs` @ 3f65c164 (adaptations: godot +//! `Vector3` instead of bevy `Vec3`; no `position_precision` / `scene_driven_animation` — our +//! vendored rfc4 predates those fields; teleports snap via `is_instant` set at bridge time). +//! +//! Reconstructs each subject's full state into an [`rfc4::Movement`], so that everything +//! downstream (`MessageProcessor` → `AvatarScene` transform updates) is reused verbatim — the +//! rest of the client never learns Pulse exists below the `IncomingMessage` line. +//! +//! Two contracts are agreed out-of-band, not on the wire: +//! * the quantization ABI (linear `{min,max,bits}` or power-law `{max,pow,bits}` per field) +//! — handled by the `*_dequantized()` accessors generated from the proto descriptor by +//! `build_quant.rs`; +//! * the parcel grid ([`PulseParcelGrid`]) — the server's `ParcelEncoder` config, which maps a +//! `parcel_index` + in-parcel local position back to world coordinates. +//! +//! The animation rider that rides on LiveKit `Movement` packets has no Pulse equivalent; it keeps +//! arriving over LiveKit and converges on the same wallet address. + +use std::collections::HashMap; + +use ethers_core::types::H160; +use godot::prelude::Vector3; + +use crate::auth::wallet::AsH160; +use crate::dcl::components::proto_components::{kernel::comms::rfc4, pulse}; + +/// World ↔ parcel mapping, mirroring the server's `ParcelEncoder`. The server folds `Padding` into +/// the bounds, so we recompute `min`/`width` exactly the same way rather than pre-baking a width — +/// a padding change on the server can't silently desync the decode. +/// +/// These values are a per-server-instance deployment constant (one global grid shared across all +/// realms; `realm` only partitions visibility), not realm-derived and not transmitted. +#[derive(Debug, Clone, Copy)] +pub struct PulseParcelGrid { + min_x: i32, + min_z: i32, + width: i32, + height: i32, + parcel_size: i32, +} + +impl PulseParcelGrid { + /// Takes the server's full `ParcelEncoder` option set (1:1) so deployments can be configured + /// straight from the instance's `appsettings`. + pub fn new( + min_parcel_x: i32, + min_parcel_z: i32, + max_parcel_x: i32, + max_parcel_z: i32, + padding: i32, + parcel_size: i32, + ) -> Self { + let min_x = min_parcel_x - padding; + let min_z = min_parcel_z - padding; + let max_x = max_parcel_x + padding; + let max_z = max_parcel_z + padding; + let width = max_x - min_x + 1; + let height = max_z - min_z + 1; + Self { + min_x, + min_z, + width, + height, + parcel_size, + } + } + + /// Inverse of the server's `ParcelEncoder.DecodeToGlobalPosition`. `local` is the in-parcel + /// offset (DCL world convention; the render-frame flip happens later in `AvatarScene`). + pub fn decode_to_world(&self, parcel_index: i32, local: Vector3) -> Vector3 { + let x = parcel_index.rem_euclid(self.width) + self.min_x; + let z = parcel_index.div_euclid(self.width) + self.min_z; + Vector3::new( + (x * self.parcel_size) as f32 + local.x, + local.y, + (z * self.parcel_size) as f32 + local.z, + ) + } + + /// Inverse of [`Self::decode_to_world`]: a world position (DCL convention) → the server's + /// `parcel_index` plus the in-parcel local offset, matching the server's `ParcelEncoder.Encode` + /// + relative-position split. Used to build our own `TeleportRequest` / `PlayerStateInput`. + pub fn encode_to_parcel(&self, world: Vector3) -> (i32, Vector3) { + let size = self.parcel_size as f32; + let parcel_x = (world.x / size).floor() as i32; + let parcel_z = (world.z / size).floor() as i32; + let parcel_index = (parcel_x - self.min_x) + (parcel_z - self.min_z) * self.width; + let local = Vector3::new( + world.x - (parcel_x * self.parcel_size) as f32, + world.y, + world.z - (parcel_z * self.parcel_size) as f32, + ); + (parcel_index, local) + } + + /// Whether a world position (DCL convention) falls inside the grid. Out-of-grid positions + /// (some Worlds/preview realms) must never be sent — the server disconnects with a terminal + /// `InvalidTeleportField`/`InvalidInputField` code on an invalid parcel index. + pub fn contains_world(&self, world: Vector3) -> bool { + let size = self.parcel_size as f32; + let parcel_x = (world.x / size).floor() as i32; + let parcel_z = (world.z / size).floor() as i32; + parcel_x >= self.min_x + && parcel_x < self.min_x + self.width + && parcel_z >= self.min_z + && parcel_z < self.min_z + self.height + } +} + +impl Default for PulseParcelGrid { + /// Server `appsettings.json` defaults (Genesis-City-sized bounding grid). Override per + /// deployment with the target instance's `ParcelEncoder` section. + fn default() -> Self { + Self::new(-150, -150, 163, 158, 2, 16) + } +} + +/// Result of feeding one [`pulse::ServerMessage`] to the decoder. `PulseRoom` bridges `Movement`/ +/// `Joined`/`Left`/`ProfileVersion`/emotes into `IncomingMessage`s for the shared +/// `MessageProcessor`, and transmits `Resync` reliably back to the server. +#[derive(Debug)] +pub enum PulseEvent { + /// Handshake ack from the server. `success == false` carries the rejection reason. + Connected { + success: bool, + error: Option, + }, + /// Reconstructed full movement state for a subject, ready to push through the rfc4 pipeline. + /// `teleport` marks state from a `TeleportPerformed` — the bridge sets `is_instant` so the + /// avatar snaps to it instead of interpolating, since it's a discontinuous reposition. + Movement { + address: H160, + movement: Box, + teleport: bool, + }, + /// Subject entered the interest set (or connected). Establishes the subject↔wallet alias. + Joined { + subject_id: u32, + address: H160, + profile_version: i32, + }, + /// Subject left the interest set (or disconnected). Drop the alias / foreign player. + Left { address: H160 }, + /// Subject announced a new profile version. + ProfileVersion { address: H160, version: i32 }, + /// Subject started an emote. Emitted alongside the piggybacked `Movement`. `tick` is the + /// server tick, used downstream only as a monotonic id so re-triggering the same urn replays. + EmoteStart { + address: H160, + urn: String, + tick: u32, + }, + /// Subject's emote stopped (one-shot completed or looping cancelled). + EmoteStop { address: H160 }, + /// A sequence gap was detected — transmit this reliably so the server replays full state. + Resync(pulse::ResyncRequest), +} + +/// Per-subject baseline. Pulse sends field-masked deltas against the last sequence we acked, so we +/// keep the last reconstructed full state and overlay deltas onto it. +struct Subject { + wallet: H160, + last_seq: u32, + baseline: SubjectState, +} + +/// A subject's reconstructed full state, in floats. Both full [`pulse::PlayerState`] snapshots and +/// field-masked deltas dequantize into this — each field via its own proto-derived accessor — so the +/// baseline is independent of the wire quantization grid. `to_movement` then reads it directly. +#[derive(Debug, Clone, Default)] +struct SubjectState { + parcel_index: i32, + /// In-parcel local position (world altitude in `y`); parcel-decoded to world in `to_movement`. + position: Vector3, + velocity: Vector3, + rotation_y: f32, + movement_blend: f32, + slide_blend: f32, + head_yaw: Option, + head_pitch: Option, + state_flags: u32, + /// Raw `GlideState` discriminant (shared verbatim with rfc4's identical enum). + glide_state: i32, + jump_count: i32, + /// Absolute world point-at target; only meaningful when `POINTING_AT` is set in `state_flags`. + point_at: Vector3, +} + +impl SubjectState { + /// Dequantize a wire full state into the float baseline. + fn from_player_state(s: &pulse::PlayerState) -> Self { + Self { + parcel_index: s.parcel_index, + position: Vector3::new( + s.position_x_dequantized(), + s.position_y_dequantized(), + s.position_z_dequantized(), + ), + velocity: Vector3::new( + s.velocity_x_dequantized(), + s.velocity_y_dequantized(), + s.velocity_z_dequantized(), + ), + rotation_y: s.rotation_y_dequantized(), + movement_blend: s.movement_blend_dequantized(), + slide_blend: s.slide_blend_dequantized(), + head_yaw: s.head_yaw_dequantized(), + head_pitch: s.head_pitch_dequantized(), + state_flags: s.state_flags, + glide_state: s.glide_state, + jump_count: s.jump_count, + point_at: Vector3::new( + s.point_at_x_dequantized().unwrap_or_default(), + s.point_at_y_dequantized().unwrap_or_default(), + s.point_at_z_dequantized().unwrap_or_default(), + ), + } + } + + /// Overlay a field-masked delta. Present fields replace (dequantized via the delta's own + /// accessors); absent fields carry forward. Discrete fields (parcel, flags, glide, jump) are + /// plain; position/velocity/head/point-at are quantized. + fn apply_delta(&mut self, delta: &pulse::PlayerStateDeltaTier0) { + if let Some(parcel) = delta.parcel_index { + self.parcel_index = parcel; + } + if let Some(x) = delta.position_x_dequantized() { + self.position.x = x; + } + if let Some(y) = delta.position_y_dequantized() { + self.position.y = y; + } + if let Some(z) = delta.position_z_dequantized() { + self.position.z = z; + } + if let Some(x) = delta.velocity_x_dequantized() { + self.velocity.x = x; + } + if let Some(y) = delta.velocity_y_dequantized() { + self.velocity.y = y; + } + if let Some(z) = delta.velocity_z_dequantized() { + self.velocity.z = z; + } + if let Some(rotation_y) = delta.rotation_y_dequantized() { + self.rotation_y = rotation_y; + } + if let Some(movement_blend) = delta.movement_blend_dequantized() { + self.movement_blend = movement_blend; + } + if let Some(slide_blend) = delta.slide_blend_dequantized() { + self.slide_blend = slide_blend; + } + if let Some(head_yaw) = delta.head_yaw_dequantized() { + self.head_yaw = Some(head_yaw); + } + if let Some(head_pitch) = delta.head_pitch_dequantized() { + self.head_pitch = Some(head_pitch); + } + if let Some(state_flags) = delta.state_flags { + self.state_flags = state_flags; + } + if let Some(glide_state) = delta.glide_state { + self.glide_state = glide_state; + } + if let Some(jump_count) = delta.jump_count { + self.jump_count = jump_count; + } + if let Some(x) = delta.point_at_x_dequantized() { + self.point_at.x = x; + } + if let Some(y) = delta.point_at_y_dequantized() { + self.point_at.y = y; + } + if let Some(z) = delta.point_at_z_dequantized() { + self.point_at.z = z; + } + } +} + +/// Owns all Pulse-specific sliding-window state. Single-threaded; lives inside [`PulseRoom`]. +/// +/// [`PulseRoom`]: super::pulse_room::PulseRoom +pub struct PulseDecoder { + grid: PulseParcelGrid, + subjects: HashMap, +} + +impl PulseDecoder { + pub fn new(grid: PulseParcelGrid) -> Self { + Self { + grid, + subjects: HashMap::new(), + } + } + + /// Wallets of every subject currently in the interest set. Used by `PulseRoom::clean()` to + /// flood synthetic `PeerLeft`s on teardown, so LiveKit-driven rendering resumes immediately. + pub fn known_wallets(&self) -> Vec { + self.subjects.values().map(|s| s.wallet).collect() + } + + /// Decode one server message, advancing per-subject state and emitting downstream events. + pub fn handle(&mut self, msg: pulse::ServerMessage) -> Vec { + use pulse::server_message::Message; + + let Some(message) = msg.message else { + return Vec::new(); + }; + + match message { + Message::Handshake(h) => vec![PulseEvent::Connected { + success: h.success, + error: h.error, + }], + Message::PlayerJoined(j) => self.on_joined(j), + Message::PlayerLeft(l) => self.on_left(l.subject_id), + Message::PlayerStateFull(f) => { + self.on_full(f.subject_id, f.sequence, f.server_tick, f.state, false) + } + // Teleport / emote start / stop all piggyback full state; treat them as a full refresh + // so the subject's position never goes stale, then (for emotes) emit the emote event so + // the avatar pipeline plays/stops it. Order: movement first so the position is current + // before the emote starts. Teleport is flagged so the avatar snaps rather than + // interpolates across the jump. + Message::Teleported(t) => { + self.on_full(t.subject_id, t.sequence, t.server_tick, t.state, true) + } + Message::EmoteStarted(e) => { + let mut events = self.on_full( + e.subject_id, + e.sequence, + e.server_tick, + e.player_state, + false, + ); + if let Some(subject) = self.subjects.get(&e.subject_id) { + events.push(PulseEvent::EmoteStart { + address: subject.wallet, + urn: e.emote_id, + tick: e.server_tick, + }); + } + events + } + Message::EmoteStopped(e) => { + let mut events = self.on_full( + e.subject_id, + e.sequence, + e.server_tick, + e.player_state, + false, + ); + if let Some(subject) = self.subjects.get(&e.subject_id) { + events.push(PulseEvent::EmoteStop { + address: subject.wallet, + }); + } + events + } + Message::PlayerStateDelta(d) => self.on_delta(d), + Message::PlayerProfileVersionAnnounced(p) => self.on_profile(p.subject_id, p.version), + } + } + + fn on_joined(&mut self, joined: pulse::PlayerJoined) -> Vec { + let Some(full) = joined.state else { + return Vec::new(); + }; + let Some(state) = full.state else { + return Vec::new(); + }; + let Some(address) = joined.user_id.as_h160() else { + tracing::warn!( + "pulse: PlayerJoined with unparseable user_id {:?}", + joined.user_id + ); + return Vec::new(); + }; + + let baseline = SubjectState::from_player_state(&state); + let movement = self.to_movement(&baseline, full.server_tick); + self.subjects.insert( + full.subject_id, + Subject { + wallet: address, + last_seq: full.sequence, + baseline, + }, + ); + + vec![ + PulseEvent::Joined { + subject_id: full.subject_id, + address, + profile_version: joined.profile_version, + }, + PulseEvent::Movement { + address, + movement: Box::new(movement), + // A peer entering the interest set has no prior position to interpolate + // from — snap (is_instant). Lerping from the avatar's previous/default + // target makes it sprint across the scene, and the delta-derived run flag + // then latches until the next update (never, for a stationary peer). + teleport: true, + }, + ] + } + + fn on_left(&mut self, subject_id: u32) -> Vec { + match self.subjects.remove(&subject_id) { + Some(subject) => vec![PulseEvent::Left { + address: subject.wallet, + }], + None => Vec::new(), + } + } + + /// Replace a known subject's baseline from a server-supplied full state. Unknown subjects are + /// dropped: full state carries no wallet, so without a prior `PlayerJoined` we can't address it. + fn on_full( + &mut self, + subject_id: u32, + sequence: u32, + server_tick: u32, + state: Option, + teleport: bool, + ) -> Vec { + let Some(state) = state else { + return Vec::new(); + }; + let Some(subject) = self.subjects.get_mut(&subject_id) else { + return Vec::new(); + }; + + subject.baseline = SubjectState::from_player_state(&state); + subject.last_seq = sequence; + let address = subject.wallet; + let movement = self.to_movement_for(subject_id, server_tick); + vec![PulseEvent::Movement { + address, + movement: Box::new(movement), + teleport, + }] + } + + fn on_delta(&mut self, delta: pulse::PlayerStateDeltaTier0) -> Vec { + let Some(subject) = self.subjects.get_mut(&delta.subject_id) else { + // No baseline yet — ask the server for full state (known_seq 0 = "I have nothing"). + return vec![PulseEvent::Resync(pulse::ResyncRequest { + subject_id: delta.subject_id, + known_seq: 0, + })]; + }; + + // Already have this (e.g. a reliable resync retransmit of a seq we applied unreliably). + if delta.new_seq <= subject.last_seq { + return Vec::new(); + } + + // The delta is diffed from `baseline_seq`; we can only apply it if our state is exactly + // that sequence. Otherwise we missed an intermediate delta — resync from what we have. + if delta.baseline_seq != subject.last_seq { + return vec![PulseEvent::Resync(pulse::ResyncRequest { + subject_id: delta.subject_id, + known_seq: subject.last_seq, + })]; + } + + subject.baseline.apply_delta(&delta); + subject.last_seq = delta.new_seq; + let address = subject.wallet; + let movement = self.to_movement_for(delta.subject_id, delta.server_tick); + vec![PulseEvent::Movement { + address, + movement: Box::new(movement), + teleport: false, + }] + } + + fn on_profile(&self, subject_id: u32, version: i32) -> Vec { + match self.subjects.get(&subject_id) { + Some(subject) => vec![PulseEvent::ProfileVersion { + address: subject.wallet, + version, + }], + None => Vec::new(), + } + } + + /// Convenience: convert an already-stored subject baseline. + fn to_movement_for(&self, subject_id: u32, server_tick: u32) -> rfc4::Movement { + self.to_movement(&self.subjects[&subject_id].baseline, server_tick) + } + + /// Reconstruct an `rfc4::Movement` from a subject's float baseline. Position is parcel-decoded + /// to world; `state_flags` is unpacked into the rfc4 bool fields; the animation rider arrives + /// over LiveKit instead. + fn to_movement(&self, state: &SubjectState, server_tick: u32) -> rfc4::Movement { + let world = self + .grid + .decode_to_world(state.parcel_index, state.position); + let velocity = state.velocity; + let point_at = state.point_at; + let flags = state.state_flags; + + rfc4::Movement { + // Pulse has no client-side send timestamp; the unified server clock (ms→s) is + // monotonic per subject, which is what the per-avatar dedup needs. NEVER comparable + // with LiveKit movement timestamps (sender clock) — the transport-preference gate in + // MessageProcessor is what keeps the two sources from being compared. + timestamp: server_tick as f32 / 1000.0, + position_x: world.x, + position_y: world.y, + position_z: world.z, + velocity_x: velocity.x, + velocity_y: velocity.y, + velocity_z: velocity.z, + movement_blend_value: state.movement_blend, + slide_blend_value: state.slide_blend, + is_grounded: flag(flags, pulse::PlayerAnimationFlags::Grounded), + // No Pulse equivalent: single jumps are inferred downstream from jump_count / velocity. + is_jumping: false, + is_long_jump: flag(flags, pulse::PlayerAnimationFlags::LongJump), + is_long_fall: flag(flags, pulse::PlayerAnimationFlags::LongFall), + is_falling: flag(flags, pulse::PlayerAnimationFlags::Falling), + is_stunned: flag(flags, pulse::PlayerAnimationFlags::Stunned), + rotation_y: state.rotation_y, + // Set by the bridge for teleport-derived movements; never by the decoder. + is_instant: false, + is_emoting: false, + jump_count: state.jump_count, + // Pulse GlideState and rfc4 GlideState share identical enum values. + glide_state: state.glide_state, + point_at_x: point_at.x, + point_at_y: point_at.y, + point_at_z: point_at.z, + is_pointing_at: flag(flags, pulse::PlayerAnimationFlags::PointingAt), + head_ik_yaw_enabled: flag(flags, pulse::PlayerAnimationFlags::HeadYaw), + head_ik_pitch_enabled: flag(flags, pulse::PlayerAnimationFlags::HeadPitch), + // Head angles are quantized over the unsigned [0, 360] range, so the sender wraps + // negatives (via `rem_euclid`); undo that to the signed range the head-IK consumer + // expects (pitch especially — it's clamped to ±cone, not wrapped). + head_yaw: state.head_yaw.map(signed_angle).unwrap_or(0.0), + head_pitch: state.head_pitch.map(signed_angle).unwrap_or(0.0), + } + } +} + +fn flag(flags: u32, f: pulse::PlayerAnimationFlags) -> bool { + flags & (f as u32) != 0 +} + +/// Map a dequantized head angle from the wire's unsigned [0, 360) range back to the signed +/// (-180, 180] range the head-IK consumer works in. Inverse of the sender's `rem_euclid(360)`. +fn signed_angle(deg: f32) -> f32 { + if deg > 180.0 { + deg - 360.0 + } else { + deg + } +} + +/// Inverse of [`PulseDecoder::to_movement`]: pack a locally-built [`rfc4::Movement`] into the Pulse +/// [`PlayerState`] we send. World position is split into `parcel_index` + in-parcel local; the rfc4 +/// bool fields are folded back into `state_flags`; head yaw/pitch and point-at ride only when their +/// enable flag is set (matching Unity's `WritePlayerState`). Fields with no Pulse equivalent +/// (`is_jumping`, `is_emoting`) are dropped — animation stays on LiveKit. +pub fn from_movement(movement: &rfc4::Movement, grid: &PulseParcelGrid) -> pulse::PlayerState { + let world = Vector3::new( + movement.position_x, + movement.position_y, + movement.position_z, + ); + let (parcel_index, local) = grid.encode_to_parcel(world); + + let mut state_flags = 0u32; + let mut set = |on: bool, f: pulse::PlayerAnimationFlags| { + if on { + state_flags |= f as u32; + } + }; + set(movement.is_grounded, pulse::PlayerAnimationFlags::Grounded); + set(movement.is_long_jump, pulse::PlayerAnimationFlags::LongJump); + set(movement.is_long_fall, pulse::PlayerAnimationFlags::LongFall); + set(movement.is_falling, pulse::PlayerAnimationFlags::Falling); + set(movement.is_stunned, pulse::PlayerAnimationFlags::Stunned); + set( + movement.head_ik_yaw_enabled, + pulse::PlayerAnimationFlags::HeadYaw, + ); + set( + movement.head_ik_pitch_enabled, + pulse::PlayerAnimationFlags::HeadPitch, + ); + set( + movement.is_pointing_at, + pulse::PlayerAnimationFlags::PointingAt, + ); + + use pulse::PlayerState as P; + pulse::PlayerState { + parcel_index, + position_x: P::position_x_quantized(local.x), + position_y: P::position_y_quantized(local.y), + position_z: P::position_z_quantized(local.z), + velocity_x: P::velocity_x_quantized(movement.velocity_x), + velocity_y: P::velocity_y_quantized(movement.velocity_y), + velocity_z: P::velocity_z_quantized(movement.velocity_z), + // Quantized over [0, 360] with clamping (Unity: Quantize.Encode) — wrap signed degrees + // so a negative yaw doesn't clamp to 0 (Unity sends transform.eulerAngles.y, already + // [0, 360); rfc4 receivers tolerate signed values, the Pulse quantizer does not). + rotation_y: P::rotation_y_quantized(movement.rotation_y.rem_euclid(360.0)), + movement_blend: P::movement_blend_quantized(movement.movement_blend_value), + slide_blend: P::slide_blend_quantized(movement.slide_blend_value), + // Head angles quantize over the unsigned [0, 360] range; wrap negatives so a left/up look + // (negative yaw/pitch) doesn't clamp to 0. The receiver undoes this via `signed_angle`. + head_yaw: movement + .head_ik_yaw_enabled + .then(|| P::head_yaw_quantized(movement.head_yaw.rem_euclid(360.0))), + head_pitch: movement + .head_ik_pitch_enabled + .then(|| P::head_pitch_quantized(movement.head_pitch.rem_euclid(360.0))), + state_flags, + glide_state: movement.glide_state, + jump_count: movement.jump_count, + point_at_x: movement + .is_pointing_at + .then(|| P::point_at_x_quantized(movement.point_at_x)), + point_at_y: movement + .is_pointing_at + .then(|| P::point_at_y_quantized(movement.point_at_y)), + point_at_z: movement + .is_pointing_at + .then(|| P::point_at_z_quantized(movement.point_at_z)), + } +} + +// Ported from bevy-explorer `crates/comms/src/pulse/test.rs` @ 3f65c164. +#[cfg(test)] +mod tests { + use super::*; + + const SUBJECT: u32 = 42; + const WALLET: &str = "0x0000000000000000000000000000000000000001"; + + // Parcel (10, 20) under the default grid: min_x = min_z = -150 - 2 = -152, width = 318. + // index = (10 - -152) + (20 - -152) * 318 = 162 + 172*318 = 54858. World base = (160, _, 320). + const PARCEL_INDEX: i32 = 54858; + + fn wallet() -> H160 { + WALLET.as_h160().unwrap() + } + + fn server_msg(message: pulse::server_message::Message) -> pulse::ServerMessage { + pulse::ServerMessage { + message: Some(message), + } + } + + fn player_state(local: (f32, f32, f32), flags: u32) -> pulse::PlayerState { + pulse::PlayerState { + parcel_index: PARCEL_INDEX, + position_x: pulse::PlayerState::position_x_quantized(local.0), + position_y: pulse::PlayerState::position_y_quantized(local.1), + position_z: pulse::PlayerState::position_z_quantized(local.2), + state_flags: flags, + ..Default::default() + } + } + + fn joined(sequence: u32, local: (f32, f32, f32), flags: u32) -> pulse::server_message::Message { + pulse::server_message::Message::PlayerJoined(pulse::PlayerJoined { + user_id: WALLET.to_string(), + profile_version: 7, + state: Some(pulse::PlayerStateFull { + subject_id: SUBJECT, + sequence, + server_tick: 1000, + state: Some(player_state(local, flags)), + }), + realm: "main".to_owned(), + }) + } + + fn only_movement(events: Vec) -> rfc4::Movement { + let mut found = None; + for event in events { + if let PulseEvent::Movement { movement, .. } = event { + assert!(found.is_none(), "expected exactly one Movement event"); + found = Some(*movement); + } + } + found.expect("no Movement event") + } + + fn approx(a: f32, b: f32) { + assert!((a - b).abs() < 0.05, "expected ~{b}, got {a}"); + } + + #[test] + fn parcel_decode_matches_server_scheme() { + let grid = PulseParcelGrid::default(); + let world = grid.decode_to_world(PARCEL_INDEX, Vector3::new(1.0, 2.0, 3.0)); + approx(world.x, 161.0); // 10*16 + 1 + approx(world.y, 2.0); + approx(world.z, 323.0); // 20*16 + 3 + } + + #[test] + fn parcel_encode_roundtrips_and_bounds_check_works() { + let grid = PulseParcelGrid::default(); + let world = Vector3::new(161.0, 2.0, 323.0); + let (index, local) = grid.encode_to_parcel(world); + assert_eq!(index, PARCEL_INDEX); + approx(local.x, 1.0); + approx(local.z, 3.0); + assert!(grid.contains_world(world)); + // Well outside the padded Genesis grid (parcel x > 163 + 2). + assert!(!grid.contains_world(Vector3::new(10_000.0, 0.0, 0.0))); + assert!(!grid.contains_world(Vector3::new(0.0, 0.0, -3000.0))); + } + + #[test] + fn join_emits_alias_and_world_movement() { + let mut decoder = PulseDecoder::new(PulseParcelGrid::default()); + let grounded = pulse::PlayerAnimationFlags::Grounded as u32; + let events = decoder.handle(server_msg(joined(5, (1.0, 2.0, 3.0), grounded))); + + let join = events + .iter() + .find_map(|e| match e { + PulseEvent::Joined { + subject_id, + address, + profile_version, + } => Some((*subject_id, *address, *profile_version)), + _ => None, + }) + .expect("no Joined event"); + assert_eq!(join, (SUBJECT, wallet(), 7)); + + // Joins must snap (teleport → is_instant): there is no prior position to lerp from, + // and a non-instant first update latches a bogus run flag on a stationary peer. + assert!( + events + .iter() + .any(|e| matches!(e, PulseEvent::Movement { teleport: true, .. })), + "join Movement must carry teleport=true" + ); + + let movement = only_movement(events); + approx(movement.position_x, 161.0); + approx(movement.position_z, 323.0); + assert!(movement.is_grounded); + } + + #[test] + fn in_sequence_delta_applies_and_dequantizes() { + let mut decoder = PulseDecoder::new(PulseParcelGrid::default()); + decoder.handle(server_msg(joined(5, (1.0, 2.0, 3.0), 0))); + + // position_x is local, 8 bits over [0,16]; encoded 128 → 128/255*16 ≈ 8.031. + let delta = pulse::PlayerStateDeltaTier0 { + subject_id: SUBJECT, + baseline_seq: 5, + new_seq: 6, + server_tick: 1033, + position_x: Some(128), + ..Default::default() + }; + let movement = only_movement(decoder.handle(server_msg( + pulse::server_message::Message::PlayerStateDelta(delta), + ))); + + // local x replaced (8.031), y/z carried forward from the join (2, 3); parcel base (160, 320). + approx(movement.position_x, 168.031); + approx(movement.position_y, 2.0); + approx(movement.position_z, 323.0); + } + + #[test] + fn rotation_round_trips_signed_yaw() { + // rotation_y quantizes over [0, 360] with clamping (Unity Quantize.Encode parity). + // A signed rfc4 yaw (e.g. -90°) must wrap to its [0, 360) equivalent (270°), not + // clamp to 0 — clamping made every negative heading face the same direction on Unity. + let grid = PulseParcelGrid::default(); + let movement = rfc4::Movement { + position_x: 161.0, + position_y: 2.0, + position_z: 323.0, + rotation_y: -90.0, + ..Default::default() + }; + let state = from_movement(&movement, &grid); + + let mut decoder = PulseDecoder::new(grid); + let out = only_movement(decoder.handle(server_msg( + pulse::server_message::Message::PlayerJoined(pulse::PlayerJoined { + user_id: WALLET.to_string(), + profile_version: 1, + state: Some(pulse::PlayerStateFull { + subject_id: SUBJECT, + sequence: 1, + server_tick: 1000, + state: Some(state), + }), + realm: "main".to_owned(), + }), + ))); + + // 7 bits over 360° ≈ 2.83°/step. + assert!( + (out.rotation_y - 270.0).abs() < 3.0, + "rotation_y {}", + out.rotation_y + ); + } + + #[test] + fn head_angles_round_trip_signed() { + // A left/up look: negative yaw and pitch. Head angles quantize over the unsigned [0, 360] + // range, so without the sender's `rem_euclid` wrap + receiver's `signed_angle` unwrap these + // would clamp to 0 — only right/down (positive) would survive. + let grid = PulseParcelGrid::default(); + let movement = rfc4::Movement { + position_x: 161.0, + position_y: 2.0, + position_z: 323.0, + head_ik_yaw_enabled: true, + head_ik_pitch_enabled: true, + head_yaw: -30.0, + head_pitch: -20.0, + ..Default::default() + }; + let state = from_movement(&movement, &grid); + + let mut decoder = PulseDecoder::new(grid); + let out = only_movement(decoder.handle(server_msg( + pulse::server_message::Message::PlayerJoined(pulse::PlayerJoined { + user_id: WALLET.to_string(), + profile_version: 1, + state: Some(pulse::PlayerStateFull { + subject_id: SUBJECT, + sequence: 1, + server_tick: 1000, + state: Some(state), + }), + realm: "main".to_owned(), + }), + ))); + + assert!(out.head_ik_yaw_enabled && out.head_ik_pitch_enabled); + // 7 bits over 360° ≈ 2.83°/step. + assert!((out.head_yaw - (-30.0)).abs() < 3.0, "yaw {}", out.head_yaw); + assert!( + (out.head_pitch - (-20.0)).abs() < 3.0, + "pitch {}", + out.head_pitch + ); + } + + #[test] + fn sequence_gap_requests_resync_without_applying() { + let mut decoder = PulseDecoder::new(PulseParcelGrid::default()); + decoder.handle(server_msg(joined(5, (1.0, 2.0, 3.0), 0))); + + // baseline_seq 4 ≠ our last_seq 5 → we missed an intermediate delta. + let delta = pulse::PlayerStateDeltaTier0 { + subject_id: SUBJECT, + baseline_seq: 4, + new_seq: 6, + position_x: Some(200), + ..Default::default() + }; + let events = decoder.handle(server_msg( + pulse::server_message::Message::PlayerStateDelta(delta), + )); + + assert_eq!(events.len(), 1); + match &events[0] { + PulseEvent::Resync(r) => { + assert_eq!(r.subject_id, SUBJECT); + assert_eq!(r.known_seq, 5); + } + other => panic!("expected Resync, got {other:?}"), + } + } + + #[test] + fn stale_delta_is_dropped() { + let mut decoder = PulseDecoder::new(PulseParcelGrid::default()); + decoder.handle(server_msg(joined(5, (1.0, 2.0, 3.0), 0))); + + // new_seq 5 == last_seq 5 → already applied. + let delta = pulse::PlayerStateDeltaTier0 { + subject_id: SUBJECT, + baseline_seq: 4, + new_seq: 5, + ..Default::default() + }; + assert!(decoder + .handle(server_msg( + pulse::server_message::Message::PlayerStateDelta(delta) + )) + .is_empty()); + } + + #[test] + fn delta_for_unknown_subject_requests_full() { + let mut decoder = PulseDecoder::new(PulseParcelGrid::default()); + let delta = pulse::PlayerStateDeltaTier0 { + subject_id: 99, + baseline_seq: 0, + new_seq: 1, + ..Default::default() + }; + let events = decoder.handle(server_msg( + pulse::server_message::Message::PlayerStateDelta(delta), + )); + match &events[0] { + PulseEvent::Resync(r) => { + assert_eq!(r.subject_id, 99); + assert_eq!(r.known_seq, 0); + } + other => panic!("expected Resync, got {other:?}"), + } + } + + #[test] + fn left_drops_subject_and_emits_address() { + let mut decoder = PulseDecoder::new(PulseParcelGrid::default()); + decoder.handle(server_msg(joined(5, (1.0, 2.0, 3.0), 0))); + + let events = decoder.handle(server_msg(pulse::server_message::Message::PlayerLeft( + pulse::PlayerLeft { + subject_id: SUBJECT, + }, + ))); + match &events[0] { + PulseEvent::Left { address } => assert_eq!(*address, wallet()), + other => panic!("expected Left, got {other:?}"), + } + + // After leaving, a delta should be treated as unknown (resync), proving the baseline is gone. + let delta = pulse::PlayerStateDeltaTier0 { + subject_id: SUBJECT, + baseline_seq: 5, + new_seq: 6, + ..Default::default() + }; + assert!(matches!( + decoder + .handle(server_msg( + pulse::server_message::Message::PlayerStateDelta(delta) + )) + .as_slice(), + [PulseEvent::Resync(_)] + )); + } + + #[test] + fn subject_slot_reuse_replaces_binding() { + let mut decoder = PulseDecoder::new(PulseParcelGrid::default()); + decoder.handle(server_msg(joined(5, (1.0, 2.0, 3.0), 0))); + + // The server recycles ENet slot indices: a new PlayerJoined on the same subject_id is a + // different peer and must replace the wallet binding and the sequence window. + const OTHER_WALLET: &str = "0x0000000000000000000000000000000000000002"; + let events = decoder.handle(server_msg(pulse::server_message::Message::PlayerJoined( + pulse::PlayerJoined { + user_id: OTHER_WALLET.to_string(), + profile_version: 1, + state: Some(pulse::PlayerStateFull { + subject_id: SUBJECT, + sequence: 1, + server_tick: 2000, + state: Some(player_state((4.0, 0.0, 4.0), 0)), + }), + realm: "main".to_owned(), + }, + ))); + let join_addr = events + .iter() + .find_map(|e| match e { + PulseEvent::Joined { address, .. } => Some(*address), + _ => None, + }) + .expect("no Joined event"); + assert_eq!(join_addr, OTHER_WALLET.as_h160().unwrap()); + + // The old wallet's window is gone: a delta against the old seq (5) must resync from the + // NEW binding's seq (1). + let delta = pulse::PlayerStateDeltaTier0 { + subject_id: SUBJECT, + baseline_seq: 5, + new_seq: 6, + ..Default::default() + }; + match decoder + .handle(server_msg( + pulse::server_message::Message::PlayerStateDelta(delta), + )) + .as_slice() + { + [PulseEvent::Resync(r)] => assert_eq!(r.known_seq, 1), + other => panic!("expected Resync, got {other:?}"), + } + } +} diff --git a/lib/src/comms/pulse/mod.rs b/lib/src/comms/pulse/mod.rs new file mode 100644 index 000000000..5ff9e3f49 --- /dev/null +++ b/lib/src/comms/pulse/mod.rs @@ -0,0 +1,17 @@ +//! Pulse transport — the ENet/UDP avatar-state relay that coexists with LiveKit. +//! See `plan_pulse/` at the repo root for the architecture and rollout docs. +//! +//! Module map (port of bevy-explorer `crates/comms/src/pulse/` @ 3f65c164, ENet only): +//! - [`transport`] — the byte-boundary seam (frames, status, disconnect codes, channel pair) +//! - `native` — the `pulse-enet` driver thread (rusty_enet, ENet-CSharp modified protocol) +//! - [`decoder`] — quantized state → `rfc4::Movement` reconstruction + parcel grid +//! - [`pulse_room`] — connection state machine, handshake, `MessageProcessor` bridging + +pub mod decoder; +mod native; +pub mod pulse_room; +pub mod transport; + +/// See `comms::consts::PULSE_ROOM_ID` (lives there, unconditional, so MessageProcessor's +/// transport-preference gate compiles without the `use_pulse` feature). +pub use crate::comms::consts::PULSE_ROOM_ID; diff --git a/lib/src/comms/pulse/native.rs b/lib/src/comms/pulse/native.rs new file mode 100644 index 000000000..c67a9a181 --- /dev/null +++ b/lib/src/comms/pulse/native.rs @@ -0,0 +1,210 @@ +//! Native Pulse driver — a dedicated OS thread running a current-thread Tokio runtime around the +//! ENet host. Ported from bevy-explorer `crates/comms/src/pulse/native.rs` @ 3f65c164 (the +//! cross-realm `presence` inbound gate dropped — this client tears the room down on realm change). +//! +//! ENet is a synchronous, non-thread-safe poll loop, so it gets its own thread that owns the host. +//! Rather than busy-polling, the thread blocks in [`tokio::select!`] on the two work sources — the +//! outbound [`PulseFrame`] channel and UDP-socket readability — plus a coarse maintenance tick so +//! ENet's own timers (pings, reliable retransmits) keep ticking when otherwise idle. After any +//! wake it drains outbound onto the peer and services the host. The host's socket is a Tokio +//! [`UdpSocket`] driven non-blocking via `try_recv_from` / `try_send_to`. +//! +//! The host is [`rusty_enet`] from robtfm's `decentraland-pulse` fork — a pure-Rust port of ENet +//! retargeted to the nxrighthere/SoftwareGuy ENet-CSharp "modified protocol" the server runs +//! (header-flag constants in the fork's `src/c/protocol.rs`). Stock ENet — including Godot's +//! built-in one — is wire-incompatible. No compressor and no checksum, matching the server's host. + +use std::io::{self, ErrorKind}; +use std::net::{Ipv4Addr, SocketAddr, ToSocketAddrs}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use rusty_enet as enet; +use tokio::net::UdpSocket; + +use super::transport::{ + PulseDisconnect, PulseDriverChannels, PulseFrame, PulseReliability, PulseStatus, + PulseTransportConfig, +}; + +/// Channels in server order: RELIABLE=0, UNRELIABLE_SEQUENCED=1, UNRELIABLE_UNSEQUENCED=2 +/// (the server's `ENetChannel.COUNT`). +const CHANNEL_COUNT: usize = 3; + +/// How long to block with no inbound/outbound activity before servicing the host anyway, so ENet's +/// pings and reliable retransmits keep ticking on an otherwise-idle connection. +const MAINTENANCE_INTERVAL: Duration = Duration::from_millis(100); + +pub(super) fn spawn( + config: PulseTransportConfig, + channels: PulseDriverChannels, + stop: Arc, +) -> JoinHandle<()> { + thread::Builder::new() + .name("pulse-enet".into()) + .spawn(move || run(config, channels, &stop)) + .expect("failed to spawn pulse-enet thread") +} + +/// Tokio [`UdpSocket`] adapter implementing rusty_enet's [`enet::Socket`]. The newtype is required +/// by the orphan rule; the methods just bridge to Tokio's non-blocking datagram ops. +struct PulseSocket(UdpSocket); + +impl enet::Socket for PulseSocket { + type Address = SocketAddr; + type Error = io::Error; + + fn init(&mut self, _options: enet::SocketOptions) -> io::Result<()> { + // Tokio sockets are already non-blocking; match the std impl's broadcast flag. + self.0.set_broadcast(true) + } + + fn send(&mut self, address: SocketAddr, buffer: &[u8]) -> io::Result { + match self.0.try_send_to(buffer, address) { + Ok(sent) => Ok(sent), + Err(err) if err.kind() == ErrorKind::WouldBlock => Ok(0), + Err(err) => Err(err), + } + } + + fn receive( + &mut self, + buffer: &mut [u8; enet::MTU_MAX], + ) -> io::Result> { + match self.0.try_recv_from(buffer) { + Ok((len, address)) => Ok(Some((address, enet::PacketReceived::Complete(len)))), + Err(err) if err.kind() == ErrorKind::WouldBlock => Ok(None), + Err(err) => Err(err), + } + } +} + +fn run(config: PulseTransportConfig, mut channels: PulseDriverChannels, stop: &AtomicBool) { + let _ = channels.status.try_send(PulseStatus::Connecting); + + // Resolve the server address (DNS). A failure here is never-established → Failed (retryable). + let address = match (config.host.as_str(), config.port).to_socket_addrs() { + Ok(mut addrs) => match addrs.next() { + Some(address) => address, + None => return fail(&mut channels, format!("no address for {}", config.host)), + }, + Err(err) => { + return fail( + &mut channels, + format!("resolve {} failed: {err}", config.host), + ) + } + }; + + // One OS thread, one current-thread runtime — keeps ENet single-threaded while letting us block + // on socket/channel readiness instead of spinning. + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(err) => return fail(&mut channels, format!("tokio runtime build failed: {err}")), + }; + runtime.block_on(drive(&mut channels, address, stop)); +} + +async fn drive(channels: &mut PulseDriverChannels, address: SocketAddr, stop: &AtomicBool) { + // Ephemeral local UDP socket; ENet drives it non-blocking via `PulseSocket`. + let socket = match UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await { + Ok(socket) => PulseSocket(socket), + Err(err) => return fail(channels, format!("udp bind failed: {err}")), + }; + + // One peer (the server), three channels, no compressor/checksum (`HostSettings` defaults). + let mut host = match enet::Host::new( + socket, + enet::HostSettings { + peer_limit: 1, + channel_limit: CHANNEL_COUNT, + ..Default::default() + }, + ) { + Ok(host) => host, + Err(err) => return fail(channels, format!("enet host create failed: {err}")), + }; + + let peer = match host.connect(address, CHANNEL_COUNT, 0) { + Ok(peer) => peer.id(), + Err(err) => return fail(channels, format!("enet connect failed: {err}")), + }; + + while !stop.load(Ordering::Relaxed) { + // Block until there's work: an outbound frame to send, an inbound datagram to read, or the + // maintenance deadline so ENet's timers keep ticking. + let outbound = tokio::select! { + frame = channels.outbound.recv() => Some(frame), + _ = host.socket().0.readable() => None, + _ = tokio::time::sleep(MAINTENANCE_INTERVAL) => None, + }; + match outbound { + // Channel closed — the protocol layer dropped the link; stop the driver. + Some(None) => break, + Some(Some(frame)) => queue_frame(&mut host, peer, &frame), + None => {} + } + + // Drain any further queued outbound without blocking; the next service flushes them. + while let Ok(frame) = channels.outbound.try_recv() { + queue_frame(&mut host, peer, &frame); + } + + // Service the host: socket I/O + dispatch all ready events. + loop { + match host.service() { + Ok(Some(enet::Event::Connect { .. })) => { + let _ = channels.status.try_send(PulseStatus::Connected); + } + Ok(Some(enet::Event::Disconnect { data, .. })) => { + let reason = PulseDisconnect::from_code(data); + let _ = channels.status.try_send(PulseStatus::Disconnected(reason)); + return; + } + Ok(Some(enet::Event::Receive { packet, .. })) => { + let _ = channels.inbound.try_send(packet.data().to_vec()); + } + Ok(None) => break, + Err(err) => return fail(channels, format!("enet service error: {err}")), + } + } + } + + // Stop requested by the protocol layer (room dropped) — disconnect cleanly and flush. + host.peer_mut(peer).disconnect(0); + host.flush(); + let _ = channels + .status + .try_send(PulseStatus::Disconnected(PulseDisconnect::Graceful)); +} + +/// Queue one outbound [`PulseFrame`] onto the peer. Channel and packet kind together reproduce the +/// server's `ENetChannel` wire commands: reliable → `SEND_RELIABLE`, unreliable-sequenced → +/// `SEND_UNRELIABLE`, unreliable-unsequenced → `SEND_UNSEQUENCED`. +fn queue_frame(host: &mut enet::Host, peer: enet::PeerID, frame: &PulseFrame) { + let (channel_id, packet) = match frame.reliability { + PulseReliability::Reliable => (0, enet::Packet::reliable(frame.bytes.as_slice())), + PulseReliability::UnreliableSequenced => { + (1, enet::Packet::unreliable(frame.bytes.as_slice())) + } + PulseReliability::UnreliableUnsequenced => ( + 2, + enet::Packet::unreliable_unsequenced(frame.bytes.as_slice()), + ), + }; + if let Err(err) = host.peer_mut(peer).send(channel_id, &packet) { + tracing::warn!("pulse: peer send failed: {err}"); + } +} + +/// Report a never-established failure (DNS/socket/connect). Always transient — the protocol layer +/// retries. +fn fail(channels: &mut PulseDriverChannels, message: String) { + tracing::warn!("pulse: {message}"); + let _ = channels.status.try_send(PulseStatus::Failed(message)); +} diff --git a/lib/src/comms/pulse/pulse_room.rs b/lib/src/comms/pulse/pulse_room.rs new file mode 100644 index 000000000..6d907257e --- /dev/null +++ b/lib/src/comms/pulse/pulse_room.rs @@ -0,0 +1,897 @@ +//! The Pulse protocol layer — godot-explorer's re-expression of bevy-explorer's +//! `crates/comms/src/pulse/plugin.rs` @ 3f65c164 (Bevy ECS systems → one struct polled per frame +//! from `CommunicationManager::process()`). +//! +//! Owns the [`PulseDecoder`] and the driver lifecycle, and pumps the byte boundary: inbound +//! `ServerMessage` bytes are decoded and bridged into the shared `MessageProcessor` as ordinary +//! `IncomingMessage`s with `room_id = "pulse"` — downstream (avatars, profiles, emotes) never +//! learns Pulse exists. Outbound gameplay (`PlayerStateInput`, emotes, teleports, profile +//! versions) is encoded onto the driver. +//! +//! Connect sequence (mirroring the Unity reference client): once the driver reports +//! [`PulseStatus::Connected`] we sign and send a `HandshakeRequest` (auth chain in the +//! `x-identity-*` header-dictionary shape, delivered as protobuf bytes); on the server's +//! `HandshakeResponse` the room reports [`PulseRoomEvent::Established`] and the +//! `CommunicationManager` sends the first gameplay message — a `TeleportRequest` announcing +//! realm + position, which is what makes the server start streaming same-realm peers. +//! +//! Reconnection model: a driver runs exactly one connection attempt and, when that attempt ends, +//! drops its channel ends. The protocol layer treats that *pipe close* — not the advisory +//! `Disconnected(reason)` message — as the authoritative "transport is gone" signal, and rebuilds +//! the whole driver/link from `Down` (unless the reason was terminal, in which case it parks in +//! `Dead` until the room itself is rebuilt on the next realm change). + +use std::time::{Duration, Instant}; + +use ethers_core::types::H160; +use godot::prelude::Vector3; +use prost::Message as _; +use tokio::sync::mpsc; + +use super::decoder::{from_movement, PulseDecoder, PulseEvent, PulseParcelGrid}; +use super::transport::{ + self, PulseDriverHandle, PulseFrame, PulseLink, PulseReliability, PulseStatus, + PulseTransportConfig, +}; +use super::PULSE_ROOM_ID; +use crate::auth::ephemeral_auth_chain::EphemeralAuthChain; +use crate::auth::wallet::sign_pulse_connect; +use crate::comms::adapter::message_processor::{IncomingMessage, MessageType, Rfc4Message}; +use crate::comms::consts::DEFAULT_PROTOCOL_VERSION; +use crate::dcl::components::proto_components::{kernel::comms::rfc4, pulse}; +use crate::scene_runner::tokio_runtime::TokioRuntime; + +/// Cooldown before re-attempting after any retryable failure (no identity yet, sign error, +/// response timeout, server rejection, or a retryable disconnect). +pub const RETRY_COOLDOWN: Duration = Duration::from_secs(2); +/// How long to wait for the server's `HandshakeResponse` before assuming it was lost and retrying. +pub const HANDSHAKE_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); +/// Byte-boundary channel capacity (outbound frames / inbound messages). +const LINK_CAPACITY: usize = 1024; + +/// The connection's lifecycle — a single linear progression with two off-ramps (`Down` to +/// rebuild, `Dead` to give up). The driver/link are live from `Connecting` through `Established`; +/// `Down` and `Dead` have no driver. +enum Connection { + /// No live driver. The next poll after `respawn_at` (re)builds one. This is both the initial + /// state and where a retryable transport drop lands. + Down { respawn_at: Instant }, + /// Driver up, waiting for it to report `Connected` (the ENet connect completing). + Connecting, + /// Transport connected, ready to sign once an identity is present and the cooldown elapses. + Idle { retry_after: Instant }, + /// Signing the auth chain off-thread; the encoded handshake arrives on `handshake_rx`. + /// Re-signed on each attempt so the connect-signature timestamp is fresh when sent. + Signing, + /// Handshake sent; awaiting the server's `HandshakeResponse` until `timeout_at`. + AwaitingResponse { timeout_at: Instant }, + /// Handshake accepted; steady state. + Established, + /// Terminally disconnected (auth rejected, banned, evicted, flagged) — no reconnect attempted. + /// Only rebuilding the room (realm change / `change_adapter`) clears this. + Dead, +} + +/// What `poll()` surfaces up to `CommunicationManager`. +#[derive(Debug, PartialEq, Eq)] +pub enum PulseRoomEvent { + /// Handshake accepted. Caller resets the session failure counter and sends the initial + /// `TeleportRequest` (realm + position) — the mandatory first gameplay message. + Established, + /// A connection attempt ended before ever establishing (connect failure, pipe close, + /// handshake never acked). Caller counts it toward the session-fallback strike limit. + AttemptFailed, + /// Terminal disconnect — the room is parked in `Dead` and will not reconnect. + Dead, +} + +pub struct PulseRoom { + /// Byte boundary to the current driver. `None` between attempts (`Down`/`Dead`). + link: Option, + /// Current driver; dropping it stops + joins the `pulse-enet` thread. Replaced wholesale on + /// every (re)connect. + driver: Option, + decoder: PulseDecoder, + grid: PulseParcelGrid, + config: PulseTransportConfig, + state: Connection, + /// Whether the current driver attempt ever reached `Established` — a drop before that counts + /// as a session-fallback strike. + established_this_attempt: bool, + + /// Signed handshake bytes arriving from the off-thread signing task. + handshake_rx: mpsc::Receiver, String>>, + handshake_tx: mpsc::Sender, String>>, + + /// Sink into the shared `MessageProcessor` — the same channel every room feeds. + processor_sender: mpsc::Sender, + + /// Last `PlayerState` sent, cached by [`Self::send_movement`]. An outbound `EmoteStart` + /// attaches this — the server rejects an emote with a null `player_state`. + last_state: Option, + /// Last profile version announced over Pulse; announcements are reliable + stored + /// server-side, so only send on change (the handshake itself carries the connect-time one). + last_announced_profile_version: Option, + /// Log the out-of-grid suppression only once per excursion. + warned_out_of_grid: bool, +} + +impl PulseRoom { + pub fn new( + config: PulseTransportConfig, + processor_sender: mpsc::Sender, + ) -> Self { + let (handshake_tx, handshake_rx) = mpsc::channel(4); + let grid = PulseParcelGrid::default(); + Self { + link: None, + driver: None, + decoder: PulseDecoder::new(grid), + grid, + config, + state: Connection::Down { + respawn_at: Instant::now(), + }, + established_this_attempt: false, + handshake_rx, + handshake_tx, + processor_sender, + last_state: None, + last_announced_profile_version: None, + warned_out_of_grid: false, + } + } + + pub fn is_established(&self) -> bool { + matches!(self.state, Connection::Established) + } + + pub fn state_name(&self) -> &'static str { + match self.state { + Connection::Down { .. } => "down", + Connection::Connecting => "connecting", + Connection::Idle { .. } => "idle", + Connection::Signing => "signing", + Connection::AwaitingResponse { .. } => "awaiting_response", + Connection::Established => "established", + Connection::Dead => "dead", + } + } + + pub fn endpoint(&self) -> String { + format!("{}:{}", self.config.host, self.config.port) + } + + /// Drive the room one frame: drain driver status, advance the connection state machine + /// (signing when `identity` yields one), then decode + bridge inbound. `identity` is called + /// at most once, only when a handshake is actually about to be signed; it returns the + /// ephemeral auth chain and the current profile version. + pub fn poll( + &mut self, + now: Instant, + identity: impl FnOnce() -> Option<(EphemeralAuthChain, i32)>, + ) -> Vec { + let mut events = Vec::new(); + self.drain_status(now, &mut events); + self.drive_connection(now, identity); + self.drain_inbound(now, &mut events); + events + } + + /// Graceful teardown: flood synthetic `PeerLeft`s for every Pulse-known peer (so + /// LiveKit-driven rendering takes over within a frame) and drop the driver (its `Drop` + /// disconnects the ENet peer cleanly and joins the thread). + pub fn clean(&mut self) { + self.flood_peer_left(); + self.link = None; + self.driver = None; + self.state = Connection::Dead; + } + + fn flood_peer_left(&mut self) { + for wallet in self.decoder.known_wallets() { + self.send_to_processor(wallet, MessageType::PeerLeft); + } + // Rebuild the decoder to drop all subject state; a future connection starts clean. + self.decoder = PulseDecoder::new(self.grid); + } + + // ---- outbound API ------------------------------------------------------------------------ + + /// Quantize + send the local movement as a `PlayerStateInput` (unreliable-sequenced, ch1). + /// `movement` fields are in DCL world convention — the same values the rfc4 packets carry. + /// Out-of-grid positions are suppressed entirely (invalid parcel index = terminal disconnect). + pub fn send_movement(&mut self, movement: &rfc4::Movement) { + if !self.is_established() { + return; + } + let world = Vector3::new( + movement.position_x, + movement.position_y, + movement.position_z, + ); + if !self.check_in_grid(world) { + return; + } + let state = from_movement(movement, &self.grid); + self.last_state = Some(state.clone()); + self.send_client_message( + pulse::client_message::Message::Input(pulse::PlayerStateInput { state: Some(state) }), + PulseReliability::UnreliableSequenced, + ); + } + + /// Reliable `EmoteStart`, attaching the last sent `PlayerState` (the server rejects a null + /// one — skip with a warning if no movement was ever sent on this connection). + pub fn send_emote_start(&mut self, urn: &str) { + if !self.is_established() { + return; + } + let Some(state) = self.last_state.clone() else { + tracing::warn!("pulse: emote before any movement sent; skipping EmoteStart"); + return; + }; + self.send_client_message( + pulse::client_message::Message::EmoteStart(pulse::EmoteStart { + emote_id: urn.to_owned(), + duration_ms: None, + player_state: Some(state), + mask: None, + }), + PulseReliability::Reliable, + ); + } + + /// Reliable `EmoteStop`. Required for one-shots too, not just looping emotes: the server + /// only auto-completes emotes that carry `duration_ms`, and our `EmoteStart` never sets it — + /// without an explicit stop the server ledger keeps the peer emoting forever. Sending a stop + /// with no active emote is harmless (server logs a warning and ignores it). + pub fn send_emote_stop(&mut self) { + if !self.is_established() { + return; + } + self.send_client_message( + pulse::client_message::Message::EmoteStop(pulse::EmoteStop {}), + PulseReliability::Reliable, + ); + } + + /// Reliable `ProfileVersionAnnouncement`, deduped on change (the 10s LiveKit rebroadcast + /// idiom is noise on a reliable, server-stored channel). + pub fn send_profile_version(&mut self, version: u32) { + if !self.is_established() || self.last_announced_profile_version == Some(version) { + return; + } + self.last_announced_profile_version = Some(version); + self.send_client_message( + pulse::client_message::Message::ProfileAnnouncement( + pulse::ProfileVersionAnnouncement { + version: version as i32, + }, + ), + PulseReliability::Reliable, + ); + } + + /// Reliable `TeleportRequest` announcing realm + position (DCL world convention). The + /// mandatory first gameplay message after the handshake, re-sent on instant repositions + /// (server supports same-realm re-teleports). The caller guards the realm-name validity; + /// this guards the grid bounds. + pub fn send_teleport(&mut self, world: Vector3, realm: &str) { + if !self.check_in_grid(world) { + return; + } + let (parcel_index, local) = self.grid.encode_to_parcel(world); + use pulse::TeleportRequest as T; + self.send_client_message( + pulse::client_message::Message::Teleport(T { + parcel_index, + position_x: T::position_x_quantized(local.x), + position_y: T::position_y_quantized(local.y), + position_z: T::position_z_quantized(local.z), + realm: realm.to_owned(), + }), + PulseReliability::Reliable, + ); + tracing::info!("pulse: teleport sent (parcel {parcel_index}, realm {realm})"); + } + + // ---- internals --------------------------------------------------------------------------- + + /// Grid-bounds guard shared by every position-bearing send. Out-of-grid (some Worlds / + /// preview realms) → suppress and log once per excursion. + fn check_in_grid(&mut self, world: Vector3) -> bool { + if self.grid.contains_world(world) { + self.warned_out_of_grid = false; + true + } else { + if !self.warned_out_of_grid { + self.warned_out_of_grid = true; + tracing::warn!( + "pulse: position {world:?} outside the pulse parcel grid; suppressing sends until back in bounds" + ); + } + false + } + } + + fn send_client_message( + &mut self, + message: pulse::client_message::Message, + reliability: PulseReliability, + ) { + let Some(link) = self.link.as_ref() else { + return; + }; + let bytes = pulse::ClientMessage { + message: Some(message), + } + .encode_to_vec(); + let _ = link.outbound.try_send(PulseFrame { bytes, reliability }); + } + + fn send_to_processor(&self, address: H160, message: MessageType) { + let _ = self.processor_sender.try_send(IncomingMessage { + message, + address, + room_id: PULSE_ROOM_ID.to_owned(), + }); + } + + fn send_rfc4_to_processor(&self, address: H160, message: rfc4::packet::Message) { + self.send_to_processor( + address, + MessageType::Rfc4(Rfc4Message { + message, + protocol_version: DEFAULT_PROTOCOL_VERSION, + }), + ); + } + + /// Drain the driver's status channel into the state machine. A `Disconnected`/`Failed` + /// status is the driver signing off; a bare channel close means it vanished with no word. + /// Either way `lost_connection` nulls the link, ending the loop and making a + /// status-then-close sequence idempotent. + fn drain_status(&mut self, now: Instant, events: &mut Vec) { + while let Some(status) = self.link.as_mut().map(|link| link.status.try_recv()) { + match status { + Ok(PulseStatus::Connecting) => tracing::debug!("pulse: connecting"), + Ok(PulseStatus::Connected) => { + tracing::info!("pulse: connected"); + if matches!(self.state, Connection::Connecting) { + self.state = Connection::Idle { retry_after: now }; + } + } + Ok(PulseStatus::Disconnected(reason)) => { + tracing::warn!("pulse: disconnected ({reason:?})"); + self.lost_connection(reason.should_retry(), now, events); + } + // Never established (DNS/socket/connect timeout) — always transient. + Ok(PulseStatus::Failed(error)) => { + tracing::warn!("pulse: failed ({error})"); + self.lost_connection(true, now, events); + } + Err(mpsc::error::TryRecvError::Empty) => break, + Err(mpsc::error::TryRecvError::Disconnected) => { + self.lost_connection(true, now, events); + break; + } + } + } + } + + /// The transport is gone — tear the driver/link down, surface the peer loss, and decide + /// what's next: rebuild from `Down` after a cooldown, or park in `Dead` for a terminal + /// reason. Idempotent: with no live link it's a no-op. + fn lost_connection(&mut self, retry: bool, now: Instant, events: &mut Vec) { + if self.link.is_none() { + return; + } + self.link = None; + self.driver = None; // dropping joins the already-exited driver thread + self.last_state = None; + self.last_announced_profile_version = None; + self.flood_peer_left(); + if !self.established_this_attempt { + events.push(PulseRoomEvent::AttemptFailed); + } + self.state = if retry { + tracing::info!("pulse: transport dropped — reinitialising after cooldown"); + Connection::Down { + respawn_at: now + RETRY_COOLDOWN, + } + } else { + tracing::warn!("pulse: terminal disconnect — not reconnecting"); + events.push(PulseRoomEvent::Dead); + Connection::Dead + }; + } + + /// Advance the connection one step. `Down` (re)builds the driver; `Connecting` waits + /// passively for the transport-up status; `Idle` waits for an identity + cooldown, then + /// signs off-thread; each retryable handshake failure folds back to `Idle` (driver still up). + fn drive_connection( + &mut self, + now: Instant, + identity: impl FnOnce() -> Option<(EphemeralAuthChain, i32)>, + ) { + match &self.state { + Connection::Dead | Connection::Established | Connection::Connecting => {} + Connection::Down { respawn_at } => { + if now < *respawn_at { + return; + } + let (link, channels) = transport::pulse_channels(LINK_CAPACITY); + let driver = transport::spawn_pulse_driver(self.config.clone(), channels); + self.link = Some(link); + self.driver = Some(driver); + self.established_this_attempt = false; + self.state = Connection::Connecting; + } + Connection::Idle { retry_after } => { + if now < *retry_after { + return; + } + let Some((ephemeral, profile_version)) = identity() else { + return; + }; + // Discard any stale signing result from an earlier, torn-down attempt so the + // handshake we send always carries this attempt's fresh timestamp. + while self.handshake_rx.try_recv().is_ok() {} + let tx = self.handshake_tx.clone(); + let sign_task = async move { + let result = sign_pulse_connect(&ephemeral).await.map(|auth_chain| { + pulse::ClientMessage { + message: Some(pulse::client_message::Message::Handshake( + pulse::HandshakeRequest { + auth_chain, + profile_version, + initial_state: None, + }, + )), + } + .encode_to_vec() + }); + let _ = tx.send(result).await; + }; + // Prefer an ambient tokio runtime (unit tests); the Godot main thread has none, + // so production goes through the shared TokioRuntime (whose Godot-singleton + // lookup would panic in a non-engine process). + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(sign_task); + } else { + TokioRuntime::spawn(sign_task); + } + self.state = Connection::Signing; + } + Connection::Signing => match self.handshake_rx.try_recv() { + Ok(Ok(bytes)) => { + if let Some(link) = self.link.as_ref() { + let _ = link.outbound.try_send(PulseFrame { + bytes, + reliability: PulseReliability::Reliable, + }); + } + tracing::info!("pulse: handshake sent"); + self.state = Connection::AwaitingResponse { + timeout_at: now + HANDSHAKE_RESPONSE_TIMEOUT, + }; + } + Ok(Err(err)) => { + tracing::warn!("pulse: failed to build handshake, retrying: {err}"); + self.state = Connection::Idle { + retry_after: now + RETRY_COOLDOWN, + }; + } + Err(mpsc::error::TryRecvError::Empty) => {} + Err(mpsc::error::TryRecvError::Disconnected) => { + self.state = Connection::Idle { + retry_after: now + RETRY_COOLDOWN, + }; + } + }, + Connection::AwaitingResponse { timeout_at } => { + if now > *timeout_at { + tracing::warn!("pulse: handshake response timed out, retrying"); + self.state = Connection::Idle { + retry_after: now + RETRY_COOLDOWN, + }; + } + } + } + } + + /// Decode + bridge inbound `ServerMessage` bytes into the shared `MessageProcessor`. + fn drain_inbound(&mut self, now: Instant, events: &mut Vec) { + while let Some(Ok(bytes)) = self.link.as_mut().map(|link| link.inbound.try_recv()) { + let decoded = match pulse::ServerMessage::decode(bytes.as_slice()) { + Ok(message) => self.decoder.handle(message), + Err(err) => { + tracing::warn!("pulse: failed to decode ServerMessage: {err}"); + continue; + } + }; + for event in decoded { + match event { + PulseEvent::Connected { success, error } => { + self.on_handshake_response(now, success, error, events) + } + PulseEvent::Movement { + address, + mut movement, + teleport, + } => { + // A teleport is a discontinuous reposition — snap, don't interpolate. + movement.is_instant = teleport; + self.send_rfc4_to_processor( + address, + rfc4::packet::Message::Movement(*movement), + ); + } + // The subject↔wallet alias was established: surface the peer and its + // connect-time profile version (idempotent downstream). + PulseEvent::Joined { + address, + profile_version, + .. + } => { + self.send_to_processor(address, MessageType::PeerJoined); + self.bridge_profile_version(address, profile_version); + } + // Removes only the "pulse" room membership; the avatar survives if the peer + // is still live on LiveKit (multi-room peer lifecycle in MessageProcessor). + PulseEvent::Left { address } => { + self.send_to_processor(address, MessageType::PeerLeft); + } + PulseEvent::ProfileVersion { address, version } => { + self.bridge_profile_version(address, version); + } + PulseEvent::EmoteStart { address, urn, tick } => { + self.send_rfc4_to_processor( + address, + rfc4::packet::Message::PlayerEmote(rfc4::PlayerEmote { + incremental_id: tick, + urn, + timestamp: 0.0, + is_stopping: Some(false), + ..Default::default() + }), + ); + } + PulseEvent::EmoteStop { address } => { + self.send_rfc4_to_processor( + address, + rfc4::packet::Message::PlayerEmote(rfc4::PlayerEmote { + incremental_id: 0, + urn: String::new(), + timestamp: 0.0, + is_stopping: Some(true), + ..Default::default() + }), + ); + } + // A sequence gap — ask the server to replay full state, reliably. + PulseEvent::Resync(request) => { + self.send_client_message( + pulse::client_message::Message::Resync(request), + PulseReliability::Reliable, + ); + } + } + } + } + } + + fn bridge_profile_version(&self, address: H160, version: i32) { + self.send_rfc4_to_processor( + address, + rfc4::packet::Message::ProfileVersion(rfc4::AnnounceProfileVersion { + // Pulse carries i32; rfc4 is u32 — clamp negatives (bevy parity). + profile_version: version.max(0) as u32, + }), + ); + } + + fn on_handshake_response( + &mut self, + now: Instant, + success: bool, + error: Option, + events: &mut Vec, + ) { + // Ignore a stray response we're not waiting on (e.g. a duplicate after establishing). + if !matches!(self.state, Connection::AwaitingResponse { .. }) { + return; + } + if !success { + tracing::warn!( + "pulse: handshake rejected, retrying: {}", + error.unwrap_or_default() + ); + self.state = Connection::Idle { + retry_after: now + RETRY_COOLDOWN, + }; + return; + } + tracing::info!("pulse: handshake accepted"); + self.state = Connection::Established; + self.established_this_attempt = true; + events.push(PulseRoomEvent::Established); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::wallet::{SimpleAuthChain, Wallet}; + use crate::comms::pulse::transport::{pulse_channels, PulseDriverChannels}; + + fn processor_channel() -> ( + mpsc::Sender, + mpsc::Receiver, + ) { + mpsc::channel(64) + } + + /// A room wired to in-memory channels instead of a live driver, parked in `Connecting`. + /// Returns the driver-side channel ends so tests can play the server/transport role. + fn test_room(processor: mpsc::Sender) -> (PulseRoom, PulseDriverChannels) { + let mut room = PulseRoom::new( + PulseTransportConfig { + host: "test.invalid".into(), + port: 7777, + }, + processor, + ); + let (link, channels) = pulse_channels(64); + room.link = Some(link); + room.state = Connection::Connecting; + (room, channels) + } + + async fn test_identity() -> EphemeralAuthChain { + let signer = Wallet::new_local_wallet(); + let ephemeral = ethers_signers::LocalWallet::new(&mut rand::thread_rng()); + let ephemeral_keys = ephemeral.signer().to_bytes().to_vec(); + let expiration = std::time::SystemTime::now() + Duration::from_secs(600); + // The chain-link message content doesn't matter for these tests (nothing verifies it); + // only the ephemeral wallet signing the connect payload does. + let message = "test ephemeral".to_owned(); + let signature = signer.sign_message(&message).await.unwrap(); + let chain = SimpleAuthChain::new_ephemeral_identity_auth_chain( + signer.address(), + message, + signature, + ); + EphemeralAuthChain::new(signer.address(), ephemeral_keys, chain, expiration) + } + + fn server_message_bytes(message: pulse::server_message::Message) -> Vec { + pulse::ServerMessage { + message: Some(message), + } + .encode_to_vec() + } + + fn handshake_response(success: bool) -> Vec { + server_message_bytes(pulse::server_message::Message::Handshake( + pulse::HandshakeResponse { + success, + error: (!success).then(|| "rejected".to_owned()), + }, + )) + } + + fn player_joined(subject_id: u32, wallet: &str) -> Vec { + server_message_bytes(pulse::server_message::Message::PlayerJoined( + pulse::PlayerJoined { + user_id: wallet.to_owned(), + profile_version: 3, + state: Some(pulse::PlayerStateFull { + subject_id, + sequence: 1, + server_tick: 100, + state: Some(pulse::PlayerState { + parcel_index: 54858, + ..Default::default() + }), + }), + realm: "main".to_owned(), + }, + )) + } + + /// Full happy path: Connected → Idle → Signing → handshake frame on the wire → + /// HandshakeResponse{success} → Established event. + #[test] + fn state_name_and_endpoint_reflect_config_and_lifecycle() { + let (sender, _keep) = processor_channel(); + let mut room = PulseRoom::new( + PulseTransportConfig { + host: "test.invalid".into(), + port: 7777, + }, + sender, + ); + assert_eq!(room.state_name(), "down"); + assert_eq!(room.endpoint(), "test.invalid:7777"); + + room.state = Connection::Connecting; + assert_eq!(room.state_name(), "connecting"); + room.state = Connection::Signing; + assert_eq!(room.state_name(), "signing"); + room.state = Connection::Established; + assert_eq!(room.state_name(), "established"); + room.state = Connection::Dead; + assert_eq!(room.state_name(), "dead"); + } + + #[tokio::test] + async fn connect_sign_handshake_established() { + let (sender, _keep) = processor_channel(); + let (mut room, mut driver) = test_room(sender); + let identity = test_identity().await; + + driver.status.try_send(PulseStatus::Connected).unwrap(); + let now = Instant::now(); + assert!(room.poll(now, || Some((identity.clone(), 7))).is_empty()); + assert!(matches!(room.state, Connection::Signing)); + + // The signing task runs on a fallback thread in tests; keep polling until the room + // observes the signed bytes and puts the handshake frame on the wire. + let deadline = Instant::now() + Duration::from_secs(5); + while !matches!(room.state, Connection::AwaitingResponse { .. }) { + assert!(Instant::now() < deadline, "sign timed out"); + tokio::time::sleep(Duration::from_millis(10)).await; + room.poll(now, || None); + } + let frame = driver.outbound.try_recv().expect("no handshake frame"); + assert_eq!(frame.reliability, PulseReliability::Reliable); + + // Decode what we would send and sanity-check the auth chain shape. + let sent = pulse::ClientMessage::decode(frame.bytes.as_slice()).unwrap(); + let Some(pulse::client_message::Message::Handshake(handshake)) = sent.message else { + panic!("expected handshake message"); + }; + assert_eq!(handshake.profile_version, 7); + let dict: serde_json::Map = + serde_json::from_slice(&handshake.auth_chain).unwrap(); + assert!(dict.contains_key("x-identity-timestamp")); + assert_eq!(dict["x-identity-metadata"], "{}"); + assert!(dict.contains_key("x-identity-auth-chain-0")); + // The signed-entity link carries the verbatim connect payload. + let last_link = dict + .iter() + .filter(|(k, _)| k.starts_with("x-identity-auth-chain-")) + .map(|(_, v)| v.as_str().unwrap()) + .find(|v| v.contains("connect:/:")) + .expect("no connect signed entity in chain"); + assert!(last_link.contains("ECDSA_SIGNED_ENTITY")); + + driver.inbound.try_send(handshake_response(true)).unwrap(); + let events = room.poll(now, || None); + assert_eq!(events, vec![PulseRoomEvent::Established]); + assert!(room.is_established()); + } + + #[tokio::test] + async fn handshake_rejection_folds_back_to_idle_with_cooldown() { + let (sender, _keep) = processor_channel(); + let (mut room, driver) = test_room(sender); + let now = Instant::now(); + room.state = Connection::AwaitingResponse { + timeout_at: now + HANDSHAKE_RESPONSE_TIMEOUT, + }; + + driver.inbound.try_send(handshake_response(false)).unwrap(); + assert!(room.poll(now, || None).is_empty()); + match room.state { + Connection::Idle { retry_after } => assert!(retry_after > now), + _ => panic!("expected Idle"), + } + } + + #[tokio::test] + async fn handshake_timeout_retries() { + let (sender, _keep) = processor_channel(); + let (mut room, _driver) = test_room(sender); + let now = Instant::now(); + room.state = Connection::AwaitingResponse { + timeout_at: now - Duration::from_millis(1), + }; + room.poll(now, || None); + assert!(matches!(room.state, Connection::Idle { .. })); + } + + #[tokio::test] + async fn retryable_disconnect_before_established_counts_a_strike() { + let (sender, _keep) = processor_channel(); + let (mut room, driver) = test_room(sender); + let now = Instant::now(); + + driver + .status + .try_send(PulseStatus::Disconnected( + super::super::transport::PulseDisconnect::ServerFull, + )) + .unwrap(); + let events = room.poll(now, || None); + assert_eq!(events, vec![PulseRoomEvent::AttemptFailed]); + assert!(matches!(room.state, Connection::Down { .. })); + } + + #[tokio::test] + async fn terminal_disconnect_parks_dead() { + let (sender, _keep) = processor_channel(); + let (mut room, driver) = test_room(sender); + let now = Instant::now(); + + driver + .status + .try_send(PulseStatus::Disconnected( + super::super::transport::PulseDisconnect::AuthFailed, + )) + .unwrap(); + let events = room.poll(now, || None); + assert_eq!( + events, + vec![PulseRoomEvent::AttemptFailed, PulseRoomEvent::Dead] + ); + assert!(matches!(room.state, Connection::Dead)); + // Dead is sticky: further polls do nothing. + assert!(room.poll(now, || None).is_empty()); + } + + #[tokio::test] + async fn pipe_close_is_authoritative_teardown() { + let (sender, _keep) = processor_channel(); + let (mut room, driver) = test_room(sender); + room.state = Connection::Established; + room.established_this_attempt = true; + let now = Instant::now(); + + drop(driver); // driver vanished with no word + let events = room.poll(now, || None); + // Established before the drop → no strike; retryable → Down. + assert!(events.is_empty()); + assert!(matches!(room.state, Connection::Down { .. })); + } + + /// Inbound peers bridged with room_id "pulse"; teardown floods synthetic PeerLefts. + #[tokio::test] + async fn joined_bridges_peer_and_teardown_floods_left() { + const WALLET: &str = "0x00000000000000000000000000000000000000aa"; + let (sender, mut processor_rx) = processor_channel(); + let (mut room, driver) = test_room(sender); + room.state = Connection::Established; + room.established_this_attempt = true; + let now = Instant::now(); + + driver.inbound.try_send(player_joined(1, WALLET)).unwrap(); + room.poll(now, || None); + + // PeerJoined + ProfileVersion + Movement, all room_id = "pulse". + let mut kinds = Vec::new(); + while let Ok(msg) = processor_rx.try_recv() { + assert_eq!(msg.room_id, PULSE_ROOM_ID); + kinds.push(match msg.message { + MessageType::PeerJoined => "joined", + MessageType::PeerLeft => "left", + MessageType::Rfc4(r) => match r.message { + rfc4::packet::Message::Movement(_) => "movement", + rfc4::packet::Message::ProfileVersion(_) => "profile", + _ => "other", + }, + _ => "other", + }); + } + assert_eq!(kinds, vec!["joined", "profile", "movement"]); + + // Teardown → synthetic PeerLeft for the known wallet. + drop(driver); + room.poll(now, || None); + let msg = processor_rx.try_recv().expect("no teardown PeerLeft"); + assert!(matches!(msg.message, MessageType::PeerLeft)); + assert_eq!(msg.room_id, PULSE_ROOM_ID); + } +} diff --git a/lib/src/comms/pulse/transport.rs b/lib/src/comms/pulse/transport.rs new file mode 100644 index 000000000..da95882f0 --- /dev/null +++ b/lib/src/comms/pulse/transport.rs @@ -0,0 +1,210 @@ +//! Transport boundary for Pulse — the byte seam between the protocol layer and the ENet driver. +//! Ported from bevy-explorer `crates/comms/src/pulse/transport.rs` @ 3f65c164 (WebTransport +//! support and the cross-realm warm-socket `presence` gate dropped: this client tears the room +//! down on realm change, like every other comms room). +//! +//! The driver knows nothing about protobuf, identity, the decoder, or the parcel grid. Its whole +//! contract is: connect to `host:port`, then move bytes between two channels — outbound +//! [`PulseFrame`]s (with a reliability tag the driver maps to ENet channels) and inbound raw +//! `ServerMessage` bytes — and report [`PulseStatus`]. Because the seam is bytes, everything +//! above it (handshake, decode, state machine) is testable with in-memory channels, no sockets. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use tokio::sync::mpsc; + +/// Channel/reliability selector, mirroring the server's `PacketMode`. The driver maps this to +/// ENet channel + packet flags. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PulseReliability { + /// Reliable ordered — handshake, resync, teleport, emotes (server channel 0). + Reliable, + /// Unreliable sequenced — high-frequency state input (server channel 1). + UnreliableSequenced, + /// Unreliable unordered. + #[allow(dead_code)] + UnreliableUnsequenced, +} + +/// One outbound unit of work: an already-encoded `ClientMessage` plus how to deliver it. +#[derive(Debug)] +pub struct PulseFrame { + pub bytes: Vec, + pub reliability: PulseReliability, +} + +/// Connection lifecycle, surfaced from the driver to the protocol layer. +#[derive(Debug, Clone)] +pub enum PulseStatus { + Connecting, + Connected, + /// Connected-then-dropped, carrying the server's reason (see [`PulseDisconnect`]). + Disconnected(PulseDisconnect), + /// Never established (DNS/socket/connect timeout) — always transient, safe to retry. + Failed(String), +} + +/// Server disconnect reason. This is an out-of-band ABI shared with the server +/// (`Pulse.Transport.DisconnectReason`), *not* a wire message: it arrives as the ENet disconnect +/// event's `data` code. The driver maps that `u32` here so the protocol layer can decide whether a +/// reconnect could plausibly help — several reasons are terminal by the server's design (bad auth, +/// ban, eviction, flagged misbehaviour) and must not trigger a reconnect loop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PulseDisconnect { + /// No reason supplied (e.g. a plain network timeout where the remote set no code). + None, + /// Clean shutdown / server stopping. + Graceful, + /// Our `PENDING_AUTH` deadline was exceeded (handshake too slow). + AuthTimeout, + /// Handshake validation failed. + AuthFailed, + /// Evicted by a newer connection with the same wallet — retrying fights that session. + DuplicateSession, + /// Banned platform-wide. + Banned, + /// Server at capacity. + ServerFull, + /// Per-source-IP pre-auth connection cap exceeded. + PreAuthIpLimit, + /// Global pre-auth budget exhausted. + PreAuthBudget, + /// Sent `PlayerStateInput` faster than the server's cap (misbehaving client). + InputRateExceeded, + /// Exceeded the discrete-event (emote/teleport) cap (misbehaving client). + DiscreteEventRateExceeded, + /// `PlayerStateInput` carried an invalid field. + InvalidInputField, + /// `EmoteStart` carried an invalid field. + InvalidEmoteField, + /// `TeleportRequest` carried an invalid field (oversized/empty realm, bad parcel). + InvalidTeleportField, + /// A handshake with the same (wallet, timestamp) was replayed inside the anti-replay window. + HandshakeReplayRejected, + /// `HandshakeRequest` carried a malformed `PlayerInitialState`. + InvalidHandshakeField, + /// Sustained corrupt/oversized packets (buggy client, fuzzer, or amplification probe). + PacketCorrupted, + /// A code this client build doesn't recognise. Treated as terminal to be safe. + Unknown(u32), +} + +impl PulseDisconnect { + /// Map an ENet disconnect `data` code to a reason. Mirrors the server's `DisconnectReason` enum + /// 1:1; unrecognised codes (a newer server) fall through to [`PulseDisconnect::Unknown`]. + pub fn from_code(code: u32) -> Self { + match code { + 0 => Self::None, + 1 => Self::Graceful, + 2 => Self::AuthTimeout, + 3 => Self::AuthFailed, + 4 => Self::DuplicateSession, + 5 => Self::Banned, + 6 => Self::ServerFull, + 7 => Self::PreAuthIpLimit, + 8 => Self::PreAuthBudget, + 9 => Self::InputRateExceeded, + 10 => Self::DiscreteEventRateExceeded, + 11 => Self::InvalidInputField, + 12 => Self::InvalidEmoteField, + 13 => Self::InvalidTeleportField, + 14 => Self::HandshakeReplayRejected, + 15 => Self::InvalidHandshakeField, + 16 => Self::PacketCorrupted, + other => Self::Unknown(other), + } + } + + /// Whether reconnecting could plausibly succeed. Only transient, server-side, or + /// too-slow-this-time reasons are retryable; auth/ban/eviction/misbehaviour reasons (and any + /// unrecognised code) are terminal — reconnecting against them just loops. + pub fn should_retry(self) -> bool { + matches!( + self, + Self::None + | Self::Graceful + | Self::AuthTimeout + | Self::ServerFull + | Self::PreAuthIpLimit + | Self::PreAuthBudget + ) + } +} + +/// Where to connect. Identity and realm live in the protocol layer, not here — the driver only +/// needs an address. +#[derive(Debug, Clone)] +pub struct PulseTransportConfig { + pub host: String, + pub port: u16, +} + +/// Protocol-layer end of the boundary (held by `PulseRoom`). +pub struct PulseLink { + pub outbound: mpsc::Sender, + pub inbound: mpsc::Receiver>, + pub status: mpsc::Receiver, +} + +/// Driver end of the boundary (moved into the `pulse-enet` thread). +pub struct PulseDriverChannels { + pub outbound: mpsc::Receiver, + pub inbound: mpsc::Sender>, + pub status: mpsc::Sender, +} + +/// Build a matched [`PulseLink`] / [`PulseDriverChannels`] pair. +pub fn pulse_channels(capacity: usize) -> (PulseLink, PulseDriverChannels) { + let (outbound_tx, outbound_rx) = mpsc::channel(capacity); + let (inbound_tx, inbound_rx) = mpsc::channel(capacity); + let (status_tx, status_rx) = mpsc::channel(16); + + ( + PulseLink { + outbound: outbound_tx, + inbound: inbound_rx, + status: status_rx, + }, + PulseDriverChannels { + outbound: outbound_rx, + inbound: inbound_tx, + status: status_tx, + }, + ) +} + +/// Owns the running driver. Dropping (or `stop()`) tears it down and joins the thread. +pub struct PulseDriverHandle { + stop: Arc, + join: Option>, +} + +impl PulseDriverHandle { + #[allow(dead_code)] + pub fn stop(&self) { + self.stop.store(true, Ordering::Relaxed); + } +} + +impl Drop for PulseDriverHandle { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(join) = self.join.take() { + let _ = join.join(); + } + } +} + +/// Spawn the ENet driver on its dedicated thread (ENet is a synchronous single-thread poll loop). +pub fn spawn_pulse_driver( + config: PulseTransportConfig, + channels: PulseDriverChannels, +) -> PulseDriverHandle { + let stop = Arc::new(AtomicBool::new(false)); + let join = super::native::spawn(config, channels, stop.clone()); + PulseDriverHandle { + stop, + join: Some(join), + } +} diff --git a/lib/src/dcl/components/proto_components.rs b/lib/src/dcl/components/proto_components.rs index 9938ef697..6191d8c83 100644 --- a/lib/src/dcl/components/proto_components.rs +++ b/lib/src/dcl/components/proto_components.rs @@ -221,6 +221,14 @@ pub mod kernel { } } +#[allow(clippy::all)] +pub mod pulse { + include!(concat!(env!("OUT_DIR"), "/decentraland.pulse.rs")); + // Quantize/dequantize accessors generated by build_quant.rs from the + // (decentraland.common.quantized[_power]) field options — see options.proto. + include!(concat!(env!("OUT_DIR"), "/pulse_quant.rs")); +} + pub mod social_service { // Include the error types from the social_service package include!(concat!(env!("OUT_DIR"), "/decentraland.social_service.rs")); @@ -293,3 +301,154 @@ fn deserialize_transform(data: &[u8]) -> Option { "parent": parent })) } + +// Exercises the build_quant.rs-generated accessors against the grids the Pulse +// server bakes from the same .proto options — a drift here means wrong world +// positions on the wire with no compile error anywhere else. +#[cfg(test)] +mod pulse_quant_tests { + use super::pulse; + + fn assert_approx(actual: f32, expected: f32, eps: f32) { + assert!( + (actual - expected).abs() <= eps, + "expected ~{expected}, got {actual} (eps {eps})" + ); + } + + #[test] + fn position_grids_match_server_scheme() { + // position_x/z: 8 bits over [0, 16] → step 16/255; encoded 128 → ≈ 8.031. + assert_approx(pulse::PlayerState::position_x_step(), 16.0 / 255.0, 1e-6); + let state = pulse::PlayerState { + position_x: 128, + ..Default::default() + }; + assert_approx(state.position_x_dequantized(), 8.031, 0.001); + assert_eq!(pulse::PlayerState::position_x_quantized(8.031), 128); + + // position_y: 13 bits over [0, 200] → step ≈ 0.0244. + assert_approx(pulse::PlayerState::position_y_step(), 200.0 / 8191.0, 1e-6); + } + + #[test] + fn linear_roundtrip_error_bounded_by_half_step() { + for value in [0.0f32, 0.03, 1.0, 7.99, 8.0, 15.97, 16.0] { + let encoded = pulse::PlayerState::position_x_quantized(value); + let decoded = pulse::PlayerState { + position_x: encoded, + ..Default::default() + } + .position_x_dequantized(); + assert_approx( + decoded, + value, + pulse::PlayerState::position_x_step() / 2.0 + 1e-6, + ); + } + } + + #[test] + fn rotation_seven_bits_full_circle() { + assert_eq!(pulse::PlayerState::rotation_y_quantized(0.0), 0); + assert_eq!(pulse::PlayerState::rotation_y_quantized(360.0), 127); + let encoded = pulse::PlayerState::rotation_y_quantized(90.0); + let decoded = pulse::PlayerState { + rotation_y: encoded, + ..Default::default() + } + .rotation_y_dequantized(); + assert_approx(decoded, 90.0, 360.0 / 127.0 / 2.0 + 1e-4); + } + + #[test] + fn power_law_velocity_zero_is_exact_and_sign_rides_the_lsb() { + // Exact zero: a stopped peer must decode to exactly 0.0, not the linear + // quantizer's ±half-step residual (the whole point of quantized_power). + assert_eq!(pulse::PlayerState::velocity_x_quantized(0.0), 0); + let stopped = pulse::PlayerState::default(); + assert_eq!(stopped.velocity_x_dequantized(), 0.0); + + // Sign in the LSB: same magnitude bits, opposite sign bit. + let pos = pulse::PlayerState::velocity_x_quantized(1.0); + let neg = pulse::PlayerState::velocity_x_quantized(-1.0); + assert_eq!(pos & 1, 0); + assert_eq!(neg & 1, 1); + assert_eq!(pos >> 1, neg >> 1); + + // pow=2 concentrates resolution at low speeds: 1 m/s round-trips tightly... + let decoded = pulse::PlayerState { + velocity_x: pos, + ..Default::default() + } + .velocity_x_dequantized(); + assert_approx(decoded, 1.0, 0.05); + // ...and the extremes clamp to the symmetric ±50 range. + let max_enc = pulse::PlayerState::velocity_x_quantized(50.0); + let max_dec = pulse::PlayerState { + velocity_x: max_enc, + ..Default::default() + } + .velocity_x_dequantized(); + assert_approx(max_dec, 50.0, 1e-3); + assert_eq!( + pulse::PlayerState::velocity_x_quantized(999.0), + max_enc, + "out-of-range clamps to max" + ); + } + + #[test] + fn delta_optional_fields_dequantize_to_option() { + let delta = pulse::PlayerStateDeltaTier0 { + position_x: Some(128), + ..Default::default() + }; + assert_approx(delta.position_x_dequantized().unwrap(), 8.031, 0.001); + assert_eq!(delta.position_y_dequantized(), None); + assert_eq!(delta.velocity_x_dequantized(), None); + } + + #[test] + fn point_at_covers_signed_world_span() { + // 17 bits over [-3000, 3000] → step ≈ 0.0458 m. + assert_approx( + pulse::PlayerState::point_at_x_step(), + 6000.0 / 131071.0, + 1e-6, + ); + assert_eq!(pulse::PlayerState::point_at_x_quantized(-3000.0), 0); + let origin = pulse::PlayerState::point_at_x_quantized(0.0); + let decoded = pulse::PlayerState { + point_at_x: Some(origin), + ..Default::default() + } + .point_at_x_dequantized() + .unwrap(); + assert_approx( + decoded, + 0.0, + pulse::PlayerState::point_at_x_step() / 2.0 + 1e-4, + ); + } + + #[test] + fn envelopes_roundtrip_through_prost() { + use prost::Message; + + let msg = pulse::ClientMessage { + message: Some(pulse::client_message::Message::Teleport( + pulse::TeleportRequest { + parcel_index: 54858, + position_x: pulse::TeleportRequest::position_x_quantized(1.0), + position_y: pulse::TeleportRequest::position_y_quantized(2.0), + position_z: pulse::TeleportRequest::position_z_quantized(3.0), + realm: "main".into(), + }, + )), + }; + let bytes = msg.encode_to_vec(); + let decoded = pulse::ClientMessage::decode(bytes.as_slice()).unwrap(); + assert_eq!(msg, decoded); + } +} diff --git a/lib/src/deep_link.rs b/lib/src/deep_link.rs index 6ca53ce76..206e77ff5 100644 --- a/lib/src/deep_link.rs +++ b/lib/src/deep_link.rs @@ -23,8 +23,8 @@ pub struct DeepLinkResult { pub is_walletconnect_callback: bool, /// Numbered profile slot pub saved_profile: String, - /// LiveKit debug flag - pub livekit_debug: bool, + /// Multiplayer debug panel flag + pub multiplayer_debug: bool, /// Routable path (e.g. "/jump", "/events", "/places", "/mobile") pub path: String, /// Dev/testing: short-circuit profile deploys so local changes never publish. @@ -160,8 +160,9 @@ pub fn parse_deep_link(url_str: &str) -> Option { result.saved_profile = n.to_string(); } } - "livekit_debug" => { - result.livekit_debug = value.eq_ignore_ascii_case("true") || value == "1"; + // "livekit_debug" is the legacy name, kept so old shared links keep working + "multiplayer_debug" | "livekit_debug" => { + result.multiplayer_debug = value.eq_ignore_ascii_case("true") || value == "1"; } "disable-profile-deploy" => { result.disable_profile_deploy = value.eq_ignore_ascii_case("true") || value == "1"; @@ -479,9 +480,15 @@ mod tests { } #[test] - fn livekit_debug() { + fn multiplayer_debug() { + let r = parse("decentraland://open?multiplayer_debug=true"); + assert!(r.multiplayer_debug); + } + + #[test] + fn multiplayer_debug_legacy_alias() { let r = parse("decentraland://open?livekit_debug=true"); - assert!(r.livekit_debug); + assert!(r.multiplayer_debug); } #[test] @@ -631,7 +638,7 @@ mod tests { let r = parse("decentraland://open?location=1,2&realm=r1&rust-log=debug&livekit_debug=1"); assert_eq!(r.location, Some((1, 2))); assert_eq!(r.realm, "r1"); - assert!(r.livekit_debug); + assert!(r.multiplayer_debug); assert_eq!(get_param(&r, "rust-log"), Some("debug")); } diff --git a/lib/src/godot_classes/dcl_avatar.rs b/lib/src/godot_classes/dcl_avatar.rs index 402c32ccb..fd698fe5a 100644 --- a/lib/src/godot_classes/dcl_avatar.rs +++ b/lib/src/godot_classes/dcl_avatar.rs @@ -176,6 +176,29 @@ impl DclAvatar { self.update_parcel_position(self.lerp_state.target_position); } + /// Instant reposition (teleport): place the avatar at the target with no interpolation — + /// lerping across a discontinuous jump would drag the avatar through the world. + #[func] + pub fn snap_to_position(&mut self, new_target: Transform3D) { + self.walk = false; + self.run = false; + self.jog = false; + self.rise = false; + self.fall = false; + self.land = true; + + self.lerp_state.initial_position = new_target.origin; + self.lerp_state.target_position = new_target.origin; + self.lerp_state.factor = 1.0; + self.lerp_state.initial_velocity_y = 0.0; + + self.base_mut() + .set_global_rotation(new_target.basis.get_euler()); + self.base_mut().set_global_position(new_target.origin); + + self.update_parcel_position(new_target.origin); + } + // This function is called when a parcel scene is created, // it handles the corner case where the avatar is already in the parcel // that is being created @@ -303,6 +326,20 @@ impl DclAvatar { .lerp(self.lerp_state.target_position, self.lerp_state.factor); self.base_mut().set_global_position(new_position); + } else if self.lerp_state.factor > 3.0 + && (self.walk || self.jog || self.run || self.rise || self.fall) + { + // The locomotion flags are derived per-update in set_target_position from + // the distance to the previous target, so they latch until the next packet. + // LiveKit streams ~10 Hz and self-corrects, but Pulse goes silent when the + // peer stands still (delta protocol) — decay to idle once the stream pauses + // (factor 1.0 == one 100 ms packet interval; 3.0 == 300 ms of silence). + self.walk = false; + self.jog = false; + self.run = false; + self.rise = false; + self.fall = false; + self.land = true; } } } diff --git a/lib/src/godot_classes/dcl_cli.rs b/lib/src/godot_classes/dcl_cli.rs index 6142e919e..7a57ca366 100644 --- a/lib/src/godot_classes/dcl_cli.rs +++ b/lib/src/godot_classes/dcl_cli.rs @@ -174,6 +174,20 @@ pub struct DclCli { pub avatar_impostor_benchmark_output: GString, #[var(get)] pub saved_profile: GString, + + // Pulse transport (opt-in; see comms/pulse/). `pulse_server` empty = default endpoint. + #[var(get)] + pub pulse: bool, + #[var(get)] + pub pulse_server: GString, + // Dual-channel movement kill switch: when set, movement stops going over LiveKit while + // Pulse is established (it always resumes if Pulse drops, so it can't cause invisibility). + #[var(get)] + pub no_livekit_movement: bool, + // Pulse-only mode: no LiveKit-backed rooms at all (no chat/voice/scene messages). + // Dev/testing switch; deeplink `livekit=false` is the runtime equivalent. + #[var(get)] + pub no_livekit: bool, } impl DclCli { @@ -555,6 +569,31 @@ impl DclCli { arg_type: ArgType::Flag, category: "Testing".to_string(), }, + // Comms + ArgDefinition { + name: "--pulse".to_string(), + description: "Enable the Pulse transport (ENet/UDP avatar-state relay); coexists with LiveKit".to_string(), + arg_type: ArgType::Flag, + category: "Comms".to_string(), + }, + ArgDefinition { + name: "--pulse-server".to_string(), + description: "Pulse server endpoint as host:port (implies --pulse); PULSE_SERVER env var works too".to_string(), + arg_type: ArgType::Value("".to_string()), + category: "Comms".to_string(), + }, + ArgDefinition { + name: "--no-livekit-movement".to_string(), + description: "Stop sending movement over LiveKit while Pulse is established (dual-channel movement off; auto-resumes if Pulse drops)".to_string(), + arg_type: ArgType::Flag, + category: "Comms".to_string(), + }, + ArgDefinition { + name: "--no-livekit".to_string(), + description: "Pulse-only mode: skip all LiveKit rooms (main/island/scene — no chat, voice or scene messages). Dev/testing".to_string(), + arg_type: ArgType::Flag, + category: "Comms".to_string(), + }, ] } @@ -809,6 +848,22 @@ impl INode for DclCli { .map(|n| GString::from(&n.to_string())) .unwrap_or_default(); + // Pulse transport activation (opt-in). --pulse-server implies --pulse; the PULSE_SERVER + // env var (desktop; bevy parity) implies both. Empty pulse_server = default endpoint + // (urls::pulse_server() : PULSE_SERVER_PORT). + let pulse_server = args_map + .get("--pulse-server") + .and_then(|v| v.as_ref()) + .map(GString::from) + .unwrap_or_else(|| { + std::env::var("PULSE_SERVER") + .map(|s| GString::from(s.as_str())) + .unwrap_or_default() + }); + let pulse = args_map.contains_key("--pulse") || !pulse_server.is_empty(); + let no_livekit_movement = args_map.contains_key("--no-livekit-movement"); + let no_livekit = args_map.contains_key("--no-livekit"); + // Convert combined args back to PackedStringArray for storage let args: PackedStringArray = args_vec.iter().cloned().collect(); @@ -873,6 +928,10 @@ impl INode for DclCli { fi_benchmark_output, avatar_impostor_benchmark_output, saved_profile, + pulse, + pulse_server, + no_livekit_movement, + no_livekit, } } } diff --git a/lib/src/godot_classes/dcl_parse_deep_link.rs b/lib/src/godot_classes/dcl_parse_deep_link.rs index 19f1e76a2..400920fea 100644 --- a/lib/src/godot_classes/dcl_parse_deep_link.rs +++ b/lib/src/godot_classes/dcl_parse_deep_link.rs @@ -42,9 +42,10 @@ pub struct DclParseDeepLink { #[var] saved_profile: GString, - /// Enable LiveKit debug panel from deep link (livekit_debug=true) + /// Enable the multiplayer debug panel from deep link (multiplayer_debug=true; + /// legacy alias: livekit_debug=true) #[var] - livekit_debug: bool, + multiplayer_debug: bool, /// The URL path component (e.g., "/jump", "/events", "/places", "/mobile") #[var] @@ -108,7 +109,7 @@ impl DclParseDeepLink { is_walletconnect_callback: false, dclenv: GString::new(), saved_profile: GString::new(), - livekit_debug: false, + multiplayer_debug: false, path: GString::new(), disable_profile_deploy: false, fake_owned_wearables: PackedStringArray::new(), @@ -140,7 +141,7 @@ impl DclParseDeepLink { dclenv: GString::from(&r.dclenv), is_walletconnect_callback: r.is_walletconnect_callback, saved_profile: GString::from(&r.saved_profile), - livekit_debug: r.livekit_debug, + multiplayer_debug: r.multiplayer_debug, path: GString::from(&r.path), disable_profile_deploy: r.disable_profile_deploy, fake_owned_wearables: PackedStringArray::from_iter( diff --git a/lib/src/godot_classes/dcl_social_service.rs b/lib/src/godot_classes/dcl_social_service.rs index e9c1c5b14..9a8523507 100644 --- a/lib/src/godot_classes/dcl_social_service.rs +++ b/lib/src/godot_classes/dcl_social_service.rs @@ -1334,6 +1334,13 @@ impl DclSocialService { } fn emit_block_update_signal(node: &mut Gd, update: BlockUpdate) { + // Empty payloads decode as a default BlockUpdate (stream keep-alive/teardown + // artifact) — not a real block event, so don't forward it to GDScript + if update.address.is_empty() { + tracing::debug!("Ignoring empty BlockUpdate (stream keep-alive/teardown artifact)"); + return; + } + // BlockUpdate has fields: address (string) and is_blocked (bool) let address = update.address; let is_blocked = update.is_blocked; diff --git a/lib/src/scene_runner/components/billboard.rs b/lib/src/scene_runner/components/billboard.rs index a7ad0d3b6..9ca23b4e2 100644 --- a/lib/src/scene_runner/components/billboard.rs +++ b/lib/src/scene_runner/components/billboard.rs @@ -115,6 +115,10 @@ mod test { scene.godot_dcl_scene.ensure_node_3d(&entity); SceneCrdtStateProtoComponents::get_billboard_mut(&mut crdt_state).put( entity, + // needless_update is proto-vintage-dependent: with pre-target_entity protos + // billboard_mode is the only field, but the pinned npm protocol adds + // target_entity, where the update fills it. Keep it for both vintages. + #[allow(clippy::needless_update)] Some(PbBillboard { billboard_mode: Some(3), ..Default::default() diff --git a/lib/src/urls/mod.rs b/lib/src/urls/mod.rs index cf02b700c..2b5d16446 100644 --- a/lib/src/urls/mod.rs +++ b/lib/src/urls/mod.rs @@ -126,6 +126,20 @@ pub fn identity_content() -> Option { } // Comms +/// Pulse (ENet avatar-state relay) host — no scheme/port; the game port is +/// `comms::consts::PULSE_SERVER_PORT` (UDP 7777). Pulse follows the `comms` +/// service group (it is part of the comms stack): `dclenv=zone` or +/// `dclenv=comms::zone,org` both point it at the zone deployment. Pulse only +/// has org + zone deployments (Unity: `pulse-server.decentraland.{ENV}`), so +/// Today maps to zone. +#[cfg(feature = "use_pulse")] +pub fn pulse_server() -> String { + let suffix = match resolved_env(ServiceGroup::Comms) { + DclEnvironment::Org => "org", + _ => "zone", + }; + format!("pulse-server.decentraland.{suffix}") +} pub fn comms_gatekeeper() -> String { format!( "https://comms-gatekeeper.decentraland.{}/get-scene-adapter", diff --git a/src/install_dependency.rs b/src/install_dependency.rs index 2a0a78f3f..583f9bb98 100644 --- a/src/install_dependency.rs +++ b/src/install_dependency.rs @@ -34,15 +34,18 @@ fn create_directory_all(path: &Path) -> io::Result<()> { // Resolve @dcl/protocol from the npm `next` dist-tag (see PROTOCOL_NPM_DIST_TAG), // unless PROTOCOL_FIXED_VERSION_URL pins a specific tarball. // -// Pinned for the 1.11.0 RC: tracking `next` re-resolves on every CI run, so an +// Pinning rationale: tracking `next` re-resolves on every CI run, so an // upstream protocol publish can break or change builds with no repo change // (e.g. PBBillboard.target_entity landed mid-RC and broke the billboard itest). -// Bump the pin deliberately — grab the new tarball URL from -// `https://registry.npmjs.org/@dcl/protocol/next` (dist.tarball), update any -// affected generated-struct usages, and set it here. Reset to `None` to track -// @next again after the release is cut. +// Bump the pin deliberately and update any affected generated-struct usages. +// +// Currently pinned to the protocol PR #429 branch build (feat/pulse-prd, +// commit 45edead) — it ships the decentraland/pulse protos, options.proto +// (quantization FieldOptions) and rfc4 PlayerEmote.is_stopping that the Pulse +// transport needs. Once that PR merges, repoint at the released tarball from +// `https://registry.npmjs.org/@dcl/protocol/next` (dist.tarball). const PROTOCOL_FIXED_VERSION_URL: Option<&str> = Some( - "https://registry.npmjs.org/@dcl/protocol/-/protocol-1.0.0-28974105118.commit-a598406.tgz", + "https://sdk-team-cdn.decentraland.org/@dcl/protocol/branch//dcl-protocol-1.0.0-29255694007.commit-45edead.tgz", ); const PROTOCOL_NPM_DIST_TAG: &str = "next";