Merge branch 'main' into claude/peaceful-bardeen-ode7bj

This commit is contained in:
Robin Möller authored and GitHub committed 2026-10-10 01:50:53 +02:00
commit 5e9fafd48b
21 files changed
+389 -60

No files matched your search

@@ -35,4 +35,18 @@ inline constexpr std::uintptr_t named_lookup = 0x16fc900;
inline constexpr std::array<unsigned char, 24> named_lookup_prefix{
0x40,0x55,0x56,0x57,0x41,0x56,0x48,0x81,0xec,0xd8,0x02,0x00,0x00,0x48,0x8b,0x05,
0xac,0x7a,0xac,0x05,0x48,0x33,0xc4,0x48};
// The skater scripts' item:addEsShaderPreset(material, preset): a Lua function (state) -> count
// of results. Its third argument is a reference to the loaded preset, which it uses without
// checking that the preset is there: an item whose preset has gone (or never loaded) by the
// time its script runs crashes the game in the material's construction.
inline constexpr std::uintptr_t add_shader_preset = 0x52cb70;
inline constexpr std::array<unsigned char, 16> add_shader_preset_prefix{
0x40,0x53,0x55,0x56,0x57,0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x28,0x48,0x8b,0xd9};
// The two calls it makes to read that argument: (state, argument number) -> the reference, and
// (reference, 0) -> the preset it holds, or null.
inline constexpr std::uintptr_t script_preset_reference = 0x336f060;
inline constexpr std::array<unsigned char, 8> script_preset_reference_prefix{0x4c,0x8d,0x05,0x81,0xa2,0x0d,0x03,0xe9};
inline constexpr std::uintptr_t referenced_preset = 0x16fe9c0;
inline constexpr std::array<unsigned char, 17> referenced_preset_prefix{
0x48,0x8b,0x09,0x48,0x85,0xc9,0x74,0x07,0x48,0x8b,0x01,0x48,0xff,0x60,0x18,0x33,0xc0};
}
+52
View File
@@ -0,0 +1,52 @@
#include "exit_watch.h"
#include "Engine/Core/Log/logging.h"
#include <atomic>
#include <thread>
namespace dingosdk::exit_watch {
namespace {
// How long a close may take once the window is gone or has stopped answering, and how long a
// window that still answers is watched (the game may ask before it closes, and be told no).
constexpr ULONGLONG grace_ms = 8000, watch_ms = 120000;
std::atomic<bool> watching{};
std::atomic<ULONGLONG> destroyed_at{};
void watch(HWND window) noexcept {
const auto asked = GetTickCount64();
ULONGLONG stuck_since{};
for (;;) {
Sleep(500);
const auto now = GetTickCount64();
DWORD_PTR result{};
// Gone, or not taking messages: the close is under way, or stuck.
const bool answers = !destroyed_at.load() && IsWindow(window) &&
SendMessageTimeoutW(window, WM_NULL, 0, 0, SMTO_ABORTIFHUNG, 1000, &result);
if (answers) {
stuck_since = 0;
// Still the game as it was: the close was turned down. Watch the next one afresh.
if (now - asked >= watch_ms) { watching = false; return; }
continue;
}
if (!stuck_since) stuck_since = now;
if (now - stuck_since < grace_ms) continue;
logging::log(logging::Level::warning, logging::Channel::runtime,
"The game did not finish closing {} s after it was asked to; ending it.", (now - asked) / 1000);
logging::flush();
TerminateProcess(GetCurrentProcess(), 0);
}
}
} // namespace
void note_window_message(HWND window, UINT message, WPARAM wp) noexcept {
const bool closing = message == WM_CLOSE || message == WM_DESTROY || (message == WM_SYSCOMMAND && (wp & 0xfff0) == SC_CLOSE) ||
(message == WM_ENDSESSION && wp);
if (!closing) return;
if (message == WM_DESTROY) destroyed_at = GetTickCount64();
if (watching.exchange(true)) return;
try {
std::thread(watch, window).detach();
} catch (...) {
watching = false;
}
}
} // namespace dingosdk::exit_watch
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <Windows.h>
namespace dingosdk::exit_watch {
// The game sometimes never finishes closing: its window goes (or stops answering) and the
// process stays, holding the game's files and Steam's "running" state. Once the player has
// asked the game to close, this gives it a few seconds and then ends the process.
// Called from the game window's procedure with each message of that window.
void note_window_message(HWND window, UINT message, WPARAM wp) noexcept;
}
@@ -34,6 +34,43 @@ void **lookup(void **out, const char *name, void *table, std::uint8_t flag, void
text.empty() ? "unnamed" : text);
return out;
}
// item:addEsShaderPreset(material, preset) with a preset that is not there: nothing is added,
// and the script goes on as it does after any other call.
using ScriptFunction = int (*)(void *state);
std::atomic<ScriptFunction> original_add_preset{};
std::uintptr_t image{};
int add_preset(void *state) {
const auto reference = reinterpret_cast<void *(*)(void *, int)>(image + native::script_preset_reference)(state, 3);
if (reference && reinterpret_cast<void *(*)(void *, int)>(image + native::referenced_preset)(reference, 0))
return original_add_preset.load()(state);
static std::atomic<unsigned> seen{};
const auto count = seen.fetch_add(1, std::memory_order_relaxed) + 1;
if (count <= 5 || count % 500 == 0)
logging::log(logging::Level::warning, logging::Channel::customization,
"An item's shader preset was not loaded when its script used it ({} so far); the item is shown without it.",
count);
return 0;
}
template<std::size_t Size> bool matches(std::uintptr_t address, const std::array<unsigned char, Size> &prefix) {
std::array<unsigned char, Size> bytes{};
return memory::read_bytes(address, bytes.data(), bytes.size()) && bytes == prefix;
}
bool start_add_preset(std::uintptr_t base) {
if (!matches(base + native::add_shader_preset, native::add_shader_preset_prefix) ||
!matches(base + native::script_preset_reference, native::script_preset_reference_prefix) ||
!matches(base + native::referenced_preset, native::referenced_preset_prefix))
return false;
image = base;
auto *target = reinterpret_cast<void *>(base + native::add_shader_preset);
void *previous{};
if (hook_prepare(target, reinterpret_cast<void *>(&add_preset), &previous) != HookOk) return false;
original_add_preset = reinterpret_cast<ScriptFunction>(previous);
if (hook_enable(target) != HookOk) {
hook_remove(target);
return false;
}
return true;
}
} // namespace
bool start(std::uintptr_t base) noexcept {
@@ -50,7 +87,7 @@ bool start(std::uintptr_t base) noexcept {
hook_remove(target);
return false;
}
return true;
return start_add_preset(base);
} catch (...) {
return false;
}
@@ -4,6 +4,7 @@
namespace dingosdk::preset_lookup_guard {
// A preset whose bundle cannot be found (a cosmetic mod's registration missing
// from the character bundle-reference table) is treated as not found instead
// of crashing the game in the named lookup.
// of crashing the game in the named lookup. So is an item's shader preset that is
// not loaded when the item's script comes to use it: the item goes without it.
bool start(std::uintptr_t base) noexcept;
}
+23
View File
@@ -352,6 +352,29 @@ void limit_bone_scale(Pose &pose, float limit) noexcept {
for (auto &t : pose.skater) hold(t);
for (auto &t : pose.board) hold(t);
}
void limit_bone_reach(Pose &pose, float limit) noexcept {
if (!(limit > 0.f)) return;
const auto hold = [](std::array<float, 3> &position, const std::array<float, 3> &from, float most) {
float squared{};
for (unsigned i = 0; i < 3; ++i) squared += (position[i] - from[i]) * (position[i] - from[i]);
if (!(squared > most * most)) return;
const float factor = most / std::sqrt(squared);
for (unsigned i = 0; i < 3; ++i) position[i] = from[i] + (position[i] - from[i]) * factor;
};
constexpr std::array<float, 3> parent{};
// Bone 1 is the rig's place in the world (offset_pose): it goes with the root.
for (std::size_t i = 0; i < pose.skater.size(); ++i) {
if (i == 1) continue;
const bool free = std::find(free_skater_bones.begin(), free_skater_bones.end(), i) != free_skater_bones.end();
hold(pose.skater[i].position, parent, free ? std::max(limit, free_bone_reach) : limit);
}
// The board is its own actor (0) with its rig's place in the world (2): wherever it was
// left, but in one piece.
for (std::size_t i = 1; i < pose.board.size(); ++i) {
if (i == 2) hold(pose.board[i].position, pose.board[0].position, std::max(limit, 5.f));
else hold(pose.board[i].position, parent, limit);
}
}
void coarsen_rotations(Pose &pose, unsigned bits) noexcept {
bits = std::min(bits, 12U);
coarsen(pose.root, std::min(bits, 6U)); // which way they face matters from further off than a finger does
+12
View File
@@ -301,6 +301,18 @@ void coarsen_rotations(Pose &pose, unsigned bits) noexcept;
// that resizes part of a skater (a head four times the size) does it with a bone's scale,
// which travels in the pose and so shows to everyone, mod or not.
void limit_bone_scale(Pose &pose, float limit) noexcept;
// Keeps every bone of the body and the board within `limit` metres of the bone it hangs from
// (0: no limit). A bone's place is relative to its parent and, on the game's rigs, never
// changes: the longest is a thigh, 0.44 m. A hacked client that moves them stretches its
// skater across the map for everyone. The skater's few bones that follow the board and the
// body's place in a fall (free_skater_bones) do move, by metres, and are held to
// free_bone_reach instead; so is the board's own rig from the board.
inline constexpr std::array<std::uint16_t, 14> free_skater_bones{3, 50, 283, 375, 376, 377, 378, 379, 380, 390, 391, 392, 393, 394};
inline constexpr float free_bone_reach = 100.f;
// What a game holds every pose it is sent to, whatever sent it: twice the server's standard
// reach, and the most a server may allow a bone to be resized.
inline constexpr float client_bone_reach = 2.f, client_bone_scale = 8.f;
void limit_bone_reach(Pose &pose, float limit) noexcept;
// The same, with a pose encoded at another update interval (a recipient thinned by
// distance) instead of the packet's own, so the packet need not be copied for it.
std::vector<std::uint8_t> encode(const Packet &, bool compact_pose, std::uint32_t pose_interval_us);
@@ -169,6 +169,10 @@ template <class P> bool PoseBuffer::push_frame(P &&p, std::uint64_t arrival) {
}
const auto source = sender_clock_ ? p.time_us : arrival;
frames_.push_back({std::forward<P>(p).pose, arrival, source});
// Whatever sent it (a server that checks nothing, or a player's own game): a skater is
// never shown stretched across the map or blown up past what any server allows.
limit_bone_reach(frames_.back().pose, client_bone_reach);
limit_bone_scale(frames_.back().pose, client_bone_scale);
interpolation_delay_us_ = std::max(100000U, p.pose_interval_us + 50000);
while (frames_.size() > 64)
frames_.pop_front();
@@ -173,6 +173,34 @@ void coarse_checks() {
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};
@@ -356,13 +356,49 @@ int bench(const char *path) {
return 2;
}
std::vector<QuantPose> poses;
// How far each bone is ever put from where it is measured from: the figures the reach
// limit (limit_bone_reach) has to leave room for.
std::vector<float> skater_reach, board_reach;
float anchor_reach{}, board_from_root{}, board_anchor_reach{};
const auto length_of = [](const std::array<float, 3> &v) { return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); };
const auto between = [&](const std::array<float, 3> &a, const std::array<float, 3> &b) {
return length_of({a[0] - b[0], a[1] - b[1], a[2] - b[2]});
};
for (;;) {
std::uint64_t time{};
std::uint32_t length{};
if (!in.read(reinterpret_cast<char *>(&time), 8) || !in.read(reinterpret_cast<char *>(&length), 4) || length > 1 << 20) break;
std::vector<std::uint8_t> raw(length);
if (!in.read(reinterpret_cast<char *>(raw.data()), length)) break;
if (const auto packet = dingosdk::multiplayer::decode(raw); packet && packet->kind == PacketKind::pose) poses.push_back(quantize(packet->pose));
// A recording made by an earlier version: poses are laid out as they were.
if (raw.size() > 6) raw[4] = static_cast<std::uint8_t>(dingosdk::multiplayer::protocol_version), raw[5] = static_cast<std::uint8_t>(dingosdk::multiplayer::protocol_version >> 8);
if (const auto packet = dingosdk::multiplayer::decode(raw); packet && packet->kind == PacketKind::pose) {
poses.push_back(quantize(packet->pose));
const auto &pose = packet->pose;
skater_reach.resize(std::max(skater_reach.size(), pose.skater.size()));
board_reach.resize(std::max(board_reach.size(), pose.board.size()));
for (std::size_t i = 0; i < pose.skater.size(); ++i)
if (i != 1) skater_reach[i] = std::max(skater_reach[i], length_of(pose.skater[i].position));
if (pose.skater.size() > 1) anchor_reach = std::max(anchor_reach, between(pose.skater[1].position, pose.root.position));
for (std::size_t i = 0; i < pose.board.size(); ++i)
if (i != 0 && i != 2) board_reach[i] = std::max(board_reach[i], length_of(pose.board[i].position));
if (!pose.board.empty()) board_from_root = std::max(board_from_root, between(pose.board[0].position, pose.root.position));
if (pose.board.size() > 2) board_anchor_reach = std::max(board_anchor_reach, between(pose.board[2].position, pose.board[0].position));
}
}
{
const auto furthest = [](const std::vector<float> &reach, const char *what) {
std::vector<std::size_t> order(reach.size());
for (std::size_t i = 0; i < order.size(); ++i) order[i] = i;
std::sort(order.begin(), order.end(), [&](auto a, auto b) { return reach[a] > reach[b]; });
std::cout << what << " bones furthest from their parent:";
for (std::size_t i = 0; i < std::min<std::size_t>(order.size(), 16); ++i) std::cout << ' ' << order[i] << '=' << reach[order[i]] << "m";
std::cout << '\n';
};
furthest(skater_reach, "skater");
furthest(board_reach, "board");
std::cout << "skater anchor from root " << anchor_reach << " m; board from root " << board_from_root << " m; board anchor from board "
<< board_anchor_reach << " m\n";
}
if (poses.size() < 100) {
std::cerr << "Too few poses in " << path << '\n';
+3
View File
@@ -1,5 +1,6 @@
#include "Engine/Core/Platform/launcher_support.h"
#include "Engine/Core/Log/logging.h"
#include "Extension/Boot/exit_watch.h"
#include "overlay_internal.h"
#include "input_capture.h"
#include "playstation_input.h"
@@ -120,6 +121,8 @@ LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
if (window != s.window.load())
return previous ? CallWindowProcW(previous, window, message, wp, lp)
: DefWindowProcW(window, message, wp, lp);
// The player closing the game: it is ended if it does not finish by itself.
dingosdk::exit_watch::note_window_message(window, message, wp);
struct ReleaseOnOpen {
HWND window; WNDPROC previous; bool was_visible;
~ReleaseOnOpen() {
+4 -4
View File
@@ -15,7 +15,6 @@
#include "Extension/Assets/native_render_resource_pool.h"
#include "Extension/Rendering/display_startup.h"
#include "Extension/World/native_route_lookahead.h"
#include "Extension/World/unload_guard.h"
#include "Extension/World/native_entity_pages.h"
#include "Extension/World/physics_world_size.h"
#include <string>
@@ -58,9 +57,10 @@ bool initialize_bootstrap(std::uintptr_t base) {
if (!stage(Channel::world, "Physics world pools (65000 static bodies)", ready, error)) return false;
error.clear(); ready = start_native_route_lookahead(base, error);
if (!stage(Channel::world, "NPC route cycle and endpoint guards", ready, error)) return false;
// A guard against one of the game's own crashes: without it the game runs as shipped.
error.clear(); ready = start_unload_guard(base, error);
(void)stage(Channel::world, "Level unload crash guard", ready, error);
// The level unload guard (unload_guard.h) is not installed: stepping over an entry with no
// asset stopped the crash, but that entry had been registered when the level loaded, and
// left registered it kept the level from ever finishing its unload (a map change that never
// ends, which is worse than the crash). It goes back in once it can release what it skips.
if (!stage(Channel::graphics, "Display startup settings", start_display_settings(base), {})) return false;
error.clear(); ready = start_user_data_redirect(error);
if (!stage(Channel::runtime, "Separate game user data", ready, error)) return false;
+26 -1
View File
@@ -177,6 +177,14 @@ bone_scale_limit How far a mod may resize part of a skater for the other
leaves height alone and still halves the largest heads.
The player with the mod still sees it on their own screen.
Console: bone-scale <1-8>|off.
bone_reach_limit How far, in metres, a bone of a skater's body or board may
be from the one it hangs from for the other players: 0.5
to 20 (default 1); 0 is no limit. The game never moves
them (the longest, a thigh, is 0.44), so the default
changes nothing for an ordinary player and stops a hacked
game from stretching its skater across the map. Every
player's own game also holds what it is shown to 2.
Console: bone-reach <0.5-20>|off.
"network" - How players connect and how much they are sent. The defaults suit most servers.
use_steam_relay How players reach the server: true (default) or false.
@@ -272,6 +280,17 @@ polls Who may ask everyone a question with up to six answers:
Players answer on the card or with /1, /2... A poll runs
nothing; it ends after "poll_seconds" (default 60), or when
whoever started it (or an admin) types /poll end.
Server votes The console (and scripts that talk to it) starts the same
votes and polls: "vote tod night", "vote map grom",
"vote noclip off", "poll Next map? | Grom | Stadium" and
"poll end". A vote still has to be on, and its "min_players"
met; a poll may be asked whatever "polls" says. The server has
no vote of its own and no cooldown. Chat reads "The server
started a vote to ..." or "The server asks: ...".
"poll-run <command> | <question> | <answer>..." also runs a
command for the answer that wins, {answer} replaced by it
(nothing on a tie or when nobody answered), e.g.
poll-run tod {answer} | Time of day? | morning | noon | night
"announcements" - Messages from the server.
messages Lines the server posts in turn, one every "interval_minutes"
@@ -360,7 +379,13 @@ and change voice, distances, placement and kicks from the Multiplayer menu.
is map, kick, tod or a custom vote's name, which
also takes on|off|<percent>.
votes polls off|admins|everyone votes poll-seconds <n> votes starter-yes on|off
announce <text> Tell everyone, on a card.
vote <map|kick|tod|<custom vote>> [argument] Start a vote as the server
(see Server votes).
poll <question> | <answer> | <answer>... Ask everyone (2 to 6 answers).
poll end End the running poll now.
poll-run <command with {answer}> | <question> | <answer>... A poll whose
winning answer runs the command (console only).
announce <text> Tell everyone, in chat and on a card.
announce-to <player> <text> Tell one player, on a card.
announcements [list | add <text> | remove <n> | clear | interval <minutes>|off]
The messages posted on a timer.
+4
View File
@@ -77,6 +77,10 @@ int run() {
ServerConfig scaled;
scaled.bone_scale_limit = 0.5f;
check(config_error(scaled).find("bone_scale_limit") != std::string::npos, "A bone scale limit under 1 was accepted");
check(has("anti_cheat.bone_reach_limit") && config.bone_reach_limit == 1, "bone_reach_limit is not a new setting of 1");
ServerConfig reached;
reached.bone_reach_limit = 0.1f;
check(config_error(reached).find("bone_reach_limit") != std::string::npos, "A bone reach limit under half a metre was accepted");
check(has("network.pack_ms") && config.pack_ms == 10, "pack_ms is not a new setting of 10");
check(has("network.threads") && config.threads == 0, "threads is not a new setting of 0");
check(has("network.finger_distance") && config.finger_distance == 25, "finger_distance is not a new setting of 25");
+51 -1
View File
@@ -15,10 +15,12 @@ namespace {
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"
"voice on|off | voice-range <50-1000> | distances <full> <half> <half-return> <low> | crowd <n>|off | rate <KB/s> | bone-scale <1-8>|off\n"
"voice on|off | voice-range <50-1000> | distances <full> <half> <half-return> <low> | crowd <n>|off | rate <KB/s> | bone-scale <1-8>|off | bone-reach <0.5-20>|off\n"
"placement everyone|admins|nobody | objects <number>|off | object-scaling on|off | effects on|off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n"
"tpall [player] | tphere <player> | votes [<vote> on|off|<percent>|seconds|cooldown|min-players <n>] | vote-cancel\n"
"votes polls off|admins|everyone | votes poll-seconds <n> | votes starter-yes on|off\n"
"vote <map|kick|tod|<custom vote>> [argument] | poll <question> | <answer> | <answer>... | poll end\n"
"poll-run <command with {answer}> | <question> | <answer> | <answer>...\n"
"announce <text> | announce-to <player> <text> | announcements [list|add <text>|remove <n>|clear|interval <minutes>|off]\n"
"map-pool [add|remove <map>|clear] | rotation [<minutes>|off]\n"
"park <lot> <layout> | park random | layer-sync on|off | layer <key> default|on|off | tod <time|default>\n"
@@ -354,6 +356,20 @@ std::string Host::command(std::string_view line, std::uint64_t admin) {
: value == 1.f ? std::string("Skaters show at the game's own proportions: resized body parts are not passed on.")
: "Resized body parts show at up to " + std::to_string(value).substr(0, 4) + "x.");
}
if (name == "bone-reach") {
const bool off = argument == "off";
float value{};
const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), value);
if (!off && (parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || !(value >= .5f && value <= 20.f)))
return "bone-reach <0.5-20>|off: how many metres a bone of a skater may be from the one it hangs from (now " +
(config_.bone_reach_limit > 0.f ? std::to_string(config_.bone_reach_limit).substr(0, 4) : std::string("off")) + ")";
config_.bone_reach_limit = off ? 0.f : value;
// Whole states go again so that nobody keeps a reference with the old places in it.
for (auto &[id, guest] : guests_)
for (const auto &[other, unused] : guests_) guest->sender.forget(other, PacketKind::pose);
return changed(off ? std::string("Skaters' bones may be moved any distance.")
: "Skaters' bones show at most " + std::to_string(value).substr(0, 4) + " m from where they hang.");
}
if (name == "crowd") {
// crowd <poses a second>|off: the most one player is sent (crowd_limits).
auto value = number(argument);
@@ -494,6 +510,40 @@ std::string Host::command(std::string_view line, std::uint64_t admin) {
cancel_vote(console ? "the server cancelled it" : "an admin cancelled it");
return "Vote cancelled.";
}
if (name == "vote") {
// A vote started by the server: the same card and checks as /vote, without a cooldown or a vote of its own.
Guest *by = console ? nullptr : find(admin);
const auto [what_text, rest] = split(argument);
const auto what = lower(what_text);
const auto started = [](std::string why) { return why.empty() ? std::string("Vote started.") : why; };
if (what.empty()) return vote_ ? running_vote_text() : std::string("vote <map|kick|tod|<custom vote>> [argument]");
if (what == "map") return started(start_vote(by, VoteKind::map, rest));
if (what == "kick") return started(start_vote(by, VoteKind::kick, rest));
if (what == "tod" || what == "time") return started(start_vote(by, VoteKind::time, rest));
for (std::size_t i = 0; i < config_.votes.custom.size(); ++i)
if (config_.votes.custom[i].name == what) return started(start_vote(by, VoteKind::custom, rest, i));
return "No vote is called \"" + what + "\": vote map, kick, tod or a custom vote's name (votes lists them).";
}
if (name == "poll") {
Guest *by = console ? nullptr : find(admin);
if (lower(trim(argument)) == "end") {
const auto why = end_poll(by);
return why.empty() ? "Poll ended." : why;
}
const auto why = start_poll(by, argument);
return why.empty() ? "Poll started." : why;
}
if (name == "poll-run") {
// "poll-run <command with {answer}> | <question> | <answer>...": the winner's command runs as
// the console's, so only the console may set one up.
if (!console) return "poll-run is for the server console only.";
const auto bar = argument.find('|');
const auto run = trim(argument.substr(0, bar == std::string_view::npos ? 0 : bar));
if (run.empty())
return "poll-run <command with {answer}> | <question> | <answer> | <answer>..., e.g. poll-run tod {answer} | Time of day? | morning | night";
const auto why = start_poll(nullptr, argument.substr(bar + 1), std::string(run));
return why.empty() ? "Poll started; the winning answer runs: " + std::string(run) : why;
}
if (name == "tpall" || name == "tphere") {
// Where they go: the admin who asked, or (tpall from the console) the named player.
Guest *to{};
+4
View File
@@ -116,6 +116,7 @@ Layout layout(const ServerConfig &c) {
anti_cheat.set("allowed_scoring_mods", std::move(allowed));
anti_cheat.set("enforce_tuning", c.enforce_tuning);
anti_cheat.set("bone_scale_limit", c.bone_scale_limit);
anti_cheat.set("bone_reach_limit", c.bone_reach_limit);
auto &network = root.section("network");
network.set("use_steam_relay", c.use_steam_relay);
@@ -317,6 +318,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector<std::str
if (const auto fingerprint = parse_scoring(value.string())) c.score_allow.push_back(*fingerprint);
c.enforce_tuning = get("anti_cheat", "enforce_tuning", c.enforce_tuning);
c.bone_scale_limit = get("anti_cheat", "bone_scale_limit", c.bone_scale_limit);
c.bone_reach_limit = get("anti_cheat", "bone_reach_limit", c.bone_reach_limit);
c.use_steam_relay = get("network", "use_steam_relay", c.use_steam_relay);
c.send_rate = get("network", "send_rate", c.send_rate);
@@ -573,6 +575,8 @@ std::string config_error(const ServerConfig &c) {
if (c.send_rate < 128 || c.send_rate > 16384) return "send_rate must be 128 to 16384 (KB/s for each player).";
if (c.bone_scale_limit != 0 && !(c.bone_scale_limit >= 1.f && c.bone_scale_limit <= 8.f))
return "bone_scale_limit must be 0 (no limit) or 1 to 8 (1: no resized body parts at all).";
if (c.bone_reach_limit != 0 && !(c.bone_reach_limit >= .5f && c.bone_reach_limit <= 20.f))
return "bone_reach_limit must be 0 (no limit) or 0.5 to 20 metres.";
if (c.pack_ms > 50) return "pack_ms must be 0 (off) to 50.";
if (c.threads > 32) return "threads must be 0 (one for each processor but one) to 32.";
if (c.finger_distance > 10000) return "finger_distance must be 0 (fingers always sent) to 10000.";
+3
View File
@@ -79,6 +79,9 @@ struct ServerConfig {
// inverse the least). The game's own skater height is a scale as well, so 1 shows every
// skater at one height and build; 2, the default, leaves height alone. 0 is no limit.
float bone_scale_limit = 2;
// How far (metres) a bone of a skater's body or board may be from the one it hangs from
// for the other players (limit_bone_reach). 0: no limit.
float bone_reach_limit = 1;
// How players reach the server: true, through Steam's relay network only; false, straight
// to `port` (UDP). A direct server still answers through the relays, for a player the port
// does not reach, one who has turned direct connections off, or an older game.
+3 -1
View File
@@ -284,9 +284,10 @@ Host::Guest::KeptPose *Host::keep_pose(Guest &from, const Packet &packet) {
while (kept.size() >= 96) kept.pop_front();
if (!kept.empty() && kept.back().sequence == packet.sequence) return &kept.back();
// What a mod resized on its player's skater reaches the others only as far as the server allows.
if (config_.bone_scale_limit >= 1.f) {
if (config_.bone_scale_limit >= 1.f || config_.bone_reach_limit > 0.f) {
auto pose = packet.pose;
limit_bone_scale(pose, config_.bone_scale_limit);
limit_bone_reach(pose, config_.bone_reach_limit);
kept.push_back({packet.sequence, packet.time_us, now_, 0, pose_codec::quantize(pose)});
} else {
kept.push_back({packet.sequence, packet.time_us, now_, 0, pose_codec::quantize(packet.pose)});
@@ -536,6 +537,7 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6
if (packet.kind == PacketKind::pose) {
// What a mod resized on its player's skater reaches the others only as far as the server allows.
if (config_.bone_scale_limit >= 1.f) limit_bone_scale(data.packet.pose, config_.bone_scale_limit);
limit_bone_reach(data.packet.pose, config_.bone_reach_limit);
}
data.raw = encode(data.packet, true);
data.wire = encode_wire_bytes(data.raw);
+6 -4
View File
@@ -191,7 +191,7 @@ class Host {
Log log_;
ActivityLog activity_; // what players do, for the console (config_.activity_log)
// The running player vote (server_votes.cpp), and when each player may start another.
// A poll is one too: it has answers instead of yes and no, and runs nothing.
// A poll is one too: it has answers instead of yes and no, and runs nothing (but the console's poll-run).
struct Vote {
VoteKind kind{};
std::size_t custom{}; // custom: which of config_.votes.custom
@@ -201,6 +201,7 @@ class Host {
std::set<std::uint64_t> yes, no;
std::vector<std::string> answers; // poll
std::map<std::uint64_t, std::size_t> chosen; // poll: each voter's answer
std::string run; // poll-run: the console command for the winner ("{answer}")
std::uint64_t ends{};
};
std::optional<Vote> vote_;
@@ -334,11 +335,12 @@ class Host {
// Chat commands and votes (server_votes.cpp).
void chat_command(Guest &, std::string_view line);
void run_custom_command(Guest &, const CustomCommand &, std::string_view argument);
void start_vote(Guest &, VoteKind, std::string_view argument, std::size_t custom = 0);
void start_poll(Guest &, std::string_view text);
// Starting a vote or poll: nullptr is the server console; the text says why it did not start ("" = it did).
std::string start_vote(Guest *starter, VoteKind, std::string_view argument, std::size_t custom = 0);
std::string start_poll(Guest *starter, std::string_view text, std::string run = {});
void cast_vote(Guest &, bool yes);
void answer_poll(Guest &, std::size_t answer);
void end_poll(Guest &);
std::string end_poll(Guest *by);
void check_vote(bool expired);
void cancel_vote(const std::string &why);
const VoteSetting &vote_setting(VoteKind, std::size_t custom = 0) const;
+64 -46
View File
@@ -167,9 +167,12 @@ void Host::chat_command(Guest &guest, std::string_view line) {
return static_cast<std::size_t>(word[0] - '1');
};
if (const auto chosen = answer(verb)) return answer_poll(guest, *chosen);
// Starting a vote or poll answers only when it did not start.
const auto say = [&](const std::string &why) {
if (!why.empty()) reply(guest, why);
};
if (verb == "poll") {
if (lower(trim(rest)) == "end") return end_poll(guest);
return start_poll(guest, rest);
return say(lower(trim(rest)) == "end" ? end_poll(&guest) : start_poll(&guest, rest));
}
if (verb == "yes" || verb == "y") return cast_vote(guest, true);
if (verb == "no" || verb == "n") return cast_vote(guest, false);
@@ -179,9 +182,9 @@ void Host::chat_command(Guest &guest, std::string_view line) {
if (what == "yes" || what == "y") return cast_vote(guest, true);
if (what == "no" || what == "n") return cast_vote(guest, false);
if (const auto chosen = answer(what)) return answer_poll(guest, *chosen);
if (what == "map") return start_vote(guest, VoteKind::map, argument);
if (what == "kick") return start_vote(guest, VoteKind::kick, argument);
if (what == "tod" || what == "time") return start_vote(guest, VoteKind::time, argument);
if (what == "map") return say(start_vote(&guest, VoteKind::map, argument));
if (what == "kick") return say(start_vote(&guest, VoteKind::kick, argument));
if (what == "tod" || what == "time") return say(start_vote(&guest, VoteKind::time, argument));
if (what == "list") {
if (custom.empty()) return reply(guest, "This server has no votes of its own.");
std::string text;
@@ -191,7 +194,7 @@ void Host::chat_command(Guest &guest, std::string_view line) {
return reply(guest, text, static_cast<unsigned>(multiplayer::server_custom_vote_limit));
}
for (std::size_t i = 0; i < config_.votes.custom.size(); ++i)
if (config_.votes.custom[i].name == what) return start_vote(guest, VoteKind::custom, argument, i);
if (config_.votes.custom[i].name == what) return say(start_vote(&guest, VoteKind::custom, argument, i));
if (vote_) return reply(guest, running_vote_text());
return reply(guest, enabled_votes() || !custom.empty() ? "Start one with /vote map, /vote kick, /vote tod or /vote list (see /help)."
: "This server has no player votes.");
@@ -231,40 +234,43 @@ std::string Host::running_vote_text() const {
return "A vote is running: " + vote_->label + ". Type /yes or /no.";
}
void Host::start_vote(Guest &guest, VoteKind kind, std::string_view argument, std::size_t custom) {
// `starter`: the player who asked, or nullptr for the server console. Returns why it did not
// start, or "" when it did. The server has no vote of its own and no cooldown.
std::string Host::start_vote(Guest *starter, VoteKind kind, std::string_view argument, std::size_t custom) {
const auto &setting = vote_setting(kind, custom);
const std::string slash = starter ? "/" : ""; // usage: "/vote ..." in chat, "vote ..." on the console
const bool on = kind == VoteKind::custom ? setting.enabled : (enabled_votes() & vote_bit(kind)) != 0;
if (!on)
return reply(guest, kind == VoteKind::time && setting.enabled ? std::string("Time of day votes need world layer sync on the server.")
: kind == VoteKind::custom ? "That vote is off on this server."
: std::string(vote_name(kind)) + " votes are off on this server.");
if (vote_) return reply(guest, running_vote_text());
if (const auto wait = vote_cooldowns_.find(guest.member.id); wait != vote_cooldowns_.end() && now_ < wait->second)
return reply(guest, "Wait " + std::to_string((wait->second - now_) / 1000000 + 1) + " s before starting another vote.");
return kind == VoteKind::time && setting.enabled ? std::string("Time of day votes need world layer sync on the server.")
: kind == VoteKind::custom ? "That vote is off on this server."
: std::string(vote_name(kind)) + " votes are off on this server.";
if (vote_) return running_vote_text();
if (const auto wait = starter ? vote_cooldowns_.find(starter->member.id) : vote_cooldowns_.end(); wait != vote_cooldowns_.end() && now_ < wait->second)
return "Wait " + std::to_string((wait->second - now_) / 1000000 + 1) + " s before starting another vote.";
if (players() < setting.min_players)
return reply(guest, "That vote needs " + std::to_string(setting.min_players) + " players on (" + std::to_string(players()) + " now).");
return "That vote needs " + std::to_string(setting.min_players) + " players on (" + std::to_string(players()) + " now).";
Vote vote;
vote.kind = kind;
vote.custom = custom;
vote.starter = guest.member.id;
vote.starter = starter ? starter->member.id : 0;
switch (kind) {
case VoteKind::map: {
if (argument.empty()) return reply(guest, "/vote map <map>, e.g. /vote map grom");
if (argument.empty()) return slash + "vote map <map>, e.g. " + slash + "vote map grom";
// Players vote between the server's own maps (/maps); a raw level path is admins only.
const auto *level = find_level(argument);
if (!level || !valid_map_destination(map_destination(argument)))
return reply(guest, "No single map is called \"" + std::string(argument) + "\".");
if (!in_map_pool(config_, level->asset)) return reply(guest, level->name + " is not one of this server's maps.\n" + pool_text());
if (map_hash(map_destination(argument)) == map_) return reply(guest, "The server is already on that map.");
return "No single map is called \"" + std::string(argument) + "\".";
if (!in_map_pool(config_, level->asset)) return level->name + " is not one of this server's maps.\n" + pool_text();
if (map_hash(map_destination(argument)) == map_) return "The server is already on that map.";
vote.value = std::string(argument);
vote.label = "change the map to " + map_label(argument);
break;
}
case VoteKind::kick: {
auto *target = match_player(argument);
if (!target) return reply(guest, "No single connected player matches \"" + std::string(argument) + "\".");
if (target == &guest) return reply(guest, "You cannot vote to kick yourself.");
if (is_admin(target->member.id)) return reply(guest, "Admins cannot be kicked by a vote.");
if (!target) return "No single connected player matches \"" + std::string(argument) + "\".";
if (target == starter) return "You cannot vote to kick yourself.";
if (is_admin(target->member.id)) return "Admins cannot be kicked by a vote.";
vote.target = target->member.id;
vote.label = "kick " + guest_name(*target);
break;
@@ -272,7 +278,7 @@ void Host::start_vote(Guest &guest, VoteKind kind, std::string_view argument, st
case VoteKind::time: {
const auto wanted = lower(argument);
if (std::find(times.begin(), times.end(), wanted) == times.end())
return reply(guest, "/vote tod <default|morning|noon|afternoon|evening|night|weatherday|weathernight>");
return slash + "vote tod <default|morning|noon|afternoon|evening|night|weatherday|weathernight>";
vote.value = wanted;
vote.label = "set the time of day to " + wanted;
break;
@@ -283,7 +289,7 @@ void Host::start_vote(Guest &guest, VoteKind kind, std::string_view argument, st
auto choice = lower(trim(argument));
if (v.choices.empty()) choice.clear();
else if (std::find(v.choices.begin(), v.choices.end(), choice) == v.choices.end())
return reply(guest, "/vote " + v.name + " <" + joined(v.choices, "|") + ">" + (v.description.empty() ? "" : ": " + v.description));
return slash + "vote " + v.name + " <" + joined(v.choices, "|") + ">" + (v.description.empty() ? "" : ": " + v.description);
vote.value = fill(fill(v.command, "{map}", config_.map), "{arg}", choice);
vote.label = v.description.empty() ? v.name + (choice.empty() ? "" : " " + choice)
: v.description + (choice.empty() ? "" : ": " + choice);
@@ -291,31 +297,36 @@ void Host::start_vote(Guest &guest, VoteKind kind, std::string_view argument, st
if (!vote.label.empty() && vote.label.front() >= 'A' && vote.label.front() <= 'Z') vote.label.front() = static_cast<char>(vote.label.front() + 32);
break;
}
case VoteKind::poll: return start_poll(guest, argument);
case VoteKind::poll: return start_poll(starter, argument);
}
if (config_.votes.starter_votes_yes) vote.yes.insert(guest.member.id);
if (starter && config_.votes.starter_votes_yes) vote.yes.insert(starter->member.id);
if (!++vote_ids_) ++vote_ids_;
vote.id = vote_ids_;
const auto seconds = setting.seconds ? setting.seconds : config_.votes.seconds;
vote.ends = now_ + microseconds(seconds);
vote_cooldowns_[guest.member.id] = now_ + microseconds(setting.cooldown ? setting.cooldown : config_.votes.cooldown);
if (starter) vote_cooldowns_[starter->member.id] = now_ + microseconds(setting.cooldown ? setting.cooldown : config_.votes.cooldown);
const auto label = vote.label;
vote_ = std::move(vote);
send_chat(guest_name(guest) + " started a vote to " + label + " (" + std::to_string(setting.percent) + "% needed, " +
const auto who = starter ? guest_name(*starter) : std::string("The server");
send_chat(who + " started a vote to " + label + " (" + std::to_string(setting.percent) + "% needed, " +
std::to_string(seconds) + " s). Vote on the card at the right of your screen, or type /yes or /no.");
log_("[vote] " + guest_name(guest) + " started a vote to " + label + ".");
log_("[vote] " + who + " started a vote to " + label + ".");
check_vote(false);
return {};
}
void Host::start_poll(Guest &guest, std::string_view text) {
// `starter` nullptr: the server console, which may always ask (the "polls" setting is about
// players). `run`: a console command run for a clear winner, "{answer}" replaced by it.
std::string Host::start_poll(Guest *starter, std::string_view text, std::string run) {
using multiplayer::ServerPolls;
const auto polls = enabled_polls();
const bool admin = is_admin(guest.member.id);
if (polls == ServerPolls::off) return reply(guest, "Polls are off on this server.");
if (polls == ServerPolls::admins && !admin) return reply(guest, "Only admins can start a poll on this server.");
if (vote_) return reply(guest, running_vote_text());
if (const auto wait = vote_cooldowns_.find(guest.member.id); !admin && wait != vote_cooldowns_.end() && now_ < wait->second)
return reply(guest, "Wait " + std::to_string((wait->second - now_) / 1000000 + 1) + " s before starting a poll.");
const std::string slash = starter ? "/" : "";
const bool admin = !starter || is_admin(starter->member.id);
if (starter && polls == ServerPolls::off) return "Polls are off on this server.";
if (polls == ServerPolls::admins && !admin) return "Only admins can start a poll on this server.";
if (vote_) return running_vote_text();
if (const auto wait = starter ? vote_cooldowns_.find(starter->member.id) : vote_cooldowns_.end(); !admin && wait != vote_cooldowns_.end() && now_ < wait->second)
return "Wait " + std::to_string((wait->second - now_) / 1000000 + 1) + " s before starting a poll.";
// "<question> | <answer> | <answer>..."
std::vector<std::string> parts;
for (std::size_t at = 0; at <= text.size();) {
@@ -327,25 +338,28 @@ void Host::start_poll(Guest &guest, std::string_view text) {
at = bar + 1;
}
if (parts.size() < 3 || parts.size() > multiplayer::max_vote_answers + 1)
return reply(guest, "/poll <question> | <answer> | <answer>... (2 to " + std::to_string(multiplayer::max_vote_answers) +
" answers), e.g. /poll Next map? | Grom | San Vansterdam");
return slash + "poll <question> | <answer> | <answer>... (2 to " + std::to_string(multiplayer::max_vote_answers) +
" answers), e.g. " + slash + "poll Next map? | Grom | San Vansterdam";
Vote poll;
poll.kind = VoteKind::poll;
poll.starter = guest.member.id;
poll.starter = starter ? starter->member.id : 0;
poll.label = parts.front();
poll.answers.assign(parts.begin() + 1, parts.end());
poll.run = std::move(run);
if (!++vote_ids_) ++vote_ids_;
poll.id = vote_ids_;
poll.ends = now_ + microseconds(config_.votes.poll_seconds);
if (!admin) vote_cooldowns_[guest.member.id] = now_ + microseconds(config_.votes.cooldown);
if (!admin) vote_cooldowns_[starter->member.id] = now_ + microseconds(config_.votes.cooldown);
std::string choices;
for (std::size_t i = 0; i < poll.answers.size(); ++i) choices += (i ? " " : "") + std::string("/") + std::to_string(i + 1) + " " + poll.answers[i];
vote_ = std::move(poll);
send_chat(guest_name(guest) + " asks: " + vote_->label + " (" + std::to_string(config_.votes.poll_seconds) +
const auto who = starter ? guest_name(*starter) : std::string("The server");
send_chat(who + " asks: " + vote_->label + " (" + std::to_string(config_.votes.poll_seconds) +
" s). Answer on the card at the right of your screen, or type:");
send_chat(choices);
log_("[poll] " + guest_name(guest) + " started a poll: " + vote_->label + " " + joined(vote_->answers, " | "));
log_("[poll] " + who + " started a poll: " + vote_->label + " " + joined(vote_->answers, " | "));
check_vote(false);
return {};
}
void Host::cast_vote(Guest &guest, bool yes) {
@@ -372,11 +386,13 @@ void Host::answer_poll(Guest &guest, std::size_t answer) {
check_vote(false);
}
void Host::end_poll(Guest &guest) {
if (!vote_ || vote_->kind != VoteKind::poll) return reply(guest, "No poll is running.");
if (vote_->starter != guest.member.id && !is_admin(guest.member.id))
return reply(guest, "Only whoever started the poll, or an admin, can end it early.");
// `by` nullptr: the server console, which may always end it.
std::string Host::end_poll(Guest *by) {
if (!vote_ || vote_->kind != VoteKind::poll) return "No poll is running.";
if (by && vote_->starter != by->member.id && !is_admin(by->member.id))
return "Only whoever started the poll, or an admin, can end it early.";
check_vote(true);
return {};
}
std::set<std::uint64_t> Host::vote_voters(const Vote &vote) const {
@@ -455,6 +471,8 @@ void Host::check_vote(bool expired) {
const auto line = "Poll \"" + done.label + "\" ended: " + answer_count(done.answers, count) + " (" + outcome + ").";
send_chat(line);
log_("[poll] " + line);
// A console poll-run: the winning answer's command, as the console. A tie or no answer runs nothing.
if (!done.run.empty() && top && leaders.size() == 1) log_(command(fill(done.run, "{answer}", leaders.front())));
return;
}
if (vote.kind == VoteKind::kick && !find(vote.target)) return cancel_vote("the player left");
+1
View File
@@ -97,6 +97,7 @@ add_library(dingosdk_runtime SHARED
Extension/World/level_loading.cpp
Extension/World/loading_screen.cpp
Extension/Customization/preset_lookup_guard.cpp
Extension/Boot/exit_watch.cpp
Extension/Customization/developer_hoodie.cpp
Extension/Customization/developer_board.cpp
Extension/Skater/skater_model.cpp