From 05d18f56e416a0c3fb065ef9ff2210fa4ef4a993 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 16:28:40 -0500 Subject: [PATCH] feat(net): VERSION_CONFIRM carries the server's build identifier After serviceType the reply now sends major, minor, patch, a flags byte (build kind in bits 0-1, dirty in bit 2), the first 32 bits of the commit hash and a u16 length-prefixed build string, instead of the stale fixed ASCII "0.1.3". unknown stays "DLU3". The 1.10.64 client reads only netVersion and serviceType and never checks the length, so the extra bytes are ignored. The build string is optional on read. Co-Authored-By: Claude Opus 5.5 --- dNet/CommonPackets.cpp | 23 +++- dNet/CommonPackets.h | 22 ++- .../dNetTests/CommonAuthPacketsTests.cpp | 130 +++++++++++++++++- 3 files changed, 161 insertions(+), 14 deletions(-) diff --git a/dNet/CommonPackets.cpp b/dNet/CommonPackets.cpp index 10c3939f5..060e93413 100644 --- a/dNet/CommonPackets.cpp +++ b/dNet/CommonPackets.cpp @@ -52,15 +52,32 @@ namespace CommonPackets { bitStream.Write(netVersion); bitStream.Write(unknown); bitStream.Write(serviceType); - bitStream.Write(trailing); + bitStream.Write(versionMajor); + bitStream.Write(versionMinor); + bitStream.Write(versionPatch); + bitStream.Write(buildFlags); + for (int shift = 24; shift >= 0; shift -= 8) bitStream.Write(commitPrefix >> shift); + BitStreamUtils::WriteLengthPrefixed(bitStream, buildString); } bool ServerVersionConfirm::Deserialize(RakNet::BitStream& bitStream) { VALIDATE_READ(bitStream.Read(netVersion)); VALIDATE_READ(bitStream.Read(unknown)); VALIDATE_READ(bitStream.Read(serviceType)); - VALIDATE_READ(bitStream.Read(trailing)); - return true; + VALIDATE_READ(bitStream.Read(versionMajor)); + VALIDATE_READ(bitStream.Read(versionMinor)); + VALIDATE_READ(bitStream.Read(versionPatch)); + VALIDATE_READ(bitStream.Read(buildFlags)); + commitPrefix = 0; + for (int i = 0; i < 4; i++) { + uint8_t byte{}; + VALIDATE_READ(bitStream.Read(byte)); + commitPrefix = (commitPrefix << 8) | byte; + } + // Older DLU servers stop after the 12 fixed bytes. + buildString.clear(); + if (bitStream.GetNumberOfUnreadBits() == 0) return true; + return BitStreamUtils::ReadLengthPrefixed(bitStream, buildString, MAX_BUILD_STRING_LENGTH); } void DisconnectNotify::Serialize(RakNet::BitStream& bitStream) const { diff --git a/dNet/CommonPackets.h b/dNet/CommonPackets.h index b843faa93..9e056a67c 100644 --- a/dNet/CommonPackets.h +++ b/dNet/CommonPackets.h @@ -2,10 +2,12 @@ #define COMMONPACKETS_H #include "BitStreamUtils.h" +#include "BuildInfo.h" #include "MessageType/Server.h" #include "RakNetTypes.h" #include +#include enum class eServerDisconnectIdentifiers : uint32_t; @@ -32,17 +34,27 @@ namespace CommonPackets { }; // Server -> client. The answer to ClientVersionConfirm. - // The client (ServerHandShakePacket, 1.10.64) only reads netVersion, unknown and serviceType; the trailing - // 8 bytes are what DLU has always sent after them. + // The client (1.10.64, PacketHandler_MSG_SERVER_VERSION_CONFIRM) reads only netVersion and serviceType; it never + // reads unknown and ignores anything after serviceType (no length check). DLU uses those bytes to say which build + // the server is: + // u32 netVersion | u32 unknown ("DLU3") | u32 serviceType + // | u8 major | u8 minor | u8 patch | u8 flags (bits 0-1 BuildInfo::eBuildKind, bit 2 dirty) + // | 4 bytes: first 32 bits of the commit hash, in hash order (a hex dump shows the hash) + // | u16 length + build string, e.g. "3.0.0-experimental+g1a2b3c4d-dirty" (optional on read) struct ServerVersionConfirm : public LUBitStream { static constexpr uint32_t DEFAULT_NET_VERSION = 171022; - static constexpr uint32_t UNKNOWN_VALUE = 861228100; - static constexpr uint64_t TRAILING_VALUE = 219818307120; + static constexpr uint32_t UNKNOWN_VALUE = 861228100; // "DLU3" + static constexpr uint32_t MAX_BUILD_STRING_LENGTH = 1024; uint32_t netVersion = DEFAULT_NET_VERSION; uint32_t unknown = UNKNOWN_VALUE; uint32_t serviceType{}; // The server's ServiceType, written as 4 bytes - uint64_t trailing = TRAILING_VALUE; + uint8_t versionMajor = BuildInfo::versionMajor; + uint8_t versionMinor = BuildInfo::versionMinor; + uint8_t versionPatch = BuildInfo::versionPatch; + uint8_t buildFlags = BuildInfo::Flags(); + uint32_t commitPrefix = BuildInfo::CommitPrefix(); + std::string buildString{ BuildInfo::buildString }; ServerVersionConfirm() : LUBitStream(ServiceType::COMMON, MessageType::Server::VERSION_CONFIRM) {} void Serialize(RakNet::BitStream& bitStream) const override; diff --git a/tests/dGameTests/dNetTests/CommonAuthPacketsTests.cpp b/tests/dGameTests/dNetTests/CommonAuthPacketsTests.cpp index 3f34fe279..e52d9528b 100644 --- a/tests/dGameTests/dNetTests/CommonAuthPacketsTests.cpp +++ b/tests/dGameTests/dNetTests/CommonAuthPacketsTests.cpp @@ -1,4 +1,5 @@ #include "AuthPackets.h" +#include "BuildInfo.h" #include "ClientPackets.h" #include "CommonPackets.h" #include "GameDependencies.h" @@ -10,6 +11,7 @@ #include "eServerDisconnectIdentifiers.h" #include "magic_enum.hpp" +#include #include #include #include @@ -71,6 +73,32 @@ namespace { FAIL() << "the clock kept changing seconds while comparing"; } + // VERSION_CONFIRM replies: the header, netVersion, unknown and serviceType (the 20 bytes the client reads up to) + // must match the legacy reply; the build identifier after them replaced the legacy's 8 fixed bytes. + constexpr size_t VERSION_CONFIRM_LEGACY_PREFIX = 20; + + void ExpectSameVersionConfirmPrefix(const std::function& legacy, const std::function& converted) { + const auto expected = Capture(legacy); + const auto actual = Capture(converted); + ASSERT_FALSE(expected.empty()); + ASSERT_EQ(expected.size(), actual.size()); + RakNet::BitStream thisBuild; + CommonPackets::ServerVersionConfirm{}.WritePacket(thisBuild); + const auto build = FromBitStream(thisBuild); + const std::vector expectedTail(build.bytes.begin() + VERSION_CONFIRM_LEGACY_PREFIX, build.bytes.end()); + for (size_t i = 0; i < expected.size(); i++) { + ASSERT_GE(expected[i].bytes.size(), VERSION_CONFIRM_LEGACY_PREFIX); + ASSERT_GE(actual[i].bytes.size(), VERSION_CONFIRM_LEGACY_PREFIX); + const std::vector expectedPrefix(expected[i].bytes.begin(), expected[i].bytes.begin() + VERSION_CONFIRM_LEGACY_PREFIX); + const std::vector actualPrefix(actual[i].bytes.begin(), actual[i].bytes.begin() + VERSION_CONFIRM_LEGACY_PREFIX); + EXPECT_EQ(expectedPrefix, actualPrefix); + const std::vector actualTail(actual[i].bytes.begin() + VERSION_CONFIRM_LEGACY_PREFIX, actual[i].bytes.end()); + EXPECT_EQ(expectedTail, actualTail); + EXPECT_EQ(expected[i].sysAddr, actual[i].sysAddr); + EXPECT_EQ(expected[i].broadcast, actual[i].broadcast); + } + } + template T RoundTrip(const T& packet) { RakNet::BitStream first; @@ -122,7 +150,7 @@ TEST_F(CommonAuthPacketsTests, VersionConfirmHandshakeMatchesLegacy) { request.WritePacket(bytes); const auto sysAddr = TestAddress(); - ExpectSameOutput( + ExpectSameVersionConfirmPrefix( [&] { auto packet = MakePacket(bytes, sysAddr); LegacyAuthPackets::HandleHandshake(Game::server, &packet); }, [&] { Dispatch(bytes, sysAddr, CommonPackets::Handle); }); @@ -136,10 +164,10 @@ TEST_F(CommonAuthPacketsTests, VersionConfirmHandshakeMatchesLegacy) { } } -TEST_F(CommonAuthPacketsTests, ServerVersionConfirmMatchesLegacy) { +TEST_F(CommonAuthPacketsTests, ServerVersionConfirmKeepsLegacyPrefix) { for (const auto serviceType : { ServiceType::AUTH, ServiceType::WORLD, ServiceType::CHAT }) { const auto sysAddr = TestAddress(); - ExpectSameOutput( + ExpectSameVersionConfirmPrefix( [&] { LegacyAuthPackets::SendHandshake(Game::server, sysAddr, "ignored", 1, serviceType); }, [&] { CommonPackets::ServerVersionConfirm response; @@ -152,11 +180,21 @@ TEST_F(CommonAuthPacketsTests, ServerVersionConfirmMatchesLegacy) { TEST_F(CommonAuthPacketsTests, VersionConfirmGoldenBytes) { CommonPackets::ServerVersionConfirm response; response.serviceType = static_cast(ServiceType::WORLD); + response.versionMajor = 3; + response.versionMinor = 0; + response.versionPatch = 1; + response.buildFlags = static_cast(BuildInfo::eBuildKind::CI) | BuildInfo::DIRTY_FLAG; + response.commitPrefix = 0x1a2b3c4d; + response.buildString = "3.0.1-main+g1a2b3c4d"; RakNet::BitStream bytes; response.WritePacket(bytes); - // 0x53 | COMMON u16 | VERSION_CONFIRM u32 | pad | 171022 | 861228100 | WORLD u32 | 219818307120 u64 - EXPECT_PACKET_EQ(FromHex("53 00 00 00 00 00 00 00 0e 9c 02 00 44 4c 55 33 04 00 00 00 30 2e 31 2e 33 00 00 00"), FromBitStream(bytes)); - RoundTrip(response); + // 0x53 | COMMON u16 | VERSION_CONFIRM u32 | pad | 171022 | 861228100 ("DLU3") | WORLD u32 + // | 3 0 1 | flags (CI | dirty) | commit 1a2b3c4d | u16 20 | "3.0.1-main+g1a2b3c4d" + EXPECT_PACKET_EQ(FromHex("53 00 00 00 00 00 00 00 0e 9c 02 00 44 4c 55 33 04 00 00 00 03 00 01 06 1a 2b 3c 4d 14 00" + " 33 2e 30 2e 31 2d 6d 61 69 6e 2b 67 31 61 32 62 33 63 34 64"), FromBitStream(bytes)); + const auto copy = RoundTrip(response); + EXPECT_EQ(copy.commitPrefix, 0x1a2b3c4du); + EXPECT_EQ(copy.buildString, "3.0.1-main+g1a2b3c4d"); CommonPackets::ClientVersionConfirm request; request.netVersion = 171022; @@ -172,6 +210,86 @@ TEST_F(CommonAuthPacketsTests, VersionConfirmGoldenBytes) { ExpectTruncatedFails(request); } +TEST_F(CommonAuthPacketsTests, ServerVersionConfirmRoundTrip) { + CommonPackets::ServerVersionConfirm response; + response.netVersion = 0xFFFFFFFF; + response.serviceType = static_cast(ServiceType::CHAT); + response.versionMajor = 0xFF; + response.versionMinor = 0x7F; + response.versionPatch = 0; + response.buildFlags = static_cast(BuildInfo::eBuildKind::RELEASE); + response.commitPrefix = 0xDEADBEEF; + for (const auto& buildString : g_Strings) { + response.buildString = buildString; + const auto copy = RoundTrip(response); + EXPECT_EQ(copy.netVersion, response.netVersion); + EXPECT_EQ(copy.unknown, CommonPackets::ServerVersionConfirm::UNKNOWN_VALUE); + EXPECT_EQ(copy.serviceType, response.serviceType); + EXPECT_EQ(copy.versionMajor, response.versionMajor); + EXPECT_EQ(copy.versionMinor, response.versionMinor); + EXPECT_EQ(copy.versionPatch, response.versionPatch); + EXPECT_EQ(copy.buildFlags, response.buildFlags); + EXPECT_EQ(copy.commitPrefix, response.commitPrefix); + EXPECT_EQ(copy.buildString, buildString); + } + + // The defaults describe this build. + const CommonPackets::ServerVersionConfirm current; + EXPECT_EQ(current.unknown, 861228100u); + EXPECT_EQ(current.versionMajor, BuildInfo::versionMajor); + EXPECT_EQ(current.versionMinor, BuildInfo::versionMinor); + EXPECT_EQ(current.versionPatch, BuildInfo::versionPatch); + EXPECT_EQ(current.buildFlags, BuildInfo::Flags()); + EXPECT_EQ(current.commitPrefix, BuildInfo::CommitPrefix()); + EXPECT_EQ(current.buildString, BuildInfo::buildString); + EXPECT_EQ(RoundTrip(current).buildString, BuildInfo::buildString); +} + +TEST_F(CommonAuthPacketsTests, ServerVersionConfirmReadsWithoutBuildString) { + // A reply that stops after the commit bytes still reads; the build string is optional on read. + const auto fixed = FromHex("53 00 00 00 00 00 00 00 0e 9c 02 00 44 4c 55 33 04 00 00 00 03 00 01 06 1a 2b 3c 4d"); + RakNet::BitStream bytes(const_cast(fixed.bytes.data()), fixed.bytes.size(), true); + CommonPackets::ServerVersionConfirm copy; + copy.buildString = "stale"; + ASSERT_TRUE(copy.ReadHeader(bytes)); + ASSERT_TRUE(copy.Deserialize(bytes)); + EXPECT_EQ(copy.netVersion, 171022u); + EXPECT_EQ(copy.serviceType, static_cast(ServiceType::WORLD)); + EXPECT_EQ(copy.versionMajor, 3); + EXPECT_EQ(copy.versionPatch, 1); + EXPECT_EQ(copy.buildFlags, 6); + EXPECT_EQ(copy.commitPrefix, 0x1a2b3c4du); + EXPECT_TRUE(copy.buildString.empty()); + + // Cut inside the fixed bytes or inside the string: fails. + CommonPackets::ServerVersionConfirm full; + full.buildString = "3.0.0"; + RakNet::BitStream fullBytes; + full.WritePacket(fullBytes); + for (const uint32_t cut : { 8u, 19u, 27u, 29u, 32u }) { + RakNet::BitStream truncated(fullBytes.GetData(), cut, true); + CommonPackets::ServerVersionConfirm partial; + ASSERT_TRUE(partial.ReadHeader(truncated)); + EXPECT_FALSE(partial.Deserialize(truncated)) << "cut at " << cut; + } +} + +TEST(BuildInfoTests, FlagsAndCommitPrefix) { + EXPECT_EQ(BuildInfo::Flags() & BuildInfo::BUILD_KIND_MASK, static_cast(BuildInfo::buildKind)); + EXPECT_EQ((BuildInfo::Flags() & BuildInfo::DIRTY_FLAG) != 0, BuildInfo::dirty); + EXPECT_EQ(BuildInfo::Flags() & ~(BuildInfo::BUILD_KIND_MASK | BuildInfo::DIRTY_FLAG), 0); + if (BuildInfo::commit.size() >= 8) { + char hex[9]; + std::snprintf(hex, sizeof(hex), "%08x", BuildInfo::CommitPrefix()); + EXPECT_EQ(std::string(hex), std::string(BuildInfo::commit.substr(0, 8))); + EXPECT_NE(BuildInfo::buildString.find("+g" + std::string(hex)), std::string_view::npos); + } else { + EXPECT_EQ(BuildInfo::CommitPrefix(), 0u); + } + const auto version = std::to_string(BuildInfo::versionMajor) + "." + std::to_string(BuildInfo::versionMinor) + "." + std::to_string(BuildInfo::versionPatch); + EXPECT_TRUE(BuildInfo::buildString.starts_with(version)); +} + TEST_F(CommonAuthPacketsTests, DisconnectNotifyMatchesLegacy) { for (const auto id : magic_enum::enum_values()) { RakNet::BitStream expected;