From d8900c5d684e6fdbfeedcf48f49919dbaf846b96 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 03:06:10 -0500 Subject: [PATCH] feat(net): CaptureTools::Worlds lists each captured character's world visits One entry each time the packets a character's client sends carry another zone or instance, in time order, for following them across world servers in playback. Co-Authored-By: Claude Opus 5.5 --- dNet/CaptureTools.cpp | 15 +++++++++ dNet/CaptureTools.h | 11 +++++++ .../dNetTests/PacketCaptureTests.cpp | 33 +++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/dNet/CaptureTools.cpp b/dNet/CaptureTools.cpp index 005349d1e..e07854a9d 100644 --- a/dNet/CaptureTools.cpp +++ b/dNet/CaptureTools.cpp @@ -151,6 +151,21 @@ namespace CaptureTools { return tracks; } + std::vector Worlds(const std::vector& records, int64_t startUs) { + std::vector visits; + std::map last; // character -> their latest entry in visits + for (const auto& record : records) { + const auto& h = record.header; + // Only what the client sent: the old world can still send a few packets after the client reached the new one + if (h.source != static_cast(eCaptureSource::WORLD) || !h.characterId || (h.flags & PacketRecordFlags::GAP) || !FromClient(h)) continue; + const auto it = last.find(h.characterId); + if (it != last.end() && visits[it->second].zoneId == h.zoneId && visits[it->second].instanceId == h.instanceId) continue; + last[h.characterId] = visits.size(); + visits.push_back({ h.characterId, static_cast(h.timeUs - startUs) / 1e6f, h.zoneId, h.instanceId, h.cloneId }); + } + return visits; + } + std::map MakePortable(CaptureBundle::Bundle& bundle) { std::map characters; std::map accounts; diff --git a/dNet/CaptureTools.h b/dNet/CaptureTools.h index f810d087b..c560fe7da 100644 --- a/dNet/CaptureTools.h +++ b/dNet/CaptureTools.h @@ -35,6 +35,17 @@ namespace CaptureTools { // Where each captured character moved (their POSITION_UPDATEs), per zone and instance std::vector Tracks(const std::vector& records, int64_t startUs); + struct WorldVisit { + LWOOBJID characterId{}; + float t{}; // seconds from the capture's start: the character's first packet on this world server + uint32_t zoneId{}; + uint32_t instanceId{}; + uint32_t cloneId{}; + }; + // Which world server each captured character was on, in time order: one entry each time the packets their client + // sends move to another zone or instance (zone 0 is character select) + std::vector Worlds(const std::vector& records, int64_t startUs); + /** * Makes a bundle portable: the source server's character and account IDs are replaced by placeholders * (PLACEHOLDER_BASE + n, written in the records' bytes and headers), listed in meta.ids as "char#n" / "account#n"; diff --git a/tests/dGameTests/dNetTests/PacketCaptureTests.cpp b/tests/dGameTests/dNetTests/PacketCaptureTests.cpp index b82358625..1ef827d6c 100644 --- a/tests/dGameTests/dNetTests/PacketCaptureTests.cpp +++ b/tests/dGameTests/dNetTests/PacketCaptureTests.cpp @@ -416,6 +416,39 @@ TEST(CaptureToolsTests, TimelineTracksAndBundles) { 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";