diff --git a/dGame/dGameMessages/GameMessageDecoder.cpp b/dGame/dGameMessages/GameMessageDecoder.cpp index 1b2ee3b96..1f8e2f3e0 100644 --- a/dGame/dGameMessages/GameMessageDecoder.cpp +++ b/dGame/dGameMessages/GameMessageDecoder.cpp @@ -6,6 +6,7 @@ #include #include "GameMessages.h" +#include "GameMessageHandler.h" #include "ActivityMessages.h" #include "InventoryMessages.h" #include "ObjectMessages.h" @@ -94,4 +95,24 @@ namespace GameMessageDecoder { if (it == Decoders().end()) return std::nullopt; return it->second(payload); } + + std::optional RoundTripReceived(MessageType::Game messageId, RakNet::BitStream& payload) { + auto message = GameMessageHandler::CreateReceived(messageId); + if (!message) return std::nullopt; + const auto start = payload.GetReadOffset(); + if (!message->Deserialize(payload)) return std::nullopt; + const auto read = payload.GetReadOffset() - start; + // A whole byte or more left over: the struct stopped short of fields the message has + if (payload.GetNumberOfUnreadBits() >= 8) return false; + RakNet::BitStream written; + message->Serialize(written); + if (written.GetNumberOfBitsUsed() != read) return false; + // The bits read, compared with the bits written + payload.SetReadOffset(start); + for (uint32_t bit = 0; bit < read; bit++) { + bool original{}, again{}; + if (!payload.Read(original) || !written.Read(again) || original != again) return false; + } + return true; + } } diff --git a/dGame/dGameMessages/GameMessageDecoder.h b/dGame/dGameMessages/GameMessageDecoder.h index 4191065f3..fb96a5423 100644 --- a/dGame/dGameMessages/GameMessageDecoder.h +++ b/dGame/dGameMessages/GameMessageDecoder.h @@ -19,6 +19,13 @@ namespace GameMessageDecoder { // The message's fields, read from `payload` (the bits after the object ID and message ID), or nullopt when it has // no typed struct or doesn't read cleanly std::optional Decode(MessageType::Game messageId, bool toServer, RakNet::BitStream& payload); + + /** + * Reads a message a client sent with the struct the server reads it with, and writes it again. nullopt when the + * server has no typed struct for it or it doesn't read; otherwise whether it read the whole message (padding + * after the last field aside) and the same bits came back. The capture fixture tests run it on every recorded client message. + */ + std::optional RoundTripReceived(MessageType::Game messageId, RakNet::BitStream& payload); } #endif //!__GAMEMESSAGEDECODER__H__ diff --git a/dGame/dGameMessages/GameMessageHandler.cpp b/dGame/dGameMessages/GameMessageHandler.cpp index a77e8dc42..0f61fe4c9 100644 --- a/dGame/dGameMessages/GameMessageHandler.cpp +++ b/dGame/dGameMessages/GameMessageHandler.cpp @@ -269,3 +269,8 @@ void GameMessageHandler::HandleMessage(RakNet::BitStream& inStream, const System LOG_DEBUG("Received Unknown GM with ID: %4i, %s", messageID, StringifiedEnum::ToString(messageID).data()); } + +std::unique_ptr GameMessageHandler::CreateReceived(MessageType::Game messageID) { + const auto handler = g_MessageHandlers.find(messageID); + return handler == g_MessageHandlers.end() ? nullptr : handler->second(); +} diff --git a/dGame/dGameMessages/GameMessageHandler.h b/dGame/dGameMessages/GameMessageHandler.h index c8026bb13..89947a259 100644 --- a/dGame/dGameMessages/GameMessageHandler.h +++ b/dGame/dGameMessages/GameMessageHandler.h @@ -23,6 +23,10 @@ namespace GameMessageHandler { void HandleMessage(RakNet::BitStream& inStream, const SystemAddress& sysAddr, LWOOBJID objectID, MessageType::Game messageID); + + // A new, empty typed struct for a message a client sends, or nullptr when the server reads it inline (capture + // fixtures read recorded messages with it) + std::unique_ptr CreateReceived(MessageType::Game messageID); }; #endif // GAMEMESSAGEHANDLER_H diff --git a/tests/dGameTests/dNetTests/PacketCaptureTests.cpp b/tests/dGameTests/dNetTests/PacketCaptureTests.cpp index 0b3eff186..b82358625 100644 --- a/tests/dGameTests/dNetTests/PacketCaptureTests.cpp +++ b/tests/dGameTests/dNetTests/PacketCaptureTests.cpp @@ -12,6 +12,9 @@ #include "RakNetTypes.h" #include "ServiceType.h" #include "sqlite3.h" +#include "GameMessageDecoder.h" +#include "ObjectMessages.h" +#include "MessageType/World.h" #include #include @@ -526,30 +529,153 @@ TEST_F(PacketCaptureTest, OverheadOfCapturingEverything) { EXPECT_LT(static_cast(tapNs) / PACKETS, 20000.0); } +namespace { + struct FixtureResult { + size_t packets{}; // records read + size_t checked{}; // packets with a struct, read and written again + size_t gameMessages{}; // of them, client game messages read with the server's own struct + std::vector failures; + }; + + // Game message fields come from the server's own structs (the tests link the game) + void UseGameMessageDecoder() { + PacketDecoder::SetGameMessageDecoder([](MessageType::Game id, bool toServer, RakNet::BitStream& payload) { + return GameMessageDecoder::Decode(id, toServer, payload); + }); + } + + /** + * What every fixture must pass: each packet the server has a struct for reads and writes back to the same bytes, + * client game messages included (with the struct the server reads them with), and none fails to decode. + */ + FixtureResult CheckFixture(const CaptureBundle::Bundle& bundle) { + FixtureResult result; + for (const auto& record : bundle.records) { + if (record.header.flags & (PacketRecordFlags::GAP | PacketRecordFlags::CUT)) continue; + result.packets++; + const auto decoded = PacketDecoder::Decode(record.bytes, CaptureTools::FromClient(record.header)); + const auto where = "record " + std::to_string(record.header.seq) + " " + decoded.name; + if (decoded.failed) result.failures.push_back(where + " doesn't read with its struct"); + if (const auto same = PacketDecoder::RoundTrip(record.bytes)) { + result.checked++; + if (!*same) result.failures.push_back(where + " doesn't write back to the same bytes"); + continue; + } + // A client game message: the LU header, the object and the message ID, then its fields + if (decoded.gameMessageId < 0 || decoded.serviceId != static_cast(ServiceType::WORLD)) continue; + constexpr size_t FIELDS = 8 + sizeof(LWOOBJID) + sizeof(uint16_t); + if (record.bytes.size() < FIELDS) continue; + RakNet::BitStream payload(reinterpret_cast(const_cast(record.bytes.data())) + FIELDS, + static_cast(record.bytes.size() - FIELDS), false); + const auto same = GameMessageDecoder::RoundTripReceived(static_cast(decoded.gameMessageId), payload); + if (!same) continue; + result.checked++; + result.gameMessages++; + if (!*same) result.failures.push_back(where + " doesn't write back to the same bits"); + } + return result; + } + + // A client game message as it arrives: WORLD GAME_MSG, the object, the message ID, then Serialize + std::string ClientGameMessage(LWOOBJID object, const GameMessages::NetGameMsg& message) { + RakNet::BitStream stream; + LUBitStream(ServiceType::WORLD, MessageType::World::GAME_MSG).WriteHeader(stream); + stream.Write(object); + stream.Write(message.msgId); + message.Serialize(stream); + return std::string(reinterpret_cast(stream.GetData()), stream.GetNumberOfBytesUsed()); + } + + CaptureBundle::Record Recorded(uint32_t seq, eCaptureSource source, bool sent, std::string bytes) { + CaptureBundle::Record record; + record.header.seq = seq; + record.header.timeUs = 1000000 + seq * 1000; + record.header.source = static_cast(source); + record.header.direction = static_cast(sent ? ePacketDirection::SENT : ePacketDirection::RECEIVED); + record.header.bits = static_cast(bytes.size() * 8); + record.bytes = std::move(bytes); + return record; + } +} + +/** + * A synthetic fixture, made here from the server's structs (committed fixtures are never recorded ones): it goes + * through the same export steps as a real capture (portable, anonymised, saved and read again) and passes the same + * checks as local fixtures. + */ +TEST(CaptureFixtureTests, SyntheticFixturePassesTheFixtureChecks) { + UseGameMessageDecoder(); + constexpr LWOOBJID CHARACTER = 1152921510436607007; + CaptureBundle::Bundle bundle; + bundle.meta = { {"format", CaptureBundle::FORMAT_VERSION}, {"origin", "dlu-capture"}, {"target", "character"} }; + + AuthPackets::LoginRequest login; + login.username = LUWString("", 33); + login.password = LUWString("", 41); + bundle.records.push_back(Recorded(1, eCaptureSource::AUTH, false, Bytes(login))); + + WorldPackets::PositionUpdate position; + position.update.position = NiPoint3(10.0f, 20.0f, 30.0f); + bundle.records.push_back(Recorded(2, eCaptureSource::WORLD, false, Bytes(position))); + + GameMessages::RequestUse use; + use.object = 70000; + use.secondary = true; + auto used = Recorded(3, eCaptureSource::WORLD, false, ClientGameMessage(CHARACTER, use)); + used.header.characterId = CHARACTER; + bundle.records.push_back(used); + + CaptureTools::MakePortable(bundle); + CaptureTools::Anonymise(bundle); + const auto path = std::filesystem::temp_directory_path() / "dlu_synthetic_fixture.bundle"; + ASSERT_TRUE(CaptureBundle::Save(path, bundle)); + CaptureBundle::Bundle loaded; + std::string error; + ASSERT_TRUE(CaptureBundle::Load(path, loaded, error)) << error; + std::filesystem::remove(path); + ASSERT_EQ(loaded.records.size(), 3u); + + const auto result = CheckFixture(loaded); + EXPECT_TRUE(result.failures.empty()) << result.failures.front(); + EXPECT_EQ(result.checked, 3u); + EXPECT_EQ(result.gameMessages, 1u); + // The character's ID is a placeholder once the bundle is portable + EXPECT_EQ(loaded.records[2].bytes.find(std::string(reinterpret_cast(&CHARACTER), sizeof(CHARACTER))), std::string::npos); +} + +// A message with more than its struct reads (a field the struct leaves out) fails the check +TEST(CaptureFixtureTests, FixtureCheckCatchesFieldsTheStructLeavesOut) { + GameMessages::RequestUse use; + use.object = 70000; + CaptureBundle::Bundle bundle; + bundle.records.push_back(Recorded(1, eCaptureSource::WORLD, false, ClientGameMessage(1152921510436607007, use))); + EXPECT_TRUE(CheckFixture(bundle).failures.empty()); + bundle.records[0].bytes += std::string("\x12\x34", 2); + EXPECT_EQ(CheckFixture(bundle).failures.size(), 1u); +} + /** * Local fixtures (docs/CaptureReplay.md): bundles exported with anonymise=1 and put in tests/fixtures-local (never - * committed). Every packet the server has a struct for must read and write back to the same bytes. + * committed). Every packet the server has a struct for must read and write back to the same bytes, client game + * messages included. */ TEST(CaptureFixtureTests, RecordedPacketsRoundTrip) { + UseGameMessageDecoder(); const std::filesystem::path folder = std::filesystem::path(DLU_SOURCE_DIR) / "tests" / "fixtures-local"; std::error_code ec; if (!std::filesystem::is_directory(folder, ec)) GTEST_SKIP() << "No local fixtures in " << folder.string(); - size_t checked = 0, bundles = 0; + size_t checked = 0, gameMessages = 0, bundles = 0; for (const auto& entry : std::filesystem::directory_iterator(folder)) { if (entry.path().extension() != ".bundle") continue; CaptureBundle::Bundle bundle; std::string error; ASSERT_TRUE(CaptureBundle::Load(entry.path(), bundle, error)) << entry.path() << ": " << error; bundles++; - for (const auto& record : bundle.records) { - if (record.header.flags & (PacketRecordFlags::GAP | PacketRecordFlags::CUT)) continue; - const auto same = PacketDecoder::RoundTrip(record.bytes); - if (!same) continue; - EXPECT_TRUE(*same) << entry.path().filename() << " record " << record.header.seq << " " - << PacketDecoder::Decode(record.bytes, CaptureTools::FromClient(record.header)).name << " doesn't write back to the same bytes"; - checked++; - } + const auto result = CheckFixture(bundle); + for (const auto& failure : result.failures) ADD_FAILURE() << entry.path().filename() << " " << failure; + checked += result.checked; + gameMessages += result.gameMessages; } if (bundles == 0) GTEST_SKIP() << "No .bundle files in " << folder.string(); - std::printf("[fixtures] %zu bundle(s), %zu packets checked\n", bundles, checked); + std::printf("[fixtures] %zu bundle(s), %zu packets checked (%zu client game messages)\n", bundles, checked, gameMessages); }