This commit is contained in:
2026-08-25 16:56:37 +08:00
commit 2cc77c577e
22 changed files with 3339 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <chrono>
#include <condition_variable>
#include <functional>
#include <future>
#include <memory>
#include <mutex>
#include <queue>
#include <string>
namespace simple_rcon
{
class CommandQueue
{
public:
using Executor = std::function<std::string(const std::string&)>;
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<std::string> result;
};
Executor m_executor;
std::mutex m_mutex;
std::queue<std::shared_ptr<Task>> m_tasks;
bool m_stopping{false};
};
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <string>
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);
}
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <string>
#include <string_view>
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};
};
}
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include <atomic>
#include <chrono>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
#include <unordered_set>
#include <vector>
#include <winsock2.h>
#include "Config.hpp"
namespace simple_rcon
{
class RconServer
{
public:
using CommandHandler = std::function<std::string(const std::string&)>;
using Logger = std::function<void(const std::string&)>;
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<bool> m_running{false};
std::atomic<int> m_active_clients{0};
std::thread m_accept_thread;
SOCKET m_listen_socket{INVALID_SOCKET};
std::mutex m_clients_mutex;
std::unordered_set<SOCKET> m_clients;
std::vector<std::thread> m_client_threads;
};
}
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <string>
#include <string_view>
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 <type 0-7> "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};
};
}
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <filesystem>
#include <mutex>
#include <string>
#include <unordered_set>
#include <vector>
#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<std::string> m_admin_ids;
OfflineAdminExecutor m_offline_executor;
RealChatDispatcher m_real_chat;
};
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <memory>
#include <string>
#include <Mod/CppUserModBase.hpp>
#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<RconServer> m_server;
bool m_hook_installed{false};
uint64_t m_tick_callback_id{0};
};
}