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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-30 03:06:10 -05:00
parent 5658fb036f
commit d8900c5d68
3 changed files with 59 additions and 0 deletions

View File

@@ -151,6 +151,21 @@ namespace CaptureTools {
return tracks;
}
std::vector<WorldVisit> Worlds(const std::vector<Record>& records, int64_t startUs) {
std::vector<WorldVisit> visits;
std::map<LWOOBJID, size_t> 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<uint8_t>(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<float>(h.timeUs - startUs) / 1e6f, h.zoneId, h.instanceId, h.cloneId });
}
return visits;
}
std::map<std::string, LWOOBJID> MakePortable(CaptureBundle::Bundle& bundle) {
std::map<LWOOBJID, std::string> characters;
std::map<uint32_t, uint32_t> accounts;

View File

@@ -35,6 +35,17 @@ namespace CaptureTools {
// Where each captured character moved (their POSITION_UPDATEs), per zone and instance
std::vector<Track> Tracks(const std::vector<CaptureBundle::Record>& 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<WorldVisit> Worlds(const std::vector<CaptureBundle::Record>& 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";

View File

@@ -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<CaptureBundle::Record> 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";