From 99799d20b1c9c4354c87f2231853ae769603fc02 Mon Sep 17 00:00:00 2001 From: Yioxoii6 <171155710+Yioxoii6@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:49:07 +0800 Subject: [PATCH] Fix Windows console/RCON encoding and add native player ID queries --- .github/workflows/console-query-tests.yml | 28 ++ CMakeLists.txt | 6 + CONSOLE_QUERY.md | 87 +++++++ .../Components/Console/console_encoding.hpp | 77 ++++++ Server/Components/Console/console_impl.hpp | 66 ++++- .../LegacyNetwork/Query/detailed_players.hpp | 81 ++++++ .../Components/LegacyNetwork/Query/query.cpp | 84 +++++- .../Components/LegacyNetwork/Query/query.hpp | 10 + .../LegacyNetwork/legacy_network_impl.cpp | 6 + .../LegacyNetwork/legacy_network_impl.hpp | 5 +- Tests/ConsoleQuery/CMakeLists.txt | 48 ++++ Tests/ConsoleQuery/README.md | 33 +++ Tests/ConsoleQuery/console-encoding-test.cpp | 34 +++ Tests/ConsoleQuery/console-fixture.cpp | 67 +++++ Tests/ConsoleQuery/fixture.cpp | 59 +++++ Tests/ConsoleQuery/integration.py | 241 ++++++++++++++++++ Tests/ConsoleQuery/query-packet-test.cpp | 61 +++++ 17 files changed, 983 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/console-query-tests.yml create mode 100644 CONSOLE_QUERY.md create mode 100644 Server/Components/Console/console_encoding.hpp create mode 100644 Server/Components/LegacyNetwork/Query/detailed_players.hpp create mode 100644 Tests/ConsoleQuery/CMakeLists.txt create mode 100644 Tests/ConsoleQuery/README.md create mode 100644 Tests/ConsoleQuery/console-encoding-test.cpp create mode 100644 Tests/ConsoleQuery/console-fixture.cpp create mode 100644 Tests/ConsoleQuery/fixture.cpp create mode 100644 Tests/ConsoleQuery/integration.py create mode 100644 Tests/ConsoleQuery/query-packet-test.cpp diff --git a/.github/workflows/console-query-tests.yml b/.github/workflows/console-query-tests.yml new file mode 100644 index 000000000..9adbebdc7 --- /dev/null +++ b/.github/workflows/console-query-tests.yml @@ -0,0 +1,28 @@ +name: Console and query regression tests +on: + push: + paths: + - 'Server/Components/Console/**' + - 'Server/Components/LegacyNetwork/**' + - 'Tests/ConsoleQuery/**' + - '.github/workflows/console-query-tests.yml' + pull_request: + paths: + - 'Server/Components/Console/**' + - 'Server/Components/LegacyNetwork/**' + - 'Tests/ConsoleQuery/**' + - '.github/workflows/console-query-tests.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + unit: + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - run: cmake -S Tests/ConsoleQuery -B build/regression -DCMAKE_BUILD_TYPE=Release + - run: cmake --build build/regression --config Release + - run: ctest --test-dir build/regression -C Release --output-on-failure diff --git a/CMakeLists.txt b/CMakeLists.txt index 2c822c01a..88c861450 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -157,3 +157,9 @@ if(BUILD_ABI_CHECK_TOOL) message("Configuring abi-check") add_subdirectory(Tools) endif() + +option(BUILD_CONSOLE_QUERY_TESTS "Build console encoding and player query regression tests" OFF) +if(BUILD_CONSOLE_QUERY_TESTS) + enable_testing() + add_subdirectory(Tests/ConsoleQuery) +endif() diff --git a/CONSOLE_QUERY.md b/CONSOLE_QUERY.md new file mode 100644 index 000000000..ceb00757e --- /dev/null +++ b/CONSOLE_QUERY.md @@ -0,0 +1,87 @@ +# Legacy console text and detailed player queries + +These changes are source changes proposed for review, not an official open.mp +release. Build the whole server from one revision; do not mix components from +this checkout with a different server/SDK revision. + +## Windows console and remote RCON encoding + +The game protocol sends text bytes without an encoding marker. For a GBK game +server, Unicode text entered at the Windows console must become GBK before it +reaches `say` or a script command. Add this to the existing `config.json`: + +```json +"console": { "input_codepage": 936 } +``` + +`console.input_codepage` defaults to **65001 (UTF-8)**, preserving existing +behavior. Use 936 for GBK, 950 for Big5, or 0 for the Windows system ANSI code +page. Conversion rejects invalid or unrepresentable characters, including emoji +that GBK cannot encode, instead of executing a changed command. Local command +replies are converted back to UTF-8 for console/log output. Settings are Windows +only; POSIX console behavior is unchanged. + +Remote RCON defaults to forwarding the original command bytes. Clients that +already send GBK, including SampCtrl's AUTO/GBK mode, need no server conversion. +For a client that sends **UTF-8 commands** to a GBK game server, explicitly enable: + +```json +"rcon": { "utf8_command_codepage": 936 } +``` + +Merge this property into the existing `rcon` object, preserving its `enable` and +`password` properties. Zero (the default) disables conversion. A nonzero value +selects the Windows code page used for the decoded UTF-8 command. Malformed UTF-8 +and lossy conversions return an error and do not dispatch the command. Passwords +are checked against their original bytes before any conversion; replies retain +their original byte encoding. In-game RCON and `SendRconCommand` are unchanged. + +All remote senders must agree on UTF-8 before enabling this option. Do not enable +it while SampCtrl is in AUTO/GBK mode: either leave it at 0 or select UTF-8 in the +app. UTF-8 command input does not change the encoding of server replies, player +names, configuration strings, or other game messages. A client's response decoder +must still support the server's reply encoding. There is no reliable automatic +way to distinguish untagged UTF-8 from every legacy encoding. + +## Public detailed player queries + +The existing `c` response remains unchanged: it has names and scores but no IDs. +The legacy `d` response supplies real pool IDs, names, scores and ping when there +are at most 100 players and every ID fits in a byte. If any ID exceeds 255, `d` +does not reply; it never truncates an ID or substitutes the row number. + +The proposed uppercase `D` extension supplies 16-bit IDs in bounded pages. It +uses the existing SampCtrl `sc_d16=1` rule as a compatibility capability marker. +This is an experimental extension for upstream review, not a claim of an +official standardized protocol. It exposes the same public names/scores plus +IDs and ping; it contains no IP addresses or credentials. `enable_query=false` +disables it together with the existing queries. RCON authentication is neither +required for reading this public information nor bypassed for management actions. + +All integers are little-endian. Requests start with the normal 11-byte SAMP query +header with opcode `D`, followed by: + +| Field | Bytes | Meaning | +| --- | ---: | --- | +| version | 1 | 1 | +| generation | 4 | 0 for the first page; reuse the returned generation thereafter | +| page | 2 | Zero based; generation 0 requires page 0 | + +Responses echo the query header, then version(1), generation(4), total(2), page(2), +pageCount(2), count(2). Each record contains ID(2), nameLength(1), name bytes, +signed score(4), unsigned ping(4). Name bytes are unchanged, at most 24 bytes. + +- The main thread snapshots non-bot players every second, sorted by actual ID. +- Each page has at most 32 players. At 1000 players there are 32 pages; even with + maximum-length names a response is at most 1144 bytes, including the query header. +- An empty server returns one empty page. Generation 0 is never emitted. +- The last 16 snapshots are retained, bounding memory and supporting consistent + pagination while players change. Bad lengths, versions, pages and expired + generations receive no reply. A client should restart at generation 0 after + an unavailable page and reject mixed generations or duplicate IDs. +- A snapshot may be up to one refresh interval old. Management clients must + revalidate identity through authenticated commands before acting on an ID + that may have been reused after a disconnect. + +See [the regression test instructions](Tests/ConsoleQuery/README.md) for local +unit tests and isolated server integration tests. diff --git a/Server/Components/Console/console_encoding.hpp b/Server/Components/Console/console_encoding.hpp new file mode 100644 index 000000000..646194c7d --- /dev/null +++ b/Server/Components/Console/console_encoding.hpp @@ -0,0 +1,77 @@ +/* SPDX-License-Identifier: MPL-2.0 + * Lossless Windows console command conversion. + */ +#pragma once +#ifdef WIN32 +#include +#include +#include +#include +#include + +namespace ConsoleEncoding +{ +// Refuse lossy conversion: a command must not silently acquire '?' or best-fit characters. +inline bool encode(std::wstring_view input, unsigned codepage, std::string& output) +{ + output.clear(); + if (codepage == 0) + codepage = GetACP(); + if (!IsValidCodePage(codepage) || input.size() > (std::numeric_limits::max)()) + return false; + if (input.empty()) + return true; + const DWORD flags = (codepage == CP_UTF8 || codepage == 54936) ? WC_ERR_INVALID_CHARS : 0; + const int length = static_cast(input.size()); + const int size = WideCharToMultiByte(codepage, flags, input.data(), length, nullptr, 0, nullptr, nullptr); + if (size <= 0) + return false; + std::string result(size, '\0'); + if (WideCharToMultiByte(codepage, flags, input.data(), length, result.data(), size, nullptr, nullptr) != size) + return false; + const int wideSize = MultiByteToWideChar(codepage, 0, result.data(), size, nullptr, 0); + if (wideSize != length) + return false; + std::wstring roundTrip(wideSize, L'\0'); + if (MultiByteToWideChar(codepage, 0, result.data(), size, roundTrip.data(), wideSize) != wideSize || roundTrip != input) + return false; + output = std::move(result); + return true; +} + +inline bool toUtf8(std::string_view input, unsigned codepage, std::string& output) +{ + output.clear(); + if (input.size() > (std::numeric_limits::max)()) + return false; + if (input.empty()) + return true; + const int size = MultiByteToWideChar(codepage, 0, input.data(), static_cast(input.size()), nullptr, 0); + if (size <= 0) + return false; + std::wstring wide(size, L'\0'); + if (MultiByteToWideChar(codepage, 0, input.data(), static_cast(input.size()), wide.data(), size) != size) + return false; + return encode(wide, CP_UTF8, output); +} + +// Remote RCON has no encoding marker. Only call this when an administrator +// explicitly configures UTF-8 input; never guess from the command bytes. +inline bool fromUtf8(std::string_view input, unsigned codepage, std::string& output) +{ + output.clear(); + if (input.size() > (std::numeric_limits::max)()) + return false; + if (input.empty()) + return encode({}, codepage, output); + const int length = static_cast(input.size()); + const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, input.data(), length, nullptr, 0); + if (size <= 0) + return false; + std::wstring wide(size, L'\0'); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, input.data(), length, wide.data(), size) != size) + return false; + return encode(wide, codepage, output); +} +} +#endif diff --git a/Server/Components/Console/console_impl.hpp b/Server/Components/Console/console_impl.hpp index 474a03377..761cf5460 100644 --- a/Server/Components/Console/console_impl.hpp +++ b/Server/Components/Console/console_impl.hpp @@ -20,6 +20,7 @@ #include #include #include +#include "console_encoding.hpp" using namespace Impl; @@ -63,7 +64,10 @@ class ConsoleComponent final : public IConsoleComponent, public CoreEventHandler DefaultEventDispatcher eventDispatcher; std::mutex cmdMutex; std::atomic_bool newCmd = false; - String cmd; + std::wstring cmd; +#ifdef WIN32 + inline static thread_local unsigned localReplyCodepage = 0; +#endif ThreadProcData* threadData; std::thread cinThread; std::thread::native_handle_type nativeThreadHandle; @@ -154,6 +158,16 @@ class ConsoleComponent final : public IConsoleComponent, public CoreEventHandler public: void sendMessage(const ConsoleCommandSenderData& recipient, StringView message) override { +#ifdef WIN32 + // Replies to local input use the command's byte encoding; logLnU8 expects UTF-8. + String localReply; + if (recipient.sender == ConsoleCommandSender::Console && localReplyCodepage != 0 + && ConsoleEncoding::toUtf8(std::string_view(message.data(), message.size()), localReplyCodepage, localReply)) + { + core->logLnU8(LogLevel::Message, "%.*s", PRINT_VIEW(localReply)); + return; + } +#endif // We don't output UTF-8 for ingame commands if (recipient.sender == ConsoleCommandSender::Player) { @@ -203,6 +217,22 @@ class ConsoleComponent final : public IConsoleComponent, public CoreEventHandler return SemanticVersion(OMP_VERSION_MAJOR, OMP_VERSION_MINOR, OMP_VERSION_PATCH, BUILD_NUMBER); } + void provideConfiguration(ILogger& logger, IEarlyConfig& config, bool defaults) override + { +#ifdef WIN32 + // Preserve UTF-8 by default; legacy servers can select their game code page. + if (defaults || config.getType("console.input_codepage") == ConfigOptionType_None) + { + config.setInt("console.input_codepage", CP_UTF8); + } + if (defaults || config.getType("rcon.utf8_command_codepage") == ConfigOptionType_None) + { + // Zero leaves remote commands byte-for-byte unchanged. + config.setInt("rcon.utf8_command_codepage", 0); + } +#endif + } + void onLoad(ICore* core) override { this->core = core; @@ -245,7 +275,8 @@ class ConsoleComponent final : public IConsoleComponent, public CoreEventHandler { std::scoped_lock lock(threadData->component->cmdMutex); - threadData->component->cmd = std::wstring_convert, wchar_t>().to_bytes(line); + // Keep Unicode intact until the main thread applies the configured game encoding. + threadData->component->cmd = line; threadData->component->newCmd = true; } else @@ -342,10 +373,33 @@ class ConsoleComponent final : public IConsoleComponent, public CoreEventHandler bool expected = true; if (newCmd.compare_exchange_weak(expected, false)) { - cmdMutex.lock(); - String command = cmd; - cmdMutex.unlock(); - + std::wstring line; + { + std::scoped_lock lock(cmdMutex); + line = cmd; + } + String command; +#ifdef WIN32 + const int* setting = core->getConfig().getInt("console.input_codepage"); + const unsigned codepage = setting ? static_cast(*setting) : CP_UTF8; + if (!ConsoleEncoding::encode(line, codepage, command)) + { + core->logLn(LogLevel::Error, "Local console command was not executed: text cannot be represented in console.input_codepage (%u).", codepage); + return; + } + struct ReplyEncodingScope + { + unsigned previous; + ReplyEncodingScope(unsigned value) + : previous(localReplyCodepage) + { + localReplyCodepage = value; + } + ~ReplyEncodingScope() { localReplyCodepage = previous; } + } scope(codepage == 0 ? GetACP() : codepage); +#else + command = std::wstring_convert, wchar_t>().to_bytes(line); +#endif send(command); } } diff --git a/Server/Components/LegacyNetwork/Query/detailed_players.hpp b/Server/Components/LegacyNetwork/Query/detailed_players.hpp new file mode 100644 index 000000000..48c8fa88e --- /dev/null +++ b/Server/Components/LegacyNetwork/Query/detailed_players.hpp @@ -0,0 +1,81 @@ +/* SPDX-License-Identifier: MPL-2.0 + * Bounded, paginated detailed player query encoding. + */ +#pragma once +#include +#include +#include +#include +#include + +namespace DetailedPlayersQuery +{ +constexpr size_t PageSize = 32; +constexpr size_t MaxPlayers = 1000; +constexpr size_t MaxNameBytes = 24; +struct Player +{ + uint16_t id; + std::string name; + int32_t score; + uint32_t ping; +}; +struct Snapshot +{ + uint32_t generation; + std::vector> pages; +}; +inline void append(std::vector& out, uint32_t value, size_t bytes) +{ + for (size_t i = 0; i < bytes; ++i) + out.push_back(static_cast((value >> (8 * i)) & 255)); +} +inline void record(std::vector& out, const Player& player, bool wide) +{ + append(out, player.id, wide ? 2 : 1); + const auto length = std::min(player.name.size(), MaxNameBytes); + append(out, static_cast(length), 1); + out.insert(out.end(), player.name.begin(), player.name.begin() + length); + append(out, static_cast(player.score), 4); + append(out, player.ping, 4); +} +inline Snapshot buildSnapshot(uint32_t generation, const std::vector& players) +{ + Snapshot result { generation, {} }; + if (generation == 0 || players.size() > MaxPlayers) + return result; + const size_t pageCount = std::max(size_t(1), (players.size() + PageSize - 1) / PageSize); + for (size_t page = 0; page < pageCount; ++page) + { + const size_t begin = page * PageSize; + const size_t end = std::min(begin + PageSize, players.size()); + std::vector output(11, 0); + output[10] = 'D'; + append(output, 1, 1); + append(output, generation, 4); + append(output, static_cast(players.size()), 2); + append(output, static_cast(page), 2); + append(output, static_cast(pageCount), 2); + append(output, static_cast(end - begin), 2); + for (size_t i = begin; i < end; ++i) + record(output, players[i], true); + result.pages.push_back(std::move(output)); + } + return result; +} +inline std::vector buildLegacy(const std::vector& players) +{ + // The legacy protocol cannot represent IDs above 255. Never wrap or invent IDs. + if (players.size() > 100 || std::any_of(players.begin(), players.end(), [](const Player& p) + { + return p.id > 255; + })) + return {}; + std::vector output(11, 0); + output[10] = 'd'; + append(output, static_cast(players.size()), 2); + for (const auto& player : players) + record(output, player, false); + return output; +} +} diff --git a/Server/Components/LegacyNetwork/Query/query.cpp b/Server/Components/LegacyNetwork/Query/query.cpp index 9c6e4e2bb..392d72eda 100644 --- a/Server/Components/LegacyNetwork/Query/query.cpp +++ b/Server/Components/LegacyNetwork/Query/query.cpp @@ -7,6 +7,7 @@ */ #include "query.hpp" +#include "../../Console/console_encoding.hpp" #include const size_t MAX_ACCEPTABLE_HOSTNAME_SIZE = 63; @@ -15,6 +16,65 @@ const size_t MAX_ACCEPTABLE_GMTEXT_SIZE = 39; const size_t MAX_ACCEPTABLE_DISCORD_LINK_SIZE = 50; const size_t MAX_ACCEPTABLE_IMAGE_URL_SIZE = 160; +void Query::refreshDetailedPlayers() +{ + if (!core) + return; + std::vector players; + for (IPlayer* player : core->getPlayers().players()) + { + if (player->isBot()) + continue; + const auto name = player->getName(); + players.push_back({ static_cast(player->getID()), std::string(name.data(), name.size()), static_cast(player->getScore()), static_cast(player->getPing()) }); + } + std::sort(players.begin(), players.end(), [](const auto& a, const auto& b) + { + return a.id < b.id; + }); + if (++detailedGeneration == 0) + ++detailedGeneration; + auto snapshot = DetailedPlayersQuery::buildSnapshot(detailedGeneration, players); + auto legacy = DetailedPlayersQuery::buildLegacy(players); + std::lock_guard lock(detailedMutex); + detailedSnapshots.push_back(std::move(snapshot)); + while (detailedSnapshots.size() > 16) + detailedSnapshots.pop_front(); + legacyDetailedPlayers = std::move(legacy); +} + +Span Query::detailedQuery(Span buffer) +{ + std::lock_guard lock(detailedMutex); + if (buffer.size() == BASE_QUERY_SIZE && buffer[QUERY_TYPE_INDEX] == 'd') + { + detailedResponse = legacyDetailedPlayers; + } + else if (buffer.size() == BASE_QUERY_SIZE + 7 && buffer[QUERY_TYPE_INDEX] == 'D' && buffer[BASE_QUERY_SIZE] == 1) + { + const auto* bytes = reinterpret_cast(buffer.data()); + uint32_t generation = uint32_t(bytes[12]) | (uint32_t(bytes[13]) << 8) | (uint32_t(bytes[14]) << 16) | (uint32_t(bytes[15]) << 24); + const uint16_t page = uint16_t(bytes[16]) | (uint16_t(bytes[17]) << 8); + if (detailedSnapshots.empty() || (generation == 0 && page != 0)) + return {}; + if (generation == 0) + generation = detailedSnapshots.back().generation; + auto snapshot = std::find_if(detailedSnapshots.begin(), detailedSnapshots.end(), [generation](const auto& item) + { + return item.generation == generation; + }); + if (snapshot == detailedSnapshots.end() || page >= snapshot->pages.size()) + return {}; + detailedResponse = snapshot->pages[page]; + } + else + return {}; + if (detailedResponse.empty()) + return {}; + memcpy(detailedResponse.data(), buffer.data(), QUERY_COPY_TO); + return Span(detailedResponse.data(), detailedResponse.size()); +} + template void writeToBuffer(char* output, size_t& offset, T value) { @@ -210,7 +270,7 @@ void Query::buildRulesBuffer() } } -constexpr Span getBuffer(Span input, std::unique_ptr& buffer, size_t length) +inline Span getBuffer(Span input, std::unique_ptr& buffer, size_t length) { if (!buffer) { @@ -271,6 +331,21 @@ void Query::handleRCON(Span buffer, uint32_t sock, const sockaddr_in { StringView cmd(&subbuf.data()[offset], cmdLen); LegacyConsoleMessageHandler handler(sock, client, tolen, buffer.subspan(0, BASE_QUERY_SIZE)); +#ifdef WIN32 + // Password validation always uses the original bytes. Conversion is + // opt-in because legacy RCON clients already send the game's encoding. + String converted; + const int* codepage = core->getConfig().getInt("rcon.utf8_command_codepage"); + if (codepage && *codepage != 0) + { + if (!ConsoleEncoding::fromUtf8(std::string_view(cmd.data(), cmd.size()), static_cast(*codepage), converted)) + { + handler.handleConsoleMessage("RCON command was not executed: invalid UTF-8 or text cannot be represented in rcon.utf8_command_codepage."); + return; + } + cmd = converted; + } +#endif console->send(cmd, ConsoleCommandSenderData(handler)); } } @@ -286,7 +361,7 @@ void Query::handleRCON(Span buffer, uint32_t sock, const sockaddr_in Span Query::handleQuery(Span buffer, uint32_t sock, const sockaddr_in& client, int tolen) { - if (core == nullptr) + if (core == nullptr || buffer.size() < BASE_QUERY_SIZE) { return Span(); } @@ -301,6 +376,11 @@ Span Query::handleQuery(Span buffer, uint32_t sock, cons core->printLn("[query:%c] from %.*s", buffer[QUERY_TYPE_INDEX], PRINT_VIEW(addrString)); } + if (buffer[QUERY_TYPE_INDEX] == 'd' || buffer[QUERY_TYPE_INDEX] == 'D') + { + return detailedQuery(buffer); + } + // Ping if (buffer[QUERY_TYPE_INDEX] == 'p') { diff --git a/Server/Components/LegacyNetwork/Query/query.hpp b/Server/Components/LegacyNetwork/Query/query.hpp index 72e0b5fe1..62e46f90f 100644 --- a/Server/Components/LegacyNetwork/Query/query.hpp +++ b/Server/Components/LegacyNetwork/Query/query.hpp @@ -10,6 +10,9 @@ #include "Server/Components/Console/console.hpp" #include "sdk.hpp" #include +#include +#include +#include "detailed_players.hpp" using namespace Impl; @@ -38,6 +41,7 @@ class Query : NoCopy Span handleQuery(Span buffer, uint32_t sock, const sockaddr_in& client, int tolen); void handleRCON(Span buffer, uint32_t sock, const sockaddr_in& client, int tolen); void buildRulesBuffer(); + void refreshDetailedPlayers(); void buildPlayerDependentBuffers(IPlayer* except = nullptr) { @@ -179,6 +183,12 @@ class Query : NoCopy bool passworded = false; bool logQueries = false; bool rconEnabled = false; + std::mutex detailedMutex; + uint32_t detailedGeneration = 0; + std::deque detailedSnapshots; + std::vector legacyDetailedPlayers; + std::vector detailedResponse; + Span detailedQuery(Span buffer); std::unique_ptr playerListBuffer; size_t playerListBufferLength = 0; diff --git a/Server/Components/LegacyNetwork/legacy_network_impl.cpp b/Server/Components/LegacyNetwork/legacy_network_impl.cpp index 354d182da..852b9eb08 100644 --- a/Server/Components/LegacyNetwork/legacy_network_impl.cpp +++ b/Server/Components/LegacyNetwork/legacy_network_impl.cpp @@ -850,6 +850,7 @@ void RakNetLegacyNetwork::start() std::stringstream version; version << "omp " << OMP_VERSION_MAJOR << "." << OMP_VERSION_MINOR << "." << OMP_VERSION_PATCH << "." << BUILD_NUMBER; query.setRuleValue("version", version.str()); + query.setRuleValue("sc_d16", "1"); // Send as a rule to announce which clients are allowed to join query.setRuleValue("allowed_clients", !allow037 ? "0.3.DL" : "0.3.7, 0.3.DL"); @@ -925,6 +926,11 @@ void RakNetLegacyNetwork::start() void RakNetLegacyNetwork::onTick(Microseconds elapsed, TimePoint now) { + if (*core->getConfig().getBool("enable_query") && now - lastDetailedQueryRefresh >= Seconds(1)) + { + query.refreshDetailedPlayers(); + lastDetailedQueryRefresh = now; + } for (RakNet::Packet* pkt = rakNetServer.Receive(); pkt; pkt = rakNetServer.Receive()) { if (pkt->playerIndex >= playerFromRakIndex.size()) diff --git a/Server/Components/LegacyNetwork/legacy_network_impl.hpp b/Server/Components/LegacyNetwork/legacy_network_impl.hpp index 1c4d769e8..111b439fd 100644 --- a/Server/Components/LegacyNetwork/legacy_network_impl.hpp +++ b/Server/Components/LegacyNetwork/legacy_network_impl.hpp @@ -29,8 +29,8 @@ using namespace Impl; #define MAX_MTU_037 576 #define MAX_MTU_DL MAXIMUM_MTU_SIZE -static const StaticArray ProtectedRules = { - "version", "allowed_clients" +static const StaticArray ProtectedRules = { + "version", "allowed_clients", "sc_d16" }; class Core; @@ -45,6 +45,7 @@ class RakNetLegacyNetwork final : public Network, public CoreEventHandler, publi StaticArray playerRemoteSystem; Milliseconds cookieSeedTime; TimePoint lastCookieSeed; + TimePoint lastDetailedQueryRefresh {}; INPCComponent* npcComponent = nullptr; public: diff --git a/Tests/ConsoleQuery/CMakeLists.txt b/Tests/ConsoleQuery/CMakeLists.txt new file mode 100644 index 000000000..b1c471b6e --- /dev/null +++ b/Tests/ConsoleQuery/CMakeLists.txt @@ -0,0 +1,48 @@ +cmake_minimum_required(VERSION 3.19) +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + project(ConsoleQueryTests LANGUAGES CXX) + enable_testing() +endif() +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +get_filename_component(OPENMP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) + +add_executable(query-packet-test query-packet-test.cpp) +target_include_directories(query-packet-test PRIVATE "${OPENMP_ROOT}/Server/Components/LegacyNetwork/Query") +add_test(NAME query-packet-test COMMAND query-packet-test) +if(WIN32) + add_executable(console-encoding-test console-encoding-test.cpp) + target_include_directories(console-encoding-test PRIVATE "${OPENMP_ROOT}/Server/Components/Console") + target_compile_definitions(console-encoding-test PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) + if(MSVC) + target_compile_options(console-encoding-test PRIVATE /utf-8) + endif() + add_test(NAME console-encoding-test COMMAND console-encoding-test) +endif() + +option(BUILD_CONSOLE_QUERY_FIXTURES "Build isolated integration fixtures; never deploy these components" OFF) +if(BUILD_CONSOLE_QUERY_FIXTURES) + if(NOT TARGET Server OR NOT TARGET LegacyNetwork OR NOT TARGET Console) + message(FATAL_ERROR "Integration fixtures require a full server build with Console and LegacyNetwork.") + endif() + foreach(fixture NativeQueryFixture ConsoleEncodingFixture) + if(fixture STREQUAL "NativeQueryFixture") + set(source fixture.cpp) + else() + set(source console-fixture.cpp) + endif() + add_library(${fixture} SHARED ${source}) + target_link_libraries(${fixture} PRIVATE OMP-SDK OMP-NetCode) + # Keep test components out of the deployable server directory. + set_target_properties(${fixture} PROPERTIES PREFIX "" RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/fixtures" LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/fixtures") + endforeach() + if(WIN32) + find_package(Python3 REQUIRED COMPONENTS Interpreter) + add_test(NAME console-query-integration COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/integration.py" + --server "$" --network "$" + --console "$" --query-fixture "$" + --console-fixture "$") + set_tests_properties(console-query-integration PROPERTIES TIMEOUT 120) + endif() +endif() diff --git a/Tests/ConsoleQuery/README.md b/Tests/ConsoleQuery/README.md new file mode 100644 index 000000000..76e3e9c37 --- /dev/null +++ b/Tests/ConsoleQuery/README.md @@ -0,0 +1,33 @@ +# Console and player query regression tests + +Unit tests can run without server dependencies or submodules: + +```sh +cmake -S Tests/ConsoleQuery -B build/regression -DCMAKE_BUILD_TYPE=Release +cmake --build build/regression --config Release +ctest --test-dir build/regression -C Release --output-on-failure +``` + +The packet tests run on all supported C++17 platforms. Windows additionally tests +lossless GBK, Big5 and UTF-8 conversion, invalid Unicode and unsupported characters. + +For the full Windows integration test, configure the normal server build from +[BUILD.md](../../BUILD.md) with these additional options (Python 3 is required): + +```sh +-DBUILD_CONSOLE_QUERY_TESTS=ON -DBUILD_CONSOLE_QUERY_FIXTURES=ON +``` + +Build the project, then run `ctest --test-dir build -C RelWithDebInfo --output-on-failure`. +Adjust the directory and configuration to match your build. Fixtures are written +under `Tests/ConsoleQuery/fixtures` in the build directory, outside the server +output. **Never deploy these test components.** + +`integration.py` starts isolated instances on ephemeral loopback UDP ports with +fresh random passwords, no user configuration and no game mode. It verifies +0/1/1000 players, actual player pool IDs, retained snapshot generations, malformed +requests, old `c`/`d` layouts, disabled Query/RCON, real Windows UTF-16 stdin, +outgoing game RPC bytes, Unicode passwords, and rejection without dispatch. +Temporary files and child processes are cleaned up on success and failure. + +This verifies the server's network bytes, not rendering in a real game client. diff --git a/Tests/ConsoleQuery/console-encoding-test.cpp b/Tests/ConsoleQuery/console-encoding-test.cpp new file mode 100644 index 000000000..7f8e4ab02 --- /dev/null +++ b/Tests/ConsoleQuery/console-encoding-test.cpp @@ -0,0 +1,34 @@ +#include +#include +#include + +void check(bool value) +{ + if (!value) + throw std::runtime_error("Console encoding check failed"); +} +int main() +{ + std::string result; + check(ConsoleEncoding::encode(L"say 中文", 936, result) && result == "say \xD6\xD0\xCE\xC4"); + check(ConsoleEncoding::encode(L"say 中英 ABC 123,。!", 936, result) && result == "say \xD6\xD0\xD3\xA2 ABC 123\xA3\xAC\xA1\xA3\xA3\xA1"); + check(ConsoleEncoding::encode(L"say 中文", 65001, result) && result == u8"say 中文"); + check(ConsoleEncoding::encode(L"say 中文", 950, result) && result == "say \xA4\xA4\xA4\xE5"); + check(ConsoleEncoding::toUtf8("\xD6\xD0\xCE\xC4", 936, result) && result == u8"中文"); + check(!ConsoleEncoding::encode(L"say \U0001F600", 936, result) && result.empty()); + check(!ConsoleEncoding::encode(std::wstring(1, wchar_t(0xD800)), 936, result)); + check(!ConsoleEncoding::encode(std::wstring(1, wchar_t(0xD800)), 65001, result)); + check(!ConsoleEncoding::encode(L"say Chinese", 999999, result)); + check(ConsoleEncoding::encode(L"", 936, result) && result.empty()); + check(ConsoleEncoding::encode(std::wstring(8192, L'中'), 936, result) && result.size() == 16384); + check(ConsoleEncoding::fromUtf8(u8"say 中文 ABC 123", 936, result) && result == "say \xD6\xD0\xCE\xC4 ABC 123"); + check(ConsoleEncoding::fromUtf8(u8"say 中文", 65001, result) && result == u8"say 中文"); + check(!ConsoleEncoding::fromUtf8("say \xD6\xD0\xCE\xC4", 936, result) && result.empty()); + check(!ConsoleEncoding::fromUtf8("\xC0\xAF", 936, result)); // Overlong UTF-8. + check(!ConsoleEncoding::fromUtf8("\xED\xA0\x80", 936, result)); // Surrogate. + check(!ConsoleEncoding::fromUtf8("\xF4\x90\x80\x80", 936, result)); // Above U+10FFFF. + check(!ConsoleEncoding::fromUtf8(u8"say \U0001F600", 936, result) && result.empty()); + check(!ConsoleEncoding::fromUtf8("ASCII", 999999, result)); + check(ConsoleEncoding::fromUtf8("", 936, result) && result.empty()); + std::cout << "PASS: GBK exact bytes, mixed Chinese/ASCII, UTF-8, Big5, reply encoding, unsupported characters, malformed UTF-16, invalid codepage, empty and long input\n"; +} diff --git a/Tests/ConsoleQuery/console-fixture.cpp b/Tests/ConsoleQuery/console-fixture.cpp new file mode 100644 index 000000000..ed83b9afc --- /dev/null +++ b/Tests/ConsoleQuery/console-fixture.cpp @@ -0,0 +1,67 @@ +#include +#include +#include +#include + +// Test-only: captures the actual outgoing game message RPC. Never deploy this DLL. +class ConsoleFixture final : public IComponent, public NetworkOutEventHandler, public ConsoleEventHandler +{ + ICore* core = nullptr; + IConsoleComponent* console = nullptr; + void printBytes(const char* prefix, StringView text) + { + const char hex[] = "0123456789ABCDEF"; + std::string bytes; + for (unsigned char c : text) + { + bytes += hex[c >> 4]; + bytes += hex[c & 15]; + } + core->printLn("[SC-CONSOLE-TEST] %s %s", prefix, bytes.c_str()); + } + +public: + StringView componentName() const override { return "SampCtrl TEST console fixture - DO NOT DEPLOY"; } + SemanticVersion componentVersion() const override { return { 1, 0, 0, 0 }; } + UID getUID() override { return 0x984631ddf0938155; } + void onLoad(ICore* value) override { core = value; } + void onInit(IComponentList* list) override { console = list->queryComponent(); } + void onReady() override + { + if (core->getConfig().getString("network.bind") != "127.0.0.1") + return; + for (auto network : core->getNetworks()) + network->getOutEventDispatcher().addEventHandler(this); + if (console) + console->getEventDispatcher().addEventHandler(this); + core->printLn("[SC-CONSOLE-TEST] READY"); + } + bool onSendRPC(IPlayer*, int id, NetworkBitStream& bs) override + { + if (id != 93) + return true; + uint32_t colour; + HybridString<256> message; + if (bs.readUINT32(colour) && bs.readDynStr32(message)) + printBytes("RPC", message); + return false; + } + bool onConsoleText(StringView command, StringView params, const ConsoleCommandSenderData&) override + { + if (command == "sc_test_command") + { + printBytes("CUSTOM", params); + return true; + } + if (command == "sc_test_script") + { + // Same API and raw byte path used by Pawn SendRconCommand. + console->send("say \xD6\xD0\xCE\xC4"); + return true; + } + return false; + } + void free() override { delete this; } + void reset() override { } +}; +COMPONENT_ENTRY_POINT() { return new ConsoleFixture(); } diff --git a/Tests/ConsoleQuery/fixture.cpp b/Tests/ConsoleQuery/fixture.cpp new file mode 100644 index 000000000..84e8c4688 --- /dev/null +++ b/Tests/ConsoleQuery/fixture.cpp @@ -0,0 +1,59 @@ +#include +#include +#include +#include + +// Test-only component: creates pool records without connecting any real clients. +class Fixture final : public IComponent, public CoreEventHandler +{ + ICore* core = nullptr; + bool ready = false; + +public: + StringView componentName() const override { return "SampCtrl TEST fixture - DO NOT DEPLOY"; } + SemanticVersion componentVersion() const override { return { 1, 0, 0, 0 }; } + UID getUID() override { return 0x985721ddb0938147; } + void onLoad(ICore* value) override + { + core = value; + core->getEventDispatcher().addEventHandler(this); + } + void free() override { delete this; } + void reset() override { } + void onTick(Microseconds, TimePoint) override + { + if (ready) + return; + ready = true; + auto& networks = core->getNetworks(); + if (networks.empty()) + return; + if (core->getConfig().getString("network.bind") != "127.0.0.1") + return; + const char* requested = std::getenv("SAMPCTRL_TEST_PLAYERS"); + if (!requested) + return; + int count = std::atoi(requested); + if (count < 0 || count > *core->getConfig().getInt("max_players")) + return; + for (int i = 0; i < count; ++i) + { + PeerNetworkData net {}; + net.network = *networks.begin(); + net.networkID.address.ipv6 = false; + net.networkID.address.v4 = 0x0100007f; + net.networkID.port = static_cast(30000 + i); + const std::string name = "SC_Test_" + std::to_string(i); + PeerRequestParams params { ClientVersion::ClientVersion_SAMP_037, "0.3.7", false, name, "1234567890ABCDEF1234567890ABCDEF12345678", true, false }; + auto result = core->getPlayers().requestPlayer(net, params); + if (!result.second) + { + core->printLn("[SC-QUERY-TEST] ERROR allocating %d", i); + return; + } + result.second->setScore(10000 + result.second->getID()); + } + core->printLn("[SC-QUERY-TEST] READY players=%d", count); + } +}; +COMPONENT_ENTRY_POINT() { return new Fixture(); } diff --git a/Tests/ConsoleQuery/integration.py b/Tests/ConsoleQuery/integration.py new file mode 100644 index 000000000..bcd4406a8 --- /dev/null +++ b/Tests/ConsoleQuery/integration.py @@ -0,0 +1,241 @@ +"""Exercise the built server over loopback and capture real outgoing message RPCs. + +Only the two test components are loaded, never a user's game mode or configuration. +""" +import argparse +import json +import os +from pathlib import Path +import queue +import secrets +import shutil +import socket +import struct +import subprocess +import tempfile +import threading +import time + + +def check(condition, message): + if not condition: + raise AssertionError(message) + + +class Server: + def __init__(self, args, fixture, count=1, query_enabled=True, codepage=None, remote=None): + self.args, self.fixture = args, fixture + self.count, self.query_enabled = count, query_enabled + self.codepage, self.remote = codepage, remote + self.password = "密码_" + secrets.token_hex(12) + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.bind(("127.0.0.1", 0)) + self.port = sock.getsockname()[1] + self.lines, self.pending = [], queue.Queue() + + def __enter__(self): + self.temp = tempfile.TemporaryDirectory(prefix="omp-console-query-") + root = Path(self.temp.name) + components = root / "components" + components.mkdir() + shutil.copy2(self.args.server, root / "omp-server.exe") + for path in (self.args.network, self.args.console, self.fixture): + shutil.copy2(path, components / Path(path).name) + config = { + "name": "Isolated console/query regression", "announce": False, + "enable_query": self.query_enabled, "max_players": max(1, self.count), + "network": {"bind": "127.0.0.1", "port": self.port}, + "rcon": {"enable": self.fixture == self.args.console_fixture, "password": self.password}, + "logging": {"enable": True, "file": "test.log"}, + } + if self.codepage is not None: + config["console"] = {"input_codepage": self.codepage} + if self.remote is not None: + config["rcon"]["utf8_command_codepage"] = self.remote + (root / "config.json").write_text(json.dumps(config), encoding="utf-8") + env = dict(os.environ, SAMPCTRL_TEST_PLAYERS=str(self.count)) + self.process = subprocess.Popen( + [str(root / "omp-server.exe")], cwd=root, env=env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + self.reader = threading.Thread(target=self.read_lines, daemon=True) + self.reader.start() + try: + marker = "[SC-QUERY-TEST] READY" if self.fixture == self.args.query_fixture else "[SC-CONSOLE-TEST] READY" + self.wait_line(marker) + except BaseException: + self.__exit__(None, None, None) + raise + return self + + def read_lines(self): + for raw in self.process.stdout: + line = raw.decode("utf-8", errors="replace").rstrip() + self.lines.append(line) + self.pending.put(line) + + def wait_line(self, marker, timeout=10): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + line = self.pending.get(timeout=min(.2, max(.01, deadline - time.monotonic()))) + except queue.Empty: + if self.process.poll() is not None: + break + continue + if marker in line: + return line + raise AssertionError(f"Missing {marker!r}:\n" + "\n".join(self.lines[-30:])) + + def local(self, command): + self.process.stdin.write((command + "\n").encode("utf-16-le")) + self.process.stdin.flush() + + def packet(self, opcode, payload=b"", expect=True): + header = b"SAMP" + socket.inet_aton("127.0.0.1") + struct.pack(" +#include +#include +#include + +void check(bool condition) +{ + if (!condition) + throw std::runtime_error("Packet check failed"); +} +uint32_t read(const std::vector& p, size_t offset, size_t bytes) +{ + uint32_t result = 0; + for (size_t i = 0; i < bytes; ++i) + result |= uint32_t(static_cast(p.at(offset + i))) << (i * 8); + return result; +} +int main() +{ + using namespace DetailedPlayersQuery; + auto empty = buildSnapshot(1, {}); + check(empty.pages.size() == 1 && empty.pages[0].size() == 24 && read(empty.pages[0], 22, 2) == 0); + std::vector players; + for (uint16_t id = 0; id < 1000; ++id) + players.push_back({ id, std::string(24, 'A'), -42, 0xffffffff }); + auto snapshot = buildSnapshot(0x12345678, players); + check(snapshot.pages.size() == 32); + size_t seen = 0; + for (size_t page = 0; page < snapshot.pages.size(); ++page) + { + const auto& packet = snapshot.pages[page]; + check(packet.size() <= 1144 && packet[10] == 'D' && read(packet, 12, 4) == 0x12345678); + check(read(packet, 16, 2) == 1000 && read(packet, 18, 2) == page && read(packet, 20, 2) == 32); + for (size_t row = 0; row < read(packet, 22, 2); ++row) + { + const auto offset = 24 + 35 * row; + check(read(packet, offset, 2) == seen++); + check(read(packet, offset + 27, 4) == uint32_t(-42)); + check(read(packet, offset + 31, 4) == 0xffffffff); + } + } + check(seen == 1000 && buildLegacy(players).empty()); + check(buildLegacy({ { 256, "High", 1, 2 } }).empty()); + auto legacy = buildLegacy({ { 255, "Low", -1, 42 } }); + check(legacy[10] == 'd' && read(legacy, 11, 2) == 1 && read(legacy, 13, 1) == 255); + check(buildLegacy({}).size() == 13); + auto sparse = buildSnapshot(2, { { 7, "Seven", -1, 0 }, { 999, "Last", 42, 0xffffffff } }); + check(read(sparse.pages[0], 24, 2) == 7); + check(read(sparse.pages[0], 40, 2) == 999); + for (size_t count : { 31, 32, 33, 100, 101 }) + { + std::vector subset(players.begin(), players.begin() + count); + auto pageBoundary = buildSnapshot(3, subset); + check(pageBoundary.pages.size() == (count + 31) / 32); + check(read(pageBoundary.pages.back(), 22, 2) == (count - 1) % 32 + 1); + check(buildLegacy(subset).empty() == (count > 100)); + } + players.push_back({ 1000, "Overflow", 0, 0 }); + check(buildSnapshot(1, players).pages.empty() && buildSnapshot(0, {}).pages.empty()); + std::cout << "PASS: empty, 1000 players, IDs 0-999, page size, signed score, unsigned ping and legacy overflow\n"; +}