mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 15:07:06 +02:00
Other players' skaters and skateboards showed none of the game's skater effects: not the trail or fire a costume or deck comes with, and not the sparks, dust and puffs of a skater touching the world. Costume and skateboard effects. The game gives another player's skater and skateboard the right effect sets by itself, but builds them before their outfit is on, so nothing showed. They are now built again half a second after the outfit is applied. An always-on effect that a skater's item puts on the skateboard's wheel joints (the Ghost Skater trail) follows the skater's own copy of those joints, which only this player's skater keeps up to date: for other players it is taken out of the skater's sets and built on their skateboard, which has the same joints. Contact effects. They come from a skater touching the world, which only happens in that player's own game. Each game now captures its skater's contacts (position, velocity, surface normal, surface material) and sends up to three a tick in a new unreliable packet, PacketKind::effects. Other games play each through the game's own function, so the effect is still chosen from the surface and the speed. A contact is shown as late as that player's poses are, only within 30 m of their skater, and only for a material the level's table has: the game reads that table without a bound. Server. A dedicated server relays effects packets to players within 150 m of the sender, at most one packet a tick from each player. New setting players.sync_effects (default true), console and admin command "effects on|off". Off: the server relays none, and the roster tells games neither to send their contacts nor to build other players' costume and skateboard effects. Player-hosted lobbies relay through the host and always share them. The roster carries one more byte for the setting. Still protocol 45, which has not been released. New console command "mp effects": what has been captured and played, and why the last contact was refused if one was.
728 lines
38 KiB
C++
728 lines
38 KiB
C++
#include "session_internal.h"
|
|
#include "Extension/Multiplayer/Remote/native_cosmetics.h"
|
|
#include "Extension/Multiplayer/Remote/native_audio.h"
|
|
#include "Extension/Multiplayer/Remote/native_vfx.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 ||
|
|
packet.kind == PacketKind::effects;
|
|
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 || packet.kind == PacketKind::effects) && 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);
|
|
}
|
|
// This skater's contacts with the world, for the sparks and dust others see on it.
|
|
if (auto impacts = drain_impacts(); !impacts.empty() && s.sync_effects) {
|
|
auto e = packet(s, PacketKind::effects, now);
|
|
e.impacts = std::move(impacts);
|
|
deliver(e, false, true);
|
|
}
|
|
}
|
|
} // namespace session_detail
|
|
} // namespace dingosdk::multiplayer
|