Files
ReSkate/Extension/Multiplayer/Test/bandwidth_tests.cpp
T
AyeZeeBB c98c3f3207 Keep busy dedicated servers playable; hold stale skaters instead of rebuilding them
No protocol change: a server built from this still takes 1.1.4 players.

Server: what each player is sent
- A crowd limit: each player is sent at most crowd_budget poses a second
  (default 600, 0 for none; "crowd <n>|off"). Everyone at 5 a second at
  least, the nearest at the full rate, the next nearest at 10. Before,
  everyone within full-rate distance was sent at the full rate, which a
  crowd of 40 turned into more than a connection carries
- A skater's sound goes only to players within the full-rate distance,
  and in a crowd only from the nearest; it went to everyone
- A player who joins, or arrives after a map change, is shown the others
  about seven a second, nearest first, and none while what they were
  already sent is still on its way. Before, every outfit and every
  stream went at once (and to everyone at once after a map change)
- Whole pose and sound states every 8 seconds instead of 2, on the
  source's own clock, and slower pose rates sent on that clock too, so
  recipients share one patch and compression (DeltaCache) instead of one
  each. The settings survive a map change
- Object updates wait for a connection that is behind

Server: Steam connections
- send_rate (default 900 KB/s, "rate <KB/s>") is set as both Steam's
  minimum and maximum, for open connections and each new one. With only
  the maximum raised a connection stayed at Steam's 256 KB/s; set high,
  what was lost was resent until the connection was full of resends
  (about 1.1 MB/s reaches a player through the relays)
- A 4 MB send buffer, and a refused must-arrive message no longer ends
  the player's session at once: 15 seconds of refusals do (30 for object
  updates). A full queue used to drop everyone who was behind together

Server: seeing what is going on
- "net [player]": every connection's ping, delivery, queue and rate, the
  traffic by kind (poses, sound, voice, outfits, objects), who uploads
  most, and how busy the server's loop is
- A [network] line in the log every minute
- A leave or failed join says how it ended: closed by the player's game
  with Steam's reason, connection lost, or closed by the server and why
- The queue time no longer prints Steam's "unknown" as a number

Server: other
- Reserved slots: reserved_slots and reserved in ReSkateServer.json, and
  the "reserved" command; admins always have one
- The speed check flags a game at about 1.5x and over (was 1.06x), and
  tells only that player; for the admins it is in the log

Game
- A remote skater with no pose for a second stays where it was for up to
  eight seconds instead of being destroyed and rebuilt three seconds
  later. On a busy server one slow frame did that to many players at
  once, and the rebuilding made the next frames slower still
- Every waiting message is read each frame (at most 128 were)
- On an official server the role tags (Dev, Staff, Homie, Creator) show
  for every player, not only the few the game is connected to directly;
  community servers keep the old rule
- The server browser searches Steam at most every 15 seconds (45 after an
  empty answer) and keeps the last list for 15 minutes when Steam returns
  nothing, up from 2
- The game's own connections send at up to 900 KB/s
2026-10-07 14:43:45 -05:00

219 lines
13 KiB
C++

#include "Extension/Multiplayer/Remote/audio_capture.h"
#include "Extension/Multiplayer/Net/delta_codec.h"
#include "Extension/Multiplayer/Net/pose_delta.h"
#include "Extension/Multiplayer/Session/room.h"
#include "Extension/Multiplayer/Net/block_codec.h"
#include <bit>
#include <iostream>
#include <random>
using namespace dingosdk::multiplayer;
namespace {
void check(bool value, const char *why) { if (!value) throw std::runtime_error(why); }
Packet fixture() {
Packet p;
p.session = 73; p.epoch = 91; p.map = 42; p.source = 200;
p.sequence = 1; p.time_us = 1000000;
p.pose.skater.resize(395); p.pose.board.resize(17);
for (std::size_t i = 0; i < p.pose.skater.size(); ++i) {
auto &t = p.pose.skater[i];
t.position = {static_cast<float>(i) * .004f, .11f, .07f};
t.rotation = {.1f, .2f, .3f, .9f};
}
return p;
}
void pose_checks() {
auto p = fixture();
const auto reference = encode(p, true);
std::mt19937 random(871);
DeltaSender tx; DeltaReceiver rx;
bool missing{};
auto base = tx.prepare(p);
check(rx.receive(base.bytes, missing).has_value(), "Initial reliable reference rejected");
tx.sent(p, std::move(base));
unsigned sparse{};
for (unsigned frame = 0; frame < 100; ++frame) {
p.pose_interval_us = dingosdk::multiplayer_pose_interval(dingosdk::multiplayer_tick_rates[(frame / 25) % 4]);
++p.sequence; p.time_us += 50000;
p.pose.root.position[0] += .03f;
for (unsigned edit = 0; edit < 8; ++edit) {
auto &t = p.pose.skater[random() % p.pose.skater.size()];
t.position[random() % 3] = frame % 2 ? 40.23f : .12f;
t.rotation = frame % 2 ? std::array<float, 4>{.9f, .1f, .2f, .3f} : std::array<float, 4>{.1f, .2f, .3f, .9f};
t.scale = frame % 3 ? std::array<float, 3>{1, 1, 1} : std::array<float, 3>{1.1f, .9f, 1.2f};
}
p.pose.board[0].position[1] += .025f;
const auto raw = encode(p, true), patch = pose_delta::encode(raw, reference);
check(pose_delta::decode(patch, reference, raw.size()) == raw, "Sparse pose changed packed bytes");
if (frame == 0) {
for (std::size_t n = 0; n < patch.size(); ++n) {
bool rejected{};
try { (void)pose_delta::decode(std::span(patch).first(n), reference, raw.size()); }
catch (...) { rejected = true; }
check(rejected, "Truncated pose patch accepted");
}
auto extra = patch; extra.push_back(0);
bool rejected{};
try { (void)pose_delta::decode(extra, reference, raw.size()); } catch (...) { rejected = true; }
check(rejected, "Trailing pose patch bytes accepted");
}
auto update = tx.prepare(p);
sparse += update.bytes.size() > 4 && update.bytes[2] == 'S';
if (frame % 4 || update.establishes_baseline()) {
const auto decoded = rx.receive(update.bytes, missing);
check(decoded && encode(*decoded) == encode(*decode(raw)), "Loss changed skater/board transforms");
}
tx.sent(p, std::move(update));
}
check(sparse > 0, "Sparse field candidate never selected");
// Reference changes must survive missing frames, roster changes and travel.
p.pose.board.clear(); ++p.sequence; p.time_us += 50000;
auto changed = tx.prepare(p);
check(rx.receive(changed.bytes, missing)->pose.board.empty(), "Board disappearance lost");
tx.sent(p, std::move(changed));
++p.world; ++p.sequence; p.time_us += 50000;
auto world = tx.prepare(p);
check(world.establishes_baseline(), "New world reused an old reference");
DeltaReceiver late;
check(late.receive(world.bytes, missing, p.world).has_value(), "New world reference rejected");
tx.sent(p, std::move(world));
++p.sequence; p.time_us += 50000;
auto next = tx.prepare(p);
check(late.receive(next.bytes, missing, p.world).has_value(), "New world delta rejected");
std::cout << "Sparse poses: exact packed bytes, width/scale/rotation changes, board removal, loss, travel and malformed data passed.\n";
}
void audio_checks() {
AudioCaptureBuffer capture;
AudioState state;
for (unsigned i = 0; i < 12; ++i) {
state.values[1] = static_cast<float>(i);
if (i == 4) state.flags[2] = 1;
if (i == 5) state.flags[2] = 0;
if (i == 7) state.selectors[3] = 9;
capture.push(state, 1000000 + i * 4000);
}
auto p = fixture(); p.kind = PacketKind::audio; p.time_us = 1050000;
p.audio = capture.drain(p.time_us);
check(p.audio.size() == 7, "Continuous samples not coalesced around action edges");
check(std::count_if(p.audio.begin(), p.audio.end(), [](const auto &v) { return v.event; }) == 4,
"Capture lost a contact or selector edge");
p.audio.back().state.values[57] = -0.f;
const auto raw = encode(p), wire = encode_wire(p);
const auto decoded = decode_wire(wire);
check(decoded && encode(*decoded) == raw, "Sparse sound changed fields, ages or event bits");
for (std::size_t n = 0; n < raw.size(); ++n)
check(!decode(std::span(raw).first(n)), "Truncated sparse audio accepted");
AudioBuffer playback;
Packet continuous = p; continuous.sequence = 3; continuous.time_us = 1100000;
continuous.audio = {{0, state, false}};
check(playback.push(p, 2050000), "First sound batch rejected");
check(playback.push(continuous, 2100000), "Continuous sound rejected");
while (playback.size()) (void)playback.sample(2200000);
Packet late = continuous; late.sequence = 2; late.time_us = 1075000;
late.audio[0].event = true; late.audio[0].state.flags[2] = 1;
check(playback.push(late, 2210000), "Reliable event rejected behind continuous state");
check(playback.sample(2210000)->flags[2] == 1, "Late contact edge not played");
check(playback.sample(2211000) == state, "Late edge rewound continuous sound");
check(!playback.push(late, 2211000), "Relay copy replayed an event");
check(!playback.sample(3210001), "Disconnected sound did not stop");
capture.push(state, 1100000);
check(capture.drain(1100000).empty(), "Unchanged audio was not suppressed");
capture.push(state, 1350000);
check(!capture.drain(1350000).empty(), "Idle audio heartbeat missing");
capture.clear();
capture.push(state, 1400000);
state.values[20] = 8.f; capture.push(state, 1401000);
state.values[20] = 0.f; capture.push(state, 1402000);
const auto impulse = capture.drain(1450000);
check(impulse.size() == 3 && impulse[1].event && impulse[1].state.values[20] == 8.f,
"Unknown scalar impulse was treated as continuous motion");
check(raw.size() < packet_header_size + 2 + p.audio.size() * 383, "Sparse audio failed to shrink fixture");
std::cout << "Audio: continuous coalescing, contact/selector edges, exact values, idle heartbeat, reordering, deduplication and disconnect passed.\n";
}
void rate_checks() {
check(pose_interval(61*61, 50000) == 100000 && pose_interval(56*56, 100000) == 100000 &&
pose_interval(49*49, 100000) == 50000, "Near-rate hysteresis failed");
check(pose_interval(171*171, 100000) == 200000 && pose_interval(160*160, 200000) == 200000 &&
pose_interval(149*149, 200000) == 100000, "Far-rate hysteresis failed");
{
// A crowd: no limit while everyone fits at the full rate, then the nearest at full,
// the next at half, the rest at low, within the budget.
std::vector<float> few(20), crowd(49), packed(127);
for (std::size_t i = 0; i < packed.size(); ++i) {
const auto d = static_cast<float>((packed.size() - i) * (packed.size() - i)); // farthest first
packed[i] = d;
if (i < crowd.size()) crowd[i] = d;
if (i < few.size()) few[i] = d;
}
const auto none = crowd_limits(few, 30);
// The default leaves twenty players in one place at the full rate, each to each.
std::vector<float> twenty(crowd.begin(), crowd.begin() + 19);
check(crowd_limits(twenty, 30).half == none.half && dingosdk::valid_crowd_budget(0) &&
dingosdk::valid_crowd_budget(dingosdk::crowd_pose_budget) && !dingosdk::valid_crowd_budget(50),
"The default crowd budget slowed twenty players, or a bad one was valid");
check(crowd_interval(33333, 1e9f, none) == 33333, "A small group was limited");
const auto count = [](std::span<const float> all, const CrowdLimits &limits, unsigned tps) {
unsigned sent{};
for (const auto d : all) sent += 1000000U / crowd_interval(dingosdk::multiplayer_pose_interval(tps), d, limits);
return sent;
};
for (const unsigned tps : {20U, 30U, 60U, 120U}) {
const auto limits = crowd_limits(crowd, tps, 600);
const auto sent = count(crowd, limits, tps);
check(sent <= 602 && sent > 450, "A crowd was not sent within the budget");
check(crowd_interval(dingosdk::multiplayer_pose_interval(tps), crowd.front(), limits) == dingosdk::multiplayer_pose_interval(tps) &&
crowd_interval(dingosdk::multiplayer_pose_interval(tps), crowd.back(), limits) == 200000,
"A crowd's nearest and farthest were not sent at the full and low rates");
check(limits.half < limits.low, "A crowd had no half-rate ring");
}
// Too many for the budget even at the low rate: all at the low rate, which is the floor.
const auto all_low = crowd_limits(packed, 30, 600);
check(count(packed, all_low, 30) == packed.size() * 5, "An over-full crowd was not sent at the low rate");
// Distance still slows what the crowd limit would send faster.
check(crowd_interval(200000, 0, none) == 200000 && crowd_interval(100000, 0, crowd_limits(crowd, 30, 600)) == 100000,
"A crowd limit sped a far player up");
}
for (auto interval : {8333U, 16666U, 33333U, 50000U, 100000U, 200000U}) {
PoseBuffer buffer;
auto p = fixture(); p.pose_interval_us = interval;
for (unsigned i = 0; i < 20; ++i) {
p.sequence = i + 1; p.time_us = 1000000 + i * interval;
p.pose.root.position[0] = static_cast<float>(i * interval) / 1000000.f;
p.pose.board[0] = p.pose.root;
check(buffer.push(p, p.time_us + 7000000), "Rate fixture rejected");
}
const auto sampled = buffer.sample(p.time_us + 7000000 + interval / 2);
const auto expected = static_cast<float>(19 * interval + interval/2 - std::max(100000U, interval + 50000)) / 1000000.f;
check(sampled && std::abs(sampled->root.position[0] - expected) < .0001f &&
sampled->root == sampled->board[0], "Rate changed animation speed or board alignment");
}
std::cout << "Distance rates: hysteresis and 20/10/5 TPS sender-time interpolation with aligned boards passed.\n";
}
void block_checks() {
std::vector<std::uint8_t> raw(6000), compressed(6500), restored(raw.size());
for (std::size_t i = 0; i < raw.size(); ++i) raw[i] = static_cast<std::uint8_t>((i / 97) ^ (i % 13));
const auto encoded = compress_block(raw);
check(encoded.codec == BlockCodec::lz4, "Client hot-path encoder did not select LZ4");
check(decompress_block(encoded.bytes, restored, encoded.codec) && raw == restored,
"Client hot-path LZ4 block changed bytes");
for (const auto codec : {BlockCodec::lz4, BlockCodec::zstd}) {
const auto size = codec == BlockCodec::zstd ? ZSTD_compress(compressed.data(), compressed.size(), raw.data(), raw.size(), 1)
: static_cast<std::size_t>(LZ4_compress_default(reinterpret_cast<const char *>(raw.data()),
reinterpret_cast<char *>(compressed.data()), static_cast<int>(raw.size()), static_cast<int>(compressed.size())));
check(size && size < compressed.size(), "Compression fixture failed");
const auto bytes = std::span(compressed).first(size);
check(decompress_block(bytes, restored, codec) && raw == restored, "Block codec changed bytes");
for (std::size_t n = 0; n < size; ++n)
check(!decompress_block(bytes.first(n), restored, codec), "Truncated compressed block accepted");
check(!decompress_block(bytes, std::span(restored).first(raw.size() - 1), codec), "Block output bound ignored");
auto trailing = std::vector<std::uint8_t>(bytes.begin(), bytes.end()); trailing.push_back(0);
check(!decompress_block(trailing, restored, codec), "Trailing compressed bytes accepted");
}
std::cout << "LZ4/Zstd blocks: exact output, truncation, bounded decompression and trailing data checks passed.\n";
}
}
int main() {
try { pose_checks(); audio_checks(); rate_checks(); block_checks(); }
catch (const std::exception &e) { std::cerr << e.what() << '\n'; return 1; }
}