mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 15:07:06 +02:00
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.
362 lines
17 KiB
C++
362 lines
17 KiB
C++
#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
|