Files
DarkflameServer/dCaptureTool/LiveImport.cpp
Aaron Kimbrell 3a8838d463 feat(capture): replay bundles against a sandbox stack with a headless client
CaptureTool (built next to the servers) replays packet bundles and compares the answers:

- replay: per bundle a fresh sandbox folder with copied server binaries, rewritten settings
  (replay_sandbox=1, a new SQLite file inside the folder, ports from --port on, no dashboard),
  read back before anything starts; sandbox-setup runs inside it to apply migrations and make
  the replay account and the bundle's characters (setup mode); master is started, the stack is
  stopped as one process group and the folder deleted unless kept
- with replay_sandbox=1 every server refuses a database that isn't SQLite, isn't inside its
  own folder, or is replay_live_sqlite_path (Database::Connect); replay-target against a
  running server needs --i-know-this-is-not-a-sandbox
- the fake client splits the recording into connections, logs in and picks the character
  itself when the recording doesn't, fills in the target's account, session key and IDs,
  learns server-made object IDs from replica constructions by LOT, follows the recorded
  timing and waits for the answers a client waits for; the diff pairs answers by name (and
  constructions by LOT) and ignores fields that differ between runs
- import-live converts the 2014 live captures (folders of *_traffic.zip; pcaps and encrypted
  captures are left alone) into bundles, with secrets removed and CREATE_CHARACTER as setup
- anonymise makes local fixtures; docs/CaptureReplay.md describes capture, the bundle format,
  portability rules, the sandbox and the replay

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:34:34 -05:00

288 lines
11 KiB
C++

#include "LiveImport.h"
#include <algorithm>
#include <cstring>
#include <ctime>
#include <fstream>
#include <map>
#include <regex>
#include <set>
#include "ClientPackets.h"
#include "CaptureTools.h"
#include "MessageIdentifiers.h"
#include "MessageType/Client.h"
#include "PacketDecoder.h"
#include "ServiceType.h"
#include "ZCompression.h"
namespace fs = std::filesystem;
using json = nlohmann::json;
namespace {
constexpr uint16_t AUTH_PORT = 1001;
struct Entry {
std::string name;
std::string data;
int64_t timeUs{};
};
template<typename T>
T Get(const std::string& data, size_t at) {
T value{};
if (at + sizeof(T) <= data.size()) std::memcpy(&value, data.data() + at, sizeof(T));
return value;
}
int64_t DosTimeUs(uint16_t time, uint16_t date) {
std::tm tm{};
tm.tm_year = ((date >> 9) & 0x7f) + 80;
tm.tm_mon = ((date >> 5) & 0x0f) - 1;
tm.tm_mday = date & 0x1f;
tm.tm_hour = (time >> 11) & 0x1f;
tm.tm_min = (time >> 5) & 0x3f;
tm.tm_sec = (time & 0x1f) * 2;
return static_cast<int64_t>(timegm(&tm)) * 1000000;
}
// The entries of a zip file (stored or deflated; no zip64, no encryption)
bool ReadZip(const fs::path& path, std::vector<Entry>& entries, std::string& error) {
std::ifstream file(path, std::ios::binary);
const std::string zip((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
if (zip.size() < 22) {
error = "not a zip file";
return false;
}
size_t end = std::string::npos;
for (size_t at = zip.size() - 22 + 1; at-- > 0 && zip.size() - at < 65557;) {
if (Get<uint32_t>(zip, at) == 0x06054b50) {
end = at;
break;
}
}
if (end == std::string::npos) {
error = "no zip directory";
return false;
}
const auto count = Get<uint16_t>(zip, end + 10);
size_t at = Get<uint32_t>(zip, end + 16);
for (uint16_t i = 0; i < count; i++) {
if (Get<uint32_t>(zip, at) != 0x02014b50) {
error = "damaged zip directory";
return false;
}
const auto flags = Get<uint16_t>(zip, at + 8);
const auto method = Get<uint16_t>(zip, at + 10);
const auto time = Get<uint16_t>(zip, at + 12), date = Get<uint16_t>(zip, at + 14);
const auto compressed = Get<uint32_t>(zip, at + 20), size = Get<uint32_t>(zip, at + 24);
const auto nameLength = Get<uint16_t>(zip, at + 28), extraLength = Get<uint16_t>(zip, at + 30), commentLength = Get<uint16_t>(zip, at + 32);
const auto local = Get<uint32_t>(zip, at + 42);
Entry entry;
entry.name = zip.substr(at + 46, nameLength);
entry.timeUs = DosTimeUs(time, date);
at += 46 + nameLength + extraLength + commentLength;
if (flags & 1) continue; // encrypted: left alone
if (Get<uint32_t>(zip, local) != 0x04034b50) continue;
const size_t data = local + 30 + Get<uint16_t>(zip, local + 26) + Get<uint16_t>(zip, local + 28);
if (data + compressed > zip.size()) continue;
const std::string_view raw(zip.data() + data, compressed);
if (method == 0) entry.data = std::string(raw);
else if (method == 8) {
auto inflated = ZCompression::InflateRaw(raw, size);
if (!inflated) continue;
entry.data = std::move(*inflated);
} else continue;
entries.push_back(std::move(entry));
}
return true;
}
/**
* The live servers' CREATE_CHARACTER: a compressed LDF list with more types than DLU writes (so read here, not with
* ClientPackets::CreateCharacter): the object ID, name and character XML.
*/
bool ReadCreateCharacter(const std::string& bytes, LWOOBJID& id, std::string& name, std::string& xml) {
if (bytes.size() < 8 + 13) return false;
const auto compressedSize = Get<uint32_t>(bytes, 8 + 9), size = Get<uint32_t>(bytes, 8 + 5);
if (bytes[8 + 4] != 1 || 8 + 13 + static_cast<size_t>(compressedSize) > bytes.size() || size > 64 * 1024 * 1024) return false;
std::vector<uint8_t> out(size);
int32_t error{};
if (ZCompression::Decompress(reinterpret_cast<const uint8_t*>(bytes.data()) + 21, compressedSize, out.data(), size, error) != static_cast<int32_t>(size)) return false;
const std::string data(out.begin(), out.end());
size_t at = 4;
for (uint32_t i = 0, count = Get<uint32_t>(data, 0); i < count && at < data.size(); i++) {
const uint8_t keyBytes = static_cast<uint8_t>(data[at++]);
std::u16string key(keyBytes / 2, u'\0');
std::memcpy(key.data(), data.data() + at, keyBytes);
at += keyBytes;
const uint8_t type = static_cast<uint8_t>(data[at++]);
switch (type) {
case 0: { // UTF-16
const auto length = Get<uint32_t>(data, at);
std::u16string value(length, u'\0');
std::memcpy(value.data(), data.data() + at + 4, std::min<size_t>(length * 2, data.size() - at - 4));
if (key == u"name") name = GeneralUtils::UTF16ToWTF8(value);
at += 4 + length * 2;
break;
}
case 13: { // UTF-8
const auto length = Get<uint32_t>(data, at);
if (key == u"xmlData") xml = data.substr(at + 4, length);
at += 4 + length;
break;
}
case 1: case 3: case 5: at += 4; break;
case 7: at += 1; break;
case 4: case 8: at += 8; break;
case 9:
if (key == u"objid") id = Get<int64_t>(data, at);
at += 8;
break;
default: return id != 0;
}
}
return id != 0;
}
// auth_traffic, char_traffic, world_traffic, world1_traffic, world2_traffic, ...: the order they were played in
int ZipOrder(const std::string& name) {
if (name.starts_with("auth")) return 0;
if (name.starts_with("char")) return 1;
static const std::regex world(R"(world(\d*)_traffic)");
std::smatch match;
if (std::regex_search(name, match, world)) return 2 + (match[1].length() ? std::stoi(match[1]) : 0);
return 1000;
}
}
namespace LiveImport {
std::vector<fs::path> FindScenarios(const fs::path& root) {
std::set<fs::path> folders;
std::error_code ec;
for (auto it = fs::recursive_directory_iterator(root, fs::directory_options::skip_permission_denied, ec); it != fs::recursive_directory_iterator(); it.increment(ec)) {
if (ec) break;
const auto name = it->path().filename().string();
if (it->is_regular_file(ec) && name.ends_with("_traffic.zip")) folders.insert(it->path().parent_path());
}
return { folders.begin(), folders.end() };
}
Result Import(const fs::path& scenario) {
Result result;
std::vector<fs::path> zips;
std::error_code ec;
if (fs::is_directory(scenario, ec)) {
for (const auto& entry : fs::directory_iterator(scenario, ec)) {
const auto name = entry.path().filename().string();
if (entry.is_regular_file() && name.ends_with("_traffic.zip")) zips.push_back(entry.path());
}
} else {
zips.push_back(scenario);
}
std::ranges::sort(zips, [](const fs::path& a, const fs::path& b) { return ZipOrder(a.filename().string()) < ZipOrder(b.filename().string()); });
if (zips.empty()) {
result.error = "No *_traffic.zip files";
return result;
}
// Split packets come as their parts ("(1of81)", left out) and joined ("<n>_<ports>_joined_[...]")
static const std::regex packetName(R"(^(\d+)_(\d+)-(\d+)(?:_(\d+|joined))?_\[)");
std::map<LWOOBJID, std::pair<std::string, std::string>> characters; // id -> name, xml
int64_t last = 0;
uint32_t seq = 0;
for (const auto& zip : zips) {
std::vector<Entry> entries;
std::string error;
if (!ReadZip(zip, entries, error)) {
result.error = zip.filename().string() + ": " + error;
return result;
}
result.zips++;
struct Numbered { uint64_t index; uint32_t part; Entry* entry; uint16_t from; uint16_t to; };
std::vector<Numbered> packets;
for (auto& entry : entries) {
std::smatch match;
const auto name = fs::path(entry.name).filename().string();
if (entry.data.empty() || !std::regex_search(name, match, packetName)) {
result.skipped++;
continue;
}
const auto part = match[4].str();
packets.push_back({ std::stoull(match[1]), part.empty() || part == "joined" ? 1u : static_cast<uint32_t>(std::stoul(part)), &entry,
static_cast<uint16_t>(std::stoul(match[2])), static_cast<uint16_t>(std::stoul(match[3])) });
}
std::ranges::sort(packets, [](const Numbered& a, const Numbered& b) { return a.index != b.index ? a.index < b.index : a.part < b.part; });
const bool auth = zip.filename().string().starts_with("auth");
uint16_t zone = 0, instance = 0;
uint32_t clone = 0;
LWOOBJID character = 0;
for (const auto& p : packets) {
// The server is the end with the lower port (auth 1001, worlds 2000 and up; clients' ports are higher)
const uint16_t server = std::min(p.from, p.to), client = std::max(p.from, p.to);
CaptureBundle::Record record;
record.bytes = p.entry->data;
if (!PacketDecoder::Redact(record.bytes)) {
result.skipped++;
continue;
}
auto& h = record.header;
h.timeUs = last = std::max(last + 1000, p.entry->timeUs);
h.seq = ++seq;
h.source = static_cast<uint8_t>(auth || server == AUTH_PORT ? eCaptureSource::AUTH : eCaptureSource::WORLD);
h.direction = static_cast<uint8_t>(p.to == server ? ePacketDirection::RECEIVED : ePacketDirection::SENT);
h.peer = client;
h.bits = static_cast<uint32_t>(record.bytes.size() * 8);
h.length = static_cast<uint32_t>(record.bytes.size());
// Where this is, and whose: from the zone the server loads and the character it makes
const auto decoded = PacketDecoder::Decode(record.bytes, h.direction == static_cast<uint8_t>(ePacketDirection::RECEIVED));
if (decoded.name == "LOAD_STATIC_ZONE" && decoded.fields) {
zone = static_cast<uint16_t>((*decoded.fields)["mapID"].get<int>());
instance = static_cast<uint16_t>((*decoded.fields)["instanceID"].get<int>());
clone = (*decoded.fields)["cloneID"].get<uint32_t>();
}
if (decoded.name == "CREATE_CHARACTER") {
LWOOBJID id{};
std::string name, xml;
if (ReadCreateCharacter(record.bytes, id, name, xml)) {
character = id;
characters[id] = { name, xml };
}
}
h.zoneId = zone;
h.instanceId = instance;
h.cloneId = clone;
h.characterId = character;
result.bundle.records.push_back(std::move(record));
result.packets++;
}
}
auto& meta = result.bundle.meta;
meta["format"] = CaptureBundle::FORMAT_VERSION;
meta["origin"] = "live-2014";
meta["scenario"] = scenario.filename().string();
meta["server"] = { {"version", "LEGO Universe live servers"} };
json zones = json::object();
for (const auto& record : result.bundle.records) {
const auto decoded = PacketDecoder::Decode(record.bytes, CaptureTools::FromClient(record.header));
if (decoded.name == "LOAD_STATIC_ZONE" && decoded.fields) zones[std::to_string((*decoded.fields)["mapID"].get<int>())] = (*decoded.fields)["mapChecksum"];
if (decoded.name == "VALIDATION" && decoded.fields) meta["fdbChecksum"] = (*decoded.fields)["fdbChecksum"];
}
meta["zones"] = zones;
const auto symbols = CaptureTools::MakePortable(result.bundle);
json setup = json::array();
static const std::regex account(R"( acct="[0-9]+")");
for (const auto& [symbol, id] : symbols) {
const auto it = characters.find(id);
if (it == characters.end()) continue;
setup.push_back({ {"symbol", symbol}, {"placeholder", meta["ids"][symbol]["placeholder"]}, {"name", it->second.first},
{"xml", std::regex_replace(it->second.second, account, "")} });
}
meta["setup"] = { {"characters", setup} };
return result;
}
}