From 6ccbac75bcf7631767b0599b842fa90c560661c3 Mon Sep 17 00:00:00 2001 From: cjujn <311919349+cjujn@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:05:10 +0800 Subject: [PATCH 1/2] Add cs2f_spawngroup_release_manifests to stop ForceSpawn slowing down Every point_template ForceSpawn loads a new spawn group and merges it into the owner spawn group (the map's group by default). The merge moves the child's resource manifests into the owner, which keeps them until map change, so each ForceSpawn adds one manifest and later ForceSpawns get slower (0.53 ms -> 1.47 ms per call after 65k calls, nothing left alive). With the new experimental cvar enabled, the manifests a merge hands over are destroyed right away, the same way the spawn group destructor does it. ForceSpawn cost then stays flat (0.49 ms per call after 141k calls). The hook fails soft: a missing signature or offset only disables the cvar. Co-Authored-By: Claude Opus 5.5 --- AMBuilder | 1 + cfg/cs2fixes/cs2fixes.cfg | 1 + gamedata/cs2fixes.jsonc | 24 +++++ src/spawngroupfix.cpp | 180 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 206 insertions(+) create mode 100644 src/spawngroupfix.cpp 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..78e53f4c 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_spawngroup_release_manifests 0 // Whether to destroy the resource manifests a point_template ForceSpawn hands to the map spawn group, stops ForceSpawn getting slower over the map, 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..eadfad96 --- /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_spawngroup_release_manifests 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_cvarSpawnGroupReleaseManifests("cs2f_spawngroup_release_manifests", FCVAR_NONE, "Whether to destroy the resource manifests a point_template ForceSpawn hands to the map spawn group, stops ForceSpawn getting slower over the map, 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_cvarSpawnGroupReleaseManifests.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_spawngroup_release_manifests is unavailable\n"); + return; + } + + auto address = g_GameConfig->ResolveSignature("CBaseSpawnGroup_MergeIntoOwner"); + + if (!address) + { + Warning("[SpawnGroup] Failed to find CBaseSpawnGroup_MergeIntoOwner, cs2f_spawngroup_release_manifests 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 From 8a03d6203311a892e15090338a668f0864a3db2d Mon Sep 17 00:00:00 2001 From: cjujn <311919349+cjujn@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:05:10 +0800 Subject: [PATCH 2/2] Rename cvar to cs2f_fix_forcespawn_slowdown Name it after the symptom server operators see, matching cs2f_fix_game_bans. Co-Authored-By: Claude Opus 5.5 --- cfg/cs2fixes/cs2fixes.cfg | 2 +- src/spawngroupfix.cpp | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cfg/cs2fixes/cs2fixes.cfg b/cfg/cs2fixes/cs2fixes.cfg index 78e53f4c..69f0af31 100644 --- a/cfg/cs2fixes/cs2fixes.cfg +++ b/cfg/cs2fixes/cs2fixes.cfg @@ -28,7 +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_spawngroup_release_manifests 0 // Whether to destroy the resource manifests a point_template ForceSpawn hands to the map spawn group, stops ForceSpawn getting slower over the map, experimental +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/src/spawngroupfix.cpp b/src/spawngroupfix.cpp index eadfad96..a0e13005 100644 --- a/src/spawngroupfix.cpp +++ b/src/spawngroupfix.cpp @@ -23,7 +23,7 @@ // 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_spawngroup_release_manifests enabled, the manifests a merge hands to the owner are +// 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. @@ -39,7 +39,7 @@ #include "tier0/memdbgon.h" -CConVar g_cvarSpawnGroupReleaseManifests("cs2f_spawngroup_release_manifests", FCVAR_NONE, "Whether to destroy the resource manifests a point_template ForceSpawn hands to the map spawn group, stops ForceSpawn getting slower over the map, experimental", false); +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 { @@ -85,7 +85,7 @@ namespace { int nCount = -1; - if (pChild && pOwner && !g_bDisabled && g_cvarSpawnGroupReleaseManifests.Get()) + if (pChild && pOwner && !g_bDisabled && g_cvarFixForceSpawnSlowdown.Get()) { ManifestVector* pManifests = GetManifests(pOwner); @@ -155,7 +155,7 @@ namespace if (g_iManifestsOffset < 0 || g_iResourceServiceOffset < 0 || g_iDestroyManifestIndex < 0) { - Warning("[SpawnGroup] Missing spawn group offsets, cs2f_spawngroup_release_manifests is unavailable\n"); + Warning("[SpawnGroup] Missing spawn group offsets, cs2f_fix_forcespawn_slowdown is unavailable\n"); return; } @@ -163,7 +163,7 @@ namespace if (!address) { - Warning("[SpawnGroup] Failed to find CBaseSpawnGroup_MergeIntoOwner, cs2f_spawngroup_release_manifests is unavailable\n"); + Warning("[SpawnGroup] Failed to find CBaseSpawnGroup_MergeIntoOwner, cs2f_fix_forcespawn_slowdown is unavailable\n"); return; }