From 2cc77c577e9dc916e408f9a760f5a46a4ad08372 Mon Sep 17 00:00:00 2001 From: fantasai Date: Tue, 25 Aug 2026 16:56:37 +0800 Subject: [PATCH] init --- AGENTS.md | 79 +++ BUILD.md | 65 ++ CMakeLists.txt | 52 ++ INSTALL.md | 83 +++ README.md | 90 +++ include/CommandQueue.hpp | 37 + include/Config.hpp | 34 + include/OfflineAdminExecutor.hpp | 52 ++ include/RconServer.hpp | 58 ++ include/RealChatDispatcher.hpp | 46 ++ include/ScumBridge.hpp | 49 ++ include/SimpleRconMod.hpp | 37 + src/CommandQueue.cpp | 78 +++ src/OfflineAdminExecutor.cpp | 859 ++++++++++++++++++++++++ src/RconServer.cpp | 368 ++++++++++ src/RealChatDispatcher.cpp | 742 ++++++++++++++++++++ src/ScumBridge.cpp | 319 +++++++++ src/SimpleRconMod.cpp | 238 +++++++ src/dllmain.cpp | 14 + ue4ss/Mods/mods.txt | 2 + ue4ss/Mods/scum_simple_rcon/config.ini | 36 + ue4ss/Mods/scum_simple_rcon/enabled.txt | 1 + 22 files changed, 3339 insertions(+) create mode 100644 AGENTS.md create mode 100644 BUILD.md create mode 100644 CMakeLists.txt create mode 100644 INSTALL.md create mode 100644 README.md create mode 100644 include/CommandQueue.hpp create mode 100644 include/Config.hpp create mode 100644 include/OfflineAdminExecutor.hpp create mode 100644 include/RconServer.hpp create mode 100644 include/RealChatDispatcher.hpp create mode 100644 include/ScumBridge.hpp create mode 100644 include/SimpleRconMod.hpp create mode 100644 src/CommandQueue.cpp create mode 100644 src/OfflineAdminExecutor.cpp create mode 100644 src/RconServer.cpp create mode 100644 src/RealChatDispatcher.cpp create mode 100644 src/ScumBridge.cpp create mode 100644 src/SimpleRconMod.cpp create mode 100644 src/dllmain.cpp create mode 100644 ue4ss/Mods/mods.txt create mode 100644 ue4ss/Mods/scum_simple_rcon/config.ini create mode 100644 ue4ss/Mods/scum_simple_rcon/enabled.txt diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..295d962 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,79 @@ +# Development guide + +This repository is a UE4SS C++ mod for a SCUM dedicated server. Its purpose is +to provide a password-protected Source RCON endpoint while keeping all Unreal +object work on the game's thread. + +## Runtime architecture + +```text +RCON TCP worker + -> CommandQueue + -> EngineTick game-thread drain + -> ScumBridge + -> RealChatDispatcher (ordinary SendChat) + -> OfflineAdminExecutor (AdminCommand path) + -> server console fallback (non-AdminCommand console commands) +``` + +- `RconServer` owns sockets, Source-RCON framing, authentication, and response + splitting. It must never create Unreal objects or call Unreal APIs. +- `CommandQueue` is the sole boundary between RCON threads and the game thread. + Keep all `UObject` lookup, `ProcessEvent`, object creation, and command + execution inside its EngineTick drain. +- `SimpleRconMod` exposes the internal diagnostics: `rcon.status`, + `rcon.admins`, `rcon.dispatch`, and `rcon.chat`. +- `ScumBridge` routes `SendChat` before checking `AdminUsers.ini`; all other + game commands follow the admin/offline-dispatch route. + +## Admin-command route + +- `OfflineAdminExecutor` may create and reuse the synthetic + `BP_ConZPlayerController` and `ConZCharacter`. This exists only so native + SCUM `AdminCommand` execution can run with no human administrator online. +- Read valid administrator SteamID64 values from + `SCUM/Saved/Config/WindowsServer/AdminUsers.ini`. Never hard-code one and + never substitute an arbitrary connected player or `PlayerRpcChannel`. +- The selected ID must come from that file (the optional preferred ID is valid + only when also present there). Refresh a cached synthetic caller if the file + changes. +- Resolve the native executor only through its exact, build-specific signature. + On zero or multiple matches, fail closed and report the problem; do not call a + guessed address or weaken the game's authorization check. + +## Ordinary-chat route + +`SendChat "message" [SteamID64]` is deliberately not an +`AdminCommand`. + +- It requires RCON authentication, but not `AdminUsers.ini`. +- Resolve SCUM's real server-side entry point at runtime: + `MiscStatics:SendChatLineToPlayer`, its `Default__MiscStatics`, and + `ConZPlayerController:GetUserId`. +- A targeted send must match the supplied SteamID64 against an actual loaded + `ConZPlayerController` whose inherited `Player` is a live `UNetConnection`. + A missing/offline target is an error. +- A broadcast is a separate send to each such real online controller. `type` + controls the SCUM chat style; recipient scope comes from the optional + SteamID64, not from `type`. +- Do not construct a chat controller, fabricate a network/RPC channel, use the + first `PlayerRpcChannel`, or impersonate a player. The SCUM helper receives + the real target controller and uses the game's own delivery path. +- Inspect the UFunction parameter schema through reflection before calling it. + If SCUM changes the schema, reject the command and log the discovered schema + instead of guessing parameter offsets. + +## Change and test rules + +- Keep command parsing and messages UTF-8-safe. Quoted RCON arguments contain + spaces; do not append shell-style inline comments to command examples. +- Keep `SendChat` semantics separate from notifications and admin commands. + An administrator may be offline; a chat recipient cannot be offline. +- Update `README.md` and `INSTALL.md` with every externally visible behavior + change. Describe what this repository does; do not retain stale feature + comparison or proprietary-bundle claims. +- Before committing, run `git diff --check`. On a Windows server built against + the deployed UE4SS ABI, test `rcon.status`, `rcon.admins`, `rcon.dispatch`, + `rcon.chat`, one targeted `SendChat`, and one broadcast `SendChat`. +- macOS-only checks cannot validate the final DLL/SCUM ABI. Do not claim a + Windows runtime test passed until it has run on the target server. diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..4a18a15 --- /dev/null +++ b/BUILD.md @@ -0,0 +1,65 @@ +# Build + +UE4SS C++ mods need to be built with the same UE4SS source/ABI line as the runtime you deploy with. Build this beside a working RE-UE4SS checkout. + +## Folder layout + +```text +MyMods/ + CMakeLists.txt + RE-UE4SS/ + scum_simple_rcon_ue4ss/ +``` + +`MyMods/CMakeLists.txt`: + +```cmake +cmake_minimum_required(VERSION 3.22) +project(MyMods) + +add_subdirectory(RE-UE4SS) +add_subdirectory(scum_simple_rcon_ue4ss) +``` + +## Configure and build + +Visual Studio: + +```bat +cmake -B build -G "Visual Studio 17 2022" +cmake --build build --config Game__Shipping__Win64 --target scum_simple_rcon +``` + +Ninja: + +```bat +cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Game__Shipping__Win64 +cmake --build build --target scum_simple_rcon +``` + +Output package: + +```text +build/package/ue4ss/Mods/scum_simple_rcon/ + config.ini + enabled.txt + dlls/main.dll +``` + +If your UE4SS source checkout cannot initialize `deps/first/Unreal`, link your GitHub account to Epic Games and rerun: + +```bat +git submodule update --init --recursive +``` + +Required UE4SS hooks/settings: + +```ini +[Hooks] +HookEngineTick = 1 +HookUObjectProcessEvent = 1 +``` + +`HookEngineTick` is mandatory because RCON worker threads must never create +UObjects or call the SCUM command executor themselves. The mod performs that +work only from the game-thread drain callback. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..b3e8fc0 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,52 @@ +cmake_minimum_required(VERSION 3.22) + +set(TARGET scum_simple_rcon) +project(${TARGET} LANGUAGES CXX) + +add_library(${TARGET} SHARED + src/dllmain.cpp + src/SimpleRconMod.cpp + src/RconServer.cpp + src/CommandQueue.cpp + src/ScumBridge.cpp + src/OfflineAdminExecutor.cpp + src/RealChatDispatcher.cpp +) + +target_compile_features(${TARGET} PRIVATE cxx_std_20) +target_include_directories(${TARGET} PRIVATE include) + +target_compile_definitions(${TARGET} PRIVATE + WIN32_LEAN_AND_MEAN + NOMINMAX + _WINSOCK_DEPRECATED_NO_WARNINGS +) + +if(MSVC) + target_compile_options(${TARGET} PRIVATE /permissive- /EHsc /bigobj) +endif() + +if(NOT TARGET UE4SS) + message(FATAL_ERROR + "UE4SS target not found. Build this as a sibling project next to RE-UE4SS, " + "or add_subdirectory(RE-UE4SS) before add_subdirectory(scum_simple_rcon_ue4ss).") +endif() + +target_link_libraries(${TARGET} PUBLIC UE4SS) +target_link_libraries(${TARGET} PRIVATE ws2_32) + +set_target_properties(${TARGET} PROPERTIES + OUTPUT_NAME "main" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/package/ue4ss/Mods/scum_simple_rcon/dlls" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/package/ue4ss/Mods/scum_simple_rcon/dlls" +) + +add_custom_command(TARGET ${TARGET} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/package/ue4ss/Mods/scum_simple_rcon" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/ue4ss/Mods/scum_simple_rcon/config.ini" + "${CMAKE_BINARY_DIR}/package/ue4ss/Mods/scum_simple_rcon/config.ini" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/ue4ss/Mods/scum_simple_rcon/enabled.txt" + "${CMAKE_BINARY_DIR}/package/ue4ss/Mods/scum_simple_rcon/enabled.txt" +) diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..9a86417 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,83 @@ +# Install + +1. Build the mod; see `BUILD.md`. +2. Copy the built folder into the SCUM server Win64 directory: + +```text +\SCUM\Binaries\Win64\ue4ss\Mods\scum_simple_rcon\ +``` + +Expected layout: + +```text +Win64/ + dwmapi.dll + SCUMServer.exe + ue4ss/ + UE4SS.dll + UE4SS-settings.ini + Mods/ + mods.txt + scum_simple_rcon/ + enabled.txt + config.ini + dlls/ + main.dll +``` + +3. Edit `config.ini`: + +```ini +[rcon] +bind_address = 127.0.0.1 +port = 27015 +password = use-a-real-password +``` + +4. Enable the mod in `ue4ss/Mods/mods.txt`: + +```text +scum_simple_rcon : 1 +``` + +5. Start the server and check `ue4ss/UE4SS.log` for: + +```text +[scum_simple_rcon] rcon: listening on 127.0.0.1:27015 +``` + +6. Test locally: + +```bat +mcrcon -H 127.0.0.1 -P 27015 -p use-a-real-password rcon.status +mcrcon -H 127.0.0.1 -P 27015 -p use-a-real-password rcon.admins +mcrcon -H 127.0.0.1 -P 27015 -p use-a-real-password rcon.dispatch +mcrcon -H 127.0.0.1 -P 27015 -p use-a-real-password rcon.chat +``` + +Security note: keep `bind_address = 127.0.0.1` unless you are putting this behind a firewall/VPN. Source RCON is not encrypted. + +Before sending an `AdminCommand` (for example `SetTime`, `SpawnItem`, or an +admin-targeted `SendNotification`), make sure the server has: + +```text +\SCUM\Saved\Config\WindowsServer\AdminUsers.ini +``` + +with at least one 17-digit SteamID64. This mod intentionally does not fall back to the first online player. + +Ordinary `SendChat` is different: it only needs an authenticated RCON client +and a real online recipient. It does not need `AdminUsers.ini`, does not create +a controller/channel, and refuses a targeted send when that SteamID is not +currently connected. + +For the no-player test, wait until the SCUM world has loaded, leave the server +empty, then run `rcon.dispatch` followed by a harmless command such as: + +```bat +mcrcon -H 127.0.0.1 -P 27015 -p use-a-real-password SetTime 12 +``` + +`rcon.dispatch` must report `synthetic=ready`. If the native executor signature +is not found, do not force an address: the SCUM build changed and the mod needs +an updated, tested signature. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d35e0db --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# scum_simple_rcon_ue4ss + +Minimal UE4SS C++ Mod that exposes a Source RCON listener and dispatches raw SCUM admin command text on the game thread. + +This intentionally implements only the scope requested: + +- UE4SS C++ mod lifecycle: `start_mod`, `uninstall_mod`, `on_unreal_init`. +- Source RCON server: TCP listener, password auth, request/response packet framing, multi-packet replies. +- Game-thread queue: RCON worker threads enqueue commands; EngineTick drains and executes them. +- Offline AdminCommand execution: builds a synthetic `BP_ConZPlayerController` + `ConZCharacter` in the dedicated-server world, then invokes SCUM's native `AdminCommand` executor. An administrator does **not** need to be online. +- Unified RCON endpoint: normal admin commands and notifications enter through the authenticated, game-thread queue. `SendChat` has a deliberate chat-only branch: it uses SCUM `MiscStatics:SendChatLineToPlayer` against real, online target controllers, never a fabricated or arbitrary `PlayerRpcChannel`. +- Admin identity guard: reads `SCUM/Saved/Config/WindowsServer/AdminUsers.ini` and refuses game commands when no configured admin SteamID64 exists, unless disabled in config. + +The implementation fails closed when a required SCUM binding, native executor +signature, or reflected function schema does not match the expected runtime +shape. It reports the mismatch instead of guessing an address, object, or +network channel. + +## Runtime behavior + +1. UE4SS loads `ue4ss/Mods/scum_simple_rcon/dlls/main.dll`. +2. The mod reads `ue4ss/Mods/scum_simple_rcon/config.ini`. +3. If the password is still `CHANGE_ME_BEFORE_USE`, the listener refuses to start. +4. After RCON auth, any command except `rcon.status`, `rcon.admins`, and `rcon.help` is queued to the game thread. `rcon.dispatch` and `rcon.chat` are safe game-thread diagnostics. +5. The game thread checks `AdminUsers.ini`, discovers SCUM `AdminCommand` classes, and creates/reuses a synthetic server-only caller. +6. The mod resolves SCUM's native AdminCommand executor with one exact signature. It only calls the executor when exactly one match is found; otherwise it returns a clear error. Non-AdminCommand Unreal console commands use the server-console fallback. + +Normal server admin commands can therefore run while the player count is **zero**. `SendChat` is not an admin command in this mod: it does not need `AdminUsers.ini`, but it does require each recipient to be online with a real network-backed controller. A player-targeted notification likewise needs its recipient online in order to be delivered/displayed; neither feature requires the administrator to be online. + +## Example commands + +```text +rcon.status +rcon.admins +rcon.dispatch +rcon.chat +Announce Hello from RCON +SendChat 4 "Bounty claimed: +500" 7656119XXXXXXXXXX +SendChat 2 "xxx killed xxx" +SendNotification 2 0 "Welcome!" 7656119XXXXXXXXXX +``` + +For `SendChat`, an omitted SteamID broadcasts by resolving and sending once to +each real online controller. A supplied SteamID routes only to that controller. +The implementation requires the controller's inherited `Player` object to be +a real `UNetConnection`, so synthetic offline admin controllers and +class-default objects are deliberately excluded. + +The last example is a broadcast only to real online players. Do not add an +inline `# ...` comment to an RCON command: it is command text, not a shell +script, so it would become part of the message/argument stream. + +Run `rcon.chat` after world load to validate the non-admin chat route without +sending anything. A healthy result contains `real chat dispatcher ready` and +reports the number of real online controllers; zero is valid on an empty server. + +Do not prefix commands with `#`; if you do, this mod strips one leading `#` before dispatch. + +## AdminUsers.ini + +Default lookup: + +```text +\SCUM\Saved\Config\WindowsServer\AdminUsers.ini +``` + +The parser accepts any line containing a 17-digit SteamID64, so these all work: + +```ini +7656119XXXXXXXXXX +Admin=7656119XXXXXXXXXX ++AdminUsers=7656119XXXXXXXXXX +``` + +No SteamID is compiled into the DLL and no online `PlayerRpcChannel` is used as +an admin surrogate. The file is re-read after changes; with multiple configured +admins the dispatcher selects the lexicographically first valid ID, unless the +optional `[admin] preferred_steam_id` names one that is also present in the file. +An already-created synthetic caller refreshes its selected identity after a file +change. + +## Offline-dispatch diagnostics + +Run `rcon.dispatch` after the world has loaded. A healthy result contains: + +```text +synthetic=ready; executor=native AdminCommand executor resolved by exact signature +``` + +If SCUM changes its executable code, the signature may no longer match. In that case the mod deliberately refuses to call a guessed address; update the signature for that SCUM build and retest on a non-production server. diff --git a/include/CommandQueue.hpp b/include/CommandQueue.hpp new file mode 100644 index 0000000..506864c --- /dev/null +++ b/include/CommandQueue.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace simple_rcon +{ + class CommandQueue + { + public: + using Executor = std::function; + + explicit CommandQueue(Executor executor); + + std::string enqueue_and_wait(const std::string& command, std::chrono::milliseconds timeout); + int drain(int max_items = 32); + void shutdown(); + + private: + struct Task + { + std::string command; + std::promise result; + }; + + Executor m_executor; + std::mutex m_mutex; + std::queue> m_tasks; + bool m_stopping{false}; + }; +} diff --git a/include/Config.hpp b/include/Config.hpp new file mode 100644 index 0000000..99348e5 --- /dev/null +++ b/include/Config.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include + +namespace simple_rcon +{ + struct Config + { + std::string bind_address{"127.0.0.1"}; + uint16_t port{27015}; + std::string password{"CHANGE_ME_BEFORE_USE"}; + bool auth_log{true}; + int max_connections{4}; + int packet_body_bytes{4000}; + std::chrono::milliseconds command_timeout{std::chrono::milliseconds{10000}}; + std::string dispatch_context{"auto"}; + bool offline_admin_dispatch{true}; + bool native_admin_executor{true}; + bool allow_empty_password{false}; + + // Read from SCUM/Saved/Config/WindowsServer/AdminUsers.ini by default. + // This prevents silently treating an arbitrary online player as an admin. + std::string admin_users_file{"auto"}; + // Optional selector only; it is accepted only when also present in + // admin_users_file and is never a replacement authorization source. + std::string preferred_admin_steam_id{}; + bool require_configured_admin{true}; + }; + + Config load_config(const std::filesystem::path& path); +} diff --git a/include/OfflineAdminExecutor.hpp b/include/OfflineAdminExecutor.hpp new file mode 100644 index 0000000..c3d3001 --- /dev/null +++ b/include/OfflineAdminExecutor.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +namespace simple_rcon +{ + /** + * SCUM's normal admin commands are not Unreal console commands. They are + * instances of AdminCommand executed by a native server function. This + * class creates a server-only ConZPlayerController/ConZCharacter pair and + * calls that function, so an actual player account does not need to be + * connected. + * + * It is deliberately game-build guarded: if the known executor signature + * does not resolve to exactly one location, no native call is made. + */ + class OfflineAdminExecutor + { + public: + // Kept public solely so the .cpp's game-thread helper functions can + // operate on it without exposing any Unreal types in this header. + struct RuntimeState; + + OfflineAdminExecutor(); + ~OfflineAdminExecutor(); + OfflineAdminExecutor(const OfflineAdminExecutor&) = delete; + OfflineAdminExecutor& operator=(const OfflineAdminExecutor&) = delete; + + struct Result + { + bool attempted{false}; + bool success{false}; + std::string message; + }; + + void configure(bool enabled, bool native_executor_enabled); + void on_unreal_init(); + // Runs the non-mutating portion of the offline path on the game + // thread: discover command classes, create/reuse the synthetic caller, + // and resolve the guarded native executor address. + Result probe(std::string_view configured_admin_steam_id); + Result execute(std::string_view command, std::string_view configured_admin_steam_id); + std::string status() const; + + private: + bool m_enabled{true}; + bool m_native_executor_enabled{true}; + bool m_unreal_ready{false}; + RuntimeState* m_state{nullptr}; + }; +} diff --git a/include/RconServer.hpp b/include/RconServer.hpp new file mode 100644 index 0000000..bb8c5fc --- /dev/null +++ b/include/RconServer.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "Config.hpp" + +namespace simple_rcon +{ + class RconServer + { + public: + using CommandHandler = std::function; + using Logger = std::function; + + RconServer(Config config, CommandHandler handler, Logger logger); + ~RconServer(); + + bool start(); + void stop(); + bool running() const; + + private: + struct Packet + { + int32_t id{}; + int32_t type{}; + std::string body; + }; + + void accept_loop(); + void client_loop(SOCKET client, std::string remote); + bool read_packet(SOCKET socket, Packet& out); + bool send_packet(SOCKET socket, int32_t id, int32_t type, const std::string& body); + bool send_response_chunks(SOCKET socket, int32_t id, const std::string& body); + void close_client_socket(SOCKET socket); + void log(const std::string& line); + + Config m_config; + CommandHandler m_handler; + Logger m_logger; + std::atomic m_running{false}; + std::atomic m_active_clients{0}; + std::thread m_accept_thread; + SOCKET m_listen_socket{INVALID_SOCKET}; + std::mutex m_clients_mutex; + std::unordered_set m_clients; + std::vector m_client_threads; + }; +} diff --git a/include/RealChatDispatcher.hpp b/include/RealChatDispatcher.hpp new file mode 100644 index 0000000..28c0b37 --- /dev/null +++ b/include/RealChatDispatcher.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +namespace simple_rcon +{ + /** + * Handles ordinary SCUM chat independently of AdminCommand dispatch. + * + * It never creates or picks an arbitrary PlayerRpcChannel. A targeted + * chat message is sent only after a configured SteamID is matched against + * a real, currently network-backed ConZPlayerController whose Player is a + * live UNetConnection. Broadcast chat is the same operation performed + * once for each such controller. + */ + class RealChatDispatcher + { + public: + struct Result + { + bool handled{false}; + bool success{false}; + std::string message; + }; + + RealChatDispatcher(); + ~RealChatDispatcher(); + RealChatDispatcher(const RealChatDispatcher&) = delete; + RealChatDispatcher& operator=(const RealChatDispatcher&) = delete; + + // Recognizes: SendChat "message" [target SteamID64] + // This function must be called on the game thread. + Result dispatch_if_chat(std::string_view command); + // Non-mutating game-thread diagnostic for the real-player chat path. + Result probe(); + std::string status() const; + + // Public only so the game-thread helper functions in the .cpp can + // keep all Unreal implementation details out of this header. + struct RuntimeState; + + private: + RuntimeState* m_state{nullptr}; + }; +} diff --git a/include/ScumBridge.hpp b/include/ScumBridge.hpp new file mode 100644 index 0000000..cc61387 --- /dev/null +++ b/include/ScumBridge.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "Config.hpp" +#include "OfflineAdminExecutor.hpp" +#include "RealChatDispatcher.hpp" + +namespace RC::Unreal +{ + class UObject; +} + +namespace simple_rcon +{ + class ScumBridge + { + public: + void configure(const Config& config, const std::filesystem::path& mod_directory); + void on_unreal_init(); + std::string execute(const std::string& command); + std::string admin_status(); + std::string dispatch_status(); + + private: + RC::Unreal::UObject* resolve_server_context(std::string& detail); + bool process_server_console(RC::Unreal::UObject* context, const std::wstring& command, std::string& detail); + void refresh_admin_ids_locked(); + std::string selected_admin_id_locked() const; + static std::filesystem::path resolve_admin_users_file(const Config& config, const std::filesystem::path& mod_directory); + static std::wstring widen_utf8(const std::string& s); + static std::string trim_command(std::string s); + + std::mutex m_mutex; + bool m_unreal_ready{false}; + bool m_require_configured_admin{true}; + std::string m_preferred_admin_steam_id; + std::filesystem::path m_admin_users_file; + std::filesystem::file_time_type m_admin_users_last_write{}; + bool m_admin_file_seen{false}; + std::unordered_set m_admin_ids; + OfflineAdminExecutor m_offline_executor; + RealChatDispatcher m_real_chat; + }; +} diff --git a/include/SimpleRconMod.hpp b/include/SimpleRconMod.hpp new file mode 100644 index 0000000..aed5b62 --- /dev/null +++ b/include/SimpleRconMod.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +#include + +#include "CommandQueue.hpp" +#include "Config.hpp" +#include "RconServer.hpp" +#include "ScumBridge.hpp" + +namespace simple_rcon +{ + class SimpleRconMod final : public RC::CppUserModBase + { + public: + SimpleRconMod(); + ~SimpleRconMod() override; + + auto on_unreal_init() -> void override; + auto on_update() -> void override; + + private: + void log(const std::string& line); + std::string handle_rcon_command(const std::string& command); + void install_game_thread_drain(); + void drain_game_thread(); + + Config m_config; + ScumBridge m_bridge; + CommandQueue m_queue; + std::unique_ptr m_server; + bool m_hook_installed{false}; + uint64_t m_tick_callback_id{0}; + }; +} diff --git a/src/CommandQueue.cpp b/src/CommandQueue.cpp new file mode 100644 index 0000000..b8e18d1 --- /dev/null +++ b/src/CommandQueue.cpp @@ -0,0 +1,78 @@ +#include "CommandQueue.hpp" + +#include + +namespace simple_rcon +{ + CommandQueue::CommandQueue(Executor executor) : m_executor(std::move(executor)) {} + + std::string CommandQueue::enqueue_and_wait(const std::string& command, std::chrono::milliseconds timeout) + { + auto task = std::make_shared(); + task->command = command; + auto future = task->result.get_future(); + + { + std::scoped_lock lock{m_mutex}; + if (m_stopping) + { + return "error: command queue is shutting down"; + } + m_tasks.push(task); + } + + if (future.wait_for(timeout) == std::future_status::ready) + { + return future.get(); + } + return "error: command timed out waiting for the game thread"; + } + + int CommandQueue::drain(int max_items) + { + int drained = 0; + while (drained < max_items) + { + std::shared_ptr task; + { + std::scoped_lock lock{m_mutex}; + if (m_tasks.empty()) + { + break; + } + task = m_tasks.front(); + m_tasks.pop(); + } + + try + { + task->result.set_value(m_executor(task->command)); + } + catch (const std::exception& e) + { + task->result.set_value(std::string{"error: exception while executing command: "} + e.what()); + } + catch (...) + { + task->result.set_value("error: unknown exception while executing command"); + } + ++drained; + } + return drained; + } + + void CommandQueue::shutdown() + { + std::queue> pending; + { + std::scoped_lock lock{m_mutex}; + m_stopping = true; + std::swap(pending, m_tasks); + } + while (!pending.empty()) + { + pending.front()->result.set_value("error: command queue stopped before execution"); + pending.pop(); + } + } +} diff --git a/src/OfflineAdminExecutor.cpp b/src/OfflineAdminExecutor.cpp new file mode 100644 index 0000000..7e45bfd --- /dev/null +++ b/src/OfflineAdminExecutor.cpp @@ -0,0 +1,859 @@ +#include "OfflineAdminExecutor.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace +{ + using RC::Unreal::FProperty; + using RC::Unreal::FString; + using RC::Unreal::UClass; + using RC::Unreal::UFunction; + using RC::Unreal::UObject; + + constexpr auto k_mark_as_root_set = static_cast(0x80); + constexpr size_t k_function_parameter_bytes = 0x200; + + // This is the exact native AdminCommand executor signature recovered from + // the reference SCUM-RCON build. It is intentionally not fuzzy/wildcard + // matched: a game update must produce one exact hit or dispatch is refused. + constexpr std::array k_executor_signature{ + 0x10, 0x48, 0x89, 0x74, 0x24, 0x18, 0x57, 0x48, + 0x83, 0xEC, 0x30, 0x48, 0x8B, 0xF9, 0x0F, 0x29, + 0x74, 0x24, 0x20, 0x48, 0x8D, 0x4C, 0x24, 0x40, + }; + + // The matched bytes begin four bytes after the native function entry. + constexpr ptrdiff_t k_executor_entry_adjustment = -4; + + struct alignas(16) IdentityTransform + { + float rotation[4]{0.0f, 0.0f, 0.0f, 1.0f}; + float translation[4]{0.0f, 0.0f, 0.0f, 0.0f}; + float scale[4]{1.0f, 1.0f, 1.0f, 0.0f}; + }; + + static_assert(sizeof(IdentityTransform) == 0x30); + + auto narrow(const std::wstring& input) -> std::string + { + std::string output; + output.reserve(input.size()); + for (wchar_t c : input) + { + output.push_back(c >= 0 && c <= 0x7F ? static_cast(c) : '?'); + } + return output; + } + + auto widen_utf8(std::string_view input) -> std::wstring + { + if (input.empty()) + { + return {}; + } + + const int characters = ::MultiByteToWideChar(CP_UTF8, 0, input.data(), static_cast(input.size()), nullptr, 0); + if (characters <= 0) + { + return {input.begin(), input.end()}; + } + + std::wstring output(static_cast(characters), L'\0'); + ::MultiByteToWideChar(CP_UTF8, 0, input.data(), static_cast(input.size()), output.data(), characters); + return output; + } + + auto lowercase(std::string value) -> std::string + { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value; + } + + auto tokenize(std::string_view command) -> std::vector + { + std::vector result; + std::string current; + bool quoted = false; + bool escaped = false; + + for (char c : command) + { + if (escaped) + { + current.push_back(c); + escaped = false; + continue; + } + if (c == '\\') + { + escaped = true; + continue; + } + if (c == '"') + { + quoted = !quoted; + continue; + } + if (!quoted && std::isspace(static_cast(c))) + { + if (!current.empty()) + { + result.emplace_back(std::move(current)); + current.clear(); + } + continue; + } + current.push_back(c); + } + + if (escaped) + { + current.push_back('\\'); + } + if (!current.empty()) + { + result.emplace_back(std::move(current)); + } + return result; + } + + template + auto find_object(const wchar_t* path) -> T* + { + return RC::Unreal::UObjectGlobals::StaticFindObject(nullptr, nullptr, path); + } + + auto find_property(UFunction* function, const wchar_t* name) -> FProperty* + { + if (!function) + { + return nullptr; + } + + for (FProperty* property : RC::Unreal::TFieldRange(function, RC::Unreal::EFieldIterationFlags::IncludeDeprecated)) + { + if (property && property->GetName() == name) + { + return property; + } + } + return nullptr; + } + + auto find_property(UClass* klass, const wchar_t* name) -> FProperty* + { + if (!klass) + { + return nullptr; + } + + for (FProperty* property : RC::Unreal::TFieldRange(klass, RC::Unreal::EFieldIterationFlags::IncludeDeprecated)) + { + if (property && property->GetName() == name) + { + return property; + } + } + return nullptr; + } + + auto property_offset(UFunction* function, const wchar_t* name) -> int32_t + { + const auto* property = find_property(function, name); + return property ? property->GetOffset_Internal() : -1; + } + + template + auto write_parameter(std::array& parameters, int32_t offset, const T& value) -> bool + { + if (offset < 0 || static_cast(offset) + sizeof(T) > parameters.size()) + { + return false; + } + std::memcpy(parameters.data() + offset, &value, sizeof(T)); + return true; + } + + template + auto read_parameter(const std::array& parameters, int32_t offset) -> std::optional + { + if (offset < 0 || static_cast(offset) + sizeof(T) > parameters.size()) + { + return std::nullopt; + } + T value{}; + std::memcpy(&value, parameters.data() + offset, sizeof(T)); + return value; + } + + auto call_function(UObject* target, UFunction* function, void* parameters) -> bool + { + if (!target || !function) + { + return false; + } + target->ProcessEvent(function, parameters); + return true; + } + + auto class_is_child_of(UClass* candidate, UClass* expected_base) -> bool + { + for (auto* current = candidate; current; current = static_cast(current->GetSuperStruct())) + { + if (current == expected_base) + { + return true; + } + } + return false; + } + + auto looks_like_UClass(UObject* object) -> bool + { + if (!object || !object->GetClassPrivate()) + { + return false; + } + const auto meta_name = lowercase(narrow(object->GetClassPrivate()->GetName())); + return meta_name == "class" || meta_name == "blueprintgeneratedclass"; + } + + auto class_verb(UClass* klass) -> std::string + { + if (!klass) + { + return {}; + } + std::string name = lowercase(narrow(klass->GetName())); + constexpr std::string_view marker{"admincommand_"}; + const auto marker_pos = name.find(marker); + if (marker_pos == std::string::npos) + { + return {}; + } + std::string verb = name.substr(marker_pos + marker.size()); + if (verb.ends_with("_c")) + { + verb.resize(verb.size() - 2); + } + return verb; + } + + auto scan_main_module_for_executor(std::string& detail) -> void* + { + const auto module = ::GetModuleHandleW(nullptr); + if (!module) + { + detail = "GetModuleHandleW(nullptr) failed"; + return nullptr; + } + + const auto* image = reinterpret_cast(module); + const auto* dos = reinterpret_cast(image); + if (dos->e_magic != IMAGE_DOS_SIGNATURE) + { + detail = "main module has no DOS header"; + return nullptr; + } + const auto* nt = reinterpret_cast(image + dos->e_lfanew); + if (nt->Signature != IMAGE_NT_SIGNATURE) + { + detail = "main module has no NT header"; + return nullptr; + } + + const IMAGE_SECTION_HEADER* text_section = nullptr; + const auto* sections = IMAGE_FIRST_SECTION(nt); + for (WORD i = 0; i < nt->FileHeader.NumberOfSections; ++i) + { + const auto& section = sections[i]; + if (std::memcmp(section.Name, ".text", 5) == 0) + { + text_section = §ion; + break; + } + } + if (!text_section || text_section->Misc.VirtualSize < k_executor_signature.size()) + { + detail = "main module .text section is unavailable"; + return nullptr; + } + + const auto* begin = image + text_section->VirtualAddress; + const size_t length = text_section->Misc.VirtualSize; + const uint8_t* match = nullptr; + size_t matches = 0; + for (size_t offset = 0; offset + k_executor_signature.size() <= length; ++offset) + { + if (std::memcmp(begin + offset, k_executor_signature.data(), k_executor_signature.size()) == 0) + { + match = begin + offset; + ++matches; + } + } + + if (matches != 1) + { + detail = matches == 0 ? "native AdminCommand executor signature not found" : + "native AdminCommand executor signature is ambiguous (" + std::to_string(matches) + " matches)"; + return nullptr; + } + + detail = "native AdminCommand executor resolved by exact signature"; + return const_cast(match + k_executor_entry_adjustment); + } + + auto call_native_admin_executor(void* native_executor, + UObject* command_instance, + RC::Unreal::TArray* arguments, + bool& faulted) -> int64_t + { + using NativeAdminExecutor = int64_t(__fastcall*)(UObject*, RC::Unreal::TArray*); + const auto execute_native = reinterpret_cast(native_executor); +#if defined(_MSC_VER) + __try + { + return execute_native(command_instance, arguments); + } + __except (EXCEPTION_EXECUTE_HANDLER) + { + faulted = true; + return 0; + } +#else + return execute_native(command_instance, arguments); +#endif + } +} + +namespace simple_rcon +{ + struct OfflineAdminExecutor::RuntimeState + { + UObject* world{}; + UObject* controller{}; + UObject* pawn{}; + UObject* user_profile{}; + UClass* admin_command_base{}; + std::unordered_map verbs; + void* native_executor{}; + std::string bound_admin_steam_id; + std::string native_executor_detail{"not resolved yet"}; + std::string last_context_detail{"not built yet"}; + }; + + OfflineAdminExecutor::OfflineAdminExecutor() : m_state(new RuntimeState{}) {} + + OfflineAdminExecutor::~OfflineAdminExecutor() + { + delete m_state; + } + + void OfflineAdminExecutor::configure(bool enabled, bool native_executor_enabled) + { + m_enabled = enabled; + m_native_executor_enabled = native_executor_enabled; + } + + void OfflineAdminExecutor::on_unreal_init() + { + m_unreal_ready = true; + } + + namespace + { + auto make_identity_transform() -> IdentityTransform + { + return {}; + } + + auto spawn_actor(UObject* world, UClass* actor_class, std::string& detail) -> UObject* + { + auto* gameplay_statics = find_object(STR("/Script/Engine.Default__GameplayStatics")); + auto* begin_spawn = find_object(STR("/Script/Engine.GameplayStatics:BeginSpawningActorFromClass")); + auto* finish_spawn = find_object(STR("/Script/Engine.GameplayStatics:FinishSpawningActor")); + if (!world || !actor_class || !gameplay_statics || !begin_spawn || !finish_spawn) + { + detail = "GameplayStatics deferred-spawn objects are not available"; + return nullptr; + } + + const int32_t world_offset = property_offset(begin_spawn, STR("WorldContextObject")); + const int32_t class_offset = property_offset(begin_spawn, STR("ActorClass")); + const int32_t transform_offset = property_offset(begin_spawn, STR("SpawnTransform")); + const int32_t collision_fail_offset = property_offset(begin_spawn, STR("bNoCollisionFail")); + const int32_t collision_mode_offset = property_offset(begin_spawn, STR("CollisionHandlingOverride")); + const int32_t return_offset = property_offset(begin_spawn, STR("ReturnValue")); + if (world_offset < 0 || class_offset < 0 || transform_offset < 0 || return_offset < 0) + { + detail = "BeginSpawningActorFromClass parameters are not compatible"; + return nullptr; + } + + std::array begin_parameters{}; + const IdentityTransform transform = make_identity_transform(); + const uint8_t true_byte = 1; + const uint8_t always_spawn = 1; + if (!write_parameter(begin_parameters, world_offset, world) || + !write_parameter(begin_parameters, class_offset, actor_class) || + !write_parameter(begin_parameters, transform_offset, transform) || + !write_parameter(begin_parameters, return_offset, static_cast(nullptr)) || + (collision_fail_offset >= 0 && !write_parameter(begin_parameters, collision_fail_offset, true_byte)) || + (collision_mode_offset >= 0 && !write_parameter(begin_parameters, collision_mode_offset, always_spawn))) + { + detail = "BeginSpawningActorFromClass parameter layout is too large"; + return nullptr; + } + if (!call_function(gameplay_statics, begin_spawn, begin_parameters.data())) + { + detail = "BeginSpawningActorFromClass could not be called"; + return nullptr; + } + + const auto deferred_actor = read_parameter(begin_parameters, return_offset).value_or(nullptr); + if (!deferred_actor) + { + detail = "BeginSpawningActorFromClass returned null"; + return nullptr; + } + deferred_actor->SetFlags(k_mark_as_root_set); + + const int32_t actor_offset = property_offset(finish_spawn, STR("Actor")); + const int32_t finish_transform_offset = property_offset(finish_spawn, STR("SpawnTransform")); + const int32_t finish_return_offset = property_offset(finish_spawn, STR("ReturnValue")); + if (actor_offset < 0 || finish_transform_offset < 0 || finish_return_offset < 0) + { + detail = "FinishSpawningActor parameters are not compatible"; + return nullptr; + } + + std::array finish_parameters{}; + if (!write_parameter(finish_parameters, actor_offset, deferred_actor) || + !write_parameter(finish_parameters, finish_transform_offset, transform) || + !write_parameter(finish_parameters, finish_return_offset, static_cast(nullptr))) + { + detail = "FinishSpawningActor parameter layout is too large"; + return nullptr; + } + if (!call_function(gameplay_statics, finish_spawn, finish_parameters.data())) + { + detail = "FinishSpawningActor could not be called"; + return nullptr; + } + + auto* actor = read_parameter(finish_parameters, finish_return_offset).value_or(deferred_actor); + if (!actor) + { + detail = "FinishSpawningActor returned null"; + return nullptr; + } + actor->SetFlags(k_mark_as_root_set); + return actor; + } + + void disable_actor_tick(UObject* actor) + { + auto* function = find_object(STR("/Script/Engine.Actor:SetActorTickEnabled")); + if (!actor || !function) + { + return; + } + const int32_t enabled_offset = property_offset(function, STR("bEnabled")); + if (enabled_offset < 0) + { + return; + } + std::array parameters{}; + const uint8_t false_byte = 0; + if (write_parameter(parameters, enabled_offset, false_byte)) + { + call_function(actor, function, parameters.data()); + } + } + + void best_effort_bind_admin_identity(UObject* object, std::string_view steam_id) + { + if (!object || steam_id.empty() || !object->GetClassPrivate()) + { + return; + } + + const std::wstring steam_id_wide = widen_utf8(steam_id); + uint64_t numeric_id{}; + try + { + numeric_id = std::stoull(std::string{steam_id}); + } + catch (...) + { + return; + } + + for (FProperty* property : RC::Unreal::TFieldRange(object->GetClassPrivate(), RC::Unreal::EFieldIterationFlags::IncludeDeprecated)) + { + if (!property) + { + continue; + } + const std::string property_name = lowercase(narrow(property->GetName())); + const bool looks_like_identity = property_name.find("steam") != std::string::npos || + property_name == "userid" || property_name == "_userid" || + property_name.find("onlineid") != std::string::npos; + if (!looks_like_identity) + { + continue; + } + + if (property->IsA()) + { + auto* value = property->ContainerPtrToValuePtr(object); + if (value) + { + *value = FString{steam_id_wide.c_str()}; + } + } + else if (property->IsA()) + { + auto* value = property->ContainerPtrToValuePtr(object); + if (value) + { + *value = numeric_id; + } + } + else if (property->IsA()) + { + auto* value = property->ContainerPtrToValuePtr(object); + if (value) + { + *value = static_cast(numeric_id); + } + } + } + } + } + + namespace + { + auto ensure_synthetic_caller(OfflineAdminExecutor::RuntimeState& state, + std::string_view configured_admin_steam_id, + std::string& detail) -> bool + { + auto* current_world = RC::Unreal::UObjectGlobals::FindFirstOf(STR("World")); + if (!current_world) + { + detail = "no UWorld is available yet"; + return false; + } + + if (state.world != current_world) + { + state.world = current_world; + state.controller = nullptr; + state.pawn = nullptr; + state.user_profile = nullptr; + state.bound_admin_steam_id.clear(); + state.last_context_detail = "world changed; synthetic caller will be rebuilt"; + } + + if (state.controller && state.pawn) + { + // AdminUsers.ini is re-read by ScumBridge. Do not leave an + // already-created synthetic caller bound to a SteamID that was + // removed from the file or superseded through preferred_admin_steam_id. + if (state.bound_admin_steam_id != configured_admin_steam_id) + { + best_effort_bind_admin_identity(state.user_profile, configured_admin_steam_id); + best_effort_bind_admin_identity(state.controller, configured_admin_steam_id); + state.bound_admin_steam_id = std::string{configured_admin_steam_id}; + detail = "reusing synthetic controller and pawn; AdminUsers identity refreshed"; + } + else + { + detail = "reusing synthetic controller and pawn"; + } + return true; + } + + auto* controller_class = find_object( + STR("/Game/ConZ_Files/Blueprints/PlayerControllers/BP_ConZPlayerController.BP_ConZPlayerController_C")); + if (!controller_class) + { + detail = "BP_ConZPlayerController class is not loaded"; + return false; + } + + auto* controller = spawn_actor(current_world, controller_class, detail); + if (!controller) + { + return false; + } + disable_actor_tick(controller); + + auto* profile_class = find_object(STR("/Script/SCUM.UserProfile")); + if (profile_class) + { + RC::Unreal::FStaticConstructObjectParameters parameters{profile_class, controller}; + auto* profile = RC::Unreal::UObjectGlobals::StaticConstructObject(parameters); + if (profile) + { + profile->SetFlags(k_mark_as_root_set); + if (auto* property = find_property(controller->GetClassPrivate(), STR("_userProfile"))) + { + auto* value = property->ContainerPtrToValuePtr(controller); + if (value) + { + *value = profile; + } + } + best_effort_bind_admin_identity(profile, configured_admin_steam_id); + state.user_profile = profile; + } + } + best_effort_bind_admin_identity(controller, configured_admin_steam_id); + + auto* character_class = find_object(STR("/Script/SCUM.ConZCharacter")); + if (!character_class) + { + character_class = find_object(STR("/Script/ConZ.ConZCharacter")); + } + if (!character_class) + { + detail = "ConZCharacter class is not loaded"; + return false; + } + + auto* pawn = spawn_actor(current_world, character_class, detail); + if (!pawn) + { + return false; + } + disable_actor_tick(pawn); + + auto* possess = find_object(STR("/Script/Engine.Controller:Possess")); + const int32_t pawn_offset = property_offset(possess, STR("InPawn")); + if (!possess || pawn_offset < 0) + { + detail = "Controller:Possess or its InPawn parameter is not available"; + return false; + } + std::array parameters{}; + if (!write_parameter(parameters, pawn_offset, pawn) || !call_function(controller, possess, parameters.data())) + { + detail = "could not possess the synthetic ConZCharacter"; + return false; + } + + state.controller = controller; + state.pawn = pawn; + state.bound_admin_steam_id = std::string{configured_admin_steam_id}; + state.last_context_detail = "synthetic BP_ConZPlayerController + ConZCharacter ready"; + detail = state.last_context_detail; + return true; + } + + auto build_verb_map(OfflineAdminExecutor::RuntimeState& state, std::string& detail) -> bool + { + auto* base = find_object(STR("/Script/SCUM.AdminCommand")); + if (!base) + { + state.verbs.clear(); + detail = "SCUM.AdminCommand base class is not loaded yet"; + return false; + } + if (state.admin_command_base == base && !state.verbs.empty()) + { + return true; + } + + std::unordered_map next; + RC::Unreal::UObjectGlobals::ForEachUObject([&](UObject* object, int32_t, int32_t) { + if (!looks_like_UClass(object)) + { + return RC::LoopAction::Continue; + } + auto* candidate = static_cast(object); + if (!class_is_child_of(candidate, base)) + { + return RC::LoopAction::Continue; + } + const auto verb = class_verb(candidate); + if (!verb.empty()) + { + next.try_emplace(verb, candidate); + } + return RC::LoopAction::Continue; + }); + + state.admin_command_base = base; + state.verbs = std::move(next); + if (state.verbs.empty()) + { + detail = "AdminCommand subclasses were found but no command verbs could be extracted"; + return false; + } + detail = "discovered " + std::to_string(state.verbs.size()) + " AdminCommand verbs"; + return true; + } + + auto resolve_native_executor(OfflineAdminExecutor::RuntimeState& state, std::string& detail) -> void* + { + if (state.native_executor) + { + detail = state.native_executor_detail; + return state.native_executor; + } + state.native_executor = scan_main_module_for_executor(state.native_executor_detail); + detail = state.native_executor_detail; + return state.native_executor; + } + } + + OfflineAdminExecutor::Result OfflineAdminExecutor::execute(std::string_view command, + std::string_view configured_admin_steam_id) + { + if (!m_enabled) + { + return {false, false, "offline AdminCommand dispatcher is disabled"}; + } + if (!m_unreal_ready || !m_state) + { + return {true, false, "offline dispatcher is not ready yet"}; + } + + const auto tokens = tokenize(command); + if (tokens.empty()) + { + return {false, false, "empty command"}; + } + + std::string verb_detail; + if (!build_verb_map(*m_state, verb_detail)) + { + return {true, false, "offline AdminCommand dispatcher unavailable: " + verb_detail}; + } + + const std::string verb = lowercase(tokens.front()); + const auto command_class_it = m_state->verbs.find(verb); + if (command_class_it == m_state->verbs.end()) + { + // The caller can still try a standard UE console command through + // ScumBridge's server-console fallback. + return {false, false, "no AdminCommand class for '" + tokens.front() + "'"}; + } + + std::string caller_detail; + if (!ensure_synthetic_caller(*m_state, configured_admin_steam_id, caller_detail)) + { + return {true, false, "offline synthetic caller unavailable: " + caller_detail}; + } + + if (!m_native_executor_enabled) + { + return {true, false, "offline native executor is disabled by config"}; + } + + std::string executor_detail; + auto* native_executor = resolve_native_executor(*m_state, executor_detail); + if (!native_executor) + { + return {true, false, "offline native executor unavailable: " + executor_detail}; + } + + RC::Unreal::FStaticConstructObjectParameters construction{command_class_it->second, m_state->controller}; + auto* command_instance = RC::Unreal::UObjectGlobals::StaticConstructObject(construction); + if (!command_instance) + { + return {true, false, "could not construct AdminCommand for '" + tokens.front() + "'"}; + } + + RC::Unreal::TArray arguments{}; + for (size_t index = 1; index < tokens.size(); ++index) + { + const auto argument_wide = widen_utf8(tokens[index]); + arguments.Add(FString{argument_wide.c_str()}); + } + + bool faulted = false; + const int64_t native_result = call_native_admin_executor(native_executor, command_instance, &arguments, faulted); + if (faulted) + { + return {true, false, "AdminCommand '" + tokens.front() + "' faulted inside the SCUM native executor"}; + } + if (native_result == 0) + { + return {true, false, "AdminCommand '" + tokens.front() + "' ran but the SCUM executor returned 0"}; + } + + return {true, true, "ok: offline AdminCommand '" + tokens.front() + "' executed via synthetic server caller"}; + } + + OfflineAdminExecutor::Result OfflineAdminExecutor::probe(std::string_view configured_admin_steam_id) + { + if (!m_enabled) + { + return {true, false, "offline AdminCommand dispatcher is disabled"}; + } + if (!m_unreal_ready || !m_state) + { + return {true, false, "offline dispatcher is not ready yet"}; + } + + std::string verb_detail; + if (!build_verb_map(*m_state, verb_detail)) + { + return {true, false, "offline AdminCommand dispatcher unavailable: " + verb_detail}; + } + + std::string caller_detail; + if (!ensure_synthetic_caller(*m_state, configured_admin_steam_id, caller_detail)) + { + return {true, false, "offline synthetic caller unavailable: " + caller_detail}; + } + + if (!m_native_executor_enabled) + { + return {true, false, "offline native executor is disabled by config"}; + } + + std::string executor_detail; + if (!resolve_native_executor(*m_state, executor_detail)) + { + return {true, false, "offline native executor unavailable: " + executor_detail}; + } + + return {true, true, "ok: offline dispatcher ready; " + status()}; + } + + std::string OfflineAdminExecutor::status() const + { + std::ostringstream output; + output << "offline_dispatch=" << (m_enabled ? "enabled" : "disabled") + << "; native_executor=" << (m_native_executor_enabled ? "enabled" : "disabled"); + if (!m_state) + { + return output.str(); + } + output << "; verbs=" << m_state->verbs.size() + << "; synthetic=" << (m_state->controller && m_state->pawn ? "ready" : "not-ready") + << "; context=" << m_state->last_context_detail + << "; executor=" << m_state->native_executor_detail; + return output.str(); + } +} diff --git a/src/RconServer.cpp b/src/RconServer.cpp new file mode 100644 index 0000000..4fb32a6 --- /dev/null +++ b/src/RconServer.cpp @@ -0,0 +1,368 @@ +#include "RconServer.hpp" + +#include +#include +#include +#include +#include + +#include + +namespace +{ + constexpr int32_t SERVERDATA_RESPONSE_VALUE = 0; + constexpr int32_t SERVERDATA_AUTH_RESPONSE = 2; + constexpr int32_t SERVERDATA_EXECCOMMAND = 2; + constexpr int32_t SERVERDATA_AUTH = 3; + constexpr int32_t MAX_PACKET_SIZE = 4096; + + bool send_all(SOCKET s, const char* data, int len) + { + int sent_total = 0; + while (sent_total < len) + { + const int sent = ::send(s, data + sent_total, len - sent_total, 0); + if (sent <= 0) + { + return false; + } + sent_total += sent; + } + return true; + } + + bool recv_all(SOCKET s, char* data, int len) + { + int got_total = 0; + while (got_total < len) + { + const int got = ::recv(s, data + got_total, len - got_total, 0); + if (got <= 0) + { + return false; + } + got_total += got; + } + return true; + } + + template + T read_le(const char* p) + { + T value{}; + std::memcpy(&value, p, sizeof(T)); + return value; + } + + template + void append_le(std::vector& out, T value) + { + const auto* p = reinterpret_cast(&value); + out.insert(out.end(), p, p + sizeof(T)); + } +} + +namespace simple_rcon +{ + RconServer::RconServer(Config config, CommandHandler handler, Logger logger) + : m_config(std::move(config)), m_handler(std::move(handler)), m_logger(std::move(logger)) + { + } + + RconServer::~RconServer() + { + stop(); + } + + bool RconServer::start() + { + if (m_running.exchange(true)) + { + return true; + } + + WSADATA wsa{}; + if (::WSAStartup(MAKEWORD(2, 2), &wsa) != 0) + { + m_running = false; + log("rcon: WSAStartup failed"); + return false; + } + + m_listen_socket = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (m_listen_socket == INVALID_SOCKET) + { + m_running = false; + log("rcon: socket() failed"); + ::WSACleanup(); + return false; + } + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(m_config.port); + if (::inet_pton(AF_INET, m_config.bind_address.c_str(), &addr.sin_addr) != 1) + { + log("rcon: invalid bind_address '" + m_config.bind_address + "'"); + stop(); + return false; + } + + int yes = 1; + ::setsockopt(m_listen_socket, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast(&yes), sizeof(yes)); + + if (::bind(m_listen_socket, reinterpret_cast(&addr), sizeof(addr)) == SOCKET_ERROR) + { + log("rcon: bind failed on " + m_config.bind_address + ":" + std::to_string(m_config.port)); + stop(); + return false; + } + + if (::listen(m_listen_socket, SOMAXCONN) == SOCKET_ERROR) + { + log("rcon: listen() failed"); + stop(); + return false; + } + + m_accept_thread = std::thread([this] { accept_loop(); }); + log("rcon: listening on " + m_config.bind_address + ":" + std::to_string(m_config.port)); + return true; + } + + void RconServer::stop() + { + if (!m_running.exchange(false) && m_listen_socket == INVALID_SOCKET) + { + return; + } + + if (m_listen_socket != INVALID_SOCKET) + { + ::shutdown(m_listen_socket, SD_BOTH); + ::closesocket(m_listen_socket); + m_listen_socket = INVALID_SOCKET; + } + + if (m_accept_thread.joinable()) + { + m_accept_thread.join(); + } + + std::vector client_threads; + { + std::scoped_lock lock{m_clients_mutex}; + for (SOCKET client : m_clients) + { + // shutdown unblocks recv; the owner thread closes the socket. + ::shutdown(client, SD_BOTH); + } + std::swap(client_threads, m_client_threads); + } + + for (auto& client_thread : client_threads) + { + if (client_thread.joinable()) + { + client_thread.join(); + } + } + + ::WSACleanup(); + } + + bool RconServer::running() const + { + return m_running.load(); + } + + void RconServer::accept_loop() + { + while (m_running) + { + sockaddr_in remote_addr{}; + int remote_len = sizeof(remote_addr); + SOCKET client = ::accept(m_listen_socket, reinterpret_cast(&remote_addr), &remote_len); + if (client == INVALID_SOCKET) + { + if (m_running) + { + log("rcon: accept() failed"); + } + continue; + } + + char ip[INET_ADDRSTRLEN]{}; + ::inet_ntop(AF_INET, &remote_addr.sin_addr, ip, sizeof(ip)); + std::ostringstream remote; + remote << ip << ":" << ntohs(remote_addr.sin_port); + + if (m_active_clients.fetch_add(1) >= m_config.max_connections) + { + --m_active_clients; + log("rcon: connection limit reached; refusing " + remote.str()); + ::closesocket(client); + continue; + } + + { + std::scoped_lock lock{m_clients_mutex}; + m_clients.insert(client); + } + + std::scoped_lock lock{m_clients_mutex}; + m_client_threads.emplace_back([this, client, remote = remote.str()] { + client_loop(client, remote); + close_client_socket(client); + }); + } + } + + void RconServer::client_loop(SOCKET client, std::string remote) + { + bool authed = false; + + int timeout_ms = 30000; + ::setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&timeout_ms), sizeof(timeout_ms)); + + Packet packet{}; + while (m_running && read_packet(client, packet)) + { + if (!authed) + { + if (packet.type != SERVERDATA_AUTH) + { + log("rcon: " + remote + " sent command before auth"); + send_packet(client, -1, SERVERDATA_AUTH_RESPONSE, ""); + break; + } + + const bool ok = packet.body == m_config.password && + (m_config.allow_empty_password || !m_config.password.empty()) && + (m_config.allow_empty_password || m_config.password != "CHANGE_ME_BEFORE_USE"); + + if (!ok) + { + if (m_config.auth_log) + { + log("rcon: auth FAILED from " + remote); + } + send_packet(client, -1, SERVERDATA_AUTH_RESPONSE, ""); + break; + } + + authed = true; + if (m_config.auth_log) + { + log("rcon: auth succeeded from " + remote); + } + send_packet(client, packet.id, SERVERDATA_AUTH_RESPONSE, ""); + continue; + } + + if (packet.type != SERVERDATA_EXECCOMMAND) + { + send_response_chunks(client, packet.id, "error: unsupported packet type " + std::to_string(packet.type)); + continue; + } + + log("rcon: " + remote + " -> " + packet.body); + const std::string response = m_handler ? m_handler(packet.body) : "error: no command handler installed"; + if (!send_response_chunks(client, packet.id, response)) + { + break; + } + } + + --m_active_clients; + } + + bool RconServer::read_packet(SOCKET socket, Packet& out) + { + std::array size_buf{}; + if (!recv_all(socket, size_buf.data(), static_cast(size_buf.size()))) + { + return false; + } + + const int32_t size = read_le(size_buf.data()); + if (size < 10 || size > MAX_PACKET_SIZE) + { + return false; + } + + std::vector buf(static_cast(size)); + if (!recv_all(socket, buf.data(), size)) + { + return false; + } + + out.id = read_le(buf.data()); + out.type = read_le(buf.data() + 4); + const char* body = buf.data() + 8; + const int body_capacity = size - 10; + out.body.assign(body, body + std::max(0, body_capacity)); + const auto nul = out.body.find('\0'); + if (nul != std::string::npos) + { + out.body.resize(nul); + } + return true; + } + + bool RconServer::send_packet(SOCKET socket, int32_t id, int32_t type, const std::string& body) + { + const int32_t size = static_cast(8 + body.size() + 2); + std::vector packet; + packet.reserve(static_cast(size) + 4); + append_le(packet, size); + append_le(packet, id); + append_le(packet, type); + packet.insert(packet.end(), body.begin(), body.end()); + packet.push_back('\0'); + packet.push_back('\0'); + return send_all(socket, packet.data(), static_cast(packet.size())); + } + + bool RconServer::send_response_chunks(SOCKET socket, int32_t id, const std::string& body) + { + const int max_body = std::clamp(m_config.packet_body_bytes, 1, 4000); + if (body.empty()) + { + return send_packet(socket, id, SERVERDATA_RESPONSE_VALUE, ""); + } + + size_t offset = 0; + while (offset < body.size()) + { + const size_t n = std::min(static_cast(max_body), body.size() - offset); + if (!send_packet(socket, id, SERVERDATA_RESPONSE_VALUE, body.substr(offset, n))) + { + return false; + } + offset += n; + } + + // A final empty response packet is the common Source-RCON sentinel + // that lets clients stop waiting after a multi-packet reply. + return send_packet(socket, id, SERVERDATA_RESPONSE_VALUE, ""); + } + + void RconServer::close_client_socket(SOCKET socket) + { + { + std::scoped_lock lock{m_clients_mutex}; + m_clients.erase(socket); + } + ::shutdown(socket, SD_BOTH); + ::closesocket(socket); + } + + void RconServer::log(const std::string& line) + { + if (m_logger) + { + m_logger(line); + } + } +} diff --git a/src/RealChatDispatcher.cpp b/src/RealChatDispatcher.cpp new file mode 100644 index 0000000..e1d25f4 --- /dev/null +++ b/src/RealChatDispatcher.cpp @@ -0,0 +1,742 @@ +#include "RealChatDispatcher.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace +{ + using RC::Unreal::FProperty; + using RC::Unreal::FString; + using RC::Unreal::UClass; + using RC::Unreal::UFunction; + using RC::Unreal::UObject; + + constexpr uint16_t k_no_return_value = 0xFFFF; + constexpr size_t k_max_message_bytes = 1024; + + auto lowercase(std::string value) -> std::string + { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value; + } + + auto narrow(const std::wstring& input) -> std::string + { + std::string output; + output.reserve(input.size()); + for (wchar_t c : input) + { + output.push_back(c >= 0 && c <= 0x7F ? static_cast(c) : '?'); + } + return output; + } + + auto fstring_to_utf8(FString& value) -> std::string + { + const auto* characters = *value; + return characters ? narrow(std::wstring{characters}) : std::string{}; + } + + auto widen_utf8(std::string_view input) -> std::wstring + { + if (input.empty()) + { + return {}; + } + + const int characters = ::MultiByteToWideChar(CP_UTF8, 0, input.data(), static_cast(input.size()), nullptr, 0); + if (characters <= 0) + { + return {input.begin(), input.end()}; + } + + std::wstring output(static_cast(characters), L'\0'); + ::MultiByteToWideChar(CP_UTF8, 0, input.data(), static_cast(input.size()), output.data(), characters); + return output; + } + + auto tokenize(std::string_view command) -> std::vector + { + std::vector result; + std::string current; + bool quoted = false; + bool escaped = false; + + for (char c : command) + { + if (escaped) + { + current.push_back(c); + escaped = false; + continue; + } + if (c == '\\') + { + escaped = true; + continue; + } + if (c == '"') + { + quoted = !quoted; + continue; + } + if (!quoted && std::isspace(static_cast(c))) + { + if (!current.empty()) + { + result.emplace_back(std::move(current)); + current.clear(); + } + continue; + } + current.push_back(c); + } + + if (escaped) + { + current.push_back('\\'); + } + if (!current.empty()) + { + result.emplace_back(std::move(current)); + } + return result; + } + + auto is_steam_id64(std::string_view value) -> bool + { + return value.size() == 17 && + std::all_of(value.begin(), value.end(), [](unsigned char c) { return std::isdigit(c) != 0; }); + } + + template + auto find_object(const wchar_t* path) -> T* + { + return RC::Unreal::UObjectGlobals::StaticFindObject(nullptr, nullptr, path); + } + + auto class_is_child_of(UClass* candidate, UClass* expected_base) -> bool + { + for (auto* current = candidate; current; current = static_cast(current->GetSuperStruct())) + { + if (current == expected_base) + { + return true; + } + } + return false; + } + + auto find_property(UClass* klass, const wchar_t* name) -> FProperty* + { + for (auto* current = klass; current; current = static_cast(current->GetSuperStruct())) + { + for (FProperty* property : RC::Unreal::TFieldRange(current, RC::Unreal::EFieldIterationFlags::IncludeDeprecated)) + { + if (property && property->GetName() == name) + { + return property; + } + } + } + return nullptr; + } + + auto is_parameter(FProperty* property) -> bool + { + return property && property->HasAnyPropertyFlags(RC::Unreal::EPropertyFlags::CPF_Parm); + } + + auto property_name(FProperty* property) -> std::string + { + return property ? narrow(property->GetName()) : std::string{}; + } + + auto property_description(FProperty* property) -> std::string + { + if (!property) + { + return ""; + } + return property_name(property) + ":" + narrow(property->GetClass().GetName()); + } + + auto is_chat_type_property(FProperty* property) -> bool + { + return property && + (property->IsA() || + property->IsA() || + property->IsA() || + property->IsA() || + property->IsA()); + } + + class FunctionParameterBuffer + { + public: + explicit FunctionParameterBuffer(UFunction* function) + { + const size_t byte_count = function ? std::max(function->GetPropertiesSize(), 1) : 0; + m_byte_capacity = byte_count; + if (byte_count == 0) + { + return; + } + + m_storage = static_cast(::operator new(byte_count, std::align_val_t{alignof(std::max_align_t)})); + std::memset(m_storage, 0, byte_count); + + if (!function) + { + return; + } + + for (FProperty* property : RC::Unreal::TFieldRange(function, RC::Unreal::EFieldIterationFlags::IncludeDeprecated)) + { + if (!is_parameter(property) || !property->IsA()) + { + continue; + } + const auto offset = property->GetOffset_Internal(); + if (!contains(offset, sizeof(FString))) + { + m_valid = false; + continue; + } + auto* field = reinterpret_cast(bytes() + offset); + new (field) FString{}; + m_strings.emplace_back(field); + } + } + + ~FunctionParameterBuffer() + { + for (auto* value : m_strings) + { + value->~FString(); + } + ::operator delete(m_storage, std::align_val_t{alignof(std::max_align_t)}); + } + + FunctionParameterBuffer(const FunctionParameterBuffer&) = delete; + FunctionParameterBuffer& operator=(const FunctionParameterBuffer&) = delete; + + auto valid() const -> bool { return m_valid && m_storage != nullptr; } + auto data() -> void* { return m_storage; } + + auto write_object(FProperty* property, UObject* value) -> bool + { + if (!property || !contains(property->GetOffset_Internal(), sizeof(value))) + { + return false; + } + std::memcpy(bytes() + property->GetOffset_Internal(), &value, sizeof(value)); + return true; + } + + auto write_string(FProperty* property, std::string_view value) -> bool + { + if (!property || !property->IsA() || !contains(property->GetOffset_Internal(), sizeof(FString))) + { + return false; + } + auto* field = reinterpret_cast(bytes() + property->GetOffset_Internal()); + const auto wide = widen_utf8(value); + *field = FString{wide.c_str()}; + return true; + } + + auto write_small_integer(FProperty* property, int64_t value) -> bool + { + if (!property || !is_chat_type_property(property)) + { + return false; + } + + const auto offset = property->GetOffset_Internal(); + switch (property->GetSize()) + { + case 1: { + const auto written = static_cast(value); + if (!contains(offset, sizeof(written))) return false; + std::memcpy(bytes() + offset, &written, sizeof(written)); + return true; + } + case 2: { + const auto written = static_cast(value); + if (!contains(offset, sizeof(written))) return false; + std::memcpy(bytes() + offset, &written, sizeof(written)); + return true; + } + case 4: { + const auto written = static_cast(value); + if (!contains(offset, sizeof(written))) return false; + std::memcpy(bytes() + offset, &written, sizeof(written)); + return true; + } + case 8: { + const auto written = static_cast(value); + if (!contains(offset, sizeof(written))) return false; + std::memcpy(bytes() + offset, &written, sizeof(written)); + return true; + } + default: + return false; + } + } + + auto read_string(FProperty* property) const -> std::optional + { + if (!property || !property->IsA() || !contains(property->GetOffset_Internal(), sizeof(FString))) + { + return std::nullopt; + } + auto* field = reinterpret_cast(const_cast(bytes() + property->GetOffset_Internal())); + return fstring_to_utf8(*field); + } + + private: + auto bytes() -> uint8_t* { return m_storage; } + auto bytes() const -> const uint8_t* { return m_storage; } + + auto contains(int32_t offset, size_t size) const -> bool + { + return offset >= 0 && static_cast(offset) + size <= m_byte_capacity; + } + + uint8_t* m_storage{}; + std::vector m_strings; + size_t m_byte_capacity{}; + bool m_valid{true}; + }; + + struct ChatSchema + { + FProperty* target_controller{}; + FProperty* world_context{}; + FProperty* message{}; + FProperty* message_type{}; + std::string detail; + }; + + auto discover_chat_schema(UFunction* function, ChatSchema& schema) -> bool + { + if (!function) + { + schema.detail = "SendChatLineToPlayer UFunction is not available"; + return false; + } + + const uint16_t return_offset = function->GetReturnValueOffset(); + std::vector objects; + std::vector strings; + std::vector numeric; + std::vector unsupported; + + for (FProperty* property : RC::Unreal::TFieldRange(function, RC::Unreal::EFieldIterationFlags::IncludeDeprecated)) + { + if (!is_parameter(property) || property->GetOffset_Internal() == return_offset) + { + continue; + } + + const auto name = lowercase(property_name(property)); + if (property->IsA()) + { + if (name.find("worldcontext") != std::string::npos) + { + if (schema.world_context) + { + unsupported.emplace_back(property_description(property)); + } + else + { + schema.world_context = property; + } + } + else + { + objects.emplace_back(property); + } + } + else if (property->IsA()) + { + strings.emplace_back(property); + } + else if (is_chat_type_property(property)) + { + numeric.emplace_back(property); + } + else + { + unsupported.emplace_back(property_description(property)); + } + } + + if (objects.size() == 1) schema.target_controller = objects.front(); + if (strings.size() == 1) schema.message = strings.front(); + if (numeric.size() == 1) schema.message_type = numeric.front(); + + if (schema.target_controller && schema.message && schema.message_type && unsupported.empty() && + objects.size() == 1 && strings.size() == 1 && numeric.size() == 1) + { + schema.detail = "schema=" + property_description(schema.target_controller) + "," + + property_description(schema.message) + "," + + property_description(schema.message_type); + return true; + } + + std::ostringstream output; + output << "unsupported SendChatLineToPlayer parameter schema"; + for (FProperty* property : objects) output << "; object=" << property_description(property); + for (FProperty* property : strings) output << "; string=" << property_description(property); + for (FProperty* property : numeric) output << "; numeric=" << property_description(property); + for (const auto& property : unsupported) output << "; unsupported=" << property; + schema.detail = output.str(); + return false; + } + + auto controller_has_live_network_player(UObject* controller, UClass* net_connection_base) -> bool + { + if (!controller || !controller->GetClassPrivate() || !net_connection_base) + { + return false; + } + + // A synthetic controller created for offline AdminCommand dispatch has + // no live UNetConnection. Requiring the inherited Player property to + // be an actual NetConnection prevents it (and CDOs) from ever becoming + // a chat recipient. + auto* player_property = find_property(controller->GetClassPrivate(), STR("Player")); + if (!player_property || !player_property->IsA()) + { + return false; + } + + const auto* player = player_property->ContainerPtrToValuePtr(controller); + return player && *player && (*player)->GetClassPrivate() && + class_is_child_of((*player)->GetClassPrivate(), net_connection_base); + } + + auto call_get_user_id(UObject* controller, UFunction* function, std::string& steam_id) -> bool + { + if (!controller || !function || function->GetReturnValueOffset() == k_no_return_value) + { + return false; + } + + auto* return_property = function->GetReturnProperty(); + if (!return_property || !return_property->IsA()) + { + return false; + } + + FunctionParameterBuffer parameters{function}; + if (!parameters.valid()) + { + return false; + } + controller->ProcessEvent(function, parameters.data()); + const auto result = parameters.read_string(return_property); + if (!result || !is_steam_id64(*result)) + { + return false; + } + steam_id = *result; + return true; + } +} + +namespace simple_rcon +{ + struct RealChatDispatcher::RuntimeState + { + UClass* controller_base{}; + UClass* net_connection_base{}; + UFunction* get_user_id{}; + UObject* misc_statics{}; + UFunction* send_chat_line{}; + std::string detail{"not resolved yet"}; + }; + + RealChatDispatcher::RealChatDispatcher() : m_state(new RuntimeState{}) {} + + RealChatDispatcher::~RealChatDispatcher() + { + delete m_state; + } + + namespace + { + auto resolve_runtime(RealChatDispatcher::RuntimeState& state, std::string& detail) -> bool + { + if (!state.controller_base) + { + state.controller_base = find_object(STR("/Script/SCUM.ConZPlayerController")); + if (!state.controller_base) + { + state.controller_base = find_object(STR("/Script/ConZ.ConZPlayerController")); + } + } + if (!state.net_connection_base) + { + state.net_connection_base = find_object(STR("/Script/Engine.NetConnection")); + } + if (!state.get_user_id) + { + state.get_user_id = find_object(STR("/Script/SCUM.ConZPlayerController:GetUserId")); + if (!state.get_user_id) + { + state.get_user_id = find_object(STR("/Script/ConZ.ConZPlayerController:GetUserId")); + } + } + if (!state.misc_statics) + { + state.misc_statics = find_object(STR("/Script/SCUM.Default__MiscStatics")); + } + if (!state.send_chat_line) + { + state.send_chat_line = find_object(STR("/Script/SCUM.MiscStatics:SendChatLineToPlayer")); + } + + if (!state.controller_base || !state.net_connection_base || !state.get_user_id || !state.misc_statics || !state.send_chat_line) + { + std::ostringstream output; + output << "chat runtime unavailable:"; + if (!state.controller_base) output << " ConZPlayerController"; + if (!state.net_connection_base) output << " NetConnection"; + if (!state.get_user_id) output << " GetUserId"; + if (!state.misc_statics) output << " Default__MiscStatics"; + if (!state.send_chat_line) output << " SendChatLineToPlayer"; + state.detail = output.str(); + detail = state.detail; + return false; + } + + ChatSchema schema{}; + if (!discover_chat_schema(state.send_chat_line, schema)) + { + state.detail = schema.detail; + detail = state.detail; + return false; + } + + state.detail = "real controller lookup + " + schema.detail; + detail = state.detail; + return true; + } + + auto send_chat_line(RealChatDispatcher::RuntimeState& state, + UObject* target_controller, + int type, + std::string_view message, + std::string& detail) -> bool + { + ChatSchema schema{}; + if (!discover_chat_schema(state.send_chat_line, schema)) + { + detail = schema.detail; + return false; + } + + FunctionParameterBuffer parameters{state.send_chat_line}; + if (!parameters.valid() || + !parameters.write_object(schema.target_controller, target_controller) || + (schema.world_context && !parameters.write_object(schema.world_context, target_controller)) || + !parameters.write_string(schema.message, message) || + !parameters.write_small_integer(schema.message_type, type)) + { + detail = "could not populate SendChatLineToPlayer parameters; " + schema.detail; + return false; + } + + state.misc_statics->ProcessEvent(state.send_chat_line, parameters.data()); + detail = schema.detail; + return true; + } + + auto find_live_controller_by_steam_id(RealChatDispatcher::RuntimeState& state, + std::string_view steam_id) -> UObject* + { + UObject* result{}; + RC::Unreal::UObjectGlobals::ForEachUObject([&](UObject* object, int32_t, int32_t) { + if (result || !object || !object->GetClassPrivate() || + !class_is_child_of(object->GetClassPrivate(), state.controller_base) || + !controller_has_live_network_player(object, state.net_connection_base)) + { + return RC::LoopAction::Continue; + } + + std::string candidate_id; + if (call_get_user_id(object, state.get_user_id, candidate_id) && candidate_id == steam_id) + { + result = object; + return RC::LoopAction::Break; + } + return RC::LoopAction::Continue; + }); + return result; + } + + auto enumerate_live_controllers(RealChatDispatcher::RuntimeState& state) -> std::vector + { + std::vector result; + RC::Unreal::UObjectGlobals::ForEachUObject([&](UObject* object, int32_t, int32_t) { + if (!object || !object->GetClassPrivate() || + !class_is_child_of(object->GetClassPrivate(), state.controller_base) || + !controller_has_live_network_player(object, state.net_connection_base)) + { + return RC::LoopAction::Continue; + } + + std::string steam_id; + if (call_get_user_id(object, state.get_user_id, steam_id)) + { + result.emplace_back(object); + } + return RC::LoopAction::Continue; + }); + return result; + } + } + + RealChatDispatcher::Result RealChatDispatcher::dispatch_if_chat(std::string_view command) + { + const auto tokens = tokenize(command); + if (tokens.empty() || lowercase(tokens.front()) != "sendchat") + { + return {}; + } + + Result result{}; + result.handled = true; + if (tokens.size() < 3 || tokens.size() > 4) + { + result.message = "usage: SendChat \"message\" [target SteamID64]"; + return result; + } + + int type{}; + const auto [end, error] = std::from_chars(tokens[1].data(), tokens[1].data() + tokens[1].size(), type); + if (error != std::errc{} || end != tokens[1].data() + tokens[1].size() || type < 0 || type > 7) + { + result.message = "SendChat type must be an integer from 0 through 7"; + return result; + } + if (tokens[2].empty() || tokens[2].size() > k_max_message_bytes) + { + result.message = "SendChat message must contain 1-1024 UTF-8 bytes"; + return result; + } + + std::string target_steam_id; + if (tokens.size() == 4) + { + target_steam_id = tokens[3]; + if (!is_steam_id64(target_steam_id)) + { + result.message = "SendChat target must be a 17-digit SteamID64"; + return result; + } + } + + if (!m_state) + { + result.message = "chat dispatcher has no runtime state"; + return result; + } + + std::string detail; + if (!resolve_runtime(*m_state, detail)) + { + result.message = detail; + return result; + } + + if (!target_steam_id.empty()) + { + auto* target = find_live_controller_by_steam_id(*m_state, target_steam_id); + if (!target) + { + result.message = "SendChat target " + target_steam_id + " is not online with a real network controller"; + return result; + } + if (!send_chat_line(*m_state, target, type, tokens[2], detail)) + { + result.message = detail; + return result; + } + + result.success = true; + result.message = "ok: SendChat delivered to online SteamID " + target_steam_id; + return result; + } + + const auto targets = enumerate_live_controllers(*m_state); + if (targets.empty()) + { + result.message = "SendChat broadcast has no real online player controllers"; + return result; + } + + int sent{}; + for (auto* target : targets) + { + if (!send_chat_line(*m_state, target, type, tokens[2], detail)) + { + result.message = detail; + return result; + } + ++sent; + } + + result.success = true; + result.message = "ok: SendChat broadcast delivered to " + std::to_string(sent) + " real online player(s)"; + return result; + } + + RealChatDispatcher::Result RealChatDispatcher::probe() + { + Result result{}; + result.handled = true; + if (!m_state) + { + result.message = "chat dispatcher has no runtime state"; + return result; + } + + std::string detail; + if (!resolve_runtime(*m_state, detail)) + { + result.message = detail; + return result; + } + + const auto online = enumerate_live_controllers(*m_state); + result.success = true; + result.message = "ok: real chat dispatcher ready; online_real_controllers=" + std::to_string(online.size()) + "; " + detail; + return result; + } + + std::string RealChatDispatcher::status() const + { + return m_state ? "real_chat=" + m_state->detail : "real_chat=no runtime state"; + } +} diff --git a/src/ScumBridge.cpp b/src/ScumBridge.cpp new file mode 100644 index 0000000..848ebc6 --- /dev/null +++ b/src/ScumBridge.cpp @@ -0,0 +1,319 @@ +#include "ScumBridge.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace +{ + auto is_steam_id64(const std::string& value) -> bool + { + return value.size() == 17 && + std::all_of(value.begin(), value.end(), [](unsigned char c) { return std::isdigit(c) != 0; }); + } + + auto find_steam_ids_in_line(const std::string& line) -> std::vector + { + std::vector ids; + for (size_t begin = 0; begin < line.size();) + { + if (!std::isdigit(static_cast(line[begin]))) + { + ++begin; + continue; + } + + size_t end = begin; + while (end < line.size() && std::isdigit(static_cast(line[end]))) + { + ++end; + } + if (end - begin == 17) + { + ids.emplace_back(line.substr(begin, 17)); + } + begin = end; + } + return ids; + } + + auto path_to_utf8(const std::filesystem::path& path) -> std::string + { + return path.string(); + } +} + +namespace simple_rcon +{ + void ScumBridge::configure(const Config& config, const std::filesystem::path& mod_directory) + { + std::scoped_lock lock{m_mutex}; + m_require_configured_admin = config.require_configured_admin; + m_preferred_admin_steam_id = config.preferred_admin_steam_id; + m_admin_users_file = resolve_admin_users_file(config, mod_directory); + m_admin_ids.clear(); + m_admin_file_seen = false; + m_admin_users_last_write = {}; + m_offline_executor.configure(config.offline_admin_dispatch, config.native_admin_executor); + } + + void ScumBridge::on_unreal_init() + { + std::scoped_lock lock{m_mutex}; + m_unreal_ready = true; + m_offline_executor.on_unreal_init(); + refresh_admin_ids_locked(); + RC::Output::send( + STR("[scum_simple_rcon] SCUM bridge ready; AdminUsers.ini: {} ({} IDs)\n"), + m_admin_users_file.wstring(), + m_admin_ids.size()); + } + + std::string ScumBridge::execute(const std::string& command) + { + const auto cmd = trim_command(command); + if (cmd.empty()) + { + return "error: empty command"; + } + + std::scoped_lock lock{m_mutex}; + if (!m_unreal_ready) + { + return "error: Unreal bridge not ready yet"; + } + + if (cmd == "__scum_simple_rcon_chat_probe__") + { + const auto chat = m_real_chat.probe(); + return chat.success ? chat.message : "error: " + chat.message; + } + + // Ordinary SendChat is intentionally separate from admin-command + // execution. It resolves only real, online target controllers and + // does not borrow a PlayerRpcChannel or require an administrator ID. + const auto chat = m_real_chat.dispatch_if_chat(cmd); + if (chat.handled) + { + return chat.success ? chat.message : "error: " + chat.message; + } + + refresh_admin_ids_locked(); + const std::string admin_id = selected_admin_id_locked(); + if (m_require_configured_admin && admin_id.empty()) + { + return "error: no valid SteamID64 found in AdminUsers.ini: " + path_to_utf8(m_admin_users_file); + } + + if (cmd == "__scum_simple_rcon_probe__") + { + const auto probe = m_offline_executor.probe(admin_id); + return probe.success ? probe.message : "error: " + probe.message; + } + + const auto offline = m_offline_executor.execute(cmd, admin_id); + if (offline.attempted) + { + return offline.success ? offline.message : "error: " + offline.message; + } + + std::string context_detail; + auto* context = resolve_server_context(context_detail); + if (!context) + { + return "error: offline dispatcher skipped (" + offline.message + ") and no server execution context: " + context_detail; + } + + std::string dispatch_detail; + if (!process_server_console(context, widen_utf8(cmd), dispatch_detail)) + { + return "error: offline dispatcher skipped (" + offline.message + ") and server console dispatch failed: " + dispatch_detail; + } + + return "ok: server-console-dispatched '" + cmd + "'" + + (admin_id.empty() ? std::string{} : " (configured admin " + admin_id + ")"); + } + + std::string ScumBridge::admin_status() + { + std::scoped_lock lock{m_mutex}; + refresh_admin_ids_locked(); + + std::ostringstream reply; + reply << "AdminUsers.ini=" << path_to_utf8(m_admin_users_file) + << "; configured_admins=" << m_admin_ids.size(); + const auto selected = selected_admin_id_locked(); + if (!selected.empty()) + { + reply << "; selected=" << selected; + } + return reply.str(); + } + + std::string ScumBridge::dispatch_status() + { + std::scoped_lock lock{m_mutex}; + return m_offline_executor.status(); + } + + RC::Unreal::UObject* ScumBridge::resolve_server_context(std::string& detail) + { + // These are server-owned objects and exist independently of connected + // players. FindFirstOf deliberately ignores class-default objects. + if (auto* game_mode = RC::Unreal::UObjectGlobals::FindFirstOf(STR("GameModeBase"))) + { + detail = "GameModeBase"; + return game_mode; + } + if (auto* game_instance = RC::Unreal::UObjectGlobals::FindFirstOf(STR("GameInstance"))) + { + detail = "GameInstance"; + return game_instance; + } + if (auto* world = RC::Unreal::UObjectGlobals::FindFirstOf(STR("World"))) + { + detail = "World"; + return world; + } + + detail = "GameModeBase, GameInstance, and World are not available yet"; + return nullptr; + } + + bool ScumBridge::process_server_console(RC::Unreal::UObject* context, + const std::wstring& command, + std::string& detail) + { + RC::Unreal::FOutputDevice output{}; + if (context->ProcessConsoleExec(command.c_str(), output, context)) + { + detail = "ProcessConsoleExec returned true"; + return true; + } + + // Fallback: UE's string invocation path can resolve command-like UFUNCTIONs + // that do not respond through ProcessConsoleExec on the first context. + if (RC::Unreal::UObject::CallFunctionByNameWithArgumentsInternal(context, command.c_str(), output, context, true)) + { + detail = "CallFunctionByNameWithArgumentsInternal returned true"; + return true; + } + + detail = "ProcessConsoleExec and CallFunctionByNameWithArgumentsInternal both returned false"; + return false; + } + + void ScumBridge::refresh_admin_ids_locked() + { + if (m_admin_users_file.empty()) + { + return; + } + + std::error_code ec; + const bool exists = std::filesystem::exists(m_admin_users_file, ec); + if (ec || !exists) + { + if (m_admin_file_seen) + { + m_admin_ids.clear(); + m_admin_file_seen = false; + } + return; + } + + const auto last_write = std::filesystem::last_write_time(m_admin_users_file, ec); + if (ec || (m_admin_file_seen && last_write == m_admin_users_last_write)) + { + return; + } + + std::ifstream file{m_admin_users_file}; + if (!file) + { + return; + } + + std::unordered_set next; + std::string line; + while (std::getline(file, line)) + { + const auto comment = line.find_first_of(";#"); + if (comment != std::string::npos) + { + line.resize(comment); + } + for (const auto& id : find_steam_ids_in_line(line)) + { + next.insert(id); + } + } + + m_admin_ids = std::move(next); + m_admin_users_last_write = last_write; + m_admin_file_seen = true; + } + + std::string ScumBridge::selected_admin_id_locked() const + { + if (is_steam_id64(m_preferred_admin_steam_id) && m_admin_ids.contains(m_preferred_admin_steam_id)) + { + return m_preferred_admin_steam_id; + } + + if (m_admin_ids.empty()) + { + return {}; + } + + std::vector ordered{m_admin_ids.begin(), m_admin_ids.end()}; + std::sort(ordered.begin(), ordered.end()); + return ordered.front(); + } + + std::filesystem::path ScumBridge::resolve_admin_users_file(const Config& config, + const std::filesystem::path& mod_directory) + { + if (!config.admin_users_file.empty() && config.admin_users_file != "auto") + { + const std::filesystem::path configured{config.admin_users_file}; + return configured.is_absolute() ? configured : mod_directory / configured; + } + + // /Binaries/Win64/ue4ss/Mods/scum_simple_rcon + const auto win64 = mod_directory.parent_path().parent_path().parent_path(); + const auto scum_root = win64.parent_path().parent_path(); + return scum_root / "Saved" / "Config" / "WindowsServer" / "AdminUsers.ini"; + } + + std::wstring ScumBridge::widen_utf8(const std::string& s) + { + if (s.empty()) + { + return {}; + } + std::wstring_convert> conv; + return conv.from_bytes(s); + } + + std::string ScumBridge::trim_command(std::string s) + { + if (!s.empty() && s[0] == '#') + { + s.erase(s.begin()); + } + const auto not_space = [](unsigned char c) { return c != ' ' && c != '\t' && c != '\r' && c != '\n'; }; + s.erase(s.begin(), std::find_if(s.begin(), s.end(), not_space)); + s.erase(std::find_if(s.rbegin(), s.rend(), not_space).base(), s.end()); + return s; + } +} diff --git a/src/SimpleRconMod.cpp b/src/SimpleRconMod.cpp new file mode 100644 index 0000000..55472c8 --- /dev/null +++ b/src/SimpleRconMod.cpp @@ -0,0 +1,238 @@ +#include "SimpleRconMod.hpp" + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace +{ + std::string trim(std::string s) + { + auto not_space = [](unsigned char c) { return c != ' ' && c != '\t' && c != '\r' && c != '\n'; }; + s.erase(s.begin(), std::find_if(s.begin(), s.end(), not_space)); + s.erase(std::find_if(s.rbegin(), s.rend(), not_space).base(), s.end()); + return s; + } + + std::filesystem::path mod_dir() + { + HMODULE module{}; + wchar_t path[MAX_PATH]{}; + if (::GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&mod_dir), + &module) && + ::GetModuleFileNameW(module, path, MAX_PATH) > 0) + { + auto p = std::filesystem::path(path); + // .../Mods/scum_simple_rcon/dlls/main.dll -> .../Mods/scum_simple_rcon + return p.parent_path().parent_path(); + } + return std::filesystem::current_path() / "ue4ss" / "Mods" / "scum_simple_rcon"; + } +} + +namespace simple_rcon +{ + Config load_config(const std::filesystem::path& path) + { + Config cfg{}; + std::ifstream in(path); + if (!in) + { + return cfg; + } + + std::string section; + std::string line; + while (std::getline(in, line)) + { + line = trim(line); + if (line.empty() || line[0] == ';' || line[0] == '#') + { + continue; + } + if (line.front() == '[' && line.back() == ']') + { + section = line.substr(1, line.size() - 2); + continue; + } + const auto eq = line.find('='); + if (eq == std::string::npos) + { + continue; + } + const std::string key = trim(line.substr(0, eq)); + const std::string value = trim(line.substr(eq + 1)); + try + { + if (section == "rcon") + { + if (key == "bind_address") cfg.bind_address = value; + else if (key == "port") + { + const int port = std::stoi(value); + if (port >= 1 && port <= 65535) cfg.port = static_cast(port); + } + else if (key == "password") cfg.password = value; + else if (key == "auth_log") cfg.auth_log = value == "true" || value == "1"; + else if (key == "max_connections") cfg.max_connections = std::clamp(std::stoi(value), 1, 32); + else if (key == "packet_body_bytes") cfg.packet_body_bytes = std::clamp(std::stoi(value), 1, 4000); + else if (key == "command_timeout_ms") + { + cfg.command_timeout = std::chrono::milliseconds{std::clamp(std::stoi(value), 100, 60000)}; + } + else if (key == "allow_empty_password") cfg.allow_empty_password = value == "true" || value == "1"; + } + else if (section == "dispatch") + { + if (key == "context") cfg.dispatch_context = value; + else if (key == "offline_admin_dispatch") cfg.offline_admin_dispatch = value == "true" || value == "1"; + else if (key == "native_admin_executor") cfg.native_admin_executor = value == "true" || value == "1"; + } + else if (section == "admin") + { + if (key == "users_file") cfg.admin_users_file = value; + else if (key == "preferred_steam_id") cfg.preferred_admin_steam_id = value; + else if (key == "require_configured_admin") cfg.require_configured_admin = value == "true" || value == "1"; + } + } + catch (const std::exception&) + { + // Ignore an invalid individual value and retain the safe default. + } + } + return cfg; + } + + SimpleRconMod::SimpleRconMod() + : m_config(load_config(mod_dir() / "config.ini")), + m_queue([this](const std::string& command) { return m_bridge.execute(command); }) + { + ModName = STR("scum_simple_rcon"); + ModVersion = STR("0.1.0"); + ModDescription = STR("Minimal Source RCON server for SCUM via UE4SS"); + ModAuthors = STR("Codex"); + m_bridge.configure(m_config, mod_dir()); + } + + SimpleRconMod::~SimpleRconMod() + { + if (m_server) + { + m_server->stop(); + } + m_queue.shutdown(); + if (m_tick_callback_id != 0) + { + RC::Unreal::Hook::UnregisterCallback(m_tick_callback_id); + } + } + + auto SimpleRconMod::on_unreal_init() -> void + { + m_bridge.on_unreal_init(); + install_game_thread_drain(); + + if (!m_config.allow_empty_password && (m_config.password.empty() || m_config.password == "CHANGE_ME_BEFORE_USE")) + { + log("rcon: listener NOT started; set [rcon] password in config.ini"); + return; + } + + m_server = std::make_unique( + m_config, + [this](const std::string& command) { return handle_rcon_command(command); }, + [this](const std::string& line) { log(line); }); + + if (!m_server->start()) + { + log("rcon: listener failed to start"); + } + } + + auto SimpleRconMod::on_update() -> void + { + // Fallback path if EngineTick hook is unavailable in a given UE4SS build. + if (!m_hook_installed) + { + drain_game_thread(); + } + } + + void SimpleRconMod::install_game_thread_drain() + { + if (m_hook_installed) + { + return; + } + + if (!RC::UE4SSRuntime::IsEngineTickAvailable()) + { + log("game-thread drain: EngineTick unavailable; falling back to on_update"); + return; + } + + m_tick_callback_id = RC::Unreal::Hook::RegisterEngineTickPreCallback( + [this](auto&, RC::Unreal::UEngine*, float, bool) { + drain_game_thread(); + }, + {true, true, STR("scum_simple_rcon"), STR("DrainCommands")}); + + m_hook_installed = m_tick_callback_id != 0; + log(m_hook_installed ? "game-thread drain installed via EngineTick" : "game-thread drain install failed"); + } + + void SimpleRconMod::drain_game_thread() + { + m_queue.drain(32); + } + + std::string SimpleRconMod::handle_rcon_command(const std::string& command) + { + const auto cmd = trim(command); + if (cmd.empty()) + { + return "error: empty command"; + } + + if (cmd == "rcon.status") + { + return "scum_simple_rcon: ok; game-thread queue active"; + } + if (cmd == "rcon.help") + { + return "internal: rcon.status, rcon.admins, rcon.dispatch, rcon.chat, rcon.help; SendChat uses real online controllers; otherwise send raw SCUM command text"; + } + if (cmd == "rcon.admins") + { + return m_bridge.admin_status(); + } + if (cmd == "rcon.dispatch") + { + // A dispatcher probe creates UObject state and scans the game + // executable, so it must take the exact same game-thread path as + // an actual RCON command. + return m_queue.enqueue_and_wait("__scum_simple_rcon_probe__", m_config.command_timeout); + } + if (cmd == "rcon.chat") + { + return m_queue.enqueue_and_wait("__scum_simple_rcon_chat_probe__", m_config.command_timeout); + } + + return m_queue.enqueue_and_wait(cmd, m_config.command_timeout); + } + + void SimpleRconMod::log(const std::string& line) + { + const std::wstring wide(line.begin(), line.end()); + RC::Output::send(STR("[scum_simple_rcon] {}\n"), wide); + } +} diff --git a/src/dllmain.cpp b/src/dllmain.cpp new file mode 100644 index 0000000..0c5cf0a --- /dev/null +++ b/src/dllmain.cpp @@ -0,0 +1,14 @@ +#include "SimpleRconMod.hpp" + +extern "C" +{ + __declspec(dllexport) RC::CppUserModBase* start_mod() + { + return new simple_rcon::SimpleRconMod(); + } + + __declspec(dllexport) void uninstall_mod(RC::CppUserModBase* mod) + { + delete mod; + } +} diff --git a/ue4ss/Mods/mods.txt b/ue4ss/Mods/mods.txt new file mode 100644 index 0000000..1d767d5 --- /dev/null +++ b/ue4ss/Mods/mods.txt @@ -0,0 +1,2 @@ +; Add this line to your server's existing ue4ss/Mods/mods.txt +scum_simple_rcon : 1 diff --git a/ue4ss/Mods/scum_simple_rcon/config.ini b/ue4ss/Mods/scum_simple_rcon/config.ini new file mode 100644 index 0000000..0cc317e --- /dev/null +++ b/ue4ss/Mods/scum_simple_rcon/config.ini @@ -0,0 +1,36 @@ +; Minimal SCUM Source RCON over UE4SS. +; Change password before starting the server. + +[rcon] +bind_address = 127.0.0.1 +port = 27015 +password = CHANGE_ME_BEFORE_USE +auth_log = true +max_connections = 4 +packet_body_bytes = 4000 +command_timeout_ms = 10000 +allow_empty_password = false + +[dispatch] +; Primary path: create a server-only BP_ConZPlayerController + ConZCharacter, +; then invoke SCUM's AdminCommand executor. This does not require any player +; (including an administrator) to be connected. +offline_admin_dispatch = true +; The executor is discovered by one exact SCUM build signature. If SCUM updates +; and it no longer resolves uniquely, commands fail safely and rcon.dispatch +; reports the reason instead of calling an untrusted address. +native_admin_executor = true +; Fallback only for non-AdminCommand Unreal console commands. +context = auto + +[admin] +; auto = /Saved/Config/WindowsServer/AdminUsers.ini +; This list is re-read when the file changes. It is never substituted with +; the first online player. +users_file = auto +; Optional selection only: this ID must also already exist in AdminUsers.ini. +; Leave empty to use the first configured admin ID deterministically. This is +; not an authorization entry and never substitutes an online player. +preferred_steam_id = +; Refuse game commands if AdminUsers.ini is missing or has no valid SteamID64. +require_configured_admin = true diff --git a/ue4ss/Mods/scum_simple_rcon/enabled.txt b/ue4ss/Mods/scum_simple_rcon/enabled.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ue4ss/Mods/scum_simple_rcon/enabled.txt @@ -0,0 +1 @@ +