From b321a77a773e6c05af41fcd4a8ce4a88c19dfbf5 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Thu, 20 Aug 2026 16:08:18 -0230 Subject: [PATCH 01/10] init --- game_patch/misc/saved_votes.cpp | 3 + game_patch/misc/vote_panel.cpp | 259 ++++++++++++++++++++++------ game_patch/misc/vpackfile.cpp | 7 +- game_patch/multi/alpine_packets.cpp | 53 ++++++ game_patch/multi/alpine_packets.h | 5 + game_patch/multi/dedi_cfg.cpp | 162 ++++++++++------- game_patch/multi/multi.cpp | 44 +++++ game_patch/multi/multi.h | 5 + game_patch/multi/mutators.cpp | 77 +++++---- game_patch/multi/mutators.h | 24 ++- game_patch/multi/server.cpp | 3 + game_patch/multi/server_internal.h | 17 +- game_patch/multi/vote_client.cpp | 50 +++++- game_patch/multi/vote_client.h | 15 ++ game_patch/multi/votes.cpp | 130 +++++++------- 15 files changed, 631 insertions(+), 223 deletions(-) diff --git a/game_patch/misc/saved_votes.cpp b/game_patch/misc/saved_votes.cpp index cb9eaba72..6a82db1fa 100644 --- a/game_patch/misc/saved_votes.cpp +++ b/game_patch/misc/saved_votes.cpp @@ -643,6 +643,9 @@ AfVoteCallParams saved_vote_build_params(const SavedVote& vote, const VoteOption case AfVoteType::Match: { params.level = vote.level; params.gametype = vote.gametype; + // A saved entry records the complete mutator selection, so an empty one + // means "no mutators", not "keep whatever the session runs". + params.mutators_explicit = true; if (vote.type == AfVoteType::Match) { params.team_size = static_cast(std::clamp(vote.team_size, 1, 8)); } diff --git a/game_patch/misc/vote_panel.cpp b/game_patch/misc/vote_panel.cpp index 9665f0575..c98635138 100644 --- a/game_patch/misc/vote_panel.cpp +++ b/game_patch/misc/vote_panel.cpp @@ -13,6 +13,7 @@ #include #include #include "vote_panel.h" +#include "alpine_options.h" #include "player.h" #include "saved_votes.h" #include "../hud/hud_internal.h" @@ -394,18 +395,28 @@ struct FormState std::string level_selection; // level NAME; empty = the "Current level" row bool manual_level = false; std::string manual_level_name; - int gametype_index = 0; // 0 = server default + int gametype_index = 0; // index into selectable_gametypes(); the panel always picks a concrete type int team_size = 4; int extend_minutes = af_vote_extend_default_minutes; std::vector mutators; int description_mutator = -1; - // The mutator section starts pre-selected with what the level in context runs - // anyway, and is re-derived when that context changes, but only until the - // player touches it, after which the form is theirs and is never stomped. + // The mutator section starts pre-selected with what the session is running + // anyway, and is re-derived when that changes, but only until the player + // touches it, after which the form is theirs and is never stomped. bool mutators_touched = false; - std::string mutators_baseline_key; // level string the pre-selection came from + // Only meaningful on a server too old to push its active set, where the + // pre-selection is the selected level's configured one. + std::string mutators_baseline_key; uint32_t mutators_baseline_generation = 0; // options generation it came from + uint32_t mutators_baseline_revision = 0; // active-set revision it came from + + // Same model as the mutator section: the cycler follows the selected level until + // the player moves it, after which it is theirs until Reset re-arms it. + bool gametype_touched = false; + std::string gametype_key; + bool gametype_key_valid = false; + bool gametype_key_match = false; // the key was resolved for a Match vote float level_scroll = 0.0f; float kick_scroll = 0.0f; @@ -1215,6 +1226,13 @@ std::string selected_level(); const std::vector& resolve_baseline(const VoteOptionsData& options, const std::string& level_string) { + // What the session is running right now, which is what a vote naming no + // mutators would keep. Level-independent, so the level argument only matters + // on a server too old to push it. + if (const std::vector* active = vote_active_mutators_get()) { + return *active; + } + // Empty is Match's "Current level" row (and the state right after a rebuild), // which resolves against whatever is running locally. Normalized the same way // the server normalizes a voted level name, so both resolve the same file; a @@ -1232,6 +1250,15 @@ const std::vector& resolve_baseline(const VoteOptionsData& opti return options.base_mutator_decls; } +// Pin the mutator section's derivation context to right now, so the baseline is not +// re-derived over what is currently in the form until something it keys on moves. +void pin_mutator_baseline_context() +{ + g_form.mutators_baseline_key = selected_level(); + g_form.mutators_baseline_generation = vote_options_loaded_generation(); + g_form.mutators_baseline_revision = vote_active_mutators_revision(); +} + // Every mutator deselected and back on the schema (factory) defaults. Shared by // apply_baseline and the saved-vote load, which both need a clean slate first. void reset_mutators_to_defaults(const VoteOptionsData& options) @@ -1356,8 +1383,9 @@ void build_form(const VoteOptionsData& options) // current level; do_form re-derives it as soon as that changes. apply_baseline(options, resolve_baseline(options, selected_level())); g_form.mutators_touched = false; - g_form.mutators_baseline_key = selected_level(); - g_form.mutators_baseline_generation = vote_options_loaded_generation(); + pin_mutator_baseline_context(); + g_form.gametype_touched = false; + g_form.gametype_key_valid = false; } void ensure_form(const VoteOptionsData& options) @@ -1389,17 +1417,42 @@ std::string selected_level() return g_form.level_selection; } +// The panel always picks a concrete game type, so this only reports "none" when the +// server offered nothing to pick -- which leaves the server's own resolution to it, +// exactly as a chat vote does. uint8_t selected_gametype(const VoteOptionsData& options, bool team_only) { - if (g_form.gametype_index <= 0) { - return af_vote_gametype_none; - } const auto gametypes = selectable_gametypes(options, team_only); - const int index = g_form.gametype_index - 1; - if (index < 0 || index >= static_cast(gametypes.size())) { + if (g_form.gametype_index < 0 || g_form.gametype_index >= static_cast(gametypes.size())) { return af_vote_gametype_none; } - return gametypes[index]->id; + return gametypes[g_form.gametype_index]->id; +} + +// The game type the selected level runs under when no override is voted: the +// server's own answer, carried per level in the options blob; for a name the server +// did not list, the same run-map and prefix rules the server would apply to it. +// Falls back to what is running here for Match's "Current level" row. +uint8_t default_gametype_for_level(const VoteOptionsData& options, const std::string& level_string) +{ + if (!level_string.empty()) { + const std::string wanted = normalize_level_filename(level_string); + // Ahead of the blob: a run map's own filename says DM, so a server that + // never loaded af_level_quirks.tbl advertises DM for it. This is only the + // suggestion — the cycler still submits whatever the player leaves it on. + if (is_known_run_level(wanted)) { + return static_cast(rf::NetGameType::NG_TYPE_RUN); + } + for (const auto& entry : options.levels) { + if (string_iequals(entry.filename, wanted)) { + return entry.natural_gametype; + } + } + if (auto from_prefix = multi_game_type_for_level_prefix(wanted)) { + return static_cast(*from_prefix); + } + } + return static_cast(rf::multi_get_game_type()); } // The game type the vote would actually run under. @@ -1409,15 +1462,38 @@ uint8_t effective_gametype(const VoteOptionsData& options, bool team_only) if (chosen != af_vote_gametype_none) { return chosen; } - const std::string level = selected_level(); - if (!level.empty()) { - for (const auto& entry : options.levels) { - if (string_iequals(entry.filename, level)) { - return entry.natural_gametype; - } + return default_gametype_for_level(options, selected_level()); +} + +// Cycler index of `game_type`, or 0 when this vote offers no such type -- for a +// Match on a level whose own game type is not a team type, that is the first team +// type rather than a selection the server would refuse. +int gametype_cycler_index(const VoteOptionsData& options, bool team_only, uint8_t game_type) +{ + const auto gametypes = selectable_gametypes(options, team_only); + for (size_t i = 0; i < gametypes.size(); ++i) { + if (gametypes[i]->id == game_type) { + return static_cast(i); } } - return static_cast(rf::multi_get_game_type()); + return 0; +} + +// Snug width for the game type cycler: it only ever shows a short game type tag, so +// the widest one on offer plus the arrows is all the row needs. +int gametype_cycler_width(const VoteOptionsData& options, bool team_only, int font, int max_w) +{ + int widest = 0; + for (const VoteGametypeInfo* gametype : selectable_gametypes(options, team_only)) { + const char* short_name = gametype_short_name(gametype->id); + const std::string_view text = + short_name ? std::string_view{short_name} : std::string_view{gametype->name}; + widest = std::max(widest, rf::gr::get_string_size(text, font).first); + } + const int want = widest + 2 * ui_cycler_arrow_width() + std::max(6, scaled(12.0f)); + // Never below the two arrows: like the level column's filter checkbox, a width + // clamped to nothing would leave a control that draws but cannot be clicked. + return std::max(2 * ui_cycler_arrow_width(), std::min(want, max_w)); } // A mutator that can't be used in this game type is shown greyed out. @@ -1698,18 +1774,14 @@ void load_saved_vote_into_form(const SavedVote& vote, const VoteOptionsData& opt const bool is_match = vote.type == AfVoteType::Match; // Resolved BEFORE the level: the level list is filtered against whichever - // game type ends up selected here, and that is not always the saved one (a - // game type this server does not offer falls back to "Default"). - g_form.gametype_index = 0; // "Default" - if (vote.gametype != af_vote_gametype_none) { - const auto gametypes = selectable_gametypes(options, is_match); - for (size_t i = 0; i < gametypes.size(); ++i) { - if (gametypes[i]->id == vote.gametype) { - g_form.gametype_index = static_cast(i) + 1; - break; - } - } - } + // game type ends up selected here, and that is not always the saved one. An + // entry saved before the panel dropped its "Server default" choice carries no + // game type at all, so it derives one from its level the same way a fresh + // selection would. + const uint8_t wanted_gametype = vote.gametype != af_vote_gametype_none + ? vote.gametype + : default_gametype_for_level(options, vote.level); + g_form.gametype_index = gametype_cycler_index(options, is_match, wanted_gametype); // Exactly what do_form hands do_level_column. const uint8_t effective_gametype = selected_gametype(options, is_match); @@ -1818,10 +1890,17 @@ void load_saved_vote_into_form(const SavedVote& vote, const VoteOptionsData& opt // The loaded values are the player's from here on, so the baseline logic must // never re-derive over them: mark them touched and pin its context to now. g_form.mutators_touched = true; - g_form.mutators_baseline_key = selected_level(); - g_form.mutators_baseline_generation = vote_options_loaded_generation(); + pin_mutator_baseline_context(); g_form.mutator_scroll = 0.0f; } + // Same for the game type the entry named: touched, so the level it just resolved + // does not re-derive over it. The cycler's Reset button un-pins it. + if (vote.type == AfVoteType::Level || vote.type == AfVoteType::Match) { + g_form.gametype_touched = true; + g_form.gametype_key = selected_level(); + g_form.gametype_key_match = vote.type == AfVoteType::Match; + g_form.gametype_key_valid = true; + } g_level_cache.sel_valid = false; } @@ -2149,6 +2228,9 @@ void send_vote_from_form(const VoteOptionsData& options) } params.gametype = selected_gametype(options, false); params.mutators = build_mutator_inputs(options, effective_gametype(options, false)); + // The panel always submits its complete selection, so an empty one means + // "no mutators" rather than "keep whatever the session runs". + params.mutators_explicit = true; break; } case AfVoteType::Match: { @@ -2156,6 +2238,7 @@ void send_vote_from_form(const VoteOptionsData& options) params.level = selected_level(); params.gametype = selected_gametype(options, true); params.mutators = build_mutator_inputs(options, effective_gametype(options, true)); + params.mutators_explicit = true; break; } case AfVoteType::Extend: @@ -2949,16 +3032,21 @@ void do_rotation_form(PanelUi& ui, const Layout& lo, const VoteOptionsData& opti void do_form(PanelUi& ui, const Layout& lo, const VoteOptionsData& options, const std::vector& tabs, int content_y) { - // Re-derive the mutator pre-selection whenever its context moves. - { + // Re-derive the mutator pre-selection whenever its context moves. With an active + // set pushed by the server the baseline no longer depends on the selected level, + // so only the revision moves; the level key still matters on an older server, + // where the pre-selection comes from the level's configured set. + if (!g_form.mutators_touched) { std::string baseline_key = selected_level(); const uint32_t generation = vote_options_loaded_generation(); - if (!g_form.mutators_touched - && (baseline_key != g_form.mutators_baseline_key - || generation != g_form.mutators_baseline_generation)) { + const uint32_t revision = vote_active_mutators_revision(); + if (baseline_key != g_form.mutators_baseline_key + || generation != g_form.mutators_baseline_generation + || revision != g_form.mutators_baseline_revision) { apply_baseline(options, resolve_baseline(options, baseline_key)); g_form.mutators_baseline_key = std::move(baseline_key); g_form.mutators_baseline_generation = generation; + g_form.mutators_baseline_revision = revision; } } @@ -3052,6 +3140,24 @@ void do_form(PanelUi& ui, const Layout& lo, const VoteOptionsData& options, } const bool is_match = type == AfVoteType::Match; + + // Pre-select the game type the picked level runs under, re-derived whenever the + // level moves (or the vote type does: Match offers only team types), until the + // player moves the cycler themselves. Hit-test pass only: the level list below is + // filtered by the selected game type, so moving it between the two passes of one + // frame would draw a list that was never hit-tested. + if (!ui.draw && !g_form.gametype_touched) { + std::string key = selected_level(); + if (!g_form.gametype_key_valid || key != g_form.gametype_key + || is_match != g_form.gametype_key_match) { + g_form.gametype_index = + gametype_cycler_index(options, is_match, default_gametype_for_level(options, key)); + g_form.gametype_key = std::move(key); + g_form.gametype_key_match = is_match; + g_form.gametype_key_valid = true; + } + } + const int col_gap = std::max(6, scaled(16.0f)); const int left_w = (lo.cw - col_gap) / 2; const Rect left_col{lo.cx, y, left_w, body_bottom - y}; @@ -3086,18 +3192,23 @@ void do_form(PanelUi& ui, const Layout& lo, const VoteOptionsData& options, { const auto gametypes = selectable_gametypes(options, is_match); - const int count = static_cast(gametypes.size()) + 1; // + "Default" - g_form.gametype_index = std::clamp(g_form.gametype_index, 0, count - 1); + const int count = static_cast(gametypes.size()); + g_form.gametype_index = count > 0 ? std::clamp(g_form.gametype_index, 0, count - 1) : 0; const int label_w = right_col.w / 2; if (ui.draw) { draw_label(ui, {right_col.x, ry + (lo.row_h - rf::gr::get_font_height(lo.font)) / 2, label_w, lo.row_h}, "Game type", lo.font); } - const Rect cycler{right_col.x + label_w, ry, right_col.w - label_w, lo.row_h}; - std::string text = "Default"; - if (g_form.gametype_index > 0) { - const VoteGametypeInfo& gametype = *gametypes[g_form.gametype_index - 1]; + + const int avail = right_col.w - label_w; + const int reset_w = std::min(avail / 2, + rf::gr::get_string_size("Reset", lo.font).first + std::max(6, scaled(12.0f))); + const Rect cycler{right_col.x + label_w, ry, + gametype_cycler_width(options, is_match, lo.font, avail - reset_w - lo.gap), lo.row_h}; + std::string text = "-"; + if (count > 0) { + const VoteGametypeInfo& gametype = *gametypes[g_form.gametype_index]; const char* short_name = gametype_short_name(gametype.id); text = short_name != nullptr ? std::string{short_name} @@ -3106,18 +3217,66 @@ void do_form(PanelUi& ui, const Layout& lo, const VoteOptionsData& options, : fit_middle(gametype.name, ui_cycler_value_width(cycler), lo.font); } const int delta = ui_cycler(ui, cycler, text.c_str(), lo.font); - if (delta != 0) { + if (delta != 0 && count > 0) { g_form.gametype_index = (g_form.gametype_index + delta + count) % count; + g_form.gametype_touched = true; // the cycler is the player's from here on + play_click_sound(); + } + + // Back to the selected level's own game type, and re-armed so it follows the + // level again. + const Rect reset{right_col.x + right_col.w - reset_w, ry, reset_w, lo.row_h}; + if (ui_button(ui, reset, "Reset", lo.font)) { + std::string key = selected_level(); + g_form.gametype_index = + gametype_cycler_index(options, is_match, default_gametype_for_level(options, key)); + g_form.gametype_touched = false; + g_form.gametype_key = std::move(key); + g_form.gametype_key_match = is_match; + g_form.gametype_key_valid = true; play_click_sound(); } + ry += lo.row_h + lo.gap * 2; } - if (ui.draw) { - set_header_color(); - rf::gr::string(right_col.x, ry, "Mutators", lo.font); + { + const int header_h = rf::gr::get_font_height(lo.font); + if (ui.draw) { + set_header_color(); + rf::gr::string(right_col.x, ry, "Mutators", lo.font); + } + // Two ways back to a server-defined set. They differ in whether the section + // keeps following the session afterwards: Current IS the auto baseline, so it + // re-arms; Base is a deliberate deviation from it, so it pins. + const int btn_pad = std::max(6, scaled(12.0f)); + const int btn_cap = std::max(0, right_col.w / 4); + const int current_w = + std::min(btn_cap, rf::gr::get_string_size("Current", lo.font).first + btn_pad); + const int base_w = + std::min(btn_cap, rf::gr::get_string_size("Base", lo.font).first + btn_pad); + + const Rect current{right_col.x + right_col.w - current_w, ry - 1, current_w, header_h + 2}; + if (ui_button(ui, current, "Current", lo.font)) { + apply_baseline(options, resolve_baseline(options, selected_level())); + g_form.mutators_touched = false; + pin_mutator_baseline_context(); + g_form.mutator_scroll = 0.0f; + play_click_sound(); + } + + // Disabled rather than applied as an empty set on a server whose blob predates + // the base section: "base runs nothing" and "base unknown" are not the same. + const Rect base{current.x - lo.gap - base_w, ry - 1, base_w, header_h + 2}; + if (ui_button(ui, base, "Base", lo.font, options.base_mutator_decls_present)) { + apply_baseline(options, options.base_mutator_decls); + g_form.mutators_touched = true; + pin_mutator_baseline_context(); + g_form.mutator_scroll = 0.0f; + play_click_sound(); + } + ry += header_h + lo.gap; } - ry += rf::gr::get_font_height(lo.font) + lo.gap; // Reserve exactly three lines at the bottom of the column for the description; // everything between the header and it scrolls. diff --git a/game_patch/misc/vpackfile.cpp b/game_patch/misc/vpackfile.cpp index d6ce32683..efbd5d5d4 100644 --- a/game_patch/misc/vpackfile.cpp +++ b/game_patch/misc/vpackfile.cpp @@ -479,6 +479,10 @@ bool vpackfile_supercede_allowed(const char* requested_filename, const char* sib static void vpackfile_add_to_lookup_table(rf::VPackfileEntry* entry) { std::string filename_str = string_to_lower(entry->name); + // A dedicated server has no use for waypoint files. + if (rf::is_dedicated_server && filename_str.ends_with(".awp")) { + return; + } auto [it, inserted] = g_loopup_table.insert({filename_str, entry}); if (!inserted) { ++g_num_name_collisions; @@ -672,8 +676,9 @@ static void vpackfile_init_new() if (!rf::is_dedicated_server) { rf::vpackfile_add("music.vpp", nullptr); rf::vpackfile_add("ui.vpp", nullptr); - load_alpinefaction_vpp(); } + + load_alpinefaction_vpp(); rf::vpackfile_add("tables.vpp", nullptr); addr_as_ref(0x01BDB218) = 1; // VPackfilesLoaded addr_as_ref(0x01BDB210) = 10000; // NumFilesInVfs diff --git a/game_patch/multi/alpine_packets.cpp b/game_patch/multi/alpine_packets.cpp index 03c72d953..9aee23564 100644 --- a/game_patch/multi/alpine_packets.cpp +++ b/game_patch/multi/alpine_packets.cpp @@ -1269,12 +1269,15 @@ void af_send_vote_call(const AfVoteCallParams& params) w.str(params.level); w.u8(params.gametype); encoded = write_vote_mutators(w, params.mutators); + // Trailing and optional; see AfVoteCallParams::mutators_explicit. + w.u8(params.mutators_explicit ? 1 : 0); break; case AfVoteType::Match: w.u8(params.team_size); w.str(params.level); w.u8(params.gametype); encoded = write_vote_mutators(w, params.mutators); + w.u8(params.mutators_explicit ? 1 : 0); break; case AfVoteType::Extend: w.u8(params.extend_minutes); @@ -1464,6 +1467,46 @@ void af_send_vote_state_end(rf::Player* player, AfVoteResult result, bool passed af_finish_vote_state_packet(player, buf, w); } +// The mutator set the session is currently running, which is what the vote panel +// pre-selects. Deliberately NOT part of the vote-options blob: that blob is +// config-derived and cached behind a generation counter, while this changes with +// every vote that installs an override. +void af_send_active_mutators(rf::Player* player) +{ + if (!rf::is_server || !af_vote_recipient_is_structured(player)) { + return; + } + + std::vector decls; + server_vote_build_active_mutators_blob(decls); + + std::byte buf[rf::max_packet_size]; + VoteWriter w{buf, sizeof(buf), sizeof(RF_GamePacketHeader)}; + w.u8(static_cast(af_server_req_type::af_sreq_active_mutators)); + w.bytes(decls.data(), decls.size()); + if (!w.ok) { + xlog::warn("af_send_active_mutators: {} bytes of declarations do not fit a packet", decls.size()); + return; + } + + RF_GamePacketHeader header{}; + header.type = static_cast(af_packet_type::af_server_req); + header.size = static_cast(w.off - sizeof(header)); + std::memcpy(buf, &header, sizeof(header)); + af_send_packet(player, buf, static_cast(w.off), true); +} + +void af_send_active_mutators_to_all() +{ + if (!rf::is_server) { + return; + } + auto player_list = SinglyLinkedList{rf::player_list}; + for (auto& player : player_list) { + af_send_active_mutators(&player); + } +} + // Stream the vote-options blob as Begin -> Data* -> End on the deferred reliable // queue. Everything on that queue is drained FIFO into rf::net_rel_send, which is // an ordered reliable channel, so the client sees the three event kinds in the @@ -1784,6 +1827,10 @@ static void af_process_client_req_packet(const void* data, size_t len, const rf: xlog::warn("af_process_client_req_packet: bad vote level mutators"); return; } + // Optional trailing byte. A caller that omits it (or a client + // that predates it) is explicit only if it named anything. + params.mutators_explicit = + r.remaining() > 0 ? r.u8() != 0 : !params.mutators.empty(); break; case AfVoteType::Match: params.team_size = r.u8(); @@ -1793,6 +1840,8 @@ static void af_process_client_req_packet(const void* data, size_t len, const rf: xlog::warn("af_process_client_req_packet: bad vote match mutators"); return; } + params.mutators_explicit = + r.remaining() > 0 ? r.u8() != 0 : !params.mutators.empty(); break; case AfVoteType::Extend: params.extend_minutes = r.u8(); @@ -2788,6 +2837,10 @@ static void af_process_server_req_packet(const void* data, size_t len, const rf: } break; } + case af_server_req_type::af_sreq_active_mutators: { + vote_active_mutators_on_received(bytes + offset, remaining); + break; + } case af_server_req_type::af_sreq_vote_options_data: { VoteReader r{bytes + offset, remaining}; const uint8_t event = r.u8(); diff --git a/game_patch/multi/alpine_packets.h b/game_patch/multi/alpine_packets.h index aec16414b..5c43cd18d 100644 --- a/game_patch/multi/alpine_packets.h +++ b/game_patch/multi/alpine_packets.h @@ -322,6 +322,7 @@ enum class af_server_req_type : uint8_t af_sreq_jetpack_state = 0x9, // Alpine 1.4 (5 bytes: obj_handle, on) af_sreq_riot_shield_state = 0xA, // Alpine 1.4 (20 bytes: obj_handle, life, impact_pos) af_sreq_award = 0xB, // Alpine 1.4 (2 bytes: award_id, victim_player_id; 0xFF = no victim) + af_sreq_active_mutators = 0xC, // Alpine 1.4 (variable: one declaration set, see blob_declaration_set) }; struct ShouldGibPayload @@ -801,6 +802,10 @@ struct AfVoteCallParams uint8_t gametype = af_vote_gametype_none; uint8_t extend_minutes = af_vote_extend_default_minutes; std::vector mutators; + // `mutators` is the caller's complete selection, empty included, and replaces + // whatever the session is running. False (a chat vote, which cannot name + // mutators) means "keep the session's set". + bool mutators_explicit = false; bool preserve = true; }; diff --git a/game_patch/multi/dedi_cfg.cpp b/game_patch/multi/dedi_cfg.cpp index d2fbcd0fb..670409887 100644 --- a/game_patch/multi/dedi_cfg.cpp +++ b/game_patch/multi/dedi_cfg.cpp @@ -524,36 +524,69 @@ struct RulesParseQuietGuard RulesParseQuietGuard& operator=(const RulesParseQuietGuard&) = delete; }; +// What a parse pass over a rules scope is allowed to touch. +enum class RulesParseMode +{ + Full, // game type resolution, gametype defaults, mutators, explicit keys + NoMutators, // the same minus the mutator declarations + KeysOnly, // only the explicit keys +}; + +struct RulesParseOptions +{ + RulesParseMode mode = RulesParseMode::Full; + // Struct defaults plus the operator's explicit base keys. When set, a scope that + // resolves a DIFFERENT game type is rebuilt from this rather than inheriting the + // materialized rules it was handed, so no field claimed by the previous game type + // can survive into the new one. Null while the base scope itself is parsed (its + // own keys are what this is built from). + const AlpineServerConfigRules* rebase_source = nullptr; +}; + +static void apply_rules_keys_from_toml(const toml::table& t, AlpineServerConfigRules& o); + // parse toml rules // for base rules, load all speciifed. For not specified, defaults are in struct // for level-specific rules, start with base rules and load anything specified beyond that -AlpineServerConfigRules parse_server_rules(const toml::table& t, const AlpineServerConfigRules& base_rules, bool apply_mutators = true) +AlpineServerConfigRules parse_server_rules(const toml::table& t, const AlpineServerConfigRules& base_rules, + const RulesParseOptions& opts = {}) { AlpineServerConfigRules o = base_rules; - rf::NetGameType resolved_game_type = o.game_type; - if (auto v = t["game_type"].value()) { - auto gt_opt = resolve_gametype_from_name(*v); - resolved_game_type = gt_opt.has_value() ? gt_opt.value() : rf::NetGameType::NG_TYPE_DM; - } + if (opts.mode != RulesParseMode::KeysOnly) { + rf::NetGameType resolved_game_type = o.game_type; + if (auto v = t["game_type"].value()) { + auto gt_opt = resolve_gametype_from_name(*v); + resolved_game_type = gt_opt.has_value() ? gt_opt.value() : rf::NetGameType::NG_TYPE_DM; + } + + const bool game_type_changed = resolved_game_type != base_rules.game_type; - const bool game_type_changed = resolved_game_type != base_rules.game_type; + if (game_type_changed && opts.rebase_source) + o = *opts.rebase_source; - o.game_type = resolved_game_type; + o.game_type = resolved_game_type; - if (game_type_changed || !o.game_type_defaults_applied) - apply_defaults_for_game_type(o.game_type, o); + if (game_type_changed || !o.game_type_defaults_applied) + apply_defaults_for_game_type(o.game_type, o); - // Mutators are applied after the gametype defaults but before any explicitly - // set rule keys in this scope, so manual keys still override mutator presets. - // parse_server_rules runs once per scope (base, then each level), which yields - // the requested layering: gametype defaults -> base mutators -> manual base -> - // per-level mutators -> manual per-level. - if (apply_mutators) { - if (auto mut_arr = t["mutators"].as_array()) - apply_mutators_from_toml(*mut_arr, o); + // Mutators are applied after the gametype defaults but before any explicitly + // set rule keys in this scope, so manual keys still override mutator presets. + // parse_server_rules runs once per scope (base, then each level), which yields + // the requested layering: gametype defaults -> base mutators -> manual base -> + // per-level mutators -> manual per-level. + if (opts.mode == RulesParseMode::Full) { + if (auto mut_arr = t["mutators"].as_array()) + apply_mutators_from_toml(*mut_arr, o); + } } + apply_rules_keys_from_toml(t, o); + return o; +} + +static void apply_rules_keys_from_toml(const toml::table& t, AlpineServerConfigRules& o) +{ if (auto v = t["time_limit"].value()) o.set_time_limit(*v); if (auto v = t["overtime"].as_table()) @@ -777,8 +810,6 @@ AlpineServerConfigRules parse_server_rules(const toml::table& t, const AlpineSer } if (auto v = t["gg_final_weapon"].value()) o.gungame_final_weapon = *v; - - return o; } static std::vector parse_allowed_maps(const toml::table& t) @@ -972,7 +1003,7 @@ static AlpineServerConfigRules apply_rules_presets_and_overrides( const toml::table& scope_tbl, const fs::path& base_dir, const AlpineServerConfigRules& starting_rules, std::string_view context, const std::map* preset_aliases = nullptr, std::vector* preset_stack = nullptr, std::vector>>* applied_presets = nullptr, - bool apply_mutators = true) + const RulesParseOptions& opts = {}) { std::vector local_stack; const bool is_root_call = !preset_stack; @@ -1025,7 +1056,7 @@ static AlpineServerConfigRules apply_rules_presets_and_overrides( preset_rules = &preset_root; std::string next_context = std::format("rules preset '{}'", resolved_path.generic_string()); - rules = apply_rules_presets_and_overrides(*preset_rules, resolved_path.parent_path(), rules, next_context, preset_aliases, preset_stack, applied_presets, apply_mutators); + rules = apply_rules_presets_and_overrides(*preset_rules, resolved_path.parent_path(), rules, next_context, preset_aliases, preset_stack, applied_presets, opts); if (applied_presets) { if (used_alias) applied_presets->emplace_back(resolved_path, preset_path); @@ -1058,10 +1089,10 @@ static AlpineServerConfigRules apply_rules_presets_and_overrides( } // Allow presets to specify rule keys directly at the current scope. - rules = parse_server_rules(scope_tbl, rules, apply_mutators); + rules = parse_server_rules(scope_tbl, rules, opts); if (auto rules_tbl = scope_tbl["rules"].as_table()) - rules = parse_server_rules(*rules_tbl, rules, apply_mutators); + rules = parse_server_rules(*rules_tbl, rules, opts); return rules; } @@ -1096,7 +1127,8 @@ std::optional load_rules_preset_alias(std::string_view pres result.rules = apply_rules_presets_and_overrides( *preset_rules, resolved_path.parent_path(), g_alpine_server_config.base_rules, std::format("rules preset alias '{}'", preset_name), &g_alpine_server_config.rules_preset_aliases, - nullptr, &applied_presets); + nullptr, &applied_presets, + RulesParseOptions{RulesParseMode::Full, &g_alpine_server_config.base_rules_keys_only}); applied_presets.emplace_back(resolved_path, preset_name); result.applied_preset_paths = std::move(applied_presets); @@ -1139,15 +1171,9 @@ static void add_level_entry_from_table( std::string context = "level '" + (tmp_filename.empty() ? std::string("") : tmp_filename) + "'"; entry.rule_overrides = apply_rules_presets_and_overrides( - lvl_tbl, base_dir, cfg.base_rules, context, &cfg.rules_preset_aliases, nullptr, &entry.applied_rules_preset_paths); - // Mutator-free variant, used as the starting point for level/match votes - // that carry mutators. - { - const RulesParseQuietGuard quiet; - entry.rule_overrides_no_mutators = apply_rules_presets_and_overrides( - lvl_tbl, base_dir, cfg.base_rules_no_mutators, context, - &cfg.rules_preset_aliases, nullptr, nullptr, /*apply_mutators*/ false); - } + lvl_tbl, base_dir, cfg.base_rules, context, &cfg.rules_preset_aliases, nullptr, + &entry.applied_rules_preset_paths, + RulesParseOptions{RulesParseMode::Full, &cfg.base_rules_keys_only}); cfg.levels.push_back(std::move(entry)); } @@ -1428,7 +1454,15 @@ static void apply_known_table_in_order( const RulesParseQuietGuard quiet; cfg.base_rules_no_mutators = apply_rules_presets_and_overrides( tbl, base_dir, cfg.base_rules_no_mutators, "base configuration", - &cfg.rules_preset_aliases, nullptr, nullptr, /*apply_mutators*/ false); + &cfg.rules_preset_aliases, nullptr, nullptr, + RulesParseOptions{RulesParseMode::NoMutators, nullptr}); + // And the operator's explicit keys alone, over struct defaults: the only + // form that can be replayed onto a DIFFERENT game type's defaults without + // dragging this game type's fields along. + cfg.base_rules_keys_only = apply_rules_presets_and_overrides( + tbl, base_dir, cfg.base_rules_keys_only, "base configuration", + &cfg.rules_preset_aliases, nullptr, nullptr, + RulesParseOptions{RulesParseMode::KeysOnly, nullptr}); } } else if (key == "levels") { @@ -2519,6 +2553,7 @@ void load_and_print_alpine_dedicated_server_config(std::string ads_config_name, cfg.signal_cfg_changed = true; server_vote_invalidate_options_blob(); clear_pending_rotation_preserve(); + af_send_active_mutators_to_all(); } initialize_core_alpine_dedicated_server_settings(netgame, cfg, on_launch); @@ -2555,10 +2590,13 @@ bool apply_game_type_for_current_level() { rf::NetGameType desired = rf::NetGameType::NG_TYPE_DM; if (manual_load) { - const AlpineServerConfigRules& manual_rules = - g_manual_rules_override ? g_manual_rules_override->rules : cfg.base_rules; + // Same resolution a vote for this level would get, so a manual load and a + // level vote never disagree about what the level runs. + const rf::NetGameType level_default = g_manual_rules_override + ? g_manual_rules_override->rules.game_type + : resolve_level_default_game_type(rf::level_filename_to_load.c_str()); - desired = has_already_queued_change ? upcoming : manual_rules.game_type; + desired = has_already_queued_change ? upcoming : level_default; if (!g_ads_minimal_server_info && !has_already_queued_change && desired != upcoming) { if (g_manual_rules_override && g_manual_rules_override->mutator_labels) { @@ -2650,9 +2688,17 @@ void apply_rules_for_current_level() } } else { - g_alpine_server_config_active_rules = cfg.base_rules; + // A manual load derives exactly like a level vote that named nothing: + // the game type already resolved for this level (an explicit sv_gametype + // request, else the level's own default) and the session's mutator set. + // Never a copy of the previous level's rules, which would carry its game + // type's fields into a different game type. + const std::vector session_mutators = + g_alpine_server_config_active_rules.mutators.declarations; + g_alpine_server_config_active_rules = + build_derived_server_rules(rf::netgame.type, session_mutators); if (!g_ads_minimal_server_info) - rf::console::print("Applying base rules for manually loaded level {}...\n", rf::level_filename_to_load); + rf::console::print("Applying derived rules for manually loaded level {}...\n", rf::level_filename_to_load); } } else { // level is in rotation @@ -2681,33 +2727,25 @@ void apply_rules_for_current_level() const PendingRotationPreserve pending = *get_pending_rotation_preserve(); clear_pending_rotation_preserve(); - auto carried = load_vote_rules_override(rf::level_filename_to_load.c_str(), - pending.declarations, pending.gametype); - if (carried) { - g_alpine_server_config_active_rules = carried->rules; - g_manual_rules_override = std::move(*carried); - if (!g_ads_minimal_server_info) { - rf::console::print("Carrying voted session rules onto {}...\n", rf::level_filename_to_load); - } + ManualRulesOverride carried = load_vote_rules_override(rf::level_filename_to_load.c_str(), + pending.declarations, pending.gametype); + g_alpine_server_config_active_rules = carried.rules; + g_manual_rules_override = std::move(carried); + if (!g_ads_minimal_server_info) { + rf::console::print("Carrying voted session rules onto {}...\n", rf::level_filename_to_load); } } - // respect game type specific base rules (eg. koth spawn loadout) for voted or manually loaded maps + // The rules resolved above can still name a different game type than the one the + // level is actually starting under (sv_gametype against a rotation entry). Rebuilt + // rather than retargeted in place: retargeting leaves every field the old game type + // claimed and the new one does not (spawn_life, drop_weapons, ...) in force. const rf::NetGameType active_game_type = rf::netgame.type; if (g_alpine_server_config_active_rules.game_type != active_game_type) { - // apply_defaults_for_game_type() rebuilds the loadout and clears MutatorConfig, - // so capture this scope's mutator declarations first and re-apply them on top - // of the new game-type defaults. const std::vector saved_mutators = g_alpine_server_config_active_rules.mutators.declarations; - - g_alpine_server_config_active_rules.game_type = active_game_type; - apply_defaults_for_game_type(active_game_type, g_alpine_server_config_active_rules); - - if (!saved_mutators.empty()) { - const toml::array mut_arr = mutator_declarations_to_toml_array(saved_mutators); - apply_mutators_from_toml(mut_arr, g_alpine_server_config_active_rules); - } + g_alpine_server_config_active_rules = + build_derived_server_rules(active_game_type, saved_mutators); } // apply the rules @@ -2715,6 +2753,10 @@ void apply_rules_for_current_level() // Signal consumers that the active rules were (re)applied this call. ++g_active_rules_generation; + + // The vote panel pre-selects the session's mutator set, so clients need it + // whenever it can have changed. + af_send_active_mutators_to_all(); } void init_alpine_dedicated_server() { diff --git a/game_patch/multi/multi.cpp b/game_patch/multi/multi.cpp index 6a38505c3..6b57f3c7f 100644 --- a/game_patch/multi/multi.cpp +++ b/game_patch/multi/multi.cpp @@ -1058,6 +1058,50 @@ std::string_view multi_game_type_prefix(const rf::NetGameType game_type) { } } +// Does this filename carry THIS game type's own level prefix? Strictly the prefix: +// the any-level rule and the RUN level list are the caller's business. The two "p" +// prefixes belong to the game types that share the base prefix, so this is the one +// place that owns those pairings. +bool multi_level_name_has_game_type_prefix(std::string_view level_filename, rf::NetGameType game_type) +{ + if (string_istarts_with(level_filename, multi_game_type_prefix(game_type))) { + return true; + } + if ((game_type == rf::NG_TYPE_DM || game_type == rf::NG_TYPE_TEAMDM) + && string_istarts_with(level_filename, "pdm")) { + return true; + } + if ((game_type == rf::NG_TYPE_CTF || game_type == rf::NG_TYPE_SAL) + && string_istarts_with(level_filename, "pctf")) { + return true; + } + return false; +} + +// The game type a level filename's prefix names, or nullopt for a filename that +// carries no game type prefix at all. Derived from multi_game_type_prefix rather +// than from a table of its own, so adding or removing a prefix there is all it +// takes. Enum order decides the two prefixes two game types share (DM precedes +// TeamDM, CTF precedes SAL) and keeps NG_TYPE_UNK — whose prefix is a fallback, +// not its own — out of it. +// +// The any-level game types are skipped: their nominal prefix is what THEIR levels +// would be called, not something a filename can be identified by, so wooden_bridge +// must not read as Wipeout. This direction only; they still accept any mp level. +std::optional multi_game_type_for_level_prefix(std::string_view level_filename) +{ + for (int i = 0; i <= static_cast(rf::NG_TYPE_SAL); ++i) { + const auto game_type = static_cast(i); + if (multi_game_type_uses_any_level(game_type)) { + continue; + } + if (multi_level_name_has_game_type_prefix(level_filename, game_type)) { + return game_type; + } + } + return std::nullopt; +} + // Game types that have no dedicated level-name prefix of their own and are // played on any standard MP level. bool multi_game_type_uses_any_level(rf::NetGameType game_type) diff --git a/game_patch/multi/multi.h b/game_patch/multi/multi.h index 6ccf569a0..649e162c6 100644 --- a/game_patch/multi/multi.h +++ b/game_patch/multi/multi.h @@ -208,6 +208,11 @@ std::string_view multi_game_type_name(rf::NetGameType game_type); std::string_view multi_game_type_name_upper(rf::NetGameType game_type); std::string_view multi_game_type_name_short(rf::NetGameType game_type); std::string_view multi_game_type_prefix(rf::NetGameType game_type); +// Strict prefix test: does the filename carry this game type's own level prefix +// (including the "p" variant of a shared one)? No any-level or RUN special casing. +bool multi_level_name_has_game_type_prefix(std::string_view level_filename, rf::NetGameType game_type); +// The inverse of multi_game_type_prefix: the game type a filename's prefix names. +std::optional multi_game_type_for_level_prefix(std::string_view level_filename); bool multi_game_type_uses_any_level(rf::NetGameType game_type); bool multi_level_name_matches_any_mp_prefix(const char* filename); std::string normalize_level_filename(std::string_view name); // appends ".rfl" when it is missing diff --git a/game_patch/multi/mutators.cpp b/game_patch/multi/mutators.cpp index b1d525104..ec7b5261a 100644 --- a/game_patch/multi/mutators.cpp +++ b/game_patch/multi/mutators.cpp @@ -43,6 +43,7 @@ #include "../main/main.h" #include "../hud/multi_spectate.h" #include "../misc/player.h" +#include "../misc/alpine_options.h" #include "../misc/alpine_settings.h" // Spawn reserve for a no-clip "infinite ammo" weapon. Firing draws from reserve, @@ -977,44 +978,45 @@ std::string mutators_join_labels(const std::vector& declarat return joined; } -const AlpineServerConfigRules& vote_natural_rules_for_level(std::string_view level_filename) +rf::NetGameType resolve_level_default_game_type(std::string_view level_filename) { + const std::string normalized = normalize_level_filename(level_filename); + for (const auto& entry : g_alpine_server_config.levels) { - if (string_iequals(entry.level_filename, level_filename)) - return entry.rule_overrides_no_mutators; + if (string_iequals(entry.level_filename, normalized)) + return entry.rule_overrides.game_type; } - // Not in the rotation: a manually named level runs on the base rules. - return g_alpine_server_config.base_rules_no_mutators; + + // Run maps are named for the campaign they came from, not for a game type, so + // the quirks table is the only thing that can identify them. Behind the rotation + // lookup: an operator who configured one as something else meant it. + if (is_known_run_level(normalized)) + return rf::NetGameType::NG_TYPE_RUN; + + if (auto from_prefix = multi_game_type_for_level_prefix(normalized)) + return *from_prefix; + + return g_alpine_server_config.base_rules.game_type; } -std::optional load_vote_rules_override( - std::string_view level_filename, const std::vector& mutators, - std::optional gametype) +AlpineServerConfigRules build_derived_server_rules(rf::NetGameType game_type, + const std::vector& mutators) { - if (mutators.empty() && !gametype) - return std::nullopt; - - // Inheritance rule for a vote override, in layering order: - // 1. the rules the voted level would run with on its own — its rotation - // entry's rules if it is in the rotation, otherwise the base rules — - // with config-declared mutators stripped (voted mutators REPLACE - // configured ones rather than stacking on them), - // 2. the voted game type and its gametype defaults, if one was voted, - // 3. the voted mutator declarations. - // Starting from the base rules instead (as this used to) silently dragged the - // base game type onto a level whose rotation entry overrides it, so adding a - // single mutator to a level vote could flip the whole game type. - AlpineServerConfigRules rules = vote_natural_rules_for_level(level_filename); - - if (gametype && rules.game_type != *gametype) { - // Only re-derive the gametype defaults when the type actually CHANGES. - // apply_defaults_for_game_type() overwrites operator-configured rules - // (spawn loadout, pvp_damage_modifier, spawn_delay, ...), so explicitly - // voting the type a level already runs must behave the same as voting - // "Server default" rather than silently wiping the config. It also - // rebuilds the loadout and clears MutatorConfig, so mutators come after. - rules.game_type = *gametype; - apply_defaults_for_game_type(*gametype, rules); + const auto& cfg = g_alpine_server_config; + + AlpineServerConfigRules rules; + if (game_type == cfg.base_rules.game_type) { + // The operator's own keys already sit on top of this game type's defaults in + // parse order, which is a layering the rebuild below cannot reproduce. + rules = cfg.base_rules_no_mutators; + } + else { + // Never derived from another game type's materialized rules: those carry + // fields (spawn_life, drop_weapons, ...) that apply_defaults_for_game_type + // does not claim back, so they would leak into the new game type. + rules = cfg.base_rules_keys_only; + rules.game_type = game_type; + apply_defaults_for_game_type(game_type, rules); } if (!mutators.empty()) { @@ -1022,6 +1024,17 @@ std::optional load_vote_rules_override( apply_mutators_from_toml(arr, rules); } + return rules; +} + +ManualRulesOverride load_vote_rules_override( + std::string_view level_filename, const std::vector& mutators, + std::optional gametype) +{ + const rf::NetGameType game_type = gametype.value_or(resolve_level_default_game_type(level_filename)); + + AlpineServerConfigRules rules = build_derived_server_rules(game_type, mutators); + ManualRulesOverride result; // Reported from what actually applied rather than from what was voted. std::string labels; diff --git a/game_patch/multi/mutators.h b/game_patch/multi/mutators.h index e1832edb2..470249002 100644 --- a/game_patch/multi/mutators.h +++ b/game_patch/multi/mutators.h @@ -234,14 +234,20 @@ std::optional mutators_build_declarations_from_vote( // Human-readable labels of the declared mutators, joined with ", ". std::string mutators_join_labels(const std::vector& declarations); -// The rules `level_filename` would run with if no vote were involved: its -// rotation entry's rules when it is in the rotation, otherwise the base rules — -// in both cases with config-declared mutators stripped. -const AlpineServerConfigRules& vote_natural_rules_for_level(std::string_view level_filename); - -// Build the rules a level/match vote should install: the voted level's natural -// rules (above), optionally re-based on a voted game type plus that type's -// defaults, then the voted mutators applied in MUTATOR_APPLY_ORDER. -std::optional load_vote_rules_override( +// The game type a level runs under when nothing names one: its rotation entry's +// type when it is in the rotation, else the type its filename prefix names, else +// the base game type. The single answer validation and application both use. +rf::NetGameType resolve_level_default_game_type(std::string_view level_filename); + +// Rules for `game_type` built without inheriting any other game type's fields: +// the materialized base rules when it IS the base game type, otherwise struct +// defaults + the operator's base keys + that type's defaults. `mutators` is +// applied last, in MUTATOR_APPLY_ORDER. +AlpineServerConfigRules build_derived_server_rules(rf::NetGameType game_type, + const std::vector& mutators); + +// Build the rules a level/match vote (or a manual level load) should install. +// `gametype` falls back to resolve_level_default_game_type. +ManualRulesOverride load_vote_rules_override( std::string_view level_filename, const std::vector& mutators, std::optional gametype); diff --git a/game_patch/multi/server.cpp b/game_patch/multi/server.cpp index 29b5c3b03..987e296c7 100644 --- a/game_patch/multi/server.cpp +++ b/game_patch/multi/server.cpp @@ -3420,6 +3420,9 @@ void server_reliable_socket_ready(rf::Player* player) // bring a player who joined during a vote up to date (AF 1.4+ only) server_vote_send_state_to_new_player(player); + // the vote panel pre-selects the session's mutator set, which is not in the + // (config-derived) vote options blob + af_send_active_mutators(player); // alert alpine clients to the queued match on join if (g_match_info.pre_match_active && player->version_info.software == ClientSoftware::AlpineFaction) { diff --git a/game_patch/multi/server_internal.h b/game_patch/multi/server_internal.h index aa0683aca..1085f0e3d 100644 --- a/game_patch/multi/server_internal.h +++ b/game_patch/multi/server_internal.h @@ -940,11 +940,6 @@ struct AlpineServerConfigLevelEntry { std::string level_filename; AlpineServerConfigRules rule_overrides; - // The same rules re-resolved with every config-declared mutator stripped. - // Starting point for a level/match vote that carries mutators, so the voted - // mutators replace (rather than stack on) whatever the config declared while - // the rest of this level's rules — notably its game type — are preserved. - AlpineServerConfigRules rule_overrides_no_mutators; std::vector>> applied_rules_preset_paths; }; @@ -1025,6 +1020,10 @@ struct AlpineServerConfig // for a mutator applied via a level/match vote, so the voted mutator replaces // (rather than stacks on) any mutator the base rules declared. AlpineServerConfigRules base_rules_no_mutators; + // The operator's explicit base keys layered over struct defaults and NOTHING else: + // no game type resolution, no gametype defaults, no mutators. Rules for any other + // game type are built from this, so nothing the base game type claimed can leak in. + AlpineServerConfigRules base_rules_keys_only; std::vector>> base_rules_preset_paths; std::map rules_preset_aliases; std::vector levels; @@ -1163,6 +1162,14 @@ void server_vote_handle_options_request(rf::Player* sender, bool has_cache, uint // discard a stream that was superseded mid-flight. const std::vector& server_vote_get_options_blob(uint32_t& generation); void server_vote_invalidate_options_blob(); +// The mutator declaration set currently in force, in the vote-options blob's +// declaration-set encoding. This is session state, not config, so it is pushed +// separately from the (config-derived, cached) options blob. +void server_vote_build_active_mutators_blob(std::vector& blob); +// Push that set to one player / to every 1.4+ client. Called on join and whenever +// the active rules are (re)applied. +void af_send_active_mutators(rf::Player* player); +void af_send_active_mutators_to_all(); void vote_level_refresh_allowed_maps(); // Push the current vote state to a player who joined while a vote is running. void server_vote_send_state_to_new_player(rf::Player* player); diff --git a/game_patch/multi/vote_client.cpp b/game_patch/multi/vote_client.cpp index f23613a2b..43f5fc47c 100644 --- a/game_patch/multi/vote_client.cpp +++ b/game_patch/multi/vote_client.cpp @@ -6,6 +6,7 @@ #include "vote_client.h" #include "alpine_packets.h" #include "../hud/hud.h" +#include "../misc/alpine_options.h" #include "../misc/alpine_settings.h" #include "../os/os.h" #include "../rf/multi.h" @@ -41,6 +42,10 @@ struct VoteOptionsCache VoteOptionsCache g_vote_options; std::optional g_active_vote; +// Session mutator set pushed by the server (af_sreq_active_mutators). +std::optional> g_active_mutators; +uint32_t g_active_mutators_revision = 0; + // Retry interval while we have no blob at all. Once one is loaded a stale marker // costs exactly one request, so this only paces the initial fetch. constexpr int64_t vote_options_request_cooldown_ms = 3000; @@ -443,6 +448,14 @@ bool parse_vote_options_blob(const uint8_t* data, size_t len, VoteOptionsData& o level.filename = body.str(); level.natural_gametype = body.u8(); level.valid_gametype_mask = body.u32(); + // A run map is identified by the quirks table, not by its filename, and a + // dedicated server never loads that table -- so the mask it sent leaves RUN + // out. Corrected once here rather than at each consumer, so the panel's + // filter and its RUN pre-selection cannot disagree. Local only: the server + // still adjudicates the vote it is actually sent. + if (is_known_run_level(level.filename)) { + level.valid_gametype_mask |= 1u << static_cast(rf::NG_TYPE_RUN); + } level.allowed_for_vote = (body.u8() & AF_VOTE_LEVEL_FLAG_ALLOWED) != 0; // Read the entry's own success BEFORE the appended baseline set below, so // trouble in the addition can only cost the pre-selection, never the level. @@ -477,9 +490,14 @@ bool parse_vote_options_blob(const uint8_t* data, size_t len, VoteOptionsData& o // The base mutator set, appended after the level section. Failing to read it // costs the vote panel's pre-selection and nothing else, so it never fails the // blob: a blob from a server built before it existed simply ends here. - if (r.remaining() > 0 && !parse_declaration_set(r, parsed.base_mutator_decls)) { - xlog::debug("vote options: unparseable base mutator set; the vote panel will pre-select nothing"); - parsed.base_mutator_decls.clear(); + if (r.remaining() > 0) { + if (parse_declaration_set(r, parsed.base_mutator_decls)) { + parsed.base_mutator_decls_present = true; + } + else { + xlog::debug("vote options: unparseable base mutator set; the vote panel will pre-select nothing"); + parsed.base_mutator_decls.clear(); + } } // Anything left over is a section a newer server appended; ignored on purpose. @@ -532,6 +550,30 @@ uint32_t vote_options_loaded_generation() return g_vote_options.loaded_generation; } +const std::vector* vote_active_mutators_get() +{ + return g_active_mutators ? &*g_active_mutators : nullptr; +} + +uint32_t vote_active_mutators_revision() +{ + return g_active_mutators_revision; +} + +void vote_active_mutators_on_received(const uint8_t* data, size_t len) +{ + BlobReader r{data, len}; + std::vector decls; + if (!parse_declaration_set(r, decls)) { + xlog::warn("vote options: unparseable active mutator set ({} bytes); keeping the previous one", len); + return; + } + // An empty set is meaningful ("the session runs no mutators"), so it is stored + // like any other rather than read as "nothing received". + g_active_mutators = std::move(decls); + ++g_active_mutators_revision; +} + bool vote_level_allows_gametype(const VoteLevelInfo& level, uint8_t game_type) { if (game_type >= 32) { @@ -804,6 +846,8 @@ void vote_client_reset() // Drops any partial stream along with the parsed cache: the accumulated bytes // belong to the server we just left. g_vote_options = VoteOptionsCache{}; + g_active_mutators.reset(); + g_active_mutators_revision = 0; // Also drop the HUD prompt: without this a vote left running on the server we // just left would keep its notification up across a reconnect elsewhere. remove_hud_vote_notification(); diff --git a/game_patch/multi/vote_client.h b/game_patch/multi/vote_client.h index d49d9aeac..6b0edefc9 100644 --- a/game_patch/multi/vote_client.h +++ b/game_patch/multi/vote_client.h @@ -103,6 +103,10 @@ struct VoteOptionsData // Mutators the server's base rules declare; the baseline for every level that // does not carry its own. Empty for a server built before the blob carried it. std::vector base_mutator_decls; + // Whether the blob actually carried that section. An empty set is meaningful + // ("base declares none"), so a consumer offering it as a choice has to tell that + // apart from a server that never sent one. + bool base_mutator_decls_present = false; }; // Does this level match the given game type's level prefix rules? Whether that @@ -154,6 +158,17 @@ bool vote_options_is_type_enabled(AfVoteType type); void vote_options_request_if_needed(); void vote_options_mark_stale(); +// --- active mutator set (af_sreq_active_mutators) --- +// The mutator declarations the server session is running right now, including any a +// vote installed. `nullptr` until the server has sent one, which is also what an +// old server leaves forever: consumers fall back to the config-derived baseline the +// options blob carries. +const std::vector* vote_active_mutators_get(); +// Bumped on every push, so a consumer that derived state from the set can notice a +// change without comparing the declarations. 0 means nothing has been received. +uint32_t vote_active_mutators_revision(); +void vote_active_mutators_on_received(const uint8_t* data, size_t len); + // Blob stream (af_sreq_vote_options_data). Ordered reliable delivery, so Begin -> // Data* -> End arrive in that order; anything out of order is a protocol error and // discards the stream. diff --git a/game_patch/multi/votes.cpp b/game_patch/multi/votes.cpp index fc29e9f2f..f8019f604 100644 --- a/game_patch/multi/votes.cpp +++ b/game_patch/multi/votes.cpp @@ -561,25 +561,7 @@ static bool does_level_match_gametype_prefix(const std::string& level_name, rf:: return true; } - const auto base_prefix = multi_game_type_prefix(game_type); - - auto matches_prefix = [&](std::string_view prefix) { - return string_istarts_with(map_name, prefix); - }; - - if (matches_prefix(base_prefix)) { - return true; - } - - if ((game_type == rf::NG_TYPE_DM || game_type == rf::NG_TYPE_TEAMDM) && matches_prefix("pdm")) { - return true; - } - - if ((game_type == rf::NG_TYPE_CTF || game_type == rf::NG_TYPE_SAL) && matches_prefix("pctf")) { - return true; - } - - return false; + return multi_level_name_has_game_type_prefix(map_name, game_type); } // The union of the rotation and the vote-allowed list, in rotation order, with @@ -610,21 +592,13 @@ static std::vector build_votable_level_list() return levels; } -// The game type the voted level will actually run with, mirroring -// load_vote_rules_override's inheritance: a voted game type wins; otherwise a -// vote that builds an override rebases onto the level's natural rules, and a -// vote that builds no override leaves the level running exactly as it is. +// The game type the voted level will actually run with. Same resolution +// load_vote_rules_override applies, so validation can never accept a combination +// the application then changes. static rf::NetGameType resolve_effective_vote_game_type(const std::string& level_name, - std::optional gametype, - bool builds_override, bool keeps_current_level) + std::optional gametype) { - if (gametype) { - return *gametype; - } - if (!builds_override && keeps_current_level) { - return g_alpine_server_config_active_rules.game_type; - } - return vote_natural_rules_for_level(level_name).game_type; + return gametype.value_or(resolve_level_default_game_type(level_name)); } // Enforcement-aware answer to "would the server accept this level voted with this @@ -779,19 +753,32 @@ static std::string build_rules_title_suffix(std::optional gamet return suffix; } +// The mutator set a vote installs. A vote that names its own set (the panel always +// sends the full selection, empty included) replaces the baseline outright; one +// that names none — a chat vote — inherits whatever the session is running. +static std::vector effective_vote_mutators( + const std::vector& voted, bool voted_set_is_explicit) +{ + if (voted_set_is_explicit) { + return voted; + } + return g_alpine_server_config_active_rules.mutators.declarations; +} + struct VoteMatch : public Vote { int m_team_size; std::string m_level_name; std::optional m_gametype; std::vector m_mutators; + bool m_mutators_explicit; std::optional m_manual_rules_override; std::string m_mutator_labels; VoteMatch(int team_size, std::string level_name, std::optional gametype, - std::vector mutators) + std::vector mutators, bool mutators_explicit) : m_team_size(team_size), m_level_name(std::move(level_name)), m_gametype(gametype), - m_mutators(std::move(mutators)) + m_mutators(std::move(mutators)), m_mutators_explicit(mutators_explicit) {} VoteType get_type() const override @@ -819,15 +806,11 @@ struct VoteMatch : public Vote m_level_name = std::move(normalized_name); } - // A match on the current level with no rules override keeps the level - // (and therefore its active rules) exactly as they are; anything else - // re-resolves from the level's natural rules. Level names are compared - // case-insensitively: they come from a client packet, a config file and the - // engine, none of which agree on case. - const bool builds_override = !m_mutators.empty() || m_gametype.has_value(); + // Level names are compared case-insensitively: they come from a client + // packet, a config file and the engine, none of which agree on case. const bool using_current_level = string_iequals(m_level_name, rf::level.filename.c_str()); - const rf::NetGameType effective_game_type = resolve_effective_vote_game_type( - m_level_name, m_gametype, builds_override, using_current_level); + const rf::NetGameType effective_game_type = + resolve_effective_vote_game_type(m_level_name, m_gametype); if (!is_level_allowed_for_vote(m_level_name, source, effective_game_type)) { return false; @@ -845,8 +828,19 @@ struct VoteMatch : public Vote return false; } - m_manual_rules_override = load_vote_rules_override(m_level_name, m_mutators, m_gametype); - m_mutator_labels = mutators_join_labels(m_mutators); + const std::vector effective_mutators = + effective_vote_mutators(m_mutators, m_mutators_explicit); + // A match on the current level that asks for nothing the level is not + // already running keeps the active rules untouched, so an entry's own + // configured rules survive a plain "start a match here" vote. + const auto& active = g_alpine_server_config_active_rules; + const bool rules_unchanged = effective_game_type == active.game_type + && effective_mutators == active.mutators.declarations; + if (!using_current_level || !rules_unchanged) { + m_manual_rules_override = + load_vote_rules_override(m_level_name, effective_mutators, effective_game_type); + } + m_mutator_labels = mutators_join_labels(effective_mutators); // Deliberately does NOT touch g_match_info: validation passing only means // the vote may be PUT, not that it wins. Writing team_size / @@ -1137,12 +1131,14 @@ struct VoteLevel : public Vote std::string m_level_name; std::optional m_gametype; std::vector m_mutators; - std::optional m_manual_rules_override; + bool m_mutators_explicit; + ManualRulesOverride m_manual_rules_override; std::string m_mutator_labels; VoteLevel(std::string level_name, std::optional gametype, - std::vector mutators) - : m_level_name(std::move(level_name)), m_gametype(gametype), m_mutators(std::move(mutators)) + std::vector mutators, bool mutators_explicit) + : m_level_name(std::move(level_name)), m_gametype(gametype), m_mutators(std::move(mutators)), + m_mutators_explicit(mutators_explicit) {} VoteType get_type() const override @@ -1162,18 +1158,18 @@ struct VoteLevel : public Vote m_level_name = std::move(level_name); - // A level vote always reloads the level, so it never keeps the currently - // active rules — the target always re-resolves from rotation/base. - const bool builds_override = !m_mutators.empty() || m_gametype.has_value(); - const rf::NetGameType effective_game_type = resolve_effective_vote_game_type( - m_level_name, m_gametype, builds_override, /*keeps_current_level*/ false); + const rf::NetGameType effective_game_type = + resolve_effective_vote_game_type(m_level_name, m_gametype); if (!is_level_allowed_for_vote(m_level_name, source, effective_game_type)) { return false; } - m_manual_rules_override = load_vote_rules_override(m_level_name, m_mutators, m_gametype); - m_mutator_labels = mutators_join_labels(m_mutators); + const std::vector effective_mutators = + effective_vote_mutators(m_mutators, m_mutators_explicit); + m_manual_rules_override = + load_vote_rules_override(m_level_name, effective_mutators, effective_game_type); + m_mutator_labels = mutators_join_labels(effective_mutators); return true; } @@ -1203,10 +1199,10 @@ struct VoteLevel : public Vote afstats::note_game_end_type(afstats::GameEndType::map_change_vote); multi_change_level_alpine(m_level_name.c_str()); - if (m_manual_rules_override) { - set_manual_rules_override(std::move(*m_manual_rules_override)); - m_manual_rules_override.reset(); - } + // Installed AFTER the level switch: a voted level that happens to be in the + // rotation goes through set_manually_loaded_level(false), which drops the + // override. + set_manual_rules_override(std::move(m_manual_rules_override)); } [[nodiscard]] std::string get_detail() const override { return m_level_name; } @@ -1247,7 +1243,7 @@ struct VoteRotation : public Vote m_carried_mutators = active.mutators.declarations; // Only carry a game type that actually deviates from what this level runs // on its own; otherwise the target level's own configured type must win. - if (active.game_type != vote_natural_rules_for_level(rf::level.filename.c_str()).game_type) { + if (active.game_type != resolve_level_default_game_type(rf::level.filename.c_str())) { m_carried_gametype = active.game_type; } m_carried_labels = mutators_join_labels(m_carried_mutators); @@ -1901,7 +1897,7 @@ static void build_vote_options_blob(std::vector& blob) const std::string& level = levels[i]; blob_sized_u16(blob, [&] { blob_str(blob, level); - blob_u8(blob, static_cast(vote_natural_rules_for_level(level).game_type)); + blob_u8(blob, static_cast(resolve_level_default_game_type(level))); blob_u32(blob, build_level_valid_gametype_mask(level)); // Derived from the SAME predicate the call-time gate uses, so the blob can // never advertise a level the server would refuse. @@ -1914,7 +1910,7 @@ static void build_vote_options_blob(std::vector& blob) // deliberately clears it -- carries its own. const std::vector* level_decls = nullptr; for (const auto& entry : g_alpine_server_config.levels) { - // Same lookup as vote_natural_rules_for_level: first match wins. + // Same lookup as resolve_level_default_game_type: first match wins. if (string_iequals(entry.level_filename, level)) { level_decls = &entry.rule_overrides.mutators.declarations; break; @@ -1937,6 +1933,12 @@ static void build_vote_options_blob(std::vector& blob) blob_declaration_set(blob, g_alpine_server_config.base_rules.mutators.declarations); } +void server_vote_build_active_mutators_blob(std::vector& blob) +{ + blob.clear(); + blob_declaration_set(blob, g_alpine_server_config_active_rules.mutators.declarations); +} + void server_vote_invalidate_options_blob() { g_vote_options_blob_valid = false; @@ -2172,7 +2174,8 @@ void handle_vote_call_packet(rf::Player* sender, AfVoteCallParams&& params) send_vote_reject_msg("Cannot start vote: no level was specified.", sender); return; } - g_vote_mgr.StartVote(sender, std::move(params.level), gametype, std::move(mutators)); + g_vote_mgr.StartVote(sender, std::move(params.level), gametype, std::move(mutators), + params.mutators_explicit); break; } case AfVoteType::Match: { @@ -2185,7 +2188,8 @@ void handle_vote_call_packet(rf::Player* sender, AfVoteCallParams&& params) return; } g_vote_mgr.StartVote(sender, static_cast(params.team_size), - std::move(params.level), gametype, std::move(mutators)); + std::move(params.level), gametype, std::move(mutators), + params.mutators_explicit); break; } case AfVoteType::Extend: From 0cf65e3fbfef5706c8ec594319c2977f19efbe39 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Thu, 20 Aug 2026 19:42:52 -0230 Subject: [PATCH 02/10] fix --- docs/CHANGELOG.md | 3 + game_patch/graphics/gr.cpp | 8 +- game_patch/misc/alpine_options.cpp | 7 +- game_patch/misc/saved_votes.cpp | 29 +++++- game_patch/misc/saved_votes.h | 4 + game_patch/misc/vote_panel.cpp | 140 +++++++++++++++++++++------- game_patch/misc/vpackfile.cpp | 2 +- game_patch/multi/alpine_packets.cpp | 59 ++++++++---- game_patch/multi/alpine_packets.h | 17 +++- game_patch/multi/dedi_cfg.cpp | 54 +++++------ game_patch/multi/multi.cpp | 7 +- game_patch/multi/mutators.cpp | 64 +++++++++---- game_patch/multi/mutators.h | 8 +- game_patch/multi/server_internal.h | 15 +++ game_patch/multi/vote_client.cpp | 36 +++---- game_patch/multi/vote_client.h | 8 +- game_patch/multi/votes.cpp | 35 ++++--- game_patch/os/os.cpp | 3 + 18 files changed, 355 insertions(+), 144 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 33f719e06..ae7df6110 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -45,6 +45,7 @@ Version 1.4.0 (Lupin): Not yet released - Servers describe their votable levels, game types, and mutator options to clients - `Level` and `Match` can now select a game type and any number of mutators (with their options) for the voted level - Add a vote panel for calling any vote the server allows, opened during gameplay with the bindable `Call Vote Menu` control (`F4` by default) + - The vote panel preselects the chosen level's game type and the server's currently active mutator set, with buttons to reset the game type and restore the `Base` or `Current` mutator set - Vote HUD notification now shows live tally, time remaining, and whether you have already voted - Add FactionFiles-integrated multiplayer statistics tracking - Dedicated servers with a configured `fflink_gsk` report a gameplay event stream to FactionFiles @@ -208,6 +209,8 @@ Version 1.4.0 (Lupin): Not yet released - Fix particle emitters created from `emitters.tbl` templates inheriting uninitialized UID and `Active Distance` values that could make their particles silently fail to spawn in rare cases - Fix spacebar (when bound to `Jump`) moving freelook camera upward when typing in chat - Fix crash risk when leaving a match or changing levels by keeping animation skeletons loaded while animation instances are still playing them, instead of unloading as soon as no character references them +- Fix dedicated servers not loading `alpinefaction.vpp`, which prevented `af_level_quirks.tbl` from loading and left known run maps unrecognized by dedicated servers +- Fix potential crash when an Alpine options tbl file contains an unrecognized option name [@is-this-c](https://github.com/is-this-c) - Clear cached server config output after a shuffle of a server's rotation diff --git a/game_patch/graphics/gr.cpp b/game_patch/graphics/gr.cpp index faf7362ac..1b8e31b11 100644 --- a/game_patch/graphics/gr.cpp +++ b/game_patch/graphics/gr.cpp @@ -605,7 +605,11 @@ void evaluate_pow2tex(const rf::String& level_filename) { if (is_p2t_fix_level(level_filename)) { should_p2t_fix = true; - rf::console::print("Applying power of 2 texture fix to known affected level {}", level_filename); + // Renderer-only, so a dedicated server has nothing to report -- and the + // quirks table lists enough levels to bury its console. + if (!rf::is_dedicated_server) { + rf::console::print("Applying power of 2 texture fix to known affected level {}", level_filename); + } } rf::gr::d3d::p2t = should_p2t_fix; @@ -614,7 +618,7 @@ void evaluate_pow2tex(const rf::String& level_filename) { // Always sync D3D11 state with current p2t value at level load if (g_game_config.renderer == GameConfig::Renderer::d3d11) { gr::d3d11::set_pow2_tex_active(rf::gr::d3d::p2t != 0); - if (is_sky_fix_level(level_filename)) { + if (is_sky_fix_level(level_filename) && !rf::is_dedicated_server) { rf::console::print("Applying sky fix to known affected level {}", level_filename); } } diff --git a/game_patch/misc/alpine_options.cpp b/game_patch/misc/alpine_options.cpp index 8daa01fac..9d36905e3 100644 --- a/game_patch/misc/alpine_options.cpp +++ b/game_patch/misc/alpine_options.cpp @@ -848,12 +848,13 @@ void load_single_af_options_file(const std::string& file_name) } auto meta_it = option_metadata.find(option_name); + const bool meta_found = meta_it != option_metadata.end(); // Allow any af_client*.tbl file for options designated to af_client.tbl - bool is_af_client_variant = (meta_it->second.filename == "af_client.tbl" && + bool is_af_client_variant = (meta_found && meta_it->second.filename == "af_client.tbl" && file_name.rfind("af_client", 0) == 0 && file_name.ends_with(".tbl")); - if (meta_it != option_metadata.end() && + if (meta_found && (meta_it->second.filename == file_name || is_af_client_variant) && (!rf::is_dedicated_server || meta_it->second.apply_on_server)) { @@ -866,7 +867,7 @@ void load_single_af_options_file(const std::string& file_name) xlog::debug("Option ID {} marked as loaded", static_cast(metadata.id)); } } - else if (meta_it != option_metadata.end()) { + else if (meta_found) { if (meta_it->second.filename != file_name && !is_af_client_variant) { xlog::warn("Option {} in {} skipped (wrong alpine tbl file)", option_name, file_name); } diff --git a/game_patch/misc/saved_votes.cpp b/game_patch/misc/saved_votes.cpp index 6a82db1fa..c33f5d7a3 100644 --- a/game_patch/misc/saved_votes.cpp +++ b/game_patch/misc/saved_votes.cpp @@ -338,7 +338,9 @@ const std::vector& saved_votes_unparsed() std::string saved_vote_encode(const SavedVote& vote) { - std::string out = "1|"; + // Version 2 appends the explicit-mutators field. Version 1 records still load; + // see saved_vote_parse. + std::string out = "2|"; out += encode_field(vote.name); out += '|'; out += std::format("{}", static_cast(vote.type)); @@ -397,6 +399,8 @@ std::string saved_vote_encode(const SavedVote& vote) } } } + out += '|'; + out += vote.mutators_explicit ? '1' : '0'; return out; } @@ -411,7 +415,10 @@ bool saved_vote_parse(std::string_view encoded, SavedVote& out) } const auto fields = split_view(encoded, '|'); - if (fields.size() != 8 || fields[0] != "1") { + // Version 1: 8 fields. Version 2: the same plus the explicit-mutators field. + const bool v1 = fields.size() == 8 && fields[0] == "1"; + const bool v2 = fields.size() == 9 && fields[0] == "2"; + if (!v1 && !v2) { return false; } @@ -526,6 +533,16 @@ bool saved_vote_parse(std::string_view encoded, SavedVote& out) } } + // Version 2 only. A version 1 record predates the field and keeps the meaning it + // was written with: inherit whatever set the session is running. + if (fields.size() == 9) { + unsigned long explicit_raw = 0; + if (!parse_uint_field(fields[8], explicit_raw) || explicit_raw > 1) { + return false; + } + vote.mutators_explicit = explicit_raw != 0; + } + // A Level vote with no level could never be called, so it is rejected here // rather than being listed as permanently broken. if (vote.type == AfVoteType::Level && vote.level.empty()) { @@ -643,9 +660,11 @@ AfVoteCallParams saved_vote_build_params(const SavedVote& vote, const VoteOption case AfVoteType::Match: { params.level = vote.level; params.gametype = vote.gametype; - // A saved entry records the complete mutator selection, so an empty one - // means "no mutators", not "keep whatever the session runs". - params.mutators_explicit = true; + // An entry the panel saved records the complete selection, so an empty + // one means "no mutators" rather than "keep whatever the session runs". + // A record written before the field existed says nothing, and inheriting + // is what it meant. + params.mutators_explicit = vote.mutators_explicit; if (vote.type == AfVoteType::Match) { params.team_size = static_cast(std::clamp(vote.team_size, 1, 8)); } diff --git a/game_patch/misc/saved_votes.h b/game_patch/misc/saved_votes.h index 51ad81d5f..f4aefd72f 100644 --- a/game_patch/misc/saved_votes.h +++ b/game_patch/misc/saved_votes.h @@ -37,6 +37,10 @@ struct SavedVote uint8_t team_size = 4; uint8_t extend_minutes = af_vote_extend_default_minutes; std::vector mutators; + // Whether `mutators` is the complete selection (replacing the session's set) or + // just what the panel happened to show. Absent from a record written before the + // field existed, which defaults to false -- the meaning those records had. + bool mutators_explicit = false; }; // The only three types worth saving: everything else is either parameterless diff --git a/game_patch/misc/vote_panel.cpp b/game_patch/misc/vote_panel.cpp index c98635138..ae77f09b9 100644 --- a/game_patch/misc/vote_panel.cpp +++ b/game_patch/misc/vote_panel.cpp @@ -1226,8 +1226,8 @@ std::string selected_level(); const std::vector& resolve_baseline(const VoteOptionsData& options, const std::string& level_string) { - // What the session is running right now, which is what a vote naming no - // mutators would keep. Level-independent, so the level argument only matters + // What the session is running right now, so an untouched form reproduces what + // the level already runs. Level-independent, so the level argument only matters // on a server too old to push it. if (const std::vector* active = vote_active_mutators_get()) { return *active; @@ -1276,6 +1276,10 @@ void reset_mutators_to_defaults(const VoteOptionsData& options) value.choice_index = option.default_choice; value.int_value = option.default_int; value.float_value = option.default_float; + // The number just stopped being whatever the panel last auto-filled, so + // the "player owns this" test has to be re-armed with it -- otherwise the + // stale marker reads as an edit and the auto default never applies again. + value.auto_defaulted_int.reset(); } } } @@ -1435,24 +1439,30 @@ uint8_t selected_gametype(const VoteOptionsData& options, bool team_only) // Falls back to what is running here for Match's "Current level" row. uint8_t default_gametype_for_level(const VoteOptionsData& options, const std::string& level_string) { + const auto running = rf::multi_get_game_type(); if (!level_string.empty()) { const std::string wanted = normalize_level_filename(level_string); - // Ahead of the blob: a run map's own filename says DM, so a server that - // never loaded af_level_quirks.tbl advertises DM for it. This is only the - // suggestion — the cycler still submits whatever the player leaves it on. - if (is_known_run_level(wanted)) { - return static_cast(rf::NetGameType::NG_TYPE_RUN); - } + // A level the server listed already carries the server's own resolution -- + // run maps, its base game type, prefixes -- so nothing here second-guesses it. for (const auto& entry : options.levels) { if (string_iequals(entry.filename, wanted)) { return entry.natural_gametype; } } + // Typed name the server never listed: mirror the server's resolution with + // what is running here standing in for its base game type. + if (is_known_run_level(wanted)) { + return static_cast(rf::NetGameType::NG_TYPE_RUN); + } + if (multi_game_type_uses_any_level(running) + || multi_level_name_has_game_type_prefix(wanted, running)) { + return static_cast(running); + } if (auto from_prefix = multi_game_type_for_level_prefix(wanted)) { return static_cast(*from_prefix); } } - return static_cast(rf::multi_get_game_type()); + return static_cast(running); } // The game type the vote would actually run under. @@ -1465,9 +1475,11 @@ uint8_t effective_gametype(const VoteOptionsData& options, bool team_only) return default_gametype_for_level(options, selected_level()); } -// Cycler index of `game_type`, or 0 when this vote offers no such type -- for a -// Match on a level whose own game type is not a team type, that is the first team -// type rather than a selection the server would refuse. +// Cycler index of `game_type` when this vote offers it. When it does not -- a Match +// on a level whose own game type is not a team type -- DM resolves to TeamDM, the +// team form of the same thing; anything else leaves the cycler where it already is +// rather than snapping to the first entry, which on Match is CTF and would filter +// the level list down to maps the player never asked for. int gametype_cycler_index(const VoteOptionsData& options, bool team_only, uint8_t game_type) { const auto gametypes = selectable_gametypes(options, team_only); @@ -1476,7 +1488,15 @@ int gametype_cycler_index(const VoteOptionsData& options, bool team_only, uint8_ return static_cast(i); } } - return 0; + if (game_type == static_cast(rf::NG_TYPE_DM)) { + for (size_t i = 0; i < gametypes.size(); ++i) { + if (gametypes[i]->id == static_cast(rf::NG_TYPE_TEAMDM)) { + return static_cast(i); + } + } + } + const int count = static_cast(gametypes.size()); + return count > 0 ? std::clamp(g_form.gametype_index, 0, count - 1) : 0; } // Snug width for the game type cycler: it only ever shows a short game type tag, so @@ -2381,6 +2401,8 @@ void begin_save_from_form(const VoteOptionsData& options) vote.team_size = static_cast(std::clamp(g_form.team_size, 1, 8)); } vote.mutators = build_saved_mutators(options, effective_gametype(options, is_match)); + // The panel always submits its complete selection, empty included. + vote.mutators_explicit = true; } // Snapshotted here, not in the callback: the popup returns later and a blob @@ -2632,6 +2654,29 @@ void do_mutator_scroll_region(PanelUi& ui, const Layout& lo, const VoteOptionsDa } } +// True when the stored level selection is still a level this server offers for +// voting and still passes the typed name filter, and only the game type mask puts +// it off the list. Read straight from the blob rather than from the display cache, +// which by construction no longer holds it. +bool selection_hidden_only_by_gametype(const VoteOptionsData& options, uint8_t gametype) +{ + for (const auto& level : options.levels) { + if (!string_iequals(level.filename, g_form.level_selection)) { + continue; + } + if (!level.allowed_for_vote || !level_matches_name_filter(level.filename)) { + continue; + } + const bool matches = gametype == af_vote_gametype_none + ? vote_level_allows_default_gametype(level) + : vote_level_allows_gametype(level, gametype); + if (!matches) { + return true; + } + } + return false; +} + void do_level_column(PanelUi& ui, const Layout& lo, const VoteOptionsData& options, const Rect& col, bool allow_current, uint8_t gametype) { @@ -2683,6 +2728,10 @@ void do_level_column(PanelUi& ui, const Layout& lo, const VoteOptionsData& optio // Read after the checkbox so a toggle takes effect in the same pass. const bool filter_active = options.gametype_prefix_restricted || g_filter_gametype_local; + // What the rows are actually filtered by. With the filter off the game type is + // not consulted at all, so keying the cache on it would rebuild a byte-identical + // list every time a level click re-derives the cycler. + const uint8_t filter_gametype = filter_active ? gametype : af_vote_gametype_none; // Rebuild the display rows only when an input changed. allowed_for_vote is an // independent axis from the gametype mask: it is applied ALWAYS, because a @@ -2692,11 +2741,11 @@ void do_level_column(PanelUi& ui, const Layout& lo, const VoteOptionsData& optio LevelListCache& cache = g_level_cache; if (!cache.valid || cache.levels_fp != levels_fp || cache.filter_active != filter_active || cache.name_filter != g_level_filter_text - || cache.gametype != gametype || cache.allow_current != allow_current) { + || cache.gametype != filter_gametype || cache.allow_current != allow_current) { cache.levels_fp = levels_fp; cache.filter_active = filter_active; cache.name_filter = g_level_filter_text; - cache.gametype = gametype; + cache.gametype = filter_gametype; cache.allow_current = allow_current; cache.gametype_hidden = 0; cache.not_allowed_hidden = 0; @@ -2711,9 +2760,9 @@ void do_level_column(PanelUi& ui, const Layout& lo, const VoteOptionsData& optio continue; } if (filter_active) { - const bool matches = gametype == af_vote_gametype_none + const bool matches = filter_gametype == af_vote_gametype_none ? vote_level_allows_default_gametype(level) - : vote_level_allows_gametype(level, gametype); + : vote_level_allows_gametype(level, filter_gametype); if (!matches) { ++cache.gametype_hidden; continue; @@ -2765,7 +2814,13 @@ void do_level_column(PanelUi& ui, const Layout& lo, const VoteOptionsData& optio [](const std::string& item) { return string_iequals(item, g_form.level_selection); }) - == cache.items.end()) { + == cache.items.end() + // Not when the game type doing the hiding is the one the panel derived from + // this very level: that pre-selection exists to follow the player's pick, so + // letting it discard the pick inverts it. A game type the player cycled to + // themselves still drops it, as does a map that left the vote list. + && (g_form.gametype_touched || !filter_active + || !selection_hidden_only_by_gametype(options, gametype))) { g_form.level_selection.clear(); cache.sel_valid = false; } @@ -2970,6 +3025,13 @@ int do_tab_row(PanelUi& ui, const Layout& lo, const std::vector& tabs) if (ui_button(ui, btn, tabs[i].label, lo.font, true, active)) { if (!active) { g_form.on_saved_tab = false; + if (g_form.type_index != i) { + // A cycler hold belongs to the tab it was made on: Level and Match + // offer different type sets, so carrying it over leaves the new tab + // pinned to a choice the player never made there. + g_form.gametype_touched = false; + g_form.gametype_key_valid = false; + } g_form.type_index = i; g_form.mutator_scroll = 0.0f; // the section is rebuilt for the new type } @@ -3158,6 +3220,15 @@ void do_form(PanelUi& ui, const Layout& lo, const VoteOptionsData& options, } } + // Clamped HERE, ahead of the level column that reads it: a tab switch can narrow + // the offered list under an index the player already moved, and clamping after + // the column would have the hit-test pass filter on "no game type" and the draw + // pass on the clamped one -- two different lists in one frame. + const auto gametypes = selectable_gametypes(options, is_match); + const int gametype_count = static_cast(gametypes.size()); + g_form.gametype_index = + gametype_count > 0 ? std::clamp(g_form.gametype_index, 0, gametype_count - 1) : 0; + const int col_gap = std::max(6, scaled(16.0f)); const int left_w = (lo.cw - col_gap) / 2; const Rect left_col{lo.cx, y, left_w, body_bottom - y}; @@ -3191,10 +3262,6 @@ void do_form(PanelUi& ui, const Layout& lo, const VoteOptionsData& options, } { - const auto gametypes = selectable_gametypes(options, is_match); - const int count = static_cast(gametypes.size()); - g_form.gametype_index = count > 0 ? std::clamp(g_form.gametype_index, 0, count - 1) : 0; - const int label_w = right_col.w / 2; if (ui.draw) { draw_label(ui, {right_col.x, ry + (lo.row_h - rf::gr::get_font_height(lo.font)) / 2, label_w, @@ -3207,18 +3274,19 @@ void do_form(PanelUi& ui, const Layout& lo, const VoteOptionsData& options, const Rect cycler{right_col.x + label_w, ry, gametype_cycler_width(options, is_match, lo.font, avail - reset_w - lo.gap), lo.row_h}; std::string text = "-"; - if (count > 0) { + if (gametype_count > 0) { const VoteGametypeInfo& gametype = *gametypes[g_form.gametype_index]; const char* short_name = gametype_short_name(gametype.id); - text = short_name != nullptr - ? std::string{short_name} - // A game type this build predates has no short tag, so fall back - // to the server-sent display name, truncated to the selector. - : fit_middle(gametype.name, ui_cycler_value_width(cycler), lo.font); + // A game type this build predates has no short tag, so the server-sent + // display name stands in. Both go through fit_middle: the cycler width is + // capped by the column, so even a short tag can outgrow its value box. + text = fit_middle(short_name != nullptr ? std::string_view{short_name} + : std::string_view{gametype.name}, + ui_cycler_value_width(cycler), lo.font); } const int delta = ui_cycler(ui, cycler, text.c_str(), lo.font); - if (delta != 0 && count > 0) { - g_form.gametype_index = (g_form.gametype_index + delta + count) % count; + if (delta != 0 && gametype_count > 0) { + g_form.gametype_index = (g_form.gametype_index + delta + gametype_count) % gametype_count; g_form.gametype_touched = true; // the cycler is the player's from here on play_click_sound(); } @@ -3226,7 +3294,7 @@ void do_form(PanelUi& ui, const Layout& lo, const VoteOptionsData& options, // Back to the selected level's own game type, and re-armed so it follows the // level again. const Rect reset{right_col.x + right_col.w - reset_w, ry, reset_w, lo.row_h}; - if (ui_button(ui, reset, "Reset", lo.font)) { + if (ui_button(ui, reset, "Reset", lo.font, gametype_count > 0) && gametype_count > 0) { std::string key = selected_level(); g_form.gametype_index = gametype_cycler_index(options, is_match, default_gametype_for_level(options, key)); @@ -3256,8 +3324,13 @@ void do_form(PanelUi& ui, const Layout& lo, const VoteOptionsData& options, const int base_w = std::min(btn_cap, rf::gr::get_string_size("Base", lo.font).first + btn_pad); + // Both capped to a quarter of the column, which on a narrow panel is less + // than the label needs; ui_button centres text in its rect without clipping. + const std::string current_label = fit_middle("Current", current_w, lo.font); + const std::string base_label = fit_middle("Base", base_w, lo.font); + const Rect current{right_col.x + right_col.w - current_w, ry - 1, current_w, header_h + 2}; - if (ui_button(ui, current, "Current", lo.font)) { + if (ui_button(ui, current, current_label.c_str(), lo.font)) { apply_baseline(options, resolve_baseline(options, selected_level())); g_form.mutators_touched = false; pin_mutator_baseline_context(); @@ -3268,7 +3341,8 @@ void do_form(PanelUi& ui, const Layout& lo, const VoteOptionsData& options, // Disabled rather than applied as an empty set on a server whose blob predates // the base section: "base runs nothing" and "base unknown" are not the same. const Rect base{current.x - lo.gap - base_w, ry - 1, base_w, header_h + 2}; - if (ui_button(ui, base, "Base", lo.font, options.base_mutator_decls_present)) { + if (ui_button(ui, base, base_label.c_str(), lo.font, options.base_mutator_decls_present) + && options.base_mutator_decls_present) { apply_baseline(options, options.base_mutator_decls); g_form.mutators_touched = true; pin_mutator_baseline_context(); diff --git a/game_patch/misc/vpackfile.cpp b/game_patch/misc/vpackfile.cpp index efbd5d5d4..53085d331 100644 --- a/game_patch/misc/vpackfile.cpp +++ b/game_patch/misc/vpackfile.cpp @@ -677,7 +677,7 @@ static void vpackfile_init_new() rf::vpackfile_add("music.vpp", nullptr); rf::vpackfile_add("ui.vpp", nullptr); } - + load_alpinefaction_vpp(); rf::vpackfile_add("tables.vpp", nullptr); addr_as_ref(0x01BDB218) = 1; // VPackfilesLoaded diff --git a/game_patch/multi/alpine_packets.cpp b/game_patch/multi/alpine_packets.cpp index 9aee23564..2cd0c68ba 100644 --- a/game_patch/multi/alpine_packets.cpp +++ b/game_patch/multi/alpine_packets.cpp @@ -1038,7 +1038,9 @@ struct VoteWriter ok = false; return; } - std::memcpy(buf + off, src, n); + if (n) { + std::memcpy(buf + off, src, n); // an empty vector's data() may be null + } off += n; } }; @@ -1269,15 +1271,15 @@ void af_send_vote_call(const AfVoteCallParams& params) w.str(params.level); w.u8(params.gametype); encoded = write_vote_mutators(w, params.mutators); - // Trailing and optional; see AfVoteCallParams::mutators_explicit. - w.u8(params.mutators_explicit ? 1 : 0); + // Trailing and optional; see af_vote_call_flags. + w.u8(params.mutators_explicit ? AF_VOTE_CALL_FLAG_MUTATORS_EXPLICIT : uint8_t{0}); break; case AfVoteType::Match: w.u8(params.team_size); w.str(params.level); w.u8(params.gametype); encoded = write_vote_mutators(w, params.mutators); - w.u8(params.mutators_explicit ? 1 : 0); + w.u8(params.mutators_explicit ? AF_VOTE_CALL_FLAG_MUTATORS_EXPLICIT : uint8_t{0}); break; case AfVoteType::Extend: w.u8(params.extend_minutes); @@ -1471,22 +1473,22 @@ void af_send_vote_state_end(rf::Player* player, AfVoteResult result, bool passed // pre-selects. Deliberately NOT part of the vote-options blob: that blob is // config-derived and cached behind a generation counter, while this changes with // every vote that installs an override. -void af_send_active_mutators(rf::Player* player) +// Returns false only when the declarations do not fit a packet, which is a +// property of the blob rather than of the recipient -- so a fan-out can stop on it +// instead of logging the same complaint once per player. +static bool af_send_active_mutators_blob(rf::Player* player, const std::vector& decls) { - if (!rf::is_server || !af_vote_recipient_is_structured(player)) { - return; + if (!af_vote_recipient_is_structured(player)) { + return true; } - std::vector decls; - server_vote_build_active_mutators_blob(decls); - std::byte buf[rf::max_packet_size]; VoteWriter w{buf, sizeof(buf), sizeof(RF_GamePacketHeader)}; w.u8(static_cast(af_server_req_type::af_sreq_active_mutators)); w.bytes(decls.data(), decls.size()); if (!w.ok) { xlog::warn("af_send_active_mutators: {} bytes of declarations do not fit a packet", decls.size()); - return; + return false; } RF_GamePacketHeader header{}; @@ -1494,6 +1496,17 @@ void af_send_active_mutators(rf::Player* player) header.size = static_cast(w.off - sizeof(header)); std::memcpy(buf, &header, sizeof(header)); af_send_packet(player, buf, static_cast(w.off), true); + return true; +} + +void af_send_active_mutators(rf::Player* player) +{ + if (!rf::is_server) { + return; + } + std::vector decls; + server_vote_build_active_mutators_blob(decls); + af_send_active_mutators_blob(player, decls); } void af_send_active_mutators_to_all() @@ -1501,9 +1514,16 @@ void af_send_active_mutators_to_all() if (!rf::is_server) { return; } + // Built once: the blob is the same for every recipient, and building it walks + // the mutator registry. + std::vector decls; + server_vote_build_active_mutators_blob(decls); + auto player_list = SinglyLinkedList{rf::player_list}; for (auto& player : player_list) { - af_send_active_mutators(&player); + if (!af_send_active_mutators_blob(&player, decls)) { + return; + } } } @@ -1827,10 +1847,12 @@ static void af_process_client_req_packet(const void* data, size_t len, const rf: xlog::warn("af_process_client_req_packet: bad vote level mutators"); return; } - // Optional trailing byte. A caller that omits it (or a client - // that predates it) is explicit only if it named anything. - params.mutators_explicit = - r.remaining() > 0 ? r.u8() != 0 : !params.mutators.empty(); + // Optional trailing flags byte; reserved bits are ignored. A + // caller that omits it (or a client that predates it) is + // explicit only if it named anything. + params.mutators_explicit = r.remaining() > 0 + ? (r.u8() & AF_VOTE_CALL_FLAG_MUTATORS_EXPLICIT) != 0 + : !params.mutators.empty(); break; case AfVoteType::Match: params.team_size = r.u8(); @@ -1840,8 +1862,9 @@ static void af_process_client_req_packet(const void* data, size_t len, const rf: xlog::warn("af_process_client_req_packet: bad vote match mutators"); return; } - params.mutators_explicit = - r.remaining() > 0 ? r.u8() != 0 : !params.mutators.empty(); + params.mutators_explicit = r.remaining() > 0 + ? (r.u8() & AF_VOTE_CALL_FLAG_MUTATORS_EXPLICIT) != 0 + : !params.mutators.empty(); break; case AfVoteType::Extend: params.extend_minutes = r.u8(); diff --git a/game_patch/multi/alpine_packets.h b/game_patch/multi/alpine_packets.h index 5c43cd18d..fdc4a0a93 100644 --- a/game_patch/multi/alpine_packets.h +++ b/game_patch/multi/alpine_packets.h @@ -219,6 +219,17 @@ enum af_vote_level_flags : uint8_t AF_VOTE_LEVEL_FLAG_ALLOWED = 1 << 0, }; +// The optional trailing flags byte of a Level/Match vote call. Reserved bits are +// masked off by the server and ignored, so a later writer can claim one without +// breaking an older server; an absent byte falls back to "explicit iff the vote +// named any mutator", which is what a client that predates the byte meant. +enum af_vote_call_flags : uint8_t +{ + // `mutators` is the caller's complete selection, empty included. Clear means + // "keep whatever set the session is running". + AF_VOTE_CALL_FLAG_MUTATORS_EXPLICIT = 1 << 0, +}; + // The `baseline_kind` byte appended after a level entry's flags: which mutator // set the vote panel pre-selects when that level is picked. enum class AfVoteLevelBaseline : uint8_t @@ -802,9 +813,9 @@ struct AfVoteCallParams uint8_t gametype = af_vote_gametype_none; uint8_t extend_minutes = af_vote_extend_default_minutes; std::vector mutators; - // `mutators` is the caller's complete selection, empty included, and replaces - // whatever the session is running. False (a chat vote, which cannot name - // mutators) means "keep the session's set". + // AF_VOTE_CALL_FLAG_MUTATORS_EXPLICIT: `mutators` is the caller's complete + // selection, empty included, and replaces whatever the session is running. + // False (a chat vote, which cannot name mutators) means "keep the session's set". bool mutators_explicit = false; bool preserve = true; }; diff --git a/game_patch/multi/dedi_cfg.cpp b/game_patch/multi/dedi_cfg.cpp index 670409887..e739a87f6 100644 --- a/game_patch/multi/dedi_cfg.cpp +++ b/game_patch/multi/dedi_cfg.cpp @@ -508,21 +508,8 @@ void apply_defaults_for_game_type(rf::NetGameType game_type, AlpineServerConfigR } } -// Set while a scope is re-parsed to DERIVE a second rules variant (the -// mutator-free baseline vote overrides start from). That pass walks the same TOML -// and re-reads the same preset files, so without this every preset warning and -// error was printed twice per scope — the repeat tagged "(no mutators)", which an -// admin has no way to interpret. The first pass is the one that reports problems. -static bool g_rules_parse_quiet = false; - -struct RulesParseQuietGuard -{ - bool previous; - RulesParseQuietGuard() : previous(g_rules_parse_quiet) { g_rules_parse_quiet = true; } - ~RulesParseQuietGuard() { g_rules_parse_quiet = previous; } - RulesParseQuietGuard(const RulesParseQuietGuard&) = delete; - RulesParseQuietGuard& operator=(const RulesParseQuietGuard&) = delete; -}; +// Declared in server_internal.h; see there for what it suppresses and why. +bool g_rules_parse_quiet = false; // What a parse pass over a rules scope is allowed to touch. enum class RulesParseMode @@ -548,8 +535,8 @@ static void apply_rules_keys_from_toml(const toml::table& t, AlpineServerConfigR // parse toml rules // for base rules, load all speciifed. For not specified, defaults are in struct // for level-specific rules, start with base rules and load anything specified beyond that -AlpineServerConfigRules parse_server_rules(const toml::table& t, const AlpineServerConfigRules& base_rules, - const RulesParseOptions& opts = {}) +static AlpineServerConfigRules parse_server_rules(const toml::table& t, const AlpineServerConfigRules& base_rules, + const RulesParseOptions& opts = {}) { AlpineServerConfigRules o = base_rules; @@ -2553,7 +2540,6 @@ void load_and_print_alpine_dedicated_server_config(std::string ads_config_name, cfg.signal_cfg_changed = true; server_vote_invalidate_options_blob(); clear_pending_rotation_preserve(); - af_send_active_mutators_to_all(); } initialize_core_alpine_dedicated_server_settings(netgame, cfg, on_launch); @@ -2690,13 +2676,18 @@ void apply_rules_for_current_level() else { // A manual load derives exactly like a level vote that named nothing: // the game type already resolved for this level (an explicit sv_gametype - // request, else the level's own default) and the session's mutator set. + // request, else the level's own default) over the configured base set. // Never a copy of the previous level's rules, which would carry its game - // type's fields into a different game type. - const std::vector session_mutators = - g_alpine_server_config_active_rules.mutators.declarations; + // type's fields into a different game type -- and never its mutators + // either: a vote-installed set lives in g_manual_rules_override (handled + // above, so `map x` mid-session still carries it) or in the rotation + // preserve stash (consumed below). Reaching here means neither is in + // play, i.e. something cleared the override, and reading the still-active + // rules would resurrect exactly what the clear was for. + const std::vector base_mutators = + cfg.base_rules.mutators.declarations; g_alpine_server_config_active_rules = - build_derived_server_rules(rf::netgame.type, session_mutators); + build_derived_server_rules(rf::netgame.type, base_mutators); if (!g_ads_minimal_server_info) rf::console::print("Applying derived rules for manually loaded level {}...\n", rf::level_filename_to_load); } @@ -2746,17 +2737,26 @@ void apply_rules_for_current_level() g_alpine_server_config_active_rules.mutators.declarations; g_alpine_server_config_active_rules = build_derived_server_rules(active_game_type, saved_mutators); + // The session override is what the NEXT manual load resolves its game type + // and rules from, so leaving it on the retargeted-away game type would have + // that load revert this one. + if (g_manual_rules_override) { + g_manual_rules_override->rules = g_alpine_server_config_active_rules; + } } // apply the rules apply_alpine_dedicated_server_rules(netgame, g_alpine_server_config_active_rules); - // Signal consumers that the active rules were (re)applied this call. - ++g_active_rules_generation; - // The vote panel pre-selects the session's mutator set, so clients need it - // whenever it can have changed. + // whenever it can have changed. Ahead of the generation bump: the blob only + // needs the registry's ids and option shapes, none of which the bump changes, + // so paying a full registry rebuild inside the fan-out buys nothing. The next + // consumer that actually reads the live defaults rebuilds it instead. af_send_active_mutators_to_all(); + + // Signal consumers that the active rules were (re)applied this call. + ++g_active_rules_generation; } void init_alpine_dedicated_server() { diff --git a/game_patch/multi/multi.cpp b/game_patch/multi/multi.cpp index 6b57f3c7f..9271222a3 100644 --- a/game_patch/multi/multi.cpp +++ b/game_patch/multi/multi.cpp @@ -1064,6 +1064,11 @@ std::string_view multi_game_type_prefix(const rf::NetGameType game_type) { // place that owns those pairings. bool multi_level_name_has_game_type_prefix(std::string_view level_filename, rf::NetGameType game_type) { + // UNK has no prefix of its own -- multi_game_type_prefix falls back to "dm" for + // it, which would claim every dm* name. + if (game_type == rf::NG_TYPE_UNK) { + return false; + } if (string_istarts_with(level_filename, multi_game_type_prefix(game_type))) { return true; } @@ -1090,7 +1095,7 @@ bool multi_level_name_has_game_type_prefix(std::string_view level_filename, rf:: // must not read as Wipeout. This direction only; they still accept any mp level. std::optional multi_game_type_for_level_prefix(std::string_view level_filename) { - for (int i = 0; i <= static_cast(rf::NG_TYPE_SAL); ++i) { + for (int i = 0; i < static_cast(rf::NG_TYPE_UNK); ++i) { const auto game_type = static_cast(i); if (multi_game_type_uses_any_level(game_type)) { continue; diff --git a/game_patch/multi/mutators.cpp b/game_patch/multi/mutators.cpp index ec7b5261a..2017a5ea9 100644 --- a/game_patch/multi/mutators.cpp +++ b/game_patch/multi/mutators.cpp @@ -795,7 +795,7 @@ void apply_mutators_from_toml(const toml::array& mutators_arr, AlpineServerConfi def->apply(rules, it->second); labels.push_back(def->label); } - else { + else if (!g_rules_parse_quiet) { rf::console::print(" [WARN] mutator '{}' does nothing in this game type and was not applied\n", def->name); } @@ -857,7 +857,8 @@ toml::array mutator_declarations_to_toml_array(const std::vector mutators_build_declarations_from_vote( - const std::vector& input, std::vector& out) + const std::vector& input, rf::NetGameType game_type, + std::vector& out) { out.clear(); @@ -870,6 +871,12 @@ std::optional mutators_build_declarations_from_vote( if (!seen_ids.insert(entry.mutator_id).second) { return std::format("mutator '{}' was selected more than once", info->label); } + // The panel greys these out rather than offering them, so a selection that + // reaches here did not come from one. + if (!mutator_gametype_mask_allows(info->valid_gametype_mask, static_cast(game_type))) { + return std::format("mutator '{}' cannot be used in {}", info->label, + multi_game_type_name_short(game_type)); + } MutatorDeclaration decl; decl.name = info->name; @@ -905,6 +912,12 @@ std::optional mutators_build_declarations_from_vote( decl.options[opt->name] = opt_in.int_value; break; case MutatorOptionType::Float: + // Nothing downstream (rules math, the config print, the TOML + // round-trip) is prepared for a NaN or an infinity off the wire. + if (!std::isfinite(opt_in.float_value)) { + return std::format("option '{}' of mutator '{}' has an out of range value", + opt->name, info->label); + } decl.options[opt->name] = opt_in.float_value; break; case MutatorOptionType::String: @@ -993,16 +1006,27 @@ rf::NetGameType resolve_level_default_game_type(std::string_view level_filename) if (is_known_run_level(normalized)) return rf::NetGameType::NG_TYPE_RUN; + // Ahead of the prefix scan: the server's own game type gets this level if it can + // host it at all, so a TeamDM server keeps TeamDM for dm07 instead of handing it + // to the DM the prefix names. Only when the base cannot host it does the prefix + // decide. + const rf::NetGameType base_gt = g_alpine_server_config.base_rules.game_type; + if (multi_game_type_uses_any_level(base_gt) || multi_level_name_has_game_type_prefix(normalized, base_gt)) + return base_gt; + if (auto from_prefix = multi_game_type_for_level_prefix(normalized)) return *from_prefix; - return g_alpine_server_config.base_rules.game_type; + return base_gt; } AlpineServerConfigRules build_derived_server_rules(rf::NetGameType game_type, const std::vector& mutators) { const auto& cfg = g_alpine_server_config; + // Every runtime derivation replays a set the config parse already reported on, + // and this one runs on every level load, vote apply and game type retarget. + const RulesParseQuietGuard quiet; AlpineServerConfigRules rules; if (game_type == cfg.base_rules.game_type) { @@ -2631,7 +2655,7 @@ static void crits_play_fire_sound(const rf::Vector3& pos) // Defined with the rest of the telegraph below; the listen host drives them from the server side. static void crit_glow_add(int handle, const rf::gr::Color& color); static rf::gr::Color crit_glow_color_for(const rf::Player* pp); -static void crits_flash_reticle(); +static void crits_flash_reticle(const rf::gr::Color& color); // The shooter's own client flashes its reticle for every crit fire event, whatever the weapon // class, so this goes out for hitscan, melee and the continuous window too - unlike the @@ -2644,7 +2668,7 @@ static void crits_send_shot_to_shooter(rf::Player* shooter, int weapon_type) return; // A listen host's own packet would be discarded, so its flash is stamped directly. if (shooter == rf::local_player) { - crits_flash_reticle(); + crits_flash_reticle(crit_glow_color_for(shooter)); } else if (is_player_minimum_af_client_version(shooter, 1, 4, 0)) { af_send_crit_shot_packet(shooter->net_data->player_id, @@ -2728,7 +2752,6 @@ static constexpr rf::gr::Color CRIT_GLOW_COLOR_NEUTRAL{255, 144, 32}; // gives on the screen that fired it, so it covers every weapon class. static constexpr int64_t CRIT_RETICLE_FLASH_MS = 350; static constexpr int CRIT_RETICLE_FLASH_SIZE = 96; // ~3x the stock reticle bitmap -static constexpr rf::gr::Color CRIT_RETICLE_FLASH_COLOR{255, 96, 32}; static constexpr rf::gr::Mode CRIT_RETICLE_FLASH_MODE{ rf::gr::TEXTURE_SOURCE_CLAMP, rf::gr::COLOR_SOURCE_VERTEX_TIMES_TEXTURE, @@ -2737,7 +2760,14 @@ static constexpr rf::gr::Mode CRIT_RETICLE_FLASH_MODE{ rf::gr::ZBUFFER_TYPE_NONE, rf::gr::FOG_NOT_ALLOWED, }; -static int64_t g_crit_reticle_flash_at = 0; +// Colour resolved when the flash is stamped, not when it is drawn: a first person spectator +// flashes in the shooter's colour rather than its own. +struct CritReticleFlash +{ + int64_t at = 0; + rf::gr::Color color = CRIT_GLOW_COLOR_NEUTRAL; +}; +static CritReticleFlash g_crit_reticle_flash; struct CritShotMarker { @@ -2778,9 +2808,9 @@ static void crit_glow_add(int handle, const rf::gr::Color& color) g_crit_glows.push_back({handle, color}); } -static void crits_flash_reticle() +static void crits_flash_reticle(const rf::gr::Color& color) { - g_crit_reticle_flash_at = timer::get_i64(1000); + g_crit_reticle_flash = {timer::get_i64(1000), color}; } // The engine's own projectile head glow texture. @@ -2809,7 +2839,7 @@ void crits_on_crit_shot(uint8_t shooter_player_id, uint8_t weapon_type) const bool spectating_shooter = !self && multi_spectate_is_first_person() && multi_spectate_get_target_player() == shooter; if (self || spectating_shooter) - crits_flash_reticle(); + crits_flash_reticle(color); // Everything below telegraphs a projectile in flight; a hitscan, melee or flame crit has // none, and the flash above is its whole story. The shooter and anyone spectating it are @@ -2902,16 +2932,16 @@ void crits_client_render() // HUD and never runs outside multiplayer gameplay. void crits_client_render_reticle_flash() { - if (!g_crit_reticle_flash_at || !g_alpine_game_config.crit_reticle_flash) + if (!g_crit_reticle_flash.at || !g_alpine_game_config.crit_reticle_flash) return; - const int64_t elapsed = timer::get_i64(1000) - g_crit_reticle_flash_at; + const int64_t elapsed = timer::get_i64(1000) - g_crit_reticle_flash.at; if (elapsed < 0 || elapsed >= CRIT_RETICLE_FLASH_MS) { - g_crit_reticle_flash_at = 0; + g_crit_reticle_flash = {}; return; } const int glow_bitmap = crit_glow_bitmap(); if (glow_bitmap < 0) { - g_crit_reticle_flash_at = 0; + g_crit_reticle_flash = {}; return; } int bm_w = 0, bm_h = 0; @@ -2922,8 +2952,8 @@ void crits_client_render_reticle_flash() const float fade = 1.0f - static_cast(elapsed) / CRIT_RETICLE_FLASH_MS; const int x = (rf::gr::clip_width() - CRIT_RETICLE_FLASH_SIZE) / 2; const int y = (rf::gr::clip_height() - CRIT_RETICLE_FLASH_SIZE) / 2; - rf::gr::set_color(CRIT_RETICLE_FLASH_COLOR.red, CRIT_RETICLE_FLASH_COLOR.green, - CRIT_RETICLE_FLASH_COLOR.blue, static_cast(fade * 255.0f)); + const rf::gr::Color& color = g_crit_reticle_flash.color; + rf::gr::set_color(color.red, color.green, color.blue, static_cast(fade * 255.0f)); rf::gr::bitmap_scaled(glow_bitmap, x, y, CRIT_RETICLE_FLASH_SIZE, CRIT_RETICLE_FLASH_SIZE, 0, 0, bm_w, bm_h, false, false, CRIT_RETICLE_FLASH_MODE); } @@ -3127,7 +3157,7 @@ static void crits_reset() g_crit_shot_markers.clear(); g_crit_glows.clear(); g_crit_local_shots.clear(); - g_crit_reticle_flash_at = 0; + g_crit_reticle_flash = {}; } // The universal fire function: the listen host's own trigger, bots, and every remote diff --git a/game_patch/multi/mutators.h b/game_patch/multi/mutators.h index 470249002..d8b84b6b5 100644 --- a/game_patch/multi/mutators.h +++ b/game_patch/multi/mutators.h @@ -8,8 +8,6 @@ #include #include "server_internal.h" -struct AlpineServerConfigRules; - namespace rf { struct Entity; @@ -228,8 +226,12 @@ toml::array mutator_declarations_to_toml_array(const std::vector mutators_build_declarations_from_vote( - const std::vector& input, std::vector& out); + const std::vector& input, rf::NetGameType game_type, + std::vector& out); // Human-readable labels of the declared mutators, joined with ", ". std::string mutators_join_labels(const std::vector& declarations); diff --git a/game_patch/multi/server_internal.h b/game_patch/multi/server_internal.h index 1085f0e3d..e0bbf583d 100644 --- a/game_patch/multi/server_internal.h +++ b/game_patch/multi/server_internal.h @@ -1138,6 +1138,21 @@ UpcomingGameTypeSelection get_upcoming_game_type_selection(); bool is_rcon_command_masterlisted(std::string_view command); bool set_upcoming_game_type(rf::NetGameType gt, UpcomingGameTypeSelection selection = UpcomingGameTypeSelection::Rotation); void apply_defaults_for_game_type(rf::NetGameType game_type, AlpineServerConfigRules& rules); +// Set while rules are parsed or derived somewhere the resulting complaints would be +// noise: the mutator-free/keys-only re-parses of an already-reported scope, and the +// runtime re-derivations (level load, vote apply, game type retarget) that replay a +// set the config already reported on. The config's own Full pass is the one that +// reports problems. +extern bool g_rules_parse_quiet; + +struct RulesParseQuietGuard +{ + bool previous; + RulesParseQuietGuard() : previous(g_rules_parse_quiet) { g_rules_parse_quiet = true; } + ~RulesParseQuietGuard() { g_rules_parse_quiet = previous; } + RulesParseQuietGuard(const RulesParseQuietGuard&) = delete; + RulesParseQuietGuard& operator=(const RulesParseQuietGuard&) = delete; +}; // Grants one spawn loadout weapon. Use instead of rf::player_add_weapon, which writes // ai.ammo[-1] for weapons with no ammo type. void af_give_loadout_weapon(rf::Player* pp, int weapon_type, int reserve_ammo); diff --git a/game_patch/multi/vote_client.cpp b/game_patch/multi/vote_client.cpp index 43f5fc47c..e3b370632 100644 --- a/game_patch/multi/vote_client.cpp +++ b/game_patch/multi/vote_client.cpp @@ -6,7 +6,6 @@ #include "vote_client.h" #include "alpine_packets.h" #include "../hud/hud.h" -#include "../misc/alpine_options.h" #include "../misc/alpine_settings.h" #include "../os/os.h" #include "../rf/multi.h" @@ -447,15 +446,10 @@ bool parse_vote_options_blob(const uint8_t* data, size_t len, VoteOptionsData& o VoteLevelInfo level; level.filename = body.str(); level.natural_gametype = body.u8(); + // Taken as sent: the server loads the quirks table too, so its mask already + // carries RUN for a run map. Adding it locally would only make the panel + // offer what a differently-configured server will refuse. level.valid_gametype_mask = body.u32(); - // A run map is identified by the quirks table, not by its filename, and a - // dedicated server never loads that table -- so the mask it sent leaves RUN - // out. Corrected once here rather than at each consumer, so the panel's - // filter and its RUN pre-selection cannot disagree. Local only: the server - // still adjudicates the vote it is actually sent. - if (is_known_run_level(level.filename)) { - level.valid_gametype_mask |= 1u << static_cast(rf::NG_TYPE_RUN); - } level.allowed_for_vote = (body.u8() & AF_VOTE_LEVEL_FLAG_ALLOWED) != 0; // Read the entry's own success BEFORE the appended baseline set below, so // trouble in the addition can only cost the pre-selection, never the level. @@ -487,16 +481,26 @@ bool parse_vote_options_blob(const uint8_t* data, size_t len, VoteOptionsData& o r.skip(body_len); // unconditional: the next entry starts here either way } - // The base mutator set, appended after the level section. Failing to read it - // costs the vote panel's pre-selection and nothing else, so it never fails the - // blob: a blob from a server built before it existed simply ends here. + // The base mutator set, appended after the level section behind its own u16 + // length. Failing to read it costs the vote panel's pre-selection and nothing + // else, so it never fails the blob: a blob from a server built before it existed + // simply ends here. `present` is set only when the section genuinely parsed, so + // "base runs nothing" stays distinct from "base unknown". if (r.remaining() > 0) { - if (parse_declaration_set(r, parsed.base_mutator_decls)) { - parsed.base_mutator_decls_present = true; + const uint16_t base_len = r.u16(); + if (!r.ok() || base_len > r.remaining()) { + xlog::debug("vote options: truncated base mutator section; the vote panel will pre-select nothing"); } else { - xlog::debug("vote options: unparseable base mutator set; the vote panel will pre-select nothing"); - parsed.base_mutator_decls.clear(); + BlobReader body{r.cur(), base_len}; + if (parse_declaration_set(body, parsed.base_mutator_decls) && body.ok()) { + parsed.base_mutator_decls_present = true; + } + else { + xlog::debug("vote options: unparseable base mutator set; the vote panel will pre-select nothing"); + parsed.base_mutator_decls.clear(); + } + r.skip(base_len); } } diff --git a/game_patch/multi/vote_client.h b/game_patch/multi/vote_client.h index 6b0edefc9..e2bca262b 100644 --- a/game_patch/multi/vote_client.h +++ b/game_patch/multi/vote_client.h @@ -61,9 +61,11 @@ struct VoteMutatorDeclValue std::string string_value; }; -// One config-declared mutator. The panel pre-selects these so that submitting an -// untouched vote reproduces what the level would run anyway (votes replace the -// configured mutator set rather than stacking on it). +// One declared mutator and the values it carries. The shape of every declaration +// set the server sends: a level's configured set, the base set, and the set the +// session is currently running. The panel pre-selects one of those so that +// submitting an untouched vote reproduces what is already running (a vote replaces +// the mutator set rather than stacking on it). struct VoteMutatorDecl { uint8_t mutator_id = 0; diff --git a/game_patch/multi/votes.cpp b/game_patch/multi/votes.cpp index f8019f604..1417ed8dd 100644 --- a/game_patch/multi/votes.cpp +++ b/game_patch/multi/votes.cpp @@ -620,7 +620,7 @@ static bool is_level_valid_for_vote_gametype(const std::string& level_name, rf:: static uint32_t build_level_valid_gametype_mask(const std::string& level_name) { uint32_t mask = 0; - for (int i = 0; i <= static_cast(rf::NG_TYPE_SAL); ++i) { + for (int i = 0; i < static_cast(rf::NG_TYPE_UNK); ++i) { if (does_level_match_gametype_prefix(level_name, static_cast(i))) { mask |= (1u << i); } @@ -1929,8 +1929,12 @@ static void build_vote_options_blob(std::vector& blob) // The base mutator set, as a trailing section: every level that inherits // (kind 0 above) pre-selects this, and so does a manually named level outside - // the rotation. - blob_declaration_set(blob, g_alpine_server_config.base_rules.mutators.declarations); + // the rotation. Length-prefixed like every other repeated record, so a stray + // trailing byte cannot read as an empty set and a further section can still be + // appended after it. + blob_sized_u16(blob, [&] { + blob_declaration_set(blob, g_alpine_server_config.base_rules.mutators.declarations); + }); } void server_vote_build_active_mutators_blob(std::vector& blob) @@ -2126,10 +2130,10 @@ static bool resolve_vote_gametype(uint8_t wire_value, rf::Player* sender, std::o return true; } -static bool resolve_vote_mutators(const std::vector& input, rf::Player* sender, - std::vector& out) +static bool resolve_vote_mutators(const std::vector& input, rf::NetGameType game_type, + rf::Player* sender, std::vector& out) { - if (auto error = mutators_build_declarations_from_vote(input, out)) { + if (auto error = mutators_build_declarations_from_vote(input, game_type, out)) { send_vote_reject_msg(std::format("Cannot start vote: {}", *error), sender); return false; } @@ -2162,16 +2166,19 @@ void handle_vote_call_packet(rf::Player* sender, AfVoteCallParams&& params) break; } case AfVoteType::Level: { + if (params.level.empty()) { + send_vote_reject_msg("Cannot start vote: no level was specified.", sender); + return; + } std::optional gametype; if (!resolve_vote_gametype(params.gametype, sender, gametype)) { return; } std::vector mutators; - if (!resolve_vote_mutators(params.mutators, sender, mutators)) { - return; - } - if (params.level.empty()) { - send_vote_reject_msg("Cannot start vote: no level was specified.", sender); + // Same resolution validate() applies to the same inputs, so a selection + // is checked against the mask of the type it would actually run under. + if (!resolve_vote_mutators(params.mutators, + resolve_effective_vote_game_type(params.level, gametype), sender, mutators)) { return; } g_vote_mgr.StartVote(sender, std::move(params.level), gametype, std::move(mutators), @@ -2183,8 +2190,12 @@ void handle_vote_call_packet(rf::Player* sender, AfVoteCallParams&& params) if (!resolve_vote_gametype(params.gametype, sender, gametype)) { return; } + // An empty level means the current one, exactly as validate() reads it. + const std::string match_level = + params.level.empty() ? std::string{rf::level.filename.c_str()} : params.level; std::vector mutators; - if (!resolve_vote_mutators(params.mutators, sender, mutators)) { + if (!resolve_vote_mutators(params.mutators, + resolve_effective_vote_game_type(match_level, gametype), sender, mutators)) { return; } g_vote_mgr.StartVote(sender, static_cast(params.team_size), diff --git a/game_patch/os/os.cpp b/game_patch/os/os.cpp index f0215f8f6..3764ca8c8 100644 --- a/game_patch/os/os.cpp +++ b/game_patch/os/os.cpp @@ -249,6 +249,9 @@ bool headless_bot_requested_from_raw_cmdline() bool awpgen_requested_from_raw_cmdline() { + if (rf::is_dedicated_server) { + return false; + } return raw_command_line_has_switch(L"-awpgen"); } From 5aefa5e8b0197976ac8503866d73f5d17b8561b9 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 21 Aug 2026 00:53:06 -0230 Subject: [PATCH 03/10] fix --- docs/CHANGELOG.md | 6 +- game_patch/graphics/gr.cpp | 16 +- game_patch/misc/alpine_options.cpp | 4 +- game_patch/misc/alpine_settings.cpp | 2 +- game_patch/misc/saved_votes.cpp | 20 +- game_patch/misc/saved_votes.h | 5 +- game_patch/misc/vote_panel.cpp | 280 ++++++++++++++++++---------- game_patch/misc/vpackfile.cpp | 4 +- game_patch/multi/alpine_packets.cpp | 5 +- game_patch/multi/alpine_packets.h | 12 +- game_patch/multi/dedi_cfg.cpp | 114 +++++------ game_patch/multi/multi.cpp | 32 +++- game_patch/multi/multi.h | 4 + game_patch/multi/mutators.cpp | 47 +++-- game_patch/multi/mutators.h | 10 + game_patch/multi/server_internal.h | 26 ++- game_patch/multi/vote_client.cpp | 6 +- game_patch/multi/vote_client.h | 6 +- game_patch/multi/votes.cpp | 130 ++++++------- 19 files changed, 431 insertions(+), 298 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ae7df6110..66576fb6e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -45,7 +45,7 @@ Version 1.4.0 (Lupin): Not yet released - Servers describe their votable levels, game types, and mutator options to clients - `Level` and `Match` can now select a game type and any number of mutators (with their options) for the voted level - Add a vote panel for calling any vote the server allows, opened during gameplay with the bindable `Call Vote Menu` control (`F4` by default) - - The vote panel preselects the chosen level's game type and the server's currently active mutator set, with buttons to reset the game type and restore the `Base` or `Current` mutator set + - The vote panel pre-selects the chosen level's game type and the server's currently active mutator set, with buttons to reset the game type and restore the `Base` or `Current` mutator set - Vote HUD notification now shows live tally, time remaining, and whether you have already voted - Add FactionFiles-integrated multiplayer statistics tracking - Dedicated servers with a configured `fflink_gsk` report a gameplay event stream to FactionFiles @@ -209,8 +209,8 @@ Version 1.4.0 (Lupin): Not yet released - Fix particle emitters created from `emitters.tbl` templates inheriting uninitialized UID and `Active Distance` values that could make their particles silently fail to spawn in rare cases - Fix spacebar (when bound to `Jump`) moving freelook camera upward when typing in chat - Fix crash risk when leaving a match or changing levels by keeping animation skeletons loaded while animation instances are still playing them, instead of unloading as soon as no character references them -- Fix dedicated servers not loading `alpinefaction.vpp`, which prevented `af_level_quirks.tbl` from loading and left known run maps unrecognized by dedicated servers -- Fix potential crash when an Alpine options tbl file contains an unrecognized option name +- Fix dedicated servers not loading `alpinefaction.vpp`, which prevented `af_level_quirks.tbl` from loading and left known run maps unrecognized +- Fix potential crash when an Alpine options `.tbl` file contains an unrecognized option name [@is-this-c](https://github.com/is-this-c) - Clear cached server config output after a shuffle of a server's rotation diff --git a/game_patch/graphics/gr.cpp b/game_patch/graphics/gr.cpp index 1b8e31b11..acec8f2a7 100644 --- a/game_patch/graphics/gr.cpp +++ b/game_patch/graphics/gr.cpp @@ -601,15 +601,11 @@ ConsoleCommand2 pow2_tex_cmd{ void evaluate_pow2tex(const rf::String& level_filename) { // if dbg_pow2tex is active, use manual override instead of level filename lookup if (!override_pow2tex) { - bool should_p2t_fix = false; - - if (is_p2t_fix_level(level_filename)) { - should_p2t_fix = true; - // Renderer-only, so a dedicated server has nothing to report -- and the - // quirks table lists enough levels to bury its console. - if (!rf::is_dedicated_server) { - rf::console::print("Applying power of 2 texture fix to known affected level {}", level_filename); - } + const bool should_p2t_fix = is_p2t_fix_level(level_filename); + // Renderer-only, so a dedicated server has nothing to report -- and the + // quirks table lists enough levels to bury its console. + if (!rf::is_dedicated_server && should_p2t_fix) { + rf::console::print("Applying power of 2 texture fix to known affected level {}", level_filename); } rf::gr::d3d::p2t = should_p2t_fix; @@ -618,7 +614,7 @@ void evaluate_pow2tex(const rf::String& level_filename) { // Always sync D3D11 state with current p2t value at level load if (g_game_config.renderer == GameConfig::Renderer::d3d11) { gr::d3d11::set_pow2_tex_active(rf::gr::d3d::p2t != 0); - if (is_sky_fix_level(level_filename) && !rf::is_dedicated_server) { + if (!rf::is_dedicated_server && is_sky_fix_level(level_filename)) { rf::console::print("Applying sky fix to known affected level {}", level_filename); } } diff --git a/game_patch/misc/alpine_options.cpp b/game_patch/misc/alpine_options.cpp index 9d36905e3..44d08d2f5 100644 --- a/game_patch/misc/alpine_options.cpp +++ b/game_patch/misc/alpine_options.cpp @@ -851,8 +851,8 @@ void load_single_af_options_file(const std::string& file_name) const bool meta_found = meta_it != option_metadata.end(); // Allow any af_client*.tbl file for options designated to af_client.tbl - bool is_af_client_variant = (meta_found && meta_it->second.filename == "af_client.tbl" && - file_name.rfind("af_client", 0) == 0 && file_name.ends_with(".tbl")); + const bool is_af_client_variant = (meta_found && meta_it->second.filename == "af_client.tbl" && + file_name.rfind("af_client", 0) == 0 && file_name.ends_with(".tbl")); if (meta_found && (meta_it->second.filename == file_name || is_af_client_variant) && diff --git a/game_patch/misc/alpine_settings.cpp b/game_patch/misc/alpine_settings.cpp index 4069565a2..0faab308d 100644 --- a/game_patch/misc/alpine_settings.cpp +++ b/game_patch/misc/alpine_settings.cpp @@ -1605,7 +1605,7 @@ void alpine_player_settings_save(rf::Player* player) const std::vector& unreadable_saved_votes = saved_votes_unparsed(); if (!saved_votes.empty() || !unreadable_saved_votes.empty()) { file << "\n[SavedVotes]\n"; - file << "; Format is SavedVote{N}=1|{Name}|{Type}|{Level}|{GameType}|{TeamSize}|{ExtendMinutes}|{Mutators}\n"; + file << "; Format is SavedVote{N}=2|{Name}|{Type}|{Level}|{GameType}|{TeamSize}|{ExtendMinutes}|{Mutators}|{MutatorsExplicit}\n"; for (size_t i = 0; i < saved_votes.size(); ++i) { file << "SavedVote" << i << "=" << saved_vote_encode(saved_votes[i]) << "\n"; diff --git a/game_patch/misc/saved_votes.cpp b/game_patch/misc/saved_votes.cpp index c33f5d7a3..02e3f176d 100644 --- a/game_patch/misc/saved_votes.cpp +++ b/game_patch/misc/saved_votes.cpp @@ -33,12 +33,16 @@ constexpr size_t max_unparsed_lines = 50; // --------------------------------------------------------------------------- // One-line INI encoding // -// 1||||||| +// 2|||||||| // // is entries joined by ';', each `name` or `name:opt=Xval,opt=Xval`, // where the value's first character types it: b0/b1 bool, i int, f float, -// c