diff --git a/src/core/timer_system.cpp b/src/core/timer_system.cpp index 8e5b44798..1ebe419c5 100644 --- a/src/core/timer_system.cpp +++ b/src/core/timer_system.cpp @@ -88,15 +88,19 @@ void TimerSystem::OnLevelEnd() m_has_map_ticked = false; } -void TimerSystem::OnStartupServer() +void TimerSystem::OnStartupServer(bool levelShutdown) { - if (m_has_map_ticked) + if (levelShutdown && m_has_map_ticked) { CALL_GLOBAL_LISTENER(OnLevelEnd()); CSSHARP_CORE_TRACE("name={0}", "LevelShutdown"); } + // Reset is UNCONDITIONAL -- universal_time math in OnGameFrame depends on + // m_has_map_ticked being false on the first frame of every new session, + // whether that session came in via a genuine LevelShutdown or a workshop + // ss_dead reload. m_has_map_ticked = false; m_has_map_simulated = false; } diff --git a/src/core/timer_system.h b/src/core/timer_system.h index 4a9a28a44..5b6ec3cdb 100644 --- a/src/core/timer_system.h +++ b/src/core/timer_system.h @@ -72,7 +72,14 @@ class TimerSystem : public GlobalClass void OnShutdown() override; void OnLevelEnd() override; void OnGameFrame(bool simulating); - void OnStartupServer(); + // levelShutdown=true on a genuine OnLevelShutdown -> OnStartupServer sequence + // (real changelevel/shutdown). levelShutdown=false on workshop ss_dead reload + // cycles that fire Hook_StartupServer without OnLevelShutdown -- in that case + // we must NOT fire OnLevelEnd (PlayerManager disconnects still-connected + // players -> stale .NET callbacks -> SEGV on the next DispatchConCommand + // hook), but we MUST still reset the per-map tick state for universal_time + // math to stay correct across the cycle. + void OnStartupServer(bool levelShutdown); double CalculateNextThink(double last_think_time, float interval); void RunFrame(); void RemoveMapChangeTimers(); diff --git a/src/mm_plugin.cpp b/src/mm_plugin.cpp index 1c611badc..4bf19725a 100644 --- a/src/mm_plugin.cpp +++ b/src/mm_plugin.cpp @@ -204,12 +204,29 @@ bool CounterStrikeSharpMMPlugin::Load(PluginId id, ISmmAPI* ismm, char* error, s return true; } +static bool s_bLevelShutdownOccurred = false; + void CounterStrikeSharpMMPlugin::Hook_StartupServer(const GameSessionConfiguration_t& config, ISource2WorldSession*, const char*) { globals::entitySystem = interfaces::pGameResourceServiceServer->GetGameEntitySystem(); + // Remove before adding to prevent double-registration when workshop addon changes + // trigger a second StartupServer within the same map session (ss_dead cycle). + globals::entitySystem->RemoveListenerEntity(&globals::entityManager.entityListener); globals::entitySystem->AddListenerEntity(&globals::entityManager.entityListener); - globals::timerSystem.OnStartupServer(); + // Workshop ss_dead reload cycles fire Hook_StartupServer without a + // preceding OnLevelShutdown. We pass that distinction down so that: + // levelShutdown=true -> fires OnLevelEnd (PlayerManager etc.) and + // resets timer tick state. Genuine changelevel. + // levelShutdown=false -> ONLY resets timer tick state. No OnLevelEnd, + // which is what avoids the PlayerManager + // disconnect -> stale .NET callbacks -> SEGV + // chain on ss_dead reloads. + // Tick-state reset must be unconditional so universal_time math in + // OnGameFrame doesn't desync across the cycle (otherwise pending one-off + // timers stall arbitrarily long). + globals::timerSystem.OnStartupServer(s_bLevelShutdownOccurred); + s_bLevelShutdownOccurred = false; on_activate_callback->ScriptContext().Reset(); on_activate_callback->ScriptContext().Push(globals::getGlobalVars()->mapname.ToCStr()); @@ -299,7 +316,7 @@ int CounterStrikeSharpMMPlugin::Hook_LoadEventsFromFile(const char* filename, bo RETURN_META_VALUE(MRES_IGNORED, 0); } -void CounterStrikeSharpMMPlugin::OnLevelShutdown() {} +void CounterStrikeSharpMMPlugin::OnLevelShutdown() { s_bLevelShutdownOccurred = true; } bool CounterStrikeSharpMMPlugin::Pause(char* error, size_t maxlen) { return true; }