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:
AyeZeeBB committed 2026-10-07 23:10:13 -05:00
1 parent 98e07f6899
commit 38d86e1b72
27 files changed
+2689 -58

No files matched your search

+6
View File
@@ -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)
+7
View File
@@ -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{};
+458
View File
@@ -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
+361
View File
@@ -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
+1 -1
View File
@@ -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;
+273
View File
@@ -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
+18
View File
@@ -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 &note : s.transport.take_direct_notes())
logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: {}", note);
relay_throwdowns(s, world_playing(s, local));
// A S.K.A.T.E. player waiting for their turn stays off the board.
update_board_lock(client, local.entity, local.ready && throwdown_relay_waits_offboard());
@@ -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;
+178 -8
View File
@@ -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 -1
View File
@@ -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;
}
+4 -1
View File
@@ -31,6 +31,8 @@ void register_multiplayer_commands(Commands &registry) {
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 &registry) {
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, "
+44
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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));
}
+35
View File
@@ -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);
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+2
View File
@@ -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;
+2
View File
@@ -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 &);