diff --git a/AMBuilder b/AMBuilder index 6abb84fb..cbf5565a 100644 --- a/AMBuilder +++ b/AMBuilder @@ -38,6 +38,7 @@ for sdk_target in MMSPlugin.sdk_targets: 'src/commands.cpp', 'src/addresses.cpp', 'src/detours.cpp', + 'src/spawngroupfix.cpp', 'src/virtual_hooks.cpp', 'src/entities.cpp', 'src/events.cpp', diff --git a/cfg/cs2fixes/cs2fixes.cfg b/cfg/cs2fixes/cs2fixes.cfg index 2ed7948d..69f0af31 100644 --- a/cfg/cs2fixes/cs2fixes.cfg +++ b/cfg/cs2fixes/cs2fixes.cfg @@ -28,6 +28,7 @@ cs2f_cvarwhitelist_enable 0 // Whether to enable the custom ConVar whitelist f cs2f_block_particle_msgs 0 // Whether to block CUserMsg_ParticleManager messages to fix lag/crashes, experimental cs2f_disable_setmodel 0 // Whether to disable SetModel usage from maps (custom input, cs_script function) cs2f_allow_duck_spam 0 // Whether to allow duck spamming by removing the duck slowdown, clients will only partially predict [0 = disabled, 1 = both teams, 2 = T only, 3 = CT only] +cs2f_fix_forcespawn_slowdown 0 // Whether to fix point_template ForceSpawn getting slower over the course of a map by releasing the resource manifests each spawn leaves on the map spawn group, experimental cs2f_beacon_particle "particles/cs2fixes/admin_beacon.vpcf" // .vpcf file to be precached and used for player beacon cs2f_motd_url "" // Server MOTD URL, shows up as a "Server Website" button in scoreboard diff --git a/gamedata/cs2fixes.jsonc b/gamedata/cs2fixes.jsonc index d3e74c63..99b1e450 100644 --- a/gamedata/cs2fixes.jsonc +++ b/gamedata/cs2fixes.jsonc @@ -229,6 +229,13 @@ "windows": "48 8B C4 44 88 48 20 44 89 40 18 48 89 50 10 53", "linux": "55 48 89 E5 41 57 41 56 41 55 41 54 53 48 89 FB 48 83 EC ? 89 4D" }, + // CBaseSpawnGroup vfunc that merges a spawn group into its owner, the function with "%s: Transfering creation sequence %d to owner spawn group %s" + "CBaseSpawnGroup_MergeIntoOwner": + { + "library": "engine", + "windows": "48 89 5C 24 18 55 56 57 41 54 41 55 41 56 41 57 48 83 EC 30 33 ED 4C 8B F2 89 6C 24 70 4C 8B F9 F6 81 68 02 00 00 01", + "linux": "55 48 89 E5 41 57 41 56 41 55 49 89 FD 41 54 53 48 89 F3 48 83 EC ? F6 87 68 02 00 00 01" + }, // Return value of this function is used to determine whether "NETWORK_DISCONNECT_REJECT_SERVERFULL to %s: Cannot get free client\n" gets printed "GetFreeClient": { @@ -448,6 +455,23 @@ "windows": 37, "linux": 38 }, + // Byte offset of the resource manifest CUtlVector, the first vector CBaseSpawnGroup_MergeIntoOwner appends from the child to the owner + "CBaseSpawnGroup_ResourceManifests": + { + "windows": 552, + "linux": 552 + }, + // Byte offset of the IGameResourceService pointer, the CBaseSpawnGroup destructor calls IGameResourceService_DestroyManifest on it for each resource manifest + "CBaseSpawnGroup_ResourceService": + { + "windows": 544, + "linux": 544 + }, + "IGameResourceService_DestroyManifest": + { + "windows": 31, + "linux": 31 + }, // There's no easy way to find this, but it's a function that checks entity flags (0x370) and ends by calling RemoveFlag with 0x800000 (FL_BASEVELOCITY) "CCSPlayer_MovementServices::CheckMovingGround": { diff --git a/src/spawngroupfix.cpp b/src/spawngroupfix.cpp new file mode 100644 index 00000000..a0e13005 --- /dev/null +++ b/src/spawngroupfix.cpp @@ -0,0 +1,180 @@ +/** + * ============================================================================= + * CS2Fixes + * Copyright (C) 2023-2026 Source2ZE + * ============================================================================= + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, version 3.0, as published by the + * Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +// Every point_template ForceSpawn loads a new spawn group and merges it into the template's owner +// spawn group (the map's group by default) when the spawn finishes. The merge moves the child's +// resource manifests into the owner, which keeps them until it is destroyed on map change, so the +// owner gains one manifest per ForceSpawn and every later ForceSpawn gets slower (measured 0.53 ms +// -> 1.47 ms per call after 65k calls, nothing left alive). +// +// With cs2f_fix_forcespawn_slowdown enabled, the manifests a merge hands to the owner are +// destroyed right away, the same way the spawn group destructor destroys them. ForceSpawn cost then +// stays flat (0.49 ms per call after 141k calls). Resources that are only referenced by those +// manifests may be unloaded, so this is opt-in. + +#include "common.h" +#include "cs2fixes.h" +#include "gameconfig.h" +#include "khook.hpp" +#include "tier0/convar.h" +#include "utils/khook_helpers.h" + +#include + +#include "tier0/memdbgon.h" + +CConVar g_cvarFixForceSpawnSlowdown("cs2f_fix_forcespawn_slowdown", FCVAR_NONE, "Whether to fix point_template ForceSpawn getting slower over the course of a map by releasing the resource manifests each spawn leaves on the map spawn group, experimental", false); + +namespace +{ + // CUtlVector layout used by CBaseSpawnGroup for its resource manifest list + struct ManifestVector + { + int m_nSize; + void** m_pMemory; + int m_nAllocated; + int m_nGrowSize; + }; + + int g_iManifestsOffset = -1; + int g_iResourceServiceOffset = -1; + int g_iDestroyManifestIndex = -1; + bool g_bDisabled = false; + + // Manifest count of the owner before each (possibly nested) merge, -1 when not releasing + thread_local std::vector t_ownerManifestCounts; + + ManifestVector* GetManifests(void* pSpawnGroup) + { + return reinterpret_cast(reinterpret_cast(pSpawnGroup) + g_iManifestsOffset); + } + + void* GetResourceService(void* pSpawnGroup) + { + return *reinterpret_cast(reinterpret_cast(pSpawnGroup) + g_iResourceServiceOffset); + } + + bool IsValidVector(const ManifestVector* pVec) + { + return pVec->m_nSize >= 0 && pVec->m_nSize <= pVec->m_nAllocated && (pVec->m_nSize == 0 || pVec->m_pMemory); + } + + void Disable(const char* pszReason) + { + g_bDisabled = true; + Warning("[SpawnGroup] %s, manifest release disabled\n", pszReason); + } + + KHook::Return Hook_MergeIntoOwner(void* pChild, void* pOwner) + { + int nCount = -1; + + if (pChild && pOwner && !g_bDisabled && g_cvarFixForceSpawnSlowdown.Get()) + { + ManifestVector* pManifests = GetManifests(pOwner); + + if (!IsValidVector(pManifests)) + Disable("Unexpected spawn group layout"); + else if (GetResourceService(pOwner)) + nCount = pManifests->m_nSize; + } + + t_ownerManifestCounts.push_back(nCount); + return {KHook::Action::Ignore}; + } + + KHook::Return Hook_MergeIntoOwner_Post(void* pChild, void* pOwner) + { + if (t_ownerManifestCounts.empty()) + return {KHook::Action::Ignore}; + + int nOldCount = t_ownerManifestCounts.back(); + t_ownerManifestCounts.pop_back(); + + if (nOldCount < 0 || g_bDisabled) + return {KHook::Action::Ignore}; + + ManifestVector* pManifests = GetManifests(pOwner); + void* pService = GetResourceService(pOwner); + + if (!IsValidVector(pManifests) || pManifests->m_nSize < nOldCount || !pService) + { + Disable("Owner manifest list changed unexpectedly during merge"); + return {KHook::Action::Ignore}; + } + + using DestroyManifestFn = void (*)(void*, void*); + auto pfnDestroyManifest = (*reinterpret_cast(pService))[g_iDestroyManifestIndex]; + int nNewCount = pManifests->m_nSize; + + // Shrink first so the owner never points at a destroyed manifest + pManifests->m_nSize = nOldCount; + + for (int i = nOldCount; i < nNewCount; i++) + { + if (pManifests->m_pMemory[i]) + pfnDestroyManifest(pService, pManifests->m_pMemory[i]); + + pManifests->m_pMemory[i] = nullptr; + } + + return {KHook::Action::Ignore}; + } + + // Unlike CKHookFunction, a missing signature or offset only disables this feature instead of failing the plugin load + class CMergeIntoOwnerHook : public CKHookBase + { + public: + CMergeIntoOwnerHook() : + m_hook(Hook_MergeIntoOwner, Hook_MergeIntoOwner_Post) + { + GetKHookList().push_back(this); + } + + virtual void Configure() override + { + g_iManifestsOffset = g_GameConfig->GetOffset("CBaseSpawnGroup_ResourceManifests"); + g_iResourceServiceOffset = g_GameConfig->GetOffset("CBaseSpawnGroup_ResourceService"); + g_iDestroyManifestIndex = g_GameConfig->GetOffset("IGameResourceService_DestroyManifest"); + + if (g_iManifestsOffset < 0 || g_iResourceServiceOffset < 0 || g_iDestroyManifestIndex < 0) + { + Warning("[SpawnGroup] Missing spawn group offsets, cs2f_fix_forcespawn_slowdown is unavailable\n"); + return; + } + + auto address = g_GameConfig->ResolveSignature("CBaseSpawnGroup_MergeIntoOwner"); + + if (!address) + { + Warning("[SpawnGroup] Failed to find CBaseSpawnGroup_MergeIntoOwner, cs2f_fix_forcespawn_slowdown is unavailable\n"); + return; + } + + m_hook.Configure(address); + + Message("Hooked CBaseSpawnGroup_MergeIntoOwner at 0x%p\n", address); + } + + private: + KHook::Function m_hook; + }; + + CMergeIntoOwnerHook g_hookMergeIntoOwner; +} // namespace