Files
ReSkate/Extension/Multiplayer/Test/bandwidth_tests.cpp
T
AyeZeeBB 7ee82b2771 Player distance and nametag settings; far players sent less and more coarsely; profiler addresses
Game
- Player distance (Settings > Interface, "player-distance"): how far
  away another player is still built as a skater, 50 to 1000 m, default
  120. Past it they keep their nametag or dot. All the way up shows
  everyone. A player already shown stays until 15% further out, so one
  skating along the edge is not built and taken down over and over. The
  game ran a full skater for every player on the server wherever they
  were, which is most of a frame on a full one
- Nametags are always ReSkate's own. The "ReSkate nametags" toggle, the
  "Nametag style" button and the nametag-style command are gone; the
  game's own nametags are never shown
- Nametag distance ("nametag-distance"): how far away a name still
  shows before it becomes a dot, 10 to 500 m. Default 120, was a fixed
  150, so by default the name and the skater go at the same distance
- Nametag dots ("nametag-dots"): off hides the dots for far players and
  the ones at the screen's edge for players off screen
- Friends' nametags only ("nametags-friends"): only Steam friends get a
  name or dot; chat bubbles still show for everyone

Server
- A player out of sight of another (half as far again as the low rate
  starts, 255 m by default) is sent to them once a second instead of
  five times. 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
- Rotations are rounded before a pose is passed on, more coarsely the
  further away the recipient is: 0.04 degrees within the full rate's
  reach, 0.16 within the half rate's, a third of a degree past that,
  about 5 degrees out of sight. The pose is still an ordinary one. A
  bone that turned by less than a step is the same bytes as before, so
  the difference from the reference leaves it out, and the low bits of
  the ones that did turn pack away. By distance only: a player the
  crowd limit slowed keeps the fine rotations
- A recipient crossing from one precision to another is sent a whole
  state of that player, since a difference from a reference of another
  precision would carry every bone again
- "net" shows the size of the poses sent at each precision

Profiler
- A function in ReSkate.dll is reported by its address when the DLL has
  no symbols. A release build named every one of ours as the nearest of
  three exports, so a report could not say which was slow; the
  release's symbols name the address afterwards
2026-10-07 16:43:18 -05:00

270 lines
16 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 coarse_checks() {
// Coarser rotations still encode and decode as any pose, land within a step of the
// original, and make a bone that barely turned the same bytes as before.
std::mt19937 random(11);
std::uniform_real_distribution<float> any(-1.f, 1.f);
auto p = fixture();
p.kind = PacketKind::pose;
for (auto &t : p.pose.skater) {
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);
}
// The awkward ones: two components equal and as large as a smaller one can be, and an exact axis.
p.pose.skater[0].rotation = {.70710678f, .70710678f, 0, 0};
p.pose.skater[1].rotation = {0, 0, 0, -1};
p.pose.skater[2].rotation = {.5f, .5f, .5f, .5f};
for (const unsigned bits : {4U, 6U, 7U, 11U}) {
auto coarse = p;
coarsen_rotations(coarse.pose, bits);
const auto decoded = decode(encode(coarse, true));
check(decoded && decoded->pose.skater.size() == p.pose.skater.size(), "A coarsened pose did not decode");
const float step = static_cast<float>(1U << bits) / 46339.5358f;
for (std::size_t i = 0; decoded && i < p.pose.skater.size(); ++i) {
const auto &a = p.pose.skater[i].rotation, &b = decoded->pose.skater[i].rotation;
const float same = std::abs(a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]);
// Each of three components is off by at most half a step.
check(same > 1.f - 2.f * step * step - 1e-4f, "A coarsened rotation moved by more than its step");
}
// A turn of a fortieth of a step is lost: the same bytes, so a difference leaves the bone out.
auto nudged = p;
for (auto &t : nudged.pose.skater) {
t.rotation[0] += step * .025f;
float norm{};
for (float v : t.rotation) norm += v * v;
for (float &v : t.rotation) v /= std::sqrt(norm);
}
coarsen_rotations(nudged.pose, bits);
const auto first = encode(coarse, true), second = encode(nudged, true);
std::size_t different{};
for (std::size_t i = 0; i < std::min(first.size(), second.size()); ++i) different += first[i] != second[i];
check(first.size() == second.size() && different < first.size() / 20, "A turn far below the step still changed the pose's bytes");
}
std::cout << "Coarse rotations: valid poses, within a step, small turns unchanged.\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);
// Within the budget, or at the least every crowd is sent: the nearest few at the
// full and half rates, which at a high TPS is more than the budget by itself.
const unsigned least = crowd_always_full * (1000000U / dingosdk::multiplayer_pose_interval(tps)) + crowd_always_half * 10 +
(static_cast<unsigned>(crowd.size()) - crowd_always_full - crowd_always_half) * 5;
check(sent <= std::max(602U, least + 2) && 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: the nearest few are still sent at the
// full and half rates, and only the rest at the low rate.
const auto floor = crowd_limits(packed, 30, 600);
check(count(packed, floor, 30) == crowd_always_full * 30 + crowd_always_half * 10 + (packed.size() - crowd_always_full - crowd_always_half) * 5,
"An over-full crowd did not keep its nearest at the full and half rates");
// 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(); coarse_checks(); rate_checks(); block_checks(); }
catch (const std::exception &e) { std::cerr << e.what() << '\n'; return 1; }
}