Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AMBuilder
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions cfg/cs2fixes/cs2fixes.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions gamedata/cs2fixes.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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":
{
Expand Down Expand Up @@ -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":
{
Expand Down
180 changes: 180 additions & 0 deletions src/spawngroupfix.cpp
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

// 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 <vector>

#include "tier0/memdbgon.h"

CConVar<bool> 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<int> t_ownerManifestCounts;

ManifestVector* GetManifests(void* pSpawnGroup)
{
return reinterpret_cast<ManifestVector*>(reinterpret_cast<uintptr_t>(pSpawnGroup) + g_iManifestsOffset);
}

void* GetResourceService(void* pSpawnGroup)
{
return *reinterpret_cast<void**>(reinterpret_cast<uintptr_t>(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<void> 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<void> 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<DestroyManifestFn**>(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<void, void*, void*> m_hook;
};

CMergeIntoOwnerHook g_hookMergeIntoOwner;
} // namespace
Loading