From 38d86e1b7245e39bd11d70f9262e6d039b89ec21 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Wed, 7 Oct 2026 23:10:13 -0500 Subject: [PATCH] New pose and sound formats for dedicated servers; direct connections; protocol 44 Poses and skater sound between a dedicated server and its players use new formats. Lobbies and the links between games are unchanged. Poses (pose_codec.h, pose_batch.h): - A pose is rounded once (rotations to 0.08 degrees, positions to a millimetre) and sent as its differences from a pose of the same player the receiver has acknowledged. Several players' poses share one message. - The receiver acknowledges messages once a tick; the sender builds only on acknowledged poses, or on the whole pose it sent reliably. Loss costs the poses in that packet and nothing more. - Fingers are not sent to players further than "finger_distance" (25 m). - Games upload their own poses the same way, and the server acknowledges. - About 300 B a pose at full rate against about 660 B before. - The server no longer rounds rotations by distance: it saved little. Sound (sound_codec.h): - Each sample is sent as what changed since the one before, numbers rounded to 1/1024. One reliable message a tick carries every skater a player can hear. A stream is sent whole at its start and every two seconds. - About 106 B a message against 368 B before, on a recording of real play. Direct connections: - "connection": "relay" (default) or "direct", with "direct_address" and "direct_port". A direct server listens on UDP and is listed with its address; players still sign in through Steam. - Games connect straight to servers that offer it and fall back to the relays after 5 s. Setting and "mp direct-connections" to turn it off. Server: - "bone_scale_limit" is 1 by default: resized body parts are off unless a server allows them. - Logs Steam's relay network status at start-up and in "net", and watches the Steam sign-in. - "pack_ms" (10): how long Steam may hold a message to pack it with others. - "steam_debug": logs what Steam's networking says, for connection faults. - "relay_everything": sends poses and sound through the server even between games that have linked, for testing with few players. - "net" shows pose sizes by rate, the share sent without fingers, and whole poses sent. Game: - Only ReSkate's nametags are shown; the game's own are removed. - "mp pose-dump " records the local skater's poses and sound. Tests: pose_codec_tests covers exact round trips, chains of differences, held fingers, damaged input, the sender and receiver over a link that delays, loses and reorders, and the sound format; with a recording as its argument it prints sizes on real play. Protocol 44: servers and games must both update. --- CMakeLists.txt | 6 + Engine/Game/Multiplayer/session_model.h | 7 + Extension/Multiplayer/Net/pose_batch.h | 458 ++++++++++++++ Extension/Multiplayer/Net/pose_codec.h | 361 +++++++++++ Extension/Multiplayer/Net/protocol.h | 2 +- Extension/Multiplayer/Net/sound_codec.h | 273 +++++++++ Extension/Multiplayer/Session/session.cpp | 18 + .../Multiplayer/Session/session_commands.cpp | 47 +- .../Multiplayer/Session/session_internal.h | 22 + .../Multiplayer/Session/session_receive.cpp | 107 +++- .../Multiplayer/Session/session_send.cpp | 55 ++ .../Multiplayer/Session/session_view.cpp | 1 + .../Steam/steam_server_browser.cpp | 25 + .../Multiplayer/Steam/steam_transport.cpp | 186 +++++- Extension/Multiplayer/Steam/steam_transport.h | 26 +- .../Multiplayer/Test/pose_codec_tests.cpp | 564 ++++++++++++++++++ Extension/Multiplayer/console_commands.cpp | 5 +- Extension/UI/Overlay/multiplayer_menu.cpp | 9 + Server/README.txt | 44 ++ Server/Test/server_config_tests.cpp | 6 + Server/main.cpp | 78 ++- Server/server_config.cpp | 35 ++ Server/server_config.h | 23 + Server/server_host.cpp | 325 ++++++++-- Server/server_host.h | 60 +- Server/steam_server.cpp | 2 + Server/steam_server.h | 2 + 27 files changed, 2689 insertions(+), 58 deletions(-) create mode 100644 Extension/Multiplayer/Net/pose_batch.h create mode 100644 Extension/Multiplayer/Net/pose_codec.h create mode 100644 Extension/Multiplayer/Net/sound_codec.h create mode 100644 Extension/Multiplayer/Test/pose_codec_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 639d84f5..cabe51e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -101,6 +101,12 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS) Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Net/delta_codec.cpp) target_link_libraries(dingosdk_multiplayer_bandwidth_tests PRIVATE dingosdk_lz4 dingosdk_zstd) add_test(NAME multiplayer_bandwidth COMMAND dingosdk_multiplayer_bandwidth_tests) + # With a pose-dump recording as its argument it prints what each encoding costs on real play. + add_executable(dingosdk_pose_codec_tests + Extension/Multiplayer/Test/pose_codec_tests.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Remote/playback_buffers.cpp + Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Net/delta_codec.cpp) + target_link_libraries(dingosdk_pose_codec_tests PRIVATE dingosdk_lz4 dingosdk_zstd) + add_test(NAME multiplayer_pose_codec COMMAND dingosdk_pose_codec_tests) add_executable(dingosdk_multiplayer_lane_tests Extension/Multiplayer/Test/steam_lanes_tests.cpp) target_include_directories(dingosdk_multiplayer_lane_tests SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking") add_test(NAME multiplayer_lanes COMMAND dingosdk_multiplayer_lane_tests) diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 35c36b62..7381ddae 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -37,6 +37,10 @@ struct MultiplayerLobby { // A dedicated server the ReSkate team runs (developer_identity.h): first in the browser. bool official{}; int ping = -1; // ms, when the server answered directly + // A dedicated server that takes direct connections: its address (host byte order) and UDP + // port, 0 for none. + std::uint32_t direct_ip{}; + int direct_port{}; // Steam friends skating there now, by name (steam_social.h). std::vector friends; }; @@ -154,6 +158,9 @@ struct MultiplayerModel { // it they keep their nametag or dot. Each skater costs memory and frame time, in view or // not. `player_distance_unlimited` and above: every player, however far. float player_distance{120.f}; + // Local: connect straight to dedicated servers that offer it (their "connection": "direct") + // instead of through Steam's relays. Off: always the relays, and no server sees this PC's address. + bool prefer_direct{true}; float nametag_distance{120.f}; bool nametag_dots{true}; bool nametags_friends{}; diff --git a/Extension/Multiplayer/Net/pose_batch.h b/Extension/Multiplayer/Net/pose_batch.h new file mode 100644 index 00000000..53e45e56 --- /dev/null +++ b/Extension/Multiplayer/Net/pose_batch.h @@ -0,0 +1,458 @@ +#pragma once +#include "pose_codec.h" +#include +#include +#include +#include +#include +#include +#include +#include + +// Poses from a dedicated server to a player: several players' poses in one message, each as its +// differences (pose_codec.h) from a pose of that player the receiver has said it holds. +// +// A message is a header and entries. A whole pose opens a stream: it names the player and gives +// the stream a number, and every later entry is that number, which earlier pose it builds on, and +// the differences. The receiver answers with which messages it could read in full (an ack: the +// newest and a bit for each of the 64 before it), and the server builds only on poses in messages +// that were acked, or on the whole pose it sent reliably. Messages are kept to one packet, so a +// lost packet loses a handful of poses, each of which the next one makes good. +namespace dingosdk::multiplayer::pose_batch { +inline constexpr std::array batch_magic{'R', 'M', 'Q', '1'}, ack_magic{'R', 'M', 'A', '1'}; +inline constexpr std::size_t header_size = 16, ack_size = 16; +// Entries are added to a message while it stays under this, which with Steam's own framing fits a packet. +inline constexpr std::size_t message_room = 1100; +// How many poses a receiver keeps of each stream to build on, and how many a server may send +// past the newest one acked before it sends the stream whole again. +inline constexpr std::size_t receiver_keeps = 32, sender_runs = 24; + +inline bool is_batch(std::span bytes) noexcept { + return bytes.size() >= header_size && std::equal(batch_magic.begin(), batch_magic.end(), bytes.begin()); +} +inline bool is_ack(std::span bytes) noexcept { + return bytes.size() == ack_size && std::equal(ack_magic.begin(), ack_magic.end(), bytes.begin()); +} + +namespace detail { +inline void put(std::vector &out, std::uint64_t value, unsigned bytes) { + for (unsigned i = 0; i < bytes; ++i) out.push_back(static_cast(value >> (8 * i))); +} +inline void varint(std::vector &out, std::uint64_t value) { + while (value >= 0x80) { + out.push_back(static_cast(value | 0x80)); + value >>= 7; + } + out.push_back(static_cast(value)); +} +struct Reader { + std::span bytes; + std::size_t at{}; + std::uint64_t get(unsigned count) { + if (at > bytes.size() || count > bytes.size() - at) throw std::runtime_error("Truncated pose batch"); + std::uint64_t value{}; + for (unsigned i = 0; i < count; ++i) value |= std::uint64_t{bytes[at++]} << (8 * i); + return value; + } + std::uint64_t varint() { + std::uint64_t value{}; + for (unsigned shift = 0; shift < 64; shift += 7) { + const auto byte = get(1); + value |= (byte & 0x7f) << shift; + if (!(byte & 0x80)) return value; + } + throw std::runtime_error("Invalid pose batch number"); + } + std::span take(std::size_t count) { + if (at > bytes.size() || count > bytes.size() - at) throw std::runtime_error("Truncated pose batch"); + const auto part = bytes.subspan(at, count); + at += count; + return part; + } +}; +constexpr std::uint64_t zigzag(std::int64_t value) noexcept { + return (static_cast(value) << 1) ^ static_cast(value >> 63); +} +constexpr std::int64_t unzigzag(std::uint64_t value) noexcept { return static_cast(value >> 1) ^ -static_cast(value & 1); } +} // namespace detail + +// How often the pose's player is being sent, which the receiver plays it back by: at the +// session's own rate, or every 100 or 200 ms. +enum class Rate : std::uint8_t { full, half, low }; + +struct Entry { + std::uint16_t stream{}; + bool whole{}, collision{}; + Rate rate{Rate::full}; + std::uint64_t source{}, epoch{}; // whole only: who the stream is + std::uint32_t sequence{}; // whole only: the pose's own number + std::uint16_t reference{}; // difference only: the low bits of the number of the pose it builds on + std::uint32_t ahead{}; // difference only: how far past that pose's number its own is (1 or more) + std::uint64_t time_us{}; // whole: the pose's time. Difference: left 0, see time_step + std::int64_t time_step{}; // difference only: its time less its reference's + std::span pose; // pose_codec bytes +}; + +// The first 16 bytes: which message this is, and the world and map it belongs to (their low bits: +// enough to tell a message from before a map change). +inline std::vector begin(std::uint32_t number, std::uint64_t world, std::uint64_t map) { + std::vector out(batch_magic.begin(), batch_magic.end()); + out.reserve(message_room + 64); + detail::put(out, number, 4); + detail::put(out, world & 0xffffffff, 4); + detail::put(out, map & 0xffffffff, 4); + return out; +} +inline void add(std::vector &out, const Entry &entry) { + detail::varint(out, entry.stream); + out.push_back(static_cast((entry.whole ? 1U : 0U) | (entry.collision ? 2U : 0U) | (static_cast(entry.rate) << 2))); + if (entry.whole) { + detail::put(out, entry.source, 8); + detail::put(out, entry.epoch, 8); + detail::put(out, entry.sequence, 4); + detail::put(out, entry.time_us, 8); + } else { + detail::put(out, entry.reference, 2); + detail::varint(out, entry.ahead); + detail::varint(out, detail::zigzag(entry.time_step)); + } + detail::varint(out, entry.pose.size()); + out.insert(out.end(), entry.pose.begin(), entry.pose.end()); +} +// The room an entry takes beyond its pose bytes, at most. +inline constexpr std::size_t entry_room = 3 + 1 + 28 + 3; + +struct Batch { + std::uint32_t number{}, world{}, map{}; + std::vector entries; // their pose bytes point into the message read +}; +// A message's header and entries, or nothing when it is not one or is cut short. +inline std::optional read(std::span bytes) noexcept { + try { + if (!is_batch(bytes)) return {}; + detail::Reader r{bytes, 4}; + Batch batch; + batch.number = static_cast(r.get(4)); + batch.world = static_cast(r.get(4)); + batch.map = static_cast(r.get(4)); + while (r.at < bytes.size()) { + Entry entry; + const auto stream = r.varint(); + if (stream > 0xffff) return {}; + entry.stream = static_cast(stream); + const auto flags = r.get(1); + if (flags & ~0xfULL || ((flags >> 2) & 3) > 2) return {}; + entry.whole = flags & 1; + entry.collision = flags & 2; + entry.rate = static_cast((flags >> 2) & 3); + if (entry.whole) { + entry.source = r.get(8); + entry.epoch = r.get(8); + entry.sequence = static_cast(r.get(4)); + entry.time_us = r.get(8); + } else { + entry.reference = static_cast(r.get(2)); + const auto ahead = r.varint(); + if (!ahead || ahead > 0xffffff) return {}; + entry.ahead = static_cast(ahead); + entry.time_step = detail::unzigzag(r.varint()); + } + const auto length = r.varint(); + if (length > bytes.size()) return {}; + entry.pose = r.take(static_cast(length)); + batch.entries.push_back(entry); + if (batch.entries.size() > 512) return {}; + } + return batch; + } catch (...) { + return {}; + } +} + +// Which messages a receiver read in full: the newest, and a bit for each of the 64 before it +// (bit 0 the one just before). +struct Ack { + std::uint32_t newest{}; + std::uint64_t earlier{}; + bool any{}; + void note(std::uint32_t number) noexcept { + if (!any) { + any = true; + newest = number; + earlier = 0; + } else if (number > newest) { + const auto step = number - newest; + earlier = step >= 64 ? 0 : earlier << step; + if (step <= 64) earlier |= std::uint64_t{1} << (step - 1); // the one that was newest + newest = number; + } else if (number < newest && newest - number <= 64) { + earlier |= std::uint64_t{1} << (newest - number - 1); + } + } + bool holds(std::uint32_t number) const noexcept { + return any && (number == newest || (number < newest && newest - number <= 64 && (earlier >> (newest - number - 1)) & 1)); + } + std::array bytes() const noexcept { + std::array out{}; + std::copy(ack_magic.begin(), ack_magic.end(), out.begin()); + for (unsigned i = 0; i < 4; ++i) out[4 + i] = static_cast(newest >> (8 * i)); + for (unsigned i = 0; i < 8; ++i) out[8 + i] = static_cast(earlier >> (8 * i)); + return out; + } + static std::optional read(std::span bytes) noexcept { + if (!is_ack(bytes)) return {}; + Ack ack; + ack.any = true; + for (unsigned i = 0; i < 4; ++i) ack.newest |= std::uint32_t{bytes[4 + i]} << (8 * i); + for (unsigned i = 0; i < 8; ++i) ack.earlier |= std::uint64_t{bytes[8 + i]} << (8 * i); + return ack; + } +}; + +// A receiver's side of one stream: who it is, and the last poses of theirs it rebuilt. +struct Stream { + std::uint64_t source{}, epoch{}; + struct Held { + std::uint32_t sequence{}; + std::uint64_t time_us{}; + pose_codec::QuantPose pose; + }; + std::deque held; + const Held *find_low(std::uint16_t low) const noexcept { + for (auto it = held.rbegin(); it != held.rend(); ++it) + if (static_cast(it->sequence) == low) return &*it; + return nullptr; + } + bool holds(std::uint32_t sequence) const noexcept { + for (const auto &pose : held) + if (pose.sequence == sequence) return true; + return false; + } + void keep(Held pose) { + held.push_back(std::move(pose)); + while (held.size() > receiver_keeps) held.pop_front(); + } +}; +// What one entry rebuilds to on the receiver, or nothing when it cannot be (a stream it was never +// told of, a reference it does not hold, bytes that are not a pose). `streams` is updated: a whole +// pose opens or reopens its stream, and every rebuilt pose is kept to build on. +struct Rebuilt { + std::uint64_t source{}, epoch{}, time_us{}; + std::uint32_t sequence{}; + bool collision{}; + Rate rate{Rate::full}; + pose_codec::QuantPose pose; + bool repeat{}; // this stream's pose of that number was already held: nothing new +}; +template std::optional rebuild(Streams &streams, const Entry &entry) { + Rebuilt out; + out.collision = entry.collision; + out.rate = entry.rate; + if (entry.whole) { + if (!pose_codec::whole(entry.pose)) return {}; + auto pose = pose_codec::decode(entry.pose, nullptr); + if (!pose) return {}; + auto &stream = streams[entry.stream]; + if (stream.source != entry.source || stream.epoch != entry.epoch) stream = {entry.source, entry.epoch, {}}; + out.source = entry.source; + out.epoch = entry.epoch; + out.sequence = entry.sequence; + out.time_us = entry.time_us; + out.repeat = stream.holds(entry.sequence); + out.pose = std::move(*pose); + if (!out.repeat) stream.keep({out.sequence, out.time_us, out.pose}); + return out; + } + const auto found = streams.find(entry.stream); + if (found == streams.end() || pose_codec::whole(entry.pose)) return {}; + auto &stream = found->second; + const auto *reference = stream.find_low(entry.reference); + if (!reference) return {}; + auto pose = pose_codec::decode(entry.pose, &reference->pose); + if (!pose) return {}; + out.source = stream.source; + out.epoch = stream.epoch; + out.sequence = reference->sequence + entry.ahead; + out.time_us = static_cast(static_cast(reference->time_us) + entry.time_step); + out.repeat = stream.holds(out.sequence); + out.pose = std::move(*pose); + if (!out.repeat) stream.keep({out.sequence, out.time_us, out.pose}); + return out; +} + +// A server's side of what one player is sent: for each other player a stream, the newest pose of +// it the receiver acked, and the messages sent and not yet acked. Poses are handed to add() as +// they fall due; it packs them into messages and gives each to `emit(bytes, reliable)`, which +// answers whether it went out. `find(sequence)` gives a kept pose of the player being added, or +// nothing when it is no longer kept. +struct KeptView { + std::uint32_t sequence{}; + std::uint64_t time_us{}; + const pose_codec::QuantPose *pose{}; +}; +class Sender { + public: + // What add() did with a pose, for counting. + enum class Did : std::uint8_t { nothing, difference, whole }; + struct Added { + Did did{Did::nothing}; + std::size_t bytes{}; + }; + void begin(std::uint64_t world, std::uint64_t map) { + world_ = world; + map_ = map; + message_.clear(); + batch_ = {}; + } + template + Added add(std::uint64_t source, std::uint64_t epoch, const KeptView &pose, Find &&find, bool hold_fingers, bool collision, Rate rate, + std::uint64_t now, Emit &&emit) { + using pose_codec::Fingers; + auto &out = streams_[source]; + if (!out.stream || out.epoch != epoch) { + out = {}; + out.epoch = epoch; + out.stream = next_stream_++; + if (!next_stream_) next_stream_ = 1; + } + // The pose to build on: the newest they acked, else the whole one sent reliably (it will + // arrive; a difference that overtakes it is only dropped). Neither when too many have + // gone since for the receiver to still hold it, or it is no longer kept here. + std::optional reference; + if (out.acked && out.sends - out.acked_send <= sender_runs) reference = find(out.acked_sequence); + if (!reference && out.whole_sent && out.sends - out.whole_send <= sender_runs) reference = find(out.whole_sequence); + if (reference && pose.sequence <= reference->sequence) return {}; // nothing newer than they hold + Entry entry; + entry.stream = out.stream; + entry.collision = collision; + entry.rate = rate; + std::vector bytes; + bool fingers{}; + if (reference) { + // Fingers: not sent to a player too far to see them; and after that, sent whole until + // an ack shows the receiver has them again, since its copy of the reference has old + // ones in it. + const auto mode = hold_fingers ? Fingers::held : out.fingers_stale ? Fingers::whole : Fingers::sent; + bytes = pose_codec::encode_delta(*pose.pose, *reference->pose, mode); + fingers = mode == Fingers::whole; + if (bytes.empty()) reference.reset(); // another skeleton (a changed rig): whole + } + if (!reference) { + // The whole pose, in a message of its own, reliably. Not again while the last is still + // on its way: at most one a second for a stream. + if (out.whole_sent && now - out.whole_at < 1000000) return {}; + flush(emit); + open(); + bytes = pose_codec::encode_whole(*pose.pose); + entry.whole = true; + entry.source = source; + entry.epoch = epoch; + entry.sequence = pose.sequence; + entry.time_us = pose.time_us; + entry.pose = bytes; + pose_batch::add(message_, entry); + out.acked = false; + out.whole_sent = true; + out.whole_sequence = pose.sequence; + out.whole_send = ++out.sends; + out.whole_at = now; + out.fingers_stale = false; + batch_.poses.push_back({source, epoch, pose.sequence, out.sends, true}); + send(emit, true); + return {Did::whole, bytes.size()}; + } + entry.reference = static_cast(reference->sequence); + entry.ahead = pose.sequence - reference->sequence; + entry.time_step = static_cast(pose.time_us) - static_cast(reference->time_us); + entry.pose = bytes; + if (message_.empty()) open(); + if (!batch_.poses.empty() && message_.size() + bytes.size() + entry_room > message_room) { + send(emit, false); + open(); + } + pose_batch::add(message_, entry); + if (hold_fingers) { + out.fingers_stale = true; + out.last_held_batch = batch_.number; + } + batch_.poses.push_back({source, epoch, pose.sequence, ++out.sends, fingers}); + return {Did::difference, bytes.size()}; + } + // Sends what add() has packed so far. + template void flush(Emit &&emit) { send(emit, false); } + // The receiver said which messages it read in full: the poses in those are ones it holds. + void ack(const Ack &ack) { + for (auto it = sent_.begin(); it != sent_.end();) { + if (!ack.holds(it->number)) { + ++it; + continue; + } + for (const auto &pose : it->poses) { + const auto found = streams_.find(pose.source); + if (found == streams_.end() || found->second.epoch != pose.epoch) continue; + auto &out = found->second; + if (!out.acked || pose.sequence > out.acked_sequence) { + out.acked = true; + out.acked_sequence = pose.sequence; + out.acked_send = pose.send; + } + // (Not when a pose without fingers went out after this one.) + if (pose.fingers && it->number > out.last_held_batch) out.fingers_stale = false; + } + it = sent_.erase(it); + } + while (!sent_.empty() && sent_.front().number + 128 < ack.newest) sent_.pop_front(); + } + // A new world: every stream starts over. The messages' numbers go on, so the receiver's + // acks need no starting over with them. + void restart() { + streams_.clear(); + sent_.clear(); + message_.clear(); + batch_ = {}; + } + // The player left: their stream is nobody's. + void forget(std::uint64_t source) { streams_.erase(source); } + std::size_t unacked() const noexcept { return sent_.size(); } + + private: + struct Out { + std::uint64_t epoch{}; + std::uint16_t stream{}; + bool acked{}, whole_sent{}, fingers_stale{}; + std::uint32_t acked_sequence{}, whole_sequence{}, sends{}, acked_send{}, whole_send{}, last_held_batch{}; + std::uint64_t whole_at{}; + }; + struct SentPose { + std::uint64_t source{}, epoch{}; + std::uint32_t sequence{}, send{}; + bool fingers{}; // it carried the fingers whole + }; + struct SentBatch { + std::uint32_t number{}; + std::vector poses; + }; + void open() { + batch_ = {next_batch_++, {}}; + message_ = pose_batch::begin(batch_.number, world_, map_); + } + // Unreliable messages are let go when the connection is behind (the next poses replace + // them); nothing is then recorded as sent, so nothing is built on them either. + template void send(Emit &&emit, bool reliable) { + if (batch_.poses.empty()) return; + if (emit(std::span(message_), reliable)) { + sent_.push_back(std::move(batch_)); + while (sent_.size() > 256) sent_.pop_front(); + } + batch_ = {}; + message_.clear(); + } + std::map streams_; + std::deque sent_; + std::vector message_; + SentBatch batch_; + std::uint64_t world_{}, map_{}; + std::uint16_t next_stream_ = 1; + std::uint32_t next_batch_ = 1; +}; +} // namespace dingosdk::multiplayer::pose_batch diff --git a/Extension/Multiplayer/Net/pose_codec.h b/Extension/Multiplayer/Net/pose_codec.h new file mode 100644 index 00000000..c9a9385d --- /dev/null +++ b/Extension/Multiplayer/Net/pose_codec.h @@ -0,0 +1,361 @@ +#pragma once +#include "protocol.h" +#include +#include +#include +#include +#include +#include +#include +#include + +// A pose as the differences from an earlier one, each in as few bits as it needs. +// +// Measured on a minute of recorded play (tools/research, pose-dump): of a skater's 413 bones 87 +// ever turn and about 70 turn in any one pose, by a degree or so; 40 of the 87 are fingers. The +// older encoding sends each changed field whole (6 bytes a rotation) and leaves the rest to a +// general compressor: 661 bytes a pose. Here a pose is first rounded to whole steps (QuantPose), +// and what is sent is, for each bone that changed, the small integer it changed by: about 310 +// bytes against a pose 67 ms older, 215 without the fingers. +// +// Both ends work on the rounded integers, never on the floats, so a chain of differences cannot +// drift: applying a difference to the reference gives exactly the sender's rounded pose. +namespace dingosdk::multiplayer::pose_codec { +// Rotations are rounded to 2^rotation_shift of the 16-bit form a compact pose packs them in: +// 0.079 degrees. Positions to a millimetre. +inline constexpr unsigned rotation_shift = 5; +inline constexpr int rotation_bits = 16 - rotation_shift + 1; // a whole component, signed +inline constexpr float rotation_scale = 46339.5358f; // as Writer::compact_transform +inline constexpr std::int32_t position_limit = 1000000000; // mm: valid_transform's million metres + +struct QuantBone { + std::int16_t rotation[3]{}; // the three smaller components, in steps + std::uint8_t largest{3}; // which component is left out (and is positive) + bool scaled{}; + std::int32_t position[3]{}; // mm + float scale[3]{1, 1, 1}; // sent whole, and only when it is not 1: mods aside it always is + bool operator==(const QuantBone &) const = default; +}; +// The root, the skater's bones, then the board's. +struct QuantPose { + std::uint16_t skater{}, board{}; + std::vector bones; + bool operator==(const QuantPose &) const = default; +}; +// The skater's finger bones, as indices into QuantPose::bones (the root is 0): two runs of +// twenty. Only in the skeleton this was measured on; any other bone count has none. +inline constexpr std::size_t finger_skeleton = 395; +inline constexpr bool finger_bone(const QuantPose &pose, std::size_t bone) noexcept { + return pose.skater == finger_skeleton && ((bone >= 52 && bone < 72) || (bone >= 281 && bone < 301)); +} + +inline QuantBone quantize(const Transform &t) noexcept { + QuantBone bone; + unsigned largest{}; + float norm{}; + for (unsigned i = 0; i < 4; ++i) { + norm += t.rotation[i] * t.rotation[i]; + if (std::abs(t.rotation[i]) > std::abs(t.rotation[largest])) largest = i; + } + const float factor = norm > 0.f && std::isfinite(norm) ? (t.rotation[largest] < 0 ? -1.f : 1.f) * rotation_scale / std::sqrt(norm) : 0.f; + const float step = static_cast(1U << rotation_shift); + const auto most = static_cast(32767 >> rotation_shift); + bone.largest = static_cast(largest); + unsigned out{}; + for (unsigned i = 0; i < 4; ++i) + if (i != largest) bone.rotation[out++] = static_cast(std::clamp(std::lround(t.rotation[i] * factor / step), -most, most)); + for (unsigned i = 0; i < 3; ++i) { + const auto mm = std::isfinite(t.position[i]) ? std::llround(static_cast(t.position[i]) * 1000.0) : 0LL; + bone.position[i] = static_cast(std::clamp(mm, -position_limit, position_limit)); + } + bone.scaled = t.scale != std::array{1, 1, 1}; + if (bone.scaled) std::copy(t.scale.begin(), t.scale.end(), bone.scale); + return bone; +} +inline Transform restore(const QuantBone &bone) noexcept { + Transform t; + const float step = static_cast(1U << rotation_shift) / rotation_scale; + float sum{}; + unsigned in{}; + for (unsigned i = 0; i < 4; ++i) { + if (i == bone.largest) continue; + t.rotation[i] = static_cast(bone.rotation[in++]) * step; + sum += t.rotation[i] * t.rotation[i]; + } + t.rotation[bone.largest & 3] = std::sqrt(std::max(0.f, 1.f - sum)); + for (unsigned i = 0; i < 3; ++i) t.position[i] = static_cast(static_cast(bone.position[i]) / 1000.0); + if (bone.scaled) std::copy(bone.scale, bone.scale + 3, t.scale.begin()); + return t; +} +inline QuantPose quantize(const Pose &pose) { + QuantPose out; + out.skater = static_cast(pose.skater.size()); + out.board = static_cast(pose.board.size()); + out.bones.reserve(1 + pose.skater.size() + pose.board.size()); + out.bones.push_back(quantize(pose.root)); + for (const auto &t : pose.skater) out.bones.push_back(quantize(t)); + for (const auto &t : pose.board) out.bones.push_back(quantize(t)); + return out; +} +inline Pose restore(const QuantPose &pose) { + Pose out; + if (pose.bones.size() != std::size_t{1} + pose.skater + pose.board) return out; + out.root = restore(pose.bones[0]); + out.skater.reserve(pose.skater); + out.board.reserve(pose.board); + for (std::size_t i = 0; i < pose.skater; ++i) out.skater.push_back(restore(pose.bones[1 + i])); + for (std::size_t i = 0; i < pose.board; ++i) out.board.push_back(restore(pose.bones[1 + pose.skater + i])); + return out; +} + +namespace detail { +struct BitWriter { + std::vector bytes; + std::uint64_t hold{}; + unsigned held{}; + void put(std::uint64_t value, unsigned bits) { // the low `bits` of value, at most 32 + hold |= (value & ((std::uint64_t{1} << bits) - 1)) << held; + held += bits; + while (held >= 8) { + bytes.push_back(static_cast(hold)); + hold >>= 8; + held -= 8; + } + } + // A count of 1 or more, shorter the smaller it is (Elias gamma): bones that changed come in + // runs, and the step from one to the next is nearly always 1, which is a single bit. + void gap(std::uint32_t value) { + const auto width = static_cast(std::bit_width(value)); + put(0, width - 1); + put(1, 1); + put(value, width - 1); + } + std::vector finish() { + if (held) bytes.push_back(static_cast(hold)); + hold = held = 0; + return std::move(bytes); + } +}; +struct BitReader { + std::span bytes; + std::size_t at{}; + std::uint64_t hold{}; + unsigned held{}; + std::uint64_t get(unsigned bits) { + while (held < bits) { + if (at >= bytes.size()) throw std::runtime_error("Truncated pose"); + hold |= std::uint64_t{bytes[at++]} << held; + held += 8; + } + const auto value = hold & ((std::uint64_t{1} << bits) - 1); + hold >>= bits; + held -= bits; + return value; + } + std::uint32_t gap() { + unsigned width{1}; + while (!get(1)) + if (++width > 32) throw std::runtime_error("Invalid pose gap"); + return static_cast((std::uint64_t{1} << (width - 1)) | get(width - 1)); + } +}; +constexpr std::uint32_t zigzag(std::int64_t value) noexcept { + return static_cast(value >= 0 ? static_cast(value) * 2 : static_cast(-value) * 2 - 1); +} +constexpr std::int64_t unzigzag(std::uint64_t value) noexcept { + return (value & 1) ? -static_cast((value + 1) / 2) : static_cast(value / 2); +} +// A signed number as its length (a fixed few bits) and then that many bits. +inline void small(BitWriter &w, std::int64_t value, unsigned length_bits) { + const auto coded = zigzag(value); + const auto width = static_cast(std::bit_width(coded)); + w.put(width, length_bits); + if (width > 32 - 1) { + w.put(coded & 0xffff, 16); + w.put(coded >> 16, width - 16); + } else { + w.put(coded, width); + } +} +inline std::int64_t small(BitReader &r, unsigned length_bits) { + const auto width = static_cast(r.get(length_bits)); + if (width > 32) throw std::runtime_error("Invalid pose number"); + if (width > 31) { + const auto low = r.get(16); + return unzigzag(low | (r.get(width - 16) << 16)); + } + return unzigzag(r.get(width)); +} +inline void whole_rotation(BitWriter &w, const QuantBone &bone) { + w.put(bone.largest, 2); + for (const auto component : bone.rotation) w.put(zigzag(component), rotation_bits); +} +inline void whole_rotation(BitReader &r, QuantBone &bone) { + bone.largest = static_cast(r.get(2)); + for (auto &component : bone.rotation) component = static_cast(unzigzag(r.get(rotation_bits))); +} +inline void scale(BitWriter &w, const QuantBone &bone) { + w.put(bone.scaled, 1); + if (bone.scaled) + for (const float axis : bone.scale) { + const auto bits = std::bit_cast(axis); + w.put(bits & 0xffff, 16); + w.put(bits >> 16, 16); + } +} +inline void scale(BitReader &r, QuantBone &bone) { + bone.scaled = r.get(1) != 0; + for (float &axis : bone.scale) { + if (!bone.scaled) { + axis = 1; + continue; + } + const auto low = static_cast(r.get(16)); + axis = std::bit_cast(low | (static_cast(r.get(16)) << 16)); + if (!std::isfinite(axis) || std::abs(axis) > 1000.f) throw std::runtime_error("Invalid pose scale"); + } +} +inline bool same_rotation(const QuantBone &a, const QuantBone &b) noexcept { + return a.largest == b.largest && std::equal(a.rotation, a.rotation + 3, b.rotation); +} +inline bool same_position(const QuantBone &a, const QuantBone &b) noexcept { return std::equal(a.position, a.position + 3, b.position); } +inline bool same_scale(const QuantBone &a, const QuantBone &b) noexcept { + return a.scaled == b.scaled && (!a.scaled || std::equal(a.scale, a.scale + 3, b.scale)); +} +} // namespace detail + +// How a pose's fingers are sent (finger_bone). `held`: not at all, the receiver keeps the ones it +// has (a player too far away to make out a hand). `whole`: every finger bone in full, whatever +// the reference holds: the first poses after `held`, until the receiver is known to have them, +// since its copy of the reference has old fingers in it. +enum class Fingers : std::uint8_t { sent, held, whole }; + +// The whole pose, needing no reference: the first of a stream, and whenever the receiver holds +// nothing recent enough to build on. +inline std::vector encode_whole(const QuantPose &pose) { + detail::BitWriter w; + w.put(1, 1); + w.put(rotation_shift, 3); + w.put(pose.skater, 10); + w.put(pose.board, 7); + for (const auto &bone : pose.bones) { + detail::whole_rotation(w, bone); + for (const auto mm : bone.position) detail::small(w, mm, 6); + detail::scale(w, bone); + } + return w.finish(); +} +// The pose as its differences from `reference`, which the receiver must hold exactly. Empty when +// the two are not the same skeleton: send it whole. +inline std::vector encode_delta(const QuantPose &pose, const QuantPose &reference, Fingers fingers = Fingers::sent) { + if (pose.skater != reference.skater || pose.board != reference.board || pose.bones.size() != reference.bones.size()) return {}; + detail::BitWriter w; + w.put(0, 1); + w.put(rotation_shift, 3); + w.put(static_cast(fingers), 2); + const auto count = pose.bones.size(); + const auto listed = [&](std::size_t bone) { return fingers == Fingers::sent || !finger_bone(pose, bone); }; + // Three lists of the bones that changed, each as how many and then the steps between them. + const auto list = [&](auto &&differs, auto &&write) { + std::uint32_t changed{}; + for (std::size_t bone = 0; bone < count; ++bone) changed += listed(bone) && differs(pose.bones[bone], reference.bones[bone]); + w.put(changed, 10); + std::size_t last{}; + for (std::size_t bone = 0; bone < count; ++bone) { + if (!listed(bone) || !differs(pose.bones[bone], reference.bones[bone])) continue; + w.gap(static_cast(bone + 1 - last)); + last = bone + 1; + write(pose.bones[bone], reference.bones[bone]); + } + }; + list([](const QuantBone &a, const QuantBone &b) { return !detail::same_rotation(a, b); }, [&](const QuantBone &now, const QuantBone &was) { + // A different component left out: the three sent are other ones, so they go whole. + const bool flipped = now.largest != was.largest; + w.put(flipped, 1); + if (flipped) return detail::whole_rotation(w, now); + for (unsigned i = 0; i < 3; ++i) detail::small(w, std::int64_t{now.rotation[i]} - was.rotation[i], 4); + }); + list([](const QuantBone &a, const QuantBone &b) { return !detail::same_position(a, b); }, [&](const QuantBone &now, const QuantBone &was) { + for (unsigned i = 0; i < 3; ++i) detail::small(w, std::int64_t{now.position[i]} - was.position[i], 6); + }); + list([](const QuantBone &a, const QuantBone &b) { return !detail::same_scale(a, b); }, + [&](const QuantBone &now, const QuantBone &) { detail::scale(w, now); }); + if (fingers == Fingers::whole) + for (std::size_t bone = 0; bone < count; ++bone) { + if (!finger_bone(pose, bone)) continue; + detail::whole_rotation(w, pose.bones[bone]); + for (const auto mm : pose.bones[bone].position) detail::small(w, mm, 6); + detail::scale(w, pose.bones[bone]); + } + return w.finish(); +} +inline bool whole(std::span bytes) noexcept { return !bytes.empty() && (bytes[0] & 1); } +// The pose `bytes` holds. `reference`: the pose its differences are from, as this end holds it +// (ignored by a whole pose). Nothing when the bytes are not a pose, or need a reference of +// another skeleton. +inline std::optional decode(std::span bytes, const QuantPose *reference) noexcept { + try { + detail::BitReader r{bytes}; + const bool is_whole = r.get(1) != 0; + if (r.get(3) != rotation_shift) return {}; + const auto valid = [](QuantBone &bone) { + if (bone.largest > 3) throw std::runtime_error("Invalid pose rotation"); + const long most = 32767 >> rotation_shift; + for (const auto component : bone.rotation) + if (component > most || component < -most) throw std::runtime_error("Invalid pose rotation"); + for (const auto mm : bone.position) + if (mm > position_limit || mm < -position_limit) throw std::runtime_error("Invalid pose position"); + }; + QuantPose pose; + if (is_whole) { + pose.skater = static_cast(r.get(10)); + pose.board = static_cast(r.get(7)); + if (pose.skater > max_skater_bones || pose.board > max_board_bones) return {}; + pose.bones.resize(std::size_t{1} + pose.skater + pose.board); + for (auto &bone : pose.bones) { + detail::whole_rotation(r, bone); + for (auto &mm : bone.position) mm = static_cast(std::clamp(detail::small(r, 6), INT32_MIN, INT32_MAX)); + detail::scale(r, bone); + valid(bone); + } + return pose; + } + if (!reference || reference->bones.size() != std::size_t{1} + reference->skater + reference->board) return {}; + pose = *reference; + const auto fingers = static_cast(r.get(2)); + if (fingers > Fingers::whole) return {}; + const auto count = pose.bones.size(); + const auto list = [&](auto &&read) { + const auto changed = r.get(10); + std::size_t last{}; + for (std::uint64_t i = 0; i < changed; ++i) { + const auto bone = last + r.gap() - 1; + if (bone >= count) throw std::runtime_error("Invalid pose bone"); + last = bone + 1; + read(pose.bones[bone]); + valid(pose.bones[bone]); + } + }; + list([&](QuantBone &bone) { + if (r.get(1)) return detail::whole_rotation(r, bone); + for (auto &component : bone.rotation) + component = static_cast(std::clamp(component + detail::small(r, 4), INT16_MIN, INT16_MAX)); + }); + list([&](QuantBone &bone) { + for (auto &mm : bone.position) mm = static_cast(std::clamp(mm + detail::small(r, 6), INT32_MIN, INT32_MAX)); + }); + list([&](QuantBone &bone) { detail::scale(r, bone); }); + if (fingers == Fingers::whole) + for (std::size_t bone = 0; bone < count; ++bone) { + if (!finger_bone(pose, bone)) continue; + detail::whole_rotation(r, pose.bones[bone]); + for (auto &mm : pose.bones[bone].position) mm = static_cast(std::clamp(detail::small(r, 6), INT32_MIN, INT32_MAX)); + detail::scale(r, pose.bones[bone]); + valid(pose.bones[bone]); + } + return pose; + } catch (...) { + return {}; + } +} +} // namespace dingosdk::multiplayer::pose_codec diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index b847e744..c634e1fc 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -25,7 +25,7 @@ namespace dingosdk::multiplayer { constexpr std::size_t max_skater_bones = 512, max_board_bones = 64; constexpr std::size_t max_packet = 24576; constexpr std::size_t packet_header_size = 64; -constexpr std::uint16_t protocol_version = 43; +constexpr std::uint16_t protocol_version = 44; constexpr std::size_t max_throwdown_message = 4096; // Packet::tuning: the host's SkatePhysicsTuning differences (Extension/Skater/physics_tuning.h). constexpr std::size_t max_physics_tuning = 16384; diff --git a/Extension/Multiplayer/Net/sound_codec.h b/Extension/Multiplayer/Net/sound_codec.h new file mode 100644 index 00000000..68c69efb --- /dev/null +++ b/Extension/Multiplayer/Net/sound_codec.h @@ -0,0 +1,273 @@ +#pragma once +#include "Extension/Multiplayer/Remote/audio_state.h" +#include "pose_batch.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Skaters' sound between a dedicated server and its players: several players' samples in one +// message, each sample as what changed since the one before it. +// +// A sample is 58 numbers, 26 selectors and 43 switches, of which about twenty numbers change from +// one to the next and the rest seldom. Numbers are rounded to 1/1024 and sent as how far they +// moved; a switch or selector is sent only when one changed. Messages go reliably and in order +// (many samples are a pulse one sample long, which must not be lost), so each builds on the last +// one sent: the sender keeps what the receiver holds of each stream. A stream is sent whole when +// it starts and again every couple of seconds, which also puts right a receiver that missed its +// start. +namespace dingosdk::multiplayer::sound_codec { +inline constexpr std::array magic{'R', 'M', 'S', '1'}; +inline constexpr std::size_t header_size = 12; +inline constexpr std::int64_t value_scale = 1024, value_limit = 1000000LL * value_scale; +// Ages are sent in steps of 256 us. +inline constexpr unsigned age_shift = 8; +inline constexpr std::uint32_t age_limit = 1000000U >> age_shift; +inline constexpr std::uint64_t whole_every_us = 2000000; + +inline bool is_sound(std::span bytes) noexcept { + return bytes.size() >= header_size && std::equal(magic.begin(), magic.end(), bytes.begin()); +} + +struct QuantState { + std::array values{}; + std::array selectors{}; + std::uint64_t flags{}; // a bit each + bool operator==(const QuantState &) const = default; +}; +// A number that is not nothing never rounds to nothing: the game may ask only whether it is. +inline QuantState quantize(const AudioState &state) noexcept { + QuantState out; + for (std::size_t i = 0; i < audio_float_count; ++i) { + const double value = std::isfinite(state.values[i]) ? static_cast(state.values[i]) : 0.0; + auto rounded = std::llround(std::clamp(value * static_cast(value_scale), -static_cast(value_limit), static_cast(value_limit))); + if (!rounded && value != 0.0) rounded = value > 0 ? 1 : -1; + out.values[i] = static_cast(rounded); + } + out.selectors = state.selectors; + // (Never the first switch: a remote skater's sound is not the local player's mix.) + for (std::size_t i = 1; i < audio_flag_count; ++i) + if (state.flags[i]) out.flags |= std::uint64_t{1} << i; + return out; +} +inline AudioState restore(const QuantState &state) noexcept { + AudioState out; + for (std::size_t i = 0; i < audio_float_count; ++i) + out.values[i] = static_cast(static_cast(state.values[i]) / static_cast(value_scale)); + out.selectors = state.selectors; + for (std::size_t i = 0; i < audio_flag_count; ++i) out.flags[i] = (state.flags >> i) & 1; + return out; +} + +namespace detail { +using pose_batch::detail::put; +using pose_batch::detail::Reader; +using pose_batch::detail::unzigzag; +using pose_batch::detail::varint; +using pose_batch::detail::zigzag; +// Eight bytes say which numbers changed (a bit each) and, above those, whether the sample is an +// event, whether the switches follow and whether selectors do. +inline constexpr unsigned event_bit = 58, flags_bit = 59, selectors_bit = 60; +inline void put_sample(std::vector &out, const QuantState &now, const QuantState &was, std::uint32_t age_us, bool event) { + std::uint64_t changed{}; + std::uint32_t selectors{}; + for (std::size_t i = 0; i < audio_float_count; ++i) + if (now.values[i] != was.values[i]) changed |= std::uint64_t{1} << i; + for (std::size_t i = 0; i < audio_selector_count; ++i) + if (now.selectors[i] != was.selectors[i]) selectors |= 1U << i; + const bool flags = now.flags != was.flags; + put(out, changed | (std::uint64_t{event} << event_bit) | (std::uint64_t{flags} << flags_bit) | (std::uint64_t{selectors != 0} << selectors_bit), 8); + varint(out, std::min(age_us >> age_shift, age_limit)); + for (std::size_t i = 0; i < audio_float_count; ++i) + if (changed & (std::uint64_t{1} << i)) varint(out, zigzag(std::int64_t{now.values[i]} - std::int64_t{was.values[i]})); + if (flags) put(out, now.flags, 6); + if (selectors) { + put(out, selectors, 4); + for (std::size_t i = 0; i < audio_selector_count; ++i) + if (selectors & (1U << i)) put(out, now.selectors[i], 4); + } +} +// Throws on bytes that are not a sample or that make an impossible one. +inline QuantState get_sample(Reader &r, const QuantState &was, std::uint32_t &age_us, bool &event) { + const auto head = r.get(8); + if (head >> (selectors_bit + 1)) throw std::runtime_error("Invalid sound sample"); + const auto age = r.varint(); + if (age > age_limit) throw std::runtime_error("Invalid sound sample age"); + age_us = static_cast(age) << age_shift; + event = (head >> event_bit) & 1; + QuantState now = was; + for (std::size_t i = 0; i < audio_float_count; ++i) { + if (!(head & (std::uint64_t{1} << i))) continue; + const auto step = unzigzag(r.varint()); + if (step > 2 * value_limit || step < -2 * value_limit) throw std::runtime_error("Invalid sound value"); + const auto value = std::int64_t{was.values[i]} + step; + if (value > value_limit || value < -value_limit) throw std::runtime_error("Invalid sound value"); + now.values[i] = static_cast(value); + } + if ((head >> flags_bit) & 1) { + now.flags = r.get(6); + if (now.flags >> audio_flag_count || (now.flags & 1)) throw std::runtime_error("Invalid sound switches"); + } + if ((head >> selectors_bit) & 1) { + const auto selectors = r.get(4); + if (!selectors || selectors >> audio_selector_count) throw std::runtime_error("Invalid sound selectors"); + for (std::size_t i = 0; i < audio_selector_count; ++i) + if (selectors & (std::uint64_t{1} << i)) now.selectors[i] = static_cast(r.get(4)); + } + return now; +} +} // namespace detail + +// The sending side of one connection: what its receiver holds of each player's sound. begin(), +// then add() for each player with samples, then the message goes reliably and sent() is told +// whether it went: what the receiver holds moves on only when it did. +class Sender { + public: + void begin(std::uint64_t world, std::uint64_t map) { + message_.assign(magic.begin(), magic.end()); + detail::put(message_, world & 0xffffffff, 4); + detail::put(message_, map & 0xffffffff, 4); + staged_.clear(); + } + void add(std::uint64_t source, std::uint64_t epoch, std::uint32_t sequence, std::uint64_t time_us, std::span samples, std::uint64_t now) { + if (samples.empty() || samples.size() > max_audio_samples) return; + Out out; + if (const auto staged = staged_.find(source); staged != staged_.end()) out = staged->second; + else if (const auto found = streams_.find(source); found != streams_.end()) out = found->second; + if (!out.stream || out.epoch != epoch) { + out = {}; + out.epoch = epoch; + out.stream = next_stream_++; + if (!next_stream_) next_stream_ = 1; + } + const bool whole = !out.started || sequence <= out.sequence || now - out.whole_at >= whole_every_us; + detail::varint(message_, out.stream); + message_.push_back(static_cast((whole ? 1U : 0U) | (static_cast(samples.size()) << 1))); + if (whole) { + detail::put(message_, source, 8); + detail::put(message_, epoch, 8); + detail::put(message_, sequence, 4); + detail::put(message_, time_us, 8); + out.whole_at = now; + } else { + detail::varint(message_, sequence - out.sequence); + detail::varint(message_, detail::zigzag(static_cast(time_us) - static_cast(out.time_us))); + } + QuantState was = whole ? QuantState{} : out.state; + for (const auto &sample : samples) { + const auto state = quantize(sample.state); + detail::put_sample(message_, state, was, sample.age_us, sample.event); + was = state; + } + out.state = was; + out.sequence = sequence; + out.time_us = time_us; + out.started = true; + staged_[source] = out; + } + bool pending() const noexcept { return !staged_.empty(); } + std::span message() const noexcept { return message_; } + void sent(bool went) { + if (went) + for (const auto &[source, out] : staged_) streams_[source] = out; + staged_.clear(); + message_.clear(); + } + // The player left: their stream is nobody's. + void forget(std::uint64_t source) { streams_.erase(source); } + // A new world: every stream starts over. + void restart() { + streams_.clear(); + staged_.clear(); + message_.clear(); + } + + private: + struct Out { + std::uint64_t epoch{}, time_us{}, whole_at{}; + std::uint32_t sequence{}; + std::uint16_t stream{}; + bool started{}; + QuantState state; + }; + std::map streams_, staged_; + std::vector message_; + std::uint16_t next_stream_ = 1; +}; + +// The receiving side of one stream: whose it is and the last sample of it. +struct In { + std::uint64_t source{}, epoch{}, time_us{}; + std::uint32_t sequence{}; + QuantState state; +}; +// One player's samples from a message, as the packet of sound it would have been. +struct Heard { + std::uint64_t source{}, epoch{}, time_us{}; + std::uint32_t sequence{}; + std::vector samples; +}; +// What a message holds, or nothing when it is not one or is damaged. A message of another world +// holds nothing; samples of a stream this side was never told of are passed over. `streams` moves +// on with what is read. +template +std::optional> read(Streams &streams, std::span bytes, std::uint64_t world, std::uint64_t map) noexcept { + try { + if (!is_sound(bytes)) return {}; + detail::Reader r{bytes, 4}; + std::vector out; + if (r.get(4) != (world & 0xffffffff) || r.get(4) != (map & 0xffffffff)) return out; + while (r.at < bytes.size()) { + const auto stream = r.varint(); + if (stream > 0xffff) return {}; + const auto head = r.get(1); + const bool whole = head & 1; + const auto count = static_cast(head >> 1); + if (!count || count > max_audio_samples) return {}; + In in; + bool known = whole; + if (whole) { + in.source = r.get(8); + in.epoch = r.get(8); + in.sequence = static_cast(r.get(4)); + in.time_us = r.get(8); + } else { + const auto ahead = r.varint(); + const auto time_step = detail::unzigzag(r.varint()); + if (!ahead || ahead > 0xffffff) return {}; + if (const auto found = streams.find(static_cast(stream)); found != streams.end()) { + in = found->second; + known = true; + } + in.sequence += static_cast(ahead); + in.time_us = static_cast(static_cast(in.time_us) + time_step); + } + Heard heard{in.source, in.epoch, in.time_us, in.sequence, {}}; + heard.samples.reserve(count); + QuantState was = whole ? QuantState{} : in.state; + std::uint32_t last_age = 1000000; + for (std::size_t i = 0; i < count; ++i) { + std::uint32_t age{}; + bool event{}; + was = detail::get_sample(r, was, age, event); + if (age > last_age) return {}; + last_age = age; + heard.samples.push_back({age, restore(was), event}); + } + if (!known) continue; + in.state = was; + streams[static_cast(stream)] = in; + out.push_back(std::move(heard)); + if (out.size() > 512) return {}; + } + return out; + } catch (...) { + return {}; + } +} +} // namespace dingosdk::multiplayer::sound_codec diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index cc2859b1..7e9978ef 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -155,6 +155,13 @@ void stop(Session &s, std::string reason) { 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.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); } @@ -162,6 +169,14 @@ void stop(Session &s, std::string reason) { // 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; @@ -264,6 +279,7 @@ void publish_party(Session &s) { if (const auto saved = profile_runtime::local_value("NametagDistance"); saved && saved->is_number()) s.nametag_distance = std::clamp(saved->get(), 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(), player_distance_least, player_distance_unlimited); s.nametags_friends = profile_runtime::local_preference("NametagsFriendsOnly").value_or(false); @@ -889,6 +905,8 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready, std::string_vi 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()); diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index 54763465..3ea39db5 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -414,6 +414,32 @@ std::string edit_player_distance(Session &s, std::string_view argument) { return value >= player_distance_unlimited ? std::string("Every player is shown as a skater, however far.") : "Players within " + std::to_string(static_cast(value)) + " m are shown as skaters."; } +std::string start_pose_dump(Session &s, std::string_view argument) { + int seconds{}; + const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), seconds); + if (parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || seconds < 1 || seconds > 600) + return "pose-dump (1-600): records your own poses while in a session."; + std::error_code error; + const auto folder = std::filesystem::current_path(error) / "logs"; + std::filesystem::create_directories(folder, error); + const auto file = folder / ("poses-" + std::to_string(now_us() / 1000000) + ".bin"); + s.pose_dump.close(); + s.pose_dump.clear(); + s.pose_dump.open(file, std::ios::binary | std::ios::trunc); + if (!s.pose_dump) return "Could not write " + file.string() + "."; + s.pose_dump.write("RSPD1\n", 6); + s.pose_dump_until = now_us() + static_cast(seconds) * 1000000; + s.pose_dump_count = 0; + return "Recording your poses for " + std::to_string(seconds) + " s to " + file.string() + ". Skate as you normally would."; +} +std::string edit_direct_connections(Session &s, std::string_view argument) { + const auto enabled = parse_switch(argument, s.prefer_direct); + if (!enabled) return "Use on, off, or toggle for direct connections."; + s.prefer_direct = *enabled; + profile_runtime::set_local_preference("DirectConnections", s.prefer_direct); + return s.prefer_direct ? "Servers that offer it are connected to directly (from the next join)." + : "Every server is reached through Steam's relays (from the next join)."; +} std::string edit_nametag_distance(Session &s, std::string_view argument) { float value{}; const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), value); @@ -590,7 +616,7 @@ bool queue_command(std::string_view action, std::string_view argument, std::stri if ((action != "host" && action != "host-config" && action != "join" && action != "join-lobby" && action != "join-friend-lobby" && action != "stop" && action != "distances" && action != "object-placement" && action != "object-limit" && action != "kick" && action != "clear-objects" && action != "nametags" && action != "chat-visible" && action != "chat-filter" && - action != "nametag-distance" && action != "nametag-dots" && action != "nametags-friends" && action != "player-distance" && + action != "nametag-distance" && action != "nametag-dots" && action != "nametags-friends" && action != "player-distance" && action != "direct-connections" && action != "pose-dump" && action != "chat-bubbles" && action != "chat-bubbles-own" && action != "chat-bubbles-distance" && action != "chat-bubbles-duration" && action != "chat-bubbles-history" && !own_mark_command(action) && @@ -773,7 +799,7 @@ std::string command(std::string_view action, std::string_view argument, std::str {"world-layer-sync", edit_world_layer_sync}, {"distances", edit_distances}, {"nametags", edit_nametags}, {"nametag-distance", edit_nametag_distance}, {"nametag-dots", edit_nametag_dots}, - {"nametags-friends", edit_nametags_friends}, {"player-distance", edit_player_distance}, + {"nametags-friends", edit_nametags_friends}, {"player-distance", edit_player_distance}, {"direct-connections", edit_direct_connections}, {"pose-dump", start_pose_dump}, {"mark-tag", edit_own_tag}, {"mark-items", edit_own_items}, {"mark-style", edit_mark_style}, {"chat-visible", edit_chat_visible}, {"chat-filter", edit_chat_filter}, {"chat-bubbles", edit_chat_bubbles}, {"chat-bubbles-own", edit_chat_bubbles_own}, @@ -980,7 +1006,22 @@ std::string command(std::string_view action, std::string_view argument, std::str save_host_preferences(s); s.status = "Hosting for up to " + std::to_string(capacity) + " players at " + std::to_string(s.tps) + " TPS."; } else { - if (!s.transport.join(invitation->steam_id)) { + // A server that listens on its own address is connected to directly, with Steam's + // relays as the fallback (steam_transport.h). The server sees the address of a + // player who connects this way, as any dedicated server does, so the player may + // turn it off (direct-connections). Preferences load with the first session frame, + // so read this one here. + const bool direct_allowed = s.display_preferences_loaded + ? s.prefer_direct + : profile_runtime::local_preference("DirectConnections").value_or(true); + std::uint32_t direct_ip{}; + std::uint16_t direct_port{}; + if (const auto *listed = s.servers.find(invitation->steam_id); + listed && direct_allowed && listed->direct_ip && listed->direct_port > 0) { + direct_ip = listed->direct_ip; + direct_port = static_cast(listed->direct_port); + } + if (!s.transport.join(invitation->steam_id, direct_ip, direct_port)) { s.status = s.transport.status().detail; publish(s); return s.status; diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 8ce5b2b6..2f92778e 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -13,14 +13,18 @@ #include "room.h" #include "party_book.h" #include "Extension/Multiplayer/Net/delta_codec.h" +#include "Extension/Multiplayer/Net/pose_batch.h" +#include "Extension/Multiplayer/Net/sound_codec.h" #include "password.h" #include "client_timing.h" #include "monotonic_clock.h" #include +#include #include #include #include #include +#include #include #include #include @@ -234,6 +238,24 @@ struct Session { float nametag_distance = 120.f; // names within this many metres, dots past it // Only players within this many metres have a skater (session.cpp); checked a few times a second. float player_distance = 120.f; + bool prefer_direct = true; // straight to servers that offer it, not through the relays + // Poses from a dedicated server (pose_batch.h): each player's stream as this game holds it, + // and which of the server's messages were read in full, which it is told once a tick. + std::unordered_map pose_streams; + pose_batch::Ack pose_ack; + bool pose_ack_due{}; + // This game's own poses go to a dedicated server the same way: what was sent and what the + // server acked, and the last poses sent, to build the next on. + pose_batch::Sender pose_upload; + std::deque own_poses; + // Skaters' sound to and from a dedicated server (sound_codec.h). + std::unordered_map sound_streams; + sound_codec::Sender sound_upload; + // pose-dump: this player's own poses, as encoded for sending, written to a file for a + // while. Real poses to measure encodings against (tools/research). + std::ofstream pose_dump; + std::uint64_t pose_dump_until{}; + std::size_t pose_dump_count{}; std::uint64_t next_shown_rank{}; bool nametag_dots = true, nametags_friends{}; bool chat_filter = true; // bad words in chat show as **** (Engine/Core/Text/word_filter.h) diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index 3353b013..1e45f8f6 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -130,6 +130,73 @@ void proven_chat(Session &s, Peer &sender, const Packet &p, std::uint64_t now) { // of one stream in a batch can be dropped undecoded. Keep the newest ones playback may // still interpolate between (its delay plus a frame each side) among those whose baseline // is already here; baselines and every other kind are always read. +// The poses in one message from a dedicated server (pose_batch.h), each as the packet it would +// have been had it come alone. A message read in full is noted for the ack; one with a pose +// this game could not rebuild (it never got what that pose builds on) is not, so the server +// goes on building on what this game does hold. +std::vector unpack_poses(Session &s, std::span bytes) { + std::vector out; + const auto batch = pose_batch::read(bytes); + if (!batch || batch->world != static_cast(s.world) || batch->map != static_cast(s.map)) return out; + // Streams of players who left are dropped once there are many more than there are players. + if (s.pose_streams.size() > 2 * max_players) + std::erase_if(s.pose_streams, [&](const auto &stream) { return !find_peer(s, stream.second.source); }); + bool complete = true; + out.reserve(batch->entries.size()); + for (const auto &entry : batch->entries) { + auto rebuilt = pose_batch::rebuild(s.pose_streams, entry); + if (!rebuilt) { + complete = false; + continue; + } + if (rebuilt->repeat) continue; + Packet p; + p.kind = PacketKind::pose; + p.session = s.secret; + p.map = s.map; + p.world = s.world; + p.source = rebuilt->source; + p.epoch = rebuilt->epoch; + p.sequence = rebuilt->sequence; + p.time_us = rebuilt->time_us; + p.player_collision = rebuilt->collision; + p.pose_interval_us = rebuilt->rate == pose_batch::Rate::full ? multiplayer_pose_interval(s.tps) + : rebuilt->rate == pose_batch::Rate::half ? 100000U + : 200000U; + p.pose = pose_codec::restore(rebuilt->pose); + out.push_back(std::move(p)); + } + if (complete) { + s.pose_ack.note(batch->number); + s.pose_ack_due = true; + } + return out; +} +// The skaters' sound in one message from a dedicated server (sound_codec.h), each player's as +// the packet it would have been had it come alone. +std::vector unpack_sound(Session &s, std::span bytes) { + std::vector out; + if (s.sound_streams.size() > 2 * max_players) + std::erase_if(s.sound_streams, [&](const auto &stream) { return !find_peer(s, stream.second.source); }); + auto heard = sound_codec::read(s.sound_streams, bytes, s.world, s.map); + if (!heard) return out; + out.reserve(heard->size()); + for (auto &one : *heard) { + if (!valid_audio_batch(one.samples)) continue; + Packet p; + p.kind = PacketKind::audio; + p.session = s.secret; + p.map = s.map; + p.world = s.world; + p.source = one.source; + p.epoch = one.epoch; + p.sequence = one.sequence; + p.time_us = one.time_us; + p.audio = std::move(one.samples); + out.push_back(std::move(p)); + } + return out; +} std::vector stale_pose_deltas(Session &s, const std::vector &messages) { std::vector stale(messages.size()); struct Stream { @@ -412,12 +479,23 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { } const auto messages = s.transport.receive(); const auto stale = stale_pose_deltas(s, messages); - for (std::size_t index = 0; index < messages.size(); ++index) { + // A message of several poses from a dedicated server is taken apart, and the loop goes + // round once for each pose in it as if it had come alone. + std::vector batch; + std::size_t batch_at{}; + for (std::size_t index = 0; index < messages.size(); batch_at < batch.size() ? index : ++index) { const auto &message = messages[index]; auto *link = find_peer(s, message.peer); - if (!link) + if (!link) { + batch.clear(); + batch_at = 0; continue; + } const bool direct_link = s.mode == Mode::join && message.peer != s.host_id; + const bool resumed = batch_at < batch.size(); + if (!resumed) { + batch.clear(); + batch_at = 0; if (!link->budget.accept(message.arrived ? message.arrived : now, message.bytes.size(), s.mode == Mode::join && !direct_link ? max_remote_players : 1U)) { disconnect(s, message.peer, "Peer exceeded the multiplayer packet limit."); @@ -427,8 +505,25 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { } if (stale[index]) continue; + // The server saying which of this game's own pose messages it read. + if (const auto ack = pose_batch::Ack::read(message.bytes)) { + if (s.mode == Mode::join && !direct_link && dedicated_host(s)) s.pose_upload.ack(*ack); + continue; + } + if (sound_codec::is_sound(message.bytes)) { + if (s.mode == Mode::join && !direct_link && dedicated_host(s)) batch = unpack_sound(s, message.bytes); + if (batch.empty()) continue; + } + if (pose_batch::is_batch(message.bytes)) { + // Only a dedicated server sends these, and only to its players. + if (s.mode == Mode::join && !direct_link && dedicated_host(s)) batch = unpack_poses(s, message.bytes); + if (batch.empty()) continue; + } + } bool missing_reference{}; - auto decoded = link->receiver.receive(message.bytes, missing_reference, s.world); + std::optional decoded; + if (batch_at < batch.size()) decoded = std::move(batch[batch_at++]); + else decoded = link->receiver.receive(message.bytes, missing_reference, s.world); if (missing_reference) continue; if (!decoded || decoded->session != s.secret || @@ -880,6 +975,12 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { [](const auto &sample) { return sample.event; })), p.kind != PacketKind::cosmetics, now, p.source); } + // Tell the server which of its pose messages arrived, once for everything read this tick. + if (s.pose_ack_due && s.mode == Mode::join) { + const auto ack = s.pose_ack.bytes(); + s.transport.send(s.host_id, ack, false, true, TrafficLane::gameplay); + s.pose_ack_due = false; + } receive_cosmetics(s, local, now); for (std::size_t i = 0; i < s.used_slots; ++i) { const auto &p = s.peers[i]; diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index 4e88dc4e..511da74b 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -258,6 +258,25 @@ void send_throwdown(Session &s, std::vector message) { p.throwdown = std::move(message); broadcast(s, p, true, false, now); } +// This game's pose to a dedicated server: its differences from a pose of its own the server has +// said it holds (pose_batch.h), as the server sends everyone else's. +void upload_pose(Session &s, const Packet &packet, std::uint64_t now) { + auto &kept = s.own_poses; + if (!kept.empty() && kept.back().sequence >= packet.sequence) return; + kept.push_back({packet.sequence, packet.time_us, pose_codec::quantize(packet.pose)}); + while (kept.size() > 64) kept.pop_front(); + const auto find = [&](std::uint32_t sequence) -> std::optional { + for (auto it = kept.rbegin(); it != kept.rend(); ++it) + if (it->sequence == sequence) return pose_batch::KeptView{it->sequence, it->time_us, &it->pose}; + return {}; + }; + const auto emit = [&](std::span message, bool reliable) { + return s.transport.send(s.host_id, message, reliable, !reliable, TrafficLane::gameplay); + }; + s.pose_upload.begin(s.world, s.map); + s.pose_upload.add(packet.source, packet.epoch, *find(packet.sequence), find, false, packet.player_collision, pose_batch::Rate::full, now, emit); + s.pose_upload.flush(emit); +} void broadcast(Session &s, const Packet &packet, bool reliable, bool fresh, std::uint64_t now, std::uint64_t except) { const auto *source = find_peer(s, packet.source); @@ -316,6 +335,19 @@ void broadcast(Session &s, const Packet &packet, bool reliable, bool fresh, std: (packet.kind == PacketKind::pose || packet.kind == PacketKind::audio) && !needs_relay(p.direct_routes, p.route_reported, source->member, now)) continue; + if (packet.kind == PacketKind::pose && dedicated_host(s) && p.member.id == s.host_id) { + upload_pose(s, packet, now); + continue; + } + // This game's skater's sound to a dedicated server (sound_codec.h): what changed since + // the last samples it was sent. + if (packet.kind == PacketKind::audio && dedicated_host(s) && p.member.id == s.host_id) { + s.sound_upload.begin(s.world, s.map); + s.sound_upload.add(packet.source, packet.epoch, packet.sequence, packet.time_us, packet.audio, now); + if (s.sound_upload.pending()) + s.sound_upload.sent(s.transport.send(s.host_id, s.sound_upload.message(), true, fresh, traffic_lane(packet.kind))); + continue; + } PoseDelivery *delivery{}; std::uint32_t interval = multiplayer_pose_interval(s.tps); if (packet.kind == PacketKind::pose) { @@ -635,11 +667,34 @@ void send_local(Session &s, const NativeFrame &local, std::uint64_t now, std::ui if (captured_at) p.time_us = captured_at; p.pose = local.pose; ++s.local_pose_count; + if (s.pose_dump.is_open()) { + // A record: when it was captured (8 bytes), its length (4), and the compact encoding. + const auto raw = encode(p, true); + const std::uint64_t time = p.time_us; + const auto length = static_cast(raw.size()); + s.pose_dump.write(reinterpret_cast(&time), sizeof time); + s.pose_dump.write(reinterpret_cast(&length), sizeof length); + s.pose_dump.write(reinterpret_cast(raw.data()), static_cast(raw.size())); + ++s.pose_dump_count; + if (now >= s.pose_dump_until) { + s.pose_dump.close(); + logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: pose dump finished, {} poses.", s.pose_dump_count); + } + } deliver(p, false, true); auto samples = drain_audio_capture(now); if (!samples.empty()) { auto a = packet(s, PacketKind::audio, now); a.audio = std::move(samples); + if (s.pose_dump.is_open()) { + // The skater's sound is recorded with the poses, in the same records. + const auto raw = encode(a, true); + const std::uint64_t time = a.time_us; + const auto length = static_cast(raw.size()); + s.pose_dump.write(reinterpret_cast(&time), sizeof time); + s.pose_dump.write(reinterpret_cast(&length), sizeof length); + s.pose_dump.write(reinterpret_cast(raw.data()), static_cast(raw.size())); + } deliver(a, std::any_of(a.audio.begin(), a.audio.end(), [](const auto &sample) { return sample.event; }), true); } } diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 4e5a1565..9b0d447b 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -229,6 +229,7 @@ void publish(Session &s, const NativeFrame *local) { view.server_map_votes = (s.server_votes & server_vote_map) != 0; } view.player_distance = s.player_distance; + view.prefer_direct = s.prefer_direct; view.nametag_distance = s.nametag_distance; view.nametag_dots = s.nametag_dots; view.nametags_friends = s.nametags_friends; diff --git a/Extension/Multiplayer/Steam/steam_server_browser.cpp b/Extension/Multiplayer/Steam/steam_server_browser.cpp index 50c37a92..43dcfb65 100644 --- a/Extension/Multiplayer/Steam/steam_server_browser.cpp +++ b/Extension/Multiplayer/Steam/steam_server_browser.cpp @@ -127,6 +127,30 @@ std::optional read_server_tags(std::string_view tags, std::uin case 'p': if (!integer(value, row.players)) row.players = 0; break; case 'c': if (!integer(value, row.capacity)) row.capacity = 0; break; case 'w': row.password_required = value == "1"; break; + case 'd': { + // A port, or "a.b.c.d:port" when the server is reached at another address than it is listed under. + const auto colon = value.find(':'); + std::uint32_t address{}; + bool ok = true; + if (colon != std::string_view::npos) { + auto rest = value.substr(0, colon); + unsigned parts{}; + while (ok && parts < 4) { + const auto dot = rest.find('.'); + int part{}; + ok = integer(rest.substr(0, dot), part) && part >= 0 && part <= 255; + address = (address << 8) | static_cast(part & 255); + ++parts; + if (dot == std::string_view::npos) break; + rest = rest.substr(dot + 1); + } + ok = ok && parts == 4 && address; + } + const auto port = colon == std::string_view::npos ? value : value.substr(colon + 1); + if (!ok || !integer(port, row.direct_port) || row.direct_port < 1 || row.direct_port > 65535) row.direct_port = 0; + else row.direct_ip = address; // 0: the address the server is listed under + break; + } case 'm': row.map = std::string(value); break; // The name is last and may itself contain anything but commas. case 'n': row.name = std::string(value) + (tags.empty() ? "" : "," + std::string(tags)); tags = {}; break; @@ -196,6 +220,7 @@ void SteamServerBrowser::read(std::uint64_t now) { if (!item) continue; auto row = read_server_tags(bounded(item->tags, sizeof item->tags), item->steam_id.value); if (!row) continue; + if (row->direct_port && !row->direct_ip) row->direct_ip = item->ip; // the address Steam lists the server at auto &entry = found_[row->id]; entry.search = search_; entry.listed = now; diff --git a/Extension/Multiplayer/Steam/steam_transport.cpp b/Extension/Multiplayer/Steam/steam_transport.cpp index f0acca6e..386ba40f 100644 --- a/Extension/Multiplayer/Steam/steam_transport.cpp +++ b/Extension/Multiplayer/Steam/steam_transport.cpp @@ -79,6 +79,7 @@ struct SteamTransport::Impl { TransportStatus state; SteamLanes lanes; HSteamListenSocket listener{}; + HSteamListenSocket direct_listener{}; // a dedicated server's, on its own address (listen_direct) struct Link { HSteamNetConnection handle{}; ULONGLONG connecting_since{}; @@ -92,6 +93,7 @@ struct SteamTransport::Impl { std::uint64_t status_frame{}; bool status_ok{}; bool recheck{}; // a status callback named this connection: read its state this poll + bool direct{}; // straight to or from an address, not through Steam's relays }; std::map links; // How the connections Steam closed ended (take_closed). A handful at most wait here. @@ -101,6 +103,46 @@ struct SteamTransport::Impl { void *utils{}; bool (*set_config)(void *, ESteamNetworkingConfigValue, ESteamNetworkingConfigScope, intptr_t, ESteamNetworkingConfigDataType, const void *){}; + // Steam's SteamRelayNetworkStatus_t, which the bundled header only names: how its relay + // network looks from here. Room to spare after the message, should Steam's grow. + struct RelayNetwork { + ESteamNetworkingAvailability available{}; + int measuring{}; + ESteamNetworkingAvailability config{}, any_relay{}; + char message[256]{}; + char spare[256]{}; + }; + ESteamNetworkingAvailability (*relay_network)(void *, RelayNetwork *){}; + void (*debug_output)(void *, ESteamNetworkingSocketsDebugOutputType, void (*)(ESteamNetworkingSocketsDebugOutputType, const char *)){}; + // The options of a connection by address: as options(), without the one about relays. + std::array direct_options() { + std::array out{}; + out[4].SetInt32(k_ESteamNetworkingConfig_NagleTime, packing_us ? packing_us : 5000); + out[0] = callback_option(); + out[1].SetInt32(k_ESteamNetworkingConfig_SendRateMax, send_rate); + out[2].SetInt32(k_ESteamNetworkingConfig_SendRateMin, send_rate); + out[3].SetInt32(k_ESteamNetworkingConfig_SendBufferSize, 4 * 1024 * 1024); + return out; + } + // A guest's direct attempt at its host did not come up (no answer, refused, or not the + // server it should be): the same link carries on through Steam's relays. + void fall_back(std::uint64_t id, Link &link, const std::string &why) { + if (link.handle && close) close(sockets, link.handle, 0, "Direct connection not used", false); + SteamNetworkingIdentity identity{}; + identity.SetSteamID64(id); + auto relayed = options(); + const auto connection = connect(sockets, &identity, virtual_port, static_cast(relayed.size()), relayed.data()); + const auto name = std::move(link.name); + link = Link{connection, GetTickCount64(), false, name, connection ? lanes.setup(sockets, connection) : false}; + state.detail = "Direct connection not available (" + why + "): connecting through Steam's relays..."; + direct_note = state.detail; + note_direct("Direct connection to the server did not come up (" + why + "); using Steam's relays."); + } + std::string direct_note; // how the last direct attempt went + std::vector direct_notes; // take_direct_notes + void note_direct(std::string text) { + if (direct_notes.size() < 64) direct_notes.push_back(std::move(text)); + } // Gives one open connection the current send rate. A connection a listen socket // accepts starts with the rate the socket was opened with, so each one is given it. bool apply_rate(HSteamNetConnection handle) { @@ -141,6 +183,9 @@ struct SteamTransport::Impl { std::deque events; static inline std::atomic callback_owner{}; HSteamListenSocket (*listen)(void *, int, int, const SteamNetworkingConfigValue_t *){}; + // By address instead of through the relays; absent from a Steam too old to have them. + HSteamListenSocket (*listen_ip)(void *, const SteamNetworkingIPAddr *, int, const SteamNetworkingConfigValue_t *){}; + HSteamNetConnection (*connect_ip)(void *, const SteamNetworkingIPAddr *, int, const SteamNetworkingConfigValue_t *){}; HSteamNetConnection (*connect)(void *, const SteamNetworkingIdentity *, int, int, const SteamNetworkingConfigValue_t *){}; EResult (*accept)(void *, HSteamNetConnection){}; @@ -179,8 +224,9 @@ struct SteamTransport::Impl { reinterpret_cast(&changed)); return option; } - std::array options() { - std::array out{}; + std::array options() { + std::array out{}; + out[5].SetInt32(k_ESteamNetworkingConfig_NagleTime, packing_us ? packing_us : 5000); // 5000: Steam's own out[0] = callback_option(); // This connection's send rate. Steam asks for the minimum and the maximum to be set // to the same value: with only the maximum raised a connection stays at the 256 KB/s @@ -276,7 +322,12 @@ struct SteamTransport::Impl { } return true; } - static int send_flags(bool reliable, TrafficLane lane) { + int packing_us{}; // set_packing: how long a message may wait for company, 0 for not at all + int send_flags(bool reliable, TrafficLane lane) const { + // Packing: without "no Nagle" Steam fills a packet with what follows within the time + // above, and without "no delay" it queues instead of dropping (admit() has already + // turned away what a slow connection cannot take). + if (packing_us && lane != TrafficLane::voice) return reliable ? k_nSteamNetworkingSend_Reliable : k_nSteamNetworkingSend_Unreliable; return reliable ? (lane == TrafficLane::cosmetics ? k_nSteamNetworkingSend_Reliable : k_nSteamNetworkingSend_ReliableNoNagle) : k_nSteamNetworkingSend_UnreliableNoDelay; @@ -345,6 +396,23 @@ bool SteamTransport::bind(void *library, void *sockets, void *networking_utils) p.lanes = {}; } p.utils = networking_utils; + try { + p.listen_ip = symbol(module, "SteamAPI_ISteamNetworkingSockets_CreateListenSocketIP"); + p.connect_ip = symbol(module, "SteamAPI_ISteamNetworkingSockets_ConnectByIPAddress"); + } catch (...) { + p.listen_ip = nullptr; + p.connect_ip = nullptr; + } + try { + p.debug_output = symbol(module, "SteamAPI_ISteamNetworkingUtils_SetDebugOutputFunction"); + } catch (...) { + p.debug_output = nullptr; + } + try { + p.relay_network = symbol(module, "SteamAPI_ISteamNetworkingUtils_GetRelayNetworkStatus"); + } catch (...) { + p.relay_network = nullptr; + } try { p.set_config = symbol(module, "SteamAPI_ISteamNetworkingUtils_SetConfigValue"); } catch (...) { @@ -466,6 +534,8 @@ void SteamTransport::stop() { auto &p = *impl_; const auto listener = p.listener; p.listener = 0; + if (p.direct_listener && p.close_listener) p.close_listener(p.sockets, p.direct_listener); + p.direct_listener = 0; p.state.hosting = false; p.allowed.clear(); p.host_id = 0; @@ -495,7 +565,7 @@ bool SteamTransport::host(unsigned capacity) { p.state.detail = p.listener ? "Waiting for players to join." : "Steam could not open the P2P listener."; return p.listener != 0; } -bool SteamTransport::join(std::uint64_t id) { +bool SteamTransport::join(std::uint64_t id, std::uint32_t direct_ip, std::uint16_t direct_port) { stop(); if (!open()) return false; @@ -511,10 +581,65 @@ bool SteamTransport::join(std::uint64_t id) { // Guests listen only for identities admitted in the host's reliable roster. // Failure to listen leaves the host-forwarded path usable. p.listener = p.listen(p.sockets, virtual_port, static_cast(options.size()), options.data()); + // A dedicated server's own address first, when it has one (the header). + if (direct_ip && direct_port && p.connect_ip) { + SteamNetworkingIPAddr address{}; + address.Clear(); + address.SetIPv4(direct_ip, direct_port); + auto by_address = p.direct_options(); + if (const auto connection = p.connect_ip(p.sockets, &address, static_cast(by_address.size()), by_address.data())) { + p.closed.erase(id); + auto &link = p.links.emplace(id, Impl::Link{connection, GetTickCount64(), false, name(id), + p.lanes.setup(p.sockets, connection)}).first->second; + link.direct = true; + p.note_direct("Connecting straight to the server at " + std::to_string(direct_ip >> 24) + "." + std::to_string((direct_ip >> 16) & 255) + + "." + std::to_string((direct_ip >> 8) & 255) + "." + std::to_string(direct_ip & 255) + ":" + std::to_string(direct_port) + "."); + p.direct_note = "Connecting straight to the server..."; + p.state.detail = p.direct_note; + p.publish_links(); + return true; + } + } const bool connected = connect_peer(id); p.state.detail = connected ? "Connecting through Steam..." : "Steam rejected the connection request."; return connected; } +namespace { +// Steam calls this from its own threads. +std::mutex steam_debug_mutex; +std::vector steam_debug_lines; +void steam_debug_output(ESteamNetworkingSocketsDebugOutputType, const char *text) { + std::lock_guard lock(steam_debug_mutex); + if (text && steam_debug_lines.size() < 400) steam_debug_lines.emplace_back(text); +} +} // namespace +bool SteamTransport::set_steam_debug(bool on) { + auto &p = *impl_; + if (!p.debug_output || !p.utils) return false; + p.debug_output(p.utils, on ? k_ESteamNetworkingSocketsDebugOutputType_Verbose : k_ESteamNetworkingSocketsDebugOutputType_None, + on ? &steam_debug_output : nullptr); + return true; +} +std::vector SteamTransport::take_direct_notes() { + auto notes = std::exchange(impl_->direct_notes, {}); + std::lock_guard lock(steam_debug_mutex); + for (auto &line : steam_debug_lines) notes.push_back("Steam: " + std::move(line)); + steam_debug_lines.clear(); + return notes; +} +void SteamTransport::set_packing(unsigned milliseconds) { impl_->packing_us = static_cast(std::min(milliseconds, 50U) * 1000); } +bool SteamTransport::listen_direct(std::uint16_t port) { + auto &p = *impl_; + if (!p.state.hosting || !port || !p.listen_ip) return false; + if (p.direct_listener) return true; + SteamNetworkingIPAddr address{}; + address.Clear(); // every address of this machine + address.m_port = port; + auto options = p.direct_options(); + p.listen_rate = p.send_rate; + p.direct_listener = p.listen_ip(p.sockets, &address, static_cast(options.size()), options.data()); + return p.direct_listener != 0; +} bool SteamTransport::connect_peer(std::uint64_t id) { auto &p = *impl_; if (!p.state.ready || p.state.hosting || !p.allowed.contains(id) || id == p.state.local_id) @@ -579,7 +704,10 @@ void SteamTransport::poll() { const auto existing = p.links.find(id); if (existing != p.links.end() && existing->second.handle == event.m_hConn) continue; - if (event.m_info.m_hListenSocket != p.listener || !p.listener || !id || id == p.state.local_id || + const bool direct = p.direct_listener && event.m_info.m_hListenSocket == p.direct_listener; + if (direct) p.note_direct("Direct connection asked for by " + std::to_string(id) + "."); + // (A connection by address names a Steam ID only when Steam has vouched for it.) + if ((event.m_info.m_hListenSocket != p.listener && !direct) || !p.listener || !id || id == p.state.local_id || (!p.state.hosting && !p.allowed.contains(id)) || p.links.size() >= p.capacity - 1 || existing != p.links.end() || event.m_info.m_identityRemote.m_eType != k_ESteamNetworkingIdentityType_SteamID) { @@ -592,7 +720,7 @@ void SteamTransport::poll() { } p.closed.erase(id); p.links.emplace(id, Impl::Link{event.m_hConn, now, false, name(id), - p.lanes.setup(p.sockets, event.m_hConn)}); + p.lanes.setup(p.sockets, event.m_hConn)}).first->second.direct = direct; if (p.send_rate != p.listen_rate) p.apply_rate(event.m_hConn); } // Connected links report changes through the status callback (above), so read their @@ -606,12 +734,40 @@ void SteamTransport::poll() { if (link.connected && !link.recheck && !sweep) continue; link.recheck = false; + // A guest's direct attempt gets a few seconds; the relays take that long themselves. + const bool trying_direct = link.direct && !link.connected && !p.state.hosting; + if (trying_direct && now - link.connecting_since > 5000) { + p.fall_back(id, link, "no answer"); + continue; + } SteamNetConnectionInfo_t info{}; if (!p.get_info(p.sockets, link.handle, &info)) { + if (trying_direct) { + p.fall_back(id, link, "it went away"); + continue; + } disconnect(id, "Steam connection disappeared."); continue; } + if (trying_direct && info.m_eState == k_ESteamNetworkingConnectionState_Connected && + (info.m_identityRemote.m_eType != k_ESteamNetworkingIdentityType_SteamID || info.m_identityRemote.GetSteamID64() != id)) { + // Something else answers at that address: not the server that was asked for. + p.fall_back(id, link, "another server answered there"); + continue; + } + // (A server that answers and closes the connection itself, 1000 to 1999, has given its + // answer: full, banned, wrong password. That is not a route to try another way.) + if (trying_direct && !(info.m_eState == k_ESteamNetworkingConnectionState_ClosedByPeer && info.m_eEndReason >= 1000 && info.m_eEndReason < 2000) && + (info.m_eState == k_ESteamNetworkingConnectionState_ClosedByPeer || + info.m_eState == k_ESteamNetworkingConnectionState_ProblemDetectedLocally)) { + p.fall_back(id, link, "Steam reason " + std::to_string(info.m_eEndReason)); + continue; + } if (info.m_eState == k_ESteamNetworkingConnectionState_Connected) { + if (trying_direct) { + p.direct_note = "Connected straight to the server."; + p.note_direct(p.direct_note); + } if (!link.connected && !link.prioritized) link.prioritized = p.lanes.setup(p.sockets, link.handle); link.connected = true; p.state.detail = "Steam connected."; @@ -653,6 +809,19 @@ bool SteamTransport::set_send_rate(int rate) { if (link.handle) all &= p.apply_rate(link.handle); return all; } +std::string SteamTransport::relay_status() const { + const auto &p = *impl_; + if (!p.relay_network || !p.utils) return "unknown (this Steam does not say)"; + Impl::RelayNetwork status; + const auto available = p.relay_network(p.utils, &status); + status.message[sizeof(status.message) - 1] = 0; + std::string text = available == k_ESteamNetworkingAvailability_Current ? "OK" + : static_cast(available) < 0 ? "FAILED" + : "not ready"; + if (status.measuring) text += ", measuring pings to the relays"; + if (status.message[0] && std::string_view(status.message) != "OK") text += std::string(": ") + status.message; + return text; +} std::int64_t SteamTransport::pending(std::uint64_t id) const { const auto found = impl_->links.find(id); if (found == impl_->links.end() || !found->second.status_ok) return 0; @@ -685,6 +854,7 @@ std::vector SteamTransport::links() { row.out_bps = s.m_flOutBytesPerSec; row.in_bps = s.m_flInBytesPerSec; row.queue_us = queue_time(s); + row.direct = link.direct; SteamNetConnectionInfo_t info{}; if (p.get_info && p.get_info(p.sockets, link.handle, &info)) { // A location's ID is its name's characters packed into a number. @@ -740,7 +910,7 @@ bool SteamTransport::send(std::uint64_t id, std::span bytes, const auto found = p.links.find(id); if (found == p.links.end() || !p.admit(found->second, bytes, fresh, lane)) return false; - const int flags = Impl::send_flags(reliable, lane); + const int flags = p.send_flags(reliable, lane); const auto result = found->second.prioritized ? p.lanes.transmit(p.sockets, found->second.handle, bytes, flags, lane) : p.send_message(p.sockets, found->second.handle, bytes.data(), static_cast(bytes.size()), flags, nullptr); @@ -758,7 +928,7 @@ void SteamTransport::send_batch(std::span messages) { const auto found = p.links.find(m.id); if (found == p.links.end() || !p.admit(found->second, m.bytes, m.fresh, m.lane)) continue; - const int flags = Impl::send_flags(m.reliable, m.lane); + const int flags = p.send_flags(m.reliable, m.lane); const auto &link = found->second; if (!link.prioritized || m.bytes.empty()) { // Without lanes (older Steam adapters) each message goes out as send() sends it. diff --git a/Extension/Multiplayer/Steam/steam_transport.h b/Extension/Multiplayer/Steam/steam_transport.h index c4a2aec5..8ef4d068 100644 --- a/Extension/Multiplayer/Steam/steam_transport.h +++ b/Extension/Multiplayer/Steam/steam_transport.h @@ -25,6 +25,7 @@ struct TransportLink { // ("ord", "fra"...): the one this side uses and the one the other side uses. A ping far // above the direct one is the route, and these say which way it went. std::string relay, remote_relay; + bool direct{}; // not through the relays at all }; struct TransportMessage { std::uint64_t peer{}; @@ -70,7 +71,26 @@ class SteamTransport { // steam_api64.dll module handle it was started with. bool open_game_server(void *steam_api); bool host(unsigned capacity); - bool join(std::uint64_t steam_id); + // `direct_ip` and `direct_port` (host byte order; 0 for none): the address a dedicated server + // listens on for direct connections. It is tried first, and the connection goes through + // Steam's relays as usual if it has not come up in a few seconds. Either way Steam vouches + // for who is at the other end, and only a server with this Steam ID is taken. + bool join(std::uint64_t steam_id, std::uint32_t direct_ip = 0, std::uint16_t direct_port = 0); + // A host that also takes connections straight to this UDP port, without Steam's relays. + // For a dedicated server with a public address. False when Steam could not open it. + bool listen_direct(std::uint16_t port); + // Lets Steam hold a message back for up to this many milliseconds to send it in the same + // packet as the next ones (0: every message is its own packet, at once, as a game sends + // them). A dedicated server sends each player hundreds of small messages a second, and + // every packet carries the same headers again. Voice is never held. Set before host(). + void set_packing(unsigned milliseconds); + // What happened to direct connections since this was last asked, a line each, for a log: + // a guest's attempt at its host (where to, and how it went), or a host's incoming ones. + std::vector take_direct_notes(); + // Has Steam's networking say what it is doing (connections asked for, refused, packets it + // would not take and why), as lines for take_direct_notes. For finding out why a + // connection does not come up; a lot of text on a busy server. False when Steam cannot. + bool set_steam_debug(bool on); bool connect_peer(std::uint64_t steam_id); void allow_peers(std::span); bool socket_test(); @@ -91,6 +111,10 @@ class SteamTransport { std::string link_report(std::uint64_t id); // Bytes waiting to go out to a player, as last measured (a few times a second). std::int64_t pending(std::uint64_t id) const; + // What Steam says of its relay network from here, in its own words ("OK", or what it is + // waiting for or failed at). Every connection goes through the relays: while this is not + // OK, players time out joining ("negotiate rendezvous") or come by a long way round. + std::string relay_status() const; // What each connection may send a second from now on, the open ones included. False when // out of range, or when an open connection could not be changed (new ones still get it). bool set_send_rate(int bytes_per_second); diff --git a/Extension/Multiplayer/Test/pose_codec_tests.cpp b/Extension/Multiplayer/Test/pose_codec_tests.cpp new file mode 100644 index 00000000..fa342796 --- /dev/null +++ b/Extension/Multiplayer/Test/pose_codec_tests.cpp @@ -0,0 +1,564 @@ +#include "Extension/Multiplayer/Net/pose_batch.h" +#include "Extension/Multiplayer/Net/pose_codec.h" +#include "Extension/Multiplayer/Net/sound_codec.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace dingosdk::multiplayer; +using namespace dingosdk::multiplayer::pose_codec; + +namespace { +int failures = 0; +void check(bool ok, const char *what) { + if (ok) return; + ++failures; + std::cerr << "FAIL: " << what << '\n'; +} +// A skater of the measured skeleton in some pose; `turn` moves it on a little or a lot. +Pose skeleton(std::mt19937 &random) { + std::uniform_real_distribution any(-1.f, 1.f); + Pose pose; + pose.skater.resize(finger_skeleton); + pose.board.resize(17); + const auto set = [&](Transform &t, float reach) { + t.position = {any(random) * reach, any(random) * reach, any(random) * reach}; + t.rotation = {any(random), any(random), any(random), any(random)}; + float norm{}; + for (float v : t.rotation) norm += v * v; + for (float &v : t.rotation) v /= std::sqrt(norm); + }; + set(pose.root, 900.f); + for (auto &t : pose.skater) set(t, 0.4f); + for (auto &t : pose.board) set(t, 0.4f); + pose.board[0].position = {512.25f, 31.5f, -640.125f}; + return pose; +} +void turn(Pose &pose, std::mt19937 &random, float amount, unsigned bones) { + std::uniform_real_distribution any(-amount, amount); + std::uniform_int_distribution which(0, pose.skater.size() - 1); + for (unsigned i = 0; i < bones; ++i) { + auto &t = pose.skater[which(random)]; + for (float &v : t.rotation) v += any(random); + float norm{}; + for (float v : t.rotation) norm += v * v; + for (float &v : t.rotation) v /= std::sqrt(norm); + } + pose.root.position[0] += any(random); + pose.board[0].position[2] += any(random); +} + +void exact_checks() { + std::mt19937 random(5); + auto pose = skeleton(random); + // The awkward rotations: two components equal and as large as a smaller one can be, an exact + // axis either way round, and all four equal. + pose.skater[0].rotation = {.70710678f, .70710678f, 0, 0}; + pose.skater[1].rotation = {0, 0, 0, -1}; + pose.skater[2].rotation = {.5f, .5f, .5f, .5f}; + pose.skater[3].rotation = {-.5f, .5f, -.5f, .5f}; + pose.skater[4].scale = {4, 4, 4}; + const auto first = quantize(pose); + check(first.bones.size() == 1 + finger_skeleton + 17, "A pose did not keep its bones"); + const auto whole_bytes = encode_whole(first); + const auto back = decode(whole_bytes, nullptr); + check(whole(whole_bytes) && back && *back == first, "A whole pose did not come back as it was sent"); + // Rounded and restored, a rotation is within a step and a position within a millimetre. + const auto restored = restore(first); + const float step = static_cast(1U << rotation_shift) / rotation_scale; + bool near = restored.skater.size() == pose.skater.size(); + for (std::size_t i = 0; near && i < pose.skater.size(); ++i) { + const auto &a = pose.skater[i].rotation, &b = restored.skater[i].rotation; + near = std::abs(a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]) > 1.f - 3.f * step * step - 1e-4f; + for (unsigned axis = 0; axis < 3; ++axis) near = near && std::abs(pose.skater[i].position[axis] - restored.skater[i].position[axis]) < 0.00051f; + } + check(near && std::abs(restored.board[0].position[0] - 512.25f) < 0.001f && restored.skater[4].scale == std::array{4, 4, 4}, + "A restored pose was not within a step of the original"); + // A chain of differences lands exactly on what rounding the pose directly gives: no drift. + auto held_by_receiver = first; + auto moving = pose; + std::size_t bytes{}; + for (unsigned frame = 0; frame < 200; ++frame) { + turn(moving, random, frame % 17 ? 0.01f : 0.6f, 70); + if (frame == 60) moving.skater[9].scale = {1.5f, 1, 1}; + if (frame == 90) moving.skater[9].scale = {1, 1, 1}; + const auto now = quantize(moving); + const auto delta = encode_delta(now, held_by_receiver); + bytes += delta.size(); + const auto got = decode(delta, &held_by_receiver); + if (!got || *got != now || whole(delta)) { + check(false, "A difference did not rebuild the sender's pose exactly"); + break; + } + held_by_receiver = *got; + } + // Nothing changed: a handful of bytes. + check(encode_delta(first, first).size() <= 6, "An unchanged pose was not nearly free"); + // Another skeleton cannot be a difference. + auto other = first; + other.bones.pop_back(); + --other.board; + check(encode_delta(other, first).empty(), "A difference between two skeletons was offered"); + std::cout << "Pose codec: whole poses, exact chains of differences (" << bytes / 200 << " B a pose on the synthetic skater).\n"; +} + +void finger_checks() { + std::mt19937 random(9); + auto pose = skeleton(random); + const auto start = quantize(pose); + // Held: everything but the fingers follows; the receiver's fingers stay as they were. + auto receiver = start; + QuantPose truth = start; + for (unsigned frame = 0; frame < 40; ++frame) { + turn(pose, random, 0.2f, 200); + truth = quantize(pose); + const auto got = decode(encode_delta(truth, receiver, Fingers::held), &receiver); + if (!got) return check(false, "A pose with its fingers held did not decode"); + receiver = *got; + } + bool body{true}, fingers_kept{true}, fingers_moved{}; + for (std::size_t bone = 0; bone < truth.bones.size(); ++bone) { + if (finger_bone(truth, bone)) { + fingers_kept = fingers_kept && receiver.bones[bone] == start.bones[bone]; + fingers_moved = fingers_moved || !(truth.bones[bone] == start.bones[bone]); + } else { + body = body && receiver.bones[bone] == truth.bones[bone]; + } + } + check(body && fingers_kept && fingers_moved, "Held fingers moved, or the rest of the pose did not follow"); + // Whole: the receiver's stale fingers are replaced, whatever its reference holds. + turn(pose, random, 0.2f, 200); + truth = quantize(pose); + const auto synced = decode(encode_delta(truth, receiver, Fingers::whole), &receiver); + check(synced && *synced == truth, "Fingers sent whole did not bring the receiver back to the sender's pose"); + // And held fingers are what makes the pose small. + turn(pose, random, 0.02f, 395); + const auto next = quantize(pose); + check(encode_delta(next, truth, Fingers::held).size() < encode_delta(next, truth).size(), "Holding the fingers did not make a pose smaller"); + QuantPose small; + small.skater = 12; + small.board = 1; + small.bones.resize(14); + check(!finger_bone(small, 60), "Another skeleton was given fingers"); + std::cout << "Pose codec: fingers held, and sent whole again.\n"; +} + +void hostile_checks() { + // Whatever arrives, decoding answers or declines: it never reads past the bytes or builds an + // impossible pose. + std::mt19937 random(21); + auto pose = skeleton(random); + const auto reference = quantize(pose); + turn(pose, random, 0.3f, 120); + const auto good = encode_delta(quantize(pose), reference); + const auto whole_bytes = encode_whole(reference); + unsigned answered{}; + for (const auto &source : {good, whole_bytes}) { + for (std::size_t cut = 0; cut < source.size(); cut += std::max(1, source.size() / 97)) { + const std::vector part(source.begin(), source.begin() + static_cast(cut)); + answered += decode(part, &reference).has_value(); + } + for (unsigned round = 0; round < 400; ++round) { + auto damaged = source; + for (unsigned flips = 0; flips < 1 + round % 5; ++flips) damaged[random() % damaged.size()] ^= static_cast(1U << (random() % 8)); + if (const auto got = decode(damaged, &reference)) { + ++answered; + bool sane = got->bones.size() == std::size_t{1} + got->skater + got->board; + for (const auto &bone : got->bones) { + sane = sane && bone.largest < 4; + for (const auto component : bone.rotation) sane = sane && std::abs(component) <= (32767 >> rotation_shift); + for (const auto mm : bone.position) sane = sane && std::abs(std::int64_t{mm}) <= position_limit; + } + if (!sane) return check(false, "Damaged bytes decoded to an impossible pose"); + } + } + } + std::vector noise(300); + for (unsigned round = 0; round < 2000; ++round) { + for (auto &byte : noise) byte = static_cast(random()); + (void)decode(noise, round % 2 ? &reference : nullptr); + } + check(!decode({}, &reference) && !decode(good, nullptr), "Empty bytes or a difference without its reference decoded"); + std::cout << "Pose codec: cut, damaged and random bytes handled (" << answered << " still decoded, all to possible poses).\n"; +} + +// The server's sender and a game's receiver, joined by a link that delays, loses and reorders: +// the same code a dedicated server and a game run (pose_batch.h). Whatever the link does, every +// pose the receiver rebuilds must be the sender's exactly, no stream may stop for good, and whole +// poses must stay rare. +struct LinkResult { + std::size_t sent{}, rebuilt{}, wrong{}, wholes{}, messages{}, bytes{}, stalled{}; +}; +LinkResult run_link(unsigned seed, double loss, unsigned delay_least, unsigned delay_most, std::size_t kept_poses) { + using namespace pose_batch; + std::mt19937 random(seed); + std::uniform_real_distribution chance(0, 1); + constexpr std::size_t sources = 6; + constexpr unsigned ticks = 800; // 40 s at 20 a second + struct Source { + Pose pose; + std::uint32_t sequence{}; + std::deque kept; + unsigned every{1}; // sent every this many ticks + }; + std::array world; + for (std::size_t i = 0; i < sources; ++i) { + world[i].pose = skeleton(random); + world[i].every = i == 3 ? 2 : i == 4 ? 4 : i == 5 ? 20 : 1; + } + std::map, QuantPose> truth; + Sender sender; + std::unordered_map streams; + Ack ack; + bool ack_due{}; + struct Flying { + unsigned arrives{}; + std::vector bytes; + }; + std::vector to_receiver, to_sender; + const auto delay = [&] { return delay_least + static_cast(random() % (delay_most - delay_least + 1)); }; + LinkResult result; + std::array last_rebuilt{}; + // Fingers held and let go in turns, on every other player. A pose's number is its tick. + const auto held = [](std::size_t source, unsigned tick) { return source % 2 == 1 && (tick / 100) % 4 == 1; }; + for (unsigned tick = 1; tick <= ticks; ++tick) { + const std::uint64_t now = std::uint64_t{tick} * 50000; + for (auto it = to_sender.begin(); it != to_sender.end();) { + if (it->arrives > tick) { + ++it; + continue; + } + if (const auto got = Ack::read(it->bytes)) sender.ack(*got); + it = to_sender.erase(it); + } + // The server's pass: every player moves, and those due go out. + const auto emit = [&](std::span message, bool reliable) { + ++result.messages; + result.bytes += message.size(); + if (reliable || chance(random) >= loss) to_receiver.push_back({tick + delay(), {message.begin(), message.end()}}); + return true; + }; + sender.begin(7, 42); + for (std::size_t i = 0; i < sources; ++i) { + auto &source = world[i]; + turn(source.pose, random, tick % 37 ? 0.02f : 0.5f, 90); + ++source.sequence; + source.kept.push_back({source.sequence, now, quantize(source.pose)}); + while (source.kept.size() > kept_poses) source.kept.pop_front(); + truth[{i, source.sequence}] = source.kept.back().pose; + if (tick % source.every) continue; + const auto find = [&](std::uint32_t sequence) -> std::optional { + for (const auto &pose : source.kept) + if (pose.sequence == sequence) return KeptView{pose.sequence, pose.time_us, &pose.pose}; + return {}; + }; + const auto added = sender.add(1000 + i, 5, *find(source.sequence), find, held(i, tick), false, Rate::full, now, emit); + result.wholes += added.did == Sender::Did::whole; + result.sent += added.did != Sender::Did::nothing; + } + sender.flush(emit); + // What reached the game this tick, in whatever order. + std::shuffle(to_receiver.begin(), to_receiver.end(), random); + for (auto it = to_receiver.begin(); it != to_receiver.end();) { + if (it->arrives > tick) { + ++it; + continue; + } + if (const auto batch = read(it->bytes); batch && batch->world == 7 && batch->map == 42) { + bool complete = true; + for (const auto &entry : batch->entries) { + const auto got = rebuild(streams, entry); + if (!got) { + complete = false; + continue; + } + const auto who = static_cast(got->source - 1000); + const auto &real = truth[{who, got->sequence}]; + bool same = got->pose.bones.size() == real.bones.size() && got->epoch == 5; + // A pose sent with its fingers held has the receiver's old ones; every other is exact. + const bool fingers = !held(who, got->sequence); + for (std::size_t bone = 0; same && bone < real.bones.size(); ++bone) + if (fingers || !finger_bone(real, bone)) same = got->pose.bones[bone] == real.bones[bone]; + result.wrong += !same; + ++result.rebuilt; + last_rebuilt[who] = tick; + } + if (complete) { + ack.note(batch->number); + ack_due = true; + } + } + it = to_receiver.erase(it); + } + if (ack_due) { + const auto bytes = ack.bytes(); + if (chance(random) >= loss) to_sender.push_back({tick + delay(), {bytes.begin(), bytes.end()}}); + ack_due = false; + } + } + // Nobody left behind: each stream's last pose came within a few seconds of the end. + for (std::size_t i = 0; i < sources; ++i) result.stalled += last_rebuilt[i] + 80 + world[i].every < ticks; + return result; +} +void link_checks() { + struct Case { + const char *name; + double loss; + unsigned least, most; + std::size_t kept, most_wholes; + }; + for (const auto &c : {Case{"a clean link", 0.0, 1, 1, 300, 6}, Case{"a 400 ms link losing 5%", 0.05, 7, 9, 300, 12}, + Case{"a link losing 30% and reordering", 0.30, 1, 6, 300, 60}, Case{"poses kept only briefly", 0.02, 2, 4, 12, 80}}) { + const auto r = run_link(31, c.loss, c.least, c.most, c.kept); + std::cout << " " << c.name << ": " << r.sent << " poses sent, " << r.rebuilt << " rebuilt, " << r.wrong << " wrong, " << r.wholes << " whole, " + << r.bytes / std::max(1, r.sent) << " B a pose in " << r.messages / 40 << " messages a second\n"; + check(r.wrong == 0, "A pose was rebuilt differently from what the server sent"); + check(r.stalled == 0, "A stream stopped for good"); + check(static_cast(r.rebuilt) > static_cast(r.sent) * (1 - c.loss) * 0.85, "Too few of the poses sent were rebuilt"); + check(r.wholes <= c.most_wholes, "Too many whole poses were needed"); + } + // Acks: the newest and the 64 before it, in any order. + pose_batch::Ack ack; + for (const std::uint32_t number : {5U, 3U, 9U, 8U, 70U, 200U, 199U, 136U}) ack.note(number); + check(ack.holds(200) && ack.holds(199) && ack.holds(136) && !ack.holds(135) && !ack.holds(70) && !ack.holds(198), "Acks did not keep the last 65 messages"); + const auto bytes = ack.bytes(); + const auto back = pose_batch::Ack::read(bytes); + check(back && back->newest == 200 && back->holds(199) && back->holds(136) && !back->holds(137), "An ack did not survive being sent"); + // Noise under the right first four bytes is declined or rebuilds nothing; it never throws. + std::vector junk(96); + std::copy(pose_batch::batch_magic.begin(), pose_batch::batch_magic.end(), junk.begin()); + std::mt19937 random(77); + for (unsigned round = 0; round < 5000; ++round) { + for (std::size_t i = 4; i < junk.size(); ++i) junk[i] = static_cast(round % 3 ? random() : random() % 4); + if (const auto batch = pose_batch::read(junk)) { + std::unordered_map streams; + for (const auto &entry : batch->entries) (void)pose_batch::rebuild(streams, entry); + } + } + std::cout << "Pose batches: delay, loss, reordering, short memory and noise handled.\n"; +} + +// Sizes on real play: a pose-dump recording (mp pose-dump), each record a time, a length and a +// compact packet. Not a pass or fail: prints what each choice costs, and checks every difference +// rebuilds the pose it was made from. +int bench(const char *path) { + std::ifstream in(path, std::ios::binary); + char magic[6]{}; + if (!in.read(magic, 6) || std::memcmp(magic, "RSPD1\n", 6) != 0) { + std::cerr << "Not a pose-dump recording: " << path << '\n'; + return 2; + } + std::vector poses; + for (;;) { + std::uint64_t time{}; + std::uint32_t length{}; + if (!in.read(reinterpret_cast(&time), 8) || !in.read(reinterpret_cast(&length), 4) || length > 1 << 20) break; + std::vector raw(length); + if (!in.read(reinterpret_cast(raw.data()), length)) break; + if (const auto packet = dingosdk::multiplayer::decode(raw); packet && packet->kind == PacketKind::pose) poses.push_back(quantize(packet->pose)); + } + if (poses.size() < 100) { + std::cerr << "Too few poses in " << path << '\n'; + return 2; + } + std::cout << poses.size() << " recorded poses, " << poses[0].bones.size() << " bones; rotations to " + << static_cast(1U << rotation_shift) * 0.00247 << " degrees\n"; + std::cout << "whole pose: " << encode_whole(poses[poses.size() / 2]).size() << " B\n"; + std::cout << "reference age all bones fingers held\n"; + bool exact = true; + for (const std::size_t age : {std::size_t{1}, std::size_t{2}, std::size_t{3}, std::size_t{6}, std::size_t{12}, std::size_t{30}, std::size_t{150}, std::size_t{600}}) { + std::size_t all{}, held{}, count{}; + for (std::size_t frame = age; frame < poses.size(); ++frame) { + const auto delta = encode_delta(poses[frame], poses[frame - age]); + all += delta.size(); + held += encode_delta(poses[frame], poses[frame - age], Fingers::held).size(); + ++count; + if (frame % 13 == 0) { + const auto got = decode(delta, &poses[frame - age]); + exact = exact && got && *got == poses[frame]; + } + } + std::cout << " " << age * 33 << " ms" << std::string(age * 33 < 100 ? 9 : age * 33 < 1000 ? 8 : age * 33 < 10000 ? 7 : 6, ' ') << all / count + << " B " << held / count << " B\n"; + } + const auto started = std::chrono::steady_clock::now(); + std::size_t made{}; + for (unsigned round = 0; round < 20; ++round) + for (std::size_t frame = 2; frame < poses.size(); ++frame) made += encode_delta(poses[frame], poses[frame - 2]).size() > 0; + const auto encode_us = std::chrono::duration(std::chrono::steady_clock::now() - started).count() / static_cast(made); + std::cout << "one difference takes " << encode_us << " us to build; every one checked rebuilt its pose: " << (exact ? "yes" : "NO") << '\n'; + return exact ? 0 : 1; +} +// Skater sound (sound_codec.h): a chain of messages rebuilds every sample as the sender rounded +// it, a message that did not go leaves the chain where it was, and noise is declined. +AudioSample sound_sample(std::mt19937 &random, AudioState &state, std::uint32_t age) { + std::uniform_real_distribution any(-1.f, 1.f); + for (std::size_t i = 0; i < 20; ++i) state.values[i] += any(random) * 0.2f; + if (random() % 9 == 0) state.values[20 + random() % 38] = any(random) * 90.f; + if (random() % 7 == 0) state.flags[1 + random() % 42] ^= 1; + if (random() % 11 == 0) state.selectors[random() % 26] = static_cast(random()); + return {age, state, random() % 3 == 0}; +} +void sound_checks() { + using namespace sound_codec; + std::mt19937 random(41); + Sender sender; + std::unordered_map streams; + std::array states{}; + states[1].values[7] = 0.0001f; // not nothing: must not arrive as nothing + std::size_t bytes{}, messages{}, heard_samples{}; + bool exact = true; + for (unsigned tick = 1; tick <= 600; ++tick) { + const std::uint64_t now = std::uint64_t{tick} * 33333; + sender.begin(7, 42); + std::array, 3> made; + for (std::size_t who = 0; who < 3; ++who) { + if (who == 2 && (tick / 50) % 2) continue; // out of earshot for a while + made[who] = {sound_sample(random, states[who], 24000), sound_sample(random, states[who], 9000)}; + sender.add(500 + who, 9, tick, now, made[who], now); + } + const bool went = tick % 13 != 0; // some messages are refused by the connection + std::vector message(sender.message().begin(), sender.message().end()); + sender.sent(went); + if (!went) continue; + bytes += message.size(); + ++messages; + const auto heard = read(streams, message, 7, 42); + if (!heard) { + exact = false; + break; + } + for (const auto &one : *heard) { + const auto who = static_cast(one.source - 500); + exact = exact && who < 3 && one.epoch == 9 && one.sequence == tick && one.time_us == now && one.samples.size() == made[who].size() && + valid_audio_batch(one.samples); + for (std::size_t i = 0; exact && i < one.samples.size(); ++i) { + exact = one.samples[i].state == restore(quantize(made[who][i].state)) && one.samples[i].event == made[who][i].event && + one.samples[i].age_us == (made[who][i].age_us >> age_shift << age_shift); + ++heard_samples; + } + } + } + check(exact && heard_samples > 2500, "Sound was not rebuilt as it was sent"); + check(restore(quantize(states[1])).values[7] != 0.f, "A small sound value arrived as nothing"); + // Rounding again changes nothing: what a server relays is what it was sent. + const auto once = quantize(states[0]); + check(quantize(restore(once)) == once, "Sound did not survive being rounded twice"); + // Another world's message holds nothing; a stream never started is passed over, not guessed. + sender.begin(7, 42); + const std::vector one{sound_sample(random, states[0], 5000)}; + sender.add(500, 9, 700, 1, one, 600 * 33333 + 1); + std::vector message(sender.message().begin(), sender.message().end()); + sender.sent(true); + std::unordered_map fresh; + const auto other_world = read(streams, message, 8, 42); + const auto unknown = read(fresh, message, 7, 42); + check(other_world && other_world->empty() && unknown && unknown->empty() && fresh.empty(), "Sound for another world or an unknown stream was played"); + for (unsigned round = 0; round < 4000; ++round) { + auto damaged = message; + if (round % 2) damaged.resize(random() % damaged.size()); + else damaged[random() % damaged.size()] ^= static_cast(1U << (random() % 8)); + std::unordered_map copy = streams; + if (const auto got = read(copy, damaged, 7, 42)) + for (const auto &entry : *got) + for (const auto &sample : entry.samples) + if (!valid_audio(sample.state)) return check(false, "Damaged sound decoded to an impossible sample"); + } + std::cout << "Sound codec: chains, refused messages, other worlds and damage handled (" << bytes / messages / 3 << " B a player a message on synthetic sound).\n"; +} +// Sizes on real play: the sound in a pose-dump recording, sent as a game would send it. +int sound_bench(const char *path) { + using namespace sound_codec; + std::ifstream in(path, std::ios::binary); + char magic_bytes[6]{}; + if (!in.read(magic_bytes, 6)) return 2; + Sender sender; + std::unordered_map streams; + std::size_t messages{}, old_bytes{}, new_bytes{}, samples{}; + bool exact = true; + std::uint64_t first{}, last{}; + for (;;) { + std::uint64_t time{}; + std::uint32_t length{}; + if (!in.read(reinterpret_cast(&time), 8) || !in.read(reinterpret_cast(&length), 4) || length > 1 << 20) break; + std::vector raw(length); + if (!in.read(reinterpret_cast(raw.data()), length)) break; + const auto packet = dingosdk::multiplayer::decode(raw); + if (!packet || packet->kind != PacketKind::audio) continue; + if (!first) first = packet->time_us; + last = packet->time_us; + sender.begin(1, 2); + sender.add(packet->source, packet->epoch, packet->sequence, packet->time_us, packet->audio, packet->time_us); + const std::vector message(sender.message().begin(), sender.message().end()); + sender.sent(true); + const auto heard = read(streams, message, 1, 2); + exact = exact && heard && heard->size() == 1 && heard->front().samples.size() == packet->audio.size(); + for (std::size_t i = 0; exact && i < packet->audio.size(); ++i) exact = heard->front().samples[i].state == restore(quantize(packet->audio[i].state)); + ++messages; + samples += packet->audio.size(); + old_bytes += length; + new_bytes += message.size(); + } + if (!messages) return 0; + const double seconds = static_cast(last - first) / 1e6; + std::cout << messages << " sound messages, " << samples << " samples in " << seconds << " s\n" + << " old format, before the wire's own compression: " << old_bytes / messages << " B a message, " << static_cast(old_bytes) / seconds / 1024 << " KB/s\n" + << " new format: " << new_bytes / messages << " B a message, " << static_cast(new_bytes) / seconds / 1024 << " KB/s; every sample rebuilt as rounded: " + << (exact ? "yes" : "NO") << '\n'; + return exact ? 0 : 1; +} + +// The sound in a recording, laid flat for study: for each sample its message's number and size, +// its age, whether it is an event, and its values, selectors and flags. +int sound_out(const char *path, const char *to) { + std::ifstream in(path, std::ios::binary); + char magic[6]{}; + if (!in.read(magic, 6)) return 2; + std::ofstream out(to, std::ios::binary); + std::uint32_t message{}; + for (;;) { + std::uint64_t time{}; + std::uint32_t length{}; + if (!in.read(reinterpret_cast(&time), 8) || !in.read(reinterpret_cast(&length), 4) || length > 1 << 20) break; + std::vector raw(length); + if (!in.read(reinterpret_cast(raw.data()), length)) break; + const auto packet = dingosdk::multiplayer::decode(raw); + if (!packet || packet->kind != PacketKind::audio) continue; + for (const auto &sample : packet->audio) { + const std::uint32_t head[4]{message, length, sample.age_us, sample.event ? 1U : 0U}; + out.write(reinterpret_cast(head), sizeof head); + out.write(reinterpret_cast(&time), 8); + out.write(reinterpret_cast(sample.state.values.data()), sizeof sample.state.values); + out.write(reinterpret_cast(sample.state.selectors.data()), sizeof sample.state.selectors); + out.write(reinterpret_cast(sample.state.flags.data()), sizeof sample.state.flags); + } + ++message; + } + std::cout << message << " sound messages\n"; + return 0; +} +} // namespace + +int main(int argc, char **argv) { + if (argc == 3) return sound_out(argv[1], argv[2]); + if (argc == 2) { + const auto poses = bench(argv[1]); + const auto sound = sound_bench(argv[1]); + return poses ? poses : sound; + } + exact_checks(); + finger_checks(); + hostile_checks(); + link_checks(); + sound_checks(); + if (failures) return 1; + std::cout << "Pose codec tests passed.\n"; + return 0; +} diff --git a/Extension/Multiplayer/console_commands.cpp b/Extension/Multiplayer/console_commands.cpp index 2acc6bc6..61fe763d 100644 --- a/Extension/Multiplayer/console_commands.cpp +++ b/Extension/Multiplayer/console_commands.cpp @@ -31,6 +31,8 @@ void register_multiplayer_commands(Commands ®istry) { Command{"tpall", "Host or server admin: teleport everyone to you"}, Command{"tphere", "Host or server admin: teleport one player to you"}, Command{"nametags", "Show or hide player nametags (on, off, toggle)"}, + Command{"pose-dump", "Research: record your own poses for a number of seconds (1-600) to logs/poses-*.bin"}, + Command{"direct-connections", "Connect straight to servers that offer it, not through Steam's relays (on, off, toggle)"}, Command{"player-distance", "How far away another player still gets a skater, in metres (50-1000; 1000: every player). Past it: a nametag or dot"}, Command{"nametag-distance", "How far away a player's name still shows, in metres (10-500); past it they are a dot"}, Command{"nametag-dots", "Show far and off-screen players as dots (on, off, toggle)"}, @@ -57,11 +59,12 @@ void register_multiplayer_commands(Commands ®istry) { if (std::string_view(c.name) == "nametags" || std::string_view(c.name) == "chat-bubbles" || std::string_view(c.name) == "chat-bubbles-own" || std::string_view(c.name) == "nametag-dots" || std::string_view(c.name) == "nametags-friends" || + std::string_view(c.name) == "direct-connections" || std::string_view(c.name) == "score-check") args.push_back(argument("choice")); if (std::string_view(c.name) == "chat-bubbles-distance" || std::string_view(c.name) == "nametag-distance") args.push_back(argument("metres")); - if (std::string_view(c.name) == "chat-bubbles-duration") + if (std::string_view(c.name) == "chat-bubbles-duration" || std::string_view(c.name) == "pose-dump") args.push_back(argument("seconds")); if (std::string_view(c.name) == "player-distance") args.push_back(argument("metres")); diff --git a/Extension/UI/Overlay/multiplayer_menu.cpp b/Extension/UI/Overlay/multiplayer_menu.cpp index 0c8412bb..6869ae95 100644 --- a/Extension/UI/Overlay/multiplayer_menu.cpp +++ b/Extension/UI/Overlay/multiplayer_menu.cpp @@ -256,6 +256,15 @@ void multiplayer_display_settings(SkateMenu &menu, const Model &model) { (*menu.player_distance_pending == mp.player_distance || ImGui::GetTime() >= menu.player_distance_until)) menu.player_distance_pending.reset(); } + bool direct = mp.prefer_direct; + if (toggle_row(menu, "Direct connections", + "Connect straight to dedicated servers that offer it: the shortest route, so the lowest ping. The server can then " + "see your IP address, as with any game's dedicated servers; other players never can. Off: always through Steam's " + "relays. Applies from the next server you join.", + direct)) { + std::array unused{}; + send_private(menu, "direct-connections", direct ? "on" : "off", unused, false); + } bool nametags = mp.nametags; if (toggle_row(menu, "Player nametags", "The name above each skater, with their distance: purple for ReSkate developers, red for content creators, " diff --git a/Server/README.txt b/Server/README.txt index 7e9bf82f..69277deb 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -60,6 +60,50 @@ send_rate The most the server sends one player, in KB/s (default 900, Steam's relays: set higher, what is lost is resent until the connection is full of resends and half of everything is lost. Console: rate , also for the players already on. +finger_distance Past this many metres (default 25) a player's fingers are not + sent moving: they stay as they were, and move again when the + player is nearer. Fingers are nearly half of every position + update and cannot be made out at that distance. 0 always + sends them. +pack_ms How long a message to a player may wait to go in the same + packet as the next ones, in milliseconds (default 10, 0 to + 50). A full server sends each player hundreds of small + messages a second, each in a packet of its own with its own + headers; packed, the same updates take fewer packets, less + bandwidth and less CPU. It adds up to that long to when an + update arrives. Voice is never held back. 0 sends every + message at once, as before. Takes effect on restart. +connection How players reach the server: "relay" (default) or "direct". + relay Through Steam's relay network. Nothing to open, and + the server never sees a player's address. The route + is Steam's choice and can be a long way round. + direct Straight to this server's "port" over UDP, which + must be open to the internet. The shortest route, + so the lowest ping. The server sees the addresses + of the players who connect this way, as any + dedicated server does; players never see each + other's. + A direct server still answers through the relays: a game + tries the port first and uses the relays if it gets no + answer in a few seconds, and a player can turn direct + connections off in their own settings. So "direct" never + keeps anyone out. Steam vouches for who each player is + either way. "net" shows who is direct, and the log has a + [direct] line for each player who asks to connect that way. +direct_address For "direct": the address players connect to, like + 203.0.113.7, when it is not the one the server's start-up + line calls its public IP. Some hosts send from one address + and take connections on another; use the one your panel + shows for the server. Empty (default): the public IP. +steam_debug true logs what Steam's own networking says it is doing: each + connection asked for, and what it refused or ignored and + why. For finding out why players cannot connect; it is a + lot of text, so turn it off again afterwards. +relay_everything true sends poses through the server even between players + whose games have linked to each other. For testing with a + few players; leave it false otherwise. +direct_port For "direct": the UDP port to listen on when it is not + "port" (default 0: "port"). Must differ from query_port. bone_scale_limit How far a mod may resize part of a skater for the other players (a "big head" mod and the like): the most a bone may be scaled, 1 to 8. 1 (default) shows every skater at the diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 2a5ced2a..56a673cf 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -70,6 +70,12 @@ int run() { ServerConfig scaled; scaled.bone_scale_limit = 0.5f; check(config_error(scaled).find("bone_scale_limit") != std::string::npos, "A bone scale limit under 1 was accepted"); + check(has("pack_ms") && config.pack_ms == 10, "pack_ms is not a new setting of 10"); + check(has("finger_distance") && config.finger_distance == 25, "finger_distance is not a new setting of 25"); + check(has("connection") && config.connection == "relay", "connection is not a new setting of relay"); + ServerConfig how; + how.connection = "carrier pigeon"; + check(config_error(how).find("connection") != std::string::npos, "An unknown connection was accepted"); ServerConfig crowd; crowd.crowd_budget = 50; check(config_error(crowd).find("crowd_budget") != std::string::npos, "A crowd budget too small to play with was accepted"); diff --git a/Server/main.cpp b/Server/main.cpp index c0768360..f28adf47 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -15,6 +15,8 @@ #include "Engine/Game/World/world_names.h" #include "Engine/Vfs/world_layer_scan.h" #ifdef _WIN32 +#include +#include #include #include #else @@ -389,6 +391,65 @@ int run(int argc, char **argv, bool skip_update) { write_log("Steam networking failed: " + transport.status().detail); return 1; } + // A check of another server's direct connections, from here: RESKATE_PROBE_DIRECT set to + // ",,". Signs in as this server would, connects + // as a game does (the address first, Steam's relays after five seconds), says which it got, + // and exits. Run it from a machine outside the other server's network. + std::string probe; +#ifdef _WIN32 + { + char *value{}; + std::size_t length{}; + if (!_dupenv_s(&value, &length, "RESKATE_PROBE_DIRECT") && value) probe = value; + std::free(value); + } +#else + if (const char *value = std::getenv("RESKATE_PROBE_DIRECT")) probe = value; +#endif + if (!probe.empty()) { + unsigned long long id{}; + unsigned port{}; + const auto first = probe.find(','), second = probe.find(',', first == std::string::npos ? 0 : first + 1); + const auto address = first != std::string::npos && second != std::string::npos ? direct_ipv4(std::string_view(probe).substr(first + 1, second - first - 1)) : 0; + const bool parsed = address && std::from_chars(probe.data(), probe.data() + first, id).ec == std::errc{} && + std::from_chars(probe.data() + second + 1, probe.data() + probe.size(), port).ec == std::errc{}; + if (!parsed || !id || !port || port > 65535) { + write_log("RESKATE_PROBE_DIRECT must be ,,."); + return 1; + } + const unsigned a = address >> 24, b = (address >> 16) & 255, c = (address >> 8) & 255, d = address & 255; + write_log("Probe: connecting straight to " + std::to_string(a) + "." + std::to_string(b) + "." + std::to_string(c) + "." + + std::to_string(d) + ":" + std::to_string(port) + " as a game would..."); + if (!transport.join(id, address, static_cast(port))) { + write_log("Probe: Steam would not start the connection: " + transport.status().detail); + return 1; + } + std::string said; + const auto started = std::chrono::steady_clock::now(); + while (!stopping && std::chrono::steady_clock::now() - started < std::chrono::seconds(30)) { + steam.run_callbacks(); + transport.poll(); + if (const auto &detail = transport.status().detail; detail != said) { + said = detail; + write_log("Probe: " + said); + } + const auto links = transport.links(); + if (links.empty()) { + write_log("Probe: no connection either way. " + transport.take_closed(id)); + return 1; + } + if (links.front().connected && links.front().measured) { + write_log(links.front().direct + ? "Probe: CONNECTED DIRECTLY, ping " + std::to_string(links.front().ping_ms) + " ms." + : "Probe: the address did not answer; connected through Steam's relays (" + links.front().relay + "-" + + links.front().remote_relay + "), ping " + std::to_string(links.front().ping_ms) + " ms."); + return 0; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + write_log("Probe: nothing after 30 s."); + return 1; + } Host host(config, transport, write_log); if (!host.start(error)) { write_log("Could not open the server: " + error); @@ -422,8 +483,23 @@ int run(int argc, char **argv, bool skip_update) { auto next_ban_check = next_advertise; bool bans_unread{}; if (!config.global_bans) write_log("Global bans are off (\"global_bans\": false): only this server's own bans apply."); + // Signed in to Steam, as last logged, and when it is looked at again. Players already on stay + // connected through a lost sign-in, but nobody new can join: Steam carries the first messages + // of a connection, and theirs time out ("negotiate rendezvous"). + bool signed_in = true; + auto next_sign_in_check = std::chrono::steady_clock::now(); while (!stopping && !restart) { steam.run_callbacks(); + if (const auto now = std::chrono::steady_clock::now(); now >= next_sign_in_check) { + next_sign_in_check = now + std::chrono::seconds(5); + if (const bool on = steam.logged_on(); on != signed_in) { + signed_in = on; + write_log(on ? "[steam] Signed in to Steam again: players can join." + : "[steam] No longer signed in to Steam: players already on stay, but nobody can join until it is back. " + "Steam signs a server out when another one signs in with the same steam_token, and for its own " + "maintenance or a lost connection."); + } + } try { host.tick(multiplayer::now_us()); } catch (const std::exception &e) { @@ -516,7 +592,7 @@ int run(int argc, char **argv, bool skip_update) { name_allowed = allowed; } steam.advertise({config.name, host.map_name(), host.players(), config.max_players, !config.password.empty(), - config.listed && allowed, host.secret()}); + config.listed && allowed, host.secret(), host.direct_port(), host.direct_address()}); } std::this_thread::sleep_for(std::chrono::milliseconds(2)); } diff --git a/Server/server_config.cpp b/Server/server_config.cpp index cf0a3f48..a9ee42f1 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -26,6 +26,13 @@ Json to_json(const ServerConfig &c) { root["reserved_slots"] = c.reserved_slots; root["crowd_budget"] = c.crowd_budget; root["bone_scale_limit"] = c.bone_scale_limit; + root["connection"] = c.connection; + root["direct_address"] = c.direct_address; + root["steam_debug"] = c.steam_debug; + root["relay_everything"] = c.relay_everything; + root["direct_port"] = c.direct_port; + root["pack_ms"] = c.pack_ms; + root["finger_distance"] = c.finger_distance; root["send_rate"] = c.send_rate; auto reserved = Json::array(); for (const auto id : c.reserved) reserved.push_back(std::to_string(id)); // as strings, like the admins @@ -134,6 +141,13 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector 16384) return "send_rate must be 128 to 16384 (KB/s for each player)."; if (c.bone_scale_limit != 0 && !(c.bone_scale_limit >= 1.f && c.bone_scale_limit <= 8.f)) return "bone_scale_limit must be 0 (no limit) or 1 to 8 (1: no resized body parts at all)."; + if (c.connection != "relay" && c.connection != "direct") return "connection must be \"relay\" or \"direct\"."; + if (!c.direct_address.empty() && !direct_ipv4(c.direct_address)) return "direct_address must be an address like 203.0.113.7, or empty."; + if (c.direct_port && c.direct_port == c.query_port) return "direct_port and query_port must differ."; + if (c.pack_ms > 50) return "pack_ms must be 0 (off) to 50."; + if (c.finger_distance > 10000) return "finger_distance must be 0 (fingers always sent) to 10000."; if (!valid_crowd_budget(c.crowd_budget)) return "crowd_budget must be 0 (no limit) or " + std::to_string(min_crowd_budget) + " to " + std::to_string(max_crowd_budget) + "."; @@ -360,6 +379,22 @@ const ServerLevel *find_level(std::string_view map) { } return nullptr; } +std::uint32_t direct_ipv4(std::string_view text) noexcept { + std::uint32_t address{}; + unsigned parts{}; + while (!text.empty() && parts < 4) { + const auto dot = text.find('.'); + const auto part = text.substr(0, dot); + unsigned value{}; + const auto parsed = std::from_chars(part.data(), part.data() + part.size(), value); + if (part.empty() || part.size() > 3 || parsed.ec != std::errc{} || parsed.ptr != part.data() + part.size() || value > 255) return 0; + address = (address << 8) | value; + ++parts; + text = dot == std::string_view::npos ? std::string_view{} : text.substr(dot + 1); + if (dot != std::string_view::npos && text.empty()) return 0; // a trailing dot + } + return parts == 4 && text.empty() ? address : 0; +} bool installed_map(std::string_view map) { const auto destination = map_destination(map); const auto asset = world_destination_asset(destination); diff --git a/Server/server_config.h b/Server/server_config.h index f149d46e..eeb5d1f2 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -46,6 +46,27 @@ struct ServerConfig { // inverse the least): 1, the default, shows every skater at the game's own proportions; // 0 is no limit. float bone_scale_limit = 1; + // How players reach the server: "relay", through Steam's relay network only, or "direct", + // straight to `port` (UDP). A direct server still answers through the relays, for a player + // the port does not reach, one who has turned direct connections off, or an older game. + std::string connection = "relay"; + // The address players connect straight to, a.b.c.d, when it is not the one Steam lists the + // server under (a host that sends from one address and takes connections on another). + // Empty: the listed one. And the port, when it is not `port`; 0: `port`. + std::string direct_address; + // Logs what Steam's networking says it is doing, to find out why connections fail. + bool steam_debug = false; + // For testing with few players: poses go through the server even between players whose + // games have linked to each other. + bool relay_everything = false; + std::uint16_t direct_port = 0; + // How long a message to a player may wait to share a packet with the next ones, in + // milliseconds (0: each goes at once in a packet of its own). Fewer, fuller packets: + // less sent for the same updates, and less work sending it. + unsigned pack_ms = 10; + // Past this many metres a player's fingers are not sent moving (0: always). A skater's + // forty finger bones turn in nearly every pose and are half of what a pose carries. + unsigned finger_distance = 25; // What the server may send each player, in KB/s (128-16384). unsigned send_rate = 900; std::vector reserved; @@ -129,6 +150,8 @@ const ServerLevel *find_level(std::string_view map); // Whether the server has this map (a name, level path or destination): one of the game's own, // or one a mod folder in its Mods lists. It only moves players to a map it has itself. bool installed_map(std::string_view map); +// "a.b.c.d" as a number (a the highest byte), or 0 when it is not an IPv4 address. +std::uint32_t direct_ipv4(std::string_view text) noexcept; // What players load for a map, as the protocol carries it ("|"). // Empty when the map is unknown (a full level path is always accepted). std::string map_destination(std::string_view map); diff --git a/Server/server_host.cpp b/Server/server_host.cpp index a85a31f2..84d79058 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -140,10 +140,29 @@ unsigned Host::players() const { bool Host::start(std::string &error) { transport_.set_send_rate(static_cast(config_.send_rate * 1024)); + transport_.set_packing(config_.pack_ms); + if (config_.steam_debug) + log_(transport_.set_steam_debug(true) ? "Steam networking debug output is on (\"steam_debug\"): its lines are marked [direct] Steam:." + : "steam_debug: this Steam has no debug output."); if (!transport_.host(capacity())) { error = transport_.status().detail; return false; } + direct_port_ = 0; + if (config_.connection == "direct") { + const auto port = config_.direct_port ? config_.direct_port : config_.port; + if (transport_.listen_direct(port)) { + direct_port_ = port; + log_("Connection: direct, on UDP port " + std::to_string(port) + + (config_.direct_address.empty() ? std::string{} : " at " + config_.direct_address) + + ". The port must be open to the internet; players it does not reach come through Steam's relays."); + } else { + log_("Connection: direct was asked for, but Steam could not listen on UDP port " + std::to_string(port) + + " (in use, or not allowed here). Players connect through Steam's relays only."); + } + } else { + log_("Connection: through Steam's relays (\"connection\": \"direct\" lets players connect straight to the server)."); + } id_ = transport_.status().local_id; secret_ = nonce(); epoch_ = nonce(); @@ -181,13 +200,14 @@ namespace { // for a difference from one, however old the reference (net's pose size line), so they are // kept rare: at every 8 seconds they were a seventh of everything a busy server sent. constexpr std::uint64_t whole_state_refresh = 60000000; -// How finely a player's rotations are sent, by how far away the recipient is (coarsen_rotations): -// within the full rate's reach, the half rate's, the low rate's, and out of sight. The -// nearest is 0.04 degrees a step, which nothing shows; the low rate's a third of a degree, -// at 170 m and more; out of sight only where the skater is matters. A bone that turns by less -// than a step is not sent at all, and a far skater's fingers and face never turn by more. -constexpr std::array pose_precision{4, 6, 7, 11}; -constexpr std::array pose_precision_names{"near", "mid", "far", "out of sight"}; +// Rotations were rounded more coarsely for far recipients for a while (coarsen_rotations). +// Measured on a full server it saved 0 to 7% of a pose (24% out of sight), because a pose is +// sent as a difference from a reference up to a minute old, from which every animated bone +// has turned by far more than any step; and renewing the reference whenever a recipient +// crossed from one precision to another cost more than that saved. So poses go as they come. +constexpr std::array pose_rate_names{"full rate", "half rate", "low rate", "out of sight"}; +// How long a kept pose stays (KeptPose::keep), in microseconds: by the slowest rate it went out at. +constexpr std::array pose_kept_for{1500000, 3000000, 5000000, 15000000}; constexpr std::array traffic_names{"poses", "sound", "voice", "outfits", "objects", "other"}; constexpr std::size_t traffic_kind(PacketKind kind) noexcept { return kind == PacketKind::pose ? 0 : kind == PacketKind::audio ? 1 : kind == PacketKind::voice ? 2 @@ -228,6 +248,26 @@ void Host::measure_pose(Guest &from, const Packet &packet) { const auto last = cost(from.pose_last), quarter = cost(from.pose_quarter), second = cost(from.pose_second); if (last && quarter && second) { ++pose_sizes_.samples; + // What changed since the pose before, field by field, as pose_delta walks them. + try { + constexpr auto prefix = packet_header_size + 6; + const auto bones = pose_delta::count(raw); + if (bones && bones == pose_delta::count(from.pose_last.raw)) { + pose_delta::Cursor now_at{raw, prefix}, before_at{from.pose_last.raw, prefix}; + pose_sizes_.bones += bones; + for (unsigned bone = 0; bone < bones; ++bone) { + const auto current = pose_delta::fields(now_at), earlier = pose_delta::fields(before_at); + // Fields: flags, position (12 bytes as floats, 6 in mm), rotation, scale. + for (unsigned field = 1; field < 4; ++field) { + if (std::equal(current[field].begin(), current[field].end(), earlier[field].begin(), earlier[field].end())) continue; + auto &kind = pose_sizes_.changed[field == 1 ? (current[1].size() == 12 ? 0 : 1) : field]; + ++kind.fields; + kind.bytes += current[field].size(); + } + } + } + } catch (...) { + } pose_sizes_.whole += encode_wire_bytes(raw).size() + 4; pose_sizes_.last += last; pose_sizes_.quarter += quarter; @@ -239,9 +279,95 @@ void Host::measure_pose(Guest &from, const Packet &packet) { if (kept->raw.empty() || packet.time_us < kept->time || packet.time_us - kept->time >= age) *kept = {raw, packet.time_us}; from.pose_last = {std::move(raw), packet.time_us}; } +Host::Guest::KeptPose *Host::keep_pose(Guest &from, const Packet &packet) { + auto &kept = from.kept_poses; + std::erase_if(kept, [&](const Guest::KeptPose &pose) { return now_ - pose.kept_at > pose_kept_for[std::min(pose.keep, 3)]; }); + while (kept.size() >= 96) kept.pop_front(); + if (!kept.empty() && kept.back().sequence == packet.sequence) return &kept.back(); + // What a mod resized on its player's skater reaches the others only as far as the server allows. + if (config_.bone_scale_limit >= 1.f) { + auto pose = packet.pose; + limit_bone_scale(pose, config_.bone_scale_limit); + kept.push_back({packet.sequence, packet.time_us, now_, 0, pose_codec::quantize(pose)}); + } else { + kept.push_back({packet.sequence, packet.time_us, now_, 0, pose_codec::quantize(packet.pose)}); + } + return &kept.back(); +} +// Sends each player the poses queued for them this pass (broadcast): their differences from a +// pose of the same player the recipient is known to hold, packed several to a message. +void Host::flush_poses() { + for (auto &[id, guest] : guests_) { + auto &g = *guest; + // Which of their own pose messages arrived, once for all read this pass. + if (std::exchange(g.upload_ack_due, false)) { + const auto ack = g.upload_ack.bytes(); + if (transport_.send(id, ack, false, true, TrafficLane::gameplay)) + for (auto *counted : {&g.traffic, &traffic_}) counted->total.out[traffic_kind(PacketKind::pose)] += ack.size(); + } + // The sound of the skaters they can hear, in one message, reliably: each builds on the + // last, and many samples are a pulse that must not be lost. + if (!g.queued_sound.empty()) { + const auto sounds = std::exchange(g.queued_sound, {}); + if (g.handshaken && g.world_ready) { + g.sound_sender.begin(world_, map_); + for (const auto &q : sounds) + if (const auto *from = find(q.source)) g.sound_sender.add(q.source, from->member.epoch, q.sequence, q.time_us, *q.samples, now_); + if (g.sound_sender.pending()) { + const auto message = g.sound_sender.message(); + const bool went = transport_.send(id, message, true, true, traffic_lane(PacketKind::audio)); + if (went) + for (auto *counted : {&g.traffic, &traffic_}) counted->total.out[traffic_kind(PacketKind::audio)] += message.size(); + g.sound_sender.sent(went); + } + } + } + if (g.queued_poses.empty()) continue; + const auto queued = std::exchange(g.queued_poses, {}); + if (!g.handshaken || !g.world_ready) continue; + const auto emit = [&](std::span message, bool reliable) { + if (!transport_.send(id, message, reliable, !reliable, TrafficLane::gameplay)) return false; + for (auto *counted : {&g.traffic, &traffic_}) counted->total.out[traffic_kind(PacketKind::pose)] += message.size(); + return true; + }; + g.pose_sender.begin(world_, map_); + for (const auto &q : queued) { + auto *from = find(q.source); + if (!from) continue; + const auto find_kept = [&](std::uint32_t sequence) -> std::optional { + for (auto it = from->kept_poses.rbegin(); it != from->kept_poses.rend(); ++it) + if (it->sequence == sequence) return pose_batch::KeptView{it->sequence, it->time_us, &it->pose}; + return {}; + }; + const auto pose = find_kept(q.sequence); + if (!pose) continue; + const auto added = g.pose_sender.add(q.source, from->member.epoch, *pose, find_kept, q.hold_fingers, q.collision, q.rate, now_, emit); + if (added.did == pose_batch::Sender::Did::whole) { + // Kept long enough for the ack of it to find it here. + for (auto &kept : from->kept_poses) + if (kept.sequence == q.sequence) kept.keep = 3; + for (auto *counted : {&g.traffic, &traffic_}) ++counted->total.snapshots; + ++pose_sizes_.whole_sent; + pose_sizes_.whole_sent_bytes += added.bytes; + } else if (added.did == pose_batch::Sender::Did::difference) { + const auto tier = std::min(q.tier, 3); + ++pose_sizes_.sent[tier]; + pose_sizes_.sent_bytes[tier] += added.bytes + 8; + pose_sizes_.held += q.hold_fingers; + } + } + g.pose_sender.flush(emit); + } +} +// A player said which messages of poses they read in full: the poses in those are ones they hold. +void Host::pose_ack(Guest &g, const pose_batch::Ack &ack) { g.pose_sender.ack(ack); } void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint64_t except) { - if (packet.kind == PacketKind::pose) - if (auto *from = find(packet.source)) measure_pose(*from, packet); + // A pose is rounded once and kept (keep_pose); each recipient is then sent its differences + // from a pose of this player they hold, several players to a message (flush_poses). + auto *kept = packet.kind == PacketKind::pose ? [&]() -> Guest::KeptPose * { + auto *from = find(packet.source); + return from ? keep_pose(*from, packet) : nullptr; + }() : nullptr; const auto *source = find(packet.source); // Recipients holding the same delta reference share one patch and compression // (DeltaCache): every update is built first, then all are sent and recorded. @@ -250,6 +376,7 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 struct Outgoing { Guest *guest; std::uint64_t id; WireUpdate update; PoseDelivery *delivery; std::uint32_t interval; unsigned variant; }; std::vector outgoing; outgoing.reserve(guests_.size()); + std::shared_ptr> sound; // a skater's samples, shared by everyone sent them const bool gameplay = packet.kind == PacketKind::pose || packet.kind == PacketKind::audio || packet.kind == PacketKind::voice || packet.kind == PacketKind::cosmetics; for (auto &[id, guest] : guests_) { @@ -285,9 +412,15 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 if (distance > p.crowd.half || (config_.distances.valid() && distance > reach * reach)) continue; } // Frequent gameplay streams skip the server once the receiver confirms a direct route. - if (source && (packet.kind == PacketKind::pose || packet.kind == PacketKind::audio) && + if (source && !config_.relay_everything && (packet.kind == PacketKind::pose || packet.kind == PacketKind::audio) && !needs_relay(p.direct_routes, p.route_reported, source->member, now_)) continue; + // Sound is sent with everyone else's they hear, once a pass (flush_poses). + if (packet.kind == PacketKind::audio) { + if (!sound) sound = std::make_shared>(packet.audio); + p.queued_sound.push_back({packet.source, packet.time_us, packet.sequence, sound}); + continue; + } PoseDelivery *delivery{}; std::uint32_t slot{}; // how often this source's poses go to them, when not every `interval` bool out_of_sight{}; @@ -313,18 +446,16 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 // Half as far again as the low rate starts: nothing of a skater can be made out // there, only where they are (their dot, the map). // (A little nearer to come back into sight than to leave it.) - const auto sight = static_cast(config_.distances.low_rate_start) * (delivery->precision == 3 ? 1.4f : 1.5f); + const auto sight = static_cast(config_.distances.low_rate_start) * ((delivery->precision & 1) ? 1.4f : 1.5f); out_of_sight = config_.distances.valid() && distance > sight * sight; - precision = out_of_sight ? 3U : delivery->by_distance == 200000 ? 2U : delivery->by_distance == 100000 ? 1U : 0U; + // Too far to make out a hand: the fingers are not sent (pose_codec's Fingers). A + // little nearer to get them back than to lose them. + const auto hands = static_cast(config_.finger_distance) * ((delivery->precision & 2) ? 0.9f : 1.1f); + const bool held = config_.finger_distance && packet.pose.skater.size() == pose_codec::finger_skeleton && distance > hands * hands; + precision = held ? 1U : 0U; + delivery->precision = static_cast((out_of_sight ? 1U : 0U) | (held ? 2U : 0U)); // which side of each they are on } else { delivery->by_distance = 0; - precision = delivery->precision; - } - // By distance only, which changes seldom: a difference from a reference of another - // precision carries every bone again, so the reference is renewed with it. - if (precision != delivery->precision) { - p.sender.forget(packet.source, PacketKind::pose); - delivery->precision = static_cast(precision); } // Standing still for a few seconds: five poses a second carry it to anyone. The // rate is back at once when they move (the change of rate sends the next pose). @@ -336,6 +467,18 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 // it hears nothing of for several seconds). On a full server most players are out // of each other's sight, and at five a second each they were most of what was sent. slot = out_of_sight && interval == 200000 ? 1000000U : interval; + if (kept) { + const std::uint8_t tier = slot == 1000000 ? 3 : interval == 200000 ? 2 : interval == 100000 ? 1 : 0; + p.queued_poses.push_back({packet.source, packet.sequence, + tier >= 2 ? pose_batch::Rate::low : tier == 1 ? pose_batch::Rate::half : pose_batch::Rate::full, + packet.player_collision, precision == 1, tier}); + kept->keep = std::max(kept->keep, tier); + delivery->last_sent = now_; + // The next slot on the source's own clock: everyone sent a source at a slower rate + // is sent the same poses of it, so few of them need keeping to build on. + delivery->next_source_time = (packet.time_us / slot + 1) * slot; + continue; + } } const auto variant = (interval == 200000 ? 2U : interval == 100000 ? 1U : 0U) * 4 + precision; auto &data = encoded[variant]; @@ -345,7 +488,6 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 if (packet.kind == PacketKind::pose) { // What a mod resized on its player's skater reaches the others only as far as the server allows. if (config_.bone_scale_limit >= 1.f) limit_bone_scale(data.packet.pose, config_.bone_scale_limit); - coarsen_rotations(data.packet.pose, pose_precision[precision]); } data.raw = encode(data.packet, true); data.wire = encode_wire_bytes(data.raw); @@ -366,8 +508,8 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 counted->total.snapshots += whole; } if (sending.kind == PacketKind::pose && !whole) { - ++pose_sizes_.sent[out.variant % 4]; - pose_sizes_.sent_bytes[out.variant % 4] += out.update.bytes.size(); + ++pose_sizes_.sent[out.variant / 4]; + pose_sizes_.sent_bytes[out.variant / 4] += out.update.bytes.size(); } out.guest->sender.sent(sending, std::move(out.update)); } @@ -586,6 +728,10 @@ void Host::drop(std::uint64_t id, const std::string &reason, const std::string & (detail.empty() ? std::string{} : " [" + detail + "]")); } guests_.erase(found); + for (auto &[other, guest] : guests_) { + guest->pose_sender.forget(id); + guest->sound_sender.forget(id); + } party_left(id, name); // Their vote cooldown stays (tick() drops it once it has run out): leaving and coming // back does not let a player start another vote sooner. @@ -628,14 +774,95 @@ bool Host::accept_data(Guest &source, const Packet &p) { } return true; } -void Host::receive(std::uint64_t peer, std::span bytes, std::uint64_t received_at) { +// A player's own poses (pose_batch.h): each is rebuilt from one of theirs held here and taken as +// the packet it would have been. A message read in full is acked (flush_poses); one with a pose +// that could not be rebuilt is not, so their game goes on building on what is held here. +void Host::receive_poses(std::uint64_t peer, std::span bytes, std::uint64_t received_at) { + auto *link = find(peer); + if (!link || !link->handshaken) return; + const auto batch = pose_batch::read(bytes); + if (!batch || batch->world != static_cast(world_) || batch->map != static_cast(map_)) return; + bool complete = true, first = true; + for (const auto &entry : batch->entries) { + if (entry.whole) { + // Only themselves, and one stream at a time: a new one replaces the last. + if (entry.source != peer || entry.epoch != link->member.epoch) { + complete = false; + continue; + } + if (!link->upload_streams.contains(entry.stream)) link->upload_streams.clear(); + } + const auto rebuilt = pose_batch::rebuild(link->upload_streams, entry); + if (!rebuilt) { + complete = false; + continue; + } + if (rebuilt->repeat) continue; + Packet p; + p.kind = PacketKind::pose; + p.session = secret_; + p.map = map_; + p.world = world_; + p.source = peer; + p.epoch = rebuilt->epoch; + p.sequence = rebuilt->sequence; + p.time_us = rebuilt->time_us; + p.player_collision = rebuilt->collision; + p.pose = pose_codec::restore(rebuilt->pose); + // The message's bytes are counted once, with its first pose. + receive(peer, first ? bytes : bytes.first(0), received_at, &p); + first = false; + link = find(peer); // a pose can be what gets its player removed + if (!link) return; + } + if (complete) { + link->upload_ack.note(batch->number); + link->upload_ack_due = true; + } +} +// A player's own skater's sound (sound_codec.h), taken as the packet it would have been. +void Host::receive_sound(std::uint64_t peer, std::span bytes, std::uint64_t received_at) { + auto *link = find(peer); + if (!link || !link->handshaken) return; + if (link->sound_in.size() > 2) link->sound_in.clear(); // only their own, one stream at a time + const auto heard = sound_codec::read(link->sound_in, bytes, world_, map_); + if (!heard) return; + bool first = true; + for (const auto &one : *heard) { + if (one.source != peer || one.epoch != link->member.epoch || !valid_audio_batch(one.samples)) continue; + Packet p; + p.kind = PacketKind::audio; + p.session = secret_; + p.map = map_; + p.world = world_; + p.source = peer; + p.epoch = one.epoch; + p.sequence = one.sequence; + p.time_us = one.time_us; + p.audio = one.samples; + receive(peer, first ? bytes : bytes.first(0), received_at, &p); + first = false; + link = find(peer); + if (!link) return; + } +} +void Host::receive(std::uint64_t peer, std::span bytes, std::uint64_t received_at, const Packet *ready) { auto *link = find(peer); if (!link) return; - // Counted by arrival: after the server was held up, everyone's packets are read at once. - if (!link->budget.accept(received_at ? received_at : now_, bytes.size(), 1U)) - return drop(peer, "Peer exceeded the multiplayer packet limit."); + if (!ready) { + // Counted by arrival: after the server was held up, everyone's packets are read at once. + if (!link->budget.accept(received_at ? received_at : now_, bytes.size(), 1U)) + return drop(peer, "Peer exceeded the multiplayer packet limit."); + // Which messages of poses they read (pose_batch.h): not a packet, and says nothing else. + if (const auto ack = pose_batch::Ack::read(bytes)) { + if (link->handshaken) pose_ack(*link, *ack); + return; + } + if (pose_batch::is_batch(bytes)) return receive_poses(peer, bytes, received_at); + if (sound_codec::is_sound(bytes)) return receive_sound(peer, bytes, received_at); + } bool missing_reference{}; - const auto decoded = link->receiver.receive(bytes, missing_reference, world_); + const auto decoded = ready ? std::optional(*ready) : link->receiver.receive(bytes, missing_reference, world_); if (missing_reference) return; if (!decoded || decoded->session != secret_ || decoded->source != peer) return drop(peer, "Join code, sender identity, or multiplayer protocol did not match."); @@ -998,8 +1225,9 @@ std::string Host::network_report(std::string_view player, bool console) { const auto *guest = find(link.id); if (std::to_string(link.id) == player || (guest && lower(guest_name(*guest)).starts_with(wanted))) return row(link).substr(2) + (guest ? "\n" + by_kind(guest->traffic) : std::string{}) + - "\nroute: through Steam's relay " + (link.relay.empty() ? std::string("(unknown)") : link.relay) + - " on this side and " + (link.remote_relay.empty() ? std::string("(unknown)") : link.remote_relay) + " on theirs"; + (link.direct ? std::string("\nroute: direct, not through Steam's relays") + : "\nroute: through Steam's relay " + (link.relay.empty() ? std::string("(unknown)") : link.relay) + + " on this side and " + (link.remote_relay.empty() ? std::string("(unknown)") : link.remote_relay) + " on theirs"); } return "No connected player matches \"" + std::string(player) + "\"."; } @@ -1026,23 +1254,30 @@ std::string Host::network_report(std::string_view player, bool console) { // The relay locations in use, most connections first: everyone through one far away is a route problem. std::map relays; for (const auto &link : links) - if (link.measured) ++relays[(link.relay.empty() ? "?" : link.relay) + "-" + (link.remote_relay.empty() ? "?" : link.remote_relay)]; + if (link.measured) + ++relays[link.direct ? std::string("direct") + : (link.relay.empty() ? "?" : link.relay) + "-" + (link.remote_relay.empty() ? "?" : link.remote_relay)]; std::vector> order(relays.begin(), relays.end()); std::stable_sort(order.begin(), order.end(), [](const auto &a, const auto &b) { return a.second > b.second; }); + text += "\nSteam relay network: " + transport_.relay_status(); text += "\nrelays (ours-theirs):"; for (std::size_t i = 0; i < std::min(order.size(), 10); ++i) text += " " + order[i].first + " x" + std::to_string(order[i].second); } text += "\nloop: " + loop_report(); text += "\n" + by_kind(traffic_); - if (pose_sizes_.samples) { - const auto each = [&](std::uint64_t sum) { return std::to_string(sum / pose_sizes_.samples) + " B"; }; - text += "\npose size as sent:"; - for (std::size_t i = 0; i < pose_sizes_.sent.size(); ++i) - if (pose_sizes_.sent[i]) - text += std::string(" ") + pose_precision_names[i] + " " + std::to_string(pose_sizes_.sent_bytes[i] / pose_sizes_.sent[i]) + " B x" + - std::to_string(pose_sizes_.sent[i]); - text += "; as it arrives " + each(pose_sizes_.last) + " against the pose before, whole " + each(pose_sizes_.whole); + { + std::uint64_t poses{}; + for (const auto count : pose_sizes_.sent) poses += count; + if (poses) { + text += "\npose sizes:"; + for (std::size_t i = 0; i < pose_sizes_.sent.size(); ++i) + if (pose_sizes_.sent[i]) + text += std::string(" ") + pose_rate_names[i] + " " + std::to_string(pose_sizes_.sent_bytes[i] / pose_sizes_.sent[i]) + " B x" + + std::to_string(pose_sizes_.sent[i]); + text += "; " + std::to_string(pose_sizes_.held * 100 / poses) + "% without fingers; whole poses " + std::to_string(pose_sizes_.whole_sent) + + (pose_sizes_.whole_sent ? " at " + std::to_string(pose_sizes_.whole_sent_bytes / pose_sizes_.whole_sent) + " B" : std::string{}); + } } { // Who uploads most, and what most of it is: one player's outfit or objects can cost @@ -1181,6 +1416,7 @@ void Host::tick(std::uint64_t now) { if (link.connected && !guest->connected_at) guest->connected_at = now_; } for (const auto &message : transport_.receive()) receive(message.peer, message.bytes, message.arrived); + flush_poses(); receive_cosmetics(); introduce(); for (auto it = guests_.begin(); it != guests_.end();) { @@ -1199,6 +1435,19 @@ void Host::tick(std::uint64_t now) { drop(g.member.id, "Timed out waiting for gameplay data.", "nothing for " + std::to_string((now_ - g.last_packet) / 1000000) + " s; " + transport_.link_report(g.member.id)); } + for (auto note : transport_.take_direct_notes()) { + while (!note.empty() && (note.back() == '\n' || note.back() == '\r')) note.pop_back(); + log_("[direct] " + note); + } + // Steam's relay network, whenever what it says of itself changes: every connection goes + // through it, and joins time out while it is not ready. + if (now_ >= next_relay_check_) { + next_relay_check_ = now_ + 10000000; + if (auto status = transport_.relay_status(); status != relay_status_) { + log_("[steam] Relay network: " + status); + relay_status_ = std::move(status); + } + } // Every half minute, what was sent and received in it (net). if (now_ >= traffic_mark_ + 30000000) { const auto roll = [](Counted &c) { diff --git a/Server/server_host.h b/Server/server_host.h index c5b641e1..60d44523 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -4,12 +4,15 @@ #include "speed_check.h" #include "Engine/Game/Multiplayer/chat_rate.h" #include "Extension/Multiplayer/Net/delta_codec.h" +#include "Extension/Multiplayer/Net/pose_batch.h" +#include "Extension/Multiplayer/Net/sound_codec.h" #include "Extension/Multiplayer/Session/object_state.h" #include "Extension/Multiplayer/Session/party_book.h" #include "Extension/Multiplayer/Session/password.h" #include "Extension/Multiplayer/Session/room.h" #include "Extension/Multiplayer/Steam/steam_transport.h" #include "Engine/Game/World/world_layers.h" +#include #include #include #include @@ -39,6 +42,9 @@ class Host { std::string map_name() const; unsigned players() const; std::uint64_t secret() const { return secret_; } + // The UDP port players may connect straight to, or 0 (config connection). + std::uint16_t direct_port() const { return direct_port_; } + const std::string &direct_address() const { return config_.direct_address; } enum class VoteKind { map, kick, time }; @@ -85,6 +91,42 @@ class Host { // and a second (measure_pose). struct Earlier { std::vector raw; std::uint64_t time{}; }; Earlier pose_last, pose_quarter, pose_second; + // Their own last poses, rounded (pose_codec.h): what the others' differences are built + // on. `keep` is how long one stays, by the slowest rate anyone was sent it at: a + // player sent this one once a second must still find it here when their ack arrives. + struct KeptPose { + std::uint32_t sequence{}; + std::uint64_t time_us{}, kept_at{}; + std::uint8_t keep{}; + pose_codec::QuantPose pose; + }; + std::deque kept_poses; + // What this player is being sent of each other one, and what of it they acked. + pose_batch::Sender pose_sender; + // Their own poses arrive the same way (receive_poses): the last ones rebuilt, and which + // of their messages were read in full, which they are told once a pass. + std::unordered_map upload_streams; + pose_batch::Ack upload_ack; + bool upload_ack_due{}; + // Poses due to them this pass (flush_poses). + struct QueuedPose { + std::uint64_t source{}; + std::uint32_t sequence{}; + pose_batch::Rate rate{}; + bool collision{}, hold_fingers{}; + std::uint8_t tier{}; // 0 full rate, 1 half, 2 low, 3 out of sight + }; + std::vector queued_poses; + // Skaters' sound (sound_codec.h): what this player holds of each other one's, the + // samples due to them this pass, and what is held here of their own. + sound_codec::Sender sound_sender; + struct QueuedSound { + std::uint64_t source{}, time_us{}; + std::uint32_t sequence{}; + std::shared_ptr> samples; + }; + std::vector queued_sound; + std::unordered_map sound_in; Transform still_at; std::uint64_t moved_at{}; std::uint64_t pose_arrival{}; @@ -163,6 +205,9 @@ class Host { bool roster_dirty_ = true, running_{}; std::uint64_t bans_revision_ = 1; // bumped whenever the ban list or the admins change std::uint64_t now_{}, last_roster_{}, last_world_state_{}, next_object_update_{}, travel_started_{}; + std::string relay_status_; // Steam's relay network as last logged + std::uint16_t direct_port_{}; + std::uint64_t next_relay_check_{}; std::uint64_t next_crowd_{}; // when the crowd limits are worked out again std::uint64_t next_network_log_{}; // the log's once-a-minute line about the connections // How the server's own loop is keeping up, for `net` and that line: passes of tick() in a @@ -178,9 +223,18 @@ class Host { // smaller: this says by how much, before the server is made to send them that way. struct PoseSizes { std::uint64_t samples{}, whole{}, last{}, quarter{}, second{}; - std::array sent{}, sent_bytes{}; // differences sent, by precision (pose_precision) + std::array sent{}, sent_bytes{}; // differences sent, by rate: full, half, low, out of sight + std::uint64_t whole_sent{}, whole_sent_bytes{}, held{}; // whole poses sent; differences sent without fingers + // Of the poses measured, what had changed since the pose before, by the kind of field: + // how many, and the bytes they take before packing. Says what a pose's size is made of. + struct Changed { std::uint64_t fields{}, bytes{}; }; + std::array changed{}; // positions as floats, positions in mm, rotations, scales + std::uint64_t bones{}; } pose_sizes_; void measure_pose(Guest &from, const Packet &packet); + Guest::KeptPose *keep_pose(Guest &from, const Packet &packet); + void flush_poses(); + void pose_ack(Guest &guest, const pose_batch::Ack &ack); std::uint64_t traffic_mark_{}, traffic_window_us_{}; // when `mark` was taken, and how long `last` covers void meet_later(Guest &guest); void introduce(); @@ -212,7 +266,9 @@ class Host { bool same_map(std::string_view asset) const { return map_hash(map_destination(asset)) == map_; } bool accept_data(Guest &source, const Packet &); // `received_at`: the transport's arrival time for the message (TransportMessage::arrived). - void receive(std::uint64_t peer, std::span bytes, std::uint64_t received_at); + void receive(std::uint64_t peer, std::span bytes, std::uint64_t received_at, const Packet *ready = nullptr); + void receive_poses(std::uint64_t peer, std::span bytes, std::uint64_t received_at); + void receive_sound(std::uint64_t peer, std::span bytes, std::uint64_t received_at); void receive_cosmetics(); void sync_objects(); void apply_layers(); diff --git a/Server/steam_server.cpp b/Server/steam_server.cpp index 532b231b..f6eb162f 100644 --- a/Server/steam_server.cpp +++ b/Server/steam_server.cpp @@ -127,6 +127,8 @@ std::string server_tags(const Advertisement &a) { for (int shift = 60; shift >= 0; shift -= 4) secret += hex[(a.secret >> shift) & 15]; auto tags = "reskate,v" + std::to_string(multiplayer::protocol_version) + ",k" + secret + ",p" + std::to_string(a.players) + ",c" + std::to_string(a.max_players) + (a.password ? ",w1" : ",w0"); + // "d27015", or "d203.0.113.7:27015" for a server reached at another address than it is listed under. + if (a.direct_port) tags += ",d" + (a.direct_address.empty() ? std::string{} : a.direct_address + ":") + std::to_string(a.direct_port); tags += ",m" + tag_text(a.map, 24); tags += ",n" + tag_text(a.name, 127 - tags.size() - 2); return tags; diff --git a/Server/steam_server.h b/Server/steam_server.h index db20889f..2b1ebfff 100644 --- a/Server/steam_server.h +++ b/Server/steam_server.h @@ -12,6 +12,8 @@ struct Advertisement { unsigned players{}, max_players{}; bool password{}, listed{true}; std::uint64_t secret{}; + std::uint16_t direct_port{}; // players may connect straight to this UDP port; 0: relays only + std::string direct_address; // at this address, when it is not the one Steam lists the server under }; std::string server_tags(const Advertisement &);