From 4c74c04916e94b0fceb312df8e9fef44d8a6808f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:18:39 +0000 Subject: [PATCH 1/3] Add a server setting that turns off typed console commands Gameplay:bAllowConsoleCommands (default true, so existing servers are unchanged; ToggleConsoleCommands switches it at runtime) is sent to clients with the other server settings. When it is false, the client refuses lines a player types into the console and sends with Enter, and prints a note in the console instead. The console still opens, so clicking a reference to read its id keeps working, and commands run by code (ConsoleUtil, Papyrus ExecuteCommand, the F2 chat /wave) are untouched. Stops trolls spawning enemies in town or killing NPCs from the console. The hook is the Console menu's ExecuteCommand callback (51084), which only builds and queues a script from the typed line. Checked in the 1.6.1170 and 1.7.104 databases: 51083 registers it, 51084 reads the line from args->pArgs[0] and queues it without running it, 51109 is ConsoleLog::Print and 401203 the ConsoleLog singleton. Not tested in game yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxNiMZ7kPLL1PGMuxYo --- .../Skyrim/Interface/ConsoleCommandGuard.cpp | 96 +++++++++++++++++++ Code/encoding/Structs/ServerSettings.cpp | 4 +- Code/encoding/Structs/ServerSettings.h | 1 + Code/server/GameServer.cpp | 11 +++ 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 Code/client/Games/Skyrim/Interface/ConsoleCommandGuard.cpp diff --git a/Code/client/Games/Skyrim/Interface/ConsoleCommandGuard.cpp b/Code/client/Games/Skyrim/Interface/ConsoleCommandGuard.cpp new file mode 100644 index 0000000000..0bf7cf3fef --- /dev/null +++ b/Code/client/Games/Skyrim/Interface/ConsoleCommandGuard.cpp @@ -0,0 +1,96 @@ +#include + +#include + +// Lets a server turn off console commands that players type and send with Enter (Gameplay:bAllowConsoleCommands). +// The console still opens, so clicking a reference to read its id keeps working, and commands that code runs +// (ConsoleUtil, Papyrus ExecuteCommand, the F2 chat /wave) are untouched: none of them go through this callback. +// +// Engine notes, checked in the 1.6.1170 and 1.7.104 databases (same bodies, only the addresses differ): +// - 51083 is the Console menu's FxDelegate Accept; it registers "ExecuteCommand" -> 51084. +// - 51084 is that callback, void(const FxDelegateArgs&). It builds a Script from the typed line (Script::SetText, +// 21883) and queues a task that compiles and runs it later. It echoes nothing and keeps no history itself. +// - 51109 is ConsoleLog::Print(ConsoleLog*, const char* fmt, ...), 401203 the ConsoleLog* singleton. + +namespace +{ +// Minimal Scaleform layouts used by the callback (CommonLibSSE-NG GFxValue / FxDelegateArgs). +struct GFxValueRaw +{ + void* pObjectInterface; // 00 + uint32_t type; // 08 + uint32_t pad0C; // 0C + void* pValue; // 10 +}; +static_assert(sizeof(GFxValueRaw) == 0x18); + +struct FxDelegateArgsRaw +{ + GFxValueRaw responseId; // 00 + void* pHandler; // 18 + void* pMovie; // 20 + const GFxValueRaw* pArgs; // 28 + uint32_t numArgs; // 30 +}; +static_assert(offsetof(FxDelegateArgsRaw, pArgs) == 0x28); + +constexpr uint32_t kGFxTypeString = 4; +constexpr uint32_t kGFxManagedBit = 0x40; + +const char* GetTypedLine(const FxDelegateArgsRaw& acArgs) noexcept +{ + if (!acArgs.pArgs || acArgs.numArgs == 0) + return nullptr; + + const GFxValueRaw& cValue = acArgs.pArgs[0]; + if ((cValue.type & 0x8F) != kGFxTypeString || !cValue.pValue) + return nullptr; + + // A managed string holds a pointer to the string's data pointer. + if (cValue.type & kGFxManagedBit) + return *static_cast(cValue.pValue); + + return static_cast(cValue.pValue); +} + +void PrintToConsole(const char* acpText) noexcept +{ + using TPrint = void(void*, const char*, ...); + POINTER_SKYRIMSE(TPrint, s_print, 51109); + POINTER_SKYRIMSE(void*, s_consoleLog, 401203); + + void* pConsoleLog = *s_consoleLog.Get(); + if (pConsoleLog) + s_print.Get()(pConsoleLog, "%s", acpText); +} + +bool AreTypedCommandsBlocked() noexcept +{ + World& world = World::Get(); + return world.GetTransport().IsConnected() && !world.GetServerSettings().ConsoleCommandsAllowed; +} +} // namespace + +using TExecuteCommand = void(const FxDelegateArgsRaw&); +static TExecuteCommand* RealExecuteCommand = nullptr; + +static void HookExecuteCommand(const FxDelegateArgsRaw& acArgs) +{ + if (!AreTypedCommandsBlocked()) + { + RealExecuteCommand(acArgs); + return; + } + + const char* cpLine = GetTypedLine(acArgs); + spdlog::info("Console command refused, the server has turned console commands off: {}", cpLine ? cpLine : ""); + PrintToConsole("Console commands are turned off on this server."); +} + +static TiltedPhoques::Initializer s_consoleCommandGuardHooks( + []() + { + POINTER_SKYRIMSE(TExecuteCommand, s_executeCommand, 51084); + RealExecuteCommand = s_executeCommand.Get(); + TP_HOOK(&RealExecuteCommand, HookExecuteCommand); + }); diff --git a/Code/encoding/Structs/ServerSettings.cpp b/Code/encoding/Structs/ServerSettings.cpp index bdf8f77adc..8748465b70 100644 --- a/Code/encoding/Structs/ServerSettings.cpp +++ b/Code/encoding/Structs/ServerSettings.cpp @@ -5,7 +5,7 @@ using TiltedPhoques::Serialization; bool ServerSettings::operator==(const ServerSettings& acRhs) const noexcept { - return Difficulty == acRhs.Difficulty && GreetingsEnabled == acRhs.GreetingsEnabled && PvpEnabled == acRhs.PvpEnabled && SyncPlayerHomes == acRhs.SyncPlayerHomes && DeathSystemEnabled == acRhs.DeathSystemEnabled && AutoPartyJoin == acRhs.AutoPartyJoin; + return Difficulty == acRhs.Difficulty && GreetingsEnabled == acRhs.GreetingsEnabled && PvpEnabled == acRhs.PvpEnabled && SyncPlayerHomes == acRhs.SyncPlayerHomes && DeathSystemEnabled == acRhs.DeathSystemEnabled && AutoPartyJoin == acRhs.AutoPartyJoin && ConsoleCommandsAllowed == acRhs.ConsoleCommandsAllowed; } bool ServerSettings::operator!=(const ServerSettings& acRhs) const noexcept @@ -22,6 +22,7 @@ void ServerSettings::Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noe Serialization::WriteBool(aWriter, DeathSystemEnabled); Serialization::WriteBool(aWriter, SyncPlayerCalendar); Serialization::WriteBool(aWriter, AutoPartyJoin); + Serialization::WriteBool(aWriter, ConsoleCommandsAllowed); } void ServerSettings::Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept @@ -33,4 +34,5 @@ void ServerSettings::Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcep DeathSystemEnabled = Serialization::ReadBool(aReader); SyncPlayerCalendar = Serialization::ReadBool(aReader); AutoPartyJoin = Serialization::ReadBool(aReader); + ConsoleCommandsAllowed = Serialization::ReadBool(aReader); } diff --git a/Code/encoding/Structs/ServerSettings.h b/Code/encoding/Structs/ServerSettings.h index 02a49302fe..90bc5a8540 100644 --- a/Code/encoding/Structs/ServerSettings.h +++ b/Code/encoding/Structs/ServerSettings.h @@ -17,4 +17,5 @@ struct ServerSettings bool DeathSystemEnabled{}; bool SyncPlayerCalendar{}; bool AutoPartyJoin{}; + bool ConsoleCommandsAllowed{true}; }; diff --git a/Code/server/GameServer.cpp b/Code/server/GameServer.cpp index de5e917934..35b57805b0 100644 --- a/Code/server/GameServer.cpp +++ b/Code/server/GameServer.cpp @@ -41,6 +41,7 @@ Console::Setting bEnableDeathSystem{"Gameplay:bEnableDeathSystem", "Enables the Console::Setting uTimeScale{"Gameplay:uTimeScale", "How many seconds pass ingame for every real second (0 to 1000). Changing this can make the game unstable", 20u}; Console::Setting bSyncPlayerCalendar{"Gameplay:bSyncPlayerCalendar", "Syncs up all player calendars to be the same day, month, and year. This uses the date of the player with the furthest ahead date at connection.", false}; Console::Setting bAutoPartyJoin{"Gameplay:bAutoPartyJoin", "Join parties automatically, as long as there is only one party in the server", true}; +Console::Setting bAllowConsoleCommands{"Gameplay:bAllowConsoleCommands", "Lets players run console commands they type while connected. Turn it off to stop console trolling such as spawning enemies or killing NPCs. The console still opens for reading ids, and mods can still run commands", true}; // ModPolicy Stuff Console::Setting bEnableModCheck{"ModPolicy:bEnableModCheck", "Refuse clients whose mods do not match Data/loadorder.txt", false, Console::SettingsFlags::kLocked}; Console::Setting bAllowSKSE{"ModPolicy:bAllowSKSE", "Allow players using SKSE to join. Applies even when bEnableModCheck is off", true, Console::SettingsFlags::kLocked}; @@ -64,6 +65,15 @@ Console::Command<> TogglePvp( GameServer::Get()->UpdateSettings(); }); +Console::Command<> ToggleConsoleCommands( + "ToggleConsoleCommands", "Toggle whether players can run console commands they type", + [](Console::ArgStack&) + { + bAllowConsoleCommands = !bAllowConsoleCommands; + spdlog::get("ConOut")->info("Typed console commands have been {}.", bAllowConsoleCommands == true ? "allowed" : "turned off"); + GameServer::Get()->UpdateSettings(); + }); + Console::Command SetDifficulty( "SetDifficulty", "Set server difficulty (0 being Novice and 5 being Legendary; default is 4)", [](Console::ArgStack& aStack) @@ -141,6 +151,7 @@ ServerSettings GetSettings() settings.DeathSystemEnabled = bEnableDeathSystem; settings.SyncPlayerCalendar = bSyncPlayerCalendar; settings.AutoPartyJoin = bAutoPartyJoin; + settings.ConsoleCommandsAllowed = bAllowConsoleCommands; return settings; } From ba5a0097e6b5e45c99e853b2bb1d0b85754822c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:16:58 +0000 Subject: [PATCH 2/3] Address review on the console command guard - Shorten the comments and drop the mention of an unmerged feature. - Type the ExecuteCommand callback as void(const FxDelegateArgs*). - Replace the ported CommonLibSSE-NG structs with GFxValue and FxDelegateArgs in IMenu.h, laid out after powerof3's CommonLibSSE, and use them for FxDelegateHandler::CallbackFn. - Keep tcl, tfc, tgm, tm and coc (and their full names) allowed while typed commands are turned off. A reference prefix is still refused. World has no const getter, so World::Get() stays as it is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxR8LzniCZWnKRhyEtP --- .../Skyrim/Interface/ConsoleCommandGuard.cpp | 97 +++++++++++-------- Code/client/Games/Skyrim/Interface/IMenu.h | 64 +++++++++++- 2 files changed, 121 insertions(+), 40 deletions(-) diff --git a/Code/client/Games/Skyrim/Interface/ConsoleCommandGuard.cpp b/Code/client/Games/Skyrim/Interface/ConsoleCommandGuard.cpp index 0bf7cf3fef..e6cc3ca05b 100644 --- a/Code/client/Games/Skyrim/Interface/ConsoleCommandGuard.cpp +++ b/Code/client/Games/Skyrim/Interface/ConsoleCommandGuard.cpp @@ -1,56 +1,69 @@ #include #include +#include -// Lets a server turn off console commands that players type and send with Enter (Gameplay:bAllowConsoleCommands). -// The console still opens, so clicking a reference to read its id keeps working, and commands that code runs -// (ConsoleUtil, Papyrus ExecuteCommand, the F2 chat /wave) are untouched: none of them go through this callback. +// Refuses typed console commands while connected to a server with Gameplay:bAllowConsoleCommands off. +// Commands run by code (ConsoleUtil, Papyrus ExecuteCommand) don't go through this callback. // -// Engine notes, checked in the 1.6.1170 and 1.7.104 databases (same bodies, only the addresses differ): -// - 51083 is the Console menu's FxDelegate Accept; it registers "ExecuteCommand" -> 51084. -// - 51084 is that callback, void(const FxDelegateArgs&). It builds a Script from the typed line (Script::SetText, -// 21883) and queues a task that compiles and runs it later. It echoes nothing and keeps no history itself. -// - 51109 is ConsoleLog::Print(ConsoleLog*, const char* fmt, ...), 401203 the ConsoleLog* singleton. +// 51084 is the Console menu's "ExecuteCommand" callback (registered by 51083). It queues the typed line as a +// Script to be compiled and run later. 51109 is ConsoleLog::Print, 401203 the ConsoleLog singleton. namespace { -// Minimal Scaleform layouts used by the callback (CommonLibSSE-NG GFxValue / FxDelegateArgs). -struct GFxValueRaw -{ - void* pObjectInterface; // 00 - uint32_t type; // 08 - uint32_t pad0C; // 0C - void* pValue; // 10 +// Commands that stay allowed while typed commands are turned off, by short and full name. +constexpr std::string_view kAllowedCommands[]{ + "tcl", "ToggleCollision", + "tfc", "ToggleFreeCamera", + "tgm", "ToggleGodMode", + "tm", "ToggleMenus", + "coc", "CenterOnCell", }; -static_assert(sizeof(GFxValueRaw) == 0x18); -struct FxDelegateArgsRaw +bool EqualsIgnoreCase(std::string_view aLhs, std::string_view aRhs) noexcept { - GFxValueRaw responseId; // 00 - void* pHandler; // 18 - void* pMovie; // 20 - const GFxValueRaw* pArgs; // 28 - uint32_t numArgs; // 30 -}; -static_assert(offsetof(FxDelegateArgsRaw, pArgs) == 0x28); + return aLhs.size() == aRhs.size() && _strnicmp(aLhs.data(), aRhs.data(), aLhs.size()) == 0; +} -constexpr uint32_t kGFxTypeString = 4; -constexpr uint32_t kGFxManagedBit = 0x40; +bool IsSpace(char aChar) noexcept +{ + return aChar == ' ' || aChar == '\t'; +} -const char* GetTypedLine(const FxDelegateArgsRaw& acArgs) noexcept +// Only the command name is checked. A reference prefix ("player.tcl") isn't allowed, so the command can't be +// aimed at another reference. +bool IsAllowedCommand(std::string_view aLine) noexcept { - if (!acArgs.pArgs || acArgs.numArgs == 0) - return nullptr; + if (aLine.find_first_of("\r\n") != std::string_view::npos) + return false; + + while (!aLine.empty() && IsSpace(aLine.front())) + aLine.remove_prefix(1); + + size_t nameLength = 0; + while (nameLength < aLine.size() && !IsSpace(aLine[nameLength])) + ++nameLength; - const GFxValueRaw& cValue = acArgs.pArgs[0]; - if ((cValue.type & 0x8F) != kGFxTypeString || !cValue.pValue) + const std::string_view cName = aLine.substr(0, nameLength); + for (const std::string_view cAllowed : kAllowedCommands) + { + if (EqualsIgnoreCase(cName, cAllowed)) + return true; + } + + return false; +} + +const char* GetTypedLine(const FxDelegateArgs* apArgs) noexcept +{ + if (!apArgs || !apArgs->pArgs || apArgs->GetArgCount() == 0) return nullptr; - // A managed string holds a pointer to the string's data pointer. - if (cValue.type & kGFxManagedBit) - return *static_cast(cValue.pValue); + const GFxValue& cValue = (*apArgs)[0]; + if (!cValue.IsString() || !cValue.value.pObject) + return nullptr; - return static_cast(cValue.pValue); + return cValue.GetString(); } void PrintToConsole(const char* acpText) noexcept @@ -71,18 +84,24 @@ bool AreTypedCommandsBlocked() noexcept } } // namespace -using TExecuteCommand = void(const FxDelegateArgsRaw&); +using TExecuteCommand = void(const FxDelegateArgs* apArgs); static TExecuteCommand* RealExecuteCommand = nullptr; -static void HookExecuteCommand(const FxDelegateArgsRaw& acArgs) +static void HookExecuteCommand(const FxDelegateArgs* apArgs) { if (!AreTypedCommandsBlocked()) { - RealExecuteCommand(acArgs); + RealExecuteCommand(apArgs); + return; + } + + const char* cpLine = GetTypedLine(apArgs); + if (cpLine && IsAllowedCommand(cpLine)) + { + RealExecuteCommand(apArgs); return; } - const char* cpLine = GetTypedLine(acArgs); spdlog::info("Console command refused, the server has turned console commands off: {}", cpLine ? cpLine : ""); PrintToConsole("Console commands are turned off on this server."); } diff --git a/Code/client/Games/Skyrim/Interface/IMenu.h b/Code/client/Games/Skyrim/Interface/IMenu.h index d472b4f73b..5fa90b767c 100644 --- a/Code/client/Games/Skyrim/Interface/IMenu.h +++ b/Code/client/Games/Skyrim/Interface/IMenu.h @@ -76,9 +76,71 @@ struct GStatGroups }; }; +// Layout from powerof3's CommonLibSSE (RE/G/GFxValue.h). +struct GFxValue +{ + enum ValueType : uint32_t + { + kUndefined = 0x00, + kNull = 0x01, + kBoolean = 0x02, + kNumber = 0x03, + kString = 0x04, + kStringW = 0x05, + kObject = 0x06, + kArray = 0x07, + kDisplayObject = 0x08, + + kManagedBit = 1 << 6, + kConvertBit = 1 << 7, + kValueMask = 0x0F, + kTypeMask = kConvertBit | kValueMask, + }; + + [[nodiscard]] uint32_t GetType() const noexcept { return type & kTypeMask; } + [[nodiscard]] bool IsString() const noexcept { return GetType() == kString; } + [[nodiscard]] const char* GetString() const noexcept { return (type & kManagedBit) ? *value.pManagedString : value.pString; } + + union ValueUnion + { + double number; + bool boolean; + const char* pString; + const char** pManagedString; + const wchar_t* pWideString; + const wchar_t** pManagedWideString; + void* pObject; + }; + static_assert(sizeof(ValueUnion) == 0x8); + + void* pObjectInterface; // 00 + uint32_t type; // 08 + uint32_t pad0C; // 0C + ValueUnion value; // 10 +}; +static_assert(sizeof(GFxValue) == 0x18); + +struct FxDelegateHandler; + +// Layout from powerof3's CommonLibSSE (RE/F/FxDelegateArgs.h). +struct FxDelegateArgs +{ + [[nodiscard]] uint32_t GetArgCount() const noexcept { return numArgs; } + [[nodiscard]] const GFxValue& operator[](uint32_t aIndex) const noexcept { return pArgs[aIndex]; } + + GFxValue responseId; // 00 + FxDelegateHandler* pHandler; // 18 + void* pMovieView; // 20 + const GFxValue* pArgs; // 28 + uint32_t numArgs; // 30 + uint32_t pad34; // 34 +}; +static_assert(offsetof(FxDelegateArgs, pArgs) == 0x28); +static_assert(sizeof(FxDelegateArgs) == 0x38); + struct FxDelegateHandler : GRefCountBase { - using CallbackFn = void(const void*); + using CallbackFn = void(const FxDelegateArgs* apArgs); class CallbackProcessor { From 8218d707b8e9fd66550a43934adbfdc91fbe3543 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:02:00 +0000 Subject: [PATCH 3/3] Keep tgm and tcl blocked when typed commands are off tgm makes a player invulnerable, which breaks PvP, and tcl lets a player walk through walls and breaks enemy pathing. Players who are stuck can use fast travel, teleport to a party member, or the Become Ethereal shout instead. tfc, tm and coc stay allowed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxR8LzniCZWnKRhyEtP --- .../Games/Skyrim/Interface/ConsoleCommandGuard.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Code/client/Games/Skyrim/Interface/ConsoleCommandGuard.cpp b/Code/client/Games/Skyrim/Interface/ConsoleCommandGuard.cpp index e6cc3ca05b..986b6ba3b2 100644 --- a/Code/client/Games/Skyrim/Interface/ConsoleCommandGuard.cpp +++ b/Code/client/Games/Skyrim/Interface/ConsoleCommandGuard.cpp @@ -11,11 +11,14 @@ namespace { -// Commands that stay allowed while typed commands are turned off, by short and full name. +// Commands that stay allowed while typed commands are turned off, by short and full name. They help players +// get past things that block a playthrough without affecting other players. +// +// tgm and tcl stay blocked: tgm makes a player invulnerable in PvP, and tcl lets a player walk through walls +// and breaks enemy pathing. The game already offers fair alternatives: fast travel, teleporting to a party +// member, and the Become Ethereal shout. constexpr std::string_view kAllowedCommands[]{ - "tcl", "ToggleCollision", "tfc", "ToggleFreeCamera", - "tgm", "ToggleGodMode", "tm", "ToggleMenus", "coc", "CenterOnCell", };