mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-18 07:05:15 +02:00
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 <seconds>" 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.
This commit is contained in:
1 parent
98e07f6899
commit
38d86e1b72
27 files changed
+2689
-58
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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<std::string> 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{};
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
#pragma once
|
||||
#include "pose_codec.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
// 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<std::uint8_t, 4> 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<const std::uint8_t> bytes) noexcept {
|
||||
return bytes.size() >= header_size && std::equal(batch_magic.begin(), batch_magic.end(), bytes.begin());
|
||||
}
|
||||
inline bool is_ack(std::span<const std::uint8_t> bytes) noexcept {
|
||||
return bytes.size() == ack_size && std::equal(ack_magic.begin(), ack_magic.end(), bytes.begin());
|
||||
}
|
||||
|
||||
namespace detail {
|
||||
inline void put(std::vector<std::uint8_t> &out, std::uint64_t value, unsigned bytes) {
|
||||
for (unsigned i = 0; i < bytes; ++i) out.push_back(static_cast<std::uint8_t>(value >> (8 * i)));
|
||||
}
|
||||
inline void varint(std::vector<std::uint8_t> &out, std::uint64_t value) {
|
||||
while (value >= 0x80) {
|
||||
out.push_back(static_cast<std::uint8_t>(value | 0x80));
|
||||
value >>= 7;
|
||||
}
|
||||
out.push_back(static_cast<std::uint8_t>(value));
|
||||
}
|
||||
struct Reader {
|
||||
std::span<const std::uint8_t> 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<const std::uint8_t> 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<std::uint64_t>(value) << 1) ^ static_cast<std::uint64_t>(value >> 63);
|
||||
}
|
||||
constexpr std::int64_t unzigzag(std::uint64_t value) noexcept { return static_cast<std::int64_t>(value >> 1) ^ -static_cast<std::int64_t>(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<const std::uint8_t> 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<std::uint8_t> begin(std::uint32_t number, std::uint64_t world, std::uint64_t map) {
|
||||
std::vector<std::uint8_t> 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<std::uint8_t> &out, const Entry &entry) {
|
||||
detail::varint(out, entry.stream);
|
||||
out.push_back(static_cast<std::uint8_t>((entry.whole ? 1U : 0U) | (entry.collision ? 2U : 0U) | (static_cast<unsigned>(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<Entry> 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<Batch> read(std::span<const std::uint8_t> bytes) noexcept {
|
||||
try {
|
||||
if (!is_batch(bytes)) return {};
|
||||
detail::Reader r{bytes, 4};
|
||||
Batch batch;
|
||||
batch.number = static_cast<std::uint32_t>(r.get(4));
|
||||
batch.world = static_cast<std::uint32_t>(r.get(4));
|
||||
batch.map = static_cast<std::uint32_t>(r.get(4));
|
||||
while (r.at < bytes.size()) {
|
||||
Entry entry;
|
||||
const auto stream = r.varint();
|
||||
if (stream > 0xffff) return {};
|
||||
entry.stream = static_cast<std::uint16_t>(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<Rate>((flags >> 2) & 3);
|
||||
if (entry.whole) {
|
||||
entry.source = r.get(8);
|
||||
entry.epoch = r.get(8);
|
||||
entry.sequence = static_cast<std::uint32_t>(r.get(4));
|
||||
entry.time_us = r.get(8);
|
||||
} else {
|
||||
entry.reference = static_cast<std::uint16_t>(r.get(2));
|
||||
const auto ahead = r.varint();
|
||||
if (!ahead || ahead > 0xffffff) return {};
|
||||
entry.ahead = static_cast<std::uint32_t>(ahead);
|
||||
entry.time_step = detail::unzigzag(r.varint());
|
||||
}
|
||||
const auto length = r.varint();
|
||||
if (length > bytes.size()) return {};
|
||||
entry.pose = r.take(static_cast<std::size_t>(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<std::uint8_t, ack_size> bytes() const noexcept {
|
||||
std::array<std::uint8_t, ack_size> out{};
|
||||
std::copy(ack_magic.begin(), ack_magic.end(), out.begin());
|
||||
for (unsigned i = 0; i < 4; ++i) out[4 + i] = static_cast<std::uint8_t>(newest >> (8 * i));
|
||||
for (unsigned i = 0; i < 8; ++i) out[8 + i] = static_cast<std::uint8_t>(earlier >> (8 * i));
|
||||
return out;
|
||||
}
|
||||
static std::optional<Ack> read(std::span<const std::uint8_t> 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> held;
|
||||
const Held *find_low(std::uint16_t low) const noexcept {
|
||||
for (auto it = held.rbegin(); it != held.rend(); ++it)
|
||||
if (static_cast<std::uint16_t>(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 <typename Streams> std::optional<Rebuilt> 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<std::uint64_t>(static_cast<std::int64_t>(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 <typename Find, typename Emit>
|
||||
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<KeptView> 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<std::uint8_t> 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<std::uint16_t>(reference->sequence);
|
||||
entry.ahead = pose.sequence - reference->sequence;
|
||||
entry.time_step = static_cast<std::int64_t>(pose.time_us) - static_cast<std::int64_t>(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 <typename Emit> 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<SentPose> 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 <typename Emit> void send(Emit &&emit, bool reliable) {
|
||||
if (batch_.poses.empty()) return;
|
||||
if (emit(std::span<const std::uint8_t>(message_), reliable)) {
|
||||
sent_.push_back(std::move(batch_));
|
||||
while (sent_.size() > 256) sent_.pop_front();
|
||||
}
|
||||
batch_ = {};
|
||||
message_.clear();
|
||||
}
|
||||
std::map<std::uint64_t, Out> streams_;
|
||||
std::deque<SentBatch> sent_;
|
||||
std::vector<std::uint8_t> 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
|
||||
@@ -0,0 +1,361 @@
|
||||
#pragma once
|
||||
#include "protocol.h"
|
||||
#include <algorithm>
|
||||
#include <bit>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
// 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<QuantBone> 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<float>(1U << rotation_shift);
|
||||
const auto most = static_cast<long>(32767 >> rotation_shift);
|
||||
bone.largest = static_cast<std::uint8_t>(largest);
|
||||
unsigned out{};
|
||||
for (unsigned i = 0; i < 4; ++i)
|
||||
if (i != largest) bone.rotation[out++] = static_cast<std::int16_t>(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<double>(t.position[i]) * 1000.0) : 0LL;
|
||||
bone.position[i] = static_cast<std::int32_t>(std::clamp<long long>(mm, -position_limit, position_limit));
|
||||
}
|
||||
bone.scaled = t.scale != std::array<float, 3>{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<float>(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<float>(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<float>(static_cast<double>(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<std::uint16_t>(pose.skater.size());
|
||||
out.board = static_cast<std::uint16_t>(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<std::uint8_t> 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<std::uint8_t>(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<unsigned>(std::bit_width(value));
|
||||
put(0, width - 1);
|
||||
put(1, 1);
|
||||
put(value, width - 1);
|
||||
}
|
||||
std::vector<std::uint8_t> finish() {
|
||||
if (held) bytes.push_back(static_cast<std::uint8_t>(hold));
|
||||
hold = held = 0;
|
||||
return std::move(bytes);
|
||||
}
|
||||
};
|
||||
struct BitReader {
|
||||
std::span<const std::uint8_t> 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::uint32_t>((std::uint64_t{1} << (width - 1)) | get(width - 1));
|
||||
}
|
||||
};
|
||||
constexpr std::uint32_t zigzag(std::int64_t value) noexcept {
|
||||
return static_cast<std::uint32_t>(value >= 0 ? static_cast<std::uint64_t>(value) * 2 : static_cast<std::uint64_t>(-value) * 2 - 1);
|
||||
}
|
||||
constexpr std::int64_t unzigzag(std::uint64_t value) noexcept {
|
||||
return (value & 1) ? -static_cast<std::int64_t>((value + 1) / 2) : static_cast<std::int64_t>(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<unsigned>(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<unsigned>(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<std::uint8_t>(r.get(2));
|
||||
for (auto &component : bone.rotation) component = static_cast<std::int16_t>(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<std::uint32_t>(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<std::uint32_t>(r.get(16));
|
||||
axis = std::bit_cast<float>(low | (static_cast<std::uint32_t>(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<std::uint8_t> 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<std::uint8_t> 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<unsigned>(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<std::uint32_t>(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<const std::uint8_t> 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<QuantPose> decode(std::span<const std::uint8_t> 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<std::uint16_t>(r.get(10));
|
||||
pose.board = static_cast<std::uint16_t>(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::int32_t>(std::clamp<std::int64_t>(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<Fingers>(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::int16_t>(std::clamp<std::int64_t>(component + detail::small(r, 4), INT16_MIN, INT16_MAX));
|
||||
});
|
||||
list([&](QuantBone &bone) {
|
||||
for (auto &mm : bone.position) mm = static_cast<std::int32_t>(std::clamp<std::int64_t>(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::int32_t>(std::clamp<std::int64_t>(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
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
#pragma once
|
||||
#include "Extension/Multiplayer/Remote/audio_state.h"
|
||||
#include "pose_batch.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
// 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<std::uint8_t, 4> 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<const std::uint8_t> bytes) noexcept {
|
||||
return bytes.size() >= header_size && std::equal(magic.begin(), magic.end(), bytes.begin());
|
||||
}
|
||||
|
||||
struct QuantState {
|
||||
std::array<std::int32_t, audio_float_count> values{};
|
||||
std::array<std::uint32_t, audio_selector_count> 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<double>(state.values[i]) : 0.0;
|
||||
auto rounded = std::llround(std::clamp(value * static_cast<double>(value_scale), -static_cast<double>(value_limit), static_cast<double>(value_limit)));
|
||||
if (!rounded && value != 0.0) rounded = value > 0 ? 1 : -1;
|
||||
out.values[i] = static_cast<std::int32_t>(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<float>(static_cast<double>(state.values[i]) / static_cast<double>(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<std::uint8_t> &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<std::uint32_t>(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<std::int32_t>(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<std::uint32_t>(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<const AudioSample> 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<std::uint8_t>((whole ? 1U : 0U) | (static_cast<unsigned>(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<std::int64_t>(time_us) - static_cast<std::int64_t>(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<const std::uint8_t> 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<std::uint64_t, Out> streams_, staged_;
|
||||
std::vector<std::uint8_t> 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<AudioSample> 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 <typename Streams>
|
||||
std::optional<std::vector<Heard>> read(Streams &streams, std::span<const std::uint8_t> bytes, std::uint64_t world, std::uint64_t map) noexcept {
|
||||
try {
|
||||
if (!is_sound(bytes)) return {};
|
||||
detail::Reader r{bytes, 4};
|
||||
std::vector<Heard> 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<std::size_t>(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<std::uint32_t>(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<std::uint16_t>(stream)); found != streams.end()) {
|
||||
in = found->second;
|
||||
known = true;
|
||||
}
|
||||
in.sequence += static_cast<std::uint32_t>(ahead);
|
||||
in.time_us = static_cast<std::uint64_t>(static_cast<std::int64_t>(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<std::uint16_t>(stream)] = in;
|
||||
out.push_back(std::move(heard));
|
||||
if (out.size() > 512) return {};
|
||||
}
|
||||
return out;
|
||||
} catch (...) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
} // namespace dingosdk::multiplayer::sound_codec
|
||||
@@ -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<float>(), 10.f, 500.f);
|
||||
s.nametag_dots = profile_runtime::local_preference("NametagDots").value_or(true);
|
||||
s.prefer_direct = profile_runtime::local_preference("DirectConnections").value_or(true);
|
||||
if (const auto saved = profile_runtime::local_value("PlayerDistance"); saved && saved->is_number())
|
||||
s.player_distance = std::clamp(saved->get<float>(), player_distance_least, player_distance_unlimited);
|
||||
s.nametags_friends = profile_runtime::local_preference("NametagsFriendsOnly").value_or(false);
|
||||
@@ -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());
|
||||
|
||||
@@ -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<int>(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 <seconds> (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<std::uint64_t>(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<std::uint16_t>(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;
|
||||
|
||||
@@ -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 <algorithm>
|
||||
#include <fstream>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <unordered_map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
@@ -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<std::uint16_t, pose_batch::Stream> 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<pose_batch::Stream::Held> own_poses;
|
||||
// Skaters' sound to and from a dedicated server (sound_codec.h).
|
||||
std::unordered_map<std::uint16_t, sound_codec::In> 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)
|
||||
|
||||
@@ -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<Packet> unpack_poses(Session &s, std::span<const std::uint8_t> bytes) {
|
||||
std::vector<Packet> out;
|
||||
const auto batch = pose_batch::read(bytes);
|
||||
if (!batch || batch->world != static_cast<std::uint32_t>(s.world) || batch->map != static_cast<std::uint32_t>(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<Packet> unpack_sound(Session &s, std::span<const std::uint8_t> bytes) {
|
||||
std::vector<Packet> 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<bool> stale_pose_deltas(Session &s, const std::vector<TransportMessage> &messages) {
|
||||
std::vector<bool> 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<Packet> 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<Packet> 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];
|
||||
|
||||
@@ -258,6 +258,25 @@ void send_throwdown(Session &s, std::vector<std::uint8_t> 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<pose_batch::KeptView> {
|
||||
for (auto it = kept.rbegin(); it != kept.rend(); ++it)
|
||||
if (it->sequence == sequence) return pose_batch::KeptView{it->sequence, it->time_us, &it->pose};
|
||||
return {};
|
||||
};
|
||||
const auto emit = [&](std::span<const std::uint8_t> message, bool reliable) {
|
||||
return s.transport.send(s.host_id, message, reliable, !reliable, TrafficLane::gameplay);
|
||||
};
|
||||
s.pose_upload.begin(s.world, s.map);
|
||||
s.pose_upload.add(packet.source, packet.epoch, *find(packet.sequence), find, false, packet.player_collision, pose_batch::Rate::full, now, emit);
|
||||
s.pose_upload.flush(emit);
|
||||
}
|
||||
void broadcast(Session &s, const Packet &packet, bool reliable, bool fresh, std::uint64_t now,
|
||||
std::uint64_t except) {
|
||||
const auto *source = find_peer(s, packet.source);
|
||||
@@ -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<std::uint32_t>(raw.size());
|
||||
s.pose_dump.write(reinterpret_cast<const char *>(&time), sizeof time);
|
||||
s.pose_dump.write(reinterpret_cast<const char *>(&length), sizeof length);
|
||||
s.pose_dump.write(reinterpret_cast<const char *>(raw.data()), static_cast<std::streamsize>(raw.size()));
|
||||
++s.pose_dump_count;
|
||||
if (now >= s.pose_dump_until) {
|
||||
s.pose_dump.close();
|
||||
logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: pose dump finished, {} poses.", s.pose_dump_count);
|
||||
}
|
||||
}
|
||||
deliver(p, false, true);
|
||||
auto samples = drain_audio_capture(now);
|
||||
if (!samples.empty()) {
|
||||
auto a = packet(s, PacketKind::audio, now);
|
||||
a.audio = std::move(samples);
|
||||
if (s.pose_dump.is_open()) {
|
||||
// The skater's sound is recorded with the poses, in the same records.
|
||||
const auto raw = encode(a, true);
|
||||
const std::uint64_t time = a.time_us;
|
||||
const auto length = static_cast<std::uint32_t>(raw.size());
|
||||
s.pose_dump.write(reinterpret_cast<const char *>(&time), sizeof time);
|
||||
s.pose_dump.write(reinterpret_cast<const char *>(&length), sizeof length);
|
||||
s.pose_dump.write(reinterpret_cast<const char *>(raw.data()), static_cast<std::streamsize>(raw.size()));
|
||||
}
|
||||
deliver(a, std::any_of(a.audio.begin(), a.audio.end(), [](const auto &sample) { return sample.event; }), true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -127,6 +127,30 @@ std::optional<MultiplayerLobby> 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<std::uint32_t>(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;
|
||||
|
||||
@@ -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<std::uint64_t, Link> 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<SteamNetworkingConfigValue_t, 5> direct_options() {
|
||||
std::array<SteamNetworkingConfigValue_t, 5> 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<int>(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<std::string> 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<SteamNetConnectionStatusChangedCallback_t> events;
|
||||
static inline std::atomic<Impl *> 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<void *>(&changed));
|
||||
return option;
|
||||
}
|
||||
std::array<SteamNetworkingConfigValue_t, 5> options() {
|
||||
std::array<SteamNetworkingConfigValue_t, 5> out{};
|
||||
std::array<SteamNetworkingConfigValue_t, 6> options() {
|
||||
std::array<SteamNetworkingConfigValue_t, 6> 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<decltype(p.listen_ip)>(module, "SteamAPI_ISteamNetworkingSockets_CreateListenSocketIP");
|
||||
p.connect_ip = symbol<decltype(p.connect_ip)>(module, "SteamAPI_ISteamNetworkingSockets_ConnectByIPAddress");
|
||||
} catch (...) {
|
||||
p.listen_ip = nullptr;
|
||||
p.connect_ip = nullptr;
|
||||
}
|
||||
try {
|
||||
p.debug_output = symbol<decltype(p.debug_output)>(module, "SteamAPI_ISteamNetworkingUtils_SetDebugOutputFunction");
|
||||
} catch (...) {
|
||||
p.debug_output = nullptr;
|
||||
}
|
||||
try {
|
||||
p.relay_network = symbol<decltype(p.relay_network)>(module, "SteamAPI_ISteamNetworkingUtils_GetRelayNetworkStatus");
|
||||
} catch (...) {
|
||||
p.relay_network = nullptr;
|
||||
}
|
||||
try {
|
||||
p.set_config = symbol<decltype(p.set_config)>(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<int>(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<int>(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<std::string> 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<std::string> 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<int>(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<int>(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<int>(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<TransportLink> 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<const std::uint8_t> 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<uint32>(bytes.size()), flags, nullptr);
|
||||
@@ -758,7 +928,7 @@ void SteamTransport::send_batch(std::span<TransportSend> 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.
|
||||
|
||||
@@ -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<std::string> 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<const Member>);
|
||||
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);
|
||||
|
||||
@@ -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 <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <deque>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <random>
|
||||
#include <unordered_map>
|
||||
|
||||
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<float> 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<float> any(-amount, amount);
|
||||
std::uniform_int_distribution<std::size_t> 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<float>(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<float, 3>{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<std::size_t>(1, source.size() / 97)) {
|
||||
const std::vector<std::uint8_t> part(source.begin(), source.begin() + static_cast<std::ptrdiff_t>(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<std::uint8_t>(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<std::uint8_t> noise(300);
|
||||
for (unsigned round = 0; round < 2000; ++round) {
|
||||
for (auto &byte : noise) byte = static_cast<std::uint8_t>(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<double> 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<Stream::Held> kept;
|
||||
unsigned every{1}; // sent every this many ticks
|
||||
};
|
||||
std::array<Source, sources> 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<std::pair<std::size_t, std::uint32_t>, QuantPose> truth;
|
||||
Sender sender;
|
||||
std::unordered_map<std::uint16_t, Stream> streams;
|
||||
Ack ack;
|
||||
bool ack_due{};
|
||||
struct Flying {
|
||||
unsigned arrives{};
|
||||
std::vector<std::uint8_t> bytes;
|
||||
};
|
||||
std::vector<Flying> to_receiver, to_sender;
|
||||
const auto delay = [&] { return delay_least + static_cast<unsigned>(random() % (delay_most - delay_least + 1)); };
|
||||
LinkResult result;
|
||||
std::array<unsigned, sources> 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<const std::uint8_t> 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<KeptView> {
|
||||
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<std::size_t>(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<std::size_t>(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<double>(r.rebuilt) > static_cast<double>(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<std::uint8_t> 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<std::uint8_t>(round % 3 ? random() : random() % 4);
|
||||
if (const auto batch = pose_batch::read(junk)) {
|
||||
std::unordered_map<std::uint16_t, pose_batch::Stream> 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<QuantPose> poses;
|
||||
for (;;) {
|
||||
std::uint64_t time{};
|
||||
std::uint32_t length{};
|
||||
if (!in.read(reinterpret_cast<char *>(&time), 8) || !in.read(reinterpret_cast<char *>(&length), 4) || length > 1 << 20) break;
|
||||
std::vector<std::uint8_t> raw(length);
|
||||
if (!in.read(reinterpret_cast<char *>(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<double>(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<double, std::micro>(std::chrono::steady_clock::now() - started).count() / static_cast<double>(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<float> 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<std::uint32_t>(random());
|
||||
return {age, state, random() % 3 == 0};
|
||||
}
|
||||
void sound_checks() {
|
||||
using namespace sound_codec;
|
||||
std::mt19937 random(41);
|
||||
Sender sender;
|
||||
std::unordered_map<std::uint16_t, In> streams;
|
||||
std::array<AudioState, 3> 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<std::vector<AudioSample>, 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<std::uint8_t> 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<std::size_t>(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<AudioSample> one{sound_sample(random, states[0], 5000)};
|
||||
sender.add(500, 9, 700, 1, one, 600 * 33333 + 1);
|
||||
std::vector<std::uint8_t> message(sender.message().begin(), sender.message().end());
|
||||
sender.sent(true);
|
||||
std::unordered_map<std::uint16_t, In> 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<std::uint8_t>(1U << (random() % 8));
|
||||
std::unordered_map<std::uint16_t, In> 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<std::uint16_t, In> 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<char *>(&time), 8) || !in.read(reinterpret_cast<char *>(&length), 4) || length > 1 << 20) break;
|
||||
std::vector<std::uint8_t> raw(length);
|
||||
if (!in.read(reinterpret_cast<char *>(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<std::uint8_t> 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<double>(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<double>(old_bytes) / seconds / 1024 << " KB/s\n"
|
||||
<< " new format: " << new_bytes / messages << " B a message, " << static_cast<double>(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<char *>(&time), 8) || !in.read(reinterpret_cast<char *>(&length), 4) || length > 1 << 20) break;
|
||||
std::vector<std::uint8_t> raw(length);
|
||||
if (!in.read(reinterpret_cast<char *>(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<const char *>(head), sizeof head);
|
||||
out.write(reinterpret_cast<const char *>(&time), 8);
|
||||
out.write(reinterpret_cast<const char *>(sample.state.values.data()), sizeof sample.state.values);
|
||||
out.write(reinterpret_cast<const char *>(sample.state.selectors.data()), sizeof sample.state.selectors);
|
||||
out.write(reinterpret_cast<const char *>(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;
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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<char, 65> 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, "
|
||||
|
||||
@@ -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 <KB/s>, 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
|
||||
|
||||
@@ -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");
|
||||
|
||||
+77
-1
@@ -15,6 +15,8 @@
|
||||
#include "Engine/Game/World/world_names.h"
|
||||
#include "Engine/Vfs/world_layer_scan.h"
|
||||
#ifdef _WIN32
|
||||
#include <charconv>
|
||||
#include <cstdlib>
|
||||
#include <Windows.h>
|
||||
#include <timeapi.h>
|
||||
#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
|
||||
// "<its SteamID64>,<its address a.b.c.d>,<its port>". 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 <SteamID64>,<a.b.c.d>,<port>.");
|
||||
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<std::uint16_t>(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));
|
||||
}
|
||||
|
||||
@@ -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<std::str
|
||||
c.reserved_slots = root.value("reserved_slots", c.reserved_slots);
|
||||
c.crowd_budget = root.value("crowd_budget", c.crowd_budget);
|
||||
c.bone_scale_limit = root.value("bone_scale_limit", c.bone_scale_limit);
|
||||
c.connection = root.value("connection", c.connection);
|
||||
c.direct_address = root.value("direct_address", c.direct_address);
|
||||
c.steam_debug = root.value("steam_debug", c.steam_debug);
|
||||
c.relay_everything = root.value("relay_everything", c.relay_everything);
|
||||
c.direct_port = root.value("direct_port", c.direct_port);
|
||||
c.pack_ms = root.value("pack_ms", c.pack_ms);
|
||||
c.finger_distance = root.value("finger_distance", c.finger_distance);
|
||||
c.send_rate = root.value("send_rate", c.send_rate);
|
||||
if (root.contains("reserved") && root.at("reserved").is_array())
|
||||
for (const auto &id : root.at("reserved")) c.reserved.push_back(steam_id(id));
|
||||
@@ -262,6 +276,11 @@ std::string config_error(const ServerConfig &c) {
|
||||
if (c.send_rate < 128 || c.send_rate > 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);
|
||||
|
||||
@@ -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<std::uint64_t> 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 ("<root>|<level>").
|
||||
// Empty when the map is unknown (a full level path is always accepted).
|
||||
std::string map_destination(std::string_view map);
|
||||
|
||||
+287
-38
@@ -140,10 +140,29 @@ unsigned Host::players() const {
|
||||
|
||||
bool Host::start(std::string &error) {
|
||||
transport_.set_send_rate(static_cast<int>(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<unsigned, 4> pose_precision{4, 6, 7, 11};
|
||||
constexpr std::array<const char *, 4> 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<const char *, 4> 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<std::uint64_t, 4> pose_kept_for{1500000, 3000000, 5000000, 15000000};
|
||||
constexpr std::array<const char *, 6> 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<std::size_t>(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<const std::uint8_t> 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<pose_batch::KeptView> {
|
||||
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<std::size_t>(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;
|
||||
outgoing.reserve(guests_.size());
|
||||
std::shared_ptr<const std::vector<AudioSample>> 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<const std::vector<AudioSample>>(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<float>(config_.distances.low_rate_start) * (delivery->precision == 3 ? 1.4f : 1.5f);
|
||||
const auto sight = static_cast<float>(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<float>(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<std::uint8_t>((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<std::uint8_t>(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<const std::uint8_t> 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<const std::uint8_t> 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<std::uint32_t>(world_) || batch->map != static_cast<std::uint32_t>(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<const std::uint8_t> 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<const std::uint8_t> 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<Packet>(*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<std::string, unsigned> 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<std::pair<std::string, unsigned>> 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<std::size_t>(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) {
|
||||
|
||||
+58
-2
@@ -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 <unordered_map>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
@@ -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<std::uint8_t> 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<KeptPose> 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<std::uint16_t, pose_batch::Stream> 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<QueuedPose> 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<const std::vector<AudioSample>> samples;
|
||||
};
|
||||
std::vector<QueuedSound> queued_sound;
|
||||
std::unordered_map<std::uint16_t, sound_codec::In> 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<std::uint64_t, 4> sent{}, sent_bytes{}; // differences sent, by precision (pose_precision)
|
||||
std::array<std::uint64_t, 4> 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, 4> 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<const std::uint8_t> bytes, std::uint64_t received_at);
|
||||
void receive(std::uint64_t peer, std::span<const std::uint8_t> bytes, std::uint64_t received_at, const Packet *ready = nullptr);
|
||||
void receive_poses(std::uint64_t peer, std::span<const std::uint8_t> bytes, std::uint64_t received_at);
|
||||
void receive_sound(std::uint64_t peer, std::span<const std::uint8_t> bytes, std::uint64_t received_at);
|
||||
void receive_cosmetics();
|
||||
void sync_objects();
|
||||
void apply_layers();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 &);
|
||||
|
||||
|
||||
Reference in new issue
Block a user