diff --git a/CMakeLists.txt b/CMakeLists.txt index 63675d5..a5cf08f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,6 +4,8 @@ project(csgo_gc) set(CMAKE_CXX_STANDARD 17) +find_package(Threads REQUIRED) + if (CMAKE_SIZEOF_VOID_P EQUAL 4) set(IS_32BIT ON) endif() diff --git a/README.md b/README.md index c8f7bda..15e277d 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,24 @@ In Valve games, the Game Coordinator (GC) is a backend service most notably resp While it's still possible to connect CS:GO to CS2's GC by spoofing the version number, this may break in the future if Valve updates the GC protocol. This project aims to restore most GC-related functionality without relying on a centralized server. ## Current features -- Editable inventory (inventory.txt) -- Item equipping -- Opening cases (including sticker capsules, patch packs, graffiti boxes and music kit boxes) +- Editable inventory (`inventory.txt`) +- Live inventory updates while the game is running +- Item equipping and loadout updates +- Opening cases, sticker capsules, patch packs, graffiti boxes, music kit boxes and souvenir packages +- Souvenir item generation +- Stickers and patches +- Name tags - Graffiti support +- Music kits - Weapon StatTrak support +- Music kit StatTrak support, including round MVP count propagation - Storage Units - StatTrak Swaps - Trade ups -- Stickers and patches -- Name tags -- Music kits -- In-game store +- In-game store purchases +- Read-only inventory consistency diagnostics +- Source RCON-compatible local control interface +- Parameterized RCON item creation for paint kits, wear, seed, StatTrak, music kits, sprays, stickers and custom names - Works without full Steam API emulation - Full Windows, Linux and macOS support - Functional lobbies @@ -30,7 +36,8 @@ While it's still possible to connect CS:GO to CS2's GC by spoofing the version n - Networking using Steam's P2P interface ## Planned features -- Rest of the core features (souvenirs...) +- More validation and polish for edge-case inventory operations +- Tooling around live inventory editing I'm still looking for the **full** CS:GO Item Schema. If you have a relatively recent copy of it and are willing to share it, let me know! @@ -39,7 +46,7 @@ I'm still looking for the **full** CS:GO Item Schema. If you have a relatively r ## Installation - Download [CS:GO from Steam](steam://install/4465480) -- Download the latest release for your platform from the [releases page](https://github.com/mikkokko/csgo_gc/releases/latest) +- Download the latest release for your platform from the [releases page](https://github.com/GT-610/csgo_gc/releases/latest) - Navigate to the game's installation directory - Back up your existing launcher executables as they'll be overwritten (i.e. csgo.exe, srcds.exe, csgo_linux64, etc.) - Extract the contents of the downloaded archive to your game directory, replace the executables when prompted @@ -47,37 +54,63 @@ I'm still looking for the **full** CS:GO Item Schema. If you have a relatively r - macOS users: The release binaries are not notarized, so if you're using them, you'll have to deal with that somehow ## Inventory editing -For GUI inventory editors, see https://github.com/mikkokko/csgo_gc/issues/82. For manual editing, there is a guide made by someone else [here](https://gist.github.com/dricotec/1ae3deb06c42012970c00df914348e76). +Inventory can still be edited offline through `inventory.txt`. For manual editing, there is a guide made by someone else [here](https://gist.github.com/dricotec/1ae3deb06c42012970c00df914348e76). + +The local RCON interface can also create and remove items while the game is running. This is intended for scripts and GUI editors that want to avoid the old edit-file-and-restart workflow. ## Configuration See [csgo_gc/config.txt](examples/config.txt) for available options. +## RCON +RCON is disabled by default and binds to localhost with the default configuration; the actual listen address is controlled by `bind_address`. It uses the Source RCON binary protocol, so existing Source RCON clients can be used. + +See [rcon.md](docs/rcon.md) for protocol details, configuration, supported parameters and error formats. + ## Building Requirements: - Git - CMake 3.20 or newer - C++ compiler with C++17 support (VS 2017 or later, Clang 5 or later, GCC 7 or later) +The top-level CMake project downloads protobuf, cryptopp-cmake and funchook through `FetchContent` during configure. + The game is 32-bit on Windows so you need to build as 32-bit: -`cmake -A Win32 -B build` +```bat +cmake -A Win32 -B build +cmake --build build --config Release --target csgo_gc +``` Linux dedicated servers are also 32-bit: -`cmake -DCMAKE_C_FLAGS=-m32 -DCMAKE_CXX_FLAGS=-m32 -DCMAKE_ASM_FLAGS=-m32 -B build` +```sh +cmake -DCMAKE_C_FLAGS=-m32 -DCMAKE_CXX_FLAGS=-m32 -DCMAKE_ASM_FLAGS=-m32 -B build +cmake --build build --target csgo_gc +``` On macOS, you need to build for x86_64 instead of arm64: -`cmake -DCMAKE_OSX_ARCHITECTURES=x86_64 -DFUNCHOOK_CPU=x86 -B build` +```sh +cmake -DCMAKE_OSX_ARCHITECTURES=x86_64 -DFUNCHOOK_CPU=x86 -B build +cmake --build build --target csgo_gc +``` For Linux clients you don't have to specify any additional options. +For a full launcher package build, also build the launcher targets: + +```sh +cmake --build build --config Release --target csgo srcds csgo_gc +``` + ## License This project is licensed under the 2-Clause BSD License. See [LICENSE.md](LICENSE.md) for details. ## Credits -* **Mikko Kokko** - Author +* **Mikko Kokko** - Original author * **Theeto** - Code reused from the predecessor project, unusual loot lists +* **GT610** (`GT-610`) - Fork deveopment +* Contributors who continued inventory, networking, diagnostics and RCON work after the original upstream changes ## Third party dependencies - [Crypto++](https://github.com/weidai11/cryptopp) ([Boost Software License](https://github.com/weidai11/cryptopp/blob/master/License.txt)) diff --git a/csgo_gc/CMakeLists.txt b/csgo_gc/CMakeLists.txt index 35df56d..b8cbb8a 100644 --- a/csgo_gc/CMakeLists.txt +++ b/csgo_gc/CMakeLists.txt @@ -16,6 +16,7 @@ add_library(csgo_gc SHARED main.cpp networking_client.cpp networking_server.cpp + rcon_server.cpp souvenir.cpp steam_hook.cpp) @@ -45,7 +46,12 @@ target_link_libraries(csgo_gc PRIVATE cryptopp funchook-static libprotobuf-lite - steam_api) + steam_api + Threads::Threads) + +if (CMAKE_SYSTEM_NAME STREQUAL "Windows") + target_link_libraries(csgo_gc PRIVATE ws2_32) +endif() # apple message box if (APPLE) diff --git a/csgo_gc/config.cpp b/csgo_gc/config.cpp index 02f3d2b..2e97cf2 100644 --- a/csgo_gc/config.cpp +++ b/csgo_gc/config.cpp @@ -25,6 +25,20 @@ GCConfig::GCConfig() m_appIdOverride = config.GetNumber("appid_override", m_appIdOverride); m_showCsgoGCServersOnly = config.GetNumber("show_csgo_gc_servers_only", m_showCsgoGCServersOnly); + const KeyValue *rcon = config.GetSubkey("rcon"); + if (rcon) + { + m_rconEnabled = rcon->GetNumber("enabled", m_rconEnabled); + m_rconBindAddress = rcon->GetString("bind_address", m_rconBindAddress); + m_rconPort = rcon->GetNumber("port", m_rconPort); + m_rconPassword = rcon->GetString("password", m_rconPassword); + + if (m_rconEnabled && m_rconPassword.empty()) + { + Platform::Print("WARNING: RCON is enabled with an empty password; any Source RCON password will authenticate. Keep bind_address local or set rcon.password.\n"); + } + } + const KeyValue *ranks = config.GetSubkey("ranks"); if (ranks) { diff --git a/csgo_gc/config.h b/csgo_gc/config.h index 662fba8..e5660a4 100644 --- a/csgo_gc/config.h +++ b/csgo_gc/config.h @@ -28,6 +28,10 @@ class GCConfig // options used by steam hook uint32_t AppIdOverride() const { return m_appIdOverride; } bool ShowCsgoGCServersOnly() const { return m_showCsgoGCServersOnly; } + bool RconEnabled() const { return m_rconEnabled; } + const std::string &RconBindAddress() const { return m_rconBindAddress; } + uint16_t RconPort() const { return m_rconPort; } + const std::string &RconPassword() const { return m_rconPassword; } RankId CompetitiveRank() const { return m_competitiveRank; } int CompetitiveWins() const { return m_competitiveWins; } @@ -54,6 +58,10 @@ class GCConfig // and then wonder why the game doesn't work and open an issue on github otherwise uint32_t m_appIdOverride{ 4465480 }; bool m_showCsgoGCServersOnly{ true }; + bool m_rconEnabled{ false }; + std::string m_rconBindAddress{ "127.0.0.1" }; + uint16_t m_rconPort{ 37016 }; + std::string m_rconPassword; RankId m_competitiveRank{ RankNone }; int m_competitiveWins{ 0 }; diff --git a/csgo_gc/gc_client.cpp b/csgo_gc/gc_client.cpp index 19e8a4e..b84abbf 100644 --- a/csgo_gc/gc_client.cpp +++ b/csgo_gc/gc_client.cpp @@ -5,6 +5,174 @@ #include "keyvalue.h" #include "networking_shared.h" +#include +#include +#include + +namespace +{ + +struct RconCommand +{ + explicit RconCommand(std::string command_) + : command{ std::move(command_) } + { + } + + std::string command; + std::promise response; +}; + +template +bool TryParseNumber(std::string_view text, T &value) +{ + T parsed{}; + auto result = std::from_chars(text.data(), text.data() + text.size(), parsed); + if (result.ec != std::errc{} || result.ptr != text.data() + text.size()) + { + return false; + } + + value = parsed; + return true; +} + +bool TryParseFloat01(std::string_view text, float &value) +{ + std::string input{ text }; + char *end = nullptr; + errno = 0; + float parsed = std::strtof(input.c_str(), &end); + if (input.empty() || errno == ERANGE || end != input.c_str() + input.size() || !std::isfinite(parsed)) + { + return false; + } + + value = parsed; + return value >= 0.0f && value <= 1.0f; +} + +bool TryParseFloat(std::string_view text, float &value) +{ + std::string input{ text }; + char *end = nullptr; + errno = 0; + float parsed = std::strtof(input.c_str(), &end); + if (input.empty() || errno == ERANGE || end != input.c_str() + input.size() || !std::isfinite(parsed)) + { + return false; + } + + value = parsed; + return true; +} + +bool IsValidQuality(uint32_t quality) +{ + return quality <= ItemSchema::QualityTournament; +} + +bool IsValidRarity(uint32_t rarity) +{ + return rarity <= ItemSchema::RarityImmortal || rarity == ItemSchema::RarityUnusual; +} + +std::string ToLower(std::string value) +{ + for (char &ch : value) + { + ch = static_cast(tolower(static_cast(ch))); + } + + return value; +} + +bool TokenizeRconCommand(std::string_view command, std::vector &tokens) +{ + size_t i = 0; + while (i < command.size()) + { + while (i < command.size() && command[i] <= ' ') + { + i++; + } + + if (i >= command.size()) + { + break; + } + + std::string token; + bool quoted = false; + for (; i < command.size(); i++) + { + char ch = command[i]; + if (quoted) + { + if (ch == '\\' && i + 1 < command.size()) + { + token.push_back(command[++i]); + continue; + } + + if (ch == '"') + { + quoted = false; + continue; + } + + token.push_back(ch); + continue; + } + + if (ch <= ' ') + { + break; + } + + if (ch == '"') + { + quoted = true; + continue; + } + + token.push_back(ch); + } + + if (quoted) + { + return false; + } + + tokens.push_back(std::move(token)); + } + + return true; +} + +std::string Usage(std::string_view usage) +{ + std::string response{ "ERR usage: " }; + response.append(usage.data(), usage.size()); + return response; +} + +std::string InvalidParameter(std::string_view key) +{ + std::string response{ "ERR invalid parameter " }; + response.append(key.data(), key.size()); + return response; +} + +std::string UnknownParameter(std::string_view key) +{ + std::string response{ "ERR unknown parameter " }; + response.append(key.data(), key.size()); + return response; +} + +} // namespace + ClientGC::ClientGC(uint64_t steamId) : m_steamId{ steamId } , m_inventory{ steamId } @@ -38,6 +206,22 @@ uint32_t ClientGC::LocalPlayerMusicKitMVPsForRoundMVPEvent() const return cachedMVPs >= 0 ? static_cast(cachedMVPs + 1) : 0; } +std::string ClientGC::RunRconCommand(std::string command) +{ + auto request = std::make_shared(std::move(command)); + std::future response = request->response.get_future(); + + auto holder = new std::shared_ptr{ request }; + PostToGC(GCEvent::RconCommand, 0, &holder, sizeof(holder)); + + if (response.wait_for(std::chrono::seconds{ 5 }) != std::future_status::ready) + { + return "ERR timeout"; + } + + return response.get(); +} + void ClientGC::RefreshCachedMusicKitMVPs() { if (!m_inventory.EquippedMusicKitItemId(true)) @@ -126,12 +310,386 @@ void ClientGC::HandleEvent(GCEvent type, uint64_t id, const std::vector } break; + case GCEvent::RconCommand: + if (buffer.size() == sizeof(std::shared_ptr *)) + { + std::shared_ptr *holder; + memcpy(&holder, buffer.data(), sizeof(holder)); + + std::shared_ptr request = std::move(*holder); + delete holder; + + request->response.set_value(ExecuteRconCommand(request->command)); + } + else + { + Platform::Print("ClientGC::HandleEvent: invalid RconCommand buffer size\n"); + } + break; + default: Platform::Print("ClientGC::HandleEvent: unknown event type %d\n", static_cast(type)); break; } } +const ClientGC::RconCommandDef *ClientGC::RconCommands(size_t &count) +{ + static const RconCommandDef Commands[]{ + { "help", "help", &ClientGC::RconHelp }, + { "ping", "ping", &ClientGC::RconPing }, + { "status", "status", &ClientGC::RconStatus }, + { "clients", "clients", &ClientGC::RconClients }, + { "give_item", "give_item [count] [key=value...]", &ClientGC::RconGiveItem }, + { "remove_item", "remove_item ", &ClientGC::RconRemoveItem }, + { "refresh_inventory", "refresh_inventory", &ClientGC::RconRefreshInventory }, + }; + + count = sizeof(Commands) / sizeof(Commands[0]); + return Commands; +} + +std::string ClientGC::ExecuteRconCommand(std::string_view command) +{ + RconRequest request; + std::vector tokens; + if (!TokenizeRconCommand(command, tokens)) + { + return "ERR malformed command"; + } + + if (tokens.empty()) + { + return "ERR unknown command"; + } + + request.name = std::move(tokens[0]); + request.name = ToLower(std::move(request.name)); + for (size_t i = 1; i < tokens.size(); i++) + { + request.args.push_back(std::move(tokens[i])); + } + + size_t commandCount = 0; + const RconCommandDef *commands = RconCommands(commandCount); + for (size_t i = 0; i < commandCount; i++) + { + const RconCommandDef &commandDef = commands[i]; + if (request.name == commandDef.name) + { + return (this->*commandDef.handler)(request); + } + } + + return "ERR unknown command"; +} + +std::string ClientGC::RconHelp(const RconRequest &request) +{ + if (!request.args.empty()) + { + return Usage("help"); + } + + std::ostringstream response; + response << "OK commands:"; + + size_t commandCount = 0; + const RconCommandDef *commands = RconCommands(commandCount); + for (size_t i = 0; i < commandCount; i++) + { + response << (i ? ", " : " ") << commands[i].usage; + } + + return response.str(); +} + +std::string ClientGC::RconPing(const RconRequest &request) +{ + if (!request.args.empty()) + { + return Usage("ping"); + } + + return "OK pong"; +} + +std::string ClientGC::RconStatus(const RconRequest &request) +{ + if (!request.args.empty()) + { + return Usage("status"); + } + + std::ostringstream response; + response << "OK rcon=enabled client=online steamid=" << m_steamId + << " items=" << m_inventory.ItemCount(); + return response.str(); +} + +std::string ClientGC::RconClients(const RconRequest &request) +{ + if (!request.args.empty()) + { + return Usage("clients"); + } + + std::ostringstream response; + response << "OK steamid=" << m_steamId; + return response.str(); +} + +std::string ClientGC::RconGiveItem(const RconRequest &request) +{ + constexpr const char *GiveItemUsage = "give_item [count] [key=value...]"; + + if (request.args.empty()) + { + return Usage(GiveItemUsage); + } + + uint32_t defIndex; + if (!TryParseNumber(request.args[0], defIndex)) + { + return Usage(GiveItemUsage); + } + + uint32_t count = 1; + size_t parameterStart = 1; + if (parameterStart < request.args.size() && request.args[parameterStart].find('=') == std::string::npos) + { + if (!TryParseNumber(request.args[parameterStart], count) || count == 0 || count > 100) + { + return Usage(GiveItemUsage); + } + parameterStart++; + } + + Inventory::ParameterizedItemOptions options; + bool hasParameters = parameterStart < request.args.size(); + + for (size_t i = parameterStart; i < request.args.size(); i++) + { + std::string_view parameter{ request.args[i] }; + size_t separator = parameter.find('='); + if (separator == std::string_view::npos || separator == 0) + { + return Usage(GiveItemUsage); + } + + std::string key = ToLower(std::string{ parameter.substr(0, separator) }); + std::string_view value = parameter.substr(separator + 1); + + auto parseUint32 = [&](std::optional &target) -> std::optional { + uint32_t parsed; + if (!TryParseNumber(value, parsed)) + { + return InvalidParameter(key); + } + target = parsed; + return {}; + }; + + auto parseFloat01 = [&](std::optional &target) -> std::optional { + float parsed; + if (!TryParseFloat01(value, parsed)) + { + return InvalidParameter(key); + } + target = parsed; + return {}; + }; + + auto parseFloat = [&](std::optional &target) -> std::optional { + float parsed; + if (!TryParseFloat(value, parsed)) + { + return InvalidParameter(key); + } + target = parsed; + return {}; + }; + + std::optional error; + if (key == "level") + { + error = parseUint32(options.level); + } + else if (key == "quality") + { + error = parseUint32(options.quality); + if (!error && !IsValidQuality(*options.quality)) + { + error = InvalidParameter(key); + } + } + else if (key == "rarity") + { + error = parseUint32(options.rarity); + if (!error && !IsValidRarity(*options.rarity)) + { + error = InvalidParameter(key); + } + } + else if (key == "name") + { + options.customName = std::string{ value }; + } + else if (key == "paint") + { + error = parseUint32(options.paint); + } + else if (key == "seed") + { + error = parseUint32(options.seed); + } + else if (key == "wear") + { + error = parseFloat01(options.wear); + } + else if (key == "stattrak") + { + error = parseUint32(options.statTrak); + } + else if (key == "music") + { + error = parseUint32(options.music); + } + else if (key == "spray_color") + { + error = parseUint32(options.sprayColor); + } + else if (key == "spray_remaining") + { + error = parseUint32(options.sprayRemaining); + } + else if (key.rfind("sticker", 0) == 0 && key.size() >= 8 && key[7] >= '0' && key[7] <= '5') + { + size_t stickerSlot = static_cast(key[7] - '0'); + std::string_view suffix{ key.data() + 8, key.size() - 8 }; + if (suffix.empty()) + { + error = parseUint32(options.sticker[stickerSlot]); + } + else if (suffix == "_wear") + { + error = parseFloat01(options.stickerWear[stickerSlot]); + } + else if (suffix == "_scale") + { + error = parseFloat(options.stickerScale[stickerSlot]); + } + else if (suffix == "_rotation") + { + error = parseFloat(options.stickerRotation[stickerSlot]); + } + else + { + return UnknownParameter(key); + } + } + else + { + return UnknownParameter(key); + } + + if (error) + { + return *error; + } + } + + std::vector updates; + std::vector itemIds; + updates.reserve(count); + itemIds.reserve(count); + + for (uint32_t i = 0; i < count; i++) + { + uint64_t itemId = 0; + if (hasParameters) + { + CMsgSOSingleObject &update = updates.emplace_back(); + std::string error; + itemId = m_inventory.CreateParameterizedItem(defIndex, options, update, error); + if (!itemId) + { + updates.pop_back(); + if (error == "unknown defindex") + { + return "ERR unknown defindex"; + } + return std::string{ "ERR " }.append(error); + } + } + else + { + itemId = m_inventory.PurchaseItem(defIndex, updates); + } + + if (!itemId) + { + return "ERR unknown defindex"; + } + + itemIds.push_back(itemId); + } + + for (CMsgSOSingleObject &update : updates) + { + SendMessageToGame(true, k_ESOMsg_Create, update); + } + + std::ostringstream response; + response << "OK item_ids="; + for (size_t i = 0; i < itemIds.size(); i++) + { + if (i) + { + response << ","; + } + response << itemIds[i]; + } + + return response.str(); +} + +std::string ClientGC::RconRemoveItem(const RconRequest &request) +{ + if (request.args.size() != 1) + { + return Usage("remove_item "); + } + + uint64_t itemId; + if (!TryParseNumber(request.args[0], itemId)) + { + return Usage("remove_item "); + } + + CMsgSOSingleObject destroyed; + if (!m_inventory.RemoveItem(itemId, destroyed)) + { + return "ERR item not found"; + } + + SendMessageToGame(true, k_ESOMsg_Destroy, destroyed); + return "OK removed"; +} + +std::string ClientGC::RconRefreshInventory(const RconRequest &request) +{ + if (!request.args.empty()) + { + return Usage("refresh_inventory"); + } + + CMsgSOCacheSubscribed message; + m_inventory.BuildCacheSubscription(message, GetConfig().Level(), false); + SendMessageToGame(false, k_ESOMsg_CacheSubscribed, message); + return "OK refreshed"; +} + void ClientGC::HandleMessage(uint32_t type, const void *data, uint32_t size) { GCMessageRead messageRead{ type, data, size }; diff --git a/csgo_gc/gc_client.h b/csgo_gc/gc_client.h index 3124d8e..95d7bc7 100644 --- a/csgo_gc/gc_client.h +++ b/csgo_gc/gc_client.h @@ -10,8 +10,22 @@ class ClientGC final : public SharedGC ClientGC(uint64_t steamId); ~ClientGC(); uint32_t LocalPlayerMusicKitMVPsForRoundMVPEvent() const; + std::string RunRconCommand(std::string command); private: + struct RconRequest + { + std::string name; + std::vector args; + }; + + struct RconCommandDef + { + const char *name; + const char *usage; + std::string (ClientGC::*handler)(const RconRequest &request); + }; + void HandleEvent(GCEvent type, uint64_t id, const std::vector &buffer) override; // event handlers @@ -21,6 +35,15 @@ class ClientGC final : public SharedGC void RefreshCachedMusicKitMVPs(); void SyncLocalPlayerMusicKitState(int userId); void SendMusicKitMVPStateToGameServer(); + static const RconCommandDef *RconCommands(size_t &count); + std::string ExecuteRconCommand(std::string_view command); + std::string RconHelp(const RconRequest &request); + std::string RconPing(const RconRequest &request); + std::string RconStatus(const RconRequest &request); + std::string RconClients(const RconRequest &request); + std::string RconGiveItem(const RconRequest &request); + std::string RconRemoveItem(const RconRequest &request); + std::string RconRefreshInventory(const RconRequest &request); // send to the local game and the game server we're connected to (if we're connected) void SendMessageToGame(bool sendToGameServer, uint32_t type, diff --git a/csgo_gc/gc_shared.h b/csgo_gc/gc_shared.h index c9fc210..7bd6578 100644 --- a/csgo_gc/gc_shared.h +++ b/csgo_gc/gc_shared.h @@ -17,6 +17,7 @@ enum class GCEvent LocalPlayerRoundMVP, // sent to client gc when the local player earns round MVP SyncLocalPlayerMusicKitState, // sent to client gc from the main thread, buffer contains the local userid ClientSOCacheUnsubscribe, // sent to server gc when a client disconnects, id contains the steam id + RconCommand, // sent to client gc, buffer contains a RconCommand shared_ptr holder }; struct EventData diff --git a/csgo_gc/inventory.cpp b/csgo_gc/inventory.cpp index d4b1641..63f6c0f 100644 --- a/csgo_gc/inventory.cpp +++ b/csgo_gc/inventory.cpp @@ -39,6 +39,42 @@ inline bool IsDefaultItemId(uint64_t itemId, uint32_t &defIndex, uint32_t &paint return false; } +CSOEconItemAttribute *FindOrAddAttribute(CSOEconItem &item, uint32_t defIndex) +{ + for (int i = 0; i < item.attribute_size(); i++) + { + CSOEconItemAttribute *attribute = item.mutable_attribute(i); + if (attribute->def_index() == defIndex) + { + return attribute; + } + } + + CSOEconItemAttribute *attribute = item.add_attribute(); + attribute->set_def_index(defIndex); + return attribute; +} + +uint32_t StickerIdAttribute(size_t slot) +{ + return ItemSchema::AttributeStickerId0 + static_cast(slot) * 4; +} + +uint32_t StickerWearAttribute(size_t slot) +{ + return ItemSchema::AttributeStickerWear0 + static_cast(slot) * 4; +} + +uint32_t StickerScaleAttribute(size_t slot) +{ + return ItemSchema::AttributeStickerScale0 + static_cast(slot) * 4; +} + +uint32_t StickerRotationAttribute(size_t slot) +{ + return ItemSchema::AttributeStickerRotation0 + static_cast(slot) * 4; +} + Inventory::Inventory(uint64_t steamId) : m_steamId{ steamId } { @@ -1886,6 +1922,190 @@ uint64_t Inventory::PurchaseItem(uint32_t defIndex, std::vector ItemSchema::GraffitiTintMax)) + { + error = "invalid parameter spray_color"; + return 0; + } + + for (const std::optional &sticker : options.sticker) + { + if (sticker && !m_itemSchema.StickerKitInfoByDefIndex(*sticker)) + { + error = "unknown sticker"; + return 0; + } + } + + CSOEconItem &item = CreateItem(defIndex, ItemOriginPurchased, UnacknowledgedPurchased); + + if (options.level) + { + item.set_level(*options.level); + } + + if (options.customName && !options.customName->empty()) + { + item.set_custom_name(*options.customName); + } + + if (options.paint) + { + CSOEconItemAttribute *attribute = FindOrAddAttribute(item, ItemSchema::AttributeTexturePrefab); + m_itemSchema.SetAttributeUint32(attribute, *options.paint); + + attribute = FindOrAddAttribute(item, ItemSchema::AttributeTextureSeed); + m_itemSchema.SetAttributeUint32(attribute, options.seed.value_or(0)); + + attribute = FindOrAddAttribute(item, ItemSchema::AttributeTextureWear); + m_itemSchema.SetAttributeFloat(attribute, options.wear.value_or(0.001f)); + + if (!options.quality) + { + item.set_quality(ItemSchema::QualityUnique); + } + + if (!options.rarity) + { + item.set_rarity(m_itemSchema.GetPaintedRarity(defIndex, *options.paint, item.rarity())); + } + } + else + { + if (options.seed) + { + CSOEconItemAttribute *attribute = FindOrAddAttribute(item, ItemSchema::AttributeTextureSeed); + m_itemSchema.SetAttributeUint32(attribute, *options.seed); + } + + if (options.wear) + { + CSOEconItemAttribute *attribute = FindOrAddAttribute(item, ItemSchema::AttributeTextureWear); + m_itemSchema.SetAttributeFloat(attribute, *options.wear); + } + } + + if (options.music) + { + CSOEconItemAttribute *attribute = FindOrAddAttribute(item, ItemSchema::AttributeMusicId); + m_itemSchema.SetAttributeUint32(attribute, *options.music); + + attribute = FindOrAddAttribute(item, ItemSchema::AttributeKillEater); + m_itemSchema.SetAttributeUint32(attribute, options.statTrak ? (*options.statTrak == 1 ? 0 : *options.statTrak) : 0); + + attribute = FindOrAddAttribute(item, ItemSchema::AttributeKillEaterScoreType); + m_itemSchema.SetAttributeUint32(attribute, 1); + + if (!options.quality) + { + item.set_quality(options.statTrak ? ItemSchema::QualityStrange : ItemSchema::QualityUnique); + } + + if (!options.rarity) + { + item.set_rarity(ItemSchema::RarityRare); + } + } + + if (options.statTrak && !options.music) + { + CSOEconItemAttribute *attribute = FindOrAddAttribute(item, ItemSchema::AttributeKillEater); + m_itemSchema.SetAttributeUint32(attribute, *options.statTrak == 1 ? 0 : *options.statTrak); + + attribute = FindOrAddAttribute(item, ItemSchema::AttributeKillEaterScoreType); + m_itemSchema.SetAttributeUint32(attribute, 0); + + if (!options.quality) + { + item.set_quality(ItemSchema::QualityStrange); + } + } + + if (options.sprayColor) + { + CSOEconItemAttribute *attribute = FindOrAddAttribute(item, ItemSchema::AttributeSprayTintId); + m_itemSchema.SetAttributeUint32(attribute, *options.sprayColor); + } + + if (options.sprayRemaining) + { + CSOEconItemAttribute *attribute = FindOrAddAttribute(item, ItemSchema::AttributeSpraysRemaining); + m_itemSchema.SetAttributeUint32(attribute, *options.sprayRemaining); + } + + for (size_t i = 0; i < options.sticker.size(); i++) + { + if (options.sticker[i]) + { + CSOEconItemAttribute *attribute = FindOrAddAttribute(item, StickerIdAttribute(i)); + m_itemSchema.SetAttributeUint32(attribute, *options.sticker[i]); + + attribute = FindOrAddAttribute(item, StickerWearAttribute(i)); + m_itemSchema.SetAttributeFloat(attribute, options.stickerWear[i].value_or(0.0f)); + } + else if (options.stickerWear[i]) + { + CSOEconItemAttribute *attribute = FindOrAddAttribute(item, StickerWearAttribute(i)); + m_itemSchema.SetAttributeFloat(attribute, *options.stickerWear[i]); + } + + if (options.stickerScale[i]) + { + CSOEconItemAttribute *attribute = FindOrAddAttribute(item, StickerScaleAttribute(i)); + m_itemSchema.SetAttributeFloat(attribute, *options.stickerScale[i]); + } + + if (options.stickerRotation[i]) + { + CSOEconItemAttribute *attribute = FindOrAddAttribute(item, StickerRotationAttribute(i)); + m_itemSchema.SetAttributeFloat(attribute, *options.stickerRotation[i]); + } + } + + if (options.quality) + { + item.set_quality(*options.quality); + } + + if (options.rarity) + { + item.set_rarity(*options.rarity); + } + + ToSingleObject(update, item); + return item.id(); +} + bool Inventory::UnequipItem(uint64_t itemId, CMsgSOMultipleObjects &update) { uint32_t defIndex, paintKitIndex; diff --git a/csgo_gc/inventory.h b/csgo_gc/inventory.h index 08b5d72..553a264 100644 --- a/csgo_gc/inventory.h +++ b/csgo_gc/inventory.h @@ -14,6 +14,27 @@ class Inventory Inventory(uint64_t steamId); ~Inventory(); + struct ParameterizedItemOptions + { + std::optional level; + std::optional quality; + std::optional rarity; + std::optional customName; + + std::optional paint; + std::optional seed; + std::optional wear; + std::optional statTrak; + std::optional music; + std::optional sprayColor; + std::optional sprayRemaining; + + std::array, 6> sticker; + std::array, 6> stickerWear; + std::array, 6> stickerScale; + std::array, 6> stickerRotation; + }; + void BuildCacheSubscription(CMsgSOCacheSubscribed &message, int level, bool server); bool EquipItem(uint64_t itemId, uint32_t classId, uint32_t slotId, CMsgSOMultipleObjects &update); @@ -138,6 +159,11 @@ class Inventory // returns the item id and adds the item to the provided CMsgSOMultipleObjects // on failure returns 0 and does nothing uint64_t PurchaseItem(uint32_t defIndex, std::vector &update); + uint64_t CreateParameterizedItem(uint32_t defIndex, + const ParameterizedItemOptions &options, + CMsgSOSingleObject &update, + std::string &error); + size_t ItemCount() const { return m_items.size(); } private: uint32_t AccountId() const; diff --git a/csgo_gc/item_schema.cpp b/csgo_gc/item_schema.cpp index 137080a..539f8cb 100644 --- a/csgo_gc/item_schema.cpp +++ b/csgo_gc/item_schema.cpp @@ -1182,6 +1182,19 @@ const StickerKitInfo *ItemSchema::StickerKitByTournamentTeamId(uint32_t eventId, return nullptr; } +const StickerKitInfo *ItemSchema::StickerKitInfoByDefIndex(uint32_t defIndex) const +{ + for (const auto &pair : m_stickerKitInfo) + { + if (pair.second.m_defIndex == defIndex) + { + return &pair.second; + } + } + + return nullptr; +} + PaintKitInfo *ItemSchema::PaintKitInfoByName(std::string_view name) { auto it = m_paintKitInfo.find(std::string{ name }); @@ -1218,6 +1231,19 @@ MusicDefinitionInfo *ItemSchema::MusicDefinitionInfoByName(std::string_view name return &it->second; } +const MusicDefinitionInfo *ItemSchema::MusicDefinitionInfoByDefIndex(uint32_t defIndex) const +{ + for (const auto &pair : m_musicDefinitionInfo) + { + if (pair.second.m_defIndex == defIndex) + { + return &pair.second; + } + } + + return nullptr; +} + bool ItemSchema::GetCollectionsForPaintedItem(uint32_t defIndex, uint32_t paintKitDefIndex, std::vector &outCollections) const { diff --git a/csgo_gc/item_schema.h b/csgo_gc/item_schema.h index fae382c..9111237 100644 --- a/csgo_gc/item_schema.h +++ b/csgo_gc/item_schema.h @@ -151,6 +151,8 @@ class ItemSchema // trade-up helpers const ItemInfo *ItemInfoByDefIndex(uint32_t defIndex) const; const PaintKitInfo *PaintKitInfoByDefIndex(uint32_t defIndex) const; + const StickerKitInfo *StickerKitInfoByDefIndex(uint32_t defIndex) const; + const MusicDefinitionInfo *MusicDefinitionInfoByDefIndex(uint32_t defIndex) const; const StickerKitInfo *StickerKitByTournamentEventId(uint32_t eventId) const; const StickerKitInfo *StickerKitByTournamentTeamId(uint32_t eventId, uint32_t teamId) const; bool GetCollectionsForPaintedItem(uint32_t defIndex, uint32_t paintKitDefIndex, diff --git a/csgo_gc/rcon_server.cpp b/csgo_gc/rcon_server.cpp new file mode 100644 index 0000000..a65c269 --- /dev/null +++ b/csgo_gc/rcon_server.cpp @@ -0,0 +1,540 @@ +#include "stdafx.h" +#include "rcon_server.h" +#include "config.h" +#include "gc_client.h" + +#ifdef _WIN32 +#include +#include +#else +#include +#include +#include +#include +#include +#include +#endif + +namespace +{ + +constexpr int32_t SourceRconResponseValue = 0; +constexpr int32_t SourceRconExecCommand = 2; +constexpr int32_t SourceRconAuth = 3; +constexpr int32_t SourceRconAuthResponse = 2; +constexpr int32_t SourceRconMinPacketSize = 10; // request id + type + two null bytes +constexpr int32_t SourceRconMaxPacketSize = 4096; + +#ifdef _WIN32 +using SocketType = SOCKET; +constexpr SocketType InvalidSocket = INVALID_SOCKET; +#else +using SocketType = int; +constexpr SocketType InvalidSocket = -1; +#endif + +SocketType FromHandle(uintptr_t handle) +{ + return static_cast(handle); +} + +uintptr_t ToHandle(SocketType socket) +{ + return static_cast(socket); +} + +void CloseSocket(SocketType socket) +{ + if (socket == InvalidSocket) + { + return; + } + +#ifdef _WIN32 + closesocket(socket); +#else + close(socket); +#endif +} + +void ShutdownSocket(SocketType socket) +{ + if (socket == InvalidSocket) + { + return; + } + +#ifdef _WIN32 + shutdown(socket, SD_BOTH); +#else + shutdown(socket, SHUT_RDWR); +#endif +} + +int32_t ReadInt32LE(const char *data) +{ + const auto *bytes = reinterpret_cast(data); + return static_cast( + static_cast(bytes[0]) + | (static_cast(bytes[1]) << 8) + | (static_cast(bytes[2]) << 16) + | (static_cast(bytes[3]) << 24)); +} + +void AppendInt32LE(std::string &buffer, int32_t value) +{ + uint32_t unsignedValue = static_cast(value); + buffer.push_back(static_cast(unsignedValue & 0xff)); + buffer.push_back(static_cast((unsignedValue >> 8) & 0xff)); + buffer.push_back(static_cast((unsignedValue >> 16) & 0xff)); + buffer.push_back(static_cast((unsignedValue >> 24) & 0xff)); +} + +bool IsValidSourcePacketSize(int32_t size) +{ + return size >= SourceRconMinPacketSize && size <= SourceRconMaxPacketSize; +} + +bool SendAll(SocketType socket, const char *data, size_t size) +{ + while (size) + { +#if defined(_WIN32) || !defined(MSG_NOSIGNAL) + constexpr int SendFlags = 0; +#else + constexpr int SendFlags = MSG_NOSIGNAL; +#endif + int sent = send(socket, data, static_cast(size), SendFlags); + if (sent <= 0) + { + return false; + } + + data += sent; + size -= sent; + } + + return true; +} + +bool SendSourcePacket(SocketType socket, int32_t requestId, int32_t type, std::string_view body) +{ + if (body.size() > static_cast(SourceRconMaxPacketSize - SourceRconMinPacketSize)) + { + body = body.substr(0, static_cast(SourceRconMaxPacketSize - SourceRconMinPacketSize)); + } + + std::string packet; + packet.reserve(sizeof(int32_t) * 3 + body.size() + 2); + AppendInt32LE(packet, static_cast(sizeof(int32_t) * 2 + body.size() + 2)); + AppendInt32LE(packet, requestId); + AppendInt32LE(packet, type); + packet.append(body.data(), body.size()); + packet.push_back('\0'); + packet.push_back('\0'); + return SendAll(socket, packet.data(), packet.size()); +} + +} // namespace + +RconServer::RconServer() = default; + +class RconServer::ActiveClientCommand +{ +public: + explicit ActiveClientCommand(RconServer &server) + : m_server{ server } + { + std::lock_guard lock{ m_server.m_mutex }; + m_client = m_server.m_client; + if (m_client) + { + m_server.m_activeClientCommands++; + } + } + + ~ActiveClientCommand() + { + if (!m_client) + { + return; + } + + std::lock_guard lock{ m_server.m_mutex }; + m_server.m_activeClientCommands--; + m_server.m_clientIdle.notify_all(); + } + + ClientGC *Client() const { return m_client; } + +private: + RconServer &m_server; + ClientGC *m_client{}; +}; + +RconServer::~RconServer() +{ + Stop(); +} + +void RconServer::Start() +{ + Platform::Print("RCON: config enabled=%d bind_address=%s port=%u protocol=source password=%s\n", + GetConfig().RconEnabled() ? 1 : 0, + GetConfig().RconBindAddress().c_str(), + GetConfig().RconPort(), + GetConfig().RconPassword().empty() ? "empty" : "set"); + + if (!GetConfig().RconEnabled()) + { + Platform::Print("RCON: disabled\n"); + return; + } + + bool expected = false; + if (!m_running.compare_exchange_strong(expected, true)) + { + Platform::Print("RCON: already running\n"); + return; + } + + Platform::Print("RCON: starting listener thread\n"); + m_thread = std::thread{ &RconServer::ThreadMain, this }; +} + +void RconServer::Stop() +{ + bool expected = true; + if (!m_running.compare_exchange_strong(expected, false)) + { + if (m_thread.joinable()) + { + m_thread.join(); + } + return; + } + + SocketType listenSocket = InvalidSocket; + { + std::lock_guard lock{ m_mutex }; + listenSocket = FromHandle(m_listenSocket); + m_listenSocket = ToHandle(InvalidSocket); + } + + ShutdownSocket(listenSocket); + CloseSocket(listenSocket); + + if (m_thread.joinable()) + { + m_thread.join(); + } +} + +void RconServer::RegisterClient(ClientGC *client) +{ + Start(); + + std::lock_guard lock{ m_mutex }; + m_client = client; +} + +void RconServer::UnregisterClient(ClientGC *client) +{ + std::unique_lock lock{ m_mutex }; + if (m_client == client) + { + m_client = nullptr; + m_clientIdle.wait(lock, [this] { return m_activeClientCommands == 0; }); + } +} + +void RconServer::ThreadMain() +{ +#ifdef _WIN32 + WSADATA wsaData; + if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) + { + Platform::Print("RCON: WSAStartup failed\n"); + m_running.store(false); + return; + } +#endif + + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_flags = AI_PASSIVE; + + addrinfo *result = nullptr; + std::string port = std::to_string(GetConfig().RconPort()); + int gai = getaddrinfo(GetConfig().RconBindAddress().c_str(), port.c_str(), &hints, &result); + if (gai != 0) + { + Platform::Print("RCON: getaddrinfo failed for %s:%s\n", + GetConfig().RconBindAddress().c_str(), + port.c_str()); + m_running.store(false); +#ifdef _WIN32 + WSACleanup(); +#endif + return; + } + + SocketType listenSocket = InvalidSocket; + for (addrinfo *it = result; it; it = it->ai_next) + { + listenSocket = socket(it->ai_family, it->ai_socktype, it->ai_protocol); + if (listenSocket == InvalidSocket) + { + continue; + } + + int reuse = 1; + setsockopt(listenSocket, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast(&reuse), sizeof(reuse)); + + if (bind(listenSocket, it->ai_addr, static_cast(it->ai_addrlen)) == 0 + && listen(listenSocket, 8) == 0) + { + break; + } + + CloseSocket(listenSocket); + listenSocket = InvalidSocket; + } + + freeaddrinfo(result); + + if (listenSocket == InvalidSocket) + { + Platform::Print("RCON: failed to listen on %s:%s\n", + GetConfig().RconBindAddress().c_str(), + port.c_str()); + m_running.store(false); +#ifdef _WIN32 + WSACleanup(); +#endif + return; + } + + { + std::lock_guard lock{ m_mutex }; + m_listenSocket = ToHandle(listenSocket); + } + + Platform::Print("RCON: listening on %s:%s\n", GetConfig().RconBindAddress().c_str(), port.c_str()); + + while (m_running.load()) + { + SocketType clientSocket = accept(listenSocket, nullptr, nullptr); + if (clientSocket == InvalidSocket) + { + if (m_running.load()) + { + Platform::Print("RCON: accept failed\n"); + } + break; + } + + HandleConnection(ToHandle(clientSocket)); + } + + { + std::lock_guard lock{ m_mutex }; + m_listenSocket = ToHandle(InvalidSocket); + } + + if (m_running.load()) + { + CloseSocket(listenSocket); + } + +#ifdef _WIN32 + WSACleanup(); +#endif +} + +void RconServer::HandleConnection(uintptr_t socketHandle) +{ + SocketType socket = FromHandle(socketHandle); + +#if !defined(_WIN32) && defined(SO_NOSIGPIPE) + int noSigPipe = 1; + setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, sizeof(noSigPipe)); +#endif + +#ifdef _WIN32 + DWORD timeoutMs = 1000; + setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&timeoutMs), sizeof(timeoutMs)); +#else + timeval timeout{}; + timeout.tv_sec = 1; + setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); +#endif + + std::string buffer; + char chunk[1024]; + bool authenticated = false; + + while (m_running.load()) + { + int received = recv(socket, chunk, sizeof(chunk), 0); + if (received == 0) + { + break; + } + + if (received < 0) + { +#ifndef _WIN32 + if (errno == EAGAIN || errno == EWOULDBLOCK) + { + continue; + } +#else + if (WSAGetLastError() == WSAETIMEDOUT) + { + continue; + } +#endif + break; + } + + buffer.append(chunk, chunk + received); + + if (!HandlePacketBuffer(socketHandle, buffer, authenticated)) + { + break; + } + } + + CloseSocket(socket); +} + +bool RconServer::HandlePacketBuffer(uintptr_t socketHandle, std::string &buffer, bool &authenticated) +{ + while (buffer.size() >= sizeof(int32_t)) + { + int32_t packetSize = ReadInt32LE(buffer.data()); + if (!IsValidSourcePacketSize(packetSize)) + { + Platform::Print("RCON: malformed Source packet size %d\n", packetSize); + return false; + } + + size_t fullPacketSize = sizeof(int32_t) + static_cast(packetSize); + if (buffer.size() < fullPacketSize) + { + return true; + } + + const char *packet = buffer.data() + sizeof(int32_t); + int32_t requestId = ReadInt32LE(packet); + int32_t type = ReadInt32LE(packet + sizeof(int32_t)); + std::string_view payload{ packet + sizeof(int32_t) * 2, static_cast(packetSize - sizeof(int32_t) * 2) }; + + size_t firstTerminator = payload.find('\0'); + if (firstTerminator == std::string_view::npos) + { + Platform::Print("RCON: malformed Source packet without body terminator\n"); + return false; + } + + std::string_view body = payload.substr(0, firstTerminator); + std::string_view tail = payload.substr(firstTerminator + 1); + if (tail.empty() || tail.find('\0') == std::string_view::npos) + { + Platform::Print("RCON: malformed Source packet without empty terminator\n"); + return false; + } + + if (!HandlePacket(socketHandle, requestId, type, body, authenticated)) + { + return false; + } + + buffer.erase(0, fullPacketSize); + } + + return true; +} + +bool RconServer::HandlePacket(uintptr_t socketHandle, int32_t requestId, int32_t type, std::string_view body, bool &authenticated) +{ + SocketType socket = FromHandle(socketHandle); + + if (type == SourceRconAuth) + { + authenticated = IsSourceRconPassword(body); + int32_t authResponseId = authenticated ? requestId : -1; + + if (!authenticated) + { + Platform::Print("RCON: Source auth failed\n"); + } + + return SendSourcePacket(socket, requestId, SourceRconResponseValue, {}) + && SendSourcePacket(socket, authResponseId, SourceRconAuthResponse, {}); + } + + if (type != SourceRconExecCommand) + { + std::string response = "ERR unsupported packet type"; + return SendSourcePacket(socket, requestId, SourceRconResponseValue, response); + } + + if (!authenticated) + { + std::string response = "ERR not authenticated"; + return SendSourcePacket(socket, requestId, SourceRconResponseValue, response); + } + + std::string command{ body }; + std::string response = ExecuteCommand(std::move(command)); + return SendSourcePacket(socket, requestId, SourceRconResponseValue, response); +} + +bool RconServer::IsSourceRconPassword(std::string_view password) const +{ + const std::string &configuredPassword = GetConfig().RconPassword(); + return configuredPassword.empty() || password == configuredPassword; +} + +std::string RconServer::ExecuteCommand(std::string command) +{ + std::istringstream stream{ command }; + std::string name; + stream >> name; + + for (char &ch : name) + { + ch = static_cast(tolower(static_cast(ch))); + } + + if (name == "ping") + { + return "OK pong"; + } + + ActiveClientCommand activeCommand{ *this }; + if (ClientGC *client = activeCommand.Client()) + { + return client->RunRconCommand(std::move(command)); + } + + if (name == "status") + { + return "OK rcon=enabled client=none"; + } + + if (name == "clients") + { + return "OK no clients"; + } + + if (name == "help") + { + return "OK commands: help, ping, status, clients, give_item [count] [key=value...], remove_item , refresh_inventory"; + } + + return "ERR no client gc"; +} diff --git a/csgo_gc/rcon_server.h b/csgo_gc/rcon_server.h new file mode 100644 index 0000000..fa01f08 --- /dev/null +++ b/csgo_gc/rcon_server.h @@ -0,0 +1,34 @@ +#pragma once + +class ClientGC; + +class RconServer +{ +public: + RconServer(); + ~RconServer(); + + void Start(); + void Stop(); + + void RegisterClient(ClientGC *client); + void UnregisterClient(ClientGC *client); + +private: + void ThreadMain(); + void HandleConnection(uintptr_t socketHandle); + bool HandlePacketBuffer(uintptr_t socketHandle, std::string &buffer, bool &authenticated); + bool HandlePacket(uintptr_t socketHandle, int32_t requestId, int32_t type, std::string_view body, bool &authenticated); + bool IsSourceRconPassword(std::string_view password) const; + std::string ExecuteCommand(std::string command); + + class ActiveClientCommand; + + std::mutex m_mutex; + std::condition_variable m_clientIdle; + ClientGC *m_client{}; + size_t m_activeClientCommands{}; + std::thread m_thread; + std::atomic m_running{ false }; + uintptr_t m_listenSocket{ UINTPTR_MAX }; +}; diff --git a/csgo_gc/stdafx.h b/csgo_gc/stdafx.h index 81a3822..e2d6b43 100644 --- a/csgo_gc/stdafx.h +++ b/csgo_gc/stdafx.h @@ -1,14 +1,20 @@ #pragma once #include +#include +#include #include #include #include #include +#include +#include +#include #include #include #include +#include #include #include #include diff --git a/csgo_gc/steam_hook.cpp b/csgo_gc/steam_hook.cpp index 3f904e1..9685c4f 100644 --- a/csgo_gc/steam_hook.cpp +++ b/csgo_gc/steam_hook.cpp @@ -15,6 +15,7 @@ #include "gc_client.h" #include "gc_server.h" #include "platform.h" +#include "rcon_server.h" #include #ifdef _WIN32 @@ -153,6 +154,7 @@ class GCWrapper final // client connect/disconnect notifications static GCWrapper *s_clientGC; static GCWrapper *s_serverGC; +static RconServer s_rconServer; struct PlayerInfo { @@ -505,6 +507,7 @@ class SteamGameCoordinatorProxy final : public ISteamGameCoordinator { assert(!s_clientGC); s_clientGC = new GCWrapper{ SteamNetworkingMessages(), steamId }; + s_rconServer.RegisterClient(&s_clientGC->m_gc); } } @@ -519,6 +522,7 @@ class SteamGameCoordinatorProxy final : public ISteamGameCoordinator else { assert(s_clientGC); + s_rconServer.UnregisterClient(&s_clientGC->m_gc); delete s_clientGC; s_clientGC = nullptr; } @@ -2410,4 +2414,6 @@ void SteamHookInstall(bool dedicated) INLINE_HOOK(SteamAPI_UnregisterCallback); INLINE_HOOK(SteamAPI_RunCallbacks); INLINE_HOOK(SteamGameServer_RunCallbacks); + + s_rconServer.Start(); } diff --git a/docs/rcon.md b/docs/rcon.md new file mode 100644 index 0000000..81cbb72 --- /dev/null +++ b/docs/rcon.md @@ -0,0 +1,217 @@ +# RCON + +csgo_gc exposes an optional Source RCON-compatible control port for the local +ClientGC. It is intended for scripting, debugging, and tools that need to update +the live inventory without restarting CS:GO. + +RCON is disabled by default. + +## Configuration + +Add an `rcon` block to `csgo_gc/config.txt`: + +```text +"rcon" +{ + "enabled" "1" + "bind_address" "127.0.0.1" + "port" "37016" + "password" "" +} +``` + +Options: + +- `enabled`: `1` starts the RCON listener. Missing or `0` leaves it disabled. +- `bind_address`: defaults to `127.0.0.1`. Keep this local unless you have added + your own network protection. +- `port`: defaults to `37016`. +- `password`: Source RCON password. An empty configured password intentionally + accepts any supplied Source RCON password for compatibility with this custom GC. + +The setting is read when the GC is loaded. There is no config hot reload. + +Startup logs include the configured bind address, port, protocol, and whether the +password is empty, without printing the password. + +## Protocol + +The listener speaks the Source RCON binary protocol: + +- Little-endian size-prefixed packets. +- `SERVERDATA_AUTH` for authentication. +- `SERVERDATA_EXECCOMMAND` for commands. +- `SERVERDATA_RESPONSE_VALUE` / `SERVERDATA_AUTH_RESPONSE` for responses. + +Raw newline text mode is not supported. Use a Source RCON client or a script that +implements Source RCON packets. + +Example: + +```powershell +rcon -a 127.0.0.1:37016 -p 1 ping +``` + +Expected response body: + +```text +OK pong +``` + +Basic connectivity check: + +```powershell +Test-NetConnection 127.0.0.1 -Port 37016 +``` + +## Response Format + +Command responses are plain text inside Source RCON response packets: + +```text +OK +ERR +``` + +Examples: + +```text +OK pong +OK item_ids=1234567890123 +ERR no client gc +ERR unknown parameter foo +``` + +## Commands + +### `help` + +Lists available commands. + +### `ping` + +Returns: + +```text +OK pong +``` + +This works as long as the RCON server is online, even if no ClientGC is currently +registered. + +### `status` + +Returns RCON and ClientGC state. If the game client GC is online, the response +includes the local SteamID and basic inventory stats. + +### `clients` + +Lists the controllable ClientGC instance. v1 only controls the local ClientGC. + +### `give_item` + +Creates one or more inventory items and sends live SO create updates to the game. + +Syntax: + +```text +give_item [count] [key=value...] +``` + +Compatibility forms: + +```text +give_item 7 +give_item 7 5 +``` + +Parameterized examples: + +```text +give_item 7 paint=44 +give_item 7 paint=44 wear=0.12 seed=123 stattrak=5 +give_item 7 paint=44 name="RCON Test" +give_item 1314 music=3 stattrak=10 +give_item 7 paint=44 sticker0=12 sticker0_wear=0 +``` + +Rules: + +- `count` is optional and must be `1..100`. +- Parameters are `key=value`. +- Keys are case-insensitive. +- Values may be double quoted. Backslash escapes the next character inside + quotes. +- Unknown keys return `ERR unknown parameter `. +- Invalid numeric values return `ERR invalid parameter `. +- Successful creation returns `OK item_ids=...`. + +Supported parameters: + +| Parameter | Type | Notes | +| --- | --- | --- | +| `level` | uint32 | Overrides item level. | +| `quality` | uint32 | Explicit quality override. | +| `rarity` | uint32 | Explicit rarity override. | +| `name` | string | Sets the custom item name. Quote values containing spaces. | +| `paint` | uint32 | Paint kit defindex. Must exist in the item schema. | +| `seed` | uint32 | Paint seed. | +| `wear` | float | Paint wear, `0.0..1.0`. | +| `stattrak` | uint32 | Kill count. `stattrak=1` creates StatTrak with 0 kills. | +| `music` | uint32 | Music definition id. Requires music kit defindex `1314`. | +| `spray_color` | uint32 | Graffiti tint id. | +| `spray_remaining` | uint32 | Remaining spray uses. | +| `sticker0`..`sticker5` | uint32 | Sticker kit defindex. Must exist in the item schema. | +| `stickerN_wear` | float | Sticker wear, `0.0..1.0`. | +| `stickerN_scale` | float | Sticker scale. | +| `stickerN_rotation` | float | Sticker rotation. | + +Derived defaults: + +- `paint` adds paint kit, seed, and wear attributes. If not explicitly set, + `seed=0`, `wear=0.001`, `quality=Unique`, and painted rarity are applied. +- `stattrak` adds kill eater attributes. Non-music items use score type `0`. +- `music` adds music id and music-kit score type `1`. It requires `defindex=1314`. +- `stickerN` adds sticker id and defaults that slot's wear to `0` unless + `stickerN_wear` is supplied. +- Explicit `quality` and `rarity` override derived defaults. +- If no parameters are supplied, the command uses the original basic purchase + path. + +### `remove_item` + +Removes an item and sends a live SO destroy update to the game. + +Syntax: + +```text +remove_item +``` + +### `refresh_inventory` + +Resends the full inventory cache subscription to the game. This is a repair and +debug command, not the normal path for adding items. + +## Common Errors + +```text +ERR no client gc +ERR unknown defindex +ERR unknown paint +ERR unknown music +ERR unknown sticker +ERR item not found +ERR usage: give_item [count] [key=value...] +ERR invalid parameter wear +``` + +`ERR no client gc` means the RCON listener is running, but the local ClientGC has +not registered yet or has already been destroyed. + +## Safety Notes + +The default bind address is `127.0.0.1` because RCON can mutate local GC state and +inventory. Do not bind to a public interface without adding your own access +controls. Source RCON compatibility is provided for tooling convenience; it is +not TLS-protected. diff --git a/examples/config.txt b/examples/config.txt index efcd8f6..3c1af8c 100644 --- a/examples/config.txt +++ b/examples/config.txt @@ -40,6 +40,16 @@ // only show servers with the "csgo_gc" tag in the server browser "show_csgo_gc_servers_only" "1" +// local Source RCON. keep bind_address on localhost unless you use a password. +// empty password accepts any Source RCON password. +"rcon" +{ + "enabled" "0" + "bind_address" "127.0.0.1" + "port" "37016" + "password" "" +} + // 0 = don't log anything // 1 = log to in-game console // 2 = log to in-game console and file (gc_log.txt)