#include "session_internal.h" #include "Extension/Profile/local_profile_runtime.h" #include "Extension/Skater/drone_beacon.h" #include "Extension/Multiplayer/Net/wire_codec.h" #include "Engine/Core/Log/logging.h" #include "Engine/Game/Build/supported_build.h" #include #include #include #include #include namespace dingosdk::multiplayer { using namespace session_detail; namespace { Peer &reserve(Session &s, std::uint64_t id, std::uint64_t now) { if (auto *p = find_peer(s, id)) return *p; for (std::size_t slot = 0; slot < s.peers.size(); ++slot) if (auto &p = s.peers[slot]; !p.member.id) { p.member.id = id; p.last_packet = now; note_slot(s, slot); return p; } throw std::runtime_error("Session has no free player slots."); } void defer_cosmetics(Peer &link, const Packet &packet, std::uint64_t now) { auto &pending = link.pending_cosmetics; std::erase_if(pending, [&](const auto &item) { return now - item.received > 10000000; }); const auto found = std::find_if(pending.begin(), pending.end(), [&](const auto &item) { return item.packet.source == packet.source && item.packet.epoch == packet.epoch; }); if (found != pending.end()) { if (newer_sequence(packet.sequence, found->packet.sequence)) *found = {packet, now}; } else { if (pending.size() >= max_players) pending.erase(pending.begin()); pending.push_back({packet, now}); } } void receive_cosmetics(Session &s, const NativeFrame &local, std::uint64_t now) { for (auto &link : active_peers(s)) { auto pending = std::exchange(link.pending_cosmetics, {}); const bool direct = s.mode == Mode::join && link.member.id != s.host_id; for (auto &item : pending) { const auto &p = item.packet; if (p.world != s.world || now - item.received > 10000000) continue; auto *source = find_peer(s, p.source); // Lanes may deliver an outfit before welcome/roster/readiness. Keep // the bounded decoded value until those independent messages arrive; // never apply it before the normal identity/world/admission checks. if (!link.handshaken || !local.ready || s.awaiting_map || !s.host_world_ready || !s.started_map || (s.mode == Mode::host && !link.world_ready) || (direct && !link.direct_ready) || !source || !source->handshaken || source->member.epoch != p.epoch) { link.pending_cosmetics.push_back(std::move(item)); continue; } if (direct && dedicated_host(s)) continue; // only what the server passed on (identity_link) if (p.map == s.map && routed_source(p, source->member, link.member.id, s.mode == Mode::host, s.host_id, direct) && accept_data(*source, p, now) && s.mode == Mode::host) broadcast(s, p, true, false, now, p.source); } } } // Chat proofs. A guest takes chat from the host alone, which passes every other guest's lines // on, and a host can pass on what it likes under any of its players' names. That is no worse // than a name on a line, until the line shows a badge of the backend's (Dev, Creator, Homie): // then it is a line a developer never wrote, with their badge on it. So such a guest also // sends each line straight to the players Steam connects them to (send_chat), and here a line // the host passed on as theirs shows their badge only when that copy says the same. One // without it waits a moment for it, then shows as any other player's would: from an older // build, a party line (which the host writes), or not theirs at all. constexpr std::uint64_t chat_proof_wait = 500000, chat_proof_life = 10000000; constexpr std::size_t chat_proofs_kept = 8; // Whether a line the host passed on as this player's would show a badge that rests on who // they are: another guest's, known to Steam, on one of the backend's lists and showing it. bool owes_proof(const Session &s, const Peer &sender) { // Their own copy only comes over a direct connection: without one there is none to wait for. return s.mode == Mode::join && sender.member.id != s.host_id && sender.direct_ready && shows_tag(sender) && identity_mark(sender.member.id); } std::string chat_name(Session &s, const Peer &sender) { return sender.member.name.empty() ? s.transport.name(sender.member.id) : sender.member.name; } // Shows the lines that have waited long enough (or all of them), without the badge. void release_chat(Session &s, Peer &sender, std::uint64_t now, bool all) { auto &waiting = sender.chat_waiting; auto line = waiting.begin(); for (; line != waiting.end() && (all || now < line->at || now - line->at >= chat_proof_wait); ++line) add_chat(s, sender.member.id, chat_name(s, sender), std::move(line->text), false, false); waiting.erase(waiting.begin(), line); } // The host's copy of a line: shown at once when the sender's own copy is here, else it waits. void relayed_chat(Session &s, Peer &sender, const Packet &p, std::uint64_t now) { auto &proofs = sender.chat_proofs; const auto proof = std::find_if(proofs.begin(), proofs.end(), [&](const auto ©) { return copy.sequence == p.sequence && copy.text == p.text && now >= copy.at && now - copy.at <= chat_proof_life; }); const bool proven = proof != proofs.end(); if (proven) proofs.erase(proof); // The sender's copies come in the order of their lines: with this one's here, or none // owed for it, nothing is on its way for the lines before it. if (proven || p.text.starts_with("[Party] ")) { release_chat(s, sender, now, true); add_chat(s, sender.member.id, chat_name(s, sender), p.text, false, proven); return; } if (sender.chat_waiting.size() >= chat_proofs_kept) release_chat(s, sender, now, true); sender.chat_waiting.push_back({p.sequence, p.text, now}); } // The sender's own copy of a line, over their own connection. Never shown by itself. void proven_chat(Session &s, Peer &sender, const Packet &p, std::uint64_t now) { if (!owes_proof(s, sender)) return; auto &waiting = sender.chat_waiting; const auto held = std::find_if(waiting.begin(), waiting.end(), [&](const auto &line) { return line.sequence == p.sequence && line.text == p.text; }); if (held != waiting.end()) { for (auto line = waiting.begin(); line != held; ++line) add_chat(s, sender.member.id, chat_name(s, sender), std::move(line->text), false, false); add_chat(s, sender.member.id, chat_name(s, sender), std::move(held->text)); waiting.erase(waiting.begin(), held + 1); return; } auto &proofs = sender.chat_proofs; std::erase_if(proofs, [&](const auto ©) { return now < copy.at || now - copy.at > chat_proof_life; }); if (proofs.size() >= chat_proofs_kept) proofs.erase(proofs.begin()); proofs.push_back({p.sequence, p.text, now}); } // A hitch leaves a backlog of pose updates, all of which would be decoded in one frame. // Every pose delta references a reliable baseline, never another delta, so older deltas // of one stream in a batch can be dropped undecoded. Keep the newest ones playback may // still interpolate between (its delay plus a frame each side) among those whose baseline // is already here; baselines and every other kind are always read. // The poses in one message from a dedicated server (pose_batch.h), each as the packet it would // have been had it come alone. A message read in full is noted for the ack; one with a pose // this game could not rebuild (it never got what that pose builds on) is not, so the server // goes on building on what this game does hold. std::vector unpack_poses(Session &s, std::span bytes) { std::vector out; const auto batch = pose_batch::read(bytes); if (!batch || batch->world != static_cast(s.world) || batch->map != static_cast(s.map)) return out; // Streams of players who left are dropped once there are many more than there are players. if (s.pose_streams.size() > 2 * max_players) std::erase_if(s.pose_streams, [&](const auto &stream) { return !find_peer(s, stream.second.source); }); bool complete = true; out.reserve(batch->entries.size()); for (const auto &entry : batch->entries) { // A stream is opened only for a player in this session (a server sends no others; one // whose player is not known yet is not acked, so it comes again). auto rebuilt = entry.whole && !find_peer(s, entry.source) ? std::nullopt : pose_batch::rebuild(s.pose_streams, entry); if (!rebuilt) { complete = false; continue; } if (rebuilt->repeat) continue; Packet p; p.kind = PacketKind::pose; p.session = s.secret; p.map = s.map; p.world = s.world; p.source = rebuilt->source; p.epoch = rebuilt->epoch; p.sequence = rebuilt->sequence; p.time_us = rebuilt->time_us; p.player_collision = rebuilt->collision; p.pose_interval_us = rebuilt->rate == pose_batch::Rate::full ? multiplayer_pose_interval(s.tps) : rebuilt->rate == pose_batch::Rate::half ? 100000U : 200000U; p.pose = pose_codec::restore(rebuilt->pose); out.push_back(std::move(p)); } if (complete) { s.pose_ack.note(batch->number); s.pose_ack_due = true; } return out; } // The skaters' sound in one message from a dedicated server (sound_codec.h), each player's as // the packet it would have been had it come alone. std::vector unpack_sound(Session &s, std::span bytes) { std::vector out; if (s.sound_streams.size() > 2 * max_players) std::erase_if(s.sound_streams, [&](const auto &stream) { return !find_peer(s, stream.second.source); }); // (A stream only for a player in this session; it is sent whole again every couple of seconds.) auto heard = sound_codec::read(s.sound_streams, bytes, s.world, s.map, [&](std::uint64_t source) { return find_peer(s, source) != nullptr; }); if (!heard) return out; out.reserve(heard->size()); for (auto &one : *heard) { if (!valid_audio_batch(one.samples)) continue; Packet p; p.kind = PacketKind::audio; p.session = s.secret; p.map = s.map; p.world = s.world; p.source = one.source; p.epoch = one.epoch; p.sequence = one.sequence; p.time_us = one.time_us; p.audio = std::move(one.samples); out.push_back(std::move(p)); } return out; } std::vector stale_pose_deltas(Session &s, const std::vector &messages) { std::vector stale(messages.size()); struct Stream { std::uint64_t link{}, source{}; unsigned kept{}, keep{}; }; std::vector streams; for (auto i = messages.size(); i-- > 0;) { const auto &bytes = messages[i].bytes; // (A dedicated server sends none of these: what it sends that starts the same is sound.) if (dedicated_host(s) && messages[i].peer == s.host_id) continue; // Sparse pose patches (delta_codec.cpp): "RMS1"/"RMS2", source at 4, kind at 20. if (bytes.size() <= 34 || bytes[0] != 'R' || bytes[1] != 'M' || bytes[2] != 'S' || (bytes[3] != '1' && bytes[3] != '2')) continue; std::uint64_t source{}; for (unsigned b = 0; b < 8; ++b) source |= std::uint64_t{bytes[4 + b]} << (8 * b); if ((bytes[20] | (unsigned{bytes[21]} << 8)) != static_cast(PacketKind::pose)) continue; // One whose baseline has not arrived (it may be in this batch) is left to decode // or fail as before, and does not use up the count. std::uint64_t epoch{}; std::uint32_t reference{}; for (unsigned b = 0; b < 8; ++b) epoch |= std::uint64_t{bytes[12 + b]} << (8 * b); for (unsigned b = 0; b < 4; ++b) reference |= std::uint32_t{bytes[22 + b]} << (8 * b); const auto *link = find_peer(s, messages[i].peer); if (!link || !link->receiver.holds(source, PacketKind::pose, epoch, reference)) continue; auto stream = std::find_if(streams.begin(), streams.end(), [&](const auto &v) { return v.link == messages[i].peer && v.source == source; }); if (stream == streams.end()) { const auto *peer = find_peer(s, source); const std::uint32_t interval = peer && peer->received_pose_interval ? peer->received_pose_interval : 50000; const auto delay = std::max(100000, interval + 50000); streams.push_back({messages[i].peer, source, 0, std::clamp(delay / interval + 2, 2, 16)}); stream = std::prev(streams.end()); } if (stream->kept >= stream->keep) stale[i] = true; else ++stream->kept; } return stale; } } // namespace namespace session_detail { void apply_roster(Session &s, const Packet &p, std::uint64_t now) { auto *host = find_peer(s, s.host_id); if (!host || !host->handshaken || !trusted_roster(p, s.host_id, host->member.epoch, s.transport.status().local_id, s.epoch)) throw std::runtime_error("Host supplied an invalid player roster."); if (s.roster_sequence && !newer_sequence(p.sequence, s.roster_sequence)) return; s.roster_sequence = p.sequence; s.own_tag.clear(); s.own_tag_color = 0; for (const auto &m : p.members) if (m.id == s.transport.status().local_id) { if (game_server_steam_id(s.host_id)) { s.own_tag = m.tag; s.own_tag_color = m.tag_color; } set_local_party(s, m); s.local_speeding = m.speeding && game_server_steam_id(s.host_id); if (m.scoring && !s.local_scoring) add_chat(s, 0, "ReSkate", own_scoring_notice()); s.local_scoring = m.scoring; } if (s.voice_policy != p.voice_policy) s.voice.reset(); s.voice_policy = p.voice_policy; if (s.tps != p.tps) { s.tps = p.tps; s.next_send = 0; for (auto &peer : active_peers(s)) peer.pose_delivery = {}; } apply_object_placement(s, p.object_placement); apply_object_limit(s, p.object_limit); // after server_admin, which exempts an admin s.object_scaling = !dedicated_host(s) || p.object_scaling; s.sync_effects = !dedicated_host(s) || p.sync_effects; s.sync_drones = !dedicated_host(s) || p.sync_drones; drone::beacon::server_allows().store(s.sync_drones); apply_guest_tools(s, p.guest_noclip, p.guest_no_bail, p.guest_boosts); s.enforce_tuning = p.enforce_tuning; s.server_votes = dedicated_host(s) ? p.server_votes : 0; s.server_polls = dedicated_host(s) ? p.server_polls : 0; if (dedicated_host(s)) s.server_custom_votes = p.server_custom_votes; else s.server_custom_votes.clear(); publish_chat(s); // the "/" list follows the server's votes, its players and its maps if (s.object_clears && *s.object_clears != p.object_clears) s.clear_pending = true; s.object_clears = p.object_clears; s.force_world_layers = p.force_world_layers; s.layers = unpack_world_layers(p.layers); apply_host_world_layers(s.force_world_layers, s.layers); apply_distances(s, p.distances); apply_host_park_choices(p.parks); MemberSlots old{}; for (std::size_t i = 0; i < old.size(); ++i) old[i] = s.peers[i].member; const auto next = roster_slots(old, p.members, s.transport.status().local_id); for (std::size_t i = 0; i < next.size(); ++i) { if (old[i].id != next[i].id || old[i].epoch != next[i].epoch) { if (old[i].id && old[i].id != s.host_id) s.transport.disconnect(old[i].id, "Player left or rejoined the host roster."); release_chat(s, s.peers[i], now, true); // what they said just before leaving reset_peer(s, i); s.peers[i].last_packet = now; } // Someone the host just flagged (or who arrives flagged) for mods that change scoring or physics. if (next[i].id && next[i].scoring && !(old[i].id == next[i].id && old[i].scoring)) { const auto name = next[i].name.empty() ? s.transport.name(next[i].id) : next[i].name; add_chat(s, 0, "ReSkate", name + "'s mods change scoring or physics, so they are out of throwdowns and coop challenges."); } s.peers[i].member = next[i]; s.peers[i].handshaken = next[i].id != 0; if (next[i].id) note_slot(s, i); } s.capacity = p.capacity; s.roster_voice_range = p.voice_range; s.server_chat_badge = p.chat_badge; s.server_chat_text = p.chat_text; // The server's vote. A new one starts with no answer from this player, unless they started // it and the server counts that as a yes. A poll they started they still answer. if (dedicated_host(s)) { const auto local = s.transport.status().local_id; const bool poll = p.vote.kind == server_vote_poll; if (p.vote.id != s.vote.id) s.vote_mine = p.vote.id && !poll && p.vote.starter == local && p.vote.yes ? 1 : 0; s.vote = p.vote; s.vote_ends = now_us() + std::uint64_t{p.vote.seconds} * 1000000; // The Yes and No binds answer a yes/no vote; a poll is answered with the number keys, on // its card or with /1, /2... server_vote_open_flag.store(p.vote.id && !poll && p.vote.outcome == vote_running && p.vote.target != local, std::memory_order_relaxed); server_poll_answers_flag.store(p.vote.id && poll && p.vote.outcome == vote_running ? static_cast(std::min(p.vote.answers.size(), max_vote_answers)) : 0U, std::memory_order_relaxed); if (p.announcement.id != s.announcement.id) s.announcement_ends = now_us() + std::uint64_t{p.announcement.seconds} * 1000000; s.announcement = p.announcement; } ++s.party_revision; // anyone's party may have changed // A dedicated server knows players only by the name each sent in their hello. for (auto &peer : active_peers(s)) if (peer.member.id && peer.member.name.empty() && individual_steam_id(peer.member.id)) peer.member.name = s.transport.name(peer.member.id); // On a dedicated server games link only to prove who a player is (identity_link). if (dedicated_host(s)) { std::vector known; for (const auto &member : p.members) if (identity_link(s, member.id)) known.push_back(member); s.transport.allow_peers(known); } else s.transport.allow_peers(p.members); s.last_routes = 0; s.status = "Connected through Steam. Network updates: " + std::to_string(s.tps) + " TPS."; } bool accept_data(Peer &peer, const Packet &p, std::uint64_t now) { bool accepted{}; if (p.kind == PacketKind::cosmetics) { accepted = peer.outfit_budget.accept(now) && peer.appearance.push(p); if (accepted) { peer.cosmetic_packet = encode_wire(p); ++peer.cosmetic_revision; } } else if (p.kind == PacketKind::audio) accepted = peer.sound_budget.accept(now, p.audio.size()) && peer.audio.push(p, now); else if (p.kind == PacketKind::effects) { accepted = peer.effect_budget.accept(now); if (accepted) { const auto due = now + std::max(100000, peer.received_pose_interval + 50000); for (const auto &impact : p.impacts) peer.impacts.emplace_back(due, impact); while (peer.impacts.size() > 64) peer.impacts.pop_front(); } } else if (p.kind == PacketKind::drone) { accepted = drone::beacon::server_allows().load(std::memory_order_relaxed) && peer.effect_budget.accept(now); if (accepted) drone::beacon::receive(peer.member.id, peer.member.name, p.drone_position, p.drone_rotation, p.drone_active); } else if (p.kind == PacketKind::pose) accepted = peer.poses.push_validated(p, now); if (accepted) { peer.last_packet = now; if (p.kind == PacketKind::pose) { peer.pose_arrival = now; peer.latest_root = p.pose.root; peer.player_collision = p.player_collision; peer.received_pose_interval = p.pose_interval_us; ++peer.pose_count; } } return accepted; } bool accept_data(Peer &peer, Packet &&p, std::uint64_t now) { if (p.kind != PacketKind::pose) return accept_data(peer, std::as_const(p), now); const auto root = p.pose.root; const auto interval = p.pose_interval_us; const bool collision = p.player_collision; if (!peer.poses.push_validated(std::move(p), now)) return false; peer.player_collision = collision; peer.last_packet = now; peer.pose_arrival = now; peer.latest_root = root; peer.received_pose_interval = interval; ++peer.pose_count; return true; } void networking(Session &s, const NativeFrame &local, std::uint64_t now) { s.network_now = now; s.join_backoff.prune(now); trim_slots(s); if (world_playing(s, local)) s.local_root = local.pose.root; for (auto &peer : active_peers(s)) if (!peer.chat_waiting.empty()) release_chat(s, peer, now, false); s.transport.poll(); if (s.mode == Mode::join && s.transport.status().telemetry) { const auto &t = s.transport.status(); const auto peers = active_peers(s); const auto active = static_cast(std::count_if(peers.begin(), peers.end(), [&](const auto &p) { return p.member.id != s.host_id && p.direct_ready; })); s.direct_upload.update(now, t.queue_us, t.skipped, active); } const auto links = s.transport.status().peers; const auto physical = [&](std::uint64_t id) { return std::find_if(links.begin(), links.end(), [id](const auto &v) { return v.id == id; }); }; for (auto &p : active_peers(s)) { const auto id = p.member.id; if (!id) continue; const bool direct = s.mode == Mode::join && id != s.host_id; if (physical(id) == links.end()) { if (!direct) { disconnect(s, id, s.mode == Mode::join ? s.transport.status().detail : "A player disconnected."); if (s.mode == Mode::off) return; } else { if (p.connected_at || p.direct_ready) reset_direct(s, p, now); if (p.handshaken && now >= p.next_dial && (!dedicated_host(s) || identity_link(s, id)) && dial_peer(s.transport.status().local_id, id, s.host_id)) { p.next_dial = now + 3000000; s.transport.connect_peer(id); } } } } if (s.mode == Mode::join && physical(s.host_id) == links.end()) { // What the other end said as it closed the link, when it was said to this player // ("You were kicked...", "You are banned...", "Your mods..."), is put in front of // them (take_removed_notice): otherwise it is only the status line of a menu they // may never open. A link that just went away says nothing of the kind. if (const auto said = s.transport.status().detail; said.starts_with("You ") || said.starts_with("Your ")) s.removed_notice = said; stop(s, s.transport.status().detail); return; } // The backend's bans (reskate_banned) arrive while a session runs: a banned player stops // hosting theirs, and nobody stays with a banned host. A banned guest is for the host or // the server to turn away (below, and Host::tick), which a server may choose not to. if (s.mode == Mode::host && reskate_banned(s.transport.status().local_id)) { stop(s, std::string(banned_notice)); return; } if (s.mode == Mode::join && reskate_banned(s.host_id)) { stop(s, "This host is banned from ReSkate multiplayer."); return; } // The same for a player who may not host (banned_host): theirs stops, and nobody stays in it. if (s.mode == Mode::host && banned_host(s.transport.status().local_id)) { stop(s, std::string(banned_host_notice)); return; } if (s.mode == Mode::join && banned_host(s.host_id)) { stop(s, std::string(banned_host_lobby_notice)); return; } for (const auto &link : links) { if (s.mode == Mode::host && s.banned.contains(link.id)) { s.transport.disconnect(link.id, "You were kicked from this session."); continue; } if (s.mode == Mode::host && is_banned(s, link.id)) { s.transport.disconnect(link.id, "You are banned from this host's lobbies."); continue; } if (s.mode == Mode::host && reskate_banned(link.id)) { s.transport.disconnect(link.id, banned_notice.data()); continue; } auto *p = find_peer(s, link.id); if (!p && s.mode == Mode::host && s.join_backoff.waiting(link.id, now)) { s.transport.disconnect(link.id, "Too many failed attempts to join. Wait a little and try again."); continue; } if (!p && s.mode == Mode::host) p = &reserve(s, link.id, now); if (!p) { // The initial host is reserved here; all other identities need a roster. if (s.mode == Mode::join && link.id == s.host_id) p = &reserve(s, link.id, now); else { s.transport.disconnect(link.id, "Identity is not in the host roster."); continue; } } if (link.connected && !p->connected_at) p->connected_at = now; if (s.mode == Mode::join && link.id != s.host_id && world_playing(s, local) && link.connected && p->handshaken && !p->direct_ready && now - p->last_direct_hello >= 1000000) { send_required(s, link.id, encode_wire(packet(s, PacketKind::peer_hello, now))); p->last_direct_hello = now; } } auto *host = find_peer(s, s.host_id); if (s.mode == Mode::join && host && host->connected_at && !host->handshaken && (s.awaiting_map || !local.ready) && now - s.last_map_request >= 1000000) { auto request = packet(s, PacketKind::map_request, now); request.map = s.join_destination.empty() ? 0 : map_hash(s.join_destination); request.map_fetching = s.fetching_since != 0; if (s.password && host->password_challenge && request.map) { request.challenge = host->password_challenge; request.proof = password_proof(*s.password, s.secret, request.map, s.host_id, request.source, host->member.epoch, s.epoch, request.challenge); } send_required(s, s.host_id, encode_wire(request)); s.last_map_request = now; } if (s.mode == Mode::join && host && host->connected_at && !host->handshaken && local.ready && !s.awaiting_map && now - s.last_hello >= 1000000) { auto hello = packet(s, PacketKind::hello, now); // Our name, for a dedicated server that has no Steam friends list to look it up. hello.text = s.transport.name(hello.source); while (!hello.text.empty() && !valid_member_name(hello.text)) hello.text.pop_back(); if (s.password && host->password_challenge) { hello.challenge = host->password_challenge; hello.proof = password_proof(*s.password, s.secret, s.map, s.host_id, hello.source, host->member.epoch, s.epoch, hello.challenge); } send_required(s, s.host_id, encode_wire(hello)); s.last_hello = now; } const auto messages = s.transport.receive(); const auto stale = stale_pose_deltas(s, messages); // A message of several poses from a dedicated server is taken apart, and the loop goes // round once for each pose in it as if it had come alone. std::vector batch; std::size_t batch_at{}; for (std::size_t index = 0; index < messages.size(); batch_at < batch.size() ? index : ++index) { const auto &message = messages[index]; auto *link = find_peer(s, message.peer); if (!link) { batch.clear(); batch_at = 0; continue; } const bool direct_link = s.mode == Mode::join && message.peer != s.host_id; const bool resumed = batch_at < batch.size(); if (!resumed) { batch.clear(); batch_at = 0; if (!link->budget.accept(message.arrived ? message.arrived : now, message.bytes.size(), s.mode == Mode::join && !direct_link ? max_remote_players : 1U)) { disconnect(s, message.peer, "Peer exceeded the multiplayer packet limit."); if (s.mode == Mode::off) return; continue; } if (stale[index]) continue; // A dedicated server's own messages (pose_batch.h, sound_codec.h). Only what comes // from the server is read as one: sound_codec's first four bytes are also those of // the pose updates games send each other directly (delta_codec's "RMS1"), which // must go on to be decoded as what they are. if (s.mode == Mode::join && !direct_link && dedicated_host(s)) { // The server saying which of this game's own pose messages it read. if (const auto ack = pose_batch::Ack::read(message.bytes)) { s.pose_upload.ack(*ack); continue; } if (sound_codec::is_sound(message.bytes)) { batch = unpack_sound(s, message.bytes); if (batch.empty()) continue; } else if (pose_batch::is_batch(message.bytes)) { batch = unpack_poses(s, message.bytes); if (batch.empty()) continue; } } } bool missing_reference{}; std::optional decoded; if (batch_at < batch.size()) decoded = std::move(batch[batch_at++]); else decoded = link->receiver.receive(message.bytes, missing_reference, s.world); if (missing_reference) continue; if (!decoded || decoded->session != s.secret || ((s.mode == Mode::host || direct_link) && decoded->source != message.peer)) { // Which of the three it was, and what the message looked like: the status line // says only that one of them failed (2026-10-13: joins to updated servers ended // here with nothing to tell them apart). try { std::string start; for (std::size_t i = 0; i < message.bytes.size() && i < 16; ++i) start += std::format("{:02x}", static_cast(message.bytes[i])); dingosdk::logging::log(dingosdk::logging::Level::warning, dingosdk::logging::Channel::runtime, "Multiplayer: a message from {} was turned down: {}. {} byte(s), starting {}; {} link{}{}.", message.peer, !decoded ? std::string("it could not be read") : decoded->session != s.secret ? std::format("it is of another session (kind {})", static_cast(decoded->kind)) : std::format("it names another sender, {} (kind {})", decoded->source, static_cast(decoded->kind)), message.bytes.size(), start, direct_link ? "a direct" : "the host's", batch.empty() ? "" : ", one of a batch", s.mode == Mode::join && dedicated_host(s) ? ", a dedicated server" : ""); } catch (...) {} disconnect(s, message.peer, "Join code, sender identity, or multiplayer protocol did not match."); if (s.mode == Mode::off) return; continue; } const auto &p = *decoded; if (s.mode == Mode::join && map_fetch_policy::expired(s.map_fetch_deadline, now)) { stop(s, "Map download timed out. Please try joining the server again."); return; } if (s.mode == Mode::host && link->fetching_since && (!link->handshaken || link->travel_since) && map_fetch_policy::host_expired(link->fetching_since, now)) { disconnect(s, message.peer, "The server stopped waiting for the map download."); if (s.mode == Mode::off) return; continue; } // A dedicated server sends no poses; its roster and control traffic keep it alive. if (dedicated_host(s) && message.peer == s.host_id) link->last_packet = now; if (p.kind == PacketKind::world_state) { if (s.mode != Mode::join || message.peer != s.host_id || p.source != s.host_id || !link->handshaken || p.epoch != link->member.epoch || p.build != supported_build::game_sha256_bytes) { disconnect(s, message.peer, "Only the admitted host may change the room's map."); if (s.mode == Mode::off) return; continue; } if (p.world < s.world || (p.world == s.world && s.world_state_sequence && !newer_sequence(p.sequence, s.world_state_sequence))) continue; if (p.world > s.world) { // Initial authorization during an automatic rejoin keeps the original budget. const auto fetch_deadline = s.world ? 0 : s.map_fetch_deadline; clear_world(s, now); s.world = p.world; s.travelling = s.awaiting_map = s.join_map_authorized = true; s.host_world_ready = false; s.travel_started = s.join_started = now; s.join_destination.clear(); s.map_name.clear(); s.map_label.clear(); s.map_package.clear(); s.fetching_since = 0; s.map_fetch_deadline = fetch_deadline; map_fetch_deadline_flag.store(fetch_deadline); fetching_map_flag.store(false, std::memory_order_relaxed); s.map = 0; s.map_load_submitted = false; s.last_map_load_check = 0; } if (!s.join_destination.empty() && !p.destination.empty() && s.map != p.map) { stop(s, "The host changed destinations without starting a new map transition."); return; } s.world_state_sequence = p.sequence; if (!p.destination.empty()) { s.join_destination = s.map_name = p.destination; s.map_label = p.map_label; s.map_package = p.map_package; s.map = p.map; } s.host_world_ready = p.world_ready; link->world_ready = p.world_ready; link->last_packet = now; if (s.travelling) s.status = p.world_ready ? "Host map ready. Waiting for local loading..." : "The host is changing maps. Your session stays connected."; continue; } if (p.kind == PacketKind::world_ready) { if (s.mode != Mode::host || !link->handshaken || p.source != message.peer || p.epoch != link->member.epoch) { disconnect(s, message.peer, "Invalid map readiness message."); if (s.mode == Mode::off) return; continue; } if (p.world != s.world || p.map != s.map || (link->ready_sequence && !newer_sequence(p.sequence, link->ready_sequence))) continue; link->ready_sequence = p.sequence; link->last_packet = now; if (p.map_fetching) guest_fetching(*link, now); const bool arrived = p.world_ready && !link->world_ready; link->world_ready = p.world_ready; if (p.world_ready) link->travel_since = 0; if (arrived) { send_required(s, message.peer, s.cosmetic_packet); for (const auto &other : active_peers(s)) if (other.handshaken && other.member.id != message.peer) send_required(s, message.peer, other.cosmetic_packet); } continue; } if (p.kind == PacketKind::map_request) { if (s.mode != Mode::host || p.source != message.peer || p.build != supported_build::game_sha256_bytes || (link->member.epoch && link->member.epoch != p.epoch)) { disconnect(s, message.peer, "Invalid host map request. Update ReSkate and join again."); if (s.mode == Mode::off) return; continue; } if (link->handshaken || !local.ready || !valid_map_destination(s.map_name)) continue; if (p.map && p.map != s.map) { disconnect(s, message.peer, "The host changed maps while you were joining. Join again."); continue; } if (p.map_fetching) guest_fetching(*link, now); link->member.epoch = p.epoch; bool authorized = !s.password; if (s.password) { if (!link->password_challenge) link->password_challenge = nonce(); if (p.challenge == link->password_challenge) { const auto proof = password_proof(*s.password, s.secret, s.map, s.host_id, message.peer, s.epoch, p.epoch, link->password_challenge); if (!proof_matches(proof, p.proof)) { disconnect(s, message.peer, "Incorrect lobby password."); continue; } authorized = true; } } const bool newly_authorized = authorized && !link->map_authorized; link->map_authorized |= authorized; if (newly_authorized || !link->last_map_offer || now - link->last_map_offer >= 1000000) { auto offer = packet(s, PacketKind::map_offer, now); offer.destination = s.map_name; offer.map_package = host_map_package(s.map_name); offer.challenge = link->password_challenge; offer.map_authorized = authorized; send_required(s, message.peer, encode_wire(offer)); link->last_map_offer = now; } continue; } if (p.kind == PacketKind::map_offer) { if (link->handshaken || p.world < s.world) continue; if (s.mode != Mode::join || message.peer != s.host_id || p.source != s.host_id || p.build != supported_build::game_sha256_bytes || (link->member.epoch && p.epoch != link->member.epoch) || (!s.join_destination.empty() && map_hash(s.join_destination) != p.map) || (!p.map_authorized && !p.challenge)) { disconnect(s, message.peer, "Host map or session changed. Join again."); if (s.mode == Mode::off) return; continue; } if (link->handshaken) continue; if (p.challenge && !s.password) { stop(s, "This lobby requires a password. Use Join with password and try again."); return; } const bool new_challenge = link->password_challenge != p.challenge; link->member.epoch = p.epoch; link->password_challenge = p.challenge; link->map_authorized |= p.map_authorized; s.join_destination = p.destination; s.map_label = p.map_label; s.map_package = p.map_package; s.world = p.world; s.join_map_authorized |= p.map_authorized; link->last_packet = now; if (new_challenge) s.last_map_request = 0; if (!s.join_map_authorized) s.status = "Checking lobby password before loading the host's map..."; continue; } // A map reload can return to the same asset. The generation prevents // late poses, audio, cosmetics and control from reviving the old world. if (p.world != s.world) continue; if (p.kind == PacketKind::peer_hello || p.kind == PacketKind::peer_welcome) { if (!world_playing(s, local)) continue; if (!direct_link || !link->handshaken || !greeting_error(p, s.secret, local.ready ? s.map : 0, supported_build::game_sha256_bytes, link->member.epoch) .empty()) { disconnect(s, message.peer, "Direct peer is not admitted to this session."); if (s.mode == Mode::off) return; continue; } const bool first = !link->direct_ready; link->direct_ready = true; link->world_ready = true; if (p.kind == PacketKind::peer_hello) send_required(s, message.peer, encode_wire(packet(s, PacketKind::peer_welcome, now))); if (first) { // An outfit, like everything else, reaches a dedicated server's players through it. if (!dedicated_host(s)) send_required(s, message.peer, s.cosmetic_packet); s.last_routes = 0; } continue; } if (p.kind == PacketKind::challenge) { if (s.mode != Mode::join || message.peer != s.host_id || p.source != s.host_id || !p.challenge || !greeting_error(p, s.secret, local.ready ? s.map : 0, supported_build::game_sha256_bytes, link->member.epoch) .empty()) { disconnect(s, message.peer, "Invalid lobby password challenge."); if (s.mode == Mode::off) return; continue; } if (!s.password) { stop(s, "This lobby requires a password. Enter it in Join and try again."); return; } if (!link->handshaken) { link->member.epoch = p.epoch; link->password_challenge = p.challenge; s.last_hello = 0; } continue; } if (p.kind == PacketKind::hello || p.kind == PacketKind::welcome) { if (!world_playing(s, local)) continue; if (direct_link || p.source != message.peer || (s.mode == Mode::host ? p.kind != PacketKind::hello : p.kind != PacketKind::welcome)) { disconnect(s, message.peer, "Unexpected multiplayer handshake direction."); if (s.mode == Mode::off) return; continue; } const auto error = greeting_error(p, s.secret, local.ready ? s.map : 0, supported_build::game_sha256_bytes, link->handshaken ? link->member.epoch : 0); if (!error.empty()) { disconnect(s, message.peer, std::string(error)); if (s.mode == Mode::off) return; continue; } if (s.mode == Mode::host && s.password && !link->handshaken) { if (!link->password_challenge) link->password_challenge = nonce(); if (p.challenge != link->password_challenge) { auto challenge = packet(s, PacketKind::challenge, now); challenge.challenge = link->password_challenge; send_required(s, message.peer, encode_wire(challenge)); continue; } const auto proof = password_proof(*s.password, s.secret, s.map, s.host_id, message.peer, s.epoch, p.epoch, link->password_challenge); if (!proof_matches(proof, p.proof)) { disconnect(s, message.peer, "Incorrect lobby password."); continue; } } const bool joined = !link->handshaken; link->member.epoch = p.epoch; link->handshaken = true; link->world_ready = true; link->travel_since = 0; link->last_packet = now; if (s.mode == Mode::join && message.peer == s.host_id && !s.awaiting_map) { s.map_fetch_deadline = 0; map_fetch_deadline_flag.store(0); } if (joined) s.join_backoff.joined(message.peer); if (s.mode == Mode::host) { send_required(s, message.peer, encode_wire(packet(s, PacketKind::welcome, now))); if (joined) { send_roster(s, now); if (!s.tuning_packet.empty()) send_required(s, message.peer, s.tuning_packet); if (!s.extras_packet.empty()) send_required(s, message.peer, s.extras_packet); send_required(s, message.peer, s.cosmetic_packet); for (const auto &other : active_peers(s)) if (other.handshaken && other.member.id != message.peer) send_required(s, message.peer, other.cosmetic_packet); } } else if (joined) send_required(s, message.peer, s.cosmetic_packet); s.status = "Connected through Steam. Network updates: " + std::to_string(s.tps) + " TPS."; continue; } if (p.kind == PacketKind::cosmetics) { defer_cosmetics(*link, p, now); continue; } // A guest's report of how its mods change trick scoring; read before the map check, so one // sent while the guest is still loading the host's map counts. if (p.kind == PacketKind::scoring) { if (s.mode == Mode::host && !direct_link && link->handshaken && p.source == message.peer && p.epoch == link->member.epoch && link->scoring_budget.accept(now, 4)) { link->last_packet = now; link->scoring = p.scoring; link->scoring_mods = p.text; judge_scoring(s, *link); } continue; } if (!link->handshaken || p.map != s.map || (direct_link && !link->direct_ready)) continue; if (p.kind == PacketKind::routes) { const bool valid = s.mode == Mode::host && p.source == message.peer && p.epoch == link->member.epoch; if (!valid) { disconnect(s, message.peer, "Invalid direct route report."); if (s.mode == Mode::off) return; continue; } if (!link->route_reported || newer_sequence(p.sequence, link->route_sequence)) { link->direct_routes.clear(); // Reports can cross a roster update during a departure/rejoin. // Discard stale entries, never disconnect healthy reporters. for (const auto &m : p.members) { const auto *other = find_peer(s, m.id); if (other && other->handshaken && other->member.epoch == m.epoch && m.id != message.peer) link->direct_routes.push_back(m); } link->route_reported = now; link->route_sequence = p.sequence; } continue; } if (p.kind == PacketKind::roster) { if (s.mode != Mode::join || message.peer != s.host_id) { disconnect(s, message.peer, "Only the host may publish the player roster."); continue; } apply_roster(s, p, now); continue; } // Only the host (or dedicated server) moves players: tpall and tphere. if (p.kind == PacketKind::teleport) { if (s.mode == Mode::join && message.peer == s.host_id && p.source == s.host_id && dingosdk::teleport_local_skater(p.teleport)) add_chat(s, 0, "ReSkate", dedicated_host(s) ? "A server admin teleported you." : "The host teleported you."); continue; } // The host's physics tuning (Extension/Skater/physics_tuning.h), for its guests only. if (p.kind == PacketKind::physics_tuning) { if (s.mode == Mode::join && message.peer == s.host_id && p.source == s.host_id && !dedicated_host(s)) s.host_tuning = std::move(p.tuning); continue; } // And the physics its tuning does not carry (Engine/Game/Multiplayer/session_physics.h). if (p.kind == PacketKind::physics_extras) { if (s.mode == Mode::join && message.peer == s.host_id && p.source == s.host_id && !dedicated_host(s)) s.host_extras = p.extras; continue; } // What the player's role on the server lets them do: the rules that depend on it are applied again. if (p.kind == PacketKind::permissions) { if (dedicated_host(s) && message.peer == s.host_id && p.source == s.host_id) { s.role = p.role; s.role_all_commands = p.role_all_commands; s.role_commands = p.role_commands; s.role_permissions = p.role_permissions; s.server_admin = s.role_all_commands || !s.role_commands.empty(); apply_object_limit(s, s.object_limit); apply_object_placement(s, s.object_placement); apply_guest_tools(s, s.guest_noclip, s.guest_no_bail, s.guest_boosts); publish(s); publish_chat(s); } continue; } // The maps the server's vote for the next map will offer, for their pictures (map_icons.h). if (p.kind == PacketKind::next_maps) { if (dedicated_host(s) && message.peer == s.host_id && p.source == s.host_id) { std::vector maps; for (const auto &map : p.next_maps) maps.push_back({clean_chat_text(map.name), map.package}); set_next_maps(std::move(maps)); } continue; } // The server's own filtered chat words: filtered here too while on it (reset_session clears them). if (p.kind == PacketKind::words) { if (dedicated_host(s) && message.peer == s.host_id && p.source == s.host_id) { text::set_own_words(p.words, {}); s.chat_masked.clear(); publish_chat(s); } continue; } if (p.kind == PacketKind::maps) { if (dedicated_host(s) && message.peer == s.host_id && p.source == s.host_id) { s.server_maps = p.maps; s.server_map_pool.clear(); for (const auto entry : p.map_pool) s.server_map_pool.push_back(p.maps[entry]); s.server_map_rotation = p.map_rotation; publish(s); publish_chat(s); } continue; } if (p.kind == PacketKind::bans) { if (dedicated_host(s) && message.peer == s.host_id && p.source == s.host_id) { s.server_bans = p.bans; s.server_ban_total = p.ban_total; publish(s); } continue; } // Whoever hosts telling us about a party invite; or, hosting a lobby, a guest asking // for something to be done with their party. if (p.kind == PacketKind::party) { if (s.mode == Mode::join) { if (message.peer == s.host_id && p.source == s.host_id) receive_party(s, p, now); } else if (s.mode == Mode::host) { auto *sender = find_peer(s, p.source); if (!direct_link && sender && sender->handshaken && message.peer == p.source && routed_source(p, sender->member, message.peer, true, s.host_id) && sender->party_budget.accept(now, 20)) host_party_request(s, p.source, p.party_action, p.party_player, now); } continue; } // A dedicated server's answer to one of our admin requests, or a lobby host's to a // party request: a line for this player only. if (p.kind == PacketKind::admin) { if (s.mode == Mode::join && message.peer == s.host_id && p.source == s.host_id) add_chat(s, 0, dedicated_host(s) ? "Server" : "ReSkate", p.text); continue; } // Chat is accepted while either side is still loading a map: it needs // only an authenticated sender, never the world. if (p.kind == PacketKind::chat) { auto *sender = find_peer(s, p.source); if (!sender || !sender->handshaken) continue; // Another guest's own copy of a line they said: proof of the host's, never a line. if (direct_link) { if (routed_source(p, sender->member, message.peer, false, s.host_id, true)) proven_chat(s, *sender, p, now); continue; } if (!routed_source(p, sender->member, message.peer, s.mode == Mode::host, s.host_id)) continue; // A dedicated server speaks (welcome message, its console) as "Server". const bool server = dedicated_host(s) && sender->member.id == s.host_id; // Everyone holds everyone to the same pace, so a modified client cannot flood. if (!server && sender->chat_rate.accept(now, p.text, 1) != ChatRate::Verdict::accepted) continue; sender->last_packet = now; // A word that is not allowed at all: a lobby's host does not show or pass the line on, // warns the guest, and after the last warning removes them (a dedicated server // does the same with its own count). if (s.mode == Mode::host && text::contains_forbidden_words(p.text)) { const auto count = ++s.word_warnings[sender->member.id]; if (count > word_warnings_default) { s.transport.disconnect(sender->member.id, word_kick_notice.data()); } else { auto notice = packet(s, PacketKind::admin, now); notice.text = clean_chat_text(word_warning(count, word_warnings_default)); send_packet(s, sender->member.id, notice, true, false); } continue; } // "/p": party chat, which a lobby's host relays to the sender's party and nobody else. if (s.mode == Mode::host && p.text.starts_with("/p ")) { host_party_chat(s, sender->member.id, std::string_view(p.text).substr(3), now); continue; } if (owes_proof(s, *sender)) relayed_chat(s, *sender, p, now); else add_chat(s, sender->member.id, server ? std::string("Server") : chat_name(s, *sender), p.text); if (s.mode == Mode::host) broadcast(s, p, true, false, now, p.source); continue; } // Grey dots above the sender. Not a chat line. "On" is held to a few a second so a // modified client cannot flood the relay, and "off" only clears dots that are up, so // it cannot outpace "on". if (p.kind == PacketKind::typing) { auto *sender = find_peer(s, p.source); if (direct_link || !sender || !sender->handshaken) continue; if (!routed_source(p, sender->member, message.peer, s.mode == Mode::host, s.host_id)) continue; if (p.typing) { if (sender->typing_notice && now < sender->typing_notice + 200000) continue; sender->typing_notice = now; sender->typing_until = now + typing_hold_us; } else { if (!sender->typing_until) continue; sender->typing_until = 0; } sender->last_packet = now; if (s.mode == Mode::host) broadcast(s, p, true, false, now, p.source); continue; } // Throwdown messages are relayed like chat, but only within one map: a drop // belongs to the world it was placed in. if (p.kind == PacketKind::throwdown) { auto *sender = find_peer(s, p.source); if (direct_link || !sender || !sender->handshaken || p.map != s.map || p.world != s.world || !routed_source(p, sender->member, message.peer, s.mode == Mode::host, s.host_id)) continue; if (!sender->throwdown_budget.accept(now, throwdown_burst)) continue; sender->last_packet = now; // A player whose mods change scoring or physics takes part in nothing linked: the host // passes none of theirs on. if (sender->member.scoring) continue; if (s.throwdown_inbox.size() < 256) s.throwdown_inbox.emplace_back(p.source, p.throwdown); if (s.mode == Mode::host) broadcast(s, p, true, false, now, p.source); continue; } // A host-ready notice and its fresh cosmetics can share one receive // batch. Accept them once our target skater is loaded, even though the // local transition is finalized on the next tick. if (!local.ready || s.awaiting_map || !s.host_world_ready || !s.started_map || (s.mode == Mode::host && !link->world_ready)) continue; auto *source = find_peer(s, p.source); if (!source || !source->handshaken || !routed_source(p, source->member, message.peer, s.mode == Mode::host, s.host_id, direct_link)) continue; if (p.kind == PacketKind::away) { disconnect(s, message.peer, "A player ended their session."); if (s.mode == Mode::off) return; continue; } if (direct_link && p.kind == PacketKind::pose) link->last_direct_pose = now; // A dedicated server sends everything itself, held to its rules (how far a body part may // be resized, say). A link between two of its games is there to prove who they are // (identity_link); nothing else that arrives over one is taken. if (direct_link && dedicated_host(s)) continue; if (p.kind == PacketKind::objects) { if (direct_link) { disconnect(s, message.peer, "Object updates must use the host route."); continue; } const auto result = source->objects.receive(p.objects); if (result == ObjectState::Result::invalid) { disconnect(s, message.peer, "Invalid shared object revision or layout."); if (s.mode == Mode::off) return; } else source->last_packet = now; continue; } if (p.kind == PacketKind::voice) { if (!s.voice_policy.accepts(p.voice) || (s.mode == Mode::join && !s.roster_sequence)) continue; if (direct_link || (source->received_voice && !newer_sequence(p.sequence, source->voice_sequence))) continue; if (!source->voice_budget.accept(now, p.voice.bytes.size())) continue; source->received_voice = true; source->voice_sequence = p.sequence; source->last_packet = now; s.voice.receive(p); if (s.mode == Mode::host) broadcast(s, p, false, true, now, p.source); continue; } // The host forwards what it accepts, so it keeps the packet; a guest moves the // pose into playback instead of copying it. if (s.mode != Mode::host) accept_data(*source, std::move(*decoded), now); else if (accept_data(*source, p, now)) broadcast(s, p, p.kind == PacketKind::cosmetics || (p.kind == PacketKind::audio && std::any_of(p.audio.begin(), p.audio.end(), [](const auto &sample) { return sample.event; })), p.kind != PacketKind::cosmetics, now, p.source); } // Tell the server which of its pose messages arrived, once for everything read this tick. if (s.pose_ack_due && s.mode == Mode::join) { const auto ack = s.pose_ack.bytes(); s.transport.send(s.host_id, ack, false, true, TrafficLane::gameplay); s.pose_ack_due = false; } receive_cosmetics(s, local, now); for (std::size_t i = 0; i < s.used_slots; ++i) { const auto &p = s.peers[i]; if (!p.member.id) continue; if (s.mode == Mode::join && p.member.id != s.host_id) { if (world_playing(s, local) && p.connected_at && !p.direct_ready && now - p.connected_at > 8000000) disconnect(s, p.member.id, "Direct handshake timed out; using host forwarding."); continue; } // Authorized arrivals get time for native loading; this has an absolute // deadline, so requests cannot keep an unused slot reserved indefinitely. if (s.mode == Mode::host && p.fetching_since && (!p.handshaken || p.travel_since) && map_fetch_policy::host_expired(p.fetching_since, now)) { disconnect(s, p.member.id, "The server stopped waiting for the map download."); if (s.mode == Mode::off) return; continue; } const std::uint64_t handshake_timeout = p.map_authorized ? 180000000 : 8000000; const bool fetching_from_host = s.mode == Mode::join && p.member.id == s.host_id && s.map_fetch_deadline && !map_fetch_policy::expired(s.map_fetch_deadline, now); if (p.handshaken && (s.travelling || p.travel_since)) { if (p.travel_since && now - p.travel_since > 180000000) { disconnect(s, p.member.id, "A player could not finish loading the new map."); if (s.mode == Mode::off) return; } continue; } if ((!p.handshaken && p.connected_at && (fetching_from_host ? now - p.last_packet > 10000000 : now - p.connected_at > handshake_timeout)) || (p.handshaken && now - p.last_packet > 10000000)) { disconnect(s, p.member.id, "A player timed out while waiting for gameplay data."); if (s.mode == Mode::off) return; } } if (s.mode == Mode::join && world_playing(s, local) && now - s.last_routes >= 250000) { if (const auto *host_peer = find_peer(s, s.host_id); host_peer && host_peer->handshaken) { auto report = packet(s, PacketKind::routes, now); for (const auto &p : active_peers(s)) if (p.member.id != s.host_id && p.direct_ready && p.last_direct_pose && now - p.last_direct_pose <= 500000) report.members.push_back({p.member.id, p.member.epoch, {}}); // A report can expire safely: failed/skipped reports leave relay enabled. if (send_packet(s, s.host_id, report, false, true)) s.last_routes = now; } } update_scoring(s, now); // before the roster, so a host's own flag goes out with it tick_host_parties(s, now); // and the lobby's parties, which the roster carries if (s.mode == Mode::host && world_playing(s, local) && (s.roster_dirty || now - s.last_roster > 2000000)) send_roster(s, now); const auto peers = active_peers(s); if (s.mode == Mode::host && (s.travelling || !s.last_world_state || std::any_of(peers.begin(), peers.end(), [](const auto &peer) { return peer.handshaken && !peer.world_ready; })) && (!s.last_world_state || now - s.last_world_state >= 1000000) && (s.travelling || s.world > 1)) send_world_state(s, now); if (s.mode == Mode::join && s.world > 1 && (s.travelling || !s.last_world_ready) && (!s.last_world_ready || now - s.last_world_ready >= 1000000)) { if (const auto *host_peer = find_peer(s, s.host_id); host_peer && host_peer->handshaken) { auto ready = packet(s, PacketKind::world_ready, now); ready.world_ready = local.ready && !s.awaiting_map; ready.map_fetching = !ready.world_ready && s.fetching_since != 0; send_required(s, s.host_id, encode_wire(ready)); s.last_world_ready = now; } } if (s.public_host) s.lobbies.update_host(world_playing(s, local), static_cast(s.transport.status().peers.size()) + 1, s.map_name, now); if (s.mode == Mode::host && !s.travelling && s.travel_started && std::none_of(peers.begin(), peers.end(), [](const auto &peer) { return peer.handshaken && !peer.world_ready; })) { s.travel_started = 0; s.status = "Map changed. All connected players are ready."; } } } // namespace session_detail } // namespace dingosdk::multiplayer