mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-18 08:05:15 +02:00
A hacked client could move its skater's bones anywhere and stretch the skater across the map for everyone: the server limited a bone's scale but not its place. Server: new anti_cheat setting bone_reach_limit, default 1 metre (0.5 to 20, 0 for off; console bone-reach <0.5-20>|off). A body or board bone further than that from its parent is pulled back before the pose is passed on. On the game's rig these bones never move (the longest, a thigh, is 0.44 m). The fourteen skater bones that do move by metres (they follow the board and the body in a fall) are held to 100 m, and the board's rig to 5 m from the board. Client: every pose a game is shown is held to 2 m reach and 8x bone scale, whoever sent it, which covers servers that have not updated and sessions without a dedicated server. A client used to accept up to 100x scale. The limits come from two recorded play sessions; the exempt bones are picked by index for the current build's 395-bone rig. The pose codec bench now prints each recording's bone reach, and reads recordings made by earlier versions.
310 lines
18 KiB
C++
310 lines
18 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");
|
|
}
|
|
// A bone moved away from its parent is brought back to the limit, the way it pointed; the
|
|
// bones that follow the board keep their metres, and no limit leaves everything.
|
|
{
|
|
Pose stretched;
|
|
stretched.skater.resize(395);
|
|
stretched.board.resize(18);
|
|
stretched.root.position = stretched.skater[1].position = {500, 20, -300};
|
|
stretched.skater[7].position = {0, .4f, 0};
|
|
stretched.skater[20].position = {0, 30, 40};
|
|
stretched.skater[50].position = {0, 9, 0};
|
|
stretched.skater[393].position = {0, 0, 4000};
|
|
stretched.board[0].position = {530, 20, -300};
|
|
stretched.board[2].position = {530, 20, 700};
|
|
stretched.board[5].position = {12, 0, 0};
|
|
auto reached = stretched, loose = stretched;
|
|
limit_bone_reach(reached, 1.f);
|
|
limit_bone_reach(loose, 0.f);
|
|
const auto near = [](const std::array<float, 3> &a, const std::array<float, 3> &b) {
|
|
return std::abs(a[0] - b[0]) < 1e-3f && std::abs(a[1] - b[1]) < 1e-3f && std::abs(a[2] - b[2]) < 1e-3f;
|
|
};
|
|
check(reached.skater[7] == stretched.skater[7] && reached.skater[1] == stretched.skater[1] && reached.root == stretched.root &&
|
|
reached.skater[50] == stretched.skater[50] && reached.board[0] == stretched.board[0],
|
|
"The reach limit moved a bone that was where the game puts it");
|
|
check(near(reached.skater[20].position, {0, .6f, .8f}) && near(reached.skater[393].position, {0, 0, 100}) &&
|
|
near(reached.board[2].position, {530, 20, -295}) && near(reached.board[5].position, {1, 0, 0}),
|
|
"A stretched bone was not brought back to the reach limit");
|
|
check(loose.skater == stretched.skater && loose.board == stretched.board, "No reach limit still moved a bone");
|
|
}
|
|
// A resized bone is held to the limit both ways, and at 1 is not resized at all.
|
|
auto big = fixture();
|
|
big.pose.skater[5].scale = {4, 4, 4};
|
|
big.pose.skater[6].scale = {.1f, 1, 1};
|
|
auto held = big.pose, plain = big.pose, free = big.pose;
|
|
limit_bone_scale(held, 1.5f);
|
|
limit_bone_scale(plain, 1.f);
|
|
limit_bone_scale(free, 0.f);
|
|
check(held.skater[5].scale == std::array<float, 3>{1.5f, 1.5f, 1.5f} && std::abs(held.skater[6].scale[0] - 1.f / 1.5f) < 1e-5f &&
|
|
plain.skater[5].scale == std::array<float, 3>{1, 1, 1} && plain.skater[6].scale == std::array<float, 3>{1, 1, 1} &&
|
|
free.skater[5].scale == std::array<float, 3>{4, 4, 4},
|
|
"A bone's scale was not held to the limit");
|
|
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; }
|
|
}
|