From 6460254d38dd90afb85dbf79806024026a585c72 Mon Sep 17 00:00:00 2001 From: Rushaway Date: Fri, 4 Sep 2026 13:06:52 +0200 Subject: [PATCH 1/2] fix(exploit): stop FixClientUI from racing ZR round end (#10) The "restart round" exploit reported in #10 is triggered by FixClientUI(): it runs on every non-infection death of a thirdperson/mirror user and calls ChangeClientTeam(client, CS_TEAM_SPECTATOR). On CS:S that native goes through the mod's generic team-change path (it also kills the player) and re-enters CCSGameRules win-condition checking. When the dying player is the last alive member of their team, that fires a round_end the same moment ZR is resolving its own round end - ZR reacts to the unexpected state by restarting the round. The Spec plugin + TeamManager just let a player engineer the "last alive on my team" condition on demand and then die in thirdperson. Changes: - FixClientUI() never touches teams while the round is ending or once the client's team has no other alive members; it only does a local ClientFullUpdate() in that case and lets ZR/TeamManager own team state. - Replace ChangeClientTeam() with CS_SwitchTeam() for the HUD-refresh bounce: no kill, no team-change/win-condition game logic. - Guard the FindConVar("zr_respawn") result against null. - MirrorOff() now also calls ClientFullUpdate() (parity with ThirdPersonOff()). - Add sm_thirdperson_debug (0/1): logs round_end, player_death and FixClientUI decisions with per-team alive counts, as requested in the issue. Co-Authored-By: Claude Sonnet 5 --- addons/sourcemod/scripting/ThirdPerson.sp | 101 +++++++++++++++++++++- 1 file changed, 98 insertions(+), 3 deletions(-) diff --git a/addons/sourcemod/scripting/ThirdPerson.sp b/addons/sourcemod/scripting/ThirdPerson.sp index 5658729..6871d46 100644 --- a/addons/sourcemod/scripting/ThirdPerson.sp +++ b/addons/sourcemod/scripting/ThirdPerson.sp @@ -18,16 +18,18 @@ bool g_bZombieReloaded = false; bool g_bZombieSpawned = false; bool g_bTeamManager = false; bool g_bFullUpdate = false; +bool g_bRoundEnding = false; ConVar g_cvAllowThirdPerson; ConVar g_cvForceCamera; +ConVar g_cvDebug; public Plugin myinfo = { name = "ThirdPerson", author = "BotoX, maxime1907, .Rushaway", description = "Allow players/admins to toggle thirdperson on themselves/players.", - version = "1.3.6" + version = "1.4.0" } public APLRes AskPluginLoad2(Handle myself, bool late, char[] error, int err_max) @@ -46,11 +48,16 @@ public void OnPluginStart() RegConsoleCmd("sm_tp", Command_ThirdPerson, "Toggle thirdperson"); RegConsoleCmd("sm_mirror", Command_Mirror, "Toggle Rotational Thirdperson view"); + g_cvDebug = CreateConVar("sm_thirdperson_debug", "0", "Log detailed ThirdPerson & round diagnostics to help track the round-restart exploit (issue #10).", FCVAR_NOTIFY, true, 0.0, true, 1.0); + HookEvent("round_start", Event_RoundStart, EventHookMode_Pre); + HookEvent("round_end", Event_RoundEnd, EventHookMode_Pre); HookEvent("player_death", Event_PlayerDeath); HookEvent("player_spawn", Event_PlayerSpawn); g_cvForceCamera = FindConVar("mp_forcecamera"); + + AutoExecConfig(true, "ThirdPerson"); } public void OnConVarChanged(ConVar cvar, const char[] oldVal, const char[] newVal) @@ -115,6 +122,27 @@ public void OnClientDisconnect(int client) public void Event_RoundStart(Event event, const char[] name, bool dontBroadcast) { g_bZombieSpawned = false; + g_bRoundEnding = false; +} + +public void Event_RoundEnd(Event event, const char[] name, bool dontBroadcast) +{ + g_bRoundEnding = true; + + if (!g_cvDebug.BoolValue) + return; + + LogMessage("[ThirdPerson] round_end | winner=%d reason=%d | T=%d/%d alive, CT=%d/%d alive", + event.GetInt("winner"), event.GetInt("reason"), + CountTeam(CS_TEAM_T, true), CountTeam(CS_TEAM_T, false), + CountTeam(CS_TEAM_CT, true), CountTeam(CS_TEAM_CT, false)); + + for (int i = 1; i <= MaxClients; i++) + { + if (IsValidClient(i, true) && (g_bThirdPerson[i] || g_bMirror[i])) + LogMessage("[ThirdPerson] - %L still flagged (thirdperson=%d mirror=%d team=%d alive=%d)", + i, g_bThirdPerson[i], g_bMirror[i], GetClientTeam(i), IsPlayerAlive(i)); + } } public Action Command_Mirror(int client, int args) @@ -176,6 +204,11 @@ public Action Event_PlayerDeath(Event event, const char[] name, bool dontBroadca int client = GetClientOfUserId(GetEventInt(event, "userid")); if (IsValidClient(client, true, false) && (g_bThirdPerson[client] || g_bMirror[client])) { + if (g_cvDebug.BoolValue) + LogMessage("[ThirdPerson] player_death | %L thirdperson=%d mirror=%d team=%d roundEnding=%d teammatesAlive=%d", + client, g_bThirdPerson[client], g_bMirror[client], GetClientTeam(client), + g_bRoundEnding, CountTeam(GetClientTeam(client), true, client)); + int attacker = GetClientOfUserId(GetEventInt(event, "attacker")); if (g_bZombieReloaded && IsValidClient(attacker, false)) { @@ -262,21 +295,62 @@ stock void ResetClient(int client, bool bFixUI = false) stock void FixClientUI(int client) { int currentTeam = GetClientTeam(client); - ChangeClientTeam(client, CS_TEAM_SPECTATOR); + if (currentTeam <= CS_TEAM_SPECTATOR) + return; + + /** + * Never juggle the client's team while the round is ending, or once this + * client's team has no other alive members (the round is about to be + * decided). ChangeClientTeam() runs CS' generic team-change path, which + * re-enters the CCSGameRules win-condition checks; racing that against + * ZR's own round-end handling is what lets players "restart" the round. + * See https://github.com/srcdslab/sm-plugin-ThirdPerson/issues/10 + * + * In that situation we only refresh the client locally (observer props + * were already cleared by ThirdPersonOff()/MirrorOff()) and let + * ZR / TeamManager own the team state. + */ + if (g_bRoundEnding || CountTeam(currentTeam, true, client) == 0) + { + if (g_cvDebug.BoolValue) + LogMessage("[ThirdPerson] FixClientUI: skipped team refresh for %L (roundEnding=%d, teammatesAlive=%d)", + client, g_bRoundEnding, CountTeam(currentTeam, true, client)); + +#if defined _FullUpdate_Included + if (g_bFullUpdate) + ClientFullUpdate(client); +#endif + return; + } + + /** + * CS_SwitchTeam() rebuilds the client's team/observer state without + * killing the player and without triggering the team-change / + * win-condition game logic that ChangeClientTeam() fires. + */ + CS_SwitchTeam(client, CS_TEAM_SPECTATOR); + if (!g_bZombieReloaded) + { CS_SwitchTeam(client, currentTeam); + } else { CS_SwitchTeam(client, CS_TEAM_T); if (!IsPlayerAlive(client)) { ConVar cvRespawn = FindConVar("zr_respawn"); - if (cvRespawn.IntValue == 1) + if (cvRespawn != null && cvRespawn.IntValue == 1) RequestFrame(RespawnClient, client); delete cvRespawn; } } + +#if defined _FullUpdate_Included + if (g_bFullUpdate) + ClientFullUpdate(client); +#endif } stock void RespawnClient(int client) @@ -317,6 +391,11 @@ stock void MirrorOff(int client, bool notify = true) g_bMirror[client] = false; +#if defined _FullUpdate_Included + if (g_bFullUpdate) + ClientFullUpdate(client); +#endif + if (notify) CPrintToChat(client, "{darkblue}[Mirror]{default} is {red}OFF{default}."); } @@ -341,3 +420,19 @@ stock bool IsValidClient(int client, bool bots = false, bool bAlive = false) } return false; } + +stock int CountTeam(int team, bool aliveOnly, int ignoreClient = 0) +{ + int count = 0; + for (int i = 1; i <= MaxClients; i++) + { + if (i == ignoreClient || !IsClientInGame(i)) + continue; + if (GetClientTeam(i) != team) + continue; + if (aliveOnly && !IsPlayerAlive(i)) + continue; + count++; + } + return count; +} From 05be37b03f7093634db0b8cf62eb258fb9a88330 Mon Sep 17 00:00:00 2001 From: Rushaway Date: Fri, 4 Sep 2026 13:17:20 +0200 Subject: [PATCH 2/2] refactor: cache CountTeam() result instead of re-looping CountTeam() walks all client slots; calling it twice (once for the condition, once for the debug log) could report different values if team state changed between the calls. Compute it once and reuse. Same for the GetClientTeam() double call in the player_death debug log. Co-Authored-By: Claude Sonnet 5 --- addons/sourcemod/scripting/ThirdPerson.sp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/addons/sourcemod/scripting/ThirdPerson.sp b/addons/sourcemod/scripting/ThirdPerson.sp index 6871d46..81fa686 100644 --- a/addons/sourcemod/scripting/ThirdPerson.sp +++ b/addons/sourcemod/scripting/ThirdPerson.sp @@ -205,9 +205,12 @@ public Action Event_PlayerDeath(Event event, const char[] name, bool dontBroadca if (IsValidClient(client, true, false) && (g_bThirdPerson[client] || g_bMirror[client])) { if (g_cvDebug.BoolValue) + { + int iTeam = GetClientTeam(client); LogMessage("[ThirdPerson] player_death | %L thirdperson=%d mirror=%d team=%d roundEnding=%d teammatesAlive=%d", - client, g_bThirdPerson[client], g_bMirror[client], GetClientTeam(client), - g_bRoundEnding, CountTeam(GetClientTeam(client), true, client)); + client, g_bThirdPerson[client], g_bMirror[client], iTeam, + g_bRoundEnding, CountTeam(iTeam, true, client)); + } int attacker = GetClientOfUserId(GetEventInt(event, "attacker")); if (g_bZombieReloaded && IsValidClient(attacker, false)) @@ -298,6 +301,8 @@ stock void FixClientUI(int client) if (currentTeam <= CS_TEAM_SPECTATOR) return; + int iTeammatesAlive = CountTeam(currentTeam, true, client); + /** * Never juggle the client's team while the round is ending, or once this * client's team has no other alive members (the round is about to be @@ -310,11 +315,11 @@ stock void FixClientUI(int client) * were already cleared by ThirdPersonOff()/MirrorOff()) and let * ZR / TeamManager own the team state. */ - if (g_bRoundEnding || CountTeam(currentTeam, true, client) == 0) + if (g_bRoundEnding || iTeammatesAlive == 0) { if (g_cvDebug.BoolValue) LogMessage("[ThirdPerson] FixClientUI: skipped team refresh for %L (roundEnding=%d, teammatesAlive=%d)", - client, g_bRoundEnding, CountTeam(currentTeam, true, client)); + client, g_bRoundEnding, iTeammatesAlive); #if defined _FullUpdate_Included if (g_bFullUpdate)