Files
ReSkate/Extension/Multiplayer/Session/session_send.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

630 lines
32 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); });
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;
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;
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 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);
}
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;
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.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;
const auto appearance = capture_cosmetics(s.base, local, s.cosmetic_capture_status);
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;
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);
deliver(a, std::any_of(a.audio.begin(), a.audio.end(), [](const auto &sample) { return sample.event; }), true);
}
}
} // namespace session_detail
} // namespace dingosdk::multiplayer