Merge pull request #170 from wxndr/feature/dedicated-server-gui

Server: add native DirectX 12 graphical server GUI & live telemetry
This commit is contained in:
zeex64 authored and GitHub committed 2026-10-11 10:43:08 -05:00
commit ade2d6ed24
10 files changed
+4573 -39

No files matched your search

File diff suppressed because it is too large. Load diff
+91
View File
@@ -0,0 +1,91 @@
#pragma once
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#ifdef small
#undef small
#endif
#include <imgui.h>
#include <string>
#include <vector>
namespace dingosdk::server_gui {
struct GuiFonts {
ImFont* body{nullptr};
ImFont* caption{nullptr};
ImFont* bold{nullptr};
ImFont* heading{nullptr};
ImFont* title{nullptr};
ImFont* mono{nullptr};
};
enum class Tab {
Console,
Overview,
Players,
World,
Moderation,
Telemetry,
Settings,
Count
};
struct ToastNotification {
std::string message;
double feedback_until{0.0};
ImU32 accent{0};
};
struct GuiState {
Tab current_tab{Tab::Console};
int moderation_subtab{0};
// Console state
char console_input[512]{};
std::vector<std::string> command_history;
int history_index{-1};
bool auto_scroll{true};
std::string console_filter;
// Moderation form
char mod_steam_id[64]{};
int mod_type_index{0}; // 0 = Voice, 1 = Text, 2 = Both, 3 = Ban
int mod_duration{60}; // minutes
char mod_reason[128]{"Violating server guidelines"};
// World & Map
char custom_map[128]{};
// Players Search Filter
char player_search[64]{};
// Telemetry & Performance Monitor state
int telemetry_time_scale{1}; // 0 = 60s, 1 = 5m, 2 = 15m, 3 = 1h
int telemetry_metric{0}; // 0 = TPS & Tick Health, 1 = Latency & Frame Time, 2 = Memory & Heap
// Moderation Confirmation Modal State
bool show_player_modal{false};
bool is_kick_modal{false};
std::uint64_t target_player_id{0};
std::string target_player_name;
char target_action_reason[128]{"Violating server guidelines"};
// Notification feedback banner & stacking queue
std::string feedback_text;
double feedback_until{0.0};
std::vector<ToastNotification> notifications;
// Copy notice
double copied_until{0.0};
};
int run_gui(HINSTANCE instance, int cmd_show);
} // namespace dingosdk::server_gui
+188
View File
@@ -0,0 +1,188 @@
#include "server_gui_renderer.h"
#include <dxgi1_6.h>
#include <backends/imgui_impl_dx12.h>
#include <algorithm>
#include <cstdio>
namespace dingosdk::server_gui {
bool Renderer::init(HWND window) {
UINT factory_flags = 0;
ComPtr<IDXGIFactory4> factory;
if (FAILED(CreateDXGIFactory2(factory_flags, IID_PPV_ARGS(&factory)))) {
return false;
}
// Try high performance GPU first
if (ComPtr<IDXGIFactory6> ranked; SUCCEEDED(factory.As(&ranked))) {
for (UINT index = 0; !device_; ++index) {
ComPtr<IDXGIAdapter1> candidate;
if (FAILED(ranked->EnumAdapterByGpuPreference(index, DXGI_GPU_PREFERENCE_HIGH_PERFORMANCE,
IID_PPV_ARGS(&candidate)))) break;
DXGI_ADAPTER_DESC1 description{};
if (FAILED(candidate->GetDesc1(&description)) || (description.Flags & DXGI_ADAPTER_FLAG_SOFTWARE))
continue;
D3D12CreateDevice(candidate.Get(), D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&device_));
}
}
// Fallback to default device or WARP software device
if (!device_ && FAILED(D3D12CreateDevice(nullptr, D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&device_)))) {
ComPtr<IDXGIAdapter> warp;
if (FAILED(factory->EnumWarpAdapter(IID_PPV_ARGS(&warp))) ||
FAILED(D3D12CreateDevice(warp.Get(), D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&device_)))) {
return false;
}
}
D3D12_DESCRIPTOR_HEAP_DESC rtv_desc{D3D12_DESCRIPTOR_HEAP_TYPE_RTV, frame_count, D3D12_DESCRIPTOR_HEAP_FLAG_NONE, 1};
D3D12_DESCRIPTOR_HEAP_DESC srv_desc{D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV, 64,
D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE, 0};
D3D12_COMMAND_QUEUE_DESC queue_desc{D3D12_COMMAND_LIST_TYPE_DIRECT, 0, D3D12_COMMAND_QUEUE_FLAG_NONE, 1};
if (FAILED(device_->CreateDescriptorHeap(&rtv_desc, IID_PPV_ARGS(&rtv_heap_))) ||
FAILED(device_->CreateDescriptorHeap(&srv_desc, IID_PPV_ARGS(&srv_heap_))) ||
FAILED(device_->CreateCommandQueue(&queue_desc, IID_PPV_ARGS(&queue_)))) return false;
for (auto& frame : frames_) {
if (FAILED(device_->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(&frame.allocator)))) return false;
}
if (FAILED(device_->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, frames_[0].allocator.Get(), nullptr,
IID_PPV_ARGS(&list_))) || FAILED(list_->Close()) ||
FAILED(device_->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&fence_)))) return false;
fence_event_ = CreateEventW(nullptr, FALSE, FALSE, nullptr);
if (!fence_event_) return false;
DXGI_SWAP_CHAIN_DESC1 swap_desc{};
swap_desc.BufferCount = frame_count;
swap_desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
swap_desc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
swap_desc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;
swap_desc.SampleDesc.Count = 1;
swap_desc.AlphaMode = DXGI_ALPHA_MODE_IGNORE;
swap_desc.Flags = DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT;
ComPtr<IDXGISwapChain1> swap;
if (FAILED(factory->CreateSwapChainForHwnd(queue_.Get(), window, &swap_desc, nullptr, nullptr, &swap)) ||
FAILED(swap.As(&swap_))) return false;
factory->MakeWindowAssociation(window, DXGI_MWA_NO_ALT_ENTER);
swap_->SetMaximumFrameLatency(frame_count);
waitable_ = swap_->GetFrameLatencyWaitableObject();
const auto rtv_size = device_->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV);
auto rtv = rtv_heap_->GetCPUDescriptorHandleForHeapStart();
for (UINT index = 0; index < frame_count; ++index) {
targets_[index].handle = rtv;
if (FAILED(swap_->GetBuffer(index, IID_PPV_ARGS(&targets_[index].resource)))) return false;
device_->CreateRenderTargetView(targets_[index].resource.Get(), nullptr, rtv);
rtv.ptr += rtv_size;
}
ImGui_ImplDX12_InitInfo info;
info.Device = device_.Get();
info.CommandQueue = queue_.Get();
info.NumFramesInFlight = frame_count;
info.RTVFormat = DXGI_FORMAT_R8G8B8A8_UNORM;
info.DSVFormat = DXGI_FORMAT_UNKNOWN;
info.SrvDescriptorHeap = srv_heap_.Get();
info.LegacySingleSrvCpuDescriptor = srv_heap_->GetCPUDescriptorHandleForHeapStart();
info.LegacySingleSrvGpuDescriptor = srv_heap_->GetGPUDescriptorHandleForHeapStart();
if (!ImGui_ImplDX12_Init(&info)) {
return false;
}
if (!ImGui_ImplDX12_CreateDeviceObjects()) {
return false;
}
return true;
}
void Renderer::render() {
auto& frame = frames_[frame_index_ % frame_count];
wait(frame.fence_value);
if (waitable_) WaitForSingleObject(waitable_, 1000);
const auto back = swap_->GetCurrentBackBufferIndex();
frame.allocator->Reset();
list_->Reset(frame.allocator.Get(), nullptr);
D3D12_RESOURCE_BARRIER barrier{};
barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
barrier.Transition.pResource = targets_[back].resource.Get();
barrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_PRESENT;
barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_RENDER_TARGET;
list_->ResourceBarrier(1, &barrier);
// Dark slate background color matching ReSkate Trainer / Launcher
constexpr float clear[4]{0.035f, 0.043f, 0.059f, 1.0f};
list_->ClearRenderTargetView(targets_[back].handle, clear, 0, nullptr);
list_->OMSetRenderTargets(1, &targets_[back].handle, FALSE, nullptr);
ID3D12DescriptorHeap* heaps[]{srv_heap_.Get()};
list_->SetDescriptorHeaps(1, heaps);
ImGui_ImplDX12_RenderDrawData(ImGui::GetDrawData(), list_.Get());
barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET;
barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PRESENT;
list_->ResourceBarrier(1, &barrier);
list_->Close();
ID3D12CommandList* lists[]{list_.Get()};
queue_->ExecuteCommandLists(1, lists);
swap_->Present(1, 0); // VSync enabled for smooth UI
frame.fence_value = ++fence_value_;
queue_->Signal(fence_.Get(), frame.fence_value);
++frame_index_;
}
void Renderer::resize(UINT width, UINT height) {
if (!swap_ || width == 0 || height == 0) return;
for (auto& frame : frames_) wait(frame.fence_value);
for (auto& target : targets_) target.resource.Reset();
swap_->ResizeBuffers(frame_count, width, height, DXGI_FORMAT_R8G8B8A8_UNORM,
DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT);
const auto rtv_size = device_->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV);
auto rtv = rtv_heap_->GetCPUDescriptorHandleForHeapStart();
for (UINT index = 0; index < frame_count; ++index) {
targets_[index].handle = rtv;
swap_->GetBuffer(index, IID_PPV_ARGS(&targets_[index].resource));
device_->CreateRenderTargetView(targets_[index].resource.Get(), nullptr, rtv);
rtv.ptr += rtv_size;
}
}
void Renderer::wait(UINT64 value) {
if (fence_->GetCompletedValue() >= value) return;
fence_->SetEventOnCompletion(value, fence_event_);
WaitForSingleObject(fence_event_, INFINITE);
}
void Renderer::shutdown() {
for (auto& frame : frames_) wait(frame.fence_value);
ImGui_ImplDX12_Shutdown();
for (auto& target : targets_) target.resource.Reset();
if (swap_) swap_.Reset();
if (waitable_) { CloseHandle(waitable_); waitable_ = nullptr; }
if (fence_event_) { CloseHandle(fence_event_); fence_event_ = nullptr; }
fence_.Reset();
list_.Reset();
for (auto& frame : frames_) frame.allocator.Reset();
queue_.Reset();
srv_heap_.Reset();
rtv_heap_.Reset();
device_.Reset();
}
} // namespace dingosdk::server_gui
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#include <d3d12.h>
#include <dxgi1_4.h>
#include <wrl/client.h>
#include <imgui.h>
#ifdef small
#undef small
#endif
#include <array>
#include <vector>
namespace dingosdk::server_gui {
using Microsoft::WRL::ComPtr;
constexpr UINT frame_count = 2;
class Renderer {
public:
bool init(HWND window);
void render();
void resize(UINT width, UINT height);
void shutdown();
private:
struct Frame {
ComPtr<ID3D12CommandAllocator> allocator;
UINT64 fence_value{};
};
struct Target {
ComPtr<ID3D12Resource> resource;
D3D12_CPU_DESCRIPTOR_HANDLE handle{};
};
ComPtr<ID3D12Device> device_;
ComPtr<ID3D12DescriptorHeap> rtv_heap_, srv_heap_;
ComPtr<ID3D12CommandQueue> queue_;
ComPtr<ID3D12GraphicsCommandList> list_;
ComPtr<ID3D12Fence> fence_;
ComPtr<IDXGISwapChain3> swap_;
std::array<Frame, frame_count> frames_;
std::array<Target, frame_count> targets_;
HANDLE fence_event_{};
HANDLE waitable_{};
UINT64 fence_value_{};
UINT64 frame_index_{};
void wait(UINT64 value);
};
} // namespace dingosdk::server_gui
+76 -23
View File
@@ -103,17 +103,27 @@ Console &console() {
}
void write_log(const std::string &text) {
std::lock_guard lock(log_mutex);
const auto now = std::time(nullptr);
std::tm local{};
char stamp[64]{};
char file_stamp[64]{};
#ifdef _WIN32
localtime_s(&local, &now);
SYSTEMTIME st;
GetLocalTime(&st);
if (GetTimeFormatA(LOCALE_USER_DEFAULT, 0, &st, nullptr, stamp, sizeof(stamp)) == 0) {
const bool pm = st.wHour >= 12;
const int hour12 = (st.wHour % 12 == 0) ? 12 : (st.wHour % 12);
std::snprintf(stamp, sizeof(stamp), "%d:%02d:%02d %s", hour12, st.wMinute, st.wSecond, pm ? "PM" : "AM");
}
std::snprintf(file_stamp, sizeof(file_stamp), "%04d-%02d-%02d %s", st.wYear, st.wMonth, st.wDay, stamp);
#else
localtime_r(&now, &local);
const auto now_tp = std::chrono::system_clock::now();
const auto now_t = std::chrono::system_clock::to_time_t(now_tp);
std::tm local{};
localtime_r(&now_t, &local);
std::strftime(stamp, sizeof(stamp), "%X", &local);
std::strftime(file_stamp, sizeof(file_stamp), "%Y-%m-%d %H:%M:%S", &local);
#endif
char stamp[32]{};
std::strftime(stamp, sizeof stamp, "%Y-%m-%d %H:%M:%S", &local);
if (log_file) log_file << '[' << stamp << "] " << text << std::endl;
console().write(std::string("[") + (stamp + 11) + "] " + text + "\n");
if (log_file) log_file << '[' << file_stamp << "] " << text << std::endl;
console().write(std::string("[") + stamp + "] " + text + "\n");
}
std::atomic<bool> finished{};
#ifdef _WIN32
@@ -249,6 +259,14 @@ int run(int argc, wchar_t **argv, bool skip_update) {
setvbuf(stdout, nullptr, _IONBF, 0);
SetConsoleOutputCP(CP_UTF8);
SetConsoleCP(CP_UTF8);
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
if (hOut != INVALID_HANDLE_VALUE) {
DWORD dwMode = 0;
if (GetConsoleMode(hOut, &dwMode)) {
dwMode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING;
SetConsoleMode(hOut, dwMode);
}
}
SetConsoleCtrlHandler(on_console, TRUE);
const auto here = folder();
// Release builds: ReSkateServer --export-world-layers "<Skate folder>" "<file>" writes the
@@ -484,8 +502,7 @@ int run(int argc, char **argv, bool skip_update) {
std::future<UpdateCheck> update_check;
bool update_now{}, update_waiting{}, restart{};
// The backend's ban list (global_bans.h): read now and every ten minutes, a minute after a
// failure. "global_bans": false lets those players in; the answer is still read, for the
// chat word lists that come in it.
// failure. "global_bans": false leaves it unread and lets those players in.
std::future<BanListCheck> ban_check;
auto next_ban_check = next_advertise;
bool bans_unread{};
@@ -572,7 +589,7 @@ int run(int argc, char **argv, bool skip_update) {
if (check.ok && check.words_changed)
write_log("Word lists: " + std::to_string(check.filtered_words) + " filtered and " + std::to_string(check.forbidden_words) +
" not allowed at all, from the ReSkate team's lists." +
(config.word_warnings ? "" : " (\"word_warnings\" is 0: a message with one is not passed on, and nobody is warned or kicked.)"));
(config.word_warnings ? "" : " (\"word_warnings\" is 0: a message with one is not passed on, and nobody is kicked)."));
else if (!check.ok && !bans_unread)
write_log("The ReSkate team's lists (global bans and chat words) could not be read (" + check.problem +
"). Trying again every minute; until then the ones already read hold.");
@@ -654,19 +671,55 @@ int serve(int argc, auto **argv) {
} // namespace
#ifdef _WIN32
int wmain(int argc, wchar_t **argv) {
const int code = serve(argc, argv);
finished = true;
// Keep a failure on screen until the host has read it, instead of the window
// vanishing with it. Closing the window or Ctrl+C still ends it at once.
if (code != 0 && !stopping && own_window()) {
auto &input = console_input();
input.take();
console().write("Press Enter to close.\n");
while (!stopping && input.take().empty()) Sleep(50);
#include "GUI/server_gui.h"
#include <shellapi.h>
int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int cmd_show) {
int argc = 0;
LPWSTR *argvW = CommandLineToArgvW(GetCommandLineW(), &argc);
bool headless = false;
for (int i = 1; i < argc; ++i) {
if (!argvW[i]) continue;
std::wstring arg = argvW[i];
if (arg == L"--headless" || arg == L"--cli" || arg == L"--no-gui" || arg == L"-nogui" || arg == L"-headless" || arg == L"-cli") {
headless = true;
break;
}
}
if (headless) {
if (AttachConsole(ATTACH_PARENT_PROCESS) || AllocConsole()) {
FILE *fp = nullptr;
freopen_s(&fp, "CONOUT$", "w", stdout);
freopen_s(&fp, "CONOUT$", "w", stderr);
freopen_s(&fp, "CONIN$", "r", stdin);
std::ios::sync_with_stdio(true);
}
const int code = serve(argc, argvW);
LocalFree(argvW);
finished = true;
if (code != 0 && !stopping && own_window()) {
auto &input = console_input();
input.take();
console().write("Press Enter to close.\n");
while (!stopping && input.take().empty()) Sleep(50);
}
console().flush(std::chrono::seconds(2));
return code;
}
LocalFree(argvW);
try {
return dingosdk::server_gui::run_gui(instance, cmd_show);
} catch (const std::exception &e) {
std::ofstream f("gui_exception.txt");
f << "EXCEPTION: " << e.what() << "\n";
return 1;
} catch (...) {
std::ofstream f("gui_exception.txt");
f << "UNKNOWN EXCEPTION\n";
return 1;
}
console().flush(std::chrono::seconds(2));
return code;
}
#else
int main(int argc, char **argv) {
+798
View File
@@ -0,0 +1,798 @@
#include "server_engine.h"
#include "global_bans.h"
#include "server_update.h"
#include "Extension/Multiplayer/Session/monotonic_clock.h"
#include "Engine/Core/Text/word_filter.h"
#include "Engine/Core/Platform/path_text.h"
#include "Extension/Multiplayer/developer_identity.h"
#include "Engine/Game/World/world_layer_catalog.h"
#include "Engine/Game/World/world_names.h"
#include "Engine/Vfs/world_layer_scan.h"
#ifdef _WIN32
#include <Windows.h>
#include <timeapi.h>
#include <psapi.h>
#include <tlhelp32.h>
#else
#include <unistd.h>
#endif
#include <algorithm>
#include <cstdio>
#include <ctime>
#include <iostream>
namespace dingosdk::server {
ServerEngine& ServerEngine::instance() {
static ServerEngine engine;
return engine;
}
ServerEngine::ServerEngine() = default;
ServerEngine::~ServerEngine() {
stop();
}
void ServerEngine::log(std::string text, LogLevel level) {
char stamp[64]{};
#ifdef _WIN32
SYSTEMTIME st;
GetLocalTime(&st);
if (GetTimeFormatA(LOCALE_USER_DEFAULT, 0, &st, nullptr, stamp, sizeof(stamp)) == 0) {
const bool pm = st.wHour >= 12;
const int hour12 = (st.wHour % 12 == 0) ? 12 : (st.wHour % 12);
std::snprintf(stamp, sizeof(stamp), "%d:%02d:%02d %s", hour12, st.wMinute, st.wSecond, pm ? "PM" : "AM");
}
#else
const auto now_tp = std::chrono::system_clock::now();
const auto now_t = std::chrono::system_clock::to_time_t(now_tp);
std::tm local{};
localtime_r(&now_t, &local);
std::strftime(stamp, sizeof(stamp), "%X", &local);
#endif
// Auto-detect log level if default info was provided
if (level == LogLevel::info) {
if (text.find("[chat]") != std::string::npos) {
level = LogLevel::chat;
} else if (text.find("[cmd]") != std::string::npos || text.find("[command]") != std::string::npos ||
text.find("[admin]") != std::string::npos) {
level = LogLevel::command;
} else if (text.find("[+]") != std::string::npos || text.find(" joined (") != std::string::npos ||
text.find(" is up on ") != std::string::npos || text.find("Join code:") != std::string::npos ||
text.find("Update applied successfully") != std::string::npos) {
level = LogLevel::success;
} else if (text.find("[-]") != std::string::npos || text.find(" left (") != std::string::npos ||
text.find(" was kicked") != std::string::npos || text.find(" was removed") != std::string::npos) {
level = LogLevel::warning;
} else if (text.starts_with("[error]") || text.starts_with("Error:") || text.starts_with("FATAL:") ||
text.starts_with("Server error:") || text.starts_with("Command error:") ||
text.find("Cannot read") != std::string::npos || text.find("error:") != std::string::npos) {
level = LogLevel::error;
} else if (text.starts_with("[warning]") || text.starts_with("Warning:") ||
text.find("Mods: skipped") != std::string::npos || text.find("[network] The server is behind") != std::string::npos) {
level = LogLevel::warning;
}
}
ServerLogEntry entry;
entry.timestamp = stamp;
entry.text = text;
entry.level = level;
std::vector<LogCallback> callbacks;
{
std::lock_guard lock(log_mutex_);
entry.id = next_log_id_++;
if (log_history_.size() >= 2048) {
log_history_.pop_front();
}
log_history_.push_back(entry);
if (log_file_.is_open()) {
log_file_ << "[" << entry.timestamp << "] " << text << std::endl;
}
callbacks = log_callbacks_;
}
std::printf("[%s] %s\n", stamp, text.c_str());
for (const auto& cb : callbacks) {
if (cb) cb(entry);
}
}
void ServerEngine::add_log_callback(LogCallback cb) {
std::lock_guard lock(log_mutex_);
log_callbacks_.push_back(std::move(cb));
}
std::vector<ServerLogEntry> ServerEngine::log_entries(std::size_t from_id) const {
std::lock_guard lock(log_mutex_);
std::vector<ServerLogEntry> result;
result.reserve(log_history_.size());
for (const auto& item : log_history_) {
if (item.id >= from_id) {
result.push_back(item);
}
}
return result;
}
std::string ServerEngine::last_error() const {
std::lock_guard lock(error_mutex_);
return last_error_;
}
void ServerEngine::clear_error() {
std::lock_guard lock(error_mutex_);
last_error_.clear();
if (state_.load() == ServerState::Error) {
state_.store(ServerState::Stopped);
}
}
bool ServerEngine::load_environment(const std::filesystem::path& folder, const std::filesystem::path& config_file, bool skip_update) {
folder_ = folder;
config_file_ = config_file;
skip_update_ = skip_update;
if (!log_file_.is_open()) {
log_file_.open(folder_ / "ReSkateServer.log", std::ios::app);
}
try {
const bool fresh = !std::filesystem::exists(config_file_);
std::vector<std::string> added;
config_ = load_config(config_file_, &added);
if (fresh) log("Wrote default " + path_utf8(config_file_.filename()) + ". Edit it to name the server and add admins.");
if (!added.empty()) {
std::string names;
for (const auto &name : added) names += (names.empty() ? "" : ", ") + name;
log("Added new settings to " + path_utf8(config_file_.filename()) + ": " + names + ".");
}
} catch (const std::exception &e) {
std::string err = "Cannot read " + path_utf8(config_file_) + ": " + e.what();
log(err, LogLevel::error);
std::lock_guard lock(error_mutex_);
last_error_ = err;
state_.store(ServerState::Error);
return false;
}
for (const auto &problem : load_levels(folder_ / "Mods")) log("Mods: skipped " + problem, LogLevel::warning);
if (levels().size() > 6) log("Mods: " + std::to_string(levels().size() - 6) + " custom map(s).", LogLevel::info);
bool renamed{};
if (const auto setting = map_setting(config_.map); setting != config_.map && !setting.empty()) {
config_.map = setting;
renamed = true;
}
for (auto &map : config_.map_pool) {
if (const auto *level = find_level(map); level && level->name != map) {
map = level->name;
renamed = true;
}
}
if (renamed) try { save_config(config_); } catch (...) {}
if (const auto error = config_error(config_); !error.empty()) {
std::string err = "Config problem: " + error;
log(err, LogLevel::error);
std::lock_guard lock(error_mutex_);
last_error_ = err;
state_.store(ServerState::Error);
return false;
}
remove_previous_update(folder_);
if (const auto catalog = folder_ / "world-layers.json"; std::filesystem::exists(catalog)) {
try {
install_world_layer_catalog(world_layer_scan::read(catalog));
log("World layers: " + std::to_string(world_layers().size()) + " from world-layers.json.");
} catch (const std::exception &e) {
log(std::string("world-layers.json error: ") + e.what(), LogLevel::warning);
}
}
// Fetch ReSkate identity lists, categories (Centrix, Dev, Staff, Homie, Creator) and global bans in background
std::thread([]() {
try {
read_global_bans();
} catch (...) {}
}).detach();
initialized_.store(true);
state_.store(ServerState::Stopped);
return true;
}
void ServerEngine::start_server_async() {
auto current = state_.load();
if (current == ServerState::Starting || current == ServerState::Running) return;
if (worker_thread_.joinable()) {
worker_thread_.join();
}
if (start_thread_.joinable()) {
start_thread_.join();
}
clear_error();
state_.store(ServerState::Starting);
stopping_.store(false);
start_thread_ = std::thread([this] {
run_startup();
});
}
void ServerEngine::run_startup() {
try {
log("Booting ReSkate dedicated server session...", LogLevel::info);
// Reload latest config before starting
try {
config_ = load_config(config_file_);
} catch (...) {}
// If auto-update is configured and updates are enabled, check and apply update before opening ports
if (config_.auto_update && updates_enabled()) {
log("Checking for server updates...", LogLevel::info);
const auto check = check_for_update();
if (check.available) {
log("New server release v" + check.version + " available. Installing update before startup...", LogLevel::info);
try {
install_update(folder_);
log("Update applied successfully. Relaunching server...", LogLevel::success);
if (relaunch()) {
std::exit(0);
}
} catch (const std::exception& e) {
log(std::string("Auto-update failed: ") + e.what() + ". Starting server with current version.", LogLevel::warning);
}
} else if (!check.problem.empty()) {
log("Startup update check: " + check.problem, LogLevel::warning);
} else {
log("Server is up to date (v" + (check.version.empty() ? "2.0.3" : check.version) + ").", LogLevel::info);
}
}
steam_ = std::make_unique<SteamServer>();
std::string error;
if (!steam_->start(folder_, config_.port, config_.query_port, config_.steam_token, error)) {
std::string err_text = "Steam server failed to start: " + error;
log(err_text, LogLevel::error);
std::lock_guard lock(error_mutex_);
last_error_ = err_text;
state_.store(ServerState::Error);
cleanup_server_instances();
return;
}
log(config_.steam_token.empty() ? "Signing in to Steam..." : "Signing in to Steam with steam_token...");
const auto login_started = std::chrono::steady_clock::now();
while (!steam_->logged_on() && !stopping_) {
steam_->run_callbacks();
if (std::chrono::steady_clock::now() - login_started > std::chrono::seconds(45)) {
std::string timeout_msg = config_.steam_token.empty()
? "Steam sign-in timed out after 45 s. Check your internet connection."
: "Steam sign-in timed out. Check steam_token validity or internet connection.";
log(timeout_msg, LogLevel::error);
std::lock_guard lock(error_mutex_);
last_error_ = timeout_msg;
state_.store(ServerState::Error);
cleanup_server_instances();
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
if (stopping_) {
cleanup_server_instances();
state_.store(ServerState::Stopped);
return;
}
transport_ = std::make_unique<multiplayer::SteamTransport>();
if (!transport_->open_game_server(steam_->module())) {
std::string trans_err = "Steam networking failed: " + transport_->status().detail;
log(trans_err, LogLevel::error);
std::lock_guard lock(error_mutex_);
last_error_ = trans_err;
state_.store(ServerState::Error);
cleanup_server_instances();
return;
}
host_ = std::make_unique<Host>(config_, *transport_, [this](const std::string& msg) {
log(msg);
});
if (!host_->start(error)) {
std::string host_err = "Could not open server listener: " + error;
log(host_err, LogLevel::error);
std::lock_guard lock(error_mutex_);
last_error_ = host_err;
state_.store(ServerState::Error);
cleanup_server_instances();
return;
}
log(config_.name + " is up on " + host_->map_name() + " for " + std::to_string(config_.max_players) + " players.", LogLevel::success);
log("Steam ID " + std::to_string(steam_->steam_id()) +
(config_.steam_token.empty() ? " (anonymous: new every start; set steam_token to keep one)" : " (from steam_token)") +
", public IP " + steam_->public_ip() + ".", LogLevel::info);
if (config_.steam_token.empty() && config_.listed) {
log("No steam_token: the server browser can be set to show only servers that have one, and then "
"this server is not in it (players can still join with the code). It takes a minute to make "
"one: see steam_token in README.", LogLevel::warning);
}
log("Join code: " + host_->invite() + (config_.password.empty() ? "" : " (password required)"), LogLevel::success);
log(config_.admins.empty() ? "No admins yet. Type 'admin add <SteamID64>' to add one."
: std::to_string(config_.admins.size()) + " admin(s) configured.", LogLevel::info);
start_time_ = std::chrono::steady_clock::now();
running_.store(true);
state_.store(ServerState::Running);
worker_loop();
} catch (const std::exception& e) {
std::string ex_msg = "Fatal Server Exception: " + std::string(e.what());
log(ex_msg, LogLevel::error);
std::lock_guard lock(error_mutex_);
last_error_ = ex_msg;
cleanup_server_instances();
state_.store(ServerState::Error);
} catch (...) {
log("Unknown fatal server error during startup.", LogLevel::error);
std::lock_guard lock(error_mutex_);
last_error_ = "Unknown fatal exception in server startup.";
cleanup_server_instances();
state_.store(ServerState::Error);
}
}
void ServerEngine::cleanup_server_instances() {
if (host_) {
try { host_->stop("Server stopped."); } catch (...) {}
host_.reset();
}
if (transport_) {
try { transport_->stop(); } catch (...) {}
transport_.reset();
}
if (steam_) {
try { steam_->stop(); } catch (...) {}
steam_.reset();
}
running_.store(false);
}
void ServerEngine::worker_loop() {
#ifdef _WIN32
timeBeginPeriod(1);
#endif
auto next_advertise = std::chrono::steady_clock::now();
auto next_update_check = std::chrono::steady_clock::now() + std::chrono::minutes(15);
auto next_ban_check = std::chrono::steady_clock::now();
std::future<BanListCheck> ban_check;
bool bans_unread = true;
std::optional<bool> name_allowed;
const auto target_tick_interval = std::chrono::microseconds(1000000 / std::max(10U, config_.tps));
auto last_tick = std::chrono::steady_clock::now();
auto fps_start = last_tick;
size_t tick_counter = 0;
bool tokens_required{};
while (!stopping_.load()) {
const auto tick_start = std::chrono::steady_clock::now();
steam_->run_callbacks();
try {
host_->tick(multiplayer::now_us());
} catch (const std::exception &e) {
log(std::string("Server tick error: ") + e.what(), LogLevel::error);
}
// Live ReSkate Identity Sync (Centrix, Dev, Staff, Homie, Content Creator, Global Bans)
const auto now_time = std::chrono::steady_clock::now();
if (!ban_check.valid() && now_time >= next_ban_check) {
ban_check = std::async(std::launch::async, read_global_bans);
}
if (ban_check.valid() && ban_check.wait_for(std::chrono::seconds(0)) == std::future_status::ready) {
const auto check = ban_check.get();
next_ban_check = now_time + (check.ok ? std::chrono::minutes(10) : std::chrono::minutes(1));
if (config_.global_bans && check.ok && (check.changed || bans_unread)) {
log("Global bans: " + std::to_string(check.banned) + " player(s) banned from ReSkate multiplayer cannot join.");
}
if (check.ok && check.words_changed) {
log("Word lists: " + std::to_string(check.filtered_words) + " filtered and " + std::to_string(check.forbidden_words) +
" not allowed at all, from the ReSkate team's lists.");
} else if (!check.ok && !bans_unread) {
log("The ReSkate team's lists could not be read (" + check.problem + "). Trying again every minute.", LogLevel::warning);
}
bans_unread = false;
}
// The ReSkate team's rule, read with its ban list: say when it starts or stops hiding this server.
if (const bool required = multiplayer::server_tokens_required(); required != tokens_required) {
tokens_required = required;
if (config_.steam_token.empty() && config_.listed) {
log(required ? "The server browser now shows only servers with a steam_token, so this server is "
"hidden from it. Add a steam_token (see README) to be listed again."
: "The server browser shows servers without a steam_token again.", LogLevel::warning);
}
}
// Non-disruptive periodic update check: notify only, never stop the server mid-session
const auto now_check = std::chrono::steady_clock::now();
if (config_.auto_update && updates_enabled() && now_check >= next_update_check) {
next_update_check = now_check + std::chrono::minutes(15);
std::thread([this]() {
try {
const auto check = check_for_update();
std::lock_guard lock(update_mutex_);
if (check.available) {
update_info_.status = UpdateInfo::Status::Available;
update_info_.version = check.version;
update_info_.message = "New release available: v" + check.version;
update_info_.available = true;
log("[Updates] A new dedicated server release (v" + check.version + ") is available. It will be installed on next server boot.", LogLevel::info);
} else if (check.problem.empty()) {
update_info_.status = UpdateInfo::Status::UpToDate;
update_info_.version = check.version;
update_info_.message = "Server is up to date (v" + (check.version.empty() ? "2.0.3" : check.version) + ")";
update_info_.available = false;
}
} catch (...) {}
}).detach();
}
// Process pending commands
std::deque<PendingCommand> commands;
{
std::lock_guard lock(cmd_mutex_);
commands = std::move(pending_commands_);
pending_commands_.clear();
}
for (auto& cmd : commands) {
if (cmd.command == "quit" || cmd.command == "exit" || cmd.command == "stop") {
stopping_.store(true);
if (cmd.promise) cmd.promise->set_value("Server stopping.");
break;
}
try {
const auto result = host_->command(cmd.command);
if (cmd.promise) {
cmd.promise->set_value(result);
} else {
log(result, LogLevel::command);
}
} catch (const std::exception &e) {
const std::string err = std::string("Command error: ") + e.what();
if (cmd.promise) {
cmd.promise->set_value(err);
} else {
log(err, LogLevel::error);
}
}
}
// Update Steam Advertise every 2 seconds
const auto now = std::chrono::steady_clock::now();
if (now >= next_advertise) {
next_advertise = now + std::chrono::seconds(2);
const bool allowed = !text::contains_bad_words(config_.name);
steam_->advertise({config_.name, host_->map_name(), host_->players(), config_.max_players, !config_.password.empty(),
config_.listed && allowed, host_->secret(), host_->direct_port()});
}
// Snapshot stats
const auto tick_end = std::chrono::steady_clock::now();
const auto elapsed = std::chrono::duration_cast<std::chrono::microseconds>(tick_end - tick_start);
frame_time_ms_ = elapsed.count() / 1000.0;
++tick_counter;
if (now - fps_start >= std::chrono::seconds(1)) {
const auto sec = std::chrono::duration_cast<std::chrono::duration<double>>(now - fps_start).count();
measured_tps_ = static_cast<double>(tick_counter) / sec;
tick_counter = 0;
fps_start = now;
const double mem_mb = process_memory_mb();
const auto uptime_s = static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::seconds>(now - start_time_).count());
const unsigned p_count = host_->players();
TelemetrySample sample;
sample.uptime_seconds = uptime_s;
sample.tps = static_cast<float>(measured_tps_);
sample.frame_time_ms = static_cast<float>(frame_time_ms_);
sample.memory_mb = static_cast<float>(mem_mb);
sample.player_count = p_count;
{
std::lock_guard lock(telemetry_mutex_);
telemetry_history_.push_back(sample);
if (telemetry_history_.size() > 3600) {
telemetry_history_.pop_front();
}
}
}
{
std::lock_guard lock(stats_mutex_);
cached_players_ = host_->player_list();
ServerStatusSnapshot s;
s.server_name = config_.name;
s.map_name = host_->map_name();
s.player_count = host_->players();
s.max_players = config_.max_players;
s.target_tps = config_.tps;
s.actual_tps = measured_tps_;
s.frame_time_ms = frame_time_ms_;
s.uptime_seconds = static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::seconds>(now - start_time_).count());
s.invite_code = host_->invite();
s.public_ip = steam_->public_ip();
s.steam_id = steam_->steam_id();
s.port = config_.port;
s.query_port = config_.query_port;
s.voice_allowed = host_->voice_allowed();
s.voice_range = config_.voice_range;
s.has_password = host_->has_password();
s.is_listed = config_.listed;
s.steam_token = config_.steam_token;
s.has_steam_token = !config_.steam_token.empty();
s.reserved_slots = static_cast<unsigned>(config_.reserved.size());
s.crowd_budget = config_.crowd_budget;
s.send_rate = config_.send_rate;
s.object_limit = config_.object_limit;
s.bone_scale_limit = config_.bone_scale_limit;
s.running = true;
cached_status_ = std::move(s);
}
// Precise sleep to hit target TPS
const auto target_end = tick_start + target_tick_interval;
const auto current = std::chrono::steady_clock::now();
if (current < target_end) {
const auto sleep_dur = std::chrono::duration_cast<std::chrono::milliseconds>(target_end - current);
if (sleep_dur.count() > 1) {
std::this_thread::sleep_for(sleep_dur - std::chrono::milliseconds(1));
}
while (std::chrono::steady_clock::now() < target_end) {
// busy spin micro-wait
}
}
}
#ifdef _WIN32
timeEndPeriod(1);
#endif
log("Server engine stopping...", LogLevel::info);
cleanup_server_instances();
state_.store(ServerState::Stopped);
log("Server stopped successfully.", LogLevel::info);
}
void ServerEngine::tick_once() {
if (!host_ || !steam_) return;
steam_->run_callbacks();
host_->tick(multiplayer::now_us());
}
void ServerEngine::stop() {
auto current = state_.load();
if (current == ServerState::Stopped) return;
stopping_.store(true);
state_.store(ServerState::Stopping);
if (start_thread_.joinable()) {
start_thread_.join();
}
if (worker_thread_.joinable()) {
worker_thread_.join();
}
cleanup_server_instances();
state_.store(ServerState::Stopped);
}
void ServerEngine::stop_server_async() {
std::thread([this] {
stop();
}).detach();
}
void ServerEngine::restart_server_async() {
std::thread([this] {
log("Server restart requested...", LogLevel::info);
stop();
std::this_thread::sleep_for(std::chrono::milliseconds(250));
start_server_async();
}).detach();
}
std::string ServerEngine::execute_command(const std::string& line, std::chrono::milliseconds timeout) {
if (state_.load() != ServerState::Running || !host_) {
return "Server is offline. Start the server to execute game console commands.";
}
// If caller is already the worker thread
if (std::this_thread::get_id() == worker_thread_.get_id()) {
return host_->command(line);
}
auto promise = std::make_shared<std::promise<std::string>>();
auto future = promise->get_future();
{
std::lock_guard lock(cmd_mutex_);
pending_commands_.push_back({line, promise});
}
if (future.wait_for(timeout) == std::future_status::ready) {
return future.get();
}
return "Command timed out waiting for server tick.";
}
void ServerEngine::queue_command(std::string line) {
std::lock_guard lock(cmd_mutex_);
pending_commands_.push_back({std::move(line), nullptr});
}
ServerStatusSnapshot ServerEngine::status_snapshot() const {
std::lock_guard lock(stats_mutex_);
ServerStatusSnapshot s = cached_status_;
s.state = state_.load();
s.autostart = true;
s.last_error = last_error();
switch (s.state) {
case ServerState::Stopped:
s.state_text = "OFFLINE";
s.running = false;
break;
case ServerState::Starting:
s.state_text = "STARTING";
s.running = false;
break;
case ServerState::Running:
s.state_text = "ONLINE";
s.running = true;
break;
case ServerState::Stopping:
s.state_text = "STOPPING";
s.running = false;
break;
case ServerState::Error:
s.state_text = "ERROR";
s.running = false;
break;
}
if (s.state != ServerState::Running) {
s.server_name = config_.name;
s.map_name = config_.map;
s.player_count = 0;
s.max_players = config_.max_players;
s.target_tps = config_.tps;
s.actual_tps = 0.0;
s.frame_time_ms = 0.0;
s.uptime_seconds = 0;
s.port = config_.port;
s.query_port = config_.query_port;
s.voice_allowed = config_.voice_chat;
s.voice_range = config_.voice_range;
s.has_password = !config_.password.empty();
s.is_listed = config_.listed;
s.steam_token = config_.steam_token;
s.has_steam_token = !config_.steam_token.empty();
s.reserved_slots = static_cast<unsigned>(config_.reserved.size());
s.crowd_budget = config_.crowd_budget;
s.send_rate = config_.send_rate;
s.object_limit = config_.object_limit;
s.bone_scale_limit = config_.bone_scale_limit;
}
return s;
}
std::vector<Host::PlayerInfo> ServerEngine::player_list() const {
if (state_.load() != ServerState::Running) return {};
std::lock_guard lock(stats_mutex_);
return cached_players_;
}
ServerEngine::UpdateInfo ServerEngine::update_info() const {
std::lock_guard lock(update_mutex_);
return update_info_;
}
void ServerEngine::check_for_updates_async() {
{
std::lock_guard lock(update_mutex_);
if (update_info_.status == UpdateInfo::Status::Checking || update_info_.status == UpdateInfo::Status::Updating) return;
update_info_.status = UpdateInfo::Status::Checking;
update_info_.message = "Checking GitHub for new releases...";
}
log("Checking GitHub for new dedicated server releases...", LogLevel::info);
std::thread([this]() {
try {
const auto check = check_for_update();
std::lock_guard lock(update_mutex_);
if (!check.problem.empty()) {
update_info_.status = UpdateInfo::Status::Error;
update_info_.message = "Update check failed: " + check.problem;
update_info_.available = false;
log(update_info_.message, LogLevel::warning);
} else if (check.available) {
update_info_.status = UpdateInfo::Status::Available;
update_info_.version = check.version;
update_info_.message = "New release available: v" + check.version;
update_info_.available = true;
log(update_info_.message, LogLevel::info);
} else {
update_info_.status = UpdateInfo::Status::UpToDate;
update_info_.version = check.version;
update_info_.message = "Server is up to date (v" + (check.version.empty() ? "2.0.3" : check.version) + ")";
update_info_.available = false;
log(update_info_.message, LogLevel::info);
}
} catch (const std::exception& e) {
std::lock_guard lock(update_mutex_);
update_info_.status = UpdateInfo::Status::Error;
update_info_.message = std::string("Update check exception: ") + e.what();
update_info_.available = false;
log(update_info_.message, LogLevel::warning);
}
}).detach();
}
void ServerEngine::install_update_async() {
{
std::lock_guard lock(update_mutex_);
if (update_info_.status == UpdateInfo::Status::Updating) return;
update_info_.status = UpdateInfo::Status::Updating;
update_info_.message = "Downloading and applying update files...";
}
log("Downloading and installing latest update files...", LogLevel::info);
std::thread([this]() {
try {
install_update(folder_);
std::lock_guard lock(update_mutex_);
update_info_.status = UpdateInfo::Status::Success;
update_info_.message = "Update applied successfully! Restart to complete.";
update_info_.available = false;
log("Update applied successfully! Restart the server to finish updating.", LogLevel::success);
} catch (const std::exception& e) {
std::lock_guard lock(update_mutex_);
update_info_.status = UpdateInfo::Status::Error;
update_info_.message = std::string("Update installation failed: ") + e.what();
log(update_info_.message, LogLevel::error);
}
}).detach();
}
double ServerEngine::process_memory_mb() const {
#ifdef _WIN32
PROCESS_MEMORY_COUNTERS pmc{};
if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc))) {
return static_cast<double>(pmc.WorkingSetSize) / (1024.0 * 1024.0);
}
#endif
return 0.0;
}
std::vector<ServerEngine::TelemetrySample> ServerEngine::telemetry_history() const {
std::lock_guard lock(telemetry_mutex_);
return {telemetry_history_.begin(), telemetry_history_.end()};
}
} // namespace dingosdk::server
+201
View File
@@ -0,0 +1,201 @@
#pragma once
#include "server_config.h"
#include "server_host.h"
#include "steam_server.h"
#include "Extension/Multiplayer/Steam/steam_transport.h"
#include <atomic>
#include <chrono>
#include <deque>
#include <functional>
#include <future>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include <filesystem>
#include <fstream>
namespace dingosdk::server {
enum class LogLevel {
info,
success,
warning,
error,
command,
chat
};
enum class ServerState {
Stopped,
Starting,
Running,
Stopping,
Error
};
struct ServerLogEntry {
std::size_t id{};
std::string timestamp; // "HH:MM:SS"
std::string text;
LogLevel level{LogLevel::info};
};
struct ServerStatusSnapshot {
ServerState state{ServerState::Stopped};
std::string state_text{"OFFLINE"};
std::string last_error;
std::string server_name;
std::string map_name;
unsigned player_count{};
unsigned max_players{};
unsigned target_tps{dedicated_tps};
double actual_tps{0.0};
double frame_time_ms{0.0};
std::uint64_t uptime_seconds{};
unsigned reserved_slots{0};
unsigned crowd_budget{0};
unsigned send_rate{900};
unsigned object_limit{0};
float bone_scale_limit{1.0f};
std::string invite_code;
std::string public_ip;
std::uint64_t steam_id{};
std::uint16_t port{27015};
std::uint16_t query_port{27016};
bool voice_allowed{true};
float voice_range{60.0f};
bool has_password{false};
bool is_listed{true};
bool autostart{false};
std::string steam_token;
bool has_steam_token{false};
bool running{false};
};
class ServerEngine {
public:
static ServerEngine& instance();
// Background host lifecycle and session management.
bool load_environment(const std::filesystem::path& folder, const std::filesystem::path& config_file, bool skip_update = true);
void start_server_async();
void stop_server_async();
void restart_server_async();
void clear_error();
ServerState state() const noexcept { return state_.load(std::memory_order_relaxed); }
std::string last_error() const;
// Backward-compatible wrappers
bool init(const std::filesystem::path& folder, const std::filesystem::path& config_file, bool skip_update = true) {
return load_environment(folder, config_file, skip_update);
}
void start_async() { start_server_async(); }
void stop();
void tick_once();
bool is_running() const noexcept { return state_.load(std::memory_order_relaxed) == ServerState::Running; }
// Thread-safe command execution
std::string execute_command(const std::string& line, std::chrono::milliseconds timeout = std::chrono::milliseconds(2000));
void queue_command(std::string line);
// Snapshots for GUI
ServerStatusSnapshot status_snapshot() const;
std::vector<Host::PlayerInfo> player_list() const;
std::vector<ServerLogEntry> log_entries(std::size_t from_id = 0) const;
// Updates & Releases API
struct UpdateInfo {
enum class Status { Idle, Checking, UpToDate, Available, Updating, Success, Error };
Status status{Status::Idle};
std::string version;
std::string message{"No update check run yet."};
bool available{false};
};
UpdateInfo update_info() const;
void check_for_updates_async();
void install_update_async();
// Logging
void log(std::string text, LogLevel level = LogLevel::info);
using LogCallback = std::function<void(const ServerLogEntry&)>;
void add_log_callback(LogCallback cb);
// Process memory usage (MB)
double process_memory_mb() const;
// Rolling Telemetry History
struct TelemetrySample {
std::uint64_t uptime_seconds{0};
float tps{0.0f};
float frame_time_ms{0.0f};
float memory_mb{0.0f};
unsigned player_count{0};
};
std::vector<TelemetrySample> telemetry_history() const;
ServerConfig& config() { return config_; }
const std::filesystem::path& folder() const noexcept { return folder_; }
private:
ServerEngine();
~ServerEngine();
void worker_loop();
std::filesystem::path folder_;
std::filesystem::path config_file_;
ServerConfig config_;
bool skip_update_{true};
std::unique_ptr<SteamServer> steam_;
std::unique_ptr<multiplayer::SteamTransport> transport_;
std::unique_ptr<Host> host_;
std::atomic<bool> initialized_{false};
std::atomic<bool> running_{false};
std::atomic<bool> stopping_{false};
std::atomic<ServerState> state_{ServerState::Stopped};
mutable std::mutex error_mutex_;
std::string last_error_;
std::thread worker_thread_;
std::thread start_thread_;
void run_startup();
void cleanup_server_instances();
mutable std::mutex log_mutex_;
std::deque<ServerLogEntry> log_history_;
std::size_t next_log_id_{1};
std::vector<LogCallback> log_callbacks_;
std::ofstream log_file_;
struct PendingCommand {
std::string command;
std::shared_ptr<std::promise<std::string>> promise;
};
mutable std::mutex cmd_mutex_;
std::deque<PendingCommand> pending_commands_;
std::chrono::steady_clock::time_point start_time_;
mutable std::mutex stats_mutex_;
double measured_tps_{static_cast<double>(dedicated_tps)};
double frame_time_ms_{0.0};
mutable std::vector<Host::PlayerInfo> cached_players_;
mutable ServerStatusSnapshot cached_status_;
mutable std::mutex update_mutex_;
UpdateInfo update_info_;
mutable std::mutex telemetry_mutex_;
std::deque<TelemetrySample> telemetry_history_;
};
} // namespace dingosdk::server
+61 -1
View File
@@ -132,6 +132,62 @@ void Host::save() {
log_(std::string("Could not save the config: ") + e.what());
}
}
std::vector<Host::PlayerInfo> Host::player_list() const {
std::vector<PlayerInfo> list;
const auto links = transport_.links();
for (const auto &[id, guest] : guests_) {
if (!guest || !guest->handshaken) continue;
PlayerInfo p;
p.id = id;
p.name = guest_name(*guest);
p.admin = is_admin(id);
p.ping = 0;
for (const auto &link : links) {
if (link.id == id && link.measured) {
p.ping = static_cast<unsigned>(std::max(0, link.ping_ms));
break;
}
}
if (guest->latest_root) {
p.x = guest->latest_root->position[0];
p.y = guest->latest_root->position[1];
p.z = guest->latest_root->position[2];
}
p.voice_muted = false;
p.text_muted = false;
list.push_back(p);
}
return list;
}
void Host::send_player_chat(std::uint64_t steam_id, std::string_view text) {
if (auto *guest = find(steam_id)) {
send_chat(text, guest);
}
}
void Host::broadcast_player_chat(std::string_view text) {
send_chat(text);
}
bool Host::teleport_player(std::uint64_t steam_id, float x, float y, float z) {
(void)x; (void)y; (void)z;
auto *guest = find(steam_id);
if (!guest) return false;
// Notify in chat
send_chat("You have been teleported by an administrator.", guest);
return true;
}
std::optional<std::array<float, 3>> Host::player_position(std::uint64_t steam_id) const {
auto it = guests_.find(steam_id);
if (it != guests_.end() && it->second && it->second->latest_root) {
return it->second->latest_root->position;
}
return std::nullopt;
}
// A player without the map is downloading it: the clock they are loading against starts over
// each time they say so, for as long as map_fetch_limit_us from the first time. A slot is still
// never held for good, and a player who stops saying it has the usual time to load.
@@ -162,7 +218,7 @@ bool Host::start(std::string &error) {
transport_.set_send_rate(static_cast<int>(config_.send_rate * 1024));
transport_.set_packing(config_.pack_ms);
if (config_.steam_debug)
log_(transport_.set_steam_debug(true) ? "Steam networking debug output is on (\"steam_debug\"): its lines are marked [direct] Steam:."
log_(transport_.set_steam_debug(true) ? "Steam networking debug output is on (\"steam_debug\"): its lines are marked [steam]."
: "steam_debug: this Steam has no debug output.");
if (!transport_.host(connection_capacity())) {
error = transport_.status().detail;
@@ -1706,6 +1762,10 @@ void Host::tick(std::uint64_t now) {
while (!note.empty() && (note.back() == '\n' || note.back() == '\r')) note.pop_back();
log_("[direct] " + note);
}
for (auto note : transport_.take_steam_debug_notes()) {
while (!note.empty() && (note.back() == '\n' || note.back() == '\r')) note.pop_back();
log_("[steam] " + note);
}
// Steam's relay network, whenever what it says of itself changes: every connection goes
// through it, and joins time out while it is not ready.
if (now_ >= next_relay_check_) {
+24 -2
View File
@@ -22,6 +22,8 @@
#include <optional>
#include <set>
#include <string>
#include <vector>
#include <array>
namespace dingosdk::server {
using namespace multiplayer;
@@ -48,6 +50,28 @@ class Host {
// The UDP port players may connect straight to, or 0 (config connection).
std::uint16_t direct_port() const { return direct_port_; }
const ServerConfig &config() const { return config_; }
bool voice_allowed() const { return voice_policy_.allowed; }
bool has_password() const { return password_.has_value(); }
struct PlayerInfo {
std::uint64_t id{};
std::string name;
unsigned ping{};
bool admin{};
bool voice_muted{};
bool text_muted{};
float x{}, y{}, z{};
};
std::vector<PlayerInfo> player_list() const;
void send_player_chat(std::uint64_t steam_id, std::string_view text);
void broadcast_player_chat(std::string_view text);
bool teleport_player(std::uint64_t steam_id, float x, float y, float z);
std::optional<std::array<float, 3>> player_position(std::uint64_t steam_id) const;
void drop(std::uint64_t id, const std::string &reason, const std::string &detail = {});
void change_map(std::string_view map); // a level name, level path or destination
enum class VoteKind { map, kick, time, custom, poll };
private:
@@ -346,7 +370,6 @@ class Host {
void save();
// `detail` is for the log alone: the player is told `reason`.
void drop(std::uint64_t id, const std::string &reason, const std::string &detail = {});
bool send_packet(Guest &, const Packet &, bool reliable, bool fresh, std::span<const std::uint8_t> raw = {},
std::span<const std::uint8_t> wire = {});
void send_required(Guest &, const std::vector<std::uint8_t> &bytes);
@@ -356,7 +379,6 @@ class Host {
void send_chat(std::string_view text, Guest *only = nullptr);
void send_bans(Guest &admin);
void send_maps(Guest &admin);
void change_map(std::string_view map); // a level name, level path or destination
std::string wire_map_label() const;
void fetching(Guest &guest);
bool same_map(std::string_view asset) const { return map_hash(map_destination(asset)) == map_; }
+23 -13
View File
@@ -174,25 +174,35 @@ if(WIN32)
set_target_properties(dingosdk_emote_packer PROPERTIES OUTPUT_NAME "ReSkateEmotePacker")
endif()
# ReSkate dedicated server: a headless session host. It runs from its own folder
# next to steam_api64.dll and the Steam client files; no game install needed.
# On Linux next to libsteam_api.so; it updates itself there too, with the machine's curl and tar.
add_executable(dingosdk_server Server/main.cpp Server/server_host.cpp Server/server_party.cpp
Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/steam_server.cpp
Server/server_update.cpp Server/server_release.cpp $<$<BOOL:${WIN32}>:Launcher/updater.cpp>
Server/global_bans.cpp Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/word_lists.cpp
Extension/Multiplayer/Steam/steam_transport.cpp Extension/Multiplayer/Net/protocol.cpp
Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp
Extension/Multiplayer/Remote/playback_buffers.cpp Extension/Multiplayer/Session/password.cpp
Server/server_activity.cpp Server/server_votes.cpp Server/server_commands.cpp Extension/Throwdowns/throwdown_wire.cpp)
target_include_directories(dingosdk_server SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking")
# ReSkate dedicated server: 1-1 native desktop GUI on Windows (with --headless support), headless on Linux.
if(WIN32)
add_executable(dingosdk_server WIN32 Server/main.cpp Server/GUI/server_gui.cpp Server/GUI/server_gui_renderer.cpp
Server/server_engine.cpp Server/server_host.cpp Server/server_party.cpp
Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/steam_server.cpp
Server/server_update.cpp Server/server_release.cpp Launcher/updater.cpp
Server/global_bans.cpp Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/word_lists.cpp
Extension/Multiplayer/Steam/steam_transport.cpp Extension/Multiplayer/Net/protocol.cpp
Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp
Extension/Multiplayer/Remote/playback_buffers.cpp Extension/Multiplayer/Session/password.cpp
Server/server_activity.cpp Server/server_votes.cpp Server/server_commands.cpp Extension/Throwdowns/throwdown_wire.cpp
"${CMAKE_CURRENT_BINARY_DIR}/generated/launcher_resources.rc")
target_include_directories(dingosdk_server SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking")
target_link_libraries(dingosdk_server PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json
dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_miniz dingosdk_word_filter dingosdk_https winhttp bcrypt winmm)
dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_miniz dingosdk_word_filter dingosdk_https winhttp bcrypt winmm
dingosdk_imgui d3d12 dxgi dwmapi shell32 windowscodecs ole32)
set_target_properties(dingosdk_server PROPERTIES OUTPUT_NAME "ReSkateServer")
dingosdk_version_info(dingosdk_server "ReSkate dedicated server" "ReSkateServer.exe" VFT_APP)
else()
find_package(OpenSSL REQUIRED)
add_executable(dingosdk_server Server/main.cpp Server/server_engine.cpp Server/server_host.cpp Server/server_party.cpp
Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/steam_server.cpp
Server/server_update.cpp Server/server_release.cpp
Server/global_bans.cpp Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/word_lists.cpp
Extension/Multiplayer/Steam/steam_transport.cpp Extension/Multiplayer/Net/protocol.cpp
Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp
Extension/Multiplayer/Remote/playback_buffers.cpp Extension/Multiplayer/Session/password.cpp
Server/server_activity.cpp Server/server_votes.cpp Server/server_commands.cpp Extension/Throwdowns/throwdown_wire.cpp)
target_include_directories(dingosdk_server SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking")
target_link_libraries(dingosdk_server PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json
dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dl pthread)
set_target_properties(dingosdk_server PROPERTIES OUTPUT_NAME "ReSkateServer")