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