diff --git a/Engine/Game/Build/20260929/native_cosmetics.h b/Engine/Game/Build/20260929/native_cosmetics.h index 97f0693..c169c63 100644 --- a/Engine/Game/Build/20260929/native_cosmetics.h +++ b/Engine/Game/Build/20260929/native_cosmetics.h @@ -35,4 +35,18 @@ inline constexpr std::uintptr_t named_lookup = 0x16fc900; inline constexpr std::array 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 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 script_preset_reference_prefix{0x4c,0x8d,0x05,0x81,0xa2,0x0d,0x03,0xe9}; +inline constexpr std::uintptr_t referenced_preset = 0x16fe9c0; +inline constexpr std::array referenced_preset_prefix{ + 0x48,0x8b,0x09,0x48,0x85,0xc9,0x74,0x07,0x48,0x8b,0x01,0x48,0xff,0x60,0x18,0x33,0xc0}; } diff --git a/Extension/Boot/exit_watch.cpp b/Extension/Boot/exit_watch.cpp new file mode 100644 index 0000000..b329111 --- /dev/null +++ b/Extension/Boot/exit_watch.cpp @@ -0,0 +1,52 @@ +#include "exit_watch.h" +#include "Engine/Core/Log/logging.h" +#include +#include + +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 watching{}; +std::atomic 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 diff --git a/Extension/Boot/exit_watch.h b/Extension/Boot/exit_watch.h new file mode 100644 index 0000000..94d7c43 --- /dev/null +++ b/Extension/Boot/exit_watch.h @@ -0,0 +1,10 @@ +#pragma once +#include + +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; +} diff --git a/Extension/Customization/preset_lookup_guard.cpp b/Extension/Customization/preset_lookup_guard.cpp index 50aa2f0..51eb239 100644 --- a/Extension/Customization/preset_lookup_guard.cpp +++ b/Extension/Customization/preset_lookup_guard.cpp @@ -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 original_add_preset{}; +std::uintptr_t image{}; +int add_preset(void *state) { + const auto reference = reinterpret_cast(image + native::script_preset_reference)(state, 3); + if (reference && reinterpret_cast(image + native::referenced_preset)(reference, 0)) + return original_add_preset.load()(state); + static std::atomic 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 bool matches(std::uintptr_t address, const std::array &prefix) { + std::array 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(base + native::add_shader_preset); + void *previous{}; + if (hook_prepare(target, reinterpret_cast(&add_preset), &previous) != HookOk) return false; + original_add_preset = reinterpret_cast(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; } diff --git a/Extension/Customization/preset_lookup_guard.h b/Extension/Customization/preset_lookup_guard.h index 803e80e..c9f62e9 100644 --- a/Extension/Customization/preset_lookup_guard.h +++ b/Extension/Customization/preset_lookup_guard.h @@ -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; } diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index 0ae2c75..5fef1b5 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -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 &position, const std::array &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 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 diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index ef6d153..ef6d92c 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -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 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 encode(const Packet &, bool compact_pose, std::uint32_t pose_interval_us); diff --git a/Extension/Multiplayer/Remote/playback_buffers.cpp b/Extension/Multiplayer/Remote/playback_buffers.cpp index 79f461d..c7989b5 100644 --- a/Extension/Multiplayer/Remote/playback_buffers.cpp +++ b/Extension/Multiplayer/Remote/playback_buffers.cpp @@ -169,6 +169,10 @@ template 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).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(); diff --git a/Extension/Multiplayer/Test/bandwidth_tests.cpp b/Extension/Multiplayer/Test/bandwidth_tests.cpp index 586a36c..d1e5b67 100644 --- a/Extension/Multiplayer/Test/bandwidth_tests.cpp +++ b/Extension/Multiplayer/Test/bandwidth_tests.cpp @@ -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 &a, const std::array &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}; diff --git a/Extension/Multiplayer/Test/pose_codec_tests.cpp b/Extension/Multiplayer/Test/pose_codec_tests.cpp index fa34279..8a753a5 100644 --- a/Extension/Multiplayer/Test/pose_codec_tests.cpp +++ b/Extension/Multiplayer/Test/pose_codec_tests.cpp @@ -356,13 +356,49 @@ int bench(const char *path) { return 2; } std::vector 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 skater_reach, board_reach; + float anchor_reach{}, board_from_root{}, board_anchor_reach{}; + const auto length_of = [](const std::array &v) { return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); }; + const auto between = [&](const std::array &a, const std::array &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(&time), 8) || !in.read(reinterpret_cast(&length), 4) || length > 1 << 20) break; std::vector raw(length); if (!in.read(reinterpret_cast(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(dingosdk::multiplayer::protocol_version), raw[5] = static_cast(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 &reach, const char *what) { + std::vector 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(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'; diff --git a/Extension/UI/Overlay/overlay_input.cpp b/Extension/UI/Overlay/overlay_input.cpp index 95ee5c3..b703e84 100644 --- a/Extension/UI/Overlay/overlay_input.cpp +++ b/Extension/UI/Overlay/overlay_input.cpp @@ -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() { diff --git a/Runtime/bootstrap.cpp b/Runtime/bootstrap.cpp index 92a310f..b62b5a9 100644 --- a/Runtime/bootstrap.cpp +++ b/Runtime/bootstrap.cpp @@ -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 @@ -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; diff --git a/Server/README.txt b/Server/README.txt index cd54460..aad2747 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -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 | | ..." 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|. votes polls off|admins|everyone votes poll-seconds votes starter-yes on|off - announce Tell everyone, on a card. + vote > [argument] Start a vote as the server + (see Server votes). + poll | | ... Ask everyone (2 to 6 answers). + poll end End the running poll now. + poll-run | | ... A poll whose + winning answer runs the command (console only). + announce Tell everyone, in chat and on a card. announce-to Tell one player, on a card. announcements [list | add | remove | clear | interval |off] The messages posted on a timer. diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index ff33b2c..e11bca0 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -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"); diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index ebaffb2..9fb8b68 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -15,10 +15,12 @@ namespace { constexpr std::string_view help_text = "status | net [player] | players | say | msg | msg-party | msg-admins | kick | ban [name] | unban | bans\n" "map | maps | name | password | welcome | listed on|off\n" - "voice on|off | voice-range <50-1000> | distances | crowd |off | rate | bone-scale <1-8>|off\n" + "voice on|off | voice-range <50-1000> | distances | crowd |off | rate | bone-scale <1-8>|off | bone-reach <0.5-20>|off\n" "placement everyone|admins|nobody | objects |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 | votes [ on|off||seconds|cooldown|min-players ] | vote-cancel\n" "votes polls off|admins|everyone | votes poll-seconds | votes starter-yes on|off\n" + "vote > [argument] | poll | | ... | poll end\n" + "poll-run | | | ...\n" "announce | announce-to | announcements [list|add |remove |clear|interval |off]\n" "map-pool [add|remove |clear] | rotation [|off]\n" "park | park random | layer-sync on|off | layer default|on|off | tod \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 |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 > [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 | | ...": 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 | | | ..., 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{}; diff --git a/Server/server_config.cpp b/Server/server_config.cpp index c3cdb35..bf3f635 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -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 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."; diff --git a/Server/server_config.h b/Server/server_config.h index 2fe884c..ab5d7f9 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -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. diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 6d59f65..33421b3 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -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); diff --git a/Server/server_host.h b/Server/server_host.h index 7037e13..e8eb7b0 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -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 yes, no; std::vector answers; // poll std::map 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_; @@ -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; diff --git a/Server/server_votes.cpp b/Server/server_votes.cpp index bcd761a..cd6f221 100644 --- a/Server/server_votes.cpp +++ b/Server/server_votes.cpp @@ -167,9 +167,12 @@ void Host::chat_command(Guest &guest, std::string_view line) { return static_cast(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(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 , e.g. /vote map grom"); + if (argument.empty()) return slash + "vote 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 "); + return slash + "vote tod "; 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(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."; // " | | ..." std::vector 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 | | ... (2 to " + std::to_string(multiplayer::max_vote_answers) + - " answers), e.g. /poll Next map? | Grom | San Vansterdam"); + return slash + "poll | | ... (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 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"); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 482299f..563c105 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -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