mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
test(capture): fixtures check client game messages, with a synthetic fixture
The fixture check now also reads every recorded client game message with the struct the server reads it with (GameMessageHandler::CreateReceived) and writes it again: it must read the whole message and give back the same bits. Game message fields are decoded with the server's structs in the fixture tests. A synthetic fixture, built in the test from the server's own structs (login, position update, a client game message), goes through the export steps (portable, anonymised, saved, read again) and passes the same checks; recorded fixtures stay in tests/fixtures-local and are never committed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,9 @@
|
||||
#include "RakNetTypes.h"
|
||||
#include "ServiceType.h"
|
||||
#include "sqlite3.h"
|
||||
#include "GameMessageDecoder.h"
|
||||
#include "ObjectMessages.h"
|
||||
#include "MessageType/World.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
@@ -526,30 +529,153 @@ TEST_F(PacketCaptureTest, OverheadOfCapturingEverything) {
|
||||
EXPECT_LT(static_cast<double>(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<std::string> 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<uint16_t>(ServiceType::WORLD)) continue;
|
||||
constexpr size_t FIELDS = 8 + sizeof(LWOOBJID) + sizeof(uint16_t);
|
||||
if (record.bytes.size() < FIELDS) continue;
|
||||
RakNet::BitStream payload(reinterpret_cast<unsigned char*>(const_cast<char*>(record.bytes.data())) + FIELDS,
|
||||
static_cast<unsigned int>(record.bytes.size() - FIELDS), false);
|
||||
const auto same = GameMessageDecoder::RoundTripReceived(static_cast<MessageType::Game>(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<const char*>(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<uint8_t>(source);
|
||||
record.header.direction = static_cast<uint8_t>(sent ? ePacketDirection::SENT : ePacketDirection::RECEIVED);
|
||||
record.header.bits = static_cast<uint32_t>(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<const char*>(&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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user