mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 17:27:19 +02:00
Players reported the game hanging for about a second while skating. A profile showed the memory line in the log walking every heap (HeapSummary) on the client thread, 0.5 to 2 s every two minutes. An audit of everything that runs on the game's update and render threads found more of the same, and Sentry's top crashes had causes of ours. Freezes: - Memory log line: no heap walk or thread snapshot; the GPU part and the write happen on a thread of their own. - Profile database: synchronous=NORMAL in WAL mode. A save is still kept when the game crashes; every play event no longer waits for the disk inside the game's hooks. - ReSkate's own preferences and values (menu clicks, session sliders, Discord) are saved by a background writer in order; reads see a value until it is saved. - Input: the foreground window and the keyboard are sampled on a thread of their own (GetAncestor and GetAsyncKeyState stalled the client tick 40-50 ms when another program held Windows' lock); flight input uses the sample; ClipCursor at most twice a second while a menu has the pointer. - Hooks installed while the game runs go in one transaction per place (HookBatch): throwdowns, remote players, the party, Hall of Meat. - Off the update thread: the host's map package lookup (one table read in the background, not the Mods folder per offer), the trainer's map file and settings save, Board Break's settings save, mod music playlists and their covers, the Spotify station covers. - Off the render thread: the Modding page's and the deck card's Mods folder reads. The overlay's three startup waits share five seconds. - Less work per frame: Steam names reused while one a frame is asked again, 2 friends a tick, steam_api64.dll checked once per process, cosmetic names read in place rather than a system call per character, an outfit load no longer copies the inventory per slot, the throwdown type scan runs at background priority over resident memory, the Board Break deck label is kept a minute. Crashes: - Render-resource pool: the metadata fields are written to the state owner only. The other renderer is 0x2daa00 bytes, and +0x11aae28 is far past its end (store_field crash; heap corruption in every release). - Other players' skateboard effects: a stale skateboard component is no longer followed, and the joint lookup is walked with a bound first (an endless loop in reused memory ended in GameStall). - Launcher: Oodle decodes into the buffer it asks for with corrupt data possible, and its assertions no longer stop the process (heap corruption in the store-copy scan; a breakpoint in oo2core). Timings (Send performance timings): - A frame of half a second or more in a level is sent as game.freeze: where the time went (ReSkate's tick, the game's frame, or outside it), whether the renderer kept presenting, and ReSkate's busiest zones. - Closing the game sends the level session it was in. Music after a connection drop: - Spotify device: an account Spotify could not be reached with is kept and tried again (3 s doubling to a minute); a session that drops is taken up again. Only an account Spotify refuses is asked for anew. - YouTube playlist pictures that could not be fetched are tried again, from 30 s to 10 minutes apart.
1093 lines
55 KiB
C++
1093 lines
55 KiB
C++
#include "session_internal.h"
|
|
#include "Engine/Core/Hooks/hooks.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;
|
|
{
|
|
// (Its hooks, and the throwdown lab's, go in together: one pause of the game's threads.)
|
|
HookBatch batch;
|
|
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();
|
|
std::string entity_detail;
|
|
{
|
|
// All of them in one pause of the game's threads, not one each.
|
|
HookBatch batch;
|
|
prepare_native_indicators(base);
|
|
prepare_player_ui(base);
|
|
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 ¬e : 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
|