#include "session_internal.h" #include "Engine/Game/World/world_names.h" #include "Extension/Multiplayer/Hud/native_player_ui.h" #include "Extension/Multiplayer/Remote/native_audio.h" #include "Extension/Multiplayer/Steam/steam_social.h" #include "Extension/Multiplayer/Hud/custom_nametags.h" #include "Extension/Multiplayer/Hud/game_ui_state.h" #include "Extension/Profile/local_profile_runtime.h" #include "Extension/Objects/network_object_runtime.h" #include "Engine/Core/Log/logging.h" #include "Engine/Core/Text/word_filter.h" #include "Extension/Skater/physics_tuning.h" #include #include #include #include namespace dingosdk::multiplayer::session_detail { // ---- saved host settings ("ReSkate.Host.*" in the local profile) void load_host_preferences(Session &s) { auto &p = s.host_preferences; if (p.loaded) return; if (!profile_runtime::local_profile_store_ready()) return; p.loaded = true; const auto number = [](const char *key) -> std::optional { const auto value = profile_runtime::local_value(key); if (!value || !value->is_number()) return {}; const auto result = value->get(); return std::isfinite(result) && std::abs(result) < 1e9 ? std::optional(static_cast(result)) : std::nullopt; }; p.public_lobby = profile_runtime::local_preference("Host.Public").value_or(true); p.password_required = profile_runtime::local_preference("Host.RequirePassword").value_or(false); p.world_layer_sync = profile_runtime::local_preference("Host.WorldLayerSync").value_or(false); // A limit saved before lobbies were capped comes back as the cap. if (const auto value = number("Host.Capacity"); value && *value >= 2 && *value <= static_cast(max_players)) p.capacity = static_cast(std::min(*value, multiplayer_lobby_player_limit)); if (const auto value = number("Host.Tps"); value && *value > 0 && valid_multiplayer_tps(static_cast(*value))) p.tps = static_cast(*value); if (const auto value = profile_runtime::local_value("Host.LobbyName"); value && value->is_string() && value->string().size() <= 128) p.lobby_name = value->string(); MultiplayerDistances distances; const std::array, 4> fields{{ {"Host.Distance.FullReturn", &distances.full_rate_return}, {"Host.Distance.HalfStart", &distances.half_rate_start}, {"Host.Distance.HalfReturn", &distances.half_rate_return}, {"Host.Distance.LowStart", &distances.low_rate_start}}}; for (const auto &[key, field] : fields) if (const auto value = number(key)) *field = static_cast(*value); if (distances.valid()) p.distances = distances; if (const auto value = profile_runtime::local_value("Host.VoiceRange"); value && value->is_number() && valid_voice_range(value->get())) p.voice_range = value->get(); if (const auto value = number("Host.ObjectPlacement"); value && *value >= 0 && valid_object_placement(static_cast(*value))) p.placement = static_cast(*value); if (const auto value = number("Host.ObjectLimit"); value && *value >= 0 && valid_object_limit(static_cast(*value))) p.object_limit = static_cast(*value); p.guest_noclip = profile_runtime::local_preference("Host.GuestNoclip").value_or(true); p.guest_no_bail = profile_runtime::local_preference("Host.GuestNoBail").value_or(true); p.guest_boosts = profile_runtime::local_preference("Host.GuestBoosts").value_or(true); p.enforce_tuning = profile_runtime::local_preference("Host.EnforceTuning").value_or(true); p.score_check = profile_runtime::local_preference("Host.ScoreCheck").value_or(true); } void save_host_preferences(const Session &s) { const auto &p = s.host_preferences; profile_runtime::set_local_values({ {"Host.Public", p.public_lobby}, {"Host.RequirePassword", p.password_required}, {"Host.WorldLayerSync", p.world_layer_sync}, {"Host.Capacity", static_cast(p.capacity)}, {"Host.Tps", static_cast(p.tps)}, {"Host.LobbyName", p.lobby_name}, {"Host.Distance.FullReturn", static_cast(p.distances.full_rate_return)}, {"Host.Distance.HalfStart", static_cast(p.distances.half_rate_start)}, {"Host.Distance.HalfReturn", static_cast(p.distances.half_rate_return)}, {"Host.Distance.LowStart", static_cast(p.distances.low_rate_start)}, {"Host.ObjectPlacement", static_cast(p.placement)}, {"Host.ObjectLimit", static_cast(p.object_limit)}, {"Host.GuestNoclip", p.guest_noclip}, {"Host.GuestNoBail", p.guest_no_bail}, {"Host.GuestBoosts", p.guest_boosts}, {"Host.EnforceTuning", p.enforce_tuning}, {"Host.ScoreCheck", p.score_check}, {"Host.VoiceRange", static_cast(p.voice_range)}}); } // Bans live in the local profile as [{"id": "", "name": ..., "added": }]; // the ID is a string because a JSON number cannot hold every 64-bit value. void load_bans(Session &s) { if (s.bans_loaded) return; s.bans_loaded = true; s.bans.clear(); s.ban_ids_dirty = true; const auto value = profile_runtime::local_value("Host.Bans"); if (!value || !value->is_array()) return; for (const auto &entry : *value) { if (!entry.is_object() || !entry.contains("id") || !entry.at("id").is_string()) continue; MultiplayerBan ban; const auto &text = entry.at("id").string(); const auto parsed = std::from_chars(text.data(), text.data() + text.size(), ban.id); if (parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size() || !ban.id) continue; if (entry.contains("name") && entry.at("name").is_string() && entry.at("name").string().size() <= 128) ban.name = entry.at("name").string(); if (entry.contains("added") && entry.at("added").is_number()) ban.added = entry.at("added").get(); if (std::none_of(s.bans.begin(), s.bans.end(), [&](const auto &b) { return b.id == ban.id; })) s.bans.push_back(std::move(ban)); } } void save_bans(const Session &s) { Json list = Json::array(); for (const auto &ban : s.bans) { auto item = Json::object(); item["id"] = std::to_string(ban.id); item["name"] = ban.name; item["added"] = ban.added; list.push_back(std::move(item)); } profile_runtime::set_local_values({{"Host.Bans", std::move(list)}}); } bool is_banned(Session &s, std::uint64_t id) { load_bans(s); // The host checks every connection each frame; keep the IDs sorted for that. if (s.ban_ids_dirty) { s.ban_ids_dirty = false; s.ban_ids.clear(); for (const auto &ban : s.bans) s.ban_ids.push_back(ban.id); std::sort(s.ban_ids.begin(), s.ban_ids.end()); } return std::binary_search(s.ban_ids.begin(), s.ban_ids.end(), id); } void refresh_friends(Session &s) { const auto social = steam_social_snapshot(); if (!social || (s.friend_revision && *s.friend_revision == social->revision)) return; s.friend_revision = social->revision; s.friend_ids.clear(); for (const auto &f : social->friends) s.friend_ids.push_back(f.id); std::sort(s.friend_ids.begin(), s.friend_ids.end()); } void publish(Session &s, const NativeFrame *local) { // Chat stays off screen in the main and pause menus and while the game hides its UI, as // the nametags do. const auto ui = sample_game_ui_state(s.base); s.game_menu = ui.in_menu || ui.ui_hidden; MultiplayerModel view; load_bans(s); view.bans = s.bans; view.voice = s.voice.model(); view.voice.allowed = s.voice_policy.allowed && (s.mode != Mode::join || s.roster_sequence != 0); if (!view.voice.allowed) { view.voice.transmitting = view.voice.ready = false; view.voice.status = s.mode == Mode::join && !s.roster_sequence ? "Waiting for the host's voice policy." : "Voice chat is disabled by the host."; } view.tps = s.tps; view.distances = s.distances; view.object_placement = s.object_placement; view.object_limit = s.object_limit; view.object_scaling = s.object_scaling || (s.role_permissions & role_objects) || s.mode != Mode::join; view.object_limit_own = s.mode == Mode::host || (s.role_permissions & role_objects) ? 0 : s.object_limit; // as apply_object_limit gives this game view.objects_placed = s.mode == Mode::off ? 0 : static_cast(s.local_objects.objects().size()); view.guest_noclip = s.guest_noclip; view.guest_no_bail = s.guest_no_bail; view.guest_boosts = s.guest_boosts; view.enforce_tuning = s.enforce_tuning; view.tuning_status = s.mode == Mode::join && s.enforce_tuning ? physics_tuning::status() : std::string{}; view.force_world_layers = s.force_world_layers; view.host_id = s.host_id; const auto now = now_us(); view.client_timing = s.client_timing.snapshot(now); const auto &t = s.transport.status(); view.password_required = s.password.has_value(); view.direct_upload_limit = s.direct_upload.limit; view.active = s.mode != Mode::off; view.hosting = s.mode == Mode::host; view.map_fetching = s.fetching_since != 0; view.echo = s.mode == Mode::echo; view.local_id = t.local_id; view.local_name = s.mode != Mode::off ? s.transport.name(t.local_id) : steam_social_snapshot()->local.name; view.lobby_name = s.lobby_name; // A dedicated server's roster capacity counts the server itself. view.capacity = static_cast(dedicated_host(s) ? s.capacity - 1 : s.capacity); load_host_preferences(s); view.saved_host = {s.host_preferences.loaded, s.host_preferences.public_lobby, s.host_preferences.password_required, static_cast(s.host_preferences.capacity), s.host_preferences.tps, s.host_preferences.lobby_name}; view.nametags = s.nametags; if (const auto social = steam_social_snapshot()) view.steam_ids_shown = identity_listed(social->local.id, IdentityList::developer) || identity_listed(social->local.id, IdentityList::staff) || identity_listed(social->local.id, IdentityList::homie); if (const auto social = steam_social_snapshot()) if (const auto mark = identity_mark(social->local.id)) { std::tie(view.identity_tag_colour, view.identity_tag) = mark_role(*mark); view.identity_animation = *mark == IdentityList::developer ? "RAINBOW" : *mark == IdentityList::content_creator ? "RED" : *mark == IdentityList::centrix ? "BLUE" : *mark == IdentityList::staff ? "GREEN" : "GOLD"; // A developer's standard is the rainbow already. view.identity_rainbow = *mark == IdentityList::staff; const auto styles = developer_hoodie_detail::own_styles.load(); const auto standard = developer_hoodie_detail::standard_picks(*mark); view.identity_styles.resize(styles.size()); for (std::size_t i = 0; i < styles.size(); ++i) { // A cosmetic never given colours shows its list's own in the pickers. // The rainbow has no colours of its own to show either. const bool rainbow = view.identity_rainbow && rainbow_style(styles[i]); const bool picked = !rainbow && (styles[i].mode == MarkMode::gradient || styles[i].mode == MarkMode::solid || styles[i].from != styles[i].to || styles[i].from != std::array{}); const auto &from = picked ? styles[i].from : standard.first, &to = picked ? styles[i].to : standard.second; auto &shown = view.identity_styles[i]; shown.name = mark_item_names[i]; shown.mode = rainbow ? 4 : static_cast(styles[i].mode); shown.speed = styles[i].speed; for (std::size_t part = 0; part < 3; ++part) shown.from[part] = static_cast(from[part]) / 255.f, shown.to[part] = static_cast(to[part]) / 255.f; } } view.identity_tag_shown = own_tag_shown(); view.identity_items_shown = own_items_shown(); load_host_preferences(s); view.voice_range = s.mode == Mode::host ? s.voice_range : dedicated_host(s) ? s.roster_voice_range : s.host_preferences.voice_range; view.dedicated = dedicated_host(s); view.server_admin = view.dedicated && s.server_admin; if (view.dedicated) { view.server_role = s.role; view.server_role_tag = s.own_tag; view.server_all_commands = s.role_all_commands; view.server_commands = s.role_commands; view.server_permissions = s.role_permissions; } view.party = s.local_party; view.party_leader = s.local_party_leader; view.party_open = s.local_party_open; view.parties = s.mode == Mode::host || s.mode == Mode::join; for (const auto &invite : s.party_invites) { const auto *from = find_peer(s, invite.from); view.party_invites.push_back({invite.from, from && !from->member.name.empty() ? from->member.name : s.transport.name(invite.from)}); } if (server_can(view, "ban") || server_can(view, "unban") || server_can(view, "bans")) { view.bans = s.server_bans; view.server_bans = true; view.server_ban_total = s.server_ban_total; } if (server_can(view, "map")) { view.server_maps = s.server_maps; view.server_map_pool = s.server_map_pool; view.server_map_rotation = s.server_map_rotation; view.server_map_votes = (s.server_votes & server_vote_map) != 0; } view.player_distance = s.player_distance; view.prefer_direct = s.prefer_direct; view.nametag_distance = s.nametag_distance; view.nametag_dots = s.nametag_dots; view.nametags_friends = s.nametags_friends; view.chat_visible = s.chat_visible; view.chat_filter = s.chat_filter; view.chat_bubbles = s.chat_bubbles; view.chat_bubbles_own = s.chat_bubbles_own; view.chat_bubbles_distance = s.chat_bubbles_distance; view.chat_bubbles_duration = s.chat_bubbles_duration; view.chat_bubbles_history = s.chat_bubbles_history; view.object_status = network_object_status(); view.players = static_cast(player_count(s)); view.sent = t.sent; view.received = t.received; view.dropped = t.dropped; view.network_telemetry = t.telemetry; view.ping_ms = t.ping_ms; view.send_rate = t.send_rate; view.pending_bytes = t.pending_bytes; view.outgoing_bps = t.outgoing_bps; view.incoming_bps = t.incoming_bps; view.delivery_local = t.delivery_local; view.delivery_remote = t.delivery_remote; view.queue_us = t.queue_us; view.prioritized_connections = t.prioritized_connections; view.cosmetic_queue_us = t.cosmetic_queue_us; view.skipped_updates = t.skipped; view.send_failures = t.send_failures; view.invalid_messages = t.invalid_messages; view.sent_bytes = t.sent_bytes; view.raw_sent_bytes = t.raw_sent_bytes; view.invite = s.invite; if ((s.mode == Mode::host || s.mode == Mode::join) && s.host_id && s.secret) view.join_code = format_invite({s.host_id, s.secret}); view.map = s.map_name.empty() ? s.available_map : s.map_name; view.local_ready = s.gameplay_ready && !s.available_map.empty(); const auto &lobby = s.lobbies.status(); view.public_host = s.public_host; view.public_lobby = s.mode == Mode::host ? s.lobbies.hosted_lobby() : s.joined_public_lobby; view.lobby_listed = lobby.listed; view.lobby_searching = lobby.searching || s.servers.searching(); view.lobby_joining = lobby.joining; view.lobby_searched = lobby.searched; view.lobby_status = lobby.hosting; view.browser_status = lobby.browser; view.browse_ready_us = s.browsed ? s.browsed + multiplayer_browse_interval_us : 0; // Dedicated servers first: they are always up and never a stranger's own session. view.lobbies = s.servers.rows(); // Looked up as the list is shown: the team's list can arrive, or change, after a server was found. for (auto &server : view.lobbies) server.official = official_server(server.id); view.lobbies.insert(view.lobbies.end(), lobby.rows.begin(), lobby.rows.end()); // Steam friends by the public server or lobby their game says they are in. if (const auto social = steam_social_snapshot()) for (const auto &player : social->friends) { if (!player.session) continue; const auto row = std::find_if(view.lobbies.begin(), view.lobbies.end(), [&](const auto &entry) { return entry.id == player.session; }); if (row != view.lobbies.end() && row->friends.size() < 16) row->friends.push_back(player.name.empty() ? std::string("A friend") : player.name); } view.status = s.status; view.native_status = s.native_status; view.audio_captured = captured_audio_frames(); each_peer([&] { auto &p = s.peers[peer_slot]; const auto native_count = remote_pose_updates(), board_count = remote_board_pose_updates(); const auto animation = remote_animation_stats(); view.pose_updates += native_count; view.board_pose_updates += board_count; view.audio_played += played_audio_frames(); view.player_map_updates += player_map_updates(); if (!p.member.id) return; view.connected |= p.handshaken; if (dedicated_host(s) && p.member.id == s.host_id) { // The server is not a player: name the session after it instead. view.lobby_name = p.member.name; return; } view.remote_visible |= p.visible; const auto name = p.member.name.empty() ? std::to_string(p.member.id) : p.member.name; view.roster.push_back({p.member.id, name, p.handshaken, p.visible, p.native_status, p.cosmetic_status, native_audio_status(), player_ui_status(), {}, 0, 0}); view.roster.back().epoch = p.member.epoch; view.roster.back().party = p.member.party; view.roster.back().party_leader = p.member.party_leader; view.roster.back().party_member = party_member(s, p.member.id); view.roster.back().objects_solid = objects_solid(s, p.member.id); if (!p.rate_at) { p.rate_at = now; p.native_rate_count = native_count; p.board_rate_count = board_count; p.animation_rate_base = animation; } if (now - p.rate_at >= 1000000) { p.pose_hz = static_cast(p.pose_count - p.rate_count) * 1000000.f / static_cast(now - p.rate_at); const auto rate = 1000000.f / static_cast(now - p.rate_at); p.native_pose_hz = static_cast(native_count - std::min(native_count, p.native_rate_count)) * rate; p.native_board_hz = static_cast(board_count - std::min(board_count, p.board_rate_count)) * rate; const auto evaluations = animation.evaluated - std::min(animation.evaluated, p.animation_rate_base.evaluated); const auto applies = animation.evaluated + animation.skipped - std::min(animation.evaluated + animation.skipped, p.animation_rate_base.evaluated + p.animation_rate_base.skipped); const auto evaluation_us = animation.evaluation_us - std::min(animation.evaluation_us, p.animation_rate_base.evaluation_us); const auto apply_us = animation.apply_us - std::min(animation.apply_us, p.animation_rate_base.apply_us); p.native_animation_hz = static_cast(evaluations) * rate; p.native_animation_ms = evaluations ? static_cast(evaluation_us) / (1000.f * evaluations) : 0.f; p.pose_apply_ms = applies ? static_cast(apply_us) / (1000.f * applies) : 0.f; p.native_rate_count = native_count; p.board_rate_count = board_count; p.animation_rate_base = animation; p.rate_at = now; p.rate_count = p.pose_count; } auto &row = view.roster.back(); row.pose_hz = p.pose_hz; row.native_pose_hz = p.native_pose_hz; row.native_board_hz = p.native_board_hz; row.native_animation_hz = p.native_animation_hz; row.native_animation_ms = p.native_animation_ms; row.pose_apply_ms = p.pose_apply_ms; row.native_animation_skipped = animation.skipped; const auto playback = p.poses.playback(); row.playback = s.mode == Mode::echo ? "Buffered echo" : playback.mode == PosePlaybackMode::buffered ? "Buffered" : playback.mode == PosePlaybackMode::predicted ? "Predicting" : playback.mode == PosePlaybackMode::held ? "Holding" : "Waiting"; row.prediction_ms = static_cast(playback.prediction_us / 1000); row.correcting = playback.correcting; row.pose_target_tps = 1000000 / p.received_pose_interval; if (p.latest_root && s.local_root) { float distance{}; for (unsigned i = 0; i < 3; ++i) { const auto d = p.latest_root->position[i] - s.local_root->position[i]; distance += d * d; } row.distance_m = std::sqrt(distance); } row.pose_age_ms = p.pose_arrival ? (now - p.pose_arrival) / 1000 : 0; const bool direct = s.mode == Mode::host || p.member.id == s.host_id || (p.direct_ready && p.last_direct_pose && now - p.last_direct_pose <= 500000); row.route = s.mode == Mode::echo ? "Local Echo" : direct ? "Steam peer" : "Via host"; if (s.mode != Mode::echo && p.handshaken) { if (direct) ++view.direct_connections; else ++view.fallback_streams; if (s.mode == Mode::host) for (const auto &other : active_peers(s)) if (other.handshaken && other.member.id != p.member.id && needs_relay(p.direct_routes, p.route_reported, other.member, now)) ++view.fallback_streams; } if (!view.peer_id) { view.peer_id = p.member.id; view.peer_name = name; view.native_status = p.native_status; view.cosmetic_status = p.cosmetic_status; view.audio_status = row.audio_status; // this slot's, read above view.player_ui_status = row.ui_status; } }); if (!s.cosmetic_capture_status.empty()) view.cosmetic_status = s.cosmetic_capture_status; view.lobby_joining = lobby.joining || (s.mode == Mode::join && !view.connected); if (local) { view.local_ready = local->ready; } view.skater_bones = s.last_skater_bones; view.board_bones = s.last_board_bones; publish_chat(s); // takes the same lock, so before it // Only this thread writes s.view, so it reads it without the lock. Log before taking // the lock the overlay copies the model under: file and console writes never hold it. if (view.status != s.view.status || view.players != s.view.players) logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: {} Players {}/{}", view.status, view.players, view.capacity); if (view.lobby_status != s.view.lobby_status && !view.lobby_status.empty()) logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: {}", view.lobby_status); if (view.connected && now - s.last_network_log >= 60000000) { // a summary a minute, not log spam const auto local_hz = s.last_network_log ? static_cast(s.local_pose_count - s.logged_local_pose_count) * 1000000. / static_cast(now - s.last_network_log) : 0.; s.logged_local_pose_count = s.local_pose_count; s.last_network_log = now; logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer network: players={}, direct={}, relay streams={}, out={:.1f} KiB/s, " "in={:.1f} KiB/s, queue={:.1f} ms, ping={} ms, local poses={:.1f}/s, " "direct budget={}, skipped={}, dropped={}.", view.players, view.direct_connections, view.fallback_streams, view.outgoing_bps / 1024.f, view.incoming_bps / 1024.f, static_cast(view.queue_us) / 1000., view.ping_ms, local_hz, view.direct_upload_limit, view.skipped_updates, view.dropped); // One line per player every 5 s adds up in a full lobby: debug level (off by default). if (logging::enabled(logging::Level::debug)) for (const auto &row : view.roster) logging::log(logging::Level::debug, logging::Channel::runtime, "Multiplayer delivery: peer={}, route={}, poses={:.1f}/s, target={} TPS, newest arrival={} ms ago, " "native skater/board={:.1f}/{:.1f}/s, graph={:.1f}/s ({:.3f} ms), " "pose apply={:.3f} ms, graph skips={}, playback={}, prediction={} ms, correcting={}.", row.id, row.route, row.pose_hz, row.pose_target_tps, row.pose_age_ms, row.native_pose_hz, row.native_board_hz, row.native_animation_hz, row.native_animation_ms, row.pose_apply_ms, row.native_animation_skipped, row.playback, row.prediction_ms, row.correcting); } std::lock_guard lock(s.mutex); s.view = std::move(view); } std::vector chat_commands(const Session &s) { std::vector list{ {"/help", "/help", "List these commands"}, {"/tp", "/tp ", "Teleport beside a player (or /tp )", "player"}, {"/p", "/p ", "Talk to your party only"}, }; if (s.mode == Mode::host || s.mode == Mode::join) { list.push_back({"/party invite", "/party invite ", "Invite a player to your party", "player"}); list.push_back({"/party accept", "/party accept [player]", "Join the party you were invited to", "player"}); list.push_back({"/party decline", "/party decline [player]", "Turn down a party invite", "player"}); list.push_back({"/party join", "/party join ", "Join a player's open party", "player"}); list.push_back({"/party leave", "/party leave", "Leave your party"}); list.push_back({"/party kick", "/party kick ", "Leader: remove a player from the party", "player"}); list.push_back({"/party promote", "/party promote ", "Leader: hand the lead to a player", "player"}); list.push_back({"/party open", "/party open", "Leader: let anyone join the party (/party close: invite only)"}); list.push_back({"/party", "/party", "Who is in your party"}); } if (dedicated_host(s)) { // The server's own commands (server_votes.cpp, server_host.cpp): it answers them, and // this list is only what the "/" menu offers, so one left out here still works unseen. list.push_back({"/w", "/w ", "Send a player a private message", "player"}); if (s.server_votes & server_vote_map) list.push_back({"/vote map", "/vote map ", "Start a vote to change the map", "map"}); if (s.server_votes & server_vote_kick) list.push_back({"/vote kick", "/vote kick ", "Start a vote to kick a player", "player"}); if (s.server_votes & server_vote_time) list.push_back({"/vote tod", "/vote tod