Files
ReSkate/Extension/Multiplayer/Session/session_send.cpp
T
AyeZeeBB 5967124f3a Vote card above chat with rebindable Yes and No; fix full servers breaking on extra slots
Votes on a dedicated server show as a card above chat instead of being
followed through chat lines.

- The card has what is voted on, the Yes and No counts, how many are needed,
  a bar of how far Yes has got, and the seconds left. When the vote ends it
  shows Passed, Failed or Cancelled for four seconds.
- Yes and No are two buttons, labelled with the player's binds. The answer
  given is highlighted and can be changed. They can be clicked whenever the
  cursor is free (chat, menu or console open).
- Two new binds, "Vote yes" and "Vote no", in the binds list: F1 and F2
  unless changed, to keys or controller combos like the others. They are
  read only while a vote is running, and not while typing.
- /yes and /no still work and show on the card. The player a kick vote is
  about sees the card and has no vote in it.
- Console: "mp vote yes|no", "bind voteyes <n>", "bind voteno <n>".

Server:
- The roster carries the running vote (id, kind, tally, seconds left,
  starter, target, label) and the one just finished with how it ended.
- A chat line is no longer sent for each change of the tally; the lines for
  a vote starting and ending stay.

Fix: a dedicated server's roster may list more players than its limit.
The extra slots for reserved players and admins (33 of 32) made a roster the
protocol refused to encode, so with one in use the server logged
"Server error" every pass and stopped part-way through each one until
somebody left. Lobbies hosted by a player are still held to their size.
2026-10-08 21:54:45 -05:00

720 lines
38 KiB
C++

#include "session_internal.h"
#include "Extension/Multiplayer/Remote/native_cosmetics.h"
#include "Extension/Multiplayer/Remote/native_audio.h"
#include "Extension/Profile/local_profile_runtime.h"
#include "Extension/Objects/network_object_runtime.h"
#include "Extension/Multiplayer/Net/wire_codec.h"
#include "Engine/Game/Build/supported_build.h"
#include "Engine/Core/Log/logging.h"
#include "Extension/Skater/physics_tuning.h"
#include "Engine/Vfs/mod_scoring.h"
#include "Extension/Multiplayer/Remote/remote_collision.h"
#include <algorithm>
#include <stdexcept>
#include <utility>
namespace dingosdk::multiplayer {
using namespace session_detail;
namespace {
// Guests upload their layouts to the host, which alone decides what everyone
// else sees. Unless guests may place objects, each shared layout stays frozen,
// so a patched client cannot place, move or delete objects for anyone else.
// Uploads are still accepted meanwhile, keeping the owner's revisions in step.
void publish_guest_objects(Session &s) {
if (s.mode != Mode::host || s.object_placement != ObjectPlacement::everyone) return;
for (auto &peer : active_peers(s))
if (peer.handshaken && peer.objects.revision() != peer.shared_from) {
auto layout = peer.objects.layout();
std::erase_if(layout, [&](const auto &object) { return peer.cleared.contains(object.id); });
// The host's limit on each guest's objects, which a patched client cannot place past.
layout = limited_layout(std::move(layout), peer.shared.objects(), s.object_limit);
peer.shared.replace(layout);
peer.shared_from = peer.objects.revision();
}
}
// The objects a receiver should render or forward for this owner.
const ObjectState &visible_objects(const Session &s, const Peer &peer) {
return s.mode == Mode::host ? peer.shared : peer.objects;
}
void send_object_states(Session &s, std::uint64_t now) {
struct Source { std::uint64_t id, epoch; const ObjectState *state; };
std::vector<Source> sources{{s.transport.status().local_id, s.epoch, &s.local_objects}};
if (s.mode == Mode::host)
for (const auto &peer : active_peers(s))
if (peer.handshaken && peer.world_ready) sources.push_back({peer.member.id, peer.member.epoch, &peer.shared});
for (auto &peer : active_peers(s)) {
if (!peer.handshaken || !peer.world_ready || (s.mode == Mode::join && peer.member.id != s.host_id)) continue;
auto &delivery = peer.object_delivery;
const auto find = [&](std::uint64_t id, std::uint64_t epoch) {
return std::find_if(sources.begin(), sources.end(), [&](const auto &source) { return source.id == id && source.epoch == epoch; });
};
std::erase_if(delivery.sent, [&](const auto &row) { return find(row.first, row.second.first) == sources.end(); });
if (!delivery.chunks.empty() && find(delivery.source, delivery.epoch) == sources.end()) delivery.chunks.clear();
// Bounded reliable chunks share the ordered control lane with admission,
// roster and travel. A newly admitted owner always precedes its objects.
for (unsigned budget = 0; budget < 4; ++budget) {
if (delivery.chunks.empty()) {
for (std::size_t attempt = 0; attempt < sources.size(); ++attempt) {
const auto &source = sources[delivery.cursor++ % sources.size()];
if (source.id == peer.member.id || !source.state->revision()) continue;
const auto previous = delivery.sent.find(source.id);
const auto since = previous != delivery.sent.end() && previous->second.first == source.epoch ? previous->second.second : 0;
delivery.chunks = source.state->updates(since);
if (delivery.chunks.empty()) continue;
delivery.source = source.id; delivery.epoch = source.epoch; delivery.next = 0;
break;
}
if (delivery.chunks.empty()) break;
}
auto update = packet(s, PacketKind::objects, now);
update.source = delivery.source; update.epoch = delivery.epoch;
update.objects = delivery.chunks[delivery.next];
if (!send_packet(s, peer.member.id, update, true, false)) {
s.transport.disconnect(peer.member.id, "Cannot deliver shared object state. Join again.");
break;
}
if (++delivery.next == delivery.chunks.size()) {
delivery.sent[delivery.source] = {delivery.epoch, update.objects.revision};
delivery.chunks.clear();
}
}
}
}
} // namespace
namespace session_detail {
Packet packet(Session &s, PacketKind kind, std::uint64_t now) {
Packet p;
p.kind = kind;
p.sequence = ++s.sequence;
p.session = s.secret;
p.epoch = s.epoch;
p.map = s.map;
p.time_us = now;
p.source = s.transport.status().local_id;
p.world = s.world;
p.tps = s.tps;
p.pose_interval_us = multiplayer_pose_interval(s.tps);
p.build = supported_build::game_sha256_bytes;
return p;
}
void reset_direct(Session &s, Peer &p, std::uint64_t now) {
p.sender = {};
p.receiver = {};
p.budget = {};
p.pose_delivery = {};
p.direct_ready = false;
p.connected_at = p.last_direct_hello = p.last_direct_pose = 0;
p.next_dial = now + 3000000;
s.last_routes = 0;
}
void disconnect(Session &s, std::uint64_t id, const std::string &reason) {
if (s.mode == Mode::join) {
if (id == s.host_id)
stop(s, reason);
else {
s.transport.disconnect(id, reason.c_str());
if (auto *p = find_peer(s, id))
reset_direct(s, *p, s.network_now);
}
return;
}
s.transport.disconnect(id, reason.c_str());
for (std::size_t i = 0; i < s.used_slots; ++i)
if (s.peers[i].member.id == id) {
s.roster_dirty |= s.peers[i].handshaken;
// Never admitted: it held a player slot meanwhile. An ID that keeps failing waits
// longer each time before its connection is taken again.
if (s.mode == Mode::host && !s.peers[i].handshaken) {
const auto failures = s.join_backoff.failed(id, s.network_now);
logging::log(logging::Level::info, logging::Channel::runtime,
"Multiplayer: {} did not finish joining ({}), attempt {}.", id, reason, failures);
}
reset_peer(s, i);
break;
}
s.status = reason;
}
bool send_packet(Session &s, std::uint64_t id, const Packet &p, bool reliable, bool fresh,
std::span<const std::uint8_t> raw, std::span<const std::uint8_t> wire) {
auto *peer = find_peer(s, id);
if (!peer)
return false;
auto update = raw.empty() ? peer->sender.prepare(p) : peer->sender.prepare(p, raw, wire);
// A packet that cannot be built for anyone is its source's fault, never this recipient's:
// nothing is sent, and the recipient is not treated as unreachable.
if (update.bytes.empty())
return true;
// A stream and its reliable delta references must always use the same lane.
if (!s.transport.send(id, update.bytes, reliable || update.establishes_baseline(), fresh, traffic_lane(p.kind)))
return false;
peer->sender.sent(p, std::move(update));
return true;
}
void send_required(Session &s, std::uint64_t id, const std::vector<std::uint8_t> &bytes) {
if (bytes.empty())
return;
const auto p = decode_wire(bytes);
if (!p || !send_packet(s, id, *p, true, false))
s.transport.disconnect(id, "Cannot deliver required session data. Join again.");
}
void send_world_state(Session &s, std::uint64_t now) {
auto state = packet(s, PacketKind::world_state, now);
state.destination = s.map_name;
state.world_ready = s.host_world_ready;
const auto bytes = encode_wire(state);
for (const auto &peer : active_peers(s))
if (peer.handshaken) send_required(s, peer.member.id, bytes);
s.last_world_state = now;
}
void begin_host_world(Session &s, std::string_view destination, std::uint64_t now) {
if (!destination.empty() && !valid_map_destination(destination))
throw std::runtime_error("The new host map is not a supported native destination.");
clear_world(s, now);
if (++s.world == 0) throw std::runtime_error("Map transition counter exhausted.");
s.map_name = destination;
s.map = destination.empty() ? 0 : map_hash(destination);
s.travelling = true;
s.host_world_ready = false;
s.travel_started = now;
s.status = "Changing maps. Players remain connected while loading...";
send_world_state(s, now);
if (s.public_host)
s.lobbies.update_host(false, static_cast<unsigned>(s.transport.status().peers.size()) + 1,
s.map_name, now);
}
std::string send_chat(Session &s, std::string_view typed) {
if (s.mode != Mode::host && s.mode != Mode::join) return "Chat needs a multiplayer session.";
auto text = clean_chat_text(typed);
if (text.empty()) return "Type a message first.";
const auto now = now_us();
switch (s.local_chat_rate.accept(now, text)) {
case ChatRate::Verdict::repeated: return "You just said that.";
case ChatRate::Verdict::too_fast: return "Slow down: one message every couple of seconds.";
case ChatRate::Verdict::accepted: break;
}
auto message = packet(s, PacketKind::chat, now);
message.text = text;
broadcast(s, message, true, false, now);
const auto local = s.transport.status().local_id;
// The host passes a guest's line on, and could pass on anything under anyone's name. So a
// guest whose line shows a badge of the backend's also sends it straight to every player
// Steam connects them to: the copy is not shown, it is what lets that player know the
// host's one is ours (chat proofs, session_receive.cpp). Older builds ignore it.
if (s.mode == Mode::join && own_tag_shown() && identity_mark(local))
for (const auto &peer : active_peers(s))
if (peer.handshaken && peer.direct_ready && peer.member.id != s.host_id)
send_packet(s, peer.member.id, message, true, false);
add_chat(s, local, s.transport.name(local), std::move(text), true);
return {};
}
std::string send_chat_command(Session &s, std::string_view typed) {
if (!dedicated_host(s)) return "That command needs a dedicated server.";
auto text = clean_chat_text(typed);
if (text.size() < 2 || text.front() != '/') return "Type a command after the /.";
const auto now = now_us();
switch (s.local_chat_rate.accept(now, text)) {
case ChatRate::Verdict::repeated: return "You just sent that.";
case ChatRate::Verdict::too_fast: return "Slow down: one message every couple of seconds.";
case ChatRate::Verdict::accepted: break;
}
auto message = packet(s, PacketKind::chat, now);
message.text = std::move(text);
if (!send_packet(s, s.host_id, message, true, false)) return "Could not reach the server.";
// Party chat: the server relays it to the rest of the party, not back to us.
const std::string_view sent = message.text;
// An answer to the vote typed in chat shows on the vote card like one given there.
if (s.vote.id && s.vote.outcome == vote_running) {
if (sent == "/yes" || sent == "/y" || sent == "/vote yes" || sent == "/vote y") s.vote_mine = 1;
else if (sent == "/no" || sent == "/n" || sent == "/vote no" || sent == "/vote n") s.vote_mine = 2;
}
if (sent.starts_with("/p ") && s.local_party) {
const auto local = s.transport.status().local_id;
add_chat(s, local, s.transport.name(local), "[Party] " + std::string(sent.substr(3)), true);
}
return {};
}
std::string cast_server_vote(Session &s, bool yes) {
if (!dedicated_host(s) || !s.vote.id || s.vote.outcome != vote_running) return "No vote is running.";
if (s.vote.target == s.transport.status().local_id) return "You cannot vote on your own kick.";
if (s.vote_mine == (yes ? 1 : 2)) return {};
// The server takes the answer as the chat command (server_votes.cpp); it is not a chat
// line, so the pace kept for those does not hold it back.
auto message = packet(s, PacketKind::chat, now_us());
message.text = yes ? "/yes" : "/no";
if (!send_packet(s, s.host_id, message, true, false)) return "Could not reach the server.";
s.vote_mine = yes ? 1 : 2;
return {};
}
std::string send_party_chat(Session &s, std::string_view typed) {
if (s.mode != Mode::host && s.mode != Mode::join) return "Chat needs a multiplayer session.";
if (!s.local_party) return "You're not in a party.";
const auto text = clean_chat_text(typed);
if (text.empty()) return "Type a message first.";
const auto now = now_us();
switch (s.local_chat_rate.accept(now, text)) {
case ChatRate::Verdict::repeated: return "You just said that.";
case ChatRate::Verdict::too_fast: return "Slow down: one message every couple of seconds.";
case ChatRate::Verdict::accepted: break;
}
const auto local = s.transport.status().local_id;
if (s.mode == Mode::host) {
host_party_chat(s, local, text, now);
} else {
// The host relays it to the rest of the party, not back to us.
auto message = packet(s, PacketKind::chat, now);
message.text = clean_chat_text("/p " + text);
if (!send_packet(s, s.host_id, message, true, false)) return "Could not reach the host.";
}
add_chat(s, local, s.transport.name(local), "[Party] " + text, true);
return {};
}
void send_throwdown(Session &s, std::vector<std::uint8_t> message) {
if ((s.mode != Mode::host && s.mode != Mode::join) || message.empty() || message.size() > max_throwdown_message) return;
const auto now = now_us();
auto p = packet(s, PacketKind::throwdown, now);
p.throwdown = std::move(message);
broadcast(s, p, true, false, now);
}
// This game's pose to a dedicated server: its differences from a pose of its own the server has
// said it holds (pose_batch.h), as the server sends everyone else's.
void upload_pose(Session &s, const Packet &packet, std::uint64_t now) {
auto &kept = s.own_poses;
if (!kept.empty() && kept.back().sequence >= packet.sequence) return;
kept.push_back({packet.sequence, packet.time_us, pose_codec::quantize(packet.pose)});
while (kept.size() > 64) kept.pop_front();
const auto find = [&](std::uint32_t sequence) -> std::optional<pose_batch::KeptView> {
for (auto it = kept.rbegin(); it != kept.rend(); ++it)
if (it->sequence == sequence) return pose_batch::KeptView{it->sequence, it->time_us, &it->pose};
return {};
};
const auto emit = [&](std::span<const std::uint8_t> message, bool reliable) {
return s.transport.send(s.host_id, message, reliable, !reliable, TrafficLane::gameplay);
};
s.pose_upload.begin(s.world, s.map);
s.pose_upload.add(packet.source, packet.epoch, *find(packet.sequence), find, false, packet.player_collision, pose_batch::Rate::full, now, emit);
s.pose_upload.flush(emit);
}
void broadcast(Session &s, const Packet &packet, bool reliable, bool fresh, std::uint64_t now,
std::uint64_t except) {
const auto *source = find_peer(s, packet.source);
// One encoding per pose rate (full, 100 ms, 200 ms), each of the same packet with only
// the interval it carries changed. Recipients holding the same delta reference share one
// patch (DeltaCache).
struct Encoded {
bool ready{};
std::vector<std::uint8_t> raw, wire;
DeltaCache deltas;
};
std::array<Encoded, 3> encoded;
// Every recipient's update is built first and all go to Steam in one call. The delta
// references a DeltaCache points into stay valid until the senders record the sends.
struct Outgoing {
Peer *peer{};
WireUpdate update;
PoseDelivery *delivery{};
std::uint32_t interval{};
};
std::vector<Outgoing> outgoing;
unsigned direct_sent{};
const bool gameplay = packet.kind == PacketKind::pose || packet.kind == PacketKind::audio ||
packet.kind == PacketKind::voice || packet.kind == PacketKind::cosmetics;
for (auto &p : active_peers(s)) {
if (!p.handshaken || p.member.id == except ||
(s.mode == Mode::host && gameplay && !p.world_ready) ||
(s.mode == Mode::join && p.member.id != s.host_id && !p.direct_ready))
continue;
// Chat and throwdown messages always travel through the host, which relays
// them once to everyone else; a direct copy as well would deliver them twice.
if ((packet.kind == PacketKind::chat || packet.kind == PacketKind::throwdown) && s.mode == Mode::join &&
p.member.id != s.host_id)
continue;
if (packet.kind == PacketKind::voice) {
if (!s.voice_policy.accepts(packet.voice) || (s.mode == Mode::join && !s.roster_sequence)) continue;
if (s.mode == Mode::join && p.member.id != s.host_id) continue;
// Each listener fades voices by their own hearing distance, so the host
// forwards proximity voice to anyone within its voice range (a host
// setting). Unknown positions are forwarded rather than cut.
if (s.mode == Mode::host && packet.voice.distance > 0.f) {
const auto *root = source ? (source->latest_root ? &*source->latest_root : nullptr)
: (s.local_root ? &*s.local_root : nullptr);
const bool known = root && p.latest_root && p.pose_arrival && now - p.pose_arrival <= 3000000 &&
(!source || (source->pose_arrival && now - source->pose_arrival <= 3000000));
if (known && voice_gain(root->position, p.latest_root->position, s.voice_range) <= 0.f) continue;
}
}
if (s.mode == Mode::join && p.member.id != s.host_id &&
(packet.kind == PacketKind::pose || packet.kind == PacketKind::audio) &&
direct_sent++ >= s.direct_upload.limit)
continue;
// Keep admission/roster/cosmetics host-controlled. Only frequent gameplay
// streams move off the host after the destination confirms direct poses.
if (s.mode == Mode::host && source &&
(packet.kind == PacketKind::pose || packet.kind == PacketKind::audio) &&
!needs_relay(p.direct_routes, p.route_reported, source->member, now))
continue;
if (packet.kind == PacketKind::pose && dedicated_host(s) && p.member.id == s.host_id) {
upload_pose(s, packet, now);
continue;
}
// This game's skater's sound to a dedicated server (sound_codec.h): what changed since
// the last samples it was sent.
if (packet.kind == PacketKind::audio && dedicated_host(s) && p.member.id == s.host_id) {
s.sound_upload.begin(s.world, s.map);
s.sound_upload.add(packet.source, packet.epoch, packet.sequence, packet.time_us, packet.audio, now);
if (s.sound_upload.pending())
s.sound_upload.sent(s.transport.send(s.host_id, s.sound_upload.message(), true, fresh, traffic_lane(packet.kind)));
continue;
}
PoseDelivery *delivery{};
std::uint32_t interval = multiplayer_pose_interval(s.tps);
if (packet.kind == PacketKind::pose) {
auto entry = std::find_if(p.pose_delivery.begin(), p.pose_delivery.end(),
[&](const auto &v) { return v.source == packet.source; });
if (entry == p.pose_delivery.end())
entry = p.pose_delivery.size() < max_players
? p.pose_delivery.insert(p.pose_delivery.end(), PoseDelivery{})
: std::min_element(p.pose_delivery.begin(), p.pose_delivery.end(),
[](const auto &a, const auto &b) { return a.last_sent < b.last_sent; });
delivery = &*entry;
if (delivery->source != packet.source || delivery->epoch != packet.epoch)
*delivery = {packet.source, packet.epoch, 0, interval};
// Full upstream rate lets the host forward to nearby recipients even
// when its own skater is far from this guest.
if (!(s.mode == Mode::join && p.member.id == s.host_id) && p.latest_root &&
p.pose_arrival && now >= p.pose_arrival && now - p.pose_arrival <= 1000000) {
float distance{};
for (unsigned i = 0; i < 3; ++i) {
const auto d = p.latest_root->position[i] - packet.pose.root.position[i];
distance += d * d;
}
interval = pose_interval(distance, delivery->interval_us, s.distances, s.tps);
}
if (delivery->interval_us != interval) delivery->next_source_time = 0;
delivery->interval_us = interval;
// Source capture already caps full-rate poses. A second arrival-time
// throttle discards good updates when frames or relay arrivals jitter.
if (interval > multiplayer_pose_interval(s.tps) && packet.time_us < delivery->next_source_time)
continue;
}
const auto variant = interval == 200000 ? 2U : interval == 100000 ? 1U : 0U;
auto &data = encoded[variant];
if (!data.ready) {
// Only a pose's encoding carries its interval.
data.raw = packet.kind == PacketKind::pose ? encode(packet, true, interval) : encode(packet, true);
data.wire = encode_wire_bytes(data.raw);
data.ready = true;
}
auto update = p.sender.prepare(packet, data.raw, data.wire, data.deltas);
// Unbuildable for anyone (see send_packet): skipped, and no recipient is dropped for it.
if (update.bytes.empty())
continue;
outgoing.push_back({&p, std::move(update), delivery, interval});
}
if (outgoing.empty())
return;
std::vector<TransportSend> sends;
sends.reserve(outgoing.size());
// A stream and its reliable delta references must always use the same lane.
for (const auto &item : outgoing)
sends.push_back({item.peer->member.id, item.update.bytes, reliable || item.update.establishes_baseline(), fresh,
traffic_lane(packet.kind)});
s.transport.send_batch(sends);
for (std::size_t i = 0; i < outgoing.size(); ++i) {
auto &item = outgoing[i];
if (sends[i].sent) {
item.peer->sender.sent(packet, std::move(item.update));
if (item.delivery) {
item.delivery->last_sent = now;
advance_pose_deadline(item.delivery->next_source_time, packet.time_us, item.interval);
}
} else if (reliable && !fresh)
s.transport.disconnect(item.peer->member.id, "Cannot deliver required session data. Join again.");
}
}
void sync_objects(Session &s, const NativeFrame &local, std::uint64_t now) {
if ((s.mode != Mode::host && s.mode != Mode::join) || !world_playing(s, local) || now < s.next_object_update) return;
s.next_object_update = now + 100000;
const auto captured = capture_local_network_objects();
if (s.clear_pending && clear_lobby_guest_objects()) s.clear_pending = false;
if (captured) s.local_objects.replace(captured->objects);
publish_guest_objects(s);
send_object_states(s, now);
if (!captured) {
// The native runtime may reset its remote objects meanwhile (a level reload).
s.object_owners_valid = false;
return;
}
// Handing the owners over copies every remote object into a new native table. Do it
// when an owner, its revision or the map changed; and once a second anyway, since the
// runtime can reset its table itself (a reload keeps the map name).
std::vector<std::tuple<std::uint64_t, std::uint64_t, std::uint64_t>> current;
for (const auto &peer : active_peers(s))
if (const auto &objects = visible_objects(s, peer); peer.handshaken && peer.world_ready && objects.revision())
current.emplace_back(peer.member.id, peer.member.epoch, objects.revision());
if (s.object_owners_valid && current == s.object_owners_sent && captured->map == s.object_map_sent &&
now >= s.object_owners_at && now - s.object_owners_at < 1000000)
return;
std::vector<NetworkObjectOwner> owners;
for (const auto &peer : active_peers(s))
if (const auto &objects = visible_objects(s, peer); peer.handshaken && peer.world_ready && objects.revision())
owners.push_back({peer.member.id, peer.member.epoch, objects.layout()});
set_remote_network_objects(captured->map, owners);
s.object_owners_sent = std::move(current);
s.object_map_sent = captured->map;
s.object_owners_valid = true;
s.object_owners_at = now;
}
void refresh_host_choices(Session &s, std::uint64_t now) {
if (s.mode != Mode::host || now < s.next_park_update) return;
s.next_park_update = now + 100000;
const auto parks = local_profile_parks().choices;
if (parks != s.parks) { s.parks = parks; s.roster_dirty = true; }
if (s.force_world_layers) {
const auto layers = local_profile_world_layers().choices;
if (layers != s.layers) { s.layers = layers; s.roster_dirty = true; }
}
}
void send_roster(Session &s, std::uint64_t now) {
auto p = packet(s, PacketKind::roster, now);
p.voice_policy = s.voice_policy;
p.voice_range = s.voice_range;
p.distances = s.distances;
p.object_placement = s.object_placement;
p.object_limit = s.object_limit;
p.guest_noclip = s.guest_noclip;
p.guest_no_bail = s.guest_no_bail;
p.guest_boosts = s.guest_boosts;
p.enforce_tuning = s.enforce_tuning;
p.object_clears = s.object_clears.value_or(0);
p.force_world_layers = s.force_world_layers;
s.layers = local_profile_world_layers().choices;
p.layers = pack_world_layers(s.layers);
s.parks = local_profile_parks().choices;
p.parks = s.parks;
p.capacity = s.capacity;
// Names come from the transport's short-lived name cache, not a Steam call per player.
// Steam names are cleaned to what a roster may carry: one that is not would be refused
// by every guest, and by this host's own encoder.
p.members.push_back({p.source, s.epoch, clean_roster_name(s.transport.name(p.source))});
p.members.back().scoring = s.local_scoring;
for (auto &peer : active_peers(s))
if (peer.handshaken) {
peer.member.name = clean_roster_name(s.transport.name(peer.member.id));
p.members.push_back(peer.member);
}
// Parties are the ones the lobby's players formed (session_party.cpp), as on a dedicated
// server. The host's own copy of each player follows the roster it sends.
fill_roster_parties(s, p.members);
for (auto &peer : active_peers(s))
if (peer.handshaken)
for (const auto &m : p.members)
if (m.id == peer.member.id) {
if (peer.member.party != m.party || peer.member.party_leader != m.party_leader ||
peer.member.party_open != m.party_open) ++s.party_revision;
peer.member.party = m.party;
peer.member.party_leader = m.party_leader;
peer.member.party_open = m.party_open;
}
set_local_party(s, p.members.front());
broadcast(s, p, true, false, now);
s.roster_dirty = false;
s.last_roster = now;
publish_chat(s); // the host's "/" argument lists (players) follow its own roster
}
void update_physics_tuning(Session &s, const NativeFrame &local, std::uint64_t now) {
// A guest whose host sets everyone's physics: the player's own edits stand down from the
// moment of joining (the roster says otherwise, if it does) and through travel, and what
// the host shares beyond its tuning is theirs. A dedicated server shares the game's own.
const bool enforced = s.mode == Mode::join && s.enforce_tuning;
set_session_tuning_enforced(enforced);
if (enforced && s.host_extras && !dedicated_host(s)) set_host_physics_extras(*s.host_extras);
else set_host_physics_extras({});
if (s.mode == Mode::host) {
physics_tuning::release(s.base);
if (!s.enforce_tuning) {
s.sent_tuning.reset();
s.tuning_packet.clear();
s.sent_extras = 0;
s.extras_packet.clear();
return;
}
// The host's physics beyond its tuning: small, so a look four times a second, and sent
// whenever they change (the first time even when they are the game's own, so a guest
// never keeps what an earlier spell of enforcement left it).
if (now >= s.next_extras_check) {
s.next_extras_check = now + 250000;
std::vector<std::uint8_t> extras;
if (local_physics_extras(s.sent_extras, extras)) {
if (extras.size() > max_physics_extras) extras.clear();
logging::log(logging::Level::info, logging::Channel::runtime,
"Multiplayer: sending your other physics changes to guests ({} bytes).", extras.size());
auto p = packet(s, PacketKind::physics_extras, now);
p.extras = std::move(extras);
s.extras_packet = encode_wire(p);
broadcast(s, p, true, false, now);
}
}
// Edits to the tuning are rare: a look every 5 s (a 17 KB copy) is enough.
if (now < s.next_tuning_check) return;
s.next_tuning_check = now + 5000000;
physics_tuning::prepare();
const auto differences = physics_tuning::local_differences(s.base, max_physics_tuning);
if (!differences || (s.sent_tuning && *s.sent_tuning == differences->bytes)) return;
if (differences->left_out)
logging::log(logging::Level::warning, logging::Channel::runtime,
"Multiplayer: {} of your changed physics tuning curves do not fit in the session's tuning and are left out.",
differences->left_out);
logging::log(logging::Level::info, logging::Channel::runtime,
"Multiplayer: sending your physics tuning to guests ({} changed groups of values, {} curves, {} bytes).",
differences->runs, differences->curves, differences->bytes.size());
s.sent_tuning = differences->bytes;
auto p = packet(s, PacketKind::physics_tuning, now);
p.tuning = differences->bytes;
s.tuning_packet = encode_wire(p);
broadcast(s, p, true, false, now);
return;
}
if (s.mode == Mode::join && s.roster_sequence && s.enforce_tuning) {
if (!world_playing(s, local)) return; // kept as it is while travelling
physics_tuning::prepare();
if (dedicated_host(s))
physics_tuning::enforce(s.base, local.entity, {});
else if (s.host_tuning)
physics_tuning::enforce(s.base, local.entity, *s.host_tuning);
return;
}
physics_tuning::release(s.base);
}
namespace {
std::string scoring_mod_names(const std::vector<std::string> &mods) {
std::string names;
for (const auto &mod : mods) names += (names.empty() ? "" : ", ") + mod;
return clean_chat_text(names);
}
} // namespace
std::string own_scoring_notice() {
const auto names = scoring_mod_names(mods::scoring_state().mods);
return "Your mods change scoring or physics" + (names.empty() ? std::string() : " (" + names + ")") +
", so throwdowns and coop challenges are off for you in this session. Turn them off and restart Skate to take part.";
}
void update_scoring(Session &s, std::uint64_t now) {
if ((s.mode != Mode::host && s.mode != Mode::join) || now < s.next_scoring_check) return;
s.next_scoring_check = now + 1000000; // it changes at most when mods are applied
const auto state = mods::scoring_state();
if (!state.known) return; // the launch's check is still reading the mods
if (s.mode == Mode::host) {
const bool flagged = s.score_check && state.fingerprint;
if (flagged == s.local_scoring) return;
s.local_scoring = flagged;
s.roster_dirty = true;
if (flagged) add_chat(s, 0, "ReSkate", own_scoring_notice());
return;
}
const auto *host = find_peer(s, s.host_id);
if (!host || !host->handshaken) return;
std::pair report{state.fingerprint, scoring_mod_names(state.mods)};
if (s.scoring_sent == report) return;
auto p = packet(s, PacketKind::scoring, now);
p.scoring = report.first;
p.text = report.second;
send_required(s, s.host_id, encode_wire(p));
logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: told the host our trick scoring is {}.",
report.first ? "changed by " + (report.second.empty() ? std::string("mods") : report.second) : "the game's own");
s.scoring_sent = std::move(report);
}
void judge_scoring(Session &s, Peer &peer) {
const bool flagged = s.score_check && peer.scoring && *peer.scoring;
if (flagged == peer.member.scoring) return;
peer.member.scoring = flagged;
s.roster_dirty = true;
const auto name = peer.member.name.empty() ? s.transport.name(peer.member.id) : peer.member.name;
logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: {}'s mods {} (scoring {:016x}{}).", name,
flagged ? "change scoring or physics: out of throwdowns and coop challenges" : "are no longer checked",
peer.scoring.value_or(0), peer.scoring_mods.empty() ? "" : ", " + peer.scoring_mods);
if (flagged)
add_chat(s, 0, "ReSkate", name + "'s mods change scoring or physics" +
(peer.scoring_mods.empty() ? std::string() : " (" + peer.scoring_mods + ")") +
", so they are out of throwdowns and coop challenges.");
}
void send_local(Session &s, const NativeFrame &local, std::uint64_t now, std::uint64_t captured_at,
bool pose_captured) {
if (!world_playing(s, local))
return;
s.local_root = local.pose.root;
auto deliver = [&](Packet &p, bool reliable, bool fresh) {
if (s.mode == Mode::echo) {
auto update = s.peers[0].sender.prepare(p);
bool missing{};
auto echoed = s.peers[0].receiver.receive(update.bytes, missing);
s.peers[0].sender.sent(p, std::move(update));
if (!echoed || !accept_data(s.peers[0], std::move(*echoed), now))
throw std::runtime_error("Local Echo packet could not be decoded.");
} else
broadcast(s, p, reliable, fresh, now);
return p.kind == PacketKind::cosmetics ? encode_wire(p) : std::vector<std::uint8_t>{};
};
if (now - s.last_cosmetic_capture >= 500000) {
s.last_cosmetic_capture = now;
auto appearance = capture_cosmetics(s.base, local, s.cosmetic_capture_status);
// The player's choices to go without their tag or their animated items travel with their
// outfit, so a change is sent like one and reaches players who join later.
if (appearance) {
appearance->hide_tag = !own_tag_shown();
appearance->hide_items = !own_items_shown();
appearance->marks = developer_hoodie_detail::own_styles.load();
}
if (appearance && (!s.sent_appearance || *appearance != *s.sent_appearance)) {
auto p = packet(s, PacketKind::cosmetics, now);
p.appearance = *appearance;
s.cosmetic_packet = deliver(p, true, false);
s.sent_appearance = appearance;
}
}
if (now < s.next_send)
return;
// A callback can cross the deadline after its cheap root capture. Wait for
// the next callback instead of accidentally publishing an empty rig.
if (!pose_captured)
return;
advance_pose_deadline(s.next_send, now, multiplayer_pose_interval(s.tps));
auto p = packet(s, PacketKind::pose, now);
// A few guarded reads, at the pose rate.
s.local_player_collision = local_allows_player_collision(s.base, local.entity);
p.player_collision = s.local_player_collision;
if (captured_at) p.time_us = captured_at;
p.pose = local.pose;
++s.local_pose_count;
if (s.pose_dump.is_open()) {
// A record: when it was captured (8 bytes), its length (4), and the compact encoding.
const auto raw = encode(p, true);
const std::uint64_t time = p.time_us;
const auto length = static_cast<std::uint32_t>(raw.size());
s.pose_dump.write(reinterpret_cast<const char *>(&time), sizeof time);
s.pose_dump.write(reinterpret_cast<const char *>(&length), sizeof length);
s.pose_dump.write(reinterpret_cast<const char *>(raw.data()), static_cast<std::streamsize>(raw.size()));
++s.pose_dump_count;
if (now >= s.pose_dump_until) {
s.pose_dump.close();
logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: pose dump finished, {} poses.", s.pose_dump_count);
}
}
deliver(p, false, true);
auto samples = drain_audio_capture(now);
if (!samples.empty()) {
auto a = packet(s, PacketKind::audio, now);
a.audio = std::move(samples);
if (s.pose_dump.is_open()) {
// The skater's sound is recorded with the poses, in the same records.
const auto raw = encode(a, true);
const std::uint64_t time = a.time_us;
const auto length = static_cast<std::uint32_t>(raw.size());
s.pose_dump.write(reinterpret_cast<const char *>(&time), sizeof time);
s.pose_dump.write(reinterpret_cast<const char *>(&length), sizeof length);
s.pose_dump.write(reinterpret_cast<const char *>(raw.data()), static_cast<std::streamsize>(raw.size()));
}
deliver(a, std::any_of(a.audio.begin(), a.audio.end(), [](const auto &sample) { return sample.event; }), true);
}
}
} // namespace session_detail
} // namespace dingosdk::multiplayer