Dedicated servers run at 20 TPS; still players at the low rate; message commands in the "/" menu

Server
- Dedicated servers run at 20 network updates a second, fixed for now:
  whatever tps says in ReSkateServer.json is read as 20, and the tps
  command no longer changes it. A busy server's traffic, in and out, is
  its players' poses, and 20 a second is a third less than 30. Lobbies
  keep their own choice
- A player who has not moved or turned for three seconds is sent on at
  five poses a second; the full rate is back with their first movement
- "net" shows what a pose costs as sent, and what it would cost against
  a reference renewed every second, every quarter second, and against
  the pose before, measured on every fourth pose that arrives. It only
  measures: a nearer reference packs smaller, and this says by how much
  before the server is made to send them that way
Game
- The "/" menu on a dedicated server lists /w for everyone, and /msg,
  /msg-party and /msg-admins for admins. The server already answered
  them; the menu is the game's own list and never had them
This commit is contained in:
AyeZeeBB committed 2026-10-07 15:27:05 -05:00
1 parent 737c1611af
commit d540b88640
7 files changed
+85 -13

No files matched your search

@@ -457,6 +457,9 @@ std::vector<MultiplayerChatCommand> chat_commands(const Session &s) {
list.push_back({"/party", "/party", "Who is in your party"});
}
if (dedicated_host(s)) {
// The server's own commands (server_votes.cpp, server_host.cpp): it answers them, and
// this list is only what the "/" menu offers, so one left out here still works unseen.
list.push_back({"/w", "/w <player> <message>", "Send a player a private message", "player"});
if (s.server_votes & server_vote_map) list.push_back({"/vote map", "/vote map <map>", "Start a vote to change the map", "map"});
if (s.server_votes & server_vote_kick)
list.push_back({"/vote kick", "/vote kick <player>", "Start a vote to kick a player", "player"});
@@ -467,6 +470,9 @@ std::vector<MultiplayerChatCommand> chat_commands(const Session &s) {
list.push_back({"/no", "/no", "Vote no in the running vote"});
}
if (s.server_admin) {
list.push_back({"/msg", "/msg <player> <message>", "Admin: message a player privately", "player"});
list.push_back({"/msg-party", "/msg-party <player> <message>", "Admin: message everyone in a player's party", "player"});
list.push_back({"/msg-admins", "/msg-admins <message>", "Admin: message the admins who are on"});
list.push_back({"/kick", "/kick <player>", "Admin: kick a player until the server restarts", "player"});
list.push_back({"/ban", "/ban <player>", "Admin: ban a player", "player"});
list.push_back({"/map", "/map <map>", "Admin: change the server's map", "map"});
+4 -3
View File
@@ -63,7 +63,7 @@ send_rate The most the server sends one player, in KB/s (default 900,
crowd_budget The most position updates a second one player is sent
(default 600, 0 for no limit). Players near each other are
sent at the full rate (tps); this only matters once more are
in one place than budget / tps, about 20 at 30 TPS. Then the
in one place than budget / tps, about 30 at 20 TPS. Then the
nearest stay at the full rate and the farthest of the crowd
drop to 10 and 5 a second, instead of everyone's connection
filling up. About 1000 is what reaches a player through
@@ -135,7 +135,8 @@ activity_log Log what players do (default true): throwdown drops placed,
joins, starts, turns and results; objects placed or removed;
how long players take to load.
port, query_port Steam game server ports (default 27015, 27016).
tps Network updates per second: 20, 30, 60 or 120.
tps Network updates per second. Fixed at 20 on dedicated servers
for now: whatever is set here is read as 20.
voice_chat Allow voice chat.
voice_range How far proximity voice reaches, 50-1000 m.
distances When far-away players update less often (metres).
@@ -193,7 +194,7 @@ and change voice, distances, placement and kicks from the Multiplayer menu.
rotation uses (see map_pool).
rotation [<minutes>|off] Change the map on a timer (see map_rotation_minutes).
name <text> password <text|off> welcome <text|off> listed on|off
tps 20|30|60|120 voice on|off voice-range <m>
voice on|off voice-range <m>
distances <full> <half> <half-return> <low>
placement everyone|admins|nobody clear-objects
objects <number>|off How many objects each player may have placed.
+1 -1
View File
@@ -42,7 +42,7 @@ int run() {
const auto written = text(file);
check(written.find("\"enforce_tuning\"") != std::string::npos && written.find("\"seconds\"") != std::string::npos,
"New settings not written into the file");
check(config.name == "Old Server" && config.tps == 60 && !config.boosts && config.votes.map.enabled &&
check(config.name == "Old Server" && config.tps == dedicated_tps /* fixed: the file's 60 is not kept */ && !config.boosts && config.votes.map.enabled &&
config.votes.map.percent == 60 && config.enforce_tuning && config.global_bans,
"The file's own values or the new defaults were lost");
const auto reloaded = load_config(file);
+2 -2
View File
@@ -163,7 +163,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector<std::str
};
c.port = read_port("port", c.port);
c.query_port = read_port("query_port", c.query_port);
c.tps = root.value("tps", c.tps);
c.tps = dedicated_tps; // not the file's (see ServerConfig::tps)
c.voice_chat = root.value("voice_chat", c.voice_chat);
c.voice_range = root.value("voice_range", c.voice_range);
if (root.contains("distances")) {
@@ -274,7 +274,7 @@ std::string config_error(const ServerConfig &c) {
return "max_players must be 1 to " + std::to_string(max_players - 1) + ".";
if (c.password.size() > 64) return "password must be at most 64 characters.";
if (!c.welcome.empty() && !valid_chat_text(c.welcome)) return "welcome must be one chat line (at most 200 bytes).";
if (!valid_multiplayer_tps(c.tps)) return "tps must be 20, 30, 60 or 120.";
if (c.tps != dedicated_tps) return "tps is " + std::to_string(dedicated_tps) + " on dedicated servers.";
if (!valid_voice_range(c.voice_range)) return "voice_range must be 50 to 1000.";
if (!valid_object_limit(c.object_limit)) return "object_limit must be 0 (no limit) to " + std::to_string(max_object_limit) + ".";
if (!c.distances.valid()) return "distances must be ordered: full_rate_return < half_rate_start <= half_rate_return < low_rate_start <= 10000.";
+5 -1
View File
@@ -27,6 +27,7 @@ struct VoteSettings {
};
// ReSkateServer.json. Every setting an admin or the console changes is saved
// back, so a restart keeps it.
inline constexpr unsigned dedicated_tps = 20;
struct ServerConfig {
std::filesystem::path file;
std::string name = "ReSkate server";
@@ -70,7 +71,10 @@ struct ServerConfig {
// everyone installs). Each player's fingerprint is in the console when they are flagged.
std::vector<std::uint64_t> score_allow;
std::uint16_t port = 27015, query_port = 27016;
unsigned tps = multiplayer_default_tps;
// Fixed for dedicated servers for now (dedicated_tps): a busy one's traffic, in and out,
// is its players' poses, and at 20 a second that is a third less than at 30. Whatever
// the file says is read as this; lobbies keep their own choice.
unsigned tps = dedicated_tps;
bool voice_chat = true;
float voice_range = default_voice_range;
MultiplayerDistances distances;
+52 -6
View File
@@ -1,6 +1,8 @@
#include "server_host.h"
#include "server_text.h"
#include "Extension/Multiplayer/Net/wire_codec.h"
#include "Extension/Multiplayer/Net/block_codec.h"
#include "Extension/Multiplayer/Net/pose_delta.h"
#include "Extension/Multiplayer/developer_identity.h"
#include "Extension/Multiplayer/Session/monotonic_clock.h"
#include "Engine/Core/Text/word_filter.h"
@@ -44,7 +46,7 @@ std::uint64_t nonce() {
constexpr std::string_view help_text =
"status | net [player] | players | say <text> | msg <player> <text> | msg-party <player> <text> | msg-admins <text> | kick <player> | ban <player or SteamID64> [name] | unban <SteamID64> | bans\n"
"map <name, e.g. San Vansterdam> | maps | name <text> | password <text|off> | welcome <text|off> | listed on|off\n"
"tps 20|30|60|120 | voice on|off | voice-range <50-1000> | distances <full> <half> <half-return> <low> | crowd <n>|off | rate <KB/s>\n"
"voice on|off | voice-range <50-1000> | distances <full> <half> <half-return> <low> | crowd <n>|off | rate <KB/s>\n"
"placement everyone|admins|nobody | objects <number>|off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n"
"tpall [player] | tphere <player> | votes [map|kick|tod on|off|<percent>] | vote-cancel\n"
"map-pool [add|remove <map>|clear] | rotation [<minutes>|off]\n"
@@ -203,7 +205,32 @@ void Host::send_required(Guest &g, const std::vector<std::uint8_t> &bytes) {
if (!p || !send_packet(g, *p, true, false))
transport_.disconnect(g.member.id, "Cannot deliver required session data. Join again.");
}
void Host::measure_pose(Guest &from, const Packet &packet) {
auto raw = encode(packet, true);
// As DeltaSender would send it: the patch packed, behind its 34 byte header.
const auto cost = [&](const Guest::Earlier &reference) -> std::uint64_t {
if (reference.raw.empty()) return 0;
const auto patch = pose_delta::encode(raw, reference.raw);
return patch.empty() ? 0 : 34 + compress_block(patch).bytes.size();
};
if (packet.sequence % 4 == 0) {
const auto last = cost(from.pose_last), quarter = cost(from.pose_quarter), second = cost(from.pose_second);
if (last && quarter && second) {
++pose_sizes_.samples;
pose_sizes_.whole += encode_wire_bytes(raw).size() + 4;
pose_sizes_.last += last;
pose_sizes_.quarter += quarter;
pose_sizes_.second += second;
}
}
// Each kept reference is replaced once it is its age old, as a whole state sent that often would be.
for (auto [kept, age] : {std::pair{&from.pose_quarter, 250000ULL}, std::pair{&from.pose_second, 1000000ULL}})
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};
}
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);
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.
@@ -272,6 +299,9 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6
} else {
delivery->by_distance = 0;
}
// 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).
if (source && source->moved_at && now_ - source->moved_at > 3000000) interval = std::max<std::uint32_t>(interval, 200000);
if (delivery->interval_us != interval) delivery->next_source_time = 0;
delivery->interval_us = interval;
if (interval > multiplayer_pose_interval(config_.tps) && packet.time_us < delivery->next_source_time) continue;
@@ -299,6 +329,10 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6
counted->total.out[traffic_kind(sending.kind)] += out.update.bytes.size();
counted->total.snapshots += whole;
}
if (sending.kind == PacketKind::pose && !whole) {
++pose_sizes_.sent;
pose_sizes_.sent_bytes += out.update.bytes.size();
}
out.guest->sender.sent(sending, std::move(out.update));
}
}
@@ -544,6 +578,16 @@ bool Host::accept_data(Guest &source, const Packet &p) {
if (p.kind == PacketKind::pose) {
source.pose_arrival = now_;
source.latest_root = p.pose.root;
{
const auto &root = p.pose.root, &was = source.still_at;
float moved{}, facing{};
for (unsigned i = 0; i < 3; ++i) moved += (root.position[i] - was.position[i]) * (root.position[i] - was.position[i]);
for (unsigned i = 0; i < 4; ++i) facing += root.rotation[i] * was.rotation[i];
if (!source.moved_at || moved > 0.05f * 0.05f || std::abs(facing) < 0.9995f) {
source.still_at = root;
source.moved_at = now_;
}
}
return check_speed(source, p.time_us); // last: a speed-check kick frees `source`
}
return true;
@@ -942,6 +986,12 @@ std::string Host::network_report(std::string_view player, bool console) {
if (!console) return text;
text += "\nloop: " + loop_report();
text += "\n" + by_kind(traffic_);
if (pose_sizes_.samples && pose_sizes_.sent) {
const auto each = [&](std::uint64_t sum) { return std::to_string(sum / pose_sizes_.samples) + " B"; };
text += "\npose size: " + std::to_string(pose_sizes_.sent_bytes / pose_sizes_.sent) + " B as sent (whole " + each(pose_sizes_.whole) +
"); it would be " + each(pose_sizes_.second) + " against a reference renewed every second, " + each(pose_sizes_.quarter) +
" every quarter second, " + each(pose_sizes_.last) + " against the pose before (" + std::to_string(pose_sizes_.samples) + " measured)";
}
{
// Who uploads most, and what most of it is: one player's outfit or objects can cost
// every other player's connection.
@@ -1507,11 +1557,7 @@ std::string Host::command(std::string_view line, std::uint64_t admin) {
return changed(*value ? "The server is listed in the server browser." : "The server is hidden; players need the code.");
}
if (name == "tps") {
const auto value = number(argument);
if (!value || !valid_multiplayer_tps(static_cast<unsigned>(*value))) return "tps 20|30|60|120";
config_.tps = static_cast<unsigned>(*value);
for (auto &[id, guest] : guests_) guest->pose_delivery = {};
return changed("Network updates set to " + std::to_string(config_.tps) + " TPS.");
return "Dedicated servers run at " + std::to_string(dedicated_tps) + " TPS for now; it cannot be changed.";
}
if (name == "voice") {
const auto value = on_off(argument);
+15
View File
@@ -79,6 +79,14 @@ class Host {
struct PendingCosmetics { Packet packet; std::uint64_t received{}; };
std::vector<PendingCosmetics> pending_cosmetics;
std::optional<Transform> latest_root;
// Where they last stood and faced, and when they last moved from it: a player standing
// still (in a menu, away from the keyboard, watching) is sent on at the low rate.
// Their earlier poses, as encoded: the one before, and ones kept a quarter of a second
// and a second (measure_pose).
struct Earlier { std::vector<std::uint8_t> raw; std::uint64_t time{}; };
Earlier pose_last, pose_quarter, pose_second;
Transform still_at;
std::uint64_t moved_at{};
std::uint64_t pose_arrival{};
std::array<PoseDelivery, max_players> pose_delivery;
CrowdLimits crowd; // how far the full and half rates reach for them in a crowd
@@ -165,6 +173,13 @@ class Host {
} loop_, last_loop_, worst_loop_;
std::uint64_t pass_started_{};
Counted traffic_;
// What a pose costs to send as a difference from references of different ages, measured on
// every fourth pose that arrives (net). A nearer reference changes less, so it packs
// 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{}, sent{}, sent_bytes{};
} pose_sizes_;
void measure_pose(Guest &from, const Packet &packet);
std::uint64_t traffic_mark_{}, traffic_window_us_{}; // when `mark` was taken, and how long `last` covers
void meet_later(Guest &guest);
void introduce();