Files
ReSkate/Extension/Multiplayer/Session/session_receive.cpp
T
AyeZeeBB 3fddd1433a Trainer: quiet by default, host's setup for guests; drop the out-of-view cut
Follow-ups to the trainer (PR 37), and one multiplayer change.

Off until asked for (Extension/Trainer)
- The jump read-out and the LB+RB controller shortcuts start off. Jumps
  are logged only while the read-out or telemetry recording is on, and
  trick launches only when the trainer scaled them. A saved trainer.json
  keeps what the player chose.

Memory search on demand (trainer.cpp, trainer_classes.cpp)
- The search for the game's tuning classes reads all writable memory.
  It ran for every player after a level loaded, and up to seven times
  when it found nothing. It now runs only when a class value or the flip
  speed is not the game's own (the player's, or a host's they skate
  with) or the EVERYTHING list is open, and at most three times a level.
- For those players it runs again on each level, which brings fresh
  copies; a search from an earlier level used to count as done.

Guests under enforced physics
- A guest is locked by the session's enforce setting, from the moment
  of joining (Engine/Game/Multiplayer/session_physics.h), not by whether
  the enforcement status text was set yet.
- A guest's class values no longer come back after a map change.

The host's whole setup reaches its guests (protocol 39)
- Only Gameplay/SkatePhysicsTuning travelled, so a host could push,
  sprint or jump further than the guests it held to the game's own.
- New packet physics_extras (30): the host's class values, trick
  multipliers and auto push (trainer_session.h), sent when they change
  and to players who join later, taken from the host only. A dedicated
  server's are the game's own and it ignores the packet.
- Under enforcement a guest's own trick multipliers stand down for the
  host's, whatever the boosts switch says. With enforcement off nothing
  changes.
- A guest refuses bytes that are not an encoding, holds values to the
  trainer's own ranges, and leaves out class values written for another
  game build's table.
- protocol_version 38 -> 39: released versions disconnect from a peer
  that sends a packet they cannot decode.

Out-of-view players are posed every frame again (Session/session.cpp)
- Players outside the camera's view and past 8 m were sampled at 20 Hz
  (b763631). The game's replays record each skater as it stood every
  frame and their camera looks anywhere, so those players moved at
  20 Hz in replays. Removed; the distance rule (20 Hz past 200 m, 10 Hz
  past 350 m) stays. This gives back the client-tick time it saved on
  busy servers.
2026-10-04 14:19:30 -05:00

856 lines
42 KiB
C++

#include "session_internal.h"
#include "Extension/Profile/local_profile_runtime.h"
#include "Extension/Multiplayer/Net/wire_codec.h"
#include "Engine/Game/Build/supported_build.h"
#include <algorithm>
#include <iterator>
#include <stdexcept>
#include <utility>
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 (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);
}
}
}
// 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.
std::vector<bool> stale_pose_deltas(Session &s, const std::vector<TransportMessage> &messages) {
std::vector<bool> stale(messages.size());
struct Stream {
std::uint64_t link{}, source{};
unsigned kept{}, keep{};
};
std::vector<Stream> streams;
for (auto i = messages.size(); i-- > 0;) {
const auto &bytes = messages[i].bytes;
// 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<unsigned>(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<std::uint32_t>(100000, interval + 50000);
streams.push_back({messages[i].peer, source, 0, std::clamp<unsigned>(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.server_admin = false;
for (const auto &m : p.members)
if (m.id == s.transport.status().local_id) {
s.server_admin = m.admin && game_server_steam_id(s.host_id);
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_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;
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.");
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.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);
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::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;
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<unsigned>(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 &&
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()) {
stop(s, s.transport.status().detail);
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;
}
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);
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);
for (std::size_t index = 0; index < messages.size(); ++index) {
const auto &message = messages[index];
auto *link = find_peer(s, message.peer);
if (!link)
continue;
const bool direct_link = s.mode == Mode::join && message.peer != s.host_id;
if (!link->budget.accept(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;
bool missing_reference{};
auto 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)) {
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;
// 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) {
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 = 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 = 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;
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;
}
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.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.world = p.world;
s.join_map_authorized |= p.map_authorized;
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) {
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 (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;
}
if (p.kind == PacketKind::maps) {
if (dedicated_host(s) && message.peer == s.host_id && p.source == s.host_id) {
s.server_maps = p.maps;
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 (direct_link || !sender || !sender->handshaken ||
!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;
// "/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;
}
add_chat(s, sender->member.id, server ? std::string("Server")
: sender->member.name.empty() ? s.transport.name(sender->member.id) : sender->member.name, p.text);
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;
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);
}
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.
const std::uint64_t handshake_timeout = p.map_authorized ? 180000000 : 8000000;
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 && 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;
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<unsigned>(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