From 606f5449f55e30088e107ce4d48f415bc2062913 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sun, 5 Jul 2026 23:17:55 -0230 Subject: [PATCH 1/5] initial --- common/include/common/rfproto.h | 3 +- game_patch/CMakeLists.txt | 2 + game_patch/hud/multi_hud.cpp | 23 +- game_patch/hud/multi_scoreboard.cpp | 11 + game_patch/multi/dedi_cfg.cpp | 41 +++ game_patch/multi/gametype.cpp | 32 +- game_patch/multi/gametype.h | 1 + game_patch/multi/multi.cpp | 8 + game_patch/multi/rounds.cpp | 62 +++- game_patch/multi/rounds.h | 8 + game_patch/multi/server.cpp | 123 +++++++- game_patch/multi/wipeout.cpp | 465 ++++++++++++++++++++++++++++ game_patch/multi/wipeout.h | 51 +++ game_patch/rf/multi.h | 1 + game_patch/rf/player/player.h | 9 + 15 files changed, 820 insertions(+), 20 deletions(-) create mode 100644 game_patch/multi/wipeout.cpp create mode 100644 game_patch/multi/wipeout.h diff --git a/common/include/common/rfproto.h b/common/include/common/rfproto.h index 1f717612..09aa066f 100644 --- a/common/include/common/rfproto.h +++ b/common/include/common/rfproto.h @@ -175,7 +175,8 @@ enum RF_GameType RF_GT_BM = 0x08, RF_GT_TBM = 0x09, RF_GT_LMS = 0x0A, - // Sentinel: must always be the last entry. + RF_GT_WO = 0x0B, + // Sentinel: must always be the last entry. Keep in sync with rf::NetGameType. RF_GT_UNK }; diff --git a/game_patch/CMakeLists.txt b/game_patch/CMakeLists.txt index cda3822e..bd26184d 100644 --- a/game_patch/CMakeLists.txt +++ b/game_patch/CMakeLists.txt @@ -150,6 +150,8 @@ set(SRCS multi/rounds.cpp multi/lms.h multi/lms.cpp + multi/wipeout.h + multi/wipeout.cpp multi/kill.cpp multi/network.cpp multi/network.h diff --git a/game_patch/hud/multi_hud.cpp b/game_patch/hud/multi_hud.cpp index ad36d789..2a7129cc 100644 --- a/game_patch/hud/multi_hud.cpp +++ b/game_patch/hud/multi_hud.cpp @@ -15,6 +15,7 @@ #include "../multi/multi.h" #include "../multi/gametype.h" #include "../multi/bagman.h" +#include "../multi/wipeout.h" #include "../input/input.h" #include "../rf/input.h" #include "../rf/hud.h" @@ -932,6 +933,21 @@ void multi_hud_render_team_scores() } } + // Wipeout: next to each team's flag icon, show how many of that team are + // currently alive vs. waiting (dead/awaiting-respawn or a late joiner). The + // round-win score itself is drawn on the right by the standard path below. + if (game_type == rf::NG_TYPE_WO) { + rf::gr::set_color(53, 207, 22, 255); + std::string red_label = std::format("({} Alive, {} Waiting)", + wipeout_count_team_alive(rf::TEAM_RED), wipeout_count_team_waiting(rf::TEAM_RED)); + std::string blue_label = std::format("({} Alive, {} Waiting)", + wipeout_count_team_alive(rf::TEAM_BLUE), wipeout_count_team_waiting(rf::TEAM_BLUE)); + std::string red_fit = hud_fit_string(red_label, max_miniflag_label_w, nullptr, font_id); + std::string blue_fit = hud_fit_string(blue_label, max_miniflag_label_w, nullptr, font_id); + rf::gr::string(miniflag_label_x, red_miniflag_label_y, red_fit.c_str(), font_id); + rf::gr::string(miniflag_label_x, blue_miniflag_label_y, blue_fit.c_str(), font_id); + } + if (multi_is_team_game_type() && !is_hill_score && !is_run) { float miniflag_scale = g_big_team_scores_hud ? 1.5f : 1.0f; rf::gr::set_color(255, 255, 255, 255); @@ -974,6 +990,11 @@ void multi_hud_render_team_scores() red_score = bagman_get_red_team_score(); blue_score = bagman_get_blue_team_score(); } + else if (game_type == rf::NG_TYPE_WO) { + rf::gr::set_color(53, 207, 22, 255); + red_score = wipeout_get_red_team_score(); + blue_score = wipeout_get_blue_team_score(); + } } auto red_score_str = std::to_string(red_score); @@ -1068,7 +1089,7 @@ CodeInjection multi_hud_render_team_scores_new_gamemodes_patch { const bool is_ffa_with_list = game_type == rf::NG_TYPE_DM || game_type == rf::NG_TYPE_BAG || game_type == rf::NG_TYPE_LMS; - if (gt_is_koth() || gt_is_dc() || gt_is_rev() || gt_is_run() || gt_is_esc() || gt_is_tbag() || is_ffa_with_list) { + if (gt_is_koth() || gt_is_dc() || gt_is_rev() || gt_is_run() || gt_is_esc() || gt_is_tbag() || gt_is_wipeout() || is_ffa_with_list) { regs.eip = 0x00476E06; // multi_hud_render_team_scores } } diff --git a/game_patch/hud/multi_scoreboard.cpp b/game_patch/hud/multi_scoreboard.cpp index 6b488a5d..5a4e8126 100644 --- a/game_patch/hud/multi_scoreboard.cpp +++ b/game_patch/hud/multi_scoreboard.cpp @@ -10,6 +10,7 @@ #include "../multi/multi.h" #include "../multi/gametype.h" #include "../multi/bagman.h" +#include "../multi/wipeout.h" #include "../misc/alpine_options.h" #include "../misc/alpine_settings.h" #include "../rf/player/control_config.h" @@ -230,6 +231,16 @@ int draw_scoreboard_header(int x, int y, int w, rf::NetGameType game_type, bool red_score = bagman_get_red_team_score(); blue_score = bagman_get_blue_team_score(); } + else if (game_type == rf::NG_TYPE_WO) { + static int hud_flag_red_bm = rf::bm::load("hud_flag_red.tga", -1, true); + static int hud_flag_blue_bm = rf::bm::load("hud_flag_blue.tga", -1, true); + int flag_bm_w, flag_bm_h; + rf::bm::get_dimensions(hud_flag_red_bm, &flag_bm_w, &flag_bm_h); + rf::gr::bitmap(hud_flag_red_bm, x + w * 2 / 6 - flag_bm_w / 2, cur_y); + rf::gr::bitmap(hud_flag_blue_bm, x + w * 4 / 6 - flag_bm_w / 2, cur_y); + red_score = wipeout_get_red_team_score(); + blue_score = wipeout_get_blue_team_score(); + } else if (game_type == rf::NG_TYPE_REV || game_type == rf::NG_TYPE_ESC) { static int hud_flag_red_bm = rf::bm::load("hud_flag_red.tga", -1, true); static int hud_flag_blue_bm = rf::bm::load("hud_flag_blue.tga", -1, true); diff --git a/game_patch/multi/dedi_cfg.cpp b/game_patch/multi/dedi_cfg.cpp index be784947..7c4e511e 100644 --- a/game_patch/multi/dedi_cfg.cpp +++ b/game_patch/multi/dedi_cfg.cpp @@ -486,6 +486,47 @@ void apply_defaults_for_game_type(rf::NetGameType game_type, AlpineServerConfigR break; } + case rf::NetGameType::NG_TYPE_WO: { + // Round-based team wipe mode. + rules.location_pinging = true; + + // 200 / 200 spawn health and armor. + rules.spawn_life.enabled = true; + rules.spawn_life.set_value(200.0f); + rules.spawn_armour.enabled = true; + rules.spawn_armour.set_value(200.0f); + + // Base respawn delay of 5s; the death hook multiplies it by the + // player's death count this round (escalating 5s/10s/15s...). + rules.spawn_delay.enabled = true; + rules.spawn_delay.set_base_value(5.0f); + rules.force_respawn = false; // wipeout_do_frame auto-respawns once the delay elapses + + // Fixed loadout: Pistol / AR / SR / RL / SG, with infinite reloads + // and large reserves (effectively infinite ammo). + constexpr int wo_reserve = 999; + rules.spawn_loadout.loadouts_active = true; + rules.spawn_loadout.add("12mm handgun", wo_reserve, false, true); + rules.spawn_loadout.add("Assault Rifle", wo_reserve, false, true); + rules.spawn_loadout.add("Sniper Rifle", wo_reserve, false, true); + rules.spawn_loadout.add("Rocket Launcher", wo_reserve, false, true); + rules.spawn_loadout.add("Shotgun", wo_reserve, false, true); + rules.weapon_infinite_magazines = true; + rules.default_player_weapon.set_weapon("Assault Rifle"); + + // No item spawns, no weapon/amp drops (wipeout also hides all level + // items each round; see wipeout.cpp). + rules.drop_weapons = false; + rules.drop_amps = false; + + // Best-of-7, untimed rounds (a round ends only on a team wipe). + rules.rounds.set_max_rounds(7); + rules.rounds.round_time = 0; // 0 == untimed sentinel (see rounds.cpp) + rules.rounds.set_post_round_time(3); + rules.rounds.set_intermission_time(5); + break; + } + default: { rules.spawn_delay.enabled = false; diff --git a/game_patch/multi/gametype.cpp b/game_patch/multi/gametype.cpp index 732e683a..a890ffad 100644 --- a/game_patch/multi/gametype.cpp +++ b/game_patch/multi/gametype.cpp @@ -10,6 +10,7 @@ #include "bagman.h" #include "rounds.h" #include "lms.h" +#include "wipeout.h" #include "multi.h" #include "alpine_packets.h" #include "../hud/hud_internal.h" @@ -38,6 +39,8 @@ static char tbag_name[] = "TBAG"; static char* tbag_slot = tbag_name; static char lms_name[] = "LMS"; static char* lms_slot = lms_name; +static char wo_name[] = "WO"; +static char* wo_slot = wo_name; // UNK is the sentinel; new game types must be added above static char unk_name[] = "UNK"; static char* unk_slot = unk_name; @@ -80,6 +83,7 @@ void populate_gametype_table() { g_af_gametype_names[rf::NG_TYPE_BAG] = &bag_slot; g_af_gametype_names[rf::NG_TYPE_TBAG] = &tbag_slot; g_af_gametype_names[rf::NG_TYPE_LMS] = &lms_slot; + g_af_gametype_names[rf::NG_TYPE_WO] = &wo_slot; g_af_gametype_names[rf::NG_TYPE_UNK] = &unk_slot; for (int i = 0; i < 5; ++i) { @@ -128,8 +132,9 @@ bool multi_game_type_is_team_type(rf::NetGameType game_type) case rf::NG_TYPE_REV: case rf::NG_TYPE_ESC: case rf::NG_TYPE_TBAG: + case rf::NG_TYPE_WO: return true; - default: // DM, RUN, BAG + default: // DM, RUN, BAG, LMS return false; } } @@ -241,14 +246,20 @@ bool gt_is_lms() return rf::multi_get_game_type() == rf::NetGameType::NG_TYPE_LMS; } +bool gt_is_wipeout() +{ + return rf::multi_get_game_type() == rf::NetGameType::NG_TYPE_WO; +} + bool gt_uses_custom_scoring() { - return gt_is_bagman_any() || gt_is_lms(); + return gt_is_bagman_any() || gt_is_lms() || gt_is_wipeout(); } bool gt_type_uses_rounds(rf::NetGameType game_type) { - return game_type == rf::NetGameType::NG_TYPE_LMS; + return game_type == rf::NetGameType::NG_TYPE_LMS + || game_type == rf::NetGameType::NG_TYPE_WO; } bool gt_uses_rounds() @@ -1957,6 +1968,7 @@ void multi_level_init_post_gametypes() hill_mode_level_init_post(); bagman_level_init_post(); lms_level_init_post(); + wipeout_level_init_post(); // Rounds must initialise AFTER per-gametype level-init so the gametype // has registered its callbacks before round 1 begins. rounds_level_init_post(); @@ -1969,6 +1981,7 @@ CodeInjection multi_level_init_gametypes_injection{ rounds_level_init(); hill_mode_level_init(); bagman_level_init(); + wipeout_level_init(); }, }; @@ -2053,6 +2066,13 @@ CodeInjection send_team_score_patch{ regs.ax = blue_score; regs.eip = 0x00472176; // use stock game packet send } + else if (gt_is_wipeout()) { + const uint16_t red_score = (uint16_t)std::clamp(wipeout_get_red_team_score(), 0, 0xFFFF); + const uint16_t blue_score = (uint16_t)std::clamp(wipeout_get_blue_team_score(), 0, 0xFFFF); + regs.si = red_score; + regs.ax = blue_score; + regs.eip = 0x00472176; // use stock game packet send + } }, }; @@ -2073,6 +2093,12 @@ CodeInjection process_team_score_patch{ bagman_set_red_team_score(red_score); bagman_set_blue_team_score(blue_score); } + else if (gt_is_wipeout()) { + int red_score = regs.esi; + int blue_score = regs.edi; + wipeout_set_red_team_score(red_score); + wipeout_set_blue_team_score(blue_score); + } }, }; diff --git a/game_patch/multi/gametype.h b/game_patch/multi/gametype.h index 610b6db8..514649aa 100644 --- a/game_patch/multi/gametype.h +++ b/game_patch/multi/gametype.h @@ -177,6 +177,7 @@ bool gt_is_bag(); bool gt_is_tbag(); bool gt_is_bagman_any(); bool gt_is_lms(); +bool gt_is_wipeout(); // Score is driven by gametype-specific logic rather than directly by frags. bool gt_uses_custom_scoring(); diff --git a/game_patch/multi/multi.cpp b/game_patch/multi/multi.cpp index 9727a8b7..ff9d4a46 100644 --- a/game_patch/multi/multi.cpp +++ b/game_patch/multi/multi.cpp @@ -803,6 +803,8 @@ std::string_view multi_game_type_name(const rf::NetGameType game_type) { return std::string_view{"Team Bagman"}; } else if (game_type == rf::NG_TYPE_LMS) { return std::string_view{"Last Miner Standing"}; + } else if (game_type == rf::NG_TYPE_WO) { + return std::string_view{"Wipeout"}; } else if (game_type == rf::NG_TYPE_UNK) { return std::string_view{"Unknown"}; } else { @@ -834,6 +836,8 @@ std::string_view multi_game_type_name_upper(const rf::NetGameType game_type) { return std::string_view{"TEAM BAGMAN"}; } else if (game_type == rf::NG_TYPE_LMS) { return std::string_view{"LAST MINER STANDING"}; + } else if (game_type == rf::NG_TYPE_WO) { + return std::string_view{"WIPEOUT"}; } else if (game_type == rf::NG_TYPE_UNK) { return std::string_view{"UNKNOWN"}; } else { @@ -865,6 +869,8 @@ std::string_view multi_game_type_name_short(const rf::NetGameType game_type) { return std::string_view{"TBAG"}; } else if (game_type == rf::NG_TYPE_LMS) { return std::string_view{"LMS"}; + } else if (game_type == rf::NG_TYPE_WO) { + return std::string_view{"WO"}; } else if (game_type == rf::NG_TYPE_UNK) { return std::string_view{"UNK"}; } else { @@ -898,6 +904,8 @@ std::string_view multi_game_type_prefix(const rf::NetGameType game_type) { return std::string_view{"tbag"}; } else if (game_type == rf::NG_TYPE_LMS) { return std::string_view{"lms"}; + } else if (game_type == rf::NG_TYPE_WO) { + return std::string_view{"wo"}; } else if (game_type == rf::NG_TYPE_UNK) { // No real level-name prefix for unknown game types; "dm" is the safest fallback. return std::string_view{"dm"}; diff --git a/game_patch/multi/rounds.cpp b/game_patch/multi/rounds.cpp index a37246c8..814005c8 100644 --- a/game_patch/multi/rounds.cpp +++ b/game_patch/multi/rounds.cpp @@ -148,8 +148,16 @@ void start_round() // The engine uses multi_time_limit (== netgame.max_time_seconds) for the // HUD countdown rendered as (max_time - level.time). Setting it to the // round's deadline makes the existing HUD show the correct remaining time - // every round, with no new packet. - rf::multi_time_limit = g_rounds_runtime.round_deadline_level_time; + // every round, with no new packet. When round_time is 0 the round is + // untimed (e.g. Wipeout, which ends only on a team wipe): park the limit + // far in the future so no countdown ever appears and tick_active never + // time-ends the round. + if (cfg().round_time == 0) { + rf::multi_time_limit = rf::level.time + 1.0e9f; + } + else { + rf::multi_time_limit = g_rounds_runtime.round_deadline_level_time; + } if (g_rounds_callbacks.on_round_begin) { g_rounds_callbacks.on_round_begin(); @@ -194,11 +202,45 @@ void enter_intermission() } } +void rotate_to_next_level() +{ + g_rounds_runtime.state = RoundState::Inactive; + g_rounds_runtime.current = 0; + g_rounds_runtime.pending_level_change = true; // gate Inactive→start until level actually loads + // Advance the rotation. multi_change_level is async; the latch above + // keeps rounds_do_frame from restarting a round-set on the doomed level. + set_manually_loaded_level(false); + rf::multi_change_level(nullptr); +} + void proceed_to_next_round_or_rotate() { // Called after the PostRound celebration window expires (or directly if - // post_round_time is 0). Decides whether to rotate the level (max rounds - // hit) or continue into intermission + the next round. + // post_round_time is 0). Decides whether to rotate the level (match/max + // rounds decided) or continue into intermission + the next round. + + // Gametype-driven match end (best-of-N + sudden death etc.) takes priority + // over the fixed max_rounds count and fully governs rotation when present. + if (g_rounds_callbacks.is_match_over) { + if (g_rounds_callbacks.is_match_over()) { + rf::console::print("Rounds: match decided, advancing to next level.\n"); + rotate_to_next_level(); + } + else if (cfg().intermission_time > 0) { + enter_intermission(); + } + else { + if (g_rounds_callbacks.on_round_cleanup) { + g_rounds_callbacks.on_round_cleanup(); + } + if (!ready_to_start_round()) { + enter_intermission(); // hold here, no countdown + return; + } + start_round(); + } + return; + } if (g_rounds_runtime.current >= cfg().max_rounds) { rf::console::print("Rounds: max rounds reached, advancing to next level.\n"); @@ -207,13 +249,7 @@ void proceed_to_next_round_or_rotate() 3, static_cast(HudNotificationType::Round), true); - g_rounds_runtime.state = RoundState::Inactive; - g_rounds_runtime.current = 0; - g_rounds_runtime.pending_level_change = true; // gate Inactive→start until level actually loads - // Advance the rotation. multi_change_level is async; the latch above - // keeps rounds_do_frame from restarting a round-set on the doomed level. - set_manually_loaded_level(false); - rf::multi_change_level(nullptr); + rotate_to_next_level(); return; } @@ -293,8 +329,8 @@ void tick_active() } } - // 2. Time-up - if (rf::level.time >= g_rounds_runtime.round_deadline_level_time) { + // 2. Time-up (skipped entirely for untimed rounds, round_time == 0) + if (cfg().round_time != 0 && rf::level.time >= g_rounds_runtime.round_deadline_level_time) { rf::Player* w = nullptr; if (g_rounds_callbacks.resolve_timeout_winner) { w = g_rounds_callbacks.resolve_timeout_winner(); diff --git a/game_patch/multi/rounds.h b/game_patch/multi/rounds.h index bc3fbf07..88991836 100644 --- a/game_patch/multi/rounds.h +++ b/game_patch/multi/rounds.h @@ -60,6 +60,14 @@ struct RoundCallbacks // Used to gate on minimum player count, warmup, etc. bool (*can_round_start)() = nullptr; + // Optional match-end arbiter for gametypes whose match length isn't a fixed + // round count (e.g. best-of-N with sudden death). Consulted after a round + // ends, once the winner has been credited. If set, it FULLY governs level + // rotation: return true to rotate now (match decided), false to continue to + // the next round regardless of cfg().max_rounds. If null, the rounds system + // falls back to the stock "current >= max_rounds" rotation (e.g. LMS). + bool (*is_match_over)() = nullptr; + // Fired when a player joins while a round is already Active. The // gametype's job is to mark the player as "out for this round" — the // canonical marker is rf::Player::round_is_out, which the spawn gate diff --git a/game_patch/multi/server.cpp b/game_patch/multi/server.cpp index 2c269eb0..94e9ec6e 100644 --- a/game_patch/multi/server.cpp +++ b/game_patch/multi/server.cpp @@ -26,6 +26,7 @@ #include "bagman.h" #include "rounds.h" #include "lms.h" +#include "wipeout.h" #include "../os/console.h" #include "../hud/hud.h" #include "../misc/player.h" @@ -830,6 +831,9 @@ std::optional resolve_gametype_from_name(std::string_view gamet if (string_iequals(gametype_name, "lms")) { return rf::NetGameType::NG_TYPE_LMS; } + if (string_iequals(gametype_name, "wo") || string_iequals(gametype_name, "wipeout")) { + return rf::NetGameType::NG_TYPE_WO; + } return std::nullopt; } @@ -912,7 +916,7 @@ ConsoleCommand2 sv_game_type_cmd{ } }, "Load a specific gametype. Loads level if specificed, otherwise restarts current level. Only available for ADS dedicated servers.", - "sv_gametype [level]", + "sv_gametype [level]", }; DcCommandAlias gt_cmd{ @@ -2142,6 +2146,12 @@ FunHook multi_spawn_player_server_side_hook{ return; } + // Wipeout: block late joiners / between-round spawn attempts (the + // escalating per-death delay is enforced by the respawn_timer check below). + if (!wipeout_can_player_spawn(player)) { + return; + } + // if a respawn timer has been set by the server, enforce it if (player->respawn_timer.valid() && !player->respawn_timer.elapsed()) { const float spawn_delay_left = std::max( @@ -3016,6 +3026,34 @@ float get_nearest_other_player(const rf::Player* player, const rf::Vector3* spaw return min_dist_sq; } +// Squared distance from a candidate spawn to the nearest LIVING teammate (used +// by Wipeout mid-round respawns to cluster on teammates). Returns float max if +// no teammate has a live entity. +float get_nearest_teammate(const rf::Player* player, const rf::Vector3* spawn_pos) +{ + float min_dist_sq = std::numeric_limits::max(); + const int player_team = player->team; + + for (const auto* other_player : get_clients(false, true)) { + if (other_player == player) { + continue; + } + if (other_player->team != player_team) { + continue; + } + auto* other_entity = rf::entity_from_handle(other_player->entity_handle); + if (!other_entity) { + continue; + } + const float dist_sq = rf::vec_dist_squared(spawn_pos, &other_entity->pos); + if (dist_sq < min_dist_sq) { + min_dist_sq = dist_sq; + } + } + + return min_dist_sq; +} + FunHook multi_respawn_get_next_point_hook{ 0x00470300, [](rf::Vector3* pos, rf::Matrix3* orient, rf::Player* player) { //if (!rf::is_server) @@ -3044,6 +3082,76 @@ FunHook multi_respawn_get_next_po const bool is_team_game = multi_is_team_game_type(); const auto& config = g_alpine_server_config_active_rules.spawn_logic; + // Wipeout mid-round respawn: ignore spawn-point team flags entirely and + // cluster near a living teammate, while avoiding points right next to an + // enemy. The round's FIRST spawn is not "subsequent" yet, so it falls + // through to the standard (TDM) path below; the hook flips the flag once + // a point is chosen here. + if (rf::is_server && wipeout_is_subsequent_spawn(player)) { + // Enemy-proximity threshold (units^2): points with an enemy closer + // than this are deprioritized. Tuning knob; degrades gracefully. + constexpr float k_enemy_avoid_dist_sq = 20.0f * 20.0f; + + std::vector wo_eligible; + wo_eligible.reserve(g_alpine_respawn_points.size()); + for (auto& point : g_alpine_respawn_points) { + if (!point.enabled) { + continue; // team flags intentionally ignored + } + point.dist_other_player = get_nearest_teammate(player, &point.position); + wo_eligible.push_back(&point); + } + + if (!wo_eligible.empty()) { + // Prefer points that aren't hugging an enemy; if that empties the + // pool, fall back to the full teammate-weighted list. + std::vector safe; + safe.reserve(wo_eligible.size()); + for (auto* p : wo_eligible) { + if (get_nearest_other_player(player, &p->position, true) >= k_enemy_avoid_dist_sq) { + safe.push_back(p); + } + } + std::vector& pool = safe.empty() ? wo_eligible : safe; + + const bool have_teammate = std::any_of(pool.begin(), pool.end(), + [](const rf::AlpineRespawnPoint* p) { + return p->dist_other_player < std::numeric_limits::max(); + }); + + int sel = 0; + if (have_teammate) { + // Closest-to-teammate first, then weighted RNG biased to the front. + std::sort(pool.begin(), pool.end(), + [](const rf::AlpineRespawnPoint* a, const rf::AlpineRespawnPoint* b) { + return a->dist_other_player < b->dist_other_player; + }); + std::uniform_real_distribution real_dist(0.0, 1.0); + sel = static_cast((1 - std::sqrt(real_dist(g_rng))) * pool.size()); + sel = std::clamp(sel, 0, static_cast(pool.size()) - 1); + } + else { + std::uniform_int_distribution dist(0, static_cast(pool.size()) - 1); + sel = dist(g_rng); + } + + const int global_index = std::distance(g_alpine_respawn_points.data(), pool[sel]); + *pos = pool[sel]->position; + *orient = pool[sel]->orientation; + player->last_spawn_point_index = global_index; + player->wipeout_spawned_this_round = true; + return; + } + // No eligible points — fall through to the standard path below. + } + + // Any Wipeout spawn that reaches the standard path (the round's first + // spawn, or a subsequent spawn with no teammate-eligible point) marks the + // player as spawned this round so the NEXT respawn uses teammate logic. + if (gt_is_wipeout()) { + player->wipeout_spawned_this_round = true; + } + //xlog::debug("Spawn point requested! Player: {}, Team: {}, Last Spawn Index: {}", player->name, team, last_index); // Step 1: Build a list of eligible spawn points for this request @@ -3328,7 +3436,17 @@ CodeInjection entity_maybe_die_patch{ rf::Player* player = rf::player_from_entity_handle(ep->handle); if (player && g_alpine_server_config_active_rules.spawn_delay.enabled) { - player->respawn_timer.set(g_alpine_server_config_active_rules.spawn_delay.base_value); + int spawn_delay_ms = g_alpine_server_config_active_rules.spawn_delay.base_value; + + // Wipeout: the respawn delay escalates by the base value on each + // death this round (5s, 10s, 15s...), resetting at round start. The + // growing delay is what eventually lets a whole team be wiped. + if (gt_is_wipeout()) { + ++player->wipeout_round_deaths; + spawn_delay_ms *= player->wipeout_round_deaths; + } + + player->respawn_timer.set(spawn_delay_ms); bool respawn_allowed = true; // nothing currently disables respawns bool force_respawn = (rf::multi_server_flags & rf::NetGameFlags::NG_FLAG_FORCE_RESPAWN) != 0; @@ -3690,6 +3808,7 @@ void server_do_frame() match_do_frame(); process_delayed_kicks(); lms_do_frame(); + wipeout_do_frame(); rounds_do_frame(); } diff --git a/game_patch/multi/wipeout.cpp b/game_patch/multi/wipeout.cpp new file mode 100644 index 00000000..8bef7072 --- /dev/null +++ b/game_patch/multi/wipeout.cpp @@ -0,0 +1,465 @@ +#include +#include +#include +#include +#include +#include "wipeout.h" +#include "rounds.h" +#include "gametype.h" +#include "multi.h" +#include "server.h" +#include "server_internal.h" +#include "alpine_packets.h" +#include "../hud/hud.h" +#include "../sound/sound.h" +#include "../rf/multi.h" +#include "../rf/entity.h" +#include "../rf/object.h" +#include "../rf/item.h" +#include "../rf/player/player.h" +#include "../rf/level.h" +#include "../rf/gameseq.h" + +namespace +{ + +// Best-of-7: match is decided once a team's lead can't be caught, with sudden +// death if the seven rounds finish level (possible because double-wipes are +// no-contest rounds that advance the count without changing either score). +constexpr int WIPEOUT_MATCH_ROUNDS = 7; + +struct WipeoutInfo +{ + int red_team_score = 0; // rounds won by red + int blue_team_score = 0; // rounds won by blue + int rounds_completed = 0; // rounds finished this match (incl. no-contest) + bool match_over = false; + int match_winner_team = -1; // -1 none, 0 red, 1 blue +}; + +WipeoutInfo g_info; + +// True while we call multi_spawn_player_server_side ourselves (round-start batch +// / auto-respawn). Lets the spawn gate permit those regardless of round state. +bool g_internal_spawn_in_progress = false; + +// Winner team latched by should_end_round at the wipe instant, consumed by +// on_round_end a frame later. -1 == double wipe (no contest). +int g_pending_winner_team = -1; + +// Latches once both teams have had a live player this round; keeps a round from +// "ending" during the round-1 spawn race before everyone is in. +bool g_round_had_both_teams = false; + +bool player_has_alive_entity(rf::Player* p) +{ + if (!p) return false; + if (p->is_browser) return false; + rf::Entity* ep = rf::entity_from_handle(p->entity_handle); + if (!ep) return false; + if (rf::entity_is_dying(ep)) return false; + return true; +} + +// A player has finished loading when the engine set NPF_CLIENT_IS_LOADED. The +// listen-server host is always considered loaded. +bool player_is_loaded(rf::Player* p) +{ + if (!p) return false; + if (p == rf::local_player) return true; + if (!p->net_data) return false; + return (p->net_data->flags & rf::NPF_CLIENT_IS_LOADED) != 0; +} + +int count_team_alive(int team) +{ + int n = 0; + for (rf::Player& p : SinglyLinkedList{rf::player_list}) { + if (p.is_browser) continue; + if (p.team != team) continue; + if (player_has_alive_entity(&p)) ++n; + } + return n; +} + +// Connected, non-browser players on a team without a live entity: dead and +// awaiting respawn, or a late joiner not yet in the round. +int count_team_waiting(int team) +{ + int n = 0; + for (rf::Player& p : SinglyLinkedList{rf::player_list}) { + if (p.is_browser) continue; + if (p.team != team) continue; + if (!player_has_alive_entity(&p)) ++n; + } + return n; +} + +bool match_is_decided() +{ + const int remaining = std::max(0, WIPEOUT_MATCH_ROUNDS - g_info.rounds_completed); + const int r = g_info.red_team_score; + const int b = g_info.blue_team_score; + return (r > b + remaining) || (b > r + remaining); +} + +// Hide every level item and destroy any dropped weapons so the arena stays +// item-free. Mirrors lms_reset_world_items but hides instead of restoring. The +// engine's periodic visibility broadcast replicates the hidden state to clients. +void hide_all_items() +{ + if (!rf::is_server) return; + + rf::Item* it = rf::item_list.next; + while (it && it != &rf::item_list) { + rf::Item* next = it->next; + const uint32_t flags = it->item_flags; + const bool is_dropped = (flags & rf::IF_DROPPED) != 0; + const bool is_ctf_flag = (flags & rf::IF_CTF_FLAG) != 0; + + if (is_dropped) { + rf::send_item_apply_packet(nullptr, it->handle, 0, -1, -1, -1); + rf::obj_flag_dead(it); + } + else if (!is_ctf_flag) { + it->respawn_next.invalidate(); + rf::obj_hide(it); + } + + it = next; + } +} + +// === Round callbacks ============================================ + +bool wipeout_can_round_start() +{ + // Need at least one loaded player on EACH team for a real team round. + int red_loaded = 0; + int blue_loaded = 0; + for (rf::Player& p : SinglyLinkedList{rf::player_list}) { + if (p.is_browser) continue; + if (!player_is_loaded(&p)) continue; + if (p.team == rf::TEAM_RED) ++red_loaded; + else ++blue_loaded; + } + return red_loaded >= 1 && blue_loaded >= 1; +} + +void wipeout_on_round_begin() +{ + if (!rf::is_server) return; + + g_round_had_both_teams = false; + g_pending_winner_team = -1; + + g_internal_spawn_in_progress = true; + int spawned = 0; + for (rf::Player& p : SinglyLinkedList{rf::player_list}) { + if (p.is_browser) continue; + + // Fresh round: clear elimination, participation, the per-round death + // counter (resets the escalating delay) and any lingering respawn timer + // from the between-round cleanup kill (otherwise it would block the + // round-start spawn below). + p.round_is_out = false; + p.round_participated = false; + p.wipeout_round_deaths = 0; + p.wipeout_spawned_this_round = false; + p.respawn_timer.invalidate(); + + if (!player_is_loaded(&p)) continue; + + if (!player_has_alive_entity(&p)) { + // First spawn of the round uses standard (TDM) spawn logic because + // wipeout_spawned_this_round is still false at selection time; the + // spawn hook flips it to true once the point is chosen. + rf::multi_spawn_player_server_side(&p); + ++spawned; + } + else { + // Already holds a live entity (e.g. spawned during the pre-round + // Inactive window). That counts as this round's first spawn, so + // their next death-respawn correctly clusters on teammates. + p.wipeout_spawned_this_round = true; + } + } + g_internal_spawn_in_progress = false; + + // Arena stays item-free every round. + hide_all_items(); + + xlog::info("Wipeout: round {} begin, {} spawned (R {} - B {})", + g_info.rounds_completed + 1, spawned, g_info.red_team_score, g_info.blue_team_score); +} + +bool wipeout_should_end_round(rf::Player** out_winner) +{ + if (!rf::is_server) return false; + if (out_winner) *out_winner = nullptr; // team-based, not player-based + + const int red_alive = count_team_alive(rf::TEAM_RED); + const int blue_alive = count_team_alive(rf::TEAM_BLUE); + + // Don't evaluate the wipe condition until both teams have genuinely had a + // live player this round (guards the round-start spawn race). + if (!g_round_had_both_teams) { + if (red_alive > 0 && blue_alive > 0) g_round_had_both_teams = true; + return false; + } + + if (red_alive > 0 && blue_alive > 0) return false; // both still standing + + // A team has been wiped. Winner is whoever is still up; both down in the + // same frame (same event) is a no-contest. + if (red_alive == 0 && blue_alive == 0) g_pending_winner_team = -1; + else g_pending_winner_team = (red_alive > 0) ? rf::TEAM_RED : rf::TEAM_BLUE; + + return true; +} + +void announce_round_sounds(int winner_team) +{ + // Winners hear the stock "winner" cue; everyone else hears "match over". + for (rf::Player& p : SinglyLinkedList{rf::player_list}) { + if (p.is_browser) continue; + const bool on_winning = (p.team == winner_team); + if (on_winning) { + if (&p == rf::local_player) { + play_local_sound_2d(static_cast(stock_sound_id::ann_winner), 0, 1.0f); + } + else { + send_sound_packet_throwaway(&p, stock_sound_id::ann_winner); + } + } + else { + af_send_play_custom_sound(custom_sound_id::ann_match_over, &p); + } + } +} + +void wipeout_on_round_end(rf::Player* /*winner*/, RoundEndReason /*reason*/) +{ + if (!rf::is_server) return; + + ++g_info.rounds_completed; + const int winner_team = g_pending_winner_team; + g_pending_winner_team = -1; + + if (winner_team == rf::TEAM_RED) ++g_info.red_team_score; + else if (winner_team == rf::TEAM_BLUE) ++g_info.blue_team_score; + + if (winner_team == rf::TEAM_RED || winner_team == rf::TEAM_BLUE) { + const char* tname = (winner_team == rf::TEAM_RED) ? "Red" : "Blue"; + af_broadcast_hud_notification( + std::format("{} team wins round {}! (Red {} - {} Blue)", + tname, g_info.rounds_completed, g_info.red_team_score, g_info.blue_team_score), + 3, static_cast(HudNotificationType::Round), true); + announce_round_sounds(winner_team); + } + else { + af_broadcast_hud_notification( + std::format("Round {} was a double wipe - no point awarded. (Red {} - {} Blue)", + g_info.rounds_completed, g_info.red_team_score, g_info.blue_team_score), + 3, static_cast(HudNotificationType::Round), true); + } + + // Announce the match result up front if this round decided it; the rounds + // system rotates the level once the celebration window closes (governed by + // our is_match_over arbiter below). + if (match_is_decided()) { + g_info.match_over = true; + g_info.match_winner_team = + (g_info.red_team_score > g_info.blue_team_score) ? rf::TEAM_RED : rf::TEAM_BLUE; + const char* tname = (g_info.match_winner_team == rf::TEAM_RED) ? "Red" : "Blue"; + af_broadcast_hud_notification( + std::format("{} team wins the match {} - {}!", + tname, g_info.red_team_score, g_info.blue_team_score), + 4, static_cast(HudNotificationType::Round), true); + } +} + +void wipeout_on_round_cleanup() +{ + if (!rf::is_server) return; + if (!gt_is_wipeout()) return; + + // Kill any survivors through the full death pipeline so the next round + // starts everyone fresh. Clear killer info first so no stale obituary fires. + for (rf::Player& p : SinglyLinkedList{rf::player_list}) { + if (p.is_browser) continue; + p.round_is_out = true; + rf::Entity* ep = rf::entity_from_handle(p.entity_handle); + if (ep && !rf::entity_is_dying(ep)) { + ep->killer_handle = 0; + ep->killer_netid = -1; + rf::entity_maybe_die(ep); + } + } + + hide_all_items(); +} + +bool wipeout_is_match_over() +{ + return match_is_decided(); +} + +// Only reached if a server admin overrides the untimed default (round_time > 0) +// and a round hits the clock. Resolve by who has more players standing; a tie in +// alive count is a no-contest. Sets the team latch the way should_end_round would +// and returns null (Wipeout resolves by team, not by a single player). +rf::Player* wipeout_resolve_timeout_winner() +{ + const int red_alive = count_team_alive(rf::TEAM_RED); + const int blue_alive = count_team_alive(rf::TEAM_BLUE); + if (red_alive > blue_alive) g_pending_winner_team = rf::TEAM_RED; + else if (blue_alive > red_alive) g_pending_winner_team = rf::TEAM_BLUE; + else g_pending_winner_team = -1; + return nullptr; +} + +void wipeout_on_late_join(rf::Player* player) +{ + // Sit the late joiner out until the next round begins; wipeout_can_player_spawn + // denies their spawn attempts in the meantime, and on_round_begin re-includes + // them alongside everyone else. + if (player) player->round_is_out = true; +} + +} // namespace + +void wipeout_level_init() +{ + // Real level boundary == a new match. + g_info = WipeoutInfo{}; +} + +void wipeout_level_init_post() +{ + if (!rf::is_server) return; + if (!gt_is_wipeout()) return; + + RoundCallbacks cb{}; + cb.on_round_begin = &wipeout_on_round_begin; + cb.on_round_end = &wipeout_on_round_end; + cb.on_round_cleanup = &wipeout_on_round_cleanup; + cb.should_end_round = &wipeout_should_end_round; + cb.can_round_start = &wipeout_can_round_start; + cb.on_late_join = &wipeout_on_late_join; + cb.is_match_over = &wipeout_is_match_over; + cb.resolve_timeout_winner = &wipeout_resolve_timeout_winner; + rounds_register_callbacks(cb); + + for (rf::Player& p : SinglyLinkedList{rf::player_list}) { + p.round_is_out = false; + p.round_participated = false; + p.wipeout_round_deaths = 0; + p.wipeout_spawned_this_round = false; + } + + hide_all_items(); +} + +void wipeout_do_frame() +{ + if (!rf::is_server) return; + if (!gt_is_wipeout()) return; + // Only act during live gameplay; rounds_do_frame stops pumping the state + // machine outside GS_GAMEPLAY, so rounds_is_active() can linger through a + // mid-round level-change limbo window without this guard. + if (rf::gameseq_get_state() != rf::GameState::GS_GAMEPLAY) return; + if (!rounds_is_active()) return; + + // Repeated mid-round respawns: bring back any player whose escalating delay + // has elapsed. Players stay in the "waiting" pool only for the length of + // their respawn_timer, which is what makes a full-team wipe achievable. + g_internal_spawn_in_progress = true; + for (rf::Player& p : SinglyLinkedList{rf::player_list}) { + if (p.is_browser) continue; + + if (player_has_alive_entity(&p)) { + p.round_participated = true; + continue; + } + + if (p.round_is_out) continue; // late joiner / between rounds + if (!player_is_loaded(&p)) continue; + if (p.respawn_timer.valid() && !p.respawn_timer.elapsed()) continue; // still waiting out the delay + + rf::multi_spawn_player_server_side(&p); + } + g_internal_spawn_in_progress = false; +} + +bool wipeout_can_player_spawn(rf::Player* player) +{ + if (!gt_is_wipeout()) return true; // not our problem + if (!player) return true; + if (g_internal_spawn_in_progress) return true; + + // Level still initializing — allow the engine's normal spawn flow so players + // have entities by the time round 1 begins. + if (rounds_get_state() == RoundState::Inactive) return true; + + const bool between_rounds = rounds_is_between_rounds(); + if (between_rounds || player->round_is_out) { + // Throttle the notice: the client re-requests a spawn every frame while + // the fire button is held, which would otherwise spam chat. + if (!player->wipeout_waiting_msg_timer.valid() || player->wipeout_waiting_msg_timer.elapsed()) { + af_send_automated_chat_msg( + between_rounds ? "Wait - the next round is starting shortly." + : "You're waiting for the next round to begin.", + player); + player->wipeout_waiting_msg_timer.set(3000); + } + return false; + } + + // Otherwise allowed; the escalating per-death delay is enforced by the + // server-side respawn_timer check in multi_spawn_player_server_side_hook. + return true; +} + +void wipeout_on_player_disconnect(rf::Player* /*player*/) +{ + // Nothing to clean up — alive/waiting counts derive from the live list, and + // a round-end transition (if a team just emptied) is caught next tick. +} + +bool wipeout_is_subsequent_spawn(rf::Player* player) +{ + return gt_is_wipeout() && player && player->wipeout_spawned_this_round; +} + +int wipeout_get_red_team_score() +{ + return g_info.red_team_score; +} + +int wipeout_get_blue_team_score() +{ + return g_info.blue_team_score; +} + +void wipeout_set_red_team_score(int v) +{ + if (rf::is_server) return; // server is authoritative; clients receive via packet + g_info.red_team_score = v; +} + +void wipeout_set_blue_team_score(int v) +{ + if (rf::is_server) return; + g_info.blue_team_score = v; +} + +int wipeout_count_team_alive(int team) +{ + return count_team_alive(team); +} + +int wipeout_count_team_waiting(int team) +{ + return count_team_waiting(team); +} diff --git a/game_patch/multi/wipeout.h b/game_patch/multi/wipeout.h new file mode 100644 index 00000000..fbbcbb64 --- /dev/null +++ b/game_patch/multi/wipeout.h @@ -0,0 +1,51 @@ +#pragma once + +namespace rf +{ + struct Player; + struct Entity; +} + +// Wipeout (NG_TYPE_WO): a round-based TEAM mode where players respawn repeatedly +// within a round on an escalating delay (5s * deaths, reset each round). A team +// loses the round the instant its entire roster is simultaneously down (a +// "wipe"); the survivors are awarded the round. Simultaneous double-wipes are a +// no-contest. Match is best-of-7 with early clinch + sudden death, implemented +// via the rounds.cpp is_match_over arbiter. + +// Called on real level load (pre-init) on server and client. Resets scores and +// match state (a new map == a new match). +void wipeout_level_init(); + +// Called after the level finishes initializing. On the server, registers +// Wipeout's round callbacks and clears per-player round state. +void wipeout_level_init_post(); + +// Per-frame pump on the server: auto-respawns players whose escalating delay has +// elapsed (the repeated mid-round respawn) and tracks per-round participation. +void wipeout_do_frame(); + +// Spawn gate: false while a player must wait (late joiner / between rounds). +// The escalating per-death delay itself is enforced by the server-side +// respawn_timer check, not here. +bool wipeout_can_player_spawn(rf::Player* player); + +// Disconnect hook — no per-player teardown needed (counts derive from the list). +void wipeout_on_player_disconnect(rf::Player* player); + +// True when the given player's next spawn is a mid-round respawn (cluster near +// teammates) rather than the round's first spawn (standard TDM logic). Used by +// the spawn-point selection hook in server.cpp. +bool wipeout_is_subsequent_spawn(rf::Player* player); + +// Team round-win scores (shown on the HUD, synced via the stock team_scores +// packet). Setters are client-only; the server is authoritative. +int wipeout_get_red_team_score(); +int wipeout_get_blue_team_score(); +void wipeout_set_red_team_score(int v); +void wipeout_set_blue_team_score(int v); + +// HUD helpers (client-safe): how many players on a team are currently alive vs. +// waiting (dead/awaiting-respawn or a late joiner not yet in the round). +int wipeout_count_team_alive(int team); +int wipeout_count_team_waiting(int team); diff --git a/game_patch/rf/multi.h b/game_patch/rf/multi.h index 2414fb95..f9c30552 100644 --- a/game_patch/rf/multi.h +++ b/game_patch/rf/multi.h @@ -144,6 +144,7 @@ namespace rf NG_TYPE_BAG = 8, // Bagman, as of AF v1.4 NG_TYPE_TBAG = 9, // Team Bagman, as of AF v1.4 NG_TYPE_LMS = 10, // Last Miner Standing, as of AF v1.4 + NG_TYPE_WO = 11, // Wipeout, as of AF v1.4 // Sentinel: UNK must always be the last entry. NG_TYPE_UNK }; diff --git a/game_patch/rf/player/player.h b/game_patch/rf/player/player.h index 6d8f0f0f..674b71da 100644 --- a/game_patch/rf/player/player.h +++ b/game_patch/rf/player/player.h @@ -96,6 +96,15 @@ struct PlayerAdditionalData { bool round_is_out = false; bool round_participated = false; // the player has participated this round float lms_round_damage_dealt = 0.0f; // damage dealt during current round (tiebreak) + + // Wipeout (NG_TYPE_WO): per-round death counter drives the escalating respawn + // delay (5s * deaths); spawned_this_round selects first-spawn (TDM) vs + // subsequent-spawn (cluster near teammates) logic. Both reset each round. + int wipeout_round_deaths = 0; + bool wipeout_spawned_this_round = false; + // Throttles the "you're waiting" spawn-denied chat line so repeated spawn + // requests (e.g. holding fire while waiting) don't spam it. + rf::Timestamp wipeout_waiting_msg_timer{}; }; static_assert(alignof(PlayerAdditionalData) == 0x8); #endif From 2632d5ab98793c8374976964adabe08e24350973 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Tue, 14 Jul 2026 14:01:45 -0230 Subject: [PATCH 2/5] fixes --- game_patch/misc/player.cpp | 23 ++++++++++++++ game_patch/multi/alpine_packets.cpp | 47 +++++++++++++++++++++++++++-- game_patch/multi/alpine_packets.h | 11 +++++-- game_patch/multi/server.cpp | 41 +++++++++++++++++-------- game_patch/rf/player/player.h | 3 ++ 5 files changed, 109 insertions(+), 16 deletions(-) diff --git a/game_patch/misc/player.cpp b/game_patch/misc/player.cpp index e733943c..2df821cf 100644 --- a/game_patch/misc/player.cpp +++ b/game_patch/misc/player.cpp @@ -13,6 +13,7 @@ #include "../rf/gr/gr_light.h" #include "../rf/os/os.h" #include "../rf/os/frametime.h" +#include "../rf/gameseq.h" #include "../os/console.h" #include "../main/main.h" #include "../misc/alpine_options.h" @@ -523,10 +524,32 @@ CallHook player_is_dying_red_bars_hook{0x00432A5F, player_is_dying_and_not_spect CallHook player_is_dying_scoreboard_hook{0x00437C01, player_is_dying_and_not_spectating}; CallHook player_is_dying_scoreboard2_hook{0x00437C36, player_is_dying_and_not_spectating}; +// Report our locally-selected mp character to the server whenever it changes, +// so server-driven spawns (round modes) and the first post-join spawn use it +// instead of the default. +void multi_character_sync_client_do_frame() { + static int last_reported = -1; + + if (rf::is_server || !rf::is_multi || !rf::local_player) { + last_reported = -1; + return; + } + if (rf::gameseq_get_state() != rf::GameState::GS_GAMEPLAY) { + return; + } + + const int cur = rf::local_player->settings.multi_character; + if (cur != last_reported) { + af_send_character_request(cur); + last_reported = cur; + } +} + FunHook players_do_frame_hook{ 0x004A26D0, []() { players_do_frame_hook.call_target(); + multi_character_sync_client_do_frame(); if (multi_spectate_is_spectating()) { rf::Player* target = multi_spectate_get_target_player(); rf::hud_do_frame(target); diff --git a/game_patch/multi/alpine_packets.cpp b/game_patch/multi/alpine_packets.cpp index 2e8ae264..76431b64 100644 --- a/game_patch/multi/alpine_packets.cpp +++ b/game_patch/multi/alpine_packets.cpp @@ -590,6 +590,12 @@ void serialize_payload(const SprayReqPayload& payload, std::byte* buf, size_t& o offset += sizeof(payload.normal); } +// af_req_character +void serialize_payload(const CharacterPayload& payload, std::byte* buf, size_t& offset) +{ + buf[offset++] = static_cast(payload.character_index); +} + // af_req_server_cfg void serialize_payload(const std::monostate& payload, const std::byte* const buf, const size_t& offset) { @@ -669,7 +675,7 @@ void af_send_spray_request(uint16_t texture_id, const rf::Vector3& pos, const rf } // send client request packet -void af_send_client_req_packet(const af_client_req_packet& packet) +void af_send_client_req_packet(const af_client_req_packet& packet, bool is_reliable) { // Send: client -> server if (!rf::is_multi || rf::is_server) { @@ -690,7 +696,26 @@ void af_send_client_req_packet(const af_client_req_packet& packet) std::visit([&](const auto& payload) { serialize_payload(payload, buf, offset); }, packet.payload); int total_len = static_cast(offset); - af_send_packet(rf::local_player, buf, total_len, false); + af_send_packet(rf::local_player, buf, total_len, is_reliable); +} + +// Reliably report the locally-selected mp character to the server. +void af_send_character_request(int character_index) +{ + if (!rf::is_multi || rf::is_server) { + return; + } + if (character_index < 0 || character_index >= rf::num_multi_characters) { + return; // never report a selection outside the valid range + } + + af_client_req_packet packet{}; + packet.header.type = static_cast(af_packet_type::af_client_req); + packet.header.size = sizeof(uint8_t) + sizeof(uint8_t); // req_type + character index + packet.req_type = af_client_req_type::af_req_character; + packet.payload = CharacterPayload{static_cast(character_index)}; + + af_send_client_req_packet(packet, true); // reliable } // process client request packet @@ -761,6 +786,24 @@ static void af_process_client_req_packet(const void* data, size_t len, const rf: } break; } + case af_client_req_type::af_req_character: { + if (remaining < sizeof(uint8_t)) { + xlog::warn("af_process_client_req_packet: Character payload too short"); + return; + } + const uint8_t idx = bytes[offset]; + if (idx < rf::num_multi_characters) { + // Store the client's real selection separately from + // settings.multi_character, which force_character overwrites at + // spawn time. The spawn hook picks force vs. this reported value. + player->reported_multi_character = static_cast(idx); + } + else { + xlog::warn("af_process_client_req_packet: character index {} out of range (max {})", + idx, rf::num_multi_characters - 1); + } + break; + } case af_client_req_type::af_req_spray: { constexpr size_t expected = sizeof(uint16_t) + sizeof(RF_Vector) + sizeof(RF_Vector); if (remaining < expected) { diff --git a/game_patch/multi/alpine_packets.h b/game_patch/multi/alpine_packets.h index 84116f6a..52d34629 100644 --- a/game_patch/multi/alpine_packets.h +++ b/game_patch/multi/alpine_packets.h @@ -81,6 +81,7 @@ enum class af_client_req_type : uint8_t af_req_handicap = 0x0, af_req_server_cfg = 0x1, af_req_spray = 0x2, + af_req_character = 0x3, }; struct HandicapPayload @@ -96,7 +97,12 @@ struct SprayReqPayload }; static_assert(sizeof(SprayReqPayload) == 26); -using af_client_payload = std::variant; +struct CharacterPayload +{ + uint8_t character_index = 0; +}; + +using af_client_payload = std::variant; struct af_client_req_packet { @@ -451,8 +457,9 @@ void af_send_damage_notify_packet(uint8_t player_id, float damage, bool died, rf static void af_process_damage_notify_packet(const void* data, size_t len, const rf::NetAddr& addr); void af_send_obj_update_packet(rf::Player* player); static void af_process_obj_update_packet(const void* data, size_t len, const rf::NetAddr& addr); -void af_send_client_req_packet(const af_client_req_packet& packet); +void af_send_client_req_packet(const af_client_req_packet& packet, bool is_reliable = false); static void af_process_client_req_packet(const void* data, size_t len, const rf::NetAddr& addr); +void af_send_character_request(int character_index); void af_send_server_req_packet(const af_server_req_packet& packet, rf::Player* player); void af_send_should_gib_req(uint32_t obj_handle); void af_send_teleport_entity_req(uint32_t obj_handle, const rf::Vector3& pos, const rf::Matrix3& orient, const rf::Vector3& vel); diff --git a/game_patch/multi/server.cpp b/game_patch/multi/server.cpp index 529da482..ef5170d1 100644 --- a/game_patch/multi/server.cpp +++ b/game_patch/multi/server.cpp @@ -2122,6 +2122,10 @@ FunHook multi_spawn_player_server_side_hook{ player->settings.multi_character = g_alpine_server_config_active_rules.force_character.character_index; } + else if (player->reported_multi_character >= 0) { + // No forced character: honour the character the client reported. + player->settings.multi_character = player->reported_multi_character; + } if (player->is_browser) { return; } @@ -2998,16 +3002,14 @@ FunHook multi_respawn_level_init_hook { } }; -// more flexible replacement for get_nearest_other_player_dist_sq in stock game -float get_nearest_other_player(const rf::Player* player, const rf::Vector3* spawn_pos, bool only_enemies = false) +float get_nearest_other_player(const std::vector& clients, + const rf::Player* player, const rf::Vector3* spawn_pos, bool only_enemies) { float min_dist_sq = std::numeric_limits::max(); const bool is_team_game = multi_is_team_game_type(); const int player_team = player->team; - auto player_list = get_clients(false, true); - - for (const auto* other_player : player_list) { + for (const auto* other_player : clients) { if (other_player == player) { continue; } @@ -3032,15 +3034,21 @@ float get_nearest_other_player(const rf::Player* player, const rf::Vector3* spaw return min_dist_sq; } +float get_nearest_other_player(const rf::Player* player, const rf::Vector3* spawn_pos, bool only_enemies = false) +{ + return get_nearest_other_player(get_clients(false, true), player, spawn_pos, only_enemies); +} + // Squared distance from a candidate spawn to the nearest LIVING teammate (used // by Wipeout mid-round respawns to cluster on teammates). Returns float max if -// no teammate has a live entity. -float get_nearest_teammate(const rf::Player* player, const rf::Vector3* spawn_pos) +// no teammate has a live entity. Core overload takes a pre-fetched client list. +float get_nearest_teammate(const std::vector& clients, + const rf::Player* player, const rf::Vector3* spawn_pos) { float min_dist_sq = std::numeric_limits::max(); const int player_team = player->team; - for (const auto* other_player : get_clients(false, true)) { + for (const auto* other_player : clients) { if (other_player == player) { continue; } @@ -3060,6 +3068,11 @@ float get_nearest_teammate(const rf::Player* player, const rf::Vector3* spawn_po return min_dist_sq; } +float get_nearest_teammate(const rf::Player* player, const rf::Vector3* spawn_pos) +{ + return get_nearest_teammate(get_clients(false, true), player, spawn_pos); +} + FunHook multi_respawn_get_next_point_hook{ 0x00470300, [](rf::Vector3* pos, rf::Matrix3* orient, rf::Player* player) { //if (!rf::is_server) @@ -3098,13 +3111,16 @@ FunHook multi_respawn_get_next_po // than this are deprioritized. Tuning knob; degrades gracefully. constexpr float k_enemy_avoid_dist_sq = 20.0f * 20.0f; + // Build the client list once and reuse it for every respawn point + const auto wo_clients = get_clients(false, true); + std::vector wo_eligible; wo_eligible.reserve(g_alpine_respawn_points.size()); for (auto& point : g_alpine_respawn_points) { if (!point.enabled) { continue; // team flags intentionally ignored } - point.dist_other_player = get_nearest_teammate(player, &point.position); + point.dist_other_player = get_nearest_teammate(wo_clients, player, &point.position); wo_eligible.push_back(&point); } @@ -3114,7 +3130,7 @@ FunHook multi_respawn_get_next_po std::vector safe; safe.reserve(wo_eligible.size()); for (auto* p : wo_eligible) { - if (get_nearest_other_player(player, &p->position, true) >= k_enemy_avoid_dist_sq) { + if (get_nearest_other_player(wo_clients, player, &p->position, true) >= k_enemy_avoid_dist_sq) { safe.push_back(p); } } @@ -3441,13 +3457,14 @@ CodeInjection entity_maybe_die_patch{ rf::Player* player = rf::player_from_entity_handle(ep->handle); - if (player && g_alpine_server_config_active_rules.spawn_delay.enabled) { + const bool is_wipeout = gt_is_wipeout(); + if (player && (g_alpine_server_config_active_rules.spawn_delay.enabled || is_wipeout)) { int spawn_delay_ms = g_alpine_server_config_active_rules.spawn_delay.base_value; // Wipeout: the respawn delay escalates by the base value on each // death this round (5s, 10s, 15s...), resetting at round start. The // growing delay is what eventually lets a whole team be wiped. - if (gt_is_wipeout()) { + if (is_wipeout) { ++player->wipeout_round_deaths; spawn_delay_ms *= player->wipeout_round_deaths; } diff --git a/game_patch/rf/player/player.h b/game_patch/rf/player/player.h index 55e92d59..c2bb497e 100644 --- a/game_patch/rf/player/player.h +++ b/game_patch/rf/player/player.h @@ -79,6 +79,9 @@ struct PlayerAdditionalData { std::optional last_spawn_point_index{}; + // Client's locally-selected multiplayer character + int reported_multi_character = -1; + // Requires `spawn_delay` to be enabled. rf::Timestamp respawn_timer{}; From 660d1cd80f601a32ec113e14315d241100d7f8d6 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Tue, 14 Jul 2026 14:05:52 -0230 Subject: [PATCH 3/5] fix --- docs/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 38549112..792d78bd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,7 @@ Version 1.4.0 (Lupin): Not yet released - Bagman (`BAG`) - Team Bagman (`TBAG`) - Last Miner Standing (`LMS`) + - Wipeout (`WO`) - Add sprays with bindable `Spray` control - `cl_sprays` console command to toggle local display and dedicated server `[sprays]` config section - `spray` console command to select spray, and in-game spray picker in advanced options From 1777581d8db231a0d48c3c241d6862a79d77a67b Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Tue, 14 Jul 2026 15:25:03 -0230 Subject: [PATCH 4/5] fix --- game_patch/multi/rounds.cpp | 9 +++++---- game_patch/multi/wipeout.cpp | 10 ++++++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/game_patch/multi/rounds.cpp b/game_patch/multi/rounds.cpp index 814005c8..9693c70a 100644 --- a/game_patch/multi/rounds.cpp +++ b/game_patch/multi/rounds.cpp @@ -149,11 +149,12 @@ void start_round() // HUD countdown rendered as (max_time - level.time). Setting it to the // round's deadline makes the existing HUD show the correct remaining time // every round, with no new packet. When round_time is 0 the round is - // untimed (e.g. Wipeout, which ends only on a team wipe): park the limit - // far in the future so no countdown ever appears and tick_active never - // time-ends the round. + // untimed (e.g. Wipeout, which ends only on a team wipe): use the no-limit + // sentinel (<= 0) so no countdown appears on the HUD and the server browser + // advertises "no time limit" rather than a huge finite value. tick_active + // already skips time-up for untimed rounds. if (cfg().round_time == 0) { - rf::multi_time_limit = rf::level.time + 1.0e9f; + rf::multi_time_limit = 0.0f; } else { rf::multi_time_limit = g_rounds_runtime.round_deadline_level_time; diff --git a/game_patch/multi/wipeout.cpp b/game_patch/multi/wipeout.cpp index 8bef7072..00afd0c3 100644 --- a/game_patch/multi/wipeout.cpp +++ b/game_patch/multi/wipeout.cpp @@ -55,6 +55,7 @@ bool player_has_alive_entity(rf::Player* p) { if (!p) return false; if (p->is_browser) return false; + if (p->is_spectator) return false; // spectators never count toward wipe detection rf::Entity* ep = rf::entity_from_handle(p->entity_handle); if (!ep) return false; if (rf::entity_is_dying(ep)) return false; @@ -82,13 +83,15 @@ int count_team_alive(int team) return n; } -// Connected, non-browser players on a team without a live entity: dead and -// awaiting respawn, or a late joiner not yet in the round. +// Connected, non-browser, non-spectator players on a team without a live entity: +// dead and awaiting respawn, or a late joiner not yet in the round. Spectators +// are excluded so they don't inflate the alive/waiting HUD labels. int count_team_waiting(int team) { int n = 0; for (rf::Player& p : SinglyLinkedList{rf::player_list}) { if (p.is_browser) continue; + if (p.is_spectator) continue; if (p.team != team) continue; if (!player_has_alive_entity(&p)) ++n; } @@ -135,10 +138,13 @@ void hide_all_items() bool wipeout_can_round_start() { // Need at least one loaded player on EACH team for a real team round. + // Spectators carry a team value but never spawn, so a round gated only on + // their presence would start unwinnable and hang; exclude them here. int red_loaded = 0; int blue_loaded = 0; for (rf::Player& p : SinglyLinkedList{rf::player_list}) { if (p.is_browser) continue; + if (p.is_spectator) continue; if (!player_is_loaded(&p)) continue; if (p.team == rf::TEAM_RED) ++red_loaded; else ++blue_loaded; From be793c2ea877d71729afc331a457766a35f9be8c Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Wed, 15 Jul 2026 00:18:14 -0230 Subject: [PATCH 5/5] fix --- game_patch/multi/dedi_cfg.cpp | 2 +- game_patch/multi/rounds.cpp | 5 ++++- game_patch/multi/server.cpp | 1 + game_patch/multi/wipeout.cpp | 34 +++++++++++++--------------------- game_patch/multi/wipeout.h | 32 -------------------------------- 5 files changed, 19 insertions(+), 55 deletions(-) diff --git a/game_patch/multi/dedi_cfg.cpp b/game_patch/multi/dedi_cfg.cpp index 9ca503d1..d50c883e 100644 --- a/game_patch/multi/dedi_cfg.cpp +++ b/game_patch/multi/dedi_cfg.cpp @@ -521,7 +521,7 @@ void apply_defaults_for_game_type(rf::NetGameType game_type, AlpineServerConfigR // Best-of-7, untimed rounds (a round ends only on a team wipe). rules.rounds.set_max_rounds(7); - rules.rounds.round_time = 0; // 0 == untimed sentinel (see rounds.cpp) + rules.rounds.round_time = 0; // unlimited rules.rounds.set_post_round_time(3); rules.rounds.set_intermission_time(5); break; diff --git a/game_patch/multi/rounds.cpp b/game_patch/multi/rounds.cpp index 9693c70a..c6de9503 100644 --- a/game_patch/multi/rounds.cpp +++ b/game_patch/multi/rounds.cpp @@ -164,8 +164,11 @@ void start_round() g_rounds_callbacks.on_round_begin(); } + const std::string round_msg = g_rounds_callbacks.is_match_over + ? std::format("Round {} - fight!", g_rounds_runtime.current + 1) + : std::format("Round {} of {} - fight!", g_rounds_runtime.current + 1, cfg().max_rounds); af_broadcast_hud_notification( - std::format("Round {} of {} - fight!", g_rounds_runtime.current + 1, cfg().max_rounds), + round_msg, 3, static_cast(HudNotificationType::Round), true); diff --git a/game_patch/multi/server.cpp b/game_patch/multi/server.cpp index ef5170d1..5158ddd8 100644 --- a/game_patch/multi/server.cpp +++ b/game_patch/multi/server.cpp @@ -3467,6 +3467,7 @@ CodeInjection entity_maybe_die_patch{ if (is_wipeout) { ++player->wipeout_round_deaths; spawn_delay_ms *= player->wipeout_round_deaths; + spawn_delay_ms = std::min(spawn_delay_ms, 60000); // max 1 min } player->respawn_timer.set(spawn_delay_ms); diff --git a/game_patch/multi/wipeout.cpp b/game_patch/multi/wipeout.cpp index 00afd0c3..a668fe8b 100644 --- a/game_patch/multi/wipeout.cpp +++ b/game_patch/multi/wipeout.cpp @@ -23,11 +23,6 @@ namespace { -// Best-of-7: match is decided once a team's lead can't be caught, with sudden -// death if the seven rounds finish level (possible because double-wipes are -// no-contest rounds that advance the count without changing either score). -constexpr int WIPEOUT_MATCH_ROUNDS = 7; - struct WipeoutInfo { int red_team_score = 0; // rounds won by red @@ -98,12 +93,15 @@ int count_team_waiting(int team) return n; } +int match_win_threshold() +{ + return rounds_get_max() / 2 + 1; +} + bool match_is_decided() { - const int remaining = std::max(0, WIPEOUT_MATCH_ROUNDS - g_info.rounds_completed); - const int r = g_info.red_team_score; - const int b = g_info.blue_team_score; - return (r > b + remaining) || (b > r + remaining); + const int threshold = match_win_threshold(); + return g_info.red_team_score >= threshold || g_info.blue_team_score >= threshold; } // Hide every level item and destroy any dropped weapons so the arena stays @@ -133,8 +131,6 @@ void hide_all_items() } } -// === Round callbacks ============================================ - bool wipeout_can_round_start() { // Need at least one loaded player on EACH team for a real team round. @@ -174,6 +170,9 @@ void wipeout_on_round_begin() p.wipeout_spawned_this_round = false; p.respawn_timer.invalidate(); + // Spectators never spawn; skip before the spawn attempt so we don't pulse + // their active status (which would exempt them from inactivity kicking). + if (p.is_spectator) continue; if (!player_is_loaded(&p)) continue; if (!player_has_alive_entity(&p)) { @@ -383,13 +382,14 @@ void wipeout_do_frame() g_internal_spawn_in_progress = true; for (rf::Player& p : SinglyLinkedList{rf::player_list}) { if (p.is_browser) continue; + if (p.is_spectator) continue; if (player_has_alive_entity(&p)) { p.round_participated = true; continue; } - if (p.round_is_out) continue; // late joiner / between rounds + if (p.round_is_out) continue; // late joiner if (!player_is_loaded(&p)) continue; if (p.respawn_timer.valid() && !p.respawn_timer.elapsed()) continue; // still waiting out the delay @@ -422,17 +422,9 @@ bool wipeout_can_player_spawn(rf::Player* player) return false; } - // Otherwise allowed; the escalating per-death delay is enforced by the - // server-side respawn_timer check in multi_spawn_player_server_side_hook. return true; } -void wipeout_on_player_disconnect(rf::Player* /*player*/) -{ - // Nothing to clean up — alive/waiting counts derive from the live list, and - // a round-end transition (if a team just emptied) is caught next tick. -} - bool wipeout_is_subsequent_spawn(rf::Player* player) { return gt_is_wipeout() && player && player->wipeout_spawned_this_round; @@ -450,7 +442,7 @@ int wipeout_get_blue_team_score() void wipeout_set_red_team_score(int v) { - if (rf::is_server) return; // server is authoritative; clients receive via packet + if (rf::is_server) return; g_info.red_team_score = v; } diff --git a/game_patch/multi/wipeout.h b/game_patch/multi/wipeout.h index fbbcbb64..c123415a 100644 --- a/game_patch/multi/wipeout.h +++ b/game_patch/multi/wipeout.h @@ -6,46 +6,14 @@ namespace rf struct Entity; } -// Wipeout (NG_TYPE_WO): a round-based TEAM mode where players respawn repeatedly -// within a round on an escalating delay (5s * deaths, reset each round). A team -// loses the round the instant its entire roster is simultaneously down (a -// "wipe"); the survivors are awarded the round. Simultaneous double-wipes are a -// no-contest. Match is best-of-7 with early clinch + sudden death, implemented -// via the rounds.cpp is_match_over arbiter. - -// Called on real level load (pre-init) on server and client. Resets scores and -// match state (a new map == a new match). void wipeout_level_init(); - -// Called after the level finishes initializing. On the server, registers -// Wipeout's round callbacks and clears per-player round state. void wipeout_level_init_post(); - -// Per-frame pump on the server: auto-respawns players whose escalating delay has -// elapsed (the repeated mid-round respawn) and tracks per-round participation. void wipeout_do_frame(); - -// Spawn gate: false while a player must wait (late joiner / between rounds). -// The escalating per-death delay itself is enforced by the server-side -// respawn_timer check, not here. bool wipeout_can_player_spawn(rf::Player* player); - -// Disconnect hook — no per-player teardown needed (counts derive from the list). -void wipeout_on_player_disconnect(rf::Player* player); - -// True when the given player's next spawn is a mid-round respawn (cluster near -// teammates) rather than the round's first spawn (standard TDM logic). Used by -// the spawn-point selection hook in server.cpp. bool wipeout_is_subsequent_spawn(rf::Player* player); - -// Team round-win scores (shown on the HUD, synced via the stock team_scores -// packet). Setters are client-only; the server is authoritative. int wipeout_get_red_team_score(); int wipeout_get_blue_team_score(); void wipeout_set_red_team_score(int v); void wipeout_set_blue_team_score(int v); - -// HUD helpers (client-safe): how many players on a team are currently alive vs. -// waiting (dead/awaiting-respawn or a late joiner not yet in the round). int wipeout_count_team_alive(int team); int wipeout_count_team_waiting(int team);