Files
ReSkate/Extension/Multiplayer/Session/session.cpp
T
zeex64 e7c6df0df6 Merge pull request #225 from Jean-LucTch/dev
Fix multiplayer custom map download and join timeout handling
2026-10-15 14:03:06 -05:00

1084 lines
55 KiB
C++

#include "session_internal.h"
#include "Extension/Skater/drone_beacon.h"
#include "Engine/Core/Debug/trace.h"
#include "Extension/Multiplayer/Hud/native_player_ui.h"
#include "Extension/Multiplayer/Hud/native_indicators.h"
#include "Extension/Multiplayer/Hud/native_party.h"
#include "party_book.h"
#include "Engine/Game/World/world_names.h"
#include "Extension/Multiplayer/Remote/native_skater.h"
#include "Extension/Multiplayer/Hud/custom_nametags.h"
#include "Extension/Multiplayer/Hud/follow_camera.h"
#include "Extension/Throwdowns/native_throwdowns.h"
#include "Extension/Throwdowns/throwdown_relay.h"
#include "Extension/Progression/script_natives.h"
#include "Extension/Multiplayer/Steam/steam_social.h"
#include "Extension/Profile/local_profile_runtime.h"
#include "Extension/Objects/network_object_runtime.h"
#include "Extension/Objects/ParkEditor/park_editor_runtime.h"
#include "Extension/Multiplayer/Remote/native_audio.h"
#include "Extension/Multiplayer/Remote/native_vfx.h"
#include "Extension/Skater/client_source_spawn.h"
#include "Extension/Skater/no_bail.h"
#include "Extension/Skater/physics_tuning.h"
#include "Extension/Multiplayer/Remote/remote_collision.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Core/Text/word_filter.h"
#include "Engine/Game/Multiplayer/session_tools.h"
#include "Engine/Game/UI/game_view.h"
#include <Windows.h>
#include <bcrypt.h>
#include <algorithm>
#include <cmath>
#include <limits>
#include <stdexcept>
#include <tuple>
#include <utility>
namespace dingosdk::multiplayer {
using namespace session_detail;
namespace session_detail {
std::uint64_t nonce() {
std::uint64_t value{};
if (BCryptGenRandom(nullptr, reinterpret_cast<PUCHAR>(&value), sizeof(value),
BCRYPT_USE_SYSTEM_PREFERRED_RNG) < 0 ||
!value)
throw std::runtime_error("Cannot generate a multiplayer session code.");
return value;
}
Session &session() {
static auto *s = new Session;
return *s;
}
Peer *find_peer(Session &s, std::uint64_t id) {
if (id)
for (auto &p : active_peers(s))
if (p.member.id == id)
return &p;
return nullptr;
}
unsigned player_count(const Session &s) {
unsigned count = 1;
for (const auto &p : active_peers(s))
if (p.handshaken && !(dedicated_host(s) && p.member.id == s.host_id))
++count;
return count;
}
void reset_peer(Session &s, std::size_t slot) {
remove_remote_network_objects(s.peers[slot].member.id, s.peers[slot].member.epoch);
const PeerScope scope(slot);
update_player_ui(s.base, nullptr, {});
remove_remote(s.base);
s.peers[slot] = {};
}
void stop(Session &s, std::string reason) {
s.local_typing_until = 0;
// A join that ends before the host's map was ready never got in.
if (s.mode == Mode::join && s.awaiting_map && !s.travelling && s.join_started)
trace::record("multiplayer.join", "app.join", std::chrono::milliseconds((now_us() - s.join_started) / 1000), false);
s.voice.reset();
s.voice_policy = {};
s.tps = multiplayer_default_tps;
s.object_placement = ObjectPlacement::everyone;
s.object_limit = 0;
s.object_scaling = true;
s.sync_effects = true;
s.sync_drones = true;
drone::beacon::server_allows().store(true);
set_lobby_object_limit(0);
s.server_admin = false;
s.role.clear();
s.own_tag.clear();
s.own_tag_color = 0;
s.role_all_commands = false;
s.role_commands.clear();
s.role_permissions = 0;
s.server_bans.clear();
s.server_ban_total = 0;
s.server_maps.clear();
text::set_own_words({}, {}); // the server's own filtered words go with it
s.chat_masked.clear();
s.server_map_pool.clear();
s.server_map_rotation = 0;
set_lobby_object_placement_allowed(true);
s.guest_noclip = s.guest_no_bail = s.guest_boosts = true;
s.enforce_tuning = true;
s.score_check = true;
s.scoring_sent.reset();
s.local_scoring = false;
s.next_scoring_check = 0;
s.host_tuning.reset();
s.sent_tuning.reset();
s.tuning_packet.clear();
s.next_tuning_check = 0;
s.host_extras.reset();
s.sent_extras = 0;
s.extras_packet.clear();
s.next_extras_check = 0;
set_session_tuning_enforced(false);
set_host_physics_extras({});
s.server_votes = 0;
s.server_polls = 0;
s.server_custom_votes.clear();
set_session_tools_allowed(true, true, true);
s.object_clears.reset();
s.clear_pending = false;
s.force_world_layers = false;
s.banned.clear();
s.join_backoff = {};
apply_host_world_layers(false, s.layers);
s.layers = default_world_layers();
clear_remote_network_objects();
set_lobby_object_guest(false);
s.local_objects = {}; s.next_object_update = 0;
set_lobby_park_mode(false, false);
s.parks = {}; s.next_park_update = 0;
s.next_party_update = 0;
s.parties = PartyBook{};
s.parties_revision = 0;
s.client_timing = {};
s.next_publish = 0;
s.last_client_log = 0;
erase_key(s.password);
erase_password(s.lobby_password);
s.lobbies.stop();
s.joined_public_lobby = 0;
s.transport.stop();
// Slots at or above used_slots were never used, or were reset when they were freed.
for (std::size_t slot = 0; slot < s.used_slots; ++slot)
reset_peer(s, slot);
s.used_slots = 0;
s.object_owners_valid = false;
reset_audio();
reset_effects();
s.public_host = s.started_map = s.roster_dirty = false;
s.awaiting_map = s.join_map_authorized = s.map_load_submitted = false;
s.join_started = s.last_map_request = s.last_map_load_check = 0;
s.join_destination.clear();
s.map_label.clear();
s.map_package.clear();
s.fetching_since = 0;
s.map_fetch_deadline = 0;
map_fetch_deadline_flag.store(0);
fetching_map_flag.store(false, std::memory_order_relaxed);
s.world = 1;
s.travelling = false;
s.host_world_ready = true;
s.travel_started = s.last_world_state = s.last_world_ready = 0;
s.world_state_sequence = 0;
s.direct_upload = {};
s.distances = {};
s.mode = Mode::off;
s.invite.clear();
s.lobby_name.clear();
s.map_name.clear();
s.native_status.clear();
s.cosmetic_capture_status.clear();
s.sent_appearance.reset();
s.local_root.reset();
s.last_skater_bones = s.last_board_bones = 0;
s.cosmetic_packet.clear();
s.map = s.context = s.parent = s.host_id = 0;
s.next_send = s.last_hello = s.last_roster = s.last_cosmetic_capture = s.last_routes =
s.last_network_log = 0;
s.sequence = s.roster_sequence = 0;
s.server_chat_badge = default_server_chat_badge;
s.server_chat_text = default_server_chat_text;
s.vote = {};
s.vote_ends = 0;
s.vote_mine = 0;
s.announcement = {};
s.announcement_ends = 0;
server_vote_open_flag.store(false, std::memory_order_relaxed);
server_poll_answers_flag.store(0, std::memory_order_relaxed);
set_next_maps({});
s.pose_streams.clear();
s.pose_ack = {};
s.pose_ack_due = false;
s.pose_upload = {};
s.own_poses.clear();
s.sound_streams.clear();
s.sound_upload = {};
s.local_pose_count = s.logged_local_pose_count = 0;
s.status = std::move(reason);
}
// Release world-owned objects and buffered state, keeping Steam connections,
// admission, password challenges and stable member slots intact.
void clear_world(Session &s, std::uint64_t now) {
s.voice.reset();
// The server starts its pose streams and message numbers over with the world.
s.pose_streams.clear();
s.pose_ack = {};
s.pose_ack_due = false;
s.pose_upload.restart();
s.own_poses.clear();
s.sound_streams.clear();
s.sound_upload.restart();
clear_remote_network_objects();
s.object_owners_valid = false;
s.local_objects = {}; s.next_object_update = 0;
for (std::size_t slot = 0; slot < s.used_slots; ++slot) {
const auto member = s.peers[slot].member;
const bool admitted = s.peers[slot].handshaken;
const bool authorized = s.peers[slot].map_authorized;
const auto challenge = s.peers[slot].password_challenge;
reset_peer(s, slot);
auto &peer = s.peers[slot];
peer.member = member;
peer.handshaken = admitted;
peer.map_authorized = authorized;
peer.password_challenge = challenge;
peer.world_ready = false;
peer.travel_since = admitted ? now : 0;
peer.fetching_since = 0;
peer.last_packet = now;
}
reset_audio();
reset_effects();
s.started_map = false;
s.context = s.parent = 0;
s.sent_appearance.reset();
s.local_root.reset();
s.last_skater_bones = s.last_board_bones = 0;
s.cosmetic_packet.clear();
s.cosmetic_capture_status.clear();
s.native_status.clear();
s.next_send = s.last_cosmetic_capture = s.last_routes = s.last_world_ready = 0;
s.roster_dirty = true;
}
bool world_playing(const Session &s, const NativeFrame &local) {
return local.ready && !s.travelling && !s.awaiting_map && s.host_world_ready;
}
} // namespace session_detail
namespace {
void publish_party(Session &s) {
const auto now = now_us();
if (now < s.next_party_update) return;
s.next_party_update = now + 100000;
PartyRoster roster;
if (s.mode != Mode::off) {
expire_party_invites(s, now);
const bool echo = s.mode == Mode::echo;
// Who is talking, for the party voice HUD (UIPlayerInfo PlayerMic).
const auto voice = s.voice.model();
const auto mic = [&](std::uint64_t id) -> std::int8_t {
if (!voice.ready || !voice.allowed) return -1;
const auto found = std::find_if(voice.players.begin(), voice.players.end(), [&](const auto &p) { return p.id == id; });
if (found == voice.players.end()) return -1;
return found->muted ? 0 : found->speaking ? 2 : 1;
};
const auto local_id = echo ? 2ULL : s.transport.status().local_id;
if (local_id) {
const auto name = echo ? steam_social_snapshot()->local.name : s.transport.name(local_id);
roster[0] = {local_id, s.epoch, name.empty() ? "Skater" : name,
true, echo || s.local_party_leader, s.gameplay_ready};
roster[0].member = echo || s.local_party;
roster[0].party = echo ? lobby_party : s.local_party;
roster[0].leader_of_party = echo || s.local_party_leader;
roster[0].party_open = !echo && s.local_party_open;
roster[0].mic = voice.ready && voice.allowed && voice.settings.enabled ? (voice.transmitting ? 2 : 1) : -1;
if (s.sent_appearance) roster[0].card = s.sent_appearance->card;
}
std::size_t index = 1;
for (std::size_t slot = 0; slot < s.used_slots; ++slot) {
const auto &peer = s.peers[slot];
if (!peer.handshaken || !peer.member.id || (dedicated_host(s) && peer.member.id == s.host_id)) continue;
const bool member = echo || party_member(s, peer.member.id);
roster[index++] = {peer.member.id, peer.member.epoch, peer.member.name, false,
member && peer.member.party_leader, peer.visible && peer.world_ready, slot};
roster[index - 1].member = member;
roster[index - 1].party = echo ? lobby_party : peer.member.party;
roster[index - 1].leader_of_party = !echo && peer.member.party_leader;
// A party's openness is on its leader's roster entry.
roster[index - 1].party_open = !echo && peer.member.party &&
(peer.member.party == s.local_party ? s.local_party_open : [&] {
for (std::size_t other = 0; other < s.used_slots; ++other)
if (s.peers[other].handshaken && s.peers[other].member.party == peer.member.party &&
s.peers[other].member.party_leader)
return s.peers[other].member.party_open;
return false;
}());
roster[index - 1].mic = mic(peer.member.id);
if (peer.appearance.value()) roster[index - 1].card = peer.appearance.value()->card;
}
// Native party colors follow member slots. Stable ordering keeps them
// consistent on every client even though remote render slots differ:
// party members first (leader first), then everyone else.
// Entries from `index` on are empty and already last.
std::sort(roster.begin(), roster.begin() + static_cast<std::ptrdiff_t>(index), [](const auto &a, const auto &b) {
if (!a.id || !b.id) return a.id != 0 && b.id == 0;
if (a.member != b.member) return a.member;
if (a.leader != b.leader) return a.leader;
return a.id < b.id;
});
}
if (!s.display_preferences_loaded) {
s.display_preferences_loaded = true;
s.nametags = profile_runtime::local_preference("Nametags").value_or(true);
if (const auto saved = profile_runtime::local_value("NametagDistance"); saved && saved->is_number())
s.nametag_distance = std::clamp(saved->get<float>(), 10.f, 500.f);
s.nametag_dots = profile_runtime::local_preference("NametagDots").value_or(true);
s.prefer_direct = profile_runtime::local_preference("DirectConnections").value_or(true);
if (const auto saved = profile_runtime::local_value("PlayerDistance"); saved && saved->is_number())
s.player_distance = std::clamp(saved->get<float>(), player_distance_least, player_distance_unlimited);
s.nametags_friends = profile_runtime::local_preference("NametagsFriendsOnly").value_or(false);
s.chat_visible = profile_runtime::local_preference("ChatVisible").value_or(true);
s.chat_filter = profile_runtime::local_preference("ChatFilter").value_or(true);
s.chat_bubbles = profile_runtime::local_preference("ChatBubbles").value_or(true);
s.chat_bubbles_own = profile_runtime::local_preference("ChatBubblesOwn").value_or(false);
if (const auto saved = profile_runtime::local_value("ChatBubblesDistance"); saved && saved->is_number())
s.chat_bubbles_distance = std::clamp(saved->get<float>(), 5.f, 500.f);
if (const auto saved = profile_runtime::local_value("ChatBubblesDuration"); saved && saved->is_number())
s.chat_bubbles_duration = std::clamp(saved->get<float>(), 1.f, 30.f);
if (const auto saved = profile_runtime::local_value("ChatBubblesHistory"); saved && saved->is_number())
s.chat_bubbles_history = std::clamp(static_cast<int>(saved->get<double>()), 1, 8);
show_own_tag(profile_runtime::local_preference("IdentityTag").value_or(true));
show_own_items(profile_runtime::local_preference("IdentityItems").value_or(true));
if (const auto saved = profile_runtime::local_value("IdentityStyles"); saved && saved->is_string())
if (const auto styles = developer_hoodie_detail::parse_mark_styles(saved->string()))
developer_hoodie_detail::own_styles.store(*styles);
apply_nametags(s);
}
// The party's limit: parties players form hold up to eight (the game's Party panel rows),
// in a lobby as on a dedicated server.
const bool session = s.mode == Mode::host || s.mode == Mode::join;
const auto capacity = s.mode == Mode::off ? static_cast<unsigned>(max_players)
: session ? static_cast<unsigned>(PartyBook::default_limit) : s.capacity;
// A party the player formed is the game's party (its Social menu, Coop button, beacons...).
// Nobody is in one just for being in the same lobby or on the same server.
set_native_party_changes(session);
const bool shown = session ? s.local_party != 0 : s.mode != Mode::off;
// What a dedicated server placed (its parks) says "Server", as its chat lines do.
set_native_party_server(dedicated_host(s) ? s.host_id : 0, "Server");
update_native_party(s.base, roster, capacity, shown);
}
// Links the players' own throwdowns (Extension/Throwdowns/throwdown_relay.cpp). Runs
// every tick, with no session too, so linked drops are removed when it ends. The input
// persists: its player list is rebuilt when the admitted players change, and names
// (Steam lookups) are refreshed once a second.
void relay_throwdowns(Session &s, bool in_world) {
auto &input = s.throwdown_input;
const bool in_session = s.mode == Mode::host || s.mode == Mode::join;
const auto local = in_session ? s.transport.status().local_id : 0;
const auto now = now_us();
const bool names = local != input.local || now < s.throwdown_names_at || now - s.throwdown_names_at >= 1000000;
if (names) s.throwdown_names_at = now;
input.local = local;
// A player the dedicated server flagged for game speed, or whose mods change trick scoring,
// plays no linked throwdowns or challenges: they are left out here as if gone (and when it is
// the local player, everyone is).
const bool barred = s.local_speeding || s.local_scoring;
const auto listed = [&](const Peer &peer) {
return peer.handshaken && peer.member.id && !(dedicated_host(s) && peer.member.id == s.host_id) &&
!peer.member.speeding && !peer.member.scoring && !barred;
};
input.barred = in_session && barred;
bool changed = names;
std::size_t count{};
if (in_session)
for (std::size_t slot = 0; slot < s.used_slots; ++slot)
if (listed(s.peers[slot])) {
changed |= count >= input.peers.size() || input.peers[count].id != s.peers[slot].member.id ||
input.peers[count].slot != slot;
++count;
}
if (changed || count != input.peers.size()) {
input.peers.clear();
if (in_session)
for (std::size_t slot = 0; slot < s.used_slots; ++slot) {
const auto &peer = s.peers[slot];
if (listed(peer))
input.peers.push_back({peer.member.id, slot,
peer.member.name.empty() ? s.transport.name(peer.member.id) : peer.member.name});
}
}
input.world = in_session ? s.map * 0x100000001b3ULL ^ s.world : 0;
input.in_world = in_world;
// Coop challenges invite players near the local skater (and place the celebration).
const auto me = in_session && in_world ? s.local_root : std::nullopt;
input.position = me ? std::optional(me->position) : std::nullopt;
for (auto &peer : input.peers) {
const auto &other = s.peers[peer.slot].latest_root;
float d2 = 1e12f;
if (me && other)
d2 = (other->position[0] - me->position[0]) * (other->position[0] - me->position[0]) +
(other->position[1] - me->position[1]) * (other->position[1] - me->position[1]) +
(other->position[2] - me->position[2]) * (other->position[2] - me->position[2]);
peer.nearby = d2 < 60.f * 60.f;
peer.party = party_member(s, peer.id);
}
if (names)
input.local_name = in_session ? s.transport.name(local) : std::string(steam_social_snapshot()->local.name);
for (const auto &[sender, message] : s.throwdown_inbox) receive_throwdown_relay(sender, message);
s.throwdown_inbox.clear();
for (auto &message : tick_throwdown_relay(s.base, input)) send_throwdown(s, std::move(message));
for (auto &text : take_throwdown_relay_notices()) add_chat(s, 0, "ReSkate", std::move(text));
}
// A player in the local coop celebration stands at their spot there: the whole pose moves,
// the board's rig anchor with it (offset_pose), or their board stays in the local player's hands.
Pose moved_by(Pose pose, const std::array<float, 3> &offset) {
offset_pose(pose, offset);
return pose;
}
// A skater the throwdown hides stays spawned (no respawn hitch every turn) but far below
// the world: its root, its placement joint (1), the board entity and the board rig all move down.
Pose out_of_sight(Pose pose) {
offset_pose(pose, {0.0f, -2000.0f, 0.0f});
return pose;
}
// How often a player's pose is sampled for their skater, from how far they are from both the
// local skater and the camera: every frame within 200 m, 20 Hz past it, 10 Hz past 350 m, and
// every frame again once back inside 190 m. Between samples the skater keeps its pose, which
// saves the interpolation, the pose copy and the skeleton write for players too far away to see.
// Only distance decides. Players out of the camera's view were sampled at 20 Hz as well for a
// while (it saved client-tick time on busy servers), but the game's replays record every skater
// as it stood each frame and their camera looks wherever it likes afterwards: a player who had
// been behind the camera moved at 20 Hz in the replay.
constexpr int far_full_rate_return = 190, far_half_rate_start = 200, far_low_rate_start = 350;
// Metres from a player to the nearer of the local skater and the camera (infinite if neither
// is known).
float nearest_distance(const Peer &p, const NativeFrame &local, const std::optional<GameView> &view) {
const auto &at = p.render_pose.root.position;
float nearest = std::numeric_limits<float>::infinity();
const auto consider = [&](float x, float y, float z) {
const float dx = at[0] - x, dy = at[1] - y, dz = at[2] - z;
nearest = std::min(nearest, dx * dx + dy * dy + dz * dz);
};
if (local.ready) consider(local.pose.root.position[0], local.pose.root.position[1], local.pose.root.position[2]);
if (view) consider(view->world[12], view->world[13], view->world[14]);
return std::sqrt(nearest);
}
std::uint64_t far_sample_interval(const Peer &p, float distance) {
const auto beyond = [&](int metres) { return distance > static_cast<float>(metres); };
if (!std::isfinite(distance) || !beyond(far_full_rate_return)) return 0;
if (beyond(far_low_rate_start)) return 100000;
if (beyond(far_half_rate_start) || p.far_interval) return 50000;
return 0;
}
void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::uint64_t now) {
if (!world_playing(s, local)) return;
const auto view = latest_game_view();
// ReSkate's nametags and chat bubbles: every shown player, placed above their head
// each frame.
std::vector<NametagPlayer> nametags;
const bool bubbles = s.chat_bubbles;
const bool labels = s.nametags || bubbles;
if (labels) refresh_friends(s);
// The newest chat lines from `sender` still inside the bubble duration, oldest first,
// filtered the same way the chat panel filters them, each with the opacity it has left
// (it fades over the last half second). At most `chat_bubbles_history` lines.
const auto recent_bubbles = [&](std::uint64_t sender) {
std::vector<NametagBubble> result;
if (!bubbles) return result;
const int limit = std::max(1, s.chat_bubbles_history);
const auto duration = static_cast<std::uint64_t>(std::max(0.5f, s.chat_bubbles_duration) * 1e6f);
const auto fade_from = std::min<std::uint64_t>(duration, 500000);
for (auto it = s.chat.rbegin(); it != s.chat.rend() && static_cast<int>(result.size()) < limit; ++it) {
if (it->sender != sender) continue;
// Older lines are older still: once one has expired, stop.
if (now < it->received || now - it->received >= duration) break;
NametagBubble line;
line.text = it->text;
if (s.chat_filter) {
const auto masked = s.chat_masked.find(it->sequence);
auto filtered = masked != s.chat_masked.end() ? masked->second.second : text::mask_bad_words(it->text);
if (filtered != it->text) line.raw = std::exchange(line.text, std::move(filtered));
}
if (line.text.find_first_not_of(' ') == std::string::npos) continue;
const auto age = now - it->received;
// Pops in over the first few frames, fades over the last half second.
constexpr std::uint64_t pop_us = 220000;
line.appear = std::min(1.0f, static_cast<float>(age) / static_cast<float>(pop_us));
line.fade = fade_from ? std::min(1.0f, static_cast<float>(duration - age) / static_cast<float>(fade_from)) : 1.0f;
result.push_back(std::move(line));
}
std::reverse(result.begin(), result.end()); // oldest first
return result;
};
// Grey discs while they typed recently. The step is local, about three a second.
// ".", "..", "..." is one, two, then three discs; the overlay draws them.
const auto typing_bubble = [&](std::uint64_t until) {
NametagBubble line;
if (!until || now >= until) return line;
static constexpr const char *steps[] = {".", "..", "..."};
line.text = steps[(now / 333000) % 3];
line.hint = true;
line.appear = line.fade = 1.f;
return line;
};
// Who is talking, as last published for the UI (10 Hz), without copying the voice model.
const auto &voices = s.view.voice.players;
const auto label = [&](const Peer &p) {
NametagPlayer tag;
tag.head = p.render_pose.root.position;
tag.head[1] += 1.0f;
tag.name = p.member.name.empty() ? s.transport.name(p.member.id) : p.member.name;
// The same role colour and badge the player's chat lines get.
std::tie(tag.color, tag.tag) = player_role(s, p.member.id, false);
// Friends only: everyone else keeps their chat bubbles and loses the name and dot.
tag.nameless = s.nametags_friends && !std::binary_search(s.friend_ids.begin(), s.friend_ids.end(), p.member.id);
tag.talking = std::any_of(voices.begin(), voices.end(), [&](const auto &v) { return v.id == p.member.id && v.speaking; });
tag.flying_drone = drone::beacon::flying(p.member.id);
tag.bubbles = recent_bubbles(p.member.id);
if (bubbles)
if (auto hint = typing_bubble(p.typing_until); !hint.text.empty()) tag.bubbles.push_back(std::move(hint));
nametags.push_back(std::move(tag));
};
// Creating a player's actor (skater, skateboard and both recipes) or re-applying a
// changed outfit is one heavy native pass. When several players arrive together
// (joining a busy server) run one pass per frame; the others wait for later frames.
bool native_pass{};
const auto apply_cosmetics = [&](Peer &p, bool spawned) {
if (now - p.last_cosmetic_apply < 500000) return;
if (p.applied_cosmetics != p.cosmetic_revision && !spawned) {
if (native_pass) return;
native_pass = true;
}
update_remote_cosmetics(s.base, local, *p.appearance.value(), p.cosmetic_status);
// The effects their costume and board items come with follow the outfit.
if (p.applied_cosmetics != p.cosmetic_revision && s.sync_effects) note_remote_outfit(now);
p.last_cosmetic_apply = now;
p.applied_cosmetics = p.cosmetic_revision;
};
// The player's sound for this frame, every frame they are shown, and the effects of their
// skater's contacts that are due.
const auto present_audio = [&](Peer &p) {
tick_remote_effects(now);
if (!s.sync_effects) p.impacts.clear();
while (!p.impacts.empty() && p.impacts.front().first <= now) {
const auto &impact = p.impacts.front().second;
float distance{};
for (unsigned i = 0; i < 3; ++i) {
const auto d = impact.position[i] - p.render_pose.root.position[i];
distance += d * d;
}
// Only where their skater is: a contact far from it is not theirs to place.
if (distance <= 30.f * 30.f) play_impact(impact);
p.impacts.pop_front();
}
if (const auto audio = p.audio.sample(now)) {
// Preserve discrete audio changes immediately, but let
// unchanged continuous inputs and spatial transforms
// ride for one network tick.
if (!p.presented_audio || audio_event_changed(*p.presented_audio, *audio) ||
now >= p.next_audio_update) {
update_remote_audio(s.base, local, p.render_pose, *audio);
p.presented_audio = *audio;
p.next_audio_update = now + network_tick_us;
}
} else {
stop_remote_audio();
p.presented_audio.reset();
p.next_audio_update = 0;
}
};
// The developer hoodie and board mark an identity: only one Steam vouches for (steam_vouched).
const auto developer_id = [&](const Peer &p) {
return steam_vouched(s, p) && shows_items(p) ? p.member.id : std::uint64_t{};
};
// Only players within `player_distance` get a skater: each one costs memory and frame time
// whether or not it is in view, and on a full server most are far across the map, too small
// to see. Checked twice a second; a player already shown stays until a little further
// out, so one skating along the edge is not built and taken down over and over (building
// a skater is the expensive part). Before a pose has placed them, nobody is too far.
if (now >= s.next_shown_rank) {
s.next_shown_rank = now + 500000;
const bool everyone = s.player_distance >= player_distance_unlimited;
for (auto &p : active_peers(s)) {
if (!p.member.id || (dedicated_host(s) && p.member.id == s.host_id)) continue;
p.shown_wanted = everyone || !p.placed ||
nearest_distance(p, local, view) <= s.player_distance * (p.visible ? 1.15f : 1.f);
}
}
each_active_peer(s, [&](Peer &p) {
// A dedicated server has no skater to show.
if (!p.member.id || (dedicated_host(s) && p.member.id == s.host_id))
return;
const bool was_visible = p.visible;
// A far player between samples: their skater keeps its pose; sound, the label and
// the spectate position are still refreshed (sound events are released one per frame).
if (was_visible && local.ready && !p.render_failed && p.far_interval && now < p.next_far_sample) {
update_developer_hoodie(s.base, remote_skater_entity(), developer_id(p), remote_skater_generation(), p.developer_hoodie,
mark_styles(p));
update_developer_board(s.base, remote_board_entity(), developer_id(p), remote_skater_generation(), p.developer_board,
mark_styles(p));
present_audio(p);
update_party_position(&p.render_pose);
if (labels) label(p);
return;
}
p.visible = false;
if (local.ready && !p.render_failed) {
const bool sampled = s.mode == Mode::echo ? p.poses.sample(now, p.render_pose)
: p.poses.sample_remote(now, p.render_pose);
// Nothing new from them for over a second (a stall here, at the host or on the way):
// their skater stays where it was for a while longer. Taking it down and building
// it again three seconds later is the most expensive thing a frame can do, and on
// a busy server one slow frame made it happen to everyone at once, which made
// the next frames slower still.
const bool held = !sampled && was_visible && p.poses.heard_within(now, 8000000);
// Showing a player without an actor spawns one (native_skater_spawn.cpp).
if (sampled) p.placed = true;
const bool spawning = sampled && p.shown_wanted && p.appearance.value() && !was_visible && !remote_skater_entity();
if (spawning && now < p.next_spawn) {
p.native_status = "Waiting to show the player again.";
} else if (spawning && native_pass) {
p.native_status = "Waiting for another player's skater to finish spawning.";
} else if ((sampled || held) && p.shown_wanted && p.appearance.value()) {
if (spawning) {
native_pass = true;
p.applied_cosmetics = 0; // the new actor wears no recipe yet
}
const bool hidden = throwdown_relay_hides(p.member.id);
const auto spot = hidden ? std::nullopt
: throwdown_relay_celebration_offset(p.member.id, p.render_pose.root.position);
p.visible = show_remote(s.base, client, local,
hidden ? out_of_sight(p.render_pose) : spot ? moved_by(p.render_pose, *spot) : p.render_pose,
p.native_status);
const auto distance = nearest_distance(p, local, view);
p.far_interval = p.visible && !hidden ? far_sample_interval(p, distance) : 0;
p.next_far_sample = now + p.far_interval;
// The native work a far player's skater may skip (puppet_cost.cpp).
if (p.visible) note_remote_distance(distance);
// Solid for the local skater within reach, when both have party collision on.
// Local Echo shows your own skater: never solid.
const auto &at = p.render_pose.root.position, &me = local.pose.root.position;
const float reach2 = (at[0] - me[0]) * (at[0] - me[0]) + (at[1] - me[1]) * (at[1] - me[1]) +
(at[2] - me[2]) * (at[2] - me[2]);
update_remote_collision(s.base, local.context, p.render_pose,
p.visible && !hidden && s.mode != Mode::echo && s.local_player_collision &&
p.player_collision && reach2 < 40.f * 40.f,
now);
if (p.visible && hidden) {
// Keeps the actor and its cosmetics; no sound, nametag or spectate position.
stop_remote_audio();
p.presented_audio.reset();
if (p.ui_visible) {
update_player_ui(s.base, nullptr, {});
p.ui_visible = false;
p.next_ui_update = 0;
}
apply_cosmetics(p, spawning);
} else if (p.visible) {
present_audio(p);
apply_cosmetics(p, spawning);
// The spectate camera follows this every frame; the map and
// indicators below only need network-rate updates.
update_party_position(&p.render_pose);
if (labels) label(p);
if (!p.ui_visible || now >= p.next_ui_update) {
update_player_ui(s.base, &p.render_pose, p.member.name);
p.ui_visible = true;
p.next_ui_update = now + network_tick_us;
}
} else {
p.render_failed = true;
remove_remote(s.base);
}
} else {
if (was_visible) {
remove_remote(s.base);
p.next_spawn = now + 3000000;
}
p.native_status = !p.shown_wanted ? "Further away than players are shown."
: sampled ? "Waiting for the player's cosmetic recipe."
: "Waiting for player poses.";
// No skater for them, but still their name or dot where they are, and where the
// Social menu's teleport and spectate find them.
if (!p.shown_wanted && sampled) update_party_position(&p.render_pose);
if (!p.shown_wanted && sampled && labels) label(p);
}
}
update_developer_hoodie(s.base, p.visible ? remote_skater_entity() : 0, developer_id(p),
remote_skater_generation(), p.developer_hoodie, mark_styles(p));
update_developer_board(s.base, p.visible ? remote_board_entity() : 0, developer_id(p),
remote_skater_generation(), p.developer_board, mark_styles(p));
if (!p.visible) {
stop_remote_audio();
p.presented_audio.reset();
p.next_audio_update = 0;
if (p.ui_visible) {
update_player_ui(s.base, nullptr, {});
p.ui_visible = false;
p.next_ui_update = 0;
}
}
});
// The local player's own lines, above their own skater, when asked for. The typing
// preview uses the same three-second window other players see, and shows even when
// own chat lines are hidden.
if (labels && bubbles && local.ready) {
auto own = s.chat_bubbles_own ? recent_bubbles(s.transport.status().local_id) : std::vector<NametagBubble>{};
if (auto hint = typing_bubble(s.local_typing_until); !hint.text.empty()) own.push_back(std::move(hint));
if (!own.empty()) {
NametagPlayer tag;
tag.head = local.pose.root.position;
tag.head[1] += 1.0f;
tag.self = true;
tag.bubbles = std::move(own);
nametags.push_back(std::move(tag));
}
}
if (labels)
publish_custom_nametags(s.base, std::move(nametags),
local.ready ? std::optional(local.pose.root.position) : std::nullopt,
s.nametags, bubbles, s.chat_bubbles_distance, s.nametag_distance,
s.nametag_dots);
}
} // namespace
MultiplayerModel model() {
auto &s = session();
std::lock_guard lock(s.mutex);
return s.view;
}
std::string take_leave_notice() { return std::exchange(session().leave_notice, {}); }
std::string take_removed_notice() { return std::exchange(session().removed_notice, {}); }
std::optional<MapNeed> take_map_need() { return std::exchange(session().map_need, std::nullopt); }
void set_map_package_lookup(MapPackageLookup lookup) noexcept { map_package_lookup.store(lookup, std::memory_order_relaxed); }
bool fetching_map() noexcept { return fetching_map_flag.load(std::memory_order_relaxed); }
std::uint64_t map_fetch_deadline() noexcept { return map_fetch_deadline_flag.load(); }
void hold_map_load(bool hold, std::string_view package) noexcept {
map_load_held.store(hold && !package.empty() ? map_hash(package) : 0);
}
MultiplayerChat chat() {
auto &s = session();
std::lock_guard lock(s.mutex);
return s.chat_view;
}
void host_map_change(std::string_view destination) {
auto &s = session();
if (s.mode != Mode::host || (!s.started_map && !s.travelling)) return;
try {
begin_host_world(s, destination, now_us());
publish(s);
} catch (const std::exception &e) {
stop(s, e.what());
publish(s);
}
}
bool observe_local_world(Session &s, const NativeFrame &local, std::string_view current, std::uint64_t now) {
const auto map = current.empty() ? 0 : map_hash(current);
const bool changed = s.started_map &&
(!local.ready || s.map != map || s.context != local.context || s.parent != local.parent);
if (s.mode == Mode::host) {
if (changed && !s.travelling)
begin_host_world(s, local.ready ? current : std::string_view{}, now);
if (s.travelling && local.ready) {
// A failed native load may leave the old map active. Publish that
// destination with a fresh generation so guests recover with us.
if (s.map && s.map != map) begin_host_world(s, current, now);
s.map = map;
s.map_name = current;
s.travelling = false;
s.host_world_ready = true;
s.last_world_state = 0;
for (auto &peer : active_peers(s)) peer.last_packet = now;
s.status = "Host map loaded. Waiting for players to finish loading...";
}
} else if (s.travelling) {
if (local.ready && !s.awaiting_map && s.host_world_ready) {
s.travelling = false;
s.map_fetch_deadline = 0;
map_fetch_deadline_flag.store(0);
s.last_world_ready = 0;
for (auto &peer : active_peers(s)) {
peer.last_packet = now;
peer.connected_at = now;
peer.travel_since = 0;
}
s.status = "Map changed. Still connected to the same session.";
}
} else if (changed) {
stop(s, "Session ended because your local map changed. Join again to return to the host.");
return false;
}
if (s.travelling && now - s.travel_started > 180000000) {
stop(s, "Map change timed out while waiting for loading to finish.");
return false;
}
if (local.ready && !s.awaiting_map) {
s.started_map = true;
s.map = map;
s.map_name = current;
s.context = local.context;
s.parent = local.parent;
}
return true;
}
// Native loading can unload every local actor. Keep the connection in preflight
// until the selected map and a controllable skater are available.
bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoader loader, std::uint64_t now) {
if (map_fetch_policy::expired(s.map_fetch_deadline, now)) {
stop(s, "Map download timed out. Please try joining the server again.");
return false;
}
if (!s.awaiting_map)
return true;
// (While the map is being fetched the clock is held: see below.)
if (!s.fetching_since && now - s.join_started > 180000000) {
stop(s, "Joining timed out while waiting for the host's map to load.");
return false;
}
if (!s.join_map_authorized || s.join_destination.empty())
return false;
if (map_hash(current) == map_hash(s.join_destination)) {
if (!ready) {
s.status = "Host map loaded. Waiting for your skater...";
return false;
}
if (!s.travelling && s.join_started)
trace::record("multiplayer.join", "app.join", std::chrono::milliseconds((now - s.join_started) / 1000));
s.awaiting_map = false;
s.fetching_since = 0;
fetching_map_flag.store(false, std::memory_order_relaxed);
s.last_hello = 0;
s.status = s.travelling ? "Your map is ready. Waiting for the host..." : "Host map ready. Joining the session...";
return true;
}
if (!loader) {
stop(s, "The native map loader is unavailable. Restart ReSkate and try joining again.");
return false;
}
if (!s.last_map_load_check || now - s.last_map_load_check >= 500000) {
s.last_map_load_check = now;
std::string detail;
// The host's map, installed here at another version: it is not loaded as it is. It is
// fetched at the host's version as a missing one is (the downloader puts it in the
// installed one's place), and loads when the two agree.
const auto have = std::string(other_map_version(s.map_package, host_map_package(s.join_destination)));
// (Nor while its files are being put in place and applied: hold_map_load.)
const bool held = !s.map_package.empty() && map_load_held.load() == map_hash(s.map_package);
const auto result = have.empty() && !held ? loader(s.join_destination, s.map_load_submitted, detail) : MapLoadResult::missing;
if (result == MapLoadResult::missing) {
const auto name = s.map_label.empty() ? world_level_name(world_destination_asset(s.join_destination)) : s.map_label;
const char *who = dedicated_host(s) ? "server" : "host";
const auto where = (s.travelling ? std::string("The ") + who + " moved to " : std::string("The ") + who + " is on ") + name;
// A map the host named the package of can be downloaded (take_map_need). The
// session waits for it: the host is told the map is being fetched and gives that
// far longer than a load, and the map is tried again here until it is installed,
// when it loads like any other. The wait has an end of its own.
if (!s.map_package.empty()) {
if (!s.fetching_since) {
s.fetching_since = now;
if (!s.map_fetch_deadline) s.map_fetch_deadline = now + map_fetch_limit_us;
map_fetch_deadline_flag.store(s.map_fetch_deadline);
fetching_map_flag.store(true, std::memory_order_relaxed);
s.map_need = MapNeed{s.map_package, name, dedicated_host(s), s.travelling, have, s.map_fetch_deadline};
s.rejoin = Session::Rejoin{{s.host_id, s.secret}, s.password, s.map_fetch_deadline};
s.last_map_request = s.last_world_ready = 0; // say so at once
}
// The time a load gets starts when the map is here.
s.join_started = now;
if (s.travelling) s.travel_started = now;
s.status = "Waiting for " + name + " to download...";
return false;
}
s.leave_notice = where + ", which is not installed on this PC. Install its map mod and join again.";
stop(s, s.leave_notice);
return false;
}
if (result == MapLoadResult::failed) {
stop(s, detail.empty() ? "The host's map could not be loaded." : std::move(detail));
return false;
}
s.map_load_submitted |= result == MapLoadResult::queued;
s.status = detail.empty() ? "Loading the host's map..." : std::move(detail);
}
return false;
}
void tick(std::uintptr_t base, std::uintptr_t client, bool ready, std::string_view map_name, MapLoader loader) {
auto &s = session();
struct VoicePlaybackTick {
VoiceChat &voice;
std::uintptr_t base;
~VoicePlaybackTick() { voice.process_native(base); }
} voice_playback_tick{s.voice, base};
s.base = base;
initialize_native_throwdowns(base);
profile_runtime::install_script_error_log(base);
profile_runtime::install_board_wear_hold(base);
s.gameplay_ready = ready;
s.available_map = map_name;
set_multiplayer_session_active(s.mode == Mode::host || s.mode == Mode::join);
struct PartyPublication {
Session &value;
~PartyPublication() { try { publish_party(value); } catch (...) {} }
} party_publication{s};
try {
std::deque<std::unique_ptr<PrivateRequest>> requests;
{
std::lock_guard lock(s.request_mutex);
requests.swap(s.requests);
}
const auto dispatch_now = now_us();
for (auto &request : requests)
if (dispatch_now - request->queued <= 10000000)
command(request->action, request->argument, request->password);
requests.clear();
// Commands start asynchronous lobby timers using the current clock.
s.lobbies.tick(now_us());
s.servers.tick(now_us());
if (const auto lobby = s.lobbies.take_join()) {
PrivateRequest input;
input.password = s.lobby_password;
erase_password(s.lobby_password);
if (s.mode == Mode::off) {
command("join", lobby->code, input.password);
if (s.mode == Mode::join) s.joined_public_lobby = lobby->id;
} else
s.status = "Lobby join cancelled because the session changed.";
}
if (!s.lobbies.status().joining)
erase_password(s.lobby_password);
expire_remote_collision(base, now_us());
if (s.mode == Mode::off) {
physics_tuning::release(base); // the player's own tuning again after a session
set_session_tuning_enforced(false);
relay_throwdowns(s, ready);
// Without a session the UI model follows at the same 10 Hz as in one;
// commands still publish at once.
if (const auto now = now_us(); now >= s.next_publish) {
s.next_publish = now + 100000;
publish(s);
}
return;
}
// Both queued joins and completed browser joins set join_started inside
// command(). An earlier frame timestamp would underflow timeout checks.
const auto now = now_us();
refresh_host_choices(s, now);
s.client_timing.begin(now);
struct TimedTick {
Session &s;
std::uint64_t started;
~TimedTick() {
if (s.mode == Mode::off) return;
const auto end = now_us();
s.client_timing.finish(started, end);
if (!s.last_client_log) s.last_client_log = end;
if (end - s.last_client_log < 60000000) return; // a summary a minute, not log spam
s.last_client_log = end;
const auto timing = s.client_timing.snapshot(end);
const auto last_entry = s.client_timing.last_entry;
s.client_timing = {};
s.client_timing.last_entry = last_entry;
logging::log(logging::Level::info, logging::Channel::runtime,
"Multiplayer client: callbacks={:.1f}/s, max gap={:.2f} ms, work avg/max={:.2f}/{:.2f} ms, "
"capture={:.2f}/{:.2f}, receive={:.2f}/{:.2f}, send={:.2f}/{:.2f}, render={:.2f}/{:.2f}, "
"diagnostics={:.2f}/{:.2f} ms (avg/max).",
timing.callback_hz, timing.gap_max_ms, timing.work_ms, timing.work_max_ms,
timing.mean_ms[0], timing.peak_ms[0], timing.mean_ms[1], timing.peak_ms[1],
timing.mean_ms[2], timing.peak_ms[2], timing.mean_ms[3], timing.peak_ms[3],
timing.mean_ms[4], timing.peak_ms[4]);
}
} timed_tick{s, now};
NativeFrame local;
const bool map_ready = prepare_join_map(s, ready, map_name, loader, now);
// The hooks the remote-player code needs, installed once on the first tick in a world.
// Each install briefly suspends every game thread; done when the first other player
// appeared, that was a visible hitch (joining a server with players on it).
if (ready && !map_name.empty() && !s.hooks_prepared) {
s.hooks_prepared = true;
const auto started = now_us();
prepare_native_indicators(base);
prepare_player_ui(base);
std::string entity_detail;
if (!install_entity_hooks(base, entity_detail) && !entity_detail.empty())
logging::log(logging::Level::warning, logging::Channel::runtime, "Multiplayer: {}", entity_detail);
prepare_remote_audio(base);
prepare_effects(base);
logging::log(logging::Level::info, logging::Channel::runtime,
"Multiplayer: remote-player hooks installed in {} ms (once, before anyone joins).",
(now_us() - started) / 1000);
}
if (s.mode == Mode::off) {
// Solo, the spectate camera still learns how the game's camera frames the skater.
if (ready && !map_name.empty()) {
try {
const auto solo = capture_local(base, client, false);
if (solo.ready) note_local_skater(solo.pose.root);
} catch (...) {}
}
publish(s);
return;
}
const bool capture_pose = !s.next_send || now >= s.next_send;
if (map_ready && ready && !map_name.empty())
local = capture_local(base, client, capture_pose);
else
local.detail = s.awaiting_map ? "Waiting for the host's map and local skater." : "Load a map to skate.";
const auto captured_at = capture_pose ? now_us() : 0;
if (local.ready) note_local_skater(local.pose.root);
if (capture_pose && local.ready) {
s.last_skater_bones = local.pose.skater.size();
s.last_board_bones = local.pose.board.empty() ? 0 : local.pose.board.size() - 1;
}
if (!observe_local_world(s, local, map_name, now)) {
s.voice.reset();
publish(s);
return;
}
if (!local.ready) {
s.native_status = local.detail;
} else {
if (world_playing(s, local)) prepare_audio_capture(base, local);
}
const auto receive_at = now_us();
s.client_timing.record(ClientTiming::capture, now, receive_at);
if (s.mode != Mode::echo)
networking(s, local, receive_at);
for (const auto &note : s.transport.take_direct_notes())
logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: {}", note);
relay_throwdowns(s, world_playing(s, local));
// A S.K.A.T.E. player waiting for their turn stays off the board.
update_board_lock(client, local.entity, local.ready && throwdown_relay_waits_offboard());
update_physics_tuning(s, local, now);
const auto send_at = now_us();
s.client_timing.record(ClientTiming::network, receive_at, send_at);
if (s.mode != Mode::off) {
VoiceScene voice_scene;
voice_scene.active = s.mode != Mode::echo && world_playing(s, local);
voice_scene.policy = s.voice_policy;
if (s.mode == Mode::join && !s.roster_sequence) voice_scene.policy.allowed = false;
voice_scene.session = s.secret; voice_scene.world = s.world;
voice_scene.listener = local.pose.root;
std::array<float, 16> camera{};
if (voice_scene.active && voice_scene.policy.allowed && s.voice_settings.enabled &&
read_local_camera_transform(base, client, camera)) {
voice_scene.ear_position = {camera[12], camera[13], camera[14]};
voice_scene.ear_right = {camera[0], camera[1], camera[2]};
}
for (const auto &peer : active_peers(s))
if (peer.handshaken && peer.latest_root && peer.world_ready && peer.pose_arrival &&
send_at - peer.pose_arrival <= 1000000)
voice_scene.peers.push_back({peer.member.id, peer.member.epoch, *peer.latest_root});
s.voice.update(std::move(voice_scene));
for (auto &data : s.voice.take_capture()) {
auto voice = packet(s, PacketKind::voice, send_at);
voice.voice = std::move(data);
broadcast(s, voice, false, true, send_at);
}
sync_objects(s, local, send_at);
send_local(s, local, send_at, captured_at, capture_pose);
const auto render_at = now_us();
s.client_timing.record(ClientTiming::send, send_at, render_at);
render(s, client, local, render_at);
const auto diagnostics_at = now_us();
s.client_timing.record(ClientTiming::render, render_at, diagnostics_at);
// Native rendering/networking remain per-frame. Rebuild the UI
// snapshot at 10 Hz; explicit commands still publish immediately.
if (diagnostics_at >= s.next_publish) {
s.next_publish = diagnostics_at + 100000;
publish(s, &local);
}
s.client_timing.record(ClientTiming::publish, diagnostics_at, now_us());
} else {
publish(s, &local);
}
} catch (const std::exception &e) {
stop(s, std::string("Multiplayer stopped: ") + e.what());
publish(s);
}
}
} // namespace dingosdk::multiplayer