#include "PacketCapture.h" #include "PacketDecoder.h" #include "CaptureBundle.h" #include "CaptureTools.h" #include "AuthPackets.h" #include "ChatPackets.h" #include "ClientPackets.h" #include "MasterPackets.h" #include "WorldPackets.h" #include "master/MessageCapture.h" #include "MessageIdentifiers.h" #include "RakNetTypes.h" #include "ServiceType.h" #include "sqlite3.h" #include "GameMessageDecoder.h" #include "ObjectMessages.h" #include "MessageType/World.h" #include #include #include #include #include namespace { std::string Bytes(const LUBitStream& packet) { RakNet::BitStream stream; packet.WritePacket(stream); return std::string(reinterpret_cast(stream.GetData()), stream.GetNumberOfBytesUsed()); } SystemAddress Address(uint32_t ip, uint16_t port) { SystemAddress address; address.binaryAddress = ip; address.port = port; return address; } // UTF-16 LE, as LUWString writes text std::string Wide(const std::string& text) { std::string out; for (const char c : text) { out += c; out += '\0'; } return out; } struct Captured { std::vector records; std::vector batches; std::string all; // every record's bytes, to look for secrets }; class PacketCaptureTest : public ::testing::Test { protected: Captured captured; bool connected = true; void Start(ServiceType server) { PacketCapture::Reset(); PacketCapture::Attach(server, nullptr, nullptr, 1100, 3); PacketCapture::SetSettings({ .flushIntervalMs = 1000, .flushBytes = 1, .maxBufferBytes = 1024 * 1024 }); PacketCapture::SetSink([this](MessageCaptureData& data) { if (!connected) return false; captured.batches.push_back(data); PacketRecord::ForEach(data.packets, [this](const PacketRecordHeader& header, std::string_view bytes) { captured.records.push_back({ header, std::string(bytes) }); captured.all += bytes; }); return true; }); } void TearDown() override { PacketCapture::Reset(); } void Arm(uint8_t slot, uint32_t captureId, eCaptureTarget target, uint32_t accountId = 0, const std::string& name = "", std::vector characters = {}) { MessageCaptureControl control; control.action = eMessageCaptureControl::ARM; control.captureId = captureId; control.slot = slot; control.seconds = 60; control.target = target; control.accountId = accountId; control.accountName = name; control.characterIds = std::move(characters); PacketCapture::Control(control); // Arming reads the settings; keep one record per batch for the tests PacketCapture::SetSettings({ .flushIntervalMs = 1000, .flushBytes = 1, .maxBufferBytes = 1024 * 1024 }); } void Receive(const SystemAddress& from, const std::string& bytes) { Packet packet{}; packet.systemAddress = from; packet.length = static_cast(bytes.size()); packet.bitSize = static_cast(bytes.size() * 8); packet.data = reinterpret_cast(const_cast(bytes.data())); PacketCapture::OnReceive(&packet); PacketCapture::OnReceive(nullptr); } void Send(const SystemAddress& to, const std::string& bytes, bool broadcast = false) { PacketCapture::RecordForTest(to, true, broadcast, reinterpret_cast(bytes.data()), static_cast(bytes.size() * 8)); } }; } TEST(MessageCaptureTests, PacketCaptureControlRoundTrips) { MessageCaptureControl control; control.action = eMessageCaptureControl::ARM; control.captureId = 12; control.slot = 5; control.seconds = 120; control.target = eCaptureTarget::ACCOUNT; control.accountId = 44; control.accountName = "Alice"; control.characterIds = { 1152921510436607007LL, 1152921510436607008LL }; RakNet::BitStream stream; control.Serialize(stream); MessageCaptureControl read; ASSERT_TRUE(read.Deserialize(stream)); EXPECT_EQ(read.action, eMessageCaptureControl::ARM); EXPECT_EQ(read.slot, 5); EXPECT_EQ(read.target, eCaptureTarget::ACCOUNT); EXPECT_EQ(read.accountId, 44u); EXPECT_EQ(read.accountName, "Alice"); EXPECT_EQ(read.characterIds, control.characterIds); // A slot past the last is refused RakNet::BitStream bad; control.slot = MessageCapture::MAX_SLOTS; control.Serialize(bad); EXPECT_FALSE(read.Deserialize(bad)); } TEST(MessageCaptureTests, PacketBatchRoundTrips) { MessageCaptureData data; data.status = eMessageCaptureStatus::PACKETS; data.source = static_cast(eCaptureSource::CHAT); data.slots[2] = 99; data.packetCount = 1; data.packetsDropped = 7; PacketRecordHeader header; header.length = 3; PacketRecord::Append(data.packets, header, "abc"); RakNet::BitStream stream; data.Serialize(stream); MessageCaptureData read; ASSERT_TRUE(read.Deserialize(stream)); EXPECT_EQ(read.status, eMessageCaptureStatus::PACKETS); EXPECT_EQ(read.source, data.source); EXPECT_EQ(read.slots[2], 99u); EXPECT_EQ(read.packetsDropped, 7u); EXPECT_EQ(read.packets, data.packets); } TEST_F(PacketCaptureTest, NothingRecordedWhenNotArmed) { Start(ServiceType::WORLD); EXPECT_FALSE(PacketCapture::g_Armed); WorldPackets::CharacterListRequest request; Receive(Address(1, 1000), Bytes(request)); PacketCapture::Update(); EXPECT_TRUE(captured.records.empty()); } TEST_F(PacketCaptureTest, AccountCaptureKeepsWhatCameBeforeTheLoginAndRedactsSecrets) { Start(ServiceType::WORLD); Arm(0, 10, eCaptureTarget::ACCOUNT, 7, "Alice"); const auto alice = Address(0x0100007f, 50000), bob = Address(0x0200007f, 50001); WorldPackets::Validation validation; validation.username = LUWString(std::string("alice")); validation.sessionKey = LUWString(std::string("SECRETSESSIONKEY")); Receive(alice, Bytes(validation)); Receive(bob, Bytes(validation)); PacketCapture::Update(); EXPECT_TRUE(captured.records.empty()) << "nothing is known to be the account's yet"; PacketCapture::Bind(alice, 7, "alice"); ClientPackets::LoadStaticZone zone; zone.mapID = 1100; Send(alice, Bytes(zone)); Send(bob, Bytes(zone)); WorldPackets::CharacterListRequest list; Receive(bob, Bytes(list)); PacketCapture::Update(); ASSERT_EQ(captured.records.size(), 2u); EXPECT_EQ(PacketDecoder::Decode(captured.records[0].bytes, true).name, "VALIDATION"); EXPECT_EQ(PacketDecoder::Decode(captured.records[1].bytes, false).name, "LOAD_STATIC_ZONE"); for (const auto& record : captured.records) { EXPECT_EQ(record.header.mask, 1); EXPECT_EQ(record.header.accountId, 7u); EXPECT_EQ(record.header.source, static_cast(eCaptureSource::WORLD)); EXPECT_EQ(record.header.zoneId, 1100); } EXPECT_EQ(captured.batches.front().slots[0], 10u); EXPECT_EQ(captured.all.find(Wide("SECRETSESSIONKEY")), std::string::npos); EXPECT_EQ(captured.all.find("SECRETSESSIONKEY"), std::string::npos); } TEST_F(PacketCaptureTest, AuthNeverStoresPasswordsOrUserKeys) { Start(ServiceType::AUTH); Arm(0, 11, eCaptureTarget::ACCOUNT, 7, "alice"); const auto client = Address(0x0100007f, 50000); AuthPackets::LoginRequest login; login.username = LUWString(std::string("alice")); login.password = LUWString(std::string("hunter2password"), 41); Receive(client, Bytes(login)); PacketCapture::Bind(client, 7, "alice"); ClientPackets::LoginResponse response; response.userKey = LUWString(std::string("USERKEY0123456789")); Send(client, Bytes(response)); // Anything else on auth isn't kept MasterPackets::SetSessionKey other; Send(client, Bytes(other)); PacketCapture::Update(); ASSERT_EQ(captured.records.size(), 2u); for (const auto& secret : { std::string("hunter2password"), std::string("USERKEY0123456789"), std::string("alice") }) { EXPECT_EQ(captured.all.find(Wide(secret)), std::string::npos) << secret; EXPECT_EQ(captured.all.find(secret), std::string::npos) << secret; } // Still readable, with the secrets blank const auto decoded = PacketDecoder::Decode(captured.records[0].bytes, true); ASSERT_TRUE(decoded.fields); EXPECT_EQ((*decoded.fields)["username"], ""); } TEST_F(PacketCaptureTest, CharacterCaptureStartsWhenTheCharacterIsPicked) { Start(ServiceType::WORLD); constexpr LWOOBJID character = 1152921510436607007LL; Arm(3, 12, eCaptureTarget::CHARACTER, 7, "alice", { character }); const auto alice = Address(0x0100007f, 50000); PacketCapture::Bind(alice, 7, "alice"); WorldPackets::CharacterListRequest list; Receive(alice, Bytes(list)); PacketCapture::BindCharacter(alice, character); WorldPackets::LevelLoadComplete loaded; Receive(alice, Bytes(loaded)); PacketCapture::Update(); ASSERT_EQ(captured.records.size(), 1u); EXPECT_EQ(captured.records[0].header.mask, 1 << 3); EXPECT_EQ(captured.records[0].header.characterId, character); } TEST_F(PacketCaptureTest, BroadcastsReachCapturedPlayers) { Start(ServiceType::WORLD); Arm(0, 13, eCaptureTarget::ACCOUNT, 7, "alice"); const auto alice = Address(1, 1), bob = Address(2, 2); PacketCapture::Bind(alice, 7, "alice"); PacketCapture::Bind(bob, 8, "bob"); ClientPackets::LoadStaticZone zone; Send(bob, Bytes(zone), true); // everyone but Bob: Alice gets it Send(alice, Bytes(zone), true); // everyone but Alice PacketCapture::Update(); ASSERT_EQ(captured.records.size(), 1u); EXPECT_TRUE(captured.records[0].header.flags & PacketRecordFlags::BROADCAST); } TEST_F(PacketCaptureTest, EverythingRecordsAllButTheCapturesOwnTraffic) { Start(ServiceType::MASTER); Arm(1, 14, eCaptureTarget::EVERYTHING); MasterPackets::PlayerAdded added; Receive(Address(1, 1), Bytes(added)); MessageCaptureData data; Receive(Address(1, 1), Bytes(data)); PacketCapture::IgnorePeer(Address(9, 9)); Receive(Address(9, 9), Bytes(added)); PacketCapture::Update(); ASSERT_EQ(captured.records.size(), 1u); EXPECT_EQ(captured.records[0].header.mask, 1 << 1); EXPECT_EQ(captured.records[0].header.source, static_cast(eCaptureSource::MASTER)); } TEST_F(PacketCaptureTest, ChatFindsThePlayerInThePacket) { Start(ServiceType::CHAT); constexpr LWOOBJID character = 1152921510436607007LL; Arm(0, 15, eCaptureTarget::ACCOUNT, 7, "alice", { character }); ChatPackets::GeneralChatMessage mine, theirs; mine.playerID = character; theirs.playerID = character + 1; Receive(Address(1, 1), Bytes(mine)); Receive(Address(1, 1), Bytes(theirs)); PacketCapture::Update(); ASSERT_EQ(captured.records.size(), 1u); EXPECT_EQ(captured.records[0].header.characterId, character); } TEST_F(PacketCaptureTest, BatchesWaitForMasterAndDropTheOldestPastTheCap) { Start(ServiceType::WORLD); Arm(0, 16, eCaptureTarget::EVERYTHING); PacketCapture::SetSettings({ .flushIntervalMs = 1000, .flushBytes = 1, .maxBufferBytes = 2000 }); connected = false; ClientPackets::LoadStaticZone zone; const auto bytes = Bytes(zone); for (int i = 0; i < 100; i++) Send(Address(1, 1), bytes); PacketCapture::Update(); EXPECT_TRUE(captured.batches.empty()); EXPECT_GT(PacketCapture::GetStats().dropped, 0u); connected = true; PacketCapture::Update(); ASSERT_FALSE(captured.batches.empty()); EXPECT_EQ(captured.batches.front().packetsDropped, PacketCapture::GetStats().dropped); EXPECT_EQ(captured.records.size() + PacketCapture::GetStats().dropped, 100u); // The sequence shows where the gap is EXPECT_GT(captured.records.front().header.seq, 1u); } TEST_F(PacketCaptureTest, DisarmSendsWhatIsLeftThenStops) { Start(ServiceType::WORLD); Arm(0, 17, eCaptureTarget::EVERYTHING); PacketCapture::SetSettings({ .flushIntervalMs = 60000, .flushBytes = 1024 * 1024, .maxBufferBytes = 1024 * 1024 }); ClientPackets::LoadStaticZone zone; Send(Address(1, 1), Bytes(zone)); MessageCaptureControl control; control.action = eMessageCaptureControl::DISARM; control.captureId = 17; PacketCapture::Control(control); PacketCapture::Update(); ASSERT_EQ(captured.records.size(), 1u); EXPECT_EQ(captured.batches.front().slots[0], 17u) << "sent under the capture it was recorded for"; EXPECT_FALSE(PacketCapture::g_Armed); } TEST(PacketDecoderTests, NamesEveryService) { WorldPackets::PositionUpdate position; position.update.position = NiPoint3(1, 2, 3); RakNet::BitStream stream; position.WritePacket(stream); const std::string bytes(reinterpret_cast(stream.GetData()), stream.GetNumberOfBytesUsed()); const auto decoded = PacketDecoder::Decode(bytes, true); EXPECT_EQ(decoded.service, "WORLD"); EXPECT_EQ(decoded.name, "POSITION_UPDATE"); ASSERT_TRUE(decoded.fields); EXPECT_FLOAT_EQ((*decoded.fields)["position"][1].get(), 2.0f); const auto where = PacketDecoder::Position(bytes); ASSERT_TRUE(where); EXPECT_FLOAT_EQ(where->z, 3.0f); const std::string raknet(1, static_cast(ID_REPLICA_MANAGER_CONSTRUCTION)); EXPECT_EQ(PacketDecoder::Decode(raknet, false).name, "ID_REPLICA_MANAGER_CONSTRUCTION"); EXPECT_EQ(PacketDecoder::Name(ServiceType::MASTER, static_cast(MessageType::Master::SESSION_KEY_RESPONSE)), "SESSION_KEY_RESPONSE"); EXPECT_TRUE(PacketDecoder::HasSecrets(ServiceType::MASTER, static_cast(MessageType::Master::SET_SESSION_KEY))); EXPECT_FALSE(PacketDecoder::HasSecrets(ServiceType::WORLD, static_cast(MessageType::World::POSITION_UPDATE))); } TEST(PacketDecoderTests, SessionKeysBetweenServersAreBlanked) { MasterPackets::SetSessionKey key; key.sessionKey = 0xDEADBEEF; key.username = LUString("alice"); RakNet::BitStream stream; key.WritePacket(stream); std::string bytes(reinterpret_cast(stream.GetData()), stream.GetNumberOfBytesUsed()); ASSERT_TRUE(PacketDecoder::Redact(bytes)); const uint32_t secret = 0xDEADBEEF; EXPECT_EQ(bytes.find(std::string(reinterpret_cast(&secret), 4)), std::string::npos); } namespace { CaptureBundle::Record MakeRecord(const LUBitStream& packet, int64_t timeUs, eCaptureSource source, ePacketDirection direction, LWOOBJID character = 0) { CaptureBundle::Record record; record.bytes = Bytes(packet); record.header.timeUs = timeUs; record.header.source = static_cast(source); record.header.direction = static_cast(direction); record.header.characterId = character; record.header.zoneId = 1100; record.header.length = static_cast(record.bytes.size()); record.header.bits = record.header.length * 8; return record; } } TEST(CaptureToolsTests, TimelineTracksAndBundles) { constexpr LWOOBJID character = 1152921510436607007LL; WorldPackets::PositionUpdate a, b; a.update.position = NiPoint3(0, 0, 0); b.update.position = NiPoint3(10, 0, 0); WorldPackets::CharacterLoginRequest login; login.playerID = character; CaptureBundle::Bundle bundle; bundle.records.push_back(MakeRecord(b, 3000000, eCaptureSource::WORLD, ePacketDirection::RECEIVED, character)); bundle.records.push_back(MakeRecord(a, 1000000, eCaptureSource::WORLD, ePacketDirection::RECEIVED, character)); bundle.records.push_back(MakeRecord(login, 500000, eCaptureSource::WORLD, ePacketDirection::RECEIVED, character)); CaptureTools::SortTimeline(bundle.records); EXPECT_EQ(bundle.records.front().header.timeUs, 500000); const auto tracks = CaptureTools::Tracks(bundle.records, 0); ASSERT_EQ(tracks.size(), 1u); ASSERT_EQ(tracks[0].samples.size(), 8u); EXPECT_FLOAT_EQ(tracks[0].samples[0], 1.0f); EXPECT_FLOAT_EQ(tracks[0].samples[5], 10.0f); // Portable: the character's ID is gone from the bytes, a placeholder is in its place const auto characters = CaptureTools::MakePortable(bundle); ASSERT_EQ(characters.size(), 1u); EXPECT_EQ(characters.at("char#1"), character); const auto decoded = PacketDecoder::Decode(bundle.records.front().bytes, true); EXPECT_EQ((*decoded.fields)["playerID"], std::to_string(CaptureTools::PLACEHOLDER_BASE + 1)); EXPECT_EQ(bundle.meta["ids"]["char#1"]["kind"], "character"); // Saved and read back const auto path = std::filesystem::temp_directory_path() / "dlu_capture_tools_test.bundle"; ASSERT_TRUE(CaptureBundle::Save(path, bundle)); CaptureBundle::Bundle read; std::string error; ASSERT_TRUE(CaptureBundle::Load(path, read, error)) << error; EXPECT_EQ(read.records.size(), 3u); EXPECT_EQ(read.records[1].bytes, bundle.records[1].bytes); EXPECT_TRUE(read.meta["portable"].get()); std::filesystem::remove(path); } TEST(CaptureToolsTests, WorldsFollowTheClientAcrossWorldServers) { constexpr LWOOBJID character = 1152921510436607007LL; WorldPackets::PositionUpdate move; const auto at = [&](int64_t timeUs, uint32_t zone, uint32_t instance, ePacketDirection direction = ePacketDirection::RECEIVED) { auto record = MakeRecord(move, timeUs, eCaptureSource::WORLD, direction, character); record.header.zoneId = zone; record.header.instanceId = instance; record.header.cloneId = zone == 1200 ? 0 : 7; return record; }; std::vector records{ at(1000000, 0, 1), // character select at(2000000, 1000, 2), at(2500000, 1000, 2), // the first world at(4000000, 1100, 3), // transferred to another zone at(4100000, 1000, 2, ePacketDirection::SENT), // the old world still sending: not a move at(5000000, 1100, 3), at(6000000, 1100, 4), // another instance of the same zone MakeRecord(move, 7000000, eCaptureSource::WORLD, ePacketDirection::RECEIVED, 0) // nobody's }; const auto worlds = CaptureTools::Worlds(records, 1000000); ASSERT_EQ(worlds.size(), 4u); EXPECT_EQ(worlds[0].zoneId, 0u); EXPECT_FLOAT_EQ(worlds[0].t, 0.0f); EXPECT_EQ(worlds[1].zoneId, 1000u); EXPECT_FLOAT_EQ(worlds[1].t, 1.0f); EXPECT_EQ(worlds[2].zoneId, 1100u); EXPECT_EQ(worlds[2].instanceId, 3u); EXPECT_EQ(worlds[2].cloneId, 7u); EXPECT_FLOAT_EQ(worlds[2].t, 3.0f); EXPECT_EQ(worlds[3].instanceId, 4u); EXPECT_EQ(worlds[3].characterId, character); } TEST(CaptureToolsTests, AnonymiseKeepsSizes) { WorldPackets::GeneralChatMessage chat; chat.message = u"my secret plans"; CaptureBundle::Bundle bundle; bundle.records.push_back(MakeRecord(chat, 1, eCaptureSource::WORLD, ePacketDirection::RECEIVED)); const auto size = bundle.records[0].bytes.size(); EXPECT_EQ(CaptureTools::Anonymise(bundle), 1u); EXPECT_EQ(bundle.records[0].bytes.size(), size); EXPECT_EQ(bundle.records[0].bytes.find(Wide("secret")), std::string::npos); } TEST(CaptureToolsTests, DiffPairsAnswersAndIgnoresVolatileFields) { ClientPackets::LoadStaticZone zone, otherInstance, otherChecksum; zone.mapID = otherInstance.mapID = otherChecksum.mapID = 1100; zone.instanceID = 1; otherInstance.instanceID = 7; // differs every run: not a difference otherChecksum.mapChecksum = 1234; ClientPackets::TransferToWorld transfer; std::vector expected{ MakeRecord(zone, 1, eCaptureSource::WORLD, ePacketDirection::SENT), MakeRecord(zone, 2, eCaptureSource::WORLD, ePacketDirection::SENT), MakeRecord(transfer, 3, eCaptureSource::WORLD, ePacketDirection::SENT) }; std::vector actual{ MakeRecord(otherInstance, 1, eCaptureSource::WORLD, ePacketDirection::SENT), MakeRecord(otherChecksum, 2, eCaptureSource::WORLD, ePacketDirection::SENT), MakeRecord(zone, 3, eCaptureSource::WORLD, ePacketDirection::SENT) }; const auto report = CaptureTools::Diff(expected, actual); EXPECT_EQ(report.expected, 3u); EXPECT_EQ(report.matched, 1u); EXPECT_EQ(report.differing, 1u); EXPECT_EQ(report.missing, 1u); EXPECT_EQ(report.extra, 1u); EXPECT_EQ(report.missingByName.at("TRANSFER_TO_WORLD"), 1u); } /** * The cost of capturing everything (docs/CaptureReplay.md, "Overhead"): a synthetic load of world packets through the * tap with an EVERYTHING capture armed, then what the dashboard does with the batches: append them to a file, or * (for comparison) insert every packet as a row in one SQLite transaction per batch. */ TEST_F(PacketCaptureTest, OverheadOfCapturingEverything) { using Clock = std::chrono::steady_clock; constexpr int PACKETS = 300000; Start(ServiceType::WORLD); Arm(0, 18, eCaptureTarget::EVERYTHING); PacketCapture::SetSettings({ .flushIntervalMs = 1000, .flushBytes = 256 * 1024, .maxBufferBytes = 64 * 1024 * 1024 }); std::vector batches; PacketCapture::SetSink([&](MessageCaptureData& data) { batches.push_back(std::move(data.packets)); return true; }); WorldPackets::PositionUpdate position; position.hasVelocity = true; const auto bytes = Bytes(position); const auto* data = reinterpret_cast(bytes.data()); const auto bits = static_cast(bytes.size() * 8); const auto start = Clock::now(); for (int i = 0; i < PACKETS; i++) { PacketCapture::RecordForTest(Address(static_cast(i % 100), 1000), (i & 1) != 0, false, data, bits); if (i % 1000 == 0) PacketCapture::Update(); // about one main loop frame } PacketCapture::Update(); const auto tapNs = std::chrono::duration_cast(Clock::now() - start).count(); size_t total = 0; for (const auto& batch : batches) total += batch.size(); ASSERT_EQ(PacketCapture::GetStats().recorded, static_cast(PACKETS)); // The dashboard's side: one append per batch const auto path = std::filesystem::temp_directory_path() / "dlu_capture_overhead.bundle"; const auto fileStart = Clock::now(); { std::ofstream file(path, std::ios::binary | std::ios::trunc); for (const auto& batch : batches) { file.write(batch.data(), static_cast(batch.size())); file.flush(); } } const auto fileNs = std::chrono::duration_cast(Clock::now() - fileStart).count(); std::filesystem::remove(path); // For comparison: a row per packet, one transaction per batch const auto dbPath = std::filesystem::temp_directory_path() / "dlu_capture_overhead.sqlite"; std::filesystem::remove(dbPath); sqlite3* db = nullptr; ASSERT_EQ(sqlite3_open(dbPath.string().c_str(), &db), SQLITE_OK); sqlite3_exec(db, "PRAGMA journal_mode=WAL; CREATE TABLE e (session INTEGER, seq INTEGER, t INTEGER, payload BLOB, PRIMARY KEY (session, seq));", nullptr, nullptr, nullptr); sqlite3_stmt* insert = nullptr; sqlite3_prepare_v2(db, "INSERT INTO e VALUES (1, ?, ?, ?);", -1, &insert, nullptr); const auto dbStart = Clock::now(); int seq = 0; for (const auto& batch : batches) { sqlite3_exec(db, "BEGIN;", nullptr, nullptr, nullptr); PacketRecord::ForEach(batch, [&](const PacketRecordHeader& header, std::string_view payload) { sqlite3_bind_int(insert, 1, ++seq); sqlite3_bind_int64(insert, 2, header.timeUs); sqlite3_bind_blob(insert, 3, payload.data(), static_cast(payload.size()), SQLITE_STATIC); sqlite3_step(insert); sqlite3_reset(insert); }); sqlite3_exec(db, "COMMIT;", nullptr, nullptr, nullptr); } const auto dbNs = std::chrono::duration_cast(Clock::now() - dbStart).count(); sqlite3_finalize(insert); sqlite3_close(db); std::filesystem::remove(dbPath); std::filesystem::remove(dbPath.string() + "-wal"); std::filesystem::remove(dbPath.string() + "-shm"); const double seconds = static_cast(tapNs) / 1e9; std::printf("[overhead] %d packets of %zu bytes through the tap: %.0f ns each, %.0f packets/s on one core, %.1f MB/s of records, %zu batches\n", PACKETS, bytes.size(), static_cast(tapNs) / PACKETS, PACKETS / seconds, static_cast(total) / 1e6 / seconds, batches.size()); std::printf("[overhead] dashboard: file append %.1f ms (%.0f ns/packet); SQLite row per packet %.1f ms (%.0f ns/packet)\n", static_cast(fileNs) / 1e6, static_cast(fileNs) / PACKETS, static_cast(dbNs) / 1e6, static_cast(dbNs) / PACKETS); // Generous bounds: this is a smoke test, the numbers above are the measurement 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, 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, 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++; 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 (%zu client game messages)\n", bundles, checked, gameMessages); }