This commit is contained in:
2026-08-25 16:56:37 +08:00
commit 2cc77c577e
22 changed files with 3339 additions and 0 deletions
+79
View File
@@ -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 <type 0-7> "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.
+65
View File
@@ -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.
+52
View File
@@ -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"
)
+83
View File
@@ -0,0 +1,83 @@
# Install
1. Build the mod; see `BUILD.md`.
2. Copy the built folder into the SCUM server Win64 directory:
```text
<ServerInstall>\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
<ServerInstall>\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.
+90
View File
@@ -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
<ServerInstall>\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.
+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};
};
}
+78
View File
@@ -0,0 +1,78 @@
#include "CommandQueue.hpp"
#include <exception>
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>();
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> 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<std::shared_ptr<Task>> 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();
}
}
}
+859
View File
@@ -0,0 +1,859 @@
#include "OfflineAdminExecutor.hpp"
#include <algorithm>
#include <array>
#include <cctype>
#include <cstdint>
#include <cstring>
#include <optional>
#include <sstream>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
#include <Windows.h>
#include <DynamicOutput/DynamicOutput.hpp>
#include <UnrealDef.hpp>
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<RC::Unreal::EObjectFlags>(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<uint8_t, 24> 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<char>(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<int>(input.size()), nullptr, 0);
if (characters <= 0)
{
return {input.begin(), input.end()};
}
std::wstring output(static_cast<size_t>(characters), L'\0');
::MultiByteToWideChar(CP_UTF8, 0, input.data(), static_cast<int>(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<char>(std::tolower(c));
});
return value;
}
auto tokenize(std::string_view command) -> std::vector<std::string>
{
std::vector<std::string> 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<unsigned char>(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 <typename T>
auto find_object(const wchar_t* path) -> T*
{
return RC::Unreal::UObjectGlobals::StaticFindObject<T*>(nullptr, nullptr, path);
}
auto find_property(UFunction* function, const wchar_t* name) -> FProperty*
{
if (!function)
{
return nullptr;
}
for (FProperty* property : RC::Unreal::TFieldRange<FProperty>(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<FProperty>(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 <typename T>
auto write_parameter(std::array<uint8_t, k_function_parameter_bytes>& parameters, int32_t offset, const T& value) -> bool
{
if (offset < 0 || static_cast<size_t>(offset) + sizeof(T) > parameters.size())
{
return false;
}
std::memcpy(parameters.data() + offset, &value, sizeof(T));
return true;
}
template <typename T>
auto read_parameter(const std::array<uint8_t, k_function_parameter_bytes>& parameters, int32_t offset) -> std::optional<T>
{
if (offset < 0 || static_cast<size_t>(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<UClass*>(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<const uint8_t*>(module);
const auto* dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(image);
if (dos->e_magic != IMAGE_DOS_SIGNATURE)
{
detail = "main module has no DOS header";
return nullptr;
}
const auto* nt = reinterpret_cast<const IMAGE_NT_HEADERS*>(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 = &section;
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<uint8_t*>(match + k_executor_entry_adjustment);
}
auto call_native_admin_executor(void* native_executor,
UObject* command_instance,
RC::Unreal::TArray<FString>* arguments,
bool& faulted) -> int64_t
{
using NativeAdminExecutor = int64_t(__fastcall*)(UObject*, RC::Unreal::TArray<FString>*);
const auto execute_native = reinterpret_cast<NativeAdminExecutor>(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<std::string, UClass*> 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<UObject>(STR("/Script/Engine.Default__GameplayStatics"));
auto* begin_spawn = find_object<UFunction>(STR("/Script/Engine.GameplayStatics:BeginSpawningActorFromClass"));
auto* finish_spawn = find_object<UFunction>(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<uint8_t, k_function_parameter_bytes> 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<UObject*>(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<UObject*>(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<uint8_t, k_function_parameter_bytes> 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<UObject*>(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<UObject*>(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<UFunction>(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<uint8_t, k_function_parameter_bytes> 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<FProperty>(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<RC::Unreal::FStrProperty>())
{
auto* value = property->ContainerPtrToValuePtr<FString>(object);
if (value)
{
*value = FString{steam_id_wide.c_str()};
}
}
else if (property->IsA<RC::Unreal::FUInt64Property>())
{
auto* value = property->ContainerPtrToValuePtr<uint64_t>(object);
if (value)
{
*value = numeric_id;
}
}
else if (property->IsA<RC::Unreal::FInt64Property>())
{
auto* value = property->ContainerPtrToValuePtr<int64_t>(object);
if (value)
{
*value = static_cast<int64_t>(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<UClass>(
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<UClass>(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<UObject*>(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<UClass>(STR("/Script/SCUM.ConZCharacter"));
if (!character_class)
{
character_class = find_object<UClass>(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<UFunction>(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<uint8_t, k_function_parameter_bytes> 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<UClass>(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<std::string, UClass*> next;
RC::Unreal::UObjectGlobals::ForEachUObject([&](UObject* object, int32_t, int32_t) {
if (!looks_like_UClass(object))
{
return RC::LoopAction::Continue;
}
auto* candidate = static_cast<UClass*>(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<FString> 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();
}
}
+368
View File
@@ -0,0 +1,368 @@
#include "RconServer.hpp"
#include <algorithm>
#include <array>
#include <cstring>
#include <sstream>
#include <vector>
#include <ws2tcpip.h>
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 <typename T>
T read_le(const char* p)
{
T value{};
std::memcpy(&value, p, sizeof(T));
return value;
}
template <typename T>
void append_le(std::vector<char>& out, T value)
{
const auto* p = reinterpret_cast<const char*>(&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<const char*>(&yes), sizeof(yes));
if (::bind(m_listen_socket, reinterpret_cast<sockaddr*>(&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<std::thread> 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<sockaddr*>(&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<const char*>(&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<char, 4> size_buf{};
if (!recv_all(socket, size_buf.data(), static_cast<int>(size_buf.size())))
{
return false;
}
const int32_t size = read_le<int32_t>(size_buf.data());
if (size < 10 || size > MAX_PACKET_SIZE)
{
return false;
}
std::vector<char> buf(static_cast<size_t>(size));
if (!recv_all(socket, buf.data(), size))
{
return false;
}
out.id = read_le<int32_t>(buf.data());
out.type = read_le<int32_t>(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<int32_t>(8 + body.size() + 2);
std::vector<char> packet;
packet.reserve(static_cast<size_t>(size) + 4);
append_le<int32_t>(packet, size);
append_le<int32_t>(packet, id);
append_le<int32_t>(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<int>(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<size_t>(static_cast<size_t>(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);
}
}
}
+742
View File
@@ -0,0 +1,742 @@
#include "RealChatDispatcher.hpp"
#include <algorithm>
#include <charconv>
#include <cctype>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <new>
#include <optional>
#include <sstream>
#include <string>
#include <system_error>
#include <utility>
#include <vector>
#include <Windows.h>
#include <UnrealDef.hpp>
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<char>(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<char>(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<int>(input.size()), nullptr, 0);
if (characters <= 0)
{
return {input.begin(), input.end()};
}
std::wstring output(static_cast<size_t>(characters), L'\0');
::MultiByteToWideChar(CP_UTF8, 0, input.data(), static_cast<int>(input.size()), output.data(), characters);
return output;
}
auto tokenize(std::string_view command) -> std::vector<std::string>
{
std::vector<std::string> 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<unsigned char>(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 <typename T>
auto find_object(const wchar_t* path) -> T*
{
return RC::Unreal::UObjectGlobals::StaticFindObject<T*>(nullptr, nullptr, path);
}
auto class_is_child_of(UClass* candidate, UClass* expected_base) -> bool
{
for (auto* current = candidate; current; current = static_cast<UClass*>(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<UClass*>(current->GetSuperStruct()))
{
for (FProperty* property : RC::Unreal::TFieldRange<FProperty>(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 "<null>";
}
return property_name(property) + ":" + narrow(property->GetClass().GetName());
}
auto is_chat_type_property(FProperty* property) -> bool
{
return property &&
(property->IsA<RC::Unreal::FByteProperty>() ||
property->IsA<RC::Unreal::FIntProperty>() ||
property->IsA<RC::Unreal::FInt64Property>() ||
property->IsA<RC::Unreal::FUInt64Property>() ||
property->IsA<RC::Unreal::FEnumProperty>());
}
class FunctionParameterBuffer
{
public:
explicit FunctionParameterBuffer(UFunction* function)
{
const size_t byte_count = function ? std::max<size_t>(function->GetPropertiesSize(), 1) : 0;
m_byte_capacity = byte_count;
if (byte_count == 0)
{
return;
}
m_storage = static_cast<uint8_t*>(::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<FProperty>(function, RC::Unreal::EFieldIterationFlags::IncludeDeprecated))
{
if (!is_parameter(property) || !property->IsA<RC::Unreal::FStrProperty>())
{
continue;
}
const auto offset = property->GetOffset_Internal();
if (!contains(offset, sizeof(FString)))
{
m_valid = false;
continue;
}
auto* field = reinterpret_cast<FString*>(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<RC::Unreal::FStrProperty>() || !contains(property->GetOffset_Internal(), sizeof(FString)))
{
return false;
}
auto* field = reinterpret_cast<FString*>(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<uint8_t>(value);
if (!contains(offset, sizeof(written))) return false;
std::memcpy(bytes() + offset, &written, sizeof(written));
return true;
}
case 2: {
const auto written = static_cast<uint16_t>(value);
if (!contains(offset, sizeof(written))) return false;
std::memcpy(bytes() + offset, &written, sizeof(written));
return true;
}
case 4: {
const auto written = static_cast<uint32_t>(value);
if (!contains(offset, sizeof(written))) return false;
std::memcpy(bytes() + offset, &written, sizeof(written));
return true;
}
case 8: {
const auto written = static_cast<uint64_t>(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<std::string>
{
if (!property || !property->IsA<RC::Unreal::FStrProperty>() || !contains(property->GetOffset_Internal(), sizeof(FString)))
{
return std::nullopt;
}
auto* field = reinterpret_cast<FString*>(const_cast<uint8_t*>(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<size_t>(offset) + size <= m_byte_capacity;
}
uint8_t* m_storage{};
std::vector<FString*> 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<FProperty*> objects;
std::vector<FProperty*> strings;
std::vector<FProperty*> numeric;
std::vector<std::string> unsupported;
for (FProperty* property : RC::Unreal::TFieldRange<FProperty>(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<RC::Unreal::FObjectProperty>())
{
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<RC::Unreal::FStrProperty>())
{
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<RC::Unreal::FObjectProperty>())
{
return false;
}
const auto* player = player_property->ContainerPtrToValuePtr<UObject*>(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<RC::Unreal::FStrProperty>())
{
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<UClass>(STR("/Script/SCUM.ConZPlayerController"));
if (!state.controller_base)
{
state.controller_base = find_object<UClass>(STR("/Script/ConZ.ConZPlayerController"));
}
}
if (!state.net_connection_base)
{
state.net_connection_base = find_object<UClass>(STR("/Script/Engine.NetConnection"));
}
if (!state.get_user_id)
{
state.get_user_id = find_object<UFunction>(STR("/Script/SCUM.ConZPlayerController:GetUserId"));
if (!state.get_user_id)
{
state.get_user_id = find_object<UFunction>(STR("/Script/ConZ.ConZPlayerController:GetUserId"));
}
}
if (!state.misc_statics)
{
state.misc_statics = find_object<UObject>(STR("/Script/SCUM.Default__MiscStatics"));
}
if (!state.send_chat_line)
{
state.send_chat_line = find_object<UFunction>(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<UObject*>
{
std::vector<UObject*> 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 <type 0-7> \"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";
}
}
+319
View File
@@ -0,0 +1,319 @@
#include "ScumBridge.hpp"
#include <algorithm>
#include <array>
#include <cctype>
#include <codecvt>
#include <fstream>
#include <locale>
#include <sstream>
#include <DynamicOutput/DynamicOutput.hpp>
#include <Unreal/FOutputDevice.hpp>
#include <Unreal/UObject.hpp>
#include <Unreal/UObjectGlobals.hpp>
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::string>
{
std::vector<std::string> ids;
for (size_t begin = 0; begin < line.size();)
{
if (!std::isdigit(static_cast<unsigned char>(line[begin])))
{
++begin;
continue;
}
size_t end = begin;
while (end < line.size() && std::isdigit(static_cast<unsigned char>(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<RC::LogLevel::Verbose>(
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<std::string> 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<std::string> 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;
}
// <SCUM>/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<std::codecvt_utf8_utf16<wchar_t>> 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;
}
}
+238
View File
@@ -0,0 +1,238 @@
#include "SimpleRconMod.hpp"
#include <algorithm>
#include <exception>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <Windows.h>
#include <DynamicOutput/DynamicOutput.hpp>
#include <UE4SSRuntime.hpp>
#include <Unreal/Hooks/Hooks.hpp>
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<LPCWSTR>(&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<uint16_t>(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<RconServer>(
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<RC::LogLevel::Verbose>(STR("[scum_simple_rcon] {}\n"), wide);
}
}
+14
View File
@@ -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;
}
}
+2
View File
@@ -0,0 +1,2 @@
; Add this line to your server's existing ue4ss/Mods/mods.txt
scum_simple_rcon : 1
+36
View File
@@ -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 = <SCUM>/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
+1
View File
@@ -0,0 +1 @@