From c7d094ae453bebdc64c246f80ab42146003cd9c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20M=C3=B6ller?= Date: Fri, 9 Oct 2026 21:48:50 +0000 Subject: [PATCH 1/4] Server votes: start votes and polls from the console The console (stdin, mp server, scripts) can now start the real vote and poll cards instead of faking them with chat lines: vote > [argument] poll | | ... and poll end poll-run | | ... A server vote keeps every check a player's has (the vote is on, min_players, one vote at a time, the map pool, the tod list, the custom choices, admins cannot be kicked) but has no cooldown and no yes of its own; its starter is 0. A server poll may be asked whatever "polls" says. Chat reads "The server started a vote to ..." / "The server asks: ...". poll-run runs its command for a clear winner, as the console, and nothing on a tie or no answers; it is refused from anywhere but the console. start_vote, start_poll and end_poll take a Guest * (nullptr: the console) and return why they did not start; every player message and log line is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QVkhUHpDKhPgxS3HNWNXB8 --- Server/README.txt | 17 ++++++ Server/server_commands.cpp | 36 ++++++++++++ Server/server_host.h | 10 ++-- Server/server_votes.cpp | 110 +++++++++++++++++++++---------------- 4 files changed, 123 insertions(+), 50 deletions(-) diff --git a/Server/README.txt b/Server/README.txt index ecdf1a7..1e5b794 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -272,6 +272,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" @@ -344,6 +355,12 @@ 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 + 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. announcements [list | add | remove | clear | interval |off | card on|off] The messages posted on a timer. diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index 336eb3c..5ff7c08 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -19,6 +19,8 @@ constexpr std::string_view help_text = "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 | announcements [list|add |remove |clear|interval |off|card on|off]\n" "map-pool [add|remove |clear] | rotation [|off]\n" "park | park random | layer-sync on|off | layer default|on|off | tod \n" @@ -486,6 +488,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_host.h b/Server/server_host.h index 5356a53..f0fca98 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_; @@ -332,11 +333,12 @@ class Host { void apply_layers(); // Chat commands and votes (server_votes.cpp). void chat_command(Guest &, std::string_view line); - 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 8631065..93a20d2 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."); @@ -215,40 +218,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; @@ -256,7 +262,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; @@ -267,7 +273,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); @@ -275,31 +281,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();) { @@ -311,25 +322,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) { @@ -356,11 +370,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 { @@ -439,6 +455,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"); From 978c2a2430af37b484442a11cceb0b5763d28e90 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Fri, 9 Oct 2026 16:58:40 -0500 Subject: [PATCH 2/4] Guard a missing shader preset, end a stuck close, unload guard off Costume crash: the skater scripts' item:addEsShaderPreset uses the preset it is handed without checking that it is loaded, and the game crashed in the material's construction shortly after arriving on a server's map while wearing a modded costume. The call is now checked first: an item whose preset is not there is shown without it, and the log says so. Stuck close: once the player closes the game, a watch starts. If the window has gone or stopped answering and the process is still there 8 seconds later, the process is ended. A window that still answers (the close was turned down) is left alone, and the watch stands down after two minutes. Level unload guard: switched off. Skipping the entry that crashed left its reference registered, so leaving the level never finished and the game hung with the world unloaded. The crash it covered is back until the reference is released properly. --- Engine/Game/Build/20260929/native_cosmetics.h | 14 +++++ Extension/Boot/exit_watch.cpp | 52 +++++++++++++++++++ Extension/Boot/exit_watch.h | 10 ++++ .../Customization/preset_lookup_guard.cpp | 39 +++++++++++++- Extension/Customization/preset_lookup_guard.h | 3 +- Extension/UI/Overlay/overlay_input.cpp | 3 ++ Runtime/bootstrap.cpp | 8 +-- cmake/Runtime.cmake | 1 + 8 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 Extension/Boot/exit_watch.cpp create mode 100644 Extension/Boot/exit_watch.h 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/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/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 From c733816d629073cfa976185b0b07df76fce6cb65 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Fri, 9 Oct 2026 17:03:35 -0500 Subject: [PATCH 3/4] Guard a missing shader preset, end a stuck close, unload guard off Costume crash: the skater scripts' item:addEsShaderPreset uses the preset it is handed without checking that it is loaded, and the game crashed in the material's construction shortly after arriving on a server's map while wearing a modded costume. The call is now checked first: an item whose preset is not there is shown without it, and the log says so. Stuck close: once the player closes the game, a watch starts. If the window has gone or stopped answering and the process is still there 8 seconds later, the process is ended. A window that still answers (the close was turned down) is left alone, and the watch stands down after two minutes. Level unload guard: switched off. Skipping the entry that crashed left its reference registered, so leaving the level never finished and the game hung with the world unloaded. The crash it covered is back until the reference is released properly. --- .../Multiplayer/Test/pose_codec_tests.cpp | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/Extension/Multiplayer/Test/pose_codec_tests.cpp b/Extension/Multiplayer/Test/pose_codec_tests.cpp index fa34279..5a56b22 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(), 40); ++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'; From 20bd777b4c937e93b5500dfd42190dafa00dd7f7 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Fri, 9 Oct 2026 17:11:16 -0500 Subject: [PATCH 4/4] Anti-cheat: limit how far a skater's bones can be moved A hacked client could move its skater's bones anywhere and stretch the skater across the map for everyone: the server limited a bone's scale but not its place. Server: new anti_cheat setting bone_reach_limit, default 1 metre (0.5 to 20, 0 for off; console bone-reach <0.5-20>|off). A body or board bone further than that from its parent is pulled back before the pose is passed on. On the game's rig these bones never move (the longest, a thigh, is 0.44 m). The fourteen skater bones that do move by metres (they follow the board and the body in a fall) are held to 100 m, and the board's rig to 5 m from the board. Client: every pose a game is shown is held to 2 m reach and 8x bone scale, whoever sent it, which covers servers that have not updated and sessions without a dedicated server. A client used to accept up to 100x scale. The limits come from two recorded play sessions; the exempt bones are picked by index for the current build's 395-bone rig. The pose codec bench now prints each recording's bone reach, and reads recordings made by earlier versions. --- Extension/Multiplayer/Net/protocol.cpp | 23 +++++++++++++++ Extension/Multiplayer/Net/protocol.h | 12 ++++++++ .../Multiplayer/Remote/playback_buffers.cpp | 4 +++ .../Multiplayer/Test/bandwidth_tests.cpp | 28 +++++++++++++++++++ .../Multiplayer/Test/pose_codec_tests.cpp | 2 +- Server/README.txt | 8 ++++++ Server/Test/server_config_tests.cpp | 4 +++ Server/server_commands.cpp | 16 ++++++++++- Server/server_config.cpp | 4 +++ Server/server_config.h | 3 ++ Server/server_host.cpp | 4 ++- 11 files changed, 105 insertions(+), 3 deletions(-) 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 5a56b22..8a753a5 100644 --- a/Extension/Multiplayer/Test/pose_codec_tests.cpp +++ b/Extension/Multiplayer/Test/pose_codec_tests.cpp @@ -392,7 +392,7 @@ int bench(const char *path) { 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(), 40); ++i) std::cout << ' ' << order[i] << '=' << reach[order[i]] << "m"; + 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"); diff --git a/Server/README.txt b/Server/README.txt index 1e5b794..6699c1a 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. diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index af44b9d..5830c41 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 5ff7c08..3ebb730 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -15,7 +15,7 @@ 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" @@ -356,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); diff --git a/Server/server_config.cpp b/Server/server_config.cpp index b084ce7..64dba14 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); @@ -305,6 +306,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 4f6d0e8..3d36656 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -69,6 +69,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 a5d8519..79cb64e 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);