From 0652b4e9bed97a2d3c4805eb46641ceec493a0ea Mon Sep 17 00:00:00 2001 From: Dan Peavey Date: Tue, 18 Aug 2026 19:33:37 -0700 Subject: [PATCH 1/3] Add sv_neo_wep_xp_override cheat cvar for testing XP-gated loadouts Adds a replicated cheat cvar that overrides the effective XP used in weapon eligibility checks, so any rank tier can be tested without grinding XP. Applied at the loadout menu count, both server-side loadout validations, and bot weapon selection. Bot profile weapon-bit validation is left untouched since it validates per-rank config rather than a specific player's XP. Note that bots choose their loadout at spawn, so a mid-round change applies from the next spawn onward. --- src/game/client/neo/game_controls/neo_loadoutmenu.cpp | 4 ++-- src/game/server/neo/bot/neo_bot.cpp | 5 +++-- src/game/server/neo/neo_player.cpp | 4 ++-- src/game/shared/neo/neo_weapon_loadout.cpp | 10 ++++++++++ src/game/shared/neo/neo_weapon_loadout.h | 4 ++++ 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/game/client/neo/game_controls/neo_loadoutmenu.cpp b/src/game/client/neo/game_controls/neo_loadoutmenu.cpp index 47edb4149d..8a7ba2d7f4 100644 --- a/src/game/client/neo/game_controls/neo_loadoutmenu.cpp +++ b/src/game/client/neo/game_controls/neo_loadoutmenu.cpp @@ -227,7 +227,7 @@ void CNeoLoadoutMenu::OnCommand(const char* command) auto localPlayer = C_NEO_Player::GetLocalNEOPlayer(); if (!localPlayer) { return; } - int currentXP = localPlayer->m_iXP.Get(); + int currentXP = CNEOWeaponLoadout::GetEffectiveXP(localPlayer->m_iXP.Get()); int currentClass = localPlayer->m_iNextSpawnClassChoice.Get() != NEO_CLASS_RANDOM ? localPlayer->m_iNextSpawnClassChoice.Get() : localPlayer->m_iNeoClass.Get(); int numWeapons = CNEOWeaponLoadout::GetNumberOfLoadoutWeapons(currentXP, sv_neo_dev_loadout.GetBool() ? NEO_LOADOUT_DEV : currentClass); @@ -295,7 +295,7 @@ void CNeoLoadoutMenu::ApplySchemeSettings(vgui::IScheme *pScheme) auto localPlayer = C_NEO_Player::GetLocalNEOPlayer(); if (!localPlayer) { return; } - const int currentXP = localPlayer->m_iXP.Get(); + const int currentXP = CNEOWeaponLoadout::GetEffectiveXP(localPlayer->m_iXP.Get()); const int currentClass = localPlayer->m_iNextSpawnClassChoice.Get() != NEO_CLASS_RANDOM ? localPlayer->m_iNextSpawnClassChoice.Get() : localPlayer->m_iNeoClass.Get(); int iLoadout = sv_neo_dev_loadout.GetBool() ? NEO_LOADOUT_DEV : currentClass; diff --git a/src/game/server/neo/bot/neo_bot.cpp b/src/game/server/neo/bot/neo_bot.cpp index 175eb07c2f..c020f523c1 100644 --- a/src/game/server/neo/bot/neo_bot.cpp +++ b/src/game/server/neo/bot/neo_bot.cpp @@ -643,7 +643,8 @@ void CNEOBot::Spawn() int CNEOBot::ChooseRandomWeaponIndex() const { - const ENeoRank eRank = static_cast(GetRank(m_iXP) - 1); + const int iEffectiveXP = CNEOWeaponLoadout::GetEffectiveXP(m_iXP); + const ENeoRank eRank = static_cast(GetRank(iEffectiveXP) - 1); if (eRank == NEO_RANK_RANKLESS_DOG || (false == IN_BETWEEN_EQ(NEO_CLASS_RECON, m_iNeoClass, NEO_CLASS_VIP))) { return 0; @@ -665,7 +666,7 @@ int CNEOBot::ChooseRandomWeaponIndex() const // Generally shouldn't happen, but if so, just pick from any under the XP limit for (int i = 0; i < MAX_WEAPON_LOADOUTS; ++i) { - if (CNEOWeaponLoadout::s_LoadoutWeapons[m_iNeoClass][i].m_iWeaponPrice > m_iXP) + if (CNEOWeaponLoadout::s_LoadoutWeapons[m_iNeoClass][i].m_iWeaponPrice > iEffectiveXP) { break; } diff --git a/src/game/server/neo/neo_player.cpp b/src/game/server/neo/neo_player.cpp index bfe41aaec4..37138e7861 100644 --- a/src/game/server/neo/neo_player.cpp +++ b/src/game/server/neo/neo_player.cpp @@ -390,7 +390,7 @@ bool CNEO_Player::RequestSetLoadout(int loadoutNumber) } if (!sv_neo_ignore_wep_xp_limit.GetBool() && - loadoutNumber+1 > CNEOWeaponLoadout::GetNumberOfLoadoutWeapons(m_iXP, + loadoutNumber+1 > CNEOWeaponLoadout::GetNumberOfLoadoutWeapons(CNEOWeaponLoadout::GetEffectiveXP(m_iXP), sv_neo_dev_loadout.GetBool() ? NEO_LOADOUT_DEV : classChosen)) { DevMsg("Insufficient XP for %s\n", pszWepName); @@ -3597,7 +3597,7 @@ void CNEO_Player::GiveLoadoutWeapon(void) if (pNeoWeapon) { if (sv_neo_ignore_wep_xp_limit.GetBool() || - m_iLoadoutWepChoice+1 <= CNEOWeaponLoadout::GetNumberOfLoadoutWeapons(m_iXP, + m_iLoadoutWepChoice+1 <= CNEOWeaponLoadout::GetNumberOfLoadoutWeapons(CNEOWeaponLoadout::GetEffectiveXP(m_iXP), sv_neo_dev_loadout.GetBool() ? NEO_LOADOUT_DEV : m_iNeoClass.Get())) { pNeoWeapon->SetSubType(wepSubType); diff --git a/src/game/shared/neo/neo_weapon_loadout.cpp b/src/game/shared/neo/neo_weapon_loadout.cpp index 64870b6c32..a135d601a7 100644 --- a/src/game/shared/neo/neo_weapon_loadout.cpp +++ b/src/game/shared/neo/neo_weapon_loadout.cpp @@ -2,6 +2,7 @@ #include "tier0/dbg.h" #include "tier0/commonmacros.h" +#include "convar.h" #include "neo_misc.h" namespace CNEOWeaponLoadout { @@ -105,5 +106,14 @@ int GetNumberOfLoadoutWeapons(const int rank, const int classType) return amount; } +ConVar sv_neo_wep_xp_override("sv_neo_wep_xp_override", "-1", FCVAR_CHEAT | FCVAR_REPLICATED, + "Override the effective XP used for weapon eligibility checks, or -1 to disable.", true, -1.0f, false, 0.0f); + +int GetEffectiveXP(const int actualXP) +{ + const int iOverride = sv_neo_wep_xp_override.GetInt(); + return (iOverride >= 0) ? iOverride : actualXP; +} + } // namespace CNEOWeaponLoadout diff --git a/src/game/shared/neo/neo_weapon_loadout.h b/src/game/shared/neo/neo_weapon_loadout.h index 26f4cbc999..4f7d3ef253 100644 --- a/src/game/shared/neo/neo_weapon_loadout.h +++ b/src/game/shared/neo/neo_weapon_loadout.h @@ -44,5 +44,9 @@ namespace CNEOWeaponLoadout extern const CLoadoutWeapon s_LoadoutWeapons[NEO_LOADOUT__COUNT][MAX_WEAPON_LOADOUTS]; int GetNumberOfLoadoutWeapons(const int rank, const int classType); + + // The XP value to use for weapon eligibility checks. Returns actualXP unless + // sv_neo_wep_xp_override is set (>= 0), in which case the override wins. + int GetEffectiveXP(const int actualXP); } // namespace CNEOWeaponLoadout From 99c121a312eb6cea1fc565c76e7e6b563c42a5f0 Mon Sep 17 00:00:00 2001 From: Dan Peavey Date: Tue, 18 Aug 2026 19:33:53 -0700 Subject: [PATCH 2/3] Make detpack loadout grant honor sv_neo_detpack_xp_limit The recon spawn path hardcoded an XP >= 4 check before calling GiveDet, so raising or lowering sv_neo_detpack_xp_limit had no effect on whether the detpack was actually granted. Replace the hardcoded gate with an eligibility check at the top of GiveDet that reads the convar directly, making it the single source of truth. The check runs before the weapon entity is created, preserving the old gate's property that ineligible spawns never pay a create/destroy cycle. GiveDet's old internal cost check, which created the entity first to reach GetNeoWepXPCost, is dropped in its favor. The check routes through GetEffectiveXP so sv_neo_wep_xp_override covers the detpack as well. --- src/game/server/neo/neo_player.cpp | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/game/server/neo/neo_player.cpp b/src/game/server/neo/neo_player.cpp index 37138e7861..fbe5a95fd6 100644 --- a/src/game/server/neo/neo_player.cpp +++ b/src/game/server/neo/neo_player.cpp @@ -144,6 +144,7 @@ extern CBaseEntity *g_pLastSpawn; extern ConVar sv_neo_bot_cmdr_enable; extern ConVar sv_neo_ignore_wep_xp_limit; extern ConVar sv_neo_clantag_allow; +extern ConVar sv_neo_detpack_xp_limit; extern ConVar sv_neo_dev_test_clantag; extern ConVar sv_stickysprint; extern ConVar sv_neo_dev_loadout; @@ -3475,6 +3476,15 @@ CBaseEntity* CNEO_Player::GiveNamedItem(const char* szName, int iSubType) void GiveDet(CNEO_Player* pPlayer) { + // Cost of -1 XP means no XP cost. Checked before creating the weapon + // entity so ineligible players don't pay a create/destroy cycle. + const int detXpCost = sv_neo_detpack_xp_limit.GetInt(); + const bool canHaveDet = (detXpCost < 0 || CNEOWeaponLoadout::GetEffectiveXP(pPlayer->m_iXP) >= detXpCost); + if (!canHaveDet) + { + return; + } + constexpr const char* detpackClassname = "weapon_remotedet"; if (!pPlayer->Weapon_OwnsThisType(detpackClassname)) { @@ -3492,23 +3502,12 @@ void GiveDet(CNEO_Player* pPlayer) auto pWeapon = assert_cast((CBaseEntity*)pent); if (pWeapon) { - const int detXpCost = pWeapon->GetNeoWepXPCost(pPlayer->GetClass()); - // Cost of -1 XP means no XP cost. - const bool canHaveDet = (detXpCost < 0 || pPlayer->m_iXP >= detXpCost); - pWeapon->SetSubType(0); - if (canHaveDet) - { - DispatchSpawn(pent); + DispatchSpawn(pent); - if (pent != NULL && !(pent->IsMarkedForDeletion())) - { - pent->Touch(pPlayer); - } - } - else + if (pent != NULL && !(pent->IsMarkedForDeletion())) { - UTIL_Remove(pWeapon); + pent->Touch(pPlayer); } } } @@ -3522,7 +3521,7 @@ void CNEO_Player::GiveDefaultItems(void) case NEO_CLASS_RECON: GiveNamedItem("weapon_knife"); GiveNamedItem("weapon_milso"); - if (this->m_iXP >= 4) { GiveDet(this); } + GiveDet(this); Weapon_Switch(Weapon_OwnsThisType("weapon_milso")); break; case NEO_CLASS_ASSAULT: From f601aa63cdee4e8edde5662b3cf6afda173e997e Mon Sep 17 00:00:00 2001 From: Dan Peavey Date: Tue, 18 Aug 2026 20:28:32 -0700 Subject: [PATCH 3/3] Fold sv_neo_ignore_wep_xp_limit into the effective XP helper Reimplement the cvar inside GetEffectiveXP as an effective max rank (XP_LIEUTENANT) instead of scattered bypass checks. This removes the two redundant call-site checks in neo_player.cpp and the unused extern in the loadout menu. Behavior change, wider scope on purpose: the cvar previously bypassed only the two server-side loadout validations, and the loadout menu never honored it, so locked tiers stayed unclickable and it only worked via a typed loadout command. It now also unlocks the menu UI, bot weapon selection, and the recon detpack grant, matching what its description already claims. It takes precedence over sv_neo_wep_xp_override when both are set. --- src/game/client/neo/game_controls/neo_loadoutmenu.cpp | 1 - src/game/server/neo/neo_player.cpp | 11 ++++------- src/game/shared/neo/neo_weapon_loadout.cpp | 7 +++++++ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/game/client/neo/game_controls/neo_loadoutmenu.cpp b/src/game/client/neo/game_controls/neo_loadoutmenu.cpp index 8a7ba2d7f4..d005506a76 100644 --- a/src/game/client/neo/game_controls/neo_loadoutmenu.cpp +++ b/src/game/client/neo/game_controls/neo_loadoutmenu.cpp @@ -190,7 +190,6 @@ void CNeoLoadoutMenu::OnMousePressed(vgui::MouseCode code) extern ConCommand loadoutmenu; -extern ConVar sv_neo_ignore_wep_xp_limit; extern ConVar sv_neo_dev_loadout; void CNeoLoadoutMenu::OnClose() diff --git a/src/game/server/neo/neo_player.cpp b/src/game/server/neo/neo_player.cpp index fbe5a95fd6..e85940a6c5 100644 --- a/src/game/server/neo/neo_player.cpp +++ b/src/game/server/neo/neo_player.cpp @@ -142,7 +142,6 @@ CNEOGameRulesProxy* neoGameRules; extern CBaseEntity *g_pLastSpawn; extern ConVar sv_neo_bot_cmdr_enable; -extern ConVar sv_neo_ignore_wep_xp_limit; extern ConVar sv_neo_clantag_allow; extern ConVar sv_neo_detpack_xp_limit; extern ConVar sv_neo_dev_test_clantag; @@ -390,9 +389,8 @@ bool CNEO_Player::RequestSetLoadout(int loadoutNumber) result = false; } - if (!sv_neo_ignore_wep_xp_limit.GetBool() && - loadoutNumber+1 > CNEOWeaponLoadout::GetNumberOfLoadoutWeapons(CNEOWeaponLoadout::GetEffectiveXP(m_iXP), - sv_neo_dev_loadout.GetBool() ? NEO_LOADOUT_DEV : classChosen)) + if (loadoutNumber+1 > CNEOWeaponLoadout::GetNumberOfLoadoutWeapons(CNEOWeaponLoadout::GetEffectiveXP(m_iXP), + sv_neo_dev_loadout.GetBool() ? NEO_LOADOUT_DEV : classChosen)) { DevMsg("Insufficient XP for %s\n", pszWepName); result = RequestSetLoadout(0); @@ -3595,9 +3593,8 @@ void CNEO_Player::GiveLoadoutWeapon(void) CNEOBaseCombatWeapon *pNeoWeapon = assert_cast((CBaseEntity*)pEnt); if (pNeoWeapon) { - if (sv_neo_ignore_wep_xp_limit.GetBool() || - m_iLoadoutWepChoice+1 <= CNEOWeaponLoadout::GetNumberOfLoadoutWeapons(CNEOWeaponLoadout::GetEffectiveXP(m_iXP), - sv_neo_dev_loadout.GetBool() ? NEO_LOADOUT_DEV : m_iNeoClass.Get())) + if (m_iLoadoutWepChoice+1 <= CNEOWeaponLoadout::GetNumberOfLoadoutWeapons(CNEOWeaponLoadout::GetEffectiveXP(m_iXP), + sv_neo_dev_loadout.GetBool() ? NEO_LOADOUT_DEV : m_iNeoClass.Get())) { pNeoWeapon->SetSubType(wepSubType); diff --git a/src/game/shared/neo/neo_weapon_loadout.cpp b/src/game/shared/neo/neo_weapon_loadout.cpp index a135d601a7..65ae9c5e90 100644 --- a/src/game/shared/neo/neo_weapon_loadout.cpp +++ b/src/game/shared/neo/neo_weapon_loadout.cpp @@ -5,6 +5,8 @@ #include "convar.h" #include "neo_misc.h" +extern ConVar sv_neo_ignore_wep_xp_limit; + namespace CNEOWeaponLoadout { #define WEAPON_DEF(M_BITWNAME, M_NAME, M_DISPLAY_NAME, ...) \ @@ -111,6 +113,11 @@ ConVar sv_neo_wep_xp_override("sv_neo_wep_xp_override", "-1", FCVAR_CHEAT | FCVA int GetEffectiveXP(const int actualXP) { + if (sv_neo_ignore_wep_xp_limit.GetBool()) + { + // Treat as max rank so every XP-gated weapon is eligible + return XP_LIEUTENANT; + } const int iOverride = sv_neo_wep_xp_override.GetInt(); return (iOverride >= 0) ? iOverride : actualXP; }