| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119 |
- #include "entity_create_probe.h"
- #include <Windows.h>
- #include <intrin.h>
- #include <array>
- #include <cstddef>
- #include <cstdint>
- #include <cstdio>
- #include <span>
- #include <string_view>
- #include "../../core/logging/log.h"
- #include "../hooking/detour.h"
- #include "../patterns/image_scan.h"
- #include "../patterns/signature_text.h"
- namespace sunrise::client::diagnostics {
- namespace {
- namespace patterns = client::patterns;
- namespace detour = client::hooking::detour;
- /**
- * The index allocator the entity creator calls first.
- * Recovered from the mapped-image dump. Its body is unmistakable: it stores -1 into the caller's
- * out-parameter, then asks a pool at `+0xC118` sized `0x2000` for a free index. The frame size is
- * wildcarded so the match carries no position-dependent byte.
- */
- constexpr std::string_view kIndexAllocatorText =
- "48 89 5C 24 08 48 89 74 24 10 57 48 83 EC ? 48 8B DA C7 02 FF FF FF FF 48 8B F9 "
- "BA 00 20 00 00";
- /** Compiled pattern bytes of the signature text above. */
- constexpr auto kIndexAllocator =
- patterns::signature<patterns::signature_length(kIndexAllocatorText)>(kIndexAllocatorText);
- /** The allocator answers this in its out-parameter when it has no index to give. */
- constexpr std::int32_t kNoIndex = -1;
- /**
- * Byte offset of the free-slot bitmap inside the manager the allocator is handed.
- * Read out of the allocator's body: it calls the bitmap search with `rcx = manager + 0xC118` and
- * a width of `0x2000`, then clears the bit it was given. A set bit is therefore a FREE slot, and
- * the search answers -1 only when every word is zero.
- */
- constexpr std::size_t kFreeBitmapOffset = 0xC118;
- /** Slots the bitmap covers, from the width the allocator passes. */
- constexpr std::size_t kFreeBitmapBits = 0x2000;
- /** Words in that bitmap. */
- constexpr std::size_t kFreeBitmapWords = kFreeBitmapBits / 32;
- /**
- * Counts the free slots the manager currently holds.
- * The exhaustion line alone cannot separate "the host never gave the client any slots" from
- * "the client used everything it was given", and those need opposite fixes.
- * @param pool Manager the allocator was handed.
- * @return Set bits in its free bitmap, or -1 when the bitmap cannot be read.
- */
- [[nodiscard]] std::int64_t free_slot_count(const void* pool) noexcept {
- if (pool == nullptr) {
- return -1;
- }
- std::int64_t free = 0;
- __try {
- const auto* words = reinterpret_cast<const std::uint32_t*>(
- static_cast<const std::byte*>(pool) + kFreeBitmapOffset);
- for (std::size_t word = 0; word < kFreeBitmapWords; ++word) {
- free += static_cast<std::int64_t>(__popcnt(words[word]));
- }
- } __except (EXCEPTION_EXECUTE_HANDLER) {
- return -1;
- }
- return free;
- }
- /** Outcomes reported per run, so a per-frame failure cannot fill the log. */
- constexpr LONG kMaxReports = 200;
- /**
- * Stack frames captured above this probe on each allocation.
- * The allocator itself is generic — one function serves every entity in the game — so its own
- * address says nothing about what is being built. The callers above it are what differ, and six
- * frames is enough to separate "the world is placing an object" from "a weapon spawned a
- * projectile" without unwinding the whole fiber stack.
- */
- constexpr ULONG kTraceFrames = 6;
- /**
- * Allocation traces per run.
- * A raid load builds a few hundred entities, so this holds several bubble loads while still
- * bounding what a long firefight can write.
- */
- constexpr LONG kMaxTraces = 4096;
- /** Traces already spent. */
- volatile LONG g_traces{};
- /**
- * Image offset of the pointer to the game's entity record table.
- * Recovered from the creation path itself, which indexes it as `base + (handle & 0x1FFF) * stride`
- * at `0x4D71F7`: `imul ebx, [rip -> 0x1F93430]` then `add rbx, [rip -> 0x1F93428]`. The mask is the
- * same 13 bits the allocator's bitmap covers, so a record addresses exactly one allocated index.
- */
- constexpr std::uintptr_t kEntityTableBaseRva = 0x1F93428;
- /** Image offset of the record stride that pairs with the table above. */
- constexpr std::uintptr_t kEntityTableStrideRva = 0x1F93430;
- /** Stride the dump reports. Checked at runtime, because a wrong one would read foreign memory. */
- constexpr std::uint32_t kExpectedRecordStride = 224;
- /** Bytes of each record dumped. The whole record, so the type field can be found by comparison. */
- constexpr std::size_t kRecordDumpBytes = kExpectedRecordStride;
- /** Records dumped per run, bounded so a long session cannot fill the sink. */
- constexpr LONG kMaxRecords = 512;
- /** Records already dumped. */
- volatile LONG g_records{};
- /**
- * Record class every live entity carries at `+0x64`.
- * Constant across all 57 records of a run, so it marks a slot the game has actually built rather
- * than one holding whatever the last entity left behind.
- */
- constexpr std::uint32_t kRecordClass = 0x80809783;
- /** Offset of the record class within a record. */
- constexpr std::size_t kRecordClassOffset = 0x64;
- /** Offset of the object's definition hash. Varies per object kind; `0xFFFFFFFF` where absent. */
- constexpr std::size_t kRecordDefinitionOffset = 0x88;
- /** Offset of the instance ordinal that counts copies of one definition. */
- constexpr std::size_t kRecordOrdinalOffset = 0x8C;
- /** Offset of the transform block, which is still unset when a record is first dumped. */
- constexpr std::size_t kRecordTransformOffset = 0xA0;
- /** Dwords of the transform block reported, covering the orientation and position quads. */
- constexpr std::size_t kRecordTransformDwords = 8;
- /** Seconds between censuses. Short enough to catch a bubble soon after it settles. */
- constexpr DWORD kCensusIntervalMs = 15'000;
- /**
- * Most recent manager the allocator was handed.
- * The census needs the free bitmap to tell a live record from one an entity left behind, and the
- * allocator is the only place the manager pointer is known.
- */
- void* volatile g_lastPool{};
- /** Entries one census reports, so a fully populated table cannot fill the sink. */
- constexpr LONG kCensusEntryBudget = 2'048;
- /**
- * Distinct record classes counted per census.
- * The census filtered on one class, `kRecordClass`, and so never reported an index above ~1019.
- * An interaction incident then named entity **3539** as its target while the player stood on the
- * Wall of Wishes activation plate -- an object that works -- and the twenty panels that do not
- * work sit at 749..768. Whatever separates them is not visible while the walk only ever admits
- * one class, so every class is counted and sampled now.
- */
- constexpr std::size_t kClassCapacity = 24;
- /**
- * Records dumped per distinct class, so a large class cannot crowd out a small one.
- * Set at 48 this hid the very thing it was built to find: one class holds every real record, so
- * only indices 0..47 were ever dumped and the Wall of Wishes panels at 749..768 fell outside the
- * log entirely. That absence then read as "the player never reached the wall", which was wrong.
- * The share only needs to stop one class starving another, so it sits at the whole budget.
- */
- constexpr LONG kPerClassDump = 2'048;
- /** Cleared to stop the census thread. */
- volatile LONG g_censusRunning{};
- /** Census thread handle. */
- HANDLE g_censusThread{};
- /**
- * Index whose record has not been dumped yet.
- * The record is empty when the allocator hands the index out — the creator fills it afterwards — so
- * each index is read one allocation late, when whatever built it has finished.
- */
- volatile LONG g_pendingIndex{-1};
- /**
- * Dumps one entity record so the entity can be named rather than counted.
- * Counting proved the pool works and says nothing about what is in it. The record is the only place
- * the client keeps an entity's identity, and every entity in the run shares one creation path, so
- * the bytes here are what separate a wall panel from a projectile.
- * @param index Index whose record to read.
- */
- void report_record(std::int32_t index) noexcept {
- if (index < 0 || static_cast<std::size_t>(index) >= kFreeBitmapBits
- || !core::log::accepts(core::log::Channel::client, core::log::Level::debug)
- || InterlockedIncrement(&g_records) > kMaxRecords) {
- return;
- }
- const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
- if (base == 0) {
- return;
- }
- std::array<char, core::log::kLineCapacity> line{};
- int written = 0;
- __try {
- const auto table = *reinterpret_cast<const std::byte* const*>(base + kEntityTableBaseRva);
- const auto stride = *reinterpret_cast<const std::uint32_t*>(base + kEntityTableStrideRva);
- // A stride that has moved means this offset no longer names the table, and reading through
- // it would dump unrelated memory as if it were an entity.
- if (table == nullptr || stride != kExpectedRecordStride) {
- return;
- }
- const auto* const record = table + static_cast<std::size_t>(index) * stride;
- written = std::snprintf(line.data(),
- line.size(),
- "ev=entity_create stage=record idx=%d hex=",
- static_cast<int>(index));
- for (std::size_t offset = 0; offset < kRecordDumpBytes && written > 0
- && static_cast<std::size_t>(written) + 3 < line.size();
- ++offset) {
- const int more = std::snprintf(line.data() + written,
- line.size() - static_cast<std::size_t>(written),
- "%02X",
- std::to_integer<unsigned char>(record[offset]));
- if (more <= 0) {
- break;
- }
- written += more;
- }
- } __except (EXCEPTION_EXECUTE_HANDLER) {
- return;
- }
- if (written <= 0) {
- return;
- }
- const auto length = static_cast<std::size_t>(written) < line.size()
- ? static_cast<std::size_t>(written)
- : line.size() - 1;
- core::log::write(core::log::Channel::client, core::log::Level::debug, {line.data(), length});
- }
- /** Resolved `RtlCaptureStackBackTrace`, or null when ntdll would not give it up. */
- USHORT(NTAPI* g_captureBacktrace)(ULONG, ULONG, PVOID*, PULONG){};
- /** Allocations between pool samples. Frequent enough to shape the drain, rare enough to be free. */
- constexpr LONG kSampleInterval = 16;
- /** Bytes in the bitmap, from the width the client's own stocking path passes to its fill. */
- constexpr std::size_t kFreeBitmapBytes = kFreeBitmapBits / 8;
- /**
- * High slots the host keeps for its own entities and never leases to the client.
- * The join grant is `kSlotCount - kDefaultServerReserve` = 7936, so the top 256 indices are the
- * host's. The client's own initialiser frees the whole bitmap because in its intended world it
- * owns every slot; here it does not, and handing it the reserve would let it allocate an index
- * the host also considers its own.
- */
- constexpr std::size_t kServerReserveSlots = 256;
- /** Bytes of the bitmap that stay clear, covering the reserve at the top of the index space. */
- constexpr std::size_t kReserveBytes = kServerReserveSlots / 8;
- /** Bytes of the bitmap that are freed to the client. */
- constexpr std::size_t kClientBytes = kFreeBitmapBytes - kReserveBytes;
- /** Words of the bitmap covering the client's half. The split lands on a word boundary. */
- constexpr std::size_t kClientWords = kClientBytes / sizeof(std::uint32_t);
- static_assert(kClientBytes % sizeof(std::uint32_t) == 0,
- "the client half must end on a word so a refill never touches the reserve");
- /** Bits per bitmap word. */
- constexpr std::size_t kBitsPerWord = 32;
- /**
- * Address span treated as belonging to the game's image.
- * The dump reports an image size of 0x8A5EA00, so this clears it with room for a larger build while
- * still rejecting a frame that landed in Sunrise's own module or on a foreign allocation.
- */
- constexpr std::uintptr_t kImageSpan = 0x10000000;
- /**
- * The allocator's real shape, read from its body rather than guessed.
- * It uses exactly two arguments: `rcx` is the manager whose free-slot bitmap sits at `+0xC118`,
- * and `rdx` is the out-parameter it fills with the allocated index. It returns `rdx` unchanged.
- */
- using IndexAllocator = void*(__fastcall*)(void*, std::int32_t*) noexcept;
- detour::Handle g_allocator{};
- volatile LONG g_reports{};
- /** Successful allocations seen, used only to space the samples. */
- volatile LONG g_allocations{};
- /**
- * One manager's record of the indices this probe has watched the allocator hand out.
- *
- * A blanket `memset(bitmap, 0xFF, ...)` is what made the very first stocking work and what made
- * every later one lethal. It frees index 0 upward, and by the time a pool has drained, index 0
- * belongs to a live entity. The allocator picks the lowest set bit, so the next creation lands on
- * top of a live entity and the world stops being a consistent list of them. That is the crash on
- * respawn, the crash on Worldline Zero's ability, and the mainloop stall that ends a Shuro Chi run
- * a few seconds after the room loads.
- *
- * Keeping the set of indices already handed out turns the refill from "free everything" into
- * "free what was never taken", which is the only form of it that is safe to run on a live pool.
- */
- struct PoolRecord {
- /** Manager this record belongs to, or null while the slot is unused. */
- void* pool;
- /** Set bit per index the allocator gave out and the client has not since handed back. */
- std::array<volatile LONG, kFreeBitmapWords> live;
- /** Whether this pool has been refilled at least once. */
- volatile LONG stocked;
- };
- /** Managers tracked at once. A world change builds a new one, so several are live per run. */
- constexpr std::size_t kTrackedPoolCapacity = 16;
- /** Per-manager occupancy records, claimed on first sight. */
- std::array<PoolRecord, kTrackedPoolCapacity> g_pools{};
- /**
- * Finds the record for one manager, claiming a free slot on first sight.
- * @param pool Manager the allocator was handed.
- * @return Its record, or null when the table is full.
- */
- [[nodiscard]] PoolRecord* find_pool(void* pool) noexcept {
- for (auto& record : g_pools) {
- if (record.pool == pool) {
- return &record;
- }
- }
- for (auto& record : g_pools) {
- auto* const slot = reinterpret_cast<void* volatile*>(&record.pool);
- if (InterlockedCompareExchangePointer(slot, pool, nullptr) == nullptr
- || record.pool == pool) {
- return &record;
- }
- }
- // Past capacity nothing is tracked, so nothing is refilled either. A missed refill costs this
- // world's entities; an untracked one corrupts a live pool.
- return nullptr;
- }
- /**
- * Records that one index is now owned by an entity.
- * @param record Manager record, or null when the manager is untracked.
- * @param index Index the allocator produced.
- */
- void mark_live(PoolRecord* record, std::int32_t index) noexcept {
- if (record == nullptr || index < 0 || static_cast<std::size_t>(index) >= kFreeBitmapBits) {
- return;
- }
- const auto slot = static_cast<std::size_t>(index);
- (void)InterlockedOr(&record->live[slot / kBitsPerWord],
- static_cast<LONG>(1u << (slot % kBitsPerWord)));
- }
- /** Off leaves the probe reporting only, which is what it did before it could write. */
- bool g_stockUnstockedPool{};
- /** Refill a drained pool as well as an unstocked one. Safe now that the refill spares live slots. */
- bool g_restockAlways{};
- /**
- * Reports one probe outcome, up to the per-run budget.
- * @param stage Which half answered.
- * @param outcome What it answered.
- * @param detail Free slots left in the pool, or -1 when the bitmap could not be read.
- */
- void report_pair(const char* stage,
- const char* outcome,
- std::int64_t detail,
- std::int64_t allocations) noexcept {
- if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
- || InterlockedIncrement(&g_reports) > kMaxReports) {
- return;
- }
- std::array<char, core::log::kLineCapacity> line{};
- const int written = std::snprintf(line.data(),
- line.size(),
- "ev=entity_create stage=%s result=%s free=%lld allocs=%lld",
- stage,
- outcome,
- static_cast<long long>(detail),
- static_cast<long long>(allocations));
- if (written > 0) {
- core::log::write(core::log::Channel::client,
- core::log::Level::debug,
- {line.data(), static_cast<std::size_t>(written)});
- }
- }
- /**
- * Reports one refill, naming how many slots it actually handed back.
- * @param outcome Whether the pool answered after the refill.
- * @param free Free slots the bitmap holds now.
- * @param freed Slots this refill put back.
- * @param allocations Successful allocations seen so far.
- */
- void report_stock(const char* outcome,
- std::int64_t free,
- std::int64_t freed,
- std::int64_t allocations) noexcept {
- if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
- || InterlockedIncrement(&g_reports) > kMaxReports) {
- return;
- }
- std::array<char, core::log::kLineCapacity> line{};
- const int written =
- std::snprintf(line.data(),
- line.size(),
- "ev=entity_create stage=allocate result=%s free=%lld freed=%lld allocs=%lld",
- outcome,
- static_cast<long long>(free),
- static_cast<long long>(freed),
- static_cast<long long>(allocations));
- if (written > 0) {
- core::log::write(core::log::Channel::client,
- core::log::Level::debug,
- {line.data(), static_cast<std::size_t>(written)});
- }
- }
- /**
- * Names one allocation and the call sites that asked for it.
- *
- * Counting allocations proved the pool works; it cannot say what is being built, and that is the
- * question a missing Wall of Wishes actually poses. Its panels are one repeated object, so a burst
- * of identical traces landing on consecutive indices as a bubble loads is the wall being created,
- * and the absence of such a burst is the wall never being asked for. Those two need opposite fixes.
- *
- * Addresses are image-relative because the game is rebased every run; an RVA maps straight into the
- * mapped-image dump, where file offset equals RVA.
- * @param pool Manager the index came from, so per-type managers would show as distinct pointers.
- * @param index Index the allocator produced.
- * @param sequence Allocation ordinal within the run.
- */
- void report_allocation(const void* pool, std::int32_t index, LONG sequence) noexcept {
- if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
- || InterlockedIncrement(&g_traces) > kMaxTraces) {
- return;
- }
- const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
- std::array<char, core::log::kLineCapacity> line{};
- int written = std::snprintf(line.data(),
- line.size(),
- "ev=entity_create stage=alloc n=%ld idx=%d pool=0x%llX sites=",
- static_cast<long>(sequence),
- static_cast<int>(index),
- static_cast<unsigned long long>(reinterpret_cast<std::uintptr_t>(pool)));
- if (written <= 0) {
- return;
- }
- std::array<void*, kTraceFrames> frames{};
- // Frame 0 is this probe, which is never interesting, so the capture starts one above it.
- const USHORT captured = g_captureBacktrace == nullptr
- ? 0
- : g_captureBacktrace(1, kTraceFrames, frames.data(), nullptr);
- for (USHORT frame = 0; frame < captured && written > 0
- && static_cast<std::size_t>(written) < line.size();
- ++frame) {
- const auto site = reinterpret_cast<std::uintptr_t>(frames[frame]);
- // A frame inside Sunrise's own module is noise here; only the game's code is addressable
- // in the dump, so anything outside it is printed as a gap rather than a misleading offset.
- const bool inImage = base != 0 && site >= base && (site - base) < kImageSpan;
- const int more =
- std::snprintf(line.data() + written,
- line.size() - static_cast<std::size_t>(written),
- inImage ? "%s0x%llX" : "%s-",
- frame == 0 ? "" : ",",
- static_cast<unsigned long long>(inImage ? site - base : 0));
- if (more <= 0) {
- break;
- }
- written += more;
- }
- const auto length = static_cast<std::size_t>(written) < line.size()
- ? static_cast<std::size_t>(written)
- : line.size() - 1;
- core::log::write(core::log::Channel::client, core::log::Level::debug, {line.data(), length});
- }
- void report(const char* stage, const char* outcome, std::int64_t detail) noexcept {
- if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
- || InterlockedIncrement(&g_reports) > kMaxReports) {
- return;
- }
- std::array<char, core::log::kLineCapacity> line{};
- const int written = std::snprintf(line.data(),
- line.size(),
- "ev=entity_create stage=%s result=%s free=%lld",
- stage,
- outcome,
- static_cast<long long>(detail));
- if (written > 0) {
- core::log::write(core::log::Channel::client,
- core::log::Level::debug,
- {line.data(), static_cast<std::size_t>(written)});
- }
- }
- /**
- * Mirrors the index allocator and reports whether it produced an index.
- * The out-parameter is the answer: the original writes -1 into it before doing anything, and
- * overwrites it only on success.
- */
- /**
- * Reads the game's entity record table, or reports that it cannot be addressed.
- * @param table Receives the table base.
- * @param stride Receives the record stride.
- * @return True when both were read and the stride still matches this build.
- */
- [[nodiscard]] bool entity_table(const std::byte*& table, std::uint32_t& stride) noexcept {
- const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
- if (base == 0) {
- return false;
- }
- __try {
- table = *reinterpret_cast<const std::byte* const*>(base + kEntityTableBaseRva);
- stride = *reinterpret_cast<const std::uint32_t*>(base + kEntityTableStrideRva);
- } __except (EXCEPTION_EXECUTE_HANDLER) {
- return false;
- }
- return table != nullptr && stride == kExpectedRecordStride;
- }
- /**
- * Reports which of one word's 32 indices already hold an entity.
- *
- * The probe's own record of handed-out indices covers only what came through the hooked allocator,
- * and a census measured that as 58 of 830 — the world's placed objects reach the table by some
- * other path entirely. Trusting that record alone therefore freed 7936 slots while 42 entities
- * were sitting in them, and the client then allocated straight over the top. The game's own record
- * table is the authority on which slots are taken, so occupancy is read from there instead.
- * @param table Entity record table base.
- * @param stride Record stride.
- * @param word Word of the free bitmap being refilled.
- * @return Set bit per index in that word whose record is live.
- */
- [[nodiscard]] LONG occupied_mask(const std::byte* table, std::uint32_t stride, std::size_t word) noexcept {
- std::uint32_t mask = 0;
- for (std::size_t bit = 0; bit < kBitsPerWord; ++bit) {
- const std::size_t index = word * kBitsPerWord + bit;
- __try {
- if (*reinterpret_cast<const std::uint32_t*>(table + index * stride
- + kRecordClassOffset)
- == kRecordClass) {
- mask |= 1u << bit;
- }
- } __except (EXCEPTION_EXECUTE_HANDLER) {
- // An unreadable record is treated as taken, which costs a slot rather than an entity.
- mask |= 1u << bit;
- }
- }
- return static_cast<LONG>(mask);
- }
- /**
- * Frees every client slot that no entity holds, leaving the ones that do alone.
- *
- * The client's own initialiser at `0x7FF71DDADB20` fills this bitmap with `0xFF` — every slot free
- * — but only when a role global reads zero; here it reads 3, so the fill never runs and the bitmap
- * is all-zero from the first frame. Every entity creation then fails, which is why no enemy, plate,
- * door or banner ever appeared and why an encounter bubble kicked to orbit. Writing those bytes
- * ourselves is right exactly once, on a pool that is still empty. On a pool that has drained it is
- * catastrophic, because the slots the client is using read as clear too and become free again.
- *
- * So the refill is driven by `record->live` instead of by a constant. A slot is freed only when the
- * bitmap says it is taken AND this probe never watched the allocator hand it out. Two passes,
- * because another thread may claim a slot while the first one runs: the second re-clears anything
- * that became live in between, so no index is ever offered twice.
- * @param pool Manager the allocator was handed.
- * @param record Occupancy record for that manager.
- * @param failure Receives a Windows error, or 1 for a null pool and 2 for a faulting write.
- * @return Slots freed, or -1 when the bitmap could not be written.
- */
- [[nodiscard]] std::int64_t stock_pool(void* pool,
- PoolRecord* record,
- std::uint32_t& failure) noexcept {
- failure = 0;
- if (pool == nullptr || record == nullptr) {
- failure = 1;
- return -1;
- }
- auto* const bitmap = static_cast<std::byte*>(pool) + kFreeBitmapOffset;
- // The bitmap sits in the game's own allocation, so it carries whatever protection that
- // allocation was given. Reading it worked, which does not prove it is writable.
- DWORD previous = 0;
- if (VirtualProtect(bitmap, kFreeBitmapBytes, PAGE_READWRITE, &previous) == FALSE) {
- failure = GetLastError();
- return -1;
- }
- const std::byte* table = nullptr;
- std::uint32_t stride = 0;
- const bool hasTable = entity_table(table, stride);
- std::int64_t freed = 0;
- __try {
- auto* const words = reinterpret_cast<volatile LONG*>(bitmap);
- for (std::size_t word = 0; word < kClientWords; ++word) {
- const LONG available = words[word];
- // A slot the client has put back is no longer live, so it returns to the pool with the
- // rest. Without this the record would only ever grow and the refill would fade to a
- // no-op over a long session.
- const LONG live = InterlockedAnd(&record->live[word], ~available) & ~available;
- // The record table is the authority; the probe's own list is kept as a second opinion
- // for anything created in the window before its record is filled in.
- const LONG occupied = hasTable ? occupied_mask(table, stride, word) : 0;
- const LONG missing =
- static_cast<LONG>(~static_cast<std::uint32_t>(live | available | occupied));
- if (missing != 0) {
- (void)InterlockedOr(&words[word], missing);
- freed += __popcnt(static_cast<unsigned int>(missing));
- }
- }
- for (std::size_t word = 0; word < kClientWords; ++word) {
- const LONG live = record->live[word];
- if (live != 0) {
- (void)InterlockedAnd(&words[word], ~live);
- }
- }
- // The host's reserve at the top of the space stays clear so the client cannot allocate an
- // index the host also considers its own.
- for (std::size_t word = kClientWords; word < kFreeBitmapWords; ++word) {
- (void)InterlockedAnd(&words[word], 0);
- }
- } __except (EXCEPTION_EXECUTE_HANDLER) {
- failure = 2;
- freed = -1;
- }
- DWORD restored = 0;
- (void)VirtualProtect(bitmap, kFreeBitmapBytes, previous, &restored);
- if (freed >= 0) {
- (void)InterlockedExchange(&record->stocked, 1);
- if (!hasTable) {
- // Worth saying out loud: without the table the refill is back to trusting a list that
- // has been measured as 7% complete, which is how live entities got overwritten.
- report("allocate", "stock_without_table", freed);
- }
- }
- return freed;
- }
- void* __fastcall allocator_body(void* pool, std::int32_t* index) noexcept {
- const auto call = reinterpret_cast<IndexAllocator>(g_allocator.original);
- if (call == nullptr) {
- return nullptr;
- }
- void* result = call(pool, index);
- InterlockedExchangePointer(&g_lastPool, pool);
- PoolRecord* const record = find_pool(pool);
- if (index == nullptr || *index != kNoIndex) {
- // Every index the client takes is recorded before anything else can act on it, because a
- // refill that does not know about it would offer the same index to a second entity.
- if (index != nullptr) {
- mark_live(record, *index);
- }
- // Sample the pool as it is spent. A steadily falling count means indices are allocated and
- // never returned; a count that rises again means the client's own free path does work and
- // the drain is simply the world being large. Those need opposite fixes, and the exhaustion
- // line alone cannot tell them apart because it only ever fires at zero.
- const LONG seen = InterlockedIncrement(&g_allocations);
- report_allocation(pool, index == nullptr ? kNoIndex : *index, seen);
- // One allocation behind, so the creator has had time to fill the record being read.
- report_record(InterlockedExchange(&g_pendingIndex, index == nullptr ? -1 : *index));
- if ((seen % kSampleInterval) == 0) {
- // The count is reported beside the free total: if the pool empties while this barely
- // moves, the bitmap is being cleared by something other than allocation.
- report_pair("allocate", "sample", free_slot_count(pool), seen);
- }
- return result;
- }
- const std::int64_t free = free_slot_count(pool);
- // A pool is refilled the first time it is seen empty, and again on every later drain when the
- // knob is on. Both are safe now: the refill spares the indices already handed out, so it can
- // no longer hand one index to two entities the way the old blanket fill did.
- const bool allowed = g_stockUnstockedPool && record != nullptr
- && (g_restockAlways || record->stocked == 0);
- if (free != 0 || !allowed) {
- report_pair("allocate", "exhausted", free, g_allocations);
- return result;
- }
- std::uint32_t failure = 0;
- const std::int64_t freed = stock_pool(pool, record, failure);
- if (freed < 0) {
- // Naming the reason matters: a refused write and a faulting page need different fixes.
- report("allocate", "stock_failed", static_cast<std::int64_t>(failure));
- return result;
- }
- result = call(pool, index);
- // `freed` is the number that matters. It should fall well short of the whole client half: the
- // gap is the live entities the old fill used to trample.
- report_stock(*index == kNoIndex ? "stocked_still_empty" : "stocked",
- free_slot_count(pool),
- freed,
- g_allocations);
- if (index != nullptr) {
- mark_live(record, *index);
- }
- return result;
- }
- /**
- * Image offset of the pointer that reaches the game's entity pool descriptors.
- * From the creation path at `0x4D71B5`: `mov rcx, [rip -> 0x2439C70]` then `add rdx, [rcx]` with
- * the pool ordinal already shifted left by six, so descriptors are 64 bytes apart and their array
- * base is one further dereference in. Within a descriptor, `+0x08` is the pool base and `+0x30`
- * its element size -- `imul eax, [rdx + 0x30]` then `add rcx, [rdx + 8]`.
- */
- constexpr std::uintptr_t kPoolDirectoryRva = 0x2439C70;
- /** Bytes between pool descriptors. */
- constexpr std::size_t kPoolDescriptorStride = 64;
- /** Descriptors probed. The ordinal comes from a handle's high bits, which are six wide. */
- constexpr std::size_t kPoolDescriptorCount = 64;
- /** Offset of a pool's base pointer within its descriptor. */
- constexpr std::size_t kPoolBaseOffset = 0x08;
- /** Offset of a pool's element size within its descriptor. */
- constexpr std::size_t kPoolElementSizeOffset = 0x30;
- /** An element size outside this is not a record, so the descriptor is not one either. */
- constexpr std::uint32_t kMaximumElementSize = 4096;
- /**
- * Pools whose elements match the entity record stride, walked by the census.
- * The directory holds TWO 224-byte pools, ordinals 33 and 35, at stable and distinct bases. The
- * census has only ever read whichever one `kEntityTableBaseRva` points at, so half the records of
- * this shape were never looked at -- and the activation plate that works, entity 3539, is not in
- * the half that was.
- */
- constexpr std::size_t kRecordPoolCapacity = 4;
- /** Bases of the record-shaped pools found in the directory. */
- std::array<const std::byte*, kRecordPoolCapacity> g_recordPools{};
- /** Ordinals of those pools, in the same order. */
- std::array<std::size_t, kRecordPoolCapacity> g_recordPoolOrdinals{};
- /** Record-shaped pools found. */
- std::size_t g_recordPoolCount{};
- /**
- * Reports every entity pool the game keeps, not just the one the census walks.
- *
- * The class tally proved the 224-byte table holds exactly one class and 830 records, and that
- * everything read above them is out-of-bounds noise. So the Wall of Wishes activation plate, which
- * an interaction incident named as entity 3539 and which visibly works, cannot be in that table at
- * all -- while the twenty panels that do not work are. Handles carry a pool ordinal in their high
- * bits, which is why one table was never the whole picture.
- */
- void report_pools() noexcept {
- if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)) {
- return;
- }
- const auto image = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
- if (image == 0) {
- return;
- }
- for (std::size_t ordinal = 0; ordinal < kPoolDescriptorCount; ++ordinal) {
- const std::byte* poolBase = nullptr;
- std::uint32_t elementSize = 0;
- __try {
- const auto* const directory =
- *reinterpret_cast<const std::byte* const*>(image + kPoolDirectoryRva);
- if (directory == nullptr) {
- return;
- }
- const auto* const descriptors = *reinterpret_cast<const std::byte* const*>(directory);
- if (descriptors == nullptr) {
- return;
- }
- const auto* const descriptor = descriptors + ordinal * kPoolDescriptorStride;
- poolBase = *reinterpret_cast<const std::byte* const*>(descriptor + kPoolBaseOffset);
- elementSize =
- *reinterpret_cast<const std::uint32_t*>(descriptor + kPoolElementSizeOffset);
- } __except (EXCEPTION_EXECUTE_HANDLER) {
- continue;
- }
- if (poolBase == nullptr || elementSize == 0 || elementSize > kMaximumElementSize) {
- continue;
- }
- if (elementSize == kExpectedRecordStride && g_recordPoolCount < kRecordPoolCapacity) {
- g_recordPoolOrdinals[g_recordPoolCount] = ordinal;
- g_recordPools[g_recordPoolCount++] = poolBase;
- }
- std::array<char, core::log::kLineCapacity> line{};
- const int written =
- std::snprintf(line.data(),
- line.size(),
- "ev=entity_census stage=pool ordinal=%zu base=0x%llX element=%u",
- ordinal,
- static_cast<unsigned long long>(
- reinterpret_cast<std::uintptr_t>(poolBase)),
- elementSize);
- if (written > 0) {
- core::log::write(core::log::Channel::client,
- core::log::Level::debug,
- {line.data(), static_cast<std::size_t>(written)});
- }
- }
- }
- /**
- * Reports the built records of one record-shaped pool other than the cached one.
- *
- * The cached pointer at `kEntityTableBaseRva` names a single pool, and the directory shows two of
- * this shape. An object that works and an object that does not may simply live in different pools,
- * and that is not visible while only one is read.
- * @param poolBase Base of the pool to walk.
- * @param stride Record stride, the same for every pool of this shape.
- * @param ordinal Directory ordinal, reported so the two can be told apart.
- */
- void walk_pool(const std::byte* poolBase, std::uint32_t stride, std::size_t ordinal) noexcept {
- LONG reported = 0;
- for (std::size_t index = 0; index < kFreeBitmapBits && reported < kCensusEntryBudget; ++index) {
- std::array<char, core::log::kLineCapacity> line{};
- int written = 0;
- __try {
- const auto* const record = poolBase + index * stride;
- const auto recordClass =
- *reinterpret_cast<const std::uint32_t*>(record + kRecordClassOffset);
- if (recordClass != kRecordClass) {
- continue;
- }
- written = std::snprintf(
- line.data(),
- line.size(),
- "ev=entity_census stage=entry pool=%zu idx=%zu cls=0x%08X def=0x%08X ord=%u rec=",
- ordinal,
- index,
- recordClass,
- *reinterpret_cast<const std::uint32_t*>(record + kRecordDefinitionOffset),
- *reinterpret_cast<const std::uint32_t*>(record + kRecordOrdinalOffset));
- for (std::size_t offset = 0; offset < kRecordDumpBytes && written > 0
- && static_cast<std::size_t>(written) + 3 < line.size();
- ++offset) {
- const int more = std::snprintf(line.data() + written,
- line.size() - static_cast<std::size_t>(written),
- "%02X",
- std::to_integer<unsigned char>(record[offset]));
- if (more <= 0) {
- break;
- }
- written += more;
- }
- } __except (EXCEPTION_EXECUTE_HANDLER) {
- continue;
- }
- if (written <= 0) {
- continue;
- }
- ++reported;
- core::log::write(core::log::Channel::client,
- core::log::Level::debug,
- {line.data(), static_cast<std::size_t>(written)});
- }
- std::array<char, core::log::kLineCapacity> tail{};
- const int written = std::snprintf(tail.data(),
- tail.size(),
- "ev=entity_census stage=pool_end ordinal=%zu records=%ld",
- ordinal,
- static_cast<long>(reported));
- if (written > 0) {
- core::log::write(core::log::Channel::client,
- core::log::Level::debug,
- {tail.data(), static_cast<std::size_t>(written)});
- }
- }
- /**
- * Walks the whole entity table and reports every slot the game has built.
- *
- * The per-allocation dump reads a record one allocation after it is handed out, which is early
- * enough that the transform is still its default — every instance of one definition reported the
- * same placement, which cannot be true. A census taken well after a bubble has settled reads the
- * finished records instead, and placement is the field that matters here: a grid of identical
- * co-planar objects is a wall of shootable panels and nothing else is, so this can identify the
- * Wall of Wishes without knowing the game's own name for it.
- */
- void run_census() noexcept {
- if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)) {
- return;
- }
- const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
- if (base == 0) {
- return;
- }
- const std::byte* table = nullptr;
- std::uint32_t stride = 0;
- __try {
- table = *reinterpret_cast<const std::byte* const*>(base + kEntityTableBaseRva);
- stride = *reinterpret_cast<const std::uint32_t*>(base + kEntityTableStrideRva);
- } __except (EXCEPTION_EXECUTE_HANDLER) {
- return;
- }
- if (table == nullptr || stride != kExpectedRecordStride) {
- return;
- }
- // A record keeps its class marker after the entity is gone, so the marker alone cannot
- // distinguish a live entity from a slot one left behind. The free bitmap can: a slot the
- // allocator would hand out is not holding anything, whatever its record still says.
- const auto* freeWords = static_cast<const std::uint32_t*>(nullptr);
- if (void* const pool = g_lastPool; pool != nullptr) {
- freeWords = reinterpret_cast<const std::uint32_t*>(static_cast<std::byte*>(pool)
- + kFreeBitmapOffset);
- }
- // First pass counts every class present. A record whose class word is zero or all ones has
- // never been built, so those are the only two values treated as empty.
- std::array<std::uint32_t, kClassCapacity> classes{};
- std::array<LONG, kClassCapacity> classCounts{};
- std::array<LONG, kClassCapacity> classDumped{};
- std::size_t classCount = 0;
- for (std::size_t index = 0; index < kFreeBitmapBits; ++index) {
- std::uint32_t value = 0;
- __try {
- value = *reinterpret_cast<const std::uint32_t*>(table + index * stride
- + kRecordClassOffset);
- } __except (EXCEPTION_EXECUTE_HANDLER) {
- continue;
- }
- if (value == 0 || value == 0xFFFFFFFFU) {
- continue;
- }
- std::size_t slot = 0;
- while (slot < classCount && classes[slot] != value) {
- ++slot;
- }
- if (slot == classCount) {
- if (classCount == kClassCapacity) {
- continue;
- }
- classes[classCount++] = value;
- }
- ++classCounts[slot];
- }
- for (std::size_t slot = 0; slot < classCount; ++slot) {
- std::array<char, core::log::kLineCapacity> head{};
- const int headWritten = std::snprintf(head.data(),
- head.size(),
- "ev=entity_census stage=class value=0x%08X count=%ld",
- classes[slot],
- static_cast<long>(classCounts[slot]));
- if (headWritten > 0) {
- core::log::write(core::log::Channel::client,
- core::log::Level::debug,
- {head.data(), static_cast<std::size_t>(headWritten)});
- }
- }
- g_recordPoolCount = 0;
- report_pools();
- // Name the pool the census has been reading all along, so its ordinal can be matched against
- // the directory rather than assumed.
- {
- std::array<char, core::log::kLineCapacity> line{};
- const int written = std::snprintf(
- line.data(),
- line.size(),
- "ev=entity_census stage=table base=0x%llX stride=%u pools=%zu",
- static_cast<unsigned long long>(reinterpret_cast<std::uintptr_t>(table)),
- stride,
- g_recordPoolCount);
- if (written > 0) {
- core::log::write(core::log::Channel::client,
- core::log::Level::debug,
- {line.data(), static_cast<std::size_t>(written)});
- }
- }
- // Every record-shaped pool, not just the cached one. A pool the cached pointer already names
- // is not walked twice.
- for (std::size_t slot = 0; slot < g_recordPoolCount; ++slot) {
- if (g_recordPools[slot] == table) {
- continue;
- }
- walk_pool(g_recordPools[slot], stride, g_recordPoolOrdinals[slot]);
- }
- LONG live = 0;
- LONG stale = 0;
- // Per pass, not per run: a shared budget truncated the one census that mattered.
- LONG entries = 0;
- for (std::size_t index = 0; index < kFreeBitmapBits; ++index) {
- std::array<char, core::log::kLineCapacity> line{};
- int written = 0;
- __try {
- const auto* const record = table + index * stride;
- const auto recordClass =
- *reinterpret_cast<const std::uint32_t*>(record + kRecordClassOffset);
- if (recordClass == 0 || recordClass == 0xFFFFFFFFU) {
- continue;
- }
- std::size_t slot = 0;
- while (slot < classCount && classes[slot] != recordClass) {
- ++slot;
- }
- const bool spent = slot == classCount || classDumped[slot] >= kPerClassDump;
- if (!spent) {
- ++classDumped[slot];
- }
- unsigned slotFree = 0;
- if (freeWords != nullptr
- && (freeWords[index / kBitsPerWord] & (1u << (index % kBitsPerWord))) != 0) {
- slotFree = 1;
- ++stale;
- } else {
- ++live;
- }
- // The tally above counts every record; only the dump is rationed.
- if (spent || entries >= kCensusEntryBudget) {
- continue;
- }
- written = std::snprintf(
- line.data(),
- line.size(),
- "ev=entity_census stage=entry idx=%zu cls=0x%08X def=0x%08X ord=%u free=%u rec=",
- index,
- recordClass,
- *reinterpret_cast<const std::uint32_t*>(record + kRecordDefinitionOffset),
- *reinterpret_cast<const std::uint32_t*>(record + kRecordOrdinalOffset),
- slotFree);
- // The whole record, not just the transform block. The block at `+0xA0` decodes as a
- // clean quaternion but the four dwords after it are not the position — as floats they
- // are denormals and values in the trillions. Somewhere in these 224 bytes there are
- // three coordinates, and the way to find them is to scan every aligned offset across a
- // group for one that varies plausibly. A 5x5 grid of co-planar panels is the Wall of
- // Wishes and nothing else in the room is shaped like that, so placement identifies it
- // where counting has not.
- for (std::size_t offset = 0; offset < kRecordDumpBytes && written > 0
- && static_cast<std::size_t>(written) + 3 < line.size();
- ++offset) {
- const int more = std::snprintf(line.data() + written,
- line.size() - static_cast<std::size_t>(written),
- "%02X",
- std::to_integer<unsigned char>(record[offset]));
- if (more <= 0) {
- break;
- }
- written += more;
- }
- } __except (EXCEPTION_EXECUTE_HANDLER) {
- continue;
- }
- if (written <= 0) {
- continue;
- }
- ++entries;
- core::log::write(core::log::Channel::client,
- core::log::Level::debug,
- {line.data(), static_cast<std::size_t>(written)});
- }
- std::array<char, core::log::kLineCapacity> tail{};
- const int written = std::snprintf(tail.data(),
- tail.size(),
- "ev=entity_census stage=end live=%ld stale=%ld allocs=%ld",
- static_cast<long>(live),
- static_cast<long>(stale),
- static_cast<long>(g_allocations));
- if (written > 0) {
- core::log::write(core::log::Channel::client,
- core::log::Level::debug,
- {tail.data(), static_cast<std::size_t>(written)});
- }
- }
- /**
- * Runs a census on its own thread so it does not sit inside the game's allocation path.
- * @param unused Thread parameter, unused.
- * @return Always zero.
- */
- DWORD WINAPI census_thread(LPVOID unused) noexcept {
- (void)unused;
- while (g_censusRunning != 0) {
- Sleep(kCensusIntervalMs);
- if (g_censusRunning == 0) {
- break;
- }
- run_census();
- }
- return 0;
- }
- /**
- * Attaches one probe, reporting its own outcome.
- * @param signature Pattern to find.
- * @param name Reported name.
- * @param replacement Probe body.
- * @param handle Receives the trampoline.
- * @return True when the target was found and the detour attached.
- */
- [[nodiscard]] bool attach(std::span<const patterns::PatternByte> signature,
- const char* name,
- void* replacement,
- detour::Handle& handle) noexcept {
- std::byte* const target = patterns::scan_main_image_unique(signature, name);
- std::array<char, core::log::kLineCapacity> line{};
- if (target == nullptr) {
- const int written = std::snprintf(line.data(),
- line.size(),
- "ev=entity_create stage=attach name=%s result=fail",
- name);
- if (written > 0) {
- core::log::write(core::log::Channel::client,
- core::log::Level::warn,
- {line.data(), static_cast<std::size_t>(written)});
- }
- return false;
- }
- const detour::Spec spec{target, replacement};
- const bool attached = detour::install(spec, handle);
- const int written = std::snprintf(line.data(),
- line.size(),
- "ev=entity_create stage=attach name=%s result=%s",
- name,
- attached ? "ok" : "fail");
- if (written > 0) {
- core::log::write(core::log::Channel::client,
- attached ? core::log::Level::info : core::log::Level::warn,
- {line.data(), static_cast<std::size_t>(written)});
- }
- return attached;
- }
- } // namespace
- /** Reports which half of the client's entity creation refuses. */
- bool install_entity_create_probe(bool stockUnstockedPool, bool restockAlways) noexcept {
- g_stockUnstockedPool = stockUnstockedPool;
- g_restockAlways = restockAlways;
- // Resolved rather than linked: the trace is a diagnostic, and a missing export should cost the
- // call sites in the log, not the probe that stocks the pool.
- if (HMODULE const ntdll = GetModuleHandleW(L"ntdll.dll"); ntdll != nullptr) {
- g_captureBacktrace = reinterpret_cast<decltype(g_captureBacktrace)>(
- reinterpret_cast<void*>(GetProcAddress(ntdll, "RtlCaptureStackBackTrace")));
- }
- const bool allocator = attach(kIndexAllocator,
- "entity_index_allocator",
- reinterpret_cast<void*>(&allocator_body),
- g_allocator);
- if (allocator) {
- InterlockedExchange(&g_censusRunning, 1);
- g_censusThread = CreateThread(nullptr, 0, &census_thread, nullptr, 0, nullptr);
- }
- // The initialiser is deliberately NOT hooked. Its fifth argument is passed on the stack
- // (`mov dword [var_20h], eax` before the call), and a four-argument replacement got that
- // wrong and black-screened the load. It does not need hooking anyway: the allocator alone
- // answers the question, because the initialiser only runs when the allocator succeeded.
- return allocator;
- }
- /** Detaches the entity-creation probes. */
- void uninstall_entity_create_probe() noexcept {
- InterlockedExchange(&g_censusRunning, 0);
- if (g_censusThread != nullptr) {
- // The census only reads, so a shutdown that beats it costs a census, never the process.
- (void)CloseHandle(g_censusThread);
- g_censusThread = nullptr;
- }
- if (g_allocator.attached) {
- (void)detour::uninstall(g_allocator);
- }
- }
- } // namespace sunrise::client::diagnostics
|