Repository navigation
Conversation
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 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
FixClientUI() currently performs a redundant extra ClientFullUpdate() after ResetClient() already triggers full updates via ThirdPersonOff()/MirrorOff(), which can unnecessarily amplify UI churn during player_death.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR addresses a round-restart exploit in the ThirdPerson SourceMod plugin by preventing FixClientUI() from triggering win-condition / round-end logic during sensitive timing (round end or last-alive situations), and adds diagnostics to help validate the behavior on ZR + TeamManager setups.
Changes:
- Add
g_bRoundEnding(set onround_end, cleared onround_start) and use it to gate UI refresh behavior. - Update
FixClientUI()to avoidChangeClientTeam()side effects by usingCS_SwitchTeam()and skipping team juggling when it could race round end; add null-guard forzr_respawn. - Add
sm_thirdperson_debugConVar and debug logging forround_end,player_death, andFixClientUI()decisions; bump version to1.4.0.
File summaries
| File | Description |
|---|---|
addons/sourcemod/scripting/ThirdPerson.sp |
Introduces round-end gating + safer UI refresh flow in FixClientUI(), adds debug instrumentation and supporting helpers. |
Review details
Suppressed comments (1)
addons/sourcemod/scripting/ThirdPerson.sp:354
ResetClient()already callsThirdPersonOff()/MirrorOff()beforeFixClientUI(), and both of those functions already performClientFullUpdate()when the FullUpdate library is present. The unconditionalClientFullUpdate()at the end ofFixClientUI()is therefore redundant and adds extra full updates duringplayer_death, which can amplify UI churn and the behavior described in issue #2. Consider removing the trailing full update here and keeping it only in the early-return path where no team refresh occurs.
#if defined _FullUpdate_Included
if (g_bFullUpdate)
ClientFullUpdate(client);
#endif
}
- Files reviewed: 1/1 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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 <noreply@anthropic.com>
Investigation — issue #10
Reported behaviour
On a
ZR + TeamManagersetup, players could "restart" the round by combiningthirdpersonwith aspeccommand. Root cause was never found, so PR #9mitigated it by blocking the commands until a zombie has spawned.
Root cause
The trigger is
FixClientUI():FixClientUI()runs fromEvent_PlayerDeath(ResetClient(client, true)) onevery non-infection death of a thirdperson/mirror user.
ChangeClientTeam()on CS:S goes through the mod's generic team-changefunction — per the SourceMod docs
it "will kill the player" and it re-enters
CCSGameRuleswin-conditionchecking. When the dying player is the last alive member of their team,
moving them to
CS_TEAM_SPECTATORmakes the engine fireround_endat theexact moment ZR is resolving its own round end (ZR ends a round when
"one team has zero valid living clients").
ZR sees the inconsistent/duplicated round-end and reacts by restarting the round.
The following
CS_SwitchTeam(client, CS_TEAM_T)then drops the dead player ontothe zombie team, further corrupting ZR's round-end accounting / team balance.
Why
thirdperson+specspecifically:thirdperson/mirroris the gate — withoutg_bThirdPerson[client]/g_bMirror[client],Event_PlayerDeathreturns early andFixClientUI()isnever reached.
Spec(team change for alive players) +TeamManagerlet a player putthemselves in the "I am the last one alive on my team" state on demand, then
die in thirdperson to fire the doubled
round_end.This also explains issue #2 ("plugin goes crazy… slay, move to spec"): that is
ChangeClientTeam()killing the player and bouncing them through spectator.Side finding (not fixed here)
The PR #9 mitigation lives under
#if defined _zr_included, but neither the oldSourceKnight config nor the current
ci.ymlever providedzombiereloaded.inc,so that mitigation has never been compiled into a released
.smx. Happy tofollow up with a separate PR that adds the include to CI and guards the block
with
g_bZombieReloaded && g_bTeamManager(otherwise it would permanently blockthe commands on non-TeamManager servers).
Changes
FixClientUI()ClientFullUpdate()only) wheng_bRoundEndingis setor the client's team has no other alive members — i.e. exactly the
situations where touching teams collides with ZR's round end.
CS_SwitchTeam()instead ofChangeClientTeam()for the HUD-refreshbounce: no kill, no team-change / win-condition game logic.
FindConVar("zr_respawn").MirrorOff()now also callsClientFullUpdate()(parity withThirdPersonOff()).g_bRoundEndingflag (round_endpre-hook, cleared onround_start).sm_thirdperson_debug(0/1, default 0) — logsround_end,player_deathand
FixClientUI()decisions with per-team alive/total counts and whichclients are still flagged, as requested in the issue.
1.3.6→1.4.0.Testing
Not runtime-tested (no ZR+TeamManager server on hand). Logic review + CI build.
Ideally verified on a staging ZR server with
sm_thirdperson_debug 1:!tp, die to non-claws damage → roundshould end normally (no restart), log shows
FixClientUI: skipped.!tpwith living teammates → HUD still resets, nodouble death / slay.
🤖 Generated with Claude Code