From 33feda8179b4b5e65e2e2cb7fe994e574b0470e5 Mon Sep 17 00:00:00 2001 From: Astra Date: Mon, 28 Sep 2026 11:23:10 +0100 Subject: [PATCH] AdminStealth 0.3.12: keep hidden admins off the team select screen Hidden admins skip the team screen on reconnect (moved to spectators until re-hidden), never receive the server's team menu message, and a pick from the screen M opens is answered by telling only their client they joined spectators. The restore runs css_hide at most once, since it toggles. Each command listener gets its own delegate: CSS keys listeners by handler, and reusing one orphaned the first registration, which then swallowed its command. --- AdminStealth.cs | 124 ++++++++++++++++++++++++++++++++++++++++++++++-- README.md | 15 +++++- 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/AdminStealth.cs b/AdminStealth.cs index 3d4679e..37d0adf 100644 --- a/AdminStealth.cs +++ b/AdminStealth.cs @@ -7,6 +7,7 @@ using CounterStrikeSharp.API.Modules.Admin; using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Timers; +using CounterStrikeSharp.API.Modules.UserMessages; using CounterStrikeSharp.API.Modules.Utils; using CounterStrikeSharp.API.ValveConstants.Protobuf; using CS2_SimpleAdminApi; @@ -22,7 +23,7 @@ namespace AdminStealth; public class AdminStealth : BasePlugin { public override string ModuleName => "AdminStealth"; - public override string ModuleVersion => "0.2.0"; + public override string ModuleVersion => "0.3.12"; public override string ModuleAuthor => "astra"; public override string ModuleDescription => "Makes CS2-SimpleAdmin's css_hide look like leaving, and keeps it"; @@ -30,6 +31,11 @@ public class AdminStealth : BasePlugin private const string HidePermission = "@css/kick"; private const float RestoreDelay = 2f; private const int RestoreAttempts = 5; + // How soon after connecting a hidden admin is moved to spectators, past the team select screen. + private const float SkipTeamSelectDelay = 0.1f; + // CS_UM_VGUIMenu: how the server opens the team select screen, as name "team". CS2 opens it + // whatever the message's "show" says (the server doesn't even set it), so it can't close one. + private const int VguiMenuMessage = 301; private static readonly PluginCapability SimpleAdminCapability = new("simpleadmin:api"); private ICS2_SimpleAdminApi? _simpleAdmin; @@ -54,12 +60,19 @@ public class AdminStealth : BasePlugin { LoadHidden(); _hideAliases = ReadHideAliases(); + // Each listener needs its own delegate: CSS keys a plugin's listeners by handler, so adding + // the same method twice orphans the first registration, which then blocks its command + // without ever calling us. foreach (var alias in _hideAliases) - AddCommandListener(alias, OnHideTyped); + AddCommandListener(alias, (player, command) => OnHideTyped(player, command)); // Joining any team, spectators included, would make SimpleAdmin unhide them. - AddCommandListener("jointeam", OnJoinTeam); - AddCommandListener("spectate", OnJoinTeam); + AddCommandListener("jointeam", (player, command) => OnJoinTeam(player, command)); + AddCommandListener("spectate", (player, command) => OnJoinTeam(player, command)); + // M. Hidden admins have no team, and the game won't let a player with no team close the + // team screen without picking one, so don't let it open. + AddCommandListener("teammenu", OnTeamMenu); AddCommandListener("status", OnStatus); + HookUserMessage(VguiMenuMessage, OnVguiMenu); RegisterListener(_ => { @@ -121,9 +134,67 @@ public class AdminStealth : BasePlugin return HookResult.Continue; player!.PrintToChat($" You're hidden, so you can't join a team. Type {ChatAlias()} to show yourself."); + + // M opens the team screen on the client, where the server can't block it, and a player with + // no team (as hidden admins are) can't close it without picking. Answer the pick by telling + // only their client they joined spectators, with the team screen disabled around it, so + // nobody else ever sees them on a team. + var slot = player.Slot; + Server.ExecuteCommand("sv_disable_teamselect_menu 1"); + AddTimer(0.3f, () => + { + var p = Utilities.GetPlayerFromSlot(slot); + if (p is { IsValid: true } && IsHidden(p)) + SendTeamEvent(p, (int)CsTeam.Spectator, p.TeamNum); + }, TimerFlags.STOP_ON_MAPCHANGE); + AddTimer(0.6f, () => Server.ExecuteCommand("sv_disable_teamselect_menu 0"), TimerFlags.STOP_ON_MAPCHANGE); return HookResult.Stop; } + private HookResult OnTeamMenu(CCSPlayerController? player, CommandInfo command) + { + if (!IsHidden(player)) + return HookResult.Continue; + + Logger.LogInformation("Blocked teammenu from hidden admin {Name}", player!.PlayerName); + player.PrintToChat($" You're hidden, so you can't join a team. Type {ChatAlias()} to show yourself."); + return HookResult.Stop; + } + + // Keeps the team select screen from ever opening for a hidden admin. + private HookResult OnVguiMenu(UserMessage message) + { + try + { + if (message.ReadString("name") != "team") + return HookResult.Continue; + } + catch (Exception ex) + { + WarnMenuOnce($"Couldn't read the team menu message ({message.DebugString}): {ex.Message}"); + return HookResult.Continue; + } + + foreach (var player in message.Recipients.ToList()) + { + if (!IsHidden(player)) + continue; + message.Recipients.Remove(player); + Logger.LogInformation("Kept the team menu from hidden admin {Name}", player.PlayerName); + } + return HookResult.Continue; + } + + private bool _menuWarned; + + private void WarnMenuOnce(string warning) + { + if (_menuWarned) + return; + _menuWarned = true; + Logger.LogWarning("{Warning}", warning); + } + private void OnAdminToggleSilent(int slot, bool hidden) { // The typed command's listener may run before or after SimpleAdmin's handler, which fires @@ -216,6 +287,18 @@ public class AdminStealth : BasePlugin return; } + // css_hide toggles, so running it again after it didn't seem to take could unhide them. That + // happens when SimpleAdmin was hot-reloaded: CSS keeps handing out the old instance's API. + if (_restoring.Contains(slot)) + { + _restoreScheduled.Remove(slot); + _restoring.Remove(slot); + Logger.LogWarning("Ran css_hide for {Name} ({SteamId}) but SimpleAdmin doesn't report them hidden. " + + "If CS2-SimpleAdmin was reloaded without a server restart, restart the server.", + player.PlayerName, steamId); + return; + } + if (attempt > RestoreAttempts) { _restoreScheduled.Remove(slot); @@ -269,6 +352,21 @@ public class AdminStealth : BasePlugin info.DontBroadcast = true; ScheduleRestore(player, RestoreDelay); + + // Put them in spectators straight away so they skip the team select screen, rather than + // waiting for the restore. SimpleAdmin doesn't count them as hidden yet, so this doesn't + // unhide them; the restore then moves them from spectators to hidden. + var slot = player.Slot; + var steamId = player.SteamID; + AddTimer(SkipTeamSelectDelay, () => + { + var p = Utilities.GetPlayerFromSlot(slot); + if (p is { IsValid: true } && p.SteamID == steamId && _hidden.Contains(steamId) && + p.TeamNum == (byte)CsTeam.None && _simpleAdmin?.IsAdminSilent(p) != true) + { + p.ChangeTeam(CsTeam.Spectator); + } + }, TimerFlags.STOP_ON_MAPCHANGE); return HookResult.Continue; } @@ -280,11 +378,29 @@ public class AdminStealth : BasePlugin return HookResult.Continue; info.DontBroadcast = true; + // Still tell the admin themselves: their client closes the team screen on it. + SendTeamEvent(player!, @event.Team, @event.Oldteam); if (@event.Team > (int)CsTeam.Spectator && _hidden.Contains(player!.SteamID)) ScheduleRestore(player, 0.1f); return HookResult.Continue; } + private static void SendTeamEvent(CCSPlayerController player, int team, int oldTeam) + { + var copy = new EventPlayerTeam(true) + { + Userid = player, + Team = team, + Oldteam = oldTeam, + Disconnect = false, + Silent = true, + Isbot = false, + Name = player.PlayerName, + }; + copy.FireEventToClient(player); + copy.Free(); + } + [GameEventHandler(HookMode.Pre)] public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) { diff --git a/README.md b/README.md index 8c921f7..4c4e7e6 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,17 @@ admin off the scoreboard. This plugin makes it look like they left the server: - A hidden admin can't join a team or go to spectators (`jointeam` and `spectate` are blocked with a chat message). If the game or another plugin puts them on a team anyway, which makes SimpleAdmin unhide them, they're hidden again straight away. +- On reconnect or map change, a hidden admin is moved to spectators 0.1 s after connecting, so + the team select screen doesn't open, until the restore hides them again. +- The team select screen: the server's "team" menu message (`CS_UM_VGUIMenu`) is never sent to + a hidden admin. M opens it on the client regardless, and a player with no team can't close it + without picking, so a pick is answered by telling only that client it joined spectators + (with `sv_disable_teamselect_menu` on around it); nobody else sees a team change. +- Restoring runs `css_hide` at most once: it toggles, so a second run could unhide them. + +Needs our SimpleAdmin fork (1.8.2b-zio2 or later), where moving to spectators doesn't unhide a +hidden admin. Never hot-reload CS2-SimpleAdmin while this runs: CSS keeps handing out the old +instance's API until the server restarts. - `status` from a player's console is blocked and prints nothing. It still works from the server console and RCON. @@ -37,8 +48,8 @@ the `status` block works. - Unhiding doesn't announce the admin as joining again. - The server browser and trackers get the player list from a separate server query, which this doesn't touch, so they still list a hidden admin. -- Reloading CS2-SimpleAdmin on its own leaves this plugin listening to the old instance. Reload - AdminStealth after it. +- Reloading CS2-SimpleAdmin on its own leaves this plugin (and WebPanelBridge) talking to the old + instance until a server restart. ## Install