This commit is contained in:
2026-08-25 16:56:37 +08:00
commit 2cc77c577e
22 changed files with 3339 additions and 0 deletions
+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;
}
}